@a9i5k4/dsh-auto-memory 0.1.29 → 0.1.30
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/README.md +352 -266
- package/README.zh-CN.md +370 -264
- package/cordis.patch.yml +9 -9
- package/lib/activation-host.js +455 -0
- package/lib/activation-inbox-state.js +261 -0
- package/lib/activation-inbox.js +426 -0
- package/lib/client.js +1379 -50
- package/lib/context-bridge.js +619 -0
- package/lib/context-host.js +712 -0
- package/lib/context-sink-python.js +90 -0
- package/lib/episodic-store.js +316 -0
- package/lib/evidence-store.js +272 -0
- package/lib/fact-store.js +418 -0
- package/lib/index-sync.js +160 -0
- package/lib/index.js +2357 -102
- package/lib/intent-clean.js +74 -0
- package/lib/m4-corpus.js +169 -0
- package/lib/m7-index-sync-host.js +194 -0
- package/lib/m7-wire.js +268 -0
- package/lib/memory-anchor.js +451 -0
- package/lib/memory-hub.js +259 -0
- package/lib/memory-index.js +145 -0
- package/lib/memory-writer.js +391 -0
- package/lib/policies/activation_policy_v2.json +88 -0
- package/lib/policies/recall_intent_lr_v1.json +1 -0
- package/lib/procedure-store.js +406 -0
- package/lib/python-sidecar-client.js +326 -0
- package/lib/semantic-decide.js +265 -0
- package/lib/semantic-js.js +381 -0
- package/lib/shadow-host.js +361 -0
- package/lib/shadow-retrieval.js +673 -0
- package/lib/storage-manage.js +203 -0
- package/package.json +2 -2
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M8-0 Fact Store 纯核心(docs/proactive-associative-memory-system-map.html M-03 Semantic/Profile)。
|
|
3
|
+
* 纯内存状态机,零 IO、零依赖(node:crypto 仅作确定性身份);持久化通过可注入 IO 接口,
|
|
4
|
+
* Host 接线时才接真实文件(本模块自身不读写磁盘,测试用内存 IO)。
|
|
5
|
+
*
|
|
6
|
+
* 设计目标(M-03 元代码逐行落地):
|
|
7
|
+
* - Fact 四元组: {scope, subject, predicate, object?} + provenance + confirmedAt + ttl? + revoked?
|
|
8
|
+
* - upsert(cand): 冲突→冲突集保留双方; 自动推断不覆盖已有规则; 否则合并写入。
|
|
9
|
+
* - 用户明确声明 > 模型推断; 项目事实/用户画像/术语分开; 冲突可见、可确认、可撤销。
|
|
10
|
+
* - supersede: 同一 subject+predicate 的新事实显式取代旧事实(旧 revoked=true,保留 provenance)。
|
|
11
|
+
*
|
|
12
|
+
* 与 M7 judgement-shadow 的衔接(输入侧):
|
|
13
|
+
* - ingestJudgementRow(row) 消费 worker 输出的 semantic_candidate / profile_candidate,
|
|
14
|
+
* 转成 FactCandidate; suggestion=supersede_suggest → supersede 路径, merge_suggest → merge。
|
|
15
|
+
*
|
|
16
|
+
* 与 M5 AccessEvidence 的衔接(证据挂钩,供 M9 Procedure 复用):
|
|
17
|
+
* - evidenceFor(memoryId) 聚合 seen/read/cite/reuse/success/correction 六类计数与去重 session 数。
|
|
18
|
+
*
|
|
19
|
+
* 与 M-06 可读投影 / sidecar 索引的衔接(输出侧,预留):
|
|
20
|
+
* - snapshot() 导出全部 fact 供 M-06 投影; Host 接线点=fact-store-pre 的 store.put 回调。
|
|
21
|
+
*
|
|
22
|
+
* 全部同输入确定; UTF-8 无 BOM。
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from 'node:crypto'
|
|
25
|
+
|
|
26
|
+
// ========== 冻结常量(变更必须升级 FACT_POLICY_VERSION) ==========
|
|
27
|
+
|
|
28
|
+
/** Fact 策略版本。 */
|
|
29
|
+
export const FACT_POLICY_VERSION = 'fact_store_v1'
|
|
30
|
+
|
|
31
|
+
/** factId 前缀(确定性身份)。 */
|
|
32
|
+
export const FACT_ID_PREFIX = 'fact_'
|
|
33
|
+
|
|
34
|
+
/** factId 长度前缀断言(与 memoryId/anchorId 同风格)。 */
|
|
35
|
+
export const FACT_ID_RE = /^fact_[0-9a-f]{32}$/
|
|
36
|
+
|
|
37
|
+
/** scope 枚举: 与 M5/M6 的 scope 对齐(User=跨工作区, Workspace=当前工作区)。 */
|
|
38
|
+
export const FACT_SCOPES_V1 = Object.freeze(['User', 'Workspace'])
|
|
39
|
+
|
|
40
|
+
/** sourceClass 枚举: 与 m4-corpus-pre 的 sourceClass 对齐。 */
|
|
41
|
+
export const FACT_SOURCE_CLASSES_V1 = Object.freeze([
|
|
42
|
+
'user-memory', 'workspace-notes', 'workspace-log', 'semantic-candidate', 'profile-candidate',
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
/** 来源类型: 用户明确声明(硬规则) vs 模型推断(candidate)。 */
|
|
46
|
+
export const FACT_SOURCE_KINDS_V1 = Object.freeze(['explicit', 'inference'])
|
|
47
|
+
|
|
48
|
+
/** 冲突处理结果枚举。 */
|
|
49
|
+
export const UPSERT_OUTCOMES_V1 = Object.freeze([
|
|
50
|
+
'created', 'merged', 'superseded', 'conflict-added', 'inference-blocked', 'expired-ignored',
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
/** TTL 语义: 过期事实视为不存在(读取时过滤),但保留记录供审计。 */
|
|
54
|
+
export const FACT_TTL_DEFAULT_V1 = 0 // 0 = 永不过期
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* FactCandidate 校验(fail closed)。
|
|
58
|
+
* 输入可以是 judgement-shadow 行或显式用户声明; sourceKind 决定 upsert 的覆盖权。
|
|
59
|
+
*/
|
|
60
|
+
export function validateFactCandidatePre(cand) {
|
|
61
|
+
const p = []
|
|
62
|
+
if (!cand || typeof cand !== 'object' || Array.isArray(cand)) return { ok: false, reason: 'not-object' }
|
|
63
|
+
if (!FACT_SCOPES_V1.includes(cand.scope)) p.push('scope')
|
|
64
|
+
if (typeof cand.subject !== 'string' || !cand.subject.trim()) p.push('subject')
|
|
65
|
+
if (typeof cand.predicate !== 'string' || !cand.predicate.trim()) p.push('predicate')
|
|
66
|
+
if (cand.object !== undefined && cand.object !== null && typeof cand.object !== 'string') p.push('object')
|
|
67
|
+
if (!FACT_SOURCE_KINDS_V1.includes(cand.sourceKind)) p.push('sourceKind')
|
|
68
|
+
if (cand.sourceClass !== undefined && !FACT_SOURCE_CLASSES_V1.includes(cand.sourceClass)) p.push('sourceClass')
|
|
69
|
+
if (cand.provenance !== undefined && (!Array.isArray(cand.provenance) || cand.provenance.some((s) => typeof s !== 'string'))) p.push('provenance')
|
|
70
|
+
if (cand.confidence !== undefined && (typeof cand.confidence !== 'number' || !Number.isFinite(cand.confidence) || cand.confidence < 0 || cand.confidence > 1)) p.push('confidence')
|
|
71
|
+
if (cand.ttl !== undefined && (typeof cand.ttl !== 'number' || !Number.isFinite(cand.ttl) || cand.ttl < 0)) p.push('ttl')
|
|
72
|
+
if (p.length) return { ok: false, reason: 'invalid:' + p.join(',') }
|
|
73
|
+
return { ok: true, candidate: cand }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 校验一个已固化的 Fact(读回/持久化时 fail closed)。
|
|
78
|
+
* 与 FactCandidate 的区别: Fact 必须有 factId/confirmedAt, provenance 必填数组。
|
|
79
|
+
*/
|
|
80
|
+
export function validateFactPre(fact) {
|
|
81
|
+
const p = []
|
|
82
|
+
if (!fact || typeof fact !== 'object' || Array.isArray(fact)) return { ok: false, reason: 'not-object' }
|
|
83
|
+
if (typeof fact.factId !== 'string' || !FACT_ID_RE.test(fact.factId)) p.push('factId')
|
|
84
|
+
if (!FACT_SCOPES_V1.includes(fact.scope)) p.push('scope')
|
|
85
|
+
if (typeof fact.subject !== 'string' || !fact.subject.trim()) p.push('subject')
|
|
86
|
+
if (typeof fact.predicate !== 'string' || !fact.predicate.trim()) p.push('predicate')
|
|
87
|
+
if (fact.object !== undefined && fact.object !== null && typeof fact.object !== 'string') p.push('object')
|
|
88
|
+
if (!Array.isArray(fact.provenance) || fact.provenance.some((s) => typeof s !== 'string')) p.push('provenance')
|
|
89
|
+
if (typeof fact.confirmedAt !== 'number' || !Number.isFinite(fact.confirmedAt)) p.push('confirmedAt')
|
|
90
|
+
if (fact.ttl !== undefined && (typeof fact.ttl !== 'number' || !Number.isFinite(fact.ttl))) p.push('ttl')
|
|
91
|
+
if (fact.revoked !== undefined && typeof fact.revoked !== 'boolean') p.push('revoked')
|
|
92
|
+
if (p.length) return { ok: false, reason: 'invalid:' + p.join(',') }
|
|
93
|
+
return { ok: true, fact }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 冲突定义: 同一 subject+predicate+scope 但 object 不同(或一方有 object 一方无)。
|
|
98
|
+
* 注意: 完全相同的 (subject,predicate,object,scope) 不算冲突, 是重复(走 merge)。
|
|
99
|
+
*/
|
|
100
|
+
export function isFactConflict(a, b) {
|
|
101
|
+
if (!a || !b) return false
|
|
102
|
+
if (a.scope !== b.scope || a.subject !== b.subject || a.predicate !== b.predicate) return false
|
|
103
|
+
const ao = a.object === undefined || a.object === null ? '' : a.object
|
|
104
|
+
const bo = b.object === undefined || b.object === null ? '' : b.object
|
|
105
|
+
return ao !== bo
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 主状态机工厂。
|
|
110
|
+
*
|
|
111
|
+
* @param {object} opts
|
|
112
|
+
* @param {object} opts.io 可选持久化接口 { save(facts), load() → facts[] , clear() }
|
|
113
|
+
* 默认 = 内存版(不落盘,进程内有效)。Host 接线时注入真实文件 IO。
|
|
114
|
+
* @param {function} opts.now 可选时钟(默认 Date.now), 测试注入确定性时间。
|
|
115
|
+
* @param {function} opts.factId 可选 factId 工厂(默认确定性哈希), 测试注入。
|
|
116
|
+
*/
|
|
117
|
+
export function createFactStorePre(opts = {}) {
|
|
118
|
+
// ---- 内部状态(全部在实例内, 无全局) ----
|
|
119
|
+
let facts = [] // 已固化 Fact 数组(含 revoked/过期, 读取时过滤)
|
|
120
|
+
let conflicts = [] // 冲突集(保留双方 provenance)
|
|
121
|
+
let disposed = false
|
|
122
|
+
const stats = {
|
|
123
|
+
upserts: 0, created: 0, merged: 0, superseded: 0, conflictAdded: 0,
|
|
124
|
+
inferenceBlocked: 0, expiredIgnored: 0, revoked: 0,
|
|
125
|
+
}
|
|
126
|
+
const io = opts.io || { save() {}, load() { return [] }, clear() {} }
|
|
127
|
+
const nowFn = typeof opts.now === 'function' ? opts.now : () => Date.now()
|
|
128
|
+
const idFn = typeof opts.factId === 'function' ? opts.factId : defaultFactId
|
|
129
|
+
|
|
130
|
+
// ---- 确定性 factId(与 memoryId/anchorId 同风格) ----
|
|
131
|
+
function defaultFactId(subject, predicate, object, scope) {
|
|
132
|
+
const h = createHash('sha256').update(
|
|
133
|
+
['fact-pre-v1', String(scope), String(subject), String(predicate), String(object == null ? '' : object)].join('\u0000')
|
|
134
|
+
).digest('hex')
|
|
135
|
+
return FACT_ID_PREFIX + h.slice(0, 32)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 冲突自增序号:同一 subject+predicate 在极短时间内反复冲突时,conflictId 仍唯一。
|
|
139
|
+
let conflictSeq = 0
|
|
140
|
+
|
|
141
|
+
function isExpired(fact, now) {
|
|
142
|
+
if (!fact || !fact.ttl || fact.ttl <= 0) return false
|
|
143
|
+
return now >= fact.confirmedAt + fact.ttl
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function findSubjectPredicate(scope, subject, predicate) {
|
|
147
|
+
return facts.find((f) => f.scope === scope && f.subject === subject && f.predicate === predicate && !f.revoked && !isExpired(f, nowFn()))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- 核心 upsert(M-03 元代码逐行) ----
|
|
151
|
+
function upsert(cand) {
|
|
152
|
+
if (disposed) return { ok: false, reason: 'disposed', outcome: 'disposed' }
|
|
153
|
+
stats.upserts++
|
|
154
|
+
const v = validateFactCandidatePre(cand)
|
|
155
|
+
if (!v.ok) return { ok: false, reason: v.reason, outcome: 'invalid' }
|
|
156
|
+
const c = v.candidate
|
|
157
|
+
const now = nowFn()
|
|
158
|
+
|
|
159
|
+
// 1. 冲突检测: 同 subject+predicate+scope 但 object 不同
|
|
160
|
+
const existing = findSubjectPredicate(c.scope, c.subject, c.predicate)
|
|
161
|
+
if (existing && isFactConflict(existing, c)) {
|
|
162
|
+
stats.conflictAdded++
|
|
163
|
+
// conflictId 必须唯一:同一候选值反复出现时,每次冲突都是独立待决事件。
|
|
164
|
+
// 用 subject+predicate+object+序号+detectedAt 派生,保证可被逐个 resolve。
|
|
165
|
+
conflictSeq++
|
|
166
|
+
conflicts.push({
|
|
167
|
+
conflictId: defaultFactId(c.scope, c.subject, c.predicate, c.object) + '_conflict_' + conflictSeq + '_' + String(now),
|
|
168
|
+
scope: c.scope, subject: c.subject, predicate: c.predicate,
|
|
169
|
+
left: existing, right: c, detectedAt: now, resolved: false,
|
|
170
|
+
})
|
|
171
|
+
void persist() // 冲突集是重要状态,必须落盘(不持久化会丢失待决冲突)
|
|
172
|
+
return { ok: true, outcome: 'conflict-added', conflict: conflicts[conflicts.length - 1], existing }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 2. 自动推断不覆盖已有规则(M-03: if (existing && cand.source === 'inference') return)
|
|
176
|
+
if (existing && c.sourceKind === 'inference') {
|
|
177
|
+
stats.inferenceBlocked++
|
|
178
|
+
return { ok: true, outcome: 'inference-blocked', existing }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 3. 新建 / 合并 / 取代
|
|
182
|
+
if (!existing) {
|
|
183
|
+
const fact = {
|
|
184
|
+
factId: idFn(c.scope, c.subject, c.predicate, c.object),
|
|
185
|
+
scope: c.scope, subject: c.subject, predicate: c.predicate,
|
|
186
|
+
object: c.object === undefined ? null : c.object,
|
|
187
|
+
sourceKind: c.sourceKind,
|
|
188
|
+
sourceClass: c.sourceClass || (c.sourceKind === 'explicit' ? 'user-memory' : 'semantic-candidate'),
|
|
189
|
+
provenance: c.provenance || [],
|
|
190
|
+
confidence: c.confidence !== undefined ? c.confidence : null,
|
|
191
|
+
confirmedAt: now, ttl: c.ttl !== undefined ? c.ttl : FACT_TTL_DEFAULT_V1,
|
|
192
|
+
revoked: false,
|
|
193
|
+
}
|
|
194
|
+
const fv = validateFactPre(fact)
|
|
195
|
+
if (!fv.ok) return { ok: false, reason: 'fact-invalid:' + fv.reason, outcome: 'invalid' }
|
|
196
|
+
facts.push(fv.fact)
|
|
197
|
+
stats.created++
|
|
198
|
+
void persist()
|
|
199
|
+
return { ok: true, outcome: 'created', fact: fv.fact }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 已有且不冲突: 合并(用户声明提升 sourceKind/confidence, 追加 provenance)
|
|
203
|
+
const prev = existing
|
|
204
|
+
if (c.sourceKind === 'explicit' || prev.sourceKind !== 'explicit') {
|
|
205
|
+
prev.sourceKind = c.sourceKind === 'explicit' ? 'explicit' : prev.sourceKind
|
|
206
|
+
}
|
|
207
|
+
if (c.sourceClass) prev.sourceClass = c.sourceClass
|
|
208
|
+
if (c.provenance && c.provenance.length) {
|
|
209
|
+
const seen = new Set(prev.provenance)
|
|
210
|
+
for (const s of c.provenance) if (!seen.has(s)) prev.provenance.push(s)
|
|
211
|
+
}
|
|
212
|
+
if (c.confidence !== undefined && c.confidence !== null) prev.confidence = c.confidence
|
|
213
|
+
prev.confirmedAt = now // 合并视为重新确认
|
|
214
|
+
if (c.ttl !== undefined) prev.ttl = c.ttl
|
|
215
|
+
stats.merged++
|
|
216
|
+
void persist()
|
|
217
|
+
return { ok: true, outcome: 'merged', fact: prev }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 显式取代: 同一 subject+predicate 的新事实取代旧事实(旧 revoked=true, 保留 provenance)。
|
|
222
|
+
* 语义 = judgement-shadow 的 supersede_suggest / 用户明确纠正。
|
|
223
|
+
*/
|
|
224
|
+
function supersede(cand) {
|
|
225
|
+
if (disposed) return { ok: false, reason: 'disposed', outcome: 'disposed' }
|
|
226
|
+
const v = validateFactCandidatePre(cand)
|
|
227
|
+
if (!v.ok) return { ok: false, reason: v.reason, outcome: 'invalid' }
|
|
228
|
+
const c = v.candidate
|
|
229
|
+
const existing = findSubjectPredicate(c.scope, c.subject, c.predicate)
|
|
230
|
+
if (existing) {
|
|
231
|
+
existing.revoked = true
|
|
232
|
+
existing.revokedAt = nowFn()
|
|
233
|
+
stats.revoked++
|
|
234
|
+
}
|
|
235
|
+
return upsert(c)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* M10 存储管理级联撤销(2026-08-30 P3):删除一条记忆后,由它派生出来的事实必须一起失效,
|
|
240
|
+
* 否则「记忆已删、事实仍在被召回/写回」(HANDOFF §2 P3 缺口③)。
|
|
241
|
+
* provenance 含该 sourceId 的未撤销事实一律 revoked=true —— 保留 provenance 与 revokedAt,
|
|
242
|
+
* 只读不删(可审计、可追溯),与 supersede 的撤销语义完全一致。
|
|
243
|
+
*/
|
|
244
|
+
function revokeBySource(sourceId) {
|
|
245
|
+
if (disposed) return { ok: false, reason: 'disposed', revoked: 0, factIds: [] }
|
|
246
|
+
const sid = String(sourceId || '')
|
|
247
|
+
if (!sid) return { ok: false, reason: 'no-source-id', revoked: 0, factIds: [] }
|
|
248
|
+
const now = nowFn()
|
|
249
|
+
const factIds = []
|
|
250
|
+
for (const f of facts) {
|
|
251
|
+
if (f.revoked) continue
|
|
252
|
+
if (!Array.isArray(f.provenance) || !f.provenance.includes(sid)) continue
|
|
253
|
+
f.revoked = true
|
|
254
|
+
f.revokedAt = now
|
|
255
|
+
f.revokeReason = 'source-deleted'
|
|
256
|
+
stats.revoked++
|
|
257
|
+
factIds.push(f.factId)
|
|
258
|
+
}
|
|
259
|
+
if (factIds.length) void persist()
|
|
260
|
+
return { ok: true, revoked: factIds.length, factIds }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---- 查询 ----
|
|
264
|
+
function get(scope, subject, predicate) {
|
|
265
|
+
if (disposed) return null
|
|
266
|
+
const f = findSubjectPredicate(scope, subject, predicate)
|
|
267
|
+
return f ? { ...f } : null // 返回副本, 防外部改内部态
|
|
268
|
+
}
|
|
269
|
+
function query(q = {}) {
|
|
270
|
+
if (disposed) return []
|
|
271
|
+
const now = nowFn()
|
|
272
|
+
return facts
|
|
273
|
+
.filter((f) =>
|
|
274
|
+
(!f.revoked) &&
|
|
275
|
+
(!isExpired(f, now)) &&
|
|
276
|
+
(q.scope === undefined || f.scope === q.scope) &&
|
|
277
|
+
(q.subject === undefined || f.subject === q.subject) &&
|
|
278
|
+
(q.predicate === undefined || f.predicate === q.predicate))
|
|
279
|
+
.map((f) => ({ ...f }))
|
|
280
|
+
}
|
|
281
|
+
function conflictsList() {
|
|
282
|
+
return conflicts.map((c) => ({ ...c }))
|
|
283
|
+
}
|
|
284
|
+
function pendingConflicts() {
|
|
285
|
+
return conflicts.filter((c) => !c.resolved).map((c) => ({ ...c }))
|
|
286
|
+
}
|
|
287
|
+
function resolveConflict(conflictId, choice) {
|
|
288
|
+
// choice: 'left' 保留左(已有), 'right' 采用右(新候选)。解析后冲突标记 resolved。
|
|
289
|
+
const c = conflicts.find((x) => x.conflictId === conflictId)
|
|
290
|
+
if (!c) return { ok: false, reason: 'not-found' }
|
|
291
|
+
if (c.resolved) return { ok: false, reason: 'already-resolved' }
|
|
292
|
+
if (choice === 'left') {
|
|
293
|
+
// 保留现有: 丢弃右候选(不落库)
|
|
294
|
+
c.resolved = true; c.resolvedAt = nowFn(); c.choice = 'left'
|
|
295
|
+
} else if (choice === 'right') {
|
|
296
|
+
// 采用新候选: 旧 revoked, 新落库
|
|
297
|
+
const old = findSubjectPredicate(c.scope, c.subject, c.predicate)
|
|
298
|
+
if (old) { old.revoked = true; old.revokedAt = nowFn(); stats.revoked++ }
|
|
299
|
+
upsert(c.right)
|
|
300
|
+
c.resolved = true; c.resolvedAt = nowFn(); c.choice = 'right'
|
|
301
|
+
} else {
|
|
302
|
+
return { ok: false, reason: 'invalid-choice' }
|
|
303
|
+
}
|
|
304
|
+
void persist()
|
|
305
|
+
return { ok: true, conflict: { ...c } }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---- M5 evidence 挂钩(供 M9 Procedure 复用) ----
|
|
309
|
+
function evidenceFor(memoryId, evidenceList = []) {
|
|
310
|
+
if (disposed) return null
|
|
311
|
+
const evs = evidenceList.filter((e) => e && e.memoryId === memoryId)
|
|
312
|
+
const byKind = {}
|
|
313
|
+
for (const k of ['seen', 'read', 'cite', 'reuse', 'success', 'correction']) byKind[k] = 0
|
|
314
|
+
const sessions = new Set()
|
|
315
|
+
for (const e of evs) {
|
|
316
|
+
if (byKind[e.kind] !== undefined) byKind[e.kind]++
|
|
317
|
+
if (e.sessionRef) sessions.add(e.sessionRef)
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
memoryId, total: evs.length,
|
|
321
|
+
distinctSessions: sessions.size,
|
|
322
|
+
...byKind,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- 持久化(可注入 IO) ----
|
|
327
|
+
function persist() {
|
|
328
|
+
try { io.save(snapshot({ includeRevoked: true })) } catch (_) {}
|
|
329
|
+
}
|
|
330
|
+
function snapshot(opts = {}) {
|
|
331
|
+
const now = nowFn()
|
|
332
|
+
return {
|
|
333
|
+
schemaVersion: 1, namespace: 'dsh-auto-memory', policyVersion: FACT_POLICY_VERSION,
|
|
334
|
+
savedAt: now,
|
|
335
|
+
facts: (opts.includeRevoked ? facts : facts.filter((f) => !f.revoked))
|
|
336
|
+
.map((f) => ({ ...f })),
|
|
337
|
+
conflicts: conflicts.map((c) => ({ ...c })),
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function restore(data) {
|
|
341
|
+
if (!data || data.schemaVersion !== 1) return { ok: false, reason: 'bad-schema' }
|
|
342
|
+
if (!Array.isArray(data.facts)) return { ok: false, reason: 'bad-facts' }
|
|
343
|
+
facts = []
|
|
344
|
+
for (const f of data.facts) {
|
|
345
|
+
const v = validateFactPre(f)
|
|
346
|
+
if (!v.ok) continue // 坏记录跳过, 不整体失败(幂等恢复)
|
|
347
|
+
facts.push(v.fact)
|
|
348
|
+
}
|
|
349
|
+
conflicts = Array.isArray(data.conflicts) ? data.conflicts.map((c) => ({ ...c })) : []
|
|
350
|
+
return { ok: true, restored: facts.length }
|
|
351
|
+
}
|
|
352
|
+
function clear() {
|
|
353
|
+
facts = []; conflicts = []
|
|
354
|
+
try { io.clear() } catch (_) {}
|
|
355
|
+
stats.created = 0; stats.merged = 0; stats.superseded = 0
|
|
356
|
+
return { ok: true }
|
|
357
|
+
}
|
|
358
|
+
function dispose(reason) {
|
|
359
|
+
if (disposed) return
|
|
360
|
+
disposed = true
|
|
361
|
+
try { io.save(snapshot({ includeRevoked: true })) } catch (_) {}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
upsert, supersede, get, query, conflictsList, pendingConflicts, resolveConflict,
|
|
366
|
+
evidenceFor, revokeBySource, snapshot, restore, clear, dispose,
|
|
367
|
+
getStats: () => ({ ...stats }),
|
|
368
|
+
get size() { return facts.length },
|
|
369
|
+
get conflictCount() { return conflicts.length },
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ========== judgement-shadow 消费(输入侧适配器) ==========
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 把 judgement-shadow 行转成 FactCandidate。
|
|
377
|
+
* 只接受 semantic_candidate / profile_candidate; 其他 kind 返回 null(忽略)。
|
|
378
|
+
* suggestion=supersede_suggest → 调用方走 supersede, 否则走 upsert。
|
|
379
|
+
*/
|
|
380
|
+
export function factCandidateFromJudgementRow(row) {
|
|
381
|
+
if (!row || typeof row !== 'object') return null
|
|
382
|
+
const kind = row.kindCandidate
|
|
383
|
+
if (kind !== 'semantic_candidate' && kind !== 'profile_candidate') return null
|
|
384
|
+
const sourceIds = Array.isArray(row.sourceIds) ? row.sourceIds : []
|
|
385
|
+
if (!sourceIds.length) return null
|
|
386
|
+
const subject = String(row.subject || sourceIds[0] || '') // 行内无 subject 时用 sourceId 占位
|
|
387
|
+
const predicate = String(row.predicate || 'relation')
|
|
388
|
+
const object = row.object === undefined || row.object === null ? null : String(row.object)
|
|
389
|
+
return {
|
|
390
|
+
scope: row.scope === 'User' ? 'User' : 'Workspace',
|
|
391
|
+
subject, predicate, object,
|
|
392
|
+
sourceKind: 'inference',
|
|
393
|
+
sourceClass: kind === 'profile_candidate' ? 'profile-candidate' : 'semantic-candidate',
|
|
394
|
+
provenance: [...sourceIds],
|
|
395
|
+
confidence: typeof row.confidence === 'number' ? row.confidence : null,
|
|
396
|
+
ttl: typeof row.ttl === 'number' ? row.ttl : undefined,
|
|
397
|
+
_suggestion: row.suggestion || 'keep_suggest',
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 批量消费 judgement-shadow 行(幂等: 同 observationId 同 sourceIds 只处理一次)。
|
|
403
|
+
* 返回每行的处理结果数组。
|
|
404
|
+
*/
|
|
405
|
+
export function ingestJudgementRows(store, rows, opts = {}) {
|
|
406
|
+
const seen = new Set(opts.seenObservationIds || [])
|
|
407
|
+
const out = []
|
|
408
|
+
for (const row of rows) {
|
|
409
|
+
const cand = factCandidateFromJudgementRow(row)
|
|
410
|
+
if (!cand) { out.push({ skipped: true, reason: 'not-fact-kind' }); continue }
|
|
411
|
+
const key = String(row.observationId || '') + '|' + (row.sourceIds || []).join(',')
|
|
412
|
+
if (seen.has(key)) { out.push({ skipped: true, reason: 'duplicate' }); continue }
|
|
413
|
+
seen.add(key)
|
|
414
|
+
const r = cand._suggestion === 'supersede_suggest' ? store.supersede(cand) : store.upsert(cand)
|
|
415
|
+
out.push({ row: key, outcome: r.outcome, ok: r.ok, reason: r.reason || null })
|
|
416
|
+
}
|
|
417
|
+
return { results: out, seenObservationIds: [...seen] }
|
|
418
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M7-1 Authorized index_sync 构造与执行(docs/PYTHON-SIDECAR-CONTRACT.md §8.4;handoff M7-1)。
|
|
3
|
+
* JS 是唯一语料授权者:records 只能由现有 M3/M4 corpus snapshot 投影产生;
|
|
4
|
+
* Python 绝不自行读文件或发现路径(本模块是唯一授权出口)。
|
|
5
|
+
*
|
|
6
|
+
* 约束(冻结):单页 ≤64 records 且 ≤256KiB(JSON 字节);pageDigest=sha256(canonical(records));
|
|
7
|
+
* finalDigest=sha256(canonical(final 对象));同一 sync 的 workspaceRef/scope/memoryIndexVersion 一致。
|
|
8
|
+
* 占位 chunking(M7-2 tokenizer 前冻结):整记录=单 chunk,chunkId 由 memoryId+recordDigest 派生,
|
|
9
|
+
* chunkId 只是派生定位,永不替代 memoryId。本阶段不做 tokenizer/embedding/cosine/BM25/RRF/聚类/图。
|
|
10
|
+
* UTF-8 无 BOM。
|
|
11
|
+
*/
|
|
12
|
+
import { workspaceRefOf } from './evidence-store.js'
|
|
13
|
+
import {
|
|
14
|
+
buildChunkIdPre, buildSyncIdPre, computePageDigestPre, computeFinalDigestPre,
|
|
15
|
+
validateIndexSyncBeginPre, validateIndexSyncPagePre, validateIndexSyncCommitPre, validateSemanticRecordPre,
|
|
16
|
+
M7_INDEX_POLICY_VERSION_V1,
|
|
17
|
+
} from './m7-wire.js'
|
|
18
|
+
|
|
19
|
+
export const INDEX_SYNC_PAGE_BUDGET_V1 = Object.freeze({ maxRecordsPerPage: 64, maxPageBytes: 256 * 1024 })
|
|
20
|
+
const SCOPE_ORDER_PRE = ['Workspace', 'User']
|
|
21
|
+
|
|
22
|
+
function pagePayloadBytes(syncId, pageNo, pageCount, records) {
|
|
23
|
+
return Buffer.byteLength(JSON.stringify({ schemaVersion: 1, syncId, pageNo, pageCount, pageDigest: '0'.repeat(64), records }), 'utf8')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* corpus snapshot 记录 → SemanticRecordPre 授权投影。text 边界即 snapshot 已授权切片;
|
|
28
|
+
* occurredAt 语料无此概念 → null;heading 透传(null 允许)。
|
|
29
|
+
*/
|
|
30
|
+
export function toSemanticRecordPre(rec, workspaceRef) {
|
|
31
|
+
const out = {
|
|
32
|
+
memoryId: rec.memoryId,
|
|
33
|
+
anchorId: rec.anchorId,
|
|
34
|
+
scope: rec.scope,
|
|
35
|
+
workspaceRef,
|
|
36
|
+
sourceRef: rec.sourceRef,
|
|
37
|
+
sourceEpoch: rec.sourceEpoch,
|
|
38
|
+
sourceVersion: rec.sourceVersion,
|
|
39
|
+
fileDigest: rec.fileDigest,
|
|
40
|
+
recordDigest: rec.recordDigest,
|
|
41
|
+
heading: rec.heading != null ? String(rec.heading) : null,
|
|
42
|
+
text: String(rec.text == null ? '' : rec.text),
|
|
43
|
+
occurredAt: null,
|
|
44
|
+
chunkId: buildChunkIdPre(rec.memoryId, rec.recordDigest),
|
|
45
|
+
chunkOrdinal: 0,
|
|
46
|
+
chunkCount: 1,
|
|
47
|
+
}
|
|
48
|
+
const v = validateSemanticRecordPre(out)
|
|
49
|
+
return v.ok ? { ok: true, record: v.record } : { ok: false, reason: v.reason }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 由 corpus snapshot 构造按 scope 分组的 index sync 计划。
|
|
54
|
+
* input: {snapshot(m4-corpus CorpusSnapshot), workspaceKey} 或显式 {records, sources, memoryIndexVersion, workspaceKey}。
|
|
55
|
+
* 返回 {ok:true, plans:[{scope, begin, pages[], commit}], recordTotal}|{ok:false, reason}。
|
|
56
|
+
*/
|
|
57
|
+
export function buildIndexSyncPlansPre(input) {
|
|
58
|
+
const B = INDEX_SYNC_PAGE_BUDGET_V1
|
|
59
|
+
const snap = input && input.snapshot
|
|
60
|
+
const recordsRaw = Array.isArray(input && input.records) ? input.records : (snap ? snap.records : [])
|
|
61
|
+
const sourcesAll = Array.isArray(input && input.sources) ? input.sources : (snap ? snap.sources : [])
|
|
62
|
+
const miv = String((input && input.memoryIndexVersion) || (snap ? snap.memoryIndexVersion : '') || '')
|
|
63
|
+
if (!miv.startsWith('idx_')) return { ok: false, reason: 'memoryIndexVersion' }
|
|
64
|
+
const wsRef = workspaceRefOf(String((input && input.workspaceKey) || ''))
|
|
65
|
+
const byScope = new Map()
|
|
66
|
+
for (const r of recordsRaw) {
|
|
67
|
+
if (!byScope.has(r.scope)) byScope.set(r.scope, [])
|
|
68
|
+
byScope.get(r.scope).push(r)
|
|
69
|
+
}
|
|
70
|
+
const plans = []
|
|
71
|
+
let recordTotal = 0
|
|
72
|
+
for (const scope of SCOPE_ORDER_PRE) {
|
|
73
|
+
const group = byScope.get(scope)
|
|
74
|
+
if (!group || !group.length) continue
|
|
75
|
+
const projected = []
|
|
76
|
+
for (const r of group) {
|
|
77
|
+
const pr = toSemanticRecordPre(r, wsRef)
|
|
78
|
+
if (!pr.ok) return { ok: false, reason: 'record:' + pr.reason + ':' + (r.memoryId || '') }
|
|
79
|
+
projected.push(pr.record)
|
|
80
|
+
}
|
|
81
|
+
projected.sort((a, b) => (a.memoryId < b.memoryId ? -1 : a.memoryId > b.memoryId ? 1 : a.recordDigest < b.recordDigest ? -1 : 1))
|
|
82
|
+
const recordCount = projected.length
|
|
83
|
+
const syncId = buildSyncIdPre(wsRef, scope, miv, recordCount)
|
|
84
|
+
const sourceTuples = sourcesAll
|
|
85
|
+
.filter((s) => s.scope === scope)
|
|
86
|
+
.map((s) => ({ sourceRef: s.sourceRef, sourceEpoch: s.sourceEpoch, sourceVersion: s.sourceVersion, fileDigest: s.fileDigest }))
|
|
87
|
+
.sort((a, b) => (a.sourceRef < b.sourceRef ? -1 : 1))
|
|
88
|
+
// 分页:计数边界 + 字节边界;单条超页预算 → fail closed(record-oversize)
|
|
89
|
+
const pagesRecords = []
|
|
90
|
+
let cur = []
|
|
91
|
+
for (const r of projected) {
|
|
92
|
+
if (cur.length >= B.maxRecordsPerPage) { pagesRecords.push(cur); cur = [] }
|
|
93
|
+
const candidate = [...cur, r]
|
|
94
|
+
if (pagePayloadBytes(syncId, pagesRecords.length, 0, candidate) > B.maxPageBytes) {
|
|
95
|
+
if (!cur.length) return { ok: false, reason: 'record-oversize:' + r.memoryId }
|
|
96
|
+
pagesRecords.push(cur)
|
|
97
|
+
cur = [r]
|
|
98
|
+
if (pagePayloadBytes(syncId, pagesRecords.length, 0, cur) > B.maxPageBytes) {
|
|
99
|
+
return { ok: false, reason: 'record-oversize:' + r.memoryId }
|
|
100
|
+
}
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
cur = candidate
|
|
104
|
+
}
|
|
105
|
+
if (cur.length) pagesRecords.push(cur)
|
|
106
|
+
const pageCount = pagesRecords.length
|
|
107
|
+
const begin = {
|
|
108
|
+
schemaVersion: 1, syncId, workspaceRef: wsRef, scope, memoryIndexVersion: miv,
|
|
109
|
+
sourceTuples, recordCount, pageCount, indexPolicyVersion: M7_INDEX_POLICY_VERSION_V1,
|
|
110
|
+
}
|
|
111
|
+
const bv = validateIndexSyncBeginPre(begin)
|
|
112
|
+
if (!bv.ok) return { ok: false, reason: 'begin:' + bv.reason }
|
|
113
|
+
const pageDigests = []
|
|
114
|
+
const pages = pagesRecords.map((recs, i) => {
|
|
115
|
+
const pageDigest = computePageDigestPre(recs)
|
|
116
|
+
pageDigests.push(pageDigest)
|
|
117
|
+
return { schemaVersion: 1, syncId, pageNo: i, pageCount, pageDigest, records: recs }
|
|
118
|
+
})
|
|
119
|
+
for (const p of pages) {
|
|
120
|
+
const pv = validateIndexSyncPagePre(p)
|
|
121
|
+
if (!pv.ok) return { ok: false, reason: 'page:' + pv.reason }
|
|
122
|
+
}
|
|
123
|
+
const commit = { schemaVersion: 1, syncId, memoryIndexVersion: miv, finalDigest: computeFinalDigestPre({
|
|
124
|
+
syncId, memoryIndexVersion: miv, workspaceRef: wsRef, scope, recordCount, pageCount, pageDigests,
|
|
125
|
+
}) }
|
|
126
|
+
const cv = validateIndexSyncCommitPre(commit)
|
|
127
|
+
if (!cv.ok) return { ok: false, reason: 'commit:' + cv.reason }
|
|
128
|
+
plans.push({ scope, begin, pages, commit })
|
|
129
|
+
recordTotal += recordCount
|
|
130
|
+
}
|
|
131
|
+
return { ok: true, plans, recordTotal, workspaceRef: wsRef, memoryIndexVersion: miv }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 顺序执行一个 sync 计划(begin→每页→commit;任一 index_ack accepted=false 即中止并返回失败原因)。
|
|
136
|
+
* 返回 {ok:true, acks:[...], recordCount}|{ok:false, phase, reason, acks}。
|
|
137
|
+
*/
|
|
138
|
+
export async function sendIndexSyncPlanPre(client, plan, opts = {}) {
|
|
139
|
+
const timeoutMs = Number(opts.timeoutMs) || undefined
|
|
140
|
+
const signal = opts.signal
|
|
141
|
+
const acks = []
|
|
142
|
+
async function step(type, payload) {
|
|
143
|
+
const res = await client.request(type, payload, { timeoutMs, signal })
|
|
144
|
+
const frame = res.ok ? res.frame : null
|
|
145
|
+
const ackPayload = frame ? frame.payload : null
|
|
146
|
+
acks.push(frame ? JSON.parse(JSON.stringify(frame)) : { error: res.code })
|
|
147
|
+
if (!res.ok) return { ok: false, transportCode: res.code }
|
|
148
|
+
if (!ackPayload || ackPayload.accepted !== true) return { ok: false, reason: (ackPayload && ackPayload.reason) || 'rejected' }
|
|
149
|
+
return { ok: true }
|
|
150
|
+
}
|
|
151
|
+
let r = await step('index_sync_begin', plan.begin)
|
|
152
|
+
if (!r.ok) return { ok: false, phase: 'begin', ...r, acks }
|
|
153
|
+
for (const page of plan.pages) {
|
|
154
|
+
r = await step('index_sync_page', page)
|
|
155
|
+
if (!r.ok) return { ok: false, phase: 'page', pageNo: page.pageNo, ...r, acks }
|
|
156
|
+
}
|
|
157
|
+
r = await step('index_sync_commit', plan.commit)
|
|
158
|
+
if (!r.ok) return { ok: false, phase: 'commit', ...r, acks }
|
|
159
|
+
return { ok: true, acks, recordCount: plan.begin.recordCount, syncId: plan.begin.syncId, scope: plan.scope }
|
|
160
|
+
}
|