pi-session-memory 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -184
- package/extensions/index.ts +67 -291
- package/package.json +5 -3
- package/src/backfill.ts +16 -4
- package/src/db.ts +33 -336
- package/src/fetch-session.ts +8 -0
- package/src/helper.ts +21 -41
- package/src/retriever.ts +25 -289
- package/src/writer.ts +5 -2
package/src/db.ts
CHANGED
|
@@ -2,66 +2,29 @@ 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";
|
|
6
5
|
|
|
7
6
|
const DB_PATH = process.env.MEMORY_DB_PATH ?? join(homedir(), ".pi", "agent", "memory.db");
|
|
8
7
|
|
|
9
8
|
const SCHEMA = `
|
|
10
9
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
11
|
-
session_id
|
|
12
|
-
|
|
13
|
-
cwd TEXT NOT NULL,
|
|
14
|
-
started_at INTEGER NOT NULL,
|
|
15
|
-
model_id TEXT,
|
|
16
|
-
jsonl_path TEXT NOT NULL
|
|
10
|
+
session_id TEXT PRIMARY KEY, source TEXT NOT NULL DEFAULT 'pi', cwd TEXT NOT NULL,
|
|
11
|
+
started_at INTEGER NOT NULL, model_id TEXT, jsonl_path TEXT NOT NULL
|
|
17
12
|
);
|
|
18
|
-
|
|
19
13
|
CREATE INDEX IF NOT EXISTS idx_sessions_time ON sessions(started_at DESC);
|
|
20
|
-
|
|
21
14
|
CREATE TABLE IF NOT EXISTS turns (
|
|
22
|
-
turn_id
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
ts INTEGER NOT NULL,
|
|
26
|
-
user_text TEXT NOT NULL,
|
|
27
|
-
reply_text TEXT NOT NULL,
|
|
28
|
-
tool_names TEXT,
|
|
29
|
-
user_message_id TEXT
|
|
15
|
+
turn_id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
|
16
|
+
turn_index INTEGER NOT NULL, ts INTEGER NOT NULL, user_text TEXT NOT NULL, reply_text TEXT NOT NULL,
|
|
17
|
+
tool_names TEXT, user_message_id TEXT
|
|
30
18
|
);
|
|
31
|
-
|
|
32
19
|
CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id, turn_index);
|
|
33
|
-
CREATE INDEX IF NOT EXISTS idx_turns_ts
|
|
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
|
-
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_turns_ts ON turns(ts DESC);
|
|
53
21
|
CREATE TABLE IF NOT EXISTS source_files (
|
|
54
|
-
jsonl_path TEXT PRIMARY KEY,
|
|
55
|
-
|
|
56
|
-
size INTEGER NOT NULL,
|
|
57
|
-
mtime_ms REAL NOT NULL,
|
|
58
|
-
sha256 TEXT NOT NULL
|
|
59
|
-
);
|
|
60
|
-
`;
|
|
22
|
+
jsonl_path TEXT PRIMARY KEY, source TEXT NOT NULL, size INTEGER NOT NULL, mtime_ms REAL NOT NULL, sha256 TEXT NOT NULL
|
|
23
|
+
);`;
|
|
61
24
|
|
|
62
25
|
let _db: DatabaseSync | undefined;
|
|
63
26
|
|
|
64
|
-
/** Open the singleton
|
|
27
|
+
/** Open the singleton local transcript index and upgrade its raw-history schema. */
|
|
65
28
|
export function getDb(): DatabaseSync {
|
|
66
29
|
if (_db) return _db;
|
|
67
30
|
mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
|
|
@@ -70,316 +33,50 @@ export function getDb(): DatabaseSync {
|
|
|
70
33
|
_db.exec("PRAGMA synchronous = NORMAL");
|
|
71
34
|
_db.exec(SCHEMA);
|
|
72
35
|
_migrate(_db);
|
|
36
|
+
_db.exec(SCHEMA);
|
|
73
37
|
return _db;
|
|
74
38
|
}
|
|
75
39
|
|
|
76
|
-
/**
|
|
40
|
+
/** Keep only raw session history when opening databases created by earlier package versions. */
|
|
77
41
|
function _migrate(db: DatabaseSync): void {
|
|
78
42
|
const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
|
|
79
|
-
if (!sessionColumns.some((column) => column.name === "source"))
|
|
80
|
-
db.exec("ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'pi'");
|
|
81
|
-
}
|
|
82
|
-
|
|
43
|
+
if (!sessionColumns.some((column) => column.name === "source")) db.exec("ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'pi'");
|
|
83
44
|
const turnColumns = db.prepare("PRAGMA table_info(turns)").all() as Array<{ name: string }>;
|
|
84
45
|
if (!turnColumns.some((column) => column.name === "user_message_id")) {
|
|
85
46
|
db.exec("ALTER TABLE turns ADD COLUMN user_message_id TEXT");
|
|
86
47
|
db.exec("DELETE FROM turns WHERE user_message_id IS NULL");
|
|
87
48
|
db.exec("DELETE FROM sessions WHERE session_id NOT IN (SELECT DISTINCT session_id FROM turns)");
|
|
88
49
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
}
|
|
50
|
+
db.exec("DROP TABLE IF EXISTS memory_sources");
|
|
51
|
+
db.exec("DROP TABLE IF EXISTS cache_migration_audit");
|
|
52
|
+
db.exec("DROP TABLE IF EXISTS memories");
|
|
100
53
|
}
|
|
101
54
|
|
|
102
|
-
export interface SessionRow {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
model_id: string | null;
|
|
108
|
-
jsonl_path: string;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export interface TurnRow {
|
|
112
|
-
turn_id: string;
|
|
113
|
-
session_id: string;
|
|
114
|
-
turn_index: number;
|
|
115
|
-
ts: number;
|
|
116
|
-
user_text: string;
|
|
117
|
-
reply_text: string;
|
|
118
|
-
tool_names: string | null;
|
|
119
|
-
user_message_id: string;
|
|
120
|
-
}
|
|
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
|
-
|
|
144
|
-
export interface SourceFileRow {
|
|
145
|
-
jsonl_path: string;
|
|
146
|
-
source: "pi" | "claude" | "codex";
|
|
147
|
-
size: number;
|
|
148
|
-
mtime_ms: number;
|
|
149
|
-
sha256: string;
|
|
150
|
-
}
|
|
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
|
-
}
|
|
55
|
+
export interface SessionRow { session_id: string; source: "pi" | "claude" | "codex"; cwd: string; started_at: number; model_id: string | null; jsonl_path: string; }
|
|
56
|
+
export interface TurnRow { turn_id: string; session_id: string; turn_index: number; ts: number; user_text: string; reply_text: string; tool_names: string | null; user_message_id: string; }
|
|
57
|
+
export interface StoredSession { session: SessionRow; turns: TurnRow[]; }
|
|
58
|
+
export interface SourceFileRow { jsonl_path: string; source: "pi" | "claude" | "codex"; size: number; mtime_ms: number; sha256: string; }
|
|
59
|
+
export interface HistoryStats { sessions: number; turns: number; oldestTs: number | null; newestTs: number | null; sources: Array<{ source: "pi" | "claude" | "codex"; sessions: number; turns: number }>; }
|
|
211
60
|
|
|
212
|
-
|
|
213
|
-
export function
|
|
214
|
-
|
|
215
|
-
|
|
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. */
|
|
265
|
-
export function getSourceFile(jsonlPath: string): SourceFileRow | undefined {
|
|
266
|
-
return getDb().prepare(`
|
|
267
|
-
SELECT jsonl_path, source, size, mtime_ms, sha256
|
|
268
|
-
FROM source_files
|
|
269
|
-
WHERE jsonl_path = ?
|
|
270
|
-
`).get(jsonlPath) as SourceFileRow | undefined;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** Store the latest metadata and content hash for an imported source file. */
|
|
274
|
-
export function upsertSourceFile(row: SourceFileRow): void {
|
|
275
|
-
getDb().prepare(`
|
|
276
|
-
INSERT INTO source_files (jsonl_path, source, size, mtime_ms, sha256)
|
|
277
|
-
VALUES (?, ?, ?, ?, ?)
|
|
278
|
-
ON CONFLICT(jsonl_path) DO UPDATE SET
|
|
279
|
-
source = excluded.source,
|
|
280
|
-
size = excluded.size,
|
|
281
|
-
mtime_ms = excluded.mtime_ms,
|
|
282
|
-
sha256 = excluded.sha256
|
|
283
|
-
`).run(row.jsonl_path, row.source, row.size, row.mtime_ms, row.sha256);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** Insert session metadata when a source session is first encountered. */
|
|
287
|
-
export function upsertSession(row: SessionRow): void {
|
|
288
|
-
getDb().prepare(`
|
|
289
|
-
INSERT OR IGNORE INTO sessions (session_id, source, cwd, started_at, model_id, jsonl_path)
|
|
290
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
291
|
-
`).run(row.session_id, row.source, row.cwd, row.started_at, row.model_id, row.jsonl_path);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/** Read one persisted session and an optional inclusive range of its ordered conversation turns. */
|
|
61
|
+
export function getSourceFile(jsonlPath: string): SourceFileRow | undefined { return getDb().prepare("SELECT jsonl_path, source, size, mtime_ms, sha256 FROM source_files WHERE jsonl_path = ?").get(jsonlPath) as SourceFileRow | undefined; }
|
|
62
|
+
export function upsertSourceFile(row: SourceFileRow): void { getDb().prepare(`INSERT INTO source_files (jsonl_path, source, size, mtime_ms, sha256) VALUES (?, ?, ?, ?, ?)
|
|
63
|
+
ON CONFLICT(jsonl_path) DO UPDATE SET source = excluded.source, size = excluded.size, mtime_ms = excluded.mtime_ms, sha256 = excluded.sha256`).run(row.jsonl_path, row.source, row.size, row.mtime_ms, row.sha256); }
|
|
64
|
+
export function upsertSession(row: SessionRow): void { getDb().prepare("INSERT OR IGNORE INTO sessions (session_id, source, cwd, started_at, model_id, jsonl_path) VALUES (?, ?, ?, ?, ?, ?)").run(row.session_id, row.source, row.cwd, row.started_at, row.model_id, row.jsonl_path); }
|
|
295
65
|
export function getSession(sessionId: string, fromTurnIndex?: number, toTurnIndex?: number): StoredSession {
|
|
296
66
|
const session = getDb().prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId) as SessionRow | undefined;
|
|
297
|
-
if (!session) throw new Error(`
|
|
298
|
-
const filters = ["session_id = ?"];
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
|
|
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[];
|
|
67
|
+
if (!session) throw new Error(`History session not found: ${sessionId}`);
|
|
68
|
+
const filters = ["session_id = ?"]; const parameters: Array<string | number> = [sessionId];
|
|
69
|
+
if (fromTurnIndex !== undefined) { filters.push("turn_index >= ?"); parameters.push(fromTurnIndex); }
|
|
70
|
+
if (toTurnIndex !== undefined) { filters.push("turn_index <= ?"); parameters.push(toTurnIndex); }
|
|
71
|
+
const turns = getDb().prepare(`SELECT turn_id, session_id, turn_index, ts, user_text, reply_text, tool_names, user_message_id FROM turns WHERE ${filters.join(" AND ")} ORDER BY turn_index`).all(...parameters) as TurnRow[];
|
|
314
72
|
return { session, turns };
|
|
315
73
|
}
|
|
316
|
-
|
|
317
|
-
|
|
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 };
|
|
74
|
+
export function getHistoryStats(): HistoryStats {
|
|
75
|
+
const summary = getDb().prepare("SELECT count(*) AS turns, min(ts) AS oldestTs, max(ts) AS newestTs FROM turns").get() as { turns: number; oldestTs: number | null; newestTs: number | null };
|
|
331
76
|
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"];
|
|
77
|
+
const sources = getDb().prepare("SELECT sessions.source, count(DISTINCT sessions.session_id) AS sessions, count(turns.turn_id) AS turns FROM sessions LEFT JOIN turns ON turns.session_id = sessions.session_id GROUP BY sessions.source ORDER BY sessions.source").all() as HistoryStats["sources"];
|
|
339
78
|
return { sessions: sessionCount.count, turns: summary.turns, oldestTs: summary.oldestTs, newestTs: summary.newestTs, sources };
|
|
340
79
|
}
|
|
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. */
|
|
366
80
|
export function insertTurn(row: TurnRow): boolean {
|
|
367
|
-
|
|
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
|
-
|
|
373
|
-
const result = getDb().prepare(`
|
|
374
|
-
INSERT OR IGNORE INTO turns
|
|
375
|
-
(turn_id, session_id, turn_index, ts, user_text, reply_text, tool_names, user_message_id)
|
|
376
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
377
|
-
`).run(...values);
|
|
378
|
-
return result.changes === 1;
|
|
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}`);
|
|
81
|
+
return getDb().prepare("INSERT OR IGNORE INTO turns (turn_id, session_id, turn_index, ts, user_text, reply_text, tool_names, user_message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(row.turn_id, row.session_id, row.turn_index, row.ts, row.user_text, row.reply_text, row.tool_names, row.user_message_id).changes === 1;
|
|
385
82
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { getSession, type StoredSession } from "./db.ts";
|
|
2
|
+
|
|
3
|
+
export interface FetchedSessionResult { stored: StoredSession; }
|
|
4
|
+
|
|
5
|
+
/** Fetch original persisted transcript evidence without changing the local transcript index. */
|
|
6
|
+
export function fetchSession(sessionId: string, fromTurnIndex?: number, toTurnIndex?: number): FetchedSessionResult {
|
|
7
|
+
return { stored: getSession(sessionId, fromTurnIndex, toTurnIndex) };
|
|
8
|
+
}
|
package/src/helper.ts
CHANGED
|
@@ -1,44 +1,24 @@
|
|
|
1
1
|
/** Static user-facing overview displayed by the pi-session-memory helper command. */
|
|
2
2
|
export const SESSION_MEMORY_HELP = `# pi-session-memory
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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.`;
|
|
4
|
+
本插件将 Pi、Claude Code 和 Codex 的本地历史索引到 SQLite,供按需跨会话检索。
|
|
5
|
+
This extension indexes local Pi, Claude Code, and Codex history for on-demand cross-session retrieval.
|
|
6
|
+
|
|
7
|
+
- **检索以前的讨论 / Recall past discussions**
|
|
8
|
+
- 直接询问“我们之前讨论过 xxx 的什么方案?”或 “What did we decide about xxx?”
|
|
9
|
+
- Pi 只在需要时搜索本地历史;匹配摘要不足时才读取最小必要会话范围。
|
|
10
|
+
- 历史不会自动注入模型上下文,也不会保存 Agent 生成的记忆摘要。
|
|
11
|
+
|
|
12
|
+
- **迁移项目会话 / Migrate project sessions**
|
|
13
|
+
- 只有希望通过 \`/resume\` 在 Pi 原生继续 Claude Code 或 Codex 项目会话时,才运行迁移。
|
|
14
|
+
- 使用 \`/project-claude-session-migration\` 或 \`/project-session-migration\`。
|
|
15
|
+
- 每个迁移源会话生成一个独立 Pi session。
|
|
16
|
+
|
|
17
|
+
- **本地存储 / Local storage**
|
|
18
|
+
- \`/memory-search <query>\` 搜索原始本地 transcript。
|
|
19
|
+
- \`/memory-backfill\` 明确要求时全量重扫历史。
|
|
20
|
+
- \`/memory-status\` 显示已索引的 session 和 turn 总数。
|
|
21
|
+
- 持久规则、偏好和项目指令应写在 \`AGENTS.md\`。
|
|
22
|
+
|
|
23
|
+
历史仅保存在本机 SQLite。导入错误按来源文件隔离,不会阻止其他 Pi、Claude Code 或 Codex 会话被索引。
|
|
24
|
+
History stays in local SQLite; an import error in one source file does not block other sessions.`;
|