memory-eternal 0.10.0 → 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/lib/sse.js CHANGED
@@ -1,51 +1,51 @@
1
- // SSE 推送中心:把「配置/卡片变更」即时推给所有已打开的页面(不必手动刷新)。
2
- // 两个宿主(DSH 内嵌 + 独立 web)共用同一套实现;纯逻辑,便于单测(注入假 res)。
3
- export function createHub({ heartbeatMs = 25000 } = {}) {
4
- const clients = new Set()
5
- let timer = null
6
- const send = (res, chunk) => {
7
- try { res.write(chunk); return true } catch { return false }
8
- }
9
- const drop = (res) => {
10
- clients.delete(res)
11
- try { res.end() } catch { /* 已断开 */ }
12
- }
13
- const ensureTimer = () => {
14
- if (timer || !(heartbeatMs > 0)) return
15
- timer = setInterval(() => {
16
- for (const res of [...clients]) if (!send(res, ': ping\n\n')) drop(res)
17
- }, heartbeatMs)
18
- if (typeof timer.unref === 'function') timer.unref()
19
- }
20
- return {
21
- /** 接入一个连接(保持长连接,不要 end)。 */
22
- add(res) {
23
- try {
24
- res.writeHead(200, {
25
- 'Content-Type': 'text/event-stream; charset=utf-8',
26
- 'Cache-Control': 'no-store',
27
- Connection: 'keep-alive',
28
- 'X-Accel-Buffering': 'no',
29
- })
30
- } catch { return false }
31
- if (!send(res, 'retry: 3000\n\n')) return false
32
- clients.add(res)
33
- ensureTimer()
34
- const onClose = () => drop(res)
35
- try { res.on('close', onClose); res.on('error', onClose) } catch { /* 某些响应对象没有 on */ }
36
- return true
37
- },
38
- /** 广播事件(event: <name>,data: JSON)。返回当前连接数。 */
39
- broadcast(event, data) {
40
- const chunk = 'event: ' + event + '\ndata: ' + JSON.stringify(data === undefined ? {} : data) + '\n\n'
41
- for (const res of [...clients]) if (!send(res, chunk)) drop(res)
42
- return clients.size
43
- },
44
- get size() { return clients.size },
45
- close() {
46
- if (timer) clearInterval(timer)
47
- timer = null
48
- for (const res of [...clients]) drop(res)
49
- },
50
- }
51
- }
1
+ // SSE 推送中心:把「配置/卡片变更」即时推给所有已打开的页面(不必手动刷新)。
2
+ // 两个宿主(DSH 内嵌 + 独立 web)共用同一套实现;纯逻辑,便于单测(注入假 res)。
3
+ export function createHub({ heartbeatMs = 25000 } = {}) {
4
+ const clients = new Set()
5
+ let timer = null
6
+ const send = (res, chunk) => {
7
+ try { res.write(chunk); return true } catch { return false }
8
+ }
9
+ const drop = (res) => {
10
+ clients.delete(res)
11
+ try { res.end() } catch { /* 已断开 */ }
12
+ }
13
+ const ensureTimer = () => {
14
+ if (timer || !(heartbeatMs > 0)) return
15
+ timer = setInterval(() => {
16
+ for (const res of [...clients]) if (!send(res, ': ping\n\n')) drop(res)
17
+ }, heartbeatMs)
18
+ if (typeof timer.unref === 'function') timer.unref()
19
+ }
20
+ return {
21
+ /** 接入一个连接(保持长连接,不要 end)。 */
22
+ add(res) {
23
+ try {
24
+ res.writeHead(200, {
25
+ 'Content-Type': 'text/event-stream; charset=utf-8',
26
+ 'Cache-Control': 'no-store',
27
+ Connection: 'keep-alive',
28
+ 'X-Accel-Buffering': 'no',
29
+ })
30
+ } catch { return false }
31
+ if (!send(res, 'retry: 3000\n\n')) return false
32
+ clients.add(res)
33
+ ensureTimer()
34
+ const onClose = () => drop(res)
35
+ try { res.on('close', onClose); res.on('error', onClose) } catch { /* 某些响应对象没有 on */ }
36
+ return true
37
+ },
38
+ /** 广播事件(event: <name>,data: JSON)。返回当前连接数。 */
39
+ broadcast(event, data) {
40
+ const chunk = 'event: ' + event + '\ndata: ' + JSON.stringify(data === undefined ? {} : data) + '\n\n'
41
+ for (const res of [...clients]) if (!send(res, chunk)) drop(res)
42
+ return clients.size
43
+ },
44
+ get size() { return clients.size },
45
+ close() {
46
+ if (timer) clearInterval(timer)
47
+ timer = null
48
+ for (const res of [...clients]) drop(res)
49
+ },
50
+ }
51
+ }
package/lib/stall.js CHANGED
@@ -1,30 +1,30 @@
1
- // 记忆核心 · 「轮次收尾事件是否真的没来」判定
2
- //
3
- // 旧判据:turnsStarted > turnsStopped && claimIdleMs > 15min && idleMs > 15min。两个毛病:
4
- // 1) 两个计数**累积且永不归零**,而被取消/被拒的轮次不发 turn-stopping(子代理轮次同样不计)
5
- // → 差额一旦出现就永久为真,判据退化成「只要空闲 15 分钟就报一次」(issue #4 的
6
- // 「已开始 16 / 已收尾 12,且 15 分钟无任何沉淀活动」就是这么来的);
7
- // 2) 真故障的特征恰好相反:轮次**持续开始**、只是没有收尾 —— 此时 claimed 一直新鲜,
8
- // claimIdleMs 条件不满足 → 真故障反而不报。
9
- //
10
- // 现在改成滑动窗口:只看「窗口内开始了几个、收尾了几个」。
11
- // 持续开始 + 零收尾 → 判停滞;用户离开(窗口内 0 开始)→ 不报。
12
-
13
- /**
14
- * @param {object} input
15
- * @param {number[]} input.claimedAt 轮次开始时间戳(ms)
16
- * @param {number[]} input.stoppedAt 轮次收尾时间戳(ms)
17
- * @param {number} [input.now] 当前时间
18
- * @param {number} [input.windowMs] 观察窗口,默认 20 分钟
19
- * @param {number} [input.minStarted] 窗口内至少开始几个轮次才判停滞,默认 3
20
- * @returns {{alert:boolean, started:number, stopped:number, windowMs:number}}
21
- */
22
- export function evaluateStall({ claimedAt = [], stoppedAt = [], now = Date.now(), windowMs = 20 * 60 * 1000, minStarted = 3 } = {}) {
23
- const from = now - windowMs
24
- const started = claimedAt.filter((t) => t >= from).length
25
- const stopped = stoppedAt.filter((t) => t >= from).length
26
- return { alert: started >= minStarted && stopped === 0, started, stopped, windowMs }
27
- }
28
-
29
- /** 时间戳数组的裁剪上限:只保留窗口相关的近邻,避免长跑无界增长。 */
30
- export const MAX_STAMPS = 200
1
+ // 记忆核心 · 「轮次收尾事件是否真的没来」判定
2
+ //
3
+ // 旧判据:turnsStarted > turnsStopped && claimIdleMs > 15min && idleMs > 15min。两个毛病:
4
+ // 1) 两个计数**累积且永不归零**,而被取消/被拒的轮次不发 turn-stopping(子代理轮次同样不计)
5
+ // → 差额一旦出现就永久为真,判据退化成「只要空闲 15 分钟就报一次」(issue #4 的
6
+ // 「已开始 16 / 已收尾 12,且 15 分钟无任何沉淀活动」就是这么来的);
7
+ // 2) 真故障的特征恰好相反:轮次**持续开始**、只是没有收尾 —— 此时 claimed 一直新鲜,
8
+ // claimIdleMs 条件不满足 → 真故障反而不报。
9
+ //
10
+ // 现在改成滑动窗口:只看「窗口内开始了几个、收尾了几个」。
11
+ // 持续开始 + 零收尾 → 判停滞;用户离开(窗口内 0 开始)→ 不报。
12
+
13
+ /**
14
+ * @param {object} input
15
+ * @param {number[]} input.claimedAt 轮次开始时间戳(ms)
16
+ * @param {number[]} input.stoppedAt 轮次收尾时间戳(ms)
17
+ * @param {number} [input.now] 当前时间
18
+ * @param {number} [input.windowMs] 观察窗口,默认 20 分钟
19
+ * @param {number} [input.minStarted] 窗口内至少开始几个轮次才判停滞,默认 3
20
+ * @returns {{alert:boolean, started:number, stopped:number, windowMs:number}}
21
+ */
22
+ export function evaluateStall({ claimedAt = [], stoppedAt = [], now = Date.now(), windowMs = 20 * 60 * 1000, minStarted = 3 } = {}) {
23
+ const from = now - windowMs
24
+ const started = claimedAt.filter((t) => t >= from).length
25
+ const stopped = stoppedAt.filter((t) => t >= from).length
26
+ return { alert: started >= minStarted && stopped === 0, started, stopped, windowMs }
27
+ }
28
+
29
+ /** 时间戳数组的裁剪上限:只保留窗口相关的近邻,避免长跑无界增长。 */
30
+ export const MAX_STAMPS = 200
package/lib/sweep.js CHANGED
@@ -1,56 +1,56 @@
1
- // 记忆核心 · 会话记录挖掘(简版 sweep)。
2
- //
3
- // 扫描目录下的 Claude Code 会话 JSONL(*.jsonl),每文件提取对话文本,
4
- // 按文件粒度蒸馏入库(source 带 sweep 标记;重复跑幂等靠 vault 词法去重)。
5
- // 适用:Codex sessions 目录 / Claude Code projects 目录 / 任意兼容 JSONL。
6
-
7
- import { promises as fs } from 'node:fs'
8
- import path from 'node:path'
9
- import { runStandaloneCapture, defaultVaultDir } from './capture-run.js'
10
-
11
- async function* walk(dir) {
12
- let entries
13
- try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
14
- for (const e of entries) {
15
- const p = path.join(dir, e.name)
16
- if (e.isDirectory()) yield* walk(p)
17
- else if (e.name.endsWith('.jsonl')) yield p
18
- }
19
- }
20
-
21
- function extractConversation(jsonlText) {
22
- const texts = []
23
- for (const line of jsonlText.split('\n')) {
24
- if (!line.trim()) continue
25
- let rec
26
- try { rec = JSON.parse(line) } catch { continue }
27
- const msg = rec?.message
28
- if (!msg || !msg.role) continue
29
- let text = ''
30
- if (typeof msg.content === 'string') text = msg.content
31
- else if (Array.isArray(msg.content)) {
32
- text = msg.content.filter((c) => c?.type === 'text').map((c) => c.text).join('\n')
33
- }
34
- if (text && text.trim().length > 4) texts.push(`${msg.role}: ${text.trim()}`)
35
- }
36
- return texts.slice(-60).join('\n\n')
37
- }
38
-
39
- export async function sweepSessions(dir, { vaultRoot = defaultVaultDir(), limit = 50 } = {}) {
40
- const root = path.resolve(dir)
41
- let scanned = 0
42
- let captured = 0
43
- let skipped = 0
44
- for await (const file of walk(root)) {
45
- if (scanned >= limit) break
46
- scanned++
47
- try {
48
- const text = extractConversation(await fs.readFile(file, 'utf8'))
49
- if (text.trim().length < 200) { skipped++; continue }
50
- const out = await runStandaloneCapture(vaultRoot, text, { source: `sweep:${path.basename(file)}` })
51
- if (out.ok && out.action !== 'skipped') captured++
52
- else skipped++
53
- } catch { skipped++ }
54
- }
55
- return { ok: true, dir: root, scanned, captured, skipped }
56
- }
1
+ // 记忆核心 · 会话记录挖掘(简版 sweep)。
2
+ //
3
+ // 扫描目录下的 Claude Code 会话 JSONL(*.jsonl),每文件提取对话文本,
4
+ // 按文件粒度蒸馏入库(source 带 sweep 标记;重复跑幂等靠 vault 词法去重)。
5
+ // 适用:Codex sessions 目录 / Claude Code projects 目录 / 任意兼容 JSONL。
6
+
7
+ import { promises as fs } from 'node:fs'
8
+ import path from 'node:path'
9
+ import { runStandaloneCapture, defaultVaultDir } from './capture-run.js'
10
+
11
+ async function* walk(dir) {
12
+ let entries
13
+ try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
14
+ for (const e of entries) {
15
+ const p = path.join(dir, e.name)
16
+ if (e.isDirectory()) yield* walk(p)
17
+ else if (e.name.endsWith('.jsonl')) yield p
18
+ }
19
+ }
20
+
21
+ function extractConversation(jsonlText) {
22
+ const texts = []
23
+ for (const line of jsonlText.split('\n')) {
24
+ if (!line.trim()) continue
25
+ let rec
26
+ try { rec = JSON.parse(line) } catch { continue }
27
+ const msg = rec?.message
28
+ if (!msg || !msg.role) continue
29
+ let text = ''
30
+ if (typeof msg.content === 'string') text = msg.content
31
+ else if (Array.isArray(msg.content)) {
32
+ text = msg.content.filter((c) => c?.type === 'text').map((c) => c.text).join('\n')
33
+ }
34
+ if (text && text.trim().length > 4) texts.push(`${msg.role}: ${text.trim()}`)
35
+ }
36
+ return texts.slice(-60).join('\n\n')
37
+ }
38
+
39
+ export async function sweepSessions(dir, { vaultRoot = defaultVaultDir(), limit = 50 } = {}) {
40
+ const root = path.resolve(dir)
41
+ let scanned = 0
42
+ let captured = 0
43
+ let skipped = 0
44
+ for await (const file of walk(root)) {
45
+ if (scanned >= limit) break
46
+ scanned++
47
+ try {
48
+ const text = extractConversation(await fs.readFile(file, 'utf8'))
49
+ if (text.trim().length < 200) { skipped++; continue }
50
+ const out = await runStandaloneCapture(vaultRoot, text, { source: `sweep:${path.basename(file)}` })
51
+ if (out.ok && out.action !== 'skipped') captured++
52
+ else skipped++
53
+ } catch { skipped++ }
54
+ }
55
+ return { ok: true, dir: root, scanned, captured, skipped }
56
+ }
@@ -1,84 +1,84 @@
1
- // 记忆核心 · vault 目录解析(宿主与所有独立进程共用同一套优先级)
2
- //
3
- // 为什么单独成模块:以前宿主 index.js 认 activeVault,而 CLI / MCP / hooks /
4
- // 独立 web / sweep 只认 MEMORY_VAULT_DIR 与默认库 —— 用户切库后终端与 hooks 仍写
5
- // 默认库,同一台机器上的记忆被劈成两份(dsh-memory-eternal#5)。解析逻辑必须只有一份。
6
- //
7
- // 优先级:
8
- // 1. MEMORY_VAULT_DIR (临时切库 / 宿主 spawn 子进程时显式传入)
9
- // 2. activeVault 命中 vaultProfiles[].name
10
- // 3. vaultProfiles[].match.workspace 命中当前 workspace(最长前缀优先,opt-in)
11
- // 4. vaultDir(单库配置)
12
- // 5. 默认 ~/.dsh/memory-vault
13
-
14
- import path from 'node:path'
15
- import os from 'node:os'
16
-
17
- /** 取当前 workspace(用于 match.workspace 的按项目选库):MEMORY_WORKSPACE 优先,其次进程 cwd。 */
18
- export function currentWorkspace(env = process.env) {
19
- const explicit = String((env && env.MEMORY_WORKSPACE) || '').trim()
20
- if (explicit) return explicit
21
- try { return process.cwd() } catch { return '' }
22
- }
23
-
24
- /** 默认库目录。 */
25
- export function defaultVaultRoot(env = process.env) {
26
- const home = (env && env.DSH_HOME) || path.join(os.homedir(), '.dsh')
27
- return path.join(home, 'memory-vault')
28
- }
29
-
30
- /** 归一化:反斜杠转正斜杠、去尾斜杠、统一小写(Windows 路径大小写不敏感)。 */
31
- const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
32
-
33
- /**
34
- * 找出 match.workspace 命中当前 workspace 的 profile。
35
- * 双向前缀匹配(workspace 落在规则目录下,或规则目录落在 workspace 下),最长规则优先。
36
- * @returns {object|null}
37
- */
38
- export function matchWorkspaceProfile(profiles, workspace) {
39
- const ws = norm(workspace)
40
- if (!ws) return null
41
- let best = null
42
- let bestLen = -1
43
- for (const p of Array.isArray(profiles) ? profiles : []) {
44
- const rule = p && p.match && p.match.workspace
45
- const target = norm(rule)
46
- if (!target) continue
47
- // 规则没配可用目录 → 不能作为记忆库,直接跳过(与 resolveVaultDir 的判定保持一致)
48
- if (!p.path || !String(p.path).trim()) continue
49
- if (!(ws === target || ws.startsWith(target + '/') || target.startsWith(ws + '/'))) continue
50
- if (target.length > bestLen) { best = p; bestLen = target.length }
51
- }
52
- return best
53
- }
54
-
55
- /**
56
- * 解析当前 vault 目录。
57
- * @param {object} input
58
- * @param {NodeJS.ProcessEnv} [input.env]
59
- * @param {Array<{name?:string,path?:string,match?:{workspace?:string}}>} [input.profiles]
60
- * @param {string} [input.activeVault]
61
- * @param {string} [input.configured] vaultDir 单库配置
62
- * @param {string} [input.workspace] 显式指定 workspace(默认 currentWorkspace)
63
- * @returns {{root:string, source:'env'|'profile'|'workspace'|'vaultDir'|'default', name:string, workspace:string}}
64
- */
65
- export function resolveVaultDir({ env = process.env, profiles = [], activeVault = '', configured = '', workspace } = {}) {
66
- const ws = workspace === undefined ? currentWorkspace(env) : String(workspace || '')
67
- const envDir = String((env && env.MEMORY_VAULT_DIR) || '').trim()
68
- if (envDir) return { root: path.resolve(envDir), source: 'env', name: '', workspace: ws }
69
-
70
- const list = Array.isArray(profiles) ? profiles : []
71
- const active = String(activeVault || '').trim()
72
- if (active) {
73
- const hit = list.find((p) => p && String(p.name || '') === active && p.path && String(p.path).trim())
74
- if (hit) return { root: path.resolve(String(hit.path).trim()), source: 'profile', name: active, workspace: ws }
75
- }
76
- const byWs = matchWorkspaceProfile(list, ws)
77
- if (byWs && byWs.path && String(byWs.path).trim()) {
78
- return { root: path.resolve(String(byWs.path).trim()), source: 'workspace', name: String(byWs.name || ''), workspace: ws }
79
- }
80
- if (configured && String(configured).trim()) {
81
- return { root: path.resolve(String(configured).trim()), source: 'vaultDir', name: '', workspace: ws }
82
- }
83
- return { root: defaultVaultRoot(env), source: 'default', name: '', workspace: ws }
84
- }
1
+ // 记忆核心 · vault 目录解析(宿主与所有独立进程共用同一套优先级)
2
+ //
3
+ // 为什么单独成模块:以前宿主 index.js 认 activeVault,而 CLI / MCP / hooks /
4
+ // 独立 web / sweep 只认 MEMORY_VAULT_DIR 与默认库 —— 用户切库后终端与 hooks 仍写
5
+ // 默认库,同一台机器上的记忆被劈成两份(dsh-memory-eternal#5)。解析逻辑必须只有一份。
6
+ //
7
+ // 优先级:
8
+ // 1. MEMORY_VAULT_DIR (临时切库 / 宿主 spawn 子进程时显式传入)
9
+ // 2. activeVault 命中 vaultProfiles[].name
10
+ // 3. vaultProfiles[].match.workspace 命中当前 workspace(最长前缀优先,opt-in)
11
+ // 4. vaultDir(单库配置)
12
+ // 5. 默认 ~/.dsh/memory-vault
13
+
14
+ import path from 'node:path'
15
+ import os from 'node:os'
16
+
17
+ /** 取当前 workspace(用于 match.workspace 的按项目选库):MEMORY_WORKSPACE 优先,其次进程 cwd。 */
18
+ export function currentWorkspace(env = process.env) {
19
+ const explicit = String((env && env.MEMORY_WORKSPACE) || '').trim()
20
+ if (explicit) return explicit
21
+ try { return process.cwd() } catch { return '' }
22
+ }
23
+
24
+ /** 默认库目录。 */
25
+ export function defaultVaultRoot(env = process.env) {
26
+ const home = (env && env.DSH_HOME) || path.join(os.homedir(), '.dsh')
27
+ return path.join(home, 'memory-vault')
28
+ }
29
+
30
+ /** 归一化:反斜杠转正斜杠、去尾斜杠、统一小写(Windows 路径大小写不敏感)。 */
31
+ const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
32
+
33
+ /**
34
+ * 找出 match.workspace 命中当前 workspace 的 profile。
35
+ * 双向前缀匹配(workspace 落在规则目录下,或规则目录落在 workspace 下),最长规则优先。
36
+ * @returns {object|null}
37
+ */
38
+ export function matchWorkspaceProfile(profiles, workspace) {
39
+ const ws = norm(workspace)
40
+ if (!ws) return null
41
+ let best = null
42
+ let bestLen = -1
43
+ for (const p of Array.isArray(profiles) ? profiles : []) {
44
+ const rule = p && p.match && p.match.workspace
45
+ const target = norm(rule)
46
+ if (!target) continue
47
+ // 规则没配可用目录 → 不能作为记忆库,直接跳过(与 resolveVaultDir 的判定保持一致)
48
+ if (!p.path || !String(p.path).trim()) continue
49
+ if (!(ws === target || ws.startsWith(target + '/') || target.startsWith(ws + '/'))) continue
50
+ if (target.length > bestLen) { best = p; bestLen = target.length }
51
+ }
52
+ return best
53
+ }
54
+
55
+ /**
56
+ * 解析当前 vault 目录。
57
+ * @param {object} input
58
+ * @param {NodeJS.ProcessEnv} [input.env]
59
+ * @param {Array<{name?:string,path?:string,match?:{workspace?:string}}>} [input.profiles]
60
+ * @param {string} [input.activeVault]
61
+ * @param {string} [input.configured] vaultDir 单库配置
62
+ * @param {string} [input.workspace] 显式指定 workspace(默认 currentWorkspace)
63
+ * @returns {{root:string, source:'env'|'profile'|'workspace'|'vaultDir'|'default', name:string, workspace:string}}
64
+ */
65
+ export function resolveVaultDir({ env = process.env, profiles = [], activeVault = '', configured = '', workspace } = {}) {
66
+ const ws = workspace === undefined ? currentWorkspace(env) : String(workspace || '')
67
+ const envDir = String((env && env.MEMORY_VAULT_DIR) || '').trim()
68
+ if (envDir) return { root: path.resolve(envDir), source: 'env', name: '', workspace: ws }
69
+
70
+ const list = Array.isArray(profiles) ? profiles : []
71
+ const active = String(activeVault || '').trim()
72
+ if (active) {
73
+ const hit = list.find((p) => p && String(p.name || '') === active && p.path && String(p.path).trim())
74
+ if (hit) return { root: path.resolve(String(hit.path).trim()), source: 'profile', name: active, workspace: ws }
75
+ }
76
+ const byWs = matchWorkspaceProfile(list, ws)
77
+ if (byWs && byWs.path && String(byWs.path).trim()) {
78
+ return { root: path.resolve(String(byWs.path).trim()), source: 'workspace', name: String(byWs.name || ''), workspace: ws }
79
+ }
80
+ if (configured && String(configured).trim()) {
81
+ return { root: path.resolve(String(configured).trim()), source: 'vaultDir', name: '', workspace: ws }
82
+ }
83
+ return { root: defaultVaultRoot(env), source: 'default', name: '', workspace: ws }
84
+ }
package/lib/vault.js CHANGED
@@ -142,12 +142,26 @@ export function queryTerms(query) {
142
142
  * 会把新知识追加进待审垃圾卡或已被驳回的卡,等于绕过审核往废弃卡里灌数据。
143
143
  */
144
144
  export async function dedupCheck(root, text, threshold = 0.62) {
145
+ return bestDuplicate(await mainStoreBodies(root), text, threshold)
146
+ }
147
+
148
+ /**
149
+ * 主库已审核卡的 `(path, body)` 快照。
150
+ *
151
+ * 批量写入(导入备份)必须用它做**导入前**基线:否则同一份备份里互相近似的卡会在写入
152
+ * 过程中互相判重,把备份当成「重复」丢掉一批(备份是真相源,不能被自己人吞)。
153
+ */
154
+ export async function mainStoreBodies(root) {
145
155
  const db = getDb(root)
146
- const rows = db.prepare("SELECT path, body FROM cards WHERE status = 'approved' AND deleted_at IS NULL").all()
156
+ return db.prepare("SELECT path, body FROM cards WHERE status = 'approved' AND deleted_at IS NULL").all()
157
+ }
158
+
159
+ /** 在一组候选里找与给定文本最相似的一张(≥ 阈值)。候选集合由调用方提供,供快照比对复用。 */
160
+ export function bestDuplicate(candidates, text, threshold = 0.62) {
147
161
  let best = null
148
- for (const row of rows) {
149
- const score = textSimilarity(text, row.body || '')
150
- if (score >= threshold && (!best || score > best.score)) best = { path: row.path, score }
162
+ for (const row of Array.isArray(candidates) ? candidates : []) {
163
+ const score = textSimilarity(text, row?.body || '')
164
+ if (score >= threshold && (!best || score > best.score)) best = { path: row?.path, score }
151
165
  }
152
166
  return best
153
167
  }
@@ -540,8 +554,11 @@ export async function setCardStatus(root, rel, status, { changedBy = 'system', r
540
554
  *
541
555
  * 两套存储:enforceAudit 判定出的 status 决定**写进哪张表** ——
542
556
  * approved → 主库(可被召回);pending/rejected/deleted → 隔离区(只有审核中心能看)。
557
+ *
558
+ * `dedupAgainst` 传入候选集合时,去重只跟**这些**卡比(导入备份用:基线取导入前的快照,
559
+ * 让同一批里互相近似的卡不会互吞)。
543
560
  */
544
- export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true } = {}) {
561
+ export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true, dedupAgainst = null } = {}) {
545
562
  const db = getDb(root)
546
563
  const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
547
564
 
@@ -551,7 +568,7 @@ export async function writeCard(root, { kind, title, tags = [], body, source = '
551
568
 
552
569
  // 去重只在主库(approved)里找 —— 把新知识追加进隔离区里的待审/驳回卡是绕过审核
553
570
  if (dedup) {
554
- const hit = await dedupCheck(root, body, threshold)
571
+ const hit = dedupAgainst ? bestDuplicate(dedupAgainst, body, threshold) : await dedupCheck(root, body, threshold)
555
572
  if (hit) return { ok: false, duplicate: { ...hit, path: hit.path } }
556
573
  }
557
574