opencode-self-improvement 0.4.1 → 0.5.0

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
@@ -296,7 +296,7 @@ same data (`memory_status` tool under the hood; no model call):
296
296
  "abstained: no vocabulary overlap"
297
297
  - **RECENT BLOCKED WRITES** — that `memory_remember` that didn't stick,
298
298
  with its gate reason
299
- - **STORE** — active / superseded / open / scopes
299
+ - **STORE** — active / superseded / open / scopes (plus `malformed`, only when the store contains rows whose `ts` is not a valid ISO instant — those are skipped by every reader, never loaded)
300
300
  - keys: `r` refresh, `f` fullscreen
301
301
  - a warning toast when a write is blocked (opt out: `"notifyBlocked": false`)
302
302
 
@@ -317,6 +317,8 @@ Run `bun memory-view` in your terminal and visit http://127.0.0.1:8787 in your
317
317
  | --- | --- |
318
318
  | `memory_*` tools not available | The plugin didn't load. Restart opencode and check the `plugins` entry in your config. |
319
319
  | `memory_remember` says blocked | A gate rejected the write (stale code reference, a cited `path/to/file.ts` that doesn't exist in the repo, overlap with a superseded memory, or an ambiguous claim). The reason is logged — check `memory_evidence`, then rephrase against current code and retry. |
320
+ | `invalid arguments to memory_*` | The tool call didn't match the tool's input schema — nothing was done, and the message names the offending fields. Schema validation lives at the tool boundary; gate blocks look different (`blocked at GATE (…)`). |
321
+ | STORE shows `N malformed` | Rows in a daily file have a `ts=` that is not a valid ISO instant (typically a hand-edit gone wrong). Every reader skips them; fix the `ts=` value or restore the file with `memory_undo`. |
320
322
  | `/repo-init` missing from the command list | The self-install couldn't write to `~/.config/opencode/commands/` (e.g. read-only config dir). Create it, restart, or just ask the model to "init memory for this repo" — it calls the same tool. |
321
323
  | Init says `Graph: unavailable` | `codebase-memory-mcp` couldn't start (not installed, or its once-only download is blocked). Install pinned `0.11.0` via the one-liner above, or point `OPENCODE_CBM_BIN` at the binary. Scripts are still remembered without it. |
322
324
  | Stale plugin after an update | Bump the pinned version in the `plugin` entry (or `opencode plugin update opencode-self-improvement` on v2) and restart opencode. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
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": {
@@ -39,15 +39,15 @@
39
39
  "codebase-memory-mcp": "0.11.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@biomejs/biome": "^2.6.2",
43
- "@opencode/plugin": "^2.0.32",
44
- "@opentui/core": ">=0.6.6",
45
- "@opentui/solid": ">=0.6.6",
46
- "@semantic-release/git": "^11.0.1",
47
- "@types/bun": "latest",
48
- "semantic-release": "^25.0.9",
49
- "solid-js": ">=1.9.0",
50
- "typescript": "^7.0.2"
42
+ "@biomejs/biome": "2.6.2",
43
+ "@opencode/plugin": "2.0.32",
44
+ "@opentui/core": "0.6.6",
45
+ "@opentui/solid": "0.6.6",
46
+ "@semantic-release/git": "11.0.1",
47
+ "@types/bun": "1.4.2",
48
+ "semantic-release": "25.0.9",
49
+ "solid-js": "1.9.17",
50
+ "typescript": "7.0.2"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@opentui/core": ">=0.6.6",
@@ -62,5 +62,8 @@
62
62
  "bench:recall": "bun bench/recall-quality.ts",
63
63
  "memory-view": "bun run src/viewer/server.ts",
64
64
  "lint": "biome lint"
65
+ },
66
+ "dependencies": {
67
+ "zod": "4.6.7"
65
68
  }
66
69
  }
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  isActive,
20
20
  isVisibleHere,
21
21
  loadAll,
22
+ malformedCount,
22
23
  markSuperseded,
23
24
  resolveMemoryTarget,
24
25
  snapshotFiles,
@@ -65,19 +66,23 @@ import { resolveRepo, setRepoScope } from "./repokey.ts";
65
66
  import { readLines } from "./logfmt.ts";
66
67
  import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
67
68
 
68
- // --- minimal JSON Schema helpers (OpenCode V2 tools take JSON Schema) --------
69
- type JSONSchema = Record<string, unknown>;
70
- const str = (): JSONSchema => ({ type: "string" });
71
- const bool = (): JSONSchema => ({ type: "boolean" });
72
- const num = (): JSONSchema => ({ type: "number" });
73
- const strArray = (): JSONSchema => ({ type: "array", items: { type: "string" } });
74
- const en = (values: readonly string[]): JSONSchema => ({ type: "string", enum: [...values] });
75
- function obj(properties: Record<string, JSONSchema>, required: string[] = []): JSONSchema {
76
- return { type: "object", properties, required, additionalProperties: false };
77
- }
78
- function withDesc(schema: JSONSchema, description: string): JSONSchema {
79
- return { ...schema, description };
80
- }
69
+ // Tool/input boundaries are zod schemas (src/schema.ts): the JSON Schema the
70
+ // host sees is GENERATED from them, and raw args are re-parsed at execution.
71
+ import {
72
+ DecayArgs,
73
+ EmptyArgs,
74
+ EvidenceArgs,
75
+ ExportArgs,
76
+ ForgetArgs,
77
+ ImportArgs,
78
+ InitArgs,
79
+ MergeReviewArgs,
80
+ RecallArgs,
81
+ RememberArgs,
82
+ UndoArgs,
83
+ UpdateArgs,
84
+ validated,
85
+ } from "./schema.ts";
81
86
 
82
87
  function scoreMemory(m: Memory, q: string): number {
83
88
  const hay = `${m.type} ${m.scope} ${m.content}`.toLowerCase();
@@ -94,7 +99,7 @@ function scoreMemory(m: Memory, q: string): number {
94
99
  interface ToolDef {
95
100
  name: string;
96
101
  description: string;
97
- input: JSONSchema;
102
+ input: Record<string, unknown>;
98
103
  options?: { codemode?: boolean; namespace?: string };
99
104
  execute: (args: any, context: any) => Promise<{ content: string }>;
100
105
  }
@@ -202,30 +207,12 @@ const impl = {
202
207
  await ctx.tool?.transform((editor) => {
203
208
  const direct = { codemode: false } as const;
204
209
 
205
- editor.add({
210
+ editor.add(validated(RememberArgs, {
206
211
  name: "memory_remember",
207
212
  description:
208
213
  "Store a memory after the 4-gate write pipeline validates it (canonical graph, stale refs, dupes). Blocked writes record evidence instead of persisting.",
209
214
  options: direct,
210
- input: obj(
211
- {
212
- type: withDesc(en(TYPES), "Type of memory"),
213
- scope: withDesc(str(), "Scope/area (e.g., auth, api, ctl)"),
214
- content: withDesc(str(), "The memory content"),
215
- issue: withDesc(str(), "Related issue"),
216
- tags: strArray(),
217
- status: withDesc(
218
- en(STATUSES),
219
- "open = live planning thread / frontier (surfaced at session start); settled or omitted = durable knowledge",
220
- ),
221
- global: withDesc(
222
- bool(),
223
- "Store cross-project (visible in every repo): user preferences and general tooling habits. Default false = the memory belongs to THIS repo.",
224
- ),
225
- },
226
- ["type", "scope", "content"],
227
- ),
228
- async execute(args: any): Promise<{ content: string }> {
215
+ async execute(args): Promise<{ content: string }> {
229
216
  const candidate: WriteCandidate = {
230
217
  type: args.type,
231
218
  scope: args.scope,
@@ -244,30 +231,25 @@ const impl = {
244
231
  }`,
245
232
  };
246
233
  },
247
- });
234
+ }));
248
235
 
249
- editor.add({
236
+ editor.add(validated(InitArgs, {
250
237
  name: "memory_init",
251
238
  description:
252
239
  "Initialize repository memory and knowledge graph. Ensures the codebase-memory graph for this repo is indexed (one-shot `cli` spawn; no session MCP needed) and re-derives build/lint/test/typecheck command memories through the write gate, then stores an init stamp keyed on git HEAD. Idempotent: a no-op when the stamp matches HEAD, unless force=true (refresh after a refactor).",
253
240
  options: direct,
254
- input: obj({
255
- force: withDesc(bool(), "Re-run even if initialized (refresh after drift)"),
256
- mode: withDesc(en(["fast", "moderate", "full"]), "codebase-memory index mode (default moderate)"),
257
- }),
258
- async execute(args: any): Promise<{ content: string }> {
241
+ async execute(args): Promise<{ content: string }> {
259
242
  const summary = await runInit({ liveRoot, force: args.force, mode: args.mode });
260
243
  return { content: formatInit(summary) };
261
244
  },
262
- });
245
+ }));
263
246
 
264
- editor.add({
247
+ editor.add(validated(RecallArgs, {
265
248
  name: "memory_recall",
266
249
  description:
267
250
  "Look up durable project knowledge — build/test/lint commands, decisions, patterns, preferences, blockers — BEFORE searching the repo or reading files. Use for 'how do I run X', conventions, and past decisions. By scope, type, or free-text query; with no filters it returns the most recent memories.",
268
251
  options: direct,
269
- input: obj({ scope: str(), type: en(TYPES), query: str(), limit: num() }),
270
- async execute(args: any): Promise<{ content: string }> {
252
+ async execute(args): Promise<{ content: string }> {
271
253
  let results = (await loadAll()).filter(isActive).filter(isVisibleHere);
272
254
  if (!results.length) return { content: "No memories found" };
273
255
  const total = results.length;
@@ -295,27 +277,14 @@ const impl = {
295
277
  }
296
278
  return { content: `Found ${results.length} (${total} total)\n\n${limited.map(formatMemory).join("\n")}` };
297
279
  },
298
- });
280
+ }));
299
281
 
300
- editor.add({
282
+ editor.add(validated(UpdateArgs, {
301
283
  name: "memory_update",
302
284
  description:
303
285
  "Update an existing memory (gated like remember; the prior entry is superseded, not erased). `query` selects which row to update: its exact id (ts), a clock fragment (e.g. HH:MM:SS.mmm), or a unique substring of its content. Omitting `query` targets the most recent active row of scope+type. An unmatched or ambiguous `query` fails closed and writes nothing.",
304
286
  options: direct,
305
- input: obj(
306
- {
307
- scope: str(),
308
- type: en(TYPES),
309
- content: str(),
310
- query: withDesc(
311
- str(),
312
- "Row selector: id (ts), clock fragment, or unique content substring. Omit to target the newest active row of scope+type.",
313
- ),
314
- status: en(STATUSES),
315
- },
316
- ["scope", "type", "content"],
317
- ),
318
- async execute(args: any): Promise<{ content: string }> {
287
+ async execute(args): Promise<{ content: string }> {
319
288
  const prior = (await loadAll()).filter(
320
289
  (m) => m.scope === args.scope && m.type === args.type && isActive(m) && isVisibleHere(m),
321
290
  );
@@ -356,30 +325,24 @@ const impl = {
356
325
  if (outcome.action === "collapsed") return { content: `Duplicate ignored: ${args.type}/${args.scope}` };
357
326
  return { content: `Write blocked at GATE (${outcome.reason}). ${outcome.hint ?? ""}`.trimEnd() };
358
327
  },
359
- });
328
+ }));
360
329
 
361
- editor.add({
330
+ editor.add(validated(ForgetArgs, {
362
331
  name: "memory_forget",
363
332
  description:
364
333
  "Delete memories by scope and type. Snapshots first, logs the reason for audit, never touches a repo.",
365
334
  options: direct,
366
- input: obj({ scope: str(), type: en(TYPES), reason: withDesc(str(), "Why this is being deleted (audit)") }, [
367
- "scope",
368
- "type",
369
- "reason",
370
- ]),
371
- async execute(args: any): Promise<{ content: string }> {
335
+ async execute(args): Promise<{ content: string }> {
372
336
  const res = await pruneMemory(args.scope, args.type, args.reason);
373
337
  if (!res.deleted.length) return { content: `No memories found for ${args.type} in ${args.scope}` };
374
338
  return { content: `Deleted ${res.deleted.length} ${args.type} memory(s) from ${args.scope}. Audit: ${res.audit}` };
375
339
  },
376
- });
340
+ }));
377
341
 
378
- editor.add({
342
+ editor.add(validated(EmptyArgs, {
379
343
  name: "memory_list",
380
344
  description: "List memory scopes and types for discovery.",
381
345
  options: direct,
382
- input: obj({}),
383
346
  async execute(): Promise<{ content: string }> {
384
347
  const mems = (await loadAll()).filter(isActive).filter(isVisibleHere);
385
348
  if (!mems.length) return { content: "No memories found" };
@@ -393,20 +356,14 @@ const impl = {
393
356
  [...map.entries()].sort((a, b) => b[1] - a[1]).map(([k, c]) => ` ${k}: ${c}`).join("\n");
394
357
  return { content: `Total: ${mems.length}\n\nScopes:\n${fmt(scopes)}\n\nTypes:\n${fmt(types)}` };
395
358
  },
396
- });
359
+ }));
397
360
 
398
- editor.add({
361
+ editor.add(validated(UndoArgs, {
399
362
  name: "memory_undo",
400
363
  description:
401
364
  "List memory snapshots, or restore one (rollback). SAFE BY DEFAULT: daily store files are shared across sessions, so if the current file has rows added or edited since the snapshot, restore REFUSES and lists them — re-run with merge=true to restore AND keep those rows, or force=true for the exact rollback. `snapshot` accepts a bare name from the list or a full path.",
402
365
  options: direct,
403
- input: obj({
404
- list: withDesc(bool(), "List snapshots"),
405
- snapshot: withDesc(str(), "Snapshot to restore (bare name from list, or a full path)"),
406
- merge: withDesc(bool(), "With snapshot: keep rows written after the snapshot instead of refusing"),
407
- force: withDesc(bool(), "With snapshot: exact rollback even if the file changed since"),
408
- }),
409
- async execute(args: any): Promise<{ content: string }> {
366
+ async execute(args): Promise<{ content: string }> {
410
367
  if (args.snapshot) {
411
368
  const mode = args.force ? "force" : args.merge ? "merge" : "check";
412
369
  const res = await restoreSnapshot(args.snapshot, memoryRoot(), mode);
@@ -430,14 +387,13 @@ const impl = {
430
387
  if (!snaps.length) return { content: "No snapshots" };
431
388
  return { content: snaps.map((s) => `${s.name} (${s.bytes} bytes)`).join("\n") };
432
389
  },
433
- });
390
+ }));
434
391
 
435
- editor.add({
392
+ editor.add(validated(EvidenceArgs, {
436
393
  name: "memory_evidence",
437
394
  description: "Read captured evidence (data only) for a date, optionally filtered by event.",
438
395
  options: direct,
439
- input: obj({ date: str(), ev: str(), limit: num() }),
440
- async execute(args: any): Promise<{ content: string }> {
396
+ async execute(args): Promise<{ content: string }> {
441
397
  const file = join(evidenceDir(), `${args.date || dateStr()}.logfmt`);
442
398
  let lines = await readLines(file);
443
399
  if (args.ev) lines = lines.filter((l) => l.includes(`ev=${args.ev}`));
@@ -445,22 +401,14 @@ const impl = {
445
401
  const limit = args.limit || 50;
446
402
  return { content: lines.slice(-limit).join("\n") };
447
403
  },
448
- });
404
+ }));
449
405
 
450
- editor.add({
406
+ editor.add(validated(MergeReviewArgs, {
451
407
  name: "memory_merge_review",
452
408
  description:
453
409
  "Resolve an open merge/review gate non-destructively: mark the loser as superseded_by the survivor, and clear any supersession the survivor itself carries so the canonical row stays recallable. Both rows stay active data (the loser is recoverable via memory_undo); nothing is deleted. Fails closed (no partial apply) when the reason or the survivor id is missing, or the loser is already superseded.",
454
410
  options: direct,
455
- input: obj(
456
- {
457
- loser: withDesc(str(), "Evidence id of the rival/overlap write to supersede"),
458
- survivor: withDesc(str(), "Evidence id of the canonical winning write"),
459
- reason: withDesc(str(), "Why the survivor is canonical over the loser (written to evidence)"),
460
- },
461
- ["loser", "survivor", "reason"],
462
- ),
463
- async execute(args: any): Promise<{ content: string }> {
411
+ async execute(args): Promise<{ content: string }> {
464
412
  if (!args.loser || !args.survivor || !args.reason) {
465
413
  return { content: "merge_review blocked: loser, survivor, and reason are all required (fail-closed)." };
466
414
  }
@@ -485,30 +433,25 @@ const impl = {
485
433
  `Evidence recorded for reason='${args.reason}'.`,
486
434
  };
487
435
  },
488
- });
436
+ }));
489
437
 
490
- editor.add({
438
+ editor.add(validated(ExportArgs, {
491
439
  name: "memory_export",
492
440
  description:
493
441
  "Export active memories as a JSONL file (one JSON object per line: {ts,type,scope,content,status,tags,issue}) for backup or transfer to another machine. Defaults to <memory-root>/exports/<timestamp>.jsonl; pass `file` to choose the path, `scope` to export one scope. Superseded rows are not exported (history stays in the store).",
494
442
  options: direct,
495
- input: obj({
496
- scope: withDesc(str(), "Only export memories in this exact scope"),
497
- file: withDesc(str(), "Destination path (default: <memory-root>/exports/)"),
498
- }),
499
- async execute(args: any): Promise<{ content: string }> {
443
+ async execute(args): Promise<{ content: string }> {
500
444
  const res = await exportMemories({ scope: args.scope, file: args.file });
501
445
  return { content: `Exported ${res.count} memories -> ${res.path}` };
502
446
  },
503
- });
447
+ }));
504
448
 
505
- editor.add({
449
+ editor.add(validated(ImportArgs, {
506
450
  name: "memory_import",
507
451
  description:
508
452
  "Import memories from a JSONL export (see memory_export). Every row goes through the same 4-gate write pipeline as memory_remember (tagged `import`, original tags preserved), so duplicates collapse and ungrounded rows are blocked exactly as usual — those are normal tallied outcomes, not errors. Malformed lines are skipped and counted.",
509
453
  options: direct,
510
- input: obj({ file: withDesc(str(), "Path to a .jsonl export file") }, ["file"]),
511
- async execute(args: any): Promise<{ content: string }> {
454
+ async execute(args): Promise<{ content: string }> {
512
455
  try {
513
456
  const t = await importMemories(args.file, (c) =>
514
457
  runWriteGate(
@@ -527,24 +470,14 @@ const impl = {
527
470
  };
528
471
  }
529
472
  },
530
- });
473
+ }));
531
474
 
532
- editor.add({
475
+ editor.add(validated(DecayArgs, {
533
476
  name: "memory_decay",
534
477
  description:
535
478
  "Retire stale open threads (status=open older than olderThanDays) by superseding them — never deleting; rows stay recoverable via memory_undo. DRY RUN BY DEFAULT: without an explicit dryRun=false this only LISTS what would retire and mutates nothing (fail-closed). A real run snapshots every affected file before the first mutation.",
536
479
  options: direct,
537
- input: obj({
538
- olderThanDays: withDesc(
539
- { type: "number", default: 14 },
540
- "Age threshold in days (default 14)",
541
- ),
542
- dryRun: withDesc(
543
- { type: "boolean", default: true },
544
- "Preview only (default true). Set false to actually supersede.",
545
- ),
546
- }),
547
- async execute(args: any): Promise<{ content: string }> {
480
+ async execute(args): Promise<{ content: string }> {
548
481
  const days = Number(args.olderThanDays) > 0 ? Number(args.olderThanDays) : 14;
549
482
  const dryRun = args.dryRun !== false; // only an explicit false mutates
550
483
  const stale = await findStaleOpen(days);
@@ -595,14 +528,13 @@ const impl = {
595
528
  "Recover with memory_undo.",
596
529
  };
597
530
  },
598
- });
531
+ }));
599
532
 
600
- editor.add({
533
+ editor.add(validated(EmptyArgs, {
601
534
  name: "memory_status",
602
535
  description:
603
536
  "Plain-text memory report for chat clients (desktop/web/IDE), same sections as the terminal /memory panel: CONTEXT (this session's injected block, budget use, cumulative ride and abstentions), CACHE (epoch invalidations plus the measured session prompt-cache read rate), the block verbatim, recent blocked writes with gate reasons, and store stats. Reads only — no model call.",
604
537
  options: direct,
605
- input: obj({}),
606
538
  async execute(): Promise<{ content: string }> {
607
539
  // In-process truth: the live epoch + ledger, fresher than any file.
608
540
  const sid = getCurrent().sid;
@@ -614,12 +546,13 @@ const impl = {
614
546
  superseded: all.length - act.length,
615
547
  open: act.filter((m) => m.status === "open").length,
616
548
  scopes: new Set(act.map((m) => m.scope)).size,
549
+ malformed: malformedCount(),
617
550
  };
618
551
  return {
619
552
  content: renderMemoryReport({ status, store, blocked: await blockedRecent(6) }),
620
553
  };
621
554
  },
622
- });
555
+ }));
623
556
  });
624
557
 
625
558
  // Per-session frozen injection epochs: the relevance block plus the store
@@ -667,6 +600,7 @@ const impl = {
667
600
  superseded: all.length - act.length,
668
601
  open: act.filter((m) => m.status === "open").length,
669
602
  scopes: new Set(act.map((m) => m.scope)).size,
603
+ malformed: malformedCount(),
670
604
  },
671
605
  stats: statsFor(sid),
672
606
  measured: measuredTurns.get(sid) ?? null,
package/src/memory.ts CHANGED
@@ -46,12 +46,36 @@ const RESERVED = new Set([
46
46
  "blocklist.logfmt",
47
47
  ]);
48
48
 
49
+ // Row identity is `ts`: supersede chains, the blocklist, undo matching, and
50
+ // the viewer's action buttons all key off it (issue #15). A row whose ts is
51
+ // not an ISO instant must never become an addressable live row — readers
52
+ // treat it as malformed: skipped, counted, warned once per file. The shape
53
+ // check lives here (the store knows its schema); logfmt stays a dumb parser.
54
+ const ISO_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
55
+ let malformedRows = 0;
56
+ const malformedWarnedFiles = new Set<string>();
57
+
58
+ /** Rows skipped as malformed by the most recent store read (approximate, in-process). */
59
+ export function malformedCount(): number {
60
+ return malformedRows;
61
+ }
62
+
49
63
  function toMemory(fields: Record<string, unknown>, file: string, index: number): Memory | null {
50
64
  const ts = fields.ts;
51
65
  const type = fields.type;
52
66
  const scope = fields.scope;
53
67
  if (typeof ts !== "string" || typeof type !== "string") return null;
54
68
  if (typeof scope !== "string") return null;
69
+ if (!ISO_TS_RE.test(ts) || Number.isNaN(Date.parse(ts))) {
70
+ malformedRows++;
71
+ if (!malformedWarnedFiles.has(file)) {
72
+ malformedWarnedFiles.add(file);
73
+ console.warn(
74
+ `[memory] malformed row skipped in ${file} (ts is not an ISO instant): ${JSON.stringify(ts.slice(0, 48))}`,
75
+ );
76
+ }
77
+ return null;
78
+ }
55
79
  return {
56
80
  ts,
57
81
  type,
@@ -85,6 +109,7 @@ export async function memoryFiles(): Promise<string[]> {
85
109
  }
86
110
 
87
111
  export async function loadAll(): Promise<Memory[]> {
112
+ malformedRows = 0; // per-load telemetry: the most recent read wins
88
113
  const blocked = await blockedTs();
89
114
  const out: Memory[] = [];
90
115
  for (const file of await memoryFiles()) {
package/src/schema.ts ADDED
@@ -0,0 +1,211 @@
1
+ // Input-boundary schemas (issue #15's sibling work; zod verdict from the
2
+ // 2026-10-21 security review). ONE definition per boundary serves both sides:
3
+ // the JSON Schema handed to the host/model is GENERATED from these zod
4
+ // schemas, and every raw tool call / viewer POST is re-parsed through them
5
+ // before touching any business logic. Keeping a hand-written wire schema next
6
+ // to a runtime validator would just recreate the drift surface zod removes.
7
+ //
8
+ // Scope — shape, not meaning:
9
+ // * Numbers coerce (z.coerce.number): `limit: "20"` means 20, and stringified
10
+ // numerics already worked downstream, so coercion is strictly non-breaking.
11
+ // * Booleans and strings stay strict. z.coerce.boolean() is JS Boolean() —
12
+ // "false" would coerce to TRUE, silently flipping a dry run into a
13
+ // mutation; a rejected call gets a clear error the model can fix instead.
14
+ // * Nothing here validates semantics (a well-shaped lie still passes) —
15
+ // that is what the 4-gate pipeline is for.
16
+ // * Store reads deliberately do NOT use zod: they get a plain ISO-instant
17
+ // check in memory.ts (row identity, issue #15), where the row schema lives.
18
+ import { z, type ZodTypeAny } from "zod";
19
+ import { STATUSES, TYPES } from "./types.ts";
20
+
21
+ type JSONSchema = Record<string, unknown>;
22
+
23
+ /** Row identity anywhere it arrives off a boundary: an ISO instant. */
24
+ export const isoTs = z.iso.datetime();
25
+
26
+ // ---- tool argument schemas (JSON Schema generated from these) ------------
27
+
28
+ export const RememberArgs = z.object({
29
+ type: z.enum(TYPES).describe("Type of memory"),
30
+ scope: z.string().describe("Scope/area (e.g., auth, api, ctl)"),
31
+ content: z.string().describe("The memory content"),
32
+ issue: z.string().describe("Related issue").optional(),
33
+ tags: z.array(z.string()).optional(),
34
+ status: z
35
+ .enum(STATUSES)
36
+ .describe(
37
+ "open = live planning thread / frontier (surfaced at session start); settled or omitted = durable knowledge",
38
+ )
39
+ .optional(),
40
+ global: z
41
+ .boolean()
42
+ .describe(
43
+ "Store cross-project (visible in every repo): user preferences and general tooling habits. Default false = the memory belongs to THIS repo.",
44
+ )
45
+ .optional(),
46
+ });
47
+
48
+ export const InitArgs = z.object({
49
+ force: z.boolean().describe("Re-run even if initialized (refresh after drift)").optional(),
50
+ mode: z
51
+ .enum(["fast", "moderate", "full"])
52
+ .describe("codebase-memory index mode (default moderate)")
53
+ .optional(),
54
+ });
55
+
56
+ export const RecallArgs = z.object({
57
+ scope: z.string().optional(),
58
+ type: z.enum(TYPES).optional(),
59
+ query: z.string().optional(),
60
+ limit: z.coerce.number().optional(),
61
+ });
62
+
63
+ export const UpdateArgs = z.object({
64
+ scope: z.string(),
65
+ type: z.enum(TYPES),
66
+ content: z.string(),
67
+ query: z
68
+ .string()
69
+ .describe(
70
+ "Row selector: id (ts), clock fragment, or unique content substring. Omit to target the newest active row of scope+type.",
71
+ )
72
+ .optional(),
73
+ status: z.enum(STATUSES).optional(),
74
+ });
75
+
76
+ export const ForgetArgs = z.object({
77
+ scope: z.string(),
78
+ type: z.enum(TYPES),
79
+ reason: z.string().describe("Why this is being deleted (audit)"),
80
+ });
81
+
82
+ export const EmptyArgs = z.object({});
83
+
84
+ export const UndoArgs = z.object({
85
+ list: z.boolean().describe("List snapshots").optional(),
86
+ snapshot: z
87
+ .string()
88
+ .describe("Snapshot to restore (bare name from list, or a full path)")
89
+ .optional(),
90
+ merge: z
91
+ .boolean()
92
+ .describe("With snapshot: keep rows written after the snapshot instead of refusing")
93
+ .optional(),
94
+ force: z
95
+ .boolean()
96
+ .describe("With snapshot: exact rollback even if the file changed since")
97
+ .optional(),
98
+ });
99
+
100
+ export const EvidenceArgs = z.object({
101
+ date: z.string().optional(),
102
+ ev: z.string().optional(),
103
+ limit: z.coerce.number().optional(),
104
+ });
105
+
106
+ export const MergeReviewArgs = z.object({
107
+ loser: z.string().describe("Evidence id of the rival/overlap write to supersede"),
108
+ survivor: z.string().describe("Evidence id of the canonical winning write"),
109
+ reason: z.string().describe("Why the survivor is canonical over the loser (written to evidence)"),
110
+ });
111
+
112
+ export const ExportArgs = z.object({
113
+ scope: z.string().describe("Only export memories in this exact scope").optional(),
114
+ file: z.string().describe("Destination path (default: <memory-root>/exports/)").optional(),
115
+ });
116
+
117
+ export const ImportArgs = z.object({
118
+ file: z.string().describe("Path to a .jsonl export file"),
119
+ });
120
+
121
+ export const DecayArgs = z.object({
122
+ olderThanDays: z
123
+ .coerce
124
+ .number()
125
+ .optional()
126
+ .meta({ default: 14 })
127
+ .describe("Age threshold in days (default 14)"),
128
+ // Strict boolean on purpose: only a real JSON false mutates. A string
129
+ // "false" is rejected with guidance, never coerced into `true` (see header).
130
+ dryRun: z
131
+ .boolean()
132
+ .optional()
133
+ .meta({ default: true })
134
+ .describe("Preview only (default true). Set false to actually supersede."),
135
+ });
136
+
137
+ // ---- viewer request bodies (the boundary here is the network) ------------
138
+ // /api/restore is NOT here: its `name` grammar (basename + pinned regex) is
139
+ // containment logic with its own regression tests, not a shape gate.
140
+
141
+ export const EditBody = z.object({
142
+ ts: isoTs,
143
+ type: z.enum(TYPES),
144
+ scope: z.string().min(1),
145
+ content: z.string().min(1),
146
+ issue: z.string().optional(),
147
+ tags: z.array(z.string()).optional(),
148
+ // min NOT enforced here: the mutate layer owns the audit-reason contract
149
+ // (and its tests pin its message). Shape is the boundary's job only.
150
+ reason: z.string(),
151
+ });
152
+
153
+ export const DeleteBody = z.object({ ts: isoTs, reason: z.string() });
154
+ export const BlockBody = z.object({ ts: isoTs, reason: z.string() });
155
+ export const UnblockBody = z.object({ ts: isoTs });
156
+ // The restore-entry hole (security review): unvalidated POSTs used to append
157
+ // rows with empty type/scope/content — String(body.x ?? "") made anything fit.
158
+ export const RestoreEntryBody = z.object({
159
+ type: z.enum(TYPES),
160
+ scope: z.string().min(1),
161
+ content: z.string().min(1),
162
+ });
163
+
164
+ // ---- wiring ---------------------------------------------------------------
165
+
166
+ /**
167
+ * Generate the host-facing JSON Schema. Matches the pre-zod hand-written
168
+ * builders byte-for-byte modulo key order (test/boundaries.test.ts pins all
169
+ * 13 against a capture from main): $schema stripped, empty `required` kept.
170
+ */
171
+ export function wire(schema: ZodTypeAny): JSONSchema {
172
+ const out = z.toJSONSchema(schema) as JSONSchema;
173
+ delete out.$schema;
174
+ out.required ??= [];
175
+ return out;
176
+ }
177
+
178
+ export interface ToolDef<S extends ZodTypeAny> {
179
+ name: string;
180
+ description: string;
181
+ options: { codemode: false };
182
+ execute(args: z.infer<S>): Promise<{ content: string }>;
183
+ }
184
+
185
+ /**
186
+ * Register a tool: wire schema from zod + runtime re-parse of raw args.
187
+ * A malformed call returns a self-describing error the model can act on —
188
+ * nothing was done — instead of throwing inside the host or coercing junk
189
+ * into the store.
190
+ */
191
+ export function validated<S extends ZodTypeAny>(
192
+ schema: S,
193
+ def: ToolDef<S>,
194
+ ): ToolDef<S> & { input: JSONSchema } {
195
+ return {
196
+ ...def,
197
+ input: wire(schema),
198
+ async execute(raw: unknown): Promise<{ content: string }> {
199
+ const parsed = schema.safeParse(raw ?? {});
200
+ if (!parsed.success) {
201
+ const issues = parsed.error.issues.map(
202
+ (i) => `${i.path.join(".") || "(root)"}: ${i.message}`,
203
+ );
204
+ return {
205
+ content: `invalid arguments to ${def.name} — nothing was done: ${issues.join("; ")}`,
206
+ };
207
+ }
208
+ return def.execute(parsed.data);
209
+ },
210
+ };
211
+ }
package/src/status.ts CHANGED
@@ -22,6 +22,8 @@ export interface StoreCounts {
22
22
  superseded: number;
23
23
  open: number;
24
24
  scopes: number;
25
+ /** Rows skipped as malformed by the most recent store read (issue #15). */
26
+ malformed: number;
25
27
  }
26
28
 
27
29
  /**
@@ -309,7 +311,8 @@ export function renderMemoryReport(input: {
309
311
  out.push(
310
312
  "",
311
313
  "STORE",
312
- ` ${store.active} active · ${store.superseded} superseded · ${store.open} open · ${store.scopes} scopes`,
314
+ ` ${store.active} active · ${store.superseded} superseded · ${store.open} open · ${store.scopes} scopes` +
315
+ (store.malformed ? ` · ${store.malformed} malformed (skipped; fix the ts= field or undo)` : ""),
313
316
  );
314
317
  out.push("", "Terminal (CLI/TUI) users: /memory opens this as a live panel; the footer shows a one-line badge.");
315
318
  return out.join("\n");
package/src/tui.tsx CHANGED
@@ -285,7 +285,8 @@ function MemoryPanel(props: {
285
285
  <Show when={s()}>
286
286
  <text fg={context.theme.text.base}>
287
287
  {" " + (s()?.store.active ?? 0) + " active · " + (s()?.store.superseded ?? 0) + " superseded · " +
288
- (s()?.store.open ?? 0) + " open · " + (s()?.store.scopes ?? 0) + " scopes"}
288
+ (s()?.store.open ?? 0) + " open · " + (s()?.store.scopes ?? 0) + " scopes" +
289
+ ((s()?.store.malformed ?? 0) > 0 ? " · " + s()!.store.malformed + " malformed (skipped)" : "")}
289
290
  </text>
290
291
  </Show>
291
292
 
@@ -6,6 +6,8 @@
6
6
  // I2: serves only file data; never writes into any repo.
7
7
  import { basename, join } from "node:path";
8
8
  import { serve, type Server } from "bun";
9
+ import { z, type ZodTypeAny } from "zod";
10
+ import { BlockBody, DeleteBody, EditBody, RestoreEntryBody, UnblockBody } from "../schema.ts";
9
11
  import { deletionsFile, memoryRoot, snapshotDir } from "../config.ts";
10
12
  import { blockedEntries } from "../blocklist.ts";
11
13
  import { listSnapshots, restoreSnapshot } from "../undo.ts";
@@ -194,48 +196,67 @@ async function loaded(): Promise<Record<string, unknown>> {
194
196
 
195
197
  async function route(req: Request, url: URL): Promise<Response> {
196
198
  if (req.method === "POST") {
197
- const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
198
- if (url.pathname === "/api/edit") return editRoute(body);
199
- if (url.pathname === "/api/delete") return deleteRoute(body);
200
- if (url.pathname === "/api/block") return blockRoute(body);
201
- if (url.pathname === "/api/unblock") return unblockRoute(body);
202
- if (url.pathname === "/api/restore-entry") return restoreEntryRoute(body);
203
- if (url.pathname === "/api/restore") return restoreRoute(body);
199
+ const body: unknown = await req.json().catch(() => ({}));
200
+ if (url.pathname === "/api/edit") return parseWith(EditBody, body, editRoute);
201
+ if (url.pathname === "/api/delete") return parseWith(DeleteBody, body, deleteRoute);
202
+ if (url.pathname === "/api/block") return parseWith(BlockBody, body, blockRoute);
203
+ if (url.pathname === "/api/unblock") return parseWith(UnblockBody, body, unblockRoute);
204
+ if (url.pathname === "/api/restore-entry") return parseWith(RestoreEntryBody, body, restoreEntryRoute);
205
+ // /api/restore is deliberately NOT schema-parsed here: its name check is
206
+ // basename + pinned-grammar CONTAINMENT (issue #21), not a shape gate,
207
+ // and its regression tests pin that behavior.
208
+ if (url.pathname === "/api/restore") return restoreRoute(body as Record<string, unknown>);
204
209
  }
205
210
  return json({ error: "not found" }, 404);
206
211
  }
207
212
 
208
- async function editRoute(body: Record<string, unknown>): Promise<Response> {
213
+ // Boundary shape check for POST routes (the restore-entry hole from the
214
+ // 2026-10-21 review): the old `String(body.ts ?? "")` coercion made ANY junk
215
+ // fit — ts:{} became "[object Object]" as a row identity, a missing content
216
+ // appended an empty row. A malformed call now gets a clean 400; semantics
217
+ // (which row, what for) stay with the mutate layer.
218
+ function parseWith<S extends ZodTypeAny>(
219
+ schema: S,
220
+ body: unknown,
221
+ h: (data: z.infer<S>) => Response | Promise<Response>,
222
+ ): Response | Promise<Response> {
223
+ const p = schema.safeParse(body);
224
+ if (!p.success) {
225
+ return error(
226
+ `invalid request: ${p.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
227
+ 400,
228
+ );
229
+ }
230
+ return h(p.data);
231
+ }
232
+
233
+ async function editRoute(body: z.infer<typeof EditBody>): Promise<Response> {
209
234
  const r: MutationResult = await editMemory({
210
- ts: String(body.ts ?? ""),
211
- type: String(body.type ?? ""),
212
- scope: String(body.scope ?? ""),
213
- content: String(body.content ?? ""),
214
- issue: typeof body.issue === "string" ? body.issue : undefined,
215
- tags: Array.isArray(body.tags) ? (body.tags as string[]) : undefined,
216
- reason: String(body.reason ?? ""),
235
+ ts: body.ts,
236
+ type: body.type,
237
+ scope: body.scope,
238
+ content: body.content,
239
+ issue: body.issue,
240
+ tags: body.tags,
241
+ reason: body.reason,
217
242
  });
218
243
  return json(r);
219
244
  }
220
245
 
221
- async function deleteRoute(body: Record<string, unknown>): Promise<Response> {
222
- return json(await deleteMemory({ ts: String(body.ts ?? ""), reason: String(body.reason ?? "") }));
246
+ async function deleteRoute(body: z.infer<typeof DeleteBody>): Promise<Response> {
247
+ return json(await deleteMemory({ ts: body.ts, reason: body.reason }));
223
248
  }
224
249
 
225
- async function blockRoute(body: Record<string, unknown>): Promise<Response> {
226
- return json({ ts: await addBlock(String(body.ts ?? ""), String(body.reason ?? "")) });
250
+ async function blockRoute(body: z.infer<typeof BlockBody>): Promise<Response> {
251
+ return json({ ts: await addBlock(body.ts, body.reason) });
227
252
  }
228
253
 
229
- async function unblockRoute(body: Record<string, unknown>): Promise<Response> {
230
- return json({ unblocked: await removeBlock(String(body.ts ?? "")) });
254
+ async function unblockRoute(body: z.infer<typeof UnblockBody>): Promise<Response> {
255
+ return json({ unblocked: await removeBlock(body.ts) });
231
256
  }
232
257
 
233
- async function restoreEntryRoute(body: Record<string, unknown>): Promise<Response> {
234
- const t = await appendMemory({
235
- type: String(body.type ?? ""),
236
- scope: String(body.scope ?? ""),
237
- content: String(body.content ?? ""),
238
- });
258
+ async function restoreEntryRoute(body: z.infer<typeof RestoreEntryBody>): Promise<Response> {
259
+ const t = await appendMemory({ type: body.type, scope: body.scope, content: body.content });
239
260
  return json({ restored: t });
240
261
  }
241
262