memory-eternal 0.9.23 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +27 -7
- package/README.md +40 -7
- package/bin/dsh-memory.mjs +318 -188
- package/examples/mcp-memory/memory-eternal.cordis.yml +59 -0
- 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 +187 -32
- package/lib/api.js +592 -536
- package/lib/capture-log.js +65 -59
- package/lib/capture-run.js +134 -126
- package/lib/capture.js +278 -14
- package/lib/client.js +20 -17
- package/lib/config-sync.js +161 -89
- package/lib/db.js +359 -212
- package/lib/feedback.js +214 -205
- package/lib/llm-openai.js +84 -84
- package/lib/mcp.js +197 -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 +347 -106
- package/lib/watchdog.js +527 -392
- package/lib/web-assets.js +41 -0
- package/lib/web.js +316 -176
- package/package.json +12 -7
- package/scripts/release.mjs +204 -0
- package/skills/memory-recall/SKILL.md +14 -14
- package/web/app.js +20 -17
- 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 } 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,12 +524,21 @@ 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 调用(超上下文/超时)。
|
|
435
533
|
const raw = extractLastTurn(events)
|
|
436
534
|
const text = raw.length > CAPTURE_TEXT_MAX ? raw.slice(-CAPTURE_TEXT_MAX) : raw
|
|
437
535
|
logCapture(sessionId, 'listen', `会话事件 ${events.length} 条 → 有效对话 ${text.length} 字${raw.length > text.length ? '(超长已截尾)' : ''}`, { preview: preview(text) })
|
|
536
|
+
// 噪声闸门(P0):extractLastTurn 已剥掉运行时注入(环境快照 / team 广播 / 工具说明),
|
|
537
|
+
// 这里再判一次「剥完还有没有真实内容」——只有注入残留的轮次不进 LLM、不写卡。
|
|
538
|
+
if (!hasUsableContent(text)) {
|
|
539
|
+
logCapture(sessionId, 'skip', `有效内容不足:剥离运行时注入后只剩 ${text.trim().length} 字(多为环境快照 / 广播 / 工具说明)`)
|
|
540
|
+
return
|
|
541
|
+
}
|
|
438
542
|
if (text.length < (cfg.captureMinChars ?? 200)) {
|
|
439
543
|
logCapture(sessionId, 'skip', `内容太短:${text.length} < ${cfg.captureMinChars ?? 200} 字(调小「捕获最小长度」可放宽)`)
|
|
440
544
|
return
|
|
@@ -455,9 +559,10 @@ export function apply(ctx, config) {
|
|
|
455
559
|
// 成本控制:distillEnabled=false 时不调 LLM,直接存原文卡(零蒸馏成本)
|
|
456
560
|
const source = DSH_AGENT
|
|
457
561
|
if (cfg.distillEnabled === false || !llm) {
|
|
458
|
-
const
|
|
562
|
+
const rawTitle = deriveTitle(text)
|
|
563
|
+
const out = await captureCard(root, {
|
|
459
564
|
kind: 'content',
|
|
460
|
-
title:
|
|
565
|
+
title: rawTitle.length >= 6 && !looksTruncated(rawTitle) ? rawTitle : '对话原文记录',
|
|
461
566
|
tags: ['raw'],
|
|
462
567
|
body: text,
|
|
463
568
|
source,
|
|
@@ -477,38 +582,55 @@ export function apply(ctx, config) {
|
|
|
477
582
|
// 语义去重近邻:把已有卡片索引喂给模型,让模型决定新建 vs 追加。
|
|
478
583
|
// 成本控制:dedupByLLM=false 时跳过喂 LLM 的近邻采样(纯词法去重兜底)。
|
|
479
584
|
const draft = { title: '', body: text.slice(0, 400) }
|
|
480
|
-
const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(
|
|
585
|
+
const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(root, draft, 8)
|
|
481
586
|
let route = routes[0]
|
|
482
587
|
let result = null
|
|
483
|
-
let failure = null
|
|
588
|
+
let failure = null // 主失败 = **第一个**候选的失败(通常是显式配置的那个,信息量最大)
|
|
589
|
+
const failures = [] // 全部候选的失败,用于汇总上报(#18 根因 2)
|
|
590
|
+
const maxTokens = Number(cfg.captureMaxTokens) || DEFAULT_CAPTURE_MAX_TOKENS
|
|
484
591
|
for (let i = 0; i < routes.length; i++) {
|
|
485
592
|
route = routes[i]
|
|
486
|
-
|
|
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
|
+
}
|
|
487
601
|
if (detailed.card !== undefined) { result = detailed.card; failure = null; break }
|
|
488
602
|
if (detailed.failure) {
|
|
489
|
-
|
|
603
|
+
const f = { ...detailed.failure, provider: route.provider, model: route.model }
|
|
604
|
+
failures.push(f)
|
|
605
|
+
// 只保留第一个:旧实现每轮覆盖,最终上报的是**最后一个兜底候选**的错误,
|
|
606
|
+
// 真正有信息量的主因被彻底丢弃(#18 根因 2)。
|
|
607
|
+
if (!failure) failure = f
|
|
490
608
|
const more = i < routes.length - 1
|
|
491
|
-
logCapture(sessionId, 'fail', `蒸馏调用失败(${route.provider}/${route.model}):${
|
|
609
|
+
logCapture(sessionId, 'fail', `蒸馏调用失败(${route.provider}/${route.model}):${f.code} ${f.message}${more ? ' → 换下一个 provider 重试' : ''}`)
|
|
492
610
|
if (more) continue
|
|
493
611
|
}
|
|
494
612
|
break // skip(太短 / 不值得保存)或没有更多候选
|
|
495
613
|
}
|
|
496
614
|
// 真失败要亮红并进提示段:此前只有笼统的「蒸馏无输出」,真实原因(如缺凭证)完全不可见。
|
|
497
615
|
if (failure) { health.fail(`蒸馏失败(${failure.provider}):${failure.code} ${failure.message}`); touchPrompt() }
|
|
616
|
+
// 主因 + 兜底候选失败汇总:卡片 reason 与 created 日志都用这个,避免把排查者引向错误方向。
|
|
617
|
+
const why = failure ? describeDistillFailure(failure, failures) : '无可用产出'
|
|
498
618
|
if (!result) {
|
|
499
619
|
// 蒸馏失败不能让内容白丢:退成原文卡(与「关闭蒸馏」同一条降级路径)。
|
|
500
|
-
const
|
|
620
|
+
const rawTitle = deriveTitle(text)
|
|
621
|
+
const raw = await captureCard(root, {
|
|
501
622
|
kind: 'content',
|
|
502
|
-
title:
|
|
503
|
-
|
|
623
|
+
title: rawTitle.length >= 6 && !looksTruncated(rawTitle) ? rawTitle : '对话原文记录',
|
|
624
|
+
// 蒸馏失败产出的原文卡与被批准的噪声卡是审核中心的主要污染源(#15 评论):
|
|
625
|
+
// 打上独立 tag,便于按 tag 过滤 / 批量处理,不再和正常卡混作一团。
|
|
626
|
+
tags: ['raw', 'distill-failed'],
|
|
504
627
|
body: text,
|
|
505
628
|
source,
|
|
506
629
|
status: resolveAuditStatus(cfg, 'content', source),
|
|
507
630
|
submittedBy: source,
|
|
508
631
|
severity: 'info',
|
|
509
|
-
reason:
|
|
632
|
+
reason: `AI 自动沉淀(蒸馏无输出 → 原文卡兜底;主因:${why})`,
|
|
510
633
|
}, { threshold: cfg.dedupThreshold })
|
|
511
|
-
const why = failure ? `${failure.code} ${failure.message}` : '无可用产出'
|
|
512
634
|
if (raw.ok) { countWrite(); logCapture(sessionId, 'created', `蒸馏失败(${why})→ 原文卡兜底`, { path: raw.path ?? raw.rel, kind: 'content', model: route.model }) }
|
|
513
635
|
else if (raw.duplicate) { countWrite(); logCapture(sessionId, 'appended', '蒸馏无输出 + 与已有卡重复 → 追加更新', { path: raw.duplicate.path, model: route.model }) }
|
|
514
636
|
else logCapture(sessionId, 'fail', `蒸馏无输出,且兜底原文卡也失败:${raw.reason || '未知原因'}`, { model: route.model })
|
|
@@ -517,7 +639,7 @@ export function apply(ctx, config) {
|
|
|
517
639
|
if (result.save !== true) { logCapture(sessionId, 'skip', '模型判定不值得保存', { model: route.model }); return }
|
|
518
640
|
if (result.append_to) {
|
|
519
641
|
// 模型判定属于已有卡 → 追加更新记录,不新建(boujoy 语义)。
|
|
520
|
-
await captureUpdate(
|
|
642
|
+
await captureUpdate(root, result.append_to, result.update, { threshold: cfg.dedupThreshold })
|
|
521
643
|
countWrite()
|
|
522
644
|
logCapture(sessionId, 'appended', '模型判定属于已有卡 → 追加更新', { path: result.append_to, model: route.model })
|
|
523
645
|
return
|
|
@@ -533,7 +655,7 @@ export function apply(ctx, config) {
|
|
|
533
655
|
severity: 'info',
|
|
534
656
|
reason: 'AI 自动沉淀(蒸馏卡)',
|
|
535
657
|
}
|
|
536
|
-
const out = await captureCard(
|
|
658
|
+
const out = await captureCard(root, card, { threshold: cfg.dedupThreshold })
|
|
537
659
|
if (out.ok) {
|
|
538
660
|
countWrite()
|
|
539
661
|
logCapture(sessionId, 'created', `新卡:${card.title}(${card.status === 'approved' ? '已入库' : '待审核'})`, { path: out.path ?? out.rel, kind: card.kind, model: route.model })
|
|
@@ -541,7 +663,7 @@ export function apply(ctx, config) {
|
|
|
541
663
|
}
|
|
542
664
|
if (out.duplicate) {
|
|
543
665
|
// 词法兜底:高度相似 → 追加更新记录而不是再建一张重复卡。
|
|
544
|
-
await captureUpdate(
|
|
666
|
+
await captureUpdate(root, out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, {
|
|
545
667
|
threshold: cfg.dedupThreshold,
|
|
546
668
|
})
|
|
547
669
|
countWrite()
|
|
@@ -668,11 +790,14 @@ export function apply(ctx, config) {
|
|
|
668
790
|
render(_a, v) { return [{ type: 'text', text: v }] },
|
|
669
791
|
},
|
|
670
792
|
timeoutMs: 20000,
|
|
671
|
-
|
|
793
|
+
// 第二个参数是 ToolRunContext(含 caller agent)—— 用它取会话工作区,
|
|
794
|
+
// 让「当前库」也按项目路由,而不是永远落在进程 cwd 对应的库(#15)。
|
|
795
|
+
async execute(args, exec) {
|
|
672
796
|
const cfg = settings.get() ?? {}
|
|
673
797
|
if (!cfg.enabled) return '(记忆核心已禁用)'
|
|
674
798
|
const query = String(args.query || '').trim()
|
|
675
799
|
if (!query) return '(未提供检索词)'
|
|
800
|
+
const workspace = sessionWorkspace(exec && exec.agent)
|
|
676
801
|
const cfg2 = settings.get() ?? {}
|
|
677
802
|
const defLimit = Number(cfg2.recallLimit) || 5
|
|
678
803
|
const defLen = Number(cfg2.recallSummaryLen) || 130
|
|
@@ -680,8 +805,8 @@ export function apply(ctx, config) {
|
|
|
680
805
|
const limit = Math.min(Math.max(Number(args.limit) || defLimit, 1), 20)
|
|
681
806
|
// 作用域(#10):留空 = 当前激活库;all = 跨库聚合;库名 = 指定 profile;路径前缀 = 命中多个库
|
|
682
807
|
const scope = String(args.scope || '').trim()
|
|
683
|
-
const roots = vaultRoots()
|
|
684
|
-
let targets = [{ name: '', root: vaultDir() }]
|
|
808
|
+
const roots = vaultRoots(workspace)
|
|
809
|
+
let targets = [{ name: '', root: vaultDir(workspace) }]
|
|
685
810
|
let scoped = false
|
|
686
811
|
if (scope) {
|
|
687
812
|
const want = scope.toLowerCase()
|
|
@@ -899,7 +1024,7 @@ export function apply(ctx, config) {
|
|
|
899
1024
|
retried: !!(writeResult && writeResult.retried),
|
|
900
1025
|
note: pending.length
|
|
901
1026
|
? '已写入;宿主尚未回流这些值(面板已本地生效,重启 DSH 后以配置文件为准)'
|
|
902
|
-
: '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH
|
|
1027
|
+
: '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH 生效;若已有常驻 watchdog,关闭/改参不会自动停掉它,需 dsh-memory stop / restart(#19)',
|
|
903
1028
|
})
|
|
904
1029
|
} catch (e) {
|
|
905
1030
|
if (e && e.code === 'SETTINGS_CONFLICT') return json(res, 409, { ok: false, error: '配置已被外部修改,请刷新后重试(revision conflict)' })
|
|
@@ -1012,10 +1137,26 @@ export function apply(ctx, config) {
|
|
|
1012
1137
|
}
|
|
1013
1138
|
|
|
1014
1139
|
// (3) 看门狗独立进程:默认不 spawn;开启时启动一个与 DSH 解耦的 node watchdog。
|
|
1140
|
+
const wdPort = Number(cfg0.webPort) || 7999
|
|
1015
1141
|
if (cfg0.watchdogAutoSpawn === true && cfg0.autoWeb !== false) {
|
|
1016
1142
|
import('./lib/watchdog.js')
|
|
1017
1143
|
.then((m) => {
|
|
1018
|
-
|
|
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
|
|
1019
1160
|
// 用 nodeBinary() 而非 process.execPath:Electron 宿主下后者是 Electron
|
|
1020
1161
|
// 主程序,spawn 出来不会执行 watchdog.js(见 lib/node-bin.js)。
|
|
1021
1162
|
const bin = nodeBinary()
|
|
@@ -1039,11 +1180,25 @@ export function apply(ctx, config) {
|
|
|
1039
1180
|
console.error(`[memory-eternal] ${why}`)
|
|
1040
1181
|
logCapture('system', 'fail', why)
|
|
1041
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(() => {})
|
|
1042
1195
|
}
|
|
1043
1196
|
|
|
1044
1197
|
return () => {
|
|
1045
1198
|
if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null }
|
|
1046
|
-
// 注意:watchdog
|
|
1199
|
+
// 注意:watchdog 进程是独立的,**故意不杀**(7×24 与多会话共用同一个实例)。
|
|
1200
|
+
// 因此同端口下的配置变更(含关闭 watchdogAutoSpawn)不会自动停掉或替换既有实例,
|
|
1201
|
+
// 需要显式执行 dsh-memory stop / restart(issue #19)。
|
|
1047
1202
|
}
|
|
1048
1203
|
}, 'memory-eternal: multi-host ensure')
|
|
1049
1204
|
|