@mnemoverse/mcp-memory-server 0.10.2 → 0.12.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 +81 -15
- package/dist/errors.d.ts +90 -7
- package/dist/errors.js +179 -45
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.js +13 -1474
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +68 -0
- package/dist/limits.js +69 -0
- package/dist/limits.js.map +1 -0
- package/dist/names.d.ts +76 -4
- package/dist/names.js +118 -8
- package/dist/names.js.map +1 -1
- package/dist/prompts.d.ts +25 -0
- package/dist/prompts.js +110 -0
- package/dist/prompts.js.map +1 -0
- package/dist/render.d.ts +114 -3
- package/dist/render.js +157 -9
- package/dist/render.js.map +1 -1
- package/dist/requests.d.ts +34 -3
- package/dist/requests.js +12 -3
- package/dist/requests.js.map +1 -1
- package/dist/resources.d.ts +28 -0
- package/dist/resources.js +97 -0
- package/dist/resources.js.map +1 -0
- package/dist/shared.d.ts +52 -0
- package/dist/shared.js +52 -0
- package/dist/shared.js.map +1 -0
- package/dist/time.d.ts +10 -21
- package/dist/time.js +19 -1
- package/dist/time.js.map +1 -1
- package/dist/tools.d.ts +134 -0
- package/dist/tools.js +2437 -0
- package/dist/tools.js.map +1 -0
- package/package.json +24 -5
package/dist/tools.js
ADDED
|
@@ -0,0 +1,2437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ten memory tools, as one function any MCP server can register.
|
|
3
|
+
*
|
|
4
|
+
* ADR-025 (mnemoverse-core): this package defines the MCP surface, and every
|
|
5
|
+
* server that exposes Mnemoverse memory over MCP registers the SAME tools from
|
|
6
|
+
* here instead of keeping its own copy. The stdio server in src/index.ts is one
|
|
7
|
+
* such server; the hosted connector (mnemoverse-mcp-remote) is the other.
|
|
8
|
+
*
|
|
9
|
+
* What differs between servers is only how a request reaches the API: which
|
|
10
|
+
* credential it carries, where it goes, and how a transport failure is worded.
|
|
11
|
+
* That is `MemoryToolDeps.apiFetch`. Nothing in this file reads configuration
|
|
12
|
+
* or the environment.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { CURSOR_RE, formatReadItem, formatRecentPage, rawAuthorName, safeInline, structuredItem, } from "./render.js";
|
|
16
|
+
// `NO_MATCH_MESSAGE` is no longer imported here: this file used to pick between
|
|
17
|
+
// it and buildReadEmptyResponse by testing a note string for truthiness, which
|
|
18
|
+
// is how "we could not check" came to be spelled like "the store is there".
|
|
19
|
+
// Assembling the whole answer belongs in one place — src/teaching.ts.
|
|
20
|
+
import { buildReadEmptyResponse } from "./teaching.js";
|
|
21
|
+
import { classifyRooms, futureSinceNote, probeReadScope, readScopeNote, scopeLabel, } from "./scope.js";
|
|
22
|
+
import { readRequestBody, recentRequestBody, writeRequestBody, searchedScope, } from "./requests.js";
|
|
23
|
+
import { exactLiteral, formatDomainList, MAX_DOMAIN_LITERAL, MAX_DOMAIN_TAG_LITERAL, roomNamePhrase, structuredText, withDomainEscapeLegend, withEscapeLegendAt, } from "./names.js";
|
|
24
|
+
import { ApiError, rewordFailure } from "./errors.js";
|
|
25
|
+
// Field limits, generated from core's contract (src/limits.ts, ADR-025).
|
|
26
|
+
import { CORE_LIMITS } from "./limits.js";
|
|
27
|
+
// For the invite's `expires_at` (S8, structured-output plan): the same
|
|
28
|
+
// UTC-instant reader src/render.ts uses for a memory's `created_at`.
|
|
29
|
+
import { utcInstant } from "./time.js";
|
|
30
|
+
/**
|
|
31
|
+
* `deps.apiFetch` with `deps.wording` applied to what it throws (STEP4-2):
|
|
32
|
+
* every rejection passes through {@link rewordFailure}, so an `ApiError`,
|
|
33
|
+
* `NetworkError` or `UnreadableBodyError` the consumer built with no
|
|
34
|
+
* `wording` (or with a different one) reaches the model explained under the
|
|
35
|
+
* wording this registration was given. Results and every other rejection
|
|
36
|
+
* pass through untouched. Installed only when `wording` is supplied at all:
|
|
37
|
+
* the stdio server supplies none, and its `apiFetch` is used exactly as
|
|
38
|
+
* before.
|
|
39
|
+
*/
|
|
40
|
+
export function wordedApiFetch(inner, wording) {
|
|
41
|
+
return async (path, options) => {
|
|
42
|
+
try {
|
|
43
|
+
return await inner(path, options);
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
throw rewordFailure(e, wording);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Hard cap on tool result size — required by Claude Connectors Directory
|
|
51
|
+
// (https://support.claude.com/en/articles/12922490-remote-mcp-server-submission-guide).
|
|
52
|
+
// Approximate token count = chars / 4. Cap at 24,000 tokens to leave headroom under the 25K limit.
|
|
53
|
+
//
|
|
54
|
+
// Exported (re-exported from ./shared, ADR-025) so a future editor does not
|
|
55
|
+
// move or rename it without checking there: it is now the one cap both this
|
|
56
|
+
// server and, from step 4 of the structured-output plan, the hosted connector
|
|
57
|
+
// apply to a tool result's text. `structuredContent` is NOT capped (OD-11,
|
|
58
|
+
// 2026-09-23); this bound is text-only.
|
|
59
|
+
export const MAX_RESULT_CHARS = 24_000 * 4;
|
|
60
|
+
/**
|
|
61
|
+
* Truncate a result string to MAX_RESULT_CHARS, appending a notice if truncated.
|
|
62
|
+
* Required by Claude Connectors Directory submission policy.
|
|
63
|
+
*
|
|
64
|
+
* Defensive against splitting UTF-16 surrogate pairs: if the character right
|
|
65
|
+
* before the cut point is a high surrogate (U+D800–U+DBFF), drop it so the
|
|
66
|
+
* result stays well-formed. Otherwise an emoji or non-BMP character at the
|
|
67
|
+
* boundary can produce a lone surrogate and corrupt downstream JSON encoding.
|
|
68
|
+
*
|
|
69
|
+
* Exported (re-exported from ./shared) so a consumer applying MAX_RESULT_CHARS
|
|
70
|
+
* to its own tool results does not have to re-implement this code-point-safe
|
|
71
|
+
* truncation as a plain `slice`, which can cut a surrogate pair.
|
|
72
|
+
*/
|
|
73
|
+
export function capResult(text,
|
|
74
|
+
// The default recommends ONLY the control that works. A previous draft also
|
|
75
|
+
// said "or smaller top_k" — but top_k is not a hard cap (association
|
|
76
|
+
// expansion can return more, the relevance floor fewer; the same query at
|
|
77
|
+
// 1/5/20 returned 6/7/4 items), which this release's own top_k description
|
|
78
|
+
// already admits. One surface must not recommend the knob another refutes.
|
|
79
|
+
moreHint = "Use a more specific query to see all results.") {
|
|
80
|
+
if (text.length <= MAX_RESULT_CHARS)
|
|
81
|
+
return text;
|
|
82
|
+
// `moreHint` lets no-input tools (the discovery lists) give accurate truncation
|
|
83
|
+
// guidance instead of the read-tool default (which points at a query control a
|
|
84
|
+
// repeated no-arg call cannot use). Existing callers keep the default message.
|
|
85
|
+
//
|
|
86
|
+
// The reserve for the notice is 200 characters, as it always was, so every
|
|
87
|
+
// existing truncated result keeps its exact cut; a hint longer than that
|
|
88
|
+
// reserve (a consumer of the /shared export may pass any sentence) widens
|
|
89
|
+
// the reserve to the suffix's own length instead of pushing the result over
|
|
90
|
+
// the cap, and a hint is bounded at MAX_HINT_CODE_POINTS so the suffix can
|
|
91
|
+
// never be the whole budget (review round 2 on the export, CodeRabbit).
|
|
92
|
+
const hint = [...moreHint].slice(0, MAX_HINT_CODE_POINTS).join("");
|
|
93
|
+
const suffix = `\n\n[…truncated to fit the 25K token limit. ${hint}]`;
|
|
94
|
+
const reserve = Math.max(200, suffix.length);
|
|
95
|
+
let truncated = text.slice(0, MAX_RESULT_CHARS - reserve);
|
|
96
|
+
const lastCode = truncated.charCodeAt(truncated.length - 1);
|
|
97
|
+
if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
|
|
98
|
+
truncated = truncated.slice(0, -1);
|
|
99
|
+
}
|
|
100
|
+
return `${truncated}${suffix}`;
|
|
101
|
+
}
|
|
102
|
+
/** The longest `moreHint` capResult will print; the rest is cut, so the suffix
|
|
103
|
+
* cannot take the whole result budget. 1,000 code points is ten times the
|
|
104
|
+
* longest hint this package passes. */
|
|
105
|
+
const MAX_HINT_CODE_POINTS = 1_000;
|
|
106
|
+
/**
|
|
107
|
+
* A 2xx whose body does not carry what core always sends for this operation.
|
|
108
|
+
*
|
|
109
|
+
* Reading such a body as an EMPTY list is the substitution this release exists
|
|
110
|
+
* to remove: `Array.isArray(x) ? x : []` turned "this client could not read
|
|
111
|
+
* the response" into "there is nothing there" — an absence claim on zero
|
|
112
|
+
* evidence. `classifyRooms` (src/scope.ts) and the stats domains guard closed
|
|
113
|
+
* it for rooms and domains; this builder is the same answer for the remaining
|
|
114
|
+
* list surfaces (search results, the feed, the vault), phrased the way the
|
|
115
|
+
* room list already phrases it. The three parts are the subject, what the body
|
|
116
|
+
* is NOT ("a list of your rooms"), and the absence it is NOT evidence of
|
|
117
|
+
* ("you have none") — so every consumer states its own boundary while the
|
|
118
|
+
* sentence stays one sentence everywhere (truth F13, 2026-08-08).
|
|
119
|
+
*
|
|
120
|
+
* NOT ONLY LISTS any more: memory_write was the last surface still reading
|
|
121
|
+
* a missing field as a stated one — `if (r?.stored)`, whose else-branch was an
|
|
122
|
+
* unconditional "NOT STORED — nothing was saved" plus a mechanism nobody sent.
|
|
123
|
+
* It is the same class of claim about a different shape, so it gets the same
|
|
124
|
+
* sentence, and the name no longer says "List".
|
|
125
|
+
*
|
|
126
|
+
* `extra` exists for the write surface alone: a list that could not be read
|
|
127
|
+
* leaves the caller merely uninformed, whereas a WRITE that could not be read
|
|
128
|
+
* leaves an operation whose outcome is unknown, and the caller must be told not
|
|
129
|
+
* to report either outcome to the user.
|
|
130
|
+
*
|
|
131
|
+
* Carries `isError: true`, not a text-only success. Once a tool declares an
|
|
132
|
+
* `outputSchema`, the SDK's `validateToolOutput` (see the comment on
|
|
133
|
+
* `structured()` below) rejects a non-error result that has no
|
|
134
|
+
* `structuredContent`, and there is no honest `structuredContent` for "this
|
|
135
|
+
* client could not read the body": an empty shape such as `{items: []}` would
|
|
136
|
+
* make exactly the absence claim this sentence exists to avoid. `isError` is
|
|
137
|
+
* the one shape the SDK exempts from that check, so it is the shape this
|
|
138
|
+
* reply must take (pinned against the installed SDK in
|
|
139
|
+
* test/structured-output.test.ts).
|
|
140
|
+
*/
|
|
141
|
+
function unreadableAnswerReply(subject, notA, absence, extra = "") {
|
|
142
|
+
return {
|
|
143
|
+
content: [
|
|
144
|
+
{
|
|
145
|
+
type: "text",
|
|
146
|
+
text: unreadableAnswerText(subject, notA, absence, extra),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
isError: true,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The sentence behind {@link unreadableAnswerReply}, exported so the memory
|
|
154
|
+
* resource (src/resources.ts) says the same thing about an unreadable answer
|
|
155
|
+
* rather than keeping its own copy.
|
|
156
|
+
*
|
|
157
|
+
* The tail attributes the unreadable 200 to "whatever answered this call", not
|
|
158
|
+
* to "the memory service" — this client cannot establish WHO answered: a
|
|
159
|
+
* gateway, a proxy, or the endpoint a mis-set MNEMOVERSE_API_URL points at
|
|
160
|
+
* produces the same 200 with an unrecognised body (truth re-verification,
|
|
161
|
+
* 2026-08-09). Pinned in test/handlers.test.ts.
|
|
162
|
+
*/
|
|
163
|
+
export function unreadableAnswerText(subject, notA, absence, extra = "") {
|
|
164
|
+
return (`${subject} came back in a shape this client does not recognise — so this ` +
|
|
165
|
+
`is not ${notA}, and it is not evidence that ${absence}.${extra} Retry; ` +
|
|
166
|
+
`if it persists, whatever answered this call — the memory service, a ` +
|
|
167
|
+
`gateway or proxy in front of it, or the endpoint a mis-set ` +
|
|
168
|
+
`MNEMOVERSE_API_URL points at — is answering in a shape this client ` +
|
|
169
|
+
`cannot read.`);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* A success result carrying both a text block and `structuredContent`, which
|
|
173
|
+
* is what a tool with a declared `outputSchema` must return, or the SDK
|
|
174
|
+
* itself rejects the call. `validateToolOutput` in the installed SDK
|
|
175
|
+
* (node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js:185-207)
|
|
176
|
+
* turns a non-error, text-only result into an `isError` "Output validation
|
|
177
|
+
* error: … has an output schema but no structured content was provided", the
|
|
178
|
+
* moment a tool declares that schema, even though the same text alone was a
|
|
179
|
+
* fine answer the day before the schema was added. Pinned against that
|
|
180
|
+
* installed SDK in test/structured-output.test.ts.
|
|
181
|
+
*
|
|
182
|
+
* Deliberately dumb: it does not call `capResult` or `withDomainEscapeLegend`
|
|
183
|
+
* itself. Every tool already measures and caps its own text with a per-tool
|
|
184
|
+
* hint and applies the legend after the cap (see memory_read below), so a
|
|
185
|
+
* helper that capped again would double-truncate; callers pass `text`
|
|
186
|
+
* already finished.
|
|
187
|
+
*/
|
|
188
|
+
export function structured(text, data) {
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: "text", text }],
|
|
191
|
+
structuredContent: data,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Two renderers, and which one a value gets is a decision, not a style choice.
|
|
196
|
+
*
|
|
197
|
+
* `safeInline` (src/render.ts) SANITISES an untrusted display string for inline
|
|
198
|
+
* rendering in tool output that a DIFFERENT principal's LLM will read (CN-032
|
|
199
|
+
* anti-injection): strip to a conservative charset, collapse whitespace, cap
|
|
200
|
+
* the length. The treatment `formatAuthorTag` applies to a server-stamped
|
|
201
|
+
* author, and the defensive second pass the machine-shaped room fields
|
|
202
|
+
* (address, room_id, role, scope — all charset-validated or enum-shaped in
|
|
203
|
+
* core) get here. It is lossy on purpose, and everything it still renders is a
|
|
204
|
+
* value the reader looks at and never has to retype or compare.
|
|
205
|
+
*
|
|
206
|
+
* `exactLiteral` / `domainPhrase` / `roomNamePhrase` (src/names.ts) print a
|
|
207
|
+
* value as a JSON string literal, or refuse to print it. Domain names because
|
|
208
|
+
* the engine matches them byte-for-byte, so the reader must be able to send
|
|
209
|
+
* the exact bytes back. Room NAMES (0.8.1) because the sanitiser did not make
|
|
210
|
+
* them harmless so much as it made them WRONG: "проект" echoed back as `""` on
|
|
211
|
+
* create, "Zoë" as "Zo" inside quotes that claim to be the name. The literal
|
|
212
|
+
* is one line with quotes, backslashes and invisibles escaped, so it is as
|
|
213
|
+
* injection-safe as the sanitised spelling was — without the renaming.
|
|
214
|
+
*/
|
|
215
|
+
/**
|
|
216
|
+
* True for a shared-room address (`xroom:<room_id>`).
|
|
217
|
+
*
|
|
218
|
+
* Mirrors core's own predicate (`memory_engine._is_room_domain`), which is a
|
|
219
|
+
* plain prefix test — deliberately, so a padded or otherwise non-canonical
|
|
220
|
+
* address does NOT normalise into a room. Byte-for-byte, no trimming: this
|
|
221
|
+
* file's write handler documents at length why trimming a domain here once
|
|
222
|
+
* nearly wrote into a room visible to other accounts.
|
|
223
|
+
*
|
|
224
|
+
* Used only to choose which TRUE sentence to print about a refusal. It never
|
|
225
|
+
* changes what is sent, so a wrong answer here misinforms and cannot misroute.
|
|
226
|
+
*/
|
|
227
|
+
function isRoomDomain(domain) {
|
|
228
|
+
return typeof domain === "string" && domain.startsWith("xroom:");
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The three outcomes this client will ever print a WRITE promise about for a
|
|
232
|
+
* room membership. Core grants exactly two scopes — "read" and "read_write"
|
|
233
|
+
* (rooms_routes.py `_VALID_SCOPES`) — and refuses a read-only member's
|
|
234
|
+
* memory_write with a 403 "Read-only membership cannot write to this room"
|
|
235
|
+
* (src/errors.ts). Anything else on the wire (missing field, a Copilot-shaped
|
|
236
|
+
* partial body) is UNSPECIFIED: the server did not say what memory_write would
|
|
237
|
+
* do for this membership, so this client does not guess either — it is treated
|
|
238
|
+
* like "read" for the purpose of NOT promising write, but is not told it is
|
|
239
|
+
* read-only, because that is also a claim the response did not make.
|
|
240
|
+
*
|
|
241
|
+
* Bug hunt (pre-0.9.2, P2): memory_join_room's usage sentence and
|
|
242
|
+
* memory_list_rooms's per-row tail both used to print "use domain=... [on
|
|
243
|
+
* memory_write / memory_read] to read and write" unconditionally — true for a
|
|
244
|
+
* read_write membership, false for a read-only one.
|
|
245
|
+
*/
|
|
246
|
+
function roomScopeVerdict(scope) {
|
|
247
|
+
if (scope === "read_write")
|
|
248
|
+
return "read_write";
|
|
249
|
+
if (scope === "read")
|
|
250
|
+
return "read";
|
|
251
|
+
return "unspecified";
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* How long a HONESTY PROBE may take before the answer goes out without it (#73).
|
|
255
|
+
*
|
|
256
|
+
* The probes are a courtesy: they let a zero-result answer say what it did NOT
|
|
257
|
+
* cover. They are not the answer. Without a deadline they inherit undici's
|
|
258
|
+
* default (~300s), so one slow engine endpoint turns an empty read — which
|
|
259
|
+
* 0.8.0 answered instantly, having probed nothing — into a multi-minute stall.
|
|
260
|
+
* `probeReadScope` already treats a failed probe as "unknown" and says so, so
|
|
261
|
+
* an abort degrades into the honest fallback rather than an error.
|
|
262
|
+
*
|
|
263
|
+
* 4s is chosen against measured production latency: `/memory/read` averaged
|
|
264
|
+
* 1,330 ms with a 10.2 s maximum (core observability, 2026-08), and these two
|
|
265
|
+
* are cheaper GETs. Long enough not to fire on a normal slow day, short enough
|
|
266
|
+
* that a hung endpoint costs seconds instead of minutes.
|
|
267
|
+
*/
|
|
268
|
+
const PROBE_TIMEOUT_MS = 4000;
|
|
269
|
+
/**
|
|
270
|
+
* The memory-item shape shared by memory_read's `outputSchema` and
|
|
271
|
+
* memory_list_recent's (S5, structured-output plan): copied from the
|
|
272
|
+
* connector's `memoryItemOutput` (mnemoverse-mcp-remote, src/tools/index.ts),
|
|
273
|
+
* field for field and description for description, with the SAME ONE
|
|
274
|
+
* deliberate difference memory_write's outputSchema already carries:
|
|
275
|
+
* `memory_id` here is `z.string()`, not `z.guid()` (decision OD-7, owner,
|
|
276
|
+
* 2026-09-22). This package's ids are opaque strings, and nothing in the
|
|
277
|
+
* contract promises they are UUIDs; a guid validator would turn any future
|
|
278
|
+
* id-format change into a whole-page "Output validation error" instead of a
|
|
279
|
+
* value this client simply could not shape-check further.
|
|
280
|
+
*
|
|
281
|
+
* A raw shape, not a `z.object(...)`, so both tools can build their own
|
|
282
|
+
* object around it (`z.object(MEMORY_ITEM_OUTPUT)`) without importing a
|
|
283
|
+
* schema instance neither owns, so memory_read's emitted JSON schema
|
|
284
|
+
* stays byte-identical to what it was before this constant existed
|
|
285
|
+
* (test/read-structured.test.ts pins it unchanged).
|
|
286
|
+
*/
|
|
287
|
+
const MEMORY_ITEM_OUTPUT = {
|
|
288
|
+
memory_id: z.string().describe("Identifier needed to rate or manage this saved memory."),
|
|
289
|
+
content: z.string().describe("Stored memory content."),
|
|
290
|
+
domain: z.string().describe("User-defined memory namespace or domain."),
|
|
291
|
+
created_at: z
|
|
292
|
+
.string()
|
|
293
|
+
.optional()
|
|
294
|
+
.describe("UTC creation instant, ISO-8601; absent on legacy memories without a timestamp."),
|
|
295
|
+
author: z
|
|
296
|
+
.string()
|
|
297
|
+
.optional()
|
|
298
|
+
.describe("Sanitized AGENT identity of the writer (never the human principal) — attribution in shared rooms."),
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* Register the ten memory tools on `server`. Call once per server instance.
|
|
302
|
+
* `deps.apiFetch` is the only way the tools reach the API.
|
|
303
|
+
*/
|
|
304
|
+
export function registerMemoryTools(server, deps) {
|
|
305
|
+
const { wording, writeAuthor } = deps;
|
|
306
|
+
// STEP4-2: `wording` reaches the error text here, on every rejection, not
|
|
307
|
+
// in the consumer's constructors. See wordedApiFetch.
|
|
308
|
+
const apiFetch = wording === undefined ? deps.apiFetch : wordedApiFetch(deps.apiFetch, wording);
|
|
309
|
+
// Read once, defensively: `wording` crosses a public package boundary a
|
|
310
|
+
// caller controls only at compile time (STEP4-2, owner 2026-09-24). A
|
|
311
|
+
// strict-equality check rather than a truthiness check, so any value other
|
|
312
|
+
// than the one literal "this connector" (including a typo, a boolean, or
|
|
313
|
+
// a stale value from a future third option) falls back to the default
|
|
314
|
+
// rather than being printed. Defaults to today's wording exactly, so a
|
|
315
|
+
// server that supplies no `wording` at all gets byte-identical descriptions.
|
|
316
|
+
const serverNoun = wording?.serverNoun === "this connector" ? "this connector" : "this server";
|
|
317
|
+
// Same value, sentence-initial capitalisation, for the one description that
|
|
318
|
+
// opens a second sentence with it rather than sitting mid-clause.
|
|
319
|
+
const serverNounCap = serverNoun === "this connector" ? "This connector" : "This server";
|
|
320
|
+
// ANNOTATIONS, decided once for every server that registers these tools
|
|
321
|
+
// (owner, 2026-09-21; the stdio server and the hosted connector had answered
|
|
322
|
+
// both opposite ways):
|
|
323
|
+
// - openWorldHint is false on all ten. Every tool works on the user's own
|
|
324
|
+
// memory store and reaches nothing else; that the store sits behind an API
|
|
325
|
+
// does not make it an open world.
|
|
326
|
+
// - destructiveHint is true only for a tool that deletes. None of these ten
|
|
327
|
+
// does. Rating a memory moves its ranking signals and never alters or
|
|
328
|
+
// erases what was saved (see memory_feedback).
|
|
329
|
+
/**
|
|
330
|
+
* What we know about the scope this read actually covered — a VALUE rather
|
|
331
|
+
* than a sentence-or-empty-string. Costs one GET (rooms or stats, chosen by
|
|
332
|
+
* the scope) and runs on zero-result paths only — but it is not that path's
|
|
333
|
+
* only probe: the plain unscoped empty read also hands buildReadEmptyResponse
|
|
334
|
+
* a stats call for the first-contact greeting, so that answer makes two
|
|
335
|
+
* probes where 0.8.0 made one, and the scoped/filtered answers make one where
|
|
336
|
+
* 0.8.0 made none. Disclosed in the 0.8.1 CHANGELOG entry.
|
|
337
|
+
*
|
|
338
|
+
* This replaces `domainMissNote`, whose `catch { return ""; }` made three
|
|
339
|
+
* different states share one spelling: the store is there and the query missed,
|
|
340
|
+
* the room is there and the query missed, and the probe failed. The caller then
|
|
341
|
+
* read that string as a boolean, so "we could not check" was rendered exactly
|
|
342
|
+
* like "the store exists" — the collision this release is about, at the point
|
|
343
|
+
* where the whole scoped path converges. The states are now arms of a union
|
|
344
|
+
* (src/scope.ts) and every consumer must answer for each of them.
|
|
345
|
+
*
|
|
346
|
+
* The probe routing lives in src/scope.ts: a room address is checked against the
|
|
347
|
+
* ROOM list and a domain against `/memory/stats`, because stats never contains a
|
|
348
|
+
* room address (CodeRabbit #65). `searched` is the RAW value that went over the
|
|
349
|
+
* wire, so the diagnosis can never describe a different store than the request.
|
|
350
|
+
*/
|
|
351
|
+
function probeScope(searched) {
|
|
352
|
+
return probeReadScope(searched, () => apiFetch("/memory/stats", { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }), () => apiFetch("/memory/rooms", { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }), safeInline);
|
|
353
|
+
}
|
|
354
|
+
// --- Tool: memory_write ---
|
|
355
|
+
server.registerTool("memory_write", {
|
|
356
|
+
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.",
|
|
357
|
+
inputSchema: {
|
|
358
|
+
content: z
|
|
359
|
+
.string()
|
|
360
|
+
.min(CORE_LIMITS.writeContent.minLength)
|
|
361
|
+
.max(CORE_LIMITS.writeContent.maxLength)
|
|
362
|
+
.describe("The memory to store as a self-contained statement, e.g. 'User prefers TypeScript strict mode' or 'Decided to deploy the API on Cloudflare Workers (2026-06)'."),
|
|
363
|
+
concepts: z
|
|
364
|
+
.array(z.string())
|
|
365
|
+
.max(CORE_LIMITS.writeConcepts.maxItems)
|
|
366
|
+
.optional()
|
|
367
|
+
.describe("Key concepts for linking related memories (e.g. ['deploy', 'friday', 'staging'])"),
|
|
368
|
+
// The only domain the contract bounds is this one: core's write
|
|
369
|
+
// schema caps it at 100 characters, and a longer one is refused there.
|
|
370
|
+
// Read, list_recent and feedback have no limit in the contract, so
|
|
371
|
+
// none is invented for them here (src/limits.ts, ADR-025).
|
|
372
|
+
domain: z
|
|
373
|
+
.string()
|
|
374
|
+
.max(CORE_LIMITS.domain.maxLength)
|
|
375
|
+
.optional()
|
|
376
|
+
.describe("Namespace to organize memories (e.g. 'engineering', 'user:alice', 'project:acme')." +
|
|
377
|
+
" Matched byte-for-byte — a leading space, a different case, or an invisible" +
|
|
378
|
+
" character opens a SEPARATE, permanent store, so reuse an exact name from" +
|
|
379
|
+
" memory_stats rather than retyping one. To write into a shared room, pass its" +
|
|
380
|
+
" address here instead (e.g. 'xroom:room_01ABC'). Find room addresses with" +
|
|
381
|
+
" memory_list_rooms."),
|
|
382
|
+
},
|
|
383
|
+
// Copied from the connector's `memoryWriteOutput` (mnemoverse-mcp-remote,
|
|
384
|
+
// src/tools/index.ts), field for field and description for description,
|
|
385
|
+
// except that `reason`'s description also states the 400-character cap and
|
|
386
|
+
// the normalised-to-empty case (the connector's text promises exact
|
|
387
|
+
// preservation while capping the same way; review, 2026-09-23), and with ONE
|
|
388
|
+
// deliberate difference in a validator: `memory_id` here is `z.string()`, not
|
|
389
|
+
// `z.guid()` (decision OD-7, owner, 2026-09-22). This package's ids are
|
|
390
|
+
// opaque strings, and nothing in the contract promises they are UUIDs;
|
|
391
|
+
// a guid validator would turn any future id-format change into a
|
|
392
|
+
// whole-page "Output validation error" instead of a value this client
|
|
393
|
+
// simply could not shape-check further.
|
|
394
|
+
outputSchema: {
|
|
395
|
+
stored: z
|
|
396
|
+
.boolean()
|
|
397
|
+
.describe("Whether the memory passed the novelty gate and was stored."),
|
|
398
|
+
memory_id: z
|
|
399
|
+
.string()
|
|
400
|
+
.nullable()
|
|
401
|
+
.describe("Identifier of the stored memory, or null when it was not stored."),
|
|
402
|
+
reason: z
|
|
403
|
+
.string()
|
|
404
|
+
.optional()
|
|
405
|
+
.describe("The memory service's own explanation of this outcome, quoted as sent — when stored is false this is the ONLY statement of WHY, e.g. \"Below importance threshold (0.047 < 0.1)\". Ordinary text is preserved exactly; only control, bidi, zero-width, and repeated-whitespace characters are normalized before display, and the value is capped at 400 characters. Absent when the service sent no explanation, or when nothing remains after that normalization."),
|
|
406
|
+
importance: z
|
|
407
|
+
.number()
|
|
408
|
+
.optional()
|
|
409
|
+
.describe("Novelty score for this write (0-1): how much it adds over the nearest memories already saved in the same domain. A first-generation metric UNDER ACTIVE DEVELOPMENT and known to be unreliable — the same content has measured ~0.08 in Russian against ~0.55 in English, so it under-reads non-English text. It is not a verdict on whether the memory was worth keeping. Absent when the service sent no score."),
|
|
410
|
+
},
|
|
411
|
+
annotations: {
|
|
412
|
+
title: "Store Memory",
|
|
413
|
+
readOnlyHint: false,
|
|
414
|
+
destructiveHint: false,
|
|
415
|
+
idempotentHint: false,
|
|
416
|
+
openWorldHint: false,
|
|
417
|
+
},
|
|
418
|
+
}, async ({ content, concepts, domain }) => {
|
|
419
|
+
// NO NORMALISATION HERE — deliberately, after a review found two ways it
|
|
420
|
+
// breaks (2026-08-08). Trimming looked like an obvious win: domain names
|
|
421
|
+
// are matched byte-for-byte in core, so " engineering" opens a permanent
|
|
422
|
+
// second store beside "engineering". But:
|
|
423
|
+
//
|
|
424
|
+
// 1. Core REJECTS a non-canonical room address on purpose — 400
|
|
425
|
+
// "Non-canonical room address", so a write "can't be mis-routed and
|
|
426
|
+
// tagged with a spoofed xroom domain" in its own words. Trimming
|
|
427
|
+
// " xroom:room_01ABC" normalises past that guard, and the atom lands
|
|
428
|
+
// in the ROOM's store, visible to every member. Content that never
|
|
429
|
+
// left the caller in 0.8.0 would leave the account in 0.8.1. The
|
|
430
|
+
// address in that shape is one our own output hands the model.
|
|
431
|
+
// 2. For a caller who has been padding a domain for months, trimming
|
|
432
|
+
// silently relocates new writes and orphans the old corpus, and (at
|
|
433
|
+
// the time this was written) memory_delete_domain did NOT trim, so
|
|
434
|
+
// the same client could no longer even name the shard it created —
|
|
435
|
+
// that tool was withdrawn to an administrative REST-only operation
|
|
436
|
+
// on 2026-08-20, so this specific consequence no longer applies, but
|
|
437
|
+
// the orphaned-corpus risk from silently relocating writes does.
|
|
438
|
+
//
|
|
439
|
+
// Both are behaviour changes, so they do not belong in a patch whose
|
|
440
|
+
// whole claim is that it only changes wording. Normalisation, if it ever
|
|
441
|
+
// returns, needs room addresses deliberately EXEMPT and zero-width
|
|
442
|
+
// characters handled (JS trim() does not strip them — verified, contrary
|
|
443
|
+
// to what an earlier comment here asserted).
|
|
444
|
+
//
|
|
445
|
+
// `writeAuthor` (STEP4-5): called once, here, only for this one write,
|
|
446
|
+
// never for a read or a probe. `typeof` first, not a shape check: this
|
|
447
|
+
// dependency crosses a public package boundary a caller controls only
|
|
448
|
+
// at compile time, so a runtime value that is not an object (a string,
|
|
449
|
+
// a number, `null`) is treated the same as "no author", rather than
|
|
450
|
+
// reaching JSON.stringify as a field core would then have to reject.
|
|
451
|
+
// Whatever IS an object is sent exactly as returned: no field-level
|
|
452
|
+
// normalisation here, because core re-normalises server-side (see
|
|
453
|
+
// {@link WriteAuthor}), and this package cannot know the sixth field a
|
|
454
|
+
// future core release adds any better than core's own validator does.
|
|
455
|
+
const authorRaw = writeAuthor?.();
|
|
456
|
+
const author = typeof authorRaw === "object" && authorRaw !== null ? authorRaw : undefined;
|
|
457
|
+
const r = await apiFetch("/memory/write", {
|
|
458
|
+
method: "POST",
|
|
459
|
+
body: JSON.stringify(writeRequestBody({ content, concepts, domain }, author)),
|
|
460
|
+
});
|
|
461
|
+
// `stored` MUST BE A BOOLEAN before either verdict below may be printed.
|
|
462
|
+
//
|
|
463
|
+
// This was the last surface reading a MISSING field as a field that said
|
|
464
|
+
// false. `if (r?.stored)` sent every body that did not say `true` to the
|
|
465
|
+
// else-branch, whose first four words are "NOT STORED — nothing was saved"
|
|
466
|
+
// and whose last sentence explains WHY: "Writes are gated on how much a
|
|
467
|
+
// memory adds… so a near-duplicate is refused." So a 204, an empty `{}`, a
|
|
468
|
+
// proxy or gateway answering `{"ok":true}`, and a mis-set
|
|
469
|
+
// MNEMOVERSE_API_URL all produced an absence claim about the user's memory
|
|
470
|
+
// AND a fabricated mechanism for it — two statements, neither with any
|
|
471
|
+
// evidence behind it. The write is also the surface where being wrong costs
|
|
472
|
+
// most: a caller told "nothing was saved" re-words and retries, or drops
|
|
473
|
+
// the fact, and the atom that may in fact be sitting in the store is not
|
|
474
|
+
// what the user is told about.
|
|
475
|
+
//
|
|
476
|
+
// The four LIST surfaces have had this guard since 0.8.1 (truth F13); the
|
|
477
|
+
// write did not. The test for `boolean` and not for presence is deliberate:
|
|
478
|
+
// `{"stored":"yes"}` is not core speaking either.
|
|
479
|
+
if (typeof r?.stored !== "boolean") {
|
|
480
|
+
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" +
|
|
481
|
+
" outcome of the RETRY, not of this call, and do not tell the user it" +
|
|
482
|
+
" was saved or that it was rejected.");
|
|
483
|
+
}
|
|
484
|
+
// "unknown", not 0.00, when the server didn't send a score — the same rule
|
|
485
|
+
// memory_stats got in this release. A live surface exists that answers
|
|
486
|
+
// {"stored":false} with no reason and no score; printing "0.00" there
|
|
487
|
+
// fabricates the gate's verdict (review, 2026-08-08).
|
|
488
|
+
// A non-finite number is no score either: JSON can carry `1e400`, which
|
|
489
|
+
// parses as Infinity, passes a `typeof` check, prints as "Infinity" and
|
|
490
|
+
// fails the output schema (zod rejects non-finite numbers), so the whole
|
|
491
|
+
// reply would turn into an SDK validation error (review, 2026-09-23).
|
|
492
|
+
const importance = typeof r?.importance === "number" && Number.isFinite(r.importance)
|
|
493
|
+
? r.importance.toFixed(2)
|
|
494
|
+
: "unknown";
|
|
495
|
+
// `Server reason:` is a VERBATIM label, and safeInline made it a lie on
|
|
496
|
+
// every occurrence: core's only rejection reason is
|
|
497
|
+
// "Below importance threshold (0.412 < 0.500)", whose parentheses and `<`
|
|
498
|
+
// are outside the sanitiser's charset — so the relay read "Below importance
|
|
499
|
+
// threshold 0.412 0.500", with the comparison operator and both delimiters
|
|
500
|
+
// deleted and the two numbers left unlabelled and order-only. Quoted
|
|
501
|
+
// exactly instead (src/names.ts). If it will not fit, say THAT rather than
|
|
502
|
+
// drop the clause: omitting it would report a server that gave no reason.
|
|
503
|
+
const reasonQuote = r?.reason
|
|
504
|
+
? (exactLiteral(r.reason, 400)?.literal ?? "(too long to quote exactly)")
|
|
505
|
+
: "";
|
|
506
|
+
// Structured twins of the two text-only values above, for
|
|
507
|
+
// `structuredContent`: the raw number instead of the two-decimal
|
|
508
|
+
// string, and the control/bidi/zero-width-normalised text (src/names.ts
|
|
509
|
+
// `structuredText`) instead of the quoted JSON literal `reasonQuote`
|
|
510
|
+
// uses, since a `structuredContent` consumer reads `reason` as a plain
|
|
511
|
+
// string field, not a literal it must decode. Computed once and used on
|
|
512
|
+
// BOTH verdict branches below, since core can send either on a stored
|
|
513
|
+
// write too. Core's `superseded` array (the ids this write replaced) is
|
|
514
|
+
// deliberately not carried in this slice.
|
|
515
|
+
const reasonStructured = structuredText(r.reason, 400);
|
|
516
|
+
const importanceStructured = typeof r.importance === "number" && Number.isFinite(r.importance)
|
|
517
|
+
? r.importance
|
|
518
|
+
: undefined;
|
|
519
|
+
const optionalStructured = {
|
|
520
|
+
...(reasonStructured === undefined ? {} : { reason: reasonStructured }),
|
|
521
|
+
...(importanceStructured === undefined ? {} : { importance: importanceStructured }),
|
|
522
|
+
};
|
|
523
|
+
// Narrowed to `true` by the guard above, so this is now the server's stated
|
|
524
|
+
// verdict rather than "the body was not falsy".
|
|
525
|
+
if (r.stored) {
|
|
526
|
+
// core's WriteResponseSchema carries `atom_id` on every stored write,
|
|
527
|
+
// so a `stored: true` body without one is not core's answer: the
|
|
528
|
+
// same class of unreadable 2xx the guard above catches for a missing
|
|
529
|
+
// `stored`, just discovered one field later. `structuredContent`
|
|
530
|
+
// needs `memory_id` to be a string (the declared outputSchema), and
|
|
531
|
+
// there is no honest value to put there for a write whose own result
|
|
532
|
+
// does not say what was stored.
|
|
533
|
+
if (typeof r.atom_id !== "string") {
|
|
534
|
+
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" +
|
|
535
|
+
" outcome of the RETRY, not of this call, and do not tell the user it" +
|
|
536
|
+
" was saved or that it was rejected.");
|
|
537
|
+
}
|
|
538
|
+
return structured(`Stored (importance: ${importance}). ID: ${r.atom_id}`, {
|
|
539
|
+
stored: true,
|
|
540
|
+
memory_id: r.atom_id,
|
|
541
|
+
...optionalStructured,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
// NOT STORED. The old wording ("Filtered — …") named the mechanism but
|
|
545
|
+
// never the outcome, so a caller could read it as a soft success and move
|
|
546
|
+
// on. In dogfooding this ate a CORRECTION to a wrong fact: the stale
|
|
547
|
+
// version stayed as the only record, and looked more authoritative for
|
|
548
|
+
// having no competitor (2026-08-07).
|
|
549
|
+
//
|
|
550
|
+
// The ADVICE was wrong until 2026-08-08, and wrong in a way that made
|
|
551
|
+
// things worse. It told the caller to rewrite the content as a cleaner
|
|
552
|
+
// factual statement — but the gate scores GEOMETRIC NOVELTY against the
|
|
553
|
+
// nearest existing atom in the same domain, not phrasing or factuality.
|
|
554
|
+
// "Below importance threshold" means TOO SIMILAR TO SOMETHING ALREADY
|
|
555
|
+
// STORED. A rewrite of the same fact therefore produces a near-identical
|
|
556
|
+
// embedding, scores the same or lower, and is rejected again — and the old
|
|
557
|
+
// text ended with "write it again", so a compliant agent looped. It was
|
|
558
|
+
// anti-correlated with the mechanism in exactly the case it was written
|
|
559
|
+
// for: a correction, which is by nature similar to what it corrects.
|
|
560
|
+
// WHAT IS CONDITIONAL HERE, stated exactly, because a previous
|
|
561
|
+
// version of this comment claimed more than the code does.
|
|
562
|
+
//
|
|
563
|
+
// Conditional: the SERVER'S VERDICT and the SCORE. `Server reason:`
|
|
564
|
+
// is printed only when `reason` came back, and `Novelty score` only
|
|
565
|
+
// when a numeric `importance` did — a live surface answers
|
|
566
|
+
// `{"stored":false}` with neither, and quoting a verdict nobody sent
|
|
567
|
+
// would be a claim on zero evidence.
|
|
568
|
+
//
|
|
569
|
+
// Unconditional: the MECHANISM sentence below, and it is not derived
|
|
570
|
+
// from this response. It is a statement about core: `/memory/write`
|
|
571
|
+
// refuses for exactly one reason — the importance gate, scoring
|
|
572
|
+
// geometric novelty against the nearest existing atom in the same
|
|
573
|
+
// domain, with "Below importance threshold (x < y)" as its only text
|
|
574
|
+
// (two branches in memory_engine, one reason). The BATCH endpoint has
|
|
575
|
+
// other failure paths; this client does not call it. So for any
|
|
576
|
+
// rejection this client can receive, that sentence is true whether or
|
|
577
|
+
// not the server bothered to say why. The earlier comment here read
|
|
578
|
+
// "the cause is named only when the server named it", which describes
|
|
579
|
+
// a draft that did not carry this sentence at all.
|
|
580
|
+
//
|
|
581
|
+
// NOT PRESENT AT ALL: a prediction about the retry. "Rewording will
|
|
582
|
+
// score the same or lower" was asserted as fact and is probably
|
|
583
|
+
// BACKWARDS — novelty decreases with similarity to the blocking
|
|
584
|
+
// memory, so a reworded sentence is usually LESS similar and scores
|
|
585
|
+
// HIGHER. And delete-then-write advice an earlier draft carried was
|
|
586
|
+
// impossible for a room write even when memory_delete still existed:
|
|
587
|
+
// the blocker is a room atom, which that tool could not touch either
|
|
588
|
+
// (reviews, 2026-08-08). Deletion is administrative-only now
|
|
589
|
+
// (2026-08-20), so this message never suggests it at all.
|
|
590
|
+
return structured(`NOT STORED — nothing was saved.` +
|
|
591
|
+
(reasonQuote ? ` Server reason: ${reasonQuote}.` : ``) +
|
|
592
|
+
(importance === "unknown"
|
|
593
|
+
? ``
|
|
594
|
+
: ` Novelty score ${importance}. That score is a first-generation` +
|
|
595
|
+
` metric under active development and known to be unreliable —` +
|
|
596
|
+
` identical content has measured ~0.08 in Russian against ~0.55 in` +
|
|
597
|
+
` English — so read it as a rough hint about similarity, not as a` +
|
|
598
|
+
` judgement of whether this memory was worth keeping.`) +
|
|
599
|
+
(isRoomDomain(domain)
|
|
600
|
+
? // ROOM RULE (core#482, 2026-08-13). A room is a message bus:
|
|
601
|
+
// the second agent's job is to receive a restatement of what
|
|
602
|
+
// the first was told, so a briefing or a status summary STORES
|
|
603
|
+
// here. Only a write the embedder cannot distinguish from one
|
|
604
|
+
// already present is refused. Telling a caller to "write the
|
|
605
|
+
// delta" in a room would be advice against the room's purpose.
|
|
606
|
+
` Restatements are allowed in rooms — this one was refused only` +
|
|
607
|
+
` because it is indistinguishable by embedding from a message` +
|
|
608
|
+
` already there. Similarity is judged on roughly the first 500` +
|
|
609
|
+
` tokens, so a long message that OPENS like an earlier one can` +
|
|
610
|
+
` land here even when its body differs: lead with what is new.`
|
|
611
|
+
: ` Writes are gated on how much a memory adds over what is already in the` +
|
|
612
|
+
` same domain, so a near-duplicate is refused. If the point is genuinely` +
|
|
613
|
+
` new, write what is DIFFERENT rather than restating the whole fact.`),
|
|
614
|
+
// `memory_id` is a literal null on a refusal, per the field's own
|
|
615
|
+
// description ("null when it was not stored"); the connector's refusal
|
|
616
|
+
// fixtures carry `atom_id: null` as well. The connector forwards
|
|
617
|
+
// `res.atom_id` on both verdicts; this package does not forward an id
|
|
618
|
+
// for a write that was not stored, since a refusal carrying one would
|
|
619
|
+
// be a body this client cannot vouch for.
|
|
620
|
+
{ stored: false, memory_id: null, ...optionalStructured });
|
|
621
|
+
});
|
|
622
|
+
// --- Tool: memory_read ---
|
|
623
|
+
server.registerTool("memory_read", {
|
|
624
|
+
description: "Search your long-term memory before answering anything that may have come up before — user preferences, past decisions, project setup, people, or earlier context. This memory is shared: it persists across sessions and across every AI tool the user has connected (Claude, ChatGPT, Cursor, VS Code). ALWAYS check here first when you're unsure whether you already know something; no need to call it for general world knowledge you already hold. Returns matches ranked by relevance (or newest-first with order_by: 'recency'); each result carries an id you can pass to memory_feedback. A wrong or stale memory is corrected by writing a fresh one with memory_write, not by deleting it.",
|
|
625
|
+
inputSchema: {
|
|
626
|
+
query: z
|
|
627
|
+
.string()
|
|
628
|
+
.min(CORE_LIMITS.readQuery.minLength)
|
|
629
|
+
.max(CORE_LIMITS.readQuery.maxLength)
|
|
630
|
+
.describe("Natural-language description of what you're looking for, e.g. 'database choice for the API' or 'user's preferred testing framework'."),
|
|
631
|
+
top_k: z
|
|
632
|
+
.number()
|
|
633
|
+
.int()
|
|
634
|
+
.min(CORE_LIMITS.readTopK.minimum)
|
|
635
|
+
.max(CORE_LIMITS.readTopK.maximum)
|
|
636
|
+
.optional()
|
|
637
|
+
.describe(`Requested number of results (default: 5, what ${serverNoun} asks for when you omit it; the engine's own default of 10 never applies, because the field is always sent). ⚠️ Not a hard cap: association expansion can return MORE than this, and the relevance floor can return fewer — raising it does not reliably widen the result set. For a complete, exactly-bounded listing use memory_list_recent instead.`),
|
|
638
|
+
domain: z
|
|
639
|
+
.string()
|
|
640
|
+
.optional()
|
|
641
|
+
.describe("Restrict the search to one domain namespace (e.g. 'project:acme'). Omitting it searches your OWN domains — it does NOT include shared rooms, which are separate stores: to search a room, pass its address here (e.g. 'xroom:room_01ABC'). Find room addresses with memory_list_rooms."),
|
|
642
|
+
order_by: z
|
|
643
|
+
.enum(["relevance", "recency"])
|
|
644
|
+
.optional()
|
|
645
|
+
.describe("'relevance' (default) = ranking order. 'recency' = the matched " +
|
|
646
|
+
"set re-sorted newest-first. For a complete newest-first feed " +
|
|
647
|
+
"with no search at all, use memory_list_recent instead."),
|
|
648
|
+
since: z
|
|
649
|
+
.string()
|
|
650
|
+
.max(40)
|
|
651
|
+
.optional()
|
|
652
|
+
.describe("Only memories created at/after this ISO-8601 instant (naive = " +
|
|
653
|
+
"UTC) — e.g. your last-seen watermark in a shared room."),
|
|
654
|
+
until: z
|
|
655
|
+
.string()
|
|
656
|
+
.max(40)
|
|
657
|
+
.optional()
|
|
658
|
+
.describe("Only memories created at/before this ISO-8601 instant."),
|
|
659
|
+
exclude_author: z
|
|
660
|
+
.string()
|
|
661
|
+
.max(200)
|
|
662
|
+
.optional()
|
|
663
|
+
.describe("Drop memories written by this author PRINCIPAL — the server-side " +
|
|
664
|
+
"identity. ⚠️ NOT USABLE FROM HERE YET: the principal is not shown " +
|
|
665
|
+
"in these results, so there is no value you can obtain through " +
|
|
666
|
+
"this tool, and a guess like 'me' silently matches nothing and " +
|
|
667
|
+
"filters nothing. Only pass it if your system knows the exact " +
|
|
668
|
+
"principal from elsewhere (e.g. the REST API). A self-exclusion " +
|
|
669
|
+
"shortcut is planned."),
|
|
670
|
+
},
|
|
671
|
+
// Item shape is MEMORY_ITEM_OUTPUT (above), shared with
|
|
672
|
+
// memory_list_recent's outputSchema as of S5; see that constant's
|
|
673
|
+
// comment for provenance and the OD-7 id-type note.
|
|
674
|
+
outputSchema: {
|
|
675
|
+
items: z
|
|
676
|
+
.array(z.object(MEMORY_ITEM_OUTPUT))
|
|
677
|
+
.describe("Matching memories, ordered per order_by."),
|
|
678
|
+
},
|
|
679
|
+
annotations: {
|
|
680
|
+
title: "Search Memories",
|
|
681
|
+
readOnlyHint: true,
|
|
682
|
+
destructiveHint: false,
|
|
683
|
+
idempotentHint: true,
|
|
684
|
+
openWorldHint: false,
|
|
685
|
+
},
|
|
686
|
+
}, async ({ query, top_k, domain, order_by, since, until, exclude_author }) => {
|
|
687
|
+
// ONE value, used for the request AND for every decision about it.
|
|
688
|
+
//
|
|
689
|
+
// A previous draft sent the raw string but decided the wording from a
|
|
690
|
+
// TRIMMED copy, and the two disagreed in the release's own founding case:
|
|
691
|
+
// a read on " engineering" searched the padded store (correct) while the
|
|
692
|
+
// diagnosis checked "engineering", found it, and stayed silent — so the
|
|
693
|
+
// one note that would have said "a stray space makes a different store"
|
|
694
|
+
// was suppressed exactly when a stray space had made a different store.
|
|
695
|
+
// For a whitespace-only domain it went the other way and claimed the
|
|
696
|
+
// search had covered the caller's own domains when it had covered none
|
|
697
|
+
// (reviews, 2026-08-08).
|
|
698
|
+
//
|
|
699
|
+
// `|| undefined` is what 0.8.0 sent and must stay: core filters on
|
|
700
|
+
// `domain is not None`, not on truthiness, so passing "" through would
|
|
701
|
+
// become `WHERE domain = ''` — a store that cannot exist — turning a
|
|
702
|
+
// search of every domain into a guaranteed miss. That is data movement,
|
|
703
|
+
// not wording, and it does not belong in a patch.
|
|
704
|
+
const searched = searchedScope(domain);
|
|
705
|
+
const r = await apiFetch("/memory/read", {
|
|
706
|
+
method: "POST",
|
|
707
|
+
body: JSON.stringify(readRequestBody({ query, top_k, domain, order_by, since, until, exclude_author })),
|
|
708
|
+
});
|
|
709
|
+
// `items` must be a REAL array before anything below may speak. Core's
|
|
710
|
+
// read response always carries one on a 200, so a body without it is not
|
|
711
|
+
// core's answer — and the old `Array.isArray(r?.items) ? r.items : []`
|
|
712
|
+
// fed exactly that body to the entire zero-result machinery: head
|
|
713
|
+
// sentence, scope probe, diagnosis. An absence claim derived from a body
|
|
714
|
+
// this client could not read — the substitution classifyRooms removed for
|
|
715
|
+
// rooms, one level up from the probes (truth F13, 2026-08-08).
|
|
716
|
+
const items = r?.items;
|
|
717
|
+
if (!Array.isArray(items)) {
|
|
718
|
+
return unreadableAnswerReply("The search result", "a list of matches", "nothing matched");
|
|
719
|
+
}
|
|
720
|
+
if (items.length === 0 && (since || until || exclude_author)) {
|
|
721
|
+
// A bounded/filtered read that finds nothing is NOT a bad query —
|
|
722
|
+
// the truthful answer is "nothing new for these filters" (the feed's
|
|
723
|
+
// filtered head uses the same sentence; no stats probe, no broaden
|
|
724
|
+
// hint). Unscoped, it also has to name the rooms it never looked in.
|
|
725
|
+
const scopeNote = readScopeNote(await probeScope(searched));
|
|
726
|
+
// The scope is IN the sentence, not appended after it. This branch
|
|
727
|
+
// was already the honest one in 0.8.0 — it names its filters — and
|
|
728
|
+
// it is the model the feed's copy now follows.
|
|
729
|
+
//
|
|
730
|
+
// The legend wraps the WHOLE message and is added at most once:
|
|
731
|
+
// `scopeNote` may itself have named a store (a case-twin), and one
|
|
732
|
+
// explanation of the escaping per answer is the point of it.
|
|
733
|
+
return structured(withDomainEscapeLegend(`Nothing in ${scopeLabel(searched)} matches within the given time/author filters.` +
|
|
734
|
+
futureSinceNote(since, Date.now()) +
|
|
735
|
+
scopeNote, searched), { items: [] });
|
|
736
|
+
}
|
|
737
|
+
if (items.length === 0) {
|
|
738
|
+
// Zero results. The scope is probed FIRST and the whole answer is then
|
|
739
|
+
// assembled from that ONE value in src/teaching.ts — head sentence and
|
|
740
|
+
// disclosure together, per state.
|
|
741
|
+
//
|
|
742
|
+
// ORDER MATTERS and is now structural: whether the caller has rooms we
|
|
743
|
+
// could not search decides whether the first-contact greeting is even
|
|
744
|
+
// true, so the room knowledge is an INPUT to the answer rather than a flag
|
|
745
|
+
// consulted beside it. `total_atoms` counts the personal org only, so a
|
|
746
|
+
// joiner with three full rooms and no personal writes would otherwise be
|
|
747
|
+
// told "nothing has been saved yet" and contradicted by the note right
|
|
748
|
+
// underneath (review, 2026-08-08).
|
|
749
|
+
//
|
|
750
|
+
// Nothing is appended here any more. While the head came from teaching.ts
|
|
751
|
+
// and the tail was concatenated at this line, the two could disagree — a
|
|
752
|
+
// head promising "that is not the whole picture:" with an empty tail after
|
|
753
|
+
// it was a live bug for an account whose only room was archived.
|
|
754
|
+
const scope = await probeScope(searched);
|
|
755
|
+
const text = await buildReadEmptyResponse(
|
|
756
|
+
// THE THIRD PROBE, and the one the first pass at #73 missed. An
|
|
757
|
+
// UNSCOPED empty read takes this path: probeScope only fetches the
|
|
758
|
+
// room list (scope.ts: `if (!searched) return { kind: "own-domains" }`),
|
|
759
|
+
// and the stats call for the first-contact greeting is issued here.
|
|
760
|
+
// Deadlining only the two inside probeScope left the commonest empty
|
|
761
|
+
// read of all still able to hang for undici's ~300s default — the exact
|
|
762
|
+
// symptom #73 names.
|
|
763
|
+
() => apiFetch("/memory/stats", {
|
|
764
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
765
|
+
}), scope);
|
|
766
|
+
// NO legend wrapper here, on purpose — `withDomainEscapeLegend(
|
|
767
|
+
// text, searched)` stood on this line and was dead code that
|
|
768
|
+
// looked load-bearing (tests-lens F9, 2026-08-08): every arm of
|
|
769
|
+
// buildReadEmptyResponse either names no store at all (fixed
|
|
770
|
+
// sentences), or names it inside a note that appends its own
|
|
771
|
+
// legend (the case-twin diagnosis, src/scope.ts) — and the
|
|
772
|
+
// unscoped path passes `searched === undefined`, which can never
|
|
773
|
+
// need one. So there was no input on which the wrapper fired.
|
|
774
|
+
// Pinned by "the plain-empty read is legended by its notes" in
|
|
775
|
+
// test/handlers.test.ts.
|
|
776
|
+
return structured(text, { items: [] });
|
|
777
|
+
}
|
|
778
|
+
// Every item must carry the three fields core's MemoryItemSchema always
|
|
779
|
+
// sends before this handler may render OR structure it: `atom_id`,
|
|
780
|
+
// `content` and `domain`. A body with items but missing one of those on
|
|
781
|
+
// any entry is not core's answer to memory_read, the same "unreadable
|
|
782
|
+
// 2xx" class the guard above catches one level up, discovered one field
|
|
783
|
+
// later. `structuredItem` (src/render.ts) needs all three to build a
|
|
784
|
+
// schema-honest structuredContent item, and there is no honest value to
|
|
785
|
+
// put in a required field this response did not send.
|
|
786
|
+
if (items.some((it) => typeof it?.atom_id !== "string" ||
|
|
787
|
+
typeof it?.content !== "string" ||
|
|
788
|
+
typeof it?.domain !== "string")) {
|
|
789
|
+
return unreadableAnswerReply("The search result", "a list of matches", "nothing matched");
|
|
790
|
+
}
|
|
791
|
+
// Rendering lives in src/render.ts (testable): each line carries the
|
|
792
|
+
// CN-001 author tag, the created_at date (#404 R1 — a reader cannot
|
|
793
|
+
// reason about recency it cannot see) and the full atom id (the tool
|
|
794
|
+
// description always promised ids for memory_feedback/memory_delete;
|
|
795
|
+
// the old render never delivered them, making both uncallable from
|
|
796
|
+
// read results).
|
|
797
|
+
const lines = items.map((item, i) => formatReadItem(item, i));
|
|
798
|
+
// `?? 0` prints a FABRICATED `(0ms)` when the server sent no timing — the
|
|
799
|
+
// same class as "Associations: 0" for an unknown count, which memory_stats
|
|
800
|
+
// fixed in this release. Untouched here and recorded in CHANGELOG's "Known
|
|
801
|
+
// and NOT fixed here" with the other three.
|
|
802
|
+
const searchMs = (r?.search_time_ms ?? 0).toFixed(0);
|
|
803
|
+
const text = lines.join("\n\n") + `\n\n(${searchMs}ms)`;
|
|
804
|
+
// Each line's `@"domain"` tag is an exact literal; the legend that
|
|
805
|
+
// explains an escape belongs to the answer, not to twenty tags.
|
|
806
|
+
//
|
|
807
|
+
// Legend AFTER the cap, never before. capResult truncates from the
|
|
808
|
+
// END, and the legend is appended at the end — so applied first it
|
|
809
|
+
// was the first thing the cap ate, on exactly the pages long enough
|
|
810
|
+
// to need both: a hundred escaped tags left with nothing saying that
|
|
811
|
+
// \u00a0 is ONE character, not six (truth F6, 2026-08-08). Applied
|
|
812
|
+
// to the CAPPED text the legend survives; and since it fires only
|
|
813
|
+
// when an escaped literal is still on the page, a cap that removed
|
|
814
|
+
// every escaped name drops the legend with it.
|
|
815
|
+
//
|
|
816
|
+
// `structuredContent` carries every item, uncapped (OD-11): the cap
|
|
817
|
+
// and its legend are TEXT-side concerns, and `structuredItem`
|
|
818
|
+
// (src/render.ts) is deliberately not run through either.
|
|
819
|
+
//
|
|
820
|
+
// Author names are candidates too, not just domains (I66-1..I66-3,
|
|
821
|
+
// issue #66): the text tag now quotes every author name through the
|
|
822
|
+
// same `exactLiteral` the `@domain` tag uses, so an escaped one needs
|
|
823
|
+
// the same "these are JSON string literals" legend an escaped domain
|
|
824
|
+
// already gets. `rawAuthorName` is the SAME raw value `formatAuthorTag`
|
|
825
|
+
// quoted, so this recomputation finds the same literal that is
|
|
826
|
+
// actually on the page.
|
|
827
|
+
return structured(withEscapeLegendAt(MAX_DOMAIN_TAG_LITERAL, capResult(text), ...items.map((it) => it?.domain), ...items.map((it) => rawAuthorName(it?.provenance))), { items: items.map(structuredItem) });
|
|
828
|
+
});
|
|
829
|
+
// --- Tool: memory_list_recent ---
|
|
830
|
+
/**
|
|
831
|
+
* How big ONE feed page may get, and how the handler stays under it (#104).
|
|
832
|
+
*
|
|
833
|
+
* THE INCIDENT. `memory_list_recent(domain: "xroom:…", limit: 40, cursor: …)`
|
|
834
|
+
* over a shared room of long archival entries produced a single tool result of
|
|
835
|
+
* 72,648 characters. Claude Code refused to inline it and spilled it to a file;
|
|
836
|
+
* a client without that fallback loses the page. MAX_RESULT_CHARS did not fire,
|
|
837
|
+
* and could not have: 72,648 is comfortably under 96,000. That number is
|
|
838
|
+
* `24,000 tokens × 4 chars/token`, and the 4 is an average over ordinary prose —
|
|
839
|
+
* archival room entries carry ids, code, punctuation and non-ASCII, which
|
|
840
|
+
* tokenize far worse. A cap derived from an optimistic ratio is not a promise
|
|
841
|
+
* about a client's real limit.
|
|
842
|
+
*
|
|
843
|
+
* WHY `limit` COULD NOT SOLVE IT. `limit` bounds the COUNT. Whether a count is
|
|
844
|
+
* safe depends entirely on how long the entries happen to be — 1,500–4,000+
|
|
845
|
+
* chars each in coordination rooms, against a 10,000-char write cap — and the
|
|
846
|
+
* caller cannot know that before asking. Too high explodes; too low costs
|
|
847
|
+
* dozens of round trips for the same catch-up.
|
|
848
|
+
*
|
|
849
|
+
* WHAT THIS IS. A page is assembled from small sub-requests and stops BEFORE
|
|
850
|
+
* the budget is exceeded, returning the server cursor of the last FULLY
|
|
851
|
+
* accepted sub-batch. The cursor is per-batch, which is why the batch is small:
|
|
852
|
+
* it is the granularity at which the page can end without losing or repeating
|
|
853
|
+
* an entry. Nothing is dropped silently — an entry that exceeds the budget on
|
|
854
|
+
* its own is returned whole, as a page of one, because per-entry truncation
|
|
855
|
+
* needs a fetch-one-by-id verb this server does not have (#104, suggestion 2).
|
|
856
|
+
*
|
|
857
|
+
* THE NUMBER IS DELIBERATELY CONSERVATIVE AND DELIBERATELY LOCAL. 40,000 is
|
|
858
|
+
* roughly half of what already failed in production; the true boundary of any
|
|
859
|
+
* given client is unmeasured, and calibrating it is follow-up work. It does NOT
|
|
860
|
+
* touch MAX_RESULT_CHARS, which is shared with memory_read and every other
|
|
861
|
+
* surface and remains the final backstop after this budget has done its work.
|
|
862
|
+
*/
|
|
863
|
+
const LIST_PAGE_CHAR_BUDGET = 40_000;
|
|
864
|
+
/**
|
|
865
|
+
* Entries per sub-request. Small enough that the budget can end a page at a
|
|
866
|
+
* useful granularity, large enough that an ordinary short-entry feed still
|
|
867
|
+
* costs one or two round trips (the default `limit` of 20 costs two).
|
|
868
|
+
*/
|
|
869
|
+
const LIST_PAGE_CHUNK = 10;
|
|
870
|
+
/**
|
|
871
|
+
* A ceiling on sub-requests per call — the loop's own stop condition is the
|
|
872
|
+
* budget, the caller's `limit`, or the end of the feed, and this fires only if
|
|
873
|
+
* a server answers in a way none of those three catch. `limit: 100` needs ten,
|
|
874
|
+
* plus a few for narrowing an over-budget batch.
|
|
875
|
+
*/
|
|
876
|
+
const LIST_PAGE_MAX_REQUESTS = 16;
|
|
877
|
+
/**
|
|
878
|
+
* Appended when the page stopped because a LATER sub-request failed. Chunking
|
|
879
|
+
* multiplies the requests per call and therefore the chance one of them fails
|
|
880
|
+
* mid-page; discarding the entries already in hand would make this change a
|
|
881
|
+
* regression for exactly the long-entry rooms it exists for. Saying nothing
|
|
882
|
+
* would be worse — a short page with a valid cursor is indistinguishable from
|
|
883
|
+
* a page the budget ended, which is the could-not-fetch/does-not-exist
|
|
884
|
+
* collision this codebase keeps closing.
|
|
885
|
+
*/
|
|
886
|
+
const LIST_PAGE_EARLY_STOP_NOTE = "\n\n(This page stopped early — the request for the next batch of older " +
|
|
887
|
+
"entries did not come back usable, so this page holds fewer entries than " +
|
|
888
|
+
"asked for. The cursor above is unaffected: continue from it.)";
|
|
889
|
+
server.registerTool("memory_list_recent", {
|
|
890
|
+
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.",
|
|
891
|
+
inputSchema: {
|
|
892
|
+
domain: z
|
|
893
|
+
.string()
|
|
894
|
+
.optional()
|
|
895
|
+
.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`)."),
|
|
896
|
+
since: z
|
|
897
|
+
.string()
|
|
898
|
+
.optional()
|
|
899
|
+
.describe("Only entries created at/after this ISO-8601 instant (naive = UTC) — your novelty watermark."),
|
|
900
|
+
until: z
|
|
901
|
+
.string()
|
|
902
|
+
.optional()
|
|
903
|
+
.describe("Only entries created at/before this ISO-8601 instant (inclusive). Pair with `since` to read a closed window — 'what happened on Monday' — instead of paging back from now."),
|
|
904
|
+
exclude_author: z
|
|
905
|
+
.string()
|
|
906
|
+
.max(200)
|
|
907
|
+
.optional()
|
|
908
|
+
.describe("Drop entries written by this author PRINCIPAL. ⚠️ NOT USABLE FROM HERE YET — the principal is never shown in these results, so there is no value you can get through this tool; a guess like 'me' filters nothing, silently. Same caveat as on memory_read."),
|
|
909
|
+
limit: z
|
|
910
|
+
.number()
|
|
911
|
+
.int()
|
|
912
|
+
.min(CORE_LIMITS.recentLimit.minimum)
|
|
913
|
+
.max(CORE_LIMITS.recentLimit.maximum)
|
|
914
|
+
.optional()
|
|
915
|
+
.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."),
|
|
916
|
+
cursor: z
|
|
917
|
+
.string()
|
|
918
|
+
.max(CORE_LIMITS.recentCursor.maxLength)
|
|
919
|
+
.optional()
|
|
920
|
+
.describe("Opaque cursor from a previous page's 'More older entries exist' line — continues the listing without skips or duplicates."),
|
|
921
|
+
},
|
|
922
|
+
// Item shape is MEMORY_ITEM_OUTPUT, shared with memory_read's
|
|
923
|
+
// outputSchema (S5, structured-output plan; see that constant's
|
|
924
|
+
// comment). `next_cursor` copies the connector's own field, same name
|
|
925
|
+
// and meaning (mnemoverse-mcp-remote `memoryListRecentOutput`), but
|
|
926
|
+
// this package derives it differently, because it pages through
|
|
927
|
+
// several core requests per call instead of one; see the comment on
|
|
928
|
+
// `acceptedCursor` where the value is produced, below.
|
|
929
|
+
outputSchema: {
|
|
930
|
+
items: z
|
|
931
|
+
.array(z.object(MEMORY_ITEM_OUTPUT))
|
|
932
|
+
.describe("Entries newest-first (creation time descending)."),
|
|
933
|
+
// Optional, unlike the connector's required field (decision OD-12,
|
|
934
|
+
// 2026-09-23): the key is ABSENT when the service sent a continuation
|
|
935
|
+
// token this client will not pass on (CN-032 shape gate, the same one
|
|
936
|
+
// the text applies), because null would claim the listing is complete.
|
|
937
|
+
next_cursor: z
|
|
938
|
+
.string()
|
|
939
|
+
.nullable()
|
|
940
|
+
.optional()
|
|
941
|
+
.describe("Pass back as cursor for the next (older) page; null = listing complete. Absent when the service sent a continuation token this client will not pass on; the text then says the token could not be displayed."),
|
|
942
|
+
},
|
|
943
|
+
annotations: {
|
|
944
|
+
title: "List Recent Memories",
|
|
945
|
+
readOnlyHint: true,
|
|
946
|
+
destructiveHint: false,
|
|
947
|
+
idempotentHint: true,
|
|
948
|
+
openWorldHint: false,
|
|
949
|
+
},
|
|
950
|
+
}, async ({ domain, since, until, exclude_author, limit, cursor }) => {
|
|
951
|
+
// ONE value for the request and for every decision about it — see the note
|
|
952
|
+
// at the top of memory_read.
|
|
953
|
+
const searched = searchedScope(domain);
|
|
954
|
+
// The caller's `limit` is a CEILING on the item count. The page also has a
|
|
955
|
+
// character budget (LIST_PAGE_CHAR_BUDGET), and whichever binds first ends
|
|
956
|
+
// the page — which is why this is a loop over small sub-requests rather
|
|
957
|
+
// than one request for `limit` entries followed by a cap that arrives too
|
|
958
|
+
// late to do anything but truncate.
|
|
959
|
+
const ceiling = limit || 20;
|
|
960
|
+
const accepted = [];
|
|
961
|
+
// The server cursor of the last FULLY accepted sub-batch: the only value
|
|
962
|
+
// that can be handed back without losing or repeating an entry, since a
|
|
963
|
+
// cursor names a batch boundary and nothing finer.
|
|
964
|
+
let acceptedCursor;
|
|
965
|
+
// Where the next sub-request continues from — the caller's cursor first,
|
|
966
|
+
// the server's thereafter. Resending the caller's would replay page one.
|
|
967
|
+
let position = cursor || undefined;
|
|
968
|
+
let ask = Math.min(LIST_PAGE_CHUNK, ceiling);
|
|
969
|
+
let stoppedEarly = false;
|
|
970
|
+
for (let attempt = 0; attempt < LIST_PAGE_MAX_REQUESTS; attempt++) {
|
|
971
|
+
let r;
|
|
972
|
+
try {
|
|
973
|
+
r = await apiFetch("/memory/recent", {
|
|
974
|
+
method: "POST",
|
|
975
|
+
body: JSON.stringify(recentRequestBody({
|
|
976
|
+
domain,
|
|
977
|
+
since,
|
|
978
|
+
until,
|
|
979
|
+
exclude_author,
|
|
980
|
+
limit: ask,
|
|
981
|
+
cursor: position,
|
|
982
|
+
})),
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
catch (e) {
|
|
986
|
+
// A failure with entries already in hand ends the page instead of the
|
|
987
|
+
// call: the caller keeps what was fetched plus a cursor that still
|
|
988
|
+
// continues correctly, and the appended note says the page was cut
|
|
989
|
+
// short by a failed request rather than by the budget. With nothing
|
|
990
|
+
// accepted there is no page to return, so the error surfaces exactly
|
|
991
|
+
// as it did before chunking.
|
|
992
|
+
if (accepted.length > 0) {
|
|
993
|
+
stoppedEarly = true;
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
// Graceful degradation while the server side rolls out: a 404 with no
|
|
997
|
+
// error `code` in the body is what an undeployed /memory/recent looks
|
|
998
|
+
// like, so degrade to a usable alternative instead of surfacing a raw
|
|
999
|
+
// HTTP error.
|
|
1000
|
+
//
|
|
1001
|
+
// NAMED FOR WHAT IT TESTS. This was `endpointAbsent`, which asserted a
|
|
1002
|
+
// deployment fact the check cannot establish: every engine 404 carries a
|
|
1003
|
+
// `code`, so a real room-404 is excluded, but a gateway, a proxy or a
|
|
1004
|
+
// wrong MNEMOVERSE_API_URL produces the same bare 404 and is
|
|
1005
|
+
// indistinguishable from here. The MESSAGE below still states the
|
|
1006
|
+
// deployment cause outright, which is more than this boolean knows —
|
|
1007
|
+
// listed in CHANGELOG's "Known and NOT fixed here" rather than papered
|
|
1008
|
+
// over with a hedge.
|
|
1009
|
+
//
|
|
1010
|
+
// NOW READ FROM STRUCTURED FIELDS. It used to be
|
|
1011
|
+
// `e.message.startsWith("Mnemoverse API error 404:")` plus a substring
|
|
1012
|
+
// hunt for `"code"` in the same string — a behavioural branch keyed to the
|
|
1013
|
+
// exact prefix of a user-facing sentence. Rewording that sentence, which
|
|
1014
|
+
// is precisely what src/errors.ts does, would have flipped this branch
|
|
1015
|
+
// silently: every per-request 404 would have degraded into "the service
|
|
1016
|
+
// does not support the feed yet". `ApiError.isBare404` asks the parsed
|
|
1017
|
+
// envelope instead, so the prose and the branch can no longer collide.
|
|
1018
|
+
const bare404 = e instanceof ApiError && e.isBare404;
|
|
1019
|
+
if (bare404) {
|
|
1020
|
+
// OD-9 (owner, 2026-09-23): this reply is now `isError`, not a
|
|
1021
|
+
// silent success. Once this tool declares an outputSchema (S5),
|
|
1022
|
+
// the SDK's own `validateToolOutput` (see the comment on
|
|
1023
|
+
// `structured()` above) rejects a non-error result with no
|
|
1024
|
+
// `structuredContent`, and there is no honest structuredContent
|
|
1025
|
+
// to give it: `{items: [], next_cursor: null}` would be a
|
|
1026
|
+
// schema-valid EMPTY PAGE, exactly the absence claim about the
|
|
1027
|
+
// feed this sentence exists to avoid making. `isError` is the one
|
|
1028
|
+
// shape the SDK exempts from that check, so it is the shape this
|
|
1029
|
+
// reply takes now. The sentence itself is unchanged.
|
|
1030
|
+
return {
|
|
1031
|
+
content: [
|
|
1032
|
+
{
|
|
1033
|
+
type: "text",
|
|
1034
|
+
text: "The memory service does not support the recent-entries feed yet. " +
|
|
1035
|
+
"Use memory_read with order_by: 'recency' as an approximation.",
|
|
1036
|
+
},
|
|
1037
|
+
],
|
|
1038
|
+
isError: true,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
throw e;
|
|
1042
|
+
}
|
|
1043
|
+
// Same guard as memory_read: a 200 without an items array is UNREADABLE,
|
|
1044
|
+
// not empty — and the feed's empty heads below are precisely the absence
|
|
1045
|
+
// claims that must not be derived from it (truth F13, 2026-08-08). Mid
|
|
1046
|
+
// page it is treated like a failed sub-request, for the same reason.
|
|
1047
|
+
const batch = r?.items;
|
|
1048
|
+
if (!Array.isArray(batch)) {
|
|
1049
|
+
if (accepted.length > 0) {
|
|
1050
|
+
stoppedEarly = true;
|
|
1051
|
+
break;
|
|
1052
|
+
}
|
|
1053
|
+
return unreadableAnswerReply("The recent-entries feed", "an empty feed", "there is nothing to list");
|
|
1054
|
+
}
|
|
1055
|
+
const next = r?.next_cursor;
|
|
1056
|
+
// Measured on the TEXT THAT WOULD SHIP, rendered by the same function
|
|
1057
|
+
// that renders the answer — an estimate from item lengths would drift
|
|
1058
|
+
// from the renderer the first time a line gained a field. The early-stop
|
|
1059
|
+
// note's length is reserved up front: it is appended only when a LATER
|
|
1060
|
+
// sub-request fails, which cannot be known while this batch is being
|
|
1061
|
+
// sized, so every page keeps room for it (CodeRabbit, PR #108).
|
|
1062
|
+
const fits = formatRecentPage(accepted.concat(batch), next).length <=
|
|
1063
|
+
LIST_PAGE_CHAR_BUDGET - LIST_PAGE_EARLY_STOP_NOTE.length;
|
|
1064
|
+
if (fits) {
|
|
1065
|
+
accepted.push(...batch);
|
|
1066
|
+
// An EMPTY cursor ends the feed for the page as well as for this loop.
|
|
1067
|
+
// The loop below already stops on it (`position` is undefined), but
|
|
1068
|
+
// the raw "" went to formatRecentPage, which reads only null as the
|
|
1069
|
+
// end, so a finished feed printed "More entries exist but the
|
|
1070
|
+
// continuation token could not be displayed" (CodeRabbit, #144).
|
|
1071
|
+
// Only "" is normalised: any other non-null value still reaches the
|
|
1072
|
+
// renderer, which says entries exist but refuses to print the token.
|
|
1073
|
+
acceptedCursor = next === "" ? null : next;
|
|
1074
|
+
// The same gate the two surfaces apply (CURSOR_RE, src/render.ts):
|
|
1075
|
+
// a token this client will not print or carry is not passed back
|
|
1076
|
+
// to the service either, so paging stops here and the page says
|
|
1077
|
+
// the token could not be displayed (Copilot, #159).
|
|
1078
|
+
position =
|
|
1079
|
+
typeof next === "string" && next && CURSOR_RE.test(next) ? next : undefined;
|
|
1080
|
+
// No cursor: the feed ended, and the page says so. No entries: the
|
|
1081
|
+
// server is not advancing, so continuing would spend requests on the
|
|
1082
|
+
// same nothing. Ceiling reached: the caller's count is spent.
|
|
1083
|
+
if (!position || batch.length === 0 || accepted.length >= ceiling)
|
|
1084
|
+
break;
|
|
1085
|
+
ask = Math.min(LIST_PAGE_CHUNK, ceiling - accepted.length);
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
// Over budget. With entries already accepted, THIS is the ordinary stop:
|
|
1089
|
+
// keep the batches that fit and hand back the cursor of the last one.
|
|
1090
|
+
if (accepted.length > 0)
|
|
1091
|
+
break;
|
|
1092
|
+
// Nothing accepted yet, so this one batch is over budget by itself and
|
|
1093
|
+
// the page cannot be empty — something must be returned.
|
|
1094
|
+
//
|
|
1095
|
+
// `batch.length > ask` means the server ignored `limit`; asking again,
|
|
1096
|
+
// smaller, would be a wasted round trip against a deployment that is not
|
|
1097
|
+
// listening, and MAX_RESULT_CHARS is the backstop for it. A batch of one
|
|
1098
|
+
// is the entry that exceeds the budget alone: it ships whole, because
|
|
1099
|
+
// dropping it is silent loss and truncating it needs a fetch-by-id verb
|
|
1100
|
+
// that does not exist yet (#104).
|
|
1101
|
+
//
|
|
1102
|
+
// KNOWN EDGE, inherited rather than introduced (CodeRabbit, PR #108):
|
|
1103
|
+
// when a limit-ignoring server's batch ships whole and capResult then
|
|
1104
|
+
// truncates the tail, the printed cursor points past entries the reader
|
|
1105
|
+
// never saw. 0.9.1 had the identical hazard (one request, the server's
|
|
1106
|
+
// cursor, the same cap). The alternatives are worse lies: slicing to
|
|
1107
|
+
// `ask` keeps the server's cursor and SKIPS the sliced entries silently;
|
|
1108
|
+
// rejecting the batch outright answers a working feed with "unreadable".
|
|
1109
|
+
// A contract-violating server is the precondition; the real fix is
|
|
1110
|
+
// fetch-by-id (#104 follow-up), not a guess here.
|
|
1111
|
+
const narrower = Math.max(1, Math.min(Math.floor(ask / 2), batch.length - 1));
|
|
1112
|
+
if (batch.length <= 1 || batch.length > ask || narrower >= ask) {
|
|
1113
|
+
accepted.push(...batch);
|
|
1114
|
+
acceptedCursor = next === "" ? null : next; // same end-of-feed rule as above
|
|
1115
|
+
break;
|
|
1116
|
+
}
|
|
1117
|
+
// Re-ask the SAME position for fewer entries. `narrower < batch.length`
|
|
1118
|
+
// by construction, so the ask strictly shrinks and the loop converges.
|
|
1119
|
+
ask = narrower;
|
|
1120
|
+
}
|
|
1121
|
+
const items = accepted;
|
|
1122
|
+
// Every ACCEPTED item must carry the three fields core's
|
|
1123
|
+
// MemoryItemSchema always sends before this handler may render OR
|
|
1124
|
+
// structure it: `atom_id`, `content` and `domain`; same guard as
|
|
1125
|
+
// memory_read's (S4), added here for S5. A body with items but missing
|
|
1126
|
+
// one of those on any entry is not core's answer, the same "unreadable
|
|
1127
|
+
// 2xx" class the batch guard above catches one request earlier;
|
|
1128
|
+
// `structuredItem` (src/render.ts) needs all three to build a
|
|
1129
|
+
// schema-honest structuredContent item, and there is no honest value to
|
|
1130
|
+
// put in a required field a batch did not send. Trivially satisfied
|
|
1131
|
+
// when `items` is empty, so this runs before the zero-length branch
|
|
1132
|
+
// rather than only inside the non-empty one.
|
|
1133
|
+
if (items.some((it) => typeof it?.atom_id !== "string" ||
|
|
1134
|
+
typeof it?.content !== "string" ||
|
|
1135
|
+
typeof it?.domain !== "string")) {
|
|
1136
|
+
return unreadableAnswerReply("The recent-entries feed", "an empty feed", "there is nothing to list");
|
|
1137
|
+
}
|
|
1138
|
+
if (items.length === 0) {
|
|
1139
|
+
// THE SENTENCE ITSELF carries the scope — and it is selected by EVERY
|
|
1140
|
+
// filter that narrowed the window, not by `since` alone.
|
|
1141
|
+
//
|
|
1142
|
+
// Two prior shapes of this branch were wrong the same way. In 0.8.0 it
|
|
1143
|
+
// printed "Nothing new since your watermark." with no scope in it — the
|
|
1144
|
+
// sentence src/scope.ts was written to eliminate. The first fix put the
|
|
1145
|
+
// scope into the clause but kept choosing BETWEEN the two heads by
|
|
1146
|
+
// looking at `since` only, so {until} alone and {exclude_author} alone
|
|
1147
|
+
// fell to "No memories in ${where} yet." — an emptiness claim about a
|
|
1148
|
+
// store holding a thousand atoms outside the window — and {since, until}
|
|
1149
|
+
// kept the watermark phrasing, which pretends the read reached the
|
|
1150
|
+
// present when `until` stopped it years short (review, 2026-08-08).
|
|
1151
|
+
//
|
|
1152
|
+
// Four heads, one rule: the emptiness claim ("yet") is allowed only
|
|
1153
|
+
// when NOTHING narrowed the window; the watermark phrasing only when
|
|
1154
|
+
// `since` was the whole narrowing; any other filter combination gets the
|
|
1155
|
+
// sentence memory_read's filtered branch uses — the filters are named as
|
|
1156
|
+
// what bounded the result, and nothing is claimed beyond them.
|
|
1157
|
+
//
|
|
1158
|
+
// A `cursor` outranks all three. It is not a filter over the store — it
|
|
1159
|
+
// is a POSITION in a listing whose earlier pages the caller has already
|
|
1160
|
+
// read, so every other head is false here: "No memories in ${where}
|
|
1161
|
+
// yet." is an absence claim about a store the caller has just SEEN
|
|
1162
|
+
// entries from, and the watermark phrasing pretends a continuation that
|
|
1163
|
+
// stopped mid-listing was a clean catch-up. The engine hands out a
|
|
1164
|
+
// cursor only when more entries existed at that moment, so an empty
|
|
1165
|
+
// continued page means the listing moved under the caller — entries
|
|
1166
|
+
// removed between page fetches — and the head speaks about the
|
|
1167
|
+
// continuation only, never about what the store holds (follow-up to the
|
|
1168
|
+
// head-selection fix, 2026-08-08). Decided by the same truthiness the
|
|
1169
|
+
// request used: an empty-string cursor never reaches the wire
|
|
1170
|
+
// (src/requests.ts), so it may not pick the sentence either.
|
|
1171
|
+
const scopeNote = readScopeNote(await probeScope(searched));
|
|
1172
|
+
const where = scopeLabel(searched);
|
|
1173
|
+
const head = cursor
|
|
1174
|
+
? `Nothing further in ${where} past this cursor — entries may have been ` +
|
|
1175
|
+
`removed since the previous page was fetched.` +
|
|
1176
|
+
(since || until || exclude_author
|
|
1177
|
+
? ` The given time/author filters still bounded this page.`
|
|
1178
|
+
: ``)
|
|
1179
|
+
: since && !until && !exclude_author
|
|
1180
|
+
? `Nothing new in ${where} since your watermark.`
|
|
1181
|
+
: since || until || exclude_author
|
|
1182
|
+
? `Nothing in ${where} matches within the given time/author filters.`
|
|
1183
|
+
: `No memories in ${where} yet.`;
|
|
1184
|
+
// `structuredContent.next_cursor` is unconditionally null on this
|
|
1185
|
+
// branch: none of the four heads above ever prints a NEW cursor to
|
|
1186
|
+
// continue from (a `cursor` in the args is the caller's OWN, already
|
|
1187
|
+
// spent; the others are absence claims over the whole scope), so
|
|
1188
|
+
// there is no cursor value the text could be said to agree with.
|
|
1189
|
+
return structured(withDomainEscapeLegend(head + futureSinceNote(since, Date.now()) + scopeNote, searched), { items: [], next_cursor: null });
|
|
1190
|
+
}
|
|
1191
|
+
return structured(
|
|
1192
|
+
// The page body comes from src/render.ts; the escape legend is
|
|
1193
|
+
// applied HERE, to the CAPPED text. formatRecentPage used to append
|
|
1194
|
+
// it itself, which put it before capResult, and capResult truncates
|
|
1195
|
+
// from the end, so the one sentence explaining the escapes was the
|
|
1196
|
+
// first casualty on every page long enough to be capped (truth F6,
|
|
1197
|
+
// 2026-08-08). Same order as memory_read's result page, same
|
|
1198
|
+
// automatic drop: a cap that removed every escaped name removes the
|
|
1199
|
+
// reason for the legend too.
|
|
1200
|
+
//
|
|
1201
|
+
// The cursor is the last ACCEPTED batch's, never the newest one
|
|
1202
|
+
// seen: a batch that did not fit the budget was not returned, so
|
|
1203
|
+
// pointing past it would skip every entry in it.
|
|
1204
|
+
//
|
|
1205
|
+
// Author names are candidates too, not just domains: same reasoning
|
|
1206
|
+
// as memory_read's call site above (I66-1..I66-3, issue #66).
|
|
1207
|
+
withEscapeLegendAt(MAX_DOMAIN_TAG_LITERAL, capResult(formatRecentPage(items, acceptedCursor) +
|
|
1208
|
+
(stoppedEarly ? LIST_PAGE_EARLY_STOP_NOTE : ""),
|
|
1209
|
+
// Still true, and now only reachable when ONE entry is larger
|
|
1210
|
+
// than the whole budget: the case `limit` cannot fix and the
|
|
1211
|
+
// global cap has to.
|
|
1212
|
+
"Lower `limit` or add a `domain` for smaller pages."), ...items.map((it) => it?.domain), ...items.map((it) => rawAuthorName(it?.provenance))), {
|
|
1213
|
+
items: items.map(structuredItem),
|
|
1214
|
+
// Same field name and meaning as the connector's `next_cursor`
|
|
1215
|
+
// (mnemoverse-mcp-remote `memoryListRecentOutput`): the cursor a
|
|
1216
|
+
// client can safely pass back to continue, null when the feed is
|
|
1217
|
+
// finished. DIFFERENT derivation, because this package pages
|
|
1218
|
+
// through several core requests per call (LIST_PAGE_CHAR_BUDGET)
|
|
1219
|
+
// instead of one: it is `acceptedCursor`, the cursor of the last
|
|
1220
|
+
// FULLY accepted batch (set above, same value `formatRecentPage`
|
|
1221
|
+
// just printed), not core's newest-seen cursor: a batch that did
|
|
1222
|
+
// not fit the budget is absent from `items` too, so pointing past
|
|
1223
|
+
// it would both skip entries and contradict the page just shown.
|
|
1224
|
+
// `acceptedCursor` is normalised to null in the loop already
|
|
1225
|
+
// (`next === "" ? null : next`) before it ever reaches here. One
|
|
1226
|
+
// more gate, the same one the text applies (CURSOR_RE, src/render.ts):
|
|
1227
|
+
// a token of a shape this client will not pass on is withheld from
|
|
1228
|
+
// the data too, and the key is then ABSENT, not null, since null
|
|
1229
|
+
// would claim the listing is complete (decision OD-12, 2026-09-23;
|
|
1230
|
+
// the schema marks the field optional for exactly this case).
|
|
1231
|
+
// `typeof` first: the wire value is declared a string but a number
|
|
1232
|
+
// would pass the regex by coercion and then fail the schema.
|
|
1233
|
+
...(acceptedCursor == null
|
|
1234
|
+
? { next_cursor: null }
|
|
1235
|
+
: typeof acceptedCursor === "string" && CURSOR_RE.test(acceptedCursor)
|
|
1236
|
+
? { next_cursor: acceptedCursor }
|
|
1237
|
+
: {}),
|
|
1238
|
+
});
|
|
1239
|
+
});
|
|
1240
|
+
// --- Tool: memory_feedback ---
|
|
1241
|
+
/**
|
|
1242
|
+
* Why ids can miss, listed once and used by both branches that need it — the
|
|
1243
|
+
* total miss (`updated_count: 0`) and the partial one (a count short of the
|
|
1244
|
+
* ids sent). They are the same event at two scales, and when the sentence
|
|
1245
|
+
* lived inline in the zero branch only, the partial case got no explanation at
|
|
1246
|
+
* all.
|
|
1247
|
+
*
|
|
1248
|
+
* No frequency claim. "Most often that means…" was a statistic we do not have
|
|
1249
|
+
* (review, 2026-08-08); the causes are listed as possibilities, with the one
|
|
1250
|
+
* the caller cannot otherwise guess first. A room memory lives in the room's
|
|
1251
|
+
* own store, and a rating reaches it only when that room's address is the
|
|
1252
|
+
* `domain` (core routes the rating by it, routes.py `feedback` →
|
|
1253
|
+
* `_resolve_target_org`). Until 0.11 this tool had no `domain` at all, so a
|
|
1254
|
+
* room memory could not be rated from here; now the miss is a wrong or
|
|
1255
|
+
* missing address, and the text says which store was searched.
|
|
1256
|
+
*/
|
|
1257
|
+
const feedbackMissCauses = (domain) => isRoomDomain(domain)
|
|
1258
|
+
? "Possible causes: the ids did not come from that room (your own memories " +
|
|
1259
|
+
"are rated without a domain, and another room's only with its own address); " +
|
|
1260
|
+
"the memory was deleted; or the id came from somewhere other than a " +
|
|
1261
|
+
"memory_read result."
|
|
1262
|
+
: "Possible causes: the ids came from a shared room, which is reached only " +
|
|
1263
|
+
"when domain is that room's address; the memory was deleted; or the id came " +
|
|
1264
|
+
"from somewhere other than a memory_read result.";
|
|
1265
|
+
const feedbackScope = (domain) => isRoomDomain(domain) ? "in that room" : "in your own domains";
|
|
1266
|
+
server.registerTool("memory_feedback", {
|
|
1267
|
+
description:
|
|
1268
|
+
// "negative feedback lets it fade" was withdrawn as false by 0.9.1
|
|
1269
|
+
// (#95) — and survived here, in the sentence every connected model
|
|
1270
|
+
// reads. Nothing time-decays and nothing is auto-deleted: a downvoted
|
|
1271
|
+
// memory is OUT-RANKED, and deletion has been administrative-only since
|
|
1272
|
+
// 0.9.0. The replacement is the wording that release put on the README.
|
|
1273
|
+
"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 as memory_ids. For memories read from a shared room, also pass that room's address as domain; your own memories need no domain. A read-only room member cannot rate the room's memories.",
|
|
1274
|
+
// `memory_ids` is the name (2026-09-21): the tool rates memories, which
|
|
1275
|
+
// is what every result is (an atom is the engine's word for its smallest
|
|
1276
|
+
// unit), and the hosted connector already names the parameter so. Both
|
|
1277
|
+
// fields are optional in the schema only so the handler can refuse the
|
|
1278
|
+
// two ways a call can get this wrong with a sentence instead of a
|
|
1279
|
+
// validation dump. Neither carries a format or a count cap: the engine
|
|
1280
|
+
// validates the ids and sets no maximum, and ADR-025 keeps such checks
|
|
1281
|
+
// with the engine rather than copying them here.
|
|
1282
|
+
inputSchema: {
|
|
1283
|
+
memory_ids: z
|
|
1284
|
+
.array(z.string())
|
|
1285
|
+
.min(1)
|
|
1286
|
+
.optional()
|
|
1287
|
+
.describe("Required: IDs of the memories to rate, the `id:` line of each memory_read result. (Optional in this schema only while the deprecated atom_ids is still accepted in its place.)"),
|
|
1288
|
+
atom_ids: z
|
|
1289
|
+
.array(z.string())
|
|
1290
|
+
.min(1)
|
|
1291
|
+
.optional()
|
|
1292
|
+
.describe("Deprecated since 0.11, removed in 0.13: atom_ids is the old name of memory_ids, still accepted on its own until then. Pass memory_ids instead."),
|
|
1293
|
+
outcome: z
|
|
1294
|
+
.number()
|
|
1295
|
+
.min(CORE_LIMITS.feedbackOutcome.minimum)
|
|
1296
|
+
.max(CORE_LIMITS.feedbackOutcome.maximum)
|
|
1297
|
+
.describe("How helpful was this? 1.0 = very helpful, 0 = neutral, -1.0 = harmful/wrong"),
|
|
1298
|
+
// Added in 0.11 (owner, 2026-09-22). Core has always accepted it and
|
|
1299
|
+
// routes the rating to the room's store when it is an xroom address,
|
|
1300
|
+
// refusing a non-member, an archived room and a read-only member with
|
|
1301
|
+
// a 403 that explain403 names. Any other value changes nothing: the
|
|
1302
|
+
// rating goes to the caller's own store, as it does without one. No
|
|
1303
|
+
// format or length check here (ADR-025). The value is sent untouched,
|
|
1304
|
+
// like every other domain this package passes on, except that an
|
|
1305
|
+
// empty string counts as none (searchedScope, as on memory_read).
|
|
1306
|
+
domain: z
|
|
1307
|
+
.string()
|
|
1308
|
+
.optional()
|
|
1309
|
+
.describe("Only for memories read from a shared room: that room's address (xroom:...), exactly as you read it. Omit it for your own memories, which are rated by id alone."),
|
|
1310
|
+
},
|
|
1311
|
+
// Copied from the connector's `memoryFeedbackOutput` (mnemoverse-mcp-remote,
|
|
1312
|
+
// src/tools/index.ts), field for field and description for description,
|
|
1313
|
+
// with one noun changed: the connector's `coactivation_edges` text says
|
|
1314
|
+
// "This connector does not send query_concepts"; here it says "This
|
|
1315
|
+
// server", since the fact holds for this tool's own request body (the
|
|
1316
|
+
// POST above carries only atom_ids, outcome and domain) and the noun was
|
|
1317
|
+
// wrong for a local stdio server. `updated_count` is required, matching both the connector's
|
|
1318
|
+
// schema and core's FeedbackResponseSchema (decision OD-8, owner,
|
|
1319
|
+
// 2026-09-23): a body without a usable count is not core's answer. See
|
|
1320
|
+
// the unknown-count branch below, which is `isError` for exactly that
|
|
1321
|
+
// reason, since there is no honest default to declare for it here.
|
|
1322
|
+
outputSchema: {
|
|
1323
|
+
updated_count: z
|
|
1324
|
+
.number()
|
|
1325
|
+
.int()
|
|
1326
|
+
.nonnegative()
|
|
1327
|
+
.describe("How many memories the service reports it applied the rating to. Processed synchronously this is the real count of memories that existed and were updated; processed asynchronously it is a best-effort ACCEPTED-count estimate — the number of IDs submitted — and the authoritative number is not known until the background worker runs. Zero means no submitted ID matched in the service's resolved request scope."),
|
|
1328
|
+
avg_valence: z
|
|
1329
|
+
.number()
|
|
1330
|
+
.optional()
|
|
1331
|
+
.describe("Average valence of the rated memories after the update. Reported as 0 in an asynchronous acknowledgement, where the real value is computed later — a 0 here is therefore not evidence of a neutral outcome."),
|
|
1332
|
+
coactivation_edges: z
|
|
1333
|
+
.number()
|
|
1334
|
+
.int()
|
|
1335
|
+
.nonnegative()
|
|
1336
|
+
.optional()
|
|
1337
|
+
.describe(`Number of feedback-driven query/result concept co-activation edges changed by the service. This is separate from ordinary Hebbian strengthening among a memory's own concepts. ${serverNounCap} does not send query_concepts, so live calls through this tool report 0; asynchronous acknowledgements also report 0.`),
|
|
1338
|
+
},
|
|
1339
|
+
annotations: {
|
|
1340
|
+
title: "Rate Memory Helpfulness",
|
|
1341
|
+
readOnlyHint: false,
|
|
1342
|
+
// NOT destructive (owner, 2026-09-21: "only deletion is destructive;
|
|
1343
|
+
// rating is a good action"). A rating moves the memory's valence and
|
|
1344
|
+
// the weights of its associations, which decide how it ranks; the
|
|
1345
|
+
// saved text, its concepts and its domain are untouched, and later
|
|
1346
|
+
// ratings keep moving those scores. Until 0.11 this said true, citing
|
|
1347
|
+
// the spec's "destructive update" to stored state. A client that asks
|
|
1348
|
+
// for confirmation on destructive tools would then have asked before
|
|
1349
|
+
// every rating, taxing the one signal the ranking learns from. The
|
|
1350
|
+
// engine still replaces the previous scores on each rating; keeping
|
|
1351
|
+
// every rating so any one can be undone is planned there, and the
|
|
1352
|
+
// 0.11 CHANGELOG entry says so until it ships.
|
|
1353
|
+
destructiveHint: false,
|
|
1354
|
+
idempotentHint: false,
|
|
1355
|
+
openWorldHint: false,
|
|
1356
|
+
},
|
|
1357
|
+
}, async ({ memory_ids, atom_ids: legacyIds, outcome, domain }) => {
|
|
1358
|
+
// Both names at once is ambiguous (which list did the caller mean?), so
|
|
1359
|
+
// it is refused rather than resolved by a silent preference.
|
|
1360
|
+
if (memory_ids !== undefined && legacyIds !== undefined) {
|
|
1361
|
+
return {
|
|
1362
|
+
isError: true,
|
|
1363
|
+
content: [
|
|
1364
|
+
{
|
|
1365
|
+
type: "text",
|
|
1366
|
+
text: "Pass the ids as memory_ids only. atom_ids is its old name, still " +
|
|
1367
|
+
"accepted on its own, but both at once is ambiguous. Nothing was rated.",
|
|
1368
|
+
},
|
|
1369
|
+
],
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
const atom_ids = memory_ids ?? legacyIds;
|
|
1373
|
+
if (atom_ids === undefined) {
|
|
1374
|
+
return {
|
|
1375
|
+
isError: true,
|
|
1376
|
+
content: [
|
|
1377
|
+
{
|
|
1378
|
+
type: "text",
|
|
1379
|
+
text: "memory_feedback needs memory_ids: the ids from the memory_read results " +
|
|
1380
|
+
"you are rating. Nothing was rated.",
|
|
1381
|
+
},
|
|
1382
|
+
],
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
// `atom_ids` below is the ENGINE's field name for the same list; the
|
|
1386
|
+
// wire contract is unchanged. `domain` goes through `searchedScope`, as
|
|
1387
|
+
// on memory_read and memory_list_recent: an empty string counts as no
|
|
1388
|
+
// domain and is not sent, so a call without one is byte-identical to one
|
|
1389
|
+
// from before 0.11 and core applies its own default. Any other value is
|
|
1390
|
+
// sent untouched.
|
|
1391
|
+
const scope = searchedScope(domain);
|
|
1392
|
+
const r = await apiFetch("/memory/feedback", {
|
|
1393
|
+
method: "POST",
|
|
1394
|
+
body: JSON.stringify(scope === undefined ? { atom_ids, outcome } : { atom_ids, outcome, domain: scope }),
|
|
1395
|
+
});
|
|
1396
|
+
// A FIELD THE SERVER DID NOT SEND IS UNKNOWN, NOT ZERO — the rule
|
|
1397
|
+
// memory_stats already applies with `num()`, broken here by
|
|
1398
|
+
// `r?.updated_count ?? 0` in three directions at once:
|
|
1399
|
+
//
|
|
1400
|
+
// 1. A 200 with the field absent, an explicit null, and a 204 (which
|
|
1401
|
+
// apiFetch turns into `{}`) all became 0, and 0 prints "No feedback
|
|
1402
|
+
// was recorded" plus three causes for it — an absence claim read out
|
|
1403
|
+
// of a body that carried no claim. Under core's async path the
|
|
1404
|
+
// rating may well have been applied while the ack said nothing.
|
|
1405
|
+
// 2. A string "0" is not 0, so `??` passed it straight through and the
|
|
1406
|
+
// ±1 branches printed "The service reports 0 memories updated — they
|
|
1407
|
+
// should surface sooner next time": two clauses contradicting each
|
|
1408
|
+
// other in one sentence.
|
|
1409
|
+
// 3. Nothing rejected a negative: "reports -2 memories updated".
|
|
1410
|
+
//
|
|
1411
|
+
// So: usable means a non-negative integer. Anything else is UNKNOWN and
|
|
1412
|
+
// gets its own sentence, which diagnoses nothing — the causes of a miss
|
|
1413
|
+
// belong to a reported zero, not to a number we never received.
|
|
1414
|
+
const reported = r?.updated_count;
|
|
1415
|
+
const count =
|
|
1416
|
+
// isSafeInteger, not isInteger: the output schema is z.number().int(),
|
|
1417
|
+
// and zod 4 rejects an integer above 2^53 - 1, so such a count would
|
|
1418
|
+
// turn the whole reply into an SDK validation error (review, 2026-09-23).
|
|
1419
|
+
typeof reported === "number" && Number.isSafeInteger(reported) && reported >= 0
|
|
1420
|
+
? reported
|
|
1421
|
+
: undefined;
|
|
1422
|
+
// Structured twin of the text's average-valence clause built further
|
|
1423
|
+
// below (`the service reports their average valence is now …`): the
|
|
1424
|
+
// RAW number, not the two-decimal string. Computed here, ahead of that
|
|
1425
|
+
// clause, because it is also carried on the count===0 branch just
|
|
1426
|
+
// below, which returns before the later clause exists. Same rule as
|
|
1427
|
+
// the text: a value that is not a finite number is unknown and absent
|
|
1428
|
+
// from structuredContent, never defaulted to 0.
|
|
1429
|
+
const avgValenceRaw = r?.avg_valence;
|
|
1430
|
+
const avgValenceStructured = typeof avgValenceRaw === "number" && Number.isFinite(avgValenceRaw)
|
|
1431
|
+
? avgValenceRaw
|
|
1432
|
+
: undefined;
|
|
1433
|
+
// Structured twin of `coactivation_edges`, left out of the TEXT below
|
|
1434
|
+
// for the reason the comment on `valence` gives (this tool sends no
|
|
1435
|
+
// query_concepts, so the number is always 0 here and a sentence about
|
|
1436
|
+
// it would report nothing) but not out of structuredContent, where the
|
|
1437
|
+
// connector's schema carries it as a genuine data field, on every
|
|
1438
|
+
// path, the count===0 one included (review, 2026-09-23). Forwarded
|
|
1439
|
+
// only when it is a non-negative integer, the shape the schema itself
|
|
1440
|
+
// requires, so a value core could never send in that shape is simply
|
|
1441
|
+
// absent here rather than turning this whole reply into an SDK
|
|
1442
|
+
// "Output validation error".
|
|
1443
|
+
const coactivationRaw = r?.coactivation_edges;
|
|
1444
|
+
const coactivationEdges = typeof coactivationRaw === "number" &&
|
|
1445
|
+
Number.isSafeInteger(coactivationRaw) &&
|
|
1446
|
+
coactivationRaw >= 0
|
|
1447
|
+
? coactivationRaw
|
|
1448
|
+
: undefined;
|
|
1449
|
+
// Direction is echoed for every outcome, including the ones with no count
|
|
1450
|
+
// to report: the same four words for +1 and -1 gave a caller no evidence
|
|
1451
|
+
// the loop did anything, which is why nobody calls it twice.
|
|
1452
|
+
const sent = outcome > 0
|
|
1453
|
+
? `Rating sent: +${outcome} (helpful).`
|
|
1454
|
+
: outcome < 0
|
|
1455
|
+
? `Rating sent: ${outcome} (unhelpful).`
|
|
1456
|
+
: "Rating sent: 0.";
|
|
1457
|
+
// Zero has no effect clause to offer — promising "this shifts how they
|
|
1458
|
+
// rank" for outcome 0 would be a claim we cannot make (dogfooding saw a
|
|
1459
|
+
// neutral rating move a score UP by about five points, CodeRabbit #65) —
|
|
1460
|
+
// so it offers the one thing that is actionable instead.
|
|
1461
|
+
//
|
|
1462
|
+
// WHAT 0 ACTUALLY DOES, restated against core#493 (merged 2026-08-13). The
|
|
1463
|
+
// valence step used to be `sign(outcome) * |prediction error|`, so outcome
|
|
1464
|
+
// 0 took the POSITIVE branch and pushed valence UP — which is what
|
|
1465
|
+
// dogfooding saw, and what the previous version of this comment recorded.
|
|
1466
|
+
// That is no longer true: core now uses the SIGNED error,
|
|
1467
|
+
// `pe = outcome - valence` (memory_engine.py:5167-5169), so a 0 against a
|
|
1468
|
+
// positive valence moves it DOWN, toward neutral. Either way 0 is not a
|
|
1469
|
+
// no-op and the line must not imply one — but the old explanation is now
|
|
1470
|
+
// backwards, and it ships verbatim inside dist/index.js, so it cannot be
|
|
1471
|
+
// left to rot in a comment.
|
|
1472
|
+
const pickADirection = outcome === 0
|
|
1473
|
+
? " Use +1 (helpful) or -1 (harmful/wrong) to express a clear direction."
|
|
1474
|
+
: "";
|
|
1475
|
+
if (count === undefined) {
|
|
1476
|
+
// OD-8 (owner, 2026-09-23): this reply is now `isError`, not a
|
|
1477
|
+
// silent non-error text. Once this tool declares an outputSchema
|
|
1478
|
+
// (S6), the SDK's own `validateToolOutput` (see the comment on
|
|
1479
|
+
// `structured()` above) rejects a non-error result with no
|
|
1480
|
+
// `structuredContent`, and `updated_count` is REQUIRED in that
|
|
1481
|
+
// schema (matching core's own FeedbackResponseSchema), so there is
|
|
1482
|
+
// no honest value to put there for a body that sent none. `isError`
|
|
1483
|
+
// is the one shape the SDK exempts from that check, so it is the
|
|
1484
|
+
// shape this reply takes now. The sentence itself is unchanged,
|
|
1485
|
+
// "do not re-send the same rating" included.
|
|
1486
|
+
return {
|
|
1487
|
+
isError: true,
|
|
1488
|
+
content: [
|
|
1489
|
+
{
|
|
1490
|
+
type: "text",
|
|
1491
|
+
text: `${sent} The service accepted the call but did not report how many ` +
|
|
1492
|
+
`memories it updated, so whether any changed is unknown from here. ` +
|
|
1493
|
+
`That is not evidence of a failure — do not re-send the same rating ` +
|
|
1494
|
+
`on the strength of it.` + pickADirection,
|
|
1495
|
+
},
|
|
1496
|
+
],
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
// "Feedback recorded for 0 memories." is one character away from the
|
|
1500
|
+
// success line and reads like one. But the DIAGNOSIS matters as much as
|
|
1501
|
+
// the fact: an earlier version of this branch blamed deletion, which is
|
|
1502
|
+
// usually the wrong cause (review, 2026-08-08).
|
|
1503
|
+
//
|
|
1504
|
+
// Core resolves the feedback org from `domain`, defaulting to the
|
|
1505
|
+
// caller's own store. So the ordinary way to get zero is to rate atoms
|
|
1506
|
+
// that live somewhere else: read a room, take the ids off the `id:`
|
|
1507
|
+
// lines, rate them without the room's address (or with another room's),
|
|
1508
|
+
// and every one silently misses. The atoms exist, the ids are valid, and
|
|
1509
|
+
// telling the caller they were deleted sends them to look for a problem
|
|
1510
|
+
// that isn't there. Before 0.11 there was no `domain` to pass at all.
|
|
1511
|
+
if (count === 0) {
|
|
1512
|
+
return structured("No feedback was recorded — none of those ids matched a memory " +
|
|
1513
|
+
`${feedbackScope(scope)}. ${feedbackMissCauses(scope)}`, {
|
|
1514
|
+
updated_count: 0,
|
|
1515
|
+
...(avgValenceStructured === undefined ? {} : { avg_valence: avgValenceStructured }),
|
|
1516
|
+
...(coactivationEdges === undefined ? {} : { coactivation_edges: coactivationEdges }),
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
// WHOSE NUMBER THIS IS (#68). `updated_count` is the count of memories the
|
|
1520
|
+
// service says it touched — and that is true only while core runs
|
|
1521
|
+
// `feedback_async = False`. Under async it returns the number of ids
|
|
1522
|
+
// SUBMITTED, not applied (core schemas.py:826-838, memory_engine.py:4898-4902),
|
|
1523
|
+
// and nothing in the response says which mode ran. A server-side config
|
|
1524
|
+
// flip would therefore turn a confident sentence here false on every
|
|
1525
|
+
// installed client, silently. So the number is reported as the SERVICE'S
|
|
1526
|
+
// report rather than asserted as an outcome this client verified. The
|
|
1527
|
+
// hosted connector took the same correction on 2026-08-13
|
|
1528
|
+
// (mnemoverse-mcp-remote#38); one number, one degree of confidence,
|
|
1529
|
+
// whichever surface a model reaches it through.
|
|
1530
|
+
const noun = `${count} memor${count === 1 ? "y" : "ies"}`;
|
|
1531
|
+
const effect = outcome > 0
|
|
1532
|
+
? " — they should surface sooner next time."
|
|
1533
|
+
: outcome < 0
|
|
1534
|
+
// No fade. 0.9.1 (#95) withdrew "lets it fade" as false — nothing
|
|
1535
|
+
// time-decays, nothing is auto-deleted, and deletion has been
|
|
1536
|
+
// administrative-only since 0.9.0 — and this line kept promising it
|
|
1537
|
+
// after the release that deleted the claim from the README.
|
|
1538
|
+
? " — they should rank lower next time. Out-ranked, not erased: nothing is deleted and nothing decays with time."
|
|
1539
|
+
: ".";
|
|
1540
|
+
// WHAT THE COUNT IS NOT: a guarantee that every id landed. `atom_ids.length`
|
|
1541
|
+
// was never compared with it, so five ids and `updated_count: 2` printed
|
|
1542
|
+
// the unqualified success line and three silent misses — the typical shape
|
|
1543
|
+
// of the room case, where half the ids came off a read of a room this
|
|
1544
|
+
// call did not address. A SHORTFALL can only come from core's sync path (the async
|
|
1545
|
+
// ack is exactly `len(atom_ids)`, memory_engine.py:4898-4902), where the
|
|
1546
|
+
// number is the authoritative count of atoms that existed — so the same
|
|
1547
|
+
// causes as the zero branch apply, at a smaller scale, and the string is
|
|
1548
|
+
// shared so the two cannot drift apart.
|
|
1549
|
+
//
|
|
1550
|
+
// An EXCESS is not a shape core produces at all (sync counts one per id
|
|
1551
|
+
// that resolved, async counts the ids). But MNEMOVERSE_API_URL points
|
|
1552
|
+
// wherever it is pointed, and "reports 9 memories updated" for one id
|
|
1553
|
+
// would otherwise read as nine of the caller's memories rated. Say what it
|
|
1554
|
+
// cannot be rather than pass it off as a per-id result.
|
|
1555
|
+
const idsSent = `${atom_ids.length} id${atom_ids.length === 1 ? "" : "s"} you sent`;
|
|
1556
|
+
const mismatch = count < atom_ids.length
|
|
1557
|
+
? ` That is fewer than the ${idsSent}: ${atom_ids.length - count} of them ` +
|
|
1558
|
+
`matched nothing ${feedbackScope(scope)}. ${feedbackMissCauses(scope)}`
|
|
1559
|
+
: count > atom_ids.length
|
|
1560
|
+
? ` That is more than the ${idsSent}, so it cannot be a per-id result — ` +
|
|
1561
|
+
`read it as the service's own tally, not as how many of your memories ` +
|
|
1562
|
+
`were rated.`
|
|
1563
|
+
: "";
|
|
1564
|
+
// AVERAGE VALENCE, which core returns and this tool dropped until 0.11
|
|
1565
|
+
// (the hosted connector already passed it on). It is the mean valence of
|
|
1566
|
+
// the memories the rating reached, AFTER the rating: the one number that
|
|
1567
|
+
// shows the rating moved something. Same degree of confidence as the
|
|
1568
|
+
// count, and the same rule: a value that is not a finite number is
|
|
1569
|
+
// unknown and prints nothing, never 0. Core's async mode acks with 0
|
|
1570
|
+
// before the worker runs (memory_engine.py `feedback` docstring), which
|
|
1571
|
+
// is why this is the service's report too; in production every rating
|
|
1572
|
+
// observed so far took the synchronous path (the asynchronous
|
|
1573
|
+
// acknowledgement is reachable but was not seen in the checked window).
|
|
1574
|
+
//
|
|
1575
|
+
// coactivation_edges is left out of the text on purpose: core links
|
|
1576
|
+
// concepts only when the request carries query_concepts, which this tool
|
|
1577
|
+
// does not send, so the number is always 0 here and a sentence about it
|
|
1578
|
+
// would report nothing.
|
|
1579
|
+
//
|
|
1580
|
+
// toFixed keeps the sign of a value that rounds to zero: (-0.001) prints
|
|
1581
|
+
// "-0.00", which reads as a negative valence. Shown as "0.00" instead
|
|
1582
|
+
// (CodeRabbit on #146).
|
|
1583
|
+
const avg = r?.avg_valence;
|
|
1584
|
+
const valence = typeof avg === "number" && Number.isFinite(avg)
|
|
1585
|
+
? ` The service reports their average valence is now ${avg.toFixed(2).replace(/^-0\.00$/, "0.00")} (on a scale from -1 to 1).`
|
|
1586
|
+
: "";
|
|
1587
|
+
// Order: what was sent, what the service reported, what that means
|
|
1588
|
+
// for the ids — then the advice. Putting `pickADirection` before the
|
|
1589
|
+
// mismatch clause interrupted the report with a suggestion and
|
|
1590
|
+
// resumed it afterwards.
|
|
1591
|
+
return structured(`${sent} The service reports ${noun} updated${effect}${valence}${mismatch}${pickADirection}`, {
|
|
1592
|
+
updated_count: count,
|
|
1593
|
+
...(avgValenceStructured === undefined ? {} : { avg_valence: avgValenceStructured }),
|
|
1594
|
+
...(coactivationEdges === undefined ? {} : { coactivation_edges: coactivationEdges }),
|
|
1595
|
+
});
|
|
1596
|
+
});
|
|
1597
|
+
// --- Tool: memory_stats ---
|
|
1598
|
+
server.registerTool("memory_stats", {
|
|
1599
|
+
description: "Get an overview of the stored memory: total count, episodes vs consolidated prototypes, number of learned associations, the list of domains, and average quality scores. This memory is shared across all AI tools the user has connected to Mnemoverse. Use it to orient yourself, to confirm the exact domain name before writing to it, or when the user asks what you remember. Read-only — changes nothing.",
|
|
1600
|
+
inputSchema: {},
|
|
1601
|
+
// `memory_count` and `domains` are copied from the connector's
|
|
1602
|
+
// `memoryStatsOutput` (mnemoverse-mcp-remote, src/tools/index.ts), field
|
|
1603
|
+
// for field and description for description, under the CONNECTOR's
|
|
1604
|
+
// naming rather than core's (`total_atoms`): the same structured
|
|
1605
|
+
// consumer can read both servers, and a data field spelled differently
|
|
1606
|
+
// between them would defeat the point of one shared shape (decision Q3,
|
|
1607
|
+
// owner, 2026-09-23). Both are required, matching the connector: a body
|
|
1608
|
+
// without a usable value for either is not core's answer (see the guard
|
|
1609
|
+
// in the handler below). The other five fields are this package's own
|
|
1610
|
+
// addition, the rest of what this tool's own text already reports,
|
|
1611
|
+
// each optional, present only when core sent a usable number for it.
|
|
1612
|
+
outputSchema: {
|
|
1613
|
+
memory_count: z
|
|
1614
|
+
.number()
|
|
1615
|
+
.int()
|
|
1616
|
+
.nonnegative()
|
|
1617
|
+
.describe("Number of saved memories."),
|
|
1618
|
+
domains: z.array(z.string()).describe("User-defined memory domains."),
|
|
1619
|
+
episodes: z
|
|
1620
|
+
.number()
|
|
1621
|
+
.int()
|
|
1622
|
+
.nonnegative()
|
|
1623
|
+
.optional()
|
|
1624
|
+
.describe("Number of episodic (not yet consolidated) memories."),
|
|
1625
|
+
prototypes: z
|
|
1626
|
+
.number()
|
|
1627
|
+
.int()
|
|
1628
|
+
.nonnegative()
|
|
1629
|
+
.optional()
|
|
1630
|
+
.describe("Number of consolidated prototype memories."),
|
|
1631
|
+
hebbian_edges: z
|
|
1632
|
+
.number()
|
|
1633
|
+
.int()
|
|
1634
|
+
.nonnegative()
|
|
1635
|
+
.optional()
|
|
1636
|
+
.describe("Number of Hebbian concept-to-concept links, learned from concepts that occur together as memories are stored and used."),
|
|
1637
|
+
avg_valence: z
|
|
1638
|
+
.number()
|
|
1639
|
+
.optional()
|
|
1640
|
+
.describe("Average valence of stored memories: how well recalls turned out, on a scale from -1 to 1."),
|
|
1641
|
+
avg_importance: z
|
|
1642
|
+
.number()
|
|
1643
|
+
.optional()
|
|
1644
|
+
.describe("Average importance of stored memories, on a scale from 0 to 1."),
|
|
1645
|
+
},
|
|
1646
|
+
annotations: {
|
|
1647
|
+
title: "Memory Statistics",
|
|
1648
|
+
readOnlyHint: true,
|
|
1649
|
+
destructiveHint: false,
|
|
1650
|
+
idempotentHint: true,
|
|
1651
|
+
openWorldHint: false,
|
|
1652
|
+
},
|
|
1653
|
+
}, async () => {
|
|
1654
|
+
const r = await apiFetch("/memory/stats");
|
|
1655
|
+
// REQUIRED-FIELD GUARD (OD-8 precedent, owner, 2026-09-23): `memory_count`
|
|
1656
|
+
// and `domains` are REQUIRED in the outputSchema above, matching the
|
|
1657
|
+
// connector's own `memoryStatsOutput`, so a body without a usable
|
|
1658
|
+
// `total_atoms` (a non-negative safe integer) or without an ARRAY
|
|
1659
|
+
// `domains` is not core's answer and there is no honest
|
|
1660
|
+
// structuredContent to build for it. Before this schema existed, both
|
|
1661
|
+
// degraded silently into this tool's own "unknown" numbers / "none
|
|
1662
|
+
// reported" domains text; now they are `isError`, the same shape every
|
|
1663
|
+
// other unreadable-answer reply in this file takes. isSafeInteger, not
|
|
1664
|
+
// isInteger, for the same reason memory_feedback's count check gives:
|
|
1665
|
+
// the schema is z.number().int(), and zod 4 rejects an integer above
|
|
1666
|
+
// 2^53 - 1.
|
|
1667
|
+
const totalAtomsRaw = r?.total_atoms;
|
|
1668
|
+
const memoryCount = typeof totalAtomsRaw === "number" &&
|
|
1669
|
+
Number.isSafeInteger(totalAtomsRaw) &&
|
|
1670
|
+
totalAtomsRaw >= 0
|
|
1671
|
+
? totalAtomsRaw
|
|
1672
|
+
: undefined;
|
|
1673
|
+
const domainsRaw = r?.domains;
|
|
1674
|
+
if (memoryCount === undefined || !Array.isArray(domainsRaw)) {
|
|
1675
|
+
return unreadableAnswerReply("The stats answer", "the memory store's statistics", "the store is empty");
|
|
1676
|
+
}
|
|
1677
|
+
// A field the server did not send is UNKNOWN, not zero. Rendering it as
|
|
1678
|
+
// "0" is the same class of lie as an empty search claiming emptiness:
|
|
1679
|
+
// "Associations: 0" reads as "this memory has learned nothing", which is
|
|
1680
|
+
// a strong and possibly false statement about the product itself.
|
|
1681
|
+
const num = (v) => (typeof v === "number" ? String(v) : "unknown");
|
|
1682
|
+
const dec = (v) => (typeof v === "number" ? v.toFixed(2) : "unknown");
|
|
1683
|
+
// THE surface this tool's own description sends the reader to, to
|
|
1684
|
+
// confirm the exact domain name before writing to it — so it has to be
|
|
1685
|
+
// able to answer that. (Until 2026-08-20 memory_delete_domain also sent
|
|
1686
|
+
// readers here for the same reason, before deletion was withdrawn to an
|
|
1687
|
+
// administrative REST-only operation; see CHANGELOG.)
|
|
1688
|
+
//
|
|
1689
|
+
// Quoting was tried, then withdrawn as a fix that wasn't one: safeInline
|
|
1690
|
+
// collapses and trims whitespace BEFORE the quotes go on, so " engineering"
|
|
1691
|
+
// and "engineering" printed identically and the list read like a tool bug.
|
|
1692
|
+
// The conclusion drawn then — "revealing whitespace needs escaping rather
|
|
1693
|
+
// than quoting" — was right; this is that escaping. Each name is a JSON
|
|
1694
|
+
// string literal, so a leading space, a no-break space, a newline and a
|
|
1695
|
+
// zero-width character are all visible, Cyrillic stays Cyrillic, and two
|
|
1696
|
+
// different names can no longer print as one. Assembly lives in
|
|
1697
|
+
// src/names.ts, where it is unit-tested against those exact inputs.
|
|
1698
|
+
//
|
|
1699
|
+
// The assembly also BOUNDS the list (MAX_DOMAIN_LIST_CHARS), which is what
|
|
1700
|
+
// makes this handler respect the 25K-token cap every other surface already
|
|
1701
|
+
// respected. The line is linear in the number of stores and nothing bounded
|
|
1702
|
+
// it: 4,000 domains rendered past 100,000 characters — deterministically,
|
|
1703
|
+
// with no hostile input involved. Bounding the LIST rather than leaning on
|
|
1704
|
+
// capResult alone is the point: capResult truncates from the END, so the
|
|
1705
|
+
// wall of names would have taken the average-quality line and the rooms
|
|
1706
|
+
// reminder down with it, leaving the answer nothing but names.
|
|
1707
|
+
const domains = formatDomainList(r?.domains);
|
|
1708
|
+
const text = [
|
|
1709
|
+
`Memories: ${num(r?.total_atoms)} (${num(r?.episodes)} episodes, ${num(r?.prototypes)} prototypes)`,
|
|
1710
|
+
// The gloss names the real mechanism. Core's `hebbian_edges` counts
|
|
1711
|
+
// concept-concept edges (api/schemas.py: "Number of Hebbian
|
|
1712
|
+
// concept-concept edges"), and every path that learns one links two
|
|
1713
|
+
// CONCEPTS: `strengthen` walks pairs within one atom's concept list at
|
|
1714
|
+
// write time and again on feedback, `co_activate` links query concepts
|
|
1715
|
+
// to result concepts on use. An earlier gloss said "links between
|
|
1716
|
+
// memories that get used together" — wrong unit (memories, not
|
|
1717
|
+
// concepts) and wrong trigger (an edge records co-occurrence, not two
|
|
1718
|
+
// memories being used together).
|
|
1719
|
+
`Associations: ${num(r?.hebbian_edges)} Hebbian edges — concept-to-concept links learned from concepts that occur together as memories are stored and used`,
|
|
1720
|
+
`Domains: ${domains}`,
|
|
1721
|
+
`Avg quality: valence ${dec(r?.avg_valence)} (how well recalls turned out, -1..1), importance ${dec(r?.avg_importance)} (0..1)`,
|
|
1722
|
+
"",
|
|
1723
|
+
"Counts cover your own domains. Shared rooms are separate stores and are not included — see memory_list_rooms.",
|
|
1724
|
+
].join("\n");
|
|
1725
|
+
// STRUCTURED TWIN of the optional numeric fields, following the same
|
|
1726
|
+
// rule memory_feedback's does: a value core did not send, or sent in a
|
|
1727
|
+
// shape the schema could not hold, is absent from structuredContent,
|
|
1728
|
+
// never defaulted to 0. Int fields use isSafeInteger for the reason the
|
|
1729
|
+
// guard above gives (the schema is z.number().int(), and zod 4 rejects
|
|
1730
|
+
// an integer above 2^53 - 1); the two averages use isFinite, so an
|
|
1731
|
+
// Infinity smuggled through a raw body (e.g. avg_valence: 1e400) never
|
|
1732
|
+
// reaches a structured consumer as data. The TEXT above keeps printing
|
|
1733
|
+
// whatever num()/dec() print for the same malformed value, and that
|
|
1734
|
+
// text/data divergence is disclosed in the CHANGELOG, as S5 disclosed
|
|
1735
|
+
// its cursor semantics.
|
|
1736
|
+
const safeIntOrUndefined = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : undefined;
|
|
1737
|
+
const finiteOrUndefined = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
1738
|
+
const episodesStructured = safeIntOrUndefined(r?.episodes);
|
|
1739
|
+
const prototypesStructured = safeIntOrUndefined(r?.prototypes);
|
|
1740
|
+
const hebbianEdgesStructured = safeIntOrUndefined(r?.hebbian_edges);
|
|
1741
|
+
const avgValenceStructured = finiteOrUndefined(r?.avg_valence);
|
|
1742
|
+
const avgImportanceStructured = finiteOrUndefined(r?.avg_importance);
|
|
1743
|
+
// DOMAINS FOR structuredContent: FILTERED, not an error and not zeroed
|
|
1744
|
+
// (decisions S7-1/S7-2, owner, 2026-09-23). A non-string element is
|
|
1745
|
+
// dropped rather than turning the whole reply into isError, and the
|
|
1746
|
+
// TEXT above already counts it in formatDomainList's "not shown, cannot
|
|
1747
|
+
// be printed exactly" clause, so the count is not silently lost, only
|
|
1748
|
+
// moved off the surface a structured consumer reads. What a structured
|
|
1749
|
+
// consumer would silently lose is the fact that anything was dropped at
|
|
1750
|
+
// all, so the drop is also reported once on stderr, in this package's
|
|
1751
|
+
// existing startup-diagnostic style ("Mnemoverse: ..." in src/index.ts),
|
|
1752
|
+
// the operator's channel rather than the model's: putting this in the
|
|
1753
|
+
// tool text would surface an implementation detail to the agent reading it.
|
|
1754
|
+
const domainsStructured = domainsRaw.filter((d) => typeof d === "string");
|
|
1755
|
+
const droppedDomains = domainsRaw.length - domainsStructured.length;
|
|
1756
|
+
if (droppedDomains > 0) {
|
|
1757
|
+
console.error(`Mnemoverse: memory_stats dropped ${droppedDomains} non-string domain ` +
|
|
1758
|
+
`entr${droppedDomains === 1 ? "y" : "ies"} from structuredContent.domains ` +
|
|
1759
|
+
`(still counted in the text's "not shown" total).`);
|
|
1760
|
+
}
|
|
1761
|
+
return structured(
|
|
1762
|
+
// Array.isArray, not `?? []`: `domains` is typed as a string[] but
|
|
1763
|
+
// arrives over the wire, and spreading a non-iterable object would
|
|
1764
|
+
// throw here — turning a malformed payload into a dead tool instead
|
|
1765
|
+
// of the "none reported" it degrades to two lines up.
|
|
1766
|
+
//
|
|
1767
|
+
// capResult is the second belt, not the mechanism: the domain list is
|
|
1768
|
+
// already bounded above, so this only fires if some future line grows
|
|
1769
|
+
// unboundedly. It stays because this was the ONE tool result with no
|
|
1770
|
+
// cap at all, and "every surface is capped" is worth being an
|
|
1771
|
+
// invariant rather than an argument about which surfaces can grow.
|
|
1772
|
+
// Its hint names a control this no-input tool actually has — none —
|
|
1773
|
+
// rather than the read tool's "use a more specific query".
|
|
1774
|
+
//
|
|
1775
|
+
// Legend AFTER the cap, as everywhere else: it must describe the names
|
|
1776
|
+
// that SURVIVED, and it is appended at the end, where the cap cuts.
|
|
1777
|
+
withDomainEscapeLegend(capResult(text, "The domain list was truncated — some domain names are not shown."), ...(Array.isArray(r?.domains) ? r.domains : [])), {
|
|
1778
|
+
memory_count: memoryCount,
|
|
1779
|
+
domains: domainsStructured,
|
|
1780
|
+
...(episodesStructured === undefined ? {} : { episodes: episodesStructured }),
|
|
1781
|
+
...(prototypesStructured === undefined ? {} : { prototypes: prototypesStructured }),
|
|
1782
|
+
...(hebbianEdgesStructured === undefined
|
|
1783
|
+
? {}
|
|
1784
|
+
: { hebbian_edges: hebbianEdgesStructured }),
|
|
1785
|
+
...(avgValenceStructured === undefined ? {} : { avg_valence: avgValenceStructured }),
|
|
1786
|
+
...(avgImportanceStructured === undefined
|
|
1787
|
+
? {}
|
|
1788
|
+
: { avg_importance: avgImportanceStructured }),
|
|
1789
|
+
});
|
|
1790
|
+
});
|
|
1791
|
+
// --- Tool: memory_create_room ---
|
|
1792
|
+
//
|
|
1793
|
+
// CORE'S `next_steps` IS NOT PRINTED, on purpose (checked 2026-09-22, step
|
|
1794
|
+
// 3c). Core returns it on create and on join, and the closed
|
|
1795
|
+
// mnemoverse-mcp-remote#29 proposed echoing it. It is written for REST
|
|
1796
|
+
// callers ("POST /api/v1/memory/write ... mint an invite with POST
|
|
1797
|
+
// .../invites"), which would steer a model toward calls it does not have;
|
|
1798
|
+
// on join it tells every member to write, a read-only one included; and it
|
|
1799
|
+
// carries the owner-chosen room name raw, which this file prints only
|
|
1800
|
+
// through roomNamePhrase (CN-032). The usage lines below say the same
|
|
1801
|
+
// things in MCP terms, scope-gated on join.
|
|
1802
|
+
// OUTPUT SCHEMAS for the three room tools (S8, structured-output plan):
|
|
1803
|
+
// `room_id`/`address`/`room_address`/`scope` copied from the connector's
|
|
1804
|
+
// `roomCreatedOutput`/`roomInviteOutput`/`roomJoinedOutput`
|
|
1805
|
+
// (mnemoverse-mcp-remote, src/tools/index.ts), field for field and
|
|
1806
|
+
// description for description.
|
|
1807
|
+
//
|
|
1808
|
+
// OD-13 (owner, 2026-09-23): several fields the connector marks required
|
|
1809
|
+
// are OPTIONAL here: `name` on create and join, `scope`/`already_member`
|
|
1810
|
+
// on join, and `code`/`scope`/`room_address`/`expires_at` on invite. The
|
|
1811
|
+
// connector's own core client types those fields as always-present; this
|
|
1812
|
+
// package treats every wire value as untyped (the general rule this whole
|
|
1813
|
+
// file follows) and already has a non-degraded THREE-state phrase for a
|
|
1814
|
+
// room name core did not send (`roomNamePhrase`) and for a join whose
|
|
1815
|
+
// scope core did not report (`roomScopeVerdict`'s "unspecified" arm), so
|
|
1816
|
+
// "core sent no usable value for this field" is an existing, honestly
|
|
1817
|
+
// representable outcome here, not an error, and the schema says so by
|
|
1818
|
+
// making the field optional rather than forcing a fabricated placeholder
|
|
1819
|
+
// into a field declared required.
|
|
1820
|
+
//
|
|
1821
|
+
// `join_url`/`share_message` on invite stay as today: at least one of the
|
|
1822
|
+
// two must be usable or the call is `isError` (unchanged from before this
|
|
1823
|
+
// schema existed), so `share_message` is the one guaranteed field, built
|
|
1824
|
+
// from the SAME fallback the text already prints (share_message when core
|
|
1825
|
+
// sent one, else join_url), and `join_url` itself is optional, present
|
|
1826
|
+
// only when core actually returned a string for it.
|
|
1827
|
+
// --- Tool: memory_create_room ---
|
|
1828
|
+
server.registerTool("memory_create_room", {
|
|
1829
|
+
description: "Create a SHARED memory room — a space OTHER people's assistants can read, and write too when their invite granted read_write (the default scope), across Claude/ChatGPT/Cursor. Use when the user wants to share context or collaborate with someone else (e.g. 'make a room for me and Olya'). Returns the room's address; pass that address as the `domain` on memory_write/memory_read to use it, and on memory_list_recent to catch up on what others added. To bring someone in, call memory_invite_to_room next.",
|
|
1830
|
+
inputSchema: {
|
|
1831
|
+
name: z
|
|
1832
|
+
.string()
|
|
1833
|
+
.min(CORE_LIMITS.roomName.minLength)
|
|
1834
|
+
.max(CORE_LIMITS.roomName.maxLength)
|
|
1835
|
+
.describe("Room name, unique within your account (e.g. 'me-and-olya')."),
|
|
1836
|
+
description: z
|
|
1837
|
+
.string()
|
|
1838
|
+
.max(CORE_LIMITS.roomDescription.maxLength)
|
|
1839
|
+
.optional()
|
|
1840
|
+
.describe("Optional description of the room."),
|
|
1841
|
+
},
|
|
1842
|
+
outputSchema: {
|
|
1843
|
+
room_id: z
|
|
1844
|
+
.string()
|
|
1845
|
+
.describe("The room's id (room_...); pass to memory_invite_to_room."),
|
|
1846
|
+
address: z
|
|
1847
|
+
.string()
|
|
1848
|
+
.describe("Domain address (xroom:<id>); pass as `domain` on read/write."),
|
|
1849
|
+
name: z.string().optional().describe("The room name as stored."),
|
|
1850
|
+
},
|
|
1851
|
+
annotations: {
|
|
1852
|
+
title: "Create shared room",
|
|
1853
|
+
readOnlyHint: false,
|
|
1854
|
+
destructiveHint: false,
|
|
1855
|
+
idempotentHint: false,
|
|
1856
|
+
openWorldHint: false,
|
|
1857
|
+
},
|
|
1858
|
+
}, async ({ name, description }) => {
|
|
1859
|
+
const r = await apiFetch("/memory/rooms", {
|
|
1860
|
+
method: "POST",
|
|
1861
|
+
body: JSON.stringify({ name, description }),
|
|
1862
|
+
});
|
|
1863
|
+
// The name is echoed back to the caller who CHOSE it, so it is printed
|
|
1864
|
+
// exactly (src/names.ts): safeInline turned `memory_create_room({name:
|
|
1865
|
+
// "проект"})` into `Created shared room ""` — an echo claiming the caller
|
|
1866
|
+
// named their room the empty string.
|
|
1867
|
+
const rawName = r?.name ?? name;
|
|
1868
|
+
const roomName = roomNamePhrase(rawName);
|
|
1869
|
+
const address = safeInline(r?.address);
|
|
1870
|
+
const roomId = safeInline(r?.room_id);
|
|
1871
|
+
// If core returned no usable id (empty body / sanitized away), don't print
|
|
1872
|
+
// broken `domain=""` guidance — say so instead (Copilot).
|
|
1873
|
+
//
|
|
1874
|
+
// GATE (S8-1, owner, 2026-09-23): core's RoomCreatedSchema sends
|
|
1875
|
+
// room_id alongside address on every create, so `roomId` now joins
|
|
1876
|
+
// `address` in the condition that picks this branch: a body missing
|
|
1877
|
+
// a usable one of either is not core's answer, the same class of
|
|
1878
|
+
// unreadable 2xx this file already refuses rather than describes.
|
|
1879
|
+
const text = address && roomId
|
|
1880
|
+
? `Created shared room ${roomName}. Address: ${address}\n` +
|
|
1881
|
+
`Use it now: pass domain="${address}" on memory_write / memory_read, and on memory_list_recent to catch up on what others added.\n` +
|
|
1882
|
+
(roomId
|
|
1883
|
+
? `To add someone: call memory_invite_to_room with room_id="${roomId}".`
|
|
1884
|
+
: "")
|
|
1885
|
+
: `Room ${roomName} was created but the server did not return a usable address — ` +
|
|
1886
|
+
`retry, or check that your API key is set.`;
|
|
1887
|
+
// Legend AFTER the cap (same rule as memory_read/memory_list_recent):
|
|
1888
|
+
// capResult cuts from the end, so a legend applied first would be the
|
|
1889
|
+
// first casualty; applied to the capped text it also drops itself when
|
|
1890
|
+
// the cap removed the only escaped name.
|
|
1891
|
+
const finalText = withDomainEscapeLegend(capResult(text), rawName);
|
|
1892
|
+
// Same gate as the text above: no usable address or no usable room_id
|
|
1893
|
+
// is not core's answer, and there is no honest structuredContent for
|
|
1894
|
+
// it either. The existing degrade sentence is the whole reply now,
|
|
1895
|
+
// with isError: true, instead of a 200-shaped "success" with nothing
|
|
1896
|
+
// a caller can act on.
|
|
1897
|
+
if (!address || !roomId) {
|
|
1898
|
+
return {
|
|
1899
|
+
content: [{ type: "text", text: finalText }],
|
|
1900
|
+
isError: true,
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
// STRUCTURED `name` comes from the RESPONSE only, never from the
|
|
1904
|
+
// request's `name` the text above falls back to: the schema says "as
|
|
1905
|
+
// stored", and a body that omits `name` gives this client no evidence
|
|
1906
|
+
// of what core stored, so the key is absent rather than an echo of the
|
|
1907
|
+
// caller's own spelling dressed up as core's answer (Copilot, review
|
|
1908
|
+
// round 2). The text keeps its fallback: it is written for the caller
|
|
1909
|
+
// who chose the name and stays byte-identical.
|
|
1910
|
+
const nameStructured = structuredText(r?.name, 200);
|
|
1911
|
+
return structured(finalText, {
|
|
1912
|
+
room_id: roomId,
|
|
1913
|
+
address,
|
|
1914
|
+
...(nameStructured === undefined ? {} : { name: nameStructured }),
|
|
1915
|
+
});
|
|
1916
|
+
});
|
|
1917
|
+
// --- Tool: memory_invite_to_room ---
|
|
1918
|
+
server.registerTool("memory_invite_to_room", {
|
|
1919
|
+
description: "Mint an invite for a room you own and get a ready-to-forward message. An invite is single-use by default; pass max_uses to let several people join with the same one. The user sends that message to the person they want to add (any messenger); the recipient opens the link or tells THEIR assistant the code to join. Use after memory_create_room, or whenever the user says 'invite <someone>' to an existing room.",
|
|
1920
|
+
inputSchema: {
|
|
1921
|
+
room_id: z
|
|
1922
|
+
.string()
|
|
1923
|
+
.min(1)
|
|
1924
|
+
.max(100)
|
|
1925
|
+
.describe("The room's id (room_...), from memory_create_room."),
|
|
1926
|
+
scope: z
|
|
1927
|
+
.enum(["read", "read_write"])
|
|
1928
|
+
.optional()
|
|
1929
|
+
.describe("Role the invitee gets — 'read' or 'read_write' (default read_write)."),
|
|
1930
|
+
expires_in_days: z
|
|
1931
|
+
.number()
|
|
1932
|
+
.int()
|
|
1933
|
+
.min(CORE_LIMITS.inviteExpiresInDays.minimum)
|
|
1934
|
+
.max(CORE_LIMITS.inviteExpiresInDays.maximum)
|
|
1935
|
+
.optional()
|
|
1936
|
+
.describe("Days until the invite expires (default 7)."),
|
|
1937
|
+
// Added in 0.11 (step 3c; the hosted connector already had it). Core
|
|
1938
|
+
// takes 1 to 1000, default 1. Both ends come from the contract now
|
|
1939
|
+
// (src/limits.ts), so an engine that widens the range widens this
|
|
1940
|
+
// parameter with it rather than being refused here.
|
|
1941
|
+
max_uses: z
|
|
1942
|
+
.number()
|
|
1943
|
+
.int()
|
|
1944
|
+
.min(CORE_LIMITS.inviteMaxUses.minimum)
|
|
1945
|
+
.max(CORE_LIMITS.inviteMaxUses.maximum)
|
|
1946
|
+
.optional()
|
|
1947
|
+
.describe("How many people may join with this invite (default 1, single-use)."),
|
|
1948
|
+
},
|
|
1949
|
+
outputSchema: {
|
|
1950
|
+
share_message: z.string().describe("Ready-to-forward invite text."),
|
|
1951
|
+
join_url: z.string().optional().describe("Landing URL the invitee can open to join."),
|
|
1952
|
+
code: z
|
|
1953
|
+
.string()
|
|
1954
|
+
.optional()
|
|
1955
|
+
.describe("The invite code (mnvr_...). Single-use by default, with a configurable use limit. Shown once."),
|
|
1956
|
+
scope: z.string().optional().describe("Role the invitee will get."),
|
|
1957
|
+
room_address: z
|
|
1958
|
+
.string()
|
|
1959
|
+
.optional()
|
|
1960
|
+
.describe("The room's domain address (xroom:<id>)."),
|
|
1961
|
+
expires_at: z.string().nullable().optional().describe("ISO 8601 expiry, or null."),
|
|
1962
|
+
},
|
|
1963
|
+
annotations: {
|
|
1964
|
+
title: "Invite to room",
|
|
1965
|
+
readOnlyHint: false,
|
|
1966
|
+
destructiveHint: false,
|
|
1967
|
+
idempotentHint: false,
|
|
1968
|
+
openWorldHint: false,
|
|
1969
|
+
},
|
|
1970
|
+
}, async ({ room_id, scope, expires_in_days, max_uses }) => {
|
|
1971
|
+
// JSON.stringify drops undefined keys, so a call without max_uses sends
|
|
1972
|
+
// exactly the body it sent before 0.11 and core applies its default of 1.
|
|
1973
|
+
const r = await apiFetch(`/memory/rooms/${encodeURIComponent(room_id)}/invites`, {
|
|
1974
|
+
method: "POST",
|
|
1975
|
+
body: JSON.stringify({ scope, expires_in_days, max_uses }),
|
|
1976
|
+
});
|
|
1977
|
+
// ONE selection feeds both surfaces (Copilot, review round 2): the
|
|
1978
|
+
// message the text forwards is core's share_message when the body has
|
|
1979
|
+
// one (its raw value, exactly as before this schema existed), else
|
|
1980
|
+
// join_url, else the "(no message returned)" sentence.
|
|
1981
|
+
const rawMessage = r?.share_message ?? r?.join_url;
|
|
1982
|
+
const text = capResult(`Invite ready. Forward this message to the person you're inviting:\n\n` +
|
|
1983
|
+
`${rawMessage ?? "(no message returned)"}`);
|
|
1984
|
+
// Shown to the room OWNER (who minted it), not a foreign principal, so
|
|
1985
|
+
// the core-generated share_message is fine as-is; capResult only bounds
|
|
1986
|
+
// its length for the Connectors-Directory 25K cap. STRUCTURED
|
|
1987
|
+
// `share_message` (S8-4, owner, 2026-09-23) is that SAME selected
|
|
1988
|
+
// value, normalised through structuredText, so the data never carries
|
|
1989
|
+
// a message the text did not show. A selected value that normalises to
|
|
1990
|
+
// nothing (no share_message and no join_url, or a share_message that is
|
|
1991
|
+
// empty, whitespace-only or not a string at all) is not this tool's
|
|
1992
|
+
// success case any more: the text above is unchanged (the blank or the
|
|
1993
|
+
// "(no message returned)" sentence, as before) and the reply is
|
|
1994
|
+
// isError: true, since there is no honest
|
|
1995
|
+
// structuredContent.share_message to pair it with.
|
|
1996
|
+
const shareMessageStructured = structuredText(rawMessage, 800);
|
|
1997
|
+
if (shareMessageStructured === undefined) {
|
|
1998
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
1999
|
+
}
|
|
2000
|
+
// `join_url` through safeInline with the connector's own cap of 400,
|
|
2001
|
+
// as the connector does (mnemoverse-mcp-remote, memory_invite_to_room):
|
|
2002
|
+
// core builds it as `<base>/<code>`, which safeInline's character class
|
|
2003
|
+
// carries unchanged; anything else it would have to alter is not a URL
|
|
2004
|
+
// this client should hand on as one. Absent when nothing remains.
|
|
2005
|
+
const joinUrlSafe = safeInline(r?.join_url, 400);
|
|
2006
|
+
const joinUrlStructured = joinUrlSafe === "" ? undefined : joinUrlSafe;
|
|
2007
|
+
// The other machine fields the same way: safeInline returns "" for a
|
|
2008
|
+
// non-string, an empty string and a string with nothing it may keep,
|
|
2009
|
+
// and "" is not a usable code, scope or address, so the key is absent
|
|
2010
|
+
// (CodeRabbit, review round 2). Present only when something remains.
|
|
2011
|
+
const codeSafe = safeInline(r?.code);
|
|
2012
|
+
const codeStructured = codeSafe === "" ? undefined : codeSafe;
|
|
2013
|
+
const scopeSafe = safeInline(r?.scope);
|
|
2014
|
+
const scopeStructured = scopeSafe === "" ? undefined : scopeSafe;
|
|
2015
|
+
const roomAddressSafe = safeInline(r?.room_address);
|
|
2016
|
+
const roomAddressStructured = roomAddressSafe === "" ? undefined : roomAddressSafe;
|
|
2017
|
+
// `expires_at` through utcInstant's RETURN, the rule memory_read's
|
|
2018
|
+
// `created_at` already follows (S4): a value that states its offset is
|
|
2019
|
+
// carried exactly as sent, an offset-less one, which this package reads
|
|
2020
|
+
// as UTC by contract, is re-emitted as the UTC ISO-8601 instant, so a
|
|
2021
|
+
// structured consumer lands on the instant this client used rather than
|
|
2022
|
+
// reading the naive string as local time (CodeRabbit, review round 2).
|
|
2023
|
+
// `null` when core sent null; absent when the value does not parse.
|
|
2024
|
+
const expiresAtStructured = r?.expires_at === null ? null : (utcInstant(r?.expires_at) ?? undefined);
|
|
2025
|
+
return structured(text, {
|
|
2026
|
+
share_message: shareMessageStructured,
|
|
2027
|
+
...(joinUrlStructured === undefined ? {} : { join_url: joinUrlStructured }),
|
|
2028
|
+
...(codeStructured === undefined ? {} : { code: codeStructured }),
|
|
2029
|
+
...(scopeStructured === undefined ? {} : { scope: scopeStructured }),
|
|
2030
|
+
...(roomAddressStructured === undefined ? {} : { room_address: roomAddressStructured }),
|
|
2031
|
+
...(expiresAtStructured === undefined ? {} : { expires_at: expiresAtStructured }),
|
|
2032
|
+
});
|
|
2033
|
+
});
|
|
2034
|
+
// --- Tool: memory_join_room ---
|
|
2035
|
+
server.registerTool("memory_join_room", {
|
|
2036
|
+
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.",
|
|
2037
|
+
inputSchema: {
|
|
2038
|
+
code: z.string().min(1).max(200).describe("The invite code (mnvr_...)."),
|
|
2039
|
+
},
|
|
2040
|
+
outputSchema: {
|
|
2041
|
+
room_id: z.string().describe("The room's id (room_...)."),
|
|
2042
|
+
address: z
|
|
2043
|
+
.string()
|
|
2044
|
+
.describe("Domain address (xroom:<id>); pass as `domain` on read/write."),
|
|
2045
|
+
name: z.string().optional().describe("The room name."),
|
|
2046
|
+
scope: z.string().optional().describe("Your role in the room ('read' | 'read_write')."),
|
|
2047
|
+
already_member: z
|
|
2048
|
+
.boolean()
|
|
2049
|
+
.optional()
|
|
2050
|
+
.describe("True if you were already a member (no-op join)."),
|
|
2051
|
+
next_steps: z.string().describe("How to use the room now."),
|
|
2052
|
+
},
|
|
2053
|
+
annotations: {
|
|
2054
|
+
title: "Join room",
|
|
2055
|
+
readOnlyHint: false,
|
|
2056
|
+
destructiveHint: false,
|
|
2057
|
+
idempotentHint: true,
|
|
2058
|
+
openWorldHint: false,
|
|
2059
|
+
},
|
|
2060
|
+
}, async ({ code }) => {
|
|
2061
|
+
const r = await apiFetch("/memory/rooms/join", {
|
|
2062
|
+
method: "POST",
|
|
2063
|
+
body: JSON.stringify({ code }),
|
|
2064
|
+
});
|
|
2065
|
+
// The room name is OWNER-chosen and rendered into the JOINER's LLM context
|
|
2066
|
+
// (CN-032) — and it is printed EXACTLY (src/names.ts): the literal is one
|
|
2067
|
+
// line with quotes and invisibles escaped, so it cannot forge an
|
|
2068
|
+
// instruction block, and "Zoë" stops being quoted as "Zo" in a sentence
|
|
2069
|
+
// that presents it as the name. `scope` stays sanitised: server-shaped
|
|
2070
|
+
// enum, display-only.
|
|
2071
|
+
const roomName = roomNamePhrase(r?.name);
|
|
2072
|
+
const scope = safeInline(r?.scope) || "member";
|
|
2073
|
+
const address = safeInline(r?.address);
|
|
2074
|
+
const roomId = safeInline(r?.room_id);
|
|
2075
|
+
const prefix = r?.already_member
|
|
2076
|
+
? `You're already a member of ${roomName}.`
|
|
2077
|
+
: `Joined ${roomName} (${scope}).`;
|
|
2078
|
+
// Don't print broken `domain=""` guidance if no address came back (Copilot).
|
|
2079
|
+
// The write half of this sentence is scope-gated (roomScopeVerdict, above
|
|
2080
|
+
// isRoomDomain): a "read" invite gets told memory_write will be refused
|
|
2081
|
+
// rather than offered it, and a scope the response did not report at all
|
|
2082
|
+
// gets no promise about write either way.
|
|
2083
|
+
//
|
|
2084
|
+
// GATE (S8-2, mirrors create's S8-1, owner, 2026-09-23): core's
|
|
2085
|
+
// JoinedRoomSchema sends room_id alongside address on every join, so
|
|
2086
|
+
// `roomId` now joins `address` in the condition that picks this
|
|
2087
|
+
// sentence: same wording as before (it still names only "address" -
|
|
2088
|
+
// the caller cannot tell which of the two core actually omitted, and a
|
|
2089
|
+
// second sentence for a case indistinguishable from this one would be
|
|
2090
|
+
// a distinction this client cannot see either).
|
|
2091
|
+
const verdict = roomScopeVerdict(r?.scope);
|
|
2092
|
+
const usage = !address || !roomId
|
|
2093
|
+
? `The server did not return a room address — retry, or check that your API key is set.`
|
|
2094
|
+
: verdict === "read_write"
|
|
2095
|
+
? `Use it: pass domain="${address}" on memory_write / memory_read to read and write the shared room, and on memory_list_recent to catch up on what is new.`
|
|
2096
|
+
: verdict === "read"
|
|
2097
|
+
? `Use it: pass domain="${address}" on memory_read or memory_list_recent to read it; this membership is read-only, so memory_write to that address will be refused.`
|
|
2098
|
+
: `Use it: pass domain="${address}" on memory_read or memory_list_recent to read it — the server did not report this membership's write access, so whether memory_write to that address would succeed is unknown.`;
|
|
2099
|
+
// No usable address or room_id: the reply is the same text as before
|
|
2100
|
+
// this schema existed (the prefix line plus the degrade sentence), now
|
|
2101
|
+
// isError: true, since there is no honest structuredContent for a join
|
|
2102
|
+
// whose room this client cannot address. The text stays byte-identical
|
|
2103
|
+
// on purpose: whether this reply should stop saying "Joined" is a
|
|
2104
|
+
// wording decision for the owner, not for this slice.
|
|
2105
|
+
if (!address || !roomId) {
|
|
2106
|
+
return {
|
|
2107
|
+
content: [
|
|
2108
|
+
{
|
|
2109
|
+
type: "text",
|
|
2110
|
+
text: withDomainEscapeLegend(capResult(`${prefix}\n${usage}`), r?.name),
|
|
2111
|
+
},
|
|
2112
|
+
],
|
|
2113
|
+
isError: true,
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
const nameStructured = structuredText(r?.name, 200);
|
|
2117
|
+
// STRUCTURED `next_steps` (S8-6, owner, 2026-09-23) is this SAME usage
|
|
2118
|
+
// sentence, never core's own `next_steps`: that field points at REST
|
|
2119
|
+
// endpoints and is deliberately not echoed (see the comment above
|
|
2120
|
+
// memory_create_room). `?? usage` is a defensive fallback for the
|
|
2121
|
+
// unreachable case where structuredText would normalise `usage` to
|
|
2122
|
+
// nothing; it is built from safe, already-sanitised parts and never
|
|
2123
|
+
// actually empties.
|
|
2124
|
+
const nextStepsStructured = structuredText(usage, 500) ?? usage;
|
|
2125
|
+
// `scope` absent when safeInline leaves nothing, as on invite.
|
|
2126
|
+
const scopeSafe = safeInline(r?.scope);
|
|
2127
|
+
const scopeStructured = scopeSafe === "" ? undefined : scopeSafe;
|
|
2128
|
+
return structured(
|
|
2129
|
+
// Legend after the cap, same ordering rule as everywhere else.
|
|
2130
|
+
withDomainEscapeLegend(capResult(`${prefix}\n${usage}`), r?.name), {
|
|
2131
|
+
room_id: roomId,
|
|
2132
|
+
address,
|
|
2133
|
+
...(nameStructured === undefined ? {} : { name: nameStructured }),
|
|
2134
|
+
...(scopeStructured === undefined ? {} : { scope: scopeStructured }),
|
|
2135
|
+
...(typeof r?.already_member === "boolean"
|
|
2136
|
+
? { already_member: r.already_member }
|
|
2137
|
+
: {}),
|
|
2138
|
+
next_steps: nextStepsStructured,
|
|
2139
|
+
});
|
|
2140
|
+
});
|
|
2141
|
+
// --- Tool: memory_list_rooms ---
|
|
2142
|
+
// OUTPUT SCHEMA (S9, structured-output plan): `room_id`/`name`/`address`/
|
|
2143
|
+
// `role`/`scope`/`archived` copied from the connector's `roomListOutput`
|
|
2144
|
+
// (mnemoverse-mcp-remote, src/tools/index.ts), field for field and
|
|
2145
|
+
// description for description.
|
|
2146
|
+
//
|
|
2147
|
+
// OD-14 (owner, 2026-09-23, S9-1): `name` is OPTIONAL here though the
|
|
2148
|
+
// connector's own schema marks it a REQUIRED z.string(); so is `scope`, for
|
|
2149
|
+
// the reason OD-13 gave on memory_join_room: the text already has a
|
|
2150
|
+
// supported, non-degraded state for a membership whose write access core
|
|
2151
|
+
// did not report ("this membership's write access was not reported"), and
|
|
2152
|
+
// a required field would turn that state into an SDK "Output validation
|
|
2153
|
+
// error" (review round 2). `structuredText`
|
|
2154
|
+
// (src/names.ts) returns undefined for a genuinely empty or absent name, a
|
|
2155
|
+
// real, already-tested case ("keeps '(unnamed room)' for a genuinely absent
|
|
2156
|
+
// or empty name", test/handlers.test.ts), and forcing that through a
|
|
2157
|
+
// REQUIRED field would make the SDK reject the WHOLE reply with "Output
|
|
2158
|
+
// validation error" on an unnamed room, which is a supported, non-error
|
|
2159
|
+
// outcome, not a malformed response. The alternative, falling back to the
|
|
2160
|
+
// literal empty string, was rejected: the text would keep saying
|
|
2161
|
+
// "(unnamed room)" while the data silently said `name: ""`, the same
|
|
2162
|
+
// text/data lie the anti-fabrication rule this package already applies to
|
|
2163
|
+
// every other optional field exists to prevent.
|
|
2164
|
+
server.registerTool("memory_list_rooms", {
|
|
2165
|
+
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.",
|
|
2166
|
+
inputSchema: {},
|
|
2167
|
+
outputSchema: {
|
|
2168
|
+
rooms: z.array(z.object({
|
|
2169
|
+
room_id: z.string().describe("The room's id (room_...)."),
|
|
2170
|
+
name: z.string().optional().describe("The room name."),
|
|
2171
|
+
address: z
|
|
2172
|
+
.string()
|
|
2173
|
+
.describe("Domain address (xroom:<id>); pass as `domain` on read/write."),
|
|
2174
|
+
role: z.string().describe("'owner' or 'member'."),
|
|
2175
|
+
scope: z.string().optional().describe("'read' or 'read_write'."),
|
|
2176
|
+
archived: z.boolean().describe("True if archived (owned rooms only)."),
|
|
2177
|
+
})),
|
|
2178
|
+
},
|
|
2179
|
+
annotations: {
|
|
2180
|
+
title: "List rooms",
|
|
2181
|
+
readOnlyHint: true,
|
|
2182
|
+
destructiveHint: false,
|
|
2183
|
+
idempotentHint: true,
|
|
2184
|
+
openWorldHint: false,
|
|
2185
|
+
},
|
|
2186
|
+
}, async () => {
|
|
2187
|
+
// The same classifier the scope notes use, for the same reason: this handler
|
|
2188
|
+
// did `Array.isArray(rooms) ? rooms : []` and then asserted "You have no
|
|
2189
|
+
// shared rooms yet" — a claim about the account derived from a body it could
|
|
2190
|
+
// not read. It is the third consumer of this payload and the third place the
|
|
2191
|
+
// substitution was made; all three now go through one function that maps an
|
|
2192
|
+
// unreadable body to `unknown`, never to `none`.
|
|
2193
|
+
const rooms = classifyRooms(await apiFetch("/memory/rooms"), safeInline);
|
|
2194
|
+
if (rooms.state === "unknown") {
|
|
2195
|
+
// Byte-identical to the inline wording this replaces — the builder is
|
|
2196
|
+
// shared so the four list surfaces answer the unreadable case with one
|
|
2197
|
+
// sentence, not four drifting ones.
|
|
2198
|
+
return unreadableAnswerReply("The room list", "a list of your rooms", "you have none");
|
|
2199
|
+
}
|
|
2200
|
+
if (rooms.state === "none") {
|
|
2201
|
+
return structured("You have no shared rooms yet. Create one with memory_create_room, " +
|
|
2202
|
+
"or join one with memory_join_room using an invite code.", { rooms: [] });
|
|
2203
|
+
}
|
|
2204
|
+
// ARCHIVED ROOMS ARE LISTED HERE, unlike in the scope note — this tool's job
|
|
2205
|
+
// is the inventory, and the `[archived]` tag says which ones cannot be read.
|
|
2206
|
+
const list = rooms.rooms;
|
|
2207
|
+
// Room name is OWNER-chosen — printed EXACTLY (src/names.ts), like every
|
|
2208
|
+
// other room-name surface as of 0.8.1: the sanitiser listed "проект" and
|
|
2209
|
+
// "план" as two "(unnamed room)" entries and quoted "Zoë" as "Zo".
|
|
2210
|
+
// address/role/scope are server-shaped and keep the defensive sanitiser.
|
|
2211
|
+
const lines = list.map((r) => {
|
|
2212
|
+
const name = roomNamePhrase(r?.name);
|
|
2213
|
+
const roomId = safeInline(r?.room_id);
|
|
2214
|
+
// Always surface the canonical address: fall back to xroom:<room_id> when the server
|
|
2215
|
+
// omits `address`, so the domain guidance this tool promises is never silently dropped.
|
|
2216
|
+
const address = safeInline(r?.address) || (roomId ? `xroom:${roomId}` : "");
|
|
2217
|
+
const role = safeInline(r?.role);
|
|
2218
|
+
const scope = safeInline(r?.scope);
|
|
2219
|
+
// No "use domain=..." on an archived room: core refuses EVERY read of one
|
|
2220
|
+
// with a 403, for owner and member alike (see the archived-only note in
|
|
2221
|
+
// src/scope.ts), so that clause was an instruction to make a call that
|
|
2222
|
+
// cannot succeed. The address stays visible — it is the room's identity —
|
|
2223
|
+
// but the line says what a read against it will do.
|
|
2224
|
+
//
|
|
2225
|
+
// For a live room, the write half is scope-gated the same way
|
|
2226
|
+
// memory_join_room's usage sentence is (roomScopeVerdict, near
|
|
2227
|
+
// isRoomDomain): this used to say "use domain=..." with no operation
|
|
2228
|
+
// named, which read as an unqualified read+write invitation even for a
|
|
2229
|
+
// "read" member — false, since core refuses that member's memory_write.
|
|
2230
|
+
const verdict = roomScopeVerdict(scope);
|
|
2231
|
+
const tail = r?.archived
|
|
2232
|
+
? address
|
|
2233
|
+
? ` [archived] — address ${address}, but every read is refused while it is archived`
|
|
2234
|
+
: ` [archived] — every read is refused while it is archived`
|
|
2235
|
+
: !address
|
|
2236
|
+
? ""
|
|
2237
|
+
: verdict === "read_write"
|
|
2238
|
+
? ` — use domain="${address}" to read and write it`
|
|
2239
|
+
: verdict === "read"
|
|
2240
|
+
? ` — use domain="${address}" on memory_read only; this membership is read-only, so memory_write to it will be refused`
|
|
2241
|
+
: ` — use domain="${address}" on memory_read; this membership's write access was not reported`;
|
|
2242
|
+
return `- ${name} (${role}${scope ? `, ${scope}` : ""})${tail}`;
|
|
2243
|
+
});
|
|
2244
|
+
const text = `Your shared rooms (${list.length}):\n${lines.join("\n")}`;
|
|
2245
|
+
// Legend after the cap: this is the one room surface long enough to
|
|
2246
|
+
// actually overflow, and the legend must describe the names that
|
|
2247
|
+
// SURVIVED the cut, not the ones it removed.
|
|
2248
|
+
const finalText = withDomainEscapeLegend(capResult(text, "The room list was truncated — some rooms are not shown."), ...list.map((r) => r?.name));
|
|
2249
|
+
// STRUCTURED rows (S9-1/S9-3, owner, 2026-09-23): room_id/address/role/
|
|
2250
|
+
// scope through the SAME safeInline sanitiser the text loop above
|
|
2251
|
+
// already applies (address keeps the identical xroom:<room_id>
|
|
2252
|
+
// fallback), archived through Boolean(). The name is withheld from the
|
|
2253
|
+
// data in exactly the cases the text withholds it: roomNamePhrase
|
|
2254
|
+
// prints "(room name cannot be printed exactly)" when exactLiteral
|
|
2255
|
+
// refuses the JSON literal (longer than MAX_DOMAIN_LITERAL once quoted
|
|
2256
|
+
// and escaped), so the same exactLiteral check decides whether `name`
|
|
2257
|
+
// is present here, and the value carried is the structuredText
|
|
2258
|
+
// normalisation of the raw name (control, bidi and zero-width
|
|
2259
|
+
// characters out), never a truncated prefix presented as the name
|
|
2260
|
+
// (review round 2: the two caps measure different things, a JSON
|
|
2261
|
+
// literal and a code-point count, so reusing the number alone did not
|
|
2262
|
+
// make the surfaces agree). Absent, never fabricated as "" or
|
|
2263
|
+
// "(unnamed room)", for a room with no usable name (OD-14 above).
|
|
2264
|
+
// One stated exception (review round 3): a name made only of the
|
|
2265
|
+
// characters structuredText removes (whitespace, control, bidi,
|
|
2266
|
+
// zero-width) is printed exactly in the text, as an escaped literal
|
|
2267
|
+
// with the legend, but leaves nothing to carry as a plain data value;
|
|
2268
|
+
// the data omits the key rather than carry "" (which would claim the
|
|
2269
|
+
// name is empty) or the raw characters (which the data surface keeps
|
|
2270
|
+
// out by rule). The same exception holds for every structuredText
|
|
2271
|
+
// field on this surface.
|
|
2272
|
+
//
|
|
2273
|
+
// Core's RoomListItemSchema sends room_id, address and role on every
|
|
2274
|
+
// row; a row whose room_id or role sanitises to nothing (or whose
|
|
2275
|
+
// address cannot even be rebuilt from room_id) is not core's row and
|
|
2276
|
+
// is dropped from the data rather than emitted with fabricated empty
|
|
2277
|
+
// strings in required fields (review round 2). `scope` is optional
|
|
2278
|
+
// (OD-14 above): absent from the data when the text says the write
|
|
2279
|
+
// access was not reported. The text keeps its existing per-row
|
|
2280
|
+
// degrade; the drop is counted and reported once on stderr, as
|
|
2281
|
+
// vault_list and memory_stats report theirs.
|
|
2282
|
+
const roomsStructured = [];
|
|
2283
|
+
let droppedRooms = 0;
|
|
2284
|
+
for (const r of list) {
|
|
2285
|
+
const roomId = safeInline(r?.room_id);
|
|
2286
|
+
const address = safeInline(r?.address) || (roomId ? `xroom:${roomId}` : "");
|
|
2287
|
+
const role = safeInline(r?.role);
|
|
2288
|
+
const scope = safeInline(r?.scope);
|
|
2289
|
+
if (!roomId || !address || !role) {
|
|
2290
|
+
droppedRooms += 1;
|
|
2291
|
+
continue;
|
|
2292
|
+
}
|
|
2293
|
+
const nameStructured = typeof r?.name === "string" && exactLiteral(r.name, MAX_DOMAIN_LITERAL)
|
|
2294
|
+
? structuredText(r.name, MAX_DOMAIN_LITERAL)
|
|
2295
|
+
: undefined;
|
|
2296
|
+
roomsStructured.push({
|
|
2297
|
+
room_id: roomId,
|
|
2298
|
+
address,
|
|
2299
|
+
role,
|
|
2300
|
+
...(scope ? { scope } : {}),
|
|
2301
|
+
archived: Boolean(r?.archived),
|
|
2302
|
+
...(nameStructured === undefined ? {} : { name: nameStructured }),
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
if (droppedRooms > 0) {
|
|
2306
|
+
console.error(`Mnemoverse: memory_list_rooms dropped ${droppedRooms} malformed room ` +
|
|
2307
|
+
`row${droppedRooms === 1 ? "" : "s"} from structuredContent.rooms ` +
|
|
2308
|
+
`(room_id, address or role missing or not usable; still shown in the text).`);
|
|
2309
|
+
}
|
|
2310
|
+
return structured(finalText, { rooms: roomsStructured });
|
|
2311
|
+
});
|
|
2312
|
+
// --- Tool: vault_list ---
|
|
2313
|
+
// Core's CreateSecretRequest caps (mnemoverse-core src/mnemo/api/vault_routes.py):
|
|
2314
|
+
// alias max_length 200, context max_length 10,000. Concepts carry no cap
|
|
2315
|
+
// of their own there; 200 is the alias cap reused for a tag, not a new
|
|
2316
|
+
// number. Not in src/limits.ts because the generator covers the memory
|
|
2317
|
+
// routes, not the vault ones.
|
|
2318
|
+
const VAULT_ALIAS_CAP = 200;
|
|
2319
|
+
const VAULT_CONTEXT_CAP = 10_000;
|
|
2320
|
+
const VAULT_CONCEPT_CAP = 200;
|
|
2321
|
+
// OUTPUT SCHEMA (S9, structured-output plan): `alias`/`context`/`concepts`
|
|
2322
|
+
// copied from the connector's `vaultListOutput` (mnemoverse-mcp-remote,
|
|
2323
|
+
// src/tools/index.ts), field for field and description for description,
|
|
2324
|
+
// all three REQUIRED, matching the connector exactly (unlike
|
|
2325
|
+
// memory_list_rooms's `name`, above).
|
|
2326
|
+
//
|
|
2327
|
+
// OD-15 (owner, 2026-09-23, S9-2): a row whose `alias` or `context` is not
|
|
2328
|
+
// a usable string is SKIPPED from `secrets` in structuredContent rather
|
|
2329
|
+
// than turning the whole call into `isError`. The plan's original wording
|
|
2330
|
+
// (isError on one bad row) directly reversed an existing, deliberately
|
|
2331
|
+
// named test, "a broken alias is one anonymous row, not a dead tool"
|
|
2332
|
+
// (test/handlers.test.ts), which the owner confirmed keeping as-is: the
|
|
2333
|
+
// text already substitutes "(no alias)" for that ONE row and leaves every
|
|
2334
|
+
// other row and the call itself untouched, so the data follows the same
|
|
2335
|
+
// rule this package applies everywhere else, a value the text withholds
|
|
2336
|
+
// must be withheld from the data too. Because both fields are REQUIRED
|
|
2337
|
+
// here, exactly as in the connector, there is no honest partial row to
|
|
2338
|
+
// emit for one that fails either check, including a row whose `context`
|
|
2339
|
+
// (or `alias`) was never sent at all (`typeof undefined` is not
|
|
2340
|
+
// `"string"`): this package does not fabricate `""` for a value it does
|
|
2341
|
+
// not have, the same rule OD-14 above applies to a room name. The skip is
|
|
2342
|
+
// reported once per call on stderr, in this package's existing
|
|
2343
|
+
// startup-diagnostic style (src/index.ts's "Mnemoverse: ..." lines,
|
|
2344
|
+
// mirroring memory_stats's S7 domain-drop diagnostic), because a
|
|
2345
|
+
// structured consumer reading only `structuredContent.secrets` has no
|
|
2346
|
+
// other way to learn the array is shorter than the count in the text's own
|
|
2347
|
+
// header line.
|
|
2348
|
+
//
|
|
2349
|
+
// `concepts` is a brand-new field with no text-side precedent, nothing in
|
|
2350
|
+
// this tool's text renders it. Core's SecretSummary sends it on every row,
|
|
2351
|
+
// so a row without it, or with a value that is not an array of strings,
|
|
2352
|
+
// is treated the same as a malformed alias/context and drops the row: no
|
|
2353
|
+
// `[]` is fabricated for a value core did not send, and there is no honest
|
|
2354
|
+
// subset of an unshaped value to keep.
|
|
2355
|
+
//
|
|
2356
|
+
// Free text on this surface (alias, context, each concept) goes through
|
|
2357
|
+
// structuredText with core's own caps (alias 200, context 10,000, a
|
|
2358
|
+
// concept 200; vault_routes.py CreateSecretRequest), the same normalisation
|
|
2359
|
+
// the room name gets: a value that normalises to nothing (empty,
|
|
2360
|
+
// whitespace-only, control characters only) is not usable, and the row is
|
|
2361
|
+
// dropped rather than carried as "" (review round 2). The text prints its
|
|
2362
|
+
// own safeInline reading of alias and context unchanged.
|
|
2363
|
+
server.registerTool("vault_list", {
|
|
2364
|
+
description: `List the secrets stored in your Mnemoverse Vault — by ALIAS and purpose only; the secret VALUE is never returned or shown to you, and no tool on ${serverNoun} returns it. Use this to check WHICH secrets the user has stored and under what alias (e.g. the user says 'do I have a GitHub token saved?'). Only YOUR account's secrets are listed.`,
|
|
2365
|
+
inputSchema: {},
|
|
2366
|
+
outputSchema: {
|
|
2367
|
+
secrets: z.array(z.object({
|
|
2368
|
+
alias: z
|
|
2369
|
+
.string()
|
|
2370
|
+
.describe("The secret's alias — the reference you use, never the value."),
|
|
2371
|
+
context: z.string().describe("The secret's purpose/context — never the value."),
|
|
2372
|
+
concepts: z.array(z.string()).describe("Concept tags."),
|
|
2373
|
+
})),
|
|
2374
|
+
},
|
|
2375
|
+
annotations: {
|
|
2376
|
+
title: "List vault secrets",
|
|
2377
|
+
readOnlyHint: true,
|
|
2378
|
+
destructiveHint: false,
|
|
2379
|
+
idempotentHint: true,
|
|
2380
|
+
openWorldHint: false,
|
|
2381
|
+
},
|
|
2382
|
+
}, async () => {
|
|
2383
|
+
const r = await apiFetch("/vault/secrets");
|
|
2384
|
+
// A 200 missing the `secrets` array used to print "No secrets are stored
|
|
2385
|
+
// in your Vault yet." — an absence claim about the Vault derived from a
|
|
2386
|
+
// body this client could not read. The family fix (classifyRooms, the
|
|
2387
|
+
// domains guard) had stopped one tool short of here (truth F13,
|
|
2388
|
+
// 2026-08-08). A genuinely empty array keeps the absence claim below.
|
|
2389
|
+
const list = r?.secrets;
|
|
2390
|
+
if (!Array.isArray(list)) {
|
|
2391
|
+
return unreadableAnswerReply("The secret list", "a list of your Vault secrets", "none are stored");
|
|
2392
|
+
}
|
|
2393
|
+
if (list.length === 0) {
|
|
2394
|
+
return structured("No secrets are stored in your Vault yet.", { secrets: [] });
|
|
2395
|
+
}
|
|
2396
|
+
const lines = list.map((s) => {
|
|
2397
|
+
const alias = safeInline(s?.alias) || "(no alias)";
|
|
2398
|
+
const context = safeInline(s?.context);
|
|
2399
|
+
return context ? `- ${alias} — ${context}` : `- ${alias}`;
|
|
2400
|
+
});
|
|
2401
|
+
const text = `Your Vault secrets (${list.length}) — alias and purpose only, never the value:\n` +
|
|
2402
|
+
lines.join("\n");
|
|
2403
|
+
const finalText = capResult(text, "The secret list was truncated — some secrets are not shown.");
|
|
2404
|
+
// STRUCTURED rows (S9-2, owner, 2026-09-23; see the OD-15 comment
|
|
2405
|
+
// above): core's SecretSummary sends alias, context and concepts on
|
|
2406
|
+
// every row, so a row is kept only when alias and context are strings
|
|
2407
|
+
// and concepts is an array of strings; a row missing any of the three,
|
|
2408
|
+
// or carrying one in another shape, is not core's row and is dropped
|
|
2409
|
+
// from the data, never defaulted (no "" for a context, no [] for
|
|
2410
|
+
// concepts). The call itself stands; the drop is counted and reported
|
|
2411
|
+
// once on stderr.
|
|
2412
|
+
const secretsStructured = [];
|
|
2413
|
+
let droppedSecrets = 0;
|
|
2414
|
+
for (const s of list) {
|
|
2415
|
+
const alias = structuredText(s?.alias, VAULT_ALIAS_CAP);
|
|
2416
|
+
const context = structuredText(s?.context, VAULT_CONTEXT_CAP);
|
|
2417
|
+
const conceptsRaw = s?.concepts;
|
|
2418
|
+
const concepts = Array.isArray(conceptsRaw)
|
|
2419
|
+
? conceptsRaw.map((c) => structuredText(c, VAULT_CONCEPT_CAP))
|
|
2420
|
+
: undefined;
|
|
2421
|
+
const conceptsOk = concepts !== undefined && concepts.every((c) => c !== undefined);
|
|
2422
|
+
if (alias === undefined || context === undefined || !conceptsOk) {
|
|
2423
|
+
droppedSecrets += 1;
|
|
2424
|
+
continue;
|
|
2425
|
+
}
|
|
2426
|
+
secretsStructured.push({ alias, context, concepts: concepts });
|
|
2427
|
+
}
|
|
2428
|
+
if (droppedSecrets > 0) {
|
|
2429
|
+
console.error(`Mnemoverse: vault_list dropped ${droppedSecrets} malformed secret ` +
|
|
2430
|
+
`row${droppedSecrets === 1 ? "" : "s"} from structuredContent.secrets ` +
|
|
2431
|
+
`(alias, context or concepts missing, empty or not in core's shape; ` +
|
|
2432
|
+
`still shown in the text).`);
|
|
2433
|
+
}
|
|
2434
|
+
return structured(finalText, { secrets: secretsStructured });
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
//# sourceMappingURL=tools.js.map
|