pi-session-memory 0.1.4 → 0.2.1
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 +101 -12
- package/extensions/index.ts +254 -19
- package/package.json +6 -2
- package/src/backfill.ts +102 -24
- package/src/db.ts +254 -4
- package/src/helper.ts +44 -0
- package/src/retriever.ts +213 -48
- package/src/session-migration.ts +188 -0
- package/src/writer.ts +3 -0
- package/spec.md +0 -172
- package/tests/core.test.ts +0 -68
- package/tsconfig.json +0 -10
package/src/retriever.ts
CHANGED
|
@@ -1,70 +1,235 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { getDb, type MemoryKind } from "./db.ts";
|
|
2
3
|
|
|
3
|
-
export
|
|
4
|
+
export type MemorySource = "pi" | "claude" | "codex";
|
|
5
|
+
|
|
6
|
+
export interface RecallOptions {
|
|
7
|
+
query: string;
|
|
8
|
+
entities?: string[];
|
|
9
|
+
sources?: MemorySource[];
|
|
10
|
+
cwd?: string;
|
|
11
|
+
after?: number;
|
|
12
|
+
before?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RecallTurnResult {
|
|
16
|
+
type: "turn";
|
|
4
17
|
turn_id: string;
|
|
5
|
-
|
|
18
|
+
session_id: string;
|
|
19
|
+
turn_index: number;
|
|
20
|
+
source: MemorySource;
|
|
21
|
+
cwd: string;
|
|
6
22
|
ts: number;
|
|
7
23
|
user_text: string;
|
|
8
24
|
reply_text: string;
|
|
9
25
|
hits: number;
|
|
26
|
+
score: number;
|
|
10
27
|
}
|
|
11
28
|
|
|
12
|
-
export
|
|
13
|
-
|
|
29
|
+
export interface RecallDurableMemoryResult {
|
|
30
|
+
type: "memory";
|
|
31
|
+
memory_id: string;
|
|
32
|
+
kind: MemoryKind;
|
|
33
|
+
content: string;
|
|
34
|
+
project_key: string;
|
|
35
|
+
source_turn_id: string | null;
|
|
36
|
+
source_session_id: string | null;
|
|
37
|
+
source_content_hash: string | null;
|
|
38
|
+
source_turn_index: number | null;
|
|
39
|
+
freshness_candidate: boolean;
|
|
40
|
+
created_at: number;
|
|
41
|
+
last_confirmed_at: number;
|
|
42
|
+
importance: number;
|
|
43
|
+
hits: number;
|
|
44
|
+
score: number;
|
|
45
|
+
}
|
|
14
46
|
|
|
15
|
-
|
|
16
|
-
CASE WHEN LOWER(turns.user_text) LIKE ? ESCAPE '\\' THEN 2 ELSE 0 END +
|
|
17
|
-
CASE WHEN LOWER(turns.reply_text) LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END
|
|
18
|
-
)`).join(" + ");
|
|
19
|
-
const scoreParameters = entities.flatMap((entity) => {
|
|
20
|
-
const pattern = _likePattern(entity);
|
|
21
|
-
return [pattern, pattern];
|
|
22
|
-
});
|
|
47
|
+
export type RecallResult = RecallDurableMemoryResult | RecallTurnResult;
|
|
23
48
|
|
|
24
|
-
|
|
25
|
-
LOWER(turns.user_text) LIKE ? ESCAPE '\\' OR
|
|
26
|
-
LOWER(turns.reply_text) LIKE ? ESCAPE '\\'
|
|
27
|
-
)`).join(" OR ");
|
|
28
|
-
const whereParameters = entities.flatMap((entity) => {
|
|
29
|
-
const pattern = _likePattern(entity);
|
|
30
|
-
return [pattern, pattern];
|
|
31
|
-
});
|
|
49
|
+
export const RECALL_PAGE_SIZE = 5;
|
|
32
50
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
`).all(...scoreParameters, ...whereParameters, topK) as RecallResult[];
|
|
51
|
+
export interface RecallPage {
|
|
52
|
+
results: RecallResult[];
|
|
53
|
+
offset: number;
|
|
54
|
+
totalResults: number;
|
|
55
|
+
nextOffset: number | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Recency is a bounded tie-breaker, not a replacement for literal relevance.
|
|
59
|
+
const RECENCY_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
|
|
60
|
+
|
|
61
|
+
/** Retained for compatibility with the v0.1 public retrieval helper. */
|
|
62
|
+
export function recallTurns(entities: string[]): RecallTurnResult[] {
|
|
63
|
+
return _recallTurns({ query: entities.join(" "), entities });
|
|
47
64
|
}
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
66
|
+
/** Retrieve active durable memories, then raw turns not already represented by unchanged source evidence. */
|
|
67
|
+
export function recallMemories(options: RecallOptions): RecallResult[] {
|
|
68
|
+
const durableMemories = _recallDurableMemories(options);
|
|
69
|
+
const recalledTurns = _recallTurns(options);
|
|
70
|
+
const freshnessCandidates = new Set(
|
|
71
|
+
durableMemories
|
|
72
|
+
.filter((memory) => memory.source_session_id && memory.source_turn_index !== null)
|
|
73
|
+
.filter((memory) => recalledTurns.some((turn) => turn.session_id === memory.source_session_id && turn.turn_index > memory.source_turn_index!))
|
|
74
|
+
.map((memory) => memory.memory_id),
|
|
75
|
+
);
|
|
76
|
+
const memories = durableMemories.map((memory) => ({ ...memory, freshness_candidate: freshnessCandidates.has(memory.memory_id) }));
|
|
77
|
+
const coveredSourceHashes = new Map(
|
|
78
|
+
memories
|
|
79
|
+
.filter((memory) => memory.source_turn_id && memory.source_content_hash)
|
|
80
|
+
.map((memory) => [memory.source_turn_id!, memory.source_content_hash!]),
|
|
81
|
+
);
|
|
82
|
+
const rawTurns = recalledTurns.filter((turn) => coveredSourceHashes.get(turn.turn_id) !== _turnContentHash(turn));
|
|
83
|
+
return [...memories, ...rawTurns];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Select one fixed-size recall page without limiting the complete local retrieval result. */
|
|
87
|
+
export function paginateRecallResults(results: RecallResult[], offset = 0): RecallPage {
|
|
88
|
+
if (!Number.isInteger(offset) || offset < 0) throw new Error("Recall offset must be a non-negative integer");
|
|
89
|
+
const pageResults = results.slice(offset, offset + RECALL_PAGE_SIZE);
|
|
90
|
+
const nextOffset = offset + pageResults.length < results.length ? offset + pageResults.length : null;
|
|
91
|
+
return { results: pageResults, offset, totalResults: results.length, nextOffset };
|
|
92
|
+
}
|
|
51
93
|
|
|
52
|
-
|
|
94
|
+
/** Render the exact query inputs and recall results as concise Markdown for a command notification or tool response. */
|
|
95
|
+
export function formatRecallResults(results: RecallResult[], options?: Pick<RecallOptions, "query" | "entities" | "sources" | "cwd" | "after" | "before">, page?: Omit<RecallPage, "results">): string {
|
|
96
|
+
const lines = options ? [_formatRecallQuery(options), ""] : [];
|
|
97
|
+
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");
|
|
98
|
+
|
|
99
|
+
if (page) lines.push(`**Results:** ${page.offset + 1}–${page.offset + results.length} of ${page.totalResults} (five results per page)\n`);
|
|
100
|
+
lines.push("## Relevant past memories\n");
|
|
53
101
|
for (const result of results) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
102
|
+
if (result.type === "memory") {
|
|
103
|
+
lines.push(`### [durable ${result.kind} · ${new Date(result.last_confirmed_at).toLocaleString()}]`);
|
|
104
|
+
lines.push(result.content);
|
|
105
|
+
lines.push(`**Memory ID:** ${result.memory_id}`);
|
|
106
|
+
if (result.source_turn_id) lines.push(`**Source turn:** ${result.source_turn_id}`);
|
|
107
|
+
if (result.source_session_id) lines.push(`**Source session:** ${result.source_session_id}`);
|
|
108
|
+
if (result.freshness_candidate) lines.push("**Freshness:** newer matching turn exists in the source session; confirm or supersede this memory.");
|
|
109
|
+
} else {
|
|
110
|
+
const date = new Date(result.ts).toLocaleString();
|
|
111
|
+
lines.push(`### [${result.source} · ${date}]`);
|
|
112
|
+
lines.push(`**Session:** ${result.session_id} · **Turn:** ${result.turn_index}`);
|
|
113
|
+
lines.push(`**Excerpt:** ${_excerpt(result.user_text || result.reply_text)}`);
|
|
114
|
+
lines.push("Use `fetch_session` with this session ID when the surrounding conversation is needed.");
|
|
62
115
|
}
|
|
63
116
|
lines.push("");
|
|
64
117
|
}
|
|
118
|
+
if (page?.nextOffset !== null && page?.nextOffset !== undefined) {
|
|
119
|
+
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}\`.`);
|
|
120
|
+
}
|
|
65
121
|
return lines.join("\n");
|
|
66
122
|
}
|
|
67
123
|
|
|
68
|
-
|
|
69
|
-
|
|
124
|
+
/** Make each tool invocation auditable by showing its exact literal terms and scopes. */
|
|
125
|
+
function _formatRecallQuery(options: Pick<RecallOptions, "query" | "entities" | "sources" | "cwd" | "after" | "before">): string {
|
|
126
|
+
const filters = [
|
|
127
|
+
options.entities?.length ? `entities: ${options.entities.map((entity) => `\`${entity}\``).join(", ")}` : null,
|
|
128
|
+
options.sources?.length ? `sources: ${options.sources.join(", ")}` : null,
|
|
129
|
+
options.cwd ? `cwd: \`${options.cwd}\`` : null,
|
|
130
|
+
options.after !== undefined ? `after: ${new Date(options.after).toISOString()}` : null,
|
|
131
|
+
options.before !== undefined ? `before: ${new Date(options.before).toISOString()}` : null,
|
|
132
|
+
].filter(Boolean);
|
|
133
|
+
return `**Search query:** \`${options.query}\`${filters.length ? ` \\n**Filters:** ${filters.join(" · ")}` : ""}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Keep discovery results small; full persisted turn text belongs to fetch_session. */
|
|
137
|
+
function _excerpt(text: string, maxLength = 240): string {
|
|
138
|
+
const normalized = text.replaceAll(/\s+/g, " ").trim();
|
|
139
|
+
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}…` : normalized;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Search active durable memories with the same escaped literal matching used for transcript recall. */
|
|
143
|
+
function _recallDurableMemories(options: RecallOptions): RecallDurableMemoryResult[] {
|
|
144
|
+
const terms = _terms(options);
|
|
145
|
+
if (terms.length === 0) return [];
|
|
146
|
+
const scoreExpression = terms.map(() => "CASE WHEN LOWER(content) LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END").join(" + ");
|
|
147
|
+
const parameters = terms.map(_likePattern);
|
|
148
|
+
const filters = terms.map(() => "LOWER(content) LIKE ? ESCAPE '\\'");
|
|
149
|
+
const filterParameters: Array<string | number> = terms.map(_likePattern);
|
|
150
|
+
filters.push("superseded_by IS NULL");
|
|
151
|
+
if (options.cwd) {
|
|
152
|
+
filters.push("project_key = ?");
|
|
153
|
+
filterParameters.push(options.cwd);
|
|
154
|
+
}
|
|
155
|
+
if (options.after !== undefined) {
|
|
156
|
+
filters.push("last_confirmed_at >= ?");
|
|
157
|
+
filterParameters.push(options.after);
|
|
158
|
+
}
|
|
159
|
+
if (options.before !== undefined) {
|
|
160
|
+
filters.push("last_confirmed_at <= ?");
|
|
161
|
+
filterParameters.push(options.before);
|
|
162
|
+
}
|
|
163
|
+
return getDb().prepare(`
|
|
164
|
+
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,
|
|
165
|
+
(${scoreExpression}) AS hits
|
|
166
|
+
FROM memories
|
|
167
|
+
WHERE ${filters.join(" AND ")}
|
|
168
|
+
ORDER BY hits DESC, importance DESC, last_confirmed_at DESC
|
|
169
|
+
`).all(...parameters, ...filterParameters)
|
|
170
|
+
.map((memory) => ({ ...memory, type: "memory" as const, freshness_candidate: false, score: memory.hits + memory.importance })) as RecallDurableMemoryResult[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Retrieve and rank locally stored turns using literal query terms and optional scopes. */
|
|
174
|
+
function _recallTurns(options: RecallOptions): RecallTurnResult[] {
|
|
175
|
+
const terms = _terms(options);
|
|
176
|
+
if (terms.length === 0) return [];
|
|
177
|
+
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(" + ");
|
|
178
|
+
const scoreParameters = terms.flatMap((term) => _likeParameters(term));
|
|
179
|
+
const whereExpressions = terms.map(() => "(LOWER(turns.user_text) LIKE ? ESCAPE '\\' OR LOWER(turns.reply_text) LIKE ? ESCAPE '\\')");
|
|
180
|
+
const whereParameters = terms.flatMap((term) => _likeParameters(term));
|
|
181
|
+
const filters = [...whereExpressions];
|
|
182
|
+
const filterParameters: Array<string | number> = [...whereParameters];
|
|
183
|
+
if (options.sources?.length) {
|
|
184
|
+
filters.push(`sessions.source IN (${options.sources.map(() => "?").join(", ")})`);
|
|
185
|
+
filterParameters.push(...options.sources);
|
|
186
|
+
}
|
|
187
|
+
if (options.cwd) {
|
|
188
|
+
filters.push("sessions.cwd = ?");
|
|
189
|
+
filterParameters.push(options.cwd);
|
|
190
|
+
}
|
|
191
|
+
if (options.after !== undefined) {
|
|
192
|
+
filters.push("turns.ts >= ?");
|
|
193
|
+
filterParameters.push(options.after);
|
|
194
|
+
}
|
|
195
|
+
if (options.before !== undefined) {
|
|
196
|
+
filters.push("turns.ts <= ?");
|
|
197
|
+
filterParameters.push(options.before);
|
|
198
|
+
}
|
|
199
|
+
const candidates = getDb().prepare(`
|
|
200
|
+
SELECT turns.turn_id, turns.session_id, turns.turn_index, sessions.source, sessions.cwd, turns.ts, turns.user_text, turns.reply_text,
|
|
201
|
+
(${scoreExpression}) AS hits
|
|
202
|
+
FROM turns JOIN sessions ON sessions.session_id = turns.session_id
|
|
203
|
+
WHERE ${filters.join(" AND ")}
|
|
204
|
+
ORDER BY hits DESC, turns.ts DESC
|
|
205
|
+
`).all(...scoreParameters, ...filterParameters) as Array<Omit<RecallTurnResult, "type" | "score">>;
|
|
206
|
+
const newestTs = candidates.reduce((newest, result) => Math.max(newest, result.ts), 0);
|
|
207
|
+
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);
|
|
208
|
+
return results;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Hash the current raw turn evidence using the same representation captured during pinning. */
|
|
212
|
+
function _turnContentHash(turn: RecallTurnResult): string {
|
|
213
|
+
return createHash("sha256").update(JSON.stringify([turn.user_text, turn.reply_text])).digest("hex");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Build a de-duplicated set of non-empty literal search terms from the request. */
|
|
217
|
+
function _terms(options: RecallOptions): string[] {
|
|
218
|
+
return [...new Set([options.query, ...(options.entities ?? [])].map((term) => term.trim()).filter(Boolean))];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Produce matching user and assistant SQL LIKE parameters for one term. */
|
|
222
|
+
function _likeParameters(term: string): [string, string] {
|
|
223
|
+
const pattern = _likePattern(term);
|
|
224
|
+
return [pattern, pattern];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Convert one literal search term into an escaped, case-normalized SQL LIKE pattern. */
|
|
228
|
+
function _likePattern(term: string): string {
|
|
229
|
+
return `%${term.toLowerCase().replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Return a bounded recency bonus relative to the newest candidate timestamp. */
|
|
233
|
+
function _recencyScore(ts: number, newestTs: number): number {
|
|
234
|
+
return Math.max(0, 0.5 * (1 - (newestTs - ts) / RECENCY_WINDOW_MS));
|
|
70
235
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
interface CodexMessage {
|
|
7
|
+
id: string;
|
|
8
|
+
role: "user" | "assistant";
|
|
9
|
+
text: string;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CodexSession {
|
|
14
|
+
id: string;
|
|
15
|
+
cwd: string;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
messages: CodexMessage[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProjectSessionMigrationStats {
|
|
21
|
+
scannedFiles: number;
|
|
22
|
+
migratedSessions: number;
|
|
23
|
+
skippedSessions: number;
|
|
24
|
+
migratedMessages: number;
|
|
25
|
+
issues: Array<{ path: string; error: string }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Convert every Codex session recorded for cwd into an independently resumable Pi session file. */
|
|
29
|
+
export function migrateCodexProjectSessions(cwd: string): ProjectSessionMigrationStats {
|
|
30
|
+
const stats: ProjectSessionMigrationStats = {
|
|
31
|
+
scannedFiles: 0,
|
|
32
|
+
migratedSessions: 0,
|
|
33
|
+
skippedSessions: 0,
|
|
34
|
+
migratedMessages: 0,
|
|
35
|
+
issues: [],
|
|
36
|
+
};
|
|
37
|
+
for (const path of _jsonlFiles(join(homedir(), ".codex", "sessions"))) {
|
|
38
|
+
stats.scannedFiles++;
|
|
39
|
+
try {
|
|
40
|
+
const session = _parseCodexSession(path);
|
|
41
|
+
if (!session || session.cwd !== cwd) continue;
|
|
42
|
+
const outputPath = _targetPath(session);
|
|
43
|
+
if (existsSync(outputPath)) {
|
|
44
|
+
stats.skippedSessions++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
_writePiSession(session, outputPath);
|
|
48
|
+
stats.migratedSessions++;
|
|
49
|
+
stats.migratedMessages += session.messages.length;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
stats.issues.push({ path, error: error instanceof Error ? error.message : String(error) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return stats;
|
|
55
|
+
}
|
|
56
|
+
|
|
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>);
|
|
60
|
+
const metadata = entries.find((entry) => entry.type === "session_meta")?.payload as Record<string, unknown> | undefined;
|
|
61
|
+
if (!metadata) return undefined;
|
|
62
|
+
const id = _string(metadata.session_id) ?? _string(metadata.id);
|
|
63
|
+
const cwd = _string(metadata.cwd);
|
|
64
|
+
const timestamp = _timestamp(_string(metadata.timestamp));
|
|
65
|
+
if (!id || !cwd || timestamp === undefined) throw new Error("Codex session_meta requires session_id/id, cwd, and timestamp");
|
|
66
|
+
|
|
67
|
+
const messages: CodexMessage[] = [];
|
|
68
|
+
for (const [index, entry] of entries.entries()) {
|
|
69
|
+
if (entry.type !== "response_item") continue;
|
|
70
|
+
const payload = entry.payload as Record<string, unknown> | undefined;
|
|
71
|
+
if (payload?.type !== "message") continue;
|
|
72
|
+
const role = _string(payload.role);
|
|
73
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
74
|
+
const text = _text(payload.content);
|
|
75
|
+
if (!text) continue;
|
|
76
|
+
const messageId = _string(payload.id)
|
|
77
|
+
?? _string(entry.id)
|
|
78
|
+
?? _string((payload.internal_chat_message_metadata_passthrough as Record<string, unknown> | undefined)?.turn_id);
|
|
79
|
+
if (!messageId) throw new Error(`Codex textual message at entry ${index} has no stable native ID`);
|
|
80
|
+
messages.push({
|
|
81
|
+
id: messageId,
|
|
82
|
+
role,
|
|
83
|
+
text,
|
|
84
|
+
timestamp: _timestamp(_string(entry.timestamp)) ?? timestamp,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return { id, cwd, timestamp, messages };
|
|
88
|
+
}
|
|
89
|
+
|
|
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 {
|
|
92
|
+
mkdirSync(join(homedir(), ".pi", "agent", "sessions", _encodedCwd(session.cwd)), { recursive: true });
|
|
93
|
+
const lines: string[] = [JSON.stringify({
|
|
94
|
+
type: "session",
|
|
95
|
+
version: 3,
|
|
96
|
+
id: randomUUID(),
|
|
97
|
+
timestamp: new Date(session.timestamp).toISOString(),
|
|
98
|
+
cwd: session.cwd,
|
|
99
|
+
})];
|
|
100
|
+
let parentId: string | null = null;
|
|
101
|
+
const nameId = _entryId(session.id, "name");
|
|
102
|
+
lines.push(JSON.stringify({
|
|
103
|
+
type: "session_info",
|
|
104
|
+
id: nameId,
|
|
105
|
+
parentId,
|
|
106
|
+
timestamp: new Date(session.timestamp).toISOString(),
|
|
107
|
+
name: `Migrated from Codex: ${session.id}`,
|
|
108
|
+
}));
|
|
109
|
+
parentId = nameId;
|
|
110
|
+
for (const message of session.messages) {
|
|
111
|
+
const id = _entryId(session.id, message.id);
|
|
112
|
+
const timestamp = new Date(message.timestamp).toISOString();
|
|
113
|
+
lines.push(JSON.stringify({
|
|
114
|
+
type: "message",
|
|
115
|
+
id,
|
|
116
|
+
parentId,
|
|
117
|
+
timestamp,
|
|
118
|
+
message: message.role === "user"
|
|
119
|
+
? { role: "user", content: [{ type: "text", text: message.text }], timestamp: message.timestamp }
|
|
120
|
+
: {
|
|
121
|
+
role: "assistant",
|
|
122
|
+
content: [{ type: "text", text: message.text }],
|
|
123
|
+
api: "codex-migration",
|
|
124
|
+
provider: "codex",
|
|
125
|
+
model: "unknown",
|
|
126
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
127
|
+
stopReason: "stop",
|
|
128
|
+
timestamp: message.timestamp,
|
|
129
|
+
},
|
|
130
|
+
}));
|
|
131
|
+
parentId = id;
|
|
132
|
+
}
|
|
133
|
+
const temporaryPath = `${path}.tmp-${randomUUID()}`;
|
|
134
|
+
try {
|
|
135
|
+
writeFileSync(temporaryPath, `${lines.join("\n")}\n`, { flag: "wx" });
|
|
136
|
+
renameSync(temporaryPath, path);
|
|
137
|
+
} finally {
|
|
138
|
+
if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
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`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Encode cwd exactly as Pi's default session directory convention. */
|
|
148
|
+
function _encodedCwd(cwd: string): string {
|
|
149
|
+
return `--${cwd.split("/").filter(Boolean).join("-")}--`;
|
|
150
|
+
}
|
|
151
|
+
|
|
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);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Extract non-empty string values only. */
|
|
158
|
+
function _string(value: unknown): string | undefined {
|
|
159
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Parse an ISO timestamp only when it is valid. */
|
|
163
|
+
function _timestamp(value: string | undefined): number | undefined {
|
|
164
|
+
if (!value) return undefined;
|
|
165
|
+
const timestamp = Date.parse(value);
|
|
166
|
+
return Number.isNaN(timestamp) ? undefined : timestamp;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Join supported Codex text content blocks. */
|
|
170
|
+
function _text(content: unknown): string {
|
|
171
|
+
if (!Array.isArray(content)) return "";
|
|
172
|
+
return content
|
|
173
|
+
.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")
|
|
174
|
+
.filter((block) => block.type === "input_text" || block.type === "output_text" || block.type === "text")
|
|
175
|
+
.map((block) => block.text)
|
|
176
|
+
.join("\n")
|
|
177
|
+
.trim();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Recursively enumerate source JSONL files. */
|
|
181
|
+
function _jsonlFiles(root: string): string[] {
|
|
182
|
+
if (!existsSync(root)) return [];
|
|
183
|
+
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
|
184
|
+
const path = join(root, entry.name);
|
|
185
|
+
if (entry.isDirectory()) return _jsonlFiles(path);
|
|
186
|
+
return entry.isFile() && path.endsWith(".jsonl") ? [path] : [];
|
|
187
|
+
});
|
|
188
|
+
}
|
package/src/writer.ts
CHANGED
|
@@ -2,6 +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
6
|
export function writeTurn(ctx: ExtensionContext): void {
|
|
6
7
|
const sessionManager = ctx.sessionManager;
|
|
7
8
|
const sessionId = `pi:${sessionManager.getSessionId()}`;
|
|
@@ -50,6 +51,7 @@ export function writeTurn(ctx: ExtensionContext): void {
|
|
|
50
51
|
});
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/** Join text blocks from a Pi message while ignoring non-text content. */
|
|
53
55
|
function _extractText(message: { content: string | Array<{ type: string; text?: string }> }): string {
|
|
54
56
|
if (typeof message.content === "string") return message.content.trim();
|
|
55
57
|
return message.content
|
|
@@ -59,6 +61,7 @@ function _extractText(message: { content: string | Array<{ type: string; text?:
|
|
|
59
61
|
.trim();
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/** Calculate the zero-based user-turn position within the current session branch. */
|
|
62
65
|
function _userTurnIndex(branch: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>, userIndex: number): number {
|
|
63
66
|
return branch.slice(0, userIndex + 1)
|
|
64
67
|
.filter((entry) => entry.type === "message" && entry.message.role === "user")
|
package/spec.md
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
# pi-session-memory — Spec
|
|
2
|
-
|
|
3
|
-
## Goal
|
|
4
|
-
|
|
5
|
-
A pi extension that persists every conversation turn to SQLite and exposes a
|
|
6
|
-
`recall_memory` tool so the LLM can retrieve relevant past turns when the user
|
|
7
|
-
references previous discussions.
|
|
8
|
-
|
|
9
|
-
## Architecture
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
pi turn_end event ──────────────────────────┐
|
|
13
|
-
▼
|
|
14
|
-
Writer (src/writer.ts)
|
|
15
|
-
- writes current pi turns
|
|
16
|
-
│
|
|
17
|
-
session_start ───────────┐ │
|
|
18
|
-
▼ ▼
|
|
19
|
-
Incremental source sync → SQLite ~/.pi/agent/memory.db
|
|
20
|
-
- per-file size + mtime quick check
|
|
21
|
-
- SHA-256 only for changed candidates
|
|
22
|
-
- Pi / Claude / Codex JSONL adapters
|
|
23
|
-
|
|
24
|
-
/memory-backfill forces a full rescan.
|
|
25
|
-
│
|
|
26
|
-
▼
|
|
27
|
-
recall_memory tool
|
|
28
|
-
- LLM supplies entities[]
|
|
29
|
-
- LIKE substring match + hit-score ranking
|
|
30
|
-
- returns top-5 turns
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
## Tables
|
|
34
|
-
|
|
35
|
-
### sessions
|
|
36
|
-
| column | type | note |
|
|
37
|
-
|------------|---------|-------------------------------|
|
|
38
|
-
| session_id | TEXT PK | native source session ID (UUID) |
|
|
39
|
-
| source | TEXT | `pi`, `claude`, or `codex` |
|
|
40
|
-
| cwd | TEXT | working directory |
|
|
41
|
-
| started_at | INTEGER | unix ms |
|
|
42
|
-
| model_id | TEXT | first model_change value |
|
|
43
|
-
| jsonl_path | TEXT | absolute path to source file |
|
|
44
|
-
|
|
45
|
-
### turns
|
|
46
|
-
| column | type | note |
|
|
47
|
-
|-------------|---------|-------------------------------------------|
|
|
48
|
-
| turn_id | TEXT PK | `{session_id}:{user_message_id}` — stable across live write and backfill |
|
|
49
|
-
| session_id | TEXT FK | |
|
|
50
|
-
| turn_index | INTEGER | display order within session; never used as identity |
|
|
51
|
-
| ts | INTEGER | user message timestamp (unix ms) |
|
|
52
|
-
| user_text | TEXT | user message content |
|
|
53
|
-
| reply_text | TEXT | assistant final text (all text blocks) |
|
|
54
|
-
| tool_names | TEXT | JSON array e.g. `["bash","read"]` |
|
|
55
|
-
|
|
56
|
-
There is intentionally no full-text virtual table. Retrieval uses escaped
|
|
57
|
-
SQLite `LIKE` against the complete `user_text` and `reply_text`, because literal
|
|
58
|
-
substring coverage and retrieval quality are prioritized over index performance.
|
|
59
|
-
|
|
60
|
-
## Write Path
|
|
61
|
-
|
|
62
|
-
Trigger: `agent_settled` event, after the agent run and any automatic
|
|
63
|
-
continuations have completed.
|
|
64
|
-
|
|
65
|
-
Steps:
|
|
66
|
-
1. Locate the latest user `SessionEntry` in `ctx.sessionManager.getBranch()` and
|
|
67
|
-
use its stable entry ID as `user_message_id`; do not use pi's transient `turnIndex`.
|
|
68
|
-
2. Extract `user_text` from that user entry's text content blocks.
|
|
69
|
-
3. Walk branch entries after that user entry to collect assistant text blocks →
|
|
70
|
-
`reply_text`, and toolCall names → `tool_names`.
|
|
71
|
-
4. Upsert source=`pi` session row (INSERT OR IGNORE).
|
|
72
|
-
5. Insert turn by stable ID (INSERT OR IGNORE — live writing and backfill target
|
|
73
|
-
the same row).
|
|
74
|
-
|
|
75
|
-
## Retrieval Path (recall_memory tool)
|
|
76
|
-
|
|
77
|
-
### source_files
|
|
78
|
-
| column | type | note |
|
|
79
|
-
|---|---|---|
|
|
80
|
-
| jsonl_path | TEXT PK | absolute source file path |
|
|
81
|
-
| source | TEXT | `pi`, `claude`, or `codex` |
|
|
82
|
-
| size | INTEGER | file size at last sync |
|
|
83
|
-
| mtime_ms | REAL | modification time at last sync |
|
|
84
|
-
| sha256 | TEXT | content hash for changed candidates |
|
|
85
|
-
|
|
86
|
-
### Tool Invocation Policy
|
|
87
|
-
|
|
88
|
-
- **Direct recall:** Call `recall_memory` immediately when the user explicitly
|
|
89
|
-
asks to review, remember, summarize, continue, or compare a prior discussion
|
|
90
|
-
about a topic.
|
|
91
|
-
- **Knowledge-gap recall:** When the user asks about a topic you cannot answer
|
|
92
|
-
confidently from the current conversation and your general knowledge, but it
|
|
93
|
-
may have been discussed in the user's past sessions, ask the user whether they
|
|
94
|
-
want you to search their conversation history. Call `recall_memory` only after
|
|
95
|
-
the user agrees.
|
|
96
|
-
- Do not search history merely because a question is difficult when the user has
|
|
97
|
-
not indicated that their own prior work or discussions are relevant.
|
|
98
|
-
|
|
99
|
-
Input: `{ entities: string[] }` — 2-5 key terms extracted by LLM from user query.
|
|
100
|
-
|
|
101
|
-
Steps:
|
|
102
|
-
1. Build per-entity LIKE hit score:
|
|
103
|
-
- `user_text` match = 2 points
|
|
104
|
-
- `reply_text` match = 1 point
|
|
105
|
-
2. `SELECT ... WHERE (LOWER(user_text) LIKE ? OR LOWER(reply_text) LIKE ?) OR ...`
|
|
106
|
-
3. Escape `%`, `_`, and `\\` in every entity, then use `LIKE ? ESCAPE '\\'` so
|
|
107
|
-
technical names containing LIKE wildcards remain literal substring matches.
|
|
108
|
-
4. `ORDER BY hits DESC, ts DESC LIMIT 5`
|
|
109
|
-
|
|
110
|
-
No FTS5 or write-time preprocessing. LIKE is a literal substring match after
|
|
111
|
-
escaping, so it does not lose substring matches through tokenization.
|
|
112
|
-
|
|
113
|
-
## Backfill
|
|
114
|
-
|
|
115
|
-
At `session_start`, `syncChangedHistory()` recursively enumerates the three
|
|
116
|
-
source roots. It records one row per source JSONL file in `source_files`:
|
|
117
|
-
`jsonl_path`, source, size, `mtime_ms`, and SHA-256. Unchanged size/mtime files
|
|
118
|
-
are skipped without reading; changed candidates are SHA-256 checked, and only
|
|
119
|
-
new content is parsed and imported.
|
|
120
|
-
|
|
121
|
-
`/memory-backfill` forces a full reparse of all historical records. All writes
|
|
122
|
-
use `INSERT OR IGNORE`, so both automatic sync and forced backfill are safe and
|
|
123
|
-
idempotent to run repeatedly.
|
|
124
|
-
|
|
125
|
-
| source | scan root | accepted user/assistant records | excluded records |
|
|
126
|
-
|---|---|---|---|
|
|
127
|
-
| pi | `~/.pi/agent/sessions/**/*.jsonl` | `message.role=user|assistant` | thinking, tool results, non-text content |
|
|
128
|
-
| claude | `~/.claude/projects/**/*.jsonl` | `type=user|assistant`, `message.role=user|assistant` | `isMeta`, sidechains, slash commands, local-command tags, continuation summaries, system/snapshot/attachments |
|
|
129
|
-
| codex | `~/.codex/sessions/**/*.jsonl` | `type=response_item`, `payload.type=message`, `role=user|assistant` | developer/system context, AGENTS.md and environment-context injection, IDE/image context, reasoning and tool events |
|
|
130
|
-
|
|
131
|
-
Each adapter outputs the common `ImportedSession` / `ImportedTurn` model.
|
|
132
|
-
Turns pair one accepted user message with all following accepted assistant text
|
|
133
|
-
until the next accepted user message. Each source adapter preserves the native
|
|
134
|
-
user-message ID (`pi entry.id`, Claude `uuid`, Codex `payload.id`) so rerunning
|
|
135
|
-
backfill never creates a duplicate of an already live-written pi turn.
|
|
136
|
-
|
|
137
|
-
## Acceptance Criteria
|
|
138
|
-
|
|
139
|
-
- A pi session with multiple user turns produces one distinct `turns` row per
|
|
140
|
-
user message; no row is dropped because `turnIndex` restarts.
|
|
141
|
-
- Running backfill after live pi writing does not duplicate those pi turns.
|
|
142
|
-
- Claude and Codex injected context records listed above are absent from
|
|
143
|
-
`turns.user_text`.
|
|
144
|
-
- Entity text containing `%`, `_`, or `\\` only matches its literal occurrence.
|
|
145
|
-
- Automated tests cover stable turn identity, LIKE escaping/ranking, and
|
|
146
|
-
cross-source backfill parsing.
|
|
147
|
-
|
|
148
|
-
## File Structure
|
|
149
|
-
|
|
150
|
-
```
|
|
151
|
-
pi-session-memory/
|
|
152
|
-
├── spec.md
|
|
153
|
-
├── package.json
|
|
154
|
-
├── tsconfig.json
|
|
155
|
-
├── extensions/
|
|
156
|
-
│ └── index.ts ← extension entry: tool + /memory-backfill
|
|
157
|
-
├── tests/
|
|
158
|
-
│ └── core.test.ts ← persistence identity and literal-LIKE tests
|
|
159
|
-
└── src/
|
|
160
|
-
├── db.ts ← DatabaseSync schema + upsert helpers
|
|
161
|
-
├── writer.ts ← live pi turn_end writer
|
|
162
|
-
├── retriever.ts ← LIKE query + hit-score ranking
|
|
163
|
-
└── backfill.ts ← Pi / Claude / Codex adapters and import runner
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
## Constraints
|
|
167
|
-
|
|
168
|
-
- Zero external dependencies (use `node:sqlite`, `node:fs`, `node:path`, `node:os`)
|
|
169
|
-
- Idempotent writes (INSERT OR IGNORE on turn_id)
|
|
170
|
-
- Cross-platform paths: storage is `join(homedir(), ".pi", "agent", "memory.db")`; source roots are derived with `join(homedir(), ...)`, never hard-coded POSIX paths.
|
|
171
|
-
- Requires a Pi-supported Node.js runtime that exposes `node:sqlite` (`DatabaseSync`).
|
|
172
|
-
- No fallback / silent failure — let errors surface
|