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:
co-authored by
Claude Opus 5
parent
9d1544d799
commit
07e84e43b3
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user