oh-memos-mcp 3.1.0 → 3.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -26
- package/dist/handlers/memory.js +35 -5
- package/dist/handlers/search.d.ts +43 -0
- package/dist/handlers/search.js +105 -21
- package/dist/spreading-activation.js +14 -14
- package/dist/verbatim-source.d.ts +76 -0
- package/dist/verbatim-source.js +118 -0
- package/package.json +68 -67
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
MCP Server for **oh-memos** — Intelligent Persistent Memory for AI Assistants.
|
|
8
8
|
|
|
9
|
-
Pure Node.js. No Python required.
|
|
9
|
+
Pure Node.js. No Python required.
|
|
10
10
|
|
|
11
11
|
> **3.0:** requires Node.js 20+, serves legacy 2025-era and MCP `2026-07-28`
|
|
12
12
|
> clients from the same stdio package, and requires no data migration. Still on
|
|
@@ -19,8 +19,8 @@ Pure Node.js. No Python required. Works with `npx` out of the box.
|
|
|
19
19
|
- **Node.js 20 or newer**
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
npm i -g oh-memos-mcp # 3.x, npm latest — needs Node.js 20+
|
|
23
|
+
npm i -g oh-memos-mcp@2 # 2.x maintenance line — still runs on Node.js 18
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
oh-memos backend API must be running before connecting this MCP server:
|
|
@@ -35,50 +35,96 @@ oh-memos backend API must be running before connecting this MCP server:
|
|
|
35
35
|
|
|
36
36
|
## Quick Start
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Install once, then point the client at the installed entry point:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install -g oh-memos-mcp
|
|
42
|
+
npm root -g # prints the directory used in "args" below
|
|
43
|
+
```
|
|
39
44
|
|
|
40
45
|
```json
|
|
41
46
|
{
|
|
42
47
|
"mcpServers": {
|
|
43
48
|
"oh-memos": {
|
|
44
|
-
"command": "
|
|
45
|
-
"args": ["-
|
|
49
|
+
"command": "node",
|
|
50
|
+
"args": ["<npm root -g>/oh-memos-mcp/dist/index.js"],
|
|
46
51
|
"env": {
|
|
47
52
|
"MEMOS_URL": "http://localhost:18000",
|
|
48
53
|
"MEMOS_USER": "dev_user",
|
|
49
54
|
"MEMOS_DEFAULT_CUBE": "dev_cube",
|
|
50
|
-
"MEMOS_CUBES_DIR": "
|
|
55
|
+
"MEMOS_CUBES_DIR": "/path/to/oh-memos/data/oh-memos_cubes",
|
|
56
|
+
"MEMOS_ENV_FILE": "/path/to/oh-memos/.env"
|
|
51
57
|
}
|
|
52
58
|
}
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
```
|
|
56
62
|
|
|
57
|
-
|
|
63
|
+
To upgrade, reinstall globally — the config needs no edit:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm install -g oh-memos-mcp@latest
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Then restart the client. A running MCP server is never swapped mid-session: the
|
|
70
|
+
stdio pipe is bound when the client starts, so an upgrade takes effect only after
|
|
71
|
+
the client restarts.
|
|
72
|
+
|
|
73
|
+
### Why not `npx`
|
|
74
|
+
|
|
75
|
+
`npx -y oh-memos-mcp@<version>` also works and is fine for a one-off trial, but it
|
|
76
|
+
costs two extra processes per client — `cmd`/`sh` → `npx-cli` (node) → `cmd`/`sh` →
|
|
77
|
+
server (node), versus a direct launch's two. With several MCP clients open at once
|
|
78
|
+
that overhead is measurable: on one Windows machine running seven clients, the npx
|
|
79
|
+
wrappers alone held ~360 MB. `npx` also pins the version inside every client
|
|
80
|
+
config, so upgrading means editing each one.
|
|
81
|
+
|
|
82
|
+
| | `npx -y oh-memos-mcp@x.y.z` | `node <npm root -g>/oh-memos-mcp/dist/index.js` |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| Processes per client | 4 | 2 |
|
|
85
|
+
| Upgrade | edit every config | `npm i -g oh-memos-mcp@latest` |
|
|
86
|
+
| Version source | the config | the global install |
|
|
87
|
+
|
|
88
|
+
A third option is to point `args` at a checkout's `dist/index.js`. That is the
|
|
89
|
+
lowest-overhead choice for working *on* oh-memos — `npm run build` takes effect on
|
|
90
|
+
the next client restart, with no publish — but the client then breaks if the
|
|
91
|
+
checkout moves or its build output is stale.
|
|
92
|
+
|
|
93
|
+
Or use a `.env` file in your working directory (the server auto-discovers it when
|
|
94
|
+
the working directory is the project root):
|
|
58
95
|
|
|
59
96
|
```bash
|
|
60
97
|
cp node_modules/oh-memos-mcp/.env.example .env
|
|
61
98
|
# Edit .env with your paths
|
|
62
|
-
|
|
99
|
+
node "$(npm root -g)/oh-memos-mcp/dist/index.js"
|
|
63
100
|
```
|
|
64
101
|
|
|
65
102
|
---
|
|
66
103
|
|
|
67
104
|
## Configuration Examples
|
|
68
105
|
|
|
106
|
+
The `args` path below assumes npm's **default** global prefix for each platform.
|
|
107
|
+
A custom prefix (nvm, fnm, Volta, a hand-set `prefix`) puts the package
|
|
108
|
+
elsewhere, so confirm with `npm root -g` and use what it prints:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
node -e "console.log(require('path').join(process.argv[1],'oh-memos-mcp/dist/index.js'))" "$(npm root -g)"
|
|
112
|
+
```
|
|
113
|
+
|
|
69
114
|
### Linux / macOS
|
|
70
115
|
|
|
71
116
|
```json
|
|
72
117
|
{
|
|
73
118
|
"mcpServers": {
|
|
74
119
|
"oh-memos": {
|
|
75
|
-
"command": "
|
|
76
|
-
"args": ["
|
|
120
|
+
"command": "node",
|
|
121
|
+
"args": ["/usr/local/lib/node_modules/oh-memos-mcp/dist/index.js"],
|
|
77
122
|
"env": {
|
|
78
123
|
"MEMOS_URL": "http://localhost:18000",
|
|
79
124
|
"MEMOS_USER": "dev_user",
|
|
80
125
|
"MEMOS_DEFAULT_CUBE": "dev_cube",
|
|
81
|
-
"MEMOS_CUBES_DIR": "/home/user/oh-memos/data/oh-memos_cubes"
|
|
126
|
+
"MEMOS_CUBES_DIR": "/home/user/oh-memos/data/oh-memos_cubes",
|
|
127
|
+
"MEMOS_ENV_FILE": "/home/user/oh-memos/.env"
|
|
82
128
|
}
|
|
83
129
|
}
|
|
84
130
|
}
|
|
@@ -91,13 +137,14 @@ npx -y oh-memos-mcp
|
|
|
91
137
|
{
|
|
92
138
|
"mcpServers": {
|
|
93
139
|
"oh-memos": {
|
|
94
|
-
"command": "
|
|
95
|
-
"args": ["
|
|
140
|
+
"command": "node",
|
|
141
|
+
"args": ["C:/Users/you/AppData/Roaming/npm/node_modules/oh-memos-mcp/dist/index.js"],
|
|
96
142
|
"env": {
|
|
97
143
|
"MEMOS_URL": "http://localhost:18000",
|
|
98
144
|
"MEMOS_USER": "dev_user",
|
|
99
145
|
"MEMOS_DEFAULT_CUBE": "dev_cube",
|
|
100
|
-
"MEMOS_CUBES_DIR": "G:/test/oh-memos/data/oh-memos_cubes"
|
|
146
|
+
"MEMOS_CUBES_DIR": "G:/test/oh-memos/data/oh-memos_cubes",
|
|
147
|
+
"MEMOS_ENV_FILE": "G:/test/oh-memos/.env"
|
|
101
148
|
}
|
|
102
149
|
}
|
|
103
150
|
}
|
|
@@ -110,13 +157,14 @@ npx -y oh-memos-mcp
|
|
|
110
157
|
{
|
|
111
158
|
"mcpServers": {
|
|
112
159
|
"oh-memos": {
|
|
113
|
-
"command": "
|
|
114
|
-
"args": ["
|
|
160
|
+
"command": "node",
|
|
161
|
+
"args": ["/usr/lib/node_modules/oh-memos-mcp/dist/index.js"],
|
|
115
162
|
"env": {
|
|
116
163
|
"MEMOS_URL": "http://localhost:18000",
|
|
117
164
|
"MEMOS_USER": "dev_user",
|
|
118
165
|
"MEMOS_DEFAULT_CUBE": "dev_cube",
|
|
119
|
-
"MEMOS_CUBES_DIR": "/mnt/g/test/oh-memos/data/oh-memos_cubes"
|
|
166
|
+
"MEMOS_CUBES_DIR": "/mnt/g/test/oh-memos/data/oh-memos_cubes",
|
|
167
|
+
"MEMOS_ENV_FILE": "/mnt/g/test/oh-memos/.env"
|
|
120
168
|
}
|
|
121
169
|
}
|
|
122
170
|
}
|
|
@@ -137,13 +185,14 @@ be deleted without a prompt; add it only if you have decided you want that.
|
|
|
137
185
|
"mcpServers": {
|
|
138
186
|
"oh-memos": {
|
|
139
187
|
"type": "stdio",
|
|
140
|
-
"command": "
|
|
141
|
-
"args": ["-
|
|
188
|
+
"command": "node",
|
|
189
|
+
"args": ["<npm root -g>/oh-memos-mcp/dist/index.js"],
|
|
142
190
|
"env": {
|
|
143
191
|
"MEMOS_URL": "http://localhost:18000",
|
|
144
192
|
"MEMOS_USER": "dev_user",
|
|
145
193
|
"MEMOS_DEFAULT_CUBE": "dev_cube",
|
|
146
|
-
"MEMOS_CUBES_DIR": "/path/to/oh-memos/data/oh-memos_cubes"
|
|
194
|
+
"MEMOS_CUBES_DIR": "/path/to/oh-memos/data/oh-memos_cubes",
|
|
195
|
+
"MEMOS_ENV_FILE": "/path/to/oh-memos/.env"
|
|
147
196
|
},
|
|
148
197
|
"alwaysAllow": [
|
|
149
198
|
"memos_context_resume",
|
|
@@ -183,16 +232,80 @@ be deleted without a prompt; add it only if you have decided you want that.
|
|
|
183
232
|
| `NEO4J_PASSWORD` | No | — | Neo4j password |
|
|
184
233
|
| `MEMOS_ENV_FILE` | No | — | Explicit path to a `.env` file. Highest priority — see below |
|
|
185
234
|
|
|
235
|
+
### Mode and provider
|
|
236
|
+
|
|
237
|
+
| Variable | Required | Default | Description |
|
|
238
|
+
|----------|----------|---------|-------------|
|
|
239
|
+
| `MEMOS_MODE` | No | `full` | `lite` runs the local JSONL provider with no API, Python, Neo4j, or Qdrant. `full` uses the HTTP backend |
|
|
240
|
+
| `MEMOS_PROVIDER` | No | `local` when `MEMOS_MODE=lite`, else `api` | Storage backend. Set explicitly only to override the mode-derived default |
|
|
241
|
+
| `MEMOS_LOG_LEVEL` | No | `info` | `debug` / `info` / `warning` / `error`. Logs go to stderr so they never corrupt the stdio JSON-RPC stream |
|
|
242
|
+
|
|
243
|
+
`MEMOS_URL` is required for Full mode and unused in Lite.
|
|
244
|
+
|
|
245
|
+
### Retrieval behaviour (3.1.x)
|
|
246
|
+
|
|
247
|
+
These change what search returns. All are off or conservative by default, so
|
|
248
|
+
upgrading never silently changes ranking.
|
|
249
|
+
|
|
250
|
+
| Variable | Required | Default | Description |
|
|
251
|
+
|----------|----------|---------|-------------|
|
|
252
|
+
| `MEMOS_SPREAD_ACTIVATION` | No | `false` | One-hop graph spreading activation. When on, a search also returns memories reachable from its direct hits over `CAUSE` / `CONDITION` / `RELATE` edges, annotated ` · via CAUSE from <first 8 chars of the source id>`. Full mode only — it queries Neo4j directly, so `NEO4J_HTTP_URL`, `NEO4J_USER`, and `NEO4J_PASSWORD` must all resolve |
|
|
253
|
+
| `MEMOS_SHOW_WORKING_MEMORY` | No | `false` | Show the scheduler's `WorkingMemory` tier. The backend writes each memory twice — one short-term copy plus one long-term graph node with identical content — so leaving this off is what stops every memory appearing in pairs. Debugging only |
|
|
254
|
+
| `MEMOS_AUTO_CAPTURE` | No | `false` | Accept auto-captured memories. Auto-captured records are also ranked below explicit saves |
|
|
255
|
+
|
|
256
|
+
> **Spreading activation degrades silently without Neo4j credentials.** Retrieval
|
|
257
|
+
> still returns memories and logs nothing unusual — you simply get no ` via `
|
|
258
|
+
> annotations. If you enabled the switch and see no annotations, check that the
|
|
259
|
+
> Neo4j variables actually reach the server (under `npx`, that means
|
|
260
|
+
> `MEMOS_ENV_FILE`), not that the feature is missing.
|
|
261
|
+
|
|
262
|
+
Ranking that needs no configuration: per-type exponential decay (a `PROGRESS`
|
|
263
|
+
note ages out in weeks, a `DECISION` stays relevant for years), access
|
|
264
|
+
reinforcement, and near-duplicate folding with per-type thresholds. Folded
|
|
265
|
+
duplicates are reported as ` · folded N: <ids>` rather than dropped.
|
|
266
|
+
|
|
267
|
+
### Lite embeddings
|
|
268
|
+
|
|
269
|
+
| Variable | Required | Default | Description |
|
|
270
|
+
|----------|----------|---------|-------------|
|
|
271
|
+
| `MEMOS_LITE_EMBED` | No | `auto` | `off` forces lexical search. `auto` uses Ollama embeddings when reachable and falls back to lexical |
|
|
272
|
+
| `MEMOS_LITE_EMBED_URL` | No | `http://localhost:11434` | Ollama endpoint |
|
|
273
|
+
| `MEMOS_LITE_EMBED_MODEL` | No | `nomic-embed-text` | Embedding model |
|
|
274
|
+
|
|
275
|
+
### Read from `.env`, not from the client config
|
|
276
|
+
|
|
277
|
+
The server reads these when building a cube, and they are conventionally kept in
|
|
278
|
+
`.env` rather than repeated in every client config. `MOS_CHAT_MODEL` is
|
|
279
|
+
**required** — cube registration dies on it first, surfacing as a cube that
|
|
280
|
+
"registers" and immediately reports itself unregistered.
|
|
281
|
+
|
|
282
|
+
| Variable | Description |
|
|
283
|
+
|----------|-------------|
|
|
284
|
+
| `MOS_CHAT_MODEL` | Chat model for memory extraction. Required |
|
|
285
|
+
| `MOS_EMBEDDER_BACKEND` / `_PROVIDER` / `_MODEL` / `_API_BASE` | Embedder wiring |
|
|
286
|
+
| `MOS_ENABLE_REORGANIZE` | Background memory reorganization |
|
|
287
|
+
| `NEO4J_BACKEND` / `NEO4J_URI` / `NEO4J_DB_NAME` | Graph DB connection for cube construction |
|
|
288
|
+
| `NEO4J_AUTO_CREATE` / `NEO4J_USE_MULTI_DB` | Cube creation policy |
|
|
289
|
+
|
|
186
290
|
> **On locating `.env`**: without `MEMOS_ENV_FILE`, the file is found by guessing
|
|
187
291
|
> from position (working directory, two levels above the package, then dotenv's
|
|
188
|
-
> upward search). That works from a checkout
|
|
189
|
-
> where the package
|
|
190
|
-
> variable loads at all. Set `MEMOS_ENV_FILE` (or pass `--memos-env-file`)
|
|
191
|
-
> client's working directory and the install location are both outside the
|
|
192
|
-
> A path that does not exist warns on stderr rather than failing silently.
|
|
292
|
+
> upward search). That works from a checkout but **not from a global install or
|
|
293
|
+
> `npx`**, where the package sits outside the project and every candidate misses —
|
|
294
|
+
> so no variable loads at all. Set `MEMOS_ENV_FILE` (or pass `--memos-env-file`)
|
|
295
|
+
> when the client's working directory and the install location are both outside the
|
|
296
|
+
> project. A path that does not exist warns on stderr rather than failing silently.
|
|
193
297
|
>
|
|
194
298
|
> Copy `.env.example` to get started.
|
|
195
299
|
|
|
300
|
+
> **`MEMOS_ENV_FILE` wins over the client config.** It is loaded with
|
|
301
|
+
> `override: true`, so a value in that file beats the same key in the MCP client's
|
|
302
|
+
> `env` block. The positional fallbacks are the opposite — they only fill gaps and
|
|
303
|
+
> never override the launcher. This is deliberate: an explicitly named env file is
|
|
304
|
+
> meant to be authoritative, while a `.env` that merely happens to sit nearby is
|
|
305
|
+
> not. The practical consequence is that switches like
|
|
306
|
+
> `MEMOS_SHOW_WORKING_MEMORY` cannot be flipped from the client config once
|
|
307
|
+
> `.env` sets them — edit the env file instead.
|
|
308
|
+
|
|
196
309
|
---
|
|
197
310
|
|
|
198
311
|
## Tools (17)
|
package/dist/handlers/memory.js
CHANGED
|
@@ -11,6 +11,7 @@ import { ensureCubeRegistered } from "../cube-manager.js";
|
|
|
11
11
|
import { parseMemoryWriteResponse } from "../memory-write-response.js";
|
|
12
12
|
import { recordAccess } from "../access-tracker.js";
|
|
13
13
|
import { filterEphemeralTier } from "../memory-tier.js";
|
|
14
|
+
import { findSiblings, renderVerbatimSections, verbatimOf, } from "../verbatim-source.js";
|
|
14
15
|
// formatMemoriesForDisplay intentionally not imported here — list output is built
|
|
15
16
|
// from the truncated allMemories in handleMemosList (avoids printing the full cube).
|
|
16
17
|
import { PREVIEW_COUNT, shouldCompact, compactedResultToText, toMinimal, toFull, } from "../models.js";
|
|
@@ -223,6 +224,26 @@ export async function handleMemosList(arguments_) {
|
|
|
223
224
|
// ============================================================================
|
|
224
225
|
// memos_get
|
|
225
226
|
// ============================================================================
|
|
227
|
+
/**
|
|
228
|
+
* 取同源碎片。只在当前记忆确实带原文时才发这次请求 —— fast-path 写入不会付这个代价。
|
|
229
|
+
*
|
|
230
|
+
* 失败即返回空数组:同源列表是附加信息,取不到时 `memos_get` 仍应给出原文。
|
|
231
|
+
* 拉取量上限 200,避免大 cube 上把整库拉回来(碎片来自同一次写入,时间相邻,
|
|
232
|
+
* 后端按 updated_at 倒序返回,200 条足以覆盖)。
|
|
233
|
+
*/
|
|
234
|
+
async function fetchSiblings(cubeId, target) {
|
|
235
|
+
try {
|
|
236
|
+
const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories`, cubeId, { params: { user_id: MEMOS_USER, mem_cube_id: cubeId, limit: 200 } }, ensureCubeRegistered);
|
|
237
|
+
if (!result.success || !result.data)
|
|
238
|
+
return [];
|
|
239
|
+
const data = result.data.data;
|
|
240
|
+
return findSiblings(target, extractMemoriesFromData(data ?? {}));
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
logger.debug(`sibling lookup failed: ${String(err)}`);
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
226
247
|
export async function handleMemosGet(arguments_) {
|
|
227
248
|
const cubeId = getCubeIdFromArgs(arguments_);
|
|
228
249
|
const memoryId = String(arguments_.memory_id ?? "");
|
|
@@ -251,10 +272,11 @@ export async function handleMemosGet(arguments_) {
|
|
|
251
272
|
`**Cube**: ${full.cubeId} (local)`,
|
|
252
273
|
full.tags.length ? `**Tags**: ${full.tags.join(", ")}` : "",
|
|
253
274
|
full.createdAt ? `**Created**: ${full.createdAt}` : "",
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
275
|
+
// Lite 的 JSONL 不写 sources,verbatimOf 返回 null → 保持原有输出。
|
|
276
|
+
// 保留这个分支是为了后端哪天开始写该字段时自动生效。
|
|
277
|
+
...(verbatimOf(node) === null
|
|
278
|
+
? ["", "### Content", "", full.content]
|
|
279
|
+
: renderVerbatimSections(verbatimOf(node), [], full.content)),
|
|
258
280
|
]
|
|
259
281
|
.filter(Boolean)
|
|
260
282
|
.join("\n"),
|
|
@@ -272,6 +294,7 @@ export async function handleMemosGet(arguments_) {
|
|
|
272
294
|
// Full 模式同样记在本机侧车 —— 使用度是本机数据,不需要后端端点。
|
|
273
295
|
recordAccess(MEMOS_CUBES_DIR, cubeId, [memoryId]);
|
|
274
296
|
const fullMem = toFull(resultData, cubeId, MEMOS_USER);
|
|
297
|
+
const verbatim = verbatimOf(resultData);
|
|
275
298
|
const lines = [
|
|
276
299
|
"## 📝 Memory Details",
|
|
277
300
|
"",
|
|
@@ -285,7 +308,14 @@ export async function handleMemosGet(arguments_) {
|
|
|
285
308
|
lines.push(`**Tags**: ${fullMem.tags.join(", ")}`);
|
|
286
309
|
if (fullMem.createdAt)
|
|
287
310
|
lines.push(`**Created**: ${fullMem.createdAt}`);
|
|
288
|
-
|
|
311
|
+
// 有原文时原文即正文,`memory` 字段(LLM 概括)降级到下方的「抽取概括」区块。
|
|
312
|
+
// 无原文(fast-path 写入)时保持原有行为。
|
|
313
|
+
if (verbatim === null) {
|
|
314
|
+
lines.push("", "### Content", "", fullMem.content);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
lines.push(...renderVerbatimSections(verbatim, await fetchSiblings(cubeId, resultData), fullMem.content));
|
|
318
|
+
}
|
|
289
319
|
if (fullMem.background) {
|
|
290
320
|
lines.push("", "### Background", "", fullMem.background);
|
|
291
321
|
}
|
|
@@ -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
|
// ============================================================================
|
|
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) {
|
|
66
|
+
const timeFilter = timeWindowHours
|
|
67
|
+
? `AND n.updated_at >= datetime() - duration({hours: ${timeWindowHours}})`
|
|
68
|
+
: "";
|
|
69
|
+
const tierFilter = showsEphemeralTier(env)
|
|
70
|
+
? ""
|
|
71
|
+
: "AND coalesce(n.memory_type, '') <> 'WorkingMemory'";
|
|
72
|
+
return `
|
|
73
|
+
MATCH (n:Memory)
|
|
74
|
+
WHERE n.user_name = $user_name
|
|
75
|
+
AND n.status = 'activated'
|
|
76
|
+
${timeFilter}
|
|
77
|
+
${tierFilter}
|
|
78
|
+
RETURN n.id AS id, n.memory AS memory, n.key AS key,
|
|
79
|
+
n.updated_at AS updated_at, n.background AS background,
|
|
80
|
+
n.tags AS tags, n.memory_type AS memory_type
|
|
81
|
+
ORDER BY n.updated_at DESC
|
|
82
|
+
LIMIT $top_k
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
38
85
|
export async function getTemporalMemories(cubeId, topK = 10, timeWindowHours) {
|
|
39
86
|
if (!NEO4J_HTTP_URL || !NEO4J_USER || !NEO4J_PASSWORD) {
|
|
40
87
|
logger.debug("Neo4j config missing, skipping temporal query");
|
|
41
88
|
return [];
|
|
42
89
|
}
|
|
43
90
|
const auth = Buffer.from(`${NEO4J_USER}:${NEO4J_PASSWORD}`).toString("base64");
|
|
44
|
-
const
|
|
45
|
-
? `AND n.updated_at >= datetime() - duration({hours: ${timeWindowHours}})`
|
|
46
|
-
: "";
|
|
47
|
-
const cypher = `
|
|
48
|
-
MATCH (n:Memory)
|
|
49
|
-
WHERE n.user_name = $user_name
|
|
50
|
-
AND n.status = 'activated'
|
|
51
|
-
${timeFilter}
|
|
52
|
-
RETURN n.id AS id, n.memory AS memory, n.key AS key,
|
|
53
|
-
n.updated_at AS updated_at, n.background AS background,
|
|
54
|
-
n.tags AS tags
|
|
55
|
-
ORDER BY n.updated_at DESC
|
|
56
|
-
LIMIT $top_k
|
|
57
|
-
`;
|
|
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];
|
|
@@ -32,20 +32,20 @@ export const SPREAD_FROM_KEY = "spread_from";
|
|
|
32
32
|
*/
|
|
33
33
|
export function buildSpreadCypher() {
|
|
34
34
|
const types = SPREAD_EDGE_TYPES.map((t) => `'${t}'`).join(", ");
|
|
35
|
-
return `
|
|
36
|
-
MATCH (n:Memory)-[r]-(m:Memory)
|
|
37
|
-
WHERE n.id IN $seeds
|
|
38
|
-
AND type(r) IN [${types}]
|
|
39
|
-
AND NOT m.id IN $seeds
|
|
40
|
-
AND m.user_name = $user_name
|
|
41
|
-
AND coalesce(m.status, 'activated') = 'activated'
|
|
42
|
-
AND coalesce(m.memory_type, '') <> 'WorkingMemory'
|
|
43
|
-
AND coalesce(m.type, '') <> 'reasoning'
|
|
44
|
-
WITH m, n, type(r) AS via, size([(m)--() | 1]) AS degree
|
|
45
|
-
WITH m, collect({via: via, from: n.id})[0] AS edge, degree
|
|
46
|
-
RETURN m.id AS id, m.memory AS memory, m.key AS key, m.tags AS tags,
|
|
47
|
-
m.updated_at AS updated_at, edge.via AS via, edge.from AS from,
|
|
48
|
-
degree
|
|
35
|
+
return `
|
|
36
|
+
MATCH (n:Memory)-[r]-(m:Memory)
|
|
37
|
+
WHERE n.id IN $seeds
|
|
38
|
+
AND type(r) IN [${types}]
|
|
39
|
+
AND NOT m.id IN $seeds
|
|
40
|
+
AND m.user_name = $user_name
|
|
41
|
+
AND coalesce(m.status, 'activated') = 'activated'
|
|
42
|
+
AND coalesce(m.memory_type, '') <> 'WorkingMemory'
|
|
43
|
+
AND coalesce(m.type, '') <> 'reasoning'
|
|
44
|
+
WITH m, n, type(r) AS via, size([(m)--() | 1]) AS degree
|
|
45
|
+
WITH m, collect({via: via, from: n.id})[0] AS edge, degree
|
|
46
|
+
RETURN m.id AS id, m.memory AS memory, m.key AS key, m.tags AS tags,
|
|
47
|
+
m.updated_at AS updated_at, edge.via AS via, edge.from AS from,
|
|
48
|
+
degree
|
|
49
49
|
`;
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 原文取回(verbatim source)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* `MOS_TYPED_SAVE_FAST=false` 时后端走 LLM 抽取:一次写入被切成多条记忆,每条的
|
|
7
|
+
* `memory` 字段是 LLM 概括(约 200-250 字),而**逐字原文保存在
|
|
8
|
+
* `metadata.sources[].content` 里**(实测:一条 877 字原文被切成 5 条,5 条的
|
|
9
|
+
* sources 内容 sha 完全相同)。
|
|
10
|
+
*
|
|
11
|
+
* 于是同一份数据有两层,用途不同:
|
|
12
|
+
*
|
|
13
|
+
* | 层 | 内容 | 服务于 |
|
|
14
|
+
* |---|---|---|
|
|
15
|
+
* | `memory` | LLM 概括,细粒度 | 向量化与建边 —— 图谱节点质量、扩散联想 |
|
|
16
|
+
* | `metadata.sources[].content` | 逐字原文 | `memos_get` —— 完整上下文 |
|
|
17
|
+
*
|
|
18
|
+
* 两者不冲突:抽取让图谱拿到细粒度节点(`memos_search` 的 ` via CAUSE from` 联想
|
|
19
|
+
* 依赖它),而 `memos_get` 读 sources 就不丢原文。维护者的决定是「保证图谱节点
|
|
20
|
+
* 编写分析,同时让 memos_get 拉原文」。
|
|
21
|
+
*
|
|
22
|
+
* ## 同源判定用内容指纹,不用 session_id
|
|
23
|
+
*
|
|
24
|
+
* 实测 5 条碎片的 `session_id` 相同 —— 但那是**整个 MCP 会话**的 id,一个会话里
|
|
25
|
+
* 的多次无关写入会共享它,用它分组会把无关记忆归到一起。`created_at` 也不行:
|
|
26
|
+
* 5 条各不相同(微秒级递增)。
|
|
27
|
+
*
|
|
28
|
+
* 只有 sources 内容本身是精确的分组键。
|
|
29
|
+
*
|
|
30
|
+
* ## fast-path 下本模块是无操作
|
|
31
|
+
*
|
|
32
|
+
* `MOS_TYPED_SAVE_FAST=true`(默认)时原样存储,`memory` 就是原文、`sources` 为空
|
|
33
|
+
* → `verbatimOf()` 返回 null,渲染层保持原有行为。所以这套改动对两种模式都安全。
|
|
34
|
+
*
|
|
35
|
+
* 设计讨论见 docs/design/memory-retrieval-optimization.md。
|
|
36
|
+
*/
|
|
37
|
+
import type { MemoryNode } from "./types.js";
|
|
38
|
+
/**
|
|
39
|
+
* 取出逐字原文。
|
|
40
|
+
*
|
|
41
|
+
* 没有 sources、sources 为空、或第一条没有非空 content 时返回 null —— 调用方据此
|
|
42
|
+
* 判断「这条是 fast-path 存的,无需原文层」。
|
|
43
|
+
*
|
|
44
|
+
* 多条 sources 时用第一条:LLM 抽取路径下一次写入只产生一个 source(实测
|
|
45
|
+
* `src_n=1`)。多条的情况来自 messages 数组写入,此时第一条仍是用户输入。
|
|
46
|
+
*/
|
|
47
|
+
export declare function verbatimOf(node: MemoryNode | undefined | null): string | null;
|
|
48
|
+
/**
|
|
49
|
+
* 同源指纹。同一次写入切出的碎片共享它。
|
|
50
|
+
*
|
|
51
|
+
* 直接用原文本身,不做哈希:这是进程内的分组键,不持久化也不跨机器比较,
|
|
52
|
+
* 字符串相等就是精确判定。
|
|
53
|
+
*
|
|
54
|
+
* 初版加了 `${text.length}:` 前缀,理由是「让不同原文在第一个字符就分开」——
|
|
55
|
+
* 变异验证时发现构造不出它能防住的碰撞(去掉前缀后 `abc` 与 `abcdef` 指纹依然
|
|
56
|
+
* 不同),且 V8 的字符串相等本就先比长度。没有测试能支撑的防御代码比没有更糟,
|
|
57
|
+
* 所以删掉。
|
|
58
|
+
*
|
|
59
|
+
* 无原文返回 null —— null 不参与分组,避免把所有 fast-path 记忆归成一组。
|
|
60
|
+
*/
|
|
61
|
+
export declare function sourceFingerprint(node: MemoryNode | undefined | null): string | null;
|
|
62
|
+
/** 同源碎片的展示条目。 */
|
|
63
|
+
export interface SiblingRef {
|
|
64
|
+
id: string;
|
|
65
|
+
key: string;
|
|
66
|
+
}
|
|
67
|
+
/** 渲染原文区块与同源碎片列表,供 `memos_get` 两条路径共用。 */
|
|
68
|
+
export declare function renderVerbatimSections(verbatim: string | null, siblings: readonly SiblingRef[], summary: string): string[];
|
|
69
|
+
/**
|
|
70
|
+
* 在候选集里找与 target 同源的其他碎片。
|
|
71
|
+
*
|
|
72
|
+
* 排除 target 自身,也排除没有原文的节点。顺序按候选集原顺序,由调用方决定
|
|
73
|
+
* 候选集怎么排(后端返回顺序通常已按 created_at)。
|
|
74
|
+
*/
|
|
75
|
+
export declare function findSiblings(target: MemoryNode | undefined | null, candidates: readonly (MemoryNode | undefined | null)[], limit?: number): SiblingRef[];
|
|
76
|
+
//# sourceMappingURL=verbatim-source.d.ts.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 原文取回(verbatim source)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* `MOS_TYPED_SAVE_FAST=false` 时后端走 LLM 抽取:一次写入被切成多条记忆,每条的
|
|
7
|
+
* `memory` 字段是 LLM 概括(约 200-250 字),而**逐字原文保存在
|
|
8
|
+
* `metadata.sources[].content` 里**(实测:一条 877 字原文被切成 5 条,5 条的
|
|
9
|
+
* sources 内容 sha 完全相同)。
|
|
10
|
+
*
|
|
11
|
+
* 于是同一份数据有两层,用途不同:
|
|
12
|
+
*
|
|
13
|
+
* | 层 | 内容 | 服务于 |
|
|
14
|
+
* |---|---|---|
|
|
15
|
+
* | `memory` | LLM 概括,细粒度 | 向量化与建边 —— 图谱节点质量、扩散联想 |
|
|
16
|
+
* | `metadata.sources[].content` | 逐字原文 | `memos_get` —— 完整上下文 |
|
|
17
|
+
*
|
|
18
|
+
* 两者不冲突:抽取让图谱拿到细粒度节点(`memos_search` 的 ` via CAUSE from` 联想
|
|
19
|
+
* 依赖它),而 `memos_get` 读 sources 就不丢原文。维护者的决定是「保证图谱节点
|
|
20
|
+
* 编写分析,同时让 memos_get 拉原文」。
|
|
21
|
+
*
|
|
22
|
+
* ## 同源判定用内容指纹,不用 session_id
|
|
23
|
+
*
|
|
24
|
+
* 实测 5 条碎片的 `session_id` 相同 —— 但那是**整个 MCP 会话**的 id,一个会话里
|
|
25
|
+
* 的多次无关写入会共享它,用它分组会把无关记忆归到一起。`created_at` 也不行:
|
|
26
|
+
* 5 条各不相同(微秒级递增)。
|
|
27
|
+
*
|
|
28
|
+
* 只有 sources 内容本身是精确的分组键。
|
|
29
|
+
*
|
|
30
|
+
* ## fast-path 下本模块是无操作
|
|
31
|
+
*
|
|
32
|
+
* `MOS_TYPED_SAVE_FAST=true`(默认)时原样存储,`memory` 就是原文、`sources` 为空
|
|
33
|
+
* → `verbatimOf()` 返回 null,渲染层保持原有行为。所以这套改动对两种模式都安全。
|
|
34
|
+
*
|
|
35
|
+
* 设计讨论见 docs/design/memory-retrieval-optimization.md。
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* 取出逐字原文。
|
|
39
|
+
*
|
|
40
|
+
* 没有 sources、sources 为空、或第一条没有非空 content 时返回 null —— 调用方据此
|
|
41
|
+
* 判断「这条是 fast-path 存的,无需原文层」。
|
|
42
|
+
*
|
|
43
|
+
* 多条 sources 时用第一条:LLM 抽取路径下一次写入只产生一个 source(实测
|
|
44
|
+
* `src_n=1`)。多条的情况来自 messages 数组写入,此时第一条仍是用户输入。
|
|
45
|
+
*/
|
|
46
|
+
export function verbatimOf(node) {
|
|
47
|
+
const sources = node?.metadata
|
|
48
|
+
?.sources;
|
|
49
|
+
if (!Array.isArray(sources) || sources.length === 0)
|
|
50
|
+
return null;
|
|
51
|
+
const first = sources[0];
|
|
52
|
+
const content = first?.content;
|
|
53
|
+
if (typeof content !== "string" || content.trim() === "")
|
|
54
|
+
return null;
|
|
55
|
+
return content;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 同源指纹。同一次写入切出的碎片共享它。
|
|
59
|
+
*
|
|
60
|
+
* 直接用原文本身,不做哈希:这是进程内的分组键,不持久化也不跨机器比较,
|
|
61
|
+
* 字符串相等就是精确判定。
|
|
62
|
+
*
|
|
63
|
+
* 初版加了 `${text.length}:` 前缀,理由是「让不同原文在第一个字符就分开」——
|
|
64
|
+
* 变异验证时发现构造不出它能防住的碰撞(去掉前缀后 `abc` 与 `abcdef` 指纹依然
|
|
65
|
+
* 不同),且 V8 的字符串相等本就先比长度。没有测试能支撑的防御代码比没有更糟,
|
|
66
|
+
* 所以删掉。
|
|
67
|
+
*
|
|
68
|
+
* 无原文返回 null —— null 不参与分组,避免把所有 fast-path 记忆归成一组。
|
|
69
|
+
*/
|
|
70
|
+
export function sourceFingerprint(node) {
|
|
71
|
+
return verbatimOf(node);
|
|
72
|
+
}
|
|
73
|
+
/** 渲染原文区块与同源碎片列表,供 `memos_get` 两条路径共用。 */
|
|
74
|
+
export function renderVerbatimSections(verbatim, siblings, summary) {
|
|
75
|
+
if (verbatim === null)
|
|
76
|
+
return [];
|
|
77
|
+
const lines = ["", "### 原文 | Verbatim source", "", verbatim];
|
|
78
|
+
// 概括也一并给出:它是向量化与建边的实际输入,看得见才能判断图谱节点切得对不对。
|
|
79
|
+
if (summary.trim() !== "" && summary.trim() !== verbatim.trim()) {
|
|
80
|
+
lines.push("", "### 本节点的抽取概括 | Extracted summary (this node)", "", summary);
|
|
81
|
+
}
|
|
82
|
+
if (siblings.length > 0) {
|
|
83
|
+
lines.push("", `### 同源节点 | Sibling nodes (${siblings.length})`, "", "同一份原文被 LLM 抽取切成的其他节点。原文在每条上都完整,无需逐条取回;", "这里列出是为了看清图谱节点的切分方式。", "Other nodes the same source was split into. Each carries the full source;", "listed so the graph-node segmentation is visible.", "");
|
|
84
|
+
for (const s of siblings) {
|
|
85
|
+
lines.push(`- \`${s.id}\`${s.key ? ` — ${s.key}` : ""}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 在候选集里找与 target 同源的其他碎片。
|
|
92
|
+
*
|
|
93
|
+
* 排除 target 自身,也排除没有原文的节点。顺序按候选集原顺序,由调用方决定
|
|
94
|
+
* 候选集怎么排(后端返回顺序通常已按 created_at)。
|
|
95
|
+
*/
|
|
96
|
+
export function findSiblings(target, candidates, limit = 12) {
|
|
97
|
+
const fp = sourceFingerprint(target);
|
|
98
|
+
if (fp === null)
|
|
99
|
+
return [];
|
|
100
|
+
const targetId = String(target?.id ?? "");
|
|
101
|
+
const out = [];
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
for (const node of candidates) {
|
|
104
|
+
if (out.length >= limit)
|
|
105
|
+
break;
|
|
106
|
+
const id = String(node?.id ?? "");
|
|
107
|
+
if (!id || id === targetId || seen.has(id))
|
|
108
|
+
continue;
|
|
109
|
+
if (sourceFingerprint(node) !== fp)
|
|
110
|
+
continue;
|
|
111
|
+
seen.add(id);
|
|
112
|
+
const meta = (node?.metadata ?? {});
|
|
113
|
+
const key = String(meta.key ?? "").trim();
|
|
114
|
+
out.push({ id, key });
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=verbatim-source.js.map
|
package/package.json
CHANGED
|
@@ -1,67 +1,68 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "oh-memos-mcp",
|
|
3
|
-
"version": "3.1.
|
|
4
|
-
"description": "MCP Server for oh-memos — Intelligent Memory Management (Node.js, no Python required)",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "dist/index.js",
|
|
7
|
-
"bin": {
|
|
8
|
-
"oh-memos-mcp": "dist/index.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"dist/**/*.js",
|
|
12
|
-
"dist/**/*.d.ts",
|
|
13
|
-
"README.md",
|
|
14
|
-
"CHANGELOG.md",
|
|
15
|
-
".env.example"
|
|
16
|
-
],
|
|
17
|
-
"scripts": {
|
|
18
|
-
"build": "tsc",
|
|
19
|
-
"dev": "tsx src/index.ts",
|
|
20
|
-
"test": "vitest run",
|
|
21
|
-
"test:protocol": "npm run build && node scripts/protocol-smoke.mjs && node scripts/protocol-v2-smoke.mjs",
|
|
22
|
-
"test:protocol:v2": "npm run build && node scripts/protocol-v2-smoke.mjs",
|
|
23
|
-
"test:lite-smoke": "npm run build && node scripts/lite-smoke-test.mjs",
|
|
24
|
-
"test:spread-smoke": "npm run build && node scripts/spread-smoke-test.mjs",
|
|
25
|
-
"
|
|
26
|
-
"schema:semantic
|
|
27
|
-
"
|
|
28
|
-
"test:
|
|
29
|
-
"
|
|
30
|
-
"schema:budget
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"@
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "oh-memos-mcp",
|
|
3
|
+
"version": "3.1.2",
|
|
4
|
+
"description": "MCP Server for oh-memos — Intelligent Memory Management (Node.js, no Python required)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"oh-memos-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/**/*.js",
|
|
12
|
+
"dist/**/*.d.ts",
|
|
13
|
+
"README.md",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
".env.example"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"dev": "tsx src/index.ts",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:protocol": "npm run build && node scripts/protocol-smoke.mjs && node scripts/protocol-v2-smoke.mjs",
|
|
22
|
+
"test:protocol:v2": "npm run build && node scripts/protocol-v2-smoke.mjs",
|
|
23
|
+
"test:lite-smoke": "npm run build && node scripts/lite-smoke-test.mjs",
|
|
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",
|
|
26
|
+
"schema:semantic": "npm run build && node scripts/schema-semantic-snapshot.mjs --check",
|
|
27
|
+
"schema:semantic:freeze": "npm run build && node scripts/schema-semantic-snapshot.mjs --write",
|
|
28
|
+
"test:pack": "node scripts/pack-contract.mjs",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"schema:budget": "npm run build && node scripts/schema-budget.mjs --check",
|
|
31
|
+
"schema:budget:freeze": "npm run build && node scripts/schema-budget.mjs --write",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20.0.0"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"mcp",
|
|
39
|
+
"memory",
|
|
40
|
+
"memos",
|
|
41
|
+
"claude",
|
|
42
|
+
"ai",
|
|
43
|
+
"knowledge-graph"
|
|
44
|
+
],
|
|
45
|
+
"author": "xigou",
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/lsg1103275794/oh-memos.git",
|
|
50
|
+
"directory": "mcp-server-node"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/lsg1103275794/oh-memos/issues"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/lsg1103275794/oh-memos/tree/main/mcp-server-node",
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
58
|
+
"dotenv": "^16.0.0",
|
|
59
|
+
"zod": "^4.2.0"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@modelcontextprotocol/client": "^2.0.0",
|
|
63
|
+
"@types/node": "^22.0.0",
|
|
64
|
+
"tsx": "^4.23.12",
|
|
65
|
+
"typescript": "^5.5.0",
|
|
66
|
+
"vitest": "3.2.7"
|
|
67
|
+
}
|
|
68
|
+
}
|