pi-hermes-memory 0.7.22 → 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.
@@ -74,6 +74,12 @@ export interface SqliteMemoryRemoveOptions {
74
74
  project?: string | null;
75
75
  }
76
76
 
77
+ export interface MarkdownMemoryReconcileResult {
78
+ inserted: number;
79
+ existing: number;
80
+ removed: number;
81
+ }
82
+
77
83
  export interface ParsedMarkdownMemoryEntry extends SqliteMemorySyncInput {}
78
84
 
79
85
  function today(): string {
@@ -179,13 +185,18 @@ function escapeLikePattern(text: string): string {
179
185
  return text.replace(/[\\%_]/g, '\\$&');
180
186
  }
181
187
 
182
- function parseMetadataComment(raw: string): { text: string; created: string; lastReferenced: string } {
183
- const match = raw.match(/^(.*?)\s*<!--\s*created=([^,]+),\s*last=([^>]+)\s*-->\s*$/);
188
+ function parseMetadataComment(raw: string): { text: string; created: string; lastReferenced: string; project: string | null } {
189
+ const match = raw.match(/^(.*?)\s*<!--\s*created=([^,]+),\s*last=([^,>]+)(?:,\s*project64=([A-Za-z0-9_-]+))?\s*-->\s*$/);
184
190
  if (match) {
191
+ let project: string | null = null;
192
+ if (match[4]) {
193
+ try { project = Buffer.from(match[4], 'base64url').toString('utf-8').trim() || null; } catch {}
194
+ }
185
195
  return {
186
196
  text: match[1].trim(),
187
197
  created: match[2].trim(),
188
198
  lastReferenced: match[3].trim(),
199
+ project,
189
200
  };
190
201
  }
191
202
 
@@ -194,6 +205,7 @@ function parseMetadataComment(raw: string): { text: string; created: string; las
194
205
  text: raw.trim(),
195
206
  created: fallback,
196
207
  lastReferenced: fallback,
208
+ project: null,
197
209
  };
198
210
  }
199
211
 
@@ -251,7 +263,6 @@ export function formatFailureMemoryContent(
251
263
  if (options.failureReason) parts.push(`Failed: ${options.failureReason}`);
252
264
  if (options.toolState) parts.push(`Tool state: ${options.toolState}`);
253
265
  if (options.correctedTo) parts.push(`Corrected to: ${options.correctedTo}`);
254
- if (options.project) parts.push(`Project: ${options.project}`);
255
266
  return parts.join(' — ');
256
267
  }
257
268
 
@@ -265,7 +276,8 @@ export function parseMarkdownMemoryEntry(
265
276
  target: 'memory' | 'user' | 'failure',
266
277
  project: string | null = null,
267
278
  ): ParsedMarkdownMemoryEntry {
268
- const { text, created, lastReferenced } = parseMetadataComment(rawEntry);
279
+ const metadata = parseMetadataComment(rawEntry);
280
+ const { text, created, lastReferenced } = metadata;
269
281
  const parsedProject = normalizeNullable(project);
270
282
 
271
283
  if (target !== 'failure') {
@@ -403,6 +415,111 @@ export function syncMemoryEntry(
403
415
  };
404
416
  }
405
417
 
418
+ /**
419
+ * Make one exact Markdown target/project scope authoritative in SQLite.
420
+ * Upserts and orphan deletion are committed together when transactions are
421
+ * supported by the active SQLite driver.
422
+ */
423
+ export function reconcileMarkdownMemoryScope(
424
+ dbManager: DatabaseManager,
425
+ rawEntries: string[],
426
+ target: 'memory' | 'user' | 'failure',
427
+ project: string | null = null,
428
+ ): MarkdownMemoryReconcileResult {
429
+ const db = dbManager.getDb();
430
+ const normalizedProject = normalizeNullable(project);
431
+
432
+ const reconcile = (): MarkdownMemoryReconcileResult => {
433
+ let inserted = 0;
434
+ let existing = 0;
435
+ const desiredIdentities = new Set<string>();
436
+
437
+ for (const rawEntry of rawEntries) {
438
+ const parsed = parseMarkdownMemoryEntry(rawEntry, target, normalizedProject);
439
+ desiredIdentities.add(JSON.stringify([
440
+ normalizeCategory(parsed.category),
441
+ parsed.content.trim(),
442
+ ]));
443
+ const result = syncMemoryEntry(dbManager, parsed);
444
+ if (result.action === 'inserted') inserted++;
445
+ else existing++;
446
+ }
447
+
448
+ const params: unknown[] = [];
449
+ const conditions = buildScopeConditions(params, target, normalizedProject);
450
+ const scopedRows = db.prepare(`
451
+ SELECT id, content, category
452
+ FROM memories
453
+ WHERE ${conditions.join(' AND ')}
454
+ ORDER BY id ASC
455
+ `).all(...params) as Array<{ id: number; content: string; category: MemoryCategory | null }>;
456
+ const retainedIdentities = new Set<string>();
457
+ const orphanIds: number[] = [];
458
+ for (const row of scopedRows) {
459
+ const identity = JSON.stringify([normalizeCategory(row.category), row.content.trim()]);
460
+ if (!desiredIdentities.has(identity) || retainedIdentities.has(identity)) {
461
+ orphanIds.push(row.id);
462
+ } else {
463
+ retainedIdentities.add(identity);
464
+ }
465
+ }
466
+
467
+ let removed = 0;
468
+ if (orphanIds.length > 0) {
469
+ const placeholders = orphanIds.map(() => '?').join(', ');
470
+ removed = db.prepare(`DELETE FROM memories WHERE id IN (${placeholders})`).run(...orphanIds).changes;
471
+ }
472
+
473
+ return { inserted, existing, removed };
474
+ };
475
+
476
+ const transactional = db.transaction?.(reconcile);
477
+ return transactional ? transactional() : reconcile();
478
+ }
479
+
480
+ function failureProject(rawEntry: string): string | null {
481
+ return parseMetadataComment(rawEntry).project;
482
+ }
483
+
484
+ export function reconcileMarkdownFailureScopes(
485
+ dbManager: DatabaseManager,
486
+ rawEntries: string[],
487
+ ): MarkdownMemoryReconcileResult {
488
+ const entriesByProject = new Map<string | null, string[]>();
489
+ for (const rawEntry of rawEntries) {
490
+ const project = failureProject(rawEntry);
491
+ const entries = entriesByProject.get(project) ?? [];
492
+ entries.push(rawEntry);
493
+ entriesByProject.set(project, entries);
494
+ }
495
+
496
+ const mirroredProjects = dbManager.getDb().prepare(`
497
+ SELECT DISTINCT project
498
+ FROM memories
499
+ WHERE target = 'failure'
500
+ `).all() as Array<{ project: string | null }>;
501
+ const projects = new Set<string | null>([
502
+ null,
503
+ ...entriesByProject.keys(),
504
+ ...mirroredProjects.map(({ project }) => normalizeNullable(project)),
505
+ ]);
506
+ const total: MarkdownMemoryReconcileResult = { inserted: 0, existing: 0, removed: 0 };
507
+
508
+ for (const project of projects) {
509
+ const result = reconcileMarkdownMemoryScope(
510
+ dbManager,
511
+ entriesByProject.get(project) ?? [],
512
+ 'failure',
513
+ project,
514
+ );
515
+ total.inserted += result.inserted;
516
+ total.existing += result.existing;
517
+ total.removed += result.removed;
518
+ }
519
+
520
+ return total;
521
+ }
522
+
406
523
  /**
407
524
  * Best-effort substring replacement for SQLite-backed memory sync.
408
525
  * Updates all matches in the scoped slice to recover from prior duplicate rows.
@@ -11,6 +11,8 @@ import { MemoryStore } from "../store/memory-store.js";
11
11
  import { DatabaseManager } from "../store/db.js";
12
12
  import {
13
13
  formatFailureMemoryContent,
14
+ reconcileMarkdownFailureScopes,
15
+ reconcileMarkdownMemoryScope,
14
16
  removeExactSyncedMemories,
15
17
  removeSyncedMemories,
16
18
  replaceSyncedMemories,
@@ -185,6 +187,31 @@ async function syncEvictionsFromSqlite(
185
187
  }
186
188
  }
187
189
 
190
+ async function reconcileStoreScope(
191
+ entries: string[],
192
+ rawTarget: "memory" | "user" | "project" | "failure",
193
+ dbManager: DatabaseManager | null,
194
+ projectName?: string | null,
195
+ ): Promise<string | null | undefined> {
196
+ if (!dbManager) return undefined;
197
+ try {
198
+ if (rawTarget === "failure") {
199
+ reconcileMarkdownFailureScopes(dbManager, entries);
200
+ return null;
201
+ }
202
+ const target = sqliteTargetFor(rawTarget);
203
+ reconcileMarkdownMemoryScope(
204
+ dbManager,
205
+ entries,
206
+ target,
207
+ sqliteProjectFor(rawTarget, projectName) ?? null,
208
+ );
209
+ return null;
210
+ } catch (err) {
211
+ return `Saved to Markdown, but SQLite search reconciliation failed: ${err instanceof Error ? err.message : String(err)}`;
212
+ }
213
+ }
214
+
188
215
  export function registerMemoryTool(
189
216
  pi: ExtensionAPI,
190
217
  store: MemoryStore,
@@ -192,6 +219,16 @@ export function registerMemoryTool(
192
219
  dbManager: DatabaseManager | null = null,
193
220
  projectName?: string | null,
194
221
  ): void {
222
+ const reconciledStores = new WeakSet<MemoryStore>();
223
+ if (typeof store.setMutationObserver === "function") {
224
+ store.setMutationObserver((target, entries) => reconcileStoreScope(entries, target, dbManager, projectName));
225
+ reconciledStores.add(store);
226
+ }
227
+ if (projectStore && typeof projectStore.setMutationObserver === "function") {
228
+ projectStore.setMutationObserver((_target, entries) => reconcileStoreScope(entries, "project", dbManager, projectName));
229
+ reconciledStores.add(projectStore);
230
+ }
231
+
195
232
  pi.registerTool({
196
233
  name: "memory",
197
234
  label: "Memory",
@@ -244,6 +281,7 @@ export function registerMemoryTool(
244
281
 
245
282
  let result: MemoryResult;
246
283
  let syncWarning: string | null = null;
284
+ const syncHandled = reconciledStores.has(store_);
247
285
  switch (action) {
248
286
  case "add":
249
287
  if (!content) {
@@ -267,12 +305,12 @@ export function registerMemoryTool(
267
305
  category: memoryCategory,
268
306
  failureReason: failure_reason,
269
307
  });
270
- if (result.success) {
308
+ if (result.success && !syncHandled) {
271
309
  syncWarning = await syncAddToSqlite(rawTarget, content, memoryCategory, failure_reason, dbManager, projectName);
272
310
  }
273
311
  } else {
274
- result = await store_.add(target, content);
275
- if (result.success) {
312
+ result = await store_.add(target, content, signal);
313
+ if (result.success && !syncHandled) {
276
314
  await syncEvictionsFromSqlite(rawTarget, result.evicted_entries, dbManager, projectName);
277
315
  syncWarning = await syncAddToSqlite(rawTarget, content, undefined, undefined, dbManager, projectName);
278
316
  }
@@ -309,9 +347,7 @@ export function registerMemoryTool(
309
347
  };
310
348
  }
311
349
  result = await store_.replace(target, old_text, content);
312
- if (result.success) {
313
- syncWarning = await syncReplaceToSqlite(rawTarget, old_text, content, dbManager, projectName);
314
- }
350
+ if (result.success && !syncHandled) syncWarning = await syncReplaceToSqlite(rawTarget, old_text, content, dbManager, projectName);
315
351
  break;
316
352
 
317
353
  case "remove":
@@ -330,9 +366,7 @@ export function registerMemoryTool(
330
366
  };
331
367
  }
332
368
  result = await store_.remove(target, old_text);
333
- if (result.success) {
334
- syncWarning = await syncRemoveFromSqlite(rawTarget, old_text, dbManager, projectName);
335
- }
369
+ if (result.success && !syncHandled) syncWarning = await syncRemoveFromSqlite(rawTarget, old_text, dbManager, projectName);
336
370
  break;
337
371
 
338
372
  default:
@@ -342,6 +376,11 @@ export function registerMemoryTool(
342
376
  };
343
377
  }
344
378
 
379
+ if (result.success && !syncHandled && typeof store_.getRawEntriesForSync === "function") {
380
+ const reconciliationWarning = await reconcileStoreScope(store_.getRawEntriesForSync(target), rawTarget, dbManager, projectName);
381
+ if (reconciliationWarning !== undefined) syncWarning = reconciliationWarning;
382
+ }
383
+
345
384
  if (syncWarning && result.success) {
346
385
  result = appendSyncWarning(result, syncWarning);
347
386
  }
@@ -14,6 +14,10 @@ interface SearchResult {
14
14
  count?: number;
15
15
  message?: string;
16
16
  output?: string;
17
+ outputChars?: number;
18
+ outputTruncated?: boolean;
19
+ snippetChars?: number;
20
+ truncatedCount?: number;
17
21
  ranges?: SessionAnchorRange[];
18
22
  }
19
23
 
@@ -22,6 +26,26 @@ interface SessionSearchToolOptions {
22
26
  }
23
27
 
24
28
  const DEFAULT_SESSIONS_DIR = path.join(AGENT_ROOT, 'sessions');
29
+ const DEFAULT_LEGACY_SNIPPET_CHARS = 1_200;
30
+ const MAX_LEGACY_SNIPPET_CHARS = 4_000;
31
+ const MAX_LEGACY_OUTPUT_CHARS = 50 * 1024;
32
+
33
+ function truncateLegacySnippet(text: string, maxChars: number): { text: string; truncated: boolean } {
34
+ if (text.length <= maxChars) return { text, truncated: false };
35
+ return {
36
+ text: `${text.slice(0, maxChars)}\n... (truncated, ${text.length} chars total — refine the query or increase snippetChars)`,
37
+ truncated: true,
38
+ };
39
+ }
40
+
41
+ function capLegacyOutput(text: string): { text: string; truncated: boolean } {
42
+ if (text.length <= MAX_LEGACY_OUTPUT_CHARS) return { text, truncated: false };
43
+ const suffix = `\n... (output truncated, ${text.length} chars total — refine the query or lower the result limit)`;
44
+ return {
45
+ text: `${text.slice(0, MAX_LEGACY_OUTPUT_CHARS - suffix.length)}${suffix}`,
46
+ truncated: true,
47
+ };
48
+ }
25
49
 
26
50
  export function registerSessionSearchTool(
27
51
  pi: ExtensionAPI,
@@ -127,7 +151,7 @@ Examples:
127
151
  - "Find the PR where we fixed the test hang"
128
152
  - "What approach did we take for the database migration?"
129
153
 
130
- Returns conversation snippets with session dates and project context.`,
154
+ Returns bounded conversation snippets with session dates and project context. Large messages are truncated with their original character count.`,
131
155
  promptSnippet: 'Search past conversations for relevant context',
132
156
  promptGuidelines: [
133
157
  'Use session_search when the user asks about previous discussions or past work.',
@@ -137,13 +161,27 @@ Returns conversation snippets with session dates and project context.`,
137
161
  query: Type.String({ description: 'Search query. Use natural language or specific terms.' }),
138
162
  project: Type.Optional(Type.String({ description: 'Filter by project name (optional).' })),
139
163
  role: Type.Optional(StringEnum(['user', 'assistant'] as const, { description: 'Filter by message role (optional).' })),
140
- limit: Type.Optional(Type.Number({ description: 'Maximum results to return (default: 10, max: 20).' })),
164
+ limit: Type.Optional(Type.Number({
165
+ description: 'Maximum results to return (default: 10, min: 1, max: 20).',
166
+ minimum: 1,
167
+ maximum: 20,
168
+ })),
169
+ snippetChars: Type.Optional(Type.Number({
170
+ description: `Maximum characters per result snippet (default: ${DEFAULT_LEGACY_SNIPPET_CHARS}, max: ${MAX_LEGACY_SNIPPET_CHARS}).`,
171
+ minimum: 100,
172
+ maximum: MAX_LEGACY_SNIPPET_CHARS,
173
+ })),
141
174
  }),
142
- execute: async (_id: string, args: { query: string; project?: string; role?: string; limit?: number }) => {
175
+ execute: async (_id: string, args: { query: string; project?: string; role?: string; limit?: number; snippetChars?: number }) => {
143
176
  const query = args.query;
144
177
  const project = args.project;
145
178
  const role = args.role;
146
- const limit = Math.min(args.limit || 10, 20);
179
+ const requestedLimit = Number.isFinite(args.limit) ? Math.floor(args.limit!) : 10;
180
+ const limit = Math.min(Math.max(requestedLimit, 1), 20);
181
+ const requestedSnippetChars = Number.isFinite(args.snippetChars)
182
+ ? Math.floor(args.snippetChars!)
183
+ : DEFAULT_LEGACY_SNIPPET_CHARS;
184
+ const snippetChars = Math.min(Math.max(requestedSnippetChars, 100), MAX_LEGACY_SNIPPET_CHARS);
147
185
 
148
186
  if (!query || query.trim().length === 0) {
149
187
  const result: SearchResult = { success: false, message: 'query is required' };
@@ -159,11 +197,19 @@ Returns conversation snippets with session dates and project context.`,
159
197
  const results = searchSessions(dbManager, query, { project, role, limit });
160
198
 
161
199
  if (results.length === 0) {
162
- const result: SearchResult = { success: true, count: 0, message: `No results found for "${query}". Try a different search term or broader query.` };
163
- return { content: [{ type: 'text' as const, text: result.message! }], details: result };
200
+ const output = capLegacyOutput('No results found. Try a different search term or broader query.');
201
+ const result: SearchResult = {
202
+ success: true,
203
+ count: 0,
204
+ message: output.text,
205
+ outputChars: output.text.length,
206
+ outputTruncated: output.truncated,
207
+ };
208
+ return { content: [{ type: 'text' as const, text: output.text }], details: result };
164
209
  }
165
210
 
166
- let output = `Found ${results.length} results for "${query}":\n\n`;
211
+ const blocks: string[] = [`Found ${results.length} results for "${query}":`];
212
+ let truncatedCount = 0;
167
213
 
168
214
  for (const r of results) {
169
215
  const date = new Date(r.timestamp).toLocaleDateString('en-US', {
@@ -172,13 +218,25 @@ Returns conversation snippets with session dates and project context.`,
172
218
  day: 'numeric',
173
219
  });
174
220
 
175
- output += `---\n`;
176
- output += `📅 ${date} | 📁 ${r.project} | ${r.role === 'user' ? '👤 User' : '🤖 Assistant'}\n`;
177
- output += `${r.snippet}\n\n`;
221
+ const snippet = truncateLegacySnippet(r.snippet, snippetChars);
222
+ if (snippet.truncated) truncatedCount += 1;
223
+ blocks.push([
224
+ '---',
225
+ `📅 ${date} | 📁 ${r.project} | ${r.role === 'user' ? '👤 User' : '🤖 Assistant'}`,
226
+ snippet.text,
227
+ ].join('\n'));
178
228
  }
179
229
 
180
- const finalResult: SearchResult = { success: true, count: results.length, output: output.trim() };
181
- return { content: [{ type: 'text' as const, text: output.trim() }], details: finalResult };
230
+ const output = capLegacyOutput(blocks.join('\n\n').trim());
231
+ const finalResult: SearchResult = {
232
+ success: true,
233
+ count: results.length,
234
+ truncatedCount,
235
+ snippetChars,
236
+ outputChars: output.text.length,
237
+ outputTruncated: output.truncated,
238
+ };
239
+ return { content: [{ type: 'text' as const, text: output.text }], details: finalResult };
182
240
  },
183
241
  });
184
242
  }
package/src/types.ts CHANGED
@@ -10,6 +10,8 @@ export type SessionSearchVariant = "legacy" | "anchors";
10
10
 
11
11
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
12
12
 
13
+ export type ReviewTransport = "direct" | "subprocess";
14
+
13
15
  export interface SessionSearchConfig {
14
16
  /** Session search implementation variant. Default: legacy */
15
17
  variant: SessionSearchVariant;
@@ -34,6 +36,8 @@ export interface MemoryConfig {
34
36
  reviewRecentMessages?: number;
35
37
  /** Enable background learning loop. Default: true */
36
38
  reviewEnabled: boolean;
39
+ /** How background review invokes the LLM. Default: direct */
40
+ reviewTransport?: ReviewTransport;
37
41
  /** Flush memories before compaction. Default: true */
38
42
  flushOnCompact: boolean;
39
43
  /** Flush memories on session shutdown. Default: true */
@@ -52,6 +56,8 @@ export interface MemoryConfig {
52
56
  llmModelOverride?: string;
53
57
  /** Override thinking level used for child pi -p subprocess LLM calls. Default: unset */
54
58
  llmThinkingOverride?: ThinkingLevel;
59
+ /** Extra extension entry paths required by child Pi processes, such as provider auth adapters. */
60
+ childExtensionPaths?: string[];
55
61
  /** Strategy when memory is full. Default: auto-consolidate */
56
62
  memoryOverflowStrategy?: MemoryOverflowStrategy;
57
63
  /** Legacy alias for memoryOverflowStrategy. Default: true */