@yandy0725/pi-memory 1.4.0 → 2.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.
@@ -10,7 +10,7 @@ interface ModelEntry {
10
10
  /**
11
11
  * Resolve a model string to a Model instance.
12
12
  * Tries exact "provider/modelId" match (only available models), then fuzzy match.
13
- * Returns the Model on success, or undefined on failure (caller falls back to parent model).
13
+ * Returns the Model on success, or undefined(调用方报错,不回退父模型)。
14
14
  */
15
15
  export function resolveModel(input: string, registry: ModelRegistry): Model<any> | undefined {
16
16
  if (!input) return undefined;
package/src/paths.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { join, normalize, resolve, sep } from "node:path";
3
+ import { join, resolve } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
 
6
6
  const execFileP = promisify(execFile);
@@ -175,16 +175,3 @@ export async function resolveMemoryDir(config: { memoryDir: string }, cwd: strin
175
175
  const { kind, key } = await projectIdentity(cwd);
176
176
  return join(config.memoryDir, kind, projectDirName(key));
177
177
  }
178
-
179
- export function safeTopicPath(memoryDir: string, topic: string): string {
180
- const normalized = normalize(topic);
181
- if (normalized.includes("..") || normalized.startsWith(sep)) {
182
- throw new Error(`Unsafe topic path: ${topic}`);
183
- }
184
- const resolved = resolve(memoryDir, normalized);
185
- const resolvedMemoryDir = resolve(memoryDir);
186
- if (!resolved.startsWith(resolvedMemoryDir + sep) && resolved !== resolvedMemoryDir) {
187
- throw new Error(`Topic escapes memory dir: ${topic}`);
188
- }
189
- return resolved;
190
- }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * 进程内互斥(**不跨进程**)。
3
+ *
4
+ * 为什么存在:`memory add`、extract、dream 都在**同一个 pi 进程**里(dream 与 extract 是
5
+ * 进程内的 SDK 会话,不是子进程),而它们的互斥作用域长度相差三个数量级 —— 单次写入是毫秒,
6
+ * dream 整轮是分钟。让一把**跨进程**的 `.lock` 同时承担这两段时长会引出两串麻烦:
7
+ * 1. dream 整轮持 `.lock` 时,它自己的每次原语调用都会撞上自己的锁(`.lock` 不可重入);
8
+ * 2. 另一个 pi 进程(同仓库的多个 worktree 共享 memory 目录)的写入要被堵住整轮;
9
+ * 3. 「持锁数分钟」逼出了 TTL 续约心跳与 stale 接管,而后者在 POSIX 上没有
10
+ * compare-and-replace,无法做到可证明安全(见 fs-lock.ts 的说明)。
11
+ *
12
+ * 所以分层:**本模块承担「逻辑作用域」(毫秒的单次调用 或 分钟的整轮),
13
+ * `.lock` 只承担毫秒级的物理写入。** 进程内互斥是一个 Promise 队列 —— 天然可嵌套地表达
14
+ * 「谁在等谁」,零文件系统、零 staleness、零平台语义。
15
+ */
16
+
17
+ /** 等待超过 `timeoutMs` 仍未拿到锁。调用方应把它当作可向用户交代的明确失败,而不是可重试的抖动。 */
18
+ export class ProcessLockTimeoutError extends Error {
19
+ constructor(
20
+ readonly key: string,
21
+ readonly timeoutMs: number,
22
+ ) {
23
+ super(`Memory operations for ${key} are already running in this process (waited ${timeoutMs}ms)`);
24
+ this.name = "ProcessLockTimeoutError";
25
+ }
26
+ }
27
+
28
+ /** 每个 key 的状态。**永不删除** —— 删了会出现「同一 key 两套状态」的脑裂互斥(一方用旧状态、一方用新状态)。条目数 = 进程见过的 memoryDir 数,可忽略。 */
29
+ interface LockState {
30
+ /** 队尾:最后一个等待者放行后 resolve 的 promise。 */
31
+ tail: Promise<void>;
32
+ /** 当前是否有人持有。仅用于诊断/断言 —— 互斥本身依赖 `tail`,不依赖它。 */
33
+ held: boolean;
34
+ /** 正在排队(尚未拿到)的调用数。 */
35
+ waiters: number;
36
+ }
37
+
38
+ const states = new Map<string, LockState>();
39
+
40
+ function stateFor(key: string): LockState {
41
+ let state = states.get(key);
42
+ if (!state) {
43
+ state = { tail: Promise.resolve(), held: false, waiters: 0 };
44
+ states.set(key, state);
45
+ }
46
+ return state;
47
+ }
48
+
49
+ /**
50
+ * 排队取得某个 key 的进程内锁,返回释放函数。
51
+ *
52
+ * 超时时**必须把自己的 gate 放掉**:我们从未持有过锁,若不放行,排在后面的调用会被我们的 gate
53
+ * 永久挂住(队尾链是 `previous.then(() => gate)`,我们的 gate 不放,后面所有人都拿不到)。
54
+ *
55
+ * 注意:**不能用「队尾是否为空」当作「有没有被持有」** —— 等待者超时会把自己从队尾摘掉,
56
+ * 而持有者仍在工作,于是队尾变空而锁定仍被持有。所以 `held` / `waiters` 单独计数。
57
+ */
58
+ async function acquire(key: string, timeoutMs: number): Promise<() => void> {
59
+ const state = stateFor(key);
60
+ const previous = state.tail;
61
+ let release!: () => void;
62
+ const gate = new Promise<void>((resolve) => {
63
+ release = resolve;
64
+ });
65
+ state.tail = previous.then(() => gate, () => gate);
66
+ state.waiters += 1;
67
+
68
+ let timer: ReturnType<typeof setTimeout> | undefined;
69
+ const timedOut = Symbol("timeout");
70
+ const outcome = await Promise.race([
71
+ previous.then(() => "acquired" as const),
72
+ new Promise<typeof timedOut>((resolve) => {
73
+ timer = setTimeout(() => resolve(timedOut), timeoutMs);
74
+ }),
75
+ ]);
76
+ if (timer) clearTimeout(timer);
77
+ state.waiters -= 1;
78
+
79
+ if (outcome === timedOut) {
80
+ release();
81
+ throw new ProcessLockTimeoutError(key, timeoutMs);
82
+ }
83
+
84
+ state.held = true;
85
+ return () => {
86
+ state.held = false;
87
+ release();
88
+ };
89
+ }
90
+
91
+ /** 在进程内串行执行 `fn`(按 `key` 分键)。超时抛 `ProcessLockTimeoutError`。 */
92
+ export async function withProcessLock<T>(key: string, timeoutMs: number, fn: () => Promise<T>): Promise<T> {
93
+ const release = await acquire(key, timeoutMs);
94
+ try {
95
+ return await fn();
96
+ } finally {
97
+ release();
98
+ }
99
+ }
100
+
101
+ /** 只在 `key` 空闲时执行 `fn`;有人在持有或排队则立刻返回 `null`,绝不等待(extract 的「锁忙则跳过本轮」)。 */
102
+ export async function tryWithProcessLock<T>(key: string, fn: () => Promise<T>): Promise<T | null> {
103
+ try {
104
+ return await withProcessLock(key, 0, fn);
105
+ } catch (e) {
106
+ if (e instanceof ProcessLockTimeoutError) return null;
107
+ throw e;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * 该 key 上是否有人在持有或排队。
113
+ * 用途:整轮持有者(dream)启动前断言自己确实已经包住了锁 —— 「忘了包」会静默失去整轮互斥,
114
+ * 这条断言把它变成一个立刻可见的错误。
115
+ *
116
+ * 注意它只是**诊断用**的:互斥由 `tail` 保证。释放与下一个持有者接上之间存在一个微任务窗口,
117
+ * 此窗口内 `isProcessLockActive` 可能瞬时返回 false,但那时新调用者仍然会正确地排在 `tail` 之后。
118
+ */
119
+ export function isProcessLockActive(key: string): boolean {
120
+ const state = states.get(key);
121
+ return state !== undefined && (state.held || state.waiters > 0);
122
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 注入净化(spec §13)。**只在注入时**使用,绝不写回磁盘(D11)—— 磁盘上的 entry 必须
3
+ * 保持用户可读、可手工编辑的原始形态。
4
+ *
5
+ * 本模块是纯函数模块:不 import `node:fs`、不读配置、不碰 store。
6
+ */
7
+
8
+ /**
9
+ * 全部 Unicode `Cf`(format)类别字符:零宽(U+200B–U+200D、U+FEFF)、bidi 控制符
10
+ * (U+202A–U+202E、U+2066–U+2069)、软连字符、LRM/RLM、word joiner 等。
11
+ *
12
+ * 用 `\p{Cf}` 而不是逐个码点枚举:新加入的格式字符自动被覆盖。`\n` / `\t` 是 Cc、
13
+ * 空格是 Zs,都不在此列 —— 注入文本的排版必须原样保留。
14
+ */
15
+ const INVISIBLE_RE = /\p{Cf}/gu;
16
+
17
+ /** 剥离全部不可见格式字符(bidi 覆写可以让磁盘上的记忆在注入后"读出"别的意思)。 */
18
+ export function stripInvisibleChars(text: string): string {
19
+ return text.replace(INVISIBLE_RE, "");
20
+ }
21
+
22
+ /**
23
+ * 净化一段将要进入 system prompt / 注入消息的文本:剥离不可见字符 + 中和尖括号。
24
+ *
25
+ * **幂等是硬要求,所以这里刻意不转义 `&`。** 若把 `&` → `&amp;`,第一次产出的 `&lt;`
26
+ * 在第二次就会变成 `&amp;lt;`;而 `memory_index` 的值在 resume / fork / reload 时会被
27
+ * 逐轮重放(D13 / D14),每重放一次就漂移一次 —— system prompt 头部再也稳定不下来,
28
+ * prefix cache 全废。`sanitizeForInjection(sanitizeForInjection(x)) === sanitizeForInjection(x)`
29
+ * 由测试钉住。
30
+ *
31
+ * 代价:正文里字面的 `&lt;` 在注入后无法与「原本是 `<`」区分。这是可接受的取舍 ——
32
+ * 两者在模型眼里都读作 `<`,而我们要防的正是「用 `<` 伪造系统标签」。
33
+ */
34
+ export function sanitizeForInjection(text: string): string {
35
+ return stripInvisibleChars(text).replaceAll("<", "&lt;").replaceAll(">", "&gt;");
36
+ }
@@ -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
- }