memhtml 0.6.0 → 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-CHoz5uHd.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
@@ -1454,58 +1587,220 @@ const neighborsOf = (params) => Effect.gen(function* () {
1454
1587
  const db = yield* DatabaseService;
1455
1588
  const center = normalizePath(params.path);
1456
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)));
1457
1591
  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
1592
  /**
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.
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.
1464
1617
  */
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
- }));
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
+ }
1504
1641
  return {
1505
1642
  center,
1506
1643
  depth,
1507
- nodes,
1508
- edges: nodes.length
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.
1675
+ */
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* () {
1746
+ const db = yield* DatabaseService;
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
+ }
1796
+ return {
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
  {
@@ -2667,7 +3216,7 @@ const COMMANDS = [
2667
3216
  {
2668
3217
  name: "deep",
2669
3218
  type: "boolean",
2670
- 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 nightly community gate cannot; costs more model calls. Same branch, review, and merge gate as a nightly run.",
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.",
2671
3220
  default: false
2672
3221
  },
2673
3222
  {
@@ -2728,6 +3277,13 @@ const COMMANDS = [
2728
3277
  flags: [],
2729
3278
  responseTypes: ["sleep.report"]
2730
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
+ },
2731
3287
  {
2732
3288
  name: "status",
2733
3289
  summary: "Corpus health: HEAD, dirty state, counts by type, edges, index freshness.",
@@ -2771,6 +3327,11 @@ const COMMANDS = [
2771
3327
  type: "int",
2772
3328
  description: "The fixture corpus seed. A failing run is reproducible from this number."
2773
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
+ },
2774
3335
  {
2775
3336
  name: "size",
2776
3337
  type: "int",
@@ -2831,7 +3392,7 @@ const COMMANDS = [
2831
3392
  {
2832
3393
  name: "file",
2833
3394
  type: "string",
2834
- 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`."
2835
3396
  },
2836
3397
  {
2837
3398
  name: "script",
@@ -2884,7 +3445,7 @@ const COMMANDS = [
2884
3445
  },
2885
3446
  {
2886
3447
  name: "serve mcp",
2887
- 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.",
2888
3449
  args: [],
2889
3450
  flags: [],
2890
3451
  responseTypes: ["serve.exit"]
@@ -2919,12 +3480,12 @@ const GUIDE = [
2919
3480
  },
2920
3481
  {
2921
3482
  topic: "write-surfaces",
2922
- 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."
2923
3484
  },
2924
3485
  {
2925
3486
  topic: "when-to-batch",
2926
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:
2927
- ${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.`
2928
3489
  },
2929
3490
  {
2930
3491
  topic: "conflicts",
@@ -2936,7 +3497,7 @@ ${GUIDE_OP_EXAMPLE}\n\`op\` is \`write\` (the only verb in the vocabulary today)
2936
3497
  },
2937
3498
  {
2938
3499
  topic: "code-mode",
2939
- 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."
2940
3501
  }
2941
3502
  ];
2942
3503
  const GUIDE_TOPICS = GUIDE.map((block) => block.topic);
@@ -2947,13 +3508,14 @@ const GUIDE_TOPICS = GUIDE.map((block) => block.topic);
2947
3508
  */
2948
3509
  const buildManifest = () => ({
2949
3510
  name: "memhtml",
2950
- version: "0.6.0",
3511
+ version: "0.7.0",
2951
3512
  summary: "Read, write, and curate the git-backed memory repo.",
2952
3513
  apiVersion: "1",
2953
3514
  /**
2954
3515
  * The prose an agent needs before the command table means anything, so it is listed before it.
2955
- * A manifest that opened with 33 command specifications makes an agent infer the workflow from a
2956
- * 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`.
2957
3519
  */
2958
3520
  guide: GUIDE,
2959
3521
  globalFlags: GLOBAL_FLAGS,
@@ -3153,22 +3715,22 @@ const runAgentsDoc = (options) => Effect.gen(function* () {
3153
3715
  * Prose → claim derivation: the single implementation both write doors use.
3154
3716
  *
3155
3717
  * The tools take `{title, body}` because that is what a model produces, and the format needs a
3156
- * `<mark>` claim plus one `<p>` per paragraph. Turning the first into the second is a text heuristic,
3157
- * and it lives here for two reasons. It was duplicated once, as `claimOf`/`restOf` in `apps/mcp` and
3158
- * `claimFromProse`/`proseTail` in `apps/cli`, the same regex in two packages. A sentence-splitting
3159
- * rule that drifts between the doors also makes `memhtml apply` and `memory_write_batch` derive different
3160
- * 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.
3161
3723
  *
3162
3724
  * It does not live in `@memhtml/html`, which owns markup and the format's own rules. "Where does a
3163
3725
  * sentence end" is a guess about natural-language prose, and the format states no such constraint. It
3164
3726
  * is not in `operations.ts` either, because that module holds the use cases both doors call, and this
3165
3727
  * is a text helper they apply before calling one.
3166
3728
  *
3167
- * The derivation is defense in depth now; it was once the only guard. `@memhtml/html` constraint 1 now
3168
- * rejects an empty `<mark>` outright, so a door that skipped this would be stopped by the store's
3169
- * render gate instead of landing a file with an empty `files.gist`. What is left here is the
3170
- * authoring convenience the doors exist to provide: a JSONL line and an MCP call carry no `claim`
3171
- * 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.
3172
3734
  */
3173
3735
  /**
3174
3736
  * Split prose into paragraphs on blank lines, dropping the empties. Inside a fenced code block a
@@ -3271,11 +3833,20 @@ const LIST_FIELDS = {
3271
3833
  entity: "entities",
3272
3834
  entities: "entities"
3273
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" };
3274
3844
  /** `op` is the discriminator rather than a `WriteParams` field, so it is legal and never mapped. */
3275
3845
  const KNOWN_FIELDS = /* @__PURE__ */ new Set([
3276
3846
  "op",
3277
3847
  ...Object.keys(SCALAR_FIELDS),
3278
- ...Object.keys(LIST_FIELDS)
3848
+ ...Object.keys(LIST_FIELDS),
3849
+ ...Object.keys(BOOLEAN_FIELDS)
3279
3850
  ]);
3280
3851
  /** A usage failure naming the offending line, 1-based as a text editor counts. */
3281
3852
  const lineError = (code, line, reason, suggestions = []) => fail(code, `${APPLY_DOC}: line ${line}: ${reason}`, suggestions);
@@ -3361,6 +3932,12 @@ const opAt = (record, line) => {
3361
3932
  if (isFailure(parsed)) return parsed;
3362
3933
  params[target] = [...params[target] ?? [], ...parsed];
3363
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
+ }
3364
3941
  /**
3365
3942
  * `body` prose becomes claim + tail; `article_html` is used verbatim and leaves `claim` empty.
3366
3943
  *
@@ -3369,10 +3946,10 @@ const opAt = (record, line) => {
3369
3946
  * has no `claim` field, so a prose line's claim is derived rather than restated by its author (see
3370
3947
  * {@link claimFromProse}, the one copy both doors share).
3371
3948
  *
3372
- * Skipping this no longer lands a bad file. `@memhtml/html` constraint 1 rejects an empty `<mark>`, so
3373
- * the render gate would stop the op instead of committing a file with an empty `files.gist`. The
3374
- * derivation is what makes a prose line valid in the first place. It is no longer the only thing
3375
- * 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.
3376
3953
  */
3377
3954
  const prose = typeof params.body === "string" ? params.body : void 0;
3378
3955
  if (prose !== void 0 && prose.trim() !== "") {
@@ -3505,7 +4082,7 @@ const applyPayload = (result) => ({
3505
4082
  * `memhtml doctor`: the corpus's own health check, and `--fix` for the two findings a repair can settle
3506
4083
  * without a judgement call.
3507
4084
  *
3508
- * 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:
3509
4086
  *
3510
4087
  * 1. **Dangling `<link>` hrefs**: an authored edge pointing at a path the tree does not hold. Design
3511
4088
  * §2.3 has no foreign key on `edges` deliberately (a `<link>` may name a file the indexer has not
@@ -3529,8 +4106,12 @@ const applyPayload = (result) => ({
3529
4106
  * is a task waiting on something that will never move.
3530
4107
  * 8. **Task inbox depth**: a task in `areas/inbox/tasks/` is work with no project, and a task inbox
3531
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.
3532
4113
  *
3533
- * **`--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
3534
4115
  * integrity phase rather than re-ported.** `archivedFormOf` decides whether a dangling target moved
3535
4116
  * to the archive or is genuinely gone, and `applyHeadEdits`/`link`/`unlink`/`meta` are the byte-splice
3536
4117
  * editors that change one head line without touching the article. A parse→serialize round trip drops
@@ -3538,11 +4119,12 @@ const applyPayload = (result) => ({
3538
4119
  * every file it touched. A second implementation of either would be the consumer-side reimplementation
3539
4120
  * of producer semantics the fleet has paid for repeatedly.
3540
4121
  *
3541
- * 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
3542
4123
  * where it belongs, a vocabulary warning needs the author's intent, and a stale index needs
3543
4124
  * `memhtml index update`, which doctor names in its own suggestions rather than running behind the
3544
4125
  * operator's back. An overdue task needs the work done or the deadline moved, and a stale blocker
3545
- * 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.
3546
4128
  */
3547
4129
  /** How deep the inbox may get before doctor calls it a finding. */
3548
4130
  const INBOX_WARN_DEPTH = 20;
@@ -3554,6 +4136,14 @@ const INBOX_WARN_DEPTH = 20;
3554
4136
  * is the state a to-do list rots in, and a task inbox is meant to be drained rather than accumulated.
3555
4137
  */
3556
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;
3557
4147
  /** Every `state.access` path the index has no `files` row for. */
3558
4148
  const orphanAccess = (db) => db.hasState ? db.all(`SELECT a.path AS path FROM ${STATE_SCHEMA}.access a
3559
4149
  LEFT JOIN files f ON f.path = a.path
@@ -3580,8 +4170,8 @@ const inboxTaskDepth = (db) => db.get(`SELECT count(*) AS n FROM files
3580
4170
  * "overdue" meaning the same thing in the two places an operator reads it.
3581
4171
  *
3582
4172
  * **`archived = 0` and `task_status <> 'done'` both change the result** (mutation-verified
3583
- * 2026-08-02). A finished task's deadline is history, and reporting it would make the finding grow
3584
- * 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.
3585
4175
  */
3586
4176
  const overdueTasks = (db, today) => db.all(`SELECT path, task_status, due_at FROM files
3587
4177
  WHERE memory_type = 'task' AND archived = 0 AND due_at IS NOT NULL
@@ -3629,6 +4219,35 @@ const staleBlockers = (db) => db.all(`SELECT t.path AS path, e.src_path AS block
3629
4219
  blockerState: row.blocker_state === "missing" ? "missing" : "archived"
3630
4220
  }))), Effect.orElseSucceed(() => []));
3631
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
+ /**
3632
4251
  * Re-read every active file and collect its format warnings.
3633
4252
  *
3634
4253
  * Re-read rather than taken from the index, because a warning is not a stored column. The indexer
@@ -3682,6 +4301,7 @@ const repair = (root, findings, orphans) => Effect.gen(function* () {
3682
4301
  let rewritten = 0;
3683
4302
  let dropped = 0;
3684
4303
  const touched = [];
4304
+ const failedWrites = [];
3685
4305
  for (const finding of findings) {
3686
4306
  if (!isEdgeRel(finding.rel)) continue;
3687
4307
  const rel = finding.rel;
@@ -3696,10 +4316,13 @@ const repair = (root, findings, orphans) => Effect.gen(function* () {
3696
4316
  const edited = applyHeadEdits(html, edits);
3697
4317
  if (edited === html) continue;
3698
4318
  if (finding.rewriteTo === null) yield* Effect.logWarning(`doctor dropped a dangling ${rel} from ${finding.srcPath}: target has no file`);
3699
- yield* attemptIo(`doctor.write:${finding.srcPath}`, async () => {
4319
+ if (!(yield* attemptIo(`doctor.write:${finding.srcPath}`, async () => {
3700
4320
  await mkdir(dirname(absolute), { recursive: true });
3701
4321
  await writeFile(absolute, edited, "utf8");
3702
- }).pipe(Effect.orElseSucceed(() => void 0));
4322
+ }).pipe(Effect.as(true), Effect.orElseSucceed(() => false)))) {
4323
+ failedWrites.push(finding.srcPath);
4324
+ continue;
4325
+ }
3703
4326
  touched.push(finding.srcPath);
3704
4327
  if (finding.rewriteTo === null) dropped += 1;
3705
4328
  else rewritten += 1;
@@ -3720,6 +4343,7 @@ const repair = (root, findings, orphans) => Effect.gen(function* () {
3720
4343
  return {
3721
4344
  rewritten,
3722
4345
  dropped,
4346
+ failedWrites,
3723
4347
  prunedAccessRows,
3724
4348
  commitSha
3725
4349
  };
@@ -3754,6 +4378,7 @@ const doctor = (options) => Effect.gen(function* () {
3754
4378
  const taskDepth = yield* inboxTaskDepth(db);
3755
4379
  const overdue = yield* overdueTasks(db, yield* todayDate);
3756
4380
  const stale = yield* staleBlockers(db);
4381
+ const untyped = yield* untypedEntities(db);
3757
4382
  const active = yield* db.all("SELECT path FROM files WHERE archived = 0 ORDER BY path ASC").pipe(Effect.orElseSucceed(() => []));
3758
4383
  const { warnings, unparseable } = yield* collectWarnings(git.root, active.map((row) => row.path));
3759
4384
  const repaired = options.fix ? yield* repair(git.root, dangling, orphanAccessRows) : void 0;
@@ -3775,6 +4400,8 @@ const doctor = (options) => Effect.gen(function* () {
3775
4400
  inboxTasksCrowded: taskDepth > 10,
3776
4401
  overdueTasks: overdue,
3777
4402
  staleBlockers: stale,
4403
+ untypedEntities: untyped.sample,
4404
+ untypedEntityTotal: untyped.total,
3778
4405
  warnings,
3779
4406
  unparseable,
3780
4407
  indexFresh,
@@ -3795,8 +4422,7 @@ const doctor = (options) => Effect.gen(function* () {
3795
4422
  *
3796
4423
  * ROADMAP item 7 is the requirement. A multi-hop traversal written as code answers in one execution
3797
4424
  * what the tool path answers in one round trip per hop, and the closed vocabulary is what makes the
3798
- * tree queryable without a new surface per question. Measured in the 2026-08 spike and
3799
- * re-probed here on 2026-08-09: a 305-file
4425
+ * tree queryable without a new surface per question. Measured here: a 305-file
3800
4426
  * census in 598ms, and an edge walk resolving 410/410 edges into 201 chains, the longest 8 hops, in one
3801
4427
  * execution at 430ms.
3802
4428
  *
@@ -3829,7 +4455,7 @@ const DEFAULT_TIMEOUT_MS = 3e4;
3829
4455
  *
3830
4456
  * `maxJsTimeoutMs` is the only thing standing between a runaway guest loop and a `memhtml exec` that
3831
4457
  * never returns. The guest is a QuickJS worker with no host-side reaper of its own, and an unbounded
3832
- * 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`
3833
4459
  * against `for(;;){n++}`: exit 124 at 724ms, "js-exec: Execution timeout: exceeded 700ms limit".
3834
4460
  */
3835
4461
  const MAX_TIMEOUT_MS = 6e5;
@@ -3846,7 +4472,7 @@ const SHELL_TIMEOUT_GRACE_MS = 2e3;
3846
4472
  *
3847
4473
  * QuickJS ships no base64 builtins and `node-html-parser` decodes a base64 entity table at load time,
3848
4474
  * so without this the parser throws "'atob' is not defined" at import and every script fails before
3849
- * 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
3850
4476
  * to the parser's own bytes works, and omitting it fails at `decodeBase64`. `bootstrap` is chosen
3851
4477
  * because it leaves the vendored parser byte-identical to the published artifact. A shim spliced into
3852
4478
  * the bundle would make the seeded file something no `pnpm` install reproduces.
@@ -3915,14 +4541,14 @@ const parserSourcePath = () => createRequire(import.meta.url).resolve("node-html
3915
4541
  * through the command and therefore a claim rather than a guard. As a function it is testable against
3916
4542
  * both strings just-bash actually produces.
3917
4543
  *
3918
- * Both wordings, measured 2026-08-09 on `for(;;)` at a 400ms bound:
4544
+ * Both wordings, measured on `for(;;)` at a 400ms bound:
3919
4545
  *
3920
4546
  * - `maxJsTimeoutMs` fires: `js-exec: Execution timeout: exceeded 400ms limit`
3921
4547
  * - `maxExecutionTimeMs` fires: `bash: js-exec exceeded its execution deadline`, with no "timeout" in it
3922
4548
  *
3923
4549
  * A pattern matching only `/timeout/` therefore reports `timedOut: false` on a script that was cut off,
3924
4550
  * which is what the first version of this did. `aborted` covers `bash: execution aborted`, which is what
3925
- * 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
3926
4552
  * now is cheap if it ever does.
3927
4553
  *
3928
4554
  * The exit code is required as well as the wording. 124 alone is reachable from a script that exits 124
@@ -3944,8 +4570,8 @@ const cutOffByTheRuntime = (exitCode, stderr) => exitCode === 124 && /timeout|de
3944
4570
  *
3945
4571
  * Neither is a fact about the corpus or the script. Both reach `stderr` as a thrown guest error, which
3946
4572
  * without this check is reported as the SCRIPT's non-zero exit — telling an agent its selector is wrong
3947
- * when the sandbox merely failed to hand back a `stat`. Observed once on a 4-vCPU CI runner (2026-08-14,
3948
- * `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
3949
4575
  * (/workspace/lib/corpus.mjs:45:28): Error code: 0`, on a commit whose tree was byte-identical to one
3950
4576
  * that had passed minutes earlier. The bridge kept working afterwards — the guest's own `stderr` write
3951
4577
  * and exit both landed — so the fault is one operation, not a torn-down sandbox, which is what makes
@@ -3974,8 +4600,8 @@ const BRIDGE_ATTEMPTS = 3;
3974
4600
  * Run one attempt at a time until a report is the script's own answer, or fail as the runtime.
3975
4601
  *
3976
4602
  * Exported and parameterized by `attempt` because that is the only shape this loop can be tested in: a
3977
- * bridge fault is a rare race — 72 executions under 3x CPU oversubscription did not produce one
3978
- * (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
3979
4605
  * a fault that never fired. The injected attempt makes the loop's three claims falsifiable: a faulting
3980
4606
  * attempt is re-run, a script's own failure is NOT, and exhaustion is a typed failure.
3981
4607
  *
@@ -4015,7 +4641,7 @@ const withBridgeRetry = (attempt, attempts = 3) => Effect.gen(function* () {
4015
4641
  *
4016
4642
  * `new Bash()` is constructed with no `network` and no `fetch` option, so just-bash never registers its
4017
4643
  * network commands at all. Per `Bash.d.ts:80`: "Network commands (curl, wget) are registered when either
4018
- * `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
4019
4645
  * exit 127 "command not found", and the guest's `fetch` refuses on call with "Network access not
4020
4646
  * configured." `fetch` is a function there, so a `typeof` check on the global proves nothing. Eve
4021
4647
  * passes `dangerouslyAllowFullInternetAccess`, so the consolidator's sandbox does reach the network.
@@ -4046,7 +4672,7 @@ const runExec = (input) => Effect.gen(function* () {
4046
4672
  catch: (cause) => StorageFailure.make({ operation: `exec.sandbox-load: ${String(cause)}` })
4047
4673
  });
4048
4674
  const { mountReadOnlyRoots } = yield* Effect.tryPromise({
4049
- try: () => import("./dist-DuzGralO.mjs"),
4675
+ try: () => import("./dist-CBhYV3up.mjs"),
4050
4676
  catch: (cause) => StorageFailure.make({ operation: `exec.mount-load: ${String(cause)}` })
4051
4677
  });
4052
4678
  const helperSource = yield* Effect.tryPromise({
@@ -4086,7 +4712,7 @@ const runExec = (input) => Effect.gen(function* () {
4086
4712
  *
4087
4713
  * `maxJsTimeoutMs` bounds the `js-exec` call and `maxExecutionTimeMs` bounds the whole shell
4088
4714
  * invocation, so both are needed. A script cannot outlive its budget by spending the time
4089
- * 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
4090
4716
  * `for(;;)` loop at a 400ms bound:
4091
4717
  *
4092
4718
  * | limits | exit | stderr |
@@ -4157,8 +4783,8 @@ const readScript = async (file) => {
4157
4783
  *
4158
4784
  * ## Why a pinned worktree and not `$MEMHTML_ROOT` itself
4159
4785
  *
4160
- * A live `$MEMHTML_ROOT` contains `.memhtml/index.db`, and the guest ships `sqlite3`. Probed 2026-08-09
4161
- * 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
4162
4788
  * /mnt/memhtml/.memhtml/index.db 'select count(*) …'` returned the row, exit 0. Read-only is therefore no
4163
4789
  * barrier to a reader, and mounting the live root would hand every script the ranked planes this command
4164
4790
  * is scoped to exclude, through a door no `memhtml exec` flag opens.
@@ -4191,7 +4817,7 @@ const execCommand = (input) => Effect.gen(function* () {
4191
4817
  * would not cover.
4192
4818
  */
4193
4819
  const { pinCorpusSnapshot } = yield* Effect.tryPromise({
4194
- try: () => import("./dist-DuzGralO.mjs"),
4820
+ try: () => import("./dist-CBhYV3up.mjs"),
4195
4821
  catch: (cause) => StorageFailure.make({ operation: `exec.mount-load: ${String(cause)}` })
4196
4822
  });
4197
4823
  const snapshot = yield* Effect.acquireRelease(Effect.tryPromise({
@@ -4393,7 +5019,7 @@ const indexReport = () => Effect.gen(function* () {
4393
5019
  });
4394
5020
  const count = (db, sql) => db.get(sql).pipe(Effect.map((row) => row?.n ?? 0), Effect.orElseSucceed(() => 0));
4395
5021
  /**
4396
- * 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`.
4397
5023
  *
4398
5024
  * An unknown phase is rejected instead of dropped silently. A run asked for `--phases dedup,compress`
4399
5025
  * with a typo in the first name would otherwise execute only the second. `dedup-merge` is a hard
@@ -4432,6 +5058,41 @@ const sleepRunReport = (report) => ({
4432
5058
  //#region apps/cli/src/run.ts
4433
5059
  const KNOWN_FLAGS = /* @__PURE__ */ new Set([...GLOBAL_FLAGS.map((flag) => flag.name), ...COMMANDS.flatMap((command) => command.flags.map((flag) => flag.name))]);
4434
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
+ /**
4435
5096
  * The two-word command names, longest first.
4436
5097
  *
4437
5098
  * A subcommand is matched greedily so `index status` beats `index`, and the leftover tokens become
@@ -4441,16 +5102,22 @@ const KNOWN_FLAGS = /* @__PURE__ */ new Set([...GLOBAL_FLAGS.map((flag) => flag.
4441
5102
  */
4442
5103
  const COMPOUND_NAMES = COMMAND_NAMES.filter((name) => name.includes(" ")).sort((left, right) => right.length - left.length);
4443
5104
  /**
4444
- * `--flag value`, `--flag=value`, `--no-flag`, and bare `--flag`.
5105
+ * `--flag value`, `--flag=value`, `--no-flag`, and bare `--flag`, parsed against the spec table.
4445
5106
  *
4446
5107
  * Every flag's value is an array, because several flags are repeatable (`--tag`, `--entity`,
4447
5108
  * `--body`) and a map of scalars would silently keep only the last occurrence, so a write with three
4448
5109
  * entities would store one. Non-repeatable flags read `.at(-1)`, so a duplicate is last-wins rather
4449
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.
4450
5116
  */
4451
5117
  const parseArgv = (argv) => {
4452
5118
  const positional = [];
4453
5119
  const flags = /* @__PURE__ */ new Map();
5120
+ const strayBooleanValues = [];
4454
5121
  const push = (name, value) => {
4455
5122
  const existing = flags.get(name);
4456
5123
  if (existing === void 0) flags.set(name, [value]);
@@ -4473,11 +5140,13 @@ const parseArgv = (argv) => {
4473
5140
  continue;
4474
5141
  }
4475
5142
  const next = argv[index + 1];
4476
- if (next !== void 0 && !next.startsWith("--")) {
5143
+ const type = FLAG_TYPES.get(body);
5144
+ if ((type === "string" || type === "int") && next !== void 0 && !next.startsWith("--")) {
4477
5145
  push(body, next);
4478
5146
  index += 2;
4479
5147
  continue;
4480
5148
  }
5149
+ if (type === "boolean" && next !== void 0 && BOOLEAN_VALUE_TOKENS.has(next.toLowerCase())) strayBooleanValues.push([body, next]);
4481
5150
  push(body, true);
4482
5151
  index += 1;
4483
5152
  continue;
@@ -4492,13 +5161,15 @@ const parseArgv = (argv) => {
4492
5161
  return {
4493
5162
  command: compound,
4494
5163
  positional: positional.slice(consumed),
4495
- flags
5164
+ flags,
5165
+ strayBooleanValues
4496
5166
  };
4497
5167
  }
4498
5168
  return {
4499
5169
  command: positional[0] ?? "",
4500
5170
  positional: positional.slice(1),
4501
- flags
5171
+ flags,
5172
+ strayBooleanValues
4502
5173
  };
4503
5174
  };
4504
5175
  /** A flag's last value as a string, or `undefined` when it was not given. */
@@ -4535,6 +5206,7 @@ const scopeOf = (parsed) => ({
4535
5206
  workspace: str(parsed, "workspace"),
4536
5207
  tags: list(parsed, "tag"),
4537
5208
  entity: str(parsed, "entity"),
5209
+ facets: parseFacetFilters(list(parsed, "facet")),
4538
5210
  includeArchived: bool(parsed, "include-archived", false),
4539
5211
  asOf: str(parsed, "as-of")
4540
5212
  });
@@ -4545,6 +5217,25 @@ const provenanceOf = (parsed) => ({
4545
5217
  turnUuid: str(parsed, "turn-uuid")
4546
5218
  });
4547
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
+ /**
4548
5239
  * Dispatch one parsed invocation against the provided services.
4549
5240
  *
4550
5241
  * Every arm is decode → call → name the response type. No arm builds an envelope, catches an error,
@@ -4571,6 +5262,7 @@ const dispatch = (parsed, applyOps = []) => {
4571
5262
  articleHtml: str(parsed, "article-html"),
4572
5263
  memoryType: str(parsed, "type") ?? "",
4573
5264
  path: str(parsed, "path"),
5265
+ strictPath: bool(parsed, "strict-path", false),
4574
5266
  workspace: str(parsed, "workspace"),
4575
5267
  tags: list(parsed, "tag"),
4576
5268
  entities: list(parsed, "entity"),
@@ -4644,9 +5336,13 @@ const dispatch = (parsed, applyOps = []) => {
4644
5336
  return ["memory.neighbors", yield* neighborsOf({
4645
5337
  path: parsed.positional[0] ?? "",
4646
5338
  depth: int(parsed, "depth"),
5339
+ limit: int(parsed, "limit"),
4647
5340
  rels: list(parsed, "rel")
4648
5341
  })];
4649
5342
  });
5343
+ case "resolve": return Effect.gen(function* () {
5344
+ return ["memory.resolved", yield* resolveMemory(parsed.positional[0] ?? "")];
5345
+ });
4650
5346
  case "archive": return Effect.gen(function* () {
4651
5347
  return ["memory.archived", yield* archiveMemory(parsed.positional[0] ?? "", str(parsed, "reason") ?? "")];
4652
5348
  });
@@ -4659,12 +5355,20 @@ const dispatch = (parsed, applyOps = []) => {
4659
5355
  workspace: str(parsed, "workspace"),
4660
5356
  tag: str(parsed, "tag"),
4661
5357
  entity: str(parsed, "entity"),
5358
+ facets: parseFacetFilters(list(parsed, "facet")),
4662
5359
  para: str(parsed, "para"),
4663
5360
  limit: int(parsed, "limit"),
4664
5361
  cursor: str(parsed, "cursor"),
4665
5362
  includeArchived: bool(parsed, "include-archived", false)
4666
5363
  })];
4667
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
+ });
4668
5372
  case "task add": return Effect.gen(function* () {
4669
5373
  const title = str(parsed, "title") ?? "";
4670
5374
  const result = yield* writeMemory({
@@ -4754,11 +5458,21 @@ const dispatch = (parsed, applyOps = []) => {
4754
5458
  deep: bool(parsed, "deep", false),
4755
5459
  ...maxLlmCalls === void 0 ? {} : { maxLlmCalls }
4756
5460
  });
4757
- return ["sleep.report", sleepRunReport(report)];
5461
+ const payload = sleepRunReport(report);
5462
+ return [
5463
+ "sleep.report",
5464
+ payload,
5465
+ sleepExit(payload)
5466
+ ];
4758
5467
  });
4759
5468
  case "sleep resume": return Effect.gen(function* () {
4760
5469
  const report = yield* (yield* Sleep).resume(parsed.positional[0] ?? "");
4761
- return ["sleep.report", sleepRunReport(report)];
5470
+ const payload = sleepRunReport(report);
5471
+ return [
5472
+ "sleep.report",
5473
+ payload,
5474
+ sleepExit(payload)
5475
+ ];
4762
5476
  });
4763
5477
  case "sleep review": return Effect.gen(function* () {
4764
5478
  const report = yield* (yield* Sleep).review(parsed.positional[0]);
@@ -4787,6 +5501,17 @@ const dispatch = (parsed, applyOps = []) => {
4787
5501
  case "state import": return Effect.gen(function* () {
4788
5502
  return ["state.import", yield* stateImport()];
4789
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
+ });
4790
5515
  case "sleep status": return Effect.gen(function* () {
4791
5516
  const report = yield* (yield* Sleep).review();
4792
5517
  return ["sleep.report", {
@@ -4841,10 +5566,13 @@ const EITHER_CLAIM_OR_ARTICLE = /* @__PURE__ */ new Set(["write", "correct"]);
4841
5566
  *
4842
5567
  * Here for the reason `claimOrArticle` is: `validate`'s return becomes exit 2 and a failure raised in
4843
5568
  * `dispatch` becomes exit 1, so "you passed the wrong flags" must be decided before any service is
4844
- * 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`).
4845
5572
  *
4846
5573
  * At most one rather than exactly one, because zero doors is legal and means stdin, the same shape
4847
- * `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
4848
5576
  * here. An empty one is, and that check sits beside the read in {@link run} because reading is async.
4849
5577
  *
4850
5578
  * `--timeout-ms` is checked for a positive integer within the cap. Zero and negatives are refused
@@ -4853,19 +5581,33 @@ const EITHER_CLAIM_OR_ARTICLE = /* @__PURE__ */ new Set(["write", "correct"]);
4853
5581
  */
4854
5582
  const execFlags = (parsed) => {
4855
5583
  if (parsed.command !== "exec") return void 0;
4856
- 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);
4857
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", [
4858
5587
  "memhtml exec --file traverse.mjs",
4859
5588
  "memhtml exec --script 'console.log(1)'",
4860
5589
  "cat s.mjs | memhtml exec"
4861
5590
  ]);
4862
- 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]} …`]);
4863
5592
  if (str(parsed, "timeout-ms") !== void 0) {
4864
5593
  const timeout = int(parsed, "timeout-ms");
4865
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}`]);
4866
5595
  }
4867
5596
  };
4868
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
+ /**
4869
5611
  * Exactly one of `--claim` / `--article-html`.
4870
5612
  *
4871
5613
  * Checked here rather than in the dispatch arm, because the exit code is the contract. `validate`'s
@@ -4885,6 +5627,76 @@ const claimOrArticle = (parsed) => {
4885
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>'`]);
4886
5628
  };
4887
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
+ /**
4888
5700
  * Validate a parsed invocation against its spec. Usage errors only; nothing here touches a service.
4889
5701
  *
4890
5702
  * Returning the failure rather than throwing keeps the exit code decision in one place. A usage
@@ -4892,9 +5704,21 @@ const claimOrArticle = (parsed) => {
4892
5704
  * have to know that too.
4893
5705
  */
4894
5706
  const validate = (parsed) => {
4895
- for (const name of parsed.flags.keys()) if (!KNOWN_FLAGS.has(name)) return fail("ERR_INVALID_FLAG", `unknown flag: --${name}`, nearest(name, [...KNOWN_FLAGS]));
4896
5707
  const spec = COMMANDS.find((command) => command.name === parsed.command);
4897
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;
4898
5722
  const missingArgs = spec.args.filter((arg, position) => arg.required && parsed.positional[position] === void 0);
4899
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}>`]);
4900
5724
  const missingFlags = spec.flags.filter((flag) => flag.required === true && parsed.flags.get(flag.name) === void 0);
@@ -4903,6 +5727,10 @@ const validate = (parsed) => {
4903
5727
  if (eitherOr !== void 0) return eitherOr;
4904
5728
  const exec = execFlags(parsed);
4905
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;
4906
5734
  /**
4907
5735
  * A closed-vocabulary flag is checked here rather than at the service, so a typo answers with the
4908
5736
  * whole vocabulary and never touches the database. Every value of a repeatable flag is checked, not
@@ -4983,22 +5811,23 @@ const run = async (argv, layer, stdin = readStdin) => {
4983
5811
  * would open and migrate a store this command never queries, and an operator checking the gate is
4984
5812
  * typically doing it while `memhtml-mcp` serves that store.
4985
5813
  *
4986
- * **Exit 1 on a failed gate**, with `ERR_DISCRIMINATION_FAILED`. A gate that exited 0 and
4987
- * left the verdict inside the payload would be a gate every shell caller forgets to read. The
4988
- * 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.
4989
5820
  */
4990
5821
  if (parsed.command === "eval discriminate") {
4991
5822
  const requested = str(parsed, "mode") ?? "fake";
4992
5823
  return Effect.runPromise(runDiscrimination({
4993
5824
  mode: requested,
4994
5825
  ...int(parsed, "seed") === void 0 ? {} : { seed: int(parsed, "seed") },
5826
+ ...int(parsed, "now") === void 0 ? {} : { now: int(parsed, "now") },
4995
5827
  ...int(parsed, "size") === void 0 ? {} : { size: int(parsed, "size") },
4996
5828
  ...int(parsed, "probes") === void 0 ? {} : { probes: int(parsed, "probes") },
4997
5829
  ...num(parsed, "mrr-floor") === void 0 ? {} : { mrrFloor: num(parsed, "mrr-floor") }
4998
- }).pipe(Effect.map((outcome) => outcome.passed ? emit(succeed("eval.discrimination", outcome), 0) : {
4999
- stdout: render(succeed("eval.discrimination", outcome), dense),
5000
- exitCode: 1
5001
- }), 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)));
5002
5831
  }
5003
5832
  /**
5004
5833
  * `memhtml exec` does not build the app layer either, for the reason two commands over.
@@ -5020,7 +5849,8 @@ const run = async (argv, layer, stdin = readStdin) => {
5020
5849
  */
5021
5850
  if (parsed.command === "exec") {
5022
5851
  const inline = str(parsed, "script");
5023
- 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;
5024
5854
  const script = inline !== void 0 ? inline : file === void 0 ? await stdin() : await readScript(file);
5025
5855
  if (typeof script !== "string") return emit(script, 2);
5026
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", [
@@ -5054,14 +5884,15 @@ const run = async (argv, layer, stdin = readStdin) => {
5054
5884
  */
5055
5885
  let applyOps = [];
5056
5886
  if (parsed.command === "apply") {
5057
- 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;
5058
5889
  const text = await applyText(file, stdin);
5059
5890
  if (typeof text !== "string") return emit(text, 2);
5060
5891
  const decoded = decodeApply(text);
5061
5892
  if (!decoded.ok) return emit(decoded.failure, 2);
5062
5893
  applyOps = decoded.ops;
5063
5894
  }
5064
- 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);
5065
5896
  return Effect.runPromise(program);
5066
5897
  };
5067
5898