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/README.md +31 -180
- package/extensions/index.ts +74 -274
- package/package.json +5 -3
- package/src/backfill.ts +16 -4
- package/src/db.ts +33 -336
- package/src/fetch-session.ts +8 -0
- package/src/helper.ts +21 -41
- package/src/retriever.ts +25 -289
- package/src/session-migration.ts +126 -30
- package/src/writer.ts +5 -2
package/src/retriever.ts
CHANGED
|
@@ -1,293 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getDb, type MemoryKind } from "./db.ts";
|
|
1
|
+
import { getDb } from "./db.ts";
|
|
3
2
|
|
|
4
|
-
export type
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
entities:
|
|
9
|
-
|
|
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(() =>
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
}
|
package/src/session-migration.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
+
type MigrationSource = "claude" | "codex";
|
|
7
|
+
type Role = "user" | "assistant";
|
|
8
|
+
|
|
9
|
+
interface MigratedMessage {
|
|
7
10
|
id: string;
|
|
8
|
-
role:
|
|
11
|
+
role: Role;
|
|
9
12
|
text: string;
|
|
10
13
|
timestamp: number;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
interface
|
|
16
|
+
interface SourceSession {
|
|
14
17
|
id: string;
|
|
15
18
|
cwd: string;
|
|
16
19
|
timestamp: number;
|
|
17
|
-
messages:
|
|
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
|
-
|
|
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(
|
|
72
|
+
for (const path of _jsonlFiles(definition.root)) {
|
|
38
73
|
stats.scannedFiles++;
|
|
39
74
|
try {
|
|
40
|
-
const session =
|
|
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
|
|
58
|
-
function
|
|
59
|
-
const entries =
|
|
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:
|
|
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 =
|
|
73
|
-
if (role
|
|
74
|
-
const text =
|
|
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
|
|
91
|
-
function _writePiSession(session:
|
|
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
|
|
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:
|
|
124
|
-
provider:
|
|
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
|
|
143
|
-
function _targetPath(session:
|
|
144
|
-
return join(homedir(), ".pi", "agent", "sessions", _encodedCwd(session.cwd), `${new Date(session.timestamp).toISOString().replace(/[.:]/g, "-")}
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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()) {
|