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 +17 -12
- package/lib/atomic.js +38 -1
- package/lib/capture-run.js +7 -4
- package/lib/capture.js +2 -1
- package/lib/client.js +17 -22
- package/lib/corrections.js +15 -1
- package/lib/explain.js +12 -8
- package/lib/index-store.js +4 -4
- package/lib/index.js +61 -35
- package/lib/memory.js +10 -8
- package/lib/persona-tx.js +5 -14
- package/lib/profile.js +5 -3
- package/lib/store.js +14 -4
- package/lib/transfer.js +26 -7
- package/lib/warm.js +16 -2
- package/package.json +2 -2
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.
|
|
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-
|
|
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()`
|
|
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
|
|
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 字**,等于原先
|
|
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
|
|
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 #
|
|
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/
|
|
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
|
-
|
|
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')
|
package/lib/capture-run.js
CHANGED
|
@@ -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
|
|
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
|
|
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: {
|
|
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
|
|
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-' +
|
|
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(--
|
|
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:
|
|
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) {
|
package/lib/corrections.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
57
|
-
const
|
|
58
|
-
list.
|
|
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
|
-
|
|
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 : []
|
package/lib/index-store.js
CHANGED
|
@@ -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
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
303
|
+
if (used + countChars(body) > limit) {
|
|
304
304
|
skipped.push(body)
|
|
305
305
|
continue
|
|
306
306
|
}
|
|
307
|
-
used += body
|
|
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()
|
|
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
|
|
504
|
-
const refit = packNotes(entries, Math.max(0, room - next
|
|
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
|
|
669
|
-
const refit = packFacts(ranked, Math.max(0, limit - next
|
|
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
|
|
692
|
+
const profileKey = [cfg.profileEnabled, cfg.profileMaxChars, countChars(cfg.profileOverride), cfg.profileOverride].join('~')
|
|
693
693
|
// 临时自述会自己到期:文件一个字没改,跨过保质期那天注入也得重算。
|
|
694
694
|
// 按「天」进键就够了,不必精确到毫秒 —— 一天最多重算一次。
|
|
695
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
855
|
-
sources: seg.key === 'notes' && Array.isArray(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
|
|
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
|
|
1920
|
-
|
|
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(
|
|
1929
|
-
if (probe.kept.includes(body))
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
56
|
+
if (countChars(body) > pieceMax) continue
|
|
55
57
|
const piece = `${tag}:${body}`
|
|
56
58
|
const candidate = text === '' ? piece : `${text};${piece}`
|
|
57
|
-
if (candidate
|
|
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
|
|
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()
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
|
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.
|
|
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": {
|