@momenttttt/pi-zcode-memory 0.0.0-stage → 0.1.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.
@@ -0,0 +1,480 @@
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
+ "Memory files are managed through these tools only: read a memory with memory_read, never with the built-in read tool. " +
129
+ "The only exception is a truncated body: the result then says where to read the rest.",
130
+ promptSnippet: "Read the body of one project memory",
131
+ parameters: memoryReadParams,
132
+ exposure: "direct",
133
+ async execute(_toolCallId, params: Static<typeof memoryReadParams>, _signal, _onUpdate, ctx) {
134
+ const { resolution } = resolveFor(ctx);
135
+ const dir = resolution.dir;
136
+ if (dir === undefined) {
137
+ return {
138
+ content: [{ type: "text", text: noMemoryDirText(resolution) }],
139
+ details: detailsOf(resolution),
140
+ isError: true,
141
+ };
142
+ }
143
+ const located = locateMemory(dir, params.name);
144
+ if (!located.ok) {
145
+ return {
146
+ content: [{ type: "text", text: `Failed to read memory: ${located.error.message}` }],
147
+ details: { dir, code: located.error.code } satisfies MemoryToolDetails,
148
+ isError: true,
149
+ };
150
+ }
151
+ const memory = located.value;
152
+ const truncated = truncateMemoryBody(memory.body);
153
+ const text = formatMemoryReadResult(memory, truncated);
154
+ // 读态互通(票据 08):在共享事件总线上登记"这个记忆文件刚被读成什么程度"。
155
+ // 没有安装 pi-read-guard 时总线上没有订阅者,emit 是 no-op——双方都不需要硬依赖。
156
+ // 截断读发布 kind: "truncated" 且不带 hash,read-guard 不会放行后续 edit。
157
+ pi.events.emit(
158
+ READ_STATE_EVENT,
159
+ buildReadStatePayload({
160
+ absolutePath: memory.absolutePath,
161
+ mtimeMs: memory.mtimeMs,
162
+ sizeBytes: memory.sizeBytes,
163
+ contentHash: memory.contentHash,
164
+ truncated: truncated.truncated,
165
+ }),
166
+ );
167
+ return {
168
+ content: [{ type: "text", text }],
169
+ details: {
170
+ dir,
171
+ filename: memory.filename,
172
+ absolutePath: memory.absolutePath,
173
+ } satisfies MemoryToolDetails,
174
+ };
175
+ },
176
+ });
177
+
178
+ const memoryWriteParams = Type.Object({
179
+ name: Type.String({
180
+ description:
181
+ "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.",
182
+ }),
183
+ description: Type.String({
184
+ description:
185
+ "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.",
186
+ }),
187
+ type: Type.Union(
188
+ MEMORY_TYPES.map((value) => Type.Literal(value)),
189
+ {
190
+ description:
191
+ "user (who the user is) / feedback (working-style feedback the user gave) / project (ongoing project facts) / reference (where an external resource lives).",
192
+ },
193
+ ),
194
+ body: Type.String({
195
+ description:
196
+ "The memory body: one fact per file. For feedback / project memories the convention is **Why:** and **How to apply:**; link related memories with [[name]].",
197
+ }),
198
+ mode: Type.Union([Type.Literal("create"), Type.Literal("upsert")], {
199
+ description: "create errors when a memory with that name exists; upsert overwrites it and updates the index line.",
200
+ }),
201
+ });
202
+
203
+ pi.registerTool({
204
+ name: "memory_write",
205
+ label: "Memory Write",
206
+ description:
207
+ "Write one long-term fact into the project memory directory shared with ZCode, and maintain its MEMORY.md index line. " +
208
+ "Memories are long-term facts (user preferences, working-style feedback the user gave, project constraints, external resources), **not a to-do list**, " +
209
+ "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. " +
210
+ "Before writing, work out whether an existing memory covers the same thing: if so, upsert it rather than creating a duplicate entry. " +
211
+ "Memory files are managed through these tools only: create or change a memory with memory_write, never with the built-in write or edit. " +
212
+ "Only this tool keeps the fact file and its MEMORY.md index line in sync; a fact written by hand has no index line, and later sessions cannot see it.",
213
+ promptSnippet: "Write a long-term fact into the project memory shared with ZCode (not a to-do)",
214
+ parameters: memoryWriteParams,
215
+ exposure: "direct",
216
+ async execute(_toolCallId, params: Static<typeof memoryWriteParams>, _signal, _onUpdate, ctx) {
217
+ const { resolution } = resolveFor(ctx);
218
+ // 尚无记忆时按计算路径创建(三级回退第 3 步);配置给了目录就以配置为准
219
+ const dir = resolution.dir ?? resolution.computedDir;
220
+ try {
221
+ // 全部写入串行化:同一记忆目录下的读-改-写不会互相踩
222
+ const outcome = await withFileMutationQueue(dir, () =>
223
+ writeMemory({
224
+ root: dir,
225
+ name: params.name,
226
+ description: params.description,
227
+ type: params.type,
228
+ body: params.body,
229
+ mode: params.mode,
230
+ }),
231
+ );
232
+ const action = outcome.fileAction === "created" ? "Created" : "Overwrote";
233
+ const indexAction =
234
+ outcome.indexAction === "inserted"
235
+ ? "index line added"
236
+ : outcome.indexAction === "updated"
237
+ ? "index line updated"
238
+ : "index line unchanged";
239
+ return {
240
+ content: [
241
+ {
242
+ type: "text",
243
+ text:
244
+ `${action} memory ${outcome.filename}; MEMORY.md ${indexAction}.\n` +
245
+ `Memory directory: ${dir}\n` +
246
+ `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.`,
247
+ },
248
+ ],
249
+ details: {
250
+ dir,
251
+ filename: outcome.filename,
252
+ absolutePath: outcome.absolutePath,
253
+ } satisfies MemoryToolDetails,
254
+ };
255
+ } catch (error) {
256
+ if (error instanceof MemoryWriteError) {
257
+ const hint = error.hint === undefined ? "" : `\n${error.hint}`;
258
+ return {
259
+ content: [{ type: "text", text: `Failed to write memory: ${error.message}${hint}` }],
260
+ details: { dir, code: error.code } satisfies MemoryToolDetails,
261
+ isError: true,
262
+ };
263
+ }
264
+ throw error;
265
+ }
266
+ },
267
+ });
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // 索引注入:一次注入 + 标记;hash 变化 / 被压缩掉 / /memory reload 时重注
271
+ // ---------------------------------------------------------------------------
272
+
273
+ pi.on("before_agent_start", async (event, ctx) => {
274
+ // `pi.sendMessage(..., { triggerTurn: true })` 起的运行不算用户轮
275
+ if (event.messageType === "custom") return undefined;
276
+ const { config, resolution } = resolveFor(ctx);
277
+ if (!config.enabled || !config.injectIndex) return undefined;
278
+ const dir = resolution.dir;
279
+ if (dir === undefined) {
280
+ warnMissingMemoryDir(ctx, resolution);
281
+ return undefined;
282
+ }
283
+ const indexPath = join(dir, INDEX_FILENAME);
284
+ let raw: string;
285
+ try {
286
+ raw = await readFile(indexPath, "utf8");
287
+ } catch {
288
+ // 没有索引(或读不了)就不注入;清掉状态,等索引出现后重新注入
289
+ injectedHash = undefined;
290
+ return undefined;
291
+ }
292
+ const formatted = formatMemoryIndexForInjection(raw);
293
+ if (formatted.text.length === 0) return undefined;
294
+ const hash = memoryIndexHash(formatted.text);
295
+ const decision = decideInjection({
296
+ messageType: event.messageType,
297
+ enabled: config.enabled,
298
+ injectIndex: config.injectIndex,
299
+ indexPresent: true,
300
+ indexHash: hash,
301
+ injectedHash,
302
+ force: forceInject,
303
+ });
304
+ if (!decision.inject) return undefined;
305
+
306
+ injectedHash = hash;
307
+ forceInject = false;
308
+ pi.appendEntry(INJECTED_ENTRY_TYPE, { indexHash: hash });
309
+ return {
310
+ message: {
311
+ customType: INDEX_MESSAGE_TYPE,
312
+ content: buildInjectionText(dir, formatted.text, formatted.truncated),
313
+ display: false,
314
+ details: { indexHash: hash },
315
+ },
316
+ };
317
+ });
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // /memory:状态 + 索引体检;/memory reload 强制下次运行重新注入
321
+ // ---------------------------------------------------------------------------
322
+
323
+ pi.registerCommand("memory", {
324
+ description: "查看共享记忆目录状态与索引体检;/memory reload 让下一次运行重新注入索引",
325
+ handler: async (args, ctx) => {
326
+ const subcommand = args.trim().toLowerCase();
327
+ if (subcommand === "reload") {
328
+ forceInject = true;
329
+ notify(ctx, "memory:下一次运行会重新注入 MEMORY.md 索引。", "info");
330
+ return;
331
+ }
332
+ if (subcommand.length > 0 && subcommand !== "status") {
333
+ notify(ctx, "memory:用法是 /memory(状态)或 /memory reload(强制重新注入)。", "warning");
334
+ return;
335
+ }
336
+ const { resolution } = resolveFor(ctx);
337
+ const dir = resolution.dir;
338
+ let indexContent: string | undefined;
339
+ if (dir !== undefined) {
340
+ try {
341
+ indexContent = await readFile(join(dir, INDEX_FILENAME), "utf8");
342
+ } catch {
343
+ indexContent = undefined;
344
+ }
345
+ }
346
+ const files: MemoryListEntry[] = dir === undefined ? [] : listMemoryFiles(dir);
347
+ const text = formatMemoryStatus({
348
+ resolution,
349
+ indexContent,
350
+ files,
351
+ injectionLabel: injectionLabel(resolution, indexContent, injectedHash, forceInject),
352
+ });
353
+ notify(ctx, text, "info");
354
+ },
355
+ });
356
+
357
+ function warnMissingMemoryDir(ctx: ExtensionContext, resolution: MemoryDirResolution): void {
358
+ if (missingDirWarned) return;
359
+ missingDirWarned = true;
360
+ const hint = resolution.warnings.length > 0 ? ` ${resolution.warnings[resolution.warnings.length - 1]}` : "";
361
+ notify(ctx, `memory:这次没有注入记忆索引——该项目尚无 ZCode 记忆。${hint}`, "warning");
362
+ }
363
+ };
364
+
365
+ function detailsOf(resolution: MemoryDirResolution): MemoryToolDetails {
366
+ return resolution.dir === undefined ? {} : { dir: resolution.dir };
367
+ }
368
+
369
+ function noMemoryDirText(resolution: MemoryDirResolution): string {
370
+ const warnings = resolution.warnings.map((warning) => `\nNote: ${warning}`).join("");
371
+ return (
372
+ `This project has no ZCode memory yet: the computed directory does not exist (${resolution.computedDir}). ` +
373
+ `memory_write creates it; if ZCode really has written memories for this project, set dir in the config to point at that directory. ` +
374
+ `${warnings}`
375
+ );
376
+ }
377
+
378
+ function formatMemoryListResult(resolution: MemoryDirResolution): string {
379
+ const dir = resolution.dir;
380
+ if (dir === undefined) return noMemoryDirText(resolution);
381
+ const entries = listMemoryFiles(dir);
382
+ const warnings = resolution.warnings.map((warning) => `Note: ${warning}`).join("\n");
383
+ const header = `Memory directory: ${dir}\nFact files: ${entries.length}`;
384
+ const body =
385
+ entries.length === 0
386
+ ? "(no fact files)"
387
+ : entries
388
+ .map((entry) => {
389
+ const mtime = new Date(entry.mtimeMs).toISOString();
390
+ if (entry.malformed) {
391
+ return `- ${entry.filename} (frontmatter cannot be parsed: treated as an entry with no description; modified ${mtime})`;
392
+ }
393
+ const type = entry.type === undefined ? "" : ` [${entry.type}]`;
394
+ const description = entry.description === undefined ? "" : ` — ${entry.description}`;
395
+ const name = entry.name === undefined ? "" : ` ${entry.name}`;
396
+ return `- ${entry.filename}${type}${name}${description} (modified ${mtime})`;
397
+ })
398
+ .join("\n");
399
+ return warnings.length === 0 ? `${header}\n${body}` : `${warnings}\n${header}\n${body}`;
400
+ }
401
+
402
+ function formatMemoryReadResult(
403
+ memory: {
404
+ filename: string;
405
+ absolutePath: string;
406
+ sizeBytes: number;
407
+ mtimeMs: number;
408
+ frontmatter: { name?: string; description?: string; type?: string; malformed: boolean };
409
+ },
410
+ truncated: { text: string; truncated: boolean; reason?: "lines" | "bytes" | "both" },
411
+ ): string {
412
+ const frontmatter = memory.frontmatter;
413
+ const lines: string[] = [];
414
+ lines.push(`File: ${memory.filename}`);
415
+ lines.push(
416
+ frontmatter.malformed
417
+ ? "frontmatter: cannot be parsed (treated as an entry with no description)"
418
+ : `name: ${frontmatter.name ?? "(none)"}; description: ${frontmatter.description ?? "(no description)"}; type: ${frontmatter.type ?? "(not set)"}`,
419
+ );
420
+ lines.push(`Size: ${memory.sizeBytes} bytes; modified: ${new Date(memory.mtimeMs).toISOString()}`);
421
+ lines.push("");
422
+ lines.push(truncated.text.length === 0 ? "(body is empty)" : truncated.text);
423
+ if (truncated.truncated) {
424
+ const why =
425
+ truncated.reason === "lines"
426
+ ? "the first 200 lines"
427
+ : truncated.reason === "bytes"
428
+ ? "4096 bytes"
429
+ : "the first 200 lines / 4096 bytes";
430
+ lines.push("");
431
+ lines.push(
432
+ `(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}.)`,
433
+ );
434
+ }
435
+ return lines.join("\n");
436
+ }
437
+
438
+ function buildInjectionText(dir: string, indexText: string, truncated: boolean): string {
439
+ const lines = [
440
+ "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.",
441
+ "",
442
+ `Memory directory: ${dir}`,
443
+ `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)`,
444
+ ];
445
+ if (truncated) lines.push("The index injected this time is a truncated excerpt (see the WARNING at the end).");
446
+ lines.push("", indexText);
447
+ return lines.join("\n");
448
+ }
449
+
450
+ function injectionLabel(
451
+ resolution: MemoryDirResolution,
452
+ indexContent: string | undefined,
453
+ injectedHash: string | undefined,
454
+ force: boolean,
455
+ ): string {
456
+ if (resolution.dir === undefined) return "未注入(尚无记忆目录)";
457
+ if (indexContent === undefined) return "未注入(索引不存在)";
458
+ if (force) return "已排队:下一次运行重新注入";
459
+ if (injectedHash === undefined) return "未注入(下一次运行会注入)";
460
+ return `已注入(hash ${injectedHash.slice(0, 12)}…)`;
461
+ }
462
+
463
+ /**
464
+ * 按设计共同约定 7:所有 UI 交互先看 `ctx.hasUI`。
465
+ *
466
+ * 非交互模式(print / json)没有 UI;显式守一道不是为了防崩,而是让"这里依赖渲染"
467
+ * 在代码里看得见——真实运行里 `ctx.ui` 的无操作实现会吞掉内容,静默通过很难被发现。
468
+ */
469
+ function notify(
470
+ ctx: { hasUI: boolean; ui: { notify(message: string, kind?: "info" | "warning" | "error"): void } },
471
+ message: string,
472
+ kind?: "info" | "warning" | "error",
473
+ ): void {
474
+ if (!ctx.hasUI) return;
475
+ ctx.ui.notify(message, kind);
476
+ }
477
+
478
+ export default zcodeMemory;
479
+ export { zcodeMemory as zcodeMemoryFactory };
480
+ export type { ZcodeMemoryConfig };
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@momenttttt/pi-zcode-memory",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Share ZCode's per-project memory directory from pi (memory_read / memory_write / memory_list)",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi",
10
+ "pi-extension",
11
+ "memory",
12
+ "zcode",
13
+ "project-memory"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/Momenttttt/pi-extension-suite.git",
18
+ "directory": "packages/pi-zcode-memory"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "registry": "https://registry.npmjs.org/"
23
+ },
24
+ "files": [
25
+ "extensions",
26
+ "src",
27
+ "README.md",
28
+ "README.zh-CN.md",
29
+ "LICENSE"
30
+ ],
31
+ "pi": {
32
+ "extensions": [
33
+ "./extensions/zcode-memory.ts"
34
+ ]
35
+ },
36
+ "dependencies": {
37
+ "yaml": "^2.9.1"
38
+ },
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-coding-agent": "*",
41
+ "typebox": "*"
42
+ },
43
+ "scripts": {
44
+ "test": "vitest run --root ../.."
45
+ },
46
+ "engines": {
47
+ "node": ">=22.19.0"
48
+ }
49
+ }
package/src/atomic.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 原子写:同目录临时文件(`O_EXCL`)→ 写入 → fsync → rename;不跟随符号链接。
3
+ *
4
+ * 失败就报错让模型重试,不做"open 失败就改用 `O_TRUNC` 直写目标"的降级:那一步会破坏原子性,
5
+ * 留下一个只写了一半的文件。
6
+ */
7
+ import { constants } from "node:fs";
8
+ import { lstat, open, rename, unlink } from "node:fs/promises";
9
+ import { dirname, join } from "node:path";
10
+ import { randomBytes } from "node:crypto";
11
+
12
+ export class SymlinkWriteRefusedError extends Error {
13
+ constructor(path: string) {
14
+ super(`Refusing to write through a symbolic link: ${path}. Pass the real target path instead.`);
15
+ this.name = "SymlinkWriteRefusedError";
16
+ }
17
+ }
18
+
19
+ /** 临时文件名:同目录才能保证 rename 是原子的;`.tmp.<pid>.<random>` 避免并发撞名。 */
20
+ function tempPathFor(path: string): string {
21
+ return join(dirname(path), `.${basenameOf(path)}.tmp.${process.pid}.${randomBytes(6).toString("hex")}`);
22
+ }
23
+
24
+ function basenameOf(path: string): string {
25
+ const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
26
+ return separator < 0 ? path : path.slice(separator + 1);
27
+ }
28
+
29
+ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
30
+
31
+ export async function atomicWriteFile(path: string, content: string): Promise<void> {
32
+ let existingMode: number | undefined;
33
+ try {
34
+ const target = await lstat(path);
35
+ if (target.isSymbolicLink()) throw new SymlinkWriteRefusedError(path);
36
+ existingMode = target.mode;
37
+ } catch (error) {
38
+ if (!isNoEntryError(error)) throw error;
39
+ }
40
+
41
+ const tempPath = tempPathFor(path);
42
+ const buffer = Buffer.from(content, "utf8");
43
+ try {
44
+ const handle = await open(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW);
45
+ try {
46
+ await handle.writeFile(buffer);
47
+ if (existingMode !== undefined) {
48
+ // 原子写会用临时文件的 inode 覆盖目标;先复制原权限,避免抹掉执行位
49
+ await handle.chmod(existingMode);
50
+ }
51
+ await handle.sync();
52
+ } finally {
53
+ await handle.close();
54
+ }
55
+ await rename(tempPath, path);
56
+ } catch (error) {
57
+ await unlink(tempPath).catch(() => undefined);
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ function isNoEntryError(error: unknown): boolean {
63
+ return (
64
+ typeof error === "object" &&
65
+ error !== null &&
66
+ "code" in error &&
67
+ (error as { code?: unknown }).code === "ENOENT"
68
+ );
69
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 与 pi-read-guard 的读态互通:**发布方**的本地契约。
3
+ *
4
+ * 机制选择:`pi.events` 共享事件总线。理由:
5
+ * - 两个包都从 pi 拿到同一个总线,不需要新增配置文件契约;
6
+ * - 耦合只剩一个字符串 + 一份形状;发布方 `emit`、接收方 `on`,任一方缺席都不报错;
7
+ * - 相比"由配置声明的读态提供方",少一层配置形状与解析规则,收益相同。
8
+ *
9
+ * 按 ADR-0001(四个独立包、无内部共享包)与 CODING_STANDARDS 规则 5,这里**刻意重复**
10
+ * 声明常量与类型,不 import `pi-read-guard`。对齐点见
11
+ * `packages/pi-read-guard/src/bridge.ts`(接收方)——改形状时两边要一起改。
12
+ */
13
+ import { createHash } from "node:crypto";
14
+
15
+ export const READ_STATE_EVENT = "read-guard/file-read";
16
+
17
+ /** 一次"某记忆文件刚被读过"的登记。 */
18
+ export interface ReadStateEventPayload {
19
+ /** 绝对路径:发布方与接收方可能不在同一个 cwd 下。 */
20
+ path: string;
21
+ /** 读时的文件 mtime(毫秒)。 */
22
+ mtimeMs: number;
23
+ sizeBytes: number;
24
+ /** 完整文本内容的 sha256;截断读时**不带**。 */
25
+ contentHash?: string;
26
+ kind: "full" | "ranged" | "truncated";
27
+ }
28
+
29
+ export interface ReadStatePayloadInput {
30
+ absolutePath: string;
31
+ mtimeMs: number;
32
+ sizeBytes: number;
33
+ /** 完整正文(未截断时)的 sha256;截断读或拿不到全文时不传。 */
34
+ contentHash?: string;
35
+ truncated: boolean;
36
+ }
37
+
38
+ /**
39
+ * 生成发布载荷。
40
+ *
41
+ * 截断读一律 `kind: "truncated"` 且不带 hash——read-guard 会把它当成"读得不完整",
42
+ * 不允许它成为写水位(与内置 `read` 的 `details.truncation` 同一条规则)。
43
+ */
44
+ export function buildReadStatePayload(input: ReadStatePayloadInput): ReadStateEventPayload {
45
+ return {
46
+ path: input.absolutePath,
47
+ mtimeMs: input.mtimeMs,
48
+ sizeBytes: input.sizeBytes,
49
+ kind: input.truncated ? "truncated" : "full",
50
+ ...(!input.truncated && input.contentHash !== undefined ? { contentHash: input.contentHash } : {}),
51
+ };
52
+ }
53
+
54
+ /** 与 read-guard 的 `hashContent` 同构:sha256(utf8 文本原样)。 */
55
+ export function hashMemoryContent(content: string): string {
56
+ return createHash("sha256").update(content, "utf8").digest("hex");
57
+ }