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 +4 -0
- package/package.json +4 -1
- package/src/attribution.ts +150 -0
- package/src/index.ts +71 -11
- package/src/inject.ts +19 -4
- package/src/runtime.ts +40 -0
- package/src/state.ts +5 -0
- package/src/status.ts +58 -0
- package/src/triggers.ts +14 -1
- package/src/tui.tsx +12 -0
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.
|
|
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
|
-
|
|
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.
|
|
216
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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<
|
|
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
|
|
689
|
-
const block =
|
|
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
|
|
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
|
-
|
|
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
|
|
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}>
|