pi-session-memory 0.3.1 → 0.6.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/src/retriever.ts CHANGED
@@ -1,293 +1,29 @@
1
- import { createHash } from "node:crypto";
2
- import { getDb, type MemoryKind } from "./db.ts";
1
+ import { getDb } from "./db.ts";
3
2
 
4
- export type MemorySource = "pi" | "claude" | "codex";
3
+ export type HistorySource = "pi" | "claude" | "codex";
4
+ export interface RecallOptions { entities: string[]; sources?: HistorySource[]; cwd?: string; after?: number; before?: number; }
5
+ export interface RecallTurnResult { turn_id: string; session_id: string; turn_index: number; source: HistorySource; cwd: string; ts: number; user_text: string; reply_text: string; score: number; }
5
6
 
6
- export interface RecallOptions {
7
- /** High-signal literal alternatives; a result may match any entity. */
8
- entities: string[];
9
- sources?: MemorySource[];
10
- cwd?: string;
11
- after?: number;
12
- before?: number;
13
- }
14
-
15
- export interface RecallTurnResult {
16
- type: "turn";
17
- turn_id: string;
18
- session_id: string;
19
- turn_index: number;
20
- source: MemorySource;
21
- cwd: string;
22
- ts: number;
23
- user_text: string;
24
- reply_text: string;
25
- hits: number;
26
- score: number;
27
- }
28
-
29
- export interface FreshnessEvidence {
30
- turn_id: string;
31
- session_id: string;
32
- turn_index: number;
33
- source: MemorySource;
34
- ts: number;
35
- relation: "same_source_session_later" | "newer_cross_session";
36
- excerpt: string;
37
- }
38
-
39
- export interface RecallDurableMemoryResult {
40
- type: "memory";
41
- memory_id: string;
42
- kind: MemoryKind;
43
- content: string;
44
- project_key: string;
45
- source_turn_id: string | null;
46
- source_session_id: string | null;
47
- source_content_hash: string | null;
48
- source_turn_index: number | null;
49
- source_session_changed: boolean;
50
- freshness_candidate: boolean;
51
- freshness_evidence: FreshnessEvidence[];
52
- created_at: number;
53
- last_confirmed_at: number;
54
- importance: number;
55
- hits: number;
56
- score: number;
57
- }
58
-
59
- export type RecallResult = RecallDurableMemoryResult | RecallTurnResult;
60
-
61
- export const RECALL_PAGE_SIZE = 5;
62
-
63
- export interface RecallPage {
64
- results: RecallResult[];
65
- offset: number;
66
- totalResults: number;
67
- nextOffset: number | null;
68
- }
69
-
70
- // Recency is a bounded tie-breaker, not a replacement for literal relevance.
71
- const RECENCY_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
72
-
73
- /** Retained for compatibility with the v0.1 public retrieval helper. */
74
- export function recallTurns(entities: string[]): RecallTurnResult[] {
75
- return _recallTurns({ entities });
76
- }
77
-
78
- /** Retrieve active durable memories, then raw turns not already represented by unchanged source evidence. */
79
- export function recallMemories(options: RecallOptions): RecallResult[] {
80
- const durableMemories = _recallDurableMemories(options);
81
- const recalledTurns = _recallTurns(options);
82
- const memories = durableMemories.map((memory) => {
83
- const freshness_evidence = _freshnessEvidence(memory, recalledTurns);
84
- return {
85
- ...memory,
86
- source_session_changed: _sourceSessionChanged(memory),
87
- freshness_candidate: freshness_evidence.length > 0,
88
- freshness_evidence,
89
- };
90
- });
91
- const coveredSourceHashes = new Map(
92
- memories
93
- .filter((memory) => memory.source_turn_id && memory.source_content_hash)
94
- .map((memory) => [memory.source_turn_id!, memory.source_content_hash!]),
95
- );
96
- const rawTurns = recalledTurns.filter((turn) => coveredSourceHashes.get(turn.turn_id) !== _turnContentHash(turn));
97
- return [...memories, ...rawTurns];
98
- }
99
-
100
- /** Select one fixed-size recall page without limiting the complete local retrieval result. */
101
- export function paginateRecallResults(results: RecallResult[], offset = 0): RecallPage {
102
- if (!Number.isInteger(offset) || offset < 0) throw new Error("Recall offset must be a non-negative integer");
103
- const pageResults = results.slice(offset, offset + RECALL_PAGE_SIZE);
104
- const nextOffset = offset + pageResults.length < results.length ? offset + pageResults.length : null;
105
- return { results: pageResults, offset, totalResults: results.length, nextOffset };
106
- }
107
-
108
- /** Render the exact entity inputs and recall results as concise Markdown for a command notification or tool response. */
109
- export function formatRecallResults(results: RecallResult[], options?: Pick<RecallOptions, "entities" | "sources" | "cwd" | "after" | "before">, page?: Omit<RecallPage, "results">): string {
110
- const lines = options ? [_formatRecallQuery(options), ""] : [];
111
- if (results.length === 0) return [...lines, page && page.totalResults > 0 ? `No results at offset ${page.offset}; the matching result set contains ${page.totalResults} result(s).` : "No relevant past conversations found."].join("\n");
112
-
113
- if (page) lines.push(`**Results:** ${page.offset + 1}–${page.offset + results.length} of ${page.totalResults} (five results per page)\n`);
114
- lines.push("## Relevant past memories\n");
115
- for (const result of results) {
116
- if (result.type === "memory") {
117
- lines.push(`### [durable ${result.kind} · ${new Date(result.last_confirmed_at).toLocaleString()}]`);
118
- lines.push(result.content);
119
- lines.push(`**Memory ID:** ${result.memory_id}`);
120
- if (result.source_turn_id) lines.push(`**Source turn:** ${result.source_turn_id}`);
121
- if (result.source_session_id) lines.push(`**Source session:** ${result.source_session_id}`);
122
- if (result.source_session_changed) lines.push("**Source session changed:** later turns exist; this alone does not mean the memory is stale.");
123
- if (result.freshness_candidate) {
124
- lines.push("**Newer evidence to compare:**");
125
- for (const evidence of result.freshness_evidence) {
126
- lines.push(`- [${evidence.relation.replaceAll("_", " ")} · ${evidence.source} · ${new Date(evidence.ts).toLocaleString()} · ${evidence.session_id} · turn ${evidence.turn_index}] ${evidence.excerpt}`);
127
- }
128
- lines.push("Compare this evidence with the durable memory; it may confirm, supplement, conflict with, or replace it. Do not change the memory without the user's explicit choice.");
129
- }
130
- } else {
131
- const date = new Date(result.ts).toLocaleString();
132
- lines.push(`### [${result.source} · ${date}]`);
133
- lines.push(`**Session:** ${result.session_id} · **Turn:** ${result.turn_index}`);
134
- lines.push(`**Excerpt:** ${_excerpt(result.user_text || result.reply_text)}`);
135
- lines.push("Use `fetch_session` with this session ID when the surrounding conversation is needed.");
136
- }
137
- lines.push("");
138
- }
139
- if (page?.nextOffset !== null && page?.nextOffset !== undefined) {
140
- lines.push(`More matching results exist. To retrieve the next five, call \`recall_memory\` again with every same search/filter parameter and \`offset: ${page.nextOffset}\`.`);
141
- }
142
- return lines.join("\n");
143
- }
144
-
145
- /** Make each tool invocation auditable by showing its literal entities separately from its scope. */
146
- function _formatRecallQuery(options: Pick<RecallOptions, "entities" | "sources" | "cwd" | "after" | "before">): string {
147
- const scope = [
148
- options.sources?.length ? `sources: ${options.sources.join(", ")}` : null,
149
- options.cwd ? `cwd: \`${options.cwd}\`` : null,
150
- options.after !== undefined ? `after: ${new Date(options.after).toISOString()}` : null,
151
- options.before !== undefined ? `before: ${new Date(options.before).toISOString()}` : null,
152
- ].filter(Boolean);
153
- return `**Search entities:** ${options.entities.map((entity) => `\`${entity}\``).join(", ")}${scope.length ? ` \\n**Scope:** ${scope.join(" · ")}` : ""}`;
154
- }
155
-
156
- /** Keep discovery results small; full persisted turn text belongs to fetch_session. */
157
- function _excerpt(text: string, maxLength = 240): string {
158
- const normalized = text.replaceAll(/\s+/g, " ").trim();
159
- return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}…` : normalized;
160
- }
161
-
162
- /** Search active durable memories with the same escaped literal matching used for transcript recall. */
163
- function _recallDurableMemories(options: RecallOptions): RecallDurableMemoryResult[] {
164
- const terms = _terms(options);
165
- if (terms.length === 0) return [];
166
- const scoreExpression = terms.map(() => "CASE WHEN LOWER(content) LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END").join(" + ");
167
- const parameters = terms.map(_likePattern);
168
- const filters = [`(${terms.map(() => "LOWER(content) LIKE ? ESCAPE '\\'").join(" OR ")})`];
169
- const filterParameters: Array<string | number> = terms.map(_likePattern);
170
- filters.push("superseded_by IS NULL");
171
- if (options.cwd) {
172
- filters.push("project_key = ?");
173
- filterParameters.push(options.cwd);
174
- }
175
- if (options.after !== undefined) {
176
- filters.push("last_confirmed_at >= ?");
177
- filterParameters.push(options.after);
178
- }
179
- if (options.before !== undefined) {
180
- filters.push("last_confirmed_at <= ?");
181
- filterParameters.push(options.before);
182
- }
183
- return getDb().prepare(`
184
- SELECT memory_id, kind, content, project_key, source_turn_id, source_session_id, source_content_hash, source_turn_index, created_at, last_confirmed_at, importance,
185
- (${scoreExpression}) AS hits
186
- FROM memories
187
- WHERE ${filters.join(" AND ")}
188
- ORDER BY hits DESC, importance DESC, last_confirmed_at DESC
189
- `).all(...parameters, ...filterParameters)
190
- .map((memory) => ({ ...memory, type: "memory" as const, source_session_changed: false, freshness_candidate: false, freshness_evidence: [], score: memory.hits + memory.importance })) as RecallDurableMemoryResult[];
191
- }
192
-
193
- /** Detect later activity in the original session independently of this recall query and ranking. */
194
- function _sourceSessionChanged(memory: RecallDurableMemoryResult): boolean {
195
- if (!memory.source_session_id || memory.source_turn_index === null) return false;
196
- return getDb().prepare(`
197
- SELECT 1 FROM turns
198
- WHERE session_id = ? AND turn_index > ?
199
- LIMIT 1
200
- `).get(memory.source_session_id, memory.source_turn_index) !== undefined;
201
- }
202
-
203
- /** Associate each memory with all newer query-relevant turns without deciding their semantic relationship. */
204
- function _freshnessEvidence(memory: RecallDurableMemoryResult, recalledTurns: RecallTurnResult[]): FreshnessEvidence[] {
205
- const baselineTs = _memoryEvidenceTimestamp(memory);
206
- return recalledTurns
207
- .filter((turn) => {
208
- if (memory.source_session_id === turn.session_id && memory.source_turn_index !== null) {
209
- return turn.turn_index > memory.source_turn_index;
210
- }
211
- return turn.ts > baselineTs;
212
- })
213
- .map((turn) => ({
214
- turn_id: turn.turn_id,
215
- session_id: turn.session_id,
216
- turn_index: turn.turn_index,
217
- source: turn.source,
218
- ts: turn.ts,
219
- relation: memory.source_session_id === turn.session_id ? "same_source_session_later" as const : "newer_cross_session" as const,
220
- excerpt: _excerpt(turn.user_text || turn.reply_text),
221
- }));
222
- }
223
-
224
- /** Use source-turn time when available; explicit memories become comparable from their creation time. */
225
- function _memoryEvidenceTimestamp(memory: RecallDurableMemoryResult): number {
226
- if (!memory.source_turn_id) return memory.created_at;
227
- const source = getDb().prepare("SELECT ts FROM turns WHERE turn_id = ?").get(memory.source_turn_id) as { ts: number } | undefined;
228
- return source?.ts ?? memory.created_at;
229
- }
230
-
231
- /** Retrieve and rank locally stored turns using literal query terms and optional scopes. */
232
- function _recallTurns(options: RecallOptions): RecallTurnResult[] {
233
- const terms = _terms(options);
7
+ /** Search every matching raw local transcript turn using literal entity alternatives and strict optional scope filters. */
8
+ export function recallTurns(options: RecallOptions | string[]): RecallTurnResult[] {
9
+ const normalized = Array.isArray(options) ? { entities: options } : options;
10
+ const terms = normalized.entities.map((entity) => entity.toLowerCase());
234
11
  if (terms.length === 0) return [];
235
- const scoreExpression = terms.map(() => `(CASE WHEN LOWER(turns.user_text) LIKE ? ESCAPE '\\' THEN 2 ELSE 0 END + CASE WHEN LOWER(turns.reply_text) LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END)`).join(" + ");
236
- const scoreParameters = terms.flatMap((term) => _likeParameters(term));
237
- const whereExpressions = terms.map(() => "(LOWER(turns.user_text) LIKE ? ESCAPE '\\' OR LOWER(turns.reply_text) LIKE ? ESCAPE '\\')");
238
- const whereParameters = terms.flatMap((term) => _likeParameters(term));
239
- const filters = [`(${whereExpressions.join(" OR ")})`];
240
- const filterParameters: Array<string | number> = [...whereParameters];
241
- if (options.sources?.length) {
242
- filters.push(`sessions.source IN (${options.sources.map(() => "?").join(", ")})`);
243
- filterParameters.push(...options.sources);
244
- }
245
- if (options.cwd) {
246
- filters.push("sessions.cwd = ?");
247
- filterParameters.push(options.cwd);
248
- }
249
- if (options.after !== undefined) {
250
- filters.push("turns.ts >= ?");
251
- filterParameters.push(options.after);
252
- }
253
- if (options.before !== undefined) {
254
- filters.push("turns.ts <= ?");
255
- filterParameters.push(options.before);
256
- }
257
- const candidates = getDb().prepare(`
258
- SELECT turns.turn_id, turns.session_id, turns.turn_index, sessions.source, sessions.cwd, turns.ts, turns.user_text, turns.reply_text,
259
- (${scoreExpression}) AS hits
260
- FROM turns JOIN sessions ON sessions.session_id = turns.session_id
261
- WHERE ${filters.join(" AND ")}
262
- ORDER BY hits DESC, turns.ts DESC
263
- `).all(...scoreParameters, ...filterParameters) as Array<Omit<RecallTurnResult, "type" | "score">>;
264
- const newestTs = candidates.reduce((newest, result) => Math.max(newest, result.ts), 0);
265
- const results = candidates.map((result) => ({ ...result, type: "turn" as const, score: result.hits + _recencyScore(result.ts, newestTs) + (options.cwd === result.cwd ? 0.8 : 0) })).sort((left, right) => right.score - left.score || right.ts - left.ts);
266
- return results;
267
- }
268
-
269
- /** Hash the current raw turn evidence using the same representation captured during pinning. */
270
- function _turnContentHash(turn: RecallTurnResult): string {
271
- return createHash("sha256").update(JSON.stringify([turn.user_text, turn.reply_text])).digest("hex");
272
- }
273
-
274
- /** Build a de-duplicated set of non-empty literal search entities from the request. */
275
- function _terms(options: RecallOptions): string[] {
276
- return [...new Set(options.entities.map((entity) => entity.trim()).filter(Boolean))];
277
- }
278
-
279
- /** Produce matching user and assistant SQL LIKE parameters for one term. */
280
- function _likeParameters(term: string): [string, string] {
281
- const pattern = _likePattern(term);
282
- return [pattern, pattern];
283
- }
284
-
285
- /** Convert one literal search term into an escaped, case-normalized SQL LIKE pattern. */
286
- function _likePattern(term: string): string {
287
- return `%${term.toLowerCase().replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
288
- }
289
-
290
- /** Return a bounded recency bonus relative to the newest candidate timestamp. */
291
- function _recencyScore(ts: number, newestTs: number): number {
292
- return Math.max(0, 0.5 * (1 - (newestTs - ts) / RECENCY_WINDOW_MS));
12
+ const scoreExpression = terms.map(() => "CASE WHEN instr(lower(turns.user_text || ' ' || turns.reply_text), ?) > 0 THEN 1 ELSE 0 END").join(" + ");
13
+ const filters = ["(" + terms.map(() => "instr(lower(turns.user_text || ' ' || turns.reply_text), ?) > 0").join(" OR ") + ")"];
14
+ const scopedParameters: Array<string | number> = [];
15
+ if (normalized.sources?.length) { filters.push(`sessions.source IN (${normalized.sources.map(() => "?").join(", ")})`); scopedParameters.push(...normalized.sources); }
16
+ if (normalized.cwd !== undefined) { filters.push("sessions.cwd = ?"); scopedParameters.push(normalized.cwd); }
17
+ if (normalized.after !== undefined) { filters.push("turns.ts >= ?"); scopedParameters.push(normalized.after); }
18
+ if (normalized.before !== undefined) { filters.push("turns.ts <= ?"); scopedParameters.push(normalized.before); }
19
+ return getDb().prepare(`SELECT turns.turn_id, turns.session_id, turns.turn_index, sessions.source, sessions.cwd, turns.ts, turns.user_text, turns.reply_text, (${scoreExpression}) AS score FROM turns JOIN sessions ON sessions.session_id = turns.session_id WHERE ${filters.join(" AND ")} ORDER BY score DESC, turns.ts DESC, turns.turn_id`).all(...terms, ...terms, ...scopedParameters) as RecallTurnResult[];
20
+ }
21
+
22
+ /** Render all raw transcript matches without implying that they are persistent summary memories. */
23
+ export function formatRecallResults(results: RecallTurnResult[], options: RecallOptions = { entities: [] }): string {
24
+ const header = ["# Recall results", `**Entities:** ${options.entities.map((entity) => `\`${entity}\``).join(", ")}`, `**Results:** ${results.length}`].join("\n");
25
+ const turns = results.length
26
+ ? results.map((turn) => `### ${turn.session_id} · turn ${turn.turn_index}\n**Source turn ID:** \`${turn.turn_id}\`\n**You:** ${turn.user_text}\n${turn.reply_text ? `**Assistant:** ${turn.reply_text}` : ""}`).join("\n\n")
27
+ : "No matching conversation history.";
28
+ return `${header}\n\n## Matching raw conversation history\n${turns}`;
293
29
  }
@@ -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 [];
package/src/writer.ts CHANGED
@@ -2,7 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AssistantMessage } from "@earendil-works/pi-ai";
3
3
  import { upsertSession, insertTurn } from "./db.ts";
4
4
 
5
- /** Persist the latest Pi user request and all following assistant output as one turn. */
5
+ /** Persist the latest Pi user request and assistant output until the next user message as one turn. */
6
6
  export function writeTurn(ctx: ExtensionContext): void {
7
7
  const sessionManager = ctx.sessionManager;
8
8
  const sessionId = `pi:${sessionManager.getSessionId()}`;
@@ -19,7 +19,10 @@ export function writeTurn(ctx: ExtensionContext): void {
19
19
  const toolNames: string[] = [];
20
20
  const userIndex = branch.indexOf(userEntry);
21
21
  for (const entry of branch.slice(userIndex + 1)) {
22
- if (entry.type !== "message" || entry.message.role !== "assistant") continue;
22
+ if (entry.type !== "message") continue;
23
+ // A later user message begins a different turn and must never be aggregated.
24
+ if (entry.message.role === "user") break;
25
+ if (entry.message.role !== "assistant") continue;
23
26
  const message = entry.message as AssistantMessage;
24
27
  for (const block of message.content) {
25
28
  if (block.type === "text" && block.text.trim()) {