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/lib/profile.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 画像层(P7)
3
+ *
4
+ * 定位:**已审核记忆的聚合视图**,不是新的事实库。
5
+ * - 只读 status === 'approved' 的 facts;candidate / retired 不参与
6
+ * - 不产生新事实,每一句都能回指到具体 fact id
7
+ * - 可重建:facts 变了就重算,画像本身从不作为事实来源
8
+ * - 同一标签下多条时不擅自归纳,只取最新并标注「另有 N 条」
9
+ */
10
+
11
+ const DEFAULT_MAX_CHARS = 80
12
+ const DEFAULT_PIECE_MAX = 60
13
+
14
+ /** facts 的时间戳只有日期(YYYY-MM-DD),比大小用字符串就够。 */
15
+ function stamp(fact) {
16
+ const value = String(fact?.updated ?? fact?.created ?? '')
17
+ return value === '' ? '0000-00-00' : value
18
+ }
19
+
20
+ /**
21
+ * @param {Array<object>} facts 全部事实(内部会自己筛 approved)
22
+ * @param {{maxChars?: number}} options
23
+ * @returns {{text: string, parts: Array<{tag: string, factId: string, text: string}>, conflicts: Array<{tag: string, count: number}>, chars: number, sources: string[]}}
24
+ */
25
+ export function buildProfile(facts, options = {}) {
26
+ const maxChars = Number(options.maxChars ?? DEFAULT_MAX_CHARS) || DEFAULT_MAX_CHARS
27
+ // 单句上限:画像是一句话的自我认知,不是把长决定整段落搬进来。
28
+ // 超过这个长度的(比如「上下文快照存哪」这种操作性决定)留在结论段,别挤掉真画像。
29
+ const pieceMax = Number(options.pieceMax ?? DEFAULT_PIECE_MAX) || DEFAULT_PIECE_MAX
30
+ /* channel === 'env' 是环境事实(机器路径、账号、坑),不是「这个人是谁」,不进画像 */
31
+ const approved = (Array.isArray(facts) ? facts : []).filter(
32
+ (fact) => fact?.status === 'approved' && (fact?.channel ?? 'rel') !== 'env',
33
+ )
34
+
35
+ /* 按第一个标签分组;没有标签的归到「其他」 */
36
+ const groups = new Map()
37
+ for (const fact of approved) {
38
+ const raw = Array.isArray(fact.tags) ? fact.tags : []
39
+ const tag = String(raw[0] ?? '其他').trim() || '其他'
40
+ const bucket = groups.get(tag) ?? []
41
+ bucket.push(fact)
42
+ groups.set(tag, bucket)
43
+ }
44
+
45
+ const parts = []
46
+ const conflicts = []
47
+ const ordered = [...groups.entries()].sort((a, b) => stamp(b[1][0]) < stamp(a[1][0]) ? -1 : 1)
48
+
49
+ let text = ''
50
+ for (const [tag, bucket] of ordered) {
51
+ const latest = bucket.slice().sort((a, b) => (stamp(a) < stamp(b) ? 1 : stamp(a) > stamp(b) ? -1 : 0))[0]
52
+ const body = String(latest.text ?? '').trim()
53
+ if (body === '') continue
54
+ if (body.length > pieceMax) continue
55
+ const piece = `${tag}:${body}`
56
+ const candidate = text === '' ? piece : `${text};${piece}`
57
+ if (candidate.length > maxChars) continue
58
+ text = candidate
59
+ parts.push({ tag, factId: latest.id, text: body })
60
+ if (bucket.length > 1) conflicts.push({ tag, count: bucket.length - 1 })
61
+ }
62
+
63
+ return {
64
+ text,
65
+ parts,
66
+ conflicts,
67
+ chars: text.length,
68
+ sources: parts.map((part) => part.factId),
69
+ }
70
+ }
package/lib/store.js ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * selfnote 的存储层:把「AI 写给自己看的叙述」落成纯文本,零依赖。
3
+ *
4
+ * 目录(默认 <DSH_HOME>/selfnote,可用 DSH_SELFNOTE_DIR 覆盖):
5
+ * notes.md 当前生效的自述,一行一条,最新的在最后
6
+ * archive/YYYY-MM.md 被挤出容量的旧条目,只追加不改写
7
+ *
8
+ * 为什么用纯文本:用户能直接打开看、能 grep、能进 git;
9
+ * 插件崩了也不影响内容,删掉文件就是重置。
10
+ */
11
+
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import { writeAtomic, writeJsonAtomic } from './atomic.js'
16
+
17
+ /** 当前生效内容的上限(字符)。默认 1600 字,约 1.1k token。 */
18
+ export const DEFAULT_MAX_CHARS = 1600
19
+ /** 条数上限,和字符上限共同生效。 */
20
+ export const DEFAULT_MAX_ENTRIES = 40
21
+ /** 单条自述的硬上限,防止一次写入撑爆。 */
22
+ export const MAX_ENTRY_CHARS = 600
23
+
24
+ function dshHome() {
25
+ const fromEnv = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
26
+ return fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh')
27
+ }
28
+
29
+ export function selfnoteRoot() {
30
+ const fromEnv = typeof process.env.DSH_SELFNOTE_DIR === 'string' ? process.env.DSH_SELFNOTE_DIR.trim() : ''
31
+ return fromEnv.length > 0 ? fromEnv : join(dshHome(), 'selfnote')
32
+ }
33
+
34
+ export function notesPath() {
35
+ return join(selfnoteRoot(), 'notes.md')
36
+ }
37
+
38
+ export function archiveDir() {
39
+ return join(selfnoteRoot(), 'archive')
40
+ }
41
+
42
+ function ensureDir(dir) {
43
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
44
+ }
45
+
46
+ export function today() {
47
+ const now = new Date()
48
+ const pad = (n) => String(n).padStart(2, '0')
49
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
50
+ }
51
+
52
+ /** 把 notes.md 解析成 [{ date, text }],忽略空行与注释。 */
53
+ export function parseNotes(raw) {
54
+ const out = []
55
+ for (const line of String(raw ?? '').split(/\r?\n/)) {
56
+ const trimmed = line.trim()
57
+ if (trimmed === '' || trimmed.startsWith('#')) continue
58
+ const m = trimmed.match(/^[-*]\s+(\d{4}-\d{2}-\d{2})?\s*(.*)$/)
59
+ if (!m) continue
60
+ const text = (m[2] ?? '').trim()
61
+ if (text === '') continue
62
+ out.push({ date: m[1] ?? '', text })
63
+ }
64
+ return out
65
+ }
66
+
67
+ /** 读当前生效的自述。 */
68
+ export function readNotes() {
69
+ const file = notesPath()
70
+ if (!existsSync(file)) return []
71
+ return parseNotes(readFileSync(file, 'utf8'))
72
+ }
73
+
74
+ /** 渲染成注入/展示用的文本(最新在后)。 */
75
+ export function renderNotes(entries) {
76
+ return entries.map((entry) => `- ${entry.date ? `${entry.date} ` : ''}${entry.text}`).join('\n')
77
+ }
78
+
79
+ /** 写回当前生效的自述(整份重写,内容不超过上限)。 */
80
+ export function writeNotes(entries) {
81
+ ensureDir(selfnoteRoot())
82
+ const body = entries.length === 0 ? '' : `${renderNotes(entries)}\n`
83
+ // 原子写:notes.md 是注入来源,被截断等于每回合注入半句话
84
+ writeAtomic(notesPath(), body)
85
+ }
86
+
87
+ /**
88
+ * 按容量裁剪:从最旧的一端丢,直到同时满足条数与字符上限。
89
+ * 返回 { kept, overflow },overflow 是要归档的旧条目(旧的在前)。
90
+ */
91
+ export function trim(entries, { maxChars = DEFAULT_MAX_CHARS, maxEntries = DEFAULT_MAX_ENTRIES } = {}) {
92
+ const kept = [...entries]
93
+ const overflow = []
94
+ let total = renderNotes(kept).length
95
+ while (kept.length > maxEntries || (total > maxChars && kept.length > 1)) {
96
+ const dropped = kept.shift()
97
+ if (!dropped) break
98
+ overflow.push(dropped)
99
+ total = renderNotes(kept).length
100
+ }
101
+ return { kept, overflow }
102
+ }
103
+
104
+ /** 把被挤出来的条目追加到当月归档文件。 */
105
+ export function archive(entries) {
106
+ if (entries.length === 0) return ''
107
+ ensureDir(archiveDir())
108
+ const month = entries[0]?.date?.slice(0, 7) || today().slice(0, 7)
109
+ const file = join(archiveDir(), `${month}.md`)
110
+ // 归档文件小(每月一个),整份读回再原子写,比 append 更能扛「写到一半被杀」
111
+ const head = existsSync(file) ? readFileSync(file, 'utf8') : `# 自述归档 ${month}\n\n`
112
+ writeAtomic(file, `${head}${renderNotes(entries)}\n`)
113
+ return file
114
+ }
115
+
116
+ /** 追加一条自述并裁剪归档。返回 { kept, archived, notesFile }。 */
117
+ export function appendNote(text, limits) {
118
+ const clean = String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_ENTRY_CHARS)
119
+ if (clean === '') throw new Error('自述内容不能为空')
120
+ const entries = readNotes()
121
+ entries.push({ date: today(), text: clean })
122
+ const { kept, overflow } = trim(entries, limits)
123
+ // 先归档、再改 notes.md:反过来的话,归档一抛错(磁盘满、被占用),
124
+ // 被挤出预算的那几条既已从 notes.md 消失、又没进归档,等于静默丢掉。
125
+ // 万一 notes.md 写失败,最坏是归档里有重复,还能人工删 —— 丢内容没法补。
126
+ const archivedFile = archive(overflow)
127
+ writeNotes(kept)
128
+ return { kept: kept.length, archived: overflow.length, archivedFile, notesFile: notesPath() }
129
+ }
130
+
131
+ /**
132
+ * 改一条(0 = 最旧,和 readNotes 的顺序一致)。
133
+ * 日期保留原样:改的是措辞,不是「今天又写了一条」。
134
+ * 改长了照样按容量裁剪,被挤出去的进归档 —— 和追加走同一条规则。
135
+ */
136
+ export function updateNote(index, text, limits) {
137
+ const entries = readNotes()
138
+ const at = Number(index)
139
+ if (!Number.isInteger(at) || at < 0 || at >= entries.length) {
140
+ // 列表可能是几秒前拉的:给一句能照着做的话,而不是「非法参数」
141
+ throw new Error('没有这一条了(可能刚被改过),刷新一下再试')
142
+ }
143
+ const clean = String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_ENTRY_CHARS)
144
+ if (clean === '') throw new Error('自述内容不能为空')
145
+ if (clean === entries[at].text) return { kept: entries.length, archived: 0, unchanged: true, notesFile: notesPath() }
146
+ entries[at] = { date: entries[at].date || today(), text: clean }
147
+ const { kept, overflow } = trim(entries, limits)
148
+ const archivedFile = archive(overflow)
149
+ writeNotes(kept)
150
+ return { kept: kept.length, archived: overflow.length, archivedFile, unchanged: false, notesFile: notesPath() }
151
+ }
152
+
153
+ /** 删一条:先归档再摘除,删掉的东西仍然找得回来(archive/YYYY-MM.md)。 */
154
+ export function deleteNote(index) {
155
+ const entries = readNotes()
156
+ const at = Number(index)
157
+ if (!Number.isInteger(at) || at < 0 || at >= entries.length) {
158
+ throw new Error('没有这一条了(可能刚被删过),刷新一下再试')
159
+ }
160
+ const [dropped] = entries.splice(at, 1)
161
+ const archivedFile = archive([dropped])
162
+ writeNotes(entries)
163
+ return { kept: entries.length, removed: dropped, archivedFile, notesFile: notesPath() }
164
+ }
165
+
166
+ /** 统计:生效条数、字符数、归档文件数。 */
167
+ export function stats() {
168
+ const entries = readNotes()
169
+ const dir = archiveDir()
170
+ let archiveFiles = 0
171
+ if (existsSync(dir)) {
172
+ for (const name of readdirSync(dir)) {
173
+ if (name.endsWith('.md')) archiveFiles += 1
174
+ }
175
+ }
176
+ return {
177
+ entries: entries.length,
178
+ chars: renderNotes(entries).length,
179
+ archiveFiles,
180
+ notesFile: notesPath(),
181
+ // 上次真正写进 notes.md 的时刻:条目里只存日期(YYYY-MM-DD),看不出「几小时前」,
182
+ // 所以用文件 mtime —— 它就是写入那一刻,设置页拿这个算「上次写入:N 小时前」。
183
+ lastWrite: lastWriteAt(),
184
+ }
185
+ }
186
+
187
+ function lastWriteAt() {
188
+ try {
189
+ const info = statSync(notesPath())
190
+ if (info.size === 0) return null
191
+ return info.mtime.toISOString()
192
+ } catch {
193
+ return null
194
+ }
195
+ }
196
+
197
+ /* ------------------------------ 配置 ------------------------------ */
198
+
199
+ export function configPath() {
200
+ return join(selfnoteRoot(), 'config.json')
201
+ }
202
+
203
+ /** 读配置;文件不存在或坏掉都返回 {},由调用方补默认值。 */
204
+ export function readConfig() {
205
+ const file = configPath()
206
+ if (!existsSync(file)) return {}
207
+ try {
208
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
209
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
210
+ } catch {
211
+ return {}
212
+ }
213
+ }
214
+
215
+ /** 整块写入配置(保留未知字段由调用方决定)。 */
216
+ export function writeConfig(next) {
217
+ ensureDir(selfnoteRoot())
218
+ // 原子写:config.json 被截断会让插件静默回落默认值,等于悄悄改行为
219
+ writeJsonAtomic(configPath(), next ?? {})
220
+ return configPath()
221
+ }
222
+
223
+ /* ------------------------------ 准入 token ------------------------------ */
224
+
225
+ export function authTokenPath() {
226
+ return join(selfnoteRoot(), 'auth-token')
227
+ }
228
+
229
+ /** 读本地 token;没有或空文件都返回 ''。 */
230
+ export function readAuthToken() {
231
+ try {
232
+ return readFileSync(authTokenPath(), 'utf8').trim()
233
+ } catch {
234
+ return ''
235
+ }
236
+ }
237
+
238
+ /** 写本地 token,尽量收成 600(Windows 上 chmod 基本是空操作,不报错即可)。 */
239
+ export function writeAuthToken(value) {
240
+ ensureDir(selfnoteRoot())
241
+ const file = authTokenPath()
242
+ writeAtomic(file, `${String(value).trim()}\n`)
243
+ try {
244
+ chmodSync(file, 0o600)
245
+ } catch {
246
+ /* 平台不支持就算了,token 本身不是密钥材料 */
247
+ }
248
+ return file
249
+ }
250
+
251
+ /** 清空当前生效的自述:全部进归档,不丢内容。 */export function clearNotes() {
252
+ const entries = readNotes()
253
+ if (entries.length === 0) return { cleared: 0, archivedFile: '' }
254
+ const archivedFile = archive(entries)
255
+ writeNotes([])
256
+ return { cleared: entries.length, archivedFile }
257
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * 迁移:把一份记忆库导出成一个 JSON,在另一台机器上导入。
3
+ *
4
+ * 两条硬规矩:
5
+ * 1. **不覆盖**。同 id 已存在就跳过;正文一样的也跳过(哪怕状态不同,让人自己看着办)。
6
+ * 2. **默认不保留状态**。导入进来的 approved 一律变回 candidate —— 采纳必须由人在这里点一次,
7
+ * 而不是把别人机器上的结论悄悄当成自己的。想原样搬过来,得在界面上勾「保留原状态」。
8
+ *
9
+ * 索引不导出:它记的是磁盘路径与 mtime,搬过去必然过期,导入后重建一次就行。
10
+ */
11
+
12
+ export const BUNDLE_VERSION = 1
13
+ export const BUNDLE_APP = 'dsh-selfnote'
14
+
15
+ const CONFIG_FIELDS = [
16
+ 'enabled',
17
+ 'heading',
18
+ 'maxChars',
19
+ 'maxEntries',
20
+ 'profileEnabled',
21
+ 'profileOverride',
22
+ 'profileMaxChars',
23
+ 'halfLifeDays',
24
+ 'noteGuide',
25
+ ]
26
+
27
+ function isObject(value) {
28
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
29
+ }
30
+
31
+ function textOf(value) {
32
+ return typeof value === 'string' ? value.trim() : ''
33
+ }
34
+
35
+ /** 导出:只挑能带走的东西,配置只挑用户可调的那几项。 */
36
+ export function buildBundle({ notes = [], facts = [], habits = [], config = {}, now = new Date() } = {}) {
37
+ const safeConfig = {}
38
+ for (const field of CONFIG_FIELDS) {
39
+ if (config[field] !== undefined) safeConfig[field] = config[field]
40
+ }
41
+ return {
42
+ app: BUNDLE_APP,
43
+ v: BUNDLE_VERSION,
44
+ exportedAt: now.toISOString(),
45
+ counts: { notes: notes.length, facts: facts.length, habits: habits.length },
46
+ notes: notes.map((entry) => ({ date: entry.date ?? '', text: entry.text ?? '' })),
47
+ facts: facts.map((fact) => ({ ...fact })),
48
+ habits: habits.map((habit) => ({ ...habit })),
49
+ config: safeConfig,
50
+ }
51
+ }
52
+
53
+ /** 导入前的体检:结构不对就直说,别合并一半。 */
54
+ export function validateBundle(bundle) {
55
+ if (!isObject(bundle)) return '不是 JSON 对象'
56
+ if (bundle.app !== BUNDLE_APP) return `不是本插件导出的文件(app=${String(bundle.app)})`
57
+ const v = Number(bundle.v ?? 0)
58
+ if (!Number.isSafeInteger(v) || v < 1) return '缺少版本号'
59
+ if (v > BUNDLE_VERSION) return `这份文件来自更新的版本(v${v}),本机只认到 v${BUNDLE_VERSION}`
60
+ for (const key of ['notes', 'facts', 'habits']) {
61
+ if (bundle[key] !== undefined && !Array.isArray(bundle[key])) return `${key} 不是数组`
62
+ }
63
+ return null
64
+ }
65
+
66
+ /**
67
+ * 合并:返回「合并后的三份数据 + 账目」。
68
+ * 纯函数,不写盘 —— 写盘由调用方做,失败才好回滚。
69
+ */
70
+ export function mergeBundle(bundle, current = {}, options = {}) {
71
+ const keepStatus = options.keepStatus === true
72
+ const notes = (current.notes ?? []).map((entry) => ({ ...entry }))
73
+ const facts = (current.facts ?? []).map((fact) => ({ ...fact }))
74
+ const habits = (current.habits ?? []).map((habit) => ({ ...habit }))
75
+
76
+ const seenNotes = new Set(notes.map((entry) => textOf(entry.text)))
77
+ const seenFacts = new Set(facts.map((fact) => textOf(fact.text)))
78
+ const factIds = new Set(facts.map((fact) => fact.id))
79
+ const seenHabits = new Set(habits.map((habit) => textOf(habit.text)))
80
+
81
+ const added = { notes: 0, facts: 0, habits: 0 }
82
+ const skipped = { notes: 0, facts: 0, habits: 0, invalid: 0 }
83
+ const demoted = []
84
+ const conflicts = []
85
+
86
+ for (const raw of bundle.facts ?? []) {
87
+ const text = textOf(isObject(raw) ? raw.text : '')
88
+ const id = textOf(isObject(raw) ? raw.id : '')
89
+ if (text === '' || id === '') {
90
+ skipped.invalid += 1
91
+ continue
92
+ }
93
+ if (factIds.has(id)) {
94
+ skipped.facts += 1
95
+ continue
96
+ }
97
+ if (seenFacts.has(text)) {
98
+ skipped.facts += 1
99
+ conflicts.push({ text, reason: 'same-text' })
100
+ continue
101
+ }
102
+ const status = textOf(raw.status) || 'candidate'
103
+ const fact = { ...raw, text }
104
+ if (!keepStatus && status !== 'candidate') {
105
+ fact.status = 'candidate'
106
+ demoted.push(id)
107
+ }
108
+ if (fact.status === 'candidate') {
109
+ delete fact.reviewedAt
110
+ delete fact.retiredAt
111
+ }
112
+ fact.importedFrom = textOf(raw.source) || 'import'
113
+ facts.push(fact)
114
+ factIds.add(id)
115
+ seenFacts.add(text)
116
+ added.facts += 1
117
+ }
118
+
119
+ for (const raw of bundle.notes ?? []) {
120
+ const text = textOf(isObject(raw) ? raw.text : '')
121
+ if (text === '') {
122
+ skipped.invalid += 1
123
+ continue
124
+ }
125
+ if (seenNotes.has(text)) {
126
+ skipped.notes += 1
127
+ continue
128
+ }
129
+ notes.push({ date: textOf(raw.date) || '', text })
130
+ seenNotes.add(text)
131
+ added.notes += 1
132
+ }
133
+
134
+ for (const raw of bundle.habits ?? []) {
135
+ const text = textOf(isObject(raw) ? raw.text : '')
136
+ if (text === '') {
137
+ skipped.invalid += 1
138
+ continue
139
+ }
140
+ if (seenHabits.has(text)) {
141
+ skipped.habits += 1
142
+ continue
143
+ }
144
+ habits.push({
145
+ text,
146
+ status: textOf(raw.status) === 'active' ? 'active' : 'retired',
147
+ tag: textOf(raw.tag) || '',
148
+ })
149
+ seenHabits.add(text)
150
+ added.habits += 1
151
+ }
152
+
153
+ return { notes, facts, habits, added, skipped, demoted, conflicts, keepStatus }
154
+ }
package/lib/warm.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * 温层(P3)
3
+ *
4
+ * 定位:**带指针的摘要**,介于「每回合注入的热记忆」和「按需检索的原始会话」之间。
5
+ * - 一条摘要必须指回原文(sessionId + 行号)。没有指针的摘要不许写进来 ——
6
+ * 否则它就是一段无法核对的自述,跟幻觉没区别。
7
+ * - 可以吊销(status: void),不能删除:删了就不知道「我曾经这么以为过」。
8
+ * - 不进注入。只有 memory_search 命中时才浮出来,所以条数可以很多。
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+
14
+ import { memoryRoot, readFacts, tokenize } from './memory.js'
15
+ import { writeAtomic } from './atomic.js'
16
+
17
+ const MAX_TEXT = 300
18
+ const MAX_POINTERS = 8
19
+
20
+ export const warmPath = () => join(memoryRoot(), 'warm.jsonl')
21
+
22
+ export function readWarm() {
23
+ const file = warmPath()
24
+ if (!existsSync(file)) return []
25
+ const out = []
26
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
27
+ if (line.trim() === '') continue
28
+ try {
29
+ const item = JSON.parse(line)
30
+ if (item && typeof item === 'object') out.push(item)
31
+ } catch {
32
+ // 坏行跳过:一条摘要读不出来,不该让整层都废掉
33
+ }
34
+ }
35
+ return out
36
+ }
37
+
38
+ function writeWarm(items) {
39
+ // 目录可能还不存在(第一次写温层)
40
+ mkdirSync(memoryRoot(), { recursive: true })
41
+ writeAtomic(warmPath(), items.map((item) => JSON.stringify(item)).join('\n') + (items.length > 0 ? '\n' : ''))
42
+ }
43
+
44
+ function validPointer(pointer) {
45
+ return (
46
+ pointer &&
47
+ typeof pointer === 'object' &&
48
+ String(pointer.sessionId ?? '').trim() !== '' &&
49
+ Number.isSafeInteger(Number(pointer.index)) &&
50
+ Number(pointer.index) >= 0
51
+ )
52
+ }
53
+
54
+ /** 写一条摘要。没指针直接拒 —— 这条规矩是温层唯一的护栏。 */
55
+ export function writeSummary(input = {}) {
56
+ const text = String(input.text ?? '').trim()
57
+ if (text === '') return { ok: false, error: 'text 不能为空' }
58
+ if (text.length > MAX_TEXT) return { ok: false, error: `一条摘要最多 ${MAX_TEXT} 字,拆成两条` }
59
+
60
+ const raw = Array.isArray(input.pointers) ? input.pointers : []
61
+ const pointers = raw
62
+ .filter(validPointer)
63
+ .map((pointer) => ({ sessionId: String(pointer.sessionId).trim(), index: Number(pointer.index) }))
64
+ .slice(0, MAX_POINTERS)
65
+ if (pointers.length === 0) {
66
+ return { ok: false, error: '摘要必须带指针(sessionId + 行号)—— 核不到原文的摘要,就是一段无法证伪的自述' }
67
+ }
68
+
69
+ const items = readWarm()
70
+ const at = new Date().toISOString()
71
+ const item = {
72
+ id: 'w' + Math.random().toString(36).slice(2, 10),
73
+ text,
74
+ tags: Array.isArray(input.tags) ? input.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 6) : [],
75
+ pointers,
76
+ status: 'active',
77
+ created: at,
78
+ }
79
+ items.push(item)
80
+ writeWarm(items)
81
+ return { ok: true, item, total: items.length }
82
+ }
83
+
84
+ /** 吊销:不删,只标 void —— 以后还能查到「当时是这么以为的」。 */
85
+ export function voidSummary(id, reason = '') {
86
+ const items = readWarm()
87
+ const item = items.find((entry) => entry.id === id)
88
+ if (!item) return { ok: false, error: '没有这条摘要' }
89
+ item.status = 'void'
90
+ item.voidedAt = new Date().toISOString()
91
+ item.voidReason = String(reason ?? '')
92
+ writeWarm(items)
93
+ return { ok: true, item }
94
+ }
95
+
96
+ function score(text, terms) {
97
+ const lower = String(text ?? '').toLowerCase()
98
+ let matched = 0
99
+ let total = 0
100
+ for (const term of terms) {
101
+ if (lower.includes(term)) {
102
+ matched += 1
103
+ total += 3
104
+ }
105
+ }
106
+ return { matched, total }
107
+ }
108
+
109
+ /**
110
+ * 温层检索:摘要 + 事实(含环境事实的 detail 段落)。
111
+ * 返回的每条都带指针,能顺着回原文核对 —— 这是它跟「让模型凭印象说」的区别。
112
+ */
113
+ export function searchWarm(query, options = {}) {
114
+ const terms = tokenize(query)
115
+ if (terms.length === 0) return { mode: 'warm', total: 0, terms, hits: [] }
116
+ const limit = Math.max(1, Math.min(Number(options.limit ?? 8) || 8, 20))
117
+
118
+ const hits = []
119
+ for (const item of readWarm()) {
120
+ if (item.status === 'void') continue
121
+ const hit = score(item.text + ' ' + (item.tags ?? []).join(' '), terms)
122
+ if (hit.matched === 0) continue
123
+ hits.push({ kind: 'summary', id: item.id, text: item.text, tags: item.tags ?? [], pointers: item.pointers ?? [], matched: hit.matched, scaled: hit.total })
124
+ }
125
+
126
+ for (const fact of readFacts()) {
127
+ const hit = score(fact.text + ' ' + (fact.detail ?? '') + ' ' + (fact.tags ?? []).join(' '), terms)
128
+ if (hit.matched === 0) continue
129
+ hits.push({
130
+ kind: 'fact',
131
+ id: fact.id,
132
+ text: fact.text,
133
+ detail: fact.detail ?? '',
134
+ status: fact.status,
135
+ channel: fact.channel ?? 'rel',
136
+ pointers: fact.citation ? [{ sessionId: fact.citation.sessionId, index: fact.citation.index }] : [],
137
+ matched: hit.matched,
138
+ scaled: hit.total,
139
+ })
140
+ }
141
+
142
+ // 全词命中优先,其次分数
143
+ hits.sort((a, b) => b.matched - a.matched || b.scaled - a.scaled)
144
+ return { mode: 'warm', total: hits.length, terms, hits: hits.slice(0, limit) }
145
+ }
146
+
147
+ export function warmStats() {
148
+ const items = readWarm()
149
+ return {
150
+ total: items.length,
151
+ active: items.filter((item) => item.status !== 'void').length,
152
+ voided: items.filter((item) => item.status === 'void').length,
153
+ withPointers: items.filter((item) => Array.isArray(item.pointers) && item.pointers.length > 0).length,
154
+ }
155
+ }