opencode-self-improvement 0.8.11 → 0.9.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
@@ -225,6 +225,10 @@ Opencode installs the package (and its dependencies) automatically with Bun at
225
225
  startup. Quit and restart opencode; the plugin loads and `/repo-init` installs
226
226
  itself. Nothing else to do.
227
227
 
228
+ The plugin runs on the Bun embedded in your opencode build; if that Bun is
229
+ older than 1.3.0 (this repo's tested floor) the plugin says so once at startup
230
+ and memory features may misbehave.
231
+
228
232
  An unpinned entry resolves `@latest` exactly once and is never re-resolved
229
233
  afterwards, so updates are deliberate: `opencode plugin update
230
234
  opencode-self-improvement`. To install a specific release, append
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.8.11",
3
+ "version": "0.9.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": {
@@ -10,6 +10,9 @@
10
10
  "module": "index.ts",
11
11
  "type": "module",
12
12
  "license": "MIT",
13
+ "engines": {
14
+ "bun": ">=1.3.0"
15
+ },
13
16
  "author": "Alex Bogartz",
14
17
  "repository": {
15
18
  "type": "git",
@@ -0,0 +1,150 @@
1
+ // Memory attribution: which of this session's answers came from an injected
2
+ // memory instead of re-deriving the fact from the filesystem. Pure and
3
+ // deterministic — no model, no I/O, no ambient clock. The caller (src/index.ts)
4
+ // feeds it the session's tool ledger and the refs cited by the memories that
5
+ // were actually injected this session, and renders the result through the
6
+ // shared status builders (tool + TUI panel stay 1:1).
7
+ //
8
+ // Epistemics (label every number by how it was obtained):
9
+ // [measured] an observed fact about tool calls that did or did not happen.
10
+ // [inferred] a coverage statement — memory named something the agent did not
11
+ // re-read — which is consistent with "memory answered it" but does
12
+ // not prove the model used it. Never presented as a certainty.
13
+ import { extractPathRefs } from "./resolver.ts";
14
+ import type { Memory } from "./memory.ts";
15
+
16
+ // A tool call as recorded by the ledger (see src/state.ts ToolResult). Only the
17
+ // fields attribution needs; extra fields are ignored.
18
+ export interface CallLike {
19
+ tool: string;
20
+ arg?: string;
21
+ ts: string;
22
+ }
23
+
24
+ // Facts the injected memories assert, used as the join key against tool calls.
25
+ export interface MemoryRefs {
26
+ paths: string[]; // repo-relative code paths the memories cite
27
+ commands: string[]; // shell commands the memories state (backticked)
28
+ }
29
+
30
+ export interface Attribution {
31
+ // Tier A — command reuse [measured]: the agent ran a remembered command
32
+ // without first reading/searching for its terms.
33
+ commandSaves: number;
34
+ commandsMatched: number; // remembered commands the agent did run
35
+ // Tier B — file coverage: memory named a path; did the agent re-open it?
36
+ pathsCited: number;
37
+ pathsReRead: number; // [measured] cited path WAS opened again this session
38
+ pathsSkipped: number; // [inferred] cited path NOT opened this session
39
+ // Honest denominator for "new work": file lookups memory did not cover.
40
+ readsTotal: number;
41
+ readsNotCovered: number; // [measured] reads of paths no memory cited
42
+ }
43
+
44
+ const READ_TOOLS = new Set(["read", "glob", "grep"]);
45
+ const BASH_TOOLS = new Set(["bash", "shell"]);
46
+
47
+ // Command-shaped bins. A backticked span only counts as a remembered command
48
+ // if it starts with a known runner/build tool AND has arguments, so prose like
49
+ // `the store` or `src/gates.ts` is never mistaken for a command.
50
+ const BIN_RE =
51
+ /^(?:bun|bunx|npm|npx|pnpm|yarn|node|make|go|cargo|pytest|tsc|gh|git|docker|python3?|deno|uv|pip3?)(?:\s|$)/;
52
+
53
+ // Small words that carry no lookup signal; a command "save" is credited only
54
+ // when nothing the agent read earlier mentioned a distinctive term.
55
+ const STOP = new Set(["run", "the", "and", "for", "with", "to", "all", "test", "tests"]);
56
+
57
+ function norm(s: string | undefined): string {
58
+ return (s ?? "").toLowerCase().replace(/\s+/g, " ").trim();
59
+ }
60
+
61
+ // Backticked shell commands a memory asserts. Conservative on purpose: false
62
+ // positives here would inflate the headline "saves" number, so we require a
63
+ // known bin plus at least one argument.
64
+ export function extractCommandRefs(content: string): string[] {
65
+ if (content.length > 10_000) return [];
66
+ const out: string[] = [];
67
+ for (const m of content.matchAll(/`([^`\n]{2,120})`/g)) {
68
+ const s = (m[1] ?? "").trim();
69
+ if (s.includes(" ") && BIN_RE.test(s)) out.push(s);
70
+ }
71
+ return [...new Set(out)];
72
+ }
73
+
74
+ export function deriveMemoryRefs(mems: Memory[]): MemoryRefs {
75
+ const paths = new Set<string>();
76
+ const commands = new Set<string>();
77
+ for (const m of mems) {
78
+ for (const p of extractPathRefs(m.content)) paths.add(p.replace(/:\d+$/, ""));
79
+ for (const c of extractCommandRefs(m.content)) commands.add(c);
80
+ }
81
+ return { paths: [...paths], commands: [...commands] };
82
+ }
83
+
84
+ // A read/grep arg "hits" a cited path when they name the same file: exact,
85
+ // suffix, or same basename (memory cites `src/gates.ts`, the agent opened
86
+ // `/abs/src/gates.ts` or `gates.ts`). Deliberately loose — Tier B is coverage,
87
+ // not an exact-path proof.
88
+ function pathHit(arg: string, path: string): boolean {
89
+ if (!arg || !path) return false;
90
+ const base = path.split("/").pop() || path;
91
+ return arg === path || arg.endsWith("/" + base) || path.endsWith("/" + arg) || arg.includes(path);
92
+ }
93
+
94
+ function distinctiveTokens(command: string): string[] {
95
+ return command.split(/[^a-z0-9_.-]+/).filter((t) => t.length > 2 && !STOP.has(t) && !BIN_RE.test(t));
96
+ }
97
+
98
+ export function attributeSession(calls: CallLike[], refs: MemoryRefs): Attribution {
99
+ const readCalls = calls.filter((c) => READ_TOOLS.has(c.tool));
100
+ const bashCalls = calls.filter((c) => BASH_TOOLS.has(c.tool));
101
+
102
+ // ---- Tier A: remembered command run without a prior lookup of its terms ----
103
+ let commandSaves = 0;
104
+ let commandsMatched = 0;
105
+ const credited = new Set<string>();
106
+ for (const b of bashCalls) {
107
+ const arg = norm(b.arg);
108
+ if (!arg) continue;
109
+ for (const mem of refs.commands) {
110
+ const mc = norm(mem);
111
+ if (mc.length < 3 || credited.has(mc)) continue;
112
+ if (!(arg.includes(mc) || mc.includes(arg))) continue;
113
+ commandsMatched++;
114
+ credited.add(mc);
115
+ const terms = distinctiveTokens(mc);
116
+ // "Looked it up" = an earlier file-touching call mentioned any distinctive
117
+ // term of the command. If none did, the command came from context, not a
118
+ // fresh search — attributable to memory (which was injected).
119
+ const lookedUp = readCalls.some((r) => {
120
+ if (!(r.ts < b.ts)) return false;
121
+ const ra = norm(r.arg);
122
+ return !!ra && terms.some((t) => ra.includes(t));
123
+ });
124
+ if (!lookedUp) commandSaves++;
125
+ break;
126
+ }
127
+ }
128
+
129
+ // ---- Tier B: coverage of the paths the memories named ----
130
+ const readArgs = readCalls.map((c) => norm(c.arg)).filter(Boolean);
131
+ let pathsReRead = 0;
132
+ for (const p of refs.paths) {
133
+ if (readArgs.some((ra) => pathHit(ra, norm(p)))) pathsReRead++;
134
+ }
135
+ const pathsCited = refs.paths.length;
136
+ const pathsSkipped = pathsCited - pathsReRead;
137
+
138
+ // ---- New work: file lookups no memory covered ----
139
+ const readsNotCovered = readArgs.filter((ra) => !refs.paths.some((p) => pathHit(ra, norm(p)))).length;
140
+
141
+ return {
142
+ commandSaves,
143
+ commandsMatched,
144
+ pathsCited,
145
+ pathsReRead,
146
+ pathsSkipped,
147
+ readsTotal: readArgs.length,
148
+ readsNotCovered,
149
+ };
150
+ }
package/src/index.ts CHANGED
@@ -36,10 +36,11 @@ import {
36
36
  compactionAnchor,
37
37
  contextualPull,
38
38
  formatMemory,
39
- relevantKnowledge,
39
+ selectRelevant,
40
40
  storeVersion,
41
41
  systemDigest,
42
42
  } from "./inject.ts";
43
+ import { attributeSession, deriveMemoryRefs, type MemoryRefs } from "./attribution.ts";
43
44
  import { formatInit, runInit } from "./init.ts";
44
45
  import { ensureInitCommand, ensureMemoryReportCommand } from "./command.ts";
45
46
  import {
@@ -53,6 +54,7 @@ import {
53
54
  } from "./status.ts";
54
55
  import {
55
56
  afterTool,
57
+ callsFor,
56
58
  checkNewSession,
57
59
  evidenceCalls,
58
60
  firstFrontierTap,
@@ -68,6 +70,7 @@ import { readLines } from "./logfmt.ts";
68
70
  import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
69
71
  import { redactSecrets } from "./secrets.ts";
70
72
  import { flattenMemoryText } from "./sanitize.ts";
73
+ import { bunFloorWarning } from "./runtime.ts";
71
74
 
72
75
  // Tool/input boundaries are zod schemas (src/schema.ts): the JSON Schema the
73
76
  // host sees is GENERATED from them, and raw args are re-parsed at execution.
@@ -130,6 +133,7 @@ interface PluginCtx {
130
133
  delivery?: "steer" | "queue";
131
134
  }) => Promise<unknown>;
132
135
  context?: (input: { sessionID: string }) => Promise<unknown>;
136
+ get?: (input: { sessionID: string }) => Promise<unknown>;
133
137
  };
134
138
  event?: {
135
139
  subscribe: (o?: { signal?: AbortSignal }) => AsyncIterable<unknown>;
@@ -167,6 +171,10 @@ function resultMeta(result: unknown): Record<string, unknown> {
167
171
 
168
172
  const PLUGIN_ID = "opencode-self-improvement";
169
173
 
174
+ // The setup-time Bun-floor warning fires once per process (setup can run for
175
+ // more than one location).
176
+ let bunFloorChecked = false;
177
+
170
178
  // The real setup implementation, written against the loose local PluginCtx
171
179
  // (the only members of the V2 context this plugin actually touches).
172
180
  const impl = {
@@ -186,6 +194,15 @@ const impl = {
186
194
  const budget = Number(ctx.options?.injectBudget) || injectBudget();
187
195
  const openBudget = Number(ctx.options?.frontierBudget) || frontierBudget(budget);
188
196
 
197
+ // Warn-once Bun floor check (same setup-time warning style as the
198
+ // captureModel warnings below): opencode embeds Bun, and the store code's
199
+ // fs failure modes are Bun-version dependent, so flag an old host.
200
+ if (!bunFloorChecked) {
201
+ bunFloorChecked = true;
202
+ const floorWarning = bunFloorWarning(typeof Bun === "undefined" ? undefined : Bun.version);
203
+ if (floorWarning) console.warn(floorWarning);
204
+ }
205
+
189
206
  // Mirror the /repo-init slash command into opencode's global commands dir
190
207
  // (idempotent, write-if-missing). OpenCode 2.x loads `*.md` from
191
208
  // ~/.config/opencode/commands, so this is the canonical V2 install path.
@@ -212,8 +229,10 @@ const impl = {
212
229
  // Identity-event race backstop: a resumed session's FIRST turn can write
213
230
  // before the subscription has delivered its execution.started, and the
214
231
  // module-scope fallback is the last setup instance — possibly another
215
- // workspace. The session context API is synchronous and its messages
216
- // carry location.directory, so bind once from them for unmapped sids.
232
+ // workspace. Bind once from the session context API for unmapped sids —
233
+ // but note host 2.1.4 sends message payloads WITHOUT any location field,
234
+ // so this path is a legacy-shape best-effort; the session-row fallback
235
+ // below is what actually resolves serve-host sessions.
217
236
  // One-shot: never overwrites an event-bound entry.
218
237
  const bindingSids = new Set<string>();
219
238
  async function bindSessionFromMessages(sid: string | undefined): Promise<void> {
@@ -239,6 +258,37 @@ const impl = {
239
258
  }
240
259
  }
241
260
 
261
+ // Serve-host reality (recorded on 2.1.4 `serve --service`): the plugin
262
+ // instance loads AFTER the session was created, so `session.created` — the
263
+ // only event carrying a location — is never delivered; `execution.started`
264
+ // arrives with just {sessionID}. The session ROW is the remaining source
265
+ // of truth: the host's session get returns `location.directory`. One
266
+ // lookup per session; a miss is remembered so a host without the API is
267
+ // not re-asked on every turn (bounded LRU, same cap as the scope cache).
268
+ const apiBindTried = new Set<string>();
269
+ const apiBindOrder = new SessionLru(SESSION_CAP, (evicted) => apiBindTried.delete(evicted));
270
+ async function bindSessionFromApi(sid: string | undefined): Promise<void> {
271
+ if (!sid || sid === "?" || sessionDir(sid) || apiBindTried.has(sid)) return;
272
+ apiBindTried.add(sid);
273
+ apiBindOrder.touch(sid);
274
+ try {
275
+ const got = (await ctx.session?.get?.({ sessionID: sid })) as Record<string, any> | undefined;
276
+ const row = (got?.data ?? got) as Record<string, any> | undefined;
277
+ const dir = row?.location?.directory ?? row?.directory;
278
+ if (typeof dir === "string" && dir) rememberSessionDir(sid, dir);
279
+ } catch {
280
+ // no session.get on this host (or unknown session): stay unbound
281
+ }
282
+ }
283
+
284
+ // Identity must resolve BEFORE a row is stamped or a read is partitioned:
285
+ // try the (legacy) message payloads, then the session row.
286
+ async function bindSessionIdentity(sid: string | undefined): Promise<void> {
287
+ if (!sid || sid === "?" || sessionDir(sid)) return;
288
+ await bindSessionFromMessages(sid);
289
+ if (!sessionDir(sid)) await bindSessionFromApi(sid);
290
+ }
291
+
242
292
  // ---- tools --------------------------------------------------------------
243
293
  await ctx.tool?.transform((editor) => {
244
294
  const direct = { codemode: false } as const;
@@ -258,7 +308,7 @@ const impl = {
258
308
  status: args.status,
259
309
  repo: args.global ? "global" : undefined,
260
310
  };
261
- await bindSessionFromMessages(getCurrent().sid);
311
+ await bindSessionIdentity(getCurrent().sid);
262
312
  const outcome = await runWriteGate(candidate, gateContext());
263
313
  if (outcome.action === "promoted") return { content: `Remembered: ${args.type} in ${args.scope}` };
264
314
  if (outcome.action === "collapsed") return { content: `Duplicate ignored: ${args.type}/${args.scope}` };
@@ -276,7 +326,7 @@ const impl = {
276
326
  "Initialize repository memory and knowledge graph. Ensures the codebase-memory graph for this repo is indexed (one-shot `cli` spawn; no session MCP needed) and re-derives build/lint/test/typecheck command memories through the write gate, then stores an init stamp keyed on git HEAD. Idempotent: a no-op when the stamp matches HEAD, unless force=true (refresh after a refactor).",
277
327
  options: direct,
278
328
  async execute(args): Promise<{ content: string }> {
279
- await bindSessionFromMessages(getCurrent().sid);
329
+ await bindSessionIdentity(getCurrent().sid);
280
330
  const summary = await runInit({ liveRoot: gateContext().liveRoot, force: args.force, mode: args.mode });
281
331
  return { content: formatInit(summary) };
282
332
  },
@@ -323,7 +373,7 @@ const impl = {
323
373
  "Update an existing memory (gated like remember; the prior entry is superseded, not erased). `query` selects which row to update: its exact id (ts), a clock fragment (e.g. HH:MM:SS.mmm), or a unique substring of its content. Omitting `query` targets the most recent active row of scope+type. An unmatched or ambiguous `query` fails closed and writes nothing.",
324
374
  options: direct,
325
375
  async execute(args): Promise<{ content: string }> {
326
- await bindSessionFromMessages(getCurrent().sid);
376
+ await bindSessionIdentity(getCurrent().sid);
327
377
  const prior = (await loadAll()).filter(
328
378
  (m) => m.scope === args.scope && m.type === args.type && isActive(m) && isVisibleHere(m),
329
379
  );
@@ -613,7 +663,10 @@ const impl = {
613
663
  // the prompt/context/compaction hooks) — so the system prefix, and with it
614
664
  // the provider prompt cache, survives ordinary requests. The shared LRU
615
665
  // below may evict the whole session state; a later prompt starts it fresh.
616
- const epoch = new Map<string, { version: string; block: string; at: string; reason: "first" | "store_changed" }>();
666
+ const epoch = new Map<
667
+ string,
668
+ { version: string; block: string; at: string; reason: "first" | "store_changed"; refs: MemoryRefs }
669
+ >();
617
670
  // Latest user prompt per tracked session. A changed store is re-scored
618
671
  // against it even when the change happened during a tool loop with no new
619
672
  // prompt. It is evicted together with the epoch and session ledgers.
@@ -668,6 +721,7 @@ const impl = {
668
721
  },
669
722
  stats: statsFor(sid),
670
723
  measured: measuredTurns.get(sid) ?? null,
724
+ attribution: attributeSession(callsFor(sid), ep.refs),
671
725
  };
672
726
  };
673
727
  const flushStatus = (sid: string): Promise<void> =>
@@ -685,11 +739,14 @@ const impl = {
685
739
  const version = await storeVersion(mems);
686
740
  const cached = epoch.get(sid);
687
741
  if (cached?.version === version) return;
688
- const raw = await relevantKnowledge(latestPrompt.get(sid) ?? "", 5, mems); // widen candidate pool
689
- const block = raw ? capToBudget(raw, budget) : "";
742
+ const sel = await selectRelevant(latestPrompt.get(sid) ?? "", 5, mems); // widen candidate pool
743
+ const block = sel.block ? capToBudget(sel.block, budget) : "";
744
+ // Refs the injected rows assert (paths + commands), the join key for
745
+ // attribution. Derived once per epoch — the block is frozen, so is this.
746
+ const refs = deriveMemoryRefs(sel.selected);
690
747
  const at = new Date().toISOString();
691
748
  const reason: "first" | "store_changed" = cached ? "store_changed" : "first";
692
- epoch.set(sid, { version, block, at, reason });
749
+ epoch.set(sid, { version, block, at, reason, refs });
693
750
  await record(
694
751
  "epoch_recompute",
695
752
  {
@@ -794,7 +851,7 @@ const impl = {
794
851
  sessions.touch(sid);
795
852
  markTurn(sid);
796
853
  await checkNewSession(sid);
797
- await bindSessionFromMessages(sid);
854
+ await bindSessionIdentity(sid);
798
855
  latestPrompt.set(sid, event?.prompt?.text ?? "");
799
856
  await refreshEpoch(sid);
800
857
  }
@@ -850,6 +907,9 @@ const impl = {
850
907
  const dir =
851
908
  info.location?.directory ?? d.location?.directory ?? ev.location?.directory ?? info.directory;
852
909
  if (typeof sid === "string" && typeof dir === "string") rememberSessionDir(sid, dir);
910
+ // 2.1.4 serve sends execution.started with no location at all:
911
+ // resolve the session row instead of leaving the session unbound.
912
+ else if (typeof sid === "string") void guarded("session bind (api)", () => bindSessionFromApi(sid));
853
913
  continue;
854
914
  }
855
915
  if (ev?.type !== "message.updated") continue;
package/src/inject.ts CHANGED
@@ -239,14 +239,29 @@ export async function systemDigest(
239
239
  );
240
240
  }
241
241
 
242
- export async function relevantKnowledge(message: string, limit = 3, rows?: Memory[]): Promise<string> {
242
+ // Select the rows relevant to a prompt and render the injection block, AND
243
+ // return the rows themselves so attribution (src/attribution.ts) can join the
244
+ // injected facts against the session's later tool calls. `relevantKnowledge`
245
+ // keeps its string contract for every existing caller; this is the richer seam.
246
+ export async function selectRelevant(
247
+ message: string,
248
+ limit = 3,
249
+ rows?: Memory[],
250
+ ): Promise<{ block: string; selected: Memory[] }> {
243
251
  const text = message.trim();
244
- if (!text) return "";
252
+ if (!text) return { block: "", selected: [] };
245
253
  // Repo-scoped: model-facing reads see this repo + global rows.
246
254
  const mems = rows ?? visibleHere((await loadAll()).filter(isActive));
247
255
  const top = scoreMemories(text, mems, limit);
248
- if (!top.length) return "";
249
- return `## Relevant durable memory${FRAME}\n${top.map(formatMemory).join("\n")}`;
256
+ if (!top.length) return { block: "", selected: [] };
257
+ return {
258
+ block: `## Relevant durable memory${FRAME}\n${top.map(formatMemory).join("\n")}`,
259
+ selected: top,
260
+ };
261
+ }
262
+
263
+ export async function relevantKnowledge(message: string, limit = 3, rows?: Memory[]): Promise<string> {
264
+ return (await selectRelevant(message, limit, rows)).block;
250
265
  }
251
266
 
252
267
  // Deterministic fingerprint of the rows that can affect injection. Hash the
package/src/runtime.ts ADDED
@@ -0,0 +1,40 @@
1
+ // Bun version floor. The plugin runs inside opencode, whose v2 compiled
2
+ // binaries embed Bun (upstream builds with BUN_COMPILE_RELEASE=bun-v1.4.4;
3
+ // older v2 builds embed older bun). The store code's filesystem calls have
4
+ // version-divergent failure modes — e.g. DirHandle.close() behaves differently
5
+ // on some Bun+Windows combos — so an old host shows up as odd memory errors,
6
+ // not as a clean "unsupported" signal. MIN_BUN is the lower bound of the Bun
7
+ // versions this repo has actually been run against: 1.3.10 and 1.4.4. Pure
8
+ // module: nothing here reads the environment; the caller (src/index.ts)
9
+ // injects the version string, so these helpers are unit-testable anywhere.
10
+
11
+ export const MIN_BUN: readonly [number, number, number] = [1, 3, 0];
12
+
13
+ // Pulls the first x.y.z triple out of a version string: "1.4.4",
14
+ // "v1.4.4-canary" and "bun-v1.4.4" all parse; junk or a partial version
15
+ // (no patch component) yields null. Never throws.
16
+ export function parseBunVersion(raw: string): [number, number, number] | null {
17
+ const m = /v?(\d+)\.(\d+)\.(\d+)/.exec(raw);
18
+ if (!m) return null;
19
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
20
+ }
21
+
22
+ // Numeric (not lexical) triple compare: 1.10.0 > 1.3.0.
23
+ function cmpTriples(a: readonly number[], b: readonly number[]): number {
24
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
25
+ }
26
+
27
+ // One-line startup warning when the host's Bun is below MIN_BUN or its version
28
+ // could not be parsed; null when the version looks fine. Warn-only by design:
29
+ // an old embedded bun should be visible, but must not stop memory from
30
+ // loading. The caller decides how often to emit it (src/index.ts: once).
31
+ export function bunFloorWarning(versionString: string | undefined): string | null {
32
+ const floor = MIN_BUN.join(".");
33
+ const seen = typeof versionString === "string" ? parseBunVersion(versionString) : null;
34
+ if (seen) {
35
+ if (cmpTriples(seen, MIN_BUN) >= 0) return null;
36
+ return `[opencode-self-improvement] Bun ${seen.join(".")} is below the supported minimum ${floor}; memory features may misbehave.`;
37
+ }
38
+ const shown = versionString === undefined ? "unknown" : JSON.stringify(versionString);
39
+ return `[opencode-self-improvement] Bun version ${shown} is unrecognized (supported minimum ${floor}); memory features may misbehave.`;
40
+ }
package/src/state.ts CHANGED
@@ -10,6 +10,11 @@ export interface ToolResult {
10
10
  status: "FAIL" | "PASS" | null;
11
11
  cmd?: string;
12
12
  gateCheck?: boolean;
13
+ // Input arg that identifies the target (command / pattern / path), kept for
14
+ // memory attribution (src/attribution.ts): what the call was looking at.
15
+ arg?: string;
16
+ // Turn index (from markTurn) at the time of the call, for ordering.
17
+ turn?: number;
13
18
  }
14
19
 
15
20
  export interface FirstTap {
package/src/status.ts CHANGED
@@ -16,6 +16,7 @@ import { readdir, rm, stat } from "node:fs/promises";
16
16
  import { join } from "node:path";
17
17
  import { dateStr, evidenceDir, memoryRoot } from "./config.ts";
18
18
  import { parseLine, readLines } from "./logfmt.ts";
19
+ import type { Attribution } from "./attribution.ts";
19
20
 
20
21
  export interface StoreCounts {
21
22
  active: number;
@@ -81,6 +82,8 @@ export interface StatusPayload {
81
82
  /** optional for backward-compatible reads of pre-v2 files. */
82
83
  stats?: SessionStats;
83
84
  measured?: Measured | null;
85
+ /** memory-vs-files attribution for the session; null/undefined = not computed. */
86
+ attribution?: Attribution | null;
84
87
  }
85
88
 
86
89
  export interface BlockedRow {
@@ -292,6 +295,59 @@ export function cacheLines(p: StatusPayload): string[] {
292
295
  ];
293
296
  }
294
297
 
298
+ /**
299
+ * CONTEXT WINDOW — the "is memory bloating my context?" answer, built ONLY from
300
+ * numbers we trust: the exact injected block (we wrote those bytes), the
301
+ * provider's own cache receipts, and the memory-caused bust count. No estimated
302
+ * decomposition of the system prompt or history (we cannot measure those); the
303
+ * one honest share we can state is memory as a slice of what was actually sent.
304
+ */
305
+ export function windowLines(p: StatusPayload): string[] {
306
+ const memTok = estTok(p.blockChars);
307
+ const pct = p.budget > 0 ? Math.round((p.blockChars / p.budget) * 100) : 0;
308
+ const out: string[] = [
309
+ ` memory slice ${p.blockChars}c ≈ ${memTok} tok · ${pct}% of the ${p.budget}c inject budget (hard-capped by design)`,
310
+ ];
311
+ const m = p.measured;
312
+ const used = m ? (m.input || 0) + (m.read || 0) + (m.write || 0) : 0;
313
+ if (used > 0) {
314
+ const share = Math.round((memTok / used) * 1000) / 10;
315
+ const rate = cacheRate(m);
316
+ out.push(` of what you sent ≈ ${fmtCount(used)} prompt tok · memory is ${share}% of it`);
317
+ out.push(
318
+ rate
319
+ ? ` cache served ${rate.pct}% of that prompt from cache → memory rides free after turn 1`
320
+ : " cache served provider has not reported a split yet",
321
+ );
322
+ } else {
323
+ out.push(" of what you sent not measured yet — receipts appear after the first assistant turn");
324
+ }
325
+ const busts = p.stats?.invalidations ?? 0;
326
+ out.push(
327
+ ` memory busts ${busts} this session${busts ? "" : " — memory never invalidated your cache prefix"}`,
328
+ );
329
+ return out;
330
+ }
331
+
332
+ /**
333
+ * MEMORY ATTRIBUTION — which answers came from memory instead of re-reading
334
+ * files. Every line is labeled [measured] (an observed tool-call fact) or
335
+ * [inferred] (a coverage statement). Misses are shown alongside saves so the
336
+ * panel reads as an audit, not marketing.
337
+ */
338
+ export function attributionLines(p: StatusPayload): string[] {
339
+ const a = p.attribution;
340
+ if (!a) return [" not computed — no memory was injected this session"];
341
+ if (a.pathsCited === 0 && a.commandsMatched === 0 && a.readsTotal === 0) {
342
+ return [" the injected memory cited no file paths or commands yet — nothing to attribute"];
343
+ }
344
+ return [
345
+ ` Tier A · commands ${a.commandSaves} lookup(s) avoided [measured] · ran a remembered command with no prior file lookup`,
346
+ ` Tier B · coverage cited ${a.pathsCited} · re-read ${a.pathsReRead} [measured] · not opened ${a.pathsSkipped} [inferred]`,
347
+ ` new work ${a.readsNotCovered}/${a.readsTotal} file lookups had no memory coverage [measured]`,
348
+ ];
349
+ }
350
+
295
351
  /**
296
352
  * Plain-text rendering shared by the memory_status tool (and any other chat
297
353
  * client). Sections match the terminal /memory panel one-for-one.
@@ -312,12 +368,14 @@ export function renderMemoryReport(input: {
312
368
  } else {
313
369
  out.push("CONTEXT", ...contextLines(status));
314
370
  out.push("", "CACHE — the receipts on your prompt-cache cost", ...cacheLines(status));
371
+ out.push("", "CONTEXT WINDOW — is memory bloating your context?", ...windowLines(status));
315
372
  out.push("", "INJECTED BLOCK (verbatim)");
316
373
  if (status.block) out.push(...status.block.split("\n").map((l) => ` ${l}`));
317
374
  else
318
375
  out.push(
319
376
  " abstained — your prompt shares no content vocabulary with any memory, so nothing was injected (by design, no noise).",
320
377
  );
378
+ out.push("", "MEMORY ATTRIBUTION — answered from memory vs re-reading files", ...attributionLines(status));
321
379
  }
322
380
 
323
381
  out.push("", "RECENT BLOCKED WRITES");
package/src/triggers.ts CHANGED
@@ -93,8 +93,16 @@ export async function afterTool(input: AfterInput, out: AfterOutput): Promise<vo
93
93
  const status = classify(tool, out);
94
94
  const ts = new Date().toISOString();
95
95
 
96
- const rec: ToolResult = { call, tool, summary: summarize(text), ts, status: null };
97
96
  const s = session(sid);
97
+ const rec: ToolResult = {
98
+ call,
99
+ tool,
100
+ summary: summarize(text),
101
+ ts,
102
+ status: null,
103
+ arg: cmd || undefined,
104
+ turn: s.turns,
105
+ };
98
106
  const family = tool === "bash" || tool === "shell" ? gateCmd(cmd) : null;
99
107
 
100
108
  // Generic capture (data, not chat).
@@ -183,6 +191,11 @@ export function evidenceCalls(sid: string): string[] {
183
191
  return session(sid).calls.map((c) => c.call);
184
192
  }
185
193
 
194
+ // The full per-session tool ledger, for memory attribution (src/attribution.ts).
195
+ export function callsFor(sid: string): ToolResult[] {
196
+ return session(sid).calls;
197
+ }
198
+
186
199
  export function hasGateCheck(sid: string): boolean {
187
200
  return session(sid).calls.some((c) => c.gateCheck === true);
188
201
  }
package/src/tui.tsx CHANGED
@@ -14,6 +14,7 @@ import { Plugin, usePlugin } from "@opencode/plugin/tui";
14
14
  import type { PanelInput } from "@opencode/plugin/tui/context";
15
15
  import { memoryRoot, evidenceDir } from "./config.ts";
16
16
  import {
17
+ attributionLines,
17
18
  blockedRecent,
18
19
  cacheHealth,
19
20
  cacheRate,
@@ -21,6 +22,7 @@ import {
21
22
  contextLines,
22
23
  readStatus,
23
24
  statusDir,
25
+ windowLines,
24
26
  type BlockedRow,
25
27
  type CacheHealth,
26
28
  type StatusPayload,
@@ -261,6 +263,11 @@ function MemoryPanel(props: {
261
263
  <For each={cacheLines(s()!)}>{(line) => <text fg={context.theme.text.base}>{line}</text>}</For>
262
264
  </Show>
263
265
 
266
+ <Section t="CONTEXT WINDOW — is memory bloating your context?" />
267
+ <Show when={s()} fallback={<text fg={context.theme.text.base}> nothing injected yet</text>}>
268
+ <For each={windowLines(s()!)}>{(line) => <text fg={context.theme.text.base}>{line}</text>}</For>
269
+ </Show>
270
+
264
271
  <Section t="INJECTED BLOCK (VERBATIM)" />
265
272
  <Show when={s()}>
266
273
  <Show
@@ -275,6 +282,11 @@ function MemoryPanel(props: {
275
282
  </Show>
276
283
  </Show>
277
284
 
285
+ <Section t="MEMORY ATTRIBUTION — answered from memory vs re-reading files" />
286
+ <Show when={s()} fallback={<text fg={context.theme.text.base}> nothing injected yet</text>}>
287
+ <For each={attributionLines(s()!)}>{(line) => <text fg={context.theme.text.base}>{line}</text>}</For>
288
+ </Show>
289
+
278
290
  <Section t="RECENT BLOCKED WRITES" />
279
291
  <Show when={props.blocked.length} fallback={<text fg={context.theme.text.base}> (none today or yesterday)</text>}>
280
292
  <For each={props.blocked}>