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/src/retriever.ts CHANGED
@@ -1,70 +1,235 @@
1
- import { getDb } from "./db.ts";
1
+ import { createHash } from "node:crypto";
2
+ import { getDb, type MemoryKind } from "./db.ts";
2
3
 
3
- export interface RecallResult {
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
- source: "pi" | "claude" | "codex";
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 function recallTurns(entities: string[], topK = 5): RecallResult[] {
13
- if (entities.length === 0) return [];
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
- const scoreExpression = entities.map(() => `(
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
- const whereExpression = entities.map(() => `(
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
- return getDb().prepare(`
34
- SELECT
35
- turns.turn_id,
36
- sessions.source,
37
- turns.ts,
38
- turns.user_text,
39
- turns.reply_text,
40
- (${scoreExpression}) AS hits
41
- FROM turns
42
- JOIN sessions ON sessions.session_id = turns.session_id
43
- WHERE ${whereExpression}
44
- ORDER BY hits DESC, turns.ts DESC
45
- LIMIT ?
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
- export function formatRecallResults(results: RecallResult[]): string {
50
- if (results.length === 0) return "No relevant past conversations found.";
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
- const lines = ["## Relevant past conversations\n"];
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
- const date = new Date(result.ts).toLocaleString();
55
- lines.push(`### [${result.source} · ${date}]`);
56
- lines.push(`**You:** ${result.user_text}`);
57
- if (result.reply_text) {
58
- const preview = result.reply_text.length > 500
59
- ? `${result.reply_text.slice(0, 500)}…`
60
- : result.reply_text;
61
- lines.push(`**Assistant:** ${preview}`);
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
- function _likePattern(entity: string): string {
69
- return `%${entity.toLowerCase().replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
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