@rol1n/memory-log 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ import type { Topic } from "./paths.ts";
2
+
3
+ function summaryOf(topic: Topic): string {
4
+ const s = (topic.summary ?? "").trim();
5
+ return s.length > 0 ? s : "(no summary)";
6
+ }
7
+
8
+ function titleOf(topic: Topic): string {
9
+ const t = (topic.title ?? "").trim();
10
+ return t.length > 0 ? t : topic.filename;
11
+ }
12
+
13
+ /** The on-disk INDEX.md content. Orchestrator-owned; the consolidator never writes it. */
14
+ export function renderIndexFile(topics: Topic[]): string {
15
+ const parts: string[] = ["# Memory index", ""];
16
+ if (topics.length === 0) {
17
+ parts.push("_No topics yet._");
18
+ return `${parts.join("\n")}\n`;
19
+ }
20
+ parts.push("Durable memory topics for this project. Read a file for its full current state.", "");
21
+ for (const topic of topics) {
22
+ const updated = topic.updated ? ` · updated ${topic.updated}` : "";
23
+ parts.push(`## ${titleOf(topic)}`);
24
+ parts.push(`- \`${topic.path}\`${updated}`);
25
+ parts.push(`- ${summaryOf(topic)}`);
26
+ parts.push("");
27
+ }
28
+ return `${parts.join("\n").trimEnd()}\n`;
29
+ }
30
+
31
+ /**
32
+ * The compaction injection block's memory-map section. Returns undefined when there are no topics.
33
+ */
34
+ export function renderMemoryMap(topics: Topic[]): string | undefined {
35
+ if (topics.length === 0) return undefined;
36
+ const lines: string[] = [
37
+ "## Memory map",
38
+ "Durable long-term notes live in `.memory/`. Read a file when a topic below looks relevant; these summaries are intentionally terse.",
39
+ ];
40
+ for (const topic of topics) {
41
+ const updated = topic.updated ? ` (updated ${topic.updated})` : "";
42
+ lines.push(`- \`${topic.path}\` — ${summaryOf(topic)}${updated}`);
43
+ }
44
+ return lines.join("\n");
45
+ }
@@ -0,0 +1,120 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+
4
+ export const INDEX_FILENAME = "INDEX.md";
5
+ export const JOURNEY_FILENAME = "JOURNEY.md";
6
+
7
+ /** The project-level `.memory/` base. Per-session roots live one level below it. */
8
+ export function memoryBaseDir(cwd: string): string {
9
+ return join(cwd, ".memory");
10
+ }
11
+
12
+ /**
13
+ * The per-session memory root: `.memory/<sessionId>/`.
14
+ */
15
+ export function sessionMemoryRoot(cwd: string, sessionId: string): string {
16
+ return join(memoryBaseDir(cwd), sessionId);
17
+ }
18
+
19
+ export function indexPath(root: string): string {
20
+ return join(root, INDEX_FILENAME);
21
+ }
22
+
23
+ export function journeyPath(root: string): string {
24
+ return join(root, JOURNEY_FILENAME);
25
+ }
26
+
27
+ /** Read `.memory/JOURNEY.md` body, trimmed. Returns undefined when missing or effectively empty. */
28
+ export function readJourney(root: string): string | undefined {
29
+ const path = journeyPath(root);
30
+ if (!existsSync(path)) return undefined;
31
+ try {
32
+ const body = readFileSync(path, "utf-8").trim();
33
+ return body.length > 0 ? body : undefined;
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ }
38
+
39
+ /** Atomic write (temp + rename). Creates parent dirs as needed. */
40
+ export function atomicWrite(path: string, content: string): void {
41
+ mkdirSync(dirname(path), { recursive: true });
42
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
43
+ writeFileSync(tmp, content, "utf-8");
44
+ renameSync(tmp, path);
45
+ }
46
+
47
+ /**
48
+ * Resolve a (possibly relative) path and confirm it stays inside `.memory/`.
49
+ */
50
+ export function resolveWithinMemory(root: string, requestedPath: string): string | undefined {
51
+ const base = resolve(root);
52
+ const abs = resolve(base, requestedPath);
53
+ const rel = relative(base, abs);
54
+ if (rel === "" || rel === ".") return abs;
55
+ if (rel.startsWith("..") || resolve(base, rel) !== abs) return undefined;
56
+ return abs;
57
+ }
58
+
59
+ export type TopicFrontMatter = {
60
+ id?: string;
61
+ title?: string;
62
+ summary?: string;
63
+ updated?: string;
64
+ };
65
+
66
+ export type Topic = TopicFrontMatter & {
67
+ /** Path relative to the project root, e.g. ".memory/<sessionId>/auth.md". */
68
+ path: string;
69
+ /** Bare filename, e.g. "auth.md". */
70
+ filename: string;
71
+ };
72
+
73
+ const FRONT_MATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
74
+
75
+ /**
76
+ * Parse leading YAML-ish front-matter.
77
+ */
78
+ export function parseFrontMatter(content: string): { front: TopicFrontMatter; body: string } {
79
+ const match = FRONT_MATTER_RE.exec(content);
80
+ if (!match) return { front: {}, body: content };
81
+ const front: TopicFrontMatter = {};
82
+ for (const line of match[1].split("\n")) {
83
+ const idx = line.indexOf(":");
84
+ if (idx < 0) continue;
85
+ const key = line.slice(0, idx).trim();
86
+ let value = line.slice(idx + 1).trim();
87
+ if (
88
+ (value.startsWith('"') && value.endsWith('"')) ||
89
+ (value.startsWith("'") && value.endsWith("'"))
90
+ ) {
91
+ value = value.slice(1, -1);
92
+ }
93
+ if (key === "id" || key === "title" || key === "summary" || key === "updated") {
94
+ front[key] = value;
95
+ }
96
+ }
97
+ return { front, body: content.slice(match[0].length) };
98
+ }
99
+
100
+ /**
101
+ * List parsed topic files under a session memory root, sorted by filename.
102
+ */
103
+ export function listTopics(root: string): Topic[] {
104
+ if (!existsSync(root)) return [];
105
+ const cwd = resolve(root, "..", "..");
106
+ const topics: Topic[] = [];
107
+ for (const filename of readdirSync(root)) {
108
+ if (!filename.endsWith(".md") || filename === INDEX_FILENAME || filename === JOURNEY_FILENAME) continue;
109
+ let content: string;
110
+ try {
111
+ content = readFileSync(join(root, filename), "utf-8");
112
+ } catch {
113
+ continue;
114
+ }
115
+ const { front } = parseFrontMatter(content);
116
+ topics.push({ ...front, path: relative(cwd, join(root, filename)), filename });
117
+ }
118
+ topics.sort((a, b) => (a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0));
119
+ return topics;
120
+ }
@@ -0,0 +1,64 @@
1
+ import { cpSync, existsSync, readFileSync, renameSync, rmSync } from "node:fs";
2
+ import { basename, sep } from "node:path";
3
+ import { sessionMemoryRoot } from "./paths.ts";
4
+
5
+ type SessionCtx = {
6
+ cwd: string;
7
+ sessionManager: {
8
+ getSessionId: () => string;
9
+ getHeader?: () => { id?: string; cwd?: string; parentSession?: string } | null | undefined;
10
+ };
11
+ };
12
+
13
+ /** Read a session file's header id (first JSONL line). Undefined on any parse/IO failure. */
14
+ function readSessionHeaderId(file: string): string | undefined {
15
+ try {
16
+ const firstLine = readFileSync(file, "utf-8").split("\n", 1)[0] ?? "";
17
+ const header = JSON.parse(firstLine) as { type?: string; id?: string } | undefined;
18
+ return typeof header?.id === "string" ? header.id : undefined;
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Resolve the parent session's memory root for fork/clone seeding.
26
+ */
27
+ function parentMemoryRoot(ctx: SessionCtx): string | undefined {
28
+ const parentFile = ctx.sessionManager.getHeader?.()?.parentSession;
29
+ if (!parentFile) return undefined;
30
+ const parentId = readSessionHeaderId(parentFile);
31
+ if (!parentId) return undefined;
32
+ const root = sessionMemoryRoot(ctx.cwd, parentId);
33
+ return existsSync(root) ? root : undefined;
34
+ }
35
+
36
+ /** True for any path inside a `.runs` directory (transient IPC; never seeded). */
37
+ function isRunsPath(p: string): boolean {
38
+ return basename(p) === ".runs" || p.includes(`${sep}.runs${sep}`);
39
+ }
40
+
41
+ /**
42
+ * Resolve this session's `.memory/<sessionId>/` root, seeding it from the parent session on first touch.
43
+ */
44
+ export function ensureSessionMemory(ctx: SessionCtx): string {
45
+ const sessionId = ctx.sessionManager.getSessionId();
46
+ const root = sessionMemoryRoot(ctx.cwd, sessionId);
47
+ if (existsSync(root)) return root;
48
+
49
+ const parent = parentMemoryRoot(ctx);
50
+ if (parent) {
51
+ const tmp = `${root}.seed-tmp-${process.pid}-${Date.now()}`;
52
+ try {
53
+ cpSync(parent, tmp, { recursive: true, filter: (src: string) => !isRunsPath(src) });
54
+ renameSync(tmp, root);
55
+ } catch {
56
+ try {
57
+ rmSync(tmp, { recursive: true, force: true });
58
+ } catch {
59
+ // best-effort cleanup
60
+ }
61
+ }
62
+ }
63
+ return root;
64
+ }
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@rol1n/memory-log",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "memory-log extension from pi-kit",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/RolinShmily/pi-kit.git",
12
+ "directory": "memory-log"
13
+ },
14
+ "homepage": "https://github.com/RolinShmily/pi-kit#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/RolinShmily/pi-kit/issues"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.19.0"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-ai": "*",
26
+ "@earendil-works/pi-agent-core": "*",
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "@earendil-works/pi-tui": "*",
29
+ "typebox": "*"
30
+ },
31
+ "pi": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ],
35
+ "skills": [],
36
+ "prompts": [],
37
+ "themes": []
38
+ }
39
+ }
package/runtime.ts ADDED
@@ -0,0 +1,127 @@
1
+ import { type Config, DEFAULTS, loadConfig } from "./config.ts";
2
+ import { foldLedger, poolTokens, rawTokensSinceObservationCoverage, sumSessionCost, type Entry } from "./ledger/index.ts";
3
+ import { StatusController } from "./ui/status-controller.ts";
4
+
5
+ /**
6
+ * In-process orchestrator state.
7
+ */
8
+ export class Runtime {
9
+ config: Config = { ...DEFAULTS };
10
+ configLoaded = false;
11
+
12
+ /** The per-session on/off gate (uses config.defaultEnabled when no ledger entry exists). */
13
+ enabled = false;
14
+
15
+ /**
16
+ * Absolute `.memory/<sessionId>/` root for this session.
17
+ */
18
+ memoryRoot = "";
19
+
20
+ /**
21
+ * In-flight observer subprocesses, keyed by runId.
22
+ */
23
+ readonly observersInFlight = new Map<string, { controller: AbortController; coversUpToId: string }>();
24
+
25
+ /** In-flight observer async tasks. */
26
+ readonly observerTasks = new Set<Promise<void>>();
27
+
28
+ consolidatorInFlight = false;
29
+ consolidatorController: AbortController | undefined;
30
+
31
+ dispatchedCoversUpToId: string | undefined;
32
+
33
+ compactInFlight = false;
34
+ compactHookInFlight = false;
35
+ autoCompactionController: AbortController | undefined;
36
+
37
+ lastWorkerError: string | undefined;
38
+
39
+ lastCompactionObserverWait: "skipped" | "waited" | undefined;
40
+
41
+ readonly status = new StatusController();
42
+
43
+ private pendingInfoToastLines: string[] = [];
44
+ private infoToastFlushTimer: ReturnType<typeof setTimeout> | undefined;
45
+
46
+ queueToast(
47
+ line: string,
48
+ level: "info" | "warning" | "error",
49
+ notify: (message: string, level: "info" | "warning" | "error") => void,
50
+ ): void {
51
+ if (level !== "info") {
52
+ notify(line, level);
53
+ return;
54
+ }
55
+ this.pendingInfoToastLines.push(line);
56
+ if (this.infoToastFlushTimer !== undefined) return;
57
+ this.infoToastFlushTimer = setTimeout(() => {
58
+ this.infoToastFlushTimer = undefined;
59
+ const lines = this.pendingInfoToastLines.splice(0);
60
+ if (lines.length > 0) notify(lines.join("\n"), "info");
61
+ }, 0);
62
+ (this.infoToastFlushTimer as any)?.unref?.();
63
+ }
64
+
65
+ cancelPendingToasts(): void {
66
+ if (this.infoToastFlushTimer !== undefined) {
67
+ clearTimeout(this.infoToastFlushTimer);
68
+ this.infoToastFlushTimer = undefined;
69
+ }
70
+ this.pendingInfoToastLines = [];
71
+ }
72
+
73
+ ensureConfig(cwd: string): void {
74
+ if (this.configLoaded) return;
75
+ this.config = loadConfig(cwd);
76
+ this.configLoaded = true;
77
+ }
78
+
79
+ refreshFooterGauges(branch: Entry[], contextTokens?: number | null): void {
80
+ if (!this.enabled) return;
81
+ const folded = foldLedger(branch);
82
+ this.status.setGauges({
83
+ nextValue: rawTokensSinceObservationCoverage(branch),
84
+ nextMax: this.config.chunkTokens,
85
+ poolValue: poolTokens(folded.activeObservations),
86
+ poolMax: this.config.consolidateAtPoolTokens,
87
+ ctxValue: contextTokens ?? 0,
88
+ ctxMax: this.config.compactAtContextTokens,
89
+ });
90
+ }
91
+
92
+ refreshCost(allEntries: Entry[]): void {
93
+ if (!this.enabled) return;
94
+ const { costUsd, runs } = sumSessionCost(allEntries);
95
+ this.status.setCost(costUsd, runs);
96
+ }
97
+
98
+ abortAllWorkers(): void {
99
+ this.cancelPendingToasts();
100
+ for (const { controller } of this.observersInFlight.values()) {
101
+ controller.abort();
102
+ }
103
+ this.observersInFlight.clear();
104
+ this.consolidatorController?.abort();
105
+ this.consolidatorController = undefined;
106
+ this.consolidatorInFlight = false;
107
+ if (this.autoCompactionController) {
108
+ this.autoCompactionController.abort();
109
+ this.autoCompactionController = undefined;
110
+ }
111
+ }
112
+
113
+ trackObserverTask(task: Promise<void>): void {
114
+ this.observerTasks.add(task);
115
+ void task.finally(() => this.observerTasks.delete(task));
116
+ }
117
+
118
+ async whenObserversIdle(): Promise<void> {
119
+ while (this.observerTasks.size > 0) {
120
+ await Promise.allSettled([...this.observerTasks]);
121
+ }
122
+ }
123
+
124
+ get observerSlotsAvailable(): number {
125
+ return Math.max(0, this.config.observerConcurrency - this.observersInFlight.size);
126
+ }
127
+ }
@@ -0,0 +1,133 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync, realpathSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import type { ConfiguredModel } from "../config.ts";
6
+ import { runCostPath, runResultPath } from "./runs.ts";
7
+
8
+ /** Path to agent/index.ts relative to spawn directory */
9
+ export const AGENT_EXTENSION_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "..", "agent", "index.ts");
10
+
11
+ export function modelArg(model: ConfiguredModel): string {
12
+ return model.provider && model.id ? `${model.provider}/${model.id}` : (model.id || "");
13
+ }
14
+
15
+ /** Resolve the `pi` entry point, falling back to `pi` on PATH. */
16
+ export function resolvePiBinary(): { command: string; baseArgs: string[] } {
17
+ const entry = process.argv[1];
18
+ if (entry) {
19
+ try {
20
+ const realEntry = realpathSync(entry);
21
+ if (/\.(?:mjs|cjs|js|ts)$/i.test(realEntry)) {
22
+ return { command: process.execPath, baseArgs: [realEntry] };
23
+ }
24
+ } catch {
25
+ // fall through
26
+ }
27
+ }
28
+ const fallbackCmd = process.platform === "win32" ? "pi.cmd" : "pi";
29
+ return { command: fallbackCmd, baseArgs: [] };
30
+ }
31
+
32
+ export function buildWorkerArgv(opts: {
33
+ model: ConfiguredModel;
34
+ sessionName: string;
35
+ kickoffPrompt: string;
36
+ promptPath?: string;
37
+ agentExtensionPath?: string;
38
+ }): string[] {
39
+ const pi = resolvePiBinary();
40
+ const args = [
41
+ ...pi.baseArgs,
42
+ "--no-skills",
43
+ "--no-prompt-templates",
44
+ "--no-context-files",
45
+ "--no-builtin-tools",
46
+ ];
47
+ if (opts.model.provider) {
48
+ args.push("--provider", opts.model.provider);
49
+ }
50
+ if (opts.model.id) {
51
+ args.push("--model", opts.model.id);
52
+ }
53
+ if (opts.model.thinking) {
54
+ args.push("--thinking", opts.model.thinking);
55
+ }
56
+ args.push("-e", opts.agentExtensionPath ?? AGENT_EXTENSION_PATH);
57
+ args.push("-n", opts.sessionName);
58
+ if (opts.promptPath) {
59
+ args.push("-p", `@${resolve(opts.promptPath)}`);
60
+ } else {
61
+ args.push("-p", opts.kickoffPrompt);
62
+ }
63
+ return [pi.command, ...args];
64
+ }
65
+
66
+ export type WorkerExit = { code: number | null; signal: NodeJS.Signals | null; stderr: string };
67
+
68
+ /**
69
+ * Spawn a headless worker; resolve when it exits.
70
+ */
71
+ export function spawnWorker(opts: {
72
+ argv: string[];
73
+ cwd: string;
74
+ env: NodeJS.ProcessEnv;
75
+ signal?: AbortSignal;
76
+ }): Promise<WorkerExit> {
77
+ const [command, ...rest] = opts.argv;
78
+ mkdirSync(opts.cwd, { recursive: true });
79
+ return new Promise<WorkerExit>((resolvePromise) => {
80
+ const isWindowsCmd = process.platform === "win32" && (command.endsWith(".cmd") || command.endsWith(".bat"));
81
+ let proc;
82
+ try {
83
+ proc = spawn(command, rest, {
84
+ cwd: opts.cwd,
85
+ env: opts.env,
86
+ stdio: ["ignore", "ignore", "pipe"],
87
+ shell: isWindowsCmd,
88
+ });
89
+ } catch (err: any) {
90
+ resolvePromise({ code: 1, signal: null, stderr: `spawn error: ${err?.message || String(err)}` });
91
+ return;
92
+ }
93
+
94
+ let stderr = "";
95
+ proc.stderr?.on("data", (d: Buffer | string) => {
96
+ stderr += d.toString();
97
+ });
98
+ proc.on("error", (err: any) => resolvePromise({ code: 1, signal: null, stderr: stderr || `spawn error: ${err?.message || String(err)}` }));
99
+ proc.on("close", (code: number | null, signal: NodeJS.Signals | null) => resolvePromise({ code, signal, stderr }));
100
+
101
+ if (opts.signal) {
102
+ const kill = () => {
103
+ proc.kill("SIGTERM");
104
+ const timer = setTimeout(() => {
105
+ if (!proc.killed) proc.kill("SIGKILL");
106
+ }, 3000);
107
+ (timer as any).unref?.();
108
+ };
109
+ if (opts.signal.aborted) kill();
110
+ else opts.signal.addEventListener("abort", kill, { once: true });
111
+ }
112
+ });
113
+ }
114
+
115
+ export type ObserverLaunchEnv = {
116
+ /** Absolute `.memory/<sessionId>/` root. */
117
+ memoryRoot: string;
118
+ runId: string;
119
+ };
120
+
121
+ /**
122
+ * Build the env a worker subprocess needs to write its result file.
123
+ */
124
+ export function buildWorkerEnv(role: "observer" | "consolidator", opts: ObserverLaunchEnv): NodeJS.ProcessEnv {
125
+ return {
126
+ ...process.env,
127
+ OM_WORKER: role,
128
+ OM_RUN_ID: opts.runId,
129
+ OM_RESULT_PATH: runResultPath(opts.memoryRoot, opts.runId),
130
+ OM_COST_PATH: runCostPath(opts.memoryRoot, opts.runId),
131
+ OM_MEMORY_DIR: opts.memoryRoot,
132
+ };
133
+ }
package/spawn/runs.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ /** What the observer model emits, before the orchestrator re-derives precise timestamp-ids. */
5
+ export type RawObservation = {
6
+ timestamp: string; // "YYYY-MM-DD HH:MM"
7
+ content: string;
8
+ };
9
+
10
+ export type ObserverRunResult = {
11
+ observations: RawObservation[];
12
+ };
13
+
14
+ export function runsDir(root: string): string {
15
+ return resolve(root, ".runs");
16
+ }
17
+
18
+ export function runResultPath(root: string, runId: string): string {
19
+ return resolve(runsDir(root), `${runId}.result.json`);
20
+ }
21
+
22
+ /**
23
+ * Per-run cost handoff file. Written by the worker EXTENSION from pi's built-in usage.cost.total.
24
+ */
25
+ export function runCostPath(root: string, runId: string): string {
26
+ return resolve(runsDir(root), `${runId}.cost.json`);
27
+ }
28
+
29
+ /**
30
+ * Per-run prompt file for workers to avoid command line length limits (spawn ENAMETOOLONG on Windows).
31
+ */
32
+ export function runPromptPath(root: string, runId: string): string {
33
+ return resolve(runsDir(root), `${runId}.prompt.txt`);
34
+ }
35
+
36
+ export type WorkerCostResult = {
37
+ costUsd: number;
38
+ };
39
+
40
+ export function writeWorkerCost(path: string, cost: WorkerCostResult): void {
41
+ atomicWrite(path, JSON.stringify(cost));
42
+ }
43
+
44
+ /** Best-effort read of a worker cost file; returns undefined on missing/malformed input. */
45
+ export function readWorkerCost(path: string): WorkerCostResult | undefined {
46
+ try {
47
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
48
+ if (!raw || typeof raw !== "object") return undefined;
49
+ const cost = (raw as { costUsd?: unknown }).costUsd;
50
+ if (typeof cost !== "number" || !Number.isFinite(cost) || cost < 0) return undefined;
51
+ return { costUsd: cost };
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ /** Atomic write (temp + rename) so a reader never sees a half-written file. */
58
+ export function atomicWrite(path: string, content: string): void {
59
+ mkdirSync(dirname(path), { recursive: true });
60
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
61
+ writeFileSync(tmp, content, "utf-8");
62
+ renameSync(tmp, path);
63
+ }
64
+
65
+ function isRawObservation(value: unknown): value is RawObservation {
66
+ if (!value || typeof value !== "object") return false;
67
+ const v = value as Record<string, unknown>;
68
+ return typeof v.timestamp === "string" && typeof v.content === "string" && v.content.trim().length > 0;
69
+ }
70
+
71
+ /** Parse + validate an observer result file. Throws on malformed input. */
72
+ export function readObserverResult(path: string): ObserverRunResult {
73
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
74
+ if (!raw || typeof raw !== "object" || !Array.isArray((raw as { observations?: unknown }).observations)) {
75
+ throw new Error("observer result missing observations array");
76
+ }
77
+ const observations = (raw as { observations: unknown[] }).observations.filter(isRawObservation);
78
+ return { observations };
79
+ }
80
+
81
+ export function writeObserverResult(path: string, result: ObserverRunResult): void {
82
+ atomicWrite(path, JSON.stringify(result));
83
+ }
package/tokens.ts ADDED
@@ -0,0 +1,66 @@
1
+ /** Rough char-based token estimate. Matches OM's heuristic (≈4 chars/token). */
2
+ export function estimateStringTokens(text: string): number {
3
+ return Math.ceil(text.length / 4);
4
+ }
5
+
6
+ export function estimateMessageTokens(message: unknown): number {
7
+ if (!message || typeof message !== "object") return 0;
8
+ const msg = message as { content?: unknown };
9
+ if (typeof msg.content === "string") {
10
+ return estimateStringTokens(msg.content);
11
+ }
12
+ if (Array.isArray(msg.content)) {
13
+ let total = 0;
14
+ for (const part of msg.content) {
15
+ if (part && typeof part === "object") {
16
+ if ("text" in part && typeof (part as { text?: unknown }).text === "string") {
17
+ total += estimateStringTokens((part as { text: string }).text);
18
+ } else if ("thinking" in part && typeof (part as { thinking?: unknown }).thinking === "string") {
19
+ total += estimateStringTokens((part as { thinking: string }).thinking);
20
+ }
21
+ }
22
+ }
23
+ return total;
24
+ }
25
+ try {
26
+ return estimateStringTokens(JSON.stringify(message));
27
+ } catch {
28
+ return 0;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Estimate the token cost of a single ledger entry. Only "source" entries
34
+ * (message / custom_message / branch_summary) carry conversational text; every
35
+ * other entry type (memory ledger records, compaction, etc.) contributes 0 so
36
+ * the raw-token clocks measure only new conversation.
37
+ */
38
+ export function estimateEntryTokens(entry: {
39
+ type: string;
40
+ message?: unknown;
41
+ content?: unknown;
42
+ summary?: unknown;
43
+ }): number {
44
+ if (entry.type === "message" && entry.message) {
45
+ return estimateMessageTokens(
46
+ entry.message as Parameters<typeof estimateMessageTokens>[0],
47
+ );
48
+ }
49
+ if (entry.type === "custom_message" && entry.content) {
50
+ const content = entry.content;
51
+ if (typeof content === "string") return estimateStringTokens(content);
52
+ if (Array.isArray(content)) {
53
+ let total = 0;
54
+ for (const block of content) {
55
+ if (block.type === "text" && block.text) {
56
+ total += estimateStringTokens(block.text);
57
+ }
58
+ }
59
+ return total;
60
+ }
61
+ }
62
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
63
+ return estimateStringTokens(entry.summary);
64
+ }
65
+ return 0;
66
+ }