pi-session-memory 0.1.3 → 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 CHANGED
@@ -8,7 +8,8 @@ A local-first Pi extension that saves completed conversations to SQLite and give
8
8
  ## Features
9
9
 
10
10
  - Persists completed Pi conversations in `~/.pi/agent/memory.db`.
11
- - Imports historical session records from Pi, Claude Code, and Codex with `/memory-backfill`.
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`.
12
13
  - Exposes `recall_memory`, allowing Pi to retrieve relevant prior discussions when users explicitly refer to earlier work.
13
14
  - Uses stable native user-message IDs and `INSERT OR IGNORE`, making live persistence and backfill idempotent.
14
15
  - Searches literal substrings with escaped SQLite `LIKE` patterns, including technical terms containing `%`, `_`, or `\\`.
@@ -17,20 +18,25 @@ A local-first Pi extension that saves completed conversations to SQLite and give
17
18
  ## Installation
18
19
 
19
20
  ```bash
20
- pi install npm:pi-session-memory@0.1.3
21
+ pi install npm:pi-session-memory@0.1.4
21
22
  ```
22
23
 
23
24
  To try the package without installing it permanently:
24
25
 
25
26
  ```bash
26
- pi -e npm:pi-session-memory@0.1.3
27
+ pi -e npm:pi-session-memory@0.1.4
27
28
  ```
28
29
 
29
30
  ## Usage
30
31
 
31
32
  ### Import existing history
32
33
 
33
- Run this once after installation, and again whenever you want to scan newly available historical session files:
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:
34
40
 
35
41
  ```text
36
42
  /memory-backfill
@@ -61,10 +67,12 @@ The tool searches prior user prompts and assistant responses, ranks matches by e
61
67
  ## How it works
62
68
 
63
69
  ```text
64
- completed Pi agent run ───► SQLite memory.db ◄─── /memory-backfill
65
-
66
-
67
- recall_memory Pi / Claude / Codex
70
+ Pi startup ──► incremental source sync ──► SQLite memory.db ◄── completed Pi agent run
71
+
72
+
73
+ recall_memory
74
+
75
+ Pi / Claude / Codex
68
76
  ```
69
77
 
70
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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-memory",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Persistent, local-first cross-session memory for Pi, with SQLite-backed recall across Pi, Claude Code, and Codex conversations",
5
5
  "keywords": [
6
6
  "pi-package"
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
- /backfill command ────────┐
18
- ▼ ▼
19
- Source adapters → SQLite ~/.pi/agent/memory.db
20
- - Pi JSONL sessions + turns
21
- - Claude JSONL
22
- - Codex JSONL
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
- `/memory-backfill` scans and imports all historical records. All writes use
105
- `INSERT OR IGNORE`, so it is safe and idempotent to run repeatedly.
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
  |---|---|---|---|
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
- const stats: BackfillStats = { pi: 0, claude: 0, codex: 0, turns: 0 };
45
+ return _syncHistory(true);
46
+ }
36
47
 
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
- }
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
- 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
- }
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
- for (const jsonlPath of _jsonlFiles(join(homedir(), ".codex", "sessions"))) {
54
- const session = _parseCodex(jsonlPath);
55
- if (session) {
56
- stats.codex++;
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)