pi-session-memory 0.1.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.
@@ -0,0 +1,5 @@
1
+ # CodeGraph data files — local to each machine, not for committing.
2
+ # Ignore everything in .codegraph/ except this file itself, so transient
3
+ # files (the database, daemon.pid, sockets, logs) never show up in git.
4
+ *
5
+ !.gitignore
@@ -0,0 +1,66 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { writeTurn } from "../src/writer.ts";
4
+ import { recallTurns, formatRecallResults } from "../src/retriever.ts";
5
+ import { backfillAll } from "../src/backfill.ts";
6
+
7
+ export default function (pi: ExtensionAPI) {
8
+
9
+ // ── Write: persist each completed agent run to SQLite ────────────────────
10
+ pi.on("agent_settled", async (_event, ctx) => {
11
+ try {
12
+ writeTurn(ctx);
13
+ } catch (err) {
14
+ ctx.ui.notify(`[session-memory] write failed: ${String(err)}`, "error");
15
+ }
16
+ });
17
+
18
+ pi.registerCommand("memory-backfill", {
19
+ description: "Import historical Pi, Claude Code, and Codex sessions into memory.db",
20
+ handler: async (_args, ctx) => {
21
+ const stats = backfillAll();
22
+ ctx.ui.notify(
23
+ `[session-memory] imported ${stats.turns} turns from ${stats.pi} Pi, ${stats.claude} Claude, ${stats.codex} Codex sessions`,
24
+ "info",
25
+ );
26
+ },
27
+ });
28
+
29
+ // ── Read: recall_memory tool ──────────────────────────────────────────────
30
+ pi.registerTool({
31
+ name: "recall_memory",
32
+ label: "Recall Memory",
33
+ description: `Search this user's past conversation history across Pi, Claude Code, and Codex.
34
+
35
+ Invocation policy:
36
+ 1. Call this tool immediately when the user explicitly asks to review, remember, summarize, continue, or compare a previous discussion about a topic. Examples:
37
+ - "我之前关于 xxx 的实践" / "I previously worked on xxx"
38
+ - "上次我们讨论过..." / "last time we discussed..."
39
+ - "你还记得那个 xxx 项目吗" / "remember that xxx project?"
40
+ - "之前那个方案怎么说的" / "what was that plan we had?"
41
+ 2. When you cannot confidently answer from the current conversation and your general knowledge, but the user may have discussed the topic in prior sessions, first ask whether they want you to search their conversation history. Call this tool only after they agree.
42
+ 3. Do not search history merely because a question is difficult when the user's prior discussions are not relevant.
43
+
44
+ Extract 2–5 specific entities from the user's topic: project names, tool names, technologies, domain terms, or identifiers.`,
45
+
46
+ parameters: Type.Object({
47
+ entities: Type.Array(
48
+ Type.String({ minLength: 1 }),
49
+ {
50
+ description: 'Key technical terms from the query. E.g. ["payroll", "LangGraph", "A2A"]',
51
+ minItems: 1,
52
+ maxItems: 8,
53
+ },
54
+ ),
55
+ }),
56
+
57
+ async execute(_toolCallId, { entities }) {
58
+ const results = recallTurns(entities, 5);
59
+ const text = formatRecallResults(results);
60
+ return {
61
+ content: [{ type: "text" as const, text }],
62
+ details: { entities, resultCount: results.length },
63
+ };
64
+ },
65
+ });
66
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "pi-session-memory",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension: persists conversation turns to SQLite and exposes recall_memory tool for cross-session retrieval",
5
+ "keywords": ["pi-package"],
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "tsx tests/core.test.ts"
9
+ },
10
+ "devDependencies": {
11
+ "tsx": "^4.23.13"
12
+ },
13
+ "pi": {
14
+ "extensions": ["./extensions"]
15
+ },
16
+ "peerDependencies": {
17
+ "@earendil-works/pi-coding-agent": "*"
18
+ }
19
+ }
package/spec.md ADDED
@@ -0,0 +1,153 @@
1
+ # pi-session-memory — Spec
2
+
3
+ ## Goal
4
+
5
+ A pi extension that persists every conversation turn to SQLite and exposes a
6
+ `recall_memory` tool so the LLM can retrieve relevant past turns when the user
7
+ references previous discussions.
8
+
9
+ ## Architecture
10
+
11
+ ```
12
+ pi turn_end event ──────────────────────────┐
13
+
14
+ Writer (src/writer.ts)
15
+ - writes current pi turns
16
+
17
+ /backfill command ────────┐ │
18
+ ▼ ▼
19
+ Source adapters → SQLite ~/.pi/agent/memory.db
20
+ - Pi JSONL sessions + turns
21
+ - Claude JSONL
22
+ - Codex JSONL
23
+
24
+
25
+ recall_memory tool
26
+ - LLM supplies entities[]
27
+ - LIKE substring match + hit-score ranking
28
+ - returns top-5 turns
29
+ ```
30
+
31
+ ## Tables
32
+
33
+ ### sessions
34
+ | column | type | note |
35
+ |------------|---------|-------------------------------|
36
+ | session_id | TEXT PK | native source session ID (UUID) |
37
+ | source | TEXT | `pi`, `claude`, or `codex` |
38
+ | cwd | TEXT | working directory |
39
+ | started_at | INTEGER | unix ms |
40
+ | model_id | TEXT | first model_change value |
41
+ | jsonl_path | TEXT | absolute path to source file |
42
+
43
+ ### turns
44
+ | column | type | note |
45
+ |-------------|---------|-------------------------------------------|
46
+ | turn_id | TEXT PK | `{session_id}:{user_message_id}` — stable across live write and backfill |
47
+ | session_id | TEXT FK | |
48
+ | turn_index | INTEGER | display order within session; never used as identity |
49
+ | ts | INTEGER | user message timestamp (unix ms) |
50
+ | user_text | TEXT | user message content |
51
+ | reply_text | TEXT | assistant final text (all text blocks) |
52
+ | tool_names | TEXT | JSON array e.g. `["bash","read"]` |
53
+
54
+ There is intentionally no full-text virtual table. Retrieval uses escaped
55
+ SQLite `LIKE` against the complete `user_text` and `reply_text`, because literal
56
+ substring coverage and retrieval quality are prioritized over index performance.
57
+
58
+ ## Write Path
59
+
60
+ Trigger: `agent_settled` event, after the agent run and any automatic
61
+ continuations have completed.
62
+
63
+ Steps:
64
+ 1. Locate the latest user `SessionEntry` in `ctx.sessionManager.getBranch()` and
65
+ use its stable entry ID as `user_message_id`; do not use pi's transient `turnIndex`.
66
+ 2. Extract `user_text` from that user entry's text content blocks.
67
+ 3. Walk branch entries after that user entry to collect assistant text blocks →
68
+ `reply_text`, and toolCall names → `tool_names`.
69
+ 4. Upsert source=`pi` session row (INSERT OR IGNORE).
70
+ 5. Insert turn by stable ID (INSERT OR IGNORE — live writing and backfill target
71
+ the same row).
72
+
73
+ ## Retrieval Path (recall_memory tool)
74
+
75
+ ### Tool Invocation Policy
76
+
77
+ - **Direct recall:** Call `recall_memory` immediately when the user explicitly
78
+ asks to review, remember, summarize, continue, or compare a prior discussion
79
+ about a topic.
80
+ - **Knowledge-gap recall:** When the user asks about a topic you cannot answer
81
+ confidently from the current conversation and your general knowledge, but it
82
+ may have been discussed in the user's past sessions, ask the user whether they
83
+ want you to search their conversation history. Call `recall_memory` only after
84
+ the user agrees.
85
+ - Do not search history merely because a question is difficult when the user has
86
+ not indicated that their own prior work or discussions are relevant.
87
+
88
+ Input: `{ entities: string[] }` — 2-5 key terms extracted by LLM from user query.
89
+
90
+ Steps:
91
+ 1. Build per-entity LIKE hit score:
92
+ - `user_text` match = 2 points
93
+ - `reply_text` match = 1 point
94
+ 2. `SELECT ... WHERE (LOWER(user_text) LIKE ? OR LOWER(reply_text) LIKE ?) OR ...`
95
+ 3. Escape `%`, `_`, and `\\` in every entity, then use `LIKE ? ESCAPE '\\'` so
96
+ technical names containing LIKE wildcards remain literal substring matches.
97
+ 4. `ORDER BY hits DESC, ts DESC LIMIT 5`
98
+
99
+ No FTS5 or write-time preprocessing. LIKE is a literal substring match after
100
+ escaping, so it does not lose substring matches through tokenization.
101
+
102
+ ## Backfill
103
+
104
+ `/memory-backfill` scans and imports all historical records. All writes use
105
+ `INSERT OR IGNORE`, so it is safe and idempotent to run repeatedly.
106
+
107
+ | source | scan root | accepted user/assistant records | excluded records |
108
+ |---|---|---|---|
109
+ | pi | `~/.pi/agent/sessions/**/*.jsonl` | `message.role=user|assistant` | thinking, tool results, non-text content |
110
+ | claude | `~/.claude/projects/**/*.jsonl` | `type=user|assistant`, `message.role=user|assistant` | `isMeta`, sidechains, slash commands, local-command tags, continuation summaries, system/snapshot/attachments |
111
+ | codex | `~/.codex/sessions/**/*.jsonl` | `type=response_item`, `payload.type=message`, `role=user|assistant` | developer/system context, AGENTS.md and environment-context injection, IDE/image context, reasoning and tool events |
112
+
113
+ Each adapter outputs the common `ImportedSession` / `ImportedTurn` model.
114
+ Turns pair one accepted user message with all following accepted assistant text
115
+ until the next accepted user message. Each source adapter preserves the native
116
+ user-message ID (`pi entry.id`, Claude `uuid`, Codex `payload.id`) so rerunning
117
+ backfill never creates a duplicate of an already live-written pi turn.
118
+
119
+ ## Acceptance Criteria
120
+
121
+ - A pi session with multiple user turns produces one distinct `turns` row per
122
+ user message; no row is dropped because `turnIndex` restarts.
123
+ - Running backfill after live pi writing does not duplicate those pi turns.
124
+ - Claude and Codex injected context records listed above are absent from
125
+ `turns.user_text`.
126
+ - Entity text containing `%`, `_`, or `\\` only matches its literal occurrence.
127
+ - Automated tests cover stable turn identity, LIKE escaping/ranking, and
128
+ cross-source backfill parsing.
129
+
130
+ ## File Structure
131
+
132
+ ```
133
+ pi-session-memory/
134
+ ├── spec.md
135
+ ├── package.json
136
+ ├── tsconfig.json
137
+ ├── extensions/
138
+ │ └── index.ts ← extension entry: tool + /memory-backfill
139
+ ├── tests/
140
+ │ └── core.test.ts ← persistence identity and literal-LIKE tests
141
+ └── src/
142
+ ├── db.ts ← DatabaseSync schema + upsert helpers
143
+ ├── writer.ts ← live pi turn_end writer
144
+ ├── retriever.ts ← LIKE query + hit-score ranking
145
+ └── backfill.ts ← Pi / Claude / Codex adapters and import runner
146
+ ```
147
+
148
+ ## Constraints
149
+
150
+ - Zero external dependencies (use `node:sqlite`, `node:fs`, `node:path`, `node:os`)
151
+ - Idempotent writes (INSERT OR IGNORE on turn_id)
152
+ - DB path: `~/.pi/agent/memory.db`
153
+ - No fallback / silent failure — let errors surface
@@ -0,0 +1,262 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { insertTurn, upsertSession } from "./db.ts";
5
+
6
+ type Source = "pi" | "claude" | "codex";
7
+ type Role = "user" | "assistant";
8
+
9
+ interface ImportedMessage {
10
+ id: string;
11
+ role: Role;
12
+ text: string;
13
+ ts: number;
14
+ toolNames: string[];
15
+ }
16
+
17
+ interface ImportedSession {
18
+ source: Source;
19
+ nativeSessionId: string;
20
+ cwd: string;
21
+ startedAt: number;
22
+ modelId: string | null;
23
+ jsonlPath: string;
24
+ messages: ImportedMessage[];
25
+ }
26
+
27
+ export interface BackfillStats {
28
+ pi: number;
29
+ claude: number;
30
+ codex: number;
31
+ turns: number;
32
+ }
33
+
34
+ export function backfillAll(): BackfillStats {
35
+ const stats: BackfillStats = { pi: 0, claude: 0, codex: 0, turns: 0 };
36
+
37
+ for (const jsonlPath of _jsonlFiles(join(homedir(), ".pi", "agent", "sessions"))) {
38
+ const session = _parsePi(jsonlPath);
39
+ if (session) {
40
+ stats.pi++;
41
+ stats.turns += _persist(session);
42
+ }
43
+ }
44
+
45
+ for (const jsonlPath of _jsonlFiles(join(homedir(), ".claude", "projects"))) {
46
+ const session = _parseClaude(jsonlPath);
47
+ if (session) {
48
+ stats.claude++;
49
+ stats.turns += _persist(session);
50
+ }
51
+ }
52
+
53
+ for (const jsonlPath of _jsonlFiles(join(homedir(), ".codex", "sessions"))) {
54
+ const session = _parseCodex(jsonlPath);
55
+ if (session) {
56
+ stats.codex++;
57
+ stats.turns += _persist(session);
58
+ }
59
+ }
60
+
61
+ return stats;
62
+ }
63
+
64
+ function _persist(session: ImportedSession): number {
65
+ const sessionId = `${session.source}:${session.nativeSessionId}`;
66
+ upsertSession({
67
+ session_id: sessionId,
68
+ source: session.source,
69
+ cwd: session.cwd,
70
+ started_at: session.startedAt,
71
+ model_id: session.modelId,
72
+ jsonl_path: session.jsonlPath,
73
+ });
74
+
75
+ let turnIndex = 0;
76
+ let user: ImportedMessage | undefined;
77
+ let replyText = "";
78
+ const toolNames: string[] = [];
79
+ let persisted = 0;
80
+
81
+ const flush = () => {
82
+ if (!user || !replyText.trim()) return;
83
+ const inserted = insertTurn({
84
+ turn_id: `${sessionId}:${user.id}`,
85
+ session_id: sessionId,
86
+ turn_index: turnIndex++,
87
+ ts: user.ts,
88
+ user_text: user.text,
89
+ reply_text: replyText.trim(),
90
+ tool_names: toolNames.length ? JSON.stringify([...new Set(toolNames)]) : null,
91
+ user_message_id: user.id,
92
+ });
93
+ if (inserted) persisted++;
94
+ };
95
+
96
+ for (const message of session.messages) {
97
+ if (message.role === "user") {
98
+ flush();
99
+ user = message;
100
+ replyText = "";
101
+ toolNames.length = 0;
102
+ continue;
103
+ }
104
+ if (!user) continue;
105
+ if (message.text) replyText += (replyText ? "\n" : "") + message.text;
106
+ toolNames.push(...message.toolNames);
107
+ }
108
+ flush();
109
+ return persisted;
110
+ }
111
+
112
+ function _parsePi(jsonlPath: string): ImportedSession | undefined {
113
+ const entries = _readJsonl(jsonlPath);
114
+ const header = entries.find((entry) => entry.type === "session");
115
+ if (!header) return undefined;
116
+ const model = entries.find((entry) => entry.type === "model_change");
117
+ const messages: ImportedMessage[] = [];
118
+
119
+ for (const entry of entries) {
120
+ if (entry.type !== "message") continue;
121
+ const message = entry.message;
122
+ if (message?.role !== "user" && message?.role !== "assistant") continue;
123
+ const text = _piText(message);
124
+ if (!text) continue;
125
+ const toolNames = message.role === "assistant"
126
+ ? message.content.filter((block: any) => block.type === "toolCall").map((block: any) => block.name)
127
+ : [];
128
+ messages.push({ id: entry.id, role: message.role, text, ts: message.timestamp, toolNames });
129
+ }
130
+
131
+ return {
132
+ source: "pi",
133
+ nativeSessionId: header.id,
134
+ cwd: header.cwd,
135
+ startedAt: Date.parse(header.timestamp),
136
+ modelId: model?.modelId ?? null,
137
+ jsonlPath,
138
+ messages,
139
+ };
140
+ }
141
+
142
+ function _parseClaude(jsonlPath: string): ImportedSession | undefined {
143
+ const entries = _readJsonl(jsonlPath);
144
+ const firstConversation = entries.find((entry) =>
145
+ (entry.type === "user" || entry.type === "assistant") && !entry.isMeta && !entry.isSidechain,
146
+ );
147
+ if (!firstConversation?.sessionId) return undefined;
148
+
149
+ const messages: ImportedMessage[] = [];
150
+ for (const entry of entries) {
151
+ if ((entry.type !== "user" && entry.type !== "assistant") || entry.isMeta || entry.isSidechain) continue;
152
+ const role = entry.message?.role;
153
+ if (role !== "user" && role !== "assistant") continue;
154
+ const text = _claudeText(entry.message);
155
+ if (!text || (role === "user" && _isClaudeInjectedContext(text))) continue;
156
+ const toolNames = role === "assistant"
157
+ ? entry.message.content.filter((block: any) => block.type === "tool_use").map((block: any) => block.name)
158
+ : [];
159
+ messages.push({ id: entry.uuid, role, text, ts: Date.parse(entry.timestamp), toolNames });
160
+ }
161
+
162
+ return {
163
+ source: "claude",
164
+ nativeSessionId: firstConversation.sessionId,
165
+ cwd: firstConversation.cwd ?? "",
166
+ startedAt: Date.parse(firstConversation.timestamp),
167
+ modelId: null,
168
+ jsonlPath,
169
+ messages,
170
+ };
171
+ }
172
+
173
+ function _parseCodex(jsonlPath: string): ImportedSession | undefined {
174
+ const entries = _readJsonl(jsonlPath);
175
+ const meta = entries.find((entry) => entry.type === "session_meta")?.payload;
176
+ if (!meta?.session_id) return undefined;
177
+
178
+ const messages: ImportedMessage[] = [];
179
+ for (const entry of entries) {
180
+ if (entry.type !== "response_item") continue;
181
+ const payload = entry.payload;
182
+ if (payload?.type !== "message" || (payload.role !== "user" && payload.role !== "assistant")) continue;
183
+ const text = _codexText(payload);
184
+ if (!text || (payload.role === "user" && _isCodexInjectedContext(text))) continue;
185
+ messages.push({
186
+ id: payload.id,
187
+ role: payload.role,
188
+ text,
189
+ ts: Date.parse(entry.timestamp),
190
+ toolNames: [],
191
+ });
192
+ }
193
+
194
+ return {
195
+ source: "codex",
196
+ nativeSessionId: meta.session_id,
197
+ cwd: meta.cwd ?? "",
198
+ startedAt: Date.parse(meta.timestamp),
199
+ modelId: meta.model_provider ?? null,
200
+ jsonlPath,
201
+ messages,
202
+ };
203
+ }
204
+
205
+ function _piText(message: any): string {
206
+ if (typeof message.content === "string") return message.content.trim();
207
+ return message.content
208
+ .filter((block: any) => block.type === "text")
209
+ .map((block: any) => block.text ?? "")
210
+ .join("\n")
211
+ .trim();
212
+ }
213
+
214
+ function _claudeText(message: any): string {
215
+ if (typeof message.content === "string") return message.content.trim();
216
+ if (!Array.isArray(message.content)) return "";
217
+ return message.content
218
+ .filter((block: any) => block.type === "text")
219
+ .map((block: any) => block.text ?? "")
220
+ .join("\n")
221
+ .trim();
222
+ }
223
+
224
+ function _codexText(message: any): string {
225
+ return message.content
226
+ .filter((block: any) => block.type === "input_text" || block.type === "output_text")
227
+ .map((block: any) => block.text ?? "")
228
+ .join("\n")
229
+ .trim();
230
+ }
231
+
232
+ function _isClaudeInjectedContext(text: string): boolean {
233
+ return text.startsWith("<command-name>")
234
+ || text.startsWith("<command-message>")
235
+ || text.startsWith("<local-command-")
236
+ || text.startsWith("<task-notification>")
237
+ || text.startsWith("This session is being continued from a previous conversation");
238
+ }
239
+
240
+ function _isCodexInjectedContext(text: string): boolean {
241
+ return text.startsWith("# AGENTS.md instructions")
242
+ || text.startsWith("<environment_context>")
243
+ || text.startsWith("# Context from my IDE setup:")
244
+ || text.startsWith("<image name=");
245
+ }
246
+
247
+ function _readJsonl(jsonlPath: string): any[] {
248
+ return readFileSync(jsonlPath, "utf8")
249
+ .split("\n")
250
+ .filter(Boolean)
251
+ .map((line) => JSON.parse(line));
252
+ }
253
+
254
+ function _jsonlFiles(root: string): string[] {
255
+ const files: string[] = [];
256
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
257
+ const path = join(root, entry.name);
258
+ if (entry.isDirectory()) files.push(..._jsonlFiles(path));
259
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path);
260
+ }
261
+ return files;
262
+ }
package/src/db.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { mkdirSync } from "node:fs";
5
+
6
+ const DB_PATH = process.env.MEMORY_DB_PATH ?? join(homedir(), ".pi", "agent", "memory.db");
7
+
8
+ const SCHEMA = `
9
+ CREATE TABLE IF NOT EXISTS sessions (
10
+ session_id TEXT PRIMARY KEY,
11
+ source TEXT NOT NULL DEFAULT 'pi',
12
+ cwd TEXT NOT NULL,
13
+ started_at INTEGER NOT NULL,
14
+ model_id TEXT,
15
+ jsonl_path TEXT NOT NULL
16
+ );
17
+
18
+ CREATE INDEX IF NOT EXISTS idx_sessions_time ON sessions(started_at DESC);
19
+
20
+ CREATE TABLE IF NOT EXISTS turns (
21
+ turn_id TEXT PRIMARY KEY,
22
+ session_id TEXT NOT NULL REFERENCES sessions(session_id),
23
+ turn_index INTEGER NOT NULL,
24
+ ts INTEGER NOT NULL,
25
+ user_text TEXT NOT NULL,
26
+ reply_text TEXT NOT NULL,
27
+ tool_names TEXT,
28
+ user_message_id TEXT
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id, turn_index);
32
+ CREATE INDEX IF NOT EXISTS idx_turns_ts ON turns(ts DESC);
33
+ `;
34
+
35
+ let _db: DatabaseSync | undefined;
36
+
37
+ export function getDb(): DatabaseSync {
38
+ if (_db) return _db;
39
+ mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
40
+ _db = new DatabaseSync(DB_PATH);
41
+ _db.exec("PRAGMA journal_mode = WAL");
42
+ _db.exec("PRAGMA synchronous = NORMAL");
43
+ _db.exec(SCHEMA);
44
+ _migrate(_db);
45
+ return _db;
46
+ }
47
+
48
+ function _migrate(db: DatabaseSync): void {
49
+ const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
50
+ if (!sessionColumns.some((column) => column.name === "source")) {
51
+ db.exec("ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'pi'");
52
+ }
53
+
54
+ const turnColumns = db.prepare("PRAGMA table_info(turns)").all() as Array<{ name: string }>;
55
+ if (!turnColumns.some((column) => column.name === "user_message_id")) {
56
+ db.exec("ALTER TABLE turns ADD COLUMN user_message_id TEXT");
57
+ db.exec("DELETE FROM turns WHERE user_message_id IS NULL");
58
+ db.exec("DELETE FROM sessions WHERE session_id NOT IN (SELECT DISTINCT session_id FROM turns)");
59
+ }
60
+ }
61
+
62
+ export interface SessionRow {
63
+ session_id: string;
64
+ source: "pi" | "claude" | "codex";
65
+ cwd: string;
66
+ started_at: number;
67
+ model_id: string | null;
68
+ jsonl_path: string;
69
+ }
70
+
71
+ export interface TurnRow {
72
+ turn_id: string;
73
+ session_id: string;
74
+ turn_index: number;
75
+ ts: number;
76
+ user_text: string;
77
+ reply_text: string;
78
+ tool_names: string | null;
79
+ user_message_id: string;
80
+ }
81
+
82
+ export function upsertSession(row: SessionRow): void {
83
+ getDb().prepare(`
84
+ INSERT OR IGNORE INTO sessions (session_id, source, cwd, started_at, model_id, jsonl_path)
85
+ VALUES (?, ?, ?, ?, ?, ?)
86
+ `).run(row.session_id, row.source, row.cwd, row.started_at, row.model_id, row.jsonl_path);
87
+ }
88
+
89
+ export function insertTurn(row: TurnRow): boolean {
90
+ const result = getDb().prepare(`
91
+ INSERT OR IGNORE INTO turns
92
+ (turn_id, session_id, turn_index, ts, user_text, reply_text, tool_names, user_message_id)
93
+ 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
+ );
98
+ return result.changes === 1;
99
+ }
@@ -0,0 +1,70 @@
1
+ import { getDb } from "./db.ts";
2
+
3
+ export interface RecallResult {
4
+ turn_id: string;
5
+ source: "pi" | "claude" | "codex";
6
+ ts: number;
7
+ user_text: string;
8
+ reply_text: string;
9
+ hits: number;
10
+ }
11
+
12
+ export function recallTurns(entities: string[], topK = 5): RecallResult[] {
13
+ if (entities.length === 0) return [];
14
+
15
+ const scoreExpression = entities.map(() => `(
16
+ CASE WHEN LOWER(turns.user_text) LIKE ? ESCAPE '\\' THEN 2 ELSE 0 END +
17
+ CASE WHEN LOWER(turns.reply_text) LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END
18
+ )`).join(" + ");
19
+ const scoreParameters = entities.flatMap((entity) => {
20
+ const pattern = _likePattern(entity);
21
+ return [pattern, pattern];
22
+ });
23
+
24
+ const whereExpression = entities.map(() => `(
25
+ LOWER(turns.user_text) LIKE ? ESCAPE '\\' OR
26
+ LOWER(turns.reply_text) LIKE ? ESCAPE '\\'
27
+ )`).join(" OR ");
28
+ const whereParameters = entities.flatMap((entity) => {
29
+ const pattern = _likePattern(entity);
30
+ return [pattern, pattern];
31
+ });
32
+
33
+ return getDb().prepare(`
34
+ SELECT
35
+ turns.turn_id,
36
+ sessions.source,
37
+ turns.ts,
38
+ turns.user_text,
39
+ turns.reply_text,
40
+ (${scoreExpression}) AS hits
41
+ FROM turns
42
+ JOIN sessions ON sessions.session_id = turns.session_id
43
+ WHERE ${whereExpression}
44
+ ORDER BY hits DESC, turns.ts DESC
45
+ LIMIT ?
46
+ `).all(...scoreParameters, ...whereParameters, topK) as RecallResult[];
47
+ }
48
+
49
+ export function formatRecallResults(results: RecallResult[]): string {
50
+ if (results.length === 0) return "No relevant past conversations found.";
51
+
52
+ const lines = ["## Relevant past conversations\n"];
53
+ for (const result of results) {
54
+ const date = new Date(result.ts).toLocaleString();
55
+ lines.push(`### [${result.source} · ${date}]`);
56
+ lines.push(`**You:** ${result.user_text}`);
57
+ if (result.reply_text) {
58
+ const preview = result.reply_text.length > 500
59
+ ? `${result.reply_text.slice(0, 500)}…`
60
+ : result.reply_text;
61
+ lines.push(`**Assistant:** ${preview}`);
62
+ }
63
+ lines.push("");
64
+ }
65
+ return lines.join("\n");
66
+ }
67
+
68
+ function _likePattern(entity: string): string {
69
+ return `%${entity.toLowerCase().replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
70
+ }
package/src/writer.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
3
+ import { upsertSession, insertTurn } from "./db.ts";
4
+
5
+ export function writeTurn(ctx: ExtensionContext): void {
6
+ const sessionManager = ctx.sessionManager;
7
+ const sessionId = `pi:${sessionManager.getSessionId()}`;
8
+ const branch = sessionManager.getBranch();
9
+ const userEntry = [...branch].reverse().find((entry) =>
10
+ entry.type === "message" && entry.message.role === "user",
11
+ );
12
+ if (!userEntry) throw new Error("Current session branch has no user message");
13
+
14
+ const userText = _extractText(userEntry.message);
15
+ if (!userText) return;
16
+
17
+ let replyText = "";
18
+ const toolNames: string[] = [];
19
+ const userIndex = branch.indexOf(userEntry);
20
+ for (const entry of branch.slice(userIndex + 1)) {
21
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
22
+ const message = entry.message as AssistantMessage;
23
+ for (const block of message.content) {
24
+ if (block.type === "text" && block.text.trim()) {
25
+ replyText += (replyText ? "\n" : "") + block.text.trim();
26
+ }
27
+ if (block.type === "toolCall") toolNames.push(block.name);
28
+ }
29
+ }
30
+
31
+ const header = sessionManager.getHeader();
32
+ upsertSession({
33
+ session_id: sessionId,
34
+ source: "pi",
35
+ cwd: sessionManager.getCwd(),
36
+ started_at: header ? Date.parse(header.timestamp) : userEntry.message.timestamp,
37
+ model_id: null,
38
+ jsonl_path: sessionManager.getSessionFile() ?? "",
39
+ });
40
+
41
+ insertTurn({
42
+ turn_id: `${sessionId}:${userEntry.id}`,
43
+ session_id: sessionId,
44
+ turn_index: _userTurnIndex(branch, userIndex),
45
+ ts: userEntry.message.timestamp,
46
+ user_text: userText,
47
+ reply_text: replyText,
48
+ tool_names: toolNames.length ? JSON.stringify([...new Set(toolNames)]) : null,
49
+ user_message_id: userEntry.id,
50
+ });
51
+ }
52
+
53
+ function _extractText(message: { content: string | Array<{ type: string; text?: string }> }): string {
54
+ if (typeof message.content === "string") return message.content.trim();
55
+ return message.content
56
+ .filter((block) => block.type === "text")
57
+ .map((block) => block.text)
58
+ .join("\n")
59
+ .trim();
60
+ }
61
+
62
+ function _userTurnIndex(branch: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>, userIndex: number): number {
63
+ return branch.slice(0, userIndex + 1)
64
+ .filter((entry) => entry.type === "message" && entry.message.role === "user")
65
+ .length - 1;
66
+ }
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const dbPath = join("/tmp", `pi-session-memory-${process.pid}.db`);
6
+ process.env.MEMORY_DB_PATH = dbPath;
7
+
8
+ const { getDb, insertTurn, upsertSession } = await import("../src/db.ts");
9
+ const { recallTurns } = await import("../src/retriever.ts");
10
+
11
+ function cleanup(): void {
12
+ for (const suffix of ["", "-wal", "-shm"]) {
13
+ const path = `${dbPath}${suffix}`;
14
+ if (existsSync(path)) rmSync(path);
15
+ }
16
+ }
17
+
18
+ cleanup();
19
+
20
+ upsertSession({
21
+ session_id: "pi:test",
22
+ source: "pi",
23
+ cwd: "/tmp",
24
+ started_at: 1,
25
+ model_id: null,
26
+ jsonl_path: "/tmp/test.jsonl",
27
+ });
28
+
29
+ assert.equal(insertTurn({
30
+ turn_id: "pi:test:user-1",
31
+ session_id: "pi:test",
32
+ turn_index: 0,
33
+ ts: 1,
34
+ user_text: "literal 100% and a_b",
35
+ reply_text: "first reply",
36
+ tool_names: null,
37
+ user_message_id: "user-1",
38
+ }), true);
39
+
40
+ assert.equal(insertTurn({
41
+ turn_id: "pi:test:user-2",
42
+ session_id: "pi:test",
43
+ turn_index: 1,
44
+ ts: 2,
45
+ user_text: "wildcard 100x and acb",
46
+ reply_text: "second reply",
47
+ tool_names: null,
48
+ user_message_id: "user-2",
49
+ }), true);
50
+
51
+ assert.equal(insertTurn({
52
+ turn_id: "pi:test:user-1",
53
+ session_id: "pi:test",
54
+ turn_index: 0,
55
+ ts: 1,
56
+ user_text: "duplicate",
57
+ reply_text: "duplicate",
58
+ tool_names: null,
59
+ user_message_id: "user-1",
60
+ }), false);
61
+
62
+ assert.deepEqual(recallTurns(["100%"], 10).map((result) => result.turn_id), ["pi:test:user-1"]);
63
+ assert.deepEqual(recallTurns(["a_b"], 10).map((result) => result.turn_id), ["pi:test:user-1"]);
64
+ assert.equal(getDb().prepare("SELECT count(*) AS count FROM turns").get().count, 2);
65
+
66
+ cleanup();
67
+ console.log("core.test.ts: passed");
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "allowImportingTsExtensions": true,
8
+ "noEmit": true
9
+ }
10
+ }