feat(rewrite): bootstrap monorepo Bun + port domain layer ke TypeScript

Mulai migrasi Zesdex dari Rust ke TypeScript/Bun.

- Root workspace package.json, tsconfig strict, bun.lock, gitignore fix
- Paket @zesdex/domain (apps/packages/domain):
  - core: ChatMessage/Conversation/ToolCall/SseParser/repairJson/
    sanitizeToolArguments/UsageStats/Store/DomainError
  - auth: Session/SessionId/SessionLock/OAuth + repository & service interfaces
  - cms: AppConfig/Settings/SettingsPatch/Memory/EditLog + repository &
    service interfaces
  - agent: TurnEvent/SessionRuntime/AgentTurnParams/AgentProgress/prompt
  - subagent & workflow models
- Wire-shape JSON disamakan dengan Rust (serde rename/flatten/skip_if)
- 27 unit test Bun port dari test Rust (SseParser, repair_json, resolve_effective_model, session id)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
asepharyana
2026-09-02 22:50:38 +07:00
co-authored by Claude Opus 5
parent 9d1544d799
commit 07e84e43b3
45 changed files with 2315 additions and 1 deletions
@@ -0,0 +1,62 @@
/**
* Application configuration entities. Mirrors `app_config.rs`.
*/
import { DEFAULT_CONTEXT_WINDOW } from "../agent/defaults.ts";
/** 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[];
}
@@ -0,0 +1,7 @@
/**
* Re-export of conversation types from core (matches Rust `cms/conversation.rs`).
*/
export type { Conversation } from "../core/conversation.ts";
export type { ChatMessage, Role } from "../core/message.ts";
export { newConversation, pushMessage, rebuildSystem, toApiMessages } from "../core/conversation.ts";
export { Roles } from "../core/message.ts";
+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 "../core/error.ts";
/** 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);
}
@@ -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 "../core/conversation.ts";
/** 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 "../core/conversation.ts";
import type { ChatMessage } from "../core/message.ts";
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;
}
@@ -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;
}