opencode-self-improvement 0.8.1 → 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/capture.ts +9 -2
- package/src/gates.ts +29 -0
- package/src/index.ts +71 -44
- package/src/inject.ts +29 -16
- package/src/memory.ts +13 -0
- package/src/portable.ts +9 -1
- package/src/sanitize.ts +9 -0
- package/src/schema.ts +18 -8
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/capture.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// All parsing functions are TOTAL: any malformed input yields [] / "" / null,
|
|
8
8
|
// never a throw.
|
|
9
9
|
import { STATUSES, TYPES } from "./types.ts";
|
|
10
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
10
11
|
|
|
11
12
|
export interface CaptureOptions {
|
|
12
13
|
model: string; // "provider/id"
|
|
@@ -97,10 +98,16 @@ export function parseCandidates(raw: string, max: number): CaptureCandidate[] {
|
|
|
97
98
|
if (out.length >= max) break;
|
|
98
99
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
99
100
|
const o = item as Record<string, unknown>;
|
|
100
|
-
const
|
|
101
|
-
const
|
|
101
|
+
const rawType = typeof o.type === "string" ? o.type : "";
|
|
102
|
+
const rawScope = typeof o.scope === "string" ? o.scope : "";
|
|
103
|
+
const type = rawType.trim();
|
|
104
|
+
const scope = rawScope.trim();
|
|
102
105
|
const content = typeof o.content === "string" ? o.content.trim() : "";
|
|
103
106
|
if (!type || !scope || !content) continue;
|
|
107
|
+
if (
|
|
108
|
+
flattenMemoryText(rawType) !== rawType ||
|
|
109
|
+
flattenMemoryText(rawScope) !== rawScope
|
|
110
|
+
) continue;
|
|
104
111
|
if (!VALID_TYPES.has(type)) continue;
|
|
105
112
|
if (content.length < 20 || content.length > 600) continue;
|
|
106
113
|
const status = o.status === "open" || o.status === "settled" ? o.status : undefined;
|
package/src/gates.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type Memory,
|
|
15
15
|
} from "./memory.ts";
|
|
16
16
|
import { chainId, contentHash, record, recordWriteBlocked } from "./evidence.ts";
|
|
17
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
17
18
|
import {
|
|
18
19
|
displayCall,
|
|
19
20
|
extractCallForms,
|
|
@@ -222,6 +223,34 @@ export async function runWriteGate(
|
|
|
222
223
|
};
|
|
223
224
|
}
|
|
224
225
|
|
|
226
|
+
const badMetadata: string[] = ([
|
|
227
|
+
["type", candidate.type],
|
|
228
|
+
["scope", candidate.scope],
|
|
229
|
+
["issue", candidate.issue],
|
|
230
|
+
] as const)
|
|
231
|
+
.filter(
|
|
232
|
+
([, value]) =>
|
|
233
|
+
value !== undefined &&
|
|
234
|
+
(typeof value !== "string" || flattenMemoryText(value) !== value),
|
|
235
|
+
)
|
|
236
|
+
.map(([field]) => field);
|
|
237
|
+
if (
|
|
238
|
+
candidate.tags !== undefined &&
|
|
239
|
+
(!Array.isArray(candidate.tags) ||
|
|
240
|
+
candidate.tags.some((tag) => typeof tag !== "string" || flattenMemoryText(tag) !== tag))
|
|
241
|
+
) {
|
|
242
|
+
badMetadata.push("tags");
|
|
243
|
+
}
|
|
244
|
+
if (badMetadata.length) {
|
|
245
|
+
await recordWriteBlocked(4, "invalid_metadata", [`fields=${badMetadata.join(",")}`], chain);
|
|
246
|
+
return {
|
|
247
|
+
action: "blocked",
|
|
248
|
+
reason: "invalid_metadata",
|
|
249
|
+
chain,
|
|
250
|
+
hint: `${badMetadata.join(", ")} must not contain control characters.`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
225
254
|
const ch = contentHash(candidate.content);
|
|
226
255
|
await record("gate_start", {
|
|
227
256
|
chain,
|
package/src/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ import { getCurrent, setCurrent } from "./state.ts";
|
|
|
65
65
|
import { resolveRepo, setRepoScope, rememberSessionDir, sessionDir } from "./repokey.ts";
|
|
66
66
|
import { readLines } from "./logfmt.ts";
|
|
67
67
|
import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
|
|
68
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
68
69
|
|
|
69
70
|
// Tool/input boundaries are zod schemas (src/schema.ts): the JSON Schema the
|
|
70
71
|
// host sees is GENERATED from them, and raw args are re-parsed at execution.
|
|
@@ -387,7 +388,10 @@ const impl = {
|
|
|
387
388
|
types.set(m.type, (types.get(m.type) ?? 0) + 1);
|
|
388
389
|
}
|
|
389
390
|
const fmt = (map: Map<string, number>) =>
|
|
390
|
-
[...map.entries()]
|
|
391
|
+
[...map.entries()]
|
|
392
|
+
.sort((a, b) => b[1] - a[1])
|
|
393
|
+
.map(([k, c]) => ` ${flattenMemoryText(k)}: ${c}`)
|
|
394
|
+
.join("\n");
|
|
391
395
|
return { content: `Total: ${mems.length}\n\nScopes:\n${fmt(scopes)}\n\nTypes:\n${fmt(types)}` };
|
|
392
396
|
},
|
|
393
397
|
}));
|
|
@@ -523,7 +527,10 @@ const impl = {
|
|
|
523
527
|
return { content: `No active open threads older than ${days} days.` };
|
|
524
528
|
}
|
|
525
529
|
const listing = stale
|
|
526
|
-
.map(
|
|
530
|
+
.map(
|
|
531
|
+
(m) =>
|
|
532
|
+
` ${flattenMemoryText(m.ts)} ${flattenMemoryText(m.type)}/${flattenMemoryText(m.scope)}: ${flattenMemoryText(m.content).slice(0, 100)}`,
|
|
533
|
+
)
|
|
527
534
|
.join("\n");
|
|
528
535
|
if (dryRun) {
|
|
529
536
|
await record(
|
|
@@ -595,10 +602,13 @@ const impl = {
|
|
|
595
602
|
|
|
596
603
|
// Per-session frozen injection epochs: the relevance block plus the store
|
|
597
604
|
// fingerprint it was computed against. Recomputed ONLY on the first prompt
|
|
598
|
-
// of a session, when
|
|
599
|
-
// prompt/compaction hooks) — so the system prefix, and with it the
|
|
600
|
-
// 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.
|
|
601
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>();
|
|
602
612
|
// Recomputes since server start: every one invalidated a prompt cache, so
|
|
603
613
|
// it is the honest cost number the surfaces show (status files / tools).
|
|
604
614
|
let epochRecomputes = 0;
|
|
@@ -647,6 +657,47 @@ const impl = {
|
|
|
647
657
|
const flushStatus = (sid: string): Promise<void> =>
|
|
648
658
|
buildPayload(sid).then((p) => (p ? writeStatus(p) : undefined));
|
|
649
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
|
+
|
|
650
701
|
// ---- hooks (all fail-closed) -------------------------------------------
|
|
651
702
|
// Bookkeeping: track the active session/tool/call before a tool runs.
|
|
652
703
|
await ctx.tool?.hook("execute.before", (event: any) =>
|
|
@@ -684,12 +735,14 @@ const impl = {
|
|
|
684
735
|
guarded("session.context (systemDigest)", async () => {
|
|
685
736
|
// Route store reads in this hook through the right session's scope.
|
|
686
737
|
setCurrent({ sessionID: event?.sessionID });
|
|
738
|
+
const sid = event?.sessionID;
|
|
739
|
+
const mems = await injectableRows();
|
|
740
|
+
if (typeof sid === "string") await refreshEpoch(sid, mems);
|
|
687
741
|
const parts: string[] = [];
|
|
688
|
-
await systemDigest({ sessionID: event?.sessionID }, { system: parts });
|
|
742
|
+
await systemDigest({ sessionID: event?.sessionID }, { system: parts }, mems);
|
|
689
743
|
for (const text of parts) event?.system?.push?.({ type: "text", text });
|
|
690
744
|
// Deliver this epoch's relevance-ranked memory via the system channel.
|
|
691
745
|
// The UI never renders system parts, unlike appended prompt text.
|
|
692
|
-
const sid = event?.sessionID;
|
|
693
746
|
const knowledge = typeof sid === "string" ? epoch.get(sid)?.block : undefined;
|
|
694
747
|
if (knowledge) event?.system?.push?.({ type: "text", text: knowledge });
|
|
695
748
|
// Count AT THE INJECTION DECISION so badge/panel numbers match exactly
|
|
@@ -705,10 +758,11 @@ const impl = {
|
|
|
705
758
|
}),
|
|
706
759
|
);
|
|
707
760
|
|
|
708
|
-
// Memory-first injection, frozen per epoch (cache-safe).
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
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.
|
|
712
766
|
// Never mutate event.prompt.text — it is stored verbatim and rendered in
|
|
713
767
|
// the UI.
|
|
714
768
|
await ctx.session?.hook("prompt", (event: any) =>
|
|
@@ -719,38 +773,8 @@ const impl = {
|
|
|
719
773
|
markTurn(sid);
|
|
720
774
|
await checkNewSession(sid);
|
|
721
775
|
await bindSessionFromMessages(sid);
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
if (!cached || cached.version !== version) {
|
|
725
|
-
const text = event?.prompt?.text ?? "";
|
|
726
|
-
const raw = await relevantKnowledge(text, 5); // widen candidate pool
|
|
727
|
-
const block = raw ? capToBudget(raw, budget) : "";
|
|
728
|
-
const at = new Date().toISOString();
|
|
729
|
-
const reason: "first" | "store_changed" = cached ? "store_changed" : "first";
|
|
730
|
-
epoch.set(sid, { version, block, at, reason });
|
|
731
|
-
await record(
|
|
732
|
-
"epoch_recompute",
|
|
733
|
-
{
|
|
734
|
-
session: sid,
|
|
735
|
-
reason,
|
|
736
|
-
chars: block.length,
|
|
737
|
-
},
|
|
738
|
-
false,
|
|
739
|
-
);
|
|
740
|
-
epochRecomputes++;
|
|
741
|
-
// Ledger: a store_changed recompute of a live epoch is an
|
|
742
|
-
// invalidation (the first epoch of a session busts nothing). The
|
|
743
|
-
// epoch request count restarts with the new block.
|
|
744
|
-
const st = statsFor(sid);
|
|
745
|
-
st.epochRequests = 0;
|
|
746
|
-
if (reason === "store_changed") {
|
|
747
|
-
st.invalidations++;
|
|
748
|
-
st.lastInvalidation = { at, reason };
|
|
749
|
-
}
|
|
750
|
-
// Status hand-off for the terminal panel (src/tui.tsx) and any
|
|
751
|
-
// out-of-process surface. Best-effort; never breaks injection.
|
|
752
|
-
await flushStatus(sid);
|
|
753
|
-
}
|
|
776
|
+
latestPrompt.set(sid, event?.prompt?.text ?? "");
|
|
777
|
+
await refreshEpoch(sid);
|
|
754
778
|
}
|
|
755
779
|
}),
|
|
756
780
|
);
|
|
@@ -765,7 +789,10 @@ const impl = {
|
|
|
765
789
|
guarded("session.compaction (onCompacting)", async () => {
|
|
766
790
|
setCurrent({ sessionID: event?.sessionID });
|
|
767
791
|
await onCompacting({ sessionID: event?.sessionID });
|
|
768
|
-
if (typeof event?.sessionID === "string")
|
|
792
|
+
if (typeof event?.sessionID === "string") {
|
|
793
|
+
epoch.delete(event.sessionID);
|
|
794
|
+
latestPrompt.delete(event.sessionID);
|
|
795
|
+
}
|
|
769
796
|
const anchor = await compactionAnchor();
|
|
770
797
|
if (anchor) event?.system?.push?.({ type: "text", text: anchor });
|
|
771
798
|
}),
|
package/src/inject.ts
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
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";
|
|
11
12
|
import { bracketList } from "./logfmt.ts";
|
|
12
13
|
import { recentConvergenceSummary } from "./triggers.ts";
|
|
14
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
13
15
|
|
|
14
16
|
export interface TransformOutput {
|
|
15
17
|
system: string[];
|
|
@@ -53,9 +55,12 @@ export function formatMemory(m: Memory): string {
|
|
|
53
55
|
// One memory is ONE line in every injected block: the store round-trips real
|
|
54
56
|
// newlines, so a planted "\n## Fake header" would forge its own section and
|
|
55
57
|
// escape the framing (and throw capToBudget's line accounting off). Line
|
|
56
|
-
// breaks collapse to spaces; ordinary spacing survives.
|
|
57
|
-
const
|
|
58
|
-
|
|
58
|
+
// breaks and other controls collapse to spaces; ordinary spacing survives.
|
|
59
|
+
const date = flattenMemoryText(dateOf(m.ts));
|
|
60
|
+
const type = flattenMemoryText(m.type);
|
|
61
|
+
const scope = flattenMemoryText(m.scope);
|
|
62
|
+
const content = flattenMemoryText(m.content);
|
|
63
|
+
return `- [${date}]${tag}${src} ${type}/${scope}: ${content}`;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
function argText(args: unknown): string {
|
|
@@ -137,11 +142,12 @@ const frontierInjectedSessions = new Set<string>();
|
|
|
137
142
|
export async function systemDigest(
|
|
138
143
|
_input: unknown,
|
|
139
144
|
output: TransformOutput,
|
|
145
|
+
rows?: Memory[],
|
|
140
146
|
): Promise<void> {
|
|
141
147
|
// Repo-scoped: model-facing reads see this repo + global rows.
|
|
142
|
-
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
148
|
+
const mems = rows ?? visibleHere((await loadAll()).filter(isActive));
|
|
143
149
|
if (!mems.length) return;
|
|
144
|
-
const latest = mems.map((m) => m.ts).sort().pop() ?? "?";
|
|
150
|
+
const latest = flattenMemoryText(mems.map((m) => m.ts).sort().pop() ?? "?");
|
|
145
151
|
output.system.push(
|
|
146
152
|
"\n## Persistent memory (opencode-self-improvement)\n" +
|
|
147
153
|
`${mems.length} durable memories; latest: ${latest}. ` +
|
|
@@ -177,7 +183,7 @@ export async function systemDigest(
|
|
|
177
183
|
"frontier_injected",
|
|
178
184
|
{
|
|
179
185
|
session: sid,
|
|
180
|
-
scopes: bracketList([...new Set(open.map((m) => m.scope))]),
|
|
186
|
+
scopes: bracketList([...new Set(open.map((m) => flattenMemoryText(m.scope)))]),
|
|
181
187
|
shown: open.length,
|
|
182
188
|
open_total: openMems.length,
|
|
183
189
|
ts_since: dateStr(),
|
|
@@ -186,26 +192,33 @@ export async function systemDigest(
|
|
|
186
192
|
);
|
|
187
193
|
}
|
|
188
194
|
|
|
189
|
-
export async function relevantKnowledge(message: string, limit = 3): Promise<string> {
|
|
195
|
+
export async function relevantKnowledge(message: string, limit = 3, rows?: Memory[]): Promise<string> {
|
|
190
196
|
const text = message.trim();
|
|
191
197
|
if (!text) return "";
|
|
192
198
|
// Repo-scoped: model-facing reads see this repo + global rows.
|
|
193
|
-
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
199
|
+
const mems = rows ?? visibleHere((await loadAll()).filter(isActive));
|
|
194
200
|
const top = scoreMemories(text, mems, limit);
|
|
195
201
|
if (!top.length) return "";
|
|
196
202
|
return `## Relevant durable memory${FRAME}\n${top.map(formatMemory).join("\n")}`;
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
// Deterministic fingerprint of the
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
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> {
|
|
204
211
|
// Repo-scoped: model-facing reads see this repo + global rows.
|
|
205
|
-
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
212
|
+
const mems = rows ?? visibleHere((await loadAll()).filter(isActive));
|
|
213
|
+
if (!mems.length) return "0:";
|
|
206
214
|
let max = "";
|
|
207
|
-
|
|
208
|
-
|
|
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")}`;
|
|
209
222
|
}
|
|
210
223
|
|
|
211
224
|
// Trim an injection block to `budget` characters at WHOLE-line boundaries:
|
package/src/memory.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { getCurrent } from "./state.ts";
|
|
|
11
11
|
import { appendLog, ensureDir, formatLine, parseLine, readLines, writeAtomic, type Fields } from "./logfmt.ts";
|
|
12
12
|
import { withStoreLock, LOCK_WRITE } from "./lock.ts";
|
|
13
13
|
import { blockedTs } from "./blocklist.ts";
|
|
14
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
14
15
|
|
|
15
16
|
export type MemoryStatus = "open" | "settled";
|
|
16
17
|
|
|
@@ -167,6 +168,18 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
|
167
168
|
if (input.content.length > MAX_CONTENT_CHARS) {
|
|
168
169
|
throw new Error(`memory content exceeds ${MAX_CONTENT_CHARS} chars (${input.content.length})`);
|
|
169
170
|
}
|
|
171
|
+
for (const [field, value] of [
|
|
172
|
+
["type", input.type],
|
|
173
|
+
["scope", input.scope],
|
|
174
|
+
["issue", input.issue],
|
|
175
|
+
] as const) {
|
|
176
|
+
if (value !== undefined && flattenMemoryText(value) !== value) {
|
|
177
|
+
throw new Error(`memory ${field} must not contain control characters`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (input.tags?.some((tag) => flattenMemoryText(tag) !== tag)) {
|
|
181
|
+
throw new Error("memory tags must not contain control characters");
|
|
182
|
+
}
|
|
170
183
|
// Repo stamp at the single write choke point: gated writes, viewer restores
|
|
171
184
|
// and edits all pass through here. Explicit "global" survives; an unscoped
|
|
172
185
|
// process (viewer) stamps nothing = global. Session identity wins over the
|
package/src/portable.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { memoryRoot } from "./config.ts";
|
|
|
8
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
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
11
12
|
|
|
12
13
|
// --- export -------------------------------------------------------------------
|
|
13
14
|
|
|
@@ -101,7 +102,8 @@ function exportHeader(line: string): { version: number } | null {
|
|
|
101
102
|
|
|
102
103
|
// A line is malformed (skipped, never gated) unless it is a JSON object with a
|
|
103
104
|
// known type, a non-empty scope, and non-empty content. Optional fields are
|
|
104
|
-
// sanitized rather than fatal
|
|
105
|
+
// sanitized rather than fatal where noted. Control characters in structured
|
|
106
|
+
// metadata make the row malformed; content alone may remain multiline.
|
|
105
107
|
function parseRow(line: string): WriteCandidate | null {
|
|
106
108
|
let row: unknown;
|
|
107
109
|
try {
|
|
@@ -113,7 +115,13 @@ function parseRow(line: string): WriteCandidate | null {
|
|
|
113
115
|
const r = row as Record<string, unknown>;
|
|
114
116
|
if (typeof r.type !== "string" || !(TYPES as readonly string[]).includes(r.type)) return null;
|
|
115
117
|
if (typeof r.scope !== "string" || !r.scope.trim()) return null;
|
|
118
|
+
if (flattenMemoryText(r.scope) !== r.scope) return null;
|
|
116
119
|
if (typeof r.content !== "string" || !r.content.trim()) return null;
|
|
120
|
+
if (
|
|
121
|
+
Array.isArray(r.tags) &&
|
|
122
|
+
r.tags.some((t) => typeof t === "string" && flattenMemoryText(t) !== t)
|
|
123
|
+
) return null;
|
|
124
|
+
if (typeof r.issue === "string" && flattenMemoryText(r.issue) !== r.issue) return null;
|
|
117
125
|
const c: WriteCandidate = { type: r.type, scope: r.scope, content: r.content };
|
|
118
126
|
if (r.status === "open" || r.status === "settled") c.status = r.status;
|
|
119
127
|
if (Array.isArray(r.tags) && r.tags.every((t) => typeof t === "string")) c.tags = r.tags;
|
package/src/sanitize.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Memory content may legitimately be multiline in the store, but every field
|
|
2
|
+
// rendered into a model-facing line must stay on that line. C0/C1 controls and
|
|
3
|
+
// Unicode line/paragraph separators all collapse through this one helper so
|
|
4
|
+
// renderers and write-boundary validators cannot drift.
|
|
5
|
+
const CONTROL_CHARS = /[\p{Cc}\p{Zl}\p{Zp}]+/gu;
|
|
6
|
+
|
|
7
|
+
export function flattenMemoryText(value: string): string {
|
|
8
|
+
return value.replace(CONTROL_CHARS, " ");
|
|
9
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -16,20 +16,30 @@
|
|
|
16
16
|
// check in memory.ts (row identity), where the row schema lives.
|
|
17
17
|
import { z, type ZodTypeAny } from "zod";
|
|
18
18
|
import { STATUSES, TYPES } from "./types.ts";
|
|
19
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
19
20
|
|
|
20
21
|
type JSONSchema = Record<string, unknown>;
|
|
21
22
|
|
|
22
23
|
/** Row identity anywhere it arrives off a boundary: an ISO instant. */
|
|
23
24
|
export const isoTs = z.iso.datetime();
|
|
24
25
|
|
|
26
|
+
// Structured row fields become labels in model-facing text. Content remains
|
|
27
|
+
// multiline by design and is flattened only when rendered; metadata controls
|
|
28
|
+
// are rejected at every write boundary instead. Lookup-only scopes (recall,
|
|
29
|
+
// forget, export) stay plain strings so a row stored before this check can
|
|
30
|
+
// still be found and forgotten.
|
|
31
|
+
const memoryField = z.string().refine((value) => flattenMemoryText(value) === value, {
|
|
32
|
+
message: "must not contain control characters",
|
|
33
|
+
});
|
|
34
|
+
|
|
25
35
|
// ---- tool argument schemas (JSON Schema generated from these) ------------
|
|
26
36
|
|
|
27
37
|
export const RememberArgs = z.object({
|
|
28
38
|
type: z.enum(TYPES).describe("Type of memory"),
|
|
29
|
-
scope:
|
|
39
|
+
scope: memoryField.describe("Scope/area (e.g., auth, api, ctl)"),
|
|
30
40
|
content: z.string().describe("The memory content"),
|
|
31
|
-
issue:
|
|
32
|
-
tags: z.array(
|
|
41
|
+
issue: memoryField.describe("Related issue").optional(),
|
|
42
|
+
tags: z.array(memoryField).optional(),
|
|
33
43
|
status: z
|
|
34
44
|
.enum(STATUSES)
|
|
35
45
|
.describe(
|
|
@@ -60,7 +70,7 @@ export const RecallArgs = z.object({
|
|
|
60
70
|
});
|
|
61
71
|
|
|
62
72
|
export const UpdateArgs = z.object({
|
|
63
|
-
scope:
|
|
73
|
+
scope: memoryField,
|
|
64
74
|
type: z.enum(TYPES),
|
|
65
75
|
content: z.string(),
|
|
66
76
|
query: z
|
|
@@ -140,10 +150,10 @@ export const DecayArgs = z.object({
|
|
|
140
150
|
export const EditBody = z.object({
|
|
141
151
|
ts: isoTs,
|
|
142
152
|
type: z.enum(TYPES),
|
|
143
|
-
scope:
|
|
153
|
+
scope: memoryField.min(1),
|
|
144
154
|
content: z.string().min(1),
|
|
145
|
-
issue:
|
|
146
|
-
tags: z.array(
|
|
155
|
+
issue: memoryField.optional(),
|
|
156
|
+
tags: z.array(memoryField).optional(),
|
|
147
157
|
// min NOT enforced here: the mutate layer owns the audit-reason contract
|
|
148
158
|
// (and its tests pin its message). Shape is the boundary's job only.
|
|
149
159
|
reason: z.string(),
|
|
@@ -156,7 +166,7 @@ export const UnblockBody = z.object({ ts: isoTs });
|
|
|
156
166
|
// POSTs append rows with empty type/scope/content.
|
|
157
167
|
export const RestoreEntryBody = z.object({
|
|
158
168
|
type: z.enum(TYPES),
|
|
159
|
-
scope:
|
|
169
|
+
scope: memoryField.min(1),
|
|
160
170
|
content: z.string().min(1),
|
|
161
171
|
});
|
|
162
172
|
|