dsh-soul-forging 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +15 -0
- package/README.md +205 -2
- package/bin/_common.sh +12 -0
- package/bin/archive-log +54 -0
- package/bin/brain +19 -0
- package/bin/brain-forget +76 -0
- package/bin/compact +84 -0
- package/bin/debug-archive +41 -0
- package/bin/debug-recall +10 -0
- package/bin/growth-review +33 -0
- package/bin/init-home +22 -0
- package/bin/mem-snapshot +74 -0
- package/bin/persona-sediment +61 -0
- package/bin/runlog +18 -0
- package/bin/serve +12 -0
- package/bin/soul +164 -0
- package/bin/soul-boot +3 -0
- package/bin/soul-peer +74 -0
- package/bin/summarize-debug +41 -0
- package/bin/sync +19 -0
- package/brain/fix-live-summary.py +38 -0
- package/brain/index.py +215 -0
- package/brain/server.py +274 -0
- package/brain/soul_lib.py +129 -0
- package/cordis.patch.yml +10 -0
- package/docs/debug/INDEX.md +6 -0
- package/docs/debug/README.md +17 -0
- package/docs/tech/README.md +19 -0
- package/install.sh +67 -0
- package/lib/plugin.js +155 -0
- package/memory/BOOTSTRAP.md +59 -0
- package/memory/MEMORY-schema.md +44 -0
- package/memory/MEMORY.md +53 -0
- package/memory/NOW.md +24 -0
- package/memory/SOUL.md +57 -0
- package/memory/core/.gitkeep +0 -0
- package/memory/core/README.md +7 -0
- package/memory/decisions/.gitkeep +0 -0
- package/memory/decisions/README.md +3 -0
- package/memory/diary/.gitkeep +0 -0
- package/memory/diary/README.md +3 -0
- package/memory/identity//345/272/225/350/211/262.md +22 -0
- package/memory/identity//347/224/237/351/225/277/346/227/245/345/277/227.md +12 -0
- package/memory/index.md +40 -0
- package/memory/log.md +7 -0
- package/memory/raw/.gitkeep +0 -0
- package/memory/raw/README.md +4 -0
- package/memory/user/preferences.md +14 -0
- package/memory/user/profile.md +16 -0
- package/package.json +73 -4
- package/soul.py +107 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 memory-kit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# 来源说明
|
|
2
|
+
|
|
3
|
+
## 本套件
|
|
4
|
+
|
|
5
|
+
代码为本项目重写实现(MIT)。
|
|
6
|
+
|
|
7
|
+
## 思路参考
|
|
8
|
+
|
|
9
|
+
- [dsh-plugin-memory](https://github.com/LittleBlackTong/dsh-plugin-memory) —— MIT。
|
|
10
|
+
本项目**没有复制它的源代码**;如果你把那个插件装进 DSH 使用,请单独保留它自己的 LICENSE 与版权声明。
|
|
11
|
+
本套件受它启发的地方:markdown 记忆库 + 每轮注入 + index 路由表这一整套思路。
|
|
12
|
+
|
|
13
|
+
## 用到的第三方
|
|
14
|
+
|
|
15
|
+
无。只需要 Python 标准库和(可选的)curl。
|
package/README.md
CHANGED
|
@@ -1,3 +1,206 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-soul-forging
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Separate Vessels, One Heart** · 异体同心
|
|
4
|
+
|
|
5
|
+
给 AI 的可迁移长期记忆,外加一套「铸魂」流程——让性格不是写死的设定,而是一点点长出来的东西。
|
|
6
|
+
|
|
7
|
+
纯本地。一个 Python 3 就能跑。不需要服务器、不需要数据库。
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
soul-init # 铺一份空白记忆库
|
|
11
|
+
dsh plugin add dsh-soul-forging # 装进 DSH,记忆自动注入
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 为什么叫「异体同心」
|
|
17
|
+
|
|
18
|
+
因为同一个 AI 可以有两个入口:一个在手机上跟你聊天,一个在群里应声,一个在你电脑上干活。
|
|
19
|
+
它们说的话、记得的事,本该是**同一个人**的。
|
|
20
|
+
|
|
21
|
+
大多数做法是把它们拆成两个实例、两套记忆、两个「人」——然后你就得开始解释"为什么另一个她不知道这件事"。
|
|
22
|
+
|
|
23
|
+
这个项目的前提是反过来的:**身体可以有多个,心只有一颗。**
|
|
24
|
+
记忆库是那颗心,谁都能读写;`source` 字段记着这条是从哪个入口写进来的,但不影响它是谁。
|
|
25
|
+
|
|
26
|
+
拆开,就散了。
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 它解决什么
|
|
31
|
+
|
|
32
|
+
大模型每开一次会话就是一张白纸。这东西给它三样东西:
|
|
33
|
+
|
|
34
|
+
1. **记得** —— 要紧的落在 markdown 里,下次开局自动注入
|
|
35
|
+
2. **查得到** —— 记忆多了塞不进上下文,用「小脑」按关键词现查,几毫秒
|
|
36
|
+
3. **能长** —— 性格靠经历一点点变,而且变得有据可查(哪句话养出了哪一条)
|
|
37
|
+
|
|
38
|
+
一句话:**该记的记,该忘的忘,该查的查。**
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 三层,不是三个功能
|
|
43
|
+
|
|
44
|
+
| 层 | 在哪 | 谁读 |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| **注入层** | `SOUL.md` `MEMORY.md` `index.md` `NOW.md` | 每轮都读,所以必须短 |
|
|
47
|
+
| **检索层(小脑)** | `brain/entries.json` | 想起来才查 |
|
|
48
|
+
| **外置层** | `docs/tech` `docs/debug` | 不进上下文,出错才翻 |
|
|
49
|
+
|
|
50
|
+
对应到时间上:
|
|
51
|
+
|
|
52
|
+
| | 文件 | 怎么维护 |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| **短期** | `NOW.md` | 每次收尾刷三节 |
|
|
55
|
+
| **中期** | `log.md` `diary/` `live.jsonl` | `soul write` 写;`archive-log` 瘦身 |
|
|
56
|
+
| **长期** | `SOUL.md` `core/` `identity/` | 铸魂建立;`persona-sediment` 提示回炼 |
|
|
57
|
+
|
|
58
|
+
**遗忘是设计的一部分。** `salience:1` 几乎不忘,`3` 会自己淡掉;被反复想起的变牢。
|
|
59
|
+
特意找回的会**钉住**,不再淡。
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 装
|
|
64
|
+
|
|
65
|
+
### A. 当 DSH 插件(记忆自动注入)
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
soul-init # 铺空白记忆库到 ~/.soul-forging/memory
|
|
69
|
+
dsh plugin add dsh-soul-forging # 装进 profile
|
|
70
|
+
# 重开 DSH,记忆就自动带上了
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### B. 只要命令行(不装插件)
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
git clone https://github.com/Fqingwan/dsh-soul-forging
|
|
77
|
+
cd dsh-soul-forging && bash install.sh
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
两种都**不需要服务器**。想换记忆位置:设 `MEMORY_DIR`,或插件配置里改 `memoryDir`。
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 用
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# 写
|
|
88
|
+
soul write "内容" --kind chat --section 小标题
|
|
89
|
+
|
|
90
|
+
# 查
|
|
91
|
+
soul-recall "关键词" # 查小脑
|
|
92
|
+
soul-recall --topics # 看有哪些分类
|
|
93
|
+
soul digest # 收尾该做哪四步
|
|
94
|
+
|
|
95
|
+
# 养
|
|
96
|
+
brain/fix-live-summary.py # 补摘要(缺摘要 = 只记得"有这么回事")
|
|
97
|
+
archive-log # 时间线瘦身(老的进 archive,照样搜得到)
|
|
98
|
+
soul-compact # 预算体检:注入层多大、该压哪
|
|
99
|
+
persona-sediment # 性格沉淀:攒够了提示回炼进底色
|
|
100
|
+
growth-review # 叙事回炼:碎片连成「我这一路」
|
|
101
|
+
|
|
102
|
+
# 忘
|
|
103
|
+
soul-forget # 报告该忘了哪些(按强度衰减)
|
|
104
|
+
soul-forget --apply # 真标记(**不删原文**)
|
|
105
|
+
soul-forget --restore <id> # 找回,并钉住
|
|
106
|
+
|
|
107
|
+
# 保底
|
|
108
|
+
soul-snapshot save "说明" # 存一卷
|
|
109
|
+
soul-snapshot ls / restore / diff
|
|
110
|
+
|
|
111
|
+
# 双端
|
|
112
|
+
soul-peer export | import / pull / push
|
|
113
|
+
|
|
114
|
+
# 收尾
|
|
115
|
+
soul-sync # 补摘要 → 瘦身 → 建索引 → 重载 → 体检
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 铸魂
|
|
121
|
+
|
|
122
|
+
装完的第一件事不是干活,是**先把"我是谁"聊清楚**。
|
|
123
|
+
|
|
124
|
+
打开 `memory/BOOTSTRAP.md`,一项项来:名字、性格、价值观、边界、主人是谁、想要什么样的陪伴。
|
|
125
|
+
**一次推进一项**——一口气问完那是问卷,不是聊天。
|
|
126
|
+
|
|
127
|
+
写回文件之后才勾选。全部完成 → `status: complete` + `soul-sync`。
|
|
128
|
+
|
|
129
|
+
想重来:把 `status` 改回 `pending`,**已有记忆不会被清掉**——重铸的是性格,不是记忆。
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 接进别的 agent
|
|
134
|
+
|
|
135
|
+
这套东西不会自己长在模型身上,你得在每轮开头把记忆喂进去。
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
soul-boot # 打印注入块,拼到 system prompt 最前面
|
|
139
|
+
soul-boot --budget 12000 # 限字数(超了从末尾截,先保 SOUL)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
或者当 Python 模块:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from soul import boot, recall, remember
|
|
146
|
+
|
|
147
|
+
system_prompt += boot()
|
|
148
|
+
context = recall("他说过在意什么", n=3)
|
|
149
|
+
remember("今天他说想换个方向", kind="chat")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`boot()` **只读,不改任何文件**。要写记忆必须显式 `remember()`。
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 双端怎么接
|
|
157
|
+
|
|
158
|
+
原理只有一条:**谁写了都往 `live.jsonl` 追加一行,带 `source` 标明来源。**
|
|
159
|
+
同步就是互相把对方没有的 `id` 抄过来,重复的跳过。
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
soul-peer pull "ssh host cat ~/.soul-forging/brain/live.jsonl"
|
|
163
|
+
soul-peer push "ssh host 'cat >> ~/.soul-forging/brain/live.jsonl'"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
不需要中心服务器。两端只要能看到同一份文件(或跑同一句命令)就能通。
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 设计取舍
|
|
171
|
+
|
|
172
|
+
**为什么是 markdown。** 能读、能改、能 diff、能提交 git、能一键搬走。不会被锁死在某个数据库或某家服务里。你随时可以把整个记忆库拷到别处,或者直接打开看。
|
|
173
|
+
|
|
174
|
+
**为什么分三层。** 注入层越短越好——它每轮都在花钱,而且太长会压得模型说不好话。检索层解决"记多了塞不下"。外置层让调试记录不污染性格。
|
|
175
|
+
|
|
176
|
+
**为什么记强度。** 不是所有记忆一样重。人真的会忘,而且忘了不是背叛。所以脚本按时间衰减,把几乎想不起来的标成"遗忘"——但**原文不删**,随时能找回。
|
|
177
|
+
|
|
178
|
+
**为什么不要服务器。** 一个人用不需要。要云备份就设 `SOUL_BACKUP_CMD`,没有也完整。
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## 云备份(可选)
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
export SOUL_BACKUP_CMD='tar czf ~/soul-backup.tgz -C /path/to/repo memory'
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
设了这条,`soul-sync` 每次顺带跑一遍。没设就不跑。
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## 诚实的局限
|
|
193
|
+
|
|
194
|
+
- **没验证过"空壳能不能填出性格"**。代码能跑是确定的;一个人对着清单填,填出来是长出个有性格的人、还是填出一份简历——这个只有真人试过才知道。你要是试了,欢迎回来说。
|
|
195
|
+
- **检索是字面匹配,不懂意思**。"老板今天骂我"和"上班受气"不一定互相搜得到。要语义得上向量,那是另一件事。
|
|
196
|
+
- **只支持单用户**。同一份记忆库给多人共用会串味,别那么干。
|
|
197
|
+
- **注入块别贪大**。默认 3.3K 字左右;SOUL 要是写到一万字,每轮都在烧钱。压。
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## 授权
|
|
202
|
+
|
|
203
|
+
代码 MIT。记忆内容(`memory/`)是你自己的。
|
|
204
|
+
|
|
205
|
+
思路参考过 [dsh-plugin-memory](https://github.com/LittleBlackTong/dsh-plugin-memory)(MIT)——
|
|
206
|
+
这一版是**重写实现**,没有复制它的代码。如果你把它当插件装进 DSH,那份要单独保留它自己的 LICENSE。
|
package/bin/_common.sh
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 所有脚本共用的路径与环境。别的脚本都 source 这个文件,不要各自写死路径。
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
|
|
5
|
+
MEMKIT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
6
|
+
export MEMORY_DIR="${MEMORY_DIR:-$MEMKIT_ROOT/memory}"
|
|
7
|
+
export BRAIN_DIR="${BRAIN_DIR:-$MEMKIT_ROOT/brain}"
|
|
8
|
+
export DOCS_DIR="${DOCS_DIR:-$MEMKIT_ROOT/docs}"
|
|
9
|
+
export BIN_DIR="$MEMKIT_ROOT/bin"
|
|
10
|
+
export BRAIN_PORT="${BRAIN_PORT:-3914}"
|
|
11
|
+
export BRAIN_URL="http://127.0.0.1:${BRAIN_PORT}"
|
|
12
|
+
export PY="${PY:-python3}"
|
package/bin/archive-log
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""日志归档:log.md 太长就把老条目整段挪进 log-archive.md。
|
|
4
|
+
|
|
5
|
+
**不是删除**——归档页照样被小脑索引、照样搜得到。
|
|
6
|
+
目的只是别让 log.md 无限长(它每轮都要读,长了既慢又烧钱)。
|
|
7
|
+
|
|
8
|
+
用法: bin/archive-log [--keep 30] [--dry]
|
|
9
|
+
--keep 只留最近 N 天(默认 30)
|
|
10
|
+
"""
|
|
11
|
+
import os, re, sys
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "brain"))
|
|
14
|
+
from soul_lib import MEM, atomic_write
|
|
15
|
+
|
|
16
|
+
def main():
|
|
17
|
+
keep = 30
|
|
18
|
+
if "--keep" in sys.argv:
|
|
19
|
+
keep = int(sys.argv[sys.argv.index("--keep") + 1])
|
|
20
|
+
dry = "--dry" in sys.argv
|
|
21
|
+
p = os.path.join(MEM, "log.md")
|
|
22
|
+
if not os.path.exists(p):
|
|
23
|
+
print("没有 log.md"); return 0
|
|
24
|
+
text = open(p, encoding="utf-8").read()
|
|
25
|
+
# 按条目切:## [日期...] 开头
|
|
26
|
+
chunks = re.split(r"\n(?=##\s+\[)", "\n" + text)
|
|
27
|
+
head, items = "", []
|
|
28
|
+
for c in chunks:
|
|
29
|
+
c = c.strip()
|
|
30
|
+
if not c: continue
|
|
31
|
+
if not c.startswith("##"):
|
|
32
|
+
head = c; continue
|
|
33
|
+
m = re.match(r"##\s+\[([\d-]+)", c)
|
|
34
|
+
items.append((m.group(1) if m else "", c))
|
|
35
|
+
if not items:
|
|
36
|
+
print("log.md 里没有条目"); return 0
|
|
37
|
+
|
|
38
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(days=keep)).strftime("%Y-%m-%d")
|
|
39
|
+
old = [(d, c) for d, c in items if d and d < cutoff]
|
|
40
|
+
new = [(d, c) for d, c in items if not (d and d < cutoff)]
|
|
41
|
+
if not old:
|
|
42
|
+
print(f"没有早于 {cutoff} 的条目,不用归档"); return 0
|
|
43
|
+
if dry:
|
|
44
|
+
print(f"[dry] 会归档 {len(old)} 条,留 {len(new)} 条"); return 0
|
|
45
|
+
|
|
46
|
+
ap = os.path.join(MEM, "log-archive.md")
|
|
47
|
+
prev = open(ap, encoding="utf-8").read() if os.path.exists(ap) else "# Log 归档\n\n> 从 log.md 挪出来的旧条目,一字未改。\n"
|
|
48
|
+
atomic_write(ap, prev.rstrip() + "\n\n" + "\n\n".join(c for _, c in old) + "\n")
|
|
49
|
+
atomic_write(p, (head + "\n\n" if head else "") + ("> 更早的记录在 [log-archive.md](log-archive.md),照样搜得到。\n\n" if True else "") + "\n\n".join(c for _, c in new) + "\n")
|
|
50
|
+
print(f"归档 {len(old)} 条 → log-archive.md;log.md 剩 {len(new)} 条")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
sys.exit(main())
|
package/bin/brain
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 查小脑:brain "关键词" [条数]
|
|
3
|
+
# 先走常驻服务(毫秒级),服务没起就本地现算。
|
|
4
|
+
source "$(dirname "$0")/_common.sh"
|
|
5
|
+
Q="${1:-}"; N="${2:-5}"
|
|
6
|
+
[ -z "$Q" ] && { echo "用法:brain \"关键词\" [条数]"; echo " brain --topics"; exit 1; }
|
|
7
|
+
|
|
8
|
+
if [ "$Q" = "--topics" ]; then
|
|
9
|
+
curl -s -m 3 "$BRAIN_URL/topics" && exit 0
|
|
10
|
+
echo "(服务没起,先跑 bash bin/sync 或 bash bin/serve)"; exit 1
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
# 中文必须 percent-encode 后再进请求行,否则服务端按 latin-1 解出乱码
|
|
14
|
+
QENC=$("$PY" -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1]))' "$Q")
|
|
15
|
+
OUT=$(curl -s -m 5 "$BRAIN_URL/text?q=$QENC&n=$N" 2>/dev/null)
|
|
16
|
+
if [ -z "$OUT" ] || echo "$OUT" | grep -q "Connection refused"; then
|
|
17
|
+
OUT=$("$PY" "$BRAIN_DIR/server.py" --query "$Q" -n "$N" 2>/dev/null)
|
|
18
|
+
fi
|
|
19
|
+
printf '%s\n' "$OUT"
|
package/bin/brain-forget
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""遗忘:按强度衰减把"几乎想不起来"的条目标记为遗忘。
|
|
4
|
+
|
|
5
|
+
**不删原文。** 只在 state.json 里记一笔,索引时跳过。
|
|
6
|
+
想找回:brain-forget --restore <id>。
|
|
7
|
+
|
|
8
|
+
强度 = salience 基数 × 时间衰减 + 被想起的加成。
|
|
9
|
+
阈值默认 0.12,低于它就是"模糊到想不起来"。
|
|
10
|
+
|
|
11
|
+
用法: brain-forget # 只报告
|
|
12
|
+
brain-forget --apply # 真的标记
|
|
13
|
+
brain-forget --restore <id>
|
|
14
|
+
brain-forget --list
|
|
15
|
+
"""
|
|
16
|
+
import json, math, os, sys
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "brain"))
|
|
19
|
+
from soul_lib import load_state, save_state, BRAIN
|
|
20
|
+
|
|
21
|
+
BASE = {1: 1.0, 2: 0.75, 3: 0.5}
|
|
22
|
+
HALFLIFE = {1: 720, 2: 180, 3: 45}
|
|
23
|
+
THRESHOLD = float(os.environ.get("FORGET_THRESHOLD") or 0.12)
|
|
24
|
+
|
|
25
|
+
def age_days(d):
|
|
26
|
+
if not d: return None
|
|
27
|
+
for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M"):
|
|
28
|
+
try:
|
|
29
|
+
dd = datetime.strptime(d[:16] if fmt.endswith("%H:%M") else d[:10], fmt).replace(tzinfo=timezone.utc)
|
|
30
|
+
return max(0.0, (datetime.now(timezone.utc) - dd).total_seconds() / 86400)
|
|
31
|
+
except Exception: continue
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
def main():
|
|
35
|
+
st = load_state()
|
|
36
|
+
if "--list" in sys.argv:
|
|
37
|
+
for k, v in st["forgotten"].items():
|
|
38
|
+
print(f"{k} {v.get('at','')} {v.get('why','')}")
|
|
39
|
+
print(f"共 {len(st['forgotten'])} 条"); return 0
|
|
40
|
+
if "--restore" in sys.argv:
|
|
41
|
+
i = sys.argv.index("--restore")
|
|
42
|
+
rid = sys.argv[i+1] if len(sys.argv) > i+1 else ""
|
|
43
|
+
if rid in st["forgotten"]:
|
|
44
|
+
del st["forgotten"][rid]
|
|
45
|
+
st.setdefault("pinned", {})[rid] = {"at": datetime.now(timezone.utc).strftime("%Y-%m-%d")}
|
|
46
|
+
save_state(st)
|
|
47
|
+
print(f"已找回 {rid}(并钉住,不会再淡掉)")
|
|
48
|
+
return 0
|
|
49
|
+
print("没这条"); return 1
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
entries = json.load(open(os.path.join(BRAIN, "entries.json"), encoding="utf-8"))
|
|
53
|
+
except Exception:
|
|
54
|
+
print("还没有索引,先跑 soul index"); return 1
|
|
55
|
+
|
|
56
|
+
hit = []
|
|
57
|
+
for e in entries:
|
|
58
|
+
sal = int(e.get("salience") or 2)
|
|
59
|
+
age = age_days(e.get("date") or "")
|
|
60
|
+
decay = math.exp(-age / HALFLIFE.get(sal, 180)) if age is not None else 1.0
|
|
61
|
+
hits = st["touch"].get(e["id"], 0)
|
|
62
|
+
stv = max(0.0, min(1.0, BASE.get(sal, .75) * decay + min(.35, hits*.05)))
|
|
63
|
+
if stv < THRESHOLD:
|
|
64
|
+
hit.append((stv, e["id"], e.get("section") or e.get("title")))
|
|
65
|
+
if "--apply" in sys.argv:
|
|
66
|
+
for stv, rid, _ in hit:
|
|
67
|
+
st["forgotten"][rid] = {"at": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "why": f"强度{stv:.2f}"}
|
|
68
|
+
save_state(st); print(f"已标记遗忘 {len(hit)} 条(原文没删,可用 --restore 找回)")
|
|
69
|
+
else:
|
|
70
|
+
print(f"该遗忘了 {len(hit)} 条(加 --apply 才真标记)")
|
|
71
|
+
for stv, rid, sec in hit[:15]:
|
|
72
|
+
print(f" {stv:.2f} {sec[:40] if sec else rid}")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
sys.exit(main())
|
package/bin/compact
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""压缩:让注入层和索引保持苗条。
|
|
4
|
+
|
|
5
|
+
记忆会越堆越多,最后两个地方会先崩:
|
|
6
|
+
① 注入层太大 → 每轮烧钱、还压得模型说不好话
|
|
7
|
+
② 索引太长 → 检索变糊,搜什么都像
|
|
8
|
+
|
|
9
|
+
这一步做三件事:
|
|
10
|
+
· 时间线瘦身(老的进 log-archive,照样搜得到)
|
|
11
|
+
· 运行期记忆归档(很老的只留摘要,原文进 live-archive)
|
|
12
|
+
· 报告注入块大小,超预算就点名该压哪个
|
|
13
|
+
|
|
14
|
+
用法: bin/compact [--apply] [--budget 20000]
|
|
15
|
+
"""
|
|
16
|
+
import json, os, sys
|
|
17
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "brain"))
|
|
18
|
+
from soul_lib import MEM, BRAIN, LIVE, atomic_write
|
|
19
|
+
|
|
20
|
+
BUDGET_DEFAULT = 20000
|
|
21
|
+
INJECTED = ["SOUL.md", "MEMORY.md", "index.md", "NOW.md"]
|
|
22
|
+
|
|
23
|
+
def sizes():
|
|
24
|
+
out = []
|
|
25
|
+
for n in INJECTED:
|
|
26
|
+
p = os.path.join(MEM, n)
|
|
27
|
+
out.append((n, len(open(p, encoding="utf-8").read()) if os.path.exists(p) else 0))
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
def main():
|
|
31
|
+
apply_ = "--apply" in sys.argv
|
|
32
|
+
budget = BUDGET_DEFAULT
|
|
33
|
+
if "--budget" in sys.argv:
|
|
34
|
+
budget = int(sys.argv[sys.argv.index("--budget") + 1])
|
|
35
|
+
|
|
36
|
+
print("== 注入层 ==")
|
|
37
|
+
total = 0
|
|
38
|
+
for n, sz in sizes():
|
|
39
|
+
flag = " ← 偏大" if sz > budget * 0.5 else ""
|
|
40
|
+
print(f" {n:16s} {sz:6d} 字{flag}")
|
|
41
|
+
total += sz
|
|
42
|
+
pct = total / budget * 100
|
|
43
|
+
print(f" 合计 {total} 字 / 预算 {budget}({pct:.0f}%)")
|
|
44
|
+
if total > budget:
|
|
45
|
+
print(" ⚠ 超预算了:从最大的那个开始压,或调小 --budget")
|
|
46
|
+
|
|
47
|
+
# ① 时间线瘦身
|
|
48
|
+
print("\n== 时间线 ==")
|
|
49
|
+
import subprocess
|
|
50
|
+
cmd = [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), "archive-log")]
|
|
51
|
+
if not apply_:
|
|
52
|
+
cmd.append("--dry")
|
|
53
|
+
print(subprocess.run(cmd, capture_output=True, text=True).stdout.strip() or "(没输出)")
|
|
54
|
+
|
|
55
|
+
# ② 运行期记忆归档:超过 keep_days 的只留摘要
|
|
56
|
+
print("\n== 运行期记忆 ==")
|
|
57
|
+
if not os.path.exists(LIVE):
|
|
58
|
+
print(" 没有 live.jsonl")
|
|
59
|
+
else:
|
|
60
|
+
from datetime import datetime, timedelta, timezone
|
|
61
|
+
keep = int(os.environ.get("COMPACT_KEEP_DAYS") or 180)
|
|
62
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(days=keep)).strftime("%Y-%m-%d")
|
|
63
|
+
keep_recs, old_recs = [], []
|
|
64
|
+
for line in open(LIVE, encoding="utf-8"):
|
|
65
|
+
s = line.strip()
|
|
66
|
+
if not s: continue
|
|
67
|
+
try: r = json.loads(s)
|
|
68
|
+
except Exception: continue
|
|
69
|
+
(old_recs if (r.get("date") or "") < cutoff else keep_recs).append(r)
|
|
70
|
+
print(f" 早于 {cutoff} 的:{len(old_recs)} 条,保留中的:{len(keep_recs)} 条")
|
|
71
|
+
if old_recs and apply_:
|
|
72
|
+
ap = os.path.join(BRAIN, "live-archive.jsonl")
|
|
73
|
+
with open(ap, "a", encoding="utf-8") as f:
|
|
74
|
+
for r in old_recs:
|
|
75
|
+
r2 = dict(r); r2["archived"] = True
|
|
76
|
+
f.write(json.dumps(r2, ensure_ascii=False) + "\n")
|
|
77
|
+
atomic_write(LIVE, "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in keep_recs))
|
|
78
|
+
print(f" 已归档 {len(old_recs)} 条 → live-archive.jsonl(原文没丢)")
|
|
79
|
+
elif old_recs:
|
|
80
|
+
print(" (加 --apply 才真归档)")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
sys.exit(main())
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""调试档案:扫 docs/debug/ 下的文件,检查有没有登记进 INDEX.md。
|
|
4
|
+
|
|
5
|
+
不登记等于没写——下次搜不到。
|
|
6
|
+
另外可选打包(原文可以放云端,本地只留索引)。
|
|
7
|
+
|
|
8
|
+
用法: bin/debug-archive # 检查漏登记的
|
|
9
|
+
bin/debug-archive --tar # 顺便打成一个包
|
|
10
|
+
"""
|
|
11
|
+
import os, subprocess, sys, tarfile, time
|
|
12
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "brain"))
|
|
13
|
+
from soul_lib import DOCS
|
|
14
|
+
|
|
15
|
+
DBG = os.path.join(DOCS, "debug")
|
|
16
|
+
IDX = os.path.join(DBG, "INDEX.md")
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
if not os.path.isdir(DBG):
|
|
20
|
+
print("没有 docs/debug"); return 0
|
|
21
|
+
idx = open(IDX, encoding="utf-8").read() if os.path.exists(IDX) else ""
|
|
22
|
+
files = [f for f in sorted(os.listdir(DBG)) if f.endswith(".md") and f not in ("INDEX.md", "README.md")]
|
|
23
|
+
if not files:
|
|
24
|
+
print("还没有档案"); return 0
|
|
25
|
+
missing = [f for f in files if f not in idx]
|
|
26
|
+
print(f"档案 {len(files)} 份,未登记 {len(missing)} 份")
|
|
27
|
+
for f in missing:
|
|
28
|
+
print(f"\n {f}")
|
|
29
|
+
out = subprocess.run([sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), "summarize-debug"),
|
|
30
|
+
os.path.join(DBG, f)], capture_output=True, text=True).stdout.strip()
|
|
31
|
+
print(" 建议加这一行到 INDEX.md:")
|
|
32
|
+
print(" " + (out or "(没提取到,手写吧)"))
|
|
33
|
+
if "--tar" in sys.argv:
|
|
34
|
+
out = os.path.join(DBG, f"debug-{time.strftime('%Y%m%d')}.tgz")
|
|
35
|
+
with tarfile.open(out, "w:gz") as t:
|
|
36
|
+
for f in files: t.add(os.path.join(DBG, f), arcname=f)
|
|
37
|
+
print(f"\n打包:{out}")
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
sys.exit(main())
|
package/bin/debug-recall
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 查调试旧账:debug-recall "报错关键词"
|
|
3
|
+
# 遇到报错先查这里,别重新走一遍弯路。
|
|
4
|
+
source "$(dirname "$0")/_common.sh"
|
|
5
|
+
Q="${1:-}"
|
|
6
|
+
IDX="$DOCS_DIR/debug/INDEX.md"
|
|
7
|
+
[ -z "$Q" ] && { echo "用法:debug-recall \"关键词\""; echo " debug-recall --list"; exit 1; }
|
|
8
|
+
[ ! -f "$IDX" ] && { echo "还没有调试档案($IDX 不存在)"; exit 0; }
|
|
9
|
+
if [ "$Q" = "--list" ]; then grep -E '^\- \[' "$IDX" | head -50; exit 0; fi
|
|
10
|
+
grep -i -n -- "$Q" "$IDX" | head -20 || echo "(没查到同类问题——那这就是新问题)"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""叙事回炼:把碎片连成「我这一路」。
|
|
4
|
+
|
|
5
|
+
生长日志是一条条碎片,攒多了没人读得出来你到底怎么变的。
|
|
6
|
+
这一步提示:够不够量、值不值得写一段叙事。
|
|
7
|
+
|
|
8
|
+
用法: bin/growth-review [--n 20]
|
|
9
|
+
"""
|
|
10
|
+
import os, sys
|
|
11
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "brain"))
|
|
12
|
+
from soul_lib import MEM
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
n = 20
|
|
16
|
+
if "--n" in sys.argv: n = int(sys.argv[sys.argv.index("--n")+1])
|
|
17
|
+
p = os.path.join(MEM, "identity", "生长日志.md")
|
|
18
|
+
try: log = open(p, encoding="utf-8").read()
|
|
19
|
+
except OSError: print("还没有生长日志"); return 0
|
|
20
|
+
entries = [l for l in log.splitlines() if l.strip().startswith("-")]
|
|
21
|
+
print(f"生长日志 {len(entries)} 条(门槛 {n})")
|
|
22
|
+
if len(entries) < n:
|
|
23
|
+
print("还不够,先攒。半年写一次就够,不用天天写。")
|
|
24
|
+
return 0
|
|
25
|
+
print("够量了。该写一段「我这一路」了:")
|
|
26
|
+
print(" · 不是流水账,是**为什么变成现在这样**")
|
|
27
|
+
print(" · 挑 5-8 个转折点,每个一两句")
|
|
28
|
+
print(f" · 写到 {os.path.join(MEM,'identity','我这一路.md')}")
|
|
29
|
+
print(" · 写完别常改——这页半年动一次")
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
sys.exit(main())
|
package/bin/init-home
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 把空白记忆库铺到用户目录(装成插件时用这个)
|
|
3
|
+
# 默认 $HOME/.soul-forging/memory,可用 MEMORY_DIR 覆盖
|
|
4
|
+
set -uo pipefail
|
|
5
|
+
MEMKIT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
6
|
+
TARGET="${MEMORY_DIR:-$HOME/.soul-forging/memory}"
|
|
7
|
+
|
|
8
|
+
if [ -d "$TARGET" ] && [ -n "$(ls -A "$TARGET" 2>/dev/null)" ]; then
|
|
9
|
+
echo "目标已存在且有内容:$TARGET"
|
|
10
|
+
echo "不覆盖。要重来先自己备份并删掉它。"
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
mkdir -p "$TARGET"
|
|
15
|
+
cp -a "$MEMKIT_ROOT/memory/." "$TARGET/"
|
|
16
|
+
echo "✓ 空白记忆库已铺到:$TARGET"
|
|
17
|
+
echo
|
|
18
|
+
echo "接下来:"
|
|
19
|
+
echo " 1. 填 $TARGET/SOUL.md"
|
|
20
|
+
echo " 2. 按 $TARGET/BOOTSTRAP.md 走一遍铸魂"
|
|
21
|
+
echo " 3. 建索引:MEMORY_DIR=$TARGET python3 $MEMKIT_ROOT/brain/index.py"
|
|
22
|
+
echo " 4. 起服务:MEMORY_DIR=$TARGET bash $MEMKIT_ROOT/bin/serve"
|