pi-session-memory 0.1.3 → 0.2.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.
package/spec.md CHANGED
@@ -1,154 +1,28 @@
1
- # pi-session-memory — Spec
1
+ # Native Codex-to-Pi Project Session Migration — Spec
2
2
 
3
3
  ## Goal
4
4
 
5
- A pi extension that persists every conversation turn to SQLite and exposes a
6
- `recall_memory` tool so the LLM can retrieve relevant past turns when the user
7
- references previous discussions.
5
+ Allow a user to convert each historical Codex session for the active project into a separate, native Pi session that can be selected through Pi `/resume` and continued normally.
8
6
 
9
- ## Architecture
7
+ This is distinct from SQLite historical import and `recall_memory`:
10
8
 
11
- ```
12
- pi turn_end event ──────────────────────────┐
13
-
14
- Writer (src/writer.ts)
15
- - writes current pi turns
16
-
17
- /backfill command ────────┐ │
18
- ▼ ▼
19
- Source adapters → SQLite ~/.pi/agent/memory.db
20
- - Pi JSONL sessions + turns
21
- - Claude JSONL
22
- - Codex JSONL
23
-
24
-
25
- recall_memory tool
26
- - LLM supplies entities[]
27
- - LIKE substring match + hit-score ranking
28
- - returns top-5 turns
29
- ```
9
+ - Native migration writes Pi session JSONL files for direct continuation in Pi.
10
+ - `recall_memory` searches local SQLite excerpts and does not restore a client session.
30
11
 
31
- ## Tables
12
+ ## Design
32
13
 
33
- ### sessions
34
- | column | type | note |
35
- |------------|---------|-------------------------------|
36
- | session_id | TEXT PK | native source session ID (UUID) |
37
- | source | TEXT | `pi`, `claude`, or `codex` |
38
- | cwd | TEXT | working directory |
39
- | started_at | INTEGER | unix ms |
40
- | model_id | TEXT | first model_change value |
41
- | jsonl_path | TEXT | absolute path to source file |
14
+ - Export `migrateCodexProjectSessions(cwd)` from `src/session-migration.ts`.
15
+ - Scan Codex JSONL sessions and select only sessions with `session.cwd === cwd`.
16
+ - Create one Pi v3 session JSONL per Codex session under Pi's default session directory for that cwd.
17
+ - Write a `Migrated from Codex: <session-id>` session name so it is recognizable in `/resume`.
18
+ - Convert user and assistant textual messages only. Do not represent Codex system/developer prompts, tool calls, or tool results as Pi conversation messages.
19
+ - Use deterministic output file names and skip an already migrated Codex session, making reruns idempotent.
20
+ - Register `/project-session-migration` for users and `migrate_codex_project_sessions` for Pi agents. Both descriptions must state that this is native Pi continuation, not ordinary recall.
42
21
 
43
- ### turns
44
- | column | type | note |
45
- |-------------|---------|-------------------------------------------|
46
- | turn_id | TEXT PK | `{session_id}:{user_message_id}` — stable across live write and backfill |
47
- | session_id | TEXT FK | |
48
- | turn_index | INTEGER | display order within session; never used as identity |
49
- | ts | INTEGER | user message timestamp (unix ms) |
50
- | user_text | TEXT | user message content |
51
- | reply_text | TEXT | assistant final text (all text blocks) |
52
- | tool_names | TEXT | JSON array e.g. `["bash","read"]` |
22
+ ## Acceptance criteria
53
23
 
54
- There is intentionally no full-text virtual table. Retrieval uses escaped
55
- SQLite `LIKE` against the complete `user_text` and `reply_text`, because literal
56
- substring coverage and retrieval quality are prioritized over index performance.
57
-
58
- ## Write Path
59
-
60
- Trigger: `agent_settled` event, after the agent run and any automatic
61
- continuations have completed.
62
-
63
- Steps:
64
- 1. Locate the latest user `SessionEntry` in `ctx.sessionManager.getBranch()` and
65
- use its stable entry ID as `user_message_id`; do not use pi's transient `turnIndex`.
66
- 2. Extract `user_text` from that user entry's text content blocks.
67
- 3. Walk branch entries after that user entry to collect assistant text blocks →
68
- `reply_text`, and toolCall names → `tool_names`.
69
- 4. Upsert source=`pi` session row (INSERT OR IGNORE).
70
- 5. Insert turn by stable ID (INSERT OR IGNORE — live writing and backfill target
71
- the same row).
72
-
73
- ## Retrieval Path (recall_memory tool)
74
-
75
- ### Tool Invocation Policy
76
-
77
- - **Direct recall:** Call `recall_memory` immediately when the user explicitly
78
- asks to review, remember, summarize, continue, or compare a prior discussion
79
- about a topic.
80
- - **Knowledge-gap recall:** When the user asks about a topic you cannot answer
81
- confidently from the current conversation and your general knowledge, but it
82
- may have been discussed in the user's past sessions, ask the user whether they
83
- want you to search their conversation history. Call `recall_memory` only after
84
- the user agrees.
85
- - Do not search history merely because a question is difficult when the user has
86
- not indicated that their own prior work or discussions are relevant.
87
-
88
- Input: `{ entities: string[] }` — 2-5 key terms extracted by LLM from user query.
89
-
90
- Steps:
91
- 1. Build per-entity LIKE hit score:
92
- - `user_text` match = 2 points
93
- - `reply_text` match = 1 point
94
- 2. `SELECT ... WHERE (LOWER(user_text) LIKE ? OR LOWER(reply_text) LIKE ?) OR ...`
95
- 3. Escape `%`, `_`, and `\\` in every entity, then use `LIKE ? ESCAPE '\\'` so
96
- technical names containing LIKE wildcards remain literal substring matches.
97
- 4. `ORDER BY hits DESC, ts DESC LIMIT 5`
98
-
99
- No FTS5 or write-time preprocessing. LIKE is a literal substring match after
100
- escaping, so it does not lose substring matches through tokenization.
101
-
102
- ## Backfill
103
-
104
- `/memory-backfill` scans and imports all historical records. All writes use
105
- `INSERT OR IGNORE`, so it is safe and idempotent to run repeatedly.
106
-
107
- | source | scan root | accepted user/assistant records | excluded records |
108
- |---|---|---|---|
109
- | pi | `~/.pi/agent/sessions/**/*.jsonl` | `message.role=user|assistant` | thinking, tool results, non-text content |
110
- | claude | `~/.claude/projects/**/*.jsonl` | `type=user|assistant`, `message.role=user|assistant` | `isMeta`, sidechains, slash commands, local-command tags, continuation summaries, system/snapshot/attachments |
111
- | codex | `~/.codex/sessions/**/*.jsonl` | `type=response_item`, `payload.type=message`, `role=user|assistant` | developer/system context, AGENTS.md and environment-context injection, IDE/image context, reasoning and tool events |
112
-
113
- Each adapter outputs the common `ImportedSession` / `ImportedTurn` model.
114
- Turns pair one accepted user message with all following accepted assistant text
115
- until the next accepted user message. Each source adapter preserves the native
116
- user-message ID (`pi entry.id`, Claude `uuid`, Codex `payload.id`) so rerunning
117
- backfill never creates a duplicate of an already live-written pi turn.
118
-
119
- ## Acceptance Criteria
120
-
121
- - A pi session with multiple user turns produces one distinct `turns` row per
122
- user message; no row is dropped because `turnIndex` restarts.
123
- - Running backfill after live pi writing does not duplicate those pi turns.
124
- - Claude and Codex injected context records listed above are absent from
125
- `turns.user_text`.
126
- - Entity text containing `%`, `_`, or `\\` only matches its literal occurrence.
127
- - Automated tests cover stable turn identity, LIKE escaping/ranking, and
128
- cross-source backfill parsing.
129
-
130
- ## File Structure
131
-
132
- ```
133
- pi-session-memory/
134
- ├── spec.md
135
- ├── package.json
136
- ├── tsconfig.json
137
- ├── extensions/
138
- │ └── index.ts ← extension entry: tool + /memory-backfill
139
- ├── tests/
140
- │ └── core.test.ts ← persistence identity and literal-LIKE tests
141
- └── src/
142
- ├── db.ts ← DatabaseSync schema + upsert helpers
143
- ├── writer.ts ← live pi turn_end writer
144
- ├── retriever.ts ← LIKE query + hit-score ranking
145
- └── backfill.ts ← Pi / Claude / Codex adapters and import runner
146
- ```
147
-
148
- ## Constraints
149
-
150
- - Zero external dependencies (use `node:sqlite`, `node:fs`, `node:path`, `node:os`)
151
- - Idempotent writes (INSERT OR IGNORE on turn_id)
152
- - Cross-platform paths: storage is `join(homedir(), ".pi", "agent", "memory.db")`; source roots are derived with `join(homedir(), ...)`, never hard-coded POSIX paths.
153
- - Requires a Pi-supported Node.js runtime that exposes `node:sqlite` (`DatabaseSync`).
154
- - No fallback / silent failure — let errors surface
24
+ 1. Each current-project Codex fixture produces one independently resumable Pi session with the expected user/assistant message sequence.
25
+ 2. Another project's Codex session is not migrated.
26
+ 3. A second migration skips existing output session files.
27
+ 4. A malformed source file becomes an isolated issue and does not block other sessions.
28
+ 5. `npm test` and `git diff --check` pass.
package/src/backfill.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { readdirSync, readFileSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
- import { insertTurn, upsertSession } from "./db.ts";
5
+ import { getSourceFile, insertTurn, upsertSession, upsertSourceFile } from "./db.ts";
5
6
 
6
7
  type Source = "pi" | "claude" | "codex";
7
8
  type Role = "user" | "assistant";
@@ -24,43 +25,100 @@ interface ImportedSession {
24
25
  messages: ImportedMessage[];
25
26
  }
26
27
 
28
+ export interface BackfillIssue {
29
+ source: Source;
30
+ jsonlPath: string | null;
31
+ error: string;
32
+ }
33
+
27
34
  export interface BackfillStats {
28
35
  pi: number;
29
36
  claude: number;
30
37
  codex: number;
31
38
  turns: number;
39
+ scannedFiles: number;
40
+ skippedFiles: number;
41
+ issues: BackfillIssue[];
32
42
  }
33
43
 
44
+ /** Versions whose Claude Code and Codex JSONL schemas this importer was verified against. */
45
+ export const HISTORY_SCHEMA_REFERENCE_VERSIONS = {
46
+ pi: "0.85.1",
47
+ claude: "2.1.234",
48
+ codex: "0.154.0",
49
+ } as const;
50
+
51
+ const SOURCES: Array<{ source: Source; root: string; parse: (path: string) => ImportedSession | undefined }> = [
52
+ { source: "pi", root: join(homedir(), ".pi", "agent", "sessions"), parse: _parsePi },
53
+ { source: "claude", root: join(homedir(), ".claude", "projects"), parse: _parseClaude },
54
+ { source: "codex", root: join(homedir(), ".codex", "sessions"), parse: _parseCodex },
55
+ ];
56
+
57
+ /** Reparse every known source file. Intended for the explicit /memory-backfill command. */
34
58
  export function backfillAll(): BackfillStats {
35
- const stats: BackfillStats = { pi: 0, claude: 0, codex: 0, turns: 0 };
59
+ return _syncHistory(true);
60
+ }
36
61
 
37
- for (const jsonlPath of _jsonlFiles(join(homedir(), ".pi", "agent", "sessions"))) {
38
- const session = _parsePi(jsonlPath);
39
- if (session) {
40
- stats.pi++;
41
- stats.turns += _persist(session);
62
+ /** Parse only source files that are new or whose metadata/content changed. */
63
+ export function syncChangedHistory(): BackfillStats {
64
+ return _syncHistory(false);
65
+ }
66
+
67
+ /** Synchronize all configured JSONL sources. */
68
+ function _syncHistory(force: boolean): BackfillStats {
69
+ const stats: BackfillStats = { pi: 0, claude: 0, codex: 0, turns: 0, scannedFiles: 0, skippedFiles: 0, issues: [] };
70
+ for (const definition of SOURCES) {
71
+ if (!existsSync(definition.root)) continue;
72
+ try {
73
+ for (const jsonlPath of _jsonlFiles(definition.root)) _syncSourceFile(definition, jsonlPath, force, stats);
74
+ } catch (error) {
75
+ stats.issues.push(_backfillIssue(definition.source, null, error));
42
76
  }
43
77
  }
78
+ return stats;
79
+ }
44
80
 
45
- for (const jsonlPath of _jsonlFiles(join(homedir(), ".claude", "projects"))) {
46
- const session = _parseClaude(jsonlPath);
47
- if (session) {
48
- stats.claude++;
49
- stats.turns += _persist(session);
81
+ /** Synchronize one source file without allowing its failure to block other files or sources. */
82
+ function _syncSourceFile(definition: typeof SOURCES[number], jsonlPath: string, force: boolean, stats: BackfillStats): void {
83
+ try {
84
+ const metadata = statSync(jsonlPath);
85
+ const known = getSourceFile(jsonlPath);
86
+ if (!force && known?.size === metadata.size && known.mtime_ms === metadata.mtimeMs) {
87
+ stats.skippedFiles++;
88
+ return;
89
+ }
90
+
91
+ const sha256 = _sha256(jsonlPath);
92
+ if (!force && known?.sha256 === sha256) {
93
+ upsertSourceFile({ jsonl_path: jsonlPath, source: definition.source, size: metadata.size, mtime_ms: metadata.mtimeMs, sha256 });
94
+ stats.skippedFiles++;
95
+ return;
50
96
  }
51
- }
52
97
 
53
- for (const jsonlPath of _jsonlFiles(join(homedir(), ".codex", "sessions"))) {
54
- const session = _parseCodex(jsonlPath);
98
+ const session = definition.parse(jsonlPath);
55
99
  if (session) {
56
- stats.codex++;
100
+ stats[definition.source]++;
57
101
  stats.turns += _persist(session);
102
+ upsertSourceFile({ jsonl_path: jsonlPath, source: definition.source, size: metadata.size, mtime_ms: metadata.mtimeMs, sha256 });
58
103
  }
104
+ stats.scannedFiles++;
105
+ } catch (error) {
106
+ stats.issues.push(_backfillIssue(definition.source, jsonlPath, error));
59
107
  }
108
+ }
60
109
 
61
- return stats;
110
+ /** Format an isolated source failure with the reference version for schema comparison. */
111
+ function _backfillIssue(source: Source, jsonlPath: string | null, error: unknown): BackfillIssue {
112
+ const location = jsonlPath ? ` (${jsonlPath})` : "";
113
+ const message = error instanceof Error ? error.message : String(error);
114
+ return {
115
+ source,
116
+ jsonlPath,
117
+ error: `${source} history import failed${location}: ${message}. Compare the local ${source} version with the supported reference ${HISTORY_SCHEMA_REFERENCE_VERSIONS[source]}; this may be a JSONL schema compatibility issue.`,
118
+ };
62
119
  }
63
120
 
121
+ /** Convert one normalized source session into paired, idempotently stored memory turns. */
64
122
  function _persist(session: ImportedSession): number {
65
123
  const sessionId = `${session.source}:${session.nativeSessionId}`;
66
124
  upsertSession({
@@ -78,6 +136,7 @@ function _persist(session: ImportedSession): number {
78
136
  const toolNames: string[] = [];
79
137
  let persisted = 0;
80
138
 
139
+ /** Persist the current user-plus-assistant accumulation when a turn boundary is reached. */
81
140
  const flush = () => {
82
141
  if (!user || !replyText.trim()) return;
83
142
  const inserted = insertTurn({
@@ -109,6 +168,7 @@ function _persist(session: ImportedSession): number {
109
168
  return persisted;
110
169
  }
111
170
 
171
+ /** Parse a Pi JSONL session into the source-neutral import representation. */
112
172
  function _parsePi(jsonlPath: string): ImportedSession | undefined {
113
173
  const entries = _readJsonl(jsonlPath);
114
174
  const header = entries.find((entry) => entry.type === "session");
@@ -125,7 +185,13 @@ function _parsePi(jsonlPath: string): ImportedSession | undefined {
125
185
  const toolNames = message.role === "assistant"
126
186
  ? message.content.filter((block: any) => block.type === "toolCall").map((block: any) => block.name)
127
187
  : [];
128
- messages.push({ id: entry.id, role: message.role, text, ts: message.timestamp, toolNames });
188
+ messages.push({
189
+ id: _messageId("Pi entry.id", entry.id),
190
+ role: message.role,
191
+ text,
192
+ ts: message.timestamp,
193
+ toolNames,
194
+ });
129
195
  }
130
196
 
131
197
  return {
@@ -139,6 +205,7 @@ function _parsePi(jsonlPath: string): ImportedSession | undefined {
139
205
  };
140
206
  }
141
207
 
208
+ /** Parse non-meta Claude Code conversation records into normalized messages. */
142
209
  function _parseClaude(jsonlPath: string): ImportedSession | undefined {
143
210
  const entries = _readJsonl(jsonlPath);
144
211
  const firstConversation = entries.find((entry) =>
@@ -156,7 +223,14 @@ function _parseClaude(jsonlPath: string): ImportedSession | undefined {
156
223
  const toolNames = role === "assistant"
157
224
  ? entry.message.content.filter((block: any) => block.type === "tool_use").map((block: any) => block.name)
158
225
  : [];
159
- messages.push({ id: entry.uuid, role, text, ts: Date.parse(entry.timestamp), toolNames });
226
+ // Workaround: Claude Code JSONL schema differs by version; message IDs may be in uuid or id.
227
+ messages.push({
228
+ id: _messageId("Claude entry.uuid or entry.id", entry.uuid, entry.id),
229
+ role,
230
+ text,
231
+ ts: Date.parse(entry.timestamp),
232
+ toolNames,
233
+ });
160
234
  }
161
235
 
162
236
  return {
@@ -170,6 +244,7 @@ function _parseClaude(jsonlPath: string): ImportedSession | undefined {
170
244
  };
171
245
  }
172
246
 
247
+ /** Parse Codex session metadata and response-message envelopes into normalized messages. */
173
248
  function _parseCodex(jsonlPath: string): ImportedSession | undefined {
174
249
  const entries = _readJsonl(jsonlPath);
175
250
  const meta = entries.find((entry) => entry.type === "session_meta")?.payload;
@@ -182,8 +257,14 @@ function _parseCodex(jsonlPath: string): ImportedSession | undefined {
182
257
  if (payload?.type !== "message" || (payload.role !== "user" && payload.role !== "assistant")) continue;
183
258
  const text = _codexText(payload);
184
259
  if (!text || (payload.role === "user" && _isCodexInjectedContext(text))) continue;
260
+ // Workaround: Codex JSONL schema differs by version; legacy sessions store the ID as metadata.turn_id.
185
261
  messages.push({
186
- id: payload.id,
262
+ id: _messageId(
263
+ "Codex payload.id, entry.id, or metadata.turn_id",
264
+ payload.id,
265
+ entry.id,
266
+ payload.internal_chat_message_metadata_passthrough?.turn_id,
267
+ ),
187
268
  role: payload.role,
188
269
  text,
189
270
  ts: Date.parse(entry.timestamp),
@@ -202,6 +283,14 @@ function _parseCodex(jsonlPath: string): ImportedSession | undefined {
202
283
  };
203
284
  }
204
285
 
286
+ /** Read a source message ID from a known schema field without inventing one for malformed records. */
287
+ function _messageId(field: string, ...values: unknown[]): string {
288
+ const id = values.find((value): value is string => typeof value === "string" && value.length > 0);
289
+ if (!id) throw new Error(`Invalid ${field}: expected a non-empty string`);
290
+ return id;
291
+ }
292
+
293
+ /** Extract Pi text content while excluding thinking and non-text blocks. */
205
294
  function _piText(message: any): string {
206
295
  if (typeof message.content === "string") return message.content.trim();
207
296
  return message.content
@@ -211,6 +300,7 @@ function _piText(message: any): string {
211
300
  .trim();
212
301
  }
213
302
 
303
+ /** Extract Claude Code text content from either legacy strings or content blocks. */
214
304
  function _claudeText(message: any): string {
215
305
  if (typeof message.content === "string") return message.content.trim();
216
306
  if (!Array.isArray(message.content)) return "";
@@ -221,6 +311,7 @@ function _claudeText(message: any): string {
221
311
  .trim();
222
312
  }
223
313
 
314
+ /** Extract user input and assistant output text from a Codex message payload. */
224
315
  function _codexText(message: any): string {
225
316
  return message.content
226
317
  .filter((block: any) => block.type === "input_text" || block.type === "output_text")
@@ -229,6 +320,7 @@ function _codexText(message: any): string {
229
320
  .trim();
230
321
  }
231
322
 
323
+ /** Identify Claude Code client-injected text that must not become user memory. */
232
324
  function _isClaudeInjectedContext(text: string): boolean {
233
325
  return text.startsWith("<command-name>")
234
326
  || text.startsWith("<command-message>")
@@ -237,6 +329,7 @@ function _isClaudeInjectedContext(text: string): boolean {
237
329
  || text.startsWith("This session is being continued from a previous conversation");
238
330
  }
239
331
 
332
+ /** Identify Codex environment or IDE context that must not become user memory. */
240
333
  function _isCodexInjectedContext(text: string): boolean {
241
334
  return text.startsWith("# AGENTS.md instructions")
242
335
  || text.startsWith("<environment_context>")
@@ -244,6 +337,12 @@ function _isCodexInjectedContext(text: string): boolean {
244
337
  || text.startsWith("<image name=");
245
338
  }
246
339
 
340
+ /** Hash a source JSONL file so unchanged content can skip reparsing. */
341
+ function _sha256(jsonlPath: string): string {
342
+ return createHash("sha256").update(readFileSync(jsonlPath)).digest("hex");
343
+ }
344
+
345
+ /** Read every non-empty JSONL line into its ordered JSON record. */
247
346
  function _readJsonl(jsonlPath: string): any[] {
248
347
  return readFileSync(jsonlPath, "utf8")
249
348
  .split("\n")
@@ -251,6 +350,7 @@ function _readJsonl(jsonlPath: string): any[] {
251
350
  .map((line) => JSON.parse(line));
252
351
  }
253
352
 
353
+ /** Recursively discover JSONL session files under a source root. */
254
354
  function _jsonlFiles(root: string): string[] {
255
355
  const files: string[] = [];
256
356
  for (const entry of readdirSync(root, { withFileTypes: true })) {