opencode-self-improvement 0.6.4 → 0.7.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.6.4",
3
+ "version": "0.7.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": {
@@ -29,6 +29,7 @@
29
29
  "files": [
30
30
  "index.ts",
31
31
  "tui.ts",
32
+ "tsconfig.json",
32
33
  "src",
33
34
  "command",
34
35
  "vendor",
@@ -56,7 +57,10 @@
56
57
  "typescript": "^7.0.2"
57
58
  },
58
59
  "scripts": {
59
- "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",
60
64
  "typecheck": "bunx tsc --noEmit -p tsconfig.json",
61
65
  "bench": "bun bench/inject-cost.ts",
62
66
  "bench:recall": "bun bench/recall-quality.ts",
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/tui.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource @opentui/solid */
1
2
  // Terminal surface for opencode-self-improvement (the CLI/TUI half of the
2
3
  // plugin). Runs in the terminal process, beside the host's OpenTUI renderer:
3
4
  // a one-line footer badge (what is in my context + is the cache healthy), a
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "types": ["bun", "node"],
7
+ "skipLibCheck": true,
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "allowImportingTsExtensions": true,
11
+ "erasableSyntaxOnly": true,
12
+ "verbatimModuleSyntax": true,
13
+ "jsx": "preserve",
14
+ "jsxImportSource": "@opentui/solid"
15
+ },
16
+ "include": ["index.ts", "src", "test", "bench", "e2e"]
17
+ }