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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.8.1",
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 type = typeof o.type === "string" ? o.type.trim() : "";
101
- const scope = typeof o.scope === "string" ? o.scope.trim() : "";
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()].sort((a, b) => b[1] - a[1]).map(([k, c]) => ` ${k}: ${c}`).join("\n");
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((m) => ` ${m.ts} ${m.type}/${m.scope}: ${m.content.slice(0, 100)}`)
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 the store changes, or after a compaction (see the
599
- // prompt/compaction hooks) — so the system prefix, and with it the
600
- // 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.
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). The block is
709
- // relevance-scored against the prompt that STARTED the epoch — accepted
710
- // staleness, traded for a stable system prefix across ordinary turns. An
711
- // 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.
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
- const version = await storeVersion();
723
- const cached = epoch.get(sid);
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") epoch.delete(event.sessionID);
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 content = m.content.replace(/[\r\n\t]+/g, " ");
58
- return `- [${dateOf(m.ts)}]${tag}${src} ${m.type}/${m.scope}: ${content}`;
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 active store: `${activeCount}:${maxActiveTs}`.
200
- // Cheap change detector for the per-session epoch cache in index.ts: the frozen
201
- // injection block is recomputed only when this string changes (or after a
202
- // compaction). Evidence and superseded rows never affect it (I3, memory.ts).
203
- 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> {
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
- for (const m of mems) if (m.ts > max) max = m.ts;
208
- 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")}`;
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: an unusable status/tags/issue is dropped.
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;
@@ -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: z.string().describe("Scope/area (e.g., auth, api, ctl)"),
39
+ scope: memoryField.describe("Scope/area (e.g., auth, api, ctl)"),
30
40
  content: z.string().describe("The memory content"),
31
- issue: z.string().describe("Related issue").optional(),
32
- tags: z.array(z.string()).optional(),
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: z.string(),
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: z.string().min(1),
153
+ scope: memoryField.min(1),
144
154
  content: z.string().min(1),
145
- issue: z.string().optional(),
146
- tags: z.array(z.string()).optional(),
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: z.string().min(1),
169
+ scope: memoryField.min(1),
160
170
  content: z.string().min(1),
161
171
  });
162
172