opencode-self-improvement 0.6.5 → 0.8.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
@@ -54,6 +54,11 @@ prompt cache on every message. This one is built the other way around:
54
54
  a compaction — and stays byte-identical in between. A stable system prefix
55
55
  means the provider's prompt cache survives ordinary turns instead of being
56
56
  invalidated on every message.
57
+ - **Two-concept evidence gate.** A free-text prompt must share at least two
58
+ distinct concepts with a memory for it to be injected; keyword-length
59
+ queries (one or two words — a deliberate search) need only one. Injection
60
+ stays quiet on single-word coincidences, which `memory_recall` still
61
+ reaches on the pull path — rare and right beats frequent and maybe.
57
62
  - **Hard character budget.** The block is capped at 1200 characters by default
58
63
  (see Options) and trimmed at whole-memory boundaries — never cut mid-sentence.
59
64
  - **Evidence is never injected**, and the standing system digest is two lines.
@@ -67,10 +72,10 @@ store writes) using the plugin's real injection code:
67
72
 
68
73
  | Policy | Recomputes | Injected chars (20 turns) | Avg/turn | Worst block |
69
74
  | --- | ---: | ---: | ---: | ---: |
70
- | A — recompute per prompt, uncapped (naive) | 20 | 29460 | 1473.0 | 1765 |
71
- | B — epoch freeze + 1200-char cap (shipped) | 4 | 19516 | 975.8 | 1139 |
75
+ | A — recompute per prompt, uncapped (naive) | 20 | 25742 | 1287.1 | 1838 |
76
+ | B — epoch freeze + 1200-char cap (shipped) | 4 | 19990 | 999.5 | 1139 |
72
77
 
73
- Policy B injects 33.8% fewer characters — and the bigger saving isn't even in
78
+ Policy B injects 22.3% fewer characters — and the bigger saving isn't even in
74
79
  the character count: B's block is byte-identical between store changes, so the
75
80
  prompt cache stays warm across ordinary turns. Reproduce (byte-identical
76
81
  output):
@@ -123,23 +128,29 @@ right memory (store of 200; k=3 = the production injection limit):
123
128
 
124
129
  | Query, vs. the stored memory | share | R@1 | R@3 |
125
130
  | --- | ---: | ---: | ---: |
126
- | lexical — reuses your project's words | 46% | 100% | 100% |
127
- | near — reworded, one overlap | 17% | 44% | 83% |
131
+ | lexical — reuses your project's words | 46% | 98% | 98% |
132
+ | near — reworded, one overlap | 17% | 28% | 28% |
128
133
  | strict — zero shared words | 37% | 0% | 0% |
129
134
 
130
135
  What the benchmark bought us:
131
136
 
132
137
  - **In-project prompts are overwhelmingly lexical** — you keep saying
133
138
  `bun test`, "the rate limiter", "staging deploys". The shipped matcher
134
- nails those: 100% R@3, flat from store size 50 to 500.
139
+ nails those: 98–100% R@3, flat from store size 50 to 500, and the
140
+ two-concept evidence gate never abstains on one (0 of 48).
135
141
  - **It caught a real bug.** Function words ("the", "what", "how") counted as
136
142
  matches, so nearly half the time the top memory was the wrong one. The
137
143
  matcher now ignores them on the query side: wrong-first rate fell 49% → 34%,
138
- and classic BM25 — a tuned scoring algorithm — no longer beats the shipped
139
- matcher on any class except the hopeless one.
144
+ and the two-concept evidence gate + IDF ranking finished the job: **6%**.
145
+ - **One shared word is a coin flip, so injection stopped playing it.** At the
146
+ near class (a single shared concept) the old matcher force-pushed a guess;
147
+ free-text prompts now abstain and the pull path covers them. The trade,
148
+ exactly: near-class R@3 83% → 28% (the remainder is keyword-length queries,
149
+ which still inject on one concept) for the 34% → 6% wrong-memory number
150
+ above. A quiet turn beats a confident wrong one.
140
151
  - **Strict paraphrase is the real lexical wall**, and we won't pretend
141
152
  otherwise. Nothing keyword-side crosses it. So instead of confident noise the
142
- matcher now goes *silent* when the vocabulary shares nothing (a third of
153
+ matcher now goes *silent* when the vocabulary shares nothing (35 of 39
143
154
  strict queries inject nothing at all), and the agent's `memory_recall` loop
144
155
  remains as the pull path. Embeddings are the only known crossing — the bench
145
156
  exposes a scorer seam if you want to measure them — but requiring an
@@ -211,6 +222,32 @@ After changes, `git pull` and restart opencode.
211
222
 
212
223
  </details>
213
224
 
225
+ ## Merge gate
226
+
227
+ Nothing merges without the full gate: CI runs it as required checks (`lint`,
228
+ `test`, `smoke`), and the same sequence is one command locally:
229
+
230
+ ```sh
231
+ bun run gate # typecheck + lint + unit tests + terminal smoke & BDD
232
+ bun run gate:hooks # once per clone: run the gate automatically on `git push`
233
+ ```
234
+
235
+ **Feature order is BDD-first.** Every new feature starts by adding (or fixing)
236
+ a BDD scenario in `e2e/` describing the behavior as a user would observe it —
237
+ before any implementation code and before unit tests. Then write the unit
238
+ tests for the pieces, then the code until everything is green. If no scenario
239
+ turns red first, the feature has no observable contract.
240
+
241
+ The `e2e/` suite is what keeps a load-error release from shipping again: it
242
+ boots the **real opencode CLI** against each candidate in a fully isolated
243
+ instance (fresh config, fresh db, fresh memory store, empty `HOME`) driven by
244
+ a scripted OpenAI-compatible fake model — no API keys anywhere. Scenarios
245
+ cover loading from the source tree and from the **packed npm artifact
246
+ installed runtime-deps-only** (the same semantics as opencode's plugin
247
+ cache), plus BDD flows for remember, recall, exact duplicates, near duplicates,
248
+ ungrounded claims, and forget. Needs `opencode` on `PATH` locally (or set
249
+ `OPENCODE_BIN`).
250
+
214
251
  ## Options
215
252
 
216
253
  Zero configuration is the default. To tune the plugin, use the object form of
@@ -328,9 +365,12 @@ same data (`memory_status` tool under the hood; no model call):
328
365
  - **CONTEXT** — exact chars + estimated tokens riding now, % of the budget,
329
366
  requests the current epoch has ridden, session-lifetime totals, and how
330
367
  many requests abstained (silence is a feature)
331
- - **CACHE** — epoch invalidations this session with the last reason, server
332
- recompute count, and the *measured* prompt-cache read rate of the last turn
333
- — the receipt, not just the promise
368
+ - **CACHE** — the receipts, in plain language: cache busts this session
369
+ (0 = memory has never rewritten a byte of your history), the measured
370
+ share of prompt tokens the provider served from cache (≥90% reads =
371
+ "memory is cache-invisible"), or — when the provider reports no usage at
372
+ all — the structural proof instead: the same block bytes rode every
373
+ request with zero busts
334
374
  - **INJECTED BLOCK** — the verbatim block riding your prompts, or an explicit
335
375
  "abstained: no vocabulary overlap"
336
376
  - **RECENT BLOCKED WRITES** — that `memory_remember` that didn't stick,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.6.5",
3
+ "version": "0.8.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": {
@@ -57,7 +57,10 @@
57
57
  "typescript": "^7.0.2"
58
58
  },
59
59
  "scripts": {
60
- "test": "bun test",
60
+ "test": "bun test ./test/",
61
+ "test:e2e": "bun test ./e2e/ --timeout 240000",
62
+ "gate": "bun scripts/merge-gate.ts",
63
+ "gate:hooks": "git config core.hooksPath .githooks",
61
64
  "typecheck": "bunx tsc --noEmit -p tsconfig.json",
62
65
  "bench": "bun bench/inject-cost.ts",
63
66
  "bench:recall": "bun bench/recall-quality.ts",
package/src/index.ts CHANGED
@@ -570,7 +570,7 @@ const impl = {
570
570
  editor.add(validated(EmptyArgs, {
571
571
  name: "memory_status",
572
572
  description:
573
- "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.",
573
+ "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 (plain-language cache receipts: bust count, and the provider-reported share of prompt tokens served from cache — the proof that memory never mutates your prefix), the block verbatim, recent blocked writes with gate reasons, and store stats. Reads only — no model call.",
574
574
  options: direct,
575
575
  async execute(): Promise<{ content: string }> {
576
576
  // In-process truth: the live epoch + ledger, fresher than any file.
package/src/inject.ts CHANGED
@@ -82,23 +82,44 @@ export function words(text: string): string[] {
82
82
  // Memory-side tokens are never filtered: a memory stating "the" must not
83
83
  // gain or lose rank from it, only queries drive matching.
84
84
  const QUERY_STOPWORDS = new Set(
85
- "a an and are at be but by can did do does for from had has have how i in into is it its me my no not of on or our so than that the their them then there these they this to us was we were what when where which who why will with you your run runs getting want needs right back yet also just still ever".split(" "),
85
+ "a an and are at be but by can did do does for from had has have how i in into is it its me my no not of on or our so than that the their them then there these they this to us was we were what when where which who why will with you your run runs getting want needs right back yet also just still ever should would could might may must shall ought nothing something anything everything thing things".split(" "),
86
86
  );
87
87
 
88
- // Production relevance ranking: score = number of distinct query tokens the
89
- // memory's (scope+type+content) token set contains. Exported for
90
- // bench/recall-quality.ts — the benchmark must measure the shipped matcher.
88
+ // A free-text prompt long enough to carry >= this many content tokens must
89
+ // share >= 2 distinct concepts with a memory to inject: with a long query
90
+ // vocabulary, one shared token is near-certain coincidence (a live store hit
91
+ // injected a git memory on the word "should", df=1). A keyword-length query
92
+ // is a deliberate search — one shared concept suffices. memory_recall and the
93
+ // contextual pull keep single-overlap matches reachable; injection just stays
94
+ // quiet unless the vocabulary genuinely agrees twice.
95
+ const LONG_QUERY_TOKENS = 3;
96
+
97
+ // Production relevance ranking: candidates must clear the concept-overlap
98
+ // gate, then rank by Robertson-IF of the shared tokens (rare concepts beat
99
+ // piles of common ones — the same idf form the bench's BM25 row uses).
100
+ // Exported for bench/recall-quality.ts — the benchmark measures the shipped
101
+ // matcher.
91
102
  export function scoreMemories(term: string, mems: Memory[], limit: number): Memory[] {
92
103
  const query = new Set(words(term).filter((t) => !QUERY_STOPWORDS.has(t)));
93
104
  if (!query.size) return [];
105
+ const needShared = query.size >= LONG_QUERY_TOKENS ? 2 : 1;
106
+ const hays = mems.map((m) => new Set(words(`${m.scope} ${m.type} ${m.content}`)));
107
+ const df = new Map<string, number>();
108
+ for (const h of hays) for (const t of h) df.set(t, (df.get(t) ?? 0) + 1);
109
+ const n = mems.length || 1;
94
110
  return mems
95
- .map((m) => {
96
- const hay = new Set(words(`${m.scope} ${m.type} ${m.content}`));
111
+ .map((m, i) => {
112
+ let shared = 0;
97
113
  let score = 0;
98
- for (const w of query) if (hay.has(w)) score++;
99
- return { m, score };
114
+ for (const w of query)
115
+ if (hays[i]!.has(w)) {
116
+ shared++;
117
+ const d = df.get(w) ?? 0;
118
+ score += Math.log(1 + (n - d + 0.5) / (d + 0.5));
119
+ }
120
+ return { m, shared, score };
100
121
  })
101
- .filter((s) => s.score > 0)
122
+ .filter((s) => s.shared >= needShared)
102
123
  .sort((a, b) => b.score - a.score || openRank(b.m) - openRank(a.m))
103
124
  .slice(0, limit)
104
125
  .map((s) => s.m);
package/src/status.ts CHANGED
@@ -262,15 +262,34 @@ export function cacheLines(p: StatusPayload): string[] {
262
262
  const st = p.stats ?? emptyStats();
263
263
  const inv =
264
264
  st.invalidations === 0
265
- ? " invalidations 0 this session"
266
- : ` invalidations ${st.invalidations} this session · last ${
267
- st.lastInvalidation ? `${st.lastInvalidation.at.slice(11, 19)} (${st.lastInvalidation.reason})` : "-"
268
- }`;
265
+ ? [
266
+ " cache busts 0 — memory has never rewritten a byte of your past",
267
+ " conversation, so the provider can serve history from cache",
268
+ ]
269
+ : [
270
+ ` cache busts ${st.invalidations} this session · last ${
271
+ st.lastInvalidation ? `${st.lastInvalidation.at.slice(11, 19)} (${st.lastInvalidation.reason})` : "-"
272
+ }`,
273
+ " each memory write between turns costs exactly one; the",
274
+ " prefix re-warms on the following turn",
275
+ ];
269
276
  const rate = cacheRate(p.measured);
270
- const measured = rate
271
- ? ` measured last turn ${fmtCount(rate.read)}/${fmtCount(rate.prompt)} prompt tok cache-read (${rate.pct}%)`
272
- : " measured not measured yet — no completed assistant turn";
273
- return [inv, ` recomputes ${p.recomputes} since server start (all sessions)`, measured];
277
+ const receipts = rate
278
+ ? [
279
+ ` receipts ${fmtCount(rate.read)}/${fmtCount(rate.prompt)} prompt tok served from cache (${rate.pct}%)`,
280
+ rate.pct >= 90
281
+ ? " — memory is cache-invisible: the same bytes ride every turn"
282
+ : " — below ~90% something re-bills the whole history each turn",
283
+ ]
284
+ : [
285
+ " receipts provider reports no cache usage yet — structural proof:",
286
+ ` the same block bytes rode ${st.epochRequests} request(s) with ${st.invalidations} busts`,
287
+ ];
288
+ return [
289
+ ...inv,
290
+ ...receipts,
291
+ ` recomputes ${p.recomputes} block builds since server start (all sessions)`,
292
+ ];
274
293
  }
275
294
 
276
295
  /**
@@ -292,7 +311,7 @@ export function renderMemoryReport(input: {
292
311
  );
293
312
  } else {
294
313
  out.push("CONTEXT", ...contextLines(status));
295
- out.push("", "CACHE", ...cacheLines(status));
314
+ out.push("", "CACHE — the receipts on your prompt-cache cost", ...cacheLines(status));
296
315
  out.push("", "INJECTED BLOCK (verbatim)");
297
316
  if (status.block) out.push(...status.block.split("\n").map((l) => ` ${l}`));
298
317
  else
package/tsconfig.json CHANGED
@@ -13,5 +13,5 @@
13
13
  "jsx": "preserve",
14
14
  "jsxImportSource": "@opentui/solid"
15
15
  },
16
- "include": ["index.ts", "src", "test", "bench"]
16
+ "include": ["index.ts", "src", "test", "bench", "e2e"]
17
17
  }