sage-mem 2.2.2 → 2.3.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/README.md +2 -1
- package/README.zh.md +2 -1
- package/cordis.patch.yml +18 -1
- package/lib/recall/injector.js +9 -1
- package/lib/recall/text.js +11 -3
- package/package.json +3 -1
- package/skills/sage-mem/SKILL.md +176 -0
- package/skills/sage-mem/scripts/frontmatter-assert.cjs +87 -0
- package/skills/sage-mem/scripts/memory-audit.cjs +222 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
**Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
|
|
14
14
|
|
|
15
|
-
**Current release: `2.
|
|
15
|
+
**Current release: `2.3.0`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
|
|
16
16
|
[frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
|
|
17
17
|
|
|
18
18
|
If it is useful to you, a Star is the most direct feedback.
|
|
@@ -77,6 +77,7 @@ Most memory schemes take the "SQLite + a long-running worker" route: once memori
|
|
|
77
77
|
| **Anti-bloat** | Rules guide the model in judging "is this worth storing": nothing without substance is stored, nothing derivable by reading the code / git is stored |
|
|
78
78
|
| **Tidyable** | The optional "autodream" reads your memories through once: merges duplicates, fixes the index and broken links, and creates a rollback point before changing anything |
|
|
79
79
|
| **Migration from Claude Code** | The persona (`CLAUDE.md` → `AGENTS.md`) is copied as-is; memories just need organizing into `.md` files under four types (see [the next section](#migrating-from-claude-code)) |
|
|
80
|
+
| **Ships its own usage guide** | The package carries a skill (`skills/sage-mem/`): what a memory looks like, the four types, what not to store, the frontmatter pitfalls, how retrieval actually works, how to audit and fix a broken index. Installing the plugin is enough — the agent gets the instructions, no manual setup |
|
|
80
81
|
|
|
81
82
|
The UI also carries four more capabilities (all of them optional): the memory management page, type and tag filtering, the memory star map, and audit & archiving.
|
|
82
83
|
|
package/README.zh.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
> File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
|
|
16
16
|
|
|
17
|
-
**当前发布版本:`2.
|
|
17
|
+
**当前发布版本:`2.3.0`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
|
|
18
18
|
[契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
|
|
19
19
|
|
|
20
20
|
如果它对你有用,给个 Star 是最直接的反馈。
|
|
@@ -79,6 +79,7 @@ sage-mem 补上这一层,做法是:**把该记住的事写成一张张卡片
|
|
|
79
79
|
| **防膨胀** | 靠规则引导模型判断「值不值得存」:无实质内容的不存,靠读代码 / git 能推出来的不存 |
|
|
80
80
|
| **可整理** | 可选的「自动做梦」把记忆通读一遍:合并重复、修索引与断链,改动前先建回滚点 |
|
|
81
81
|
| **从 Claude Code 迁移** | 人格(`CLAUDE.md` → `AGENTS.md`)照搬;记忆按四类整理成 `.md` 即可(见[下一节](#从-claude-code-迁移)) |
|
|
82
|
+
| **自带使用指南** | 包内携带一份技能(`skills/sage-mem/`):记忆长什么样、四类怎么分、什么不该存、frontmatter 的坑、检索怎么工作、索引坏了怎么修。装上插件就够了 —— Agent 拿到说明书,不需要手工配置 |
|
|
82
83
|
|
|
83
84
|
界面上还有四块能力(都可以不用):记忆管理页、类型与标签筛选、记忆星图、体检与归档。
|
|
84
85
|
|
package/cordis.patch.yml
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
# sage-mem bundle patch
|
|
1
|
+
# sage-mem bundle patch
|
|
2
|
+
#
|
|
3
|
+
# 1) 注入记忆桥插件(sage-mem 本体)
|
|
4
|
+
# 2) 把本包自带的 `skills/` 目录注册成一个**额外的技能根** —— 装上插件就等于
|
|
5
|
+
# 装上了它的使用指南(`skills/sage-mem/SKILL.md`)。
|
|
6
|
+
#
|
|
7
|
+
# `providerName` 必须每个包唯一:技能注册表拒绝同名第二个 provider,用默认值
|
|
8
|
+
# ("filesystem") 会和宿主自己的 provider、以及其它用同一机制的插件撞名。
|
|
9
|
+
# `includeDefaultRoots: false` 让这个 provider 只管自己的目录 —— 宿主已经有默认
|
|
10
|
+
# provider 在扫项目根与用户根,重复扫会产生同名技能的来源歧义。
|
|
11
|
+
|
|
2
12
|
- insert:
|
|
3
13
|
- id: sage-mem
|
|
4
14
|
name: 'sage-mem'
|
|
15
|
+
- id: sage-mem-skills
|
|
16
|
+
name: '@deepseek-ai/dsh-skill-filesystem'
|
|
17
|
+
config:
|
|
18
|
+
providerName: sage-mem
|
|
19
|
+
includeDefaultRoots: false
|
|
20
|
+
customSkillDirs:
|
|
21
|
+
- !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))"
|
package/lib/recall/injector.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* 1. 只取 `source.kind === 'user'` 的消息 —— DSH 0.1.5 起注入与工具结果也存成 role:'user';
|
|
15
15
|
* 2. 代次处理(`state.begin`)必须在算正文与控制提示**之前**,否则压缩后会拿上一代的账去判重(复审 R03)。
|
|
16
16
|
*/
|
|
17
|
-
import { MEMORY_DIR, RECALL_DISCIPLINE, STATE_ROOT, validityEnabled } from '../memory/config.js'
|
|
17
|
+
import { MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, STATE_ROOT, validityEnabled } from '../memory/config.js'
|
|
18
18
|
import { listConflictIndex } from '../memory/conflicts.js'
|
|
19
19
|
import { buildContradictionFileSet, buildExpirySet } from '../memory/validity.js'
|
|
20
20
|
import { extractText } from '../memory/retrieval.js'
|
|
@@ -270,6 +270,14 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
|
|
|
270
270
|
cardPart,
|
|
271
271
|
memoryDir: MEMORY_DIR,
|
|
272
272
|
discipline: RECALL_DISCIPLINE,
|
|
273
|
+
// 2.3.0:上限**现读**(模块级常量就是本次启动生效的值 —— 改了设置要重启,
|
|
274
|
+
// 这一点设置页会提示)。随包技能因此不必写死任何数字。
|
|
275
|
+
limits: {
|
|
276
|
+
maxResults: MAX_RESULTS,
|
|
277
|
+
maxChars: MAX_CHARS_PER_FILE,
|
|
278
|
+
maxSessionBytes: MAX_SESSION_BYTES,
|
|
279
|
+
maxFileBytes: MAX_FILE_BYTES,
|
|
280
|
+
},
|
|
273
281
|
})
|
|
274
282
|
|
|
275
283
|
return {
|
package/lib/recall/text.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 注入文本的**组装**(1.2.0 从 `lib/index.js` 原样搬出)。
|
|
3
3
|
*
|
|
4
|
-
* 只有正文时:头部 + 引用纪律 + 正文 [+ 卡]
|
|
4
|
+
* 只有正文时:头部 + 引用纪律 + **本轮上限** + 正文 [+ 卡]
|
|
5
5
|
* 只有卡时:**就只发卡** —— 头部那句「以下是与你当前问题相关的历史记忆」在没有正文
|
|
6
6
|
* 的情况下是假陈述,而引用纪律管的是「别把记过的东西当当前事实」,没有正文就没有
|
|
7
7
|
* 需要约束的对象。
|
|
8
|
+
*
|
|
9
|
+
* 2.3.0:上限行**现读**传入(调用方从 `LIMIT_SPECS` 派生)。为什么不让随包技能写死
|
|
10
|
+
* 这些数字:技能一旦发布就是第二份真相,插件改了上限它不会跟着改。现在数字只有
|
|
11
|
+
* 一个来源 —— 这条刚刚打印出来的行。
|
|
8
12
|
*/
|
|
9
|
-
export function composeRecallText({ parts, cardPart, memoryDir, discipline }) {
|
|
13
|
+
export function composeRecallText({ parts, cardPart, memoryDir, discipline, limits }) {
|
|
10
14
|
if (!parts.length) return cardPart || ''
|
|
15
|
+
const kb = (n) => Math.round(n / 1024)
|
|
16
|
+
const limitLine = limits
|
|
17
|
+
? `> **本轮上限**(插件现读,随版本与设置变):一次最多 ${limits.maxResults} 条、每条最多 ${limits.maxChars} 字、单会话累计 ${kb(limits.maxSessionBytes)} KB 到顶、单文件 ${kb(limits.maxFileBytes)} KB。\n\n`
|
|
18
|
+
: ''
|
|
11
19
|
return `以下是与你当前问题相关的历史记忆(文件式,来自 ${memoryDir}):\n\n` +
|
|
12
|
-
`${discipline}\n\n${parts.join('\n\n')}` +
|
|
20
|
+
`${discipline}\n\n${limitLine}${parts.join('\n\n')}` +
|
|
13
21
|
(cardPart ? `\n\n${cardPart}` : '')
|
|
14
22
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sage-mem",
|
|
3
3
|
"description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"icon": "./icon.png",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"lib/",
|
|
16
16
|
"cordis.patch.yml",
|
|
17
|
+
"skills",
|
|
17
18
|
"NOTICE",
|
|
18
19
|
"icon.png"
|
|
19
20
|
],
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
68
69
|
"@deepseek-ai/dsh-typert-protocol": ">=0.1.2-rc.1",
|
|
69
70
|
"@deepseek-ai/dsh-client-locale": ">=0.1.2-rc.1",
|
|
71
|
+
"@deepseek-ai/dsh-skill-filesystem": "*",
|
|
70
72
|
"react": "^18.0.0"
|
|
71
73
|
},
|
|
72
74
|
"repository": {
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sage-mem
|
|
3
|
+
description: 使用 sage-mem 跨会话记忆插件 —— 记忆是 markdown + YAML frontmatter 的纯文件,没有数据库。当你需要「以后还要用到这件事」「上次那个决定」「用户偏好」而当前上下文里没有依据时、要把一条信息记下来、判断该不该存、不知道写进哪个文件、要按格式新建或修改记忆、维护 MEMORY.md 索引、发现记忆没被注入或检索不到、或者想知道它到底怎么检索时用它。即使对方没说出「记忆」两个字,只要涉及跨会话地记住/找回某件事,也适用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# sage-mem 使用指南
|
|
7
|
+
|
|
8
|
+
记忆不是存档,是**给未来的自己留的可用证据**。每次会话开始你什么都不记得;写进来的每一条都要让下一个你真的能在三十秒内判断「这条还准不准、能不能用」。
|
|
9
|
+
|
|
10
|
+
本技能管「怎么用这个插件」:文件长什么样、四类怎么分、什么坚决不写、写完怎么验、检索不到时先查哪里。
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 一、它给你什么
|
|
15
|
+
|
|
16
|
+
三件事,别的没有:
|
|
17
|
+
|
|
18
|
+
1. **自动注入** —— 每次提问,它按问题检索记忆、把相关片段塞进系统提示词,你直接「想起」。
|
|
19
|
+
2. **两个工具** —— `search_memory`(按词找)与 `read_memory`(按文件名读,可翻页)。
|
|
20
|
+
3. **纯文件** —— 没有数据库、没有常驻进程。所有记忆都是目录里的 `.md`,你能用普通文件工具直接读写、diff、放进 git。
|
|
21
|
+
|
|
22
|
+
**没有**「写入工具」。要写记忆,用你手上的通用文件写入工具改 markdown。格式错了不会被拦,只会在下次注入时表现为「这条永远搜不到」。
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 二、记忆长什么样
|
|
27
|
+
|
|
28
|
+
一条记忆 = **一个 `.md` 文件 + YAML frontmatter**。
|
|
29
|
+
|
|
30
|
+
```markdown
|
|
31
|
+
---
|
|
32
|
+
name: deploy-freeze
|
|
33
|
+
description: 生产环境在发版窗口内禁止合并;窗口是每周三 10:00-12:00。问「能不能合」「发版时间」时用。
|
|
34
|
+
metadata:
|
|
35
|
+
type: project
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
# 发版冻结窗口
|
|
39
|
+
|
|
40
|
+
正文……
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `name`:稳定标识(小写、连字符),改名等于换一条记忆。
|
|
44
|
+
- `description`:**最重要的一行**,检索主要靠它(见第八节)。
|
|
45
|
+
- `metadata.type`:四类之一(见第三节)。
|
|
46
|
+
- 正文:随便写,但**前 1500 字以外基本不会被注入**,硬货放前面。
|
|
47
|
+
|
|
48
|
+
文件名同样参与检索打分,所以文件名要有实义词:`feedback_dangerous-ops.md` 远好于 `feedback_f1.md`。
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 三、四类记忆:决定你写进哪个文件
|
|
53
|
+
|
|
54
|
+
| 类型 | 存什么 | 判据 | 例子 |
|
|
55
|
+
|---|---|---|---|
|
|
56
|
+
| `user` | **用户是谁**:身份、偏好、习惯、长期约束 | 换成任何人都会变 → 不是它 | 「用中文」「不吃香菜」「惯用 Windows」 |
|
|
57
|
+
| `feedback` | **用户给的工作方式**:纠正、规矩、踩过的坑 | 「以后遇到 X 就按 Y 做」 | 「发布前必须脱敏四查」「别用 git push --force」 |
|
|
58
|
+
| `project` | **进行中的工作**:目标、状态、决定、待办 | 有生命周期,做完就该更新或归档 | 「迁移项目的当前进度」「这个功能的三个待办」 |
|
|
59
|
+
| `reference` | **外部信息在哪找**:地址、凭据位置、指针 | 「怎么找到它」而不是「它是什么」 | 「服务器地址见某文件」「API 文档在某个 URL」 |
|
|
60
|
+
|
|
61
|
+
**分类不是装饰**:`type` 会进检索词表,也会影响使用者怎么看这批记忆。归错类的代价是「该被当规矩的一条,被当成背景资料」。
|
|
62
|
+
|
|
63
|
+
**一条记忆只放一件事。** 主题不同的东西写两个文件 —— 混在一起会让检索命中它却只给半截,也会让「改其中一件」变成「改整份」。
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 四、什么不写
|
|
68
|
+
|
|
69
|
+
这条比「写什么」更能决定记忆库的好坏。**一条坏记忆的代价不是占空间,是让未来的你在错误的前提上做判断。**
|
|
70
|
+
|
|
71
|
+
- **读代码 / 跑一次命令就能得到的** —— 比如「这个函数在第 120 行」。行号会漂,漂了还看起来很权威。存**推不出来的那一点**:为什么这么设计、当时否决了什么。
|
|
72
|
+
- **会过期的状态快照** —— 「当前版本 2.1.22」。要存就存成「怎么查当前版本」,或者明确标注日期并当作历史。
|
|
73
|
+
- **一次性的中间过程** —— 调试时的临时假设、试错路径。除非结论有价值,否则不要留过程。
|
|
74
|
+
- **从别处抄来的通用知识** —— 那是文档的活。记忆存的是**这个项目/这个人特有的**东西。
|
|
75
|
+
- **未经核实的推断** —— 把猜测写成事实,下一个人会拿它当依据。宁可写「未验证:怀疑是 X 因为 Y」。
|
|
76
|
+
|
|
77
|
+
**判据一句话**:下一个你读到这条,是**省一轮核实**,还是**多一次误判**?后者就删掉。
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 五、写一条记忆的流程
|
|
82
|
+
|
|
83
|
+
1. **先搜**:`search_memory` 看有没有相关的。有就**改它**,别再开一个 —— 同一件事两份,迟早分叉。
|
|
84
|
+
2. **定类型**:按第三节的表。
|
|
85
|
+
3. **定文件名**:`<类型>_<实义词>.md`,如 `feedback_release-steps.md`。
|
|
86
|
+
4. **写 frontmatter**(字段见第六节),description 按「将来我会怎么问」来写。
|
|
87
|
+
5. **写正文**:结论在前,理由在后,风险写清楚。
|
|
88
|
+
6. **更新 `updated:` 到今天就改(如果字段存在)** —— 注入时会显示「保存于 N 天前」,不改就永远显示旧日期,未来的你会误判它过期。
|
|
89
|
+
7. **验证**:跑 `frontmatter-assert`(见第九节)。
|
|
90
|
+
8. **索引**:如果目录里有 `MEMORY.md`,给它加一行(见第七节)。
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 六、frontmatter 的硬格式
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
---
|
|
98
|
+
name: <稳定标识,与文件名不同也无妨,但别改>
|
|
99
|
+
description: <一行,写清「什么时候该用它」>
|
|
100
|
+
metadata:
|
|
101
|
+
type: user | feedback | project | reference
|
|
102
|
+
updated: 2026-01-01 # 可选但强烈建议
|
|
103
|
+
---
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**两个静默杀手**(不会报错,只会让检索面残缺):
|
|
107
|
+
|
|
108
|
+
1. **值里有空格或 `#` 却没加引号** —— `description: 部署 注意 # 危险` 里 `# 危险` 会被当注释吞掉。含空格、冒号、`#`、单双引号时**整值加引号**:`description: "部署 注意 # 危险"`。
|
|
109
|
+
2. **缩进错位** —— `metadata` 下面的 `type` 必须缩进两格。缩进错会让 YAML 解析出一层多余结构,`type` 变成空。
|
|
110
|
+
|
|
111
|
+
**可选检索字段**:如果插件版本支持,`aliases`(同义说法)与 `baselinePriority`(每场会话必带的优先级)能显著提升命中率。不确定支持与否时,先搜一下同类记忆的实际写法,别凭空发明字段。
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 七、索引与归档
|
|
116
|
+
|
|
117
|
+
**`MEMORY.md`(索引)** —— 如果记忆目录里有它,它就是把散落文件串起来的那张表:按类型分组,每条一行「链接 + 一句话」。**新增/改名/删除记忆时同步它**,否则索引会指向不存在的文件(断链)。
|
|
118
|
+
|
|
119
|
+
索引是**整份覆盖风险最高**的文件:永远用「改一行」而不是「重建全文」,否则原有内容一个字都留不下。
|
|
120
|
+
|
|
121
|
+
**归档** —— 不再成立但还有历史价值的记忆(被取代的决定、已完成的阶段),移进 `archive/` 子目录。
|
|
122
|
+
|
|
123
|
+
- 归档的语义是「**还在,但不该进上下文**」:检索不扫子目录,所以归档 = 静默失效且可回退。
|
|
124
|
+
- **别直接删**。删了就没人能回答「当时为什么那么决定」。
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 八、检索怎么工作(以及为什么你搜不到)
|
|
129
|
+
|
|
130
|
+
**命中靠词面重合,不靠语义。** 机制:把提问和「每条记忆的 description + 文件名」都切成双字组,算覆盖率,按得分取前若干条。
|
|
131
|
+
|
|
132
|
+
推论,写记忆时照着做:
|
|
133
|
+
|
|
134
|
+
- **description 要写「将来会怎么问它」**,不是「它是什么」。一条讲归档的,description 里要有「归档」「索引」「断链」这些字 —— 用同义词或缩写就是错过。
|
|
135
|
+
- **文件名要有实义词**,它和 description 一起打分。
|
|
136
|
+
- **改内容顺手改 `updated:`**,否则新鲜度标记会骗人。
|
|
137
|
+
|
|
138
|
+
**上限不是写死的数字。** 一次注几条、每条多少字、单会话总预算、单文件上限 —— 这些随版本和用户设置变。插件在注入时会带上当前生效的值;**以注入文本为准,别把数字抄进记忆,也别硬编码**。
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 九、验证
|
|
143
|
+
|
|
144
|
+
两个随包脚本(在技能的 `scripts/` 下):
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
# 单条/多条记忆的 frontmatter 断言:真有块、真能 YAML 解析、name/description/type 非空、
|
|
148
|
+
# 以及「未加引号的值里有空格+#」这个静默杀手
|
|
149
|
+
node <技能目录>/scripts/frontmatter-assert.cjs <记忆文件.md> [更多文件...]
|
|
150
|
+
|
|
151
|
+
# 记忆库体检:索引断链、分类配比、整理阈值提醒
|
|
152
|
+
node <技能目录>/scripts/memory-audit.cjs [记忆目录]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**退出码 0 = 通过,非 0 = 有硬问题。** 别靠眼睛看 —— 落盘之后必须当场验。
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 十、症状 → 先查哪里
|
|
160
|
+
|
|
161
|
+
| 症状 | 先查 |
|
|
162
|
+
|---|---|
|
|
163
|
+
| 明明写过的记忆搜不到 | description 里有没有「你提问时会出现的词」;文件是不是在记忆根目录(子目录不扫) |
|
|
164
|
+
| 记忆写进去了但注入的是旧的 | 有 `updated:` 就把它改对;没有就检查文件 mtime |
|
|
165
|
+
| 改了文件名之后一切都不对 | 记忆的身份是文件名 + `name`;索引与双链都指向旧名,要一起改 |
|
|
166
|
+
| 索引里一堆断了 | 跑 `memory-audit` 的断链检查;归档文件要去 `archive/` 下找 |
|
|
167
|
+
| frontmatter 看着对但解析出来是空的 | 未加引号的 `#` 把后半句吞了;或者缩进错位 |
|
|
168
|
+
| 注入总量很快就到顶 | 记忆太啰嗦 —— 每条的精炼度直接决定能注几条 |
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## 十一、边界
|
|
173
|
+
|
|
174
|
+
- **改的是记忆内容** → 本技能。
|
|
175
|
+
- **改的是插件本身**(注入逻辑、检索算法、索引生成)→ 那是代码问题,去读插件仓库的 `lib/`,不是改记忆能修的。
|
|
176
|
+
- **记忆目录由谁决定** → 环境变量 `SAGE_MEM_DIR`(插件读它)。不确定当前用哪个目录时,看设置页,或读一次 `search_memory` 返回的 `file` 字段路径。
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
/**
|
|
4
|
+
* frontmatter-assert.cjs — 记忆文件的 frontmatter 断言(2026-10-06 从 SKILL.md §9.1 固化)
|
|
5
|
+
*
|
|
6
|
+
* 原先 §9.1 是一条 `node -e "...一大坨..."` 命令。实际用起来有两个问题:
|
|
7
|
+
* ① **PowerShell 的引号地狱**:命令里有 `^["']` 这类片段,放进 pwsh 的双引号串会
|
|
8
|
+
* 提前结束字符串,手抄一次要试两遍(2026-10-06 亲历,改用临时 .cjs 才跑通);
|
|
9
|
+
* ② 它一次只验一个文件,批量改完记忆得循环手拼。
|
|
10
|
+
* 现在:多文件一次过,逐行 OK/FAIL,退出码 0/1。
|
|
11
|
+
*
|
|
12
|
+
* 三件事都要有(解析通过 ≠ 字段完整):
|
|
13
|
+
* 1. 真有 frontmatter 块;2. 能被**真 YAML 解析**;3. `name` / `description` / `type` 非空;
|
|
14
|
+
* 外加逐行查「未加引号的值里有空格+#」—— 那会让后半句被当注释吞掉,检索面残缺且零提示。
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ 这是给**记忆文件**用的。技能文件(SKILL.md)没有 `type` 字段,对它跑必然报「缺 type」。
|
|
17
|
+
*
|
|
18
|
+
* 用法: node frontmatter-assert.cjs <记忆文件.md> [更多文件...]
|
|
19
|
+
* 退出码: 0 = 全部通过;1 = 有文件有问题;2 = 用法/环境错
|
|
20
|
+
*/
|
|
21
|
+
const fs = require('fs')
|
|
22
|
+
const path = require('path')
|
|
23
|
+
|
|
24
|
+
function loadYaml() {
|
|
25
|
+
const guesses = [
|
|
26
|
+
// ① DSH 的 profile 依赖树(DSH 自带 yaml,装了 DSH 就一定有)
|
|
27
|
+
process.env.DSH_HOME && path.join(process.env.DSH_HOME, 'profiles/node_modules/yaml'),
|
|
28
|
+
// ② 本脚本所在包自己的依赖树(技能在 <包>/skills/<技能名>/scripts/,往上三级到包根)
|
|
29
|
+
path.join(__dirname, '../../../node_modules/yaml'),
|
|
30
|
+
path.join(__dirname, '../../node_modules/yaml'),
|
|
31
|
+
// ③ 正常的模块解析
|
|
32
|
+
'yaml',
|
|
33
|
+
].filter(Boolean)
|
|
34
|
+
for (const g of guesses) {
|
|
35
|
+
try { return require(g) } catch { /* 试下一个 */ }
|
|
36
|
+
}
|
|
37
|
+
console.error('找不到 yaml 包。三条出路:① 设好 DSH_HOME(DSH 自带 yaml);② 在本插件目录 npm install yaml;③ 全局装 yaml 并把路径加进本脚本的 guesses')
|
|
38
|
+
process.exit(2)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const yaml = loadYaml()
|
|
42
|
+
const files = process.argv.slice(2)
|
|
43
|
+
if (!files.length) {
|
|
44
|
+
console.error('用法: node frontmatter-assert.cjs <记忆文件.md> [更多文件...]')
|
|
45
|
+
process.exit(2)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let bad = 0
|
|
49
|
+
for (const p of files) {
|
|
50
|
+
const label = path.basename(p)
|
|
51
|
+
let raw
|
|
52
|
+
try {
|
|
53
|
+
raw = fs.readFileSync(p, 'utf8')
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.log(`FAIL ${label} — 读不到:${e.message}`)
|
|
56
|
+
bad++
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
60
|
+
if (!fm) { console.log(`FAIL ${label} — 没有 frontmatter 块`); bad++; continue }
|
|
61
|
+
|
|
62
|
+
let m
|
|
63
|
+
try {
|
|
64
|
+
m = yaml.parse(fm[1])
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.log(`FAIL ${label} — YAML 解析失败:${e.message}`)
|
|
67
|
+
bad++
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const errs = []
|
|
72
|
+
const type = m.type || (m.metadata && m.metadata.type)
|
|
73
|
+
for (const k of ['name', 'description']) if (!m[k]) errs.push(`缺 ${k}`)
|
|
74
|
+
if (!type) errs.push('缺 type(会被静默兜底成 reference)')
|
|
75
|
+
for (const l of fm[1].split(/\r?\n/)) {
|
|
76
|
+
const mv = l.match(/^[A-Za-z0-9_.-]+:\s*(.*)$/)
|
|
77
|
+
if (mv && !/^["']/.test(mv[1]) && / #/.test(mv[1])) {
|
|
78
|
+
errs.push(`未加引号的值里有「空格+#」:${l.trim().slice(0, 70)}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (errs.length) { console.log(`FAIL ${label} — ${errs.join(' | ')}`); bad++ }
|
|
83
|
+
else console.log(`OK ${label} type=${type} name=${m.name}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(bad ? `结果:${bad} 个文件有问题` : `结果:${files.length} 个文件全部通过 ✅`)
|
|
87
|
+
process.exit(bad ? 1 : 0)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
/**
|
|
4
|
+
* memory-audit.cjs — 记忆库体检(2026-10-06 从 SKILL.md §9.2 固化下来)
|
|
5
|
+
*
|
|
6
|
+
* 为什么固化:原先它是技能正文里一段「存成临时脚本再跑」的代码,后果是
|
|
7
|
+
* ① 每次手抄一遍,口径改一处要同步两处;② 断链检查的口径本来就要演进,
|
|
8
|
+
* 散在文档里没法断言。现在体检这条链只有一个出口。
|
|
9
|
+
*
|
|
10
|
+
* 用法: node memory-audit.cjs [记忆目录] # 默认读 $SAGE_MEM_DIR
|
|
11
|
+
* 退出码: 0 = 无硬问题(软提醒不算失败);1 = 有硬问题
|
|
12
|
+
*
|
|
13
|
+
* ── 2026-10-06 改了三处口径 ──
|
|
14
|
+
* 1. **归档认可(P1-10)**:断链检查的名字池现在同时收 `archive/` 下的文件名与
|
|
15
|
+
* frontmatter name。原先只扫根目录,于是「文件被移进 archive/」会让所有指向它的
|
|
16
|
+
* `[[链接]]` 静默变成断链 —— 那等于把「归档」显示成「从未存在过」。
|
|
17
|
+
* 分级:指向根目录 ✅ / 指向 archive/ 记一句提示(文件还在)/ 两边都没有 ❌ 断链。
|
|
18
|
+
* ⚠️ **只有审计认可 archive/,检索不认可** —— 归档的语义是「还在,但不该进上下文」。
|
|
19
|
+
* 2. **代码块排除**:`[[...]]` 出现在反引号或围栏代码块里时不算链接。改这条是因为
|
|
20
|
+
* 实测有误报:`reference_legacy-session-archive.md` 正文里拿 `` `[[双链]]` `` 当
|
|
21
|
+
* 例子说明,被当成了一条真断链。
|
|
22
|
+
* 3. **新增两类统计**:分类配比(P1-4)与整理阈值(P1-11)—— 报告式软提醒,不改数据。
|
|
23
|
+
*/
|
|
24
|
+
const fs = require('fs')
|
|
25
|
+
const path = require('path')
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 整理阈值(P1-11)。**按我们自己的现状定,不抄别家数字** —— Basic Memory 的索引线是
|
|
29
|
+
* 500 行、Claude Code 是 200 行 / 25 KB,两个数差一倍多,说明这是各家拍的经验值。
|
|
30
|
+
* 我们的现状(2026-10-06 实测)与取值理由:
|
|
31
|
+
* · 索引 112 行 / 36.5 KB → 线 150 行 / 45 KB(留约 30% 余量)。
|
|
32
|
+
* ⚠️ 注意字节线:CC 是 25 KB,**我们 36.5 KB 已经超了** —— 所以这条线按自己的
|
|
33
|
+
* 体量定,照抄会变成「每天都在报警」。
|
|
34
|
+
* · 单条记忆最大 50.3 KB / 374 行(project_guikit.md)→ 线 30 KB / 300 行。
|
|
35
|
+
* 它会一直报:这正是它该被拆开或做摘要前置的信号,不是误报。
|
|
36
|
+
* · 索引单行最长约 800 字符(那些把整段结论塞进索引行的条目)→ 线 400 字符。
|
|
37
|
+
* · session-log 538 KB → 线 600 KB。
|
|
38
|
+
* · project_* 占比 38% → 线 45%(论文里最容易陈旧、最该注意配比的正是这一类)。
|
|
39
|
+
*/
|
|
40
|
+
const LIMITS = {
|
|
41
|
+
indexLines: 150,
|
|
42
|
+
indexKB: 45,
|
|
43
|
+
indexLineChars: 400,
|
|
44
|
+
fileKB: 30,
|
|
45
|
+
fileLines: 300,
|
|
46
|
+
logKB: 600,
|
|
47
|
+
projectSharePct: 45,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const dir = process.argv[2] || process.env.SAGE_MEM_DIR
|
|
51
|
+
if (!dir) {
|
|
52
|
+
console.error('sage-mem: 未指定记忆目录,也未设置 SAGE_MEM_DIR')
|
|
53
|
+
process.exit(2)
|
|
54
|
+
}
|
|
55
|
+
const archiveDir = path.join(dir, 'archive')
|
|
56
|
+
const INDEX = 'MEMORY.md'
|
|
57
|
+
const LOG = 'session-log.md'
|
|
58
|
+
|
|
59
|
+
// ── 工具 ─────────────────────────────────────────────────────────────
|
|
60
|
+
/** 剥掉围栏代码块与行内 code span —— 反引号里的 `[[x]]` 是例子,不是链接。 */
|
|
61
|
+
function stripCode(raw) {
|
|
62
|
+
return raw.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, '')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readFiles(d) {
|
|
66
|
+
try {
|
|
67
|
+
return fs.readdirSync(d).filter((f) => f.endsWith('.md'))
|
|
68
|
+
} catch {
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 解析 frontmatter 的 name / type(兼容顶层与 metadata 嵌套两种 type)。 */
|
|
74
|
+
function parseFm(raw) {
|
|
75
|
+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
76
|
+
if (!m) return { fm: null, name: '', type: '' }
|
|
77
|
+
const grab = (k) => {
|
|
78
|
+
const hit = raw.match(new RegExp(`^[ \\t]*${k}:[ \\t]*["']?([^"'\\r\\n]+?)["']?[ \\t]*$`, 'm'))
|
|
79
|
+
return hit ? hit[1].trim() : ''
|
|
80
|
+
}
|
|
81
|
+
let type = grab('type')
|
|
82
|
+
if (!type) {
|
|
83
|
+
const nested = m[1].match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+type:\s*(\S+)/m)
|
|
84
|
+
if (nested) type = nested[1]
|
|
85
|
+
}
|
|
86
|
+
return { fm: m[1], name: grab('name'), type }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function addKeys(set, file, meta) {
|
|
90
|
+
set.add(file)
|
|
91
|
+
set.add(file.replace(/\.md$/, ''))
|
|
92
|
+
if (meta && meta.name) set.add(meta.name)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const load = (d, names) =>
|
|
96
|
+
names.map((f) => {
|
|
97
|
+
const raw = fs.readFileSync(path.join(d, f), 'utf8')
|
|
98
|
+
return { file: f, raw, ...parseFm(raw), bytes: Buffer.byteLength(raw, 'utf8') }
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// ── 读数 ─────────────────────────────────────────────────────────────
|
|
102
|
+
const rootFiles = readFiles(dir).filter((f) => f !== INDEX)
|
|
103
|
+
const arcFiles = readFiles(archiveDir)
|
|
104
|
+
const rootData = load(dir, rootFiles)
|
|
105
|
+
const arcData = load(archiveDir, arcFiles)
|
|
106
|
+
|
|
107
|
+
const rootKeys = new Set()
|
|
108
|
+
rootData.forEach((o) => addKeys(rootKeys, o.file, o))
|
|
109
|
+
const arcKeys = new Set()
|
|
110
|
+
arcData.forEach((o) => addKeys(arcKeys, o.file, o))
|
|
111
|
+
|
|
112
|
+
const hard = []
|
|
113
|
+
const soft = []
|
|
114
|
+
|
|
115
|
+
// ── 1. 双链:断链 / 指向归档 ────────────────────────────────────────
|
|
116
|
+
const toArchive = []
|
|
117
|
+
for (const o of rootData) {
|
|
118
|
+
for (const m of stripCode(o.raw).matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
119
|
+
const k = m[1].trim()
|
|
120
|
+
if (rootKeys.has(k)) continue
|
|
121
|
+
if (arcKeys.has(k)) { toArchive.push(`${o.file} → [[${k}]]`); continue }
|
|
122
|
+
hard.push(`双链断链:${o.file} → [[${k}]]`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 2. 索引:悬空 / 漏索引 / 单行过长 ───────────────────────────────
|
|
127
|
+
// 索引读不到也要出报告,而不是崩掉 —— 体检工具在半坏的状态下更有用。
|
|
128
|
+
let idxRaw = ''
|
|
129
|
+
try {
|
|
130
|
+
idxRaw = fs.readFileSync(path.join(dir, INDEX), 'utf8')
|
|
131
|
+
} catch {
|
|
132
|
+
hard.push(`索引文件不存在或读不到:${INDEX}(目录 ${dir})`)
|
|
133
|
+
}
|
|
134
|
+
const linked = [...new Set([...idxRaw.matchAll(/\]\(([^)\/]+\.md)\)/g)].map((m) => m[1]))]
|
|
135
|
+
const missing = linked.filter((f) => !fs.existsSync(path.join(dir, f)))
|
|
136
|
+
missing.forEach((f) => hard.push(`索引指向不存在的文件:${f}`))
|
|
137
|
+
const unlisted = rootFiles.filter((f) => !linked.includes(f))
|
|
138
|
+
unlisted.forEach((f) => hard.push(`漏在索引外的文件:${f}`))
|
|
139
|
+
|
|
140
|
+
const idxLines = idxRaw.split(/\r?\n/)
|
|
141
|
+
const longLines = idxLines
|
|
142
|
+
.map((l, i) => ({ l, n: i + 1 }))
|
|
143
|
+
.filter((o) => o.l.length > LIMITS.indexLineChars)
|
|
144
|
+
const idxKB = Buffer.byteLength(idxRaw, 'utf8') / 1024
|
|
145
|
+
// 口径与外部数字对齐:CC 那条「索引 200 行」说的是文件行数(含空行)。
|
|
146
|
+
const idxLineCount = idxLines.length
|
|
147
|
+
|
|
148
|
+
// ── 3. 每条:type / 前缀 / CRLF / 引号里的「 #」 ────────────────────
|
|
149
|
+
for (const o of rootData) {
|
|
150
|
+
const prefix = o.file.split('_')[0]
|
|
151
|
+
if (!o.type) hard.push(`缺 type(会被静默兜底成 reference):${o.file}`)
|
|
152
|
+
else if (o.type !== prefix && o.file !== LOG) {
|
|
153
|
+
hard.push(`前缀与 type 不一致:${o.file} type=${o.type} 前缀=${prefix}`)
|
|
154
|
+
}
|
|
155
|
+
if (o.raw.includes('\r')) soft.push(`CRLF 换行:${o.file}`)
|
|
156
|
+
if (o.fm) {
|
|
157
|
+
for (const l of o.fm.split(/\r?\n/)) {
|
|
158
|
+
const mv = l.match(/^[A-Za-z0-9_.-]+:\s*(.*)$/)
|
|
159
|
+
if (mv && !/^["']/.test(mv[1]) && / #/.test(mv[1])) {
|
|
160
|
+
hard.push(`未加引号的值里有「空格+#」(会被当注释吞掉):${o.file} :: ${l.trim().slice(0, 80)}`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// session-log 是追加型流水、天生巨大,由下面那条 logKB 专属线管 —— 别拿「一条记忆」
|
|
165
|
+
// 的尺子量它,否则它会天天占着这个告警位,把真正该拆的那几条淹掉。
|
|
166
|
+
if (o.file === LOG) continue
|
|
167
|
+
const kb = o.bytes / 1024
|
|
168
|
+
const lines = o.raw.split(/\r?\n/).length
|
|
169
|
+
if (kb > LIMITS.fileKB || lines > LIMITS.fileLines) {
|
|
170
|
+
soft.push(`单条超整理线:${o.file} ${kb.toFixed(1)} KB / ${lines} 行(线 ${LIMITS.fileKB} KB / ${LIMITS.fileLines} 行)`)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── 4. 配比与体量(P1-4 / P1-11)───────────────────────────────────
|
|
175
|
+
const counts = {}
|
|
176
|
+
for (const o of rootData) {
|
|
177
|
+
if (o.file === LOG) continue
|
|
178
|
+
const p = o.file.split('_')[0]
|
|
179
|
+
counts[p] = (counts[p] || 0) + 1
|
|
180
|
+
}
|
|
181
|
+
const totalMem = Object.values(counts).reduce((a, b) => a + b, 0)
|
|
182
|
+
const pct = (n) => (totalMem ? Math.round((n / totalMem) * 100) : 0)
|
|
183
|
+
const dist = Object.entries(counts).sort((a, b) => b[1] - a[1])
|
|
184
|
+
const totalKB = (rootData.reduce((n, o) => n + o.bytes, 0) + Buffer.byteLength(idxRaw, 'utf8')) / 1024
|
|
185
|
+
|
|
186
|
+
if (idxLineCount > LIMITS.indexLines) soft.push(`索引 ${idxLineCount} 行,超过 ${LIMITS.indexLines} 行的线`)
|
|
187
|
+
if (idxKB > LIMITS.indexKB) soft.push(`索引 ${idxKB.toFixed(1)} KB,超过 ${LIMITS.indexKB} KB 的线`)
|
|
188
|
+
if (longLines.length) {
|
|
189
|
+
const worst = longLines.reduce((a, b) => (a.l.length > b.l.length ? a : b))
|
|
190
|
+
soft.push(`索引有 ${longLines.length} 行超过 ${LIMITS.indexLineChars} 字符(最长第 ${worst.n} 行 ${worst.l.length} 字符)——该降级成一行一句`)
|
|
191
|
+
}
|
|
192
|
+
const projShare = pct(counts.project || 0)
|
|
193
|
+
if (projShare > LIMITS.projectSharePct) {
|
|
194
|
+
soft.push(`project_* 占 ${projShare}%(${counts.project} 条),超过 ${LIMITS.projectSharePct}% —— 论文里最容易陈旧、最该注意配比的正是这一类`)
|
|
195
|
+
}
|
|
196
|
+
const log = rootData.find((o) => o.file === LOG)
|
|
197
|
+
if (log) {
|
|
198
|
+
const logKB = log.bytes / 1024
|
|
199
|
+
if (logKB > LIMITS.logKB) soft.push(`session-log.md ${logKB.toFixed(1)} KB,超过 ${LIMITS.logKB} KB 的线 —— 该考虑按年分段`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── 报告 ─────────────────────────────────────────────────────────────
|
|
203
|
+
console.log(`══ 记忆库体检 · ${new Date().toLocaleString('zh-CN')} ══`)
|
|
204
|
+
console.log(`目录 ${dir}`)
|
|
205
|
+
console.log('')
|
|
206
|
+
console.log(`根目录 ${rootData.length} 条 / ${totalKB.toFixed(1)} KB · 归档 ${arcData.length} 条 · 索引 ${idxLineCount} 行 / ${idxKB.toFixed(1)} KB`)
|
|
207
|
+
console.log(`分类:${dist.map(([k, n]) => `${k} ${n} (${pct(n)}%)`).join(' · ')}`)
|
|
208
|
+
console.log('')
|
|
209
|
+
console.log(`── 硬问题(${hard.length})──`)
|
|
210
|
+
if (hard.length) hard.forEach((h) => console.log(' ❌ ' + h))
|
|
211
|
+
else console.log(' (无)')
|
|
212
|
+
console.log('')
|
|
213
|
+
console.log(`── 软提醒(${soft.length})──`)
|
|
214
|
+
if (soft.length) soft.forEach((s) => console.log(' ⚠️ ' + s))
|
|
215
|
+
else console.log(' (无)')
|
|
216
|
+
console.log('')
|
|
217
|
+
console.log(`── 指向归档的引用(${toArchive.length} 条,不算问题)──`)
|
|
218
|
+
console.log(` 归档文件名与 name 都在认可池里,所以这些链接不再报断链。`)
|
|
219
|
+
console.log(' ⚠️ 只有审计认可 archive/,检索不认可 —— 归档的仍然不进上下文。')
|
|
220
|
+
console.log('')
|
|
221
|
+
console.log(hard.length ? `结果:${hard.length} 个硬问题` : '结果:无硬问题 ✅')
|
|
222
|
+
process.exit(hard.length ? 1 : 0)
|