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/README.md +77 -72
- package/agent/instructions.md +34 -10
- package/dist/dist-CBhYV3up.mjs +3 -0
- package/dist/{dist-CHoz5uHd.mjs → dist-D1wH0oJ0.mjs} +2574 -703
- package/dist/dist-D1wH0oJ0.mjs.map +1 -0
- package/dist/{dist-BCsav-EP.mjs → dist-DHFdTnlp.mjs} +825 -249
- package/dist/dist-DHFdTnlp.mjs.map +1 -0
- package/dist/memhtml-mcp.mjs +1272 -346
- package/dist/memhtml-mcp.mjs.map +1 -1
- package/dist/memhtml.mjs +1196 -365
- package/dist/memhtml.mjs.map +1 -1
- package/migrations/0007_watermark.sql +4 -2
- package/migrations/0011_edge_indexes.sql +78 -0
- package/migrations/0012_origin_path.sql +21 -0
- package/package.json +10 -10
- package/src/agent-build.ts +293 -21
- package/src/child-stderr.ts +36 -0
- package/src/client.ts +284 -171
- package/src/contract.ts +317 -74
- package/src/mount.ts +31 -7
- package/src/run-auth.ts +18 -15
- package/state-migrations/S0002_entity_corroboration.sql +13 -7
- package/dist/dist-BCsav-EP.mjs.map +0 -1
- package/dist/dist-CHoz5uHd.mjs.map +0 -1
- package/dist/dist-DuzGralO.mjs +0 -3
package/dist/memhtml.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as
|
|
3
|
-
import {
|
|
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
|
-
|
|
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 (
|
|
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
|
|
528
|
-
* `process.env.MEMHTML_LLM` after importing `effect` changes nothing `Config.string` returns
|
|
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
|
|
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
|
|
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
|
|
680
|
-
*
|
|
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
|
|
684
|
-
* suggestion here that nothing
|
|
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: () => [
|
|
694
|
-
|
|
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
|
-
*
|
|
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.
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
* 2.
|
|
807
|
-
*
|
|
808
|
-
*
|
|
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
|
|
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.
|
|
824
|
-
*
|
|
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.
|
|
1440
|
-
*
|
|
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
|
-
*
|
|
1462
|
-
*
|
|
1463
|
-
* at
|
|
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
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
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
|
-
*
|
|
1586
|
-
*
|
|
1587
|
-
* under two paths, and
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
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
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
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
|
-
*
|
|
2644
|
-
* and
|
|
2645
|
-
* beside
|
|
2646
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
3157
|
-
* and it
|
|
3158
|
-
*
|
|
3159
|
-
*
|
|
3160
|
-
*
|
|
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
|
|
3168
|
-
*
|
|
3169
|
-
*
|
|
3170
|
-
*
|
|
3171
|
-
*
|
|
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
|
|
3373
|
-
* the render gate would stop the op instead of committing a file with an empty `files.gist`.
|
|
3374
|
-
* derivation is what makes a prose line valid in the first place
|
|
3375
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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(() =>
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
3948
|
-
*
|
|
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
|
-
*
|
|
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."
|
|
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-
|
|
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,
|
|
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`.
|
|
4161
|
-
*
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
4987
|
-
* left the verdict inside the payload would be a gate every shell caller
|
|
4988
|
-
*
|
|
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
|
|
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
|
|
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
|
|