pi-session-memory 0.1.2 → 0.1.4
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 +16 -26
- package/extensions/index.ts +12 -2
- package/package.json +1 -1
- package/spec.md +28 -9
- package/src/backfill.ts +44 -22
- package/src/db.ts +36 -0
- package/tests/core.test.ts +2 -1
package/README.md
CHANGED
|
@@ -5,12 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
A local-first Pi extension that saves completed conversations to SQLite and gives the agent a `recall_memory` tool for retrieving relevant discussions from previous Pi, Claude Code, and Codex sessions.
|
|
7
7
|
|
|
8
|
-
> **Paper:** An accompanying arXiv paper is planned. [arXiv:XXXX.XXXXX](https://arxiv.org/abs/XXXX.XXXXX) *(placeholder; not published yet)*
|
|
9
|
-
|
|
10
8
|
## Features
|
|
11
9
|
|
|
12
10
|
- Persists completed Pi conversations in `~/.pi/agent/memory.db`.
|
|
13
|
-
-
|
|
11
|
+
- Automatically imports only new or changed Pi, Claude Code, and Codex session files when Pi starts.
|
|
12
|
+
- Supports a forced full rescan with `/memory-backfill`.
|
|
14
13
|
- Exposes `recall_memory`, allowing Pi to retrieve relevant prior discussions when users explicitly refer to earlier work.
|
|
15
14
|
- Uses stable native user-message IDs and `INSERT OR IGNORE`, making live persistence and backfill idempotent.
|
|
16
15
|
- Searches literal substrings with escaped SQLite `LIKE` patterns, including technical terms containing `%`, `_`, or `\\`.
|
|
@@ -19,20 +18,25 @@ A local-first Pi extension that saves completed conversations to SQLite and give
|
|
|
19
18
|
## Installation
|
|
20
19
|
|
|
21
20
|
```bash
|
|
22
|
-
pi install npm:pi-session-memory@0.1.
|
|
21
|
+
pi install npm:pi-session-memory@0.1.4
|
|
23
22
|
```
|
|
24
23
|
|
|
25
24
|
To try the package without installing it permanently:
|
|
26
25
|
|
|
27
26
|
```bash
|
|
28
|
-
pi -e npm:pi-session-memory@0.1.
|
|
27
|
+
pi -e npm:pi-session-memory@0.1.4
|
|
29
28
|
```
|
|
30
29
|
|
|
31
30
|
## Usage
|
|
32
31
|
|
|
33
32
|
### Import existing history
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
At Pi startup, the extension automatically scans the three source roots. It compares
|
|
35
|
+
per-file size and modification time to saved sync state; only new or changed JSONL
|
|
36
|
+
files are read and hashed with SHA-256 before import. Unchanged files are skipped.
|
|
37
|
+
|
|
38
|
+
Use this command when you intentionally want to force a full rescan of every
|
|
39
|
+
historical JSONL file:
|
|
36
40
|
|
|
37
41
|
```text
|
|
38
42
|
/memory-backfill
|
|
@@ -63,10 +67,12 @@ The tool searches prior user prompts and assistant responses, ranks matches by e
|
|
|
63
67
|
## How it works
|
|
64
68
|
|
|
65
69
|
```text
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
Pi startup ──► incremental source sync ──► SQLite memory.db ◄── completed Pi agent run
|
|
71
|
+
▲
|
|
72
|
+
│
|
|
73
|
+
recall_memory
|
|
74
|
+
│
|
|
75
|
+
Pi / Claude / Codex
|
|
70
76
|
```
|
|
71
77
|
|
|
72
78
|
After a Pi agent run settles, the extension captures the latest user message and the subsequent assistant replies/tool names from the active session branch. Historical imports normalize each supported source into the same session/turn schema.
|
|
@@ -86,19 +92,3 @@ Conversation data is stored and queried locally. This package does not add a rem
|
|
|
86
92
|
```bash
|
|
87
93
|
npm test
|
|
88
94
|
```
|
|
89
|
-
|
|
90
|
-
## Paper placeholder
|
|
91
|
-
|
|
92
|
-
```bibtex
|
|
93
|
-
@article{pi-session-memory-2026,
|
|
94
|
-
title = {Persistent Local-First Cross-Session Memory for Coding Agents},
|
|
95
|
-
author = {Anonymous},
|
|
96
|
-
year = {2026},
|
|
97
|
-
eprint = {XXXX.XXXXX},
|
|
98
|
-
archivePrefix = {arXiv},
|
|
99
|
-
primaryClass = {cs.AI},
|
|
100
|
-
note = {Placeholder; preprint forthcoming}
|
|
101
|
-
}
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
Replace the title, authors, arXiv identifier, and citation metadata after the preprint is submitted.
|
package/extensions/index.ts
CHANGED
|
@@ -2,10 +2,20 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { writeTurn } from "../src/writer.ts";
|
|
4
4
|
import { recallTurns, formatRecallResults } from "../src/retriever.ts";
|
|
5
|
-
import { backfillAll } from "../src/backfill.ts";
|
|
5
|
+
import { backfillAll, syncChangedHistory } from "../src/backfill.ts";
|
|
6
6
|
|
|
7
7
|
export default function (pi: ExtensionAPI) {
|
|
8
8
|
|
|
9
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
10
|
+
const stats = syncChangedHistory();
|
|
11
|
+
if (stats.scannedFiles > 0) {
|
|
12
|
+
ctx.ui.notify(
|
|
13
|
+
`[session-memory] synced ${stats.turns} turns from ${stats.scannedFiles} changed session files`,
|
|
14
|
+
"info",
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
9
19
|
// ── Write: persist each completed agent run to SQLite ────────────────────
|
|
10
20
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
11
21
|
try {
|
|
@@ -20,7 +30,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
20
30
|
handler: async (_args, ctx) => {
|
|
21
31
|
const stats = backfillAll();
|
|
22
32
|
ctx.ui.notify(
|
|
23
|
-
`[session-memory] imported ${stats.turns} turns from ${stats.pi} Pi, ${stats.claude} Claude, ${stats.codex} Codex sessions`,
|
|
33
|
+
`[session-memory] imported ${stats.turns} turns from ${stats.scannedFiles} files: ${stats.pi} Pi, ${stats.claude} Claude, ${stats.codex} Codex sessions`,
|
|
24
34
|
"info",
|
|
25
35
|
);
|
|
26
36
|
},
|
package/package.json
CHANGED
package/spec.md
CHANGED
|
@@ -14,12 +14,14 @@ pi turn_end event ────────────────────
|
|
|
14
14
|
Writer (src/writer.ts)
|
|
15
15
|
- writes current pi turns
|
|
16
16
|
│
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
session_start ───────────┐ │
|
|
18
|
+
▼ ▼
|
|
19
|
+
Incremental source sync → SQLite ~/.pi/agent/memory.db
|
|
20
|
+
- per-file size + mtime quick check
|
|
21
|
+
- SHA-256 only for changed candidates
|
|
22
|
+
- Pi / Claude / Codex JSONL adapters
|
|
23
|
+
|
|
24
|
+
/memory-backfill forces a full rescan.
|
|
23
25
|
│
|
|
24
26
|
▼
|
|
25
27
|
recall_memory tool
|
|
@@ -72,6 +74,15 @@ Steps:
|
|
|
72
74
|
|
|
73
75
|
## Retrieval Path (recall_memory tool)
|
|
74
76
|
|
|
77
|
+
### source_files
|
|
78
|
+
| column | type | note |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| jsonl_path | TEXT PK | absolute source file path |
|
|
81
|
+
| source | TEXT | `pi`, `claude`, or `codex` |
|
|
82
|
+
| size | INTEGER | file size at last sync |
|
|
83
|
+
| mtime_ms | REAL | modification time at last sync |
|
|
84
|
+
| sha256 | TEXT | content hash for changed candidates |
|
|
85
|
+
|
|
75
86
|
### Tool Invocation Policy
|
|
76
87
|
|
|
77
88
|
- **Direct recall:** Call `recall_memory` immediately when the user explicitly
|
|
@@ -101,8 +112,15 @@ escaping, so it does not lose substring matches through tokenization.
|
|
|
101
112
|
|
|
102
113
|
## Backfill
|
|
103
114
|
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
At `session_start`, `syncChangedHistory()` recursively enumerates the three
|
|
116
|
+
source roots. It records one row per source JSONL file in `source_files`:
|
|
117
|
+
`jsonl_path`, source, size, `mtime_ms`, and SHA-256. Unchanged size/mtime files
|
|
118
|
+
are skipped without reading; changed candidates are SHA-256 checked, and only
|
|
119
|
+
new content is parsed and imported.
|
|
120
|
+
|
|
121
|
+
`/memory-backfill` forces a full reparse of all historical records. All writes
|
|
122
|
+
use `INSERT OR IGNORE`, so both automatic sync and forced backfill are safe and
|
|
123
|
+
idempotent to run repeatedly.
|
|
106
124
|
|
|
107
125
|
| source | scan root | accepted user/assistant records | excluded records |
|
|
108
126
|
|---|---|---|---|
|
|
@@ -149,5 +167,6 @@ pi-session-memory/
|
|
|
149
167
|
|
|
150
168
|
- Zero external dependencies (use `node:sqlite`, `node:fs`, `node:path`, `node:os`)
|
|
151
169
|
- Idempotent writes (INSERT OR IGNORE on turn_id)
|
|
152
|
-
-
|
|
170
|
+
- Cross-platform paths: storage is `join(homedir(), ".pi", "agent", "memory.db")`; source roots are derived with `join(homedir(), ...)`, never hard-coded POSIX paths.
|
|
171
|
+
- Requires a Pi-supported Node.js runtime that exposes `node:sqlite` (`DatabaseSync`).
|
|
153
172
|
- No fallback / silent failure — let errors surface
|
package/src/backfill.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
|
-
import { insertTurn, upsertSession } from "./db.ts";
|
|
5
|
+
import { getSourceFile, insertTurn, upsertSession, upsertSourceFile } from "./db.ts";
|
|
5
6
|
|
|
6
7
|
type Source = "pi" | "claude" | "codex";
|
|
7
8
|
type Role = "user" | "assistant";
|
|
@@ -29,35 +30,52 @@ export interface BackfillStats {
|
|
|
29
30
|
claude: number;
|
|
30
31
|
codex: number;
|
|
31
32
|
turns: number;
|
|
33
|
+
scannedFiles: number;
|
|
34
|
+
skippedFiles: number;
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
const SOURCES: Array<{ source: Source; root: string; parse: (path: string) => ImportedSession | undefined }> = [
|
|
38
|
+
{ source: "pi", root: join(homedir(), ".pi", "agent", "sessions"), parse: _parsePi },
|
|
39
|
+
{ source: "claude", root: join(homedir(), ".claude", "projects"), parse: _parseClaude },
|
|
40
|
+
{ source: "codex", root: join(homedir(), ".codex", "sessions"), parse: _parseCodex },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Reparse every known source file. Intended for the explicit /memory-backfill command. */
|
|
34
44
|
export function backfillAll(): BackfillStats {
|
|
35
|
-
|
|
45
|
+
return _syncHistory(true);
|
|
46
|
+
}
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
stats.turns += _persist(session);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
48
|
+
/** Parse only source files that are new or whose metadata/content changed. */
|
|
49
|
+
export function syncChangedHistory(): BackfillStats {
|
|
50
|
+
return _syncHistory(false);
|
|
51
|
+
}
|
|
44
52
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
function _syncHistory(force: boolean): BackfillStats {
|
|
54
|
+
const stats: BackfillStats = { pi: 0, claude: 0, codex: 0, turns: 0, scannedFiles: 0, skippedFiles: 0 };
|
|
55
|
+
for (const definition of SOURCES) {
|
|
56
|
+
for (const jsonlPath of _jsonlFiles(definition.root)) {
|
|
57
|
+
const metadata = statSync(jsonlPath);
|
|
58
|
+
const known = getSourceFile(jsonlPath);
|
|
59
|
+
if (!force && known?.size === metadata.size && known.mtime_ms === metadata.mtimeMs) {
|
|
60
|
+
stats.skippedFiles++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sha256 = _sha256(jsonlPath);
|
|
65
|
+
if (!force && known?.sha256 === sha256) {
|
|
66
|
+
upsertSourceFile({ jsonl_path: jsonlPath, source: definition.source, size: metadata.size, mtime_ms: metadata.mtimeMs, sha256 });
|
|
67
|
+
stats.skippedFiles++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
52
70
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
71
|
+
const session = definition.parse(jsonlPath);
|
|
72
|
+
upsertSourceFile({ jsonl_path: jsonlPath, source: definition.source, size: metadata.size, mtime_ms: metadata.mtimeMs, sha256 });
|
|
73
|
+
stats.scannedFiles++;
|
|
74
|
+
if (!session) continue;
|
|
75
|
+
stats[definition.source]++;
|
|
57
76
|
stats.turns += _persist(session);
|
|
58
77
|
}
|
|
59
78
|
}
|
|
60
|
-
|
|
61
79
|
return stats;
|
|
62
80
|
}
|
|
63
81
|
|
|
@@ -244,6 +262,10 @@ function _isCodexInjectedContext(text: string): boolean {
|
|
|
244
262
|
|| text.startsWith("<image name=");
|
|
245
263
|
}
|
|
246
264
|
|
|
265
|
+
function _sha256(jsonlPath: string): string {
|
|
266
|
+
return createHash("sha256").update(readFileSync(jsonlPath)).digest("hex");
|
|
267
|
+
}
|
|
268
|
+
|
|
247
269
|
function _readJsonl(jsonlPath: string): any[] {
|
|
248
270
|
return readFileSync(jsonlPath, "utf8")
|
|
249
271
|
.split("\n")
|
package/src/db.ts
CHANGED
|
@@ -30,6 +30,14 @@ CREATE TABLE IF NOT EXISTS turns (
|
|
|
30
30
|
|
|
31
31
|
CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id, turn_index);
|
|
32
32
|
CREATE INDEX IF NOT EXISTS idx_turns_ts ON turns(ts DESC);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS source_files (
|
|
35
|
+
jsonl_path TEXT PRIMARY KEY,
|
|
36
|
+
source TEXT NOT NULL,
|
|
37
|
+
size INTEGER NOT NULL,
|
|
38
|
+
mtime_ms REAL NOT NULL,
|
|
39
|
+
sha256 TEXT NOT NULL
|
|
40
|
+
);
|
|
33
41
|
`;
|
|
34
42
|
|
|
35
43
|
let _db: DatabaseSync | undefined;
|
|
@@ -79,6 +87,34 @@ export interface TurnRow {
|
|
|
79
87
|
user_message_id: string;
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
export interface SourceFileRow {
|
|
91
|
+
jsonl_path: string;
|
|
92
|
+
source: "pi" | "claude" | "codex";
|
|
93
|
+
size: number;
|
|
94
|
+
mtime_ms: number;
|
|
95
|
+
sha256: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getSourceFile(jsonlPath: string): SourceFileRow | undefined {
|
|
99
|
+
return getDb().prepare(`
|
|
100
|
+
SELECT jsonl_path, source, size, mtime_ms, sha256
|
|
101
|
+
FROM source_files
|
|
102
|
+
WHERE jsonl_path = ?
|
|
103
|
+
`).get(jsonlPath) as SourceFileRow | undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function upsertSourceFile(row: SourceFileRow): void {
|
|
107
|
+
getDb().prepare(`
|
|
108
|
+
INSERT INTO source_files (jsonl_path, source, size, mtime_ms, sha256)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?)
|
|
110
|
+
ON CONFLICT(jsonl_path) DO UPDATE SET
|
|
111
|
+
source = excluded.source,
|
|
112
|
+
size = excluded.size,
|
|
113
|
+
mtime_ms = excluded.mtime_ms,
|
|
114
|
+
sha256 = excluded.sha256
|
|
115
|
+
`).run(row.jsonl_path, row.source, row.size, row.mtime_ms, row.sha256);
|
|
116
|
+
}
|
|
117
|
+
|
|
82
118
|
export function upsertSession(row: SessionRow): void {
|
|
83
119
|
getDb().prepare(`
|
|
84
120
|
INSERT OR IGNORE INTO sessions (session_id, source, cwd, started_at, model_id, jsonl_path)
|
package/tests/core.test.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
|
|
5
|
-
const dbPath = join(
|
|
6
|
+
const dbPath = join(tmpdir(), `pi-session-memory-${process.pid}.db`);
|
|
6
7
|
process.env.MEMORY_DB_PATH = dbPath;
|
|
7
8
|
|
|
8
9
|
const { getDb, insertTurn, upsertSession } = await import("../src/db.ts");
|