@momenttttt/pi-zcode-memory 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Momenttttt
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/README.md CHANGED
@@ -1,3 +1,153 @@
1
- # Temporary Holding Version
1
+ # @momenttttt/pi-zcode-memory
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 让 pi 与 ZCode 共用同一份项目记忆。
4
+
5
+ - 三个工具:`memory_list`、`memory_read`、`memory_write`
6
+ - 会话开始时只把 `MEMORY.md` 索引注入一次;正文永不自动注入,按需读
7
+ - 目录算法、索引格式、事实文件 frontmatter 逐条对齐 ZCode,两边读写同一批文件
8
+ - `/memory` 查看状态与索引体检,`/memory reload` 强制下一次运行重新注入
9
+
10
+ ## 安装
11
+
12
+ ```bash
13
+ pi install npm:@momenttttt/pi-zcode-memory
14
+ ```
15
+
16
+ 本地开发:
17
+
18
+ ```bash
19
+ pi -e ./packages/pi-zcode-memory/extensions/zcode-memory.ts
20
+ ```
21
+
22
+ ## 记忆目录怎么定位
23
+
24
+ 按共享记忆目录的路径算法计算:
25
+
26
+ ```
27
+ ~/.zcode -> 追加 cli(basename 已是 cli 则不追加)
28
+ <storageRoot>/memories/projects/<slug>-<hash16>/memory/
29
+ hash16 = sha256(win32 ? resolve(cwd).toLowerCase() : resolve(cwd)).slice(0, 16)
30
+ slug = 小写、非 [a-z0-9._-] 换 -、去首尾 -、截 48、空则 project
31
+ ```
32
+
33
+ 实际使用时有三级回退:
34
+
35
+ 1. 配置里的 `dir`(最高优先级;路径算法对不上时用它,例如工作区标识或路径写法差异)
36
+ 2. 算法算出的路径存在 → 用它
37
+ 3. 不存在时,`memories/projects/` 下恰好一个 `<slug>-*` 目录 → 用它并警告;
38
+ 否则视为"该项目尚无 ZCode 记忆"(不创建目录;`memory_write` 时才按计算路径创建)
39
+
40
+ 检测到 `project-` 前缀目录时会提示:ZCode 可能用了 workspace identity(slug 固定为 `project`,
41
+ hash 输入不是路径)。独立 CLI 与普通桌面项目不会出现这种情况。
42
+
43
+ ## 配置
44
+
45
+ 两个位置,深合并,项目覆盖全局:
46
+
47
+ - 全局:`<agent-dir>/extensions/zcode-memory.json`(默认 `~/.pi/agent/extensions/zcode-memory.json`)
48
+ - 项目:`<cwd>/.pi/zcode-memory.json`
49
+
50
+ ```json
51
+ {
52
+ "enabled": true,
53
+ "dir": "D:/zcode/cli/memories/projects/chat-ace14747b286680c/memory",
54
+ "injectIndex": true
55
+ }
56
+ ```
57
+
58
+ | 项 | 默认 | 说明 |
59
+ | --- | --- | --- |
60
+ | `enabled` | `true` | 关掉后工具仍注册,但不注入索引(工具本身仍可用) |
61
+ | `dir` | 无 | 显式记忆目录。任意异常场景的兜底 |
62
+ | `injectIndex` | `true` | 是否做索引注入 |
63
+
64
+ 配置解析失败(文件坏了、JSON 不合法)不会让插件崩,退回默认值。
65
+
66
+ ## 工具
67
+
68
+ | 工具 | 行为 |
69
+ | --- | --- |
70
+ | `memory_list` | 递归扫记忆目录(排除 `MEMORY.md`、不跟随目录符号链接),返回文件名 / name / description / type / mtime。坏 frontmatter 降级成"无描述条目",不抛错 |
71
+ | `memory_read` | 按 name 或文件名定位;返回 frontmatter 摘要 + 正文。正文按 ZCode 召回上限截断(前 200 行 / 4096 字节)并提示去哪里读全文 |
72
+ | `memory_write` | `name` / `description` / `type` / `body` / `mode: create\|upsert`。原子写事实文件 + 维护 `MEMORY.md` 索引行 |
73
+
74
+ 写入的 frontmatter 只有四个键:
75
+
76
+ ```yaml
77
+ ---
78
+ name: <kebab-case-slug>
79
+ description: <一行描述>
80
+ metadata:
81
+ node_type: memory
82
+ type: user | feedback | project | reference
83
+ ---
84
+ ```
85
+
86
+ **不写** `originSessionId`(那是读文件的一方在缺失时自己补的惰性字段,我们不伪造不属于自己的
87
+ 会话 id),也不在 `metadata` 里加自定义键。
88
+
89
+ ## 设计取舍
90
+
91
+ **为什么索引只注入一次,而不是每轮重注?**
92
+ 只有 `MEMORY.md` 进上下文,正文按需读。注入形态是 `before_agent_start` 返回的
93
+ 一条 `display: false` custom 消息(不是塞进 system prompt)。之后用
94
+ `pi.appendEntry("zcode-memory/injected", { indexHash })` 记标记;`session_start`
95
+ (new / resume / fork)按当前会话投影重建标记。重新注入只有三种情况:索引内容 hash 变化、
96
+ 注入消息被压缩摘要掉(不在保留尾部里)、`/memory reload`。`messageType === "custom"`
97
+ 的运行(`pi.sendMessage(..., { triggerTurn: true })` 起的)直接跳过。
98
+
99
+ **为什么 `memory_write` 走 `withFileMutationQueue` + revision 重试,而不是做跨进程锁?**
100
+ ZCode 的前台写本来就没有跨进程锁(后台整理进程除外),它靠 revision
101
+ (`mtime:<截断毫秒>:size:<字节>`)发现并发。我们走同一条自愈路径:进程内用 pi 的写队列
102
+ 串行;`MEMORY.md` 写前比对 revision,不一致就重读重试一次,再失败就报错让模型重试——
103
+ **不静默覆盖**。代价是 ZCode 下一次写会撞上 revision 冲突,它重读索引后即可继续。
104
+
105
+ **为什么 `memory_read` 后要发读态事件?**
106
+ 用 `memory_read` 读过的记忆,紧接着用内置 `edit` 修改时不应该再被
107
+ [pi-read-guard](https://github.com/Momenttttt/pi-extension-suite) 要求"先读"。两个包通过
108
+ `pi.events` 共享总线互通(频道 `read-guard/file-read`):发布方只 `emit`,接收方只 `on`,
109
+ 任一方缺席时静默降级,没有 import 依赖。相比"配置声明的读态提供方",少一层配置形状与
110
+ 解析规则,收益相同。截断读发布 `kind: "truncated"` 且不带内容 hash —— 截断读不能成为
111
+ 写水位,和内置 `read` 的 `details.truncation` 是同一条规则。
112
+
113
+ **为什么正文读取要有截断上限?**
114
+ `memory_read` 返回的就是"会话里实际可用的量":一条记忆被召回时最多取前 200 行 /
115
+ 4096 字节,完整内容始终可以用内置 `read` 读。这样模型不会误以为工具给了全文。
116
+
117
+ **为什么目录算法不做 `~` 展开、不做 realpath?**
118
+ hash 的输入就是 `resolve(cwd)` 这个路径字符串。realpath 会把符号链接/短路径折成另一个
119
+ 字符串,反而算错目录。win32 折叠大小写也是同一个目的:让同一目录的不同写法落到同一个 hash。
120
+
121
+ ## 边界与已知限制
122
+
123
+ - **与 ZCode 是最终一致,不是实时同步。** ZCode 的 `MEMORY.md` **整个运行期只读一次**,
124
+ 刷新点只有首次上下文初始化、冷恢复、`/clear`。**压缩不是刷新点**——陈旧索引会活过压缩。
125
+ 所以 pi 写完记忆后,ZCode 那个正在跑的会话看不到,要到新会话/冷恢复才可见;事实文件正文
126
+ 不被缓存,比索引新鲜。
127
+ - **ZCode 的 `Write` 是整体覆盖。** 它拿着旧快照写 `MEMORY.md` 时,我们的外部改动会被
128
+ 整份盖掉(它不会合并)。缓解只有"每次写前读一次"和"不要在 ZCode 正主动写记忆时批量写"。
129
+ - **索引可能被模型写坏。** `memory_list` 走文件系统兜底;`/memory` 给出孤儿索引行与
130
+ 未入索引文件的体检,但**不自动修正**索引。
131
+ - **frontmatter 只在前 30 行内解析。** 闭合行更靠后的文件,读它的一方同样会当成
132
+ "无描述条目"——所以写文件时 frontmatter 必须短。
133
+ - **写入拒绝跟随符号链接。** 目标是符号链接时报错,要求传真实路径。
134
+ - **`memory_read` 截断读不会成为写水位**:接着用内置 `edit` 改同一文件仍会被
135
+ pi-read-guard 要求重读(先读后写约束的预期行为)。
136
+ - **没有删除工具、没有自动抽取。** v1 需要"忘记"时用内置 `bash rm`;提炼记忆走显式
137
+ 工具调用,不在后台跑模型。
138
+ - **索引是 `custom_message`**,不在 pi-context-guard 微压缩的可清理白名单里,不会被清理。
139
+
140
+ ## 开发
141
+
142
+ ```bash
143
+ npm install
144
+ npm run check # typecheck + test + 文档交叉引用
145
+ git config core.hooksPath .githooks # 提交身份与提交信息格式钩子
146
+ ```
147
+
148
+ 测试分两层:纯函数单测(目录算法、frontmatter、解析/截断、索引行、注入决策),以及
149
+ "进程内真实会话 + 桩 provider"的端到端测试(工具输出、索引注入、与 read-guard 的互通)。
150
+
151
+ ## License
152
+
153
+ MIT
@@ -0,0 +1,476 @@
1
+ /**
2
+ * 扩展入口:让 pi 与 ZCode 共用同一份项目记忆。
3
+ *
4
+ * 互通是这个包存在的理由,也是唯一一条外部约束:目录布局、索引格式、事实文件 frontmatter
5
+ * 必须与 ZCode 读写的那一套一致,因为那些文件由它创建、也由它召回。除此之外本包不依赖它,
6
+ * 代码里也不复述它的内部实现。
7
+ *
8
+ * 分层(越靠下越纯,纯层不 import pi,便于 S1 单测):
9
+ * - `src/paths.ts` 目录算法
10
+ * - `src/resolve.ts` 三级回退
11
+ * - `src/config.ts` 全局 + 项目配置深合并
12
+ * - `src/frontmatter.ts` 事实文件 frontmatter 解析与生成
13
+ * - `src/list.ts` / `src/read.ts` memory_list / memory_read 的落盘逻辑
14
+ * - `src/atomic.ts` / `src/index-file.ts` / `src/write.ts` memory_write
15
+ * - `src/index-content.ts` / `src/injection.ts` / `src/status.ts` 索引注入与 /memory
16
+ * - 本文件 事件与工具接线
17
+ *
18
+ * 并发:所有写入都走 pi 的 `withFileMutationQueue(root, ...)` 串行;跨进程的部分不做锁,
19
+ * 靠写前 revision 比对 + 重读重试自愈——**不**静默覆盖别人的改动。
20
+ */
21
+ import { readFile } from "node:fs/promises";
22
+ import { isAbsolute, join, resolve } from "node:path";
23
+
24
+ import type {
25
+ ExtensionAPI,
26
+ ExtensionContext,
27
+ ExtensionFactory,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
30
+ import { Type, type Static } from "typebox";
31
+
32
+ import { buildReadStatePayload, READ_STATE_EVENT } from "../src/bridge.ts";
33
+ import { loadConfig, type ZcodeMemoryConfig } from "../src/config.ts";
34
+ import { MemoryWriteError, type MemoryWriteErrorCode } from "../src/errors.ts";
35
+ import { INDEX_MESSAGE_TYPE, INJECTED_ENTRY_TYPE, decideInjection, rebuildInjectedHash } from "../src/injection.ts";
36
+ import { formatMemoryIndexForInjection, memoryIndexHash } from "../src/index-content.ts";
37
+ import { listMemoryFiles, type MemoryListEntry } from "../src/list.ts";
38
+ import { MEMORY_TYPES } from "../src/paths.ts";
39
+ import { locateMemory, truncateMemoryBody } from "../src/read.ts";
40
+ import { resolveMemoryDir, type MemoryDirResolution } from "../src/resolve.ts";
41
+ import { formatMemoryStatus } from "../src/status.ts";
42
+ import { writeMemory } from "../src/write.ts";
43
+
44
+ const INDEX_FILENAME = "MEMORY.md";
45
+
46
+ /** 工具结果详情:给脚本/后续扩展判断用,模型看不到(除 content 外)。 */
47
+ interface MemoryToolDetails {
48
+ dir?: string;
49
+ filename?: string;
50
+ absolutePath?: string;
51
+ /** 失败原因码:写入失败是 `MemoryWriteErrorCode`,读取失败是定位错误码。 */
52
+ code?: string;
53
+ }
54
+
55
+ const zcodeMemory: ExtensionFactory = (pi: ExtensionAPI): void => {
56
+ /** 当前上下文投影里仍存活的注入 hash;`session_start` 起重建,压缩/切分支后失效重算。 */
57
+ let injectedHash: string | undefined;
58
+ /** `/memory reload` 置位:下一次运行强制重新注入。 */
59
+ let forceInject = false;
60
+ let missingDirWarned = false;
61
+
62
+ const resolveFor = (ctx: ExtensionContext): { config: ZcodeMemoryConfig; resolution: MemoryDirResolution } => {
63
+ const config = loadConfig(ctx.cwd);
64
+ // 配置里的相对路径按会话 cwd 解析,避免受进程启动目录影响
65
+ const configuredDir =
66
+ config.dir === undefined ? undefined : isAbsolute(config.dir) ? config.dir : resolve(ctx.cwd, config.dir);
67
+ const resolution = resolveMemoryDir({
68
+ cwd: ctx.cwd,
69
+ ...(configuredDir === undefined ? {} : { configDir: configuredDir }),
70
+ });
71
+ return { config, resolution };
72
+ };
73
+
74
+ const rebuildInjectionState = (ctx: ExtensionContext): void => {
75
+ // 会话投影只包含压缩后的保留尾部:注入消息被摘要掉后这里就找不到它了
76
+ const projection = ctx.sessionManager.buildSessionProjection();
77
+ injectedHash = rebuildInjectedHash(projection.entries.map((entry) => entry.sourceEntry));
78
+ };
79
+
80
+ pi.on("session_start", (_event, ctx) => {
81
+ forceInject = false;
82
+ missingDirWarned = false;
83
+ rebuildInjectionState(ctx);
84
+ });
85
+
86
+ pi.on("session_compact", (_event, ctx) => rebuildInjectionState(ctx));
87
+ pi.on("session_tree", (_event, ctx) => rebuildInjectionState(ctx));
88
+ pi.on("session_shutdown", () => {
89
+ injectedHash = undefined;
90
+ forceInject = false;
91
+ });
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // 工具:memory_list / memory_read / memory_write
95
+ // ---------------------------------------------------------------------------
96
+
97
+ pi.registerTool({
98
+ name: "memory_list",
99
+ label: "Memory List",
100
+ description:
101
+ "List the fact files in the project memory directory shared with ZCode (filename / name / description / type / modified time). " +
102
+ "Memories are long-term facts, not a to-do list; use memory_read to see a body and memory_write to store a new fact. " +
103
+ "This list comes from the files on disk, so it does not depend on the MEMORY.md index being intact.",
104
+ promptSnippet: "List project memories shared with ZCode (long-term facts)",
105
+ parameters: Type.Object({}),
106
+ exposure: "direct",
107
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
108
+ const { resolution } = resolveFor(ctx);
109
+ const text = formatMemoryListResult(resolution);
110
+ return { content: [{ type: "text", text }], details: detailsOf(resolution) };
111
+ },
112
+ });
113
+
114
+ const memoryReadParams = Type.Object({
115
+ name: Type.String({
116
+ description:
117
+ "The memory's name (the name in its frontmatter) or its filename, for example user-api-preference or user-api-preference.md; filenames in subdirectories work too.",
118
+ }),
119
+ });
120
+
121
+ pi.registerTool({
122
+ name: "memory_read",
123
+ label: "Memory Read",
124
+ description:
125
+ "Read one project memory's frontmatter summary and body. The parameter is the memory's name or filename. " +
126
+ "A body longer than 200 lines or 4096 bytes is truncated at the same limit ZCode uses when it recalls a memory, " +
127
+ "and the result says where to read the full text — so what you see here is what is actually usable in a session.",
128
+ promptSnippet: "Read the body of one project memory",
129
+ parameters: memoryReadParams,
130
+ exposure: "direct",
131
+ async execute(_toolCallId, params: Static<typeof memoryReadParams>, _signal, _onUpdate, ctx) {
132
+ const { resolution } = resolveFor(ctx);
133
+ const dir = resolution.dir;
134
+ if (dir === undefined) {
135
+ return {
136
+ content: [{ type: "text", text: noMemoryDirText(resolution) }],
137
+ details: detailsOf(resolution),
138
+ isError: true,
139
+ };
140
+ }
141
+ const located = locateMemory(dir, params.name);
142
+ if (!located.ok) {
143
+ return {
144
+ content: [{ type: "text", text: `Failed to read memory: ${located.error.message}` }],
145
+ details: { dir, code: located.error.code } satisfies MemoryToolDetails,
146
+ isError: true,
147
+ };
148
+ }
149
+ const memory = located.value;
150
+ const truncated = truncateMemoryBody(memory.body);
151
+ const text = formatMemoryReadResult(memory, truncated);
152
+ // 读态互通(票据 08):在共享事件总线上登记"这个记忆文件刚被读成什么程度"。
153
+ // 没有安装 pi-read-guard 时总线上没有订阅者,emit 是 no-op——双方都不需要硬依赖。
154
+ // 截断读发布 kind: "truncated" 且不带 hash,read-guard 不会放行后续 edit。
155
+ pi.events.emit(
156
+ READ_STATE_EVENT,
157
+ buildReadStatePayload({
158
+ absolutePath: memory.absolutePath,
159
+ mtimeMs: memory.mtimeMs,
160
+ sizeBytes: memory.sizeBytes,
161
+ contentHash: memory.contentHash,
162
+ truncated: truncated.truncated,
163
+ }),
164
+ );
165
+ return {
166
+ content: [{ type: "text", text }],
167
+ details: {
168
+ dir,
169
+ filename: memory.filename,
170
+ absolutePath: memory.absolutePath,
171
+ } satisfies MemoryToolDetails,
172
+ };
173
+ },
174
+ });
175
+
176
+ const memoryWriteParams = Type.Object({
177
+ name: Type.String({
178
+ description:
179
+ "The memory's name, also used as its filename (.md optional); by convention a kebab-case slug of 3-4 words, for example user-api-preference.",
180
+ }),
181
+ description: Type.String({
182
+ description:
183
+ "One sentence describing what this memory is; it goes into the frontmatter and the MEMORY.md index line, and it is what ZCode uses to judge relevance when recalling memories.",
184
+ }),
185
+ type: Type.Union(
186
+ MEMORY_TYPES.map((value) => Type.Literal(value)),
187
+ {
188
+ description:
189
+ "user (who the user is) / feedback (working-style feedback the user gave) / project (ongoing project facts) / reference (where an external resource lives).",
190
+ },
191
+ ),
192
+ body: Type.String({
193
+ description:
194
+ "The memory body: one fact per file. For feedback / project memories the convention is **Why:** and **How to apply:**; link related memories with [[name]].",
195
+ }),
196
+ mode: Type.Union([Type.Literal("create"), Type.Literal("upsert")], {
197
+ description: "create errors when a memory with that name exists; upsert overwrites it and updates the index line.",
198
+ }),
199
+ });
200
+
201
+ pi.registerTool({
202
+ name: "memory_write",
203
+ label: "Memory Write",
204
+ description:
205
+ "Write one long-term fact into the project memory directory shared with ZCode, and maintain its MEMORY.md index line. " +
206
+ "Memories are long-term facts (user preferences, working-style feedback the user gave, project constraints, external resources), **not a to-do list**, " +
207
+ "and not temporary state that ends with the conversation; do not write down information that the repository code, git history, or this task already answers. " +
208
+ "Before writing, work out whether an existing memory covers the same thing: if so, upsert it rather than creating a duplicate entry.",
209
+ promptSnippet: "Write a long-term fact into the project memory shared with ZCode (not a to-do)",
210
+ parameters: memoryWriteParams,
211
+ exposure: "direct",
212
+ async execute(_toolCallId, params: Static<typeof memoryWriteParams>, _signal, _onUpdate, ctx) {
213
+ const { resolution } = resolveFor(ctx);
214
+ // 尚无记忆时按计算路径创建(三级回退第 3 步);配置给了目录就以配置为准
215
+ const dir = resolution.dir ?? resolution.computedDir;
216
+ try {
217
+ // 全部写入串行化:同一记忆目录下的读-改-写不会互相踩
218
+ const outcome = await withFileMutationQueue(dir, () =>
219
+ writeMemory({
220
+ root: dir,
221
+ name: params.name,
222
+ description: params.description,
223
+ type: params.type,
224
+ body: params.body,
225
+ mode: params.mode,
226
+ }),
227
+ );
228
+ const action = outcome.fileAction === "created" ? "Created" : "Overwrote";
229
+ const indexAction =
230
+ outcome.indexAction === "inserted"
231
+ ? "index line added"
232
+ : outcome.indexAction === "updated"
233
+ ? "index line updated"
234
+ : "index line unchanged";
235
+ return {
236
+ content: [
237
+ {
238
+ type: "text",
239
+ text:
240
+ `${action} memory ${outcome.filename}; MEMORY.md ${indexAction}.\n` +
241
+ `Memory directory: ${dir}\n` +
242
+ `ZCode sees this change only after it re-reads the index (new session / cold resume / /clear); that is a sync delay, not a failed write.`,
243
+ },
244
+ ],
245
+ details: {
246
+ dir,
247
+ filename: outcome.filename,
248
+ absolutePath: outcome.absolutePath,
249
+ } satisfies MemoryToolDetails,
250
+ };
251
+ } catch (error) {
252
+ if (error instanceof MemoryWriteError) {
253
+ const hint = error.hint === undefined ? "" : `\n${error.hint}`;
254
+ return {
255
+ content: [{ type: "text", text: `Failed to write memory: ${error.message}${hint}` }],
256
+ details: { dir, code: error.code } satisfies MemoryToolDetails,
257
+ isError: true,
258
+ };
259
+ }
260
+ throw error;
261
+ }
262
+ },
263
+ });
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // 索引注入:一次注入 + 标记;hash 变化 / 被压缩掉 / /memory reload 时重注
267
+ // ---------------------------------------------------------------------------
268
+
269
+ pi.on("before_agent_start", async (event, ctx) => {
270
+ // `pi.sendMessage(..., { triggerTurn: true })` 起的运行不算用户轮
271
+ if (event.messageType === "custom") return undefined;
272
+ const { config, resolution } = resolveFor(ctx);
273
+ if (!config.enabled || !config.injectIndex) return undefined;
274
+ const dir = resolution.dir;
275
+ if (dir === undefined) {
276
+ warnMissingMemoryDir(ctx, resolution);
277
+ return undefined;
278
+ }
279
+ const indexPath = join(dir, INDEX_FILENAME);
280
+ let raw: string;
281
+ try {
282
+ raw = await readFile(indexPath, "utf8");
283
+ } catch {
284
+ // 没有索引(或读不了)就不注入;清掉状态,等索引出现后重新注入
285
+ injectedHash = undefined;
286
+ return undefined;
287
+ }
288
+ const formatted = formatMemoryIndexForInjection(raw);
289
+ if (formatted.text.length === 0) return undefined;
290
+ const hash = memoryIndexHash(formatted.text);
291
+ const decision = decideInjection({
292
+ messageType: event.messageType,
293
+ enabled: config.enabled,
294
+ injectIndex: config.injectIndex,
295
+ indexPresent: true,
296
+ indexHash: hash,
297
+ injectedHash,
298
+ force: forceInject,
299
+ });
300
+ if (!decision.inject) return undefined;
301
+
302
+ injectedHash = hash;
303
+ forceInject = false;
304
+ pi.appendEntry(INJECTED_ENTRY_TYPE, { indexHash: hash });
305
+ return {
306
+ message: {
307
+ customType: INDEX_MESSAGE_TYPE,
308
+ content: buildInjectionText(dir, formatted.text, formatted.truncated),
309
+ display: false,
310
+ details: { indexHash: hash },
311
+ },
312
+ };
313
+ });
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // /memory:状态 + 索引体检;/memory reload 强制下次运行重新注入
317
+ // ---------------------------------------------------------------------------
318
+
319
+ pi.registerCommand("memory", {
320
+ description: "查看共享记忆目录状态与索引体检;/memory reload 让下一次运行重新注入索引",
321
+ handler: async (args, ctx) => {
322
+ const subcommand = args.trim().toLowerCase();
323
+ if (subcommand === "reload") {
324
+ forceInject = true;
325
+ notify(ctx, "memory:下一次运行会重新注入 MEMORY.md 索引。", "info");
326
+ return;
327
+ }
328
+ if (subcommand.length > 0 && subcommand !== "status") {
329
+ notify(ctx, "memory:用法是 /memory(状态)或 /memory reload(强制重新注入)。", "warning");
330
+ return;
331
+ }
332
+ const { resolution } = resolveFor(ctx);
333
+ const dir = resolution.dir;
334
+ let indexContent: string | undefined;
335
+ if (dir !== undefined) {
336
+ try {
337
+ indexContent = await readFile(join(dir, INDEX_FILENAME), "utf8");
338
+ } catch {
339
+ indexContent = undefined;
340
+ }
341
+ }
342
+ const files: MemoryListEntry[] = dir === undefined ? [] : listMemoryFiles(dir);
343
+ const text = formatMemoryStatus({
344
+ resolution,
345
+ indexContent,
346
+ files,
347
+ injectionLabel: injectionLabel(resolution, indexContent, injectedHash, forceInject),
348
+ });
349
+ notify(ctx, text, "info");
350
+ },
351
+ });
352
+
353
+ function warnMissingMemoryDir(ctx: ExtensionContext, resolution: MemoryDirResolution): void {
354
+ if (missingDirWarned) return;
355
+ missingDirWarned = true;
356
+ const hint = resolution.warnings.length > 0 ? ` ${resolution.warnings[resolution.warnings.length - 1]}` : "";
357
+ notify(ctx, `memory:这次没有注入记忆索引——该项目尚无 ZCode 记忆。${hint}`, "warning");
358
+ }
359
+ };
360
+
361
+ function detailsOf(resolution: MemoryDirResolution): MemoryToolDetails {
362
+ return resolution.dir === undefined ? {} : { dir: resolution.dir };
363
+ }
364
+
365
+ function noMemoryDirText(resolution: MemoryDirResolution): string {
366
+ const warnings = resolution.warnings.map((warning) => `\nNote: ${warning}`).join("");
367
+ return (
368
+ `This project has no ZCode memory yet: the computed directory does not exist (${resolution.computedDir}). ` +
369
+ `memory_write creates it; if ZCode really has written memories for this project, set dir in the config to point at that directory. ` +
370
+ `${warnings}`
371
+ );
372
+ }
373
+
374
+ function formatMemoryListResult(resolution: MemoryDirResolution): string {
375
+ const dir = resolution.dir;
376
+ if (dir === undefined) return noMemoryDirText(resolution);
377
+ const entries = listMemoryFiles(dir);
378
+ const warnings = resolution.warnings.map((warning) => `Note: ${warning}`).join("\n");
379
+ const header = `Memory directory: ${dir}\nFact files: ${entries.length}`;
380
+ const body =
381
+ entries.length === 0
382
+ ? "(no fact files)"
383
+ : entries
384
+ .map((entry) => {
385
+ const mtime = new Date(entry.mtimeMs).toISOString();
386
+ if (entry.malformed) {
387
+ return `- ${entry.filename} (frontmatter cannot be parsed: treated as an entry with no description; modified ${mtime})`;
388
+ }
389
+ const type = entry.type === undefined ? "" : ` [${entry.type}]`;
390
+ const description = entry.description === undefined ? "" : ` — ${entry.description}`;
391
+ const name = entry.name === undefined ? "" : ` ${entry.name}`;
392
+ return `- ${entry.filename}${type}${name}${description} (modified ${mtime})`;
393
+ })
394
+ .join("\n");
395
+ return warnings.length === 0 ? `${header}\n${body}` : `${warnings}\n${header}\n${body}`;
396
+ }
397
+
398
+ function formatMemoryReadResult(
399
+ memory: {
400
+ filename: string;
401
+ absolutePath: string;
402
+ sizeBytes: number;
403
+ mtimeMs: number;
404
+ frontmatter: { name?: string; description?: string; type?: string; malformed: boolean };
405
+ },
406
+ truncated: { text: string; truncated: boolean; reason?: "lines" | "bytes" | "both" },
407
+ ): string {
408
+ const frontmatter = memory.frontmatter;
409
+ const lines: string[] = [];
410
+ lines.push(`File: ${memory.filename}`);
411
+ lines.push(
412
+ frontmatter.malformed
413
+ ? "frontmatter: cannot be parsed (treated as an entry with no description)"
414
+ : `name: ${frontmatter.name ?? "(none)"}; description: ${frontmatter.description ?? "(no description)"}; type: ${frontmatter.type ?? "(not set)"}`,
415
+ );
416
+ lines.push(`Size: ${memory.sizeBytes} bytes; modified: ${new Date(memory.mtimeMs).toISOString()}`);
417
+ lines.push("");
418
+ lines.push(truncated.text.length === 0 ? "(body is empty)" : truncated.text);
419
+ if (truncated.truncated) {
420
+ const why =
421
+ truncated.reason === "lines"
422
+ ? "the first 200 lines"
423
+ : truncated.reason === "bytes"
424
+ ? "4096 bytes"
425
+ : "the first 200 lines / 4096 bytes";
426
+ lines.push("");
427
+ lines.push(
428
+ `(body truncated: at most ${why} is read here, the same limit ZCode uses when recalling a memory. Use the built-in read tool to read the full content at ${memory.absolutePath}.)`,
429
+ );
430
+ }
431
+ return lines.join("\n");
432
+ }
433
+
434
+ function buildInjectionText(dir: string, indexText: string, truncated: boolean): string {
435
+ const lines = [
436
+ "Below is the cross-session automatic memory index for this project. It records long-term facts (who the user is, working-style feedback they gave, what the project is doing, where external resources live), not a to-do list for this turn.",
437
+ "",
438
+ `Memory directory: ${dir}`,
439
+ `Index: ${join(dir, INDEX_FILENAME)} (only the index appears in context; read bodies on demand with memory_read, and do not infer a body's content from its index line)`,
440
+ ];
441
+ if (truncated) lines.push("The index injected this time is a truncated excerpt (see the WARNING at the end).");
442
+ lines.push("", indexText);
443
+ return lines.join("\n");
444
+ }
445
+
446
+ function injectionLabel(
447
+ resolution: MemoryDirResolution,
448
+ indexContent: string | undefined,
449
+ injectedHash: string | undefined,
450
+ force: boolean,
451
+ ): string {
452
+ if (resolution.dir === undefined) return "未注入(尚无记忆目录)";
453
+ if (indexContent === undefined) return "未注入(索引不存在)";
454
+ if (force) return "已排队:下一次运行重新注入";
455
+ if (injectedHash === undefined) return "未注入(下一次运行会注入)";
456
+ return `已注入(hash ${injectedHash.slice(0, 12)}…)`;
457
+ }
458
+
459
+ /**
460
+ * 按设计共同约定 7:所有 UI 交互先看 `ctx.hasUI`。
461
+ *
462
+ * 非交互模式(print / json)没有 UI;显式守一道不是为了防崩,而是让"这里依赖渲染"
463
+ * 在代码里看得见——真实运行里 `ctx.ui` 的无操作实现会吞掉内容,静默通过很难被发现。
464
+ */
465
+ function notify(
466
+ ctx: { hasUI: boolean; ui: { notify(message: string, kind?: "info" | "warning" | "error"): void } },
467
+ message: string,
468
+ kind?: "info" | "warning" | "error",
469
+ ): void {
470
+ if (!ctx.hasUI) return;
471
+ ctx.ui.notify(message, kind);
472
+ }
473
+
474
+ export default zcodeMemory;
475
+ export { zcodeMemory as zcodeMemoryFactory };
476
+ export type { ZcodeMemoryConfig };