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.
- package/package.json +1 -1
- package/src/index.ts +62 -42
- 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.
|
|
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
|
|
606
|
-
// prompt/compaction hooks) — so the system prefix, and with it the
|
|
607
|
-
// provider prompt cache, survives ordinary
|
|
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).
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
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
|
-
|
|
730
|
-
|
|
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")
|
|
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
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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:
|