memory-eternal 0.1.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.
package/lib/api.js ADDED
@@ -0,0 +1,321 @@
1
+ // 记忆核心 · 知识库 JSON API(与宿主无关的纯 HTTP 层)。
2
+ //
3
+ // 从 index.js 抽出的 handleApi/json:逻辑原封不动,仅把对 DSH 闭包
4
+ // (vaultDir/vaultRoots/settings)的依赖改为通过 createApi(deps) 注入,
5
+ // 使 DSH 插件(经 webServer 前缀路由)与独立 Web server(lib/web.js)
6
+ // 共用同一份 API 实现。
7
+ //
8
+ // deps:
9
+ // - vaultDir() → 当前激活 vault 根目录(string)
10
+ // - vaultRoots() → [{ name, root }] 全部 profile 根(跨库聚合用)
11
+ // - getSettings() → 插件设置对象(/budget /compress 读取;可选,缺省 {})
12
+
13
+ import { listCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyBrief, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, restoreCard } from './vault.js'
14
+ import { compressExcerpt } from './capture.js'
15
+ import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
16
+
17
+ export const API_PREFIX = '/memory-eternal/api'
18
+
19
+ export function createApi(deps = {}) {
20
+ const vaultDir = deps.vaultDir ?? (() => '')
21
+ const vaultRoots = deps.vaultRoots ?? (() => [{ name: '', root: vaultDir() }])
22
+ const getSettings = deps.getSettings ?? (() => ({}))
23
+ // DSH 宿主信息(独立 web server 无 DSH 环境时为 null)。head DSH 状态行用。
24
+ const getDshInfo = deps.getDshInfo ?? (() => null)
25
+
26
+ return async function handleApi(req, res) {
27
+ const vaultRoot = vaultDir()
28
+ const url = new URL(req.url, 'http://localhost')
29
+ const route = url.pathname.slice(API_PREFIX.length).replace(/\/+$/, '') || '/overview'
30
+ const query = url.searchParams
31
+
32
+ switch (route) {
33
+ case '/overview': {
34
+ await ensureVault(vaultRoot)
35
+ json(res, 200, { ok: true, vaultDir: vaultRoot, ...(await overview(vaultRoot)) })
36
+ return
37
+ }
38
+ case '/cards': {
39
+ const kind = query.get('kind') || ''
40
+ const q = query.get('q') || ''
41
+ const status = (query.get('status') || 'approved').toLowerCase()
42
+ const limit = Math.min(Number(query.get('limit')) || 200, 500)
43
+ // all = 全部(含 pending/rejected,排除回收站);默认/approved = 已审核。
44
+ const want = status === 'all' ? ['approved', 'pending', 'rejected'] : status === 'pending' || status === 'rejected' ? [status] : ['approved']
45
+ let cards = await listCards(vaultRoot, { status: want })
46
+ if (kind) cards = cards.filter((c) => c.kind === kind)
47
+ if (q.trim()) {
48
+ const hits = await search(vaultRoot, q, { limit: 200 })
49
+ const hitPaths = new Set(hits.map((h) => h.path))
50
+ cards = cards.filter((c) => hitPaths.has(c.path))
51
+ }
52
+ json(res, 200, { ok: true, cards: cards.slice(0, limit) })
53
+ return
54
+ }
55
+ case '/card': {
56
+ const rel = query.get('path') || ''
57
+ if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
58
+ const text = await readCard(vaultRoot, rel)
59
+ json(res, 200, { ok: true, path: rel, text })
60
+ return
61
+ }
62
+ case '/search': {
63
+ const q = query.get('q') || ''
64
+ if (!q.trim()) return json(res, 200, { ok: true, hits: [] })
65
+ const all = query.get('all') === '1'
66
+ const semantic = query.get('semantic') === '1'
67
+ let hits
68
+ try { hits = all ? await searchAll(vaultRoots(), q, { limit: 30, semantic }) : await search(vaultRoot, q, { limit: 30, semantic }) }
69
+ catch (e) { hits = await search(vaultRoot, q, { limit: 30, semantic }) }
70
+ json(res, 200, { ok: true, hits })
71
+ return
72
+ }
73
+ case '/graph': {
74
+ await ensureVault(vaultRoot)
75
+ const all = query.get('all') === '1'
76
+ let g
77
+ try { g = all ? await graphAll(vaultRoots()) : await graph(vaultRoot) }
78
+ catch (e) { g = await graph(vaultRoot) }
79
+ json(res, 200, { ok: true, ...g })
80
+ return
81
+ }
82
+ case '/todayBrief': {
83
+ await ensureVault(vaultRoot)
84
+ const s = await stats(vaultRoot)
85
+ json(res, 200, { ok: true, today: s.today, brief: dailyBrief(s.todayCards) })
86
+ return
87
+ }
88
+ case '/export': {
89
+ await ensureVault(vaultRoot)
90
+ json(res, 200, { ok: true, cards: await exportCards(vaultRoot) })
91
+ return
92
+ }
93
+ case '/delete': {
94
+ const rel = query.get('path') || ''
95
+ if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
96
+ // 软删(进回收站);?permanent=1 直接永久删除
97
+ const permanent = query.get('permanent') === '1'
98
+ await deleteCard(vaultRoot, rel, { permanent })
99
+ json(res, 200, { ok: true, soft: !permanent })
100
+ return
101
+ }
102
+ case '/audit/list': {
103
+ const { pending, rejected } = await auditQueue(vaultRoot)
104
+ json(res, 200, { ok: true, pending, rejected })
105
+ return
106
+ }
107
+ case '/audit/approve': {
108
+ // POST {path} 或 GET ?path= → status=approved
109
+ const rel = query.get('path') || (await readBody(req))?.path || ''
110
+ if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
111
+ await setCardStatus(vaultRoot, rel, 'approved')
112
+ json(res, 200, { ok: true, path: rel, status: 'approved' })
113
+ return
114
+ }
115
+ case '/audit/reject': {
116
+ // POST {path} 或 GET ?path= → status=rejected(标记驳回,不删除)
117
+ const rel = query.get('path') || (await readBody(req))?.path || ''
118
+ if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
119
+ await setCardStatus(vaultRoot, rel, 'rejected')
120
+ json(res, 200, { ok: true, path: rel, status: 'rejected' })
121
+ return
122
+ }
123
+ case '/recycle/list': {
124
+ const items = await recycleList(vaultRoot)
125
+ json(res, 200, { ok: true, items })
126
+ return
127
+ }
128
+ case '/recycle/restore': {
129
+ const rel = query.get('path') || ''
130
+ if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
131
+ await restoreCard(vaultRoot, rel)
132
+ json(res, 200, { ok: true, path: rel })
133
+ return
134
+ }
135
+ case '/recycle/purge': {
136
+ const rel = query.get('path') || ''
137
+ if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
138
+ await deleteCard(vaultRoot, rel, { permanent: true })
139
+ json(res, 200, { ok: true, path: rel, purged: true })
140
+ return
141
+ }
142
+ case '/recycle/purge-expired': {
143
+ const days = Number(query.get('days')) || 30
144
+ const r = await purgeExpired(vaultRoot, days)
145
+ json(res, 200, r)
146
+ return
147
+ }
148
+ case '/write': {
149
+ let raw = ''
150
+ for await (const chunk of req) raw += chunk
151
+ let body
152
+ try { body = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
153
+ if (!body.body) return json(res, 400, { ok: false, error: '缺少正文' })
154
+ await ensureVault(vaultRoot)
155
+ const r = await writeCard(vaultRoot, { kind: body.kind || 'knowledge', title: body.title || '无标题', tags: body.tags || [], body: body.body, source: body.source || 'manual', status: 'pending', submittedBy: body.source || 'manual', severity: body.severity || 'info', reason: body.reason || '手动创建' }, { dedup: false })
156
+ json(res, 200, r)
157
+ return
158
+ }
159
+ case '/import': {
160
+ let raw = ''
161
+ for await (const chunk of req) raw += chunk
162
+ if (raw.length > 20 * 1024 * 1024) return json(res, 413, { ok: false, error: '文件过大' })
163
+ let payload
164
+ try { payload = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
165
+ const list = payload.cards || []
166
+ if (!Array.isArray(list)) return json(res, 400, { ok: false, error: '缺少 cards 数组' })
167
+ let imported = 0, skipped = 0
168
+ for (const c of list) {
169
+ const text = c.text || ''
170
+ let kind = c.kind || 'knowledge', title = c.title || '导入记忆', tags = [], body = text, source = ''
171
+ 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 } catch {}
172
+ const r = await writeCard(vaultRoot, { kind, title, tags, body, source, status: 'pending' })
173
+ if (r.ok) imported++; else skipped++
174
+ }
175
+ json(res, 200, { ok: true, imported, skipped })
176
+ return
177
+ }
178
+ case '/merge': {
179
+ const paths = (query.get('paths') || '').split(',').map((p) => p.trim()).filter(Boolean)
180
+ const r = await mergeCards(vaultRoot, paths)
181
+ if (!r.ok) return json(res, 400, r)
182
+ json(res, 200, r)
183
+ return
184
+ }
185
+ case '/stats': {
186
+ await ensureVault(vaultRoot)
187
+ const days = Math.min(Number(query.get('days')) || 30, 90)
188
+ json(res, 200, { ok: true, ...(await stats(vaultRoot)), trend: await dailyCounts(vaultRoot, days) })
189
+ return
190
+ }
191
+ case '/optimize': {
192
+ // 非破坏性:只返回「整理建议」(相似卡对 + 陈旧卡),不自动删改。
193
+ json(res, 200, { ok: true, ...(await optimizeCandidates(vaultRoot)) })
194
+ return
195
+ }
196
+ case '/optimize-execute': {
197
+ // 一键优化执行:合并相似卡 + 可选清理陈旧卡。POST body: {cleanupStale?, simThreshold?, staleDays?, dryRun?}
198
+ if (req.method !== 'POST') { json(res, 405, { ok: false, error: '需 POST' }); return }
199
+ let raw = ''
200
+ for await (const chunk of req) raw += chunk
201
+ let opts = {}
202
+ try { opts = JSON.parse(raw || '{}') } catch { opts = {} }
203
+ const result = await optimizeApply(vaultRoot, opts)
204
+ json(res, 200, result)
205
+ return
206
+ }
207
+ case '/setup-run': {
208
+ // 「补全 MCP」:真正执行 runSetup(写外部 agent 配置)。共享层(独立 web 也可用)
209
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
210
+ try {
211
+ const out = await runSetup({ log: () => {}, enabled: true })
212
+ json(res, 200, { ok: true, results: out.results })
213
+ } catch (e) { json(res, 500, { ok: false, error: String(e?.message || e) }) }
214
+ return
215
+ }
216
+ case '/mcp/action': {
217
+ // 单智能体安装/卸载 MCP:POST {agent, action}。共享层(独立 web 可用)
218
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
219
+ try {
220
+ let raw = ''
221
+ for await (const chunk of req) raw += chunk
222
+ const body = JSON.parse(raw || '{}')
223
+ const out = await mcpAgentAction(String(body.agent || ''), String(body.action || ''), { log: () => {} })
224
+ json(res, 200, out)
225
+ } catch (e) { json(res, 500, { ok: false, error: String(e?.message || e) }) }
226
+ return
227
+ }
228
+ case '/setup-status': {
229
+ // 只读查询各 agent MCP 配置状态,不写任何文件
230
+ const status = await getSetupStatus()
231
+ // prepend DSH 宿主行(独立 web server 无 DSH 环境时只有外部 agent)
232
+ const dsh = getDshInfo ? getDshInfo() : null
233
+ if (dsh) {
234
+ status.agents = [{ name: 'dsh', label: dsh.label || 'DSH (当前宿主)', installed: true, mcpConfigured: true, isDsh: true, recallTool: dsh.memoryRecallTool, autoCapture: dsh.autoCapture, autoRecall: dsh.autoRecall, vaultDir: dsh.vaultDir }, ...(status.agents || [])]
235
+ }
236
+ json(res, 200, status)
237
+ return
238
+ }
239
+ case '/feedback': {
240
+ let raw = ''
241
+ for await (const chunk of req) raw += chunk
242
+ let rec
243
+ try { rec = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
244
+ if (!rec || !rec.path) return json(res, 400, { ok: false, error: '缺少 path' })
245
+ await addFeedback(vaultRoot, { query: String(rec.query || ''), path: String(rec.path || ''), useful: rec.useful === true })
246
+ json(res, 200, { ok: true })
247
+ return
248
+ }
249
+ case '/budget': {
250
+ try {
251
+ const cfg = getSettings() ?? {}
252
+ const env = typeof process !== 'undefined' ? process.env : {}
253
+ json(res, 200, {
254
+ ok: true,
255
+ budgetChars: cfg.sessionBudgetChars ?? 80000,
256
+ recallLimit: cfg.recallLimit ?? 5,
257
+ embedding: cfg.recallEmbedding || '',
258
+ // 服务自管理配置(DSH host 从 settings 读;独立 web server 从 env 兜底)
259
+ autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0',
260
+ autoWebMode: cfg.autoWebMode || 'init',
261
+ webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999,
262
+ webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000,
263
+ webMaxRestart: Number(cfg.webMaxRestart) || 10,
264
+ watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1',
265
+ autoMcpSetup: cfg.autoMcpSetup ?? false,
266
+ })
267
+ } catch (e) {
268
+ // 配置读取异常时降级返回默认值,避免面板整块红屏
269
+ json(res, 200, { ok: true, budgetChars: 80000, recallLimit: 5, embedding: '', autoWeb: true, autoWebMode: 'init', webPort: 7999, webCheckIntervalMs: 5000, webMaxRestart: 10, watchdogAutoSpawn: true, autoMcpSetup: false })
270
+ }
271
+ return
272
+ }
273
+ case '/config': {
274
+ // 独立 web server(无 DSH settings)的配置读取兜底:env + 默认值。DSH host 的 /config 在其 webServer handler 拦截实现(可写),此处只读。
275
+ const cfg = getSettings() ?? {}
276
+ const env = typeof process !== 'undefined' ? process.env : {}
277
+ const safe = {
278
+ autoCapture: cfg.autoCapture ?? true, autoRecall: cfg.autoRecall ?? true, recallLimit: cfg.recallLimit ?? 5, recallSummaryLen: cfg.recallSummaryLen ?? 130, recallIncludeBody: cfg.recallIncludeBody ?? false,
279
+ captureMinChars: cfg.captureMinChars ?? 200, captureCooldownMs: cfg.captureCooldownMs ?? 300000, dedupThreshold: cfg.dedupThreshold ?? 0.62, maxCardsPerDay: cfg.maxCardsPerDay ?? 60,
280
+ distillEnabled: cfg.distillEnabled ?? true, dedupByLLM: cfg.dedupByLLM ?? true, captureMaxTokens: cfg.captureMaxTokens ?? 900, recallMinScore: cfg.recallMinScore ?? 2,
281
+ 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,
282
+ auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents ?? [], auditExemptKinds: cfg.auditExemptKinds ?? [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
283
+ }
284
+ json(res, 200, { ok: true, config: safe, revision: 0, writable: false, readonly: true, version: env.MEMORY_ETERNAL_VERSION || '' })
285
+ return
286
+ }
287
+ case '/compress': {
288
+ // 记忆侧「压缩产物」接口:供 harness 在会话内压缩旧轮次时调用,返回一段可注入的摘要。
289
+ const cfg = getSettings() ?? {}
290
+ const body = new URLSearchParams(query)
291
+ const text = body.get('text') || ''
292
+ const maxChars = Math.min(Number(body.get('max')) || 2400, 6000)
293
+ if (!text.trim()) return json(res, 400, { ok: false, error: '缺少 text' })
294
+ const compressed = await compressExcerpt(text, maxChars)
295
+ json(res, 200, { ok: true, compressed, budgetChars: cfg.sessionBudgetChars ?? 80000 })
296
+ return
297
+ }
298
+ default:
299
+ json(res, 404, { ok: false, error: '未知接口' })
300
+ }
301
+ }
302
+ }
303
+
304
+ export function json(res, status, body) {
305
+ const payload = JSON.stringify(body)
306
+ res.writeHead(status, {
307
+ 'Content-Type': 'application/json; charset=utf-8',
308
+ 'Content-Length': Buffer.byteLength(payload),
309
+ 'Cache-Control': 'no-store',
310
+ })
311
+ res.end(payload)
312
+ }
313
+
314
+ /** 读请求体(POST body),返回解析后的 JSON 对象或空。 */
315
+ export async function readBody(req) {
316
+ try {
317
+ let raw = ''
318
+ for await (const chunk of req) raw += chunk
319
+ return raw ? JSON.parse(raw) : {}
320
+ } catch { return {} }
321
+ }
@@ -0,0 +1,98 @@
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, summarizeTurn } from './capture.js'
12
+ import { createLlmFromEnv } from './llm-openai.js'
13
+
14
+ export function defaultVaultDir(env = process.env) {
15
+ if (env.MEMORY_VAULT_DIR && env.MEMORY_VAULT_DIR.trim()) return path.resolve(env.MEMORY_VAULT_DIR.trim())
16
+ const home = env.DSH_HOME || path.join(os.homedir(), '.dsh')
17
+ return path.join(home, 'memory-vault')
18
+ }
19
+
20
+ // 读取 DSH 设置的完整配置(写入的共享文件),使独立 web / MCP hook 捕获与 DSH 设置同步。
21
+ const CONFIG_FILE = 'memory-eternal-config.json'
22
+ export function configFilePath(env = process.env) {
23
+ return path.join(env.DSH_HOME || path.join(os.homedir(), '.dsh'), CONFIG_FILE)
24
+ }
25
+ function readConfig(env = process.env) {
26
+ try {
27
+ return JSON.parse(fs.readFileSync(configFilePath(env), 'utf8'))
28
+ } catch { return null }
29
+ }
30
+ function splitList(x) { return (Array.isArray(x) ? x : String(x || '').split(',')).map((s) => String(s).trim()).filter(Boolean) }
31
+
32
+ /**
33
+ * 沉淀一段对话文本到 vault。
34
+ * @returns {Promise<{ok:boolean, action:'created'|'appended'|'skipped'|'failed', path?:string, reason?:string, degraded?:boolean}>}
35
+ */
36
+ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm, env = process.env, minChars = 120 } = {}) {
37
+ const clean = String(text || '').trim()
38
+ if (clean.length < minChars) return { ok: false, action: 'skipped', reason: 'text too short' }
39
+
40
+ // 自动审核:共享配置文件(DSH)优先,其次 env,默认 all(独立进程无 DSH settings)
41
+ const resolveAuditStatus = (kind, sub) => {
42
+ const shared = readConfig(env)
43
+ const mode = shared?.auditMode || env.MEMORY_AUDIT_MODE || 'all'
44
+ const agents = shared?.auditExemptAgents ? splitList(shared.auditExemptAgents) : splitList(env.MEMORY_AUDIT_EXEMPT_AGENTS || '')
45
+ const kinds = shared?.auditExemptKinds ? splitList(shared.auditExemptKinds) : splitList(env.MEMORY_AUDIT_EXEMPT_KINDS || '')
46
+ if (mode === 'none') return 'approved'
47
+ if (agents.includes('__all__') || agents.includes(sub)) return 'approved'
48
+ if (kinds.includes('__all__') || kinds.includes(kind)) return 'approved'
49
+ return 'pending'
50
+ }
51
+
52
+ const llmClient = llm ?? createLlmFromEnv(env)
53
+ const route = llmClient ? { provider: llmClient.listProviders()[0].id, model: (await llmClient.listModels())[0].id } : null
54
+
55
+ // 有 LLM:蒸馏判定链(与 DSH 侧一致)
56
+ if (llmClient && route) {
57
+ try {
58
+ const neighbors = await pickNeighbors(vaultRoot, { title: '', body: clean.slice(0, 400) }, 8)
59
+ const result = await summarizeTurn(llmClient, route, clean, { existing: neighbors, signal: AbortSignal.timeout(45000) })
60
+ if (result && result.save === true) {
61
+ if (result.append_to) {
62
+ await captureUpdate(vaultRoot, result.append_to, result.update, { threshold: 0.62 })
63
+ return { ok: true, action: 'appended', path: result.append_to }
64
+ }
65
+ 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 自动沉淀(蒸馏卡)' }
66
+ const out = await captureCard(vaultRoot, card, { threshold: 0.62 })
67
+ if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel }
68
+ if (out.duplicate) {
69
+ await captureUpdate(vaultRoot, out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, { threshold: 0.62 })
70
+ return { ok: true, action: 'appended', path: out.duplicate.path }
71
+ }
72
+ return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
73
+ }
74
+ if (result && result.save === false) return { ok: true, action: 'skipped', reason: 'model judged not durable' }
75
+ } catch (error) {
76
+ // LLM 失败 → 落入降级路径,不能让一次网络抖动丢掉沉淀
77
+ }
78
+ }
79
+
80
+ // 降级:无 LLM(或 LLM 调用失败)→ 原文卡(kind 默认 content,dedup 开)
81
+ const out = await captureCard(vaultRoot, {
82
+ kind: 'content',
83
+ title: clean.replace(/\s+/g, ' ').slice(0, 40) || '未命名记录',
84
+ tags: ['raw'],
85
+ body: clean,
86
+ source: source || 'manual',
87
+ status: resolveAuditStatus('content', source || 'agent'),
88
+ submittedBy: source || 'agent',
89
+ severity: 'info',
90
+ reason: 'AI 自动沉淀(原文卡)',
91
+ }, { threshold: 0.62 })
92
+ if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel, degraded: true }
93
+ if (out.duplicate) {
94
+ await captureUpdate(vaultRoot, out.duplicate.path, clean.slice(0, 400), { threshold: 0.62 })
95
+ return { ok: true, action: 'appended', path: out.duplicate.path, degraded: true }
96
+ }
97
+ return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
98
+ }