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/backfill.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readdirSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
@@ -25,6 +25,12 @@ interface ImportedSession {
25
25
  messages: ImportedMessage[];
26
26
  }
27
27
 
28
+ export interface BackfillIssue {
29
+ source: Source;
30
+ jsonlPath: string | null;
31
+ error: string;
32
+ }
33
+
28
34
  export interface BackfillStats {
29
35
  pi: number;
30
36
  claude: number;
@@ -32,8 +38,16 @@ export interface BackfillStats {
32
38
  turns: number;
33
39
  scannedFiles: number;
34
40
  skippedFiles: number;
41
+ issues: BackfillIssue[];
35
42
  }
36
43
 
44
+ /** Versions whose Claude Code and Codex JSONL schemas this importer was verified against. */
45
+ export const HISTORY_SCHEMA_REFERENCE_VERSIONS = {
46
+ pi: "0.85.1",
47
+ claude: "2.1.234",
48
+ codex: "0.154.0",
49
+ } as const;
50
+
37
51
  const SOURCES: Array<{ source: Source; root: string; parse: (path: string) => ImportedSession | undefined }> = [
38
52
  { source: "pi", root: join(homedir(), ".pi", "agent", "sessions"), parse: _parsePi },
39
53
  { source: "claude", root: join(homedir(), ".claude", "projects"), parse: _parseClaude },
@@ -50,35 +64,61 @@ export function syncChangedHistory(): BackfillStats {
50
64
  return _syncHistory(false);
51
65
  }
52
66
 
67
+ /** Synchronize all configured JSONL sources. */
53
68
  function _syncHistory(force: boolean): BackfillStats {
54
- const stats: BackfillStats = { pi: 0, claude: 0, codex: 0, turns: 0, scannedFiles: 0, skippedFiles: 0 };
69
+ const stats: BackfillStats = { pi: 0, claude: 0, codex: 0, turns: 0, scannedFiles: 0, skippedFiles: 0, issues: [] };
55
70
  for (const definition of SOURCES) {
56
- for (const jsonlPath of _jsonlFiles(definition.root)) {
57
- const metadata = statSync(jsonlPath);
58
- const known = getSourceFile(jsonlPath);
59
- if (!force && known?.size === metadata.size && known.mtime_ms === metadata.mtimeMs) {
60
- stats.skippedFiles++;
61
- continue;
62
- }
63
-
64
- const sha256 = _sha256(jsonlPath);
65
- if (!force && known?.sha256 === sha256) {
66
- upsertSourceFile({ jsonl_path: jsonlPath, source: definition.source, size: metadata.size, mtime_ms: metadata.mtimeMs, sha256 });
67
- stats.skippedFiles++;
68
- continue;
69
- }
70
-
71
- const session = definition.parse(jsonlPath);
71
+ if (!existsSync(definition.root)) continue;
72
+ try {
73
+ for (const jsonlPath of _jsonlFiles(definition.root)) _syncSourceFile(definition, jsonlPath, force, stats);
74
+ } catch (error) {
75
+ stats.issues.push(_backfillIssue(definition.source, null, error));
76
+ }
77
+ }
78
+ return stats;
79
+ }
80
+
81
+ /** Synchronize one source file without allowing its failure to block other files or sources. */
82
+ function _syncSourceFile(definition: typeof SOURCES[number], jsonlPath: string, force: boolean, stats: BackfillStats): void {
83
+ try {
84
+ const metadata = statSync(jsonlPath);
85
+ const known = getSourceFile(jsonlPath);
86
+ if (!force && known?.size === metadata.size && known.mtime_ms === metadata.mtimeMs) {
87
+ stats.skippedFiles++;
88
+ return;
89
+ }
90
+
91
+ const sha256 = _sha256(jsonlPath);
92
+ if (!force && known?.sha256 === sha256) {
72
93
  upsertSourceFile({ jsonl_path: jsonlPath, source: definition.source, size: metadata.size, mtime_ms: metadata.mtimeMs, sha256 });
73
- stats.scannedFiles++;
74
- if (!session) continue;
94
+ stats.skippedFiles++;
95
+ return;
96
+ }
97
+
98
+ const session = definition.parse(jsonlPath);
99
+ if (session) {
75
100
  stats[definition.source]++;
76
101
  stats.turns += _persist(session);
102
+ upsertSourceFile({ jsonl_path: jsonlPath, source: definition.source, size: metadata.size, mtime_ms: metadata.mtimeMs, sha256 });
77
103
  }
104
+ stats.scannedFiles++;
105
+ } catch (error) {
106
+ stats.issues.push(_backfillIssue(definition.source, jsonlPath, error));
78
107
  }
79
- return stats;
80
108
  }
81
109
 
110
+ /** Format an isolated source failure with the reference version for schema comparison. */
111
+ function _backfillIssue(source: Source, jsonlPath: string | null, error: unknown): BackfillIssue {
112
+ const location = jsonlPath ? ` (${jsonlPath})` : "";
113
+ const message = error instanceof Error ? error.message : String(error);
114
+ return {
115
+ source,
116
+ jsonlPath,
117
+ error: `${source} history import failed${location}: ${message}. Compare the local ${source} version with the supported reference ${HISTORY_SCHEMA_REFERENCE_VERSIONS[source]}; this may be a JSONL schema compatibility issue.`,
118
+ };
119
+ }
120
+
121
+ /** Convert one normalized source session into paired, idempotently stored memory turns. */
82
122
  function _persist(session: ImportedSession): number {
83
123
  const sessionId = `${session.source}:${session.nativeSessionId}`;
84
124
  upsertSession({
@@ -96,6 +136,7 @@ function _persist(session: ImportedSession): number {
96
136
  const toolNames: string[] = [];
97
137
  let persisted = 0;
98
138
 
139
+ /** Persist the current user-plus-assistant accumulation when a turn boundary is reached. */
99
140
  const flush = () => {
100
141
  if (!user || !replyText.trim()) return;
101
142
  const inserted = insertTurn({
@@ -127,6 +168,7 @@ function _persist(session: ImportedSession): number {
127
168
  return persisted;
128
169
  }
129
170
 
171
+ /** Parse a Pi JSONL session into the source-neutral import representation. */
130
172
  function _parsePi(jsonlPath: string): ImportedSession | undefined {
131
173
  const entries = _readJsonl(jsonlPath);
132
174
  const header = entries.find((entry) => entry.type === "session");
@@ -143,7 +185,13 @@ function _parsePi(jsonlPath: string): ImportedSession | undefined {
143
185
  const toolNames = message.role === "assistant"
144
186
  ? message.content.filter((block: any) => block.type === "toolCall").map((block: any) => block.name)
145
187
  : [];
146
- messages.push({ id: entry.id, role: message.role, text, ts: message.timestamp, toolNames });
188
+ messages.push({
189
+ id: _messageId("Pi entry.id", entry.id),
190
+ role: message.role,
191
+ text,
192
+ ts: message.timestamp,
193
+ toolNames,
194
+ });
147
195
  }
148
196
 
149
197
  return {
@@ -157,6 +205,7 @@ function _parsePi(jsonlPath: string): ImportedSession | undefined {
157
205
  };
158
206
  }
159
207
 
208
+ /** Parse non-meta Claude Code conversation records into normalized messages. */
160
209
  function _parseClaude(jsonlPath: string): ImportedSession | undefined {
161
210
  const entries = _readJsonl(jsonlPath);
162
211
  const firstConversation = entries.find((entry) =>
@@ -174,7 +223,14 @@ function _parseClaude(jsonlPath: string): ImportedSession | undefined {
174
223
  const toolNames = role === "assistant"
175
224
  ? entry.message.content.filter((block: any) => block.type === "tool_use").map((block: any) => block.name)
176
225
  : [];
177
- messages.push({ id: entry.uuid, role, text, ts: Date.parse(entry.timestamp), toolNames });
226
+ // Workaround: Claude Code JSONL schema differs by version; message IDs may be in uuid or id.
227
+ messages.push({
228
+ id: _messageId("Claude entry.uuid or entry.id", entry.uuid, entry.id),
229
+ role,
230
+ text,
231
+ ts: Date.parse(entry.timestamp),
232
+ toolNames,
233
+ });
178
234
  }
179
235
 
180
236
  return {
@@ -188,6 +244,7 @@ function _parseClaude(jsonlPath: string): ImportedSession | undefined {
188
244
  };
189
245
  }
190
246
 
247
+ /** Parse Codex session metadata and response-message envelopes into normalized messages. */
191
248
  function _parseCodex(jsonlPath: string): ImportedSession | undefined {
192
249
  const entries = _readJsonl(jsonlPath);
193
250
  const meta = entries.find((entry) => entry.type === "session_meta")?.payload;
@@ -200,8 +257,14 @@ function _parseCodex(jsonlPath: string): ImportedSession | undefined {
200
257
  if (payload?.type !== "message" || (payload.role !== "user" && payload.role !== "assistant")) continue;
201
258
  const text = _codexText(payload);
202
259
  if (!text || (payload.role === "user" && _isCodexInjectedContext(text))) continue;
260
+ // Workaround: Codex JSONL schema differs by version; legacy sessions store the ID as metadata.turn_id.
203
261
  messages.push({
204
- id: payload.id,
262
+ id: _messageId(
263
+ "Codex payload.id, entry.id, or metadata.turn_id",
264
+ payload.id,
265
+ entry.id,
266
+ payload.internal_chat_message_metadata_passthrough?.turn_id,
267
+ ),
205
268
  role: payload.role,
206
269
  text,
207
270
  ts: Date.parse(entry.timestamp),
@@ -220,6 +283,14 @@ function _parseCodex(jsonlPath: string): ImportedSession | undefined {
220
283
  };
221
284
  }
222
285
 
286
+ /** Read a source message ID from a known schema field without inventing one for malformed records. */
287
+ function _messageId(field: string, ...values: unknown[]): string {
288
+ const id = values.find((value): value is string => typeof value === "string" && value.length > 0);
289
+ if (!id) throw new Error(`Invalid ${field}: expected a non-empty string`);
290
+ return id;
291
+ }
292
+
293
+ /** Extract Pi text content while excluding thinking and non-text blocks. */
223
294
  function _piText(message: any): string {
224
295
  if (typeof message.content === "string") return message.content.trim();
225
296
  return message.content
@@ -229,6 +300,7 @@ function _piText(message: any): string {
229
300
  .trim();
230
301
  }
231
302
 
303
+ /** Extract Claude Code text content from either legacy strings or content blocks. */
232
304
  function _claudeText(message: any): string {
233
305
  if (typeof message.content === "string") return message.content.trim();
234
306
  if (!Array.isArray(message.content)) return "";
@@ -239,6 +311,7 @@ function _claudeText(message: any): string {
239
311
  .trim();
240
312
  }
241
313
 
314
+ /** Extract user input and assistant output text from a Codex message payload. */
242
315
  function _codexText(message: any): string {
243
316
  return message.content
244
317
  .filter((block: any) => block.type === "input_text" || block.type === "output_text")
@@ -247,6 +320,7 @@ function _codexText(message: any): string {
247
320
  .trim();
248
321
  }
249
322
 
323
+ /** Identify Claude Code client-injected text that must not become user memory. */
250
324
  function _isClaudeInjectedContext(text: string): boolean {
251
325
  return text.startsWith("<command-name>")
252
326
  || text.startsWith("<command-message>")
@@ -255,6 +329,7 @@ function _isClaudeInjectedContext(text: string): boolean {
255
329
  || text.startsWith("This session is being continued from a previous conversation");
256
330
  }
257
331
 
332
+ /** Identify Codex environment or IDE context that must not become user memory. */
258
333
  function _isCodexInjectedContext(text: string): boolean {
259
334
  return text.startsWith("# AGENTS.md instructions")
260
335
  || text.startsWith("<environment_context>")
@@ -262,10 +337,12 @@ function _isCodexInjectedContext(text: string): boolean {
262
337
  || text.startsWith("<image name=");
263
338
  }
264
339
 
340
+ /** Hash a source JSONL file so unchanged content can skip reparsing. */
265
341
  function _sha256(jsonlPath: string): string {
266
342
  return createHash("sha256").update(readFileSync(jsonlPath)).digest("hex");
267
343
  }
268
344
 
345
+ /** Read every non-empty JSONL line into its ordered JSON record. */
269
346
  function _readJsonl(jsonlPath: string): any[] {
270
347
  return readFileSync(jsonlPath, "utf8")
271
348
  .split("\n")
@@ -273,6 +350,7 @@ function _readJsonl(jsonlPath: string): any[] {
273
350
  .map((line) => JSON.parse(line));
274
351
  }
275
352
 
353
+ /** Recursively discover JSONL session files under a source root. */
276
354
  function _jsonlFiles(root: string): string[] {
277
355
  const files: string[] = [];
278
356
  for (const entry of readdirSync(root, { withFileTypes: true })) {
package/src/db.ts CHANGED
@@ -2,6 +2,7 @@ import { DatabaseSync } from "node:sqlite";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { mkdirSync } from "node:fs";
5
+ import { createHash, randomUUID } from "node:crypto";
5
6
 
6
7
  const DB_PATH = process.env.MEMORY_DB_PATH ?? join(homedir(), ".pi", "agent", "memory.db");
7
8
 
@@ -31,6 +32,24 @@ CREATE TABLE IF NOT EXISTS turns (
31
32
  CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id, turn_index);
32
33
  CREATE INDEX IF NOT EXISTS idx_turns_ts ON turns(ts DESC);
33
34
 
35
+ CREATE TABLE IF NOT EXISTS memories (
36
+ memory_id TEXT PRIMARY KEY,
37
+ kind TEXT NOT NULL CHECK(kind IN ('preference', 'decision', 'fact', 'project_state', 'task', 'lesson')),
38
+ content TEXT NOT NULL,
39
+ project_key TEXT NOT NULL,
40
+ source_turn_id TEXT,
41
+ source_session_id TEXT,
42
+ source_content_hash TEXT,
43
+ source_turn_index INTEGER,
44
+ created_at INTEGER NOT NULL,
45
+ last_confirmed_at INTEGER NOT NULL,
46
+ importance REAL NOT NULL,
47
+ superseded_by TEXT
48
+ );
49
+
50
+ CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_key);
51
+ CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by, last_confirmed_at DESC);
52
+
34
53
  CREATE TABLE IF NOT EXISTS source_files (
35
54
  jsonl_path TEXT PRIMARY KEY,
36
55
  source TEXT NOT NULL,
@@ -42,6 +61,7 @@ CREATE TABLE IF NOT EXISTS source_files (
42
61
 
43
62
  let _db: DatabaseSync | undefined;
44
63
 
64
+ /** Open the singleton SQLite database and ensure its schema is ready for use. */
45
65
  export function getDb(): DatabaseSync {
46
66
  if (_db) return _db;
47
67
  mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
@@ -53,6 +73,7 @@ export function getDb(): DatabaseSync {
53
73
  return _db;
54
74
  }
55
75
 
76
+ /** Apply additive schema migrations required by newer memory formats. */
56
77
  function _migrate(db: DatabaseSync): void {
57
78
  const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
58
79
  if (!sessionColumns.some((column) => column.name === "source")) {
@@ -65,6 +86,17 @@ function _migrate(db: DatabaseSync): void {
65
86
  db.exec("DELETE FROM turns WHERE user_message_id IS NULL");
66
87
  db.exec("DELETE FROM sessions WHERE session_id NOT IN (SELECT DISTINCT session_id FROM turns)");
67
88
  }
89
+
90
+ const memoryColumns = db.prepare("PRAGMA table_info(memories)").all() as Array<{ name: string }>;
91
+ if (!memoryColumns.some((column) => column.name === "source_session_id")) {
92
+ db.exec("ALTER TABLE memories ADD COLUMN source_session_id TEXT");
93
+ }
94
+ if (!memoryColumns.some((column) => column.name === "source_content_hash")) {
95
+ db.exec("ALTER TABLE memories ADD COLUMN source_content_hash TEXT");
96
+ }
97
+ if (!memoryColumns.some((column) => column.name === "source_turn_index")) {
98
+ db.exec("ALTER TABLE memories ADD COLUMN source_turn_index INTEGER");
99
+ }
68
100
  }
69
101
 
70
102
  export interface SessionRow {
@@ -87,6 +119,28 @@ export interface TurnRow {
87
119
  user_message_id: string;
88
120
  }
89
121
 
122
+ export interface StoredSession {
123
+ session: SessionRow;
124
+ turns: TurnRow[];
125
+ }
126
+
127
+ export type MemoryKind = "preference" | "decision" | "fact" | "project_state" | "task" | "lesson";
128
+
129
+ export interface MemoryRow {
130
+ memory_id: string;
131
+ kind: MemoryKind;
132
+ content: string;
133
+ project_key: string;
134
+ source_turn_id: string | null;
135
+ source_session_id: string | null;
136
+ source_content_hash: string | null;
137
+ source_turn_index: number | null;
138
+ created_at: number;
139
+ last_confirmed_at: number;
140
+ importance: number;
141
+ superseded_by: string | null;
142
+ }
143
+
90
144
  export interface SourceFileRow {
91
145
  jsonl_path: string;
92
146
  source: "pi" | "claude" | "codex";
@@ -95,6 +149,119 @@ export interface SourceFileRow {
95
149
  sha256: string;
96
150
  }
97
151
 
152
+ /** Create an explicit durable memory that remains independent from transcript retention. */
153
+ export function createMemory(input: Omit<MemoryRow, "memory_id" | "source_session_id" | "source_content_hash" | "source_turn_index" | "created_at" | "last_confirmed_at" | "superseded_by"> & {
154
+ memory_id?: string;
155
+ source_session_id?: string | null;
156
+ source_content_hash?: string | null;
157
+ source_turn_index?: number | null;
158
+ created_at?: number;
159
+ last_confirmed_at?: number;
160
+ }): MemoryRow {
161
+ const memory: MemoryRow = {
162
+ memory_id: input.memory_id ?? randomUUID(),
163
+ kind: input.kind,
164
+ content: input.content,
165
+ project_key: input.project_key,
166
+ source_turn_id: input.source_turn_id,
167
+ source_session_id: input.source_session_id ?? null,
168
+ source_content_hash: input.source_content_hash ?? null,
169
+ source_turn_index: input.source_turn_index ?? null,
170
+ created_at: input.created_at ?? Date.now(),
171
+ last_confirmed_at: input.last_confirmed_at ?? input.created_at ?? Date.now(),
172
+ importance: input.importance,
173
+ superseded_by: null,
174
+ };
175
+ getDb().prepare(`
176
+ INSERT INTO memories
177
+ (memory_id, kind, content, project_key, source_turn_id, source_session_id, source_content_hash, source_turn_index, created_at, last_confirmed_at, importance, superseded_by)
178
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
179
+ `).run(
180
+ memory.memory_id, memory.kind, memory.content, memory.project_key, memory.source_turn_id, memory.source_session_id, memory.source_content_hash, memory.source_turn_index,
181
+ memory.created_at, memory.last_confirmed_at, memory.importance, memory.superseded_by,
182
+ );
183
+ return memory;
184
+ }
185
+
186
+ /** Promote a historical turn into a durable fact with a permanent provenance reference. */
187
+ export function pinTurnAsMemory(turnId: string): MemoryRow {
188
+ const turn = getDb().prepare(`
189
+ SELECT turns.turn_id, turns.session_id, turns.turn_index, turns.user_text, turns.reply_text, sessions.cwd
190
+ FROM turns JOIN sessions ON sessions.session_id = turns.session_id
191
+ WHERE turns.turn_id = ?
192
+ `).get(turnId) as { turn_id: string; session_id: string; turn_index: number; user_text: string; reply_text: string; cwd: string } | undefined;
193
+ if (!turn) throw new Error(`Memory turn not found: ${turnId}`);
194
+ const content = turn.reply_text ? `User: ${turn.user_text}\nAssistant: ${turn.reply_text}` : turn.user_text;
195
+ return createMemory({
196
+ kind: "fact",
197
+ content,
198
+ project_key: turn.cwd,
199
+ source_turn_id: turn.turn_id,
200
+ source_session_id: turn.session_id,
201
+ source_content_hash: _turnContentHash(turn.user_text, turn.reply_text),
202
+ source_turn_index: turn.turn_index,
203
+ importance: 1,
204
+ });
205
+ }
206
+
207
+ /** Hash the exact source evidence copied into a pinned durable memory. */
208
+ function _turnContentHash(userText: string, replyText: string): string {
209
+ return createHash("sha256").update(JSON.stringify([userText, replyText])).digest("hex");
210
+ }
211
+
212
+ /** List durable memories, optionally narrowed to one validated memory kind. */
213
+ export function listMemories(kind?: MemoryKind): MemoryRow[] {
214
+ const statement = kind
215
+ ? getDb().prepare(`SELECT * FROM memories WHERE kind = ? ORDER BY last_confirmed_at DESC, memory_id`)
216
+ : getDb().prepare(`SELECT * FROM memories ORDER BY last_confirmed_at DESC, memory_id`);
217
+ return (kind ? statement.all(kind) : statement.all()) as MemoryRow[];
218
+ }
219
+
220
+ /** Mark an active memory as recently reviewed without changing its content or provenance. */
221
+ export function confirmMemory(memoryId: string): MemoryRow {
222
+ const memory = getDb().prepare("SELECT * FROM memories WHERE memory_id = ? AND superseded_by IS NULL").get(memoryId) as MemoryRow | undefined;
223
+ if (!memory) throw new Error(`Active durable memory not found: ${memoryId}`);
224
+ const last_confirmed_at = Date.now();
225
+ getDb().prepare("UPDATE memories SET last_confirmed_at = ? WHERE memory_id = ?").run(last_confirmed_at, memoryId);
226
+ return { ...memory, last_confirmed_at };
227
+ }
228
+
229
+ /** Explicitly replace one active memory with another while retaining the old record as history. */
230
+ export function supersedeMemory(oldMemoryId: string, newMemoryId: string): void {
231
+ if (oldMemoryId === newMemoryId) throw new Error("A memory cannot supersede itself");
232
+ const db = getDb();
233
+ const oldMemory = db.prepare("SELECT memory_id, superseded_by FROM memories WHERE memory_id = ?").get(oldMemoryId) as { memory_id: string; superseded_by: string | null } | undefined;
234
+ const newMemory = db.prepare("SELECT memory_id FROM memories WHERE memory_id = ?").get(newMemoryId) as { memory_id: string } | undefined;
235
+ if (!oldMemory) throw new Error(`Durable memory not found: ${oldMemoryId}`);
236
+ if (!newMemory) throw new Error(`Durable memory not found: ${newMemoryId}`);
237
+ if (oldMemory.superseded_by) throw new Error(`Durable memory is already superseded: ${oldMemoryId}`);
238
+ db.prepare("UPDATE memories SET superseded_by = ? WHERE memory_id = ?").run(newMemoryId, oldMemoryId);
239
+ }
240
+
241
+ /** Return the complete oldest-to-newest replacement chain containing a durable memory. */
242
+ export function getMemoryHistory(memoryId: string): MemoryRow[] {
243
+ const db = getDb();
244
+ let current = db.prepare("SELECT * FROM memories WHERE memory_id = ?").get(memoryId) as MemoryRow | undefined;
245
+ if (!current) throw new Error(`Durable memory not found: ${memoryId}`);
246
+ while (true) {
247
+ const predecessor = db.prepare("SELECT * FROM memories WHERE superseded_by = ?").get(current.memory_id) as MemoryRow | undefined;
248
+ if (!predecessor) break;
249
+ current = predecessor;
250
+ }
251
+ const history = [current];
252
+ while (current.superseded_by) {
253
+ current = db.prepare("SELECT * FROM memories WHERE memory_id = ?").get(current.superseded_by) as MemoryRow;
254
+ history.push(current);
255
+ }
256
+ return history;
257
+ }
258
+
259
+ /** Permanently delete one durable memory without altering its source transcript turn. */
260
+ export function deleteMemory(memoryId: string): boolean {
261
+ return getDb().prepare("DELETE FROM memories WHERE memory_id = ?").run(memoryId).changes === 1;
262
+ }
263
+
264
+ /** Look up the persisted fingerprint for a source JSONL file. */
98
265
  export function getSourceFile(jsonlPath: string): SourceFileRow | undefined {
99
266
  return getDb().prepare(`
100
267
  SELECT jsonl_path, source, size, mtime_ms, sha256
@@ -103,6 +270,7 @@ export function getSourceFile(jsonlPath: string): SourceFileRow | undefined {
103
270
  `).get(jsonlPath) as SourceFileRow | undefined;
104
271
  }
105
272
 
273
+ /** Store the latest metadata and content hash for an imported source file. */
106
274
  export function upsertSourceFile(row: SourceFileRow): void {
107
275
  getDb().prepare(`
108
276
  INSERT INTO source_files (jsonl_path, source, size, mtime_ms, sha256)
@@ -115,6 +283,7 @@ export function upsertSourceFile(row: SourceFileRow): void {
115
283
  `).run(row.jsonl_path, row.source, row.size, row.mtime_ms, row.sha256);
116
284
  }
117
285
 
286
+ /** Insert session metadata when a source session is first encountered. */
118
287
  export function upsertSession(row: SessionRow): void {
119
288
  getDb().prepare(`
120
289
  INSERT OR IGNORE INTO sessions (session_id, source, cwd, started_at, model_id, jsonl_path)
@@ -122,14 +291,95 @@ export function upsertSession(row: SessionRow): void {
122
291
  `).run(row.session_id, row.source, row.cwd, row.started_at, row.model_id, row.jsonl_path);
123
292
  }
124
293
 
294
+ /** Read one persisted session and an optional inclusive range of its ordered conversation turns. */
295
+ export function getSession(sessionId: string, fromTurnIndex?: number, toTurnIndex?: number): StoredSession {
296
+ const session = getDb().prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId) as SessionRow | undefined;
297
+ if (!session) throw new Error(`Memory session not found: ${sessionId}`);
298
+ const filters = ["session_id = ?"];
299
+ const parameters: Array<string | number> = [sessionId];
300
+ if (fromTurnIndex !== undefined) {
301
+ filters.push("turn_index >= ?");
302
+ parameters.push(fromTurnIndex);
303
+ }
304
+ if (toTurnIndex !== undefined) {
305
+ filters.push("turn_index <= ?");
306
+ parameters.push(toTurnIndex);
307
+ }
308
+ const turns = getDb().prepare(`
309
+ SELECT turn_id, session_id, turn_index, ts, user_text, reply_text, tool_names, user_message_id
310
+ FROM turns
311
+ WHERE ${filters.join(" AND ")}
312
+ ORDER BY turn_index
313
+ `).all(...parameters) as TurnRow[];
314
+ return { session, turns };
315
+ }
316
+
317
+ export interface MemoryStats {
318
+ sessions: number;
319
+ turns: number;
320
+ oldestTs: number | null;
321
+ newestTs: number | null;
322
+ sources: Array<{ source: "pi" | "claude" | "codex"; sessions: number; turns: number }>;
323
+ }
324
+
325
+ /** Summarize locally stored sessions and turns for memory management UI. */
326
+ export function getMemoryStats(): MemoryStats {
327
+ const summary = getDb().prepare(`
328
+ SELECT count(*) AS turns, min(ts) AS oldestTs, max(ts) AS newestTs
329
+ FROM turns
330
+ `).get() as { turns: number; oldestTs: number | null; newestTs: number | null };
331
+ const sessionCount = getDb().prepare("SELECT count(*) AS count FROM sessions").get() as { count: number };
332
+ const sources = getDb().prepare(`
333
+ SELECT sessions.source, count(DISTINCT sessions.session_id) AS sessions, count(turns.turn_id) AS turns
334
+ FROM sessions
335
+ LEFT JOIN turns ON turns.session_id = sessions.session_id
336
+ GROUP BY sessions.source
337
+ ORDER BY sessions.source
338
+ `).all() as MemoryStats["sources"];
339
+ return { sessions: sessionCount.count, turns: summary.turns, oldestTs: summary.oldestTs, newestTs: summary.newestTs, sources };
340
+ }
341
+
342
+ /** Permanently remove one turn and clean up its session if it becomes empty. */
343
+ export function deleteTurn(turnId: string): boolean {
344
+ const db = getDb();
345
+ const turn = db.prepare("SELECT session_id FROM turns WHERE turn_id = ?").get(turnId) as { session_id: string } | undefined;
346
+ if (!turn) return false;
347
+ // Delete the turn and its now-empty session atomically; a failed cleanup must not leave partial state.
348
+ db.exec("BEGIN");
349
+ try {
350
+ db.prepare("DELETE FROM turns WHERE turn_id = ?").run(turnId);
351
+ // Session metadata exists only to support its turns, so remove it after the final turn is forgotten.
352
+ db.prepare(`
353
+ DELETE FROM sessions
354
+ WHERE session_id = ?
355
+ AND NOT EXISTS (SELECT 1 FROM turns WHERE turns.session_id = sessions.session_id)
356
+ `).run(turn.session_id);
357
+ db.exec("COMMIT");
358
+ } catch (error) {
359
+ db.exec("ROLLBACK");
360
+ throw error;
361
+ }
362
+ return true;
363
+ }
364
+
365
+ /** Idempotently insert a normalized conversation turn and report whether it was new. */
125
366
  export function insertTurn(row: TurnRow): boolean {
367
+ const values: Array<string | number | bigint | Uint8Array | null> = [
368
+ row.turn_id, row.session_id, row.turn_index, row.ts,
369
+ row.user_text, row.reply_text, row.tool_names, row.user_message_id,
370
+ ];
371
+ values.forEach((value, index) => _assertSqliteValue(value, index + 1));
372
+
126
373
  const result = getDb().prepare(`
127
374
  INSERT OR IGNORE INTO turns
128
375
  (turn_id, session_id, turn_index, ts, user_text, reply_text, tool_names, user_message_id)
129
376
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
130
- `).run(
131
- row.turn_id, row.session_id, row.turn_index, row.ts,
132
- row.user_text, row.reply_text, row.tool_names, row.user_message_id,
133
- );
377
+ `).run(...values);
134
378
  return result.changes === 1;
135
379
  }
380
+
381
+ /** Reject unsupported SQLite values before binding them to an INSERT statement. */
382
+ function _assertSqliteValue(value: unknown, parameter: number): asserts value is string | number | bigint | Uint8Array | null {
383
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "bigint" || value instanceof Uint8Array) return;
384
+ throw new TypeError(`SQLite parameter ${parameter} must be string, number, bigint, Uint8Array, or null; received ${typeof value}`);
385
+ }
package/src/helper.ts ADDED
@@ -0,0 +1,44 @@
1
+ /** Static user-facing overview displayed by the pi-session-memory helper command. */
2
+ export const SESSION_MEMORY_HELP = `# pi-session-memory
3
+
4
+ 你可以直接像正常聊天一样请求使用历史记忆。
5
+ You can request history and memory features in natural language.
6
+
7
+ **跨客户端回忆与记忆 / Cross-client recall and memory**
8
+
9
+ 无论对话来自 Pi、Claude Code 还是 Codex,你都可以直接请求回忆历史或保存、管理长期记忆。
10
+ Regardless of whether a conversation came from Pi, Claude Code, or Codex, you can directly recall history and save or manage durable memories.
11
+
12
+ - **在 Pi 中无缝接续 Codex 项目会话 / Seamlessly continue Codex project sessions in Pi**
13
+ - 只有当你明确希望在 Pi 中接续某个项目的 Codex 历史工作流时,才建议迁移项目会话。
14
+ - Migrate sessions only when you explicitly want to continue a project's Codex workflow seamlessly in Pi.
15
+ - “请把当前项目以前的 Codex 会话迁移成 Pi session。”
16
+ - “Convert this project's previous Codex sessions into Pi sessions.”
17
+ - 每个 Codex session 会成为一个独立的 Pi session;完成后用 \`/resume\` 选择要继续的会话。
18
+ - Each Codex session becomes an independent Pi session; use \`/resume\` to select the one you want to continue.
19
+ - 只迁移记录的工作目录与当前项目一致的 Codex 会话。
20
+ - Only Codex sessions whose recorded working directory matches the current project are migrated.
21
+
22
+ - **回忆以前的讨论 / Recall past discussions**
23
+ - “我们之前讨论过 xxx 的什么方案?”、“找一下我以前关于 xxx 的结论。”
24
+ - “What did we decide about xxx?” or “Find our earlier conclusion about the xxx.”
25
+ - 我会检索本地历史;必要时会读取匹配会话的相关上下文。
26
+ - I search local history and, when needed, retrieve relevant context from a matching session.
27
+
28
+ - **保存长期结论 / Save a durable conclusion**
29
+ - “记住:发布前必须运行集成测试。”或“把刚才的架构决定固定下来。”
30
+ - “Remember that integration tests must run before release.” or “Save the architecture decision we just made.”
31
+
32
+ - **管理保存的记忆 / Manage saved memories**
33
+ - “列出我保存的记忆”、“确认这条记忆仍然有效”、“用新结论替换旧记忆”,或“忘记那条部署约定。”
34
+ - “List my saved memories,” “confirm this memory is still current,” “replace the old memory with this conclusion,” or “forget that deployment convention.”
35
+
36
+ - **查看当前存储情况 / Check local storage**
37
+ - “现在已经导入了多少历史记录?”
38
+ - “How much conversation history has been imported?”
39
+
40
+ 历史和记忆均保存在本机 SQLite 中。历史导入会按来源和文件隔离错误:一个不兼容的 Pi、Claude Code 或 Codex 会话不会阻止其他会话被导入。
41
+ History and memories stay in local SQLite storage. Import errors are isolated by source and file, so one incompatible Pi, Claude Code, or Codex session does not block other sessions.
42
+
43
+ 随时运行 \`/pi-session-memory-helper\` 再次查看这些使用方式。
44
+ Run \`/pi-session-memory-helper\` at any time to view this guide again.`;