pi-session-memory 0.1.3 → 0.2.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/AGENTS.md +5 -0
- package/README.md +90 -10
- package/extensions/index.ts +255 -16
- package/package.json +1 -1
- package/spec.md +19 -145
- package/src/backfill.ts +121 -21
- package/src/db.ts +290 -4
- package/src/helper.ts +44 -0
- package/src/retriever.ts +208 -48
- package/src/session-migration.ts +188 -0
- package/src/writer.ts +3 -0
- package/tests/core.test.ts +223 -4
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
|
|
|
@@ -30,10 +31,37 @@ CREATE TABLE IF NOT EXISTS turns (
|
|
|
30
31
|
|
|
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);
|
|
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
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS source_files (
|
|
54
|
+
jsonl_path TEXT PRIMARY KEY,
|
|
55
|
+
source TEXT NOT NULL,
|
|
56
|
+
size INTEGER NOT NULL,
|
|
57
|
+
mtime_ms REAL NOT NULL,
|
|
58
|
+
sha256 TEXT NOT NULL
|
|
59
|
+
);
|
|
33
60
|
`;
|
|
34
61
|
|
|
35
62
|
let _db: DatabaseSync | undefined;
|
|
36
63
|
|
|
64
|
+
/** Open the singleton SQLite database and ensure its schema is ready for use. */
|
|
37
65
|
export function getDb(): DatabaseSync {
|
|
38
66
|
if (_db) return _db;
|
|
39
67
|
mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
|
|
@@ -45,6 +73,7 @@ export function getDb(): DatabaseSync {
|
|
|
45
73
|
return _db;
|
|
46
74
|
}
|
|
47
75
|
|
|
76
|
+
/** Apply additive schema migrations required by newer memory formats. */
|
|
48
77
|
function _migrate(db: DatabaseSync): void {
|
|
49
78
|
const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
|
|
50
79
|
if (!sessionColumns.some((column) => column.name === "source")) {
|
|
@@ -57,6 +86,17 @@ function _migrate(db: DatabaseSync): void {
|
|
|
57
86
|
db.exec("DELETE FROM turns WHERE user_message_id IS NULL");
|
|
58
87
|
db.exec("DELETE FROM sessions WHERE session_id NOT IN (SELECT DISTINCT session_id FROM turns)");
|
|
59
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
|
+
}
|
|
60
100
|
}
|
|
61
101
|
|
|
62
102
|
export interface SessionRow {
|
|
@@ -79,6 +119,171 @@ export interface TurnRow {
|
|
|
79
119
|
user_message_id: string;
|
|
80
120
|
}
|
|
81
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
|
+
}
|
|
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. */
|
|
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. */
|
|
82
287
|
export function upsertSession(row: SessionRow): void {
|
|
83
288
|
getDb().prepare(`
|
|
84
289
|
INSERT OR IGNORE INTO sessions (session_id, source, cwd, started_at, model_id, jsonl_path)
|
|
@@ -86,14 +291,95 @@ export function upsertSession(row: SessionRow): void {
|
|
|
86
291
|
`).run(row.session_id, row.source, row.cwd, row.started_at, row.model_id, row.jsonl_path);
|
|
87
292
|
}
|
|
88
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. */
|
|
89
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
|
+
|
|
90
373
|
const result = getDb().prepare(`
|
|
91
374
|
INSERT OR IGNORE INTO turns
|
|
92
375
|
(turn_id, session_id, turn_index, ts, user_text, reply_text, tool_names, user_message_id)
|
|
93
376
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
94
|
-
`).run(
|
|
95
|
-
row.turn_id, row.session_id, row.turn_index, row.ts,
|
|
96
|
-
row.user_text, row.reply_text, row.tool_names, row.user_message_id,
|
|
97
|
-
);
|
|
377
|
+
`).run(...values);
|
|
98
378
|
return result.changes === 1;
|
|
99
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.`;
|
package/src/retriever.ts
CHANGED
|
@@ -1,70 +1,230 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { getDb, type MemoryKind } from "./db.ts";
|
|
2
3
|
|
|
3
|
-
export
|
|
4
|
+
export type MemorySource = "pi" | "claude" | "codex";
|
|
5
|
+
|
|
6
|
+
export interface RecallOptions {
|
|
7
|
+
query: string;
|
|
8
|
+
entities?: string[];
|
|
9
|
+
topK?: number;
|
|
10
|
+
sources?: MemorySource[];
|
|
11
|
+
cwd?: string;
|
|
12
|
+
after?: number;
|
|
13
|
+
before?: number;
|
|
14
|
+
diversify?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RecallTurnResult {
|
|
18
|
+
type: "turn";
|
|
4
19
|
turn_id: string;
|
|
5
|
-
|
|
20
|
+
session_id: string;
|
|
21
|
+
turn_index: number;
|
|
22
|
+
source: MemorySource;
|
|
23
|
+
cwd: string;
|
|
6
24
|
ts: number;
|
|
7
25
|
user_text: string;
|
|
8
26
|
reply_text: string;
|
|
9
27
|
hits: number;
|
|
28
|
+
score: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RecallDurableMemoryResult {
|
|
32
|
+
type: "memory";
|
|
33
|
+
memory_id: string;
|
|
34
|
+
kind: MemoryKind;
|
|
35
|
+
content: string;
|
|
36
|
+
project_key: string;
|
|
37
|
+
source_turn_id: string | null;
|
|
38
|
+
source_session_id: string | null;
|
|
39
|
+
source_content_hash: string | null;
|
|
40
|
+
source_turn_index: number | null;
|
|
41
|
+
freshness_candidate: boolean;
|
|
42
|
+
created_at: number;
|
|
43
|
+
last_confirmed_at: number;
|
|
44
|
+
importance: number;
|
|
45
|
+
hits: number;
|
|
46
|
+
score: number;
|
|
10
47
|
}
|
|
11
48
|
|
|
12
|
-
export
|
|
13
|
-
if (entities.length === 0) return [];
|
|
49
|
+
export type RecallResult = RecallDurableMemoryResult | RecallTurnResult;
|
|
14
50
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const scoreParameters = entities.flatMap((entity) => {
|
|
20
|
-
const pattern = _likePattern(entity);
|
|
21
|
-
return [pattern, pattern];
|
|
22
|
-
});
|
|
51
|
+
// Avoid allowing one long conversation to fill every raw-transcript recall slot.
|
|
52
|
+
const MAX_TURNS_PER_SESSION = 2;
|
|
53
|
+
// Recency is a bounded tie-breaker, not a replacement for literal relevance.
|
|
54
|
+
const RECENCY_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
|
|
23
55
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const whereParameters = entities.flatMap((entity) => {
|
|
29
|
-
const pattern = _likePattern(entity);
|
|
30
|
-
return [pattern, pattern];
|
|
31
|
-
});
|
|
56
|
+
/** Retained for compatibility with the v0.1 public retrieval helper. */
|
|
57
|
+
export function recallTurns(entities: string[], topK = 5): RecallTurnResult[] {
|
|
58
|
+
return _recallTurns({ query: entities.join(" "), entities, topK, diversify: false });
|
|
59
|
+
}
|
|
32
60
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
/** Retrieve active durable memories, then raw turns not already represented by unchanged source evidence. */
|
|
62
|
+
export function recallMemories(options: RecallOptions): RecallResult[] {
|
|
63
|
+
const durableMemories = _recallDurableMemories(options);
|
|
64
|
+
const recalledTurns = _recallTurns(options);
|
|
65
|
+
const freshnessCandidates = new Set(
|
|
66
|
+
durableMemories
|
|
67
|
+
.filter((memory) => memory.source_session_id && memory.source_turn_index !== null)
|
|
68
|
+
.filter((memory) => recalledTurns.some((turn) => turn.session_id === memory.source_session_id && turn.turn_index > memory.source_turn_index!))
|
|
69
|
+
.map((memory) => memory.memory_id),
|
|
70
|
+
);
|
|
71
|
+
const memories = durableMemories.map((memory) => ({ ...memory, freshness_candidate: freshnessCandidates.has(memory.memory_id) }));
|
|
72
|
+
const coveredSourceHashes = new Map(
|
|
73
|
+
memories
|
|
74
|
+
.filter((memory) => memory.source_turn_id && memory.source_content_hash)
|
|
75
|
+
.map((memory) => [memory.source_turn_id!, memory.source_content_hash!]),
|
|
76
|
+
);
|
|
77
|
+
const rawTurns = recalledTurns.filter((turn) => coveredSourceHashes.get(turn.turn_id) !== _turnContentHash(turn));
|
|
78
|
+
return [...memories, ...rawTurns].slice(0, options.topK ?? 5);
|
|
47
79
|
}
|
|
48
80
|
|
|
49
|
-
|
|
50
|
-
|
|
81
|
+
/** Render the exact query inputs and recall results as concise Markdown for a command notification or tool response. */
|
|
82
|
+
export function formatRecallResults(results: RecallResult[], options?: Pick<RecallOptions, "query" | "entities" | "sources" | "cwd" | "after" | "before">): string {
|
|
83
|
+
const lines = options ? [_formatRecallQuery(options), ""] : [];
|
|
84
|
+
if (results.length === 0) return [...lines, "No relevant past conversations found."].join("\n");
|
|
51
85
|
|
|
52
|
-
|
|
86
|
+
lines.push("## Relevant past memories\n");
|
|
53
87
|
for (const result of results) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
88
|
+
if (result.type === "memory") {
|
|
89
|
+
lines.push(`### [durable ${result.kind} · ${new Date(result.last_confirmed_at).toLocaleString()}]`);
|
|
90
|
+
lines.push(result.content);
|
|
91
|
+
lines.push(`**Memory ID:** ${result.memory_id}`);
|
|
92
|
+
if (result.source_turn_id) lines.push(`**Source turn:** ${result.source_turn_id}`);
|
|
93
|
+
if (result.source_session_id) lines.push(`**Source session:** ${result.source_session_id}`);
|
|
94
|
+
if (result.freshness_candidate) lines.push("**Freshness:** newer matching turn exists in the source session; confirm or supersede this memory.");
|
|
95
|
+
} else {
|
|
96
|
+
const date = new Date(result.ts).toLocaleString();
|
|
97
|
+
lines.push(`### [${result.source} · ${date}]`);
|
|
98
|
+
lines.push(`**Session:** ${result.session_id} · **Turn:** ${result.turn_index}`);
|
|
99
|
+
lines.push(`**Excerpt:** ${_excerpt(result.user_text || result.reply_text)}`);
|
|
100
|
+
lines.push("Use `fetch_session` with this session ID when the surrounding conversation is needed.");
|
|
62
101
|
}
|
|
63
102
|
lines.push("");
|
|
64
103
|
}
|
|
65
104
|
return lines.join("\n");
|
|
66
105
|
}
|
|
67
106
|
|
|
68
|
-
|
|
69
|
-
|
|
107
|
+
/** Make each tool invocation auditable by showing its exact literal terms and scopes. */
|
|
108
|
+
function _formatRecallQuery(options: Pick<RecallOptions, "query" | "entities" | "sources" | "cwd" | "after" | "before">): string {
|
|
109
|
+
const filters = [
|
|
110
|
+
options.entities?.length ? `entities: ${options.entities.map((entity) => `\`${entity}\``).join(", ")}` : null,
|
|
111
|
+
options.sources?.length ? `sources: ${options.sources.join(", ")}` : null,
|
|
112
|
+
options.cwd ? `cwd: \`${options.cwd}\`` : null,
|
|
113
|
+
options.after !== undefined ? `after: ${new Date(options.after).toISOString()}` : null,
|
|
114
|
+
options.before !== undefined ? `before: ${new Date(options.before).toISOString()}` : null,
|
|
115
|
+
].filter(Boolean);
|
|
116
|
+
return `**Search query:** \`${options.query}\`${filters.length ? ` \\n**Filters:** ${filters.join(" · ")}` : ""}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Keep discovery results small; full persisted turn text belongs to fetch_session. */
|
|
120
|
+
function _excerpt(text: string, maxLength = 240): string {
|
|
121
|
+
const normalized = text.replaceAll(/\s+/g, " ").trim();
|
|
122
|
+
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}…` : normalized;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Search active durable memories with the same escaped literal matching used for transcript recall. */
|
|
126
|
+
function _recallDurableMemories(options: RecallOptions): RecallDurableMemoryResult[] {
|
|
127
|
+
const terms = _terms(options);
|
|
128
|
+
if (terms.length === 0) return [];
|
|
129
|
+
const scoreExpression = terms.map(() => "CASE WHEN LOWER(content) LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END").join(" + ");
|
|
130
|
+
const parameters = terms.map(_likePattern);
|
|
131
|
+
const filters = terms.map(() => "LOWER(content) LIKE ? ESCAPE '\\'");
|
|
132
|
+
const filterParameters: Array<string | number> = terms.map(_likePattern);
|
|
133
|
+
filters.push("superseded_by IS NULL");
|
|
134
|
+
if (options.cwd) {
|
|
135
|
+
filters.push("project_key = ?");
|
|
136
|
+
filterParameters.push(options.cwd);
|
|
137
|
+
}
|
|
138
|
+
if (options.after !== undefined) {
|
|
139
|
+
filters.push("last_confirmed_at >= ?");
|
|
140
|
+
filterParameters.push(options.after);
|
|
141
|
+
}
|
|
142
|
+
if (options.before !== undefined) {
|
|
143
|
+
filters.push("last_confirmed_at <= ?");
|
|
144
|
+
filterParameters.push(options.before);
|
|
145
|
+
}
|
|
146
|
+
return getDb().prepare(`
|
|
147
|
+
SELECT memory_id, kind, content, project_key, source_turn_id, source_session_id, source_content_hash, source_turn_index, created_at, last_confirmed_at, importance,
|
|
148
|
+
(${scoreExpression}) AS hits
|
|
149
|
+
FROM memories
|
|
150
|
+
WHERE ${filters.join(" AND ")}
|
|
151
|
+
ORDER BY hits DESC, importance DESC, last_confirmed_at DESC
|
|
152
|
+
LIMIT ?
|
|
153
|
+
`).all(...parameters, ...filterParameters, Math.max(options.topK ?? 5, 1))
|
|
154
|
+
.map((memory) => ({ ...memory, type: "memory" as const, freshness_candidate: false, score: memory.hits + memory.importance })) as RecallDurableMemoryResult[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Retrieve and rank locally stored turns using literal query terms and optional scopes. */
|
|
158
|
+
function _recallTurns(options: RecallOptions): RecallTurnResult[] {
|
|
159
|
+
const terms = _terms(options);
|
|
160
|
+
if (terms.length === 0) return [];
|
|
161
|
+
const scoreExpression = terms.map(() => `(CASE WHEN LOWER(turns.user_text) LIKE ? ESCAPE '\\' THEN 2 ELSE 0 END + CASE WHEN LOWER(turns.reply_text) LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END)`).join(" + ");
|
|
162
|
+
const scoreParameters = terms.flatMap((term) => _likeParameters(term));
|
|
163
|
+
const whereExpressions = terms.map(() => "(LOWER(turns.user_text) LIKE ? ESCAPE '\\' OR LOWER(turns.reply_text) LIKE ? ESCAPE '\\')");
|
|
164
|
+
const whereParameters = terms.flatMap((term) => _likeParameters(term));
|
|
165
|
+
const filters = [...whereExpressions];
|
|
166
|
+
const filterParameters: Array<string | number> = [...whereParameters];
|
|
167
|
+
if (options.sources?.length) {
|
|
168
|
+
filters.push(`sessions.source IN (${options.sources.map(() => "?").join(", ")})`);
|
|
169
|
+
filterParameters.push(...options.sources);
|
|
170
|
+
}
|
|
171
|
+
if (options.cwd) {
|
|
172
|
+
filters.push("sessions.cwd = ?");
|
|
173
|
+
filterParameters.push(options.cwd);
|
|
174
|
+
}
|
|
175
|
+
if (options.after !== undefined) {
|
|
176
|
+
filters.push("turns.ts >= ?");
|
|
177
|
+
filterParameters.push(options.after);
|
|
178
|
+
}
|
|
179
|
+
if (options.before !== undefined) {
|
|
180
|
+
filters.push("turns.ts <= ?");
|
|
181
|
+
filterParameters.push(options.before);
|
|
182
|
+
}
|
|
183
|
+
const candidates = getDb().prepare(`
|
|
184
|
+
SELECT turns.turn_id, turns.session_id, turns.turn_index, sessions.source, sessions.cwd, turns.ts, turns.user_text, turns.reply_text,
|
|
185
|
+
(${scoreExpression}) AS hits
|
|
186
|
+
FROM turns JOIN sessions ON sessions.session_id = turns.session_id
|
|
187
|
+
WHERE ${filters.join(" AND ")}
|
|
188
|
+
ORDER BY hits DESC, turns.ts DESC LIMIT ?
|
|
189
|
+
`).all(...scoreParameters, ...filterParameters, Math.max(options.topK ?? 5, 1) * 10) as Array<Omit<RecallTurnResult, "type" | "score">>;
|
|
190
|
+
const newestTs = candidates.reduce((newest, result) => Math.max(newest, result.ts), 0);
|
|
191
|
+
const results = candidates.map((result) => ({ ...result, type: "turn" as const, score: result.hits + _recencyScore(result.ts, newestTs) + (options.cwd === result.cwd ? 0.8 : 0) })).sort((left, right) => right.score - left.score || right.ts - left.ts);
|
|
192
|
+
return options.diversify === false ? results.slice(0, options.topK ?? 5) : _diversify(results, options.topK ?? 5);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Hash the current raw turn evidence using the same representation captured during pinning. */
|
|
196
|
+
function _turnContentHash(turn: RecallTurnResult): string {
|
|
197
|
+
return createHash("sha256").update(JSON.stringify([turn.user_text, turn.reply_text])).digest("hex");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Build a de-duplicated set of non-empty literal search terms from the request. */
|
|
201
|
+
function _terms(options: RecallOptions): string[] {
|
|
202
|
+
return [...new Set([options.query, ...(options.entities ?? [])].map((term) => term.trim()).filter(Boolean))];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Produce matching user and assistant SQL LIKE parameters for one term. */
|
|
206
|
+
function _likeParameters(term: string): [string, string] {
|
|
207
|
+
const pattern = _likePattern(term);
|
|
208
|
+
return [pattern, pattern];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Convert one literal search term into an escaped, case-normalized SQL LIKE pattern. */
|
|
212
|
+
function _likePattern(term: string): string {
|
|
213
|
+
return `%${term.toLowerCase().replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Return a bounded recency bonus relative to the newest candidate timestamp. */
|
|
217
|
+
function _recencyScore(ts: number, newestTs: number): number {
|
|
218
|
+
return Math.max(0, 0.5 * (1 - (newestTs - ts) / RECENCY_WINDOW_MS));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Limit ranked output to prevent any one session from dominating the recall window. */
|
|
222
|
+
function _diversify(results: RecallTurnResult[], topK: number): RecallTurnResult[] {
|
|
223
|
+
const counts = new Map<string, number>();
|
|
224
|
+
return results.filter((result) => {
|
|
225
|
+
const count = counts.get(result.session_id) ?? 0;
|
|
226
|
+
if (count >= MAX_TURNS_PER_SESSION) return false;
|
|
227
|
+
counts.set(result.session_id, count + 1);
|
|
228
|
+
return true;
|
|
229
|
+
}).slice(0, topK);
|
|
70
230
|
}
|