@a9i5k4/dsh-auto-memory 0.1.29 → 0.1.31

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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * M10 存储管理(docs/HANDOFF-M8-M9-M10.md §2 P3)。
3
+ *
4
+ * 原语此前都已就绪但**零组装**,本模块把三个接线点装起来:
5
+ * ①删除一条记忆 = docStore.replace(原子事务,省略锚定 ID 即删除)
6
+ * + activationHost.purgeMemory(在途激活包级联清理)
7
+ * + factStore.revokeBySource(派生事实级联失效)
8
+ * ②语料健康扫描 = 逐源 sidecar ↔ 正文 fileDigest 比对(复用 M4-2 loadCorpusSnapshot 的
9
+ * dropped 分类),stale 自动 rebuildSidecar(只重建 sidecar,不动正文,零风险)
10
+ * ③管理动作审计(有界 64 条最小投影,只记 sourceRef/原因,不记正文)
11
+ *
12
+ * 铁律遵守:
13
+ * - 不碰 M5/M6 validator / Reference Tail 固定边界 / seen 语义;
14
+ * 删除只影响「还没投递的包」与「还没固化的事实」,已产生的 seen 证据不改写。
15
+ * - 全 _pre 命名空间;可注入 IO(docStore/io/activationHost/factStore),便于纯内存测试。
16
+ * - 每个动作 fail-closed:任一步失败不影响其它步,结果逐项回报。
17
+ * UTF-8 无 BOM。
18
+ */
19
+ import { buildSourceCatalog, loadCorpusSnapshot } from './m4-corpus.js'
20
+ import { parseAnchors } from './memory-anchor.js'
21
+
22
+ export const STORAGE_MANAGE_VERSION_V1 = 'storage_manage_v1'
23
+ /** 审计环形缓冲上限(最小投影,不记正文)。 */
24
+ const AUDIT_MAX = 64
25
+ /** 可通过「重建 sidecar」自愈的失效分类(与 loadCorpusSnapshot 的 dropped.reason 对齐)。 */
26
+ export const REPAIRABLE_REASONS_V1 = Object.freeze(['sidecar-missing', 'sidecar-invalid', 'stale-source', 'record-stale'])
27
+
28
+ export function createStorageManagerPre(opts = {}) {
29
+ const docStore = opts.docStore || null
30
+ const io = opts.io || {}
31
+ const pathsOf = typeof opts.pathsOf === 'function' ? opts.pathsOf : () => null
32
+ const activationHostOf = typeof opts.activationHostOf === 'function' ? opts.activationHostOf : () => opts.activationHost || null
33
+ const factStoreOf = typeof opts.factStoreOf === 'function' ? opts.factStoreOf : () => opts.factStore || null
34
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now()
35
+ const audit = []
36
+
37
+ function auditPush(entry) {
38
+ audit.push({ at: now(), ...entry })
39
+ while (audit.length > AUDIT_MAX) audit.shift()
40
+ }
41
+
42
+ /** 当前工作区的三源 catalog(用户记忆/项目笔记/今日日志);路径缺失返回 null。 */
43
+ function catalogFor(paths) {
44
+ const p = paths || pathsOf()
45
+ if (!p) return null
46
+ return buildSourceCatalog({
47
+ workspaceKey: p.workspaceKey || p.ws || '',
48
+ userMemoryPath: p.userDir ? (p.userMemoryPath || (p.userDir + '/MEMORY.md')) : p.userMemoryPath,
49
+ workspaceMemoryPath: p.notesPath,
50
+ todayLogPath: p.logPath,
51
+ })
52
+ }
53
+
54
+ /**
55
+ * ①语料健康扫描:逐源比对 sidecar 与正文。
56
+ * 健康度判定完全复用 M4-2 的 dropped 分类,不另写一套 digest 逻辑(避免两套真相)。
57
+ * @returns {{ok:true, sources:Array, stale:Array, counts:object}|{ok:false,reason:string}}
58
+ */
59
+ function scanHealth(paths) {
60
+ const catalog = catalogFor(paths)
61
+ if (!catalog) return { ok: false, reason: 'no-paths' }
62
+ if (!io.sidecarDir) return { ok: false, reason: 'no-sidecar-dir' }
63
+ const res = loadCorpusSnapshot(catalog, io)
64
+ const dropped = (res && res.dropped) || []
65
+ const byRef = new Map()
66
+ for (const d of dropped) {
67
+ if (!byRef.has(d.sourceRef)) byRef.set(d.sourceRef, [])
68
+ byRef.get(d.sourceRef).push(d.reason)
69
+ }
70
+ const sources = catalog.sources.map((s) => {
71
+ const reasons = [...new Set(byRef.get(s.sourceRef) || [])]
72
+ const repairable = reasons.filter((r) => REPAIRABLE_REASONS_V1.includes(r))
73
+ return {
74
+ sourceRef: s.sourceRef, kind: s.kind, scope: s.scope, file: s.file,
75
+ status: repairable.length ? 'stale' : (reasons.length ? 'unrepairable' : 'ok'),
76
+ reasons, repairable,
77
+ }
78
+ })
79
+ const stale = sources.filter((s) => s.status === 'stale')
80
+ const out = {
81
+ ok: true,
82
+ scannedAt: now(),
83
+ version: STORAGE_MANAGE_VERSION_V1,
84
+ sources,
85
+ stale,
86
+ counts: {
87
+ total: sources.length,
88
+ ok: sources.filter((s) => s.status === 'ok').length,
89
+ stale: stale.length,
90
+ unrepairable: sources.filter((s) => s.status === 'unrepairable').length,
91
+ },
92
+ }
93
+ auditPush({ action: 'scan', stale: stale.length, total: sources.length })
94
+ return out
95
+ }
96
+
97
+ /** 读取既有 sidecar(尽力而为):用于 rebuildSidecar 继承 epoch/version,避免无谓的 epoch 漂移。 */
98
+ function readSidecarPrev(file) {
99
+ try {
100
+ if (!docStore || typeof docStore.sidecarPath !== 'function') return null
101
+ const sp = docStore.sidecarPath(file)
102
+ if (!sp) return null
103
+ const txt = io.readFileSync ? io.readFileSync(sp, 'utf8') : null
104
+ if (!txt) return null
105
+ const j = JSON.parse(String(txt))
106
+ if (!j || typeof j !== 'object') return null
107
+ return { sourceEpoch: j.sourceEpoch, sourceVersion: j.sourceVersion, fileDigest: j.fileDigest }
108
+ } catch (_) { return null }
109
+ }
110
+
111
+ /**
112
+ * ②stale 自愈:只重建 sidecar,**不改动一个字节的正文**(用户手改内容零风险)。
113
+ * @param {Array<{file:string}>} items 为空时自动按 scanHealth 的 stale 列表修复
114
+ */
115
+ async function repair(items, paths) {
116
+ if (!docStore || typeof docStore.rebuildSidecar !== 'function') return { ok: false, reason: 'no-doc-store' }
117
+ let list = Array.isArray(items) ? items : null
118
+ if (!list) {
119
+ const sc = scanHealth(paths)
120
+ if (!sc.ok) return sc
121
+ list = sc.stale
122
+ }
123
+ const out = []
124
+ for (const it of list) {
125
+ const file = it && it.file
126
+ if (!file) { out.push({ ok: false, reason: 'no-file' }); continue }
127
+ try {
128
+ const r = await docStore.rebuildSidecar(file, readSidecarPrev(file) || undefined)
129
+ out.push({ file, sourceRef: it.sourceRef || null, ok: !!r.ok, reason: r.ok ? undefined : r.reason })
130
+ } catch (e) { out.push({ file, ok: false, reason: String(e && e.message || e).slice(0, 120) }) }
131
+ }
132
+ const repaired = out.filter((x) => x.ok).length
133
+ auditPush({ action: 'repair', repaired, attempted: out.length })
134
+ return { ok: true, repaired, attempted: out.length, results: out }
135
+ }
136
+
137
+ /**
138
+ * ③删除一条记忆(三联动事务)。
139
+ * 步骤:解析正文 → 摘掉该记录(marker 行 + 正文块)→ docStore.replace 原子写
140
+ * → activationHost.purgeMemory(在途包)→ factStore.revokeBySource(派生事实)。
141
+ * 后两步是「尽力而为」的级联:失败不影响正文删除结果,但会在 cascade 里如实回报。
142
+ * @param {{filePath:string, memoryId:string, expectedDigest?:string}} input
143
+ */
144
+ async function deleteMemory(input) {
145
+ const filePath = input && input.filePath
146
+ const memoryId = String((input && input.memoryId) || '')
147
+ if (!filePath) return { ok: false, reason: 'no-file-path' }
148
+ if (!memoryId) return { ok: false, reason: 'no-memory-id' }
149
+ if (!docStore || typeof docStore.replace !== 'function') return { ok: false, reason: 'no-doc-store' }
150
+
151
+ let buf
152
+ try { buf = await docStore.fs.readFile(filePath) } catch (e) { return { ok: false, reason: 'read-failed:' + String(e && e.code || 'error') } }
153
+ if (!Buffer.isBuffer(buf)) buf = Buffer.from(String(buf), 'utf8')
154
+ const parsed = parseAnchors(buf)
155
+ if (parsed.status === 'oversized') return { ok: false, reason: 'oversized' }
156
+ if (parsed.status !== 'clean') return { ok: false, reason: 'conflict:' + parsed.conflicts.map((c) => c.type).join(',') }
157
+ const rec = parsed.records.find((r) => r.kind === 'anchored' && r.memoryId === memoryId)
158
+ if (!rec) return { ok: false, reason: 'not-found' }
159
+
160
+ // BOM:parseAnchors 的字节偏移是**去掉 BOM 之后**的坐标系,切除前先对齐
161
+ const bomLen = parsed.bom ? 3 : 0
162
+ const body = bomLen ? buf.subarray(bomLen) : buf
163
+ // 删除区间 = marker 行起点 → 最后一行内容结束(byteStart/byteEnd 不含 marker 行)
164
+ let start = rec.markerByteStart
165
+ let end = rec.byteEnd
166
+ if (!(start >= 0 && end > start && end <= body.length)) return { ok: false, reason: 'bad-record-range' }
167
+ // 顺带吃掉紧随其后的一个换行(CRLF/LF),避免留下空洞空行
168
+ if (body[end] === 0x0d && body[end + 1] === 0x0a) end += 2
169
+ else if (body[end] === 0x0a) end += 1
170
+ const newBody = Buffer.concat([body.subarray(0, start), body.subarray(end)])
171
+ const newText = bomLen ? Buffer.concat([buf.subarray(0, bomLen), newBody]) : newBody
172
+
173
+ let write
174
+ try {
175
+ write = await docStore.replace(filePath, newText, { expectedDigest: input && input.expectedDigest })
176
+ } catch (e) { return { ok: false, reason: 'write-failed:' + String(e && e.message || e).slice(0, 120) } }
177
+ if (!write || !write.ok) return { ok: false, reason: (write && write.reason) || 'write-failed' }
178
+
179
+ const cascade = { purge: null, revoked: null }
180
+ try {
181
+ const host = activationHostOf()
182
+ if (host && typeof host.purgeMemory === 'function') cascade.purge = host.purgeMemory(memoryId)
183
+ else cascade.purge = { ok: false, reason: 'no-activation-host' }
184
+ } catch (e) { cascade.purge = { ok: false, reason: String(e && e.message || e).slice(0, 80) } }
185
+ try {
186
+ const fs2 = factStoreOf()
187
+ if (fs2 && typeof fs2.revokeBySource === 'function') cascade.revoked = fs2.revokeBySource(memoryId)
188
+ else cascade.revoked = { ok: false, reason: 'no-fact-store', revoked: 0 }
189
+ } catch (e) { cascade.revoked = { ok: false, reason: String(e && e.message || e).slice(0, 80), revoked: 0 } }
190
+
191
+ auditPush({ action: 'delete', memoryId, removed: (write.removed || []).length, revoked: (cascade.revoked && cascade.revoked.revoked) || 0 })
192
+ return { ok: true, memoryId, file: filePath, removed: write.removed || [], kept: write.kept || [], cascade }
193
+ }
194
+
195
+ return {
196
+ scanHealth,
197
+ repair,
198
+ deleteMemory,
199
+ auditLog: () => audit.slice(),
200
+ version: STORAGE_MANAGE_VERSION_V1,
201
+ dispose() { audit.length = 0 },
202
+ }
203
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@a9i5k4/dsh-auto-memory",
3
- "description": "DSH 自动记忆插件:三层记忆(用户级/项目笔记/每日日志)自动注入与检索、每轮对话自动沉淀、每日反思、可视化面板与设置页,支持继承其他 AI 工具的记忆。",
4
- "version": "0.1.29",
3
+ "description": "主动联想记忆插件:记忆不靠模型调用,自己被唤回——Host 观察情境,NLP+向量化双轨检索,固定边界注入不破坏前缀缓存。三层记忆自动沉淀、欢迎向导、唤起回顾、无人值守、AI 问候与反思、日历、跨工具记忆继承。Proactive associative memory for DSH: zero-call recall, three-layer auto-consolidation, welcome tour, unattended mode.",
4
+ "version": "0.1.31",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {