opencode-self-improvement 0.2.2 → 0.3.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 CHANGED
@@ -10,7 +10,12 @@ LLMs waste a lot of tokens reading and re-reading your code and asking you for c
10
10
  - **`memory_*` tools** — remember facts, decisions, and commands the agent should
11
11
  keep across sessions (`memory_init`, `memory_remember`, `memory_recall`,
12
12
  `memory_update`, `memory_forget`, `memory_list`, `memory_undo`,
13
- `memory_evidence`, `memory_export`, `memory_import`, `memory_decay`).
13
+ `memory_evidence`, `memory_export`, `memory_import`, `memory_decay`,
14
+ `memory_status`).
15
+ - **Memory at a glance** — the terminal shows a footer badge (injected chars
16
+ vs budget + a cache-health dot) and a `/memory` panel; desktop/web/IDE get
17
+ the same report in-chat via `/memory-report`, including the *measured*
18
+ prompt-cache hit rate. See [Memory at a glance](#memory-at-a-glance).
14
19
  - **`/repo-init`** — for each repo: indexes the code graph and remembers the
15
20
  test/build commands, keyed to the git head. The command installs itself; no
16
21
  setup beyond the plugin.
@@ -47,6 +52,10 @@ prompt cache on every message. This one is built the other way around:
47
52
  - **Hard character budget.** The block is capped at 1200 characters by default
48
53
  (see Options) and trimmed at whole-memory boundaries — never cut mid-sentence.
49
54
  - **Evidence is never injected**, and the standing system digest is two lines.
55
+ - **Receipts, not promises.** The plugin measures your session's actual
56
+ prompt-cache usage (from each turn's `tokens.cache` accounting) and shows it
57
+ next to the badge — so a cache hit rate you can watch, not just a byte-freeze
58
+ claim you have to trust.
50
59
 
51
60
  Measured on a deterministic workload (50 memories, 20 prompts, 3 mid-session
52
61
  store writes) using the plugin's real injection code:
@@ -130,7 +139,7 @@ Add a `plugins` entry to your opencode config —
130
139
  ```json
131
140
  {
132
141
  "$schema": "https://opencode.ai/config.json",
133
- "plugins": ["opencode-self-improvement@0.2.1"]
142
+ "plugins": ["opencode-self-improvement@0.3.0"]
134
143
  }
135
144
  ```
136
145
 
@@ -144,7 +153,7 @@ bump the version in the config and restart. On Opencode v2 you can also manage
144
153
  it with the CLI:
145
154
 
146
155
  ```sh
147
- opencode plugin add opencode-self-improvement@0.2.1
156
+ opencode plugin add opencode-self-improvement@0.3.0
148
157
  opencode plugin update opencode-self-improvement
149
158
  ```
150
159
 
@@ -187,6 +196,7 @@ the plugin entry in `opencode.json(c)` instead of the plain string:
187
196
  | `autoCapture` | `false` (off) | When on, each session-idle event makes one cheap model pass that proposes memory candidates. Every candidate still goes through the 4-gate write pipeline — capture can never bypass the gates. Off by default: the zero-token install stays zero-token. |
188
197
  | `captureModel` | — | `"provider/id"` model used for auto-capture. Required when `autoCapture` is on; a missing or invalid value disables capture with a startup warning. |
189
198
  | `captureMax` | `3` | Maximum candidates proposed per idle event. |
199
+ | `notifyBlocked` | `true` (on) | Terminal (TUI) half only: toast when the write gate blocks a memory write. `false` keeps the panel quiet; blocks are always in `memory_evidence` regardless. |
190
200
 
191
201
  ## Code graph (optional)
192
202
 
@@ -251,6 +261,38 @@ and the tool reports a tally of four outcomes per line —
251
261
  so re-running the same import is a no-op (everything collapses). Copying an
252
262
  export to a fresh machine is how you seed a new laptop from an old one.
253
263
 
264
+ ## Memory at a glance
265
+
266
+ "Wait, what did the memory plugin just do?" — answered differently per client,
267
+ same data (`memory_status` tool under the hood; no model call):
268
+
269
+ **Terminal (opencode CLI)** — the plugin ships a TUI half
270
+ (`@opencode/plugin/tui`) that loads automatically:
271
+
272
+ - a footer badge: `● mem 1157/1200c · cache 96%` — the injected block against
273
+ its budget, and the measured session prompt-cache hit rate. The dot is green
274
+ (stable + warm), yellow (a recent recompute, or 50–90% reads), or red (last
275
+ turn served almost nothing from cache). `—` until the first assistant turn.
276
+ - **`/memory`** opens a session panel (every number is this session only):
277
+ - **CONTEXT** — exact chars + estimated tokens riding now, % of the budget,
278
+ requests the current epoch has ridden, session-lifetime totals, and how
279
+ many requests abstained (silence is a feature)
280
+ - **CACHE** — epoch invalidations this session with the last reason, server
281
+ recompute count, and the *measured* prompt-cache read rate of the last turn
282
+ — the receipt, not just the promise
283
+ - **INJECTED BLOCK** — the verbatim block riding your prompts, or an explicit
284
+ "abstained: no vocabulary overlap"
285
+ - **RECENT BLOCKED WRITES** — that `memory_remember` that didn't stick,
286
+ with its gate reason
287
+ - **STORE** — active / superseded / open / scopes
288
+ - keys: `r` refresh, `f` fullscreen
289
+ - a warning toast when a write is blocked (opt out: `"notifyBlocked": false`)
290
+
291
+ **Desktop / web / IDE** — no TUI slots there (they're terminal-only), so use
292
+ the self-installed **`/memory-report`** chat command: the same four sections,
293
+ rendered in the conversation. For editing, the browser admin (`bun memory-view`)
294
+ works everywhere.
295
+
254
296
  ## Admin Tool
255
297
 
256
298
  Editing and deleting memories is easy!
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: Memory status — what is injected right now, blocked writes, store stats, cost ledger
3
+ ---
4
+
5
+ Call the `memory_status` tool and render its output verbatim inside a fenced
6
+ code block. Do not summarize, reinterpret, or add commentary. If the report
7
+ shows no epoch yet, say so in one sentence instead of guessing.
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Durable memory for opencode: remember facts, decisions, and commands across sessions, plus a one-command per-repo setup (/repo-init).",
5
5
  "main": "index.ts",
6
6
  "exports": {
7
- ".": "./index.ts"
7
+ ".": "./index.ts",
8
+ "./tui": "./src/tui.tsx"
8
9
  },
9
10
  "module": "index.ts",
10
11
  "type": "module",
@@ -27,6 +28,7 @@
27
28
  ],
28
29
  "files": [
29
30
  "index.ts",
31
+ "tui.ts",
30
32
  "src",
31
33
  "command",
32
34
  "vendor",
@@ -37,13 +39,20 @@
37
39
  "codebase-memory-mcp": "0.11.0"
38
40
  },
39
41
  "devDependencies": {
42
+ "@biomejs/biome": "^2.6.2",
40
43
  "@opencode/plugin": "^2.0.32",
44
+ "@opentui/core": ">=0.6.6",
45
+ "@opentui/solid": ">=0.6.6",
41
46
  "@semantic-release/git": "^11.0.1",
42
47
  "@types/bun": "latest",
43
48
  "semantic-release": "^25.0.9",
49
+ "solid-js": ">=1.9.0",
44
50
  "typescript": "^7.0.2"
45
51
  },
46
52
  "peerDependencies": {
53
+ "@opentui/core": ">=0.6.6",
54
+ "@opentui/solid": ">=0.6.6",
55
+ "solid-js": ">=1.9.0",
47
56
  "typescript": "^7.0.2"
48
57
  },
49
58
  "scripts": {
@@ -51,6 +60,7 @@
51
60
  "typecheck": "bunx tsc --noEmit -p tsconfig.json",
52
61
  "bench": "bun bench/inject-cost.ts",
53
62
  "bench:recall": "bun bench/recall-quality.ts",
54
- "memory-view": "bun run src/viewer/server.ts"
63
+ "memory-view": "bun run src/viewer/server.ts",
64
+ "lint": "biome lint"
55
65
  }
56
66
  }
package/src/command.ts CHANGED
@@ -1,25 +1,36 @@
1
- // Self-installs the /repo-init slash command as part of plugin load, so users
2
- // never run a terminal command to set up init. The command markdown ships in
3
- // the package (command/repo-init.md) and is mirrored idempotently
1
+ // Self-installs the plugin's slash commands as part of plugin load, so users
2
+ // never run a terminal command to set up. Command markdown ships in the
3
+ // package (command/<name>.md) and is mirrored idempotently
4
4
  // (write-if-missing; never overwrites a user's edits). Failures are swallowed —
5
- // a missing command degrades to "ask the model to call memory_init".
5
+ // a missing command degrades to "ask the model to call the tool".
6
6
  import { existsSync } from "node:fs";
7
7
  import { mkdir } from "node:fs/promises";
8
8
  import { dirname, join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { commandsDir } from "./config.ts";
11
11
 
12
- const SOURCE = join(dirname(fileURLToPath(import.meta.url)), "..", "command", "repo-init.md");
12
+ const PKG_COMMANDS = join(dirname(fileURLToPath(import.meta.url)), "..", "command");
13
13
 
14
- export async function ensureInitCommand(): Promise<string | null> {
14
+ export async function ensureCommand(name: string): Promise<string | null> {
15
15
  try {
16
- const target = join(commandsDir(), "repo-init.md");
16
+ const target = join(commandsDir(), `${name}.md`);
17
17
  if (existsSync(target)) return null;
18
- const text = await Bun.file(SOURCE).text();
18
+ const text = await Bun.file(join(PKG_COMMANDS, `${name}.md`)).text();
19
19
  await mkdir(commandsDir(), { recursive: true });
20
20
  await Bun.write(target, text);
21
21
  return target;
22
22
  } catch {
23
23
  return null;
24
24
  }
25
- }
25
+ }
26
+
27
+ export async function ensureInitCommand(): Promise<string | null> {
28
+ return ensureCommand("repo-init");
29
+ }
30
+
31
+ // /memory-report: the chat-client parity for the terminal /memory panel
32
+ // (drives the memory_status tool). Terminal users with the TUI half get the
33
+ // live panel via /memory instead; both are documented in the README.
34
+ export async function ensureMemoryReportCommand(): Promise<string | null> {
35
+ return ensureCommand("memory-report");
36
+ }
package/src/index.ts CHANGED
@@ -38,7 +38,16 @@ import {
38
38
  systemDigest,
39
39
  } from "./inject.ts";
40
40
  import { formatInit, runInit } from "./init.ts";
41
- import { ensureInitCommand } from "./command.ts";
41
+ import { ensureInitCommand, ensureMemoryReportCommand } from "./command.ts";
42
+ import {
43
+ blockedRecent,
44
+ emptyStats,
45
+ renderMemoryReport,
46
+ writeStatus,
47
+ type Measured,
48
+ type SessionStats,
49
+ type StatusPayload,
50
+ } from "./status.ts";
42
51
  import {
43
52
  afterTool,
44
53
  checkNewSession,
@@ -163,6 +172,7 @@ const impl = {
163
172
  // (idempotent, write-if-missing). OpenCode 2.x loads `*.md` from
164
173
  // ~/.config/opencode/commands, so this is the canonical V2 install path.
165
174
  await ensureInitCommand();
175
+ await ensureMemoryReportCommand();
166
176
 
167
177
  // Fail-closed hook guard: the plugin's own bookkeeping can never take down a
168
178
  // tool call, the system transform, or compaction.
@@ -574,6 +584,30 @@ const impl = {
574
584
  };
575
585
  },
576
586
  });
587
+
588
+ editor.add({
589
+ name: "memory_status",
590
+ description:
591
+ "Plain-text memory report for chat clients (desktop/web/IDE), same sections as the terminal /memory panel: CONTEXT (this session's injected block, budget use, cumulative ride and abstentions), CACHE (epoch invalidations plus the measured session prompt-cache read rate), the block verbatim, recent blocked writes with gate reasons, and store stats. Reads only — no model call.",
592
+ options: direct,
593
+ input: obj({}),
594
+ async execute(): Promise<{ content: string }> {
595
+ // In-process truth: the live epoch + ledger, fresher than any file.
596
+ const sid = getCurrent().sid;
597
+ const status = sid === "?" ? null : await buildPayload(sid);
598
+ const all = await loadAll();
599
+ const act = all.filter(isActive);
600
+ const store = {
601
+ active: act.length,
602
+ superseded: all.length - act.length,
603
+ open: act.filter((m) => m.status === "open").length,
604
+ scopes: new Set(act.map((m) => m.scope)).size,
605
+ };
606
+ return {
607
+ content: renderMemoryReport({ status, store, blocked: await blockedRecent(6) }),
608
+ };
609
+ },
610
+ });
577
611
  });
578
612
 
579
613
  // Per-session frozen injection epochs: the relevance block plus the store
@@ -581,7 +615,53 @@ const impl = {
581
615
  // of a session, when the store changes, or after a compaction (see the
582
616
  // prompt/compaction hooks) — so the system prefix, and with it the
583
617
  // provider prompt cache, survives ordinary turns.
584
- const epoch = new Map<string, { version: string; block: string }>();
618
+ const epoch = new Map<string, { version: string; block: string; at: string; reason: "first" | "store_changed" }>();
619
+ // Recomputes since server start: every one invalidated a prompt cache, so
620
+ // it is the honest cost number the surfaces show (status files / tools).
621
+ let epochRecomputes = 0;
622
+
623
+ // Per-session ledger, incremented at the injection decision below, and the
624
+ // last measured prompt-cache usage per session (message.updated). The
625
+ // surfaces read these through flushed status files (TUI) or directly
626
+ // (memory_status, in-process).
627
+ const sessionStats = new Map<string, SessionStats>();
628
+ const measuredTurns = new Map<string, Measured>();
629
+ const statsFor = (sid: string): SessionStats => {
630
+ let st = sessionStats.get(sid);
631
+ if (!st) {
632
+ st = emptyStats();
633
+ sessionStats.set(sid, st);
634
+ }
635
+ return st;
636
+ };
637
+
638
+ // Build the full status snapshot for one session, or null before its first
639
+ // epoch. Store counts are computed fresh so every flush is current.
640
+ const buildPayload = async (sid: string): Promise<StatusPayload | null> => {
641
+ const ep = epoch.get(sid);
642
+ if (!ep) return null;
643
+ const all = await loadAll();
644
+ const act = all.filter(isActive);
645
+ return {
646
+ at: ep.at,
647
+ sessionID: sid,
648
+ reason: ep.reason,
649
+ recomputes: epochRecomputes,
650
+ block: ep.block,
651
+ blockChars: ep.block.length,
652
+ budget,
653
+ store: {
654
+ active: act.length,
655
+ superseded: all.length - act.length,
656
+ open: act.filter((m) => m.status === "open").length,
657
+ scopes: new Set(act.map((m) => m.scope)).size,
658
+ },
659
+ stats: statsFor(sid),
660
+ measured: measuredTurns.get(sid) ?? null,
661
+ };
662
+ };
663
+ const flushStatus = (sid: string): Promise<void> =>
664
+ buildPayload(sid).then((p) => (p ? writeStatus(p) : undefined));
585
665
 
586
666
  // ---- hooks (all fail-closed) -------------------------------------------
587
667
  // Bookkeeping: track the active session/tool/call before a tool runs.
@@ -626,6 +706,16 @@ const impl = {
626
706
  const sid = event?.sessionID;
627
707
  const knowledge = typeof sid === "string" ? epoch.get(sid)?.block : undefined;
628
708
  if (knowledge) event?.system?.push?.({ type: "text", text: knowledge });
709
+ // Count AT THE INJECTION DECISION so badge/panel numbers match exactly
710
+ // what rode this request (a later abort still rode the bytes — known
711
+ // and documented). Abstained = the epoch exists but its block is empty.
712
+ if (typeof sid === "string") {
713
+ const st = statsFor(sid);
714
+ st.requests++;
715
+ st.epochRequests++;
716
+ if (knowledge) st.cumChars += knowledge.length;
717
+ else if (epoch.has(sid)) st.abstained++;
718
+ }
629
719
  }),
630
720
  );
631
721
 
@@ -647,16 +737,31 @@ const impl = {
647
737
  const text = event?.prompt?.text ?? "";
648
738
  const raw = await relevantKnowledge(text, 5); // widen candidate pool
649
739
  const block = raw ? capToBudget(raw, budget) : "";
650
- epoch.set(sid, { version, block });
740
+ const at = new Date().toISOString();
741
+ const reason: "first" | "store_changed" = cached ? "store_changed" : "first";
742
+ epoch.set(sid, { version, block, at, reason });
651
743
  await record(
652
744
  "epoch_recompute",
653
745
  {
654
746
  session: sid,
655
- reason: cached ? "store_changed" : "first",
747
+ reason,
656
748
  chars: block.length,
657
749
  },
658
750
  false,
659
751
  );
752
+ epochRecomputes++;
753
+ // Ledger: a store_changed recompute of a live epoch is an
754
+ // invalidation (the first epoch of a session busts nothing). The
755
+ // epoch request count restarts with the new block.
756
+ const st = statsFor(sid);
757
+ st.epochRequests = 0;
758
+ if (reason === "store_changed") {
759
+ st.invalidations++;
760
+ st.lastInvalidation = { at, reason };
761
+ }
762
+ // Status hand-off for the terminal panel (src/tui.tsx) and any
763
+ // out-of-process surface. Best-effort; never breaks injection.
764
+ await flushStatus(sid);
660
765
  }
661
766
  }
662
767
  }),
@@ -677,8 +782,46 @@ const impl = {
677
782
  }),
678
783
  );
679
784
 
785
+ // ---- measured prompt-cache health (message.updated usage) --------------
786
+ // Zero-model by design: assistant messages carry tokens {input, output,
787
+ // reasoning, cache.read/write}; the session-wide read/(read+input+write)
788
+ // share is the honest receipt behind the byte-stability proxy. Streaming
789
+ // updates for one message arrive repeatedly — overwriting with the latest
790
+ // is correct because the final update carries the complete usage. Flushes
791
+ // the per-session status file so the TUI badge refreshes once per turn
792
+ // instead of per step. Degrades to "not measured" when the host exposes
793
+ // no event stream.
794
+ const measuredController = new AbortController();
795
+ if (ctx.event?.subscribe) {
796
+ const eventApi = ctx.event;
797
+ void (async () => {
798
+ try {
799
+ for await (const raw of eventApi.subscribe({ signal: measuredController.signal })) {
800
+ const ev = raw as Record<string, any>;
801
+ if (ev?.type !== "message.updated") continue;
802
+ const info = ev.properties?.info ?? ev.info;
803
+ if (!info || info.role !== "assistant") continue;
804
+ const sid = info.sessionID;
805
+ const t = info.tokens;
806
+ if (typeof sid !== "string" || !t) continue;
807
+ measuredTurns.set(sid, {
808
+ at: new Date().toISOString(),
809
+ input: Number(t.input) || 0,
810
+ read: Number(t.cache?.read) || 0,
811
+ write: Number(t.cache?.write) || 0,
812
+ });
813
+ await guarded("status flush (measured)", () => flushStatus(sid));
814
+ }
815
+ } catch {
816
+ // the subscription ended (abort or host error); nothing to do
817
+ }
818
+ })();
819
+ }
820
+
680
821
  // ---- opt-in auto-capture on session idle --------------------------------
681
- // Default OFF: zero-token installs never subscribe to anything. When
822
+ // Default OFF: capture never reads sessions and never calls a model. (The
823
+ // measured-cache listener above does subscribe to the event stream by
824
+ // default, but it is zero-model: pure bookkeeping of message usage.) When
682
825
  // enabled (options.autoCapture === true AND a valid captureModel), each
683
826
  // session.idle event runs ONE cheap ctx.generate.text call whose reply is
684
827
  // parsed into candidates that go through the SAME runWriteGate as
@@ -754,9 +897,12 @@ const impl = {
754
897
  captureCleanup = () => controller.abort();
755
898
  }
756
899
  }
757
- // Single cleanup returned by setup: tears down the capture subscription.
758
- // Future teardowns merge into this same function.
759
- return captureCleanup;
900
+ // Single cleanup returned by setup: tears down the measured listener and,
901
+ // when enabled, the capture subscription. Future teardowns merge in here.
902
+ return () => {
903
+ measuredController.abort();
904
+ if (captureCleanup) return captureCleanup();
905
+ };
760
906
  },
761
907
  };
762
908
 
package/src/logfmt.ts CHANGED
@@ -5,10 +5,17 @@ import { mkdir } from "node:fs/promises";
5
5
 
6
6
  export type Fields = Record<string, unknown>;
7
7
 
8
+ // Escapes for quoted values. One record is one line, so a raw newline inside a
9
+ // value would end the record early and the rest of the value would be read
10
+ // back as a separate line of fields. Backslash is escaped too, so a value
11
+ // ending in `\` cannot swallow its closing quote.
12
+ const ESCAPE: Record<string, string> = { "\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t" };
13
+ const UNESCAPE: Record<string, string> = { "\\": "\\", '"': '"', n: "\n", r: "\r", t: "\t" };
14
+
8
15
  export function quote(value: unknown): string {
9
16
  const s = String(value);
10
17
  if (s === "") return '""';
11
- return /[\s"=]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
18
+ return /[\s"=]/.test(s) ? `"${s.replace(/[\\"\n\r\t]/g, (c) => ESCAPE[c])}"` : s;
12
19
  }
13
20
 
14
21
  export function formatLine(fields: Fields): string {
@@ -20,9 +27,10 @@ export function formatLine(fields: Fields): string {
20
27
  return parts.join(" ");
21
28
  }
22
29
 
23
- // Unescape a quoted logfmt value and read it back.
30
+ // Unescape a quoted logfmt value and read it back. Unknown sequences are kept
31
+ // as written: older files stored backslashes unescaped (`C:\Program Files`).
24
32
  function unquote(s: string): string {
25
- return s.replace(/\\"/g, '"');
33
+ return s.replace(/\\(.)/g, (seq, c: string) => UNESCAPE[c] ?? seq);
26
34
  }
27
35
 
28
36
  export function parseLine(line: string): Fields | null {
@@ -30,8 +38,9 @@ export function parseLine(line: string): Fields | null {
30
38
  if (!trimmed || !trimmed.includes("=")) return null;
31
39
  const fields: Fields = {};
32
40
  const re = /([A-Za-z0-9_.-]+)=("(?:[^"\\]|\\.)*"|[^\s]*)/g;
33
- let m: RegExpExecArray | null;
34
- while ((m = re.exec(trimmed)) !== null) {
41
+ for (;;) {
42
+ const m = re.exec(trimmed);
43
+ if (!m) break;
35
44
  const key = m[1];
36
45
  let raw = m[2];
37
46
  if (raw.startsWith('"') && raw.endsWith('"')) {
package/src/resolver.ts CHANGED
@@ -85,8 +85,9 @@ function looksProseArgs(inner: string): boolean {
85
85
  export function extractCallForms(content: string): CallForm[] {
86
86
  const forms: CallForm[] = [];
87
87
  CALL_RE.lastIndex = 0;
88
- let m: RegExpExecArray | null;
89
- while ((m = CALL_RE.exec(content)) !== null) {
88
+ for (;;) {
89
+ const m = CALL_RE.exec(content);
90
+ if (!m) break;
90
91
  const symbol = m[1];
91
92
  const openIdx = content.indexOf("(", m.index + symbol.length);
92
93
  const paren = openIdx >= 0 ? balancedParen(content, openIdx) : null;
package/src/status.ts ADDED
@@ -0,0 +1,316 @@
1
+ // The status hand-off between the two surface halves. The server plugin writes
2
+ // one status file per session on every epoch recompute AND every measured
3
+ // assistant turn (src/index.ts); the terminal plugin (src/tui.tsx) and the
4
+ // memory_status tool consume them. Status files are deliberately NOT .logfmt
5
+ // memory files — loadAll() must never ingest them.
6
+ //
7
+ // Why per-session: the epoch freeze is per-session, so a single global
8
+ // last-writer-wins file let one session's badge show another session's epoch.
9
+ // `status/<sessionID>.json` is the primary read; the global `status.json`
10
+ // survives as a "latest" fallback for argument-free reads. Both are pruned /
11
+ // written best-effort: a status failure degrades surfaces, never injection.
12
+ //
13
+ // Scope of v2: same-machine reads. Against a REMOTE server the terminal panel
14
+ // sees no status files (it says so) — the RPC surface is the planned follow-up.
15
+ import { readdir, rm, stat } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { dateStr, evidenceDir, memoryRoot } from "./config.ts";
18
+ import { parseLine, readLines } from "./logfmt.ts";
19
+
20
+ export interface StoreCounts {
21
+ active: number;
22
+ superseded: number;
23
+ open: number;
24
+ scopes: number;
25
+ }
26
+
27
+ /**
28
+ * Per-session ledger, incremented at the session.context injection decision
29
+ * (so counts match exactly what was injected — an aborted turn still rode the
30
+ * bytes). Cumulative fields are session-lifetime; epochRequests resets when a
31
+ * new epoch freezes. An invalidation = a store_changed recompute that moved a
32
+ * live epoch (the first epoch of a session is not one).
33
+ */
34
+ export interface SessionStats {
35
+ requests: number; // provider requests that injected through session.context
36
+ abstained: number; // requests where the epoch block was empty
37
+ cumChars: number; // lifetime sum of injected block chars
38
+ epochRequests: number; // requests the CURRENT epoch block has ridden
39
+ invalidations: number; // store_changed recomputes this session
40
+ lastInvalidation: { at: string; reason: string } | null;
41
+ }
42
+
43
+ export function emptyStats(): SessionStats {
44
+ return {
45
+ requests: 0,
46
+ abstained: 0,
47
+ cumChars: 0,
48
+ epochRequests: 0,
49
+ invalidations: 0,
50
+ lastInvalidation: null,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Measured prompt-cache usage from the last completed assistant message
56
+ * (message.updated -> tokens {input, cache.read, cache.write}). This is the
57
+ * SESSION-WIDE prompt cache — the honest receipt for "are we busting cache",
58
+ * never billed as our block's own rate.
59
+ */
60
+ export interface Measured {
61
+ at: string;
62
+ input: number;
63
+ read: number;
64
+ write: number;
65
+ }
66
+
67
+ export interface StatusPayload {
68
+ /** ISO instant of the epoch recompute that produced this snapshot. */
69
+ at: string;
70
+ sessionID: string;
71
+ reason: "first" | "store_changed";
72
+ /** epoch recomputes since the server process started, ALL sessions. */
73
+ recomputes: number;
74
+ /** exact injected block ("" = abstained: no vocabulary overlap). */
75
+ block: string;
76
+ blockChars: number;
77
+ budget: number;
78
+ store: StoreCounts;
79
+ /** optional for backward-compatible reads of pre-v2 files. */
80
+ stats?: SessionStats;
81
+ measured?: Measured | null;
82
+ }
83
+
84
+ export interface BlockedRow {
85
+ ts: string;
86
+ sid: string;
87
+ reason: string;
88
+ }
89
+
90
+ export function statusFile(): string {
91
+ return join(memoryRoot(), "status.json");
92
+ }
93
+
94
+ export function statusDir(): string {
95
+ return join(memoryRoot(), "status");
96
+ }
97
+
98
+ // Session ids are host-generated (ses_…), but sanitize anyway: this string
99
+ // becomes a file name.
100
+ function safeName(sessionID: string): string {
101
+ return sessionID.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
102
+ }
103
+
104
+ export function sessionStatusFile(sessionID: string): string {
105
+ return join(statusDir(), `${safeName(sessionID)}.json`);
106
+ }
107
+
108
+ // LRU cap on per-session files: the badge only ever reads the active session,
109
+ // so old ones are kept for post-hoc inspection up to this count, then pruned
110
+ // by mtime (newest survive; a re-written session refreshes its mtime).
111
+ const SESSION_FILE_CAP = 32;
112
+
113
+ async function pruneStatusDir(): Promise<void> {
114
+ try {
115
+ const names = (await readdir(statusDir())).filter((n) => n.endsWith(".json"));
116
+ if (names.length <= SESSION_FILE_CAP) return;
117
+ const withMtime = await Promise.all(
118
+ names.map(async (n) => {
119
+ try {
120
+ return { n, m: (await stat(join(statusDir(), n))).mtimeMs };
121
+ } catch {
122
+ return { n, m: 0 }; // vanished mid-prune or unstatable: prune first
123
+ }
124
+ }),
125
+ );
126
+ withMtime.sort((a, b) => b.m - a.m);
127
+ for (const f of withMtime.slice(SESSION_FILE_CAP)) {
128
+ await rm(join(statusDir(), f.n), { force: true });
129
+ }
130
+ } catch {
131
+ // pruning is housekeeping; the surfaces tolerate a growing dir
132
+ }
133
+ }
134
+
135
+ async function writeJson(path: string, p: StatusPayload): Promise<boolean> {
136
+ try {
137
+ await Bun.write(path, JSON.stringify(p, null, 2) + "\n");
138
+ return true;
139
+ } catch {
140
+ return false; // swallow — surfaces degrade to "no data", the session does not
141
+ }
142
+ }
143
+
144
+ // Best-effort by design: a status write must never break injection. Writes the
145
+ // per-session file (primary) AND the global latest file (fallback).
146
+ export async function writeStatus(p: StatusPayload): Promise<void> {
147
+ await writeJson(statusFile(), p);
148
+ if (typeof p.sessionID === "string" && p.sessionID) {
149
+ await writeJson(sessionStatusFile(p.sessionID), p);
150
+ await pruneStatusDir();
151
+ }
152
+ }
153
+
154
+ async function readJson(path: string): Promise<StatusPayload | null> {
155
+ try {
156
+ const f = Bun.file(path);
157
+ if (!(await f.exists())) return null;
158
+ const v: unknown = await f.json();
159
+ if (!v || typeof v !== "object") return null;
160
+ const p = v as Partial<StatusPayload>;
161
+ if (typeof p.at !== "string" || typeof p.block !== "string") return null;
162
+ return p as StatusPayload;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Read the status for a session. With a sessionID: ONLY that session's file —
170
+ * a missing file returns null ("mem -") rather than another session's epoch.
171
+ * Without one: the global latest file.
172
+ */
173
+ export async function readStatus(sessionID?: string): Promise<StatusPayload | null> {
174
+ if (sessionID) return readJson(sessionStatusFile(sessionID));
175
+ return readJson(statusFile());
176
+ }
177
+
178
+ // Newest-first last `limit` write_blocked evidence rows, scanning today and
179
+ // yesterday so a block just before midnight stays visible.
180
+ export async function blockedRecent(limit = 6): Promise<BlockedRow[]> {
181
+ const today = dateStr();
182
+ const yesterday = new Date(Date.now() - 86_400_000).toISOString().split("T")[0]!;
183
+ const rows: BlockedRow[] = [];
184
+ for (const day of [yesterday, today]) {
185
+ for (const line of await readLines(join(evidenceDir(), `${day}.logfmt`))) {
186
+ const f = parseLine(line);
187
+ if (!f || f.ev !== "write_blocked") continue;
188
+ rows.push({
189
+ ts: String(f.ts ?? day),
190
+ sid: String(f.sid ?? "?"),
191
+ reason: String(f.reason ?? "?"),
192
+ });
193
+ }
194
+ }
195
+ return rows.slice(-limit).reverse();
196
+ }
197
+
198
+ function ago(iso: string): string {
199
+ const ms = Date.now() - new Date(iso).getTime();
200
+ if (!Number.isFinite(ms) || ms < 0) return "now";
201
+ const m = Math.floor(ms / 60_000);
202
+ if (m < 1) return "just now";
203
+ if (m < 60) return `${m}m ago`;
204
+ const h = Math.floor(m / 60);
205
+ if (h < 24) return `${h}h ago`;
206
+ return `${Math.floor(h / 24)}d ago`;
207
+ }
208
+
209
+ // --- shared formatting (the /memory panel and memory_status render 1:1) ------
210
+
211
+ /** Char counts are exact; tokens are an estimate, chars/4, shown only with "≈". */
212
+ export function estTok(chars: number): number {
213
+ return Math.round(chars / 4);
214
+ }
215
+
216
+ /** 9999 -> "9999"; 10000 -> "10k"; 41234 -> "41.2k". */
217
+ export function fmtCount(n: number): string {
218
+ if (n < 10_000) return String(n);
219
+ const k = n / 1000;
220
+ return `${k >= 100 ? Math.round(k) : Math.round(k * 10) / 10}k`;
221
+ }
222
+
223
+ /** Session-wide prompt-cache hit rate from measured usage; null = nothing measured. */
224
+ export function cacheRate(m?: Measured | null): { read: number; prompt: number; pct: number } | null {
225
+ if (!m) return null;
226
+ const prompt = (m.input || 0) + (m.read || 0) + (m.write || 0);
227
+ if (prompt <= 0) return null;
228
+ return { read: m.read || 0, prompt, pct: Math.round(((m.read || 0) / prompt) * 100) };
229
+ }
230
+
231
+ /**
232
+ * Badge dot state: green = no invalidation since the last measured turn AND
233
+ * last measured reads >= 90%; recent = the epoch moved after the last
234
+ * measurement (a bust we have not yet seen re-warm); amber 50-89%; red < 50%;
235
+ * unknown = nothing measured yet (NEVER show 0% for "no data").
236
+ */
237
+ export type CacheHealth = "green" | "recent" | "amber" | "red" | "unknown";
238
+
239
+ export function cacheHealth(p: StatusPayload): CacheHealth {
240
+ const inv = p.stats?.lastInvalidation?.at;
241
+ if (inv && (!p.measured || inv > p.measured.at)) return "recent";
242
+ const rate = cacheRate(p.measured);
243
+ if (!rate) return "unknown";
244
+ if (rate.pct >= 90) return "green";
245
+ if (rate.pct >= 50) return "amber";
246
+ return "red";
247
+ }
248
+
249
+ export function contextLines(p: StatusPayload): string[] {
250
+ const st = p.stats ?? emptyStats();
251
+ const pct = p.budget > 0 ? Math.round((p.blockChars / p.budget) * 100) : 0;
252
+ return [
253
+ ` injected now ${p.blockChars}c ≈ ${estTok(p.blockChars)} tok · ${pct}% of ${p.budget}c budget`,
254
+ ` this epoch ${p.reason} epoch since ${p.at} (${ago(p.at)}) · rode ${st.epochRequests} request(s)`,
255
+ ` session ${st.requests} request(s) · ${fmtCount(st.cumChars)}c ≈ ${fmtCount(estTok(st.cumChars))} tok rode · abstained ${st.abstained}`,
256
+ ];
257
+ }
258
+
259
+ export function cacheLines(p: StatusPayload): string[] {
260
+ const st = p.stats ?? emptyStats();
261
+ const inv =
262
+ st.invalidations === 0
263
+ ? " invalidations 0 this session"
264
+ : ` invalidations ${st.invalidations} this session · last ${
265
+ st.lastInvalidation ? `${st.lastInvalidation.at.slice(11, 19)} (${st.lastInvalidation.reason})` : "-"
266
+ }`;
267
+ const rate = cacheRate(p.measured);
268
+ const measured = rate
269
+ ? ` measured last turn ${fmtCount(rate.read)}/${fmtCount(rate.prompt)} prompt tok cache-read (${rate.pct}%)`
270
+ : " measured not measured yet — no completed assistant turn";
271
+ return [inv, ` recomputes ${p.recomputes} since server start (all sessions)`, measured];
272
+ }
273
+
274
+ /**
275
+ * Plain-text rendering shared by the memory_status tool (and any other chat
276
+ * client). Sections match the terminal /memory panel one-for-one.
277
+ */
278
+ export function renderMemoryReport(input: {
279
+ status: StatusPayload | null;
280
+ store: StoreCounts;
281
+ blocked: BlockedRow[];
282
+ }): string {
283
+ const { status, store, blocked } = input;
284
+ const out: string[] = ["MEMORY STATUS", ""];
285
+
286
+ if (!status) {
287
+ out.push(
288
+ "CONTEXT",
289
+ " no epoch yet — memory has not injected this session (fresh server, or no prompt scored yet)",
290
+ );
291
+ } else {
292
+ out.push("CONTEXT", ...contextLines(status));
293
+ out.push("", "CACHE", ...cacheLines(status));
294
+ out.push("", "INJECTED BLOCK (verbatim)");
295
+ if (status.block) out.push(...status.block.split("\n").map((l) => ` ${l}`));
296
+ else
297
+ out.push(
298
+ " abstained — your prompt shares no content vocabulary with any memory, so nothing was injected (by design, no noise).",
299
+ );
300
+ }
301
+
302
+ out.push("", "RECENT BLOCKED WRITES");
303
+ if (!blocked.length) out.push(" (none today or yesterday)");
304
+ for (const b of blocked) {
305
+ out.push(` ${b.ts} session ${b.sid} ${b.reason}`);
306
+ }
307
+ out.push(" (reasons are logged via memory_evidence; the gate never deletes — it asks for a rephrase)");
308
+
309
+ out.push(
310
+ "",
311
+ "STORE",
312
+ ` ${store.active} active · ${store.superseded} superseded · ${store.open} open · ${store.scopes} scopes`,
313
+ );
314
+ out.push("", "Terminal (CLI/TUI) users: /memory opens this as a live panel; the footer shows a one-line badge.");
315
+ return out.join("\n");
316
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,295 @@
1
+ // Terminal surface for opencode-self-improvement (the CLI/TUI half of the
2
+ // plugin). Runs in the terminal process, beside the host's OpenTUI renderer:
3
+ // a one-line footer badge (what is in my context + is the cache healthy), a
4
+ // /memory session panel, and toasts when a write gets blocked. All data comes
5
+ // from the per-session status hand-off the server plugin writes (src/status.ts)
6
+ // plus the evidence log — plain file reads, no model, no network. The badge
7
+ // reads ONLY its own session's file: a missing file renders "mem -" rather
8
+ // than another session's epoch. Against a REMOTE server the panel reports that
9
+ // status is local-only (the RPC surface is the follow-up).
10
+ import { watch, type FSWatcher } from "node:fs";
11
+ import { For, Show, createSignal } from "solid-js";
12
+ import { Plugin, usePlugin } from "@opencode/plugin/tui";
13
+ import type { PanelInput } from "@opencode/plugin/tui/context";
14
+ import { memoryRoot, evidenceDir } from "./config.ts";
15
+ import {
16
+ blockedRecent,
17
+ cacheHealth,
18
+ cacheRate,
19
+ cacheLines,
20
+ contextLines,
21
+ readStatus,
22
+ statusDir,
23
+ type BlockedRow,
24
+ type CacheHealth,
25
+ type StatusPayload,
26
+ } from "./status.ts";
27
+
28
+ const PANEL = "self-improvement.memory";
29
+ const POLL_MS = 5000;
30
+
31
+ function age(iso?: string): string {
32
+ if (!iso) return "-";
33
+ const ms = Date.now() - new Date(iso).getTime();
34
+ if (!Number.isFinite(ms) || ms < 0) return "now";
35
+ const m = Math.floor(ms / 60_000);
36
+ if (m < 1) return "just now";
37
+ if (m < 60) return `${m}m`;
38
+ const h = Math.floor(m / 60);
39
+ if (h < 24) return `${h}h`;
40
+ return `${Math.floor(h / 24)}d`;
41
+ }
42
+
43
+ function Section(props: { t: string }) {
44
+ const context = usePlugin();
45
+ return <text fg={context.theme.text.base}>{"\n▍ " + props.t}</text>;
46
+ }
47
+
48
+ export default Plugin.define({
49
+ id: "self-improvement.tui",
50
+ setup(context) {
51
+ const notifyBlocked = context.options?.notifyBlocked !== false;
52
+ const [status, setStatus] = createSignal<StatusPayload | null>(null);
53
+ const [blocked, setBlocked] = createSignal<BlockedRow[]>([]);
54
+ let seenBlockKeys: Set<string> | null = null; // null until first poll (no startup toast storm)
55
+ // The session whose status file we render. Fed by slot inputs (the
56
+ // prompt.footer receives the active session; the panel its own).
57
+ let activeSid: string | undefined;
58
+
59
+ // Dot colors: theme semantic colors when the host provides them, stable
60
+ // hex fallbacks otherwise. Resolved once per setup.
61
+ const th = context.theme as unknown as Record<string, any>;
62
+ const col = (v: unknown, fallback: string): string => (typeof v === "string" ? v : fallback);
63
+ const DOT: Record<CacheHealth, string> = {
64
+ green: col(th.success, "#4ade80"),
65
+ recent: col(th.warning, "#fbbf24"),
66
+ amber: col(th.warning, "#fbbf24"),
67
+ red: col(th.error, col(th.danger, "#f87171")),
68
+ unknown: col(th.text?.muted, col(th.text?.base, "#9ca3af")),
69
+ };
70
+
71
+ // Inline color inside <text> must be a <span> (a nested <text> crashes:
72
+ // add() only accepts strings / TextNodeRenderable / StyledText), and the
73
+ // reconciler's setProperty only honors `style` on text nodes — a direct
74
+ // fg attribute is silently dropped. SpanProps types style as {} (upstream
75
+ // gap), so pass a pre-typed object: assignment skips the excess-prop
76
+ // check, and runtime reads style.fg/style.bg.
77
+ const spanStyle = (fg: string): { fg?: string } => ({ fg });
78
+
79
+ const refresh = async (sid?: string): Promise<void> => {
80
+ if (sid !== undefined) activeSid = sid;
81
+ setStatus(await readStatus(activeSid));
82
+ const rows = await blockedRecent(8);
83
+ setBlocked(rows);
84
+ const keys = new Set(rows.map((r) => `${r.ts}|${r.reason}`));
85
+ if (notifyBlocked && seenBlockKeys) {
86
+ for (const r of rows) {
87
+ if (seenBlockKeys.has(`${r.ts}|${r.reason}`)) continue;
88
+ context.ui.toast.show({
89
+ title: "memory blocked",
90
+ message: `${r.reason} — run /memory for the reason list`,
91
+ variant: "warning",
92
+ duration: 6000,
93
+ sessionID: r.sid,
94
+ });
95
+ }
96
+ }
97
+ seenBlockKeys = keys;
98
+ };
99
+ void refresh();
100
+
101
+ // ---- live updates ---------------------------------------------------------
102
+ // The server rewrites the status files the moment an epoch recomputes or a
103
+ // measured turn lands; polling made the badge lag up to POLL_MS behind its
104
+ // own data. Directories watched (TUI and server share this machine by
105
+ // design; against a remote server there are no files and the badge shows
106
+ // "mem -" regardless). The poll survives as a fallback: it refreshes the
107
+ // age display, reattaches watchers after failures, and attaches the
108
+ // status/ watcher once the server's FIRST write creates the directory.
109
+ // Debounced because Bun.write can surface as several fs events per write
110
+ // and prune touches the directory too.
111
+ let pendingTimer: ReturnType<typeof setTimeout> | undefined;
112
+ const scheduleRefresh = () => {
113
+ if (pendingTimer) clearTimeout(pendingTimer);
114
+ pendingTimer = setTimeout(() => void refresh(), 150);
115
+ };
116
+ let rootWatcher: FSWatcher | null = null;
117
+ let dirWatcher: FSWatcher | null = null;
118
+ let evWatcher: FSWatcher | null = null;
119
+ const attach = (make: () => FSWatcher): FSWatcher | null => {
120
+ try {
121
+ const w = make();
122
+ w.on("error", () => {
123
+ try {
124
+ w.close();
125
+ } catch {}
126
+ if (rootWatcher === w) rootWatcher = null;
127
+ if (dirWatcher === w) dirWatcher = null;
128
+ if (evWatcher === w) evWatcher = null;
129
+ });
130
+ return w;
131
+ } catch {
132
+ return null; // path missing (fresh install) — the poll retries
133
+ }
134
+ };
135
+ const ensureWatchers = () => {
136
+ if (!rootWatcher) rootWatcher = attach(() => watch(memoryRoot(), scheduleRefresh));
137
+ if (!dirWatcher) dirWatcher = attach(() => watch(statusDir(), scheduleRefresh));
138
+ if (!evWatcher) evWatcher = attach(() => watch(evidenceDir(), scheduleRefresh));
139
+ };
140
+ ensureWatchers();
141
+ const timer = setInterval(() => {
142
+ ensureWatchers();
143
+ void refresh();
144
+ }, POLL_MS);
145
+
146
+ // ---- badge: budget use + cache health; home footer keeps store size ----
147
+ context.ui.slot({
148
+ append: "prompt.footer.status",
149
+ render: (input) => {
150
+ // Session switches re-render this slot: adopt the new sid and poll
151
+ // once immediately so the badge does not wait up to POLL_MS.
152
+ if (input.sessionID !== undefined && input.sessionID !== activeSid) {
153
+ activeSid = input.sessionID;
154
+ void refresh();
155
+ }
156
+ const s = status();
157
+ if (!s) return <text fg={context.theme.text.base}>mem -</text>;
158
+ const rate = cacheRate(s.measured);
159
+ return (
160
+ <text fg={context.theme.text.base}>
161
+ <span style={spanStyle(DOT[cacheHealth(s)])}>●</span>
162
+ {` mem ${s.blockChars}/${s.budget}c · cache ${rate ? `${rate.pct}%` : "—"}`}
163
+ </text>
164
+ );
165
+ },
166
+ });
167
+ context.ui.slot({
168
+ append: "home.footer.status",
169
+ render: () => {
170
+ const s = status();
171
+ return <text fg={context.theme.text.base}>{s ? `mem ${s.store.active}` : "mem -"}</text>;
172
+ },
173
+ });
174
+
175
+ // ---- /memory panel --------------------------------------------------------
176
+ context.ui.slot({
177
+ append: "session.panel",
178
+ render: (panel) => (
179
+ <Show when={panel.name === PANEL}>
180
+ <MemoryPanel panel={panel} refresh={refresh} status={status()} blocked={blocked()} />
181
+ </Show>
182
+ ),
183
+ });
184
+
185
+ // Command + slash registration on a persistent invisible slot so it exists
186
+ // while the panel is closed (docs pattern).
187
+ context.ui.slot({
188
+ append: "app",
189
+ render: () => {
190
+ context.keymap.layer(() => ({
191
+ mode: "global",
192
+ commands: [
193
+ {
194
+ id: "self-improvement.memory",
195
+ title: "Memory status panel",
196
+ group: "Memory",
197
+ palette: true,
198
+ slash: { name: "memory" },
199
+ run: () => {
200
+ if (!context.ui.panel.open(PANEL)) {
201
+ context.ui.toast.show({ message: "the memory panel needs an open session", variant: "info" });
202
+ }
203
+ },
204
+ },
205
+ ],
206
+ }));
207
+ return null;
208
+ },
209
+ });
210
+
211
+ return () => {
212
+ clearInterval(timer);
213
+ if (pendingTimer) clearTimeout(pendingTimer);
214
+ for (const w of [rootWatcher, dirWatcher, evWatcher]) {
215
+ try {
216
+ w?.close();
217
+ } catch {}
218
+ }
219
+ };
220
+ },
221
+ });
222
+
223
+ function MemoryPanel(props: {
224
+ panel: PanelInput;
225
+ refresh: (sid?: string) => Promise<void>;
226
+ status: StatusPayload | null;
227
+ blocked: BlockedRow[];
228
+ }) {
229
+ const context = usePlugin();
230
+ context.keymap.layer(() => ({
231
+ commands: [
232
+ {
233
+ id: "self-improvement.memory.refresh",
234
+ title: "Refresh",
235
+ bind: "r",
236
+ run: () => void props.refresh(props.panel.sessionID),
237
+ },
238
+ { id: "self-improvement.memory.fullscreen", title: "Fullscreen", bind: "f", run: props.panel.toggleFullscreen },
239
+ ],
240
+ }));
241
+
242
+ const s = () => props.status;
243
+ return (
244
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
245
+ <text fg={context.theme.text.base}>
246
+ MEMORY — as of {s() ? age(s()!.at) : "never"}
247
+ {s() ? ` · ${s()!.sessionID}` : ""}
248
+ </text>
249
+
250
+ <Section t="CONTEXT" />
251
+ <Show
252
+ when={s()}
253
+ fallback={<text fg={context.theme.text.base}> no epoch yet — nothing injected (fresh server, or no prompt scored)</text>}
254
+ >
255
+ <For each={contextLines(s()!)}>{(line) => <text fg={context.theme.text.base}>{line}</text>}</For>
256
+ </Show>
257
+
258
+ <Section t="CACHE" />
259
+ <Show when={s()} fallback={<text fg={context.theme.text.base}> nothing to report before the first epoch</text>}>
260
+ <For each={cacheLines(s()!)}>{(line) => <text fg={context.theme.text.base}>{line}</text>}</For>
261
+ </Show>
262
+
263
+ <Section t="INJECTED BLOCK (VERBATIM)" />
264
+ <Show when={s()}>
265
+ <Show
266
+ when={s()!.block}
267
+ fallback={
268
+ <text fg={context.theme.text.base}>
269
+ {" abstained — your prompt shares no content vocabulary with any memory, so nothing was injected (by design)"}
270
+ </text>
271
+ }
272
+ >
273
+ <For each={s()!.block.split("\n")}>{(line) => <text fg={context.theme.text.base}>{" " + line}</text>}</For>
274
+ </Show>
275
+ </Show>
276
+
277
+ <Section t="RECENT BLOCKED WRITES" />
278
+ <Show when={props.blocked.length} fallback={<text fg={context.theme.text.base}> (none today or yesterday)</text>}>
279
+ <For each={props.blocked}>
280
+ {(b) => <text fg={context.theme.text.base}>{" " + b.ts.slice(11, 19) + " " + b.reason + " " + b.sid}</text>}
281
+ </For>
282
+ </Show>
283
+
284
+ <Section t="STORE" />
285
+ <Show when={s()}>
286
+ <text fg={context.theme.text.base}>
287
+ {" " + (s()?.store.active ?? 0) + " active · " + (s()?.store.superseded ?? 0) + " superseded · " +
288
+ (s()?.store.open ?? 0) + " open · " + (s()?.store.scopes ?? 0) + " scopes"}
289
+ </text>
290
+ </Show>
291
+
292
+ <text fg={context.theme.text.base}>{"\n r refresh · f fullscreen · esc close"}</text>
293
+ </box>
294
+ );
295
+ }
@@ -125,20 +125,20 @@
125
125
  <textarea id="eContent" placeholder="content"></textarea>
126
126
  <input id="eReason" placeholder="reason (required, e.g. corrected after human review)" style="width:100%" title="Reason">
127
127
  <div class="btns">
128
- <button id="eCancel" class="btn">Cancel</button>
129
- <button id="eSave" class="btn primary" title="Saves a new entry and marks this one superseded">Save (snapshot + supersede)</button>
128
+ <button type="button" id="eCancel" class="btn">Cancel</button>
129
+ <button type="button" id="eSave" class="btn primary" title="Saves a new entry and marks this one superseded">Save (snapshot + supersede)</button>
130
130
  </div>
131
131
  </div>
132
132
  </div>
133
133
 
134
134
  <div id="promptModal" class="modal">
135
135
  <div class="box card">
136
- <h2 id="promptTitle"></h2>
136
+ <h2 id="promptTitle">Confirm</h2>
137
137
  <p id="promptHint" class="muted"></p>
138
138
  <textarea id="promptReason" rows="2" placeholder="reason (required)" title="Reason"></textarea>
139
139
  <div class="btns">
140
- <button id="promptCancel" class="btn">Cancel</button>
141
- <button id="promptOk" class="btn primary"></button>
140
+ <button type="button" id="promptCancel" class="btn">Cancel</button>
141
+ <button type="button" id="promptOk" class="btn primary">OK</button>
142
142
  </div>
143
143
  </div>
144
144
  </div>
@@ -28,6 +28,36 @@ function error(msg: string, status = 400): Response {
28
28
  return json({ error: msg }, status);
29
29
  }
30
30
 
31
+ // Binding 127.0.0.1 keeps other machines out, but not other websites open in
32
+ // the user's browser. Two checks close that:
33
+ // - Host must name this viewer. A DNS-rebinding page reaches 127.0.0.1 under
34
+ // its own hostname, so its requests arrive with a foreign Host.
35
+ // - Writes must be JSON from this viewer's own origin. A cross-site form or
36
+ // no-cors fetch can POST text/plain without a CORS preflight, and every
37
+ // memory edited here is injected into the agent's system prompt.
38
+ // A viewer deliberately exposed under another name (OPENCODE_VIEWER_HOST set
39
+ // to a LAN address, a hostname) can list it in OPENCODE_VIEWER_ALLOWED_HOSTS.
40
+ function allowedHostnames(): Set<string> {
41
+ const extra = (process.env.OPENCODE_VIEWER_ALLOWED_HOSTS ?? "").split(",");
42
+ const names = ["127.0.0.1", "localhost", "::1", HOST, ...extra]
43
+ .map((n) => n.trim().toLowerCase())
44
+ .filter(Boolean)
45
+ .map((n) => (n.includes(":") && !n.startsWith("[") ? `[${n}]` : n));
46
+ return new Set(names);
47
+ }
48
+
49
+ function rejectRequest(req: Request, url: URL, port: number): Response | null {
50
+ if (!allowedHostnames().has(url.hostname) || (url.port || "80") !== String(port)) {
51
+ return error("unexpected Host header", 403);
52
+ }
53
+ if (req.method === "GET" || req.method === "HEAD") return null;
54
+ const origin = req.headers.get("origin");
55
+ if (origin !== null && origin !== url.origin) return error("cross-origin request refused", 403);
56
+ const type = (req.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
57
+ if (type !== "application/json") return error("content-type must be application/json", 415);
58
+ return null;
59
+ }
60
+
31
61
  interface Row {
32
62
  ts: string;
33
63
  type: string;
@@ -86,7 +116,10 @@ export async function startViewer(port: number = PORT): Promise<ViewerServer> {
86
116
  hostname: HOST,
87
117
  port,
88
118
  fetch(req, srv) {
119
+ // Bun builds req.url from the request's Host header.
89
120
  const url = new URL(req.url);
121
+ const rejected = rejectRequest(req, url, server.port ?? port);
122
+ if (rejected) return rejected;
90
123
  if (url.pathname === "/") {
91
124
  return new Response(page, {
92
125
  headers: { "content-type": "text/html; charset=utf-8" },
package/tui.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Convenience re-export for the loose plugin-discovery layout
2
+ // (<config>/plugins/<name>/index.ts + tui.ts). Package installs resolve
3
+ // "./tui" through package.json exports to src/tui.tsx directly.
4
+ export { default } from "./src/tui.tsx";