@mnemoverse/mcp-memory-server 0.9.1 → 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/dist/errors.d.ts +64 -0
- package/dist/errors.js +177 -9
- package/dist/errors.js.map +1 -1
- package/dist/index.js +655 -125
- 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 +1 -2
package/dist/index.js
CHANGED
|
@@ -14,8 +14,9 @@ import { readRequestBody, recentRequestBody, writeRequestBody, searchedScope, }
|
|
|
14
14
|
import { exactLiteral, formatDomainList, roomNamePhrase, withDomainEscapeLegend, } from "./names.js";
|
|
15
15
|
// Every non-2xx becomes an instruction to the calling model instead of a raw
|
|
16
16
|
// wire echo — see the header of src/errors.ts for why, and for what each status
|
|
17
|
-
// actually means in this engine.
|
|
18
|
-
|
|
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";
|
|
19
20
|
/**
|
|
20
21
|
* What we know about the scope this read actually covered — a VALUE rather
|
|
21
22
|
* than a sentence-or-empty-string. Costs one GET (rooms or stats, chosen by
|
|
@@ -62,19 +63,121 @@ function probeScope(searched) {
|
|
|
62
63
|
// `node_modules/@mnemoverse/mcp-memory-server/dist/` after an npm install.
|
|
63
64
|
const require = createRequire(import.meta.url);
|
|
64
65
|
const pkg = require("../package.json");
|
|
65
|
-
|
|
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;
|
|
66
70
|
const API_KEY = process.env.MNEMOVERSE_API_KEY || "";
|
|
67
71
|
// Hard cap on tool result size — required by Claude Connectors Directory
|
|
68
72
|
// (https://support.claude.com/en/articles/12922490-remote-mcp-server-submission-guide).
|
|
69
73
|
// Approximate token count = chars / 4. Cap at 24,000 tokens to leave headroom under the 25K limit.
|
|
70
74
|
const MAX_RESULT_CHARS = 24_000 * 4;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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);
|
|
78
181
|
/**
|
|
79
182
|
* Fetch from the Mnemoverse core API with authentication.
|
|
80
183
|
*
|
|
@@ -104,10 +207,29 @@ async function apiFetch(path, options = {}) {
|
|
|
104
207
|
"and starts with mk_live_. Do not retry until it is set; every memory " +
|
|
105
208
|
"tool will fail the same way until then.");
|
|
106
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
|
+
}
|
|
107
219
|
let res;
|
|
108
220
|
try {
|
|
109
221
|
res = await fetch(`${API_URL}${path}`, {
|
|
110
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",
|
|
111
233
|
headers: {
|
|
112
234
|
"Content-Type": "application/json",
|
|
113
235
|
"X-Api-Key": API_KEY,
|
|
@@ -126,13 +248,19 @@ async function apiFetch(path, options = {}) {
|
|
|
126
248
|
if (!res.ok) {
|
|
127
249
|
// `fetch()` resolves once HEADERS arrive; a connection reset during the
|
|
128
250
|
// body read rejects HERE, not in the catch above, and used to surface as
|
|
129
|
-
// a raw undici TypeError (Copilot, #93).
|
|
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.
|
|
130
258
|
let text;
|
|
131
259
|
try {
|
|
132
260
|
text = await res.text();
|
|
133
261
|
}
|
|
134
262
|
catch (cause) {
|
|
135
|
-
throw new
|
|
263
|
+
throw new UnreadableBodyError({ status: res.status, method, path, cause });
|
|
136
264
|
}
|
|
137
265
|
throw new ApiError({
|
|
138
266
|
status: res.status,
|
|
@@ -149,14 +277,38 @@ async function apiFetch(path, options = {}) {
|
|
|
149
277
|
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
|
150
278
|
return {};
|
|
151
279
|
}
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
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;
|
|
155
295
|
try {
|
|
156
|
-
|
|
296
|
+
body = await res.text();
|
|
157
297
|
}
|
|
158
298
|
catch (cause) {
|
|
159
|
-
throw new
|
|
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
|
+
});
|
|
160
312
|
}
|
|
161
313
|
}
|
|
162
314
|
/**
|
|
@@ -188,7 +340,7 @@ moreHint = "Use a more specific query to see all results.") {
|
|
|
188
340
|
return `${truncated}\n\n[…truncated to fit the 25K token limit. ${moreHint}]`;
|
|
189
341
|
}
|
|
190
342
|
/**
|
|
191
|
-
* A
|
|
343
|
+
* A 2xx whose body does not carry what core always sends for this operation.
|
|
192
344
|
*
|
|
193
345
|
* Reading such a body as an EMPTY list is the substitution this release exists
|
|
194
346
|
* to remove: `Array.isArray(x) ? x : []` turned "this client could not read
|
|
@@ -200,8 +352,19 @@ moreHint = "Use a more specific query to see all results.") {
|
|
|
200
352
|
* is NOT ("a list of your rooms"), and the absence it is NOT evidence of
|
|
201
353
|
* ("you have none") — so every consumer states its own boundary while the
|
|
202
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.
|
|
203
366
|
*/
|
|
204
|
-
function
|
|
367
|
+
function unreadableAnswerReply(subject, notA, absence, extra = "") {
|
|
205
368
|
return {
|
|
206
369
|
content: [
|
|
207
370
|
{
|
|
@@ -213,7 +376,7 @@ function unreadableListReply(subject, notA, absence) {
|
|
|
213
376
|
// unrecognised body (truth re-verification, 2026-08-09). Pinned in
|
|
214
377
|
// test/handlers.test.ts.
|
|
215
378
|
text: `${subject} came back in a shape this client does not recognise — so this ` +
|
|
216
|
-
`is not ${notA}, and it is not evidence that ${absence}
|
|
379
|
+
`is not ${notA}, and it is not evidence that ${absence}.${extra} Retry; ` +
|
|
217
380
|
`if it persists, whatever answered this call — the memory service, a ` +
|
|
218
381
|
`gateway or proxy in front of it, or the endpoint a mis-set ` +
|
|
219
382
|
`MNEMOVERSE_API_URL points at — is answering in a shape this client ` +
|
|
@@ -272,6 +435,29 @@ export const server = new McpServer({
|
|
|
272
435
|
function isRoomDomain(domain) {
|
|
273
436
|
return typeof domain === "string" && domain.startsWith("xroom:");
|
|
274
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
|
+
}
|
|
275
461
|
// --- Tool: memory_write ---
|
|
276
462
|
server.registerTool("memory_write", {
|
|
277
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.",
|
|
@@ -288,7 +474,12 @@ server.registerTool("memory_write", {
|
|
|
288
474
|
domain: z
|
|
289
475
|
.string()
|
|
290
476
|
.optional()
|
|
291
|
-
.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."),
|
|
292
483
|
},
|
|
293
484
|
annotations: {
|
|
294
485
|
title: "Store Memory",
|
|
@@ -327,6 +518,29 @@ server.registerTool("memory_write", {
|
|
|
327
518
|
method: "POST",
|
|
328
519
|
body: JSON.stringify(writeRequestBody({ content, concepts, domain })),
|
|
329
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
|
+
}
|
|
330
544
|
// "unknown", not 0.00, when the server didn't send a score — the same rule
|
|
331
545
|
// memory_stats got in this release. A live surface exists that answers
|
|
332
546
|
// {"stored":false} with no reason and no score; printing "0.00" there
|
|
@@ -343,7 +557,9 @@ server.registerTool("memory_write", {
|
|
|
343
557
|
const reasonQuote = r?.reason
|
|
344
558
|
? (exactLiteral(r.reason, 400)?.literal ?? "(too long to quote exactly)")
|
|
345
559
|
: "";
|
|
346
|
-
|
|
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) {
|
|
347
563
|
return {
|
|
348
564
|
content: [
|
|
349
565
|
{
|
|
@@ -519,7 +735,7 @@ server.registerTool("memory_read", {
|
|
|
519
735
|
// rooms, one level up from the probes (truth F13, 2026-08-08).
|
|
520
736
|
const items = r?.items;
|
|
521
737
|
if (!Array.isArray(items)) {
|
|
522
|
-
return
|
|
738
|
+
return unreadableAnswerReply("The search result", "a list of matches", "nothing matched");
|
|
523
739
|
}
|
|
524
740
|
if (items.length === 0 && (since || until || exclude_author)) {
|
|
525
741
|
// A bounded/filtered read that finds nothing is NOT a bad query —
|
|
@@ -627,13 +843,72 @@ server.registerTool("memory_read", {
|
|
|
627
843
|
};
|
|
628
844
|
});
|
|
629
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.)";
|
|
630
905
|
server.registerTool("memory_list_recent", {
|
|
631
|
-
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.",
|
|
632
907
|
inputSchema: {
|
|
633
908
|
domain: z
|
|
634
909
|
.string()
|
|
635
910
|
.optional()
|
|
636
|
-
.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`)."),
|
|
637
912
|
since: z
|
|
638
913
|
.string()
|
|
639
914
|
.optional()
|
|
@@ -653,7 +928,7 @@ server.registerTool("memory_list_recent", {
|
|
|
653
928
|
.min(1)
|
|
654
929
|
.max(100)
|
|
655
930
|
.optional()
|
|
656
|
-
.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."),
|
|
657
932
|
cursor: z
|
|
658
933
|
.string()
|
|
659
934
|
.max(512)
|
|
@@ -671,57 +946,151 @@ server.registerTool("memory_list_recent", {
|
|
|
671
946
|
// ONE value for the request and for every decision about it — see the note
|
|
672
947
|
// at the top of memory_read.
|
|
673
948
|
const searched = searchedScope(domain);
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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.
|
|
686
1066
|
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
// listed in CHANGELOG's "Known and NOT fixed here" rather than papered
|
|
694
|
-
// over with a hedge.
|
|
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).
|
|
695
1073
|
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
text: "The memory service does not support the recent-entries feed yet. " +
|
|
711
|
-
"Use memory_read with order_by: 'recency' as an approximation.",
|
|
712
|
-
},
|
|
713
|
-
],
|
|
714
|
-
};
|
|
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;
|
|
715
1088
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
// not empty — and the feed's empty heads below are precisely the absence
|
|
720
|
-
// claims that must not be derived from it (truth F13, 2026-08-08).
|
|
721
|
-
const items = r?.items;
|
|
722
|
-
if (!Array.isArray(items)) {
|
|
723
|
-
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;
|
|
724
1092
|
}
|
|
1093
|
+
const items = accepted;
|
|
725
1094
|
if (items.length === 0) {
|
|
726
1095
|
// THE SENTENCE ITSELF carries the scope — and it is selected by EVERY
|
|
727
1096
|
// filter that narrowed the window, not by `since` alone.
|
|
@@ -789,14 +1158,45 @@ server.registerTool("memory_list_recent", {
|
|
|
789
1158
|
// 2026-08-08). Same order as memory_read's result page, same
|
|
790
1159
|
// automatic drop: a cap that removed every escaped name removes the
|
|
791
1160
|
// reason for the legend too.
|
|
792
|
-
|
|
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)),
|
|
793
1171
|
},
|
|
794
1172
|
],
|
|
795
1173
|
};
|
|
796
1174
|
});
|
|
797
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.";
|
|
798
1192
|
server.registerTool("memory_feedback", {
|
|
799
|
-
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.",
|
|
800
1200
|
inputSchema: {
|
|
801
1201
|
atom_ids: z
|
|
802
1202
|
.array(z.string())
|
|
@@ -824,7 +1224,67 @@ server.registerTool("memory_feedback", {
|
|
|
824
1224
|
method: "POST",
|
|
825
1225
|
body: JSON.stringify({ atom_ids, outcome }),
|
|
826
1226
|
});
|
|
827
|
-
|
|
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
|
+
}
|
|
828
1288
|
// "Feedback recorded for 0 memories." is one character away from the
|
|
829
1289
|
// success line and reads like one. But the DIAGNOSIS matters as much as
|
|
830
1290
|
// the fact: an earlier version of this branch blamed deletion, which is
|
|
@@ -841,32 +1301,16 @@ server.registerTool("memory_feedback", {
|
|
|
841
1301
|
content: [
|
|
842
1302
|
{
|
|
843
1303
|
type: "text",
|
|
844
|
-
text:
|
|
845
|
-
|
|
846
|
-
// we do not have (review, 2026-08-08); the causes are listed as
|
|
847
|
-
// possibilities, with the one the caller cannot otherwise guess
|
|
848
|
-
// first because it is invisible from the tool surface.
|
|
849
|
-
"No feedback was recorded — none of those ids matched a memory in your own " +
|
|
850
|
-
"domains. Possible causes: the ids came from a shared room (this tool takes " +
|
|
851
|
-
"no domain argument and cannot reach room atoms, so rating them is a no-op); " +
|
|
852
|
-
"the memory was deleted; or the id came from somewhere other than a " +
|
|
853
|
-
"memory_read result.",
|
|
1304
|
+
text: "No feedback was recorded — none of those ids matched a memory in your own " +
|
|
1305
|
+
`domains. ${FEEDBACK_MISS_CAUSES}`,
|
|
854
1306
|
},
|
|
855
1307
|
],
|
|
856
1308
|
};
|
|
857
1309
|
}
|
|
858
|
-
// Echo the DIRECTION, not just the count: the same four words for +1 and
|
|
859
|
-
// -1 gave a caller no evidence the loop did anything, which is why nobody
|
|
860
|
-
// calls it twice.
|
|
861
|
-
//
|
|
862
|
-
// The effect clause is per-direction. Promising "this shifts how they
|
|
863
|
-
// rank" for outcome 0 would be a claim we cannot make — dogfooding saw a
|
|
864
|
-
// neutral rating move a score UP by about five points, so neither "no
|
|
865
|
-
// change" nor "ranks higher" is safe to assert (CodeRabbit, #65).
|
|
866
1310
|
// WHOSE NUMBER THIS IS (#68). `updated_count` is the count of memories the
|
|
867
1311
|
// service says it touched — and that is true only while core runs
|
|
868
1312
|
// `feedback_async = False`. Under async it returns the number of ids
|
|
869
|
-
// SUBMITTED, not applied (core schemas.py:
|
|
1313
|
+
// SUBMITTED, not applied (core schemas.py:826-838, memory_engine.py:4898-4902),
|
|
870
1314
|
// and nothing in the response says which mode ran. A server-side config
|
|
871
1315
|
// flip would therefore turn a confident sentence here false on every
|
|
872
1316
|
// installed client, silently. So the number is reported as the SERVICE'S
|
|
@@ -875,27 +1319,50 @@ server.registerTool("memory_feedback", {
|
|
|
875
1319
|
// (mnemoverse-mcp-remote#38); one number, one degree of confidence,
|
|
876
1320
|
// whichever surface a model reaches it through.
|
|
877
1321
|
const noun = `${count} memor${count === 1 ? "y" : "ies"}`;
|
|
878
|
-
const
|
|
879
|
-
?
|
|
1322
|
+
const effect = outcome > 0
|
|
1323
|
+
? " — they should surface sooner next time."
|
|
880
1324
|
: outcome < 0
|
|
881
|
-
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
+
: "";
|
|
897
1355
|
return {
|
|
898
|
-
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
|
+
],
|
|
899
1366
|
};
|
|
900
1367
|
});
|
|
901
1368
|
// --- Tool: memory_stats ---
|
|
@@ -932,6 +1399,15 @@ server.registerTool("memory_stats", {
|
|
|
932
1399
|
// zero-width character are all visible, Cyrillic stays Cyrillic, and two
|
|
933
1400
|
// different names can no longer print as one. Assembly lives in
|
|
934
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.
|
|
935
1411
|
const domains = formatDomainList(r?.domains);
|
|
936
1412
|
const text = [
|
|
937
1413
|
`Memories: ${num(r?.total_atoms)} (${num(r?.episodes)} episodes, ${num(r?.prototypes)} prototypes)`,
|
|
@@ -958,7 +1434,18 @@ server.registerTool("memory_stats", {
|
|
|
958
1434
|
// arrives over the wire, and spreading a non-iterable object would
|
|
959
1435
|
// throw here — turning a malformed payload into a dead tool instead
|
|
960
1436
|
// of the "none reported" it degrades to two lines up.
|
|
961
|
-
|
|
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 : [])),
|
|
962
1449
|
},
|
|
963
1450
|
],
|
|
964
1451
|
};
|
|
@@ -1069,7 +1556,7 @@ server.registerTool("memory_invite_to_room", {
|
|
|
1069
1556
|
});
|
|
1070
1557
|
// --- Tool: memory_join_room ---
|
|
1071
1558
|
server.registerTool("memory_join_room", {
|
|
1072
|
-
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.",
|
|
1073
1560
|
inputSchema: {
|
|
1074
1561
|
code: z.string().min(1).max(200).describe("The invite code (mnvr_...)."),
|
|
1075
1562
|
},
|
|
@@ -1098,9 +1585,18 @@ server.registerTool("memory_join_room", {
|
|
|
1098
1585
|
? `You're already a member of ${roomName}.`
|
|
1099
1586
|
: `Joined ${roomName} (${scope}).`;
|
|
1100
1587
|
// Don't print broken `domain=""` guidance if no address came back (Copilot).
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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.`;
|
|
1104
1600
|
return {
|
|
1105
1601
|
content: [
|
|
1106
1602
|
{
|
|
@@ -1113,7 +1609,7 @@ server.registerTool("memory_join_room", {
|
|
|
1113
1609
|
});
|
|
1114
1610
|
// --- Tool: memory_list_rooms ---
|
|
1115
1611
|
server.registerTool("memory_list_rooms", {
|
|
1116
|
-
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.",
|
|
1117
1613
|
inputSchema: {},
|
|
1118
1614
|
annotations: {
|
|
1119
1615
|
title: "List rooms",
|
|
@@ -1134,7 +1630,7 @@ server.registerTool("memory_list_rooms", {
|
|
|
1134
1630
|
// Byte-identical to the inline wording this replaces — the builder is
|
|
1135
1631
|
// shared so the four list surfaces answer the unreadable case with one
|
|
1136
1632
|
// sentence, not four drifting ones.
|
|
1137
|
-
return
|
|
1633
|
+
return unreadableAnswerReply("The room list", "a list of your rooms", "you have none");
|
|
1138
1634
|
}
|
|
1139
1635
|
if (rooms.state === "none") {
|
|
1140
1636
|
return {
|
|
@@ -1167,13 +1663,24 @@ server.registerTool("memory_list_rooms", {
|
|
|
1167
1663
|
// src/scope.ts), so that clause was an instruction to make a call that
|
|
1168
1664
|
// cannot succeed. The address stays visible — it is the room's identity —
|
|
1169
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);
|
|
1170
1673
|
const tail = r?.archived
|
|
1171
1674
|
? address
|
|
1172
1675
|
? ` [archived] — address ${address}, but every read is refused while it is archived`
|
|
1173
1676
|
: ` [archived] — every read is refused while it is archived`
|
|
1174
|
-
: address
|
|
1175
|
-
?
|
|
1176
|
-
: ""
|
|
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`;
|
|
1177
1684
|
return `- ${name} (${role}${scope ? `, ${scope}` : ""})${tail}`;
|
|
1178
1685
|
});
|
|
1179
1686
|
const text = `Your shared rooms (${list.length}):\n${lines.join("\n")}`;
|
|
@@ -1209,7 +1716,7 @@ server.registerTool("vault_list", {
|
|
|
1209
1716
|
// 2026-08-08). A genuinely empty array keeps the absence claim below.
|
|
1210
1717
|
const list = r?.secrets;
|
|
1211
1718
|
if (!Array.isArray(list)) {
|
|
1212
|
-
return
|
|
1719
|
+
return unreadableAnswerReply("The secret list", "a list of your Vault secrets", "none are stored");
|
|
1213
1720
|
}
|
|
1214
1721
|
if (list.length === 0) {
|
|
1215
1722
|
return {
|
|
@@ -1295,10 +1802,33 @@ server.registerTool("vault_list", {
|
|
|
1295
1802
|
function probeApiKeyInBackground() {
|
|
1296
1803
|
if (!API_KEY)
|
|
1297
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
|
+
}
|
|
1298
1820
|
void (async () => {
|
|
1299
1821
|
try {
|
|
1300
1822
|
const res = await fetch(`${API_URL}/memory/stats`, {
|
|
1301
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",
|
|
1302
1832
|
signal: AbortSignal.timeout(10_000),
|
|
1303
1833
|
});
|
|
1304
1834
|
// Drain the body so the connection returns to the pool immediately —
|