@mnemoverse/mcp-memory-server 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -4
- package/dist/errors.d.ts +90 -7
- package/dist/errors.js +311 -51
- package/dist/errors.js.map +1 -1
- package/dist/names.d.ts +34 -4
- package/dist/names.js +50 -10
- package/dist/names.js.map +1 -1
- package/dist/render.d.ts +69 -7
- package/dist/render.js +97 -14
- 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.js +5 -2
- package/dist/resources.js.map +1 -1
- package/dist/shared.d.ts +23 -2
- package/dist/shared.js +21 -1
- package/dist/shared.js.map +1 -1
- package/dist/tools.d.ts +64 -1
- package/dist/tools.js +638 -116
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* or the environment.
|
|
13
13
|
*/
|
|
14
14
|
import { z } from "zod";
|
|
15
|
-
import { CURSOR_RE, formatReadItem, formatRecentPage, safeInline, structuredItem, } from "./render.js";
|
|
15
|
+
import { CURSOR_RE, formatReadItem, formatRecentPage, rawAuthorName, safeInline, structuredItem, } from "./render.js";
|
|
16
16
|
// `NO_MATCH_MESSAGE` is no longer imported here: this file used to pick between
|
|
17
17
|
// it and buildReadEmptyResponse by testing a note string for truthiness, which
|
|
18
18
|
// is how "we could not check" came to be spelled like "the store is there".
|
|
@@ -20,14 +20,43 @@ import { CURSOR_RE, formatReadItem, formatRecentPage, safeInline, structuredItem
|
|
|
20
20
|
import { buildReadEmptyResponse } from "./teaching.js";
|
|
21
21
|
import { classifyRooms, futureSinceNote, probeReadScope, readScopeNote, scopeLabel, } from "./scope.js";
|
|
22
22
|
import { readRequestBody, recentRequestBody, writeRequestBody, searchedScope, } from "./requests.js";
|
|
23
|
-
import { exactLiteral, formatDomainList, roomNamePhrase, structuredText, withDomainEscapeLegend, } from "./names.js";
|
|
24
|
-
import { ApiError } from "./errors.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
25
|
// Field limits, generated from core's contract (src/limits.ts, ADR-025).
|
|
26
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
|
+
}
|
|
27
50
|
// Hard cap on tool result size — required by Claude Connectors Directory
|
|
28
51
|
// (https://support.claude.com/en/articles/12922490-remote-mcp-server-submission-guide).
|
|
29
52
|
// Approximate token count = chars / 4. Cap at 24,000 tokens to leave headroom under the 25K limit.
|
|
30
|
-
|
|
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;
|
|
31
60
|
/**
|
|
32
61
|
* Truncate a result string to MAX_RESULT_CHARS, appending a notice if truncated.
|
|
33
62
|
* Required by Claude Connectors Directory submission policy.
|
|
@@ -36,8 +65,12 @@ const MAX_RESULT_CHARS = 24_000 * 4;
|
|
|
36
65
|
* before the cut point is a high surrogate (U+D800–U+DBFF), drop it so the
|
|
37
66
|
* result stays well-formed. Otherwise an emoji or non-BMP character at the
|
|
38
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.
|
|
39
72
|
*/
|
|
40
|
-
function capResult(text,
|
|
73
|
+
export function capResult(text,
|
|
41
74
|
// The default recommends ONLY the control that works. A previous draft also
|
|
42
75
|
// said "or smaller top_k" — but top_k is not a hard cap (association
|
|
43
76
|
// expansion can return more, the relevance floor fewer; the same query at
|
|
@@ -46,16 +79,30 @@ function capResult(text,
|
|
|
46
79
|
moreHint = "Use a more specific query to see all results.") {
|
|
47
80
|
if (text.length <= MAX_RESULT_CHARS)
|
|
48
81
|
return text;
|
|
49
|
-
|
|
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);
|
|
50
96
|
const lastCode = truncated.charCodeAt(truncated.length - 1);
|
|
51
97
|
if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
|
|
52
98
|
truncated = truncated.slice(0, -1);
|
|
53
99
|
}
|
|
54
|
-
|
|
55
|
-
// guidance instead of the read-tool default (which points at a query control a
|
|
56
|
-
// repeated no-arg call cannot use). Existing callers keep the default message.
|
|
57
|
-
return `${truncated}\n\n[…truncated to fit the 25K token limit. ${moreHint}]`;
|
|
100
|
+
return `${truncated}${suffix}`;
|
|
58
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;
|
|
59
106
|
/**
|
|
60
107
|
* A 2xx whose body does not carry what core always sends for this operation.
|
|
61
108
|
*
|
|
@@ -255,7 +302,21 @@ const MEMORY_ITEM_OUTPUT = {
|
|
|
255
302
|
* `deps.apiFetch` is the only way the tools reach the API.
|
|
256
303
|
*/
|
|
257
304
|
export function registerMemoryTools(server, deps) {
|
|
258
|
-
const {
|
|
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";
|
|
259
320
|
// ANNOTATIONS, decided once for every server that registers these tools
|
|
260
321
|
// (owner, 2026-09-21; the stdio server and the hosted connector had answered
|
|
261
322
|
// both opposite ways):
|
|
@@ -380,9 +441,22 @@ export function registerMemoryTools(server, deps) {
|
|
|
380
441
|
// returns, needs room addresses deliberately EXEMPT and zero-width
|
|
381
442
|
// characters handled (JS trim() does not strip them — verified, contrary
|
|
382
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;
|
|
383
457
|
const r = await apiFetch("/memory/write", {
|
|
384
458
|
method: "POST",
|
|
385
|
-
body: JSON.stringify(writeRequestBody({ content, concepts, domain })),
|
|
459
|
+
body: JSON.stringify(writeRequestBody({ content, concepts, domain }, author)),
|
|
386
460
|
});
|
|
387
461
|
// `stored` MUST BE A BOOLEAN before either verdict below may be printed.
|
|
388
462
|
//
|
|
@@ -560,7 +634,7 @@ export function registerMemoryTools(server, deps) {
|
|
|
560
634
|
.min(CORE_LIMITS.readTopK.minimum)
|
|
561
635
|
.max(CORE_LIMITS.readTopK.maximum)
|
|
562
636
|
.optional()
|
|
563
|
-
.describe(
|
|
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.`),
|
|
564
638
|
domain: z
|
|
565
639
|
.string()
|
|
566
640
|
.optional()
|
|
@@ -742,7 +816,15 @@ export function registerMemoryTools(server, deps) {
|
|
|
742
816
|
// `structuredContent` carries every item, uncapped (OD-11): the cap
|
|
743
817
|
// and its legend are TEXT-side concerns, and `structuredItem`
|
|
744
818
|
// (src/render.ts) is deliberately not run through either.
|
|
745
|
-
|
|
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) });
|
|
746
828
|
});
|
|
747
829
|
// --- Tool: memory_list_recent ---
|
|
748
830
|
/**
|
|
@@ -1119,12 +1201,15 @@ export function registerMemoryTools(server, deps) {
|
|
|
1119
1201
|
// The cursor is the last ACCEPTED batch's, never the newest one
|
|
1120
1202
|
// seen: a batch that did not fit the budget was not returned, so
|
|
1121
1203
|
// pointing past it would skip every entry in it.
|
|
1122
|
-
|
|
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) +
|
|
1123
1208
|
(stoppedEarly ? LIST_PAGE_EARLY_STOP_NOTE : ""),
|
|
1124
1209
|
// Still true, and now only reachable when ONE entry is larger
|
|
1125
1210
|
// than the whole budget: the case `limit` cannot fix and the
|
|
1126
1211
|
// global cap has to.
|
|
1127
|
-
"Lower `limit` or add a `domain` for smaller pages."), ...items.map((it) => it?.domain)), {
|
|
1212
|
+
"Lower `limit` or add a `domain` for smaller pages."), ...items.map((it) => it?.domain), ...items.map((it) => rawAuthorName(it?.provenance))), {
|
|
1128
1213
|
items: items.map(structuredItem),
|
|
1129
1214
|
// Same field name and meaning as the connector's `next_cursor`
|
|
1130
1215
|
// (mnemoverse-mcp-remote `memoryListRecentOutput`): the cursor a
|
|
@@ -1204,7 +1289,7 @@ export function registerMemoryTools(server, deps) {
|
|
|
1204
1289
|
.array(z.string())
|
|
1205
1290
|
.min(1)
|
|
1206
1291
|
.optional()
|
|
1207
|
-
.describe("Deprecated since 0.11, removed in 0.
|
|
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."),
|
|
1208
1293
|
outcome: z
|
|
1209
1294
|
.number()
|
|
1210
1295
|
.min(CORE_LIMITS.feedbackOutcome.minimum)
|
|
@@ -1249,7 +1334,7 @@ export function registerMemoryTools(server, deps) {
|
|
|
1249
1334
|
.int()
|
|
1250
1335
|
.nonnegative()
|
|
1251
1336
|
.optional()
|
|
1252
|
-
.describe(
|
|
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.`),
|
|
1253
1338
|
},
|
|
1254
1339
|
annotations: {
|
|
1255
1340
|
title: "Rate Memory Helpfulness",
|
|
@@ -1483,9 +1568,9 @@ export function registerMemoryTools(server, deps) {
|
|
|
1483
1568
|
// count, and the same rule: a value that is not a finite number is
|
|
1484
1569
|
// unknown and prints nothing, never 0. Core's async mode acks with 0
|
|
1485
1570
|
// before the worker runs (memory_engine.py `feedback` docstring), which
|
|
1486
|
-
// is why this is the service's report too; production
|
|
1487
|
-
//
|
|
1488
|
-
//
|
|
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).
|
|
1489
1574
|
//
|
|
1490
1575
|
// coactivation_edges is left out of the text on purpose: core links
|
|
1491
1576
|
// concepts only when the request carries query_concepts, which this tool
|
|
@@ -1513,6 +1598,51 @@ export function registerMemoryTools(server, deps) {
|
|
|
1513
1598
|
server.registerTool("memory_stats", {
|
|
1514
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.",
|
|
1515
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
|
+
},
|
|
1516
1646
|
annotations: {
|
|
1517
1647
|
title: "Memory Statistics",
|
|
1518
1648
|
readOnlyHint: true,
|
|
@@ -1522,6 +1652,28 @@ export function registerMemoryTools(server, deps) {
|
|
|
1522
1652
|
},
|
|
1523
1653
|
}, async () => {
|
|
1524
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
|
+
}
|
|
1525
1677
|
// A field the server did not send is UNKNOWN, not zero. Rendering it as
|
|
1526
1678
|
// "0" is the same class of lie as an empty search claiming emptiness:
|
|
1527
1679
|
// "Associations: 0" reads as "this memory has learned nothing", which is
|
|
@@ -1570,29 +1722,71 @@ export function registerMemoryTools(server, deps) {
|
|
|
1570
1722
|
"",
|
|
1571
1723
|
"Counts cover your own domains. Shared rooms are separate stores and are not included — see memory_list_rooms.",
|
|
1572
1724
|
].join("\n");
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
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
|
+
});
|
|
1596
1790
|
});
|
|
1597
1791
|
// --- Tool: memory_create_room ---
|
|
1598
1792
|
//
|
|
@@ -1605,6 +1799,32 @@ export function registerMemoryTools(server, deps) {
|
|
|
1605
1799
|
// carries the owner-chosen room name raw, which this file prints only
|
|
1606
1800
|
// through roomNamePhrase (CN-032). The usage lines below say the same
|
|
1607
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 ---
|
|
1608
1828
|
server.registerTool("memory_create_room", {
|
|
1609
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.",
|
|
1610
1830
|
inputSchema: {
|
|
@@ -1619,6 +1839,15 @@ export function registerMemoryTools(server, deps) {
|
|
|
1619
1839
|
.optional()
|
|
1620
1840
|
.describe("Optional description of the room."),
|
|
1621
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
|
+
},
|
|
1622
1851
|
annotations: {
|
|
1623
1852
|
title: "Create shared room",
|
|
1624
1853
|
readOnlyHint: false,
|
|
@@ -1641,7 +1870,13 @@ export function registerMemoryTools(server, deps) {
|
|
|
1641
1870
|
const roomId = safeInline(r?.room_id);
|
|
1642
1871
|
// If core returned no usable id (empty body / sanitized away), don't print
|
|
1643
1872
|
// broken `domain=""` guidance — say so instead (Copilot).
|
|
1644
|
-
|
|
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
|
|
1645
1880
|
? `Created shared room ${roomName}. Address: ${address}\n` +
|
|
1646
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` +
|
|
1647
1882
|
(roomId
|
|
@@ -1649,18 +1884,35 @@ export function registerMemoryTools(server, deps) {
|
|
|
1649
1884
|
: "")
|
|
1650
1885
|
: `Room ${roomName} was created but the server did not return a usable address — ` +
|
|
1651
1886
|
`retry, or check that your API key is set.`;
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
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
|
+
});
|
|
1664
1916
|
});
|
|
1665
1917
|
// --- Tool: memory_invite_to_room ---
|
|
1666
1918
|
server.registerTool("memory_invite_to_room", {
|
|
@@ -1694,6 +1946,20 @@ export function registerMemoryTools(server, deps) {
|
|
|
1694
1946
|
.optional()
|
|
1695
1947
|
.describe("How many people may join with this invite (default 1, single-use)."),
|
|
1696
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
|
+
},
|
|
1697
1963
|
annotations: {
|
|
1698
1964
|
title: "Invite to room",
|
|
1699
1965
|
readOnlyHint: false,
|
|
@@ -1708,18 +1974,62 @@ export function registerMemoryTools(server, deps) {
|
|
|
1708
1974
|
method: "POST",
|
|
1709
1975
|
body: JSON.stringify({ scope, expires_in_days, max_uses }),
|
|
1710
1976
|
});
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
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
|
+
});
|
|
1723
2033
|
});
|
|
1724
2034
|
// --- Tool: memory_join_room ---
|
|
1725
2035
|
server.registerTool("memory_join_room", {
|
|
@@ -1727,6 +2037,19 @@ export function registerMemoryTools(server, deps) {
|
|
|
1727
2037
|
inputSchema: {
|
|
1728
2038
|
code: z.string().min(1).max(200).describe("The invite code (mnvr_...)."),
|
|
1729
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
|
+
},
|
|
1730
2053
|
annotations: {
|
|
1731
2054
|
title: "Join room",
|
|
1732
2055
|
readOnlyHint: false,
|
|
@@ -1748,6 +2071,7 @@ export function registerMemoryTools(server, deps) {
|
|
|
1748
2071
|
const roomName = roomNamePhrase(r?.name);
|
|
1749
2072
|
const scope = safeInline(r?.scope) || "member";
|
|
1750
2073
|
const address = safeInline(r?.address);
|
|
2074
|
+
const roomId = safeInline(r?.room_id);
|
|
1751
2075
|
const prefix = r?.already_member
|
|
1752
2076
|
? `You're already a member of ${roomName}.`
|
|
1753
2077
|
: `Joined ${roomName} (${scope}).`;
|
|
@@ -1756,28 +2080,102 @@ export function registerMemoryTools(server, deps) {
|
|
|
1756
2080
|
// isRoomDomain): a "read" invite gets told memory_write will be refused
|
|
1757
2081
|
// rather than offered it, and a scope the response did not report at all
|
|
1758
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).
|
|
1759
2091
|
const verdict = roomScopeVerdict(r?.scope);
|
|
1760
|
-
const usage = !address
|
|
2092
|
+
const usage = !address || !roomId
|
|
1761
2093
|
? `The server did not return a room address — retry, or check that your API key is set.`
|
|
1762
2094
|
: verdict === "read_write"
|
|
1763
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.`
|
|
1764
2096
|
: verdict === "read"
|
|
1765
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.`
|
|
1766
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.`;
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
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
|
+
});
|
|
1776
2140
|
});
|
|
1777
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.
|
|
1778
2164
|
server.registerTool("memory_list_rooms", {
|
|
1779
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.",
|
|
1780
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
|
+
},
|
|
1781
2179
|
annotations: {
|
|
1782
2180
|
title: "List rooms",
|
|
1783
2181
|
readOnlyHint: true,
|
|
@@ -1800,15 +2198,8 @@ export function registerMemoryTools(server, deps) {
|
|
|
1800
2198
|
return unreadableAnswerReply("The room list", "a list of your rooms", "you have none");
|
|
1801
2199
|
}
|
|
1802
2200
|
if (rooms.state === "none") {
|
|
1803
|
-
return
|
|
1804
|
-
|
|
1805
|
-
{
|
|
1806
|
-
type: "text",
|
|
1807
|
-
text: "You have no shared rooms yet. Create one with memory_create_room, " +
|
|
1808
|
-
"or join one with memory_join_room using an invite code.",
|
|
1809
|
-
},
|
|
1810
|
-
],
|
|
1811
|
-
};
|
|
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: [] });
|
|
1812
2203
|
}
|
|
1813
2204
|
// ARCHIVED ROOMS ARE LISTED HERE, unlike in the scope note — this tool's job
|
|
1814
2205
|
// is the inventory, and the `[archived]` tag says which ones cannot be read.
|
|
@@ -1851,22 +2242,136 @@ export function registerMemoryTools(server, deps) {
|
|
|
1851
2242
|
return `- ${name} (${role}${scope ? `, ${scope}` : ""})${tail}`;
|
|
1852
2243
|
});
|
|
1853
2244
|
const text = `Your shared rooms (${list.length}):\n${lines.join("\n")}`;
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
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 });
|
|
1865
2311
|
});
|
|
1866
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.
|
|
1867
2363
|
server.registerTool("vault_list", {
|
|
1868
|
-
description:
|
|
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.`,
|
|
1869
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
|
+
},
|
|
1870
2375
|
annotations: {
|
|
1871
2376
|
title: "List vault secrets",
|
|
1872
2377
|
readOnlyHint: true,
|
|
@@ -1886,14 +2391,7 @@ export function registerMemoryTools(server, deps) {
|
|
|
1886
2391
|
return unreadableAnswerReply("The secret list", "a list of your Vault secrets", "none are stored");
|
|
1887
2392
|
}
|
|
1888
2393
|
if (list.length === 0) {
|
|
1889
|
-
return {
|
|
1890
|
-
content: [
|
|
1891
|
-
{
|
|
1892
|
-
type: "text",
|
|
1893
|
-
text: "No secrets are stored in your Vault yet.",
|
|
1894
|
-
},
|
|
1895
|
-
],
|
|
1896
|
-
};
|
|
2394
|
+
return structured("No secrets are stored in your Vault yet.", { secrets: [] });
|
|
1897
2395
|
}
|
|
1898
2396
|
const lines = list.map((s) => {
|
|
1899
2397
|
const alias = safeInline(s?.alias) || "(no alias)";
|
|
@@ -1902,14 +2400,38 @@ export function registerMemoryTools(server, deps) {
|
|
|
1902
2400
|
});
|
|
1903
2401
|
const text = `Your Vault secrets (${list.length}) — alias and purpose only, never the value:\n` +
|
|
1904
2402
|
lines.join("\n");
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
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 });
|
|
1913
2435
|
});
|
|
1914
2436
|
}
|
|
1915
2437
|
//# sourceMappingURL=tools.js.map
|