opencode-self-improvement 0.8.5 → 0.8.7

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
@@ -65,8 +65,10 @@ prompt cache on every message. This one is built the other way around:
65
65
  queries (one or two words — a deliberate search) need only one. Injection
66
66
  stays quiet on single-word coincidences, which `memory_recall` still
67
67
  reaches on the pull path — rare and right beats frequent and maybe.
68
- - **Hard character budget.** The block is capped at 1200 characters by default
69
- (see Options) and trimmed at whole-memory boundaries — never cut mid-sentence.
68
+ - **Hard character budgets.** The relevance block and each contextual pull are
69
+ capped at 1200 characters by default; the session-start open-threads frontier
70
+ has its own 3600-character cap (see Options). All are trimmed at whole-memory
71
+ boundaries — never cut mid-sentence.
70
72
  - **Evidence is never injected**, and the standing system digest is two lines.
71
73
  - **Receipts, not promises.** The plugin measures your session's actual
72
74
  prompt-cache usage (from each turn's `tokens.cache` accounting) and shows it
@@ -263,13 +265,14 @@ the plugin entry in `opencode.json(c)` instead of the plain string:
263
265
 
264
266
  ```json
265
267
  {
266
- "plugins": [{ "package": "opencode-self-improvement", "options": { "injectBudget": 1200 } }]
268
+ "plugins": [{ "package": "opencode-self-improvement", "options": { "injectBudget": 1200, "frontierBudget": 3600 } }]
267
269
  }
268
270
  ```
269
271
 
270
272
  | Option | Default | What it does |
271
273
  | --- | --- | --- |
272
- | `injectBudget` | `1200` | Character cap on the injected relevance block, trimmed at whole-memory boundaries. Env fallback: `OPENCODE_MEMORY_INJECT_BUDGET`. |
274
+ | `injectBudget` | `1200` | Character cap on the injected relevance block and each contextual pull; trimmed at whole-memory boundaries. Env fallback: `OPENCODE_MEMORY_INJECT_BUDGET`. |
275
+ | `frontierBudget` | `3 × injectBudget` (`3600` by default) | Character cap on the session-start open-threads block. A thread longer than this budget is not injected and is visibly reported as omitted; raise this option (or `OPENCODE_MEMORY_FRONTIER_BUDGET`) to include longer planning threads. |
273
276
  | `autoCapture` | `false` (off) | When on, each session-idle event makes one cheap model pass that proposes memory candidates. Every candidate still goes through the 4-gate write pipeline — capture can never bypass the gates. Off by default: the zero-token install stays zero-token. |
274
277
  | `captureModel` | — | `"provider/id"` model used for auto-capture. Required when `autoCapture` is on; a missing or invalid value disables capture with a startup warning. |
275
278
  | `captureMax` | `3` | Maximum candidates proposed per idle event. |
@@ -349,15 +352,23 @@ and the tool reports a tally of outcomes per line —
349
352
  | `collapsed` | near-duplicate of an existing memory; nothing written |
350
353
  | `blocked` | rejected by a gate; reason is in `memory_evidence` |
351
354
  | `skipped` | malformed line; never reaches the gates |
352
- | `downgraded` | `status:"open"` without an export header to vouch for it; imported as settled |
355
+ | `downgraded` | `status:"open"` without an export header to vouch for it, claiming `repo:"global"`, or carrying an unverifiable stamp when the importing session has no repo scope; imported as settled |
353
356
 
354
357
  The frontier policy matters because an open row is exempt from ref grounding:
355
358
  a hand-written JSONL can't hand its rows that free pass (the header is
356
359
  friction, not a signature — the real defense is that imported content always
357
- injects framed as data and marked `[via import]`).
358
-
359
- so re-running the same import is a no-op (everything collapses). Copying an
360
- export to a fresh machine is how you seed a new laptop from an old one.
360
+ injects framed as data and marked `[via import]`). Even with a header, an
361
+ imported open row claiming `repo:"global"` is downgraded, so one file can't add
362
+ an open thread to every repo. Imported `repo` and `repoPath` stamps are honored
363
+ only when they match the importing session's current repo (an explicit
364
+ `repo:"global"` on a settled row is kept); foreign stamps are discarded and the
365
+ row is stamped for the importing repo instead. When there is no current repo
366
+ scope, stamps are kept as they are (dropping them would widen the row to
367
+ global) and a stamped open row is downgraded, since its stamp can't be verified.
368
+
369
+ So re-running the same import in the same repo is a no-op (everything
370
+ collapses). Copying an export to a fresh machine and importing it from that
371
+ repo is how you seed a new laptop from an old one.
361
372
 
362
373
  ## Memory at a glance
363
374
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
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/config.ts CHANGED
@@ -54,6 +54,19 @@ export function injectBudget(): number {
54
54
  return 1200;
55
55
  }
56
56
 
57
+ // Character budget for the session-start open-thread frontier. Kept separate
58
+ // from relevance injection and contextual pulls: planning rows are often much
59
+ // longer, and raising this cap must not raise those per-request surfaces too.
60
+ // Defaults to 3x the effective injection budget.
61
+ export function frontierBudget(baseBudget = injectBudget()): number {
62
+ const raw = process.env.OPENCODE_MEMORY_FRONTIER_BUDGET;
63
+ if (raw) {
64
+ const n = Number.parseInt(raw, 10);
65
+ if (Number.isFinite(n) && n > 0) return n;
66
+ }
67
+ return baseBudget * 3;
68
+ }
69
+
57
70
  // Optional live-graph binary. When present the resolver cross-checks the local
58
71
  // source scan against the codebase-memory-mcp file graph; when absent the local
59
72
  // scan alone is authoritative (install-from-scratch requirement).
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // helper modules.
13
13
  import type { Cleanup, Plugin } from "@opencode/plugin/promise/plugin";
14
14
  import { basename, join } from "node:path";
15
- import { evidenceDir, injectBudget, memoryRoot, dateStr } from "./config.ts";
15
+ import { evidenceDir, frontierBudget, injectBudget, memoryRoot, dateStr } from "./config.ts";
16
16
  import { STATUSES, TYPES } from "./types.ts";
17
17
  import {
18
18
  clearSuperseded,
@@ -29,6 +29,7 @@ import { runWriteGate, type GateContext, type WriteCandidate } from "./gates.ts"
29
29
  import { pruneMemory } from "./prune.ts";
30
30
  import { exportMemories, findStaleOpen, importMemories } from "./portable.ts";
31
31
  import { listSnapshots, restoreSnapshot } from "./undo.ts";
32
+ import { SESSION_CAP, SessionLru } from "./sessions.ts";
32
33
  import { record } from "./evidence.ts";
33
34
  import {
34
35
  capToBudget,
@@ -177,10 +178,12 @@ const impl = {
177
178
  const liveRoot = setupRoot ?? process.cwd();
178
179
  setRepoScope(setupRoot ? resolveRepo(setupRoot) : null);
179
180
 
180
- // Character budget for the epoch-frozen injection block: plugin options
181
- // (object form in opencode.json(c)) win, then OPENCODE_MEMORY_INJECT_BUDGET,
182
- // then the 1200 default.
181
+ // Character budgets: relevance injection and contextual pulls share the
182
+ // existing cap; the session-start frontier has its own larger cap. Plugin
183
+ // options win over env fallbacks, and the frontier defaults to 3x the
184
+ // effective injection budget.
183
185
  const budget = Number(ctx.options?.injectBudget) || injectBudget();
186
+ const openBudget = Number(ctx.options?.frontierBudget) || frontierBudget(budget);
184
187
 
185
188
  // Mirror the /repo-init slash command into opencode's global commands dir
186
189
  // (idempotent, write-if-missing). OpenCode 2.x loads `*.md` from
@@ -480,7 +483,7 @@ const impl = {
480
483
  editor.add(validated(ExportArgs, {
481
484
  name: "memory_export",
482
485
  description:
483
- "Export active memories as a JSONL file for backup or transfer to another machine. The first line is a metadata header ({_export,version,exportedAt,count}); each later line is one JSON object {ts,type,scope,content,status,tags,issue}. The header is what lets memory_import keep status:\"open\" frontier rows — a hand-written file without it imports such rows as settled. Defaults to <memory-root>/exports/<timestamp>.jsonl; pass `file` to choose the path, `scope` to export one scope. Superseded rows are not exported (history stays in the store).",
486
+ "Export active memories as a JSONL file for backup or transfer to another machine. The first line is a metadata header ({_export,version,exportedAt,count}); each later line is one JSON object {ts,type,scope,content,status,tags,issue}. The header is what lets memory_import keep repo-scoped status:\"open\" frontier rows — a hand-written file without it imports such rows as settled, as does an imported open row claiming repo:\"global\". Defaults to <memory-root>/exports/<timestamp>.jsonl; pass `file` to choose the path, `scope` to export one scope. Superseded rows are not exported (history stays in the store).",
484
487
  options: direct,
485
488
  async execute(args): Promise<{ content: string }> {
486
489
  const res = await exportMemories({ scope: args.scope, file: args.file });
@@ -491,7 +494,7 @@ const impl = {
491
494
  editor.add(validated(ImportArgs, {
492
495
  name: "memory_import",
493
496
  description:
494
- "Import memories from a JSONL export (see memory_export). Every row goes through the same 4-gate write pipeline as memory_remember (tagged `import`, original tags preserved), so duplicates collapse and ungrounded rows are blocked exactly as usual — those are normal tallied outcomes, not errors. Malformed lines are skipped and counted. Frontier policy: status:\"open\" exempts a row from ref grounding, so it is honored ONLY if the file opens with a memory_export header; without one, open rows are downgraded to settled and tallied. Imported content is unverified by construction: injected rows surface a `[via import]` provenance marker.",
497
+ "Import memories from a JSONL export (see memory_export). Every row goes through the same 4-gate write pipeline as memory_remember (tagged `import`, original tags preserved), so duplicates collapse and ungrounded rows are blocked exactly as usual — those are normal tallied outcomes, not errors. Malformed lines are skipped and counted. Frontier policy: status:\"open\" exempts a row from ref grounding, so it requires a memory_export header and cannot claim global scope; imported repo stamps are kept only when they match the importing session's repo, otherwise the importing repo is stamped. A stamped open row is downgraded when there is no current repo scope to verify it. Imported content is unverified by construction: injected rows surface a `[via import]` provenance marker.",
495
498
  options: direct,
496
499
  async execute(args): Promise<{ content: string }> {
497
500
  try {
@@ -506,7 +509,7 @@ const impl = {
506
509
  `Imported ${args.file}: ${t.promoted} promoted, ${t.collapsed} duplicates collapsed, ` +
507
510
  `${t.blocked} blocked by the gate, ${t.skipped} malformed lines skipped.` +
508
511
  (t.downgraded
509
- ? ` ${t.downgraded} open rows downgraded to settled (file has no memory_export header).`
512
+ ? ` ${t.downgraded} open rows downgraded to settled (missing eligible memory_export header or verifiable repo scope).`
510
513
  : ""),
511
514
  };
512
515
  } catch (err) {
@@ -604,13 +607,15 @@ const impl = {
604
607
  });
605
608
 
606
609
  // Per-session frozen injection epochs: the relevance block plus the store
607
- // fingerprint it was computed against. Recomputed ONLY on the first prompt
608
- // of a session, when injectable rows change, or after a compaction (see the
609
- // prompt/context/compaction hooks) — so the system prefix, and with it the
610
- // provider prompt cache, survives ordinary requests.
610
+ // fingerprint it was computed against. Recomputed on the first prompt of a
611
+ // tracked session, when injectable rows change, or after a compaction (see
612
+ // the prompt/context/compaction hooks) — so the system prefix, and with it
613
+ // the provider prompt cache, survives ordinary requests. The shared LRU
614
+ // below may evict the whole session state; a later prompt starts it fresh.
611
615
  const epoch = new Map<string, { version: string; block: string; at: string; reason: "first" | "store_changed" }>();
612
- // Latest user prompt per session. A changed store is re-scored against it
613
- // even when the change happened during a tool loop with no new prompt.
616
+ // Latest user prompt per tracked session. A changed store is re-scored
617
+ // against it even when the change happened during a tool loop with no new
618
+ // prompt. It is evicted together with the epoch and session ledgers.
614
619
  const latestPrompt = new Map<string, string>();
615
620
  // Recomputes since server start: every one invalidated a prompt cache, so
616
621
  // it is the honest cost number the surfaces show (status files / tools).
@@ -622,6 +627,13 @@ const impl = {
622
627
  // (memory_status, in-process).
623
628
  const sessionStats = new Map<string, SessionStats>();
624
629
  const measuredTurns = new Map<string, Measured>();
630
+ // Every map above is keyed by session and dropped together on eviction.
631
+ const sessions = new SessionLru(SESSION_CAP, (sid) => {
632
+ epoch.delete(sid);
633
+ latestPrompt.delete(sid);
634
+ sessionStats.delete(sid);
635
+ measuredTurns.delete(sid);
636
+ });
625
637
  const statsFor = (sid: string): SessionStats => {
626
638
  let st = sessionStats.get(sid);
627
639
  if (!st) {
@@ -724,7 +736,7 @@ const impl = {
724
736
  setCurrent(input);
725
737
  const out = { output: resultText(event?.result), metadata: resultMeta(event?.result) };
726
738
  await afterTool(input, out);
727
- await contextualPull(input, out as { output?: unknown });
739
+ await contextualPull(input, out as { output?: unknown }, budget);
728
740
  // Write a contextual-pull append back when the tool result is text we own.
729
741
  if (event?.result && typeof event.result === "object" && typeof event.result.content === "string") {
730
742
  const text = resultText(event.result);
@@ -739,10 +751,15 @@ const impl = {
739
751
  // Route store reads in this hook through the right session's scope.
740
752
  setCurrent({ sessionID: event?.sessionID });
741
753
  const sid = event?.sessionID;
754
+ if (typeof sid === "string") sessions.touch(sid);
742
755
  const mems = await injectableRows();
743
756
  if (typeof sid === "string") await refreshEpoch(sid, mems);
744
757
  const parts: string[] = [];
745
- await systemDigest({ sessionID: event?.sessionID }, { system: parts }, mems);
758
+ await systemDigest(
759
+ { sessionID: event?.sessionID },
760
+ { system: parts },
761
+ { rows: mems, frontierBudget: openBudget },
762
+ );
746
763
  for (const text of parts) event?.system?.push?.({ type: "text", text });
747
764
  // Deliver this epoch's relevance-ranked memory via the system channel.
748
765
  // The UI never renders system parts, unlike appended prompt text.
@@ -773,6 +790,7 @@ const impl = {
773
790
  setCurrent({ sessionID: event?.sessionID });
774
791
  const sid = event?.sessionID;
775
792
  if (typeof sid === "string") {
793
+ sessions.touch(sid);
776
794
  markTurn(sid);
777
795
  await checkNewSession(sid);
778
796
  await bindSessionFromMessages(sid);
@@ -839,6 +857,7 @@ const impl = {
839
857
  const sid = info.sessionID;
840
858
  const t = info.tokens;
841
859
  if (typeof sid !== "string" || !t) continue;
860
+ sessions.touch(sid);
842
861
  measuredTurns.set(sid, {
843
862
  at: new Date().toISOString(),
844
863
  input: Number(t.input) || 0,
package/src/inject.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import { createHash } from "node:crypto";
9
9
  import { isActive, loadAll, visibleHere, type Memory } from "./memory.ts";
10
10
  import { record } from "./evidence.ts";
11
- import { dateStr } from "./config.ts";
11
+ import { dateStr, frontierBudget, injectBudget } from "./config.ts";
12
12
  import { bracketList } from "./logfmt.ts";
13
13
  import { recentConvergenceSummary } from "./triggers.ts";
14
14
  import { flattenMemoryText } from "./sanitize.ts";
@@ -135,17 +135,63 @@ function openRank(m: Memory): number {
135
135
  }
136
136
 
137
137
  // Only the newest open threads are surfaced, and each session sees the block
138
- // once (per process), so the frontier costs a bounded number of tokens.
138
+ // once (per process). Rows can be long (up to the content cap), so the block is
139
+ // also held to its own frontierBudget, with any omission shown in the block.
139
140
  const OPEN_THREAD_LIMIT = 3;
140
141
  const frontierInjectedSessions = new Set<string>();
141
142
 
143
+ function omittedLine(count: number): string {
144
+ return `+ ${count} open ${count === 1 ? "thread" : "threads"} omitted: over budget`;
145
+ }
146
+
147
+ function cappedFrontier(open: Memory[], budget: number): {
148
+ block: string;
149
+ shown: Memory[];
150
+ omitted: number;
151
+ } {
152
+ const header = `## Open threads (frontier)${FRAME}`;
153
+ const rendered = open.map(formatMemory);
154
+ const full = [header, ...rendered].join("\n");
155
+ if (full.length <= budget) return { block: full, shown: open, omitted: 0 };
156
+
157
+ // Keep a strict newest-first prefix. Budget the omission marker alongside
158
+ // each candidate row so an older row can never replace a newer one that did
159
+ // not fit, and the drop is always visible.
160
+ const kept: string[] = [];
161
+ let used = header.length;
162
+ for (let i = 0; i < rendered.length; i++) {
163
+ const row = rendered[i];
164
+ if (row === undefined) break;
165
+ const remaining = rendered.length - i - 1;
166
+ const marker = remaining ? omittedLine(remaining) : "";
167
+ const projected = used + 1 + row.length + (marker ? 1 + marker.length : 0);
168
+ if (projected > budget) break;
169
+ kept.push(row);
170
+ used += 1 + row.length;
171
+ }
172
+
173
+ const omitted = open.length - kept.length;
174
+ const lines = [header, ...kept];
175
+ if (omitted) lines.push(omittedLine(omitted));
176
+ // The header plus omission marker is the minimum useful diagnostic. For a
177
+ // tiny budget that cannot hold even those two lines, emit them anyway rather
178
+ // than silently making an existing frontier disappear.
179
+ return { block: lines.join("\n"), shown: open.slice(0, kept.length), omitted };
180
+ }
181
+
182
+ export interface DigestOptions {
183
+ // Rows the caller already loaded for this request (shared with the epoch).
184
+ rows?: Memory[];
185
+ frontierBudget?: number;
186
+ }
187
+
142
188
  export async function systemDigest(
143
189
  _input: unknown,
144
190
  output: TransformOutput,
145
- rows?: Memory[],
191
+ opts: DigestOptions = {},
146
192
  ): Promise<void> {
147
193
  // Repo-scoped: model-facing reads see this repo + global rows.
148
- const mems = rows ?? visibleHere((await loadAll()).filter(isActive));
194
+ const mems = opts.rows ?? visibleHere((await loadAll()).filter(isActive));
149
195
  if (!mems.length) return;
150
196
  const latest = flattenMemoryText(mems.map((m) => m.ts).sort().pop() ?? "?");
151
197
  output.system.push(
@@ -160,9 +206,8 @@ export async function systemDigest(
160
206
  .slice(0, OPEN_THREAD_LIMIT);
161
207
  if (!open.length) return;
162
208
 
163
- output.system.push(
164
- `\n## Open threads (frontier)${FRAME}\n` + open.map(formatMemory).join("\n"),
165
- );
209
+ const frontier = cappedFrontier(open, opts.frontierBudget ?? frontierBudget());
210
+ output.system.push(`\n${frontier.block}`);
166
211
 
167
212
  // Aggregate of past sessions' convergence (one line, gated on data existing;
168
213
  // I3: it is a derived fact, never the raw log). Gives the next session the
@@ -183,8 +228,10 @@ export async function systemDigest(
183
228
  "frontier_injected",
184
229
  {
185
230
  session: sid,
186
- scopes: bracketList([...new Set(open.map((m) => flattenMemoryText(m.scope)))]),
187
- shown: open.length,
231
+ scopes: bracketList([...new Set(frontier.shown.map((m) => flattenMemoryText(m.scope)))]),
232
+ shown: frontier.shown.length,
233
+ omitted: frontier.omitted,
234
+ ...(frontier.omitted ? { reason: "over_budget" } : {}),
188
235
  open_total: openMems.length,
189
236
  ts_since: dateStr(),
190
237
  },
@@ -274,7 +321,11 @@ export async function compactionAnchor(limit = 12): Promise<string> {
274
321
  return capToBudget(block, ANCHOR_BUDGET);
275
322
  }
276
323
 
277
- export async function contextualPull(input: PullInput, output: PullOutput): Promise<void> {
324
+ export async function contextualPull(
325
+ input: PullInput,
326
+ output: PullOutput,
327
+ budget = injectBudget(),
328
+ ): Promise<void> {
278
329
  const tool = typeof input.tool === "string" ? input.tool : "";
279
330
  if (!["grep", "glob", "read"].includes(tool)) return;
280
331
  const term = argText(input.args);
@@ -283,8 +334,9 @@ export async function contextualPull(input: PullInput, output: PullOutput): Prom
283
334
  const mems = visibleHere((await loadAll()).filter(isActive));
284
335
  const top = scoreMemories(term, mems, 3);
285
336
  if (!top.length) return;
286
- const block = top.map(formatMemory).join("\n");
287
- if (typeof output.output === "string") {
288
- output.output += `\n## Related durable memory (pull)${FRAME}\n${block}`;
337
+ // Held to the injection budget too: three long rows were ~12 KB per call.
338
+ const block = capToBudget(`## Related durable memory (pull)${FRAME}\n${top.map(formatMemory).join("\n")}`, budget);
339
+ if (block && typeof output.output === "string") {
340
+ output.output += `\n${block}`;
289
341
  }
290
342
  }
package/src/memory.ts CHANGED
@@ -139,9 +139,9 @@ export interface AppendMemoryInput {
139
139
  issue?: string;
140
140
  tags?: string[];
141
141
  status?: MemoryStatus;
142
- // "global" for explicit cross-project writes; imports carry the row's own
143
- // origin stamp (hash survives the machine hop — see repokey.ts). Any other
144
- // value is resolved by appendMemory against the live repo scope.
142
+ // "global" for explicit cross-project writes; imports may carry a validated
143
+ // same-repo origin stamp (see portable.ts). Any missing value is resolved by
144
+ // appendMemory against the live repo scope.
145
145
  repo?: string;
146
146
  repoPath?: string;
147
147
  }
package/src/portable.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  import { join } from "node:path";
7
7
  import { memoryRoot } from "./config.ts";
8
8
  import { isActive, isVisibleHere, loadAll, type Memory } from "./memory.ts";
9
+ import { repoScopeFor } from "./repokey.ts";
10
+ import { getCurrent } from "./state.ts";
9
11
  import { TYPES } from "./types.ts";
10
12
  import type { WriteCandidate } from "./gates.ts";
11
13
  import { flattenMemoryText } from "./sanitize.ts";
@@ -63,8 +65,8 @@ export async function exportMemories(opts: ExportOptions = {}): Promise<ExportRe
63
65
  status: m.status,
64
66
  tags: m.tags,
65
67
  issue: m.issue,
66
- // Origin stamps ride along so scoping survives the machine hop (the
67
- // repo key is a remote-URL hash; the path is the secondary match).
68
+ // Origin stamps ride along so a same-repo machine hop can retain them.
69
+ // Import validates them against its current repo before promotion.
68
70
  repo: m.repo,
69
71
  repoPath: m.repoPath,
70
72
  }),
@@ -80,7 +82,7 @@ export interface ImportTally {
80
82
  collapsed: number;
81
83
  blocked: number;
82
84
  skipped: number;
83
- /** Open rows downgraded to settled because the file carried no export header. */
85
+ /** Open rows downgraded because the file cannot grant that frontier scope. */
84
86
  downgraded: number;
85
87
  }
86
88
 
@@ -141,7 +143,12 @@ function parseRow(line: string): WriteCandidate | null {
141
143
  // Frontier policy: status="open" exempts a row from ref grounding
142
144
  // at the gate, so it is honored ONLY when the file opens with our export
143
145
  // header (version <= ours); otherwise every open row is downgraded to settled
144
- // and tallied. Only a missing file throws.
146
+ // and tallied. Imported origin stamps are honored only when they match the
147
+ // importing session's current repo (or are an explicit "global"); foreign
148
+ // stamps are removed so the write choke point stamps the importing repo
149
+ // instead. With no current scope, stamps are kept (removing them would widen
150
+ // the row to global) and a stamped open row is downgraded, since its stamp
151
+ // cannot be verified. Only a missing file throws.
145
152
  export async function importMemories(
146
153
  path: string,
147
154
  gate: (c: WriteCandidate) => Promise<{ action: string }>,
@@ -149,6 +156,7 @@ export async function importMemories(
149
156
  const file = Bun.file(path);
150
157
  if (!(await file.exists())) throw new Error(`import file not found: ${path}`);
151
158
  const tally: ImportTally = { promoted: 0, collapsed: 0, blocked: 0, skipped: 0, downgraded: 0 };
159
+ const currentScope = repoScopeFor(getCurrent().sid);
152
160
  let allowFrontier = false;
153
161
  let first = true;
154
162
  for (const line of (await file.text()).split("\n")) {
@@ -166,7 +174,18 @@ export async function importMemories(
166
174
  tally.skipped++;
167
175
  continue;
168
176
  }
169
- if (c.status === "open" && !allowFrontier) {
177
+ const claimedGlobal = c.repo === "global";
178
+ const hadStamp = c.repo !== undefined || c.repoPath !== undefined;
179
+ // A foreign stamp is treated like no stamp, so appendMemory stamps the
180
+ // importing repo. Explicit "global" is not a foreign repo and is kept (open
181
+ // global rows are downgraded below). With no current scope there is nothing
182
+ // to re-stamp with: dropping the stamp would widen the row to global, so it
183
+ // is kept and only the open-row exemption is withdrawn.
184
+ if (currentScope && !claimedGlobal) {
185
+ if (c.repo !== currentScope.key) delete c.repo;
186
+ if (c.repoPath !== currentScope.path) delete c.repoPath;
187
+ }
188
+ if (c.status === "open" && (!allowFrontier || claimedGlobal || (hadStamp && !currentScope))) {
170
189
  c.status = "settled";
171
190
  tally.downgraded++;
172
191
  }
@@ -0,0 +1,34 @@
1
+ // Bounded per-session state for a long-lived server. The plugin keeps several
2
+ // session-keyed maps (epoch, latest prompt, stats, measured usage); a resident
3
+ // `opencode serve` would otherwise hold an entry per session it ever saw. One
4
+ // recency list owns eviction so the maps are always dropped together: an
5
+ // evicted session simply starts over (no block until its next prompt, then a
6
+ // "first" epoch), which costs one recompute and never serves stale state.
7
+ // Same cap as repokey.ts's session-scope cache.
8
+ export const SESSION_CAP = 64;
9
+
10
+ export class SessionLru {
11
+ private readonly order = new Set<string>();
12
+ private readonly cap: number;
13
+ private readonly onEvict: (sid: string) => void;
14
+
15
+ constructor(cap: number, onEvict: (sid: string) => void) {
16
+ this.cap = cap;
17
+ this.onEvict = onEvict;
18
+ }
19
+
20
+ // Mark a session as just used; evicts the least recently used beyond the cap.
21
+ touch(sid: string): void {
22
+ this.order.delete(sid);
23
+ this.order.add(sid);
24
+ while (this.order.size > this.cap) {
25
+ const oldest = this.order.values().next().value as string;
26
+ this.order.delete(oldest);
27
+ this.onEvict(oldest);
28
+ }
29
+ }
30
+
31
+ get size(): number {
32
+ return this.order.size;
33
+ }
34
+ }