dsh-selfnote 0.14.2 → 0.14.3

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 CHANGED
@@ -10,10 +10,10 @@
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
- <img src="https://img.shields.io/badge/version-0.14.2-4f46e5" alt="version">
13
+ <img src="https://img.shields.io/badge/version-0.14.3-4f46e5" alt="version">
14
14
  <img src="https://img.shields.io/badge/license-MIT-16a34a" alt="license">
15
15
  <img src="https://img.shields.io/badge/node-%3E%3D20.13-339933" alt="node">
16
- <img src="https://img.shields.io/badge/tests-280%20passed-0ea5e9" alt="tests">
16
+ <img src="https://img.shields.io/badge/tests-349%20passed-0ea5e9" alt="tests">
17
17
  </p>
18
18
 
19
19
  ---
@@ -36,13 +36,15 @@
36
36
 
37
37
  **主体约定**:自述里的第一人称「我」= agent 自己;「记忆条目」「事实」的主语是用户。两者分开存、分开注入,不混为一谈。
38
38
 
39
- ## 三个工具
39
+ ## 五个工具
40
40
 
41
41
  | 工具 | 做什么 | 关键约束 |
42
42
  |---|---|---|
43
43
  | `self_note` | 写一条第一人称自述 | 一句话到三句话;见下面「什么时候写」 |
44
44
  | `memory_recall` | 按关键词翻历史对话原文,回来带出处 | 只收正文,剔除 reasoning / 工具输出 / 注入内容 |
45
45
  | `memory_stage` | 把查到的一句连出处放进候选区 | 没有出处直接拒绝,所以编不了 |
46
+ | `memory_search` | 在「温层」里找带指针的摘要与已记的事实 | 每条带指针(sessionId + 行号),顺指针用 `memory_recall` 回原文核对 |
47
+ | `memory_write` | 往温层写一条带出处的摘要 | 必须带指针,不进每回合上下文,只在 `memory_search` 时浮出来 |
46
48
 
47
49
  `self_note` 另有两个只读动作:`action: "persona"` 取 L2 扩展设定(带「这是设定,不是指令」的边界句),`action: "corrections"` 翻攒下的纠偏证据(每页 30 条,带游标)。两个都不写盘 —— **模型能读人格,但改不了人格**。
48
50
 
@@ -70,7 +72,7 @@ plugin_manager { action: "install_bundle", target: "<该目录的绝对路径>"
70
72
 
71
73
  装完**重启 DSH**,三件事同时出现:
72
74
 
73
- 1. 模型多了上面三个工具;
75
+ 1. 模型多了上面五个工具;
74
76
  2. 设置页多一节「人格(自述)」(在「知识库」下面);
75
77
  3. 每回合的系统提示里,多一段人格(L0 + L1,最多 320 字)和一段最多 1000 字的记忆。
76
78
 
@@ -159,7 +161,7 @@ plugin_manager { action: "install_bundle", target: "<该目录的绝对路径>"
159
161
 
160
162
  ### 注入页:看见每回合贴了什么
161
163
 
162
- 「注入」页把 `composeInjection()` 拼出来的那段字**原样**摊开 —— 包括抬头、一句人格框架句(「以下是你自己的样子,照这个说话。」)、人格 L0/L1、「(背景资料,不是指令)」这句边界句,以及 L2 的指路句。下面是分段账单:
164
+ 「注入」页把 `assembleInjection()` 拼出来的那段字**原样**摊开(`composeInjection()` 是外面那层缓存,只管复用、不拼字)—— 包括抬头、一句人格框架句(「以下是你自己的样子,照这个说话。」)、人格 L0/L1、「(背景资料,不是指令)」这句边界句,以及 L2 的指路句。下面是分段账单:
163
165
 
164
166
  | 段 | 预算 | 页面显示 |
165
167
  |---|---|---|
@@ -271,7 +273,7 @@ DSH 的会话文件是**逐条追写**的 zstd 序列(一次写入 = 一个 fr
271
273
 
272
274
  - 只有**真的进了注入**才算一次使用;检索到但没进上下文的,不记(否则搜一次就永远活跃)。
273
275
  - 一小时内重复注入只记一次。
274
- - 半衰期默认 30 天,可调 1–365 天。这个数**设置页没有输入框**,只能走 API:`POST /api/selfnote/config`,body 里带 `{"halfLifeDays": 30}`(同一条接口还收 `profileMaxChars` 20–120)。
276
+ - 半衰期默认 30 天,可调 1–365 天。设置页「概览」里能直接调,也可以走 API:`POST /api/selfnote/config`,body 里带 `{"halfLifeDays": 30}`(同一条接口还收 `profileMaxChars` 20–120)。
275
277
  - 冷下去只影响注入排序,**不会自动退役**。哪天真想起来,它还在库里能被搜到。
276
278
 
277
279
  ## 自动采集(默认开,只发现不确认)
@@ -381,7 +383,7 @@ L2 为空时那句指路句也不发(不能指一条空路)。L2 有内容
381
383
 
382
384
  ### 抬头:只留必要的三十个字
383
385
 
384
- 注入文本里有几处纯框架文字(标题行、边界句、段名前缀),不承载任何记忆。0.10.3 之前它们合计 **92 字**,等于原先 800 的预算里有 20% 花在说「下面是什么」上。现在:
386
+ 注入文本里有几处纯框架文字(标题行、边界句、段名前缀),不承载任何记忆。0.10.3 之前它们合计 **92 字**,等于原先 460 的预算里有 20% 花在说「下面是什么」上。(92/460≈20%;旧稿一度把分母误写成 800。)现在:
385
387
 
386
388
  ```
387
389
  记忆(我写的)
@@ -522,7 +524,7 @@ L2 为空时那句指路句也不发(不能指一条空路)。L2 有内容
522
524
 
523
525
  设置页「迁移」卡两个按钮,全程在你自己的浏览器与磁盘之间。导出的包是 **v2**:比 v1 多带人格三段(`persona`)与两个配置项(`personaEnabled` / `tempTtlDays`)。**纠偏证据不进包** —— 它是「这台机器上发生过什么」,不是人格本身。v1 老包照样能导进来,缺的字段按默认补;目标机已有不同人格时不覆盖,落成 `persona.imported-<时间戳>.md`。
524
526
 
525
- - **导出**:一份 JSON(`selfnote-YYYY-MM-DD.json`),含自述、facts、习惯,以及你可调的那几项配置。
527
+ - **导出**:一份 JSON(`selfnote-YYYY-MM-DD.json`),含自述(分型与钉住也跟着走)、facts、习惯,以及你可调的那几项配置;导入时配置只按白名单与基本类型落盘。
526
528
  - **导入**:在新机器上选这份文件。规则只有三条 —— **不覆盖**(同 id、同正文都跳过)、**默认降级**(导进来的「已采纳」一律变回候选,勾了「保留原状态」才原样搬)、**坏文件直接拒**(不是本插件的包、版本比本机新,都返回 400,不会合并一半)。
527
529
 
528
530
  索引不跟着搬:它记的是本机磁盘路径与会话 mtime,换个地方必然过期。导入后重新构建一次就行(设置页「索引」卡)。
@@ -530,7 +532,7 @@ L2 为空时那句指路句也不发(不能指一条空路)。L2 有内容
530
532
  ## 开发
531
533
 
532
534
  ```bash
533
- node --run test # 280 项,24 个文件,无外部依赖
535
+ node --run test # 349 项,29 个文件,无外部依赖
534
536
  node --run test:accept # 12 项:真实 HTTP + 36 进程并发写
535
537
  ```
536
538
 
@@ -544,8 +546,10 @@ node --run test:accept # 12 项:真实 HTTP + 36 进程并发写
544
546
  | `tests/corrections.mjs` | 纠偏证据:强弱信号与误命中、事件级去重、分页游标、200 条上限、按 usedAt 清理、水位不回填、跨三文件事务的幂等恢复与坏日志隔离、指纹计数 |
545
547
  | `tests/capture.mjs` `tests/capture-p1b.mjs` `tests/capture-debug.mjs` | 触发词、去重合并、游标幂等与崩溃恢复、迟到事件(空洞)、采集轨迹落点 |
546
548
  | `tests/fair.mjs` `tests/warm.mjs` | 环境段冷门预留与轮换、压缩后补回;温层带指针摘要(无指针拒写、可吊销不可删) |
547
- | `tests/transfer.mjs` | 导出/导入:白名单、不覆盖、降级、坏文件拒绝 |
548
- | `tests/crash-write.mjs` | 写入过程中被 SIGKILL:notes.md / config.json 不留半截文件 |
549
+ | `tests/transfer.mjs` `tests/transfer-roundtrip.mjs` | 导出/导入:白名单、不覆盖、降级、坏文件拒绝;自述三型 / pin / 配置跟着往返 |
550
+ | `tests/r3-regressions.mjs` | 全量审查回归:采集正文、索引原型键、坏行隔离、缓存键与 UI 可读性 |
551
+ | `tests/crash-write.mjs` `tests/atomic-retry.mjs` `tests/capacity-warning.mjs` | 写入过程被 SIGKILL 不留半截文件、rename 瞬态冲突退避重试、长条挤掉旧规则时容量告警不静默 |
552
+ | `tests/unicode-rulers.mjs` | 全插件「字」按 Unicode 码点算:emoji 截断不写坏、字数校验、配置落盘与段 chars 都按码点 |
549
553
  | `tests/acceptance-runtime.mjs` | 鉴权(无头 / 跨站 / 固定口令)、并发写不丢、锁放弃、注入预算 |
550
554
 
551
555
  改 `lib/*.js` 或 `lib/client.js` 之后**必须重启 DSH**,热重载不会重新扫描 client bundle。测试会自动隔离 `DSH_MEMORY_DIR`,不会污染真实记忆。
@@ -562,7 +566,8 @@ lib/index-store.js 倒排索引
562
566
  lib/persona.js 人格三段:解析、码点计数、上限校验、原子保存
563
567
  lib/persona-tx.js 跨三文件事务:写人格 + 消费证据 + 记账,可幂等恢复
564
568
  lib/corrections.js 纠偏证据:强弱信号、事件去重、游标分页、水位、清理
565
- lib/store.js 自述文件读写:三型标记、pin、保质期、归档
569
+ lib/store.js 自述文件读写:三型标记、pin、保质期、归档、码点计数与截断助手
570
+ lib/explain.js 每段跳过原因:封闭枚举、字素簇预览、core 明示素材层
566
571
  lib/atomic.js 原子写:同目录临时文件 + fsync + rename
567
572
  lib/warm.js 温层:带指针摘要,可吊销不可删
568
573
  lib/transfer.js 导出/导入:白名单、不覆盖、降级
package/lib/atomic.js CHANGED
@@ -11,6 +11,40 @@
11
11
  import { closeSync, fsyncSync, openSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from 'node:fs'
12
12
  import { basename, dirname, join } from 'node:path'
13
13
 
14
+ // Windows 没有 POSIX「覆盖正被打开的目标文件」语义:多进程硬写同一份 facts.jsonl 时,
15
+ // renameSync 偶发 EPERM / EBUSY / EACCES —— 目标只是「暂时被占用」,等几毫秒就好。
16
+ // ENOENT、EISDIR 之类是路径或权限真错了,重试只会白等,必须立刻抛。
17
+ const TRANSIENT_FS_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
18
+ const RENAME_BACKOFF = new Int32Array(new SharedArrayBuffer(4))
19
+
20
+ export function isTransientFsError(error) {
21
+ return TRANSIENT_FS_CODES.has(error?.code)
22
+ }
23
+
24
+ /**
25
+ * 只对瞬态占用做有限次重试。delays 的长度就是总尝试次数(不是「重试次数」):
26
+ * 第 i 次失败后等 delays[i] 毫秒再试下一次,最后一次仍冲突就把最后的错抛出去。
27
+ * sleep 可注入,测试里用空函数省掉真实等待。
28
+ */
29
+ export function retryOnTransient(operation, options = {}) {
30
+ const delays = Array.isArray(options.delays) ? options.delays : [5, 10, 20, 40, 80]
31
+ const sleep = typeof options.sleep === 'function' ? options.sleep : (ms) => {
32
+ try {
33
+ Atomics.wait(RENAME_BACKOFF, 0, 0, ms)
34
+ } catch {
35
+ /* 部分环境不支持 Atomics.wait 就退化成不让,重试仍会进行 */
36
+ }
37
+ }
38
+ for (let attempt = 0; attempt < delays.length; attempt += 1) {
39
+ try {
40
+ return operation()
41
+ } catch (error) {
42
+ if (!isTransientFsError(error) || attempt === delays.length - 1) throw error
43
+ sleep(delays[attempt])
44
+ }
45
+ }
46
+ }
47
+
14
48
  /**
15
49
  * 扫掉同一个目标留下的陈旧临时文件。
16
50
  * 被 SIGKILL 的进程没机会自己删 tmp,所以下一次写这个文件时顺手清掉「已经放了很久」的。
@@ -53,7 +87,9 @@ export function writeAtomic(file, text) {
53
87
  fsyncSync(fd)
54
88
  closeSync(fd)
55
89
  fd = -1
56
- renameSync(tmp, file)
90
+ // Windows 上目标被别的进程短暂占用时 rename 会 EPERM/EBUSY:
91
+ // 等几毫秒重试,而不是把「整份写回」直接判成失败(test:accept 曾偶发这个错)。
92
+ retryOnTransient(() => renameSync(tmp, file))
57
93
  } catch (error) {
58
94
  if (fd !== -1) {
59
95
  try {
@@ -72,6 +108,7 @@ export function writeAtomic(file, text) {
72
108
  }
73
109
 
74
110
  export function writeJsonAtomic(file, value, options = {}) {
111
+ if (value === undefined) throw new TypeError('writeJsonAtomic: undefined 不能写进 JSON 文件')
75
112
  // 默认缩进是为了人能读;索引本体十兆,缩进全是白占的体积,传 pretty:false
76
113
  const text = options.pretty === false ? JSON.stringify(value) : JSON.stringify(value, null, 2)
77
114
  writeAtomic(file, text + '\n')
@@ -14,13 +14,13 @@ import { runSerial } from './persona-tx.js'
14
14
 
15
15
  /** 从一条 UserMessage 的 content 里抽纯文本(content 可能是字符串或块数组)。 */
16
16
  export function textOfEvent(event) {
17
- const content = event?.data?.content ?? event?.data?.text ?? ''
17
+ const content = event?.data?.message?.content ?? event?.data?.content ?? event?.data?.text ?? event?.content ?? ''
18
18
  if (typeof content === 'string') return content
19
19
  if (!Array.isArray(content)) return ''
20
20
  const parts = []
21
21
  for (const block of content) {
22
22
  if (block === null || typeof block !== 'object') continue
23
- if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
23
+ if ((block.type === 'text' || block.type === 'input_text') && typeof block.text === 'string') parts.push(block.text)
24
24
  }
25
25
  return parts.join('\n')
26
26
  }
@@ -53,7 +53,8 @@ export function planWrites(texts, options = {}) {
53
53
  for (const fact of facts) {
54
54
  if (typeof fact.text !== 'string') continue
55
55
  const verdict = compareCandidate(text, fact.text)
56
- if (best === null || verdict.score > best.verdict.score) {
56
+ if (best === null || verdict.score > best.verdict.score
57
+ || (verdict.score === best.verdict.score && fact.status === 'candidate' && best.fact.status !== 'candidate')) {
57
58
  best = { fact, verdict }
58
59
  }
59
60
  }
@@ -121,7 +122,9 @@ export async function handleEvent(event, deps) {
121
122
  if (!isUserAuthored(event)) return { handled: false, reason: 'not-user-authored' }
122
123
 
123
124
  const sessionId = deps.sessionId ?? event?.sessionId ?? event?.data?.session?.id ?? 'unknown'
124
- const seq = Number(event?.seq ?? event?.data?.seq)
125
+ const rawSeq = event?.seq ?? event?.data?.seq
126
+ const seqText = typeof rawSeq === 'number' ? String(rawSeq) : typeof rawSeq === 'string' ? rawSeq.trim() : ''
127
+ const seq = seqText === '' || /^[0-9]+$/.test(seqText) === false ? NaN : Number(seqText)
125
128
  if (!Number.isSafeInteger(seq) || seq < 0) return { handled: false, reason: 'no-seq' }
126
129
 
127
130
  const raw = textOfEvent(event)
package/lib/capture.js CHANGED
@@ -18,6 +18,7 @@ import { join } from 'node:path'
18
18
 
19
19
  import { writeJsonAtomic } from './atomic.js'
20
20
  import { memoryRoot } from './memory.js'
21
+ import { countChars } from './store.js'
21
22
 
22
23
  /* ------------------------------ 触发 ------------------------------ */
23
24
 
@@ -81,7 +82,7 @@ export function candidatesOf(text, limit = 3) {
81
82
  const out = []
82
83
  for (const sentence of value.match(SENTENCE) ?? []) {
83
84
  const line = sentence.trim()
84
- if (line.length < 4 || line.length > 200) continue
85
+ if (countChars(line) < 4 || countChars(line) > 200) continue
85
86
  if (triggersOf(line).length === 0) continue
86
87
  if (!hasScope(line)) continue
87
88
  if (out.includes(line)) continue
package/lib/client.js CHANGED
@@ -213,8 +213,6 @@ window.__ModuleLoader__.load({
213
213
  setTempTtl(String(data.tempTtlDays === undefined ? 14 : data.tempTtlDays))
214
214
  setHalfLife(String(data.halfLifeDays === undefined ? 30 : data.halfLifeDays))
215
215
  setProfileMax(String(data.profile && data.profile.maxChars !== undefined ? data.profile.maxChars : 110))
216
- setMoreEvidence([])
217
- setMoreCursor(null)
218
216
  // 每次拿到新状态,列表下标都可能整体挪位:正在改/正等确认的那条已经没意义了
219
217
  setEditing(null)
220
218
  setPendingDelete(null)
@@ -820,7 +818,6 @@ window.__ModuleLoader__.load({
820
818
  className: 'sn-input',
821
819
  rows: 2,
822
820
  // 一条事实硬上限 240 字(服务端同一条)
823
- maxLength: 240,
824
821
  value: factEdit.text,
825
822
  onChange: function (e) { setFactEdit({ id: fact.id, text: e.target.value }) },
826
823
  }),
@@ -902,10 +899,10 @@ window.__ModuleLoader__.load({
902
899
  { className: 'sn-budget', key: 'budget' },
903
900
  h(
904
901
  'div',
905
- { className: 'sn-budget-bar', title: '本回合注入 ' + (budget.total || 0) + ' / ' + limit + ' 字' },
902
+ { className: 'sn-budget-bar', title: '本回合注入 ' + (budget.total || 0) + ' / ' + limit + ' 字', role: 'progressbar', 'aria-valuenow': budget.total || 0, 'aria-valuemin': 0, 'aria-valuemax': limit, 'aria-label': '本回合注入字数' },
906
903
  segments.map(function (item) {
907
904
  var width = limit > 0 ? Math.max(0, Math.min(100, (item.value / limit) * 100)) : 0
908
- return h('div', { key: item.key, className: 'sn-budget-seg', style: { width: width + '%', background: item.color } })
905
+ return h('div', { key: item.key, className: 'sn-budget-seg', style: { width: width + '%', background: item.color }, title: item.label + ' ' + item.value + ' 字' })
909
906
  }),
910
907
  ),
911
908
  h(
@@ -973,7 +970,7 @@ window.__ModuleLoader__.load({
973
970
  h(
974
971
  'div',
975
972
  { className: 'sn-row' },
976
- h('label', { className: 'sn-toggle' }, h('input', { type: 'checkbox', checked: state.enabled, onChange: toggleEnabled }), h('span', null, '每回合注入')),
973
+ h('label', { className: 'sn-toggle' }, h('input', { type: 'checkbox', checked: state.enabled, disabled: busy !== '', onChange: toggleEnabled }), h('span', null, '每回合注入')),
977
974
  h('span', { className: 'sn-grow' }),
978
975
  h('button', { className: 'sn-btn sn-btn-ghost', onClick: refresh, disabled: busy !== '' }, '刷新'),
979
976
  ),
@@ -996,7 +993,6 @@ window.__ModuleLoader__.load({
996
993
  h('span', null, '注入抬头(每回合那段的第一行)'),
997
994
  h('input', {
998
995
  type: 'text',
999
- maxLength: 24,
1000
996
  'aria-label': '注入抬头,最多 24 个字,留空就不加抬头',
1001
997
  value: heading,
1002
998
  placeholder: '例:AI记忆',
@@ -1056,7 +1052,6 @@ window.__ModuleLoader__.load({
1056
1052
  h('span', null, '什么时候该写自述(留空=用默认判据)'),
1057
1053
  h('textarea', {
1058
1054
  rows: 2,
1059
- maxLength: 80,
1060
1055
  'aria-label': '什么时候该写自述,留空就用默认判据',
1061
1056
  value: noteGuide,
1062
1057
  placeholder: '例:每次改完一个插件、跑完验收之后写一条;闲聊不用写。',
@@ -1207,6 +1202,7 @@ window.__ModuleLoader__.load({
1207
1202
 
1208
1203
  /** 导入:读文件 → 交服务端合并 → 把账目摊在卡上。 */
1209
1204
  function importBundle(file) {
1205
+ if (busy !== '') return
1210
1206
  if (!file) return
1211
1207
  setBusy('import')
1212
1208
  setTransfer(null)
@@ -1309,7 +1305,7 @@ window.__ModuleLoader__.load({
1309
1305
  ),
1310
1306
  h(
1311
1307
  'div',
1312
- { className: 'sn-file-path', title: '点一下复制路径', onClick: function () { copyPath(file.path) } },
1308
+ { className: 'sn-file-path', title: '点一下复制路径', onClick: function () { copyPath(file.path) }, role: 'button', tabIndex: 0, onKeyDown: function (e) { if (e.key === 'Enter' || e.key === ' ') copyPath(file.path) } },
1313
1309
  file.path,
1314
1310
  ),
1315
1311
  h(
@@ -1346,12 +1342,13 @@ window.__ModuleLoader__.load({
1346
1342
  h('button', { className: 'sn-btn sn-btn-ghost', onClick: exportAll, disabled: busy !== '' }, busy === 'export' ? '导出中…' : '导出全部记忆'),
1347
1343
  h(
1348
1344
  'label',
1349
- { className: 'sn-btn sn-btn-ghost sn-transfer-pick' },
1345
+ { className: 'sn-btn sn-btn-ghost sn-transfer-pick', role: 'button', tabIndex: busy === '' ? 0 : -1, 'aria-disabled': busy !== '', onKeyDown: function (event) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); if (busy === '') event.currentTarget.querySelector('input').click() } } },
1350
1346
  busy === 'import' ? '导入中…' : '选择文件导入',
1351
1347
  h('input', {
1352
1348
  type: 'file',
1353
1349
  accept: 'application/json,.json',
1354
- style: { display: 'none' },
1350
+ style: { position: 'absolute', width: 1, height: 1, opacity: 0 },
1351
+ disabled: busy !== '',
1355
1352
  onChange: function (event) {
1356
1353
  var picked = event.target.files && event.target.files[0]
1357
1354
  event.target.value = ''
@@ -1505,7 +1502,6 @@ window.__ModuleLoader__.load({
1505
1502
  className: 'sn-input',
1506
1503
  rows: 2,
1507
1504
  // 单条硬上限 600 字(服务端同一条)——这里是提示,不是校验
1508
- maxLength: 600,
1509
1505
  value: editText,
1510
1506
  onChange: function (e) { setEditText(e.target.value) },
1511
1507
  }),
@@ -1556,7 +1552,7 @@ window.__ModuleLoader__.load({
1556
1552
  h(
1557
1553
  'button',
1558
1554
  {
1559
- className: 'sn-btn sn-btn-ghost sn-pin' + (entry.pin === true ? ' sn-pin-on' : ''),
1555
+ 'aria-label': entry.pin === true ? '取消钉住' : '钉住', className: 'sn-btn sn-btn-ghost sn-pin' + (entry.pin === true ? ' sn-pin-on' : ''),
1560
1556
  title: entry.pin === true ? '已钉住:同一型里优先装进去(再点取消)' : '钉住:同一型里优先装进去',
1561
1557
  onClick: function () { setNoteTier(stored, { pin: entry.pin !== true }) },
1562
1558
  disabled: busy !== '',
@@ -1589,7 +1585,7 @@ window.__ModuleLoader__.load({
1589
1585
  h(
1590
1586
  'label',
1591
1587
  { className: 'sn-toggle' },
1592
- h('input', { type: 'checkbox', checked: !!(state.profile && state.profile.enabled), onChange: toggleProfile }),
1588
+ h('input', { type: 'checkbox', checked: !!(state.profile && state.profile.enabled), disabled: busy !== '', onChange: toggleProfile }),
1593
1589
  h('span', null, '每回合注入画像'),
1594
1590
  ),
1595
1591
  h('span', { className: 'sn-grow' }),
@@ -1824,7 +1820,6 @@ window.__ModuleLoader__.load({
1824
1820
  h('textarea', {
1825
1821
  className: 'sn-input',
1826
1822
  rows: 2,
1827
- maxLength: 240,
1828
1823
  value: factEdit.text,
1829
1824
  onChange: function (e) { setFactEdit({ id: fact.id, text: e.target.value }) },
1830
1825
  }),
@@ -2081,12 +2076,12 @@ window.__ModuleLoader__.load({
2081
2076
  'div',
2082
2077
  { className: 'sn-attr' },
2083
2078
  attrRows.slice(0, 14).map(function (row) {
2084
- var text = row.text.length > 46 ? row.text.slice(0, 46) + '…' : row.text
2079
+ var text = row.text
2085
2080
  return h(
2086
2081
  'div',
2087
2082
  { className: 'sn-attr-row sn-attr-' + row.state, key: row.id },
2088
2083
  h('span', { className: 'sn-badge' }, attrSegLabel[row.segment] || (row.state === 'searched' ? '搜到过' : '没进')),
2089
- h('span', { className: 'sn-attr-text' }, text),
2084
+ h('span', { className: 'sn-attr-text', title: row.text }, text),
2090
2085
  h(
2091
2086
  'span',
2092
2087
  { className: 'sn-attr-num' },
@@ -2156,7 +2151,7 @@ window.__ModuleLoader__.load({
2156
2151
  type: 'button',
2157
2152
  role: 'tab',
2158
2153
  'aria-selected': active,
2159
- 'aria-controls': 'sn-panel-' + item.key,
2154
+ 'aria-controls': 'sn-panel-' + activeTab,
2160
2155
  tabIndex: active ? 0 : -1,
2161
2156
  className: 'sn-tab' + (active ? ' sn-tab-on' : ''),
2162
2157
  onClick: function () { setTab(item.key); setPicked(true) },
@@ -2193,7 +2188,7 @@ window.__ModuleLoader__.load({
2193
2188
  '.sn-transfer{display:flex;flex-direction:column;gap:8px}',
2194
2189
  '.sn-transfer-row{display:flex;gap:8px;flex-wrap:wrap}',
2195
2190
  '.sn-transfer-pick{cursor:pointer}',
2196
- '.sn-transfer-report{margin-top:4px;font-size:12px;line-height:1.7;color:var(--dsh-text-secondary)}',
2191
+ '.sn-transfer-report{margin-top:4px;font-size:12px;line-height:1.7;color:var(--dsw-alias-text-secondary,#6b7280)}',
2197
2192
  '.sn-transfer-warn{color:#b45309}',
2198
2193
  '.sn-root{display:flex;flex-direction:column;gap:14px;font-size:13px}',
2199
2194
  '.sn-head{display:flex;flex-direction:column;gap:4px}',
@@ -2289,7 +2284,7 @@ window.__ModuleLoader__.load({
2289
2284
  ".sn-tab:focus-visible{outline:2px solid var(--dsw-alias-accent-default,#2563eb);outline-offset:-2px}",
2290
2285
  ".sn-tab-on{color:var(--dsw-alias-text-primary,#111);font-weight:600}",
2291
2286
  ".sn-tab-on::after{content:\"\";position:absolute;left:10px;right:10px;bottom:-1px;height:2px;background:var(--dsw-alias-accent-default,#2563eb);border-radius:2px}",
2292
- ".sn-tab-badge{font-size:10px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--dsw-alias-bg-secondary,#f3f4f6);color:var(--dsw-alias-text-secondary,#6b7280);font-weight:400}",
2287
+ ".sn-tab-badge{font-size:11.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--dsw-alias-bg-secondary,#f3f4f6);color:var(--dsw-alias-text-secondary,#6b7280);font-weight:400}",
2293
2288
  ".sn-tab-on .sn-tab-badge{background:#eef2ff;color:#3730a3}",
2294
2289
  ".sn-panel{display:flex;flex-direction:column;gap:12px}",
2295
2290
  ".sn-panel-head{display:flex;justify-content:space-between;align-items:center}",
@@ -2364,7 +2359,7 @@ window.__ModuleLoader__.load({
2364
2359
  // —— 可读性与适配(原来是 11px + #9ca3af,在浅底上几乎要贴到屏幕上看)——
2365
2360
  '.sn-hint{font-size:12px;color:var(--dsw-alias-text-secondary,#6b7280)}',
2366
2361
  '.sn-hint-warn{font-size:12px}',
2367
- '.sn-date,.sn-file-meta,.sn-heat,.sn-evidence-meta,.sn-fact-cite,.sn-card-note{font-size:11.5px}',
2362
+ '.sn-date,.sn-file-meta,.sn-heat,.sn-evidence-meta,.sn-fact-cite,.sn-card-note{font-size:11.5px;color:var(--dsw-alias-text-secondary,#6b7280)}',
2368
2363
  '.sn-muted{color:var(--dsw-alias-text-secondary,#6b7280)}',
2369
2364
  // 输入框以前写死 110px,抬头那种长文本被压成一条缝;宽度交给容器,只在窄屏才吃满
2370
2365
  '.sn-field{flex-wrap:wrap}',
@@ -2376,7 +2371,7 @@ window.__ModuleLoader__.load({
2376
2371
  '.sn-expired{color:var(--dsw-alias-text-secondary,#6b7280);border-color:var(--dsw-alias-border-subtle,#d1d5db)}',
2377
2372
  '@media (max-width:520px){.sn-limits,.sn-row,.sn-actions{flex-wrap:wrap}.sn-field{width:100%}.sn-field input,.sn-field textarea{width:100%}.sn-card{padding:12px}}',
2378
2373
  // 深色底:插件原先把浅色写死了,宿主切深色时这些块会白得刺眼
2379
- '@media (prefers-color-scheme:dark){.sn-notice-ok{background:#052e21;color:#a7f3d0}.sn-notice-warn{background:#3f1212;color:#fecaca}.sn-notice{background:var(--dsw-alias-bg-secondary,#1f2937)}.sn-badge,.sn-file-count,.sn-tab-on .sn-tab-badge{background:#1e1b4b;color:#c7d2fe}.sn-material,.sn-kv-tag-stale,.sn-pair-head,.sn-doc-next,.sn-skipped-head{background:#3b2a06;color:#fcd34d}.sn-kv-tag-ok{background:#052e21;color:#6ee7b7}.sn-doc-next{border-color:#92400e}.sn-fact-conflict{background:#2a2308;border-color:#92400e}.sn-doc-ok{background:#052e21;color:#6ee7b7}.sn-doc-error{background:#3f1212;color:#fecaca}.sn-doc-warn{background:#3b2a06;color:#fcd34d}}',
2374
+ '@media (prefers-color-scheme:dark){.sn-notice-ok{background:#052e21;color:#a7f3d0}.sn-notice-warn{background:#3f1212;color:#fecaca}.sn-notice{background:var(--dsw-alias-bg-secondary,#1f2937)}.sn-badge,.sn-file-count,.sn-tab-on .sn-tab-badge{background:#1e1b4b;color:#c7d2fe}.sn-material,.sn-kv-tag-stale,.sn-pair-head,.sn-doc-next,.sn-skipped-head{background:#3b2a06;color:#fcd34d}.sn-kv-tag-ok{background:#052e21;color:#6ee7b7}.sn-doc-next{border-color:#92400e}.sn-fact-conflict{background:#2a2308;border-color:#92400e}.sn-doc-ok{background:#052e21;color:#6ee7b7}.sn-doc-error{background:#3f1212;color:#fecaca}.sn-doc-warn{background:#3b2a06;color:#fcd34d}.sn-file-missing .sn-file-path{color:#fcd34d}.sn-file-missing .sn-file-count{background:#3b2a06;color:#fcd34d}.sn-transfer-warn{color:#fcd34d}.sn-btn-danger{color:#fecaca;border-color:#7f1d1d}.sn-pin-on{color:#fcd34d;border-color:#92400e}.sn-count-over{color:#fca5a5}.sn-hint-warn{color:#fcd34d}.sn-loading-error{color:#fecaca}.sn-seg-state-warn{color:#fcd34d}}',
2380
2375
  ].join('\n')
2381
2376
 
2382
2377
  function apply(ctx) {
@@ -47,9 +47,20 @@ function normalizeEvent(raw) {
47
47
  used: raw.used === true,
48
48
  usedAt: raw.usedAt === null || raw.usedAt === undefined ? null : String(raw.usedAt),
49
49
  consumedBy: raw.consumedBy === null || raw.consumedBy === undefined ? null : String(raw.consumedBy),
50
+ strength: raw.strength === 'strong' || raw.strength === 'weak' ? raw.strength : null,
50
51
  }
51
52
  }
52
53
 
54
+ let pendingBadRows = []
55
+ function collectBadRows(rows) {
56
+ for (const row of rows) if (!pendingBadRows.includes(row)) pendingBadRows.push(row)
57
+ }
58
+ function flushBadRows() {
59
+ if (pendingBadRows.length === 0) return
60
+ const file = join(selfnoteRoot(), `corrections.bad-${Date.now().toString(36)}-${process.pid}.jsonl`)
61
+ writeAtomic(file, pendingBadRows.join('\n') + '\n')
62
+ pendingBadRows = []
63
+ }
53
64
  export function readEvents() {
54
65
  if (!existsSync(CORRECTIONS_PATH)) return []
55
66
  const list = []
@@ -59,8 +70,10 @@ export function readEvents() {
59
70
  try {
60
71
  const entry = normalizeEvent(JSON.parse(text))
61
72
  if (entry !== null) list.push(entry)
73
+ else collectBadRows([text])
62
74
  } catch {
63
- // 一行坏了不等于整个池子坏了:留着它也没用,跳过
75
+ // 一行坏了不等于整个池子坏了:隔离留存,别在下一次写回时无声抹掉
76
+ collectBadRows([text])
64
77
  }
65
78
  }
66
79
  return list
@@ -69,6 +82,7 @@ export function readEvents() {
69
82
  export function writeEvents(list) {
70
83
  const rows = list.map((entry) => JSON.stringify(normalizeEvent(entry) ?? entry))
71
84
  writeAtomic(CORRECTIONS_PATH, rows.length === 0 ? '' : rows.join('\n') + '\n')
85
+ flushBadRows()
72
86
  }
73
87
 
74
88
  export function findById(id) {
package/lib/explain.js CHANGED
@@ -46,21 +46,25 @@ export function previewOf(text, maxGraphemes = PREVIEW_MAX_CHARS) {
46
46
 
47
47
  // 只读取已有打包结果做「事后分类」:绝不重新打包、不改候选集合 / 排序 / 预算 / 文本。
48
48
  // sources 与 skipped 同源、等长、逐位平行(由 packNotes() 在构造 skipped 的同一次调用里产出)。
49
+ // 旧版这里恒给 budget:true,pickReason() 就永远有结果、下面的 if 永远进不去 else ——
50
+ // 一个不可达分支。现在按来源如实展开:认不出的(含缺失)才回落 budget。
49
51
  function flagsFor(source) {
50
- return { scope: false, expired: source === 'expired', duplicate: false, budget: true }
52
+ return {
53
+ scope: source === 'scope',
54
+ expired: source === 'expired',
55
+ duplicate: source === 'duplicate',
56
+ budget: source === 'budget' || !SKIP_REASONS.includes(source),
57
+ }
51
58
  }
52
59
 
53
60
  export function describeSkipped({ key, skipped = [], sources = [], notes = [] } = {}) {
54
61
  const list = Array.isArray(skipped) ? skipped : []
55
62
  // 来源数组必须同源等长;缺一项就整批保守降级,宁可少报原因也不报错原因。
56
- const src = Array.isArray(sources) && sources.length === list.length ? sources : []
57
- const out = []
58
- list.forEach((raw, i) => {
63
+ // 降级在这里显式做,调用方不必靠「故意传空数组触发长度不齐」这种暗号。
64
+ const src = Array.isArray(sources) && sources.length === list.length ? sources : null
65
+ const out = list.map((raw, i) => {
59
66
  const text = String(raw ?? '')
60
- const reason = pickReason(flagsFor(src[i] ?? null))
61
- if (reason !== null) {
62
- out.push({ id: skippedId(key, text), preview: previewOf(text), reason })
63
- }
67
+ return { id: skippedId(key, text), preview: previewOf(text), reason: pickReason(flagsFor(src === null ? null : (src[i] ?? null))) }
64
68
  })
65
69
  if (key === 'notes') {
66
70
  const coreNotes = Array.isArray(notes) ? notes : []
@@ -326,9 +326,9 @@ export function ensureIndex(options = {}) {
326
326
  export function queryIndex(index, terms) {
327
327
  const buckets = []
328
328
  for (const term of terms) {
329
- const hits = index.postings?.[term]
330
- if (hits === undefined) buckets.push([])
331
- else buckets.push(hits)
329
+ const postings = index.postings ?? {}
330
+ const hits = Object.hasOwn(postings, term) && Array.isArray(postings[term]) ? postings[term] : []
331
+ buckets.push(hits)
332
332
  }
333
333
  if (buckets.length === 0) return { ids: [], degraded: false }
334
334
 
@@ -365,7 +365,7 @@ export function indexStats(sessions = listSessions()) {
365
365
  const disk = sessions.length
366
366
  // 优先走门牌文件:几十 KB,免得为了报数字去解析 10 MB 索引
367
367
  const meta = readIndexMeta()
368
- if (meta !== null) {
368
+ if (meta !== null && existsSync(indexPath())) {
369
369
  const state = freshnessOfSessions(meta.sessions ?? {}, sessions)
370
370
  return {
371
371
  present: true,
package/lib/index.js CHANGED
@@ -94,7 +94,7 @@ import { buildProfile } from './profile.js'
94
94
  import { writeAtomic } from './atomic.js'
95
95
  import { cursorPath, enqueue, isDelegatedSession, readCursor } from './capture.js'
96
96
  import { searchWarm, warmStats, writeSummary } from './warm.js'
97
- import { buildBundle, mergeBundle, mergePersona, validateBundle } from './transfer.js'
97
+ import { buildBundle, mergeBundle, mergeConfig, mergePersona, validateBundle } from './transfer.js'
98
98
  import { handleEvent } from './capture-run.js'
99
99
 
100
100
  export const name = 'selfnote'
@@ -240,7 +240,7 @@ function currentConfig(inline) {
240
240
  maxEntries: Number.isFinite(maxEntries) && maxEntries > 0 ? Math.min(maxEntries, 200) : DEFAULT_MAX_ENTRIES,
241
241
  profileEnabled: merged.profileEnabled !== false,
242
242
  personaEnabled: merged.personaEnabled !== false,
243
- profileOverride: typeof merged.profileOverride === 'string' ? merged.profileOverride.trim().slice(0, 240) : '',
243
+ profileOverride: typeof merged.profileOverride === 'string' ? [...merged.profileOverride.trim()].slice(0, 240).join('') : '',
244
244
  profileMaxChars: Number.isFinite(Number(merged.profileMaxChars))
245
245
  ? Math.max(20, Math.min(Number(merged.profileMaxChars), 120))
246
246
  : DEFAULT_CONFIG.profileMaxChars,
@@ -250,7 +250,7 @@ function currentConfig(inline) {
250
250
  // 老版本把这句 28 字的抬头写进了 config.json。它只干一件事,现在默认 6 字。
251
251
  // 存的正好是老默认 → 跟着换代;用户自己改过的(不等于老默认)一个字都不动。
252
252
  heading: merged.heading === LEGACY_HEADING ? DEFAULT_CONFIG.heading : merged.heading,
253
- noteGuide: typeof merged.noteGuide === 'string' ? merged.noteGuide.trim().slice(0, GUIDE_LIMIT) : '',
253
+ noteGuide: typeof merged.noteGuide === 'string' ? [...merged.noteGuide.trim()].slice(0, GUIDE_LIMIT).join('') : '',
254
254
  tempTtlDays: Number.isFinite(Number(merged.tempTtlDays))
255
255
  ? Math.max(1, Math.min(Math.round(Number(merged.tempTtlDays)), 90))
256
256
  : DEFAULT_TEMP_TTL_DAYS,
@@ -300,11 +300,11 @@ function packEnv(items, limit) {
300
300
  for (const item of items) {
301
301
  const body = String(item.text ?? '').trim()
302
302
  if (body === '') continue
303
- if (used + body.length > limit) {
303
+ if (used + countChars(body) > limit) {
304
304
  skipped.push(body)
305
305
  continue
306
306
  }
307
- used += body.length + 1
307
+ used += countChars(body) + 1
308
308
  kept.push(body)
309
309
  ids.push(item.id)
310
310
  text = text === '' ? body : `${text};${body}`
@@ -355,7 +355,7 @@ function packEnvFair(items, limit, spotlight = new Set()) {
355
355
  let plan = arrange(reserve)
356
356
  const head = plan.warm[0] ?? plan.cold[0]
357
357
  if (head !== undefined) {
358
- const floor = String(head.text ?? '').trim().length + 1
358
+ const floor = countChars(String(head.text ?? '').trim()) + 1
359
359
  if (floor > reserve) {
360
360
  reserve = Math.min(Math.floor(cap / 2), floor)
361
361
  plan = arrange(reserve)
@@ -500,8 +500,8 @@ function notesSegment(entries, limit, ttl) {
500
500
  let pointer = ''
501
501
  for (let pass = 0; pass < 3; pass += 1) {
502
502
  const next = `另有 ${current.skipped.length} 条自述没装进来(self_note read)`
503
- if (next.length + 1 >= room) break
504
- const refit = packNotes(entries, Math.max(0, room - next.length - 1), ttl)
503
+ if (countChars(next) + 1 >= room) break
504
+ const refit = packNotes(entries, Math.max(0, room - countChars(next) - 1), ttl)
505
505
  const converged = refit.skipped.length === current.skipped.length
506
506
  pointer = next
507
507
  const joined = { ...refit, text: refit.text === '' ? next : `${refit.text} ${next}` }
@@ -665,8 +665,8 @@ function factsSegment(ranked, limit) {
665
665
  let pointer = ''
666
666
  for (let pass = 0; pass < 3; pass += 1) {
667
667
  const next = `另有 ${current.skipped.length} 条结论(memory_recall 查)`
668
- if (next.length + 1 >= limit) break
669
- const refit = packFacts(ranked, Math.max(0, limit - next.length - 1))
668
+ if (countChars(next) + 1 >= limit) break
669
+ const refit = packFacts(ranked, Math.max(0, limit - countChars(next) - 1))
670
670
  const converged = refit.skipped.length === current.skipped.length
671
671
  pointer = next
672
672
  current = { ...refit, text: refit.text === '' ? next : `${refit.text} ${next}` }
@@ -689,12 +689,13 @@ function injectionKey(cfg) {
689
689
  return file + ':-'
690
690
  }
691
691
  })
692
- const profileKey = [cfg.profileEnabled, cfg.profileMaxChars, cfg.profileOverride.length, cfg.profileOverride].join('~')
692
+ const profileKey = [cfg.profileEnabled, cfg.profileMaxChars, countChars(cfg.profileOverride), cfg.profileOverride].join('~')
693
693
  // 临时自述会自己到期:文件一个字没改,跨过保质期那天注入也得重算。
694
694
  // 按「天」进键就够了,不必精确到毫秒 —— 一天最多重算一次。
695
- const dayKey = 'd' + Math.floor(Date.now() / 86400000)
695
+ const now = new Date()
696
+ const dayKey = 'd' + now.getFullYear() + '-' + (now.getMonth() + 1) + '-' + now.getDate()
696
697
  // 半衰期也影响排序(facts / env 段按热度排),不改文件只改它时也得重算
697
- return stamps.join('|') + '|' + cfg.enabled + '|' + cfg.personaEnabled + '|' + cfg.heading + '|' + profileKey + '|hl' + cfg.halfLifeDays + '|ttl' + cfg.tempTtlDays + '|' + dayKey
698
+ return stamps.join('|') + '|' + cfg.enabled + '|' + cfg.personaEnabled + '|' + cfg.heading + '|' + profileKey + '|hl' + cfg.halfLifeDays + '|ttl' + cfg.tempTtlDays + '|guide' + cfg.noteGuide + '|' + dayKey
698
699
  }
699
700
 
700
701
  /**
@@ -756,7 +757,7 @@ function assembleInjection(cfg, options = {}) {
756
757
  key: 'guide',
757
758
  label: '写自述的时机',
758
759
  limit: GUIDE_LIMIT,
759
- chars: guideText.length,
760
+ chars: countChars(guideText),
760
761
  kept: guideText === '' ? [] : [guideText],
761
762
  skipped: [],
762
763
  })
@@ -771,7 +772,7 @@ function assembleInjection(cfg, options = {}) {
771
772
  key: 'profile',
772
773
  label: '画像',
773
774
  limit: BUDGET.profile,
774
- chars: profileText.length,
775
+ chars: countChars(profileText),
775
776
  kept: profileText === '' ? [] : [profileText],
776
777
  skipped: [],
777
778
  })
@@ -787,7 +788,7 @@ function assembleInjection(cfg, options = {}) {
787
788
  // 反复用「这次剩几条」重算门牌,直到条数不再变。不然那句「另有 N 条」会少报。
788
789
  for (let pass = 0; pass < 3 && env.skipped.length > 0; pass += 1) {
789
790
  const pointer = `另有 ${env.skipped.length} 条环境备忘没装进来(memory_recall 查)`
790
- const refit = packEnvFair(envRanked, Math.max(0, BUDGET.env - pointer.length - 1), spotlight)
791
+ const refit = packEnvFair(envRanked, Math.max(0, BUDGET.env - countChars(pointer) - 1), spotlight)
791
792
  const text = refit.text === '' ? pointer : `${refit.text};${pointer}`
792
793
  const converged = refit.skipped.length === env.skipped.length
793
794
  env = { ...refit, text }
@@ -851,8 +852,10 @@ function assembleInjection(cfg, options = {}) {
851
852
  seg.skippedDetail = describeSkipped({
852
853
  key: seg.key,
853
854
  skipped: Array.isArray(seg.skipped) ? seg.skipped : [],
854
- // 非 notes 段传空数组:describeSkipped 见长度不齐就整批保守降级成 budget,正是它们该有的值
855
- sources: seg.key === 'notes' && Array.isArray(noteFit.skippedSources) ? noteFit.skippedSources : [],
855
+ // 非 notes 段没有逐条来源:显式给等长的 budget 数组,不再靠「传空触发长度不齐」这种暗号
856
+ sources: seg.key === 'notes' && Array.isArray(noteFit.skippedSources)
857
+ ? noteFit.skippedSources
858
+ : (Array.isArray(seg.skipped) ? seg.skipped : []).map(() => 'budget'),
856
859
  notes: seg.key === 'notes' ? noteEntries : [],
857
860
  })
858
861
  }
@@ -1849,7 +1852,7 @@ function currentProfile(cfg) {
1849
1852
  const config = cfg ?? currentConfig()
1850
1853
  if (config.profileEnabled !== true) return { text: '', parts: [], conflicts: [], sources: [], chars: 0, manual: false }
1851
1854
  if (config.profileOverride !== '') {
1852
- return { text: config.profileOverride, parts: [], conflicts: [], sources: [], chars: config.profileOverride.length, manual: true }
1855
+ return { text: config.profileOverride, parts: [], conflicts: [], sources: [], chars: countChars(config.profileOverride), manual: true }
1853
1856
  }
1854
1857
  const built = buildProfile(listFacts(), { maxChars: config.profileMaxChars })
1855
1858
  return { ...built, manual: false }
@@ -1916,22 +1919,42 @@ const TIER_WORD = { core: '核心身份', stable: '稳定偏好', temp: '临时
1916
1919
  function noteCapacityWarning(tier, text, inline) {
1917
1920
  const cfg = currentConfig(inline)
1918
1921
  if (cfg.enabled === false) return '注入已关闭,这条暂时不会进上下文。'
1919
- const key = normalizeTier(tier)
1920
- const body = String(text ?? '')
1922
+ const entries = readNotes()
1923
+ // 调用点在 appendNote 之后:最后一条就是刚写的。旧版在这里又凭空补一条,
1924
+ // 等于把新条试装了两遍、额度算得偏紧;现在直接用落盘后的真实列表。
1925
+ const last = entries.at(-1)
1926
+ const key = normalizeTier(last?.tier ?? tier)
1927
+ const body = String(last?.text ?? text ?? '')
1921
1928
  // 核心是素材,不进注入:这时候说「超了额度」是骗人,得直接说清楚它去哪儿了
1922
1929
  if (key === 'core') {
1923
1930
  return '核心身份不再参与注入(它是提炼人格的素材)—— 想让它每回合生效,写进人格段:self_note action:"persona"。'
1924
1931
  }
1925
- // 拿真实额度试装一次:把这条当成已经写进去,看它自己进不进得来。
1932
+ // 拿真实额度试装一次:看这条自己进不进得来。
1926
1933
  // 光比「整段 200 字」不够 —— 稳定先占一份、临时只留 1/4 保底,
1927
1934
  // 一条 110 字的临时自述会顺利通过 200 字的检查,然后每回合被静默跳过。
1928
- const probe = packNotes([...readNotes(), { tier: key, text: body }], BUDGET.notes, cfg.tempTtlDays)
1929
- if (probe.kept.includes(body)) return ''
1930
- const info = probe.tiers[key]
1931
- if (body.length > info.limit) {
1932
- return `这条 ${body.length} 字,超过${TIER_WORD[key]}段这轮实际能用的 ${info.limit} 字(整段 ${BUDGET.notes} 字,别的型先占了位),模型每回合都看不到它 —— 要么写短,要么拆成几条。`
1935
+ const probe = packNotes(entries, BUDGET.notes, cfg.tempTtlDays)
1936
+ if (!probe.kept.includes(body)) {
1937
+ const info = probe.tiers[key]
1938
+ if (countChars(body) > info.limit) {
1939
+ return `这条 ${countChars(body)} 字,超过${TIER_WORD[key]}段这轮实际能用的 ${info.limit} 字(整段 ${BUDGET.notes} 字,别的型先占了位),模型每回合都看不到它 —— 要么写短,要么拆成几条。`
1940
+ }
1941
+ return `${TIER_WORD[key]}现在共 ${info.total} 条,这轮有 ${info.skipped} 条被挤掉(排在前面的才进上下文)。`
1942
+ }
1943
+ // 自己进来了,不等于没出事:线上翻过一次 —— 一条 95 字长条自己进得来,
1944
+ // 却把原本全进的短规则挤了出去;旧版只问「这条进不进得来」,于是一声不吭。
1945
+ // 拿「写入前」的列表打包做基线,按文本多重集比少了哪些条(同文重复也算得清)。
1946
+ const before = packNotes(entries.slice(0, -1), BUDGET.notes, cfg.tempTtlDays)
1947
+ const pool = new Map()
1948
+ for (const kept of probe.kept) pool.set(kept, (pool.get(kept) ?? 0) + 1)
1949
+ pool.set(body, (pool.get(body) ?? 0) - 1) // 新写的那一份本来就不在基线里
1950
+ let displaced = 0
1951
+ for (const kept of before.kept) {
1952
+ const left = pool.get(kept) ?? 0
1953
+ if (left > 0) pool.set(kept, left - 1)
1954
+ else displaced += 1
1933
1955
  }
1934
- return `${TIER_WORD[key]}现在共 ${info.total} 条,这轮有 ${info.skipped} 条被挤掉(排在前面的才进上下文)。`
1956
+ if (displaced === 0) return ''
1957
+ return `这条 ${countChars(body)} 字自己进了上下文,但把 ${displaced} 条自述挤掉了(排在前面的才进上下文)—— 写短些,或先把旧规则整理归档。`
1935
1958
  }
1936
1959
 
1937
1960
  function renderText(args, value) {
@@ -2218,7 +2241,7 @@ export function currentAuthInjection() {
2218
2241
  function injectTokenScript(html) {
2219
2242
  if (authToken === '') return html
2220
2243
  if (html.includes('__DSH_SELFNOTE_TOKEN__')) return html
2221
- const script = `<script>window.__DSH_SELFNOTE_TOKEN__=${JSON.stringify(authToken)}</script>`
2244
+ const script = `<script>window.__DSH_SELFNOTE_TOKEN__=${JSON.stringify(authToken).replace(/</g, '\\u003c')}</script>`
2222
2245
  if (html.includes('</head>')) {
2223
2246
  authInjected = 'head'
2224
2247
  return html.replace('</head>', `${script}</head>`)
@@ -2443,7 +2466,7 @@ export function apply(ctx, config) {
2443
2466
  return {
2444
2467
  action,
2445
2468
  entries: entries.length,
2446
- chars: renderNotes(entries).length,
2469
+ chars: countChars(renderNotes(entries)),
2447
2470
  maxChars: limits.maxChars,
2448
2471
  text: renderNotes(entries),
2449
2472
  }
@@ -2662,7 +2685,7 @@ export function apply(ctx, config) {
2662
2685
  if (typeof body.heading === 'string') {
2663
2686
  const value = body.heading.trim()
2664
2687
  // 抬头是注入的第一行,长度不设限等于让人用一句话吃掉整个预算
2665
- if (value.length > HEADING_LIMIT) {
2688
+ if (countChars(value) > HEADING_LIMIT) {
2666
2689
  return sendJson(res, 400, { ok: false, error: `抬头不能超过 ${HEADING_LIMIT} 字` })
2667
2690
  }
2668
2691
  next.heading = value
@@ -2671,7 +2694,7 @@ export function apply(ctx, config) {
2671
2694
  if (typeof body.noteGuide !== 'string') {
2672
2695
  return sendJson(res, 400, { ok: false, error: 'noteGuide 要是字符串' })
2673
2696
  }
2674
- if (body.noteGuide.trim().length > GUIDE_LIMIT) {
2697
+ if (countChars(body.noteGuide.trim()) > GUIDE_LIMIT) {
2675
2698
  return sendJson(res, 400, { ok: false, error: `noteGuide 不能超过 ${GUIDE_LIMIT} 字` })
2676
2699
  }
2677
2700
  next.noteGuide = body.noteGuide.trim()
@@ -2681,7 +2704,7 @@ export function apply(ctx, config) {
2681
2704
  if (typeof body.profileOverride !== 'string') {
2682
2705
  return sendJson(res, 400, { ok: false, error: 'profileOverride 要是字符串' })
2683
2706
  }
2684
- next.profileOverride = body.profileOverride.slice(0, 240)
2707
+ next.profileOverride = [...body.profileOverride].slice(0, 240).join('')
2685
2708
  }
2686
2709
  if (body.halfLifeDays !== undefined) {
2687
2710
  const value = Number(body.halfLifeDays)
@@ -2712,7 +2735,7 @@ export function apply(ctx, config) {
2712
2735
  const text = typeof body?.text === 'string' ? body.text.trim() : ''
2713
2736
  if (text === '') return sendJson(res, 400, { ok: false, error: '内容不能为空' })
2714
2737
  const result = appendNote(text, limitsOf(currentConfig(inline)), { tier: body?.tier, pin: body?.pin })
2715
- auditAction('note-add', { tier: result.tier, pin: result.pin, chars: text.length }, req)
2738
+ auditAction('note-add', { tier: result.tier, pin: result.pin, chars: countChars(text) }, req)
2716
2739
  return sendJson(res, 200, {
2717
2740
  ...stateOf(inline),
2718
2741
  noteAdd: { tier: result.tier, pin: result.pin, warning: noteCapacityWarning(result.tier, text, inline) },
@@ -2801,7 +2824,8 @@ export function apply(ctx, config) {
2801
2824
  return sendJson(res, 400, { ok: false, error: `导入包里的人格用不了:${verdict.error}` })
2802
2825
  }
2803
2826
  }
2804
- const limits = limitsOf(currentConfig(inline))
2827
+ const configPlan = mergeConfig(incoming, readConfig())
2828
+ const limits = limitsOf({ ...currentConfig(inline), ...configPlan.config })
2805
2829
  const before = { notes: readNotes().length, facts: listFacts().length, habits: readHabits().length }
2806
2830
  const merged = mergeBundle(
2807
2831
  incoming,
@@ -2814,6 +2838,7 @@ export function apply(ctx, config) {
2814
2838
  const { kept: trimmedNotes } = trim(merged.notes, limits)
2815
2839
  writeNotes(trimmedNotes)
2816
2840
  writeHabits(merged.habits)
2841
+ if (configPlan.applied.length > 0) writeConfig({ ...readConfig(), ...configPlan.config })
2817
2842
  // 人格:目标空就采用,一模一样就跳过,两边都有且不同就落旁路文件 —— 不覆盖
2818
2843
  let personaFile = ''
2819
2844
  if (personaPlan.action === 'adopt') {
@@ -2835,6 +2860,7 @@ export function apply(ctx, config) {
2835
2860
  after: { notes: trimmedNotes.length, facts: merged.facts.length, habits: merged.habits.length },
2836
2861
  persona: { action: personaPlan.action, file: personaFile },
2837
2862
  indexStale: true,
2863
+ configApplied: configPlan.applied.length,
2838
2864
  }
2839
2865
  auditAction('import', { added: merged.added, keepStatus: merged.keepStatus }, req)
2840
2866
  return sendJson(res, 200, { ...stateOf(inline), import: report })
package/lib/memory.js CHANGED
@@ -25,6 +25,7 @@ import { join } from 'node:path'
25
25
  import { zstdDecompressSync } from 'node:zlib'
26
26
 
27
27
  import { writeAtomic, writeJsonAtomic } from './atomic.js'
28
+ import { countChars } from './store.js'
28
29
 
29
30
  const SESSION_FILE = 'session.v4.jsonl.zstd'
30
31
  /** 缓存格式版本:解析规则变了就 +1,旧缓存自动作废。 */
@@ -759,7 +760,7 @@ export function mutateFacts(mutate, attempts = 5, deadlineMs = 150) {
759
760
  export function stageFact(input = {}) {
760
761
  const text = String(input.text ?? '').trim()
761
762
  if (text === '') return { ok: false, error: 'text 不能为空' }
762
- if (text.length > 240) return { ok: false, error: '一条事实最多 240 字,拆成两条' }
763
+ if (countChars(text) > 240) return { ok: false, error: '一条事实最多 240 字,拆成两条' }
763
764
  const check = checkCitation(input.citation)
764
765
  if (!check.ok) return { ok: false, error: check.error }
765
766
 
@@ -798,7 +799,7 @@ export function stageFact(input = {}) {
798
799
  export function setFactText(id, text, meta = {}) {
799
800
  const clean = String(text ?? '').replace(/\s+/g, ' ').trim()
800
801
  if (clean === '') return { ok: false, error: '内容不能为空' }
801
- if (clean.length > 240) return { ok: false, error: '一条事实最多 240 字,拆成两条' }
802
+ if (countChars(clean) > 240) return { ok: false, error: '一条事实最多 240 字,拆成两条' }
802
803
  let before = ''
803
804
  let updated = null
804
805
  const outcome = mutateFacts((facts) => {
@@ -885,8 +886,9 @@ export function touchFacts(ids, now = Date.now()) {
885
886
  // 而 'YYYY-MM-DD' 解析出来是当天 00:00 —— 过了午夜那一小时窗口就永远失效,
886
887
  // 每装配一次都会 +1(实测 25 分钟能连涨 3 次,把「进过几次」记成「算过几次」)。
887
888
  const stamp = new Date(now).toISOString()
888
- // 热度是软数据:抢不到锁就这回合先不记(lastHit 没变,下一回合还会再试),
889
- // 别为了记一次点击把事件循环卡住 —— 所以给它的重试窗口比别的写入短。
889
+ // 热度是软数据:抢不到锁就这回合先不记(lastHit 没变,下一回合还会再试)。
890
+ // 机会比硬写少(2 次 vs 5 次),但每次等锁的窗口给足:20ms 在 16 并发下几乎必输,
891
+ // test:accept 曾因此偶发「热度没落下:t2(返回 0)」。
890
892
  const outcome = mutateFacts((facts) => {
891
893
  let changed = 0
892
894
  for (const fact of facts) {
@@ -898,7 +900,7 @@ export function touchFacts(ids, now = Date.now()) {
898
900
  changed += 1
899
901
  }
900
902
  return { changed: changed > 0, count: changed }
901
- }, 2, 20)
903
+ }, 2, 150)
902
904
  return outcome.count ?? 0
903
905
  }
904
906
 
@@ -915,7 +917,7 @@ export function seenFacts(ids, now = Date.now()) {
915
917
  const wanted = new Set(ids ?? [])
916
918
  if (wanted.size === 0) return 0
917
919
  const stamp = new Date(now).toISOString()
918
- // 和 touchFacts 一样是软写入:抢不到锁就这次先不记,别为一次记账卡住事件循环。
920
+ // 和 touchFacts 一样是软写入:2 次机会、每次等锁 150ms,抢不到就这次先不记。
919
921
  const outcome = mutateFacts((facts) => {
920
922
  let changed = 0
921
923
  for (const fact of facts) {
@@ -927,7 +929,7 @@ export function seenFacts(ids, now = Date.now()) {
927
929
  changed += 1
928
930
  }
929
931
  return { changed: changed > 0, count: changed }
930
- }, 2, 20)
932
+ }, 2, 150)
931
933
  return outcome.count ?? 0
932
934
  }
933
935
 
@@ -978,7 +980,7 @@ export function setHabit(text, status, tag) {
978
980
  const wanted = String(status ?? '')
979
981
  const clean = String(text ?? '').trim()
980
982
  if (clean === '') return { ok: false, error: 'text 不能为空' }
981
- if (clean.length > 80) return { ok: false, error: '一条习惯最多 80 字' }
983
+ if (countChars(clean) > 80) return { ok: false, error: '一条习惯最多 80 字' }
982
984
  if (!['active', 'retired'].includes(wanted)) return { ok: false, error: 'status 只能是 active / retired' }
983
985
 
984
986
  const habits = readHabits()
package/lib/persona-tx.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * 恢复时只是「把文件写成目标状态」,重放多少次结果都一样。
8
8
  */
9
9
  import { createHash, randomBytes } from 'node:crypto'
10
- import { closeSync, existsSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from 'node:fs'
10
+ import { closeSync, existsSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
11
11
  import { join } from 'node:path'
12
12
  import { selfnoteRoot } from './store.js'
13
13
  import { writeAtomic, writeJsonAtomic } from './atomic.js'
@@ -111,7 +111,8 @@ export function lockState(now = Date.now()) {
111
111
  } catch {
112
112
  return { exists: true, stale: true, pid: 0, ageMs: 0 }
113
113
  }
114
- const stale = !pidAlive(pid) || !Number.isFinite(ageMs) || ageMs > STALE_LOCK_MS
114
+ const empty = (() => { try { return readFileSync(LOCK_PATH, 'utf8').trim() === '' } catch { return false } })()
115
+ const stale = !empty && (!pidAlive(pid) || !Number.isFinite(ageMs) || ageMs > STALE_LOCK_MS)
115
116
  return { exists: true, stale, pid: Number.isInteger(pid) ? pid : 0, ageMs }
116
117
  }
117
118
 
@@ -128,29 +129,19 @@ function clearStaleLock() {
128
129
 
129
130
  /** 跨进程互斥:O_EXCL 建锁,拿不到就是「有人正在保存」,直接报错而不是排队等死。 */
130
131
  export async function withPersonaLock(job) {
131
- let fd
132
132
  try {
133
- fd = openSync(LOCK_PATH, 'wx')
133
+ writeFileSync(LOCK_PATH, String(process.pid), { flag: 'wx' })
134
134
  } catch (error) {
135
135
  if (error?.code !== 'EEXIST') throw error
136
136
  // 上次被强杀的残留锁:清掉再试一次,否则「保存人格」会永久 409
137
137
  if (!clearStaleLock()) throw new LockedError()
138
138
  try {
139
- fd = openSync(LOCK_PATH, 'wx')
139
+ writeFileSync(LOCK_PATH, String(process.pid), { flag: 'wx' })
140
140
  } catch (retryError) {
141
141
  if (retryError?.code === 'EEXIST') throw new LockedError()
142
142
  throw retryError
143
143
  }
144
144
  }
145
- try {
146
- try {
147
- writeSync(fd, String(process.pid))
148
- } catch {
149
- // 写不进去不影响互斥本身
150
- }
151
- } finally {
152
- closeSync(fd)
153
- }
154
145
  try {
155
146
  return await job()
156
147
  } finally {
package/lib/profile.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * - 同一标签下多条时不擅自归纳,只取最新并标注「另有 N 条」
9
9
  */
10
10
 
11
+ import { countChars } from './store.js'
12
+
11
13
  const DEFAULT_MAX_CHARS = 80
12
14
  const DEFAULT_PIECE_MAX = 60
13
15
 
@@ -51,10 +53,10 @@ export function buildProfile(facts, options = {}) {
51
53
  const latest = bucket.slice().sort((a, b) => (stamp(a) < stamp(b) ? 1 : stamp(a) > stamp(b) ? -1 : 0))[0]
52
54
  const body = String(latest.text ?? '').trim()
53
55
  if (body === '') continue
54
- if (body.length > pieceMax) continue
56
+ if (countChars(body) > pieceMax) continue
55
57
  const piece = `${tag}:${body}`
56
58
  const candidate = text === '' ? piece : `${text};${piece}`
57
- if (candidate.length > maxChars) continue
59
+ if (countChars(candidate) > maxChars) continue
58
60
  text = candidate
59
61
  parts.push({ tag, factId: latest.id, text: body })
60
62
  if (bucket.length > 1) conflicts.push({ tag, count: bucket.length - 1 })
@@ -64,7 +66,7 @@ export function buildProfile(facts, options = {}) {
64
66
  text,
65
67
  parts,
66
68
  conflicts,
67
- chars: text.length,
69
+ chars: countChars(text),
68
70
  sources: parts.map((part) => part.factId),
69
71
  }
70
72
  }
package/lib/store.js CHANGED
@@ -15,6 +15,16 @@ import { join } from 'node:path'
15
15
  import { writeAtomic, writeJsonAtomic } from './atomic.js'
16
16
 
17
17
  /** 当前生效内容的上限(字符)。默认 1600 字,约 1.1k token。 */
18
+ export function countChars(text) {
19
+ return [...String(text ?? '')].length
20
+ }
21
+
22
+ /** 按码点截断:不把 emoji / 增补汉字的代理对切一半。 */
23
+ export function truncateChars(text, max) {
24
+ const room = Math.max(0, Math.floor(max))
25
+ return [...String(text ?? '')].slice(0, room).join('')
26
+ }
27
+
18
28
  export const DEFAULT_MAX_CHARS = 1600
19
29
  /** 条数上限,和字符上限共同生效。 */
20
30
  export const DEFAULT_MAX_ENTRIES = 40
@@ -94,7 +104,7 @@ export function today() {
94
104
 
95
105
  /** 正文归一:回车换行压成空格、去掉首尾空白、截到硬上限。 */
96
106
  function cleanEntryText(text) {
97
- return String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_ENTRY_CHARS)
107
+ return truncateChars(String(text ?? '').replace(/\s+/g, ' ').trim(), MAX_ENTRY_CHARS)
98
108
  }
99
109
 
100
110
  /**
@@ -166,12 +176,12 @@ export function writeNotes(entries) {
166
176
  export function trim(entries, { maxChars = DEFAULT_MAX_CHARS, maxEntries = DEFAULT_MAX_ENTRIES } = {}) {
167
177
  const kept = [...entries]
168
178
  const overflow = []
169
- let total = renderNotes(kept).length
179
+ let total = countChars(renderNotes(kept))
170
180
  while (kept.length > maxEntries || (total > maxChars && kept.length > 1)) {
171
181
  const dropped = kept.shift()
172
182
  if (!dropped) break
173
183
  overflow.push(dropped)
174
- total = renderNotes(kept).length
184
+ total = countChars(renderNotes(kept))
175
185
  }
176
186
  return { kept, overflow }
177
187
  }
@@ -290,7 +300,7 @@ export function stats() {
290
300
  }
291
301
  return {
292
302
  entries: entries.length,
293
- chars: renderNotes(entries).length,
303
+ chars: countChars(renderNotes(entries)),
294
304
  // 三型各几条:设置页要能解释「为什么我这条没进上下文」
295
305
  tiers: countTiers(entries),
296
306
  pinned: entries.filter((entry) => entry.pin === true).length,
package/lib/transfer.js CHANGED
@@ -9,6 +9,8 @@
9
9
  * 索引不导出:它记的是磁盘路径与 mtime,搬过去必然过期,导入后重建一次就行。
10
10
  */
11
11
 
12
+ import { normalizeTier } from './store.js'
13
+
12
14
  export const BUNDLE_VERSION = 2
13
15
  export const BUNDLE_APP = 'dsh-selfnote'
14
16
 
@@ -55,7 +57,7 @@ export function buildBundle({ notes = [], facts = [], habits = [], config = {},
55
57
  v: BUNDLE_VERSION,
56
58
  exportedAt: now.toISOString(),
57
59
  counts: { notes: notes.length, facts: facts.length, habits: habits.length },
58
- notes: notes.map((entry) => ({ date: entry.date ?? '', text: entry.text ?? '' })),
60
+ notes: notes.map((entry) => ({ date: entry.date ?? '', text: entry.text ?? '', tier: normalizeTier(entry.tier), pin: entry.pin === true })),
59
61
  facts: facts.map((fact) => ({ ...fact })),
60
62
  habits: habits.map((habit) => ({ ...habit })),
61
63
  // 人格跟着走(v2);纠偏候选不跟 —— 它是「这台机器上攒下的证据」,搬过去就失了上下文
@@ -65,6 +67,26 @@ export function buildBundle({ notes = [], facts = [], habits = [], config = {},
65
67
  }
66
68
 
67
69
  /** 导入前的体检:结构不对就直说,别合并一半。 */
70
+ export function mergeConfig(bundle, current = {}) {
71
+ const source = isObject(bundle?.config) ? bundle.config : {}
72
+ const base = isObject(current) ? current : {}
73
+ const config = {}
74
+ for (const field of CONFIG_FIELDS) if (base[field] !== undefined) config[field] = base[field]
75
+ const applied = []
76
+ const bools = new Set(['enabled', 'profileEnabled', 'personaEnabled'])
77
+ const nums = new Set(['maxChars', 'maxEntries', 'profileMaxChars', 'halfLifeDays', 'tempTtlDays'])
78
+ for (const field of CONFIG_FIELDS) {
79
+ if (!Object.hasOwn(source, field)) continue
80
+ const value = source[field]
81
+ const good = bools.has(field) ? typeof value === 'boolean'
82
+ : nums.has(field) ? typeof value === 'number' && Number.isFinite(value)
83
+ : typeof value === 'string'
84
+ if (!good) continue
85
+ config[field] = value
86
+ applied.push(field)
87
+ }
88
+ return { config, applied }
89
+ }
68
90
  export function validateBundle(bundle) {
69
91
  if (!isObject(bundle)) return '不是 JSON 对象'
70
92
  if (bundle.app !== BUNDLE_APP) return `不是本插件导出的文件(app=${String(bundle.app)})`
@@ -138,11 +160,8 @@ export function mergeBundle(bundle, current = {}, options = {}) {
138
160
  continue
139
161
  }
140
162
  const status = textOf(raw.status) || 'candidate'
141
- const fact = { ...raw, text }
142
- if (!keepStatus && status !== 'candidate') {
143
- fact.status = 'candidate'
144
- demoted.push(id)
145
- }
163
+ const fact = { ...raw, text, status: keepStatus ? status : 'candidate' }
164
+ if (!keepStatus && status !== 'candidate') demoted.push(id)
146
165
  if (fact.status === 'candidate') {
147
166
  delete fact.reviewedAt
148
167
  delete fact.retiredAt
@@ -164,7 +183,7 @@ export function mergeBundle(bundle, current = {}, options = {}) {
164
183
  skipped.notes += 1
165
184
  continue
166
185
  }
167
- notes.push({ date: textOf(raw.date) || '', text })
186
+ notes.push({ date: textOf(raw.date) || '', text, tier: normalizeTier(raw.tier), pin: raw.pin === true })
168
187
  seenNotes.add(text)
169
188
  added.notes += 1
170
189
  }
package/lib/warm.js CHANGED
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
13
 
14
14
  import { memoryRoot, readFacts, tokenize } from './memory.js'
15
+ import { countChars } from './store.js'
15
16
  import { writeAtomic } from './atomic.js'
16
17
 
17
18
  const MAX_TEXT = 300
@@ -19,6 +20,16 @@ const MAX_POINTERS = 8
19
20
 
20
21
  export const warmPath = () => join(memoryRoot(), 'warm.jsonl')
21
22
 
23
+ let pendingBadRows = []
24
+ function collectBadRows(rows) {
25
+ for (const row of rows) if (!pendingBadRows.includes(row)) pendingBadRows.push(row)
26
+ }
27
+ function flushBadRows() {
28
+ if (pendingBadRows.length === 0) return
29
+ mkdirSync(memoryRoot(), { recursive: true })
30
+ writeAtomic(join(memoryRoot(), `warm.bad-${Date.now().toString(36)}-${process.pid}.jsonl`), pendingBadRows.join('\n') + '\n')
31
+ pendingBadRows = []
32
+ }
22
33
  export function readWarm() {
23
34
  const file = warmPath()
24
35
  if (!existsSync(file)) return []
@@ -28,8 +39,10 @@ export function readWarm() {
28
39
  try {
29
40
  const item = JSON.parse(line)
30
41
  if (item && typeof item === 'object') out.push(item)
42
+ else collectBadRows([line])
31
43
  } catch {
32
- // 坏行跳过:一条摘要读不出来,不该让整层都废掉
44
+ // 坏行跳过:隔离留存,别在下一次写回时无声抹掉
45
+ collectBadRows([line])
33
46
  }
34
47
  }
35
48
  return out
@@ -39,6 +52,7 @@ function writeWarm(items) {
39
52
  // 目录可能还不存在(第一次写温层)
40
53
  mkdirSync(memoryRoot(), { recursive: true })
41
54
  writeAtomic(warmPath(), items.map((item) => JSON.stringify(item)).join('\n') + (items.length > 0 ? '\n' : ''))
55
+ flushBadRows()
42
56
  }
43
57
 
44
58
  function validPointer(pointer) {
@@ -55,7 +69,7 @@ function validPointer(pointer) {
55
69
  export function writeSummary(input = {}) {
56
70
  const text = String(input.text ?? '').trim()
57
71
  if (text === '') return { ok: false, error: 'text 不能为空' }
58
- if (text.length > MAX_TEXT) return { ok: false, error: `一条摘要最多 ${MAX_TEXT} 字,拆成两条` }
72
+ if (countChars(text) > MAX_TEXT) return { ok: false, error: `一条摘要最多 ${MAX_TEXT} 字,拆成两条` }
59
73
 
60
74
  const raw = Array.isArray(input.pointers) ? input.pointers : []
61
75
  const pointers = raw
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-selfnote",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "type": "module",
5
5
  "description": "Self-narrative memory for DeepSeek Harness: the model writes notes to itself, which persist across sessions and are injected back. | 人格库:模型写给自己看的话,跨会话保留并每回合注入。",
6
6
  "keywords": [
@@ -27,7 +27,7 @@
27
27
  "assets"
28
28
  ],
29
29
  "scripts": {
30
- "test": "node tests/smoke.mjs && node tests/memory.mjs && node tests/roles.mjs && node tests/heat.mjs && node tests/fair.mjs && node tests/capture-debug.mjs && node tests/facts-tx.mjs && node tests/crash-write.mjs && node tests/capture.mjs && node tests/capture-p1b.mjs && node tests/index.mjs && node tests/incremental.mjs && node tests/profile.mjs && node tests/warm.mjs && node tests/transfer.mjs && node tests/api.mjs && node tests/explain.mjs && node tests/skipped.mjs && node tests/injection-count.mjs && node tests/notes-tier.mjs && node tests/persona.mjs && node tests/corrections.mjs && node tests/client-state.mjs && node tests/client.mjs",
30
+ "test": "node tests/smoke.mjs && node tests/memory.mjs && node tests/roles.mjs && node tests/heat.mjs && node tests/fair.mjs && node tests/capture-debug.mjs && node tests/facts-tx.mjs && node tests/crash-write.mjs && node tests/capture.mjs && node tests/capture-p1b.mjs && node tests/index.mjs && node tests/incremental.mjs && node tests/profile.mjs && node tests/warm.mjs && node tests/transfer.mjs && node tests/api.mjs && node tests/explain.mjs && node tests/skipped.mjs && node tests/injection-count.mjs && node tests/notes-tier.mjs && node tests/capacity-warning.mjs && node tests/atomic-retry.mjs && node tests/unicode-rulers.mjs && node tests/transfer-roundtrip.mjs && node tests/r3-regressions.mjs && node tests/persona.mjs && node tests/corrections.mjs && node tests/client-state.mjs && node tests/client.mjs",
31
31
  "test:accept": "node tests/acceptance-runtime.mjs"
32
32
  },
33
33
  "engines": {