@mnemoverse/mcp-memory-server 0.8.0 → 0.8.1
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 +39 -5
- package/dist/index.d.ts +23 -1
- package/dist/index.js +664 -122
- package/dist/index.js.map +1 -1
- package/dist/names.d.ts +168 -0
- package/dist/names.js +216 -0
- package/dist/names.js.map +1 -0
- package/dist/render.d.ts +80 -6
- package/dist/render.js +99 -10
- package/dist/render.js.map +1 -1
- package/dist/requests.d.ts +69 -0
- package/dist/requests.js +76 -0
- package/dist/requests.js.map +1 -0
- package/dist/scope.d.ts +223 -0
- package/dist/scope.js +480 -0
- package/dist/scope.js.map +1 -0
- package/dist/teaching.d.ts +68 -24
- package/dist/teaching.js +124 -30
- package/dist/teaching.js.map +1 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -4,7 +4,39 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { formatReadItem, formatRecentPage, safeInline, } from "./render.js";
|
|
7
|
+
// `NO_MATCH_MESSAGE` is no longer imported here: this file used to pick between
|
|
8
|
+
// it and buildReadEmptyResponse by testing a note string for truthiness, which
|
|
9
|
+
// is how "we could not check" came to be spelled like "the store is there".
|
|
10
|
+
// Assembling the whole answer belongs in one place — src/teaching.ts.
|
|
7
11
|
import { SERVER_INSTRUCTIONS, buildReadEmptyResponse } from "./teaching.js";
|
|
12
|
+
import { classifyRooms, futureSinceNote, probeReadScope, readScopeNote, scopeLabel, } from "./scope.js";
|
|
13
|
+
import { readRequestBody, recentRequestBody, writeRequestBody, searchedScope, } from "./requests.js";
|
|
14
|
+
import { domainPhrase, exactLiteral, formatDomainList, roomNamePhrase, withDomainEscapeLegend, } from "./names.js";
|
|
15
|
+
/**
|
|
16
|
+
* What we know about the scope this read actually covered — a VALUE rather
|
|
17
|
+
* than a sentence-or-empty-string. Costs one GET (rooms or stats, chosen by
|
|
18
|
+
* the scope) and runs on zero-result paths only — but it is not that path's
|
|
19
|
+
* only probe: the plain unscoped empty read also hands buildReadEmptyResponse
|
|
20
|
+
* a stats call for the first-contact greeting, so that answer makes two
|
|
21
|
+
* probes where 0.8.0 made one, and the scoped/filtered answers make one where
|
|
22
|
+
* 0.8.0 made none. Disclosed in the 0.8.1 CHANGELOG entry.
|
|
23
|
+
*
|
|
24
|
+
* This replaces `domainMissNote`, whose `catch { return ""; }` made three
|
|
25
|
+
* different states share one spelling: the store is there and the query missed,
|
|
26
|
+
* the room is there and the query missed, and the probe failed. The caller then
|
|
27
|
+
* read that string as a boolean, so "we could not check" was rendered exactly
|
|
28
|
+
* like "the store exists" — the collision this release is about, at the point
|
|
29
|
+
* where the whole scoped path converges. The states are now arms of a union
|
|
30
|
+
* (src/scope.ts) and every consumer must answer for each of them.
|
|
31
|
+
*
|
|
32
|
+
* The probe routing lives in src/scope.ts: a room address is checked against the
|
|
33
|
+
* ROOM list and a domain against `/memory/stats`, because stats never contains a
|
|
34
|
+
* room address (CodeRabbit #65). `searched` is the RAW value that went over the
|
|
35
|
+
* wire, so the diagnosis can never describe a different store than the request.
|
|
36
|
+
*/
|
|
37
|
+
function probeScope(searched) {
|
|
38
|
+
return probeReadScope(searched, () => apiFetch("/memory/stats"), () => apiFetch("/memory/rooms"), safeInline);
|
|
39
|
+
}
|
|
8
40
|
// Version is read at runtime from package.json so there is exactly one place
|
|
9
41
|
// to bump on each release. Works both from `dist/` during local dev and from
|
|
10
42
|
// `node_modules/@mnemoverse/mcp-memory-server/dist/` after an npm install.
|
|
@@ -69,7 +101,13 @@ async function apiFetch(path, options = {}) {
|
|
|
69
101
|
* result stays well-formed. Otherwise an emoji or non-BMP character at the
|
|
70
102
|
* boundary can produce a lone surrogate and corrupt downstream JSON encoding.
|
|
71
103
|
*/
|
|
72
|
-
function capResult(text,
|
|
104
|
+
function capResult(text,
|
|
105
|
+
// The default recommends ONLY the control that works. A previous draft also
|
|
106
|
+
// said "or smaller top_k" — but top_k is not a hard cap (association
|
|
107
|
+
// expansion can return more, the relevance floor fewer; the same query at
|
|
108
|
+
// 1/5/20 returned 6/7/4 items), which this release's own top_k description
|
|
109
|
+
// already admits. One surface must not recommend the knob another refutes.
|
|
110
|
+
moreHint = "Use a more specific query to see all results.") {
|
|
73
111
|
if (text.length <= MAX_RESULT_CHARS)
|
|
74
112
|
return text;
|
|
75
113
|
let truncated = text.slice(0, MAX_RESULT_CHARS - 200);
|
|
@@ -78,26 +116,77 @@ function capResult(text, moreHint = "Use a more specific query or smaller top_k
|
|
|
78
116
|
truncated = truncated.slice(0, -1);
|
|
79
117
|
}
|
|
80
118
|
// `moreHint` lets no-input tools (the discovery lists) give accurate truncation
|
|
81
|
-
// guidance instead of the read-tool default (which points at query
|
|
119
|
+
// guidance instead of the read-tool default (which points at a query control a
|
|
82
120
|
// repeated no-arg call cannot use). Existing callers keep the default message.
|
|
83
121
|
return `${truncated}\n\n[…truncated to fit the 25K token limit. ${moreHint}]`;
|
|
84
122
|
}
|
|
85
123
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* `
|
|
92
|
-
*
|
|
93
|
-
*
|
|
124
|
+
* A 200 whose body does not carry the array core always sends for a listing.
|
|
125
|
+
*
|
|
126
|
+
* Reading such a body as an EMPTY list is the substitution this release exists
|
|
127
|
+
* to remove: `Array.isArray(x) ? x : []` turned "this client could not read
|
|
128
|
+
* the response" into "there is nothing there" — an absence claim on zero
|
|
129
|
+
* evidence. `classifyRooms` (src/scope.ts) and the stats domains guard closed
|
|
130
|
+
* it for rooms and domains; this builder is the same answer for the remaining
|
|
131
|
+
* list surfaces (search results, the feed, the vault), phrased the way the
|
|
132
|
+
* room list already phrases it. The three parts are the subject, what the body
|
|
133
|
+
* is NOT ("a list of your rooms"), and the absence it is NOT evidence of
|
|
134
|
+
* ("you have none") — so every consumer states its own boundary while the
|
|
135
|
+
* sentence stays one sentence everywhere (truth F13, 2026-08-08).
|
|
136
|
+
*/
|
|
137
|
+
function unreadableListReply(subject, notA, absence) {
|
|
138
|
+
return {
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: "text",
|
|
142
|
+
// The tail attributes the unreadable 200 to "whatever answered this
|
|
143
|
+
// call", not to "the memory service" — this client cannot establish
|
|
144
|
+
// WHO answered: a gateway, a proxy, or the endpoint a mis-set
|
|
145
|
+
// MNEMOVERSE_API_URL points at produces the same 200 with an
|
|
146
|
+
// unrecognised body (truth re-verification, 2026-08-09). Pinned in
|
|
147
|
+
// test/handlers.test.ts.
|
|
148
|
+
text: `${subject} came back in a shape this client does not recognise — so this ` +
|
|
149
|
+
`is not ${notA}, and it is not evidence that ${absence}. Retry; ` +
|
|
150
|
+
`if it persists, whatever answered this call — the memory service, a ` +
|
|
151
|
+
`gateway or proxy in front of it, or the endpoint a mis-set ` +
|
|
152
|
+
`MNEMOVERSE_API_URL points at — is answering in a shape this client ` +
|
|
153
|
+
`cannot read.`,
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Two renderers, and which one a value gets is a decision, not a style choice.
|
|
160
|
+
*
|
|
161
|
+
* `safeInline` (src/render.ts) SANITISES an untrusted display string for inline
|
|
162
|
+
* rendering in tool output that a DIFFERENT principal's LLM will read (CN-032
|
|
163
|
+
* anti-injection): strip to a conservative charset, collapse whitespace, cap
|
|
164
|
+
* the length. The treatment `formatAuthorTag` applies to a server-stamped
|
|
165
|
+
* author, and the defensive second pass the machine-shaped room fields
|
|
166
|
+
* (address, room_id, role, scope — all charset-validated or enum-shaped in
|
|
167
|
+
* core) get here. It is lossy on purpose, and everything it still renders is a
|
|
168
|
+
* value the reader looks at and never has to retype or compare.
|
|
169
|
+
*
|
|
170
|
+
* `exactLiteral` / `domainPhrase` / `roomNamePhrase` (src/names.ts) print a
|
|
171
|
+
* value as a JSON string literal, or refuse to print it. Domain names because
|
|
172
|
+
* the engine matches them byte-for-byte, so the reader must be able to send
|
|
173
|
+
* the exact bytes back. Room NAMES (0.8.1) because the sanitiser did not make
|
|
174
|
+
* them harmless so much as it made them WRONG: "проект" echoed back as `""` on
|
|
175
|
+
* create, "Zoë" as "Zo" inside quotes that claim to be the name. The literal
|
|
176
|
+
* is one line with quotes, backslashes and invisibles escaped, so it is as
|
|
177
|
+
* injection-safe as the sanitised spelling was — without the renaming.
|
|
94
178
|
*/
|
|
95
179
|
// --- Server setup ---
|
|
96
180
|
// The second argument lands verbatim in the connected model's system prompt on
|
|
97
181
|
// clients that surface MCP instructions — it is the single highest-leverage
|
|
98
182
|
// teaching surface this server has. Kept in src/teaching.ts so tests can
|
|
99
183
|
// assert its polarity/length without booting the server.
|
|
100
|
-
|
|
184
|
+
//
|
|
185
|
+
// EXPORTED so a test can connect a real MCP client to this exact server over
|
|
186
|
+
// the SDK's in-memory transport and invoke the tools (test/harness.ts). Nothing
|
|
187
|
+
// on the published CLI path reads this binding — see the note at the bottom of
|
|
188
|
+
// the file for why the export alone is not enough, and what the CLI still does.
|
|
189
|
+
export const server = new McpServer({
|
|
101
190
|
name: "mnemoverse-memory",
|
|
102
191
|
version: pkg.version,
|
|
103
192
|
}, { instructions: SERVER_INSTRUCTIONS });
|
|
@@ -127,15 +216,48 @@ server.registerTool("memory_write", {
|
|
|
127
216
|
openWorldHint: true,
|
|
128
217
|
},
|
|
129
218
|
}, async ({ content, concepts, domain }) => {
|
|
219
|
+
// NO NORMALISATION HERE — deliberately, after a review found two ways it
|
|
220
|
+
// breaks (2026-08-08). Trimming looked like an obvious win: domain names
|
|
221
|
+
// are matched byte-for-byte in core, so " engineering" opens a permanent
|
|
222
|
+
// second store beside "engineering". But:
|
|
223
|
+
//
|
|
224
|
+
// 1. Core REJECTS a non-canonical room address on purpose — 400
|
|
225
|
+
// "Non-canonical room address", so a write "can't be mis-routed and
|
|
226
|
+
// tagged with a spoofed xroom domain" in its own words. Trimming
|
|
227
|
+
// " xroom:room_01ABC" normalises past that guard, and the atom lands
|
|
228
|
+
// in the ROOM's store, visible to every member. Content that never
|
|
229
|
+
// left the caller in 0.8.0 would leave the account in 0.8.1. The
|
|
230
|
+
// address in that shape is one our own output hands the model.
|
|
231
|
+
// 2. For a caller who has been padding a domain for months, trimming
|
|
232
|
+
// silently relocates new writes and orphans the old corpus — and
|
|
233
|
+
// memory_delete_domain does NOT trim, so the same client can no
|
|
234
|
+
// longer even name the shard it created.
|
|
235
|
+
//
|
|
236
|
+
// Both are behaviour changes, so they do not belong in a patch whose
|
|
237
|
+
// whole claim is that it only changes wording. Normalisation returns in
|
|
238
|
+
// 0.9.0 with delete_domain included, room addresses deliberately EXEMPT,
|
|
239
|
+
// and zero-width characters handled (JS trim() does not strip them —
|
|
240
|
+
// verified, contrary to what an earlier comment here asserted).
|
|
130
241
|
const r = await apiFetch("/memory/write", {
|
|
131
242
|
method: "POST",
|
|
132
|
-
body: JSON.stringify({
|
|
133
|
-
content,
|
|
134
|
-
concepts: concepts || [],
|
|
135
|
-
domain: domain || "general",
|
|
136
|
-
}),
|
|
243
|
+
body: JSON.stringify(writeRequestBody({ content, concepts, domain })),
|
|
137
244
|
});
|
|
138
|
-
|
|
245
|
+
// "unknown", not 0.00, when the server didn't send a score — the same rule
|
|
246
|
+
// memory_stats got in this release. A live surface exists that answers
|
|
247
|
+
// {"stored":false} with no reason and no score; printing "0.00" there
|
|
248
|
+
// fabricates the gate's verdict (review, 2026-08-08).
|
|
249
|
+
const importance = typeof r?.importance === "number" ? r.importance.toFixed(2) : "unknown";
|
|
250
|
+
// `Server reason:` is a VERBATIM label, and safeInline made it a lie on
|
|
251
|
+
// every occurrence: core's only rejection reason is
|
|
252
|
+
// "Below importance threshold (0.412 < 0.500)", whose parentheses and `<`
|
|
253
|
+
// are outside the sanitiser's charset — so the relay read "Below importance
|
|
254
|
+
// threshold 0.412 0.500", with the comparison operator and both delimiters
|
|
255
|
+
// deleted and the two numbers left unlabelled and order-only. Quoted
|
|
256
|
+
// exactly instead (src/names.ts). If it will not fit, say THAT rather than
|
|
257
|
+
// drop the clause: omitting it would report a server that gave no reason.
|
|
258
|
+
const reasonQuote = r?.reason
|
|
259
|
+
? (exactLiteral(r.reason, 400)?.literal ?? "(too long to quote exactly)")
|
|
260
|
+
: "";
|
|
139
261
|
if (r?.stored) {
|
|
140
262
|
return {
|
|
141
263
|
content: [
|
|
@@ -146,11 +268,61 @@ server.registerTool("memory_write", {
|
|
|
146
268
|
],
|
|
147
269
|
};
|
|
148
270
|
}
|
|
271
|
+
// NOT STORED. The old wording ("Filtered — …") named the mechanism but
|
|
272
|
+
// never the outcome, so a caller could read it as a soft success and move
|
|
273
|
+
// on. In dogfooding this ate a CORRECTION to a wrong fact: the stale
|
|
274
|
+
// version stayed as the only record, and looked more authoritative for
|
|
275
|
+
// having no competitor (2026-08-07).
|
|
276
|
+
//
|
|
277
|
+
// The ADVICE was wrong until 2026-08-08, and wrong in a way that made
|
|
278
|
+
// things worse. It told the caller to rewrite the content as a cleaner
|
|
279
|
+
// factual statement — but the gate scores GEOMETRIC NOVELTY against the
|
|
280
|
+
// nearest existing atom in the same domain, not phrasing or factuality.
|
|
281
|
+
// "Below importance threshold" means TOO SIMILAR TO SOMETHING ALREADY
|
|
282
|
+
// STORED. A rewrite of the same fact therefore produces a near-identical
|
|
283
|
+
// embedding, scores the same or lower, and is rejected again — and the old
|
|
284
|
+
// text ended with "write it again", so a compliant agent looped. It was
|
|
285
|
+
// anti-correlated with the mechanism in exactly the case it was written
|
|
286
|
+
// for: a correction, which is by nature similar to what it corrects.
|
|
149
287
|
return {
|
|
150
288
|
content: [
|
|
151
289
|
{
|
|
152
290
|
type: "text",
|
|
153
|
-
|
|
291
|
+
// WHAT IS CONDITIONAL HERE, stated exactly, because a previous
|
|
292
|
+
// version of this comment claimed more than the code does.
|
|
293
|
+
//
|
|
294
|
+
// Conditional: the SERVER'S VERDICT and the SCORE. `Server reason:`
|
|
295
|
+
// is printed only when `reason` came back, and `Novelty score` only
|
|
296
|
+
// when a numeric `importance` did — a live surface answers
|
|
297
|
+
// `{"stored":false}` with neither, and quoting a verdict nobody sent
|
|
298
|
+
// would be a claim on zero evidence.
|
|
299
|
+
//
|
|
300
|
+
// Unconditional: the MECHANISM sentence below, and it is not derived
|
|
301
|
+
// from this response. It is a statement about core: `/memory/write`
|
|
302
|
+
// refuses for exactly one reason — the importance gate, scoring
|
|
303
|
+
// geometric novelty against the nearest existing atom in the same
|
|
304
|
+
// domain, with "Below importance threshold (x < y)" as its only text
|
|
305
|
+
// (two branches in memory_engine, one reason). The BATCH endpoint has
|
|
306
|
+
// other failure paths; this client does not call it. So for any
|
|
307
|
+
// rejection this client can receive, that sentence is true whether or
|
|
308
|
+
// not the server bothered to say why. The earlier comment here read
|
|
309
|
+
// "the cause is named only when the server named it", which describes
|
|
310
|
+
// a draft that did not carry this sentence at all.
|
|
311
|
+
//
|
|
312
|
+
// NOT PRESENT AT ALL: a prediction about the retry. "Rewording will
|
|
313
|
+
// score the same or lower" was asserted as fact and is probably
|
|
314
|
+
// BACKWARDS — novelty decreases with similarity to the blocking
|
|
315
|
+
// memory, so a reworded sentence is usually LESS similar and scores
|
|
316
|
+
// HIGHER. And the delete-then-write advice an earlier draft carried is
|
|
317
|
+
// impossible for a room write: the blocker is a room atom, which
|
|
318
|
+
// memory_delete cannot touch, as this file's own delete message says
|
|
319
|
+
// (reviews, 2026-08-08).
|
|
320
|
+
text: `NOT STORED — nothing was saved.` +
|
|
321
|
+
(reasonQuote ? ` Server reason: ${reasonQuote}.` : ``) +
|
|
322
|
+
(importance === "unknown" ? `` : ` Novelty score ${importance}.`) +
|
|
323
|
+
` Writes are gated on how much a memory adds over what is already in the` +
|
|
324
|
+
` same domain, so a near-duplicate is refused. If the point is genuinely` +
|
|
325
|
+
` new, write what is DIFFERENT rather than restating the whole fact.`,
|
|
154
326
|
},
|
|
155
327
|
],
|
|
156
328
|
};
|
|
@@ -170,11 +342,11 @@ server.registerTool("memory_read", {
|
|
|
170
342
|
.min(1)
|
|
171
343
|
.max(50)
|
|
172
344
|
.optional()
|
|
173
|
-
.describe("
|
|
345
|
+
.describe("Requested number of results (default: 5). ⚠️ Not a hard cap: association expansion can return MORE than this, and the relevance floor can return fewer — raising it does not reliably widen the result set. For a complete, exactly-bounded listing use memory_list_recent instead."),
|
|
174
346
|
domain: z
|
|
175
347
|
.string()
|
|
176
348
|
.optional()
|
|
177
|
-
.describe("Restrict the search to one domain namespace (e.g. 'project:acme')
|
|
349
|
+
.describe("Restrict the search to one domain namespace (e.g. 'project:acme'). Omitting it searches your OWN domains — it does NOT include shared rooms, which are separate stores: to search a room, pass its address here (e.g. 'xroom:room_01ABC'). Find room addresses with memory_list_rooms."),
|
|
178
350
|
order_by: z
|
|
179
351
|
.enum(["relevance", "recency"])
|
|
180
352
|
.optional()
|
|
@@ -196,10 +368,13 @@ server.registerTool("memory_read", {
|
|
|
196
368
|
.string()
|
|
197
369
|
.max(200)
|
|
198
370
|
.optional()
|
|
199
|
-
.describe("Drop memories written by this author PRINCIPAL
|
|
200
|
-
"identity
|
|
201
|
-
"
|
|
202
|
-
"
|
|
371
|
+
.describe("Drop memories written by this author PRINCIPAL — the server-side " +
|
|
372
|
+
"identity. ⚠️ NOT USABLE FROM HERE YET: the principal is not shown " +
|
|
373
|
+
"in these results, so there is no value you can obtain through " +
|
|
374
|
+
"this tool, and a guess like 'me' silently matches nothing and " +
|
|
375
|
+
"filters nothing. Only pass it if your system knows the exact " +
|
|
376
|
+
"principal from elsewhere (e.g. the REST API). A self-exclusion " +
|
|
377
|
+
"shortcut is planned."),
|
|
203
378
|
},
|
|
204
379
|
annotations: {
|
|
205
380
|
title: "Search Memories",
|
|
@@ -209,48 +384,99 @@ server.registerTool("memory_read", {
|
|
|
209
384
|
openWorldHint: true,
|
|
210
385
|
},
|
|
211
386
|
}, async ({ query, top_k, domain, order_by, since, until, exclude_author }) => {
|
|
387
|
+
// ONE value, used for the request AND for every decision about it.
|
|
388
|
+
//
|
|
389
|
+
// A previous draft sent the raw string but decided the wording from a
|
|
390
|
+
// TRIMMED copy, and the two disagreed in the release's own founding case:
|
|
391
|
+
// a read on " engineering" searched the padded store (correct) while the
|
|
392
|
+
// diagnosis checked "engineering", found it, and stayed silent — so the
|
|
393
|
+
// one note that would have said "a stray space makes a different store"
|
|
394
|
+
// was suppressed exactly when a stray space had made a different store.
|
|
395
|
+
// For a whitespace-only domain it went the other way and claimed the
|
|
396
|
+
// search had covered the caller's own domains when it had covered none
|
|
397
|
+
// (reviews, 2026-08-08).
|
|
398
|
+
//
|
|
399
|
+
// `|| undefined` is what 0.8.0 sent and must stay: core filters on
|
|
400
|
+
// `domain is not None`, not on truthiness, so passing "" through would
|
|
401
|
+
// become `WHERE domain = ''` — a store that cannot exist — turning a
|
|
402
|
+
// search of every domain into a guaranteed miss. That is data movement,
|
|
403
|
+
// not wording, and it does not belong in a patch.
|
|
404
|
+
const searched = searchedScope(domain);
|
|
212
405
|
const r = await apiFetch("/memory/read", {
|
|
213
406
|
method: "POST",
|
|
214
|
-
body: JSON.stringify({
|
|
215
|
-
query,
|
|
216
|
-
top_k: top_k || 5,
|
|
217
|
-
domain: domain || undefined,
|
|
218
|
-
include_associations: true,
|
|
219
|
-
// #404 temporal dimension — omitted entirely when unused so the
|
|
220
|
-
// request body stays byte-identical for existing callers.
|
|
221
|
-
...(order_by ? { order_by } : {}),
|
|
222
|
-
...(since ? { since } : {}),
|
|
223
|
-
...(until ? { until } : {}),
|
|
224
|
-
...(exclude_author ? { exclude_author } : {}),
|
|
225
|
-
}),
|
|
407
|
+
body: JSON.stringify(readRequestBody({ query, top_k, domain, order_by, since, until, exclude_author })),
|
|
226
408
|
});
|
|
227
|
-
|
|
409
|
+
// `items` must be a REAL array before anything below may speak. Core's
|
|
410
|
+
// read response always carries one on a 200, so a body without it is not
|
|
411
|
+
// core's answer — and the old `Array.isArray(r?.items) ? r.items : []`
|
|
412
|
+
// fed exactly that body to the entire zero-result machinery: head
|
|
413
|
+
// sentence, scope probe, diagnosis. An absence claim derived from a body
|
|
414
|
+
// this client could not read — the substitution classifyRooms removed for
|
|
415
|
+
// rooms, one level up from the probes (truth F13, 2026-08-08).
|
|
416
|
+
const items = r?.items;
|
|
417
|
+
if (!Array.isArray(items)) {
|
|
418
|
+
return unreadableListReply("The search result", "a list of matches", "nothing matched");
|
|
419
|
+
}
|
|
228
420
|
if (items.length === 0 && (since || until || exclude_author)) {
|
|
229
421
|
// A bounded/filtered read that finds nothing is NOT a bad query —
|
|
230
|
-
// the truthful answer is "nothing new for these filters" (
|
|
231
|
-
//
|
|
422
|
+
// the truthful answer is "nothing new for these filters" (the feed's
|
|
423
|
+
// filtered head uses the same sentence; no stats probe, no broaden
|
|
424
|
+
// hint). Unscoped, it also has to name the rooms it never looked in.
|
|
425
|
+
const scopeNote = readScopeNote(await probeScope(searched));
|
|
232
426
|
return {
|
|
233
427
|
content: [
|
|
234
428
|
{
|
|
235
429
|
type: "text",
|
|
236
|
-
|
|
430
|
+
// The scope is IN the sentence, not appended after it. This
|
|
431
|
+
// branch was already the honest one in 0.8.0 — it names its
|
|
432
|
+
// filters — and it is the model the feed's copy now follows.
|
|
433
|
+
//
|
|
434
|
+
// The legend wraps the WHOLE message and is added at most once:
|
|
435
|
+
// `scopeNote` may itself have named a store (a case-twin), and one
|
|
436
|
+
// explanation of the escaping per answer is the point of it.
|
|
437
|
+
text: withDomainEscapeLegend(`Nothing in ${scopeLabel(searched)} matches within the given time/author filters.` +
|
|
438
|
+
futureSinceNote(since, Date.now()) +
|
|
439
|
+
scopeNote, searched),
|
|
237
440
|
},
|
|
238
441
|
],
|
|
239
442
|
};
|
|
240
443
|
}
|
|
241
444
|
if (items.length === 0) {
|
|
242
|
-
// Zero results
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
445
|
+
// Zero results. The scope is probed FIRST and the whole answer is then
|
|
446
|
+
// assembled from that ONE value in src/teaching.ts — head sentence and
|
|
447
|
+
// disclosure together, per state.
|
|
448
|
+
//
|
|
449
|
+
// ORDER MATTERS and is now structural: whether the caller has rooms we
|
|
450
|
+
// could not search decides whether the first-contact greeting is even
|
|
451
|
+
// true, so the room knowledge is an INPUT to the answer rather than a flag
|
|
452
|
+
// consulted beside it. `total_atoms` counts the personal org only, so a
|
|
453
|
+
// joiner with three full rooms and no personal writes would otherwise be
|
|
454
|
+
// told "nothing has been saved yet" and contradicted by the note right
|
|
455
|
+
// underneath (review, 2026-08-08).
|
|
456
|
+
//
|
|
457
|
+
// Nothing is appended here any more. While the head came from teaching.ts
|
|
458
|
+
// and the tail was concatenated at this line, the two could disagree — a
|
|
459
|
+
// head promising "that is not the whole picture:" with an empty tail after
|
|
460
|
+
// it was a live bug for an account whose only room was archived.
|
|
461
|
+
const scope = await probeScope(searched);
|
|
462
|
+
const text = await buildReadEmptyResponse(() => apiFetch("/memory/stats"), scope);
|
|
252
463
|
return {
|
|
253
|
-
content: [
|
|
464
|
+
content: [
|
|
465
|
+
{
|
|
466
|
+
type: "text",
|
|
467
|
+
// NO legend wrapper here, on purpose — `withDomainEscapeLegend(
|
|
468
|
+
// text, searched)` stood on this line and was dead code that
|
|
469
|
+
// looked load-bearing (tests-lens F9, 2026-08-08): every arm of
|
|
470
|
+
// buildReadEmptyResponse either names no store at all (fixed
|
|
471
|
+
// sentences), or names it inside a note that appends its own
|
|
472
|
+
// legend (the case-twin diagnosis, src/scope.ts) — and the
|
|
473
|
+
// unscoped path passes `searched === undefined`, which can never
|
|
474
|
+
// need one. So there was no input on which the wrapper fired.
|
|
475
|
+
// Pinned by "the plain-empty read is legended by its notes" in
|
|
476
|
+
// test/handlers.test.ts.
|
|
477
|
+
text,
|
|
478
|
+
},
|
|
479
|
+
],
|
|
254
480
|
};
|
|
255
481
|
}
|
|
256
482
|
// Rendering lives in src/render.ts (testable): each line carries the
|
|
@@ -260,25 +486,40 @@ server.registerTool("memory_read", {
|
|
|
260
486
|
// the old render never delivered them, making both uncallable from
|
|
261
487
|
// read results).
|
|
262
488
|
const lines = items.map((item, i) => formatReadItem(item, i));
|
|
489
|
+
// `?? 0` prints a FABRICATED `(0ms)` when the server sent no timing — the
|
|
490
|
+
// same class as "Associations: 0" for an unknown count, which memory_stats
|
|
491
|
+
// fixed in this release. Untouched here and recorded in CHANGELOG's "Known
|
|
492
|
+
// and NOT fixed here" with the other three.
|
|
263
493
|
const searchMs = (r?.search_time_ms ?? 0).toFixed(0);
|
|
264
494
|
const text = lines.join("\n\n") + `\n\n(${searchMs}ms)`;
|
|
265
495
|
return {
|
|
266
496
|
content: [
|
|
267
497
|
{
|
|
268
498
|
type: "text",
|
|
269
|
-
|
|
499
|
+
// Each line's `@"domain"` tag is an exact literal; the legend that
|
|
500
|
+
// explains an escape belongs to the answer, not to twenty tags.
|
|
501
|
+
//
|
|
502
|
+
// Legend AFTER the cap, never before. capResult truncates from the
|
|
503
|
+
// END, and the legend is appended at the end — so applied first it
|
|
504
|
+
// was the first thing the cap ate, on exactly the pages long enough
|
|
505
|
+
// to need both: a hundred escaped tags left with nothing saying that
|
|
506
|
+
// \u00a0 is ONE character, not six (truth F6, 2026-08-08). Applied
|
|
507
|
+
// to the CAPPED text the legend survives; and since it fires only
|
|
508
|
+
// when an escaped literal is still on the page, a cap that removed
|
|
509
|
+
// every escaped name drops the legend with it.
|
|
510
|
+
text: withDomainEscapeLegend(capResult(text), ...items.map((it) => it?.domain)),
|
|
270
511
|
},
|
|
271
512
|
],
|
|
272
513
|
};
|
|
273
514
|
});
|
|
274
515
|
// --- Tool: memory_list_recent ---
|
|
275
516
|
server.registerTool("memory_list_recent", {
|
|
276
|
-
description: "List the NEWEST memories first — no search query needed. Semantic search answers 'what do I know about X'; this answers 'what happened lately': resuming work after a break, catching up on a shared room ('any new messages?'), or reviewing what was saved recently. Pass `since` (your last-seen time) to get only what's new, and page through older entries with the returned cursor. Complete by construction — nothing is skipped, unlike a search
|
|
517
|
+
description: "List the NEWEST memories first — no search query needed. Semantic search answers 'what do I know about X'; this answers 'what happened lately': resuming work after a break, catching up on a shared room ('any new messages?'), or reviewing what was saved recently. Pass `since` (your last-seen time) to get only what's new, and page through older entries with the returned cursor. Complete by construction WITHIN ONE SCOPE — nothing is skipped there, unlike a semantic search. To catch up on a shared room you MUST pass its address as `domain`: rooms are separate stores and an unscoped call never covers them.",
|
|
277
518
|
inputSchema: {
|
|
278
519
|
domain: z
|
|
279
520
|
.string()
|
|
280
521
|
.optional()
|
|
281
|
-
.describe("Restrict to one domain
|
|
522
|
+
.describe("Restrict to one domain. REQUIRED to read a shared room — pass its address ('xroom:room_01ABC'), because rooms are separate stores that an unscoped feed does NOT cover. Omit only when you mean your own domains. Room addresses come from memory_list_rooms."),
|
|
282
523
|
since: z
|
|
283
524
|
.string()
|
|
284
525
|
.optional()
|
|
@@ -291,7 +532,7 @@ server.registerTool("memory_list_recent", {
|
|
|
291
532
|
.string()
|
|
292
533
|
.max(200)
|
|
293
534
|
.optional()
|
|
294
|
-
.describe("Drop entries written by this author PRINCIPAL — the
|
|
535
|
+
.describe("Drop entries written by this author PRINCIPAL. ⚠️ NOT USABLE FROM HERE YET — the principal is never shown in these results, so there is no value you can get through this tool; a guess like 'me' filters nothing, silently. Same caveat as on memory_read."),
|
|
295
536
|
limit: z
|
|
296
537
|
.number()
|
|
297
538
|
.int()
|
|
@@ -313,28 +554,34 @@ server.registerTool("memory_list_recent", {
|
|
|
313
554
|
openWorldHint: true,
|
|
314
555
|
},
|
|
315
556
|
}, async ({ domain, since, until, exclude_author, limit, cursor }) => {
|
|
557
|
+
// ONE value for the request and for every decision about it — see the note
|
|
558
|
+
// at the top of memory_read.
|
|
559
|
+
const searched = searchedScope(domain);
|
|
316
560
|
let r;
|
|
317
561
|
try {
|
|
318
562
|
r = await apiFetch("/memory/recent", {
|
|
319
563
|
method: "POST",
|
|
320
|
-
body: JSON.stringify({
|
|
321
|
-
domain: domain || undefined,
|
|
322
|
-
since: since || undefined,
|
|
323
|
-
until: until || undefined,
|
|
324
|
-
exclude_author: exclude_author || undefined,
|
|
325
|
-
limit: limit || 20,
|
|
326
|
-
cursor: cursor || undefined,
|
|
327
|
-
}),
|
|
564
|
+
body: JSON.stringify(recentRequestBody({ domain, since, until, exclude_author, limit, cursor })),
|
|
328
565
|
});
|
|
329
566
|
}
|
|
330
567
|
catch (e) {
|
|
331
|
-
// Graceful degradation while the server side rolls out: a 404
|
|
332
|
-
//
|
|
333
|
-
// so instead of surfacing a raw
|
|
334
|
-
|
|
568
|
+
// Graceful degradation while the server side rolls out: a 404 with no
|
|
569
|
+
// error `code` in the body is what an undeployed /memory/recent looks
|
|
570
|
+
// like, so degrade to a usable alternative instead of surfacing a raw
|
|
571
|
+
// HTTP error.
|
|
572
|
+
//
|
|
573
|
+
// NAMED FOR WHAT IT TESTS. This was `endpointAbsent`, which asserted a
|
|
574
|
+
// deployment fact the check cannot establish: every engine 404 carries a
|
|
575
|
+
// `code`, so a real room-404 is excluded, but a gateway, a proxy or a
|
|
576
|
+
// wrong MNEMOVERSE_API_URL produces the same bare 404 and is
|
|
577
|
+
// indistinguishable from here. The MESSAGE below still states the
|
|
578
|
+
// deployment cause outright, which is more than this boolean knows —
|
|
579
|
+
// listed in CHANGELOG's "Known and NOT fixed here" rather than papered
|
|
580
|
+
// over with a hedge.
|
|
581
|
+
const bare404 = e instanceof Error &&
|
|
335
582
|
e.message.startsWith("Mnemoverse API error 404:") &&
|
|
336
583
|
!e.message.includes('"code"');
|
|
337
|
-
if (
|
|
584
|
+
if (bare404) {
|
|
338
585
|
return {
|
|
339
586
|
content: [
|
|
340
587
|
{
|
|
@@ -347,15 +594,64 @@ server.registerTool("memory_list_recent", {
|
|
|
347
594
|
}
|
|
348
595
|
throw e;
|
|
349
596
|
}
|
|
350
|
-
|
|
597
|
+
// Same guard as memory_read: a 200 without an items array is UNREADABLE,
|
|
598
|
+
// not empty — and the feed's empty heads below are precisely the absence
|
|
599
|
+
// claims that must not be derived from it (truth F13, 2026-08-08).
|
|
600
|
+
const items = r?.items;
|
|
601
|
+
if (!Array.isArray(items)) {
|
|
602
|
+
return unreadableListReply("The recent-entries feed", "an empty feed", "there is nothing to list");
|
|
603
|
+
}
|
|
351
604
|
if (items.length === 0) {
|
|
605
|
+
// THE SENTENCE ITSELF carries the scope — and it is selected by EVERY
|
|
606
|
+
// filter that narrowed the window, not by `since` alone.
|
|
607
|
+
//
|
|
608
|
+
// Two prior shapes of this branch were wrong the same way. In 0.8.0 it
|
|
609
|
+
// printed "Nothing new since your watermark." with no scope in it — the
|
|
610
|
+
// sentence src/scope.ts was written to eliminate. The first fix put the
|
|
611
|
+
// scope into the clause but kept choosing BETWEEN the two heads by
|
|
612
|
+
// looking at `since` only, so {until} alone and {exclude_author} alone
|
|
613
|
+
// fell to "No memories in ${where} yet." — an emptiness claim about a
|
|
614
|
+
// store holding a thousand atoms outside the window — and {since, until}
|
|
615
|
+
// kept the watermark phrasing, which pretends the read reached the
|
|
616
|
+
// present when `until` stopped it years short (review, 2026-08-08).
|
|
617
|
+
//
|
|
618
|
+
// Four heads, one rule: the emptiness claim ("yet") is allowed only
|
|
619
|
+
// when NOTHING narrowed the window; the watermark phrasing only when
|
|
620
|
+
// `since` was the whole narrowing; any other filter combination gets the
|
|
621
|
+
// sentence memory_read's filtered branch uses — the filters are named as
|
|
622
|
+
// what bounded the result, and nothing is claimed beyond them.
|
|
623
|
+
//
|
|
624
|
+
// A `cursor` outranks all three. It is not a filter over the store — it
|
|
625
|
+
// is a POSITION in a listing whose earlier pages the caller has already
|
|
626
|
+
// read, so every other head is false here: "No memories in ${where}
|
|
627
|
+
// yet." is an absence claim about a store the caller has just SEEN
|
|
628
|
+
// entries from, and the watermark phrasing pretends a continuation that
|
|
629
|
+
// stopped mid-listing was a clean catch-up. The engine hands out a
|
|
630
|
+
// cursor only when more entries existed at that moment, so an empty
|
|
631
|
+
// continued page means the listing moved under the caller — entries
|
|
632
|
+
// removed between page fetches — and the head speaks about the
|
|
633
|
+
// continuation only, never about what the store holds (follow-up to the
|
|
634
|
+
// head-selection fix, 2026-08-08). Decided by the same truthiness the
|
|
635
|
+
// request used: an empty-string cursor never reaches the wire
|
|
636
|
+
// (src/requests.ts), so it may not pick the sentence either.
|
|
637
|
+
const scopeNote = readScopeNote(await probeScope(searched));
|
|
638
|
+
const where = scopeLabel(searched);
|
|
639
|
+
const head = cursor
|
|
640
|
+
? `Nothing further in ${where} past this cursor — entries may have been ` +
|
|
641
|
+
`removed since the previous page was fetched.` +
|
|
642
|
+
(since || until || exclude_author
|
|
643
|
+
? ` The given time/author filters still bounded this page.`
|
|
644
|
+
: ``)
|
|
645
|
+
: since && !until && !exclude_author
|
|
646
|
+
? `Nothing new in ${where} since your watermark.`
|
|
647
|
+
: since || until || exclude_author
|
|
648
|
+
? `Nothing in ${where} matches within the given time/author filters.`
|
|
649
|
+
: `No memories in ${where} yet.`;
|
|
352
650
|
return {
|
|
353
651
|
content: [
|
|
354
652
|
{
|
|
355
653
|
type: "text",
|
|
356
|
-
text: since
|
|
357
|
-
? "Nothing new since your watermark."
|
|
358
|
-
: "No memories here yet.",
|
|
654
|
+
text: withDomainEscapeLegend(head + futureSinceNote(since, Date.now()) + scopeNote, searched),
|
|
359
655
|
},
|
|
360
656
|
],
|
|
361
657
|
};
|
|
@@ -364,14 +660,22 @@ server.registerTool("memory_list_recent", {
|
|
|
364
660
|
content: [
|
|
365
661
|
{
|
|
366
662
|
type: "text",
|
|
367
|
-
|
|
663
|
+
// The page body comes from src/render.ts; the escape legend is
|
|
664
|
+
// applied HERE, to the CAPPED text. formatRecentPage used to append
|
|
665
|
+
// it itself, which put it before capResult — and capResult truncates
|
|
666
|
+
// from the end, so the one sentence explaining the escapes was the
|
|
667
|
+
// first casualty on every page long enough to be capped (truth F6,
|
|
668
|
+
// 2026-08-08). Same order as memory_read's result page, same
|
|
669
|
+
// automatic drop: a cap that removed every escaped name removes the
|
|
670
|
+
// reason for the legend too.
|
|
671
|
+
text: withDomainEscapeLegend(capResult(formatRecentPage(items, r?.next_cursor), "Lower `limit` or add a `domain` for smaller pages."), ...items.map((it) => it?.domain)),
|
|
368
672
|
},
|
|
369
673
|
],
|
|
370
674
|
};
|
|
371
675
|
});
|
|
372
676
|
// --- Tool: memory_feedback ---
|
|
373
677
|
server.registerTool("memory_feedback", {
|
|
374
|
-
description: "Report whether memories returned by memory_read were actually helpful. This is a learning signal, not a log: positive feedback raises a memory's ranking so it surfaces faster next time (across all of the user's tools), negative feedback lets it fade. Call it right after you act on (or reject) recalled memories, passing the ids from the memory_read results.",
|
|
678
|
+
description: "Report whether memories returned by memory_read were actually helpful. This is a learning signal, not a log: positive feedback raises a memory's ranking so it surfaces faster next time (across all of the user's tools), negative feedback lets it fade. Call it right after you act on (or reject) recalled memories, passing the ids from the memory_read results. NOTE: this reaches your own domains only — it takes no domain argument, so rating a memory that lives in a shared room silently does nothing.",
|
|
375
679
|
inputSchema: {
|
|
376
680
|
atom_ids: z
|
|
377
681
|
.array(z.string())
|
|
@@ -400,13 +704,63 @@ server.registerTool("memory_feedback", {
|
|
|
400
704
|
body: JSON.stringify({ atom_ids, outcome }),
|
|
401
705
|
});
|
|
402
706
|
const count = r?.updated_count ?? 0;
|
|
707
|
+
// "Feedback recorded for 0 memories." is one character away from the
|
|
708
|
+
// success line and reads like one. But the DIAGNOSIS matters as much as
|
|
709
|
+
// the fact: an earlier version of this branch blamed deletion, which is
|
|
710
|
+
// usually the wrong cause (review, 2026-08-08).
|
|
711
|
+
//
|
|
712
|
+
// Core resolves the feedback org from a `domain` argument that defaults to
|
|
713
|
+
// "general" — and THIS TOOL EXPOSES NO domain PARAMETER. So the ordinary
|
|
714
|
+
// way to get zero is to rate atoms that live somewhere else: read a room,
|
|
715
|
+
// take the ids off the `id:` lines, rate them, and every one silently
|
|
716
|
+
// misses. The atoms exist, the ids are valid, and telling the caller they
|
|
717
|
+
// were deleted sends them to look for a problem that isn't there.
|
|
718
|
+
if (count === 0) {
|
|
719
|
+
return {
|
|
720
|
+
content: [
|
|
721
|
+
{
|
|
722
|
+
type: "text",
|
|
723
|
+
text:
|
|
724
|
+
// No frequency claim. "Most often that means…" was a statistic
|
|
725
|
+
// we do not have (review, 2026-08-08); the causes are listed as
|
|
726
|
+
// possibilities, with the one the caller cannot otherwise guess
|
|
727
|
+
// first because it is invisible from the tool surface.
|
|
728
|
+
"No feedback was recorded — none of those ids matched a memory in your own " +
|
|
729
|
+
"domains. Possible causes: the ids came from a shared room (this tool takes " +
|
|
730
|
+
"no domain argument and cannot reach room atoms, so rating them is a no-op); " +
|
|
731
|
+
"the memory was deleted; or the id came from somewhere other than a " +
|
|
732
|
+
"memory_read result.",
|
|
733
|
+
},
|
|
734
|
+
],
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
// Echo the DIRECTION, not just the count: the same four words for +1 and
|
|
738
|
+
// -1 gave a caller no evidence the loop did anything, which is why nobody
|
|
739
|
+
// calls it twice.
|
|
740
|
+
//
|
|
741
|
+
// The effect clause is per-direction. Promising "this shifts how they
|
|
742
|
+
// rank" for outcome 0 would be a claim we cannot make — dogfooding saw a
|
|
743
|
+
// neutral rating move a score UP by about five points, so neither "no
|
|
744
|
+
// change" nor "ranks higher" is safe to assert (CodeRabbit, #65).
|
|
745
|
+
const noun = `${count} memor${count === 1 ? "y" : "ies"}`;
|
|
746
|
+
const text = outcome > 0
|
|
747
|
+
? `Recorded +${outcome} (helpful) for ${noun} — they should surface sooner next time.`
|
|
748
|
+
: outcome < 0
|
|
749
|
+
? `Recorded ${outcome} (unhelpful) for ${noun} — they should fade.`
|
|
750
|
+
// Zero claims only what is knowable from here: the rating was
|
|
751
|
+
// recorded, and ±1 send a clear signal. Two earlier wordings each
|
|
752
|
+
// asserted engine semantics that were false: "use +1/-1 when you want
|
|
753
|
+
// the ranking to move" implied 0 leaves ranking alone (dogfooding saw
|
|
754
|
+
// a neutral rating move a score UP by about five points), and "logged
|
|
755
|
+
// as a recall that was neither useful nor wrong" described a record
|
|
756
|
+
// the engine does not keep — core files outcome 0 on the POSITIVE
|
|
757
|
+
// branch of the valence update (memory_engine.py `_feedback_inner`:
|
|
758
|
+
// sign is +1 for outcome >= 0) and stores a positive-direction
|
|
759
|
+
// valence step plus outcome_count += 1. So 0 is not a neutral log
|
|
760
|
+
// entry, and the printed line must not present it as one.
|
|
761
|
+
: `Recorded 0 for ${noun}. Use +1 (helpful) or -1 (harmful/wrong) to express a clear direction.`;
|
|
403
762
|
return {
|
|
404
|
-
content: [
|
|
405
|
-
{
|
|
406
|
-
type: "text",
|
|
407
|
-
text: `Feedback recorded for ${count} memor${count === 1 ? "y" : "ies"}.`,
|
|
408
|
-
},
|
|
409
|
-
],
|
|
763
|
+
content: [{ type: "text", text }],
|
|
410
764
|
};
|
|
411
765
|
});
|
|
412
766
|
// --- Tool: memory_stats ---
|
|
@@ -422,20 +776,59 @@ server.registerTool("memory_stats", {
|
|
|
422
776
|
},
|
|
423
777
|
}, async () => {
|
|
424
778
|
const r = await apiFetch("/memory/stats");
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
779
|
+
// A field the server did not send is UNKNOWN, not zero. Rendering it as
|
|
780
|
+
// "0" is the same class of lie as an empty search claiming emptiness:
|
|
781
|
+
// "Associations: 0" reads as "this memory has learned nothing", which is
|
|
782
|
+
// a strong and possibly false statement about the product itself.
|
|
783
|
+
const num = (v) => (typeof v === "number" ? String(v) : "unknown");
|
|
784
|
+
const dec = (v) => (typeof v === "number" ? v.toFixed(2) : "unknown");
|
|
785
|
+
// THE surface two other tool descriptions send the reader to "to confirm
|
|
786
|
+
// the exact domain name before a delete" — so it has to be able to answer
|
|
787
|
+
// that, and until now it could not.
|
|
788
|
+
//
|
|
789
|
+
// Quoting was tried, then withdrawn as a fix that wasn't one: safeInline
|
|
790
|
+
// collapses and trims whitespace BEFORE the quotes go on, so " engineering"
|
|
791
|
+
// and "engineering" printed identically and the list read like a tool bug.
|
|
792
|
+
// The conclusion drawn then — "revealing whitespace needs escaping rather
|
|
793
|
+
// than quoting" — was right; this is that escaping. Each name is a JSON
|
|
794
|
+
// string literal, so a leading space, a no-break space, a newline and a
|
|
795
|
+
// zero-width character are all visible, Cyrillic stays Cyrillic, and two
|
|
796
|
+
// different names can no longer print as one. Assembly lives in
|
|
797
|
+
// src/names.ts, where it is unit-tested against those exact inputs.
|
|
798
|
+
const domains = formatDomainList(r?.domains);
|
|
428
799
|
const text = [
|
|
429
|
-
`Memories: ${r?.total_atoms
|
|
430
|
-
|
|
800
|
+
`Memories: ${num(r?.total_atoms)} (${num(r?.episodes)} episodes, ${num(r?.prototypes)} prototypes)`,
|
|
801
|
+
// The gloss names the real mechanism. Core's `hebbian_edges` counts
|
|
802
|
+
// concept-concept edges (api/schemas.py: "Number of Hebbian
|
|
803
|
+
// concept-concept edges"), and every path that learns one links two
|
|
804
|
+
// CONCEPTS: `strengthen` walks pairs within one atom's concept list at
|
|
805
|
+
// write time and again on feedback, `co_activate` links query concepts
|
|
806
|
+
// to result concepts on use. An earlier gloss said "links between
|
|
807
|
+
// memories that get used together" — wrong unit (memories, not
|
|
808
|
+
// concepts) and wrong trigger (an edge records co-occurrence, not two
|
|
809
|
+
// memories being used together).
|
|
810
|
+
`Associations: ${num(r?.hebbian_edges)} Hebbian edges — concept-to-concept links learned from concepts that occur together as memories are stored and used`,
|
|
431
811
|
`Domains: ${domains}`,
|
|
432
|
-
`Avg quality: valence ${(r?.avg_valence
|
|
812
|
+
`Avg quality: valence ${dec(r?.avg_valence)} (how well recalls turned out, -1..1), importance ${dec(r?.avg_importance)} (0..1)`,
|
|
813
|
+
"",
|
|
814
|
+
"Counts cover your own domains. Shared rooms are separate stores and are not included — see memory_list_rooms.",
|
|
433
815
|
].join("\n");
|
|
434
|
-
return {
|
|
816
|
+
return {
|
|
817
|
+
content: [
|
|
818
|
+
{
|
|
819
|
+
type: "text",
|
|
820
|
+
// Array.isArray, not `?? []`: `domains` is typed as a string[] but
|
|
821
|
+
// arrives over the wire, and spreading a non-iterable object would
|
|
822
|
+
// throw here — turning a malformed payload into a dead tool instead
|
|
823
|
+
// of the "none reported" it degrades to two lines up.
|
|
824
|
+
text: withDomainEscapeLegend(text, ...(Array.isArray(r?.domains) ? r.domains : [])),
|
|
825
|
+
},
|
|
826
|
+
],
|
|
827
|
+
};
|
|
435
828
|
});
|
|
436
829
|
// --- Tool: memory_delete ---
|
|
437
830
|
server.registerTool("memory_delete", {
|
|
438
|
-
description: "Permanently delete ONE memory by its atom_id — irreversible, the memory is gone for good. Use it to keep the memory trustworthy: prune a memory that is obsolete, superseded by a newer decision, or that you stored wrongly — and whenever the user asks to forget something. Get the atom_id from a memory_read result. To clear an entire topic at once, use memory_delete_domain instead.",
|
|
831
|
+
description: "Permanently delete ONE memory by its atom_id — irreversible, the memory is gone for good. Use it to keep the memory trustworthy: prune a memory that is obsolete, superseded by a newer decision, or that you stored wrongly — and whenever the user asks to forget something. Get the atom_id from a memory_read result. Reaches your own domains only: memories in shared rooms CANNOT be deleted through this tool. To clear an entire topic at once, use memory_delete_domain instead.",
|
|
439
832
|
inputSchema: {
|
|
440
833
|
atom_id: z
|
|
441
834
|
.string()
|
|
@@ -450,16 +843,41 @@ server.registerTool("memory_delete", {
|
|
|
450
843
|
openWorldHint: true,
|
|
451
844
|
},
|
|
452
845
|
}, async ({ atom_id }) => {
|
|
453
|
-
// Core API returns { deleted: <count>, atom_id }
|
|
454
|
-
// the
|
|
455
|
-
//
|
|
846
|
+
// Core API returns { deleted: <count>, atom_id }, so today a falsy
|
|
847
|
+
// `deleted` means the count came back 0 — nothing in the caller's own store
|
|
848
|
+
// carried that id.
|
|
849
|
+
//
|
|
850
|
+
// The check below is `!r?.deleted`, which is falsy-not-zero: a MISSING
|
|
851
|
+
// field lands in the same branch as a real 0. `apiFetch` turns a 204 or an
|
|
852
|
+
// empty body into `{}` (see its own note that FastAPI DELETE handlers may
|
|
853
|
+
// switch to 204), so under that response a successful delete would report
|
|
854
|
+
// "nothing was deleted". Unknown rendered as zero, then zero rendered as an
|
|
855
|
+
// absence claim — the same family as `updated_count ?? 0` in
|
|
856
|
+
// memory_feedback and `deleted ?? 0` in memory_delete_domain. Left as it
|
|
857
|
+
// stands and listed in CHANGELOG's "Known and NOT fixed here"; it is a
|
|
858
|
+
// behaviour fix, not a wording one.
|
|
456
859
|
const r = await apiFetch(`/memory/atoms/${encodeURIComponent(atom_id)}`, { method: "DELETE" });
|
|
457
860
|
if (!r?.deleted) {
|
|
861
|
+
// NOT "no such memory". Deletion is scoped to the caller's OWN store —
|
|
862
|
+
// core has no room-scoped delete at all — so a room atom's id lands here
|
|
863
|
+
// even when the memory lives on in its room. This release made room ids
|
|
864
|
+
// MORE visible in read results, so an agent is now MORE likely to bring
|
|
865
|
+
// one here, ask to forget it, and be told it never existed while it stays
|
|
866
|
+
// (review, 2026-08-08). Every other empty branch in this file learned to
|
|
867
|
+
// state its boundary; the destructive one has to as well.
|
|
868
|
+
//
|
|
869
|
+
// The boundary claim is the ONLY claim: "this delete never reached it"
|
|
870
|
+
// is knowable from here (room stores are out of this tool's reach by
|
|
871
|
+
// construction). "it still exists" — a previous wording — is not: the
|
|
872
|
+
// room owner may have deleted it since, and this handler has no way to
|
|
873
|
+
// check (review, 2026-08-08).
|
|
458
874
|
return {
|
|
459
875
|
content: [
|
|
460
876
|
{
|
|
461
877
|
type: "text",
|
|
462
|
-
text: `
|
|
878
|
+
text: `Nothing was deleted: no memory with id ${atom_id} in your own domains. ` +
|
|
879
|
+
`Note that memories in shared rooms CANNOT be deleted through this tool — ` +
|
|
880
|
+
`if that id came from a room, this delete never reached it.`,
|
|
463
881
|
},
|
|
464
882
|
],
|
|
465
883
|
};
|
|
@@ -475,7 +893,7 @@ server.registerTool("memory_delete", {
|
|
|
475
893
|
});
|
|
476
894
|
// --- Tool: memory_delete_domain ---
|
|
477
895
|
server.registerTool("memory_delete_domain", {
|
|
478
|
-
description: "Permanently delete EVERY memory in one domain — irreversible, and far more sweeping than memory_delete. This is a bulk wipe: run it only when the user asks for it (e.g. 'forget everything about project X') or has explicitly confirmed a wipe you proposed — never on your own judgment alone. First run memory_stats to confirm the exact domain name, then pass it together with confirm=true (a deliberate safety interlock). For a single wrong or stale memory, memory_delete is the right tool.",
|
|
896
|
+
description: "Permanently delete EVERY memory in one domain — irreversible, and far more sweeping than memory_delete. This is a bulk wipe: run it only when the user asks for it (e.g. 'forget everything about project X') or has explicitly confirmed a wipe you proposed — never on your own judgment alone. First run memory_stats to confirm the exact domain name, then pass it together with confirm=true (a deliberate safety interlock). Names match byte-for-byte, including case, and shared rooms cannot be wiped through this tool. For a single wrong or stale memory, memory_delete is the right tool.",
|
|
479
897
|
inputSchema: {
|
|
480
898
|
domain: z
|
|
481
899
|
.string()
|
|
@@ -499,20 +917,59 @@ server.registerTool("memory_delete_domain", {
|
|
|
499
917
|
// reaches this handler, so no runtime re-check is needed here.
|
|
500
918
|
async ({ domain }) => {
|
|
501
919
|
const r = await apiFetch(`/memory/domain/${encodeURIComponent(domain)}`, { method: "DELETE" });
|
|
920
|
+
// `?? 0` again: a missing `deleted` field becomes 0 and then becomes the
|
|
921
|
+
// "NOTHING was deleted" claim below. Core sends the count, so this is not
|
|
922
|
+
// reachable through core today — recorded with the other two in CHANGELOG's
|
|
923
|
+
// "Known and NOT fixed here".
|
|
502
924
|
const count = r?.deleted ?? 0;
|
|
503
|
-
|
|
925
|
+
// The name of a store on a DESTRUCTIVE confirmation, so it is printed
|
|
926
|
+
// exactly or not at all. safeInline named a different store than the one
|
|
927
|
+
// acted on: wiping `" project x"` confirmed `"project x"`, and the very next
|
|
928
|
+
// clause tells the reader that names match byte-for-byte. When the name
|
|
929
|
+
// cannot be reproduced the sentences fall back to naming nothing, which
|
|
930
|
+
// still leaves them true.
|
|
931
|
+
//
|
|
932
|
+
// NOT called `wiped`, which is what it was: on the zero branch nothing was
|
|
933
|
+
// wiped, and the value is whatever the server echoed — core echoes the path
|
|
934
|
+
// parameter back, so it equals what we sent, but a server that echoed
|
|
935
|
+
// something else would have this handler name a store the caller never
|
|
936
|
+
// passed, one clause before telling them names match byte-for-byte. The
|
|
937
|
+
// fallback to `domain` is the value that actually went over the wire.
|
|
938
|
+
const reportedDomain = r?.domain ?? domain;
|
|
939
|
+
const reportedName = domainPhrase(reportedDomain, "the domain you passed");
|
|
940
|
+
// Zero is NOT a successful wipe, and this line used to read like one.
|
|
941
|
+
// Core echoes back the caller's own string, so "Deleted 0 memories from
|
|
942
|
+
// domain 'Project X'" implies a domain that existed and is now empty. The
|
|
943
|
+
// casing trap runs in this direction too: the user says "forget everything
|
|
944
|
+
// about project X", the agent passes "Project X", gets a success-shaped
|
|
945
|
+
// line, reports done — and the atoms live on under "project x" (review,
|
|
946
|
+
// 2026-08-08). This release built the diagnosis for exactly that and had
|
|
947
|
+
// applied it only to reads.
|
|
948
|
+
if (count === 0) {
|
|
949
|
+
return {
|
|
950
|
+
content: [
|
|
951
|
+
{
|
|
952
|
+
type: "text",
|
|
953
|
+
text: withDomainEscapeLegend(`NOTHING was deleted — no memories matched domain ${reportedName} in your own ` +
|
|
954
|
+
`store. Names match byte-for-byte, so check the spelling and case against ` +
|
|
955
|
+
`memory_stats before reporting this as done. Shared rooms cannot be wiped ` +
|
|
956
|
+
`through this tool at all.`, reportedDomain),
|
|
957
|
+
},
|
|
958
|
+
],
|
|
959
|
+
};
|
|
960
|
+
}
|
|
504
961
|
return {
|
|
505
962
|
content: [
|
|
506
963
|
{
|
|
507
964
|
type: "text",
|
|
508
|
-
text: `Deleted ${count} ${count === 1 ? "memory" : "memories"} from domain
|
|
965
|
+
text: withDomainEscapeLegend(`Deleted ${count} ${count === 1 ? "memory" : "memories"} from domain ${reportedName}.`, reportedDomain),
|
|
509
966
|
},
|
|
510
967
|
],
|
|
511
968
|
};
|
|
512
969
|
});
|
|
513
970
|
// --- Tool: memory_create_room ---
|
|
514
971
|
server.registerTool("memory_create_room", {
|
|
515
|
-
description: "Create a SHARED memory room — a space
|
|
972
|
+
description: "Create a SHARED memory room — a space OTHER people's assistants can read, and write too when their invite granted read_write (the default scope), across Claude/ChatGPT/Cursor. Use when the user wants to share context or collaborate with someone else (e.g. 'make a room for me and Olya'). Returns the room's address; pass that address as the `domain` on memory_write/memory_read to use it. To bring someone in, call memory_invite_to_room next.",
|
|
516
973
|
inputSchema: {
|
|
517
974
|
name: z
|
|
518
975
|
.string()
|
|
@@ -537,24 +994,33 @@ server.registerTool("memory_create_room", {
|
|
|
537
994
|
method: "POST",
|
|
538
995
|
body: JSON.stringify({ name, description }),
|
|
539
996
|
});
|
|
540
|
-
|
|
997
|
+
// The name is echoed back to the caller who CHOSE it, so it is printed
|
|
998
|
+
// exactly (src/names.ts): safeInline turned `memory_create_room({name:
|
|
999
|
+
// "проект"})` into `Created shared room ""` — an echo claiming the caller
|
|
1000
|
+
// named their room the empty string.
|
|
1001
|
+
const rawName = r?.name ?? name;
|
|
1002
|
+
const roomName = roomNamePhrase(rawName);
|
|
541
1003
|
const address = safeInline(r?.address);
|
|
542
1004
|
const roomId = safeInline(r?.room_id);
|
|
543
1005
|
// If core returned no usable id (empty body / sanitized away), don't print
|
|
544
1006
|
// broken `domain=""` guidance — say so instead (Copilot).
|
|
545
1007
|
const text = address
|
|
546
|
-
? `Created shared room
|
|
1008
|
+
? `Created shared room ${roomName}. Address: ${address}\n` +
|
|
547
1009
|
`Use it now: pass domain="${address}" on memory_write / memory_read.\n` +
|
|
548
1010
|
(roomId
|
|
549
1011
|
? `To add someone: call memory_invite_to_room with room_id="${roomId}".`
|
|
550
1012
|
: "")
|
|
551
|
-
: `Room
|
|
1013
|
+
: `Room ${roomName} was created but the server did not return a usable address — ` +
|
|
552
1014
|
`retry, or check that your API key is set.`;
|
|
553
1015
|
return {
|
|
554
1016
|
content: [
|
|
555
1017
|
{
|
|
556
1018
|
type: "text",
|
|
557
|
-
|
|
1019
|
+
// Legend AFTER the cap (same rule as memory_read/memory_list_recent):
|
|
1020
|
+
// capResult cuts from the end, so a legend applied first would be the
|
|
1021
|
+
// first casualty; applied to the capped text it also drops itself when
|
|
1022
|
+
// the cap removed the only escaped name.
|
|
1023
|
+
text: withDomainEscapeLegend(capResult(text), rawName),
|
|
558
1024
|
},
|
|
559
1025
|
],
|
|
560
1026
|
};
|
|
@@ -623,14 +1089,18 @@ server.registerTool("memory_join_room", {
|
|
|
623
1089
|
method: "POST",
|
|
624
1090
|
body: JSON.stringify({ code }),
|
|
625
1091
|
});
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
1092
|
+
// The room name is OWNER-chosen and rendered into the JOINER's LLM context
|
|
1093
|
+
// (CN-032) — and it is printed EXACTLY (src/names.ts): the literal is one
|
|
1094
|
+
// line with quotes and invisibles escaped, so it cannot forge an
|
|
1095
|
+
// instruction block, and "Zoë" stops being quoted as "Zo" in a sentence
|
|
1096
|
+
// that presents it as the name. `scope` stays sanitised: server-shaped
|
|
1097
|
+
// enum, display-only.
|
|
1098
|
+
const roomName = roomNamePhrase(r?.name);
|
|
629
1099
|
const scope = safeInline(r?.scope) || "member";
|
|
630
1100
|
const address = safeInline(r?.address);
|
|
631
1101
|
const prefix = r?.already_member
|
|
632
|
-
? `You're already a member of
|
|
633
|
-
: `Joined
|
|
1102
|
+
? `You're already a member of ${roomName}.`
|
|
1103
|
+
: `Joined ${roomName} (${scope}).`;
|
|
634
1104
|
// Don't print broken `domain=""` guidance if no address came back (Copilot).
|
|
635
1105
|
const usage = address
|
|
636
1106
|
? `Use it: pass domain="${address}" on memory_write / memory_read to read and write the shared room.`
|
|
@@ -639,7 +1109,8 @@ server.registerTool("memory_join_room", {
|
|
|
639
1109
|
content: [
|
|
640
1110
|
{
|
|
641
1111
|
type: "text",
|
|
642
|
-
|
|
1112
|
+
// Legend after the cap — same ordering rule as everywhere else.
|
|
1113
|
+
text: withDomainEscapeLegend(capResult(`${prefix}\n${usage}`), r?.name),
|
|
643
1114
|
},
|
|
644
1115
|
],
|
|
645
1116
|
};
|
|
@@ -656,9 +1127,20 @@ server.registerTool("memory_list_rooms", {
|
|
|
656
1127
|
openWorldHint: true,
|
|
657
1128
|
},
|
|
658
1129
|
}, async () => {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
1130
|
+
// The same classifier the scope notes use, for the same reason: this handler
|
|
1131
|
+
// did `Array.isArray(rooms) ? rooms : []` and then asserted "You have no
|
|
1132
|
+
// shared rooms yet" — a claim about the account derived from a body it could
|
|
1133
|
+
// not read. It is the third consumer of this payload and the third place the
|
|
1134
|
+
// substitution was made; all three now go through one function that maps an
|
|
1135
|
+
// unreadable body to `unknown`, never to `none`.
|
|
1136
|
+
const rooms = classifyRooms(await apiFetch("/memory/rooms"), safeInline);
|
|
1137
|
+
if (rooms.state === "unknown") {
|
|
1138
|
+
// Byte-identical to the inline wording this replaces — the builder is
|
|
1139
|
+
// shared so the four list surfaces answer the unreadable case with one
|
|
1140
|
+
// sentence, not four drifting ones.
|
|
1141
|
+
return unreadableListReply("The room list", "a list of your rooms", "you have none");
|
|
1142
|
+
}
|
|
1143
|
+
if (rooms.state === "none") {
|
|
662
1144
|
return {
|
|
663
1145
|
content: [
|
|
664
1146
|
{
|
|
@@ -669,27 +1151,44 @@ server.registerTool("memory_list_rooms", {
|
|
|
669
1151
|
],
|
|
670
1152
|
};
|
|
671
1153
|
}
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
|
|
1154
|
+
// ARCHIVED ROOMS ARE LISTED HERE, unlike in the scope note — this tool's job
|
|
1155
|
+
// is the inventory, and the `[archived]` tag says which ones cannot be read.
|
|
1156
|
+
const list = rooms.rooms;
|
|
1157
|
+
// Room name is OWNER-chosen — printed EXACTLY (src/names.ts), like every
|
|
1158
|
+
// other room-name surface as of 0.8.1: the sanitiser listed "проект" and
|
|
1159
|
+
// "план" as two "(unnamed room)" entries and quoted "Zoë" as "Zo".
|
|
1160
|
+
// address/role/scope are server-shaped and keep the defensive sanitiser.
|
|
675
1161
|
const lines = list.map((r) => {
|
|
676
|
-
const name =
|
|
1162
|
+
const name = roomNamePhrase(r?.name);
|
|
677
1163
|
const roomId = safeInline(r?.room_id);
|
|
678
1164
|
// Always surface the canonical address: fall back to xroom:<room_id> when the server
|
|
679
1165
|
// omits `address`, so the domain guidance this tool promises is never silently dropped.
|
|
680
1166
|
const address = safeInline(r?.address) || (roomId ? `xroom:${roomId}` : "");
|
|
681
1167
|
const role = safeInline(r?.role);
|
|
682
1168
|
const scope = safeInline(r?.scope);
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1169
|
+
// No "use domain=..." on an archived room: core refuses EVERY read of one
|
|
1170
|
+
// with a 403, for owner and member alike (see the archived-only note in
|
|
1171
|
+
// src/scope.ts), so that clause was an instruction to make a call that
|
|
1172
|
+
// cannot succeed. The address stays visible — it is the room's identity —
|
|
1173
|
+
// but the line says what a read against it will do.
|
|
1174
|
+
const tail = r?.archived
|
|
1175
|
+
? address
|
|
1176
|
+
? ` [archived] — address ${address}, but every read is refused while it is archived`
|
|
1177
|
+
: ` [archived] — every read is refused while it is archived`
|
|
1178
|
+
: address
|
|
1179
|
+
? ` — use domain="${address}"`
|
|
1180
|
+
: "";
|
|
1181
|
+
return `- ${name} (${role}${scope ? `, ${scope}` : ""})${tail}`;
|
|
686
1182
|
});
|
|
687
1183
|
const text = `Your shared rooms (${list.length}):\n${lines.join("\n")}`;
|
|
688
1184
|
return {
|
|
689
1185
|
content: [
|
|
690
1186
|
{
|
|
691
1187
|
type: "text",
|
|
692
|
-
|
|
1188
|
+
// Legend after the cap: this is the one room surface long enough to
|
|
1189
|
+
// actually overflow, and the legend must describe the names that
|
|
1190
|
+
// SURVIVED the cut, not the ones it removed.
|
|
1191
|
+
text: withDomainEscapeLegend(capResult(text, "The room list was truncated — some rooms are not shown."), ...list.map((r) => r?.name)),
|
|
693
1192
|
},
|
|
694
1193
|
],
|
|
695
1194
|
};
|
|
@@ -707,7 +1206,15 @@ server.registerTool("vault_list", {
|
|
|
707
1206
|
},
|
|
708
1207
|
}, async () => {
|
|
709
1208
|
const r = await apiFetch("/vault/secrets");
|
|
710
|
-
|
|
1209
|
+
// A 200 missing the `secrets` array used to print "No secrets are stored
|
|
1210
|
+
// in your Vault yet." — an absence claim about the Vault derived from a
|
|
1211
|
+
// body this client could not read. The family fix (classifyRooms, the
|
|
1212
|
+
// domains guard) had stopped one tool short of here (truth F13,
|
|
1213
|
+
// 2026-08-08). A genuinely empty array keeps the absence claim below.
|
|
1214
|
+
const list = r?.secrets;
|
|
1215
|
+
if (!Array.isArray(list)) {
|
|
1216
|
+
return unreadableListReply("The secret list", "a list of your Vault secrets", "none are stored");
|
|
1217
|
+
}
|
|
711
1218
|
if (list.length === 0) {
|
|
712
1219
|
return {
|
|
713
1220
|
content: [
|
|
@@ -735,12 +1242,47 @@ server.registerTool("vault_list", {
|
|
|
735
1242
|
};
|
|
736
1243
|
});
|
|
737
1244
|
// --- Start ---
|
|
1245
|
+
/**
|
|
1246
|
+
* Opening the stdio transport at import time is what made every user-visible
|
|
1247
|
+
* sentence in this file untestable: a test that imports this module to reach a
|
|
1248
|
+
* handler instead starts a server on the test runner's stdin/stdout. So the copy
|
|
1249
|
+
* was guarded by regexes over this source text — and three review rounds each
|
|
1250
|
+
* found false sentences a behavioural test would have caught immediately, twice
|
|
1251
|
+
* in a guard that turned out to be theatre.
|
|
1252
|
+
*
|
|
1253
|
+
* The seam is one boolean, and its DEFAULT is today's behaviour.
|
|
1254
|
+
*
|
|
1255
|
+
* WHY AN ENV OPT-OUT AND NOT `import.meta.url === process.argv[1]`. That
|
|
1256
|
+
* comparison is the usual "am I the entry point?" idiom and it is the wrong tool
|
|
1257
|
+
* here, because this package ships as an npm `bin`. Under `npx` — the canonical
|
|
1258
|
+
* install, pinned in src/configs/source.json and in every README snippet — the
|
|
1259
|
+
* thing on argv[1] is the generated shim, not this file; on Windows it is a
|
|
1260
|
+
* `.cmd`/`.ps1` wrapper, and even the POSIX shim is a symlink whose realpath
|
|
1261
|
+
* resolution differs between package managers. Every one of those makes the
|
|
1262
|
+
* comparison FALSE for a real user, and a false comparison there does not throw:
|
|
1263
|
+
* the process would exit 0 having started no server, and the client would report
|
|
1264
|
+
* a silent connection failure with nothing in the logs. That is a worse defect
|
|
1265
|
+
* than the one being fixed. The env var inverts the risk — it can only misfire
|
|
1266
|
+
* for something that deliberately sets it, and no released version reads it, so
|
|
1267
|
+
* no existing config can carry it.
|
|
1268
|
+
*
|
|
1269
|
+
* The check is `=== "1"`, deliberately narrow rather than truthy: the failure
|
|
1270
|
+
* mode of a wide check is a startup that silently does nothing, so an
|
|
1271
|
+
* unrecognised value must fall through to starting the server.
|
|
1272
|
+
*
|
|
1273
|
+
* BYTE-IDENTICAL CLI BEHAVIOUR: with the variable unset (or set to anything
|
|
1274
|
+
* other than "1"), `main()` is called exactly as before, with the same catch,
|
|
1275
|
+
* the same message and the same exit code. The only other change to this module
|
|
1276
|
+
* is the `export` on `server`, which is inert when the file is run as a program.
|
|
1277
|
+
*/
|
|
738
1278
|
async function main() {
|
|
739
1279
|
const transport = new StdioServerTransport();
|
|
740
1280
|
await server.connect(transport);
|
|
741
1281
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1282
|
+
if (process.env.MNEMOVERSE_MCP_NO_AUTOSTART !== "1") {
|
|
1283
|
+
main().catch((err) => {
|
|
1284
|
+
console.error("MCP server error:", err);
|
|
1285
|
+
process.exit(1);
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
746
1288
|
//# sourceMappingURL=index.js.map
|