memhtml 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -72
- package/agent/instructions.md +34 -10
- package/dist/dist-CBhYV3up.mjs +3 -0
- package/dist/{dist-CHoz5uHd.mjs → dist-D1wH0oJ0.mjs} +2574 -703
- package/dist/dist-D1wH0oJ0.mjs.map +1 -0
- package/dist/{dist-BCsav-EP.mjs → dist-DHFdTnlp.mjs} +825 -249
- package/dist/dist-DHFdTnlp.mjs.map +1 -0
- package/dist/memhtml-mcp.mjs +1272 -346
- package/dist/memhtml-mcp.mjs.map +1 -1
- package/dist/memhtml.mjs +1196 -365
- package/dist/memhtml.mjs.map +1 -1
- package/migrations/0007_watermark.sql +4 -2
- package/migrations/0011_edge_indexes.sql +78 -0
- package/migrations/0012_origin_path.sql +21 -0
- package/package.json +10 -10
- package/src/agent-build.ts +293 -21
- package/src/child-stderr.ts +36 -0
- package/src/client.ts +284 -171
- package/src/contract.ts +317 -74
- package/src/mount.ts +31 -7
- package/src/run-auth.ts +18 -15
- package/state-migrations/S0002_entity_corroboration.sql +13 -7
- package/dist/dist-BCsav-EP.mjs.map +0 -1
- package/dist/dist-CHoz5uHd.mjs.map +0 -1
- package/dist/dist-DuzGralO.mjs +0 -3
package/dist/memhtml-mcp.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, D as wrapAsData, E as ModelClientLive, 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, Q as Store, R as facetConditions, T as ModelClient, U as readIndexState, V as Indexer, W as IndexGit, X as STATE_MIGRATIONS_DIR, Y as MIGRATIONS_DIR, _t as REINFORCE_SIGNALS, b as SLEEP_PHASES, ct as Git, et as makeStore, ft as parseMemory, gt as fenceOpeningOf, ht as closesFence, i as makeSleep, j as EMBED_WATERMARK, k as EmbeddingsLive, lt as makeGit, mt as isValidDatetime, nt as SLEEP_REPORTS_DIR, q as DatabaseService, r as Sleep, rt as STATE_DB_PATH, s as reportFilename, st as readFileOrNull, tt as INDEX_DB_PATH, u as DETECTION_PREFIX, vt as frameKeyOf, z as parseFacetFilters } from "./dist-D1wH0oJ0.mjs";
|
|
3
|
+
import { It as WRITABLE_MEMORY_TYPES, Lt as isTaskStatus, Mt as PARA_BUCKETS, Pt as TASK_STATUSES, W as hasConsolidatorCredentials, ct as TASK_RELS, et as InvalidMemory, i as makeConsolidator, it as StorageFailure, lt as isEdgeRel, nt as ModelUnavailable, st as MEMORY_RELS, ut as relClassFor, wt as normalizePath, xt as isValidMemoryPath } from "./dist-DHFdTnlp.mjs";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { Config, Context, Effect, Layer, Logger, Schema } from "effect";
|
|
6
6
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
@@ -11,74 +11,6 @@ import { fileURLToPath } from "node:url";
|
|
|
11
11
|
import { NodeRuntime, NodeStdio } from "@effect/platform-node";
|
|
12
12
|
import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/unstable/ai";
|
|
13
13
|
|
|
14
|
-
//#region apps/cli/dist/serve.js
|
|
15
|
-
/** An explicit path to the server, for a deployment that does not keep the two apps side by side. */
|
|
16
|
-
const MCP_BIN_VAR = "MEMHTML_MCP_BIN";
|
|
17
|
-
|
|
18
|
-
//#endregion
|
|
19
|
-
//#region apps/cli/dist/config.js
|
|
20
|
-
const CONFIG_VARS = [
|
|
21
|
-
{
|
|
22
|
-
name: "MEMHTML_ROOT",
|
|
23
|
-
description: "The memory repo's root: a git repository holding the corpus and `.memhtml/`.",
|
|
24
|
-
fallback: join("~", "memhtml")
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
name: "MEMHTML_TRACE_ROOT",
|
|
28
|
-
description: "Where `memhtml trace index` reads Claude Code transcripts from. Read-only; never written.",
|
|
29
|
-
fallback: join("~", ".claude")
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
name: "MEMHTML_AWS_REGION",
|
|
33
|
-
description: "The Bedrock region for embeddings and the sleep cycle's model-calling phases.",
|
|
34
|
-
fallback: "us-east-1"
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
name: "AWS_BEARER_TOKEN_BEDROCK",
|
|
38
|
-
description: "Bedrock bearer token, read by the AWS SDK itself. Absent means the default credential chain; retrieval then degrades to the lexical floor rather than failing.",
|
|
39
|
-
fallback: null
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
name: "MEMHTML_EMBED",
|
|
43
|
-
description: "`off` disables the embedder entirely. An explicit opt-out, distinct from a missing credential: a missing credential degrades one search at call time, `off` degrades every search, and an operator reading this manifest needs those to be different states.",
|
|
44
|
-
fallback: "on"
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
name: "MEMHTML_LLM",
|
|
48
|
-
description: "`off` makes every model-calling sleep phase report `no model bound` and stay `ok`, so a credential-free run is honest rather than red. `entity-resolution` still runs its deterministic normalization and character-overlap passes; the others do nothing.",
|
|
49
|
-
fallback: "on"
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
name: "MEMHTML_EXTRACT_ENTITIES",
|
|
53
|
-
description: "`on` adds one GPT-5.6 Luna call per write batch that extracts `memhtml-entity` metas the ops did not declare. Opt-in, unlike MEMHTML_EMBED, because it changes what a write STORES: extracted entities land in the files as if authored, and the write itself never waits on or fails with the model. A failed extraction is a logged warning and an unextracted batch.",
|
|
54
|
-
fallback: "off"
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
/**
|
|
58
|
-
* The name is imported rather than retyped: this row and the `process.env` read at serve.ts:50
|
|
59
|
-
* must name the same string, and a literal here would let a rename disclose a variable nothing
|
|
60
|
-
* reads.
|
|
61
|
-
*/
|
|
62
|
-
name: MCP_BIN_VAR,
|
|
63
|
-
description: "An explicit path to the `memhtml-mcp` entry point, read only by the `memhtml serve mcp` supervisor. Absent means the sibling-path default. The two apps ship as one build, so `apps/cli/dist/serve.js` finds `apps/mcp/dist/bin.js` two directories over. An operator sets it for a split deployment that does not keep the apps side by side; it locates the server rather than configuring the store, so it changes no retrieval behavior.",
|
|
64
|
-
fallback: null
|
|
65
|
-
}
|
|
66
|
-
];
|
|
67
|
-
/**
|
|
68
|
-
* `MEMHTML_ROOT`. Re-exported from `@memhtml/store`'s own config rather than redeclared, because the
|
|
69
|
-
* store's config expands a leading `~`. This value arrives from a shell profile, an MCP client
|
|
70
|
-
* config, and a cron line, and only the shell expands tildes on its own.
|
|
71
|
-
*/
|
|
72
|
-
const MemhtmlRoot = Config.string("MEMHTML_ROOT").pipe(Config.withDefault(join("~", "memhtml")), Config.map(expandRoot));
|
|
73
|
-
/**
|
|
74
|
-
* `MEMHTML_TRACE_ROOT`, defaulting to `~/.claude`.
|
|
75
|
-
*
|
|
76
|
-
* A parameter rather than a constant so the trace indexer is drivable against a fixture tree and
|
|
77
|
-
* against an archived copy, which is also what keeps real transcripts out of the test suite.
|
|
78
|
-
*/
|
|
79
|
-
const TraceRoot = Config.string("MEMHTML_TRACE_ROOT").pipe(Config.withDefault(join(homedir(), ".claude")), Config.map(expandRoot));
|
|
80
|
-
|
|
81
|
-
//#endregion
|
|
82
14
|
//#region apps/cli/dist/extraction.js
|
|
83
15
|
/**
|
|
84
16
|
* GPT-5.6 Luna, the fast high-volume model on the mantle endpoint. A constant rather than config
|
|
@@ -242,6 +174,81 @@ const fetchMantleTransport = (region, token) => ({ post: async (body, signal) =>
|
|
|
242
174
|
return JSON.parse(text);
|
|
243
175
|
} });
|
|
244
176
|
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region apps/cli/dist/serve.js
|
|
179
|
+
/** An explicit path to the server, for a deployment that does not keep the two apps side by side. */
|
|
180
|
+
const MCP_BIN_VAR = "MEMHTML_MCP_BIN";
|
|
181
|
+
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region apps/cli/dist/config.js
|
|
184
|
+
const CONFIG_VARS = [
|
|
185
|
+
{
|
|
186
|
+
name: "MEMHTML_ROOT",
|
|
187
|
+
description: "The memory repo's root: a git repository holding the corpus and `.memhtml/`.",
|
|
188
|
+
fallback: join("~", "memhtml")
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "MEMHTML_TRACE_ROOT",
|
|
192
|
+
description: "Where `memhtml trace index` reads Claude Code transcripts from. Read-only; never written.",
|
|
193
|
+
fallback: join("~", ".claude")
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "MEMHTML_AWS_REGION",
|
|
197
|
+
description: "The Bedrock region for embeddings and the sleep cycle's model-calling phases.",
|
|
198
|
+
fallback: "us-east-1"
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: "AWS_BEARER_TOKEN_BEDROCK",
|
|
202
|
+
description: "Bedrock bearer token, read by the AWS SDK itself. Absent means the default credential chain; retrieval then degrades to the lexical floor rather than failing.",
|
|
203
|
+
fallback: null
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: "MEMHTML_EMBED",
|
|
207
|
+
description: "`off` disables the embedder entirely. An explicit opt-out, distinct from a missing credential: a missing credential degrades one search at call time, `off` degrades every search, and an operator reading this manifest needs those to be different states.",
|
|
208
|
+
fallback: "on"
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "MEMHTML_LLM",
|
|
212
|
+
description: "`off` makes every model-calling sleep phase report `no model bound` and stay `ok`, so a credential-free run is honest rather than red. `entity-resolution` still runs its deterministic normalization and character-overlap passes; the others do nothing.",
|
|
213
|
+
fallback: "on"
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "MEMHTML_EXTRACT_ENTITIES",
|
|
217
|
+
/**
|
|
218
|
+
* The model id is interpolated from `extraction.ts`, never spelled here. That constant is the
|
|
219
|
+
* one the transport calls and the one the strict output schema beside it is tested against, so a
|
|
220
|
+
* second spelling in this row is a manifest that can name a model the code does not call. The
|
|
221
|
+
* lane is also not `@memhtml/llm`'s: the extractor speaks the Bedrock mantle Responses API, which
|
|
222
|
+
* is why this id is absent from `ModelKey`.
|
|
223
|
+
*/
|
|
224
|
+
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.`,
|
|
225
|
+
fallback: "off"
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
/**
|
|
229
|
+
* The name is imported rather than retyped: this row and the `process.env` read at serve.ts:50
|
|
230
|
+
* must name the same string, and a literal here would let a rename disclose a variable nothing
|
|
231
|
+
* reads.
|
|
232
|
+
*/
|
|
233
|
+
name: MCP_BIN_VAR,
|
|
234
|
+
description: "An explicit path to the `memhtml-mcp` entry point, read only by the `memhtml serve mcp` supervisor. Absent means the sibling-path default. The two apps ship as one build, so `apps/cli/dist/serve.js` finds `apps/mcp/dist/bin.js` two directories over. An operator sets it for a split deployment that does not keep the apps side by side; it locates the server rather than configuring the store, so it changes no retrieval behavior.",
|
|
235
|
+
fallback: null
|
|
236
|
+
}
|
|
237
|
+
];
|
|
238
|
+
/**
|
|
239
|
+
* `MEMHTML_ROOT`. Re-exported from `@memhtml/store`'s own config rather than redeclared, because the
|
|
240
|
+
* store's config expands a leading `~`. This value arrives from a shell profile, an MCP client
|
|
241
|
+
* config, and a cron line, and only the shell expands tildes on its own.
|
|
242
|
+
*/
|
|
243
|
+
const MemhtmlRoot = Config.string("MEMHTML_ROOT").pipe(Config.withDefault(join("~", "memhtml")), Config.map(expandRoot));
|
|
244
|
+
/**
|
|
245
|
+
* `MEMHTML_TRACE_ROOT`, defaulting to `~/.claude`.
|
|
246
|
+
*
|
|
247
|
+
* A parameter rather than a constant so the trace indexer is drivable against a fixture tree and
|
|
248
|
+
* against an archived copy, which is also what keeps real transcripts out of the test suite.
|
|
249
|
+
*/
|
|
250
|
+
const TraceRoot = Config.string("MEMHTML_TRACE_ROOT").pipe(Config.withDefault(join(homedir(), ".claude")), Config.map(expandRoot));
|
|
251
|
+
|
|
245
252
|
//#endregion
|
|
246
253
|
//#region apps/cli/dist/api-layer.js
|
|
247
254
|
const Roots = Context.Service("memhtml/Roots");
|
|
@@ -393,22 +400,21 @@ const ConsolidatorPortService = Context.Service("memhtml/ConsolidatorPort");
|
|
|
393
400
|
*
|
|
394
401
|
* The check cannot be skipped in favor of the client's own, because the provider is lazy.
|
|
395
402
|
* `createAmazonBedrock` and `provider(modelId)` both succeed with zero credentials and nothing fails
|
|
396
|
-
* until the first request (
|
|
397
|
-
* `apps/consolidator/src/contract.ts:301-319`).
|
|
403
|
+
* until the first request (the contract suite in `apps/consolidator/src/contract.ts` pins this).
|
|
398
404
|
*
|
|
399
405
|
* **`env` is a parameter, and it has to be.** `Config` reads its values through a `ConfigProvider`,
|
|
400
406
|
* which a test substitutes, while `hasConsolidatorCredentials` reads `process.env` directly, and
|
|
401
|
-
* effect's default provider snapshots `process.env` at module load
|
|
402
|
-
* `process.env.MEMHTML_LLM` after importing `effect` changes nothing `Config.string` returns
|
|
407
|
+
* effect's default provider snapshots `process.env` at module load, so mutating
|
|
408
|
+
* `process.env.MEMHTML_LLM` after importing `effect` changes nothing `Config.string` returns. A test
|
|
403
409
|
* that set both by mutation would read a stale snapshot for one gate and a live object for the other,
|
|
404
410
|
* and the two gates would disagree about which environment they are in. Threading the credential
|
|
405
411
|
* environment through as an argument makes both injectable from one call. See
|
|
406
412
|
* `apps/cli/tests/consolidator-wiring.test.ts`, where that disagreement produced a false defect
|
|
407
413
|
* before this parameter existed.
|
|
408
414
|
*
|
|
409
|
-
* **It
|
|
415
|
+
* **It requires `RootsShape`, for `traceRoot`.** That is how transcripts reach the agent. The
|
|
410
416
|
* consolidator mounts the trace root read-only rather than sending transcripts as a model message
|
|
411
|
-
* (`apps/consolidator/src/client.ts`, `manifestFor
|
|
417
|
+
* (`apps/consolidator/src/client.ts`, `manifestFor`).
|
|
412
418
|
* The root is `MEMHTML_TRACE_ROOT` and this file is where config becomes services, so it is read from the
|
|
413
419
|
* same `Roots` service `memhtml trace index` scans with. One resolution of one variable is what
|
|
414
420
|
* keeps the mounted tree and the indexed `traces` rows describing the same directory. A second
|
|
@@ -516,6 +522,7 @@ const codeFor = (error) => {
|
|
|
516
522
|
case "DuplicateContent": return "ERR_DUPLICATE_CONTENT";
|
|
517
523
|
case "ModelUnavailable": return "ERR_MODEL_UNAVAILABLE";
|
|
518
524
|
case "EmbedModelMismatch": return "ERR_EMBED_MODEL_MISMATCH";
|
|
525
|
+
case "IndexStale": return "ERR_INDEX_STALE";
|
|
519
526
|
case "DiscriminationFailed": return "ERR_DISCRIMINATION_FAILED";
|
|
520
527
|
default: return "ERR_UNKNOWN";
|
|
521
528
|
}
|
|
@@ -541,6 +548,7 @@ const messageFor = (error) => {
|
|
|
541
548
|
case "DuplicateContent": return `this content already lives at ${text$1(error.existingPath) ?? "another path"}`;
|
|
542
549
|
case "ModelUnavailable": return `bedrock refused ${text$1(error.modelId) ?? "the model"}: ${text$1(error.reason) ?? "no reason given"}`;
|
|
543
550
|
case "EmbedModelMismatch": return `the index was built in vector space ${text$1(error.stored) ?? "?"}, configured is ${text$1(error.configured) ?? "?"}`;
|
|
551
|
+
case "IndexStale": return `the index is stale: ${text$1(error.reason) ?? "it does not describe the current commit"}`;
|
|
544
552
|
case "LlmContractViolation": return `the model broke its structured-output contract: ${text$1(error.reason) ?? "no reason given"}`;
|
|
545
553
|
case "DiscriminationFailed": return text$1(error.reason) ?? "the discrimination gate refused";
|
|
546
554
|
default: return `unexpected failure: ${error._tag}`;
|
|
@@ -633,20 +641,20 @@ const recordLink = (path, linkKind, provenance, at) => Effect.gen(function* () {
|
|
|
633
641
|
/**
|
|
634
642
|
* Bring the index up to the commit a write just made.
|
|
635
643
|
*
|
|
636
|
-
*
|
|
644
|
+
* The whole COMMIT, never a list of paths the caller happens to know about, and two properties of the
|
|
645
|
+
* index rest on that:
|
|
637
646
|
*
|
|
638
|
-
* 1.
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
* 2.
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
* from a stale base.
|
|
647
|
+
* 1. **A rename is only expressible as a diff.** Every correction and every archive is a `git mv`.
|
|
648
|
+
* `update()` reads `diff --name-status -M`, sees the `R`, and re-points the row, which keeps the
|
|
649
|
+
* embedding. Indexing the destination alone leaves the source row live: the archived memory stays
|
|
650
|
+
* in `memhtml list`, `files` carries a row the tree does not have, and the chunk rows the move
|
|
651
|
+
* exists to preserve end up duplicated under two paths.
|
|
652
|
+
* 2. **The watermark is what makes freshness answerable.** `update()` records
|
|
653
|
+
* `index_state.head_sha`, and without it `memhtml status` reports `index_fresh: false` forever
|
|
654
|
+
* while `index update` re-derives from a stale base.
|
|
647
655
|
*
|
|
648
656
|
* The cost is one `git diff` over one commit, which is what the watermark exists to bound. On the
|
|
649
|
-
* very first write
|
|
657
|
+
* very first write there is no watermark row and `update()` falls through to a full rebuild. That is
|
|
650
658
|
* correct, and cheap on a corpus that has one file in it.
|
|
651
659
|
*/
|
|
652
660
|
const reindex = () => Effect.gen(function* () {
|
|
@@ -657,8 +665,8 @@ const reindex = () => Effect.gen(function* () {
|
|
|
657
665
|
*
|
|
658
666
|
* Shared by {@link writeMemory} and {@link batchWrite}, and the sharing matters. A batch that
|
|
659
667
|
* re-derived this would be a second decode of the same vocabulary, and the two would agree today
|
|
660
|
-
* and drift the first time a field is added.
|
|
661
|
-
*
|
|
668
|
+
* and drift the first time a field is added. The batch folds the singular's own decode rather
|
|
669
|
+
* than a parallel one.
|
|
662
670
|
*
|
|
663
671
|
* The two task metas are decoded here, before any file is rendered, and only for a task.
|
|
664
672
|
* `@memhtml/html`'s parser refuses `memhtml-task-status` on a non-task and refuses a `memhtml-due` that is not
|
|
@@ -679,6 +687,7 @@ const toWriteInput = (params, at) => Effect.gen(function* () {
|
|
|
679
687
|
body: params.body,
|
|
680
688
|
articleHtml: params.articleHtml,
|
|
681
689
|
path: params.path,
|
|
690
|
+
strictPath: params.strictPath,
|
|
682
691
|
workspace: params.workspace,
|
|
683
692
|
tags: params.tags,
|
|
684
693
|
entities: params.entities,
|
|
@@ -1268,13 +1277,116 @@ const reinforceMemories = (paths, signal) => Effect.gen(function* () {
|
|
|
1268
1277
|
};
|
|
1269
1278
|
});
|
|
1270
1279
|
/**
|
|
1280
|
+
* The ceiling on nodes per neighborhood, and the default when a caller names none.
|
|
1281
|
+
*
|
|
1282
|
+
* A caller-supplied `limit` is clamped into `1..NEIGHBORS_LIMIT`, which is the shape both sibling
|
|
1283
|
+
* reads have (`memory_list` `Math.min(500, …)`, `trace_search` `Math.min(200, …)`). A clamp with no
|
|
1284
|
+
* flag behind it is a ceiling a caller can neither ask for nor lower.
|
|
1285
|
+
*/
|
|
1286
|
+
const NEIGHBORS_LIMIT = 200;
|
|
1287
|
+
/**
|
|
1288
|
+
* Edge rows the statement may RETURN before it stops.
|
|
1289
|
+
*
|
|
1290
|
+
* **This bounds the answer, not the join.** Measured 2026-08-25 on node 24.19.0 against the shipped
|
|
1291
|
+
* schema: `EXPLAIN QUERY PLAN` on {@link neighborsQuery}'s depth-2 statement yields `MERGE
|
|
1292
|
+
* (UNION ALL)` with `USE TEMP B-TREE FOR ORDER BY` on every arm, so SQLite enumerates the union and
|
|
1293
|
+
* sorts the whole row set in a temp b-tree BEFORE the `LIMIT` takes its prefix. A hub of degree
|
|
1294
|
+
* 150/300/450 generates 22.5k/90k/202k rows either way, and the limited statement runs in
|
|
1295
|
+
* 47/92/155 ms against 66/253/591 ms unlimited — so the cap buys real time and memory downstream of
|
|
1296
|
+
* the sort while the join's work and the temp b-tree still grow with the center's degree squared.
|
|
1297
|
+
*
|
|
1298
|
+
* What the cap does bound: the rows that cross into JS, the fold below, and the size of one answer.
|
|
1299
|
+
* A neighborhood that reaches it is truncated rather than exhaustive, and `scanSaturated` says so
|
|
1300
|
+
* instead of leaving the caller to infer it — raising a caller's `limit` cannot recover an edge the
|
|
1301
|
+
* walk never returned.
|
|
1302
|
+
*
|
|
1303
|
+
* Bounding each arm before the union WOULD bound the join, and is not done: an arm-level `LIMIT`
|
|
1304
|
+
* takes an arbitrary prefix of one direction's edges, so the hop-1 nodes that survive decide which
|
|
1305
|
+
* hop-2 nodes exist at all, and the answer would change with the planner's row order rather than
|
|
1306
|
+
* only shrink.
|
|
1307
|
+
*/
|
|
1308
|
+
const NEIGHBORS_SCAN_LIMIT = 1e4;
|
|
1309
|
+
/**
|
|
1310
|
+
* The neighborhood walk as one statement plus its bind list.
|
|
1311
|
+
*
|
|
1312
|
+
* Exported so a cost assertion can `EXPLAIN QUERY PLAN` the string this function actually issues.
|
|
1313
|
+
* A plan asserted against a copy pasted into a test explains the copy, and the two drift the first
|
|
1314
|
+
* time an arm moves.
|
|
1315
|
+
*
|
|
1316
|
+
* Hop 1 is the center's own edges, either direction. Hop 2 walks one further from each hop-1 node and
|
|
1317
|
+
* excludes the center, so a two-cycle does not report the center as its own neighbor at distance 2.
|
|
1318
|
+
* Each arm carries the edge's own endpoints (`a`, `b`) so an edge can be counted as an edge, not
|
|
1319
|
+
* inferred from a node count.
|
|
1320
|
+
*
|
|
1321
|
+
* The join onto `files` is an inner join, so an edge pointing at a path the tree does not hold
|
|
1322
|
+
* contributes nothing. A dangling href is `memhtml doctor`'s finding rather than a titleless node.
|
|
1323
|
+
*
|
|
1324
|
+
* The rel list binds once per occurrence of the filter, in textual order: hop 1 uses it twice, hop 2
|
|
1325
|
+
* uses it four more times. Getting this count wrong is a bind mismatch rather than a wrong answer, so
|
|
1326
|
+
* it fails loudly.
|
|
1327
|
+
*/
|
|
1328
|
+
const neighborsQuery = (input) => {
|
|
1329
|
+
const { center, depth, rels } = input;
|
|
1330
|
+
const relFilter = rels.length > 0 ? ` AND e.rel IN (${rels.map(() => "?").join(", ")})` : "";
|
|
1331
|
+
const relFilter2 = rels.length > 0 ? ` AND e2.rel IN (${rels.map(() => "?").join(", ")})` : "";
|
|
1332
|
+
const hopOne = `
|
|
1333
|
+
SELECT e.dst_path AS path, e.rel AS rel, e.derived AS derived, 1 AS hop,
|
|
1334
|
+
e.src_path AS a, e.dst_path AS b
|
|
1335
|
+
FROM edges e
|
|
1336
|
+
WHERE e.src_path = ?1 AND e.edge_class = 'memory'${relFilter}
|
|
1337
|
+
UNION ALL
|
|
1338
|
+
SELECT e.src_path AS path, e.rel AS rel, e.derived AS derived, 1 AS hop,
|
|
1339
|
+
e.src_path AS a, e.dst_path AS b
|
|
1340
|
+
FROM edges e
|
|
1341
|
+
WHERE e.dst_path = ?1 AND e.edge_class = 'memory'${relFilter}`;
|
|
1342
|
+
const hopTwo = `
|
|
1343
|
+
SELECT e2.dst_path AS path, e2.rel AS rel, e2.derived AS derived, 2 AS hop,
|
|
1344
|
+
e2.src_path AS a, e2.dst_path AS b
|
|
1345
|
+
FROM edges e
|
|
1346
|
+
JOIN edges e2 ON e2.src_path = e.dst_path
|
|
1347
|
+
WHERE e.src_path = ?1 AND e.edge_class = 'memory' AND e2.edge_class = 'memory'
|
|
1348
|
+
AND e2.dst_path <> ?1${relFilter}${relFilter2}
|
|
1349
|
+
UNION ALL
|
|
1350
|
+
SELECT e2.src_path AS path, e2.rel AS rel, e2.derived AS derived, 2 AS hop,
|
|
1351
|
+
e2.src_path AS a, e2.dst_path AS b
|
|
1352
|
+
FROM edges e
|
|
1353
|
+
JOIN edges e2 ON e2.dst_path = e.src_path
|
|
1354
|
+
WHERE e.dst_path = ?1 AND e.edge_class = 'memory' AND e2.edge_class = 'memory'
|
|
1355
|
+
AND e2.src_path <> ?1${relFilter}${relFilter2}`;
|
|
1356
|
+
return {
|
|
1357
|
+
sql: `SELECT w.path AS path, f.title AS title, w.rel AS rel, w.derived AS derived,
|
|
1358
|
+
w.hop AS hop, w.a AS a, w.b AS b
|
|
1359
|
+
FROM (${depth === 1 ? hopOne : `${hopOne}\n UNION ALL${hopTwo}`}) w
|
|
1360
|
+
JOIN files f ON f.path = w.path
|
|
1361
|
+
ORDER BY w.hop ASC, w.path ASC
|
|
1362
|
+
LIMIT ${NEIGHBORS_SCAN_LIMIT}`,
|
|
1363
|
+
params: [center, ...depth === 1 ? [...rels, ...rels] : [
|
|
1364
|
+
...rels,
|
|
1365
|
+
...rels,
|
|
1366
|
+
...rels,
|
|
1367
|
+
...rels,
|
|
1368
|
+
...rels,
|
|
1369
|
+
...rels
|
|
1370
|
+
]]
|
|
1371
|
+
};
|
|
1372
|
+
};
|
|
1373
|
+
/**
|
|
1271
1374
|
* The memory graph around one path, to a fixed depth of at most two hops.
|
|
1272
1375
|
*
|
|
1273
1376
|
* **Two fixed-depth joins in a `UNION ALL`, deliberately not a recursive CTE.** The depth is
|
|
1274
1377
|
* bounded at 2 by the tool's contract, so recursion buys nothing and costs the one thing a graph
|
|
1275
1378
|
* query must not have here: an unbounded worst case on a corpus whose `relates_to` edges are
|
|
1276
|
-
* mined by the sleep cycle and can be dense.
|
|
1277
|
-
*
|
|
1379
|
+
* mined by the sleep cycle and can be dense.
|
|
1380
|
+
*
|
|
1381
|
+
* **Every arm is index-probed, in both directions, through `edges_src` and `edges_dst`.** Measured
|
|
1382
|
+
* 2026-08-26 on node 24.19.0 with no `ANALYZE`, and locked by the plan assertion in
|
|
1383
|
+
* `apps/cli/tests/e2e.test.ts`: each arm is a `SEARCH` binding two columns,
|
|
1384
|
+
* `(src_path=? AND edge_class=?)` or `(dst_path=? AND edge_class=?)`. Neither index carries a
|
|
1385
|
+
* predicate (`0011_edge_indexes.sql`), which is what makes them reachable from here at all — this walk
|
|
1386
|
+
* selects `e.derived` and filters only on `edge_class`, so a `WHERE derived = 0` index could not be a
|
|
1387
|
+
* candidate, and the reverse arms would fall back to a full scan of `edges` per arm. The row set is
|
|
1388
|
+
* still degree², which is what `NEIGHBORS_SCAN_LIMIT` bounds; what the indexes bound is the work spent
|
|
1389
|
+
* finding it.
|
|
1278
1390
|
*
|
|
1279
1391
|
* **Both directions, and `derived = 0 ∪ derived = 1`.** An edge is an assertion about a pair, and
|
|
1280
1392
|
* which file happens to hold the `<link>` is authorship rather than direction of meaning. A
|
|
@@ -1291,58 +1403,257 @@ const neighborsOf = (params) => Effect.gen(function* () {
|
|
|
1291
1403
|
const db = yield* DatabaseService;
|
|
1292
1404
|
const center = normalizePath(params.path);
|
|
1293
1405
|
const depth = Math.min(2, Math.max(1, Math.trunc(params.depth ?? 1)));
|
|
1406
|
+
const limit = Math.min(200, Math.max(1, Math.trunc(params.limit ?? 200)));
|
|
1294
1407
|
const rels = (params.rels ?? []).filter((rel) => isEdgeRel(rel) && relClassFor(rel) === "memory");
|
|
1295
|
-
const relFilter = rels.length > 0 ? ` AND e.rel IN (${rels.map(() => "?").join(", ")})` : "";
|
|
1296
|
-
const relFilter2 = rels.length > 0 ? ` AND e2.rel IN (${rels.map(() => "?").join(", ")})` : "";
|
|
1297
1408
|
/**
|
|
1298
|
-
*
|
|
1299
|
-
*
|
|
1300
|
-
* at
|
|
1409
|
+
* Edge rows, hop-1 first, folded per path below rather than `GROUP BY` in SQL. A `GROUP BY`
|
|
1410
|
+
* with `min(hop)` and `min(rel)` aggregates the two columns independently, so a node reachable
|
|
1411
|
+
* as `supersedes` at hop 1 and `contradicts` at hop 2 would report `(hop 1, contradicts)`, a
|
|
1412
|
+
* pairing no edge holds. The fold keeps the rel of an edge AT the minimal hop.
|
|
1301
1413
|
*/
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
const
|
|
1323
|
-
const
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1414
|
+
const statement = neighborsQuery({
|
|
1415
|
+
center,
|
|
1416
|
+
depth,
|
|
1417
|
+
rels
|
|
1418
|
+
});
|
|
1419
|
+
const rows = yield* db.all(statement.sql, statement.params);
|
|
1420
|
+
/**
|
|
1421
|
+
* One node per path at its minimal hop: a node reachable both directly and via a detour is a
|
|
1422
|
+
* 1-hop neighbor, and reporting it twice would let one memory occupy two slots in a bounded
|
|
1423
|
+
* answer. The rows arrive hop-first, so a path's first row IS an edge at its minimal hop and
|
|
1424
|
+
* its rel is kept verbatim. `derived` is the max over every edge reaching the node, so one
|
|
1425
|
+
* sleep-mined route marks the node as carrying a mined suspicion even when an authored edge
|
|
1426
|
+
* also reaches it. `edges` counts distinct edges the walk enumerated, which is what the MCP
|
|
1427
|
+
* schema's `edges` field claims to be.
|
|
1428
|
+
*
|
|
1429
|
+
* A path the clamp turns away is still counted, in `nodesDropped`, and its edges still count
|
|
1430
|
+
* toward `edges`: the two numbers live in different coordinate spaces on purpose, and an `edges`
|
|
1431
|
+
* total that quietly excluded a dropped path's edges would agree with `nodes` while describing
|
|
1432
|
+
* a walk that never happened.
|
|
1433
|
+
*/
|
|
1434
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
1435
|
+
const edgeKeys = /* @__PURE__ */ new Set();
|
|
1436
|
+
const dropped = /* @__PURE__ */ new Set();
|
|
1437
|
+
for (const row of rows) {
|
|
1438
|
+
edgeKeys.add(JSON.stringify([
|
|
1439
|
+
row.a,
|
|
1440
|
+
row.rel,
|
|
1441
|
+
row.b
|
|
1442
|
+
]));
|
|
1443
|
+
const existing = byPath.get(row.path);
|
|
1444
|
+
if (existing === void 0) {
|
|
1445
|
+
if (byPath.size < limit) byPath.set(row.path, {
|
|
1446
|
+
title: row.title,
|
|
1447
|
+
hop: row.hop,
|
|
1448
|
+
rel: row.rel,
|
|
1449
|
+
derived: row.derived === 1
|
|
1450
|
+
});
|
|
1451
|
+
else dropped.add(row.path);
|
|
1452
|
+
} else if (row.derived === 1) byPath.set(row.path, {
|
|
1453
|
+
...existing,
|
|
1454
|
+
derived: true
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1341
1457
|
return {
|
|
1342
1458
|
center,
|
|
1343
1459
|
depth,
|
|
1344
|
-
|
|
1345
|
-
|
|
1460
|
+
/** The node ceiling this answer was built under, after clamping the caller's ask. */
|
|
1461
|
+
limit,
|
|
1462
|
+
nodes: [...byPath.entries()].map(([path, node]) => ({
|
|
1463
|
+
path,
|
|
1464
|
+
title: node.title,
|
|
1465
|
+
hop: node.hop,
|
|
1466
|
+
rel: node.rel,
|
|
1467
|
+
derived: node.derived
|
|
1468
|
+
})),
|
|
1469
|
+
edges: edgeKeys.size,
|
|
1470
|
+
/**
|
|
1471
|
+
* Distinct paths the walk reached and `limit` turned away. `0` means `nodes` holds every path
|
|
1472
|
+
* the walk found, so a caller can tell a saturated neighborhood from a complete one. Raising
|
|
1473
|
+
* `limit` toward {@link NEIGHBORS_LIMIT} returns them.
|
|
1474
|
+
*/
|
|
1475
|
+
nodesDropped: dropped.size,
|
|
1476
|
+
/**
|
|
1477
|
+
* True when the walk returned {@link NEIGHBORS_SCAN_LIMIT} rows, so edges past the cap were
|
|
1478
|
+
* never enumerated and no `limit` recovers them. Distinct from `nodesDropped`, which a bigger
|
|
1479
|
+
* `limit` fixes.
|
|
1480
|
+
*/
|
|
1481
|
+
scanSaturated: rows.length >= NEIGHBORS_SCAN_LIMIT
|
|
1482
|
+
};
|
|
1483
|
+
});
|
|
1484
|
+
/**
|
|
1485
|
+
* Steps the forward walk takes before it stops and says which bound stopped it.
|
|
1486
|
+
*
|
|
1487
|
+
* A chain this long is a corpus that has corrected one fact sixteen times, which the walk answers
|
|
1488
|
+
* with `hop_limit` rather than by paying an unbounded number of statements for a read a caller
|
|
1489
|
+
* expects to be cheap. It is not the cycle guard: {@link resolveMemory} carries a visited set, so a
|
|
1490
|
+
* loop is reported as a loop at the hop that closes it, however short.
|
|
1491
|
+
*/
|
|
1492
|
+
const RESOLVE_MAX_HOPS = 16;
|
|
1493
|
+
/**
|
|
1494
|
+
* Why the forward walk stopped, and therefore what `path` is.
|
|
1495
|
+
*
|
|
1496
|
+
* A closed vocabulary, and the reason it is five values rather than a boolean: only the first means
|
|
1497
|
+
* "cite this". Collapsing the other four into "not found" would make an evicted memory, an
|
|
1498
|
+
* unindexed path, and a corpus defect one answer, and a caller acting on any of them needs a
|
|
1499
|
+
* different next move.
|
|
1500
|
+
*
|
|
1501
|
+
* - `live` — nothing supersedes `path` and it is active. The resolution a citation wants.
|
|
1502
|
+
* - `archived` — `path` is in the archive and nothing supersedes it, so the memory was EVICTED
|
|
1503
|
+
* rather than corrected. `git log --follow` on `path` reads through its whole life.
|
|
1504
|
+
* - `unindexed` — the index holds no such path and no archive mapping onto it. Either the path was
|
|
1505
|
+
* never a memory here, or the index does not yet describe the commit that holds it; `indexedCommit`
|
|
1506
|
+
* is which commit it does describe.
|
|
1507
|
+
* - `cycle` — the walk returned to a path it had already visited. `steps` shows the loop closing, and
|
|
1508
|
+
* `path` is a member of it rather than a resolution. Two memories each claiming to supersede the
|
|
1509
|
+
* other is an authoring defect, not a state this walk can resolve.
|
|
1510
|
+
* - `hop_limit` — the chain is longer than {@link RESOLVE_MAX_HOPS}, so `path` is where the walk
|
|
1511
|
+
* stopped and NOT the end of the chain. Resolving `path` again continues from there.
|
|
1512
|
+
*
|
|
1513
|
+
* An array rather than a bare union, so `memory_resolve` publishes the five values as a JSON Schema
|
|
1514
|
+
* enum from this one declaration and a client can branch exhaustively.
|
|
1515
|
+
*/
|
|
1516
|
+
const RESOLVE_STOP_REASONS = [
|
|
1517
|
+
"live",
|
|
1518
|
+
"archived",
|
|
1519
|
+
"unindexed",
|
|
1520
|
+
"cycle",
|
|
1521
|
+
"hop_limit"
|
|
1522
|
+
];
|
|
1523
|
+
/**
|
|
1524
|
+
* The two mechanisms that move a memory, as a closed vocabulary.
|
|
1525
|
+
*
|
|
1526
|
+
* There is no third: a memory's path changes because a correction superseded it or because a `git mv`
|
|
1527
|
+
* archived it, and nothing else in the system renames a file.
|
|
1528
|
+
*/
|
|
1529
|
+
const RESOLVE_STEP_VIA = ["supersedes", "archive_move"];
|
|
1530
|
+
/**
|
|
1531
|
+
* The three statements the walk issues, as literals a plan assertion can EXPLAIN.
|
|
1532
|
+
*
|
|
1533
|
+
* Exported for {@link neighborsQuery}'s reason: a cost contract can only be asserted at the planner,
|
|
1534
|
+
* and a test that EXPLAINed a pasted copy would explain the copy. Each one binds exactly one
|
|
1535
|
+
* parameter, so a test can run them as written.
|
|
1536
|
+
*
|
|
1537
|
+
* `successor` names `edge_class` even though `rel = 'supersedes'` implies it under `edges`' CHECK
|
|
1538
|
+
* constraints. That is a planner constraint, not a filter: measured 2026-08-26 on node 24's
|
|
1539
|
+
* `node:sqlite` with no `ANALYZE`, `dst_path = ? AND rel = ? AND derived = 0` alone plans as `SEARCH
|
|
1540
|
+
* edges USING INDEX edges_derived (derived=? AND rel=?)` — every authored correction in the corpus,
|
|
1541
|
+
* per hop — while naming the class binds two columns of `edges_dst` and the same statement probes.
|
|
1542
|
+
* The rel and `derived = 0` are still the CORRECTNESS half: `derived = 0` is the same authored-only
|
|
1543
|
+
* rule `SearchHit.supersededBy` reads, so `search` and this walk cannot disagree about who superseded
|
|
1544
|
+
* what, and a sleep-mined suspicion can never redirect a citation.
|
|
1545
|
+
*
|
|
1546
|
+
* `archived` is the archive mapping read backwards, served by `files_origin`
|
|
1547
|
+
* (`0012_origin_path.sql`). `ORDER BY archived_at DESC` decides the case a UNIQUE index would have
|
|
1548
|
+
* had to refuse: one path evicted, rewritten, and evicted again carries two archive rows, and the
|
|
1549
|
+
* NEWEST is the occupant a citation of that path most recently named. A row with no `memhtml-archived`
|
|
1550
|
+
* stamp sorts last, since SQLite puts NULLs last under DESC.
|
|
1551
|
+
*/
|
|
1552
|
+
const resolveQueries = {
|
|
1553
|
+
successor: `SELECT e.src_path AS path FROM edges e
|
|
1554
|
+
WHERE e.dst_path = ? AND e.edge_class = 'memory' AND e.rel = 'supersedes' AND e.derived = 0
|
|
1555
|
+
ORDER BY e.created_at DESC, e.src_path ASC LIMIT 1`,
|
|
1556
|
+
archived: `SELECT f.path AS path FROM files f
|
|
1557
|
+
WHERE f.origin_path = ? ORDER BY f.archived_at DESC, f.path DESC LIMIT 1`,
|
|
1558
|
+
file: "SELECT f.archived AS archived, f.title AS title FROM files f WHERE f.path = ?"
|
|
1559
|
+
};
|
|
1560
|
+
/**
|
|
1561
|
+
* The live path a possibly-moved path names now, by walking `supersedes` forward.
|
|
1562
|
+
*
|
|
1563
|
+
* **A path IS the id of a memory** (`packages/contracts/src/types.ts`, `MemoryPath`), and it is
|
|
1564
|
+
* derived from the title through `slugify`, so a re-consolidation that rewords a title lands the
|
|
1565
|
+
* corrected fact at a DIFFERENT path while `correctMemory` `git mv`s the original into
|
|
1566
|
+
* `archive/<YYYY>/`. An external receipt holding the old path therefore dead-ends at a path the tree
|
|
1567
|
+
* no longer holds — through no fault of the receipt. This read is how such a receipt is repaired
|
|
1568
|
+
* without a second identifier: the corpus already records both mechanisms that move a memory, and
|
|
1569
|
+
* nothing here is minted.
|
|
1570
|
+
*
|
|
1571
|
+
* **Two mechanisms, and a path absent from `files` is looked up by the archive mapping ALONE.** A
|
|
1572
|
+
* correction stamps its `supersedes` link toward the target's ARCHIVE path
|
|
1573
|
+
* (`packages/store/src/store.ts`, `correctMemory`), so the pre-archive path has no inbound edge at
|
|
1574
|
+
* all and only `origin_path` knows where its bytes went. An inbound `supersedes` edge over a path the
|
|
1575
|
+
* tree does not hold is a DANGLING edge — `memhtml doctor`'s finding — and following one would
|
|
1576
|
+
* resolve a citation through an assertion about a file nothing can read. Conversely a path that IS in
|
|
1577
|
+
* `files` is never redirected by the archive mapping, even when an older eviction of the same path
|
|
1578
|
+
* left a row behind: the live file at that path is the answer, and the redirect would replace it with
|
|
1579
|
+
* a historical one.
|
|
1580
|
+
*
|
|
1581
|
+
* **Every node in the chain is named by the path that holds it NOW.** A `supersedes` link is an
|
|
1582
|
+
* element inside a file, so archiving that file carries the link with it: after a second correction the
|
|
1583
|
+
* edge points from the archived middle memory, not from the path the middle was live at. A three-step
|
|
1584
|
+
* chain over two corrections therefore reads `cited → archive(cited) → archive(middle) → live`, and the
|
|
1585
|
+
* middle's own live-at-the-time path appears nowhere in it. The tree is the system of record, and this
|
|
1586
|
+
* walk reports where each memory is rather than where it was.
|
|
1587
|
+
*
|
|
1588
|
+
* **`hops: 0` with `stopReason: "live"` does not mean the bytes are unchanged.** A correction whose
|
|
1589
|
+
* title is unchanged lands at the SAME path, so the path is live and its content is a different fact.
|
|
1590
|
+
* That grain is what the pinned citation URI is for; this read answers where to look, not what was
|
|
1591
|
+
* there.
|
|
1592
|
+
*
|
|
1593
|
+
* Statement count is `1..2` per hop and the walk is bounded twice — by a visited set and by
|
|
1594
|
+
* {@link RESOLVE_MAX_HOPS} — so a corpus defect costs a bounded read and is reported rather than
|
|
1595
|
+
* hung. A recursive CTE would do it in one statement and could not report WHICH mechanism took each
|
|
1596
|
+
* hop, which is the half a receipt is audited on.
|
|
1597
|
+
*/
|
|
1598
|
+
const resolveMemory = (path) => Effect.gen(function* () {
|
|
1599
|
+
const db = yield* DatabaseService;
|
|
1600
|
+
const requested = normalizePath(path);
|
|
1601
|
+
const state = yield* readIndexState(db);
|
|
1602
|
+
const steps = [];
|
|
1603
|
+
const visited = /* @__PURE__ */ new Set([requested]);
|
|
1604
|
+
/** Titles of every indexed path the walk touched, so the answer carries one without a re-read. */
|
|
1605
|
+
const titles = /* @__PURE__ */ new Map();
|
|
1606
|
+
let at = requested;
|
|
1607
|
+
let stopReason = "unindexed";
|
|
1608
|
+
for (;;) {
|
|
1609
|
+
const row = yield* db.get(resolveQueries.file, [at]);
|
|
1610
|
+
if (row !== void 0) titles.set(at, row.title);
|
|
1611
|
+
const hop = row === void 0 ? {
|
|
1612
|
+
found: yield* db.get(resolveQueries.archived, [at]),
|
|
1613
|
+
via: "archive_move"
|
|
1614
|
+
} : {
|
|
1615
|
+
found: yield* db.get(resolveQueries.successor, [at]),
|
|
1616
|
+
via: "supersedes"
|
|
1617
|
+
};
|
|
1618
|
+
if (hop.found === void 0) {
|
|
1619
|
+
if (row === void 0) stopReason = "unindexed";
|
|
1620
|
+
else stopReason = row.archived === 1 ? "archived" : "live";
|
|
1621
|
+
break;
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* The bound is checked BEFORE the step is taken, so `steps.length` is exactly
|
|
1625
|
+
* {@link RESOLVE_MAX_HOPS} when it fires and `path` is a real path the walk stood on. Taking the
|
|
1626
|
+
* step first would report a hop past a bound the answer claims to respect.
|
|
1627
|
+
*/
|
|
1628
|
+
if (steps.length >= 16) {
|
|
1629
|
+
stopReason = "hop_limit";
|
|
1630
|
+
break;
|
|
1631
|
+
}
|
|
1632
|
+
steps.push({
|
|
1633
|
+
from: at,
|
|
1634
|
+
to: hop.found.path,
|
|
1635
|
+
via: hop.via
|
|
1636
|
+
});
|
|
1637
|
+
at = hop.found.path;
|
|
1638
|
+
/**
|
|
1639
|
+
* The repeat IS recorded as a step before the walk stops, so `steps` shows the loop closing and
|
|
1640
|
+
* a reader can name both ends of it. A cycle detected and then hidden would leave the caller
|
|
1641
|
+
* with a `path` it cannot account for.
|
|
1642
|
+
*/
|
|
1643
|
+
if (visited.has(at)) {
|
|
1644
|
+
stopReason = "cycle";
|
|
1645
|
+
break;
|
|
1646
|
+
}
|
|
1647
|
+
visited.add(at);
|
|
1648
|
+
}
|
|
1649
|
+
return {
|
|
1650
|
+
requested,
|
|
1651
|
+
path: at,
|
|
1652
|
+
hops: steps.length,
|
|
1653
|
+
steps,
|
|
1654
|
+
stopReason,
|
|
1655
|
+
title: titles.get(at) ?? null,
|
|
1656
|
+
indexedCommit: state?.head_sha ?? null
|
|
1346
1657
|
};
|
|
1347
1658
|
});
|
|
1348
1659
|
/**
|
|
@@ -1376,9 +1687,20 @@ const listMemories = (params) => Effect.gen(function* () {
|
|
|
1376
1687
|
values.push(params.tag);
|
|
1377
1688
|
}
|
|
1378
1689
|
if (params.entity !== void 0 && params.entity !== "") {
|
|
1379
|
-
conditions.push("EXISTS (SELECT 1 FROM file_entities e WHERE e.path = f.path AND e.entity_type || ':' || e.entity_name = ?)");
|
|
1380
|
-
values.push(params.entity);
|
|
1690
|
+
conditions.push("EXISTS (SELECT 1 FROM file_entities e WHERE e.path = f.path AND lower(e.entity_type || ':' || e.entity_name) = lower(?))");
|
|
1691
|
+
values.push(params.entity.trim());
|
|
1381
1692
|
}
|
|
1693
|
+
/**
|
|
1694
|
+
* The facet axis, from `@memhtml/index`'s builder rather than a second copy of the grouping.
|
|
1695
|
+
*
|
|
1696
|
+
* The listing binds anonymous `?` in textual order, so the placeholder callback pushes onto
|
|
1697
|
+
* `values` and returns the marker. That is the same contract the numbered form has: whatever the
|
|
1698
|
+
* builder emits, the values it pushed are in the order the statement reads them.
|
|
1699
|
+
*/
|
|
1700
|
+
for (const condition of facetConditions(params.facets ?? [], "f", (value) => {
|
|
1701
|
+
values.push(value);
|
|
1702
|
+
return "?";
|
|
1703
|
+
})) conditions.push(condition);
|
|
1382
1704
|
if (params.cursor !== void 0 && params.cursor !== "") {
|
|
1383
1705
|
conditions.push("f.path > ?");
|
|
1384
1706
|
values.push(normalizePath(params.cursor));
|
|
@@ -1484,6 +1806,12 @@ const searchTraces = (params) => Effect.gen(function* () {
|
|
|
1484
1806
|
};
|
|
1485
1807
|
});
|
|
1486
1808
|
/**
|
|
1809
|
+
* Rows one `trace links` answer may carry. Every sibling read clamps (`memory_list` 500,
|
|
1810
|
+
* `trace_search` 200), and a long-lived session accretes links without bound, so an unclamped
|
|
1811
|
+
* answer grows forever. Newest first, so the truncation costs the oldest links.
|
|
1812
|
+
*/
|
|
1813
|
+
const TRACE_LINKS_LIMIT = 500;
|
|
1814
|
+
/**
|
|
1487
1815
|
* The memory-session links, from either side.
|
|
1488
1816
|
*
|
|
1489
1817
|
* Both parameters absent is a refusal rather than an unbounded scan of every link ever recorded. A
|
|
@@ -1507,7 +1835,8 @@ const traceLinks = (params) => Effect.gen(function* () {
|
|
|
1507
1835
|
return { links: (yield* db.all(`SELECT l.path, l.session_id, l.prompt_id, l.turn_uuid, l.link_kind, l.at
|
|
1508
1836
|
FROM memory_session_links l
|
|
1509
1837
|
WHERE ${conditions.join(" AND ")}
|
|
1510
|
-
ORDER BY l.at DESC, l.path ASC
|
|
1838
|
+
ORDER BY l.at DESC, l.path ASC
|
|
1839
|
+
LIMIT ${TRACE_LINKS_LIMIT}`, values)).map((row) => ({
|
|
1511
1840
|
path: row.path,
|
|
1512
1841
|
sessionId: row.session_id,
|
|
1513
1842
|
promptId: row.prompt_id,
|
|
@@ -1572,27 +1901,46 @@ const countRows = (db, sql) => db.all(sql).pipe(Effect.map((rows) => Object.from
|
|
|
1572
1901
|
|
|
1573
1902
|
//#endregion
|
|
1574
1903
|
//#region apps/cli/dist/commands.js
|
|
1575
|
-
/**
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1904
|
+
/**
|
|
1905
|
+
* Flags every command accepts. Listed once so the manifest cannot drift from behavior.
|
|
1906
|
+
*
|
|
1907
|
+
* There is no `--json` flag: the typed JSON envelope is the only output the binary has, on every
|
|
1908
|
+
* command, so a flag for it would be parsed, advertised, and read by nothing. Logs go to stderr.
|
|
1909
|
+
*/
|
|
1910
|
+
const GLOBAL_FLAGS = [{
|
|
1911
|
+
name: "dense",
|
|
1912
|
+
type: "boolean",
|
|
1913
|
+
description: "Minify JSON and drop null fields, for pasting into a context window.",
|
|
1914
|
+
default: false
|
|
1915
|
+
}, {
|
|
1916
|
+
name: "repo",
|
|
1917
|
+
type: "string",
|
|
1918
|
+
description: "Path to the memory repo. Defaults to $MEMHTML_ROOT.",
|
|
1919
|
+
default: ""
|
|
1920
|
+
}];
|
|
1921
|
+
/**
|
|
1922
|
+
* The `--strict-path` help.
|
|
1923
|
+
*
|
|
1924
|
+
* It states the DEFAULT as well as the opt-in, because the default is the surprising half: a caller
|
|
1925
|
+
* reaching for this flag is a caller who just discovered that a malformed `--path` was re-derived, and
|
|
1926
|
+
* the help has to confirm that reading rather than leave it inferred. The refusal's code is named too,
|
|
1927
|
+
* since a caller branches on `code` and never on the prose.
|
|
1928
|
+
*/
|
|
1929
|
+
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.";
|
|
1930
|
+
/**
|
|
1931
|
+
* The `--facet` help, shared by every command that scopes on one.
|
|
1932
|
+
*
|
|
1933
|
+
* The composition rule is IN the help because it is a semantic contract rather than a convenience: a
|
|
1934
|
+
* caller who read `--facet a=1 --facet b=2` as "either" would act on a superset, and one who read
|
|
1935
|
+
* `--facet a=1 --facet a=2` as "both" would act on an empty result. Neither mistake is visible in
|
|
1936
|
+
* the rows that come back.
|
|
1937
|
+
*
|
|
1938
|
+
* The unitless clause is there for the same reason. `file_facets.numeric_value` exists, and offering
|
|
1939
|
+
* a numeric comparison over it would be offering an inequality on an unlabelled number — the unit
|
|
1940
|
+
* lives in the human phrasing beside the value, so the caller owns it, and it owns it by matching the
|
|
1941
|
+
* text the corpus holds.
|
|
1942
|
+
*/
|
|
1943
|
+
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.";
|
|
1596
1944
|
/** Flags every retrieval command shares, so `search` and `recall` cannot scope differently. */
|
|
1597
1945
|
const SCOPE_FLAGS = [
|
|
1598
1946
|
{
|
|
@@ -1618,6 +1966,12 @@ const SCOPE_FLAGS = [
|
|
|
1618
1966
|
type: "string",
|
|
1619
1967
|
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."
|
|
1620
1968
|
},
|
|
1969
|
+
{
|
|
1970
|
+
name: "facet",
|
|
1971
|
+
type: "string",
|
|
1972
|
+
description: FACET_FLAG,
|
|
1973
|
+
repeatable: true
|
|
1974
|
+
},
|
|
1621
1975
|
{
|
|
1622
1976
|
name: "include-archived",
|
|
1623
1977
|
type: "boolean",
|
|
@@ -1692,7 +2046,13 @@ const COMMANDS = [
|
|
|
1692
2046
|
{
|
|
1693
2047
|
name: "path",
|
|
1694
2048
|
type: "string",
|
|
1695
|
-
description: "An explicit path override.
|
|
2049
|
+
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>`."
|
|
2050
|
+
},
|
|
2051
|
+
{
|
|
2052
|
+
name: "strict-path",
|
|
2053
|
+
type: "boolean",
|
|
2054
|
+
description: STRICT_PATH_FLAG,
|
|
2055
|
+
default: false
|
|
1696
2056
|
},
|
|
1697
2057
|
{
|
|
1698
2058
|
name: "workspace",
|
|
@@ -1746,7 +2106,7 @@ const COMMANDS = [
|
|
|
1746
2106
|
{
|
|
1747
2107
|
name: "file",
|
|
1748
2108
|
type: "string",
|
|
1749
|
-
description: "The JSONL file to read. One complete JSON object per line. Omit it
|
|
2109
|
+
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."
|
|
1750
2110
|
},
|
|
1751
2111
|
{
|
|
1752
2112
|
name: "continue-on-error",
|
|
@@ -1792,11 +2152,31 @@ const COMMANDS = [
|
|
|
1792
2152
|
description: "Repo-root-relative path to the memory.",
|
|
1793
2153
|
required: true
|
|
1794
2154
|
}],
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
2155
|
+
/**
|
|
2156
|
+
* The whole provenance triple, because the `read` arm stamps the whole triple.
|
|
2157
|
+
* `memory_session_links` carries `prompt_id` and `turn_uuid` beside `session_id`, so a command
|
|
2158
|
+
* that declared only the session would record a coarser link for a read than the write path
|
|
2159
|
+
* records for the same turn, and one triple could not be threaded through a write-then-read
|
|
2160
|
+
* flow. MCP's `memory_read` narrows to `session_id`; this surface is the one an agent threads a
|
|
2161
|
+
* triple through.
|
|
2162
|
+
*/
|
|
2163
|
+
flags: [
|
|
2164
|
+
{
|
|
2165
|
+
name: "session-id",
|
|
2166
|
+
type: "string",
|
|
2167
|
+
description: "Records a `read` session link, so provenance is queryable both ways."
|
|
2168
|
+
},
|
|
2169
|
+
{
|
|
2170
|
+
name: "prompt-id",
|
|
2171
|
+
type: "string",
|
|
2172
|
+
description: "The prompt within that session."
|
|
2173
|
+
},
|
|
2174
|
+
{
|
|
2175
|
+
name: "turn-uuid",
|
|
2176
|
+
type: "string",
|
|
2177
|
+
description: "The turn within that session."
|
|
2178
|
+
}
|
|
2179
|
+
],
|
|
1800
2180
|
responseTypes: ["memory.detail"]
|
|
1801
2181
|
},
|
|
1802
2182
|
{
|
|
@@ -1877,6 +2257,16 @@ const COMMANDS = [
|
|
|
1877
2257
|
name: "session-id",
|
|
1878
2258
|
type: "string",
|
|
1879
2259
|
description: "Records a `corrected` session link."
|
|
2260
|
+
},
|
|
2261
|
+
{
|
|
2262
|
+
name: "prompt-id",
|
|
2263
|
+
type: "string",
|
|
2264
|
+
description: "The prompt within that session."
|
|
2265
|
+
},
|
|
2266
|
+
{
|
|
2267
|
+
name: "turn-uuid",
|
|
2268
|
+
type: "string",
|
|
2269
|
+
description: "The turn within that session."
|
|
1880
2270
|
}
|
|
1881
2271
|
],
|
|
1882
2272
|
responseTypes: ["memory.corrected"]
|
|
@@ -1909,22 +2299,42 @@ const COMMANDS = [
|
|
|
1909
2299
|
summary: "The memory graph around one path, to a fixed depth of at most two hops.",
|
|
1910
2300
|
args: [{
|
|
1911
2301
|
name: "path",
|
|
1912
|
-
description: "The center of the neighborhood.",
|
|
2302
|
+
description: "The center of the neighborhood.",
|
|
2303
|
+
required: true
|
|
2304
|
+
}],
|
|
2305
|
+
flags: [
|
|
2306
|
+
{
|
|
2307
|
+
name: "depth",
|
|
2308
|
+
type: "int",
|
|
2309
|
+
description: "1 or 2. Never more.",
|
|
2310
|
+
default: 1
|
|
2311
|
+
},
|
|
2312
|
+
{
|
|
2313
|
+
name: "limit",
|
|
2314
|
+
type: "int",
|
|
2315
|
+
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.",
|
|
2316
|
+
default: 200
|
|
2317
|
+
},
|
|
2318
|
+
{
|
|
2319
|
+
name: "rel",
|
|
2320
|
+
type: "string",
|
|
2321
|
+
description: "Restrict to these rels. Repeatable.",
|
|
2322
|
+
values: MEMORY_RELS,
|
|
2323
|
+
repeatable: true
|
|
2324
|
+
}
|
|
2325
|
+
],
|
|
2326
|
+
responseTypes: ["memory.neighbors"]
|
|
2327
|
+
},
|
|
2328
|
+
{
|
|
2329
|
+
name: "resolve",
|
|
2330
|
+
summary: "Follow a possibly-moved path forward to the memory that carries the fact now.",
|
|
2331
|
+
args: [{
|
|
2332
|
+
name: "path",
|
|
2333
|
+
description: "The path a receipt, citation, or older answer recorded.",
|
|
1913
2334
|
required: true
|
|
1914
2335
|
}],
|
|
1915
|
-
flags: [
|
|
1916
|
-
|
|
1917
|
-
type: "int",
|
|
1918
|
-
description: "1 or 2. Never more.",
|
|
1919
|
-
default: 1
|
|
1920
|
-
}, {
|
|
1921
|
-
name: "rel",
|
|
1922
|
-
type: "string",
|
|
1923
|
-
description: "Restrict to these rels. Repeatable.",
|
|
1924
|
-
values: MEMORY_RELS,
|
|
1925
|
-
repeatable: true
|
|
1926
|
-
}],
|
|
1927
|
-
responseTypes: ["memory.neighbors"]
|
|
2336
|
+
flags: [],
|
|
2337
|
+
responseTypes: ["memory.resolved"]
|
|
1928
2338
|
},
|
|
1929
2339
|
{
|
|
1930
2340
|
name: "archive",
|
|
@@ -1948,7 +2358,8 @@ const COMMANDS = [
|
|
|
1948
2358
|
args: [{
|
|
1949
2359
|
name: "path",
|
|
1950
2360
|
description: "A memory path. Repeat the argument for more.",
|
|
1951
|
-
required: true
|
|
2361
|
+
required: true,
|
|
2362
|
+
repeatable: true
|
|
1952
2363
|
}],
|
|
1953
2364
|
flags: [{
|
|
1954
2365
|
name: "signal",
|
|
@@ -1961,7 +2372,7 @@ const COMMANDS = [
|
|
|
1961
2372
|
},
|
|
1962
2373
|
{
|
|
1963
2374
|
name: "list",
|
|
1964
|
-
summary: "Page through the corpus by type, workspace, tag, entity, or PARA bucket.",
|
|
2375
|
+
summary: "Page through the corpus by type, workspace, tag, entity, facet, or PARA bucket.",
|
|
1965
2376
|
args: [],
|
|
1966
2377
|
flags: [
|
|
1967
2378
|
{
|
|
@@ -1985,6 +2396,12 @@ const COMMANDS = [
|
|
|
1985
2396
|
type: "string",
|
|
1986
2397
|
description: "One `type:name` entity reference."
|
|
1987
2398
|
},
|
|
2399
|
+
{
|
|
2400
|
+
name: "facet",
|
|
2401
|
+
type: "string",
|
|
2402
|
+
description: FACET_FLAG,
|
|
2403
|
+
repeatable: true
|
|
2404
|
+
},
|
|
1988
2405
|
{
|
|
1989
2406
|
name: "para",
|
|
1990
2407
|
type: "string",
|
|
@@ -2016,6 +2433,31 @@ const COMMANDS = [
|
|
|
2016
2433
|
],
|
|
2017
2434
|
responseTypes: ["memory.list"]
|
|
2018
2435
|
},
|
|
2436
|
+
{
|
|
2437
|
+
name: "entity activity",
|
|
2438
|
+
summary: "Every entity with its file count and its last activity, newest first. Report only.",
|
|
2439
|
+
args: [],
|
|
2440
|
+
flags: [
|
|
2441
|
+
{
|
|
2442
|
+
name: "type",
|
|
2443
|
+
type: "string",
|
|
2444
|
+
description: "Restrict to one entity type, e.g. `service`. The half before the colon in a `type:name` reference."
|
|
2445
|
+
},
|
|
2446
|
+
{
|
|
2447
|
+
name: "limit",
|
|
2448
|
+
type: "int",
|
|
2449
|
+
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.",
|
|
2450
|
+
default: 50
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
name: "include-archived",
|
|
2454
|
+
type: "boolean",
|
|
2455
|
+
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.",
|
|
2456
|
+
default: false
|
|
2457
|
+
}
|
|
2458
|
+
],
|
|
2459
|
+
responseTypes: ["entity.activity"]
|
|
2460
|
+
},
|
|
2019
2461
|
(
|
|
2020
2462
|
/**
|
|
2021
2463
|
* The task family: CRUDL over the 10th memory type, without retrieval.
|
|
@@ -2245,12 +2687,13 @@ const COMMANDS = [
|
|
|
2245
2687
|
{
|
|
2246
2688
|
name: "sleep run",
|
|
2247
2689
|
/**
|
|
2248
|
-
*
|
|
2249
|
-
* and
|
|
2250
|
-
* beside
|
|
2251
|
-
*
|
|
2690
|
+
* Both counts are `SLEEP_PHASES.length`, never typed. A phase added to that list moves this
|
|
2691
|
+
* summary and the `--phases` description below together, so neither can assert a number the list
|
|
2692
|
+
* printed beside it contradicts. A hand-typed count is not symmetrically caught: the doc drift
|
|
2693
|
+
* gate compares generated bytes, so it fails only on the string that also prints the names, and a
|
|
2694
|
+
* stale number in the other one ships.
|
|
2252
2695
|
*/
|
|
2253
|
-
summary: `The
|
|
2696
|
+
summary: `The curation cycle: ${SLEEP_PHASES.length} phases, each an isolated commit on a review branch.`,
|
|
2254
2697
|
args: [],
|
|
2255
2698
|
flags: [
|
|
2256
2699
|
{
|
|
@@ -2272,7 +2715,7 @@ const COMMANDS = [
|
|
|
2272
2715
|
{
|
|
2273
2716
|
name: "deep",
|
|
2274
2717
|
type: "boolean",
|
|
2275
|
-
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
|
|
2718
|
+
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.",
|
|
2276
2719
|
default: false
|
|
2277
2720
|
},
|
|
2278
2721
|
{
|
|
@@ -2333,6 +2776,13 @@ const COMMANDS = [
|
|
|
2333
2776
|
flags: [],
|
|
2334
2777
|
responseTypes: ["sleep.report"]
|
|
2335
2778
|
},
|
|
2779
|
+
{
|
|
2780
|
+
name: "sleep plan",
|
|
2781
|
+
summary: "Would a run change anything? Read the signals from index counts, running no phase.",
|
|
2782
|
+
args: [],
|
|
2783
|
+
flags: [],
|
|
2784
|
+
responseTypes: ["sleep.plan"]
|
|
2785
|
+
},
|
|
2336
2786
|
{
|
|
2337
2787
|
name: "status",
|
|
2338
2788
|
summary: "Corpus health: HEAD, dirty state, counts by type, edges, index freshness.",
|
|
@@ -2376,6 +2826,11 @@ const COMMANDS = [
|
|
|
2376
2826
|
type: "int",
|
|
2377
2827
|
description: "The fixture corpus seed. A failing run is reproducible from this number."
|
|
2378
2828
|
},
|
|
2829
|
+
{
|
|
2830
|
+
name: "now",
|
|
2831
|
+
type: "int",
|
|
2832
|
+
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."
|
|
2833
|
+
},
|
|
2379
2834
|
{
|
|
2380
2835
|
name: "size",
|
|
2381
2836
|
type: "int",
|
|
@@ -2436,7 +2891,7 @@ const COMMANDS = [
|
|
|
2436
2891
|
{
|
|
2437
2892
|
name: "file",
|
|
2438
2893
|
type: "string",
|
|
2439
|
-
description: "The script to run, as a path on the HOST. Omit it
|
|
2894
|
+
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`."
|
|
2440
2895
|
},
|
|
2441
2896
|
{
|
|
2442
2897
|
name: "script",
|
|
@@ -2489,7 +2944,7 @@ const COMMANDS = [
|
|
|
2489
2944
|
},
|
|
2490
2945
|
{
|
|
2491
2946
|
name: "serve mcp",
|
|
2492
|
-
summary: "Run the `memhtml-mcp` stdio server:
|
|
2947
|
+
summary: "Run the `memhtml-mcp` stdio server: 15 tools and 3 resources over this same repo.",
|
|
2493
2948
|
args: [],
|
|
2494
2949
|
flags: [],
|
|
2495
2950
|
responseTypes: ["serve.exit"]
|
|
@@ -2524,12 +2979,12 @@ const GUIDE = [
|
|
|
2524
2979
|
},
|
|
2525
2980
|
{
|
|
2526
2981
|
topic: "write-surfaces",
|
|
2527
|
-
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
|
|
2982
|
+
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."
|
|
2528
2983
|
},
|
|
2529
2984
|
{
|
|
2530
2985
|
topic: "when-to-batch",
|
|
2531
2986
|
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:
|
|
2532
|
-
${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.`
|
|
2987
|
+
${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.`
|
|
2533
2988
|
},
|
|
2534
2989
|
{
|
|
2535
2990
|
topic: "conflicts",
|
|
@@ -2541,7 +2996,7 @@ ${GUIDE_OP_EXAMPLE}\n\`op\` is \`write\` (the only verb in the vocabulary today)
|
|
|
2541
2996
|
},
|
|
2542
2997
|
{
|
|
2543
2998
|
topic: "code-mode",
|
|
2544
|
-
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
|
|
2999
|
+
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."
|
|
2545
3000
|
}
|
|
2546
3001
|
];
|
|
2547
3002
|
const GUIDE_TOPICS = GUIDE.map((block) => block.topic);
|
|
@@ -2552,22 +3007,22 @@ const GUIDE_TOPICS = GUIDE.map((block) => block.topic);
|
|
|
2552
3007
|
* Prose → claim derivation: the single implementation both write doors use.
|
|
2553
3008
|
*
|
|
2554
3009
|
* The tools take `{title, body}` because that is what a model produces, and the format needs a
|
|
2555
|
-
* `<mark>` claim plus one `<p>` per paragraph. Turning the first into the second is a text
|
|
2556
|
-
* and it
|
|
2557
|
-
*
|
|
2558
|
-
*
|
|
2559
|
-
*
|
|
3010
|
+
* `<mark>` claim plus one `<p>` per paragraph. Turning the first into the second is a text
|
|
3011
|
+
* heuristic, and it must have exactly one copy: `apps/mcp` and `apps/cli` both import this module,
|
|
3012
|
+
* because a sentence-splitting rule maintained per door lets `memhtml apply` and
|
|
3013
|
+
* `memory_write_batch` derive different claims from the same body, making the gist of a memory
|
|
3014
|
+
* depend on which door wrote it.
|
|
2560
3015
|
*
|
|
2561
3016
|
* It does not live in `@memhtml/html`, which owns markup and the format's own rules. "Where does a
|
|
2562
3017
|
* sentence end" is a guess about natural-language prose, and the format states no such constraint. It
|
|
2563
3018
|
* is not in `operations.ts` either, because that module holds the use cases both doors call, and this
|
|
2564
3019
|
* is a text helper they apply before calling one.
|
|
2565
3020
|
*
|
|
2566
|
-
* The derivation is defense in depth
|
|
2567
|
-
*
|
|
2568
|
-
*
|
|
2569
|
-
*
|
|
2570
|
-
*
|
|
3021
|
+
* The derivation is defense in depth: `@memhtml/html` constraint 1 rejects an empty `<mark>`
|
|
3022
|
+
* outright, so a door that skipped this would be stopped by the store's render gate rather than
|
|
3023
|
+
* landing a file with an empty `files.gist`. What this module carries is the authoring convenience
|
|
3024
|
+
* the doors exist to provide: a JSONL line and an MCP call carry no `claim` field, so the door
|
|
3025
|
+
* derives one instead of asking an author to restate the body's first sentence.
|
|
2571
3026
|
*/
|
|
2572
3027
|
/**
|
|
2573
3028
|
* Split prose into paragraphs on blank lines, dropping the empties. Inside a fenced code block a
|
|
@@ -2650,11 +3105,20 @@ const LIST_FIELDS = {
|
|
|
2650
3105
|
entity: "entities",
|
|
2651
3106
|
entities: "entities"
|
|
2652
3107
|
};
|
|
3108
|
+
/**
|
|
3109
|
+
* Fields that must be a JSON boolean.
|
|
3110
|
+
*
|
|
3111
|
+
* Its own table because {@link SCALAR_FIELDS}' decode refuses anything that is not a string, and a
|
|
3112
|
+
* boolean spelled `"true"` is a different value from `true` on this wire. A caller that sent the
|
|
3113
|
+
* string would otherwise get a strict-path ask that reads as satisfied and behaves as absent.
|
|
3114
|
+
*/
|
|
3115
|
+
const BOOLEAN_FIELDS = { strict_path: "strictPath" };
|
|
2653
3116
|
/** `op` is the discriminator rather than a `WriteParams` field, so it is legal and never mapped. */
|
|
2654
3117
|
const KNOWN_FIELDS = /* @__PURE__ */ new Set([
|
|
2655
3118
|
"op",
|
|
2656
3119
|
...Object.keys(SCALAR_FIELDS),
|
|
2657
|
-
...Object.keys(LIST_FIELDS)
|
|
3120
|
+
...Object.keys(LIST_FIELDS),
|
|
3121
|
+
...Object.keys(BOOLEAN_FIELDS)
|
|
2658
3122
|
]);
|
|
2659
3123
|
|
|
2660
3124
|
//#endregion
|
|
@@ -2670,6 +3134,25 @@ const nowSecond = Effect.clockWith((clock) => Effect.map(clock.currentTimeMillis
|
|
|
2670
3134
|
//#region apps/cli/dist/run.js
|
|
2671
3135
|
const KNOWN_FLAGS = /* @__PURE__ */ new Set([...GLOBAL_FLAGS.map((flag) => flag.name), ...COMMANDS.flatMap((command) => command.flags.map((flag) => flag.name))]);
|
|
2672
3136
|
/**
|
|
3137
|
+
* Every flag name the spec table declares, mapped to the type it declares.
|
|
3138
|
+
*
|
|
3139
|
+
* **Only a `string` or `int` flag consumes the next argv token as its value**, and the two kinds it
|
|
3140
|
+
* excludes are excluded for different reasons:
|
|
3141
|
+
*
|
|
3142
|
+
* - A `boolean` flag takes `--flag`, `--flag=value`, or `--no-flag`, so the token after it stays
|
|
3143
|
+
* positional and can be the command: `memhtml --dense list` is the `list` command, not an empty
|
|
3144
|
+
* command carrying `dense: "list"`.
|
|
3145
|
+
* - A flag the table does not declare has no type to consult, and eating the token would swallow the
|
|
3146
|
+
* command name — `memhtml --nope list` would answer the manifest at exit 0 instead of refusing an
|
|
3147
|
+
* unknown flag. Leaving the token positional lets the command reach {@link validate}, which is
|
|
3148
|
+
* where an unknown flag becomes exit 2.
|
|
3149
|
+
*
|
|
3150
|
+
* The map is name-keyed across every command even though validation is per-command, because the
|
|
3151
|
+
* parser runs before the command is known. That is sound only while one name carries ONE type
|
|
3152
|
+
* everywhere, which is a property of the table `cli.test.ts` enforces rather than a hope.
|
|
3153
|
+
*/
|
|
3154
|
+
const FLAG_TYPES = new Map([...GLOBAL_FLAGS, ...COMMANDS.flatMap((command) => command.flags)].map((flag) => [flag.name, flag.type]));
|
|
3155
|
+
/**
|
|
2673
3156
|
* The two-word command names, longest first.
|
|
2674
3157
|
*
|
|
2675
3158
|
* A subcommand is matched greedily so `index status` beats `index`, and the leftover tokens become
|
|
@@ -2687,15 +3170,16 @@ const today = Effect.clockWith((clock) => Effect.map(clock.currentTimeMillis, (m
|
|
|
2687
3170
|
* The MCP wire failure: one error class, declared on every tool, whose `.message` IS the response an
|
|
2688
3171
|
* agent reads.
|
|
2689
3172
|
*
|
|
2690
|
-
* **Why a declared class at all.** `McpServer`
|
|
2691
|
-
*
|
|
2692
|
-
* `AiError
|
|
2693
|
-
* failed due to an internal server error" unless its reason is
|
|
2694
|
-
* the tool's own `failureSchema` accepts takes branch 2,
|
|
2695
|
-
* passes the text through verbatim.
|
|
2696
|
-
*
|
|
2697
|
-
*
|
|
2698
|
-
*
|
|
3173
|
+
* **Why a declared class at all.** `McpServer`'s registration of a toolkit tool wraps the handler in
|
|
3174
|
+
* ONE `Effect.catch` with three branches (effect 4.0.0-rc.109), and only one of them lets prose
|
|
3175
|
+
* through. An `AiError` takes branch 1 and is rewritten to `INTERNAL_TOOL_ERROR_MESSAGE`, "Tool
|
|
3176
|
+
* execution failed due to an internal server error", unless its reason is
|
|
3177
|
+
* `ToolParameterValidationError`. A value the tool's own `failureSchema` accepts takes branch 2,
|
|
3178
|
+
* where `error instanceof Error ? error.message` passes the text through verbatim. Anything else
|
|
3179
|
+
* takes branch 3 and is rewritten. The schema declaration is therefore the whole difference between
|
|
3180
|
+
* an agent that can recover and an agent that reads a sentence with no content in it.
|
|
3181
|
+
* `Effect.tapCause(Effect.logError)` runs before all three branches, so stderr logging is unaffected
|
|
3182
|
+
* either way.
|
|
2699
3183
|
*
|
|
2700
3184
|
* **Why the message is composed at construction.** `McpServer` reads `.message` and nothing else.
|
|
2701
3185
|
* `code` and `suggestions` are not on the wire as fields, because MCP's tool-error channel is one
|
|
@@ -2724,7 +3208,7 @@ const text = (value) => typeof value === "string" ? value : void 0;
|
|
|
2724
3208
|
/**
|
|
2725
3209
|
* What to do about a failure, phrased as calls this agent can actually make.
|
|
2726
3210
|
*
|
|
2727
|
-
* The reader is an LLM mid-task holding
|
|
3211
|
+
* The reader is an LLM mid-task holding fifteen tools and no shell. `suggestionsFor` in
|
|
2728
3212
|
* `apps/cli/src/errors.ts:115-137` answers the same question for a human at a prompt and answers it in
|
|
2729
3213
|
* `memhtml` commands and `git` invocations, every one of which is unreachable from here. A suggestion
|
|
2730
3214
|
* an agent cannot execute costs more than none: it spends the model's attention on a plan that ends in
|
|
@@ -2748,7 +3232,11 @@ const text = (value) => typeof value === "string" ? value : void 0;
|
|
|
2748
3232
|
const mcpSuggestionsFor = (error) => {
|
|
2749
3233
|
if (!isTagged(error)) return [];
|
|
2750
3234
|
switch (error._tag) {
|
|
2751
|
-
case "PathNotFound": return [
|
|
3235
|
+
case "PathNotFound": return [
|
|
3236
|
+
"call memory_resolve on the path you cited — a correction or an eviction may have moved it",
|
|
3237
|
+
"call memory_search with a query for what you were looking for",
|
|
3238
|
+
"call memory_list to page the corpus by type or workspace"
|
|
3239
|
+
];
|
|
2752
3240
|
case "WriteConflict": return [`call memory_read on ${text(error.path) ?? "that path"} to get the current content`, "re-apply your change to that content and retry the write"];
|
|
2753
3241
|
case "DuplicateContent": return [`call memory_read on ${text(error.existingPath) ?? "that path"} — your content already lives there`, "nothing was written and no commit was made, so there is nothing to clean up"];
|
|
2754
3242
|
case "InvalidMemory": return ["fix the violated constraint named above and call the same tool again", "nothing was written and no commit was made — the store refused at the render gate"];
|
|
@@ -2771,6 +3259,17 @@ const mcpSuggestionsFor = (error) => {
|
|
|
2771
3259
|
*/
|
|
2772
3260
|
const sentence = (reason) => /[.!?]$/.test(reason) ? reason : `${reason}.`;
|
|
2773
3261
|
/**
|
|
3262
|
+
* The three parts as the one string the protocol carries: code, then reason, then suggestions.
|
|
3263
|
+
*
|
|
3264
|
+
* One function so the shape is one shape. Every caller here folds the same three parts, and the
|
|
3265
|
+
* `ERR_*` prefix followed by a colon is what lets a consumer read the code back out of prose.
|
|
3266
|
+
*/
|
|
3267
|
+
const compose = (code, reason, suggestions) => new ToolFailure({
|
|
3268
|
+
code,
|
|
3269
|
+
suggestions,
|
|
3270
|
+
message: suggestions.length === 0 ? `${code}: ${sentence(reason)}` : `${code}: ${sentence(reason)} Try: ${suggestions.join("; ")}`
|
|
3271
|
+
});
|
|
3272
|
+
/**
|
|
2774
3273
|
* A typed domain failure as the wire failure.
|
|
2775
3274
|
*
|
|
2776
3275
|
* Total by construction, three times over: `codeFor` maps an unknown `_tag` to `ERR_UNKNOWN`,
|
|
@@ -2799,14 +3298,41 @@ const toToolFailure = (error) => {
|
|
|
2799
3298
|
* handler outside the one error translation is a handler that can leak an untranslated failure.
|
|
2800
3299
|
*/
|
|
2801
3300
|
if (error instanceof ToolFailure) return error;
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
3301
|
+
return compose(codeFor(error), messageFor(error), mcpSuggestionsFor(error));
|
|
3302
|
+
};
|
|
3303
|
+
/**
|
|
3304
|
+
* A refusal the RESOURCE surface owns, composed into the same wire failure a tool call produces.
|
|
3305
|
+
*
|
|
3306
|
+
* Three refusals belong to that surface and to no use case: a URI outside the published template, a
|
|
3307
|
+
* run id with no committed report behind it, and a defect at the boundary. None is a typed error a
|
|
3308
|
+
* use case raised, so `toToolFailure` has nothing to translate, and each is composed here for the
|
|
3309
|
+
* reason `batchAbortFailure` is — the message shape is one shape, and a second hand-written copy of
|
|
3310
|
+
* it drifts the first time the format moves.
|
|
3311
|
+
*
|
|
3312
|
+
* The reason and the suggestions come from the caller because a resource read's recovery depends on
|
|
3313
|
+
* WHICH resource refused, while `mcpSuggestionsFor` answers a different question (what to do about a
|
|
3314
|
+
* given error tag). The rule they hold to is the same one: every string names a tool in the toolkit
|
|
3315
|
+
* or an action inside the caller's own control.
|
|
3316
|
+
*/
|
|
3317
|
+
const resourceFailure = (code, reason, suggestions) => compose(code, reason, suggestions);
|
|
3318
|
+
/**
|
|
3319
|
+
* A failure as the JSON-RPC error the RESOURCE surface has, carrying the tool surface's own prose.
|
|
3320
|
+
*
|
|
3321
|
+
* `resources/read` has no per-resource failure schema to declare, so a failed read reaches the client
|
|
3322
|
+
* as a JSON-RPC error object whose `message` is the only field an agent reads. `toToolFailure`
|
|
3323
|
+
* composes that string — same code vocabulary, same reason discipline, same executable suggestions —
|
|
3324
|
+
* and this function decides only which error class carries it: `InvalidParams` when the URI names
|
|
3325
|
+
* something that is not there, which is the code `McpServer.findResource` itself returns for a URI no
|
|
3326
|
+
* template matched, and `InternalError` for every other failure.
|
|
3327
|
+
*
|
|
3328
|
+
* Nothing here reads the cause's own text. `Effect.orDie` on a resource handler is what puts
|
|
3329
|
+
* `Cause.prettyErrors(cause)[0].message` on the wire, and that message carries the ABSOLUTE
|
|
3330
|
+
* filesystem path the read was attempted at, plus the stack that reached it. A resource is the one
|
|
3331
|
+
* surface with no tool-response envelope to hide behind, so the sanitizing has to happen here.
|
|
3332
|
+
*/
|
|
3333
|
+
const toResourceFailure = (error) => {
|
|
3334
|
+
const failure = toToolFailure(error);
|
|
3335
|
+
return failure.code === "ERR_PATH_NOT_FOUND" ? new McpSchema.InvalidParams({ message: failure.message }) : new McpSchema.InternalError({ message: failure.message });
|
|
2810
3336
|
};
|
|
2811
3337
|
/**
|
|
2812
3338
|
* An atomic batch's abort as the wire failure, naming the op that caused it.
|
|
@@ -2845,10 +3371,292 @@ const batchAbortFailure = (index, code, reason) => {
|
|
|
2845
3371
|
});
|
|
2846
3372
|
};
|
|
2847
3373
|
|
|
3374
|
+
//#endregion
|
|
3375
|
+
//#region apps/mcp/src/resources.ts
|
|
3376
|
+
/**
|
|
3377
|
+
* The three resources: design.md §8's two, plus one version-pinned citation grain.
|
|
3378
|
+
*
|
|
3379
|
+
* A resource is for CITATION-grade drill-down: a client that got a path from `memory_search` can
|
|
3380
|
+
* fetch `memhtml://file/<path>` and show a human the file behind an answer, without spending a tool call
|
|
3381
|
+
* and without the tool response having had to carry the whole body.
|
|
3382
|
+
*
|
|
3383
|
+
* `memhtml://file/{path}` and `memhtml://at/{commit}/{path}` are the same read at two grains, and the
|
|
3384
|
+
* difference is whether the answer can move. A path is the id of a memory and a correction rewrites
|
|
3385
|
+
* what lives at it, so a receipt citing a path alone cites whatever is there when someone follows it.
|
|
3386
|
+
* The pinned form names a commit as well, and a commit sha is immutable, so the bytes behind that URI
|
|
3387
|
+
* are the bytes the receipt was written against. This is a grain only a git-native store can offer, and
|
|
3388
|
+
* `memory_resolve` is the other half: one URI says what was true, the other says where the fact went.
|
|
3389
|
+
*/
|
|
3390
|
+
/** The URI scheme every resource publishes under, matching `SERVER_NAME`. */
|
|
3391
|
+
const SCHEME = "memhtml";
|
|
3392
|
+
/**
|
|
3393
|
+
* The router pattern that matches one resource's URIs, and why it is spelled this way.
|
|
3394
|
+
*
|
|
3395
|
+
* `McpServer` matches a `resources/read` URI with find-my-way (`effect/unstable/http/FindMyWay`,
|
|
3396
|
+
* effect 4.0.0-rc.109), and two of that router's rules decide this string:
|
|
3397
|
+
*
|
|
3398
|
+
* - A single `:` opens a NAMED PARAMETER, and `::` is the escape for a literal colon. The scheme's
|
|
3399
|
+
* colon therefore has to be doubled; left single, `memhtml:` registers a parameter named `""`.
|
|
3400
|
+
* - A named parameter's value ENDS AT THE NEXT `/`, so `:path` matches exactly one segment and cannot
|
|
3401
|
+
* reach `areas/oncall/x.html`. Every memory path has at least two segments and an archived one has
|
|
3402
|
+
* at least four, so a single-segment route leaves the resource unreachable in normal use. `*` is the
|
|
3403
|
+
* rest parameter, the only construct that matches across `/`, and the router requires it to be the
|
|
3404
|
+
* LAST character of the pattern.
|
|
3405
|
+
*
|
|
3406
|
+
* The captured value does not arrive through the parameter array. `McpServer` folds a matched route's
|
|
3407
|
+
* parameters into a POSITIONAL array by `Number(name)`, and the rest parameter's name is `*`, so
|
|
3408
|
+
* `Number("*")` is `NaN` and the slot is never filled. The handler therefore reads its one parameter
|
|
3409
|
+
* out of the URI, which the match always carries — see {@link capturedOf}.
|
|
3410
|
+
*/
|
|
3411
|
+
const routerPathFor = (section) => `${SCHEME}:://${section}/*`;
|
|
3412
|
+
/** The literal head of every URI a section serves, up to and including its parameter's slash. */
|
|
3413
|
+
const prefixOf = (section) => `${SCHEME}://${section}/`;
|
|
3414
|
+
/**
|
|
3415
|
+
* The part of a URI after `memhtml://<section>/`, percent-escapes decoded, or a refusal.
|
|
3416
|
+
*
|
|
3417
|
+
* ONE decode covers both spellings a client can send. `areas/oncall/x.html` passes through unchanged,
|
|
3418
|
+
* and `areas%2Foncall%2Fx.html` decodes to the same string, so a client that escaped the separators
|
|
3419
|
+
* and a client that did not name the same resource.
|
|
3420
|
+
*
|
|
3421
|
+
* The prefix has to be present VERBATIM. The router tolerates repeated slashes and this does not, so
|
|
3422
|
+
* `memhtml:///file/x.html` matches the route and is then refused here, rather than sliced at an offset
|
|
3423
|
+
* a character away from the one that matched.
|
|
3424
|
+
*
|
|
3425
|
+
* The `decodeURIComponent` guard is a boundary rather than a branch the route can reach today: the
|
|
3426
|
+
* router decodes a whole URI before it matches anything, so `%zz` matches nothing and never arrives.
|
|
3427
|
+
* An undecodable value that did arrive is a URI this server cannot read rather than a file that is
|
|
3428
|
+
* missing, and it is refused with the URI echoed back for the caller to compare against what it sent.
|
|
3429
|
+
*/
|
|
3430
|
+
const capturedOf = (uri, section, refuse) => {
|
|
3431
|
+
const prefix = prefixOf(section);
|
|
3432
|
+
if (!uri.startsWith(prefix)) return Effect.fail(refuse(uri));
|
|
3433
|
+
try {
|
|
3434
|
+
return Effect.succeed(decodeURIComponent(uri.slice(prefix.length)));
|
|
3435
|
+
} catch {
|
|
3436
|
+
return Effect.fail(refuse(uri));
|
|
3437
|
+
}
|
|
3438
|
+
};
|
|
3439
|
+
/**
|
|
3440
|
+
* One resource template, registered on the server.
|
|
3441
|
+
*
|
|
3442
|
+
* `McpServer.addResourceTemplate` rather than the `McpServer.resource` tagged template, because the
|
|
3443
|
+
* tagged template compiles its parameters to named router parameters and those stop at a `/` — see
|
|
3444
|
+
* {@link routerPathFor}. Registering the route directly is what lets one published template serve a
|
|
3445
|
+
* path of any depth.
|
|
3446
|
+
*
|
|
3447
|
+
* **Every failure is sanitized here, and the handler never dies.** A defect becomes a stated refusal
|
|
3448
|
+
* through `catchDefect` and a typed failure becomes one through `toResourceFailure`, both AFTER
|
|
3449
|
+
* `tapCause` has put the real cause on stderr, where an operator reads it. An `Effect.orDie` in its
|
|
3450
|
+
* place hands the client `Cause.prettyErrors(cause)[0].message`: an absolute filesystem path for a
|
|
3451
|
+
* missing sleep report, and a `PathNotFound` stripped of its `ERR_*` code and its suggestions.
|
|
3452
|
+
*
|
|
3453
|
+
* `Layer.provide(McpServer.layer)` mirrors what `McpServer.resource` does with the same static layer
|
|
3454
|
+
* reference, so the registry this writes into is the one `layerStdio` serves from: a layer is
|
|
3455
|
+
* memoized per build, so naming it here twice is naming it once.
|
|
3456
|
+
*/
|
|
3457
|
+
const templateLayer = (spec) => Layer.effectDiscard(Effect.gen(function* () {
|
|
3458
|
+
const registry = yield* McpServer.McpServer;
|
|
3459
|
+
const services = yield* Effect.context();
|
|
3460
|
+
yield* registry.addResourceTemplate({
|
|
3461
|
+
template: new McpSchema.ResourceTemplate({
|
|
3462
|
+
uriTemplate: spec.uriTemplate,
|
|
3463
|
+
name: spec.name,
|
|
3464
|
+
description: spec.description,
|
|
3465
|
+
mimeType: spec.mimeType
|
|
3466
|
+
}),
|
|
3467
|
+
routerPath: routerPathFor(spec.section),
|
|
3468
|
+
annotations: Context.empty(),
|
|
3469
|
+
completions: {},
|
|
3470
|
+
handle: (uri) => capturedOf(uri, spec.section, spec.refuse).pipe(Effect.flatMap((captured) => spec.read(uri, captured)), Effect.map((text) => ({ contents: [{
|
|
3471
|
+
uri,
|
|
3472
|
+
mimeType: spec.mimeType,
|
|
3473
|
+
text
|
|
3474
|
+
}] })), Effect.tapCause(Effect.logError), Effect.catchDefect(() => Effect.fail(resourceFailure("ERR_UNKNOWN", "the server could not read that resource", ["retry the read once", "report this to the operator if it persists — the server's own log carries the detail"]))), Effect.mapError(toResourceFailure), Effect.provideContext(services))
|
|
3475
|
+
});
|
|
3476
|
+
})).pipe(Layer.provide(McpServer.McpServer.layer));
|
|
3477
|
+
/** `memhtml://file/{path}`: nothing readable behind this URI. */
|
|
3478
|
+
const fileRefusal = (uri) => resourceFailure("ERR_PATH_NOT_FOUND", `nothing to read at ${uri}`, [
|
|
3479
|
+
`re-request it as ${prefixOf("file")}<repo-root-relative path>, the form resources/templates publishes`,
|
|
3480
|
+
"call memory_resolve on that path — a correction or an eviction may have moved the memory, and both are recorded",
|
|
3481
|
+
"call memory_search or memory_list for a path this corpus holds"
|
|
3482
|
+
]);
|
|
3483
|
+
/**
|
|
3484
|
+
* A memory file, by path.
|
|
3485
|
+
*
|
|
3486
|
+
* The BODY is returned, not the raw HTML file. A client asking a resource for a citation wants the
|
|
3487
|
+
* text a human reads; the markup is the storage format, and handing back a full document with a head
|
|
3488
|
+
* full of `memhtml-*` metas would spend a client's rendering budget on bookkeeping. The metadata is
|
|
3489
|
+
* available through `memory_read`, which is the tool for exactly that.
|
|
3490
|
+
*
|
|
3491
|
+
* A missing path fails the read rather than answering with an empty resource: a citation that
|
|
3492
|
+
* silently resolves to nothing is worse than one that says the file is gone.
|
|
3493
|
+
*
|
|
3494
|
+
* This read BUMPS salience, through the same `readMemory` the `memory_read` tool calls. The bump is
|
|
3495
|
+
* deliberate: the caller named one specific path, which is a chosen open. A client
|
|
3496
|
+
* fetching the file behind an answer is making the same statement an agent makes with `memory_read`,
|
|
3497
|
+
* and the plane should not be able to tell them apart.
|
|
3498
|
+
*
|
|
3499
|
+
* **`isValidMemoryPath` gates the path before the store sees it, and that is containment rather than
|
|
3500
|
+
* validation.** The rest parameter accepts `/`, so it also accepts `../../etc/passwd`, and the store's
|
|
3501
|
+
* reader joins a repo-relative path onto the git root without a traversal check of its own. The gate
|
|
3502
|
+
* refuses any path carrying a `.` or `..` segment, any path outside the four PARA buckets, and
|
|
3503
|
+
* anything not ending in `.html`, which is every memory path and nothing else.
|
|
3504
|
+
*/
|
|
3505
|
+
const FileResource = templateLayer({
|
|
3506
|
+
section: "file",
|
|
3507
|
+
uriTemplate: "memhtml://file/{path}",
|
|
3508
|
+
name: "Memory file",
|
|
3509
|
+
description: "One memory's title, claim, and body text, by repo-root-relative path. For showing a human the file behind an answer.",
|
|
3510
|
+
mimeType: "text/plain",
|
|
3511
|
+
refuse: fileRefusal,
|
|
3512
|
+
read: (uri, captured) => Effect.gen(function* () {
|
|
3513
|
+
if (!isValidMemoryPath(captured)) return yield* Effect.fail(fileRefusal(uri));
|
|
3514
|
+
const result = yield* readMemory(normalizePath(captured));
|
|
3515
|
+
return [
|
|
3516
|
+
`# ${result.doc.title}`,
|
|
3517
|
+
"",
|
|
3518
|
+
result.doc.article.gist,
|
|
3519
|
+
"",
|
|
3520
|
+
result.doc.article.bodyText
|
|
3521
|
+
].join("\n");
|
|
3522
|
+
})
|
|
3523
|
+
});
|
|
3524
|
+
/** `memhtml://sleep/{run-id}`: no committed report behind this URI. */
|
|
3525
|
+
const sleepRefusal = (uri) => resourceFailure("ERR_PATH_NOT_FOUND", `no sleep report at ${uri}`, ["call memory_status to read the id and the status of the last sleep run", "report this to the operator if memory_status names this run — its report never committed"]);
|
|
3526
|
+
/**
|
|
3527
|
+
* A sleep run's report, by run id.
|
|
3528
|
+
*
|
|
3529
|
+
* The report is a COMMITTED file under `.memhtml/sleep/`, so this resource reads the tree rather than the
|
|
3530
|
+
* database: the report is the durable artifact of a run and the `sleep_runs` row is reporting
|
|
3531
|
+
* convenience.
|
|
3532
|
+
*
|
|
3533
|
+
* **The filename comes from `reportFilename`, the function the sleep phase writes it with.** A run id
|
|
3534
|
+
* is `sleep/<YYYY-MM-DD>` and a `/` is not legal in a filename, so the producer folds the separator to
|
|
3535
|
+
* a hyphen and the file is `sleep-2026-08-02.html`. Deriving that here a second time is the
|
|
3536
|
+
* consumer-side reimplementation of a producer's naming rule that this repo forbids; importing it
|
|
3537
|
+
* means the two cannot disagree. It also contains the read for free, since folding every `/` leaves a
|
|
3538
|
+
* caller no way to name a directory.
|
|
3539
|
+
*
|
|
3540
|
+
* The run id is taken VERBATIM, in the `sleep/<date>` spelling `memory_status.last_sleep.run_id`
|
|
3541
|
+
* publishes, so the value a client copies out of a status call is the value this resource takes.
|
|
3542
|
+
*/
|
|
3543
|
+
const SleepResource = templateLayer({
|
|
3544
|
+
section: "sleep",
|
|
3545
|
+
uriTemplate: "memhtml://sleep/{run-id}",
|
|
3546
|
+
name: "Sleep run report",
|
|
3547
|
+
description: "One sleep run's committed HTML report: per-phase counts, commits, and what the run changed.",
|
|
3548
|
+
mimeType: "text/html",
|
|
3549
|
+
refuse: sleepRefusal,
|
|
3550
|
+
read: (uri, runId) => Effect.gen(function* () {
|
|
3551
|
+
const roots = yield* Roots;
|
|
3552
|
+
const html = yield* readFileOrNull(join(roots.memhtmlRoot, SLEEP_REPORTS_DIR, reportFilename(runId)));
|
|
3553
|
+
return html === null ? yield* Effect.fail(sleepRefusal(uri)) : html;
|
|
3554
|
+
})
|
|
3555
|
+
});
|
|
3556
|
+
/**
|
|
3557
|
+
* A commit sha, and nothing that can move.
|
|
3558
|
+
*
|
|
3559
|
+
* Seven to sixty-four lowercase hex characters: git's own abbreviation floor through the width of
|
|
3560
|
+
* SHA-256. `HEAD`, a branch name, and a tag are all REFUSED, and that refusal is the whole point of
|
|
3561
|
+
* this resource — a URI whose target can move is not a citation, and `memhtml://at/main/x.html` would
|
|
3562
|
+
* read as a pin while resolving to different bytes next week. A caller wanting the current bytes has
|
|
3563
|
+
* `memhtml://file/{path}`, which says so in its own name.
|
|
3564
|
+
*
|
|
3565
|
+
* It is also the containment on the argv: `lsTreeR` passes this value to `git ls-tree` as a positional,
|
|
3566
|
+
* and a value starting with `-` would be read as an option. Hex cannot.
|
|
3567
|
+
*/
|
|
3568
|
+
const COMMIT_SHA = /^[0-9a-f]{7,64}$/;
|
|
3569
|
+
/** `memhtml://at/{commit}/{path}`: no such path in that commit, or no such commit here. */
|
|
3570
|
+
const pinnedRefusal = (uri) => resourceFailure("ERR_PATH_NOT_FOUND", `nothing to read at ${uri}`, [
|
|
3571
|
+
`re-request it as ${prefixOf("at")}<commit-sha>/<repo-root-relative path>, the form resources/templates publishes`,
|
|
3572
|
+
"call memory_resolve for the path this memory occupies now, and read `indexed_commit` for a sha this repository holds",
|
|
3573
|
+
"a branch name or HEAD is refused on purpose: a citation names bytes that cannot move"
|
|
3574
|
+
]);
|
|
3575
|
+
/**
|
|
3576
|
+
* One memory's text AS OF a commit: a citation that cannot move.
|
|
3577
|
+
*
|
|
3578
|
+
* **Two segments after the section, split at the FIRST `/`.** The rest parameter captures
|
|
3579
|
+
* `<commit>/<path>` as one string, and the commit half cannot contain a slash while the path half must,
|
|
3580
|
+
* so the first separator is the only place the split can be. The published template names both holes,
|
|
3581
|
+
* which is what makes this route multi-segment BY CONSTRUCTION: `{path}` alone is at least two segments
|
|
3582
|
+
* for every memory and at least four for an archived one.
|
|
3583
|
+
*
|
|
3584
|
+
* **The bytes come out of git, not off disk.** `lsTreeR` resolves `<path>` in `<commit>`'s tree to a
|
|
3585
|
+
* blob sha and `catFileBatch` reads that object, so a path corrected, archived, or evicted since is
|
|
3586
|
+
* still readable at the commit that held it. Reading the worktree instead would answer with today's
|
|
3587
|
+
* file under yesterday's URI, which is the exact failure the pin exists to prevent.
|
|
3588
|
+
*
|
|
3589
|
+
* **The same text shape `memhtml://file/{path}` returns**, parsed from the historical bytes rather
|
|
3590
|
+
* than re-derived: title, claim, body. A client renders either grain the same way, and the only
|
|
3591
|
+
* difference between the two answers is which commit produced it.
|
|
3592
|
+
*
|
|
3593
|
+
* **This read does NOT bump salience, where `memhtml://file/{path}` does.** Salience counts a chosen
|
|
3594
|
+
* open of a memory, and `state.access` is keyed on PATH with no notion of a commit
|
|
3595
|
+
* (`packages/index/state-migrations/S0001_access.sql`), so a bump here would credit whatever occupies
|
|
3596
|
+
* that path today for a read of a version it may not even contain. Verifying a receipt is auditing, not
|
|
3597
|
+
* choosing, and rewarding audit traffic would let a heavily-cited-then-corrected memory outrank the
|
|
3598
|
+
* fact that replaced it. It is also why this resource declares `Store` and not `IndexRecorder`: the
|
|
3599
|
+
* dependency set makes the refusal structural.
|
|
3600
|
+
*
|
|
3601
|
+
* `isValidMemoryPath` gates the path for `FileResource`'s reason — the rest parameter accepts `/`, so
|
|
3602
|
+
* it accepts `../../etc/passwd`, and `git ls-tree` would happily resolve a path outside the four PARA
|
|
3603
|
+
* buckets if the tree held one.
|
|
3604
|
+
*
|
|
3605
|
+
* A `GitFailure` becomes this resource's own refusal rather than an `ERR_GIT`: from the client's side,
|
|
3606
|
+
* an unknown commit and a path absent from a known commit are one answer, "this URI names nothing
|
|
3607
|
+
* here", and the real cause is on stderr where an operator reads it. Nothing else in the read can fail
|
|
3608
|
+
* that way, since the path is gated and the blob is named by the tree itself.
|
|
3609
|
+
*/
|
|
3610
|
+
const PinnedResource = templateLayer({
|
|
3611
|
+
section: "at",
|
|
3612
|
+
uriTemplate: "memhtml://at/{commit}/{path}",
|
|
3613
|
+
name: "Memory file at a commit",
|
|
3614
|
+
description: "One memory's title, claim, and body text as of a specific commit sha. For a citation whose bytes cannot move: the path may since have been corrected, archived, or evicted. A branch name or HEAD is refused.",
|
|
3615
|
+
mimeType: "text/plain",
|
|
3616
|
+
refuse: pinnedRefusal,
|
|
3617
|
+
read: (uri, captured) => Effect.gen(function* () {
|
|
3618
|
+
const at = captured.indexOf("/");
|
|
3619
|
+
if (at <= 0) return yield* Effect.fail(pinnedRefusal(uri));
|
|
3620
|
+
const commit = captured.slice(0, at);
|
|
3621
|
+
const path = captured.slice(at + 1);
|
|
3622
|
+
if (!COMMIT_SHA.test(commit) || !isValidMemoryPath(path)) return yield* Effect.fail(pinnedRefusal(uri));
|
|
3623
|
+
const store = yield* Store;
|
|
3624
|
+
const normalized = normalizePath(path);
|
|
3625
|
+
const blob = (yield* store.git.lsTreeR(commit, [normalized]).pipe(Effect.tapError(Effect.logError), Effect.catchTag("GitFailure", () => Effect.fail(pinnedRefusal(uri))))).find((entry) => entry.path === normalized && entry.objectType === "blob");
|
|
3626
|
+
if (blob === void 0) return yield* Effect.fail(pinnedRefusal(uri));
|
|
3627
|
+
const body = (yield* store.git.catFileBatch([blob.sha]).pipe(Effect.tapError(Effect.logError), Effect.catchTag("GitFailure", () => Effect.fail(pinnedRefusal(uri))))).get(blob.sha);
|
|
3628
|
+
if (body === void 0) return yield* Effect.fail(pinnedRefusal(uri));
|
|
3629
|
+
const doc = yield* parseMemory(new TextDecoder().decode(body));
|
|
3630
|
+
return [
|
|
3631
|
+
`# ${doc.title}`,
|
|
3632
|
+
"",
|
|
3633
|
+
doc.article.gist,
|
|
3634
|
+
"",
|
|
3635
|
+
doc.article.bodyText
|
|
3636
|
+
].join("\n");
|
|
3637
|
+
})
|
|
3638
|
+
});
|
|
3639
|
+
/**
|
|
3640
|
+
* The URI that pins one memory at one commit, for a producer of a receipt.
|
|
3641
|
+
*
|
|
3642
|
+
* Exported so `memory_resolve` can publish a citation the caller pastes rather than assembles. The
|
|
3643
|
+
* spelling of a URI belongs to the surface that routes it, and a handler composing `memhtml://at/…`
|
|
3644
|
+
* out of its own string literals would be a second declaration of this template — the same
|
|
3645
|
+
* consumer-side reimplementation of a producer's naming rule that `reportFilename` exists to prevent
|
|
3646
|
+
* one resource over.
|
|
3647
|
+
*
|
|
3648
|
+
* The path is NOT percent-encoded. `capturedOf` decodes once, so a raw path and an escaped one name
|
|
3649
|
+
* the same resource, and the raw form is the one the published template shows.
|
|
3650
|
+
*/
|
|
3651
|
+
const pinnedUri = (commit, path) => `${prefixOf("at")}${commit}/${normalizePath(path)}`;
|
|
3652
|
+
/** Every resource as one layer, for the server to provide. */
|
|
3653
|
+
const Resources = Layer.mergeAll(FileResource, SleepResource, PinnedResource);
|
|
3654
|
+
|
|
2848
3655
|
//#endregion
|
|
2849
3656
|
//#region apps/mcp/src/tools.ts
|
|
2850
3657
|
/**
|
|
2851
|
-
* The
|
|
3658
|
+
* The fifteen tools: design.md §8 verbatim, plus `memory_write_batch` (spec 004 D7) and
|
|
3659
|
+
* `memory_resolve`.
|
|
2852
3660
|
*
|
|
2853
3661
|
* **`parameters` is always `Schema.Struct`, never `Schema.Class`.** A client sends a plain object
|
|
2854
3662
|
* literal, and a class schema's decode expects an instance. The failure is a decode error on every
|
|
@@ -2857,21 +3665,29 @@ const batchAbortFailure = (index, code, reason) => {
|
|
|
2857
3665
|
* **Sleep is deliberately absent.** It is a cron/operator action producing a reviewable branch, not
|
|
2858
3666
|
* something an agent fires mid-conversation: a sleep run rewrites confidence across the corpus,
|
|
2859
3667
|
* archives memories, and creates a branch a human is expected to read. `memhtml sleep run` is the
|
|
2860
|
-
* entry point
|
|
2861
|
-
* side stays behind an operator.
|
|
3668
|
+
* entry point. A read-only `sleep_status` is the only shape this surface could ever take for it; the
|
|
3669
|
+
* write side stays behind an operator.
|
|
2862
3670
|
*
|
|
2863
3671
|
* Every `success` schema is also a `Schema.Struct`, so `tools/list` publishes a JSON Schema the
|
|
2864
3672
|
* client can validate a response against rather than an opaque object.
|
|
2865
3673
|
*
|
|
3674
|
+
* **A `description` has to FOLD to a string by AST**, so it is built from string literals, `+`, and
|
|
3675
|
+
* identifiers declared in this file — never a template literal with a substitution and never an
|
|
3676
|
+
* imported value. The docs site reads each description straight out of this source
|
|
3677
|
+
* (`foldString` in `apps/docs/src/loaders/repo-sources.ts`) so the reference page and the published
|
|
3678
|
+
* bytes are the same string; an expression it cannot fold throws at that build instead of rendering
|
|
3679
|
+
* a paraphrase. A number that must not drift from a constant is asserted in `tests/tools.test.ts`
|
|
3680
|
+
* against the constant, which is the check a literal cannot make for itself.
|
|
3681
|
+
*
|
|
2866
3682
|
* **Every tool declares `failure: ToolFailure`, and the omission is a silent wire bug.** A tool with
|
|
2867
|
-
* no declared failure schema gets `Schema.Never` (`Tool.
|
|
2868
|
-
* predicate rejects everything and every
|
|
2869
|
-
*
|
|
2870
|
-
*
|
|
2871
|
-
* prose through; see `failure.ts` for the
|
|
2872
|
-
*
|
|
2873
|
-
* failure into the success union, where the
|
|
2874
|
-
* payload no MCP client knows to read.
|
|
3683
|
+
* no declared failure schema gets `Schema.Never` (`Tool.make`'s `options?.failure ?? Schema.Never`,
|
|
3684
|
+
* effect 4.0.0-rc.109), so `McpServer`'s `isDeclaredFailure` predicate rejects everything and every
|
|
3685
|
+
* failure, typed domain error included, is rewritten to `INTERNAL_TOOL_ERROR_MESSAGE`, "Tool
|
|
3686
|
+
* execution failed due to an internal server error", before it reaches the caller. The declaration is
|
|
3687
|
+
* what puts a tool's failures on the branch that passes prose through; see `failure.ts` for the
|
|
3688
|
+
* mechanism. `failureMode` is left at its `"error"` default on purpose: the error CHANNEL is what
|
|
3689
|
+
* `McpServer` catches, and `"return"` would instead fold the failure into the success union, where the
|
|
3690
|
+
* server would see a successful call carrying a failure payload no MCP client knows to read.
|
|
2875
3691
|
*/
|
|
2876
3692
|
/** The eight types an agent may write. `arc` is system-written by the sleep cycle. */
|
|
2877
3693
|
const WritableType = Schema.Literals(WRITABLE_MEMORY_TYPES);
|
|
@@ -2889,10 +3705,10 @@ const MemoryPath = Schema.String;
|
|
|
2889
3705
|
* `Schema.Finite`, not `Schema.Number`, for every numeric field.
|
|
2890
3706
|
*
|
|
2891
3707
|
* `Number` derives a JSON Schema with an `anyOf` carrying a STRING branch, because `Infinity` and
|
|
2892
|
-
* `NaN` are not JSON numbers and the codec represents them as strings. Probed on
|
|
2893
|
-
* `Schema.Number` derives `{"anyOf":[{"type":"number"},{"type":"string","enum":
|
|
2894
|
-
* "-Infinity","NaN"]}]}`. A client reading that sees a union where the tool wants a
|
|
2895
|
-
* derives a clean `{"type":"number"}`.
|
|
3708
|
+
* `NaN` are not JSON numbers and the codec represents them as strings. Probed on effect
|
|
3709
|
+
* 4.0.0-rc.109, `Schema.Number` derives `{"anyOf":[{"type":"number"},{"type":"string","enum":
|
|
3710
|
+
* ["Infinity","-Infinity","NaN"]}]}`. A client reading that sees a union where the tool wants a
|
|
3711
|
+
* number. `Finite` derives a clean `{"type":"number"}`.
|
|
2896
3712
|
*/
|
|
2897
3713
|
const Finite = Schema.Finite;
|
|
2898
3714
|
/** A count: a non-negative quantity. */
|
|
@@ -2903,7 +3719,7 @@ const Count = Schema.Int;
|
|
|
2903
3719
|
* A bare `Schema.optional(X)` is a WIRE BUG here, and it is the kind a byte-comparison fixture
|
|
2904
3720
|
* cannot see: the derived JSON Schema publishes `{"anyOf":[{"type":"string"},{"type":"null"}]}` , telling
|
|
2905
3721
|
* every client that `null` is acceptable, while the decoder rejects it with "Expected string |
|
|
2906
|
-
* undefined, got null" (both probed on effect 4.0.0-
|
|
3722
|
+
* undefined, got null" (both probed on effect 4.0.0-rc.109). So a client that read the schema and
|
|
2907
3723
|
* did the obvious thing, sending `{"workspace": null}` for "no workspace", would get a decode error
|
|
2908
3724
|
* on a call the published contract said was valid. Many clients serialize an absent optional exactly
|
|
2909
3725
|
* that way.
|
|
@@ -2930,8 +3746,8 @@ const Optional = (schema) => Schema.optionalKey(Schema.NullOr(schema));
|
|
|
2930
3746
|
* which keeps `memory_search` provably unable to reach the store and write.
|
|
2931
3747
|
*
|
|
2932
3748
|
* A FUNCTION per set, not a shared constant: the option's type is a mutable array, so handing the
|
|
2933
|
-
* same array to
|
|
2934
|
-
* other
|
|
3749
|
+
* same array to fifteen tools would let one tool's construction mutate the dependency list of the
|
|
3750
|
+
* other fourteen.
|
|
2935
3751
|
*/
|
|
2936
3752
|
const READS = () => [DatabaseService];
|
|
2937
3753
|
const WRITES = () => [
|
|
@@ -2966,7 +3782,19 @@ const writeFields = () => ({
|
|
|
2966
3782
|
/** Pre-authored article markup, used verbatim in place of `body`. See the description's contract. */
|
|
2967
3783
|
article_html: Optional(Schema.String),
|
|
2968
3784
|
memory_type: WritableType,
|
|
3785
|
+
/**
|
|
3786
|
+
* An explicit placement override. Unusable values are re-derived and occupied ones are refused;
|
|
3787
|
+
* `PATH_OVERRIDE_CONTRACT` states both branches in the description, which is where a caller reads
|
|
3788
|
+
* them. The refusal is `@memhtml/store`'s `freePathFor`, so this door and `memhtml apply` share it.
|
|
3789
|
+
*/
|
|
2969
3790
|
path: Optional(MemoryPath),
|
|
3791
|
+
/**
|
|
3792
|
+
* Refuse an unusable `path` instead of re-deriving one. Opt-in, because the lenient branch is what
|
|
3793
|
+
* ships and what callers depend on; `PATH_OVERRIDE_CONTRACT` states both branches in the
|
|
3794
|
+
* description, which is where a caller reads them. The refusal is `@memhtml/store`'s
|
|
3795
|
+
* `strictPathRefusal`, so this door, `memhtml write`, and `memhtml apply` share it.
|
|
3796
|
+
*/
|
|
3797
|
+
strict_path: Optional(Schema.Boolean),
|
|
2970
3798
|
workspace: Optional(Schema.String),
|
|
2971
3799
|
tags: Optional(Schema.Array(Schema.String)),
|
|
2972
3800
|
entities: Optional(Schema.Array(Schema.String)),
|
|
@@ -2977,7 +3805,7 @@ const writeFields = () => ({
|
|
|
2977
3805
|
turn_uuid: Optional(Schema.String)
|
|
2978
3806
|
});
|
|
2979
3807
|
const MemoryWrite = Tool.make("memory_write", {
|
|
2980
|
-
description: "Write one memory to the corpus. Returns the existing path with deduped=true when an active memory already holds this exact content. A duplicate creates no file and no commit. Supply EXACTLY ONE of `body` or `article_html`. Both or neither is refused. `article_html` is raw <article> inner markup used verbatim, and the caller owns the format: exactly one <mark>, inside the first <p> or the first <li>, and never inside <aside> or <details>; only elements from the closed vocabulary in docs/format.md; no class attribute, no style attribute, no <script>, no event handlers. The FIRST <time datetime=\"…\"> element becomes the memory's event time, which is what the recency arm ranks by, so an episodic memory about last week should carry last week's date, not today's. Markup that violates the format is refused before any file is written or committed. Code snippets: in `body` prose, a paragraph that is entirely a fenced code block (```ts … ```) becomes <figure><pre><code data-lang=\"ts\">, whitespace verbatim, and the language promotes to a `lang:ts` entity; a blank line inside the fence does not split it. In `article_html`, author the same markup yourself: data-lang, never class (forbidden) and never lang= (that names human languages). Call memory_write_batch ONCE rather than memory_write N times whenever this task will write more than about three memories: a batch stages every file, makes ONE commit, and reindexes ONCE, so it costs less than N calls and leaves a history a reader can follow. It returns one result per op in INPUT ORDER, each naming that op's index, its path, and whether it deduped. A batch is ATOMIC by default: the first refused op aborts the whole call, no file is written and no commit is made, and the failure names the offending op as ops[N]. Set continue_on_error to true for best-effort instead, and a refused op comes back as a failed result carrying its own code and reason while every surviving op lands in the one commit. A duplicate is never a failure: an op whose exact content is already stored returns ok with deduped=true and the existing path. Each op supplies EXACTLY ONE of body or article_html, the same rule memory_write follows.",
|
|
3808
|
+
description: "Write one memory to the corpus. Returns the existing path with deduped=true when an active memory already holds this exact content. A duplicate creates no file and no commit. Supply EXACTLY ONE of `body` or `article_html`. Both or neither is refused. `article_html` is raw <article> inner markup used verbatim, and the caller owns the format: exactly one <mark>, inside the first <p> or the first <li>, and never inside <aside> or <details>; only elements from the closed vocabulary in docs/format.md; no class attribute, no style attribute, no <script>, no event handlers. The FIRST <time datetime=\"…\"> element becomes the memory's event time, which is what the recency arm ranks by, so an episodic memory about last week should carry last week's date, not today's. Markup that violates the format is refused before any file is written or committed. Code snippets: in `body` prose, a paragraph that is entirely a fenced code block (```ts … ```) becomes <figure><pre><code data-lang=\"ts\">, whitespace verbatim, and the language promotes to a `lang:ts` entity; a blank line inside the fence does not split it. In `article_html`, author the same markup yourself: data-lang, never class (forbidden) and never lang= (that names human languages). `path` is optional and rarely worth sending: without it the placement rule picks the directory from the memory's type, workspace, and entities, and the title becomes the filename. A `path` 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. Send strict_path: true to have that REFUSED instead, with ERR_INVALID_MEMORY naming the clause the path broke and nothing written, staged, or committed; it governs the path you named, so with no `path` it changes nothing. Reach for it when you place documents at paths you compute, where a silent re-derivation makes a write look like it landed where you asked. A `path` that 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, call memory_correct on it — that archives the file it supersedes in the same commit and leaves it readable under archive/. Call memory_write_batch ONCE rather than memory_write N times whenever this task will write more than about three memories: a batch stages every file, makes ONE commit, and reindexes ONCE, so it costs less than N calls and leaves a history a reader can follow. It returns one result per op in INPUT ORDER, each naming that op's index, its path, and whether it deduped. A batch is ATOMIC by default: the first refused op aborts the whole call, no file is written and no commit is made, and the failure names the offending op as ops[N]. Set continue_on_error to true for best-effort instead, and a refused op comes back as a failed result carrying its own code and reason while every surviving op lands in the one commit. A duplicate is never a failure: an op whose exact content is already stored returns ok with deduped=true and the existing path. Each op supplies EXACTLY ONE of body or article_html, the same rule memory_write follows.",
|
|
2981
3809
|
dependencies: WRITES(),
|
|
2982
3810
|
parameters: Schema.Struct(writeFields()),
|
|
2983
3811
|
failure: ToolFailure,
|
|
@@ -2993,7 +3821,7 @@ const MemoryWrite = Tool.make("memory_write", {
|
|
|
2993
3821
|
* discriminator.
|
|
2994
3822
|
*
|
|
2995
3823
|
* A nested `Schema.Struct`, which is what makes the array's `items` a published object schema with its
|
|
2996
|
-
* own `required`. Probed on effect 4.0.0-
|
|
3824
|
+
* own `required`. Probed on effect 4.0.0-rc.109, `Schema.Array(Schema.Struct({…}))` derives the
|
|
2997
3825
|
* struct INLINE under `items` rather than hoisting it into a `$defs` a client would have to resolve.
|
|
2998
3826
|
* So `ops[].title` is as legible to a caller reading `tools/list` as `memory_write`'s own `title`, and
|
|
2999
3827
|
* the `Optional` discipline carries in unchanged: an optional inside an op publishes the same FLAT
|
|
@@ -3062,7 +3890,7 @@ const BatchOpResult = Schema.Struct({
|
|
|
3062
3890
|
superseded_path: Schema.NullOr(Schema.String)
|
|
3063
3891
|
});
|
|
3064
3892
|
const MemoryWriteBatch = Tool.make("memory_write_batch", {
|
|
3065
|
-
description: "Write many memories in ONE commit: every op is validated first, every surviving file is staged, and the batch commits and reindexes exactly once. commit_sha is null when nothing was written: an all-deduped batch, or an aborted one. Supply EXACTLY ONE of `body` or `article_html`. Both or neither is refused. `article_html` is raw <article> inner markup used verbatim, and the caller owns the format: exactly one <mark>, inside the first <p> or the first <li>, and never inside <aside> or <details>; only elements from the closed vocabulary in docs/format.md; no class attribute, no style attribute, no <script>, no event handlers. The FIRST <time datetime=\"…\"> element becomes the memory's event time, which is what the recency arm ranks by, so an episodic memory about last week should carry last week's date, not today's. Markup that violates the format is refused before any file is written or committed. Code snippets: in `body` prose, a paragraph that is entirely a fenced code block (```ts … ```) becomes <figure><pre><code data-lang=\"ts\">, whitespace verbatim, and the language promotes to a `lang:ts` entity; a blank line inside the fence does not split it. In `article_html`, author the same markup yourself: data-lang, never class (forbidden) and never lang= (that names human languages). Call memory_write_batch ONCE rather than memory_write N times whenever this task will write more than about three memories: a batch stages every file, makes ONE commit, and reindexes ONCE, so it costs less than N calls and leaves a history a reader can follow. It returns one result per op in INPUT ORDER, each naming that op's index, its path, and whether it deduped. A batch is ATOMIC by default: the first refused op aborts the whole call, no file is written and no commit is made, and the failure names the offending op as ops[N]. Set continue_on_error to true for best-effort instead, and a refused op comes back as a failed result carrying its own code and reason while every surviving op lands in the one commit. A duplicate is never a failure: an op whose exact content is already stored returns ok with deduped=true and the existing path. Each op supplies EXACTLY ONE of body or article_html, the same rule memory_write follows. Set detect_conflicts to true and each per-op result gains a `conflict` field naming what that op's claim CONTRADICTS. This is not dedupe: dedupe catches an op whose content is IDENTICAL to something stored, while this catches an op that says something DIFFERENT about the same thing, the case dedupe is blind to, and the one that actually rots a corpus. The match is grammatical rather than semantic. A claim splits into a frame (the subject and relation up to its LAST of/is/in/to/by/as) and a value, and two claims conflict when they share a frame: 'The pool ceiling is 64' and 'The pool ceiling is 128' both key on 'the pool ceiling is'. conflict.path names an ACTIVE memory already holding that slot. conflict.batch_index names an EARLIER op in this same call, which no other tool can see because neither op is stored yet; it has no path for that reason. conflict.claim is the other claim's own text, so you can decide without a second call. conflict is null when nothing matched, when detect_conflicts was absent, when the claim states no frame shape (the rule refuses frames under three tokens and values over six, so short claims and claims trailed by a clause are deliberately unmatched rather than loosely matched), and always on an op that used article_html. The claim is inside your markup there and is not read until the store renders it. THE ASSIST NEVER CHANGES WHAT IS WRITTEN. An op carrying a conflict is written exactly as it would have been without the flag: nothing is archived, nothing is refused, later does not win, and the summary counts are unchanged. That is deliberate, not a limitation. Sometimes the contradiction IS the answer. A memory recording that a runbook step changed necessarily contradicts the memory stating the old step, and a system that resolved that for you would destroy the pair a reader needs in order to see the change at all. So YOU decide, per conflict: keep both (they are about different things, or both are true), call memory_correct on the named path instead (the new claim supersedes the old one, which stays readable under archive/), or drop the op. Archived memories never match, so a superseded claim stops contradicting the claim that superseded it. Set consolidate to \"last-wins\" and the batch RESOLVES frame-key matches instead of only reporting them: for ops sharing a claim slot (the same deterministic frame key the conflict rule uses), the LATER value wins. Exactly one file is written, at the FIRST index that claimed the slot, and every later restatement reports consolidated_into naming that slot instead of a path of its own. A stored ACTIVE memory occupying a surviving slot is archived with a supersedes link from the new file, its archive path reported on the winner as superseded_path. Off by default, and claims with no frame shape are never consolidated. The guards fail closed, so this only ever acts on claims the conflict rule would have matched.",
|
|
3893
|
+
description: "Write many memories in ONE commit: every op is validated first, every surviving file is staged, and the batch commits and reindexes exactly once. commit_sha is null when nothing was written: an all-deduped batch, or an aborted one. Supply EXACTLY ONE of `body` or `article_html`. Both or neither is refused. `article_html` is raw <article> inner markup used verbatim, and the caller owns the format: exactly one <mark>, inside the first <p> or the first <li>, and never inside <aside> or <details>; only elements from the closed vocabulary in docs/format.md; no class attribute, no style attribute, no <script>, no event handlers. The FIRST <time datetime=\"…\"> element becomes the memory's event time, which is what the recency arm ranks by, so an episodic memory about last week should carry last week's date, not today's. Markup that violates the format is refused before any file is written or committed. Code snippets: in `body` prose, a paragraph that is entirely a fenced code block (```ts … ```) becomes <figure><pre><code data-lang=\"ts\">, whitespace verbatim, and the language promotes to a `lang:ts` entity; a blank line inside the fence does not split it. In `article_html`, author the same markup yourself: data-lang, never class (forbidden) and never lang= (that names human languages). `path` is optional and rarely worth sending: without it the placement rule picks the directory from the memory's type, workspace, and entities, and the title becomes the filename. A `path` 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. Send strict_path: true to have that REFUSED instead, with ERR_INVALID_MEMORY naming the clause the path broke and nothing written, staged, or committed; it governs the path you named, so with no `path` it changes nothing. Reach for it when you place documents at paths you compute, where a silent re-derivation makes a write look like it landed where you asked. A `path` that 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, call memory_correct on it — that archives the file it supersedes in the same commit and leaves it readable under archive/. Call memory_write_batch ONCE rather than memory_write N times whenever this task will write more than about three memories: a batch stages every file, makes ONE commit, and reindexes ONCE, so it costs less than N calls and leaves a history a reader can follow. It returns one result per op in INPUT ORDER, each naming that op's index, its path, and whether it deduped. A batch is ATOMIC by default: the first refused op aborts the whole call, no file is written and no commit is made, and the failure names the offending op as ops[N]. Set continue_on_error to true for best-effort instead, and a refused op comes back as a failed result carrying its own code and reason while every surviving op lands in the one commit. A duplicate is never a failure: an op whose exact content is already stored returns ok with deduped=true and the existing path. Each op supplies EXACTLY ONE of body or article_html, the same rule memory_write follows. Set detect_conflicts to true and each per-op result gains a `conflict` field naming what that op's claim CONTRADICTS. This is not dedupe: dedupe catches an op whose content is IDENTICAL to something stored, while this catches an op that says something DIFFERENT about the same thing, the case dedupe is blind to, and the one that actually rots a corpus. The match is grammatical rather than semantic. A claim splits into a frame (the subject and relation up to its LAST of/is/in/to/by/as) and a value, and two claims conflict when they share a frame: 'The pool ceiling is 64' and 'The pool ceiling is 128' both key on 'the pool ceiling is'. conflict.path names an ACTIVE memory already holding that slot. conflict.batch_index names an EARLIER op in this same call, which no other tool can see because neither op is stored yet; it has no path for that reason. conflict.claim is the other claim's own text, so you can decide without a second call. conflict is null when nothing matched, when detect_conflicts was absent, when the claim states no frame shape (the rule refuses frames under three tokens and values over six, so short claims and claims trailed by a clause are deliberately unmatched rather than loosely matched), and always on an op that used article_html. The claim is inside your markup there and is not read until the store renders it. THE ASSIST NEVER CHANGES WHAT IS WRITTEN. An op carrying a conflict is written exactly as it would have been without the flag: nothing is archived, nothing is refused, later does not win, and the summary counts are unchanged. That is deliberate, not a limitation. Sometimes the contradiction IS the answer. A memory recording that a runbook step changed necessarily contradicts the memory stating the old step, and a system that resolved that for you would destroy the pair a reader needs in order to see the change at all. So YOU decide, per conflict: keep both (they are about different things, or both are true), call memory_correct on the named path instead (the new claim supersedes the old one, which stays readable under archive/), or drop the op. Archived memories never match, so a superseded claim stops contradicting the claim that superseded it. Set consolidate to \"last-wins\" and the batch RESOLVES frame-key matches instead of only reporting them: for ops sharing a claim slot (the same deterministic frame key the conflict rule uses), the LATER value wins. Exactly one file is written, at the FIRST index that claimed the slot, and every later restatement reports consolidated_into naming that slot instead of a path of its own. A stored ACTIVE memory occupying a surviving slot is archived with a supersedes link from the new file, its archive path reported on the winner as superseded_path. Off by default, and claims with no frame shape are never consolidated. The guards fail closed, so this only ever acts on claims the conflict rule would have matched.",
|
|
3066
3894
|
dependencies: WRITES(),
|
|
3067
3895
|
parameters: Schema.Struct({
|
|
3068
3896
|
ops: Schema.Array(BatchOp),
|
|
@@ -3139,7 +3967,7 @@ const MemoryRead = Tool.make("memory_read", {
|
|
|
3139
3967
|
})
|
|
3140
3968
|
});
|
|
3141
3969
|
const MemorySearch = Tool.make("memory_search", {
|
|
3142
|
-
description: "Ranked search over the corpus: lexical, vector, recency, and salience arms fused with RRF, then diversified. Each hit carries a `snippet`: the text of the file's best-matching chunk for this query (its opening chunk when the vector arm did not fire), truncated with a trailing `…` when cut. `degraded` is true when the vector arm did not fire, so the result came from fewer signals. Each hit also carries `entities` in `type:name` form; pass one of those values back as `entity` to make the next call the second hop of a chain. That is two calls, not a guess about spelling. An `entity` scope that matches nothing returns NO hits and says so through `scope_empty`: this tool never widens a scope it could not satisfy. `as_of` is a point-in-time view: pass an ISO instant and the result is what was believed valid at that moment, including since-superseded memories (marked superseded_by). Returning a path changes nothing: a hit is this ranker's guess, so it never bumps salience. Call memory_read to open the one you chose, and memory_reinforce to record whether it was right.",
|
|
3970
|
+
description: "Ranked search over the corpus: lexical, vector, recency, and salience arms fused with RRF, then diversified. Each hit carries a `snippet`: the text of the file's best-matching chunk for this query (its opening chunk when the vector arm did not fire), truncated with a trailing `…` when cut. `degraded` is true when the vector arm did not fire, so the result came from fewer signals. Each hit also carries `entities` in `type:name` form; pass one of those values back as `entity` to make the next call the second hop of a chain. That is two calls, not a guess about spelling. An `entity` scope that matches nothing returns NO hits and says so through `scope_empty`: this tool never widens a scope it could not satisfy. `as_of` is a point-in-time view: pass an ISO instant and the result is what was believed valid at that moment, including since-superseded memories (marked superseded_by). Returning a path changes nothing: a hit is this ranker's guess, so it never bumps salience. Call memory_read to open the one you chose, and memory_reinforce to record whether it was right. `facets` narrows by the corpus's own <dl> facets, each entry spelled name=value (the value may contain =, the name may not). THE COMPOSITION IS FIXED: values under the SAME name broaden, so [\"doc-type=runbook\",\"doc-type=guide\"] is either; DIFFERENT names narrow, so [\"doc-type=runbook\",\"tier=1\"] is both. This is the extension axis. memhtml's element and meta vocabularies are closed, so your own document kinds, states, and tiers belong in <dt>/<dd> pairs inside the article, and this is how you query them back. 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 and that is deliberate: a <data value> is indexed UNITLESS, because the unit lives in the prose beside it, so you own the unit and match the text you wrote.",
|
|
3143
3971
|
dependencies: RETRIEVES(),
|
|
3144
3972
|
parameters: Schema.Struct({
|
|
3145
3973
|
query: Schema.String,
|
|
@@ -3152,6 +3980,11 @@ const MemorySearch = Tool.make("memory_search", {
|
|
|
3152
3980
|
* spelling a hit's `entities` publishes, so a value read off a hit is a valid scope verbatim.
|
|
3153
3981
|
*/
|
|
3154
3982
|
entity: Optional(Schema.String),
|
|
3983
|
+
/**
|
|
3984
|
+
* `<dl>` facet predicates as `name=value` strings. AND across distinct names, OR within one name;
|
|
3985
|
+
* the description carries the rule, because that is what a caller reads.
|
|
3986
|
+
*/
|
|
3987
|
+
facets: Optional(Schema.Array(Schema.String)),
|
|
3155
3988
|
include_archived: Optional(Schema.Boolean),
|
|
3156
3989
|
/**
|
|
3157
3990
|
* Point-in-time view: returns what was believed valid at this moment, including
|
|
@@ -3280,12 +4113,20 @@ const MemoryLink = Tool.make("memory_link", {
|
|
|
3280
4113
|
})
|
|
3281
4114
|
});
|
|
3282
4115
|
const MemoryNeighbors = Tool.make("memory_neighbors", {
|
|
3283
|
-
description: "The memory graph around one path, to at most two hops, in both directions. Includes sleep-mined edges: lateral retrieval is what they are for.",
|
|
4116
|
+
description: "The memory graph around one path, to at most two hops, in both directions. Includes sleep-mined edges: lateral retrieval is what they are for, and each node's `derived` says which kind of edge reached it. `nodes` holds at most 200 distinct paths, each at its minimal hop. `limit` chooses that ceiling and an ask outside 1..200 is clamped into it rather than refused, the same shape `memory_list` and `trace_search` have; `node_limit` echoes the bound the answer was built under. `edges` counts something DIFFERENT and is not a node count: it is the distinct edges the walk enumerated, including edges to paths the node clamp dropped, so it can exceed what the returned nodes account for. TWO markers report truncation, because they need different answers: `dropped_node_count` is the paths the walk reached and `limit` turned away, which a larger `limit` returns, while `scan_saturated` is the walk stopping at its own 10000-edge-row cap, which no `limit` recovers — narrow that one with `rels` or `depth: 1` instead.",
|
|
3284
4117
|
dependencies: READS(),
|
|
3285
4118
|
parameters: Schema.Struct({
|
|
3286
4119
|
path: MemoryPath,
|
|
3287
4120
|
depth: Optional(Count),
|
|
3288
|
-
rels: Optional(Schema.Array(MemoryRelSchema))
|
|
4121
|
+
rels: Optional(Schema.Array(MemoryRelSchema)),
|
|
4122
|
+
/**
|
|
4123
|
+
* Distinct paths `nodes` may hold, 1 to {@link NEIGHBORS_LIMIT}, defaulting to the ceiling.
|
|
4124
|
+
*
|
|
4125
|
+
* Clamped rather than refused, because a caller asking for more than the ceiling wants the
|
|
4126
|
+
* ceiling — `memory_list`'s 500 and `trace_search`'s 200 are the same shape. The value the server
|
|
4127
|
+
* actually used comes back as `node_limit`, so a clamped ask is visible rather than silent.
|
|
4128
|
+
*/
|
|
4129
|
+
limit: Optional(Count)
|
|
3289
4130
|
}),
|
|
3290
4131
|
failure: ToolFailure,
|
|
3291
4132
|
success: Schema.Struct({
|
|
@@ -3294,9 +4135,117 @@ const MemoryNeighbors = Tool.make("memory_neighbors", {
|
|
|
3294
4135
|
title: Schema.String,
|
|
3295
4136
|
/** 1-based distance from the center: 1 or 2, never 0. */
|
|
3296
4137
|
hop: Count,
|
|
3297
|
-
rel: Schema.String
|
|
4138
|
+
rel: Schema.String,
|
|
4139
|
+
/**
|
|
4140
|
+
* True when a SLEEP-MINED edge reaches this node, false when only authored `<link>` edges do.
|
|
4141
|
+
*
|
|
4142
|
+
* The max over every edge that reached the node, not the `rel` field's companion: a node an
|
|
4143
|
+
* authored edge and a mined edge both reach is `derived: true`, because the question a caller
|
|
4144
|
+
* asks of this field is "may this connection be a machine's suspicion", and one mined route is
|
|
4145
|
+
* enough for the answer to be yes.
|
|
4146
|
+
*
|
|
4147
|
+
* Published because the description advertises mined edges as the point of the tool, and
|
|
4148
|
+
* without this field a caller cannot tell a suspicion from an assertion — which is exactly the
|
|
4149
|
+
* distinction it needs in order to decide how much to trust a lateral hop.
|
|
4150
|
+
*/
|
|
4151
|
+
derived: Schema.Boolean
|
|
4152
|
+
})),
|
|
4153
|
+
/**
|
|
4154
|
+
* DISTINCT edges the walk enumerated, keyed on `(src, rel, dst)`, over both hops and both
|
|
4155
|
+
* directions. Scope: this one call's walk, not the corpus — `memory_status.edges` is the corpus
|
|
4156
|
+
* total and the two are different coordinate spaces.
|
|
4157
|
+
*
|
|
4158
|
+
* It is NOT `nodes.length` and must not be read as one: two memories joined by two rels are one
|
|
4159
|
+
* node and two edges, and an edge landing on a path the node clamp dropped is counted here and
|
|
4160
|
+
* absent there. Bounded by the walk's own 10000-row scan cap, which `scan_saturated` reports.
|
|
4161
|
+
*/
|
|
4162
|
+
edges: Count,
|
|
4163
|
+
/**
|
|
4164
|
+
* The node ceiling this answer was built under: the SERVER's clamp of the caller's `limit` into
|
|
4165
|
+
* `1..200` ({@link NEIGHBORS_LIMIT}), not the raw ask, so a client that sent 10000 reads back 200
|
|
4166
|
+
* and knows the answer is a ceiling rather than a corpus fact. A quantity of distinct paths, scoped
|
|
4167
|
+
* to this one call.
|
|
4168
|
+
*
|
|
4169
|
+
* `node_limit` and not `limit`, because this answer carries TWO bounds and they are not
|
|
4170
|
+
* interchangeable: this one governs `nodes`, and the walk's own 10000-edge-row cap governs
|
|
4171
|
+
* everything, which is what `scan_saturated` reports.
|
|
4172
|
+
*/
|
|
4173
|
+
node_limit: Count,
|
|
4174
|
+
/**
|
|
4175
|
+
* Distinct paths the walk reached and `node_limit` turned away, filled by the server.
|
|
4176
|
+
*
|
|
4177
|
+
* A COUNT of paths absent from `nodes`, scoped to this call, so `nodes.length +
|
|
4178
|
+
* dropped_node_count` is every path the walk found. `0` means `nodes` holds all of them, which is
|
|
4179
|
+
* how a client tells a complete neighborhood from a clamped one. A larger `limit`, up to
|
|
4180
|
+
* {@link NEIGHBORS_LIMIT}, returns them.
|
|
4181
|
+
*
|
|
4182
|
+
* `_count` because it is a quantity, and this repo's four numeric suffixes are not
|
|
4183
|
+
* interchangeable — an `_offset`, a `_seq`, and an `_index` are all different things. `edges`
|
|
4184
|
+
* keeps its bare name because it is already a published field a client branches on.
|
|
4185
|
+
*/
|
|
4186
|
+
dropped_node_count: Count,
|
|
4187
|
+
/**
|
|
4188
|
+
* True when the walk stopped at its own 10000-edge-row cap, so edges past the cap were never
|
|
4189
|
+
* enumerated and NO `limit` recovers them — the truncation `dropped_node_count` cannot describe.
|
|
4190
|
+
* Narrow the walk with `rels` or `depth: 1` to get an exhaustive answer.
|
|
4191
|
+
*
|
|
4192
|
+
* A plain boolean, never a null union: an absent or null marker cannot be told from a server that
|
|
4193
|
+
* does not report saturation, and this is the field that says whether `nodes` and `edges` describe
|
|
4194
|
+
* the whole neighborhood.
|
|
4195
|
+
*/
|
|
4196
|
+
scan_saturated: Schema.Boolean
|
|
4197
|
+
})
|
|
4198
|
+
});
|
|
4199
|
+
const MemoryResolve = Tool.make("memory_resolve", {
|
|
4200
|
+
description: "Follow a path an older answer, receipt, or external citation recorded FORWARD to the memory that carries the fact now. A path is the id of a memory and it is derived from the title, so a correction that rewords the title moves the file: the cited path stops resolving through no fault of the citation. `stop_reason` is what decides whether the answer is citable, and only `live` means yes. `archived` is a memory that was EVICTED rather than corrected, so nothing supersedes it and citing it as current would be wrong. `unindexed` is no such path here, which may also mean the index does not yet describe the commit that holds it — `indexed_commit` says which commit it does describe. `cycle` and `hop_limit` are the two abnormal endings: a cycle is two memories each claiming to supersede the other, an authoring defect, and `hop_limit` means `path` is where the walk stopped rather than the end of the chain, so resolving it again continues. `steps` names the mechanism of every hop, `supersedes` for an authored link and `archive_move` for a `git mv` into the archive, and each node is named by the path that holds it NOW. `hops: 0` with `stop_reason: live` does NOT mean the bytes are unchanged: a correction whose title did not change lands at the same path. Read `pinned_uri` for the grain that answers that — it is a resource URI naming these bytes at a commit, so it cannot move, where memhtml://file/<path> always returns whatever is at the path today.",
|
|
4201
|
+
dependencies: READS(),
|
|
4202
|
+
parameters: Schema.Struct({ path: MemoryPath }),
|
|
4203
|
+
failure: ToolFailure,
|
|
4204
|
+
success: Schema.Struct({
|
|
4205
|
+
/** The path asked about, normalized, so an answer can be matched back to the receipt. */
|
|
4206
|
+
requested: MemoryPath,
|
|
4207
|
+
/** Where the walk ended. What that MEANS is `stop_reason`'s, not this field's. */
|
|
4208
|
+
path: MemoryPath,
|
|
4209
|
+
/** Hops taken, equal to `steps.length`. A quantity, `0` when the path needed no walk. */
|
|
4210
|
+
hops: Count,
|
|
4211
|
+
steps: Schema.Array(Schema.Struct({
|
|
4212
|
+
from: MemoryPath,
|
|
4213
|
+
to: MemoryPath,
|
|
4214
|
+
/**
|
|
4215
|
+
* Which mechanism moved the memory, from the closed vocabulary the operation declares.
|
|
4216
|
+
*
|
|
4217
|
+
* Published as an enum rather than a string so a client can branch exhaustively: the two are
|
|
4218
|
+
* different claims. `supersedes` is an authored `<link>` inside a file, which survives a
|
|
4219
|
+
* rebuilt index; `archive_move` is a `git mv` recorded by the path itself.
|
|
4220
|
+
*/
|
|
4221
|
+
via: Schema.Literals(RESOLVE_STEP_VIA)
|
|
3298
4222
|
})),
|
|
3299
|
-
|
|
4223
|
+
/** Why the walk stopped, from the five values the operation declares. Only `live` is citable. */
|
|
4224
|
+
stop_reason: Schema.Literals(RESOLVE_STOP_REASONS),
|
|
4225
|
+
/** The title at `path`, or null when the index holds no row for it. */
|
|
4226
|
+
title: Schema.NullOr(Schema.String),
|
|
4227
|
+
/**
|
|
4228
|
+
* The commit the INDEX describes, or null before the first rebuild and during one.
|
|
4229
|
+
*
|
|
4230
|
+
* This whole answer is a statement about that commit rather than about HEAD, since the index is a
|
|
4231
|
+
* projection of git. `memory_status.index_fresh` says whether the two agree.
|
|
4232
|
+
*/
|
|
4233
|
+
indexed_commit: Schema.NullOr(Schema.String),
|
|
4234
|
+
/**
|
|
4235
|
+
* A `resources/read` URI pinning `path` at `indexed_commit`, or null when reading it would fail.
|
|
4236
|
+
*
|
|
4237
|
+
* Two conditions make it null, and both are the same rule: this field is a URI the same server
|
|
4238
|
+
* answers. There is no commit to pin to before the first rebuild, and `unindexed` is the one
|
|
4239
|
+
* `stop_reason` whose `path` the indexed commit does not hold — pinning it would publish a
|
|
4240
|
+
* citation `resources/read` refuses with `ERR_PATH_NOT_FOUND`, which a receipt would store as a
|
|
4241
|
+
* permanently dead link. The other four stop reasons all end on a path the index holds a row for.
|
|
4242
|
+
*
|
|
4243
|
+
* Composed by the server rather than by the client, because the URI's spelling belongs to the
|
|
4244
|
+
* resource that routes it. A caller writing a receipt stores this string: the path half can be
|
|
4245
|
+
* corrected, archived, or evicted afterwards and the URI still returns the bytes the receipt was
|
|
4246
|
+
* written against.
|
|
4247
|
+
*/
|
|
4248
|
+
pinned_uri: Schema.NullOr(Schema.String)
|
|
3300
4249
|
})
|
|
3301
4250
|
});
|
|
3302
4251
|
const MemoryArchive = Tool.make("memory_archive", {
|
|
@@ -3326,13 +4275,15 @@ const MemoryReinforce = Tool.make("memory_reinforce", {
|
|
|
3326
4275
|
})
|
|
3327
4276
|
});
|
|
3328
4277
|
const MemoryList = Tool.make("memory_list", {
|
|
3329
|
-
description: "Page through the corpus by facet. `next_cursor` is a keyset on the path, so a page stays correct even while a sleep cycle archives files.",
|
|
4278
|
+
description: "Page through the corpus by facet. `next_cursor` is a keyset on the path, so a page stays correct even while a sleep cycle archives files. `facets` narrows by the corpus's own <dl> facets, each entry spelled name=value (the value may contain =, the name may not). THE COMPOSITION IS FIXED: values under the SAME name broaden, so [\"doc-type=runbook\",\"doc-type=guide\"] is either; DIFFERENT names narrow, so [\"doc-type=runbook\",\"tier=1\"] is both. This is the extension axis. memhtml's element and meta vocabularies are closed, so your own document kinds, states, and tiers belong in <dt>/<dd> pairs inside the article, and this is how you query them back. 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 and that is deliberate: a <data value> is indexed UNITLESS, because the unit lives in the prose beside it, so you own the unit and match the text you wrote.",
|
|
3330
4279
|
dependencies: READS(),
|
|
3331
4280
|
parameters: Schema.Struct({
|
|
3332
4281
|
memory_type: Optional(WritableType),
|
|
3333
4282
|
workspace: Optional(Schema.String),
|
|
3334
4283
|
tag: Optional(Schema.String),
|
|
3335
4284
|
entity: Optional(Schema.String),
|
|
4285
|
+
/** `<dl>` facet predicates as `name=value` strings, composing exactly as `memory_search`'s do. */
|
|
4286
|
+
facets: Optional(Schema.Array(Schema.String)),
|
|
3336
4287
|
para: Optional(Schema.Literals(PARA_BUCKETS)),
|
|
3337
4288
|
limit: Optional(Count),
|
|
3338
4289
|
cursor: Optional(Schema.String)
|
|
@@ -3397,7 +4348,7 @@ const MemoryStatus = Tool.make("memory_status", {
|
|
|
3397
4348
|
/**
|
|
3398
4349
|
* `Tool.EmptyParams`, not `Schema.Struct({})`.
|
|
3399
4350
|
*
|
|
3400
|
-
* Probed on effect 4.0.0-
|
|
4351
|
+
* Probed on effect 4.0.0-rc.109: an empty `Schema.Struct` derives
|
|
3401
4352
|
* `{"anyOf":[{"type":"object"},{"type":"array"}]}`, a union with an ARRAY branch, because a struct
|
|
3402
4353
|
* with no fields constrains nothing and the codec's encoded form admits both. A client reading that
|
|
3403
4354
|
* cannot tell it should send `{}`, and a strict one may refuse to call the tool at all.
|
|
@@ -3423,7 +4374,8 @@ const MemoryStatus = Tool.make("memory_status", {
|
|
|
3423
4374
|
})
|
|
3424
4375
|
});
|
|
3425
4376
|
/**
|
|
3426
|
-
* The toolkit. Exactly
|
|
4377
|
+
* The toolkit. Exactly fifteen: design.md §8's thirteen, plus `memory_write_batch` and
|
|
4378
|
+
* `memory_resolve`.
|
|
3427
4379
|
*
|
|
3428
4380
|
* Order is the read order of the table in §8, which is also roughly the order an agent needs them:
|
|
3429
4381
|
* write and read, then the three retrieval shapes, then the graph operations, then the trace plane,
|
|
@@ -3434,12 +4386,12 @@ const MemoryStatus = Tool.make("memory_status", {
|
|
|
3434
4386
|
* points at is the very next entry. A pointer whose target is thirteen tools away is one an agent
|
|
3435
4387
|
* reads after it has already decided how to write.
|
|
3436
4388
|
*/
|
|
3437
|
-
const MemhtmlToolkit = Toolkit.make(MemoryWrite, MemoryWriteBatch, MemoryRead, MemorySearch, MemoryRecall, MemoryCorrect, MemoryLink, MemoryNeighbors, MemoryArchive, MemoryReinforce, MemoryList, TraceSearch, TraceLinks, MemoryStatus);
|
|
4389
|
+
const MemhtmlToolkit = Toolkit.make(MemoryWrite, MemoryWriteBatch, MemoryRead, MemorySearch, MemoryRecall, MemoryCorrect, MemoryLink, MemoryNeighbors, MemoryResolve, MemoryArchive, MemoryReinforce, MemoryList, TraceSearch, TraceLinks, MemoryStatus);
|
|
3438
4390
|
/**
|
|
3439
4391
|
* The tool names, derived from the toolkit rather than restated.
|
|
3440
4392
|
*
|
|
3441
|
-
* Two lists would drift: a placeholder list that once said
|
|
3442
|
-
* builds
|
|
4393
|
+
* Two lists would drift: a placeholder list that once said fifteen names and a toolkit that now
|
|
4394
|
+
* builds fourteen would leave a test asserting the list and proving nothing about the server.
|
|
3443
4395
|
*/
|
|
3444
4396
|
const TOOL_NAMES = Object.keys(MemhtmlToolkit.tools);
|
|
3445
4397
|
|
|
@@ -3455,17 +4407,17 @@ const TOOL_NAMES = Object.keys(MemhtmlToolkit.tools);
|
|
|
3455
4407
|
* or a memory body, because the reason is `messageFor`'s and each error class dropped those at its
|
|
3456
4408
|
* adapter edge precisely so a tool response could not carry corpus content.
|
|
3457
4409
|
*
|
|
3458
|
-
*
|
|
3459
|
-
* rewrites it to a generic internal-error sentence unless its reason is
|
|
3460
|
-
* (
|
|
3461
|
-
*
|
|
3462
|
-
*
|
|
3463
|
-
*
|
|
3464
|
-
*
|
|
3465
|
-
*
|
|
3466
|
-
* The error type is
|
|
3467
|
-
*
|
|
3468
|
-
*
|
|
4410
|
+
* **It has to be a `ToolFailure` and not an `AiError`.** `McpServer` catches an `AiError` FIRST and
|
|
4411
|
+
* rewrites it to a generic internal-error sentence unless its reason is `ToolParameterValidationError`
|
|
4412
|
+
* (effect 4.0.0-rc.109), so a typed failure delivered that way reaches its agent with the content
|
|
4413
|
+
* removed. A `ToolFailure` is what each tool's `failure:` schema declares, which puts it on the branch
|
|
4414
|
+
* that passes `.message` through verbatim. The two halves only work together: dropping the declaration
|
|
4415
|
+
* in `tools.ts` re-masks everything this function builds, and the wire test in `tests-integration` is
|
|
4416
|
+
* what holds that pair honest.
|
|
4417
|
+
*
|
|
4418
|
+
* The error type is `ToolFailure` for every handler and `kit.toLayer` checks it, so a handler that
|
|
4419
|
+
* failed with a raw domain error is a compile error rather than a masked response. `failure.ts` is the
|
|
4420
|
+
* single place the wire failure is produced.
|
|
3469
4421
|
*/
|
|
3470
4422
|
const handled = (effect) => effect.pipe(Effect.mapError(toToolFailure));
|
|
3471
4423
|
/**
|
|
@@ -3494,7 +4446,7 @@ const metaRecord = (doc) => {
|
|
|
3494
4446
|
* derived JSON Schema advertises `null`, and a client that reads the schema and sends
|
|
3495
4447
|
* `{"workspace": null}` for "no workspace" is doing the documented thing. The operations layer speaks
|
|
3496
4448
|
* `undefined` for "not supplied" because `exactOptionalPropertyTypes` distinguishes an absent key from
|
|
3497
|
-
* a present one, so the two vocabularies meet HERE, once, rather than at each of
|
|
4449
|
+
* a present one, so the two vocabularies meet HERE, once, rather than at each of fifteen call sites.
|
|
3498
4450
|
*/
|
|
3499
4451
|
const opt = (value) => value ?? void 0;
|
|
3500
4452
|
/** Absent optional array as an empty one, so a handler never passes `undefined` downstream. */
|
|
@@ -3529,6 +4481,7 @@ const writeParamsOf = (op, article) => ({
|
|
|
3529
4481
|
articleHtml: article.articleHtml,
|
|
3530
4482
|
memoryType: op.memory_type,
|
|
3531
4483
|
path: opt(op.path),
|
|
4484
|
+
strictPath: opt(op.strict_path),
|
|
3532
4485
|
workspace: opt(op.workspace),
|
|
3533
4486
|
tags: arr(op.tags),
|
|
3534
4487
|
entities: arr(op.entities),
|
|
@@ -3636,6 +4589,7 @@ const ToolHandlers = MemhtmlToolkit.toLayer({
|
|
|
3636
4589
|
articleHtml: article.articleHtml,
|
|
3637
4590
|
memoryType: params.memory_type,
|
|
3638
4591
|
path: opt(params.path),
|
|
4592
|
+
strictPath: opt(params.strict_path),
|
|
3639
4593
|
workspace: opt(params.workspace),
|
|
3640
4594
|
tags: arr(params.tags),
|
|
3641
4595
|
entities: arr(params.entities),
|
|
@@ -3811,6 +4765,7 @@ const ToolHandlers = MemhtmlToolkit.toLayer({
|
|
|
3811
4765
|
workspace: opt(params.workspace),
|
|
3812
4766
|
tags: opt(params.tags),
|
|
3813
4767
|
entity: opt(params.entity),
|
|
4768
|
+
facets: parseFacetFilters(arr(params.facets)),
|
|
3814
4769
|
includeArchived: opt(params.include_archived),
|
|
3815
4770
|
asOf: opt(params.as_of)
|
|
3816
4771
|
});
|
|
@@ -3906,11 +4861,52 @@ const ToolHandlers = MemhtmlToolkit.toLayer({
|
|
|
3906
4861
|
const result = yield* neighborsOf({
|
|
3907
4862
|
path: params.path,
|
|
3908
4863
|
depth: opt(params.depth),
|
|
3909
|
-
rels: opt(params.rels)
|
|
4864
|
+
rels: opt(params.rels),
|
|
4865
|
+
limit: opt(params.limit)
|
|
3910
4866
|
});
|
|
4867
|
+
/**
|
|
4868
|
+
* Both truncation markers cross the boundary, and the rename is the only work here.
|
|
4869
|
+
*
|
|
4870
|
+
* The operation reports the clamped ceiling as `limit` and this surface publishes it as
|
|
4871
|
+
* `node_limit`, because a tool response that spelled it `limit` would put the caller's ask and
|
|
4872
|
+
* the walk's 10000-edge-row cap under one word. Dropping either marker would leave a clamped
|
|
4873
|
+
* neighborhood indistinguishable from a complete one, which is the whole reason the operation
|
|
4874
|
+
* counts them.
|
|
4875
|
+
*/
|
|
3911
4876
|
return {
|
|
3912
4877
|
nodes: result.nodes,
|
|
3913
|
-
edges: result.edges
|
|
4878
|
+
edges: result.edges,
|
|
4879
|
+
node_limit: result.limit,
|
|
4880
|
+
dropped_node_count: result.nodesDropped,
|
|
4881
|
+
scan_saturated: result.scanSaturated
|
|
4882
|
+
};
|
|
4883
|
+
})),
|
|
4884
|
+
memory_resolve: (params) => handled(Effect.gen(function* () {
|
|
4885
|
+
const result = yield* resolveMemory(params.path);
|
|
4886
|
+
/**
|
|
4887
|
+
* `pinned_uri` is composed HERE and nowhere else, from `pinnedUri`, the function the resource
|
|
4888
|
+
* that routes that URI exports.
|
|
4889
|
+
*
|
|
4890
|
+
* The operation cannot build it: `@memhtml/cli` knows nothing about the MCP scheme, and a
|
|
4891
|
+
* resolution is a fact about the corpus rather than about a transport. Composing it from
|
|
4892
|
+
* literals in this file would be a second declaration of the published template — the
|
|
4893
|
+
* consumer-side reimplementation of a producer's naming rule this repo has paid for.
|
|
4894
|
+
*
|
|
4895
|
+
* It is withheld in two cases and they are one rule: a URI this server would refuse is not a
|
|
4896
|
+
* citation. There is no commit to pin to before the first rebuild, and `unindexed` is the one
|
|
4897
|
+
* stop reason whose `path` the indexed commit does not hold, so pinning it would hand a client
|
|
4898
|
+
* a receipt that reads `ERR_PATH_NOT_FOUND` forever. Every other stop reason ends on a path the
|
|
4899
|
+
* index holds a row for.
|
|
4900
|
+
*/
|
|
4901
|
+
return {
|
|
4902
|
+
requested: result.requested,
|
|
4903
|
+
path: result.path,
|
|
4904
|
+
hops: result.hops,
|
|
4905
|
+
steps: result.steps,
|
|
4906
|
+
stop_reason: result.stopReason,
|
|
4907
|
+
title: result.title,
|
|
4908
|
+
indexed_commit: result.indexedCommit,
|
|
4909
|
+
pinned_uri: result.indexedCommit === null || result.stopReason === "unindexed" ? null : pinnedUri(result.indexedCommit, result.path)
|
|
3914
4910
|
};
|
|
3915
4911
|
})),
|
|
3916
4912
|
memory_archive: (params) => handled(Effect.gen(function* () {
|
|
@@ -3933,6 +4929,7 @@ const ToolHandlers = MemhtmlToolkit.toLayer({
|
|
|
3933
4929
|
workspace: opt(params.workspace),
|
|
3934
4930
|
tag: opt(params.tag),
|
|
3935
4931
|
entity: opt(params.entity),
|
|
4932
|
+
facets: parseFacetFilters(arr(params.facets)),
|
|
3936
4933
|
para: opt(params.para),
|
|
3937
4934
|
limit: opt(params.limit),
|
|
3938
4935
|
cursor: opt(params.cursor)
|
|
@@ -4001,83 +4998,12 @@ const ToolHandlers = MemhtmlToolkit.toLayer({
|
|
|
4001
4998
|
}))
|
|
4002
4999
|
});
|
|
4003
5000
|
|
|
4004
|
-
//#endregion
|
|
4005
|
-
//#region apps/mcp/src/resources.ts
|
|
4006
|
-
/**
|
|
4007
|
-
* The two resources, design.md §8.
|
|
4008
|
-
*
|
|
4009
|
-
* A resource is for CITATION-grade drill-down: a client that got a path from `memory_search` can
|
|
4010
|
-
* fetch `memhtml://file/<path>` and show a human the file behind an answer, without spending a tool call
|
|
4011
|
-
* and without the tool response having had to carry the whole body.
|
|
4012
|
-
*
|
|
4013
|
-
* `McpSchema.param` names each template parameter, so `tools/list`'s sibling `resources/templates`
|
|
4014
|
-
* publishes `{path}` and `{run-id}` as named rather than positional holes.
|
|
4015
|
-
*/
|
|
4016
|
-
/** `memhtml://file/{path}`: one memory's rendered content. */
|
|
4017
|
-
const pathParam = McpSchema.param("path", Schema.String);
|
|
4018
|
-
/** `memhtml://sleep/{run-id}`: one sleep run's committed HTML report. */
|
|
4019
|
-
const runIdParam = McpSchema.param("run-id", Schema.String);
|
|
4020
|
-
/**
|
|
4021
|
-
* A memory file, by path.
|
|
4022
|
-
*
|
|
4023
|
-
* The BODY is returned, not the raw HTML file. A client asking a resource for a citation wants the
|
|
4024
|
-
* text a human reads; the markup is the storage format, and handing back a full document with a head
|
|
4025
|
-
* full of `memhtml-*` metas would spend a client's rendering budget on bookkeeping. The metadata is
|
|
4026
|
-
* available through `memory_read`, which is the tool for exactly that.
|
|
4027
|
-
*
|
|
4028
|
-
* A missing path fails the read rather than answering with an empty resource: a citation that
|
|
4029
|
-
* silently resolves to nothing is worse than one that says the file is gone.
|
|
4030
|
-
*
|
|
4031
|
-
* This read BUMPS salience, through the same `readMemory` the `memory_read` tool calls. The bump is
|
|
4032
|
-
* deliberate: the caller named one specific path, which is a chosen open. A client
|
|
4033
|
-
* fetching the file behind an answer is making the same statement an agent makes with `memory_read`,
|
|
4034
|
-
* and the plane should not be able to tell them apart.
|
|
4035
|
-
*/
|
|
4036
|
-
const FileResource = McpServer.resource`memhtml://file/${pathParam}`({
|
|
4037
|
-
name: "Memory file",
|
|
4038
|
-
description: "One memory's title, claim, and body text, by repo-root-relative path. For showing a human the file behind an answer.",
|
|
4039
|
-
mimeType: "text/plain",
|
|
4040
|
-
content: (_uri, path) => Effect.gen(function* () {
|
|
4041
|
-
const result = yield* readMemory(path);
|
|
4042
|
-
return [
|
|
4043
|
-
`# ${result.doc.title}`,
|
|
4044
|
-
"",
|
|
4045
|
-
result.doc.article.gist,
|
|
4046
|
-
"",
|
|
4047
|
-
result.doc.article.bodyText
|
|
4048
|
-
].join("\n");
|
|
4049
|
-
}).pipe(Effect.orDie)
|
|
4050
|
-
});
|
|
4051
|
-
/**
|
|
4052
|
-
* A sleep run's report, by run id.
|
|
4053
|
-
*
|
|
4054
|
-
* The report is a COMMITTED file under `.memhtml/sleep/`, so this resource reads the tree rather than the
|
|
4055
|
-
* database: the report is the durable artifact of a run and the `sleep_runs` row is reporting
|
|
4056
|
-
* convenience. A run id arrives as `sleep/2026-08-02`, and the file is named for its last segment.
|
|
4057
|
-
*/
|
|
4058
|
-
const SleepResource = McpServer.resource`memhtml://sleep/${runIdParam}`({
|
|
4059
|
-
name: "Sleep run report",
|
|
4060
|
-
description: "One sleep run's committed HTML report: per-phase counts, commits, and what the run changed.",
|
|
4061
|
-
mimeType: "text/html",
|
|
4062
|
-
content: (_uri, runId) => Effect.gen(function* () {
|
|
4063
|
-
const roots = yield* Roots;
|
|
4064
|
-
const name = runId.split("/").at(-1) ?? runId;
|
|
4065
|
-
const path = join(roots.memhtmlRoot, SLEEP_REPORTS_DIR, `${name}.html`);
|
|
4066
|
-
return yield* Effect.tryPromise({
|
|
4067
|
-
try: () => readFile(path, "utf8"),
|
|
4068
|
-
catch: (cause) => cause
|
|
4069
|
-
});
|
|
4070
|
-
}).pipe(Effect.orDie)
|
|
4071
|
-
});
|
|
4072
|
-
/** Both resources as one layer, for the server to provide. */
|
|
4073
|
-
const Resources = Layer.mergeAll(FileResource, SleepResource);
|
|
4074
|
-
|
|
4075
5001
|
//#endregion
|
|
4076
5002
|
//#region apps/mcp/src/server.ts
|
|
4077
5003
|
const SERVER_NAME = "memhtml";
|
|
4078
|
-
const SERVER_VERSION = "0.
|
|
5004
|
+
const SERVER_VERSION = "0.7.0";
|
|
4079
5005
|
/**
|
|
4080
|
-
* The server as one layer:
|
|
5006
|
+
* The server as one layer: fifteen tools, three resources, over the CLI's own `AppLive`.
|
|
4081
5007
|
*
|
|
4082
5008
|
* The same composition the CLI builds, deliberately. An MCP server with its own layer graph would be
|
|
4083
5009
|
* a second set of answers to which database file, which git root, and which vector space. An agent
|
|
@@ -4091,8 +5017,8 @@ const SERVER_VERSION = "0.6.0";
|
|
|
4091
5017
|
* **There is no server-level `instructions` here, because effect provides no way to set one.** MCP
|
|
4092
5018
|
* defines an `instructions` field on the initialize response for exactly the cross-tool guidance
|
|
4093
5019
|
* this server wants to give (when to batch, the three doors, the commit duty), and effect does not
|
|
4094
|
-
* emit it. Verified against 4.0.0-
|
|
4095
|
-
* DECLARES `instructions: optional(Schema.String)` on
|
|
5020
|
+
* emit it. Verified against effect 4.0.0-rc.109 in the dependency's own declarations: `McpSchema`
|
|
5021
|
+
* DECLARES `instructions: optional(Schema.String)` on `InitializeResult`, while `layerStdio`'s
|
|
4096
5022
|
* options are `{name, version, protocols, extensions}`, so there is not even an argument to pass,
|
|
4097
5023
|
* and the handler that builds the result supplies none.
|
|
4098
5024
|
*
|