@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/config.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 配置读取:全局 `<agent-dir>/extensions/zcode-memory.json` + 项目 `<cwd>/.pi/zcode-memory.json`,
3
+ * 深合并、防御式解析。形状与另外三个包一致。
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ export interface ZcodeMemoryConfig {
10
+ enabled: boolean;
11
+ /**
12
+ * 显式指定记忆目录(最高优先级)。路径算法对不上时(工作区标识、工作区路径写法差异),
13
+ * 这是唯一可靠的兜底。
14
+ */
15
+ dir: string | undefined;
16
+ /** 是否做索引注入。默认开。
17
+ * 单独留开关是为了排查"注入内容干扰上下文"一类问题,不面向常规使用。 */
18
+ injectIndex: boolean;
19
+ }
20
+
21
+ export const DEFAULT_ZCODE_MEMORY_CONFIG: ZcodeMemoryConfig = {
22
+ enabled: true,
23
+ dir: undefined,
24
+ injectIndex: true,
25
+ };
26
+
27
+ function readJsonIfExists(path: string): Record<string, unknown> {
28
+ if (!existsSync(path)) return {};
29
+ try {
30
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
31
+ return isPlainObject(parsed) ? parsed : {};
32
+ } catch {
33
+ // 配置坏了不能让插件崩,退化成默认值
34
+ return {};
35
+ }
36
+ }
37
+
38
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+
42
+ /** 深合并:只对普通对象递归,数组与标量后写者胜。 */
43
+ export function deepMerge<T extends Record<string, unknown>>(base: T, override: Record<string, unknown>): T {
44
+ const result: Record<string, unknown> = { ...base };
45
+ for (const [key, value] of Object.entries(override)) {
46
+ const current = result[key];
47
+ result[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value;
48
+ }
49
+ return result as T;
50
+ }
51
+
52
+ /** 把外部 JSON 折算成可信配置;非法值一律退回默认值。 */
53
+ export function parseZcodeMemoryConfig(raw: unknown): ZcodeMemoryConfig {
54
+ if (!isPlainObject(raw)) return { ...DEFAULT_ZCODE_MEMORY_CONFIG };
55
+ const config = { ...DEFAULT_ZCODE_MEMORY_CONFIG };
56
+ if (typeof raw.enabled === "boolean") config.enabled = raw.enabled;
57
+ if (typeof raw.injectIndex === "boolean") config.injectIndex = raw.injectIndex;
58
+ if (typeof raw.dir === "string" && raw.dir.trim().length > 0) config.dir = raw.dir.trim();
59
+ return config;
60
+ }
61
+
62
+ /**
63
+ * 定位全局配置目录。
64
+ *
65
+ * 优先读 pi 自己的环境变量 `PI_CODING_AGENT_DIR`(`pi --help` 列的就是它,pi 内部的
66
+ * `getAgentDir()` 也认这个),再退回 `~/.pi/agent`。
67
+ *
68
+ * 注意别写成 `PI_AGENT_DIR`:那不是 pi 认的名字,设了它 pi 自己的扩展/设置仍然走
69
+ * `~/.pi/agent`,于是"我们的全局配置"与"pi 的全局资源"会分叉到两个目录。
70
+ */
71
+ export function resolveAgentDir(env: NodeJS.ProcessEnv = process.env): string {
72
+ const fromEnv = env.PI_CODING_AGENT_DIR;
73
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv;
74
+ return join(homedir(), ".pi", "agent");
75
+ }
76
+
77
+ export interface LoadConfigOptions {
78
+ agentDir?: string;
79
+ env?: NodeJS.ProcessEnv;
80
+ }
81
+
82
+ /** 全局 → 项目,深合并。 */
83
+ export function loadConfig(cwd: string, options: LoadConfigOptions = {}): ZcodeMemoryConfig {
84
+ const agentDir = options.agentDir ?? resolveAgentDir(options.env ?? process.env);
85
+ const globalRaw = readJsonIfExists(join(agentDir, "extensions", "zcode-memory.json"));
86
+ const projectRaw = readJsonIfExists(join(cwd, ".pi", "zcode-memory.json"));
87
+ return parseZcodeMemoryConfig(deepMerge(globalRaw, projectRaw));
88
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** 写入路径上的可预期失败;`code` 供扩展折算成工具错误文案。 */
2
+ export type MemoryWriteErrorCode =
3
+ | "invalid_name"
4
+ | "exists"
5
+ | "symlink"
6
+ | "index_conflict"
7
+ | "io"
8
+ | "invalid_input";
9
+
10
+ export class MemoryWriteError extends Error {
11
+ readonly code: MemoryWriteErrorCode;
12
+ /** 给模型的下一步建议;拼进工具错误文案。 */
13
+ readonly hint?: string;
14
+
15
+ constructor(code: MemoryWriteErrorCode, message: string, hint?: string) {
16
+ super(message);
17
+ this.name = "MemoryWriteError";
18
+ this.code = code;
19
+ if (hint !== undefined) this.hint = hint;
20
+ }
21
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * 事实文件 frontmatter 的解析与生成。
3
+ *
4
+ * 解析规则:
5
+ * - 只在前 30 行里找 frontmatter:扫描窗口必须有界,否则一个没有 frontmatter 的文件要被
6
+ * 整份读进来才能判定(列表扫描对每个文件都做一次这件事);定成 30 是因为读这些文件的
7
+ * 一侧也只看这么多——闭合行更靠后的文件在两边都算"无描述条目"
8
+ * - 首行必须是 `---`,闭合行必须是单独的 `---`
9
+ * - YAML 解析失败或结果不是映射 → 视为"无描述条目",不抛错
10
+ * - `type` 取 `metadata.type`,退回顶层 `type`;只认四个枚举值
11
+ *
12
+ * 生成规则(设计 §2.2):只写 `name` / `description` / `metadata.node_type` / `metadata.type`,
13
+ * **不写** `originSessionId`:那是读文件的一方在缺失时自己补的惰性字段,我们不伪造不属于
14
+ * 自己的会话 id;也不加自定义键(未知键行为未核实)。
15
+ */
16
+ import { parse as parseYaml } from "yaml";
17
+
18
+ import { MEMORY_TYPES, type MemoryType } from "./paths.ts";
19
+
20
+ /** 扫描窗口:frontmatter 必须在前 30 行内闭合。 */
21
+ export const FRONTMATTER_SCAN_LINES = 30;
22
+
23
+ export interface MemoryFrontmatter {
24
+ name?: string;
25
+ description?: string;
26
+ type?: MemoryType;
27
+ /** 缺 frontmatter、闭合行找不到、YAML 坏、或不是映射时为 true。 */
28
+ malformed: boolean;
29
+ }
30
+
31
+ export interface MemoryDocument {
32
+ frontmatter: MemoryFrontmatter;
33
+ /** frontmatter 之后的正文;没有合法 frontmatter 时是整份文档。 */
34
+ body: string;
35
+ }
36
+
37
+ /** 统一 BOM 与行尾:解析与读取共用这一条归一化,后面按行处理都只面对 `\n`。 */
38
+ export function normalizeMemoryText(content: string): string {
39
+ return content.replace(/^\uFEFF/u, "").replace(/\r\n/gu, "\n");
40
+ }
41
+
42
+ /** 找 frontmatter 的闭合行下标(在前 `FRONTMATTER_SCAN_LINES` 行内),找不到返回 -1。 */
43
+ function findFrontmatterEnd(lines: readonly string[]): number {
44
+ const limit = Math.min(lines.length, FRONTMATTER_SCAN_LINES);
45
+ for (let index = 1; index < limit; index += 1) {
46
+ if (lines[index] === "---") return index;
47
+ }
48
+ return -1;
49
+ }
50
+
51
+ export function parseMemoryDocument(content: string): MemoryDocument {
52
+ const normalized = normalizeMemoryText(content);
53
+ const lines = normalized.split("\n");
54
+ if (lines[0] !== "---") {
55
+ return { frontmatter: { malformed: true }, body: normalized };
56
+ }
57
+ const end = findFrontmatterEnd(lines);
58
+ if (end < 0) {
59
+ return { frontmatter: { malformed: true }, body: normalized };
60
+ }
61
+ const body = lines.slice(end + 1).join("\n").replace(/^\n+/u, "");
62
+ let parsed: unknown;
63
+ try {
64
+ parsed = parseYaml(lines.slice(1, end).join("\n"));
65
+ } catch {
66
+ return { frontmatter: { malformed: true }, body };
67
+ }
68
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
69
+ return { frontmatter: { malformed: true }, body };
70
+ }
71
+ const record = parsed as Record<string, unknown>;
72
+ const metadata =
73
+ typeof record.metadata === "object" && record.metadata !== null && !Array.isArray(record.metadata)
74
+ ? (record.metadata as Record<string, unknown>)
75
+ : undefined;
76
+ const typeCandidate = metadata?.type ?? record.type;
77
+ const frontmatter: MemoryFrontmatter = { malformed: false };
78
+ if (typeof record.name === "string" && record.name.trim().length > 0) frontmatter.name = record.name.trim();
79
+ if (typeof record.description === "string" && record.description.length > 0) {
80
+ frontmatter.description = record.description;
81
+ }
82
+ if (typeof typeCandidate === "string" && (MEMORY_TYPES as readonly string[]).includes(typeCandidate)) {
83
+ frontmatter.type = typeCandidate as MemoryType;
84
+ }
85
+ return { frontmatter, body };
86
+ }
87
+
88
+ export function parseMemoryFrontmatter(content: string): MemoryFrontmatter {
89
+ return parseMemoryDocument(content).frontmatter;
90
+ }
91
+
92
+ export interface RenderMemoryFileInput {
93
+ name: string;
94
+ description: string;
95
+ type: MemoryType;
96
+ body: string;
97
+ }
98
+
99
+ /**
100
+ * 生成事实文件全文。
101
+ *
102
+ * YAML 标量按需加双引号(YAML 兼容 JSON 引号):description 里常见的 `:`、`#`、引号都不会
103
+ * 弄坏解析。name 已由写入层校验成安全字符。
104
+ */
105
+ export function renderMemoryFile(input: RenderMemoryFileInput): string {
106
+ const body = input.body.replace(/\r\n/gu, "\n").replace(/\n*$/u, "");
107
+ const frontmatter = [
108
+ "---",
109
+ `name: ${yamlScalar(input.name)}`,
110
+ `description: ${yamlScalar(input.description)}`,
111
+ "metadata:",
112
+ " node_type: memory",
113
+ ` type: ${input.type}`,
114
+ "---",
115
+ ].join("\n");
116
+ return body.length > 0 ? `${frontmatter}\n${body}\n` : `${frontmatter}\n`;
117
+ }
118
+
119
+ const SPECIAL_SCALAR = /^(?:true|false|null|yes|no|on|off|~)$/iu;
120
+ const PLAIN_SCALAR = /^[\p{L}\p{N}][\p{L}\p{N} .,;!?()[\]{}'"\\/&%*+\-=<>@^`|~$_-]*$/u;
121
+
122
+ /** 需要时才加引号:保持文件可读,同时保证任何合法输入都能被 YAML 解析回原值。 */
123
+ function yamlScalar(value: string): string {
124
+ const singleLine = value.replace(/\s+/gu, " ").trim();
125
+ if (
126
+ singleLine.length > 0 &&
127
+ PLAIN_SCALAR.test(singleLine) &&
128
+ !SPECIAL_SCALAR.test(singleLine) &&
129
+ !/^\d/u.test(singleLine) &&
130
+ !/:\s/u.test(singleLine) &&
131
+ !/\s#/u.test(singleLine)
132
+ ) {
133
+ return singleLine;
134
+ }
135
+ return JSON.stringify(singleLine);
136
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * 注入用的索引格式化。
3
+ *
4
+ * - 剥掉开头 frontmatter、剥掉顶层 HTML 注释、trim
5
+ * - 截断上限 200 行 / 25 000 字符,超限追加一条 WARNING
6
+ *
7
+ * 不引入 markdown 解析器:逐行扫描就够——代码围栏外、行首以 `<!--` 开头的注释块整块剥掉,
8
+ * 列表项与段落里的注释保持原样(真会出现在索引顶部的注释只有这一种用法)。
9
+ */
10
+ import { createHash } from "node:crypto";
11
+
12
+ export const INDEX_LINE_LIMIT = 200;
13
+ export const INDEX_CHARACTER_LIMIT = 25_000;
14
+
15
+ const LEADING_FRONTMATTER_PATTERN = /^---\s*\n[\s\S]*?---\s*\n?/u;
16
+
17
+ export interface FormattedMemoryIndex {
18
+ /** 注入用的正文;索引为空时为 ""。 */
19
+ text: string;
20
+ truncated: boolean;
21
+ lineCount: number;
22
+ characterCount: number;
23
+ }
24
+
25
+ export function formatMemoryIndexForInjection(raw: string): FormattedMemoryIndex {
26
+ const normalized = raw.replace(/^\uFEFF/u, "").replace(/\r\n/gu, "\n");
27
+ const withoutFrontmatter = normalized.replace(LEADING_FRONTMATTER_PATTERN, "");
28
+ const decorated = stripTopLevelHtmlComments(withoutFrontmatter);
29
+ return truncateMemoryIndex(decorated);
30
+ }
31
+
32
+ /** 只删代码围栏之外、行首就是 `<!--` 的顶层注释块(列表项/段落里的注释保留)。 */
33
+ export function stripTopLevelHtmlComments(content: string): string {
34
+ if (!content.includes("<!--")) return content;
35
+ const lines = content.split("\n");
36
+ const out: string[] = [];
37
+ let fence: string | undefined;
38
+ for (let index = 0; index < lines.length; index += 1) {
39
+ const line = lines[index]!;
40
+ const trimmedStart = line.trimStart();
41
+ const fenceMatch = /^(`{3,}|~{3,})/u.exec(trimmedStart);
42
+ if (fenceMatch) {
43
+ if (fence === undefined) fence = fenceMatch[1]![0];
44
+ else if (trimmedStart.startsWith(fence.repeat(3))) fence = undefined;
45
+ out.push(line);
46
+ continue;
47
+ }
48
+ if (fence === undefined && trimmedStart.startsWith("<!--")) {
49
+ let end = index;
50
+ let closed = trimmedStart.includes("-->");
51
+ while (!closed && end + 1 < lines.length) {
52
+ end += 1;
53
+ if (lines[end]!.includes("-->")) closed = true;
54
+ }
55
+ if (closed) {
56
+ const closeIndex = lines[end]!.indexOf("-->");
57
+ const rest = lines[end]!.slice(closeIndex + 3);
58
+ index = end;
59
+ if (rest.trim().length > 0) out.push(rest);
60
+ continue;
61
+ }
62
+ }
63
+ out.push(line);
64
+ }
65
+ return out.join("\n");
66
+ }
67
+
68
+ export function truncateMemoryIndex(content: string): FormattedMemoryIndex {
69
+ const trimmed = content.trim();
70
+ if (trimmed.length === 0) {
71
+ return { text: "", truncated: false, lineCount: 0, characterCount: 0 };
72
+ }
73
+ const lines = trimmed.split("\n");
74
+ const lineCount = lines.length;
75
+ const characterCount = trimmed.length;
76
+ const lineTruncated = lineCount > INDEX_LINE_LIMIT;
77
+ const characterTruncated = characterCount > INDEX_CHARACTER_LIMIT;
78
+ if (!lineTruncated && !characterTruncated) {
79
+ return { text: trimmed, truncated: false, lineCount, characterCount };
80
+ }
81
+
82
+ let truncated = lineTruncated ? lines.slice(0, INDEX_LINE_LIMIT).join("\n") : trimmed;
83
+ if (truncated.length > INDEX_CHARACTER_LIMIT) {
84
+ const lastNewline = truncated.lastIndexOf("\n", INDEX_CHARACTER_LIMIT);
85
+ truncated = truncated.slice(0, lastNewline > 0 ? lastNewline : INDEX_CHARACTER_LIMIT);
86
+ }
87
+ const warning =
88
+ `> WARNING: the MEMORY.md index is too long (${lineCount} lines / ${characterCount} characters; ` +
89
+ `the injection limit is ${INDEX_LINE_LIMIT} lines / ${INDEX_CHARACTER_LIMIT} characters), so only the truncated part above was injected. ` +
90
+ `Shorten the index lines and move details into the fact files.`;
91
+ return {
92
+ text: `${truncated}\n\n${warning}`,
93
+ truncated: true,
94
+ lineCount,
95
+ characterCount,
96
+ };
97
+ }
98
+
99
+ /** 注入状态用的内容 hash(对注入正文取,保证"内容没变就不重注")。 */
100
+ export function memoryIndexHash(text: string): string {
101
+ return createHash("sha256").update(text).digest("hex");
102
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * `MEMORY.md` 索引行的维护。
3
+ *
4
+ * 索引由模型维护,我们只在 `memory_write` 时插入/更新对应的一行:
5
+ * `- [标题](file.md) — 一句话钩子`
6
+ *
7
+ * 写前比对 revision(`mtime:<截断毫秒>:size:<字节>`);不一致就重读重试一次,再失败报错让
8
+ * 模型重试——**不**静默覆盖别人的改动。这里不是跨进程锁,只是把"读-改-写"的窗口缩到最小;
9
+ * 跨进程的并发靠 revision 前进被发现,下一次写读到新内容后自己继续。
10
+ */
11
+ import { readFile, stat } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+
14
+ import { atomicWriteFile } from "./atomic.ts";
15
+ import { MemoryWriteError } from "./errors.ts";
16
+
17
+ export const INDEX_FILENAME = "MEMORY.md";
18
+
19
+ export interface IndexRevision {
20
+ /** `mtime:<截断毫秒>:size:<字节>`。 */
21
+ id: string;
22
+ mtimeMs: number;
23
+ sizeBytes: number;
24
+ }
25
+
26
+ export function revisionIdFromStat(stat: { mtimeMs: number; size: number }): string {
27
+ return `mtime:${Math.trunc(stat.mtimeMs)}:size:${stat.size}`;
28
+ }
29
+
30
+ export async function revisionOf(path: string): Promise<IndexRevision | undefined> {
31
+ try {
32
+ const info = await stat(path);
33
+ return { id: revisionIdFromStat(info), mtimeMs: info.mtimeMs, sizeBytes: info.size };
34
+ } catch (error) {
35
+ if (isNoEntryError(error)) return undefined;
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ /** 两次 revision 是否一致;"之前不存在、现在也不存在"算一致。 */
41
+ export function sameRevision(left: IndexRevision | undefined, right: IndexRevision | undefined): boolean {
42
+ if (left === undefined && right === undefined) return true;
43
+ if (left === undefined || right === undefined) return false;
44
+ return left.id === right.id;
45
+ }
46
+
47
+ export interface IndexLineInput {
48
+ /** 相对记忆目录的文件名(统一 `/`)。 */
49
+ filename: string;
50
+ title: string;
51
+ description: string;
52
+ }
53
+
54
+ export interface UpsertLineResult {
55
+ content: string;
56
+ action: "inserted" | "updated" | "unchanged";
57
+ }
58
+
59
+ function escapeRegExp(value: string): string {
60
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
61
+ }
62
+
63
+ function indexLineOf(input: IndexLineInput): string {
64
+ const hook = input.description.replace(/\s+/gu, " ").trim();
65
+ const link = `- [${input.title}](${input.filename})`;
66
+ return hook.length > 0 ? `${link} — ${hook}` : link;
67
+ }
68
+
69
+ /**
70
+ * 插入或更新一行;已存在等价行时返回 `unchanged`(不写盘)。
71
+ * 若同一文件已有重复行,保留第一行位置、删掉其余,顺带修掉历史重复。
72
+ */
73
+ export function upsertMemoryIndexLine(content: string, input: IndexLineInput): UpsertLineResult {
74
+ const line = indexLineOf(input);
75
+ const linkPattern = new RegExp(`\\]\\(\\s*${escapeRegExp(input.filename)}\\s*\\)`, "u");
76
+ const lines = content.length === 0 ? [] : content.split("\n");
77
+ let replaced = false;
78
+ let removedDuplicate = false;
79
+ const next: string[] = [];
80
+ for (const existing of lines) {
81
+ if (linkPattern.test(existing)) {
82
+ if (!replaced) {
83
+ next.push(line);
84
+ replaced = true;
85
+ } else {
86
+ removedDuplicate = true;
87
+ }
88
+ continue;
89
+ }
90
+ next.push(existing);
91
+ }
92
+ if (replaced) {
93
+ const updated = next.join("\n");
94
+ if (updated === content && !removedDuplicate) return { content, action: "unchanged" };
95
+ return { content: ensureTrailingNewline(updated), action: "updated" };
96
+ }
97
+ const base = content.length === 0 ? "" : ensureTrailingNewline(content);
98
+ return { content: `${base}${line}\n`, action: "inserted" };
99
+ }
100
+
101
+ function ensureTrailingNewline(content: string): string {
102
+ return content.endsWith("\n") ? content : `${content}\n`;
103
+ }
104
+
105
+ async function readIndexOrEmpty(path: string): Promise<string> {
106
+ try {
107
+ return await readFile(path, "utf8");
108
+ } catch (error) {
109
+ if (isNoEntryError(error)) return "";
110
+ throw new MemoryWriteError("io", `Failed to read the index: ${path} (${describe(error)})`, "Retry later.");
111
+ }
112
+ }
113
+
114
+ export interface UpdateIndexOutcome {
115
+ indexPath: string;
116
+ action: "inserted" | "updated" | "unchanged";
117
+ }
118
+
119
+ export interface UpdateIndexOptions {
120
+ /** 测试注入:替换 revision 读取,用来确定性地演练"连续两次都读到改动"的失败路径。 */
121
+ revisionReader?: (path: string) => Promise<IndexRevision | undefined>;
122
+ }
123
+
124
+ /** 在 `<root>/MEMORY.md` 里插入/更新一行;revision 不一致时重读重试一次。 */
125
+ export async function updateMemoryIndex(
126
+ root: string,
127
+ input: IndexLineInput,
128
+ options: UpdateIndexOptions = {},
129
+ ): Promise<UpdateIndexOutcome> {
130
+ const indexPath = join(root, INDEX_FILENAME);
131
+ const readRevision = options.revisionReader ?? revisionOf;
132
+ for (let attempt = 0; attempt < 2; attempt += 1) {
133
+ const before = await readRevision(indexPath);
134
+ const existing = await readIndexOrEmpty(indexPath);
135
+ const result = upsertMemoryIndexLine(existing, input);
136
+ if (result.action === "unchanged") {
137
+ return { indexPath, action: "unchanged" };
138
+ }
139
+ const now = await readRevision(indexPath);
140
+ if (!sameRevision(before, now)) {
141
+ // 读之后有人改过:这次结果作废,重读一次再试
142
+ continue;
143
+ }
144
+ await atomicWriteFile(indexPath, result.content).catch((error: unknown) => {
145
+ throw new MemoryWriteError(
146
+ "io",
147
+ `Failed to write the index: ${indexPath} (${describe(error)})`,
148
+ "Retry later.",
149
+ );
150
+ });
151
+ return { indexPath, action: result.action };
152
+ }
153
+ throw new MemoryWriteError(
154
+ "index_conflict",
155
+ `MEMORY.md was changed by another process after you read it (${indexPath}). The index was not written this time, so someone else's changes are not overwritten.`,
156
+ "Re-read the memory and retry this write.",
157
+ );
158
+ }
159
+
160
+ function isNoEntryError(error: unknown): boolean {
161
+ return (
162
+ typeof error === "object" &&
163
+ error !== null &&
164
+ "code" in error &&
165
+ (error as { code?: unknown }).code === "ENOENT"
166
+ );
167
+ }
168
+
169
+ function describe(error: unknown): string {
170
+ return error instanceof Error ? error.message : String(error);
171
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 索引注入的纯决策部分:什么时候注入、会话里怎么重建"已注入"标记。
3
+ *
4
+ * 不 import pi;会话条目只按结构化字段读,便于 S1 单测。
5
+ */
6
+
7
+ /** 会话标记条目的 customType(`pi.appendEntry` 写入,不进模型上下文)。 */
8
+ export const INJECTED_ENTRY_TYPE = "zcode-memory/injected";
9
+
10
+ /** 注入消息的 customType(`display: false` 的 custom 消息)。 */
11
+ export const INDEX_MESSAGE_TYPE = "zcode-memory/index";
12
+
13
+ /** 重建状态时只关心这些字段。 */
14
+ export interface InjectionEntryLike {
15
+ type?: string;
16
+ customType?: string;
17
+ data?: unknown;
18
+ details?: unknown;
19
+ }
20
+
21
+ export interface InjectionDecisionInput {
22
+ messageType: "user" | "custom";
23
+ enabled: boolean;
24
+ injectIndex: boolean;
25
+ indexPresent: boolean;
26
+ /** 当前索引格式化后的 hash。 */
27
+ indexHash: string | undefined;
28
+ /** 当前会话里仍存活的注入 hash。 */
29
+ injectedHash: string | undefined;
30
+ /** `/memory reload` 置位。 */
31
+ force: boolean;
32
+ }
33
+
34
+ export type InjectionSkipReason =
35
+ | "disabled"
36
+ | "custom-run"
37
+ | "no-index"
38
+ | "already-injected";
39
+
40
+ export type InjectionDecision =
41
+ | { inject: true; reason: "first" | "changed" | "reload" | "lost" }
42
+ | { inject: false; reason: InjectionSkipReason };
43
+
44
+ export function decideInjection(input: InjectionDecisionInput): InjectionDecision {
45
+ if (!input.enabled || !input.injectIndex) return { inject: false, reason: "disabled" };
46
+ // `pi.sendMessage(..., { triggerTurn: true })` 起的运行不注入:那不是用户轮
47
+ if (input.messageType === "custom") return { inject: false, reason: "custom-run" };
48
+ if (!input.indexPresent || input.indexHash === undefined) return { inject: false, reason: "no-index" };
49
+ if (input.force) return { inject: true, reason: "reload" };
50
+ if (input.injectedHash === input.indexHash) return { inject: false, reason: "already-injected" };
51
+ if (input.injectedHash === undefined) return { inject: true, reason: "first" };
52
+ return { inject: true, reason: "changed" };
53
+ }
54
+
55
+ function hashOfMarkerData(data: unknown): string | undefined {
56
+ if (typeof data !== "object" || data === null) return undefined;
57
+ const value = (data as { indexHash?: unknown }).indexHash;
58
+ return typeof value === "string" && value.length > 0 ? value : undefined;
59
+ }
60
+
61
+ /**
62
+ * 从**当前上下文投影**的条目里重建"仍存活的注入 hash"。
63
+ *
64
+ * 传入的必须是 `buildSessionProjection()` 的条目(只包含压缩后的保留尾部):注入消息被摘要掉
65
+ * 之后投影里就没有它了,调用方据此重新注入。标记条目与注入消息成对出现,缺一即视为丢失。
66
+ */
67
+ export function rebuildInjectedHash(entries: readonly InjectionEntryLike[]): string | undefined {
68
+ const messageHashes = new Set<string>();
69
+ for (const entry of entries) {
70
+ if (entry.type !== "custom_message" || entry.customType !== INDEX_MESSAGE_TYPE) continue;
71
+ if (entry.details === undefined) continue;
72
+ const fromDetails = hashOfMarkerData(entry.details);
73
+ if (fromDetails !== undefined) messageHashes.add(fromDetails);
74
+ }
75
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
76
+ const entry = entries[index]!;
77
+ if (entry.type !== "custom" || entry.customType !== INJECTED_ENTRY_TYPE) continue;
78
+ const hash = hashOfMarkerData(entry.data);
79
+ if (hash !== undefined && messageHashes.has(hash)) return hash;
80
+ }
81
+ return undefined;
82
+ }