opencode-self-improvement 0.8.12 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.8.12",
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": {
@@ -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,
@@ -661,7 +663,10 @@ const impl = {
661
663
  // the prompt/context/compaction hooks) — so the system prefix, and with it
662
664
  // the provider prompt cache, survives ordinary requests. The shared LRU
663
665
  // below may evict the whole session state; a later prompt starts it fresh.
664
- 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
+ >();
665
670
  // Latest user prompt per tracked session. A changed store is re-scored
666
671
  // against it even when the change happened during a tool loop with no new
667
672
  // prompt. It is evicted together with the epoch and session ledgers.
@@ -716,6 +721,7 @@ const impl = {
716
721
  },
717
722
  stats: statsFor(sid),
718
723
  measured: measuredTurns.get(sid) ?? null,
724
+ attribution: attributeSession(callsFor(sid), ep.refs),
719
725
  };
720
726
  };
721
727
  const flushStatus = (sid: string): Promise<void> =>
@@ -733,11 +739,14 @@ const impl = {
733
739
  const version = await storeVersion(mems);
734
740
  const cached = epoch.get(sid);
735
741
  if (cached?.version === version) return;
736
- const raw = await relevantKnowledge(latestPrompt.get(sid) ?? "", 5, mems); // widen candidate pool
737
- 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);
738
747
  const at = new Date().toISOString();
739
748
  const reason: "first" | "store_changed" = cached ? "store_changed" : "first";
740
- epoch.set(sid, { version, block, at, reason });
749
+ epoch.set(sid, { version, block, at, reason, refs });
741
750
  await record(
742
751
  "epoch_recompute",
743
752
  {
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/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}>