oh-memos-mcp 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * memos_graph(mode="path"), memos_graph(mode="related"), memos_graph(mode="schema"), memos_graph(mode="impact")
5
5
  */
6
- import { MEMOS_URL, MEMOS_USER, NEO4J_HTTP_URL, NEO4J_USER, NEO4J_PASSWORD, logger, registeredCubes } from "../config.js";
6
+ import { MEMOS_URL, MEMOS_USER, NEO4J_HTTP_URL, NEO4J_USER, NEO4J_PASSWORD, logger, registeredCubes, } from "../config.js";
7
7
  import { fetchWithTimeout } from "../api-client.js";
8
8
  import { ensureCubeRegistered } from "../cube-manager.js";
9
9
  import { formatProvenance } from "../graph-provenance.js";
10
- import { buildGraphImportPlan, renderGraphImportPlan } from "../graphify-import.js";
10
+ import { buildGraphImportPlan, renderGraphImportPlan, } from "../graphify-import.js";
11
11
  import { detectQueryIntent, extractMemoriesFromResponse, getGraphsForIntent, getIntentDescription, } from "../query-processing.js";
12
12
  import { ERR_NEO4J_CONFIG, ERR_PARAM_MISSING, apiErrorResponse, cubeRegistrationError, errorResponse, getCubeIdFromArgs, } from "./utils.js";
13
13
  // ============================================================================
@@ -22,8 +22,13 @@ import { ERR_NEO4J_CONFIG, ERR_PARAM_MISSING, apiErrorResponse, cubeRegistration
22
22
  */
23
23
  export function handleMemosGraphifyImport(arguments_) {
24
24
  const raw = arguments_.graph_json;
25
- if (raw === undefined || raw === null || (typeof raw === "string" && !raw.trim())) {
26
- return errorResponse("graph_json is required for Graphify import dry-run", ERR_PARAM_MISSING, ["Pass the contents of Graphify graph.json as graph_json", "No database write occurs in this mode"]);
25
+ if (raw === undefined ||
26
+ raw === null ||
27
+ (typeof raw === "string" && !raw.trim())) {
28
+ return errorResponse("graph_json is required for Graphify import dry-run", ERR_PARAM_MISSING, [
29
+ "Pass the contents of Graphify graph.json as graph_json",
30
+ "No database write occurs in this mode",
31
+ ]);
27
32
  }
28
33
  let parsed = raw;
29
34
  if (typeof raw === "string") {
@@ -39,7 +44,9 @@ export function handleMemosGraphifyImport(arguments_) {
39
44
  }
40
45
  try {
41
46
  const plan = buildGraphImportPlan(parsed, {
42
- projectKey: typeof arguments_.project_key === "string" ? arguments_.project_key : undefined,
47
+ projectKey: typeof arguments_.project_key === "string"
48
+ ? arguments_.project_key
49
+ : undefined,
43
50
  });
44
51
  return [{ type: "text", text: renderGraphImportPlan(plan) }];
45
52
  }
@@ -66,7 +73,7 @@ async function neo4jQuery(cypher, parameters) {
66
73
  }),
67
74
  timeoutMs: 15,
68
75
  });
69
- const data = await response.json();
76
+ const data = (await response.json());
70
77
  return { ok: response.ok, data, status: response.status };
71
78
  }
72
79
  catch (err) {
@@ -105,7 +112,7 @@ export async function handleMemosTracePath(arguments_) {
105
112
  }),
106
113
  });
107
114
  if (response.ok) {
108
- const data = await response.json();
115
+ const data = (await response.json());
109
116
  if (data.code === 200) {
110
117
  const traceData = data.data ?? {};
111
118
  const found = traceData.path_found ?? traceData.found ?? false;
@@ -136,7 +143,10 @@ export async function handleMemosTracePath(arguments_) {
136
143
  const nodes = path.nodes ?? [];
137
144
  const edges = path.edges ?? [];
138
145
  // If API returns empty nodes, fall back to Neo4j
139
- if (nodes.length === 0 && NEO4J_HTTP_URL && NEO4J_USER && NEO4J_PASSWORD) {
146
+ if (nodes.length === 0 &&
147
+ NEO4J_HTTP_URL &&
148
+ NEO4J_USER &&
149
+ NEO4J_PASSWORD) {
140
150
  throw new Error("API returned empty path nodes, falling back to Neo4j");
141
151
  }
142
152
  results.push(`### Path ${i + 1} (Length: ${length})`, "", "```");
@@ -175,12 +185,15 @@ export async function handleMemosTracePath(arguments_) {
175
185
  const cypher = `
176
186
  MATCH (source:Memory), (target:Memory)
177
187
  WHERE source.id = $source_id AND target.id = $target_id
178
- MATCH path = shortestPath((source)-[*1..${maxDepth}]-(target))
179
- RETURN [n IN nodes(path) | {id: n.id, memory: n.memory}] AS nodes,
180
- [r IN relationships(path) | {type: type(r), provenance: properties(r)}] AS rels
188
+ MATCH path = shortestPath((source)-[*1..${maxDepth}]-(target))
189
+ RETURN [n IN nodes(path) | {id: n.id, memory: n.memory}] AS nodes,
190
+ [r IN relationships(path) | {type: type(r), provenance: properties(r)}] AS rels
181
191
  LIMIT 1
182
192
  `;
183
- const { ok, data, status } = await neo4jQuery(cypher, { source_id: sourceId, target_id: targetId });
193
+ const { ok, data, status } = await neo4jQuery(cypher, {
194
+ source_id: sourceId,
195
+ target_id: targetId,
196
+ });
184
197
  const results = ["## 🔗 Path Trace (Direct Query)", ""];
185
198
  if (ok && data) {
186
199
  const rows = data.results?.[0]?.data ?? [];
@@ -240,7 +253,7 @@ export async function handleMemosGetGraph(arguments_) {
240
253
  }),
241
254
  });
242
255
  if (searchResponse.ok) {
243
- const data = await searchResponse.json();
256
+ const data = (await searchResponse.json());
244
257
  if (data.code === 200) {
245
258
  memories = extractMemoriesFromResponse(data.data ?? {});
246
259
  }
@@ -252,10 +265,14 @@ export async function handleMemosGetGraph(arguments_) {
252
265
  const retrySearch = await fetchWithTimeout(`${MEMOS_URL}/search`, {
253
266
  method: "POST",
254
267
  headers: { "Content-Type": "application/json" },
255
- body: JSON.stringify({ user_id: MEMOS_USER, query, install_cube_ids: [cubeId] }),
268
+ body: JSON.stringify({
269
+ user_id: MEMOS_USER,
270
+ query,
271
+ install_cube_ids: [cubeId],
272
+ }),
256
273
  });
257
274
  if (retrySearch.ok) {
258
- const retryData = await retrySearch.json();
275
+ const retryData = (await retrySearch.json());
259
276
  if (retryData.code === 200) {
260
277
  memories = extractMemoriesFromResponse(retryData.data ?? {});
261
278
  }
@@ -280,10 +297,10 @@ export async function handleMemosGetGraph(arguments_) {
280
297
  cypher = `
281
298
  MATCH (a)-[r:${edgeTypesCypher}]->(b)
282
299
  WHERE a.id IN [${idList}] OR b.id IN [${idList}]
283
- RETURN a.id as source_id, a.memory as source_memory,
284
- type(r) as relation_type,
285
- b.id as target_id, b.memory as target_memory,
286
- properties(r) as relation_provenance
300
+ RETURN a.id as source_id, a.memory as source_memory,
301
+ type(r) as relation_type,
302
+ b.id as target_id, b.memory as target_memory,
303
+ properties(r) as relation_provenance
287
304
  LIMIT 20
288
305
  `;
289
306
  params = {};
@@ -293,10 +310,10 @@ export async function handleMemosGetGraph(arguments_) {
293
310
  cypher = `
294
311
  MATCH (a)-[r:${edgeTypesCypher}]->(b)
295
312
  WHERE a.memory CONTAINS $keyword OR b.memory CONTAINS $keyword
296
- RETURN a.id as source_id, a.memory as source_memory,
297
- type(r) as relation_type,
298
- b.id as target_id, b.memory as target_memory,
299
- properties(r) as relation_provenance
313
+ RETURN a.id as source_id, a.memory as source_memory,
314
+ type(r) as relation_type,
315
+ b.id as target_id, b.memory as target_memory,
316
+ properties(r) as relation_provenance
300
317
  LIMIT 20
301
318
  `;
302
319
  params = { keyword: firstWord };
@@ -358,6 +375,123 @@ export async function handleMemosGetGraph(arguments_) {
358
375
  // ============================================================================
359
376
  // memos_graph(mode="schema")
360
377
  // ============================================================================
378
+ /**
379
+ * 把 /graph/schema 的响应渲染成报告。
380
+ *
381
+ * ## 为什么是导出的纯函数
382
+ *
383
+ * 这段逻辑原本内联在 handler 里,单测触达不到 —— 于是**五个字段名读错了
384
+ * 却长期无人发现**。抽出来才能钉住字段契约。同一类漏洞本轮已出现过两次
385
+ * (list 路径的层级过滤、access-tracker 的接线)。
386
+ *
387
+ * ## 修掉的字段名不匹配
388
+ *
389
+ * API(SchemaData)返回的键与此前读取的键对不上,读不到就 `?? 0` 静默兜成零:
390
+ *
391
+ * | 此前读 | API 实际返回 |
392
+ * |----------------------------|--------------------|
393
+ * | `avg_connections_per_node` | `avg_connections` |
394
+ * | `orphan_node_count` | `orphan_nodes` |
395
+ * | `edge_type_distribution` | `edge_types` |
396
+ * | `memory_type_distribution` | `memory_types` |
397
+ * | `tag_frequency` | `top_tags` |
398
+ *
399
+ * `max_connections` / `total_nodes` / `total_edges` / `time_range` 恰好同名,
400
+ * 所以只有上面五项失效 —— 部分正确正是它难被发现的原因。
401
+ *
402
+ * 后果不只是少显示:健康评估读的也是错字段,于是 orphan 比例恒为 0(永远
403
+ * 报「连接良好」),avg 恒为 0(永远报「平均连接过低」)。两句结论同时出现、
404
+ * 自相矛盾,而它们**都是基于零值**得出的。
405
+ *
406
+ * 兼容两种键名:后端字段若将来改回长名,读取仍然成立。
407
+ */
408
+ export function formatSchemaReport(schema) {
409
+ /** 依次尝试多个键名,取第一个存在的。 */
410
+ const pick = (...keys) => {
411
+ for (const key of keys) {
412
+ if (schema[key] !== undefined && schema[key] !== null)
413
+ return schema[key];
414
+ }
415
+ return undefined;
416
+ };
417
+ const num = (...keys) => {
418
+ const raw = Number(pick(...keys));
419
+ return Number.isFinite(raw) ? raw : 0;
420
+ };
421
+ const dict = (...keys) => {
422
+ const raw = pick(...keys);
423
+ return raw && typeof raw === "object"
424
+ ? raw
425
+ : {};
426
+ };
427
+ const totalNodes = num("total_nodes");
428
+ const avgConn = num("avg_connections", "avg_connections_per_node");
429
+ const orphanCount = num("orphan_nodes", "orphan_node_count");
430
+ const results = ["## 📊 Knowledge Graph Schema", ""];
431
+ results.push("### Overview");
432
+ results.push(`- **Total Nodes**: ${totalNodes}`);
433
+ results.push(`- **Total Edges**: ${num("total_edges")}`);
434
+ results.push(`- **Avg Connections/Node**: ${avgConn.toFixed(2)}`);
435
+ results.push(`- **Max Connections**: ${num("max_connections")}`);
436
+ results.push(`- **Orphan Nodes**: ${orphanCount}`);
437
+ results.push("");
438
+ const timeRange = dict("time_range");
439
+ if (timeRange.earliest || timeRange.latest) {
440
+ results.push("### Time Range");
441
+ if (timeRange.earliest)
442
+ results.push(`- Earliest: ${timeRange.earliest}`);
443
+ if (timeRange.latest)
444
+ results.push(`- Latest: ${timeRange.latest}`);
445
+ results.push("");
446
+ }
447
+ const edgeDist = dict("edge_types", "edge_type_distribution");
448
+ if (Object.keys(edgeDist).length > 0) {
449
+ results.push("### Relationship Types");
450
+ for (const [t, c] of Object.entries(edgeDist).sort((a, b) => b[1] - a[1])) {
451
+ results.push(`- **${t}**: ${c}`);
452
+ }
453
+ results.push("");
454
+ }
455
+ const memDist = dict("memory_types", "memory_type_distribution");
456
+ if (Object.keys(memDist).length > 0) {
457
+ results.push("### Memory Types");
458
+ for (const [t, c] of Object.entries(memDist).sort((a, b) => b[1] - a[1])) {
459
+ results.push(`- ${t}: ${c}`);
460
+ }
461
+ results.push("");
462
+ }
463
+ // top_tags 在 SchemaData 里是 list;旧读法当成 {tag: count} 字典。两种都收。
464
+ const rawTags = pick("top_tags", "tag_frequency");
465
+ const tagItems = Array.isArray(rawTags)
466
+ ? rawTags
467
+ .slice(0, 10)
468
+ .map((entry) => Array.isArray(entry)
469
+ ? [String(entry[0]), entry[1]]
470
+ : [String(entry), ""])
471
+ : Object.entries(dict("top_tags", "tag_frequency")).slice(0, 10);
472
+ if (tagItems.length > 0) {
473
+ results.push("### Top Tags");
474
+ for (const [tag, count] of tagItems) {
475
+ results.push(count === "" ? `- \`${tag}\`` : `- \`${tag}\`: ${count}`);
476
+ }
477
+ results.push("");
478
+ }
479
+ results.push("### Health Assessment");
480
+ if (totalNodes > 0) {
481
+ const orphanRatio = orphanCount / totalNodes;
482
+ if (orphanRatio > 0.5)
483
+ results.push("⚠️ High orphan ratio - many memories are not connected");
484
+ else if (orphanRatio > 0.2)
485
+ results.push("📋 Moderate orphan ratio");
486
+ else
487
+ results.push("✅ Good connectivity - memories are well connected");
488
+ }
489
+ if (avgConn < 1)
490
+ results.push("⚠️ Low average connections - consider enriching relationships");
491
+ else if (avgConn > 5)
492
+ results.push("✅ Rich relationships - good knowledge graph density");
493
+ return results.join("\n");
494
+ }
361
495
  export async function handleMemosExportSchema(arguments_) {
362
496
  const cubeId = getCubeIdFromArgs(arguments_);
363
497
  const sampleSize = Math.min(Math.max(Number(arguments_.sample_size ?? 100), 10), 1000);
@@ -375,70 +509,10 @@ export async function handleMemosExportSchema(arguments_) {
375
509
  }),
376
510
  });
377
511
  if (response.ok) {
378
- const data = await response.json();
512
+ const data = (await response.json());
379
513
  if (data.code === 200) {
380
514
  const schema = data.data ?? {};
381
- const results = [];
382
- results.push("## 📊 Knowledge Graph Schema", "");
383
- results.push("### Overview");
384
- results.push(`- **Total Nodes**: ${schema.total_nodes ?? 0}`);
385
- results.push(`- **Total Edges**: ${schema.total_edges ?? 0}`);
386
- results.push(`- **Avg Connections/Node**: ${Number(schema.avg_connections_per_node ?? 0).toFixed(2)}`);
387
- results.push(`- **Max Connections**: ${schema.max_connections ?? 0}`);
388
- results.push(`- **Orphan Nodes**: ${schema.orphan_node_count ?? 0}`);
389
- results.push("");
390
- const timeRange = schema.time_range ?? {};
391
- if (timeRange.earliest || timeRange.latest) {
392
- results.push("### Time Range");
393
- if (timeRange.earliest)
394
- results.push(`- Earliest: ${timeRange.earliest}`);
395
- if (timeRange.latest)
396
- results.push(`- Latest: ${timeRange.latest}`);
397
- results.push("");
398
- }
399
- const edgeDist = schema.edge_type_distribution ?? {};
400
- if (Object.keys(edgeDist).length > 0) {
401
- results.push("### Relationship Types");
402
- for (const [t, c] of Object.entries(edgeDist).sort((a, b) => b[1] - a[1])) {
403
- results.push(`- **${t}**: ${c}`);
404
- }
405
- results.push("");
406
- }
407
- const memDist = schema.memory_type_distribution ?? {};
408
- if (Object.keys(memDist).length > 0) {
409
- results.push("### Memory Types");
410
- for (const [t, c] of Object.entries(memDist).sort((a, b) => b[1] - a[1])) {
411
- results.push(`- ${t}: ${c}`);
412
- }
413
- results.push("");
414
- }
415
- const tagFreq = schema.tag_frequency ?? {};
416
- const tagItems = Object.entries(tagFreq).slice(0, 10);
417
- if (tagItems.length > 0) {
418
- results.push("### Top Tags");
419
- for (const [tag, count] of tagItems) {
420
- results.push(`- \`${tag}\`: ${count}`);
421
- }
422
- results.push("");
423
- }
424
- results.push("### Health Assessment");
425
- const totalNodes = Number(schema.total_nodes ?? 0);
426
- const orphanCount = Number(schema.orphan_node_count ?? 0);
427
- if (totalNodes > 0) {
428
- const orphanRatio = orphanCount / totalNodes;
429
- if (orphanRatio > 0.5)
430
- results.push("⚠️ High orphan ratio - many memories are not connected");
431
- else if (orphanRatio > 0.2)
432
- results.push("📋 Moderate orphan ratio");
433
- else
434
- results.push("✅ Good connectivity - memories are well connected");
435
- }
436
- const avgConn = Number(schema.avg_connections_per_node ?? 0);
437
- if (avgConn < 1)
438
- results.push("⚠️ Low average connections - consider enriching relationships");
439
- else if (avgConn > 5)
440
- results.push("✅ Rich relationships - good knowledge graph density");
441
- return [{ type: "text", text: results.join("\n") }];
515
+ return [{ type: "text", text: formatSchemaReport(schema) }];
442
516
  }
443
517
  else {
444
518
  return apiErrorResponse("Schema export", String(data.message ?? "Unknown error"));
@@ -475,13 +549,15 @@ export async function handleMemosImpact(arguments_) {
475
549
  MATCH (source:Memory {id: $source_id})-[:CAUSE|FOLLOWS*1..${maxDepth}]->(node:Memory)
476
550
  WITH DISTINCT source, node
477
551
  MATCH path = shortestPath((source)-[:CAUSE|FOLLOWS*1..${maxDepth}]->(node))
478
- RETURN node.id AS id, node.key AS key, node.memory AS memory,
479
- length(path) AS depth, properties(node) AS node_provenance
552
+ RETURN node.id AS id, node.key AS key, node.memory AS memory,
553
+ length(path) AS depth, properties(node) AS node_provenance
480
554
  ORDER BY depth ASC
481
555
  LIMIT 30
482
556
  `;
483
557
  try {
484
- const { ok, data, status } = await neo4jQuery(cypher, { source_id: memoryId });
558
+ const { ok, data, status } = await neo4jQuery(cypher, {
559
+ source_id: memoryId,
560
+ });
485
561
  if (!ok) {
486
562
  return apiErrorResponse("Impact analysis", `Neo4j HTTP ${status}`);
487
563
  }
@@ -495,7 +571,12 @@ export async function handleMemosImpact(arguments_) {
495
571
  }
496
572
  const rows = data.results?.[0]?.data ?? [];
497
573
  if (rows.length === 0) {
498
- return [{ type: "text", text: "No forward impact found — this memory has no CAUSE or FOLLOWS successors." }];
574
+ return [
575
+ {
576
+ type: "text",
577
+ text: "No forward impact found — this memory has no CAUSE or FOLLOWS successors.",
578
+ },
579
+ ];
499
580
  }
500
581
  // Group by depth
501
582
  const depthGroups = {};
@@ -525,7 +606,9 @@ export async function handleMemosImpact(arguments_) {
525
606
  1: "Direct Impact",
526
607
  2: "Indirect Impact",
527
608
  };
528
- for (const depth of Object.keys(depthGroups).map(Number).sort((a, b) => a - b)) {
609
+ for (const depth of Object.keys(depthGroups)
610
+ .map(Number)
611
+ .sort((a, b) => a - b)) {
529
612
  const items = depthGroups[depth];
530
613
  const label = depthLabels[depth] ?? `Downstream (hop ${depth})`;
531
614
  results.push(`### ${label} (${items.length} node${items.length !== 1 ? "s" : ""})`, "");
@@ -9,4 +9,20 @@ export declare function handleMemosList(arguments_: Record<string, unknown>): Pr
9
9
  export declare function handleMemosGet(arguments_: Record<string, unknown>): Promise<TextContent[]>;
10
10
  export declare function handleMemosGetStats(arguments_: Record<string, unknown>): Promise<TextContent[]>;
11
11
  export declare function extractMemoriesFromData(data: SearchData): MemoryNode[];
12
+ /**
13
+ * memos_list_v2(Full 路径)的取数序列:提取 → 滤层级 → 滤业务类型 → 截断。
14
+ *
15
+ * 抽成导出的纯函数**只为让顺序可测**。这三步的次序不是随意的:
16
+ *
17
+ * - 滤层级必须在 slice **之前**。放到之后,`limit` 会被随即隐藏的
18
+ * WorkingMemory 副本吃掉 —— 要 20 条只拿到 10 条。
19
+ * - slice 必须最后。它是对外承诺的输出上界。
20
+ *
21
+ * 之前这段逻辑内联在 handler 里,单测触达不到:实测切断层级过滤后
22
+ * 全部 319 项 vitest 依然通过。这与 P1.5 的 W3/W4 是同一类漏洞
23
+ * (见 docs/design/memory-retrieval-optimization.md 第 11 节)。
24
+ * 在测试里自行组合这几步只能证明测试文件本身,证明不了 handler,
25
+ * 所以必须让 handler 和测试调用同一个函数。
26
+ */
27
+ export declare function prepareListMemories(data: SearchData, memoryType: string | undefined, limit: number): MemoryNode[];
12
28
  //# sourceMappingURL=memory.d.ts.map
@@ -9,6 +9,8 @@ import { getMemoryProvider } from "../providers/provider-factory.js";
9
9
  import { apiCallWithRetry } from "../api-client.js";
10
10
  import { ensureCubeRegistered } from "../cube-manager.js";
11
11
  import { parseMemoryWriteResponse } from "../memory-write-response.js";
12
+ import { recordAccess } from "../access-tracker.js";
13
+ import { filterEphemeralTier } from "../memory-tier.js";
12
14
  // formatMemoriesForDisplay intentionally not imported here — list output is built
13
15
  // from the truncated allMemories in handleMemosList (avoids printing the full cube).
14
16
  import { PREVIEW_COUNT, shouldCompact, compactedResultToText, toMinimal, toFull, } from "../models.js";
@@ -66,13 +68,28 @@ export async function handleMemosSave(arguments_) {
66
68
  content = `[${memoryType}] ${content}`;
67
69
  }
68
70
  if (isDuplicateSave(content, cubeId)) {
69
- return [{ type: "text", text: `⏭️ Skipped: Same content was saved within ${DEDUP_TTL_SECONDS}s (dedup protection)` }];
71
+ return [
72
+ {
73
+ type: "text",
74
+ text: `⏭️ Skipped: Same content was saved within ${DEDUP_TTL_SECONDS}s (dedup protection)`,
75
+ },
76
+ ];
70
77
  }
71
78
  const provider = getMemoryProvider(MEMOS_CUBES_DIR);
72
79
  if (provider) {
73
- const saved = await provider.save({ cubeId, content, memoryType, tags: [memoryType] });
80
+ const saved = await provider.save({
81
+ cubeId,
82
+ content,
83
+ memoryType,
84
+ tags: [memoryType],
85
+ });
74
86
  markSaved(content, cubeId);
75
- return [{ type: "text", text: `Memory saved as [${memoryType}] in local cube '${cubeId}' · ID: ${saved.id}` }];
87
+ return [
88
+ {
89
+ type: "text",
90
+ text: `Memory saved as [${memoryType}] in local cube '${cubeId}' · ID: ${saved.id}`,
91
+ },
92
+ ];
76
93
  }
77
94
  const [regSuccess, regError] = await ensureCubeRegistered(cubeId);
78
95
  if (!regSuccess)
@@ -88,8 +105,15 @@ export async function handleMemosSave(arguments_) {
88
105
  markSaved(content, cubeId);
89
106
  const write = parseMemoryWriteResponse(result.data);
90
107
  const idText = write.memoryIds.length > 0 ? ` · IDs: ${write.memoryIds.join(", ")}` : "";
91
- const warningText = write.warnings.length > 0 ? ` · warnings: ${write.warnings.join("; ")}` : "";
92
- return [{ type: "text", text: `Memory saved as [${memoryType}] in cube '${cubeId}'${idText}${warningText}` }];
108
+ const warningText = write.warnings.length > 0
109
+ ? ` · warnings: ${write.warnings.join("; ")}`
110
+ : "";
111
+ return [
112
+ {
113
+ type: "text",
114
+ text: `Memory saved as [${memoryType}] in cube '${cubeId}'${idText}${warningText}`,
115
+ },
116
+ ];
93
117
  }
94
118
  else if (result.data) {
95
119
  return apiErrorResponse("Save", String(result.data.message ?? "Unknown error"));
@@ -112,11 +136,34 @@ export async function handleMemosList(arguments_) {
112
136
  allMemories = allMemories.slice(0, limit);
113
137
  const totalCount = allMemories.length;
114
138
  if (compact && shouldCompact(totalCount)) {
115
- return [{ type: "text", text: compactedResultToText({ preview: allMemories.slice(0, PREVIEW_COUNT).map(toMinimal), totalCount, omittedCount: totalCount - Math.min(PREVIEW_COUNT, totalCount), message: 'Use memos_get(memory_id="<id>") for full details', query: "local list", cubeId }) }];
139
+ return [
140
+ {
141
+ type: "text",
142
+ text: compactedResultToText({
143
+ preview: allMemories.slice(0, PREVIEW_COUNT).map(toMinimal),
144
+ totalCount,
145
+ omittedCount: totalCount - Math.min(PREVIEW_COUNT, totalCount),
146
+ message: 'Use memos_get(memory_id="<id>") for full details',
147
+ query: "local list",
148
+ cubeId,
149
+ }),
150
+ },
151
+ ];
116
152
  }
117
153
  if (!allMemories.length)
118
- return [{ type: "text", text: `## Cube: ${cubeId}\n\nNo memories found.` }];
119
- return [{ type: "text", text: [`## Cube: ${cubeId} (${allMemories.length})`, "", ...allMemories.map((m, i) => `${i + 1}. [${extractMcpType(m)}] ${m.memory}\n ID: \`${m.id}\``)].join("\n\n") }];
154
+ return [
155
+ { type: "text", text: `## Cube: ${cubeId}\n\nNo memories found.` },
156
+ ];
157
+ return [
158
+ {
159
+ type: "text",
160
+ text: [
161
+ `## Cube: ${cubeId} (${allMemories.length})`,
162
+ "",
163
+ ...allMemories.map((m, i) => `${i + 1}. [${extractMcpType(m)}] ${m.memory}\n ID: \`${m.id}\``),
164
+ ].join("\n\n"),
165
+ },
166
+ ];
120
167
  }
121
168
  const [regSuccess, regError] = await ensureCubeRegistered(cubeId);
122
169
  if (!regSuccess)
@@ -132,11 +179,7 @@ export async function handleMemosList(arguments_) {
132
179
  const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories`, cubeId, { params }, ensureCubeRegistered);
133
180
  if (result.success && result.data) {
134
181
  const resultData = result.data.data ?? {};
135
- let allMemories = extractMemoriesFromData(resultData);
136
- if (memoryType) {
137
- allMemories = allMemories.filter((m) => extractMcpType(m) === memoryType);
138
- }
139
- allMemories = allMemories.slice(0, limit); // always bound output to the requested limit
182
+ const allMemories = prepareListMemories(resultData, memoryType, limit);
140
183
  const totalCount = allMemories.length;
141
184
  if (compact && shouldCompact(totalCount)) {
142
185
  const preview = allMemories.slice(0, PREVIEW_COUNT).map(toMinimal);
@@ -154,7 +197,12 @@ export async function handleMemosList(arguments_) {
154
197
  // (Previously this fell back to formatMemoriesForDisplay(resultData) = the full
155
198
  // untrimmed pull, which is how a limit=5 request printed the entire cube.)
156
199
  if (totalCount === 0) {
157
- return [{ type: "text", text: `## Cube: ${cubeId}\n\nNo memories${memoryType ? ` of type ${memoryType}` : ""} found.` }];
200
+ return [
201
+ {
202
+ type: "text",
203
+ text: `## Cube: ${cubeId}\n\nNo memories${memoryType ? ` of type ${memoryType}` : ""} found.`,
204
+ },
205
+ ];
158
206
  }
159
207
  const header = memoryType
160
208
  ? `## Cube: ${cubeId} — type=${memoryType} (${totalCount})`
@@ -189,16 +237,40 @@ export async function handleMemosGet(arguments_) {
189
237
  const node = await localProvider.get(cubeId, memoryId);
190
238
  if (!node)
191
239
  return [{ type: "text", text: notFoundText(memoryId) }];
240
+ // 只在确实取到记忆后记账 —— 未命中的 id 不构成使用度信号。
241
+ recordAccess(MEMOS_CUBES_DIR, cubeId, [memoryId]);
192
242
  const full = toFull(node, cubeId, MEMOS_USER);
193
- return [{ type: "text", text: ["## 📝 Memory Details", "", `**ID**: \`${full.id}\``, `**Type**: ${full.memoryType}`, `**Cube**: ${full.cubeId} (local)`, full.tags.length ? `**Tags**: ${full.tags.join(", ")}` : "", full.createdAt ? `**Created**: ${full.createdAt}` : "", "", "### Content", "", full.content].filter(Boolean).join("\n") }];
243
+ return [
244
+ {
245
+ type: "text",
246
+ text: [
247
+ "## 📝 Memory Details",
248
+ "",
249
+ `**ID**: \`${full.id}\``,
250
+ `**Type**: ${full.memoryType}`,
251
+ `**Cube**: ${full.cubeId} (local)`,
252
+ full.tags.length ? `**Tags**: ${full.tags.join(", ")}` : "",
253
+ full.createdAt ? `**Created**: ${full.createdAt}` : "",
254
+ "",
255
+ "### Content",
256
+ "",
257
+ full.content,
258
+ ]
259
+ .filter(Boolean)
260
+ .join("\n"),
261
+ },
262
+ ];
194
263
  }
195
264
  const [regSuccess, regError] = await ensureCubeRegistered(cubeId);
196
265
  if (!regSuccess)
197
266
  return cubeRegistrationError(cubeId, regError);
198
267
  const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories/${cubeId}/${memoryId}`, cubeId, {}, ensureCubeRegistered);
199
268
  if (result.success && result.data) {
200
- const resultData = result.data.data;
269
+ const resultData = result.data
270
+ .data;
201
271
  if (resultData) {
272
+ // Full 模式同样记在本机侧车 —— 使用度是本机数据,不需要后端端点。
273
+ recordAccess(MEMOS_CUBES_DIR, cubeId, [memoryId]);
202
274
  const fullMem = toFull(resultData, cubeId, MEMOS_USER);
203
275
  const lines = [
204
276
  "## 📝 Memory Details",
@@ -265,14 +337,25 @@ export async function handleMemosGetStats(arguments_) {
265
337
  return [{ type: "text", text: `No memories found in cube '${cubeId}'.` }];
266
338
  }
267
339
  const typeIcons = {
268
- BUGFIX: "🐛", ERROR_PATTERN: "🔴", DECISION: "📋",
269
- GOTCHA: "⚠️", CODE_PATTERN: "📝", CONFIG: "⚙️",
270
- FEATURE: "✨", MILESTONE: "🎯", PROGRESS: "📊", INFERRED: "🔗",
340
+ BUGFIX: "🐛",
341
+ ERROR_PATTERN: "🔴",
342
+ DECISION: "📋",
343
+ GOTCHA: "⚠️",
344
+ CODE_PATTERN: "📝",
345
+ CONFIG: "⚙️",
346
+ FEATURE: "✨",
347
+ MILESTONE: "🎯",
348
+ PROGRESS: "📊",
349
+ INFERRED: "🔗",
271
350
  SYNTHESIS: "🧠",
272
351
  };
273
- const result_lines = [`## 📊 Memory Stats: ${cubeId}`, `Total Memories: **${total}**`, ""];
352
+ const result_lines = [
353
+ `## 📊 Memory Stats: ${cubeId}`,
354
+ `Total Memories: **${total}**`,
355
+ "",
356
+ ];
274
357
  for (const [mtype, count] of Object.entries(stats).sort((a, b) => b[1] - a[1])) {
275
- const percentage = (count / total * 100).toFixed(1);
358
+ const percentage = ((count / total) * 100).toFixed(1);
276
359
  const icon = typeIcons[mtype] ?? "📌";
277
360
  result_lines.push(`- ${icon} **${mtype}**: ${count} (${percentage}%)`);
278
361
  }
@@ -287,7 +370,7 @@ export async function handleMemosGetStats(arguments_) {
287
370
  result_lines.push(" `BUGFIX` · `DECISION` · `MILESTONE` · `FEATURE` · `GOTCHA` · `CONFIG`");
288
371
  }
289
372
  if (userTyped > 0) {
290
- result_lines.push("", `✅ **用户标注记忆**: ${userTyped} 条 (${(userTyped / total * 100).toFixed(0)}%)`);
373
+ result_lines.push("", `✅ **用户标注记忆**: ${userTyped} 条 (${((userTyped / total) * 100).toFixed(0)}%)`);
291
374
  }
292
375
  return [{ type: "text", text: result_lines.join("\n") }];
293
376
  }
@@ -315,4 +398,27 @@ export function extractMemoriesFromData(data) {
315
398
  }
316
399
  return memories;
317
400
  }
401
+ /**
402
+ * memos_list_v2(Full 路径)的取数序列:提取 → 滤层级 → 滤业务类型 → 截断。
403
+ *
404
+ * 抽成导出的纯函数**只为让顺序可测**。这三步的次序不是随意的:
405
+ *
406
+ * - 滤层级必须在 slice **之前**。放到之后,`limit` 会被随即隐藏的
407
+ * WorkingMemory 副本吃掉 —— 要 20 条只拿到 10 条。
408
+ * - slice 必须最后。它是对外承诺的输出上界。
409
+ *
410
+ * 之前这段逻辑内联在 handler 里,单测触达不到:实测切断层级过滤后
411
+ * 全部 319 项 vitest 依然通过。这与 P1.5 的 W3/W4 是同一类漏洞
412
+ * (见 docs/design/memory-retrieval-optimization.md 第 11 节)。
413
+ * 在测试里自行组合这几步只能证明测试文件本身,证明不了 handler,
414
+ * 所以必须让 handler 和测试调用同一个函数。
415
+ */
416
+ export function prepareListMemories(data, memoryType, limit) {
417
+ const extracted = extractMemoriesFromData(data);
418
+ const visible = [...filterEphemeralTier(extracted, (m) => m.metadata)];
419
+ const typed = memoryType
420
+ ? visible.filter((m) => extractMcpType(m) === memoryType)
421
+ : visible;
422
+ return typed.slice(0, limit);
423
+ }
318
424
  //# sourceMappingURL=memory.js.map