@mnemoverse/mcp-memory-server 0.12.1 → 0.13.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/dist/tools.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The ten memory tools, as one function any MCP server can register.
2
+ * The eleven tools (ten memory tools and vault_list), as one function any MCP server can register.
3
3
  *
4
4
  * ADR-025 (mnemoverse-core): this package defines the MCP surface, and every
5
5
  * server that exposes Mnemoverse memory over MCP registers the SAME tools from
@@ -12,14 +12,14 @@
12
12
  * or the environment.
13
13
  */
14
14
  import { z } from "zod";
15
- import { CURSOR_RE, formatReadItem, formatRecentPage, rawAuthorName, safeInline, structuredItem, } from "./render.js";
15
+ import { CURSOR_RE, formatDateTag, 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".
19
19
  // Assembling the whole answer belongs in one place — src/teaching.ts.
20
20
  import { buildReadEmptyResponse } from "./teaching.js";
21
21
  import { classifyRooms, futureSinceNote, probeReadScope, readScopeNote, scopeLabel, } from "./scope.js";
22
- import { readRequestBody, recentRequestBody, writeRequestBody, searchedScope, } from "./requests.js";
22
+ import { graphRequestBody, readRequestBody, recentRequestBody, writeRequestBody, searchedScope, } from "./requests.js";
23
23
  import { exactLiteral, formatDomainList, MAX_DOMAIN_LITERAL, MAX_DOMAIN_TAG_LITERAL, roomNamePhrase, structuredText, withDomainEscapeLegend, withEscapeLegendAt, } from "./names.js";
24
24
  import { ApiError, rewordFailure } from "./errors.js";
25
25
  // Field limits, generated from core's contract (src/limits.ts, ADR-025).
@@ -285,7 +285,9 @@ const PROBE_TIMEOUT_MS = 4000;
285
285
  * (test/read-structured.test.ts pins it unchanged).
286
286
  */
287
287
  const MEMORY_ITEM_OUTPUT = {
288
- memory_id: z.string().describe("Identifier needed to rate or manage this saved memory."),
288
+ memory_id: z
289
+ .string()
290
+ .describe("Identifier needed to rate or manage this saved memory."),
289
291
  content: z.string().describe("Stored memory content."),
290
292
  domain: z.string().describe("User-defined memory namespace or domain."),
291
293
  created_at: z
@@ -298,14 +300,16 @@ const MEMORY_ITEM_OUTPUT = {
298
300
  .describe("Sanitized AGENT identity of the writer (never the human principal) — attribution in shared rooms."),
299
301
  };
300
302
  /**
301
- * Register the ten memory tools on `server`. Call once per server instance.
303
+ * Register the eleven tools on `server`. Call once per server instance.
302
304
  * `deps.apiFetch` is the only way the tools reach the API.
303
305
  */
304
306
  export function registerMemoryTools(server, deps) {
305
307
  const { wording, writeAuthor } = deps;
306
308
  // STEP4-2: `wording` reaches the error text here, on every rejection, not
307
309
  // in the consumer's constructors. See wordedApiFetch.
308
- const apiFetch = wording === undefined ? deps.apiFetch : wordedApiFetch(deps.apiFetch, wording);
310
+ const apiFetch = wording === undefined
311
+ ? deps.apiFetch
312
+ : wordedApiFetch(deps.apiFetch, wording);
309
313
  // Read once, defensively: `wording` crosses a public package boundary a
310
314
  // caller controls only at compile time (STEP4-2, owner 2026-09-24). A
311
315
  // strict-equality check rather than a truthiness check, so any value other
@@ -320,10 +324,10 @@ export function registerMemoryTools(server, deps) {
320
324
  // ANNOTATIONS, decided once for every server that registers these tools
321
325
  // (owner, 2026-09-21; the stdio server and the hosted connector had answered
322
326
  // both opposite ways):
323
- // - openWorldHint is false on all ten. Every tool works on the user's own
327
+ // - openWorldHint is false on all eleven. Every tool works on the user's own
324
328
  // memory store and reaches nothing else; that the store sits behind an API
325
329
  // does not make it an open world.
326
- // - destructiveHint is true only for a tool that deletes. None of these ten
330
+ // - destructiveHint is true only for a tool that deletes. None of these eleven
327
331
  // does. Rating a memory moves its ranking signals and never alters or
328
332
  // erases what was saved (see memory_feedback).
329
333
  /**
@@ -349,7 +353,11 @@ export function registerMemoryTools(server, deps) {
349
353
  * wire, so the diagnosis can never describe a different store than the request.
350
354
  */
351
355
  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);
356
+ return probeReadScope(searched, () => apiFetch("/memory/stats", {
357
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
358
+ }), () => apiFetch("/memory/rooms", {
359
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
360
+ }), safeInline);
353
361
  }
354
362
  // --- Tool: memory_write ---
355
363
  server.registerTool("memory_write", {
@@ -402,7 +410,7 @@ export function registerMemoryTools(server, deps) {
402
410
  reason: z
403
411
  .string()
404
412
  .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."),
413
+ .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
414
  importance: z
407
415
  .number()
408
416
  .optional()
@@ -453,7 +461,9 @@ export function registerMemoryTools(server, deps) {
453
461
  // {@link WriteAuthor}), and this package cannot know the sixth field a
454
462
  // future core release adds any better than core's own validator does.
455
463
  const authorRaw = writeAuthor?.();
456
- const author = typeof authorRaw === "object" && authorRaw !== null ? authorRaw : undefined;
464
+ const author = typeof authorRaw === "object" && authorRaw !== null
465
+ ? authorRaw
466
+ : undefined;
457
467
  const r = await apiFetch("/memory/write", {
458
468
  method: "POST",
459
469
  body: JSON.stringify(writeRequestBody({ content, concepts, domain }, author)),
@@ -501,7 +511,8 @@ export function registerMemoryTools(server, deps) {
501
511
  // exactly instead (src/names.ts). If it will not fit, say THAT rather than
502
512
  // drop the clause: omitting it would report a server that gave no reason.
503
513
  const reasonQuote = r?.reason
504
- ? (exactLiteral(r.reason, 400)?.literal ?? "(too long to quote exactly)")
514
+ ? (exactLiteral(r.reason, 400)?.literal ??
515
+ "(too long to quote exactly)")
505
516
  : "";
506
517
  // Structured twins of the two text-only values above, for
507
518
  // `structuredContent`: the raw number instead of the two-decimal
@@ -518,7 +529,9 @@ export function registerMemoryTools(server, deps) {
518
529
  : undefined;
519
530
  const optionalStructured = {
520
531
  ...(reasonStructured === undefined ? {} : { reason: reasonStructured }),
521
- ...(importanceStructured === undefined ? {} : { importance: importanceStructured }),
532
+ ...(importanceStructured === undefined
533
+ ? {}
534
+ : { importance: importanceStructured }),
522
535
  };
523
536
  // Narrowed to `true` by the guard above, so this is now the server's stated
524
537
  // verdict rather than "the body was not falsy".
@@ -683,7 +696,7 @@ export function registerMemoryTools(server, deps) {
683
696
  idempotentHint: true,
684
697
  openWorldHint: false,
685
698
  },
686
- }, async ({ query, top_k, domain, order_by, since, until, exclude_author }) => {
699
+ }, async ({ query, top_k, domain, order_by, since, until, exclude_author, }) => {
687
700
  // ONE value, used for the request AND for every decision about it.
688
701
  //
689
702
  // A previous draft sent the raw string but decided the wording from a
@@ -704,7 +717,15 @@ export function registerMemoryTools(server, deps) {
704
717
  const searched = searchedScope(domain);
705
718
  const r = await apiFetch("/memory/read", {
706
719
  method: "POST",
707
- body: JSON.stringify(readRequestBody({ query, top_k, domain, order_by, since, until, exclude_author })),
720
+ body: JSON.stringify(readRequestBody({
721
+ query,
722
+ top_k,
723
+ domain,
724
+ order_by,
725
+ since,
726
+ until,
727
+ exclude_author,
728
+ })),
708
729
  });
709
730
  // `items` must be a REAL array before anything below may speak. Core's
710
731
  // read response always carries one on a 200, so a body without it is not
@@ -1076,7 +1097,9 @@ export function registerMemoryTools(server, deps) {
1076
1097
  // to the service either, so paging stops here and the page says
1077
1098
  // the token could not be displayed (Copilot, #159).
1078
1099
  position =
1079
- typeof next === "string" && next && CURSOR_RE.test(next) ? next : undefined;
1100
+ typeof next === "string" && next && CURSOR_RE.test(next)
1101
+ ? next
1102
+ : undefined;
1080
1103
  // No cursor: the feed ended, and the page says so. No entries: the
1081
1104
  // server is not advancing, so continuing would spend requests on the
1082
1105
  // same nothing. Ceiling reached: the caller's count is spent.
@@ -1232,7 +1255,8 @@ export function registerMemoryTools(server, deps) {
1232
1255
  // would pass the regex by coercion and then fail the schema.
1233
1256
  ...(acceptedCursor == null
1234
1257
  ? { next_cursor: null }
1235
- : typeof acceptedCursor === "string" && CURSOR_RE.test(acceptedCursor)
1258
+ : typeof acceptedCursor === "string" &&
1259
+ CURSOR_RE.test(acceptedCursor)
1236
1260
  ? { next_cursor: acceptedCursor }
1237
1261
  : {}),
1238
1262
  });
@@ -1273,23 +1297,17 @@ export function registerMemoryTools(server, deps) {
1273
1297
  "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
1298
  // `memory_ids` is the name (2026-09-21): the tool rates memories, which
1275
1299
  // 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
1300
+ // unit), and the hosted connector already names the parameter so. The old
1301
+ // name, atom_ids, was accepted alongside it from 0.11 and removed in
1302
+ // 0.13 as announced; with one name left, memory_ids is schema-required.
1303
+ // It carries no format or count cap: the engine
1280
1304
  // validates the ids and sets no maximum, and ADR-025 keeps such checks
1281
1305
  // with the engine rather than copying them here.
1282
1306
  inputSchema: {
1283
1307
  memory_ids: z
1284
1308
  .array(z.string())
1285
1309
  .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."),
1310
+ .describe("IDs of the memories to rate, the `id:` line of each memory_read result."),
1293
1311
  outcome: z
1294
1312
  .number()
1295
1313
  .min(CORE_LIMITS.feedbackOutcome.minimum)
@@ -1354,34 +1372,8 @@ export function registerMemoryTools(server, deps) {
1354
1372
  idempotentHint: false,
1355
1373
  openWorldHint: false,
1356
1374
  },
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
- }
1375
+ }, async ({ memory_ids, outcome, domain }) => {
1376
+ const atom_ids = memory_ids;
1385
1377
  // `atom_ids` below is the ENGINE's field name for the same list; the
1386
1378
  // wire contract is unchanged. `domain` goes through `searchedScope`, as
1387
1379
  // on memory_read and memory_list_recent: an empty string counts as no
@@ -1391,7 +1383,9 @@ export function registerMemoryTools(server, deps) {
1391
1383
  const scope = searchedScope(domain);
1392
1384
  const r = await apiFetch("/memory/feedback", {
1393
1385
  method: "POST",
1394
- body: JSON.stringify(scope === undefined ? { atom_ids, outcome } : { atom_ids, outcome, domain: scope }),
1386
+ body: JSON.stringify(scope === undefined
1387
+ ? { atom_ids, outcome }
1388
+ : { atom_ids, outcome, domain: scope }),
1395
1389
  });
1396
1390
  // A FIELD THE SERVER DID NOT SEND IS UNKNOWN, NOT ZERO — the rule
1397
1391
  // memory_stats already applies with `num()`, broken here by
@@ -1416,7 +1410,9 @@ export function registerMemoryTools(server, deps) {
1416
1410
  // isSafeInteger, not isInteger: the output schema is z.number().int(),
1417
1411
  // and zod 4 rejects an integer above 2^53 - 1, so such a count would
1418
1412
  // turn the whole reply into an SDK validation error (review, 2026-09-23).
1419
- typeof reported === "number" && Number.isSafeInteger(reported) && reported >= 0
1413
+ typeof reported === "number" &&
1414
+ Number.isSafeInteger(reported) &&
1415
+ reported >= 0
1420
1416
  ? reported
1421
1417
  : undefined;
1422
1418
  // Structured twin of the text's average-valence clause built further
@@ -1491,7 +1487,8 @@ export function registerMemoryTools(server, deps) {
1491
1487
  text: `${sent} The service accepted the call but did not report how many ` +
1492
1488
  `memories it updated, so whether any changed is unknown from here. ` +
1493
1489
  `That is not evidence of a failure — do not re-send the same rating ` +
1494
- `on the strength of it.` + pickADirection,
1490
+ `on the strength of it.` +
1491
+ pickADirection,
1495
1492
  },
1496
1493
  ],
1497
1494
  };
@@ -1512,8 +1509,12 @@ export function registerMemoryTools(server, deps) {
1512
1509
  return structured("No feedback was recorded — none of those ids matched a memory " +
1513
1510
  `${feedbackScope(scope)}. ${feedbackMissCauses(scope)}`, {
1514
1511
  updated_count: 0,
1515
- ...(avgValenceStructured === undefined ? {} : { avg_valence: avgValenceStructured }),
1516
- ...(coactivationEdges === undefined ? {} : { coactivation_edges: coactivationEdges }),
1512
+ ...(avgValenceStructured === undefined
1513
+ ? {}
1514
+ : { avg_valence: avgValenceStructured }),
1515
+ ...(coactivationEdges === undefined
1516
+ ? {}
1517
+ : { coactivation_edges: coactivationEdges }),
1517
1518
  });
1518
1519
  }
1519
1520
  // WHOSE NUMBER THIS IS (#68). `updated_count` is the count of memories the
@@ -1531,11 +1532,11 @@ export function registerMemoryTools(server, deps) {
1531
1532
  const effect = outcome > 0
1532
1533
  ? " — they should surface sooner next time."
1533
1534
  : 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."
1535
+ ? // No fade. 0.9.1 (#95) withdrew "lets it fade" as false — nothing
1536
+ // time-decays, nothing is auto-deleted, and deletion has been
1537
+ // administrative-only since 0.9.0 — and this line kept promising it
1538
+ // after the release that deleted the claim from the README.
1539
+ " — they should rank lower next time. Out-ranked, not erased: nothing is deleted and nothing decays with time."
1539
1540
  : ".";
1540
1541
  // WHAT THE COUNT IS NOT: a guarantee that every id landed. `atom_ids.length`
1541
1542
  // was never compared with it, so five ids and `updated_count: 2` printed
@@ -1590,8 +1591,12 @@ export function registerMemoryTools(server, deps) {
1590
1591
  // resumed it afterwards.
1591
1592
  return structured(`${sent} The service reports ${noun} updated${effect}${valence}${mismatch}${pickADirection}`, {
1592
1593
  updated_count: count,
1593
- ...(avgValenceStructured === undefined ? {} : { avg_valence: avgValenceStructured }),
1594
- ...(coactivationEdges === undefined ? {} : { coactivation_edges: coactivationEdges }),
1594
+ ...(avgValenceStructured === undefined
1595
+ ? {}
1596
+ : { avg_valence: avgValenceStructured }),
1597
+ ...(coactivationEdges === undefined
1598
+ ? {}
1599
+ : { coactivation_edges: coactivationEdges }),
1595
1600
  });
1596
1601
  });
1597
1602
  // --- Tool: memory_stats ---
@@ -1678,8 +1683,8 @@ export function registerMemoryTools(server, deps) {
1678
1683
  // "0" is the same class of lie as an empty search claiming emptiness:
1679
1684
  // "Associations: 0" reads as "this memory has learned nothing", which is
1680
1685
  // 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");
1686
+ const num = (v) => typeof v === "number" ? String(v) : "unknown";
1687
+ const dec = (v) => typeof v === "number" ? v.toFixed(2) : "unknown";
1683
1688
  // THE surface this tool's own description sends the reader to, to
1684
1689
  // confirm the exact domain name before writing to it — so it has to be
1685
1690
  // able to answer that. (Until 2026-08-20 memory_delete_domain also sent
@@ -1733,7 +1738,9 @@ export function registerMemoryTools(server, deps) {
1733
1738
  // whatever num()/dec() print for the same malformed value, and that
1734
1739
  // text/data divergence is disclosed in the CHANGELOG, as S5 disclosed
1735
1740
  // its cursor semantics.
1736
- const safeIntOrUndefined = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : undefined;
1741
+ const safeIntOrUndefined = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0
1742
+ ? v
1743
+ : undefined;
1737
1744
  const finiteOrUndefined = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
1738
1745
  const episodesStructured = safeIntOrUndefined(r?.episodes);
1739
1746
  const prototypesStructured = safeIntOrUndefined(r?.prototypes);
@@ -1777,12 +1784,18 @@ export function registerMemoryTools(server, deps) {
1777
1784
  withDomainEscapeLegend(capResult(text, "The domain list was truncated — some domain names are not shown."), ...(Array.isArray(r?.domains) ? r.domains : [])), {
1778
1785
  memory_count: memoryCount,
1779
1786
  domains: domainsStructured,
1780
- ...(episodesStructured === undefined ? {} : { episodes: episodesStructured }),
1781
- ...(prototypesStructured === undefined ? {} : { prototypes: prototypesStructured }),
1787
+ ...(episodesStructured === undefined
1788
+ ? {}
1789
+ : { episodes: episodesStructured }),
1790
+ ...(prototypesStructured === undefined
1791
+ ? {}
1792
+ : { prototypes: prototypesStructured }),
1782
1793
  ...(hebbianEdgesStructured === undefined
1783
1794
  ? {}
1784
1795
  : { hebbian_edges: hebbianEdgesStructured }),
1785
- ...(avgValenceStructured === undefined ? {} : { avg_valence: avgValenceStructured }),
1796
+ ...(avgValenceStructured === undefined
1797
+ ? {}
1798
+ : { avg_valence: avgValenceStructured }),
1786
1799
  ...(avgImportanceStructured === undefined
1787
1800
  ? {}
1788
1801
  : { avg_importance: avgImportanceStructured }),
@@ -1948,7 +1961,10 @@ export function registerMemoryTools(server, deps) {
1948
1961
  },
1949
1962
  outputSchema: {
1950
1963
  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."),
1964
+ join_url: z
1965
+ .string()
1966
+ .optional()
1967
+ .describe("Landing URL the invitee can open to join."),
1952
1968
  code: z
1953
1969
  .string()
1954
1970
  .optional()
@@ -1958,7 +1974,11 @@ export function registerMemoryTools(server, deps) {
1958
1974
  .string()
1959
1975
  .optional()
1960
1976
  .describe("The room's domain address (xroom:<id>)."),
1961
- expires_at: z.string().nullable().optional().describe("ISO 8601 expiry, or null."),
1977
+ expires_at: z
1978
+ .string()
1979
+ .nullable()
1980
+ .optional()
1981
+ .describe("ISO 8601 expiry, or null."),
1962
1982
  },
1963
1983
  annotations: {
1964
1984
  title: "Invite to room",
@@ -1995,7 +2015,10 @@ export function registerMemoryTools(server, deps) {
1995
2015
  // structuredContent.share_message to pair it with.
1996
2016
  const shareMessageStructured = structuredText(rawMessage, 800);
1997
2017
  if (shareMessageStructured === undefined) {
1998
- return { content: [{ type: "text", text }], isError: true };
2018
+ return {
2019
+ content: [{ type: "text", text }],
2020
+ isError: true,
2021
+ };
1999
2022
  }
2000
2023
  // `join_url` through safeInline with the connector's own cap of 400,
2001
2024
  // as the connector does (mnemoverse-mcp-remote, memory_invite_to_room):
@@ -2021,21 +2044,33 @@ export function registerMemoryTools(server, deps) {
2021
2044
  // structured consumer lands on the instant this client used rather than
2022
2045
  // reading the naive string as local time (CodeRabbit, review round 2).
2023
2046
  // `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);
2047
+ const expiresAtStructured = r?.expires_at === null
2048
+ ? null
2049
+ : (utcInstant(r?.expires_at) ?? undefined);
2025
2050
  return structured(text, {
2026
2051
  share_message: shareMessageStructured,
2027
- ...(joinUrlStructured === undefined ? {} : { join_url: joinUrlStructured }),
2052
+ ...(joinUrlStructured === undefined
2053
+ ? {}
2054
+ : { join_url: joinUrlStructured }),
2028
2055
  ...(codeStructured === undefined ? {} : { code: codeStructured }),
2029
2056
  ...(scopeStructured === undefined ? {} : { scope: scopeStructured }),
2030
- ...(roomAddressStructured === undefined ? {} : { room_address: roomAddressStructured }),
2031
- ...(expiresAtStructured === undefined ? {} : { expires_at: expiresAtStructured }),
2057
+ ...(roomAddressStructured === undefined
2058
+ ? {}
2059
+ : { room_address: roomAddressStructured }),
2060
+ ...(expiresAtStructured === undefined
2061
+ ? {}
2062
+ : { expires_at: expiresAtStructured }),
2032
2063
  });
2033
2064
  });
2034
2065
  // --- Tool: memory_join_room ---
2035
2066
  server.registerTool("memory_join_room", {
2036
2067
  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
2068
  inputSchema: {
2038
- code: z.string().min(1).max(200).describe("The invite code (mnvr_...)."),
2069
+ code: z
2070
+ .string()
2071
+ .min(1)
2072
+ .max(200)
2073
+ .describe("The invite code (mnvr_...)."),
2039
2074
  },
2040
2075
  outputSchema: {
2041
2076
  room_id: z.string().describe("The room's id (room_...)."),
@@ -2043,7 +2078,10 @@ export function registerMemoryTools(server, deps) {
2043
2078
  .string()
2044
2079
  .describe("Domain address (xroom:<id>); pass as `domain` on read/write."),
2045
2080
  name: z.string().optional().describe("The room name."),
2046
- scope: z.string().optional().describe("Your role in the room ('read' | 'read_write')."),
2081
+ scope: z
2082
+ .string()
2083
+ .optional()
2084
+ .describe("Your role in the room ('read' | 'read_write')."),
2047
2085
  already_member: z
2048
2086
  .boolean()
2049
2087
  .optional()
@@ -2173,7 +2211,9 @@ export function registerMemoryTools(server, deps) {
2173
2211
  .describe("Domain address (xroom:<id>); pass as `domain` on read/write."),
2174
2212
  role: z.string().describe("'owner' or 'member'."),
2175
2213
  scope: z.string().optional().describe("'read' or 'read_write'."),
2176
- archived: z.boolean().describe("True if archived (owned rooms only)."),
2214
+ archived: z
2215
+ .boolean()
2216
+ .describe("True if archived (owned rooms only)."),
2177
2217
  })),
2178
2218
  },
2179
2219
  annotations: {
@@ -2290,7 +2330,8 @@ export function registerMemoryTools(server, deps) {
2290
2330
  droppedRooms += 1;
2291
2331
  continue;
2292
2332
  }
2293
- const nameStructured = typeof r?.name === "string" && exactLiteral(r.name, MAX_DOMAIN_LITERAL)
2333
+ const nameStructured = typeof r?.name === "string" &&
2334
+ exactLiteral(r.name, MAX_DOMAIN_LITERAL)
2294
2335
  ? structuredText(r.name, MAX_DOMAIN_LITERAL)
2295
2336
  : undefined;
2296
2337
  roomsStructured.push({
@@ -2368,7 +2409,9 @@ export function registerMemoryTools(server, deps) {
2368
2409
  alias: z
2369
2410
  .string()
2370
2411
  .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."),
2412
+ context: z
2413
+ .string()
2414
+ .describe("The secret's purpose/context — never the value."),
2372
2415
  concepts: z.array(z.string()).describe("Concept tags."),
2373
2416
  })),
2374
2417
  },
@@ -2391,7 +2434,9 @@ export function registerMemoryTools(server, deps) {
2391
2434
  return unreadableAnswerReply("The secret list", "a list of your Vault secrets", "none are stored");
2392
2435
  }
2393
2436
  if (list.length === 0) {
2394
- return structured("No secrets are stored in your Vault yet.", { secrets: [] });
2437
+ return structured("No secrets are stored in your Vault yet.", {
2438
+ secrets: [],
2439
+ });
2395
2440
  }
2396
2441
  const lines = list.map((s) => {
2397
2442
  const alias = safeInline(s?.alias) || "(no alias)";
@@ -2418,12 +2463,17 @@ export function registerMemoryTools(server, deps) {
2418
2463
  const concepts = Array.isArray(conceptsRaw)
2419
2464
  ? conceptsRaw.map((c) => structuredText(c, VAULT_CONCEPT_CAP))
2420
2465
  : undefined;
2421
- const conceptsOk = concepts !== undefined && concepts.every((c) => c !== undefined);
2466
+ const conceptsOk = concepts !== undefined &&
2467
+ concepts.every((c) => c !== undefined);
2422
2468
  if (alias === undefined || context === undefined || !conceptsOk) {
2423
2469
  droppedSecrets += 1;
2424
2470
  continue;
2425
2471
  }
2426
- secretsStructured.push({ alias, context, concepts: concepts });
2472
+ secretsStructured.push({
2473
+ alias,
2474
+ context,
2475
+ concepts: concepts,
2476
+ });
2427
2477
  }
2428
2478
  if (droppedSecrets > 0) {
2429
2479
  console.error(`Mnemoverse: vault_list dropped ${droppedSecrets} malformed secret ` +
@@ -2433,5 +2483,226 @@ export function registerMemoryTools(server, deps) {
2433
2483
  }
2434
2484
  return structured(finalText, { secrets: secretsStructured });
2435
2485
  });
2486
+ // --- Tool: memory_graph ---
2487
+ server.registerTool("memory_graph", {
2488
+ description: "Reads the association edges around given concepts: which concepts the memory has linked together, with each link's weight, outcome valence and co-activation count. Use to inspect what a memory store has learned or to explain why a read expanded to a concept. Read-only.",
2489
+ inputSchema: {
2490
+ // Bounds come from CORE_LIMITS (src/limits.ts, ADR-025) EXCEPT the
2491
+ // per-seed 200-character cap: GraphRequestSchema.seeds.items carries
2492
+ // no maxLength in the published JSON Schema at all (core enforces it
2493
+ // with a Pydantic validator that has no JSON-Schema-expressible
2494
+ // form, unlike writeContent/domain/etc.), so there is no numeric
2495
+ // field for scripts/generate-limits.mjs to fetch — that literal
2496
+ // stays inline, same as recentCursor's is generated but this one
2497
+ // cannot be.
2498
+ seeds: z
2499
+ .array(z
2500
+ .string()
2501
+ // CODE-POINT length, not `.max(200)` (Copilot review, round 2):
2502
+ // zod's `.max()` on a string counts UTF-16 CODE UNITS, and
2503
+ // core's 200-char/VARCHAR(200) bound counts Unicode CHARACTERS
2504
+ // — 200 astral characters (many emoji, some CJK-extension
2505
+ // ideographs) are 400 UTF-16 units and would be rejected here
2506
+ // while remaining a valid seed core would accept. `[...s]`
2507
+ // iterates by code point, the same technique `structuredText`
2508
+ // and `capResult`'s hint already use (src/names.ts, src/tools.ts).
2509
+ .refine((s) => [...s].length <= 200, {
2510
+ message: "must be at most 200 characters",
2511
+ })
2512
+ .refine((s) => s.trim().length > 0, {
2513
+ message: "must not be blank",
2514
+ }))
2515
+ .min(CORE_LIMITS.graphSeeds.minItems)
2516
+ .max(CORE_LIMITS.graphSeeds.maxItems)
2517
+ .describe("Concepts to center the graph on (1-20, each ≤200 chars, non-blank) — e.g. ['deploy', 'staging']. An unrecognised concept simply contributes no edges; it is not an error."),
2518
+ depth: z
2519
+ .number()
2520
+ .int()
2521
+ .min(CORE_LIMITS.graphDepth.minimum)
2522
+ .max(CORE_LIMITS.graphDepth.maximum)
2523
+ .optional()
2524
+ .describe("Hops to expand from the seeds (1-3, default 1). At depth 2 or 3, if min_weight is omitted the engine floors edge weight at 0.05 at EVERY hop — including the first — so a hub concept cannot fan out across the whole store before limit applies; pass min_weight explicitly (0 included) to see every edge anyway."),
2525
+ domain: z
2526
+ .string()
2527
+ .optional()
2528
+ .describe("Read a shared room's graph instead of your own: pass that room's address (e.g. 'xroom:room_01ABC'). Find room addresses with memory_list_rooms. Unlike memory_read, any OTHER value has no effect here — the association store has no domain column, so a plain domain name behaves exactly like omitting this field."),
2529
+ min_weight: z
2530
+ .number()
2531
+ .finite()
2532
+ .min(CORE_LIMITS.graphMinWeight.minimum)
2533
+ .optional()
2534
+ .describe("Only include edges at or above this weight (≥ 0). Omit for no floor at depth 1; at depth 2/3 the engine applies its own 0.05 floor when this is omitted (see depth) — pass 0 to see every edge at every depth."),
2535
+ limit: z
2536
+ .number()
2537
+ .int()
2538
+ .min(CORE_LIMITS.graphLimit.minimum)
2539
+ .max(CORE_LIMITS.graphLimit.maximum)
2540
+ .optional()
2541
+ .describe("Max edges to return (1-500, default 100 — mirrors memory_read's top_k bounds)."),
2542
+ },
2543
+ // Field names and descriptions follow core's GraphNodeSchema/GraphEdgeSchema
2544
+ // (https://core.mnemoverse.com/openapi.json) directly — this is a new
2545
+ // endpoint with no back-compat surface, so there is no prior wire shape to
2546
+ // reconcile the way memory_read's MEMORY_ITEM_OUTPUT does.
2547
+ outputSchema: {
2548
+ nodes: z
2549
+ .array(z.object({
2550
+ concept: z.string().describe("The concept name."),
2551
+ degree: z
2552
+ .number()
2553
+ .int()
2554
+ .describe("Edges in THIS response touching this concept — not its total degree across the whole store, which limit/truncated may cut short."),
2555
+ }))
2556
+ .describe("Concepts touched by edges below. A seed with no surviving edge (unrecognised concept, or every edge fell below the weight floor) is not listed."),
2557
+ edges: z
2558
+ .array(z.object({
2559
+ source: z
2560
+ .string()
2561
+ .describe("One side of the edge (storage order, not learn order)."),
2562
+ target: z
2563
+ .string()
2564
+ .describe("The other side of the edge (storage order, not learn order)."),
2565
+ weight: z.number().describe("Co-activation strength, 0 and up."),
2566
+ valence: z.number().describe("Outcome polarity, -1 to 1."),
2567
+ count: z.number().int().describe("Co-activation count."),
2568
+ updated_at: z
2569
+ .string()
2570
+ .describe("UTC instant this edge was last reinforced."),
2571
+ }))
2572
+ .describe("Association edges found within the requested depth."),
2573
+ truncated: z
2574
+ .boolean()
2575
+ .describe("True when a per-hop server cap or limit cut the walk short — the store may hold more edges than are reported here."),
2576
+ min_weight_applied: z
2577
+ .number()
2578
+ .describe("The weight floor actually used at every hop: your min_weight when you set one (0 included); otherwise 0.05 from depth 2, or 0 at depth 1."),
2579
+ },
2580
+ annotations: {
2581
+ title: "Association graph",
2582
+ readOnlyHint: true,
2583
+ destructiveHint: false,
2584
+ idempotentHint: true,
2585
+ openWorldHint: false,
2586
+ },
2587
+ }, async ({ seeds, depth, domain, min_weight, limit }) => {
2588
+ const r = await apiFetch("/memory/graph", {
2589
+ method: "POST",
2590
+ body: JSON.stringify(graphRequestBody({ seeds, depth, domain, min_weight, limit })),
2591
+ });
2592
+ // core's GraphResponseSchema sends all four of these on every 200
2593
+ // (nodes, edges, truncated, min_weight_applied are all required) — the
2594
+ // same "unreadable 2xx" class memory_read's item guard catches one
2595
+ // level up, here for the whole response shape and for each edge/node.
2596
+ // All-or-nothing, like memory_write's atom_id guard: there is no honest
2597
+ // partial rendering of a graph this client could not fully validate.
2598
+ const nodesRaw = r?.nodes;
2599
+ const edgesRaw = r?.edges;
2600
+ const truncatedRaw = r?.truncated;
2601
+ const minWeightAppliedRaw = r?.min_weight_applied;
2602
+ const shapeOk = Array.isArray(nodesRaw) &&
2603
+ Array.isArray(edgesRaw) &&
2604
+ typeof truncatedRaw === "boolean" &&
2605
+ typeof minWeightAppliedRaw === "number" &&
2606
+ Number.isFinite(minWeightAppliedRaw) &&
2607
+ // `degree`/`count` are safe-integer checks, not just finite ones:
2608
+ // the outputSchema declares both `z.number().int()` (CodeRabbit
2609
+ // review, round 1), and zod 4 rejects a non-integer AND an integer
2610
+ // above 2^53 - 1 there — the same reasoning memory_feedback's
2611
+ // `updated_count` guard already documents. `weight`/`valence` stay
2612
+ // finite-only: their outputSchema is plain `z.number()`.
2613
+ nodesRaw.every((n) => typeof n?.concept === "string" &&
2614
+ typeof n?.degree === "number" &&
2615
+ Number.isSafeInteger(n.degree)) &&
2616
+ edgesRaw.every((e) => typeof e?.source === "string" &&
2617
+ typeof e?.target === "string" &&
2618
+ typeof e?.weight === "number" &&
2619
+ Number.isFinite(e.weight) &&
2620
+ typeof e?.valence === "number" &&
2621
+ Number.isFinite(e.valence) &&
2622
+ typeof e?.count === "number" &&
2623
+ Number.isSafeInteger(e.count) &&
2624
+ typeof e?.updated_at === "string");
2625
+ if (!shapeOk) {
2626
+ return unreadableAnswerReply("The graph result", "a set of association edges", "these concepts have no associations");
2627
+ }
2628
+ const nodes = nodesRaw;
2629
+ const edges = edgesRaw;
2630
+ const truncated = truncatedRaw;
2631
+ const minWeightApplied = minWeightAppliedRaw;
2632
+ const hops = depth ?? 1;
2633
+ // Computed BEFORE the empty-edges branch and shared with the non-empty
2634
+ // one below (Copilot review, round 2): the early return used to skip
2635
+ // both notes entirely, so a truncated, all-below-floor answer (0 edges
2636
+ // survived the cut) told a text-only reader only "no association edges
2637
+ // found" — indistinguishable from a genuinely quiet concept.
2638
+ const truncatedNote = truncated
2639
+ ? "\n\n(truncated — the store may hold more edges than this call " +
2640
+ "reached; core does not promise these are sorted by weight before " +
2641
+ "the cut, so a higher min_weight, not a lower limit, is what " +
2642
+ "reliably narrows to the strongest ones)"
2643
+ : "";
2644
+ const floorAttribution = min_weight === undefined
2645
+ ? "the engine's own floor at this depth"
2646
+ : "the min_weight you passed";
2647
+ const floorNote = minWeightApplied > 0
2648
+ ? `\n\n(edges below weight ${minWeightApplied} were excluded — ${floorAttribution})`
2649
+ : "";
2650
+ if (edges.length === 0) {
2651
+ return structured(`No association edges found for ${seeds.length === 1 ? "this seed" : "these seeds"} ` +
2652
+ `within ${hops} hop${hops === 1 ? "" : "s"}` +
2653
+ (minWeightApplied > 0
2654
+ ? ` at or above weight ${minWeightApplied}`
2655
+ : "") +
2656
+ `.` +
2657
+ truncatedNote +
2658
+ floorNote, { nodes, edges, truncated, min_weight_applied: minWeightApplied });
2659
+ }
2660
+ // Sorted by weight, strongest first (the brief for this tool, and the
2661
+ // one ranking a reader can act on — core does not promise the wire
2662
+ // order is anything in particular). structuredContent.edges (below)
2663
+ // deliberately keeps the SERVER's order — only this rendered `lines`
2664
+ // array is reordered (llms.txt says so explicitly, Copilot round 2).
2665
+ const sorted = [...edges].sort((a, b) => b.weight - a.weight);
2666
+ // `-0.00` guard, same fix memory_feedback's avg_valence carries
2667
+ // (CodeRabbit on #146): valence can be negative and round to zero.
2668
+ const fmtSigned = (n) => n.toFixed(2).replace(/^-0\.00$/, "0.00");
2669
+ // Concept names are NOT this client's own text (CN-032): in a shared
2670
+ // room they are whatever concept another member's memory_write
2671
+ // supplied, so a newline or instruction-shaped string in `source`/
2672
+ // `target` must not be interpolated raw into text a DIFFERENT
2673
+ // principal's model then reads (Copilot review, round 1). Printed as
2674
+ // an exact JSON literal (src/names.ts), the same treatment `@domain`
2675
+ // and `[by "name"]` already get — not `safeInline`, which maps every
2676
+ // non-ASCII `\w`-excluded character to a space and would silently
2677
+ // erase a Cyrillic/CJK concept name the way it once erased author
2678
+ // names (issue #66). `structuredContent` still carries the raw value
2679
+ // (below): only the rendered TEXT needs the escape treatment.
2680
+ const nameLiteral = (s) => exactLiteral(s, MAX_DOMAIN_LITERAL)?.literal ??
2681
+ "(name cannot be printed exactly)";
2682
+ const lines = sorted.map((e, i) => `${i + 1}. ${nameLiteral(e.source)} — ${nameLiteral(e.target)} ` +
2683
+ `(weight: ${e.weight.toFixed(2)}, valence: ${fmtSigned(e.valence)}, ` +
2684
+ `count: ${e.count})${formatDateTag(e.updated_at)}`);
2685
+ const header = `${edges.length} association edge${edges.length === 1 ? "" : "s"} found` +
2686
+ (minWeightApplied > 0 ? ` (weight ≥ ${minWeightApplied})` : "") +
2687
+ ":";
2688
+ // The two status notes go BEFORE the edge lines, not after them: the
2689
+ // cap truncates from the end, so a page long enough to be cut would
2690
+ // otherwise lose exactly the lines that say it is incomplete and what
2691
+ // floor applied (CodeRabbit on #174, after the 0.12.1 merge). Legend
2692
+ // appended AFTER capResult, like memory_read's, for the same reason.
2693
+ const notes = truncatedNote + floorNote;
2694
+ const preface = notes ? header + notes + "\n" : header;
2695
+ const text = withDomainEscapeLegend(capResult([preface, ...lines].join("\n"), "Lower `limit` or raise `min_weight` to see fewer edges."), ...edges.flatMap((e) => [e.source, e.target]));
2696
+ // structuredContent carries the validated nodes/edges EXACTLY, uncapped
2697
+ // and unescaped (OD-11, the same rule memory_read's items get): a
2698
+ // client reading structured data reads these as the graph, not as a
2699
+ // rendering of it.
2700
+ return structured(text, {
2701
+ nodes,
2702
+ edges,
2703
+ truncated,
2704
+ min_weight_applied: minWeightApplied,
2705
+ });
2706
+ });
2436
2707
  }
2437
2708
  //# sourceMappingURL=tools.js.map