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/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
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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.
|
|
74
|
-
|
|
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({
|
|
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
|
-
|
|
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:
|
|
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.`;
|