refactor: rombak arsitektur file ke modular clean-arch (vertikal per-feature)

Organisasi ulang layer-first (domain/application/infrastructure) menjadi
vertikal per-feature, behavior tidak berubah:

- src/shared/domain/  -> fondasi bersama (ex-domain/core): message, provider,
  tool_call, tool_result, store, error, usage, conversation
- src/features/agent/ -> LLM turn loop: domain (TurnEvent, AgentTurnParams) +
  application (turn_service, ports) + infrastructure (llm LlmClient + tools)
- src/features/cms/   -> settings/conversation/memory persistence: domain +
  application (services) + infrastructure (persistence repos)
- src/features/subagent/ -> orchestration: domain (AccessTier) + infrastructure
  (engine runner, context, provider, delegate, spawn_tools)
- src/features/workflow/ -> multi-phase + hive-mind: domain + infrastructure
- src/interfaces/     -> cli + tui (outer ring) tetap

Subagent-runner (engine.ts runAgent, division.ts toolsFor) dipindah ke
features/subagent (bukan agent tools) utk memutus coupling agent<->subagent.

Alias tsconfig: @zesdex/domain -> shared/domain, + @zesdex/agent,
@zesdex/agent-infra, @zesdex/cms, @zesdex/subagent, @zesdex/workflow, @zesdex/shared.
Bersihkan dead: application/ports PasswordService/TokenService/AuthService.
compose.ts (composition root) & bootstrap repoint ke barrel feature.

Gates: tsc --noEmit 0 error, bun test 70 pass/0 fail, bun build 147 modules,
./dist/zesdex --headless -> real turn OK.
This commit is contained in:
asepharyana
2026-09-03 02:30:12 +07:00
parent 7e6ed34f0d
commit b5ebd07fa8
126 changed files with 157 additions and 166 deletions
@@ -0,0 +1,33 @@
/**
* Conversation use-case. Mirrors `apps/application/src/cms/conversation_service.rs`.
*/
import * as path from "node:path";
import { type Conversation, type ConversationRepository, type ChatMessage, pushMessage } from "@zesdex/cms";
/** Service implementation for conversation CRUD operations. */
export class ConversationServiceImpl {
constructor(
private repo: ConversationRepository,
private sessionsDir: string,
) {}
private sessionDir(sessionId: string): string {
return path.join(this.sessionsDir, sessionId);
}
async loadConversation(sessionId: string): Promise<Conversation> {
const dir = this.sessionDir(sessionId);
return this.repo.load(dir);
}
async saveConversation(conv: Conversation): Promise<void> {
const dir = this.sessionDir(conv.session_id);
await this.repo.save(dir, conv);
}
async addMessage(conv: Conversation, msg: ChatMessage): Promise<void> {
pushMessage(conv, msg);
const dir = this.sessionDir(conv.session_id);
await this.repo.save(dir, conv);
}
}
+4
View File
@@ -0,0 +1,4 @@
/** CMS application module — conversation, memory, and settings use-cases. */
export * from "./conversation_service.ts";
export * from "./memory_service.ts";
export * from "./settings_service.ts";
@@ -0,0 +1,24 @@
/**
* Memory use-case. Mirrors `apps/application/src/cms/memory_service.rs`.
*/
import { type Memory, type MemoryRepository } from "@zesdex/cms";
/** Service implementation for memory CRUD operations. */
export class MemoryServiceImpl {
constructor(
private repo: MemoryRepository,
private memoryDir: string,
) {}
async listMemories(): Promise<string[]> {
return this.repo.list(this.memoryDir);
}
async saveMemory(memory: Memory): Promise<void> {
await this.repo.save(this.memoryDir, memory);
}
async deleteMemory(name: string): Promise<void> {
await this.repo.delete(this.memoryDir, name);
}
}
@@ -0,0 +1,33 @@
/**
* Settings + app-config use-case. Mirrors `apps/application/src/cms/settings_service.rs`.
*/
import {
type AppConfig,
type AppConfigRepository,
type ProviderConfig,
type Settings,
type SettingsRepository,
} from "@zesdex/cms";
/** Service implementation for settings and app-config operations. */
export class SettingsServiceImpl {
constructor(
private settingsRepo: SettingsRepository,
private appConfigRepo: AppConfigRepository,
private baseDir: string,
) {}
async loadSettings(): Promise<Settings> {
return this.settingsRepo.load(this.baseDir);
}
async saveSettings(settings: Settings): Promise<void> {
await this.settingsRepo.save(this.baseDir, settings);
}
async updateProvider(name: string, config: ProviderConfig): Promise<void> {
const appConfig: AppConfig = await this.appConfigRepo.load(this.baseDir);
appConfig.providers[name] = config;
await this.appConfigRepo.save(this.baseDir, appConfig);
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Application configuration entities. Mirrors `app_config.rs`.
*/
import { DEFAULT_CONTEXT_WINDOW } from "@zesdex/agent";
/** Top-level application configuration. */
export interface AppConfig {
providers: Record<string, ProviderConfig>;
model_roles: Record<string, ModelRole>;
default_provider: string;
default_model: string;
default_context_window: number;
}
/** Connection details for a single LLM provider endpoint. */
export interface ProviderConfig {
api_base: string;
api_key_env?: string;
default_model?: string;
default_api_key?: string;
}
/** A named model role mapping to a provider/model with parameters. */
export interface ModelRole {
provider: string;
model: string;
max_tokens?: number;
context_window?: number;
temperature?: number;
}
/** Returns the default AppConfig with built-in "zen" and "router" providers. */
export function newAppConfig(): AppConfig {
return {
providers: {
zen: {
api_base: "https://opencode.ai/zen/v1",
api_key_env: "API_KEY",
default_model: "deepseek-v4-flash-free",
default_api_key: undefined,
},
router: {
api_base: "https://9router.asepharyana.my.id/v1",
api_key_env: "ROUTER_API_KEY",
default_model: "claude-opus-5",
default_api_key: undefined,
},
},
model_roles: {
default: {
provider: "zen",
model: "deepseek-v4-flash-free",
max_tokens: undefined,
context_window: undefined,
temperature: 0.7,
},
},
default_provider: "zen",
default_model: "deepseek-v4-flash-free",
default_context_window: DEFAULT_CONTEXT_WINDOW,
};
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Command types for CMS domain operations. Mirrors `commands.rs`.
*/
import type { InternetMode } from "./settings.ts";
const VALID_INTERNET_MODES: InternetMode[] = ["Off", "ReadOnly", "Full"];
/** Partial update command for `Settings` — only non-null fields are applied. */
export interface SettingsPatch {
internet_mode?: string;
provider?: string;
model?: string;
api_keys?: Record<string, string>;
max_tokens?: number | null;
temperature?: number | null;
review_max_lessons_per_run?: number;
adaptive_review_max_skip?: number;
verify_command?: string | null;
verify_timeout_ms?: number;
workflow_max_concurrency?: number;
review_enabled?: boolean;
session_archive_enabled?: boolean;
hive_mind_node_timeout_ms?: number;
}
/** The `apply` fields a Settings-like target must expose (subset of Settings). */
export interface SettingsPatchTarget {
internet_mode: InternetMode;
provider: string;
model: string;
api_keys: Record<string, string>;
max_tokens?: number | null;
temperature?: number | null;
review_max_lessons_per_run: number;
adaptive_review_max_skip: number;
verify_command?: string | null;
verify_timeout_ms: number;
workflow_max_concurrency: number;
review_enabled: boolean;
session_archive_enabled: boolean;
hive_mind_node_timeout_ms: number;
}
/** Merge a patch into a settings-like target; only defined fields are applied. */
export function applySettingsPatch(patch: SettingsPatch, settings: SettingsPatchTarget): string | null {
if (patch.internet_mode !== undefined) {
const mode = patch.internet_mode;
if (!VALID_INTERNET_MODES.includes(mode as InternetMode)) {
return `invalid internet_mode '${mode}'; expected Off, ReadOnly, or Full`;
}
settings.internet_mode = mode as InternetMode;
}
if (patch.provider !== undefined) settings.provider = patch.provider;
if (patch.model !== undefined) settings.model = patch.model;
if (patch.api_keys !== undefined) settings.api_keys = patch.api_keys;
if (patch.max_tokens !== undefined) settings.max_tokens = patch.max_tokens;
if (patch.temperature !== undefined) settings.temperature = patch.temperature;
if (patch.review_max_lessons_per_run !== undefined) settings.review_max_lessons_per_run = patch.review_max_lessons_per_run;
if (patch.adaptive_review_max_skip !== undefined) settings.adaptive_review_max_skip = patch.adaptive_review_max_skip;
if (patch.verify_command !== undefined) settings.verify_command = patch.verify_command;
if (patch.verify_timeout_ms !== undefined) settings.verify_timeout_ms = patch.verify_timeout_ms;
if (patch.workflow_max_concurrency !== undefined) settings.workflow_max_concurrency = patch.workflow_max_concurrency;
if (patch.review_enabled !== undefined) settings.review_enabled = patch.review_enabled;
if (patch.session_archive_enabled !== undefined) settings.session_archive_enabled = patch.session_archive_enabled;
if (patch.hive_mind_node_timeout_ms !== undefined) settings.hive_mind_node_timeout_ms = patch.hive_mind_node_timeout_ms;
return null;
}
/** Command to create a new memory entry. */
export interface NewMemory {
name: string;
description: string;
content: string;
kind?: string;
outcome?: string;
lifecycle?: string;
scope?: string;
before_snippet?: string;
after_snippet?: string;
provenances?: string[];
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Re-export of conversation types from core (matches Rust `cms/conversation.rs`).
*/
export type { Conversation } from "@zesdex/domain";
export type { ChatMessage, Role } from "@zesdex/domain";
export { newConversation, pushMessage, rebuildSystem, toApiMessages } from "@zesdex/domain";
export { Roles } from "@zesdex/domain";
+53
View File
@@ -0,0 +1,53 @@
/**
* Edit log — append-only log of file mutations. Mirrors `edit_log.rs`.
*/
/** A single recorded file edit event. */
export interface EditLogEntry {
/** Unix timestamp (ms) when the edit occurred. */
ts: number;
/** Name of the tool that performed the edit. */
tool: string;
/** Absolute file path that was modified. */
path: string;
/** Human-readable explanation of why the edit was made. */
reason: string;
/** SHA-256 hex digest of the content after the edit. */
content_sha256: string;
/** Signed byte count change (+added, -removed). */
bytes_delta: number;
/** Origin identifier (which agent / session context). */
origin: string;
/** Session in which this edit was performed. */
session_id: string;
}
/** Maximum number of edit entries held in memory at once. */
export const MAX_MEMORY_ENTRIES = 10_000;
/** In-memory view of a session's edit log. */
export class EditLog {
entries: EditLogEntry[];
constructor() {
this.entries = [];
}
/** Number of in-memory entries. */
get length(): number {
return this.entries.length;
}
/** Whether the log contains no entries. */
get isEmpty(): boolean {
return this.entries.length === 0;
}
/** Append an entry, evicting oldest beyond MAX_MEMORY_ENTRIES. */
push(entry: EditLogEntry): void {
this.entries.push(entry);
if (this.entries.length > MAX_MEMORY_ENTRIES) {
this.entries.shift();
}
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Domain error types for the CMS module. Mirrors `cms/error.rs`.
*/
import type { DomainError } from "@zesdex/domain";
/** Shared repository error type for CMS persistence. */
export type RepositoryError = DomainError;
/** Errors from service / use-case operations in the CMS domain. */
export type ServiceError =
| { kind: "repository"; error: DomainError }
| { kind: "invalid_input"; message: string }
| { kind: "other"; message: string };
export function cmsRepositoryErr(err: DomainError): ServiceError {
return { kind: "repository", error: err };
}
export function invalidInput(message: string): ServiceError {
return { kind: "invalid_input", message };
}
export function cmsOther(message: string): ServiceError {
return { kind: "other", message };
}
export function cmsServiceErrorToString(e: ServiceError): string {
switch (e.kind) {
case "repository":
return `repository error: ${e.error.message}`;
case "invalid_input":
return `invalid input: ${e.message}`;
case "other":
return e.message;
}
}
+19
View File
@@ -0,0 +1,19 @@
/** CMS domain module — settings, app config, memory, edit log, services. */
export * from "./app_config.ts";
export * from "./settings.ts";
export * from "./commands.ts";
export * from "./memory.ts";
export * from "./edit_log.ts";
export * from "./repository.ts";
export * from "./service.ts";
export {
type ServiceError as CmsServiceError,
type RepositoryError as CmsRepositoryError,
} from "./error.ts";
export {
cmsRepositoryErr,
invalidInput,
cmsOther,
cmsServiceErrorToString,
} from "./error.ts";
export * from "./conversation.ts";
+44
View File
@@ -0,0 +1,44 @@
/**
* Long-term agent memory entity. Mirrors `memory.rs`.
*/
import * as path from "node:path";
/** A single memory entry with frontmatter metadata and markdown content. */
export interface Memory {
name: string;
description: string;
content: string;
kind: string;
created_at: number;
updated_at: number;
outcome?: string;
lifecycle: string;
scope?: string;
before_snippet?: string;
after_snippet?: string;
provenances: string[];
}
/** Convert an arbitrary string into a filesystem-safe slug. */
export function slugify(s: string): string | null {
// Phase 1: replace every non-alphanumeric char with '-'
let slug = s.toLowerCase().replace(/[^a-z0-9]/g, "-");
// Phase 2: collapse consecutive '-'
slug = slug.split("-").filter((seg) => seg !== "").join("-");
if (slug === "" || slug.length > 80) return null;
return slug;
}
/**
* Compute the on-disk path for a memory of the given name.
* Falls back to `"memory.md"` when the slug is empty/invalid.
*/
export function memoryPath(memoryDir: string, name: string): string {
const slug = slugify(name) ?? "memory";
const clean = `${slug}.md`
.split("")
.map((c) => (/[a-z0-9.-]/.test(c) ? c : "-"))
.join("");
const trimmed = clean.replace(/^\.+/, "");
return path.join(memoryDir, trimmed === "" ? "memory.md" : trimmed);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Repository trait definitions (interfaces) for persistence.
* Mirrors `apps/domain/src/cms/repository.rs`. Infrastructure adapters
* implement these.
*/
import type { AppConfig } from "./app_config.ts";
import type { EditLog, EditLogEntry } from "./edit_log.ts";
import type { Memory } from "./memory.ts";
import type { Settings } from "./settings.ts";
import type { Conversation } from "@zesdex/domain";
/** Persistence contract for `Settings`. */
export interface SettingsRepository {
load(baseDir: string): Promise<Settings> | Settings;
save(baseDir: string, settings: Settings): Promise<void> | void;
}
/** Persistence contract for `AppConfig`. */
export interface AppConfigRepository {
load(baseDir: string): Promise<AppConfig> | AppConfig;
save(baseDir: string, config: AppConfig): Promise<void> | void;
}
/** Persistence contract for `Conversation`. */
export interface ConversationRepository {
load(sessionDir: string): Promise<Conversation> | Conversation;
save(sessionDir: string, conversation: Conversation): Promise<void> | void;
}
/** Persistence contract for `Memory`. */
export interface MemoryRepository {
list(memoryDir: string): Promise<string[]> | string[];
load(memoryDir: string, name: string): Promise<Memory> | Memory;
save(memoryDir: string, memory: Memory): Promise<void> | void;
delete(memoryDir: string, name: string): Promise<void> | void;
}
/** Persistence contract for rewind-snapshot binary blobs. */
export interface RewindBlobRepository {
storeBlob(sessionDir: string, blobKey: string, data: Uint8Array, mimeType?: string): Promise<void> | void;
retrieveBlob(sessionDir: string, blobKey: string): Promise<Uint8Array | null> | Uint8Array | null;
listBlobKeys(sessionDir: string): Promise<string[]> | string[];
}
/** Persistence contract for `EditLog`. */
export interface EditLogRepository {
open(sessionDir: string): Promise<EditLog> | EditLog;
append(sessionDir: string, log: EditLog, entry: EditLogEntry): Promise<void> | void;
entries(log: EditLog): EditLogEntry[];
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Service trait definitions — use-case boundaries for CMS operations.
* Mirrors `apps/domain/src/cms/service.rs`. Implemented by the application layer.
*/
import type { Conversation } from "@zesdex/domain";
import type { ChatMessage } from "@zesdex/domain";
import type { Memory } from "./memory.ts";
import type { Settings } from "./settings.ts";
import type { ProviderConfig } from "./app_config.ts";
/** Use-cases for application settings. */
export interface SettingsService {
loadSettings(): Promise<Settings> | Settings;
saveSettings(settings: Settings): Promise<void> | void;
updateProvider(name: string, config: ProviderConfig): Promise<void> | void;
}
/** Use-cases for conversation (session message) management. */
export interface ConversationService {
loadConversation(sessionId: string): Promise<Conversation> | Conversation;
saveConversation(conv: Conversation): Promise<void> | void;
addMessage(conv: Conversation, msg: ChatMessage): Promise<void> | void;
}
/** Use-cases for long-term memory management. */
export interface MemoryService {
listMemories(): Promise<string[]> | string[];
saveMemory(memory: Memory): Promise<void> | void;
deleteMemory(name: string): Promise<void> | void;
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "bun:test";
import { newSettings, resolveEffectiveModel } from "./settings.ts";
import { newAppConfig } from "./app_config.ts";
function claudeAppConfig() {
const cfg = newAppConfig();
cfg.providers.claude = {
api_base: "https://9router.example/v1",
api_key_env: "ANTHROPIC_API_KEY",
default_model: "claude-opus-5",
default_api_key: "sk-test",
};
cfg.default_provider = "claude";
cfg.default_model = "claude-opus-5";
return cfg;
}
describe("resolveEffectiveModel", () => {
it("claude provider uses opus model over stale settings model", () => {
const settings = { ...newSettings(), provider: "claude", model: "deepseek-v4-flash-free" };
expect(resolveEffectiveModel(settings, claudeAppConfig())).toBe("claude-opus-5");
});
it("non-claude provider uses settings model", () => {
const settings = { ...newSettings(), provider: "zen", model: "my-model" };
expect(resolveEffectiveModel(settings, newAppConfig())).toBe("my-model");
});
it("claude falls back to app default", () => {
const settings = { ...newSettings(), provider: "claude", model: "" };
const cfg = newAppConfig();
expect(resolveEffectiveModel(settings, cfg)).toBe(cfg.default_model);
});
});
describe("newSettings defaults", () => {
it("matches Rust defaults", () => {
const s = newSettings();
expect(s.internet_mode).toBe("Off");
expect(s.provider).toBe("zen");
expect(s.model).toBe("deepseek-v4-flash-free");
expect(s.review_max_lessons_per_run).toBe(5);
expect(s.adaptive_review_max_skip).toBe(3);
expect(s.verify_timeout_ms).toBe(30_000);
expect(s.workflow_max_concurrency).toBe(5);
expect(s.review_enabled).toBe(true);
expect(s.session_archive_enabled).toBe(true);
expect(s.hive_mind_node_timeout_ms).toBe(600_000);
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Application settings domain entity. Mirrors `settings.rs`.
*/
import type { AppConfig } from "./app_config.ts";
/** Controls how much network access the agent is permitted. */
export type InternetMode = "Off" | "ReadOnly" | "Full";
export const InternetModeLiteral = {
Off: "Off" as const,
ReadOnly: "ReadOnly" as const,
Full: "Full" as const,
} satisfies Record<string, InternetMode>;
/** Grouped boolean feature toggles. */
export interface SettingsFlags {
review_enabled: boolean;
session_archive_enabled: boolean;
}
export function newSettingsFlags(): SettingsFlags {
return { review_enabled: true, session_archive_enabled: true };
}
const DEFAULT_HIVE_MIND_NODE_TIMEOUT_MS = 600_000;
/** Top-level application settings model (serialized to `settings.json`). */
export interface Settings {
internet_mode: InternetMode;
provider: string;
model: string;
api_keys: Record<string, string>;
max_tokens?: number;
temperature?: number;
review_max_lessons_per_run: number;
adaptive_review_max_skip: number;
verify_command?: string;
verify_timeout_ms: number;
workflow_max_concurrency: number;
review_enabled: boolean;
session_archive_enabled: boolean;
hive_mind_node_timeout_ms: number;
}
export function newSettings(): Settings {
return {
internet_mode: "Off",
provider: "zen",
model: "deepseek-v4-flash-free",
api_keys: {},
max_tokens: undefined,
temperature: undefined,
review_max_lessons_per_run: 5,
adaptive_review_max_skip: 3,
verify_command: undefined,
verify_timeout_ms: 30_000,
workflow_max_concurrency: 5,
review_enabled: true,
session_archive_enabled: true,
hive_mind_node_timeout_ms: DEFAULT_HIVE_MIND_NODE_TIMEOUT_MS,
};
}
/**
* Pick the effective model name for the main agent.
*
* When `settings.provider === "claude"`, the provider's `default_model`
* (or the app-level `default_model`) wins over a possibly-stale persisted
* `settings.model`. Otherwise the user's explicit `settings.model` is used.
*/
export function resolveEffectiveModel(settings: Settings, appConfig: AppConfig): string {
if (settings.provider === "claude") {
const m = appConfig.providers["claude"]?.default_model;
if (m) return m;
return appConfig.default_model;
}
return settings.model;
}
+5
View File
@@ -0,0 +1,5 @@
/** CMS feature — configuration, memory, conversation & settings persistence.
* Vertical slice: domain (types) → application (services) → infrastructure (repos). */
export * from "./domain/index.ts";
export * from "./application/index.ts";
export * from "./infrastructure/persistence/index.ts";
@@ -0,0 +1,151 @@
/**
* JSON file–backed `AppConfigRepository` with Claude credential auto-detection.
* Mirrors `apps/infrastructure/src/persistence/cms/app_config_repo.rs`.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
type AppConfig,
type AppConfigRepository,
type ModelRole,
type ProviderConfig,
newAppConfig,
} from "@zesdex/cms";
import { writeJsonAtomic } from "@zesdex/shared";
interface ClaudeEnv {
ANTHROPIC_BASE_URL?: string;
anthropic_base_url?: string;
ANTHROPIC_API_KEY?: string;
anthropic_api_key?: string;
}
interface ClaudeSettings {
env?: ClaudeEnv;
customModel?: string;
model?: string;
}
function claudeSettingsFromFile(): ClaudeSettings | null {
const home = os.homedir();
const file = path.join(home, ".claude", "settings.json");
try {
const raw = fs.readFileSync(file, "utf8");
return JSON.parse(raw) as ClaudeSettings;
} catch {
return null;
}
}
function detectClaudeSettingsProvider(): { provider: ProviderConfig; customModel: string | null } | null {
const settings = claudeSettingsFromFile();
let fileCreds: [string, string] | null = null;
if (settings?.env) {
const baseUrl = settings.env.ANTHROPIC_BASE_URL ?? settings.env.anthropic_base_url;
const key = settings.env.ANTHROPIC_API_KEY ?? settings.env.anthropic_api_key;
if (baseUrl && key) fileCreds = [baseUrl, key];
}
const envCreds = (() => {
const baseUrl = process.env["ANTHROPIC_BASE_URL"];
const key = process.env["ANTHROPIC_API_KEY"];
if (baseUrl && key) return [baseUrl, key] as [string, string];
return null;
})();
const customModel = settings?.customModel ?? settings?.model ?? null;
const creds = fileCreds ?? envCreds;
if (!creds) return null;
const [baseUrl, key] = creds;
return {
provider: {
api_base: baseUrl,
api_key_env: "ANTHROPIC_API_KEY",
default_model: customModel ?? undefined,
default_api_key: key,
},
customModel,
};
}
/**
* Apply a detected Claude provider + custom model onto an AppConfig.
* Pure (no I/O). Always inserts "claude", registers known roles, and sets
* default_provider/model to Claude/Opus.
*/
export function applyClaudeProvider(
cfg: AppConfig,
claudeProvider: ProviderConfig,
customModel: string | null,
): void {
cfg.providers.claude = claudeProvider;
const claudeModels: [string, string][] = [
["claude-opus-5", "claude-opus-5"],
["claude-sonnet-5", "claude-sonnet-5"],
["claude-haiku-4-5", "claude-haiku-4-5-20251001"],
];
for (const [roleName, modelName] of claudeModels) {
if (!cfg.model_roles[roleName]) {
cfg.model_roles[roleName] = {
provider: "claude",
model: modelName,
max_tokens: 8192,
context_window: 200_000,
temperature: 0.7,
};
}
}
if (customModel) {
cfg.model_roles[customModel] = cfg.model_roles[customModel] ?? {
provider: "claude",
model: customModel,
max_tokens: 8192,
context_window: 200_000,
temperature: 0.7,
} satisfies ModelRole;
}
cfg.default_provider = "claude";
cfg.default_model = customModel ?? "claude-opus-5";
}
/** File-based `AppConfigRepository` that reads/writes `app_config.json`. */
export class JsonAppConfigRepository implements AppConfigRepository {
async load(baseDir: string): Promise<AppConfig> {
const file = path.join(baseDir, "app_config.json");
let cfg: AppConfig;
try {
const raw = fs.readFileSync(file, "utf8");
cfg = JSON.parse(raw) as AppConfig;
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code === "ENOENT") cfg = newAppConfig();
else throw e;
}
// Merge in default providers that are missing.
const defaults = newAppConfig();
for (const [name, provider] of Object.entries(defaults.providers)) {
if (!cfg.providers[name]) cfg.providers[name] = provider;
}
const detected = detectClaudeSettingsProvider();
if (detected) {
applyClaudeProvider(cfg, detected.provider, detected.customModel);
}
return cfg;
}
async save(baseDir: string, config: AppConfig): Promise<void> {
fs.mkdirSync(baseDir, { recursive: true });
const file = path.join(baseDir, "app_config.json");
writeJsonAtomic(file, config);
}
}
@@ -0,0 +1,28 @@
/** JSON file–backed `ConversationRepository`. Path: `<session_dir>/conversation.json`. */
import * as fs from "node:fs";
import * as path from "node:path";
import { type Conversation, type ConversationRepository, newConversation } from "@zesdex/cms";
import { writeJsonAtomic } from "@zesdex/shared";
/** Persists `Conversation` as JSON at `<session_dir>/conversation.json`. */
export class JsonConversationRepository implements ConversationRepository {
async load(sessionDir: string): Promise<Conversation> {
const file = path.join(sessionDir, "conversation.json");
try {
const raw = fs.readFileSync(file, "utf8");
return JSON.parse(raw) as Conversation;
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return newConversation("", path.basename(sessionDir));
}
throw e;
}
}
async save(sessionDir: string, conversation: Conversation): Promise<void> {
fs.mkdirSync(sessionDir, { recursive: true });
const file = path.join(sessionDir, "conversation.json");
writeJsonAtomic(file, conversation);
}
}
@@ -0,0 +1,63 @@
/** JSONL file–backed `EditLogRepository`. Stores `EditLog` as append-only NDJSON. */
import * as fs from "node:fs";
import * as path from "node:path";
import {
type EditLogEntry,
type EditLogRepository,
MAX_MEMORY_ENTRIES,
EditLog,
} from "@zesdex/cms";
/** File-based `EditLogRepository` that reads/writes `edits.jsonl`. */
export class JsonlEditLogRepository implements EditLogRepository {
private loadFromDisk(file: string): EditLogEntry[] {
try {
const raw = fs.readFileSync(file, "utf8");
const entries: EditLogEntry[] = [];
for (const line of raw.split("\n")) {
if (!line) continue;
try {
const entry = JSON.parse(line) as EditLogEntry;
if (entries.length >= MAX_MEMORY_ENTRIES) entries.shift();
entries.push(entry);
} catch {
continue;
}
}
return entries;
} catch {
return [];
}
}
async open(sessionDir: string): Promise<EditLog> {
const file = path.join(sessionDir, "edits.jsonl");
fs.mkdirSync(path.dirname(file), { recursive: true });
const entries = this.loadFromDisk(file);
if (!fs.existsSync(file)) {
fs.openSync(file, "a");
}
const log = new EditLog();
log.entries = entries;
return log;
}
async append(sessionDir: string, log: EditLog, entry: EditLogEntry): Promise<void> {
const file = path.join(sessionDir, "edits.jsonl");
const line = `${JSON.stringify(entry)}\n`;
fs.mkdirSync(path.dirname(file), { recursive: true });
const fd = fs.openSync(file, "a");
try {
fs.writeFileSync(fd, line);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
log.entries.push(entry);
if (log.entries.length > MAX_MEMORY_ENTRIES) log.entries.shift();
}
entries(log: EditLog): EditLogEntry[] {
return [...log.entries];
}
}
@@ -0,0 +1,7 @@
/** CMS persistence — file/JSON-backed repositories. */
export * from "./settings_repo.ts";
export * from "./app_config_repo.ts";
export * from "./conversation_repo.ts";
export * from "./edit_log_repo.ts";
export * from "./memory_repo.ts";
export * from "./rewind_blob_repo.ts";
@@ -0,0 +1,107 @@
/**
* Markdown file–backed `MemoryRepository`. Each memory is a `.md` file with
* YAML-ish frontmatter. Mirrors `apps/infrastructure/src/persistence/cms/memory_repo.rs`.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { type Memory, type MemoryRepository, memoryPath } from "@zesdex/cms";
import { randomUUID } from "node:crypto";
/** Escape newlines so they do not break line-oriented frontmatter. */
function escapeNewlines(s: string): string {
return s.replace(/\n/g, "\\n");
}
function unescapeNewlines(s: string): string {
return s.replace(/\\n/g, "\n");
}
function buildFrontmatter(memory: Memory): string {
const outcome = memory.outcome ? `outcome: ${escapeNewlines(memory.outcome)}\n` : "";
const scope = memory.scope ? `scope: ${escapeNewlines(memory.scope)}\n` : "";
const before = memory.before_snippet ? `before: ${escapeNewlines(memory.before_snippet)}\n` : "";
const after = memory.after_snippet ? `after: ${escapeNewlines(memory.after_snippet)}\n` : "";
const prov = memory.provenances.length > 0 ? `provenances: ${memory.provenances.join(", ")}\n` : "";
return `name: ${memory.name}\ndescription: ${memory.description}\nkind: ${memory.kind}\ncreated_at: ${memory.created_at}\nupdated_at: ${memory.updated_at}\nlifecycle: ${memory.lifecycle}\n${outcome}${scope}${before}${after}${prov}`;
}
function parseFrontmatter(front: string): Record<string, string> {
const map: Record<string, string> = {};
for (const line of front.split("\n")) {
const idx = line.indexOf(":");
if (idx === -1) continue;
map[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
return map;
}
function parseMemory(content: string): Memory {
let body = content;
if (body.startsWith("---\n")) body = body.slice(4);
const parts = body.split("\n---\n", 2);
const frontPart = parts[0];
const mdPart = parts[1];
if (frontPart === undefined || mdPart === undefined) throw new Error("missing frontmatter");
const front = parseFrontmatter(frontPart);
const md = mdPart.trim();
const prov = front.provenances ?? "";
return {
name: front.name ?? "",
description: front.description ?? "",
content: md,
kind: front.kind ?? "reference",
created_at: Number(front.created_at) || 0,
updated_at: Number(front.updated_at) || 0,
outcome: front.outcome ? unescapeNewlines(front.outcome) : undefined,
lifecycle: front.lifecycle ?? "new",
scope: front.scope ? unescapeNewlines(front.scope) : undefined,
before_snippet: front.before ? unescapeNewlines(front.before) : undefined,
after_snippet: front.after ? unescapeNewlines(front.after) : undefined,
provenances: prov === "" ? [] : prov.split(", "),
};
}
/** File-based `MemoryRepository` storing memories as `.md` files. */
export class MarkdownMemoryRepository implements MemoryRepository {
async list(memoryDir: string): Promise<string[]> {
let entries: string[];
try {
entries = fs.readdirSync(memoryDir);
} catch {
return [];
}
return entries
.filter((n) => n.endsWith(".md") && n !== "MEMORY.md")
.map((n) => n.slice(0, -3));
}
async load(memoryDir: string, name: string): Promise<Memory> {
const file = memoryPath(memoryDir, name);
const content = fs.readFileSync(file, "utf8");
try {
return parseMemory(content);
} catch (e) {
throw new Error(`failed to parse memory '${name}': ${(e as Error).message}`);
}
}
async save(memoryDir: string, memory: Memory): Promise<void> {
const file = memoryPath(memoryDir, memory.name);
fs.mkdirSync(path.dirname(file), { recursive: true });
const content = `---\n${buildFrontmatter(memory)}---\n\n${memory.content}`;
const tmp = path.join(path.dirname(file), `.${randomUUID()}.tmp`);
const fd = fs.openSync(tmp, "w");
try {
fs.writeFileSync(fd, content);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, file);
}
async delete(memoryDir: string, name: string): Promise<void> {
const file = memoryPath(memoryDir, name);
if (fs.existsSync(file)) fs.unlinkSync(file);
}
}
@@ -0,0 +1,89 @@
/**
* Filesystem-backed `RewindBlobRepository`. Blobs at
* `<session_dir>/blobs/<hex(key)>.bin` + `index.jsonl` metadata.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
import { type RewindBlobRepository } from "@zesdex/cms";
interface BlobIndexEntry {
key: string;
mime_type?: string;
created_at: number;
}
function blobsDir(sessionDir: string): string {
return path.join(sessionDir, "blobs");
}
function blobFilePath(sessionDir: string, blobKey: string): string {
const hex = crypto.createHash("sha256").update(blobKey, "utf8").digest("hex");
return path.join(blobsDir(sessionDir), `${hex}.bin`);
}
function indexPath(sessionDir: string): string {
return path.join(blobsDir(sessionDir), "index.jsonl");
}
/** Concrete filesystem rewind-blob repository. */
export class FileRewindBlobRepository implements RewindBlobRepository {
async storeBlob(sessionDir: string, blobKey: string, data: Uint8Array, mimeType?: string): Promise<void> {
const dir = blobsDir(sessionDir);
fs.mkdirSync(dir, { recursive: true });
const file = blobFilePath(sessionDir, blobKey);
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, data);
const fd = fs.openSync(tmp, "r");
try {
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, file);
const entry: BlobIndexEntry = { key: blobKey, mime_type: mimeType, created_at: Date.now() };
const idx = indexPath(sessionDir);
const out = fs.openSync(idx, "a");
try {
fs.writeFileSync(out, `${JSON.stringify(entry)}\n`);
fs.fsyncSync(out);
} finally {
fs.closeSync(out);
}
}
async retrieveBlob(sessionDir: string, blobKey: string): Promise<Uint8Array | null> {
const file = blobFilePath(sessionDir, blobKey);
if (!fs.existsSync(file)) return null;
return new Uint8Array(fs.readFileSync(file));
}
async listBlobKeys(sessionDir: string): Promise<string[]> {
const idx = indexPath(sessionDir);
let content: string;
try {
content = fs.readFileSync(idx, "utf8");
} catch {
return [];
}
const firstSeen: string[] = [];
const latest: Record<string, BlobIndexEntry> = {};
for (const line of content.split("\n")) {
if (!line) continue;
try {
const entry = JSON.parse(line) as BlobIndexEntry;
if (!(entry.key in latest)) firstSeen.push(entry.key);
latest[entry.key] = entry;
} catch {
continue;
}
}
const entries = firstSeen
.map((k) => latest[k])
.filter(Boolean)
.sort((a, b) => a!.created_at - b!.created_at);
return entries.map((e) => e!.key);
}
}
@@ -0,0 +1,31 @@
/** JSON file–backed `SettingsRepository`. Path: `<base_dir>/settings.json`. */
import * as fs from "node:fs";
import * as path from "node:path";
import { type Settings, type SettingsRepository, newSettings } from "@zesdex/cms";
import { writeJsonAtomic } from "@zesdex/shared";
/** Persists `Settings` as pretty-printed JSON at `<base_dir>/settings.json`. */
export class JsonSettingsRepository implements SettingsRepository {
async load(baseDir: string): Promise<Settings> {
const file = path.join(baseDir, "settings.json");
try {
const raw = fs.readFileSync(file, "utf8");
try {
return JSON.parse(raw) as Settings;
} catch {
console.warn(`settings.json at '${file}' failed to parse; falling back to defaults`);
return newSettings();
}
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code === "ENOENT") return newSettings();
throw e;
}
}
async save(baseDir: string, settings: Settings): Promise<void> {
fs.mkdirSync(baseDir, { recursive: true });
const file = path.join(baseDir, "settings.json");
writeJsonAtomic(file, settings);
}
}