opencode-self-improvement 0.8.4 → 0.8.6

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
@@ -302,6 +302,8 @@ Run `/repo-init` once in every repo you work in. It:
302
302
  Ongoing use is just the memory tools. Ask the agent to remember something and it
303
303
  sticks across sessions; check what's stored with `memory_list`, refine with
304
304
  `memory_update`, retract with `memory_forget`, roll back with `memory_undo`.
305
+ `memory_undo` only lists and restores its own `<stamp>-YYYY-MM-DD.logfmt`
306
+ snapshots; any other file in `snapshots/` is ignored.
305
307
 
306
308
  `memory_update` takes a `query` that picks the row: its id, a clock fragment such
307
309
  as `00:11:25.867`, or a unique substring of its text. Omit it to update the newest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
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
@@ -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,
@@ -399,7 +400,7 @@ const impl = {
399
400
  editor.add(validated(UndoArgs, {
400
401
  name: "memory_undo",
401
402
  description:
402
- "List memory snapshots, or restore one (rollback). Only snapshots inside this store's snapshots directory are accepted: pass a bare name from the list or its full path. SAFE BY DEFAULT: restore refuses if the current file has rows added or edited since the snapshot, and audited human/tool deletions stay deleted. Re-run with merge=true to restore AND keep divergent rows, or force=true for an explicit exact rollback. Blocklisted rows remain inactive in every mode.",
403
+ "List memory snapshots, or restore one (rollback). Only snapshots inside this store's snapshots directory are accepted: pass a bare name from the list or its full path. Non-standard files in snapshots/ are not listed or restorable. SAFE BY DEFAULT: restore refuses if the current file has rows added or edited since the snapshot, and audited human/tool deletions stay deleted. Re-run with merge=true to restore AND keep divergent rows, or force=true for an explicit exact rollback. Blocklisted rows remain inactive in every mode.",
403
404
  options: direct,
404
405
  async execute(args): Promise<{ content: string }> {
405
406
  if (args.snapshot) {
@@ -604,13 +605,15 @@ const impl = {
604
605
  });
605
606
 
606
607
  // 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.
608
+ // fingerprint it was computed against. Recomputed on the first prompt of a
609
+ // tracked session, when injectable rows change, or after a compaction (see
610
+ // the prompt/context/compaction hooks) — so the system prefix, and with it
611
+ // the provider prompt cache, survives ordinary requests. The shared LRU
612
+ // below may evict the whole session state; a later prompt starts it fresh.
611
613
  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.
614
+ // Latest user prompt per tracked session. A changed store is re-scored
615
+ // against it even when the change happened during a tool loop with no new
616
+ // prompt. It is evicted together with the epoch and session ledgers.
614
617
  const latestPrompt = new Map<string, string>();
615
618
  // Recomputes since server start: every one invalidated a prompt cache, so
616
619
  // it is the honest cost number the surfaces show (status files / tools).
@@ -622,6 +625,13 @@ const impl = {
622
625
  // (memory_status, in-process).
623
626
  const sessionStats = new Map<string, SessionStats>();
624
627
  const measuredTurns = new Map<string, Measured>();
628
+ // Every map above is keyed by session and dropped together on eviction.
629
+ const sessions = new SessionLru(SESSION_CAP, (sid) => {
630
+ epoch.delete(sid);
631
+ latestPrompt.delete(sid);
632
+ sessionStats.delete(sid);
633
+ measuredTurns.delete(sid);
634
+ });
625
635
  const statsFor = (sid: string): SessionStats => {
626
636
  let st = sessionStats.get(sid);
627
637
  if (!st) {
@@ -739,6 +749,7 @@ const impl = {
739
749
  // Route store reads in this hook through the right session's scope.
740
750
  setCurrent({ sessionID: event?.sessionID });
741
751
  const sid = event?.sessionID;
752
+ if (typeof sid === "string") sessions.touch(sid);
742
753
  const mems = await injectableRows();
743
754
  if (typeof sid === "string") await refreshEpoch(sid, mems);
744
755
  const parts: string[] = [];
@@ -773,6 +784,7 @@ const impl = {
773
784
  setCurrent({ sessionID: event?.sessionID });
774
785
  const sid = event?.sessionID;
775
786
  if (typeof sid === "string") {
787
+ sessions.touch(sid);
776
788
  markTurn(sid);
777
789
  await checkNewSession(sid);
778
790
  await bindSessionFromMessages(sid);
@@ -839,6 +851,7 @@ const impl = {
839
851
  const sid = info.sessionID;
840
852
  const t = info.tokens;
841
853
  if (typeof sid !== "string" || !t) continue;
854
+ sessions.touch(sid);
842
855
  measuredTurns.set(sid, {
843
856
  at: new Date().toISOString(),
844
857
  input: Number(t.input) || 0,
@@ -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
+ }
package/src/undo.ts CHANGED
@@ -19,6 +19,23 @@ import { snapshotFiles } from "./memory.ts";
19
19
 
20
20
  const SNAPSHOT_NAME = /^(\d+)-(\d{4}-\d{2}-\d{2}\.logfmt)$/;
21
21
 
22
+ function matchSnapshotName(name: string): RegExpExecArray | null {
23
+ const match = SNAPSHOT_NAME.exec(name);
24
+ if (!match) return null;
25
+ const original = match[2];
26
+ if (!original) return null;
27
+ const year = Number(original.slice(0, 4));
28
+ const month = Number(original.slice(5, 7));
29
+ const day = Number(original.slice(8, 10));
30
+ const date = new Date(0);
31
+ date.setUTCFullYear(year, month - 1, day);
32
+ return date.getUTCFullYear() === year &&
33
+ date.getUTCMonth() === month - 1 &&
34
+ date.getUTCDate() === day
35
+ ? match
36
+ : null;
37
+ }
38
+
22
39
  export interface SnapshotInfo {
23
40
  file: string;
24
41
  name: string;
@@ -31,7 +48,7 @@ export async function listSnapshots(): Promise<SnapshotInfo[]> {
31
48
  const out: SnapshotInfo[] = [];
32
49
  try {
33
50
  for await (const f of glob.scan(snapshotDir())) {
34
- if (!SNAPSHOT_NAME.test(f)) continue;
51
+ if (!matchSnapshotName(f)) continue;
35
52
  const info = Bun.file(join(snapshotDir(), f));
36
53
  out.push({
37
54
  file: join(snapshotDir(), f),
@@ -83,7 +100,7 @@ async function resolveSnapshot(
83
100
  memoryRootDir: string,
84
101
  ): Promise<{ path: string; original: string }> {
85
102
  const name = basename(snapshot);
86
- const match = SNAPSHOT_NAME.exec(name);
103
+ const match = matchSnapshotName(name);
87
104
  const original = match?.[2];
88
105
  const stamp = match ? Number(match[1]) : Number.NaN;
89
106
  if (!original || !Number.isSafeInteger(stamp)) {