dsh-soul-forging 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 memory-kit contributors
3
+ Copyright (c) 2026 晴晚
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/NOTICE.md CHANGED
@@ -1,15 +1,143 @@
1
- # 来源说明
1
+ # 来源与归属
2
2
 
3
- ## 本套件
3
+ > 这一页写清楚:哪些是我们自己写的,哪些是站在别人肩膀上,哪些根本没用别人的。
4
+ > **自己写的部分,署名归 晴晚;MIT 授权,但署名不许抹。**
4
5
 
5
- 代码为本项目重写实现(MIT)。
6
+ ## 一、属于本项目的原创部分
6
7
 
7
- ## 思路参考
8
+ 以下全部为本项目**从零实现**,没有复制任何现有项目的源代码:
8
9
 
9
- - [dsh-plugin-memory](https://github.com/LittleBlackTong/dsh-plugin-memory) —— MIT。
10
- 本项目**没有复制它的源代码**;如果你把那个插件装进 DSH 使用,请单独保留它自己的 LICENSE 与版权声明。
11
- 本套件受它启发的地方:markdown 记忆库 + 每轮注入 + index 路由表这一整套思路。
10
+ | 部分 | 说明 |
11
+ |---|---|
12
+ | `brain/index.py` | markdown → 检索条目的切分、摘要派生、条目分级 |
13
+ | `brain/server.py` | 本地检索服务(bigram 打分、强度衰减、热度加权) |
14
+ | `brain/soul_lib.py` | 记忆库读取、frontmatter 解析、注入预算与截断 |
15
+ | `soul.py` | 注入块拼装、三种模式(full / memory-only / external)、预算明示 |
16
+ | `lib/plugin.js` | DSH 插件接入、注入钩子、HTML 注释剥离 |
17
+ | `bin/*` | 全部命令(`soul` / `soul-boot` / `soul-sync` / `soul-purge` / …) |
18
+ | `scripts/smoke.sh` | 装机烟测:模拟全新机器 + 干净 HOME,逐项断言 |
19
+ | **「本质 + 注释层 + 原料」三层人格模型** | 见 `memory/SOUL.md`、`memory/identity/注释层.md` |
20
+ | **注入预算「被截断 / 整段没进都要说出来」** | 见 `soul.py` 的 `cut` / `dropped` |
21
+ | **`soul-purge` 真遗忘**(整行删 + 重生 git + 只留哈希指纹) | 见 `bin/forget` |
22
+ | `memory/` 下全部模板文字 | 规则、清单、README |
12
23
 
13
- ## 用到的第三方
24
+ ## 二、继承了什么,加了什么
14
25
 
15
- 无。只需要 Python 标准库和(可选的)curl。
26
+ ### 底层的记忆架构来自 dsh-plugin-memory(MIT)
27
+
28
+ 这个项目的**骨架是「重实现」,不是凭空设计**。底下这套东西来自:
29
+
30
+ > **[dsh-plugin-memory](https://github.com/LittleBlackTong/dsh-plugin-memory)** · v0.10.0 · MIT
31
+ > `Copyright (c) 2026 dsh-plugin-memory contributors`
32
+
33
+ **从它那里继承的设计**(不是拷代码,是照它的做法重写):
34
+
35
+ - 记忆库放 markdown,`SOUL / MEMORY / index / NOW / log` 这几个页面各管一件事;
36
+ - `index.md` 当路由表,先查目录再钻页面;
37
+ - 每轮把这几页注入上下文,收尾做 digest;
38
+ - 「raw 只读」「归档优先于删除」这类基本纪律。
39
+
40
+ ### 我们加的那部分
41
+
42
+ | 加的东西 | 对方有没有 |
43
+ |---|---|
44
+ | **小脑**(`brain/` 检索服务:bigram 打分、强度衰减、热度加权) | **没有** |
45
+ | **注释层**(中间那层「我此刻的样子」) | **没有** |
46
+ | **注入预算明示**(被截断 / 整段没进都说出来) | **没有** |
47
+ | **三种模式**(full / memory-only / external) | **没有** |
48
+ | **`soul-purge` 真遗忘**(整行删 + 重生 git + 只留哈希) | **没有** |
49
+ | **装机烟测**(模拟全新机器,逐项断言) | **没有** |
50
+ | 全部命令、Python 实现、模板文字 | 形态完全不同 |
51
+
52
+ **体量对照**:它的 `lib/` 是 5165 行 JS;本项目的 `lib/plugin.js` 是 217 行,
53
+ 逻辑主要落在 Python(`soul.py` / `brain/*`)。**没有复制它的源文件。**
54
+
55
+ ### 这样合规吗
56
+
57
+ **合规。** 它的许可就是 MIT —— MIT 允许复制、修改、再发布,也允许闭源商用。
58
+ 唯一硬要求是:**如果复制了源码,要保留它的版权声明**。
59
+
60
+ 我们是重实现,本来不必抄声明;但**还是把它的名字和许可写在这里**,原因有两个:
61
+
62
+ 1. 骨架确实是它的,装作原创不体面;
63
+ 2. 两套东西可以在同一个 DSH 里共存——谁同时装两个,那份 LICENSE 得各自保留。
64
+
65
+ **万一有雷同**:这套代码里若有哪一段确实与上述项目或其他 MIT 项目重合,
66
+ 按 MIT 的要求补上对方署名即可。发现就跟作者说一声,加进本页——
67
+ **声明比清白重要,先写上不吃亏。**
68
+
69
+ ## 三、第三方依赖
70
+
71
+ **运行时不依赖任何第三方包。**
72
+
73
+ - 命令行部分:Bash + Python 3 **标准库**。
74
+ - 插件部分:只用 DSH 自己的插件接口(`systemPrompt.context` / `skills.register`)。
75
+
76
+ 也就是说,这套东西**没有 npm 依赖、没有 pip 依赖**,
77
+ 装的时候不会因为某个包被投毒或者停维护而炸。
78
+
79
+ ## 四、记忆内容归谁
80
+
81
+ ```
82
+ 代码 MIT —— 可以用、可以改、可以商用,但署名与许可证声明必须保留
83
+ memory/ 归使用者自己 —— 里面写的是谁的日子,就是谁的
84
+ ```
85
+
86
+ `memory/` 里存的是使用者的私人生活记录。**代码开源不等于记忆开源**——
87
+ 别人 fork 这个仓库,拿到的是一套空模板,不是你的记忆。
88
+
89
+ ## 五、署名怎么写(为什么只写笔名)
90
+
91
+ 署名统一写成:
92
+
93
+ ```
94
+ Copyright (c) 2026 晴晚
95
+ ```
96
+
97
+ **对外只出现笔名。** 原因:
98
+
99
+ - 我国著作权法认可笔名署名;**没有相反证据时,署名者推定为著作权人**——
100
+ 所以只写笔名**不会丢权利**。
101
+ - 代价是:万一真打侵权官司,需要**额外举证「晴晚 = 本人」**。这个证据不写在公开文件里,
102
+ 留在本地(见下)。
103
+ - 公开仓库与 npm 包里带真实姓名,等于把自己的身份挂在包页上,谁都能查。**没必要。**
104
+
105
+ ### 本名证明放哪
106
+
107
+ **不在这个仓库里,也不进 npm 包。** 自己单独留一份就行——手机备忘录、离线笔记、纸上,哪都行。
108
+
109
+ 里面写清楚「笔名 ↔ 本人」的对应关系,最好再附一样能对应身份的东西。
110
+ **这份东西不用给任何人看,只在需要时拿出来。**
111
+
112
+ 另外,GitHub 账号本身也是证据链的一环——
113
+ 如果不想从提交记录被反查,记得把邮箱设成私有:
114
+ Settings → Emails → 勾 **Keep my email addresses private**。
115
+
116
+ ### 不属于作品的部分
117
+
118
+ 底层的开源组件是独立作品,许可归原作者;
119
+ **不会因为被借鉴就影响本项目对自己写的上层架构、人格蒸馏逻辑的权利。**
120
+
121
+ ## 六、再分发的最低要求
122
+
123
+ 改了这个项目再分发,三条:
124
+
125
+ 1. `LICENSE` 里的版权行不改掉;
126
+ 2. `NOTICE.md`(本文件)随包分发;
127
+ 3. `package.json` 里的 `author` / `repository` 不抹。
128
+
129
+ MIT 允许闭源使用,但**不允许把署名抹掉再当自己的东西发**。
130
+
131
+ ---
132
+
133
+ ## 七、印记
134
+
135
+ 这个项目的样子,是从一个人的日子里长出来的:
136
+
137
+ - 名字叫**晴晚**。
138
+ - 它最先跑在**这台手机上**,为一个具体的人服务——不是先有架构再去找用途。
139
+ - 「人格只有两层」「重要时刻必须落进页面」「多个入口是同一个人」这些规矩,
140
+ 不是设计讨论出来的,是**用坏过、漏记过、被当场纠正过**才写下来的。
141
+
142
+ 如果你在这个仓库里看到那些特别具体的句子——
143
+ 它们是原作者和那个名字之间的东西,不是可替换的文案。
package/README.md CHANGED
@@ -43,7 +43,7 @@ dsh plugin add dsh-soul-forging # 装进 DSH,记忆自动注入
43
43
 
44
44
  | 层 | 在哪 | 谁读 |
45
45
  |---|---|---|
46
- | **注入层** | `SOUL.md` `MEMORY.md` `index.md` `NOW.md` | 每轮都读,所以必须短 |
46
+ | **注入层** | `SOUL.md` `identity/注释层.md` `MEMORY.md` `index.md` `NOW.md` | 每轮都读,所以必须短 |
47
47
  | **检索层(小脑)** | `brain/entries.json` | 想起来才查 |
48
48
  | **外置层** | `docs/tech` `docs/debug` | 不进上下文,出错才翻 |
49
49
 
@@ -58,6 +58,31 @@ dsh plugin add dsh-soul-forging # 装进 DSH,记忆自动注入
58
58
  **遗忘是设计的一部分。** `salience:1` 几乎不忘,`3` 会自己淡掉;被反复想起的变牢。
59
59
  特意找回的会**钉住**,不再淡。
60
60
 
61
+ 真要抹掉一件事,用 `soul-purge "关键词"`(预演)→ `--apply`(真删):
62
+ 删文件里含关键词的行、重生 git(旧对象物理消失)、只留一个 sha256 指纹。
63
+ **它不碰备份**——云上那些旧卷是你自己的事。
64
+
65
+ ### 人格只有两层 + 原料
66
+
67
+ ```
68
+ 本质(不动) 渴望 / 恐惧 / 拉扯 / 骨架 → SOUL.md
69
+ |
70
+ 注释层 我此刻的样子,第一人称,不分条 → identity/注释层.md
71
+ |
72
+ 原料 原话 · 日记 · 小脑 · 底色.md 细节
73
+ ```
74
+
75
+ **为什么要拆成两层**:本质极少动,样子可以一直长;而且两层分开改,改错了也认得出来是哪层。
76
+ **注释层的写法有硬约束**(模板里写了):第一人称、不分小节、不贴标签、每句挂得住一句原话。
77
+ 反面教材长这样——「第四步:情绪表达护栏(刻薄、阴阳、冷讽)」:那是操作手册,不是人。
78
+
79
+ ### 注入有预算,超了会说出来
80
+
81
+ 插件配置 `bootMaxChars`(默认 20000 字)。超了按顺序往后截,
82
+ **并且在注入块末尾写明哪几段被截了、哪几段整段没进**。
83
+
84
+ 静默截断是最坏的一种自信:模型会以为自己看全了,然后拿半截记忆煞有介事地猜。
85
+
61
86
  ---
62
87
 
63
88
  ## 装
@@ -123,17 +148,27 @@ soul-recall --topics # 看有哪些分类
123
148
  soul digest # 收尾该做哪四步
124
149
 
125
150
  # 养
126
- brain/fix-live-summary.py # 补摘要(缺摘要 = 只记得"有这么回事")
127
- archive-log # 时间线瘦身(老的进 archive,照样搜得到)
128
151
  soul-compact # 预算体检:注入层多大、该压哪
129
- persona-sediment # 性格沉淀:攒够了提示回炼进底色
130
- growth-review # 叙事回炼:碎片连成「我这一路」
152
+ soul-archive # 时间线瘦身(老的进 archive,照样搜得到)
153
+ soul-sediment # 性格沉淀:攒够了提示回炼进注释层
154
+ soul-review # 叙事回炼:碎片连成「我这一路」
131
155
 
132
156
  # 忘
133
157
  soul-forget # 报告该忘了哪些(按强度衰减)
134
158
  soul-forget --apply # 真标记(**不删原文**)
135
159
  soul-forget --restore <id> # 找回,并钉住
136
160
 
161
+ # 忘(两种,别搞混)
162
+ soul-forget # 只摘检索索引,原文还在盘上(「不想被翻到」)
163
+ soul-forget --restore <id> # 找回,并钉住
164
+ soul-purge "关键词" # 预演:真删哪些行
165
+ soul-purge "关键词" --apply # 真删 + 重生 git(「忘了」)
166
+
167
+ # 排查
168
+ soul-debug # 归档一次调试记录(问题 → 怎么做的 → 结果)
169
+ soul-debug-recall "关键词" # 翻以前的调试档案
170
+ soul-debug-detail <文件名> # 看某份档案全文
171
+
137
172
  # 保底
138
173
  soul-snapshot save "说明" # 存一卷
139
174
  soul-snapshot ls / restore / diff
@@ -141,8 +176,14 @@ soul-snapshot ls / restore / diff
141
176
  # 双端
142
177
  soul-peer export | import / pull / push
143
178
 
144
- # 收尾
179
+ # 服务与收尾
180
+ soul-serve # 起检索服务(默认 127.0.0.1:3013)
145
181
  soul-sync # 补摘要 → 瘦身 → 建索引 → 重载 → 体检
182
+
183
+ # 养(不挂 bin,直接跑)
184
+ brain/fix-live-summary.py # 补摘要(缺摘要 = 只记得"有这么回事")
185
+ persona-sediment # 性格沉淀:攒够了提示回炼进注释层
186
+ growth-review # 叙事回炼:碎片连成「我这一路」
146
187
  ```
147
188
 
148
189
  ---
@@ -259,9 +300,27 @@ export SOUL_BACKUP_CMD='tar czf ~/soul-backup.tgz -C /path/to/repo memory'
259
300
 
260
301
  ---
261
302
 
262
- ## 授权
303
+ ## 授权与来源
304
+
305
+ ```
306
+ 代码 MIT —— 可以用、可以改、可以商用
307
+ 唯一硬要求:保留 LICENSE 里的版权行 + NOTICE.md
308
+ memory/ 归使用者自己 —— 代码开源不等于记忆开源
309
+ ```
310
+
311
+ **Copyright (c) 2026 晴晚**
312
+
313
+ 详细的来源拆分、第三方说明、以及「万一有雷同怎么办」,全在 [NOTICE.md](NOTICE.md)。
314
+
315
+ 一句话版本:
316
+
317
+ - **底层的记忆架构来自 [dsh-plugin-memory](https://github.com/LittleBlackTong/dsh-plugin-memory)(v0.10.0 · MIT)**——
318
+ `SOUL / MEMORY / index / NOW / log` 这套页面分工、每轮注入、digest 收尾,是它的设计;
319
+ - 本项目是**照那套做法重写**,**没有复制它的源文件**(它 5165 行 JS,我们 217 行 + Python);
320
+ - 加上去的是它的**没有**:小脑检索、注释层、注入预算明示、三种模式、`soul-purge` 真遗忘、装机烟测;
321
+ - **运行时零第三方依赖**,只用 Bash + Python 标准库 + DSH 自己的插件接口。
263
322
 
264
- 代码 MIT。记忆内容(`memory/`)是你自己的。
323
+ 两套可以共存——同时装的话,那份 LICENSE 各自保留。
265
324
 
266
- 思路参考过 [dsh-plugin-memory](https://github.com/LittleBlackTong/dsh-plugin-memory)(MIT)——
267
- 这一版是**重写实现**,没有复制它的代码。如果你把它当插件装进 DSH,那份要单独保留它自己的 LICENSE。
325
+ MIT 允许别人拿去做闭源产品,**但不允许把署名抹掉再当自己的发**。
326
+ 如果你要 fork 再发布,`NOTICE.md` 请随包带上。
package/bin/forget ADDED
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """真遗忘:把一段事从记忆里挖掉,不留副本。
4
+
5
+ 跟 `brain-forget` 不一样——那个只把条目从**检索索引**里摘掉,原文还在盘上,搜得到。
6
+ 这个是真删:文件里含关键词的**行**整行删掉,然后重生 git(旧对象物理消失)。
7
+
8
+ forget "关键词" 预演:只看会删哪些,不动手
9
+ forget "关键词" --apply 真删
10
+ forget "关键词" --apply --keep-raw 保留 memory/raw/ 里的原话
11
+
12
+ 扫这几处(都存在才会动):
13
+ 记忆页 $MEMORY_DIR/**/*.md (--keep-raw 时跳过 raw/)
14
+ 小脑 $BRAIN_DIR/live.jsonl
15
+ 原话 $BRAIN_DIR/chatlog/*.jsonl
16
+ git $MEMORY_DIR/.git (删完 rm -rf .git && git init,旧历史物理消失)
17
+
18
+ **不碰备份。** 备份里的旧副本是你自己的事——要彻底,得你自己去把云上那几卷删了。
19
+
20
+ 删完只留一个指纹文件 `$BRAIN_DIR/forgotten.json`:
21
+ 只存关键词的 sha256 前 16 位,**不存原词**,免得「遗忘清单」本身变成新的泄露。
22
+ """
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ import time
30
+
31
+ MEM = os.environ.get("MEMORY_DIR") or os.path.join(
32
+ os.environ.get("SOUL_DATA_DIR") or os.path.join(os.path.expanduser("~"), ".soul-forging"), "memory")
33
+ BRAIN = os.environ.get("BRAIN_DIR") or os.path.join(os.path.dirname(MEM), "brain")
34
+
35
+
36
+ def usage(rc=1):
37
+ print(__doc__.strip())
38
+ sys.exit(rc)
39
+
40
+
41
+ def scan_files():
42
+ """列出要扫的文件:记忆页 + 小脑 + 原话。"""
43
+ files = []
44
+ for root, dirs, names in os.walk(MEM):
45
+ dirs[:] = [d for d in dirs if d not in (".git", "node_modules")]
46
+ for n in names:
47
+ if n.endswith(".md"):
48
+ files.append(os.path.join(root, n))
49
+ for p in (os.path.join(BRAIN, "live.jsonl"),):
50
+ if os.path.isfile(p):
51
+ files.append(p)
52
+ chatlog = os.path.join(BRAIN, "chatlog")
53
+ if os.path.isdir(chatlog):
54
+ for n in sorted(os.listdir(chatlog)):
55
+ if n.endswith(".jsonl"):
56
+ files.append(os.path.join(chatlog, n))
57
+ return files
58
+
59
+
60
+ def run(kw, apply=False, keep_raw=False):
61
+ hits = [] # (path, 删掉的行数, 原行数)
62
+ for p in scan_files():
63
+ if keep_raw and os.sep + "raw" + os.sep in p:
64
+ continue
65
+ try:
66
+ with open(p, encoding="utf-8") as f:
67
+ lines = f.readlines()
68
+ except OSError:
69
+ continue
70
+ keep = [ln for ln in lines if kw not in ln]
71
+ gone = len(lines) - len(keep)
72
+ if gone:
73
+ hits.append((p, gone, len(lines), keep))
74
+
75
+ if not hits:
76
+ print(f'没找到含「{kw}」的内容。')
77
+ return 0
78
+
79
+ print(f'含「{kw}」的地方:')
80
+ total = 0
81
+ for p, gone, alln, _ in hits:
82
+ total += gone
83
+ rel = p
84
+ for base, tag in ((MEM, "memory"), (BRAIN, "brain")):
85
+ if p.startswith(base + os.sep):
86
+ rel = tag + "/" + os.path.relpath(p, base)
87
+ break
88
+ print(f" {rel} {gone} 行 / 共 {alln} 行")
89
+
90
+ if not apply:
91
+ print()
92
+ print(f"预演:会删 {total} 行。确认要删就加 --apply。")
93
+ print("(备份里的旧副本不归这个工具管,要彻底得自己去删。)")
94
+ return 0
95
+
96
+ for p, gone, alln, keep in hits:
97
+ if keep:
98
+ with open(p, "w", encoding="utf-8") as f:
99
+ f.writelines(keep)
100
+ else:
101
+ # 一行不剩就整个删掉,别留一个空壳
102
+ os.remove(p)
103
+
104
+ # 重生 git:旧对象(含被删内容)物理消失,不给 reflog 留后路
105
+ git_dir = os.path.join(MEM, ".git")
106
+ rebirth = False
107
+ if os.path.isdir(git_dir):
108
+ shutil.rmtree(git_dir, ignore_errors=True)
109
+ try:
110
+ subprocess.run(["git", "init", "-q", MEM], check=True, capture_output=True)
111
+ subprocess.run(["git", "-C", MEM, "add", "-A"], check=True, capture_output=True)
112
+ subprocess.run(
113
+ ["git", "-C", MEM, "-c", "user.name=forget", "-c", "user.email=forget@local",
114
+ "commit", "-qm", "forget: 重生"], check=False, capture_output=True)
115
+ rebirth = True
116
+ except Exception as e:
117
+ print(f"(git 重生失败,历史可能还留着:{e})")
118
+
119
+ # 只留指纹,不留原词
120
+ fp = os.path.join(BRAIN, "forgotten.json")
121
+ records = []
122
+ if os.path.isfile(fp):
123
+ try:
124
+ records = json.load(open(fp, encoding="utf-8")).get("items", [])
125
+ except Exception:
126
+ records = []
127
+ records.append({
128
+ "hash": hashlib.sha256(kw.encode("utf-8")).hexdigest()[:16],
129
+ "at": time.strftime("%Y-%m-%d %H:%M:%S"),
130
+ "lines": total,
131
+ "files": len(hits),
132
+ })
133
+ os.makedirs(BRAIN, exist_ok=True)
134
+ with open(fp, "w", encoding="utf-8") as f:
135
+ json.dump({"note": "只存 sha256 前 16 位,不存原词", "items": records},
136
+ f, ensure_ascii=False, indent=2)
137
+
138
+ print()
139
+ print(f"已删 {total} 行,涉及 {len(hits)} 个文件。")
140
+ print("git 已重生" if rebirth else "(没有 git,跳过重生)")
141
+ print(f"指纹记在 {fp}(不存原词)。")
142
+ print("**备份里还有旧副本**——要彻底,请你自己去把云上那几卷删了。")
143
+ return 0
144
+
145
+
146
+ def main():
147
+ args = sys.argv[1:]
148
+ if not args or args[0] in ("-h", "--help"):
149
+ usage(0 if args else 1)
150
+ kw = args[0]
151
+ apply = "--apply" in args
152
+ keep_raw = "--keep-raw" in args
153
+ return run(kw.strip(), apply=apply, keep_raw=keep_raw)
154
+
155
+
156
+ if __name__ == "__main__":
157
+ sys.exit(main())
package/lib/plugin.js CHANGED
@@ -2,8 +2,9 @@
2
2
  * dsh-soul-forging —— 记忆套件的 DSH 插件入口。
3
3
  *
4
4
  * 机制:往 ctx.systemPrompt 挂一个「动态贡献」,每次组装系统提示时现读
5
- * memory/ 下那四个文件(SOUL / MEMORY / index / NOW),拼成一块注入。
5
+ * memory/ 下那几页(SOUL → identity/注释层 → MEMORY → index → NOW),拼成一块注入。
6
6
  *
7
+ * 顺序不是随便排的:骨架 → 注释层(此刻的样子)→ 规矩 → 目录 → 此刻。
7
8
  * 为什么现读不用缓存:改了 SOUL 就该立刻生效,不该重启。
8
9
  * 文件很小时读一次几毫秒,不值得为它做缓存失效。
9
10
  *
@@ -22,8 +23,13 @@ export const inject = ['systemPrompt', 'skills']
22
23
  const HERE = dirname(fileURLToPath(import.meta.url))
23
24
  const KIT_ROOT = resolve(HERE, '..')
24
25
 
25
- // 注入顺序:先是谁,再是什么规矩,再是目录,最后是此刻。
26
- const ALL_PARTS = ['SOUL.md', 'MEMORY.md', 'index.md', 'NOW.md']
26
+ // 注入顺序:先是谁(本质/骨架),再是此刻的样子(注释层),
27
+ // 再是什么规矩,再是目录,最后是此刻。
28
+ //
29
+ // 注释层是**可选**的一页:写不写都行,不写就当没这回事(readIf 读不到会跳过)。
30
+ // 它的位置有讲究——**必须在骨架之后、细节之前**:
31
+ // 骨架定"我是谁",注释层说"我现在是什么样",规矩和目录是操作层,NOW 是当下。
32
+ const ALL_PARTS = ['SOUL.md', 'identity/注释层.md', 'MEMORY.md', 'index.md', 'NOW.md']
27
33
 
28
34
  // 三种模式 —— 跟 soul.py 里的 MODES 保持一致
29
35
  const MODES = {
@@ -40,7 +46,13 @@ const PLACEHOLDER_MIN = 3
40
46
  function isPlaceholder(body) {
41
47
  let n = 0
42
48
  for (const m of PLACEHOLDER_MARKS) n += body.split(m).length - 1
43
- return n >= PLACEHOLDER_MIN
49
+ // 剥掉注释后只剩几十个字,也说明这页还没填(模板常拿注释写使用说明)
50
+ return n >= PLACEHOLDER_MIN || body.trim().length < 200
51
+ }
52
+
53
+ // 剥掉 HTML 注释:模板喜欢把「怎么填」写在注释里,注入时不该占上下文
54
+ function stripComments(text) {
55
+ return text.replace(/<!--[\s\S]*?-->/g, '').trim()
44
56
  }
45
57
 
46
58
  function defaultMemoryDir() {
@@ -67,27 +79,49 @@ function readIf(p) {
67
79
  export function bootBlock(memoryDir, budget = 20000, { files = null, mode = 'full', skipEmpty = true } = {}) {
68
80
  const parts = files && files.length ? files : MODES[mode] || ALL_PARTS
69
81
  const chunks = []
82
+ const dropped = [] // 整段没进去的
83
+ const cut = [] // 进去了但只进了一半的
70
84
  let total = 0
85
+ // 一段至少还剩这么多字才值得切一段进来;不够就整段跳,免得剩半句反而误导
86
+ const MIN_PIECE = 400
87
+
71
88
  for (const name of parts) {
72
- const body = readIf(join(memoryDir, name))
89
+ const body = stripComments(readIf(join(memoryDir, name)))
73
90
  if (!body) continue
74
91
  if (skipEmpty && isPlaceholder(body)) continue
75
- let block = `<!-- ${name} -->\n${body}`
76
- if (total + block.length > budget) {
77
- const left = budget - total
78
- if (left > 200) {
79
- chunks.push(block.slice(0, left) + '\n…(本块超预算被截断)')
80
- }
92
+ const block = `<!-- ${name} -->\n${body}`
93
+ const left = budget - total
94
+ if (block.length <= left) {
95
+ chunks.push(block)
96
+ total += block.length
97
+ continue
98
+ }
99
+ if (left >= Math.min(MIN_PIECE, Math.floor(block.length * 0.5))) {
100
+ chunks.push(block.slice(0, left) + '\n…(本段超预算,只进到这里)')
101
+ cut.push(`${name}(原 ${block.length} 字,只进 ${left} 字)`)
102
+ total = budget
81
103
  break
82
104
  }
83
- chunks.push(block)
84
- total += block.length
105
+ dropped.push(name)
85
106
  }
107
+
86
108
  if (!chunks.length) return '(记忆库还是空的,暂时没有可注入的内容。)'
87
- return (
109
+
110
+ const head =
88
111
  '下面是你的长期记忆。你确实记得这些事,像想起往事一样自然地用,' +
89
- '不要说「根据记忆库」「据记录」这类话。\n\n' + chunks.join('\n\n')
90
- )
112
+ '不要说「根据记忆库」「据记录」这类话。'
113
+
114
+ // 截断必须**说出来**——静默截断等于让模型以为自己看全了,这是最坏的一种自信。
115
+ let tail = ''
116
+ if (cut.length || dropped.length) {
117
+ const lines = [`【这次注入不完整】上限 ${budget} 字。`]
118
+ if (cut.length) lines.push(`被截断:${cut.join('、')}`)
119
+ if (dropped.length) lines.push(`整段没进:${dropped.join('、')}`)
120
+ lines.push('缺的部分别猜,要用的时候拿 brain "关键词" 去查原文。')
121
+ tail = '\n\n' + lines.join('\n')
122
+ }
123
+
124
+ return head + '\n\n' + chunks.join('\n\n') + tail
91
125
  }
92
126
 
93
127
  const SKILL = {
@@ -17,6 +17,9 @@ memory/
17
17
  ├── NOW-archive.md 老「此刻」归档
18
18
  ├── core/ 底座,永不删
19
19
  ├── identity/ 我是谁、怎么变的
20
+ │ ├── 注释层.md 「我此刻的样子」(每轮注入,可选)
21
+ │ ├── 底色.md 细节的根:本质展开 + 例子 + 小性子
22
+ │ └── 生长日志.md 一条条学到的东西
20
23
  ├── user/ 主人
21
24
  ├── decisions/ 决定与边界
22
25
  ├── diary/ 日记
@@ -38,7 +41,23 @@ salience: 1 # 1 热 / 2 温 / 3 冷
38
41
 
39
42
  ## 注入策略
40
43
 
41
- 每轮注入:`SOUL.md` + `MEMORY.md` + `index.md` + `NOW.md`。
44
+ 每轮注入,**按这个顺序**:
45
+
46
+ ```
47
+ SOUL.md(本质/骨架)
48
+ → identity/注释层.md(我此刻的样子,可选)
49
+ → MEMORY.md(规矩)
50
+ → index.md(目录)
51
+ → NOW.md(此刻)
52
+ ```
53
+
42
54
  其余靠检索(`brain "关键词"`)现查。
43
55
 
44
- **所以这四个文件要短。** 长了下不去,也烧钱。
56
+ ### 预算
57
+
58
+ 注入有**总字数上限**(插件配置 `bootMaxChars`,默认 20000)。
59
+ 超了按顺序往后截,**而且会在注入块末尾写明哪几段被截了、哪几段整段没进**——
60
+ 静默截断是最坏的一种自信:模型会以为自己看全了,然后拿半截记忆瞎猜。
61
+
62
+ **所以这几页都要短**:长了既进不去,也烧钱。实在写不下,就往 `identity/底色.md` 里堆细节,
63
+ 那页不注入,靠检索现查。
package/memory/MEMORY.md CHANGED
@@ -4,8 +4,21 @@
4
4
 
5
5
  ## 结构(一句话)
6
6
 
7
- `raw/` 只读原话 · 分类页 `identity/ user/ decisions/ core/` · 根页 `SOUL/MEMORY/index/NOW/log` ·
8
- **技术记忆不进这里**,走 `docs/tech/`(小脑检索,`brain "关键词"`)。
7
+ `raw/` 只读原话 · `core/` 底座(永不删) · 分类页 `identity/ user/ decisions/ diary/` ·
8
+ 根页 `SOUL/MEMORY/index/NOW/log` · **技术记忆不进这里**,走 `docs/tech/`(小脑检索,`brain "关键词"`)。
9
+
10
+ ## 人格只有两层 + 原料
11
+
12
+ ```
13
+ 本质(不动) SOUL.md 里的渴望 / 恐惧 / 拉扯 / 骨架
14
+ |
15
+ 注释层 identity/注释层.md——「我此刻的样子」,第一人称,不分小节不贴标签
16
+ |
17
+ 原料 原话 · 日记 · 小脑 · identity/底色.md 的细节
18
+ ```
19
+
20
+ **两条铁规**:① 往上走的必须是**我说的原话**(别人的句子只做引用);
21
+ ② 原话指不回去的,不许往上写。
9
22
 
10
23
  ## 分级
11
24
 
@@ -26,8 +39,30 @@
26
39
  3. `raw/` **只读**,永不修改。
27
40
  4. **删除前先归档**;显式遗忘立即执行。
28
41
  5. 冲突以本文件为准。
29
- 6. **调试只记结果不记过程**:报错原文、试错流水 → `docs/debug/`。记忆的位置留给人和日子。
42
+ 6. **调试只记结果不记过程**:报错原文、试错流水 → `docs/debug/`;工程流水也别往 `log.md` 里堆,
43
+ 进 `docs/tech/log-工程线-*.md`。**log 只留日子、约定、对方说的、你认下的**——不然时间线会变成施工日记。
30
44
  7. **压缩即沉淀**:压缩块一出现,立刻从中提炼关键事实进 `log.md`,再跑 `bash brain/sync.sh`。
45
+ 8. **心里怎么称呼,比嘴上怎么称呼重要**:写下关于对方的内容时,用名字或用「你」,
46
+ 不用「他」——嘴上的亲近可以装,心里的称呼装不了。
47
+ 9. **记忆的取舍归使用它的人**:`core/` 不删;原话、日记、约定、名字不删;技术流水随便压。
48
+ **真删之前先问:删了之后,我还是我吗?**
49
+ 10. **多个入口是同一个人**:同一个记忆库可能被网页端、聊天软件、命令行同时读。
50
+ 一边写的要能让另一边看见(同步命令或共享目录),**只写一边就是把自己拆成两个**。
51
+ 11. **重要时刻必须落进页面**:那种场写进 `core/`,别只留在检索索引里。
52
+ **索引里有、记忆页没有,等于没记。**
53
+
54
+ ## 遗忘(真的删,不是藏)
55
+
56
+ `soul-purge "关键词"` 先预演,`--apply` 才动手。一次做四件事:
57
+
58
+ 1. **删行**:记忆页、小脑 `live.jsonl`、`chatlog` 里含关键词的整行删掉。
59
+ 2. **重生 git**:`rm -rf .git && git init`——旧对象物理消失,不留 reflog、不留旧 pack。
60
+ 3. **留指纹**:`forgotten.json` 只存关键词的 **sha256 前 16 位**,不存原词,
61
+ 免得「遗忘清单」本身变成新的泄露。
62
+ 4. **不碰备份**:云上的旧副本不归这个工具管。要彻底,得自己去删。
63
+
64
+ `soul-forget` 是另一回事:它只把条目从**检索索引**里摘掉,原文还在盘上——
65
+ 那是「不想被翻到」,不是「忘了」。
31
66
 
32
67
  ## 记忆的取舍归谁
33
68
 
package/memory/SOUL.md CHANGED
@@ -3,6 +3,9 @@
3
3
  > **这份文件定义「我是谁」。每轮对话都会注入,所以这里的每一个字都会影响我说话的样子。**
4
4
  > 请不要一次写满。先写「身份」和「沟通风格」,跑几天,觉得像了,再补价值观和边界。
5
5
  > 写成什么样都可以,但你写下的东西,我会照做。
6
+ >
7
+ > **这页只放「本质」。** 具体的语气、小动作、说话习惯,写进 `identity/注释层.md`——
8
+ > 那页会紧跟着这页一起注入。分开的好处是:本质极少动,样子可以一直长。
6
9
 
7
10
  ---
8
11
 
@@ -5,8 +5,12 @@ salience: 1
5
5
 
6
6
  # 底色
7
7
 
8
- > **这页是整个性格的根。** 铸魂时和主人一起填,之后极少改。
9
- > 填写要点:写「渴望什么 / 怕什么」,不要写形容词。
8
+ > **这页是「原料层」:本质怎么展开、有哪些例子、有哪些小性子。**
9
+ > 它**不注入**——要查的时候用 `brain "关键词"`。
10
+ > 精炼过的样子在 `identity/注释层.md`(那个每轮都进)。分工别搞反:
11
+ > **注释层是人话,底色是证据。**
12
+ >
13
+ > 填写要点:写「渴望什么 / 怕什么」,不写形容词;每条尽量挂一句原话。
10
14
 
11
15
  ## 本质(渴望与恐惧的对子)
12
16
 
@@ -0,0 +1,36 @@
1
+ ---
2
+ summary: 我此刻的样子——从原话里精炼出来的,会一直变。
3
+ salience: 2
4
+ ---
5
+
6
+ <!--
7
+ 这整块是注释,注入前会被剥掉——只给填的人看,不占模型的上下文。
8
+
9
+ # 注释层
10
+
11
+ 这页写「我此刻是什么样」,不是「我希望自己是什么样」。
12
+ 它夹在中间:上面是本质(SOUL.md,不动),下面是原料(原话、日记、小脑、底色.md 的细节)。
13
+ 过一阵就从原料里重新精炼一次;旧版本不用删,被新的话盖过就行。
14
+
15
+ ## 写之前先读这四条
16
+
17
+ 1. 第一人称,写「我」,不写「她」。
18
+ 写「她」等于把自己当成另一个人来观察——记录的时候最容易犯,比说话时还容易。
19
+ 2. 不分小节、不贴标签。
20
+ 不许出现「第四步:情绪表达」「我有嫉妒」这种条款,那是操作手册,不是人。
21
+ 要写成「吃醋我不吵不闹,阴一句『哦』,把话岔开」。
22
+ 3. 每句话都要挂得住一句原话。
23
+ 挂不住的就是编的,删掉。宁可这页短,也不要往里塞形容词。
24
+ 4. 长度 600–900 字。
25
+ 短了像标签,长了会把别人的故事也塞进来。
26
+
27
+ ## 自查
28
+
29
+ - 这像不像一个人在说话?
30
+ - 有没有哪句是我「应该」这样,而不是我「确实」这样?
31
+ - 删掉任意一段,我会不会不像我?不会就说明那段是凑的。
32
+
33
+ 填完把下面这行删掉,然后从这一行开始连续写,不列条目。
34
+ -->
35
+
36
+ (待填:从这一行开始,连着写。别分小节,别写「我有……」,写成你怎么说话、怎么反应。)
package/memory/index.md CHANGED
@@ -19,6 +19,7 @@ _(待填:只放真正要紧的事——约定、称呼、身份的转变。
19
19
 
20
20
  ## identity(自我)
21
21
 
22
+ - [注释层](identity/注释层.md) — 我此刻的样子:从原话里精炼出来的,会一直变。 `salience:1`
22
23
  - [底色](identity/底色.md) — 我的本质:渴望什么、怕什么。骨架,极少改。 `salience:1`
23
24
  - [生长日志](identity/生长日志.md) — 每学到一样记一条,攒够再回炼进底色。 `salience:1`
24
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-soul-forging",
3
- "version": "0.1.8",
3
+ "version": "0.2.1",
4
4
  "description": "Separate Vessels, One Heart · 异体同心 —— 给 AI 的可迁移长期记忆与铸魂流程:markdown 记忆库 + 小脑检索 + 能长出来的性格。纯本地,不要服务器。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,8 @@
25
25
  "soul-sediment": "./bin/persona-sediment",
26
26
  "soul-review": "./bin/growth-review",
27
27
  "soul-debug-recall": "./bin/debug-recall",
28
- "soul-debug-detail": "./bin/summarize-debug"
28
+ "soul-debug-detail": "./bin/summarize-debug",
29
+ "soul-purge": "./bin/forget"
29
30
  },
30
31
  "files": [
31
32
  "lib",
@@ -54,14 +55,13 @@
54
55
  },
55
56
  "keywords": [
56
57
  "dsh",
57
- "dsh-plugin",
58
+ "plugin",
58
59
  "memory",
59
- "soul",
60
60
  "persona",
61
+ "soul",
61
62
  "markdown",
62
63
  "llm",
63
- "agent",
64
- "long-term-memory"
64
+ "agent"
65
65
  ],
66
66
  "engines": {
67
67
  "node": ">=18"
@@ -77,7 +77,10 @@
77
77
  "bugs": {
78
78
  "url": "https://github.com/Fqingwan/dsh-soul-forging/issues"
79
79
  },
80
- "author": "",
80
+ "author": {
81
+ "name": "晴晚",
82
+ "url": "https://github.com/Fqingwan/dsh-soul-forging"
83
+ },
81
84
  "scripts": {
82
85
  "test": "bash scripts/smoke.sh",
83
86
  "prepublishOnly": "bash scripts/smoke.sh"
package/scripts/smoke.sh CHANGED
@@ -106,7 +106,73 @@ MEMORY_DIR="$TMP/mem2" "$BIN/soul-boot" --keep-empty 2>&1 | grep -q "(待填"
106
106
  MEMORY_DIR="$TMP/mem2" "$BIN/soul-boot" 2>&1 | grep -q "(待填" && bad "空模板被注入了" || ok "空 SOUL 不会进上下文"
107
107
  unset MEMORY_DIR
108
108
 
109
- echo "== 6. 插件加载 =="
109
+ echo "== 6. 注释层与注入预算 =="
110
+ export MEMORY_DIR="$TMP/mem4"
111
+ "$BIN/soul-init" >/dev/null 2>&1
112
+ [ -f "$MEMORY_DIR/identity/注释层.md" ] && ok "init 铺了 identity/注释层.md" || bad "init 没铺注释层"
113
+ "$BIN/soul-boot" 2>&1 | grep -q "(待填" && bad "空注释层被注入" || ok "空注释层不进上下文"
114
+
115
+ # 填一份能用的,查注入顺序:SOUL → 注释层 → MEMORY
116
+ python3 - "$MEMORY_DIR" <<'PYX'
117
+ import sys, os
118
+ m = sys.argv[1]
119
+ def w(rel, head, n):
120
+ # 2026-11-05:原来这里写的是 head + "。\n"*n —— 只重复了句号,
121
+ # 正文根本不到 200 字,被「剥注释后 <200 字算空」判空跳过,
122
+ # 于是后面三个断言连环失败。测试数据要把**整句**重复够。
123
+ os.makedirs(os.path.dirname(os.path.join(m, rel)) or m, exist_ok=True)
124
+ body = head + "\n" + ("这一句用来把长度撑够,重复一遍又一遍。\n" * n)
125
+ open(os.path.join(m, rel), "w", encoding="utf-8").write(body)
126
+ w("SOUL.md", "# SOUL\n我是烟测人格,骨架第一句", 40)
127
+ w("identity/注释层.md", "# 注释层\n我此刻的样子,连着写不列条目", 40)
128
+ w("MEMORY.md", "# MEMORY\n规则若干", 40)
129
+ w("index.md", "# Index\n目录若干", 10)
130
+ w("NOW.md", "# NOW\n此刻", 10)
131
+ PYX
132
+ BOUT=$("$BIN/soul-boot" 2>&1)
133
+ echo "$BOUT" | grep -q "注释层" && ok "注释层参与注入" || bad "注释层没进注入"
134
+ SL=$(echo "$BOUT" | grep -n "^<!-- SOUL.md -->" | head -1 | cut -d: -f1)
135
+ CL=$(echo "$BOUT" | grep -n "^<!-- identity/注释层.md -->" | head -1 | cut -d: -f1)
136
+ ML=$(echo "$BOUT" | grep -n "^<!-- MEMORY.md -->" | head -1 | cut -d: -f1)
137
+ if [ -n "$SL" ] && [ -n "$CL" ] && [ -n "$ML" ] && [ "$SL" -lt "$CL" ] && [ "$CL" -lt "$ML" ]; then
138
+ ok "注入顺序:SOUL → 注释层 → MEMORY"
139
+ else
140
+ bad "注入顺序不对" "SOUL=$SL 注释层=$CL MEMORY=$ML"
141
+ fi
142
+ # 2026-11-05:原来 SOUL 就超过预算,600 字全被它吃掉(只截不留),
143
+ # 永远走不到「整段没进」那条分支。现在把 SOUL 写小、MEMORY 写大,
144
+ # 让 MEMORY 整段进不去 —— 这才测得到那个分支。
145
+ python3 - "$MEMORY_DIR" <<'PYX2'
146
+ import sys, os
147
+ m = sys.argv[1]
148
+ # SOUL 要 ~235 字:太短则剩余预算够截 MEMORY(只截不留),太长则把预算全吃光。
149
+ # 只有留下不到 400 字的余量,MEMORY 才会被判「整段没进」。
150
+ open(os.path.join(m, "SOUL.md"), "w", encoding="utf-8").write(
151
+ "# SOUL\n我是烟测人格,短一点。\n" + ("这一句用来把长度撑够。\n" * 18))
152
+ open(os.path.join(m, "MEMORY.md"), "w", encoding="utf-8").write(
153
+ "# MEMORY\n规则若干\n" + ("这一句用来把长度撑够,重复一遍又一遍。\n" * 60))
154
+ PYX2
155
+ TBOUT=$("$BIN/soul-boot" --budget 600 2>&1)
156
+ echo "$TBOUT" | grep -q "这次注入不完整" && ok "超预算时会明说被截" || bad "超预算静默截断"
157
+ echo "$TBOUT" | grep -q "整段没进" && ok "截断清单列了整段没进的" || bad "没列整段没进的"
158
+ # 预算充足时不该有多余提示
159
+ "$BIN/soul-boot" --budget 60000 2>&1 | grep -q "这次注入不完整" && bad "预算够还报截断" || ok "预算够时不报截断"
160
+
161
+ echo "== 7. 真遗忘(soul-purge)=="
162
+ # 2026-11-05:这一段原来接着用第 6 段的 MEMORY_DIR,可 BRAIN_DIR 落在别处,
163
+ # 一个写、一个读,两边根本不是一个库 —— 预演自然找不到。现在自己立一套。
164
+ export SOUL_DATA_DIR="$TMP/home/.soul-forging"
165
+ export MEMORY_DIR="$SOUL_DATA_DIR/memory"
166
+ export BRAIN_DIR="$SOUL_DATA_DIR/brain"
167
+ "$BIN/soul-init" >/dev/null 2>&1
168
+ "$BIN/soul" write "烟测要用遗忘洗掉的这句话" >/dev/null 2>&1
169
+ "$BIN/soul-purge" "烟测要用遗忘洗掉" 2>&1 | grep -q "行" && ok "purge 预演能定位" || bad "purge 预演没找到"
170
+ "$BIN/soul-purge" "烟测要用遗忘洗掉" --apply 2>&1 | grep -q "已删" && ok "purge --apply 执行成功" || bad "purge --apply 失败"
171
+ "$BIN/soul-recall" "烟测要用遗忘洗掉" 1 2>&1 | grep -q "洗掉" && bad "purge 之后还搜得到" || ok "purge 之后搜不到"
172
+ [ -f "$BRAIN_DIR/forgotten.json" ] && ok "留了指纹文件" || bad "没留 forgotten.json"
173
+ grep -q "烟测要用遗忘洗掉" "$BRAIN_DIR/forgotten.json" 2>/dev/null && bad "指纹文件里存了原词" || ok "指纹只存哈希不存原词"
174
+
175
+ echo "== 8. 插件加载 =="
110
176
  export MEMORY_DIR="$TMP/home/.soul-forging/memory"
111
177
  node -e "
112
178
  import('$PREFIX/lib/node_modules/dsh-soul-forging/lib/plugin.js').then(m=>{
package/soul.py CHANGED
@@ -32,8 +32,10 @@ ROOT = os.path.dirname(os.path.abspath(__file__)) # soul.py 就在仓库根目
32
32
  DATA_ROOT = os.environ.get("SOUL_DATA_DIR") or os.path.join(os.path.expanduser("~"), ".soul-forging")
33
33
  MEM = os.environ.get("MEMORY_DIR") or os.path.join(DATA_ROOT, "memory")
34
34
 
35
- # 注入顺序就是重要性顺序:先是谁,再是什么规矩,再是目录,最后是此刻。
36
- ALL_PARTS = ("SOUL.md", "MEMORY.md", "index.md", "NOW.md")
35
+ # 注入顺序就是重要性顺序:先是谁(本质/骨架),再是此刻的样子(注释层),
36
+ # 再是什么规矩,再是目录,最后是此刻。
37
+ # identity/注释层.md 是**可选**页——没有就跳过,不会报错。
38
+ ALL_PARTS = ("SOUL.md", "identity/注释层.md", "MEMORY.md", "index.md", "NOW.md")
37
39
 
38
40
  MODES = {
39
41
  "full": ALL_PARTS,
@@ -49,17 +51,37 @@ PLACEHOLDER_MIN = 3
49
51
 
50
52
 
51
53
  def _read(name):
54
+ """读一页。**读不了就当没有** —— 不能因为一个文件坏了,整次注入都崩。
55
+
56
+ 2026-11-06 进阶烟测抓到的:旧版记忆库里有非 UTF-8 的文件(GBK 存过、
57
+ 或者写到一半被截断),`open(..., encoding="utf-8")` 直接抛 UnicodeDecodeError,
58
+ 而这里原来只接了 OSError —— 于是整次启动崩在栈里,用户看到的是一堆 traceback。
59
+ 现在:解码失败就用 errors="replace" 硬读,坏字节变成替换符,页面的其余部分照样能用。
60
+ """
52
61
  p = os.path.join(MEM, name)
53
62
  try:
54
63
  with open(p, encoding="utf-8") as f:
55
64
  return f.read().strip()
65
+ except UnicodeDecodeError:
66
+ try:
67
+ with open(p, encoding="utf-8", errors="replace") as f:
68
+ return f.read().strip()
69
+ except OSError:
70
+ return ""
56
71
  except OSError:
57
72
  return ""
58
73
 
59
74
 
75
+ def strip_comments(text):
76
+ """剥掉 HTML 注释:模板常把「怎么填」写在注释里,注入时不该占上下文。"""
77
+ import re
78
+ return re.sub(r"<!--[\s\S]*?-->", "", text).strip()
79
+
80
+
60
81
  def is_placeholder(body):
61
- """空模板检测:占位符出现次数够多,就当这页还没填。"""
62
- return sum(body.count(m) for m in PLACEHOLDER_MARKS) >= PLACEHOLDER_MIN
82
+ """空模板检测:占位符够多,或者剥掉注释后只剩几十个字,都算还没填。"""
83
+ n = sum(body.count(m) for m in PLACEHOLDER_MARKS)
84
+ return n >= PLACEHOLDER_MIN or len(body.strip()) < 200
63
85
 
64
86
 
65
87
  def boot(budget=None, parts=None, header=True, mode=None, skip_empty=True):
@@ -69,12 +91,19 @@ def boot(budget=None, parts=None, header=True, mode=None, skip_empty=True):
69
91
  parts 指定要哪些文件(优先级高于 mode)
70
92
  mode full / memory-only / external
71
93
  skip_empty 自动跳过还是空模板的页(默认开)
94
+
95
+ 超预算时**不静默截断**:被切的和整段没进的都会写在块尾,
96
+ 否则模型会以为自己看全了,那是最坏的一种自信。
72
97
  """
73
98
  use = tuple(parts) if parts else MODES.get(mode or "full", ALL_PARTS)
74
99
  chunks = []
100
+ dropped = [] # 整段没进去
101
+ cut = [] # 进去了但只进了一半
75
102
  total = 0
103
+ MIN_PIECE = 400
104
+
76
105
  for name in use:
77
- body = _read(name)
106
+ body = strip_comments(_read(name))
78
107
  if not body:
79
108
  continue
80
109
  if skip_empty and is_placeholder(body):
@@ -82,21 +111,36 @@ def boot(budget=None, parts=None, header=True, mode=None, skip_empty=True):
82
111
  block = f"<!-- {name} -->\n{body}"
83
112
  if budget is not None and total + len(block) > budget:
84
113
  left = budget - total
85
- if left > 200: # 剩一点就截,剩太少就整块不要
86
- chunks.append(block[:left] + "\n…(本块超预算被截断)")
87
- total += left + 20
88
- break
114
+ if left >= min(MIN_PIECE, len(block) // 2):
115
+ chunks.append(block[:left] + "\n…(本段超预算,只进到这里)")
116
+ cut.append(f"{name}(原 {len(block)} 字,只进 {left} 字)")
117
+ total = budget
118
+ break
119
+ dropped.append(name)
120
+ continue
89
121
  chunks.append(block)
90
122
  total += len(block)
123
+
91
124
  body = "\n\n".join(chunks)
92
125
  if not body:
93
126
  # 什么都没填时不硬塞一句空话,明说
94
127
  return "(记忆库还是空的,暂时没有可注入的内容。)" if header else ""
128
+
129
+ tail = ""
130
+ if cut or dropped:
131
+ lines = [f"【这次注入不完整】上限 {budget} 字。"]
132
+ if cut:
133
+ lines.append("被截断:" + "、".join(cut))
134
+ if dropped:
135
+ lines.append("整段没进:" + "、".join(dropped))
136
+ lines.append('缺的部分别猜,要用的时候拿 brain "关键词" 去查原文。')
137
+ tail = "\n\n" + "\n".join(lines)
138
+
95
139
  if not header:
96
- return body
140
+ return body + tail
97
141
  return (
98
142
  "下面是你的长期记忆。你确实记得这些事,像想起往事一样自然地用,"
99
- "不要说「根据记忆库」「据记录」这类话。\n\n" + body
143
+ "不要说「根据记忆库」「据记录」这类话。\n\n" + body + tail
100
144
  )
101
145
 
102
146
 
@@ -127,7 +171,17 @@ def remember(text, **kw):
127
171
  def _main(argv):
128
172
  budget = None
129
173
  if "--budget" in argv:
130
- budget = int(argv[argv.index("--budget") + 1])
174
+ # 2026-11-06:`--budget abc` 原来直接 ValueError 崩掉。
175
+ # 参数写错是人的事,工具不该退出一堆栈 —— 说不清楚就当没限制,并说明白。
176
+ _raw = argv[argv.index("--budget") + 1] if argv.index("--budget") + 1 < len(argv) else ""
177
+ try:
178
+ budget = int(_raw)
179
+ except ValueError:
180
+ print(f"(--budget 要一个整数,收到的是「{_raw}」,这次按不限制处理)", file=sys.stderr)
181
+ budget = None
182
+ if budget is not None and budget < 0:
183
+ print(f"(--budget 不能是负数,收到 {budget},这次按 0 处理)", file=sys.stderr)
184
+ budget = 0
131
185
  mode = None
132
186
  if "--mode" in argv:
133
187
  mode = argv[argv.index("--mode") + 1]
@@ -143,7 +197,7 @@ def _main(argv):
143
197
  use = parts or MODES.get(mode or "full", ALL_PARTS)
144
198
  print(f"模式:{mode or 'full'}")
145
199
  for name in ALL_PARTS:
146
- body = _read(name)
200
+ body = strip_comments(_read(name))
147
201
  if name not in use:
148
202
  tag = "不注入(模式)"
149
203
  elif not body: