oh-memos-mcp 3.1.0 → 3.1.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.
@@ -4,9 +4,52 @@
4
4
  * memos_search, memos_search, memos_suggest, memos_context_resume
5
5
  */
6
6
  import type { TextContent, MemoryNode } from "../types.js";
7
+ /**
8
+ * Cypher for the recency query behind `memos_context_resume` and the
9
+ * `temporal` search intent.
10
+ *
11
+ * ## 为什么必须在 Cypher 里排除 WorkingMemory,而不是在 handler 层
12
+ *
13
+ * 这个查询原先不 RETURN `memory_type`,构造出的 metadata 只有
14
+ * `relativity`/`temporal_rank`/`source`。于是 `isEphemeralTier()` 永远读到
15
+ * undefined 并判为可见 —— **下游任何分层过滤对 temporal 记忆都是空转**。
16
+ *
17
+ * 受害面是四个调用点,不只是 context_resume:
18
+ * - `memos_context_resume`(成对显示,用户实测发现)
19
+ * - `memos_search` 的 temporal intent 两处(经 applyMemoryQualityPolicy,
20
+ * 过滤存在但读不到字段)
21
+ * - `memos_think`(同上)
22
+ *
23
+ * 在 Cypher 里滤还顺带避开超额取数:`LIMIT` 在过滤之后,要 10 条就得 10 条真
24
+ * 记忆。若放到 handler 层,limit 会被随即隐藏的副本吃掉(这正是 §9.6 在 list
25
+ * 路径踩过的顺序缺陷)。
26
+ *
27
+ * 同时 RETURN `memory_type`,让下游过滤从空转变成真的有落点 —— 双保险,
28
+ * 且 `mergeTemporalResults` 之后的 quality policy 也能正确判层。
29
+ *
30
+ * 逃生开关 `MEMOS_SHOW_WORKING_MEMORY=true` 时不加这个条件,与
31
+ * `filterEphemeralTier` 的语义保持一致。
32
+ */
33
+ export declare function buildTemporalCypher(timeWindowHours?: number, env?: NodeJS.ProcessEnv): string;
7
34
  export declare function getTemporalMemories(cubeId: string, topK?: number, timeWindowHours?: number): Promise<MemoryNode[]>;
8
35
  export declare function handleMemosSearch(arguments_: Record<string, unknown>): Promise<TextContent[]>;
9
36
  export declare function handleMemosSearchContext(arguments_: Record<string, unknown>): Promise<TextContent[]>;
10
37
  export declare function handleMemosSuggest(arguments_: Record<string, unknown>): Promise<TextContent[]>;
38
+ /**
39
+ * 分层过滤 + 截断,供 `memos_context_resume` 两条路径共用。
40
+ *
41
+ * ## 顺序要紧:滤层级必须在 slice 之前
42
+ *
43
+ * 放到之后,`limit` 会被随即隐藏的 WorkingMemory 副本吃掉 —— 要 10 条只拿到
44
+ * 5 条(用户实测就是 10 条里 5 对)。与 `prepareListMemories` 同形,
45
+ * 那处已经因为同样的原因栽过一次。
46
+ *
47
+ * ## 为什么必须是导出的函数
48
+ *
49
+ * 原先这段逻辑内联在 handler 里,单测触达不到 —— §9.6 与 P1.5 已两次证明:
50
+ * 切断 handler 内联逻辑时全部单测仍然通过。在测试里自行组合只能证明测试文件,
51
+ * 证明不了 handler。所以 handler 与测试必须调用**同一个**函数。
52
+ */
53
+ export declare function prepareResumeMemories(memories: readonly MemoryNode[], limit: number, env?: NodeJS.ProcessEnv): MemoryNode[];
11
54
  export declare function handleMemosContextResume(arguments_: Record<string, unknown>): Promise<TextContent[]>;
12
55
  //# sourceMappingURL=search.d.ts.map
@@ -15,6 +15,7 @@ import { summarizeActiveCanvases } from "./canvas.js";
15
15
  import { applyMemoryQualityPolicy } from "../memory-quality.js";
16
16
  import { readAccessStats } from "../access-tracker.js";
17
17
  import { buildSpreadCypher, mergeSpread, pickSeeds, rankNeighbours, rowToNeighbour, spreadEnabled, spreadRelativity, toSpreadNode, } from "../spreading-activation.js";
18
+ import { filterEphemeralTier, showsEphemeralTier } from "../memory-tier.js";
18
19
  import { apiErrorResponse, cubeRegistrationError, getCubeIdFromArgs, } from "./utils.js";
19
20
  /**
20
21
  * 检索排序策略的选项。
@@ -35,26 +36,59 @@ function qualityOptions(cubeId, query) {
35
36
  // ============================================================================
36
37
  // Temporal Graph Query (Neo4j)
37
38
  // ============================================================================
38
- export async function getTemporalMemories(cubeId, topK = 10, timeWindowHours) {
39
- if (!NEO4J_HTTP_URL || !NEO4J_USER || !NEO4J_PASSWORD) {
40
- logger.debug("Neo4j config missing, skipping temporal query");
41
- return [];
42
- }
43
- const auth = Buffer.from(`${NEO4J_USER}:${NEO4J_PASSWORD}`).toString("base64");
39
+ /**
40
+ * Cypher for the recency query behind `memos_context_resume` and the
41
+ * `temporal` search intent.
42
+ *
43
+ * ## 为什么必须在 Cypher 里排除 WorkingMemory,而不是在 handler 层
44
+ *
45
+ * 这个查询原先不 RETURN `memory_type`,构造出的 metadata 只有
46
+ * `relativity`/`temporal_rank`/`source`。于是 `isEphemeralTier()` 永远读到
47
+ * undefined 并判为可见 —— **下游任何分层过滤对 temporal 记忆都是空转**。
48
+ *
49
+ * 受害面是四个调用点,不只是 context_resume:
50
+ * - `memos_context_resume`(成对显示,用户实测发现)
51
+ * - `memos_search` 的 temporal intent 两处(经 applyMemoryQualityPolicy,
52
+ * 过滤存在但读不到字段)
53
+ * - `memos_think`(同上)
54
+ *
55
+ * 在 Cypher 里滤还顺带避开超额取数:`LIMIT` 在过滤之后,要 10 条就得 10 条真
56
+ * 记忆。若放到 handler 层,limit 会被随即隐藏的副本吃掉(这正是 §9.6 在 list
57
+ * 路径踩过的顺序缺陷)。
58
+ *
59
+ * 同时 RETURN `memory_type`,让下游过滤从空转变成真的有落点 —— 双保险,
60
+ * 且 `mergeTemporalResults` 之后的 quality policy 也能正确判层。
61
+ *
62
+ * 逃生开关 `MEMOS_SHOW_WORKING_MEMORY=true` 时不加这个条件,与
63
+ * `filterEphemeralTier` 的语义保持一致。
64
+ */
65
+ export function buildTemporalCypher(timeWindowHours, env = process.env) {
44
66
  const timeFilter = timeWindowHours
45
67
  ? `AND n.updated_at >= datetime() - duration({hours: ${timeWindowHours}})`
46
68
  : "";
47
- const cypher = `
69
+ const tierFilter = showsEphemeralTier(env)
70
+ ? ""
71
+ : "AND coalesce(n.memory_type, '') <> 'WorkingMemory'";
72
+ return `
48
73
  MATCH (n:Memory)
49
74
  WHERE n.user_name = $user_name
50
75
  AND n.status = 'activated'
51
76
  ${timeFilter}
77
+ ${tierFilter}
52
78
  RETURN n.id AS id, n.memory AS memory, n.key AS key,
53
79
  n.updated_at AS updated_at, n.background AS background,
54
- n.tags AS tags
80
+ n.tags AS tags, n.memory_type AS memory_type
55
81
  ORDER BY n.updated_at DESC
56
82
  LIMIT $top_k
57
83
  `;
84
+ }
85
+ export async function getTemporalMemories(cubeId, topK = 10, timeWindowHours) {
86
+ if (!NEO4J_HTTP_URL || !NEO4J_USER || !NEO4J_PASSWORD) {
87
+ logger.debug("Neo4j config missing, skipping temporal query");
88
+ return [];
89
+ }
90
+ const auth = Buffer.from(`${NEO4J_USER}:${NEO4J_PASSWORD}`).toString("base64");
91
+ const cypher = buildTemporalCypher(timeWindowHours);
58
92
  try {
59
93
  const response = await fetchWithTimeout(NEO4J_HTTP_URL, {
60
94
  method: "POST",
@@ -96,6 +130,13 @@ export async function getTemporalMemories(cubeId, topK = 10, timeWindowHours) {
96
130
  relativity: 0.8 - i * 0.05,
97
131
  temporal_rank: i + 1,
98
132
  source: "temporal_query",
133
+ // 带上层级,否则下游 filterEphemeralTier 读到 undefined 而空转。
134
+ // Cypher 已排除 WorkingMemory,这里是双保险 + 让 quality policy
135
+ // 能正确判层。r[6] 可能缺失(旧节点没写该属性),留 undefined 即
136
+ // 按可见处理,与 isEphemeralTier 的缺失语义一致。
137
+ ...(r.length > 6 && r[6] != null
138
+ ? { memory_type: String(r[6]) }
139
+ : {}),
99
140
  },
100
141
  });
101
142
  }
@@ -467,7 +508,7 @@ function renderContextResume(cubeId, recentMemories) {
467
508
  lines.push(...canvasLines);
468
509
  if (recentMemories.length > 0) {
469
510
  lines.push(`**Recent memories** (${recentMemories.length} items, last 24h):`, "");
470
- for (let i = 0; i < Math.min(recentMemories.length, 10); i++)
511
+ for (let i = 0; i < Math.min(recentMemories.length, RESUME_LIMIT); i++)
471
512
  lines.push(`${i + 1}. ${(recentMemories[i].memory ?? "").slice(0, 120).split("\n")[0]}`);
472
513
  }
473
514
  else
@@ -475,25 +516,68 @@ function renderContextResume(cubeId, recentMemories) {
475
516
  lines.push("", "---", "**REMINDER**: Use MCP memos tools for ALL memory operations.", "NEVER use `mkdir` or `Write` to create memory files.");
476
517
  return [{ type: "text", text: lines.join("\n") }];
477
518
  }
519
+ /**
520
+ * 分层过滤 + 截断,供 `memos_context_resume` 两条路径共用。
521
+ *
522
+ * ## 顺序要紧:滤层级必须在 slice 之前
523
+ *
524
+ * 放到之后,`limit` 会被随即隐藏的 WorkingMemory 副本吃掉 —— 要 10 条只拿到
525
+ * 5 条(用户实测就是 10 条里 5 对)。与 `prepareListMemories` 同形,
526
+ * 那处已经因为同样的原因栽过一次。
527
+ *
528
+ * ## 为什么必须是导出的函数
529
+ *
530
+ * 原先这段逻辑内联在 handler 里,单测触达不到 —— §9.6 与 P1.5 已两次证明:
531
+ * 切断 handler 内联逻辑时全部单测仍然通过。在测试里自行组合只能证明测试文件,
532
+ * 证明不了 handler。所以 handler 与测试必须调用**同一个**函数。
533
+ */
534
+ export function prepareResumeMemories(memories, limit, env = process.env) {
535
+ const visible = filterEphemeralTier(memories, (m) => m.metadata, env);
536
+ return [...visible].slice(0, limit);
537
+ }
538
+ /** `memos_context_resume` 显示的条数上限。 */
539
+ const RESUME_LIMIT = 10;
540
+ /**
541
+ * 取数时的超额系数。
542
+ *
543
+ * API 回退路径的 limit 由服务端施加,过滤只能在取回之后做,所以必须多取 ——
544
+ * 每条记忆恰好有一个 WorkingMemory 副本,2 倍即可覆盖,取 3 倍留余量
545
+ * (旧节点可能没有副本,多取的部分会被 slice 丢掉,无副作用)。
546
+ *
547
+ * temporal 路径不需要这个:Cypher 的 LIMIT 在过滤之后。
548
+ */
549
+ const RESUME_OVERFETCH = 3;
478
550
  export async function handleMemosContextResume(arguments_) {
479
551
  const cubeId = getCubeIdFromArgs(arguments_);
480
552
  const localProvider = getMemoryProvider(MEMOS_CUBES_DIR);
481
553
  if (localProvider) {
482
- const recentMemories = await localProvider.recent(cubeId, 24, 10);
483
- return renderContextResume(cubeId, recentMemories);
554
+ // Lite 的 JSONL 不写 memory_type,filterEphemeralTier 按缺失即可见处理,
555
+ // 所以这里过滤是空操作 —— 保留它是为了后端哪天开始写该字段时自动生效。
556
+ const recentMemories = await localProvider.recent(cubeId, 24, RESUME_LIMIT);
557
+ return renderContextResume(cubeId, prepareResumeMemories(recentMemories, RESUME_LIMIT));
484
558
  }
485
559
  const [regSuccess, regError] = await ensureCubeRegistered(cubeId);
486
560
  if (!regSuccess)
487
561
  return cubeRegistrationError(cubeId, regError);
488
- // Try temporal query first
489
- let recentMemories = await getTemporalMemories(cubeId, 10, 24);
562
+ // Try temporal query first. Cypher 已排除 WorkingMemory 且 LIMIT 在过滤之后,
563
+ // 所以按原样取 RESUME_LIMIT 条就是 RESUME_LIMIT 条真记忆。
564
+ let recentMemories = await getTemporalMemories(cubeId, RESUME_LIMIT, 24);
490
565
  // Fallback to API list
491
566
  if (recentMemories.length === 0) {
492
- const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories`, cubeId, { params: { user_id: MEMOS_USER, mem_cube_id: cubeId, limit: 10 } }, ensureCubeRegistered);
567
+ const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories`, cubeId, {
568
+ params: {
569
+ user_id: MEMOS_USER,
570
+ mem_cube_id: cubeId,
571
+ // 超额取:服务端施加 limit,过滤只能在取回后做,不多取则 limit 被
572
+ // 随即隐藏的副本吃掉。
573
+ limit: RESUME_LIMIT * RESUME_OVERFETCH,
574
+ },
575
+ }, ensureCubeRegistered);
493
576
  if (result.success && result.data) {
494
577
  recentMemories = extractSearchMemories(result.data.data ?? {});
495
578
  }
496
579
  }
580
+ recentMemories = prepareResumeMemories(recentMemories, RESUME_LIMIT);
497
581
  const lines = ["## Context Resumed", ""];
498
582
  // Unfinished task canvases come first: after a compaction, "where was I" is
499
583
  // more urgent than "what do we know". Headlines only — the model opens what it
@@ -503,7 +587,7 @@ export async function handleMemosContextResume(arguments_) {
503
587
  lines.push(...canvasLines);
504
588
  if (recentMemories.length > 0) {
505
589
  lines.push(`**Recent memories** (${recentMemories.length} items, last 24h):`, "");
506
- for (let i = 0; i < Math.min(recentMemories.length, 10); i++) {
590
+ for (let i = 0; i < Math.min(recentMemories.length, RESUME_LIMIT); i++) {
507
591
  const mem = recentMemories[i];
508
592
  const content = mem.memory ?? "";
509
593
  const summary = content.slice(0, 120).split("\n")[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-memos-mcp",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "MCP Server for oh-memos — Intelligent Memory Management (Node.js, no Python required)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,6 +22,7 @@
22
22
  "test:protocol:v2": "npm run build && node scripts/protocol-v2-smoke.mjs",
23
23
  "test:lite-smoke": "npm run build && node scripts/lite-smoke-test.mjs",
24
24
  "test:spread-smoke": "npm run build && node scripts/spread-smoke-test.mjs",
25
+ "test:host-env-smoke": "npm run build && node scripts/host-env-smoke.mjs",
25
26
  "schema:semantic": "npm run build && node scripts/schema-semantic-snapshot.mjs --check",
26
27
  "schema:semantic:freeze": "npm run build && node scripts/schema-semantic-snapshot.mjs --write",
27
28
  "test:pack": "node scripts/pack-contract.mjs",