pi-hermes-memory 0.7.23 → 0.8.1

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.
@@ -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);
@@ -2,19 +2,72 @@
2
2
  * Auto-consolidation — when memory hits capacity, trigger automatic
3
3
  * consolidation instead of returning an error.
4
4
  *
5
- * Uses pi.exec() to spawn a one-shot consolidation process.
6
- * The child process modifies files on disk, so the parent MUST reload
7
- * from disk after consolidation completes.
5
+ * Default transport: in-process direct completion (same mechanism as
6
+ * background review see review-memory-ops.ts), used only when a caller
7
+ * supplies model/modelRegistry access (the manual `/memory-consolidate`
8
+ * command has it; the automatic over-capacity consolidator registered on
9
+ * MemoryStore does not, since MemoryStore itself has no extension-runtime
10
+ * access, so that path stays subprocess-only). Falls back to a `pi -p`
11
+ * subprocess when direct mode is unavailable, declines, or fails.
12
+ *
13
+ * The subprocess child process modifies files on disk, so the parent MUST
14
+ * reload from disk after a subprocess-based consolidation completes.
8
15
  */
9
16
 
10
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import * as fs from "node:fs/promises";
18
+ import * as path from "node:path";
19
+ import { createHash } from "node:crypto";
20
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
21
  import { MemoryStore } from "../store/memory-store.js";
12
- import { CONSOLIDATION_PROMPT, ENTRY_DELIMITER } from "../constants.js";
22
+ import { DatabaseManager } from "../store/db.js";
23
+ import { CONSOLIDATION_PROMPT, DIRECT_CONSOLIDATION_SYSTEM_PROMPT, ENTRY_DELIMITER } from "../constants.js";
13
24
  import type { ConsolidationResult, MemoryConfig } from "../types.js";
25
+ import { AGENT_ROOT } from "../paths.js";
14
26
  import { execChildPrompt } from "./pi-child-process.js";
27
+ import { runDirectMemoryCompletion, usesDirectTransport } from "./review-memory-ops.js";
28
+ import { AtomicLockCoordinator } from "../store/atomic-lock-coordinator.js";
15
29
 
16
30
  type MemoryTarget = "memory" | "user" | "failure";
17
31
  type ToolMemoryTarget = MemoryTarget | "project";
32
+ type ConsolidationLlmConfig = Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride" | "reviewTransport">;
33
+
34
+ const CONSOLIDATION_LOCK_STALE_GRACE_MS = 30000;
35
+ const CONSOLIDATION_LOCK_ENV = "PI_HERMES_CONSOLIDATION_LOCK_DIR";
36
+
37
+ interface ConsolidationLock {
38
+ release: () => Promise<void>;
39
+ }
40
+
41
+ function consolidationLockRoot(): string {
42
+ return process.env[CONSOLIDATION_LOCK_ENV]?.trim()
43
+ || path.join(AGENT_ROOT, "pi-hermes-memory", ".consolidation-locks");
44
+ }
45
+
46
+ function sanitizeLockPart(value: string): string {
47
+ return value.replace(/[^a-z0-9._-]+/gi, "_").slice(0, 80) || "unknown";
48
+ }
49
+
50
+ function consolidationLockKey(target: MemoryTarget, toolTarget: ToolMemoryTarget, storageIdentity: string): string {
51
+ const storageHash = createHash("sha256").update(storageIdentity).digest("hex");
52
+ return `${sanitizeLockPart(toolTarget)}:${sanitizeLockPart(target)}:${storageHash}`;
53
+ }
54
+
55
+ async function tryAcquireConsolidationLock(
56
+ store: MemoryStore,
57
+ target: MemoryTarget,
58
+ toolTarget: ToolMemoryTarget,
59
+ timeoutMs: number,
60
+ ): Promise<ConsolidationLock | null> {
61
+ const storageIdentity = await store.getStorageIdentity(target);
62
+ const root = consolidationLockRoot();
63
+ await fs.mkdir(root, { recursive: true });
64
+ const coordinator = new AtomicLockCoordinator(path.join(root, "locks.sqlite"));
65
+ const lease = coordinator.tryAcquire(
66
+ consolidationLockKey(target, toolTarget, storageIdentity),
67
+ { staleMs: Math.max(timeoutMs, 0) + CONSOLIDATION_LOCK_STALE_GRACE_MS },
68
+ );
69
+ return lease ? { release: async () => lease.release() } : null;
70
+ }
18
71
 
19
72
  function entriesForTarget(store: MemoryStore, target: MemoryTarget): string[] {
20
73
  if (target === "user") return store.getUserEntries();
@@ -34,7 +87,7 @@ function describeConsolidationFailure(
34
87
  timeoutMs: number,
35
88
  ): string {
36
89
  const stderr = result.stderr?.trim();
37
- const terminated = result.killed || result.code === 143;
90
+ const terminated = result.killed || result.code === 124 || result.code === 143;
38
91
 
39
92
  if (terminated) {
40
93
  return `Consolidation subprocess was terminated (likely timeout or cancellation). Timeout: ${timeoutMs}ms. Consider increasing consolidationTimeoutMs if this is a manual run.`;
@@ -50,10 +103,47 @@ export async function triggerConsolidation(
50
103
  signal?: AbortSignal,
51
104
  timeoutMs: number = 60000,
52
105
  toolTarget: ToolMemoryTarget = target,
53
- llmConfig: Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride"> = {},
106
+ llmConfig: ConsolidationLlmConfig = {},
107
+ directCtx: Pick<ExtensionContext, "model" | "modelRegistry"> | null = null,
108
+ dbManager: DatabaseManager | null = null,
109
+ projectName?: string | null,
110
+ deps: { runDirectMemoryCompletion?: typeof runDirectMemoryCompletion } = {},
54
111
  ): Promise<ConsolidationResult> {
55
112
  const entries = entriesForTarget(store, target);
56
113
  const currentContent = entries.join(ENTRY_DELIMITER);
114
+ const runDirect = deps.runDirectMemoryCompletion ?? runDirectMemoryCompletion;
115
+
116
+ if (directCtx && usesDirectTransport(llmConfig)) {
117
+ try {
118
+ const directResult = await runDirect(
119
+ directCtx,
120
+ store,
121
+ toolTarget === "project" ? store : null,
122
+ {
123
+ systemPrompt: DIRECT_CONSOLIDATION_SYSTEM_PROMPT,
124
+ userPrompt: [
125
+ `--- Current ${labelForTarget(target, toolTarget)} Entries (target: '${toolTarget}') ---`,
126
+ currentContent || "(empty)",
127
+ "",
128
+ `Only emit operations with "target": "${toolTarget}".`,
129
+ ].join("\n"),
130
+ config: llmConfig,
131
+ timeoutMs,
132
+ signal,
133
+ },
134
+ dbManager,
135
+ projectName,
136
+ );
137
+ // Consolidation only did its job if it actually freed space — unlike
138
+ // review/flush/correction, an empty or fully-skipped result here is a
139
+ // failure worth falling back to subprocess for, not a normal outcome.
140
+ if (directResult.ok && directResult.appliedCount > 0) {
141
+ return { consolidated: true };
142
+ }
143
+ } catch {
144
+ // Fall through to subprocess below.
145
+ }
146
+ }
57
147
 
58
148
  const prompt = [
59
149
  CONSOLIDATION_PROMPT,
@@ -64,7 +154,17 @@ export async function triggerConsolidation(
64
154
  `Use the memory tool to consolidate. Target: '${toolTarget}'`,
65
155
  ].join("\n");
66
156
 
157
+ let lock: ConsolidationLock | null = null;
158
+
67
159
  try {
160
+ lock = await tryAcquireConsolidationLock(store, target, toolTarget, timeoutMs);
161
+ if (!lock) {
162
+ return {
163
+ consolidated: false,
164
+ error: `Consolidation already in progress for target '${toolTarget}'. Skipping duplicate subprocess.`,
165
+ };
166
+ }
167
+
68
168
  const result = await execChildPrompt(pi, prompt, llmConfig, {
69
169
  signal,
70
170
  timeoutMs,
@@ -83,6 +183,10 @@ export async function triggerConsolidation(
83
183
  consolidated: false,
84
184
  error: `Consolidation failed: ${String(err).slice(0, 200)}`,
85
185
  };
186
+ } finally {
187
+ if (lock) {
188
+ try { await lock.release(); } catch { /* best-effort cleanup */ }
189
+ }
86
190
  }
87
191
  }
88
192
 
@@ -95,7 +199,9 @@ export function registerConsolidateCommand(
95
199
  timeoutMs: number = 60000,
96
200
  projectStore: MemoryStore | null = null,
97
201
  projectName?: string | null,
98
- llmConfig: Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride"> = {},
202
+ llmConfig: ConsolidationLlmConfig = {},
203
+ dbManager: DatabaseManager | null = null,
204
+ deps: { runDirectMemoryCompletion?: typeof runDirectMemoryCompletion } = {},
99
205
  ): void {
100
206
  pi.registerCommand("memory-consolidate", {
101
207
  description: "Manually trigger memory consolidation to free up space",
@@ -157,6 +263,10 @@ export function registerConsolidateCommand(
157
263
  manualTimeoutMs,
158
264
  item.toolTarget,
159
265
  llmConfig,
266
+ ctx,
267
+ dbManager,
268
+ projectName,
269
+ deps,
160
270
  );
161
271
 
162
272
  if (result.consolidated) {