pi-claude-store 0.1.2 → 0.1.3
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.
|
@@ -60,7 +60,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
60
60
|
label: "Read memory",
|
|
61
61
|
description:
|
|
62
62
|
"Read one memory from the store shared with Claude Code. Takes the memory name — " +
|
|
63
|
-
"the filename without the .md extension
|
|
63
|
+
"the filename without the .md extension. memory_list shows the file stem in " +
|
|
64
|
+
"parentheses when it differs from the frontmatter name.",
|
|
64
65
|
promptSnippet: "Read a stored memory by name",
|
|
65
66
|
promptGuidelines: [
|
|
66
67
|
"Call memory_read when an entry in the memory index looks relevant to the current task.",
|
|
@@ -73,7 +74,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
73
74
|
try {
|
|
74
75
|
const memory = readMemory(dir, params.name);
|
|
75
76
|
if (!memory) {
|
|
76
|
-
const known = listMemories(dir).map((m) =>
|
|
77
|
+
const known = listMemories(dir).map((m) =>
|
|
78
|
+
m.name && m.name !== m.file ? `${m.file ?? m.name} (name: ${m.name})` : (m.file ?? m.name),
|
|
79
|
+
);
|
|
77
80
|
return {
|
|
78
81
|
content: [
|
|
79
82
|
{
|
|
@@ -307,7 +310,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
307
310
|
{
|
|
308
311
|
type: "text" as const,
|
|
309
312
|
text: memories.length
|
|
310
|
-
? memories
|
|
313
|
+
? memories
|
|
314
|
+
.map((m) =>
|
|
315
|
+
m.name && m.name !== m.file
|
|
316
|
+
? `- ${m.file ?? m.name} (name: ${m.name}) — ${m.description}`
|
|
317
|
+
: `- ${m.file ?? m.name} — ${m.description}`,
|
|
318
|
+
)
|
|
319
|
+
.join("\n")
|
|
311
320
|
: "No memories stored for this project.",
|
|
312
321
|
},
|
|
313
322
|
],
|
|
@@ -16,6 +16,19 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Pure functions plus thin fs wrappers — no pi imports, so it is testable
|
|
18
18
|
* with plain `node --test`.
|
|
19
|
+
*
|
|
20
|
+
* 2026-10-10 caveat fixes (each pinned by a "(caveat)" test):
|
|
21
|
+
* - Memories are addressable by file stem OR frontmatter `name:` — the real
|
|
22
|
+
* store has files where the two differ (underscore stems vs hyphen names,
|
|
23
|
+
* `feedback_`-prefixed stems, names with spaces), and read/delete by the
|
|
24
|
+
* advertised name used to fail while the error's "Available" list showed
|
|
25
|
+
* that very name.
|
|
26
|
+
* - Index pointer lines may carry a marker emoji before the bracket
|
|
27
|
+
* (`- 🔴 [T](a.md) — hook`); parse/render/upsert/remove keep the marker, so
|
|
28
|
+
* writes no longer leave stale duplicate lines and deletes no longer orphan
|
|
29
|
+
* them.
|
|
30
|
+
* - Updates preserve unknown frontmatter fields (originSessionId, …) and bump
|
|
31
|
+
* `metadata.modified` instead of flattening the block to a fixed shape.
|
|
19
32
|
*/
|
|
20
33
|
|
|
21
34
|
import fs from "node:fs";
|
|
@@ -28,6 +41,8 @@ export interface IndexEntry {
|
|
|
28
41
|
title: string;
|
|
29
42
|
file: string;
|
|
30
43
|
hook: string;
|
|
44
|
+
/** Marker before the bracket (`🔴` in `- 🔴 [T](a.md) — h`); absent on plain lines. */
|
|
45
|
+
marker?: string;
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
export interface Memory {
|
|
@@ -35,6 +50,8 @@ export interface Memory {
|
|
|
35
50
|
description: string;
|
|
36
51
|
type: string;
|
|
37
52
|
body: string;
|
|
53
|
+
/** File stem (filename without .md) — the name memory_read resolves first. */
|
|
54
|
+
file?: string;
|
|
38
55
|
}
|
|
39
56
|
|
|
40
57
|
export interface WriteMemoryInput {
|
|
@@ -66,17 +83,29 @@ export function memoryDirFor(cwd: string): string {
|
|
|
66
83
|
return path.join(os.homedir(), ".claude", "projects", sanitizePath(root), "memory");
|
|
67
84
|
}
|
|
68
85
|
|
|
69
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Parse `- [Title](file.md) — hook` pointer lines, ignoring anything else.
|
|
88
|
+
* A short marker of non-word symbols may sit between the dash and the
|
|
89
|
+
* bracket (`- 🔴 [T](a.md) — hook`); it is captured and preserved. Prose
|
|
90
|
+
* bullets whose prefix contains word characters (`- See the [x](y.md)`) are
|
|
91
|
+
* still ignored.
|
|
92
|
+
*/
|
|
93
|
+
const INDEX_LINE_RE =
|
|
94
|
+
/^\s*-\s*([^\w\s()[\]]{1,4})?\s*\[([^\]]*)\]\(([^)]+)\)\s*(?:—\s*(.*))?$/;
|
|
95
|
+
|
|
70
96
|
export function parseIndex(md: string): IndexEntry[] {
|
|
71
97
|
const entries: IndexEntry[] = [];
|
|
72
98
|
for (const line of md.split("\n")) {
|
|
73
|
-
const match =
|
|
99
|
+
const match = INDEX_LINE_RE.exec(line);
|
|
74
100
|
if (!match) continue;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
101
|
+
const marker = match[1]?.trim();
|
|
102
|
+
const entry: IndexEntry = {
|
|
103
|
+
title: match[2].trim(),
|
|
104
|
+
file: match[3].trim(),
|
|
105
|
+
hook: (match[4] ?? "").trim(),
|
|
106
|
+
};
|
|
107
|
+
if (marker) entry.marker = marker;
|
|
108
|
+
entries.push(entry);
|
|
80
109
|
}
|
|
81
110
|
return entries;
|
|
82
111
|
}
|
|
@@ -95,35 +124,132 @@ function sanitizeInline(value: string): string {
|
|
|
95
124
|
function renderIndexLine(entry: IndexEntry): string {
|
|
96
125
|
// Square brackets are stripped from the title: a `]` would end the link
|
|
97
126
|
// text early and let title content capture the file field on re-parse,
|
|
98
|
-
// forging an index entry that points at an attacker-chosen name.
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
const
|
|
127
|
+
// forging an index entry that points at an attacker-chosen name. The
|
|
128
|
+
// marker is stripped of brackets and parens for the same reason; hook
|
|
129
|
+
// content after the em dash cannot restructure the parse.
|
|
130
|
+
const marker = sanitizeInline(entry.marker ?? "").replace(/[[\]()]/g, "");
|
|
131
|
+
const title = sanitizeInline(entry.title).replace(/[[\]]/g, "");
|
|
102
132
|
const hook = sanitizeInline(entry.hook);
|
|
103
133
|
const file = sanitizeInline(entry.file);
|
|
104
|
-
|
|
134
|
+
const lead = marker ? `- ${marker} ` : "- ";
|
|
135
|
+
return hook ? `${lead}[${title}](${file}) — ${hook}` : `${lead}[${title}](${file})`;
|
|
105
136
|
}
|
|
106
137
|
|
|
107
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* Replace the pointer line for `entry.file` if present, otherwise append it.
|
|
140
|
+
* The first matching line is rewritten (keeping its existing marker when the
|
|
141
|
+
* entry carries none); further duplicates for the same file are dropped —
|
|
142
|
+
* one pointer line per memory, per the store's own hygiene convention.
|
|
143
|
+
*/
|
|
108
144
|
export function upsertIndexLine(md: string, entry: IndexEntry): string {
|
|
109
|
-
const rendered = renderIndexLine(entry);
|
|
110
145
|
const lines = md.split("\n");
|
|
111
146
|
while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
|
|
112
147
|
|
|
113
148
|
let replaced = false;
|
|
114
|
-
const next =
|
|
149
|
+
const next: string[] = [];
|
|
150
|
+
for (const line of lines) {
|
|
115
151
|
const parsed = parseIndex(line)[0];
|
|
116
152
|
if (parsed && parsed.file === entry.file) {
|
|
117
|
-
replaced
|
|
118
|
-
|
|
153
|
+
if (!replaced) {
|
|
154
|
+
next.push(renderIndexLine(entry.marker ? entry : { ...entry, marker: parsed.marker }));
|
|
155
|
+
replaced = true;
|
|
156
|
+
}
|
|
157
|
+
continue; // duplicate pointer line for the same file — collapse it
|
|
119
158
|
}
|
|
120
|
-
|
|
121
|
-
}
|
|
159
|
+
next.push(line);
|
|
160
|
+
}
|
|
122
161
|
|
|
123
|
-
if (!replaced) next.push(
|
|
162
|
+
if (!replaced) next.push(renderIndexLine(entry));
|
|
124
163
|
return `${next.join("\n")}\n`;
|
|
125
164
|
}
|
|
126
165
|
|
|
166
|
+
interface MemoryFile {
|
|
167
|
+
/** Filename (basename, with .md). */
|
|
168
|
+
fileName: string;
|
|
169
|
+
/** Filename without .md — the canonical address for reads. */
|
|
170
|
+
stem: string;
|
|
171
|
+
filePath: string;
|
|
172
|
+
memory: Memory;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** All memory files in the directory (sorted, no symlinks, no MEMORY.md). */
|
|
176
|
+
function listMemoryFiles(dir: string): MemoryFile[] {
|
|
177
|
+
if (!fs.existsSync(dir)) return [];
|
|
178
|
+
return fs
|
|
179
|
+
.readdirSync(dir)
|
|
180
|
+
.filter((file) => file.endsWith(".md") && file !== INDEX_FILE)
|
|
181
|
+
.filter((file) => {
|
|
182
|
+
try {
|
|
183
|
+
return !fs.lstatSync(path.join(dir, file)).isSymbolicLink();
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
.sort()
|
|
189
|
+
.map((file) => {
|
|
190
|
+
const filePath = path.join(dir, file);
|
|
191
|
+
const stem = path.basename(file, ".md");
|
|
192
|
+
const parsed = parseMemory(fs.readFileSync(filePath, "utf8"));
|
|
193
|
+
return {
|
|
194
|
+
fileName: file,
|
|
195
|
+
stem,
|
|
196
|
+
filePath,
|
|
197
|
+
memory: { ...parsed, name: parsed.name || stem, file: stem },
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Split a memory file into its raw frontmatter lines (null when absent or
|
|
204
|
+
* the closing delimiter sits beyond the 30-line window) and its body.
|
|
205
|
+
*/
|
|
206
|
+
function splitFrontmatter(text: string): { frontmatter: string[] | null; body: string } {
|
|
207
|
+
const lines = text.split(/\r?\n/);
|
|
208
|
+
if (lines[0] !== "---") return { frontmatter: null, body: text.trim() };
|
|
209
|
+
const limit = Math.min(lines.length, FRONTMATTER_MAX_LINES);
|
|
210
|
+
for (let i = 1; i < limit; i++) {
|
|
211
|
+
if (lines[i] === "---") {
|
|
212
|
+
return {
|
|
213
|
+
frontmatter: lines.slice(1, i),
|
|
214
|
+
body: lines
|
|
215
|
+
.slice(i + 1)
|
|
216
|
+
.join("\n")
|
|
217
|
+
.replace(/^\s*\n/, "")
|
|
218
|
+
.trimEnd(),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { frontmatter: null, body: text.trim() };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function setTopLevelKey(fm: string[], key: string, value: string): void {
|
|
226
|
+
const re = new RegExp(`^${key}:`);
|
|
227
|
+
const idx = fm.findIndex((line) => re.test(line));
|
|
228
|
+
if (idx >= 0) fm[idx] = `${key}: ${value}`;
|
|
229
|
+
else fm.push(`${key}: ${value}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Set `key` inside the indented block introduced by `block:`; insert the key when absent. */
|
|
233
|
+
function setNestedKey(fm: string[], block: string, key: string, value: string): void {
|
|
234
|
+
const blockRe = new RegExp(`^${block}:\\s*$`);
|
|
235
|
+
const blockIdx = fm.findIndex((line) => blockRe.test(line));
|
|
236
|
+
if (blockIdx === -1) {
|
|
237
|
+
fm.push(`${block}:`);
|
|
238
|
+
fm.push(` ${key}: ${value}`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
let end = blockIdx + 1;
|
|
242
|
+
while (end < fm.length && /^\s+/.test(fm[end] ?? "")) end++;
|
|
243
|
+
const keyRe = new RegExp(`^\\s+${key}:`);
|
|
244
|
+
for (let i = blockIdx + 1; i < end; i++) {
|
|
245
|
+
if (keyRe.test(fm[i] ?? "")) {
|
|
246
|
+
fm[i] = ` ${key}: ${value}`;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
fm.splice(end, 0, ` ${key}: ${value}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
127
253
|
/**
|
|
128
254
|
* Split a memory file into its frontmatter fields and body.
|
|
129
255
|
*
|
|
@@ -132,38 +258,29 @@ export function upsertIndexLine(md: string, entry: IndexEntry): string {
|
|
|
132
258
|
* Claude Code's rule. A body that happens to contain `---` further down is
|
|
133
259
|
* left alone. The `type` field is read wherever it appears at the start of a
|
|
134
260
|
* frontmatter line, so both the current `metadata.type` nesting and the older
|
|
135
|
-
* top-level `type:` layout parse.
|
|
261
|
+
* top-level `type:` layout parse. Field values wrapped in one pair of matching
|
|
262
|
+
* quotes are unquoted — the store carries `description: "…"` files, and the
|
|
263
|
+
* raw value used to come back with its quotes baked in.
|
|
136
264
|
*/
|
|
137
265
|
export function parseMemory(text: string): Memory {
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
140
|
-
return { name: "", description: "", type: "", body: text.trim() };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
let close = -1;
|
|
144
|
-
const limit = Math.min(lines.length, FRONTMATTER_MAX_LINES);
|
|
145
|
-
for (let i = 1; i < limit; i++) {
|
|
146
|
-
if (lines[i] === "---") {
|
|
147
|
-
close = i;
|
|
148
|
-
break;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
if (close === -1) {
|
|
152
|
-
return { name: "", description: "", type: "", body: text.trim() };
|
|
153
|
-
}
|
|
266
|
+
const { frontmatter, body } = splitFrontmatter(text);
|
|
267
|
+
if (!frontmatter) return { name: "", description: "", type: "", body: text.trim() };
|
|
154
268
|
|
|
155
|
-
const
|
|
269
|
+
const fm = frontmatter.join("\n");
|
|
156
270
|
const field = (key: string): string => {
|
|
157
|
-
const found = new RegExp(`^\\s*${key}:\\s*(.*)$`, "m").exec(
|
|
158
|
-
|
|
271
|
+
const found = new RegExp(`^\\s*${key}:\\s*(.*)$`, "m").exec(fm);
|
|
272
|
+
if (!found) return "";
|
|
273
|
+
let value = found[1].trim();
|
|
274
|
+
if (
|
|
275
|
+
value.length >= 2 &&
|
|
276
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
277
|
+
(value.startsWith("'") && value.endsWith("'")))
|
|
278
|
+
) {
|
|
279
|
+
value = value.slice(1, -1);
|
|
280
|
+
}
|
|
281
|
+
return value.trim();
|
|
159
282
|
};
|
|
160
283
|
|
|
161
|
-
const body = lines
|
|
162
|
-
.slice(close + 1)
|
|
163
|
-
.join("\n")
|
|
164
|
-
.replace(/^\s*\n/, "")
|
|
165
|
-
.trimEnd();
|
|
166
|
-
|
|
167
284
|
return {
|
|
168
285
|
name: field("name"),
|
|
169
286
|
description: field("description"),
|
|
@@ -173,23 +290,30 @@ export function parseMemory(text: string): Memory {
|
|
|
173
290
|
}
|
|
174
291
|
|
|
175
292
|
/** Render a memory in the frontmatter shape Claude Code writes and reads. */
|
|
176
|
-
export function serializeMemory(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
293
|
+
export function serializeMemory(
|
|
294
|
+
input: {
|
|
295
|
+
name: string;
|
|
296
|
+
description: string;
|
|
297
|
+
type?: string;
|
|
298
|
+
body?: string;
|
|
299
|
+
},
|
|
300
|
+
previousRaw?: string,
|
|
301
|
+
): string {
|
|
302
|
+
// Start from the previous frontmatter when updating, so fields this
|
|
303
|
+
// extension does not model (originSessionId, custom metadata) survive.
|
|
304
|
+
const prev = previousRaw ? splitFrontmatter(previousRaw) : null;
|
|
305
|
+
const fm = prev?.frontmatter ? [...prev.frontmatter] : [];
|
|
306
|
+
const type = input.type || DEFAULT_TYPE;
|
|
307
|
+
|
|
308
|
+
setTopLevelKey(fm, "name", input.name);
|
|
309
|
+
setTopLevelKey(fm, "description", input.description);
|
|
310
|
+
// Older layout kept `type` top-level; keep such a line in step when present.
|
|
311
|
+
if (fm.some((line) => /^type:/.test(line))) setTopLevelKey(fm, "type", type);
|
|
312
|
+
setNestedKey(fm, "metadata", "node_type", "memory");
|
|
313
|
+
setNestedKey(fm, "metadata", "type", type);
|
|
314
|
+
setNestedKey(fm, "metadata", "modified", new Date().toISOString());
|
|
315
|
+
|
|
316
|
+
return ["---", ...fm, "---", "", `${(input.body ?? "").trim()}\n`].join("\n");
|
|
193
317
|
}
|
|
194
318
|
|
|
195
319
|
/** v1 is flat-only: one directory, single-segment names. */
|
|
@@ -201,6 +325,28 @@ function assertSafeName(name: string): void {
|
|
|
201
325
|
}
|
|
202
326
|
}
|
|
203
327
|
|
|
328
|
+
function isSafeName(name: string): boolean {
|
|
329
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && !name.includes("..");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Names that must keep throwing on sight — traversal, separators, absolute
|
|
334
|
+
* paths, hidden files, the reserved MEMORY index name. Everything else that
|
|
335
|
+
* fails the safe-name charset (spaces, punctuation, unicode) is merely an
|
|
336
|
+
* alias candidate: it can only match a frontmatter `name:`, never a path.
|
|
337
|
+
*/
|
|
338
|
+
function isDangerousName(name: string): boolean {
|
|
339
|
+
return (
|
|
340
|
+
name === "" ||
|
|
341
|
+
name.includes("\0") ||
|
|
342
|
+
name.includes("..") ||
|
|
343
|
+
/[\\/]/.test(name) ||
|
|
344
|
+
/^[A-Za-z]:/.test(name) ||
|
|
345
|
+
name.startsWith(".") ||
|
|
346
|
+
name.toUpperCase() === "MEMORY"
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
204
350
|
/**
|
|
205
351
|
* Validate a memory name and resolve its file path inside `dir`, refusing
|
|
206
352
|
* anything that escapes the directory — including through a symbolic link
|
|
@@ -211,24 +357,27 @@ function memoryFile(dir: string, name: string): string {
|
|
|
211
357
|
return resolveMemoryFilePath(dir, name).filePath;
|
|
212
358
|
}
|
|
213
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Resolve an address to an existing memory file: by file stem first (the
|
|
362
|
+
* canonical address), then by frontmatter `name:` match. Security-rejected
|
|
363
|
+
* names throw exactly as the direct path always did; a merely non-charset
|
|
364
|
+
* name (spaces, hyphens-vs-underscores skew) falls through to the alias scan.
|
|
365
|
+
*/
|
|
366
|
+
function findMemoryFile(dir: string, name: string): string | null {
|
|
367
|
+
if (isDangerousName(name)) memoryFile(dir, name); // throws
|
|
368
|
+
if (isSafeName(name)) {
|
|
369
|
+
const { filePath } = resolveMemoryFilePath(dir, name);
|
|
370
|
+
if (fs.existsSync(filePath)) return filePath;
|
|
371
|
+
}
|
|
372
|
+
for (const entry of listMemoryFiles(dir)) {
|
|
373
|
+
if (entry.memory.name === name) return entry.filePath;
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
214
378
|
/** Every memory in the directory, sorted by name. Missing directory yields []. */
|
|
215
379
|
export function listMemories(dir: string): Memory[] {
|
|
216
|
-
|
|
217
|
-
return fs
|
|
218
|
-
.readdirSync(dir)
|
|
219
|
-
.filter((file) => file.endsWith(".md") && file !== INDEX_FILE)
|
|
220
|
-
.filter((file) => {
|
|
221
|
-
try {
|
|
222
|
-
return !fs.lstatSync(path.join(dir, file)).isSymbolicLink();
|
|
223
|
-
} catch {
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
|
-
})
|
|
227
|
-
.sort()
|
|
228
|
-
.map((file) => {
|
|
229
|
-
const parsed = parseMemory(fs.readFileSync(path.join(dir, file), "utf8"));
|
|
230
|
-
return { ...parsed, name: parsed.name || path.basename(file, ".md") };
|
|
231
|
-
});
|
|
380
|
+
return listMemoryFiles(dir).map((entry) => entry.memory);
|
|
232
381
|
}
|
|
233
382
|
|
|
234
383
|
/** Read the index file verbatim, or "" when it does not exist yet. */
|
|
@@ -238,10 +387,11 @@ export function readIndex(dir: string): string {
|
|
|
238
387
|
}
|
|
239
388
|
|
|
240
389
|
export function readMemory(dir: string, name: string): Memory | null {
|
|
241
|
-
const
|
|
242
|
-
if (!
|
|
243
|
-
const
|
|
244
|
-
|
|
390
|
+
const filePath = findMemoryFile(dir, name);
|
|
391
|
+
if (!filePath) return null;
|
|
392
|
+
const stem = path.basename(filePath, ".md");
|
|
393
|
+
const parsed = parseMemory(fs.readFileSync(filePath, "utf8"));
|
|
394
|
+
return { ...parsed, name: parsed.name || stem, file: stem };
|
|
245
395
|
}
|
|
246
396
|
|
|
247
397
|
export interface WriteMemoryResult {
|
|
@@ -276,13 +426,19 @@ export function memoryDeletedSummary(name: string, existed: boolean): string {
|
|
|
276
426
|
* empty. Returns true when a memory file existed.
|
|
277
427
|
*/
|
|
278
428
|
export function deleteMemory(dir: string, name: string): boolean {
|
|
279
|
-
const
|
|
280
|
-
const existed =
|
|
281
|
-
if (
|
|
282
|
-
|
|
429
|
+
const filePath = findMemoryFile(dir, name);
|
|
430
|
+
const existed = filePath !== null;
|
|
431
|
+
if (filePath) fs.rmSync(filePath);
|
|
432
|
+
|
|
433
|
+
// Clean both the resolved stem's line and (harmlessly) a line keyed by the
|
|
434
|
+
// literal name, so an orphaned pointer can never survive either spelling.
|
|
435
|
+
const stems = new Set<string>();
|
|
436
|
+
if (filePath) stems.add(path.basename(filePath, ".md"));
|
|
437
|
+
if (isSafeName(name)) stems.add(name);
|
|
283
438
|
const indexFile = path.join(dir, INDEX_FILE);
|
|
284
|
-
if (fs.existsSync(indexFile)) {
|
|
285
|
-
|
|
439
|
+
if (fs.existsSync(indexFile) && stems.size > 0) {
|
|
440
|
+
let cleaned = fs.readFileSync(indexFile, "utf8");
|
|
441
|
+
for (const stem of stems) cleaned = removeIndexLine(cleaned, `${stem}.md`);
|
|
286
442
|
if (cleaned === "") fs.rmSync(indexFile);
|
|
287
443
|
else fs.writeFileSync(indexFile, cleaned);
|
|
288
444
|
}
|
|
@@ -291,10 +447,16 @@ export function deleteMemory(dir: string, name: string): boolean {
|
|
|
291
447
|
|
|
292
448
|
/** Write (or overwrite) a memory and register it in the index. */
|
|
293
449
|
export function writeMemory(dir: string, input: WriteMemoryInput): WriteMemoryResult {
|
|
294
|
-
|
|
450
|
+
assertSafeName(input.name);
|
|
295
451
|
fs.mkdirSync(dir, { recursive: true });
|
|
296
452
|
|
|
297
|
-
|
|
453
|
+
// Resolve the target by stem OR frontmatter name so an update addressed
|
|
454
|
+
// by either spelling rewrites the same file instead of forking a second one.
|
|
455
|
+
const existing = findMemoryFile(dir, input.name);
|
|
456
|
+
const stem = existing ? path.basename(existing, ".md") : input.name;
|
|
457
|
+
const previousRaw = existing ? fs.readFileSync(existing, "utf8") : undefined;
|
|
458
|
+
const previous = existing && previousRaw !== undefined ? parseMemory(previousRaw) : null;
|
|
459
|
+
const filePath = existing ?? memoryFile(dir, input.name);
|
|
298
460
|
|
|
299
461
|
const memory: Memory = {
|
|
300
462
|
name: input.name,
|
|
@@ -302,15 +464,16 @@ export function writeMemory(dir: string, input: WriteMemoryInput): WriteMemoryRe
|
|
|
302
464
|
// that forgets the summary should not silently blank it. Sanitized so a
|
|
303
465
|
// description can never smuggle extra frontmatter keys into the file.
|
|
304
466
|
description: sanitizeInline(input.description ?? "") || previous?.description || "",
|
|
305
|
-
type: input.type || DEFAULT_TYPE,
|
|
467
|
+
type: input.type || previous?.type || DEFAULT_TYPE,
|
|
306
468
|
body: input.body.trim(),
|
|
469
|
+
file: stem,
|
|
307
470
|
};
|
|
308
471
|
|
|
309
|
-
fs.writeFileSync(
|
|
472
|
+
fs.writeFileSync(filePath, serializeMemory(memory, previousRaw));
|
|
310
473
|
|
|
311
474
|
const index = upsertIndexLine(readIndex(dir), {
|
|
312
475
|
title: sanitizeInline(input.title ?? "") || input.name,
|
|
313
|
-
file: `${
|
|
476
|
+
file: `${stem}.md`,
|
|
314
477
|
hook: sanitizeInline(input.hook ?? "") || sanitizeInline(input.description ?? "") || "",
|
|
315
478
|
});
|
|
316
479
|
fs.writeFileSync(path.join(dir, INDEX_FILE), index);
|
|
@@ -333,7 +496,7 @@ export function buildMemoryPrompt(dir: string): string | null {
|
|
|
333
496
|
: listMemories(dir).map((memory) =>
|
|
334
497
|
renderIndexLine({
|
|
335
498
|
title: memory.name,
|
|
336
|
-
file: `${memory.name}.md`,
|
|
499
|
+
file: `${memory.file ?? memory.name}.md`,
|
|
337
500
|
hook: memory.description,
|
|
338
501
|
}),
|
|
339
502
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-claude-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Pi extension that reads and writes Claude Code's auto-memory store, keyed the way Claude Code keys it (canonical git root, Claude Code's sanitizePath)",
|
|
5
5
|
"author": "yg-codes",
|
|
6
6
|
"license": "MIT",
|