opencode-self-improvement 0.3.6 → 0.4.1

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 CHANGED
@@ -231,6 +231,18 @@ as `00:11:25.867`, or a unique substring of its text. Omit it to update the newe
231
231
  entry of that scope and type. A `query` matching nothing, or matching several rows,
232
232
  writes nothing and says so rather than updating the wrong memory.
233
233
 
234
+ ## Repo scoping
235
+
236
+ Memories belong to the repo that wrote them: rows are stamped with your git
237
+ remote (an id that survives re-clones and moves between machines) or, with no
238
+ remote, the repo path. Injection, recall, the frontier block, compaction,
239
+ `memory_forget`, and decay only ever see the current repo plus global rows —
240
+ project A's facts never surface in project B. Preferences and habits that
241
+ should follow you everywhere go in with `memory_remember global: true`.
242
+ Memories written before this existed, and ones restored through the viewer,
243
+ are global (visible everywhere), so upgrading changes nothing; exports carry
244
+ origin stamps, so scoping survives a machine hop.
245
+
234
246
  ## Backup & move between machines
235
247
 
236
248
  `memory_export` writes the live store as JSONL — one JSON object per line
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.3.6",
3
+ "version": "0.4.1",
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/gates.ts CHANGED
@@ -30,6 +30,11 @@ export interface WriteCandidate {
30
30
  issue?: string;
31
31
  tags?: string[];
32
32
  status?: "open" | "settled";
33
+ // "global" = cross-project (memory_remember global:true; imports carry a
34
+ // row's origin stamp). Any other value (including undefined) is resolved by
35
+ // appendMemory against the live repo scope (issue #8).
36
+ repo?: string;
37
+ repoPath?: string;
33
38
  }
34
39
 
35
40
  export interface GateContext {
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import { STATUSES, TYPES } from "./types.ts";
17
17
  import {
18
18
  clearSuperseded,
19
19
  isActive,
20
+ isVisibleHere,
20
21
  loadAll,
21
22
  markSuperseded,
22
23
  resolveMemoryTarget,
@@ -60,6 +61,7 @@ import {
60
61
  onCompacting,
61
62
  } from "./triggers.ts";
62
63
  import { getCurrent, setCurrent } from "./state.ts";
64
+ import { resolveRepo, setRepoScope } from "./repokey.ts";
63
65
  import { readLines } from "./logfmt.ts";
64
66
  import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
65
67
 
@@ -163,6 +165,11 @@ const impl = {
163
165
  async setup(ctx: PluginCtx): Promise<Cleanup | void> {
164
166
  const liveRoot = ctx?.location?.directory ?? process.cwd();
165
167
 
168
+ // Repo scoping (issue #8): resolve this workspace's identity ONCE. Every
169
+ // append stamps it; every model-facing read filters by it. Never throws —
170
+ // a non-git dir simply scopes by path.
171
+ setRepoScope(resolveRepo(liveRoot));
172
+
166
173
  // Character budget for the epoch-frozen injection block: plugin options
167
174
  // (object form in opencode.json(c)) win, then OPENCODE_MEMORY_INJECT_BUDGET,
168
175
  // then the 1200 default.
@@ -211,6 +218,10 @@ const impl = {
211
218
  en(STATUSES),
212
219
  "open = live planning thread / frontier (surfaced at session start); settled or omitted = durable knowledge",
213
220
  ),
221
+ global: withDesc(
222
+ bool(),
223
+ "Store cross-project (visible in every repo): user preferences and general tooling habits. Default false = the memory belongs to THIS repo.",
224
+ ),
214
225
  },
215
226
  ["type", "scope", "content"],
216
227
  ),
@@ -222,6 +233,7 @@ const impl = {
222
233
  issue: args.issue,
223
234
  tags: args.tags,
224
235
  status: args.status,
236
+ repo: args.global ? "global" : undefined,
225
237
  };
226
238
  const outcome = await runWriteGate(candidate, gateContext());
227
239
  if (outcome.action === "promoted") return { content: `Remembered: ${args.type} in ${args.scope}` };
@@ -256,7 +268,7 @@ const impl = {
256
268
  options: direct,
257
269
  input: obj({ scope: str(), type: en(TYPES), query: str(), limit: num() }),
258
270
  async execute(args: any): Promise<{ content: string }> {
259
- let results = (await loadAll()).filter(isActive);
271
+ let results = (await loadAll()).filter(isActive).filter(isVisibleHere);
260
272
  if (!results.length) return { content: "No memories found" };
261
273
  const total = results.length;
262
274
  if (args.scope) results = results.filter((m) => m.scope.includes(args.scope!));
@@ -305,7 +317,7 @@ const impl = {
305
317
  ),
306
318
  async execute(args: any): Promise<{ content: string }> {
307
319
  const prior = (await loadAll()).filter(
308
- (m) => m.scope === args.scope && m.type === args.type && isActive(m),
320
+ (m) => m.scope === args.scope && m.type === args.type && isActive(m) && isVisibleHere(m),
309
321
  );
310
322
  // Resolve the row BEFORE the gate: an update that cannot identify its
311
323
  // target must write nothing, rather than silently superseding whichever
@@ -369,7 +381,7 @@ const impl = {
369
381
  options: direct,
370
382
  input: obj({}),
371
383
  async execute(): Promise<{ content: string }> {
372
- const mems = (await loadAll()).filter(isActive);
384
+ const mems = (await loadAll()).filter(isActive).filter(isVisibleHere);
373
385
  if (!mems.length) return { content: "No memories found" };
374
386
  const scopes = new Map<string, number>();
375
387
  const types = new Map<string, number>();
package/src/init.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import { basename, join } from "node:path";
9
9
  import { existsSync } from "node:fs";
10
10
  import { cbmBin } from "./config.ts";
11
- import { loadAll, markSuperseded } from "./memory.ts";
11
+ import { isVisibleHere, loadAll, markSuperseded } from "./memory.ts";
12
12
  import { runWriteGate, type GateContext } from "./gates.ts";
13
13
  import { record } from "./evidence.ts";
14
14
  import { evidenceCalls, hasGateCheck } from "./triggers.ts";
@@ -185,7 +185,11 @@ export interface InitStamp {
185
185
  }
186
186
 
187
187
  export async function initStamp(scope: string): Promise<InitStamp | null> {
188
- const mems = (await loadAll()).filter((m) => !m.superseded_by);
188
+ // Issue #8: scope is a basename, so two different repos can share one
189
+ // ("api" next to "api", a fork beside its upstream). Visibility filtering
190
+ // makes each repo see only its own stamp — basename collisions no longer
191
+ // alias init state between repos.
192
+ const mems = (await loadAll()).filter((m) => !m.superseded_by && isVisibleHere(m));
189
193
  for (const m of mems) {
190
194
  if (m.type !== "context" || m.scope !== scope) continue;
191
195
  if (!m.tags?.some((t) => t === "init-stamp")) continue;
package/src/inject.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // results. Evidence is never injected (invariant I3). Open threads (status=
6
6
  // "open") get a single capped frontier block in the digest so planning sessions
7
7
  // start at the previous session's frontier instead of re-deriving it.
8
- import { isActive, loadAll, type Memory } from "./memory.ts";
8
+ import { isActive, loadAll, visibleHere, type Memory } from "./memory.ts";
9
9
  import { record } from "./evidence.ts";
10
10
  import { dateStr } from "./config.ts";
11
11
  import { bracketList } from "./logfmt.ts";
@@ -93,7 +93,8 @@ export async function systemDigest(
93
93
  _input: unknown,
94
94
  output: TransformOutput,
95
95
  ): Promise<void> {
96
- const mems = (await loadAll()).filter(isActive);
96
+ // Repo-scoped (issue #8): model-facing reads see this repo + global rows.
97
+ const mems = visibleHere((await loadAll()).filter(isActive));
97
98
  if (!mems.length) return;
98
99
  const latest = mems.map((m) => m.ts).sort().pop() ?? "?";
99
100
  output.system.push(
@@ -143,7 +144,8 @@ export async function systemDigest(
143
144
  export async function relevantKnowledge(message: string, limit = 3): Promise<string> {
144
145
  const text = message.trim();
145
146
  if (!text) return "";
146
- const mems = (await loadAll()).filter(isActive);
147
+ // Repo-scoped (issue #8): model-facing reads see this repo + global rows.
148
+ const mems = visibleHere((await loadAll()).filter(isActive));
147
149
  const top = scoreMemories(text, mems, limit);
148
150
  if (!top.length) return "";
149
151
  return `## Relevant durable memory\n${top.map(formatMemory).join("\n")}`;
@@ -154,7 +156,8 @@ export async function relevantKnowledge(message: string, limit = 3): Promise<str
154
156
  // injection block is recomputed only when this string changes (or after a
155
157
  // compaction). Evidence and superseded rows never affect it (I3, memory.ts).
156
158
  export async function storeVersion(): Promise<string> {
157
- const mems = (await loadAll()).filter(isActive);
159
+ // Repo-scoped (issue #8): model-facing reads see this repo + global rows.
160
+ const mems = visibleHere((await loadAll()).filter(isActive));
158
161
  let max = "";
159
162
  for (const m of mems) if (m.ts > max) max = m.ts;
160
163
  return `${mems.length}:${max}`;
@@ -193,7 +196,8 @@ const ANCHOR_SETTLED_LIMIT = 5;
193
196
  const ANCHOR_BUDGET = 2500;
194
197
 
195
198
  export async function compactionAnchor(limit = 12): Promise<string> {
196
- const mems = (await loadAll()).filter(isActive);
199
+ // Repo-scoped (issue #8): model-facing reads see this repo + global rows.
200
+ const mems = visibleHere((await loadAll()).filter(isActive));
197
201
  if (!mems.length) return "";
198
202
  const byNewest = (a: Memory, b: Memory) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0);
199
203
  const open = mems
@@ -218,7 +222,8 @@ export async function contextualPull(input: PullInput, output: PullOutput): Prom
218
222
  if (!["grep", "glob", "read"].includes(tool)) return;
219
223
  const term = argText(input.args);
220
224
  if (!term) return;
221
- const mems = (await loadAll()).filter(isActive);
225
+ // Repo-scoped (issue #8): model-facing reads see this repo + global rows.
226
+ const mems = visibleHere((await loadAll()).filter(isActive));
222
227
  const top = scoreMemories(term, mems, 3);
223
228
  if (!top.length) return;
224
229
  const block = top.map(formatMemory).join("\n");
package/src/memory.ts CHANGED
@@ -5,6 +5,7 @@
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
9
  import { appendLog, ensureDir, formatLine, parseLine, readLines } from "./logfmt.ts";
9
10
  import { blockedTs } from "./blocklist.ts";
10
11
 
@@ -28,6 +29,12 @@ export interface Memory {
28
29
  superseded_by?: string;
29
30
  reason?: string;
30
31
  status?: MemoryStatus;
32
+ // Origin repo (issue #8): sha1 of the normalized origin URL, the toplevel
33
+ // path when the repo had no remote, the literal "global" for cross-project
34
+ // writes, or absent for legacy/viewer rows — all three read as described
35
+ // in isVisibleHere().
36
+ repo?: string;
37
+ repoPath?: string;
31
38
  file: string;
32
39
  index: number;
33
40
  }
@@ -56,6 +63,8 @@ function toMemory(fields: Record<string, unknown>, file: string, index: number):
56
63
  reason: typeof fields.reason === "string" ? fields.reason : undefined,
57
64
  status:
58
65
  fields.status === "open" || fields.status === "settled" ? fields.status : undefined,
66
+ repo: typeof fields.repo === "string" ? fields.repo : undefined,
67
+ repoPath: typeof fields.repoPath === "string" ? fields.repoPath : undefined,
59
68
  file,
60
69
  index,
61
70
  };
@@ -89,7 +98,9 @@ export async function loadAll(): Promise<Memory[]> {
89
98
  }
90
99
 
91
100
  export async function loadScope(scope: string): Promise<Memory[]> {
92
- return (await loadAll()).filter((m) => m.scope === scope);
101
+ // Gate dedup/stale reads are repo-scoped: a row from another repo must not
102
+ // collapse or supersede a write here (issue #8).
103
+ return (await loadAll()).filter((m) => m.scope === scope && isVisibleHere(m));
93
104
  }
94
105
 
95
106
  export interface AppendMemoryInput {
@@ -99,6 +110,11 @@ export interface AppendMemoryInput {
99
110
  issue?: string;
100
111
  tags?: string[];
101
112
  status?: MemoryStatus;
113
+ // "global" for explicit cross-project writes; imports carry the row's own
114
+ // origin stamp (hash survives the machine hop — see repokey.ts). Any other
115
+ // value is resolved by appendMemory against the live repo scope.
116
+ repo?: string;
117
+ repoPath?: string;
102
118
  }
103
119
 
104
120
  // Existing ts values across all memory files, used to guarantee uniqueness of
@@ -130,6 +146,18 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
130
146
  while (taken.has(ts) && guard++ < 1000) {
131
147
  ts = new Date(new Date(ts).getTime() + 1).toISOString();
132
148
  }
149
+ // Repo stamp (issue #8) at the single write choke point: gated writes,
150
+ // viewer restores and edits all pass through here. Explicit "global"
151
+ // survives; an unscoped process (viewer) stamps nothing = global.
152
+ let repo = input.repo;
153
+ let repoPath = input.repoPath;
154
+ if (repo === undefined) {
155
+ const cur = repoScope();
156
+ if (cur) {
157
+ repo = cur.key;
158
+ repoPath = cur.path;
159
+ }
160
+ }
133
161
  const file = join(memoryRoot(), `${ts.split("T")[0]}.logfmt`);
134
162
  await appendLog(
135
163
  file,
@@ -141,6 +169,8 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
141
169
  issue: input.issue,
142
170
  tags: input.tags?.length ? input.tags.join(",") : undefined,
143
171
  status: input.status,
172
+ repo,
173
+ repoPath,
144
174
  }),
145
175
  );
146
176
  return ts;
@@ -333,7 +363,10 @@ export async function prune(scope: string, type: string, reason: string): Promis
333
363
  for (const line of lines) {
334
364
  const parsed = parseLine(line);
335
365
  const m = parsed ? toMemory(parsed, file, 0) : null;
336
- if (m && m.scope === scope && m.type === type) {
366
+ // Repo floor (issue #8): forgetting scope X here must never delete
367
+ // another repo's row for scope X — same basename collisions are real
368
+ // (a fork next to its upstream).
369
+ if (m && m.scope === scope && m.type === type && isVisibleHere(m)) {
337
370
  deleted.push({ memory: m, reason });
338
371
  await logDeletion(m, reason);
339
372
  changed = true;
@@ -349,3 +382,21 @@ export async function prune(scope: string, type: string, reason: string): Promis
349
382
  export function isActive(m: Memory): boolean {
350
383
  return !m.superseded_by;
351
384
  }
385
+
386
+ // The ONLY repo filter the model-facing paths use (injection sinks, recall,
387
+ // list, update targeting, prune, decay, gate scope reads). A row is visible
388
+ // when it is global (explicit marker OR no stamp — legacy rows and writes by
389
+ // processes with no scope, e.g. the viewer), or was written in this repo.
390
+ // Keeping it one predicate is what stops the filtering from drifting between
391
+ // entry points the way pre-zod validation did.
392
+ export function isVisibleHere(m: Memory): boolean {
393
+ if (!m.repo || m.repo === "global") return true;
394
+ const cur = repoScope();
395
+ if (!cur) return true; // unscoped process: no partitioning
396
+ return m.repo === cur.key || (!!m.repoPath && m.repoPath === cur.path);
397
+ }
398
+
399
+ // Rows visible to the current process (active filtering stays with callers).
400
+ export function visibleHere(mems: Memory[]): Memory[] {
401
+ return mems.filter(isVisibleHere);
402
+ }
package/src/portable.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // moat. Decay is supersede-not-delete and snapshot-first, mirroring prune.
6
6
  import { join } from "node:path";
7
7
  import { memoryRoot } from "./config.ts";
8
- import { isActive, loadAll, type Memory } from "./memory.ts";
8
+ import { isActive, isVisibleHere, loadAll, type Memory } from "./memory.ts";
9
9
  import { TYPES } from "./types.ts";
10
10
  import type { WriteCandidate } from "./gates.ts";
11
11
 
@@ -45,6 +45,10 @@ export async function exportMemories(opts: ExportOptions = {}): Promise<ExportRe
45
45
  status: m.status,
46
46
  tags: m.tags,
47
47
  issue: m.issue,
48
+ // Origin stamps ride along so scoping survives the machine hop (the
49
+ // repo key is a remote-URL hash; the path is the secondary match).
50
+ repo: m.repo,
51
+ repoPath: m.repoPath,
48
52
  }),
49
53
  );
50
54
  await Bun.write(path, lines.length ? lines.join("\n") + "\n" : "");
@@ -79,6 +83,11 @@ function parseRow(line: string): WriteCandidate | null {
79
83
  if (r.status === "open" || r.status === "settled") c.status = r.status;
80
84
  if (Array.isArray(r.tags) && r.tags.every((t) => typeof t === "string")) c.tags = r.tags;
81
85
  if (typeof r.issue === "string" && r.issue) c.issue = r.issue;
86
+ // Origin stamps survive an export/import hop (repokey.ts): a row written
87
+ // for repo X stays visible only where repo X is opened, even on a new
88
+ // machine. Unstamped rows stay global.
89
+ if (typeof r.repo === "string" && r.repo) c.repo = r.repo;
90
+ if (typeof r.repoPath === "string" && r.repoPath) c.repoPath = r.repoPath;
82
91
  return c;
83
92
  }
84
93
 
@@ -116,6 +125,8 @@ export async function importMemories(
116
125
  export async function findStaleOpen(days: number): Promise<Memory[]> {
117
126
  const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
118
127
  return (await loadAll())
119
- .filter((m) => isActive(m) && m.status === "open" && m.ts < cutoff)
128
+ // Decay is repo-scoped (issue #8): running it here must not retire
129
+ // another repo's open threads.
130
+ .filter((m) => isActive(m) && isVisibleHere(m) && m.status === "open" && m.ts < cutoff)
120
131
  .sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
121
132
  }
package/src/repokey.ts ADDED
@@ -0,0 +1,66 @@
1
+ // Repo identity for memory scoping (issue #8). 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.
4
+ //
5
+ // Two keys, both cheap, with opposite failure modes:
6
+ // key — sha1 of the normalized origin URL: survives re-clone, moves, and
7
+ // export/import to another machine. Absent when the repo has no
8
+ // origin (fresh or remote-less), where the path is used as the key.
9
+ // path — resolved git toplevel: the secondary match, so rows stamped
10
+ // before an origin was added keep recognizing their repo.
11
+ // Rows carry no stamp when their writer had no scope (viewer process,
12
+ // tests) and legacy rows predate the field: both read as GLOBAL — visible
13
+ // everywhere — which keeps every upgrade non-breaking.
14
+ import { createHash } from "node:crypto";
15
+
16
+ export interface RepoScope {
17
+ key: string;
18
+ path: string;
19
+ }
20
+
21
+ let current: RepoScope | null = null;
22
+
23
+ export function setRepoScope(scope: RepoScope | null): void {
24
+ current = scope;
25
+ }
26
+
27
+ export function repoScope(): RepoScope | null {
28
+ return current;
29
+ }
30
+
31
+ // git@github.com:Owner/Repo.git / https://GitHub.com/owner/repo/ ->
32
+ // github.com/owner/repo — the same repo must hash identically whichever
33
+ // remote form (and case) the clone used.
34
+ export function normalizeRemote(url: string): string {
35
+ let u = url.trim().toLowerCase();
36
+ u = u.replace(/^(?:https?:\/\/|ssh:\/\/|git:\/\/)/, "");
37
+ u = u.replace(/^git@/, "");
38
+ u = u.replace(/\/$/, "");
39
+ u = u.replace(/\.git$/, "");
40
+ // scp-style "github.com:owner/repo" -> slash form, so every transport of
41
+ // one repo hashes to one id. (First colon only; URL ports in remotes are
42
+ // rarer than the scp form and merely produce a stable different id.)
43
+ u = u.replace(":", "/");
44
+ return u;
45
+ }
46
+
47
+ function git(args: string[], cwd: string): string | null {
48
+ try {
49
+ const p = Bun.spawnSync(args, { cwd, stdout: "pipe", stderr: "ignore" });
50
+ const out = p.stdout?.toString().trim();
51
+ return p.exitCode === 0 && out ? out : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ // Resolve the current repo from any start dir (never throws; a non-git dir
58
+ // simply scopes by its own path).
59
+ export function resolveRepo(startDir: string): RepoScope {
60
+ const path = git(["git", "rev-parse", "--show-toplevel"], startDir) ?? startDir;
61
+ const remote = git(["git", "remote", "get-url", "origin"], path);
62
+ const key = remote
63
+ ? createHash("sha1").update(normalizeRemote(remote)).digest("hex").slice(0, 12)
64
+ : path;
65
+ return { key, path };
66
+ }
package/src/resolver.ts CHANGED
@@ -116,7 +116,11 @@ export function extractCallForms(content: string): CallForm[] {
116
116
  return forms;
117
117
  }
118
118
 
119
- const PATH_RE = /[A-Za-z0-9_@./-]+\.(?:ts|tsx|js|jsx|mjs|cjs)(?::\d+)?/g;
119
+ // The extension must end the name: without the lookahead `Button.tsx` matched
120
+ // as `Button.ts` and `config.json` as `config.js`, and the existence check then
121
+ // blocked real files. `.tsx.snap` and `.js.map` are other files, not refs; a
122
+ // sentence-ending `.` still ends the ref.
123
+ const PATH_RE = /[A-Za-z0-9_@./-]+\.(?:ts|tsx|js|jsx|mjs|cjs)(?!\w|\.\w)(?::\d+)?/g;
120
124
 
121
125
  export function extractPathRefs(content: string): string[] {
122
126
  if (content.length > REGEX_SCAN_MAX_CHARS) return [];