@mnemoverse/mcp-memory-server 0.9.0 → 0.10.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 +1 -1
- package/dist/errors.d.ts +235 -0
- package/dist/errors.js +668 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.js +712 -124
- package/dist/index.js.map +1 -1
- package/dist/names.d.ts +26 -1
- package/dist/names.js +52 -5
- package/dist/names.js.map +1 -1
- package/dist/render.d.ts +34 -1
- package/dist/render.js +63 -18
- package/dist/render.js.map +1 -1
- package/dist/scope.js +5 -22
- package/dist/scope.js.map +1 -1
- package/dist/time.d.ts +39 -0
- package/dist/time.js +48 -0
- package/dist/time.js.map +1 -0
- package/package.json +2 -3
package/dist/index.js
CHANGED
|
@@ -12,6 +12,11 @@ import { SERVER_INSTRUCTIONS, buildReadEmptyResponse } from "./teaching.js";
|
|
|
12
12
|
import { classifyRooms, futureSinceNote, probeReadScope, readScopeNote, scopeLabel, } from "./scope.js";
|
|
13
13
|
import { readRequestBody, recentRequestBody, writeRequestBody, searchedScope, } from "./requests.js";
|
|
14
14
|
import { exactLiteral, formatDomainList, roomNamePhrase, withDomainEscapeLegend, } from "./names.js";
|
|
15
|
+
// Every non-2xx becomes an instruction to the calling model instead of a raw
|
|
16
|
+
// wire echo — see the header of src/errors.ts for why, and for what each status
|
|
17
|
+
// actually means in this engine. So does a 2xx whose body this client cannot
|
|
18
|
+
// parse, which is a third case and not a variant of either of the other two.
|
|
19
|
+
import { ApiError, NetworkError, UnreadableBodyError } from "./errors.js";
|
|
15
20
|
/**
|
|
16
21
|
* What we know about the scope this read actually covered — a VALUE rather
|
|
17
22
|
* than a sentence-or-empty-string. Costs one GET (rooms or stats, chosen by
|
|
@@ -58,19 +63,121 @@ function probeScope(searched) {
|
|
|
58
63
|
// `node_modules/@mnemoverse/mcp-memory-server/dist/` after an npm install.
|
|
59
64
|
const require = createRequire(import.meta.url);
|
|
60
65
|
const pkg = require("../package.json");
|
|
61
|
-
|
|
66
|
+
/** Where the key goes when the user names no host. Referenced by the base-URL
|
|
67
|
+
* refusal below, which has to tell them what to set it back to. */
|
|
68
|
+
const DEFAULT_API_URL = "https://core.mnemoverse.com/api/v1";
|
|
69
|
+
const API_URL = process.env.MNEMOVERSE_API_URL || DEFAULT_API_URL;
|
|
62
70
|
const API_KEY = process.env.MNEMOVERSE_API_KEY || "";
|
|
63
71
|
// Hard cap on tool result size — required by Claude Connectors Directory
|
|
64
72
|
// (https://support.claude.com/en/articles/12922490-remote-mcp-server-submission-guide).
|
|
65
73
|
// Approximate token count = chars / 4. Cap at 24,000 tokens to leave headroom under the 25K limit.
|
|
66
74
|
const MAX_RESULT_CHARS = 24_000 * 4;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Is `MNEMOVERSE_API_URL` an address this client may attach the API key to —
|
|
77
|
+
* and if not, what does the user have to change (#99, CWE-319)?
|
|
78
|
+
*
|
|
79
|
+
* `apiFetch` attaches `X-Api-Key` to whatever this variable holds. Until this
|
|
80
|
+
* check existed, a base URL spelled `http://` — a typo, a copied tunnel address,
|
|
81
|
+
* a misread doc — put a live `mk_live_` key in cleartext on every tool call, on
|
|
82
|
+
* every hop between the user and that host, with nothing anywhere saying so.
|
|
83
|
+
* The key is the whole account: it reads and writes that user's memory.
|
|
84
|
+
*
|
|
85
|
+
* THREE HOSTS ARE EXEMPT, AND ONLY AS LITERAL HOSTNAMES. `http://localhost`,
|
|
86
|
+
* `http://127.0.0.1` and `http://[::1]` are legitimate — a self-hosted engine,
|
|
87
|
+
* this repo's own test harness — and a guard that demanded https unconditionally
|
|
88
|
+
* would break them. The comparison is against `URL.hostname`, NOT a prefix or
|
|
89
|
+
* suffix of the raw string, because `http://localhost.evil.example` passes a
|
|
90
|
+
* prefix test and `http://evil.localhost` passes a suffix test while both
|
|
91
|
+
* resolve to somebody else's server. The exemption is also scheme-bound —
|
|
92
|
+
* `ftp://localhost` is refused — since "aimed at loopback" is not by itself a
|
|
93
|
+
* reason to trust a transport.
|
|
94
|
+
*
|
|
95
|
+
* THE IPv6 LITERAL CARRIES ITS BRACKETS. `new URL("http://[::1]:8100")` reports
|
|
96
|
+
* `hostname` as `"[::1]"` — brackets included, not `"::1"` — so that is what the
|
|
97
|
+
* comparison holds, and test/base-url-guard.test.ts pins both spellings a user
|
|
98
|
+
* can type: the parser compresses `[0:0:0:0:0:0:0:1]` to `[::1]` before this
|
|
99
|
+
* function sees it, so one literal covers the long form too.
|
|
100
|
+
*
|
|
101
|
+
* WHAT IS STILL REFUSED, AND ON PURPOSE. `host.docker.internal` is NOT loopback
|
|
102
|
+
* and is not exempt: it resolves through the container's resolver to an address
|
|
103
|
+
* on the host network, so the packet — and the key — leaves the container before
|
|
104
|
+
* anything knows where it lands. "Aimed at my own machine" is a claim about
|
|
105
|
+
* intent; the exemption is about the wire. `[::ffff:127.0.0.1]` is refused for
|
|
106
|
+
* a smaller reason: it does reach 127.0.0.1, but it is a fourth spelling of an
|
|
107
|
+
* address that already has three legal ones, and widening an exemption that
|
|
108
|
+
* protects a live key is a decision, not a convenience.
|
|
109
|
+
*
|
|
110
|
+
* RETURNS A STRING RATHER THAN THROWING, and is called at import rather than
|
|
111
|
+
* on each request, because a throw at import would take the server down before
|
|
112
|
+
* `tools/list` — the one thing that is documented to work without any config at
|
|
113
|
+
* all (registries boot this server to enumerate its tools). The refusal has to
|
|
114
|
+
* land where the keyless refusal lands: inside a tool CALL.
|
|
115
|
+
*
|
|
116
|
+
* THE MESSAGE NEVER QUOTES THE URL, only its scheme. src/errors.ts keeps the
|
|
117
|
+
* base URL out of every message it builds on the stated grounds that the base
|
|
118
|
+
* can carry credentials — `http://user:pass@host` is exactly the case this guard
|
|
119
|
+
* fires on, so the one message whose subject IS the base URL is also the one
|
|
120
|
+
* most likely to leak it into a model's context and a client's logs. A scheme
|
|
121
|
+
* is safe to interpolate for a second reason: the URL parser restricts it to
|
|
122
|
+
* `[a-z0-9+.-]`, so it cannot carry a newline and open its own paragraph in
|
|
123
|
+
* this file's instruction voice.
|
|
124
|
+
*/
|
|
125
|
+
function refuseInsecureBaseUrl(raw) {
|
|
126
|
+
let url;
|
|
127
|
+
try {
|
|
128
|
+
url = new URL(raw);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return {
|
|
132
|
+
toolCall: "Mnemoverse: this tool did not run, because MNEMOVERSE_API_URL is not " +
|
|
133
|
+
"a URL this client can parse — so it cannot tell whether sending the " +
|
|
134
|
+
"API key to it would expose the key, and it will not guess. Nothing " +
|
|
135
|
+
"was sent. This is the user's configuration: their key, their network " +
|
|
136
|
+
"and the service are all fine. Tell them to set MNEMOVERSE_API_URL to " +
|
|
137
|
+
"a complete https:// URL including the scheme and the path — the " +
|
|
138
|
+
`default is ${DEFAULT_API_URL} — or to unset it and use that default. ` +
|
|
139
|
+
"A local engine is the one exception and must be addressed as " +
|
|
140
|
+
"http://localhost, http://127.0.0.1 or http://[::1]. Do not retry " +
|
|
141
|
+
"until it is changed; every memory tool will fail the same way " +
|
|
142
|
+
"until then.",
|
|
143
|
+
startupLog: "Mnemoverse: startup key check SKIPPED, and nothing was sent — " +
|
|
144
|
+
"MNEMOVERSE_API_URL is not a URL this server can parse, so it cannot " +
|
|
145
|
+
"tell whether sending your API key over it would expose it. Set it to " +
|
|
146
|
+
`a complete https:// URL such as ${DEFAULT_API_URL}, or unset it to ` +
|
|
147
|
+
"use that default. Every memory tool will fail until it is changed.",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const loopback = url.hostname === "localhost" ||
|
|
151
|
+
url.hostname === "127.0.0.1" ||
|
|
152
|
+
// Brackets included: that is what `URL.hostname` reports for an IPv6 host.
|
|
153
|
+
url.hostname === "[::1]";
|
|
154
|
+
if (url.protocol === "https:" || (url.protocol === "http:" && loopback)) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
toolCall: "Mnemoverse: this tool did not run, because MNEMOVERSE_API_URL does not " +
|
|
159
|
+
`address the API over https — its scheme is ${url.protocol}// — and ` +
|
|
160
|
+
"sending the API key to it would put a live mk_live_ key on the wire in " +
|
|
161
|
+
"cleartext, readable by anything on the path. Nothing was sent: this " +
|
|
162
|
+
"client refuses the call instead. This is the user's configuration, not " +
|
|
163
|
+
"a fault of the key, the network or the service. Tell them to change " +
|
|
164
|
+
"MNEMOVERSE_API_URL to the https:// form of the same host — the default " +
|
|
165
|
+
`is ${DEFAULT_API_URL} — or, if they are deliberately running the ` +
|
|
166
|
+
"engine on their own machine, to address it as http://localhost, " +
|
|
167
|
+
"http://127.0.0.1 or http://[::1] — the only three plain-http hosts " +
|
|
168
|
+
"this client accepts. Do not retry until it is changed; every memory " +
|
|
169
|
+
"tool will fail the same way until then.",
|
|
170
|
+
startupLog: "Mnemoverse: startup key check SKIPPED, and nothing was sent — " +
|
|
171
|
+
`MNEMOVERSE_API_URL has the scheme ${url.protocol}// rather than ` +
|
|
172
|
+
"https://, and this server will not put your API key on the wire in " +
|
|
173
|
+
`cleartext. Set it to ${DEFAULT_API_URL}, or to http://localhost, ` +
|
|
174
|
+
"http://127.0.0.1 or http://[::1] if you run the engine yourself. " +
|
|
175
|
+
"Every memory tool will fail until it is changed.",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** The verdict on this process's base URL, computed once. `undefined` means the
|
|
179
|
+
* key may go out; anything else is what each surface says instead. */
|
|
180
|
+
const BASE_URL_REFUSAL = refuseInsecureBaseUrl(API_URL);
|
|
74
181
|
/**
|
|
75
182
|
* Fetch from the Mnemoverse core API with authentication.
|
|
76
183
|
*
|
|
@@ -82,31 +189,127 @@ const MAX_RESULT_CHARS = 24_000 * 4;
|
|
|
82
189
|
* handlers may switch to 204 in the future even though today they return
|
|
83
190
|
* a JSON body.
|
|
84
191
|
*
|
|
85
|
-
* @throws
|
|
192
|
+
* @throws {@link ApiError} on non-2xx — whose message is the agent-facing
|
|
193
|
+
* explanation from src/errors.ts, with the raw body kept after it. The MCP SDK
|
|
194
|
+
* turns a thrown Error into `{isError: true, content: [{type: "text", text:
|
|
195
|
+
* error.message}]}`, so this message IS what the calling model reads; that is
|
|
196
|
+
* why it is written as instructions rather than as a wire dump.
|
|
86
197
|
*/
|
|
87
198
|
async function apiFetch(path, options = {}) {
|
|
199
|
+
const method = (options.method ?? "GET").toUpperCase();
|
|
88
200
|
if (!API_KEY) {
|
|
89
|
-
|
|
90
|
-
|
|
201
|
+
// The one failure that needs no request to diagnose — and the only one
|
|
202
|
+
// where the fix is "set the variable" rather than "replace its value".
|
|
203
|
+
// Kept distinct from the 401 sentence for exactly that reason.
|
|
204
|
+
throw new Error("Mnemoverse: no API key is configured, so this tool cannot run. Tell the " +
|
|
205
|
+
"user to set MNEMOVERSE_API_KEY in their MCP client config — a free key " +
|
|
206
|
+
"takes about 30 seconds at https://console.mnemoverse.com/dashboard/keys " +
|
|
207
|
+
"and starts with mk_live_. Do not retry until it is set; every memory " +
|
|
208
|
+
"tool will fail the same way until then.");
|
|
209
|
+
}
|
|
210
|
+
if (BASE_URL_REFUSAL !== undefined) {
|
|
211
|
+
// Alongside the key check, and after it: with no key configured there is
|
|
212
|
+
// nothing to expose, and "set the variable" is the more useful first thing
|
|
213
|
+
// to tell someone who has set neither. Both are decided from CONFIGURATION
|
|
214
|
+
// alone, which is why both belong here rather than in a diagnosis of the
|
|
215
|
+
// response — a guard that fired after fetch would print the right sentence
|
|
216
|
+
// about a key it had already put on the wire.
|
|
217
|
+
throw new Error(BASE_URL_REFUSAL.toolCall);
|
|
218
|
+
}
|
|
219
|
+
let res;
|
|
220
|
+
try {
|
|
221
|
+
res = await fetch(`${API_URL}${path}`, {
|
|
222
|
+
...options,
|
|
223
|
+
// AFTER the spread, so no call site can opt out of it. `fetch` defaults to
|
|
224
|
+
// "follow", and Node re-sends request headers to the redirect target: the
|
|
225
|
+
// WHATWG rules strip Authorization, Cookie and Proxy-Authorization when
|
|
226
|
+
// the origin changes and say nothing about a custom header, so
|
|
227
|
+
// `X-Api-Key` rides along to whoever answered (#99, CWE-200). This API
|
|
228
|
+
// serves one stable base path and never redirects legitimately, so the
|
|
229
|
+
// only traffic this refuses is an exfiltration path. The resulting
|
|
230
|
+
// rejection is named by explainNetworkFailure rather than left to look
|
|
231
|
+
// like a dead host.
|
|
232
|
+
redirect: "error",
|
|
233
|
+
headers: {
|
|
234
|
+
"Content-Type": "application/json",
|
|
235
|
+
"X-Api-Key": API_KEY,
|
|
236
|
+
...(options.headers || {}),
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (cause) {
|
|
241
|
+
// `TypeError: fetch failed` is what a model used to read here, which is as
|
|
242
|
+
// uninstructive as the raw 401 body this change is about. Every existing
|
|
243
|
+
// caller that swallows a failure (the scope probes, the empty-read stats
|
|
244
|
+
// call) catches without inspecting the type, so wrapping changes nothing
|
|
245
|
+
// for them — it only changes what surfaces when nobody catches.
|
|
246
|
+
throw new NetworkError(method, path, cause);
|
|
91
247
|
}
|
|
92
|
-
const res = await fetch(`${API_URL}${path}`, {
|
|
93
|
-
...options,
|
|
94
|
-
headers: {
|
|
95
|
-
"Content-Type": "application/json",
|
|
96
|
-
"X-Api-Key": API_KEY,
|
|
97
|
-
...(options.headers || {}),
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
248
|
if (!res.ok) {
|
|
101
|
-
|
|
102
|
-
|
|
249
|
+
// `fetch()` resolves once HEADERS arrive; a connection reset during the
|
|
250
|
+
// body read rejects HERE, not in the catch above, and used to surface as
|
|
251
|
+
// a raw undici TypeError (Copilot, #93).
|
|
252
|
+
//
|
|
253
|
+
// It is NOT the same failure as the catch above, and calling it one threw
|
|
254
|
+
// the status away: a 502 whose body died mid-stream was reported as "the
|
|
255
|
+
// memory service could not be reached at all… before any HTTP response
|
|
256
|
+
// came back", about a response whose status we are holding. The status is
|
|
257
|
+
// the most actionable thing this failure has, so it goes in the sentence.
|
|
258
|
+
let text;
|
|
259
|
+
try {
|
|
260
|
+
text = await res.text();
|
|
261
|
+
}
|
|
262
|
+
catch (cause) {
|
|
263
|
+
throw new UnreadableBodyError({ status: res.status, method, path, cause });
|
|
264
|
+
}
|
|
265
|
+
throw new ApiError({
|
|
266
|
+
status: res.status,
|
|
267
|
+
body: text,
|
|
268
|
+
method,
|
|
269
|
+
path,
|
|
270
|
+
// Only the rate-limit middleware sets it, and only on the 429 that
|
|
271
|
+
// waiting actually clears — so its PRESENCE is evidence, not decoration.
|
|
272
|
+
retryAfter: res.headers.get("retry-after"),
|
|
273
|
+
});
|
|
103
274
|
}
|
|
104
275
|
// 204 No Content or empty body — return an empty object cast as T so
|
|
105
276
|
// call sites using optional chaining still work without crashing.
|
|
106
277
|
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
|
107
278
|
return {};
|
|
108
279
|
}
|
|
109
|
-
|
|
280
|
+
// A 2xx WHOSE BODY THIS CLIENT CANNOT READ. Read the text first and parse it
|
|
281
|
+
// second, rather than `res.json()`, for one reason: `res.json()` consumes the
|
|
282
|
+
// stream, so when it throws there is nothing left to quote — and the bytes
|
|
283
|
+
// are the evidence. A captive portal's sign-in page, an SPA shell served with
|
|
284
|
+
// 200 text/html, a MITM proxy's notice, or a JSON body that stopped mid-write
|
|
285
|
+
// all land here, and the first line of any of them identifies the culprit.
|
|
286
|
+
//
|
|
287
|
+
// Both throws used to be NetworkError, which asserts that NOTHING answered —
|
|
288
|
+
// "could not be reached at all… before any HTTP response came back… a
|
|
289
|
+
// connectivity or DNS problem" — while a 200 sat in this very function's
|
|
290
|
+
// hand, and the Raw detail below it quoted a SyntaxError out of the body it
|
|
291
|
+
// had just called nonexistent. Sending a user to debug wifi while a portal
|
|
292
|
+
// answers every request is the confident wrong cause src/errors.ts exists to
|
|
293
|
+
// remove. A real `fetch()` rejection above keeps that wording; this does not.
|
|
294
|
+
let body;
|
|
295
|
+
try {
|
|
296
|
+
body = await res.text();
|
|
297
|
+
}
|
|
298
|
+
catch (cause) {
|
|
299
|
+
throw new UnreadableBodyError({ status: res.status, method, path, cause });
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
return JSON.parse(body);
|
|
303
|
+
}
|
|
304
|
+
catch (cause) {
|
|
305
|
+
throw new UnreadableBodyError({
|
|
306
|
+
status: res.status,
|
|
307
|
+
method,
|
|
308
|
+
path,
|
|
309
|
+
bodyPreview: body,
|
|
310
|
+
cause,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
110
313
|
}
|
|
111
314
|
/**
|
|
112
315
|
* Truncate a result string to MAX_RESULT_CHARS, appending a notice if truncated.
|
|
@@ -137,7 +340,7 @@ moreHint = "Use a more specific query to see all results.") {
|
|
|
137
340
|
return `${truncated}\n\n[…truncated to fit the 25K token limit. ${moreHint}]`;
|
|
138
341
|
}
|
|
139
342
|
/**
|
|
140
|
-
* A
|
|
343
|
+
* A 2xx whose body does not carry what core always sends for this operation.
|
|
141
344
|
*
|
|
142
345
|
* Reading such a body as an EMPTY list is the substitution this release exists
|
|
143
346
|
* to remove: `Array.isArray(x) ? x : []` turned "this client could not read
|
|
@@ -149,8 +352,19 @@ moreHint = "Use a more specific query to see all results.") {
|
|
|
149
352
|
* is NOT ("a list of your rooms"), and the absence it is NOT evidence of
|
|
150
353
|
* ("you have none") — so every consumer states its own boundary while the
|
|
151
354
|
* sentence stays one sentence everywhere (truth F13, 2026-08-08).
|
|
355
|
+
*
|
|
356
|
+
* NOT ONLY LISTS any more: memory_write was the last surface still reading
|
|
357
|
+
* a missing field as a stated one — `if (r?.stored)`, whose else-branch was an
|
|
358
|
+
* unconditional "NOT STORED — nothing was saved" plus a mechanism nobody sent.
|
|
359
|
+
* It is the same class of claim about a different shape, so it gets the same
|
|
360
|
+
* sentence, and the name no longer says "List".
|
|
361
|
+
*
|
|
362
|
+
* `extra` exists for the write surface alone: a list that could not be read
|
|
363
|
+
* leaves the caller merely uninformed, whereas a WRITE that could not be read
|
|
364
|
+
* leaves an operation whose outcome is unknown, and the caller must be told not
|
|
365
|
+
* to report either outcome to the user.
|
|
152
366
|
*/
|
|
153
|
-
function
|
|
367
|
+
function unreadableAnswerReply(subject, notA, absence, extra = "") {
|
|
154
368
|
return {
|
|
155
369
|
content: [
|
|
156
370
|
{
|
|
@@ -162,7 +376,7 @@ function unreadableListReply(subject, notA, absence) {
|
|
|
162
376
|
// unrecognised body (truth re-verification, 2026-08-09). Pinned in
|
|
163
377
|
// test/handlers.test.ts.
|
|
164
378
|
text: `${subject} came back in a shape this client does not recognise — so this ` +
|
|
165
|
-
`is not ${notA}, and it is not evidence that ${absence}
|
|
379
|
+
`is not ${notA}, and it is not evidence that ${absence}.${extra} Retry; ` +
|
|
166
380
|
`if it persists, whatever answered this call — the memory service, a ` +
|
|
167
381
|
`gateway or proxy in front of it, or the endpoint a mis-set ` +
|
|
168
382
|
`MNEMOVERSE_API_URL points at — is answering in a shape this client ` +
|
|
@@ -221,6 +435,29 @@ export const server = new McpServer({
|
|
|
221
435
|
function isRoomDomain(domain) {
|
|
222
436
|
return typeof domain === "string" && domain.startsWith("xroom:");
|
|
223
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* The three outcomes this client will ever print a WRITE promise about for a
|
|
440
|
+
* room membership. Core grants exactly two scopes — "read" and "read_write"
|
|
441
|
+
* (rooms_routes.py `_VALID_SCOPES`) — and refuses a read-only member's
|
|
442
|
+
* memory_write with a 403 "Read-only membership cannot write to this room"
|
|
443
|
+
* (src/errors.ts). Anything else on the wire (missing field, a Copilot-shaped
|
|
444
|
+
* partial body) is UNSPECIFIED: the server did not say what memory_write would
|
|
445
|
+
* do for this membership, so this client does not guess either — it is treated
|
|
446
|
+
* like "read" for the purpose of NOT promising write, but is not told it is
|
|
447
|
+
* read-only, because that is also a claim the response did not make.
|
|
448
|
+
*
|
|
449
|
+
* Bug hunt (pre-0.9.2, P2): memory_join_room's usage sentence and
|
|
450
|
+
* memory_list_rooms's per-row tail both used to print "use domain=... [on
|
|
451
|
+
* memory_write / memory_read] to read and write" unconditionally — true for a
|
|
452
|
+
* read_write membership, false for a read-only one.
|
|
453
|
+
*/
|
|
454
|
+
function roomScopeVerdict(scope) {
|
|
455
|
+
if (scope === "read_write")
|
|
456
|
+
return "read_write";
|
|
457
|
+
if (scope === "read")
|
|
458
|
+
return "read";
|
|
459
|
+
return "unspecified";
|
|
460
|
+
}
|
|
224
461
|
// --- Tool: memory_write ---
|
|
225
462
|
server.registerTool("memory_write", {
|
|
226
463
|
description: "Store a long-term memory that persists across sessions AND across every AI tool the user has connected to Mnemoverse (Claude, ChatGPT, Cursor, VS Code) — write once, recall everywhere. Call this PROACTIVELY the moment the user states a preference, makes a decision, or you learn a durable fact (people, roles, project setup, a lesson). Don't wait to be asked. Never store passwords, API keys, payment data, MFA codes, government IDs, or health records; skip transient chatter that only matters this turn. Behavior: an importance gate may filter low-value writes, so the result tells you whether the memory was stored or filtered. Write `content` as a self-contained statement that still makes sense when recalled out of context.",
|
|
@@ -237,7 +474,12 @@ server.registerTool("memory_write", {
|
|
|
237
474
|
domain: z
|
|
238
475
|
.string()
|
|
239
476
|
.optional()
|
|
240
|
-
.describe("Namespace to organize memories (e.g. 'engineering', 'user:alice', 'project:acme')"
|
|
477
|
+
.describe("Namespace to organize memories (e.g. 'engineering', 'user:alice', 'project:acme')." +
|
|
478
|
+
" Matched byte-for-byte — a leading space, a different case, or an invisible" +
|
|
479
|
+
" character opens a SEPARATE, permanent store, so reuse an exact name from" +
|
|
480
|
+
" memory_stats rather than retyping one. To write into a shared room, pass its" +
|
|
481
|
+
" address here instead (e.g. 'xroom:room_01ABC'). Find room addresses with" +
|
|
482
|
+
" memory_list_rooms."),
|
|
241
483
|
},
|
|
242
484
|
annotations: {
|
|
243
485
|
title: "Store Memory",
|
|
@@ -276,6 +518,29 @@ server.registerTool("memory_write", {
|
|
|
276
518
|
method: "POST",
|
|
277
519
|
body: JSON.stringify(writeRequestBody({ content, concepts, domain })),
|
|
278
520
|
});
|
|
521
|
+
// `stored` MUST BE A BOOLEAN before either verdict below may be printed.
|
|
522
|
+
//
|
|
523
|
+
// This was the last surface reading a MISSING field as a field that said
|
|
524
|
+
// false. `if (r?.stored)` sent every body that did not say `true` to the
|
|
525
|
+
// else-branch, whose first four words are "NOT STORED — nothing was saved"
|
|
526
|
+
// and whose last sentence explains WHY: "Writes are gated on how much a
|
|
527
|
+
// memory adds… so a near-duplicate is refused." So a 204, an empty `{}`, a
|
|
528
|
+
// proxy or gateway answering `{"ok":true}`, and a mis-set
|
|
529
|
+
// MNEMOVERSE_API_URL all produced an absence claim about the user's memory
|
|
530
|
+
// AND a fabricated mechanism for it — two statements, neither with any
|
|
531
|
+
// evidence behind it. The write is also the surface where being wrong costs
|
|
532
|
+
// most: a caller told "nothing was saved" re-words and retries, or drops
|
|
533
|
+
// the fact, and the atom that may in fact be sitting in the store is not
|
|
534
|
+
// what the user is told about.
|
|
535
|
+
//
|
|
536
|
+
// The four LIST surfaces have had this guard since 0.8.1 (truth F13); the
|
|
537
|
+
// write did not. The test for `boolean` and not for presence is deliberate:
|
|
538
|
+
// `{"stored":"yes"}` is not core speaking either.
|
|
539
|
+
if (typeof r?.stored !== "boolean") {
|
|
540
|
+
return unreadableAnswerReply("The write result", "confirmation that the memory was stored", "it was refused", " Whether the content reached memory is unknown from here — report the" +
|
|
541
|
+
" outcome of the RETRY, not of this call, and do not tell the user it" +
|
|
542
|
+
" was saved or that it was rejected.");
|
|
543
|
+
}
|
|
279
544
|
// "unknown", not 0.00, when the server didn't send a score — the same rule
|
|
280
545
|
// memory_stats got in this release. A live surface exists that answers
|
|
281
546
|
// {"stored":false} with no reason and no score; printing "0.00" there
|
|
@@ -292,7 +557,9 @@ server.registerTool("memory_write", {
|
|
|
292
557
|
const reasonQuote = r?.reason
|
|
293
558
|
? (exactLiteral(r.reason, 400)?.literal ?? "(too long to quote exactly)")
|
|
294
559
|
: "";
|
|
295
|
-
|
|
560
|
+
// Narrowed to `true` by the guard above, so this is now the server's stated
|
|
561
|
+
// verdict rather than "the body was not falsy".
|
|
562
|
+
if (r.stored) {
|
|
296
563
|
return {
|
|
297
564
|
content: [
|
|
298
565
|
{
|
|
@@ -468,7 +735,7 @@ server.registerTool("memory_read", {
|
|
|
468
735
|
// rooms, one level up from the probes (truth F13, 2026-08-08).
|
|
469
736
|
const items = r?.items;
|
|
470
737
|
if (!Array.isArray(items)) {
|
|
471
|
-
return
|
|
738
|
+
return unreadableAnswerReply("The search result", "a list of matches", "nothing matched");
|
|
472
739
|
}
|
|
473
740
|
if (items.length === 0 && (since || until || exclude_author)) {
|
|
474
741
|
// A bounded/filtered read that finds nothing is NOT a bad query —
|
|
@@ -576,13 +843,72 @@ server.registerTool("memory_read", {
|
|
|
576
843
|
};
|
|
577
844
|
});
|
|
578
845
|
// --- Tool: memory_list_recent ---
|
|
846
|
+
/**
|
|
847
|
+
* How big ONE feed page may get, and how the handler stays under it (#104).
|
|
848
|
+
*
|
|
849
|
+
* THE INCIDENT. `memory_list_recent(domain: "xroom:…", limit: 40, cursor: …)`
|
|
850
|
+
* over a shared room of long archival entries produced a single tool result of
|
|
851
|
+
* 72,648 characters. Claude Code refused to inline it and spilled it to a file;
|
|
852
|
+
* a client without that fallback loses the page. MAX_RESULT_CHARS did not fire,
|
|
853
|
+
* and could not have: 72,648 is comfortably under 96,000. That number is
|
|
854
|
+
* `24,000 tokens × 4 chars/token`, and the 4 is an average over ordinary prose —
|
|
855
|
+
* archival room entries carry ids, code, punctuation and non-ASCII, which
|
|
856
|
+
* tokenize far worse. A cap derived from an optimistic ratio is not a promise
|
|
857
|
+
* about a client's real limit.
|
|
858
|
+
*
|
|
859
|
+
* WHY `limit` COULD NOT SOLVE IT. `limit` bounds the COUNT. Whether a count is
|
|
860
|
+
* safe depends entirely on how long the entries happen to be — 1,500–4,000+
|
|
861
|
+
* chars each in coordination rooms, against a 10,000-char write cap — and the
|
|
862
|
+
* caller cannot know that before asking. Too high explodes; too low costs
|
|
863
|
+
* dozens of round trips for the same catch-up.
|
|
864
|
+
*
|
|
865
|
+
* WHAT THIS IS. A page is assembled from small sub-requests and stops BEFORE
|
|
866
|
+
* the budget is exceeded, returning the server cursor of the last FULLY
|
|
867
|
+
* accepted sub-batch. The cursor is per-batch, which is why the batch is small:
|
|
868
|
+
* it is the granularity at which the page can end without losing or repeating
|
|
869
|
+
* an entry. Nothing is dropped silently — an entry that exceeds the budget on
|
|
870
|
+
* its own is returned whole, as a page of one, because per-entry truncation
|
|
871
|
+
* needs a fetch-one-by-id verb this server does not have (#104, suggestion 2).
|
|
872
|
+
*
|
|
873
|
+
* THE NUMBER IS DELIBERATELY CONSERVATIVE AND DELIBERATELY LOCAL. 40,000 is
|
|
874
|
+
* roughly half of what already failed in production; the true boundary of any
|
|
875
|
+
* given client is unmeasured, and calibrating it is follow-up work. It does NOT
|
|
876
|
+
* touch MAX_RESULT_CHARS, which is shared with memory_read and every other
|
|
877
|
+
* surface and remains the final backstop after this budget has done its work.
|
|
878
|
+
*/
|
|
879
|
+
const LIST_PAGE_CHAR_BUDGET = 40_000;
|
|
880
|
+
/**
|
|
881
|
+
* Entries per sub-request. Small enough that the budget can end a page at a
|
|
882
|
+
* useful granularity, large enough that an ordinary short-entry feed still
|
|
883
|
+
* costs one or two round trips (the default `limit` of 20 costs two).
|
|
884
|
+
*/
|
|
885
|
+
const LIST_PAGE_CHUNK = 10;
|
|
886
|
+
/**
|
|
887
|
+
* A ceiling on sub-requests per call — the loop's own stop condition is the
|
|
888
|
+
* budget, the caller's `limit`, or the end of the feed, and this fires only if
|
|
889
|
+
* a server answers in a way none of those three catch. `limit: 100` needs ten,
|
|
890
|
+
* plus a few for narrowing an over-budget batch.
|
|
891
|
+
*/
|
|
892
|
+
const LIST_PAGE_MAX_REQUESTS = 16;
|
|
893
|
+
/**
|
|
894
|
+
* Appended when the page stopped because a LATER sub-request failed. Chunking
|
|
895
|
+
* multiplies the requests per call and therefore the chance one of them fails
|
|
896
|
+
* mid-page; discarding the entries already in hand would make this change a
|
|
897
|
+
* regression for exactly the long-entry rooms it exists for. Saying nothing
|
|
898
|
+
* would be worse — a short page with a valid cursor is indistinguishable from
|
|
899
|
+
* a page the budget ended, which is the could-not-fetch/does-not-exist
|
|
900
|
+
* collision this codebase keeps closing.
|
|
901
|
+
*/
|
|
902
|
+
const LIST_PAGE_EARLY_STOP_NOTE = "\n\n(This page stopped early — the request for the next batch of older " +
|
|
903
|
+
"entries did not come back usable, so this page holds fewer entries than " +
|
|
904
|
+
"asked for. The cursor above is unaffected: continue from it.)";
|
|
579
905
|
server.registerTool("memory_list_recent", {
|
|
580
|
-
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.",
|
|
906
|
+
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. A page is also bounded by SIZE, so a page of long entries comes back shorter than `limit` and hands you a cursor for the rest — nothing is dropped, and following the cursor is how you get it. 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.",
|
|
581
907
|
inputSchema: {
|
|
582
908
|
domain: z
|
|
583
909
|
.string()
|
|
584
910
|
.optional()
|
|
585
|
-
.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."),
|
|
911
|
+
.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. Room entries are often long — a room feed usually reaches its size budget after a handful of them, so expect to page (see `limit`)."),
|
|
586
912
|
since: z
|
|
587
913
|
.string()
|
|
588
914
|
.optional()
|
|
@@ -602,7 +928,7 @@ server.registerTool("memory_list_recent", {
|
|
|
602
928
|
.min(1)
|
|
603
929
|
.max(100)
|
|
604
930
|
.optional()
|
|
605
|
-
.describe("
|
|
931
|
+
.describe("Most entries per page (default: 20). Newest first. ⚠️ A CEILING, not a promise: the page is ALSO bounded by size, so a page of long entries stops early and returns a cursor for the rest. In rooms whose entries run long, ask for 5–10 — a large `limit` there buys nothing the size budget will not take back, and costs round trips."),
|
|
606
932
|
cursor: z
|
|
607
933
|
.string()
|
|
608
934
|
.max(512)
|
|
@@ -620,50 +946,151 @@ server.registerTool("memory_list_recent", {
|
|
|
620
946
|
// ONE value for the request and for every decision about it — see the note
|
|
621
947
|
// at the top of memory_read.
|
|
622
948
|
const searched = searchedScope(domain);
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
949
|
+
// The caller's `limit` is a CEILING on the item count. The page also has a
|
|
950
|
+
// character budget (LIST_PAGE_CHAR_BUDGET), and whichever binds first ends
|
|
951
|
+
// the page — which is why this is a loop over small sub-requests rather
|
|
952
|
+
// than one request for `limit` entries followed by a cap that arrives too
|
|
953
|
+
// late to do anything but truncate.
|
|
954
|
+
const ceiling = limit || 20;
|
|
955
|
+
const accepted = [];
|
|
956
|
+
// The server cursor of the last FULLY accepted sub-batch: the only value
|
|
957
|
+
// that can be handed back without losing or repeating an entry, since a
|
|
958
|
+
// cursor names a batch boundary and nothing finer.
|
|
959
|
+
let acceptedCursor;
|
|
960
|
+
// Where the next sub-request continues from — the caller's cursor first,
|
|
961
|
+
// the server's thereafter. Resending the caller's would replay page one.
|
|
962
|
+
let position = cursor || undefined;
|
|
963
|
+
let ask = Math.min(LIST_PAGE_CHUNK, ceiling);
|
|
964
|
+
let stoppedEarly = false;
|
|
965
|
+
for (let attempt = 0; attempt < LIST_PAGE_MAX_REQUESTS; attempt++) {
|
|
966
|
+
let r;
|
|
967
|
+
try {
|
|
968
|
+
r = await apiFetch("/memory/recent", {
|
|
969
|
+
method: "POST",
|
|
970
|
+
body: JSON.stringify(recentRequestBody({
|
|
971
|
+
domain,
|
|
972
|
+
since,
|
|
973
|
+
until,
|
|
974
|
+
exclude_author,
|
|
975
|
+
limit: ask,
|
|
976
|
+
cursor: position,
|
|
977
|
+
})),
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
catch (e) {
|
|
981
|
+
// A failure with entries already in hand ends the page instead of the
|
|
982
|
+
// call: the caller keeps what was fetched plus a cursor that still
|
|
983
|
+
// continues correctly, and the appended note says the page was cut
|
|
984
|
+
// short by a failed request rather than by the budget. With nothing
|
|
985
|
+
// accepted there is no page to return, so the error surfaces exactly
|
|
986
|
+
// as it did before chunking.
|
|
987
|
+
if (accepted.length > 0) {
|
|
988
|
+
stoppedEarly = true;
|
|
989
|
+
break;
|
|
990
|
+
}
|
|
991
|
+
// Graceful degradation while the server side rolls out: a 404 with no
|
|
992
|
+
// error `code` in the body is what an undeployed /memory/recent looks
|
|
993
|
+
// like, so degrade to a usable alternative instead of surfacing a raw
|
|
994
|
+
// HTTP error.
|
|
995
|
+
//
|
|
996
|
+
// NAMED FOR WHAT IT TESTS. This was `endpointAbsent`, which asserted a
|
|
997
|
+
// deployment fact the check cannot establish: every engine 404 carries a
|
|
998
|
+
// `code`, so a real room-404 is excluded, but a gateway, a proxy or a
|
|
999
|
+
// wrong MNEMOVERSE_API_URL produces the same bare 404 and is
|
|
1000
|
+
// indistinguishable from here. The MESSAGE below still states the
|
|
1001
|
+
// deployment cause outright, which is more than this boolean knows —
|
|
1002
|
+
// listed in CHANGELOG's "Known and NOT fixed here" rather than papered
|
|
1003
|
+
// over with a hedge.
|
|
1004
|
+
//
|
|
1005
|
+
// NOW READ FROM STRUCTURED FIELDS. It used to be
|
|
1006
|
+
// `e.message.startsWith("Mnemoverse API error 404:")` plus a substring
|
|
1007
|
+
// hunt for `"code"` in the same string — a behavioural branch keyed to the
|
|
1008
|
+
// exact prefix of a user-facing sentence. Rewording that sentence, which
|
|
1009
|
+
// is precisely what src/errors.ts does, would have flipped this branch
|
|
1010
|
+
// silently: every per-request 404 would have degraded into "the service
|
|
1011
|
+
// does not support the feed yet". `ApiError.isBare404` asks the parsed
|
|
1012
|
+
// envelope instead, so the prose and the branch can no longer collide.
|
|
1013
|
+
const bare404 = e instanceof ApiError && e.isBare404;
|
|
1014
|
+
if (bare404) {
|
|
1015
|
+
return {
|
|
1016
|
+
content: [
|
|
1017
|
+
{
|
|
1018
|
+
type: "text",
|
|
1019
|
+
text: "The memory service does not support the recent-entries feed yet. " +
|
|
1020
|
+
"Use memory_read with order_by: 'recency' as an approximation.",
|
|
1021
|
+
},
|
|
1022
|
+
],
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
throw e;
|
|
1026
|
+
}
|
|
1027
|
+
// Same guard as memory_read: a 200 without an items array is UNREADABLE,
|
|
1028
|
+
// not empty — and the feed's empty heads below are precisely the absence
|
|
1029
|
+
// claims that must not be derived from it (truth F13, 2026-08-08). Mid
|
|
1030
|
+
// page it is treated like a failed sub-request, for the same reason.
|
|
1031
|
+
const batch = r?.items;
|
|
1032
|
+
if (!Array.isArray(batch)) {
|
|
1033
|
+
if (accepted.length > 0) {
|
|
1034
|
+
stoppedEarly = true;
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
return unreadableAnswerReply("The recent-entries feed", "an empty feed", "there is nothing to list");
|
|
1038
|
+
}
|
|
1039
|
+
const next = r?.next_cursor;
|
|
1040
|
+
// Measured on the TEXT THAT WOULD SHIP, rendered by the same function
|
|
1041
|
+
// that renders the answer — an estimate from item lengths would drift
|
|
1042
|
+
// from the renderer the first time a line gained a field. The early-stop
|
|
1043
|
+
// note's length is reserved up front: it is appended only when a LATER
|
|
1044
|
+
// sub-request fails, which cannot be known while this batch is being
|
|
1045
|
+
// sized, so every page keeps room for it (CodeRabbit, PR #108).
|
|
1046
|
+
const fits = formatRecentPage(accepted.concat(batch), next).length <=
|
|
1047
|
+
LIST_PAGE_CHAR_BUDGET - LIST_PAGE_EARLY_STOP_NOTE.length;
|
|
1048
|
+
if (fits) {
|
|
1049
|
+
accepted.push(...batch);
|
|
1050
|
+
acceptedCursor = next;
|
|
1051
|
+
position = typeof next === "string" && next ? next : undefined;
|
|
1052
|
+
// No cursor: the feed ended, and the page says so. No entries: the
|
|
1053
|
+
// server is not advancing, so continuing would spend requests on the
|
|
1054
|
+
// same nothing. Ceiling reached: the caller's count is spent.
|
|
1055
|
+
if (!position || batch.length === 0 || accepted.length >= ceiling)
|
|
1056
|
+
break;
|
|
1057
|
+
ask = Math.min(LIST_PAGE_CHUNK, ceiling - accepted.length);
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
// Over budget. With entries already accepted, THIS is the ordinary stop:
|
|
1061
|
+
// keep the batches that fit and hand back the cursor of the last one.
|
|
1062
|
+
if (accepted.length > 0)
|
|
1063
|
+
break;
|
|
1064
|
+
// Nothing accepted yet, so this one batch is over budget by itself and
|
|
1065
|
+
// the page cannot be empty — something must be returned.
|
|
635
1066
|
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1067
|
+
// `batch.length > ask` means the server ignored `limit`; asking again,
|
|
1068
|
+
// smaller, would be a wasted round trip against a deployment that is not
|
|
1069
|
+
// listening, and MAX_RESULT_CHARS is the backstop for it. A batch of one
|
|
1070
|
+
// is the entry that exceeds the budget alone: it ships whole, because
|
|
1071
|
+
// dropping it is silent loss and truncating it needs a fetch-by-id verb
|
|
1072
|
+
// that does not exist yet (#104).
|
|
1073
|
+
//
|
|
1074
|
+
// KNOWN EDGE, inherited rather than introduced (CodeRabbit, PR #108):
|
|
1075
|
+
// when a limit-ignoring server's batch ships whole and capResult then
|
|
1076
|
+
// truncates the tail, the printed cursor points past entries the reader
|
|
1077
|
+
// never saw. 0.9.1 had the identical hazard (one request, the server's
|
|
1078
|
+
// cursor, the same cap). The alternatives are worse lies: slicing to
|
|
1079
|
+
// `ask` keeps the server's cursor and SKIPS the sliced entries silently;
|
|
1080
|
+
// rejecting the batch outright answers a working feed with "unreadable".
|
|
1081
|
+
// A contract-violating server is the precondition; the real fix is
|
|
1082
|
+
// fetch-by-id (#104 follow-up), not a guess here.
|
|
1083
|
+
const narrower = Math.max(1, Math.min(Math.floor(ask / 2), batch.length - 1));
|
|
1084
|
+
if (batch.length <= 1 || batch.length > ask || narrower >= ask) {
|
|
1085
|
+
accepted.push(...batch);
|
|
1086
|
+
acceptedCursor = next;
|
|
1087
|
+
break;
|
|
657
1088
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
// not empty — and the feed's empty heads below are precisely the absence
|
|
662
|
-
// claims that must not be derived from it (truth F13, 2026-08-08).
|
|
663
|
-
const items = r?.items;
|
|
664
|
-
if (!Array.isArray(items)) {
|
|
665
|
-
return unreadableListReply("The recent-entries feed", "an empty feed", "there is nothing to list");
|
|
1089
|
+
// Re-ask the SAME position for fewer entries. `narrower < batch.length`
|
|
1090
|
+
// by construction, so the ask strictly shrinks and the loop converges.
|
|
1091
|
+
ask = narrower;
|
|
666
1092
|
}
|
|
1093
|
+
const items = accepted;
|
|
667
1094
|
if (items.length === 0) {
|
|
668
1095
|
// THE SENTENCE ITSELF carries the scope — and it is selected by EVERY
|
|
669
1096
|
// filter that narrowed the window, not by `since` alone.
|
|
@@ -731,14 +1158,45 @@ server.registerTool("memory_list_recent", {
|
|
|
731
1158
|
// 2026-08-08). Same order as memory_read's result page, same
|
|
732
1159
|
// automatic drop: a cap that removed every escaped name removes the
|
|
733
1160
|
// reason for the legend too.
|
|
734
|
-
|
|
1161
|
+
//
|
|
1162
|
+
// The cursor is the last ACCEPTED batch's, never the newest one
|
|
1163
|
+
// seen: a batch that did not fit the budget was not returned, so
|
|
1164
|
+
// pointing past it would skip every entry in it.
|
|
1165
|
+
text: withDomainEscapeLegend(capResult(formatRecentPage(items, acceptedCursor) +
|
|
1166
|
+
(stoppedEarly ? LIST_PAGE_EARLY_STOP_NOTE : ""),
|
|
1167
|
+
// Still true, and now only reachable when ONE entry is larger
|
|
1168
|
+
// than the whole budget — the case `limit` cannot fix and the
|
|
1169
|
+
// global cap has to.
|
|
1170
|
+
"Lower `limit` or add a `domain` for smaller pages."), ...items.map((it) => it?.domain)),
|
|
735
1171
|
},
|
|
736
1172
|
],
|
|
737
1173
|
};
|
|
738
1174
|
});
|
|
739
1175
|
// --- Tool: memory_feedback ---
|
|
1176
|
+
/**
|
|
1177
|
+
* Why ids can miss, listed once and used by both branches that need it — the
|
|
1178
|
+
* total miss (`updated_count: 0`) and the partial one (a count short of the
|
|
1179
|
+
* ids sent). They are the same event at two scales, and when the sentence
|
|
1180
|
+
* lived inline in the zero branch only, the partial case got no explanation at
|
|
1181
|
+
* all.
|
|
1182
|
+
*
|
|
1183
|
+
* No frequency claim. "Most often that means…" was a statistic we do not have
|
|
1184
|
+
* (review, 2026-08-08); the causes are listed as possibilities, with the one
|
|
1185
|
+
* the caller cannot otherwise guess first because it is invisible from the
|
|
1186
|
+
* tool surface — this tool takes no `domain`, so a room atom is unreachable
|
|
1187
|
+
* from it by construction.
|
|
1188
|
+
*/
|
|
1189
|
+
const FEEDBACK_MISS_CAUSES = "Possible causes: the ids came from a shared room (this tool takes no domain " +
|
|
1190
|
+
"argument and cannot reach room atoms, so rating them is a no-op); the memory " +
|
|
1191
|
+
"was deleted; or the id came from somewhere other than a memory_read result.";
|
|
740
1192
|
server.registerTool("memory_feedback", {
|
|
741
|
-
description:
|
|
1193
|
+
description:
|
|
1194
|
+
// "negative feedback lets it fade" was withdrawn as false by 0.9.1
|
|
1195
|
+
// (#95) — and survived here, in the sentence every connected model
|
|
1196
|
+
// reads. Nothing time-decays and nothing is auto-deleted: a downvoted
|
|
1197
|
+
// memory is OUT-RANKED, and deletion has been administrative-only since
|
|
1198
|
+
// 0.9.0. The replacement is the wording that release put on the README.
|
|
1199
|
+
"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 lowers it so other memories out-rank it — nothing is erased and nothing decays with time. 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.",
|
|
742
1200
|
inputSchema: {
|
|
743
1201
|
atom_ids: z
|
|
744
1202
|
.array(z.string())
|
|
@@ -766,7 +1224,67 @@ server.registerTool("memory_feedback", {
|
|
|
766
1224
|
method: "POST",
|
|
767
1225
|
body: JSON.stringify({ atom_ids, outcome }),
|
|
768
1226
|
});
|
|
769
|
-
|
|
1227
|
+
// A FIELD THE SERVER DID NOT SEND IS UNKNOWN, NOT ZERO — the rule
|
|
1228
|
+
// memory_stats already applies with `num()`, broken here by
|
|
1229
|
+
// `r?.updated_count ?? 0` in three directions at once:
|
|
1230
|
+
//
|
|
1231
|
+
// 1. A 200 with the field absent, an explicit null, and a 204 (which
|
|
1232
|
+
// apiFetch turns into `{}`) all became 0, and 0 prints "No feedback
|
|
1233
|
+
// was recorded" plus three causes for it — an absence claim read out
|
|
1234
|
+
// of a body that carried no claim. Under core's async path the
|
|
1235
|
+
// rating may well have been applied while the ack said nothing.
|
|
1236
|
+
// 2. A string "0" is not 0, so `??` passed it straight through and the
|
|
1237
|
+
// ±1 branches printed "The service reports 0 memories updated — they
|
|
1238
|
+
// should surface sooner next time": two clauses contradicting each
|
|
1239
|
+
// other in one sentence.
|
|
1240
|
+
// 3. Nothing rejected a negative: "reports -2 memories updated".
|
|
1241
|
+
//
|
|
1242
|
+
// So: usable means a non-negative integer. Anything else is UNKNOWN and
|
|
1243
|
+
// gets its own sentence, which diagnoses nothing — the causes of a miss
|
|
1244
|
+
// belong to a reported zero, not to a number we never received.
|
|
1245
|
+
const reported = r?.updated_count;
|
|
1246
|
+
const count = typeof reported === "number" && Number.isInteger(reported) && reported >= 0
|
|
1247
|
+
? reported
|
|
1248
|
+
: undefined;
|
|
1249
|
+
// Direction is echoed for every outcome, including the ones with no count
|
|
1250
|
+
// to report: the same four words for +1 and -1 gave a caller no evidence
|
|
1251
|
+
// the loop did anything, which is why nobody calls it twice.
|
|
1252
|
+
const sent = outcome > 0
|
|
1253
|
+
? `Rating sent: +${outcome} (helpful).`
|
|
1254
|
+
: outcome < 0
|
|
1255
|
+
? `Rating sent: ${outcome} (unhelpful).`
|
|
1256
|
+
: "Rating sent: 0.";
|
|
1257
|
+
// Zero has no effect clause to offer — promising "this shifts how they
|
|
1258
|
+
// rank" for outcome 0 would be a claim we cannot make (dogfooding saw a
|
|
1259
|
+
// neutral rating move a score UP by about five points, CodeRabbit #65) —
|
|
1260
|
+
// so it offers the one thing that is actionable instead.
|
|
1261
|
+
//
|
|
1262
|
+
// WHAT 0 ACTUALLY DOES, restated against core#493 (merged 2026-08-13). The
|
|
1263
|
+
// valence step used to be `sign(outcome) * |prediction error|`, so outcome
|
|
1264
|
+
// 0 took the POSITIVE branch and pushed valence UP — which is what
|
|
1265
|
+
// dogfooding saw, and what the previous version of this comment recorded.
|
|
1266
|
+
// That is no longer true: core now uses the SIGNED error,
|
|
1267
|
+
// `pe = outcome - valence` (memory_engine.py:5167-5169), so a 0 against a
|
|
1268
|
+
// positive valence moves it DOWN, toward neutral. Either way 0 is not a
|
|
1269
|
+
// no-op and the line must not imply one — but the old explanation is now
|
|
1270
|
+
// backwards, and it ships verbatim inside dist/index.js, so it cannot be
|
|
1271
|
+
// left to rot in a comment.
|
|
1272
|
+
const pickADirection = outcome === 0
|
|
1273
|
+
? " Use +1 (helpful) or -1 (harmful/wrong) to express a clear direction."
|
|
1274
|
+
: "";
|
|
1275
|
+
if (count === undefined) {
|
|
1276
|
+
return {
|
|
1277
|
+
content: [
|
|
1278
|
+
{
|
|
1279
|
+
type: "text",
|
|
1280
|
+
text: `${sent} The service accepted the call but did not report how many ` +
|
|
1281
|
+
`memories it updated, so whether any changed is unknown from here. ` +
|
|
1282
|
+
`That is not evidence of a failure — do not re-send the same rating ` +
|
|
1283
|
+
`on the strength of it.` + pickADirection,
|
|
1284
|
+
},
|
|
1285
|
+
],
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
770
1288
|
// "Feedback recorded for 0 memories." is one character away from the
|
|
771
1289
|
// success line and reads like one. But the DIAGNOSIS matters as much as
|
|
772
1290
|
// the fact: an earlier version of this branch blamed deletion, which is
|
|
@@ -783,32 +1301,16 @@ server.registerTool("memory_feedback", {
|
|
|
783
1301
|
content: [
|
|
784
1302
|
{
|
|
785
1303
|
type: "text",
|
|
786
|
-
text:
|
|
787
|
-
|
|
788
|
-
// we do not have (review, 2026-08-08); the causes are listed as
|
|
789
|
-
// possibilities, with the one the caller cannot otherwise guess
|
|
790
|
-
// first because it is invisible from the tool surface.
|
|
791
|
-
"No feedback was recorded — none of those ids matched a memory in your own " +
|
|
792
|
-
"domains. Possible causes: the ids came from a shared room (this tool takes " +
|
|
793
|
-
"no domain argument and cannot reach room atoms, so rating them is a no-op); " +
|
|
794
|
-
"the memory was deleted; or the id came from somewhere other than a " +
|
|
795
|
-
"memory_read result.",
|
|
1304
|
+
text: "No feedback was recorded — none of those ids matched a memory in your own " +
|
|
1305
|
+
`domains. ${FEEDBACK_MISS_CAUSES}`,
|
|
796
1306
|
},
|
|
797
1307
|
],
|
|
798
1308
|
};
|
|
799
1309
|
}
|
|
800
|
-
// Echo the DIRECTION, not just the count: the same four words for +1 and
|
|
801
|
-
// -1 gave a caller no evidence the loop did anything, which is why nobody
|
|
802
|
-
// calls it twice.
|
|
803
|
-
//
|
|
804
|
-
// The effect clause is per-direction. Promising "this shifts how they
|
|
805
|
-
// rank" for outcome 0 would be a claim we cannot make — dogfooding saw a
|
|
806
|
-
// neutral rating move a score UP by about five points, so neither "no
|
|
807
|
-
// change" nor "ranks higher" is safe to assert (CodeRabbit, #65).
|
|
808
1310
|
// WHOSE NUMBER THIS IS (#68). `updated_count` is the count of memories the
|
|
809
1311
|
// service says it touched — and that is true only while core runs
|
|
810
1312
|
// `feedback_async = False`. Under async it returns the number of ids
|
|
811
|
-
// SUBMITTED, not applied (core schemas.py:
|
|
1313
|
+
// SUBMITTED, not applied (core schemas.py:826-838, memory_engine.py:4898-4902),
|
|
812
1314
|
// and nothing in the response says which mode ran. A server-side config
|
|
813
1315
|
// flip would therefore turn a confident sentence here false on every
|
|
814
1316
|
// installed client, silently. So the number is reported as the SERVICE'S
|
|
@@ -817,27 +1319,50 @@ server.registerTool("memory_feedback", {
|
|
|
817
1319
|
// (mnemoverse-mcp-remote#38); one number, one degree of confidence,
|
|
818
1320
|
// whichever surface a model reaches it through.
|
|
819
1321
|
const noun = `${count} memor${count === 1 ? "y" : "ies"}`;
|
|
820
|
-
const
|
|
821
|
-
?
|
|
1322
|
+
const effect = outcome > 0
|
|
1323
|
+
? " — they should surface sooner next time."
|
|
822
1324
|
: outcome < 0
|
|
823
|
-
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1325
|
+
// No fade. 0.9.1 (#95) withdrew "lets it fade" as false — nothing
|
|
1326
|
+
// time-decays, nothing is auto-deleted, and deletion has been
|
|
1327
|
+
// administrative-only since 0.9.0 — and this line kept promising it
|
|
1328
|
+
// after the release that deleted the claim from the README.
|
|
1329
|
+
? " — they should rank lower next time. Out-ranked, not erased: nothing is deleted and nothing decays with time."
|
|
1330
|
+
: ".";
|
|
1331
|
+
// WHAT THE COUNT IS NOT: a guarantee that every id landed. `atom_ids.length`
|
|
1332
|
+
// was never compared with it, so five ids and `updated_count: 2` printed
|
|
1333
|
+
// the unqualified success line and three silent misses — the typical shape
|
|
1334
|
+
// of the room case, where half the ids came off a room read this tool
|
|
1335
|
+
// cannot reach. A SHORTFALL can only come from core's sync path (the async
|
|
1336
|
+
// ack is exactly `len(atom_ids)`, memory_engine.py:4898-4902), where the
|
|
1337
|
+
// number is the authoritative count of atoms that existed — so the same
|
|
1338
|
+
// causes as the zero branch apply, at a smaller scale, and the string is
|
|
1339
|
+
// shared so the two cannot drift apart.
|
|
1340
|
+
//
|
|
1341
|
+
// An EXCESS is not a shape core produces at all (sync counts one per id
|
|
1342
|
+
// that resolved, async counts the ids). But MNEMOVERSE_API_URL points
|
|
1343
|
+
// wherever it is pointed, and "reports 9 memories updated" for one id
|
|
1344
|
+
// would otherwise read as nine of the caller's memories rated. Say what it
|
|
1345
|
+
// cannot be rather than pass it off as a per-id result.
|
|
1346
|
+
const idsSent = `${atom_ids.length} id${atom_ids.length === 1 ? "" : "s"} you sent`;
|
|
1347
|
+
const mismatch = count < atom_ids.length
|
|
1348
|
+
? ` That is fewer than the ${idsSent}: ${atom_ids.length - count} of them ` +
|
|
1349
|
+
`matched nothing in your own domains. ${FEEDBACK_MISS_CAUSES}`
|
|
1350
|
+
: count > atom_ids.length
|
|
1351
|
+
? ` That is more than the ${idsSent}, so it cannot be a per-id result — ` +
|
|
1352
|
+
`read it as the service's own tally, not as how many of your memories ` +
|
|
1353
|
+
`were rated.`
|
|
1354
|
+
: "";
|
|
839
1355
|
return {
|
|
840
|
-
content: [
|
|
1356
|
+
content: [
|
|
1357
|
+
{
|
|
1358
|
+
type: "text",
|
|
1359
|
+
// Order: what was sent, what the service reported, what that means
|
|
1360
|
+
// for the ids — then the advice. Putting `pickADirection` before the
|
|
1361
|
+
// mismatch clause interrupted the report with a suggestion and
|
|
1362
|
+
// resumed it afterwards.
|
|
1363
|
+
text: `${sent} The service reports ${noun} updated${effect}${mismatch}${pickADirection}`,
|
|
1364
|
+
},
|
|
1365
|
+
],
|
|
841
1366
|
};
|
|
842
1367
|
});
|
|
843
1368
|
// --- Tool: memory_stats ---
|
|
@@ -874,6 +1399,15 @@ server.registerTool("memory_stats", {
|
|
|
874
1399
|
// zero-width character are all visible, Cyrillic stays Cyrillic, and two
|
|
875
1400
|
// different names can no longer print as one. Assembly lives in
|
|
876
1401
|
// src/names.ts, where it is unit-tested against those exact inputs.
|
|
1402
|
+
//
|
|
1403
|
+
// The assembly also BOUNDS the list (MAX_DOMAIN_LIST_CHARS), which is what
|
|
1404
|
+
// makes this handler respect the 25K-token cap every other surface already
|
|
1405
|
+
// respected. The line is linear in the number of stores and nothing bounded
|
|
1406
|
+
// it: 4,000 domains rendered past 100,000 characters — deterministically,
|
|
1407
|
+
// with no hostile input involved. Bounding the LIST rather than leaning on
|
|
1408
|
+
// capResult alone is the point: capResult truncates from the END, so the
|
|
1409
|
+
// wall of names would have taken the average-quality line and the rooms
|
|
1410
|
+
// reminder down with it, leaving the answer nothing but names.
|
|
877
1411
|
const domains = formatDomainList(r?.domains);
|
|
878
1412
|
const text = [
|
|
879
1413
|
`Memories: ${num(r?.total_atoms)} (${num(r?.episodes)} episodes, ${num(r?.prototypes)} prototypes)`,
|
|
@@ -900,7 +1434,18 @@ server.registerTool("memory_stats", {
|
|
|
900
1434
|
// arrives over the wire, and spreading a non-iterable object would
|
|
901
1435
|
// throw here — turning a malformed payload into a dead tool instead
|
|
902
1436
|
// of the "none reported" it degrades to two lines up.
|
|
903
|
-
|
|
1437
|
+
//
|
|
1438
|
+
// capResult is the second belt, not the mechanism: the domain list is
|
|
1439
|
+
// already bounded above, so this only fires if some future line grows
|
|
1440
|
+
// unboundedly. It stays because this was the ONE tool result with no
|
|
1441
|
+
// cap at all, and "every surface is capped" is worth being an
|
|
1442
|
+
// invariant rather than an argument about which surfaces can grow.
|
|
1443
|
+
// Its hint names a control this no-input tool actually has — none —
|
|
1444
|
+
// rather than the read tool's "use a more specific query".
|
|
1445
|
+
//
|
|
1446
|
+
// Legend AFTER the cap, as everywhere else: it must describe the names
|
|
1447
|
+
// that SURVIVED, and it is appended at the end, where the cap cuts.
|
|
1448
|
+
text: withDomainEscapeLegend(capResult(text, "The domain list was truncated — some domain names are not shown."), ...(Array.isArray(r?.domains) ? r.domains : [])),
|
|
904
1449
|
},
|
|
905
1450
|
],
|
|
906
1451
|
};
|
|
@@ -1011,7 +1556,7 @@ server.registerTool("memory_invite_to_room", {
|
|
|
1011
1556
|
});
|
|
1012
1557
|
// --- Tool: memory_join_room ---
|
|
1013
1558
|
server.registerTool("memory_join_room", {
|
|
1014
|
-
description: "Join a shared memory room using an invite code (starts with 'mnvr_'). Use when the user pastes an invite code or says something like 'join room with code ...'. After joining, use the returned address as the `domain` on
|
|
1559
|
+
description: "Join a shared memory room using an invite code (starts with 'mnvr_'). Use when the user pastes an invite code or says something like 'join room with code ...'. After joining, use the returned address as the `domain` on memory_read to read the shared room — the result tells you what you may do with it: memory_write to that address is only allowed when your membership scope is read_write; a read-only membership has that write refused; and when the server does not report a scope, whether memory_write would succeed is stated as unknown rather than promised either way.",
|
|
1015
1560
|
inputSchema: {
|
|
1016
1561
|
code: z.string().min(1).max(200).describe("The invite code (mnvr_...)."),
|
|
1017
1562
|
},
|
|
@@ -1040,9 +1585,18 @@ server.registerTool("memory_join_room", {
|
|
|
1040
1585
|
? `You're already a member of ${roomName}.`
|
|
1041
1586
|
: `Joined ${roomName} (${scope}).`;
|
|
1042
1587
|
// Don't print broken `domain=""` guidance if no address came back (Copilot).
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1588
|
+
// The write half of this sentence is scope-gated (roomScopeVerdict, above
|
|
1589
|
+
// isRoomDomain): a "read" invite gets told memory_write will be refused
|
|
1590
|
+
// rather than offered it, and a scope the response did not report at all
|
|
1591
|
+
// gets no promise about write either way.
|
|
1592
|
+
const verdict = roomScopeVerdict(r?.scope);
|
|
1593
|
+
const usage = !address
|
|
1594
|
+
? `The server did not return a room address — retry, or check that your API key is set.`
|
|
1595
|
+
: verdict === "read_write"
|
|
1596
|
+
? `Use it: pass domain="${address}" on memory_write / memory_read to read and write the shared room.`
|
|
1597
|
+
: verdict === "read"
|
|
1598
|
+
? `Use it: pass domain="${address}" on memory_read to read it; this membership is read-only, so memory_write to that address will be refused.`
|
|
1599
|
+
: `Use it: pass domain="${address}" on memory_read to read it — the server did not report this membership's write access, so whether memory_write to that address would succeed is unknown.`;
|
|
1046
1600
|
return {
|
|
1047
1601
|
content: [
|
|
1048
1602
|
{
|
|
@@ -1055,7 +1609,7 @@ server.registerTool("memory_join_room", {
|
|
|
1055
1609
|
});
|
|
1056
1610
|
// --- Tool: memory_list_rooms ---
|
|
1057
1611
|
server.registerTool("memory_list_rooms", {
|
|
1058
|
-
description: "List the shared memory rooms you can use — the ones you OWN plus the ones you've JOINED — each with the address to pass as `domain` on memory_write
|
|
1612
|
+
description: "List the shared memory rooms you can use — the ones you OWN plus the ones you've JOINED — each with the address to pass as `domain` on memory_read, and on memory_write too where your membership scope is read_write; a read-only membership has that write refused. Use this to RE-FIND a room in a new session (e.g. 'what rooms do I have?', 'resume the room with Olya') instead of having to create or re-join it.",
|
|
1059
1613
|
inputSchema: {},
|
|
1060
1614
|
annotations: {
|
|
1061
1615
|
title: "List rooms",
|
|
@@ -1076,7 +1630,7 @@ server.registerTool("memory_list_rooms", {
|
|
|
1076
1630
|
// Byte-identical to the inline wording this replaces — the builder is
|
|
1077
1631
|
// shared so the four list surfaces answer the unreadable case with one
|
|
1078
1632
|
// sentence, not four drifting ones.
|
|
1079
|
-
return
|
|
1633
|
+
return unreadableAnswerReply("The room list", "a list of your rooms", "you have none");
|
|
1080
1634
|
}
|
|
1081
1635
|
if (rooms.state === "none") {
|
|
1082
1636
|
return {
|
|
@@ -1109,13 +1663,24 @@ server.registerTool("memory_list_rooms", {
|
|
|
1109
1663
|
// src/scope.ts), so that clause was an instruction to make a call that
|
|
1110
1664
|
// cannot succeed. The address stays visible — it is the room's identity —
|
|
1111
1665
|
// but the line says what a read against it will do.
|
|
1666
|
+
//
|
|
1667
|
+
// For a live room, the write half is scope-gated the same way
|
|
1668
|
+
// memory_join_room's usage sentence is (roomScopeVerdict, near
|
|
1669
|
+
// isRoomDomain): this used to say "use domain=..." with no operation
|
|
1670
|
+
// named, which read as an unqualified read+write invitation even for a
|
|
1671
|
+
// "read" member — false, since core refuses that member's memory_write.
|
|
1672
|
+
const verdict = roomScopeVerdict(scope);
|
|
1112
1673
|
const tail = r?.archived
|
|
1113
1674
|
? address
|
|
1114
1675
|
? ` [archived] — address ${address}, but every read is refused while it is archived`
|
|
1115
1676
|
: ` [archived] — every read is refused while it is archived`
|
|
1116
|
-
: address
|
|
1117
|
-
?
|
|
1118
|
-
: ""
|
|
1677
|
+
: !address
|
|
1678
|
+
? ""
|
|
1679
|
+
: verdict === "read_write"
|
|
1680
|
+
? ` — use domain="${address}" to read and write it`
|
|
1681
|
+
: verdict === "read"
|
|
1682
|
+
? ` — use domain="${address}" on memory_read only; this membership is read-only, so memory_write to it will be refused`
|
|
1683
|
+
: ` — use domain="${address}" on memory_read; this membership's write access was not reported`;
|
|
1119
1684
|
return `- ${name} (${role}${scope ? `, ${scope}` : ""})${tail}`;
|
|
1120
1685
|
});
|
|
1121
1686
|
const text = `Your shared rooms (${list.length}):\n${lines.join("\n")}`;
|
|
@@ -1151,7 +1716,7 @@ server.registerTool("vault_list", {
|
|
|
1151
1716
|
// 2026-08-08). A genuinely empty array keeps the absence claim below.
|
|
1152
1717
|
const list = r?.secrets;
|
|
1153
1718
|
if (!Array.isArray(list)) {
|
|
1154
|
-
return
|
|
1719
|
+
return unreadableAnswerReply("The secret list", "a list of your Vault secrets", "none are stored");
|
|
1155
1720
|
}
|
|
1156
1721
|
if (list.length === 0) {
|
|
1157
1722
|
return {
|
|
@@ -1237,10 +1802,33 @@ server.registerTool("vault_list", {
|
|
|
1237
1802
|
function probeApiKeyInBackground() {
|
|
1238
1803
|
if (!API_KEY)
|
|
1239
1804
|
return;
|
|
1805
|
+
if (BASE_URL_REFUSAL !== undefined) {
|
|
1806
|
+
// THE SECOND CREDENTIAL-BEARING CALL SITE (#99). This probe predates
|
|
1807
|
+
// apiFetch's base-URL guard and calls `fetch` directly, so the guard does
|
|
1808
|
+
// not reach it — and this one fires on every server START, before any tool
|
|
1809
|
+
// is called. Left alone, a user whose MNEMOVERSE_API_URL is spelled
|
|
1810
|
+
// `http://` would leak the key by launching their editor, and the tool-call
|
|
1811
|
+
// guard would then dutifully refuse to leak the key it had already leaked.
|
|
1812
|
+
//
|
|
1813
|
+
// Saying nothing is not an option either: a silent server whose every tool
|
|
1814
|
+
// then fails is the exact invisible failure mode this probe was added to
|
|
1815
|
+
// end. So it reports the skip on stderr — where MCP clients surface
|
|
1816
|
+
// connection logs — in the user's own register.
|
|
1817
|
+
console.error(BASE_URL_REFUSAL.startupLog);
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1240
1820
|
void (async () => {
|
|
1241
1821
|
try {
|
|
1242
1822
|
const res = await fetch(`${API_URL}/memory/stats`, {
|
|
1243
1823
|
headers: { "X-Api-Key": API_KEY },
|
|
1824
|
+
// Same reason as apiFetch: following a redirect re-sends this header to
|
|
1825
|
+
// the new host (#99, CWE-200), and this request carries the key just as
|
|
1826
|
+
// a tool call does. The rejection lands in the catch below with every
|
|
1827
|
+
// other probe failure — deliberately, since the probe is fire-and-forget
|
|
1828
|
+
// and must never take the server down. Nothing is lost by the silence:
|
|
1829
|
+
// the same redirect meets the first real tool call, where
|
|
1830
|
+
// explainNetworkFailure names it in full.
|
|
1831
|
+
redirect: "error",
|
|
1244
1832
|
signal: AbortSignal.timeout(10_000),
|
|
1245
1833
|
});
|
|
1246
1834
|
// Drain the body so the connection returns to the pool immediately —
|