memory-eternal 0.10.6 → 0.10.8
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +10 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.mcp.json +9 -9
- package/LICENSE +21 -21
- package/README.en.md +37 -1
- package/README.md +30 -1
- package/bin/dsh-memory.mjs +31 -45
- package/examples/mcp-memory/memory-eternal.cordis.yml +59 -59
- package/hooks/capture.mjs +77 -77
- package/hooks/claude-session.js +56 -56
- package/hooks/hooks.claude.json +23 -12
- package/hooks/hooks.codex.json +15 -15
- package/hooks/hooks.cursor.json +11 -11
- package/hooks/secret-guard.mjs +82 -0
- package/index.js +137 -21
- package/lib/api.js +148 -17
- package/lib/client.js +17 -17
- package/lib/config-sync.js +99 -0
- package/lib/db.js +359 -359
- package/lib/host-heartbeat.js +76 -72
- package/lib/llm-openai.js +84 -84
- package/lib/mcp.js +3 -1
- package/lib/node-bin.js +99 -99
- package/lib/setup.js +515 -470
- package/lib/sse.js +51 -51
- package/lib/stall.js +30 -30
- package/lib/sweep.js +56 -56
- package/lib/vault-resolve.js +84 -84
- package/lib/watchdog.js +241 -11
- package/lib/web-assets.js +41 -41
- package/lib/web.js +2 -0
- package/package.json +2 -2
- package/skills/memory-recall/SKILL.md +19 -14
- package/web/app.js +15 -15
- package/web/index.html +46 -46
package/lib/config-sync.js
CHANGED
|
@@ -204,6 +204,30 @@ export function patchApplied(config, patch) {
|
|
|
204
204
|
return true
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* 只保留「本宿主 schema 里存在」的键(升级期「新独立页 + 旧宿主」的必备过滤)。
|
|
209
|
+
*
|
|
210
|
+
* 为什么必须过滤:独立 Web 端提交的是**整张表单**。升级期它可能带上旧宿主还不认识的键
|
|
211
|
+
* (实测:独立页 0.10.7 提交含 `secretHint` 的整包 → 0.10.6 宿主按白名单丢掉它 →
|
|
212
|
+
* `patchApplied` 回读校验必然对不上 → 连试 5 次后标 `dropped`,用户还会看到
|
|
213
|
+
* 「HMR transactions cannot be nested」这种与配置内容无关的报错)。过滤之后,旧宿主只应用
|
|
214
|
+
* 它认识的那部分,不认识的记一行日志、等宿主升级后自然生效。
|
|
215
|
+
*
|
|
216
|
+
* @param {object} patch
|
|
217
|
+
* @param {Set<string>|string[]} knownKeys
|
|
218
|
+
* @returns {{known:object, ignored:string[]}}
|
|
219
|
+
*/
|
|
220
|
+
export function filterKnownKeys(patch, knownKeys) {
|
|
221
|
+
const known = knownKeys instanceof Set ? knownKeys : new Set(knownKeys || [])
|
|
222
|
+
const out = {}
|
|
223
|
+
const ignored = []
|
|
224
|
+
for (const [k, v] of Object.entries(patch || {})) {
|
|
225
|
+
if (known.has(k)) out[k] = v
|
|
226
|
+
else ignored.push(k)
|
|
227
|
+
}
|
|
228
|
+
return { known: out, ignored }
|
|
229
|
+
}
|
|
230
|
+
|
|
207
231
|
/**
|
|
208
232
|
* 应用一次 patch,并在抛错后**回读确认**(issue #21 的核心修复)。
|
|
209
233
|
*
|
|
@@ -295,3 +319,78 @@ export async function drainPendingConfig(env, apply, maxTries = 5) {
|
|
|
295
319
|
clearPendingConfig(env)
|
|
296
320
|
return pending.patch
|
|
297
321
|
}
|
|
322
|
+
|
|
323
|
+
// ---- 方案 A:把「应用 pending」搬进宿主允许的上下文(2026-10-08 实测结论)----------------
|
|
324
|
+
//
|
|
325
|
+
// 实测(同一宿主机、同一个 settings.update):
|
|
326
|
+
// · 从插件**回调**(setInterval / fs.watch)里调 → 抛「HMR transactions cannot be nested」,
|
|
327
|
+
// 且**一个字节都没提交**(与 patch 内容无关:只含宿主认识的键、值真的变了,同样失败);
|
|
328
|
+
// · 从插件**HTTP 请求处理器**里调 → 真的提交(130→132→130,revision 4→6)。
|
|
329
|
+
// 所以修法不是「换个 API」,而是**换调用上下文**:独立端保存后,请宿主在这条路由里应用 pending。
|
|
330
|
+
|
|
331
|
+
/** 宿主 drain 路由的令牌请求头(令牌写在心跳文件里,只有本机进程读得到)。 */
|
|
332
|
+
export const DRAIN_TOKEN_HEADER = 'x-memory-eternal-token'
|
|
333
|
+
|
|
334
|
+
/** 这个地址算不算「本机」:IPv4/IPv6 回环,含 IPv4-mapped 形式。 */
|
|
335
|
+
export function isLoopbackAddress(address) {
|
|
336
|
+
const s = String(address || '').trim().toLowerCase()
|
|
337
|
+
if (!s) return false
|
|
338
|
+
if (s === 'localhost' || s === '::1' || s === '0:0:0:0:0:0:0:1') return true
|
|
339
|
+
const v4 = s.startsWith('::ffff:') ? s.slice(7) : s
|
|
340
|
+
return /^127\./.test(v4)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 宿主侧:校验一次「请立刻应用 pending」的调用。放行返回空串,否则返回人话原因。
|
|
345
|
+
*
|
|
346
|
+
* 为什么两件都要验:宿主上的插件 API 是**无鉴权**的(任何本机页面都能 POST),所以这里必须同时
|
|
347
|
+
* 卡「只接受本机来源」与「心跳里的令牌」——令牌只写在共享配置目录的心跳文件里,远端页面拿不到。
|
|
348
|
+
*/
|
|
349
|
+
export function drainRequestError({ method, headers, remoteAddress } = {}, token = '') {
|
|
350
|
+
if (String(method || '').toUpperCase() !== 'POST') return '需 POST'
|
|
351
|
+
if (!isLoopbackAddress(remoteAddress)) return '只接受本机请求'
|
|
352
|
+
if (!token) return '宿主未准备令牌'
|
|
353
|
+
const h = headers || {}
|
|
354
|
+
const got = String(h[DRAIN_TOKEN_HEADER] || h['X-Memory-Eternal-Token'] || '')
|
|
355
|
+
if (got !== token) return '令牌不匹配'
|
|
356
|
+
return ''
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 独立端:请宿主**在 HTTP 请求上下文里**立刻应用 pending(方案 A 的客户端一半)。
|
|
361
|
+
*
|
|
362
|
+
* 失败分两类,调用方要区别对待:
|
|
363
|
+
* · status 404/405/0(旧宿主没这条路由 / 连不上)→ 退回「等宿主自己 drain」的老路,不是错误;
|
|
364
|
+
* · 其它 → 真错误(令牌不匹配、应用失败),把 body.error 如实报给用户。
|
|
365
|
+
*
|
|
366
|
+
* @param {{port?:number, token?:string, host?:string, timeoutMs?:number, fetchImpl?:Function}} opts
|
|
367
|
+
* @returns {Promise<{ok:boolean, status:number, body?:object, error?:string}>}
|
|
368
|
+
*/
|
|
369
|
+
export async function triggerHostDrain({ port, token, host = '127.0.0.1', timeoutMs = 2000, fetchImpl } = {}) {
|
|
370
|
+
const listenPort = Number(port) || 0
|
|
371
|
+
if (!listenPort || !token) return { ok: false, status: 0, error: '宿主端口/令牌未知(旧宿主,或心跳还没刷新)' }
|
|
372
|
+
const doFetch = fetchImpl || (typeof fetch === 'function' ? fetch : null)
|
|
373
|
+
if (!doFetch) return { ok: false, status: 0, error: '当前运行时没有 fetch' }
|
|
374
|
+
const url = 'http://' + host + ':' + listenPort + '/memory-eternal/api/drain-pending'
|
|
375
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
376
|
+
const timer = controller ? setTimeout(() => { try { controller.abort() } catch { /* 已中止 */ } }, Math.max(0, Number(timeoutMs) || 0)) : null
|
|
377
|
+
try {
|
|
378
|
+
const res = await doFetch(url, {
|
|
379
|
+
method: 'POST',
|
|
380
|
+
headers: { [DRAIN_TOKEN_HEADER]: String(token) },
|
|
381
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
382
|
+
})
|
|
383
|
+
const text = await res.text()
|
|
384
|
+
let body = null
|
|
385
|
+
try { body = text ? JSON.parse(text) : null } catch { body = null }
|
|
386
|
+
const status = Number(res.status) || 0
|
|
387
|
+
if (!res.ok || !body || body.ok !== true) {
|
|
388
|
+
return { ok: false, status, body, error: (body && body.error) || ('HTTP ' + status) }
|
|
389
|
+
}
|
|
390
|
+
return { ok: true, status, body }
|
|
391
|
+
} catch (error) {
|
|
392
|
+
return { ok: false, status: 0, error: String((error && error.message) || error) }
|
|
393
|
+
} finally {
|
|
394
|
+
if (timer) clearTimeout(timer)
|
|
395
|
+
}
|
|
396
|
+
}
|