pi-claude-store 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yg-codes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,30 @@
1
+ NOTICE — Derivative works
2
+
3
+ This project derives from two MIT-licensed projects. Their copyright notices
4
+ are preserved in the headers of the derived files and recorded here.
5
+
6
+ 1. elecnix/pi-claude-memory — Copyright (c) 2026 Nicolas Marchildon
7
+ https://github.com/elecnix/pi-claude-memory
8
+
9
+ Derived files:
10
+ - extensions/claude-store/index.ts (verbatim tool registration, index
11
+ injection, and TUI rendering)
12
+ - extensions/claude-store/memory-core.ts (base store logic)
13
+ - tests/memory-core.test.ts (base test suite, 37 tests)
14
+ - README.md (adapted)
15
+
16
+ 2. kuitos/opencode-claude-memory — Copyright (c) 2025 kuitos
17
+ https://github.com/kuitos/opencode-claude-memory
18
+
19
+ Derived files:
20
+ - extensions/claude-store/paths.ts (ported from src/store/paths.ts;
21
+ sanitizePath/findGitRoot/resolveCanonicalRoot are that project's
22
+ byte-for-byte ports of Claude Code's own implementations)
23
+ - path-resolution and file-name-validation portions of
24
+ extensions/claude-store/memory-core.ts
25
+ - tests/paths.test.ts (ported from test/store/paths.test.ts, translated
26
+ from bun:test to node:test)
27
+ - tests/helpers.ts (minimal port of test/helpers/ temp-dir helpers)
28
+
29
+ When editing any derived file, keep the attribution header and this notice
30
+ in sync with reality.
package/README.md CHANGED
@@ -1,3 +1,90 @@
1
- # Temporary Holding Version
1
+ # pi-claude-store
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A pi extension that reads and writes **Claude Code's own memory store** — the
4
+ same files, so a memory saved in either agent is immediately visible to the
5
+ other. Not a parallel store, not a sync job.
6
+
7
+ Derived from [elecnix/pi-claude-memory](https://github.com/elecnix/pi-claude-memory)
8
+ (MIT, Copyright (c) 2026 Nicolas Marchildon) with the path-resolution layer
9
+ ported from [kuitos/opencode-claude-memory](https://github.com/kuitos/opencode-claude-memory)
10
+ (MIT, Copyright (c) 2025 kuitos). See [NOTICE](NOTICE) for the derivation
11
+ record.
12
+
13
+ ## What it does
14
+
15
+ Mirrors Claude Code's two-tier design, so a large store stays cheap:
16
+
17
+ - **Index injected once per session.** The `MEMORY.md` pointer lines are added
18
+ to pi's system prompt at the first turn — titles and one-line hooks only,
19
+ never the bodies.
20
+ - **Bodies read on demand.** `memory_read` fetches one memory when the index
21
+ suggests it is relevant.
22
+
23
+ ### Tools
24
+
25
+ | Tool | Purpose |
26
+ |---|---|
27
+ | `memory_read` | Read one memory by name |
28
+ | `memory_write` | Save or update a memory, or delete it with an empty/null body |
29
+ | `memory_list` | List every memory with its description |
30
+
31
+ Tool output is compact by default and expands with `ctrl+o`
32
+ (`app.tools.expand`). `memory_write` shows the new body — or a diff when
33
+ updating — and `memory_read` shows the full body.
34
+
35
+ ## Keying: the difference from upstream
36
+
37
+ Claude Code keys the store by the **canonical git root** and slugifies it
38
+ with `sanitizePath()` (every non-alphanumeric character becomes a dash, with a
39
+ djb2 hash suffix past 200 characters). `pi-claude-memory` 0.1.x instead
40
+ slugified the raw cwd with only `/` and `.` replaced, which silently splits
41
+ the store whenever:
42
+
43
+ - the path contains `_`, spaces, or other non-alphanumerics,
44
+ - pi is launched from a repository subdirectory, or
45
+ - pi runs inside a git worktree (upstream
46
+ [issue #14](https://github.com/elecnix/pi-claude-memory/issues/14)).
47
+
48
+ This extension ports Claude Code's exact resolution (`sanitizePath`,
49
+ `findGitRoot`, `resolveCanonicalRoot` from the kuitos port, which reproduces
50
+ Claude Code's own implementations byte for byte), so pi, Claude Code, and the
51
+ opencode plugin all resolve the identical store directory — worktrees
52
+ included.
53
+
54
+ The memory-file parser also matches Claude Code's `memoryScan.ts` rules:
55
+ frontmatter is only recognised when the closing `---` appears within the
56
+ first 30 lines, and both the current `metadata.type` nesting and the older
57
+ top-level `type:` layout are read.
58
+
59
+ ## Scoping
60
+
61
+ Memories are per-project and do not follow you between directories — the same
62
+ rule that applies in Claude Code. Outside a git repository the store is keyed
63
+ by the directory itself.
64
+
65
+ ## Install
66
+
67
+ ```bash
68
+ pi install npm:pi-claude-store
69
+ ```
70
+
71
+ Or track `main` (maintainers/development — updates with every merge):
72
+
73
+ ```bash
74
+ pi install git:github.com/yg-codes/pi-claude-store
75
+ ```
76
+
77
+ ## Development
78
+
79
+ ```bash
80
+ npm test
81
+ ```
82
+
83
+ Tests have zero dependencies beyond Node (v22.6+ for native TypeScript
84
+ stripping). They port both upstream suites — elecnix's 37 store tests and
85
+ kuitos's path-resolution fixtures — and are the tripwire for Claude Code
86
+ changing the on-disk memory format.
87
+
88
+ ## License
89
+
90
+ MIT. Contains code derived from two MIT projects; see [NOTICE](NOTICE).
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Claude Store Extension
3
+ *
4
+ * Derived from elecnix/pi-claude-memory (MIT, Copyright (c) 2026 Nicolas
5
+ * Marchildon), extensions/claude-memory/index.ts — tool registration, index
6
+ * injection, and TUI rendering are his, unchanged. See NOTICE for the full
7
+ * derivation record.
8
+ *
9
+ * Gives pi read and write access to Claude Code's auto-memory store — the
10
+ * same ~/.claude/projects/<slug>/memory/ directory Claude Code uses.
11
+ * One store, two agents, no sync step.
12
+ *
13
+ * Mirrors Claude Code's own two-tier design: the MEMORY.md index is injected
14
+ * into the system prompt once per session, and bodies are fetched on demand
15
+ * with `memory_read`.
16
+ */
17
+
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import {
20
+ generateDiffString,
21
+ keyHint,
22
+ withFileMutationQueue,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import { Text } from "@earendil-works/pi-tui";
25
+ import { Type } from "typebox";
26
+ import path from "node:path";
27
+
28
+ import {
29
+ buildMemoryPrompt,
30
+ deleteMemory,
31
+ listMemories,
32
+ memoryDeletedSummary,
33
+ memoryDirFor,
34
+ memorySummary,
35
+ readMemory,
36
+ writeMemory,
37
+ } from "./memory-core.ts";
38
+
39
+ export default function (pi: ExtensionAPI) {
40
+ let injected = false;
41
+
42
+ pi.on("session_start", async () => {
43
+ injected = false;
44
+ });
45
+
46
+ // Inject the index once per session, not once per turn — the store does not
47
+ // change often, and re-injecting would grow the prompt on every exchange.
48
+ pi.on("before_agent_start", async (event, ctx) => {
49
+ if (injected) return;
50
+ injected = true;
51
+
52
+ const prompt = buildMemoryPrompt(memoryDirFor(ctx.cwd));
53
+ if (!prompt) return;
54
+
55
+ return { systemPrompt: `${event.systemPrompt}\n\n${prompt}` };
56
+ });
57
+
58
+ pi.registerTool({
59
+ name: "memory_read",
60
+ label: "Read memory",
61
+ description:
62
+ "Read one memory from the store shared with Claude Code. Takes the memory name — " +
63
+ "the filename without the .md extension, as listed in the injected memory index.",
64
+ promptSnippet: "Read a stored memory by name",
65
+ promptGuidelines: [
66
+ "Call memory_read when an entry in the memory index looks relevant to the current task.",
67
+ ],
68
+ parameters: Type.Object({
69
+ name: Type.String({ description: "Memory name, e.g. no-claude-pr-signature" }),
70
+ }),
71
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
72
+ const dir = memoryDirFor(ctx.cwd);
73
+ try {
74
+ const memory = readMemory(dir, params.name);
75
+ if (!memory) {
76
+ const known = listMemories(dir).map((m) => m.name);
77
+ return {
78
+ content: [
79
+ {
80
+ type: "text" as const,
81
+ text: known.length
82
+ ? `No memory named "${params.name}". Available: ${known.join(", ")}`
83
+ : `No memory named "${params.name}". The store is empty.`,
84
+ },
85
+ ],
86
+ details: {},
87
+ };
88
+ }
89
+
90
+ return {
91
+ content: [
92
+ {
93
+ type: "text" as const,
94
+ text: `${memory.description}\n\n${memory.body}`,
95
+ },
96
+ ],
97
+ details: {
98
+ name: memory.name,
99
+ type: memory.type,
100
+ description: memory.description,
101
+ body: memory.body,
102
+ },
103
+ };
104
+ } catch (error) {
105
+ return {
106
+ content: [{ type: "text" as const, text: `${(error as Error).message}` }],
107
+ details: {},
108
+ };
109
+ }
110
+ },
111
+
112
+ renderResult(result, { expanded, isPartial }, theme, _context) {
113
+ if (isPartial) return new Text(theme.fg("warning", "Reading..."), 0, 0);
114
+ const details = result.details as
115
+ | { name?: string; description?: string; body?: string }
116
+ | undefined;
117
+
118
+ const collapsed = theme.fg(
119
+ "success",
120
+ `${details?.name ?? "memory"}: ${details?.description ?? ""}`,
121
+ );
122
+ if (!expanded || !details?.body) {
123
+ return new Text(
124
+ `${collapsed} ${theme.fg("dim", `(${keyHint("app.tools.expand", "expand")})`)}`,
125
+ 0,
126
+ 0,
127
+ );
128
+ }
129
+ return new Text(`${collapsed}\n${theme.fg("dim", details.body)}`, 0, 0);
130
+ },
131
+ });
132
+
133
+ pi.registerTool({
134
+ name: "memory_write",
135
+ label: "Write memory",
136
+ description:
137
+ "Save a durable fact to the memory store shared with Claude Code. Use for preferences, " +
138
+ "project constraints, and guidance you were given — not for things the repo or git " +
139
+ "history already records, and not for details that only matter in this conversation. " +
140
+ "Pass an empty or null body to delete the memory instead.",
141
+ promptSnippet: "Save a durable fact to shared memory",
142
+ promptGuidelines: [
143
+ "Call memory_write when the user states a lasting preference or correction worth keeping across sessions.",
144
+ "Call memory_write with an empty or null body to delete a memory that is no longer wanted.",
145
+ ],
146
+ parameters: Type.Object({
147
+ name: Type.String({ description: "Short kebab-case slug, e.g. prefers-tabs-over-spaces" }),
148
+ description: Type.Optional(
149
+ Type.String({
150
+ description:
151
+ "One line summarizing the fact. Used to decide relevance during recall. " +
152
+ "Omit when deleting.",
153
+ }),
154
+ ),
155
+ body: Type.Optional(
156
+ Type.Union([
157
+ Type.String({
158
+ description:
159
+ "The fact itself. For feedback and project types, follow with **Why:** and " +
160
+ "**How to apply:** lines.",
161
+ }),
162
+ Type.Null({
163
+ description: "Pass null or an empty string to delete the memory.",
164
+ }),
165
+ ]),
166
+ ),
167
+ type: Type.Optional(
168
+ Type.String({ description: "One of: user, feedback, project, reference" }),
169
+ ),
170
+ title: Type.Optional(Type.String({ description: "Index title. Defaults to the name." })),
171
+ hook: Type.Optional(
172
+ Type.String({ description: "Short index hook. Defaults to the description." }),
173
+ ),
174
+ }),
175
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
176
+ const dir = memoryDirFor(ctx.cwd);
177
+
178
+ // memory_write mutates MEMORY.md read-modify-write, so two calls in one
179
+ // turn would race and lose an index line without this queue.
180
+ return withFileMutationQueue(path.join(dir, "MEMORY.md"), async () => {
181
+ try {
182
+ // An empty or null body means "delete this memory" — no separate tool.
183
+ if (params.body === undefined || params.body === null || params.body.trim() === "") {
184
+ const existed = deleteMemory(dir, params.name);
185
+ return {
186
+ content: [
187
+ {
188
+ type: "text" as const,
189
+ text: memoryDeletedSummary(params.name, existed),
190
+ },
191
+ ],
192
+ details: { name: params.name, dir, deleted: true, existed },
193
+ };
194
+ }
195
+
196
+ const { memory, previous, created } = writeMemory(dir, {
197
+ ...params,
198
+ body: params.body,
199
+ });
200
+ const diff =
201
+ previous === null
202
+ ? undefined
203
+ : generateDiffString(previous.body, memory.body).diff;
204
+ return {
205
+ content: [
206
+ { type: "text" as const, text: memorySummary(params.name, created) },
207
+ ],
208
+ details: {
209
+ name: params.name,
210
+ dir,
211
+ created,
212
+ diff,
213
+ body: memory.body,
214
+ },
215
+ };
216
+ } catch (error) {
217
+ return {
218
+ content: [
219
+ { type: "text" as const, text: `Could not save: ${(error as Error).message}` },
220
+ ],
221
+ details: {},
222
+ };
223
+ }
224
+ });
225
+ },
226
+
227
+ renderResult(result, { expanded, isPartial }, theme, _context) {
228
+ if (isPartial) return new Text(theme.fg("warning", "Writing..."), 0, 0);
229
+ const details = result.details as
230
+ | {
231
+ name?: string;
232
+ created?: boolean;
233
+ diff?: string;
234
+ body?: string;
235
+ deleted?: boolean;
236
+ existed?: boolean;
237
+ }
238
+ | undefined;
239
+
240
+ if (!details?.name) {
241
+ const content = result.content[0];
242
+ return new Text(
243
+ theme.fg("error", content?.type === "text" ? content.text : "Failed"),
244
+ 0,
245
+ 0,
246
+ );
247
+ }
248
+
249
+ if (details.deleted) {
250
+ const text = memoryDeletedSummary(details.name, details.existed ?? false);
251
+ return new Text(
252
+ theme.fg(details.existed ? "success" : "warning", text),
253
+ 0,
254
+ 0,
255
+ );
256
+ }
257
+
258
+ const summary = theme.fg(
259
+ "success",
260
+ memorySummary(details.name, details.created ?? true),
261
+ );
262
+
263
+ if (!expanded) {
264
+ return new Text(
265
+ `${summary} ${theme.fg("dim", `(${keyHint("app.tools.expand", "expand")})`)}`,
266
+ 0,
267
+ 0,
268
+ );
269
+ }
270
+
271
+ // Show a diff when updating an existing memory, otherwise the new body.
272
+ if (details.diff) {
273
+ const lines = details.diff.split("\n").slice(0, 30);
274
+ let text = summary;
275
+ for (const line of lines) {
276
+ if (line.startsWith("+")) {
277
+ text += `\n${theme.fg("success", line)}`;
278
+ } else if (line.startsWith("-")) {
279
+ text += `\n${theme.fg("error", line)}`;
280
+ } else {
281
+ text += `\n${theme.fg("dim", line)}`;
282
+ }
283
+ }
284
+ const total = details.diff.split("\n").length;
285
+ if (total > 30) {
286
+ text += `\n${theme.fg("muted", `... ${total - 30} more diff lines`)}`;
287
+ }
288
+ return new Text(text, 0, 0);
289
+ }
290
+
291
+ return new Text(`${summary}\n${theme.fg("dim", details.body ?? "")}`, 0, 0);
292
+ },
293
+ });
294
+
295
+ pi.registerTool({
296
+ name: "memory_list",
297
+ label: "List memories",
298
+ description:
299
+ "List every memory in the store shared with Claude Code, with its description. " +
300
+ "Use when the injected index looks incomplete or stale.",
301
+ promptSnippet: "List all stored memories",
302
+ parameters: Type.Object({}),
303
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
304
+ const memories = listMemories(memoryDirFor(ctx.cwd));
305
+ return {
306
+ content: [
307
+ {
308
+ type: "text" as const,
309
+ text: memories.length
310
+ ? memories.map((m) => `- ${m.name} — ${m.description}`).join("\n")
311
+ : "No memories stored for this project.",
312
+ },
313
+ ],
314
+ details: { count: memories.length },
315
+ };
316
+ },
317
+ });
318
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Core logic for reading and writing Claude Code's auto-memory store.
3
+ *
4
+ * Derived from elecnix/pi-claude-memory (MIT, Copyright (c) 2026 Nicolas
5
+ * Marchildon), extensions/claude-memory/memory-core.ts. Directory resolution
6
+ * and file-name validation are ported from kuitos/opencode-claude-memory
7
+ * (MIT, Copyright (c) 2025 kuitos), src/store/paths.ts.
8
+ *
9
+ * Claude Code keeps one memory per file under
10
+ * ~/.claude/projects/<sanitized-canonical-git-root>/memory/, alongside a
11
+ * MEMORY.md index that holds one pointer line per memory. Only the index is
12
+ * loaded at session start; bodies are read on demand. This module reproduces
13
+ * that layout exactly so pi and Claude Code share a single store — keyed the
14
+ * same way Claude Code keys it: by canonical git root (worktrees resolve to
15
+ * the main repository), with Claude Code's own sanitizePath() slug.
16
+ *
17
+ * Pure functions plus thin fs wrappers — no pi imports, so it is testable
18
+ * with plain `node --test`.
19
+ */
20
+
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+
25
+ import { findCanonicalGitRoot, resolveMemoryFilePath, sanitizePath } from "./paths.ts";
26
+
27
+ export interface IndexEntry {
28
+ title: string;
29
+ file: string;
30
+ hook: string;
31
+ }
32
+
33
+ export interface Memory {
34
+ name: string;
35
+ description: string;
36
+ type: string;
37
+ body: string;
38
+ }
39
+
40
+ export interface WriteMemoryInput {
41
+ name: string;
42
+ description?: string;
43
+ body: string;
44
+ type?: string;
45
+ title?: string;
46
+ hook?: string;
47
+ }
48
+
49
+ const INDEX_FILE = "MEMORY.md";
50
+ const DEFAULT_TYPE = "project";
51
+
52
+ /** Claude Code's memoryScan.ts only recognises frontmatter whose closing delimiter is within the first 30 lines. */
53
+ export const FRONTMATTER_MAX_LINES = 30;
54
+
55
+ /**
56
+ * Resolve the Claude Code memory directory for a working directory.
57
+ *
58
+ * Claude Code keys the store by the canonical git root — a linked worktree or
59
+ * a subdirectory of the repository resolves to the repository root — and only
60
+ * falls back to the directory itself outside a repository. The slug is Claude
61
+ * Code's own sanitizePath(): every non-alphanumeric character becomes a dash,
62
+ * with a djb2 hash suffix past 200 characters.
63
+ */
64
+ export function memoryDirFor(cwd: string): string {
65
+ const root = findCanonicalGitRoot(cwd) ?? path.resolve(cwd);
66
+ return path.join(os.homedir(), ".claude", "projects", sanitizePath(root), "memory");
67
+ }
68
+
69
+ /** Parse `- [Title](file.md) — hook` pointer lines, ignoring anything else. */
70
+ export function parseIndex(md: string): IndexEntry[] {
71
+ const entries: IndexEntry[] = [];
72
+ for (const line of md.split("\n")) {
73
+ const match = /^\s*-\s*\[([^\]]*)\]\(([^)]+)\)\s*(?:—\s*(.*))?$/.exec(line);
74
+ if (!match) continue;
75
+ entries.push({
76
+ title: match[1].trim(),
77
+ file: match[2].trim(),
78
+ hook: (match[3] ?? "").trim(),
79
+ });
80
+ }
81
+ return entries;
82
+ }
83
+
84
+ /**
85
+ * Collapse newlines and other control characters to single spaces, keeping
86
+ * index and frontmatter fields single-line. Index pointer lines are injected
87
+ * into pi's system prompt; a multi-line title or hook written by any of the
88
+ * agents sharing the store could otherwise forge additional index entries
89
+ * (second-order prompt injection — SEC-001, docs/security/2026-10-09-review.md).
90
+ */
91
+ function sanitizeInline(value: string): string {
92
+ return value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/ {2,}/g, " ").trim();
93
+ }
94
+
95
+ function renderIndexLine(entry: IndexEntry): string {
96
+ // Square brackets are stripped from the title: a `]` would end the link
97
+ // 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. Brackets
99
+ // in the hook are inert — everything after the em dash is captured as hook
100
+ // text and cannot restructure the parse.
101
+ const title = sanitizeInline(entry.title).replace(/[\[\]]/g, "");
102
+ const hook = sanitizeInline(entry.hook);
103
+ const file = sanitizeInline(entry.file);
104
+ return hook ? `- [${title}](${file}) — ${hook}` : `- [${title}](${file})`;
105
+ }
106
+
107
+ /** Replace the pointer line for `entry.file` if present, otherwise append it. */
108
+ export function upsertIndexLine(md: string, entry: IndexEntry): string {
109
+ const rendered = renderIndexLine(entry);
110
+ const lines = md.split("\n");
111
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
112
+
113
+ let replaced = false;
114
+ const next = lines.map((line) => {
115
+ const parsed = parseIndex(line)[0];
116
+ if (parsed && parsed.file === entry.file) {
117
+ replaced = true;
118
+ return rendered;
119
+ }
120
+ return line;
121
+ });
122
+
123
+ if (!replaced) next.push(rendered);
124
+ return `${next.join("\n")}\n`;
125
+ }
126
+
127
+ /**
128
+ * Split a memory file into its frontmatter fields and body.
129
+ *
130
+ * Frontmatter is only recognised when the opening `---` is the first line and
131
+ * the closing `---` appears within the first FRONTMATTER_MAX_LINES lines —
132
+ * Claude Code's rule. A body that happens to contain `---` further down is
133
+ * left alone. The `type` field is read wherever it appears at the start of a
134
+ * frontmatter line, so both the current `metadata.type` nesting and the older
135
+ * top-level `type:` layout parse.
136
+ */
137
+ export function parseMemory(text: string): Memory {
138
+ const lines = text.split(/\r?\n/);
139
+ if (lines[0] !== "---") {
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
+ }
154
+
155
+ const frontmatter = lines.slice(1, close).join("\n");
156
+ const field = (key: string): string => {
157
+ const found = new RegExp(`^\\s*${key}:\\s*(.*)$`, "m").exec(frontmatter);
158
+ return found ? found[1].trim() : "";
159
+ };
160
+
161
+ const body = lines
162
+ .slice(close + 1)
163
+ .join("\n")
164
+ .replace(/^\s*\n/, "")
165
+ .trimEnd();
166
+
167
+ return {
168
+ name: field("name"),
169
+ description: field("description"),
170
+ type: field("type"),
171
+ body,
172
+ };
173
+ }
174
+
175
+ /** Render a memory in the frontmatter shape Claude Code writes and reads. */
176
+ export function serializeMemory(input: {
177
+ name: string;
178
+ description: string;
179
+ type?: string;
180
+ body?: string;
181
+ }): string {
182
+ return [
183
+ "---",
184
+ `name: ${input.name}`,
185
+ `description: ${input.description}`,
186
+ "metadata:",
187
+ " node_type: memory",
188
+ ` type: ${input.type || DEFAULT_TYPE}`,
189
+ "---",
190
+ "",
191
+ `${(input.body ?? "").trim()}\n`,
192
+ ].join("\n");
193
+ }
194
+
195
+ /** v1 is flat-only: one directory, single-segment names. */
196
+ function assertSafeName(name: string): void {
197
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.includes("..")) {
198
+ throw new Error(
199
+ `Invalid memory name "${name}": use a kebab-case slug with no path separators.`,
200
+ );
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Validate a memory name and resolve its file path inside `dir`, refusing
206
+ * anything that escapes the directory — including through a symbolic link
207
+ * planted inside it (ported containment check from kuitos).
208
+ */
209
+ function memoryFile(dir: string, name: string): string {
210
+ assertSafeName(name);
211
+ return resolveMemoryFilePath(dir, name).filePath;
212
+ }
213
+
214
+ /** Every memory in the directory, sorted by name. Missing directory yields []. */
215
+ export function listMemories(dir: string): Memory[] {
216
+ if (!fs.existsSync(dir)) return [];
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
+ });
232
+ }
233
+
234
+ /** Read the index file verbatim, or "" when it does not exist yet. */
235
+ export function readIndex(dir: string): string {
236
+ const file = path.join(dir, INDEX_FILE);
237
+ return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
238
+ }
239
+
240
+ export function readMemory(dir: string, name: string): Memory | null {
241
+ const file = memoryFile(dir, name);
242
+ if (!fs.existsSync(file)) return null;
243
+ const parsed = parseMemory(fs.readFileSync(file, "utf8"));
244
+ return { ...parsed, name: parsed.name || name };
245
+ }
246
+
247
+ export interface WriteMemoryResult {
248
+ memory: Memory;
249
+ previous: Memory | null;
250
+ created: boolean;
251
+ }
252
+
253
+ /** Drop the pointer line for `file` from the index, preserving the rest. */
254
+ export function removeIndexLine(md: string, file: string): string {
255
+ const kept = md.split("\n").filter((line) => {
256
+ const parsed = parseIndex(line)[0];
257
+ return !(parsed && parsed.file === file);
258
+ });
259
+ while (kept.length > 0 && kept[kept.length - 1].trim() === "") kept.pop();
260
+ return kept.length ? `${kept.join("\n")}\n` : "";
261
+ }
262
+
263
+ /** One-line summary shown for a memory_write tool result. */
264
+ export function memorySummary(name: string, created: boolean): string {
265
+ return created ? `Saved memory "${name}".` : `Updated memory "${name}".`;
266
+ }
267
+
268
+ /** One-line summary shown when memory_write deletes a memory. */
269
+ export function memoryDeletedSummary(name: string, existed: boolean): string {
270
+ return existed ? `Deleted memory "${name}".` : `No memory named "${name}" to delete.`;
271
+ }
272
+
273
+ /**
274
+ * Delete a memory: remove its file and drop its pointer line from the index.
275
+ * The index file itself is removed when it empties, so an empty store stays
276
+ * empty. Returns true when a memory file existed.
277
+ */
278
+ export function deleteMemory(dir: string, name: string): boolean {
279
+ const file = memoryFile(dir, name);
280
+ const existed = fs.existsSync(file);
281
+ if (existed) fs.rmSync(file);
282
+
283
+ const indexFile = path.join(dir, INDEX_FILE);
284
+ if (fs.existsSync(indexFile)) {
285
+ const cleaned = removeIndexLine(fs.readFileSync(indexFile, "utf8"), `${name}.md`);
286
+ if (cleaned === "") fs.rmSync(indexFile);
287
+ else fs.writeFileSync(indexFile, cleaned);
288
+ }
289
+ return existed;
290
+ }
291
+
292
+ /** Write (or overwrite) a memory and register it in the index. */
293
+ export function writeMemory(dir: string, input: WriteMemoryInput): WriteMemoryResult {
294
+ const file = memoryFile(dir, input.name);
295
+ fs.mkdirSync(dir, { recursive: true });
296
+
297
+ const previous = readMemory(dir, input.name);
298
+
299
+ const memory: Memory = {
300
+ name: input.name,
301
+ // Omitted description keeps the previous one (or stays empty) — an update
302
+ // that forgets the summary should not silently blank it. Sanitized so a
303
+ // description can never smuggle extra frontmatter keys into the file.
304
+ description: sanitizeInline(input.description ?? "") || previous?.description || "",
305
+ type: input.type || DEFAULT_TYPE,
306
+ body: input.body.trim(),
307
+ };
308
+
309
+ fs.writeFileSync(file, serializeMemory(memory));
310
+
311
+ const index = upsertIndexLine(readIndex(dir), {
312
+ title: sanitizeInline(input.title ?? "") || input.name,
313
+ file: `${input.name}.md`,
314
+ hook: sanitizeInline(input.hook ?? "") || sanitizeInline(input.description ?? "") || "",
315
+ });
316
+ fs.writeFileSync(path.join(dir, INDEX_FILE), index);
317
+
318
+ return { memory, previous, created: previous === null };
319
+ }
320
+
321
+ /**
322
+ * Build the block injected into pi's system prompt: the index only, never the
323
+ * bodies. This mirrors how Claude Code loads memory — a table of contents up
324
+ * front, full text pulled on demand — so a large store stays cheap.
325
+ *
326
+ * Returns null when the directory holds no memories, so the caller can leave
327
+ * the prompt untouched.
328
+ */
329
+ export function buildMemoryPrompt(dir: string): string | null {
330
+ const indexed = parseIndex(readIndex(dir));
331
+ const lines = indexed.length
332
+ ? indexed.map((entry) => renderIndexLine(entry))
333
+ : listMemories(dir).map((memory) =>
334
+ renderIndexLine({
335
+ title: memory.name,
336
+ file: `${memory.name}.md`,
337
+ hook: memory.description,
338
+ }),
339
+ );
340
+
341
+ if (lines.length === 0) return null;
342
+
343
+ return [
344
+ "## Memory",
345
+ "",
346
+ "You share a persistent memory store with Claude Code for this project.",
347
+ "Below is the index — one line per memory, bodies not included.",
348
+ "",
349
+ ...lines,
350
+ "",
351
+ "Call `memory_read` with a name (the filename without `.md`) when an entry",
352
+ "looks relevant to the task at hand. Call `memory_write` to record a durable",
353
+ "fact — a preference, a project constraint, or guidance you were given.",
354
+ "Do not record what the repo or git history already says.",
355
+ ].join("\n");
356
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Claude Code compatible memory directory path resolution.
3
+ *
4
+ * Ported from kuitos/opencode-claude-memory (MIT, Copyright (c) 2025 kuitos),
5
+ * src/store/paths.ts. sanitizePath(), findGitRoot() and resolveCanonicalRoot()
6
+ * are that project's byte-for-byte ports of Claude Code's own implementations
7
+ * (utils/sessionStoragePortable.ts and utils/git.ts), so pi resolves exactly
8
+ * the memory directory Claude Code resolves — including git worktrees, which
9
+ * key to the main repository root.
10
+ *
11
+ * Pure functions only: no directory creation, no environment access.
12
+ * memory-core.ts owns the side effects.
13
+ */
14
+
15
+ import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
16
+ import { dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
17
+
18
+ const MAX_SANITIZED_LENGTH = 200;
19
+
20
+ // Memory file names may be relative sub-paths (`team/conventions`), matching
21
+ // Claude Code, which reads memory directories recursively. Every segment is
22
+ // validated; the result always uses `/`. (The extension itself is flat-only
23
+ // in v1 — memory-core enforces that on top of this function.)
24
+ export function validateMemoryFileName(fileName: string): string {
25
+ if (typeof fileName !== "string" || fileName.length === 0) {
26
+ throw new Error("Memory file name cannot be empty");
27
+ }
28
+ if (fileName.includes("\0")) {
29
+ throw new Error(`Memory file name must not contain null bytes: ${fileName}`);
30
+ }
31
+ if (isAbsolute(fileName) || /^[A-Za-z]:/.test(fileName) || /^[\\/]/.test(fileName)) {
32
+ throw new Error(`Memory file name must be relative to the memory directory: ${fileName}`);
33
+ }
34
+
35
+ const segments = fileName.split(/[\\/]/);
36
+ const last = segments.length - 1;
37
+ for (let i = 0; i < segments.length; i++) {
38
+ let segment = segments[i] ?? "";
39
+ if (i === last && segment.endsWith(".md")) segment = segment.slice(0, -3);
40
+ if (segment.length === 0) {
41
+ throw new Error(`Memory file name must not contain empty path segments: ${fileName}`);
42
+ }
43
+ if (segment === "." || segment.includes("..")) {
44
+ throw new Error(`Memory file name must not contain path traversal: ${fileName}`);
45
+ }
46
+ if (segment.startsWith(".")) {
47
+ throw new Error(`Memory file name segments must not start with '.': ${fileName}`);
48
+ }
49
+ segments[i] = segment;
50
+ }
51
+
52
+ if ((segments[last] ?? "").toUpperCase() === "MEMORY") {
53
+ throw new Error("'MEMORY' is a reserved name and cannot be used as a memory file name");
54
+ }
55
+
56
+ return `${segments.join("/")}.md`;
57
+ }
58
+
59
+ function canonical(path: string): string | undefined {
60
+ try {
61
+ return realpathSync.native(path);
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ function existsNoFollow(path: string): boolean {
68
+ try {
69
+ lstatSync(path);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ function isInside(path: string, root: string): boolean {
77
+ return path === root || path.startsWith(root + sep);
78
+ }
79
+
80
+ // Validates the name and resolves it inside `memoryDir`, refusing anything
81
+ // that escapes it.
82
+ //
83
+ // Lexical containment (`resolve` + prefix) is not enough once sub-directories
84
+ // are allowed: a symbolic link inside the memory directory (`team -> /elsewhere`)
85
+ // would let `team/x` read, overwrite or delete a file outside it. The deepest
86
+ // existing path component is therefore resolved through the filesystem and
87
+ // must land inside the *real* memory directory. Links that stay inside the
88
+ // memory directory are fine; a dangling link is rejected because writing
89
+ // through it would create a file wherever it points.
90
+ export function resolveMemoryFilePath(
91
+ memoryDir: string,
92
+ fileName: string,
93
+ ): { relativePath: string; filePath: string } {
94
+ const relativePath = validateMemoryFileName(fileName);
95
+ const root = resolve(memoryDir);
96
+ const filePath = resolve(root, ...relativePath.split("/"));
97
+ if (!filePath.startsWith(root + sep)) {
98
+ throw new Error(`Memory file name resolves outside the memory directory: ${fileName}`);
99
+ }
100
+
101
+ const realRoot = canonical(root);
102
+ if (realRoot !== undefined) {
103
+ let probe = filePath;
104
+ while (probe !== root && !existsNoFollow(probe)) probe = dirname(probe);
105
+ const realProbe = canonical(probe);
106
+ if (realProbe === undefined || !isInside(realProbe, realRoot)) {
107
+ throw new Error(`Memory file name resolves outside the memory directory: ${fileName}`);
108
+ }
109
+ }
110
+ return { relativePath, filePath };
111
+ }
112
+
113
+ // Exact copy of Claude Code's djb2Hash() from utils/hash.ts
114
+ function djb2Hash(str: string): number {
115
+ let hash = 0;
116
+ for (let i = 0; i < str.length; i++) {
117
+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
118
+ }
119
+ return hash;
120
+ }
121
+
122
+ function simpleHash(str: string): string {
123
+ return Math.abs(djb2Hash(str)).toString(36);
124
+ }
125
+
126
+ // Exact copy of Claude Code's sanitizePath() from utils/sessionStoragePortable.ts
127
+ export function sanitizePath(name: string): string {
128
+ const sanitized = name.replace(/[^a-zA-Z0-9]/g, "-");
129
+ if (sanitized.length <= MAX_SANITIZED_LENGTH) {
130
+ return sanitized;
131
+ }
132
+ const hash = simpleHash(name);
133
+ return `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${hash}`;
134
+ }
135
+
136
+ // Matches Claude Code's findGitRoot() from utils/git.ts
137
+ export function findGitRoot(startPath: string): string | null {
138
+ let current = resolve(startPath);
139
+ const root = current.substring(0, current.indexOf(sep) + 1) || sep;
140
+
141
+ while (current !== root) {
142
+ try {
143
+ const gitPath = join(current, ".git");
144
+ const s = statSync(gitPath);
145
+ if (s.isDirectory() || s.isFile()) {
146
+ return current.normalize("NFC");
147
+ }
148
+ } catch {}
149
+ const parent = dirname(current);
150
+ if (parent === current) break;
151
+ current = parent;
152
+ }
153
+
154
+ try {
155
+ const gitPath = join(root, ".git");
156
+ const s = statSync(gitPath);
157
+ if (s.isDirectory() || s.isFile()) {
158
+ return root.normalize("NFC");
159
+ }
160
+ } catch {}
161
+
162
+ return null;
163
+ }
164
+
165
+ // Matches Claude Code's resolveCanonicalRoot() from utils/git.ts
166
+ // Resolves worktrees to the main repo root via .git -> gitdir -> commondir chain
167
+ function resolveCanonicalRoot(gitRoot: string): string {
168
+ try {
169
+ const gitContent = readFileSync(join(gitRoot, ".git"), "utf-8").trim();
170
+ if (!gitContent.startsWith("gitdir:")) {
171
+ return gitRoot;
172
+ }
173
+ const worktreeGitDir = resolve(gitRoot, gitContent.slice("gitdir:".length).trim());
174
+
175
+ const commonDir = resolve(
176
+ worktreeGitDir,
177
+ readFileSync(join(worktreeGitDir, "commondir"), "utf-8").trim(),
178
+ );
179
+
180
+ // SECURITY: validate worktreeGitDir is a direct child of <commonDir>/worktrees/
181
+ if (resolve(dirname(worktreeGitDir)) !== join(commonDir, "worktrees")) {
182
+ return gitRoot;
183
+ }
184
+
185
+ // SECURITY: validate gitdir back-link points to our .git
186
+ const backlink = realpathSync(readFileSync(join(worktreeGitDir, "gitdir"), "utf-8").trim());
187
+ if (backlink !== join(realpathSync(gitRoot), ".git")) {
188
+ return gitRoot;
189
+ }
190
+
191
+ // `commondir` is written by git with `/` separators even on Windows, so both the platform
192
+ // separator and `/` are checked (they are the same check on POSIX).
193
+ if (commonDir.endsWith(`${sep}.git`) || commonDir.endsWith("/.git")) {
194
+ return dirname(commonDir).normalize("NFC");
195
+ }
196
+
197
+ return commonDir.normalize("NFC");
198
+ } catch {
199
+ return gitRoot;
200
+ }
201
+ }
202
+
203
+ export function findCanonicalGitRoot(startPath: string): string | null {
204
+ const root = findGitRoot(startPath);
205
+ if (!root) return null;
206
+ return resolveCanonicalRoot(root);
207
+ }
208
+
209
+ export function isRootPath(path: string): boolean {
210
+ const resolved = resolve(path);
211
+ return resolved === parse(resolved).root;
212
+ }
213
+
214
+ // OpenCode reports worktree "/" for directories outside any git repository;
215
+ // fall back to the directory so memory is not shared by every non-git project
216
+ // on the machine. Kept for parity with the upstream port and its tests; pi
217
+ // currently reports only a cwd, for which this is the identity.
218
+ export function resolveMemoryRoot(worktree: string, directory: string): string {
219
+ if (isRootPath(worktree) && !isRootPath(directory)) return directory;
220
+ return worktree;
221
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
- "name": "pi-claude-store",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "pi-claude-store",
3
+ "version": "0.1.1",
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
+ "author": "yg-codes",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/yg-codes/pi-claude-store.git"
11
+ },
12
+ "homepage": "https://github.com/yg-codes/pi-claude-store#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/yg-codes/pi-claude-store/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi",
19
+ "pi-coding-agent",
20
+ "pi-extension",
21
+ "claude-code",
22
+ "memory"
23
+ ],
24
+ "files": [
25
+ "extensions/",
26
+ "README.md",
27
+ "NOTICE"
28
+ ],
29
+ "scripts": {
30
+ "test": "node --test tests/*.test.ts"
31
+ },
32
+ "pi": {
33
+ "extensions": [
34
+ "./extensions"
35
+ ]
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-coding-agent": "*"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "@earendil-works/pi-coding-agent": {
42
+ "optional": true
43
+ }
44
+ }
45
+ }