oh-memos-mcp 3.1.8 → 3.1.9

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/CHANGELOG.md CHANGED
@@ -4,9 +4,24 @@ All notable changes to `oh-memos-mcp` are documented here.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
- ---
8
-
9
- ## [3.1.8] - 2026-08-27
7
+ ---
8
+
9
+ ## [3.1.9] - 2026-09-16
10
+
11
+ 配套 API / Docker 镜像版本为 3.1.9。保存先确认原文与向量已持久化,再返回记忆 ID;
12
+ LLM 解析由 API 在后台自动完成,即使模型调用耗时数分钟,也不阻塞 MCP 保存回执。
13
+
14
+ - 识别 `vector_saved` 与 `enrichment_status`,明确显示向量已保存和后台解析状态,兼容旧 API。
15
+ - 未确认向量保存或缺少实际 ID 时返回 MCP `isError: true`,且不写入去重缓存。
16
+ - 请求和响应正文超时归类为 `API_TIMEOUT`,不再误报 API 离线;结果未知的 POST 不自动重放。
17
+ - 显式传递 `memory_type`;Wiki 导入同样拒绝未确认的向量写入。
18
+ - 完整详情使用原始创建时间,避免将后台解析的更新时间误标为创建时间。
19
+
20
+ 验证:582 项 Node 测试、旧版及 v2 MCP 协议检查通过。配套 API 的 118 项 Python 回归通过。
21
+ 真实 MCP 首次保存 596ms 返回,LLM 约 225 秒后补齐同一 ID 的元数据;重复保存 362ms,
22
+ 原文、ID 和已有解析结果均保留。
23
+
24
+ ## [3.1.8] - 2026-08-27
10
25
 
11
26
  仅 MCP server(npm `oh-memos-mcp`)。Python 包与容器镜像无改动,仍为 3.1.5。
12
27
 
package/README.md CHANGED
@@ -240,9 +240,36 @@ be deleted without a prompt; add it only if you have decided you want that.
240
240
  | `MEMOS_PROVIDER` | No | `local` when `MEMOS_MODE=lite`, else `api` | Storage backend. Set explicitly only to override the mode-derived default |
241
241
  | `MEMOS_LOG_LEVEL` | No | `info` | `debug` / `info` / `warning` / `error`. Logs go to stderr so they never corrupt the stdio JSON-RPC stream |
242
242
 
243
- `MEMOS_URL` is required for Full mode and unused in Lite.
244
-
245
- ### Retrieval behaviour (3.1.x)
243
+ `MEMOS_URL` is required for Full mode and unused in Lite.
244
+
245
+ ### Save acknowledgement and background parsing
246
+
247
+ In Full mode with the `tree_text` backend, `memos_save` returns after the
248
+ original text and its vector have been confirmed in storage. LLM extraction
249
+ continues in the background, even when a model call takes several minutes.
250
+ The response contains the stored ID; the API adds `vector_saved: true`,
251
+ `enrichment_status: "pending"`, and `queued: false`. Here `queued` describes
252
+ the memory write, which has already completed.
253
+
254
+ Parsing adds the key, tags, background, and extracted facts to that same record.
255
+ It preserves the original text, vector, ID, creation time, and explicit source
256
+ metadata. Graph conflict handling links these originals instead of merging or
257
+ deleting them. Identical retries target the same ID.
258
+
259
+ Two background consumers process persisted tasks. A failed parse leaves the
260
+ saved memory available and retries after 30 and 60 seconds; three completed
261
+ failed attempts stop automatic retries. Interrupted attempts can resume after
262
+ restart, including local projects restored from the cube registry. Parse status
263
+ and the exception type are stored as `enrichment_*` metadata.
264
+
265
+ This API path does not depend on `MOS_TYPED_SAVE_FAST` or the memory scheduler.
266
+ Existing library, chat/document ingestion, and Lite behavior remain available.
267
+ Upgrade/restart the API and reconnect MCP clients after rebuilding the Node
268
+ server. Older APIs without the additive fields remain compatible. An explicit
269
+ unconfirmed vector result is rejected; response timeouts report `API_TIMEOUT`
270
+ and do not automatically replay writes.
271
+
272
+ ### Retrieval behaviour (3.1.x)
246
273
 
247
274
  These change what search returns. All are off or conservative by default, so
248
275
  upgrading never silently changes ranking.
@@ -250,7 +277,7 @@ upgrading never silently changes ranking.
250
277
  | Variable | Required | Default | Description |
251
278
  |----------|----------|---------|-------------|
252
279
  | `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 |
280
+ | `MEMOS_SHOW_WORKING_MEMORY` | No | `false` | Show the scheduler's `WorkingMemory` tier. Chat/document and legacy ingestion can create short-term copies alongside long-term memories; hiding this tier avoids displaying those pairs. Vector-first content saves create one original record. Debugging only |
254
281
  | `MEMOS_AUTO_CAPTURE` | No | `false` | Accept auto-captured memories. Auto-captured records are also ranked below explicit saves |
255
282
 
256
283
  > **Spreading activation degrades silently without Neo4j credentials.** Retrieval
@@ -3,6 +3,19 @@
3
3
  *
4
4
  * HTTP fetch wrapper with timeout, retry, and health check.
5
5
  */
6
+ /** A response deadline does not prove the API is offline or a write failed. */
7
+ export declare function isApiTimeoutError(error: unknown): boolean;
8
+ /** Return true when an exception means the HTTP peer could not be reached. */
9
+ export declare function isApiUnreachableError(error: unknown): boolean;
10
+ /** Return true only when no request body could have reached the peer. */
11
+ export declare function isSafeApiAliasFallbackError(error: unknown): boolean;
12
+ /** Add the IPv4 loopback alias for clients whose resolver prefers IPv6. */
13
+ export declare function apiUrlCandidates(url: string): string[];
14
+ /** Join an API path to the configured base without losing its prefix/query. */
15
+ export declare function apiUrl(path?: string, base?: string): string;
16
+ export declare function apiHealthUrl(base?: string, detail?: boolean): string;
17
+ /** Remove credentials and query values before an endpoint is shown to a client. */
18
+ export declare function apiUrlForDisplay(url?: string): string;
6
19
  export declare function fetchWithTimeout(url: string, options?: RequestInit & {
7
20
  timeoutMs?: number;
8
21
  }): Promise<Response>;
@@ -3,25 +3,296 @@
3
3
  *
4
4
  * HTTP fetch wrapper with timeout, retry, and health check.
5
5
  */
6
+ import { execFileSync } from "node:child_process";
7
+ import { readFileSync } from "node:fs";
8
+ import { release as osRelease } from "node:os";
6
9
  import { MEMOS_URL, MEMOS_API_WAIT_MAX, MEMOS_TIMEOUT_HEALTH, MEMOS_TIMEOUT_TOOL, logger, registeredCubes, } from "./config.js";
7
10
  // ============================================================================
8
11
  // Fetch with Timeout
9
12
  // ============================================================================
13
+ const API_NETWORK_ERROR_CODES = [
14
+ "ECONNREFUSED",
15
+ "ECONNABORTED",
16
+ "ECONNRESET",
17
+ "ETIMEDOUT",
18
+ "ENETDOWN",
19
+ "ENETUNREACH",
20
+ "EHOSTDOWN",
21
+ "EHOSTUNREACH",
22
+ "ENOTFOUND",
23
+ "EAI_AGAIN",
24
+ "EADDRNOTAVAIL",
25
+ "UND_ERR_SOCKET",
26
+ "UND_ERR_CONNECT_TIMEOUT",
27
+ ];
28
+ // These failures happen before an HTTP request can be delivered. They are the
29
+ // only network errors safe to replay for a non-idempotent method.
30
+ const API_ALIAS_SAFE_ERROR_CODES = [
31
+ "ECONNREFUSED",
32
+ "ENETUNREACH",
33
+ "EHOSTUNREACH",
34
+ "ENOTFOUND",
35
+ "EAI_AGAIN",
36
+ "EADDRNOTAVAIL",
37
+ "UND_ERR_CONNECT_TIMEOUT",
38
+ ];
39
+ let discoveredWslHosts;
40
+ function isWslRuntime() {
41
+ if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP)
42
+ return true;
43
+ if (process.platform !== "linux")
44
+ return false;
45
+ const kernel = osRelease().toLowerCase();
46
+ return kernel.includes("microsoft") || kernel.includes("wsl");
47
+ }
48
+ function normalizeDiscoveredHost(value) {
49
+ const host = value.trim().replace(/^\[|\]$/g, "").replace(/%[^/]+$/, "");
50
+ if (!host ||
51
+ host === "localhost" ||
52
+ host === "::1" ||
53
+ host === "0.0.0.0" ||
54
+ /^127(?:\.[0-9]+){3}$/.test(host)) {
55
+ return undefined;
56
+ }
57
+ return host.includes(":") ? `[${host}]` : host;
58
+ }
59
+ function discoverWslHosts() {
60
+ if (discoveredWslHosts)
61
+ return discoveredWslHosts;
62
+ const hosts = new Set(["host.docker.internal"]);
63
+ try {
64
+ const resolvConf = readFileSync("/etc/resolv.conf", "utf8");
65
+ for (const line of resolvConf.split(/\r?\n/)) {
66
+ const match = /^\s*nameserver\s+(\S+)/.exec(line);
67
+ const host = match ? normalizeDiscoveredHost(match[1]) : undefined;
68
+ if (host)
69
+ hosts.add(host);
70
+ }
71
+ }
72
+ catch {
73
+ // WSL integrations may not expose resolv.conf; keep the other candidates.
74
+ }
75
+ try {
76
+ const routeOutput = String(execFileSync("ip", ["route", "show", "default"], {
77
+ encoding: "utf8",
78
+ timeout: 500,
79
+ stdio: ["ignore", "pipe", "ignore"],
80
+ }));
81
+ const match = /(?:^|\s)default\s+via\s+(\S+)/.exec(routeOutput);
82
+ const host = match ? normalizeDiscoveredHost(match[1]) : undefined;
83
+ if (host)
84
+ hosts.add(host);
85
+ }
86
+ catch {
87
+ // The ip utility is optional; resolv.conf and host.docker.internal suffice.
88
+ }
89
+ discoveredWslHosts = [...hosts];
90
+ return discoveredWslHosts;
91
+ }
92
+ function replaceUrlHost(url, host) {
93
+ try {
94
+ const parsed = new URL(url);
95
+ parsed.hostname = host;
96
+ return parsed.toString();
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ /** A response deadline does not prove the API is offline or a write failed. */
103
+ export function isApiTimeoutError(error) {
104
+ if (typeof error !== "object" || error === null)
105
+ return false;
106
+ const details = error;
107
+ if (details.name === "AbortError" || details.name === "TimeoutError")
108
+ return true;
109
+ if (details.code === "UND_ERR_HEADERS_TIMEOUT" || details.code === "UND_ERR_BODY_TIMEOUT")
110
+ return true;
111
+ return details.cause !== undefined && details.cause !== error && isApiTimeoutError(details.cause);
112
+ }
113
+ /** Return true when an exception means the HTTP peer could not be reached. */
114
+ export function isApiUnreachableError(error) {
115
+ if (isApiTimeoutError(error))
116
+ return false;
117
+ if (typeof error === "object" && error !== null) {
118
+ const details = error;
119
+ if (typeof details.code === "string" &&
120
+ API_NETWORK_ERROR_CODES.includes(details.code)) {
121
+ return true;
122
+ }
123
+ if (details.cause !== undefined && isApiUnreachableError(details.cause)) {
124
+ return true;
125
+ }
126
+ }
127
+ const message = String(error).toLowerCase();
128
+ return (message.includes("fetch failed") ||
129
+ message.includes("network error") ||
130
+ message.includes("socket hang up") ||
131
+ message.includes("connect timeout") ||
132
+ API_NETWORK_ERROR_CODES.some((code) => message.includes(code.toLowerCase())));
133
+ }
134
+ /** Return true only when no request body could have reached the peer. */
135
+ export function isSafeApiAliasFallbackError(error) {
136
+ if (typeof error === "object" && error !== null) {
137
+ const details = error;
138
+ if (typeof details.code === "string" &&
139
+ API_ALIAS_SAFE_ERROR_CODES.includes(details.code)) {
140
+ return true;
141
+ }
142
+ if (details.cause !== undefined && isSafeApiAliasFallbackError(details.cause)) {
143
+ return true;
144
+ }
145
+ }
146
+ const message = String(error).toLowerCase();
147
+ return API_ALIAS_SAFE_ERROR_CODES.some((code) => message.includes(code.toLowerCase()));
148
+ }
149
+ /** Add the IPv4 loopback alias for clients whose resolver prefers IPv6. */
150
+ export function apiUrlCandidates(url) {
151
+ let parsed;
152
+ try {
153
+ parsed = new URL(url);
154
+ }
155
+ catch {
156
+ return [url];
157
+ }
158
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
159
+ return [url];
160
+ const host = parsed.hostname.toLowerCase();
161
+ if (host !== "localhost" && host !== "127.0.0.1")
162
+ return [url];
163
+ const alias = new URL(url);
164
+ alias.hostname = host === "localhost" ? "127.0.0.1" : "localhost";
165
+ const candidates = [url, alias.toString()];
166
+ if (isWslRuntime()) {
167
+ for (const discoveredHost of discoverWslHosts()) {
168
+ const candidate = replaceUrlHost(url, discoveredHost);
169
+ if (candidate && !candidates.includes(candidate))
170
+ candidates.push(candidate);
171
+ }
172
+ }
173
+ return candidates;
174
+ }
175
+ const preferredLoopbackHosts = new Map();
176
+ function orderedApiUrlCandidates(url) {
177
+ const candidates = apiUrlCandidates(url);
178
+ if (candidates.length < 2)
179
+ return candidates;
180
+ try {
181
+ const parsed = new URL(url);
182
+ const preferredHost = preferredLoopbackHosts.get(parsed.origin);
183
+ if (!preferredHost)
184
+ return candidates;
185
+ const preferred = new URL(url);
186
+ preferred.hostname = preferredHost;
187
+ const preferredUrl = preferred.toString();
188
+ return [preferredUrl, ...candidates.filter((candidate) => candidate !== preferredUrl)];
189
+ }
190
+ catch {
191
+ return candidates;
192
+ }
193
+ }
194
+ /** Join an API path to the configured base without losing its prefix/query. */
195
+ export function apiUrl(path = "", base = MEMOS_URL) {
196
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
197
+ try {
198
+ const parsedBase = new URL(base);
199
+ const parsedPath = new URL(normalizedPath, "http://memos.local");
200
+ const basePath = parsedBase.pathname.replace(/\/+$/, "").replace(/^\/+/, "");
201
+ const childPath = parsedPath.pathname.replace(/^\/+/, "");
202
+ const joinedPath = [basePath, childPath].filter(Boolean).join("/");
203
+ parsedBase.pathname = joinedPath ? `/${joinedPath}` : "/";
204
+ // Base query values (for example a gateway token) apply to every endpoint;
205
+ // a path-specific value wins when both use the same key.
206
+ const query = new URLSearchParams(parsedBase.search);
207
+ for (const [key, value] of parsedPath.searchParams)
208
+ query.set(key, value);
209
+ parsedBase.search = query.toString();
210
+ parsedBase.hash = "";
211
+ return parsedBase.toString();
212
+ }
213
+ catch {
214
+ // Keep the old string behavior for a malformed base; the first fetch then
215
+ // reports the useful URL error to the caller.
216
+ const cleanBase = base.replace(/[?#].*$/, "").replace(/\/+$/, "");
217
+ const cleanPath = normalizedPath.replace(/^\/+/, "");
218
+ return `${cleanBase}/${cleanPath}`;
219
+ }
220
+ }
221
+ export function apiHealthUrl(base = MEMOS_URL, detail = false) {
222
+ const suffix = detail ? "/health/detail" : "/health";
223
+ return apiUrl(suffix, base);
224
+ }
225
+ /** Remove credentials and query values before an endpoint is shown to a client. */
226
+ export function apiUrlForDisplay(url = MEMOS_URL) {
227
+ try {
228
+ const parsed = new URL(url);
229
+ parsed.username = "";
230
+ parsed.password = "";
231
+ parsed.search = "";
232
+ parsed.hash = "";
233
+ const displayed = parsed.toString();
234
+ return parsed.pathname === "/" && displayed.endsWith("/")
235
+ ? displayed.slice(0, -1)
236
+ : displayed;
237
+ }
238
+ catch {
239
+ return url
240
+ .replace(/[?#].*$/, "")
241
+ .replace(/:\/\/[^/@\s]+@/, "://");
242
+ }
243
+ }
10
244
  export async function fetchWithTimeout(url, options = {}) {
11
245
  const timeoutMs = (options.timeoutMs ?? MEMOS_TIMEOUT_TOOL) * 1000;
12
246
  const { timeoutMs: _, ...fetchOptions } = options;
13
- const controller = new AbortController();
14
- const timer = setTimeout(() => controller.abort(), timeoutMs);
247
+ const method = String(fetchOptions.method ?? "GET").toUpperCase();
248
+ const candidates = orderedApiUrlCandidates(url);
249
+ const isIdempotent = method === "GET" || method === "HEAD";
250
+ let originalOrigin;
15
251
  try {
16
- const response = await fetch(url, {
17
- ...fetchOptions,
18
- signal: controller.signal,
19
- });
20
- return response;
252
+ originalOrigin = new URL(url).origin;
253
+ }
254
+ catch {
255
+ // The first fetch will report the malformed URL.
21
256
  }
22
- finally {
23
- clearTimeout(timer);
257
+ let lastError;
258
+ for (const candidate of candidates) {
259
+ const controller = new AbortController();
260
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
261
+ try {
262
+ const response = await fetch(candidate, {
263
+ ...fetchOptions,
264
+ signal: controller.signal,
265
+ });
266
+ if (originalOrigin) {
267
+ const selectedOrigin = new URL(candidate).origin;
268
+ if (selectedOrigin === originalOrigin) {
269
+ preferredLoopbackHosts.delete(originalOrigin);
270
+ }
271
+ else {
272
+ preferredLoopbackHosts.set(originalOrigin, new URL(candidate).hostname);
273
+ }
274
+ }
275
+ return response;
276
+ }
277
+ catch (error) {
278
+ lastError = error;
279
+ const canTryAlias = candidate !== candidates.at(-1) &&
280
+ (isIdempotent
281
+ ? isApiUnreachableError(error)
282
+ : isSafeApiAliasFallbackError(error));
283
+ if (!canTryAlias) {
284
+ if (originalOrigin) {
285
+ preferredLoopbackHosts.delete(originalOrigin);
286
+ }
287
+ throw error;
288
+ }
289
+ logger.debug("MemOS API endpoint failed, trying loopback alias");
290
+ }
291
+ finally {
292
+ clearTimeout(timer);
293
+ }
24
294
  }
295
+ throw lastError;
25
296
  }
26
297
  // ============================================================================
27
298
  // API Readiness Check
@@ -32,7 +303,7 @@ export async function waitForApiReady(maxWait, interval = 2.0) {
32
303
  const start = Date.now();
33
304
  while (Date.now() - start < maxWaitMs) {
34
305
  try {
35
- const response = await fetchWithTimeout(`${MEMOS_URL}/users`, {
306
+ const response = await fetchWithTimeout(apiUrl("/users"), {
36
307
  timeoutMs: MEMOS_TIMEOUT_HEALTH,
37
308
  });
38
309
  if (response.ok) {
@@ -57,7 +328,15 @@ function buildUrl(base, params) {
57
328
  urlParams.append(k, String(v));
58
329
  }
59
330
  const qs = urlParams.toString();
60
- return qs ? `${base}?${qs}` : base;
331
+ if (!qs)
332
+ return base;
333
+ const hashIndex = base.indexOf("#");
334
+ const beforeHash = hashIndex === -1 ? base : base.slice(0, hashIndex);
335
+ const hash = hashIndex === -1 ? "" : base.slice(hashIndex);
336
+ const separator = beforeHash.includes("?")
337
+ ? (beforeHash.endsWith("?") || beforeHash.endsWith("&") ? "" : "&")
338
+ : "?";
339
+ return `${beforeHash}${separator}${qs}${hash}`;
61
340
  }
62
341
  async function doFetch(method, url, options) {
63
342
  const finalUrl = buildUrl(url, options.params);
@@ -80,7 +359,11 @@ async function doFetch(method, url, options) {
80
359
  const data = (await response.json());
81
360
  return { status: response.status, data };
82
361
  }
83
- catch {
362
+ catch (error) {
363
+ // Body reads can fail after response headers arrive. Preserve deadlines
364
+ // as exceptions so an ambiguous POST is not replayed or reported offline.
365
+ if (isApiTimeoutError(error))
366
+ throw error;
84
367
  return { status: response.status, data: null };
85
368
  }
86
369
  }
@@ -5,9 +5,9 @@
5
5
  */
6
6
  import * as fs from "fs";
7
7
  import * as path from "path";
8
- import { MEMOS_URL, MEMOS_USER, MEMOS_DEFAULT_CUBE, MEMOS_CUBES_DIR, MEMOS_TIMEOUT_STARTUP, REGISTRATION_RETRY_INTERVAL, registeredCubes, lastRegistrationAttempt, logger, isDefaultCubeFromEnv, } from "./config.js";
8
+ import { MEMOS_USER, MEMOS_DEFAULT_CUBE, MEMOS_CUBES_DIR, MEMOS_TIMEOUT_STARTUP, REGISTRATION_RETRY_INTERVAL, registeredCubes, lastRegistrationAttempt, logger, isDefaultCubeFromEnv, } from "./config.js";
9
9
  import { detectCubeFromPath } from "./keyword-enhancer.js";
10
- import { fetchWithTimeout } from "./api-client.js";
10
+ import { apiUrl, apiUrlForDisplay, fetchWithTimeout, isApiUnreachableError, } from "./api-client.js";
11
11
  // ============================================================================
12
12
  // Windows/WSL Path Conversion
13
13
  // ============================================================================
@@ -368,7 +368,7 @@ export function ensureCubeDirectory(cubeId) {
368
368
  // ============================================================================
369
369
  export async function verifyCubeLoaded(cubeId) {
370
370
  try {
371
- const response = await fetchWithTimeout(`${MEMOS_URL}/memories?user_id=${encodeURIComponent(MEMOS_USER)}&mem_cube_id=${encodeURIComponent(cubeId)}&limit=1`, { method: "GET", timeoutMs: 5 });
371
+ const response = await fetchWithTimeout(apiUrl(`/memories?user_id=${encodeURIComponent(MEMOS_USER)}&mem_cube_id=${encodeURIComponent(cubeId)}&limit=1`), { method: "GET", timeoutMs: 5 });
372
372
  if (response.ok) {
373
373
  const data = await response.json();
374
374
  return data.code === 200;
@@ -412,7 +412,7 @@ export async function ensureCubeRegistered(cubeId, force = false) {
412
412
  }
413
413
  }
414
414
  // Register with API
415
- const response = await fetchWithTimeout(`${MEMOS_URL}/mem_cubes`, {
415
+ const response = await fetchWithTimeout(apiUrl("/mem_cubes"), {
416
416
  method: "POST",
417
417
  headers: { "Content-Type": "application/json" },
418
418
  body: JSON.stringify({
@@ -441,8 +441,8 @@ export async function ensureCubeRegistered(cubeId, force = false) {
441
441
  }
442
442
  catch (err) {
443
443
  const msg = String(err);
444
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed") || msg.includes("ECONNRESET")) {
445
- return [false, `Cannot connect to MemOS API at ${MEMOS_URL}. Is the server running?`];
444
+ if (isApiUnreachableError(err)) {
445
+ return [false, `Cannot connect to MemOS API at ${apiUrlForDisplay()}. Is the server running?`];
446
446
  }
447
447
  return [false, `Failed to register cube '${cubeId}': ${err}`];
448
448
  }
@@ -6,8 +6,8 @@
6
6
  */
7
7
  import * as fs from "fs";
8
8
  import * as path from "path";
9
- import { MEMOS_URL, MEMOS_USER, MEMOS_DEFAULT_CUBE, MEMOS_CUBES_DIR, MEMOS_ENABLE_DELETE, MEMOS_TIMEOUT_TOOL, registeredCubes, } from "../config.js";
10
- import { fetchWithTimeout } from "../api-client.js";
9
+ import { MEMOS_USER, MEMOS_DEFAULT_CUBE, MEMOS_CUBES_DIR, MEMOS_ENABLE_DELETE, MEMOS_TIMEOUT_TOOL, registeredCubes, } from "../config.js";
10
+ import { apiUrl, apiUrlForDisplay, fetchWithTimeout } from "../api-client.js";
11
11
  import { ensureCubeDirectory, ensureCubeRegistered, getCubePath, getCubesBaseDir, getLocalCubesBaseDir, listAvailableCubes, validateAndFixCubeConfig, verifyCubeLoaded, } from "../cube-manager.js";
12
12
  import { ERR_CUBE_NOT_FOUND, ERR_DELETE_DISABLED, ERR_PARAM_MISSING, apiErrorResponse, cubeRegistrationError, errorResponse, getCubeIdFromArgs, } from "./utils.js";
13
13
  // ============================================================================
@@ -43,7 +43,7 @@ export async function handleMemosListCubes(arguments_) {
43
43
  "",
44
44
  `Cubes directory: \`${cubesDir}\``,
45
45
  "",
46
- `To create a new cube, use the MemOS web interface at ${MEMOS_URL}/docs`,
46
+ `To create a new cube, use the MemOS web interface at ${apiUrlForDisplay(apiUrl("/docs"))}`,
47
47
  "or create a cube directory with a config.json file.",
48
48
  ].join("\n") }];
49
49
  }
@@ -107,7 +107,7 @@ export async function handleMemosRegisterCube(arguments_) {
107
107
  }
108
108
  }
109
109
  try {
110
- const response = await fetchWithTimeout(`${MEMOS_URL}/mem_cubes`, {
110
+ const response = await fetchWithTimeout(apiUrl("/mem_cubes"), {
111
111
  method: "POST",
112
112
  headers: { "Content-Type": "application/json" },
113
113
  body: JSON.stringify({
@@ -157,7 +157,7 @@ export async function handleMemosCreateUser(arguments_) {
157
157
  const userId = String(arguments_.user_id ?? MEMOS_USER);
158
158
  const userName = String(arguments_.user_name ?? userId);
159
159
  try {
160
- const response = await fetchWithTimeout(`${MEMOS_URL}/users`, {
160
+ const response = await fetchWithTimeout(apiUrl("/users"), {
161
161
  method: "POST",
162
162
  headers: { "Content-Type": "application/json" },
163
163
  body: JSON.stringify({
@@ -292,7 +292,7 @@ export async function handleMemosDelete(arguments_) {
292
292
  if (!regSuccess)
293
293
  return cubeRegistrationError(cubeId, regError);
294
294
  if (deleteAll) {
295
- const response = await fetchWithTimeout(`${MEMOS_URL}/memories/${cubeId}?user_id=${encodeURIComponent(MEMOS_USER)}`, { method: "DELETE" });
295
+ const response = await fetchWithTimeout(apiUrl(`/memories/${cubeId}?user_id=${encodeURIComponent(MEMOS_USER)}`), { method: "DELETE" });
296
296
  if (response.ok) {
297
297
  const data = await response.json();
298
298
  if (data.code === 200) {
@@ -321,7 +321,7 @@ export async function handleMemosDelete(arguments_) {
321
321
  for (const mid of idsToDelete) {
322
322
  let memContent = "*(Unknown content)*";
323
323
  try {
324
- const getResp = await fetchWithTimeout(`${MEMOS_URL}/memories/${cubeId}/${mid}?user_id=${encodeURIComponent(MEMOS_USER)}`, { method: "GET" });
324
+ const getResp = await fetchWithTimeout(apiUrl(`/memories/${cubeId}/${mid}?user_id=${encodeURIComponent(MEMOS_USER)}`), { method: "GET" });
325
325
  if (getResp.ok) {
326
326
  const gData = await getResp.json();
327
327
  if (gData.code === 200 && gData.data) {
@@ -335,7 +335,7 @@ export async function handleMemosDelete(arguments_) {
335
335
  catch {
336
336
  memContent = "*(Fetch failed)*";
337
337
  }
338
- const deleteResp = await fetchWithTimeout(`${MEMOS_URL}/memories/${cubeId}/${mid}?user_id=${encodeURIComponent(MEMOS_USER)}`, { method: "DELETE" });
338
+ const deleteResp = await fetchWithTimeout(apiUrl(`/memories/${cubeId}/${mid}?user_id=${encodeURIComponent(MEMOS_USER)}`), { method: "DELETE" });
339
339
  if (deleteResp.ok) {
340
340
  const data = await deleteResp.json();
341
341
  if (data.code === 200) {
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * memos_admin(action="calendar") - project mode and student mode
5
5
  */
6
- import { MEMOS_URL, MEMOS_USER } from "../config.js";
7
- import { apiCallWithRetry } from "../api-client.js";
6
+ import { MEMOS_USER } from "../config.js";
7
+ import { apiCallWithRetry, apiUrl } from "../api-client.js";
8
8
  import { ensureCubeRegistered } from "../cube-manager.js";
9
9
  import { apiErrorResponse, cubeRegistrationError, getCubeIdFromArgs } from "./utils.js";
10
10
  // ============================================================================
@@ -201,7 +201,7 @@ export async function handleMemosCalendar(arguments_) {
201
201
  if (!regSuccess)
202
202
  return cubeRegistrationError(cubeId, regError);
203
203
  if (mode === "project") {
204
- const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories`, cubeId, { params: { user_id: MEMOS_USER, mem_cube_id: cubeId, limit: 200 } }, ensureCubeRegistered);
204
+ const result = await apiCallWithRetry("GET", apiUrl("/memories"), cubeId, { params: { user_id: MEMOS_USER, mem_cube_id: cubeId, limit: 200 } }, ensureCubeRegistered);
205
205
  if (result.success && result.data) {
206
206
  const resultData = result.data.data ?? {};
207
207
  const memories = [];
@@ -229,7 +229,7 @@ export async function handleMemosCalendar(arguments_) {
229
229
  if (week !== undefined) {
230
230
  [startDate, endDate] = getWeekDates(startDate, week);
231
231
  }
232
- const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories`, cubeId, { params: { user_id: MEMOS_USER, mem_cube_id: cubeId, limit: 100 } }, ensureCubeRegistered);
232
+ const result = await apiCallWithRetry("GET", apiUrl("/memories"), cubeId, { params: { user_id: MEMOS_USER, mem_cube_id: cubeId, limit: 100 } }, ensureCubeRegistered);
233
233
  if (result.success && result.data) {
234
234
  const resultData = result.data.data ?? {};
235
235
  let memories = [];
@@ -3,8 +3,8 @@
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";
7
- import { fetchWithTimeout } from "../api-client.js";
6
+ import { MEMOS_USER, NEO4J_HTTP_URL, NEO4J_USER, NEO4J_PASSWORD, logger, registeredCubes, } from "../config.js";
7
+ import { apiUrl, fetchWithTimeout } from "../api-client.js";
8
8
  import { ensureCubeRegistered } from "../cube-manager.js";
9
9
  import { formatProvenance } from "../graph-provenance.js";
10
10
  import { buildGraphImportPlan, renderGraphImportPlan, } from "../graphify-import.js";
@@ -99,7 +99,7 @@ export async function handleMemosTracePath(arguments_) {
99
99
  if (!regSuccess)
100
100
  return cubeRegistrationError(cubeId, regError);
101
101
  try {
102
- const response = await fetchWithTimeout(`${MEMOS_URL}/product/graph/trace_path`, {
102
+ const response = await fetchWithTimeout(apiUrl("/product/graph/trace_path"), {
103
103
  method: "POST",
104
104
  headers: { "Content-Type": "application/json" },
105
105
  body: JSON.stringify({
@@ -243,7 +243,7 @@ export async function handleMemosGetGraph(arguments_) {
243
243
  // Search for relevant memories first
244
244
  let memories = [];
245
245
  try {
246
- const searchResponse = await fetchWithTimeout(`${MEMOS_URL}/search`, {
246
+ const searchResponse = await fetchWithTimeout(apiUrl("/search"), {
247
247
  method: "POST",
248
248
  headers: { "Content-Type": "application/json" },
249
249
  body: JSON.stringify({
@@ -262,7 +262,7 @@ export async function handleMemosGetGraph(arguments_) {
262
262
  registeredCubes.delete(cubeId);
263
263
  const [retrySuccess] = await ensureCubeRegistered(cubeId, true);
264
264
  if (retrySuccess) {
265
- const retrySearch = await fetchWithTimeout(`${MEMOS_URL}/search`, {
265
+ const retrySearch = await fetchWithTimeout(apiUrl("/search"), {
266
266
  method: "POST",
267
267
  headers: { "Content-Type": "application/json" },
268
268
  body: JSON.stringify({
@@ -499,7 +499,7 @@ export async function handleMemosExportSchema(arguments_) {
499
499
  if (!regSuccess)
500
500
  return cubeRegistrationError(cubeId, regError);
501
501
  try {
502
- const response = await fetchWithTimeout(`${MEMOS_URL}/graph/schema`, {
502
+ const response = await fetchWithTimeout(apiUrl("/graph/schema"), {
503
503
  method: "POST",
504
504
  headers: { "Content-Type": "application/json" },
505
505
  body: JSON.stringify({
@@ -7,4 +7,5 @@
7
7
  import type { TextContent } from "../types.js";
8
8
  export declare function dispatchTool(name: string, arguments_: Record<string, unknown>): Promise<TextContent[]>;
9
9
  export declare function handleApiUnreachable(): Promise<TextContent[]>;
10
+ export declare function handleApiTimeout(): Promise<TextContent[]>;
10
11
  //# sourceMappingURL=index.d.ts.map
@@ -14,7 +14,8 @@ import { handleMemosImportWiki } from "./wiki-import.js";
14
14
  import { handleMemosCanvas } from "./canvas.js";
15
15
  import { handleMemosDistillSkill, handleMemosListSkillCandidates, handleMemosReviewSkillCandidate, handleMemosInstallSkillCandidate } from "./skill.js";
16
16
  import { handleMemosListCubes, handleMemosRegisterCube, handleMemosCreateUser, handleMemosValidateCubes, handleMemosDelete, handleMemosCapabilities, } from "./admin.js";
17
- import { MEMOS_URL, MEMOS_PROVIDER } from "../config.js";
17
+ import { MEMOS_PROVIDER } from "../config.js";
18
+ import { apiHealthUrl, apiUrlForDisplay } from "../api-client.js";
18
19
  import { errorResponse } from "./utils.js";
19
20
  export async function dispatchTool(name, arguments_) {
20
21
  if (MEMOS_PROVIDER === "local" && ["memos_think", "memos_graph", "memos_export_wiki", "memos_import_wiki", "memos_admin", "memos_delete"].includes(name)) {
@@ -99,13 +100,19 @@ export async function handleApiUnreachable() {
99
100
  return [{
100
101
  type: "text",
101
102
  text: [
102
- `❌ [API_UNREACHABLE] Cannot connect to MemOS API at ${MEMOS_URL}`,
103
+ `❌ [API_UNREACHABLE] Cannot connect to MemOS API at ${apiUrlForDisplay()}`,
103
104
  "",
104
105
  "💡 Suggestions:",
105
- "- Check if MemOS API is running: `curl http://localhost:18000/health`",
106
+ `- Check if MemOS API is running: \`curl ${apiUrlForDisplay(apiHealthUrl())}\``,
106
107
  "- Start with: `scripts/local/start.bat`",
107
108
  "- Check port availability",
108
109
  ].join("\n"),
109
110
  }];
110
111
  }
112
+ export async function handleApiTimeout() {
113
+ return errorResponse(`MemOS API at ${apiUrlForDisplay()} did not respond before the request deadline.`, "API_TIMEOUT", [
114
+ "A write may still have completed. Check memos_search or memos_list_v2 before retrying.",
115
+ "Check API logs for storage delays; LLM parsing runs in background after vector confirmation.",
116
+ ]);
117
+ }
111
118
  //# sourceMappingURL=index.js.map
@@ -4,11 +4,11 @@
4
4
  * memos_save, memos_list_v2, memos_get, memos_admin(action="stats")
5
5
  */
6
6
  import * as crypto from "crypto";
7
- import { MEMOS_URL, MEMOS_USER, MEMOS_CUBES_DIR, logger } from "../config.js";
7
+ import { MEMOS_USER, MEMOS_CUBES_DIR, logger } from "../config.js";
8
8
  import { getMemoryProvider } from "../providers/provider-factory.js";
9
- import { apiCallWithRetry } from "../api-client.js";
9
+ import { apiCallWithRetry, apiUrl } from "../api-client.js";
10
10
  import { ensureCubeRegistered } from "../cube-manager.js";
11
- import { parseMemoryWriteResponse } from "../memory-write-response.js";
11
+ import { parseMemoryWriteResponse, UnconfirmedMemoryWriteError } from "../memory-write-response.js";
12
12
  import { recordAccess } from "../access-tracker.js";
13
13
  import { filterEphemeralTier } from "../memory-tier.js";
14
14
  import { findSiblings, renderVerbatimSections, verbatimOf, } from "../verbatim-source.js";
@@ -95,24 +95,32 @@ export async function handleMemosSave(arguments_) {
95
95
  const [regSuccess, regError] = await ensureCubeRegistered(cubeId);
96
96
  if (!regSuccess)
97
97
  return cubeRegistrationError(cubeId, regError);
98
- const result = await apiCallWithRetry("POST", `${MEMOS_URL}/memories`, cubeId, {
98
+ const result = await apiCallWithRetry("POST", apiUrl("/memories"), cubeId, {
99
99
  body: {
100
100
  user_id: MEMOS_USER,
101
101
  mem_cube_id: cubeId,
102
102
  memory_content: content,
103
+ memory_type: memoryType,
103
104
  },
104
105
  }, ensureCubeRegistered);
105
106
  if (result.success) {
106
- markSaved(content, cubeId);
107
107
  const write = parseMemoryWriteResponse(result.data);
108
+ if (write.vectorSaved === false || (write.vectorSaved === true && write.memoryIds.length === 0)) {
109
+ throw new UnconfirmedMemoryWriteError(errorResponse("Vector storage was not confirmed; the save cannot be acknowledged.", "VECTOR_SAVE_UNCONFIRMED", ["Check API storage logs and search for the memory before retrying."]));
110
+ }
111
+ markSaved(content, cubeId);
108
112
  const idText = write.memoryIds.length > 0 ? ` · IDs: ${write.memoryIds.join(", ")}` : "";
109
113
  const warningText = write.warnings.length > 0
110
114
  ? ` · warnings: ${write.warnings.join("; ")}`
111
115
  : "";
116
+ const vectorText = write.vectorSaved === true ? " · vector saved" : "";
117
+ const enrichmentText = write.enrichmentStatus === "pending"
118
+ ? " · LLM parsing continues in background"
119
+ : write.enrichmentStatus === "failed" ? " · background parsing failed; saved memory is retained" : "";
112
120
  return [
113
121
  {
114
122
  type: "text",
115
- text: `Memory saved as [${memoryType}] in cube '${cubeId}'${idText}${warningText}`,
123
+ text: `Memory saved as [${memoryType}] in cube '${cubeId}'${idText}${vectorText}${enrichmentText}${warningText}`,
116
124
  },
117
125
  ];
118
126
  }
@@ -177,7 +185,7 @@ export async function handleMemosList(arguments_) {
177
185
  // (the API filters by internal type, not MCP type), but never the whole cube —
178
186
  // pulling everything blew a limit=5 request up to ~1.8M tokens on large cubes.
179
187
  params.limit = memoryType ? Math.max(limit * 20, 200) : limit;
180
- const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories`, cubeId, { params }, ensureCubeRegistered);
188
+ const result = await apiCallWithRetry("GET", apiUrl("/memories"), cubeId, { params }, ensureCubeRegistered);
181
189
  if (result.success && result.data) {
182
190
  const resultData = result.data.data ?? {};
183
191
  const allMemories = prepareListMemories(resultData, memoryType, limit);
@@ -233,7 +241,7 @@ export async function handleMemosList(arguments_) {
233
241
  */
234
242
  async function fetchSiblings(cubeId, target) {
235
243
  try {
236
- const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories`, cubeId, { params: { user_id: MEMOS_USER, mem_cube_id: cubeId, limit: 200 } }, ensureCubeRegistered);
244
+ const result = await apiCallWithRetry("GET", apiUrl("/memories"), cubeId, { params: { user_id: MEMOS_USER, mem_cube_id: cubeId, limit: 200 } }, ensureCubeRegistered);
237
245
  if (!result.success || !result.data)
238
246
  return [];
239
247
  const data = result.data.data;
@@ -286,7 +294,7 @@ export async function handleMemosGet(arguments_) {
286
294
  const [regSuccess, regError] = await ensureCubeRegistered(cubeId);
287
295
  if (!regSuccess)
288
296
  return cubeRegistrationError(cubeId, regError);
289
- const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories/${cubeId}/${memoryId}`, cubeId,
297
+ const result = await apiCallWithRetry("GET", apiUrl(`/memories/${cubeId}/${memoryId}`), cubeId,
290
298
  // user_id 必须显式传:后端 `get_memory` 的 user_id 是可选参数,缺失时回退到
291
299
  // MOS 实例自己的 user_id(root)。而 ensureCubeRegistered 注册的是 MEMOS_USER
292
300
  // (dev_user),于是 root 对该 cube 无授权,校验抛 ValueError → 400
@@ -385,7 +393,7 @@ export async function handleMemosGetStats(arguments_) {
385
393
  const [regSuccess, regError] = await ensureCubeRegistered(cubeId);
386
394
  if (!regSuccess)
387
395
  return cubeRegistrationError(cubeId, regError);
388
- const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories`, cubeId, { params: { user_id: MEMOS_USER, mem_cube_id: cubeId } }, ensureCubeRegistered);
396
+ const result = await apiCallWithRetry("GET", apiUrl("/memories"), cubeId, { params: { user_id: MEMOS_USER, mem_cube_id: cubeId } }, ensureCubeRegistered);
389
397
  if (result.success && result.data) {
390
398
  const [stats, total] = computeMemoryStats(result.data.data ?? {});
391
399
  if (total === 0) {
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * memos_search, memos_search, memos_suggest, memos_context_resume
5
5
  */
6
- import { MEMOS_URL, MEMOS_USER, NEO4J_HTTP_URL, NEO4J_USER, NEO4J_PASSWORD, MEMOS_MODE, MEMOS_CUBES_DIR, logger, } from "../config.js";
6
+ import { MEMOS_USER, NEO4J_HTTP_URL, NEO4J_USER, NEO4J_PASSWORD, MEMOS_MODE, MEMOS_CUBES_DIR, logger, } from "../config.js";
7
7
  import { getMemoryProvider } from "../providers/provider-factory.js";
8
- import { apiCallWithRetry, fetchWithTimeout } from "../api-client.js";
8
+ import { apiCallWithRetry, apiUrl, fetchWithTimeout } from "../api-client.js";
9
9
  import { ensureCubeRegistered } from "../cube-manager.js";
10
10
  import { formatMemoriesForDisplay } from "../formatters.js";
11
11
  import { PREVIEW_COUNT, shouldCompact, compactedResultToText, toMinimal, } from "../models.js";
@@ -307,7 +307,7 @@ export async function handleMemosSearch(arguments_) {
307
307
  const [regSuccess, regError] = await ensureCubeRegistered(cubeId);
308
308
  if (!regSuccess)
309
309
  return cubeRegistrationError(cubeId, regError);
310
- const apiResult = await apiCallWithRetry("POST", `${MEMOS_URL}/search`, cubeId, {
310
+ const apiResult = await apiCallWithRetry("POST", apiUrl("/search"), cubeId, {
311
311
  body: {
312
312
  user_id: MEMOS_USER,
313
313
  query,
@@ -387,7 +387,7 @@ export async function handleMemosSearchContext(arguments_) {
387
387
  if (!regSuccess)
388
388
  return cubeRegistrationError(cubeId, regError);
389
389
  try {
390
- const response = await fetchWithTimeout(`${MEMOS_URL}/search`, {
390
+ const response = await fetchWithTimeout(apiUrl("/search"), {
391
391
  method: "POST",
392
392
  headers: { "Content-Type": "application/json" },
393
393
  body: JSON.stringify({
@@ -424,7 +424,7 @@ export async function handleMemosSearchContext(arguments_) {
424
424
  }
425
425
  else {
426
426
  // Fallback to standard search
427
- const fallbackResult = await apiCallWithRetry("POST", `${MEMOS_URL}/search`, cubeId, { body: { user_id: MEMOS_USER, query, install_cube_ids: [cubeId] } }, ensureCubeRegistered);
427
+ const fallbackResult = await apiCallWithRetry("POST", apiUrl("/search"), cubeId, { body: { user_id: MEMOS_USER, query, install_cube_ids: [cubeId] } }, ensureCubeRegistered);
428
428
  if (fallbackResult.success && fallbackResult.data) {
429
429
  let resultData = fallbackResult.data
430
430
  .data ?? {};
@@ -564,7 +564,7 @@ export async function handleMemosContextResume(arguments_) {
564
564
  let recentMemories = await getTemporalMemories(cubeId, RESUME_LIMIT, 24);
565
565
  // Fallback to API list
566
566
  if (recentMemories.length === 0) {
567
- const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories`, cubeId, {
567
+ const result = await apiCallWithRetry("GET", apiUrl("/memories"), cubeId, {
568
568
  params: {
569
569
  user_id: MEMOS_USER,
570
570
  mem_cube_id: cubeId,
@@ -14,8 +14,8 @@
14
14
  * of CONFLICT edges: those edges are only created by the reorganizer pipeline,
15
15
  * which is disabled by default, so in practice they rarely exist.
16
16
  */
17
- import { MEMOS_URL, MEMOS_USER } from "../config.js";
18
- import { apiCallWithRetry } from "../api-client.js";
17
+ import { MEMOS_USER } from "../config.js";
18
+ import { apiCallWithRetry, apiUrl } from "../api-client.js";
19
19
  import { ensureCubeRegistered } from "../cube-manager.js";
20
20
  import { extractKeywords, extractMcpType } from "../query-processing.js";
21
21
  import { levenshteinDistance } from "../keyword-enhancer.js";
@@ -127,7 +127,7 @@ export async function handleMemosThink(arguments_) {
127
127
  return cubeRegistrationError(cubeId, regError);
128
128
  // Two recall paths in parallel: semantic search + recent temporal slice.
129
129
  const [apiResult, temporal] = await Promise.all([
130
- apiCallWithRetry("POST", `${MEMOS_URL}/search`, cubeId, { body: { user_id: MEMOS_USER, query, install_cube_ids: [cubeId], top_k: topK } }, ensureCubeRegistered),
130
+ apiCallWithRetry("POST", apiUrl("/search"), cubeId, { body: { user_id: MEMOS_USER, query, install_cube_ids: [cubeId], top_k: topK } }, ensureCubeRegistered),
131
131
  getTemporalMemories(cubeId, 5, 72).catch(() => []),
132
132
  ]);
133
133
  if (!apiResult.success) {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { detectCubeFromPath } from "../keyword-enhancer.js";
7
7
  import { getDefaultCubeId } from "../cube-manager.js";
8
+ import { apiHealthUrl, apiUrlForDisplay } from "../api-client.js";
8
9
  // ============================================================================
9
10
  // Error Codes
10
11
  // ============================================================================
@@ -43,7 +44,7 @@ export function successResponse(message) {
43
44
  }
44
45
  export function cubeRegistrationError(cubeId, detail) {
45
46
  return errorResponse(`Cube '${cubeId}' registration failed: ${detail ?? "unknown error"}`, ERR_CUBE_REGISTRATION, [
46
- `Check if MemOS API is running: \`curl http://localhost:18000/health\``,
47
+ `Check if MemOS API is running: \`curl ${apiUrlForDisplay(apiHealthUrl())}\``,
47
48
  `Verify cube exists: \`memos_admin(action="list_cubes", include_status=True)\``,
48
49
  `Try manual registration: \`memos_admin(action="register_cube", cube_id="...")\``,
49
50
  ]);
@@ -68,7 +69,7 @@ export function apiErrorResponse(operation, statusOrMsg) {
68
69
  (msg.includes("cube") && msg.includes("not found"))) {
69
70
  suggestions.push('Cube not registered — run `memos_admin(action="register_cube", cube_id="...")` (find ids via `memos_admin(action="list_cubes")`), then retry');
70
71
  }
71
- suggestions.push("Check API health: `curl http://localhost:18000/health/detail`", "Check API logs for details");
72
+ suggestions.push(`Check API health: \`curl ${apiUrlForDisplay(apiHealthUrl(undefined, true))}\``, "Check API logs for details");
72
73
  return errorResponse(`${operation} failed: ${statusOrMsg}`, ERR_API_ERROR, suggestions);
73
74
  }
74
75
  // ============================================================================
@@ -15,8 +15,8 @@
15
15
  */
16
16
  import * as fs from "fs";
17
17
  import * as path from "path";
18
- import { MEMOS_URL, logger } from "../config.js";
19
- import { fetchWithTimeout } from "../api-client.js";
18
+ import { logger } from "../config.js";
19
+ import { apiUrl, fetchWithTimeout } from "../api-client.js";
20
20
  import { ensureCubeRegistered } from "../cube-manager.js";
21
21
  import { extractMcpType } from "../query-processing.js";
22
22
  import { EDGE_LABELS } from "../wiki-relations.js";
@@ -33,7 +33,7 @@ async function fetchAllGraphData(cubeId) {
33
33
  let totalNodes = 0;
34
34
  let totalEdges = 0;
35
35
  for (let page = 1; page <= MAX_PAGES; page++) {
36
- const response = await fetchWithTimeout(`${MEMOS_URL}/product/graph/data`, {
36
+ const response = await fetchWithTimeout(apiUrl("/product/graph/data"), {
37
37
  method: "POST",
38
38
  headers: { "Content-Type": "application/json" },
39
39
  // user_id doubles as the Neo4j user_name filter server-side, and cube
@@ -1,5 +1,5 @@
1
- import { MEMOS_URL, MEMOS_USER } from "../config.js";
2
- import { apiCallWithRetry } from "../api-client.js";
1
+ import { MEMOS_USER } from "../config.js";
2
+ import { apiCallWithRetry, apiUrl } from "../api-client.js";
3
3
  import { ensureCubeRegistered } from "../cube-manager.js";
4
4
  import { relationEdges } from "../wiki-relations.js";
5
5
  async function writeRelationsForPage(cubeId, effectiveId, page, fileBaseIndex) {
@@ -7,7 +7,7 @@ async function writeRelationsForPage(cubeId, effectiveId, page, fileBaseIndex) {
7
7
  const written = [];
8
8
  const failed = [];
9
9
  for (const edge of result.resolved) {
10
- const relResult = await apiCallWithRetry("POST", `${MEMOS_URL}/product/graph/relation`, cubeId, {
10
+ const relResult = await apiCallWithRetry("POST", apiUrl("/product/graph/relation"), cubeId, {
11
11
  body: {
12
12
  user_id: MEMOS_USER,
13
13
  mem_cube_id: cubeId,
@@ -17,8 +17,8 @@
17
17
  import * as crypto from "crypto";
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
20
- import { MEMOS_URL, MEMOS_USER, logger } from "../config.js";
21
- import { apiCallWithRetry } from "../api-client.js";
20
+ import { MEMOS_USER, logger } from "../config.js";
21
+ import { apiCallWithRetry, apiUrl } from "../api-client.js";
22
22
  import { ensureCubeRegistered } from "../cube-manager.js";
23
23
  import { parseMemoryWriteResponse } from "../memory-write-response.js";
24
24
  import { inspectWikiPages, normalizeLedger, } from "../wiki-import-safety.js";
@@ -135,7 +135,7 @@ function releaseImportLock(wikiDir) {
135
135
  }
136
136
  /** Returns the stored node, null when the memory does not exist, or an error string. */
137
137
  async function getStoredMemory(cubeId, memoryId) {
138
- const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories/${cubeId}/${memoryId}`, cubeId,
138
+ const result = await apiCallWithRetry("GET", apiUrl(`/memories/${cubeId}/${memoryId}`), cubeId,
139
139
  // 同 handlers/memory.ts 的 handleMemosGet:缺 user_id 会让后端回退到 root,
140
140
  // 对 MEMOS_USER 名下的 cube 报「does not have access」。这里更隐蔽 —— 该错误
141
141
  // 既不含 "not found" 也不是 404,会被当成真实故障返回,把整页 import 判成失败。
@@ -164,7 +164,7 @@ async function savePageAsMemory(cubeId, page) {
164
164
  body.created_at = page.created;
165
165
  if (page.updated)
166
166
  body.updated_at = page.updated;
167
- const result = await apiCallWithRetry("POST", `${MEMOS_URL}/memories`, cubeId, { body }, ensureCubeRegistered);
167
+ const result = await apiCallWithRetry("POST", apiUrl("/memories"), cubeId, { body }, ensureCubeRegistered);
168
168
  if (!result.success) {
169
169
  return {
170
170
  error: String(result.data?.message ??
@@ -175,6 +175,12 @@ async function savePageAsMemory(cubeId, page) {
175
175
  };
176
176
  }
177
177
  const parsed = parseMemoryWriteResponse(result.data);
178
+ if (parsed.vectorSaved === false || (parsed.vectorSaved === true && parsed.memoryIds.length === 0)) {
179
+ return {
180
+ error: "Vector storage was not confirmed", memoryIds: [],
181
+ warnings: parsed.warnings, uncertain: true,
182
+ };
183
+ }
178
184
  return {
179
185
  error: null,
180
186
  memoryIds: parsed.memoryIds,
@@ -1,7 +1,14 @@
1
- import type { ApiResponse } from "./types.js";
1
+ import type { ApiResponse, TextContent } from "./types.js";
2
+ /** A rejected storage acknowledgement must be an MCP tool error, not success text. */
3
+ export declare class UnconfirmedMemoryWriteError extends Error {
4
+ readonly content: TextContent[];
5
+ constructor(content: TextContent[]);
6
+ }
2
7
  export interface MemoryWriteResult {
3
8
  memoryIds: string[];
4
9
  warnings: string[];
10
+ vectorSaved?: boolean;
11
+ enrichmentStatus?: string;
5
12
  }
6
13
  /** Normalize the additive POST /memories response while accepting legacy APIs. */
7
14
  export declare function parseMemoryWriteResponse(response: ApiResponse<unknown>): MemoryWriteResult;
@@ -1,3 +1,12 @@
1
+ /** A rejected storage acknowledgement must be an MCP tool error, not success text. */
2
+ export class UnconfirmedMemoryWriteError extends Error {
3
+ content;
4
+ constructor(content) {
5
+ super(content.map((item) => item.text).join("\n"));
6
+ this.content = content;
7
+ this.name = "UnconfirmedMemoryWriteError";
8
+ }
9
+ }
1
10
  /** Normalize the additive POST /memories response while accepting legacy APIs. */
2
11
  export function parseMemoryWriteResponse(response) {
3
12
  const data = response.data;
@@ -10,6 +19,10 @@ export function parseMemoryWriteResponse(response) {
10
19
  const warnings = Array.isArray(payload.warnings)
11
20
  ? payload.warnings.filter((warning) => typeof warning === "string" && warning.length > 0)
12
21
  : [];
13
- return { memoryIds, warnings };
22
+ return {
23
+ memoryIds, warnings,
24
+ ...(typeof payload.vector_saved === "boolean" ? { vectorSaved: payload.vector_saved } : {}),
25
+ ...(typeof payload.enrichment_status === "string" ? { enrichmentStatus: payload.enrichment_status } : {}),
26
+ };
14
27
  }
15
28
  //# sourceMappingURL=memory-write-response.js.map
package/dist/models.js CHANGED
@@ -76,6 +76,9 @@ export function toFull(memory, cubeId = "", userId = "") {
76
76
  const cleanContent = content.replace(/^\[[A-Z_]+\]\s*/, "");
77
77
  return {
78
78
  ...brief,
79
+ createdAt: memory.created_at ??
80
+ meta.created_at ??
81
+ brief.createdAt,
79
82
  content: cleanContent,
80
83
  background: memory.background ?? meta.background,
81
84
  cubeId,
package/dist/server.js CHANGED
@@ -7,10 +7,11 @@ import { readFileSync } from "node:fs";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { dirname, join } from "node:path";
9
9
  import { MEMOS_DEFAULT_CUBE, MEMOS_ENABLE_DELETE, MEMOS_PROVIDER, logger } from "./config.js";
10
- import { waitForApiReady } from "./api-client.js";
10
+ import { apiHealthUrl, apiUrlForDisplay, isApiTimeoutError, isApiUnreachableError, waitForApiReady, } from "./api-client.js";
11
11
  import { ensureCubeRegistered } from "./cube-manager.js";
12
12
  import { toolSchemas, toolAnnotations } from "./tools-registry.js";
13
- import { dispatchTool, handleApiUnreachable } from "./handlers/index.js";
13
+ import { dispatchTool, handleApiTimeout, handleApiUnreachable } from "./handlers/index.js";
14
+ import { UnconfirmedMemoryWriteError } from "./memory-write-response.js";
14
15
  import { createOnceFireAndForget } from "./server-lifecycle.js";
15
16
  import { NormalizingStdioTransport } from "./stdio-compat-transport.js";
16
17
  // ============================================================================
@@ -41,8 +42,14 @@ function registerTools(server) {
41
42
  };
42
43
  }
43
44
  catch (err) {
45
+ if (err instanceof UnconfirmedMemoryWriteError) {
46
+ return { content: err.content, isError: true };
47
+ }
44
48
  const errStr = String(err);
45
- if (errStr.includes("ECONNREFUSED") || errStr.includes("fetch failed")) {
49
+ if (isApiTimeoutError(err)) {
50
+ return { content: await handleApiTimeout(), isError: true };
51
+ }
52
+ if (isApiUnreachableError(err)) {
46
53
  const unreachable = await handleApiUnreachable();
47
54
  return {
48
55
  content: unreachable.map((r) => ({ type: "text", text: r.text })),
@@ -58,7 +65,7 @@ function registerTools(server) {
58
65
  "",
59
66
  "💡 Suggestions:",
60
67
  "- Check MCP server logs for details",
61
- "- Verify MemOS API is healthy: `curl http://localhost:18000/health/detail`",
68
+ `- Verify MemOS API is healthy: \`curl ${apiUrlForDisplay(apiHealthUrl(undefined, true))}\``,
62
69
  ].join("\n"),
63
70
  },
64
71
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-memos-mcp",
3
- "version": "3.1.8",
3
+ "version": "3.1.9",
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",