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.
- package/dist/handlers/search.d.ts +43 -0
- package/dist/handlers/search.js +99 -15
- package/package.json +2 -1
|
@@ -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
|
package/dist/handlers/search.js
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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, {
|
|
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,
|
|
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.
|
|
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",
|