memory-eternal 0.10.0 → 0.10.3
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 +2 -16
- package/.codex-plugin/plugin.json +2 -16
- package/.cursor-plugin/plugin.json +2 -16
- package/.mcp.json +9 -9
- package/LICENSE +21 -21
- package/README.en.md +19 -5
- package/README.md +23 -5
- package/bin/dsh-memory.mjs +343 -188
- 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 +12 -12
- package/hooks/hooks.codex.json +15 -15
- package/hooks/hooks.cursor.json +11 -11
- package/index.js +177 -30
- package/lib/api.js +48 -14
- package/lib/capture-log.js +65 -59
- package/lib/capture.js +112 -7
- package/lib/client.js +13 -13
- package/lib/config-sync.js +161 -90
- package/lib/db.js +359 -359
- package/lib/feedback.js +214 -205
- package/lib/llm-openai.js +84 -84
- package/lib/mcp.js +207 -166
- package/lib/node-bin.js +99 -99
- package/lib/setup.js +470 -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/vault.js +23 -6
- package/lib/watchdog.js +527 -392
- package/lib/web-assets.js +41 -0
- package/lib/web.js +316 -176
- package/package.json +12 -8
- package/scripts/release.mjs +204 -0
- package/skills/memory-recall/SKILL.md +14 -14
- package/web/app.js +12 -12
- package/web/index.html +46 -46
package/index.js
CHANGED
|
@@ -30,9 +30,10 @@ import { migrateFromMarkdown, setAuditConfig, backupDb } from './lib/db.js'
|
|
|
30
30
|
import { evaluateStall, MAX_STAMPS } from './lib/stall.js'
|
|
31
31
|
import { resolveVaultDir, currentWorkspace } from './lib/vault-resolve.js'
|
|
32
32
|
import { createHub } from './lib/sse.js'
|
|
33
|
-
import { summarizeTurn, summarizeTurnDetailed, routeCandidates, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors, hasUsableContent, deriveTitle, looksTruncated } from './lib/capture.js'
|
|
33
|
+
import { summarizeTurn, summarizeTurnDetailed, routeCandidates, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors, hasUsableContent, deriveTitle, looksTruncated, DEFAULT_CAPTURE_MAX_TOKENS, MAX_CAPTURE_MAX_TOKENS } from './lib/capture.js'
|
|
34
34
|
import { createApi, json, encodeBody } from './lib/api.js'
|
|
35
35
|
import { appendCaptureLog, readCaptureLog, rotateCaptureLog } from './lib/capture-log.js'
|
|
36
|
+
import { missingWebAssets, missingAssetsReason } from './lib/web-assets.js'
|
|
36
37
|
import { nodeBinary, childEnv } from './lib/node-bin.js'
|
|
37
38
|
|
|
38
39
|
export const name = 'memory-eternal'
|
|
@@ -65,7 +66,9 @@ export const Config = z.object({
|
|
|
65
66
|
captureProvider: z.string().default(''),
|
|
66
67
|
captureModel: z.string().default(''),
|
|
67
68
|
// 蒸馏单次输出上限(token),越高越准越贵
|
|
68
|
-
|
|
69
|
+
// 默认值 900 → 2000(issue #18):prompt 要求 300-800 字正文,900 会让超长会话常态化撞上限,
|
|
70
|
+
// 输出被截断后旧代码还会把它误报成「解析失败」,最终落一张 raw 噪声卡。
|
|
71
|
+
captureMaxTokens: z.number().min(100).max(4000).default(DEFAULT_CAPTURE_MAX_TOKENS),
|
|
69
72
|
// 召回相关性阈值(minScore),越高召回越少越精越省
|
|
70
73
|
recallMinScore: z.number().min(0).max(50).default(2),
|
|
71
74
|
// 注入体积可配置(召回)
|
|
@@ -121,7 +124,53 @@ function markAllVolatile(schema) {
|
|
|
121
124
|
|
|
122
125
|
markAllVolatile(Config)
|
|
123
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 取「会话自己的工作区」(issue #15 问题 1)。
|
|
129
|
+
*
|
|
130
|
+
* 宿主进程的 cwd 是**启动 dsh 时的目录**,拿它去 match.workspace 永远不命中 ——
|
|
131
|
+
* 于是按项目隔离的记忆库在宿主侧实际不可用。DSH 把会话目录放在 session.header.cwd,
|
|
132
|
+
* 这里把它取出来交给 resolveVaultDir。
|
|
133
|
+
*
|
|
134
|
+
* 优先级:MEMORY_WORKSPACE(显式覆盖 / 宿主 spawn 子进程时传入)> 会话 cwd > undefined
|
|
135
|
+
* (undefined 时 resolveVaultDir 会回落进程 cwd,与 CLI / MCP / hooks 的既有行为一致)。
|
|
136
|
+
*
|
|
137
|
+
* @param {{session?:{header?:{cwd?:string}}}} [agent]
|
|
138
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
139
|
+
* @returns {string|undefined}
|
|
140
|
+
*/
|
|
141
|
+
export function sessionWorkspaceOf(agent, env = process.env) {
|
|
142
|
+
const explicit = String((env && env.MEMORY_WORKSPACE) || '').trim()
|
|
143
|
+
if (explicit) return explicit
|
|
144
|
+
const cwd = agent && agent.session && agent.session.header && agent.session.header.cwd
|
|
145
|
+
return typeof cwd === 'string' && cwd.trim() ? cwd.trim() : undefined
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 汇总一次蒸馏的全部候选失败(issue #18 根因 2)。
|
|
150
|
+
*
|
|
151
|
+
* 旧实现的 failure 每轮被下一个候选覆盖,最终上报的偏偏是**最后一个兜底候选**的错误
|
|
152
|
+
* (例如一堆网关的 UNSUPPORTED_REASONING_EFFORT),把真正有信息量的主因埋掉。
|
|
153
|
+
* 这里始终以第一个候选(通常是显式配置的那个)为主因,其余按错误码计数附在后面。
|
|
154
|
+
*
|
|
155
|
+
* @param {{code:string,message:string,provider?:string,model?:string}} primary 主失败(第一个候选)
|
|
156
|
+
* @param {Array<object>} [all] 全部候选的失败
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
export function describeDistillFailure(primary, all = []) {
|
|
160
|
+
const head = `${primary.code} ${primary.message}`
|
|
161
|
+
const rest = Array.isArray(all) ? all.slice(1) : []
|
|
162
|
+
if (!rest.length) return head
|
|
163
|
+
const counts = new Map()
|
|
164
|
+
for (const f of rest) {
|
|
165
|
+
const key = String((f && f.code) || '未知')
|
|
166
|
+
counts.set(key, (counts.get(key) || 0) + 1)
|
|
167
|
+
}
|
|
168
|
+
const summary = [...counts.entries()].map(([code, n]) => (n > 1 ? `${code}×${n}` : code)).join('、')
|
|
169
|
+
return `${head}(另有 ${rest.length} 个兜底候选失败:${summary})`
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
const API_PREFIX = '/memory-eternal/api'
|
|
173
|
+
|
|
125
174
|
// DSH 宿主自动沉淀卡的署名:用可读名而非 agent 会话 id,便于在智能体筛选中归组。
|
|
126
175
|
const DSH_AGENT = 'deepseek-harness'
|
|
127
176
|
|
|
@@ -259,39 +308,74 @@ export function apply(ctx, config) {
|
|
|
259
308
|
// 首次激活:自动从 .md 文件迁移到 SQLite(幂等,已有数据则跳过)
|
|
260
309
|
// vault 解析与所有独立进程共用同一套优先级(见 lib/vault-resolve.js):
|
|
261
310
|
// MEMORY_VAULT_DIR → activeVault → vaultProfiles[].match.workspace → vaultDir → 默认库。
|
|
262
|
-
|
|
311
|
+
//
|
|
312
|
+
// issue #15:宿主进程的 cwd 是「启动 dsh 时的目录」,不是会话的工作区,
|
|
313
|
+
// 所以 match.workspace 在宿主侧永远不命中(配置能写、代码也有,就是取不到工作区)。
|
|
314
|
+
// DSH 把会话目录放在 session.header.cwd —— 每条会话路径把它显式传下来即可,
|
|
315
|
+
// CLI / MCP / hooks 仍然按各自 cwd 解析(那条路径本来就正确)。
|
|
316
|
+
const sessionWorkspace = (agent) => sessionWorkspaceOf(agent, process.env)
|
|
317
|
+
const vaultDir = (workspace) => resolveVaultDir({
|
|
263
318
|
profiles: (settings.get() ?? {}).vaultProfiles,
|
|
264
319
|
activeVault: (settings.get() ?? {}).activeVault,
|
|
265
320
|
configured: (settings.get() ?? {}).vaultDir,
|
|
321
|
+
workspace,
|
|
266
322
|
}).root
|
|
267
|
-
const vaultInfo = () => resolveVaultDir({
|
|
323
|
+
const vaultInfo = (workspace) => resolveVaultDir({
|
|
268
324
|
profiles: (settings.get() ?? {}).vaultProfiles,
|
|
269
325
|
activeVault: (settings.get() ?? {}).activeVault,
|
|
270
326
|
configured: (settings.get() ?? {}).vaultDir,
|
|
327
|
+
workspace,
|
|
271
328
|
})
|
|
272
329
|
|
|
273
330
|
// 自动迁移:从 .md 文件导入 SQLite(幂等,DB 有数据则跳过)
|
|
274
331
|
try { migrateFromMarkdown(vaultDir()).catch(() => {}) } catch {}
|
|
275
332
|
// 同步审核配置到 SQLite config 表(enforceAudit 从此表读取规则)
|
|
276
|
-
|
|
333
|
+
// 审核配置要写进**会话实际使用的那个库**(#15 的按项目库):enforceAudit 在 DB 层读的是
|
|
334
|
+
// 本库的 config 表,只同步激活库会让 profile 库退回「调用方传入的 fallback」。
|
|
335
|
+
const syncAudit = (root) => {
|
|
277
336
|
try {
|
|
278
337
|
const cfg = settings.get() ?? {}
|
|
279
|
-
setAuditConfig(vaultDir(), { auditMode: cfg.auditMode, auditExemptAgents: cfg.auditExemptAgents, auditExemptKinds: cfg.auditExemptKinds })
|
|
338
|
+
setAuditConfig(root || vaultDir(), { auditMode: cfg.auditMode, auditExemptAgents: cfg.auditExemptAgents, auditExemptKinds: cfg.auditExemptKinds })
|
|
280
339
|
} catch {}
|
|
281
340
|
}
|
|
282
341
|
syncAudit()
|
|
283
|
-
|
|
342
|
+
// 注意:watch 的回调会被传入「合并后的配置对象」,而 syncAudit 现在接受的是**库路径** ——
|
|
343
|
+
// 必须包一层,否则热更新时会把配置对象当成路径传进 setAuditConfig(静默失败)。
|
|
344
|
+
settings.watch(() => syncAudit())
|
|
284
345
|
// 独立 Web 页保存的配置:它写「待应用」文件,这边应用后删除(DSH 没运行时下次启动生效)
|
|
346
|
+
// 待应用配置的最近一次失败(#16):以前这里 catch {} 吞掉一切 —— 独立 Web 端显示
|
|
347
|
+
// 「已保存」,而 settings.update 的报错、乃至重试耗尽后「改动被放弃」都无人可见。
|
|
348
|
+
// 现在:报错进 stderr + 自动沉淀日志,并做去重节流(同一错误 5 分钟内只记一次,
|
|
349
|
+
// 否则 5 秒一轮的轮询会把日志刷爆)。
|
|
350
|
+
let lastDrainError = ''
|
|
351
|
+
let lastDrainErrorAt = 0
|
|
352
|
+
const reportDrainError = (error) => {
|
|
353
|
+
const dropped = !!(error && error.dropped)
|
|
354
|
+
const msg = String((error && error.message) || error || '未知错误')
|
|
355
|
+
const now = Date.now()
|
|
356
|
+
if (msg === lastDrainError && now - lastDrainErrorAt < 5 * 60 * 1000) return
|
|
357
|
+
lastDrainError = msg
|
|
358
|
+
lastDrainErrorAt = now
|
|
359
|
+
const why = dropped
|
|
360
|
+
? '独立 Web 端的配置改动连续失败已放弃(文件保留以便排查):' + msg
|
|
361
|
+
: '独立 Web 端的配置改动应用失败,将重试:' + msg
|
|
362
|
+
try { console.error('[memory-eternal] ' + why) } catch { /* 日志失败无妨 */ }
|
|
363
|
+
try { logCapture('system', dropped ? 'fail' : 'warn', why) } catch { /* 日志失败无妨 */ }
|
|
364
|
+
}
|
|
285
365
|
const drainPending = async () => {
|
|
286
366
|
try {
|
|
287
367
|
const { drainPendingConfig } = await import('./lib/config-sync.js')
|
|
288
368
|
const applied = await drainPendingConfig(process.env, (patch) => settings.update(patch, undefined))
|
|
289
369
|
if (applied) {
|
|
370
|
+
lastDrainError = ''
|
|
290
371
|
syncConfigFile()
|
|
291
372
|
syncAudit()
|
|
292
373
|
try { console.error('[memory-eternal] 已应用独立 Web 端的配置改动: ' + Object.keys(applied).join(',')) } catch { /* 日志失败无妨 */ }
|
|
293
374
|
}
|
|
294
|
-
} catch {
|
|
375
|
+
} catch (error) {
|
|
376
|
+
// 失败时保留文件由 config-sync.js 负责;这里只保证「失败可见」
|
|
377
|
+
reportDrainError(error)
|
|
378
|
+
}
|
|
295
379
|
}
|
|
296
380
|
drainPending()
|
|
297
381
|
// 5 秒轮询只是兜底;真正让它「准即时」的是下面这个文件监听(毫秒级)
|
|
@@ -308,9 +392,9 @@ export function apply(ctx, config) {
|
|
|
308
392
|
}, 'memory-eternal: pending config watch')
|
|
309
393
|
|
|
310
394
|
// 所有 profile 目录(当前激活 + 其余命名的),供跨库聚合。
|
|
311
|
-
const vaultRoots = () => {
|
|
395
|
+
const vaultRoots = (workspace) => {
|
|
312
396
|
const cfg = settings.get() ?? {}
|
|
313
|
-
const active = vaultDir()
|
|
397
|
+
const active = vaultDir(workspace)
|
|
314
398
|
const roots = [{ name: '', root: active }]
|
|
315
399
|
const seen = new Set([active])
|
|
316
400
|
const profiles = Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : []
|
|
@@ -328,7 +412,10 @@ export function apply(ctx, config) {
|
|
|
328
412
|
try {
|
|
329
413
|
const cfg = settings.get() ?? {}
|
|
330
414
|
const { configFilePath } = await import('./lib/capture-run.js')
|
|
331
|
-
|
|
415
|
+
const { writeFileAtomicSync } = await import('./lib/config-sync.js')
|
|
416
|
+
// 原子写(tmp + rename,见 lib/config-sync.js 里 writeFileAtomicSync 的说明):
|
|
417
|
+
// 这个文件是跨进程共享的读源,非原子写会让独立 web / MCP 读到半截内容并静默回落成默认值。
|
|
418
|
+
writeFileAtomicSync(configFilePath(process.env), JSON.stringify(cfg, null, 2))
|
|
332
419
|
} catch { /* 静默 */ }
|
|
333
420
|
}
|
|
334
421
|
syncConfigFile()
|
|
@@ -379,6 +466,14 @@ export function apply(ctx, config) {
|
|
|
379
466
|
if (captureLog.length > CAPTURE_LOG_MAX) captureLog.splice(0, captureLog.length - CAPTURE_LOG_MAX)
|
|
380
467
|
}).catch(() => {})
|
|
381
468
|
logCapture('system', 'boot', `记忆核心 v${versionRef || '?'} 已加载:自动沉淀 ${c.enabled !== false && c.autoCapture !== false ? '开' : '关'} · 库 ${vaultDir()}`)
|
|
469
|
+
// 安装完整性自检(issue #14):插件市场装出来的副本可能缺 web/ 静态资源,那时侧边栏
|
|
470
|
+
// 「记忆」弹窗只会是一页 ENOENT JSON。启动就写进自动沉淀日志/健康态,别等用户点开才发现。
|
|
471
|
+
{
|
|
472
|
+
const missing = missingWebAssets(PACKAGE_ROOT)
|
|
473
|
+
if (missing.length) {
|
|
474
|
+
logCapture('system', missing.includes('app.js') ? 'fail' : 'warn', missingAssetsReason(missing, PACKAGE_ROOT))
|
|
475
|
+
}
|
|
476
|
+
}
|
|
382
477
|
}
|
|
383
478
|
const lastCaptureAt = new Map() // sessionId -> 上次实际发起蒸馏的时间戳
|
|
384
479
|
|
|
@@ -429,6 +524,9 @@ export function apply(ctx, config) {
|
|
|
429
524
|
try {
|
|
430
525
|
const cfg = settings.get() ?? {}
|
|
431
526
|
if (!cfg.enabled || !cfg.autoCapture) return
|
|
527
|
+
// 本会话实际使用的库(#15):会话工作区命中 vaultProfiles[].match.workspace 时写进对应项目库。
|
|
528
|
+
const root = vaultDir(sessionWorkspace(agent))
|
|
529
|
+
if (root !== vaultDir()) syncAudit(root)
|
|
432
530
|
const llm = ctx.get('llm')
|
|
433
531
|
// 水位从 0 开始的那一轮(重启后同一会话的第一轮)会把整段历史切进来——
|
|
434
532
|
// 只取尾部,避免一次超大 LLM 调用(超上下文/超时)。
|
|
@@ -462,7 +560,7 @@ export function apply(ctx, config) {
|
|
|
462
560
|
const source = DSH_AGENT
|
|
463
561
|
if (cfg.distillEnabled === false || !llm) {
|
|
464
562
|
const rawTitle = deriveTitle(text)
|
|
465
|
-
const out = await captureCard(
|
|
563
|
+
const out = await captureCard(root, {
|
|
466
564
|
kind: 'content',
|
|
467
565
|
title: rawTitle.length >= 6 && !looksTruncated(rawTitle) ? rawTitle : '对话原文记录',
|
|
468
566
|
tags: ['raw'],
|
|
@@ -484,39 +582,55 @@ export function apply(ctx, config) {
|
|
|
484
582
|
// 语义去重近邻:把已有卡片索引喂给模型,让模型决定新建 vs 追加。
|
|
485
583
|
// 成本控制:dedupByLLM=false 时跳过喂 LLM 的近邻采样(纯词法去重兜底)。
|
|
486
584
|
const draft = { title: '', body: text.slice(0, 400) }
|
|
487
|
-
const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(
|
|
585
|
+
const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(root, draft, 8)
|
|
488
586
|
let route = routes[0]
|
|
489
587
|
let result = null
|
|
490
|
-
let failure = null
|
|
588
|
+
let failure = null // 主失败 = **第一个**候选的失败(通常是显式配置的那个,信息量最大)
|
|
589
|
+
const failures = [] // 全部候选的失败,用于汇总上报(#18 根因 2)
|
|
590
|
+
const maxTokens = Number(cfg.captureMaxTokens) || DEFAULT_CAPTURE_MAX_TOKENS
|
|
491
591
|
for (let i = 0; i < routes.length; i++) {
|
|
492
592
|
route = routes[i]
|
|
493
|
-
|
|
593
|
+
let detailed = await summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens })
|
|
594
|
+
// #18 建议 3:撞输出上限不是「模型坏了」,直接把上限翻倍再试一次同一候选
|
|
595
|
+
// (截断的产物既解析不了、也不是「不值得保存」,不该就此退成原文卡)。
|
|
596
|
+
if (detailed.failure && detailed.failure.code === 'MAX_TOKENS' && maxTokens < MAX_CAPTURE_MAX_TOKENS) {
|
|
597
|
+
const bigger = Math.min(maxTokens * 2, MAX_CAPTURE_MAX_TOKENS)
|
|
598
|
+
logCapture(sessionId, 'listen', `输出撞到 maxTokens=${maxTokens} 上限 → 以 ${bigger} 重试同一候选(${route.provider}/${route.model})`)
|
|
599
|
+
detailed = await summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens: bigger })
|
|
600
|
+
}
|
|
494
601
|
if (detailed.card !== undefined) { result = detailed.card; failure = null; break }
|
|
495
602
|
if (detailed.failure) {
|
|
496
|
-
|
|
603
|
+
const f = { ...detailed.failure, provider: route.provider, model: route.model }
|
|
604
|
+
failures.push(f)
|
|
605
|
+
// 只保留第一个:旧实现每轮覆盖,最终上报的是**最后一个兜底候选**的错误,
|
|
606
|
+
// 真正有信息量的主因被彻底丢弃(#18 根因 2)。
|
|
607
|
+
if (!failure) failure = f
|
|
497
608
|
const more = i < routes.length - 1
|
|
498
|
-
logCapture(sessionId, 'fail', `蒸馏调用失败(${route.provider}/${route.model}):${
|
|
609
|
+
logCapture(sessionId, 'fail', `蒸馏调用失败(${route.provider}/${route.model}):${f.code} ${f.message}${more ? ' → 换下一个 provider 重试' : ''}`)
|
|
499
610
|
if (more) continue
|
|
500
611
|
}
|
|
501
612
|
break // skip(太短 / 不值得保存)或没有更多候选
|
|
502
613
|
}
|
|
503
614
|
// 真失败要亮红并进提示段:此前只有笼统的「蒸馏无输出」,真实原因(如缺凭证)完全不可见。
|
|
504
615
|
if (failure) { health.fail(`蒸馏失败(${failure.provider}):${failure.code} ${failure.message}`); touchPrompt() }
|
|
616
|
+
// 主因 + 兜底候选失败汇总:卡片 reason 与 created 日志都用这个,避免把排查者引向错误方向。
|
|
617
|
+
const why = failure ? describeDistillFailure(failure, failures) : '无可用产出'
|
|
505
618
|
if (!result) {
|
|
506
619
|
// 蒸馏失败不能让内容白丢:退成原文卡(与「关闭蒸馏」同一条降级路径)。
|
|
507
620
|
const rawTitle = deriveTitle(text)
|
|
508
|
-
const raw = await captureCard(
|
|
621
|
+
const raw = await captureCard(root, {
|
|
509
622
|
kind: 'content',
|
|
510
623
|
title: rawTitle.length >= 6 && !looksTruncated(rawTitle) ? rawTitle : '对话原文记录',
|
|
511
|
-
|
|
624
|
+
// 蒸馏失败产出的原文卡与被批准的噪声卡是审核中心的主要污染源(#15 评论):
|
|
625
|
+
// 打上独立 tag,便于按 tag 过滤 / 批量处理,不再和正常卡混作一团。
|
|
626
|
+
tags: ['raw', 'distill-failed'],
|
|
512
627
|
body: text,
|
|
513
628
|
source,
|
|
514
629
|
status: resolveAuditStatus(cfg, 'content', source),
|
|
515
630
|
submittedBy: source,
|
|
516
631
|
severity: 'info',
|
|
517
|
-
reason:
|
|
632
|
+
reason: `AI 自动沉淀(蒸馏无输出 → 原文卡兜底;主因:${why})`,
|
|
518
633
|
}, { threshold: cfg.dedupThreshold })
|
|
519
|
-
const why = failure ? `${failure.code} ${failure.message}` : '无可用产出'
|
|
520
634
|
if (raw.ok) { countWrite(); logCapture(sessionId, 'created', `蒸馏失败(${why})→ 原文卡兜底`, { path: raw.path ?? raw.rel, kind: 'content', model: route.model }) }
|
|
521
635
|
else if (raw.duplicate) { countWrite(); logCapture(sessionId, 'appended', '蒸馏无输出 + 与已有卡重复 → 追加更新', { path: raw.duplicate.path, model: route.model }) }
|
|
522
636
|
else logCapture(sessionId, 'fail', `蒸馏无输出,且兜底原文卡也失败:${raw.reason || '未知原因'}`, { model: route.model })
|
|
@@ -525,7 +639,7 @@ export function apply(ctx, config) {
|
|
|
525
639
|
if (result.save !== true) { logCapture(sessionId, 'skip', '模型判定不值得保存', { model: route.model }); return }
|
|
526
640
|
if (result.append_to) {
|
|
527
641
|
// 模型判定属于已有卡 → 追加更新记录,不新建(boujoy 语义)。
|
|
528
|
-
await captureUpdate(
|
|
642
|
+
await captureUpdate(root, result.append_to, result.update, { threshold: cfg.dedupThreshold })
|
|
529
643
|
countWrite()
|
|
530
644
|
logCapture(sessionId, 'appended', '模型判定属于已有卡 → 追加更新', { path: result.append_to, model: route.model })
|
|
531
645
|
return
|
|
@@ -541,7 +655,7 @@ export function apply(ctx, config) {
|
|
|
541
655
|
severity: 'info',
|
|
542
656
|
reason: 'AI 自动沉淀(蒸馏卡)',
|
|
543
657
|
}
|
|
544
|
-
const out = await captureCard(
|
|
658
|
+
const out = await captureCard(root, card, { threshold: cfg.dedupThreshold })
|
|
545
659
|
if (out.ok) {
|
|
546
660
|
countWrite()
|
|
547
661
|
logCapture(sessionId, 'created', `新卡:${card.title}(${card.status === 'approved' ? '已入库' : '待审核'})`, { path: out.path ?? out.rel, kind: card.kind, model: route.model })
|
|
@@ -549,7 +663,7 @@ export function apply(ctx, config) {
|
|
|
549
663
|
}
|
|
550
664
|
if (out.duplicate) {
|
|
551
665
|
// 词法兜底:高度相似 → 追加更新记录而不是再建一张重复卡。
|
|
552
|
-
await captureUpdate(
|
|
666
|
+
await captureUpdate(root, out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, {
|
|
553
667
|
threshold: cfg.dedupThreshold,
|
|
554
668
|
})
|
|
555
669
|
countWrite()
|
|
@@ -676,11 +790,14 @@ export function apply(ctx, config) {
|
|
|
676
790
|
render(_a, v) { return [{ type: 'text', text: v }] },
|
|
677
791
|
},
|
|
678
792
|
timeoutMs: 20000,
|
|
679
|
-
|
|
793
|
+
// 第二个参数是 ToolRunContext(含 caller agent)—— 用它取会话工作区,
|
|
794
|
+
// 让「当前库」也按项目路由,而不是永远落在进程 cwd 对应的库(#15)。
|
|
795
|
+
async execute(args, exec) {
|
|
680
796
|
const cfg = settings.get() ?? {}
|
|
681
797
|
if (!cfg.enabled) return '(记忆核心已禁用)'
|
|
682
798
|
const query = String(args.query || '').trim()
|
|
683
799
|
if (!query) return '(未提供检索词)'
|
|
800
|
+
const workspace = sessionWorkspace(exec && exec.agent)
|
|
684
801
|
const cfg2 = settings.get() ?? {}
|
|
685
802
|
const defLimit = Number(cfg2.recallLimit) || 5
|
|
686
803
|
const defLen = Number(cfg2.recallSummaryLen) || 130
|
|
@@ -688,8 +805,8 @@ export function apply(ctx, config) {
|
|
|
688
805
|
const limit = Math.min(Math.max(Number(args.limit) || defLimit, 1), 20)
|
|
689
806
|
// 作用域(#10):留空 = 当前激活库;all = 跨库聚合;库名 = 指定 profile;路径前缀 = 命中多个库
|
|
690
807
|
const scope = String(args.scope || '').trim()
|
|
691
|
-
const roots = vaultRoots()
|
|
692
|
-
let targets = [{ name: '', root: vaultDir() }]
|
|
808
|
+
const roots = vaultRoots(workspace)
|
|
809
|
+
let targets = [{ name: '', root: vaultDir(workspace) }]
|
|
693
810
|
let scoped = false
|
|
694
811
|
if (scope) {
|
|
695
812
|
const want = scope.toLowerCase()
|
|
@@ -907,7 +1024,7 @@ export function apply(ctx, config) {
|
|
|
907
1024
|
retried: !!(writeResult && writeResult.retried),
|
|
908
1025
|
note: pending.length
|
|
909
1026
|
? '已写入;宿主尚未回流这些值(面板已本地生效,重启 DSH 后以配置文件为准)'
|
|
910
|
-
: '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH
|
|
1027
|
+
: '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH 生效;若已有常驻 watchdog,关闭/改参不会自动停掉它,需 dsh-memory stop / restart(#19)',
|
|
911
1028
|
})
|
|
912
1029
|
} catch (e) {
|
|
913
1030
|
if (e && e.code === 'SETTINGS_CONFLICT') return json(res, 409, { ok: false, error: '配置已被外部修改,请刷新后重试(revision conflict)' })
|
|
@@ -1020,10 +1137,26 @@ export function apply(ctx, config) {
|
|
|
1020
1137
|
}
|
|
1021
1138
|
|
|
1022
1139
|
// (3) 看门狗独立进程:默认不 spawn;开启时启动一个与 DSH 解耦的 node watchdog。
|
|
1140
|
+
const wdPort = Number(cfg0.webPort) || 7999
|
|
1023
1141
|
if (cfg0.watchdogAutoSpawn === true && cfg0.autoWeb !== false) {
|
|
1024
1142
|
import('./lib/watchdog.js')
|
|
1025
1143
|
.then((m) => {
|
|
1026
|
-
|
|
1144
|
+
// 同端口已有**活着的** watchdog 时不再 spawn(issue #19):旧实现每次都 spawn 一个、
|
|
1145
|
+
// 每次都打印 "spawned",而新进程会在锁上让位退出 —— 日志与实际状态完全相反,
|
|
1146
|
+
// 排查时必然误判。这里如实打印 delegated,并提示版本漂移与替换命令。
|
|
1147
|
+
const lock = m.readWatchdogLock(process.env)
|
|
1148
|
+
const existing = lock.watchdogs.find((w) => Number(w.port) === wdPort && m.isPidAlive(w.pid))
|
|
1149
|
+
if (existing) {
|
|
1150
|
+
const cur = m.currentPkgVersion()
|
|
1151
|
+
const stale = !!(existing.pkgVersion && cur && existing.pkgVersion !== cur)
|
|
1152
|
+
const why = stale
|
|
1153
|
+
? `watchdog delegated to existing pid=${existing.pid} port=${wdPort}(常驻实例是旧版 v${existing.pkgVersion},当前 v${cur};如需换新版请执行 dsh-memory restart --port ${wdPort})`
|
|
1154
|
+
: `watchdog delegated to existing pid=${existing.pid} port=${wdPort}(同端口已有常驻实例,本次不再 spawn)`
|
|
1155
|
+
console.error(`[memory-eternal] ${why}`)
|
|
1156
|
+
logCapture('system', stale ? 'warn' : 'boot', why)
|
|
1157
|
+
return
|
|
1158
|
+
}
|
|
1159
|
+
const port = wdPort
|
|
1027
1160
|
// 用 nodeBinary() 而非 process.execPath:Electron 宿主下后者是 Electron
|
|
1028
1161
|
// 主程序,spawn 出来不会执行 watchdog.js(见 lib/node-bin.js)。
|
|
1029
1162
|
const bin = nodeBinary()
|
|
@@ -1047,11 +1180,25 @@ export function apply(ctx, config) {
|
|
|
1047
1180
|
console.error(`[memory-eternal] ${why}`)
|
|
1048
1181
|
logCapture('system', 'fail', why)
|
|
1049
1182
|
})
|
|
1183
|
+
} else if (cfg0.autoWeb !== false) {
|
|
1184
|
+
// 配置关掉了 watchdogAutoSpawn,但机器上可能仍有常驻实例(#19):**故意不自动杀**
|
|
1185
|
+
// (多会话共用同一个 watchdog),但必须把「它还在跑」讲清楚,否则用户以为已经关了。
|
|
1186
|
+
import('./lib/watchdog.js')
|
|
1187
|
+
.then((m) => {
|
|
1188
|
+
const alive = m.readWatchdogLock(process.env).watchdogs.filter((w) => Number(w.port) === wdPort && m.isPidAlive(w.pid))
|
|
1189
|
+
if (!alive.length) return
|
|
1190
|
+
const why = `watchdogAutoSpawn 已关闭,但仍有常驻 watchdog pid=${alive.map((w) => w.pid).join(',')} port=${wdPort} 在运行(配置改动不会自动停掉既有实例);如需停止请执行 dsh-memory stop --port ${wdPort}`
|
|
1191
|
+
console.error(`[memory-eternal] ${why}`)
|
|
1192
|
+
logCapture('system', 'warn', why)
|
|
1193
|
+
})
|
|
1194
|
+
.catch(() => {})
|
|
1050
1195
|
}
|
|
1051
1196
|
|
|
1052
1197
|
return () => {
|
|
1053
1198
|
if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null }
|
|
1054
|
-
// 注意:watchdog
|
|
1199
|
+
// 注意:watchdog 进程是独立的,**故意不杀**(7×24 与多会话共用同一个实例)。
|
|
1200
|
+
// 因此同端口下的配置变更(含关闭 watchdogAutoSpawn)不会自动停掉或替换既有实例,
|
|
1201
|
+
// 需要显式执行 dsh-memory stop / restart(issue #19)。
|
|
1055
1202
|
}
|
|
1056
1203
|
}, 'memory-eternal: multi-host ensure')
|
|
1057
1204
|
|
package/lib/api.js
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
import { gzipSync, brotliCompressSync, constants as zlibConstants } from 'node:zlib'
|
|
15
15
|
import os from 'node:os'
|
|
16
16
|
import { readFileSync } from 'node:fs'
|
|
17
|
-
import { listCards, countCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, restoreCard } from './vault.js'
|
|
18
|
-
import { compressExcerpt } from './capture.js'
|
|
17
|
+
import { listCards, countCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, restoreCard, mainStoreBodies } from './vault.js'
|
|
18
|
+
import { compressExcerpt, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
|
|
19
19
|
import { buildDiagnostics } from './feedback.js'
|
|
20
20
|
import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
|
|
21
21
|
import { backupDb } from './db.js'
|
|
@@ -170,7 +170,12 @@ export function createApi(deps = {}) {
|
|
|
170
170
|
logD = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.entries) ? raw.entries : [])
|
|
171
171
|
} catch { logD = [] }
|
|
172
172
|
const homeD = (() => { try { return os.homedir() } catch { return '' } })()
|
|
173
|
+
// 待应用配置的失败状态(#16):独立 Web 端保存 → pending 文件 → 宿主 drain 的路,
|
|
174
|
+
// 出了问题以前完全无迹可查,现在进诊断(「反馈异常」一键带出)。
|
|
175
|
+
let syncD = null
|
|
176
|
+
try { const { readPendingConfig } = await import('./config-sync.js'); syncD = readPendingConfig(process.env) } catch { syncD = null }
|
|
173
177
|
const text = buildDiagnostics({
|
|
178
|
+
configSync: syncD,
|
|
174
179
|
version: (dshD && dshD.version) || envD.MEMORY_ETERNAL_VERSION || '',
|
|
175
180
|
dshVersion: (dshD && dshD.version) || '',
|
|
176
181
|
host: dshD ? 'deepseek-harness' : 'standalone web',
|
|
@@ -205,7 +210,9 @@ export function createApi(deps = {}) {
|
|
|
205
210
|
}
|
|
206
211
|
case '/export': {
|
|
207
212
|
await ensureVault(vaultRoot)
|
|
208
|
-
|
|
213
|
+
const cards = await exportCards(vaultRoot)
|
|
214
|
+
// 带上信封元信息:客户端可以直接把整个响应体当备份文件落下(见 /import 的兼容读取)。
|
|
215
|
+
json(res, 200, { ok: true, format: 'memory-eternal-vault', formatVersion: 1, exportedAt: new Date().toISOString(), count: cards.length, cards })
|
|
209
216
|
return
|
|
210
217
|
}
|
|
211
218
|
case '/delete': {
|
|
@@ -282,19 +289,33 @@ export function createApi(deps = {}) {
|
|
|
282
289
|
if (raw.length > 20 * 1024 * 1024) return json(res, 413, { ok: false, error: '文件过大' })
|
|
283
290
|
let payload
|
|
284
291
|
try { payload = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
292
|
+
// 兼容两种备份形状:
|
|
293
|
+
// ① 顶层就是卡片数组 —— v0.10.0 及以前「导出JSON」写出的 memory-vault.json;
|
|
294
|
+
// ② 带信封的对象 { format, formatVersion, exportedAt, count, cards: [...] } —— v0.10.1 起。
|
|
295
|
+
// 旧实现只认 payload.cards,于是①被静默当成空备份(imported=0 / skipped=0 / ok=true,
|
|
296
|
+
// UI 只显示「导入完成:0」,看不出是格式不匹配)—— 这是 issue「导出 OK 导入 0 张」的根因。
|
|
297
|
+
const list = Array.isArray(payload) ? payload
|
|
298
|
+
: Array.isArray(payload?.cards) ? payload.cards
|
|
299
|
+
: null
|
|
300
|
+
if (!list) return json(res, 400, { ok: false, error: '文件格式无法识别:应为卡片数组,或含 cards 数组的备份对象(请用本插件「导出JSON」生成的文件)' })
|
|
301
|
+
if (!list.length) return json(res, 200, { ok: true, imported: 0, skipped: 0, quarantined: 0, total: 0, failed: [], warning: '文件里没有任何卡片' })
|
|
302
|
+
// 去重基线取「导入前已存在的主库卡」快照:备份里的近似卡不能在同一批里互相吞掉。
|
|
303
|
+
const baseline = await mainStoreBodies(vaultRoot)
|
|
304
|
+
let imported = 0, skipped = 0, quarantined = 0
|
|
305
|
+
const failed = []
|
|
288
306
|
for (const c of list) {
|
|
307
|
+
if (!c || typeof c !== 'object') { skipped++; failed.push({ reason: '条目不是对象' }); continue }
|
|
289
308
|
const text = c.text || ''
|
|
290
|
-
let kind = c.kind || 'knowledge', title = c.title || '导入记忆', tags = [], body = text, source = '', status = c.status || 'pending'
|
|
291
|
-
try { const p = parseCard(text); kind = p.meta.kind || kind; title = p.meta.title || title; tags = p.meta.tags || tags; body = p.body; source = p.meta.source || source; status = p.meta.status || status } catch {}
|
|
309
|
+
let kind = c.kind || 'knowledge', title = c.title || '导入记忆', tags = [], body = text, source = '', status = c.status || 'pending', deletedAt = c.deletedAt || ''
|
|
310
|
+
try { const p = parseCard(text); kind = p.meta.kind || kind; title = p.meta.title || title; tags = p.meta.tags || tags; body = p.body; source = p.meta.source || source; status = p.meta.status || status; deletedAt = p.meta.deletedAt || deletedAt } catch {}
|
|
311
|
+
if (!String(body || '').trim()) { skipped++; failed.push({ path: c.path || '', title: String(title).slice(0, 80), reason: '正文为空' }); continue }
|
|
292
312
|
// 两套存储:导入按导出时的状态回填(备份/迁移才完整),但仍过 enforceAudit 这道闸门 ——
|
|
293
313
|
// 校验不过的会被写成 pending 进隔离区,主库永远不会被导入数据绕过。
|
|
294
|
-
const r = await writeCard(vaultRoot, { kind, title, tags, body, source, status })
|
|
295
|
-
if (r.ok) imported++;
|
|
314
|
+
const r = await writeCard(vaultRoot, { kind, title, tags, body, source, status, deletedAt }, { dedupAgainst: baseline })
|
|
315
|
+
if (r.ok) { imported++; if (r.store === 'quarantine') quarantined++ }
|
|
316
|
+
else { skipped++; failed.push({ path: c.path || '', title: String(title).slice(0, 80), reason: r.duplicate ? '已存在(内容重复)' : (r.error || '写入被拒') }) }
|
|
296
317
|
}
|
|
297
|
-
json(res, 200, { ok: true, imported, skipped })
|
|
318
|
+
json(res, 200, { ok: true, imported, skipped, quarantined, total: list.length, failed: failed.slice(0, 50) })
|
|
298
319
|
return
|
|
299
320
|
}
|
|
300
321
|
case '/merge': {
|
|
@@ -405,7 +426,7 @@ export function createApi(deps = {}) {
|
|
|
405
426
|
const safe = {
|
|
406
427
|
autoCapture: cfg.autoCapture ?? true, autoRecall: cfg.autoRecall ?? true, recallLimit: cfg.recallLimit ?? 5, recallSummaryLen: cfg.recallSummaryLen ?? 130, recallIncludeBody: cfg.recallIncludeBody ?? false,
|
|
407
428
|
captureMinChars: cfg.captureMinChars ?? 200, captureCooldownMs: cfg.captureCooldownMs ?? 300000, dedupThreshold: cfg.dedupThreshold ?? 0.62, maxCardsPerDay: cfg.maxCardsPerDay ?? 60,
|
|
408
|
-
distillEnabled: cfg.distillEnabled ?? true, dedupByLLM: cfg.dedupByLLM ?? true, captureMaxTokens: cfg.captureMaxTokens ??
|
|
429
|
+
distillEnabled: cfg.distillEnabled ?? true, dedupByLLM: cfg.dedupByLLM ?? true, captureMaxTokens: cfg.captureMaxTokens ?? DEFAULT_CAPTURE_MAX_TOKENS, recallMinScore: cfg.recallMinScore ?? 2,
|
|
409
430
|
autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0', autoWebMode: cfg.autoWebMode || 'init', webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999, webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000, webMaxRestart: Number(cfg.webMaxRestart) || 10, watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1', autoMcpSetup: cfg.autoMcpSetup ?? false,
|
|
410
431
|
auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents ?? [], auditExemptKinds: cfg.auditExemptKinds ?? [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
|
|
411
432
|
vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [], activeVault: cfg.activeVault || '',
|
|
@@ -447,15 +468,28 @@ export function createApi(deps = {}) {
|
|
|
447
468
|
}
|
|
448
469
|
if (bad.length) return json(res, 400, { ok: false, error: '字段值不合法:' + bad.join('、'), fields: bad })
|
|
449
470
|
if (!Object.keys(clean).length) return json(res, 400, { ok: false, error: '没有可写入的字段' })
|
|
450
|
-
const { writePendingConfig } = await import('./config-sync.js')
|
|
471
|
+
const { writePendingConfig, readPendingConfig } = await import('./config-sync.js')
|
|
472
|
+
// #16 建议 4:上一份改动是否已被判定失败?是的话这次不能再报「一切正常」——
|
|
473
|
+
// 否则用户第 N 次点击保存,看到的仍是成功,而问题一直在(其实是 settings.update 在报错)。
|
|
474
|
+
const before = readPendingConfig(process.env)
|
|
451
475
|
const out = writePendingConfig(process.env, clean)
|
|
452
476
|
hub.broadcast('config', { at: out.at, source: 'standalone', applied: Object.keys(clean) })
|
|
477
|
+
const stale = before && (before.dropped || before.lastError)
|
|
453
478
|
return json(res, 200, {
|
|
454
479
|
ok: true, applied: Object.keys(clean), pending: Object.keys(clean), retried: false, at: out.at,
|
|
480
|
+
warning: stale
|
|
481
|
+
? '上一次写入待应用时连续失败(' + (before.lastError || '原因未记录') + ');本次已重新写入并重试,如仍不生效请查看「反馈异常」里的诊断信息'
|
|
482
|
+
: undefined,
|
|
455
483
|
note: '已写入待应用:DSH 端会自动同步(DSH 未运行时下次启动生效)',
|
|
456
484
|
})
|
|
457
485
|
}
|
|
458
|
-
|
|
486
|
+
let pendingD = null
|
|
487
|
+
try { const { readPendingConfig } = await import('./config-sync.js'); pendingD = readPendingConfig(process.env) } catch { pendingD = null }
|
|
488
|
+
json(res, 200, {
|
|
489
|
+
ok: true, config: safe, revision: 0, writable: true, readonly: false, viaPending: true,
|
|
490
|
+
pendingConfig: pendingD ? { at: pendingD.at, tries: pendingD.tries, dropped: pendingD.dropped, lastError: pendingD.lastError } : null,
|
|
491
|
+
version: env.MEMORY_ETERNAL_VERSION || '',
|
|
492
|
+
})
|
|
459
493
|
return
|
|
460
494
|
}
|
|
461
495
|
case '/compress': {
|