@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.
package/src/write.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `memory_write` 的落盘实现。
3
+ *
4
+ * 顺序:校验 → 原子写事实文件 → 维护索引行。索引更新失败时不回滚事实文件——事实文件本身
5
+ * 是完整可读的,索引行下一次写入会补上;反过来(索引指了不存在的文件)更糟。
6
+ *
7
+ * frontmatter 只写设计 §2.2 规定的键,**不写** `originSessionId`:那是读文件的一方在缺失时
8
+ * 自己补的惰性字段,我们不伪造不属于自己的会话 id;也不加自定义键(未知键行为未核实)。
9
+ */
10
+ import { mkdir, lstat } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+
13
+ import { atomicWriteFile, SymlinkWriteRefusedError } from "./atomic.ts";
14
+ import { MemoryWriteError } from "./errors.ts";
15
+ import { renderMemoryFile } from "./frontmatter.ts";
16
+ import { updateMemoryIndex } from "./index-file.ts";
17
+ import type { MemoryType } from "./paths.ts";
18
+
19
+ export type MemoryWriteMode = "create" | "upsert";
20
+
21
+ /** 文件名只允许安全字符;首字符必须是字母或数字(挡掉 `.`/`..`/隐藏文件与盘符写法)。 */
22
+ const MEMORY_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
23
+
24
+ /** `name` → 事实文件名。`.` 与路径分隔符一律拒绝。 */
25
+ export function memoryFilenameFor(name: string): { filename: string; slug: string } {
26
+ const trimmed = name.trim();
27
+ const slug = trimmed.toLowerCase().endsWith(".md") ? trimmed.slice(0, -3) : trimmed;
28
+ if (slug.length === 0) {
29
+ throw new MemoryWriteError(
30
+ "invalid_name",
31
+ "Memory name must not be empty.",
32
+ "Use a short kebab-case name, for example user-api-preference.",
33
+ );
34
+ }
35
+ if (!MEMORY_NAME_PATTERN.test(slug)) {
36
+ throw new MemoryWriteError(
37
+ "invalid_name",
38
+ `name "${name}" is not a valid memory filename: only letters, digits, dots, underscores and hyphens are allowed; it cannot start with a dot or a hyphen, and it cannot contain path separators.`,
39
+ "Use a short kebab-case name, for example user-api-preference.",
40
+ );
41
+ }
42
+ return { filename: `${slug}.md`, slug };
43
+ }
44
+
45
+ export interface WriteMemoryInput {
46
+ /** 记忆目录。 */
47
+ root: string;
48
+ name: string;
49
+ description: string;
50
+ type: MemoryType;
51
+ body: string;
52
+ mode: MemoryWriteMode;
53
+ }
54
+
55
+ export interface WriteMemoryOutcome {
56
+ filename: string;
57
+ absolutePath: string;
58
+ indexPath: string;
59
+ fileAction: "created" | "overwritten";
60
+ indexAction: "inserted" | "updated" | "unchanged";
61
+ }
62
+
63
+ export async function writeMemory(input: WriteMemoryInput): Promise<WriteMemoryOutcome> {
64
+ const { filename, slug } = memoryFilenameFor(input.name);
65
+ const description = input.description.replace(/\s+/gu, " ").trim();
66
+ if (description.length === 0) {
67
+ throw new MemoryWriteError(
68
+ "invalid_input",
69
+ "description must not be empty: it is the one-line hook in the index line, and what ZCode uses to judge relevance when recalling a memory.",
70
+ "Write one sentence saying what this memory is.",
71
+ );
72
+ }
73
+ if (!input.body.trim()) {
74
+ throw new MemoryWriteError(
75
+ "invalid_input",
76
+ "body must not be empty: a memory file has to carry the concrete fact (the body may link other memories with [[other-memory-name]]).",
77
+ );
78
+ }
79
+
80
+ // 目录按计算结果 mkdir -p(三级回退第 3 步:尚无记忆时写入才创建)
81
+ await mkdir(input.root, { recursive: true }).catch((error: unknown) => {
82
+ throw new MemoryWriteError(
83
+ "io",
84
+ `Failed to create the memory directory: ${input.root} (${describe(error)})`,
85
+ "Check the directory permissions and retry.",
86
+ );
87
+ });
88
+
89
+ const absolutePath = join(input.root, filename);
90
+ const existing = await lstatOrUndefined(absolutePath);
91
+ if (existing?.isSymbolicLink()) {
92
+ throw new MemoryWriteError(
93
+ "symlink",
94
+ `Refusing to write through a symbolic link: ${absolutePath}.`,
95
+ "Pass the real file path instead.",
96
+ );
97
+ }
98
+ if (input.mode === "create" && existing !== undefined) {
99
+ throw new MemoryWriteError(
100
+ "exists",
101
+ `Memory ${filename} already exists; create mode does not overwrite an existing memory.`,
102
+ 'Use mode: "upsert", or choose a different name.',
103
+ );
104
+ }
105
+
106
+ const content = renderMemoryFile({ name: slug, description, type: input.type, body: input.body });
107
+ try {
108
+ await atomicWriteFile(absolutePath, content);
109
+ } catch (error) {
110
+ if (error instanceof SymlinkWriteRefusedError) {
111
+ throw new MemoryWriteError("symlink", error.message, "Pass the real file path instead.");
112
+ }
113
+ throw new MemoryWriteError(
114
+ "io",
115
+ `Failed to write the memory file: ${absolutePath} (${describe(error)})`,
116
+ "Retry later.",
117
+ );
118
+ }
119
+
120
+ let indexAction: WriteMemoryOutcome["indexAction"];
121
+ try {
122
+ const index = await updateMemoryIndex(input.root, { filename, title: slug, description });
123
+ indexAction = index.action;
124
+ } catch (error) {
125
+ if (error instanceof MemoryWriteError && error.code === "index_conflict") {
126
+ throw new MemoryWriteError(
127
+ "index_conflict",
128
+ `The memory file was written (${filename}), but MEMORY.md was changed by another process at the same time, so its index line was not updated.`,
129
+ "Re-read the memory and retry this write so the index line gets added.",
130
+ );
131
+ }
132
+ throw error;
133
+ }
134
+
135
+ return {
136
+ filename,
137
+ absolutePath,
138
+ indexPath: join(input.root, "MEMORY.md"),
139
+ fileAction: existing === undefined ? "created" : "overwritten",
140
+ indexAction,
141
+ };
142
+ }
143
+
144
+ async function lstatOrUndefined(path: string) {
145
+ try {
146
+ return await lstat(path);
147
+ } catch (error) {
148
+ if (
149
+ typeof error === "object" &&
150
+ error !== null &&
151
+ "code" in error &&
152
+ (error as { code?: unknown }).code === "ENOENT"
153
+ ) {
154
+ return undefined;
155
+ }
156
+ throw new MemoryWriteError("io", `Failed to check the target file: ${path} (${describe(error)})`);
157
+ }
158
+ }
159
+
160
+ function describe(error: unknown): string {
161
+ return error instanceof Error ? error.message : String(error);
162
+ }