hmem-mcp 2.2.0 → 2.4.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.
@@ -90,14 +90,26 @@ const MIGRATIONS = [
90
90
  "ALTER TABLE memory_nodes ADD COLUMN title TEXT",
91
91
  "ALTER TABLE memories ADD COLUMN irrelevant INTEGER DEFAULT 0",
92
92
  "ALTER TABLE memory_nodes ADD COLUMN favorite INTEGER DEFAULT 0",
93
+ "ALTER TABLE memory_nodes ADD COLUMN irrelevant INTEGER DEFAULT 0",
94
+ // Hashtag support: join table for cross-cutting tags on entries and nodes
95
+ "CREATE TABLE IF NOT EXISTS memory_tags (entry_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (entry_id, tag))",
96
+ "CREATE INDEX IF NOT EXISTS idx_tags_tag ON memory_tags(tag)",
97
+ // Pinned: super-favorites that show full L2 content in bulk reads
98
+ "ALTER TABLE memories ADD COLUMN pinned INTEGER DEFAULT 0",
93
99
  ];
94
100
  // ---- HmemStore class ----
95
101
  export class HmemStore {
96
102
  db;
97
103
  dbPath;
104
+ getDbPath() { return this.dbPath; }
98
105
  cfg;
99
106
  /** True if integrity_check found errors on open (read-only mode recommended). */
100
107
  corrupted;
108
+ /**
109
+ * Char-limit tolerance: configured limits are the "recommended" target shown in skills/errors.
110
+ * Actual hard reject is at limit * CHAR_LIMIT_TOLERANCE (25% buffer to avoid wasted retries).
111
+ */
112
+ static CHAR_LIMIT_TOLERANCE = 1.25;
101
113
  constructor(hmemPath, config) {
102
114
  this.dbPath = hmemPath;
103
115
  this.cfg = config ?? { ...DEFAULT_CONFIG };
@@ -107,6 +119,7 @@ export class HmemStore {
107
119
  }
108
120
  this.db = new Database(hmemPath);
109
121
  this.db.pragma("journal_mode = WAL");
122
+ this.db.pragma("busy_timeout = 5000");
110
123
  // Integrity check — detect corruption before any writes
111
124
  this.corrupted = false;
112
125
  try {
@@ -147,7 +160,7 @@ export class HmemStore {
147
160
  * Each indented line → its own memory_nodes row with compound ID
148
161
  * Multiple lines at the same indent depth → siblings (new capability)
149
162
  */
150
- write(prefix, content, links, minRole = "worker", favorite) {
163
+ write(prefix, content, links, minRole = "worker", favorite, tags, pinned) {
151
164
  this.guardCorrupted();
152
165
  prefix = prefix.toUpperCase();
153
166
  if (!this.cfg.prefixes[prefix]) {
@@ -163,31 +176,37 @@ export class HmemStore {
163
176
  throw new Error("Content must have at least one line (Level 1).");
164
177
  }
165
178
  const l1Limit = this.cfg.maxCharsPerLevel[0];
166
- if (level1.length > l1Limit) {
179
+ const t = HmemStore.CHAR_LIMIT_TOLERANCE;
180
+ if (level1.length > l1Limit * t) {
167
181
  throw new Error(`Level 1 exceeds ${l1Limit} character limit (${level1.length} chars). Keep L1 compact.`);
168
182
  }
169
183
  for (const node of nodes) {
170
184
  // depth 2-5 → index 1-4
171
185
  const nodeLimit = this.cfg.maxCharsPerLevel[Math.min(node.depth - 1, this.cfg.maxCharsPerLevel.length - 1)];
172
- if (node.content.length > nodeLimit) {
186
+ if (node.content.length > nodeLimit * t) {
173
187
  throw new Error(`L${node.depth} content exceeds ${nodeLimit} character limit ` +
174
188
  `(${node.content.length} chars). Split into multiple write_memory calls or use file references.`);
175
189
  }
176
190
  }
177
191
  const insertRoot = this.db.prepare(`
178
- INSERT INTO memories (id, prefix, seq, created_at, title, level_1, level_2, level_3, level_4, level_5, links, min_role, favorite)
179
- VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, ?)
192
+ INSERT INTO memories (id, prefix, seq, created_at, title, level_1, level_2, level_3, level_4, level_5, links, min_role, favorite, pinned)
193
+ VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, ?, ?)
180
194
  `);
181
195
  const insertNode = this.db.prepare(`
182
196
  INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, title, content, created_at)
183
197
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
184
198
  `);
199
+ // Validate tags before transaction
200
+ const validatedTags = tags && tags.length > 0 ? this.validateTags(tags) : [];
185
201
  // Run in a transaction
186
202
  this.db.transaction(() => {
187
- insertRoot.run(rootId, prefix, seq, timestamp, title, level1, links ? JSON.stringify(links) : null, minRole, favorite ? 1 : 0);
203
+ insertRoot.run(rootId, prefix, seq, timestamp, title, level1, links ? JSON.stringify(links) : null, minRole, favorite ? 1 : 0, pinned ? 1 : 0);
188
204
  for (const node of nodes) {
189
205
  insertNode.run(node.id, node.parent_id, rootId, node.depth, node.seq, node.title, node.content, timestamp);
190
206
  }
207
+ if (validatedTags.length > 0) {
208
+ this.setTags(rootId, validatedTags);
209
+ }
191
210
  })();
192
211
  return { id: rootId, timestamp };
193
212
  }
@@ -218,6 +237,15 @@ export class HmemStore {
218
237
  const entry = this.nodeToEntry(this.rowToNode(row), children);
219
238
  if (opts.expand)
220
239
  entry.expanded = true;
240
+ // Load tags for this node + its children
241
+ const allNodeIds = [opts.id, ...children.map(c => c.id)];
242
+ const tagMap = this.fetchTagsBulk(allNodeIds);
243
+ if (tagMap.has(opts.id))
244
+ entry.tags = tagMap.get(opts.id);
245
+ for (const child of children) {
246
+ if (tagMap.has(child.id))
247
+ child.tags = tagMap.get(child.id);
248
+ }
221
249
  return [entry];
222
250
  }
223
251
  else {
@@ -273,6 +301,26 @@ export class HmemStore {
273
301
  entry.expanded = true;
274
302
  // Auto-resolve links
275
303
  this.resolveEntryLinks(entry, opts);
304
+ // Load tags for entry + children, find related entries
305
+ const allIds = [opts.id, ...this.collectNodeIds(children)];
306
+ const tagMap = this.fetchTagsBulk(allIds);
307
+ if (tagMap.has(opts.id))
308
+ entry.tags = tagMap.get(opts.id);
309
+ for (const child of children) {
310
+ if (tagMap.has(child.id))
311
+ child.tags = tagMap.get(child.id);
312
+ if (child.children) {
313
+ for (const gc of child.children) {
314
+ if (tagMap.has(gc.id))
315
+ gc.tags = tagMap.get(gc.id);
316
+ }
317
+ }
318
+ }
319
+ // Related entries: find other entries sharing 2+ tags
320
+ const entryTags = entry.tags ?? [];
321
+ if (entryTags.length >= 2) {
322
+ entry.relatedEntries = this.findRelated(opts.id, entryTags, 5);
323
+ }
276
324
  return [entry];
277
325
  }
278
326
  }
@@ -313,6 +361,12 @@ export class HmemStore {
313
361
  const nodeLimitClause = limit !== undefined ? ` LIMIT ${limit}` : "";
314
362
  const nodeRows = this.db.prepare(`SELECT DISTINCT root_id FROM memory_nodes WHERE content LIKE ?${nodeLimitClause}`).all(pattern);
315
363
  const nodeRootIds = new Set(nodeRows.map(r => r.root_id));
364
+ // Also search tags (e.g. search="#hmem" matches tag "#hmem")
365
+ const tagRows = this.db.prepare("SELECT entry_id FROM memory_tags WHERE tag LIKE ?").all(pattern);
366
+ for (const row of tagRows) {
367
+ const eid = row.entry_id;
368
+ nodeRootIds.add(eid.includes(".") ? eid.split(".")[0] : eid);
369
+ }
316
370
  const memLimitClause = limit !== undefined ? ` LIMIT ${limit}` : "";
317
371
  const memRows = this.db.prepare(`SELECT * FROM memories ${where} ORDER BY created_at DESC${memLimitClause}`).all(pattern, ...roleFilter.params);
318
372
  // Merge: include any roots found in node search too
@@ -358,6 +412,15 @@ export class HmemStore {
358
412
  conditions.push("created_at <= ?");
359
413
  params.push(end.toISOString());
360
414
  }
415
+ // Tag-based filtering: restrict to entries that have the specified tag
416
+ if (opts.tag) {
417
+ const tagRootIds = this.getRootIdsByTag(opts.tag.toLowerCase());
418
+ if (tagRootIds.size === 0)
419
+ return [];
420
+ const placeholders = [...tagRootIds].map(() => "?").join(", ");
421
+ conditions.push(`id IN (${placeholders})`);
422
+ params.push(...tagRootIds);
423
+ }
361
424
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
362
425
  // Sort by effective_date: the most recent of root created_at OR latest child node created_at.
363
426
  // This ensures entries with recently appended L2 nodes surface alongside genuinely new entries.
@@ -379,6 +442,31 @@ export class HmemStore {
379
442
  }
380
443
  return this.readBulkV2(rows, opts);
381
444
  }
445
+ /**
446
+ * Calculate V2 selection slot counts based on the number of relevant entries.
447
+ * Uses percentage-based scaling with min/max caps when configured,
448
+ * falls back to fixed topNewestCount/topAccessCount otherwise.
449
+ */
450
+ calcV2Slots(relevantCount, isEssentials = false, fraction = 1.0) {
451
+ const v2 = this.cfg.bulkReadV2;
452
+ let newest, access;
453
+ if (v2.newestPercent !== undefined) {
454
+ const effNewest = v2.newestPercent * fraction;
455
+ const effAccess = (v2.accessPercent ?? 10) * fraction;
456
+ newest = Math.min(v2.newestMax ?? 15, Math.max(v2.newestMin ?? 5, Math.ceil(relevantCount * (effNewest / 100))));
457
+ access = Math.min(v2.accessMax ?? 8, Math.max(v2.accessMin ?? 3, Math.ceil(relevantCount * (effAccess / 100))));
458
+ }
459
+ else {
460
+ newest = Math.max(1, Math.round(v2.topNewestCount * fraction));
461
+ access = Math.max(1, Math.round(v2.topAccessCount * fraction));
462
+ }
463
+ if (isEssentials) {
464
+ const total = newest + access;
465
+ newest = Math.max(1, Math.floor(newest * 0.4));
466
+ access = total - newest;
467
+ }
468
+ return { newestCount: newest, accessCount: access };
469
+ }
382
470
  /**
383
471
  * V2 bulk-read algorithm: per-prefix expansion, smart obsolete filtering,
384
472
  * expanded entries with all L2 children + links.
@@ -400,78 +488,110 @@ export class HmemStore {
400
488
  else
401
489
  byPrefix.set(r.prefix, [r]);
402
490
  }
403
- // Session cache: filter out already-seen entries completely
404
- const suppressed = opts.suppressedIds ?? new Set();
405
- const hasCacheActive = suppressed.size > 0;
491
+ // === Curation mode: show ALL entries, bypass V2 + session cache, depth 3 children ===
492
+ if (opts.showAll) {
493
+ const visibleObsolete = opts.showObsolete ? obsoleteRows : [];
494
+ const allVisible = [...nonObsoleteRows, ...visibleObsolete];
495
+ const visibleIds = new Set(allVisible.map(r => r.id));
496
+ const entries = allVisible.map(r => {
497
+ // Fetch children to depth 3 (L2 + L3), no V2 selection, filter irrelevant
498
+ const allChildren = this.fetchChildrenDeep(r.id, 2, 4)
499
+ .filter(c => !c.irrelevant);
500
+ // Resolve links
501
+ let linkedEntries;
502
+ const links = r.links ? JSON.parse(r.links) : [];
503
+ if (links.length > 0) {
504
+ linkedEntries = links.flatMap(linkId => {
505
+ if (visibleIds.has(linkId))
506
+ return [];
507
+ try {
508
+ return this.read({ id: linkId, resolveLinks: false, linkDepth: 0, followObsolete: false });
509
+ }
510
+ catch {
511
+ return [];
512
+ }
513
+ }).filter(e => !e.obsolete && !e.irrelevant);
514
+ }
515
+ const entry = this.rowToEntry(r, allChildren);
516
+ entry.expanded = true;
517
+ if (r.favorite === 1)
518
+ entry.promoted = "favorite";
519
+ if (linkedEntries && linkedEntries.length > 0)
520
+ entry.linkedEntries = linkedEntries;
521
+ return entry;
522
+ });
523
+ this.assignBulkTags(entries);
524
+ return entries;
525
+ }
526
+ // === Normal mode: V2 selection + session cache ===
527
+ // Session cache: two phases — hidden (< 5 min, excluded) and cached (5-30 min, title-only)
528
+ const cached = opts.cachedIds ?? new Set();
529
+ const hidden = opts.hiddenIds ?? new Set();
530
+ const fraction = opts.slotFraction ?? 1.0;
406
531
  // Step 3: Build expansion set from non-obsolete rows
407
532
  const expandedIds = new Set();
408
- // Mode-based ratios: essentials shifts weight from newest to most-accessed
409
533
  const isEssentials = opts.mode === "essentials";
410
- const totalSlots = v2.topNewestCount + v2.topAccessCount; // 8 by default
411
- const baseNewest = isEssentials ? Math.max(1, Math.floor(v2.topNewestCount * 0.4)) : v2.topNewestCount;
412
- const baseAccess = isEssentials ? totalSlots - baseNewest : v2.topAccessCount;
413
- // Fibonacci-limited counts (when cache is active, use separate Fibonacci values)
414
- const newestCount = (hasCacheActive && opts.maxNewNewest !== undefined)
415
- ? opts.maxNewNewest : baseNewest;
416
- const accessCount = (hasCacheActive && opts.maxNewAccess !== undefined)
417
- ? opts.maxNewAccess : baseAccess;
418
- // Per prefix: top N newest (sliding window) + top M most-accessed (backfill)
534
+ // Per prefix: top N newest + top M most-accessed slot counts scale with prefix size
419
535
  for (const [, prefixRows] of byPrefix) {
420
- // Newest: sliding window only from unseen entries
421
- const unseenRows = hasCacheActive
422
- ? prefixRows.filter(r => !suppressed.has(r.id))
423
- : prefixRows;
424
- for (const r of unseenRows.slice(0, newestCount)) {
536
+ const { newestCount, accessCount } = this.calcV2Slots(prefixRows.length, isEssentials, fraction);
537
+ // Newest: skip cached AND hidden entries, fill from fresh entries only
538
+ const uncachedRows = prefixRows.filter(r => !cached.has(r.id) && !hidden.has(r.id));
539
+ for (const r of uncachedRows.slice(0, newestCount)) {
425
540
  expandedIds.add(r.id);
426
541
  }
427
- // Most-accessed: from unseen entries, excluding those already picked as newest.
542
+ // Most-accessed: from uncached entries, excluding those already picked as newest.
428
543
  // Minimum threshold: access_count >= 2 — a single access can be noise.
429
- const mostAccessed = [...unseenRows]
544
+ const mostAccessed = [...uncachedRows]
430
545
  .filter(r => r.access_count >= 2 && !expandedIds.has(r.id))
431
546
  .sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
432
547
  .slice(0, accessCount);
433
548
  for (const r of mostAccessed)
434
549
  expandedIds.add(r.id);
435
550
  }
436
- // Global: all unseen favorites
551
+ // Global: all uncached+unhidden favorites
437
552
  for (const r of nonObsoleteRows) {
438
- if (r.favorite === 1 && !suppressed.has(r.id)) {
553
+ if ((r.favorite === 1 || r.pinned === 1) && !cached.has(r.id) && !hidden.has(r.id)) {
439
554
  expandedIds.add(r.id);
440
555
  }
441
556
  }
442
557
  // topAccess reference for promoted marker (time-weighted, min 2 accesses)
558
+ const { accessCount: globalAccessSlots } = this.calcV2Slots(nonObsoleteRows.length);
443
559
  const topAccess = [...nonObsoleteRows]
444
560
  .filter(r => r.access_count >= 2)
445
561
  .sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
446
- .slice(0, v2.topAccessCount);
562
+ .slice(0, globalAccessSlots);
447
563
  // Obsolete entries: only shown when explicitly requested
448
564
  const visibleObsolete = opts.showObsolete ? obsoleteRows : [];
449
- // Step 4: Show expanded entries + visible obsolete (cached entries completely hidden)
565
+ // Step 4: Build visible rows (hidden entries completely excluded)
566
+ // - Expanded entries: full content with children
567
+ // - Cached entries: title-only (no expansion, no children)
450
568
  const expandedNonObsolete = nonObsoleteRows.filter(r => expandedIds.has(r.id));
451
- const visibleRows = [...expandedNonObsolete, ...visibleObsolete];
569
+ const cachedVisible = nonObsoleteRows.filter(r => cached.has(r.id) && !expandedIds.has(r.id) && !hidden.has(r.id));
570
+ const visibleRows = [...expandedNonObsolete, ...cachedVisible, ...visibleObsolete];
452
571
  const visibleIds = new Set(visibleRows.map(r => r.id));
453
572
  // titles_only: V2 selection applies, but skip link resolution
454
573
  if (opts.titlesOnly) {
455
574
  // Bulk-fetch L2 child counts (one query for all visible entries)
456
575
  const allIds = visibleRows.map(r => r.id);
457
576
  const childCounts = this.bulkChildCount(allIds);
458
- return visibleRows.map(r => {
577
+ const entries = visibleRows.map(r => {
459
578
  const isExpanded = expandedIds.has(r.id);
460
579
  const totalChildren = childCounts.get(r.id) ?? 0;
461
580
  let children;
462
581
  let hiddenCount;
463
582
  if (isExpanded && totalChildren > 0) {
464
- // Fetch L2 children with V2 selection (top newest + accessed), no links
465
- const allChildren = this.fetchChildren(r.id);
466
- if (allChildren.length > v2.topNewestCount) {
583
+ // Fetch L2 children with V2 selection (percentage-based), no links
584
+ const allChildren = this.fetchChildren(r.id).filter(c => !c.irrelevant);
585
+ const childSlots = this.calcV2Slots(allChildren.length);
586
+ if (allChildren.length > childSlots.newestCount) {
467
587
  const newestSet = new Set([...allChildren]
468
588
  .sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))
469
- .slice(0, v2.topNewestCount)
589
+ .slice(0, childSlots.newestCount)
470
590
  .map(c => c.id));
471
591
  const accessSet = new Set([...allChildren]
472
592
  .filter(c => c.access_count >= 2)
473
593
  .sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
474
- .slice(0, v2.topAccessCount)
594
+ .slice(0, childSlots.accessCount)
475
595
  .map(c => c.id));
476
596
  const selectedIds = new Set([...newestSet, ...accessSet]);
477
597
  children = allChildren.filter(c => selectedIds.has(c.id));
@@ -495,8 +615,10 @@ export class HmemStore {
495
615
  entry.hiddenChildrenCount = hiddenCount;
496
616
  return entry;
497
617
  });
618
+ this.assignBulkTags(entries);
619
+ return entries;
498
620
  }
499
- return visibleRows.map(r => {
621
+ const entries = visibleRows.map(r => {
500
622
  const isExpanded = expandedIds.has(r.id);
501
623
  let promoted;
502
624
  if (r.favorite === 1)
@@ -509,17 +631,18 @@ export class HmemStore {
509
631
  let hiddenObsoleteLinks = 0;
510
632
  let hiddenIrrelevantLinks = 0;
511
633
  if (isExpanded) {
512
- // Fetch all L2 children, then apply V2 selection (same params as L1)
513
- const allChildren = this.fetchChildren(r.id);
514
- if (allChildren.length > v2.topNewestCount) {
634
+ // Fetch all L2 children, then apply V2 selection (percentage-based)
635
+ const allChildren = this.fetchChildren(r.id).filter(c => !c.irrelevant);
636
+ const childSlots = this.calcV2Slots(allChildren.length);
637
+ if (allChildren.length > childSlots.newestCount) {
515
638
  const newestSet = new Set([...allChildren]
516
639
  .sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))
517
- .slice(0, v2.topNewestCount)
640
+ .slice(0, childSlots.newestCount)
518
641
  .map(c => c.id));
519
642
  const accessSet = new Set([...allChildren]
520
643
  .filter(c => c.access_count > 0)
521
644
  .sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
522
- .slice(0, v2.topAccessCount)
645
+ .slice(0, childSlots.accessCount)
523
646
  .map(c => c.id));
524
647
  const selectedIds = new Set([...newestSet, ...accessSet]);
525
648
  children = allChildren.filter(c => selectedIds.has(c.id));
@@ -537,7 +660,7 @@ export class HmemStore {
537
660
  if (visibleIds.has(linkId))
538
661
  return []; // already shown in bulk read
539
662
  try {
540
- return this.read({ id: linkId, resolveLinks: false, linkDepth: 0 });
663
+ return this.read({ id: linkId, resolveLinks: false, linkDepth: 0, followObsolete: false });
541
664
  }
542
665
  catch {
543
666
  return [];
@@ -565,6 +688,8 @@ export class HmemStore {
565
688
  entry.hiddenIrrelevantLinks = hiddenIrrelevantLinks;
566
689
  return entry;
567
690
  });
691
+ this.assignBulkTags(entries);
692
+ return entries;
568
693
  }
569
694
  /**
570
695
  * Get all Level 1 entries for injection at agent startup.
@@ -627,6 +752,317 @@ export class HmemStore {
627
752
  }
628
753
  return md;
629
754
  }
755
+ /**
756
+ * Export memory to a new .hmem SQLite file.
757
+ * Creates a standalone copy that can be opened with HmemStore or hmem.py.
758
+ */
759
+ exportPublicToHmem(outputPath) {
760
+ if (fs.existsSync(outputPath))
761
+ fs.unlinkSync(outputPath);
762
+ if (fs.existsSync(outputPath + "-wal"))
763
+ fs.unlinkSync(outputPath + "-wal");
764
+ if (fs.existsSync(outputPath + "-shm"))
765
+ fs.unlinkSync(outputPath + "-shm");
766
+ const exportDb = new Database(outputPath);
767
+ exportDb.pragma("journal_mode = WAL");
768
+ exportDb.exec(SCHEMA);
769
+ for (const sql of MIGRATIONS) {
770
+ try {
771
+ exportDb.exec(sql);
772
+ }
773
+ catch { }
774
+ }
775
+ // Determine export-compatible columns (source may have extra columns)
776
+ const memCols = exportDb.pragma("table_info(memories)").map((c) => c.name);
777
+ const nodeCols = exportDb.pragma("table_info(memory_nodes)").map((c) => c.name);
778
+ // Copy all entries (only columns the export schema knows)
779
+ const rows = this.db.prepare(`SELECT ${memCols.join(", ")} FROM memories WHERE seq > 0 ORDER BY prefix, seq`).all();
780
+ if (rows.length > 0) {
781
+ const placeholders = memCols.map(() => "?").join(", ");
782
+ const insertMem = exportDb.prepare(`INSERT INTO memories (${memCols.join(", ")}) VALUES (${placeholders})`);
783
+ const txn = exportDb.transaction((entries) => {
784
+ for (const r of entries)
785
+ insertMem.run(...memCols.map(c => r[c]));
786
+ });
787
+ txn(rows);
788
+ }
789
+ // Copy all nodes
790
+ const allNodes = this.db.prepare(`SELECT ${nodeCols.join(", ")} FROM memory_nodes ORDER BY root_id, depth, seq`).all();
791
+ if (allNodes.length > 0) {
792
+ const placeholders = nodeCols.map(() => "?").join(", ");
793
+ const insertNode = exportDb.prepare(`INSERT INTO memory_nodes (${nodeCols.join(", ")}) VALUES (${placeholders})`);
794
+ const txn = exportDb.transaction((nodes) => {
795
+ for (const n of nodes)
796
+ insertNode.run(...nodeCols.map(c => n[c]));
797
+ });
798
+ txn(allNodes);
799
+ }
800
+ // Copy all tags
801
+ const allTags = this.db.prepare("SELECT * FROM memory_tags").all();
802
+ if (allTags.length > 0) {
803
+ const insertTag = exportDb.prepare("INSERT INTO memory_tags (entry_id, tag) VALUES (?, ?)");
804
+ const txn = exportDb.transaction((tags) => {
805
+ for (const t of tags)
806
+ insertTag.run(t.entry_id, t.tag);
807
+ });
808
+ txn(allTags);
809
+ }
810
+ exportDb.pragma("wal_checkpoint(TRUNCATE)");
811
+ exportDb.close();
812
+ return { entries: rows.length, nodes: allNodes.length, tags: allTags.length };
813
+ }
814
+ /**
815
+ * Import entries from another .hmem file with L1 deduplication and ID remapping.
816
+ */
817
+ importFromHmem(sourcePath, dryRun = false) {
818
+ if (!fs.existsSync(sourcePath)) {
819
+ throw new Error(`Source file not found: ${sourcePath}`);
820
+ }
821
+ const sourceDb = new Database(sourcePath, { readonly: true });
822
+ try {
823
+ return this._doImport(sourceDb, dryRun);
824
+ }
825
+ finally {
826
+ sourceDb.close();
827
+ }
828
+ }
829
+ _doImport(sourceDb, dryRun) {
830
+ // ---- Phase 1: Analyse ----
831
+ const srcEntries = sourceDb.prepare("SELECT * FROM memories WHERE seq > 0 ORDER BY prefix, seq").all();
832
+ const srcNodes = sourceDb.prepare("SELECT * FROM memory_nodes ORDER BY root_id, depth, seq").all();
833
+ let srcTags = [];
834
+ try {
835
+ srcTags = sourceDb.prepare("SELECT * FROM memory_tags").all();
836
+ }
837
+ catch { /* table may not exist in older exports */ }
838
+ const srcNodesByRoot = new Map();
839
+ for (const n of srcNodes) {
840
+ const arr = srcNodesByRoot.get(n.root_id);
841
+ if (arr)
842
+ arr.push(n);
843
+ else
844
+ srcNodesByRoot.set(n.root_id, [n]);
845
+ }
846
+ const srcTagsByEntry = new Map();
847
+ for (const t of srcTags) {
848
+ const arr = srcTagsByEntry.get(t.entry_id);
849
+ if (arr)
850
+ arr.push(t.tag);
851
+ else
852
+ srcTagsByEntry.set(t.entry_id, [t.tag]);
853
+ }
854
+ const actions = [];
855
+ let conflicts = 0;
856
+ for (const src of srcEntries) {
857
+ const existing = this.db.prepare("SELECT id FROM memories WHERE prefix = ? AND level_1 = ? AND seq > 0").get(src.prefix, src.level_1);
858
+ if (existing) {
859
+ actions.push({ type: "duplicate", srcEntry: src, targetId: existing.id });
860
+ }
861
+ else {
862
+ actions.push({ type: "new", srcEntry: src });
863
+ const conflict = this.db.prepare("SELECT id FROM memories WHERE id = ?").get(src.id);
864
+ if (conflict)
865
+ conflicts++;
866
+ }
867
+ }
868
+ const needsRemap = conflicts > 0;
869
+ let totalNodesToInsert = 0;
870
+ let totalNodesToSkip = 0;
871
+ for (const action of actions) {
872
+ if (action.type === "duplicate") {
873
+ const srcChildren = (srcNodesByRoot.get(action.srcEntry.id) ?? [])
874
+ .filter((n) => n.depth === 2 && n.parent_id === action.srcEntry.id);
875
+ const targetChildren = this.db.prepare("SELECT content FROM memory_nodes WHERE parent_id = ? AND depth = 2").all(action.targetId);
876
+ const targetContents = new Set(targetChildren.map((c) => c.content));
877
+ for (const sc of srcChildren) {
878
+ const descendants = (srcNodesByRoot.get(action.srcEntry.id) ?? [])
879
+ .filter((n) => n.id.startsWith(sc.id + ".") || n.id === sc.id);
880
+ if (targetContents.has(sc.content)) {
881
+ totalNodesToSkip += descendants.length;
882
+ }
883
+ else {
884
+ totalNodesToInsert += descendants.length;
885
+ }
886
+ }
887
+ }
888
+ else {
889
+ totalNodesToInsert += (srcNodesByRoot.get(action.srcEntry.id) ?? []).length;
890
+ }
891
+ }
892
+ const newCount = actions.filter(a => a.type === "new").length;
893
+ const dupeCount = actions.filter(a => a.type === "duplicate").length;
894
+ if (dryRun) {
895
+ return {
896
+ inserted: newCount, merged: dupeCount,
897
+ nodesInserted: totalNodesToInsert, nodesSkipped: totalNodesToSkip,
898
+ tagsImported: srcTags.length, remapped: needsRemap, conflicts,
899
+ };
900
+ }
901
+ // ---- Phase 2: ID Remapping ----
902
+ const idMap = new Map();
903
+ if (needsRemap) {
904
+ const usedSeqs = new Map();
905
+ for (const action of actions) {
906
+ if (action.type === "new") {
907
+ const prefix = action.srcEntry.prefix;
908
+ const baseSeq = this.nextSeq(prefix);
909
+ const offset = usedSeqs.get(prefix) ?? 0;
910
+ const seq = baseSeq + offset;
911
+ usedSeqs.set(prefix, offset + 1);
912
+ idMap.set(action.srcEntry.id, `${prefix}${String(seq).padStart(4, "0")}`);
913
+ }
914
+ }
915
+ }
916
+ for (const action of actions) {
917
+ if (action.type === "duplicate") {
918
+ idMap.set(action.srcEntry.id, action.targetId);
919
+ }
920
+ }
921
+ const remapId = (id) => {
922
+ if (!id)
923
+ return id;
924
+ const rootId = id.split(".")[0];
925
+ const newRootId = idMap.get(rootId);
926
+ if (!newRootId)
927
+ return id;
928
+ return newRootId + id.substring(rootId.length);
929
+ };
930
+ const remapLinks = (linksJson) => {
931
+ if (!linksJson)
932
+ return linksJson;
933
+ try {
934
+ const links = JSON.parse(linksJson);
935
+ return JSON.stringify(links.map(remapId));
936
+ }
937
+ catch {
938
+ return linksJson;
939
+ }
940
+ };
941
+ const remapContent = (content) => {
942
+ if (!content)
943
+ return content;
944
+ return content.replace(/\[✓([A-Z]\d{4}(?:\.\d+)*)\]/g, (match, id) => {
945
+ const newId = remapId(id);
946
+ return newId !== id ? `[✓${newId}]` : match;
947
+ });
948
+ };
949
+ // ---- Phase 3: Insert/Merge ----
950
+ const result = {
951
+ inserted: 0, merged: 0, nodesInserted: 0, nodesSkipped: 0,
952
+ tagsImported: 0, remapped: needsRemap, conflicts,
953
+ };
954
+ const memCols = this.db.pragma("table_info(memories)").map((c) => c.name);
955
+ const nodeCols = this.db.pragma("table_info(memory_nodes)").map((c) => c.name);
956
+ const srcMemCols = (() => { try {
957
+ return sourceDb.pragma("table_info(memories)").map((c) => c.name);
958
+ }
959
+ catch {
960
+ return [];
961
+ } })();
962
+ const srcNodeCols = (() => { try {
963
+ return sourceDb.pragma("table_info(memory_nodes)").map((c) => c.name);
964
+ }
965
+ catch {
966
+ return [];
967
+ } })();
968
+ const commonMemCols = memCols.filter(c => srcMemCols.includes(c));
969
+ const commonNodeCols = nodeCols.filter(c => srcNodeCols.includes(c));
970
+ this.db.transaction(() => {
971
+ for (const action of actions) {
972
+ if (action.type !== "new")
973
+ continue;
974
+ const src = action.srcEntry;
975
+ const newId = idMap.get(src.id) ?? src.id;
976
+ const values = {};
977
+ for (const col of commonMemCols)
978
+ values[col] = src[col];
979
+ values.id = newId;
980
+ if (needsRemap) {
981
+ values.links = remapLinks(src.links);
982
+ values.level_1 = remapContent(src.level_1);
983
+ }
984
+ this.db.prepare(`INSERT INTO memories (${commonMemCols.join(", ")}) VALUES (${commonMemCols.map(() => "?").join(", ")})`).run(...commonMemCols.map(c => values[c]));
985
+ const entryNodes = srcNodesByRoot.get(src.id) ?? [];
986
+ for (const node of entryNodes) {
987
+ const nv = {};
988
+ for (const col of commonNodeCols)
989
+ nv[col] = node[col];
990
+ nv.id = remapId(node.id);
991
+ nv.parent_id = remapId(node.parent_id);
992
+ nv.root_id = newId;
993
+ if (needsRemap) {
994
+ nv.links = remapLinks(node.links);
995
+ nv.content = remapContent(node.content);
996
+ }
997
+ this.db.prepare(`INSERT INTO memory_nodes (${commonNodeCols.join(", ")}) VALUES (${commonNodeCols.map(() => "?").join(", ")})`).run(...commonNodeCols.map(c => nv[c]));
998
+ result.nodesInserted++;
999
+ }
1000
+ const entryTags = srcTagsByEntry.get(src.id) ?? [];
1001
+ for (const tag of entryTags) {
1002
+ this.db.prepare("INSERT OR IGNORE INTO memory_tags (entry_id, tag) VALUES (?, ?)").run(newId, tag);
1003
+ result.tagsImported++;
1004
+ }
1005
+ for (const node of entryNodes) {
1006
+ for (const tag of (srcTagsByEntry.get(node.id) ?? [])) {
1007
+ this.db.prepare("INSERT OR IGNORE INTO memory_tags (entry_id, tag) VALUES (?, ?)").run(remapId(node.id), tag);
1008
+ result.tagsImported++;
1009
+ }
1010
+ }
1011
+ result.inserted++;
1012
+ }
1013
+ for (const action of actions) {
1014
+ if (action.type !== "duplicate")
1015
+ continue;
1016
+ const src = action.srcEntry;
1017
+ const targetId = action.targetId;
1018
+ const targetChildren = this.db.prepare("SELECT content FROM memory_nodes WHERE parent_id = ? AND depth = 2").all(targetId);
1019
+ const targetContents = new Set(targetChildren.map((c) => c.content));
1020
+ const srcAllNodes = srcNodesByRoot.get(src.id) ?? [];
1021
+ const srcL2 = srcAllNodes.filter((n) => n.depth === 2 && n.parent_id === src.id);
1022
+ const maxSeqRow = this.db.prepare("SELECT MAX(seq) as maxSeq FROM memory_nodes WHERE parent_id = ?").get(targetId);
1023
+ let nextChildSeq = (maxSeqRow?.maxSeq ?? 0) + 1;
1024
+ for (const l2 of srcL2) {
1025
+ if (targetContents.has(l2.content)) {
1026
+ result.nodesSkipped += srcAllNodes.filter((n) => n.id === l2.id || n.id.startsWith(l2.id + ".")).length;
1027
+ continue;
1028
+ }
1029
+ const descendants = srcAllNodes.filter((n) => n.id === l2.id || n.id.startsWith(l2.id + "."));
1030
+ const l2NewId = `${targetId}.${nextChildSeq}`;
1031
+ nextChildSeq++;
1032
+ for (const desc of descendants) {
1033
+ const nv = {};
1034
+ for (const col of commonNodeCols)
1035
+ nv[col] = desc[col];
1036
+ const oldPrefix = l2.id;
1037
+ const newPrefix = l2NewId;
1038
+ nv.id = desc.id === l2.id ? l2NewId : newPrefix + desc.id.substring(oldPrefix.length);
1039
+ nv.parent_id = desc.parent_id === src.id ? targetId
1040
+ : desc.parent_id === l2.id ? l2NewId
1041
+ : newPrefix + desc.parent_id.substring(oldPrefix.length);
1042
+ nv.root_id = targetId;
1043
+ nv.content = remapContent(desc.content);
1044
+ nv.links = remapLinks(desc.links);
1045
+ if (desc.id === l2.id)
1046
+ nv.seq = nextChildSeq - 1;
1047
+ if (!nv.title)
1048
+ nv.title = (nv.content || "").substring(0, this.cfg.maxTitleChars || 50);
1049
+ this.db.prepare(`INSERT INTO memory_nodes (${commonNodeCols.join(", ")}) VALUES (${commonNodeCols.map(() => "?").join(", ")})`).run(...commonNodeCols.map(c => nv[c]));
1050
+ result.nodesInserted++;
1051
+ for (const tag of (srcTagsByEntry.get(desc.id) ?? [])) {
1052
+ this.db.prepare("INSERT OR IGNORE INTO memory_tags (entry_id, tag) VALUES (?, ?)").run(nv.id, tag);
1053
+ result.tagsImported++;
1054
+ }
1055
+ }
1056
+ }
1057
+ for (const tag of (srcTagsByEntry.get(src.id) ?? [])) {
1058
+ this.db.prepare("INSERT OR IGNORE INTO memory_tags (entry_id, tag) VALUES (?, ?)").run(targetId, tag);
1059
+ result.tagsImported++;
1060
+ }
1061
+ result.merged++;
1062
+ }
1063
+ })();
1064
+ return result;
1065
+ }
630
1066
  /**
631
1067
  * Get statistics about the memory store.
632
1068
  */
@@ -636,7 +1072,10 @@ export class HmemStore {
636
1072
  const byPrefix = {};
637
1073
  for (const r of rows)
638
1074
  byPrefix[r.prefix] = r.c;
639
- return { total, byPrefix };
1075
+ // Total characters across all entries + nodes (for token estimation)
1076
+ const memChars = this.db.prepare("SELECT COALESCE(SUM(LENGTH(level_1)),0) as c FROM memories WHERE seq > 0").get().c;
1077
+ const nodeChars = this.db.prepare("SELECT COALESCE(SUM(LENGTH(content)),0) as c FROM memory_nodes").get().c;
1078
+ return { total, byPrefix, totalChars: memChars + nodeChars };
640
1079
  }
641
1080
  /**
642
1081
  * Update specific fields of an existing root entry (curator use only).
@@ -669,6 +1108,8 @@ export class HmemStore {
669
1108
  */
670
1109
  delete(id) {
671
1110
  this.guardCorrupted();
1111
+ // Delete tags for root + all child nodes
1112
+ this.db.prepare("DELETE FROM memory_tags WHERE entry_id = ? OR entry_id LIKE ?").run(id, `${id}.%`);
672
1113
  // Delete nodes first (no CASCADE in older SQLite)
673
1114
  this.db.prepare("DELETE FROM memory_nodes WHERE root_id = ?").run(id);
674
1115
  const result = this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
@@ -680,7 +1121,7 @@ export class HmemStore {
680
1121
  * For sub-nodes: updates node content only.
681
1122
  * Does NOT modify children — use appendChildren to extend the tree.
682
1123
  */
683
- updateNode(id, newContent, links, obsolete, favorite, curatorBypass, irrelevant) {
1124
+ updateNode(id, newContent, links, obsolete, favorite, curatorBypass, irrelevant, tags, pinned) {
684
1125
  this.guardCorrupted();
685
1126
  const trimmed = newContent.trim();
686
1127
  if (id.includes(".")) {
@@ -689,7 +1130,7 @@ export class HmemStore {
689
1130
  if (!nodeRow)
690
1131
  return false;
691
1132
  const nodeLimit = this.cfg.maxCharsPerLevel[Math.min(nodeRow.depth - 1, this.cfg.maxCharsPerLevel.length - 1)];
692
- if (trimmed.length > nodeLimit) {
1133
+ if (trimmed.length > nodeLimit * HmemStore.CHAR_LIMIT_TOLERANCE) {
693
1134
  throw new Error(`Content exceeds ${nodeLimit} character limit (${trimmed.length} chars) for L${nodeRow.depth}.`);
694
1135
  }
695
1136
  const sets = ["content = ?", "title = ?"];
@@ -698,14 +1139,21 @@ export class HmemStore {
698
1139
  sets.push("favorite = ?");
699
1140
  params.push(favorite ? 1 : 0);
700
1141
  }
1142
+ if (irrelevant !== undefined) {
1143
+ sets.push("irrelevant = ?");
1144
+ params.push(irrelevant ? 1 : 0);
1145
+ }
701
1146
  params.push(id);
702
1147
  const result = this.db.prepare(`UPDATE memory_nodes SET ${sets.join(", ")} WHERE id = ?`).run(...params);
1148
+ if (result.changes > 0 && tags !== undefined) {
1149
+ this.setTags(id, tags.length > 0 ? this.validateTags(tags) : []);
1150
+ }
703
1151
  return result.changes > 0;
704
1152
  }
705
1153
  else {
706
1154
  // Root entry in memories — check L1 char limit
707
1155
  const l1Limit = this.cfg.maxCharsPerLevel[0];
708
- if (trimmed.length > l1Limit) {
1156
+ if (trimmed.length > l1Limit * HmemStore.CHAR_LIMIT_TOLERANCE) {
709
1157
  throw new Error(`Level 1 exceeds ${l1Limit} character limit (${trimmed.length} chars). Keep L1 compact.`);
710
1158
  }
711
1159
  // Obsolete enforcement: require [✓ID] correction reference
@@ -759,8 +1207,15 @@ export class HmemStore {
759
1207
  sets.push("irrelevant = ?");
760
1208
  params.push(irrelevant ? 1 : 0);
761
1209
  }
1210
+ if (pinned !== undefined) {
1211
+ sets.push("pinned = ?");
1212
+ params.push(pinned ? 1 : 0);
1213
+ }
762
1214
  params.push(id);
763
1215
  const result = this.db.prepare(`UPDATE memories SET ${sets.join(", ")} WHERE id = ?`).run(...params);
1216
+ if (result.changes > 0 && tags !== undefined) {
1217
+ this.setTags(id, tags.length > 0 ? this.validateTags(tags) : []);
1218
+ }
764
1219
  return result.changes > 0;
765
1220
  }
766
1221
  }
@@ -794,10 +1249,11 @@ export class HmemStore {
794
1249
  const nodes = this.parseRelativeTree(content, parentId, parentDepth, startSeq);
795
1250
  if (nodes.length === 0)
796
1251
  return { count: 0, ids: [] };
797
- // Validate char limits before writing
1252
+ // Validate char limits before writing (with tolerance buffer)
1253
+ const t = HmemStore.CHAR_LIMIT_TOLERANCE;
798
1254
  for (const node of nodes) {
799
1255
  const nodeLimit = this.cfg.maxCharsPerLevel[Math.min(node.depth - 1, this.cfg.maxCharsPerLevel.length - 1)];
800
- if (node.content.length > nodeLimit) {
1256
+ if (node.content.length > nodeLimit * t) {
801
1257
  throw new Error(`L${node.depth} content exceeds ${nodeLimit} character limit ` +
802
1258
  `(${node.content.length} chars). Split into multiple calls or use file references.`);
803
1259
  }
@@ -866,6 +1322,152 @@ export class HmemStore {
866
1322
  this.db.close();
867
1323
  }
868
1324
  // ---- Private helpers ----
1325
+ // ---- Tag helpers ----
1326
+ static TAG_REGEX = /^#[a-z0-9_-]{1,49}$/;
1327
+ static MAX_TAGS_PER_ENTRY = 10;
1328
+ /** Validate and normalize tags: lowercase, must match #word pattern. */
1329
+ validateTags(tags) {
1330
+ if (tags.length > HmemStore.MAX_TAGS_PER_ENTRY) {
1331
+ throw new Error(`Too many tags (${tags.length}). Maximum is ${HmemStore.MAX_TAGS_PER_ENTRY}.`);
1332
+ }
1333
+ const normalized = tags.map(t => t.toLowerCase());
1334
+ for (const tag of normalized) {
1335
+ if (!HmemStore.TAG_REGEX.test(tag)) {
1336
+ throw new Error(`Invalid tag "${tag}". Tags must match #word (lowercase, a-z 0-9 _ -).`);
1337
+ }
1338
+ }
1339
+ return [...new Set(normalized)]; // deduplicate
1340
+ }
1341
+ /** Replace all tags on an entry/node. Pass empty array to clear. */
1342
+ setTags(entryId, tags) {
1343
+ this.db.prepare("DELETE FROM memory_tags WHERE entry_id = ?").run(entryId);
1344
+ if (tags.length === 0)
1345
+ return;
1346
+ const insert = this.db.prepare("INSERT OR IGNORE INTO memory_tags (entry_id, tag) VALUES (?, ?)");
1347
+ for (const tag of tags) {
1348
+ insert.run(entryId, tag);
1349
+ }
1350
+ }
1351
+ /** Get tags for a single entry/node. */
1352
+ fetchTags(entryId) {
1353
+ return this.db.prepare("SELECT tag FROM memory_tags WHERE entry_id = ? ORDER BY tag").all(entryId)
1354
+ .map(r => r.tag);
1355
+ }
1356
+ /** Bulk-fetch tags for multiple IDs at once. */
1357
+ fetchTagsBulk(ids) {
1358
+ if (ids.length === 0)
1359
+ return new Map();
1360
+ const map = new Map();
1361
+ // Process in chunks of 500 to avoid SQLite variable limits
1362
+ for (let i = 0; i < ids.length; i += 500) {
1363
+ const chunk = ids.slice(i, i + 500);
1364
+ const placeholders = chunk.map(() => "?").join(", ");
1365
+ const rows = this.db.prepare(`SELECT entry_id, tag FROM memory_tags WHERE entry_id IN (${placeholders}) ORDER BY entry_id, tag`).all(...chunk);
1366
+ for (const row of rows) {
1367
+ const arr = map.get(row.entry_id);
1368
+ if (arr)
1369
+ arr.push(row.tag);
1370
+ else
1371
+ map.set(row.entry_id, [row.tag]);
1372
+ }
1373
+ }
1374
+ return map;
1375
+ }
1376
+ /**
1377
+ * Find entries sharing 2+ tags with the given entry.
1378
+ * Returns title-only results sorted by number of shared tags (descending).
1379
+ */
1380
+ findRelated(entryId, tags, limit = 5) {
1381
+ if (tags.length < 2)
1382
+ return [];
1383
+ const placeholders = tags.map(() => "?").join(", ");
1384
+ // Find all entry_ids sharing at least 2 tags (exclude self)
1385
+ const rows = this.db.prepare(`
1386
+ SELECT entry_id, COUNT(*) as shared
1387
+ FROM memory_tags
1388
+ WHERE tag IN (${placeholders}) AND entry_id != ?
1389
+ GROUP BY entry_id
1390
+ HAVING COUNT(*) >= 2
1391
+ ORDER BY shared DESC
1392
+ LIMIT ?
1393
+ `).all(...tags, entryId, limit * 3); // fetch extra to account for node→root dedup
1394
+ if (rows.length === 0)
1395
+ return [];
1396
+ // Resolve node IDs to root entries, dedup
1397
+ const seen = new Set();
1398
+ const results = [];
1399
+ for (const row of rows) {
1400
+ if (results.length >= limit)
1401
+ break;
1402
+ const eid = row.entry_id;
1403
+ const isNode = eid.includes(".");
1404
+ const rootId = isNode ? eid.split(".")[0] : eid;
1405
+ if (seen.has(rootId) || rootId === entryId || rootId === entryId.split(".")[0])
1406
+ continue;
1407
+ seen.add(rootId);
1408
+ // Fetch root entry title
1409
+ const rootRow = this.db.prepare("SELECT title, level_1, created_at, irrelevant, obsolete FROM memories WHERE id = ?").get(rootId);
1410
+ if (!rootRow || rootRow.irrelevant === 1 || rootRow.obsolete === 1)
1411
+ continue;
1412
+ const title = rootRow.title || this.autoExtractTitle(rootRow.level_1);
1413
+ const entryTags = this.fetchTags(rootId);
1414
+ results.push({ id: rootId, title, created_at: rootRow.created_at, tags: entryTags });
1415
+ }
1416
+ return results;
1417
+ }
1418
+ /** Bulk-assign tags to entries + their children from a single fetchTagsBulk call. */
1419
+ assignBulkTags(entries) {
1420
+ const allIds = [];
1421
+ for (const e of entries) {
1422
+ allIds.push(e.id);
1423
+ if (e.children)
1424
+ allIds.push(...this.collectNodeIds(e.children));
1425
+ }
1426
+ if (allIds.length === 0)
1427
+ return;
1428
+ const tagMap = this.fetchTagsBulk(allIds);
1429
+ for (const e of entries) {
1430
+ if (tagMap.has(e.id))
1431
+ e.tags = tagMap.get(e.id);
1432
+ if (e.children) {
1433
+ for (const child of e.children) {
1434
+ if (tagMap.has(child.id))
1435
+ child.tags = tagMap.get(child.id);
1436
+ if (child.children) {
1437
+ for (const gc of child.children) {
1438
+ if (tagMap.has(gc.id))
1439
+ gc.tags = tagMap.get(gc.id);
1440
+ }
1441
+ }
1442
+ }
1443
+ }
1444
+ }
1445
+ }
1446
+ /** Recursively collect all node IDs from a tree of MemoryNodes. */
1447
+ collectNodeIds(nodes) {
1448
+ const ids = [];
1449
+ for (const node of nodes) {
1450
+ ids.push(node.id);
1451
+ if (node.children)
1452
+ ids.push(...this.collectNodeIds(node.children));
1453
+ }
1454
+ return ids;
1455
+ }
1456
+ /** Get root IDs that have a specific tag (for bulk-read filtering). */
1457
+ getRootIdsByTag(tag) {
1458
+ const rows = this.db.prepare("SELECT entry_id FROM memory_tags WHERE tag = ?").all(tag);
1459
+ const rootIds = new Set();
1460
+ for (const row of rows) {
1461
+ const eid = row.entry_id;
1462
+ if (eid.includes(".")) {
1463
+ rootIds.add(eid.split(".")[0]);
1464
+ }
1465
+ else {
1466
+ rootIds.add(eid);
1467
+ }
1468
+ }
1469
+ return rootIds;
1470
+ }
869
1471
  migrate() {
870
1472
  for (const sql of MIGRATIONS) {
871
1473
  try {
@@ -1094,7 +1696,7 @@ export class HmemStore {
1094
1696
  if (parentIds.length === 0)
1095
1697
  return new Map();
1096
1698
  const placeholders = parentIds.map(() => "?").join(", ");
1097
- const rows = this.db.prepare(`SELECT parent_id, COUNT(*) as cnt FROM memory_nodes WHERE parent_id IN (${placeholders}) GROUP BY parent_id`).all(...parentIds);
1699
+ const rows = this.db.prepare(`SELECT parent_id, COUNT(*) as cnt FROM memory_nodes WHERE parent_id IN (${placeholders}) AND COALESCE(irrelevant, 0) != 1 GROUP BY parent_id`).all(...parentIds);
1098
1700
  const map = new Map();
1099
1701
  for (const r of rows)
1100
1702
  map.set(r.parent_id, r.cnt);
@@ -1159,6 +1761,7 @@ export class HmemStore {
1159
1761
  access_count: row.access_count || 0,
1160
1762
  last_accessed: row.last_accessed || null,
1161
1763
  favorite: row.favorite === 1 ? true : undefined,
1764
+ irrelevant: row.irrelevant === 1 ? true : undefined,
1162
1765
  child_count: childCount,
1163
1766
  };
1164
1767
  }
@@ -1181,6 +1784,7 @@ export class HmemStore {
1181
1784
  obsolete: row.obsolete === 1,
1182
1785
  favorite: row.favorite === 1,
1183
1786
  irrelevant: row.irrelevant === 1,
1787
+ pinned: row.pinned === 1,
1184
1788
  children,
1185
1789
  };
1186
1790
  }
@@ -1213,7 +1817,7 @@ export class HmemStore {
1213
1817
  * Priority: text before " — " > word-boundary truncation > hard truncation.
1214
1818
  */
1215
1819
  autoExtractTitle(text) {
1216
- const maxLen = this.cfg.maxTitleChars;
1820
+ const maxLen = Math.floor(this.cfg.maxTitleChars * HmemStore.CHAR_LIMIT_TOLERANCE);
1217
1821
  const dashIdx = text.indexOf(" — ");
1218
1822
  if (dashIdx > 0 && dashIdx <= maxLen)
1219
1823
  return text.substring(0, dashIdx);