opencode-self-improvement 0.6.2 → 0.6.3

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.3",
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,7 +198,7 @@ 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),
@@ -239,7 +240,7 @@ const impl = {
239
240
  "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
241
  options: direct,
241
242
  async execute(args): Promise<{ content: string }> {
242
- const summary = await runInit({ liveRoot, force: args.force, mode: args.mode });
243
+ const summary = await runInit({ liveRoot: gateContext().liveRoot, force: args.force, mode: args.mode });
243
244
  return { content: formatInit(summary) };
244
245
  },
245
246
  }));
@@ -647,6 +648,8 @@ const impl = {
647
648
  // Tiny system digest + open-thread frontier block, injected into the system prompt.
648
649
  await ctx.session?.hook("context", (event: any) =>
649
650
  guarded("session.context (systemDigest)", async () => {
651
+ // Route store reads in this hook through the right session's scope.
652
+ setCurrent({ sessionID: event?.sessionID });
650
653
  const parts: string[] = [];
651
654
  await systemDigest({ sessionID: event?.sessionID }, { system: parts });
652
655
  for (const text of parts) event?.system?.push?.({ type: "text", text });
@@ -676,6 +679,7 @@ const impl = {
676
679
  // the UI.
677
680
  await ctx.session?.hook("prompt", (event: any) =>
678
681
  guarded("session.prompt (inject)", async () => {
682
+ setCurrent({ sessionID: event?.sessionID });
679
683
  const sid = event?.sessionID;
680
684
  if (typeof sid === "string") {
681
685
  markTurn(sid);
@@ -724,6 +728,7 @@ const impl = {
724
728
  // the summarizer call entirely.
725
729
  await ctx.session?.hook("compaction", (event: any) =>
726
730
  guarded("session.compaction (onCompacting)", async () => {
731
+ setCurrent({ sessionID: event?.sessionID });
727
732
  await onCompacting({ sessionID: event?.sessionID });
728
733
  if (typeof event?.sessionID === "string") epoch.delete(event.sessionID);
729
734
  const anchor = await compactionAnchor();
@@ -747,6 +752,22 @@ const impl = {
747
752
  try {
748
753
  for await (const raw of eventApi.subscribe({ signal: measuredController.signal })) {
749
754
  const ev = raw as Record<string, any>;
755
+ // Session identity: created fires for new sessions, execution.started
756
+ // every turn (covers sessions resumed across a restart), moved on a
757
+ // workspace change. Stamps and visibility resolve through this.
758
+ if (
759
+ ev?.type === "session.created" ||
760
+ ev?.type === "session.execution.started" ||
761
+ ev?.type === "session.moved"
762
+ ) {
763
+ const d = ev.data ?? ev.properties ?? {};
764
+ const info = d.info ?? d;
765
+ const sid = info.sessionID ?? d.sessionID;
766
+ const dir =
767
+ info.location?.directory ?? d.location?.directory ?? ev.location?.directory ?? info.directory;
768
+ if (typeof sid === "string" && typeof dir === "string") rememberSessionDir(sid, dir);
769
+ continue;
770
+ }
750
771
  if (ev?.type !== "message.updated") continue;
751
772
  const info = ev.properties?.info ?? ev.info;
752
773
  if (!info || info.role !== "assistant") continue;
@@ -812,6 +833,9 @@ const impl = {
812
833
  if (event?.type !== "session.idle") continue;
813
834
  const sid = event.sessionID ?? event.properties?.sessionID;
814
835
  if (typeof sid !== "string") continue;
836
+ // Bind the idle session so captures stamp (and gate against)
837
+ // that session's repo, not whatever tool call ran last.
838
+ setCurrent({ sessionID: sid });
815
839
  const now = Date.now();
816
840
  if (now - (lastRun.get(sid) ?? 0) < 5 * 60_000) continue; // 5-min cooldown
817
841
  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
+ }