pi-hermes-memory 0.7.23 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -339,18 +339,18 @@ Background review triggers based on **activity level**, not just turn count:
339
339
 
340
340
  Both counters reset after each review.
341
341
 
342
- ### Background Review Transport
342
+ ### Direct-Transport LLM Calls (Review, Flush, Correction, Consolidation)
343
343
 
344
- By default, background review uses an in-process `completeSimple()` side-channel: a small JSON-only prompt, no child `pi` process, and memory writes applied directly by the extension. This keeps the main session's system prompt, tools, and LLM prefix cache intact.
344
+ By default, background review, session flush, correction save, and the manual `/memory-consolidate` command use an in-process `completeSimple()` side-channel: a small JSON-only prompt, no child `pi` process, and memory writes applied directly by the extension. This keeps the main session's system prompt, tools, and LLM prefix cache intact, and avoids the subprocess path's argv/`--no-extensions` concerns entirely on the common path.
345
345
 
346
- If direct review fails (no model, no auth, provider error, unparseable response), it automatically falls back to the legacy `pi -p --no-session` subprocess path.
346
+ If direct mode fails (no model, no auth, provider error, unparseable response, or — for consolidation only — a result that didn't actually free any space), it automatically falls back to the legacy `pi -p --no-session` subprocess path. The automatic over-capacity consolidator triggered from `MemoryStore` itself always uses the subprocess path, since it runs without extension-runtime access.
347
347
 
348
348
  Set `reviewTransport` in config only when you need to override this:
349
349
 
350
350
  | Value | Behavior |
351
351
  |---|---|
352
352
  | `direct` (default) | Try in-process `completeSimple()` first; fall back to subprocess on failure |
353
- | `subprocess` | Always use `pi -p` subprocess (pre-PR #92 behavior) |
353
+ | `subprocess` | Always use `pi -p` subprocess for every LLM-driven memory operation (pre-PR #92 behavior) |
354
354
 
355
355
  ### Skill Auto-Extraction
356
356
 
@@ -471,11 +471,12 @@ Create `~/.pi/agent/hermes-memory-config.json`:
471
471
  | `sessionSearch` | `{ "variant": "legacy" }` | Session search implementation: `legacy` keeps the existing SQLite/FTS snippet search; `anchors` uses the opt-in Markdown request surface and returns compact JSONL line-range anchors from `~/.pi/agent/sessions/` |
472
472
  | `llmModelOverride` | unset | Optional model override for background review (direct and subprocess), correction save, session flush, and consolidation |
473
473
  | `llmThinkingOverride` | unset | Optional thinking override for those LLM calls; valid values are `off`, `minimal`, `low`, `medium`, `high`, and `xhigh`. If `llmModelOverride` is set and this is omitted, review/child calls default to `off` |
474
+ | `childExtensionPaths` | unset | Trusted provider/auth adapter entry paths explicitly allowed in isolated child Pi processes; sibling packages matching the `*-oauth-adapter`/`*-auth-adapter` naming convention (including scoped packages, via their `package.json` `pi.extensions` manifest) are detected automatically — this setting is only needed for adapters that don't match that convention. In-process direct transport (the default for review/flush/correction/consolidation) doesn't need this at all, since it reads whatever provider auth is already registered |
474
475
  | `nudgeInterval` | `10` | Turns between auto-reviews |
475
476
  | `nudgeToolCalls` | `15` | Tool calls between auto-reviews (OR with turns) |
476
477
  | `reviewRecentMessages` | `0` | Recent messages included in background review (`0` = all) |
477
478
  | `reviewEnabled` | `true` | Enable/disable background learning loop |
478
- | `reviewTransport` | `direct` | Background review LLM transport: `direct` uses in-process `completeSimple()` with subprocess fallback; `subprocess` forces legacy `pi -p` only |
479
+ | `reviewTransport` | `direct` | LLM transport for background review, session flush, correction save, and manual consolidation: `direct` uses in-process `completeSimple()` with subprocess fallback; `subprocess` forces legacy `pi -p` only |
479
480
  | `memoryOverflowStrategy` | `auto-consolidate` | Behavior when MEMORY.md, USER.md, failures.md, or project-scoped memory reaches its character limit: `auto-consolidate` runs the existing consolidation flow; `reject` returns an error; `fifo-evict` rotates older entries in file order until the new entry fits |
480
481
  | `autoConsolidate` | `true` | Legacy alias for `memoryOverflowStrategy` when `memoryOverflowStrategy` is not set (`true` = `auto-consolidate`, `false` = `reject`) |
481
482
  | `consolidationTimeoutMs` | `60000` | Maximum time in milliseconds for auto-consolidation to complete |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-hermes-memory",
3
- "version": "0.7.23",
4
- "description": "🧠 Persistent memory + 🔍 session search + 🛡️ secret scanning for Pi. Token-aware policy-only memory by default, SQLite FTS5 search, auto-consolidation, procedural skills. 368 tests. Ported from Hermes agent.",
3
+ "version": "0.8.0",
4
+ "description": "🧠 Persistent memory + 🔍 session search + 🛡️ secret scanning for Pi. Token-aware policy-only memory by default, SQLite FTS5 search, auto-consolidation, procedural skills. 693 tests. Ported from Hermes agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "files": [
package/src/config.ts CHANGED
@@ -141,6 +141,12 @@ export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
141
141
  if (trimmed.length > 0) config.llmModelOverride = trimmed;
142
142
  }
143
143
  if (isThinkingLevel(parsed.llmThinkingOverride)) config.llmThinkingOverride = parsed.llmThinkingOverride;
144
+ if (isStringArray(parsed.childExtensionPaths)) {
145
+ const childExtensionPaths = [...new Set<string>(
146
+ (parsed.childExtensionPaths as string[]).map((item) => item.trim()).filter(Boolean),
147
+ )];
148
+ if (childExtensionPaths.length > 0) config.childExtensionPaths = childExtensionPaths;
149
+ }
144
150
  if (hasMemoryOverflowStrategy) {
145
151
  config.autoConsolidate = config.memoryOverflowStrategy === "auto-consolidate";
146
152
  } else if (hasLegacyAutoConsolidate) {
package/src/constants.ts CHANGED
@@ -145,16 +145,11 @@ For failures, include: what was tried, why it failed, what error occurred, and w
145
145
 
146
146
  Only act if there's something genuinely worth saving. If nothing stands out, just say 'Nothing to save.' and stop.`;
147
147
 
148
- // ─── Direct (in-process) background review prompts ───
149
- export const DIRECT_REVIEW_SYSTEM_PROMPT = `You review coding conversations and extract durable memories worth saving across sessions.
150
-
151
- Review these aspects:
152
- - **Memory**: User persona, preferences, expectations about how the agent should behave, work style.
153
- - **Failures & Corrections**: What failed, user corrections, insights, conventions, tool quirks.
154
-
155
- Do NOT create or modify skills. Only save genuinely durable facts — not task progress, session outcomes, or temporary state.
156
-
157
- Respond with JSON only (no markdown fences):
148
+ // ─── Shared JSON operations schema for direct (in-process) completions ───
149
+ // (review/flush/consolidation/correction all ask the model to respond with
150
+ // this same {"operations":[...]} shape instead of calling the memory tool,
151
+ // since direct mode is a single completeSimple() call with no tool loop).
152
+ const DIRECT_MEMORY_OPERATIONS_SCHEMA = `Respond with JSON only (no markdown fences):
158
153
  {
159
154
  "operations": [
160
155
  {
@@ -171,10 +166,57 @@ Operation fields:
171
166
  - content: required for add/replace
172
167
  - old_text: required for replace/remove (substring match)
173
168
  - category: for failure target — failure | correction | insight | convention | tool-quirk | preference
174
- - failure_reason: optional context for failure entries
169
+ - failure_reason: optional context for failure entries`;
170
+
171
+ export const DIRECT_REVIEW_SYSTEM_PROMPT = `You review coding conversations and extract durable memories worth saving across sessions.
172
+
173
+ Review these aspects:
174
+ - **Memory**: User persona, preferences, expectations about how the agent should behave, work style.
175
+ - **Failures & Corrections**: What failed, user corrections, insights, conventions, tool quirks.
176
+
177
+ Do NOT create or modify skills. Only save genuinely durable facts — not task progress, session outcomes, or temporary state.
178
+
179
+ ${DIRECT_MEMORY_OPERATIONS_SCHEMA}
175
180
 
176
181
  If nothing is worth saving, return {"operations":[]}.`;
177
182
 
183
+ // ─── Direct (in-process) flush prompt — used by session-flush.ts when the
184
+ // session is about to lose context (compaction/shutdown). ───
185
+ export const DIRECT_FLUSH_SYSTEM_PROMPT = `The session is being compressed and about to lose context. Save anything worth remembering from the conversation — prioritize user preferences, corrections, and recurring patterns over task-specific details.
186
+
187
+ ${DIRECT_MEMORY_OPERATIONS_SCHEMA}
188
+
189
+ If nothing is worth saving, return {"operations":[]}.`;
190
+
191
+ // ─── Direct (in-process) consolidation prompt — used by auto-consolidate.ts.
192
+ // Unlike review/flush/correction, a single triggerConsolidation() call is
193
+ // always scoped to exactly one target/store, passed via the user prompt. ───
194
+ export const DIRECT_CONSOLIDATION_SYSTEM_PROMPT = `The memory store you're given is at capacity. Consolidate its current entries:
195
+ - Merge related entries into a single, concise entry
196
+ - Remove outdated or superseded entries (entries older than 30 days without recent references are candidates for removal)
197
+ - Keep the most important and frequently-referenced facts
198
+ - Preserve user preferences and corrections (highest priority)
199
+
200
+ Each entry shows when it was created and last referenced in HTML comments (<!-- created=..., last=... -->). Use this to identify stale entries.
201
+
202
+ Express a merge as "remove" operations for the entries being dropped plus one "add" operation for the new merged entry. Be aggressive about merging — less is more. Every operation MUST use the exact target given to you in the user message; do not touch any other target.
203
+
204
+ ${DIRECT_MEMORY_OPERATIONS_SCHEMA}`;
205
+
206
+ // ─── Direct (in-process) correction-save prompt — used by correction-detector.ts. ───
207
+ export const DIRECT_CORRECTION_SYSTEM_PROMPT = `The user just corrected the agent. Review what went wrong and decide what durable memory to save.
208
+
209
+ Priority:
210
+ 1. User preference ("don't do X", "always use Y instead")
211
+ 2. Wrong assumption the agent made
212
+ 3. Environment fact the agent got wrong
213
+
214
+ If this contradicts an existing entry, use a "replace" operation to update it instead of "add".
215
+
216
+ ${DIRECT_MEMORY_OPERATIONS_SCHEMA}
217
+
218
+ If nothing is worth saving beyond the automatic failure-memory capture, return {"operations":[]}.`;
219
+
178
220
  // ─── Flush prompt (ported from flush_memories() in run_agent.py ~L7379) ───
179
221
  export const FLUSH_PROMPT = `[System: The session is being compressed. Save anything worth remembering — prioritize user preferences, corrections, and recurring patterns over task-specific details.]`;
180
222
 
@@ -1,12 +1,41 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
3
4
  import path from "node:path";
5
+ import Database from "better-sqlite3";
6
+ import { AtomicLockCoordinator, type AtomicLockLease } from "./store/atomic-lock-coordinator.js";
7
+ import { canonicalStoragePathSync } from "./store/canonical-storage-path.js";
8
+
9
+ const DATABASE_FILES = ["sessions.db", "sessions.db-wal", "sessions.db-shm"] as const;
10
+ const DATABASE_MIGRATION_PENDING_FILE = ".sessions-db-migration-pending";
4
11
 
5
12
  export interface ExtensionRootMigrationResult {
6
13
  moved: number;
7
14
  merged: number;
8
15
  skipped: number;
9
16
  warnings: string[];
17
+ criticalFailures: Array<{
18
+ name: string;
19
+ source: string;
20
+ target: string;
21
+ message: string;
22
+ }>;
23
+ }
24
+
25
+ export interface ExtensionRootMigrationOptions {
26
+ moveFile?: (source: string, target: string) => Promise<void>;
27
+ publishDatabaseFile?: (source: string, target: string) => Promise<void>;
28
+ retireDatabaseFile?: (source: string, target: string) => Promise<void>;
29
+ backupDatabase?: (source: string, staged: string, onProgress?: () => void) => Promise<void>;
30
+ onDatabaseBackupProgress?: () => void;
31
+ }
32
+
33
+ const MIGRATION_LOCK_WAIT_MS = 5000;
34
+ const MIGRATION_LOCK_POLL_MS = 50;
35
+
36
+ export function isDatabaseMigrationPending(legacyRoot: string, targetRoot: string): boolean {
37
+ return existsSync(path.join(targetRoot, DATABASE_MIGRATION_PENDING_FILE))
38
+ || (existsSync(path.join(legacyRoot, "sessions.db")) && !existsSync(path.join(targetRoot, "sessions.db")));
10
39
  }
11
40
 
12
41
  async function pathExists(filePath: string): Promise<boolean> {
@@ -18,6 +47,24 @@ async function pathExists(filePath: string): Promise<boolean> {
18
47
  }
19
48
  }
20
49
 
50
+ async function pathEntryExists(filePath: string): Promise<boolean> {
51
+ try {
52
+ await fs.lstat(filePath);
53
+ return true;
54
+ } catch (error) {
55
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ async function databaseFilesAt(root: string): Promise<string[]> {
61
+ const names: string[] = [];
62
+ for (const name of DATABASE_FILES) {
63
+ if (await pathEntryExists(path.join(root, name))) names.push(name);
64
+ }
65
+ return names;
66
+ }
67
+
21
68
  async function moveFileSafe(source: string, target: string): Promise<void> {
22
69
  await fs.mkdir(path.dirname(target), { recursive: true });
23
70
 
@@ -33,26 +80,176 @@ async function moveFileSafe(source: string, target: string): Promise<void> {
33
80
  await fs.unlink(source);
34
81
  }
35
82
 
36
- async function moveDirContents(sourceDir: string, targetDir: string, result: ExtensionRootMigrationResult): Promise<void> {
83
+ async function stageDatabaseSnapshot(
84
+ source: string,
85
+ staged: string,
86
+ onProgress?: () => void,
87
+ ): Promise<void> {
88
+ const sourceDb = new Database(source, { readonly: true, fileMustExist: true });
89
+ try {
90
+ await sourceDb.backup(staged, {
91
+ progress: () => {
92
+ onProgress?.();
93
+ return 64;
94
+ },
95
+ });
96
+ } finally {
97
+ sourceDb.close();
98
+ }
99
+
100
+ const stagedDb = new Database(staged, { readonly: true, fileMustExist: true });
101
+ try {
102
+ const check = stagedDb.pragma("integrity_check", { simple: true });
103
+ if (check !== "ok") throw new Error(`staged SQLite snapshot failed integrity_check: ${String(check)}`);
104
+ } finally {
105
+ stagedDb.close();
106
+ }
107
+ }
108
+
109
+ function isDatabaseCorruption(error: unknown): boolean {
110
+ const code = typeof error === "object" && error && "code" in error
111
+ ? String((error as { code?: unknown }).code)
112
+ : "";
113
+ if (code === "SQLITE_CORRUPT" || code === "SQLITE_NOTADB") return true;
114
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
115
+ return message.includes("database disk image is malformed")
116
+ || message.includes("file is not a database")
117
+ || message.includes("database schema is corrupt")
118
+ || message.includes("malformed database schema")
119
+ || message.includes("failed integrity_check");
120
+ }
121
+
122
+ async function acquireMigrationLease(legacyRoot: string, targetRoot: string): Promise<AtomicLockLease> {
123
+ const coordinator = new AtomicLockCoordinator(path.join(targetRoot, ".pi-hermes-locks.sqlite"));
124
+ const sourceIdentity = canonicalStoragePathSync(path.join(legacyRoot, "sessions.db"));
125
+ const targetIdentity = canonicalStoragePathSync(path.join(targetRoot, "sessions.db"));
126
+ const key = `extension-root-migration:${sourceIdentity}:${targetIdentity}`;
127
+ const deadline = Date.now() + MIGRATION_LOCK_WAIT_MS;
128
+
129
+ while (true) {
130
+ const lease = coordinator.tryAcquire(key, { staleMs: 300_000 });
131
+ if (lease) return lease;
132
+ if (Date.now() >= deadline) {
133
+ throw new Error(`SQLite extension-root migration already in progress for ${targetIdentity}`);
134
+ }
135
+ await new Promise<void>((resolve) => setTimeout(resolve, MIGRATION_LOCK_POLL_MS));
136
+ }
137
+ }
138
+
139
+ class DatabaseGenerationMoveError extends Error {
140
+ constructor(message: string, readonly moved: string[], options: { cause: unknown }) {
141
+ super(message, options);
142
+ }
143
+ }
144
+
145
+ async function moveDatabaseGeneration(
146
+ names: string[],
147
+ sourceRoot: string,
148
+ holdingRoot: string,
149
+ move: (source: string, target: string) => Promise<void>,
150
+ ): Promise<string[]> {
151
+ const moved: string[] = [];
152
+ await fs.mkdir(holdingRoot, { mode: 0o700 });
153
+ const orderedNames = [...names].sort((left, right) => Number(left === "sessions.db") - Number(right === "sessions.db"));
154
+ try {
155
+ for (const name of orderedNames) {
156
+ const source = path.join(sourceRoot, name);
157
+ const target = path.join(holdingRoot, name);
158
+ try {
159
+ await move(source, target);
160
+ moved.push(name);
161
+ } catch (error) {
162
+ if (await pathEntryExists(target)) moved.push(name);
163
+ throw error;
164
+ }
165
+ }
166
+ return moved;
167
+ } catch (error) {
168
+ throw new DatabaseGenerationMoveError(
169
+ error instanceof Error ? error.message : String(error),
170
+ moved,
171
+ { cause: error },
172
+ );
173
+ }
174
+ }
175
+
176
+ async function restoreDatabaseGeneration(names: string[], holdingRoot: string, sourceRoot: string): Promise<string[]> {
177
+ const failures: string[] = [];
178
+ for (const name of [...names].reverse()) {
179
+ const held = path.join(holdingRoot, name);
180
+ if (!await pathEntryExists(held)) continue;
181
+ try {
182
+ await fs.link(held, path.join(sourceRoot, name));
183
+ await fs.unlink(held);
184
+ } catch (error) {
185
+ failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
186
+ }
187
+ }
188
+ return failures;
189
+ }
190
+
191
+ interface FileIdentity {
192
+ dev: number;
193
+ ino: number;
194
+ }
195
+
196
+ async function fileIdentity(filePath: string): Promise<FileIdentity> {
197
+ const stat = await fs.lstat(filePath);
198
+ return { dev: stat.dev, ino: stat.ino };
199
+ }
200
+
201
+ async function unlinkIfOwned(filePath: string, identity: FileIdentity): Promise<void> {
202
+ try {
203
+ const current = await fileIdentity(filePath);
204
+ if (current.dev === identity.dev && current.ino === identity.ino) await fs.unlink(filePath);
205
+ } catch (error) {
206
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
207
+ }
208
+ }
209
+
210
+ async function stageDatabaseSymlink(source: string, staged: string): Promise<void> {
211
+ const before = await fs.readlink(source);
212
+ await fs.symlink(path.resolve(path.dirname(source), before), staged);
213
+ const after = await fs.readlink(source);
214
+ if (before !== after) throw new Error("sessions.db symlink changed while staging");
215
+ }
216
+
217
+ async function moveDirContents(
218
+ sourceDir: string,
219
+ targetDir: string,
220
+ result: ExtensionRootMigrationResult,
221
+ moveFile: (source: string, target: string) => Promise<void>,
222
+ relativeDir = "",
223
+ ): Promise<void> {
37
224
  await fs.mkdir(targetDir, { recursive: true });
38
225
 
39
226
  const entries = await fs.readdir(sourceDir, { withFileTypes: true });
40
227
  for (const entry of entries) {
228
+ if (!relativeDir && DATABASE_FILES.includes(entry.name as typeof DATABASE_FILES[number])) {
229
+ continue;
230
+ }
41
231
  const sourcePath = path.join(sourceDir, entry.name);
42
232
  const targetPath = path.join(targetDir, entry.name);
43
233
 
44
234
  if (!await pathExists(targetPath)) {
45
235
  try {
46
- await moveFileSafe(sourcePath, targetPath);
236
+ await moveFile(sourcePath, targetPath);
47
237
  result.moved++;
48
238
  } catch (error) {
49
- result.warnings.push(`${sourcePath}: ${error instanceof Error ? error.message : String(error)}`);
239
+ const message = error instanceof Error ? error.message : String(error);
240
+ result.warnings.push(`${sourcePath}: ${message}`);
50
241
  }
51
242
  continue;
52
243
  }
53
244
 
54
245
  if (entry.isDirectory()) {
55
- await moveDirContents(sourcePath, targetPath, result);
246
+ await moveDirContents(
247
+ sourcePath,
248
+ targetPath,
249
+ result,
250
+ moveFile,
251
+ path.join(relativeDir, entry.name),
252
+ );
56
253
  result.merged++;
57
254
  try {
58
255
  const remaining = await fs.readdir(sourcePath);
@@ -67,6 +264,221 @@ async function moveDirContents(sourceDir: string, targetDir: string, result: Ext
67
264
  }
68
265
  }
69
266
 
267
+ async function publishDatabaseFile(source: string, target: string): Promise<void> {
268
+ if ((await fs.lstat(source)).isSymbolicLink()) {
269
+ await fs.symlink(await fs.readlink(source), target);
270
+ return;
271
+ }
272
+ await fs.link(source, target);
273
+ }
274
+
275
+ async function migrateDatabaseGeneration(
276
+ legacyRoot: string,
277
+ targetRoot: string,
278
+ result: ExtensionRootMigrationResult,
279
+ publish: (source: string, target: string) => Promise<void>,
280
+ retire: (source: string, target: string) => Promise<void>,
281
+ backup: (source: string, staged: string, onProgress?: () => void) => Promise<void>,
282
+ onBackupProgress?: () => void,
283
+ ): Promise<void> {
284
+ let lease: AtomicLockLease | null = null;
285
+ try {
286
+ lease = await acquireMigrationLease(legacyRoot, targetRoot);
287
+ } catch (error) {
288
+ const message = error instanceof Error ? error.message : String(error);
289
+ result.warnings.push(`${path.join(legacyRoot, "sessions.db")}: ${message}`);
290
+ result.criticalFailures.push({
291
+ name: "sessions.db",
292
+ source: path.join(legacyRoot, "sessions.db"),
293
+ target: path.join(targetRoot, "sessions.db"),
294
+ message,
295
+ });
296
+ return;
297
+ }
298
+
299
+ try {
300
+ const pendingMarker = path.join(targetRoot, DATABASE_MIGRATION_PENDING_FILE);
301
+ const hadPendingMarker = await pathEntryExists(pendingMarker);
302
+ const sourceNames = await databaseFilesAt(legacyRoot);
303
+ const targetNames = await databaseFilesAt(targetRoot);
304
+ if (sourceNames.length === 0) {
305
+ if (!hadPendingMarker) return;
306
+ if (targetNames.includes("sessions.db")) {
307
+ await fs.unlink(pendingMarker);
308
+ return;
309
+ }
310
+ const retirementDirs = (await fs.readdir(legacyRoot))
311
+ .filter((name) => name.startsWith(".sessions-db-retirement-"));
312
+ const message = retirementDirs.length > 0
313
+ ? `an interrupted migration preserved recovery artifacts at ${retirementDirs.map((name) => path.join(legacyRoot, name)).join(", ")}`
314
+ : "an interrupted migration has no complete source or destination SQLite generation";
315
+ result.warnings.push(`${path.join(legacyRoot, "sessions.db")}: ${message}`);
316
+ result.criticalFailures.push({
317
+ name: "sessions.db",
318
+ source: path.join(legacyRoot, "sessions.db"),
319
+ target: path.join(targetRoot, "sessions.db"),
320
+ message,
321
+ });
322
+ return;
323
+ }
324
+
325
+ if (targetNames.includes("sessions.db")) {
326
+ if (hadPendingMarker) {
327
+ const message = "an incomplete migration left both legacy and destination SQLite generations; manual recovery is required";
328
+ result.warnings.push(`${path.join(legacyRoot, "sessions.db")}: ${message}`);
329
+ result.criticalFailures.push({
330
+ name: "sessions.db",
331
+ source: path.join(legacyRoot, "sessions.db"),
332
+ target: path.join(targetRoot, "sessions.db"),
333
+ message,
334
+ });
335
+ return;
336
+ }
337
+ result.skipped += sourceNames.length;
338
+ return;
339
+ }
340
+
341
+ if (!sourceNames.includes("sessions.db") || targetNames.length > 0) {
342
+ const message = targetNames.length > 0
343
+ ? `destination contains a partial SQLite generation: ${targetNames.join(", ")}`
344
+ : "legacy SQLite sidecars exist without sessions.db";
345
+ result.warnings.push(`${path.join(legacyRoot, "sessions.db")}: ${message}`);
346
+ result.criticalFailures.push({
347
+ name: "sessions.db",
348
+ source: path.join(legacyRoot, "sessions.db"),
349
+ target: path.join(targetRoot, "sessions.db"),
350
+ message,
351
+ });
352
+ return;
353
+ }
354
+
355
+ await fs.mkdir(targetRoot, { recursive: true });
356
+ const stagingDir = path.join(targetRoot, `.sessions-db-migration-${randomUUID()}`);
357
+ const retirementDir = path.join(legacyRoot, `.sessions-db-retirement-${randomUUID()}`);
358
+ const published = new Map<string, FileIdentity>();
359
+ let retired: string[] = [];
360
+ let preserveRetirement = false;
361
+ let keepPendingMarker = false;
362
+ let writeLock: {
363
+ pragma: (query: string) => unknown;
364
+ exec: (sql: string) => void;
365
+ close: () => void;
366
+ } | null = null;
367
+ let corruptGeneration = false;
368
+ let generationNames = sourceNames;
369
+ try {
370
+ await fs.writeFile(pendingMarker, `${process.pid}:${randomUUID()}\n`, { mode: 0o600 });
371
+ await fs.mkdir(stagingDir, { mode: 0o700 });
372
+ const source = path.join(legacyRoot, "sessions.db");
373
+ const staged = path.join(stagingDir, "sessions.db");
374
+ const sourceState = await fs.lstat(source);
375
+ try {
376
+ writeLock = new Database(source, { fileMustExist: true, timeout: 0 });
377
+ writeLock.pragma("busy_timeout = 0");
378
+ writeLock.exec("BEGIN IMMEDIATE");
379
+ } catch (error) {
380
+ if (!isDatabaseCorruption(error)) throw error;
381
+ if (writeLock) {
382
+ try { writeLock.close(); } catch {}
383
+ writeLock = null;
384
+ }
385
+ corruptGeneration = true;
386
+ }
387
+ generationNames = await databaseFilesAt(legacyRoot);
388
+
389
+ if (sourceState.isSymbolicLink()) {
390
+ if (sourceNames.length !== 1) {
391
+ throw new Error("symlinked sessions.db cannot be combined with legacy SQLite sidecars");
392
+ }
393
+ await stageDatabaseSymlink(source, staged);
394
+ corruptGeneration = false;
395
+ } else if (sourceState.isFile()) {
396
+ if (!corruptGeneration) {
397
+ try {
398
+ await backup(source, staged, onBackupProgress);
399
+ } catch (error) {
400
+ if (!isDatabaseCorruption(error)) throw error;
401
+ corruptGeneration = true;
402
+ try { await fs.unlink(staged); } catch {}
403
+ }
404
+ }
405
+ if (corruptGeneration) {
406
+ try {
407
+ retired = await moveDatabaseGeneration(generationNames, legacyRoot, retirementDir, retire);
408
+ } catch (error) {
409
+ if (error instanceof DatabaseGenerationMoveError) retired = error.moved;
410
+ throw error;
411
+ }
412
+ for (const name of retired) {
413
+ const target = path.join(targetRoot, name);
414
+ await publish(path.join(retirementDir, name), target);
415
+ published.set(target, await fileIdentity(target));
416
+ }
417
+ }
418
+ } else {
419
+ throw new Error("sessions.db is not a regular file or symlink");
420
+ }
421
+
422
+ if (!corruptGeneration) {
423
+ try {
424
+ retired = await moveDatabaseGeneration(generationNames, legacyRoot, retirementDir, retire);
425
+ } catch (error) {
426
+ if (error instanceof DatabaseGenerationMoveError) retired = error.moved;
427
+ throw error;
428
+ }
429
+ const target = path.join(targetRoot, "sessions.db");
430
+ await publish(staged, target);
431
+ published.set(target, await fileIdentity(target));
432
+ }
433
+
434
+ if (writeLock) writeLock.exec("COMMIT");
435
+ result.moved += generationNames.length;
436
+ } catch (error) {
437
+ for (const [target, identity] of [...published.entries()].reverse()) {
438
+ try { await unlinkIfOwned(target, identity); } catch {}
439
+ }
440
+ let restoreFailures: string[] = [];
441
+ if (retired.length > 0) {
442
+ restoreFailures = await restoreDatabaseGeneration(retired, retirementDir, legacyRoot);
443
+ preserveRetirement = restoreFailures.length > 0;
444
+ keepPendingMarker = preserveRetirement;
445
+ }
446
+ const destinationPreserved = await pathEntryExists(path.join(targetRoot, "sessions.db"));
447
+ if (destinationPreserved) keepPendingMarker = true;
448
+ if (writeLock) {
449
+ try { writeLock.exec("ROLLBACK"); } catch {}
450
+ }
451
+ const baseMessage = error instanceof Error ? error.message : String(error);
452
+ let message = restoreFailures.length > 0
453
+ ? `${baseMessage}; recovery artifacts preserved at ${retirementDir} (${restoreFailures.join("; ")})`
454
+ : baseMessage;
455
+ if (destinationPreserved) {
456
+ message += `; an unowned destination generation was preserved at ${path.join(targetRoot, "sessions.db")}`;
457
+ }
458
+ result.warnings.push(`${path.join(legacyRoot, "sessions.db")}: ${message}`);
459
+ result.criticalFailures.push({
460
+ name: "sessions.db",
461
+ source: path.join(legacyRoot, "sessions.db"),
462
+ target: path.join(targetRoot, "sessions.db"),
463
+ message,
464
+ });
465
+ } finally {
466
+ if (writeLock) {
467
+ try { writeLock.close(); } catch {}
468
+ }
469
+ try { await fs.rm(stagingDir, { recursive: true, force: true }); } catch {}
470
+ if (!preserveRetirement) {
471
+ try { await fs.rm(retirementDir, { recursive: true, force: true }); } catch {}
472
+ }
473
+ if (!keepPendingMarker) {
474
+ try { await fs.unlink(pendingMarker); } catch {}
475
+ }
476
+ }
477
+ } finally {
478
+ lease.release();
479
+ }
480
+ }
481
+
70
482
  /**
71
483
  * Move legacy extension assets from ~/.pi/agent/memory into
72
484
  * ~/.pi/agent/pi-hermes-memory. Existing destination files win.
@@ -74,19 +486,31 @@ async function moveDirContents(sourceDir: string, targetDir: string, result: Ext
74
486
  export async function migrateExtensionRoot(
75
487
  legacyRoot: string,
76
488
  targetRoot: string,
489
+ options: ExtensionRootMigrationOptions = {},
77
490
  ): Promise<ExtensionRootMigrationResult> {
78
491
  const result: ExtensionRootMigrationResult = {
79
492
  moved: 0,
80
493
  merged: 0,
81
494
  skipped: 0,
82
495
  warnings: [],
496
+ criticalFailures: [],
83
497
  };
84
498
 
85
499
  if (path.resolve(legacyRoot) === path.resolve(targetRoot)) return result;
86
500
  if (!existsSync(legacyRoot)) return result;
87
501
 
88
502
  await fs.mkdir(targetRoot, { recursive: true });
89
- await moveDirContents(legacyRoot, targetRoot, result);
503
+ await migrateDatabaseGeneration(
504
+ legacyRoot,
505
+ targetRoot,
506
+ result,
507
+ options.publishDatabaseFile ?? options.moveFile ?? publishDatabaseFile,
508
+ options.retireDatabaseFile ?? moveFileSafe,
509
+ options.backupDatabase ?? stageDatabaseSnapshot,
510
+ options.onDatabaseBackupProgress,
511
+ );
512
+ if (result.criticalFailures.some((failure) => failure.name === "sessions.db")) return result;
513
+ await moveDirContents(legacyRoot, targetRoot, result, options.moveFile ?? moveFileSafe);
90
514
 
91
515
  try {
92
516
  const remaining = await fs.readdir(legacyRoot);