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