opencode-self-improvement 0.3.6 → 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 +15 -1
- package/package.json +13 -10
- package/src/gates.ts +5 -0
- package/src/index.ts +69 -123
- package/src/init.ts +6 -2
- package/src/inject.ts +11 -6
- package/src/memory.ts +78 -2
- package/src/portable.ts +13 -2
- package/src/repokey.ts +66 -0
- package/src/resolver.ts +5 -1
- package/src/schema.ts +211 -0
- package/src/status.ts +4 -1
- package/src/tui.tsx +2 -1
- package/src/viewer/server.ts +48 -27
package/README.md
CHANGED
|
@@ -231,6 +231,18 @@ as `00:11:25.867`, or a unique substring of its text. Omit it to update the newe
|
|
|
231
231
|
entry of that scope and type. A `query` matching nothing, or matching several rows,
|
|
232
232
|
writes nothing and says so rather than updating the wrong memory.
|
|
233
233
|
|
|
234
|
+
## Repo scoping
|
|
235
|
+
|
|
236
|
+
Memories belong to the repo that wrote them: rows are stamped with your git
|
|
237
|
+
remote (an id that survives re-clones and moves between machines) or, with no
|
|
238
|
+
remote, the repo path. Injection, recall, the frontier block, compaction,
|
|
239
|
+
`memory_forget`, and decay only ever see the current repo plus global rows —
|
|
240
|
+
project A's facts never surface in project B. Preferences and habits that
|
|
241
|
+
should follow you everywhere go in with `memory_remember global: true`.
|
|
242
|
+
Memories written before this existed, and ones restored through the viewer,
|
|
243
|
+
are global (visible everywhere), so upgrading changes nothing; exports carry
|
|
244
|
+
origin stamps, so scoping survives a machine hop.
|
|
245
|
+
|
|
234
246
|
## Backup & move between machines
|
|
235
247
|
|
|
236
248
|
`memory_export` writes the live store as JSONL — one JSON object per line
|
|
@@ -284,7 +296,7 @@ same data (`memory_status` tool under the hood; no model call):
|
|
|
284
296
|
"abstained: no vocabulary overlap"
|
|
285
297
|
- **RECENT BLOCKED WRITES** — that `memory_remember` that didn't stick,
|
|
286
298
|
with its gate reason
|
|
287
|
-
- **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)
|
|
288
300
|
- keys: `r` refresh, `f` fullscreen
|
|
289
301
|
- a warning toast when a write is blocked (opt out: `"notifyBlocked": false`)
|
|
290
302
|
|
|
@@ -305,6 +317,8 @@ Run `bun memory-view` in your terminal and visit http://127.0.0.1:8787 in your
|
|
|
305
317
|
| --- | --- |
|
|
306
318
|
| `memory_*` tools not available | The plugin didn't load. Restart opencode and check the `plugins` entry in your config. |
|
|
307
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`. |
|
|
308
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. |
|
|
309
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. |
|
|
310
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.
|
|
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": "
|
|
43
|
-
"@opencode/plugin": "
|
|
44
|
-
"@opentui/core": "
|
|
45
|
-
"@opentui/solid": "
|
|
46
|
-
"@semantic-release/git": "
|
|
47
|
-
"@types/bun": "
|
|
48
|
-
"semantic-release": "
|
|
49
|
-
"solid-js": "
|
|
50
|
-
"typescript": "
|
|
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/gates.ts
CHANGED
|
@@ -30,6 +30,11 @@ export interface WriteCandidate {
|
|
|
30
30
|
issue?: string;
|
|
31
31
|
tags?: string[];
|
|
32
32
|
status?: "open" | "settled";
|
|
33
|
+
// "global" = cross-project (memory_remember global:true; imports carry a
|
|
34
|
+
// row's origin stamp). Any other value (including undefined) is resolved by
|
|
35
|
+
// appendMemory against the live repo scope (issue #8).
|
|
36
|
+
repo?: string;
|
|
37
|
+
repoPath?: string;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export interface GateContext {
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,9 @@ import { STATUSES, TYPES } from "./types.ts";
|
|
|
17
17
|
import {
|
|
18
18
|
clearSuperseded,
|
|
19
19
|
isActive,
|
|
20
|
+
isVisibleHere,
|
|
20
21
|
loadAll,
|
|
22
|
+
malformedCount,
|
|
21
23
|
markSuperseded,
|
|
22
24
|
resolveMemoryTarget,
|
|
23
25
|
snapshotFiles,
|
|
@@ -60,22 +62,27 @@ import {
|
|
|
60
62
|
onCompacting,
|
|
61
63
|
} from "./triggers.ts";
|
|
62
64
|
import { getCurrent, setCurrent } from "./state.ts";
|
|
65
|
+
import { resolveRepo, setRepoScope } from "./repokey.ts";
|
|
63
66
|
import { readLines } from "./logfmt.ts";
|
|
64
67
|
import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
|
|
65
68
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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";
|
|
79
86
|
|
|
80
87
|
function scoreMemory(m: Memory, q: string): number {
|
|
81
88
|
const hay = `${m.type} ${m.scope} ${m.content}`.toLowerCase();
|
|
@@ -92,7 +99,7 @@ function scoreMemory(m: Memory, q: string): number {
|
|
|
92
99
|
interface ToolDef {
|
|
93
100
|
name: string;
|
|
94
101
|
description: string;
|
|
95
|
-
input:
|
|
102
|
+
input: Record<string, unknown>;
|
|
96
103
|
options?: { codemode?: boolean; namespace?: string };
|
|
97
104
|
execute: (args: any, context: any) => Promise<{ content: string }>;
|
|
98
105
|
}
|
|
@@ -163,6 +170,11 @@ const impl = {
|
|
|
163
170
|
async setup(ctx: PluginCtx): Promise<Cleanup | void> {
|
|
164
171
|
const liveRoot = ctx?.location?.directory ?? process.cwd();
|
|
165
172
|
|
|
173
|
+
// Repo scoping (issue #8): resolve this workspace's identity ONCE. Every
|
|
174
|
+
// append stamps it; every model-facing read filters by it. Never throws —
|
|
175
|
+
// a non-git dir simply scopes by path.
|
|
176
|
+
setRepoScope(resolveRepo(liveRoot));
|
|
177
|
+
|
|
166
178
|
// Character budget for the epoch-frozen injection block: plugin options
|
|
167
179
|
// (object form in opencode.json(c)) win, then OPENCODE_MEMORY_INJECT_BUDGET,
|
|
168
180
|
// then the 1200 default.
|
|
@@ -195,26 +207,12 @@ const impl = {
|
|
|
195
207
|
await ctx.tool?.transform((editor) => {
|
|
196
208
|
const direct = { codemode: false } as const;
|
|
197
209
|
|
|
198
|
-
editor.add({
|
|
210
|
+
editor.add(validated(RememberArgs, {
|
|
199
211
|
name: "memory_remember",
|
|
200
212
|
description:
|
|
201
213
|
"Store a memory after the 4-gate write pipeline validates it (canonical graph, stale refs, dupes). Blocked writes record evidence instead of persisting.",
|
|
202
214
|
options: direct,
|
|
203
|
-
|
|
204
|
-
{
|
|
205
|
-
type: withDesc(en(TYPES), "Type of memory"),
|
|
206
|
-
scope: withDesc(str(), "Scope/area (e.g., auth, api, ctl)"),
|
|
207
|
-
content: withDesc(str(), "The memory content"),
|
|
208
|
-
issue: withDesc(str(), "Related issue"),
|
|
209
|
-
tags: strArray(),
|
|
210
|
-
status: withDesc(
|
|
211
|
-
en(STATUSES),
|
|
212
|
-
"open = live planning thread / frontier (surfaced at session start); settled or omitted = durable knowledge",
|
|
213
|
-
),
|
|
214
|
-
},
|
|
215
|
-
["type", "scope", "content"],
|
|
216
|
-
),
|
|
217
|
-
async execute(args: any): Promise<{ content: string }> {
|
|
215
|
+
async execute(args): Promise<{ content: string }> {
|
|
218
216
|
const candidate: WriteCandidate = {
|
|
219
217
|
type: args.type,
|
|
220
218
|
scope: args.scope,
|
|
@@ -222,6 +220,7 @@ const impl = {
|
|
|
222
220
|
issue: args.issue,
|
|
223
221
|
tags: args.tags,
|
|
224
222
|
status: args.status,
|
|
223
|
+
repo: args.global ? "global" : undefined,
|
|
225
224
|
};
|
|
226
225
|
const outcome = await runWriteGate(candidate, gateContext());
|
|
227
226
|
if (outcome.action === "promoted") return { content: `Remembered: ${args.type} in ${args.scope}` };
|
|
@@ -232,31 +231,26 @@ const impl = {
|
|
|
232
231
|
}`,
|
|
233
232
|
};
|
|
234
233
|
},
|
|
235
|
-
});
|
|
234
|
+
}));
|
|
236
235
|
|
|
237
|
-
editor.add({
|
|
236
|
+
editor.add(validated(InitArgs, {
|
|
238
237
|
name: "memory_init",
|
|
239
238
|
description:
|
|
240
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).",
|
|
241
240
|
options: direct,
|
|
242
|
-
|
|
243
|
-
force: withDesc(bool(), "Re-run even if initialized (refresh after drift)"),
|
|
244
|
-
mode: withDesc(en(["fast", "moderate", "full"]), "codebase-memory index mode (default moderate)"),
|
|
245
|
-
}),
|
|
246
|
-
async execute(args: any): Promise<{ content: string }> {
|
|
241
|
+
async execute(args): Promise<{ content: string }> {
|
|
247
242
|
const summary = await runInit({ liveRoot, force: args.force, mode: args.mode });
|
|
248
243
|
return { content: formatInit(summary) };
|
|
249
244
|
},
|
|
250
|
-
});
|
|
245
|
+
}));
|
|
251
246
|
|
|
252
|
-
editor.add({
|
|
247
|
+
editor.add(validated(RecallArgs, {
|
|
253
248
|
name: "memory_recall",
|
|
254
249
|
description:
|
|
255
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.",
|
|
256
251
|
options: direct,
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
let results = (await loadAll()).filter(isActive);
|
|
252
|
+
async execute(args): Promise<{ content: string }> {
|
|
253
|
+
let results = (await loadAll()).filter(isActive).filter(isVisibleHere);
|
|
260
254
|
if (!results.length) return { content: "No memories found" };
|
|
261
255
|
const total = results.length;
|
|
262
256
|
if (args.scope) results = results.filter((m) => m.scope.includes(args.scope!));
|
|
@@ -283,29 +277,16 @@ const impl = {
|
|
|
283
277
|
}
|
|
284
278
|
return { content: `Found ${results.length} (${total} total)\n\n${limited.map(formatMemory).join("\n")}` };
|
|
285
279
|
},
|
|
286
|
-
});
|
|
280
|
+
}));
|
|
287
281
|
|
|
288
|
-
editor.add({
|
|
282
|
+
editor.add(validated(UpdateArgs, {
|
|
289
283
|
name: "memory_update",
|
|
290
284
|
description:
|
|
291
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.",
|
|
292
286
|
options: direct,
|
|
293
|
-
|
|
294
|
-
{
|
|
295
|
-
scope: str(),
|
|
296
|
-
type: en(TYPES),
|
|
297
|
-
content: str(),
|
|
298
|
-
query: withDesc(
|
|
299
|
-
str(),
|
|
300
|
-
"Row selector: id (ts), clock fragment, or unique content substring. Omit to target the newest active row of scope+type.",
|
|
301
|
-
),
|
|
302
|
-
status: en(STATUSES),
|
|
303
|
-
},
|
|
304
|
-
["scope", "type", "content"],
|
|
305
|
-
),
|
|
306
|
-
async execute(args: any): Promise<{ content: string }> {
|
|
287
|
+
async execute(args): Promise<{ content: string }> {
|
|
307
288
|
const prior = (await loadAll()).filter(
|
|
308
|
-
(m) => m.scope === args.scope && m.type === args.type && isActive(m),
|
|
289
|
+
(m) => m.scope === args.scope && m.type === args.type && isActive(m) && isVisibleHere(m),
|
|
309
290
|
);
|
|
310
291
|
// Resolve the row BEFORE the gate: an update that cannot identify its
|
|
311
292
|
// target must write nothing, rather than silently superseding whichever
|
|
@@ -344,32 +325,26 @@ const impl = {
|
|
|
344
325
|
if (outcome.action === "collapsed") return { content: `Duplicate ignored: ${args.type}/${args.scope}` };
|
|
345
326
|
return { content: `Write blocked at GATE (${outcome.reason}). ${outcome.hint ?? ""}`.trimEnd() };
|
|
346
327
|
},
|
|
347
|
-
});
|
|
328
|
+
}));
|
|
348
329
|
|
|
349
|
-
editor.add({
|
|
330
|
+
editor.add(validated(ForgetArgs, {
|
|
350
331
|
name: "memory_forget",
|
|
351
332
|
description:
|
|
352
333
|
"Delete memories by scope and type. Snapshots first, logs the reason for audit, never touches a repo.",
|
|
353
334
|
options: direct,
|
|
354
|
-
|
|
355
|
-
"scope",
|
|
356
|
-
"type",
|
|
357
|
-
"reason",
|
|
358
|
-
]),
|
|
359
|
-
async execute(args: any): Promise<{ content: string }> {
|
|
335
|
+
async execute(args): Promise<{ content: string }> {
|
|
360
336
|
const res = await pruneMemory(args.scope, args.type, args.reason);
|
|
361
337
|
if (!res.deleted.length) return { content: `No memories found for ${args.type} in ${args.scope}` };
|
|
362
338
|
return { content: `Deleted ${res.deleted.length} ${args.type} memory(s) from ${args.scope}. Audit: ${res.audit}` };
|
|
363
339
|
},
|
|
364
|
-
});
|
|
340
|
+
}));
|
|
365
341
|
|
|
366
|
-
editor.add({
|
|
342
|
+
editor.add(validated(EmptyArgs, {
|
|
367
343
|
name: "memory_list",
|
|
368
344
|
description: "List memory scopes and types for discovery.",
|
|
369
345
|
options: direct,
|
|
370
|
-
input: obj({}),
|
|
371
346
|
async execute(): Promise<{ content: string }> {
|
|
372
|
-
const mems = (await loadAll()).filter(isActive);
|
|
347
|
+
const mems = (await loadAll()).filter(isActive).filter(isVisibleHere);
|
|
373
348
|
if (!mems.length) return { content: "No memories found" };
|
|
374
349
|
const scopes = new Map<string, number>();
|
|
375
350
|
const types = new Map<string, number>();
|
|
@@ -381,20 +356,14 @@ const impl = {
|
|
|
381
356
|
[...map.entries()].sort((a, b) => b[1] - a[1]).map(([k, c]) => ` ${k}: ${c}`).join("\n");
|
|
382
357
|
return { content: `Total: ${mems.length}\n\nScopes:\n${fmt(scopes)}\n\nTypes:\n${fmt(types)}` };
|
|
383
358
|
},
|
|
384
|
-
});
|
|
359
|
+
}));
|
|
385
360
|
|
|
386
|
-
editor.add({
|
|
361
|
+
editor.add(validated(UndoArgs, {
|
|
387
362
|
name: "memory_undo",
|
|
388
363
|
description:
|
|
389
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.",
|
|
390
365
|
options: direct,
|
|
391
|
-
|
|
392
|
-
list: withDesc(bool(), "List snapshots"),
|
|
393
|
-
snapshot: withDesc(str(), "Snapshot to restore (bare name from list, or a full path)"),
|
|
394
|
-
merge: withDesc(bool(), "With snapshot: keep rows written after the snapshot instead of refusing"),
|
|
395
|
-
force: withDesc(bool(), "With snapshot: exact rollback even if the file changed since"),
|
|
396
|
-
}),
|
|
397
|
-
async execute(args: any): Promise<{ content: string }> {
|
|
366
|
+
async execute(args): Promise<{ content: string }> {
|
|
398
367
|
if (args.snapshot) {
|
|
399
368
|
const mode = args.force ? "force" : args.merge ? "merge" : "check";
|
|
400
369
|
const res = await restoreSnapshot(args.snapshot, memoryRoot(), mode);
|
|
@@ -418,14 +387,13 @@ const impl = {
|
|
|
418
387
|
if (!snaps.length) return { content: "No snapshots" };
|
|
419
388
|
return { content: snaps.map((s) => `${s.name} (${s.bytes} bytes)`).join("\n") };
|
|
420
389
|
},
|
|
421
|
-
});
|
|
390
|
+
}));
|
|
422
391
|
|
|
423
|
-
editor.add({
|
|
392
|
+
editor.add(validated(EvidenceArgs, {
|
|
424
393
|
name: "memory_evidence",
|
|
425
394
|
description: "Read captured evidence (data only) for a date, optionally filtered by event.",
|
|
426
395
|
options: direct,
|
|
427
|
-
|
|
428
|
-
async execute(args: any): Promise<{ content: string }> {
|
|
396
|
+
async execute(args): Promise<{ content: string }> {
|
|
429
397
|
const file = join(evidenceDir(), `${args.date || dateStr()}.logfmt`);
|
|
430
398
|
let lines = await readLines(file);
|
|
431
399
|
if (args.ev) lines = lines.filter((l) => l.includes(`ev=${args.ev}`));
|
|
@@ -433,22 +401,14 @@ const impl = {
|
|
|
433
401
|
const limit = args.limit || 50;
|
|
434
402
|
return { content: lines.slice(-limit).join("\n") };
|
|
435
403
|
},
|
|
436
|
-
});
|
|
404
|
+
}));
|
|
437
405
|
|
|
438
|
-
editor.add({
|
|
406
|
+
editor.add(validated(MergeReviewArgs, {
|
|
439
407
|
name: "memory_merge_review",
|
|
440
408
|
description:
|
|
441
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.",
|
|
442
410
|
options: direct,
|
|
443
|
-
|
|
444
|
-
{
|
|
445
|
-
loser: withDesc(str(), "Evidence id of the rival/overlap write to supersede"),
|
|
446
|
-
survivor: withDesc(str(), "Evidence id of the canonical winning write"),
|
|
447
|
-
reason: withDesc(str(), "Why the survivor is canonical over the loser (written to evidence)"),
|
|
448
|
-
},
|
|
449
|
-
["loser", "survivor", "reason"],
|
|
450
|
-
),
|
|
451
|
-
async execute(args: any): Promise<{ content: string }> {
|
|
411
|
+
async execute(args): Promise<{ content: string }> {
|
|
452
412
|
if (!args.loser || !args.survivor || !args.reason) {
|
|
453
413
|
return { content: "merge_review blocked: loser, survivor, and reason are all required (fail-closed)." };
|
|
454
414
|
}
|
|
@@ -473,30 +433,25 @@ const impl = {
|
|
|
473
433
|
`Evidence recorded for reason='${args.reason}'.`,
|
|
474
434
|
};
|
|
475
435
|
},
|
|
476
|
-
});
|
|
436
|
+
}));
|
|
477
437
|
|
|
478
|
-
editor.add({
|
|
438
|
+
editor.add(validated(ExportArgs, {
|
|
479
439
|
name: "memory_export",
|
|
480
440
|
description:
|
|
481
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).",
|
|
482
442
|
options: direct,
|
|
483
|
-
|
|
484
|
-
scope: withDesc(str(), "Only export memories in this exact scope"),
|
|
485
|
-
file: withDesc(str(), "Destination path (default: <memory-root>/exports/)"),
|
|
486
|
-
}),
|
|
487
|
-
async execute(args: any): Promise<{ content: string }> {
|
|
443
|
+
async execute(args): Promise<{ content: string }> {
|
|
488
444
|
const res = await exportMemories({ scope: args.scope, file: args.file });
|
|
489
445
|
return { content: `Exported ${res.count} memories -> ${res.path}` };
|
|
490
446
|
},
|
|
491
|
-
});
|
|
447
|
+
}));
|
|
492
448
|
|
|
493
|
-
editor.add({
|
|
449
|
+
editor.add(validated(ImportArgs, {
|
|
494
450
|
name: "memory_import",
|
|
495
451
|
description:
|
|
496
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.",
|
|
497
453
|
options: direct,
|
|
498
|
-
|
|
499
|
-
async execute(args: any): Promise<{ content: string }> {
|
|
454
|
+
async execute(args): Promise<{ content: string }> {
|
|
500
455
|
try {
|
|
501
456
|
const t = await importMemories(args.file, (c) =>
|
|
502
457
|
runWriteGate(
|
|
@@ -515,24 +470,14 @@ const impl = {
|
|
|
515
470
|
};
|
|
516
471
|
}
|
|
517
472
|
},
|
|
518
|
-
});
|
|
473
|
+
}));
|
|
519
474
|
|
|
520
|
-
editor.add({
|
|
475
|
+
editor.add(validated(DecayArgs, {
|
|
521
476
|
name: "memory_decay",
|
|
522
477
|
description:
|
|
523
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.",
|
|
524
479
|
options: direct,
|
|
525
|
-
|
|
526
|
-
olderThanDays: withDesc(
|
|
527
|
-
{ type: "number", default: 14 },
|
|
528
|
-
"Age threshold in days (default 14)",
|
|
529
|
-
),
|
|
530
|
-
dryRun: withDesc(
|
|
531
|
-
{ type: "boolean", default: true },
|
|
532
|
-
"Preview only (default true). Set false to actually supersede.",
|
|
533
|
-
),
|
|
534
|
-
}),
|
|
535
|
-
async execute(args: any): Promise<{ content: string }> {
|
|
480
|
+
async execute(args): Promise<{ content: string }> {
|
|
536
481
|
const days = Number(args.olderThanDays) > 0 ? Number(args.olderThanDays) : 14;
|
|
537
482
|
const dryRun = args.dryRun !== false; // only an explicit false mutates
|
|
538
483
|
const stale = await findStaleOpen(days);
|
|
@@ -583,14 +528,13 @@ const impl = {
|
|
|
583
528
|
"Recover with memory_undo.",
|
|
584
529
|
};
|
|
585
530
|
},
|
|
586
|
-
});
|
|
531
|
+
}));
|
|
587
532
|
|
|
588
|
-
editor.add({
|
|
533
|
+
editor.add(validated(EmptyArgs, {
|
|
589
534
|
name: "memory_status",
|
|
590
535
|
description:
|
|
591
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.",
|
|
592
537
|
options: direct,
|
|
593
|
-
input: obj({}),
|
|
594
538
|
async execute(): Promise<{ content: string }> {
|
|
595
539
|
// In-process truth: the live epoch + ledger, fresher than any file.
|
|
596
540
|
const sid = getCurrent().sid;
|
|
@@ -602,12 +546,13 @@ const impl = {
|
|
|
602
546
|
superseded: all.length - act.length,
|
|
603
547
|
open: act.filter((m) => m.status === "open").length,
|
|
604
548
|
scopes: new Set(act.map((m) => m.scope)).size,
|
|
549
|
+
malformed: malformedCount(),
|
|
605
550
|
};
|
|
606
551
|
return {
|
|
607
552
|
content: renderMemoryReport({ status, store, blocked: await blockedRecent(6) }),
|
|
608
553
|
};
|
|
609
554
|
},
|
|
610
|
-
});
|
|
555
|
+
}));
|
|
611
556
|
});
|
|
612
557
|
|
|
613
558
|
// Per-session frozen injection epochs: the relevance block plus the store
|
|
@@ -655,6 +600,7 @@ const impl = {
|
|
|
655
600
|
superseded: all.length - act.length,
|
|
656
601
|
open: act.filter((m) => m.status === "open").length,
|
|
657
602
|
scopes: new Set(act.map((m) => m.scope)).size,
|
|
603
|
+
malformed: malformedCount(),
|
|
658
604
|
},
|
|
659
605
|
stats: statsFor(sid),
|
|
660
606
|
measured: measuredTurns.get(sid) ?? null,
|
package/src/init.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { basename, join } from "node:path";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { cbmBin } from "./config.ts";
|
|
11
|
-
import { loadAll, markSuperseded } from "./memory.ts";
|
|
11
|
+
import { isVisibleHere, loadAll, markSuperseded } from "./memory.ts";
|
|
12
12
|
import { runWriteGate, type GateContext } from "./gates.ts";
|
|
13
13
|
import { record } from "./evidence.ts";
|
|
14
14
|
import { evidenceCalls, hasGateCheck } from "./triggers.ts";
|
|
@@ -185,7 +185,11 @@ export interface InitStamp {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
export async function initStamp(scope: string): Promise<InitStamp | null> {
|
|
188
|
-
|
|
188
|
+
// Issue #8: scope is a basename, so two different repos can share one
|
|
189
|
+
// ("api" next to "api", a fork beside its upstream). Visibility filtering
|
|
190
|
+
// makes each repo see only its own stamp — basename collisions no longer
|
|
191
|
+
// alias init state between repos.
|
|
192
|
+
const mems = (await loadAll()).filter((m) => !m.superseded_by && isVisibleHere(m));
|
|
189
193
|
for (const m of mems) {
|
|
190
194
|
if (m.type !== "context" || m.scope !== scope) continue;
|
|
191
195
|
if (!m.tags?.some((t) => t === "init-stamp")) continue;
|
package/src/inject.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// results. Evidence is never injected (invariant I3). 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 { isActive, loadAll, type Memory } from "./memory.ts";
|
|
8
|
+
import { isActive, loadAll, visibleHere, type Memory } from "./memory.ts";
|
|
9
9
|
import { record } from "./evidence.ts";
|
|
10
10
|
import { dateStr } from "./config.ts";
|
|
11
11
|
import { bracketList } from "./logfmt.ts";
|
|
@@ -93,7 +93,8 @@ export async function systemDigest(
|
|
|
93
93
|
_input: unknown,
|
|
94
94
|
output: TransformOutput,
|
|
95
95
|
): Promise<void> {
|
|
96
|
-
|
|
96
|
+
// Repo-scoped (issue #8): model-facing reads see this repo + global rows.
|
|
97
|
+
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
97
98
|
if (!mems.length) return;
|
|
98
99
|
const latest = mems.map((m) => m.ts).sort().pop() ?? "?";
|
|
99
100
|
output.system.push(
|
|
@@ -143,7 +144,8 @@ export async function systemDigest(
|
|
|
143
144
|
export async function relevantKnowledge(message: string, limit = 3): Promise<string> {
|
|
144
145
|
const text = message.trim();
|
|
145
146
|
if (!text) return "";
|
|
146
|
-
|
|
147
|
+
// Repo-scoped (issue #8): model-facing reads see this repo + global rows.
|
|
148
|
+
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
147
149
|
const top = scoreMemories(text, mems, limit);
|
|
148
150
|
if (!top.length) return "";
|
|
149
151
|
return `## Relevant durable memory\n${top.map(formatMemory).join("\n")}`;
|
|
@@ -154,7 +156,8 @@ export async function relevantKnowledge(message: string, limit = 3): Promise<str
|
|
|
154
156
|
// injection block is recomputed only when this string changes (or after a
|
|
155
157
|
// compaction). Evidence and superseded rows never affect it (I3, memory.ts).
|
|
156
158
|
export async function storeVersion(): Promise<string> {
|
|
157
|
-
|
|
159
|
+
// Repo-scoped (issue #8): model-facing reads see this repo + global rows.
|
|
160
|
+
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
158
161
|
let max = "";
|
|
159
162
|
for (const m of mems) if (m.ts > max) max = m.ts;
|
|
160
163
|
return `${mems.length}:${max}`;
|
|
@@ -193,7 +196,8 @@ const ANCHOR_SETTLED_LIMIT = 5;
|
|
|
193
196
|
const ANCHOR_BUDGET = 2500;
|
|
194
197
|
|
|
195
198
|
export async function compactionAnchor(limit = 12): Promise<string> {
|
|
196
|
-
|
|
199
|
+
// Repo-scoped (issue #8): model-facing reads see this repo + global rows.
|
|
200
|
+
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
197
201
|
if (!mems.length) return "";
|
|
198
202
|
const byNewest = (a: Memory, b: Memory) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0);
|
|
199
203
|
const open = mems
|
|
@@ -218,7 +222,8 @@ export async function contextualPull(input: PullInput, output: PullOutput): Prom
|
|
|
218
222
|
if (!["grep", "glob", "read"].includes(tool)) return;
|
|
219
223
|
const term = argText(input.args);
|
|
220
224
|
if (!term) return;
|
|
221
|
-
|
|
225
|
+
// Repo-scoped (issue #8): model-facing reads see this repo + global rows.
|
|
226
|
+
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
222
227
|
const top = scoreMemories(term, mems, 3);
|
|
223
228
|
if (!top.length) return;
|
|
224
229
|
const block = top.map(formatMemory).join("\n");
|
package/src/memory.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import { memoryRoot, deletionsFile, snapshotDir } from "./config.ts";
|
|
8
|
+
import { repoScope } from "./repokey.ts";
|
|
8
9
|
import { appendLog, ensureDir, formatLine, parseLine, readLines } from "./logfmt.ts";
|
|
9
10
|
import { blockedTs } from "./blocklist.ts";
|
|
10
11
|
|
|
@@ -28,6 +29,12 @@ export interface Memory {
|
|
|
28
29
|
superseded_by?: string;
|
|
29
30
|
reason?: string;
|
|
30
31
|
status?: MemoryStatus;
|
|
32
|
+
// Origin repo (issue #8): sha1 of the normalized origin URL, the toplevel
|
|
33
|
+
// path when the repo had no remote, the literal "global" for cross-project
|
|
34
|
+
// writes, or absent for legacy/viewer rows — all three read as described
|
|
35
|
+
// in isVisibleHere().
|
|
36
|
+
repo?: string;
|
|
37
|
+
repoPath?: string;
|
|
31
38
|
file: string;
|
|
32
39
|
index: number;
|
|
33
40
|
}
|
|
@@ -39,12 +46,36 @@ const RESERVED = new Set([
|
|
|
39
46
|
"blocklist.logfmt",
|
|
40
47
|
]);
|
|
41
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
|
+
|
|
42
63
|
function toMemory(fields: Record<string, unknown>, file: string, index: number): Memory | null {
|
|
43
64
|
const ts = fields.ts;
|
|
44
65
|
const type = fields.type;
|
|
45
66
|
const scope = fields.scope;
|
|
46
67
|
if (typeof ts !== "string" || typeof type !== "string") return null;
|
|
47
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
|
+
}
|
|
48
79
|
return {
|
|
49
80
|
ts,
|
|
50
81
|
type,
|
|
@@ -56,6 +87,8 @@ function toMemory(fields: Record<string, unknown>, file: string, index: number):
|
|
|
56
87
|
reason: typeof fields.reason === "string" ? fields.reason : undefined,
|
|
57
88
|
status:
|
|
58
89
|
fields.status === "open" || fields.status === "settled" ? fields.status : undefined,
|
|
90
|
+
repo: typeof fields.repo === "string" ? fields.repo : undefined,
|
|
91
|
+
repoPath: typeof fields.repoPath === "string" ? fields.repoPath : undefined,
|
|
59
92
|
file,
|
|
60
93
|
index,
|
|
61
94
|
};
|
|
@@ -76,6 +109,7 @@ export async function memoryFiles(): Promise<string[]> {
|
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
export async function loadAll(): Promise<Memory[]> {
|
|
112
|
+
malformedRows = 0; // per-load telemetry: the most recent read wins
|
|
79
113
|
const blocked = await blockedTs();
|
|
80
114
|
const out: Memory[] = [];
|
|
81
115
|
for (const file of await memoryFiles()) {
|
|
@@ -89,7 +123,9 @@ export async function loadAll(): Promise<Memory[]> {
|
|
|
89
123
|
}
|
|
90
124
|
|
|
91
125
|
export async function loadScope(scope: string): Promise<Memory[]> {
|
|
92
|
-
|
|
126
|
+
// Gate dedup/stale reads are repo-scoped: a row from another repo must not
|
|
127
|
+
// collapse or supersede a write here (issue #8).
|
|
128
|
+
return (await loadAll()).filter((m) => m.scope === scope && isVisibleHere(m));
|
|
93
129
|
}
|
|
94
130
|
|
|
95
131
|
export interface AppendMemoryInput {
|
|
@@ -99,6 +135,11 @@ export interface AppendMemoryInput {
|
|
|
99
135
|
issue?: string;
|
|
100
136
|
tags?: string[];
|
|
101
137
|
status?: MemoryStatus;
|
|
138
|
+
// "global" for explicit cross-project writes; imports carry the row's own
|
|
139
|
+
// origin stamp (hash survives the machine hop — see repokey.ts). Any other
|
|
140
|
+
// value is resolved by appendMemory against the live repo scope.
|
|
141
|
+
repo?: string;
|
|
142
|
+
repoPath?: string;
|
|
102
143
|
}
|
|
103
144
|
|
|
104
145
|
// Existing ts values across all memory files, used to guarantee uniqueness of
|
|
@@ -130,6 +171,18 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
|
130
171
|
while (taken.has(ts) && guard++ < 1000) {
|
|
131
172
|
ts = new Date(new Date(ts).getTime() + 1).toISOString();
|
|
132
173
|
}
|
|
174
|
+
// Repo stamp (issue #8) at the single write choke point: gated writes,
|
|
175
|
+
// viewer restores and edits all pass through here. Explicit "global"
|
|
176
|
+
// survives; an unscoped process (viewer) stamps nothing = global.
|
|
177
|
+
let repo = input.repo;
|
|
178
|
+
let repoPath = input.repoPath;
|
|
179
|
+
if (repo === undefined) {
|
|
180
|
+
const cur = repoScope();
|
|
181
|
+
if (cur) {
|
|
182
|
+
repo = cur.key;
|
|
183
|
+
repoPath = cur.path;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
133
186
|
const file = join(memoryRoot(), `${ts.split("T")[0]}.logfmt`);
|
|
134
187
|
await appendLog(
|
|
135
188
|
file,
|
|
@@ -141,6 +194,8 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
|
141
194
|
issue: input.issue,
|
|
142
195
|
tags: input.tags?.length ? input.tags.join(",") : undefined,
|
|
143
196
|
status: input.status,
|
|
197
|
+
repo,
|
|
198
|
+
repoPath,
|
|
144
199
|
}),
|
|
145
200
|
);
|
|
146
201
|
return ts;
|
|
@@ -333,7 +388,10 @@ export async function prune(scope: string, type: string, reason: string): Promis
|
|
|
333
388
|
for (const line of lines) {
|
|
334
389
|
const parsed = parseLine(line);
|
|
335
390
|
const m = parsed ? toMemory(parsed, file, 0) : null;
|
|
336
|
-
|
|
391
|
+
// Repo floor (issue #8): forgetting scope X here must never delete
|
|
392
|
+
// another repo's row for scope X — same basename collisions are real
|
|
393
|
+
// (a fork next to its upstream).
|
|
394
|
+
if (m && m.scope === scope && m.type === type && isVisibleHere(m)) {
|
|
337
395
|
deleted.push({ memory: m, reason });
|
|
338
396
|
await logDeletion(m, reason);
|
|
339
397
|
changed = true;
|
|
@@ -349,3 +407,21 @@ export async function prune(scope: string, type: string, reason: string): Promis
|
|
|
349
407
|
export function isActive(m: Memory): boolean {
|
|
350
408
|
return !m.superseded_by;
|
|
351
409
|
}
|
|
410
|
+
|
|
411
|
+
// The ONLY repo filter the model-facing paths use (injection sinks, recall,
|
|
412
|
+
// list, update targeting, prune, decay, gate scope reads). A row is visible
|
|
413
|
+
// when it is global (explicit marker OR no stamp — legacy rows and writes by
|
|
414
|
+
// processes with no scope, e.g. the viewer), or was written in this repo.
|
|
415
|
+
// Keeping it one predicate is what stops the filtering from drifting between
|
|
416
|
+
// entry points the way pre-zod validation did.
|
|
417
|
+
export function isVisibleHere(m: Memory): boolean {
|
|
418
|
+
if (!m.repo || m.repo === "global") return true;
|
|
419
|
+
const cur = repoScope();
|
|
420
|
+
if (!cur) return true; // unscoped process: no partitioning
|
|
421
|
+
return m.repo === cur.key || (!!m.repoPath && m.repoPath === cur.path);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Rows visible to the current process (active filtering stays with callers).
|
|
425
|
+
export function visibleHere(mems: Memory[]): Memory[] {
|
|
426
|
+
return mems.filter(isVisibleHere);
|
|
427
|
+
}
|
package/src/portable.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// moat. Decay is supersede-not-delete and snapshot-first, mirroring prune.
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { memoryRoot } from "./config.ts";
|
|
8
|
-
import { isActive, loadAll, type Memory } from "./memory.ts";
|
|
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
11
|
|
|
@@ -45,6 +45,10 @@ export async function exportMemories(opts: ExportOptions = {}): Promise<ExportRe
|
|
|
45
45
|
status: m.status,
|
|
46
46
|
tags: m.tags,
|
|
47
47
|
issue: m.issue,
|
|
48
|
+
// Origin stamps ride along so scoping survives the machine hop (the
|
|
49
|
+
// repo key is a remote-URL hash; the path is the secondary match).
|
|
50
|
+
repo: m.repo,
|
|
51
|
+
repoPath: m.repoPath,
|
|
48
52
|
}),
|
|
49
53
|
);
|
|
50
54
|
await Bun.write(path, lines.length ? lines.join("\n") + "\n" : "");
|
|
@@ -79,6 +83,11 @@ function parseRow(line: string): WriteCandidate | null {
|
|
|
79
83
|
if (r.status === "open" || r.status === "settled") c.status = r.status;
|
|
80
84
|
if (Array.isArray(r.tags) && r.tags.every((t) => typeof t === "string")) c.tags = r.tags;
|
|
81
85
|
if (typeof r.issue === "string" && r.issue) c.issue = r.issue;
|
|
86
|
+
// Origin stamps survive an export/import hop (repokey.ts): a row written
|
|
87
|
+
// for repo X stays visible only where repo X is opened, even on a new
|
|
88
|
+
// machine. Unstamped rows stay global.
|
|
89
|
+
if (typeof r.repo === "string" && r.repo) c.repo = r.repo;
|
|
90
|
+
if (typeof r.repoPath === "string" && r.repoPath) c.repoPath = r.repoPath;
|
|
82
91
|
return c;
|
|
83
92
|
}
|
|
84
93
|
|
|
@@ -116,6 +125,8 @@ export async function importMemories(
|
|
|
116
125
|
export async function findStaleOpen(days: number): Promise<Memory[]> {
|
|
117
126
|
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
118
127
|
return (await loadAll())
|
|
119
|
-
|
|
128
|
+
// Decay is repo-scoped (issue #8): running it here must not retire
|
|
129
|
+
// another repo's open threads.
|
|
130
|
+
.filter((m) => isActive(m) && isVisibleHere(m) && m.status === "open" && m.ts < cutoff)
|
|
120
131
|
.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
121
132
|
}
|
package/src/repokey.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Repo identity for memory scoping (issue #8). One plugin process serves one
|
|
2
|
+
// workspace, so the scope resolves ONCE at setup; every store write stamps
|
|
3
|
+
// it and every model-facing read filters by it.
|
|
4
|
+
//
|
|
5
|
+
// Two keys, both cheap, with opposite failure modes:
|
|
6
|
+
// key — sha1 of the normalized origin URL: survives re-clone, moves, and
|
|
7
|
+
// export/import to another machine. Absent when the repo has no
|
|
8
|
+
// origin (fresh or remote-less), where the path is used as the key.
|
|
9
|
+
// path — resolved git toplevel: the secondary match, so rows stamped
|
|
10
|
+
// before an origin was added keep recognizing their repo.
|
|
11
|
+
// Rows carry no stamp when their writer had no scope (viewer process,
|
|
12
|
+
// tests) and legacy rows predate the field: both read as GLOBAL — visible
|
|
13
|
+
// everywhere — which keeps every upgrade non-breaking.
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
export interface RepoScope {
|
|
17
|
+
key: string;
|
|
18
|
+
path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let current: RepoScope | null = null;
|
|
22
|
+
|
|
23
|
+
export function setRepoScope(scope: RepoScope | null): void {
|
|
24
|
+
current = scope;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function repoScope(): RepoScope | null {
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// git@github.com:Owner/Repo.git / https://GitHub.com/owner/repo/ ->
|
|
32
|
+
// github.com/owner/repo — the same repo must hash identically whichever
|
|
33
|
+
// remote form (and case) the clone used.
|
|
34
|
+
export function normalizeRemote(url: string): string {
|
|
35
|
+
let u = url.trim().toLowerCase();
|
|
36
|
+
u = u.replace(/^(?:https?:\/\/|ssh:\/\/|git:\/\/)/, "");
|
|
37
|
+
u = u.replace(/^git@/, "");
|
|
38
|
+
u = u.replace(/\/$/, "");
|
|
39
|
+
u = u.replace(/\.git$/, "");
|
|
40
|
+
// scp-style "github.com:owner/repo" -> slash form, so every transport of
|
|
41
|
+
// one repo hashes to one id. (First colon only; URL ports in remotes are
|
|
42
|
+
// rarer than the scp form and merely produce a stable different id.)
|
|
43
|
+
u = u.replace(":", "/");
|
|
44
|
+
return u;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function git(args: string[], cwd: string): string | null {
|
|
48
|
+
try {
|
|
49
|
+
const p = Bun.spawnSync(args, { cwd, stdout: "pipe", stderr: "ignore" });
|
|
50
|
+
const out = p.stdout?.toString().trim();
|
|
51
|
+
return p.exitCode === 0 && out ? out : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Resolve the current repo from any start dir (never throws; a non-git dir
|
|
58
|
+
// simply scopes by its own path).
|
|
59
|
+
export function resolveRepo(startDir: string): RepoScope {
|
|
60
|
+
const path = git(["git", "rev-parse", "--show-toplevel"], startDir) ?? startDir;
|
|
61
|
+
const remote = git(["git", "remote", "get-url", "origin"], path);
|
|
62
|
+
const key = remote
|
|
63
|
+
? createHash("sha1").update(normalizeRemote(remote)).digest("hex").slice(0, 12)
|
|
64
|
+
: path;
|
|
65
|
+
return { key, path };
|
|
66
|
+
}
|
package/src/resolver.ts
CHANGED
|
@@ -116,7 +116,11 @@ export function extractCallForms(content: string): CallForm[] {
|
|
|
116
116
|
return forms;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
// The extension must end the name: without the lookahead `Button.tsx` matched
|
|
120
|
+
// as `Button.ts` and `config.json` as `config.js`, and the existence check then
|
|
121
|
+
// blocked real files. `.tsx.snap` and `.js.map` are other files, not refs; a
|
|
122
|
+
// sentence-ending `.` still ends the ref.
|
|
123
|
+
const PATH_RE = /[A-Za-z0-9_@./-]+\.(?:ts|tsx|js|jsx|mjs|cjs)(?!\w|\.\w)(?::\d+)?/g;
|
|
120
124
|
|
|
121
125
|
export function extractPathRefs(content: string): string[] {
|
|
122
126
|
if (content.length > REGEX_SCAN_MAX_CHARS) return [];
|
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
|
|
package/src/viewer/server.ts
CHANGED
|
@@ -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 =
|
|
198
|
-
if (url.pathname === "/api/edit") return
|
|
199
|
-
if (url.pathname === "/api/delete") return
|
|
200
|
-
if (url.pathname === "/api/block") return
|
|
201
|
-
if (url.pathname === "/api/unblock") return
|
|
202
|
-
if (url.pathname === "/api/restore-entry") return
|
|
203
|
-
|
|
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
|
-
|
|
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:
|
|
211
|
-
type:
|
|
212
|
-
scope:
|
|
213
|
-
content:
|
|
214
|
-
issue:
|
|
215
|
-
tags:
|
|
216
|
-
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:
|
|
222
|
-
return json(await deleteMemory({ ts:
|
|
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:
|
|
226
|
-
return json({ ts: await addBlock(
|
|
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:
|
|
230
|
-
return json({ unblocked: await removeBlock(
|
|
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:
|
|
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
|
|