dsh-selfnote 0.0.0-stage → 0.12.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/LICENSE +21 -0
- package/README.md +445 -2
- package/assets/cover.jpg +0 -0
- package/cordis.patch.yml +4 -0
- package/lib/atomic.js +78 -0
- package/lib/capture-run.js +178 -0
- package/lib/capture.js +433 -0
- package/lib/client.js +1713 -0
- package/lib/index-store.js +404 -0
- package/lib/index.js +2097 -0
- package/lib/memory.js +1097 -0
- package/lib/profile.js +70 -0
- package/lib/store.js +257 -0
- package/lib/transfer.js +154 -0
- package/lib/warm.js +155 -0
- package/package.json +57 -4
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1-B:把「发现」接到写入层。
|
|
3
|
+
*
|
|
4
|
+
* 边界(评审定的):
|
|
5
|
+
* - compareCandidate() 只做判定,不碰文件;这里才决定写什么
|
|
6
|
+
* - 冲突 = 两条 candidate + 双向 similarTo;已 approved 的一侧只被指向,不被改写
|
|
7
|
+
* - 合并 = 只加证据(occurrences / lastObserved / evidence),不动 status
|
|
8
|
+
* - 一次事件的所有候选,用一次 writeFacts 原子落盘;写失败就不推游标
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { alreadyCaptured, candidatesOf, commitObservation, compareCandidate, isDelegatedSession, isUserAuthored, triggersOf } from './capture.js'
|
|
12
|
+
|
|
13
|
+
/** 从一条 UserMessage 的 content 里抽纯文本(content 可能是字符串或块数组)。 */
|
|
14
|
+
export function textOfEvent(event) {
|
|
15
|
+
const content = event?.data?.content ?? event?.data?.text ?? ''
|
|
16
|
+
if (typeof content === 'string') return content
|
|
17
|
+
if (!Array.isArray(content)) return ''
|
|
18
|
+
const parts = []
|
|
19
|
+
for (const block of content) {
|
|
20
|
+
if (block === null || typeof block !== 'object') continue
|
|
21
|
+
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
22
|
+
}
|
|
23
|
+
return parts.join('\n')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function newId(now, index) {
|
|
27
|
+
// 加随机段:不同会话之间没有串行化,同一毫秒里两次提交会撞出同一个 id,
|
|
28
|
+
// 撞了之后 setFactStatus 只改得到先找到的那条,另一条永远采纳不了。
|
|
29
|
+
const salt = Math.random().toString(36).slice(2, 6)
|
|
30
|
+
return `auto-${now.getTime().toString(36)}-${salt}-${index}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 纯规划:给定文本与现有 facts,算出「写完之后 facts 长什么样」。
|
|
35
|
+
* 不读写任何文件,方便测试。
|
|
36
|
+
*/
|
|
37
|
+
export function planWrites(texts, options = {}) {
|
|
38
|
+
const facts = (options.facts ?? []).map((fact) => ({ ...fact }))
|
|
39
|
+
const sessionId = options.sessionId ?? 'unknown'
|
|
40
|
+
const seq = Number(options.seq ?? 0)
|
|
41
|
+
const now = options.now instanceof Date ? options.now : new Date()
|
|
42
|
+
const iso = now.toISOString()
|
|
43
|
+
const source = `${sessionId}#${seq}`
|
|
44
|
+
|
|
45
|
+
const created = []
|
|
46
|
+
const merged = []
|
|
47
|
+
const conflicts = []
|
|
48
|
+
|
|
49
|
+
texts.forEach((text, index) => {
|
|
50
|
+
let best = null
|
|
51
|
+
for (const fact of facts) {
|
|
52
|
+
if (typeof fact.text !== 'string') continue
|
|
53
|
+
const verdict = compareCandidate(text, fact.text)
|
|
54
|
+
if (best === null || verdict.score > best.verdict.score) {
|
|
55
|
+
best = { fact, verdict }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const action = best?.verdict.action ?? 'new'
|
|
60
|
+
const target = best?.fact ?? null
|
|
61
|
+
|
|
62
|
+
if (action === 'merge' && target !== null && target.status === 'candidate') {
|
|
63
|
+
target.occurrences = Number(target.occurrences ?? 1) + 1
|
|
64
|
+
target.lastObserved = iso
|
|
65
|
+
target.evidence = [...(target.evidence ?? []), source]
|
|
66
|
+
merged.push(target.id)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const fact = {
|
|
71
|
+
id: newId(now, index),
|
|
72
|
+
text,
|
|
73
|
+
status: 'candidate',
|
|
74
|
+
tags: [],
|
|
75
|
+
importance: 1,
|
|
76
|
+
hitCount: 0,
|
|
77
|
+
createdAt: iso,
|
|
78
|
+
updated: iso,
|
|
79
|
+
captureSource: 'auto',
|
|
80
|
+
triggers: triggersOf(text),
|
|
81
|
+
source,
|
|
82
|
+
occurrences: 1,
|
|
83
|
+
lastObserved: iso,
|
|
84
|
+
evidence: [source],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (action === 'merge' && target !== null) {
|
|
88
|
+
// 命中已采纳的结论:不改它,只让新候选指过去
|
|
89
|
+
fact.similarTo = [target.id]
|
|
90
|
+
fact.mergeSuggestion = target.id
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (action === 'conflict' && target !== null) {
|
|
94
|
+
fact.similarTo = [target.id]
|
|
95
|
+
fact.conflictWith = target.id
|
|
96
|
+
conflicts.push({ incoming: fact.id, existing: target.id })
|
|
97
|
+
if (target.status === 'candidate') {
|
|
98
|
+
target.similarTo = [...(target.similarTo ?? []), fact.id]
|
|
99
|
+
target.conflictWith = fact.id
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
facts.push(fact)
|
|
104
|
+
created.push(fact.id)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return { nextFacts: facts, created, merged, conflicts, source }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 处理一条会话事件。
|
|
112
|
+
*
|
|
113
|
+
* deps: { readFacts(), writeFacts(facts), now? }
|
|
114
|
+
* 返回:{ handled: boolean, reason?, plan? }
|
|
115
|
+
*/
|
|
116
|
+
export async function handleEvent(event, deps) {
|
|
117
|
+
// 委派会话里的一切都不是这个人说的:先按会话粒度挡掉,再谈内容判断
|
|
118
|
+
if (deps?.delegated === true) return { handled: false, reason: 'delegated-session' }
|
|
119
|
+
if (!isUserAuthored(event)) return { handled: false, reason: 'not-user-authored' }
|
|
120
|
+
|
|
121
|
+
const sessionId = deps.sessionId ?? event?.sessionId ?? event?.data?.session?.id ?? 'unknown'
|
|
122
|
+
const seq = Number(event?.seq ?? event?.data?.seq)
|
|
123
|
+
if (!Number.isSafeInteger(seq) || seq < 0) return { handled: false, reason: 'no-seq' }
|
|
124
|
+
|
|
125
|
+
const texts = candidatesOf(textOfEvent(event))
|
|
126
|
+
if (texts.length === 0) {
|
|
127
|
+
// 没有候选也要推进游标,否则同一条消息下次还会被重新判断
|
|
128
|
+
await commitObservation({ sessionId, seq }, () => null, {})
|
|
129
|
+
return { handled: false, reason: 'no-candidate' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 读 → 改 → 写 必须在同一段同步代码里:中间不留 await,注入时记热度那条路径就插不进来。
|
|
133
|
+
// 宿主给了 mutateFacts 就用它的跨进程 CAS;老调用方只给 read/write 时,用同样的形状兜一个。
|
|
134
|
+
const transact =
|
|
135
|
+
typeof deps.mutateFacts === 'function'
|
|
136
|
+
? deps.mutateFacts
|
|
137
|
+
: (mutate) => {
|
|
138
|
+
const facts = deps.readFacts()
|
|
139
|
+
const outcome = mutate(facts)
|
|
140
|
+
if (outcome?.changed === true) deps.writeFacts(facts)
|
|
141
|
+
return outcome
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const plan = await commitObservation({ sessionId, seq, texts }, (observation) => {
|
|
145
|
+
let written = null
|
|
146
|
+
let repeated = false
|
|
147
|
+
const source = `${sessionId}#${seq}`
|
|
148
|
+
const outcome = transact((facts) => {
|
|
149
|
+
// 锁外那次判重只是省事:两个进程可能同时通过,进了事务再按最新快照看一遍
|
|
150
|
+
if (alreadyCaptured(facts, source)) {
|
|
151
|
+
repeated = true
|
|
152
|
+
return { changed: false }
|
|
153
|
+
}
|
|
154
|
+
const result = planWrites(observation.texts, {
|
|
155
|
+
facts,
|
|
156
|
+
sessionId,
|
|
157
|
+
seq,
|
|
158
|
+
now: deps.now,
|
|
159
|
+
})
|
|
160
|
+
written = result
|
|
161
|
+
// 一次写入:三份文件里只有 facts 会变,写成功才算提交成功
|
|
162
|
+
facts.length = 0
|
|
163
|
+
facts.push(...result.nextFacts)
|
|
164
|
+
return { changed: true, plan: result }
|
|
165
|
+
})
|
|
166
|
+
if (outcome?.error) throw new Error(outcome.error)
|
|
167
|
+
if (repeated) return { created: [], merged: [], conflicts: [], source, repeated: true }
|
|
168
|
+
if (written === null) throw new Error('facts 事务没有产出结果')
|
|
169
|
+
return written
|
|
170
|
+
}, { facts: deps.readFacts })
|
|
171
|
+
|
|
172
|
+
if (plan.ok !== true) return { handled: false, reason: 'commit-failed', error: plan.error }
|
|
173
|
+
if (plan.skipped !== null) return { handled: false, reason: plan.skipped }
|
|
174
|
+
|
|
175
|
+
return { handled: true, plan: plan.written }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export { alreadyCaptured }
|
package/lib/capture.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1-A 原型:自动采集的「发现」部分(不做提取模型调用,不做写库)。
|
|
3
|
+
*
|
|
4
|
+
* 设计约束(来自评审):
|
|
5
|
+
* - 只发现,不确认:产出永远是 candidate,永远不碰 approved
|
|
6
|
+
* - 轻量触发:不满足条件就什么都不做
|
|
7
|
+
* - 相似不等于相同:词面相近但方向相反(简洁 / 详细)判冲突,不合并
|
|
8
|
+
* - 幂等:按会话 seq 推进游标,同一条消息不会被处理两次
|
|
9
|
+
*
|
|
10
|
+
* 修订(第二版):
|
|
11
|
+
* - 采集门槛两步走:先看有没有长期表达,再看是不是在说「对 AI 的要求 / 想让我记住的事」
|
|
12
|
+
* - 方向判定改成集合,一句话同时涉及长短与快慢也能发现冲突
|
|
13
|
+
* - seq 必须是安全的非负整数;游标文件损坏不再静默从头来,先备份再抛错
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
import { writeJsonAtomic } from './atomic.js'
|
|
20
|
+
import { memoryRoot } from './memory.js'
|
|
21
|
+
|
|
22
|
+
/* ------------------------------ 触发 ------------------------------ */
|
|
23
|
+
|
|
24
|
+
/** 长期偏好 / 明确约束 / 主动纠正 —— 三类才采,普通闲聊不采。 */
|
|
25
|
+
const TRIGGERS = [
|
|
26
|
+
{ id: 'standing', re: /(以后|今后|往后|从现在起|之后都|一直|每次都)/ },
|
|
27
|
+
{ id: 'prohibit', re: /(不要再|别再|不要用|不许|禁止|别用|不要再给|不用再)/ },
|
|
28
|
+
{ id: 'prefer', re: /(我(更)?(喜欢|偏好|习惯|想要)|我讨厌|我受不了|请(你)?(记住|记下))/ },
|
|
29
|
+
{ id: 'correct', re: /(你(刚才|这次)?(说|写|做)错了|不是这样|别这样|应该(先|用)|重来)/ },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 第二道门槛:这句话是不是在说「跟我交互的方式」或「要我记住的事」。
|
|
34
|
+
*
|
|
35
|
+
* 只看触发词是不够的 ——「我以后再也不熬夜了」有时间持续性,但不是给我的要求。
|
|
36
|
+
* 这里用规则白名单,不调模型:P1-A 阶段宁可漏,也不要乱采。
|
|
37
|
+
*/
|
|
38
|
+
const SCOPE_CUE = new RegExp(
|
|
39
|
+
[
|
|
40
|
+
// 回答方式
|
|
41
|
+
'回答', '回复', '解释', '说明', '讲(解|清楚)?', '输出', '格式', '排版', '语气', '称呼', '叫我', '称我',
|
|
42
|
+
'举例', '示例', '例子', '代码', '注释', '翻译', '中文', '英文', '分点', '列表', '结论', '总结', '步骤',
|
|
43
|
+
'详细', '简洁', '简短', '长一点', '短一点', '直接', '口语', '正式', '专业',
|
|
44
|
+
// 对我的要求 / 记忆请求
|
|
45
|
+
'提醒我', '记住', '记下', '别忘了', '每次(都)?', '一律', '统一',
|
|
46
|
+
].join('|'),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const NEGATION = /(不|别|没|勿|禁止|避免|少|勿用)/
|
|
50
|
+
|
|
51
|
+
/** 方向相反的两组词:词面再像也不能合并。 */
|
|
52
|
+
const DIRECTIONS = [
|
|
53
|
+
['简洁', '详细'],
|
|
54
|
+
['简短', '详尽'],
|
|
55
|
+
['短', '长'],
|
|
56
|
+
['快', '慢'],
|
|
57
|
+
['少', '多'],
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const SENTENCE = /[^。!?!?;;\n]+[。!?!?;;]?/g
|
|
61
|
+
|
|
62
|
+
/** 判一条用户消息值不值得采。返回命中的触发原因(空数组 = 不采)。 */
|
|
63
|
+
export function triggersOf(text) {
|
|
64
|
+
const value = String(text ?? '')
|
|
65
|
+
if (value.trim() === '') return []
|
|
66
|
+
const hits = []
|
|
67
|
+
for (const trigger of TRIGGERS) {
|
|
68
|
+
if (trigger.re.test(value)) hits.push(trigger.id)
|
|
69
|
+
}
|
|
70
|
+
return hits
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 这句话说的是不是「跟 AI 有关的要求 / 希望被记住的事」。 */
|
|
74
|
+
export function hasScope(text) {
|
|
75
|
+
return SCOPE_CUE.test(String(text ?? ''))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 从一条消息里挑出候选句:既命中触发、又落在采集范围内。 */
|
|
79
|
+
export function candidatesOf(text, limit = 3) {
|
|
80
|
+
const value = String(text ?? '')
|
|
81
|
+
const out = []
|
|
82
|
+
for (const sentence of value.match(SENTENCE) ?? []) {
|
|
83
|
+
const line = sentence.trim()
|
|
84
|
+
if (line.length < 4 || line.length > 200) continue
|
|
85
|
+
if (triggersOf(line).length === 0) continue
|
|
86
|
+
if (!hasScope(line)) continue
|
|
87
|
+
if (out.includes(line)) continue
|
|
88
|
+
out.push(line)
|
|
89
|
+
if (out.length >= limit) break
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ------------------------------ 去重 ------------------------------ */
|
|
95
|
+
|
|
96
|
+
function bigrams(text) {
|
|
97
|
+
const value = String(text ?? '').toLowerCase().replace(/[\s\p{P}]/gu, '')
|
|
98
|
+
const set = new Set()
|
|
99
|
+
for (let i = 0; i + 1 < value.length; i += 1) set.add(value.slice(i, i + 2))
|
|
100
|
+
if (value.length === 1) set.add(value)
|
|
101
|
+
return set
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 词面相似度(重叠系数),只作第一层筛选。 */
|
|
105
|
+
export function similarity(a, b) {
|
|
106
|
+
const left = bigrams(a)
|
|
107
|
+
const right = bigrams(b)
|
|
108
|
+
if (left.size === 0 || right.size === 0) return 0
|
|
109
|
+
let shared = 0
|
|
110
|
+
for (const gram of left) if (right.has(gram)) shared += 1
|
|
111
|
+
// 用重叠系数而不是 Jaccard:中文长句稍改几个字,Jaccard 掉得太快
|
|
112
|
+
return shared / Math.min(left.size, right.size)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 每条话在每个方向组上站哪一边:Map<组序号, 'left'|'right'>。
|
|
117
|
+
* 组之间互不影响,所以一句话可以同时是「简短」和「慢」。
|
|
118
|
+
*/
|
|
119
|
+
export function directionMap(text) {
|
|
120
|
+
const value = String(text ?? '')
|
|
121
|
+
const map = new Map()
|
|
122
|
+
DIRECTIONS.forEach(([left, right], group) => {
|
|
123
|
+
const hasLeft = value.includes(left)
|
|
124
|
+
const hasRight = value.includes(right)
|
|
125
|
+
if (hasLeft && !hasRight) map.set(group, 'left')
|
|
126
|
+
if (hasRight && !hasLeft) map.set(group, 'right')
|
|
127
|
+
})
|
|
128
|
+
return map
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 这句话站了哪几边的方向(可能是多个维度,空集合 = 没提)。 */
|
|
132
|
+
export function directionsOf(text) {
|
|
133
|
+
const value = String(text ?? '')
|
|
134
|
+
const matched = []
|
|
135
|
+
for (const [left, right] of DIRECTIONS) {
|
|
136
|
+
if (value.includes(left)) matched.push(left)
|
|
137
|
+
if (value.includes(right)) matched.push(right)
|
|
138
|
+
}
|
|
139
|
+
// 「简短」已经包含了「短」,别把同一个意思数两遍
|
|
140
|
+
const kept = matched.filter((word) => !matched.some((other) => other !== word && other.includes(word)))
|
|
141
|
+
return new Set(kept)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 兼容旧调用:返回第一个方向词(没提就是 null)。新代码用 directionsOf。 */
|
|
145
|
+
export function directionOf(text) {
|
|
146
|
+
const set = directionsOf(text)
|
|
147
|
+
for (const item of set) return item
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 两个候选该不该合并。
|
|
153
|
+
* 相似 + 同向 → 合并(只合并证据,状态各归各)
|
|
154
|
+
* 相似 + 反向 / 一边有一边没有 → 冲突,不许自动合并
|
|
155
|
+
*/
|
|
156
|
+
export function compareCandidate(incoming, existing) {
|
|
157
|
+
const score = similarity(incoming, existing)
|
|
158
|
+
if (score < 0.5) return { action: 'new', score }
|
|
159
|
+
|
|
160
|
+
const negA = NEGATION.test(incoming)
|
|
161
|
+
const negB = NEGATION.test(existing)
|
|
162
|
+
const mapA = directionMap(incoming)
|
|
163
|
+
const mapB = directionMap(existing)
|
|
164
|
+
|
|
165
|
+
// 同一组上双方都表了态、且方向相反 —— 这是真冲突
|
|
166
|
+
for (const [group, side] of mapA) {
|
|
167
|
+
if (mapB.has(group) && mapB.get(group) !== side) return { action: 'conflict', score }
|
|
168
|
+
}
|
|
169
|
+
// 一边提了方向、另一边完全没提 —— 也不自动合并
|
|
170
|
+
const oneSided = (mapA.size === 0) !== (mapB.size === 0)
|
|
171
|
+
if (negA !== negB || oneSided) return { action: 'conflict', score }
|
|
172
|
+
return { action: 'merge', score }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ------------------------------ 事件判定与提交顺序 ------------------------------ */
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 这条事件是不是「用户亲自打的字」。
|
|
179
|
+
* 两个条件都要满足:事件类型是 user/message,且消息自带 source.kind === 'user'。
|
|
180
|
+
* 只判其一不够 —— 注入的内容也可能走 user 通道,但 source.kind 不是 user。
|
|
181
|
+
*/
|
|
182
|
+
export function isUserAuthored(event) {
|
|
183
|
+
if (event === null || typeof event !== 'object') return false
|
|
184
|
+
if (event.type !== 'user/message') return false
|
|
185
|
+
const kind = event.data?.source?.kind
|
|
186
|
+
return kind === 'user'
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 这个会话是不是子代理(委派)会话。
|
|
191
|
+
*
|
|
192
|
+
* 实测(2026-11-03,会话 8f5832a2…,seq 9):委派消息也是 user/message + source.kind === 'user',
|
|
193
|
+
* 光看事件分不出来 —— 那是父 agent 写的任务书,不是这个人说的话。
|
|
194
|
+
* 可靠判据在会话头里(SessionHeader):origin === 'subagent',且 delegationDepth > 0。
|
|
195
|
+
* 用会话粒度判断,不用正文标记:别的插件以后往注入里塞什么标记都不影响这里。
|
|
196
|
+
*/
|
|
197
|
+
export function isDelegatedSession(header) {
|
|
198
|
+
if (header === null || typeof header !== 'object') return false
|
|
199
|
+
if (header.origin === 'subagent') return true
|
|
200
|
+
return Number(header.delegationDepth ?? 0) > 0
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 这条消息里已经有候选落在 facts 里了吗(按 会话#seq 判,重放时用来兜底去重)。 */
|
|
204
|
+
export function alreadyCaptured(facts, source) {
|
|
205
|
+
// 除了初次写入时记的 source,还要看证据链:合并只追加 evidence、不改 source,
|
|
206
|
+
// 只看 source 的话,游标保存失败后重放会把同一条消息算成第二次出现,次数无限涨。
|
|
207
|
+
return (facts ?? []).some(
|
|
208
|
+
(fact) => fact.source === source || (Array.isArray(fact.evidence) && fact.evidence.includes(source)),
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 提交一次观察:先写候选,成功了才推进游标。
|
|
214
|
+
*
|
|
215
|
+
* 顺序不能反 —— 先推进游标再写候选,写失败就等于这条消息永久丢了。
|
|
216
|
+
* 如果写成功但游标保存失败,下次会重放这一条:此时用 options.facts() 现读一遍
|
|
217
|
+
* facts(不传就只能靠调用方在 write 里自己判 alreadyCaptured)。
|
|
218
|
+
*
|
|
219
|
+
* options.facts: () => facts[],每次调用都现读,不要传缓存快照。
|
|
220
|
+
*/
|
|
221
|
+
export async function commitObservation(observation, write, options = {}) {
|
|
222
|
+
const { sessionId, seq } = observation ?? {}
|
|
223
|
+
if (!sessionId) return { ok: false, error: '缺少 sessionId' }
|
|
224
|
+
// 只收真数字或纯数字字符串:Number(null) 会变 0,Number('') 也会变 0,这两种必须挡掉
|
|
225
|
+
const text = typeof seq === 'number' ? String(seq) : typeof seq === 'string' ? seq.trim() : ''
|
|
226
|
+
const value = text === '' || /^[0-9]+$/.test(text) === false ? NaN : Number(text)
|
|
227
|
+
if (!Number.isSafeInteger(value)) {
|
|
228
|
+
return { ok: false, error: 'seq 必须是非负整数:' + String(seq) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 只跳过「确实处理过」的:游标记着高水位和它下面漏过的空洞,
|
|
232
|
+
// 迟到的消息落在空洞里就照样处理;真正的重复由下面的 source 判定兜住。
|
|
233
|
+
if (alreadySeen(sessionId, value)) return { ok: true, skipped: 'cursor' }
|
|
234
|
+
|
|
235
|
+
const source = `${sessionId}#${value}`
|
|
236
|
+
if (typeof options.facts === 'function') {
|
|
237
|
+
const facts = options.facts()
|
|
238
|
+
if (alreadyCaptured(facts, source)) {
|
|
239
|
+
await markSeenLocked(sessionId, value)
|
|
240
|
+
return { ok: true, skipped: 'source' }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const written = await write(observation)
|
|
245
|
+
try {
|
|
246
|
+
await markSeenLocked(sessionId, value)
|
|
247
|
+
} catch (error) {
|
|
248
|
+
return { ok: false, written, error: '候选已写入,但游标保存失败:' + String(error?.message ?? error) }
|
|
249
|
+
}
|
|
250
|
+
return { ok: true, skipped: null, written }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* ------------------------------ 游标(幂等) ------------------------------ */
|
|
254
|
+
|
|
255
|
+
export class CursorCorruptError extends Error {
|
|
256
|
+
constructor(file, backup) {
|
|
257
|
+
super(`游标文件损坏,已备份到 ${backup},请人工确认后再继续:${file}`)
|
|
258
|
+
this.name = 'CursorCorruptError'
|
|
259
|
+
this.file = file
|
|
260
|
+
this.backup = backup
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function cursorPath() {
|
|
265
|
+
return join(memoryRoot(), 'capture-cursor.json')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isPlainObject(value) {
|
|
269
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 每个会话最多记多少个空洞:超过就只留最近的,老空洞干脆当作没处理过(重判一次没害处)。 */
|
|
273
|
+
export const HOLE_WINDOW = 500
|
|
274
|
+
|
|
275
|
+
/** 游标里一条记录的两种形态归一化;认不出来返回 null(调用方当成损坏)。 */
|
|
276
|
+
export function normalizeCursorEntry(raw) {
|
|
277
|
+
if (Number.isSafeInteger(raw) && raw >= 0) return { high: raw, holes: [] }
|
|
278
|
+
if (!isPlainObject(raw)) return null
|
|
279
|
+
const high = typeof raw.high === 'number' ? raw.high : NaN
|
|
280
|
+
if (!Number.isSafeInteger(high) || high < 0) return null
|
|
281
|
+
const holes = Array.isArray(raw.holes)
|
|
282
|
+
? raw.holes.filter((item) => Number.isSafeInteger(item) && item >= 0 && item < high)
|
|
283
|
+
: []
|
|
284
|
+
return { high, holes: holes.slice(-HOLE_WINDOW) }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function backupBrokenCursor(file) {
|
|
288
|
+
const backup = `${file}.corrupt-${Date.now()}`
|
|
289
|
+
try {
|
|
290
|
+
renameSync(file, backup)
|
|
291
|
+
} catch {
|
|
292
|
+
throw new CursorCorruptError(file, '(备份失败)')
|
|
293
|
+
}
|
|
294
|
+
throw new CursorCorruptError(file, backup)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 读游标。
|
|
299
|
+
* - 文件不存在:首次运行,返回 {}
|
|
300
|
+
* - 结构不对:不是普通对象、或某个会话的值既不是旧的整数也不是新结构 → 同样是损坏
|
|
301
|
+
* 损坏一律「备份 + 抛错」,不静默从头来(重放历史比停一下更糟)
|
|
302
|
+
*
|
|
303
|
+
* 每个会话一条记录,两种形态都认:
|
|
304
|
+
* { "<会话>": 12 } 旧格式:只有高水位
|
|
305
|
+
* { "<会话>": { high: 12, holes: [7, 9] } } 新格式:高水位 + 它下面漏过的 seq
|
|
306
|
+
* 只存高水位是答不出「seq=7 到底处理过没有」的 —— 迟到的消息会被永久丢掉。
|
|
307
|
+
*/
|
|
308
|
+
export function readCursor() {
|
|
309
|
+
const file = cursorPath()
|
|
310
|
+
if (!existsSync(file)) return {}
|
|
311
|
+
const raw = readFileSync(file, 'utf8')
|
|
312
|
+
|
|
313
|
+
let data = null
|
|
314
|
+
try {
|
|
315
|
+
data = JSON.parse(raw)
|
|
316
|
+
} catch {
|
|
317
|
+
backupBrokenCursor(file)
|
|
318
|
+
}
|
|
319
|
+
if (!isPlainObject(data)) backupBrokenCursor(file)
|
|
320
|
+
const normalized = {}
|
|
321
|
+
for (const [key, value] of Object.entries(data)) {
|
|
322
|
+
if (key.trim() === '') backupBrokenCursor(file)
|
|
323
|
+
const entry = normalizeCursorEntry(value)
|
|
324
|
+
if (entry === null) backupBrokenCursor(file)
|
|
325
|
+
normalized[key] = entry
|
|
326
|
+
}
|
|
327
|
+
return normalized
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 这条消息处理过了吗?用 seq 判断,不靠时间戳。
|
|
332
|
+
*
|
|
333
|
+
* 判据不是「小于高水位就算处理过」——那会把迟到的消息一起吞掉。
|
|
334
|
+
* 真正处理过 = 等于/低于高水位,**且不落在空洞里**。
|
|
335
|
+
*/
|
|
336
|
+
export function alreadySeen(sessionId, seq, cursor = readCursor()) {
|
|
337
|
+
const entry = normalizeCursorEntry(cursor?.[sessionId])
|
|
338
|
+
// 没记录过就是「还没处理」;用 -1 而不是 0,否则 seq=0 的第一条会被误判成已处理
|
|
339
|
+
if (entry === null) return false
|
|
340
|
+
const value = Number(seq)
|
|
341
|
+
if (!Number.isSafeInteger(value) || value < 0) return false
|
|
342
|
+
if (value > entry.high) return false
|
|
343
|
+
return !entry.holes.includes(value)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* ------------------------------ 两把锁 ------------------------------ */
|
|
347
|
+
|
|
348
|
+
/** 游标文件的读—改—写必须串起来,否则两个会话并发写会互相覆盖。 */
|
|
349
|
+
let cursorChain = Promise.resolve()
|
|
350
|
+
|
|
351
|
+
/** 会话处理链:同一会话的事件严格按到达顺序串行,不同会话互不阻塞。 */
|
|
352
|
+
const sessionChains = new Map()
|
|
353
|
+
|
|
354
|
+
/** 把一段任务排进「游标锁」里执行(全局串行,只保护读改写这一小段)。 */
|
|
355
|
+
export function withCursorLock(task) {
|
|
356
|
+
const run = cursorChain.then(task, task)
|
|
357
|
+
cursorChain = run.then(
|
|
358
|
+
() => undefined,
|
|
359
|
+
() => undefined,
|
|
360
|
+
)
|
|
361
|
+
return run
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 把一段任务排进某个会话的串行链(会话内保序,没有全局串行)。 */
|
|
365
|
+
export function enqueue(sessionId, task) {
|
|
366
|
+
const key = String(sessionId ?? '')
|
|
367
|
+
const previous = sessionChains.get(key) ?? Promise.resolve()
|
|
368
|
+
const run = previous.then(task, task)
|
|
369
|
+
const settled = run.then(
|
|
370
|
+
() => undefined,
|
|
371
|
+
() => undefined,
|
|
372
|
+
)
|
|
373
|
+
sessionChains.set(key, settled)
|
|
374
|
+
settled.then(() => {
|
|
375
|
+
if (sessionChains.get(key) === settled) sessionChains.delete(key)
|
|
376
|
+
})
|
|
377
|
+
return run
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 同步写游标:read → 改 → write 全在一段同步代码里,单个 tick 内不会被别的会话插进来。
|
|
382
|
+
* 异步路径请用 markSeenLocked(),它会把这小段排进游标锁。
|
|
383
|
+
*
|
|
384
|
+
* 三种情况都要记:
|
|
385
|
+
* 新会话 → 记高水位,并把之前一个窗口内的 seq 全记成空洞(插件可能中途加载,
|
|
386
|
+
* 那之前的消息我们确实没处理过;真被重放时靠 source 去重兜底)
|
|
387
|
+
* seq 往前跳 → 高水位拔高,中间没见过的 seq 记成空洞(迟到时还要处理)
|
|
388
|
+
* 迟到的空洞补上 → 从空洞里划掉,高水位不动
|
|
389
|
+
*/
|
|
390
|
+
export function markSeen(sessionId, seq) {
|
|
391
|
+
const cursor = readCursor()
|
|
392
|
+
const entry = normalizeCursorEntry(cursor[sessionId])
|
|
393
|
+
const value = Number(seq)
|
|
394
|
+
if (!Number.isSafeInteger(value) || value < 0) return cursor
|
|
395
|
+
|
|
396
|
+
if (entry === null) {
|
|
397
|
+
// 第一次见到这个会话:它过去的 seq 我们一条都没处理过(插件可能中途才加载),
|
|
398
|
+
// 所以把它们记成空洞 —— 真被重放时照样处理,重复由 source 兜住。只留最近一个窗口。
|
|
399
|
+
const holes = []
|
|
400
|
+
for (let item = Math.max(0, value - HOLE_WINDOW); item < value; item += 1) holes.push(item)
|
|
401
|
+
cursor[sessionId] = { high: value, holes }
|
|
402
|
+
} else if (value > entry.high) {
|
|
403
|
+
const from = Math.max(entry.high + 1, value - HOLE_WINDOW)
|
|
404
|
+
const holes = [...entry.holes]
|
|
405
|
+
for (let item = from; item < value; item += 1) holes.push(item)
|
|
406
|
+
cursor[sessionId] = { high: value, holes: holes.sort((a, b) => a - b).slice(-HOLE_WINDOW) }
|
|
407
|
+
} else if (entry.holes.includes(value)) {
|
|
408
|
+
cursor[sessionId] = { high: entry.high, holes: entry.holes.filter((item) => item !== value) }
|
|
409
|
+
} else {
|
|
410
|
+
return cursor
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
mkdirSync(memoryRoot(), { recursive: true })
|
|
414
|
+
writeJsonAtomic(cursorPath(), cursor)
|
|
415
|
+
return cursor
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** 某个会话的高水位(没有记录返回 -1)。给体检与测试用,别自己去拆 entries。 */
|
|
419
|
+
export function cursorSeq(cursor, sessionId) {
|
|
420
|
+
const entry = normalizeCursorEntry(cursor?.[sessionId])
|
|
421
|
+
return entry === null ? -1 : entry.high
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** 某个会话还欠着多少条没处理(空洞数)。 */
|
|
425
|
+
export function cursorHoles(cursor, sessionId) {
|
|
426
|
+
const entry = normalizeCursorEntry(cursor?.[sessionId])
|
|
427
|
+
return entry === null ? 0 : entry.holes.length
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** 异步路径用的写游标:排进游标锁再写,避免跨会话并发覆盖。 */
|
|
431
|
+
export function markSeenLocked(sessionId, seq) {
|
|
432
|
+
return withCursorLock(() => markSeen(sessionId, seq))
|
|
433
|
+
}
|