memhtml 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -72
- package/agent/instructions.md +34 -10
- package/dist/dist-CBhYV3up.mjs +3 -0
- package/dist/{dist-DgKlozi6.mjs → dist-D1wH0oJ0.mjs} +3353 -901
- 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 +1280 -343
- package/dist/memhtml-mcp.mjs.map +1 -1
- package/dist/memhtml.mjs +1221 -376
- 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-DgKlozi6.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
|
|
@@ -1446,66 +1579,228 @@ const reinforceMemories = (paths, signal) => Effect.gen(function* () {
|
|
|
1446
1579
|
* is what they are for. `derived` is still reported per node so a caller can tell a
|
|
1447
1580
|
* sleep-mined suspicion from an authored assertion.
|
|
1448
1581
|
*
|
|
1449
|
-
* `edge_class = 'memory'` on every join. A person edge entering here would put
|
|
1450
|
-
* `resources/people/*` into a memory neighborhood, and the class column exists to make that
|
|
1451
|
-
* structurally impossible.
|
|
1582
|
+
* `edge_class = 'memory'` on every join. A person edge entering here would put
|
|
1583
|
+
* `resources/people/*` into a memory neighborhood, and the class column exists to make that
|
|
1584
|
+
* structurally impossible.
|
|
1585
|
+
*/
|
|
1586
|
+
const neighborsOf = (params) => Effect.gen(function* () {
|
|
1587
|
+
const db = yield* DatabaseService;
|
|
1588
|
+
const center = normalizePath(params.path);
|
|
1589
|
+
const depth = Math.min(2, Math.max(1, Math.trunc(params.depth ?? 1)));
|
|
1590
|
+
const limit = Math.min(200, Math.max(1, Math.trunc(params.limit ?? 200)));
|
|
1591
|
+
const rels = (params.rels ?? []).filter((rel) => isEdgeRel(rel) && relClassFor(rel) === "memory");
|
|
1592
|
+
/**
|
|
1593
|
+
* Edge rows, hop-1 first, folded per path below rather than `GROUP BY` in SQL. A `GROUP BY`
|
|
1594
|
+
* with `min(hop)` and `min(rel)` aggregates the two columns independently, so a node reachable
|
|
1595
|
+
* as `supersedes` at hop 1 and `contradicts` at hop 2 would report `(hop 1, contradicts)`, a
|
|
1596
|
+
* pairing no edge holds. The fold keeps the rel of an edge AT the minimal hop.
|
|
1597
|
+
*/
|
|
1598
|
+
const statement = neighborsQuery({
|
|
1599
|
+
center,
|
|
1600
|
+
depth,
|
|
1601
|
+
rels
|
|
1602
|
+
});
|
|
1603
|
+
const rows = yield* db.all(statement.sql, statement.params);
|
|
1604
|
+
/**
|
|
1605
|
+
* One node per path at its minimal hop: a node reachable both directly and via a detour is a
|
|
1606
|
+
* 1-hop neighbor, and reporting it twice would let one memory occupy two slots in a bounded
|
|
1607
|
+
* answer. The rows arrive hop-first, so a path's first row IS an edge at its minimal hop and
|
|
1608
|
+
* its rel is kept verbatim. `derived` is the max over every edge reaching the node, so one
|
|
1609
|
+
* sleep-mined route marks the node as carrying a mined suspicion even when an authored edge
|
|
1610
|
+
* also reaches it. `edges` counts distinct edges the walk enumerated, which is what the MCP
|
|
1611
|
+
* schema's `edges` field claims to be.
|
|
1612
|
+
*
|
|
1613
|
+
* A path the clamp turns away is still counted, in `nodesDropped`, and its edges still count
|
|
1614
|
+
* toward `edges`: the two numbers live in different coordinate spaces on purpose, and an `edges`
|
|
1615
|
+
* total that quietly excluded a dropped path's edges would agree with `nodes` while describing
|
|
1616
|
+
* a walk that never happened.
|
|
1617
|
+
*/
|
|
1618
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
1619
|
+
const edgeKeys = /* @__PURE__ */ new Set();
|
|
1620
|
+
const dropped = /* @__PURE__ */ new Set();
|
|
1621
|
+
for (const row of rows) {
|
|
1622
|
+
edgeKeys.add(JSON.stringify([
|
|
1623
|
+
row.a,
|
|
1624
|
+
row.rel,
|
|
1625
|
+
row.b
|
|
1626
|
+
]));
|
|
1627
|
+
const existing = byPath.get(row.path);
|
|
1628
|
+
if (existing === void 0) {
|
|
1629
|
+
if (byPath.size < limit) byPath.set(row.path, {
|
|
1630
|
+
title: row.title,
|
|
1631
|
+
hop: row.hop,
|
|
1632
|
+
rel: row.rel,
|
|
1633
|
+
derived: row.derived === 1
|
|
1634
|
+
});
|
|
1635
|
+
else dropped.add(row.path);
|
|
1636
|
+
} else if (row.derived === 1) byPath.set(row.path, {
|
|
1637
|
+
...existing,
|
|
1638
|
+
derived: true
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
return {
|
|
1642
|
+
center,
|
|
1643
|
+
depth,
|
|
1644
|
+
/** The node ceiling this answer was built under, after clamping the caller's ask. */
|
|
1645
|
+
limit,
|
|
1646
|
+
nodes: [...byPath.entries()].map(([path, node]) => ({
|
|
1647
|
+
path,
|
|
1648
|
+
title: node.title,
|
|
1649
|
+
hop: node.hop,
|
|
1650
|
+
rel: node.rel,
|
|
1651
|
+
derived: node.derived
|
|
1652
|
+
})),
|
|
1653
|
+
edges: edgeKeys.size,
|
|
1654
|
+
/**
|
|
1655
|
+
* Distinct paths the walk reached and `limit` turned away. `0` means `nodes` holds every path
|
|
1656
|
+
* the walk found, so a caller can tell a saturated neighborhood from a complete one. Raising
|
|
1657
|
+
* `limit` toward {@link NEIGHBORS_LIMIT} returns them.
|
|
1658
|
+
*/
|
|
1659
|
+
nodesDropped: dropped.size,
|
|
1660
|
+
/**
|
|
1661
|
+
* True when the walk returned {@link NEIGHBORS_SCAN_LIMIT} rows, so edges past the cap were
|
|
1662
|
+
* never enumerated and no `limit` recovers them. Distinct from `nodesDropped`, which a bigger
|
|
1663
|
+
* `limit` fixes.
|
|
1664
|
+
*/
|
|
1665
|
+
scanSaturated: rows.length >= NEIGHBORS_SCAN_LIMIT
|
|
1666
|
+
};
|
|
1667
|
+
});
|
|
1668
|
+
/**
|
|
1669
|
+
* Steps the forward walk takes before it stops and says which bound stopped it.
|
|
1670
|
+
*
|
|
1671
|
+
* A chain this long is a corpus that has corrected one fact sixteen times, which the walk answers
|
|
1672
|
+
* with `hop_limit` rather than by paying an unbounded number of statements for a read a caller
|
|
1673
|
+
* expects to be cheap. It is not the cycle guard: {@link resolveMemory} carries a visited set, so a
|
|
1674
|
+
* loop is reported as a loop at the hop that closes it, however short.
|
|
1452
1675
|
*/
|
|
1453
|
-
const
|
|
1676
|
+
const RESOLVE_MAX_HOPS = 16;
|
|
1677
|
+
/**
|
|
1678
|
+
* The three statements the walk issues, as literals a plan assertion can EXPLAIN.
|
|
1679
|
+
*
|
|
1680
|
+
* Exported for {@link neighborsQuery}'s reason: a cost contract can only be asserted at the planner,
|
|
1681
|
+
* and a test that EXPLAINed a pasted copy would explain the copy. Each one binds exactly one
|
|
1682
|
+
* parameter, so a test can run them as written.
|
|
1683
|
+
*
|
|
1684
|
+
* `successor` names `edge_class` even though `rel = 'supersedes'` implies it under `edges`' CHECK
|
|
1685
|
+
* constraints. That is a planner constraint, not a filter: measured 2026-08-26 on node 24's
|
|
1686
|
+
* `node:sqlite` with no `ANALYZE`, `dst_path = ? AND rel = ? AND derived = 0` alone plans as `SEARCH
|
|
1687
|
+
* edges USING INDEX edges_derived (derived=? AND rel=?)` — every authored correction in the corpus,
|
|
1688
|
+
* per hop — while naming the class binds two columns of `edges_dst` and the same statement probes.
|
|
1689
|
+
* The rel and `derived = 0` are still the CORRECTNESS half: `derived = 0` is the same authored-only
|
|
1690
|
+
* rule `SearchHit.supersededBy` reads, so `search` and this walk cannot disagree about who superseded
|
|
1691
|
+
* what, and a sleep-mined suspicion can never redirect a citation.
|
|
1692
|
+
*
|
|
1693
|
+
* `archived` is the archive mapping read backwards, served by `files_origin`
|
|
1694
|
+
* (`0012_origin_path.sql`). `ORDER BY archived_at DESC` decides the case a UNIQUE index would have
|
|
1695
|
+
* had to refuse: one path evicted, rewritten, and evicted again carries two archive rows, and the
|
|
1696
|
+
* NEWEST is the occupant a citation of that path most recently named. A row with no `memhtml-archived`
|
|
1697
|
+
* stamp sorts last, since SQLite puts NULLs last under DESC.
|
|
1698
|
+
*/
|
|
1699
|
+
const resolveQueries = {
|
|
1700
|
+
successor: `SELECT e.src_path AS path FROM edges e
|
|
1701
|
+
WHERE e.dst_path = ? AND e.edge_class = 'memory' AND e.rel = 'supersedes' AND e.derived = 0
|
|
1702
|
+
ORDER BY e.created_at DESC, e.src_path ASC LIMIT 1`,
|
|
1703
|
+
archived: `SELECT f.path AS path FROM files f
|
|
1704
|
+
WHERE f.origin_path = ? ORDER BY f.archived_at DESC, f.path DESC LIMIT 1`,
|
|
1705
|
+
file: "SELECT f.archived AS archived, f.title AS title FROM files f WHERE f.path = ?"
|
|
1706
|
+
};
|
|
1707
|
+
/**
|
|
1708
|
+
* The live path a possibly-moved path names now, by walking `supersedes` forward.
|
|
1709
|
+
*
|
|
1710
|
+
* **A path IS the id of a memory** (`packages/contracts/src/types.ts`, `MemoryPath`), and it is
|
|
1711
|
+
* derived from the title through `slugify`, so a re-consolidation that rewords a title lands the
|
|
1712
|
+
* corrected fact at a DIFFERENT path while `correctMemory` `git mv`s the original into
|
|
1713
|
+
* `archive/<YYYY>/`. An external receipt holding the old path therefore dead-ends at a path the tree
|
|
1714
|
+
* no longer holds — through no fault of the receipt. This read is how such a receipt is repaired
|
|
1715
|
+
* without a second identifier: the corpus already records both mechanisms that move a memory, and
|
|
1716
|
+
* nothing here is minted.
|
|
1717
|
+
*
|
|
1718
|
+
* **Two mechanisms, and a path absent from `files` is looked up by the archive mapping ALONE.** A
|
|
1719
|
+
* correction stamps its `supersedes` link toward the target's ARCHIVE path
|
|
1720
|
+
* (`packages/store/src/store.ts`, `correctMemory`), so the pre-archive path has no inbound edge at
|
|
1721
|
+
* all and only `origin_path` knows where its bytes went. An inbound `supersedes` edge over a path the
|
|
1722
|
+
* tree does not hold is a DANGLING edge — `memhtml doctor`'s finding — and following one would
|
|
1723
|
+
* resolve a citation through an assertion about a file nothing can read. Conversely a path that IS in
|
|
1724
|
+
* `files` is never redirected by the archive mapping, even when an older eviction of the same path
|
|
1725
|
+
* left a row behind: the live file at that path is the answer, and the redirect would replace it with
|
|
1726
|
+
* a historical one.
|
|
1727
|
+
*
|
|
1728
|
+
* **Every node in the chain is named by the path that holds it NOW.** A `supersedes` link is an
|
|
1729
|
+
* element inside a file, so archiving that file carries the link with it: after a second correction the
|
|
1730
|
+
* edge points from the archived middle memory, not from the path the middle was live at. A three-step
|
|
1731
|
+
* chain over two corrections therefore reads `cited → archive(cited) → archive(middle) → live`, and the
|
|
1732
|
+
* middle's own live-at-the-time path appears nowhere in it. The tree is the system of record, and this
|
|
1733
|
+
* walk reports where each memory is rather than where it was.
|
|
1734
|
+
*
|
|
1735
|
+
* **`hops: 0` with `stopReason: "live"` does not mean the bytes are unchanged.** A correction whose
|
|
1736
|
+
* title is unchanged lands at the SAME path, so the path is live and its content is a different fact.
|
|
1737
|
+
* That grain is what the pinned citation URI is for; this read answers where to look, not what was
|
|
1738
|
+
* there.
|
|
1739
|
+
*
|
|
1740
|
+
* Statement count is `1..2` per hop and the walk is bounded twice — by a visited set and by
|
|
1741
|
+
* {@link RESOLVE_MAX_HOPS} — so a corpus defect costs a bounded read and is reported rather than
|
|
1742
|
+
* hung. A recursive CTE would do it in one statement and could not report WHICH mechanism took each
|
|
1743
|
+
* hop, which is the half a receipt is audited on.
|
|
1744
|
+
*/
|
|
1745
|
+
const resolveMemory = (path) => Effect.gen(function* () {
|
|
1454
1746
|
const db = yield* DatabaseService;
|
|
1455
|
-
const
|
|
1456
|
-
const
|
|
1457
|
-
const
|
|
1458
|
-
const
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
}
|
|
1747
|
+
const requested = normalizePath(path);
|
|
1748
|
+
const state = yield* readIndexState(db);
|
|
1749
|
+
const steps = [];
|
|
1750
|
+
const visited = /* @__PURE__ */ new Set([requested]);
|
|
1751
|
+
/** Titles of every indexed path the walk touched, so the answer carries one without a re-read. */
|
|
1752
|
+
const titles = /* @__PURE__ */ new Map();
|
|
1753
|
+
let at = requested;
|
|
1754
|
+
let stopReason = "unindexed";
|
|
1755
|
+
for (;;) {
|
|
1756
|
+
const row = yield* db.get(resolveQueries.file, [at]);
|
|
1757
|
+
if (row !== void 0) titles.set(at, row.title);
|
|
1758
|
+
const hop = row === void 0 ? {
|
|
1759
|
+
found: yield* db.get(resolveQueries.archived, [at]),
|
|
1760
|
+
via: "archive_move"
|
|
1761
|
+
} : {
|
|
1762
|
+
found: yield* db.get(resolveQueries.successor, [at]),
|
|
1763
|
+
via: "supersedes"
|
|
1764
|
+
};
|
|
1765
|
+
if (hop.found === void 0) {
|
|
1766
|
+
if (row === void 0) stopReason = "unindexed";
|
|
1767
|
+
else stopReason = row.archived === 1 ? "archived" : "live";
|
|
1768
|
+
break;
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* The bound is checked BEFORE the step is taken, so `steps.length` is exactly
|
|
1772
|
+
* {@link RESOLVE_MAX_HOPS} when it fires and `path` is a real path the walk stood on. Taking the
|
|
1773
|
+
* step first would report a hop past a bound the answer claims to respect.
|
|
1774
|
+
*/
|
|
1775
|
+
if (steps.length >= 16) {
|
|
1776
|
+
stopReason = "hop_limit";
|
|
1777
|
+
break;
|
|
1778
|
+
}
|
|
1779
|
+
steps.push({
|
|
1780
|
+
from: at,
|
|
1781
|
+
to: hop.found.path,
|
|
1782
|
+
via: hop.via
|
|
1783
|
+
});
|
|
1784
|
+
at = hop.found.path;
|
|
1785
|
+
/**
|
|
1786
|
+
* The repeat IS recorded as a step before the walk stops, so `steps` shows the loop closing and
|
|
1787
|
+
* a reader can name both ends of it. A cycle detected and then hidden would leave the caller
|
|
1788
|
+
* with a `path` it cannot account for.
|
|
1789
|
+
*/
|
|
1790
|
+
if (visited.has(at)) {
|
|
1791
|
+
stopReason = "cycle";
|
|
1792
|
+
break;
|
|
1793
|
+
}
|
|
1794
|
+
visited.add(at);
|
|
1795
|
+
}
|
|
1504
1796
|
return {
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
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
|
{
|
|
@@ -2663,6 +3212,17 @@ const COMMANDS = [
|
|
|
2663
3212
|
type: "boolean",
|
|
2664
3213
|
description: "Report per-phase counts and commit nothing.",
|
|
2665
3214
|
default: false
|
|
3215
|
+
},
|
|
3216
|
+
{
|
|
3217
|
+
name: "deep",
|
|
3218
|
+
type: "boolean",
|
|
3219
|
+
description: "The deep-sleep cycle: mine a lower grouping band, group by shared entity, re-file inbox singletons, and iterate compress until a pass folds nothing. Reaches the inbox tail the default community gate cannot; costs more model calls. Same branch, review, and merge gate as a run without this flag.",
|
|
3220
|
+
default: false
|
|
3221
|
+
},
|
|
3222
|
+
{
|
|
3223
|
+
name: "max-llm-calls",
|
|
3224
|
+
type: "int",
|
|
3225
|
+
description: "Cap on model calls the deep mechanisms may spend, shared across all deep phases. Exhaustion skips remaining batches with reason `budget` and the run stays green. Read only with --deep; absent means uncapped."
|
|
2666
3226
|
}
|
|
2667
3227
|
],
|
|
2668
3228
|
responseTypes: ["sleep.report"]
|
|
@@ -2717,6 +3277,13 @@ const COMMANDS = [
|
|
|
2717
3277
|
flags: [],
|
|
2718
3278
|
responseTypes: ["sleep.report"]
|
|
2719
3279
|
},
|
|
3280
|
+
{
|
|
3281
|
+
name: "sleep plan",
|
|
3282
|
+
summary: "Would a run change anything? Read the signals from index counts, running no phase.",
|
|
3283
|
+
args: [],
|
|
3284
|
+
flags: [],
|
|
3285
|
+
responseTypes: ["sleep.plan"]
|
|
3286
|
+
},
|
|
2720
3287
|
{
|
|
2721
3288
|
name: "status",
|
|
2722
3289
|
summary: "Corpus health: HEAD, dirty state, counts by type, edges, index freshness.",
|
|
@@ -2760,6 +3327,11 @@ const COMMANDS = [
|
|
|
2760
3327
|
type: "int",
|
|
2761
3328
|
description: "The fixture corpus seed. A failing run is reproducible from this number."
|
|
2762
3329
|
},
|
|
3330
|
+
{
|
|
3331
|
+
name: "now",
|
|
3332
|
+
type: "int",
|
|
3333
|
+
description: "The run instant the fixture corpus anchors its stamps behind, UTC milliseconds since the epoch. The other half of reproducing a failing run: the corpus is a function of (seed, now), and the recency arm ranks on those stamps. Defaults to the clock, and rides back in the report."
|
|
3334
|
+
},
|
|
2763
3335
|
{
|
|
2764
3336
|
name: "size",
|
|
2765
3337
|
type: "int",
|
|
@@ -2820,7 +3392,7 @@ const COMMANDS = [
|
|
|
2820
3392
|
{
|
|
2821
3393
|
name: "file",
|
|
2822
3394
|
type: "string",
|
|
2823
|
-
description: "The script to run, as a path on the HOST. Omit it
|
|
3395
|
+
description: "The script to run, as a path on the HOST. Omit it, pass `--file -`, or pass a positional `-` to read the script from stdin. Mutually exclusive with `--script`."
|
|
2824
3396
|
},
|
|
2825
3397
|
{
|
|
2826
3398
|
name: "script",
|
|
@@ -2873,7 +3445,7 @@ const COMMANDS = [
|
|
|
2873
3445
|
},
|
|
2874
3446
|
{
|
|
2875
3447
|
name: "serve mcp",
|
|
2876
|
-
summary: "Run the `memhtml-mcp` stdio server:
|
|
3448
|
+
summary: "Run the `memhtml-mcp` stdio server: 15 tools and 3 resources over this same repo.",
|
|
2877
3449
|
args: [],
|
|
2878
3450
|
flags: [],
|
|
2879
3451
|
responseTypes: ["serve.exit"]
|
|
@@ -2908,12 +3480,12 @@ const GUIDE = [
|
|
|
2908
3480
|
},
|
|
2909
3481
|
{
|
|
2910
3482
|
topic: "write-surfaces",
|
|
2911
|
-
body: "There are three ways to put a memory into the corpus, and they are all legitimate. First, this CLI: `memhtml write` for one memory, `memhtml apply` for many. Second, the MCP server: `memhtml serve mcp` speaks stdio with
|
|
3483
|
+
body: "There are three ways to put a memory into the corpus, and they are all legitimate. First, this CLI: `memhtml write` for one memory, `memhtml apply` for many. Second, the MCP server: `memhtml serve mcp` speaks stdio with the same tools and resources over this same repo, and it is the door to use when you are already an MCP client. Third, editing files under $MEMHTML_ROOT directly with your normal file tools: the git tree IS the system of record and `.memhtml/index.db` is only a projection of it, so a hand-written or hand-edited memory file is as real as one this CLI wrote. `memhtml index update` projects uncommitted working-tree changes as well as committed ones, so a dirty edit is searchable before you commit it. What you take on by editing directly is everything the write path would have done for you: the file must satisfy the format (run `memhtml doctor`, and `memhtml read <path>` reports per-file format warnings), you own choosing a path that does not collide, you own noticing that the content already exists somewhere else, and you own the commit. `memhtml sleep run` refuses to start on a dirty tree, so an uncommitted edit blocks curation until it is committed or stashed. A CLI command and a running `memhtml serve mcp` may share one store: the index is WAL SQLite, which admits one writer at a time and any number of concurrent readers, so a second writer waits its turn rather than failing. The one thing to keep clear of is `memhtml sleep run`, and for a git reason rather than a database one: a run holds a checked-out `sleep/<date>` branch, so a write landing during it commits onto that branch and is merged as if it were curation or lost when the branch is dropped."
|
|
2912
3484
|
},
|
|
2913
3485
|
{
|
|
2914
3486
|
topic: "when-to-batch",
|
|
2915
3487
|
body: `Writing more than about three memories in one task? Call \`memhtml apply\` once with a JSONL op stream instead of running \`memhtml write\` N times. A batch stages every file, makes ONE commit, and reindexes ONCE, where N separate writes make N commits and pay N index passes over N diffs. Pass the stream as \`memhtml apply --file ops.jsonl\`, or pipe it: \`memhtml apply -\` and a bare \`memhtml apply\` both read stdin. One complete JSON object per line, no wrapping array, no pretty-printing. A line looks like this:
|
|
2916
|
-
${GUIDE_OP_EXAMPLE}\n\`op\` is \`write\` (the only verb in the vocabulary today), \`title\` and \`type\` are required, and each op carries the same optional fields \`memhtml write\` takes, in snake_case: \`path\`, \`workspace\`, \`tag\`, \`entity\`, \`importance\`, \`confidence\`, \`session_id\`, \`prompt_id\`, \`turn_uuid\`. The whole file is validated for shape before ANY op executes, so a malformed line 7 is exit 2 naming line 7 with nothing written. A failed apply costs you nothing but the call. You get one result per op in INPUT ORDER, each naming its own \`index\`, so you can match results back to the lines you sent. A batch is ATOMIC by default: the first refused op aborts the whole batch, no file is written, no commit is made, and the surviving ops report \`skipped: true\`. Pass \`--continue-on-error\` for best-effort instead, and a refused op comes back as one failed result carrying its own \`code\` and \`error\` while every op that succeeded lands in the one commit. A duplicate is never an error: an op whose exact content is already stored comes back \`ok: true\` with \`deduped: true\` and the existing path, so re-applying a file you already applied is safe and writes nothing. \`commit_sha\` is null exactly when nothing was committed: a batch that only deduped, or one that aborted.`
|
|
3488
|
+
${GUIDE_OP_EXAMPLE}\n\`op\` is \`write\` (the only verb in the vocabulary today), \`title\` and \`type\` are required, and each op carries the same optional fields \`memhtml write\` takes, in snake_case: \`path\`, \`strict_path\`, \`workspace\`, \`tag\`, \`entity\`, \`importance\`, \`confidence\`, \`status\`, \`due\`, \`session_id\`, \`prompt_id\`, \`turn_uuid\`. The whole file is validated for shape before ANY op executes, so a malformed line 7 is exit 2 naming line 7 with nothing written. A failed apply costs you nothing but the call. You get one result per op in INPUT ORDER, each naming its own \`index\`, so you can match results back to the lines you sent. A batch is ATOMIC by default: the first refused op aborts the whole batch, no file is written, no commit is made, and the surviving ops report \`skipped: true\`. Pass \`--continue-on-error\` for best-effort instead, and a refused op comes back as one failed result carrying its own \`code\` and \`error\` while every op that succeeded lands in the one commit. A duplicate is never an error: an op whose exact content is already stored comes back \`ok: true\` with \`deduped: true\` and the existing path, so re-applying a file you already applied is safe and writes nothing. \`commit_sha\` is null exactly when nothing was committed: a batch that only deduped, or one that aborted.`
|
|
2917
3489
|
},
|
|
2918
3490
|
{
|
|
2919
3491
|
topic: "conflicts",
|
|
@@ -2925,7 +3497,7 @@ ${GUIDE_OP_EXAMPLE}\n\`op\` is \`write\` (the only verb in the vocabulary today)
|
|
|
2925
3497
|
},
|
|
2926
3498
|
{
|
|
2927
3499
|
topic: "code-mode",
|
|
2928
|
-
body: "Answering a question that takes MORE THAN ONE HOP through the corpus? Write it as a script and run `memhtml exec` once, instead of spending a tool call per hop. Supersedence ancestry, live contradiction pairs, orphan census, entity co-occurrence, 'which of these 40 paths has no backlink': each of those is one traversal in code and N round trips through `memhtml read` and `memhtml neighbors`. Measured on a 305-file corpus: a full census in 598ms, and 410 edges resolved into 201 chains, longest 8 hops, in one execution at 430ms. The script runs under QuickJS in a sandbox with the corpus mounted READ-ONLY at `/mnt/memhtml`, and a helper is already seeded for you at `/workspace/lib/corpus.mjs`. Import it: `import { corpus, backlinks, chain, edges } from \"/workspace/lib/corpus.mjs\"`. `corpus()` returns a Map keyed by root-absolute path (the SAME string an edge's href holds, so `memories.get(link.href)` resolves with no path juggling) and each value carries `claim`, `memoryType`, `status`, `tags`, `entities`, `links`, `facets`, `citations`, `eventAt`, and a `document` escape hatch for any selector the fields do not cover. Print your answer as JSON on stdout with `console.log`; it comes back verbatim in `data.stdout`, so keep it small and structured rather than dumping the corpus. THREE THINGS IT CANNOT DO, by design. It cannot write: the corpus is read-only and a write answers EROFS, so every write still goes through `memhtml write` / `memhtml apply`, which own commits, dedup, and conflict detection. It cannot rank: no cosine, no RRF, no salience, and no index database. For ranked retrieval shell out to `memhtml search
|
|
3500
|
+
body: "Answering a question that takes MORE THAN ONE HOP through the corpus? Write it as a script and run `memhtml exec` once, instead of spending a tool call per hop. Supersedence ancestry, live contradiction pairs, orphan census, entity co-occurrence, 'which of these 40 paths has no backlink': each of those is one traversal in code and N round trips through `memhtml read` and `memhtml neighbors`. Measured on a 305-file corpus: a full census in 598ms, and 410 edges resolved into 201 chains, longest 8 hops, in one execution at 430ms. The script runs under QuickJS in a sandbox with the corpus mounted READ-ONLY at `/mnt/memhtml`, and a helper is already seeded for you at `/workspace/lib/corpus.mjs`. Import it: `import { corpus, backlinks, chain, edges } from \"/workspace/lib/corpus.mjs\"`. `corpus()` returns a Map keyed by root-absolute path (the SAME string an edge's href holds, so `memories.get(link.href)` resolves with no path juggling) and each value carries `claim`, `memoryType`, `status`, `tags`, `entities`, `links`, `facets`, `citations`, `eventAt`, and a `document` escape hatch for any selector the fields do not cover. Print your answer as JSON on stdout with `console.log`; it comes back verbatim in `data.stdout`, so keep it small and structured rather than dumping the corpus. THREE THINGS IT CANNOT DO, by design. It cannot write: the corpus is read-only and a write answers EROFS, so every write still goes through `memhtml write` / `memhtml apply`, which own commits, dedup, and conflict detection. It cannot rank: no cosine, no RRF, no salience, and no index database. For ranked retrieval shell out to `memhtml search` and parse its envelope, which the one-envelope-per-command contract already makes a code-mode API. And it cannot reach the network: there is no curl and the guest's `fetch` refuses on call. The intended opening move is ranked retrieval FIRST, code-mode second: `memhtml search` or `memhtml recall` to get the handful of paths the ranking stack says matter, then `memhtml exec` to walk, join, count, and filter from there. Starting in code-mode means starting with a full-corpus scan and no relevance signal. A non-zero `exitCode` in the response is YOUR script failing, not the command failing. Read `data.stderr` for the diagnostic and the exit code is still 0. A script that runs past `--timeout-ms` (default 30000) comes back `exitCode: 124` with `timedOut: true`. The tree you get is a pinned commit, HEAD by default, named in `data.sha`, so an answer is reproducible with `--sha`, and an uncommitted edit is NOT visible to the script."
|
|
2929
3501
|
}
|
|
2930
3502
|
];
|
|
2931
3503
|
const GUIDE_TOPICS = GUIDE.map((block) => block.topic);
|
|
@@ -2936,13 +3508,14 @@ const GUIDE_TOPICS = GUIDE.map((block) => block.topic);
|
|
|
2936
3508
|
*/
|
|
2937
3509
|
const buildManifest = () => ({
|
|
2938
3510
|
name: "memhtml",
|
|
2939
|
-
version: "0.
|
|
3511
|
+
version: "0.7.0",
|
|
2940
3512
|
summary: "Read, write, and curate the git-backed memory repo.",
|
|
2941
3513
|
apiVersion: "1",
|
|
2942
3514
|
/**
|
|
2943
3515
|
* The prose an agent needs before the command table means anything, so it is listed before it.
|
|
2944
|
-
* A manifest that
|
|
2945
|
-
* surface, while `guide` states it.
|
|
3516
|
+
* A manifest that opens with the command specifications makes an agent infer the workflow from a
|
|
3517
|
+
* surface, while `guide` states it. No count appears here: `commands` is `COMMANDS` walked, so the
|
|
3518
|
+
* only honest quantity is its `length`.
|
|
2946
3519
|
*/
|
|
2947
3520
|
guide: GUIDE,
|
|
2948
3521
|
globalFlags: GLOBAL_FLAGS,
|
|
@@ -3142,22 +3715,22 @@ const runAgentsDoc = (options) => Effect.gen(function* () {
|
|
|
3142
3715
|
* Prose → claim derivation: the single implementation both write doors use.
|
|
3143
3716
|
*
|
|
3144
3717
|
* The tools take `{title, body}` because that is what a model produces, and the format needs a
|
|
3145
|
-
* `<mark>` claim plus one `<p>` per paragraph. Turning the first into the second is a text
|
|
3146
|
-
* and it
|
|
3147
|
-
*
|
|
3148
|
-
*
|
|
3149
|
-
*
|
|
3718
|
+
* `<mark>` claim plus one `<p>` per paragraph. Turning the first into the second is a text
|
|
3719
|
+
* heuristic, and it must have exactly one copy: `apps/mcp` and `apps/cli` both import this module,
|
|
3720
|
+
* because a sentence-splitting rule maintained per door lets `memhtml apply` and
|
|
3721
|
+
* `memory_write_batch` derive different claims from the same body, making the gist of a memory
|
|
3722
|
+
* depend on which door wrote it.
|
|
3150
3723
|
*
|
|
3151
3724
|
* It does not live in `@memhtml/html`, which owns markup and the format's own rules. "Where does a
|
|
3152
3725
|
* sentence end" is a guess about natural-language prose, and the format states no such constraint. It
|
|
3153
3726
|
* is not in `operations.ts` either, because that module holds the use cases both doors call, and this
|
|
3154
3727
|
* is a text helper they apply before calling one.
|
|
3155
3728
|
*
|
|
3156
|
-
* The derivation is defense in depth
|
|
3157
|
-
*
|
|
3158
|
-
*
|
|
3159
|
-
*
|
|
3160
|
-
*
|
|
3729
|
+
* The derivation is defense in depth: `@memhtml/html` constraint 1 rejects an empty `<mark>`
|
|
3730
|
+
* outright, so a door that skipped this would be stopped by the store's render gate rather than
|
|
3731
|
+
* landing a file with an empty `files.gist`. What this module carries is the authoring convenience
|
|
3732
|
+
* the doors exist to provide: a JSONL line and an MCP call carry no `claim` field, so the door
|
|
3733
|
+
* derives one instead of asking an author to restate the body's first sentence.
|
|
3161
3734
|
*/
|
|
3162
3735
|
/**
|
|
3163
3736
|
* Split prose into paragraphs on blank lines, dropping the empties. Inside a fenced code block a
|
|
@@ -3260,11 +3833,20 @@ const LIST_FIELDS = {
|
|
|
3260
3833
|
entity: "entities",
|
|
3261
3834
|
entities: "entities"
|
|
3262
3835
|
};
|
|
3836
|
+
/**
|
|
3837
|
+
* Fields that must be a JSON boolean.
|
|
3838
|
+
*
|
|
3839
|
+
* Its own table because {@link SCALAR_FIELDS}' decode refuses anything that is not a string, and a
|
|
3840
|
+
* boolean spelled `"true"` is a different value from `true` on this wire. A caller that sent the
|
|
3841
|
+
* string would otherwise get a strict-path ask that reads as satisfied and behaves as absent.
|
|
3842
|
+
*/
|
|
3843
|
+
const BOOLEAN_FIELDS = { strict_path: "strictPath" };
|
|
3263
3844
|
/** `op` is the discriminator rather than a `WriteParams` field, so it is legal and never mapped. */
|
|
3264
3845
|
const KNOWN_FIELDS = /* @__PURE__ */ new Set([
|
|
3265
3846
|
"op",
|
|
3266
3847
|
...Object.keys(SCALAR_FIELDS),
|
|
3267
|
-
...Object.keys(LIST_FIELDS)
|
|
3848
|
+
...Object.keys(LIST_FIELDS),
|
|
3849
|
+
...Object.keys(BOOLEAN_FIELDS)
|
|
3268
3850
|
]);
|
|
3269
3851
|
/** A usage failure naming the offending line, 1-based as a text editor counts. */
|
|
3270
3852
|
const lineError = (code, line, reason, suggestions = []) => fail(code, `${APPLY_DOC}: line ${line}: ${reason}`, suggestions);
|
|
@@ -3350,6 +3932,12 @@ const opAt = (record, line) => {
|
|
|
3350
3932
|
if (isFailure(parsed)) return parsed;
|
|
3351
3933
|
params[target] = [...params[target] ?? [], ...parsed];
|
|
3352
3934
|
}
|
|
3935
|
+
for (const [field, target] of Object.entries(BOOLEAN_FIELDS)) {
|
|
3936
|
+
const value = record[field];
|
|
3937
|
+
if (value === void 0 || value === null) continue;
|
|
3938
|
+
if (typeof value !== "boolean") return lineError("ERR_INVALID_FLAG", line, `\`${field}\` must be a boolean, got ${typeof value}. JSON \`true\`, not the string "true"`);
|
|
3939
|
+
params[target] = value;
|
|
3940
|
+
}
|
|
3353
3941
|
/**
|
|
3354
3942
|
* `body` prose becomes claim + tail; `article_html` is used verbatim and leaves `claim` empty.
|
|
3355
3943
|
*
|
|
@@ -3358,10 +3946,10 @@ const opAt = (record, line) => {
|
|
|
3358
3946
|
* has no `claim` field, so a prose line's claim is derived rather than restated by its author (see
|
|
3359
3947
|
* {@link claimFromProse}, the one copy both doors share).
|
|
3360
3948
|
*
|
|
3361
|
-
* Skipping this
|
|
3362
|
-
* the render gate would stop the op instead of committing a file with an empty `files.gist`.
|
|
3363
|
-
* derivation is what makes a prose line valid in the first place
|
|
3364
|
-
*
|
|
3949
|
+
* Skipping this cannot land a bad file. `@memhtml/html` constraint 1 rejects an empty `<mark>`,
|
|
3950
|
+
* so the render gate would stop the op instead of committing a file with an empty `files.gist`.
|
|
3951
|
+
* The derivation is what makes a prose line valid in the first place; the render gate is the
|
|
3952
|
+
* guard between a missing claim and a silent write.
|
|
3365
3953
|
*/
|
|
3366
3954
|
const prose = typeof params.body === "string" ? params.body : void 0;
|
|
3367
3955
|
if (prose !== void 0 && prose.trim() !== "") {
|
|
@@ -3494,7 +4082,7 @@ const applyPayload = (result) => ({
|
|
|
3494
4082
|
* `memhtml doctor`: the corpus's own health check, and `--fix` for the two findings a repair can settle
|
|
3495
4083
|
* without a judgement call.
|
|
3496
4084
|
*
|
|
3497
|
-
*
|
|
4085
|
+
* Nine checks, and each one is a claim the design makes about the corpus rather than a lint:
|
|
3498
4086
|
*
|
|
3499
4087
|
* 1. **Dangling `<link>` hrefs**: an authored edge pointing at a path the tree does not hold. Design
|
|
3500
4088
|
* §2.3 has no foreign key on `edges` deliberately (a `<link>` may name a file the indexer has not
|
|
@@ -3518,8 +4106,12 @@ const applyPayload = (result) => ({
|
|
|
3518
4106
|
* is a task waiting on something that will never move.
|
|
3519
4107
|
* 8. **Task inbox depth**: a task in `areas/inbox/tasks/` is work with no project, and a task inbox
|
|
3520
4108
|
* is meant to be drained rather than accumulated.
|
|
4109
|
+
* 9. **Untyped entity references**: a `memhtml-entity` meta written as a bare name indexes under the
|
|
4110
|
+
* `unknown` type, which is supported, and is therefore unreachable by the typed reference the
|
|
4111
|
+
* `entity` scope requires. The query returns an empty set rather than an error, so a producer
|
|
4112
|
+
* emitting bare names makes its memories unfindable with nothing anywhere reporting it.
|
|
3521
4113
|
*
|
|
3522
|
-
* **`--fix` repairs exactly two of the
|
|
4114
|
+
* **`--fix` repairs exactly two of the nine, and the repair logic is imported from the sleep
|
|
3523
4115
|
* integrity phase rather than re-ported.** `archivedFormOf` decides whether a dangling target moved
|
|
3524
4116
|
* to the archive or is genuinely gone, and `applyHeadEdits`/`link`/`unlink`/`meta` are the byte-splice
|
|
3525
4117
|
* editors that change one head line without touching the article. A parse→serialize round trip drops
|
|
@@ -3527,11 +4119,12 @@ const applyPayload = (result) => ({
|
|
|
3527
4119
|
* every file it touched. A second implementation of either would be the consumer-side reimplementation
|
|
3528
4120
|
* of producer semantics the fleet has paid for repeatedly.
|
|
3529
4121
|
*
|
|
3530
|
-
* The other
|
|
4122
|
+
* The other seven report and do not repair. An inbox memory or task needs a human or an agent to decide
|
|
3531
4123
|
* where it belongs, a vocabulary warning needs the author's intent, and a stale index needs
|
|
3532
4124
|
* `memhtml index update`, which doctor names in its own suggestions rather than running behind the
|
|
3533
4125
|
* operator's back. An overdue task needs the work done or the deadline moved, and a stale blocker
|
|
3534
|
-
* needs someone to decide whether the blocked task is actually ready.
|
|
4126
|
+
* needs someone to decide whether the blocked task is actually ready. An untyped entity needs the
|
|
4127
|
+
* producer that wrote it to name a type, which is a vocabulary decision no repair can make.
|
|
3535
4128
|
*/
|
|
3536
4129
|
/** How deep the inbox may get before doctor calls it a finding. */
|
|
3537
4130
|
const INBOX_WARN_DEPTH = 20;
|
|
@@ -3543,6 +4136,14 @@ const INBOX_WARN_DEPTH = 20;
|
|
|
3543
4136
|
* is the state a to-do list rots in, and a task inbox is meant to be drained rather than accumulated.
|
|
3544
4137
|
*/
|
|
3545
4138
|
const INBOX_TASK_WARN_DEPTH = 10;
|
|
4139
|
+
/**
|
|
4140
|
+
* How many distinct untyped entity names a report lists before truncating.
|
|
4141
|
+
*
|
|
4142
|
+
* A bound rather than the whole set, because the count is a signal about a PRODUCER and twenty examples
|
|
4143
|
+
* name it as well as two thousand would. `untypedEntityTotal` carries the real cardinality beside the
|
|
4144
|
+
* sample, so a truncated list can never be mistaken for the whole one.
|
|
4145
|
+
*/
|
|
4146
|
+
const UNTYPED_ENTITY_SAMPLE = 20;
|
|
3546
4147
|
/** Every `state.access` path the index has no `files` row for. */
|
|
3547
4148
|
const orphanAccess = (db) => db.hasState ? db.all(`SELECT a.path AS path FROM ${STATE_SCHEMA}.access a
|
|
3548
4149
|
LEFT JOIN files f ON f.path = a.path
|
|
@@ -3569,8 +4170,8 @@ const inboxTaskDepth = (db) => db.get(`SELECT count(*) AS n FROM files
|
|
|
3569
4170
|
* "overdue" meaning the same thing in the two places an operator reads it.
|
|
3570
4171
|
*
|
|
3571
4172
|
* **`archived = 0` and `task_status <> 'done'` both change the result** (mutation-verified
|
|
3572
|
-
* 2026-08-02
|
|
3573
|
-
* forever and never reach zero.
|
|
4173
|
+
* 2026-08-02: dropping either predicate makes the count include finished tasks). A finished task's
|
|
4174
|
+
* deadline is history, and reporting it would make the finding grow forever and never reach zero.
|
|
3574
4175
|
*/
|
|
3575
4176
|
const overdueTasks = (db, today) => db.all(`SELECT path, task_status, due_at FROM files
|
|
3576
4177
|
WHERE memory_type = 'task' AND archived = 0 AND due_at IS NOT NULL
|
|
@@ -3618,6 +4219,35 @@ const staleBlockers = (db) => db.all(`SELECT t.path AS path, e.src_path AS block
|
|
|
3618
4219
|
blockerState: row.blocker_state === "missing" ? "missing" : "archived"
|
|
3619
4220
|
}))), Effect.orElseSucceed(() => []));
|
|
3620
4221
|
/**
|
|
4222
|
+
* Untyped entity references: `memhtml-entity` metas written as a bare name, which the projection files
|
|
4223
|
+
* under the `unknown` type rather than dropping (`@memhtml/index`'s `entityRowsFor`).
|
|
4224
|
+
*
|
|
4225
|
+
* Report-only, and excluded from `healthy` on the same reasoning as `overdueTasks`. `unknown` is a
|
|
4226
|
+
* SUPPORTED fallback — a hand-authored file's only entity is still a real handle — so a bare name is not
|
|
4227
|
+
* a defect in the corpus. What it is is invisible to the caller who would look for it: the `entity`
|
|
4228
|
+
* scope requires the type half, so a memory stored under `unknown:checkout-api` cannot be found by
|
|
4229
|
+
* `service:checkout-api`, and the query returns an empty set rather than an error. A PRODUCER that emits
|
|
4230
|
+
* bare names therefore makes every memory it writes unreachable by the reference an agent would guess,
|
|
4231
|
+
* and nothing in a green suite says so. This count is where that shows up.
|
|
4232
|
+
*
|
|
4233
|
+
* Truncated to {@link UNTYPED_ENTITY_SAMPLE} with the distinct total reported beside it, so the sample
|
|
4234
|
+
* can never be read as the whole set.
|
|
4235
|
+
*/
|
|
4236
|
+
const untypedEntities = (db) => db.all(`SELECT e.entity_name AS entity_name, count(*) AS files
|
|
4237
|
+
FROM file_entities e JOIN files f ON f.path = e.path
|
|
4238
|
+
WHERE e.entity_type = 'unknown' AND f.archived = 0
|
|
4239
|
+
GROUP BY e.entity_name
|
|
4240
|
+
ORDER BY files DESC, e.entity_name ASC`).pipe(Effect.map((rows) => ({
|
|
4241
|
+
sample: rows.slice(0, 20).map((row) => ({
|
|
4242
|
+
entityName: row.entity_name,
|
|
4243
|
+
files: row.files
|
|
4244
|
+
})),
|
|
4245
|
+
total: rows.length
|
|
4246
|
+
})), Effect.orElseSucceed(() => ({
|
|
4247
|
+
sample: [],
|
|
4248
|
+
total: 0
|
|
4249
|
+
})));
|
|
4250
|
+
/**
|
|
3621
4251
|
* Re-read every active file and collect its format warnings.
|
|
3622
4252
|
*
|
|
3623
4253
|
* Re-read rather than taken from the index, because a warning is not a stored column. The indexer
|
|
@@ -3671,6 +4301,7 @@ const repair = (root, findings, orphans) => Effect.gen(function* () {
|
|
|
3671
4301
|
let rewritten = 0;
|
|
3672
4302
|
let dropped = 0;
|
|
3673
4303
|
const touched = [];
|
|
4304
|
+
const failedWrites = [];
|
|
3674
4305
|
for (const finding of findings) {
|
|
3675
4306
|
if (!isEdgeRel(finding.rel)) continue;
|
|
3676
4307
|
const rel = finding.rel;
|
|
@@ -3685,10 +4316,13 @@ const repair = (root, findings, orphans) => Effect.gen(function* () {
|
|
|
3685
4316
|
const edited = applyHeadEdits(html, edits);
|
|
3686
4317
|
if (edited === html) continue;
|
|
3687
4318
|
if (finding.rewriteTo === null) yield* Effect.logWarning(`doctor dropped a dangling ${rel} from ${finding.srcPath}: target has no file`);
|
|
3688
|
-
yield* attemptIo(`doctor.write:${finding.srcPath}`, async () => {
|
|
4319
|
+
if (!(yield* attemptIo(`doctor.write:${finding.srcPath}`, async () => {
|
|
3689
4320
|
await mkdir(dirname(absolute), { recursive: true });
|
|
3690
4321
|
await writeFile(absolute, edited, "utf8");
|
|
3691
|
-
}).pipe(Effect.orElseSucceed(() =>
|
|
4322
|
+
}).pipe(Effect.as(true), Effect.orElseSucceed(() => false)))) {
|
|
4323
|
+
failedWrites.push(finding.srcPath);
|
|
4324
|
+
continue;
|
|
4325
|
+
}
|
|
3692
4326
|
touched.push(finding.srcPath);
|
|
3693
4327
|
if (finding.rewriteTo === null) dropped += 1;
|
|
3694
4328
|
else rewritten += 1;
|
|
@@ -3709,6 +4343,7 @@ const repair = (root, findings, orphans) => Effect.gen(function* () {
|
|
|
3709
4343
|
return {
|
|
3710
4344
|
rewritten,
|
|
3711
4345
|
dropped,
|
|
4346
|
+
failedWrites,
|
|
3712
4347
|
prunedAccessRows,
|
|
3713
4348
|
commitSha
|
|
3714
4349
|
};
|
|
@@ -3743,6 +4378,7 @@ const doctor = (options) => Effect.gen(function* () {
|
|
|
3743
4378
|
const taskDepth = yield* inboxTaskDepth(db);
|
|
3744
4379
|
const overdue = yield* overdueTasks(db, yield* todayDate);
|
|
3745
4380
|
const stale = yield* staleBlockers(db);
|
|
4381
|
+
const untyped = yield* untypedEntities(db);
|
|
3746
4382
|
const active = yield* db.all("SELECT path FROM files WHERE archived = 0 ORDER BY path ASC").pipe(Effect.orElseSucceed(() => []));
|
|
3747
4383
|
const { warnings, unparseable } = yield* collectWarnings(git.root, active.map((row) => row.path));
|
|
3748
4384
|
const repaired = options.fix ? yield* repair(git.root, dangling, orphanAccessRows) : void 0;
|
|
@@ -3764,6 +4400,8 @@ const doctor = (options) => Effect.gen(function* () {
|
|
|
3764
4400
|
inboxTasksCrowded: taskDepth > 10,
|
|
3765
4401
|
overdueTasks: overdue,
|
|
3766
4402
|
staleBlockers: stale,
|
|
4403
|
+
untypedEntities: untyped.sample,
|
|
4404
|
+
untypedEntityTotal: untyped.total,
|
|
3767
4405
|
warnings,
|
|
3768
4406
|
unparseable,
|
|
3769
4407
|
indexFresh,
|
|
@@ -3784,8 +4422,7 @@ const doctor = (options) => Effect.gen(function* () {
|
|
|
3784
4422
|
*
|
|
3785
4423
|
* ROADMAP item 7 is the requirement. A multi-hop traversal written as code answers in one execution
|
|
3786
4424
|
* what the tool path answers in one round trip per hop, and the closed vocabulary is what makes the
|
|
3787
|
-
* tree queryable without a new surface per question. Measured
|
|
3788
|
-
* re-probed here on 2026-08-09: a 305-file
|
|
4425
|
+
* tree queryable without a new surface per question. Measured here: a 305-file
|
|
3789
4426
|
* census in 598ms, and an edge walk resolving 410/410 edges into 201 chains, the longest 8 hops, in one
|
|
3790
4427
|
* execution at 430ms.
|
|
3791
4428
|
*
|
|
@@ -3818,7 +4455,7 @@ const DEFAULT_TIMEOUT_MS = 3e4;
|
|
|
3818
4455
|
*
|
|
3819
4456
|
* `maxJsTimeoutMs` is the only thing standing between a runaway guest loop and a `memhtml exec` that
|
|
3820
4457
|
* never returns. The guest is a QuickJS worker with no host-side reaper of its own, and an unbounded
|
|
3821
|
-
* script would hold the CLI process open indefinitely.
|
|
4458
|
+
* script would hold the CLI process open indefinitely. Measured with `maxJsTimeoutMs: 700`
|
|
3822
4459
|
* against `for(;;){n++}`: exit 124 at 724ms, "js-exec: Execution timeout: exceeded 700ms limit".
|
|
3823
4460
|
*/
|
|
3824
4461
|
const MAX_TIMEOUT_MS = 6e5;
|
|
@@ -3835,7 +4472,7 @@ const SHELL_TIMEOUT_GRACE_MS = 2e3;
|
|
|
3835
4472
|
*
|
|
3836
4473
|
* QuickJS ships no base64 builtins and `node-html-parser` decodes a base64 entity table at load time,
|
|
3837
4474
|
* so without this the parser throws "'atob' is not defined" at import and every script fails before
|
|
3838
|
-
* its first selector.
|
|
4475
|
+
* its first selector. Of the three possible placements, `bootstrap` works, prepending the shim
|
|
3839
4476
|
* to the parser's own bytes works, and omitting it fails at `decodeBase64`. `bootstrap` is chosen
|
|
3840
4477
|
* because it leaves the vendored parser byte-identical to the published artifact. A shim spliced into
|
|
3841
4478
|
* the bundle would make the seeded file something no `pnpm` install reproduces.
|
|
@@ -3904,14 +4541,14 @@ const parserSourcePath = () => createRequire(import.meta.url).resolve("node-html
|
|
|
3904
4541
|
* through the command and therefore a claim rather than a guard. As a function it is testable against
|
|
3905
4542
|
* both strings just-bash actually produces.
|
|
3906
4543
|
*
|
|
3907
|
-
* Both wordings, measured
|
|
4544
|
+
* Both wordings, measured on `for(;;)` at a 400ms bound:
|
|
3908
4545
|
*
|
|
3909
4546
|
* - `maxJsTimeoutMs` fires: `js-exec: Execution timeout: exceeded 400ms limit`
|
|
3910
4547
|
* - `maxExecutionTimeMs` fires: `bash: js-exec exceeded its execution deadline`, with no "timeout" in it
|
|
3911
4548
|
*
|
|
3912
4549
|
* A pattern matching only `/timeout/` therefore reports `timedOut: false` on a script that was cut off,
|
|
3913
4550
|
* which is what the first version of this did. `aborted` covers `bash: execution aborted`, which is what
|
|
3914
|
-
* an `AbortSignal` produces
|
|
4551
|
+
* an `AbortSignal` produces. This module takes no such path today, and classifying it correctly
|
|
3915
4552
|
* now is cheap if it ever does.
|
|
3916
4553
|
*
|
|
3917
4554
|
* The exit code is required as well as the wording. 124 alone is reachable from a script that exits 124
|
|
@@ -3933,8 +4570,8 @@ const cutOffByTheRuntime = (exitCode, stderr) => exitCode === 124 && /timeout|de
|
|
|
3933
4570
|
*
|
|
3934
4571
|
* Neither is a fact about the corpus or the script. Both reach `stderr` as a thrown guest error, which
|
|
3935
4572
|
* without this check is reported as the SCRIPT's non-zero exit — telling an agent its selector is wrong
|
|
3936
|
-
* when the sandbox merely failed to hand back a `stat`. Observed once on a 4-vCPU CI runner
|
|
3937
|
-
*
|
|
4573
|
+
* when the sandbox merely failed to hand back a `stat`. Observed once on a 4-vCPU CI runner
|
|
4574
|
+
* on a walk of ~900 entries: `at isDirectory
|
|
3938
4575
|
* (/workspace/lib/corpus.mjs:45:28): Error code: 0`, on a commit whose tree was byte-identical to one
|
|
3939
4576
|
* that had passed minutes earlier. The bridge kept working afterwards — the guest's own `stderr` write
|
|
3940
4577
|
* and exit both landed — so the fault is one operation, not a torn-down sandbox, which is what makes
|
|
@@ -3963,8 +4600,8 @@ const BRIDGE_ATTEMPTS = 3;
|
|
|
3963
4600
|
* Run one attempt at a time until a report is the script's own answer, or fail as the runtime.
|
|
3964
4601
|
*
|
|
3965
4602
|
* Exported and parameterized by `attempt` because that is the only shape this loop can be tested in: a
|
|
3966
|
-
* bridge fault is a rare race — 72 executions under 3x CPU oversubscription did not produce one
|
|
3967
|
-
*
|
|
4603
|
+
* bridge fault is a rare race — 72 executions under 3x CPU oversubscription did not produce one —
|
|
4604
|
+
* so a test driving the real sandbox could not distinguish a working retry from
|
|
3968
4605
|
* a fault that never fired. The injected attempt makes the loop's three claims falsifiable: a faulting
|
|
3969
4606
|
* attempt is re-run, a script's own failure is NOT, and exhaustion is a typed failure.
|
|
3970
4607
|
*
|
|
@@ -4004,7 +4641,7 @@ const withBridgeRetry = (attempt, attempts = 3) => Effect.gen(function* () {
|
|
|
4004
4641
|
*
|
|
4005
4642
|
* `new Bash()` is constructed with no `network` and no `fetch` option, so just-bash never registers its
|
|
4006
4643
|
* network commands at all. Per `Bash.d.ts:80`: "Network commands (curl, wget) are registered when either
|
|
4007
|
-
* `fetch` or `network` is provided."
|
|
4644
|
+
* `fetch` or `network` is provided." `scripts/probe-sandbox-egress.mjs` demonstrates it: `curl` is
|
|
4008
4645
|
* exit 127 "command not found", and the guest's `fetch` refuses on call with "Network access not
|
|
4009
4646
|
* configured." `fetch` is a function there, so a `typeof` check on the global proves nothing. Eve
|
|
4010
4647
|
* passes `dangerouslyAllowFullInternetAccess`, so the consolidator's sandbox does reach the network.
|
|
@@ -4035,7 +4672,7 @@ const runExec = (input) => Effect.gen(function* () {
|
|
|
4035
4672
|
catch: (cause) => StorageFailure.make({ operation: `exec.sandbox-load: ${String(cause)}` })
|
|
4036
4673
|
});
|
|
4037
4674
|
const { mountReadOnlyRoots } = yield* Effect.tryPromise({
|
|
4038
|
-
try: () => import("./dist-
|
|
4675
|
+
try: () => import("./dist-CBhYV3up.mjs"),
|
|
4039
4676
|
catch: (cause) => StorageFailure.make({ operation: `exec.mount-load: ${String(cause)}` })
|
|
4040
4677
|
});
|
|
4041
4678
|
const helperSource = yield* Effect.tryPromise({
|
|
@@ -4075,7 +4712,7 @@ const runExec = (input) => Effect.gen(function* () {
|
|
|
4075
4712
|
*
|
|
4076
4713
|
* `maxJsTimeoutMs` bounds the `js-exec` call and `maxExecutionTimeMs` bounds the whole shell
|
|
4077
4714
|
* invocation, so both are needed. A script cannot outlive its budget by spending the time
|
|
4078
|
-
* outside the JS worker. Which one fires first changes the diagnostic,
|
|
4715
|
+
* outside the JS worker. Which one fires first changes the diagnostic, measured on a
|
|
4079
4716
|
* `for(;;)` loop at a 400ms bound:
|
|
4080
4717
|
*
|
|
4081
4718
|
* | limits | exit | stderr |
|
|
@@ -4146,8 +4783,8 @@ const readScript = async (file) => {
|
|
|
4146
4783
|
*
|
|
4147
4784
|
* ## Why a pinned worktree and not `$MEMHTML_ROOT` itself
|
|
4148
4785
|
*
|
|
4149
|
-
* A live `$MEMHTML_ROOT` contains `.memhtml/index.db`, and the guest ships `sqlite3`.
|
|
4150
|
-
*
|
|
4786
|
+
* A live `$MEMHTML_ROOT` contains `.memhtml/index.db`, and the guest ships `sqlite3`. Against a
|
|
4787
|
+
* read-only `OverlayFs` over a directory holding a real database: `sqlite3
|
|
4151
4788
|
* /mnt/memhtml/.memhtml/index.db 'select count(*) …'` returned the row, exit 0. Read-only is therefore no
|
|
4152
4789
|
* barrier to a reader, and mounting the live root would hand every script the ranked planes this command
|
|
4153
4790
|
* is scoped to exclude, through a door no `memhtml exec` flag opens.
|
|
@@ -4180,7 +4817,7 @@ const execCommand = (input) => Effect.gen(function* () {
|
|
|
4180
4817
|
* would not cover.
|
|
4181
4818
|
*/
|
|
4182
4819
|
const { pinCorpusSnapshot } = yield* Effect.tryPromise({
|
|
4183
|
-
try: () => import("./dist-
|
|
4820
|
+
try: () => import("./dist-CBhYV3up.mjs"),
|
|
4184
4821
|
catch: (cause) => StorageFailure.make({ operation: `exec.mount-load: ${String(cause)}` })
|
|
4185
4822
|
});
|
|
4186
4823
|
const snapshot = yield* Effect.acquireRelease(Effect.tryPromise({
|
|
@@ -4382,7 +5019,7 @@ const indexReport = () => Effect.gen(function* () {
|
|
|
4382
5019
|
});
|
|
4383
5020
|
const count = (db, sql) => db.get(sql).pipe(Effect.map((row) => row?.n ?? 0), Effect.orElseSucceed(() => 0));
|
|
4384
5021
|
/**
|
|
4385
|
-
* A `--phases` value as a validated phase list, or `undefined` for
|
|
5022
|
+
* A `--phases` value as a validated phase list, or `undefined` for every phase in `SLEEP_PHASES`.
|
|
4386
5023
|
*
|
|
4387
5024
|
* An unknown phase is rejected instead of dropped silently. A run asked for `--phases dedup,compress`
|
|
4388
5025
|
* with a typo in the first name would otherwise execute only the second. `dedup-merge` is a hard
|
|
@@ -4421,6 +5058,41 @@ const sleepRunReport = (report) => ({
|
|
|
4421
5058
|
//#region apps/cli/src/run.ts
|
|
4422
5059
|
const KNOWN_FLAGS = /* @__PURE__ */ new Set([...GLOBAL_FLAGS.map((flag) => flag.name), ...COMMANDS.flatMap((command) => command.flags.map((flag) => flag.name))]);
|
|
4423
5060
|
/**
|
|
5061
|
+
* Every flag name the spec table declares, mapped to the type it declares.
|
|
5062
|
+
*
|
|
5063
|
+
* **Only a `string` or `int` flag consumes the next argv token as its value**, and the two kinds it
|
|
5064
|
+
* excludes are excluded for different reasons:
|
|
5065
|
+
*
|
|
5066
|
+
* - A `boolean` flag takes `--flag`, `--flag=value`, or `--no-flag`, so the token after it stays
|
|
5067
|
+
* positional and can be the command: `memhtml --dense list` is the `list` command, not an empty
|
|
5068
|
+
* command carrying `dense: "list"`.
|
|
5069
|
+
* - A flag the table does not declare has no type to consult, and eating the token would swallow the
|
|
5070
|
+
* command name — `memhtml --nope list` would answer the manifest at exit 0 instead of refusing an
|
|
5071
|
+
* unknown flag. Leaving the token positional lets the command reach {@link validate}, which is
|
|
5072
|
+
* where an unknown flag becomes exit 2.
|
|
5073
|
+
*
|
|
5074
|
+
* The map is name-keyed across every command even though validation is per-command, because the
|
|
5075
|
+
* parser runs before the command is known. That is sound only while one name carries ONE type
|
|
5076
|
+
* everywhere, which is a property of the table `cli.test.ts` enforces rather than a hope.
|
|
5077
|
+
*/
|
|
5078
|
+
const FLAG_TYPES = new Map([...GLOBAL_FLAGS, ...COMMANDS.flatMap((command) => command.flags)].map((flag) => [flag.name, flag.type]));
|
|
5079
|
+
/**
|
|
5080
|
+
* The tokens {@link bool} would have read as a boolean value.
|
|
5081
|
+
*
|
|
5082
|
+
* A boolean flag followed by one of these is a caller spelling `--flag <value>`, which parses as the
|
|
5083
|
+
* opposite value. Any other token after a boolean flag is a positional the caller meant — the
|
|
5084
|
+
* command name, a path, a query — so the set is exactly the vocabulary `bool` interprets and not
|
|
5085
|
+
* "any following token".
|
|
5086
|
+
*/
|
|
5087
|
+
const BOOLEAN_VALUE_TOKENS = /* @__PURE__ */ new Set([
|
|
5088
|
+
"true",
|
|
5089
|
+
"false",
|
|
5090
|
+
"yes",
|
|
5091
|
+
"no",
|
|
5092
|
+
"0",
|
|
5093
|
+
"1"
|
|
5094
|
+
]);
|
|
5095
|
+
/**
|
|
4424
5096
|
* The two-word command names, longest first.
|
|
4425
5097
|
*
|
|
4426
5098
|
* A subcommand is matched greedily so `index status` beats `index`, and the leftover tokens become
|
|
@@ -4430,16 +5102,22 @@ const KNOWN_FLAGS = /* @__PURE__ */ new Set([...GLOBAL_FLAGS.map((flag) => flag.
|
|
|
4430
5102
|
*/
|
|
4431
5103
|
const COMPOUND_NAMES = COMMAND_NAMES.filter((name) => name.includes(" ")).sort((left, right) => right.length - left.length);
|
|
4432
5104
|
/**
|
|
4433
|
-
* `--flag value`, `--flag=value`, `--no-flag`, and bare `--flag
|
|
5105
|
+
* `--flag value`, `--flag=value`, `--no-flag`, and bare `--flag`, parsed against the spec table.
|
|
4434
5106
|
*
|
|
4435
5107
|
* Every flag's value is an array, because several flags are repeatable (`--tag`, `--entity`,
|
|
4436
5108
|
* `--body`) and a map of scalars would silently keep only the last occurrence, so a write with three
|
|
4437
5109
|
* entities would store one. Non-repeatable flags read `.at(-1)`, so a duplicate is last-wins rather
|
|
4438
5110
|
* than an error, which is what a shell user retyping a flag expects.
|
|
5111
|
+
*
|
|
5112
|
+
* Only a flag the table types `string` or `int` consumes the next token as its value; a boolean flag
|
|
5113
|
+
* and an undeclared flag both leave it positional, for the two reasons {@link FLAG_TYPES} states.
|
|
5114
|
+
* A boolean flag followed by a value-shaped token is recorded as a stray so {@link validate} can
|
|
5115
|
+
* refuse it rather than silently inverting the caller's ask.
|
|
4439
5116
|
*/
|
|
4440
5117
|
const parseArgv = (argv) => {
|
|
4441
5118
|
const positional = [];
|
|
4442
5119
|
const flags = /* @__PURE__ */ new Map();
|
|
5120
|
+
const strayBooleanValues = [];
|
|
4443
5121
|
const push = (name, value) => {
|
|
4444
5122
|
const existing = flags.get(name);
|
|
4445
5123
|
if (existing === void 0) flags.set(name, [value]);
|
|
@@ -4462,11 +5140,13 @@ const parseArgv = (argv) => {
|
|
|
4462
5140
|
continue;
|
|
4463
5141
|
}
|
|
4464
5142
|
const next = argv[index + 1];
|
|
4465
|
-
|
|
5143
|
+
const type = FLAG_TYPES.get(body);
|
|
5144
|
+
if ((type === "string" || type === "int") && next !== void 0 && !next.startsWith("--")) {
|
|
4466
5145
|
push(body, next);
|
|
4467
5146
|
index += 2;
|
|
4468
5147
|
continue;
|
|
4469
5148
|
}
|
|
5149
|
+
if (type === "boolean" && next !== void 0 && BOOLEAN_VALUE_TOKENS.has(next.toLowerCase())) strayBooleanValues.push([body, next]);
|
|
4470
5150
|
push(body, true);
|
|
4471
5151
|
index += 1;
|
|
4472
5152
|
continue;
|
|
@@ -4481,13 +5161,15 @@ const parseArgv = (argv) => {
|
|
|
4481
5161
|
return {
|
|
4482
5162
|
command: compound,
|
|
4483
5163
|
positional: positional.slice(consumed),
|
|
4484
|
-
flags
|
|
5164
|
+
flags,
|
|
5165
|
+
strayBooleanValues
|
|
4485
5166
|
};
|
|
4486
5167
|
}
|
|
4487
5168
|
return {
|
|
4488
5169
|
command: positional[0] ?? "",
|
|
4489
5170
|
positional: positional.slice(1),
|
|
4490
|
-
flags
|
|
5171
|
+
flags,
|
|
5172
|
+
strayBooleanValues
|
|
4491
5173
|
};
|
|
4492
5174
|
};
|
|
4493
5175
|
/** A flag's last value as a string, or `undefined` when it was not given. */
|
|
@@ -4524,6 +5206,7 @@ const scopeOf = (parsed) => ({
|
|
|
4524
5206
|
workspace: str(parsed, "workspace"),
|
|
4525
5207
|
tags: list(parsed, "tag"),
|
|
4526
5208
|
entity: str(parsed, "entity"),
|
|
5209
|
+
facets: parseFacetFilters(list(parsed, "facet")),
|
|
4527
5210
|
includeArchived: bool(parsed, "include-archived", false),
|
|
4528
5211
|
asOf: str(parsed, "as-of")
|
|
4529
5212
|
});
|
|
@@ -4534,6 +5217,25 @@ const provenanceOf = (parsed) => ({
|
|
|
4534
5217
|
turnUuid: str(parsed, "turn-uuid")
|
|
4535
5218
|
});
|
|
4536
5219
|
/**
|
|
5220
|
+
* Exit 1 when a sleep run has a failed phase.
|
|
5221
|
+
*
|
|
5222
|
+
* **A partially-failed run and a fully-aborted run exit the same**, and that is a decision rather
|
|
5223
|
+
* than an omission. A caller reading the exit code is asking one question — did the curation this
|
|
5224
|
+
* invocation was for happen — and both answers are no. The difference between them is already stated
|
|
5225
|
+
* in the payload, precisely: an abort is every selected phase `failed` with `headSha === baseSha` and
|
|
5226
|
+
* no commits, while a partial run names the phases that landed. A second exit code would be a
|
|
5227
|
+
* second, weaker copy of that, and a caller would have to learn it to recover a fact the envelope
|
|
5228
|
+
* already carries.
|
|
5229
|
+
*
|
|
5230
|
+
* Exit 1 rather than 2: the call was well-formed, so this is a runtime failure an operator fixes by
|
|
5231
|
+
* changing the repo or the environment ({@link EXIT_USAGE} is reserved for fixing the call).
|
|
5232
|
+
*
|
|
5233
|
+
* `sleep status` and `sleep review` are deliberately not routed through here. They REPORT a run they
|
|
5234
|
+
* did not perform, and a read that exited non-zero because the thing it describes failed would make
|
|
5235
|
+
* "tell me what happened" indistinguishable from "I could not tell you".
|
|
5236
|
+
*/
|
|
5237
|
+
const sleepExit = (report) => report.failedPhases.length > 0 ? 1 : 0;
|
|
5238
|
+
/**
|
|
4537
5239
|
* Dispatch one parsed invocation against the provided services.
|
|
4538
5240
|
*
|
|
4539
5241
|
* Every arm is decode → call → name the response type. No arm builds an envelope, catches an error,
|
|
@@ -4560,6 +5262,7 @@ const dispatch = (parsed, applyOps = []) => {
|
|
|
4560
5262
|
articleHtml: str(parsed, "article-html"),
|
|
4561
5263
|
memoryType: str(parsed, "type") ?? "",
|
|
4562
5264
|
path: str(parsed, "path"),
|
|
5265
|
+
strictPath: bool(parsed, "strict-path", false),
|
|
4563
5266
|
workspace: str(parsed, "workspace"),
|
|
4564
5267
|
tags: list(parsed, "tag"),
|
|
4565
5268
|
entities: list(parsed, "entity"),
|
|
@@ -4633,9 +5336,13 @@ const dispatch = (parsed, applyOps = []) => {
|
|
|
4633
5336
|
return ["memory.neighbors", yield* neighborsOf({
|
|
4634
5337
|
path: parsed.positional[0] ?? "",
|
|
4635
5338
|
depth: int(parsed, "depth"),
|
|
5339
|
+
limit: int(parsed, "limit"),
|
|
4636
5340
|
rels: list(parsed, "rel")
|
|
4637
5341
|
})];
|
|
4638
5342
|
});
|
|
5343
|
+
case "resolve": return Effect.gen(function* () {
|
|
5344
|
+
return ["memory.resolved", yield* resolveMemory(parsed.positional[0] ?? "")];
|
|
5345
|
+
});
|
|
4639
5346
|
case "archive": return Effect.gen(function* () {
|
|
4640
5347
|
return ["memory.archived", yield* archiveMemory(parsed.positional[0] ?? "", str(parsed, "reason") ?? "")];
|
|
4641
5348
|
});
|
|
@@ -4648,12 +5355,20 @@ const dispatch = (parsed, applyOps = []) => {
|
|
|
4648
5355
|
workspace: str(parsed, "workspace"),
|
|
4649
5356
|
tag: str(parsed, "tag"),
|
|
4650
5357
|
entity: str(parsed, "entity"),
|
|
5358
|
+
facets: parseFacetFilters(list(parsed, "facet")),
|
|
4651
5359
|
para: str(parsed, "para"),
|
|
4652
5360
|
limit: int(parsed, "limit"),
|
|
4653
5361
|
cursor: str(parsed, "cursor"),
|
|
4654
5362
|
includeArchived: bool(parsed, "include-archived", false)
|
|
4655
5363
|
})];
|
|
4656
5364
|
});
|
|
5365
|
+
case "entity activity": return Effect.gen(function* () {
|
|
5366
|
+
return ["entity.activity", yield* entityActivity({
|
|
5367
|
+
entityType: str(parsed, "type"),
|
|
5368
|
+
limit: int(parsed, "limit"),
|
|
5369
|
+
includeArchived: bool(parsed, "include-archived", false)
|
|
5370
|
+
})];
|
|
5371
|
+
});
|
|
4657
5372
|
case "task add": return Effect.gen(function* () {
|
|
4658
5373
|
const title = str(parsed, "title") ?? "";
|
|
4659
5374
|
const result = yield* writeMemory({
|
|
@@ -4735,16 +5450,29 @@ const dispatch = (parsed, applyOps = []) => {
|
|
|
4735
5450
|
case "sleep run": return Effect.gen(function* () {
|
|
4736
5451
|
const sleep = yield* Sleep;
|
|
4737
5452
|
const phases = yield* sleepPhases(str(parsed, "phases"));
|
|
5453
|
+
const maxLlmCalls = int(parsed, "max-llm-calls");
|
|
4738
5454
|
const report = yield* sleep.run({
|
|
4739
5455
|
date: str(parsed, "date") ?? (yield* today),
|
|
4740
5456
|
...phases === void 0 ? {} : { phases },
|
|
4741
|
-
dryRun: bool(parsed, "dry-run", false)
|
|
5457
|
+
dryRun: bool(parsed, "dry-run", false),
|
|
5458
|
+
deep: bool(parsed, "deep", false),
|
|
5459
|
+
...maxLlmCalls === void 0 ? {} : { maxLlmCalls }
|
|
4742
5460
|
});
|
|
4743
|
-
|
|
5461
|
+
const payload = sleepRunReport(report);
|
|
5462
|
+
return [
|
|
5463
|
+
"sleep.report",
|
|
5464
|
+
payload,
|
|
5465
|
+
sleepExit(payload)
|
|
5466
|
+
];
|
|
4744
5467
|
});
|
|
4745
5468
|
case "sleep resume": return Effect.gen(function* () {
|
|
4746
5469
|
const report = yield* (yield* Sleep).resume(parsed.positional[0] ?? "");
|
|
4747
|
-
|
|
5470
|
+
const payload = sleepRunReport(report);
|
|
5471
|
+
return [
|
|
5472
|
+
"sleep.report",
|
|
5473
|
+
payload,
|
|
5474
|
+
sleepExit(payload)
|
|
5475
|
+
];
|
|
4748
5476
|
});
|
|
4749
5477
|
case "sleep review": return Effect.gen(function* () {
|
|
4750
5478
|
const report = yield* (yield* Sleep).review(parsed.positional[0]);
|
|
@@ -4773,6 +5501,17 @@ const dispatch = (parsed, applyOps = []) => {
|
|
|
4773
5501
|
case "state import": return Effect.gen(function* () {
|
|
4774
5502
|
return ["state.import", yield* stateImport()];
|
|
4775
5503
|
});
|
|
5504
|
+
case "sleep plan": return Effect.gen(function* () {
|
|
5505
|
+
const sleep = yield* Sleep;
|
|
5506
|
+
/**
|
|
5507
|
+
* The instant is read HERE and passed in, which keeps the one clock reading anywhere near sleep
|
|
5508
|
+
* on the caller's side. The settled-transcript cutoff is derived from it, and a plan that read a
|
|
5509
|
+
* clock inside the package would be the first thing in sleep that consults one to decide
|
|
5510
|
+
* something.
|
|
5511
|
+
*/
|
|
5512
|
+
const millis = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
5513
|
+
return ["sleep.plan", yield* sleep.plan(millis)];
|
|
5514
|
+
});
|
|
4776
5515
|
case "sleep status": return Effect.gen(function* () {
|
|
4777
5516
|
const report = yield* (yield* Sleep).review();
|
|
4778
5517
|
return ["sleep.report", {
|
|
@@ -4827,10 +5566,13 @@ const EITHER_CLAIM_OR_ARTICLE = /* @__PURE__ */ new Set(["write", "correct"]);
|
|
|
4827
5566
|
*
|
|
4828
5567
|
* Here for the reason `claimOrArticle` is: `validate`'s return becomes exit 2 and a failure raised in
|
|
4829
5568
|
* `dispatch` becomes exit 1, so "you passed the wrong flags" must be decided before any service is
|
|
4830
|
-
* built.
|
|
5569
|
+
* built. Mutually exclusive parameters are refused at this edge, before dispatch, because a refusal
|
|
5570
|
+
* raised any later is masked as a runtime error
|
|
5571
|
+
* (`.erpaval/solutions/api-patterns/xor-params-and-mcp-error-masking.md`).
|
|
4831
5572
|
*
|
|
4832
5573
|
* At most one rather than exactly one, because zero doors is legal and means stdin, the same shape
|
|
4833
|
-
* `memhtml apply` has, where a bare invocation drains the pipe.
|
|
5574
|
+
* `memhtml apply` has, where a bare invocation drains the pipe. `--file -` is the flag spelling of
|
|
5575
|
+
* stdin and counts as no door at all. A missing script is not a usage error
|
|
4834
5576
|
* here. An empty one is, and that check sits beside the read in {@link run} because reading is async.
|
|
4835
5577
|
*
|
|
4836
5578
|
* `--timeout-ms` is checked for a positive integer within the cap. Zero and negatives are refused
|
|
@@ -4839,19 +5581,33 @@ const EITHER_CLAIM_OR_ARTICLE = /* @__PURE__ */ new Set(["write", "correct"]);
|
|
|
4839
5581
|
*/
|
|
4840
5582
|
const execFlags = (parsed) => {
|
|
4841
5583
|
if (parsed.command !== "exec") return void 0;
|
|
4842
|
-
const
|
|
5584
|
+
const file = str(parsed, "file");
|
|
5585
|
+
const doors = [file === void 0 || file === "-" ? void 0 : "--file", str(parsed, "script") === void 0 ? void 0 : "--script"].filter((door) => door !== void 0);
|
|
4843
5586
|
if (doors.length > 1) return fail("ERR_INVALID_FLAG", "exec takes at most one of --file or --script, not both: two scripts cannot both be the one that runs", [
|
|
4844
5587
|
"memhtml exec --file traverse.mjs",
|
|
4845
5588
|
"memhtml exec --script 'console.log(1)'",
|
|
4846
5589
|
"cat s.mjs | memhtml exec"
|
|
4847
5590
|
]);
|
|
4848
|
-
if (doors.length === 1 && parsed.positional[0] === "-") return fail("ERR_INVALID_FLAG", `exec cannot read stdin and ${doors[0]} in the same call: \`-\` names stdin as the script source`, ["cat s.mjs | memhtml exec", `memhtml exec ${doors[0]} …`]);
|
|
5591
|
+
if (doors.length === 1 && (parsed.positional[0] === "-" || file === "-")) return fail("ERR_INVALID_FLAG", `exec cannot read stdin and ${doors[0]} in the same call: \`-\` names stdin as the script source`, ["cat s.mjs | memhtml exec", `memhtml exec ${doors[0]} …`]);
|
|
4849
5592
|
if (str(parsed, "timeout-ms") !== void 0) {
|
|
4850
5593
|
const timeout = int(parsed, "timeout-ms");
|
|
4851
5594
|
if (timeout === void 0 || timeout <= 0 || timeout > 6e5) return fail("ERR_INVALID_FLAG", `--timeout-ms must be a positive integer of at most ${MAX_TIMEOUT_MS}: a non-positive bound is no bound at all, which is the one thing a sandbox may not be`, [`memhtml exec --timeout-ms ${DEFAULT_TIMEOUT_MS}`]);
|
|
4852
5595
|
}
|
|
4853
5596
|
};
|
|
4854
5597
|
/**
|
|
5598
|
+
* `memhtml apply` takes at most one op-stream source.
|
|
5599
|
+
*
|
|
5600
|
+
* The same rule `execFlags` holds for a script, on the same two spellings: `-` (positional or as
|
|
5601
|
+
* `--file -`) names stdin, and stdin beside a real `--file` is two streams claiming to be the one
|
|
5602
|
+
* that applies. Refused here so the answer is exit 2, matching exec, rather than one of the
|
|
5603
|
+
* sources being silently ignored.
|
|
5604
|
+
*/
|
|
5605
|
+
const applyFlags = (parsed) => {
|
|
5606
|
+
if (parsed.command !== "apply") return void 0;
|
|
5607
|
+
const file = str(parsed, "file");
|
|
5608
|
+
if (file !== void 0 && file !== "-" && parsed.positional[0] === "-") return fail("ERR_INVALID_FLAG", "apply cannot read stdin and --file in the same call: `-` names stdin as the op stream", ["cat ops.jsonl | memhtml apply", "memhtml apply --file ops.jsonl"]);
|
|
5609
|
+
};
|
|
5610
|
+
/**
|
|
4855
5611
|
* Exactly one of `--claim` / `--article-html`.
|
|
4856
5612
|
*
|
|
4857
5613
|
* Checked here rather than in the dispatch arm, because the exit code is the contract. `validate`'s
|
|
@@ -4871,6 +5627,76 @@ const claimOrArticle = (parsed) => {
|
|
|
4871
5627
|
if (!hasClaim && !hasArticle) return fail("ERR_MISSING_ARGUMENT", `${parsed.command} requires exactly one of --claim or --article-html`, [`memhtml ${parsed.command} --claim <sentence>`, `memhtml ${parsed.command} --article-html '<p>…</p>'`]);
|
|
4872
5628
|
};
|
|
4873
5629
|
/**
|
|
5630
|
+
* `--as-of` must be a value the point-in-time comparison can order.
|
|
5631
|
+
*
|
|
5632
|
+
* The flag binds twice into `coalesce(valid_from, event_at, created_at) <= ? AND (valid_until IS
|
|
5633
|
+
* NULL OR valid_until > ?)` (`packages/index/src/scope.ts`), where SQLite compares TEXT to TEXT.
|
|
5634
|
+
* Nothing there parses the value, so an unsortable one does not error — it silently answers a
|
|
5635
|
+
* DIFFERENT question. `--as-of "2026-08-24 13:00"` sorts after every `T`-form instant on that day
|
|
5636
|
+
* and before none of them, so the window it selects is not the window the caller asked for, and the
|
|
5637
|
+
* result set looks like a plausible point-in-time view. A usage error is the only visible answer.
|
|
5638
|
+
*
|
|
5639
|
+
* The same {@link isValidDatetime} the format enforces on `<time datetime>` and on every datetime
|
|
5640
|
+
* meta, so the values a caller may ASK ABOUT are exactly the values a file may STATE. Two grammars
|
|
5641
|
+
* here would let a caller name an instant no memory can carry.
|
|
5642
|
+
*
|
|
5643
|
+
* `ERR_INVALID_FLAG`, this function's existing code for a flag present but unusable as given, and
|
|
5644
|
+
* exit 2 rather than a runtime error, because `validate`'s return is the usage path. A bare
|
|
5645
|
+
* `--as-of` with no value is refused for the same reason a bad one is: it reads as a scoped query
|
|
5646
|
+
* and would return an unscoped answer.
|
|
5647
|
+
*/
|
|
5648
|
+
const asOfFlag = (parsed) => {
|
|
5649
|
+
if (parsed.flags.get("as-of") === void 0) return void 0;
|
|
5650
|
+
const value = str(parsed, "as-of");
|
|
5651
|
+
if (value !== void 0 && isValidDatetime(value)) return void 0;
|
|
5652
|
+
return fail("ERR_INVALID_FLAG", `--as-of must be an ISO date or datetime (YYYY-MM-DD or YYYY-MM-DDThh:mm:ssZ)${value === void 0 ? "" : `, not "${value}"`}: the point-in-time window compares it as a string, so a value outside that grammar selects a different window rather than failing`, [`memhtml ${parsed.command} --as-of 2026-08-24`, `memhtml ${parsed.command} --as-of 2026-08-24T13:00:00Z`]);
|
|
5653
|
+
};
|
|
5654
|
+
/**
|
|
5655
|
+
* A boolean flag spelled with a space-separated value.
|
|
5656
|
+
*
|
|
5657
|
+
* `--embed false` parses as `embed: true` plus a positional `"false"`, so a caller asking to SKIP
|
|
5658
|
+
* embedding would get embedding on and a stray token nothing reads. That is a silent wrong answer,
|
|
5659
|
+
* which is the one outcome this surface may not produce, so the pair is exit 2 and the message names
|
|
5660
|
+
* both spellings that work.
|
|
5661
|
+
*
|
|
5662
|
+
* `ERR_INVALID_FLAG`, the code for a flag present but unusable as given, and the same code the
|
|
5663
|
+
* closed-vocabulary and `--as-of` checks return.
|
|
5664
|
+
*/
|
|
5665
|
+
const strayBooleanFlags = (parsed) => {
|
|
5666
|
+
const stray = parsed.strayBooleanValues[0];
|
|
5667
|
+
if (stray === void 0) return void 0;
|
|
5668
|
+
const [name, token] = stray;
|
|
5669
|
+
return fail("ERR_INVALID_FLAG", `--${name} is a boolean flag and takes no separate value, so \`--${name} ${token}\` reads as --${name} with a stray "${token}" argument`, [`memhtml ${parsed.command} --${name}=${token}`, `memhtml ${parsed.command} --no-${name}`]);
|
|
5670
|
+
};
|
|
5671
|
+
/**
|
|
5672
|
+
* The commands where a bare `-` positional names stdin rather than an argument.
|
|
5673
|
+
*
|
|
5674
|
+
* Both declare no positional argument and both document `-` as the spelling that reads the stream
|
|
5675
|
+
* from a pipe, so the dash is the caller doing what the flag description says rather than a surplus
|
|
5676
|
+
* token. Their own mutual-exclusion checks (`execFlags`, `applyFlags`) refuse a dash beside a real
|
|
5677
|
+
* `--file`.
|
|
5678
|
+
*/
|
|
5679
|
+
const STDIN_MARKER_COMMANDS = /* @__PURE__ */ new Set(["apply", "exec"]);
|
|
5680
|
+
/**
|
|
5681
|
+
* Positionals past what the command declares.
|
|
5682
|
+
*
|
|
5683
|
+
* The counterpart to the missing-argument check below: "absent" and "surplus" are both wrong calls
|
|
5684
|
+
* and both answer. Without this one a surplus positional is silently dropped — `memhtml read
|
|
5685
|
+
* a.html b.html` reads ONE memory and reports nothing about the second, and every mis-spelled
|
|
5686
|
+
* boolean value (`--embed false`) leaves one behind.
|
|
5687
|
+
*
|
|
5688
|
+
* A `repeatable` last argument turns the check off, because a variadic tail is what
|
|
5689
|
+
* `memhtml reinforce a.html b.html` is. That is declared in the table rather than listed here, so
|
|
5690
|
+
* the manifest states it and a future variadic command needs no edit to this function.
|
|
5691
|
+
*/
|
|
5692
|
+
const surplusArgs = (parsed, spec) => {
|
|
5693
|
+
if (spec.args.at(-1)?.repeatable === true) return void 0;
|
|
5694
|
+
const extra = parsed.positional.slice(spec.args.length).filter((token) => !(token === "-" && STDIN_MARKER_COMMANDS.has(spec.name)));
|
|
5695
|
+
if (extra.length === 0) return void 0;
|
|
5696
|
+
const shape = spec.args.length === 0 ? `${spec.name} takes no arguments` : `${spec.name} takes ${spec.args.length}: ${spec.args.map((arg) => arg.name).join(", ")}`;
|
|
5697
|
+
return fail("ERR_UNEXPECTED_ARGUMENT", `unexpected argument: ${extra.map((token) => `"${token}"`).join(", ")}. ${shape}`, [`memhtml ${spec.name}${spec.args.map((arg) => ` <${arg.name}>`).join("")}`, "memhtml manifest"]);
|
|
5698
|
+
};
|
|
5699
|
+
/**
|
|
4874
5700
|
* Validate a parsed invocation against its spec. Usage errors only; nothing here touches a service.
|
|
4875
5701
|
*
|
|
4876
5702
|
* Returning the failure rather than throwing keeps the exit code decision in one place. A usage
|
|
@@ -4878,9 +5704,21 @@ const claimOrArticle = (parsed) => {
|
|
|
4878
5704
|
* have to know that too.
|
|
4879
5705
|
*/
|
|
4880
5706
|
const validate = (parsed) => {
|
|
4881
|
-
for (const name of parsed.flags.keys()) if (!KNOWN_FLAGS.has(name)) return fail("ERR_INVALID_FLAG", `unknown flag: --${name}`, nearest(name, [...KNOWN_FLAGS]));
|
|
4882
5707
|
const spec = COMMANDS.find((command) => command.name === parsed.command);
|
|
4883
5708
|
if (spec === void 0) return unknownCommand(parsed);
|
|
5709
|
+
/**
|
|
5710
|
+
* Flags are validated against THIS command's spec plus the true globals, not the union of every
|
|
5711
|
+
* command's flags. A flag that is valid somewhere else is still a usage error here: an agent that
|
|
5712
|
+
* typed `memhtml list --status todo` meant `task list`, and silently ignoring the flag would
|
|
5713
|
+
* return an unfiltered answer that looks filtered. The suggestions are drawn from the whole known
|
|
5714
|
+
* set, so a flag that belongs to another command still points somewhere.
|
|
5715
|
+
*/
|
|
5716
|
+
const allowed = /* @__PURE__ */ new Set([...GLOBAL_FLAGS.map((flag) => flag.name), ...spec.flags.map((flag) => flag.name)]);
|
|
5717
|
+
for (const name of parsed.flags.keys()) if (!allowed.has(name)) return KNOWN_FLAGS.has(name) ? fail("ERR_INVALID_FLAG", `--${name} is not a flag of ${spec.name}`, COMMANDS.filter((command) => command.flags.some((flag) => flag.name === name)).slice(0, 3).map((command) => `memhtml ${command.name} --${name}`)) : fail("ERR_INVALID_FLAG", `unknown flag: --${name}`, nearest(name, [...allowed]));
|
|
5718
|
+
const strayBoolean = strayBooleanFlags(parsed);
|
|
5719
|
+
if (strayBoolean !== void 0) return strayBoolean;
|
|
5720
|
+
const surplus = surplusArgs(parsed, spec);
|
|
5721
|
+
if (surplus !== void 0) return surplus;
|
|
4884
5722
|
const missingArgs = spec.args.filter((arg, position) => arg.required && parsed.positional[position] === void 0);
|
|
4885
5723
|
if (missingArgs.length > 0) return fail("ERR_MISSING_ARGUMENT", `${spec.name} requires: ${missingArgs.map((arg) => arg.name).join(", ")}`, [`memhtml ${spec.name} <${missingArgs[0]?.name}>`]);
|
|
4886
5724
|
const missingFlags = spec.flags.filter((flag) => flag.required === true && parsed.flags.get(flag.name) === void 0);
|
|
@@ -4889,6 +5727,10 @@ const validate = (parsed) => {
|
|
|
4889
5727
|
if (eitherOr !== void 0) return eitherOr;
|
|
4890
5728
|
const exec = execFlags(parsed);
|
|
4891
5729
|
if (exec !== void 0) return exec;
|
|
5730
|
+
const apply = applyFlags(parsed);
|
|
5731
|
+
if (apply !== void 0) return apply;
|
|
5732
|
+
const asOf = asOfFlag(parsed);
|
|
5733
|
+
if (asOf !== void 0) return asOf;
|
|
4892
5734
|
/**
|
|
4893
5735
|
* A closed-vocabulary flag is checked here rather than at the service, so a typo answers with the
|
|
4894
5736
|
* whole vocabulary and never touches the database. Every value of a repeatable flag is checked, not
|
|
@@ -4969,22 +5811,23 @@ const run = async (argv, layer, stdin = readStdin) => {
|
|
|
4969
5811
|
* would open and migrate a store this command never queries, and an operator checking the gate is
|
|
4970
5812
|
* typically doing it while `memhtml-mcp` serves that store.
|
|
4971
5813
|
*
|
|
4972
|
-
* **Exit 1 on a failed gate**, with `ERR_DISCRIMINATION_FAILED
|
|
4973
|
-
* left the verdict inside the payload would be a gate every shell caller
|
|
4974
|
-
*
|
|
5814
|
+
* **Exit 1 on a failed gate**, with the `ERR_DISCRIMINATION_FAILED` FAILURE envelope. A gate
|
|
5815
|
+
* that exited 0 and left the verdict inside the payload would be a gate every shell caller
|
|
5816
|
+
* forgets to read, and a gate that exited 1 inside a success envelope would be one an agent
|
|
5817
|
+
* branching on `code` never sees fail. The failure travels through `failureFor` like every other
|
|
5818
|
+
* typed failure, so the code, the one-line reason, and the recovery suggestions are the
|
|
5819
|
+
* documented ones.
|
|
4975
5820
|
*/
|
|
4976
5821
|
if (parsed.command === "eval discriminate") {
|
|
4977
5822
|
const requested = str(parsed, "mode") ?? "fake";
|
|
4978
5823
|
return Effect.runPromise(runDiscrimination({
|
|
4979
5824
|
mode: requested,
|
|
4980
5825
|
...int(parsed, "seed") === void 0 ? {} : { seed: int(parsed, "seed") },
|
|
5826
|
+
...int(parsed, "now") === void 0 ? {} : { now: int(parsed, "now") },
|
|
4981
5827
|
...int(parsed, "size") === void 0 ? {} : { size: int(parsed, "size") },
|
|
4982
5828
|
...int(parsed, "probes") === void 0 ? {} : { probes: int(parsed, "probes") },
|
|
4983
5829
|
...num(parsed, "mrr-floor") === void 0 ? {} : { mrrFloor: num(parsed, "mrr-floor") }
|
|
4984
|
-
}).pipe(Effect.map((outcome) => outcome.passed ? emit(succeed("eval.discrimination", outcome), 0) : {
|
|
4985
|
-
stdout: render(succeed("eval.discrimination", outcome), dense),
|
|
4986
|
-
exitCode: 1
|
|
4987
|
-
}), Effect.catchCause((cause) => Effect.succeed(emit(fail("ERR_UNKNOWN", `unexpected failure: ${String(cause)}`, []), 1))), Effect.provideService(Logger.LogToStderr, true)));
|
|
5830
|
+
}).pipe(Effect.map((outcome) => outcome.passed ? emit(succeed("eval.discrimination", outcome), 0) : emit(failureFor(new DiscriminationFailed(outcome)), 1)), Effect.catchCause((cause) => Effect.succeed(emit(fail("ERR_UNKNOWN", `unexpected failure: ${String(cause)}`, []), 1))), Effect.provideService(Logger.LogToStderr, true)));
|
|
4988
5831
|
}
|
|
4989
5832
|
/**
|
|
4990
5833
|
* `memhtml exec` does not build the app layer either, for the reason two commands over.
|
|
@@ -5006,7 +5849,8 @@ const run = async (argv, layer, stdin = readStdin) => {
|
|
|
5006
5849
|
*/
|
|
5007
5850
|
if (parsed.command === "exec") {
|
|
5008
5851
|
const inline = str(parsed, "script");
|
|
5009
|
-
const
|
|
5852
|
+
const flagFile = str(parsed, "file");
|
|
5853
|
+
const file = parsed.positional[0] === "-" || flagFile === "-" ? void 0 : flagFile;
|
|
5010
5854
|
const script = inline !== void 0 ? inline : file === void 0 ? await stdin() : await readScript(file);
|
|
5011
5855
|
if (typeof script !== "string") return emit(script, 2);
|
|
5012
5856
|
if (script.trim() === "") return emit(fail("ERR_MISSING_ARGUMENT", "exec needs a script: a blank one would report an empty answer rather than an error", [
|
|
@@ -5040,14 +5884,15 @@ const run = async (argv, layer, stdin = readStdin) => {
|
|
|
5040
5884
|
*/
|
|
5041
5885
|
let applyOps = [];
|
|
5042
5886
|
if (parsed.command === "apply") {
|
|
5043
|
-
const
|
|
5887
|
+
const flagFile = str(parsed, "file");
|
|
5888
|
+
const file = parsed.positional[0] === "-" || flagFile === "-" ? void 0 : flagFile;
|
|
5044
5889
|
const text = await applyText(file, stdin);
|
|
5045
5890
|
if (typeof text !== "string") return emit(text, 2);
|
|
5046
5891
|
const decoded = decodeApply(text);
|
|
5047
5892
|
if (!decoded.ok) return emit(decoded.failure, 2);
|
|
5048
5893
|
applyOps = decoded.ops;
|
|
5049
5894
|
}
|
|
5050
|
-
const program = dispatch(parsed, applyOps).pipe(Effect.map(([type, data]) => emit(succeed(type, data), 0)), Effect.catch((error) => Effect.succeed(emit(failureFor(error), 1))), Effect.catchCause((cause) => Effect.succeed(emit(fail("ERR_UNKNOWN", `unexpected failure: ${String(cause)}`, []), 1))), Effect.provide(layer ?? layerApp(str(parsed, "repo"))), Effect.provideService(Logger.LogToStderr, true), Effect.scoped);
|
|
5895
|
+
const program = dispatch(parsed, applyOps).pipe(Effect.map(([type, data, exitCode]) => emit(succeed(type, data), exitCode ?? 0)), Effect.catch((error) => Effect.succeed(emit(failureFor(error), 1))), Effect.catchCause((cause) => Effect.succeed(emit(fail("ERR_UNKNOWN", `unexpected failure: ${String(cause)}`, []), 1))), Effect.provide(layer ?? layerApp(str(parsed, "repo"))), Effect.provideService(Logger.LogToStderr, true), Effect.scoped);
|
|
5051
5896
|
return Effect.runPromise(program);
|
|
5052
5897
|
};
|
|
5053
5898
|
|