@yandy0725/pi-memory 1.3.4 → 2.0.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.
@@ -0,0 +1,68 @@
1
+ import { cp, mkdir, readdir, rm, stat } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+
4
+ export interface SnapshotOptions {
5
+ keep: number;
6
+ now?: () => Date;
7
+ }
8
+
9
+ /** 可字典序排序的快照时间戳(ISO 8601,`:` 与 `.` 换为 `-`)。 */
10
+ export function snapshotStamp(now: () => Date): string {
11
+ return now().toISOString().replace(/[:.]/g, "-");
12
+ }
13
+
14
+ async function uniqueDir(backupRoot: string, base: string): Promise<string> {
15
+ await mkdir(backupRoot, { recursive: true });
16
+ for (let n = 1; ; n++) {
17
+ const candidate = n === 1 ? join(backupRoot, base) : join(backupRoot, `${base}-${n}`);
18
+ try {
19
+ await mkdir(candidate);
20
+ return candidate;
21
+ } catch (e) {
22
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
23
+ }
24
+ }
25
+ }
26
+
27
+ /** 将给定的 memory 目录相对路径复制到新快照目录;不存在的文件被跳过。 */
28
+ export async function createSnapshot(
29
+ backupRoot: string,
30
+ label: string,
31
+ files: string[],
32
+ memoryDir: string,
33
+ options: SnapshotOptions,
34
+ ): Promise<string> {
35
+ const dir = await uniqueDir(backupRoot, `${snapshotStamp(options.now ?? (() => new Date()))}-${label}`);
36
+ for (const file of files) {
37
+ try {
38
+ await cp(join(memoryDir, file), join(dir, basename(file)));
39
+ } catch (e) {
40
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") continue;
41
+ throw e;
42
+ }
43
+ }
44
+ await pruneSnapshots(backupRoot, options.keep);
45
+ return dir;
46
+ }
47
+
48
+ /** 保留名字序最新的 keep 个快照;`migrate-` 开头的目录永不参与裁剪。 */
49
+ export async function pruneSnapshots(backupRoot: string, keep: number): Promise<void> {
50
+ let names: string[];
51
+ try {
52
+ names = await readdir(backupRoot);
53
+ } catch {
54
+ return;
55
+ }
56
+
57
+ const dirs: string[] = [];
58
+ for (const name of names) {
59
+ if (name.startsWith("migrate-")) continue;
60
+ const info = await stat(join(backupRoot, name)).catch(() => null);
61
+ if (info?.isDirectory()) dirs.push(name);
62
+ }
63
+
64
+ dirs.sort();
65
+ for (const name of dirs.slice(0, Math.max(0, dirs.length - Math.max(0, keep)))) {
66
+ await rm(join(backupRoot, name), { recursive: true, force: true });
67
+ }
68
+ }
package/src/index-file.ts DELETED
@@ -1,91 +0,0 @@
1
- export interface IndexEntry {
2
- name: string; // 原名 title,取自 frontmatter name
3
- topic: string; // 文件名
4
- hook: string; // 一行描述
5
- raw: string; // 原始行文本
6
- }
7
-
8
- export interface IndexFile {
9
- entries: IndexEntry[];
10
- raw: string;
11
- }
12
-
13
- // Matches: - [Name](topic.md) — hook
14
- const LINE_RE = /^-\s+\[([^\]]+)\]\(([^)]+)\)\s*—\s*(.*)$/;
15
-
16
- export function parseIndex(content: string): IndexFile {
17
- const entries: IndexEntry[] = [];
18
- for (const line of content.split("\n")) {
19
- const m = line.match(LINE_RE);
20
- if (m) {
21
- entries.push({
22
- name: m[1].trim(),
23
- topic: m[2].trim(),
24
- hook: m[3].trim(),
25
- raw: line,
26
- });
27
- }
28
- }
29
- return { entries, raw: content };
30
- }
31
-
32
- export function serializeIndex(entries: IndexEntry[]): string {
33
- return entries.map((e) => `- [${e.name}](${e.topic}) — ${e.hook}`).join("\n");
34
- }
35
-
36
- export function upsertEntryByTopic(entries: IndexEntry[], entry: IndexEntry): IndexEntry[] {
37
- const idx = entries.findIndex((e) => e.topic === entry.topic);
38
- if (idx === -1) return [...entries, entry];
39
- const next = [...entries];
40
- next[idx] = entry;
41
- return next;
42
- }
43
-
44
- export function removeEntryByTopic(entries: IndexEntry[], topic: string): IndexEntry[] {
45
- const idx = entries.findIndex((e) => e.topic === topic);
46
- if (idx === -1) throw new Error(`Topic "${topic}" not found in index`);
47
- const next = [...entries];
48
- next.splice(idx, 1);
49
- return next;
50
- }
51
-
52
- export function findEntryByTopic(entries: IndexEntry[], topic: string): IndexEntry | null {
53
- return entries.find((e) => e.topic === topic) ?? null;
54
- }
55
-
56
- export function updateHook(entries: IndexEntry[], topic: string, hook: string): IndexEntry[] {
57
- const idx = entries.findIndex((e) => e.topic === topic);
58
- if (idx === -1) throw new Error(`Topic "${topic}" not found in index`);
59
- const next = [...entries];
60
- next[idx] = { ...next[idx], hook, raw: "" };
61
- return next;
62
- }
63
-
64
- export function truncateForInjection(
65
- content: string,
66
- maxLines: number,
67
- maxBytes: number,
68
- ): { ok: boolean; content: string; truncated: boolean } {
69
- const lines = content.split("\n");
70
- let out = content;
71
- let truncated = false;
72
- if (lines.length > maxLines) {
73
- out = lines.slice(0, maxLines).join("\n");
74
- truncated = true;
75
- }
76
- if (Buffer.byteLength(out, "utf8") > maxBytes) {
77
- let cut = out;
78
- while (Buffer.byteLength(cut, "utf8") > maxBytes && cut.length > 0) cut = cut.slice(0, -1);
79
- out = cut;
80
- truncated = true;
81
- }
82
- if (truncated) out += `\n[truncated: memory index exceeds injection limit]`;
83
- return { ok: !truncated, content: out, truncated };
84
- }
85
-
86
- export function checkCapacity(entries: IndexEntry[], maxLines: number, maxBytes: number): boolean {
87
- const serialized = serializeIndex(entries);
88
- if (entries.length > maxLines) return false;
89
- if (Buffer.byteLength(serialized, "utf8") > maxBytes) return false;
90
- return true;
91
- }
package/src/topic-file.ts DELETED
@@ -1,119 +0,0 @@
1
- export interface TopicMeta {
2
- name: string;
3
- description: string;
4
- type: string;
5
- updated: string;
6
- }
7
-
8
- export const ALLOWED_TYPES = ["user", "feedback", "project", "reference"] as const;
9
-
10
- export function buildFrontmatter(meta: TopicMeta): string {
11
- return [
12
- "---",
13
- `name: ${meta.name}`,
14
- `description: ${meta.description}`,
15
- `type: ${meta.type}`,
16
- `updated: ${meta.updated}`,
17
- "---",
18
- "",
19
- "",
20
- ].join("\n");
21
- }
22
-
23
- export function parseFrontmatter(raw: string): TopicMeta | null {
24
- if (!raw.startsWith("---\n")) return null;
25
- const endIdx = raw.indexOf("\n---\n", 4);
26
- if (endIdx === -1) return null;
27
- const block = raw.slice(4, endIdx);
28
- const meta: Record<string, string> = {};
29
- for (const line of block.split("\n")) {
30
- const m = line.match(/^(\w+):\s*(.*)$/);
31
- if (m) meta[m[1]] = m[2].trim();
32
- }
33
- if (!meta.name || !meta.description || !meta.type || !meta.updated) return null;
34
- if (!(ALLOWED_TYPES as readonly string[]).includes(meta.type)) return null;
35
- return meta as unknown as TopicMeta;
36
- }
37
-
38
- export function appendContent(existing: string | null, entryTitle: string, content: string): string {
39
- const section = `## ${entryTitle}\n\n${content}`;
40
- if (!existing || existing.trim() === "") return section;
41
- return `${existing.trimEnd()}\n\n${section}\n`;
42
- }
43
-
44
- export function updateFrontmatterDate(raw: string, date: string): string {
45
- return raw.replace(/^(---\n(?:.*\n)*?)updated: .+(\n---)/m, `$1updated: ${date}$2`);
46
- }
47
-
48
- export function replaceFrontmatterField(raw: string, field: string, value: string): string {
49
- const regex = new RegExp(`^(---\n(?:.*\n)*?)${field}: .+(\n)`, "m");
50
- return raw.replace(regex, (_full: string, prefix: string, nl: string) => `${prefix}${field}: ${value}${nl}`);
51
- }
52
-
53
- export function removeEntrySection(raw: string, title: string): string {
54
- const marker = `## ${title}`;
55
- // find start of this entry block
56
- const startIdx = raw.indexOf(`\n${marker}\n`) !== -1 ? raw.indexOf(`\n${marker}\n`) : raw.indexOf(marker);
57
- if (startIdx === -1) throw new Error(`Entry "${title}" not found`);
58
-
59
- // find start of next ## or EOF
60
- const afterHeader = raw.indexOf("\n", startIdx + marker.length);
61
- const nextH2 = raw.indexOf("\n## ", afterHeader + 1);
62
- const endIdx = nextH2 === -1 ? raw.length : nextH2;
63
-
64
- // remove including preceding blank lines
65
- let cutStart = startIdx;
66
- while (cutStart > 0 && raw[cutStart - 1] === "\n") cutStart--;
67
- // also strip one more \n if present (the blank line separator)
68
- if (cutStart > 0 && raw[cutStart - 1] === "\n") cutStart--;
69
-
70
- let result = raw.slice(0, cutStart) + raw.slice(endIdx);
71
- // ensure exactly one trailing newline
72
- result = result.replace(/\n{3,}$/, "\n\n").replace(/\n{2,}$/, "\n");
73
- if (!result.endsWith("\n")) result += "\n";
74
- return result;
75
- }
76
-
77
- export function hasEntries(raw: string): boolean {
78
- return /^## /m.test(raw);
79
- }
80
-
81
- export interface ParsedEntry {
82
- title: string;
83
- content: string;
84
- }
85
-
86
- export function parseEntries(raw: string): ParsedEntry[] {
87
- const entries: ParsedEntry[] = [];
88
- const lines = raw.split("\n");
89
- let currentTitle = "";
90
- let currentContent: string[] = [];
91
- let inEntry = false;
92
- let inFrontmatter = false;
93
-
94
- for (const line of lines) {
95
- if (line === "---") {
96
- inFrontmatter = !inFrontmatter;
97
- continue;
98
- }
99
- if (inFrontmatter) continue;
100
-
101
- const h2 = line.match(/^## (.+)$/);
102
- if (h2) {
103
- if (inEntry) {
104
- entries.push({ title: currentTitle, content: currentContent.join("\n").trim() });
105
- }
106
- currentTitle = h2[1];
107
- currentContent = [];
108
- inEntry = true;
109
- continue;
110
- }
111
- if (inEntry) {
112
- currentContent.push(line);
113
- }
114
- }
115
- if (inEntry) {
116
- entries.push({ title: currentTitle, content: currentContent.join("\n").trim() });
117
- }
118
- return entries;
119
- }