pi-session-memory 0.3.1 → 0.4.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/README.md CHANGED
@@ -10,7 +10,7 @@ A local-first Pi extension that saves completed conversations to SQLite and give
10
10
  - Persists completed Pi conversations in `~/.pi/agent/memory.db`.
11
11
  - Automatically imports only new or changed Pi, Claude Code, and Codex session files when Pi starts.
12
12
  - Supports a forced full SQLite history rescan with `/memory-backfill`.
13
- - Converts current-project Codex sessions into separate native Pi sessions with `/project-session-migration`, ready to select through `/resume`.
13
+ - Converts current-project Codex or Claude Code sessions into separate native Pi sessions, ready to select through `/resume`.
14
14
  - Exposes `recall_memory`, allowing Pi to retrieve relevant prior discussions when users explicitly refer to earlier work.
15
15
  - Uses stable native user-message IDs and `INSERT OR IGNORE`, making live persistence and backfill idempotent.
16
16
  - Searches literal substrings with escaped SQLite `LIKE` patterns, including technical terms containing `%`, `_`, or `\\`.
@@ -46,7 +46,7 @@ pi -e npm:pi-session-memory
46
46
  To intentionally pin a known version (which `pi update --extensions` skips), add its version explicitly:
47
47
 
48
48
  ```bash
49
- pi install npm:pi-session-memory@0.3.1
49
+ pi install npm:pi-session-memory@0.4.0
50
50
  ```
51
51
 
52
52
  ## Usage
@@ -72,15 +72,16 @@ The command imports eligible user/assistant exchanges from:
72
72
  | Claude Code | `~/.claude/projects/**/*.jsonl` |
73
73
  | Codex | `~/.codex/sessions/**/*.jsonl` |
74
74
 
75
- ### Continue a Codex session natively in Pi
75
+ ### Continue a Claude Code or Codex session natively in Pi
76
76
 
77
- Use this command only when you want to continue prior **Codex** work as a real Pi session rather than search it as memory:
77
+ Use a migration command only when you want to continue prior work as a real Pi session rather than search it as memory:
78
78
 
79
79
  ```text
80
- /project-session-migration
80
+ /project-claude-session-migration # Claude Code → Pi
81
+ /project-session-migration # Codex → Pi
81
82
  ```
82
83
 
83
- It selects Codex JSONL files whose recorded `cwd` exactly equals the current project, then creates one independent Pi v3 session JSONL for each under Pi's normal session directory. Each migrated entry is named `Migrated from Codex: <session-id>`.
84
+ Each command selects only source JSONL files whose recorded `cwd` exactly equals the current project, then creates one independent Pi v3 session JSONL under Pi's normal session directory. Entries are named `Migrated from Claude Code: <session-id>` or `Migrated from Codex: <session-id>`.
84
85
 
85
86
  After the command completes, run:
86
87
 
@@ -90,7 +91,7 @@ After the command completes, run:
90
91
 
91
92
  and select the migrated session to continue it in Pi. Existing migrated outputs are skipped on subsequent runs.
92
93
 
93
- The converter preserves user and assistant text messages only. It deliberately does not convert Codex system/developer prompts, reasoning, tool calls, or tool results into Pi messages. Migration is separate from SQLite backfill and `recall_memory`; normal cross-client recall does not require migration.
94
+ The converters preserve user and assistant text messages only. They deliberately do not convert client-injected context, system/developer prompts, reasoning, tool calls, or tool results into Pi messages. Migration is separate from SQLite backfill and `recall_memory`; normal cross-client recall does not require migration.
94
95
 
95
96
  #### Context compaction and retention
96
97
 
@@ -149,7 +150,9 @@ Pi / Claude Code / Codex history ──► incremental source sync ──► SQL
149
150
 
150
151
  recall_memory
151
152
 
152
- Current-project Codex JSONL ──► /project-session-migration ──► native Pi session JSONL ──► /resume
153
+ Current-project Claude Code JSONL ──► /project-claude-session-migration ─┐
154
+ Current-project Codex JSONL ────────► /project-session-migration ───────┼──► native Pi session JSONL ──► /resume
155
+
153
156
  ```
154
157
 
155
158
  After a Pi agent run settles, the extension captures the latest user message and the subsequent assistant replies/tool names from the active session branch. Historical imports normalize each supported source into the same session/turn schema.
@@ -168,6 +171,7 @@ Conversation data is stored and queried locally. This package does not add a rem
168
171
 
169
172
  | Version | Highlights |
170
173
  | --- | --- |
174
+ | `0.4.0` | Adds native current-project Claude Code-to-Pi session migration through `/project-claude-session-migration` and `migrate_claude_project_sessions`. Claude Code and Codex now share deterministic, source-namespaced, idempotent Pi v3 session output; SQLite data and schema remain compatible, so no database migration is required. |
171
175
  | `0.3.1` | Makes `recall_memory` entity-only: 2–8 high-signal literal entities are OR alternatives, results matching more entities rank higher, and output distinguishes search entities from strict scope filters. Existing SQLite data and schema remain compatible; no migration is required. |
172
176
  | `0.3.0` | Replaces source-session-only freshness hints with provenance-linked evidence comparison across newer same-session and cross-session turns. This changes recall output and `freshness_candidate` semantics, but keeps SQLite data and explicit user-controlled memory mutation compatible; no migration is required. |
173
177
  | `0.2.1` | Pages `recall_memory` results in explicit five-result `offset` windows while still evaluating the complete local match set; npm publishing now uses a runtime-file allowlist. |
@@ -189,7 +193,7 @@ Before every significant release, review these compatibility surfaces and record
189
193
  5. **Public TypeScript/module API:** exported types/functions and required result fields.
190
194
  6. **Extension loadability:** run `npm test`, which imports `extensions/index.ts`; Markdown inline-code backticks inside a template-literal description must be escaped as `\`` so Pi can parse and start the extension.
191
195
 
192
- The `0.3.1` review found no SQLite breaking change: it does not alter tables, migrations, or stored data, so users can upgrade without a database error or migration. It is therefore released as a patch despite changing `recall_memory` search inputs and behavior.
196
+ The `0.4.0` review found no SQLite breaking change: Claude Code native-session migration adds commands, an agent tool, and Pi session-file output only. It does not alter tables, migrations, or stored data, so users can upgrade without a database error or migration. It is therefore released as a minor version because it adds a backward-compatible user capability.
193
197
 
194
198
  ## Development
195
199
 
@@ -4,7 +4,7 @@ import { writeTurn } from "../src/writer.ts";
4
4
  import { confirmMemory, createMemory, deleteMemory, deleteTurn, getMemoryHistory, getMemoryStats, getSession, listMemories, pinTurnAsMemory, supersedeMemory, type MemoryKind } from "../src/db.ts";
5
5
  import { recallMemories, formatRecallResults, paginateRecallResults } from "../src/retriever.ts";
6
6
  import { backfillAll, syncChangedHistory, type BackfillStats } from "../src/backfill.ts";
7
- import { migrateCodexProjectSessions, type ProjectSessionMigrationStats } from "../src/session-migration.ts";
7
+ import { migrateClaudeProjectSessions, migrateCodexProjectSessions, type ProjectSessionMigrationStats } from "../src/session-migration.ts";
8
8
  import { SESSION_MEMORY_HELP } from "../src/helper.ts";
9
9
 
10
10
  /** Register lifecycle persistence, memory-management commands, and the recall tool with Pi. */
@@ -170,24 +170,48 @@ export default function (pi: ExtensionAPI) {
170
170
  });
171
171
 
172
172
  pi.registerCommand("project-session-migration", {
173
- description: "Convert this project's Codex sessions into separate Pi sessions that can be resumed",
173
+ description: "Convert current-project Codex sessions into separate native Pi sessions for /resume",
174
174
  /** Create independently resumable Pi session JSONL files from current-project Codex sessions. */
175
175
  handler: async (_args, ctx) => {
176
- _notifySessionMigration(ctx, migrateCodexProjectSessions(ctx.sessionManager.getCwd()));
176
+ _notifySessionMigration(ctx, "Codex", migrateCodexProjectSessions(ctx.sessionManager.getCwd()));
177
+ },
178
+ });
179
+
180
+ pi.registerCommand("project-claude-session-migration", {
181
+ description: "Convert current-project Claude Code sessions into separate native Pi sessions for /resume",
182
+ /** Create independently resumable Pi session JSONL files from current-project Claude Code sessions. */
183
+ handler: async (_args, ctx) => {
184
+ _notifySessionMigration(ctx, "Claude Code", migrateClaudeProjectSessions(ctx.sessionManager.getCwd()));
177
185
  },
178
186
  });
179
187
 
180
188
  pi.registerTool({
181
189
  name: "migrate_codex_project_sessions",
182
190
  label: "Migrate Codex Project Sessions",
183
- description: "Convert each Codex session for the current project into a separate native Pi session that the user can select with /resume. Call only when the user explicitly wants to continue prior Codex work as a resumable Pi session. This does not index history for recall_memory and must not be used for ordinary cross-client recall or durable-memory requests.",
184
- promptSnippet: "Convert this project's Codex sessions into separately resumable Pi sessions only when the user explicitly requests native Pi continuation.",
191
+ description: "Convert each Codex session for the current project into a separate native Pi session selectable with /resume. Call only when the user explicitly wants native Pi continuation of prior Codex work. This writes resumable sessions; it does not index history for recall_memory or manage durable memories.",
192
+ promptSnippet: "Use only when the user explicitly requests native Pi continuation of this project's prior Codex sessions.",
185
193
  parameters: Type.Object({}),
186
194
  /** Give the agent the same native Codex-to-Pi migration available through /project-session-migration. */
187
195
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
188
196
  const stats = migrateCodexProjectSessions(ctx.sessionManager.getCwd());
189
197
  return {
190
- content: [{ type: "text" as const, text: _sessionMigrationSummary(stats) }],
198
+ content: [{ type: "text" as const, text: _sessionMigrationSummary("Codex", stats) }],
199
+ details: stats,
200
+ };
201
+ },
202
+ });
203
+
204
+ pi.registerTool({
205
+ name: "migrate_claude_project_sessions",
206
+ label: "Migrate Claude Code Project Sessions",
207
+ description: "Convert each Claude Code session for the current project into a separate native Pi session selectable with /resume. Call only when the user explicitly wants native Pi continuation of prior Claude Code work. This writes resumable sessions; it does not index history for recall_memory or manage durable memories.",
208
+ promptSnippet: "Use only when the user explicitly requests native Pi continuation of this project's prior Claude Code sessions.",
209
+ parameters: Type.Object({}),
210
+ /** Give the agent an explicit Claude Code-to-Pi native-session migration capability. */
211
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
212
+ const stats = migrateClaudeProjectSessions(ctx.sessionManager.getCwd());
213
+ return {
214
+ content: [{ type: "text" as const, text: _sessionMigrationSummary("Claude Code", stats) }],
191
215
  details: stats,
192
216
  };
193
217
  },
@@ -300,13 +324,13 @@ function _notifyBackfill(ctx: ExtensionContext, stats: BackfillStats, action: st
300
324
  for (const issue of stats.issues) ctx.ui.notify(`[session-memory] ${issue.error}`, "error");
301
325
  }
302
326
 
303
- /** Report native Codex-to-Pi session migration results and isolated conversion failures. */
304
- function _notifySessionMigration(ctx: ExtensionContext, stats: ProjectSessionMigrationStats): void {
305
- ctx.ui.notify(_sessionMigrationSummary(stats), "info");
306
- for (const issue of stats.issues) ctx.ui.notify(`[session-memory] Codex migration failed (${issue.path}): ${issue.error}`, "error");
327
+ /** Report native source-to-Pi session migration results and isolated conversion failures. */
328
+ function _notifySessionMigration(ctx: ExtensionContext, sourceLabel: string, stats: ProjectSessionMigrationStats): void {
329
+ ctx.ui.notify(_sessionMigrationSummary(sourceLabel, stats), "info");
330
+ for (const issue of stats.issues) ctx.ui.notify(`[session-memory] ${sourceLabel} migration failed (${issue.path}): ${issue.error}`, "error");
307
331
  }
308
332
 
309
333
  /** Format a concise native-session migration result for commands and tools. */
310
- function _sessionMigrationSummary(stats: ProjectSessionMigrationStats): string {
311
- return `[session-memory] migrated ${stats.migratedSessions} Codex sessions (${stats.migratedMessages} messages); skipped ${stats.skippedSessions} already migrated sessions from ${stats.scannedFiles} scanned files. Use /resume to select a migrated Pi session.`;
334
+ function _sessionMigrationSummary(sourceLabel: string, stats: ProjectSessionMigrationStats): string {
335
+ return `[session-memory] migrated ${stats.migratedSessions} ${sourceLabel} sessions (${stats.migratedMessages} messages); skipped ${stats.skippedSessions} already migrated sessions from ${stats.scannedFiles} scanned files. Use /resume to select a migrated Pi session.`;
312
336
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-memory",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Persistent, local-first cross-session memory for Pi, with SQLite-backed paginated recall and freshness-evidence comparison across Pi, Claude Code, and Codex conversations",
5
5
  "keywords": [
6
6
  "pi-package"
package/src/helper.ts CHANGED
@@ -9,15 +9,15 @@ You can request history and memory features in natural language.
9
9
  无论对话来自 Pi、Claude Code 还是 Codex,你都可以直接请求回忆历史或保存、管理长期记忆。
10
10
  Regardless of whether a conversation came from Pi, Claude Code, or Codex, you can directly recall history and save or manage durable memories.
11
11
 
12
- - **在 Pi 中无缝接续 Codex 项目会话 / Seamlessly continue Codex project sessions in Pi**
13
- - 只有当你明确希望在 Pi 中接续某个项目的 Codex 历史工作流时,才建议迁移项目会话。
14
- - Migrate sessions only when you explicitly want to continue a project's Codex workflow seamlessly in Pi.
15
- - “请把当前项目以前的 Codex 会话迁移成 Pi session。”
16
- - “Convert this project's previous Codex sessions into Pi sessions.”
17
- - 每个 Codex session 会成为一个独立的 Pi session;完成后用 \`/resume\` 选择要继续的会话。
18
- - Each Codex session becomes an independent Pi session; use \`/resume\` to select the one you want to continue.
19
- - 只迁移记录的工作目录与当前项目一致的 Codex 会话。
20
- - Only Codex sessions whose recorded working directory matches the current project are migrated.
12
+ - **在 Pi 中无缝接续 Claude Code 或 Codex 项目会话 / Seamlessly continue Claude Code or Codex project sessions in Pi**
13
+ - 只有当你明确希望在 Pi 中接续某个项目的 Claude Code 或 Codex 历史工作流时,才建议迁移项目会话。
14
+ - Migrate sessions only when you explicitly want to continue a project's Claude Code or Codex workflow seamlessly in Pi.
15
+ - “请把当前项目以前的 Claude Code 会话迁移成 Pi session。”或“请把当前项目以前的 Codex 会话迁移成 Pi session。”
16
+ - “Convert this project's previous Claude Code sessions into Pi sessions.” or “Convert this project's previous Codex sessions into Pi sessions.”
17
+ - 每个迁移的 session 会成为一个独立的 Pi session;完成后用 \`/resume\` 选择要继续的会话。
18
+ - Each migrated session becomes an independent Pi session; use \`/resume\` to select the one you want to continue.
19
+ - 只迁移记录的工作目录与当前项目一致的会话。
20
+ - Only sessions whose recorded working directory matches the current project are migrated.
21
21
 
22
22
  - **回忆以前的讨论 / Recall past discussions**
23
23
  - “我们之前讨论过 xxx 的什么方案?”、“找一下我以前关于 xxx 的结论。”
@@ -3,18 +3,28 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSyn
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
- interface CodexMessage {
6
+ type MigrationSource = "claude" | "codex";
7
+ type Role = "user" | "assistant";
8
+
9
+ interface MigratedMessage {
7
10
  id: string;
8
- role: "user" | "assistant";
11
+ role: Role;
9
12
  text: string;
10
13
  timestamp: number;
11
14
  }
12
15
 
13
- interface CodexSession {
16
+ interface SourceSession {
14
17
  id: string;
15
18
  cwd: string;
16
19
  timestamp: number;
17
- messages: CodexMessage[];
20
+ messages: MigratedMessage[];
21
+ }
22
+
23
+ interface MigrationDefinition {
24
+ source: MigrationSource;
25
+ root: string;
26
+ displayName: string;
27
+ parse: (path: string) => SourceSession | undefined;
18
28
  }
19
29
 
20
30
  export interface ProjectSessionMigrationStats {
@@ -25,8 +35,33 @@ export interface ProjectSessionMigrationStats {
25
35
  issues: Array<{ path: string; error: string }>;
26
36
  }
27
37
 
28
- /** Convert every Codex session recorded for cwd into an independently resumable Pi session file. */
38
+ const MIGRATION_SOURCES: Record<MigrationSource, MigrationDefinition> = {
39
+ claude: {
40
+ source: "claude",
41
+ root: join(homedir(), ".claude", "projects"),
42
+ displayName: "Claude Code",
43
+ parse: _parseClaudeSession,
44
+ },
45
+ codex: {
46
+ source: "codex",
47
+ root: join(homedir(), ".codex", "sessions"),
48
+ displayName: "Codex",
49
+ parse: _parseCodexSession,
50
+ },
51
+ };
52
+
53
+ /** Convert every current-project Claude Code session into an independently resumable Pi session file. */
54
+ export function migrateClaudeProjectSessions(cwd: string): ProjectSessionMigrationStats {
55
+ return _migrateProjectSessions(MIGRATION_SOURCES.claude, cwd);
56
+ }
57
+
58
+ /** Convert every current-project Codex session into an independently resumable Pi session file. */
29
59
  export function migrateCodexProjectSessions(cwd: string): ProjectSessionMigrationStats {
60
+ return _migrateProjectSessions(MIGRATION_SOURCES.codex, cwd);
61
+ }
62
+
63
+ /** Migrate one supported source while isolating malformed files from other source sessions. */
64
+ function _migrateProjectSessions(definition: MigrationDefinition, cwd: string): ProjectSessionMigrationStats {
30
65
  const stats: ProjectSessionMigrationStats = {
31
66
  scannedFiles: 0,
32
67
  migratedSessions: 0,
@@ -34,17 +69,17 @@ export function migrateCodexProjectSessions(cwd: string): ProjectSessionMigratio
34
69
  migratedMessages: 0,
35
70
  issues: [],
36
71
  };
37
- for (const path of _jsonlFiles(join(homedir(), ".codex", "sessions"))) {
72
+ for (const path of _jsonlFiles(definition.root)) {
38
73
  stats.scannedFiles++;
39
74
  try {
40
- const session = _parseCodexSession(path);
75
+ const session = definition.parse(path);
41
76
  if (!session || session.cwd !== cwd) continue;
42
- const outputPath = _targetPath(session);
77
+ const outputPath = _targetPath(definition.source, session);
43
78
  if (existsSync(outputPath)) {
44
79
  stats.skippedSessions++;
45
80
  continue;
46
81
  }
47
- _writePiSession(session, outputPath);
82
+ _writePiSession(definition, session, outputPath);
48
83
  stats.migratedSessions++;
49
84
  stats.migratedMessages += session.messages.length;
50
85
  } catch (error) {
@@ -54,9 +89,40 @@ export function migrateCodexProjectSessions(cwd: string): ProjectSessionMigratio
54
89
  return stats;
55
90
  }
56
91
 
57
- /** Convert one supported Codex JSONL file into its session metadata and textual messages. */
58
- function _parseCodexSession(path: string): CodexSession | undefined {
59
- const entries = readFileSync(path, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line) as Record<string, unknown>);
92
+ /** Convert one Claude Code JSONL file into supported textual user and assistant messages. */
93
+ function _parseClaudeSession(path: string): SourceSession | undefined {
94
+ const entries = _readJsonl(path);
95
+ const firstConversation = entries.find((entry) =>
96
+ (entry.type === "user" || entry.type === "assistant") && !entry.isMeta && !entry.isSidechain,
97
+ );
98
+ if (!firstConversation) return undefined;
99
+ const id = _string(firstConversation.sessionId);
100
+ const cwd = _string(firstConversation.cwd);
101
+ const timestamp = _timestamp(_string(firstConversation.timestamp));
102
+ if (!id || !cwd || timestamp === undefined) throw new Error("Claude Code conversation requires sessionId, cwd, and timestamp");
103
+
104
+ const messages: MigratedMessage[] = [];
105
+ for (const [index, entry] of entries.entries()) {
106
+ if ((entry.type !== "user" && entry.type !== "assistant") || entry.isMeta || entry.isSidechain) continue;
107
+ const role = _role(entry.message?.role);
108
+ if (!role) continue;
109
+ const text = _claudeText(entry.message?.content);
110
+ if (!text || (role === "user" && _isClaudeInjectedContext(text))) continue;
111
+ const messageId = _string(entry.uuid) ?? _string(entry.id);
112
+ if (!messageId) throw new Error(`Claude Code textual message at entry ${index} has no stable native ID`);
113
+ messages.push({
114
+ id: messageId,
115
+ role,
116
+ text,
117
+ timestamp: _timestamp(_string(entry.timestamp)) ?? timestamp,
118
+ });
119
+ }
120
+ return { id, cwd, timestamp, messages };
121
+ }
122
+
123
+ /** Convert one Codex JSONL file into supported textual user and assistant messages. */
124
+ function _parseCodexSession(path: string): SourceSession | undefined {
125
+ const entries = _readJsonl(path);
60
126
  const metadata = entries.find((entry) => entry.type === "session_meta")?.payload as Record<string, unknown> | undefined;
61
127
  if (!metadata) return undefined;
62
128
  const id = _string(metadata.session_id) ?? _string(metadata.id);
@@ -64,14 +130,14 @@ function _parseCodexSession(path: string): CodexSession | undefined {
64
130
  const timestamp = _timestamp(_string(metadata.timestamp));
65
131
  if (!id || !cwd || timestamp === undefined) throw new Error("Codex session_meta requires session_id/id, cwd, and timestamp");
66
132
 
67
- const messages: CodexMessage[] = [];
133
+ const messages: MigratedMessage[] = [];
68
134
  for (const [index, entry] of entries.entries()) {
69
135
  if (entry.type !== "response_item") continue;
70
136
  const payload = entry.payload as Record<string, unknown> | undefined;
71
137
  if (payload?.type !== "message") continue;
72
- const role = _string(payload.role);
73
- if (role !== "user" && role !== "assistant") continue;
74
- const text = _text(payload.content);
138
+ const role = _role(payload.role);
139
+ if (!role) continue;
140
+ const text = _codexText(payload.content);
75
141
  if (!text) continue;
76
142
  const messageId = _string(payload.id)
77
143
  ?? _string(entry.id)
@@ -87,8 +153,8 @@ function _parseCodexSession(path: string): CodexSession | undefined {
87
153
  return { id, cwd, timestamp, messages };
88
154
  }
89
155
 
90
- /** Write a valid Pi v3 session with a linear message branch and an explicit migration name. */
91
- function _writePiSession(session: CodexSession, path: string): void {
156
+ /** Write one valid Pi v3 session with a linear message branch and explicit migration provenance. */
157
+ function _writePiSession(definition: MigrationDefinition, session: SourceSession, path: string): void {
92
158
  mkdirSync(join(homedir(), ".pi", "agent", "sessions", _encodedCwd(session.cwd)), { recursive: true });
93
159
  const lines: string[] = [JSON.stringify({
94
160
  type: "session",
@@ -98,17 +164,17 @@ function _writePiSession(session: CodexSession, path: string): void {
98
164
  cwd: session.cwd,
99
165
  })];
100
166
  let parentId: string | null = null;
101
- const nameId = _entryId(session.id, "name");
167
+ const nameId = _entryId(definition.source, session.id, "name");
102
168
  lines.push(JSON.stringify({
103
169
  type: "session_info",
104
170
  id: nameId,
105
171
  parentId,
106
172
  timestamp: new Date(session.timestamp).toISOString(),
107
- name: `Migrated from Codex: ${session.id}`,
173
+ name: `Migrated from ${definition.displayName}: ${session.id}`,
108
174
  }));
109
175
  parentId = nameId;
110
176
  for (const message of session.messages) {
111
- const id = _entryId(session.id, message.id);
177
+ const id = _entryId(definition.source, session.id, message.id);
112
178
  const timestamp = new Date(message.timestamp).toISOString();
113
179
  lines.push(JSON.stringify({
114
180
  type: "message",
@@ -120,8 +186,8 @@ function _writePiSession(session: CodexSession, path: string): void {
120
186
  : {
121
187
  role: "assistant",
122
188
  content: [{ type: "text", text: message.text }],
123
- api: "codex-migration",
124
- provider: "codex",
189
+ api: `${definition.source}-migration`,
190
+ provider: definition.source,
125
191
  model: "unknown",
126
192
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
127
193
  stopReason: "stop",
@@ -139,9 +205,9 @@ function _writePiSession(session: CodexSession, path: string): void {
139
205
  }
140
206
  }
141
207
 
142
- /** Return the deterministic Pi session-file location for one Codex session. */
143
- function _targetPath(session: CodexSession): string {
144
- return join(homedir(), ".pi", "agent", "sessions", _encodedCwd(session.cwd), `${new Date(session.timestamp).toISOString().replace(/[.:]/g, "-")}_codex-${session.id}.jsonl`);
208
+ /** Return the deterministic Pi session-file location for one migrated source session. */
209
+ function _targetPath(source: MigrationSource, session: SourceSession): string {
210
+ return join(homedir(), ".pi", "agent", "sessions", _encodedCwd(session.cwd), `${new Date(session.timestamp).toISOString().replace(/[.:]/g, "-")}_${source}-${session.id}.jsonl`);
145
211
  }
146
212
 
147
213
  /** Encode cwd exactly as Pi's default session directory convention. */
@@ -149,9 +215,14 @@ function _encodedCwd(cwd: string): string {
149
215
  return `--${cwd.split("/").filter(Boolean).join("-")}--`;
150
216
  }
151
217
 
152
- /** Create stable, Pi-safe entry IDs without fabricating source message identity. */
153
- function _entryId(sessionId: string, sourceId: string): string {
154
- return createHash("sha256").update(`${sessionId}:${sourceId}`).digest("hex").slice(0, 16);
218
+ /** Create stable Pi-safe entry IDs while retaining source-specific identity namespaces. */
219
+ function _entryId(source: MigrationSource, sessionId: string, sourceId: string): string {
220
+ return createHash("sha256").update(`${source}:${sessionId}:${sourceId}`).digest("hex").slice(0, 16);
221
+ }
222
+
223
+ /** Accept user and assistant roles only. */
224
+ function _role(value: unknown): Role | undefined {
225
+ return value === "user" || value === "assistant" ? value : undefined;
155
226
  }
156
227
 
157
228
  /** Extract non-empty string values only. */
@@ -166,8 +237,28 @@ function _timestamp(value: string | undefined): number | undefined {
166
237
  return Number.isNaN(timestamp) ? undefined : timestamp;
167
238
  }
168
239
 
240
+ /** Extract Claude Code text from legacy string or text content blocks. */
241
+ function _claudeText(content: unknown): string {
242
+ if (typeof content === "string") return content.trim();
243
+ if (!Array.isArray(content)) return "";
244
+ return content
245
+ .filter((block): block is { type: string; text: string } => Boolean(block) && typeof block === "object" && (block as { type?: unknown }).type === "text" && typeof (block as { text?: unknown }).text === "string")
246
+ .map((block) => block.text)
247
+ .join("\n")
248
+ .trim();
249
+ }
250
+
251
+ /** Exclude Claude Code client-injected context from migrated user conversation. */
252
+ function _isClaudeInjectedContext(text: string): boolean {
253
+ return text.startsWith("<command-name>")
254
+ || text.startsWith("<command-message>")
255
+ || text.startsWith("<local-command-")
256
+ || text.startsWith("<task-notification>")
257
+ || text.startsWith("This session is being continued from a previous conversation");
258
+ }
259
+
169
260
  /** Join supported Codex text content blocks. */
170
- function _text(content: unknown): string {
261
+ function _codexText(content: unknown): string {
171
262
  if (!Array.isArray(content)) return "";
172
263
  return content
173
264
  .filter((block): block is { type: string; text: string } => Boolean(block) && typeof block === "object" && typeof (block as { type?: unknown }).type === "string" && typeof (block as { text?: unknown }).text === "string")
@@ -177,6 +268,11 @@ function _text(content: unknown): string {
177
268
  .trim();
178
269
  }
179
270
 
271
+ /** Read every non-empty JSONL line into its ordered JSON record. */
272
+ function _readJsonl(path: string): Record<string, any>[] {
273
+ return readFileSync(path, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line) as Record<string, any>);
274
+ }
275
+
180
276
  /** Recursively enumerate source JSONL files. */
181
277
  function _jsonlFiles(root: string): string[] {
182
278
  if (!existsSync(root)) return [];