dsh-anima 0.0.0-stage → 2.2.2

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/decay.mjs ADDED
@@ -0,0 +1,131 @@
1
+ // lib/decay.mjs — 记忆随时间的可见性衰减
2
+ //
3
+ // 为什么单独一个文件:`lib/memory.mjs` 已经 583 行,而衰减是**纯粹按日历算的算术**,
4
+ // 与「怎么存」「怎么裁决」无关。放在一起会让下一个人以为衰减也需要判断语义。
5
+ //
6
+ // ============================ 这一层到底做什么 ============================
7
+ //
8
+ // 只做两件事,都不写盘:
9
+ // ① 算:一条记忆几岁了、有没有超过它这一类的时间窗;
10
+ // ② 用:超窗的在注入行尾部加一个字面标签,并在注入排序里排到最后
11
+ // (预算紧张时它先被挤出去 —— 但记录本身一个字节都不变)。
12
+ //
13
+ // 刻意**不做**的三件事,理由都是同一个「不许替用户下结论」:
14
+ // - 不自动改 state。state 是用户的所有权,「这条还算不算数」只有他能判。
15
+ // - 不自动删除。删除权只属于用户(见 store.mjs 的墓碑设计)。
16
+ // - 不判断内容有没有变旧。代码只知道日期,不知道你的偏好还是不是那个偏好。
17
+ //
18
+ // ============================ 为什么按类型分窗 ============================
19
+ //
20
+ // 四类记忆的失效方式本来就不同(这是用户在 memory 里定的分类,不是我爱发明):
21
+ // 事实 fact —— 改了才覆盖,不随年龄失效。窗口 0 = 永不因时间衰减。
22
+ // 它要防的是「环境变了」,那由 verifyAt 管,不由年龄管。
23
+ // 偏好 preference —— 半衰期很长。用户没改口之前,就该一直当真的用。
24
+ // 立场 stance —— 「我改主意了」时归档成「曾如此」;年龄只是提醒我别把它
25
+ // 当成今天的态度。
26
+ // 事件 event —— 永不失效(它确实发生过),但越快越有用。
27
+ //
28
+ // ============================ 这些天数是我猜的 ============================
29
+ //
30
+ // 540 / 365 / 180 三个数**没有任何数据支撑**,是我按上面的道理随手定的量级。
31
+ // 它们写成常量而不是配置项,是因为在我还没见过你真实的记忆增长速度之前,
32
+ // 把它做成设置页里的三个输入框,只会让你以为「调对了」,而不是「有依据」。
33
+ // 等你实际用上一段时间,觉得某类太早或太晚,再决定要不要提升为配置。
34
+ //
35
+ // ============================ 保守方向:宁可当新 ============================
36
+ //
37
+ // 解析不出日期、日期在未来、记录为空 —— 一律按「新鲜」处理。
38
+ // 理由:把一条其实很新的记忆当成旧的,会实实在在地降低它被注入的机会;
39
+ // 反过来把旧的当新的,最坏结果只是它多占一行,用户看得见、删得掉。
40
+
41
+ /**
42
+ * 每类记忆的时间窗(天)。0 = 永不因时间衰减。
43
+ *
44
+ * 只有**超过**窗口才降级,不是「一天一天地淡出」—— 因为「淡出」需要每轮重算权重,
45
+ * 而权重一旦进入排序,用户就无法解释「为什么这条昨天还在、今天没了」。
46
+ * 阈值制的好处是:跨过窗口那天有一条明确的评论可以说清。
47
+ */
48
+ export const DECAY_WINDOWS_DAYS = Object.freeze({
49
+ fact: 0,
50
+ preference: 540,
51
+ stance: 365,
52
+ event: 180
53
+ })
54
+
55
+ /** 三档。顺序即严重程度,用于排序。 */
56
+ export const DECAY_LEVELS = Object.freeze(['fresh', 'aging', 'stale'])
57
+
58
+ const DAY_MS = 24 * 60 * 60 * 1000
59
+
60
+ /** 窗口的哪一部分算「aging」:过了一半就该开始提醒,不必等到过期。 */
61
+ const AGING_RATIO = 0.5
62
+
63
+ /**
64
+ * 一条记忆从写入到现在过了多少天(向下取整)。
65
+ *
66
+ * 解析不出 / 为空 / 在未来 → 0(当新的处理,见文件头「保守方向」)。
67
+ *
68
+ * @param {object} record 记忆记录
69
+ * @param {Date} now 当前时间
70
+ * @returns {number} 非负整数天数
71
+ */
72
+ export function memoryAgeDays(record, now = new Date()) {
73
+ const captured = Date.parse(String(record?.capturedAt ?? ''))
74
+ if (!Number.isFinite(captured)) return 0
75
+ const current = now instanceof Date ? now.getTime() : Date.parse(String(now))
76
+ if (!Number.isFinite(current)) return 0
77
+ const diff = current - captured
78
+ if (diff <= 0) return 0
79
+ return Math.floor(diff / DAY_MS)
80
+ }
81
+
82
+ /**
83
+ * 算一条记忆的衰减档位。
84
+ *
85
+ * @param {object} record 记忆记录
86
+ * @param {Date} now 当前时间
87
+ * @param {object} [windows] 覆盖默认窗口(测试与将来配置化用)
88
+ * @returns {{type: string, ageDays: number, windowDays: number, level: string, decayed: boolean}}
89
+ * `decayed` 为 true 表示**已超窗**(= level === 'stale'),标签与排序都只看它,
90
+ * 所以调用方不需要自己再判一次 level。窗口为 0 的类型永远是 false。
91
+ */
92
+ export function decayInfo(record, now = new Date(), windows = DECAY_WINDOWS_DAYS) {
93
+ const type = Object.prototype.hasOwnProperty.call(windows, record?.type) ? record.type : 'fact'
94
+ const windowDays = Number(windows[type]) || 0
95
+ const ageDays = memoryAgeDays(record, now)
96
+ // 窗口为 0(事实类)= 不参与时间衰减,永远 fresh。
97
+ if (!windowDays) return { type, ageDays, windowDays: 0, level: 'fresh', decayed: false }
98
+ if (ageDays >= windowDays) return { type, ageDays, windowDays, level: 'stale', decayed: true }
99
+ if (ageDays >= windowDays * AGING_RATIO) return { type, ageDays, windowDays, level: 'aging', decayed: false }
100
+ return { type, ageDays, windowDays, level: 'fresh', decayed: false }
101
+ }
102
+
103
+ /**
104
+ * 超窗时返回加在注入行尾部的标签,否则空串。
105
+ *
106
+ * 为什么事件类用另一句话:事件不会「更新」,它只是离得远了。
107
+ * 同一个标签套在所有类型上,会让人读出一条不成立的事实。
108
+ *
109
+ * 为什么标签里不带天数:天数由紧挨着的写入日期本身就能算出,
110
+ * 而且行内每多一个字都从 4000 字预算里扣(见 config.mjs 的 maxInjectedChars)。
111
+ *
112
+ * @param {object} record 记忆记录
113
+ * @param {Date} now 当前时间
114
+ * @param {object} [windows] 覆盖默认窗口
115
+ * @returns {string} '' | '久未更新' | '较早的事'
116
+ */
117
+ export function decayTag(record, now = new Date(), windows = DECAY_WINDOWS_DAYS) {
118
+ const info = decayInfo(record, now, windows)
119
+ if (!info.decayed) return ''
120
+ return info.type === 'event' ? '较早的事' : '久未更新'
121
+ }
122
+
123
+ /**
124
+ * 排序用的降级权重:没超窗 = 0,超窗 = 1。
125
+ *
126
+ * 只分两档、不做连续权重,是刻意的:连续权重排出来的顺序,用户无法用一句话解释;
127
+ * 「超窗的挪到最后」可以。而「预算紧张时先挤掉谁」这个问题,本来就只需要一个二选一。
128
+ */
129
+ export function decayRank(record, now = new Date(), windows = DECAY_WINDOWS_DAYS) {
130
+ return decayInfo(record, now, windows).decayed ? 1 : 0
131
+ }
package/lib/import.mjs ADDED
@@ -0,0 +1,408 @@
1
+ // lib/import.mjs — 外部 AI 记忆导入(纯函数,无外部依赖)
2
+ //
3
+ // 场景(用户 m13808 拍板 Phase ②):用户从别的 AI 工具里导出一份「使用画像」,
4
+ // 粘贴进来,本插件把它**切成一条条独立记忆**并给出归类建议,落到待审缓冲区。
5
+ //
6
+ // 为什么必须切分而不是整段存成一条:
7
+ // ① 记忆层的价值在**逐条裁决** —— 一整段五千字存进去,用户在设置页只有
8
+ // 「采纳 / 丢弃」两个按钮,等于逼他全选或全不选,缓冲区这个设计就白做了;
9
+ // ② 注入有条数与字符双预算,一条巨型记忆会独占预算,把其它记忆全挤出去;
10
+ // ③ 跨 AI 的记忆里必然混着「这条我不想要」的部分,逐条才能剔除。
11
+ //
12
+ // 设计上的三条硬约束:
13
+ // 1. **切分是机械的,不做语义理解**。不调用模型、不猜意思 —— 只按显式分隔
14
+ // (标题行、列表项、空行块)切开。理由:这一步在本地同步完成,用户粘贴后
15
+ // 立刻看到结果;调模型会让「粘贴 → 等十几秒 → 结果不可复现」。
16
+ // 2. **归类是建议,不是判决**。每条带上 layer 建议与理由,用户可改。
17
+ // 3. **凭据闸门与手动添加同一条**。跨 AI 的画像里最容易夹带 API Key 与
18
+ // 路径里的账号名 —— 走 makeCandidate 复用同一套正则,命中即拒绝入库。
19
+
20
+ import { findCredentials, makeCandidate, normalizeMemoryText, similarity } from './memory.mjs'
21
+ import { MEMORY_TEXT_MAX } from './config.mjs'
22
+
23
+ /**
24
+ * 一次导入的条数上限。
25
+ *
26
+ * 与 maxPending 默认值(200)解耦:这是**单次操作**的上限,不是缓冲区容量。
27
+ * 切出几百条会让用户在设置页面对一个自己没写的长列表,反而不如让他分批粘贴。
28
+ */
29
+ export const IMPORT_MAX_ITEMS = 60
30
+
31
+ /** 单次粘贴的原文长度上限(字符)。超过就不再切分,直接报错让用户分批。 */
32
+ export const IMPORT_MAX_SOURCE = 40000
33
+
34
+ /** 切分后单条过短的丢弃阈值:低于它多半是标题残留、编号行或标点。 */
35
+ export const IMPORT_MIN_ITEM = 4
36
+
37
+ /**
38
+ * 标题行要「本身算一条结论」所需的最短汉字数(汉字按 1 计,其它字符按 0.5)。
39
+ *
40
+ * 12 的来源:冒烟实测里 6 字的「我的使用画像」「沟通偏好」是标签,
41
+ * 17 字的「跨项目通用的沟通偏好,一律结论先行」是结论。取中间偏保守的位置 ——
42
+ * 漏掉一个长标题只是少一条候选(用户本来也能手动补),
43
+ * 而多收一堆标签会让他以为导入很脏。
44
+ */
45
+ const HEADING_STATEMENT_MIN = 12
46
+
47
+ /** 粗略的信息量:汉字算 1,其余算 0.5。只用于判断标题是标签还是句子。 */
48
+ function informationLength(text) {
49
+ let score = 0
50
+ for (const char of String(text ?? '')) {
51
+ score += /[\u4e00-\u9fff]/.test(char) ? 1 : 0.5
52
+ }
53
+ return score
54
+ }
55
+
56
+ /**
57
+ * 标题行是否本身就是一条「结论」。
58
+ *
59
+ * 冒烟实测教训:一开始把**所有**标题都收成条目,结果「我的使用画像」「我是谁」
60
+ * 「沟通偏好」这类小节标签全成了候选 —— 它们在设置页里长得像记忆,实际上
61
+ * 没有可采纳的内容,还会把缓冲区计数撑起来,让用户以为导进来一堆东西。
62
+ *
63
+ * 判定只看向两个信号,不做语义理解:
64
+ * - 长度:一句结论至少十几个字(「我的使用画像」6 字,明显是标签);
65
+ * - 句读:带逗号/句号/问号的标题是句子,标签几乎不带标点。
66
+ * 两者都不满足就丢弃,只当结构。**丢的是标签不是内容** —— 标题底下的段落
67
+ * 照常成条,信息不会整体消失。
68
+ */
69
+ function headingIsStatement(title) {
70
+ if (informationLength(title) >= HEADING_STATEMENT_MIN) return true
71
+ return /[,。;:!?,.;:!?]/.test(title)
72
+ }
73
+
74
+ /**
75
+ * 切分粘贴文本。
76
+ *
77
+ * 识别四种边界(优先级从高到低):
78
+ * ① Markdown 标题行(`#`、`##`…)—— 是结论则自成一 条,是标签则丢弃(只当结构);
79
+ * ② 列表项(`-`、`*`、`+`、`1.`、`1、`、`(1)`)—— 每项一条;
80
+ * ③ 中文分号/句号结尾的连续行 —— 同段落内不切,整段一条;
81
+ * ④ 空行 —— 段落边界。
82
+ *
83
+ * 刻意**不按句号切**:中文画像里一句话常常不完整(「偏好:结论先行。」),
84
+ * 按句号切会把「偏好:」和「结论先行。」拆成两条,两条都读不通。
85
+ *
86
+ * @param {string} source 粘贴的原文
87
+ * @returns {string[]} 切分结果(已归一化空白,未过滤空串)
88
+ */
89
+ export function splitImportedText(source) {
90
+ const body = String(source ?? '').replace(/\r\n?/g, '\n')
91
+ const lines = body.split('\n')
92
+ const out = []
93
+ let buffer = []
94
+
95
+ const flush = () => {
96
+ const text = normalizeMemoryText(buffer.join('\n'))
97
+ buffer = []
98
+ if (text) out.push(text)
99
+ }
100
+
101
+ // 列表项与标题行都能独立成条;普通行累积到段落结束。
102
+ //
103
+ // 标记后面**不强制**要有空格:外部 AI 生成的清单里 `-没有空格` 很常见,
104
+ // 而旧正则要求 `\s+`,这类行会掉进「普通行」分支被并进上一条 —— 实测就是
105
+ // `1、这个项目里用 pnpm 管理依赖 -没有空格的行` 被切成一整条,两件事绑在一起,
106
+ // 用户在预览里既拆不开也退不掉其中一半。代价是 `---` 这类分隔线也会命中,
107
+ // 但它的内容只剩 `--`(2 字符),在 IMPORT_MIN_ITEM 那道闸门上就被丢掉了。
108
+ //
109
+ // 数字项的分隔符后面补一个「不紧跟数字」的判断:不强制空格之后,`1.5 版本…`
110
+ // `2024.10 的记录…` `3.14 是…` 的 `1.`/`2024.`/`3.` 会被当成编号,切出
111
+ // 「5 版本之后默认开启暗色」这种少了前导数字的残句,而且不会有任何提示。
112
+ // 分隔符后紧跟数字就判成小数或版本号,整行不命中、留作普通段落。
113
+ const LIST_ITEM = /^\s*(?:[-*+•]|\d+[.)、](?!\d)|(\d+)|\(\d+\))\s*(.+)$/
114
+ const HEADING = /^\s*(#{1,6})\s+(.*)$/
115
+
116
+ for (const line of lines) {
117
+ if (!line.trim()) {
118
+ flush()
119
+ continue
120
+ }
121
+
122
+ const heading = HEADING.exec(line)
123
+ if (heading) {
124
+ flush()
125
+ const title = normalizeMemoryText(heading[2])
126
+ if (headingIsStatement(title)) out.push(title)
127
+ continue
128
+ }
129
+
130
+ const item = LIST_ITEM.exec(line)
131
+ if (item) {
132
+ flush()
133
+ const text = normalizeMemoryText(item[1])
134
+ if (text) buffer.push(text)
135
+ // 列表项自身立即收尾:下一行若不是列表项,会是新段落。
136
+ flush()
137
+ continue
138
+ }
139
+
140
+ buffer.push(line.trim())
141
+ }
142
+ flush()
143
+
144
+ return out
145
+ }
146
+
147
+ /**
148
+ * 归类:给一条候选建议 layer,并说明理由。
149
+ *
150
+ * 判断依据只有**文本里出现的坐标证据**,不做语义猜测:
151
+ * - 出现具体盘符/项目路径(`D:\...`、`/home/...`、`~/...`)或「本工作区」「这个项目」,
152
+ * 说明这条讲的是某个具体项目 ⇒ 建议 `workspace`;
153
+ * - 出现「所有项目」「任何项目」「通用」「一律」「每次都要」等全局措辞 ⇒ 建议 `global`;
154
+ * - 两者都有或都没有 ⇒ 建议 `global`(默认层)。
155
+ *
156
+ * 为什么默认 global 而不是 workspace:把「记忆动不动就不生效」作为默认体验,
157
+ * 比跨项目污染更糟 —— 用户先看见它在起作用,才会去调范围。反过来用户会以为功能坏了。
158
+ *
159
+ * **没有工作区坐标时不建议 workspace。** 建议成 workspace 却拿不到 cwd,落库后的
160
+ * 记录就没有 `cwd` 字段,`matchLayer` 只能判 `workspace-scope-unknown` —— 这条记忆
161
+ * 永远不会被注入,用户却以为它生效了。那比「建议成全局」糟得多:全局至少看得见。
162
+ * 所以这里把「有没有 cwd」当成硬前提,不是提示语。
163
+ *
164
+ * @param {string} text
165
+ * @param {object} options {hasCwd} hasCwd=false 时不会返回 workspace
166
+ * @returns {{layer: 'global'|'workspace', reason: string, confidence: 'high'|'low'}}
167
+ */
168
+ export function suggestLayer(text, options = {}) {
169
+ const { hasCwd = true } = options
170
+ const body = String(text ?? '')
171
+ const projectHits = []
172
+ const globalHits = []
173
+
174
+ // 具体路径:盘符(Windows)、/home、/Users、~/ 开头、或形如 D:/x/y 的写法
175
+ if (/[A-Za-z]:[\\/][^\s,。;]+/.test(body)) projectHits.push('具体磁盘路径')
176
+ if (/(?:^|\s)(?:\/home\/|\/Users\/|~\/)[^\s,。;]+/.test(body)) projectHits.push('具体磁盘路径')
177
+ if (/本工作区|这个项目|本项目|当前项目|该项目|此项目/.test(body)) projectHits.push('「本项目」措辞')
178
+
179
+ if (/所有项目|任何项目|跨项目|通用|一律|每个项目|每次都要|不管哪个项目|无论什么项目/.test(body)) {
180
+ globalHits.push('「所有/任何」措辞')
181
+ }
182
+
183
+ if (projectHits.length > 0 && globalHits.length === 0) {
184
+ // 有项目坐标却没选定工作区:**明说**这条只能按全局收,别让它变成死条。
185
+ // confidence 给 low —— 这个判断本身是降级来的,不是文本给出的信心。
186
+ if (!hasCwd) {
187
+ return {
188
+ layer: 'global',
189
+ reason: `检测到${projectHits.join('、')},但当前没选定工作区,先按全局收下 —— 选定工作区后可以改成只在那里生效`,
190
+ confidence: 'low'
191
+ }
192
+ }
193
+ return {
194
+ layer: 'workspace',
195
+ reason: `检测到${projectHits.join('、')},建议只在对应工作区生效`,
196
+ confidence: 'high'
197
+ }
198
+ }
199
+ if (globalHits.length > 0 && projectHits.length === 0) {
200
+ return {
201
+ layer: 'global',
202
+ reason: `检测到${globalHits.join('、')},建议全局生效`,
203
+ confidence: 'high'
204
+ }
205
+ }
206
+ if (projectHits.length > 0 && globalHits.length > 0) {
207
+ return {
208
+ layer: 'global',
209
+ reason: hasCwd
210
+ ? '同时出现项目相关与全局措辞,先按全局收下,你可以在下面改成仅本工作区'
211
+ : '同时出现项目相关与全局措辞,先按全局收下(当前没选定工作区,改不成仅本工作区)',
212
+ confidence: 'low'
213
+ }
214
+ }
215
+ return {
216
+ layer: 'global',
217
+ reason: '没检测到工作区坐标,按全局生效(可在下面改)',
218
+ confidence: 'low'
219
+ }
220
+ }
221
+
222
+ /**
223
+ * 把粘贴文本转成一批**候选记录**(不改状态、不落盘)。
224
+ *
225
+ * 返回的分类刻意分成四类,因为调用方对它们的处置完全不同:
226
+ * - `items` 可以入库的候选
227
+ * - `skipped` 太短/空的碎片(丢弃)
228
+ * - `tooLong` 超过单条上限的(丢弃并告知,不截断 —— 截断会造出一条
229
+ * 前半句是用户的偏好、后半句断在半个词上的记忆)
230
+ * - `blocked` 命中凭据闸门的(丢弃并告警,是安全事件而非格式问题)
231
+ *
232
+ * `duplicates` 是与**已有记忆**(含缓冲区里已有的)内容重复的条目 —— 单独列出来,
233
+ * 因为它不是错误:用户可能就是从本插件导出后又粘回来。
234
+ *
235
+ * @param {string} source 粘贴文本
236
+ * @param {object[]} existing 现有全量记忆(用于去重)
237
+ * @param {object} options {maxItems, cwd}
238
+ * @returns {object}
239
+ */
240
+ export function parseImport(source, existing = [], options = {}) {
241
+ const { maxItems = IMPORT_MAX_ITEMS, cwd = '' } = options
242
+ const raw = String(source ?? '')
243
+
244
+ if (!raw.trim()) {
245
+ return { ok: false, error: '粘贴内容为空' }
246
+ }
247
+ if (raw.length > IMPORT_MAX_SOURCE) {
248
+ return {
249
+ ok: false,
250
+ error: `粘贴内容过长(${raw.length} 字符,上限 ${IMPORT_MAX_SOURCE})。请分批粘贴 —— 一次导入几百条,你在下面根本审不完。`
251
+ }
252
+ }
253
+
254
+ const pieces = splitImportedText(raw)
255
+ const items = []
256
+ const skipped = []
257
+ const tooLong = []
258
+ const blocked = []
259
+ const duplicates = []
260
+ const overflow = []
261
+
262
+ // 已有指纹:内容归一化后比较,避免「同一句话多个空格」被判成两条。
263
+ //
264
+ // **只取还活着的那批。** 原先无条件把全量记录建指纹,于是用户点过「不要」的
265
+ // (status=discarded)和已经归档的(state=dropped)照样占着指纹位 —— 他想重新
266
+ // 导入一条自己当初丢掉的东西时,面板会说「与已有记忆重复,已跳过」,等于用一次
267
+ // 已经被否决的判断把他永久挡住,而界面上根本找不到那条记忆来推翻它。
268
+ // 已采纳的(adopted)仍然参与查重:那条是真的在生效,重导一遍只会多一条副本。
269
+ const seen = new Set()
270
+ for (const record of existing ?? []) {
271
+ if (record?.status === 'discarded') continue
272
+ if (record?.state === 'dropped') continue
273
+ const digest = normalizeMemoryText(record?.text).toLowerCase()
274
+ if (digest) seen.add(digest)
275
+ }
276
+
277
+ for (const text of pieces) {
278
+ if (text.length < IMPORT_MIN_ITEM) {
279
+ skipped.push(text)
280
+ continue
281
+ }
282
+ if (text.length > MEMORY_TEXT_MAX) {
283
+ tooLong.push(text)
284
+ continue
285
+ }
286
+
287
+ const credentials = findCredentials(text)
288
+ if (credentials.length > 0) {
289
+ blocked.push({ text, credentials })
290
+ continue
291
+ }
292
+
293
+ const digest = text.toLowerCase()
294
+ if (seen.has(digest)) {
295
+ duplicates.push(text)
296
+ continue
297
+ }
298
+ seen.add(digest)
299
+
300
+ if (items.length >= maxItems) {
301
+ overflow.push(text)
302
+ continue
303
+ }
304
+
305
+ // hasCwd 传下去:没有工作区坐标时不许建议 workspace(见 suggestLayer 的注释)。
306
+ // 这里不 trim —— 空白串在 makeCandidate 里本来就会走 `if (cwd)` 那条死路,
307
+ // 但 hasCwd 必须在**这里**就判掉,否则建议文案会骗人。
308
+ const layer = suggestLayer(text, { hasCwd: Boolean(String(cwd ?? '').trim()) })
309
+ const created = makeCandidate({
310
+ text,
311
+ layer: layer.layer,
312
+ source: 'import',
313
+ cwd: layer.layer === 'workspace' ? cwd : ''
314
+ })
315
+ if (!created.ok) {
316
+ // makeCandidate 还会再拦一次(长度/凭据口径一致),
317
+ // 走到这里说明上面两道闸门与它不一致 —— 按 blocked 处理,别静默丢。
318
+ blocked.push({ text, credentials: ['makeCandidate 拒绝'], reason: created.error })
319
+ continue
320
+ }
321
+ items.push({ record: created.record, suggested: layer, text })
322
+ }
323
+
324
+ return {
325
+ ok: true,
326
+ items,
327
+ skipped,
328
+ tooLong,
329
+ blocked,
330
+ duplicates,
331
+ overflow,
332
+ counts: {
333
+ source: raw.length,
334
+ pieces: pieces.length,
335
+ accepted: items.length,
336
+ skipped: skipped.length,
337
+ tooLong: tooLong.length,
338
+ blocked: blocked.length,
339
+ duplicates: duplicates.length,
340
+ overflow: overflow.length
341
+ }
342
+ }
343
+ }
344
+
345
+ /**
346
+ * 在待审缓冲区里找出「与刚导入的这批高度相似」的已有候选。
347
+ *
348
+ * 单独做这一步而不是塞进 parseImport:相似度比较是 O(n·m),
349
+ * 且只有调用方知道「跟谁比」(缓冲区 vs 全量)。
350
+ *
351
+ * @param {object[]} imported 刚解析出的 items
352
+ * @param {object[]} pending 现有待审候选
353
+ * @param {object} options {threshold}
354
+ * @returns {object[]} [{itemIndex, pendingId, similarity, pendingText}]
355
+ */
356
+ export function findImportOverlaps(imported, pending, options = {}) {
357
+ const { threshold = 0.6 } = options
358
+ const out = []
359
+ for (let i = 0; i < (imported ?? []).length; i += 1) {
360
+ const text = imported[i]?.text
361
+ if (!text) continue
362
+ for (const candidate of pending ?? []) {
363
+ const score = similarity(text, candidate?.text)
364
+ if (score >= threshold) {
365
+ out.push({
366
+ itemIndex: i,
367
+ pendingId: candidate.id,
368
+ similarity: Number(score.toFixed(3)),
369
+ pendingText: candidate.text
370
+ })
371
+ }
372
+ }
373
+ }
374
+ return out
375
+ }
376
+
377
+ /**
378
+ * Phase ② 第一步给用户复制的那段提示词。
379
+ *
380
+ * 用途(某产品两段式的第一段):让用户在**别的 AI 工具**里把散落的偏好整理成
381
+ * 一份结构化画像,再粘回来。所以这段提示词是给别的模型看的,不是给我们自己的。
382
+ *
383
+ * 措辞上刻意要求「保留原本的表述方式,不要过度改写」:改写过的偏好会丢掉
384
+ * 用户的原话口吻,而人设层最需要的恰恰是他的原话(「别跟我绕」比
385
+ * 「偏好简洁直接的沟通风格」有用得多)。
386
+ */
387
+ export const IMPORT_PROMPT = [
388
+ '请帮我整理一份我的个人使用画像,用途是让我在不同 AI 工具之间保持一致的协作体验。',
389
+ '请基于你当前能访问到的、与我相关的长期信息和本次会话上下文进行整理。',
390
+ '在涉及我的指令和偏好时,请尽量保留我原本的表述方式,不要过度改写。'
391
+ ].join('')
392
+
393
+ /**
394
+ * 给别的 AI 的完整请求(比 IMPORT_PROMPT 多了输出格式要求)。
395
+ *
396
+ * 为什么把格式要求独立出来:用户可以只复制简短版(他自己的模型已经知道该输出什么),
397
+ * 也可以复制完整版(含格式约束,结果更规整)。两种粘贴回来都能被 splitImportedText 处理。
398
+ */
399
+ export const IMPORT_PROMPT_FULL = `${IMPORT_PROMPT}
400
+
401
+ 请按下面的格式输出,每条一行,以「- 」开头,一行说一件事:
402
+
403
+ - 我是谁:职业、技术背景、当前在做的事
404
+ - 我说话和做事的方式:沟通风格、语气偏好、明确不喜欢什么
405
+ - 我的工具与工作习惯:常用软件、项目路径、目录约定
406
+ - 我希望你记住的长期约定:需要一直遵守的规则
407
+
408
+ 每条尽量独立成句,不要写成一大段。如果某条只适用于某个具体项目,请在句中说明是哪个项目。`