@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
|
@@ -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
|
+
}
|
package/src/injection.ts
ADDED
|
@@ -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
|
+
}
|
package/src/list.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扫记忆目录里的"事实文件"。
|
|
3
|
+
*
|
|
4
|
+
* 候选与排序都是对外承诺的一部分:递归扫 `.md`、排除索引自身;不递归进目录符号链接,
|
|
5
|
+
* 但指向文件的符号链接算候选;单个文件读不了只跳过它自己,不牵连其它条目;结果按 mtime
|
|
6
|
+
* 倒序(最近改动的排在前面)。不设条数上限——事实文件本来就不多,`memory_list` 给的是完整列表。
|
|
7
|
+
*/
|
|
8
|
+
import { closeSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
9
|
+
import { basename, join, relative, sep } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { MEMORY_INDEX_FILENAME, type MemoryType } from "./paths.ts";
|
|
12
|
+
import { parseMemoryFrontmatter } from "./frontmatter.ts";
|
|
13
|
+
|
|
14
|
+
export interface MemoryListEntry {
|
|
15
|
+
/** 相对记忆目录的路径,统一 `/` 分隔。 */
|
|
16
|
+
filename: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
type?: MemoryType;
|
|
20
|
+
mtimeMs: number;
|
|
21
|
+
/** frontmatter 坏了:记成"无描述条目",不抛错。 */
|
|
22
|
+
malformed: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 单个事实文件最多读这么多字节来解析 frontmatter;防止误把大文件整份读进来。 */
|
|
26
|
+
const FRONTMATTER_READ_BYTES = 64 * 1024;
|
|
27
|
+
|
|
28
|
+
export function listMemoryFiles(root: string): MemoryListEntry[] {
|
|
29
|
+
const entries: MemoryListEntry[] = [];
|
|
30
|
+
collect(root, root, entries);
|
|
31
|
+
return entries.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function collect(root: string, directory: string, out: MemoryListEntry[]): void {
|
|
35
|
+
let dirents;
|
|
36
|
+
try {
|
|
37
|
+
dirents = readdirSync(directory, { withFileTypes: true });
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const dirent of dirents) {
|
|
42
|
+
const path = join(directory, dirent.name);
|
|
43
|
+
if (dirent.isDirectory()) {
|
|
44
|
+
// 不递归进目录符号链接;指向文件的链接在下面单独判。
|
|
45
|
+
if (!dirent.isSymbolicLink()) collect(root, path, out);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
let isFile = dirent.isFile();
|
|
49
|
+
if (dirent.isSymbolicLink()) {
|
|
50
|
+
try {
|
|
51
|
+
isFile = statSync(path).isFile();
|
|
52
|
+
} catch {
|
|
53
|
+
// 失效链接与"读不了的事实文件"一样,跳过即可
|
|
54
|
+
isFile = false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!isFile) continue;
|
|
58
|
+
if (!isMemoryCandidate(dirent.name)) continue;
|
|
59
|
+
const entry = readEntry(root, path);
|
|
60
|
+
if (entry) out.push(entry);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isMemoryCandidate(filename: string): boolean {
|
|
65
|
+
return filename.endsWith(".md") && basename(filename) !== MEMORY_INDEX_FILENAME;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readEntry(root: string, path: string): MemoryListEntry | undefined {
|
|
69
|
+
let stat;
|
|
70
|
+
let head: string;
|
|
71
|
+
try {
|
|
72
|
+
stat = statSync(path);
|
|
73
|
+
head = readHead(path);
|
|
74
|
+
} catch {
|
|
75
|
+
// 单个文件读不了不影响其它条目
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const frontmatter = parseMemoryFrontmatter(head);
|
|
79
|
+
const filename = relative(root, path).split(sep).join("/");
|
|
80
|
+
return {
|
|
81
|
+
filename,
|
|
82
|
+
mtimeMs: Math.trunc(stat.mtimeMs),
|
|
83
|
+
malformed: frontmatter.malformed,
|
|
84
|
+
...(frontmatter.name === undefined ? {} : { name: frontmatter.name }),
|
|
85
|
+
...(frontmatter.description === undefined ? {} : { description: frontmatter.description }),
|
|
86
|
+
...(frontmatter.type === undefined ? {} : { type: frontmatter.type }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 只读文件头这么多字节:frontmatter 只需前 30 行。 */
|
|
91
|
+
function readHead(path: string): string {
|
|
92
|
+
const buffer = Buffer.allocUnsafe(FRONTMATTER_READ_BYTES);
|
|
93
|
+
const fd = openSync(path, "r");
|
|
94
|
+
try {
|
|
95
|
+
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
|
|
96
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
97
|
+
} finally {
|
|
98
|
+
closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享记忆目录的路径算法(纯函数,不 import pi)。
|
|
3
|
+
*
|
|
4
|
+
* 规则:
|
|
5
|
+
* - 存储根:`<home>/.zcode`,basename 已是 `cli` 就不再追加
|
|
6
|
+
* - 项目目录:`memories/projects/<slug>-<hash16>/memory`
|
|
7
|
+
* - hash 输入:工作区标识(远程/托管工作区才有),否则 `resolve(cwd)`;**仅 win32** 折叠大小写
|
|
8
|
+
* - slug:小写、非 `[a-z0-9._-]` 换 `-`、去首尾 `-`、截 48、空则 `project`
|
|
9
|
+
*
|
|
10
|
+
* **`platform` 是参数而不是读 `process.platform`**:路径语义(分隔符、盘符、大小写)本身就是
|
|
11
|
+
* 这条算法的一部分,必须在**任何机器上**都能被单测覆盖两种语义——CI 跑在 Linux 上,
|
|
12
|
+
* 如果绑运行时平台,win32 分支(也就是用户实际在用的那套)永远测不到。
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { posix, win32 } from "node:path";
|
|
16
|
+
|
|
17
|
+
/** 记忆目录里索引文件的名字。 */
|
|
18
|
+
export const MEMORY_INDEX_FILENAME = "MEMORY.md";
|
|
19
|
+
|
|
20
|
+
/** 事实文件 frontmatter 允许的 `type`。 */
|
|
21
|
+
export const MEMORY_TYPES = ["user", "feedback", "project", "reference"] as const;
|
|
22
|
+
export type MemoryType = (typeof MEMORY_TYPES)[number];
|
|
23
|
+
|
|
24
|
+
export interface MemoryDirInput {
|
|
25
|
+
/** 工作目录(会话的工作区路径)。 */
|
|
26
|
+
cwd: string;
|
|
27
|
+
/** CLI 存储根:`<home>/.zcode/cli`。 */
|
|
28
|
+
storageRoot: string;
|
|
29
|
+
/**
|
|
30
|
+
* 外部工作区标识(远程/托管工作区才有)。
|
|
31
|
+
* 独立 CLI 与桌面普通项目都是 undefined,此时 slug 取 basename、hash 取路径。
|
|
32
|
+
*/
|
|
33
|
+
workspaceIdentity?: string;
|
|
34
|
+
/** 用哪套路径语义。默认当前平台;只有 win32 才折叠 keySource 的大小写。 */
|
|
35
|
+
platform?: NodeJS.Platform;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProjectDirParts {
|
|
39
|
+
/** 目录名里的 slug 段(identity 存在时固定为 `project`)。 */
|
|
40
|
+
slug: string;
|
|
41
|
+
/** 16 个 hex 字符。 */
|
|
42
|
+
hash: string;
|
|
43
|
+
/** `<slug>-<hash>`。 */
|
|
44
|
+
dirName: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 按平台挑 path 实现:win32 一套,其余走 POSIX。 */
|
|
48
|
+
export function pathImpl(platform: NodeJS.Platform | undefined) {
|
|
49
|
+
return (platform ?? process.platform) === "win32" ? win32 : posix;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 存储根归一化:basename 已是 `cli` 就不再追加。 */
|
|
53
|
+
export function ensureCliStorageRoot(storageRoot: string, platform?: NodeJS.Platform): string {
|
|
54
|
+
const impl = pathImpl(platform);
|
|
55
|
+
return impl.basename(storageRoot) === "cli" ? storageRoot : impl.join(storageRoot, "cli");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 由 home 推导存储根:`<home>/.zcode` 再归一化。 */
|
|
59
|
+
export function resolveZcodeStorageRoot(homeDir: string, platform?: NodeJS.Platform): string {
|
|
60
|
+
const impl = pathImpl(platform);
|
|
61
|
+
return ensureCliStorageRoot(impl.join(homeDir, ".zcode"), platform);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** slug 清洗:小写 → 非 `[a-z0-9._-]` 换 `-` → 去首尾 `-` → 截 48 → 空则 `project`。 */
|
|
65
|
+
export function sanitizeProjectSlug(value: string): string {
|
|
66
|
+
const slug = value
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
69
|
+
.replace(/^-+|-+$/g, "")
|
|
70
|
+
.slice(0, 48);
|
|
71
|
+
return slug.length > 0 ? slug : "project";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 目录名(`<slug>-<hash16>`)与它的输入。 */
|
|
75
|
+
export function computeProjectDirParts(input: MemoryDirInput): ProjectDirParts {
|
|
76
|
+
const impl = pathImpl(input.platform);
|
|
77
|
+
const identity = input.workspaceIdentity?.trim();
|
|
78
|
+
const resolvedCwd = impl.resolve(input.cwd);
|
|
79
|
+
const keySource =
|
|
80
|
+
identity || ((input.platform ?? process.platform) === "win32" ? resolvedCwd.toLowerCase() : resolvedCwd);
|
|
81
|
+
const hash = createHash("sha256").update(keySource).digest("hex").slice(0, 16);
|
|
82
|
+
const slug = identity ? "project" : sanitizeProjectSlug(impl.basename(resolvedCwd) || "project");
|
|
83
|
+
return { slug, hash, dirName: `${slug}-${hash}` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `memories/projects/` 根:三级回退第 3 步扫描的目录。 */
|
|
87
|
+
export function projectsRootOf(storageRoot: string, platform?: NodeJS.Platform): string {
|
|
88
|
+
return pathImpl(platform).join(storageRoot, "memories", "projects");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `<storageRoot>/memories/projects/<slug>-<hash>/memory`。 */
|
|
92
|
+
export function computeMemoryDir(input: MemoryDirInput): string {
|
|
93
|
+
const parts = computeProjectDirParts(input);
|
|
94
|
+
return pathImpl(input.platform).join(projectsRootOf(input.storageRoot, input.platform), parts.dirName, "memory");
|
|
95
|
+
}
|
package/src/read.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `memory_read` 的定位与读取。
|
|
3
|
+
*
|
|
4
|
+
* - 定位:按 frontmatter `name`、或按文件名(相对记忆目录,支持子目录)精确匹配;
|
|
5
|
+
* 多处命中时报错而不是猜一个。
|
|
6
|
+
* - 正文上限:先取前 200 行,再按 4096 字节截断到完整行的边界;任一超限都算截断。
|
|
7
|
+
* 这两个数就是一条记忆在会话里实际可用的量——读到上限的内容不该被当成全文。
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
10
|
+
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { hashMemoryContent } from "./bridge.ts";
|
|
13
|
+
import { normalizeMemoryText, parseMemoryDocument, type MemoryFrontmatter } from "./frontmatter.ts";
|
|
14
|
+
import { listMemoryFiles } from "./list.ts";
|
|
15
|
+
|
|
16
|
+
export const MEMORY_BODY_LINE_LIMIT = 200;
|
|
17
|
+
export const MEMORY_BODY_BYTE_LIMIT = 4096;
|
|
18
|
+
|
|
19
|
+
export interface LocatedMemory {
|
|
20
|
+
/** 相对记忆目录的路径,统一 `/` 分隔。 */
|
|
21
|
+
filename: string;
|
|
22
|
+
absolutePath: string;
|
|
23
|
+
sizeBytes: number;
|
|
24
|
+
mtimeMs: number;
|
|
25
|
+
/** sha256(磁盘上的原始 utf8 文本),与 read-guard 的 `hashContent` 同构。 */
|
|
26
|
+
contentHash: string;
|
|
27
|
+
frontmatter: MemoryFrontmatter;
|
|
28
|
+
body: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LocateError {
|
|
32
|
+
code: "not_found" | "ambiguous" | "outside_root" | "unreadable";
|
|
33
|
+
message: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type LocateResult = { ok: true; value: LocatedMemory } | { ok: false; error: LocateError };
|
|
37
|
+
|
|
38
|
+
/** 把引用折成记忆目录内的绝对路径;越界返回 undefined。 */
|
|
39
|
+
export function resolveUnderRoot(root: string, reference: string): string | undefined {
|
|
40
|
+
const cleaned = reference.trim().replace(/\\/gu, "/").replace(/^(?:\.\/)+/u, "");
|
|
41
|
+
if (cleaned.length === 0) return undefined;
|
|
42
|
+
if (cleaned.startsWith("/") || /^[A-Za-z]:/u.test(cleaned)) return undefined;
|
|
43
|
+
const absolute = resolve(root, cleaned);
|
|
44
|
+
const rel = relative(root, absolute);
|
|
45
|
+
if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) return undefined;
|
|
46
|
+
return absolute;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isFile(path: string): boolean {
|
|
50
|
+
try {
|
|
51
|
+
return statSync(path).isFile();
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function locateMemory(root: string, reference: string): LocateResult {
|
|
58
|
+
const ref = reference.trim().replace(/\\/gu, "/").replace(/^(?:\.\/)+/u, "");
|
|
59
|
+
if (ref.length === 0) {
|
|
60
|
+
return { ok: false, error: { code: "not_found", message: "The reference is empty: pass the memory's name or filename." } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 1) 直接当相对路径试:`foo.md`、`sub/foo.md`,以及(允许的)MEMORY.md
|
|
64
|
+
const direct = resolveUnderRoot(root, ref);
|
|
65
|
+
if (direct !== undefined && isFile(direct)) {
|
|
66
|
+
const located = readLocated(root, direct);
|
|
67
|
+
if (located.ok) return located;
|
|
68
|
+
}
|
|
69
|
+
// 2) 没有 `.md` 后缀时补一次
|
|
70
|
+
if (!ref.endsWith(".md")) {
|
|
71
|
+
const withSuffix = resolveUnderRoot(root, `${ref}.md`);
|
|
72
|
+
if (withSuffix !== undefined && isFile(withSuffix)) {
|
|
73
|
+
const located = readLocated(root, withSuffix);
|
|
74
|
+
if (located.ok) return located;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 3) 扫目录按 name / 文件名匹配
|
|
79
|
+
const matches = new Map<string, string>();
|
|
80
|
+
for (const entry of listMemoryFiles(root)) {
|
|
81
|
+
const withoutExtension = entry.filename.replace(/\.md$/iu, "");
|
|
82
|
+
const filenameBase = basename(withoutExtension);
|
|
83
|
+
if (entry.name === ref || entry.filename === ref || withoutExtension === ref || filenameBase === ref) {
|
|
84
|
+
matches.set(entry.filename, entry.filename);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (matches.size === 1) {
|
|
88
|
+
const filename = [...matches.keys()][0]!;
|
|
89
|
+
const absolute = resolve(root, filename);
|
|
90
|
+
const located = readLocated(root, absolute);
|
|
91
|
+
if (located.ok) return located;
|
|
92
|
+
}
|
|
93
|
+
if (matches.size > 1) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: {
|
|
97
|
+
code: "ambiguous",
|
|
98
|
+
message: `"${reference}" matches more than one memory; pass the filename instead: ${[...matches.keys()].join(", ")}`,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: {
|
|
105
|
+
code: "not_found",
|
|
106
|
+
message: `No memory "${reference}" in the memory directory. Use memory_list first to see which memories exist.`,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readLocated(root: string, absolutePath: string): LocateResult {
|
|
112
|
+
let stat;
|
|
113
|
+
let content: string;
|
|
114
|
+
try {
|
|
115
|
+
stat = statSync(absolutePath);
|
|
116
|
+
content = readFileSync(absolutePath, "utf8");
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: {
|
|
121
|
+
code: "unreadable",
|
|
122
|
+
message: `Read failed: ${absolutePath} (${error instanceof Error ? error.message : String(error)})`,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const normalized = normalizeMemoryText(content);
|
|
127
|
+
const document = parseMemoryDocument(normalized);
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
value: {
|
|
131
|
+
filename: relative(root, absolutePath).split("\\").join("/"),
|
|
132
|
+
absolutePath,
|
|
133
|
+
sizeBytes: stat.size,
|
|
134
|
+
mtimeMs: Math.trunc(stat.mtimeMs),
|
|
135
|
+
contentHash: hashMemoryContent(content),
|
|
136
|
+
frontmatter: document.frontmatter,
|
|
137
|
+
body: document.body,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface TruncatedBody {
|
|
143
|
+
text: string;
|
|
144
|
+
truncated: boolean;
|
|
145
|
+
/** 截断原因,用于提示文案;未截断时 undefined。 */
|
|
146
|
+
reason?: "lines" | "bytes" | "both";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function truncateMemoryBody(body: string): TruncatedBody {
|
|
150
|
+
const lines = body === "" ? [""] : body.split("\n");
|
|
151
|
+
const lineTruncated = lines.length > MEMORY_BODY_LINE_LIMIT;
|
|
152
|
+
const lineLimited = lineTruncated ? lines.slice(0, MEMORY_BODY_LINE_LIMIT).join("\n") : body;
|
|
153
|
+
|
|
154
|
+
const selected: string[] = [];
|
|
155
|
+
let selectedBytes = 0;
|
|
156
|
+
let byteTruncated = false;
|
|
157
|
+
for (const line of lineLimited.split("\n")) {
|
|
158
|
+
const separatorBytes = selected.length > 0 ? 1 : 0;
|
|
159
|
+
const nextBytes = selectedBytes + separatorBytes + Buffer.byteLength(line, "utf8");
|
|
160
|
+
if (nextBytes > MEMORY_BODY_BYTE_LIMIT) {
|
|
161
|
+
byteTruncated = true;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
selected.push(line);
|
|
165
|
+
selectedBytes = nextBytes;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const reason: TruncatedBody["reason"] =
|
|
169
|
+
lineTruncated && byteTruncated ? "both" : lineTruncated ? "lines" : byteTruncated ? "bytes" : undefined;
|
|
170
|
+
return {
|
|
171
|
+
text: selected.join("\n"),
|
|
172
|
+
truncated: lineTruncated || byteTruncated,
|
|
173
|
+
...(reason === undefined ? {} : { reason }),
|
|
174
|
+
};
|
|
175
|
+
}
|