@rol1n/memory-log 0.0.0-stage → 0.1.0
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.
- package/README.md +53 -2
- package/extensions/memory-log/README.md +54 -0
- package/extensions/memory-log/agent/consolidator/prompt.ts +47 -0
- package/extensions/memory-log/agent/consolidator/tools.ts +160 -0
- package/extensions/memory-log/agent/cost.ts +32 -0
- package/extensions/memory-log/agent/index.ts +42 -0
- package/extensions/memory-log/agent/observer/prompt.ts +73 -0
- package/extensions/memory-log/agent/observer/tool.ts +73 -0
- package/extensions/memory-log/commands/compact.ts +35 -0
- package/extensions/memory-log/commands/consolidate.ts +47 -0
- package/extensions/memory-log/commands/status.ts +50 -0
- package/extensions/memory-log/config.ts +253 -0
- package/extensions/memory-log/debug-log.ts +76 -0
- package/extensions/memory-log/hooks/compaction-hook.ts +161 -0
- package/extensions/memory-log/hooks/compaction-trigger.ts +160 -0
- package/extensions/memory-log/hooks/consolidator-trigger.ts +143 -0
- package/extensions/memory-log/hooks/observer-trigger.ts +176 -0
- package/extensions/memory-log/ids.ts +78 -0
- package/extensions/memory-log/index.ts +149 -0
- package/extensions/memory-log/ledger/fold.ts +77 -0
- package/extensions/memory-log/ledger/index.ts +7 -0
- package/extensions/memory-log/ledger/pool.ts +38 -0
- package/extensions/memory-log/ledger/progress.ts +181 -0
- package/extensions/memory-log/ledger/projection.ts +115 -0
- package/extensions/memory-log/ledger/render.ts +38 -0
- package/extensions/memory-log/ledger/serialize.ts +161 -0
- package/extensions/memory-log/ledger/types.ts +201 -0
- package/extensions/memory-log/memory/index-render.ts +45 -0
- package/extensions/memory-log/memory/paths.ts +120 -0
- package/extensions/memory-log/memory/session.ts +64 -0
- package/extensions/memory-log/runtime.ts +127 -0
- package/extensions/memory-log/spawn/launch.ts +133 -0
- package/extensions/memory-log/spawn/runs.ts +83 -0
- package/extensions/memory-log/tokens.ts +66 -0
- package/extensions/memory-log/ui/status-controller.ts +225 -0
- package/extensions/memory-log/ui/timeline.ts +103 -0
- package/package.json +41 -4
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI observability for SRP Observational Memory.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type WorkerType = "observer" | "consolidator";
|
|
6
|
+
|
|
7
|
+
export interface FooterGauges {
|
|
8
|
+
/** Raw tokens accrued toward the next observer chunk. */
|
|
9
|
+
nextValue: number;
|
|
10
|
+
nextMax: number;
|
|
11
|
+
/** Active pool tokens accrued toward the consolidation threshold. */
|
|
12
|
+
poolValue: number;
|
|
13
|
+
poolMax: number;
|
|
14
|
+
/** Live context-window tokens toward the compaction threshold. */
|
|
15
|
+
ctxValue: number;
|
|
16
|
+
ctxMax: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Theme {
|
|
20
|
+
fg(color: string, text: string): string;
|
|
21
|
+
bg?(color: string, text: string): string;
|
|
22
|
+
bold?(text: string): string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface StatusUI {
|
|
26
|
+
setStatus(key: string, text: string | undefined): void;
|
|
27
|
+
setWidget(
|
|
28
|
+
key: string,
|
|
29
|
+
content: string[] | undefined,
|
|
30
|
+
options?: { placement?: "aboveEditor" | "belowEditor" },
|
|
31
|
+
): void;
|
|
32
|
+
theme: Theme;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type WorkerState =
|
|
36
|
+
| { kind: "running" }
|
|
37
|
+
| { kind: "done"; delta?: number }
|
|
38
|
+
| { kind: "error" };
|
|
39
|
+
|
|
40
|
+
const WIDGET_KEY = "memory-log";
|
|
41
|
+
const LEGACY_WIDGET_KEY = "srp-memory";
|
|
42
|
+
const LEGACY_WORKERS_WIDGET_KEY = "srp-memory-workers";
|
|
43
|
+
const LEGACY_STATUS_KEY = "srp-memory";
|
|
44
|
+
const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"] as const;
|
|
45
|
+
const WORKER_SEP = " ";
|
|
46
|
+
|
|
47
|
+
export interface StatusControllerOptions {
|
|
48
|
+
spinnerIntervalMs?: number;
|
|
49
|
+
settleMs?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface WorkerEntry {
|
|
53
|
+
type: WorkerType;
|
|
54
|
+
state: WorkerState;
|
|
55
|
+
settleTimer?: ReturnType<typeof setTimeout>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class StatusController {
|
|
59
|
+
private ui: StatusUI | undefined;
|
|
60
|
+
private frame = 0;
|
|
61
|
+
private readonly workers = new Map<string, WorkerEntry>();
|
|
62
|
+
private spinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
63
|
+
private gauges: FooterGauges | undefined;
|
|
64
|
+
private cost: { costUsd: number; runs: number } | undefined;
|
|
65
|
+
private readonly spinnerIntervalMs: number;
|
|
66
|
+
private readonly settleMs: number;
|
|
67
|
+
|
|
68
|
+
constructor(options: StatusControllerOptions = {}) {
|
|
69
|
+
this.spinnerIntervalMs = options.spinnerIntervalMs ?? 120;
|
|
70
|
+
this.settleMs = options.settleMs ?? 5000;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
attach(ui: StatusUI): void {
|
|
74
|
+
this.ui = ui;
|
|
75
|
+
this.ui.setStatus(LEGACY_STATUS_KEY, undefined);
|
|
76
|
+
this.ui.setStatus(WIDGET_KEY, undefined);
|
|
77
|
+
this.ui.setWidget(LEGACY_WORKERS_WIDGET_KEY, undefined);
|
|
78
|
+
this.ui.setWidget(LEGACY_WIDGET_KEY, undefined);
|
|
79
|
+
this.renderWidget();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
detach(): void {
|
|
83
|
+
this.stopSpinner();
|
|
84
|
+
for (const entry of this.workers.values()) {
|
|
85
|
+
if (entry.settleTimer) clearTimeout(entry.settleTimer);
|
|
86
|
+
}
|
|
87
|
+
this.workers.clear();
|
|
88
|
+
this.gauges = undefined;
|
|
89
|
+
this.cost = undefined;
|
|
90
|
+
this.ui?.setWidget(WIDGET_KEY, undefined);
|
|
91
|
+
this.ui?.setWidget(LEGACY_WORKERS_WIDGET_KEY, undefined);
|
|
92
|
+
this.ui?.setWidget(LEGACY_WIDGET_KEY, undefined);
|
|
93
|
+
if (this.ui) this.ui.setStatus(LEGACY_STATUS_KEY, undefined);
|
|
94
|
+
if (this.ui) this.ui.setStatus(WIDGET_KEY, undefined);
|
|
95
|
+
this.ui = undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setGauges(gauges: FooterGauges | undefined): void {
|
|
99
|
+
this.gauges = gauges;
|
|
100
|
+
this.renderWidget();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setCost(costUsd: number, runs: number): void {
|
|
104
|
+
this.cost = { costUsd, runs };
|
|
105
|
+
this.renderWidget();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
workerStart(type: WorkerType, runId: string): void {
|
|
109
|
+
if (!this.ui) return;
|
|
110
|
+
const existing = this.workers.get(runId);
|
|
111
|
+
if (existing?.settleTimer) clearTimeout(existing.settleTimer);
|
|
112
|
+
this.workers.set(runId, { type, state: { kind: "running" } });
|
|
113
|
+
this.startSpinner();
|
|
114
|
+
this.renderWidget();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
workerDone(runId: string, delta?: number): void {
|
|
118
|
+
this.settle(runId, { kind: "done", delta });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
workerError(runId: string): void {
|
|
122
|
+
this.settle(runId, { kind: "error" });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private settle(runId: string, state: WorkerState): void {
|
|
126
|
+
if (!this.ui) return;
|
|
127
|
+
const entry = this.workers.get(runId);
|
|
128
|
+
if (!entry) return;
|
|
129
|
+
if (entry.settleTimer) clearTimeout(entry.settleTimer);
|
|
130
|
+
entry.state = state;
|
|
131
|
+
this.renderWidget();
|
|
132
|
+
entry.settleTimer = setTimeout(() => {
|
|
133
|
+
this.workers.delete(runId);
|
|
134
|
+
this.renderWidget();
|
|
135
|
+
if (!this.hasRunningWorker()) this.stopSpinner();
|
|
136
|
+
}, this.settleMs);
|
|
137
|
+
(entry.settleTimer as any)?.unref?.();
|
|
138
|
+
if (!this.hasRunningWorker()) this.stopSpinner();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private hasRunningWorker(): boolean {
|
|
142
|
+
for (const entry of this.workers.values()) {
|
|
143
|
+
if (entry.state.kind === "running") return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private startSpinner(): void {
|
|
149
|
+
if (this.spinnerTimer) return;
|
|
150
|
+
this.spinnerTimer = setInterval(() => {
|
|
151
|
+
this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
|
|
152
|
+
if (this.hasRunningWorker()) this.renderWidget();
|
|
153
|
+
}, this.spinnerIntervalMs);
|
|
154
|
+
(this.spinnerTimer as any)?.unref?.();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private stopSpinner(): void {
|
|
158
|
+
if (!this.spinnerTimer) return;
|
|
159
|
+
clearInterval(this.spinnerTimer);
|
|
160
|
+
this.spinnerTimer = undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private gaugeBar(value: number, max: number, cells = 6): string {
|
|
164
|
+
const theme = this.ui!.theme;
|
|
165
|
+
const frac = max <= 0 ? 0 : Math.max(0, value / max);
|
|
166
|
+
const filled = Math.min(cells, Math.round(Math.min(1, frac) * cells));
|
|
167
|
+
const fillColor = frac >= 1 ? "warning" : "dim";
|
|
168
|
+
return (
|
|
169
|
+
theme.fg(fillColor, "▕") +
|
|
170
|
+
theme.fg(fillColor, "█".repeat(filled)) +
|
|
171
|
+
theme.fg(fillColor, "░".repeat(cells - filled)) +
|
|
172
|
+
theme.fg(fillColor, "▏")
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private renderWorkersLine(): string | undefined {
|
|
177
|
+
if (this.workers.size === 0) return undefined;
|
|
178
|
+
const theme = this.ui!.theme;
|
|
179
|
+
const parts: string[] = [];
|
|
180
|
+
for (const entry of this.workers.values()) {
|
|
181
|
+
if (entry.state.kind === "running") {
|
|
182
|
+
parts.push(`${theme.fg("accent", SPINNER_FRAMES[this.frame])} ${theme.fg("accent", `[${entry.type}]`)}`);
|
|
183
|
+
} else if (entry.state.kind === "error") {
|
|
184
|
+
parts.push(`${theme.fg("error", "✗")} ${theme.fg("muted", `[${entry.type}]`)}`);
|
|
185
|
+
} else {
|
|
186
|
+
const delta =
|
|
187
|
+
entry.state.delta && entry.state.delta > 0
|
|
188
|
+
? ` ${theme.fg("success", `+${entry.state.delta}`)}`
|
|
189
|
+
: "";
|
|
190
|
+
parts.push(`${theme.fg("success", "✓")} ${theme.fg("muted", `[${entry.type}]`)}${delta}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return parts.length > 0 ? parts.join(WORKER_SEP) : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private renderMemLine(): string {
|
|
197
|
+
const theme = this.ui?.theme;
|
|
198
|
+
if (!theme) return "memory-log";
|
|
199
|
+
const base = `${theme.fg("success", "● mem")}`;
|
|
200
|
+
const g = this.gauges;
|
|
201
|
+
if (!g) return base;
|
|
202
|
+
const next = `${theme.fg("muted", "O")}${this.gaugeBar(g.nextValue, g.nextMax)}`;
|
|
203
|
+
const pool = `${theme.fg("muted", "C")}${this.gaugeBar(g.poolValue, g.poolMax)}`;
|
|
204
|
+
const ctx = `${theme.fg("muted", "X")}${this.gaugeBar(g.ctxValue, g.ctxMax)}`;
|
|
205
|
+
const cost = this.cost && this.cost.costUsd > 0 ? ` ${theme.fg("dim", `$${this.cost.costUsd.toFixed(3)}`)}` : "";
|
|
206
|
+
return `${base} ${next} ${pool} ${ctx}${cost}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private renderWidget(): void {
|
|
210
|
+
const ui = this.ui;
|
|
211
|
+
if (!ui) return;
|
|
212
|
+
|
|
213
|
+
const lines: string[] = [];
|
|
214
|
+
const workersLine = this.renderWorkersLine();
|
|
215
|
+
if (workersLine) {
|
|
216
|
+
lines.push(workersLine);
|
|
217
|
+
}
|
|
218
|
+
const memLine = this.renderMemLine();
|
|
219
|
+
if (memLine) {
|
|
220
|
+
lines.push(memLine);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
ui.setWidget(WIDGET_KEY, lines.length > 0 ? lines : undefined, { placement: "aboveEditor" });
|
|
224
|
+
}
|
|
225
|
+
}
|