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 +52 -12
- package/package.json +5 -2
- package/src/index.ts +1 -1
- package/src/inject.ts +30 -9
- package/src/status.ts +28 -9
- package/tsconfig.json +1 -1
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 |
|
|
71
|
-
| B — epoch freeze + 1200-char cap (shipped) | 4 |
|
|
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
|
|
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% |
|
|
127
|
-
| near — reworded, one overlap | 17% |
|
|
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
|
|
139
|
-
|
|
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 (
|
|
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** —
|
|
332
|
-
|
|
333
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
//
|
|
89
|
-
// memory
|
|
90
|
-
//
|
|
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
|
-
|
|
111
|
+
.map((m, i) => {
|
|
112
|
+
let shared = 0;
|
|
97
113
|
let score = 0;
|
|
98
|
-
for (const w of query)
|
|
99
|
-
|
|
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.
|
|
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
|
-
?
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
271
|
-
?
|
|
272
|
-
|
|
273
|
-
|
|
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