memhtml 0.5.1 → 0.7.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/dist/memhtml.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { $ as STATE_DB_PATH, A as IndexRecorder, B as IndexGit, C as ModelClient, D as EmbeddingsLive, E as Embeddings, F as makeRetrieval, G as MIGRATIONS_DIR, H as sanitizeFtsQuery, I as reinforce, J as Store, K as STATE_MIGRATIONS_DIR, L as Indexer, M as persistScanned, N as readWatermark, O as EMBED_DIM, P as Retrieval, R as makeIndexer, S as runDiscrimination, T as wrapAsData, U as DatabaseService, V as makeGitPort, W as makeDatabase, X as makeStore, Y as expandRoot, Z as INDEX_DB_PATH, _ as meta, a as parseSidecar, at as makeGit, b as isSleepPhase, c as generateArtifacts, ct as setMeta, d as allPaths, dt as fenceOpeningOf, et as STATE_SIDECAR_PATH, f as danglingEdges, ft as REINFORCE_SIGNALS, g as link, h as hrefFor, i as makeSleep, it as Git, j as makeIndexRecorder, k as EMBED_WATERMARK, l as DETECTION_PREFIX, lt as isValidDatetime, m as applyHeadEdits, n as scanTraceRoot, nt as initRepo, o as renderSidecar, ot as commitSubject, p as publishRows, pt as frameKeyOf, q as STATE_SCHEMA, r as Sleep, rt as readFileOrNull, s as archivedFormOf, st as checkMemory, t as mergeTailExtract, tt as attemptIo, u as accessRows, ut as closesFence, v as unlink, w as ModelClientLive, x as discriminationGate, y as SLEEP_PHASES, z as readIndexState } from "./dist-DgKlozi6.mjs";
3
- import { $ as TASK_RELS, Dt as isTaskStatus, Et as WRITABLE_MEMORY_TYPES, G as InvalidMemory, Q as MEMORY_RELS, R as hasConsolidatorCredentials, Y as StorageFailure, a as makeConsolidator, et as isEdgeRel, lt as TASKS_SUBDIR, mt as normalizePath, ot as INBOX_DIR, q as ModelUnavailable, tt as relClassFor, wt as TASK_STATUSES } from "./dist-BCsav-EP.mjs";
2
+ import { $ as expandRoot, A as EMBED_DIM, B as reinforce, C as discriminationGate, D as wrapAsData, E as ModelClientLive, F as readWatermark, G as makeGitPort, H as makeIndexer, I as Retrieval, J as makeDatabase, K as sanitizeFtsQuery, L as makeRetrieval, M as IndexRecorder, N as makeIndexRecorder, O as Embeddings, P as persistScanned, Q as Store, R as facetConditions, S as DiscriminationFailed, T as ModelClient, U as readIndexState, V as Indexer, W as IndexGit, X as STATE_MIGRATIONS_DIR, Y as MIGRATIONS_DIR, Z as STATE_SCHEMA, _ as link, _t as REINFORCE_SIGNALS, a as parseSidecar, at as attemptIo, b as SLEEP_PHASES, c as archivedFormOf, ct as Git, d as accessRows, dt as checkMemory, et as makeStore, f as allPaths, g as hrefFor, gt as fenceOpeningOf, h as applyHeadEdits, ht as closesFence, i as makeSleep, it as STATE_SIDECAR_PATH, j as EMBED_WATERMARK, k as EmbeddingsLive, l as generateArtifacts, lt as makeGit, m as publishRows, mt as isValidDatetime, n as scanTraceRoot, o as renderSidecar, ot as initRepo, p as danglingEdges, pt as setMeta, q as DatabaseService, r as Sleep, rt as STATE_DB_PATH, st as readFileOrNull, t as mergeTailExtract, tt as INDEX_DB_PATH, u as DETECTION_PREFIX, ut as commitSubject, v as meta, vt as frameKeyOf, w as runDiscrimination, x as isSleepPhase, y as unlink, z as parseFacetFilters } from "./dist-D1wH0oJ0.mjs";
3
+ import { It as WRITABLE_MEMORY_TYPES, Lt as isTaskStatus, Pt as TASK_STATUSES, W as hasConsolidatorCredentials, ct as TASK_RELS, et as InvalidMemory, ht as INBOX_DIR, i as makeConsolidator, it as StorageFailure, lt as isEdgeRel, nt as ModelUnavailable, st as MEMORY_RELS, ut as relClassFor, vt as TASKS_SUBDIR, wt as normalizePath } from "./dist-DHFdTnlp.mjs";
4
4
  import { createRequire } from "node:module";
5
5
  import { Config, Context, Effect, Layer, Logger } from "effect";
6
6
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
@@ -9,6 +9,170 @@ import { dirname, join, resolve } from "node:path";
9
9
  import { spawn } from "node:child_process";
10
10
  import { fileURLToPath } from "node:url";
11
11
 
12
+ //#region apps/cli/src/extraction.ts
13
+ /**
14
+ * GPT-5.6 Luna, the fast high-volume model on the mantle endpoint. A constant rather than config
15
+ * because the schema below is tested against this model's strict-mode behavior. Changing the model
16
+ * is a code change with a test run, not an env var.
17
+ */
18
+ const EXTRACTION_MODEL_ID = "openai.gpt-5.6-luna";
19
+ /**
20
+ * The strict output schema. `additionalProperties: false` and `required` on every level because
21
+ * the Responses API's `strict: true` demands both, and a lax schema invites the model to answer
22
+ * with prose keys the parser would then be guessing at.
23
+ */
24
+ const RESPONSE_SCHEMA = {
25
+ type: "object",
26
+ properties: { items: {
27
+ type: "array",
28
+ items: {
29
+ type: "object",
30
+ properties: {
31
+ index: { type: "integer" },
32
+ entities: {
33
+ type: "array",
34
+ items: {
35
+ type: "object",
36
+ properties: {
37
+ type: {
38
+ type: "string",
39
+ enum: [...[
40
+ "person",
41
+ "org",
42
+ "service",
43
+ "place",
44
+ "work",
45
+ "concept",
46
+ "event"
47
+ ]]
48
+ },
49
+ name: { type: "string" }
50
+ },
51
+ required: ["type", "name"],
52
+ additionalProperties: false
53
+ }
54
+ }
55
+ },
56
+ required: ["index", "entities"],
57
+ additionalProperties: false
58
+ }
59
+ } },
60
+ required: ["items"],
61
+ additionalProperties: false
62
+ };
63
+ 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.";
64
+ /** The request body for one batch. Exported for the wire test, where the schema is the contract. */
65
+ const requestBodyOf = (modelId, items) => JSON.stringify({
66
+ model: modelId,
67
+ instructions: INSTRUCTIONS,
68
+ input: wrapAsData("memories", JSON.stringify(items.map((item, index) => ({
69
+ index,
70
+ title: item.title,
71
+ text: item.text
72
+ })))),
73
+ text: { format: {
74
+ type: "json_schema",
75
+ name: "entities",
76
+ strict: true,
77
+ schema: RESPONSE_SCHEMA
78
+ } }
79
+ });
80
+ /**
81
+ * Decode one Responses-API payload into index-aligned `type:name` arrays.
82
+ *
83
+ * Total over unknown input: every malformed shape returns `undefined` and the caller maps that to
84
+ * `ModelUnavailable`. A payload this code cannot read carries no answer, and treating it as
85
+ * "no entities" would record a model failure as a fact about the corpus.
86
+ */
87
+ const entitiesOf = (payload, expected) => {
88
+ const text = outputTextOf(payload);
89
+ if (text === void 0) return void 0;
90
+ let parsed;
91
+ try {
92
+ parsed = JSON.parse(text);
93
+ } catch {
94
+ return;
95
+ }
96
+ const items = parsed.items;
97
+ if (!Array.isArray(items)) return void 0;
98
+ const results = Array.from({ length: expected }, () => []);
99
+ for (const item of items) {
100
+ const index = item.index;
101
+ const entities = item.entities;
102
+ if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index >= expected) continue;
103
+ if (!Array.isArray(entities)) continue;
104
+ results[index] = entities.flatMap((entity) => {
105
+ const type = entity.type;
106
+ const name = entity.name;
107
+ if (typeof type !== "string" || typeof name !== "string") return [];
108
+ const trimmedName = name.trim();
109
+ return trimmedName === "" ? [] : [`${type}:${trimmedName}`];
110
+ });
111
+ }
112
+ return results;
113
+ };
114
+ /** The assistant message text out of a Responses payload, or `undefined` off-shape. */
115
+ const outputTextOf = (payload) => {
116
+ const output = payload.output;
117
+ if (!Array.isArray(output)) return void 0;
118
+ for (const entry of output) {
119
+ if (entry.type !== "message") continue;
120
+ const content = entry.content;
121
+ if (!Array.isArray(content)) continue;
122
+ for (const part of content) {
123
+ const text = part.text;
124
+ if (part.type === "output_text" && typeof text === "string") return text;
125
+ }
126
+ }
127
+ };
128
+ /**
129
+ * Per-call ceiling. Generous against the probed ~1s because a batch of 256 ops is a bigger
130
+ * prompt than the probe's one sentence, and a late abort costs only this batch's entities. The
131
+ * write itself is unaffected.
132
+ */
133
+ const EXTRACT_TIMEOUT_MS = 6e4;
134
+ /** The extractor over a transport. The transport owns the endpoint; this owns prompt and parse. */
135
+ const makeEntityExtractor = (transport, modelId) => ({ extract: (items) => items.length === 0 ? Effect.succeed([]) : Effect.gen(function* () {
136
+ const payload = yield* Effect.tryPromise({
137
+ try: (signal) => {
138
+ const timeout = AbortSignal.timeout(EXTRACT_TIMEOUT_MS);
139
+ return transport.post(requestBodyOf(modelId, items), AbortSignal.any([signal, timeout]));
140
+ },
141
+ catch: (cause) => ModelUnavailable.make({
142
+ modelId,
143
+ reason: cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause)
144
+ })
145
+ });
146
+ const entities = entitiesOf(payload, items.length);
147
+ if (entities === void 0) return yield* Effect.fail(ModelUnavailable.make({
148
+ modelId,
149
+ reason: "unreadable extraction payload"
150
+ }));
151
+ return entities;
152
+ }) });
153
+ /**
154
+ * The production transport: bearer-token fetch against the mantle endpoint.
155
+ *
156
+ * A non-2xx status is a rejection carrying the status and the body's first line, because mantle
157
+ * reports quota and auth failures as structured JSON the operator needs verbatim. Folding it into
158
+ * a generic message was the mistake the embeddings lane made first.
159
+ */
160
+ const fetchMantleTransport = (region, token) => ({ post: async (body, signal) => {
161
+ const response = await fetch(`https://bedrock-mantle.${region}.api.aws/openai/v1/responses`, {
162
+ method: "POST",
163
+ headers: {
164
+ Authorization: `Bearer ${token}`,
165
+ "Content-Type": "application/json"
166
+ },
167
+ body,
168
+ signal
169
+ });
170
+ const text = await response.text();
171
+ if (!response.ok) throw new Error(`mantle ${response.status}: ${text.slice(0, 200)}`);
172
+ return JSON.parse(text);
173
+ } });
174
+
175
+ //#endregion
12
176
  //#region apps/cli/src/serve.ts
13
177
  /** An explicit path to the server, for a deployment that does not keep the two apps side by side. */
14
178
  const MCP_BIN_VAR = "MEMHTML_MCP_BIN";
@@ -102,7 +266,14 @@ const CONFIG_VARS = [
102
266
  },
103
267
  {
104
268
  name: "MEMHTML_EXTRACT_ENTITIES",
105
- 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.",
269
+ /**
270
+ * The model id is interpolated from `extraction.ts`, never spelled here. That constant is the
271
+ * one the transport calls and the one the strict output schema beside it is tested against, so a
272
+ * second spelling in this row is a manifest that can name a model the code does not call. The
273
+ * lane is also not `@memhtml/llm`'s: the extractor speaks the Bedrock mantle Responses API, which
274
+ * is why this id is absent from `ModelKey`.
275
+ */
276
+ description: `\`on\` adds one \`${EXTRACTION_MODEL_ID}\` 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.`,
106
277
  fallback: "off"
107
278
  },
108
279
  {
@@ -147,6 +318,7 @@ const ERROR_CODES = [
147
318
  "ERR_UNKNOWN_COMMAND",
148
319
  "ERR_MISSING_ARGUMENT",
149
320
  "ERR_INVALID_FLAG",
321
+ "ERR_UNEXPECTED_ARGUMENT",
150
322
  "ERR_PATH_NOT_FOUND",
151
323
  "ERR_INVALID_MEMORY",
152
324
  "ERR_DUPLICATE_CONTENT",
@@ -204,170 +376,6 @@ const stripNulls = (value) => {
204
376
  };
205
377
  const render = (payload, dense) => dense ? JSON.stringify(stripNulls(payload)) : JSON.stringify(payload, null, 2);
206
378
 
207
- //#endregion
208
- //#region apps/cli/src/extraction.ts
209
- /**
210
- * GPT-5.6 Luna, the fast high-volume model on the mantle endpoint. A constant rather than config
211
- * because the schema below is tested against this model's strict-mode behavior. Changing the model
212
- * is a code change with a test run, not an env var.
213
- */
214
- const EXTRACTION_MODEL_ID = "openai.gpt-5.6-luna";
215
- /**
216
- * The strict output schema. `additionalProperties: false` and `required` on every level because
217
- * the Responses API's `strict: true` demands both, and a lax schema invites the model to answer
218
- * with prose keys the parser would then be guessing at.
219
- */
220
- const RESPONSE_SCHEMA = {
221
- type: "object",
222
- properties: { items: {
223
- type: "array",
224
- items: {
225
- type: "object",
226
- properties: {
227
- index: { type: "integer" },
228
- entities: {
229
- type: "array",
230
- items: {
231
- type: "object",
232
- properties: {
233
- type: {
234
- type: "string",
235
- enum: [...[
236
- "person",
237
- "org",
238
- "service",
239
- "place",
240
- "work",
241
- "concept",
242
- "event"
243
- ]]
244
- },
245
- name: { type: "string" }
246
- },
247
- required: ["type", "name"],
248
- additionalProperties: false
249
- }
250
- }
251
- },
252
- required: ["index", "entities"],
253
- additionalProperties: false
254
- }
255
- } },
256
- required: ["items"],
257
- additionalProperties: false
258
- };
259
- 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.";
260
- /** The request body for one batch. Exported for the wire test, where the schema is the contract. */
261
- const requestBodyOf = (modelId, items) => JSON.stringify({
262
- model: modelId,
263
- instructions: INSTRUCTIONS,
264
- input: wrapAsData("memories", JSON.stringify(items.map((item, index) => ({
265
- index,
266
- title: item.title,
267
- text: item.text
268
- })))),
269
- text: { format: {
270
- type: "json_schema",
271
- name: "entities",
272
- strict: true,
273
- schema: RESPONSE_SCHEMA
274
- } }
275
- });
276
- /**
277
- * Decode one Responses-API payload into index-aligned `type:name` arrays.
278
- *
279
- * Total over unknown input: every malformed shape returns `undefined` and the caller maps that to
280
- * `ModelUnavailable`. A payload this code cannot read carries no answer, and treating it as
281
- * "no entities" would record a model failure as a fact about the corpus.
282
- */
283
- const entitiesOf = (payload, expected) => {
284
- const text = outputTextOf(payload);
285
- if (text === void 0) return void 0;
286
- let parsed;
287
- try {
288
- parsed = JSON.parse(text);
289
- } catch {
290
- return;
291
- }
292
- const items = parsed.items;
293
- if (!Array.isArray(items)) return void 0;
294
- const results = Array.from({ length: expected }, () => []);
295
- for (const item of items) {
296
- const index = item.index;
297
- const entities = item.entities;
298
- if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index >= expected) continue;
299
- if (!Array.isArray(entities)) continue;
300
- results[index] = entities.flatMap((entity) => {
301
- const type = entity.type;
302
- const name = entity.name;
303
- if (typeof type !== "string" || typeof name !== "string") return [];
304
- const trimmedName = name.trim();
305
- return trimmedName === "" ? [] : [`${type}:${trimmedName}`];
306
- });
307
- }
308
- return results;
309
- };
310
- /** The assistant message text out of a Responses payload, or `undefined` off-shape. */
311
- const outputTextOf = (payload) => {
312
- const output = payload.output;
313
- if (!Array.isArray(output)) return void 0;
314
- for (const entry of output) {
315
- if (entry.type !== "message") continue;
316
- const content = entry.content;
317
- if (!Array.isArray(content)) continue;
318
- for (const part of content) {
319
- const text = part.text;
320
- if (part.type === "output_text" && typeof text === "string") return text;
321
- }
322
- }
323
- };
324
- /**
325
- * Per-call ceiling. Generous against the probed ~1s because a batch of 256 ops is a bigger
326
- * prompt than the probe's one sentence, and a late abort costs only this batch's entities. The
327
- * write itself is unaffected.
328
- */
329
- const EXTRACT_TIMEOUT_MS = 6e4;
330
- /** The extractor over a transport. The transport owns the endpoint; this owns prompt and parse. */
331
- const makeEntityExtractor = (transport, modelId) => ({ extract: (items) => items.length === 0 ? Effect.succeed([]) : Effect.gen(function* () {
332
- const payload = yield* Effect.tryPromise({
333
- try: (signal) => {
334
- const timeout = AbortSignal.timeout(EXTRACT_TIMEOUT_MS);
335
- return transport.post(requestBodyOf(modelId, items), AbortSignal.any([signal, timeout]));
336
- },
337
- catch: (cause) => ModelUnavailable.make({
338
- modelId,
339
- reason: cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause)
340
- })
341
- });
342
- const entities = entitiesOf(payload, items.length);
343
- if (entities === void 0) return yield* Effect.fail(ModelUnavailable.make({
344
- modelId,
345
- reason: "unreadable extraction payload"
346
- }));
347
- return entities;
348
- }) });
349
- /**
350
- * The production transport: bearer-token fetch against the mantle endpoint.
351
- *
352
- * A non-2xx status is a rejection carrying the status and the body's first line, because mantle
353
- * reports quota and auth failures as structured JSON the operator needs verbatim. Folding it into
354
- * a generic message was the mistake the embeddings lane made first.
355
- */
356
- const fetchMantleTransport = (region, token) => ({ post: async (body, signal) => {
357
- const response = await fetch(`https://bedrock-mantle.${region}.api.aws/openai/v1/responses`, {
358
- method: "POST",
359
- headers: {
360
- Authorization: `Bearer ${token}`,
361
- "Content-Type": "application/json"
362
- },
363
- body,
364
- signal
365
- });
366
- const text = await response.text();
367
- if (!response.ok) throw new Error(`mantle ${response.status}: ${text.slice(0, 200)}`);
368
- return JSON.parse(text);
369
- } });
370
-
371
379
  //#endregion
372
380
  //#region apps/cli/src/api-layer.ts
373
381
  const Roots = Context.Service("memhtml/Roots");
@@ -519,22 +527,21 @@ const ConsolidatorPortService = Context.Service("memhtml/ConsolidatorPort");
519
527
  *
520
528
  * The check cannot be skipped in favor of the client's own, because the provider is lazy.
521
529
  * `createAmazonBedrock` and `provider(modelId)` both succeed with zero credentials and nothing fails
522
- * until the first request (verified in T-EVE-1's probe, recorded at
523
- * `apps/consolidator/src/contract.ts:301-319`).
530
+ * until the first request (the contract suite in `apps/consolidator/src/contract.ts` pins this).
524
531
  *
525
532
  * **`env` is a parameter, and it has to be.** `Config` reads its values through a `ConfigProvider`,
526
533
  * which a test substitutes, while `hasConsolidatorCredentials` reads `process.env` directly, and
527
- * effect's default provider snapshots `process.env` at module load (probed 2026-08-08: mutating
528
- * `process.env.MEMHTML_LLM` after importing `effect` changes nothing `Config.string` returns). A test
534
+ * effect's default provider snapshots `process.env` at module load, so mutating
535
+ * `process.env.MEMHTML_LLM` after importing `effect` changes nothing `Config.string` returns. A test
529
536
  * that set both by mutation would read a stale snapshot for one gate and a live object for the other,
530
537
  * and the two gates would disagree about which environment they are in. Threading the credential
531
538
  * environment through as an argument makes both injectable from one call. See
532
539
  * `apps/cli/tests/consolidator-wiring.test.ts`, where that disagreement produced a false defect
533
540
  * before this parameter existed.
534
541
  *
535
- * **It now requires `RootsShape`, for `traceRoot`.** That is how transcripts reach the agent. The
542
+ * **It requires `RootsShape`, for `traceRoot`.** That is how transcripts reach the agent. The
536
543
  * consolidator mounts the trace root read-only rather than sending transcripts as a model message
537
- * (`apps/consolidator/src/client.ts`, `manifestFor`, records what the superseded path actually did).
544
+ * (`apps/consolidator/src/client.ts`, `manifestFor`).
538
545
  * The root is `MEMHTML_TRACE_ROOT` and this file is where config becomes services, so it is read from the
539
546
  * same `Roots` service `memhtml trace index` scans with. One resolution of one variable is what
540
547
  * keeps the mounted tree and the indexed `traces` rows describing the same directory. A second
@@ -642,6 +649,7 @@ const codeFor = (error) => {
642
649
  case "DuplicateContent": return "ERR_DUPLICATE_CONTENT";
643
650
  case "ModelUnavailable": return "ERR_MODEL_UNAVAILABLE";
644
651
  case "EmbedModelMismatch": return "ERR_EMBED_MODEL_MISMATCH";
652
+ case "IndexStale": return "ERR_INDEX_STALE";
645
653
  case "DiscriminationFailed": return "ERR_DISCRIMINATION_FAILED";
646
654
  default: return "ERR_UNKNOWN";
647
655
  }
@@ -667,6 +675,7 @@ const messageFor = (error) => {
667
675
  case "DuplicateContent": return `this content already lives at ${text(error.existingPath) ?? "another path"}`;
668
676
  case "ModelUnavailable": return `bedrock refused ${text(error.modelId) ?? "the model"}: ${text(error.reason) ?? "no reason given"}`;
669
677
  case "EmbedModelMismatch": return `the index was built in vector space ${text(error.stored) ?? "?"}, configured is ${text(error.configured) ?? "?"}`;
678
+ case "IndexStale": return `the index is stale: ${text(error.reason) ?? "it does not describe the current commit"}`;
670
679
  case "LlmContractViolation": return `the model broke its structured-output contract: ${text(error.reason) ?? "no reason given"}`;
671
680
  case "DiscriminationFailed": return text(error.reason) ?? "the discrimination gate refused";
672
681
  default: return `unexpected failure: ${error._tag}`;
@@ -676,12 +685,14 @@ const messageFor = (error) => {
676
685
  * What to do about a failure, as commands the caller can run.
677
686
  *
678
687
  * A suggestion is part of the contract. An agent that receives `ERR_INDEX_STALE` and a
679
- * `memhtml index update` suggestion can recover in one step without a round trip to a human. Absent
680
- * suggestions are an empty array rather than a null, so a parser never branches on presence.
688
+ * `memhtml index rebuild` suggestion can recover in one step without a round trip to a human, which
689
+ * also means a suggestion has to be a call that MOVES the failure: naming the command that raised the
690
+ * tag would loop. Absent suggestions are an empty array rather than a null, so a parser never
691
+ * branches on presence.
681
692
  *
682
693
  * A record rather than a `switch`, which is what closes the drift class. Every `memhtml …` string
683
- * below names a command from the table in `commands.ts`, and a rename there used to leave a stale
684
- * suggestion here that nothing failed on. A record's keys and arms are both walkable, so the suite
694
+ * below names a command from the table in `commands.ts`, and a rename there would otherwise leave a
695
+ * stale suggestion here that nothing fails on. A record's keys and arms are both walkable, so the suite
685
696
  * can enumerate every tag, run every suggestion through the real `parseArgv`, and fail on a name the
686
697
  * table does not hold. A `switch` cannot expose any of that to a test.
687
698
  *
@@ -690,11 +701,29 @@ const messageFor = (error) => {
690
701
  * `AUTHORABLE_RELS` undefined in `commands.ts`'s module body under an operations-first import order.
691
702
  */
692
703
  const SUGGESTIONS = {
693
- PathNotFound: () => ["memhtml search <what you were looking for>", "memhtml list"],
694
- WriteConflict: (error) => [`memhtml read ${text(error.path) ?? "<path>"}`, "re-apply the change to current content"],
704
+ PathNotFound: () => [
705
+ "memhtml resolve <the path you cited> a correction or an eviction may have moved it",
706
+ "memhtml search <what you were looking for>",
707
+ "memhtml list"
708
+ ],
709
+ /**
710
+ * Two branches produce this tag and they recover differently, so both are offered.
711
+ *
712
+ * An occupied EXPLICIT `--path` is refused rather than overwritten — nothing in this corpus is
713
+ * deleted — and the recovery is `memhtml correct <path>`, which writes the superseding memory and
714
+ * archives what it replaces in one commit. A merge conflict on a sleep branch carries two blob
715
+ * shas instead, and there the recovery is to read the current content and re-apply. The read is
716
+ * first because it is the step both branches start with.
717
+ */
718
+ WriteConflict: (error) => [
719
+ `memhtml read ${text(error.path) ?? "<path>"}`,
720
+ `memhtml correct ${text(error.path) ?? "<path>"} --title <title> --claim <sentence>`,
721
+ "re-apply the change to current content"
722
+ ],
695
723
  DirtyTree: () => ["git -C $MEMHTML_ROOT status", "commit or stash the changes, then retry"],
696
724
  DuplicateContent: (error) => [`memhtml read ${text(error.existingPath) ?? "<path>"}`],
697
725
  EmbedModelMismatch: () => ["memhtml index rebuild --embed"],
726
+ IndexStale: () => ["memhtml index rebuild"],
698
727
  ModelUnavailable: () => ["retry: search still works on the lexical floor", "memhtml status"],
699
728
  InvalidMemory: () => ["memhtml manifest"],
700
729
  DiscriminationFailed: () => [
@@ -796,20 +825,20 @@ const recordLink = (path, linkKind, provenance, at) => Effect.gen(function* () {
796
825
  /**
797
826
  * Bring the index up to the commit a write just made.
798
827
  *
799
- * `indexer.update()` rather than `indexPaths([…])`, and the difference changes behavior twice:
828
+ * The whole COMMIT, never a list of paths the caller happens to know about, and two properties of the
829
+ * index rest on that:
800
830
  *
801
- * 1. **`indexPaths` cannot express a rename.** Every correction and every archive is a `git mv`, and
802
- * an index that handled one as "index the destination" leaves the source row live. The archived
803
- * memory stays in `memhtml list`, `files` gains a row the tree does not have, and the chunk rows the
804
- * move exists to preserve are duplicated under two paths. `update()` reads `diff --name-status -M`,
805
- * sees the `R`, and re-points the row, which keeps the embedding and drops nothing.
806
- * 2. **`indexPaths` never records the watermark.** `index_state.head_sha` is what makes
807
- * "the index describes the current commit" answerable at all, so a write path that skipped it
808
- * would leave `memhtml status` reporting `index_fresh: false` forever and `index update` re-deriving
809
- * from a stale base.
831
+ * 1. **A rename is only expressible as a diff.** Every correction and every archive is a `git mv`.
832
+ * `update()` reads `diff --name-status -M`, sees the `R`, and re-points the row, which keeps the
833
+ * embedding. Indexing the destination alone leaves the source row live: the archived memory stays
834
+ * in `memhtml list`, `files` carries a row the tree does not have, and the chunk rows the move
835
+ * exists to preserve end up duplicated under two paths.
836
+ * 2. **The watermark is what makes freshness answerable.** `update()` records
837
+ * `index_state.head_sha`, and without it `memhtml status` reports `index_fresh: false` forever
838
+ * while `index update` re-derives from a stale base.
810
839
  *
811
840
  * The cost is one `git diff` over one commit, which is what the watermark exists to bound. On the
812
- * very first write the watermark is absent and `update()` falls through to a full rebuild. That is
841
+ * very first write there is no watermark row and `update()` falls through to a full rebuild. That is
813
842
  * correct, and cheap on a corpus that has one file in it.
814
843
  */
815
844
  const reindex = () => Effect.gen(function* () {
@@ -820,8 +849,8 @@ const reindex = () => Effect.gen(function* () {
820
849
  *
821
850
  * Shared by {@link writeMemory} and {@link batchWrite}, and the sharing matters. A batch that
822
851
  * re-derived this would be a second decode of the same vocabulary, and the two would agree today
823
- * and drift the first time a field is added. This is `symspec`'s lesson stated as code: the batch
824
- * folds the singular's own decode rather than a parallel one.
852
+ * and drift the first time a field is added. The batch folds the singular's own decode rather
853
+ * than a parallel one.
825
854
  *
826
855
  * The two task metas are decoded here, before any file is rendered, and only for a task.
827
856
  * `@memhtml/html`'s parser refuses `memhtml-task-status` on a non-task and refuses a `memhtml-due` that is not
@@ -842,6 +871,7 @@ const toWriteInput = (params, at) => Effect.gen(function* () {
842
871
  body: params.body,
843
872
  articleHtml: params.articleHtml,
844
873
  path: params.path,
874
+ strictPath: params.strictPath,
845
875
  workspace: params.workspace,
846
876
  tags: params.tags,
847
877
  entities: params.entities,
@@ -1431,13 +1461,116 @@ const reinforceMemories = (paths, signal) => Effect.gen(function* () {
1431
1461
  };
1432
1462
  });
1433
1463
  /**
1464
+ * The ceiling on nodes per neighborhood, and the default when a caller names none.
1465
+ *
1466
+ * A caller-supplied `limit` is clamped into `1..NEIGHBORS_LIMIT`, which is the shape both sibling
1467
+ * reads have (`memory_list` `Math.min(500, …)`, `trace_search` `Math.min(200, …)`). A clamp with no
1468
+ * flag behind it is a ceiling a caller can neither ask for nor lower.
1469
+ */
1470
+ const NEIGHBORS_LIMIT = 200;
1471
+ /**
1472
+ * Edge rows the statement may RETURN before it stops.
1473
+ *
1474
+ * **This bounds the answer, not the join.** Measured 2026-08-25 on node 24.19.0 against the shipped
1475
+ * schema: `EXPLAIN QUERY PLAN` on {@link neighborsQuery}'s depth-2 statement yields `MERGE
1476
+ * (UNION ALL)` with `USE TEMP B-TREE FOR ORDER BY` on every arm, so SQLite enumerates the union and
1477
+ * sorts the whole row set in a temp b-tree BEFORE the `LIMIT` takes its prefix. A hub of degree
1478
+ * 150/300/450 generates 22.5k/90k/202k rows either way, and the limited statement runs in
1479
+ * 47/92/155 ms against 66/253/591 ms unlimited — so the cap buys real time and memory downstream of
1480
+ * the sort while the join's work and the temp b-tree still grow with the center's degree squared.
1481
+ *
1482
+ * What the cap does bound: the rows that cross into JS, the fold below, and the size of one answer.
1483
+ * A neighborhood that reaches it is truncated rather than exhaustive, and `scanSaturated` says so
1484
+ * instead of leaving the caller to infer it — raising a caller's `limit` cannot recover an edge the
1485
+ * walk never returned.
1486
+ *
1487
+ * Bounding each arm before the union WOULD bound the join, and is not done: an arm-level `LIMIT`
1488
+ * takes an arbitrary prefix of one direction's edges, so the hop-1 nodes that survive decide which
1489
+ * hop-2 nodes exist at all, and the answer would change with the planner's row order rather than
1490
+ * only shrink.
1491
+ */
1492
+ const NEIGHBORS_SCAN_LIMIT = 1e4;
1493
+ /**
1494
+ * The neighborhood walk as one statement plus its bind list.
1495
+ *
1496
+ * Exported so a cost assertion can `EXPLAIN QUERY PLAN` the string this function actually issues.
1497
+ * A plan asserted against a copy pasted into a test explains the copy, and the two drift the first
1498
+ * time an arm moves.
1499
+ *
1500
+ * Hop 1 is the center's own edges, either direction. Hop 2 walks one further from each hop-1 node and
1501
+ * excludes the center, so a two-cycle does not report the center as its own neighbor at distance 2.
1502
+ * Each arm carries the edge's own endpoints (`a`, `b`) so an edge can be counted as an edge, not
1503
+ * inferred from a node count.
1504
+ *
1505
+ * The join onto `files` is an inner join, so an edge pointing at a path the tree does not hold
1506
+ * contributes nothing. A dangling href is `memhtml doctor`'s finding rather than a titleless node.
1507
+ *
1508
+ * The rel list binds once per occurrence of the filter, in textual order: hop 1 uses it twice, hop 2
1509
+ * uses it four more times. Getting this count wrong is a bind mismatch rather than a wrong answer, so
1510
+ * it fails loudly.
1511
+ */
1512
+ const neighborsQuery = (input) => {
1513
+ const { center, depth, rels } = input;
1514
+ const relFilter = rels.length > 0 ? ` AND e.rel IN (${rels.map(() => "?").join(", ")})` : "";
1515
+ const relFilter2 = rels.length > 0 ? ` AND e2.rel IN (${rels.map(() => "?").join(", ")})` : "";
1516
+ const hopOne = `
1517
+ SELECT e.dst_path AS path, e.rel AS rel, e.derived AS derived, 1 AS hop,
1518
+ e.src_path AS a, e.dst_path AS b
1519
+ FROM edges e
1520
+ WHERE e.src_path = ?1 AND e.edge_class = 'memory'${relFilter}
1521
+ UNION ALL
1522
+ SELECT e.src_path AS path, e.rel AS rel, e.derived AS derived, 1 AS hop,
1523
+ e.src_path AS a, e.dst_path AS b
1524
+ FROM edges e
1525
+ WHERE e.dst_path = ?1 AND e.edge_class = 'memory'${relFilter}`;
1526
+ const hopTwo = `
1527
+ SELECT e2.dst_path AS path, e2.rel AS rel, e2.derived AS derived, 2 AS hop,
1528
+ e2.src_path AS a, e2.dst_path AS b
1529
+ FROM edges e
1530
+ JOIN edges e2 ON e2.src_path = e.dst_path
1531
+ WHERE e.src_path = ?1 AND e.edge_class = 'memory' AND e2.edge_class = 'memory'
1532
+ AND e2.dst_path <> ?1${relFilter}${relFilter2}
1533
+ UNION ALL
1534
+ SELECT e2.src_path AS path, e2.rel AS rel, e2.derived AS derived, 2 AS hop,
1535
+ e2.src_path AS a, e2.dst_path AS b
1536
+ FROM edges e
1537
+ JOIN edges e2 ON e2.dst_path = e.src_path
1538
+ WHERE e.dst_path = ?1 AND e.edge_class = 'memory' AND e2.edge_class = 'memory'
1539
+ AND e2.src_path <> ?1${relFilter}${relFilter2}`;
1540
+ return {
1541
+ sql: `SELECT w.path AS path, f.title AS title, w.rel AS rel, w.derived AS derived,
1542
+ w.hop AS hop, w.a AS a, w.b AS b
1543
+ FROM (${depth === 1 ? hopOne : `${hopOne}\n UNION ALL${hopTwo}`}) w
1544
+ JOIN files f ON f.path = w.path
1545
+ ORDER BY w.hop ASC, w.path ASC
1546
+ LIMIT ${NEIGHBORS_SCAN_LIMIT}`,
1547
+ params: [center, ...depth === 1 ? [...rels, ...rels] : [
1548
+ ...rels,
1549
+ ...rels,
1550
+ ...rels,
1551
+ ...rels,
1552
+ ...rels,
1553
+ ...rels
1554
+ ]]
1555
+ };
1556
+ };
1557
+ /**
1434
1558
  * The memory graph around one path, to a fixed depth of at most two hops.
1435
1559
  *
1436
1560
  * **Two fixed-depth joins in a `UNION ALL`, deliberately not a recursive CTE.** The depth is
1437
1561
  * bounded at 2 by the tool's contract, so recursion buys nothing and costs the one thing a graph
1438
1562
  * query must not have here: an unbounded worst case on a corpus whose `relates_to` edges are
1439
- * mined by the sleep cycle and can be dense. A fixed join is also index-covered by `edges_src`
1440
- * and `edges_dst`, which a recursive walk is not.
1563
+ * mined by the sleep cycle and can be dense.
1564
+ *
1565
+ * **Every arm is index-probed, in both directions, through `edges_src` and `edges_dst`.** Measured
1566
+ * 2026-08-26 on node 24.19.0 with no `ANALYZE`, and locked by the plan assertion in
1567
+ * `apps/cli/tests/e2e.test.ts`: each arm is a `SEARCH` binding two columns,
1568
+ * `(src_path=? AND edge_class=?)` or `(dst_path=? AND edge_class=?)`. Neither index carries a
1569
+ * predicate (`0011_edge_indexes.sql`), which is what makes them reachable from here at all — this walk
1570
+ * selects `e.derived` and filters only on `edge_class`, so a `WHERE derived = 0` index could not be a
1571
+ * candidate, and the reverse arms would fall back to a full scan of `edges` per arm. The row set is
1572
+ * still degree², which is what `NEIGHBORS_SCAN_LIMIT` bounds; what the indexes bound is the work spent
1573
+ * finding it.
1441
1574
  *
1442
1575
  * **Both directions, and `derived = 0 ∪ derived = 1`.** An edge is an assertion about a pair, and
1443
1576
  * which file happens to hold the `<link>` is authorship rather than direction of meaning. A
@@ -1446,66 +1579,228 @@ const reinforceMemories = (paths, signal) => Effect.gen(function* () {
1446
1579
  * is what they are for. `derived` is still reported per node so a caller can tell a
1447
1580
  * sleep-mined suspicion from an authored assertion.
1448
1581
  *
1449
- * `edge_class = 'memory'` on every join. A person edge entering here would put
1450
- * `resources/people/*` into a memory neighborhood, and the class column exists to make that
1451
- * structurally impossible.
1582
+ * `edge_class = 'memory'` on every join. A person edge entering here would put
1583
+ * `resources/people/*` into a memory neighborhood, and the class column exists to make that
1584
+ * structurally impossible.
1585
+ */
1586
+ const neighborsOf = (params) => Effect.gen(function* () {
1587
+ const db = yield* DatabaseService;
1588
+ const center = normalizePath(params.path);
1589
+ const depth = Math.min(2, Math.max(1, Math.trunc(params.depth ?? 1)));
1590
+ const limit = Math.min(200, Math.max(1, Math.trunc(params.limit ?? 200)));
1591
+ const rels = (params.rels ?? []).filter((rel) => isEdgeRel(rel) && relClassFor(rel) === "memory");
1592
+ /**
1593
+ * Edge rows, hop-1 first, folded per path below rather than `GROUP BY` in SQL. A `GROUP BY`
1594
+ * with `min(hop)` and `min(rel)` aggregates the two columns independently, so a node reachable
1595
+ * as `supersedes` at hop 1 and `contradicts` at hop 2 would report `(hop 1, contradicts)`, a
1596
+ * pairing no edge holds. The fold keeps the rel of an edge AT the minimal hop.
1597
+ */
1598
+ const statement = neighborsQuery({
1599
+ center,
1600
+ depth,
1601
+ rels
1602
+ });
1603
+ const rows = yield* db.all(statement.sql, statement.params);
1604
+ /**
1605
+ * One node per path at its minimal hop: a node reachable both directly and via a detour is a
1606
+ * 1-hop neighbor, and reporting it twice would let one memory occupy two slots in a bounded
1607
+ * answer. The rows arrive hop-first, so a path's first row IS an edge at its minimal hop and
1608
+ * its rel is kept verbatim. `derived` is the max over every edge reaching the node, so one
1609
+ * sleep-mined route marks the node as carrying a mined suspicion even when an authored edge
1610
+ * also reaches it. `edges` counts distinct edges the walk enumerated, which is what the MCP
1611
+ * schema's `edges` field claims to be.
1612
+ *
1613
+ * A path the clamp turns away is still counted, in `nodesDropped`, and its edges still count
1614
+ * toward `edges`: the two numbers live in different coordinate spaces on purpose, and an `edges`
1615
+ * total that quietly excluded a dropped path's edges would agree with `nodes` while describing
1616
+ * a walk that never happened.
1617
+ */
1618
+ const byPath = /* @__PURE__ */ new Map();
1619
+ const edgeKeys = /* @__PURE__ */ new Set();
1620
+ const dropped = /* @__PURE__ */ new Set();
1621
+ for (const row of rows) {
1622
+ edgeKeys.add(JSON.stringify([
1623
+ row.a,
1624
+ row.rel,
1625
+ row.b
1626
+ ]));
1627
+ const existing = byPath.get(row.path);
1628
+ if (existing === void 0) {
1629
+ if (byPath.size < limit) byPath.set(row.path, {
1630
+ title: row.title,
1631
+ hop: row.hop,
1632
+ rel: row.rel,
1633
+ derived: row.derived === 1
1634
+ });
1635
+ else dropped.add(row.path);
1636
+ } else if (row.derived === 1) byPath.set(row.path, {
1637
+ ...existing,
1638
+ derived: true
1639
+ });
1640
+ }
1641
+ return {
1642
+ center,
1643
+ depth,
1644
+ /** The node ceiling this answer was built under, after clamping the caller's ask. */
1645
+ limit,
1646
+ nodes: [...byPath.entries()].map(([path, node]) => ({
1647
+ path,
1648
+ title: node.title,
1649
+ hop: node.hop,
1650
+ rel: node.rel,
1651
+ derived: node.derived
1652
+ })),
1653
+ edges: edgeKeys.size,
1654
+ /**
1655
+ * Distinct paths the walk reached and `limit` turned away. `0` means `nodes` holds every path
1656
+ * the walk found, so a caller can tell a saturated neighborhood from a complete one. Raising
1657
+ * `limit` toward {@link NEIGHBORS_LIMIT} returns them.
1658
+ */
1659
+ nodesDropped: dropped.size,
1660
+ /**
1661
+ * True when the walk returned {@link NEIGHBORS_SCAN_LIMIT} rows, so edges past the cap were
1662
+ * never enumerated and no `limit` recovers them. Distinct from `nodesDropped`, which a bigger
1663
+ * `limit` fixes.
1664
+ */
1665
+ scanSaturated: rows.length >= NEIGHBORS_SCAN_LIMIT
1666
+ };
1667
+ });
1668
+ /**
1669
+ * Steps the forward walk takes before it stops and says which bound stopped it.
1670
+ *
1671
+ * A chain this long is a corpus that has corrected one fact sixteen times, which the walk answers
1672
+ * with `hop_limit` rather than by paying an unbounded number of statements for a read a caller
1673
+ * expects to be cheap. It is not the cycle guard: {@link resolveMemory} carries a visited set, so a
1674
+ * loop is reported as a loop at the hop that closes it, however short.
1452
1675
  */
1453
- const neighborsOf = (params) => Effect.gen(function* () {
1676
+ const RESOLVE_MAX_HOPS = 16;
1677
+ /**
1678
+ * The three statements the walk issues, as literals a plan assertion can EXPLAIN.
1679
+ *
1680
+ * Exported for {@link neighborsQuery}'s reason: a cost contract can only be asserted at the planner,
1681
+ * and a test that EXPLAINed a pasted copy would explain the copy. Each one binds exactly one
1682
+ * parameter, so a test can run them as written.
1683
+ *
1684
+ * `successor` names `edge_class` even though `rel = 'supersedes'` implies it under `edges`' CHECK
1685
+ * constraints. That is a planner constraint, not a filter: measured 2026-08-26 on node 24's
1686
+ * `node:sqlite` with no `ANALYZE`, `dst_path = ? AND rel = ? AND derived = 0` alone plans as `SEARCH
1687
+ * edges USING INDEX edges_derived (derived=? AND rel=?)` — every authored correction in the corpus,
1688
+ * per hop — while naming the class binds two columns of `edges_dst` and the same statement probes.
1689
+ * The rel and `derived = 0` are still the CORRECTNESS half: `derived = 0` is the same authored-only
1690
+ * rule `SearchHit.supersededBy` reads, so `search` and this walk cannot disagree about who superseded
1691
+ * what, and a sleep-mined suspicion can never redirect a citation.
1692
+ *
1693
+ * `archived` is the archive mapping read backwards, served by `files_origin`
1694
+ * (`0012_origin_path.sql`). `ORDER BY archived_at DESC` decides the case a UNIQUE index would have
1695
+ * had to refuse: one path evicted, rewritten, and evicted again carries two archive rows, and the
1696
+ * NEWEST is the occupant a citation of that path most recently named. A row with no `memhtml-archived`
1697
+ * stamp sorts last, since SQLite puts NULLs last under DESC.
1698
+ */
1699
+ const resolveQueries = {
1700
+ successor: `SELECT e.src_path AS path FROM edges e
1701
+ WHERE e.dst_path = ? AND e.edge_class = 'memory' AND e.rel = 'supersedes' AND e.derived = 0
1702
+ ORDER BY e.created_at DESC, e.src_path ASC LIMIT 1`,
1703
+ archived: `SELECT f.path AS path FROM files f
1704
+ WHERE f.origin_path = ? ORDER BY f.archived_at DESC, f.path DESC LIMIT 1`,
1705
+ file: "SELECT f.archived AS archived, f.title AS title FROM files f WHERE f.path = ?"
1706
+ };
1707
+ /**
1708
+ * The live path a possibly-moved path names now, by walking `supersedes` forward.
1709
+ *
1710
+ * **A path IS the id of a memory** (`packages/contracts/src/types.ts`, `MemoryPath`), and it is
1711
+ * derived from the title through `slugify`, so a re-consolidation that rewords a title lands the
1712
+ * corrected fact at a DIFFERENT path while `correctMemory` `git mv`s the original into
1713
+ * `archive/<YYYY>/`. An external receipt holding the old path therefore dead-ends at a path the tree
1714
+ * no longer holds — through no fault of the receipt. This read is how such a receipt is repaired
1715
+ * without a second identifier: the corpus already records both mechanisms that move a memory, and
1716
+ * nothing here is minted.
1717
+ *
1718
+ * **Two mechanisms, and a path absent from `files` is looked up by the archive mapping ALONE.** A
1719
+ * correction stamps its `supersedes` link toward the target's ARCHIVE path
1720
+ * (`packages/store/src/store.ts`, `correctMemory`), so the pre-archive path has no inbound edge at
1721
+ * all and only `origin_path` knows where its bytes went. An inbound `supersedes` edge over a path the
1722
+ * tree does not hold is a DANGLING edge — `memhtml doctor`'s finding — and following one would
1723
+ * resolve a citation through an assertion about a file nothing can read. Conversely a path that IS in
1724
+ * `files` is never redirected by the archive mapping, even when an older eviction of the same path
1725
+ * left a row behind: the live file at that path is the answer, and the redirect would replace it with
1726
+ * a historical one.
1727
+ *
1728
+ * **Every node in the chain is named by the path that holds it NOW.** A `supersedes` link is an
1729
+ * element inside a file, so archiving that file carries the link with it: after a second correction the
1730
+ * edge points from the archived middle memory, not from the path the middle was live at. A three-step
1731
+ * chain over two corrections therefore reads `cited → archive(cited) → archive(middle) → live`, and the
1732
+ * middle's own live-at-the-time path appears nowhere in it. The tree is the system of record, and this
1733
+ * walk reports where each memory is rather than where it was.
1734
+ *
1735
+ * **`hops: 0` with `stopReason: "live"` does not mean the bytes are unchanged.** A correction whose
1736
+ * title is unchanged lands at the SAME path, so the path is live and its content is a different fact.
1737
+ * That grain is what the pinned citation URI is for; this read answers where to look, not what was
1738
+ * there.
1739
+ *
1740
+ * Statement count is `1..2` per hop and the walk is bounded twice — by a visited set and by
1741
+ * {@link RESOLVE_MAX_HOPS} — so a corpus defect costs a bounded read and is reported rather than
1742
+ * hung. A recursive CTE would do it in one statement and could not report WHICH mechanism took each
1743
+ * hop, which is the half a receipt is audited on.
1744
+ */
1745
+ const resolveMemory = (path) => Effect.gen(function* () {
1454
1746
  const db = yield* DatabaseService;
1455
- const center = normalizePath(params.path);
1456
- const depth = Math.min(2, Math.max(1, Math.trunc(params.depth ?? 1)));
1457
- const rels = (params.rels ?? []).filter((rel) => isEdgeRel(rel) && relClassFor(rel) === "memory");
1458
- const relFilter = rels.length > 0 ? ` AND e.rel IN (${rels.map(() => "?").join(", ")})` : "";
1459
- const relFilter2 = rels.length > 0 ? ` AND e2.rel IN (${rels.map(() => "?").join(", ")})` : "";
1460
- /**
1461
- * Hop 1 is the center's own edges, either direction. Hop 2 walks one further from each hop-1
1462
- * node and excludes the center, so a two-cycle does not report the center as its own neighbor
1463
- * at distance 2.
1464
- */
1465
- const hopOne = `
1466
- SELECT e.dst_path AS path, e.rel AS rel, e.derived AS derived, 1 AS hop
1467
- FROM edges e
1468
- WHERE e.src_path = ?1 AND e.edge_class = 'memory'${relFilter}
1469
- UNION ALL
1470
- SELECT e.src_path AS path, e.rel AS rel, e.derived AS derived, 1 AS hop
1471
- FROM edges e
1472
- WHERE e.dst_path = ?1 AND e.edge_class = 'memory'${relFilter}`;
1473
- const hopTwo = `
1474
- SELECT e2.dst_path AS path, e2.rel AS rel, e2.derived AS derived, 2 AS hop
1475
- FROM edges e
1476
- JOIN edges e2 ON e2.src_path = e.dst_path
1477
- WHERE e.src_path = ?1 AND e.edge_class = 'memory' AND e2.edge_class = 'memory'
1478
- AND e2.dst_path <> ?1${relFilter}${relFilter2}
1479
- UNION ALL
1480
- SELECT e2.src_path AS path, e2.rel AS rel, e2.derived AS derived, 2 AS hop
1481
- FROM edges e
1482
- JOIN edges e2 ON e2.dst_path = e.src_path
1483
- WHERE e.dst_path = ?1 AND e.edge_class = 'memory' AND e2.edge_class = 'memory'
1484
- AND e2.src_path <> ?1${relFilter}${relFilter2}`;
1485
- const walk = depth === 1 ? hopOne : `${hopOne}\n UNION ALL${hopTwo}`;
1486
- const nodes = (yield* db.all(`SELECT w.path AS path, f.title AS title, min(w.hop) AS hop,
1487
- min(w.rel) AS rel, max(w.derived) AS derived
1488
- FROM (${walk}) w
1489
- JOIN files f ON f.path = w.path
1490
- GROUP BY w.path
1491
- ORDER BY hop ASC, w.path ASC`, [center, ...depth === 1 ? [...rels, ...rels] : [
1492
- ...rels,
1493
- ...rels,
1494
- ...rels,
1495
- ...rels,
1496
- ...rels,
1497
- ...rels
1498
- ]])).map((row) => ({
1499
- path: row.path,
1500
- title: row.title,
1501
- hop: row.hop,
1502
- rel: row.rel
1503
- }));
1747
+ const requested = normalizePath(path);
1748
+ const state = yield* readIndexState(db);
1749
+ const steps = [];
1750
+ const visited = /* @__PURE__ */ new Set([requested]);
1751
+ /** Titles of every indexed path the walk touched, so the answer carries one without a re-read. */
1752
+ const titles = /* @__PURE__ */ new Map();
1753
+ let at = requested;
1754
+ let stopReason = "unindexed";
1755
+ for (;;) {
1756
+ const row = yield* db.get(resolveQueries.file, [at]);
1757
+ if (row !== void 0) titles.set(at, row.title);
1758
+ const hop = row === void 0 ? {
1759
+ found: yield* db.get(resolveQueries.archived, [at]),
1760
+ via: "archive_move"
1761
+ } : {
1762
+ found: yield* db.get(resolveQueries.successor, [at]),
1763
+ via: "supersedes"
1764
+ };
1765
+ if (hop.found === void 0) {
1766
+ if (row === void 0) stopReason = "unindexed";
1767
+ else stopReason = row.archived === 1 ? "archived" : "live";
1768
+ break;
1769
+ }
1770
+ /**
1771
+ * The bound is checked BEFORE the step is taken, so `steps.length` is exactly
1772
+ * {@link RESOLVE_MAX_HOPS} when it fires and `path` is a real path the walk stood on. Taking the
1773
+ * step first would report a hop past a bound the answer claims to respect.
1774
+ */
1775
+ if (steps.length >= 16) {
1776
+ stopReason = "hop_limit";
1777
+ break;
1778
+ }
1779
+ steps.push({
1780
+ from: at,
1781
+ to: hop.found.path,
1782
+ via: hop.via
1783
+ });
1784
+ at = hop.found.path;
1785
+ /**
1786
+ * The repeat IS recorded as a step before the walk stops, so `steps` shows the loop closing and
1787
+ * a reader can name both ends of it. A cycle detected and then hidden would leave the caller
1788
+ * with a `path` it cannot account for.
1789
+ */
1790
+ if (visited.has(at)) {
1791
+ stopReason = "cycle";
1792
+ break;
1793
+ }
1794
+ visited.add(at);
1795
+ }
1504
1796
  return {
1505
- center,
1506
- depth,
1507
- nodes,
1508
- edges: nodes.length
1797
+ requested,
1798
+ path: at,
1799
+ hops: steps.length,
1800
+ steps,
1801
+ stopReason,
1802
+ title: titles.get(at) ?? null,
1803
+ indexedCommit: state?.head_sha ?? null
1509
1804
  };
1510
1805
  });
1511
1806
  /**
@@ -1539,9 +1834,20 @@ const listMemories = (params) => Effect.gen(function* () {
1539
1834
  values.push(params.tag);
1540
1835
  }
1541
1836
  if (params.entity !== void 0 && params.entity !== "") {
1542
- conditions.push("EXISTS (SELECT 1 FROM file_entities e WHERE e.path = f.path AND e.entity_type || ':' || e.entity_name = ?)");
1543
- values.push(params.entity);
1837
+ conditions.push("EXISTS (SELECT 1 FROM file_entities e WHERE e.path = f.path AND lower(e.entity_type || ':' || e.entity_name) = lower(?))");
1838
+ values.push(params.entity.trim());
1544
1839
  }
1840
+ /**
1841
+ * The facet axis, from `@memhtml/index`'s builder rather than a second copy of the grouping.
1842
+ *
1843
+ * The listing binds anonymous `?` in textual order, so the placeholder callback pushes onto
1844
+ * `values` and returns the marker. That is the same contract the numbered form has: whatever the
1845
+ * builder emits, the values it pushed are in the order the statement reads them.
1846
+ */
1847
+ for (const condition of facetConditions(params.facets ?? [], "f", (value) => {
1848
+ values.push(value);
1849
+ return "?";
1850
+ })) conditions.push(condition);
1545
1851
  if (params.cursor !== void 0 && params.cursor !== "") {
1546
1852
  conditions.push("f.path > ?");
1547
1853
  values.push(normalizePath(params.cursor));
@@ -1568,6 +1874,116 @@ const listMemories = (params) => Effect.gen(function* () {
1568
1874
  nextCursor
1569
1875
  };
1570
1876
  });
1877
+ /** The most rows one call returns. A caller asking for more is clamped into it. */
1878
+ const ENTITY_ACTIVITY_MAX = 500;
1879
+ /**
1880
+ * {@link entityActivity}'s statement, as a pure function of its parameters.
1881
+ *
1882
+ * Exported for `neighborsQuery`'s reason: a cost contract can only be asserted at the planner, and a
1883
+ * test that EXPLAINed a pasted copy of the SQL would be explaining its own string. This repo has
1884
+ * already written that test the other way and watched it keep passing while the clause it guarded was
1885
+ * deleted from the source. Handing the caller the statement the code issues is what makes the plan
1886
+ * assertion about the code.
1887
+ *
1888
+ * `count(*) OVER ()` counts the GROUPED rows, so it is the number of distinct entities in scope rather
1889
+ * than the number of `file_entities` rows. A window function is evaluated after grouping, which is what
1890
+ * makes one statement answer both the page and its total; a second `COUNT` over the same predicate
1891
+ * could disagree with this one under a concurrent write.
1892
+ *
1893
+ * `GROUP BY (entity_type, entity_name)` is exactly `file_entities_name`'s column SET, so the grouping
1894
+ * is served by an index scan rather than by a sort of the whole join. The SET is what matters and the
1895
+ * order within it does not: probed 2026-08-26 on node 24's `node:sqlite`, naming the two columns either
1896
+ * way plans identically as `SCAN e USING INDEX file_entities_name`, because SQLite reorders group keys
1897
+ * to match an index it can use. Grouping on a set the index does NOT cover — `entity_name` alone, or
1898
+ * `(path, entity_name)` — adds `USE TEMP B-TREE FOR GROUP BY`, a full sort of the join per call. The
1899
+ * ORDER BY is over an aggregate and no index can serve it, which is the one sort this statement pays
1900
+ * for knowingly.
1901
+ */
1902
+ const entityActivityQuery = (params = {}) => {
1903
+ const limit = Math.min(500, Math.max(1, Math.trunc(params.limit ?? 50)));
1904
+ const conditions = [];
1905
+ const values = [];
1906
+ if (params.includeArchived !== true) conditions.push("f.archived = 0");
1907
+ if (params.entityType !== void 0 && params.entityType !== "") {
1908
+ conditions.push("lower(e.entity_type) = lower(?)");
1909
+ values.push(params.entityType.trim());
1910
+ }
1911
+ return {
1912
+ sql: `SELECT e.entity_type AS entity_type, e.entity_name AS entity_name,
1913
+ count(*) AS file_count,
1914
+ max(coalesce(f.event_at, f.updated_at)) AS last_activity_at,
1915
+ max(f.event_at) AS last_event_at,
1916
+ max(f.updated_at) AS last_written_at,
1917
+ count(*) OVER () AS entity_total
1918
+ FROM file_entities e JOIN files f ON f.path = e.path
1919
+ ${conditions.length === 0 ? "" : `WHERE ${conditions.join(" AND ")}`}
1920
+ GROUP BY e.entity_type, e.entity_name
1921
+ ORDER BY max(coalesce(f.event_at, f.updated_at)) DESC,
1922
+ e.entity_type ASC, e.entity_name ASC
1923
+ LIMIT ?`,
1924
+ params: [...values, limit],
1925
+ limit
1926
+ };
1927
+ };
1928
+ /**
1929
+ * Every entity in the corpus with its file count and its last activity, newest first.
1930
+ *
1931
+ * **REPORT-ONLY, and that is a design constraint rather than a description of today's callers.** This
1932
+ * value must never become a decay term, a retention input, or a ranking signal. The salience arm
1933
+ * already refuses to rank two kinds of row for reasons that apply here word for word
1934
+ * (`SALIENCE_EXCLUDED_PREFIX` and `SALIENCE_EXCLUDED_TYPE`, `packages/index/src/retrieval-sql.ts`):
1935
+ * decay is wrong for identity, because a colleague unmentioned for six months is not less themselves,
1936
+ * and decay over working state would reward STALENESS, so the stuck task re-read at every triage would
1937
+ * outrank the fresh urgent one. An "entity last active" number wired into ranking reintroduces both at
1938
+ * once, on the axis where a consumer models its own domain. It answers a question an operator asks;
1939
+ * it decides nothing.
1940
+ *
1941
+ * **WRITE-side activity, deliberately, so the read stays inside one database.** Reads live in
1942
+ * `state.access`, which is path-keyed with NO foreign key onto `files`
1943
+ * (`packages/index/state-migrations/S0001_access.sql`) and which is ATTACHed as a separate plane —
1944
+ * `index.db` is a disposable projection of git and `state.db` is not. Joining it here would make one
1945
+ * report span both lifetimes, so a rebuilt index and a preserved state plane could disagree about a
1946
+ * row. The salience arm already owns read-time signals and is the only statement that crosses that
1947
+ * boundary.
1948
+ *
1949
+ * Every memory type counts, tasks included, matching {@link listMemories} rather than
1950
+ * `activeEntities` in `@memhtml/sleep`. That function excludes tasks because it FEEDS a phase that
1951
+ * mints person files from what it finds, and a person mentioned only by a to-do item would get a
1952
+ * durable identity surface out of it. Nothing here mints anything, and a report that hid an entity's
1953
+ * task activity would be answering a narrower question than the one asked.
1954
+ *
1955
+ * `entityCount` is the total matching the scope, independent of `limit`, so a clamped answer is
1956
+ * visible rather than silent — a caller can tell "these are all of them" from "these are the newest
1957
+ * of more".
1958
+ *
1959
+ * **A row is one STORED reference, not one folded identity.** The grouping is on `(entity_type,
1960
+ * entity_name)` as `file_entities` holds them, so a corpus that authored both `Service:Checkout-API`
1961
+ * and `service:checkout-api` reports two rows while `--entity` at either retrieval door folds them and
1962
+ * returns one entity's memories. That is the honest report of an unresolved corpus — `entity-resolution`
1963
+ * is the phase that folds spellings, and a report that folded them first would hide the work it has to
1964
+ * do — but it means `fileCount` is per stored spelling and a caller summing rows to a per-name total
1965
+ * has to fold them itself.
1966
+ */
1967
+ const entityActivity = (params = {}) => Effect.gen(function* () {
1968
+ const db = yield* DatabaseService;
1969
+ const statement = entityActivityQuery(params);
1970
+ const rows = yield* db.all(statement.sql, statement.params);
1971
+ return {
1972
+ entities: rows.map((row) => ({
1973
+ entity: `${row.entity_type}:${row.entity_name}`,
1974
+ entityType: row.entity_type,
1975
+ entityName: row.entity_name,
1976
+ fileCount: row.file_count,
1977
+ lastActivityAt: row.last_activity_at,
1978
+ lastEventAt: row.last_event_at,
1979
+ lastWrittenAt: row.last_written_at
1980
+ })),
1981
+ /** Distinct entities matching the scope, before `limit`. `0` when the scope matched nothing. */
1982
+ entityCount: rows[0]?.entity_total ?? 0,
1983
+ /** The bound this answer was built under, so a clamped ask is legible rather than silent. */
1984
+ limit: statement.limit
1985
+ };
1986
+ });
1571
1987
  /**
1572
1988
  * Move a task to a new status.
1573
1989
  *
@@ -1582,9 +1998,9 @@ const listMemories = (params) => Effect.gen(function* () {
1582
1998
  * fifth value every archive, correction, and publish path would have to learn. The stamp is written
1583
1999
  * before the move so both land in one commit and `git log --follow` reads through it.
1584
2000
  *
1585
- * `indexer.update()` afterwards rather than `indexPaths`, because the `done` transition is a rename and
1586
- * `indexPaths` cannot express one. It would leave the pre-archive row live, duplicate the chunks
1587
- * under two paths, and skip the watermark (finding from T9, stated at {@link reindex}).
2001
+ * Reindexed through {@link reindex}, which diffs the whole commit. The `done` transition is a rename,
2002
+ * and only a diff expresses one: indexing the destination path alone leaves the pre-archive row live,
2003
+ * duplicates the chunks under two paths, and records no watermark.
1588
2004
  */
1589
2005
  const setTaskStatus = (params) => Effect.gen(function* () {
1590
2006
  const status = yield* decodeTaskStatus(params.status);
@@ -1684,7 +2100,7 @@ const DETECTED_TASK_GLOB = `*/${DETECTION_PREFIX}${"[0-9a-f]".repeat(12)}-*.html
1684
2100
  * memory-graph query filters on, and a reader who saw this one query trust the rel alone would learn
1685
2101
  * the wrong rule about how the firewall is enforced.
1686
2102
  *
1687
- * `group_concat` over an ordered subselect, probed 2026-08-12 on node 24.19.0. The inner `ORDER BY`
2103
+ * `group_concat` over an ordered subselect, probed 2026-08-12 on node 24.19.0: the inner `ORDER BY`
1688
2104
  * is preserved, and `char(10)` is the separator because a path cannot contain a newline while it can
1689
2105
  * contain a comma.
1690
2106
  *
@@ -1810,7 +2226,18 @@ const indexTraces = () => Effect.gen(function* () {
1810
2226
  let merged = 0;
1811
2227
  for (const scanned of report.files) {
1812
2228
  const outcome = yield* persistScanned(db, scanned, tailMerger, at);
1813
- if (outcome.action !== "skip") sessionsWritten += 1;
2229
+ /**
2230
+ * `sessionsWritten` counts files for which a `traces` ROW was written, which is exactly the
2231
+ * files `persistScanned` returns a session id for. It writes a row only for a non-skip
2232
+ * carrying an extract and a session id, so the three files it declines — a skip, a failed
2233
+ * read (a null extract), and a `file-history-*`-only file with no session to be about — are
2234
+ * each not a session written.
2235
+ *
2236
+ * The action alone cannot answer this. A failed read keeps the action the PLAN named, `tail`
2237
+ * or `rescan`, because the watermark logic needs to know what was attempted; a report that
2238
+ * read the action as the write would claim a session for a transcript that errored.
2239
+ */
2240
+ if (outcome.sessionId !== null) sessionsWritten += 1;
1814
2241
  if (outcome.merged) merged += 1;
1815
2242
  promptsWritten += outcome.promptsWritten;
1816
2243
  }
@@ -1820,6 +2247,7 @@ const indexTraces = () => Effect.gen(function* () {
1820
2247
  skipped: report.skipped,
1821
2248
  tailed: report.tailed,
1822
2249
  rescanned: report.rescanned,
2250
+ filesFailed: report.failed,
1823
2251
  bytesRead: report.bytesRead,
1824
2252
  sessionsWritten,
1825
2253
  promptsWritten,
@@ -1879,6 +2307,12 @@ const searchTraces = (params) => Effect.gen(function* () {
1879
2307
  };
1880
2308
  });
1881
2309
  /**
2310
+ * Rows one `trace links` answer may carry. Every sibling read clamps (`memory_list` 500,
2311
+ * `trace_search` 200), and a long-lived session accretes links without bound, so an unclamped
2312
+ * answer grows forever. Newest first, so the truncation costs the oldest links.
2313
+ */
2314
+ const TRACE_LINKS_LIMIT = 500;
2315
+ /**
1882
2316
  * The memory-session links, from either side.
1883
2317
  *
1884
2318
  * Both parameters absent is a refusal rather than an unbounded scan of every link ever recorded. A
@@ -1902,7 +2336,8 @@ const traceLinks = (params) => Effect.gen(function* () {
1902
2336
  return { links: (yield* db.all(`SELECT l.path, l.session_id, l.prompt_id, l.turn_uuid, l.link_kind, l.at
1903
2337
  FROM memory_session_links l
1904
2338
  WHERE ${conditions.join(" AND ")}
1905
- ORDER BY l.at DESC, l.path ASC`, values)).map((row) => ({
2339
+ ORDER BY l.at DESC, l.path ASC
2340
+ LIMIT ${TRACE_LINKS_LIMIT}`, values)).map((row) => ({
1906
2341
  path: row.path,
1907
2342
  sessionId: row.session_id,
1908
2343
  promptId: row.prompt_id,
@@ -1967,27 +2402,46 @@ const countRows = (db, sql) => db.all(sql).pipe(Effect.map((rows) => Object.from
1967
2402
 
1968
2403
  //#endregion
1969
2404
  //#region apps/cli/src/commands.ts
1970
- /** Flags every command accepts. Listed once so the manifest cannot drift from behavior. */
1971
- const GLOBAL_FLAGS = [
1972
- {
1973
- name: "json",
1974
- type: "boolean",
1975
- description: "Emit the typed JSON envelope on stdout (default; logs go to stderr).",
1976
- default: true
1977
- },
1978
- {
1979
- name: "dense",
1980
- type: "boolean",
1981
- description: "Minify JSON and drop null fields, for pasting into a context window.",
1982
- default: false
1983
- },
1984
- {
1985
- name: "repo",
1986
- type: "string",
1987
- description: "Path to the memory repo. Defaults to $MEMHTML_ROOT.",
1988
- default: ""
1989
- }
1990
- ];
2405
+ /**
2406
+ * Flags every command accepts. Listed once so the manifest cannot drift from behavior.
2407
+ *
2408
+ * There is no `--json` flag: the typed JSON envelope is the only output the binary has, on every
2409
+ * command, so a flag for it would be parsed, advertised, and read by nothing. Logs go to stderr.
2410
+ */
2411
+ const GLOBAL_FLAGS = [{
2412
+ name: "dense",
2413
+ type: "boolean",
2414
+ description: "Minify JSON and drop null fields, for pasting into a context window.",
2415
+ default: false
2416
+ }, {
2417
+ name: "repo",
2418
+ type: "string",
2419
+ description: "Path to the memory repo. Defaults to $MEMHTML_ROOT.",
2420
+ default: ""
2421
+ }];
2422
+ /**
2423
+ * The `--strict-path` help.
2424
+ *
2425
+ * It states the DEFAULT as well as the opt-in, because the default is the surprising half: a caller
2426
+ * reaching for this flag is a caller who just discovered that a malformed `--path` was re-derived, and
2427
+ * the help has to confirm that reading rather than leave it inferred. The refusal's code is named too,
2428
+ * since a caller branches on `code` and never on the prose.
2429
+ */
2430
+ const STRICT_PATH_FLAG = "Refuse an unusable --path instead of letting the placement rule decide. By default a --path that is not a usable memory path is re-derived, so the memory lands somewhere you did not name and the response reports that other path as a success. With this flag the write is REFUSED with ERR_INVALID_MEMORY naming the clause the path broke, and nothing is written, staged, or committed. It governs the path you NAMED: with no --path there is nothing to be strict about and the flag changes nothing, while an EMPTY or blank --path is named rather than absent and is refused — that is what your own path template renders when it produced nothing. An OCCUPIED path is refused with ERR_WRITE_CONFLICT with or without it.";
2431
+ /**
2432
+ * The `--facet` help, shared by every command that scopes on one.
2433
+ *
2434
+ * The composition rule is IN the help because it is a semantic contract rather than a convenience: a
2435
+ * caller who read `--facet a=1 --facet b=2` as "either" would act on a superset, and one who read
2436
+ * `--facet a=1 --facet a=2` as "both" would act on an empty result. Neither mistake is visible in
2437
+ * the rows that come back.
2438
+ *
2439
+ * The unitless clause is there for the same reason. `file_facets.numeric_value` exists, and offering
2440
+ * a numeric comparison over it would be offering an inequality on an unlabelled number — the unit
2441
+ * lives in the human phrasing beside the value, so the caller owns it, and it owns it by matching the
2442
+ * text the corpus holds.
2443
+ */
2444
+ const FACET_FLAG = "Restrict to memories carrying a `<dl>` facet, as name=value; the value may contain `=`, the name may not. Repeatable, and the composition is fixed: values under the SAME name broaden (--facet doc-type=runbook --facet doc-type=guide is either), DIFFERENT names narrow (--facet doc-type=runbook --facet tier=1 is both). This is the extension axis: memhtml's element and meta vocabularies are closed, so a consumer's own document kinds, states, and tiers live in `<dt>`/`<dd>` pairs and are queried here. The match is on the facet's TEXT with no case folding, so write the halves you mean to query. The stored form is the element's text content, which the parser collapses whitespace runs in and trims — so `<dd>runbook rollback</dd>` is stored and queried single-spaced. There is no numeric comparison: a `<data value>` is indexed UNITLESS because the unit lives in the prose beside it, so the caller owns the unit and matches the text it wrote.";
1991
2445
  /** Flags every retrieval command shares, so `search` and `recall` cannot scope differently. */
1992
2446
  const SCOPE_FLAGS = [
1993
2447
  {
@@ -2013,6 +2467,12 @@ const SCOPE_FLAGS = [
2013
2467
  type: "string",
2014
2468
  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."
2015
2469
  },
2470
+ {
2471
+ name: "facet",
2472
+ type: "string",
2473
+ description: FACET_FLAG,
2474
+ repeatable: true
2475
+ },
2016
2476
  {
2017
2477
  name: "include-archived",
2018
2478
  type: "boolean",
@@ -2087,7 +2547,13 @@ const COMMANDS = [
2087
2547
  {
2088
2548
  name: "path",
2089
2549
  type: "string",
2090
- description: "An explicit path override. Ignored when it is not a valid memory path."
2550
+ description: "An explicit path override. One that is not a usable memory path (rooted in a PARA bucket, ending in .html, no `.` or `..` segment) is IGNORED and the placement rule decides instead, so a malformed override lands the memory somewhere you did not name — pass --strict-path to have it refused instead. One a file ALREADY occupies is REFUSED with ERR_WRITE_CONFLICT and nothing is written or committed: this corpus overwrites nothing, and an explicit path gets no `-2` suffix because you named one path. To replace what a memory says, use `memhtml correct <path>`."
2551
+ },
2552
+ {
2553
+ name: "strict-path",
2554
+ type: "boolean",
2555
+ description: STRICT_PATH_FLAG,
2556
+ default: false
2091
2557
  },
2092
2558
  {
2093
2559
  name: "workspace",
@@ -2141,7 +2607,7 @@ const COMMANDS = [
2141
2607
  {
2142
2608
  name: "file",
2143
2609
  type: "string",
2144
- description: "The JSONL file to read. One complete JSON object per line. Omit it (or pass `-`) to read the stream from stdin."
2610
+ description: "The JSONL file to read. One complete JSON object per line. Omit it, pass `--file -`, or pass a positional `-` to read the stream from stdin; stdin beside a real --file is refused."
2145
2611
  },
2146
2612
  {
2147
2613
  name: "continue-on-error",
@@ -2187,11 +2653,31 @@ const COMMANDS = [
2187
2653
  description: "Repo-root-relative path to the memory.",
2188
2654
  required: true
2189
2655
  }],
2190
- flags: [{
2191
- name: "session-id",
2192
- type: "string",
2193
- description: "Records a `read` session link, so provenance is queryable both ways."
2194
- }],
2656
+ /**
2657
+ * The whole provenance triple, because the `read` arm stamps the whole triple.
2658
+ * `memory_session_links` carries `prompt_id` and `turn_uuid` beside `session_id`, so a command
2659
+ * that declared only the session would record a coarser link for a read than the write path
2660
+ * records for the same turn, and one triple could not be threaded through a write-then-read
2661
+ * flow. MCP's `memory_read` narrows to `session_id`; this surface is the one an agent threads a
2662
+ * triple through.
2663
+ */
2664
+ flags: [
2665
+ {
2666
+ name: "session-id",
2667
+ type: "string",
2668
+ description: "Records a `read` session link, so provenance is queryable both ways."
2669
+ },
2670
+ {
2671
+ name: "prompt-id",
2672
+ type: "string",
2673
+ description: "The prompt within that session."
2674
+ },
2675
+ {
2676
+ name: "turn-uuid",
2677
+ type: "string",
2678
+ description: "The turn within that session."
2679
+ }
2680
+ ],
2195
2681
  responseTypes: ["memory.detail"]
2196
2682
  },
2197
2683
  {
@@ -2272,6 +2758,16 @@ const COMMANDS = [
2272
2758
  name: "session-id",
2273
2759
  type: "string",
2274
2760
  description: "Records a `corrected` session link."
2761
+ },
2762
+ {
2763
+ name: "prompt-id",
2764
+ type: "string",
2765
+ description: "The prompt within that session."
2766
+ },
2767
+ {
2768
+ name: "turn-uuid",
2769
+ type: "string",
2770
+ description: "The turn within that session."
2275
2771
  }
2276
2772
  ],
2277
2773
  responseTypes: ["memory.corrected"]
@@ -2307,20 +2803,40 @@ const COMMANDS = [
2307
2803
  description: "The center of the neighborhood.",
2308
2804
  required: true
2309
2805
  }],
2310
- flags: [{
2311
- name: "depth",
2312
- type: "int",
2313
- description: "1 or 2. Never more.",
2314
- default: 1
2315
- }, {
2316
- name: "rel",
2317
- type: "string",
2318
- description: "Restrict to these rels. Repeatable.",
2319
- values: MEMORY_RELS,
2320
- repeatable: true
2321
- }],
2806
+ flags: [
2807
+ {
2808
+ name: "depth",
2809
+ type: "int",
2810
+ description: "1 or 2. Never more.",
2811
+ default: 1
2812
+ },
2813
+ {
2814
+ name: "limit",
2815
+ type: "int",
2816
+ description: "Distinct nodes to return, clamped to 200. `nodesDropped` counts the paths the walk reached and this limit turned away, and `scanSaturated` says the walk stopped at its own 10000-row cap, which no limit recovers.",
2817
+ default: 200
2818
+ },
2819
+ {
2820
+ name: "rel",
2821
+ type: "string",
2822
+ description: "Restrict to these rels. Repeatable.",
2823
+ values: MEMORY_RELS,
2824
+ repeatable: true
2825
+ }
2826
+ ],
2322
2827
  responseTypes: ["memory.neighbors"]
2323
2828
  },
2829
+ {
2830
+ name: "resolve",
2831
+ summary: "Follow a possibly-moved path forward to the memory that carries the fact now.",
2832
+ args: [{
2833
+ name: "path",
2834
+ description: "The path a receipt, citation, or older answer recorded.",
2835
+ required: true
2836
+ }],
2837
+ flags: [],
2838
+ responseTypes: ["memory.resolved"]
2839
+ },
2324
2840
  {
2325
2841
  name: "archive",
2326
2842
  summary: "Soft-evict: `git mv` into archive/<YYYY>/ with the archive stamps. Never a delete.",
@@ -2343,7 +2859,8 @@ const COMMANDS = [
2343
2859
  args: [{
2344
2860
  name: "path",
2345
2861
  description: "A memory path. Repeat the argument for more.",
2346
- required: true
2862
+ required: true,
2863
+ repeatable: true
2347
2864
  }],
2348
2865
  flags: [{
2349
2866
  name: "signal",
@@ -2356,7 +2873,7 @@ const COMMANDS = [
2356
2873
  },
2357
2874
  {
2358
2875
  name: "list",
2359
- summary: "Page through the corpus by type, workspace, tag, entity, or PARA bucket.",
2876
+ summary: "Page through the corpus by type, workspace, tag, entity, facet, or PARA bucket.",
2360
2877
  args: [],
2361
2878
  flags: [
2362
2879
  {
@@ -2380,6 +2897,12 @@ const COMMANDS = [
2380
2897
  type: "string",
2381
2898
  description: "One `type:name` entity reference."
2382
2899
  },
2900
+ {
2901
+ name: "facet",
2902
+ type: "string",
2903
+ description: FACET_FLAG,
2904
+ repeatable: true
2905
+ },
2383
2906
  {
2384
2907
  name: "para",
2385
2908
  type: "string",
@@ -2411,6 +2934,31 @@ const COMMANDS = [
2411
2934
  ],
2412
2935
  responseTypes: ["memory.list"]
2413
2936
  },
2937
+ {
2938
+ name: "entity activity",
2939
+ summary: "Every entity with its file count and its last activity, newest first. Report only.",
2940
+ args: [],
2941
+ flags: [
2942
+ {
2943
+ name: "type",
2944
+ type: "string",
2945
+ description: "Restrict to one entity type, e.g. `service`. The half before the colon in a `type:name` reference."
2946
+ },
2947
+ {
2948
+ name: "limit",
2949
+ type: "int",
2950
+ description: "Rows to return, 1 to 500. An ask outside that is clamped into it rather than refused, and `limit` echoes the bound the answer was built under. `entityCount` is the total matching the scope, so a clamped answer is visible.",
2951
+ default: 50
2952
+ },
2953
+ {
2954
+ name: "include-archived",
2955
+ type: "boolean",
2956
+ description: "Aggregate archived memories too. Excluded by default: eviction is a `git mv`, so an archived memory still exists and would otherwise keep an entity looking active.",
2957
+ default: false
2958
+ }
2959
+ ],
2960
+ responseTypes: ["entity.activity"]
2961
+ },
2414
2962
  (
2415
2963
  /**
2416
2964
  * The task family: CRUDL over the 10th memory type, without retrieval.
@@ -2640,12 +3188,13 @@ const COMMANDS = [
2640
3188
  {
2641
3189
  name: "sleep run",
2642
3190
  /**
2643
- * The count is DERIVED from `SLEEP_PHASES`, not typed. Both strings used to spell `15` by hand,
2644
- * and adding a sixteenth phase left `AGENTS.md` and `memhtml manifest` asserting a number the list
2645
- * beside them contradicted visible only because the doc drift gate compares generated bytes, and
2646
- * only for the one of the two that also prints the names.
3191
+ * Both counts are `SLEEP_PHASES.length`, never typed. A phase added to that list moves this
3192
+ * summary and the `--phases` description below together, so neither can assert a number the list
3193
+ * printed beside it contradicts. A hand-typed count is not symmetrically caught: the doc drift
3194
+ * gate compares generated bytes, so it fails only on the string that also prints the names, and a
3195
+ * stale number in the other one ships.
2647
3196
  */
2648
- summary: `The nightly curation cycle: ${SLEEP_PHASES.length} phases, each an isolated commit on a review branch.`,
3197
+ summary: `The curation cycle: ${SLEEP_PHASES.length} phases, each an isolated commit on a review branch.`,
2649
3198
  args: [],
2650
3199
  flags: [
2651
3200
  {
@@ -2663,6 +3212,17 @@ const COMMANDS = [
2663
3212
  type: "boolean",
2664
3213
  description: "Report per-phase counts and commit nothing.",
2665
3214
  default: false
3215
+ },
3216
+ {
3217
+ name: "deep",
3218
+ type: "boolean",
3219
+ description: "The deep-sleep cycle: mine a lower grouping band, group by shared entity, re-file inbox singletons, and iterate compress until a pass folds nothing. Reaches the inbox tail the default community gate cannot; costs more model calls. Same branch, review, and merge gate as a run without this flag.",
3220
+ default: false
3221
+ },
3222
+ {
3223
+ name: "max-llm-calls",
3224
+ type: "int",
3225
+ description: "Cap on model calls the deep mechanisms may spend, shared across all deep phases. Exhaustion skips remaining batches with reason `budget` and the run stays green. Read only with --deep; absent means uncapped."
2666
3226
  }
2667
3227
  ],
2668
3228
  responseTypes: ["sleep.report"]
@@ -2717,6 +3277,13 @@ const COMMANDS = [
2717
3277
  flags: [],
2718
3278
  responseTypes: ["sleep.report"]
2719
3279
  },
3280
+ {
3281
+ name: "sleep plan",
3282
+ summary: "Would a run change anything? Read the signals from index counts, running no phase.",
3283
+ args: [],
3284
+ flags: [],
3285
+ responseTypes: ["sleep.plan"]
3286
+ },
2720
3287
  {
2721
3288
  name: "status",
2722
3289
  summary: "Corpus health: HEAD, dirty state, counts by type, edges, index freshness.",
@@ -2760,6 +3327,11 @@ const COMMANDS = [
2760
3327
  type: "int",
2761
3328
  description: "The fixture corpus seed. A failing run is reproducible from this number."
2762
3329
  },
3330
+ {
3331
+ name: "now",
3332
+ type: "int",
3333
+ description: "The run instant the fixture corpus anchors its stamps behind, UTC milliseconds since the epoch. The other half of reproducing a failing run: the corpus is a function of (seed, now), and the recency arm ranks on those stamps. Defaults to the clock, and rides back in the report."
3334
+ },
2763
3335
  {
2764
3336
  name: "size",
2765
3337
  type: "int",
@@ -2820,7 +3392,7 @@ const COMMANDS = [
2820
3392
  {
2821
3393
  name: "file",
2822
3394
  type: "string",
2823
- 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`."
3395
+ description: "The script to run, as a path on the HOST. Omit it, pass `--file -`, or pass a positional `-` to read the script from stdin. Mutually exclusive with `--script`."
2824
3396
  },
2825
3397
  {
2826
3398
  name: "script",
@@ -2873,7 +3445,7 @@ const COMMANDS = [
2873
3445
  },
2874
3446
  {
2875
3447
  name: "serve mcp",
2876
- summary: "Run the `memhtml-mcp` stdio server: 14 tools and 2 resources over this same repo.",
3448
+ summary: "Run the `memhtml-mcp` stdio server: 15 tools and 3 resources over this same repo.",
2877
3449
  args: [],
2878
3450
  flags: [],
2879
3451
  responseTypes: ["serve.exit"]
@@ -2908,12 +3480,12 @@ const GUIDE = [
2908
3480
  },
2909
3481
  {
2910
3482
  topic: "write-surfaces",
2911
- 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."
3483
+ 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 the same tools and 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. `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."
2912
3484
  },
2913
3485
  {
2914
3486
  topic: "when-to-batch",
2915
3487
  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:
2916
- ${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.`
3488
+ ${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\`, \`strict_path\`, \`workspace\`, \`tag\`, \`entity\`, \`importance\`, \`confidence\`, \`status\`, \`due\`, \`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.`
2917
3489
  },
2918
3490
  {
2919
3491
  topic: "conflicts",
@@ -2925,7 +3497,7 @@ ${GUIDE_OP_EXAMPLE}\n\`op\` is \`write\` (the only verb in the vocabulary today)
2925
3497
  },
2926
3498
  {
2927
3499
  topic: "code-mode",
2928
- 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."
3500
+ 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` 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."
2929
3501
  }
2930
3502
  ];
2931
3503
  const GUIDE_TOPICS = GUIDE.map((block) => block.topic);
@@ -2936,13 +3508,14 @@ const GUIDE_TOPICS = GUIDE.map((block) => block.topic);
2936
3508
  */
2937
3509
  const buildManifest = () => ({
2938
3510
  name: "memhtml",
2939
- version: "0.5.1",
3511
+ version: "0.7.0",
2940
3512
  summary: "Read, write, and curate the git-backed memory repo.",
2941
3513
  apiVersion: "1",
2942
3514
  /**
2943
3515
  * The prose an agent needs before the command table means anything, so it is listed before it.
2944
- * A manifest that opened with 33 command specifications makes an agent infer the workflow from a
2945
- * surface, while `guide` states it.
3516
+ * A manifest that opens with the command specifications makes an agent infer the workflow from a
3517
+ * surface, while `guide` states it. No count appears here: `commands` is `COMMANDS` walked, so the
3518
+ * only honest quantity is its `length`.
2946
3519
  */
2947
3520
  guide: GUIDE,
2948
3521
  globalFlags: GLOBAL_FLAGS,
@@ -3142,22 +3715,22 @@ const runAgentsDoc = (options) => Effect.gen(function* () {
3142
3715
  * Prose → claim derivation: the single implementation both write doors use.
3143
3716
  *
3144
3717
  * The tools take `{title, body}` because that is what a model produces, and the format needs a
3145
- * `<mark>` claim plus one `<p>` per paragraph. Turning the first into the second is a text heuristic,
3146
- * and it lives here for two reasons. It was duplicated once, as `claimOf`/`restOf` in `apps/mcp` and
3147
- * `claimFromProse`/`proseTail` in `apps/cli`, the same regex in two packages. A sentence-splitting
3148
- * rule that drifts between the doors also makes `memhtml apply` and `memory_write_batch` derive different
3149
- * claims from the same body, so the gist of a memory would depend on which door wrote it.
3718
+ * `<mark>` claim plus one `<p>` per paragraph. Turning the first into the second is a text
3719
+ * heuristic, and it must have exactly one copy: `apps/mcp` and `apps/cli` both import this module,
3720
+ * because a sentence-splitting rule maintained per door lets `memhtml apply` and
3721
+ * `memory_write_batch` derive different claims from the same body, making the gist of a memory
3722
+ * depend on which door wrote it.
3150
3723
  *
3151
3724
  * It does not live in `@memhtml/html`, which owns markup and the format's own rules. "Where does a
3152
3725
  * sentence end" is a guess about natural-language prose, and the format states no such constraint. It
3153
3726
  * is not in `operations.ts` either, because that module holds the use cases both doors call, and this
3154
3727
  * is a text helper they apply before calling one.
3155
3728
  *
3156
- * The derivation is defense in depth now; it was once the only guard. `@memhtml/html` constraint 1 now
3157
- * rejects an empty `<mark>` outright, so a door that skipped this would be stopped by the store's
3158
- * render gate instead of landing a file with an empty `files.gist`. What is left here is the
3159
- * authoring convenience the doors exist to provide: a JSONL line and an MCP call carry no `claim`
3160
- * field, so the door derives one instead of asking an author to restate the body's first sentence.
3729
+ * The derivation is defense in depth: `@memhtml/html` constraint 1 rejects an empty `<mark>`
3730
+ * outright, so a door that skipped this would be stopped by the store's render gate rather than
3731
+ * landing a file with an empty `files.gist`. What this module carries is the authoring convenience
3732
+ * the doors exist to provide: a JSONL line and an MCP call carry no `claim` field, so the door
3733
+ * derives one instead of asking an author to restate the body's first sentence.
3161
3734
  */
3162
3735
  /**
3163
3736
  * Split prose into paragraphs on blank lines, dropping the empties. Inside a fenced code block a
@@ -3260,11 +3833,20 @@ const LIST_FIELDS = {
3260
3833
  entity: "entities",
3261
3834
  entities: "entities"
3262
3835
  };
3836
+ /**
3837
+ * Fields that must be a JSON boolean.
3838
+ *
3839
+ * Its own table because {@link SCALAR_FIELDS}' decode refuses anything that is not a string, and a
3840
+ * boolean spelled `"true"` is a different value from `true` on this wire. A caller that sent the
3841
+ * string would otherwise get a strict-path ask that reads as satisfied and behaves as absent.
3842
+ */
3843
+ const BOOLEAN_FIELDS = { strict_path: "strictPath" };
3263
3844
  /** `op` is the discriminator rather than a `WriteParams` field, so it is legal and never mapped. */
3264
3845
  const KNOWN_FIELDS = /* @__PURE__ */ new Set([
3265
3846
  "op",
3266
3847
  ...Object.keys(SCALAR_FIELDS),
3267
- ...Object.keys(LIST_FIELDS)
3848
+ ...Object.keys(LIST_FIELDS),
3849
+ ...Object.keys(BOOLEAN_FIELDS)
3268
3850
  ]);
3269
3851
  /** A usage failure naming the offending line, 1-based as a text editor counts. */
3270
3852
  const lineError = (code, line, reason, suggestions = []) => fail(code, `${APPLY_DOC}: line ${line}: ${reason}`, suggestions);
@@ -3350,6 +3932,12 @@ const opAt = (record, line) => {
3350
3932
  if (isFailure(parsed)) return parsed;
3351
3933
  params[target] = [...params[target] ?? [], ...parsed];
3352
3934
  }
3935
+ for (const [field, target] of Object.entries(BOOLEAN_FIELDS)) {
3936
+ const value = record[field];
3937
+ if (value === void 0 || value === null) continue;
3938
+ if (typeof value !== "boolean") return lineError("ERR_INVALID_FLAG", line, `\`${field}\` must be a boolean, got ${typeof value}. JSON \`true\`, not the string "true"`);
3939
+ params[target] = value;
3940
+ }
3353
3941
  /**
3354
3942
  * `body` prose becomes claim + tail; `article_html` is used verbatim and leaves `claim` empty.
3355
3943
  *
@@ -3358,10 +3946,10 @@ const opAt = (record, line) => {
3358
3946
  * has no `claim` field, so a prose line's claim is derived rather than restated by its author (see
3359
3947
  * {@link claimFromProse}, the one copy both doors share).
3360
3948
  *
3361
- * Skipping this no longer lands a bad file. `@memhtml/html` constraint 1 rejects an empty `<mark>`, so
3362
- * the render gate would stop the op instead of committing a file with an empty `files.gist`. The
3363
- * derivation is what makes a prose line valid in the first place. It is no longer the only thing
3364
- * standing between a missing claim and a silent write.
3949
+ * Skipping this cannot land a bad file. `@memhtml/html` constraint 1 rejects an empty `<mark>`,
3950
+ * so the render gate would stop the op instead of committing a file with an empty `files.gist`.
3951
+ * The derivation is what makes a prose line valid in the first place; the render gate is the
3952
+ * guard between a missing claim and a silent write.
3365
3953
  */
3366
3954
  const prose = typeof params.body === "string" ? params.body : void 0;
3367
3955
  if (prose !== void 0 && prose.trim() !== "") {
@@ -3494,7 +4082,7 @@ const applyPayload = (result) => ({
3494
4082
  * `memhtml doctor`: the corpus's own health check, and `--fix` for the two findings a repair can settle
3495
4083
  * without a judgement call.
3496
4084
  *
3497
- * Eight checks, and each one is a claim the design makes about the corpus rather than a lint:
4085
+ * Nine checks, and each one is a claim the design makes about the corpus rather than a lint:
3498
4086
  *
3499
4087
  * 1. **Dangling `<link>` hrefs**: an authored edge pointing at a path the tree does not hold. Design
3500
4088
  * §2.3 has no foreign key on `edges` deliberately (a `<link>` may name a file the indexer has not
@@ -3518,8 +4106,12 @@ const applyPayload = (result) => ({
3518
4106
  * is a task waiting on something that will never move.
3519
4107
  * 8. **Task inbox depth**: a task in `areas/inbox/tasks/` is work with no project, and a task inbox
3520
4108
  * is meant to be drained rather than accumulated.
4109
+ * 9. **Untyped entity references**: a `memhtml-entity` meta written as a bare name indexes under the
4110
+ * `unknown` type, which is supported, and is therefore unreachable by the typed reference the
4111
+ * `entity` scope requires. The query returns an empty set rather than an error, so a producer
4112
+ * emitting bare names makes its memories unfindable with nothing anywhere reporting it.
3521
4113
  *
3522
- * **`--fix` repairs exactly two of the eight, and the repair logic is imported from the sleep
4114
+ * **`--fix` repairs exactly two of the nine, and the repair logic is imported from the sleep
3523
4115
  * integrity phase rather than re-ported.** `archivedFormOf` decides whether a dangling target moved
3524
4116
  * to the archive or is genuinely gone, and `applyHeadEdits`/`link`/`unlink`/`meta` are the byte-splice
3525
4117
  * editors that change one head line without touching the article. A parse→serialize round trip drops
@@ -3527,11 +4119,12 @@ const applyPayload = (result) => ({
3527
4119
  * every file it touched. A second implementation of either would be the consumer-side reimplementation
3528
4120
  * of producer semantics the fleet has paid for repeatedly.
3529
4121
  *
3530
- * The other six report and do not repair. An inbox memory or task needs a human or an agent to decide
4122
+ * The other seven report and do not repair. An inbox memory or task needs a human or an agent to decide
3531
4123
  * where it belongs, a vocabulary warning needs the author's intent, and a stale index needs
3532
4124
  * `memhtml index update`, which doctor names in its own suggestions rather than running behind the
3533
4125
  * operator's back. An overdue task needs the work done or the deadline moved, and a stale blocker
3534
- * needs someone to decide whether the blocked task is actually ready.
4126
+ * needs someone to decide whether the blocked task is actually ready. An untyped entity needs the
4127
+ * producer that wrote it to name a type, which is a vocabulary decision no repair can make.
3535
4128
  */
3536
4129
  /** How deep the inbox may get before doctor calls it a finding. */
3537
4130
  const INBOX_WARN_DEPTH = 20;
@@ -3543,6 +4136,14 @@ const INBOX_WARN_DEPTH = 20;
3543
4136
  * is the state a to-do list rots in, and a task inbox is meant to be drained rather than accumulated.
3544
4137
  */
3545
4138
  const INBOX_TASK_WARN_DEPTH = 10;
4139
+ /**
4140
+ * How many distinct untyped entity names a report lists before truncating.
4141
+ *
4142
+ * A bound rather than the whole set, because the count is a signal about a PRODUCER and twenty examples
4143
+ * name it as well as two thousand would. `untypedEntityTotal` carries the real cardinality beside the
4144
+ * sample, so a truncated list can never be mistaken for the whole one.
4145
+ */
4146
+ const UNTYPED_ENTITY_SAMPLE = 20;
3546
4147
  /** Every `state.access` path the index has no `files` row for. */
3547
4148
  const orphanAccess = (db) => db.hasState ? db.all(`SELECT a.path AS path FROM ${STATE_SCHEMA}.access a
3548
4149
  LEFT JOIN files f ON f.path = a.path
@@ -3569,8 +4170,8 @@ const inboxTaskDepth = (db) => db.get(`SELECT count(*) AS n FROM files
3569
4170
  * "overdue" meaning the same thing in the two places an operator reads it.
3570
4171
  *
3571
4172
  * **`archived = 0` and `task_status <> 'done'` both change the result** (mutation-verified
3572
- * 2026-08-02). A finished task's deadline is history, and reporting it would make the finding grow
3573
- * forever and never reach zero.
4173
+ * 2026-08-02: dropping either predicate makes the count include finished tasks). A finished task's
4174
+ * deadline is history, and reporting it would make the finding grow forever and never reach zero.
3574
4175
  */
3575
4176
  const overdueTasks = (db, today) => db.all(`SELECT path, task_status, due_at FROM files
3576
4177
  WHERE memory_type = 'task' AND archived = 0 AND due_at IS NOT NULL
@@ -3618,6 +4219,35 @@ const staleBlockers = (db) => db.all(`SELECT t.path AS path, e.src_path AS block
3618
4219
  blockerState: row.blocker_state === "missing" ? "missing" : "archived"
3619
4220
  }))), Effect.orElseSucceed(() => []));
3620
4221
  /**
4222
+ * Untyped entity references: `memhtml-entity` metas written as a bare name, which the projection files
4223
+ * under the `unknown` type rather than dropping (`@memhtml/index`'s `entityRowsFor`).
4224
+ *
4225
+ * Report-only, and excluded from `healthy` on the same reasoning as `overdueTasks`. `unknown` is a
4226
+ * SUPPORTED fallback — a hand-authored file's only entity is still a real handle — so a bare name is not
4227
+ * a defect in the corpus. What it is is invisible to the caller who would look for it: the `entity`
4228
+ * scope requires the type half, so a memory stored under `unknown:checkout-api` cannot be found by
4229
+ * `service:checkout-api`, and the query returns an empty set rather than an error. A PRODUCER that emits
4230
+ * bare names therefore makes every memory it writes unreachable by the reference an agent would guess,
4231
+ * and nothing in a green suite says so. This count is where that shows up.
4232
+ *
4233
+ * Truncated to {@link UNTYPED_ENTITY_SAMPLE} with the distinct total reported beside it, so the sample
4234
+ * can never be read as the whole set.
4235
+ */
4236
+ const untypedEntities = (db) => db.all(`SELECT e.entity_name AS entity_name, count(*) AS files
4237
+ FROM file_entities e JOIN files f ON f.path = e.path
4238
+ WHERE e.entity_type = 'unknown' AND f.archived = 0
4239
+ GROUP BY e.entity_name
4240
+ ORDER BY files DESC, e.entity_name ASC`).pipe(Effect.map((rows) => ({
4241
+ sample: rows.slice(0, 20).map((row) => ({
4242
+ entityName: row.entity_name,
4243
+ files: row.files
4244
+ })),
4245
+ total: rows.length
4246
+ })), Effect.orElseSucceed(() => ({
4247
+ sample: [],
4248
+ total: 0
4249
+ })));
4250
+ /**
3621
4251
  * Re-read every active file and collect its format warnings.
3622
4252
  *
3623
4253
  * Re-read rather than taken from the index, because a warning is not a stored column. The indexer
@@ -3671,6 +4301,7 @@ const repair = (root, findings, orphans) => Effect.gen(function* () {
3671
4301
  let rewritten = 0;
3672
4302
  let dropped = 0;
3673
4303
  const touched = [];
4304
+ const failedWrites = [];
3674
4305
  for (const finding of findings) {
3675
4306
  if (!isEdgeRel(finding.rel)) continue;
3676
4307
  const rel = finding.rel;
@@ -3685,10 +4316,13 @@ const repair = (root, findings, orphans) => Effect.gen(function* () {
3685
4316
  const edited = applyHeadEdits(html, edits);
3686
4317
  if (edited === html) continue;
3687
4318
  if (finding.rewriteTo === null) yield* Effect.logWarning(`doctor dropped a dangling ${rel} from ${finding.srcPath}: target has no file`);
3688
- yield* attemptIo(`doctor.write:${finding.srcPath}`, async () => {
4319
+ if (!(yield* attemptIo(`doctor.write:${finding.srcPath}`, async () => {
3689
4320
  await mkdir(dirname(absolute), { recursive: true });
3690
4321
  await writeFile(absolute, edited, "utf8");
3691
- }).pipe(Effect.orElseSucceed(() => void 0));
4322
+ }).pipe(Effect.as(true), Effect.orElseSucceed(() => false)))) {
4323
+ failedWrites.push(finding.srcPath);
4324
+ continue;
4325
+ }
3692
4326
  touched.push(finding.srcPath);
3693
4327
  if (finding.rewriteTo === null) dropped += 1;
3694
4328
  else rewritten += 1;
@@ -3709,6 +4343,7 @@ const repair = (root, findings, orphans) => Effect.gen(function* () {
3709
4343
  return {
3710
4344
  rewritten,
3711
4345
  dropped,
4346
+ failedWrites,
3712
4347
  prunedAccessRows,
3713
4348
  commitSha
3714
4349
  };
@@ -3743,6 +4378,7 @@ const doctor = (options) => Effect.gen(function* () {
3743
4378
  const taskDepth = yield* inboxTaskDepth(db);
3744
4379
  const overdue = yield* overdueTasks(db, yield* todayDate);
3745
4380
  const stale = yield* staleBlockers(db);
4381
+ const untyped = yield* untypedEntities(db);
3746
4382
  const active = yield* db.all("SELECT path FROM files WHERE archived = 0 ORDER BY path ASC").pipe(Effect.orElseSucceed(() => []));
3747
4383
  const { warnings, unparseable } = yield* collectWarnings(git.root, active.map((row) => row.path));
3748
4384
  const repaired = options.fix ? yield* repair(git.root, dangling, orphanAccessRows) : void 0;
@@ -3764,6 +4400,8 @@ const doctor = (options) => Effect.gen(function* () {
3764
4400
  inboxTasksCrowded: taskDepth > 10,
3765
4401
  overdueTasks: overdue,
3766
4402
  staleBlockers: stale,
4403
+ untypedEntities: untyped.sample,
4404
+ untypedEntityTotal: untyped.total,
3767
4405
  warnings,
3768
4406
  unparseable,
3769
4407
  indexFresh,
@@ -3784,8 +4422,7 @@ const doctor = (options) => Effect.gen(function* () {
3784
4422
  *
3785
4423
  * ROADMAP item 7 is the requirement. A multi-hop traversal written as code answers in one execution
3786
4424
  * what the tool path answers in one round trip per hop, and the closed vocabulary is what makes the
3787
- * tree queryable without a new surface per question. Measured in the 2026-08 spike and
3788
- * re-probed here on 2026-08-09: a 305-file
4425
+ * tree queryable without a new surface per question. Measured here: a 305-file
3789
4426
  * census in 598ms, and an edge walk resolving 410/410 edges into 201 chains, the longest 8 hops, in one
3790
4427
  * execution at 430ms.
3791
4428
  *
@@ -3818,7 +4455,7 @@ const DEFAULT_TIMEOUT_MS = 3e4;
3818
4455
  *
3819
4456
  * `maxJsTimeoutMs` is the only thing standing between a runaway guest loop and a `memhtml exec` that
3820
4457
  * never returns. The guest is a QuickJS worker with no host-side reaper of its own, and an unbounded
3821
- * script would hold the CLI process open indefinitely. Probed 2026-08-09 with `maxJsTimeoutMs: 700`
4458
+ * script would hold the CLI process open indefinitely. Measured with `maxJsTimeoutMs: 700`
3822
4459
  * against `for(;;){n++}`: exit 124 at 724ms, "js-exec: Execution timeout: exceeded 700ms limit".
3823
4460
  */
3824
4461
  const MAX_TIMEOUT_MS = 6e5;
@@ -3835,7 +4472,7 @@ const SHELL_TIMEOUT_GRACE_MS = 2e3;
3835
4472
  *
3836
4473
  * QuickJS ships no base64 builtins and `node-html-parser` decodes a base64 entity table at load time,
3837
4474
  * so without this the parser throws "'atob' is not defined" at import and every script fails before
3838
- * its first selector. Probed all three placements 2026-08-09: `bootstrap` works, prepending the shim
4475
+ * its first selector. Of the three possible placements, `bootstrap` works, prepending the shim
3839
4476
  * to the parser's own bytes works, and omitting it fails at `decodeBase64`. `bootstrap` is chosen
3840
4477
  * because it leaves the vendored parser byte-identical to the published artifact. A shim spliced into
3841
4478
  * the bundle would make the seeded file something no `pnpm` install reproduces.
@@ -3904,14 +4541,14 @@ const parserSourcePath = () => createRequire(import.meta.url).resolve("node-html
3904
4541
  * through the command and therefore a claim rather than a guard. As a function it is testable against
3905
4542
  * both strings just-bash actually produces.
3906
4543
  *
3907
- * Both wordings, measured 2026-08-09 on `for(;;)` at a 400ms bound:
4544
+ * Both wordings, measured on `for(;;)` at a 400ms bound:
3908
4545
  *
3909
4546
  * - `maxJsTimeoutMs` fires: `js-exec: Execution timeout: exceeded 400ms limit`
3910
4547
  * - `maxExecutionTimeMs` fires: `bash: js-exec exceeded its execution deadline`, with no "timeout" in it
3911
4548
  *
3912
4549
  * A pattern matching only `/timeout/` therefore reports `timedOut: false` on a script that was cut off,
3913
4550
  * which is what the first version of this did. `aborted` covers `bash: execution aborted`, which is what
3914
- * an `AbortSignal` produces (probed). This module takes no such path today, and classifying it correctly
4551
+ * an `AbortSignal` produces. This module takes no such path today, and classifying it correctly
3915
4552
  * now is cheap if it ever does.
3916
4553
  *
3917
4554
  * The exit code is required as well as the wording. 124 alone is reachable from a script that exits 124
@@ -3933,8 +4570,8 @@ const cutOffByTheRuntime = (exitCode, stderr) => exitCode === 124 && /timeout|de
3933
4570
  *
3934
4571
  * Neither is a fact about the corpus or the script. Both reach `stderr` as a thrown guest error, which
3935
4572
  * without this check is reported as the SCRIPT's non-zero exit — telling an agent its selector is wrong
3936
- * when the sandbox merely failed to hand back a `stat`. Observed once on a 4-vCPU CI runner (2026-08-14,
3937
- * `memhtml/memhtml` run 31830358200) on a walk of ~900 entries: `at isDirectory
4573
+ * when the sandbox merely failed to hand back a `stat`. Observed once on a 4-vCPU CI runner
4574
+ * on a walk of ~900 entries: `at isDirectory
3938
4575
  * (/workspace/lib/corpus.mjs:45:28): Error code: 0`, on a commit whose tree was byte-identical to one
3939
4576
  * that had passed minutes earlier. The bridge kept working afterwards — the guest's own `stderr` write
3940
4577
  * and exit both landed — so the fault is one operation, not a torn-down sandbox, which is what makes
@@ -3963,8 +4600,8 @@ const BRIDGE_ATTEMPTS = 3;
3963
4600
  * Run one attempt at a time until a report is the script's own answer, or fail as the runtime.
3964
4601
  *
3965
4602
  * Exported and parameterized by `attempt` because that is the only shape this loop can be tested in: a
3966
- * bridge fault is a rare race — 72 executions under 3x CPU oversubscription did not produce one
3967
- * (measured 2026-08-14) — so a test driving the real sandbox could not distinguish a working retry from
4603
+ * bridge fault is a rare race — 72 executions under 3x CPU oversubscription did not produce one
4604
+ * so a test driving the real sandbox could not distinguish a working retry from
3968
4605
  * a fault that never fired. The injected attempt makes the loop's three claims falsifiable: a faulting
3969
4606
  * attempt is re-run, a script's own failure is NOT, and exhaustion is a typed failure.
3970
4607
  *
@@ -4004,7 +4641,7 @@ const withBridgeRetry = (attempt, attempts = 3) => Effect.gen(function* () {
4004
4641
  *
4005
4642
  * `new Bash()` is constructed with no `network` and no `fetch` option, so just-bash never registers its
4006
4643
  * network commands at all. Per `Bash.d.ts:80`: "Network commands (curl, wget) are registered when either
4007
- * `fetch` or `network` is provided." Probed 2026-08-09 (`scripts/probe-sandbox-egress.mjs`): `curl` is
4644
+ * `fetch` or `network` is provided." `scripts/probe-sandbox-egress.mjs` demonstrates it: `curl` is
4008
4645
  * exit 127 "command not found", and the guest's `fetch` refuses on call with "Network access not
4009
4646
  * configured." `fetch` is a function there, so a `typeof` check on the global proves nothing. Eve
4010
4647
  * passes `dangerouslyAllowFullInternetAccess`, so the consolidator's sandbox does reach the network.
@@ -4035,7 +4672,7 @@ const runExec = (input) => Effect.gen(function* () {
4035
4672
  catch: (cause) => StorageFailure.make({ operation: `exec.sandbox-load: ${String(cause)}` })
4036
4673
  });
4037
4674
  const { mountReadOnlyRoots } = yield* Effect.tryPromise({
4038
- try: () => import("./dist-DuzGralO.mjs"),
4675
+ try: () => import("./dist-CBhYV3up.mjs"),
4039
4676
  catch: (cause) => StorageFailure.make({ operation: `exec.mount-load: ${String(cause)}` })
4040
4677
  });
4041
4678
  const helperSource = yield* Effect.tryPromise({
@@ -4075,7 +4712,7 @@ const runExec = (input) => Effect.gen(function* () {
4075
4712
  *
4076
4713
  * `maxJsTimeoutMs` bounds the `js-exec` call and `maxExecutionTimeMs` bounds the whole shell
4077
4714
  * invocation, so both are needed. A script cannot outlive its budget by spending the time
4078
- * outside the JS worker. Which one fires first changes the diagnostic, probed 2026-08-09 on a
4715
+ * outside the JS worker. Which one fires first changes the diagnostic, measured on a
4079
4716
  * `for(;;)` loop at a 400ms bound:
4080
4717
  *
4081
4718
  * | limits | exit | stderr |
@@ -4146,8 +4783,8 @@ const readScript = async (file) => {
4146
4783
  *
4147
4784
  * ## Why a pinned worktree and not `$MEMHTML_ROOT` itself
4148
4785
  *
4149
- * A live `$MEMHTML_ROOT` contains `.memhtml/index.db`, and the guest ships `sqlite3`. Probed 2026-08-09
4150
- * against a read-only `OverlayFs` over a directory holding a real database: `sqlite3
4786
+ * A live `$MEMHTML_ROOT` contains `.memhtml/index.db`, and the guest ships `sqlite3`. Against a
4787
+ * read-only `OverlayFs` over a directory holding a real database: `sqlite3
4151
4788
  * /mnt/memhtml/.memhtml/index.db 'select count(*) …'` returned the row, exit 0. Read-only is therefore no
4152
4789
  * barrier to a reader, and mounting the live root would hand every script the ranked planes this command
4153
4790
  * is scoped to exclude, through a door no `memhtml exec` flag opens.
@@ -4180,7 +4817,7 @@ const execCommand = (input) => Effect.gen(function* () {
4180
4817
  * would not cover.
4181
4818
  */
4182
4819
  const { pinCorpusSnapshot } = yield* Effect.tryPromise({
4183
- try: () => import("./dist-DuzGralO.mjs"),
4820
+ try: () => import("./dist-CBhYV3up.mjs"),
4184
4821
  catch: (cause) => StorageFailure.make({ operation: `exec.mount-load: ${String(cause)}` })
4185
4822
  });
4186
4823
  const snapshot = yield* Effect.acquireRelease(Effect.tryPromise({
@@ -4382,7 +5019,7 @@ const indexReport = () => Effect.gen(function* () {
4382
5019
  });
4383
5020
  const count = (db, sql) => db.get(sql).pipe(Effect.map((row) => row?.n ?? 0), Effect.orElseSucceed(() => 0));
4384
5021
  /**
4385
- * A `--phases` value as a validated phase list, or `undefined` for "all sixteen".
5022
+ * A `--phases` value as a validated phase list, or `undefined` for every phase in `SLEEP_PHASES`.
4386
5023
  *
4387
5024
  * An unknown phase is rejected instead of dropped silently. A run asked for `--phases dedup,compress`
4388
5025
  * with a typo in the first name would otherwise execute only the second. `dedup-merge` is a hard
@@ -4421,6 +5058,41 @@ const sleepRunReport = (report) => ({
4421
5058
  //#region apps/cli/src/run.ts
4422
5059
  const KNOWN_FLAGS = /* @__PURE__ */ new Set([...GLOBAL_FLAGS.map((flag) => flag.name), ...COMMANDS.flatMap((command) => command.flags.map((flag) => flag.name))]);
4423
5060
  /**
5061
+ * Every flag name the spec table declares, mapped to the type it declares.
5062
+ *
5063
+ * **Only a `string` or `int` flag consumes the next argv token as its value**, and the two kinds it
5064
+ * excludes are excluded for different reasons:
5065
+ *
5066
+ * - A `boolean` flag takes `--flag`, `--flag=value`, or `--no-flag`, so the token after it stays
5067
+ * positional and can be the command: `memhtml --dense list` is the `list` command, not an empty
5068
+ * command carrying `dense: "list"`.
5069
+ * - A flag the table does not declare has no type to consult, and eating the token would swallow the
5070
+ * command name — `memhtml --nope list` would answer the manifest at exit 0 instead of refusing an
5071
+ * unknown flag. Leaving the token positional lets the command reach {@link validate}, which is
5072
+ * where an unknown flag becomes exit 2.
5073
+ *
5074
+ * The map is name-keyed across every command even though validation is per-command, because the
5075
+ * parser runs before the command is known. That is sound only while one name carries ONE type
5076
+ * everywhere, which is a property of the table `cli.test.ts` enforces rather than a hope.
5077
+ */
5078
+ const FLAG_TYPES = new Map([...GLOBAL_FLAGS, ...COMMANDS.flatMap((command) => command.flags)].map((flag) => [flag.name, flag.type]));
5079
+ /**
5080
+ * The tokens {@link bool} would have read as a boolean value.
5081
+ *
5082
+ * A boolean flag followed by one of these is a caller spelling `--flag <value>`, which parses as the
5083
+ * opposite value. Any other token after a boolean flag is a positional the caller meant — the
5084
+ * command name, a path, a query — so the set is exactly the vocabulary `bool` interprets and not
5085
+ * "any following token".
5086
+ */
5087
+ const BOOLEAN_VALUE_TOKENS = /* @__PURE__ */ new Set([
5088
+ "true",
5089
+ "false",
5090
+ "yes",
5091
+ "no",
5092
+ "0",
5093
+ "1"
5094
+ ]);
5095
+ /**
4424
5096
  * The two-word command names, longest first.
4425
5097
  *
4426
5098
  * A subcommand is matched greedily so `index status` beats `index`, and the leftover tokens become
@@ -4430,16 +5102,22 @@ const KNOWN_FLAGS = /* @__PURE__ */ new Set([...GLOBAL_FLAGS.map((flag) => flag.
4430
5102
  */
4431
5103
  const COMPOUND_NAMES = COMMAND_NAMES.filter((name) => name.includes(" ")).sort((left, right) => right.length - left.length);
4432
5104
  /**
4433
- * `--flag value`, `--flag=value`, `--no-flag`, and bare `--flag`.
5105
+ * `--flag value`, `--flag=value`, `--no-flag`, and bare `--flag`, parsed against the spec table.
4434
5106
  *
4435
5107
  * Every flag's value is an array, because several flags are repeatable (`--tag`, `--entity`,
4436
5108
  * `--body`) and a map of scalars would silently keep only the last occurrence, so a write with three
4437
5109
  * entities would store one. Non-repeatable flags read `.at(-1)`, so a duplicate is last-wins rather
4438
5110
  * than an error, which is what a shell user retyping a flag expects.
5111
+ *
5112
+ * Only a flag the table types `string` or `int` consumes the next token as its value; a boolean flag
5113
+ * and an undeclared flag both leave it positional, for the two reasons {@link FLAG_TYPES} states.
5114
+ * A boolean flag followed by a value-shaped token is recorded as a stray so {@link validate} can
5115
+ * refuse it rather than silently inverting the caller's ask.
4439
5116
  */
4440
5117
  const parseArgv = (argv) => {
4441
5118
  const positional = [];
4442
5119
  const flags = /* @__PURE__ */ new Map();
5120
+ const strayBooleanValues = [];
4443
5121
  const push = (name, value) => {
4444
5122
  const existing = flags.get(name);
4445
5123
  if (existing === void 0) flags.set(name, [value]);
@@ -4462,11 +5140,13 @@ const parseArgv = (argv) => {
4462
5140
  continue;
4463
5141
  }
4464
5142
  const next = argv[index + 1];
4465
- if (next !== void 0 && !next.startsWith("--")) {
5143
+ const type = FLAG_TYPES.get(body);
5144
+ if ((type === "string" || type === "int") && next !== void 0 && !next.startsWith("--")) {
4466
5145
  push(body, next);
4467
5146
  index += 2;
4468
5147
  continue;
4469
5148
  }
5149
+ if (type === "boolean" && next !== void 0 && BOOLEAN_VALUE_TOKENS.has(next.toLowerCase())) strayBooleanValues.push([body, next]);
4470
5150
  push(body, true);
4471
5151
  index += 1;
4472
5152
  continue;
@@ -4481,13 +5161,15 @@ const parseArgv = (argv) => {
4481
5161
  return {
4482
5162
  command: compound,
4483
5163
  positional: positional.slice(consumed),
4484
- flags
5164
+ flags,
5165
+ strayBooleanValues
4485
5166
  };
4486
5167
  }
4487
5168
  return {
4488
5169
  command: positional[0] ?? "",
4489
5170
  positional: positional.slice(1),
4490
- flags
5171
+ flags,
5172
+ strayBooleanValues
4491
5173
  };
4492
5174
  };
4493
5175
  /** A flag's last value as a string, or `undefined` when it was not given. */
@@ -4524,6 +5206,7 @@ const scopeOf = (parsed) => ({
4524
5206
  workspace: str(parsed, "workspace"),
4525
5207
  tags: list(parsed, "tag"),
4526
5208
  entity: str(parsed, "entity"),
5209
+ facets: parseFacetFilters(list(parsed, "facet")),
4527
5210
  includeArchived: bool(parsed, "include-archived", false),
4528
5211
  asOf: str(parsed, "as-of")
4529
5212
  });
@@ -4534,6 +5217,25 @@ const provenanceOf = (parsed) => ({
4534
5217
  turnUuid: str(parsed, "turn-uuid")
4535
5218
  });
4536
5219
  /**
5220
+ * Exit 1 when a sleep run has a failed phase.
5221
+ *
5222
+ * **A partially-failed run and a fully-aborted run exit the same**, and that is a decision rather
5223
+ * than an omission. A caller reading the exit code is asking one question — did the curation this
5224
+ * invocation was for happen — and both answers are no. The difference between them is already stated
5225
+ * in the payload, precisely: an abort is every selected phase `failed` with `headSha === baseSha` and
5226
+ * no commits, while a partial run names the phases that landed. A second exit code would be a
5227
+ * second, weaker copy of that, and a caller would have to learn it to recover a fact the envelope
5228
+ * already carries.
5229
+ *
5230
+ * Exit 1 rather than 2: the call was well-formed, so this is a runtime failure an operator fixes by
5231
+ * changing the repo or the environment ({@link EXIT_USAGE} is reserved for fixing the call).
5232
+ *
5233
+ * `sleep status` and `sleep review` are deliberately not routed through here. They REPORT a run they
5234
+ * did not perform, and a read that exited non-zero because the thing it describes failed would make
5235
+ * "tell me what happened" indistinguishable from "I could not tell you".
5236
+ */
5237
+ const sleepExit = (report) => report.failedPhases.length > 0 ? 1 : 0;
5238
+ /**
4537
5239
  * Dispatch one parsed invocation against the provided services.
4538
5240
  *
4539
5241
  * Every arm is decode → call → name the response type. No arm builds an envelope, catches an error,
@@ -4560,6 +5262,7 @@ const dispatch = (parsed, applyOps = []) => {
4560
5262
  articleHtml: str(parsed, "article-html"),
4561
5263
  memoryType: str(parsed, "type") ?? "",
4562
5264
  path: str(parsed, "path"),
5265
+ strictPath: bool(parsed, "strict-path", false),
4563
5266
  workspace: str(parsed, "workspace"),
4564
5267
  tags: list(parsed, "tag"),
4565
5268
  entities: list(parsed, "entity"),
@@ -4633,9 +5336,13 @@ const dispatch = (parsed, applyOps = []) => {
4633
5336
  return ["memory.neighbors", yield* neighborsOf({
4634
5337
  path: parsed.positional[0] ?? "",
4635
5338
  depth: int(parsed, "depth"),
5339
+ limit: int(parsed, "limit"),
4636
5340
  rels: list(parsed, "rel")
4637
5341
  })];
4638
5342
  });
5343
+ case "resolve": return Effect.gen(function* () {
5344
+ return ["memory.resolved", yield* resolveMemory(parsed.positional[0] ?? "")];
5345
+ });
4639
5346
  case "archive": return Effect.gen(function* () {
4640
5347
  return ["memory.archived", yield* archiveMemory(parsed.positional[0] ?? "", str(parsed, "reason") ?? "")];
4641
5348
  });
@@ -4648,12 +5355,20 @@ const dispatch = (parsed, applyOps = []) => {
4648
5355
  workspace: str(parsed, "workspace"),
4649
5356
  tag: str(parsed, "tag"),
4650
5357
  entity: str(parsed, "entity"),
5358
+ facets: parseFacetFilters(list(parsed, "facet")),
4651
5359
  para: str(parsed, "para"),
4652
5360
  limit: int(parsed, "limit"),
4653
5361
  cursor: str(parsed, "cursor"),
4654
5362
  includeArchived: bool(parsed, "include-archived", false)
4655
5363
  })];
4656
5364
  });
5365
+ case "entity activity": return Effect.gen(function* () {
5366
+ return ["entity.activity", yield* entityActivity({
5367
+ entityType: str(parsed, "type"),
5368
+ limit: int(parsed, "limit"),
5369
+ includeArchived: bool(parsed, "include-archived", false)
5370
+ })];
5371
+ });
4657
5372
  case "task add": return Effect.gen(function* () {
4658
5373
  const title = str(parsed, "title") ?? "";
4659
5374
  const result = yield* writeMemory({
@@ -4735,16 +5450,29 @@ const dispatch = (parsed, applyOps = []) => {
4735
5450
  case "sleep run": return Effect.gen(function* () {
4736
5451
  const sleep = yield* Sleep;
4737
5452
  const phases = yield* sleepPhases(str(parsed, "phases"));
5453
+ const maxLlmCalls = int(parsed, "max-llm-calls");
4738
5454
  const report = yield* sleep.run({
4739
5455
  date: str(parsed, "date") ?? (yield* today),
4740
5456
  ...phases === void 0 ? {} : { phases },
4741
- dryRun: bool(parsed, "dry-run", false)
5457
+ dryRun: bool(parsed, "dry-run", false),
5458
+ deep: bool(parsed, "deep", false),
5459
+ ...maxLlmCalls === void 0 ? {} : { maxLlmCalls }
4742
5460
  });
4743
- return ["sleep.report", sleepRunReport(report)];
5461
+ const payload = sleepRunReport(report);
5462
+ return [
5463
+ "sleep.report",
5464
+ payload,
5465
+ sleepExit(payload)
5466
+ ];
4744
5467
  });
4745
5468
  case "sleep resume": return Effect.gen(function* () {
4746
5469
  const report = yield* (yield* Sleep).resume(parsed.positional[0] ?? "");
4747
- return ["sleep.report", sleepRunReport(report)];
5470
+ const payload = sleepRunReport(report);
5471
+ return [
5472
+ "sleep.report",
5473
+ payload,
5474
+ sleepExit(payload)
5475
+ ];
4748
5476
  });
4749
5477
  case "sleep review": return Effect.gen(function* () {
4750
5478
  const report = yield* (yield* Sleep).review(parsed.positional[0]);
@@ -4773,6 +5501,17 @@ const dispatch = (parsed, applyOps = []) => {
4773
5501
  case "state import": return Effect.gen(function* () {
4774
5502
  return ["state.import", yield* stateImport()];
4775
5503
  });
5504
+ case "sleep plan": return Effect.gen(function* () {
5505
+ const sleep = yield* Sleep;
5506
+ /**
5507
+ * The instant is read HERE and passed in, which keeps the one clock reading anywhere near sleep
5508
+ * on the caller's side. The settled-transcript cutoff is derived from it, and a plan that read a
5509
+ * clock inside the package would be the first thing in sleep that consults one to decide
5510
+ * something.
5511
+ */
5512
+ const millis = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
5513
+ return ["sleep.plan", yield* sleep.plan(millis)];
5514
+ });
4776
5515
  case "sleep status": return Effect.gen(function* () {
4777
5516
  const report = yield* (yield* Sleep).review();
4778
5517
  return ["sleep.report", {
@@ -4827,10 +5566,13 @@ const EITHER_CLAIM_OR_ARTICLE = /* @__PURE__ */ new Set(["write", "correct"]);
4827
5566
  *
4828
5567
  * Here for the reason `claimOrArticle` is: `validate`'s return becomes exit 2 and a failure raised in
4829
5568
  * `dispatch` becomes exit 1, so "you passed the wrong flags" must be decided before any service is
4830
- * built. `.erpaval/solutions/api-patterns/xor-params-and-mcp-error-masking.md` records the rule.
5569
+ * built. Mutually exclusive parameters are refused at this edge, before dispatch, because a refusal
5570
+ * raised any later is masked as a runtime error
5571
+ * (`.erpaval/solutions/api-patterns/xor-params-and-mcp-error-masking.md`).
4831
5572
  *
4832
5573
  * At most one rather than exactly one, because zero doors is legal and means stdin, the same shape
4833
- * `memhtml apply` has, where a bare invocation drains the pipe. A missing script is not a usage error
5574
+ * `memhtml apply` has, where a bare invocation drains the pipe. `--file -` is the flag spelling of
5575
+ * stdin and counts as no door at all. A missing script is not a usage error
4834
5576
  * here. An empty one is, and that check sits beside the read in {@link run} because reading is async.
4835
5577
  *
4836
5578
  * `--timeout-ms` is checked for a positive integer within the cap. Zero and negatives are refused
@@ -4839,19 +5581,33 @@ const EITHER_CLAIM_OR_ARTICLE = /* @__PURE__ */ new Set(["write", "correct"]);
4839
5581
  */
4840
5582
  const execFlags = (parsed) => {
4841
5583
  if (parsed.command !== "exec") return void 0;
4842
- const doors = [str(parsed, "file") === void 0 ? void 0 : "--file", str(parsed, "script") === void 0 ? void 0 : "--script"].filter((door) => door !== void 0);
5584
+ const file = str(parsed, "file");
5585
+ const doors = [file === void 0 || file === "-" ? void 0 : "--file", str(parsed, "script") === void 0 ? void 0 : "--script"].filter((door) => door !== void 0);
4843
5586
  if (doors.length > 1) return fail("ERR_INVALID_FLAG", "exec takes at most one of --file or --script, not both: two scripts cannot both be the one that runs", [
4844
5587
  "memhtml exec --file traverse.mjs",
4845
5588
  "memhtml exec --script 'console.log(1)'",
4846
5589
  "cat s.mjs | memhtml exec"
4847
5590
  ]);
4848
- if (doors.length === 1 && parsed.positional[0] === "-") return fail("ERR_INVALID_FLAG", `exec cannot read stdin and ${doors[0]} in the same call: \`-\` names stdin as the script source`, ["cat s.mjs | memhtml exec", `memhtml exec ${doors[0]} …`]);
5591
+ if (doors.length === 1 && (parsed.positional[0] === "-" || file === "-")) return fail("ERR_INVALID_FLAG", `exec cannot read stdin and ${doors[0]} in the same call: \`-\` names stdin as the script source`, ["cat s.mjs | memhtml exec", `memhtml exec ${doors[0]} …`]);
4849
5592
  if (str(parsed, "timeout-ms") !== void 0) {
4850
5593
  const timeout = int(parsed, "timeout-ms");
4851
5594
  if (timeout === void 0 || timeout <= 0 || timeout > 6e5) return fail("ERR_INVALID_FLAG", `--timeout-ms must be a positive integer of at most ${MAX_TIMEOUT_MS}: a non-positive bound is no bound at all, which is the one thing a sandbox may not be`, [`memhtml exec --timeout-ms ${DEFAULT_TIMEOUT_MS}`]);
4852
5595
  }
4853
5596
  };
4854
5597
  /**
5598
+ * `memhtml apply` takes at most one op-stream source.
5599
+ *
5600
+ * The same rule `execFlags` holds for a script, on the same two spellings: `-` (positional or as
5601
+ * `--file -`) names stdin, and stdin beside a real `--file` is two streams claiming to be the one
5602
+ * that applies. Refused here so the answer is exit 2, matching exec, rather than one of the
5603
+ * sources being silently ignored.
5604
+ */
5605
+ const applyFlags = (parsed) => {
5606
+ if (parsed.command !== "apply") return void 0;
5607
+ const file = str(parsed, "file");
5608
+ if (file !== void 0 && file !== "-" && parsed.positional[0] === "-") return fail("ERR_INVALID_FLAG", "apply cannot read stdin and --file in the same call: `-` names stdin as the op stream", ["cat ops.jsonl | memhtml apply", "memhtml apply --file ops.jsonl"]);
5609
+ };
5610
+ /**
4855
5611
  * Exactly one of `--claim` / `--article-html`.
4856
5612
  *
4857
5613
  * Checked here rather than in the dispatch arm, because the exit code is the contract. `validate`'s
@@ -4871,6 +5627,76 @@ const claimOrArticle = (parsed) => {
4871
5627
  if (!hasClaim && !hasArticle) return fail("ERR_MISSING_ARGUMENT", `${parsed.command} requires exactly one of --claim or --article-html`, [`memhtml ${parsed.command} --claim <sentence>`, `memhtml ${parsed.command} --article-html '<p>…</p>'`]);
4872
5628
  };
4873
5629
  /**
5630
+ * `--as-of` must be a value the point-in-time comparison can order.
5631
+ *
5632
+ * The flag binds twice into `coalesce(valid_from, event_at, created_at) <= ? AND (valid_until IS
5633
+ * NULL OR valid_until > ?)` (`packages/index/src/scope.ts`), where SQLite compares TEXT to TEXT.
5634
+ * Nothing there parses the value, so an unsortable one does not error — it silently answers a
5635
+ * DIFFERENT question. `--as-of "2026-08-24 13:00"` sorts after every `T`-form instant on that day
5636
+ * and before none of them, so the window it selects is not the window the caller asked for, and the
5637
+ * result set looks like a plausible point-in-time view. A usage error is the only visible answer.
5638
+ *
5639
+ * The same {@link isValidDatetime} the format enforces on `<time datetime>` and on every datetime
5640
+ * meta, so the values a caller may ASK ABOUT are exactly the values a file may STATE. Two grammars
5641
+ * here would let a caller name an instant no memory can carry.
5642
+ *
5643
+ * `ERR_INVALID_FLAG`, this function's existing code for a flag present but unusable as given, and
5644
+ * exit 2 rather than a runtime error, because `validate`'s return is the usage path. A bare
5645
+ * `--as-of` with no value is refused for the same reason a bad one is: it reads as a scoped query
5646
+ * and would return an unscoped answer.
5647
+ */
5648
+ const asOfFlag = (parsed) => {
5649
+ if (parsed.flags.get("as-of") === void 0) return void 0;
5650
+ const value = str(parsed, "as-of");
5651
+ if (value !== void 0 && isValidDatetime(value)) return void 0;
5652
+ return fail("ERR_INVALID_FLAG", `--as-of must be an ISO date or datetime (YYYY-MM-DD or YYYY-MM-DDThh:mm:ssZ)${value === void 0 ? "" : `, not "${value}"`}: the point-in-time window compares it as a string, so a value outside that grammar selects a different window rather than failing`, [`memhtml ${parsed.command} --as-of 2026-08-24`, `memhtml ${parsed.command} --as-of 2026-08-24T13:00:00Z`]);
5653
+ };
5654
+ /**
5655
+ * A boolean flag spelled with a space-separated value.
5656
+ *
5657
+ * `--embed false` parses as `embed: true` plus a positional `"false"`, so a caller asking to SKIP
5658
+ * embedding would get embedding on and a stray token nothing reads. That is a silent wrong answer,
5659
+ * which is the one outcome this surface may not produce, so the pair is exit 2 and the message names
5660
+ * both spellings that work.
5661
+ *
5662
+ * `ERR_INVALID_FLAG`, the code for a flag present but unusable as given, and the same code the
5663
+ * closed-vocabulary and `--as-of` checks return.
5664
+ */
5665
+ const strayBooleanFlags = (parsed) => {
5666
+ const stray = parsed.strayBooleanValues[0];
5667
+ if (stray === void 0) return void 0;
5668
+ const [name, token] = stray;
5669
+ return fail("ERR_INVALID_FLAG", `--${name} is a boolean flag and takes no separate value, so \`--${name} ${token}\` reads as --${name} with a stray "${token}" argument`, [`memhtml ${parsed.command} --${name}=${token}`, `memhtml ${parsed.command} --no-${name}`]);
5670
+ };
5671
+ /**
5672
+ * The commands where a bare `-` positional names stdin rather than an argument.
5673
+ *
5674
+ * Both declare no positional argument and both document `-` as the spelling that reads the stream
5675
+ * from a pipe, so the dash is the caller doing what the flag description says rather than a surplus
5676
+ * token. Their own mutual-exclusion checks (`execFlags`, `applyFlags`) refuse a dash beside a real
5677
+ * `--file`.
5678
+ */
5679
+ const STDIN_MARKER_COMMANDS = /* @__PURE__ */ new Set(["apply", "exec"]);
5680
+ /**
5681
+ * Positionals past what the command declares.
5682
+ *
5683
+ * The counterpart to the missing-argument check below: "absent" and "surplus" are both wrong calls
5684
+ * and both answer. Without this one a surplus positional is silently dropped — `memhtml read
5685
+ * a.html b.html` reads ONE memory and reports nothing about the second, and every mis-spelled
5686
+ * boolean value (`--embed false`) leaves one behind.
5687
+ *
5688
+ * A `repeatable` last argument turns the check off, because a variadic tail is what
5689
+ * `memhtml reinforce a.html b.html` is. That is declared in the table rather than listed here, so
5690
+ * the manifest states it and a future variadic command needs no edit to this function.
5691
+ */
5692
+ const surplusArgs = (parsed, spec) => {
5693
+ if (spec.args.at(-1)?.repeatable === true) return void 0;
5694
+ const extra = parsed.positional.slice(spec.args.length).filter((token) => !(token === "-" && STDIN_MARKER_COMMANDS.has(spec.name)));
5695
+ if (extra.length === 0) return void 0;
5696
+ const shape = spec.args.length === 0 ? `${spec.name} takes no arguments` : `${spec.name} takes ${spec.args.length}: ${spec.args.map((arg) => arg.name).join(", ")}`;
5697
+ return fail("ERR_UNEXPECTED_ARGUMENT", `unexpected argument: ${extra.map((token) => `"${token}"`).join(", ")}. ${shape}`, [`memhtml ${spec.name}${spec.args.map((arg) => ` <${arg.name}>`).join("")}`, "memhtml manifest"]);
5698
+ };
5699
+ /**
4874
5700
  * Validate a parsed invocation against its spec. Usage errors only; nothing here touches a service.
4875
5701
  *
4876
5702
  * Returning the failure rather than throwing keeps the exit code decision in one place. A usage
@@ -4878,9 +5704,21 @@ const claimOrArticle = (parsed) => {
4878
5704
  * have to know that too.
4879
5705
  */
4880
5706
  const validate = (parsed) => {
4881
- for (const name of parsed.flags.keys()) if (!KNOWN_FLAGS.has(name)) return fail("ERR_INVALID_FLAG", `unknown flag: --${name}`, nearest(name, [...KNOWN_FLAGS]));
4882
5707
  const spec = COMMANDS.find((command) => command.name === parsed.command);
4883
5708
  if (spec === void 0) return unknownCommand(parsed);
5709
+ /**
5710
+ * Flags are validated against THIS command's spec plus the true globals, not the union of every
5711
+ * command's flags. A flag that is valid somewhere else is still a usage error here: an agent that
5712
+ * typed `memhtml list --status todo` meant `task list`, and silently ignoring the flag would
5713
+ * return an unfiltered answer that looks filtered. The suggestions are drawn from the whole known
5714
+ * set, so a flag that belongs to another command still points somewhere.
5715
+ */
5716
+ const allowed = /* @__PURE__ */ new Set([...GLOBAL_FLAGS.map((flag) => flag.name), ...spec.flags.map((flag) => flag.name)]);
5717
+ for (const name of parsed.flags.keys()) if (!allowed.has(name)) return KNOWN_FLAGS.has(name) ? fail("ERR_INVALID_FLAG", `--${name} is not a flag of ${spec.name}`, COMMANDS.filter((command) => command.flags.some((flag) => flag.name === name)).slice(0, 3).map((command) => `memhtml ${command.name} --${name}`)) : fail("ERR_INVALID_FLAG", `unknown flag: --${name}`, nearest(name, [...allowed]));
5718
+ const strayBoolean = strayBooleanFlags(parsed);
5719
+ if (strayBoolean !== void 0) return strayBoolean;
5720
+ const surplus = surplusArgs(parsed, spec);
5721
+ if (surplus !== void 0) return surplus;
4884
5722
  const missingArgs = spec.args.filter((arg, position) => arg.required && parsed.positional[position] === void 0);
4885
5723
  if (missingArgs.length > 0) return fail("ERR_MISSING_ARGUMENT", `${spec.name} requires: ${missingArgs.map((arg) => arg.name).join(", ")}`, [`memhtml ${spec.name} <${missingArgs[0]?.name}>`]);
4886
5724
  const missingFlags = spec.flags.filter((flag) => flag.required === true && parsed.flags.get(flag.name) === void 0);
@@ -4889,6 +5727,10 @@ const validate = (parsed) => {
4889
5727
  if (eitherOr !== void 0) return eitherOr;
4890
5728
  const exec = execFlags(parsed);
4891
5729
  if (exec !== void 0) return exec;
5730
+ const apply = applyFlags(parsed);
5731
+ if (apply !== void 0) return apply;
5732
+ const asOf = asOfFlag(parsed);
5733
+ if (asOf !== void 0) return asOf;
4892
5734
  /**
4893
5735
  * A closed-vocabulary flag is checked here rather than at the service, so a typo answers with the
4894
5736
  * whole vocabulary and never touches the database. Every value of a repeatable flag is checked, not
@@ -4969,22 +5811,23 @@ const run = async (argv, layer, stdin = readStdin) => {
4969
5811
  * would open and migrate a store this command never queries, and an operator checking the gate is
4970
5812
  * typically doing it while `memhtml-mcp` serves that store.
4971
5813
  *
4972
- * **Exit 1 on a failed gate**, with `ERR_DISCRIMINATION_FAILED`. A gate that exited 0 and
4973
- * left the verdict inside the payload would be a gate every shell caller forgets to read. The
4974
- * exit code is what stops a pipeline.
5814
+ * **Exit 1 on a failed gate**, with the `ERR_DISCRIMINATION_FAILED` FAILURE envelope. A gate
5815
+ * that exited 0 and left the verdict inside the payload would be a gate every shell caller
5816
+ * forgets to read, and a gate that exited 1 inside a success envelope would be one an agent
5817
+ * branching on `code` never sees fail. The failure travels through `failureFor` like every other
5818
+ * typed failure, so the code, the one-line reason, and the recovery suggestions are the
5819
+ * documented ones.
4975
5820
  */
4976
5821
  if (parsed.command === "eval discriminate") {
4977
5822
  const requested = str(parsed, "mode") ?? "fake";
4978
5823
  return Effect.runPromise(runDiscrimination({
4979
5824
  mode: requested,
4980
5825
  ...int(parsed, "seed") === void 0 ? {} : { seed: int(parsed, "seed") },
5826
+ ...int(parsed, "now") === void 0 ? {} : { now: int(parsed, "now") },
4981
5827
  ...int(parsed, "size") === void 0 ? {} : { size: int(parsed, "size") },
4982
5828
  ...int(parsed, "probes") === void 0 ? {} : { probes: int(parsed, "probes") },
4983
5829
  ...num(parsed, "mrr-floor") === void 0 ? {} : { mrrFloor: num(parsed, "mrr-floor") }
4984
- }).pipe(Effect.map((outcome) => outcome.passed ? emit(succeed("eval.discrimination", outcome), 0) : {
4985
- stdout: render(succeed("eval.discrimination", outcome), dense),
4986
- exitCode: 1
4987
- }), Effect.catchCause((cause) => Effect.succeed(emit(fail("ERR_UNKNOWN", `unexpected failure: ${String(cause)}`, []), 1))), Effect.provideService(Logger.LogToStderr, true)));
5830
+ }).pipe(Effect.map((outcome) => outcome.passed ? emit(succeed("eval.discrimination", outcome), 0) : emit(failureFor(new DiscriminationFailed(outcome)), 1)), Effect.catchCause((cause) => Effect.succeed(emit(fail("ERR_UNKNOWN", `unexpected failure: ${String(cause)}`, []), 1))), Effect.provideService(Logger.LogToStderr, true)));
4988
5831
  }
4989
5832
  /**
4990
5833
  * `memhtml exec` does not build the app layer either, for the reason two commands over.
@@ -5006,7 +5849,8 @@ const run = async (argv, layer, stdin = readStdin) => {
5006
5849
  */
5007
5850
  if (parsed.command === "exec") {
5008
5851
  const inline = str(parsed, "script");
5009
- const file = parsed.positional[0] === "-" ? void 0 : str(parsed, "file");
5852
+ const flagFile = str(parsed, "file");
5853
+ const file = parsed.positional[0] === "-" || flagFile === "-" ? void 0 : flagFile;
5010
5854
  const script = inline !== void 0 ? inline : file === void 0 ? await stdin() : await readScript(file);
5011
5855
  if (typeof script !== "string") return emit(script, 2);
5012
5856
  if (script.trim() === "") return emit(fail("ERR_MISSING_ARGUMENT", "exec needs a script: a blank one would report an empty answer rather than an error", [
@@ -5040,14 +5884,15 @@ const run = async (argv, layer, stdin = readStdin) => {
5040
5884
  */
5041
5885
  let applyOps = [];
5042
5886
  if (parsed.command === "apply") {
5043
- const file = parsed.positional[0] === "-" ? void 0 : str(parsed, "file");
5887
+ const flagFile = str(parsed, "file");
5888
+ const file = parsed.positional[0] === "-" || flagFile === "-" ? void 0 : flagFile;
5044
5889
  const text = await applyText(file, stdin);
5045
5890
  if (typeof text !== "string") return emit(text, 2);
5046
5891
  const decoded = decodeApply(text);
5047
5892
  if (!decoded.ok) return emit(decoded.failure, 2);
5048
5893
  applyOps = decoded.ops;
5049
5894
  }
5050
- const program = dispatch(parsed, applyOps).pipe(Effect.map(([type, data]) => emit(succeed(type, data), 0)), Effect.catch((error) => Effect.succeed(emit(failureFor(error), 1))), Effect.catchCause((cause) => Effect.succeed(emit(fail("ERR_UNKNOWN", `unexpected failure: ${String(cause)}`, []), 1))), Effect.provide(layer ?? layerApp(str(parsed, "repo"))), Effect.provideService(Logger.LogToStderr, true), Effect.scoped);
5895
+ const program = dispatch(parsed, applyOps).pipe(Effect.map(([type, data, exitCode]) => emit(succeed(type, data), exitCode ?? 0)), Effect.catch((error) => Effect.succeed(emit(failureFor(error), 1))), Effect.catchCause((cause) => Effect.succeed(emit(fail("ERR_UNKNOWN", `unexpected failure: ${String(cause)}`, []), 1))), Effect.provide(layer ?? layerApp(str(parsed, "repo"))), Effect.provideService(Logger.LogToStderr, true), Effect.scoped);
5051
5896
  return Effect.runPromise(program);
5052
5897
  };
5053
5898