opencode-self-improvement 0.6.2 → 0.6.4
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 +1 -1
- package/src/index.ts +67 -9
- package/src/memory.ts +7 -4
- package/src/repokey.ts +48 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-self-improvement",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
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": {
|
package/src/index.ts
CHANGED
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
onCompacting,
|
|
63
63
|
} from "./triggers.ts";
|
|
64
64
|
import { getCurrent, setCurrent } from "./state.ts";
|
|
65
|
-
import { resolveRepo, setRepoScope } from "./repokey.ts";
|
|
65
|
+
import { resolveRepo, setRepoScope, rememberSessionDir, sessionDir } from "./repokey.ts";
|
|
66
66
|
import { readLines } from "./logfmt.ts";
|
|
67
67
|
import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
|
|
68
68
|
|
|
@@ -168,12 +168,13 @@ const PLUGIN_ID = "opencode-self-improvement";
|
|
|
168
168
|
// (the only members of the V2 context this plugin actually touches).
|
|
169
169
|
const impl = {
|
|
170
170
|
async setup(ctx: PluginCtx): Promise<Cleanup | void> {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
171
|
+
// Identity vs verification tree are separate concerns: the repo STAMP is
|
|
172
|
+
// resolved per session from the event stream (never from process.cwd(),
|
|
173
|
+
// which is the server launch dir and poisons rows invisibly); cwd remains
|
|
174
|
+
// only as the gate's verification tree when no session location is known.
|
|
175
|
+
const setupRoot = ctx?.location?.directory;
|
|
176
|
+
const liveRoot = setupRoot ?? process.cwd();
|
|
177
|
+
setRepoScope(setupRoot ? resolveRepo(setupRoot) : null);
|
|
177
178
|
|
|
178
179
|
// Character budget for the epoch-frozen injection block: plugin options
|
|
179
180
|
// (object form in opencode.json(c)) win, then OPENCODE_MEMORY_INJECT_BUDGET,
|
|
@@ -197,12 +198,42 @@ const impl = {
|
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
const gateContext = (): GateContext => ({
|
|
200
|
-
liveRoot,
|
|
201
|
+
liveRoot: sessionDir(getCurrent().sid) ?? liveRoot,
|
|
201
202
|
evidenceCalls: evidenceCalls(getCurrent().sid),
|
|
202
203
|
hasGateCheck: hasGateCheck(getCurrent().sid),
|
|
203
204
|
hasEngagement: hasEngagement(getCurrent().sid),
|
|
204
205
|
});
|
|
205
206
|
|
|
207
|
+
// Identity-event race backstop: a resumed session's FIRST turn can write
|
|
208
|
+
// before the subscription has delivered its execution.started, and the
|
|
209
|
+
// module-scope fallback is the last setup instance — possibly another
|
|
210
|
+
// workspace. The session context API is synchronous and its messages
|
|
211
|
+
// carry location.directory, so bind once from them for unmapped sids.
|
|
212
|
+
// One-shot: never overwrites an event-bound entry.
|
|
213
|
+
const bindingSids = new Set<string>();
|
|
214
|
+
async function bindSessionFromMessages(sid: string | undefined): Promise<void> {
|
|
215
|
+
if (!sid || sid === "?" || sessionDir(sid) || bindingSids.has(sid)) return;
|
|
216
|
+
bindingSids.add(sid);
|
|
217
|
+
try {
|
|
218
|
+
const msgs = await ctx.session?.context?.({ sessionID: sid });
|
|
219
|
+
if (Array.isArray(msgs)) {
|
|
220
|
+
for (const m of msgs) {
|
|
221
|
+
if (!m || typeof m !== "object") continue;
|
|
222
|
+
const r = m as Record<string, any>;
|
|
223
|
+
const dir = r.location?.directory ?? r.info?.location?.directory;
|
|
224
|
+
if (typeof dir === "string" && dir) {
|
|
225
|
+
rememberSessionDir(sid, dir);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// no context access or host error: the event stream still binds later
|
|
232
|
+
} finally {
|
|
233
|
+
bindingSids.delete(sid);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
206
237
|
// ---- tools --------------------------------------------------------------
|
|
207
238
|
await ctx.tool?.transform((editor) => {
|
|
208
239
|
const direct = { codemode: false } as const;
|
|
@@ -222,6 +253,7 @@ const impl = {
|
|
|
222
253
|
status: args.status,
|
|
223
254
|
repo: args.global ? "global" : undefined,
|
|
224
255
|
};
|
|
256
|
+
await bindSessionFromMessages(getCurrent().sid);
|
|
225
257
|
const outcome = await runWriteGate(candidate, gateContext());
|
|
226
258
|
if (outcome.action === "promoted") return { content: `Remembered: ${args.type} in ${args.scope}` };
|
|
227
259
|
if (outcome.action === "collapsed") return { content: `Duplicate ignored: ${args.type}/${args.scope}` };
|
|
@@ -239,7 +271,8 @@ const impl = {
|
|
|
239
271
|
"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).",
|
|
240
272
|
options: direct,
|
|
241
273
|
async execute(args): Promise<{ content: string }> {
|
|
242
|
-
|
|
274
|
+
await bindSessionFromMessages(getCurrent().sid);
|
|
275
|
+
const summary = await runInit({ liveRoot: gateContext().liveRoot, force: args.force, mode: args.mode });
|
|
243
276
|
return { content: formatInit(summary) };
|
|
244
277
|
},
|
|
245
278
|
}));
|
|
@@ -285,6 +318,7 @@ const impl = {
|
|
|
285
318
|
"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.",
|
|
286
319
|
options: direct,
|
|
287
320
|
async execute(args): Promise<{ content: string }> {
|
|
321
|
+
await bindSessionFromMessages(getCurrent().sid);
|
|
288
322
|
const prior = (await loadAll()).filter(
|
|
289
323
|
(m) => m.scope === args.scope && m.type === args.type && isActive(m) && isVisibleHere(m),
|
|
290
324
|
);
|
|
@@ -647,6 +681,8 @@ const impl = {
|
|
|
647
681
|
// Tiny system digest + open-thread frontier block, injected into the system prompt.
|
|
648
682
|
await ctx.session?.hook("context", (event: any) =>
|
|
649
683
|
guarded("session.context (systemDigest)", async () => {
|
|
684
|
+
// Route store reads in this hook through the right session's scope.
|
|
685
|
+
setCurrent({ sessionID: event?.sessionID });
|
|
650
686
|
const parts: string[] = [];
|
|
651
687
|
await systemDigest({ sessionID: event?.sessionID }, { system: parts });
|
|
652
688
|
for (const text of parts) event?.system?.push?.({ type: "text", text });
|
|
@@ -676,10 +712,12 @@ const impl = {
|
|
|
676
712
|
// the UI.
|
|
677
713
|
await ctx.session?.hook("prompt", (event: any) =>
|
|
678
714
|
guarded("session.prompt (inject)", async () => {
|
|
715
|
+
setCurrent({ sessionID: event?.sessionID });
|
|
679
716
|
const sid = event?.sessionID;
|
|
680
717
|
if (typeof sid === "string") {
|
|
681
718
|
markTurn(sid);
|
|
682
719
|
await checkNewSession(sid);
|
|
720
|
+
await bindSessionFromMessages(sid);
|
|
683
721
|
const version = await storeVersion();
|
|
684
722
|
const cached = epoch.get(sid);
|
|
685
723
|
if (!cached || cached.version !== version) {
|
|
@@ -724,6 +762,7 @@ const impl = {
|
|
|
724
762
|
// the summarizer call entirely.
|
|
725
763
|
await ctx.session?.hook("compaction", (event: any) =>
|
|
726
764
|
guarded("session.compaction (onCompacting)", async () => {
|
|
765
|
+
setCurrent({ sessionID: event?.sessionID });
|
|
727
766
|
await onCompacting({ sessionID: event?.sessionID });
|
|
728
767
|
if (typeof event?.sessionID === "string") epoch.delete(event.sessionID);
|
|
729
768
|
const anchor = await compactionAnchor();
|
|
@@ -747,6 +786,22 @@ const impl = {
|
|
|
747
786
|
try {
|
|
748
787
|
for await (const raw of eventApi.subscribe({ signal: measuredController.signal })) {
|
|
749
788
|
const ev = raw as Record<string, any>;
|
|
789
|
+
// Session identity: created fires for new sessions, execution.started
|
|
790
|
+
// every turn (covers sessions resumed across a restart), moved on a
|
|
791
|
+
// workspace change. Stamps and visibility resolve through this.
|
|
792
|
+
if (
|
|
793
|
+
ev?.type === "session.created" ||
|
|
794
|
+
ev?.type === "session.execution.started" ||
|
|
795
|
+
ev?.type === "session.moved"
|
|
796
|
+
) {
|
|
797
|
+
const d = ev.data ?? ev.properties ?? {};
|
|
798
|
+
const info = d.info ?? d;
|
|
799
|
+
const sid = info.sessionID ?? d.sessionID;
|
|
800
|
+
const dir =
|
|
801
|
+
info.location?.directory ?? d.location?.directory ?? ev.location?.directory ?? info.directory;
|
|
802
|
+
if (typeof sid === "string" && typeof dir === "string") rememberSessionDir(sid, dir);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
750
805
|
if (ev?.type !== "message.updated") continue;
|
|
751
806
|
const info = ev.properties?.info ?? ev.info;
|
|
752
807
|
if (!info || info.role !== "assistant") continue;
|
|
@@ -812,6 +867,9 @@ const impl = {
|
|
|
812
867
|
if (event?.type !== "session.idle") continue;
|
|
813
868
|
const sid = event.sessionID ?? event.properties?.sessionID;
|
|
814
869
|
if (typeof sid !== "string") continue;
|
|
870
|
+
// Bind the idle session so captures stamp (and gate against)
|
|
871
|
+
// that session's repo, not whatever tool call ran last.
|
|
872
|
+
setCurrent({ sessionID: sid });
|
|
815
873
|
const now = Date.now();
|
|
816
874
|
if (now - (lastRun.get(sid) ?? 0) < 5 * 60_000) continue; // 5-min cooldown
|
|
817
875
|
lastRun.set(sid, now);
|
package/src/memory.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import { memoryRoot, deletionsFile, snapshotDir } from "./config.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { repoScopeFor } from "./repokey.ts";
|
|
9
|
+
import { getCurrent } from "./state.ts";
|
|
9
10
|
import { appendLog, ensureDir, formatLine, parseLine, readLines } from "./logfmt.ts";
|
|
10
11
|
import { blockedTs } from "./blocklist.ts";
|
|
11
12
|
|
|
@@ -173,11 +174,13 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
|
173
174
|
}
|
|
174
175
|
// Repo stamp at the single write choke point: gated writes,
|
|
175
176
|
// viewer restores and edits all pass through here. Explicit "global"
|
|
176
|
-
// survives; an unscoped process (viewer) stamps nothing = global.
|
|
177
|
+
// survives; an unscoped process (viewer) stamps nothing = global. Session
|
|
178
|
+
// identity wins over the module scope so a server hosting several workspaces
|
|
179
|
+
// stamps the writing session's repo.
|
|
177
180
|
let repo = input.repo;
|
|
178
181
|
let repoPath = input.repoPath;
|
|
179
182
|
if (repo === undefined) {
|
|
180
|
-
const cur =
|
|
183
|
+
const cur = repoScopeFor(getCurrent().sid);
|
|
181
184
|
if (cur) {
|
|
182
185
|
repo = cur.key;
|
|
183
186
|
repoPath = cur.path;
|
|
@@ -416,7 +419,7 @@ export function isActive(m: Memory): boolean {
|
|
|
416
419
|
// entry points.
|
|
417
420
|
export function isVisibleHere(m: Memory): boolean {
|
|
418
421
|
if (!m.repo || m.repo === "global") return true;
|
|
419
|
-
const cur =
|
|
422
|
+
const cur = repoScopeFor(getCurrent().sid);
|
|
420
423
|
if (!cur) return true; // unscoped process: no partitioning
|
|
421
424
|
return m.repo === cur.key || (!!m.repoPath && m.repoPath === cur.path);
|
|
422
425
|
}
|
package/src/repokey.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// Repo identity for memory scoping.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Repo identity for memory scoping. A server process can host sessions from
|
|
2
|
+
// several workspaces, and setup() can run before the host attaches a session
|
|
3
|
+
// location — so identity is resolved PER SESSION from the event stream and
|
|
4
|
+
// falls back to the module scope (which itself may be null, never a guessed
|
|
5
|
+
// cwd). Every store write stamps it; every model-facing read filters by it.
|
|
4
6
|
//
|
|
5
7
|
// Two keys, both cheap, with opposite failure modes:
|
|
6
8
|
// key — sha1 of the normalized origin URL: survives re-clone, moves, and
|
|
@@ -64,3 +66,46 @@ export function resolveRepo(startDir: string): RepoScope {
|
|
|
64
66
|
: path;
|
|
65
67
|
return { key, path };
|
|
66
68
|
}
|
|
69
|
+
|
|
70
|
+
// --- per-session identity -----------------------------------------------------
|
|
71
|
+
// Learned from session events (created / execution.started / moved). Bounded:
|
|
72
|
+
// a long-lived server must not grow this with every session ever seen.
|
|
73
|
+
const SESSION_CAP = 64;
|
|
74
|
+
const byDir = new Map<string, RepoScope>();
|
|
75
|
+
const bySession = new Map<string, { dir: string; scope: RepoScope }>();
|
|
76
|
+
|
|
77
|
+
function scopeForDir(dir: string): RepoScope {
|
|
78
|
+
let s = byDir.get(dir);
|
|
79
|
+
if (!s) {
|
|
80
|
+
s = resolveRepo(dir);
|
|
81
|
+
byDir.set(dir, s);
|
|
82
|
+
}
|
|
83
|
+
return s;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function rememberSessionDir(sid: string, dir: string): void {
|
|
87
|
+
const prev = bySession.get(sid);
|
|
88
|
+
if (prev && prev.dir === dir) return;
|
|
89
|
+
bySession.set(sid, { dir, scope: scopeForDir(dir) });
|
|
90
|
+
while (bySession.size > SESSION_CAP) bySession.delete(bySession.keys().next().value as string);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function sessionDir(sid?: string): string | undefined {
|
|
94
|
+
if (!sid || sid === "?") return undefined;
|
|
95
|
+
return bySession.get(sid)?.dir;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The scope a write/read belongs to: the session's when known, else the
|
|
99
|
+
// module scope (null = unscoped process = global rows).
|
|
100
|
+
export function repoScopeFor(sid?: string): RepoScope | null {
|
|
101
|
+
if (sid && sid !== "?") {
|
|
102
|
+
const e = bySession.get(sid);
|
|
103
|
+
if (e) return e.scope;
|
|
104
|
+
}
|
|
105
|
+
return current;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resetSessionScopes(): void {
|
|
109
|
+
bySession.clear();
|
|
110
|
+
byDir.clear();
|
|
111
|
+
}
|