pi-hermes-memory 0.7.15 → 0.7.18

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/src/index.ts CHANGED
@@ -28,6 +28,8 @@ import { MemoryStore } from "./store/memory-store.js";
28
28
  import { SkillStore } from "./store/skill-store.js";
29
29
  import { DatabaseManager } from "./store/db.js";
30
30
  import { indexSession } from "./store/session-indexer.js";
31
+ import { scheduleSessionBackfill, waitForSessionBackfill, SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS } from "./handlers/session-backfill.js";
32
+ import { scheduleLiveSessionIndex, waitForLiveSessionIndex, SESSION_LIVE_INDEX_SHUTDOWN_TIMEOUT_MS } from "./handlers/session-live-index.js";
31
33
  import { parseSessionFile } from "./store/session-parser.js";
32
34
  import { registerMemoryTool } from "./tools/memory-tool.js";
33
35
  import { registerSkillTool } from "./tools/skill-tool.js";
@@ -107,6 +109,7 @@ export default function (pi: ExtensionAPI) {
107
109
  migrationSentinelPath: path.join(globalDir, ".skills-migrated-to-extension-storage"),
108
110
  });
109
111
  const dbManager = new DatabaseManager(globalDir);
112
+ const sessionsDir = path.join(agentRoot, "sessions");
110
113
 
111
114
  const refreshSkillProjectContext = (cwd?: string) => {
112
115
  const resource = resolveProjectSkillDiscovery(skillStore, config.projectsMemoryDir, cwd);
@@ -150,6 +153,19 @@ export default function (pi: ExtensionAPI) {
150
153
  await skillStore.ensureDiscoveredRoots();
151
154
  await store.loadFromDisk();
152
155
  if (projectStore) await projectStore.loadFromDisk();
156
+
157
+ scheduleSessionBackfill(dbManager, sessionsDir, {
158
+ notify: (message, level) => {
159
+ const ui = (ctx as { ui?: { notify?: (message: string, level?: string) => void } }).ui;
160
+ if (ui?.notify) {
161
+ ui.notify(message, level);
162
+ } else if (level === "error" || level === "warning") {
163
+ console.warn(message);
164
+ } else {
165
+ console.info(message);
166
+ }
167
+ },
168
+ });
153
169
  });
154
170
 
155
171
  registerProjectSkillDiscoveryHandler(pi, skillStore, config.projectsMemoryDir);
@@ -201,12 +217,29 @@ export default function (pi: ExtensionAPI) {
201
217
  registerSyncMarkdownMemoriesCommand(pi, dbManager, globalDir, config.projectsMemoryDir, agentRoot);
202
218
  registerPreviewContextCommand(pi, store, projectStore, projectName, config);
203
219
 
204
- // ── 10. SQLite session search + extended memory ──
220
+ // ── 10. Live session indexing ──
221
+ pi.on("message_end", async (_event, ctx) => {
222
+ scheduleLiveSessionIndex(dbManager, ctx.sessionManager, {
223
+ onError: (err) => console.warn(`⚠️ Live session indexing failed: ${err instanceof Error ? err.message : String(err)}`),
224
+ });
225
+ });
226
+
227
+ // ── 11. SQLite session search + extended memory ──
205
228
  registerSessionSearchTool(pi, dbManager, config.sessionSearch ?? { variant: "legacy" });
206
229
  registerMemorySearchTool(pi, dbManager);
207
230
  registerIndexSessionsCommand(pi);
208
231
 
209
- // ── 11. Auto-index session on shutdown ──
232
+ // ── 12. Auto-index session on shutdown ──
233
+ // Registered last, so this runs after the session-flush shutdown handler and
234
+ // is the final DB activity. Closing here truncates the WAL via
235
+ // PRAGMA wal_checkpoint(TRUNCATE); without it the WAL only grows to its
236
+ // high-water mark and is never reclaimed across sessions.
237
+ //
238
+ // Ordering is safe: Pi's ExtensionRunner.emit() runs same-extension handlers
239
+ // sequentially in registration order and awaits each one, so the flush above
240
+ // fully completes before close() runs. WARNING: do not register another
241
+ // DB-writing session_shutdown handler after this block — it would run after
242
+ // close() and silently no-op.
210
243
  pi.on("session_shutdown", async (_event, ctx) => {
211
244
  try {
212
245
  const sessionFile = ctx.sessionManager.getSessionFile();
@@ -218,6 +251,16 @@ export default function (pi: ExtensionAPI) {
218
251
  }
219
252
  } catch {
220
253
  // Silent fail — don't block shutdown
254
+ } finally {
255
+ try {
256
+ await Promise.all([
257
+ waitForSessionBackfill(SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS),
258
+ waitForLiveSessionIndex(SESSION_LIVE_INDEX_SHUTDOWN_TIMEOUT_MS),
259
+ ]);
260
+ } catch {
261
+ // Best effort only — shutdown should not be held up by indexing errors.
262
+ }
263
+ try { dbManager.close(); } catch { /* best effort — never block shutdown */ }
221
264
  }
222
265
  });
223
266
  }
package/src/paths.ts CHANGED
@@ -2,7 +2,12 @@ import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import { DEFAULT_PROJECTS_MEMORY_DIR } from "./constants.js";
4
4
 
5
- export const AGENT_ROOT = path.join(os.homedir(), ".pi", "agent");
5
+ export const AGENT_ROOT = resolveAgentRoot();
6
+
7
+ export function resolveAgentRoot(env: Record<string, string | undefined> = process.env): string {
8
+ const configured = env.PI_CODING_AGENT_DIR?.trim();
9
+ return configured ? path.resolve(expandHome(configured)) : path.join(os.homedir(), ".pi", "agent");
10
+ }
6
11
 
7
12
  export function expandHome(input: string): string {
8
13
  if (input === "~") return os.homedir();
@@ -2,6 +2,10 @@ const FTS5_OPERATOR_PATTERN = /\b(OR|AND|NOT|NEAR)\b/;
2
2
  const FTS5_TOKEN_PATTERN = /"([^"]*)"|(\S+)/g;
3
3
  const NATURAL_LANGUAGE_CONNECTORS = new Set(['and', 'or', 'not', 'near']);
4
4
 
5
+ export function hasExplicitFts5Operator(query: string): boolean {
6
+ return FTS5_OPERATOR_PATTERN.test(query.trim());
7
+ }
8
+
5
9
  function collectNaturalLanguageTerms(query: string): string[] {
6
10
  const terms: string[] = [];
7
11
 
@@ -29,7 +33,7 @@ export function normalizeFts5Query(query: string): string {
29
33
  const trimmed = query.trim();
30
34
  if (trimmed.length === 0) return '';
31
35
 
32
- if (FTS5_OPERATOR_PATTERN.test(trimmed)) {
36
+ if (hasExplicitFts5Operator(trimmed)) {
33
37
  return trimmed;
34
38
  }
35
39
 
@@ -45,7 +49,7 @@ export function normalizeFts5Query(query: string): string {
45
49
  */
46
50
  export function buildFallbackFts5Query(query: string): string | null {
47
51
  const trimmed = query.trim();
48
- if (trimmed.length === 0 || FTS5_OPERATOR_PATTERN.test(trimmed)) {
52
+ if (trimmed.length === 0 || hasExplicitFts5Operator(trimmed)) {
49
53
  return null;
50
54
  }
51
55
 
@@ -13,7 +13,6 @@
13
13
 
14
14
  import * as fs from "node:fs/promises";
15
15
  import * as path from "node:path";
16
- import * as os from "node:os";
17
16
  import { scanContent } from "./content-scanner.js";
18
17
  import { normalizeMemoryLookupText } from "./memory-lookup.js";
19
18
  import {
@@ -26,6 +25,7 @@ import {
26
25
  USER_FILE,
27
26
  } from "../constants.js";
28
27
  import type { MemoryConfig, MemoryResult, MemorySnapshot, ConsolidationResult, MemoryCategory, MemoryOverflowStrategy } from "../types.js";
28
+ import { AGENT_ROOT } from "../paths.js";
29
29
 
30
30
  export class MemoryStore {
31
31
  private memoryEntries: string[] = [];
@@ -47,7 +47,7 @@ export class MemoryStore {
47
47
  // ─── Path helpers ───
48
48
 
49
49
  private get memoryDir(): string {
50
- return this.config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "pi-hermes-memory");
50
+ return this.config.memoryDir ?? path.join(AGENT_ROOT, "pi-hermes-memory");
51
51
  }
52
52
 
53
53
  private pathFor(target: "memory" | "user" | "failure"): string {
@@ -338,6 +338,15 @@ export class MemoryStore {
338
338
  return block ? this.fenceBlock(block) : "";
339
339
  }
340
340
 
341
+ /**
342
+ * All failure entries (no age filter), metadata stripped.
343
+ * Used by consolidation, which must consider the full file size —
344
+ * unlike getFailureEntries(), which filters by age for injection.
345
+ */
346
+ getAllFailureEntries(): string[] {
347
+ return this.failureEntries.map((e) => this.stripMetadata(e));
348
+ }
349
+
341
350
  getMemoryEntries(): string[] {
342
351
  return this.memoryEntries.map((e) => this.stripMetadata(e));
343
352
  }
@@ -10,6 +10,12 @@
10
10
  */
11
11
 
12
12
  export const SCHEMA_SQL = `
13
+ -- Extension key/value metadata
14
+ CREATE TABLE IF NOT EXISTS extension_metadata (
15
+ key TEXT PRIMARY KEY,
16
+ value TEXT NOT NULL
17
+ );
18
+
13
19
  -- Session metadata
14
20
  CREATE TABLE IF NOT EXISTS sessions (
15
21
  id TEXT PRIMARY KEY,
@@ -1,13 +1,17 @@
1
+ import fs from 'node:fs';
1
2
  import { DatabaseManager } from './db.js';
2
3
  import { parseSessionFile, getSessionFiles, type ParsedSession } from './session-parser.js';
3
4
 
5
+ export const LAST_SESSION_BACKFILL_KEY = 'last_session_backfill';
6
+ export const SESSION_BACKFILL_INTERVAL_MS = 24 * 60 * 60 * 1000;
7
+
4
8
  /**
5
9
  * Index result for a single session.
6
10
  */
7
11
  export interface IndexResult {
8
12
  sessionId: string;
9
13
  messagesIndexed: number;
10
- skipped: boolean; // true if already indexed
14
+ skipped: boolean; // true if the session already existed and no new messages were indexed
11
15
  }
12
16
 
13
17
  /**
@@ -29,33 +33,39 @@ export interface BulkIndexResult {
29
33
  export function indexSession(dbManager: DatabaseManager, session: ParsedSession): IndexResult {
30
34
  const db = dbManager.getDb();
31
35
 
32
- // Check if already indexed
33
- const existing = db.prepare('SELECT id FROM sessions WHERE id = ?').get(session.id) as { id: string } | undefined;
34
- if (existing) {
35
- return { sessionId: session.id, messagesIndexed: 0, skipped: true };
36
- }
36
+ const existingSession = db.prepare('SELECT id FROM sessions WHERE id = ?').get(session.id) as { id: string } | undefined;
37
+ const before = db.prepare('SELECT COUNT(*) as count FROM messages WHERE session_id = ?').get(session.id) as { count: number };
37
38
 
38
- // Insert session
39
- db.prepare(`
40
- INSERT INTO sessions (id, project, cwd, started_at, ended_at, message_count)
39
+ const insertSession = db.prepare(`
40
+ INSERT OR IGNORE INTO sessions (id, project, cwd, started_at, ended_at, message_count)
41
41
  VALUES (?, ?, ?, ?, ?, ?)
42
- `).run(
43
- session.id,
44
- session.project,
45
- session.cwd,
46
- session.startedAt,
47
- session.endedAt,
48
- session.messages.length
49
- );
50
-
51
- // Insert messages in a transaction for performance
42
+ `);
43
+
52
44
  const insertMsg = db.prepare(`
53
- INSERT INTO messages (id, session_id, role, content, timestamp, tool_calls)
45
+ INSERT OR IGNORE INTO messages (id, session_id, role, content, timestamp, tool_calls)
54
46
  VALUES (?, ?, ?, ?, ?, ?)
55
47
  `);
56
48
 
57
- const writeMessages = (messages: ParsedSession['messages']) => {
58
- for (const msg of messages) {
49
+ const updateSession = db.prepare(`
50
+ UPDATE sessions
51
+ SET project = ?,
52
+ cwd = ?,
53
+ ended_at = COALESCE(?, ended_at),
54
+ message_count = (SELECT COUNT(*) FROM messages WHERE session_id = ?)
55
+ WHERE id = ?
56
+ `);
57
+
58
+ const writeSession = () => {
59
+ insertSession.run(
60
+ session.id,
61
+ session.project,
62
+ session.cwd,
63
+ session.startedAt,
64
+ session.endedAt,
65
+ session.messages.length
66
+ );
67
+
68
+ for (const msg of session.messages) {
59
69
  insertMsg.run(
60
70
  msg.id,
61
71
  session.id,
@@ -65,16 +75,136 @@ export function indexSession(dbManager: DatabaseManager, session: ParsedSession)
65
75
  msg.toolCalls ? JSON.stringify(msg.toolCalls) : null
66
76
  );
67
77
  }
78
+
79
+ updateSession.run(session.project, session.cwd, session.endedAt, session.id, session.id);
68
80
  };
69
81
 
70
82
  if (db.transaction) {
71
- const insertMany = db.transaction(writeMessages);
72
- insertMany(session.messages);
83
+ const tx = db.transaction(writeSession);
84
+ tx();
73
85
  } else {
74
- writeMessages(session.messages);
86
+ writeSession();
87
+ }
88
+
89
+ const after = db.prepare('SELECT COUNT(*) as count FROM messages WHERE session_id = ?').get(session.id) as { count: number };
90
+ const messagesIndexed = after.count - before.count;
91
+
92
+ return { sessionId: session.id, messagesIndexed, skipped: Boolean(existingSession) && messagesIndexed === 0 };
93
+ }
94
+
95
+ type SessionManagerSnapshot = {
96
+ getHeader: () => { id: string; timestamp: string; cwd: string } | null;
97
+ getEntries: () => unknown[];
98
+ getSessionFile?: () => string | undefined;
99
+ };
100
+
101
+ type SessionMessageEntryLike = {
102
+ type?: unknown;
103
+ id?: unknown;
104
+ timestamp?: unknown;
105
+ message?: {
106
+ role?: unknown;
107
+ content?: unknown;
108
+ };
109
+ };
110
+
111
+ function extractTextContent(content: unknown): string {
112
+ if (typeof content === 'string') return content;
113
+ if (!Array.isArray(content)) return '';
114
+
115
+ const parts: string[] = [];
116
+ for (const block of content) {
117
+ if (!block || typeof block !== 'object') continue;
118
+ const b = block as Record<string, unknown>;
119
+
120
+ switch (b.type) {
121
+ case 'text':
122
+ if (typeof b.text === 'string') parts.push(b.text);
123
+ break;
124
+ case 'tool_result':
125
+ if (typeof b.content === 'string') {
126
+ parts.push(b.content);
127
+ } else if (Array.isArray(b.content)) {
128
+ for (const item of b.content) {
129
+ if (item && typeof item === 'object' && (item as Record<string, unknown>).type === 'text') {
130
+ const text = (item as Record<string, unknown>).text;
131
+ if (typeof text === 'string') parts.push(text);
132
+ }
133
+ }
134
+ }
135
+ break;
136
+ }
137
+ }
138
+
139
+ return parts.join('\n').trim();
140
+ }
141
+
142
+ function extractToolCalls(content: unknown): string[] | undefined {
143
+ if (!Array.isArray(content)) return undefined;
144
+
145
+ const toolNames: string[] = [];
146
+ for (const block of content) {
147
+ if (!block || typeof block !== 'object') continue;
148
+ const b = block as Record<string, unknown>;
149
+ if ((b.type === 'toolCall' || b.type === 'tool_use') && typeof b.name === 'string') {
150
+ toolNames.push(b.name);
151
+ }
152
+ }
153
+ return toolNames.length > 0 ? toolNames : undefined;
154
+ }
155
+
156
+ function parseMessageEntry(entry: unknown): ParsedSession['messages'][number] | null {
157
+ if (!entry || typeof entry !== 'object') return null;
158
+ const e = entry as SessionMessageEntryLike;
159
+ if (e.type !== 'message' || typeof e.id !== 'string' || typeof e.timestamp !== 'string' || !e.message) return null;
160
+
161
+ const role = e.message.role;
162
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') return null;
163
+
164
+ const content = extractTextContent(e.message.content);
165
+ if (!content) return null;
166
+
167
+ return {
168
+ id: e.id,
169
+ role,
170
+ content,
171
+ timestamp: e.timestamp,
172
+ toolCalls: role === 'assistant' ? extractToolCalls(e.message.content) : undefined,
173
+ };
174
+ }
175
+
176
+ export function parseSessionManagerSnapshot(sessionManager: SessionManagerSnapshot): ParsedSession | null {
177
+ const header = sessionManager.getHeader();
178
+ if (!header?.id || !header.cwd || !header.timestamp) return null;
179
+
180
+ const messages = sessionManager.getEntries()
181
+ .map(parseMessageEntry)
182
+ .filter((msg): msg is ParsedSession['messages'][number] => msg !== null);
183
+
184
+ return {
185
+ id: header.id,
186
+ project: header.cwd.split('/').pop() ?? header.cwd,
187
+ cwd: header.cwd,
188
+ startedAt: header.timestamp,
189
+ endedAt: null,
190
+ messages,
191
+ };
192
+ }
193
+
194
+ export function indexCurrentSession(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null {
195
+ const session = parseSessionManagerSnapshot(sessionManager);
196
+ if (!session) return null;
197
+ return indexSession(dbManager, session);
198
+ }
199
+
200
+ export function indexLiveSession(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null {
201
+ const sessionFile = sessionManager.getSessionFile?.();
202
+ if (sessionFile && fs.existsSync(sessionFile)) {
203
+ const session = parseSessionFile(sessionFile);
204
+ if (session) return indexSession(dbManager, session);
75
205
  }
76
206
 
77
- return { sessionId: session.id, messagesIndexed: session.messages.length, skipped: false };
207
+ return indexCurrentSession(dbManager, sessionManager);
78
208
  }
79
209
 
80
210
  /**
@@ -124,6 +254,58 @@ export function indexAllSessions(
124
254
  return result;
125
255
  }
126
256
 
257
+ /**
258
+ * Cheaply count session JSONL files in the same scope indexAllSessions scans.
259
+ */
260
+ export function countSessionFiles(sessionsDir: string): number {
261
+ return getSessionFiles(sessionsDir).length;
262
+ }
263
+
264
+ function getLastBackfillTimestamp(dbManager: DatabaseManager): string | null {
265
+ const db = dbManager.getDb();
266
+ const row = db.prepare('SELECT value FROM extension_metadata WHERE key = ?').get(LAST_SESSION_BACKFILL_KEY) as { value: string } | undefined;
267
+ return row?.value ?? null;
268
+ }
269
+
270
+ function isRecentBackfillTimestamp(value: string | null, nowMs: number): boolean {
271
+ if (!value) return false;
272
+ const parsed = Date.parse(value);
273
+ if (!Number.isFinite(parsed)) return false;
274
+ return nowMs - parsed < SESSION_BACKFILL_INTERVAL_MS;
275
+ }
276
+
277
+ /**
278
+ * Determine whether a background session backfill should run.
279
+ *
280
+ * A backfill is needed when the number of JSONL session files differs from
281
+ * the indexed session count, or when no successful backfill has completed in
282
+ * the last 24 hours. The count check catches crashed/abnormal sessions; the
283
+ * timestamp check periodically repairs parse errors or manual DB edits.
284
+ */
285
+ export function needsBackfill(dbManager: DatabaseManager, sessionsDir: string, now = new Date()): boolean {
286
+ const db = dbManager.getDb();
287
+ const fileCount = countSessionFiles(sessionsDir);
288
+ const indexed = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number };
289
+
290
+ if (fileCount !== indexed.count) {
291
+ return true;
292
+ }
293
+
294
+ return !isRecentBackfillTimestamp(getLastBackfillTimestamp(dbManager), now.getTime());
295
+ }
296
+
297
+ /**
298
+ * Record a successful session backfill completion timestamp.
299
+ */
300
+ export function touchBackfillTimestamp(dbManager: DatabaseManager, timestamp = new Date()): void {
301
+ const db = dbManager.getDb();
302
+ db.prepare(`
303
+ INSERT INTO extension_metadata (key, value)
304
+ VALUES (?, ?)
305
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
306
+ `).run(LAST_SESSION_BACKFILL_KEY, timestamp.toISOString());
307
+ }
308
+
127
309
  /**
128
310
  * Get statistics about indexed sessions.
129
311
  */
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
 
3
4
  /**
4
5
  * Parsed session data from a JSONL file.
@@ -90,7 +91,7 @@ function extractToolCalls(content: unknown): string[] | undefined {
90
91
  for (const block of content) {
91
92
  if (!block || typeof block !== 'object') continue;
92
93
  const b = block as Record<string, unknown>;
93
- if (b.type === 'tool_use' && typeof b.name === 'string') {
94
+ if ((b.type === 'tool_use' || b.type === 'toolCall') && typeof b.name === 'string') {
94
95
  toolNames.push(b.name);
95
96
  }
96
97
  }
@@ -179,23 +180,29 @@ export function parseSessionFile(filePath: string): ParsedSession | null {
179
180
  */
180
181
  export function getSessionFiles(sessionsDir: string, projectDir?: string): string[] {
181
182
  if (projectDir) {
182
- const dir = `${sessionsDir}/${projectDir}`;
183
+ const dir = path.join(sessionsDir, projectDir);
183
184
  if (!fs.existsSync(dir)) return [];
184
185
  return fs.readdirSync(dir)
185
186
  .filter(f => f.endsWith('.jsonl'))
186
- .map(f => `${dir}/${f}`);
187
+ .map(f => path.join(dir, f));
187
188
  }
188
189
 
189
190
  // All projects
190
191
  if (!fs.existsSync(sessionsDir)) return [];
191
192
  const files: string[] = [];
192
- for (const dir of fs.readdirSync(sessionsDir)) {
193
- const dirPath = `${sessionsDir}/${dir}`;
194
- if (!fs.statSync(dirPath).isDirectory()) continue;
195
- for (const f of fs.readdirSync(dirPath)) {
196
- if (f.endsWith('.jsonl')) {
197
- files.push(`${dirPath}/${f}`);
193
+ for (const entry of fs.readdirSync(sessionsDir)) {
194
+ const entryPath = path.join(sessionsDir, entry);
195
+ const stat = fs.statSync(entryPath);
196
+ if (stat.isDirectory()) {
197
+ // Scan .jsonl files inside project subdirectories
198
+ for (const f of fs.readdirSync(entryPath)) {
199
+ if (f.endsWith('.jsonl')) {
200
+ files.push(path.join(entryPath, f));
201
+ }
198
202
  }
203
+ } else if (stat.isFile() && entry.endsWith('.jsonl')) {
204
+ // Also pick up root-level .jsonl files
205
+ files.push(entryPath);
199
206
  }
200
207
  }
201
208
  return files;