feat(rewrite): tambah infrastructure — LLM client + full file persistence
Sub-fase 3a & 3e infrastructure TypeScript (@zesdex/infrastructure): - llm/provider.ts: LlmClient (OpenAI/Anthropic-compatible fetch + SSE, retry 10x exponential backoff + jitter, abort on 401/402/403, stream quota-aware tool-call delta accumulation) - resolveApiKey: settings.api_keys → app_config.env_var → inline default - utils: writeJsonAtomic (tmp+rename+fsync), slugify, writeOsc52, truncateChars, buildWorkspaceTree, runCommand - persistence/cms: JsonSettingsRepository, JsonAppConfigRepository (Claude credential auto-detection dari ~/.claude/settings.json + env), JsonConversationRepository, JsonlEditLogRepository (append NDJSON), MarkdownMemoryRepository (YAML-ish frontmatter), FileRewindBlobRepository - persistence/iam: FileSystemSessionRepository, FileSystemSessionLockRepository, FileSystemOAuthRepository - Workspace @zesdex/infrastructure tsc clean, semua test domain+application tetap hijau (38 pass) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
03538a455a
commit
a2c7e5d44c
@@ -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/domain";
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user