opencode-self-improvement 0.8.2 → 0.8.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +62 -42
  3. package/src/inject.ts +20 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.8.2",
3
+ "version": "0.8.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
@@ -602,10 +602,13 @@ const impl = {
602
602
 
603
603
  // Per-session frozen injection epochs: the relevance block plus the store
604
604
  // fingerprint it was computed against. Recomputed ONLY on the first prompt
605
- // of a session, when the store changes, or after a compaction (see the
606
- // prompt/compaction hooks) — so the system prefix, and with it the
607
- // provider prompt cache, survives ordinary turns.
605
+ // of a session, when injectable rows change, or after a compaction (see the
606
+ // prompt/context/compaction hooks) — so the system prefix, and with it the
607
+ // provider prompt cache, survives ordinary requests.
608
608
  const epoch = new Map<string, { version: string; block: string; at: string; reason: "first" | "store_changed" }>();
609
+ // Latest user prompt per session. A changed store is re-scored against it
610
+ // even when the change happened during a tool loop with no new prompt.
611
+ const latestPrompt = new Map<string, string>();
609
612
  // Recomputes since server start: every one invalidated a prompt cache, so
610
613
  // it is the honest cost number the surfaces show (status files / tools).
611
614
  let epochRecomputes = 0;
@@ -654,6 +657,47 @@ const impl = {
654
657
  const flushStatus = (sid: string): Promise<void> =>
655
658
  buildPayload(sid).then((p) => (p ? writeStatus(p) : undefined));
656
659
 
660
+ // Called when a prompt arrives (to keep status surfaces current) and again
661
+ // at the injection decision on every model request. The latter keeps
662
+ // removals fail-closed: an out-of-band redaction or a block during a tool
663
+ // loop cannot ride one more request waiting for another user prompt.
664
+ const injectableRows = async (): Promise<Memory[]> =>
665
+ (await loadAll()).filter((m) => isActive(m) && isVisibleHere(m));
666
+ const refreshEpoch = async (sid: string, rows?: Memory[]): Promise<void> => {
667
+ if (!latestPrompt.has(sid)) return;
668
+ const mems = rows ?? await injectableRows();
669
+ const version = await storeVersion(mems);
670
+ const cached = epoch.get(sid);
671
+ if (cached?.version === version) return;
672
+ const raw = await relevantKnowledge(latestPrompt.get(sid) ?? "", 5, mems); // widen candidate pool
673
+ const block = raw ? capToBudget(raw, budget) : "";
674
+ const at = new Date().toISOString();
675
+ const reason: "first" | "store_changed" = cached ? "store_changed" : "first";
676
+ epoch.set(sid, { version, block, at, reason });
677
+ await record(
678
+ "epoch_recompute",
679
+ {
680
+ session: sid,
681
+ reason,
682
+ chars: block.length,
683
+ },
684
+ false,
685
+ );
686
+ epochRecomputes++;
687
+ // Ledger: a store_changed recompute of a live epoch is an
688
+ // invalidation (the first epoch of a session busts nothing). The
689
+ // epoch request count restarts with the new block.
690
+ const st = statsFor(sid);
691
+ st.epochRequests = 0;
692
+ if (reason === "store_changed") {
693
+ st.invalidations++;
694
+ st.lastInvalidation = { at, reason };
695
+ }
696
+ // Status hand-off for the terminal panel (src/tui.tsx) and any
697
+ // out-of-process surface. Best-effort; never breaks injection.
698
+ await flushStatus(sid);
699
+ };
700
+
657
701
  // ---- hooks (all fail-closed) -------------------------------------------
658
702
  // Bookkeeping: track the active session/tool/call before a tool runs.
659
703
  await ctx.tool?.hook("execute.before", (event: any) =>
@@ -691,12 +735,14 @@ const impl = {
691
735
  guarded("session.context (systemDigest)", async () => {
692
736
  // Route store reads in this hook through the right session's scope.
693
737
  setCurrent({ sessionID: event?.sessionID });
738
+ const sid = event?.sessionID;
739
+ const mems = await injectableRows();
740
+ if (typeof sid === "string") await refreshEpoch(sid, mems);
694
741
  const parts: string[] = [];
695
- await systemDigest({ sessionID: event?.sessionID }, { system: parts });
742
+ await systemDigest({ sessionID: event?.sessionID }, { system: parts }, mems);
696
743
  for (const text of parts) event?.system?.push?.({ type: "text", text });
697
744
  // Deliver this epoch's relevance-ranked memory via the system channel.
698
745
  // The UI never renders system parts, unlike appended prompt text.
699
- const sid = event?.sessionID;
700
746
  const knowledge = typeof sid === "string" ? epoch.get(sid)?.block : undefined;
701
747
  if (knowledge) event?.system?.push?.({ type: "text", text: knowledge });
702
748
  // Count AT THE INJECTION DECISION so badge/panel numbers match exactly
@@ -712,10 +758,11 @@ const impl = {
712
758
  }),
713
759
  );
714
760
 
715
- // Memory-first injection, frozen per epoch (cache-safe). The block is
716
- // relevance-scored against the prompt that STARTED the epoch — accepted
717
- // staleness, traded for a stable system prefix across ordinary turns. An
718
- // epoch is invalidated only on first prompt, store change, or compaction.
761
+ // Memory-first injection, frozen per epoch (cache-safe). Remember the
762
+ // latest user prompt here; the request-time context hook builds or
763
+ // invalidates the epoch immediately before delivery. Relevance staleness
764
+ // is accepted while the injectable rows stay unchanged, preserving a
765
+ // stable system prefix across ordinary turns.
719
766
  // Never mutate event.prompt.text — it is stored verbatim and rendered in
720
767
  // the UI.
721
768
  await ctx.session?.hook("prompt", (event: any) =>
@@ -726,38 +773,8 @@ const impl = {
726
773
  markTurn(sid);
727
774
  await checkNewSession(sid);
728
775
  await bindSessionFromMessages(sid);
729
- const version = await storeVersion();
730
- const cached = epoch.get(sid);
731
- if (!cached || cached.version !== version) {
732
- const text = event?.prompt?.text ?? "";
733
- const raw = await relevantKnowledge(text, 5); // widen candidate pool
734
- const block = raw ? capToBudget(raw, budget) : "";
735
- const at = new Date().toISOString();
736
- const reason: "first" | "store_changed" = cached ? "store_changed" : "first";
737
- epoch.set(sid, { version, block, at, reason });
738
- await record(
739
- "epoch_recompute",
740
- {
741
- session: sid,
742
- reason,
743
- chars: block.length,
744
- },
745
- false,
746
- );
747
- epochRecomputes++;
748
- // Ledger: a store_changed recompute of a live epoch is an
749
- // invalidation (the first epoch of a session busts nothing). The
750
- // epoch request count restarts with the new block.
751
- const st = statsFor(sid);
752
- st.epochRequests = 0;
753
- if (reason === "store_changed") {
754
- st.invalidations++;
755
- st.lastInvalidation = { at, reason };
756
- }
757
- // Status hand-off for the terminal panel (src/tui.tsx) and any
758
- // out-of-process surface. Best-effort; never breaks injection.
759
- await flushStatus(sid);
760
- }
776
+ latestPrompt.set(sid, event?.prompt?.text ?? "");
777
+ await refreshEpoch(sid);
761
778
  }
762
779
  }),
763
780
  );
@@ -772,7 +789,10 @@ const impl = {
772
789
  guarded("session.compaction (onCompacting)", async () => {
773
790
  setCurrent({ sessionID: event?.sessionID });
774
791
  await onCompacting({ sessionID: event?.sessionID });
775
- if (typeof event?.sessionID === "string") epoch.delete(event.sessionID);
792
+ if (typeof event?.sessionID === "string") {
793
+ epoch.delete(event.sessionID);
794
+ latestPrompt.delete(event.sessionID);
795
+ }
776
796
  const anchor = await compactionAnchor();
777
797
  if (anchor) event?.system?.push?.({ type: "text", text: anchor });
778
798
  }),
package/src/inject.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // results. Evidence is never injected. 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 { createHash } from "node:crypto";
8
9
  import { isActive, loadAll, visibleHere, type Memory } from "./memory.ts";
9
10
  import { record } from "./evidence.ts";
10
11
  import { dateStr } from "./config.ts";
@@ -141,9 +142,10 @@ const frontierInjectedSessions = new Set<string>();
141
142
  export async function systemDigest(
142
143
  _input: unknown,
143
144
  output: TransformOutput,
145
+ rows?: Memory[],
144
146
  ): Promise<void> {
145
147
  // Repo-scoped: model-facing reads see this repo + global rows.
146
- const mems = visibleHere((await loadAll()).filter(isActive));
148
+ const mems = rows ?? visibleHere((await loadAll()).filter(isActive));
147
149
  if (!mems.length) return;
148
150
  const latest = flattenMemoryText(mems.map((m) => m.ts).sort().pop() ?? "?");
149
151
  output.system.push(
@@ -190,26 +192,33 @@ export async function systemDigest(
190
192
  );
191
193
  }
192
194
 
193
- export async function relevantKnowledge(message: string, limit = 3): Promise<string> {
195
+ export async function relevantKnowledge(message: string, limit = 3, rows?: Memory[]): Promise<string> {
194
196
  const text = message.trim();
195
197
  if (!text) return "";
196
198
  // Repo-scoped: model-facing reads see this repo + global rows.
197
- const mems = visibleHere((await loadAll()).filter(isActive));
199
+ const mems = rows ?? visibleHere((await loadAll()).filter(isActive));
198
200
  const top = scoreMemories(text, mems, limit);
199
201
  if (!top.length) return "";
200
202
  return `## Relevant durable memory${FRAME}\n${top.map(formatMemory).join("\n")}`;
201
203
  }
202
204
 
203
- // Deterministic fingerprint of the active store: `${activeCount}:${maxActiveTs}`.
204
- // Cheap change detector for the per-session epoch cache in index.ts: the frozen
205
- // injection block is recomputed only when this string changes (or after a
206
- // compaction). Evidence and superseded rows never affect it (I3, memory.ts).
207
- export async function storeVersion(): Promise<string> {
205
+ // Deterministic fingerprint of the rows that can affect injection. Hash the
206
+ // exact rendered row plus its full id, in load order: content edits, blocklist
207
+ // changes and supersession all invalidate an epoch, while evidence, hidden
208
+ // rows and non-rendered metadata do not. This is only a linear hash over rows
209
+ // loadAll() already had to read and parse for the request.
210
+ export async function storeVersion(rows?: Memory[]): Promise<string> {
208
211
  // Repo-scoped: model-facing reads see this repo + global rows.
209
- const mems = visibleHere((await loadAll()).filter(isActive));
212
+ const mems = rows ?? visibleHere((await loadAll()).filter(isActive));
213
+ if (!mems.length) return "0:";
210
214
  let max = "";
211
- for (const m of mems) if (m.ts > max) max = m.ts;
212
- return `${mems.length}:${max}`;
215
+ const hash = createHash("sha256");
216
+ for (const m of mems) {
217
+ if (m.ts > max) max = m.ts;
218
+ hash.update(JSON.stringify([m.ts, formatMemory(m)]));
219
+ hash.update("\n");
220
+ }
221
+ return `${mems.length}:${max}:${hash.digest("hex")}`;
213
222
  }
214
223
 
215
224
  // Trim an injection block to `budget` characters at WHOLE-line boundaries: