memhtml 0.1.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/LICENSE +201 -0
- package/README.md +531 -0
- package/agent/agent.ts +68 -0
- package/agent/channels/eve.ts +73 -0
- package/agent/instructions.md +142 -0
- package/agent/sandbox/sandbox.ts +102 -0
- package/dist/dist-Bubu4ZZa.mjs +3 -0
- package/dist/dist-CrYVXFO2.mjs +12846 -0
- package/dist/dist-CrYVXFO2.mjs.map +1 -0
- package/dist/dist-DUuomISL.mjs +2221 -0
- package/dist/dist-DUuomISL.mjs.map +1 -0
- package/dist/memhtml-mcp.mjs +4077 -0
- package/dist/memhtml-mcp.mjs.map +1 -0
- package/dist/memhtml.mjs +5009 -0
- package/dist/memhtml.mjs.map +1 -0
- package/guest/corpus.mjs +193 -0
- package/migrations/.gitkeep +0 -0
- package/migrations/0001_files.sql +111 -0
- package/migrations/0002_chunks.sql +31 -0
- package/migrations/0003_fts.sql +40 -0
- package/migrations/0004_edges.sql +40 -0
- package/migrations/0005_traces.sql +92 -0
- package/migrations/0006_sleep.sql +33 -0
- package/migrations/0007_watermark.sql +32 -0
- package/migrations/0008_tasks.sql +214 -0
- package/migrations/0009_frame_key.sql +54 -0
- package/migrations/0010_trace_consolidations.sql +45 -0
- package/package.json +59 -0
- package/src/agent-build.ts +280 -0
- package/src/client.ts +1155 -0
- package/src/contract.ts +443 -0
- package/src/index.ts +23 -0
- package/src/mount.ts +279 -0
- package/src/run-auth.ts +231 -0
- package/state-migrations/S0001_access.sql +48 -0
|
@@ -0,0 +1,4077 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { A as makeIndexRecorder, B as makeGitPort, C as ModelClientLive, D as EMBED_DIM, E as EmbeddingsLive, F as reinforce, G as STATE_MIGRATIONS_DIR, H as DatabaseService, I as Indexer, J as expandRoot, L as makeIndexer, N as Retrieval, O as EMBED_WATERMARK, P as makeRetrieval, Q as STATE_DB_PATH, R as readIndexState, S as ModelClient, T as Embeddings, U as makeDatabase, V as sanitizeFtsQuery, W as MIGRATIONS_DIR, X as INDEX_DB_PATH, Y as makeStore, Z as SLEEP_REPORTS_DIR, ct as isValidDatetime, dt as REINFORCE_SIGNALS, ft as frameKeyOf, i as makeSleep, it as makeGit, k as IndexRecorder, lt as closesFence, q as Store, r as Sleep, rt as Git, ut as fenceOpeningOf, v as SLEEP_PHASES, w as wrapAsData, z as IndexGit } from "./dist-CrYVXFO2.mjs";
|
|
3
|
+
import { B as ModelUnavailable, G as MEMORY_RELS, H as StorageFailure, J as relClassFor, K as TASK_RELS, N as hasConsolidatorCredentials, R as InvalidMemory, gt as TASK_STATUSES, i as makeConsolidator, mt as PARA_BUCKETS, ot as normalizePath, q as isEdgeRel, vt as WRITABLE_MEMORY_TYPES, yt as isTaskStatus } from "./dist-DUuomISL.mjs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { Config, Context, Effect, Layer, Logger, Schema } from "effect";
|
|
6
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { NodeRuntime, NodeStdio } from "@effect/platform-node";
|
|
12
|
+
import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/unstable/ai";
|
|
13
|
+
|
|
14
|
+
//#region apps/cli/dist/serve.js
|
|
15
|
+
/** An explicit path to the server, for a deployment that does not keep the two apps side by side. */
|
|
16
|
+
const MCP_BIN_VAR = "MEMHTML_MCP_BIN";
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region apps/cli/dist/config.js
|
|
20
|
+
const CONFIG_VARS = [
|
|
21
|
+
{
|
|
22
|
+
name: "MEMHTML_ROOT",
|
|
23
|
+
description: "The memory repo's root: a git repository holding the corpus and `.memhtml/`.",
|
|
24
|
+
fallback: join("~", "memhtml")
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "MEMHTML_TRACE_ROOT",
|
|
28
|
+
description: "Where `memhtml trace index` reads Claude Code transcripts from. Read-only; never written.",
|
|
29
|
+
fallback: join("~", ".claude")
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "MEMHTML_AWS_REGION",
|
|
33
|
+
description: "The Bedrock region for embeddings and the sleep cycle's four LLM phases.",
|
|
34
|
+
fallback: "us-east-1"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "AWS_BEARER_TOKEN_BEDROCK",
|
|
38
|
+
description: "Bedrock bearer token, read by the AWS SDK itself. Absent means the default credential chain; retrieval then degrades to the lexical floor rather than failing.",
|
|
39
|
+
fallback: null
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "MEMHTML_EMBED",
|
|
43
|
+
description: "`off` disables the embedder entirely. An explicit opt-out, distinct from a missing credential: a missing credential degrades one search at call time, `off` degrades every search, and an operator reading this manifest needs those to be different states.",
|
|
44
|
+
fallback: "on"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "MEMHTML_LLM",
|
|
48
|
+
description: "`off` makes the four LLM sleep phases report `no model bound` and stay `ok`, so a credential-free run is honest rather than red.",
|
|
49
|
+
fallback: "on"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "MEMHTML_EXTRACT_ENTITIES",
|
|
53
|
+
description: "`on` adds one GPT-5.6 Luna call per write batch that extracts `memhtml-entity` metas the ops did not declare. Opt-in, unlike MEMHTML_EMBED, because it changes what a write STORES: extracted entities land in the files as if authored, and the write itself never waits on or fails with the model. A failed extraction is a logged warning and an unextracted batch.",
|
|
54
|
+
fallback: "off"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
/**
|
|
58
|
+
* The name is imported rather than retyped: this row and the `process.env` read at serve.ts:50
|
|
59
|
+
* must name the same string, and a literal here would let a rename disclose a variable nothing
|
|
60
|
+
* reads.
|
|
61
|
+
*/
|
|
62
|
+
name: MCP_BIN_VAR,
|
|
63
|
+
description: "An explicit path to the `memhtml-mcp` entry point, read only by the `memhtml serve mcp` supervisor. Absent means the sibling-path default. The two apps ship as one build, so `apps/cli/dist/serve.js` finds `apps/mcp/dist/bin.js` two directories over. An operator sets it for a split deployment that does not keep the apps side by side; it locates the server rather than configuring the store, so it changes no retrieval behaviour.",
|
|
64
|
+
fallback: null
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
/**
|
|
68
|
+
* `MEMHTML_ROOT`. Re-exported from `@memhtml/store`'s own config rather than redeclared, because the
|
|
69
|
+
* store's config expands a leading `~`. This value arrives from a shell profile, an MCP client
|
|
70
|
+
* config, and a cron line, and only the shell expands tildes on its own.
|
|
71
|
+
*/
|
|
72
|
+
const MemhtmlRoot = Config.string("MEMHTML_ROOT").pipe(Config.withDefault(join("~", "memhtml")), Config.map(expandRoot));
|
|
73
|
+
/**
|
|
74
|
+
* `MEMHTML_TRACE_ROOT`, defaulting to `~/.claude`.
|
|
75
|
+
*
|
|
76
|
+
* A parameter rather than a constant so the trace indexer is drivable against a fixture tree and
|
|
77
|
+
* against an archived copy, which is also what keeps real transcripts out of the test suite.
|
|
78
|
+
*/
|
|
79
|
+
const TraceRoot = Config.string("MEMHTML_TRACE_ROOT").pipe(Config.withDefault(join(homedir(), ".claude")), Config.map(expandRoot));
|
|
80
|
+
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region apps/cli/dist/extraction.js
|
|
83
|
+
/**
|
|
84
|
+
* GPT-5.6 Luna, the fast high-volume model on the mantle endpoint. A constant rather than config
|
|
85
|
+
* because the schema below is tested against this model's strict-mode behaviour. Changing the model
|
|
86
|
+
* is a code change with a test run, not an env var.
|
|
87
|
+
*/
|
|
88
|
+
const EXTRACTION_MODEL_ID = "openai.gpt-5.6-luna";
|
|
89
|
+
/**
|
|
90
|
+
* The strict output schema. `additionalProperties: false` and `required` on every level because
|
|
91
|
+
* the Responses API's `strict: true` demands both, and a lax schema invites the model to answer
|
|
92
|
+
* with prose keys the parser would then be guessing at.
|
|
93
|
+
*/
|
|
94
|
+
const RESPONSE_SCHEMA = {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: { items: {
|
|
97
|
+
type: "array",
|
|
98
|
+
items: {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {
|
|
101
|
+
index: { type: "integer" },
|
|
102
|
+
entities: {
|
|
103
|
+
type: "array",
|
|
104
|
+
items: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
type: {
|
|
108
|
+
type: "string",
|
|
109
|
+
enum: [...[
|
|
110
|
+
"person",
|
|
111
|
+
"org",
|
|
112
|
+
"service",
|
|
113
|
+
"place",
|
|
114
|
+
"work",
|
|
115
|
+
"concept",
|
|
116
|
+
"event"
|
|
117
|
+
]]
|
|
118
|
+
},
|
|
119
|
+
name: { type: "string" }
|
|
120
|
+
},
|
|
121
|
+
required: ["type", "name"],
|
|
122
|
+
additionalProperties: false
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
required: ["index", "entities"],
|
|
127
|
+
additionalProperties: false
|
|
128
|
+
}
|
|
129
|
+
} },
|
|
130
|
+
required: ["items"],
|
|
131
|
+
additionalProperties: false
|
|
132
|
+
};
|
|
133
|
+
const INSTRUCTIONS = "Extract the named entities each memory mentions. An entity is a specific nameable thing a later search would look up: a person, an organization, a service or system, a place, a titled work, a defined concept, or a named event. Skip generic nouns, dates, and quantities. Use the memory's own spelling for the name. Return one result per input index, with an empty entities array when a memory names nothing.";
|
|
134
|
+
/** The request body for one batch. Exported for the wire test, where the schema is the contract. */
|
|
135
|
+
const requestBodyOf = (modelId, items) => JSON.stringify({
|
|
136
|
+
model: modelId,
|
|
137
|
+
instructions: INSTRUCTIONS,
|
|
138
|
+
input: wrapAsData("memories", JSON.stringify(items.map((item, index) => ({
|
|
139
|
+
index,
|
|
140
|
+
title: item.title,
|
|
141
|
+
text: item.text
|
|
142
|
+
})))),
|
|
143
|
+
text: { format: {
|
|
144
|
+
type: "json_schema",
|
|
145
|
+
name: "entities",
|
|
146
|
+
strict: true,
|
|
147
|
+
schema: RESPONSE_SCHEMA
|
|
148
|
+
} }
|
|
149
|
+
});
|
|
150
|
+
/**
|
|
151
|
+
* Decode one Responses-API payload into index-aligned `type:name` arrays.
|
|
152
|
+
*
|
|
153
|
+
* Total over unknown input: every malformed shape returns `undefined` and the caller maps that to
|
|
154
|
+
* `ModelUnavailable`. A payload this code cannot read carries no answer, and treating it as
|
|
155
|
+
* "no entities" would record a model failure as a fact about the corpus.
|
|
156
|
+
*/
|
|
157
|
+
const entitiesOf = (payload, expected) => {
|
|
158
|
+
const text = outputTextOf(payload);
|
|
159
|
+
if (text === void 0) return void 0;
|
|
160
|
+
let parsed;
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(text);
|
|
163
|
+
} catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const items = parsed.items;
|
|
167
|
+
if (!Array.isArray(items)) return void 0;
|
|
168
|
+
const results = Array.from({ length: expected }, () => []);
|
|
169
|
+
for (const item of items) {
|
|
170
|
+
const index = item.index;
|
|
171
|
+
const entities = item.entities;
|
|
172
|
+
if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index >= expected) continue;
|
|
173
|
+
if (!Array.isArray(entities)) continue;
|
|
174
|
+
results[index] = entities.flatMap((entity) => {
|
|
175
|
+
const type = entity.type;
|
|
176
|
+
const name = entity.name;
|
|
177
|
+
if (typeof type !== "string" || typeof name !== "string") return [];
|
|
178
|
+
const trimmedName = name.trim();
|
|
179
|
+
return trimmedName === "" ? [] : [`${type}:${trimmedName}`];
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return results;
|
|
183
|
+
};
|
|
184
|
+
/** The assistant message text out of a Responses payload, or `undefined` off-shape. */
|
|
185
|
+
const outputTextOf = (payload) => {
|
|
186
|
+
const output = payload.output;
|
|
187
|
+
if (!Array.isArray(output)) return void 0;
|
|
188
|
+
for (const entry of output) {
|
|
189
|
+
if (entry.type !== "message") continue;
|
|
190
|
+
const content = entry.content;
|
|
191
|
+
if (!Array.isArray(content)) continue;
|
|
192
|
+
for (const part of content) {
|
|
193
|
+
const text = part.text;
|
|
194
|
+
if (part.type === "output_text" && typeof text === "string") return text;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* Per-call ceiling. Generous against the probed ~1s because a batch of 256 ops is a bigger
|
|
200
|
+
* prompt than the probe's one sentence, and a late abort costs only this batch's entities. The
|
|
201
|
+
* write itself is unaffected.
|
|
202
|
+
*/
|
|
203
|
+
const EXTRACT_TIMEOUT_MS = 6e4;
|
|
204
|
+
/** The extractor over a transport. The transport owns the endpoint; this owns prompt and parse. */
|
|
205
|
+
const makeEntityExtractor = (transport, modelId) => ({ extract: (items) => items.length === 0 ? Effect.succeed([]) : Effect.gen(function* () {
|
|
206
|
+
const payload = yield* Effect.tryPromise({
|
|
207
|
+
try: (signal) => {
|
|
208
|
+
const timeout = AbortSignal.timeout(EXTRACT_TIMEOUT_MS);
|
|
209
|
+
return transport.post(requestBodyOf(modelId, items), AbortSignal.any([signal, timeout]));
|
|
210
|
+
},
|
|
211
|
+
catch: (cause) => ModelUnavailable.make({
|
|
212
|
+
modelId,
|
|
213
|
+
reason: cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause)
|
|
214
|
+
})
|
|
215
|
+
});
|
|
216
|
+
const entities = entitiesOf(payload, items.length);
|
|
217
|
+
if (entities === void 0) return yield* Effect.fail(ModelUnavailable.make({
|
|
218
|
+
modelId,
|
|
219
|
+
reason: "unreadable extraction payload"
|
|
220
|
+
}));
|
|
221
|
+
return entities;
|
|
222
|
+
}) });
|
|
223
|
+
/**
|
|
224
|
+
* The production transport: bearer-token fetch against the mantle endpoint.
|
|
225
|
+
*
|
|
226
|
+
* A non-2xx status is a rejection carrying the status and the body's first line, because mantle
|
|
227
|
+
* reports quota and auth failures as structured JSON the operator needs verbatim. Folding it into
|
|
228
|
+
* a generic message was the mistake the embeddings lane made first.
|
|
229
|
+
*/
|
|
230
|
+
const fetchMantleTransport = (region, token) => ({ post: async (body, signal) => {
|
|
231
|
+
const response = await fetch(`https://bedrock-mantle.${region}.api.aws/openai/v1/responses`, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: {
|
|
234
|
+
Authorization: `Bearer ${token}`,
|
|
235
|
+
"Content-Type": "application/json"
|
|
236
|
+
},
|
|
237
|
+
body,
|
|
238
|
+
signal
|
|
239
|
+
});
|
|
240
|
+
const text = await response.text();
|
|
241
|
+
if (!response.ok) throw new Error(`mantle ${response.status}: ${text.slice(0, 200)}`);
|
|
242
|
+
return JSON.parse(text);
|
|
243
|
+
} });
|
|
244
|
+
|
|
245
|
+
//#endregion
|
|
246
|
+
//#region apps/cli/dist/api-layer.js
|
|
247
|
+
const Roots = Context.Service("memhtml/Roots");
|
|
248
|
+
/**
|
|
249
|
+
* The roots layer. `repoOverride` is `--repo`, and it wins over `MEMHTML_ROOT` so an operator running
|
|
250
|
+
* against a second repo does not have to mutate their environment to do it.
|
|
251
|
+
*/
|
|
252
|
+
const layerRoots = (repoOverride) => Layer.effect(Roots)(Effect.gen(function* () {
|
|
253
|
+
const fromConfig = yield* MemhtmlRoot;
|
|
254
|
+
const traceRoot = yield* TraceRoot;
|
|
255
|
+
return {
|
|
256
|
+
memhtmlRoot: repoOverride !== void 0 && repoOverride.trim() !== "" ? repoOverride.trim() : fromConfig,
|
|
257
|
+
traceRoot
|
|
258
|
+
};
|
|
259
|
+
})).pipe(Layer.orDie);
|
|
260
|
+
/**
|
|
261
|
+
* The database, rooted in the repo's `.memhtml/`.
|
|
262
|
+
*
|
|
263
|
+
* Both planes on one connection, always. The salience retrieval arm `LEFT JOIN`s `state.access`
|
|
264
|
+
* in the same statement as `main.files`, so a connection without the attachment silently drops
|
|
265
|
+
* that arm. `DatabaseShape.hasState` is what the arm registry consults, and it is `false` only
|
|
266
|
+
* for a caller that deliberately asked for the index alone.
|
|
267
|
+
*/
|
|
268
|
+
const layerDatabase = Layer.effect(DatabaseService)(Effect.gen(function* () {
|
|
269
|
+
const roots = yield* Roots;
|
|
270
|
+
return yield* makeDatabase(join(roots.memhtmlRoot, INDEX_DB_PATH), MIGRATIONS_DIR, {
|
|
271
|
+
path: join(roots.memhtmlRoot, STATE_DB_PATH),
|
|
272
|
+
migrationsDir: STATE_MIGRATIONS_DIR
|
|
273
|
+
});
|
|
274
|
+
})).pipe(Layer.orDie);
|
|
275
|
+
/** Git over the repo root. The store's shape, under the store's own tag. */
|
|
276
|
+
const layerGit = Layer.effect(Git)(Effect.gen(function* () {
|
|
277
|
+
const roots = yield* Roots;
|
|
278
|
+
return makeGit(roots.memhtmlRoot);
|
|
279
|
+
}));
|
|
280
|
+
/**
|
|
281
|
+
* The indexer's git port, over the store's git service.
|
|
282
|
+
*
|
|
283
|
+
* `readFile` is `Effect.tryPromise` rather than `Effect.promise`. `Effect.promise` turns an ENOENT
|
|
284
|
+
* into a defect, and a defect travels past the `Effect.catch` the indexer wraps each projection in.
|
|
285
|
+
* An absent path would then kill the fiber mid-update instead of becoming the counted skip the indexer
|
|
286
|
+
* already handles. An agent listing a path it just archived is the normal case, which makes this
|
|
287
|
+
* the difference between a working `index update` and a crash on an ordinary day.
|
|
288
|
+
*/
|
|
289
|
+
const layerIndexGit = Layer.effect(IndexGit)(Effect.gen(function* () {
|
|
290
|
+
const roots = yield* Roots;
|
|
291
|
+
const git = yield* Git;
|
|
292
|
+
return makeGitPort({
|
|
293
|
+
git,
|
|
294
|
+
readFile: (path) => Effect.tryPromise({
|
|
295
|
+
try: () => readFile(join(roots.memhtmlRoot, path), "utf8"),
|
|
296
|
+
catch: (cause) => cause
|
|
297
|
+
}),
|
|
298
|
+
fail: (operation) => Effect.fail(StorageFailure.make({ operation: `git.${operation}` }))
|
|
299
|
+
});
|
|
300
|
+
}));
|
|
301
|
+
/** The recorder: the dedupe lookup the store gates writes on, and the session-link writer. */
|
|
302
|
+
const layerRecorder = Layer.effect(IndexRecorder)(Effect.gen(function* () {
|
|
303
|
+
const db = yield* DatabaseService;
|
|
304
|
+
return makeIndexRecorder(db);
|
|
305
|
+
}));
|
|
306
|
+
/**
|
|
307
|
+
* The store, with the recorder's dedupe hook attached.
|
|
308
|
+
*
|
|
309
|
+
* `onMove` mirrors `state.access.path` across an archive. Cross-database foreign keys do not
|
|
310
|
+
* exist, so the mirror is an explicit call at the one place a path can change; without it every
|
|
311
|
+
* eviction leaves an orphan access row and the salience arm stops finding the memory it describes.
|
|
312
|
+
*/
|
|
313
|
+
const layerStore = Layer.effect(Store)(Effect.gen(function* () {
|
|
314
|
+
const git = yield* Git;
|
|
315
|
+
const recorder = yield* IndexRecorder;
|
|
316
|
+
const db = yield* DatabaseService;
|
|
317
|
+
return makeStore(git, {
|
|
318
|
+
dedupeLookup: recorder.activePathForHash,
|
|
319
|
+
onMove: (from, to) => db.run("UPDATE state.access SET path = ? WHERE path = ?", [to, from]).pipe(Effect.catch((error) => Effect.logWarning(`state.access mirror missed ${from} -> ${to}: ${error.operation}`)))
|
|
320
|
+
});
|
|
321
|
+
}));
|
|
322
|
+
const Embedder = Context.Service("memhtml/Embedder");
|
|
323
|
+
/**
|
|
324
|
+
* Bedrock embeddings when the region resolves, absent when `MEMHTML_EMBED` is `off`.
|
|
325
|
+
*
|
|
326
|
+
* The switch is an explicit opt-out rather than credential sniffing. A missing credential is
|
|
327
|
+
* discovered at call time and degrades one search. A deliberate `off` degrades every search, and
|
|
328
|
+
* an operator reading `memhtml manifest` needs those to be different states.
|
|
329
|
+
*/
|
|
330
|
+
const layerEmbedder = Layer.effect(Embedder)(Effect.gen(function* () {
|
|
331
|
+
if (!(yield* Config.string("MEMHTML_EMBED").pipe(Config.withDefault("on"), Config.map((value) => value.trim().toLowerCase() !== "off")))) return {
|
|
332
|
+
document: void 0,
|
|
333
|
+
query: void 0
|
|
334
|
+
};
|
|
335
|
+
const embeddings = yield* Embeddings;
|
|
336
|
+
return {
|
|
337
|
+
document: embeddings,
|
|
338
|
+
query: embeddings
|
|
339
|
+
};
|
|
340
|
+
})).pipe(Layer.orDie);
|
|
341
|
+
/** The indexer, over the database and the git port. */
|
|
342
|
+
const layerIndexer = Layer.effect(Indexer)(Effect.gen(function* () {
|
|
343
|
+
const db = yield* DatabaseService;
|
|
344
|
+
const git = yield* IndexGit;
|
|
345
|
+
const embedder = yield* Embedder;
|
|
346
|
+
return makeIndexer({
|
|
347
|
+
db,
|
|
348
|
+
git,
|
|
349
|
+
embedWatermark: EMBED_WATERMARK,
|
|
350
|
+
embedDim: EMBED_DIM,
|
|
351
|
+
embeddings: embedder.document,
|
|
352
|
+
now: () => (/* @__PURE__ */ new Date()).toISOString()
|
|
353
|
+
});
|
|
354
|
+
}));
|
|
355
|
+
/** Retrieval, over the database and the query embedder. */
|
|
356
|
+
const layerRetrieval = Layer.effect(Retrieval)(Effect.gen(function* () {
|
|
357
|
+
const db = yield* DatabaseService;
|
|
358
|
+
const embedder = yield* Embedder;
|
|
359
|
+
return makeRetrieval({
|
|
360
|
+
db,
|
|
361
|
+
embeddings: embedder.query
|
|
362
|
+
});
|
|
363
|
+
}));
|
|
364
|
+
const ModelPort = Context.Service("memhtml/ModelPort");
|
|
365
|
+
const layerModelPort = Layer.effect(ModelPort)(Effect.gen(function* () {
|
|
366
|
+
if (!(yield* Config.string("MEMHTML_LLM").pipe(Config.withDefault("on"), Config.map((value) => value.trim().toLowerCase() !== "off")))) return { model: void 0 };
|
|
367
|
+
return { model: yield* ModelClient };
|
|
368
|
+
})).pipe(Layer.orDie);
|
|
369
|
+
const ExtractorPort = Context.Service("memhtml/ExtractorPort");
|
|
370
|
+
const layerExtractorPort = Layer.effect(ExtractorPort)(Effect.gen(function* () {
|
|
371
|
+
if (!(yield* Config.string("MEMHTML_EXTRACT_ENTITIES").pipe(Config.withDefault("off"), Config.map((value) => value.trim().toLowerCase() === "on")))) return { extractor: void 0 };
|
|
372
|
+
const region = yield* Config.string("MEMHTML_AWS_REGION").pipe(Config.withDefault("us-east-1"));
|
|
373
|
+
const token = yield* Config.string("AWS_BEARER_TOKEN_BEDROCK").pipe(Config.withDefault(""));
|
|
374
|
+
if (token === "") {
|
|
375
|
+
yield* Effect.logWarning("MEMHTML_EXTRACT_ENTITIES=on but AWS_BEARER_TOKEN_BEDROCK is absent; writes proceed unextracted");
|
|
376
|
+
return { extractor: void 0 };
|
|
377
|
+
}
|
|
378
|
+
return { extractor: makeEntityExtractor(fetchMantleTransport(region, token), EXTRACTION_MODEL_ID) };
|
|
379
|
+
})).pipe(Layer.orDie);
|
|
380
|
+
const ConsolidatorPortService = Context.Service("memhtml/ConsolidatorPort");
|
|
381
|
+
/**
|
|
382
|
+
* Two gates, both cheap, both before anything is spawned.
|
|
383
|
+
*
|
|
384
|
+
* `MEMHTML_LLM=off` is the same explicit opt-out `layerModelPort` reads, and it covers the consolidator
|
|
385
|
+
* too, because an operator who turned the models off did not mean "except the expensive agent".
|
|
386
|
+
*
|
|
387
|
+
* `hasConsolidatorCredentials` is the credential preflight, read here as well as inside the client.
|
|
388
|
+
* The redundancy is deliberate and the two reads do different jobs. This one decides whether the phase
|
|
389
|
+
* sees a consolidator at all, so a credential-free environment gets `detail: "no consolidator bound"`,
|
|
390
|
+
* the same shape the other three LLM phases report with no model, rather than a bound port that
|
|
391
|
+
* fails on every call and reports a degradation. CI has no credentials and must read as skipped rather
|
|
392
|
+
* than degraded.
|
|
393
|
+
*
|
|
394
|
+
* The check cannot be skipped in favour of the client's own, because the provider is lazy.
|
|
395
|
+
* `createAmazonBedrock` and `provider(modelId)` both succeed with zero credentials and nothing fails
|
|
396
|
+
* until the first request (verified in T-EVE-1's probe, recorded at
|
|
397
|
+
* `apps/consolidator/src/contract.ts:301-319`).
|
|
398
|
+
*
|
|
399
|
+
* **`env` is a parameter, and it has to be.** `Config` reads its values through a `ConfigProvider`,
|
|
400
|
+
* which a test substitutes, while `hasConsolidatorCredentials` reads `process.env` directly, and
|
|
401
|
+
* effect's default provider snapshots `process.env` at module load (probed 2026-08-08: mutating
|
|
402
|
+
* `process.env.MEMHTML_LLM` after importing `effect` changes nothing `Config.string` returns). A test
|
|
403
|
+
* that set both by mutation would read a stale snapshot for one gate and a live object for the other,
|
|
404
|
+
* and the two gates would disagree about which environment they are in. Threading the credential
|
|
405
|
+
* environment through as an argument makes both injectable from one call. See
|
|
406
|
+
* `apps/cli/tests/consolidator-wiring.test.ts`, where that disagreement produced a false defect
|
|
407
|
+
* before this parameter existed.
|
|
408
|
+
*
|
|
409
|
+
* **It now requires `RootsShape`, for `traceRoot`.** That is how transcripts reach the agent. The
|
|
410
|
+
* consolidator mounts the trace root read-only rather than sending transcripts as a model message
|
|
411
|
+
* (`apps/consolidator/src/client.ts`, `manifestFor`, records what the superseded path actually did).
|
|
412
|
+
* The root is `MEMHTML_TRACE_ROOT` and this file is where config becomes services, so it is read from the
|
|
413
|
+
* same `Roots` service `memhtml trace index` scans with. One resolution of one variable is what
|
|
414
|
+
* keeps the mounted tree and the indexed `traces` rows describing the same directory. A second
|
|
415
|
+
* `Config.string("MEMHTML_TRACE_ROOT")` here would be a second place the `~/.claude` default lives.
|
|
416
|
+
*/
|
|
417
|
+
const layerConsolidatorPort = (env = process.env) => Layer.effect(ConsolidatorPortService)(Effect.gen(function* () {
|
|
418
|
+
const roots = yield* Roots;
|
|
419
|
+
if (!(yield* Config.string("MEMHTML_LLM").pipe(Config.withDefault("on"), Config.map((value) => value.trim().toLowerCase() !== "off")))) return { consolidator: void 0 };
|
|
420
|
+
if (!hasConsolidatorCredentials(env)) {
|
|
421
|
+
yield* Effect.logDebug("trace consolidation unbound: no Bedrock credentials in the environment");
|
|
422
|
+
return { consolidator: void 0 };
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* The client is built over the same environment the gate just read. A client over ambient
|
|
426
|
+
* `process.env` while the gate read an injected one would pass the gate and fail at the call,
|
|
427
|
+
* which is the degradation-instead-of-skip outcome this gate exists to prevent.
|
|
428
|
+
*/
|
|
429
|
+
return { consolidator: makeConsolidator({
|
|
430
|
+
env,
|
|
431
|
+
traceRoot: roots.traceRoot
|
|
432
|
+
}) };
|
|
433
|
+
})).pipe(Layer.orDie);
|
|
434
|
+
/**
|
|
435
|
+
* The sleep runner over the same services every other command uses.
|
|
436
|
+
*
|
|
437
|
+
* `@memhtml/sleep` deliberately ships no `SleepLive` that resolves its own git, database, and model.
|
|
438
|
+
* A layer that built its own would open a second connection to one database file and a second git
|
|
439
|
+
* wrapper on one root, and the run would then curate a corpus the indexer is not describing.
|
|
440
|
+
*/
|
|
441
|
+
const layerSleep = Layer.effect(Sleep)(Effect.gen(function* () {
|
|
442
|
+
const git = yield* Git;
|
|
443
|
+
const store = yield* Store;
|
|
444
|
+
const db = yield* DatabaseService;
|
|
445
|
+
const indexer = yield* Indexer;
|
|
446
|
+
const modelPort = yield* ModelPort;
|
|
447
|
+
const consolidatorPort = yield* ConsolidatorPortService;
|
|
448
|
+
return makeSleep({
|
|
449
|
+
git,
|
|
450
|
+
store,
|
|
451
|
+
db,
|
|
452
|
+
indexer,
|
|
453
|
+
model: modelPort.model,
|
|
454
|
+
consolidator: consolidatorPort.consolidator
|
|
455
|
+
});
|
|
456
|
+
}));
|
|
457
|
+
/**
|
|
458
|
+
* Everything above the embedder and the model, as one layer requiring only the roots and those two.
|
|
459
|
+
*
|
|
460
|
+
* Written top-down because that is what `Layer.provideMerge(that)` means. It feeds `that`'s output
|
|
461
|
+
* into `self`'s requirements, so the consumer is `self` and each `.pipe` step below adds the level
|
|
462
|
+
* beneath it. Chaining in dependency order instead, with the database first, reads naturally and is
|
|
463
|
+
* wrong. It would provide git to the database and leave `GitShape` in the final requirement set, which
|
|
464
|
+
* typechecks as an unsatisfied layer rather than failing where the mistake is.
|
|
465
|
+
*
|
|
466
|
+
* Split out from `layerApp` so a test provides a deterministic embedder and a real temp repo with
|
|
467
|
+
* no Bedrock anywhere in the graph. The composition under test is then the same composition
|
|
468
|
+
* production runs, which a hand-assembled test wiring would not be.
|
|
469
|
+
*/
|
|
470
|
+
const layerCore = Layer.mergeAll(layerSleep, layerRetrieval).pipe(Layer.provideMerge(Layer.mergeAll(layerIndexer, layerStore)), Layer.provideMerge(Layer.mergeAll(layerIndexGit, layerRecorder)), Layer.provideMerge(Layer.mergeAll(layerDatabase, layerGit)));
|
|
471
|
+
/**
|
|
472
|
+
* The production graph: roots from config, Bedrock behind both model ports, everything else over
|
|
473
|
+
* them. `repoOverride` is `--repo`.
|
|
474
|
+
*
|
|
475
|
+
* This is the one composition production runs. `memhtml serve mcp` runs the same one in a child
|
|
476
|
+
* process, so an MCP tool and its CLI twin cannot be looking at different databases.
|
|
477
|
+
*/
|
|
478
|
+
const layerApp = (repoOverride) => layerCore.pipe(Layer.provideMerge(Layer.mergeAll(
|
|
479
|
+
layerRoots(repoOverride),
|
|
480
|
+
layerEmbedder.pipe(Layer.provide(EmbeddingsLive), Layer.orDie),
|
|
481
|
+
layerModelPort.pipe(Layer.provide(ModelClientLive), Layer.orDie),
|
|
482
|
+
layerExtractorPort,
|
|
483
|
+
/**
|
|
484
|
+
* `layerRoots` is provided to the consolidator port explicitly rather than merged beside it.
|
|
485
|
+
* The consolidator needs `traceRoot` to mount, and a sibling in one `mergeAll` is not a
|
|
486
|
+
* dependency. The roots layer is built once with `repoOverride` and fed in, so a `--repo`
|
|
487
|
+
* run and the mounted trace root cannot come from two different resolutions.
|
|
488
|
+
*/
|
|
489
|
+
layerConsolidatorPort().pipe(Layer.provide(layerRoots(repoOverride)))
|
|
490
|
+
)));
|
|
491
|
+
|
|
492
|
+
//#endregion
|
|
493
|
+
//#region apps/cli/dist/errors.js
|
|
494
|
+
const isTagged$1 = (value) => typeof value === "object" && value !== null && typeof value._tag === "string";
|
|
495
|
+
const text$1 = (value) => typeof value === "string" ? value : void 0;
|
|
496
|
+
const paths = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
497
|
+
/**
|
|
498
|
+
* The code for a tag.
|
|
499
|
+
*
|
|
500
|
+
* `GitFailure` lives in `@memhtml/store` rather than `@memhtml/contracts`. It is the one error class
|
|
501
|
+
* outside the shared contracts package, because it carries a git subcommand name and only the store
|
|
502
|
+
* speaks git. It maps to `ERR_GIT` here at the CLI edge, the only place the two vocabularies meet.
|
|
503
|
+
*
|
|
504
|
+
* `EmbedModelMismatch` is a plain class rather than a schema error (it predates the contracts
|
|
505
|
+
* package), so it arrives with the same `_tag` shape and needs no special case.
|
|
506
|
+
*/
|
|
507
|
+
const codeFor = (error) => {
|
|
508
|
+
if (!isTagged$1(error)) return "ERR_UNKNOWN";
|
|
509
|
+
switch (error._tag) {
|
|
510
|
+
case "GitFailure": return "ERR_GIT";
|
|
511
|
+
case "StorageFailure": return "ERR_STORAGE";
|
|
512
|
+
case "InvalidMemory": return "ERR_INVALID_MEMORY";
|
|
513
|
+
case "PathNotFound": return "ERR_PATH_NOT_FOUND";
|
|
514
|
+
case "WriteConflict": return "ERR_WRITE_CONFLICT";
|
|
515
|
+
case "DirtyTree": return "ERR_DIRTY_TREE";
|
|
516
|
+
case "DuplicateContent": return "ERR_DUPLICATE_CONTENT";
|
|
517
|
+
case "ModelUnavailable": return "ERR_MODEL_UNAVAILABLE";
|
|
518
|
+
case "EmbedModelMismatch": return "ERR_EMBED_MODEL_MISMATCH";
|
|
519
|
+
case "DiscriminationFailed": return "ERR_DISCRIMINATION_FAILED";
|
|
520
|
+
default: return "ERR_UNKNOWN";
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
/**
|
|
524
|
+
* The human message for a failure.
|
|
525
|
+
*
|
|
526
|
+
* Deliberately narrow. Every payload field named here is one a caller can act on: a path to
|
|
527
|
+
* re-read, two shas to reconcile, a model to check. The message omits the driver's own text, the
|
|
528
|
+
* SQL, the git argv, and any memory body. Each typed error class already dropped those at its
|
|
529
|
+
* adapter edge so a tool response could not carry corpus content, and reconstructing them here
|
|
530
|
+
* would undo that.
|
|
531
|
+
*/
|
|
532
|
+
const messageFor = (error) => {
|
|
533
|
+
if (!isTagged$1(error)) return String(error);
|
|
534
|
+
switch (error._tag) {
|
|
535
|
+
case "GitFailure": return `git ${text$1(error.command) ?? "command"} failed (exit ${String(error.exitCode)})`;
|
|
536
|
+
case "StorageFailure": return `storage operation failed: ${text$1(error.operation) ?? "unknown"}`;
|
|
537
|
+
case "InvalidMemory": return `invalid memory: ${text$1(error.reason) ?? "unstated reason"}`;
|
|
538
|
+
case "PathNotFound": return `no memory at ${text$1(error.path) ?? "the given path"}`;
|
|
539
|
+
case "WriteConflict": return `write conflict on ${text$1(error.path) ?? "a path"}: ours ${text$1(error.ourSha) ?? "?"}, theirs ${text$1(error.theirSha) ?? "?"}`;
|
|
540
|
+
case "DirtyTree": return `the working tree has uncommitted changes: ${paths(error.paths).join(", ")}`;
|
|
541
|
+
case "DuplicateContent": return `this content already lives at ${text$1(error.existingPath) ?? "another path"}`;
|
|
542
|
+
case "ModelUnavailable": return `bedrock refused ${text$1(error.modelId) ?? "the model"}: ${text$1(error.reason) ?? "no reason given"}`;
|
|
543
|
+
case "EmbedModelMismatch": return `the index was built in vector space ${text$1(error.stored) ?? "?"}, configured is ${text$1(error.configured) ?? "?"}`;
|
|
544
|
+
case "LlmContractViolation": return `the model broke its structured-output contract: ${text$1(error.reason) ?? "no reason given"}`;
|
|
545
|
+
case "DiscriminationFailed": return text$1(error.reason) ?? "the discrimination gate refused";
|
|
546
|
+
default: return `unexpected failure: ${error._tag}`;
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region apps/cli/dist/operations.js
|
|
552
|
+
/**
|
|
553
|
+
* The use cases, one per tool. Every CLI command and every MCP tool is a thin adapter over exactly
|
|
554
|
+
* one of these, which makes `memhtml search` and `memory_search` provably the same query
|
|
555
|
+
* rather than two implementations that agree today.
|
|
556
|
+
*
|
|
557
|
+
* Nothing here parses argv or builds an envelope. A function takes decoded parameters, returns a
|
|
558
|
+
* typed result, and fails with a typed error. The adapters own the shape of the wire.
|
|
559
|
+
*/
|
|
560
|
+
/** Wall-clock as an ISO-8601 UTC second, through the Effect clock so a test can pin it. */
|
|
561
|
+
const nowSecond$1 = Effect.clockWith((clock) => Effect.map(clock.currentTimeMillis, (millis) => `${new Date(millis).toISOString().slice(0, 19)}Z`));
|
|
562
|
+
/** Drop `undefined`-valued keys, so `exactOptionalPropertyTypes` sees an absent key. */
|
|
563
|
+
const defined = (input) => {
|
|
564
|
+
const out = {};
|
|
565
|
+
for (const [key, value] of Object.entries(input)) if (value !== void 0) out[key] = value;
|
|
566
|
+
return out;
|
|
567
|
+
};
|
|
568
|
+
/**
|
|
569
|
+
* Narrow an untrusted memory-type string.
|
|
570
|
+
*
|
|
571
|
+
* `arc` is refused even though it is a valid storage type. An arc is synthesized by the sleep
|
|
572
|
+
* cycle from many memories, so an agent naming one directly would be asserting a conclusion the
|
|
573
|
+
* corpus has not earned. The vocabulary the tool exposes is therefore narrower than the CHECK
|
|
574
|
+
* constraint by exactly that one value.
|
|
575
|
+
*/
|
|
576
|
+
const decodeWritableType = (value) => WRITABLE_MEMORY_TYPES.includes(value) ? Effect.succeed(value) : Effect.fail(InvalidMemory.make({ reason: `unknown memory type: ${value}. One of: ${WRITABLE_MEMORY_TYPES.join(", ")}` }));
|
|
577
|
+
/**
|
|
578
|
+
* The rels a CALLER may author: the nine memory rels plus the two task rels.
|
|
579
|
+
*
|
|
580
|
+
* The two classes the vocabulary withholds are the ones the system mints itself. A `person` edge is
|
|
581
|
+
* written by sleep's person-links phase against `resources/people/*`, and `from_session` is written
|
|
582
|
+
* by the write path from the provenance a caller already supplied. Authoring either by hand would put
|
|
583
|
+
* a hand-guessed row where a derivation belongs.
|
|
584
|
+
*/
|
|
585
|
+
const AUTHORABLE_RELS = [...MEMORY_RELS, ...TASK_RELS];
|
|
586
|
+
/**
|
|
587
|
+
* Narrow an untrusted rel to one a caller may author.
|
|
588
|
+
*
|
|
589
|
+
* A `blocks` edge between two tasks is a legitimate authored assertion, so the task class is in and
|
|
590
|
+
* refusing it would leave the task graph writable by nothing. Whether the rel agrees with its
|
|
591
|
+
* endpoints is not this function's business. `@memhtml/store`'s `linkMemories` reads both files'
|
|
592
|
+
* `memhtml-type` and refuses a mismatch, and it is the only layer that can see the endpoints at all.
|
|
593
|
+
*
|
|
594
|
+
* `memory_link`'s MCP schema stays memory-rels-only (`MemoryRelSchema`, `apps/mcp/src/tools.ts`) and
|
|
595
|
+
* refuses a task rel at decode. That is one narrow surface for agents and one wider one for the
|
|
596
|
+
* operator, with the store's endpoint guard governing both.
|
|
597
|
+
*/
|
|
598
|
+
const decodeAuthorableRel = (value) => isEdgeRel(value) && AUTHORABLE_RELS.includes(value) ? Effect.succeed(value) : Effect.fail(InvalidMemory.make({ reason: `unknown rel: ${value}. One of: ${AUTHORABLE_RELS.join(", ")}` }));
|
|
599
|
+
/** Narrow an untrusted task status. */
|
|
600
|
+
const decodeTaskStatus = (value) => isTaskStatus(value) ? Effect.succeed(value) : Effect.fail(InvalidMemory.make({ reason: `unknown task status: ${value}. One of: ${TASK_STATUSES.join(", ")}` }));
|
|
601
|
+
/**
|
|
602
|
+
* Narrow an untrusted due date, using the FORMAT's own validator.
|
|
603
|
+
*
|
|
604
|
+
* `isValidDatetime` rather than a local regex or `Date.parse`, because `files.due_at` is compared and
|
|
605
|
+
* ordered as a string. `2026-8-9` and `Aug 9 2026` both parse as instants and neither sorts alongside
|
|
606
|
+
* `2026-08-09`, so the overdue query would silently miss them. Reusing the parser's own validator is
|
|
607
|
+
* also what keeps this refusal and the parser's violation from drifting apart.
|
|
608
|
+
*/
|
|
609
|
+
const decodeDueAt = (value) => isValidDatetime(value) ? Effect.succeed(value) : Effect.fail(InvalidMemory.make({ reason: `due date is not an ISO date or datetime: ${value}. Expected YYYY-MM-DD or YYYY-MM-DDThh:mm:ssZ` }));
|
|
610
|
+
/** Narrow an untrusted reinforcement signal. */
|
|
611
|
+
const decodeSignal = (value) => REINFORCE_SIGNALS.includes(value) ? Effect.succeed(value) : Effect.fail(InvalidMemory.make({ reason: `unknown signal: ${value}. One of: ${REINFORCE_SIGNALS.join(", ")}` }));
|
|
612
|
+
/**
|
|
613
|
+
* Record the session link for an operation that touched a path.
|
|
614
|
+
*
|
|
615
|
+
* Fire-and-log rather than fail-the-call. The link is a note about what happened, and losing the
|
|
616
|
+
* memory over a failed note about it would invert the priority. The file's own head already
|
|
617
|
+
* carries `memhtml-session`/`memhtml-prompt`/`memhtml-turn`, so the durable half of the link survives even
|
|
618
|
+
* when this row does not.
|
|
619
|
+
*/
|
|
620
|
+
const recordLink = (path, linkKind, provenance, at) => Effect.gen(function* () {
|
|
621
|
+
if (provenance.sessionId === void 0 || provenance.sessionId === "") return;
|
|
622
|
+
yield* (yield* IndexRecorder).recordLink({
|
|
623
|
+
path,
|
|
624
|
+
sessionId: provenance.sessionId,
|
|
625
|
+
linkKind,
|
|
626
|
+
at,
|
|
627
|
+
...defined({
|
|
628
|
+
promptId: provenance.promptId,
|
|
629
|
+
turnUuid: provenance.turnUuid
|
|
630
|
+
})
|
|
631
|
+
}).pipe(Effect.catch((error) => Effect.logWarning(`session link not recorded for ${path}: ${error.operation}`)));
|
|
632
|
+
});
|
|
633
|
+
/**
|
|
634
|
+
* Bring the index up to the commit a write just made.
|
|
635
|
+
*
|
|
636
|
+
* `indexer.update()` rather than `indexPaths([…])`, and the difference changes behaviour twice:
|
|
637
|
+
*
|
|
638
|
+
* 1. **`indexPaths` cannot express a rename.** Every correction and every archive is a `git mv`, and
|
|
639
|
+
* an index that handled one as "index the destination" leaves the source row live. The archived
|
|
640
|
+
* memory stays in `memhtml list`, `files` gains a row the tree does not have, and the chunk rows the
|
|
641
|
+
* move exists to preserve are duplicated under two paths. `update()` reads `diff --name-status -M`,
|
|
642
|
+
* sees the `R`, and re-points the row, which keeps the embedding and drops nothing.
|
|
643
|
+
* 2. **`indexPaths` never records the watermark.** `index_state.head_sha` is what makes
|
|
644
|
+
* "the index describes the current commit" answerable at all, so a write path that skipped it
|
|
645
|
+
* would leave `memhtml status` reporting `index_fresh: false` forever and `index update` re-deriving
|
|
646
|
+
* from a stale base.
|
|
647
|
+
*
|
|
648
|
+
* The cost is one `git diff` over one commit, which is what the watermark exists to bound. On the
|
|
649
|
+
* very first write the watermark is absent and `update()` falls through to a full rebuild. That is
|
|
650
|
+
* correct, and cheap on a corpus that has one file in it.
|
|
651
|
+
*/
|
|
652
|
+
const reindex = () => Effect.gen(function* () {
|
|
653
|
+
return yield* (yield* Indexer).update({ embed: true });
|
|
654
|
+
});
|
|
655
|
+
/**
|
|
656
|
+
* Decode untrusted write parameters into the store's `WriteInput`.
|
|
657
|
+
*
|
|
658
|
+
* Shared by {@link writeMemory} and {@link batchWrite}, and the sharing matters. A batch that
|
|
659
|
+
* re-derived this would be a second decode of the same vocabulary, and the two would agree today
|
|
660
|
+
* and drift the first time a field is added. This is `symspec`'s lesson stated as code: the batch
|
|
661
|
+
* folds the singular's own decode rather than a parallel one.
|
|
662
|
+
*
|
|
663
|
+
* The two task metas are decoded here, before any file is rendered, and only for a task.
|
|
664
|
+
* `@memhtml/html`'s parser refuses `memhtml-task-status` on a non-task and refuses a `memhtml-due` that is not
|
|
665
|
+
* an ISO datetime, so a bad value passed through would render a file the indexer then declines to
|
|
666
|
+
* project. That file is present in the tree, absent from every search, and visible only as a log
|
|
667
|
+
* line. Deciding here turns it into a typed `InvalidMemory` before the commit.
|
|
668
|
+
*/
|
|
669
|
+
const toWriteInput = (params, at) => Effect.gen(function* () {
|
|
670
|
+
const memoryType = yield* decodeWritableType(params.memoryType);
|
|
671
|
+
const taskStatus = memoryType === "task" && params.taskStatus !== void 0 && params.taskStatus !== "" ? yield* decodeTaskStatus(params.taskStatus) : void 0;
|
|
672
|
+
const dueAt = memoryType === "task" && params.dueAt !== void 0 && params.dueAt !== "" ? yield* decodeDueAt(params.dueAt) : void 0;
|
|
673
|
+
return {
|
|
674
|
+
title: params.title,
|
|
675
|
+
claim: params.claim,
|
|
676
|
+
memoryType,
|
|
677
|
+
at,
|
|
678
|
+
...defined({
|
|
679
|
+
body: params.body,
|
|
680
|
+
articleHtml: params.articleHtml,
|
|
681
|
+
path: params.path,
|
|
682
|
+
workspace: params.workspace,
|
|
683
|
+
tags: params.tags,
|
|
684
|
+
entities: params.entities,
|
|
685
|
+
importance: params.importance,
|
|
686
|
+
confidence: params.confidence,
|
|
687
|
+
sessionId: params.sessionId,
|
|
688
|
+
promptId: params.promptId,
|
|
689
|
+
turnUuid: params.turnUuid,
|
|
690
|
+
taskStatus,
|
|
691
|
+
dueAt
|
|
692
|
+
})
|
|
693
|
+
};
|
|
694
|
+
});
|
|
695
|
+
/**
|
|
696
|
+
* Write one memory: render, dedupe, commit, index.
|
|
697
|
+
*
|
|
698
|
+
* On a dedupe nothing is indexed, because nothing changed. A dedupe writes no file, stages nothing,
|
|
699
|
+
* and commits nothing, so the tree is byte-identical and the index already describes it.
|
|
700
|
+
*/
|
|
701
|
+
const writeMemory = (params) => Effect.gen(function* () {
|
|
702
|
+
const store = yield* Store;
|
|
703
|
+
const at = yield* nowSecond$1;
|
|
704
|
+
const result = yield* store.writeMemory(yield* toWriteInput(params, at));
|
|
705
|
+
if (result.created) yield* reindex();
|
|
706
|
+
yield* recordLink(result.path, "wrote", params, at);
|
|
707
|
+
return result;
|
|
708
|
+
});
|
|
709
|
+
/**
|
|
710
|
+
* A typed failure as a per-op report, through the same `codeFor`/`messageFor` every envelope error
|
|
711
|
+
* takes.
|
|
712
|
+
*
|
|
713
|
+
* Mapped here rather than in each door, deliberately. A per-op code is part of the batch's payload
|
|
714
|
+
* rather than of the envelope, so two doors shaping it independently is two mappings that agree today.
|
|
715
|
+
* `memhtml apply` and `memory_write_batch` reporting different codes for the same refused op is
|
|
716
|
+
* the drift the shared-use-case rule exists to prevent.
|
|
717
|
+
*/
|
|
718
|
+
const reportFailure = (index, error) => ({
|
|
719
|
+
index,
|
|
720
|
+
ok: false,
|
|
721
|
+
code: codeFor(error),
|
|
722
|
+
error: messageFor(error)
|
|
723
|
+
});
|
|
724
|
+
/**
|
|
725
|
+
* The `detect_conflicts` assist: which claim, if any, each op's own claim contradicts.
|
|
726
|
+
*
|
|
727
|
+
* **Propose-only, and that is the design rather than a v1 limitation.** The function returns a report
|
|
728
|
+
* per op index and writes nothing, stages nothing, and refuses nothing, because sometimes the
|
|
729
|
+
* contradiction is the answer. A memory recording that a runbook step changed necessarily contradicts
|
|
730
|
+
* the memory stating the old step, and an assist that auto-archived, applied last-wins, or blocked the
|
|
731
|
+
* write would destroy the pair a later reader needs to see the change in. The caller decides: write
|
|
732
|
+
* anyway, `memory_correct` the match, or skip.
|
|
733
|
+
*
|
|
734
|
+
* **One query for the whole batch.** Every op's frame key is collected first and `activeFramesFor` is
|
|
735
|
+
* called once with all of them. The signature takes an array so a caller cannot loop, and a
|
|
736
|
+
* per-op lookup would be the quadratic-write-cost pattern this codebase has already paid for once.
|
|
737
|
+
*
|
|
738
|
+
* **Two match sources, checked in that order.** The store answers for active non-task memories. Its
|
|
739
|
+
* predicate, and 0009's index, exclude archived rows and tasks, because an archived claim is not a
|
|
740
|
+
* competing assertion and an open to-do phrased as a claim is working state rather than knowledge. Then
|
|
741
|
+
* come the batch's own earlier ops, folded as this loop walks them in order. Two ops in one call can
|
|
742
|
+
* occupy the same slot, and neither is in the store yet, so nothing but this fold can see that pair. A
|
|
743
|
+
* store match wins when an op has both, because the store's memory is a fact already in the corpus
|
|
744
|
+
* while the earlier op is one this same call is about to create.
|
|
745
|
+
*
|
|
746
|
+
* **A later op reports on an earlier one, never the reverse.** The fold is asymmetric on purpose. Op 3
|
|
747
|
+
* matching op 1 tells a caller "you are about to restate something you just said", which is actionable
|
|
748
|
+
* with op 3 still in hand. Reporting it on op 1 as well would name a conflict with something that did
|
|
749
|
+
* not exist when op 1 was written, and would double one finding into two.
|
|
750
|
+
*
|
|
751
|
+
* **A lookup failure degrades to no conflicts.** The assist is a note about the writes, so losing the
|
|
752
|
+
* memories over a failed note about them would invert the priority as it would for
|
|
753
|
+
* {@link recordLink} and {@link bumpAccess}, with the same `Effect.catch` → `logWarning` → neutral value.
|
|
754
|
+
* The write path never sees this function's failure, which makes "the assist cannot block a
|
|
755
|
+
* write" true structurally rather than by review.
|
|
756
|
+
*/
|
|
757
|
+
const detectFrameConflicts = (ops) => Effect.gen(function* () {
|
|
758
|
+
/**
|
|
759
|
+
* `frameKeyOf(op.claim)` per op, computed once and kept alongside the index.
|
|
760
|
+
*
|
|
761
|
+
* On the `article_html` path `claim` is `""` by construction, because both doors leave it empty and
|
|
762
|
+
* the `<mark>` inside the markup is the claim. `frameKeyOf` therefore returns null and a markup op
|
|
763
|
+
* gets no assist. Deriving one here would mean parsing every op's article at the ops layer, a second
|
|
764
|
+
* render of bytes the store is about to render anyway. The boundary is stated in the tool
|
|
765
|
+
* description instead of hidden behind a duplicate parse.
|
|
766
|
+
*/
|
|
767
|
+
const keyed = [];
|
|
768
|
+
for (const [index, op] of ops.entries()) {
|
|
769
|
+
const key = frameKeyOf(op.claim);
|
|
770
|
+
if (key !== null) keyed.push({
|
|
771
|
+
index,
|
|
772
|
+
key,
|
|
773
|
+
claim: op.claim
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
if (keyed.length === 0) return /* @__PURE__ */ new Map();
|
|
777
|
+
const live = yield* (yield* IndexRecorder).activeFramesFor(keyed.map((entry) => entry.key)).pipe(Effect.catch((error) => Effect.logWarning(`conflict assist skipped: ${error.operation}`).pipe(Effect.as(/* @__PURE__ */ new Map()))));
|
|
778
|
+
const conflicts = /* @__PURE__ */ new Map();
|
|
779
|
+
/** frame key → the first op in this batch to occupy it. Built as the loop walks in order. */
|
|
780
|
+
const seen = /* @__PURE__ */ new Map();
|
|
781
|
+
for (const entry of keyed) {
|
|
782
|
+
const [stored] = live.get(entry.key) ?? [];
|
|
783
|
+
const earlier = seen.get(entry.key);
|
|
784
|
+
if (stored !== void 0) conflicts.set(entry.index, {
|
|
785
|
+
path: stored.path,
|
|
786
|
+
batchIndex: null,
|
|
787
|
+
claim: stored.gist
|
|
788
|
+
});
|
|
789
|
+
else if (earlier !== void 0) conflicts.set(entry.index, {
|
|
790
|
+
path: null,
|
|
791
|
+
batchIndex: earlier.index,
|
|
792
|
+
claim: earlier.claim
|
|
793
|
+
});
|
|
794
|
+
if (earlier === void 0) seen.set(entry.key, {
|
|
795
|
+
index: entry.index,
|
|
796
|
+
claim: entry.claim
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
return conflicts;
|
|
800
|
+
});
|
|
801
|
+
/**
|
|
802
|
+
* Fold last-wins over the caller's op array, before the decode fold, so a batch-internal loser
|
|
803
|
+
* never reaches disk. The surviving value simply occupies the earliest slot with that key.
|
|
804
|
+
*
|
|
805
|
+
* Not derived from {@link detectFrameConflicts}' output, although the walk mirrors it. A store
|
|
806
|
+
* match wins there, masking the batch-internal pair the plan needs, and the plan needs both: the
|
|
807
|
+
* batch collision decides which value writes, and the store match decides what that write supersedes.
|
|
808
|
+
*
|
|
809
|
+
* The slot rule: the first occupant of a key keeps its position and later ops with the same key
|
|
810
|
+
* replace its content (`plannedOps[slot] = laterOp`, provenance and all, since the surviving value
|
|
811
|
+
* is the later op's own statement). The occupant-tracking never moves, so a third restatement
|
|
812
|
+
* replaces the slot again, last wins, at a stable position a caller can index by.
|
|
813
|
+
*
|
|
814
|
+
* Fail-closed on both of the rule's own guards: a null frame key is never consolidated, and a
|
|
815
|
+
* failed store lookup degrades to batch-internal consolidation only, through the same
|
|
816
|
+
* `Effect.catch` → `logWarning` → neutral-shape path the conflict assist takes, because an opt-in
|
|
817
|
+
* consolidation must not become a new way to lose writes.
|
|
818
|
+
*/
|
|
819
|
+
const planLastWins = (ops) => Effect.gen(function* () {
|
|
820
|
+
/** frame key → the slot (earliest occupant's index) that carries this key's surviving value. */
|
|
821
|
+
const slotOf = /* @__PURE__ */ new Map();
|
|
822
|
+
/** slot index → the op whose value currently occupies it. */
|
|
823
|
+
const content = /* @__PURE__ */ new Map();
|
|
824
|
+
const losers = /* @__PURE__ */ new Map();
|
|
825
|
+
/** Slot indices in caller order, keyed and keyless alike. */
|
|
826
|
+
const order = [];
|
|
827
|
+
for (const [index, op] of ops.entries()) {
|
|
828
|
+
const key = frameKeyOf(op.claim);
|
|
829
|
+
if (key === null) {
|
|
830
|
+
order.push(index);
|
|
831
|
+
content.set(index, op);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const slot = slotOf.get(key);
|
|
835
|
+
if (slot === void 0) {
|
|
836
|
+
slotOf.set(key, index);
|
|
837
|
+
order.push(index);
|
|
838
|
+
content.set(index, op);
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
content.set(slot, op);
|
|
842
|
+
losers.set(index, slot);
|
|
843
|
+
}
|
|
844
|
+
const pendingSupersede = /* @__PURE__ */ new Map();
|
|
845
|
+
if (slotOf.size > 0) {
|
|
846
|
+
const live = yield* (yield* IndexRecorder).activeFramesFor([...slotOf.keys()]).pipe(Effect.catch((error) => Effect.logWarning(`consolidation store lookup skipped: ${error.operation}`).pipe(Effect.as(/* @__PURE__ */ new Map()))));
|
|
847
|
+
for (const [key, slot] of slotOf) {
|
|
848
|
+
const [stored] = live.get(key) ?? [];
|
|
849
|
+
if (stored !== void 0) pendingSupersede.set(slot, stored.path);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return {
|
|
853
|
+
ops: order.flatMap((index) => {
|
|
854
|
+
const op = content.get(index);
|
|
855
|
+
return op === void 0 ? [] : [{
|
|
856
|
+
index,
|
|
857
|
+
op
|
|
858
|
+
}];
|
|
859
|
+
}),
|
|
860
|
+
losers,
|
|
861
|
+
pendingSupersede
|
|
862
|
+
};
|
|
863
|
+
});
|
|
864
|
+
/**
|
|
865
|
+
* Loser reports for a last-wins plan, derived from the winner slots' own final reports.
|
|
866
|
+
*
|
|
867
|
+
* A loser reports `ok` with `consolidatedInto` only when its slot's write landed, which means the
|
|
868
|
+
* surviving value is on disk and the pointer names where. A slot that was skipped or refused took the
|
|
869
|
+
* loser's value down with it, so the loser reports `skipped`, which is the retryable outcome and
|
|
870
|
+
* the one an atomic abort already means: nothing of this op reached disk.
|
|
871
|
+
*/
|
|
872
|
+
const withConsolidation = (results, plan) => {
|
|
873
|
+
if (plan === null || plan.losers.size === 0) return results;
|
|
874
|
+
return results.map((report, index) => {
|
|
875
|
+
const slot = plan.losers.get(index);
|
|
876
|
+
if (slot === void 0) return report;
|
|
877
|
+
const winner = results[slot];
|
|
878
|
+
return winner?.ok === true && winner.skipped !== true ? {
|
|
879
|
+
index,
|
|
880
|
+
ok: true,
|
|
881
|
+
consolidatedInto: slot
|
|
882
|
+
} : {
|
|
883
|
+
index,
|
|
884
|
+
ok: false,
|
|
885
|
+
skipped: true
|
|
886
|
+
};
|
|
887
|
+
});
|
|
888
|
+
};
|
|
889
|
+
/**
|
|
890
|
+
* Write N memories: one commit, one reindex, per-op results in input order.
|
|
891
|
+
*
|
|
892
|
+
* **Two folds, not one.** Decode is the operations layer's job and the store never sees it, so a
|
|
893
|
+
* malformed `memory_type` on op 4 has to be caught here. This function therefore folds decode
|
|
894
|
+
* over the ops and hands the store only what decoded. The store then folds the render gate, dedup,
|
|
895
|
+
* and path claim over that, and this function splices the two result sets back into one array in
|
|
896
|
+
* the caller's index space. Anything less and a decode failure would either be invisible per-op or
|
|
897
|
+
* would shift every later op's index by one.
|
|
898
|
+
*
|
|
899
|
+
* **One reindex, gated on a file having been written** (G4). The indexer's `update()` reads
|
|
900
|
+
* `git diff` over one commit, so a batch that committed once costs one diff. A dedupe-only
|
|
901
|
+
* batch, which commits nothing, skips it entirely, because moving the watermark for a commit that
|
|
902
|
+
* never happened is what `writeMemory`'s own `if (result.created)` guard exists to avoid.
|
|
903
|
+
*
|
|
904
|
+
* **The conflict assist is a third pass and it is read-only** (AC-1-2). It runs before the store's
|
|
905
|
+
* fold, over the ops as the caller sent them, and its findings are merged into the reports at the
|
|
906
|
+
* end, so it observes the batch and never participates in it. Nothing downstream of
|
|
907
|
+
* {@link detectFrameConflicts} branches on its result: the same files are written, the same commit is
|
|
908
|
+
* made, and the same ops are refused whether the flag is on or off. That is what propose-only means,
|
|
909
|
+
* and it is checkable by reading this function rather than by trusting a description.
|
|
910
|
+
*/
|
|
911
|
+
const batchWrite = (params) => Effect.gen(function* () {
|
|
912
|
+
const continueOnError = params.continueOnError === true;
|
|
913
|
+
const store = yield* Store;
|
|
914
|
+
const at = yield* nowSecond$1;
|
|
915
|
+
/**
|
|
916
|
+
* The assist, over the caller's own op array and before anything is written.
|
|
917
|
+
*
|
|
918
|
+
* Over `params.ops` rather than the decoded `inputs` below, so a conflict is reported in the
|
|
919
|
+
* caller's index space directly and needs no `originOf` translation. An op the store then
|
|
920
|
+
* refuses still gets its finding, which is the more useful order, because a caller told both "this
|
|
921
|
+
* op is malformed" and "it also contradicts X" fixes one thing.
|
|
922
|
+
*
|
|
923
|
+
* Not gated on the ops being valid, and deliberately so. `frameKeyOf` is a pure lexical function
|
|
924
|
+
* over a string, so it has nothing to refuse and cannot fail on an op the decode is about to reject.
|
|
925
|
+
*/
|
|
926
|
+
const conflicts = params.detectConflicts === true ? yield* detectFrameConflicts(params.ops) : /* @__PURE__ */ new Map();
|
|
927
|
+
/**
|
|
928
|
+
* The consolidation plan, before the decode fold and in the caller's index space. A
|
|
929
|
+
* batch-internal loser is excluded from everything downstream, so its value never earns a file.
|
|
930
|
+
* The surviving value sits at the earliest slot with its key, so every later report and
|
|
931
|
+
* conflict finding stays at the index the caller sent.
|
|
932
|
+
*/
|
|
933
|
+
const plan = params.consolidate === "last-wins" ? yield* planLastWins(params.ops) : null;
|
|
934
|
+
const planned = plan === null ? [...params.ops.entries()].map(([index, op]) => ({
|
|
935
|
+
index,
|
|
936
|
+
op
|
|
937
|
+
})) : plan.ops;
|
|
938
|
+
/**
|
|
939
|
+
* Fold 1, decode. `Effect.result` rather than letting the failure escape, because a decode
|
|
940
|
+
* refusal is this op's result and not the batch's.
|
|
941
|
+
*/
|
|
942
|
+
const reports = params.ops.map(() => void 0);
|
|
943
|
+
const inputs = [];
|
|
944
|
+
/** Store-result position → caller's op index, since the store never sees a skipped op. */
|
|
945
|
+
const originOf = [];
|
|
946
|
+
let decodeAborted = false;
|
|
947
|
+
for (const { index, op } of planned) {
|
|
948
|
+
const decoded = yield* Effect.result(toWriteInput({
|
|
949
|
+
...op,
|
|
950
|
+
...provenanceOf(params, op)
|
|
951
|
+
}, at));
|
|
952
|
+
if (decoded._tag === "Failure") {
|
|
953
|
+
reports[index] = reportFailure(index, decoded.failure);
|
|
954
|
+
if (!continueOnError) {
|
|
955
|
+
decodeAborted = true;
|
|
956
|
+
break;
|
|
957
|
+
}
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
originOf.push(index);
|
|
961
|
+
inputs.push(decoded.success);
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* An atomic decode abort touches the store at all. Nothing was written, so every other op,
|
|
965
|
+
* including the ones that decoded, reports `skipped`, matching the store's own abort semantics
|
|
966
|
+
* exactly rather than inventing a second one.
|
|
967
|
+
*/
|
|
968
|
+
if (decodeAborted) {
|
|
969
|
+
const results = withConsolidation(merged(reports, conflicts), plan);
|
|
970
|
+
return {
|
|
971
|
+
results,
|
|
972
|
+
summary: summarize$1(results),
|
|
973
|
+
commitSha: null
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* The extraction assist: one model call over the decoded ops, extracted entities unioned into
|
|
978
|
+
* each op's own `entities` before anything is written, so they land as ordinary `memhtml-entity`
|
|
979
|
+
* metas and the git tree, rather than the index, is what remembers them.
|
|
980
|
+
*
|
|
981
|
+
* After the decode fold because a refused op must not reach the prompt, and before the store
|
|
982
|
+
* because the render is what serializes the metas. Failure costs exactly this batch's
|
|
983
|
+
* extracted entities. The port being absent, the model being down, and an unreadable payload
|
|
984
|
+
* all take the same logged-warning path, and the write itself never waits on a retry.
|
|
985
|
+
* `entities: []` is what every write produced before this assist existed.
|
|
986
|
+
*/
|
|
987
|
+
const extractor = (yield* ExtractorPort).extractor;
|
|
988
|
+
if (extractor !== void 0 && inputs.length > 0) {
|
|
989
|
+
const items = inputs.map((input) => ({
|
|
990
|
+
title: input.title,
|
|
991
|
+
text: input.articleHtml !== void 0 ? input.articleHtml : [input.claim, ...input.body ?? []].join("\n")
|
|
992
|
+
}));
|
|
993
|
+
const outcome = yield* Effect.result(extractor.extract(items));
|
|
994
|
+
if (outcome._tag === "Failure") yield* Effect.logWarning(`entity extraction skipped for this batch: ${outcome.failure.reason}`);
|
|
995
|
+
else for (const [index, extracted] of outcome.success.entries()) {
|
|
996
|
+
const input = inputs[index];
|
|
997
|
+
if (input === void 0 || extracted.length === 0) continue;
|
|
998
|
+
const declared = input.entities ?? [];
|
|
999
|
+
const union = [...declared, ...extracted.filter((entity) => !declared.includes(entity))];
|
|
1000
|
+
inputs[index] = {
|
|
1001
|
+
...input,
|
|
1002
|
+
entities: union
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
const batch = yield* store.writeMemories(inputs, { continueOnError });
|
|
1007
|
+
for (const entry of batch.results) {
|
|
1008
|
+
const index = originOf[entry.index];
|
|
1009
|
+
if (index === void 0) continue;
|
|
1010
|
+
reports[index] = entry.ok || entry.skipped === true ? {
|
|
1011
|
+
index,
|
|
1012
|
+
ok: entry.ok,
|
|
1013
|
+
...defined({
|
|
1014
|
+
path: entry.path,
|
|
1015
|
+
deduped: entry.deduped,
|
|
1016
|
+
existingPath: entry.existingPath,
|
|
1017
|
+
skipped: entry.skipped
|
|
1018
|
+
})
|
|
1019
|
+
} : reportFailure(index, entry.error);
|
|
1020
|
+
}
|
|
1021
|
+
if (batch.writtenPaths.length > 0) yield* reindex();
|
|
1022
|
+
for (const path of batch.writtenPaths) yield* recordLink(path, "wrote", params, at);
|
|
1023
|
+
/**
|
|
1024
|
+
* The store-supersede pass, after a successful batch commit: every surviving slot whose frame
|
|
1025
|
+
* key a live memory occupied archives that memory, in one `supersedeMemories` call.
|
|
1026
|
+
*
|
|
1027
|
+
* A slot qualifies when its report is `ok` with a path, including a dedupe, where the path is
|
|
1028
|
+
* the pre-existing file that already carries this slot's value. The stored occupant still
|
|
1029
|
+
* states the losing value, so superseding it is still correct. A slot that failed or was
|
|
1030
|
+
* skipped wrote nothing, so there is nothing for its occupant to lose to.
|
|
1031
|
+
*
|
|
1032
|
+
* `Effect.result` rather than a bare yield, because a failed supersede must not fail a batch whose
|
|
1033
|
+
* memories already landed. The degradation is annotate-only: `supersededPath` is omitted, the
|
|
1034
|
+
* warning says why, and the corpus is merely unconsolidated, which is what every batch produced
|
|
1035
|
+
* before this flag existed. On success there is one extra reindex, because archive paths moved.
|
|
1036
|
+
*/
|
|
1037
|
+
if (plan !== null && plan.pendingSupersede.size > 0) {
|
|
1038
|
+
const pairs = [];
|
|
1039
|
+
const winnerOf = /* @__PURE__ */ new Map();
|
|
1040
|
+
for (const [slot, storedPath] of plan.pendingSupersede) {
|
|
1041
|
+
const report = reports[slot];
|
|
1042
|
+
if (report === void 0 || !report.ok || report.skipped === true) continue;
|
|
1043
|
+
if (report.path === void 0) continue;
|
|
1044
|
+
if (report.path === storedPath) continue;
|
|
1045
|
+
pairs.push({
|
|
1046
|
+
winnerPath: report.path,
|
|
1047
|
+
loserPath: storedPath
|
|
1048
|
+
});
|
|
1049
|
+
winnerOf.set(storedPath, slot);
|
|
1050
|
+
}
|
|
1051
|
+
if (pairs.length > 0) {
|
|
1052
|
+
const outcome = yield* Effect.result(store.supersedeMemories(pairs));
|
|
1053
|
+
if (outcome._tag === "Failure") yield* Effect.logWarning(`consolidation supersede skipped: ${messageFor(outcome.failure)}`);
|
|
1054
|
+
else {
|
|
1055
|
+
for (const entry of outcome.success.archived) {
|
|
1056
|
+
const slot = winnerOf.get(entry.loserPath);
|
|
1057
|
+
const report = slot === void 0 ? void 0 : reports[slot];
|
|
1058
|
+
if (slot === void 0 || report === void 0) continue;
|
|
1059
|
+
reports[slot] = {
|
|
1060
|
+
...report,
|
|
1061
|
+
supersededPath: entry.archivePath
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
if (outcome.success.archived.length > 0) yield* reindex();
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* An op the store aborted before reaching has no result of its own, and neither does one whose
|
|
1070
|
+
* decode succeeded in a batch the store then aborted. Both are `skipped`. Losers pick up their
|
|
1071
|
+
* `consolidatedInto` pointer last, from their winner slot's own final report.
|
|
1072
|
+
*/
|
|
1073
|
+
const results = withConsolidation(merged(reports, conflicts), plan);
|
|
1074
|
+
return {
|
|
1075
|
+
results,
|
|
1076
|
+
summary: summarize$1(results),
|
|
1077
|
+
commitSha: batch.commitSha
|
|
1078
|
+
};
|
|
1079
|
+
});
|
|
1080
|
+
/**
|
|
1081
|
+
* Per-op provenance falls back to the batch's own.
|
|
1082
|
+
*
|
|
1083
|
+
* The batch call carries the session the agent is in, and an op may name its own (a `memhtml apply`
|
|
1084
|
+
* file replaying a previous session's writes). Per-op wins, because it is the more specific statement
|
|
1085
|
+
* about where that one memory came from.
|
|
1086
|
+
*/
|
|
1087
|
+
const provenanceOf = (params, op) => defined({
|
|
1088
|
+
sessionId: op.sessionId ?? params.sessionId,
|
|
1089
|
+
promptId: op.promptId ?? params.promptId,
|
|
1090
|
+
turnUuid: op.turnUuid ?? params.turnUuid
|
|
1091
|
+
});
|
|
1092
|
+
/**
|
|
1093
|
+
* The reports as their final array: an unreported op becomes `skipped`, and every op picks up the
|
|
1094
|
+
* assist's finding for its index.
|
|
1095
|
+
*
|
|
1096
|
+
* One function for both exit paths, the atomic decode abort and the normal return, because they had
|
|
1097
|
+
* already grown two copies of the same `?? skipped` fill and a third responsibility spliced into only
|
|
1098
|
+
* one of them is how a batch that aborted would silently lose its conflict findings. The abort path
|
|
1099
|
+
* needs them because nothing was written. A caller told "op 2 is malformed" and also "op 0
|
|
1100
|
+
* contradicts areas/x.html" can fix both before retrying, rather than discovering the second on the
|
|
1101
|
+
* next round trip.
|
|
1102
|
+
*
|
|
1103
|
+
* Merging here rather than at each report's construction site also keeps the assist out of the
|
|
1104
|
+
* write path. The reports are already final when the conflicts are attached, so there is no point at
|
|
1105
|
+
* which a conflict could be read by anything that decides an outcome.
|
|
1106
|
+
*/
|
|
1107
|
+
const merged = (reports, conflicts) => reports.map((report, index) => {
|
|
1108
|
+
const base = report ?? {
|
|
1109
|
+
index,
|
|
1110
|
+
ok: false,
|
|
1111
|
+
skipped: true
|
|
1112
|
+
};
|
|
1113
|
+
const conflict = conflicts.get(index);
|
|
1114
|
+
return conflict === void 0 ? base : {
|
|
1115
|
+
...base,
|
|
1116
|
+
conflict
|
|
1117
|
+
};
|
|
1118
|
+
});
|
|
1119
|
+
/** The counts, derived from the reports in one pass so they cannot disagree with them. */
|
|
1120
|
+
const summarize$1 = (results) => {
|
|
1121
|
+
let written = 0;
|
|
1122
|
+
let deduped = 0;
|
|
1123
|
+
let failed = 0;
|
|
1124
|
+
let skipped = 0;
|
|
1125
|
+
let consolidated = 0;
|
|
1126
|
+
for (const result of results) if (result.consolidatedInto !== void 0) consolidated += 1;
|
|
1127
|
+
else if (result.skipped === true) skipped += 1;
|
|
1128
|
+
else if (!result.ok) failed += 1;
|
|
1129
|
+
else if (result.deduped === true) deduped += 1;
|
|
1130
|
+
else written += 1;
|
|
1131
|
+
return {
|
|
1132
|
+
total: results.length,
|
|
1133
|
+
written,
|
|
1134
|
+
deduped,
|
|
1135
|
+
failed,
|
|
1136
|
+
skipped,
|
|
1137
|
+
consolidated
|
|
1138
|
+
};
|
|
1139
|
+
};
|
|
1140
|
+
/**
|
|
1141
|
+
* Read one memory, optionally recording that the session read it.
|
|
1142
|
+
*
|
|
1143
|
+
* The access bump lives here and nowhere else on the retrieval side, because salience accumulates
|
|
1144
|
+
* evidence that someone chose a memory and a ranker's guess is not a choice. An explicit open names
|
|
1145
|
+
* one path, through this call and the `memhtml://file/{path}` resource that funnels through it, which is
|
|
1146
|
+
* the strongest signal short of a write. A path merely returned by search or recall was the ranker's own
|
|
1147
|
+
* suggestion, and bumping it builds a rich-get-richer loop: today's top five rank higher
|
|
1148
|
+
* tomorrow while the memory that should displace them never breaks in to earn a first bump.
|
|
1149
|
+
*
|
|
1150
|
+
* `bumpAccess` sits beside `recordLink` deliberately. Both are notes about the read, both swallow their
|
|
1151
|
+
* own failures, and neither may cost the caller the memory it asked for.
|
|
1152
|
+
*/
|
|
1153
|
+
const readMemory = (path, provenance = {}) => Effect.gen(function* () {
|
|
1154
|
+
const result = yield* (yield* Store).readMemory(path);
|
|
1155
|
+
yield* recordLink(result.path, "read", provenance, yield* nowSecond$1);
|
|
1156
|
+
yield* bumpAccess([result.path]);
|
|
1157
|
+
return result;
|
|
1158
|
+
});
|
|
1159
|
+
/**
|
|
1160
|
+
* Ranked search. The retrieval service sanitizes the query text itself in `fts-query.ts`, so this
|
|
1161
|
+
* function never MATCHes user prose and neither does any caller of it.
|
|
1162
|
+
*
|
|
1163
|
+
* **No access bump, and the omission is the rule rather than an oversight.** A hit is the ranker's
|
|
1164
|
+
* guess about what the caller wanted, so counting it as salience would let the ranking teach itself.
|
|
1165
|
+
* A memory in today's top five would rank higher tomorrow purely for having been listed, and the
|
|
1166
|
+
* memory that should displace it never appears and so never earns a first bump. The cooldown does not
|
|
1167
|
+
* help, because it bounds one query replayed within 900 seconds, while the drift it would have to
|
|
1168
|
+
* bound operates across days. Salience moves when a caller opens a path ({@link readMemory}) or names
|
|
1169
|
+
* an outcome ({@link reinforceMemories}).
|
|
1170
|
+
*/
|
|
1171
|
+
const searchMemories = (params) => Effect.gen(function* () {
|
|
1172
|
+
return yield* (yield* Retrieval).search(params);
|
|
1173
|
+
});
|
|
1174
|
+
/**
|
|
1175
|
+
* A context pack under a character budget.
|
|
1176
|
+
*
|
|
1177
|
+
* No access bump either, for {@link searchMemories}' reason. A disclosed body is still the ranker's
|
|
1178
|
+
* choice of what to spend the budget on rather than the caller's choice of what to read.
|
|
1179
|
+
*/
|
|
1180
|
+
const recallMemories = (params) => Effect.gen(function* () {
|
|
1181
|
+
return yield* (yield* Retrieval).recall(params);
|
|
1182
|
+
});
|
|
1183
|
+
/**
|
|
1184
|
+
* Bump access bookkeeping for paths a caller chose to open. A missing state plane makes this a no-op.
|
|
1185
|
+
*
|
|
1186
|
+
* `reinforce` is the one SQL writer for `state.access` and this helper does not become a second one.
|
|
1187
|
+
* It moves callers to that writer rather than moving the write here.
|
|
1188
|
+
*/
|
|
1189
|
+
const bumpAccess = (paths) => Effect.gen(function* () {
|
|
1190
|
+
if (paths.length === 0) return;
|
|
1191
|
+
const db = yield* DatabaseService;
|
|
1192
|
+
if (!db.hasState) return;
|
|
1193
|
+
yield* reinforce(db, paths, "neutral", yield* nowSecond$1).pipe(Effect.catch((error) => Effect.logWarning(`access bookkeeping missed: ${error.operation}`).pipe(Effect.as({
|
|
1194
|
+
bumped: [],
|
|
1195
|
+
cooledDown: []
|
|
1196
|
+
}))));
|
|
1197
|
+
});
|
|
1198
|
+
/**
|
|
1199
|
+
* Supersede a memory: the new file and the archived target land in one commit.
|
|
1200
|
+
*
|
|
1201
|
+
* The type defaults to the target's own. A correction that silently changed the type would move
|
|
1202
|
+
* the memory to a different retention profile and a different PARA directory, and that is a second
|
|
1203
|
+
* decision the caller did not make.
|
|
1204
|
+
*/
|
|
1205
|
+
const correctMemory = (params) => Effect.gen(function* () {
|
|
1206
|
+
const store = yield* Store;
|
|
1207
|
+
const target = yield* store.readMemory(params.targetPath);
|
|
1208
|
+
const requested = params.memoryType ?? target.doc.metas.memoryType;
|
|
1209
|
+
const memoryType = yield* decodeWritableType(requested);
|
|
1210
|
+
const at = yield* nowSecond$1;
|
|
1211
|
+
const result = yield* store.correctMemory(params.targetPath, {
|
|
1212
|
+
title: params.title,
|
|
1213
|
+
claim: params.claim,
|
|
1214
|
+
memoryType,
|
|
1215
|
+
at,
|
|
1216
|
+
...defined({
|
|
1217
|
+
body: params.body,
|
|
1218
|
+
articleHtml: params.articleHtml,
|
|
1219
|
+
reason: params.reason,
|
|
1220
|
+
sessionId: params.sessionId,
|
|
1221
|
+
promptId: params.promptId,
|
|
1222
|
+
turnUuid: params.turnUuid
|
|
1223
|
+
})
|
|
1224
|
+
});
|
|
1225
|
+
yield* reindex();
|
|
1226
|
+
yield* recordLink(result.path, "corrected", params, at);
|
|
1227
|
+
return result;
|
|
1228
|
+
});
|
|
1229
|
+
/**
|
|
1230
|
+
* Add an authored edge. Idempotent on `(rel, href)`, so a re-run commits nothing.
|
|
1231
|
+
*
|
|
1232
|
+
* The rel is decoded against {@link AUTHORABLE_RELS}, the memory class plus the task class, so
|
|
1233
|
+
* `memhtml link a.html blocks b.html` reaches the task graph while a person or provenance rel, both of
|
|
1234
|
+
* which the system mints itself, stays unauthorable.
|
|
1235
|
+
*/
|
|
1236
|
+
const linkMemories = (srcPath, rel, dstPath) => Effect.gen(function* () {
|
|
1237
|
+
const edgeRel = yield* decodeAuthorableRel(rel);
|
|
1238
|
+
const store = yield* Store;
|
|
1239
|
+
const src = normalizePath(srcPath);
|
|
1240
|
+
const result = yield* store.linkMemories(src, edgeRel, dstPath);
|
|
1241
|
+
if (result.commitSha !== null) yield* reindex();
|
|
1242
|
+
return {
|
|
1243
|
+
...result,
|
|
1244
|
+
srcPath: src,
|
|
1245
|
+
dstPath: normalizePath(dstPath),
|
|
1246
|
+
rel: edgeRel
|
|
1247
|
+
};
|
|
1248
|
+
});
|
|
1249
|
+
/** Soft-evict: `git mv` into `archive/<YYYY>/` with the archive stamps. Never a delete. */
|
|
1250
|
+
const archiveMemory = (path, reason) => Effect.gen(function* () {
|
|
1251
|
+
const result = yield* (yield* Store).archiveMemory(path, reason);
|
|
1252
|
+
yield* reindex();
|
|
1253
|
+
return result;
|
|
1254
|
+
});
|
|
1255
|
+
/** Bump access bookkeeping deliberately, with a caller-chosen signal. */
|
|
1256
|
+
const reinforceMemories = (paths, signal) => Effect.gen(function* () {
|
|
1257
|
+
const decoded = yield* decodeSignal(signal);
|
|
1258
|
+
const db = yield* DatabaseService;
|
|
1259
|
+
const at = yield* nowSecond$1;
|
|
1260
|
+
if (!db.hasState) return {
|
|
1261
|
+
bumped: [],
|
|
1262
|
+
cooledDown: paths,
|
|
1263
|
+
signal: decoded
|
|
1264
|
+
};
|
|
1265
|
+
return {
|
|
1266
|
+
...yield* reinforce(db, paths, decoded, at),
|
|
1267
|
+
signal: decoded
|
|
1268
|
+
};
|
|
1269
|
+
});
|
|
1270
|
+
/**
|
|
1271
|
+
* The memory graph around one path, to a fixed depth of at most two hops.
|
|
1272
|
+
*
|
|
1273
|
+
* **Two fixed-depth joins in a `UNION ALL`, deliberately not a recursive CTE.** The depth is
|
|
1274
|
+
* bounded at 2 by the tool's contract, so recursion buys nothing and costs the one thing a graph
|
|
1275
|
+
* query must not have here: an unbounded worst case on a corpus whose `relates_to` edges are
|
|
1276
|
+
* mined by the sleep cycle and can be dense. A fixed join is also index-covered by `edges_src`
|
|
1277
|
+
* and `edges_dst`, which a recursive walk is not.
|
|
1278
|
+
*
|
|
1279
|
+
* **Both directions, and `derived = 0 ∪ derived = 1`.** An edge is an assertion about a pair, and
|
|
1280
|
+
* which file happens to hold the `<link>` is authorship rather than direction of meaning. A
|
|
1281
|
+
* neighbourhood that read only outbound edges would show a superseding memory its target and hide
|
|
1282
|
+
* from the target that it had been superseded. Derived edges are included because lateral retrieval
|
|
1283
|
+
* is what they are for. `derived` is still reported per node so a caller can tell a
|
|
1284
|
+
* sleep-mined suspicion from an authored assertion.
|
|
1285
|
+
*
|
|
1286
|
+
* `edge_class = 'memory'` on every join. A person edge entering here would put
|
|
1287
|
+
* `resources/people/*` into a memory neighbourhood, and the class column exists to make that
|
|
1288
|
+
* structurally impossible.
|
|
1289
|
+
*/
|
|
1290
|
+
const neighborsOf = (params) => Effect.gen(function* () {
|
|
1291
|
+
const db = yield* DatabaseService;
|
|
1292
|
+
const centre = normalizePath(params.path);
|
|
1293
|
+
const depth = Math.min(2, Math.max(1, Math.trunc(params.depth ?? 1)));
|
|
1294
|
+
const rels = (params.rels ?? []).filter((rel) => isEdgeRel(rel) && relClassFor(rel) === "memory");
|
|
1295
|
+
const relFilter = rels.length > 0 ? ` AND e.rel IN (${rels.map(() => "?").join(", ")})` : "";
|
|
1296
|
+
const relFilter2 = rels.length > 0 ? ` AND e2.rel IN (${rels.map(() => "?").join(", ")})` : "";
|
|
1297
|
+
/**
|
|
1298
|
+
* Hop 1 is the centre's own edges, either direction. Hop 2 walks one further from each hop-1
|
|
1299
|
+
* node and excludes the centre, so a two-cycle does not report the centre as its own neighbour
|
|
1300
|
+
* at distance 2.
|
|
1301
|
+
*/
|
|
1302
|
+
const hopOne = `
|
|
1303
|
+
SELECT e.dst_path AS path, e.rel AS rel, e.derived AS derived, 1 AS hop
|
|
1304
|
+
FROM edges e
|
|
1305
|
+
WHERE e.src_path = ?1 AND e.edge_class = 'memory'${relFilter}
|
|
1306
|
+
UNION ALL
|
|
1307
|
+
SELECT e.src_path AS path, e.rel AS rel, e.derived AS derived, 1 AS hop
|
|
1308
|
+
FROM edges e
|
|
1309
|
+
WHERE e.dst_path = ?1 AND e.edge_class = 'memory'${relFilter}`;
|
|
1310
|
+
const hopTwo = `
|
|
1311
|
+
SELECT e2.dst_path AS path, e2.rel AS rel, e2.derived AS derived, 2 AS hop
|
|
1312
|
+
FROM edges e
|
|
1313
|
+
JOIN edges e2 ON e2.src_path = e.dst_path
|
|
1314
|
+
WHERE e.src_path = ?1 AND e.edge_class = 'memory' AND e2.edge_class = 'memory'
|
|
1315
|
+
AND e2.dst_path <> ?1${relFilter}${relFilter2}
|
|
1316
|
+
UNION ALL
|
|
1317
|
+
SELECT e2.src_path AS path, e2.rel AS rel, e2.derived AS derived, 2 AS hop
|
|
1318
|
+
FROM edges e
|
|
1319
|
+
JOIN edges e2 ON e2.dst_path = e.src_path
|
|
1320
|
+
WHERE e.dst_path = ?1 AND e.edge_class = 'memory' AND e2.edge_class = 'memory'
|
|
1321
|
+
AND e2.src_path <> ?1${relFilter}${relFilter2}`;
|
|
1322
|
+
const walk = depth === 1 ? hopOne : `${hopOne}\n UNION ALL${hopTwo}`;
|
|
1323
|
+
const nodes = (yield* db.all(`SELECT w.path AS path, f.title AS title, min(w.hop) AS hop,
|
|
1324
|
+
min(w.rel) AS rel, max(w.derived) AS derived
|
|
1325
|
+
FROM (${walk}) w
|
|
1326
|
+
JOIN files f ON f.path = w.path
|
|
1327
|
+
GROUP BY w.path
|
|
1328
|
+
ORDER BY hop ASC, w.path ASC`, [centre, ...depth === 1 ? [...rels, ...rels] : [
|
|
1329
|
+
...rels,
|
|
1330
|
+
...rels,
|
|
1331
|
+
...rels,
|
|
1332
|
+
...rels,
|
|
1333
|
+
...rels,
|
|
1334
|
+
...rels
|
|
1335
|
+
]])).map((row) => ({
|
|
1336
|
+
path: row.path,
|
|
1337
|
+
title: row.title,
|
|
1338
|
+
hop: row.hop,
|
|
1339
|
+
rel: row.rel
|
|
1340
|
+
}));
|
|
1341
|
+
return {
|
|
1342
|
+
centre,
|
|
1343
|
+
depth,
|
|
1344
|
+
nodes,
|
|
1345
|
+
edges: nodes.length
|
|
1346
|
+
};
|
|
1347
|
+
});
|
|
1348
|
+
/**
|
|
1349
|
+
* Page the corpus by facet.
|
|
1350
|
+
*
|
|
1351
|
+
* Keyset pagination on `path` rather than `LIMIT/OFFSET`. `files.path` is the primary key and it also
|
|
1352
|
+
* moves, because eviction is a `git mv`, so an offset page taken while a sleep cycle archives a file
|
|
1353
|
+
* would skip a row or repeat one. A cursor on the path itself is stable against that.
|
|
1354
|
+
*/
|
|
1355
|
+
const listMemories = (params) => Effect.gen(function* () {
|
|
1356
|
+
const db = yield* DatabaseService;
|
|
1357
|
+
const limit = Math.min(500, Math.max(1, Math.trunc(params.limit ?? 50)));
|
|
1358
|
+
const conditions = [];
|
|
1359
|
+
const values = [];
|
|
1360
|
+
if (params.includeArchived !== true) conditions.push("f.archived = 0");
|
|
1361
|
+
if (params.memoryType !== void 0 && params.memoryType !== "") {
|
|
1362
|
+
const memoryType = yield* decodeWritableType(params.memoryType);
|
|
1363
|
+
conditions.push("f.memory_type = ?");
|
|
1364
|
+
values.push(memoryType);
|
|
1365
|
+
}
|
|
1366
|
+
if (params.workspace !== void 0 && params.workspace !== "") {
|
|
1367
|
+
conditions.push("f.workspace = ?");
|
|
1368
|
+
values.push(params.workspace);
|
|
1369
|
+
}
|
|
1370
|
+
if (params.para !== void 0 && params.para !== "") {
|
|
1371
|
+
conditions.push("f.para = ?");
|
|
1372
|
+
values.push(params.para);
|
|
1373
|
+
}
|
|
1374
|
+
if (params.tag !== void 0 && params.tag !== "") {
|
|
1375
|
+
conditions.push("EXISTS (SELECT 1 FROM file_tags t WHERE t.path = f.path AND t.tag = ?)");
|
|
1376
|
+
values.push(params.tag);
|
|
1377
|
+
}
|
|
1378
|
+
if (params.entity !== void 0 && params.entity !== "") {
|
|
1379
|
+
conditions.push("EXISTS (SELECT 1 FROM file_entities e WHERE e.path = f.path AND e.entity_type || ':' || e.entity_name = ?)");
|
|
1380
|
+
values.push(params.entity);
|
|
1381
|
+
}
|
|
1382
|
+
if (params.cursor !== void 0 && params.cursor !== "") {
|
|
1383
|
+
conditions.push("f.path > ?");
|
|
1384
|
+
values.push(normalizePath(params.cursor));
|
|
1385
|
+
}
|
|
1386
|
+
const where = conditions.length === 0 ? "" : `WHERE ${conditions.join(" AND ")}`;
|
|
1387
|
+
const rows = yield* db.all(`SELECT f.path, f.title, f.memory_type, f.gist, f.workspace, f.para,
|
|
1388
|
+
f.confidence, f.importance, f.archived, f.updated_at
|
|
1389
|
+
FROM files f ${where} ORDER BY f.path ASC LIMIT ?`, [...values, limit + 1]);
|
|
1390
|
+
const page = rows.slice(0, limit);
|
|
1391
|
+
const nextCursor = rows.length > limit ? page.at(-1)?.path ?? null : null;
|
|
1392
|
+
return {
|
|
1393
|
+
files: page.map((row) => ({
|
|
1394
|
+
path: row.path,
|
|
1395
|
+
title: row.title,
|
|
1396
|
+
memoryType: row.memory_type,
|
|
1397
|
+
gist: row.gist,
|
|
1398
|
+
workspace: row.workspace,
|
|
1399
|
+
para: row.para,
|
|
1400
|
+
confidence: row.confidence,
|
|
1401
|
+
importance: row.importance,
|
|
1402
|
+
archived: row.archived === 1,
|
|
1403
|
+
updatedAt: row.updated_at
|
|
1404
|
+
})),
|
|
1405
|
+
nextCursor
|
|
1406
|
+
};
|
|
1407
|
+
});
|
|
1408
|
+
/**
|
|
1409
|
+
* FTS over session first-prompts and AI titles.
|
|
1410
|
+
*
|
|
1411
|
+
* The query goes through the same sanitizer the memory arms use, and it has to. An apostrophe is a
|
|
1412
|
+
* hard driver error rather than an empty result, and "what did I ask about don't-repeat-yourself"
|
|
1413
|
+
* is an ordinary trace query. An empty sanitized query returns the most recent sessions rather
|
|
1414
|
+
* than nothing, because a caller with no terms wants a listing and an empty MATCH is not a listing.
|
|
1415
|
+
*
|
|
1416
|
+
* This is the trace plane and it stops here. No memory table is named, and nothing in the
|
|
1417
|
+
* retrieval assembler names `traces`. The firewall is by table name, in both directions.
|
|
1418
|
+
*/
|
|
1419
|
+
const searchTraces = (params) => Effect.gen(function* () {
|
|
1420
|
+
const db = yield* DatabaseService;
|
|
1421
|
+
const match = sanitizeFtsQuery(params.query);
|
|
1422
|
+
const limit = Math.min(200, Math.max(1, Math.trunc(params.limit ?? 20)));
|
|
1423
|
+
const conditions = [];
|
|
1424
|
+
const values = [];
|
|
1425
|
+
/**
|
|
1426
|
+
* The MATCH names `traces_fts` rather than a column of `traces`. The index is an external-content
|
|
1427
|
+
* FTS5 table, so it is joined in by rowid and only reached when there is something to match. Without
|
|
1428
|
+
* a query the statement never mentions it, which is what keeps a bare listing a plain table scan.
|
|
1429
|
+
*/
|
|
1430
|
+
const matched = match !== "";
|
|
1431
|
+
const from = matched ? "FROM traces_fts JOIN traces t ON t.rowid = traces_fts.rowid" : "FROM traces t";
|
|
1432
|
+
if (matched) {
|
|
1433
|
+
conditions.push("traces_fts MATCH ?");
|
|
1434
|
+
values.push(match);
|
|
1435
|
+
}
|
|
1436
|
+
if (params.cwd !== void 0 && params.cwd !== "") {
|
|
1437
|
+
conditions.push("t.cwd = ?");
|
|
1438
|
+
values.push(params.cwd);
|
|
1439
|
+
}
|
|
1440
|
+
if (params.since !== void 0 && params.since !== "") {
|
|
1441
|
+
conditions.push("t.started_at >= ?");
|
|
1442
|
+
values.push(params.since);
|
|
1443
|
+
}
|
|
1444
|
+
const where = conditions.length === 0 ? "" : `WHERE ${conditions.join(" AND ")}`;
|
|
1445
|
+
const order = matched ? "ORDER BY bm25(traces_fts)" : "ORDER BY t.started_at DESC";
|
|
1446
|
+
return {
|
|
1447
|
+
sessions: (yield* db.all(`SELECT t.session_id, t.slug, t.cwd, t.started_at, t.prompt_count, t.first_prompt, t.ai_title
|
|
1448
|
+
${from} ${where} ${order} LIMIT ?`, [...values, limit])).map((row) => ({
|
|
1449
|
+
sessionId: row.session_id,
|
|
1450
|
+
slug: row.slug,
|
|
1451
|
+
cwd: row.cwd,
|
|
1452
|
+
startedAt: row.started_at,
|
|
1453
|
+
promptCount: row.prompt_count,
|
|
1454
|
+
firstPrompt: row.first_prompt,
|
|
1455
|
+
aiTitle: row.ai_title
|
|
1456
|
+
})),
|
|
1457
|
+
degraded: match === ""
|
|
1458
|
+
};
|
|
1459
|
+
});
|
|
1460
|
+
/**
|
|
1461
|
+
* The memory-session links, from either side.
|
|
1462
|
+
*
|
|
1463
|
+
* Both parameters absent is a refusal rather than an unbounded scan of every link ever recorded. A
|
|
1464
|
+
* tool whose no-argument form returns the whole table is a tool an agent calls by accident.
|
|
1465
|
+
*/
|
|
1466
|
+
const traceLinks = (params) => Effect.gen(function* () {
|
|
1467
|
+
const hasSession = params.sessionId !== void 0 && params.sessionId !== "";
|
|
1468
|
+
const hasPath = params.path !== void 0 && params.path !== "";
|
|
1469
|
+
if (!hasSession && !hasPath) return yield* Effect.fail(InvalidMemory.make({ reason: "trace links needs a session_id or a path" }));
|
|
1470
|
+
const db = yield* DatabaseService;
|
|
1471
|
+
const conditions = [];
|
|
1472
|
+
const values = [];
|
|
1473
|
+
if (hasSession) {
|
|
1474
|
+
conditions.push("l.session_id = ?");
|
|
1475
|
+
values.push(params.sessionId);
|
|
1476
|
+
}
|
|
1477
|
+
if (hasPath) {
|
|
1478
|
+
conditions.push("l.path = ?");
|
|
1479
|
+
values.push(normalizePath(params.path));
|
|
1480
|
+
}
|
|
1481
|
+
return { links: (yield* db.all(`SELECT l.path, l.session_id, l.prompt_id, l.turn_uuid, l.link_kind, l.at
|
|
1482
|
+
FROM memory_session_links l
|
|
1483
|
+
WHERE ${conditions.join(" AND ")}
|
|
1484
|
+
ORDER BY l.at DESC, l.path ASC`, values)).map((row) => ({
|
|
1485
|
+
path: row.path,
|
|
1486
|
+
sessionId: row.session_id,
|
|
1487
|
+
promptId: row.prompt_id,
|
|
1488
|
+
turnUuid: row.turn_uuid,
|
|
1489
|
+
linkKind: row.link_kind,
|
|
1490
|
+
at: row.at
|
|
1491
|
+
})) };
|
|
1492
|
+
});
|
|
1493
|
+
/**
|
|
1494
|
+
* Corpus health, in one call.
|
|
1495
|
+
*
|
|
1496
|
+
* `indexFresh` compares the recorded watermark to `HEAD`, which is the only answer that means
|
|
1497
|
+
* anything. The index is a projection of a commit, so "fresh" means "the commit it describes is the
|
|
1498
|
+
* commit we are on". A count of rows would say the index exists rather than that it is current.
|
|
1499
|
+
*
|
|
1500
|
+
* `embedderUp` is read off the stored watermark rather than by probing Bedrock. A status call that
|
|
1501
|
+
* made a network request would fail for a reason unrelated to the corpus, and what a caller
|
|
1502
|
+
* needs to know is whether the vectors in this index are usable.
|
|
1503
|
+
*/
|
|
1504
|
+
const statusReport = () => Effect.gen(function* () {
|
|
1505
|
+
const store = yield* Store;
|
|
1506
|
+
const db = yield* DatabaseService;
|
|
1507
|
+
const headSha = yield* store.git.revParseHead();
|
|
1508
|
+
const dirty = yield* store.dirtyPaths();
|
|
1509
|
+
const state = yield* readIndexState(db).pipe(Effect.orElseSucceed(() => void 0));
|
|
1510
|
+
const byType = yield* countRows(db, "SELECT memory_type AS k, count(*) AS n FROM files WHERE archived = 0 GROUP BY memory_type");
|
|
1511
|
+
const archivedCount = yield* countOne(db, "SELECT count(*) AS n FROM files WHERE archived = 1");
|
|
1512
|
+
const edges = yield* countOne(db, "SELECT count(*) AS n FROM edges");
|
|
1513
|
+
const derivedEdges = yield* countOne(db, "SELECT count(*) AS n FROM edges WHERE derived = 1");
|
|
1514
|
+
const embeddings = yield* countOne(db, "SELECT count(*) AS n FROM embeddings");
|
|
1515
|
+
const chunks = yield* countOne(db, "SELECT count(*) AS n FROM chunks");
|
|
1516
|
+
const traces = yield* countOne(db, "SELECT count(*) AS n FROM traces");
|
|
1517
|
+
const lastSleep = yield* db.get("SELECT run_id, status, started_at FROM sleep_runs ORDER BY started_at DESC LIMIT 1").pipe(Effect.orElseSucceed(() => void 0));
|
|
1518
|
+
return {
|
|
1519
|
+
root: store.root,
|
|
1520
|
+
headSha,
|
|
1521
|
+
dirty: dirty.length > 0,
|
|
1522
|
+
dirtyPaths: dirty,
|
|
1523
|
+
countsByType: byType,
|
|
1524
|
+
archivedCount,
|
|
1525
|
+
edges,
|
|
1526
|
+
derivedEdges,
|
|
1527
|
+
chunks,
|
|
1528
|
+
embeddings,
|
|
1529
|
+
traces,
|
|
1530
|
+
indexFresh: state?.head_sha !== null && state?.head_sha === headSha,
|
|
1531
|
+
indexHeadSha: state?.head_sha ?? null,
|
|
1532
|
+
embedModel: state?.embed_model ?? null,
|
|
1533
|
+
embedderUp: state !== void 0 && state.embed_model === EMBED_WATERMARK && embeddings > 0,
|
|
1534
|
+
hasState: db.hasState,
|
|
1535
|
+
lastSleep: lastSleep === void 0 ? null : {
|
|
1536
|
+
runId: lastSleep.run_id,
|
|
1537
|
+
status: lastSleep.status,
|
|
1538
|
+
startedAt: lastSleep.started_at
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
});
|
|
1542
|
+
/** One scalar count, `0` when the table is unreachable. */
|
|
1543
|
+
const countOne = (db, sql) => db.get(sql).pipe(Effect.map((row) => row?.n ?? 0));
|
|
1544
|
+
/** A `GROUP BY` into a record. An absent key means zero, so the caller never reads a null. */
|
|
1545
|
+
const countRows = (db, sql) => db.all(sql).pipe(Effect.map((rows) => Object.fromEntries(rows.map((row) => [row.k, row.n]))));
|
|
1546
|
+
|
|
1547
|
+
//#endregion
|
|
1548
|
+
//#region apps/cli/dist/commands.js
|
|
1549
|
+
/** Flags every command accepts. Listed once so the manifest cannot drift from behavior. */
|
|
1550
|
+
const GLOBAL_FLAGS = [
|
|
1551
|
+
{
|
|
1552
|
+
name: "json",
|
|
1553
|
+
type: "boolean",
|
|
1554
|
+
description: "Emit the typed JSON envelope on stdout (default; logs go to stderr).",
|
|
1555
|
+
default: true
|
|
1556
|
+
},
|
|
1557
|
+
{
|
|
1558
|
+
name: "dense",
|
|
1559
|
+
type: "boolean",
|
|
1560
|
+
description: "Minify JSON and drop null fields, for pasting into a context window.",
|
|
1561
|
+
default: false
|
|
1562
|
+
},
|
|
1563
|
+
{
|
|
1564
|
+
name: "repo",
|
|
1565
|
+
type: "string",
|
|
1566
|
+
description: "Path to the memory repo. Defaults to $MEMHTML_ROOT.",
|
|
1567
|
+
default: ""
|
|
1568
|
+
}
|
|
1569
|
+
];
|
|
1570
|
+
/** Flags every retrieval command shares, so `search` and `recall` cannot scope differently. */
|
|
1571
|
+
const SCOPE_FLAGS = [
|
|
1572
|
+
{
|
|
1573
|
+
name: "type",
|
|
1574
|
+
type: "string",
|
|
1575
|
+
description: "Restrict to one memory type. Repeatable; each occurrence broadens (ANY-of).",
|
|
1576
|
+
values: WRITABLE_MEMORY_TYPES,
|
|
1577
|
+
repeatable: true
|
|
1578
|
+
},
|
|
1579
|
+
{
|
|
1580
|
+
name: "workspace",
|
|
1581
|
+
type: "string",
|
|
1582
|
+
description: "Restrict to one workspace. STRICT: a scoped query never returns a memory with no workspace."
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
name: "tag",
|
|
1586
|
+
type: "string",
|
|
1587
|
+
description: "Restrict to memories carrying any of these tags. Repeatable; each broadens.",
|
|
1588
|
+
repeatable: true
|
|
1589
|
+
},
|
|
1590
|
+
{
|
|
1591
|
+
name: "entity",
|
|
1592
|
+
type: "string",
|
|
1593
|
+
description: "Restrict to memories carrying one `type:name` entity reference, e.g. service:checkout-api, the form a hit's `entities` publishes, so a hop is a copy. A scope matching nothing returns no hits and says so; it never widens."
|
|
1594
|
+
},
|
|
1595
|
+
{
|
|
1596
|
+
name: "include-archived",
|
|
1597
|
+
type: "boolean",
|
|
1598
|
+
description: "Include archived memories. Eviction is a `git mv`, so they still exist.",
|
|
1599
|
+
default: false
|
|
1600
|
+
},
|
|
1601
|
+
{
|
|
1602
|
+
name: "as-of",
|
|
1603
|
+
type: "string",
|
|
1604
|
+
description: "Point-in-time view: returns what was believed valid at this ISO instant, including since-superseded memories (marked superseded_by). The validity window is coalesce(valid_from, event_at, created_at) <= as-of < valid_until."
|
|
1605
|
+
}
|
|
1606
|
+
];
|
|
1607
|
+
/**
|
|
1608
|
+
* The single source of parsing, validation, and the manifest. A command lands here
|
|
1609
|
+
* before it lands anywhere else, so `memhtml manifest` and `memhtml agents-doc` describe
|
|
1610
|
+
* what the binary actually accepts rather than what someone remembered to document.
|
|
1611
|
+
*
|
|
1612
|
+
* A subcommand is one entry with a space in its name (`index rebuild`), not a nested tree.
|
|
1613
|
+
* Flattening keeps `nearest()` able to suggest across the whole surface, a typo in the noun
|
|
1614
|
+
* (`memhtml indx rebuild`) and a typo in the verb (`memhtml index rebiuld`) both get a candidate, and
|
|
1615
|
+
* keeps one table driving parsing, the manifest, and the generated doc.
|
|
1616
|
+
*/
|
|
1617
|
+
const COMMANDS = [
|
|
1618
|
+
{
|
|
1619
|
+
name: "manifest",
|
|
1620
|
+
summary: "Emit this CLI's full machine-readable contract.",
|
|
1621
|
+
args: [],
|
|
1622
|
+
flags: [],
|
|
1623
|
+
responseTypes: ["cli.manifest"]
|
|
1624
|
+
},
|
|
1625
|
+
{
|
|
1626
|
+
name: "init",
|
|
1627
|
+
summary: "Scaffold a memory repo at --repo/$MEMHTML_ROOT: git init, PARA dirs, merge driver.",
|
|
1628
|
+
args: [],
|
|
1629
|
+
flags: [],
|
|
1630
|
+
responseTypes: ["repo.init"]
|
|
1631
|
+
},
|
|
1632
|
+
{
|
|
1633
|
+
name: "write",
|
|
1634
|
+
summary: "Write one memory. Content-hash duplicates return the existing path, uncommitted.",
|
|
1635
|
+
args: [],
|
|
1636
|
+
flags: [
|
|
1637
|
+
{
|
|
1638
|
+
name: "title",
|
|
1639
|
+
type: "string",
|
|
1640
|
+
description: "The memory's title. Becomes the <title> and the filename slug.",
|
|
1641
|
+
required: true
|
|
1642
|
+
},
|
|
1643
|
+
{
|
|
1644
|
+
name: "claim",
|
|
1645
|
+
type: "string",
|
|
1646
|
+
description: "The one load-bearing sentence. Becomes the <mark> span and files.gist. Exactly one of --claim or --article-html."
|
|
1647
|
+
},
|
|
1648
|
+
{
|
|
1649
|
+
name: "body",
|
|
1650
|
+
type: "string",
|
|
1651
|
+
description: "A prose paragraph after the claim. Repeatable, one <p> each.",
|
|
1652
|
+
repeatable: true
|
|
1653
|
+
},
|
|
1654
|
+
{
|
|
1655
|
+
name: "article-html",
|
|
1656
|
+
type: "string",
|
|
1657
|
+
description: "Raw <article> markup used verbatim in place of --claim/--body. Must contain exactly one <mark> in the first <p> or <li>; the first <time datetime> becomes the memory's event time. The store refuses format violations before any commit. Exactly one of --claim or --article-html."
|
|
1658
|
+
},
|
|
1659
|
+
{
|
|
1660
|
+
name: "type",
|
|
1661
|
+
type: "string",
|
|
1662
|
+
description: "The memory type. `arc` is absent: an arc is synthesized by sleep.",
|
|
1663
|
+
values: WRITABLE_MEMORY_TYPES,
|
|
1664
|
+
required: true
|
|
1665
|
+
},
|
|
1666
|
+
{
|
|
1667
|
+
name: "path",
|
|
1668
|
+
type: "string",
|
|
1669
|
+
description: "An explicit path override. Ignored when it is not a valid memory path."
|
|
1670
|
+
},
|
|
1671
|
+
{
|
|
1672
|
+
name: "workspace",
|
|
1673
|
+
type: "string",
|
|
1674
|
+
description: "Routes the memory to projects/<slug>/."
|
|
1675
|
+
},
|
|
1676
|
+
{
|
|
1677
|
+
name: "tag",
|
|
1678
|
+
type: "string",
|
|
1679
|
+
description: "A tag. Repeatable; the first one routes an unplaced resource memory."
|
|
1680
|
+
},
|
|
1681
|
+
{
|
|
1682
|
+
name: "entity",
|
|
1683
|
+
type: "string",
|
|
1684
|
+
description: "A `type:name` entity reference, e.g. service:checkout-api. Repeatable.",
|
|
1685
|
+
repeatable: true
|
|
1686
|
+
},
|
|
1687
|
+
{
|
|
1688
|
+
name: "importance",
|
|
1689
|
+
type: "int",
|
|
1690
|
+
description: "1-10, a display ordinal. The retention scorer divides by 10."
|
|
1691
|
+
},
|
|
1692
|
+
{
|
|
1693
|
+
name: "confidence",
|
|
1694
|
+
type: "string",
|
|
1695
|
+
description: "0-1. 1.0 is an unqualified assertion."
|
|
1696
|
+
},
|
|
1697
|
+
{
|
|
1698
|
+
name: "session-id",
|
|
1699
|
+
type: "string",
|
|
1700
|
+
description: "The Claude Code session. Stamped into the head AND indexed as a link."
|
|
1701
|
+
},
|
|
1702
|
+
{
|
|
1703
|
+
name: "prompt-id",
|
|
1704
|
+
type: "string",
|
|
1705
|
+
description: "The prompt within that session."
|
|
1706
|
+
},
|
|
1707
|
+
{
|
|
1708
|
+
name: "turn-uuid",
|
|
1709
|
+
type: "string",
|
|
1710
|
+
description: "The turn within that session."
|
|
1711
|
+
}
|
|
1712
|
+
],
|
|
1713
|
+
responseTypes: ["memory.written"]
|
|
1714
|
+
},
|
|
1715
|
+
{
|
|
1716
|
+
name: "apply",
|
|
1717
|
+
summary: "Write many memories from a JSONL op stream: ONE commit, ONE index update, per-op results.",
|
|
1718
|
+
args: [],
|
|
1719
|
+
flags: [
|
|
1720
|
+
{
|
|
1721
|
+
name: "file",
|
|
1722
|
+
type: "string",
|
|
1723
|
+
description: "The JSONL file to read. One complete JSON object per line. Omit it (or pass `-`) to read the stream from stdin."
|
|
1724
|
+
},
|
|
1725
|
+
{
|
|
1726
|
+
name: "continue-on-error",
|
|
1727
|
+
type: "boolean",
|
|
1728
|
+
description: "Best-effort: a refused op is reported and skipped while every surviving op lands in the one commit. Atomic by default. The first refused op aborts the batch and nothing is written.",
|
|
1729
|
+
default: false
|
|
1730
|
+
},
|
|
1731
|
+
{
|
|
1732
|
+
name: "detect-conflicts",
|
|
1733
|
+
type: "boolean",
|
|
1734
|
+
description: "Report each op's frame-matches as a per-op `conflict`: the ACTIVE memory (or the earlier op) whose claim occupies the same subject-and-relation slot. PROPOSE-ONLY: every op still writes exactly as it would have, because sometimes the contradiction is the answer. You decide: write anyway, `memhtml correct` the match, or drop the line.",
|
|
1735
|
+
default: false
|
|
1736
|
+
},
|
|
1737
|
+
{
|
|
1738
|
+
name: "consolidate",
|
|
1739
|
+
type: "string",
|
|
1740
|
+
values: ["last-wins"],
|
|
1741
|
+
description: "Resolve frame-key matches instead of only reporting them: `--consolidate last-wins` makes the LATER op's value win a shared claim slot (one file, written at the FIRST index that claimed the slot, with each later restatement reporting `consolidated_into` naming that slot) and archives a stored ACTIVE memory a surviving slot displaces, reported as `superseded_path`. Off by default; claims with no frame shape are never consolidated."
|
|
1742
|
+
},
|
|
1743
|
+
{
|
|
1744
|
+
name: "session-id",
|
|
1745
|
+
type: "string",
|
|
1746
|
+
description: "The Claude Code session for every op that names none. A line's own `session_id` wins over this."
|
|
1747
|
+
},
|
|
1748
|
+
{
|
|
1749
|
+
name: "prompt-id",
|
|
1750
|
+
type: "string",
|
|
1751
|
+
description: "The prompt within that session."
|
|
1752
|
+
},
|
|
1753
|
+
{
|
|
1754
|
+
name: "turn-uuid",
|
|
1755
|
+
type: "string",
|
|
1756
|
+
description: "The turn within that session."
|
|
1757
|
+
}
|
|
1758
|
+
],
|
|
1759
|
+
responseTypes: ["batch.applied"]
|
|
1760
|
+
},
|
|
1761
|
+
{
|
|
1762
|
+
name: "read",
|
|
1763
|
+
summary: "Read one memory: its metas, links, article, and format warnings.",
|
|
1764
|
+
args: [{
|
|
1765
|
+
name: "path",
|
|
1766
|
+
description: "Repo-root-relative path to the memory.",
|
|
1767
|
+
required: true
|
|
1768
|
+
}],
|
|
1769
|
+
flags: [{
|
|
1770
|
+
name: "session-id",
|
|
1771
|
+
type: "string",
|
|
1772
|
+
description: "Records a `read` session link, so provenance is queryable both ways."
|
|
1773
|
+
}],
|
|
1774
|
+
responseTypes: ["memory.detail"]
|
|
1775
|
+
},
|
|
1776
|
+
{
|
|
1777
|
+
name: "search",
|
|
1778
|
+
summary: "Ranked search: four RRF arms plus MMR. Degrades to the lexical floor.",
|
|
1779
|
+
args: [{
|
|
1780
|
+
name: "query",
|
|
1781
|
+
description: "Prose. Never a query language.",
|
|
1782
|
+
required: true
|
|
1783
|
+
}],
|
|
1784
|
+
flags: [...SCOPE_FLAGS, {
|
|
1785
|
+
name: "limit",
|
|
1786
|
+
type: "int",
|
|
1787
|
+
description: "Hits to return.",
|
|
1788
|
+
default: 10
|
|
1789
|
+
}],
|
|
1790
|
+
responseTypes: ["memory.hits"]
|
|
1791
|
+
},
|
|
1792
|
+
{
|
|
1793
|
+
name: "recall",
|
|
1794
|
+
summary: "A disclosure pack under a character budget: arcs and memories folded separately.",
|
|
1795
|
+
args: [{
|
|
1796
|
+
name: "query",
|
|
1797
|
+
description: "Prose.",
|
|
1798
|
+
required: true
|
|
1799
|
+
}],
|
|
1800
|
+
flags: [...SCOPE_FLAGS, {
|
|
1801
|
+
name: "budget",
|
|
1802
|
+
type: "int",
|
|
1803
|
+
description: "Characters of quoted body. Arcs get their own envelope on top.",
|
|
1804
|
+
default: 16e3
|
|
1805
|
+
}],
|
|
1806
|
+
responseTypes: ["recall.pack"]
|
|
1807
|
+
},
|
|
1808
|
+
{
|
|
1809
|
+
name: "correct",
|
|
1810
|
+
summary: "Supersede a memory: write the new file and archive the target in ONE commit.",
|
|
1811
|
+
args: [{
|
|
1812
|
+
name: "target",
|
|
1813
|
+
description: "The memory being corrected.",
|
|
1814
|
+
required: true
|
|
1815
|
+
}],
|
|
1816
|
+
flags: [
|
|
1817
|
+
{
|
|
1818
|
+
name: "title",
|
|
1819
|
+
type: "string",
|
|
1820
|
+
description: "The new memory's title.",
|
|
1821
|
+
required: true
|
|
1822
|
+
},
|
|
1823
|
+
{
|
|
1824
|
+
name: "claim",
|
|
1825
|
+
type: "string",
|
|
1826
|
+
description: "The corrected claim. Exactly one of --claim or --article-html."
|
|
1827
|
+
},
|
|
1828
|
+
{
|
|
1829
|
+
name: "body",
|
|
1830
|
+
type: "string",
|
|
1831
|
+
description: "A prose paragraph. Repeatable.",
|
|
1832
|
+
repeatable: true
|
|
1833
|
+
},
|
|
1834
|
+
{
|
|
1835
|
+
name: "article-html",
|
|
1836
|
+
type: "string",
|
|
1837
|
+
description: "Raw <article> markup for the superseding memory, used verbatim in place of --claim/--body. Must contain exactly one <mark> in the first <p> or <li>; the first <time datetime> becomes the memory's event time. The store refuses format violations before any commit. Exactly one of --claim or --article-html."
|
|
1838
|
+
},
|
|
1839
|
+
{
|
|
1840
|
+
name: "type",
|
|
1841
|
+
type: "string",
|
|
1842
|
+
description: "The new memory's type. Defaults to the target's.",
|
|
1843
|
+
values: WRITABLE_MEMORY_TYPES
|
|
1844
|
+
},
|
|
1845
|
+
{
|
|
1846
|
+
name: "reason",
|
|
1847
|
+
type: "string",
|
|
1848
|
+
description: "Why the correction was made."
|
|
1849
|
+
},
|
|
1850
|
+
{
|
|
1851
|
+
name: "session-id",
|
|
1852
|
+
type: "string",
|
|
1853
|
+
description: "Records a `corrected` session link."
|
|
1854
|
+
}
|
|
1855
|
+
],
|
|
1856
|
+
responseTypes: ["memory.corrected"]
|
|
1857
|
+
},
|
|
1858
|
+
{
|
|
1859
|
+
name: "link",
|
|
1860
|
+
summary: "Add an authored edge to the source file and commit it. Idempotent.",
|
|
1861
|
+
args: [
|
|
1862
|
+
{
|
|
1863
|
+
name: "src",
|
|
1864
|
+
description: "The asserting memory or task.",
|
|
1865
|
+
required: true
|
|
1866
|
+
},
|
|
1867
|
+
{
|
|
1868
|
+
name: "rel",
|
|
1869
|
+
description: `One of: ${AUTHORABLE_RELS.join(", ")}. A task rel needs two tasks; a memory rel refuses a task endpoint.`,
|
|
1870
|
+
required: true
|
|
1871
|
+
},
|
|
1872
|
+
{
|
|
1873
|
+
name: "dst",
|
|
1874
|
+
description: "The memory or task being pointed at.",
|
|
1875
|
+
required: true
|
|
1876
|
+
}
|
|
1877
|
+
],
|
|
1878
|
+
flags: [],
|
|
1879
|
+
responseTypes: ["memory.linked"]
|
|
1880
|
+
},
|
|
1881
|
+
{
|
|
1882
|
+
name: "neighbors",
|
|
1883
|
+
summary: "The memory graph around one path, to a fixed depth of at most two hops.",
|
|
1884
|
+
args: [{
|
|
1885
|
+
name: "path",
|
|
1886
|
+
description: "The centre of the neighbourhood.",
|
|
1887
|
+
required: true
|
|
1888
|
+
}],
|
|
1889
|
+
flags: [{
|
|
1890
|
+
name: "depth",
|
|
1891
|
+
type: "int",
|
|
1892
|
+
description: "1 or 2. Never more.",
|
|
1893
|
+
default: 1
|
|
1894
|
+
}, {
|
|
1895
|
+
name: "rel",
|
|
1896
|
+
type: "string",
|
|
1897
|
+
description: "Restrict to these rels. Repeatable.",
|
|
1898
|
+
values: MEMORY_RELS,
|
|
1899
|
+
repeatable: true
|
|
1900
|
+
}],
|
|
1901
|
+
responseTypes: ["memory.neighbors"]
|
|
1902
|
+
},
|
|
1903
|
+
{
|
|
1904
|
+
name: "archive",
|
|
1905
|
+
summary: "Soft-evict: `git mv` into archive/<YYYY>/ with the archive stamps. Never a delete.",
|
|
1906
|
+
args: [{
|
|
1907
|
+
name: "path",
|
|
1908
|
+
description: "The memory to archive.",
|
|
1909
|
+
required: true
|
|
1910
|
+
}],
|
|
1911
|
+
flags: [{
|
|
1912
|
+
name: "reason",
|
|
1913
|
+
type: "string",
|
|
1914
|
+
description: "Why it was evicted.",
|
|
1915
|
+
required: true
|
|
1916
|
+
}],
|
|
1917
|
+
responseTypes: ["memory.archived"]
|
|
1918
|
+
},
|
|
1919
|
+
{
|
|
1920
|
+
name: "reinforce",
|
|
1921
|
+
summary: "Bump access bookkeeping, gated by a 900-second per-path cooldown.",
|
|
1922
|
+
args: [{
|
|
1923
|
+
name: "path",
|
|
1924
|
+
description: "A memory path. Repeat the argument for more.",
|
|
1925
|
+
required: true
|
|
1926
|
+
}],
|
|
1927
|
+
flags: [{
|
|
1928
|
+
name: "signal",
|
|
1929
|
+
type: "string",
|
|
1930
|
+
description: "`neutral` bumps access without claiming the memory was right.",
|
|
1931
|
+
values: REINFORCE_SIGNALS,
|
|
1932
|
+
default: "neutral"
|
|
1933
|
+
}],
|
|
1934
|
+
responseTypes: ["memory.reinforced"]
|
|
1935
|
+
},
|
|
1936
|
+
{
|
|
1937
|
+
name: "list",
|
|
1938
|
+
summary: "Page through the corpus by type, workspace, tag, entity, or PARA bucket.",
|
|
1939
|
+
args: [],
|
|
1940
|
+
flags: [
|
|
1941
|
+
{
|
|
1942
|
+
name: "type",
|
|
1943
|
+
type: "string",
|
|
1944
|
+
description: "One memory type.",
|
|
1945
|
+
values: WRITABLE_MEMORY_TYPES
|
|
1946
|
+
},
|
|
1947
|
+
{
|
|
1948
|
+
name: "workspace",
|
|
1949
|
+
type: "string",
|
|
1950
|
+
description: "One workspace."
|
|
1951
|
+
},
|
|
1952
|
+
{
|
|
1953
|
+
name: "tag",
|
|
1954
|
+
type: "string",
|
|
1955
|
+
description: "One tag."
|
|
1956
|
+
},
|
|
1957
|
+
{
|
|
1958
|
+
name: "entity",
|
|
1959
|
+
type: "string",
|
|
1960
|
+
description: "One `type:name` entity reference."
|
|
1961
|
+
},
|
|
1962
|
+
{
|
|
1963
|
+
name: "para",
|
|
1964
|
+
type: "string",
|
|
1965
|
+
description: "One PARA bucket.",
|
|
1966
|
+
values: [
|
|
1967
|
+
"projects",
|
|
1968
|
+
"areas",
|
|
1969
|
+
"resources",
|
|
1970
|
+
"archive"
|
|
1971
|
+
]
|
|
1972
|
+
},
|
|
1973
|
+
{
|
|
1974
|
+
name: "limit",
|
|
1975
|
+
type: "int",
|
|
1976
|
+
description: "Rows per page.",
|
|
1977
|
+
default: 50
|
|
1978
|
+
},
|
|
1979
|
+
{
|
|
1980
|
+
name: "cursor",
|
|
1981
|
+
type: "string",
|
|
1982
|
+
description: "The `next_cursor` from the previous page: the last path returned."
|
|
1983
|
+
},
|
|
1984
|
+
{
|
|
1985
|
+
name: "include-archived",
|
|
1986
|
+
type: "boolean",
|
|
1987
|
+
description: "Include archived memories.",
|
|
1988
|
+
default: false
|
|
1989
|
+
}
|
|
1990
|
+
],
|
|
1991
|
+
responseTypes: ["memory.list"]
|
|
1992
|
+
},
|
|
1993
|
+
(
|
|
1994
|
+
/**
|
|
1995
|
+
* The task family: CRUDL over the 10th memory type, without retrieval.
|
|
1996
|
+
*
|
|
1997
|
+
* Sugar over the same use cases everything else uses. `task add` is `writeMemory` with
|
|
1998
|
+
* `--type task`, and `task status` is one head meta plus (for `done`) the archive machinery. The
|
|
1999
|
+
* design intent is that an agent works tasks with `Read`, `Edit`, and `ls` as readily as with these.
|
|
2000
|
+
* A task is a file in a directory, and this family exists so the common moves are one call rather
|
|
2001
|
+
* than three.
|
|
2002
|
+
*/
|
|
2003
|
+
{
|
|
2004
|
+
name: "task add",
|
|
2005
|
+
summary: "Open a task: a `task` memory in projects/<ws>/tasks/ or areas/inbox/tasks/.",
|
|
2006
|
+
args: [],
|
|
2007
|
+
flags: [
|
|
2008
|
+
{
|
|
2009
|
+
name: "title",
|
|
2010
|
+
type: "string",
|
|
2011
|
+
description: "What the task is. Becomes the <title> and the filename slug.",
|
|
2012
|
+
required: true
|
|
2013
|
+
},
|
|
2014
|
+
{
|
|
2015
|
+
name: "claim",
|
|
2016
|
+
type: "string",
|
|
2017
|
+
description: "The task statement, as the <mark> span. Defaults to --title."
|
|
2018
|
+
},
|
|
2019
|
+
{
|
|
2020
|
+
name: "body",
|
|
2021
|
+
type: "string",
|
|
2022
|
+
description: "A prose paragraph of working notes. Repeatable, one <p> each.",
|
|
2023
|
+
repeatable: true
|
|
2024
|
+
},
|
|
2025
|
+
{
|
|
2026
|
+
name: "status",
|
|
2027
|
+
type: "string",
|
|
2028
|
+
description: "The opening status. `todo` unless you are recording work already underway.",
|
|
2029
|
+
values: TASK_STATUSES,
|
|
2030
|
+
default: "todo"
|
|
2031
|
+
},
|
|
2032
|
+
{
|
|
2033
|
+
name: "due",
|
|
2034
|
+
type: "string",
|
|
2035
|
+
description: "An ISO date or datetime deadline. Compared as a string, so the form matters."
|
|
2036
|
+
},
|
|
2037
|
+
{
|
|
2038
|
+
name: "workspace",
|
|
2039
|
+
type: "string",
|
|
2040
|
+
description: "Routes the task to projects/<slug>/tasks/."
|
|
2041
|
+
},
|
|
2042
|
+
{
|
|
2043
|
+
name: "tag",
|
|
2044
|
+
type: "string",
|
|
2045
|
+
description: "A tag. Repeatable; tags scope search but never route a task.",
|
|
2046
|
+
repeatable: true
|
|
2047
|
+
},
|
|
2048
|
+
{
|
|
2049
|
+
name: "entity",
|
|
2050
|
+
type: "string",
|
|
2051
|
+
description: "A `type:name` entity reference. Repeatable.",
|
|
2052
|
+
repeatable: true
|
|
2053
|
+
},
|
|
2054
|
+
{
|
|
2055
|
+
name: "session-id",
|
|
2056
|
+
type: "string",
|
|
2057
|
+
description: "The Claude Code session that opened the task."
|
|
2058
|
+
},
|
|
2059
|
+
{
|
|
2060
|
+
name: "prompt-id",
|
|
2061
|
+
type: "string",
|
|
2062
|
+
description: "The prompt within that session."
|
|
2063
|
+
},
|
|
2064
|
+
{
|
|
2065
|
+
name: "turn-uuid",
|
|
2066
|
+
type: "string",
|
|
2067
|
+
description: "The turn within that session."
|
|
2068
|
+
}
|
|
2069
|
+
],
|
|
2070
|
+
responseTypes: ["task.written"]
|
|
2071
|
+
}),
|
|
2072
|
+
{
|
|
2073
|
+
name: "task status",
|
|
2074
|
+
summary: "Move a task's status. `done` stamps AND archives it, in one commit.",
|
|
2075
|
+
args: [{
|
|
2076
|
+
name: "path",
|
|
2077
|
+
description: "The task file.",
|
|
2078
|
+
required: true
|
|
2079
|
+
}, {
|
|
2080
|
+
name: "status",
|
|
2081
|
+
description: `One of: ${TASK_STATUSES.join(", ")}.`,
|
|
2082
|
+
required: true
|
|
2083
|
+
}],
|
|
2084
|
+
flags: [{
|
|
2085
|
+
name: "reason",
|
|
2086
|
+
type: "string",
|
|
2087
|
+
description: "Why it closed. Recorded on the archive commit when the status is `done`."
|
|
2088
|
+
}],
|
|
2089
|
+
responseTypes: ["task.updated"]
|
|
2090
|
+
},
|
|
2091
|
+
{
|
|
2092
|
+
name: "task list",
|
|
2093
|
+
summary: "The task working set: a direct indexed scan with blockers, never ranked retrieval.",
|
|
2094
|
+
args: [],
|
|
2095
|
+
flags: [
|
|
2096
|
+
{
|
|
2097
|
+
name: "status",
|
|
2098
|
+
type: "string",
|
|
2099
|
+
description: "One task status.",
|
|
2100
|
+
values: TASK_STATUSES
|
|
2101
|
+
},
|
|
2102
|
+
{
|
|
2103
|
+
name: "workspace",
|
|
2104
|
+
type: "string",
|
|
2105
|
+
description: "One workspace."
|
|
2106
|
+
},
|
|
2107
|
+
{
|
|
2108
|
+
name: "due-before",
|
|
2109
|
+
type: "string",
|
|
2110
|
+
description: "An ISO date. Returns tasks due strictly before it, by calendar day."
|
|
2111
|
+
},
|
|
2112
|
+
{
|
|
2113
|
+
name: "limit",
|
|
2114
|
+
type: "int",
|
|
2115
|
+
description: "Rows per page.",
|
|
2116
|
+
default: 50
|
|
2117
|
+
},
|
|
2118
|
+
{
|
|
2119
|
+
name: "cursor",
|
|
2120
|
+
type: "string",
|
|
2121
|
+
description: "The `next_cursor` from the previous page: the last path returned."
|
|
2122
|
+
},
|
|
2123
|
+
{
|
|
2124
|
+
name: "include-archived",
|
|
2125
|
+
type: "boolean",
|
|
2126
|
+
description: "Include finished tasks. `done` archives, so they are otherwise absent.",
|
|
2127
|
+
default: false
|
|
2128
|
+
}
|
|
2129
|
+
],
|
|
2130
|
+
responseTypes: ["task.list"]
|
|
2131
|
+
},
|
|
2132
|
+
{
|
|
2133
|
+
name: "index rebuild",
|
|
2134
|
+
summary: "Rebuild index.db from the git tree at HEAD. Destroys nothing outside .memhtml/.",
|
|
2135
|
+
args: [],
|
|
2136
|
+
flags: [{
|
|
2137
|
+
name: "embed",
|
|
2138
|
+
type: "boolean",
|
|
2139
|
+
description: "Fill missing vectors from Bedrock. --no-embed makes the rebuild instant.",
|
|
2140
|
+
default: true
|
|
2141
|
+
}],
|
|
2142
|
+
responseTypes: ["index.report"]
|
|
2143
|
+
},
|
|
2144
|
+
{
|
|
2145
|
+
name: "index update",
|
|
2146
|
+
summary: "Index only what moved since the recorded watermark, plus the dirty working tree.",
|
|
2147
|
+
args: [],
|
|
2148
|
+
flags: [{
|
|
2149
|
+
name: "embed",
|
|
2150
|
+
type: "boolean",
|
|
2151
|
+
description: "Fill missing vectors.",
|
|
2152
|
+
default: true
|
|
2153
|
+
}],
|
|
2154
|
+
responseTypes: ["index.report"]
|
|
2155
|
+
},
|
|
2156
|
+
{
|
|
2157
|
+
name: "index status",
|
|
2158
|
+
summary: "The index watermark, the vector space it was built in, and its row counts.",
|
|
2159
|
+
args: [],
|
|
2160
|
+
flags: [],
|
|
2161
|
+
responseTypes: ["index.report"]
|
|
2162
|
+
},
|
|
2163
|
+
{
|
|
2164
|
+
name: "trace index",
|
|
2165
|
+
summary: "Scan $MEMHTML_TRACE_ROOT for Claude Code transcripts, reading only what changed.",
|
|
2166
|
+
args: [],
|
|
2167
|
+
flags: [],
|
|
2168
|
+
responseTypes: ["trace.report"]
|
|
2169
|
+
},
|
|
2170
|
+
{
|
|
2171
|
+
name: "trace search",
|
|
2172
|
+
summary: "FTS over session first-prompts and AI titles. Never enters memory retrieval.",
|
|
2173
|
+
args: [{
|
|
2174
|
+
name: "query",
|
|
2175
|
+
description: "Prose.",
|
|
2176
|
+
required: true
|
|
2177
|
+
}],
|
|
2178
|
+
flags: [
|
|
2179
|
+
{
|
|
2180
|
+
name: "cwd",
|
|
2181
|
+
type: "string",
|
|
2182
|
+
description: "Restrict to sessions from this directory."
|
|
2183
|
+
},
|
|
2184
|
+
{
|
|
2185
|
+
name: "since",
|
|
2186
|
+
type: "string",
|
|
2187
|
+
description: "ISO-8601 lower bound on started_at."
|
|
2188
|
+
},
|
|
2189
|
+
{
|
|
2190
|
+
name: "limit",
|
|
2191
|
+
type: "int",
|
|
2192
|
+
description: "Sessions to return.",
|
|
2193
|
+
default: 20
|
|
2194
|
+
}
|
|
2195
|
+
],
|
|
2196
|
+
responseTypes: ["trace.sessions"]
|
|
2197
|
+
},
|
|
2198
|
+
{
|
|
2199
|
+
name: "trace links",
|
|
2200
|
+
summary: "The memory-session links, from either side.",
|
|
2201
|
+
args: [],
|
|
2202
|
+
flags: [{
|
|
2203
|
+
name: "session-id",
|
|
2204
|
+
type: "string",
|
|
2205
|
+
description: "Every memory this session touched."
|
|
2206
|
+
}, {
|
|
2207
|
+
name: "path",
|
|
2208
|
+
type: "string",
|
|
2209
|
+
description: "Every session that touched this memory."
|
|
2210
|
+
}],
|
|
2211
|
+
responseTypes: ["trace.links"]
|
|
2212
|
+
},
|
|
2213
|
+
{
|
|
2214
|
+
name: "sleep run",
|
|
2215
|
+
summary: "The nightly curation cycle: 15 phases, each an isolated commit on a review branch.",
|
|
2216
|
+
args: [],
|
|
2217
|
+
flags: [
|
|
2218
|
+
{
|
|
2219
|
+
name: "date",
|
|
2220
|
+
type: "string",
|
|
2221
|
+
description: "The run date, `YYYY-MM-DD`. Defaults to today. Names the branch."
|
|
2222
|
+
},
|
|
2223
|
+
{
|
|
2224
|
+
name: "phases",
|
|
2225
|
+
type: "string",
|
|
2226
|
+
description: `Comma-separated subset. All 15 by default: ${SLEEP_PHASES.join(", ")}.`
|
|
2227
|
+
},
|
|
2228
|
+
{
|
|
2229
|
+
name: "dry-run",
|
|
2230
|
+
type: "boolean",
|
|
2231
|
+
description: "Report per-phase counts and commit nothing.",
|
|
2232
|
+
default: false
|
|
2233
|
+
}
|
|
2234
|
+
],
|
|
2235
|
+
responseTypes: ["sleep.report"]
|
|
2236
|
+
},
|
|
2237
|
+
{
|
|
2238
|
+
name: "sleep resume",
|
|
2239
|
+
summary: "Re-run only the phases with no Memhtml-Phase trailer on the branch.",
|
|
2240
|
+
args: [{
|
|
2241
|
+
name: "run-id",
|
|
2242
|
+
description: "The run id, e.g. sleep/2026-08-02.",
|
|
2243
|
+
required: true
|
|
2244
|
+
}],
|
|
2245
|
+
flags: [],
|
|
2246
|
+
responseTypes: ["sleep.report"]
|
|
2247
|
+
},
|
|
2248
|
+
{
|
|
2249
|
+
name: "sleep review",
|
|
2250
|
+
summary: "Per-phase counts, the commit list, diff --stat, and a per-file classification.",
|
|
2251
|
+
args: [{
|
|
2252
|
+
name: "run-id",
|
|
2253
|
+
description: "The run id.",
|
|
2254
|
+
required: true
|
|
2255
|
+
}],
|
|
2256
|
+
flags: [{
|
|
2257
|
+
name: "diff",
|
|
2258
|
+
type: "boolean",
|
|
2259
|
+
description: "Include the raw diff.",
|
|
2260
|
+
default: false
|
|
2261
|
+
}],
|
|
2262
|
+
responseTypes: ["sleep.review"]
|
|
2263
|
+
},
|
|
2264
|
+
{
|
|
2265
|
+
name: "sleep merge",
|
|
2266
|
+
summary: "Fast-forward main to the run's branch, after the discrimination gate passes.",
|
|
2267
|
+
args: [{
|
|
2268
|
+
name: "run-id",
|
|
2269
|
+
description: "The run id.",
|
|
2270
|
+
required: true
|
|
2271
|
+
}],
|
|
2272
|
+
flags: [{
|
|
2273
|
+
name: "skip-gate",
|
|
2274
|
+
type: "boolean",
|
|
2275
|
+
description: "Merge without re-running discrimination. A deliberate, logged override, never a default.",
|
|
2276
|
+
default: false
|
|
2277
|
+
}],
|
|
2278
|
+
responseTypes: ["sleep.merge"]
|
|
2279
|
+
},
|
|
2280
|
+
{
|
|
2281
|
+
name: "sleep status",
|
|
2282
|
+
summary: "The latest sleep run and its per-phase outcomes.",
|
|
2283
|
+
args: [],
|
|
2284
|
+
flags: [],
|
|
2285
|
+
responseTypes: ["sleep.report"]
|
|
2286
|
+
},
|
|
2287
|
+
{
|
|
2288
|
+
name: "status",
|
|
2289
|
+
summary: "Corpus health: HEAD, dirty state, counts by type, edges, index freshness.",
|
|
2290
|
+
args: [],
|
|
2291
|
+
flags: [],
|
|
2292
|
+
responseTypes: ["status.health"]
|
|
2293
|
+
},
|
|
2294
|
+
{
|
|
2295
|
+
name: "publish",
|
|
2296
|
+
summary: "Regenerate the per-directory index.html listings and sitemap.xml, and commit them.",
|
|
2297
|
+
args: [],
|
|
2298
|
+
flags: [],
|
|
2299
|
+
responseTypes: ["publish.report"]
|
|
2300
|
+
},
|
|
2301
|
+
{
|
|
2302
|
+
name: "doctor",
|
|
2303
|
+
summary: "Corpus health: dangling hrefs, orphan state rows, inbox depth, vocabulary, staleness.",
|
|
2304
|
+
args: [],
|
|
2305
|
+
flags: [{
|
|
2306
|
+
name: "fix",
|
|
2307
|
+
type: "boolean",
|
|
2308
|
+
description: "Repair dangling hrefs and prune orphan access rows. The other findings need a decision.",
|
|
2309
|
+
default: false
|
|
2310
|
+
}],
|
|
2311
|
+
responseTypes: ["doctor.report"]
|
|
2312
|
+
},
|
|
2313
|
+
{
|
|
2314
|
+
name: "eval discriminate",
|
|
2315
|
+
summary: "The refusable retrieval gate: every probe must outrank its own wrong-fact twins.",
|
|
2316
|
+
args: [],
|
|
2317
|
+
flags: [
|
|
2318
|
+
{
|
|
2319
|
+
name: "mode",
|
|
2320
|
+
type: "string",
|
|
2321
|
+
description: "`fake` is the deterministic embedder CI measures; `live` needs AWS_BEARER_TOKEN_BEDROCK and refuses loudly without it.",
|
|
2322
|
+
values: ["fake", "live"],
|
|
2323
|
+
default: "fake"
|
|
2324
|
+
},
|
|
2325
|
+
{
|
|
2326
|
+
name: "seed",
|
|
2327
|
+
type: "int",
|
|
2328
|
+
description: "The fixture corpus seed. A failing run is reproducible from this number."
|
|
2329
|
+
},
|
|
2330
|
+
{
|
|
2331
|
+
name: "size",
|
|
2332
|
+
type: "int",
|
|
2333
|
+
description: "Base memories to generate.",
|
|
2334
|
+
default: 200
|
|
2335
|
+
},
|
|
2336
|
+
{
|
|
2337
|
+
name: "probes",
|
|
2338
|
+
type: "int",
|
|
2339
|
+
description: "Probes to run. Design §5 wants ≥30.",
|
|
2340
|
+
default: 36
|
|
2341
|
+
},
|
|
2342
|
+
{
|
|
2343
|
+
name: "mrr-floor",
|
|
2344
|
+
type: "string",
|
|
2345
|
+
description: "Mean-reciprocal-rank floor. Lowering it is a deliberate, visible choice.",
|
|
2346
|
+
default: "0.85"
|
|
2347
|
+
}
|
|
2348
|
+
],
|
|
2349
|
+
responseTypes: ["eval.discrimination"]
|
|
2350
|
+
},
|
|
2351
|
+
(
|
|
2352
|
+
/**
|
|
2353
|
+
* Code-mode (ROADMAP item 7b): one script, one execution, one envelope.
|
|
2354
|
+
*
|
|
2355
|
+
* The flag surface answers three questions a script cannot answer for itself, and nothing else.
|
|
2356
|
+
*
|
|
2357
|
+
* **How does the script arrive?** Three doors, exactly one per call, enforced in `validate` so a
|
|
2358
|
+
* wrong combination is exit 2. `--file` for a script under version control, `--script` for the
|
|
2359
|
+
* inline one-liner an agent composes, and a bare `memhtml exec` (or `-`) for stdin, which is the same
|
|
2360
|
+
* three-door shape and the same `-` spelling `memhtml apply` already uses for its op stream, so an
|
|
2361
|
+
* agent that learned one learned both. `--script` rather than a positional argument, because the
|
|
2362
|
+
* positional slot on a two-word command is where a run-id or a path goes on every other command
|
|
2363
|
+
* here, and a multi-line program in that slot would read as one.
|
|
2364
|
+
*
|
|
2365
|
+
* **How long may it run?** `--timeout-ms`, bounded and defaulted, because the guest is a QuickJS
|
|
2366
|
+
* worker with no reaper of its own and an unbounded script holds the CLI process open. The
|
|
2367
|
+
* millisecond unit is in the flag name rather than left to a note, since `--timeout 30` is
|
|
2368
|
+
* ambiguous by a factor of a thousand.
|
|
2369
|
+
*
|
|
2370
|
+
* **Which tree does it see?** `--sha`, defaulting to `HEAD`. Never the live working tree, which is
|
|
2371
|
+
* a containment decision rather than a convenience. A mounted `$MEMHTML_ROOT` exposes `.memhtml/index.db`
|
|
2372
|
+
* to the guest, whose `sqlite3` reads it happily (probed 2026-08-09: a read-only mount is no barrier
|
|
2373
|
+
* to a reader). A gitignored file is absent from a detached worktree, so pinning a commit is what
|
|
2374
|
+
* keeps the ranked planes out of reach, and the read-only mount is the second layer rather than
|
|
2375
|
+
* the only one. A pin also makes the answer reproducible. `sha` rides back in the envelope, so a
|
|
2376
|
+
* rerun is exact.
|
|
2377
|
+
*
|
|
2378
|
+
* There is deliberately no flag for the guest's own opt-ins. `javascript` is on because `js-exec`
|
|
2379
|
+
* is the feature. `python` and `network` are off and unofferable, so no invocation can turn either
|
|
2380
|
+
* on. `apps/cli/src/exec.ts` carries the mechanism and the egress probe.
|
|
2381
|
+
*/
|
|
2382
|
+
{
|
|
2383
|
+
name: "exec",
|
|
2384
|
+
summary: "Run a read-only traversal script over the corpus in a sandbox: multi-hop in ONE execution.",
|
|
2385
|
+
args: [],
|
|
2386
|
+
flags: [
|
|
2387
|
+
{
|
|
2388
|
+
name: "file",
|
|
2389
|
+
type: "string",
|
|
2390
|
+
description: "The script to run, as a path on the HOST. Omit it (or pass `-`) to read the script from stdin. Mutually exclusive with `--script`."
|
|
2391
|
+
},
|
|
2392
|
+
{
|
|
2393
|
+
name: "script",
|
|
2394
|
+
type: "string",
|
|
2395
|
+
description: "The script source, inline. Mutually exclusive with `--file` and with reading stdin."
|
|
2396
|
+
},
|
|
2397
|
+
{
|
|
2398
|
+
name: "timeout-ms",
|
|
2399
|
+
type: "int",
|
|
2400
|
+
description: "Wall-clock bound on the script. Exceeding it is `exitCode` 124 with `timedOut: true`, not an error envelope. Capped at 600000.",
|
|
2401
|
+
default: 3e4
|
|
2402
|
+
},
|
|
2403
|
+
{
|
|
2404
|
+
name: "sha",
|
|
2405
|
+
type: "string",
|
|
2406
|
+
description: "The commit to mount, materialized as a detached worktree. Defaults to HEAD. Never the live working tree, whose gitignored .memhtml/index.db a worktree omits."
|
|
2407
|
+
}
|
|
2408
|
+
],
|
|
2409
|
+
responseTypes: ["exec.report"]
|
|
2410
|
+
}),
|
|
2411
|
+
{
|
|
2412
|
+
name: "state export",
|
|
2413
|
+
summary: "Write .memhtml/state/access.jsonl, the only durable copy of the state plane, and commit.",
|
|
2414
|
+
args: [],
|
|
2415
|
+
flags: [],
|
|
2416
|
+
responseTypes: ["state.export"]
|
|
2417
|
+
},
|
|
2418
|
+
{
|
|
2419
|
+
name: "state import",
|
|
2420
|
+
summary: "Replay the committed sidecar into state.db. Counters merge by max, never last-wins.",
|
|
2421
|
+
args: [],
|
|
2422
|
+
flags: [],
|
|
2423
|
+
responseTypes: ["state.import"]
|
|
2424
|
+
},
|
|
2425
|
+
{
|
|
2426
|
+
name: "agents-doc",
|
|
2427
|
+
summary: "Regenerate AGENTS.md from this command table. --check fails on drift.",
|
|
2428
|
+
args: [],
|
|
2429
|
+
flags: [{
|
|
2430
|
+
name: "check",
|
|
2431
|
+
type: "boolean",
|
|
2432
|
+
description: "Compare the committed doc to the regenerated one and fail on a difference.",
|
|
2433
|
+
default: false
|
|
2434
|
+
}, {
|
|
2435
|
+
name: "out",
|
|
2436
|
+
type: "string",
|
|
2437
|
+
description: "Where to write. Defaults to ./AGENTS.md."
|
|
2438
|
+
}],
|
|
2439
|
+
responseTypes: ["agents.doc"]
|
|
2440
|
+
},
|
|
2441
|
+
{
|
|
2442
|
+
name: "serve mcp",
|
|
2443
|
+
summary: "Run the `memhtml-mcp` stdio server: 14 tools and 2 resources over this same repo.",
|
|
2444
|
+
args: [],
|
|
2445
|
+
flags: [],
|
|
2446
|
+
responseTypes: ["serve.exit"]
|
|
2447
|
+
}
|
|
2448
|
+
];
|
|
2449
|
+
const COMMAND_NAMES = COMMANDS.map((command) => command.name);
|
|
2450
|
+
/**
|
|
2451
|
+
* The example op line, quoted verbatim into the `when-to-batch` block.
|
|
2452
|
+
*
|
|
2453
|
+
* A constant rather than a literal inside the prose, because a test parses it. An example an agent
|
|
2454
|
+
* copies has to be valid JSONL, and it stays valid because the doc and the parser read the
|
|
2455
|
+
* same bytes. A prose-only example drifts silently the first time a field is renamed.
|
|
2456
|
+
*/
|
|
2457
|
+
const GUIDE_OP_EXAMPLE = "{\"op\":\"write\",\"title\":\"One writer and many readers share the index\",\"type\":\"semantic\",\"body\":\"WAL admits a single writer at a time and any number of concurrent readers, so a CLI command and a running `memhtml serve mcp` can work against one store.\",\"tag\":\"infra\"}";
|
|
2458
|
+
/**
|
|
2459
|
+
* The guide: what an agent reads on its first call, before it has written anything.
|
|
2460
|
+
*
|
|
2461
|
+
* Prose, in a structured field, authored here beside `COMMANDS`, which is the design (spec
|
|
2462
|
+
* D8/G6). The manifest carries it on a bare `memhtml`, `memhtml help`, `memhtml --help`, and `memhtml manifest`, and
|
|
2463
|
+
* `memhtml agents-doc` renders these same strings into `AGENTS.md`, so the doc and the live answer cannot
|
|
2464
|
+
* disagree. Prose kept in a separate Markdown file would be a second copy that drifts, and prose kept
|
|
2465
|
+
* only in `AGENTS.md` would be invisible to an agent that never opens the repo.
|
|
2466
|
+
*
|
|
2467
|
+
* Written for an LLM agent mid-task rather than for an operator browsing: complete sentences, action
|
|
2468
|
+
* first, and every claim true of this build rather than of the design. A guide that describes an
|
|
2469
|
+
* intention is worse than no guide, because an agent acts on it.
|
|
2470
|
+
*/
|
|
2471
|
+
const GUIDE = [
|
|
2472
|
+
{
|
|
2473
|
+
topic: "first-call",
|
|
2474
|
+
body: "You are reading this CLI's manifest: every command, argument, flag, response type, error code, and environment variable the binary accepts. A bare `memhtml`, `memhtml help`, `memhtml --help`, and `memhtml manifest` all return it, and all four answer on a machine with no repo, no database, and no credentials, so this is also the liveness check when something else has failed. Every command writes exactly ONE JSON envelope to stdout and nothing else; logs go to stderr. A success is `{apiVersion, type, data}` and a failure is `{apiVersion, error, code, suggestions}`. Branch on `code`, never on the `error` prose: the codes and response types are append-only and a shipped one never changes meaning, while the prose changes freely as wording improves. Exit 0 is success, exit 2 is a usage error you fix by changing the call, exit 1 is a runtime failure you fix by changing the repo or the environment. Add `--dense` to any command to get minified JSON with null fields dropped, which is what you want when the output goes into a prompt."
|
|
2475
|
+
},
|
|
2476
|
+
{
|
|
2477
|
+
topic: "write-surfaces",
|
|
2478
|
+
body: "There are three ways to put a memory into the corpus, and they are all legitimate. First, this CLI: `memhtml write` for one memory, `memhtml apply` for many. Second, the MCP server: `memhtml serve mcp` speaks stdio with 14 tools and 2 resources over this same repo, and it is the door to use when you are already an MCP client. Third, editing files under $MEMHTML_ROOT directly with your normal file tools: the git tree IS the system of record and `.memhtml/index.db` is only a projection of it, so a hand-written or hand-edited memory file is as real as one this CLI wrote. `memhtml index update` projects uncommitted working-tree changes as well as committed ones, so a dirty edit is searchable before you commit it. What you take on by editing directly is everything the write path would have done for you: the file must satisfy the format (run `memhtml doctor`, and `memhtml read <path>` reports per-file format warnings), you own choosing a path that does not collide, you own noticing that the content already exists somewhere else, and you own the commit. The nightly `memhtml sleep run` refuses to start on a dirty tree, so an uncommitted edit blocks curation until it is committed or stashed. A CLI command and a running `memhtml serve mcp` may share one store: the index is WAL SQLite, which admits one writer at a time and any number of concurrent readers, so a second writer waits its turn rather than failing. The one thing to keep clear of is `memhtml sleep run`, and for a git reason rather than a database one: a run holds a checked-out `sleep/<date>` branch, so a write landing during it commits onto that branch and is merged as if it were curation or lost when the branch is dropped."
|
|
2479
|
+
},
|
|
2480
|
+
{
|
|
2481
|
+
topic: "when-to-batch",
|
|
2482
|
+
body: `Writing more than about three memories in one task? Call \`memhtml apply\` once with a JSONL op stream instead of running \`memhtml write\` N times. A batch stages every file, makes ONE commit, and reindexes ONCE, where N separate writes make N commits and pay N index passes over N diffs. Pass the stream as \`memhtml apply --file ops.jsonl\`, or pipe it: \`memhtml apply -\` and a bare \`memhtml apply\` both read stdin. One complete JSON object per line, no wrapping array, no pretty-printing. A line looks like this:
|
|
2483
|
+
${GUIDE_OP_EXAMPLE}\n\`op\` is \`write\` (the only verb in the vocabulary today), \`title\` and \`type\` are required, and each op carries the same optional fields \`memhtml write\` takes, in snake_case: \`path\`, \`workspace\`, \`tag\`, \`entity\`, \`importance\`, \`confidence\`, \`session_id\`, \`prompt_id\`, \`turn_uuid\`. The whole file is validated for shape before ANY op executes, so a malformed line 7 is exit 2 naming line 7 with nothing written. A failed apply costs you nothing but the call. You get one result per op in INPUT ORDER, each naming its own \`index\`, so you can match results back to the lines you sent. A batch is ATOMIC by default: the first refused op aborts the whole batch, no file is written, no commit is made, and the surviving ops report \`skipped: true\`. Pass \`--continue-on-error\` for best-effort instead, and a refused op comes back as one failed result carrying its own \`code\` and \`error\` while every op that succeeded lands in the one commit. A duplicate is never an error: an op whose exact content is already stored comes back \`ok: true\` with \`deduped: true\` and the existing path, so re-applying a file you already applied is safe and writes nothing. \`commit_sha\` is null exactly when nothing was committed: a batch that only deduped, or one that aborted.`
|
|
2484
|
+
},
|
|
2485
|
+
{
|
|
2486
|
+
topic: "conflicts",
|
|
2487
|
+
body: "Pass `--detect-conflicts` to `memhtml apply` and each result gains a `conflict` field naming what that op's claim contradicts. Dedupe catches an op whose content is IDENTICAL to something stored; this catches an op that says something DIFFERENT about the same thing, the case dedupe is blind to and the one that actually rots a corpus. The match is grammatical, not semantic: a claim is split into a frame (the subject and relation, up to its last `of`/`is`/`in`/`to`/`by`/`as`) and a value, and two claims conflict when they share a frame. `The pool ceiling is 64` and `The pool ceiling is 128` share `the pool ceiling is`. `conflict.path` names an ACTIVE memory already holding that slot; `conflict.batch_index` names an EARLIER op in this same call, which is the case nothing else can see because neither op is stored yet; `conflict.claim` is the other claim's own text, so you can decide without a second read. It is null when nothing matched, and also when the claim has no frame shape. The rule refuses frames under three tokens and values over six, so short claims and claims trailed by a clause are deliberately unmatched rather than loosely matched. On a line using `article_html` instead of `body` it is always null, because the claim lives inside your markup and is not read until the store renders it. THE ASSIST NEVER CHANGES WHAT IS WRITTEN. An op carrying a conflict is written exactly as it would have been without the flag: nothing is archived, nothing is refused, and later does not win. That is deliberate, because sometimes the contradiction IS the answer. A memory recording that a runbook step changed necessarily contradicts the memory stating the old step, and a system that resolved that for you would delete the pair a reader needs in order to see the change at all. You decide per conflict: keep both (they are about different things, or both are true), `memhtml correct <path>` instead (the new claim supersedes the old one, and the old one stays readable under archive/), or drop the line (you were about to restate something already stored). Archived memories never match, so a superseded claim stops contradicting the claim that superseded it.\nWhen you have already decided that later wins (a re-scrape, a settings sync, any stream where each line is the newest statement of its slot), pass `--consolidate last-wins` (the batch tool's `consolidate: \"last-wins\"`) and the batch RESOLVES those matches instead of reporting them. Ops sharing a frame key write ONE file carrying the LATER value at the FIRST index that claimed the slot; each later restatement reports `consolidated_into` naming that slot and the summary counts it under `consolidated`, neither written nor failed. A stored ACTIVE memory occupying a surviving slot is archived with a supersedes link from the new file, its archive path reported as `superseded_path`, the same chain `memhtml correct` leaves, so ancestry reads identically. OFF by default, and the key is the conflict rule's own: the frame split is a rule measured in the eval harness before it was believed and ported verbatim into `@memhtml/domain`'s frame.ts, which detection and consolidation share, so anything the rule refuses to key (short frames, clause values) is never consolidated, and what you saw reported with `--detect-conflicts` is exactly what this flag would have acted on.\nEvery supersede, `memhtml correct` and `--consolidate last-wins` alike, also stamps a VALIDITY WINDOW, in the same one commit. The superseded memory gains `memhtml-valid-until` set to the moment the new fact became true (the winner's own `memhtml-valid-from`, else its first `<time datetime>`, else the operation's instant), and the winner gains `memhtml-valid-from` at that same moment, so one window closes exactly where the next opens. Min-wins: a memory already stating an EARLIER `memhtml-valid-until` keeps it, because a fact cannot outlive its earliest stated bound. That is what `--as-of` on `memhtml search` reads: pass an ISO instant and the result is what was believed valid AT THAT MOMENT. Since-superseded memories return, each marked `superseded_by` naming what replaced it, and facts not yet valid then are absent. History is read from the files, not replayed from git, so it survives a full index rebuild."
|
|
2488
|
+
},
|
|
2489
|
+
{
|
|
2490
|
+
topic: "authoring",
|
|
2491
|
+
body: "Every write authors the article in exactly one of two ways, and supplying both or neither is refused. Either you write prose and the template owns the markup (`--claim` is the one load-bearing sentence and becomes the `<mark>` claim span and `files.gist`, and each `--body` is one paragraph after it) or you supply `--article-html` and own the markup yourself. On a `memhtml apply` line the prose form is the `body` field, whose first sentence becomes the claim, and the markup form is `article_html`. When you supply markup you own two constraints. It must contain EXACTLY ONE `<mark>`, and that `<mark>` must sit in the article's first `<p>` or `<li>` and not inside an `<aside>` or `<details>`. The claim leads the article and is never a caveat or behind a fold. And the first `<time datetime>` in your markup becomes the memory's event time, which is what recency ranks on, so a memory about something that happened last year should say so rather than being ranked as today's news. Markup is checked before anything is written: the store renders your article, runs the format check, and refuses with the list of violations before it creates a file, stages it, or commits. A refused write leaves the tree byte-identical, so a failed attempt costs nothing and you can fix the markup and retry. Code goes in the prose path as a fenced block: a body paragraph that is entirely a ``` fence becomes <figure><pre><code>, whitespace preserved verbatim, and the fence's info string (```ts) is stamped as data-lang and promoted to a `lang:ts` entity, so `memhtml list --entity lang:ts` finds every memory carrying TypeScript. A blank line inside a fence does NOT split paragraphs. On the markup path write the same <figure><pre><code data-lang=\"ts\"> yourself; never `class` (forbidden) and never `lang=` (that attribute names human languages)."
|
|
2492
|
+
},
|
|
2493
|
+
{
|
|
2494
|
+
topic: "code-mode",
|
|
2495
|
+
body: "Answering a question that takes MORE THAN ONE HOP through the corpus? Write it as a script and run `memhtml exec` once, instead of spending a tool call per hop. Supersedence ancestry, live contradiction pairs, orphan census, entity co-occurrence, 'which of these 40 paths has no backlink': each of those is one traversal in code and N round trips through `memhtml read` and `memhtml neighbors`. Measured on a 305-file corpus: a full census in 598ms, and 410 edges resolved into 201 chains, longest 8 hops, in one execution at 430ms. The script runs under QuickJS in a sandbox with the corpus mounted READ-ONLY at `/mnt/memhtml`, and a helper is already seeded for you at `/workspace/lib/corpus.mjs`. Import it: `import { corpus, backlinks, chain, edges } from \"/workspace/lib/corpus.mjs\"`. `corpus()` returns a Map keyed by root-absolute path (the SAME string an edge's href holds, so `memories.get(link.href)` resolves with no path juggling) and each value carries `claim`, `memoryType`, `status`, `tags`, `entities`, `links`, `facets`, `citations`, `eventAt`, and a `document` escape hatch for any selector the fields do not cover. Print your answer as JSON on stdout with `console.log`; it comes back verbatim in `data.stdout`, so keep it small and structured rather than dumping the corpus. THREE THINGS IT CANNOT DO, by design. It cannot write: the corpus is read-only and a write answers EROFS, so every write still goes through `memhtml write` / `memhtml apply`, which own commits, dedup, and conflict detection. It cannot rank: no cosine, no RRF, no salience, and no index database. For ranked retrieval shell out to `memhtml search --json` and parse its envelope, which the one-envelope-per-command contract already makes a code-mode API. And it cannot reach the network: there is no curl and the guest's `fetch` refuses on call. The intended opening move is ranked retrieval FIRST, code-mode second: `memhtml search` or `memhtml recall` to get the handful of paths the ranking stack says matter, then `memhtml exec` to walk, join, count, and filter from there. Starting in code-mode means starting with a full-corpus scan and no relevance signal. A non-zero `exitCode` in the response is YOUR script failing, not the command failing. Read `data.stderr` for the diagnostic and the exit code is still 0. A script that runs past `--timeout-ms` (default 30000) comes back `exitCode: 124` with `timedOut: true`. The tree you get is a pinned commit, HEAD by default, named in `data.sha`, so an answer is reproducible with `--sha`, and an uncommitted edit is NOT visible to the script."
|
|
2496
|
+
}
|
|
2497
|
+
];
|
|
2498
|
+
const GUIDE_TOPICS = GUIDE.map((block) => block.topic);
|
|
2499
|
+
|
|
2500
|
+
//#endregion
|
|
2501
|
+
//#region apps/cli/dist/prose.js
|
|
2502
|
+
/**
|
|
2503
|
+
* Prose → claim derivation: the single implementation both write doors use.
|
|
2504
|
+
*
|
|
2505
|
+
* The tools take `{title, body}` because that is what a model produces, and the format needs a
|
|
2506
|
+
* `<mark>` claim plus one `<p>` per paragraph. Turning the first into the second is a text heuristic,
|
|
2507
|
+
* and it lives here for two reasons. It was duplicated once, as `claimOf`/`restOf` in `apps/mcp` and
|
|
2508
|
+
* `claimFromProse`/`proseTail` in `apps/cli`, the same regex in two packages. A sentence-splitting
|
|
2509
|
+
* rule that drifts between the doors also makes `memhtml apply` and `memory_write_batch` derive different
|
|
2510
|
+
* claims from the same body, so the gist of a memory would depend on which door wrote it.
|
|
2511
|
+
*
|
|
2512
|
+
* It does not live in `@memhtml/html`, which owns markup and the format's own rules. "Where does a
|
|
2513
|
+
* sentence end" is a guess about natural-language prose, and the format states no such constraint. It
|
|
2514
|
+
* is not in `operations.ts` either, because that module holds the use cases both doors call, and this
|
|
2515
|
+
* is a text helper they apply before calling one.
|
|
2516
|
+
*
|
|
2517
|
+
* The derivation is defense in depth now; it was once the only guard. `@memhtml/html` constraint 1 now
|
|
2518
|
+
* rejects an empty `<mark>` outright, so a door that skipped this would be stopped by the store's
|
|
2519
|
+
* render gate instead of landing a file with an empty `files.gist`. What is left here is the
|
|
2520
|
+
* authoring convenience the doors exist to provide: a JSONL line and an MCP call carry no `claim`
|
|
2521
|
+
* field, so the door derives one instead of asking an author to restate the body's first sentence.
|
|
2522
|
+
*/
|
|
2523
|
+
/**
|
|
2524
|
+
* Split prose into paragraphs on blank lines, dropping the empties. Inside a fenced code block a
|
|
2525
|
+
* blank line is content, so the split skips it there. Without that carve-out, a snippet containing a
|
|
2526
|
+
* blank line splits into two paragraphs, neither of which is a complete fence, and both land as
|
|
2527
|
+
* escaped backtick text instead of the `<figure><pre><code>` an intact fence renders as.
|
|
2528
|
+
*
|
|
2529
|
+
* The fence grammar comes from `@memhtml/html` (`fenceOpeningOf`/`closesFence`) rather than a second
|
|
2530
|
+
* copy here. The splitter deciding "this is one block" and the template deciding "this is a fence"
|
|
2531
|
+
* must be the same judgment, or the doors drift the way the claim derivation once did.
|
|
2532
|
+
*/
|
|
2533
|
+
const paragraphsOf = (prose) => {
|
|
2534
|
+
const parts = [[]];
|
|
2535
|
+
let opening;
|
|
2536
|
+
for (const line of prose.split("\n")) {
|
|
2537
|
+
const current = parts.at(-1);
|
|
2538
|
+
if (opening === void 0 && line.trim() === "") {
|
|
2539
|
+
if (current.length > 0) parts.push([]);
|
|
2540
|
+
continue;
|
|
2541
|
+
}
|
|
2542
|
+
current.push(line);
|
|
2543
|
+
if (opening === void 0) opening = fenceOpeningOf(line);
|
|
2544
|
+
else if (closesFence(line, opening)) opening = void 0;
|
|
2545
|
+
}
|
|
2546
|
+
return parts.map((lines) => lines.join("\n").trim()).filter((part) => part !== "");
|
|
2547
|
+
};
|
|
2548
|
+
/**
|
|
2549
|
+
* The claim: the first sentence of the prose.
|
|
2550
|
+
*
|
|
2551
|
+
* The first sentence is where a model puts the assertion. Taking the title instead would make every
|
|
2552
|
+
* gist a restatement of the filename, which is the one thing a Tier-1 disclosure line must not be.
|
|
2553
|
+
* Prose with no sentence terminator is its own claim in full. A fragment is still an assertion, and
|
|
2554
|
+
* rejecting it would reject the shortest legitimate memory there is.
|
|
2555
|
+
*/
|
|
2556
|
+
const claimFromProse = (prose) => {
|
|
2557
|
+
const trimmed = prose.trim();
|
|
2558
|
+
return (/^(.*?[.!?])(\s|$)/s.exec(trimmed)?.[1] ?? trimmed).trim();
|
|
2559
|
+
};
|
|
2560
|
+
/**
|
|
2561
|
+
* The prose after the claim, as paragraphs. Empty when the claim was the whole body.
|
|
2562
|
+
*
|
|
2563
|
+
* The first element becomes the claim paragraph's own tail rather than a second `<p>`, which is
|
|
2564
|
+
* `articleHtmlFor`'s contract in `@memhtml/html`'s template. A one-paragraph body therefore yields
|
|
2565
|
+
* exactly one `<p>` with the `<mark>` inside it, which is what constraint 1 requires.
|
|
2566
|
+
*/
|
|
2567
|
+
const proseTail = (prose) => {
|
|
2568
|
+
const remainder = prose.trim().slice(claimFromProse(prose).length).trim();
|
|
2569
|
+
return remainder === "" ? [] : paragraphsOf(remainder);
|
|
2570
|
+
};
|
|
2571
|
+
|
|
2572
|
+
//#endregion
|
|
2573
|
+
//#region apps/cli/dist/apply.js
|
|
2574
|
+
/**
|
|
2575
|
+
* Every field a line may carry, mapped to the `WriteParams` field it becomes.
|
|
2576
|
+
*
|
|
2577
|
+
* A table rather than a hand-written decode, so the snake_case → camelCase rename is stated once and
|
|
2578
|
+
* the unknown-field check below is derived from it. The MCP tool's parameters use exactly these
|
|
2579
|
+
* snake_case names (`apps/mcp/src/tools.ts`), so an agent that learned the field names from one door
|
|
2580
|
+
* can write a JSONL file for the other without translating.
|
|
2581
|
+
*/
|
|
2582
|
+
const SCALAR_FIELDS = {
|
|
2583
|
+
title: "title",
|
|
2584
|
+
type: "memoryType",
|
|
2585
|
+
body: "body",
|
|
2586
|
+
article_html: "articleHtml",
|
|
2587
|
+
path: "path",
|
|
2588
|
+
workspace: "workspace",
|
|
2589
|
+
importance: "importance",
|
|
2590
|
+
confidence: "confidence",
|
|
2591
|
+
session_id: "sessionId",
|
|
2592
|
+
prompt_id: "promptId",
|
|
2593
|
+
turn_uuid: "turnUuid",
|
|
2594
|
+
status: "taskStatus",
|
|
2595
|
+
due: "dueAt"
|
|
2596
|
+
};
|
|
2597
|
+
/** Fields that accept a string or an array of strings, and always become an array. */
|
|
2598
|
+
const LIST_FIELDS = {
|
|
2599
|
+
tag: "tags",
|
|
2600
|
+
tags: "tags",
|
|
2601
|
+
entity: "entities",
|
|
2602
|
+
entities: "entities"
|
|
2603
|
+
};
|
|
2604
|
+
/** `op` is the discriminator rather than a `WriteParams` field, so it is legal and never mapped. */
|
|
2605
|
+
const KNOWN_FIELDS = /* @__PURE__ */ new Set([
|
|
2606
|
+
"op",
|
|
2607
|
+
...Object.keys(SCALAR_FIELDS),
|
|
2608
|
+
...Object.keys(LIST_FIELDS)
|
|
2609
|
+
]);
|
|
2610
|
+
|
|
2611
|
+
//#endregion
|
|
2612
|
+
//#region apps/cli/dist/doctor.js
|
|
2613
|
+
/** The year a run's repairs partition archive lookups under: the current calendar year. */
|
|
2614
|
+
const currentYear = Effect.clockWith((clock) => Effect.map(clock.currentTimeMillis, (millis) => new Date(millis).getUTCFullYear()));
|
|
2615
|
+
/** Today as `YYYY-MM-DD`, through the Effect clock so a test can pin what "overdue" means. */
|
|
2616
|
+
const todayDate = Effect.clockWith((clock) => Effect.map(clock.currentTimeMillis, (millis) => new Date(millis).toISOString().slice(0, 10)));
|
|
2617
|
+
/** An ISO-8601 UTC second, for the `memhtml-updated` stamp a repair writes. */
|
|
2618
|
+
const nowSecond = Effect.clockWith((clock) => Effect.map(clock.currentTimeMillis, (millis) => `${new Date(millis).toISOString().slice(0, 19)}Z`));
|
|
2619
|
+
|
|
2620
|
+
//#endregion
|
|
2621
|
+
//#region apps/cli/dist/run.js
|
|
2622
|
+
const KNOWN_FLAGS = /* @__PURE__ */ new Set([...GLOBAL_FLAGS.map((flag) => flag.name), ...COMMANDS.flatMap((command) => command.flags.map((flag) => flag.name))]);
|
|
2623
|
+
/**
|
|
2624
|
+
* The two-word command names, longest first.
|
|
2625
|
+
*
|
|
2626
|
+
* A subcommand is matched greedily so `index status` beats `index`, and the leftover tokens become
|
|
2627
|
+
* positionals. Matching the shorter name first would make `memhtml index status` a call to a
|
|
2628
|
+
* hypothetical `index` command with `status` as an argument, which is a wrong answer rather than an
|
|
2629
|
+
* error.
|
|
2630
|
+
*/
|
|
2631
|
+
const COMPOUND_NAMES = COMMAND_NAMES.filter((name) => name.includes(" ")).sort((left, right) => right.length - left.length);
|
|
2632
|
+
/** Today as `YYYY-MM-DD`, through the Effect clock so a test can pin the run date. */
|
|
2633
|
+
const today = Effect.clockWith((clock) => Effect.map(clock.currentTimeMillis, (millis) => new Date(millis).toISOString().slice(0, 10)));
|
|
2634
|
+
|
|
2635
|
+
//#endregion
|
|
2636
|
+
//#region apps/mcp/src/failure.ts
|
|
2637
|
+
/**
|
|
2638
|
+
* The MCP wire failure: one error class, declared on every tool, whose `.message` IS the response an
|
|
2639
|
+
* agent reads.
|
|
2640
|
+
*
|
|
2641
|
+
* **Why a declared class at all.** `McpServer` has three catch branches for a failed `tools/call`
|
|
2642
|
+
* (`McpServer.ts:831-847`, effect 4.0.0-beta.102) and only ONE of them lets prose through. An
|
|
2643
|
+
* `AiError`, which is what this module replaced, takes branch 1 and is rewritten to "Tool execution
|
|
2644
|
+
* failed due to an internal server error" unless its reason is a parameter-validation error. A value
|
|
2645
|
+
* the tool's own `failureSchema` accepts takes branch 2, where `error instanceof Error ? error.message`
|
|
2646
|
+
* passes the text through verbatim. The schema declaration is therefore the whole
|
|
2647
|
+
* difference between an agent that can recover and an agent that reads a sentence with no content in
|
|
2648
|
+
* it. `Effect.tapCause(Effect.logError)` runs before all three branches, so stderr logging is
|
|
2649
|
+
* unaffected either way.
|
|
2650
|
+
*
|
|
2651
|
+
* **Why the message is composed at construction.** `McpServer` reads `.message` and nothing else.
|
|
2652
|
+
* `code` and `suggestions` are not on the wire as fields, because MCP's tool-error channel is one
|
|
2653
|
+
* text block. So the three parts are folded into the string HERE, once, and the structured fields stay
|
|
2654
|
+
* for tests and for any future surface that can carry them. A consumer that wanted the code back out
|
|
2655
|
+
* reads the prefix, which is why the code comes first and is followed by a colon: `ERR_*` is a stable
|
|
2656
|
+
* vocabulary and the prose after it is not.
|
|
2657
|
+
*
|
|
2658
|
+
* **Why `Schema.TaggedError` rather than a hand-written `Error` subclass.** `Schema.is(failureSchema)`
|
|
2659
|
+
* is the branch-2 predicate, so the value has to be something a schema accepts, and it has to be an
|
|
2660
|
+
* `Error` for `.message` to be read. `Schema.TaggedError` is the one construction that is both: an
|
|
2661
|
+
* instance is `instanceof Error`, `Schema.is` accepts it, and `Schema.is` REJECTS a plain `Error`,
|
|
2662
|
+
* which is what keeps a genuine defect on branch 3 where it belongs. All three of those are asserted
|
|
2663
|
+
* in `tests/failure.test.ts`, so the construction cannot be swapped for one that loses any of them.
|
|
2664
|
+
*/
|
|
2665
|
+
var ToolFailure = class extends Schema.TaggedError()("ToolFailure", {
|
|
2666
|
+
/** The stable code, from the same `ERROR_CODES` vocabulary the CLI envelope publishes. */
|
|
2667
|
+
code: Schema.String,
|
|
2668
|
+
/** The composed wire text: code, reason, then suggestions. This is what the agent reads. */
|
|
2669
|
+
message: Schema.String,
|
|
2670
|
+
/** The suggestions, kept structured so a test can assert them without parsing prose. */
|
|
2671
|
+
suggestions: Schema.Array(Schema.String)
|
|
2672
|
+
}) {};
|
|
2673
|
+
const isTagged = (value) => typeof value === "object" && value !== null && typeof value._tag === "string";
|
|
2674
|
+
const text = (value) => typeof value === "string" ? value : void 0;
|
|
2675
|
+
/**
|
|
2676
|
+
* What to do about a failure, phrased as calls this agent can actually make.
|
|
2677
|
+
*
|
|
2678
|
+
* The reader is an LLM mid-task holding fourteen tools and no shell. `suggestionsFor` in
|
|
2679
|
+
* `apps/cli/src/errors.ts:115-137` answers the same question for a human at a prompt and answers it in
|
|
2680
|
+
* `memhtml` commands and `git` invocations, every one of which is unreachable from here. A suggestion
|
|
2681
|
+
* an agent cannot execute costs more than none: it spends the model's attention on a plan that ends in
|
|
2682
|
+
* "I don't have a terminal", and the recovery that WAS available goes unmentioned. So this is a
|
|
2683
|
+
* deliberate parallel mapping rather than a reuse, and the rule it holds to is that every string names
|
|
2684
|
+
* a tool in the toolkit or an action inside the current call's own control.
|
|
2685
|
+
*
|
|
2686
|
+
* The path payloads are interpolated rather than left as `<path>` placeholders: the agent has to type
|
|
2687
|
+
* the argument, and a code that already knows the path and does not say it forces a `memory_list` the
|
|
2688
|
+
* response could have skipped.
|
|
2689
|
+
*
|
|
2690
|
+
* The FIRST suggestion is always the action, and any state the agent needs in order to trust that
|
|
2691
|
+
* action comes second. `toToolFailure` joins the list behind "Try: ", so a list that opened with
|
|
2692
|
+
* "nothing was written" would put a fact where the reader is looking for a verb.
|
|
2693
|
+
*
|
|
2694
|
+
* `DirtyTree`, `GitFailure` and `StorageFailure` share the same answer, and it states the ceiling:
|
|
2695
|
+
* an agent cannot commit, stash, or repair a database from a tool call. `memory_status` is
|
|
2696
|
+
* the one read that distinguishes "the repo is wedged" from "that one write raced", and escalation is
|
|
2697
|
+
* the correct terminal move rather than a retry loop.
|
|
2698
|
+
*/
|
|
2699
|
+
const mcpSuggestionsFor = (error) => {
|
|
2700
|
+
if (!isTagged(error)) return [];
|
|
2701
|
+
switch (error._tag) {
|
|
2702
|
+
case "PathNotFound": return ["call memory_search with a query for what you were looking for", "call memory_list to page the corpus by type or workspace"];
|
|
2703
|
+
case "WriteConflict": return [`call memory_read on ${text(error.path) ?? "that path"} to get the current content`, "re-apply your change to that content and retry the write"];
|
|
2704
|
+
case "DuplicateContent": return [`call memory_read on ${text(error.existingPath) ?? "that path"} — your content already lives there`, "nothing was written and no commit was made, so there is nothing to clean up"];
|
|
2705
|
+
case "InvalidMemory": return ["fix the violated constraint named above and call the same tool again", "nothing was written and no commit was made — the store refused at the render gate"];
|
|
2706
|
+
case "ModelUnavailable": return ["retry — search degrades to the lexical floor without the embedder, so results are narrower but real", "call memory_status to see whether the embedder is up"];
|
|
2707
|
+
case "EmbedModelMismatch": return ["keep working — memory_search still runs on the lexical, recency, and salience arms", "the vector arm stays unusable until an operator rebuilds the index"];
|
|
2708
|
+
case "DirtyTree":
|
|
2709
|
+
case "GitFailure":
|
|
2710
|
+
case "StorageFailure": return ["call memory_status to see repo health: HEAD, dirty state, and index freshness", "report this to the operator if it persists — an agent cannot repair the repo from a tool call"];
|
|
2711
|
+
case "DiscriminationFailed": return ["call memory_status to see when sleep last ran", "report this to the operator"];
|
|
2712
|
+
default: return [];
|
|
2713
|
+
}
|
|
2714
|
+
};
|
|
2715
|
+
/**
|
|
2716
|
+
* A reason ending in a sentence terminator, so the suggestions read as a second sentence.
|
|
2717
|
+
*
|
|
2718
|
+
* `messageFor` returns fragments without final punctuation because the CLI envelope carries the reason
|
|
2719
|
+
* in its own JSON field and the suggestions in another, so there is nothing to run together. Here the
|
|
2720
|
+
* three parts share one string, and "no memory at areas/x.html Try: call memory_search" is a sentence
|
|
2721
|
+
* an LLM has to re-parse.
|
|
2722
|
+
*/
|
|
2723
|
+
const sentence = (reason) => /[.!?]$/.test(reason) ? reason : `${reason}.`;
|
|
2724
|
+
/**
|
|
2725
|
+
* A typed domain failure as the wire failure.
|
|
2726
|
+
*
|
|
2727
|
+
* Total by construction, three times over: `codeFor` maps an unknown `_tag` to `ERR_UNKNOWN`,
|
|
2728
|
+
* `messageFor` maps it to a stated fallback, and `mcpSuggestionsFor` returns an empty array. So an
|
|
2729
|
+
* error class added upstream tomorrow reaches an agent as prose with a documented code rather than as
|
|
2730
|
+
* the internal-error string. That string is the failure mode this whole module exists to end, and it
|
|
2731
|
+
* would come straight back if the mapping could fall off the end.
|
|
2732
|
+
*
|
|
2733
|
+
* The reason text is `messageFor`'s and only `messageFor`'s: it excludes the driver's message, the
|
|
2734
|
+
* SQL, the git argv, and every memory body, because each error class dropped those at its adapter edge
|
|
2735
|
+
* so that a tool response could not carry corpus content. Enriching past it here would undo that at
|
|
2736
|
+
* the one boundary where the content leaves the process.
|
|
2737
|
+
*/
|
|
2738
|
+
const toToolFailure = (error) => {
|
|
2739
|
+
/**
|
|
2740
|
+
* An already-composed failure passes through UNCHANGED, and that branch is what lets a handler
|
|
2741
|
+
* compose its own wire failure at all.
|
|
2742
|
+
*
|
|
2743
|
+
* `handled` in `handlers.ts` is `Effect.mapError(toToolFailure)` over every handler, so a handler that
|
|
2744
|
+
* fails with a `ToolFailure` it built itself arrives here too. `batchAbortFailure` is that case,
|
|
2745
|
+
* since it needs an op index no typed domain error carries. Without this branch it falls off the end
|
|
2746
|
+
* of `codeFor`'s switch (its `_tag` is `"ToolFailure"`, in no error vocabulary) and is rewritten to
|
|
2747
|
+
* `ERR_UNKNOWN: unexpected failure: ToolFailure`, the whole composed message replaced by its own
|
|
2748
|
+
* class name. That is the masking this module exists to end, arriving from the inside. Caught by the
|
|
2749
|
+
* batch abort tests; kept here rather than by exempting the batch handler from `handled`, since a
|
|
2750
|
+
* handler outside the one error translation is a handler that can leak an untranslated failure.
|
|
2751
|
+
*/
|
|
2752
|
+
if (error instanceof ToolFailure) return error;
|
|
2753
|
+
const code = codeFor(error);
|
|
2754
|
+
const suggestions = mcpSuggestionsFor(error);
|
|
2755
|
+
const reason = sentence(messageFor(error));
|
|
2756
|
+
return new ToolFailure({
|
|
2757
|
+
code,
|
|
2758
|
+
suggestions,
|
|
2759
|
+
message: suggestions.length === 0 ? `${code}: ${reason}` : `${code}: ${reason} Try: ${suggestions.join("; ")}`
|
|
2760
|
+
});
|
|
2761
|
+
};
|
|
2762
|
+
/**
|
|
2763
|
+
* An atomic batch's abort as the wire failure, naming the op that caused it.
|
|
2764
|
+
*
|
|
2765
|
+
* **Why the error channel and not a success payload.** An atomic batch that aborted wrote nothing, made
|
|
2766
|
+
* no commit, and produced no path, so there is no result to return, and a success response carrying
|
|
2767
|
+
* `written: 0` is one an agent has to inspect to discover its call did nothing. Every other refusal on
|
|
2768
|
+
* this server is an error, so a batch that refused through the success channel would be the one tool
|
|
2769
|
+
* whose failures an agent could miss by not looking. `memory_write_batch`'s description promises exactly
|
|
2770
|
+
* this ("the first refused op aborts the whole call … and the failure names the offending op as
|
|
2771
|
+
* ops[N]"), and that promise is what this function makes true.
|
|
2772
|
+
*
|
|
2773
|
+
* **Why it is composed HERE rather than in the handler.** `failure.ts` is the single place the wire
|
|
2774
|
+
* failure is produced, and there are TWO atomic refusals that must be indistinguishable to a reader: the
|
|
2775
|
+
* handler's own per-op XOR check, and an op the store's render gate refused inside `batchWrite`. The
|
|
2776
|
+
* second arrives as a `BatchOpReport`, carrying a code and a reason STRING because `operations.ts`
|
|
2777
|
+
* already mapped the typed error and deliberately dropped it, so `toToolFailure` cannot be reached for
|
|
2778
|
+
* it. Two hand-composed messages would be two shapes for one outcome; one function is one shape.
|
|
2779
|
+
*
|
|
2780
|
+
* **Why the suggestions are the batch's own and not `mcpSuggestionsFor`'s.** The singular's advice for
|
|
2781
|
+
* `InvalidMemory` is "fix the violated constraint and call the same tool again", which is right and
|
|
2782
|
+
* incomplete here: the agent is holding N-1 ops that WOULD have landed, and the thing it most needs to
|
|
2783
|
+
* know is that `continue_on_error` exists. Both entries open with a verb, because the list is joined
|
|
2784
|
+
* behind "Try: ".
|
|
2785
|
+
*
|
|
2786
|
+
* The `code` is the op's own, carried through unchanged from `codeFor` so the batch and the singular
|
|
2787
|
+
* report one refusal under one code. An agent branching on `ERR_INVALID_MEMORY` must not have to know
|
|
2788
|
+
* which door produced it.
|
|
2789
|
+
*/
|
|
2790
|
+
const batchAbortFailure = (index, code, reason) => {
|
|
2791
|
+
const suggestions = [`fix ops[${index}] and call memory_write_batch again`, "set continue_on_error to true to write the ops that would have succeeded"];
|
|
2792
|
+
return new ToolFailure({
|
|
2793
|
+
code,
|
|
2794
|
+
suggestions,
|
|
2795
|
+
message: `${code}: ops[${index}]: ${sentence(reason)} The batch is atomic, so nothing was written and no commit was made. Try: ${suggestions.join("; ")}`
|
|
2796
|
+
});
|
|
2797
|
+
};
|
|
2798
|
+
|
|
2799
|
+
//#endregion
|
|
2800
|
+
//#region apps/mcp/src/tools.ts
|
|
2801
|
+
/**
|
|
2802
|
+
* The fourteen tools: design.md §8 verbatim, plus `memory_write_batch` (spec 004 D7).
|
|
2803
|
+
*
|
|
2804
|
+
* **`parameters` is always `Schema.Struct`, never `Schema.Class`.** A client sends a plain object
|
|
2805
|
+
* literal, and a class schema's decode expects an instance. The failure is a decode error on every
|
|
2806
|
+
* call, at runtime, for every tool. This is the one trap the whole surface is arranged around.
|
|
2807
|
+
*
|
|
2808
|
+
* **Sleep is deliberately absent.** It is a cron/operator action producing a reviewable branch, not
|
|
2809
|
+
* something an agent fires mid-conversation: a sleep run rewrites confidence across the corpus,
|
|
2810
|
+
* archives memories, and creates a branch a human is expected to read. `memhtml sleep run` is the
|
|
2811
|
+
* entry point, and if the fleet ever wants one here it is `sleep_status` (read-only). The write
|
|
2812
|
+
* side stays behind an operator.
|
|
2813
|
+
*
|
|
2814
|
+
* Every `success` schema is also a `Schema.Struct`, so `tools/list` publishes a JSON Schema the
|
|
2815
|
+
* client can validate a response against rather than an opaque object.
|
|
2816
|
+
*
|
|
2817
|
+
* **Every tool declares `failure: ToolFailure`, and the omission is a silent wire bug.** A tool with
|
|
2818
|
+
* no declared failure schema gets `Schema.Never` (`Tool.ts:1265`), so `McpServer`'s declared-failure
|
|
2819
|
+
* predicate rejects everything and every failure, typed domain error included, is rewritten to
|
|
2820
|
+
* "Tool execution failed due to an internal server error" before it reaches the caller
|
|
2821
|
+
* (`McpServer.ts:831-847`). The declaration is what puts a tool's failures on the branch that passes
|
|
2822
|
+
* prose through; see `failure.ts` for the mechanism. `failureMode` is left at its `"error"` default
|
|
2823
|
+
* on purpose: the error CHANNEL is what `McpServer` catches, and `"return"` would instead fold the
|
|
2824
|
+
* failure into the success union, where the server would see a successful call carrying a failure
|
|
2825
|
+
* payload no MCP client knows to read.
|
|
2826
|
+
*/
|
|
2827
|
+
/** The eight types an agent may write. `arc` is system-written by the sleep cycle. */
|
|
2828
|
+
const WritableType = Schema.Literals(WRITABLE_MEMORY_TYPES);
|
|
2829
|
+
/** The nine MEMORY-class rels. A person or provenance rel cannot be named here. */
|
|
2830
|
+
const MemoryRelSchema = Schema.Literals(MEMORY_RELS);
|
|
2831
|
+
/**
|
|
2832
|
+
* A repo-root-relative path: `areas/oncall/rollback-order.html`.
|
|
2833
|
+
*
|
|
2834
|
+
* The git-tree form with no leading slash, which is `files.path`, and the ID of a memory. The
|
|
2835
|
+
* `<link href>` form in the HTML carries a leading slash and is converted at the store boundary, so
|
|
2836
|
+
* a tool never sees it.
|
|
2837
|
+
*/
|
|
2838
|
+
const MemoryPath = Schema.String;
|
|
2839
|
+
/**
|
|
2840
|
+
* `Schema.Finite`, not `Schema.Number`, for every numeric field.
|
|
2841
|
+
*
|
|
2842
|
+
* `Number` derives a JSON Schema with an `anyOf` carrying a STRING branch, because `Infinity` and
|
|
2843
|
+
* `NaN` are not JSON numbers and the codec represents them as strings. Probed on this beta,
|
|
2844
|
+
* `Schema.Number` derives `{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity",
|
|
2845
|
+
* "-Infinity","NaN"]}]}`. A client reading that sees a union where the tool wants a number. `Finite`
|
|
2846
|
+
* derives a clean `{"type":"number"}`.
|
|
2847
|
+
*/
|
|
2848
|
+
const Finite = Schema.Finite;
|
|
2849
|
+
/** A count: a non-negative quantity. */
|
|
2850
|
+
const Count = Schema.Int;
|
|
2851
|
+
/**
|
|
2852
|
+
* An optional parameter that a client may also send explicitly as `null`.
|
|
2853
|
+
*
|
|
2854
|
+
* A bare `Schema.optional(X)` is a WIRE BUG here, and it is the kind a byte-comparison fixture
|
|
2855
|
+
* cannot see: the derived JSON Schema publishes `{"anyOf":[{"type":"string"},{"type":"null"}]}` , telling
|
|
2856
|
+
* every client that `null` is acceptable, while the decoder rejects it with "Expected string |
|
|
2857
|
+
* undefined, got null" (both probed on effect 4.0.0-beta.102). So a client that read the schema and
|
|
2858
|
+
* did the obvious thing, sending `{"workspace": null}` for "no workspace", would get a decode error
|
|
2859
|
+
* on a call the published contract said was valid. Many clients serialize an absent optional exactly
|
|
2860
|
+
* that way.
|
|
2861
|
+
*
|
|
2862
|
+
* `optionalKey(NullOr(X))` makes the decoder accept all three forms a client can produce (absent,
|
|
2863
|
+
* a value, and `null`) and publishes the FLAT `{"anyOf":[{"type":"string"},{"type":"null"}]}`.
|
|
2864
|
+
* `optional` rather than `optionalKey` would derive a nested `anyOf` wrapping that union in a second
|
|
2865
|
+
* one, which is the same contract spelled in a way a client has to unwrap twice to read.
|
|
2866
|
+
*
|
|
2867
|
+
* `null` and absent both mean "not supplied", which is what the handlers normalize to `undefined`.
|
|
2868
|
+
*/
|
|
2869
|
+
const Optional = (schema) => Schema.optionalKey(Schema.NullOr(schema));
|
|
2870
|
+
/**
|
|
2871
|
+
* The services a tool handler may reach for, declared per tool.
|
|
2872
|
+
*
|
|
2873
|
+
* `Tool.make`'s `dependencies` is what moves a service from the handler's requirement set into the
|
|
2874
|
+
* TOOL's, so `kit.toLayer({…})` accepts a handler that yields `Store`, and the requirement then
|
|
2875
|
+
* surfaces on the layer where `layerApp` satisfies it. Without the declaration a handler that reads
|
|
2876
|
+
* a service is a type error, and the only ways out are casting the handler or building the services
|
|
2877
|
+
* inside it: the first loses the check that the app layer provides what the tools need, and the
|
|
2878
|
+
* second gives every tool call its own database connection.
|
|
2879
|
+
*
|
|
2880
|
+
* Each tool declares only what it actually uses, so a handler that grows a dependency has to say so,
|
|
2881
|
+
* which keeps `memory_search` provably unable to reach the store and write.
|
|
2882
|
+
*
|
|
2883
|
+
* A FUNCTION per set, not a shared constant: the option's type is a mutable array, so handing the
|
|
2884
|
+
* same array to fourteen tools would let one tool's construction mutate the dependency list of the
|
|
2885
|
+
* other thirteen.
|
|
2886
|
+
*/
|
|
2887
|
+
const READS = () => [DatabaseService];
|
|
2888
|
+
const WRITES = () => [
|
|
2889
|
+
Store,
|
|
2890
|
+
Indexer,
|
|
2891
|
+
IndexRecorder,
|
|
2892
|
+
ExtractorPort
|
|
2893
|
+
];
|
|
2894
|
+
const RETRIEVES = () => [Retrieval, DatabaseService];
|
|
2895
|
+
/**
|
|
2896
|
+
* The fields that author ONE memory, shared by `memory_write`'s parameters and `memory_write_batch`'s
|
|
2897
|
+
* op struct.
|
|
2898
|
+
*
|
|
2899
|
+
* D7 says the batch op is "the same fields as memory_write". Written twice, that is a claim two
|
|
2900
|
+
* literals make about each other and stop making the first time a field is added to one of them. An
|
|
2901
|
+
* agent that learned `tags` from `memory_write` and had it silently dropped by a batch op would get a
|
|
2902
|
+
* memory it could not find by the facet it filed it under. Shared, the widening is automatic and the
|
|
2903
|
+
* published schemas cannot disagree.
|
|
2904
|
+
*
|
|
2905
|
+
* A FUNCTION returning a fresh literal, matching `READS`/`WRITES` above: the field record is handed to
|
|
2906
|
+
* a schema constructor and nothing here should be able to observe another tool's construction.
|
|
2907
|
+
*/
|
|
2908
|
+
const writeFields = () => ({
|
|
2909
|
+
title: Schema.String,
|
|
2910
|
+
/**
|
|
2911
|
+
* Prose. The first sentence becomes the `<mark>` claim and the rest becomes one `<p>` per blank-line
|
|
2912
|
+
* paragraph. See `claimFromProse`/`proseTail` in `@memhtml/cli`'s `prose.ts`, the one copy this door and
|
|
2913
|
+
* `memhtml apply` share. Optional because `article_html` is the other way to author the same article, and
|
|
2914
|
+
* the handler refuses a call that names both or neither.
|
|
2915
|
+
*/
|
|
2916
|
+
body: Optional(Schema.String),
|
|
2917
|
+
/** Pre-authored article markup, used verbatim in place of `body`. See the description's contract. */
|
|
2918
|
+
article_html: Optional(Schema.String),
|
|
2919
|
+
memory_type: WritableType,
|
|
2920
|
+
path: Optional(MemoryPath),
|
|
2921
|
+
workspace: Optional(Schema.String),
|
|
2922
|
+
tags: Optional(Schema.Array(Schema.String)),
|
|
2923
|
+
entities: Optional(Schema.Array(Schema.String)),
|
|
2924
|
+
importance: Optional(Count),
|
|
2925
|
+
confidence: Optional(Finite),
|
|
2926
|
+
session_id: Optional(Schema.String),
|
|
2927
|
+
prompt_id: Optional(Schema.String),
|
|
2928
|
+
turn_uuid: Optional(Schema.String)
|
|
2929
|
+
});
|
|
2930
|
+
const MemoryWrite = Tool.make("memory_write", {
|
|
2931
|
+
description: "Write one memory to the corpus. Returns the existing path with deduped=true when an active memory already holds this exact content. A duplicate creates no file and no commit. Supply EXACTLY ONE of `body` or `article_html`. Both or neither is refused. `article_html` is raw <article> inner markup used verbatim, and the caller owns the format: exactly one <mark>, inside the first <p> or the first <li>, and never inside <aside> or <details>; only elements from the closed vocabulary in docs/format.md; no class attribute, no style attribute, no <script>, no event handlers. The FIRST <time datetime=\"…\"> element becomes the memory's event time, which is what the recency arm ranks by, so an episodic memory about last week should carry last week's date, not today's. Markup that violates the format is refused before any file is written or committed. Code snippets: in `body` prose, a paragraph that is entirely a fenced code block (```ts … ```) becomes <figure><pre><code data-lang=\"ts\">, whitespace verbatim, and the language promotes to a `lang:ts` entity; a blank line inside the fence does not split it. In `article_html`, author the same markup yourself: data-lang, never class (forbidden) and never lang= (that names human languages). Call memory_write_batch ONCE rather than memory_write N times whenever this task will write more than about three memories: a batch stages every file, makes ONE commit, and reindexes ONCE, so it costs less than N calls and leaves a history a reader can follow. It returns one result per op in INPUT ORDER, each naming that op's index, its path, and whether it deduped. A batch is ATOMIC by default: the first refused op aborts the whole call, no file is written and no commit is made, and the failure names the offending op as ops[N]. Set continue_on_error to true for best-effort instead, and a refused op comes back as a failed result carrying its own code and reason while every surviving op lands in the one commit. A duplicate is never a failure: an op whose exact content is already stored returns ok with deduped=true and the existing path. Each op supplies EXACTLY ONE of body or article_html, the same rule memory_write follows.",
|
|
2932
|
+
dependencies: WRITES(),
|
|
2933
|
+
parameters: Schema.Struct(writeFields()),
|
|
2934
|
+
failure: ToolFailure,
|
|
2935
|
+
success: Schema.Struct({
|
|
2936
|
+
path: MemoryPath,
|
|
2937
|
+
created: Schema.Boolean,
|
|
2938
|
+
deduped: Schema.Boolean,
|
|
2939
|
+
existing_path: Schema.NullOr(MemoryPath)
|
|
2940
|
+
})
|
|
2941
|
+
});
|
|
2942
|
+
/**
|
|
2943
|
+
* One op in a batch: a whole `memory_write` payload, with the tool name standing in for D4's `op`
|
|
2944
|
+
* discriminator.
|
|
2945
|
+
*
|
|
2946
|
+
* A nested `Schema.Struct`, which is what makes the array's `items` a published object schema with its
|
|
2947
|
+
* own `required`. Probed on effect 4.0.0-beta.102, `Schema.Array(Schema.Struct({…}))` derives the
|
|
2948
|
+
* struct INLINE under `items` rather than hoisting it into a `$defs` a client would have to resolve.
|
|
2949
|
+
* So `ops[].title` is as legible to a caller reading `tools/list` as `memory_write`'s own `title`, and
|
|
2950
|
+
* the `Optional` discipline carries in unchanged: an optional inside an op publishes the same FLAT
|
|
2951
|
+
* `{"anyOf":[{…},{"type":"null"}]}` and accepts absent, a value, or `null`.
|
|
2952
|
+
*/
|
|
2953
|
+
const BatchOp = Schema.Struct(writeFields());
|
|
2954
|
+
/** One op's outcome, mirroring `memhtml apply`'s own per-op payload field for field, in snake_case. */
|
|
2955
|
+
const BatchOpResult = Schema.Struct({
|
|
2956
|
+
/** This op's position in the `ops` array the caller sent. Results come back in that order too. */
|
|
2957
|
+
index: Count,
|
|
2958
|
+
ok: Schema.Boolean,
|
|
2959
|
+
/**
|
|
2960
|
+
* Every field below is PRESENT and nullable rather than optional, for the reason `memory_write`'s
|
|
2961
|
+
* `existing_path` is: a client reading an absent key cannot tell "this op did not dedupe" from "this
|
|
2962
|
+
* server does not report dedupes", and an agent deciding whether to retry needs that distinction.
|
|
2963
|
+
*/
|
|
2964
|
+
path: Schema.NullOr(MemoryPath),
|
|
2965
|
+
deduped: Schema.Boolean,
|
|
2966
|
+
existing_path: Schema.NullOr(MemoryPath),
|
|
2967
|
+
/** The stable `ERR_*` code for this op's refusal, null when it did not fail. */
|
|
2968
|
+
code: Schema.NullOr(Schema.String),
|
|
2969
|
+
error: Schema.NullOr(Schema.String),
|
|
2970
|
+
/**
|
|
2971
|
+
* True when this op was never attempted: an atomic abort reports every op other than the offending
|
|
2972
|
+
* one as skipped, which is how a caller tells "refused" from "not reached".
|
|
2973
|
+
*/
|
|
2974
|
+
skipped: Schema.Boolean,
|
|
2975
|
+
/**
|
|
2976
|
+
* What this op's claim contradicts, when `detect_conflicts` was on and something matched. Null when
|
|
2977
|
+
* the flag was off, when nothing matched, or when the claim states no frame shape.
|
|
2978
|
+
*
|
|
2979
|
+
* `Schema.NullOr(Schema.Struct(…))`, present like every field above rather than optional: a client
|
|
2980
|
+
* reading an absent key cannot tell "this op conflicts with nothing" from "this server does not
|
|
2981
|
+
* report conflicts", and the two lead to opposite decisions.
|
|
2982
|
+
*
|
|
2983
|
+
* ONE struct with both source fields nullable rather than a union of two, so a client reads `claim`
|
|
2984
|
+
* unconditionally (that is the disagreement, and it is what the decision is made on) and then
|
|
2985
|
+
* whichever of `path`/`batch_index` is non-null. A `Schema.Union` would publish two near-identical
|
|
2986
|
+
* three-field shapes under an `anyOf` and force every consumer to discriminate before reading the
|
|
2987
|
+
* field it wanted, which is the same trap the `body`/`article_html` XOR avoids by not being a union.
|
|
2988
|
+
*/
|
|
2989
|
+
conflict: Schema.NullOr(Schema.Struct({
|
|
2990
|
+
/** The ACTIVE memory already holding this frame key. Null for an intra-batch match. */
|
|
2991
|
+
path: Schema.NullOr(MemoryPath),
|
|
2992
|
+
/**
|
|
2993
|
+
* The EARLIER op in THIS call holding it. Null for a store match, and it has no path because
|
|
2994
|
+
* that op's file does not exist yet. The batch has not been written when the assist runs.
|
|
2995
|
+
*/
|
|
2996
|
+
batch_index: Schema.NullOr(Count),
|
|
2997
|
+
/** The other claim's own text. */
|
|
2998
|
+
claim: Schema.String
|
|
2999
|
+
})),
|
|
3000
|
+
/**
|
|
3001
|
+
* Set on a batch-internal LOSER under `consolidate: "last-wins"`: a later op with the same frame
|
|
3002
|
+
* key replaced this op's value before anything was written, and the number is the caller-space
|
|
3003
|
+
* index of the op whose position carries the surviving value. Null everywhere else, present like
|
|
3004
|
+
* every field above so a client can tell "not consolidated" from "not reported".
|
|
3005
|
+
*/
|
|
3006
|
+
consolidated_into: Schema.NullOr(Count),
|
|
3007
|
+
/**
|
|
3008
|
+
* Set on a WINNER whose write superseded a live stored memory under `consolidate: "last-wins"`:
|
|
3009
|
+
* the loser's ARCHIVE path, where its bytes now live. Null when nothing stored occupied the
|
|
3010
|
+
* slot, and when the supersede degraded (the batch still wrote; the corpus is merely
|
|
3011
|
+
* unconsolidated).
|
|
3012
|
+
*/
|
|
3013
|
+
superseded_path: Schema.NullOr(Schema.String)
|
|
3014
|
+
});
|
|
3015
|
+
const MemoryWriteBatch = Tool.make("memory_write_batch", {
|
|
3016
|
+
description: "Write many memories in ONE commit: every op is validated first, every surviving file is staged, and the batch commits and reindexes exactly once. commit_sha is null when nothing was written: an all-deduped batch, or an aborted one. Supply EXACTLY ONE of `body` or `article_html`. Both or neither is refused. `article_html` is raw <article> inner markup used verbatim, and the caller owns the format: exactly one <mark>, inside the first <p> or the first <li>, and never inside <aside> or <details>; only elements from the closed vocabulary in docs/format.md; no class attribute, no style attribute, no <script>, no event handlers. The FIRST <time datetime=\"…\"> element becomes the memory's event time, which is what the recency arm ranks by, so an episodic memory about last week should carry last week's date, not today's. Markup that violates the format is refused before any file is written or committed. Code snippets: in `body` prose, a paragraph that is entirely a fenced code block (```ts … ```) becomes <figure><pre><code data-lang=\"ts\">, whitespace verbatim, and the language promotes to a `lang:ts` entity; a blank line inside the fence does not split it. In `article_html`, author the same markup yourself: data-lang, never class (forbidden) and never lang= (that names human languages). Call memory_write_batch ONCE rather than memory_write N times whenever this task will write more than about three memories: a batch stages every file, makes ONE commit, and reindexes ONCE, so it costs less than N calls and leaves a history a reader can follow. It returns one result per op in INPUT ORDER, each naming that op's index, its path, and whether it deduped. A batch is ATOMIC by default: the first refused op aborts the whole call, no file is written and no commit is made, and the failure names the offending op as ops[N]. Set continue_on_error to true for best-effort instead, and a refused op comes back as a failed result carrying its own code and reason while every surviving op lands in the one commit. A duplicate is never a failure: an op whose exact content is already stored returns ok with deduped=true and the existing path. Each op supplies EXACTLY ONE of body or article_html, the same rule memory_write follows. Set detect_conflicts to true and each per-op result gains a `conflict` field naming what that op's claim CONTRADICTS. This is not dedupe: dedupe catches an op whose content is IDENTICAL to something stored, while this catches an op that says something DIFFERENT about the same thing, the case dedupe is blind to, and the one that actually rots a corpus. The match is grammatical rather than semantic. A claim splits into a frame (the subject and relation up to its LAST of/is/in/to/by/as) and a value, and two claims conflict when they share a frame: 'The pool ceiling is 64' and 'The pool ceiling is 128' both key on 'the pool ceiling is'. conflict.path names an ACTIVE memory already holding that slot. conflict.batch_index names an EARLIER op in this same call, which no other tool can see because neither op is stored yet; it has no path for that reason. conflict.claim is the other claim's own text, so you can decide without a second call. conflict is null when nothing matched, when detect_conflicts was absent, when the claim states no frame shape (the rule refuses frames under three tokens and values over six, so short claims and claims trailed by a clause are deliberately unmatched rather than loosely matched), and always on an op that used article_html. The claim is inside your markup there and is not read until the store renders it. THE ASSIST NEVER CHANGES WHAT IS WRITTEN. An op carrying a conflict is written exactly as it would have been without the flag: nothing is archived, nothing is refused, later does not win, and the summary counts are unchanged. That is deliberate, not a limitation. Sometimes the contradiction IS the answer. A memory recording that a runbook step changed necessarily contradicts the memory stating the old step, and a system that resolved that for you would destroy the pair a reader needs in order to see the change at all. So YOU decide, per conflict: keep both (they are about different things, or both are true), call memory_correct on the named path instead (the new claim supersedes the old one, which stays readable under archive/), or drop the op. Archived memories never match, so a superseded claim stops contradicting the claim that superseded it. Set consolidate to \"last-wins\" and the batch RESOLVES frame-key matches instead of only reporting them: for ops sharing a claim slot (the same deterministic frame key the conflict rule uses), the LATER value wins. Exactly one file is written, at the FIRST index that claimed the slot, and every later restatement reports consolidated_into naming that slot instead of a path of its own. A stored ACTIVE memory occupying a surviving slot is archived with a supersedes link from the new file, its archive path reported on the winner as superseded_path. Off by default, and claims with no frame shape are never consolidated. The guards fail closed, so this only ever acts on claims the conflict rule would have matched.",
|
|
3017
|
+
dependencies: WRITES(),
|
|
3018
|
+
parameters: Schema.Struct({
|
|
3019
|
+
ops: Schema.Array(BatchOp),
|
|
3020
|
+
/** Best-effort mode: a refused op is reported and skipped, survivors land in the one commit. */
|
|
3021
|
+
continue_on_error: Optional(Schema.Boolean),
|
|
3022
|
+
/**
|
|
3023
|
+
* Report each op's frame-matches as a per-op `conflict`. Propose-only: it changes nothing about what
|
|
3024
|
+
* is written. `Optional` rather than defaulted-true because the assist costs one extra query per
|
|
3025
|
+
* batch, and a caller that did not ask for the field would be paying for an answer it does not read.
|
|
3026
|
+
*/
|
|
3027
|
+
detect_conflicts: Optional(Schema.Boolean),
|
|
3028
|
+
/**
|
|
3029
|
+
* Opt-in deterministic last-wins consolidation over the conflict rule's own frame keys. A
|
|
3030
|
+
* `Literals` of one value rather than a boolean, so the vocabulary can widen (a `first-wins`, a
|
|
3031
|
+
* semantic mode) without a shipped `true` changing meaning under a caller.
|
|
3032
|
+
*/
|
|
3033
|
+
consolidate: Optional(Schema.Literals(["last-wins"])),
|
|
3034
|
+
/**
|
|
3035
|
+
* Batch-level provenance: the session this call is being made in. An op that names its own wins,
|
|
3036
|
+
* because it is the more specific statement about where that one memory came from, which is what
|
|
3037
|
+
* lets a batch replay writes from an earlier session without relabelling them.
|
|
3038
|
+
*/
|
|
3039
|
+
session_id: Optional(Schema.String),
|
|
3040
|
+
prompt_id: Optional(Schema.String),
|
|
3041
|
+
turn_uuid: Optional(Schema.String)
|
|
3042
|
+
}),
|
|
3043
|
+
failure: ToolFailure,
|
|
3044
|
+
success: Schema.Struct({
|
|
3045
|
+
results: Schema.Array(BatchOpResult),
|
|
3046
|
+
/** Derived from `results` in one pass, so the counts cannot disagree with the array. */
|
|
3047
|
+
summary: Schema.Struct({
|
|
3048
|
+
total: Count,
|
|
3049
|
+
written: Count,
|
|
3050
|
+
deduped: Count,
|
|
3051
|
+
failed: Count,
|
|
3052
|
+
skipped: Count,
|
|
3053
|
+
/** Batch-internal losers under `consolidate: "last-wins"`: neither written nor failed. */
|
|
3054
|
+
consolidated: Count
|
|
3055
|
+
}),
|
|
3056
|
+
commit_sha: Schema.NullOr(Schema.String)
|
|
3057
|
+
})
|
|
3058
|
+
});
|
|
3059
|
+
const MemoryRead = Tool.make("memory_read", {
|
|
3060
|
+
description: "Read one memory in full: its head metadata, authored links, and complete article body. The only path to a <details> body, which recall never quotes. An explicit open of a named path COUNTS as salience. This is the read that moves the access plane, while a search or recall hit does not.",
|
|
3061
|
+
/**
|
|
3062
|
+
* `DatabaseService` is here because an explicit open bumps the access plane: `readMemory` reaches the
|
|
3063
|
+
* state plane through `bumpAccess`, so the tool has to declare it or the handler is a type error. The
|
|
3064
|
+
* widening is the salience rule made visible in the dependency set. `memory_search` still cannot
|
|
3065
|
+
* reach it, which is what keeps a ranker's guess out of the plane.
|
|
3066
|
+
*/
|
|
3067
|
+
dependencies: [
|
|
3068
|
+
Store,
|
|
3069
|
+
IndexRecorder,
|
|
3070
|
+
DatabaseService
|
|
3071
|
+
],
|
|
3072
|
+
parameters: Schema.Struct({
|
|
3073
|
+
path: MemoryPath,
|
|
3074
|
+
session_id: Optional(Schema.String)
|
|
3075
|
+
}),
|
|
3076
|
+
failure: ToolFailure,
|
|
3077
|
+
success: Schema.Struct({
|
|
3078
|
+
path: MemoryPath,
|
|
3079
|
+
title: Schema.String,
|
|
3080
|
+
body: Schema.String,
|
|
3081
|
+
gist: Schema.String,
|
|
3082
|
+
memory_type: Schema.String,
|
|
3083
|
+
meta: Schema.Record(Schema.String, Schema.String),
|
|
3084
|
+
links: Schema.Array(Schema.Struct({
|
|
3085
|
+
rel: Schema.String,
|
|
3086
|
+
href: Schema.String
|
|
3087
|
+
})),
|
|
3088
|
+
archived: Schema.Boolean,
|
|
3089
|
+
warnings: Schema.Array(Schema.String)
|
|
3090
|
+
})
|
|
3091
|
+
});
|
|
3092
|
+
const MemorySearch = Tool.make("memory_search", {
|
|
3093
|
+
description: "Ranked search over the corpus: lexical, vector, recency, and salience arms fused with RRF, then diversified. Each hit carries a `snippet`: the text of the file's best-matching chunk for this query (its opening chunk when the vector arm did not fire), truncated with a trailing `…` when cut. `degraded` is true when the vector arm did not fire, so the result came from fewer signals. Each hit also carries `entities` in `type:name` form; pass one of those values back as `entity` to make the next call the second hop of a chain. That is two calls, not a guess about spelling. An `entity` scope that matches nothing returns NO hits and says so through `scope_empty`: this tool never widens a scope it could not satisfy. `as_of` is a point-in-time view: pass an ISO instant and the result is what was believed valid at that moment, including since-superseded memories (marked superseded_by). Returning a path changes nothing: a hit is this ranker's guess, so it never bumps salience. Call memory_read to open the one you chose, and memory_reinforce to record whether it was right.",
|
|
3094
|
+
dependencies: RETRIEVES(),
|
|
3095
|
+
parameters: Schema.Struct({
|
|
3096
|
+
query: Schema.String,
|
|
3097
|
+
limit: Optional(Count),
|
|
3098
|
+
memory_types: Optional(Schema.Array(WritableType)),
|
|
3099
|
+
workspace: Optional(Schema.String),
|
|
3100
|
+
tags: Optional(Schema.Array(Schema.String)),
|
|
3101
|
+
/**
|
|
3102
|
+
* One entity reference in `type:name` form, the same spelling `memory_list` takes and the same
|
|
3103
|
+
* spelling a hit's `entities` publishes, so a value read off a hit is a valid scope verbatim.
|
|
3104
|
+
*/
|
|
3105
|
+
entity: Optional(Schema.String),
|
|
3106
|
+
include_archived: Optional(Schema.Boolean),
|
|
3107
|
+
/**
|
|
3108
|
+
* Point-in-time view: returns what was believed valid at this moment, including
|
|
3109
|
+
* since-superseded memories (marked superseded_by). The window is
|
|
3110
|
+
* `coalesce(valid_from, event_at, created_at) <= as_of < valid_until`. The supersede path
|
|
3111
|
+
* stamps both ends, so history is read from the files rather than replayed from git.
|
|
3112
|
+
*/
|
|
3113
|
+
as_of: Optional(Schema.String)
|
|
3114
|
+
}),
|
|
3115
|
+
failure: ToolFailure,
|
|
3116
|
+
success: Schema.Struct({
|
|
3117
|
+
hits: Schema.Array(Schema.Struct({
|
|
3118
|
+
path: MemoryPath,
|
|
3119
|
+
title: Schema.String,
|
|
3120
|
+
gist: Schema.String,
|
|
3121
|
+
memory_type: Schema.String,
|
|
3122
|
+
/** The fused RRF score. Unitless and comparable only within one result set. */
|
|
3123
|
+
score: Finite,
|
|
3124
|
+
confidence: Finite,
|
|
3125
|
+
updated_at: Schema.String,
|
|
3126
|
+
/**
|
|
3127
|
+
* The best-matching chunk's text for THIS query (the vector arm's winning chunk, or the
|
|
3128
|
+
* file's opening chunk on the degraded path), truncated with a trailing `…` when cut.
|
|
3129
|
+
*/
|
|
3130
|
+
snippet: Schema.String,
|
|
3131
|
+
/**
|
|
3132
|
+
* This memory's entity references in `type:name` form, sorted, possibly empty.
|
|
3133
|
+
*
|
|
3134
|
+
* The next hop's `entity` parameter, published in the form that parameter accepts: the whole
|
|
3135
|
+
* point is that a caller chains by COPYING a value rather than by reconstructing one.
|
|
3136
|
+
*/
|
|
3137
|
+
entities: Schema.Array(Schema.String),
|
|
3138
|
+
/**
|
|
3139
|
+
* The path of the memory that superseded this one, or `null` when nothing has. Non-null
|
|
3140
|
+
* only for an archived hit, which reaches a result through `as_of` or
|
|
3141
|
+
* `include_archived`, so a point-in-time answer is legible as history. Present and
|
|
3142
|
+
* nullable like `consolidated_into`: a client must be able to tell "not superseded" from
|
|
3143
|
+
* "this build does not report supersession".
|
|
3144
|
+
*/
|
|
3145
|
+
superseded_by: Schema.NullOr(Schema.String)
|
|
3146
|
+
})),
|
|
3147
|
+
degraded: Schema.Boolean,
|
|
3148
|
+
arms: Schema.Array(Schema.String),
|
|
3149
|
+
/** The `entity` this search was scoped to, or `null` when it was not scoped by entity. */
|
|
3150
|
+
entity_scope: Schema.NullOr(Schema.String),
|
|
3151
|
+
/**
|
|
3152
|
+
* True when a scope was named, it narrowed the query, and nothing survived it.
|
|
3153
|
+
*
|
|
3154
|
+
* A boolean in every case, following `degraded`: this is the field that makes an empty scoped
|
|
3155
|
+
* result attributable to the scope, and it would be worth nothing if its absence had to be read
|
|
3156
|
+
* as `false`.
|
|
3157
|
+
*/
|
|
3158
|
+
scope_empty: Schema.Boolean
|
|
3159
|
+
})
|
|
3160
|
+
});
|
|
3161
|
+
const MemoryRecall = Tool.make("memory_recall", {
|
|
3162
|
+
description: "A context pack under a character budget: full bodies for what fits, one index line each for what does not. Arcs are folded under their own envelope so a synthesis cannot crowd out the evidence behind it.",
|
|
3163
|
+
dependencies: RETRIEVES(),
|
|
3164
|
+
parameters: Schema.Struct({
|
|
3165
|
+
query: Schema.String,
|
|
3166
|
+
budget_chars: Optional(Count),
|
|
3167
|
+
workspace: Optional(Schema.String)
|
|
3168
|
+
}),
|
|
3169
|
+
failure: ToolFailure,
|
|
3170
|
+
success: Schema.Struct({
|
|
3171
|
+
sections: Schema.Struct({
|
|
3172
|
+
arcs: Schema.Array(Schema.Struct({
|
|
3173
|
+
path: MemoryPath,
|
|
3174
|
+
title: Schema.String,
|
|
3175
|
+
gist: Schema.String,
|
|
3176
|
+
body: Schema.String
|
|
3177
|
+
})),
|
|
3178
|
+
memories: Schema.Array(Schema.Struct({
|
|
3179
|
+
path: MemoryPath,
|
|
3180
|
+
title: Schema.String,
|
|
3181
|
+
gist: Schema.String,
|
|
3182
|
+
body: Schema.String
|
|
3183
|
+
})),
|
|
3184
|
+
/** What did not fit: claim plus path, for a deliberate drill-down. */
|
|
3185
|
+
lateral: Schema.Array(Schema.Struct({
|
|
3186
|
+
path: MemoryPath,
|
|
3187
|
+
title: Schema.String,
|
|
3188
|
+
gist: Schema.String
|
|
3189
|
+
}))
|
|
3190
|
+
}),
|
|
3191
|
+
spent_chars: Count,
|
|
3192
|
+
truncated: Schema.Boolean,
|
|
3193
|
+
degraded: Schema.Boolean
|
|
3194
|
+
})
|
|
3195
|
+
});
|
|
3196
|
+
const MemoryCorrect = Tool.make("memory_correct", {
|
|
3197
|
+
description: "Supersede a memory: write the corrected version and archive the target in ONE commit, linked in both directions. Never edits in place. The superseded memory stays readable under archive/. Supply EXACTLY ONE of `body` or `article_html`. Both or neither is refused. `article_html` is raw <article> inner markup used verbatim, and the caller owns the format: exactly one <mark>, inside the first <p> or the first <li>, and never inside <aside> or <details>; only elements from the closed vocabulary in docs/format.md; no class attribute, no style attribute, no <script>, no event handlers. The FIRST <time datetime=\"…\"> element becomes the memory's event time, which is what the recency arm ranks by, so an episodic memory about last week should carry last week's date, not today's. Markup that violates the format is refused before any file is written or committed. Code snippets: in `body` prose, a paragraph that is entirely a fenced code block (```ts … ```) becomes <figure><pre><code data-lang=\"ts\">, whitespace verbatim, and the language promotes to a `lang:ts` entity; a blank line inside the fence does not split it. In `article_html`, author the same markup yourself: data-lang, never class (forbidden) and never lang= (that names human languages).",
|
|
3198
|
+
dependencies: WRITES(),
|
|
3199
|
+
parameters: Schema.Struct({
|
|
3200
|
+
target_path: MemoryPath,
|
|
3201
|
+
title: Schema.String,
|
|
3202
|
+
/** The corrected prose; first sentence becomes the new `<mark>`. Exclusive with `article_html`. */
|
|
3203
|
+
body: Optional(Schema.String),
|
|
3204
|
+
/** Pre-authored markup for the superseding article, used verbatim. Exclusive with `body`. */
|
|
3205
|
+
article_html: Optional(Schema.String),
|
|
3206
|
+
reason: Schema.String,
|
|
3207
|
+
session_id: Optional(Schema.String)
|
|
3208
|
+
}),
|
|
3209
|
+
failure: ToolFailure,
|
|
3210
|
+
success: Schema.Struct({
|
|
3211
|
+
path: MemoryPath,
|
|
3212
|
+
superseded: Schema.Array(MemoryPath),
|
|
3213
|
+
archived: Schema.Array(MemoryPath)
|
|
3214
|
+
})
|
|
3215
|
+
});
|
|
3216
|
+
const MemoryLink = Tool.make("memory_link", {
|
|
3217
|
+
description: "Assert an edge between two memories. Written into the source file's head, so it survives an index rebuild. Idempotent: re-linking the same pair commits nothing.",
|
|
3218
|
+
dependencies: [Store, Indexer],
|
|
3219
|
+
parameters: Schema.Struct({
|
|
3220
|
+
src_path: MemoryPath,
|
|
3221
|
+
rel: MemoryRelSchema,
|
|
3222
|
+
dst_path: MemoryPath,
|
|
3223
|
+
strength: Optional(Finite)
|
|
3224
|
+
}),
|
|
3225
|
+
failure: ToolFailure,
|
|
3226
|
+
success: Schema.Struct({
|
|
3227
|
+
ok: Schema.Boolean,
|
|
3228
|
+
rel: Schema.String,
|
|
3229
|
+
src_path: MemoryPath,
|
|
3230
|
+
dst_path: MemoryPath
|
|
3231
|
+
})
|
|
3232
|
+
});
|
|
3233
|
+
const MemoryNeighbors = Tool.make("memory_neighbors", {
|
|
3234
|
+
description: "The memory graph around one path, to at most two hops, in both directions. Includes sleep-mined edges: lateral retrieval is what they are for.",
|
|
3235
|
+
dependencies: READS(),
|
|
3236
|
+
parameters: Schema.Struct({
|
|
3237
|
+
path: MemoryPath,
|
|
3238
|
+
depth: Optional(Count),
|
|
3239
|
+
rels: Optional(Schema.Array(MemoryRelSchema))
|
|
3240
|
+
}),
|
|
3241
|
+
failure: ToolFailure,
|
|
3242
|
+
success: Schema.Struct({
|
|
3243
|
+
nodes: Schema.Array(Schema.Struct({
|
|
3244
|
+
path: MemoryPath,
|
|
3245
|
+
title: Schema.String,
|
|
3246
|
+
/** 1-based distance from the centre: 1 or 2, never 0. */
|
|
3247
|
+
hop: Count,
|
|
3248
|
+
rel: Schema.String
|
|
3249
|
+
})),
|
|
3250
|
+
edges: Count
|
|
3251
|
+
})
|
|
3252
|
+
});
|
|
3253
|
+
const MemoryArchive = Tool.make("memory_archive", {
|
|
3254
|
+
description: "Soft-evict a memory: `git mv` into archive/<YYYY>/ with the archive stamps. Nothing is ever deleted, and `git log --follow` reads straight through.",
|
|
3255
|
+
dependencies: [Store, Indexer],
|
|
3256
|
+
parameters: Schema.Struct({
|
|
3257
|
+
path: MemoryPath,
|
|
3258
|
+
reason: Schema.String
|
|
3259
|
+
}),
|
|
3260
|
+
failure: ToolFailure,
|
|
3261
|
+
success: Schema.Struct({
|
|
3262
|
+
path: MemoryPath,
|
|
3263
|
+
archive_path: MemoryPath
|
|
3264
|
+
})
|
|
3265
|
+
});
|
|
3266
|
+
const MemoryReinforce = Tool.make("memory_reinforce", {
|
|
3267
|
+
description: "Record that a memory helped or misled. Gated by a 900-second per-path cooldown, so a replayed query cannot inflate a memory's ranking; `cooled_down` lists the paths the cooldown held back.",
|
|
3268
|
+
dependencies: READS(),
|
|
3269
|
+
parameters: Schema.Struct({
|
|
3270
|
+
paths: Schema.Array(MemoryPath),
|
|
3271
|
+
signal: Schema.Literals(REINFORCE_SIGNALS)
|
|
3272
|
+
}),
|
|
3273
|
+
failure: ToolFailure,
|
|
3274
|
+
success: Schema.Struct({
|
|
3275
|
+
bumped: Schema.Array(MemoryPath),
|
|
3276
|
+
cooled_down: Schema.Array(MemoryPath)
|
|
3277
|
+
})
|
|
3278
|
+
});
|
|
3279
|
+
const MemoryList = Tool.make("memory_list", {
|
|
3280
|
+
description: "Page through the corpus by facet. `next_cursor` is a keyset on the path, so a page stays correct even while a sleep cycle archives files.",
|
|
3281
|
+
dependencies: READS(),
|
|
3282
|
+
parameters: Schema.Struct({
|
|
3283
|
+
memory_type: Optional(WritableType),
|
|
3284
|
+
workspace: Optional(Schema.String),
|
|
3285
|
+
tag: Optional(Schema.String),
|
|
3286
|
+
entity: Optional(Schema.String),
|
|
3287
|
+
para: Optional(Schema.Literals(PARA_BUCKETS)),
|
|
3288
|
+
limit: Optional(Count),
|
|
3289
|
+
cursor: Optional(Schema.String)
|
|
3290
|
+
}),
|
|
3291
|
+
failure: ToolFailure,
|
|
3292
|
+
success: Schema.Struct({
|
|
3293
|
+
files: Schema.Array(Schema.Struct({
|
|
3294
|
+
path: MemoryPath,
|
|
3295
|
+
title: Schema.String,
|
|
3296
|
+
memory_type: Schema.String,
|
|
3297
|
+
gist: Schema.String,
|
|
3298
|
+
workspace: Schema.NullOr(Schema.String),
|
|
3299
|
+
para: Schema.String,
|
|
3300
|
+
confidence: Finite,
|
|
3301
|
+
importance: Count,
|
|
3302
|
+
archived: Schema.Boolean,
|
|
3303
|
+
updated_at: Schema.String
|
|
3304
|
+
})),
|
|
3305
|
+
next_cursor: Schema.NullOr(Schema.String)
|
|
3306
|
+
})
|
|
3307
|
+
});
|
|
3308
|
+
const TraceSearch = Tool.make("trace_search", {
|
|
3309
|
+
description: "Find past Claude Code sessions by what was asked in them. A read-only index over transcript files: no session content is stored, only pointers and capped heads.",
|
|
3310
|
+
dependencies: READS(),
|
|
3311
|
+
parameters: Schema.Struct({
|
|
3312
|
+
query: Schema.String,
|
|
3313
|
+
cwd: Optional(Schema.String),
|
|
3314
|
+
since: Optional(Schema.String),
|
|
3315
|
+
limit: Optional(Count)
|
|
3316
|
+
}),
|
|
3317
|
+
failure: ToolFailure,
|
|
3318
|
+
success: Schema.Struct({ sessions: Schema.Array(Schema.Struct({
|
|
3319
|
+
session_id: Schema.String,
|
|
3320
|
+
slug: Schema.String,
|
|
3321
|
+
cwd: Schema.NullOr(Schema.String),
|
|
3322
|
+
started_at: Schema.NullOr(Schema.String),
|
|
3323
|
+
prompt_count: Count,
|
|
3324
|
+
first_prompt: Schema.String,
|
|
3325
|
+
ai_title: Schema.NullOr(Schema.String)
|
|
3326
|
+
})) })
|
|
3327
|
+
});
|
|
3328
|
+
const TraceLinks = Tool.make("trace_links", {
|
|
3329
|
+
description: "Which memories a session produced, or which sessions touched a memory. Needs a session_id or a path. Both absent is refused rather than returning every link ever recorded.",
|
|
3330
|
+
dependencies: READS(),
|
|
3331
|
+
parameters: Schema.Struct({
|
|
3332
|
+
session_id: Optional(Schema.String),
|
|
3333
|
+
path: Optional(MemoryPath)
|
|
3334
|
+
}),
|
|
3335
|
+
failure: ToolFailure,
|
|
3336
|
+
success: Schema.Struct({ links: Schema.Array(Schema.Struct({
|
|
3337
|
+
path: MemoryPath,
|
|
3338
|
+
session_id: Schema.String,
|
|
3339
|
+
prompt_id: Schema.NullOr(Schema.String),
|
|
3340
|
+
turn_uuid: Schema.NullOr(Schema.String),
|
|
3341
|
+
link_kind: Schema.String,
|
|
3342
|
+
at: Schema.String
|
|
3343
|
+
})) })
|
|
3344
|
+
});
|
|
3345
|
+
const MemoryStatus = Tool.make("memory_status", {
|
|
3346
|
+
description: "Corpus health in one call: HEAD, dirty state, counts by type, edge totals, whether the index describes the current commit, and when sleep last ran.",
|
|
3347
|
+
dependencies: [Store, DatabaseService],
|
|
3348
|
+
/**
|
|
3349
|
+
* `Tool.EmptyParams`, not `Schema.Struct({})`.
|
|
3350
|
+
*
|
|
3351
|
+
* Probed on effect 4.0.0-beta.102: an empty `Schema.Struct` derives
|
|
3352
|
+
* `{"anyOf":[{"type":"object"},{"type":"array"}]}`, a union with an ARRAY branch, because a struct
|
|
3353
|
+
* with no fields constrains nothing and the codec's encoded form admits both. A client reading that
|
|
3354
|
+
* cannot tell it should send `{}`, and a strict one may refuse to call the tool at all.
|
|
3355
|
+
* `Tool.EmptyParams` derives `{"type":"object","additionalProperties":false}`, which says exactly
|
|
3356
|
+
* "an object, and no fields", the intent.
|
|
3357
|
+
*/
|
|
3358
|
+
parameters: Tool.EmptyParams,
|
|
3359
|
+
failure: ToolFailure,
|
|
3360
|
+
success: Schema.Struct({
|
|
3361
|
+
head_sha: Schema.NullOr(Schema.String),
|
|
3362
|
+
dirty: Schema.Boolean,
|
|
3363
|
+
counts_by_type: Schema.Record(Schema.String, Count),
|
|
3364
|
+
archived_count: Count,
|
|
3365
|
+
edges: Count,
|
|
3366
|
+
/** True when the index's watermark IS the current HEAD. A row count cannot answer this. */
|
|
3367
|
+
index_fresh: Schema.Boolean,
|
|
3368
|
+
embedder_up: Schema.Boolean,
|
|
3369
|
+
last_sleep: Schema.NullOr(Schema.Struct({
|
|
3370
|
+
run_id: Schema.String,
|
|
3371
|
+
status: Schema.String,
|
|
3372
|
+
started_at: Schema.String
|
|
3373
|
+
}))
|
|
3374
|
+
})
|
|
3375
|
+
});
|
|
3376
|
+
/**
|
|
3377
|
+
* The toolkit. Exactly fourteen: design.md §8's thirteen plus `memory_write_batch`.
|
|
3378
|
+
*
|
|
3379
|
+
* Order is the read order of the table in §8, which is also roughly the order an agent needs them:
|
|
3380
|
+
* write and read, then the three retrieval shapes, then the graph operations, then the trace plane,
|
|
3381
|
+
* then status.
|
|
3382
|
+
*
|
|
3383
|
+
* The batch sits SECOND, directly after `memory_write`, rather than appended at the end. `tools/list`
|
|
3384
|
+
* publishes this order and an agent reads it top-down, so the tool `memory_write`'s own description
|
|
3385
|
+
* points at is the very next entry. A pointer whose target is thirteen tools away is one an agent
|
|
3386
|
+
* reads after it has already decided how to write.
|
|
3387
|
+
*/
|
|
3388
|
+
const MemhtmlToolkit = Toolkit.make(MemoryWrite, MemoryWriteBatch, MemoryRead, MemorySearch, MemoryRecall, MemoryCorrect, MemoryLink, MemoryNeighbors, MemoryArchive, MemoryReinforce, MemoryList, TraceSearch, TraceLinks, MemoryStatus);
|
|
3389
|
+
/**
|
|
3390
|
+
* The tool names, derived from the toolkit rather than restated.
|
|
3391
|
+
*
|
|
3392
|
+
* Two lists would drift: a placeholder list that once said fourteen names and a toolkit that now
|
|
3393
|
+
* builds thirteen would leave a test asserting the list and proving nothing about the server.
|
|
3394
|
+
*/
|
|
3395
|
+
const TOOL_NAMES = Object.keys(MemhtmlToolkit.tools);
|
|
3396
|
+
|
|
3397
|
+
//#endregion
|
|
3398
|
+
//#region apps/mcp/src/handlers.ts
|
|
3399
|
+
/**
|
|
3400
|
+
* Every handler's error translation, applied once.
|
|
3401
|
+
*
|
|
3402
|
+
* MCP has one error channel and it is prose, so a typed error's STRUCTURE cannot survive the
|
|
3403
|
+
* boundary. Everything a caller acts on can, folded into the one string the protocol carries:
|
|
3404
|
+
* `toToolFailure` composes the stable code, the reason with its actionable payload fields, and
|
|
3405
|
+
* suggestions phrased as tool calls this agent can make. Nothing leaks a driver message, a git argv,
|
|
3406
|
+
* or a memory body, because the reason is `messageFor`'s and each error class dropped those at its
|
|
3407
|
+
* adapter edge precisely so a tool response could not carry corpus content.
|
|
3408
|
+
*
|
|
3409
|
+
* This used to build an `AiError`, which was the bug. `McpServer` catches `AiError` FIRST and
|
|
3410
|
+
* rewrites it to a generic internal-error sentence unless its reason is a parameter-validation error
|
|
3411
|
+
* (`McpServer.ts:831-838`), so every typed failure this server produced reached its agent with the
|
|
3412
|
+
* content removed. A `ToolFailure` is what each tool's `failure:` schema declares, which puts it on the
|
|
3413
|
+
* branch that passes `.message` through verbatim. The two halves only work together: dropping the
|
|
3414
|
+
* declaration in `tools.ts` re-masks everything this function builds, and the wire test in
|
|
3415
|
+
* `tests-integration` is what holds that pair honest.
|
|
3416
|
+
*
|
|
3417
|
+
* The error type is now `ToolFailure` for every handler, and `kit.toLayer` checks it, so a handler
|
|
3418
|
+
* that failed with a raw domain error would be a compile error rather than a masked response. This is
|
|
3419
|
+
* the single place the wire failure is produced.
|
|
3420
|
+
*/
|
|
3421
|
+
const handled = (effect) => effect.pipe(Effect.mapError(toToolFailure));
|
|
3422
|
+
/**
|
|
3423
|
+
* The head metadata as a flat string record.
|
|
3424
|
+
*
|
|
3425
|
+
* Flattened rather than typed per key: the wire schema is `Record<string, string>` because the head's
|
|
3426
|
+
* optional metas are genuinely open at the edges. A format version can add `memhtml-*` names, and a
|
|
3427
|
+
* client that had to know the closed set would break on the first addition. Numbers are stringified
|
|
3428
|
+
* because that is what the `<meta content>` attribute holds; a consumer that wants the number reads
|
|
3429
|
+
* the typed field on `memory_search` or `memory_list` instead.
|
|
3430
|
+
*/
|
|
3431
|
+
const metaRecord = (doc) => {
|
|
3432
|
+
const out = {};
|
|
3433
|
+
for (const [key, value] of Object.entries(doc.metas)) {
|
|
3434
|
+
if (value === void 0) continue;
|
|
3435
|
+
out[key] = typeof value === "string" ? value : String(value);
|
|
3436
|
+
}
|
|
3437
|
+
for (const entity of doc.entities) out[`entity:${entity}`] = "true";
|
|
3438
|
+
for (const tag of doc.tags) out[`tag:${tag}`] = "true";
|
|
3439
|
+
return out;
|
|
3440
|
+
};
|
|
3441
|
+
/**
|
|
3442
|
+
* An explicit `null` as an absent value.
|
|
3443
|
+
*
|
|
3444
|
+
* The parameter schemas accept `null` as well as absence, and `Optional` in `tools.ts` says why: the
|
|
3445
|
+
* derived JSON Schema advertises `null`, and a client that reads the schema and sends
|
|
3446
|
+
* `{"workspace": null}` for "no workspace" is doing the documented thing. The operations layer speaks
|
|
3447
|
+
* `undefined` for "not supplied" because `exactOptionalPropertyTypes` distinguishes an absent key from
|
|
3448
|
+
* a present one, so the two vocabularies meet HERE, once, rather than at each of fourteen call sites.
|
|
3449
|
+
*/
|
|
3450
|
+
const opt = (value) => value ?? void 0;
|
|
3451
|
+
/** Absent optional array as an empty one, so a handler never passes `undefined` downstream. */
|
|
3452
|
+
const arr = (value) => value ?? [];
|
|
3453
|
+
const authored = (body, articleHtml) => {
|
|
3454
|
+
const prose = opt(body);
|
|
3455
|
+
const markup = opt(articleHtml);
|
|
3456
|
+
const hasProse = prose !== void 0 && prose.trim() !== "";
|
|
3457
|
+
const hasMarkup = markup !== void 0 && markup.trim() !== "";
|
|
3458
|
+
if (hasProse === hasMarkup) return Effect.fail(InvalidMemory.make({ reason: `exactly one of body or article_html is required, and ${hasProse ? "both were supplied" : "neither was supplied"}` }));
|
|
3459
|
+
return Effect.succeed(hasMarkup ? {
|
|
3460
|
+
claim: "",
|
|
3461
|
+
body: [],
|
|
3462
|
+
articleHtml: markup
|
|
3463
|
+
} : {
|
|
3464
|
+
claim: claimFromProse(prose),
|
|
3465
|
+
body: proseTail(prose),
|
|
3466
|
+
articleHtml: void 0
|
|
3467
|
+
});
|
|
3468
|
+
};
|
|
3469
|
+
/**
|
|
3470
|
+
* One op's wire-name-to-operation-name rename, given the article its XOR already resolved to.
|
|
3471
|
+
*
|
|
3472
|
+
* The same rename `memory_write`'s handler performs, over the same field list. The ops carry a whole
|
|
3473
|
+
* `memory_write` payload (D7), so a second spelling of this mapping would be the drift the shared
|
|
3474
|
+
* `writeFields` in `tools.ts` exists to make impossible on the schema side.
|
|
3475
|
+
*/
|
|
3476
|
+
const writeParamsOf = (op, article) => ({
|
|
3477
|
+
title: op.title,
|
|
3478
|
+
claim: article.claim,
|
|
3479
|
+
body: article.body,
|
|
3480
|
+
articleHtml: article.articleHtml,
|
|
3481
|
+
memoryType: op.memory_type,
|
|
3482
|
+
path: opt(op.path),
|
|
3483
|
+
workspace: opt(op.workspace),
|
|
3484
|
+
tags: arr(op.tags),
|
|
3485
|
+
entities: arr(op.entities),
|
|
3486
|
+
importance: opt(op.importance),
|
|
3487
|
+
confidence: opt(op.confidence),
|
|
3488
|
+
sessionId: opt(op.session_id),
|
|
3489
|
+
promptId: opt(op.prompt_id),
|
|
3490
|
+
turnUuid: opt(op.turn_uuid)
|
|
3491
|
+
});
|
|
3492
|
+
/**
|
|
3493
|
+
* An op's XOR refusal as that op's own report, through the SAME `codeFor`/`messageFor` pair
|
|
3494
|
+
* `operations.ts`'s `reportFailure` uses.
|
|
3495
|
+
*
|
|
3496
|
+
* Not a second mapping of the error: a per-op `code` is part of the batch payload's contract, and
|
|
3497
|
+
* `memhtml apply` and `memory_write_batch` reporting different codes for one refused op is exactly the
|
|
3498
|
+
* drift the shared-use-case rule exists to prevent. The XOR is the one refusal the operations layer
|
|
3499
|
+
* cannot produce, being a wire-vocabulary rule about two parameters that layer never sees, since
|
|
3500
|
+
* `WriteParams` takes an already-resolved `claim`/`body`/`articleHtml`. So this is the one place
|
|
3501
|
+
* a report is built outside `batchWrite`, and it is built with `batchWrite`'s own functions.
|
|
3502
|
+
*/
|
|
3503
|
+
const xorReport = (index, error) => ({
|
|
3504
|
+
index,
|
|
3505
|
+
ok: false,
|
|
3506
|
+
code: codeFor(error),
|
|
3507
|
+
error: messageFor(error)
|
|
3508
|
+
});
|
|
3509
|
+
/**
|
|
3510
|
+
* The first op that FAILED, as opposed to one that was skipped or deduped.
|
|
3511
|
+
*
|
|
3512
|
+
* `batchWrite`'s atomic abort reports the offending op with its code and every other op as `skipped`
|
|
3513
|
+
* (`operations.ts:545-548` for a decode refusal, `store.ts:693-705` for a render-gate one), so an
|
|
3514
|
+
* aborted batch is recognizable by exactly this: one report with `ok: false` and `skipped` unset. A
|
|
3515
|
+
* check on `summary.skipped > 0` alone would also match a batch that had nothing to abort.
|
|
3516
|
+
*/
|
|
3517
|
+
const firstFailure = (reports) => reports.find((report) => !report.ok && report.skipped !== true && report.code !== void 0);
|
|
3518
|
+
/** One op's report as the wire shape: every field present, absent ones as `null`. */
|
|
3519
|
+
const wireReport = (report) => ({
|
|
3520
|
+
index: report.index,
|
|
3521
|
+
ok: report.ok,
|
|
3522
|
+
path: report.path ?? null,
|
|
3523
|
+
deduped: report.deduped === true,
|
|
3524
|
+
existing_path: report.existingPath ?? null,
|
|
3525
|
+
code: report.code ?? null,
|
|
3526
|
+
error: report.error ?? null,
|
|
3527
|
+
skipped: report.skipped === true,
|
|
3528
|
+
/**
|
|
3529
|
+
* The conflict assist's finding, `batchIndex` renamed to `batch_index`. That rename is the whole of
|
|
3530
|
+
* the handlers' remaining job, applied one level deeper than usual because this is the first nested
|
|
3531
|
+
* struct on the batch's wire shape. `memhtml apply`'s own `opPayload` performs the same rename onto
|
|
3532
|
+
* the same names, so the two doors' payloads stay byte-comparable.
|
|
3533
|
+
*
|
|
3534
|
+
* A conflict says nothing about `ok`, `path`, or `skipped`, and this function is where that is
|
|
3535
|
+
* visible: nothing above changes when the field is populated.
|
|
3536
|
+
*/
|
|
3537
|
+
conflict: report.conflict === void 0 ? null : {
|
|
3538
|
+
path: report.conflict.path,
|
|
3539
|
+
batch_index: report.conflict.batchIndex,
|
|
3540
|
+
claim: report.conflict.claim
|
|
3541
|
+
},
|
|
3542
|
+
consolidated_into: report.consolidatedInto ?? null,
|
|
3543
|
+
superseded_path: report.supersededPath ?? null
|
|
3544
|
+
});
|
|
3545
|
+
/**
|
|
3546
|
+
* The batch's counts, over the merged report array.
|
|
3547
|
+
*
|
|
3548
|
+
* The same one-pass derivation `operations.ts`'s `summarize` performs, and it has to be re-derived here
|
|
3549
|
+
* rather than taken from `batchWrite` for one reason: in continue mode this handler's own XOR refusals
|
|
3550
|
+
* are reports `batchWrite` never saw, so its summary describes a SHORTER op list. Taking it would
|
|
3551
|
+
* publish `total` less than `results.length`, a summary a client cannot reconcile with the array it
|
|
3552
|
+
* came with. On the atomic path there are no such refusals and this returns `batchWrite`'s own numbers.
|
|
3553
|
+
*/
|
|
3554
|
+
const summarize = (results) => {
|
|
3555
|
+
let written = 0;
|
|
3556
|
+
let deduped = 0;
|
|
3557
|
+
let failed = 0;
|
|
3558
|
+
let skipped = 0;
|
|
3559
|
+
let consolidated = 0;
|
|
3560
|
+
for (const result of results) if (result.consolidatedInto !== void 0) consolidated += 1;
|
|
3561
|
+
else if (result.skipped === true) skipped += 1;
|
|
3562
|
+
else if (!result.ok) failed += 1;
|
|
3563
|
+
else if (result.deduped === true) deduped += 1;
|
|
3564
|
+
else written += 1;
|
|
3565
|
+
return {
|
|
3566
|
+
total: results.length,
|
|
3567
|
+
written,
|
|
3568
|
+
deduped,
|
|
3569
|
+
failed,
|
|
3570
|
+
skipped,
|
|
3571
|
+
consolidated
|
|
3572
|
+
};
|
|
3573
|
+
};
|
|
3574
|
+
/**
|
|
3575
|
+
* The handler layer for the toolkit.
|
|
3576
|
+
*
|
|
3577
|
+
* `kit.toLayer({ … })` is checked against the toolkit's own parameter and success schemas, so a
|
|
3578
|
+
* handler returning the wrong shape is a compile error rather than a decode failure on a live call.
|
|
3579
|
+
*/
|
|
3580
|
+
const ToolHandlers = MemhtmlToolkit.toLayer({
|
|
3581
|
+
memory_write: (params) => handled(Effect.gen(function* () {
|
|
3582
|
+
const article = yield* authored(params.body, params.article_html);
|
|
3583
|
+
const result = yield* writeMemory({
|
|
3584
|
+
title: params.title,
|
|
3585
|
+
claim: article.claim,
|
|
3586
|
+
body: article.body,
|
|
3587
|
+
articleHtml: article.articleHtml,
|
|
3588
|
+
memoryType: params.memory_type,
|
|
3589
|
+
path: opt(params.path),
|
|
3590
|
+
workspace: opt(params.workspace),
|
|
3591
|
+
tags: arr(params.tags),
|
|
3592
|
+
entities: arr(params.entities),
|
|
3593
|
+
importance: opt(params.importance),
|
|
3594
|
+
confidence: opt(params.confidence),
|
|
3595
|
+
sessionId: opt(params.session_id),
|
|
3596
|
+
promptId: opt(params.prompt_id),
|
|
3597
|
+
turnUuid: opt(params.turn_uuid)
|
|
3598
|
+
});
|
|
3599
|
+
return {
|
|
3600
|
+
path: result.path,
|
|
3601
|
+
created: result.created,
|
|
3602
|
+
deduped: result.deduped,
|
|
3603
|
+
existing_path: result.existingPath ?? null
|
|
3604
|
+
};
|
|
3605
|
+
})),
|
|
3606
|
+
/**
|
|
3607
|
+
* The batch: resolve every op's XOR, call `batchWrite` ONCE, report every op in input order.
|
|
3608
|
+
*
|
|
3609
|
+
* **The XOR runs per op, up front, before `batchWrite` is called at all.** It is the wire boundary's
|
|
3610
|
+
* only rule and it is a rule about two PARAMETERS. `WriteParams` takes an already-resolved
|
|
3611
|
+
* `claim`/`body`/`articleHtml`, so an op that supplied both is a call the operations layer has no way
|
|
3612
|
+
* to recognize. Resolving it here also means the store's phase-1 validation sees only ops that could
|
|
3613
|
+
* possibly be written, which is what keeps "the atomic abort happens before any file exists" true of
|
|
3614
|
+
* the XOR too.
|
|
3615
|
+
*
|
|
3616
|
+
* **Then the modes diverge, and each one matches `batchWrite`'s own semantics for the failure class
|
|
3617
|
+
* it already handles**, a malformed `memory_type`, which is likewise a per-op decode refusal:
|
|
3618
|
+
*
|
|
3619
|
+
* - CONTINUE: each XOR refusal becomes that op's failed report, ONLY the survivors go to
|
|
3620
|
+
* `batchWrite`, and the survivors' reports are spliced back at their ORIGINAL indices. `originOf`
|
|
3621
|
+
* is what makes that possible: `batchWrite` indexes results in the array it was handed, so a
|
|
3622
|
+
* survivor at position 0 of a two-op call may be op 0 or op 1 of a three-op one, and reporting its
|
|
3623
|
+
* own index would shift every later op by the number of refusals before it. The result is a
|
|
3624
|
+
* SUCCESS: every op is present in `results`, in input order, which is the contract D3 states and
|
|
3625
|
+
* the only shape an agent can index by.
|
|
3626
|
+
* - ATOMIC (the default): the first refused op aborts, and the abort reaches the agent through the
|
|
3627
|
+
* ERROR channel as `batchAbortFailure`. An XOR refusal short-circuits before `batchWrite` is
|
|
3628
|
+
* called at all, since an atomic batch with a refused op writes nothing by definition and the call
|
|
3629
|
+
* would be a round trip whose only outcome is the abort. A refusal `batchWrite` itself produced,
|
|
3630
|
+
* such as a malformed `memory_type` or an op the store's render gate refused, comes back as an
|
|
3631
|
+
* aborted RESULT, and is converted at the same seam.
|
|
3632
|
+
*
|
|
3633
|
+
* **That conversion is the one non-obvious thing here, and it was a real bug caught by a test.** An
|
|
3634
|
+
* aborted `batchWrite` returns a well-formed result: every op reported, one of them failed, the rest
|
|
3635
|
+
* `skipped`, `commitSha: null`. Returning it verbatim is a SUCCESS response for a call that wrote
|
|
3636
|
+
* nothing, so the XOR path (an error) and the render-gate path (a success) would be two channels for
|
|
3637
|
+
* one outcome, and `BATCH_GUIDANCE`'s promise that "the first refused op aborts the whole call … and
|
|
3638
|
+
* the failure names the offending op" would be false for every refusal the handler did not itself
|
|
3639
|
+
* detect. `firstFailure` finds the offending op in the returned reports and `batchAbortFailure`
|
|
3640
|
+
* composes the one message both paths use.
|
|
3641
|
+
*/
|
|
3642
|
+
memory_write_batch: (params) => handled(Effect.gen(function* () {
|
|
3643
|
+
const continueOnError = params.continue_on_error === true;
|
|
3644
|
+
const reports = params.ops.map(() => void 0);
|
|
3645
|
+
const survivors = [];
|
|
3646
|
+
/** Survivor position in what `batchWrite` was handed → this caller's own op index. */
|
|
3647
|
+
const originOf = [];
|
|
3648
|
+
for (const [index, op] of params.ops.entries()) {
|
|
3649
|
+
const article = yield* Effect.result(authored(op.body, op.article_html));
|
|
3650
|
+
if (article._tag === "Failure") {
|
|
3651
|
+
const report = xorReport(index, article.failure);
|
|
3652
|
+
/**
|
|
3653
|
+
* Composed rather than `Effect.fail(article.failure)`, which would reach the agent as the
|
|
3654
|
+
* singular's own message: it names the rule but not WHICH of twenty ops broke it, and says
|
|
3655
|
+
* nothing about `continue_on_error`.
|
|
3656
|
+
*/
|
|
3657
|
+
if (!continueOnError) return yield* Effect.fail(batchAbortFailure(index, report.code ?? "ERR_INVALID_MEMORY", report.error ?? ""));
|
|
3658
|
+
reports[index] = report;
|
|
3659
|
+
continue;
|
|
3660
|
+
}
|
|
3661
|
+
originOf.push(index);
|
|
3662
|
+
survivors.push(writeParamsOf(op, article.success));
|
|
3663
|
+
}
|
|
3664
|
+
const batch = yield* batchWrite({
|
|
3665
|
+
ops: survivors,
|
|
3666
|
+
continueOnError,
|
|
3667
|
+
/**
|
|
3668
|
+
* The flag reaches `batchWrite` unchanged, which is the only correct place for the assist to
|
|
3669
|
+
* live: `memhtml apply --detect-conflicts` gets the same findings from the same code, so the two
|
|
3670
|
+
* doors cannot disagree about what a conflict is.
|
|
3671
|
+
*
|
|
3672
|
+
* The survivors-only consequence is real and it is right. An op this handler already refused
|
|
3673
|
+
* for the XOR is not in `survivors`, so it gets no conflict report, and it also has no claim
|
|
3674
|
+
* to derive one FROM: on the both-supplied path there is no way to tell which of the two the
|
|
3675
|
+
* caller meant, and on the neither-supplied path there is no claim at all. A finding invented
|
|
3676
|
+
* for such an op would name a slot the caller never asserted.
|
|
3677
|
+
*/
|
|
3678
|
+
detectConflicts: params.detect_conflicts === true,
|
|
3679
|
+
...params.consolidate !== void 0 && params.consolidate !== null ? { consolidate: params.consolidate } : {},
|
|
3680
|
+
sessionId: opt(params.session_id),
|
|
3681
|
+
promptId: opt(params.prompt_id),
|
|
3682
|
+
turnUuid: opt(params.turn_uuid)
|
|
3683
|
+
});
|
|
3684
|
+
if (!continueOnError) {
|
|
3685
|
+
const failed = firstFailure(batch.results);
|
|
3686
|
+
if (failed !== void 0) return yield* Effect.fail(batchAbortFailure(originOf[failed.index] ?? failed.index, failed.code ?? "ERR_INVALID_MEMORY", failed.error ?? ""));
|
|
3687
|
+
}
|
|
3688
|
+
/**
|
|
3689
|
+
* Splice each survivor's report back at its ORIGINAL index, and translate the conflict's
|
|
3690
|
+
* `batchIndex` through the SAME map, which is the non-obvious half.
|
|
3691
|
+
*
|
|
3692
|
+
* `batchWrite` saw only `survivors`, so an intra-batch conflict it found names a position in
|
|
3693
|
+
* THAT array. In continue mode with an XOR-refused op before the conflicting pair, survivor 1
|
|
3694
|
+
* is the caller's op 2, so reporting the raw number would name a different op than the one the
|
|
3695
|
+
* assist actually matched, and it would name it plausibly enough that nobody would notice. The
|
|
3696
|
+
* outer `index` has always needed this translation for exactly the same reason; the conflict is
|
|
3697
|
+
* a second index in the same space and needs it too.
|
|
3698
|
+
*
|
|
3699
|
+
* `originOf[…] ?? conflict.batchIndex` mirrors the fallback three lines above rather than
|
|
3700
|
+
* dropping the conflict: an untranslatable index is impossible here (every survivor has an
|
|
3701
|
+
* origin, by construction of the loop that built both arrays), and if it somehow were not, a
|
|
3702
|
+
* caller is better served by a suspicious number than by a finding silently deleted.
|
|
3703
|
+
*/
|
|
3704
|
+
for (const report of batch.results) {
|
|
3705
|
+
const index = originOf[report.index];
|
|
3706
|
+
if (index === void 0) continue;
|
|
3707
|
+
const conflict = report.conflict;
|
|
3708
|
+
const translated = conflict === void 0 || conflict.batchIndex === null ? {
|
|
3709
|
+
...report,
|
|
3710
|
+
index
|
|
3711
|
+
} : {
|
|
3712
|
+
...report,
|
|
3713
|
+
index,
|
|
3714
|
+
conflict: {
|
|
3715
|
+
...conflict,
|
|
3716
|
+
batchIndex: originOf[conflict.batchIndex] ?? conflict.batchIndex
|
|
3717
|
+
}
|
|
3718
|
+
};
|
|
3719
|
+
reports[index] = translated.consolidatedInto === void 0 ? translated : {
|
|
3720
|
+
...translated,
|
|
3721
|
+
consolidatedInto: originOf[translated.consolidatedInto] ?? translated.consolidatedInto
|
|
3722
|
+
};
|
|
3723
|
+
}
|
|
3724
|
+
/**
|
|
3725
|
+
* An op with no report of its own was never reached. Unreachable on the atomic path, which has
|
|
3726
|
+
* already failed by here, so this is continue mode's own case: `skipped`, the same word
|
|
3727
|
+
* `batchWrite` uses, so the two doors describe one outcome in one vocabulary.
|
|
3728
|
+
*/
|
|
3729
|
+
const results = reports.map((report, index) => report ?? {
|
|
3730
|
+
index,
|
|
3731
|
+
ok: false,
|
|
3732
|
+
skipped: true
|
|
3733
|
+
});
|
|
3734
|
+
return {
|
|
3735
|
+
results: results.map(wireReport),
|
|
3736
|
+
summary: summarize(results),
|
|
3737
|
+
commit_sha: batch.commitSha
|
|
3738
|
+
};
|
|
3739
|
+
})),
|
|
3740
|
+
memory_read: (params) => handled(Effect.gen(function* () {
|
|
3741
|
+
const result = yield* readMemory(params.path, { sessionId: opt(params.session_id) });
|
|
3742
|
+
return {
|
|
3743
|
+
path: result.path,
|
|
3744
|
+
title: result.doc.title,
|
|
3745
|
+
body: result.doc.article.bodyText,
|
|
3746
|
+
gist: result.doc.article.gist,
|
|
3747
|
+
memory_type: result.doc.metas.memoryType,
|
|
3748
|
+
meta: metaRecord(result.doc),
|
|
3749
|
+
links: result.doc.links.map((link) => ({
|
|
3750
|
+
rel: link.rel,
|
|
3751
|
+
href: link.href
|
|
3752
|
+
})),
|
|
3753
|
+
archived: result.doc.metas.status === "archived",
|
|
3754
|
+
warnings: result.doc.warnings
|
|
3755
|
+
};
|
|
3756
|
+
})),
|
|
3757
|
+
memory_search: (params) => handled(Effect.gen(function* () {
|
|
3758
|
+
const result = yield* searchMemories({
|
|
3759
|
+
query: params.query,
|
|
3760
|
+
limit: opt(params.limit),
|
|
3761
|
+
memoryTypes: opt(params.memory_types),
|
|
3762
|
+
workspace: opt(params.workspace),
|
|
3763
|
+
tags: opt(params.tags),
|
|
3764
|
+
entity: opt(params.entity),
|
|
3765
|
+
includeArchived: opt(params.include_archived),
|
|
3766
|
+
asOf: opt(params.as_of)
|
|
3767
|
+
});
|
|
3768
|
+
return {
|
|
3769
|
+
hits: result.hits.map((hit) => ({
|
|
3770
|
+
path: hit.path,
|
|
3771
|
+
title: hit.title,
|
|
3772
|
+
gist: hit.gist,
|
|
3773
|
+
memory_type: hit.memoryType,
|
|
3774
|
+
score: hit.score,
|
|
3775
|
+
confidence: hit.confidence,
|
|
3776
|
+
updated_at: hit.updatedAt,
|
|
3777
|
+
snippet: hit.snippet,
|
|
3778
|
+
entities: hit.entities,
|
|
3779
|
+
superseded_by: hit.supersededBy
|
|
3780
|
+
})),
|
|
3781
|
+
degraded: result.degraded,
|
|
3782
|
+
arms: result.arms,
|
|
3783
|
+
entity_scope: result.entityScope,
|
|
3784
|
+
scope_empty: result.scopeEmpty
|
|
3785
|
+
};
|
|
3786
|
+
})),
|
|
3787
|
+
memory_recall: (params) => handled(Effect.gen(function* () {
|
|
3788
|
+
const pack = yield* recallMemories({
|
|
3789
|
+
query: params.query,
|
|
3790
|
+
budgetChars: opt(params.budget_chars),
|
|
3791
|
+
workspace: opt(params.workspace)
|
|
3792
|
+
});
|
|
3793
|
+
/**
|
|
3794
|
+
* `lateral` is the union of both folds' index lines.
|
|
3795
|
+
*
|
|
3796
|
+
* It holds what did not fit the budget, surfaced with its claim and its path so an agent
|
|
3797
|
+
* can drill down deliberately, and it is not a third retrieval arm. Dropping it would make
|
|
3798
|
+
* a truncated pack indistinguishable from a small corpus.
|
|
3799
|
+
*/
|
|
3800
|
+
return {
|
|
3801
|
+
sections: {
|
|
3802
|
+
arcs: pack.arcs.disclosed.map((entry) => ({
|
|
3803
|
+
path: entry.path,
|
|
3804
|
+
title: entry.title,
|
|
3805
|
+
gist: entry.gist,
|
|
3806
|
+
body: entry.body
|
|
3807
|
+
})),
|
|
3808
|
+
memories: pack.memories.disclosed.map((entry) => ({
|
|
3809
|
+
path: entry.path,
|
|
3810
|
+
title: entry.title,
|
|
3811
|
+
gist: entry.gist,
|
|
3812
|
+
body: entry.body
|
|
3813
|
+
})),
|
|
3814
|
+
lateral: [...pack.arcs.indexLines, ...pack.memories.indexLines].map((line) => ({
|
|
3815
|
+
path: line.path,
|
|
3816
|
+
title: line.title,
|
|
3817
|
+
gist: line.gist
|
|
3818
|
+
}))
|
|
3819
|
+
},
|
|
3820
|
+
spent_chars: pack.spentChars,
|
|
3821
|
+
truncated: pack.truncated,
|
|
3822
|
+
degraded: pack.degraded
|
|
3823
|
+
};
|
|
3824
|
+
})),
|
|
3825
|
+
memory_correct: (params) => handled(Effect.gen(function* () {
|
|
3826
|
+
const article = yield* authored(params.body, params.article_html);
|
|
3827
|
+
const result = yield* correctMemory({
|
|
3828
|
+
targetPath: params.target_path,
|
|
3829
|
+
title: params.title,
|
|
3830
|
+
claim: article.claim,
|
|
3831
|
+
body: article.body,
|
|
3832
|
+
articleHtml: article.articleHtml,
|
|
3833
|
+
reason: params.reason,
|
|
3834
|
+
sessionId: opt(params.session_id)
|
|
3835
|
+
});
|
|
3836
|
+
/**
|
|
3837
|
+
* `superseded` names the target's ARCHIVE path, which is where the file is once the commit
|
|
3838
|
+
* lands, and it is what the new file's `memhtml-supersedes` link points at. Reporting the
|
|
3839
|
+
* pre-archive path would hand back a path with no file behind it.
|
|
3840
|
+
*/
|
|
3841
|
+
return {
|
|
3842
|
+
path: result.path,
|
|
3843
|
+
superseded: [result.archivedPath],
|
|
3844
|
+
archived: [result.archivedPath]
|
|
3845
|
+
};
|
|
3846
|
+
})),
|
|
3847
|
+
memory_link: (params) => handled(Effect.gen(function* () {
|
|
3848
|
+
const result = yield* linkMemories(params.src_path, params.rel, params.dst_path);
|
|
3849
|
+
return {
|
|
3850
|
+
ok: true,
|
|
3851
|
+
rel: result.rel,
|
|
3852
|
+
src_path: result.srcPath,
|
|
3853
|
+
dst_path: result.dstPath
|
|
3854
|
+
};
|
|
3855
|
+
})),
|
|
3856
|
+
memory_neighbors: (params) => handled(Effect.gen(function* () {
|
|
3857
|
+
const result = yield* neighborsOf({
|
|
3858
|
+
path: params.path,
|
|
3859
|
+
depth: opt(params.depth),
|
|
3860
|
+
rels: opt(params.rels)
|
|
3861
|
+
});
|
|
3862
|
+
return {
|
|
3863
|
+
nodes: result.nodes,
|
|
3864
|
+
edges: result.edges
|
|
3865
|
+
};
|
|
3866
|
+
})),
|
|
3867
|
+
memory_archive: (params) => handled(Effect.gen(function* () {
|
|
3868
|
+
const result = yield* archiveMemory(params.path, params.reason);
|
|
3869
|
+
return {
|
|
3870
|
+
path: result.path,
|
|
3871
|
+
archive_path: result.archivePath
|
|
3872
|
+
};
|
|
3873
|
+
})),
|
|
3874
|
+
memory_reinforce: (params) => handled(Effect.gen(function* () {
|
|
3875
|
+
const result = yield* reinforceMemories(params.paths, params.signal);
|
|
3876
|
+
return {
|
|
3877
|
+
bumped: result.bumped,
|
|
3878
|
+
cooled_down: result.cooledDown
|
|
3879
|
+
};
|
|
3880
|
+
})),
|
|
3881
|
+
memory_list: (params) => handled(Effect.gen(function* () {
|
|
3882
|
+
const result = yield* listMemories({
|
|
3883
|
+
memoryType: opt(params.memory_type),
|
|
3884
|
+
workspace: opt(params.workspace),
|
|
3885
|
+
tag: opt(params.tag),
|
|
3886
|
+
entity: opt(params.entity),
|
|
3887
|
+
para: opt(params.para),
|
|
3888
|
+
limit: opt(params.limit),
|
|
3889
|
+
cursor: opt(params.cursor)
|
|
3890
|
+
});
|
|
3891
|
+
return {
|
|
3892
|
+
files: result.files.map((file) => ({
|
|
3893
|
+
path: file.path,
|
|
3894
|
+
title: file.title,
|
|
3895
|
+
memory_type: file.memoryType,
|
|
3896
|
+
gist: file.gist,
|
|
3897
|
+
workspace: file.workspace,
|
|
3898
|
+
para: file.para,
|
|
3899
|
+
confidence: file.confidence,
|
|
3900
|
+
importance: file.importance,
|
|
3901
|
+
archived: file.archived,
|
|
3902
|
+
updated_at: file.updatedAt
|
|
3903
|
+
})),
|
|
3904
|
+
next_cursor: result.nextCursor
|
|
3905
|
+
};
|
|
3906
|
+
})),
|
|
3907
|
+
trace_search: (params) => handled(Effect.gen(function* () {
|
|
3908
|
+
return { sessions: (yield* searchTraces({
|
|
3909
|
+
query: params.query,
|
|
3910
|
+
cwd: opt(params.cwd),
|
|
3911
|
+
since: opt(params.since),
|
|
3912
|
+
limit: opt(params.limit)
|
|
3913
|
+
})).sessions.map((session) => ({
|
|
3914
|
+
session_id: session.sessionId,
|
|
3915
|
+
slug: session.slug,
|
|
3916
|
+
cwd: session.cwd,
|
|
3917
|
+
started_at: session.startedAt,
|
|
3918
|
+
prompt_count: session.promptCount,
|
|
3919
|
+
first_prompt: session.firstPrompt,
|
|
3920
|
+
ai_title: session.aiTitle
|
|
3921
|
+
})) };
|
|
3922
|
+
})),
|
|
3923
|
+
trace_links: (params) => handled(Effect.gen(function* () {
|
|
3924
|
+
return { links: (yield* traceLinks({
|
|
3925
|
+
sessionId: opt(params.session_id),
|
|
3926
|
+
path: opt(params.path)
|
|
3927
|
+
})).links.map((link) => ({
|
|
3928
|
+
path: link.path,
|
|
3929
|
+
session_id: link.sessionId,
|
|
3930
|
+
prompt_id: link.promptId,
|
|
3931
|
+
turn_uuid: link.turnUuid,
|
|
3932
|
+
link_kind: link.linkKind,
|
|
3933
|
+
at: link.at
|
|
3934
|
+
})) };
|
|
3935
|
+
})),
|
|
3936
|
+
memory_status: () => handled(Effect.gen(function* () {
|
|
3937
|
+
const report = yield* statusReport();
|
|
3938
|
+
return {
|
|
3939
|
+
head_sha: report.headSha,
|
|
3940
|
+
dirty: report.dirty,
|
|
3941
|
+
counts_by_type: report.countsByType,
|
|
3942
|
+
archived_count: report.archivedCount,
|
|
3943
|
+
edges: report.edges,
|
|
3944
|
+
index_fresh: report.indexFresh,
|
|
3945
|
+
embedder_up: report.embedderUp,
|
|
3946
|
+
last_sleep: report.lastSleep === null ? null : {
|
|
3947
|
+
run_id: report.lastSleep.runId,
|
|
3948
|
+
status: report.lastSleep.status,
|
|
3949
|
+
started_at: report.lastSleep.startedAt
|
|
3950
|
+
}
|
|
3951
|
+
};
|
|
3952
|
+
}))
|
|
3953
|
+
});
|
|
3954
|
+
|
|
3955
|
+
//#endregion
|
|
3956
|
+
//#region apps/mcp/src/resources.ts
|
|
3957
|
+
/**
|
|
3958
|
+
* The two resources, design.md §8.
|
|
3959
|
+
*
|
|
3960
|
+
* A resource is for CITATION-grade drill-down: a client that got a path from `memory_search` can
|
|
3961
|
+
* fetch `memhtml://file/<path>` and show a human the file behind an answer, without spending a tool call
|
|
3962
|
+
* and without the tool response having had to carry the whole body.
|
|
3963
|
+
*
|
|
3964
|
+
* `McpSchema.param` names each template parameter, so `tools/list`'s sibling `resources/templates`
|
|
3965
|
+
* publishes `{path}` and `{run-id}` as named rather than positional holes.
|
|
3966
|
+
*/
|
|
3967
|
+
/** `memhtml://file/{path}`: one memory's rendered content. */
|
|
3968
|
+
const pathParam = McpSchema.param("path", Schema.String);
|
|
3969
|
+
/** `memhtml://sleep/{run-id}`: one sleep run's committed HTML report. */
|
|
3970
|
+
const runIdParam = McpSchema.param("run-id", Schema.String);
|
|
3971
|
+
/**
|
|
3972
|
+
* A memory file, by path.
|
|
3973
|
+
*
|
|
3974
|
+
* The BODY is returned, not the raw HTML file. A client asking a resource for a citation wants the
|
|
3975
|
+
* text a human reads; the markup is the storage format, and handing back a full document with a head
|
|
3976
|
+
* full of `memhtml-*` metas would spend a client's rendering budget on bookkeeping. The metadata is
|
|
3977
|
+
* available through `memory_read`, which is the tool for exactly that.
|
|
3978
|
+
*
|
|
3979
|
+
* A missing path fails the read rather than answering with an empty resource: a citation that
|
|
3980
|
+
* silently resolves to nothing is worse than one that says the file is gone.
|
|
3981
|
+
*
|
|
3982
|
+
* This read BUMPS salience, through the same `readMemory` the `memory_read` tool calls. The bump is
|
|
3983
|
+
* deliberate: the caller named one specific path, which is a chosen open. A client
|
|
3984
|
+
* fetching the file behind an answer is making the same statement an agent makes with `memory_read`,
|
|
3985
|
+
* and the plane should not be able to tell them apart.
|
|
3986
|
+
*/
|
|
3987
|
+
const FileResource = McpServer.resource`memhtml://file/${pathParam}`({
|
|
3988
|
+
name: "Memory file",
|
|
3989
|
+
description: "One memory's title, claim, and body text, by repo-root-relative path. For showing a human the file behind an answer.",
|
|
3990
|
+
mimeType: "text/plain",
|
|
3991
|
+
content: (_uri, path) => Effect.gen(function* () {
|
|
3992
|
+
const result = yield* readMemory(path);
|
|
3993
|
+
return [
|
|
3994
|
+
`# ${result.doc.title}`,
|
|
3995
|
+
"",
|
|
3996
|
+
result.doc.article.gist,
|
|
3997
|
+
"",
|
|
3998
|
+
result.doc.article.bodyText
|
|
3999
|
+
].join("\n");
|
|
4000
|
+
}).pipe(Effect.orDie)
|
|
4001
|
+
});
|
|
4002
|
+
/**
|
|
4003
|
+
* A sleep run's report, by run id.
|
|
4004
|
+
*
|
|
4005
|
+
* The report is a COMMITTED file under `.memhtml/sleep/`, so this resource reads the tree rather than the
|
|
4006
|
+
* database: the report is the durable artifact of a run and the `sleep_runs` row is reporting
|
|
4007
|
+
* convenience. A run id arrives as `sleep/2026-08-02`, and the file is named for its last segment.
|
|
4008
|
+
*/
|
|
4009
|
+
const SleepResource = McpServer.resource`memhtml://sleep/${runIdParam}`({
|
|
4010
|
+
name: "Sleep run report",
|
|
4011
|
+
description: "One sleep run's committed HTML report: per-phase counts, commits, and what the run changed.",
|
|
4012
|
+
mimeType: "text/html",
|
|
4013
|
+
content: (_uri, runId) => Effect.gen(function* () {
|
|
4014
|
+
const roots = yield* Roots;
|
|
4015
|
+
const name = runId.split("/").at(-1) ?? runId;
|
|
4016
|
+
const path = join(roots.memhtmlRoot, SLEEP_REPORTS_DIR, `${name}.html`);
|
|
4017
|
+
return yield* Effect.tryPromise({
|
|
4018
|
+
try: () => readFile(path, "utf8"),
|
|
4019
|
+
catch: (cause) => cause
|
|
4020
|
+
});
|
|
4021
|
+
}).pipe(Effect.orDie)
|
|
4022
|
+
});
|
|
4023
|
+
/** Both resources as one layer, for the server to provide. */
|
|
4024
|
+
const Resources = Layer.mergeAll(FileResource, SleepResource);
|
|
4025
|
+
|
|
4026
|
+
//#endregion
|
|
4027
|
+
//#region apps/mcp/src/server.ts
|
|
4028
|
+
const SERVER_NAME = "memhtml";
|
|
4029
|
+
const SERVER_VERSION = "0.1.0";
|
|
4030
|
+
/**
|
|
4031
|
+
* The server as one layer: fourteen tools, two resources, over the CLI's own `AppLive`.
|
|
4032
|
+
*
|
|
4033
|
+
* The same composition the CLI builds, deliberately. An MCP server with its own layer graph would be
|
|
4034
|
+
* a second set of answers to which database file, which git root, and which vector space. An agent
|
|
4035
|
+
* whose `memory_write` landed in one repo while its operator's `memhtml search` read another would
|
|
4036
|
+
* be very hard to diagnose from either side.
|
|
4037
|
+
*
|
|
4038
|
+
* **`Logger.LogToStderr` is required here.** Effect's default logger writes to stdout, and stdout
|
|
4039
|
+
* on this transport is the NDJSON-RPC stream, so one log line would corrupt the frame a client is
|
|
4040
|
+
* mid-parse on. The CLI sets the same reference for the same reason, one fd over.
|
|
4041
|
+
*
|
|
4042
|
+
* **There is no server-level `instructions` here, because effect provides no way to set one.** MCP
|
|
4043
|
+
* defines an `instructions` field on the initialize response for exactly the cross-tool guidance
|
|
4044
|
+
* this server wants to give (when to batch, the three doors, the commit duty), and effect does not
|
|
4045
|
+
* emit it. Verified against 4.0.0-beta.107 in the dependency's own declarations: `McpSchema`
|
|
4046
|
+
* DECLARES `instructions: optional(Schema.String)` on the initialize result, while `layerStdio`'s
|
|
4047
|
+
* options are `{name, version, protocols, extensions}`, so there is not even an argument to pass,
|
|
4048
|
+
* and the handler that builds the result supplies none.
|
|
4049
|
+
*
|
|
4050
|
+
* **TOOL DESCRIPTIONS are this server's only guidance channel.** That consequence is recorded here,
|
|
4051
|
+
* beside the field a maintainer would come looking for, rather than in a doc. It is why
|
|
4052
|
+
* `BATCH_GUIDANCE` and `ARTICLE_HTML_CONTRACT` in `tools.ts` are shared constants appended to every
|
|
4053
|
+
* description they apply to, and why they read as prose to an agent rather than as reference notes to a
|
|
4054
|
+
* maintainer. Do not patch the dependency; revisit this when effect wires the field, at which point the
|
|
4055
|
+
* duplicated prose can move up here.
|
|
4056
|
+
*/
|
|
4057
|
+
const layerServer = (repoOverride) => Layer.mergeAll(McpServer.toolkit(MemhtmlToolkit), Resources).pipe(Layer.provide(ToolHandlers), Layer.provide(McpServer.layerStdio({
|
|
4058
|
+
name: SERVER_NAME,
|
|
4059
|
+
version: SERVER_VERSION,
|
|
4060
|
+
protocols: [McpProtocol.v2025_06_18]
|
|
4061
|
+
})), Layer.provide(layerApp(repoOverride)), Layer.provide(Layer.succeed(Logger.LogToStderr)(true)));
|
|
4062
|
+
|
|
4063
|
+
//#endregion
|
|
4064
|
+
//#region apps/mcp/src/bin.ts
|
|
4065
|
+
/**
|
|
4066
|
+
* The stdio entry point. stdout belongs to the MCP framing from here on: every log in the graph is
|
|
4067
|
+
* already routed to stderr by `layerServer`, and nothing in this file writes.
|
|
4068
|
+
*
|
|
4069
|
+
* `Layer.launch` runs the server for the process's lifetime rather than building the layer and
|
|
4070
|
+
* returning. The transport IS the program, and a built-then-released layer would close stdin out
|
|
4071
|
+
* from under the client mid-session.
|
|
4072
|
+
*/
|
|
4073
|
+
Layer.launch(layerServer().pipe(Layer.provide(NodeStdio.layer))).pipe(NodeRuntime.runMain);
|
|
4074
|
+
|
|
4075
|
+
//#endregion
|
|
4076
|
+
export { };
|
|
4077
|
+
//# sourceMappingURL=memhtml-mcp.mjs.map
|