pi-hermes-memory 0.6.7 → 0.6.9

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,6 +1,28 @@
1
1
  import { DatabaseManager } from './db.js';
2
2
  import type { MemoryCategory } from '../types.js';
3
3
 
4
+ const MEMORY_SELECT_COLUMNS = `
5
+ id,
6
+ project,
7
+ target,
8
+ category,
9
+ content,
10
+ failure_reason,
11
+ tool_state,
12
+ corrected_to,
13
+ created,
14
+ last_referenced
15
+ `;
16
+
17
+ const FAILURE_CATEGORY_SET = new Set<MemoryCategory>([
18
+ 'failure',
19
+ 'correction',
20
+ 'insight',
21
+ 'preference',
22
+ 'convention',
23
+ 'tool-quirk',
24
+ ]);
25
+
4
26
  /**
5
27
  * A memory entry stored in SQLite.
6
28
  */
@@ -17,6 +39,157 @@ export interface SqliteMemoryEntry {
17
39
  lastReferenced: string;
18
40
  }
19
41
 
42
+ export interface SqliteMemorySyncInput {
43
+ content: string;
44
+ target: 'memory' | 'user' | 'failure';
45
+ project?: string | null;
46
+ category?: MemoryCategory | null;
47
+ failureReason?: string | null;
48
+ toolState?: string | null;
49
+ correctedTo?: string | null;
50
+ created?: string | null;
51
+ lastReferenced?: string | null;
52
+ }
53
+
54
+ export interface SqliteMemorySyncResult {
55
+ action: 'inserted' | 'existing';
56
+ entry: SqliteMemoryEntry;
57
+ }
58
+
59
+ export interface SqliteMemoryUpdateResult {
60
+ matched: number;
61
+ updated: number;
62
+ entries: SqliteMemoryEntry[];
63
+ }
64
+
65
+ export interface SqliteMemoryRemoveResult {
66
+ matched: number;
67
+ removed: number;
68
+ }
69
+
70
+ export interface ParsedMarkdownMemoryEntry extends SqliteMemorySyncInput {}
71
+
72
+ function today(): string {
73
+ return new Date().toISOString().split('T')[0];
74
+ }
75
+
76
+ function normalizeNullable(value?: string | null): string | null {
77
+ if (value == null) return null;
78
+ const trimmed = value.trim();
79
+ return trimmed.length > 0 ? trimmed : null;
80
+ }
81
+
82
+ function normalizeCategory(value?: MemoryCategory | null): MemoryCategory | null {
83
+ return value ?? null;
84
+ }
85
+
86
+ function mapRow(row: {
87
+ id: number;
88
+ project: string | null;
89
+ target: string;
90
+ category: string | null;
91
+ content: string;
92
+ failure_reason: string | null;
93
+ tool_state: string | null;
94
+ corrected_to: string | null;
95
+ created: string;
96
+ last_referenced: string;
97
+ }): SqliteMemoryEntry {
98
+ return {
99
+ id: row.id,
100
+ project: row.project,
101
+ target: row.target as 'memory' | 'user' | 'failure',
102
+ category: row.category as MemoryCategory | null,
103
+ content: row.content,
104
+ failureReason: row.failure_reason,
105
+ toolState: row.tool_state,
106
+ correctedTo: row.corrected_to,
107
+ created: row.created,
108
+ lastReferenced: row.last_referenced,
109
+ };
110
+ }
111
+
112
+ function buildScopeConditions(params: unknown[], target?: string, project?: string | null, category?: MemoryCategory | null): string[] {
113
+ const conditions: string[] = [];
114
+
115
+ if (target) {
116
+ conditions.push('target = ?');
117
+ params.push(target);
118
+ }
119
+
120
+ if (project !== undefined) {
121
+ if (project === null) {
122
+ conditions.push('project IS NULL');
123
+ } else {
124
+ conditions.push('project = ?');
125
+ params.push(project);
126
+ }
127
+ }
128
+
129
+ if (category !== undefined) {
130
+ if (category === null) {
131
+ conditions.push('category IS NULL');
132
+ } else {
133
+ conditions.push('category = ?');
134
+ params.push(category);
135
+ }
136
+ }
137
+
138
+ return conditions;
139
+ }
140
+
141
+ function getMemoryById(dbManager: DatabaseManager, id: number): SqliteMemoryEntry | null {
142
+ const db = dbManager.getDb();
143
+ const row = db.prepare(`
144
+ SELECT ${MEMORY_SELECT_COLUMNS}
145
+ FROM memories
146
+ WHERE id = ?
147
+ `).get(id) as {
148
+ id: number;
149
+ project: string | null;
150
+ target: string;
151
+ category: string | null;
152
+ content: string;
153
+ failure_reason: string | null;
154
+ tool_state: string | null;
155
+ corrected_to: string | null;
156
+ created: string;
157
+ last_referenced: string;
158
+ } | undefined;
159
+
160
+ return row ? mapRow(row) : null;
161
+ }
162
+
163
+ function minDate(a: string, b: string): string {
164
+ return a <= b ? a : b;
165
+ }
166
+
167
+ function maxDate(a: string, b: string): string {
168
+ return a >= b ? a : b;
169
+ }
170
+
171
+ function escapeLikePattern(text: string): string {
172
+ return text.replace(/[\\%_]/g, '\\$&');
173
+ }
174
+
175
+ function parseMetadataComment(raw: string): { text: string; created: string; lastReferenced: string } {
176
+ const match = raw.match(/^(.*?)\s*<!--\s*created=([^,]+),\s*last=([^>]+)\s*-->\s*$/);
177
+ if (match) {
178
+ return {
179
+ text: match[1].trim(),
180
+ created: match[2].trim(),
181
+ lastReferenced: match[3].trim(),
182
+ };
183
+ }
184
+
185
+ const fallback = today();
186
+ return {
187
+ text: raw.trim(),
188
+ created: fallback,
189
+ lastReferenced: fallback,
190
+ };
191
+ }
192
+
20
193
  /**
21
194
  * Add a memory entry to the SQLite store.
22
195
  */
@@ -28,15 +201,16 @@ export function addMemory(
28
201
  category: MemoryCategory | null = null,
29
202
  failureReason: string | null = null,
30
203
  toolState: string | null = null,
31
- correctedTo: string | null = null
204
+ correctedTo: string | null = null,
205
+ created = today(),
206
+ lastReferenced = created
32
207
  ): SqliteMemoryEntry {
33
208
  const db = dbManager.getDb();
34
- const today = new Date().toISOString().split('T')[0];
35
209
 
36
210
  const result = db.prepare(`
37
211
  INSERT INTO memories (project, target, category, content, failure_reason, tool_state, corrected_to, created, last_referenced)
38
212
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
39
- `).run(project, target, category, content, failureReason, toolState, correctedTo, today, today);
213
+ `).run(project, target, category, content, failureReason, toolState, correctedTo, created, lastReferenced);
40
214
 
41
215
  return {
42
216
  id: Number(result.lastInsertRowid),
@@ -47,8 +221,294 @@ export function addMemory(
47
221
  failureReason,
48
222
  toolState,
49
223
  correctedTo,
50
- created: today,
51
- lastReferenced: today,
224
+ created,
225
+ lastReferenced,
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Build the visible failure-memory text stored in Markdown.
231
+ */
232
+ export function formatFailureMemoryContent(
233
+ content: string,
234
+ options: {
235
+ category: MemoryCategory;
236
+ failureReason?: string | null;
237
+ toolState?: string | null;
238
+ correctedTo?: string | null;
239
+ project?: string | null;
240
+ }
241
+ ): string {
242
+ const categoryTag = `[${options.category}]`;
243
+ const parts = [`${categoryTag} ${content.trim()}`.trim()];
244
+ if (options.failureReason) parts.push(`Failed: ${options.failureReason}`);
245
+ if (options.toolState) parts.push(`Tool state: ${options.toolState}`);
246
+ if (options.correctedTo) parts.push(`Corrected to: ${options.correctedTo}`);
247
+ if (options.project) parts.push(`Project: ${options.project}`);
248
+ return parts.join(' — ');
249
+ }
250
+
251
+ /**
252
+ * Parse a Markdown memory entry into SQLite sync fields.
253
+ * Best-effort only: if failure metadata cannot be fully reconstructed,
254
+ * content is still imported and available for search.
255
+ */
256
+ export function parseMarkdownMemoryEntry(
257
+ rawEntry: string,
258
+ target: 'memory' | 'user' | 'failure',
259
+ project: string | null = null,
260
+ ): ParsedMarkdownMemoryEntry {
261
+ const { text, created, lastReferenced } = parseMetadataComment(rawEntry);
262
+ const parsedProject = normalizeNullable(project);
263
+
264
+ if (target !== 'failure') {
265
+ return {
266
+ content: text,
267
+ target,
268
+ project: parsedProject,
269
+ created,
270
+ lastReferenced,
271
+ };
272
+ }
273
+
274
+ let category: MemoryCategory | null = null;
275
+ let failureReason: string | null = null;
276
+ let toolState: string | null = null;
277
+ let correctedTo: string | null = null;
278
+
279
+ const categoryMatch = text.match(/^\[([^\]]+)\]\s+/);
280
+ if (categoryMatch && FAILURE_CATEGORY_SET.has(categoryMatch[1] as MemoryCategory)) {
281
+ category = categoryMatch[1] as MemoryCategory;
282
+ }
283
+
284
+ const segments = text.split(' — ');
285
+ for (const segment of segments.slice(1)) {
286
+ if (segment.startsWith('Failed: ') && !failureReason) {
287
+ failureReason = segment.slice('Failed: '.length).trim() || null;
288
+ continue;
289
+ }
290
+ if (segment.startsWith('Tool state: ') && !toolState) {
291
+ toolState = segment.slice('Tool state: '.length).trim() || null;
292
+ continue;
293
+ }
294
+ if (segment.startsWith('Corrected to: ') && !correctedTo) {
295
+ correctedTo = segment.slice('Corrected to: '.length).trim() || null;
296
+ }
297
+ }
298
+
299
+ return {
300
+ content: text,
301
+ target: 'failure',
302
+ project: parsedProject,
303
+ category,
304
+ failureReason,
305
+ toolState,
306
+ correctedTo,
307
+ created,
308
+ lastReferenced,
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Idempotently sync a Markdown-backed memory entry into SQLite.
314
+ * Duplicate identity is exact: project + target + category + content.
315
+ */
316
+ export function syncMemoryEntry(
317
+ dbManager: DatabaseManager,
318
+ input: SqliteMemorySyncInput,
319
+ ): SqliteMemorySyncResult {
320
+ const db = dbManager.getDb();
321
+ const content = input.content.trim();
322
+ const project = normalizeNullable(input.project);
323
+ const category = normalizeCategory(input.category);
324
+ const failureReason = normalizeNullable(input.failureReason);
325
+ const toolState = normalizeNullable(input.toolState);
326
+ const correctedTo = normalizeNullable(input.correctedTo);
327
+ const created = input.created?.trim() || today();
328
+ const lastReferenced = input.lastReferenced?.trim() || created;
329
+
330
+ const params: unknown[] = [];
331
+ const conditions = buildScopeConditions(params, input.target, project, category);
332
+ conditions.push('content = ?');
333
+ params.push(content);
334
+
335
+ const existing = db.prepare(`
336
+ SELECT ${MEMORY_SELECT_COLUMNS}
337
+ FROM memories
338
+ WHERE ${conditions.join(' AND ')}
339
+ ORDER BY id ASC
340
+ LIMIT 1
341
+ `).get(...params) as {
342
+ id: number;
343
+ project: string | null;
344
+ target: string;
345
+ category: string | null;
346
+ content: string;
347
+ failure_reason: string | null;
348
+ tool_state: string | null;
349
+ corrected_to: string | null;
350
+ created: string;
351
+ last_referenced: string;
352
+ } | undefined;
353
+
354
+ if (!existing) {
355
+ return {
356
+ action: 'inserted',
357
+ entry: addMemory(
358
+ dbManager,
359
+ content,
360
+ input.target,
361
+ project,
362
+ category,
363
+ failureReason,
364
+ toolState,
365
+ correctedTo,
366
+ created,
367
+ lastReferenced,
368
+ ),
369
+ };
370
+ }
371
+
372
+ const updatedCreated = minDate(existing.created, created);
373
+ const updatedLastReferenced = maxDate(existing.last_referenced, lastReferenced);
374
+ const updatedCategory = (existing.category as MemoryCategory | null) ?? category;
375
+ const updatedFailureReason = existing.failure_reason ?? failureReason;
376
+ const updatedToolState = existing.tool_state ?? toolState;
377
+ const updatedCorrectedTo = existing.corrected_to ?? correctedTo;
378
+
379
+ db.prepare(`
380
+ UPDATE memories
381
+ SET category = ?, failure_reason = ?, tool_state = ?, corrected_to = ?, created = ?, last_referenced = ?
382
+ WHERE id = ?
383
+ `).run(
384
+ updatedCategory,
385
+ updatedFailureReason,
386
+ updatedToolState,
387
+ updatedCorrectedTo,
388
+ updatedCreated,
389
+ updatedLastReferenced,
390
+ existing.id,
391
+ );
392
+
393
+ return {
394
+ action: 'existing',
395
+ entry: getMemoryById(dbManager, existing.id)!,
396
+ };
397
+ }
398
+
399
+ /**
400
+ * Best-effort substring replacement for SQLite-backed memory sync.
401
+ * Updates all matches in the scoped slice to recover from prior duplicate rows.
402
+ */
403
+ export function replaceSyncedMemories(
404
+ dbManager: DatabaseManager,
405
+ oldText: string,
406
+ updates: {
407
+ content: string;
408
+ target: 'memory' | 'user' | 'failure';
409
+ project?: string | null;
410
+ category?: MemoryCategory | null;
411
+ failureReason?: string | null;
412
+ toolState?: string | null;
413
+ correctedTo?: string | null;
414
+ lastReferenced?: string | null;
415
+ },
416
+ ): SqliteMemoryUpdateResult {
417
+ const db = dbManager.getDb();
418
+ const params: unknown[] = [];
419
+ const conditions = buildScopeConditions(params, updates.target, updates.project ?? undefined);
420
+ conditions.push(`content LIKE ? ESCAPE '\\'`);
421
+ params.push(`%${escapeLikePattern(oldText)}%`);
422
+
423
+ const rows = db.prepare(`
424
+ SELECT ${MEMORY_SELECT_COLUMNS}
425
+ FROM memories
426
+ WHERE ${conditions.join(' AND ')}
427
+ ORDER BY id ASC
428
+ `).all(...params) as Array<{
429
+ id: number;
430
+ project: string | null;
431
+ target: string;
432
+ category: string | null;
433
+ content: string;
434
+ failure_reason: string | null;
435
+ tool_state: string | null;
436
+ corrected_to: string | null;
437
+ created: string;
438
+ last_referenced: string;
439
+ }>;
440
+
441
+ if (rows.length === 0) {
442
+ return { matched: 0, updated: 0, entries: [] };
443
+ }
444
+
445
+ const nextLastReferenced = updates.lastReferenced?.trim() || today();
446
+
447
+ for (const row of rows) {
448
+ db.prepare(`
449
+ UPDATE memories
450
+ SET content = ?,
451
+ category = ?,
452
+ failure_reason = ?,
453
+ tool_state = ?,
454
+ corrected_to = ?,
455
+ last_referenced = ?
456
+ WHERE id = ?
457
+ `).run(
458
+ updates.content.trim(),
459
+ updates.category === undefined ? row.category : updates.category,
460
+ updates.failureReason === undefined ? row.failure_reason : normalizeNullable(updates.failureReason),
461
+ updates.toolState === undefined ? row.tool_state : normalizeNullable(updates.toolState),
462
+ updates.correctedTo === undefined ? row.corrected_to : normalizeNullable(updates.correctedTo),
463
+ nextLastReferenced,
464
+ row.id,
465
+ );
466
+ }
467
+
468
+ return {
469
+ matched: rows.length,
470
+ updated: rows.length,
471
+ entries: rows
472
+ .map((row) => getMemoryById(dbManager, row.id))
473
+ .filter((entry): entry is SqliteMemoryEntry => entry !== null),
474
+ };
475
+ }
476
+
477
+ /**
478
+ * Best-effort substring removal for SQLite-backed memory sync.
479
+ * Deletes all matches in the scoped slice to recover from prior duplicate rows.
480
+ */
481
+ export function removeSyncedMemories(
482
+ dbManager: DatabaseManager,
483
+ oldText: string,
484
+ options: {
485
+ target: 'memory' | 'user' | 'failure';
486
+ project?: string | null;
487
+ },
488
+ ): SqliteMemoryRemoveResult {
489
+ const db = dbManager.getDb();
490
+ const params: unknown[] = [];
491
+ const conditions = buildScopeConditions(params, options.target, options.project ?? undefined);
492
+ conditions.push(`content LIKE ? ESCAPE '\\'`);
493
+ params.push(`%${escapeLikePattern(oldText)}%`);
494
+
495
+ const matchingIds = db.prepare(`
496
+ SELECT id
497
+ FROM memories
498
+ WHERE ${conditions.join(' AND ')}
499
+ `).all(...params) as Array<{ id: number }>;
500
+
501
+ if (matchingIds.length === 0) {
502
+ return { matched: 0, removed: 0 };
503
+ }
504
+
505
+ const deleteParams = matchingIds.map((row) => row.id);
506
+ const placeholders = deleteParams.map(() => '?').join(', ');
507
+ const result = db.prepare(`DELETE FROM memories WHERE id IN (${placeholders})`).run(...deleteParams);
508
+
509
+ return {
510
+ matched: matchingIds.length,
511
+ removed: result.changes,
52
512
  };
53
513
  }
54
514
 
@@ -105,7 +565,7 @@ export function searchMemories(
105
565
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
106
566
 
107
567
  const sql = `
108
- SELECT id, project, target, category, content, failure_reason, tool_state, corrected_to, created, last_referenced
568
+ SELECT ${MEMORY_SELECT_COLUMNS}
109
569
  FROM memories m
110
570
  ${whereClause}
111
571
  ORDER BY m.last_referenced DESC
@@ -126,18 +586,7 @@ export function searchMemories(
126
586
  last_referenced: string;
127
587
  }>;
128
588
 
129
- return rows.map(row => ({
130
- id: row.id,
131
- project: row.project,
132
- target: row.target as 'memory' | 'user' | 'failure',
133
- category: row.category as MemoryCategory | null,
134
- content: row.content,
135
- failureReason: row.failure_reason,
136
- toolState: row.tool_state,
137
- correctedTo: row.corrected_to,
138
- created: row.created,
139
- lastReferenced: row.last_referenced,
140
- }));
589
+ return rows.map(mapRow);
141
590
  }
142
591
 
143
592
  /**
@@ -175,7 +624,7 @@ export function getMemories(
175
624
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
176
625
 
177
626
  const rows = db.prepare(`
178
- SELECT id, project, target, category, content, failure_reason, tool_state, corrected_to, created, last_referenced
627
+ SELECT ${MEMORY_SELECT_COLUMNS}
179
628
  FROM memories
180
629
  ${whereClause}
181
630
  ORDER BY last_referenced DESC
@@ -192,18 +641,7 @@ export function getMemories(
192
641
  last_referenced: string;
193
642
  }>;
194
643
 
195
- return rows.map(row => ({
196
- id: row.id,
197
- project: row.project,
198
- target: row.target as 'memory' | 'user' | 'failure',
199
- category: row.category as MemoryCategory | null,
200
- content: row.content,
201
- failureReason: row.failure_reason,
202
- toolState: row.tool_state,
203
- correctedTo: row.corrected_to,
204
- created: row.created,
205
- lastReferenced: row.last_referenced,
206
- }));
644
+ return rows.map(mapRow);
207
645
  }
208
646
 
209
647
  /**
@@ -241,7 +679,7 @@ export function getRecentFailures(
241
679
  }
242
680
 
243
681
  const rows = db.prepare(`
244
- SELECT id, project, target, category, content, failure_reason, tool_state, corrected_to, created, last_referenced
682
+ SELECT ${MEMORY_SELECT_COLUMNS}
245
683
  FROM memories
246
684
  WHERE ${conditions.join(' AND ')}
247
685
  ORDER BY created DESC
@@ -259,18 +697,7 @@ export function getRecentFailures(
259
697
  last_referenced: string;
260
698
  }>;
261
699
 
262
- return rows.map(row => ({
263
- id: row.id,
264
- project: row.project,
265
- target: row.target as 'memory' | 'user' | 'failure',
266
- category: row.category as MemoryCategory | null,
267
- content: row.content,
268
- failureReason: row.failure_reason,
269
- toolState: row.tool_state,
270
- correctedTo: row.corrected_to,
271
- created: row.created,
272
- lastReferenced: row.last_referenced,
273
- }));
700
+ return rows.map(mapRow);
274
701
  }
275
702
 
276
703
  /**
@@ -278,8 +705,7 @@ export function getRecentFailures(
278
705
  */
279
706
  export function touchMemory(dbManager: DatabaseManager, id: number): void {
280
707
  const db = dbManager.getDb();
281
- const today = new Date().toISOString().split('T')[0];
282
- db.prepare('UPDATE memories SET last_referenced = ? WHERE id = ?').run(today, id);
708
+ db.prepare('UPDATE memories SET last_referenced = ? WHERE id = ?').run(today(), id);
283
709
  }
284
710
 
285
711
  /**