memory-eternal 0.9.23 → 0.10.0

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.
@@ -1,126 +1,134 @@
1
- // 记忆核心 · 独立进程沉淀管线(MCP / CLI / hooks 共用)。
2
- //
3
- // 复刻 index.js runCapture 的判定链(预筛→配额→LLM 蒸馏→新建/追加→词法兜底),
4
- // 但 LLM 来源是 lib/llm-openai.js 的 OpenAI 兼容适配器而非 DSH llm 服务。
5
- // 未配置 LLM 时降级:跳过蒸馏,直接把原文写成一张卡(kind/tool 或调用方指定),
6
- // 保证「无 LLM 环境也能沉淀,只是不压缩」。
7
-
8
- import path from 'node:path'
9
- import os from 'node:os'
10
- import fs from 'node:fs'
11
- import { captureCard, captureUpdate, pickNeighbors, summarizeTurnDetailed, routeCandidates } from './capture.js'
12
- import { resolveVaultDir, defaultVaultRoot } from './vault-resolve.js'
13
- import { createLlmFromEnv } from './llm-openai.js'
14
-
15
- /**
16
- * 解析当前 vault 目录。
17
- *
18
- * 优先级必须与宿主 index.js 的 vaultDir() **完全一致**:
19
- * MEMORY_VAULT_DIR → activeVault 命中 vaultProfiles → vaultDir → 默认 ~/.dsh/memory-vault。
20
- * 旧实现只认 MEMORY_VAULT_DIR / 默认路径,于是用户在宿主里切库后,CLI / MCP / hooks /
21
- * 独立 web / sweep 仍然写默认库 —— 同一台机器上记忆被劈成两份(dsh-memory-eternal#5)。
22
- *
23
- * @param {NodeJS.ProcessEnv} [env]
24
- * @returns {string} 绝对路径
25
- */
26
- export function defaultVaultDir(env = process.env) {
27
- if (env.MEMORY_VAULT_DIR && env.MEMORY_VAULT_DIR.trim()) return path.resolve(env.MEMORY_VAULT_DIR.trim())
28
- try {
29
- const cfg = readConfig(env) || {}
30
- // 与宿主共用同一套解析(含 match.workspace 的按项目选库)
31
- return resolveVaultDir({
32
- env,
33
- profiles: cfg.vaultProfiles,
34
- activeVault: cfg.activeVault,
35
- configured: cfg.vaultDir,
36
- }).root
37
- } catch { /* 读不到共享配置就回落默认库 */ }
38
- return defaultVaultRoot(env)
39
- }
40
-
41
- // 读取 DSH 设置的完整配置(写入的共享文件),使独立 web / MCP hook 捕获与 DSH 设置同步。
42
- const CONFIG_FILE = 'memory-eternal-config.json'
43
- export function configFilePath(env = process.env) {
44
- return path.join(env.DSH_HOME || path.join(os.homedir(), '.dsh'), CONFIG_FILE)
45
- }
46
- function readConfig(env = process.env) {
47
- try {
48
- return JSON.parse(fs.readFileSync(configFilePath(env), 'utf8'))
49
- } catch { return null }
50
- }
51
- function splitList(x) { return (Array.isArray(x) ? x : String(x || '').split(',')).map((s) => String(s).trim()).filter(Boolean) }
52
-
53
- /**
54
- * 沉淀一段对话文本到 vault。
55
- * @returns {Promise<{ok:boolean, action:'created'|'appended'|'skipped'|'failed', path?:string, reason?:string, degraded?:boolean}>}
56
- */
57
- export async function runStandaloneCapture(vaultRoot, text, { source = '', llm, env = process.env, minChars = 120 } = {}) {
58
- const clean = String(text || '').trim()
59
- if (clean.length < minChars) return { ok: false, action: 'skipped', reason: 'text too short' }
60
-
61
- // 自动审核:共享配置文件(DSH)优先,其次 env,默认 all(独立进程无 DSH settings)
62
- const resolveAuditStatus = (kind, sub) => {
63
- const shared = readConfig(env)
64
- const mode = shared?.auditMode || env.MEMORY_AUDIT_MODE || 'all'
65
- const agents = shared?.auditExemptAgents ? splitList(shared.auditExemptAgents) : splitList(env.MEMORY_AUDIT_EXEMPT_AGENTS || '')
66
- const kinds = shared?.auditExemptKinds ? splitList(shared.auditExemptKinds) : splitList(env.MEMORY_AUDIT_EXEMPT_KINDS || '')
67
- if (mode === 'none') return 'approved'
68
- if (agents.includes('__all__') || agents.includes(sub)) return 'approved'
69
- if (kinds.includes('__all__') || kinds.includes(kind)) return 'approved'
70
- return 'pending'
71
- }
72
-
73
- const llmClient = llm ?? createLlmFromEnv(env)
74
- // 与宿主同样的候选路由:显式配置优先,其余按注册顺序兜底(别写死 providers[0],见 issue #3)
75
- const routes = llmClient ? await routeCandidates(llmClient, { provider: shared?.captureProvider, model: shared?.captureModel }) : []
76
- const route = routes[0] ?? null
77
-
78
- // 有 LLM:蒸馏判定链(与 DSH 侧一致)
79
- if (llmClient && route) {
80
- try {
81
- const neighbors = await pickNeighbors(vaultRoot, { title: '', body: clean.slice(0, 400) }, 8)
82
- const detailed = await summarizeTurnDetailed(llmClient, route, clean, { existing: neighbors, signal: AbortSignal.timeout(45000) })
83
- const result = detailed.card
84
- // 独立进程也必须把真实失败暴露出来(走 stderr),别再静默降级成「无输出」
85
- if (detailed.failure) {
86
- process.stderr.write(`[memory-eternal] 蒸馏失败(${route.provider}/${route.model}):${detailed.failure.code} ${detailed.failure.message}\n`)
87
- }
88
- if (result && result.save === true) {
89
- if (result.append_to) {
90
- await captureUpdate(vaultRoot, result.append_to, result.update, { threshold: 0.62 })
91
- return { ok: true, action: 'appended', path: result.append_to }
92
- }
93
- const card = { kind: result.kind, title: result.title, tags: result.tags, body: result.body, source, status: resolveAuditStatus(result.kind, source || 'agent'), submittedBy: source || 'agent', severity: 'info', reason: 'AI 自动沉淀(蒸馏卡)' }
94
- const out = await captureCard(vaultRoot, card, { threshold: 0.62 })
95
- if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel }
96
- if (out.duplicate) {
97
- await captureUpdate(vaultRoot, out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, { threshold: 0.62 })
98
- return { ok: true, action: 'appended', path: out.duplicate.path }
99
- }
100
- return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
101
- }
102
- if (result && result.save === false) return { ok: true, action: 'skipped', reason: 'model judged not durable' }
103
- } catch (error) {
104
- // LLM 失败 → 落入降级路径,不能让一次网络抖动丢掉沉淀
105
- }
106
- }
107
-
108
- // 降级:无 LLM(或 LLM 调用失败)→ 原文卡(kind 默认 content,dedup 开)
109
- const out = await captureCard(vaultRoot, {
110
- kind: 'content',
111
- title: clean.replace(/\s+/g, ' ').slice(0, 40) || '未命名记录',
112
- tags: ['raw'],
113
- body: clean,
114
- source: source || 'manual',
115
- status: resolveAuditStatus('content', source || 'agent'),
116
- submittedBy: source || 'agent',
117
- severity: 'info',
118
- reason: 'AI 自动沉淀(原文卡)',
119
- }, { threshold: 0.62 })
120
- if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel, degraded: true }
121
- if (out.duplicate) {
122
- await captureUpdate(vaultRoot, out.duplicate.path, clean.slice(0, 400), { threshold: 0.62 })
123
- return { ok: true, action: 'appended', path: out.duplicate.path, degraded: true }
124
- }
125
- return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
126
- }
1
+ // 记忆核心 · 独立进程沉淀管线(MCP / CLI / hooks 共用)。
2
+ //
3
+ // 复刻 index.js runCapture 的判定链(预筛→配额→LLM 蒸馏→新建/追加→词法兜底),
4
+ // 但 LLM 来源是 lib/llm-openai.js 的 OpenAI 兼容适配器而非 DSH llm 服务。
5
+ // 未配置 LLM 时降级:跳过蒸馏,直接把原文写成一张卡(kind/tool 或调用方指定),
6
+ // 保证「无 LLM 环境也能沉淀,只是不压缩」。
7
+
8
+ import path from 'node:path'
9
+ import os from 'node:os'
10
+ import fs from 'node:fs'
11
+ import { captureCard, captureUpdate, pickNeighbors, summarizeTurnDetailed, routeCandidates, stripRuntimeNoise, hasUsableContent, deriveTitle, isUsableTitle } from './capture.js'
12
+ import { resolveVaultDir, defaultVaultRoot } from './vault-resolve.js'
13
+ import { createLlmFromEnv } from './llm-openai.js'
14
+
15
+ /**
16
+ * 解析当前 vault 目录。
17
+ *
18
+ * 优先级必须与宿主 index.js 的 vaultDir() **完全一致**:
19
+ * MEMORY_VAULT_DIR → activeVault 命中 vaultProfiles → vaultDir → 默认 ~/.dsh/memory-vault。
20
+ * 旧实现只认 MEMORY_VAULT_DIR / 默认路径,于是用户在宿主里切库后,CLI / MCP / hooks /
21
+ * 独立 web / sweep 仍然写默认库 —— 同一台机器上记忆被劈成两份(dsh-memory-eternal#5)。
22
+ *
23
+ * @param {NodeJS.ProcessEnv} [env]
24
+ * @returns {string} 绝对路径
25
+ */
26
+ export function defaultVaultDir(env = process.env) {
27
+ if (env.MEMORY_VAULT_DIR && env.MEMORY_VAULT_DIR.trim()) return path.resolve(env.MEMORY_VAULT_DIR.trim())
28
+ try {
29
+ const cfg = readConfig(env) || {}
30
+ // 与宿主共用同一套解析(含 match.workspace 的按项目选库)
31
+ return resolveVaultDir({
32
+ env,
33
+ profiles: cfg.vaultProfiles,
34
+ activeVault: cfg.activeVault,
35
+ configured: cfg.vaultDir,
36
+ }).root
37
+ } catch { /* 读不到共享配置就回落默认库 */ }
38
+ return defaultVaultRoot(env)
39
+ }
40
+
41
+ // 读取 DSH 设置的完整配置(写入的共享文件),使独立 web / MCP hook 捕获与 DSH 设置同步。
42
+ const CONFIG_FILE = 'memory-eternal-config.json'
43
+ export function configFilePath(env = process.env) {
44
+ return path.join(env.DSH_HOME || path.join(os.homedir(), '.dsh'), CONFIG_FILE)
45
+ }
46
+ function readConfig(env = process.env) {
47
+ try {
48
+ return JSON.parse(fs.readFileSync(configFilePath(env), 'utf8'))
49
+ } catch { return null }
50
+ }
51
+ function splitList(x) { return (Array.isArray(x) ? x : String(x || '').split(',')).map((s) => String(s).trim()).filter(Boolean) }
52
+
53
+ /**
54
+ * 沉淀一段对话文本到 vault。
55
+ * @returns {Promise<{ok:boolean, action:'created'|'appended'|'skipped'|'failed', path?:string, reason?:string, degraded?:boolean}>}
56
+ */
57
+ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm, env = process.env, minChars = 120 } = {}) {
58
+ // 入料噪声闸门(P0):先剥运行时注入(环境快照 / team 广播 / teammate 原文 / 工具说明),
59
+ // 再判断是否还有真实内容。此处是 MCP memory_capture / CLI / hooks 的共同入口。
60
+ const clean = stripRuntimeNoise(text)
61
+ if (clean.length < minChars) return { ok: false, action: 'skipped', reason: 'text too short' }
62
+ if (!hasUsableContent(clean)) return { ok: false, action: 'skipped', reason: 'text is runtime noise' }
63
+ const title = deriveTitle(clean)
64
+ if (!isUsableTitle(title)) return { ok: false, action: 'skipped', reason: 'cannot derive a usable title' }
65
+
66
+ // 自动审核:共享配置文件(DSH)优先,其次 env,默认 all(独立进程无 DSH settings)
67
+ // 注:`shared` 原先在回调里局部读取,第 80 行的路由兜底却引用了外层同名变量 → ReferenceError
68
+ // 被下面的 try/catch 静默吞掉,导致 captureProvider/captureModel 在本进程从不生效。这里提到函数级。
69
+ const shared = readConfig(env)
70
+ const resolveAuditStatus = (kind, sub) => {
71
+ const mode = shared?.auditMode || env.MEMORY_AUDIT_MODE || 'all'
72
+ const agents = shared?.auditExemptAgents ? splitList(shared.auditExemptAgents) : splitList(env.MEMORY_AUDIT_EXEMPT_AGENTS || '')
73
+ const kinds = shared?.auditExemptKinds ? splitList(shared.auditExemptKinds) : splitList(env.MEMORY_AUDIT_EXEMPT_KINDS || '')
74
+ if (mode === 'none') return 'approved'
75
+ if (agents.includes('__all__') || agents.includes(sub)) return 'approved'
76
+ if (kinds.includes('__all__') || kinds.includes(kind)) return 'approved'
77
+ return 'pending'
78
+ }
79
+
80
+ const llmClient = llm ?? createLlmFromEnv(env)
81
+ // 与宿主同样的候选路由:显式配置优先,其余按注册顺序兜底(别写死 providers[0],见 issue #3)
82
+ const routes = llmClient ? await routeCandidates(llmClient, { provider: shared?.captureProvider, model: shared?.captureModel }) : []
83
+ const route = routes[0] ?? null
84
+
85
+ // 有 LLM:蒸馏判定链(与 DSH 侧一致)
86
+ if (llmClient && route) {
87
+ try {
88
+ const neighbors = await pickNeighbors(vaultRoot, { title: '', body: clean.slice(0, 400) }, 8)
89
+ const detailed = await summarizeTurnDetailed(llmClient, route, clean, { existing: neighbors, signal: AbortSignal.timeout(45000) })
90
+ const result = detailed.card
91
+ // 独立进程也必须把真实失败暴露出来(走 stderr),别再静默降级成「无输出」
92
+ if (detailed.failure) {
93
+ process.stderr.write(`[memory-eternal] 蒸馏失败(${route.provider}/${route.model}):${detailed.failure.code} ${detailed.failure.message}\n`)
94
+ }
95
+ if (result && result.save === true) {
96
+ if (result.append_to) {
97
+ await captureUpdate(vaultRoot, result.append_to, result.update, { threshold: 0.62 })
98
+ return { ok: true, action: 'appended', path: result.append_to }
99
+ }
100
+ const card = { kind: result.kind, title: result.title, tags: result.tags, body: result.body, source, status: resolveAuditStatus(result.kind, source || 'agent'), submittedBy: source || 'agent', severity: 'info', reason: 'AI 自动沉淀(蒸馏卡)' }
101
+ const out = await captureCard(vaultRoot, card, { threshold: 0.62 })
102
+ if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel }
103
+ if (out.duplicate) {
104
+ await captureUpdate(vaultRoot, out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, { threshold: 0.62 })
105
+ return { ok: true, action: 'appended', path: out.duplicate.path }
106
+ }
107
+ return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
108
+ }
109
+ if (result && result.save === false) return { ok: true, action: 'skipped', reason: 'model judged not durable' }
110
+ } catch (error) {
111
+ // LLM 失败 → 落入降级路径,不能让一次网络抖动丢掉沉淀
112
+ }
113
+ }
114
+
115
+ // 降级:无 LLM(或 LLM 调用失败)→ 原文卡(kind 默认 content,dedup 开)
116
+ // 标题用 deriveTitle 派生(不再 slice 硬切);正文用剥离噪声后的 clean。
117
+ const out = await captureCard(vaultRoot, {
118
+ kind: 'content',
119
+ title,
120
+ tags: ['raw'],
121
+ body: clean,
122
+ source: source || 'manual',
123
+ status: resolveAuditStatus('content', source || 'agent'),
124
+ submittedBy: source || 'agent',
125
+ severity: 'info',
126
+ reason: 'AI 自动沉淀(原文卡)',
127
+ }, { threshold: 0.62 })
128
+ if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel, degraded: true }
129
+ if (out.duplicate) {
130
+ await captureUpdate(vaultRoot, out.duplicate.path, clean.slice(0, 400), { threshold: 0.62 })
131
+ return { ok: true, action: 'appended', path: out.duplicate.path, degraded: true }
132
+ }
133
+ return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
134
+ }
package/lib/capture.js CHANGED
@@ -22,7 +22,99 @@ export const DEDUP_NEIGHBORS = 8
22
22
  // 沉淀预筛:仅当对话含「可复用」信号时才调用 LLM,避免对纯闲聊/寒暄发起昂贵的 LLM 判定。
23
23
  const DURABLE_SIGNALS = /(#{1,3}\s|```|\[\[|\]|\(https?:|\.(md|ts|js|py|json|yml|yaml|sh|sql)\b|`|\b\d{2,}\b|选择|决定|采用|方案|结论|原因|根因|问题|解决|修复|报错|错误|教训|架构|设计|配置|部署|流程|步骤|算法|原理|函数|接口|参数|命令|依赖|版本|坑|踩|优化|性能|数据库|索引|模型|token|推荐|偏好|约定|规范)/
24
24
 
25
- /** 判断一段对话是否值得送 LLM 判定(省 token 的启发式预筛)。 */
25
+ // -- 入料噪声闸门(P0 修复)--------------------------------------------------
26
+ //
27
+ // 背景:turn-stopping 抓的是「本会话新增的 user/assistant 消息」,而 DSH 会把
28
+ // 大量**运行时注入**伪装成 user 消息塞进会话:环境快照、team 广播、teammate 原文、
29
+ // 工具使用说明(`# create-hook`)、文件片段("g libs.")。旧实现全部当正文喂给模型,
30
+ // 于是待审队列里出现「用户:<system-reminder> You are teammate」这类垃圾卡。
31
+ // 这里只做**机械剥离**,不做价值判断(价值判断仍归模型 + 审核中心)。
32
+
33
+ /** 运行时注入的段落标记:成对出现,整段剥掉。 */
34
+ const NOISE_BLOCKS = [
35
+ /<local-command-caveat>[\s\S]*?<\/local-command-caveat>/gi,
36
+ /<local-command-stdout>[\s\S]*?<\/local-command-stdout>/gi,
37
+ /<system-reminder>[\s\S]*?<\/system-reminder>/gi,
38
+ /<team-[a-z-]+>[\s\S]*?<\/team-[a-z-]+>/gi,
39
+ /<teammate[^>]*>[\s\S]*?<\/teammate>/gi,
40
+ /<agent-message[^>]*>[\s\S]*?<\/agent-message>/gi,
41
+ /<user-prompt-submit-hook>[\s\S]*?<\/user-prompt-submit-hook>/gi,
42
+ /<command-[a-z-]+>[\s\S]*?<\/command-[a-z-]+>/gi,
43
+ /\[Tool use: [^\]]*\]/gi,
44
+ ]
45
+ /** 运行时注入的行前缀:整行丢掉。 */
46
+ const NOISE_LINE = /^\s*(相关上下文|Current runtime context|Runtime context|Approval prompts are disabled|DSH file policy|Current DSH file policy|You are a teammate|Team Lead|Teammate |Subagent |Note: the user|系统提示|环境快照)[::]?/i
47
+ /** 工具/命令使用说明的标题行(`# create-hook`、`## `codex create-hook``)。 */
48
+ const TOOL_DOC_HEADING = /^\s*#{1,4}\s*`?[a-z][a-z0-9-]{2,40}`?\s*$/i
49
+
50
+ /** 段落级剥离:去掉注入块、注入行、工具说明标题行,并在相邻行间插入换行(保持行结构)。 */
51
+ export function stripRuntimeNoise(text) {
52
+ let out = String(text || '')
53
+ // 注入块整段删除(不留新行:相邻两行必须重新贴合,否则会粘出「A用户:B」这种错位文本)
54
+ for (const re of NOISE_BLOCKS) out = out.replace(re, '')
55
+ const kept = []
56
+ for (const line of out.split('\n')) {
57
+ if (NOISE_LINE.test(line) || TOOL_DOC_HEADING.test(line)) continue
58
+ kept.push(line)
59
+ }
60
+ return kept.join('\n').trim()
61
+ }
62
+
63
+ /**
64
+ * 剥掉运行时注入块之后是否**什么都不剩**(最可靠的注入特征:整段就是被标签包起来的注入)。
65
+ * `[\s\S]*?` 非贪婪匹配,长注入块也能正确剥掉;旧的 `hasUsableContent` 用「≥120 字」近似,
66
+ * 对长度超过 120 的注入块会失效(例如整段 teammate 原文),这里直接按「剥完是否为空」判定。
67
+ */
68
+ function isPureInjection(text) {
69
+ let out = String(text || '')
70
+ for (const re of NOISE_BLOCKS) out = out.replace(re, '')
71
+ return out.replace(/[\s\-*_`]+/g, '').length === 0
72
+ }
73
+
74
+ /**
75
+ * 判定「这段文本像不像被截断的片段」——用于挡住碎片卡。
76
+ * 现象来源:DSH 注入的上下文块被从中间切开,得到 `` chema-form` 当 external `` 这种标题。
77
+ * 保守起见只在**明确可疑**时返回 true:以标点/反引号开头,或以 `、-`、`(`、`/` 等
78
+ * 明显未完结字符结尾。
79
+ */
80
+ export function looksTruncated(text) {
81
+ const t = String(text || '').trim()
82
+ if (!t) return true
83
+ if (/^[`'"))】」》.,,。;;::\-–—/\\|>]/.test(t)) return true
84
+ if (/[((【「《,,、::;;\-–—/\\|]$/.test(t)) return true
85
+ return false
86
+ }
87
+
88
+ /** 过滤掉碎片行与注入行:抽标题 / 摘要前先用一次。 */
89
+ function dropFragmentLines(text) {
90
+ return String(text || '')
91
+ .split('\n')
92
+ .map((l) => l.trim())
93
+ .filter((l) => l && !looksTruncated(l))
94
+ .join('\n')
95
+ }
96
+
97
+ /**
98
+ * 剥离噪声后的正文是否还够「像一次真实对话」。
99
+ *
100
+ * 为什么不用固定 120 字门槛:真实对话常常是短行列表(每行几十字),按「逐行丢碎片 +
101
+ * 总量 ≥120」会**误杀**这类正常记录(tests/capture-route.test.mjs 的 189 字短行会话就被
102
+ * 误判过)。这里改成「事实量 + 噪声占比」双判据:
103
+ * 1. 剥完至少还剩 60 字事实内容(挡住纯注入 / 极短寒暄);
104
+ * 2. 剥掉的部分不能超过原文的 85%(挡住「主体都是注入、只带一句人话」的轮次)。
105
+ * 真实内容被误伤的成本远高于偶尔放进一条噪声(噪声还有审核中心兜底)。
106
+ */
107
+ export function hasUsableContent(text) {
108
+ const raw = String(text || '').trim()
109
+ if (raw.length < 60) return false
110
+ if (isPureInjection(raw)) return false // 整段都是注入块(无论多长)一律不算内容
111
+ const stripped = stripRuntimeNoise(raw)
112
+ if (stripped.length < 60) return false
113
+ if (raw.length > 120 && stripped.length / raw.length < 0.15) return false
114
+ return true
115
+ }
116
+
117
+ /** 判断一段(已剥离噪声的)对话是否值得交 LLM 判定(省 token 的启发式预筛)。 */
26
118
  export function looksDurable(text) {
27
119
  const t = String(text || '')
28
120
  if (t.trim().length < 120) return false
@@ -32,6 +124,42 @@ export function looksDurable(text) {
32
124
  return items >= 2 && t.trim().length >= 160
33
125
  }
34
126
 
127
+ /**
128
+ * 从正文里取一个「像标题」的片段(降级原文卡 / 无 LLM 场景用)。
129
+ *
130
+ * 旧实现是 `text.slice(0, 40)`:一句话被硬切,于是出现 `` chema-form` 当 external ``
131
+ * 这种从半截开始的标题,审核中心根本没法判断(P0 修复)。这里:
132
+ * 1. 取第一行非碎片文本;2. 去掉 `用户:` / `助手:` 说话人前缀与 markdown 记号;
133
+ * 3. 优先在首个句读(。!?;!?;)处断句;4. 仍以 `、和/` 这类连接性字符结尾就再截一点。
134
+ */
135
+ export function deriveTitle(text, maxLen = 60) {
136
+ const lines = dropFragmentLines(text).split('\n')
137
+ const firstLine = lines[0] || String(text || '').trim()
138
+ let t = cleanTitleLine(firstLine)
139
+ // 首行是 "# T" 这种过短的装饰性标题时,往后找第一句真正像标题的话(上限 5 行,避免扫全文)。
140
+ if (!isUsableTitle(t)) {
141
+ for (const line of lines.slice(1, 6)) {
142
+ const cand = cleanTitleLine(line)
143
+ if (isUsableTitle(cand)) { t = cand; break }
144
+ }
145
+ }
146
+ if (!t) return ''
147
+ const cut = t.search(/[。!?;!?;]/)
148
+ if (cut >= 4) t = t.slice(0, cut)
149
+ if (t.length > maxLen) t = t.slice(0, maxLen)
150
+ t = t.replace(/[、,,和与\/\\\-–—::]+$/, '').trim()
151
+ return t
152
+ }
153
+
154
+ /** 单行清洗:去说话人前缀、markdown 记号、压缩空白。 */
155
+ function cleanTitleLine(line) {
156
+ return String(line || '')
157
+ .replace(/^(用户|助手|user|assistant)\s*[::]\s*/i, '')
158
+ .replace(/^[#>\-*\s]+/, '')
159
+ .replace(/\s+/g, ' ')
160
+ .trim()
161
+ }
162
+
35
163
  /**
36
164
  * 用当前模型把一段对话压缩成知识卡(或判定不值得保存 / 应追加到已有卡)。
37
165
  * @param llm - dsh 的 llm 服务(ctx.get('llm'))
@@ -57,6 +185,7 @@ export async function summarizeTurn(llm, route, conversation, opts = {}) {
57
185
  export async function summarizeTurnDetailed(llm, route, conversation, opts = {}) {
58
186
  const text = String(conversation || '').trim()
59
187
  if (text.length < 120) return { skip: 'too-short' } // 太短,没有沉淀价值
188
+ if (!hasUsableContent(text)) return { skip: 'noisy' } // 剥离注入噪声后没剩多少真实内容(P0 噪声闸门)
60
189
  if (!looksDurable(text)) return { skip: 'not-durable' } // 启发式预筛:无可复用信号则跳过 LLM 判定(省 token)
61
190
 
62
191
  const existing = Array.isArray(opts.existing) ? opts.existing.slice(0, DEDUP_NEIGHBORS) : []
@@ -215,6 +344,26 @@ export function parseCaptureJson(text) {
215
344
  const title = String(parsed.title || '').trim().slice(0, 80)
216
345
  const body = String(parsed.body || '').trim()
217
346
  if (!title || body.length < 20) return null
347
+ // 标题体检(P0 噪声闸门):标题必须是「像标题」的一句话(去掉标点后 ≥4 字、不以标点/反引号开头、
348
+ // 不被截断)。糊掉的标题会让审核中心无法判断,也会在召回结果里变成噪声。
349
+ // 但**不放行垃圾标题、也不吞掉正文**:标题不合格时从正文首行派生一个,派生不出来才算无效。
350
+ if (!isUsableTitle(title)) {
351
+ const derived = deriveTitle(body)
352
+ if (!isUsableTitle(derived)) return null
353
+ return finishCard(parsed, derived, body)
354
+ }
355
+ return finishCard(parsed, title, body)
356
+ }
357
+
358
+ /** 标题是否「像标题」:≥4 个字母/数字字符,且不是碎片(错位开头 / 未完结结尾)。 */
359
+ export function isUsableTitle(title) {
360
+ const t = String(title || '').trim()
361
+ if (!t || looksTruncated(t)) return false
362
+ return t.replace(/[^\p{L}\p{N}]/gu, '').length >= 4
363
+ }
364
+
365
+ /** 组装最终卡片对象(标题已体检通过)。 */
366
+ function finishCard(parsed, title, body) {
218
367
  const kind = ['project', 'knowledge', 'content', 'prompt', 'business', 'tool', 'mistake'].includes(parsed.kind) ? parsed.kind : 'knowledge'
219
368
  const tags = Array.isArray(parsed.tags)
220
369
  ? parsed.tags.map((t) => String(t).trim().slice(0, 30)).filter(Boolean).slice(0, 8)
@@ -222,20 +371,30 @@ export function parseCaptureJson(text) {
222
371
  return { save: true, title, kind, tags, body }
223
372
  }
224
373
 
225
- /** 从会话事件里提取「增量片段」的可读对话文本(用户 + 助手)。 */
374
+ /**
375
+ * 从会话事件里提取「增量片段」的可读对话文本(用户 + 助手)。
376
+ *
377
+ * 每条消息先过 `stripRuntimeNoise`:DSH 注入的环境快照 / team 广播 / teammate 原文 /
378
+ * 工具说明都不是用户的真实表达,混进来会变成垃圾卡(P0 修复)。剥离后为空的消息直接丢。
379
+ */
226
380
  export function extractLastTurn(events, _turn) {
227
381
  const lines = []
382
+ const push = (role, raw) => {
383
+ const text = stripRuntimeNoise(raw)
384
+ if (!text) return
385
+ lines.push(`${role}:${text}`)
386
+ }
228
387
  const pushMessage = (event) => {
229
388
  const data = event.data || event
230
389
  const message = data.message || data
231
- const role = message.role
390
+ const role = message.role === 'user' ? '用户' : '助手'
232
391
  const content = message.content
233
392
  if (Array.isArray(content)) {
234
393
  for (const block of content) {
235
- if (block.type === 'text' && block.text) lines.push(`${role === 'user' ? '用户' : '助手'}:${block.text}`)
394
+ if (block.type === 'text' && block.text) push(role, block.text)
236
395
  }
237
396
  } else if (typeof content === 'string' && content) {
238
- lines.push(`${role === 'user' ? '用户' : '助手'}:${content}`)
397
+ push(role, content)
239
398
  }
240
399
  }
241
400
  for (const event of events) {
@@ -247,10 +406,10 @@ export function extractLastTurn(events, _turn) {
247
406
  if (type === 'assistant/message') pushMessage(data)
248
407
  else if (data.chunk?.content) {
249
408
  const content = data.chunk.content
250
- if (typeof content === 'string') lines.push(`助手:${content}`)
409
+ if (typeof content === 'string') push('助手', content)
251
410
  else if (Array.isArray(content)) {
252
411
  for (const block of content) {
253
- if (block.type === 'text' && block.text) lines.push(`助手:${block.text}`)
412
+ if (block.type === 'text' && block.text) push('助手', block.text)
254
413
  }
255
414
  }
256
415
  }