@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,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
+ }
package/ui/timeline.ts ADDED
@@ -0,0 +1,103 @@
1
+ import type { Config } from "../config.ts";
2
+ import {
3
+ entryIndexById,
4
+ foldLedger,
5
+ isObservationsRecordedEntry,
6
+ rawTokensAfterIndex,
7
+ rawTokensSinceObservationCoverage,
8
+ type Entry,
9
+ } from "../ledger/index.ts";
10
+
11
+ /** Glyphs for the timeline strip. */
12
+ const GLYPH = {
13
+ consolidated: "▓", // observations promoted to .memory (long-term)
14
+ partial: "▚", // chunk straddling the pool-target boundary (some obs promoted)
15
+ pool: "▒", // observed, still in the short-term buffer
16
+ raw: "░", // raw history not yet distilled into observations
17
+ cut: "┊", // compaction cutoff (verbatim tail began here)
18
+ tip: "▶", // live branch tip
19
+ } as const;
20
+
21
+ /** A committed observation chunk, in branch order. */
22
+ type Chunk = { coversUpToIndex: number; timestamps: string[] };
23
+
24
+ function fmtK(tokens: number): string {
25
+ if (tokens < 1000) return `${tokens}`;
26
+ return `${(tokens / 1000).toFixed(1)}k`;
27
+ }
28
+
29
+ function collectChunks(branch: Entry[]): Chunk[] {
30
+ const indexById = entryIndexById(branch);
31
+ const chunks: Chunk[] = [];
32
+ for (const entry of branch) {
33
+ if (!isObservationsRecordedEntry(entry)) continue;
34
+ const coversUpToIndex = indexById.get(entry.data.coversUpToId);
35
+ if (coversUpToIndex === undefined) continue;
36
+ chunks.push({ coversUpToIndex, timestamps: entry.data.observations.map((o) => o.timestamp) });
37
+ }
38
+ return chunks;
39
+ }
40
+
41
+ /** Branch indices of every compaction entry's `firstKeptEntryId` (the verbatim-tail cutoffs). */
42
+ function compactionCutIndices(branch: Entry[]): number[] {
43
+ const indexById = entryIndexById(branch);
44
+ const cuts: number[] = [];
45
+ for (const entry of branch) {
46
+ if (entry.type !== "compaction") continue;
47
+ const idx = entry.firstKeptEntryId ? indexById.get(entry.firstKeptEntryId) : undefined;
48
+ if (idx !== undefined) cuts.push(idx);
49
+ }
50
+ return cuts;
51
+ }
52
+
53
+ function chunkGlyph(chunk: Chunk, dropped: Set<string>): string {
54
+ let droppedCount = 0;
55
+ for (const ts of chunk.timestamps) if (dropped.has(ts)) droppedCount++;
56
+ if (droppedCount === 0) return GLYPH.pool;
57
+ if (droppedCount === chunk.timestamps.length) return GLYPH.consolidated;
58
+ return GLYPH.partial;
59
+ }
60
+
61
+ function wrap(cells: string[], width: number): string {
62
+ if (cells.length === 0) return "";
63
+ const rows: string[] = [];
64
+ for (let i = 0; i < cells.length; i += width) rows.push(cells.slice(i, i + width).join(""));
65
+ return rows.join("\n");
66
+ }
67
+
68
+ /**
69
+ * Render the full session as a horizontal strip.
70
+ */
71
+ export function renderTimeline(branch: Entry[], config: Config, width = 60): string {
72
+ const folded = foldLedger(branch);
73
+ const dropped = folded.droppedObservationTimestamps;
74
+ const chunks = collectChunks(branch);
75
+
76
+ const cells: string[] = chunks.map((chunk) => chunkGlyph(chunk, dropped));
77
+ const tailTokens = rawTokensSinceObservationCoverage(branch);
78
+ const tailCells = tailTokens > 0 ? Math.ceil(tailTokens / config.chunkTokens) : 0;
79
+ for (let i = 0; i < tailCells; i++) cells.push(GLYPH.raw);
80
+
81
+ const cutPositions = compactionCutIndices(branch)
82
+ .map((cutIdx) => {
83
+ let pos = 0;
84
+ for (const chunk of chunks) if (chunk.coversUpToIndex < cutIdx) pos++;
85
+ return Math.min(pos, cells.length);
86
+ })
87
+ .sort((a, b) => b - a);
88
+ for (const pos of cutPositions) cells.splice(pos, 0, GLYPH.cut);
89
+
90
+ const consolidatedChunks = chunks.filter((c) => chunkGlyph(c, dropped) === GLYPH.consolidated).length;
91
+ const poolChunks = chunks.filter((c) => chunkGlyph(c, dropped) === GLYPH.pool).length;
92
+ const rawTotal = rawTokensAfterIndex(branch, -1);
93
+ const compactions = cutPositions.length;
94
+
95
+ const strip = cells.length > 0 ? `${wrap(cells, width)}${GLYPH.tip}` : "(timeline empty)";
96
+
97
+ return [
98
+ `memory-log timeline · 1 cell ≈ ${fmtK(config.chunkTokens)} tok · cumulative ${fmtK(rawTotal)} raw · ${compactions} compaction${compactions === 1 ? "" : "s"}`,
99
+ strip,
100
+ ``,
101
+ ` ${GLYPH.consolidated} .memory (${consolidatedChunks}) ${GLYPH.pool} pool (${poolChunks}) ${GLYPH.raw} raw ${GLYPH.cut} compaction cut ${GLYPH.tip} tip`,
102
+ ].join("\n");
103
+ }