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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.6.2",
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
- const liveRoot = ctx?.location?.directory ?? process.cwd();
172
-
173
- // Repo scoping: resolve this workspace's identity ONCE. Every
174
- // append stamps it; every model-facing read filters by it. Never throws —
175
- // a non-git dir simply scopes by path.
176
- setRepoScope(resolveRepo(liveRoot));
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
- const summary = await runInit({ liveRoot, force: args.force, mode: args.mode });
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 { repoScope } from "./repokey.ts";
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 = repoScope();
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 = repoScope();
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. One plugin process serves one
2
- // workspace, so the scope resolves ONCE at setup; every store write stamps
3
- // it and every model-facing read filters by it.
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
+ }