@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 +21 -0
- package/README.md +152 -2
- package/extensions/zcode-memory.ts +476 -0
- package/package.json +46 -4
- package/src/atomic.ts +69 -0
- package/src/bridge.ts +57 -0
- package/src/config.ts +88 -0
- package/src/errors.ts +21 -0
- package/src/frontmatter.ts +136 -0
- package/src/index-content.ts +102 -0
- package/src/index-file.ts +171 -0
- package/src/injection.ts +82 -0
- package/src/list.ts +100 -0
- package/src/paths.ts +95 -0
- package/src/read.ts +175 -0
- package/src/resolve.ts +130 -0
- package/src/status.ts +111 -0
- package/src/write.ts +162 -0
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@momenttttt/pi-zcode-memory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./extensions/zcode-memory.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"yaml": "^2.9.1"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"typebox": "*"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "vitest run --root ../.."
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.19.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
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
|
+
}
|
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
|
+
}
|