@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/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
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * 记忆目录定位的三级回退(设计文档 §5.1):
3
+ *
4
+ * 1. 配置里的 `dir`(最高优先级,任何异常场景的兜底)
5
+ * 2. 按路径算法算出的目录**存在** → 用它
6
+ * 3. 计算路径不存在时,`memories/projects/` 下若恰好一个 `<slug>-*` 目录 → 用它并警告
7
+ * 都没有 → 视为"该项目尚无记忆",不创建目录
8
+ * 4. 第 3 步失败但存在 `project-*` 目录 → 提示目录可能是按 workspace identity 建的
9
+ * (slug 固定为 `project`,hash 输入不是工作区路径)
10
+ *
11
+ * 只做字符串路径判定,不做 realpath / `~` 展开:hash 的输入就是 `resolve(cwd)` 这个字符串,
12
+ * realpath 会把符号链接、短路径折成另一种写法,算出来的目录反而对不上。
13
+ */
14
+ import { existsSync, readdirSync, statSync } from "node:fs";
15
+
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import {
20
+ computeMemoryDir,
21
+ computeProjectDirParts,
22
+ projectsRootOf,
23
+ resolveZcodeStorageRoot,
24
+ type MemoryDirInput,
25
+ } from "./paths.ts";
26
+
27
+ export type MemoryDirSource = "config" | "computed" | "fallback" | "none";
28
+
29
+ export interface MemoryDirResolution {
30
+ /** 最终使用的记忆目录;`none` 时为 undefined。 */
31
+ dir: string | undefined;
32
+ source: MemoryDirSource;
33
+ /** 按算法算出的目录(`memory_write` 在 `none` 时按它创建)。 */
34
+ computedDir: string;
35
+ /** `<storageRoot>/memories/projects`,回退扫描与提示用。 */
36
+ projectsRoot: string;
37
+ /** 计算路径与账户目录不一致时为什么。 */
38
+ warnings: string[];
39
+ }
40
+
41
+ export interface ResolveMemoryDirOptions {
42
+ cwd: string;
43
+ /** 显式配置的目录。 */
44
+ configDir?: string;
45
+ /** 覆盖 home(测试用)。默认 `homedir()`。 */
46
+ homeDir?: string;
47
+ /** 覆盖存储根(测试用);给了就不再由 homeDir 推导。 */
48
+ storageRoot?: string;
49
+ workspaceIdentity?: string;
50
+ platform?: NodeJS.Platform;
51
+ /** 注入文件系统判定(测试用)。默认真实 fs。 */
52
+ pathExists?: (path: string) => boolean;
53
+ listDirNames?: (path: string) => string[];
54
+ }
55
+
56
+ export function resolveMemoryDir(options: ResolveMemoryDirOptions): MemoryDirResolution {
57
+ const storageRoot =
58
+ options.storageRoot ?? resolveZcodeStorageRoot(options.homeDir ?? homedir(), options.platform);
59
+ const dirInput: MemoryDirInput = {
60
+ cwd: options.cwd,
61
+ storageRoot,
62
+ ...(options.workspaceIdentity === undefined ? {} : { workspaceIdentity: options.workspaceIdentity }),
63
+ ...(options.platform === undefined ? {} : { platform: options.platform }),
64
+ };
65
+ const parts = computeProjectDirParts(dirInput);
66
+ const projectsRoot = projectsRootOf(storageRoot, options.platform);
67
+ const computedDir = computeMemoryDir(dirInput);
68
+ const exists = options.pathExists ?? isDirectory;
69
+ const listNames = options.listDirNames ?? listDirectoryNames;
70
+
71
+ if (options.configDir !== undefined) {
72
+ // 配置是显式覆盖:不要求目录已存在(memory_write 会创建它)。
73
+ return { dir: options.configDir, source: "config", computedDir, projectsRoot, warnings: [] };
74
+ }
75
+
76
+ if (exists(computedDir)) {
77
+ return { dir: computedDir, source: "computed", computedDir, projectsRoot, warnings: [] };
78
+ }
79
+
80
+ const candidates = listNames(projectsRoot).filter((name) => name.startsWith(`${parts.slug}-`));
81
+ if (candidates.length === 1) {
82
+ const dir = join(projectsRoot, candidates[0]!, "memory");
83
+ return {
84
+ dir,
85
+ source: "fallback",
86
+ computedDir,
87
+ projectsRoot,
88
+ warnings: [
89
+ `The computed memory directory does not exist: ${computedDir}; using the only candidate with the same slug instead: ${dir}. ` +
90
+ `Different hashes on the two sides usually mean the workspace path is written differently from the one the memory directory was computed from; set dir explicitly in the config.`,
91
+ ],
92
+ };
93
+ }
94
+
95
+ const warnings: string[] = [];
96
+ if (candidates.length > 1) {
97
+ warnings.push(
98
+ `The computed memory directory does not exist, and ${projectsRoot} holds ${candidates.length} directories starting with ${parts.slug}-, so there is no way to tell which one to use.`,
99
+ );
100
+ } else {
101
+ warnings.push(`This project has no ZCode memory yet (the computed directory does not exist: ${computedDir}).`);
102
+ }
103
+
104
+ if (listNames(projectsRoot).some((name) => name.startsWith("project-"))) {
105
+ warnings.push(
106
+ "Found memory directories with the project- prefix: a workspace identity may be in use (slug fixed to project, hash input not the workspace path). " +
107
+ "Set dir explicitly in this package's config.",
108
+ );
109
+ }
110
+
111
+ return { dir: undefined, source: "none", computedDir, projectsRoot, warnings };
112
+ }
113
+
114
+ function isDirectory(path: string): boolean {
115
+ try {
116
+ return statSync(path).isDirectory();
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ function listDirectoryNames(path: string): string[] {
123
+ try {
124
+ return readdirSync(path, { withFileTypes: true })
125
+ .filter((entry) => entry.isDirectory())
126
+ .map((entry) => entry.name);
127
+ } catch {
128
+ return [];
129
+ }
130
+ }
package/src/status.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `/memory` 的状态与索引体检。
3
+ *
4
+ * 索引体检只做两件确定的事:索引行指向的文件不存在(孤儿行)、磁盘上的事实文件没出现在
5
+ * 索引里(未入索引)。索引可能被模型写坏(重复、失效链接),`memory_list` 走文件系统兜底。
6
+ */
7
+ import { INDEX_CHARACTER_LIMIT, INDEX_LINE_LIMIT } from "./index-content.ts";
8
+ import { INDEX_FILENAME } from "./index-file.ts";
9
+ import type { MemoryListEntry } from "./list.ts";
10
+ import type { MemoryDirResolution } from "./resolve.ts";
11
+
12
+ export interface IndexHealth {
13
+ indexLineCount: number;
14
+ /** 索引里的链接目标(相对记忆目录,`/` 分隔)。 */
15
+ linkTargets: string[];
16
+ /** 指向不存在文件的索引行。 */
17
+ orphanIndexLines: string[];
18
+ /** 磁盘上有、但索引里没有链接的事实文件。 */
19
+ unindexedFiles: string[];
20
+ }
21
+
22
+ const LINK_TARGET_PATTERN = /\]\(\s*([^)\s]+)\s*\)/gu;
23
+
24
+ /** 抽出索引里的相对 `.md` 链接目标。 */
25
+ export function extractIndexTargets(indexContent: string): string[] {
26
+ const targets: string[] = [];
27
+ for (const match of indexContent.matchAll(LINK_TARGET_PATTERN)) {
28
+ const target = match[1]!.replace(/\\/gu, "/").replace(/^(?:\.\/)+/u, "");
29
+ if (target.startsWith("http://") || target.startsWith("https://")) continue;
30
+ if (!target.endsWith(".md")) continue;
31
+ if (target.split("/").pop() === INDEX_FILENAME) continue;
32
+ targets.push(target);
33
+ }
34
+ return targets;
35
+ }
36
+
37
+ /** 文件内容的"实际行数":末尾换行不算一行。 */
38
+ function countLines(content: string): number {
39
+ if (content.length === 0) return 0;
40
+ const lines = content.replace(/\r\n/gu, "\n").split("\n");
41
+ if (lines[lines.length - 1] === "") lines.pop();
42
+ return lines.length;
43
+ }
44
+
45
+ export function inspectMemoryIndex(input: {
46
+ indexContent: string | undefined;
47
+ files: readonly MemoryListEntry[];
48
+ }): IndexHealth {
49
+ const indexContent = input.indexContent ?? "";
50
+ const linkTargets = extractIndexTargets(indexContent);
51
+ const fileNames = new Set(input.files.map((entry) => entry.filename));
52
+ const targetNames = new Set(linkTargets);
53
+ return {
54
+ indexLineCount: countLines(indexContent),
55
+ linkTargets,
56
+ orphanIndexLines: [...new Set(linkTargets)].filter((target) => !fileNames.has(target)),
57
+ unindexedFiles: input.files.map((entry) => entry.filename).filter((filename) => !targetNames.has(filename)),
58
+ };
59
+ }
60
+
61
+ export interface MemoryStatusInput {
62
+ resolution: MemoryDirResolution;
63
+ /** `MEMORY.md` 的内容;不存在/读不了时 undefined。 */
64
+ indexContent: string | undefined;
65
+ files: readonly MemoryListEntry[];
66
+ /** 由扩展算出的注入状态描述。 */
67
+ injectionLabel: string;
68
+ }
69
+
70
+ function sourceLabel(source: MemoryDirResolution["source"]): string {
71
+ switch (source) {
72
+ case "config":
73
+ return "配置指定";
74
+ case "computed":
75
+ return "按算法计算(路径存在)";
76
+ case "fallback":
77
+ return "同 slug 唯一候选回退";
78
+ case "none":
79
+ return "尚无记忆";
80
+ }
81
+ }
82
+
83
+ function preview(values: readonly string[], limit = 10): string {
84
+ if (values.length === 0) return "无";
85
+ const shown = values.slice(0, limit).join("、");
86
+ return values.length > limit ? `${shown} 等 ${values.length} 项` : shown;
87
+ }
88
+
89
+ export function formatMemoryStatus(input: MemoryStatusInput): string {
90
+ const { resolution, files } = input;
91
+ const health = inspectMemoryIndex({ indexContent: input.indexContent, files });
92
+ const lines: string[] = [];
93
+ lines.push(`记忆目录:${resolution.dir ?? "未解析到"}`);
94
+ lines.push(`来源:${sourceLabel(resolution.source)}`);
95
+ if (resolution.dir === undefined || resolution.source !== "computed") {
96
+ lines.push(`计算路径:${resolution.computedDir}`);
97
+ }
98
+ lines.push(
99
+ input.indexContent === undefined
100
+ ? `索引 ${INDEX_FILENAME}:不存在(注入会跳过;memory_write 会创建它)`
101
+ : `索引 ${INDEX_FILENAME}:${health.indexLineCount} 行(注入上限 ${INDEX_LINE_LIMIT} 行 / ${INDEX_CHARACTER_LIMIT} 字符)`,
102
+ );
103
+ lines.push(`事实文件:${files.length} 个`);
104
+ lines.push(
105
+ `索引体检:孤儿索引行 ${health.orphanIndexLines.length} 条(${preview(health.orphanIndexLines)});` +
106
+ `未入索引的文件 ${health.unindexedFiles.length} 个(${preview(health.unindexedFiles)})`,
107
+ );
108
+ lines.push(`索引注入:${input.injectionLabel}`);
109
+ for (const warning of resolution.warnings) lines.push(`注意:${warning}`);
110
+ return lines.join("\n");
111
+ }