hmem-mcp 2.5.4 → 2.7.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.
@@ -48,6 +48,8 @@ export interface MemoryEntry {
48
48
  favorite?: boolean;
49
49
  /** True if the agent marked this entry as irrelevant. Hidden from bulk reads, no correction needed. */
50
50
  irrelevant?: boolean;
51
+ /** True if this entry is actively relevant (root-only). When any entry in a prefix has active=1, only active entries of that prefix are expanded in bulk reads. */
52
+ active?: boolean;
51
53
  /** True if this entry was already delivered in a previous bulk read (session cache). */
52
54
  suppressed?: boolean;
53
55
  /**
@@ -153,6 +155,10 @@ export interface ReadOptions {
153
155
  tag?: string;
154
156
  /** Show entries not accessed in the last N days (stale detection). Sorted oldest-access first. */
155
157
  staleDays?: number;
158
+ /** Find all entries related to a given entry via per-node tag scoring + direct links. */
159
+ contextFor?: string;
160
+ /** Minimum weighted tag score for context_for matches. Default: 4. Tier weights: rare(<=5)=3, medium(6-20)=2, common(>20)=1. */
161
+ minTagScore?: number;
156
162
  }
157
163
  export interface WriteResult {
158
164
  id: string;
@@ -245,7 +251,7 @@ export declare class HmemStore {
245
251
  /**
246
252
  * Update specific fields of an existing root entry (curator use only).
247
253
  */
248
- update(id: string, fields: Partial<Pick<MemoryEntry, "level_1" | "level_2" | "level_3" | "level_4" | "level_5" | "links" | "min_role" | "obsolete" | "favorite" | "irrelevant">>): boolean;
254
+ update(id: string, fields: Partial<Pick<MemoryEntry, "level_1" | "level_2" | "level_3" | "level_4" | "level_5" | "links" | "min_role" | "obsolete" | "favorite" | "irrelevant" | "active">>): boolean;
249
255
  /**
250
256
  * Delete an entry by ID (curator use only).
251
257
  * Also deletes all associated memory_nodes.
@@ -257,7 +263,7 @@ export declare class HmemStore {
257
263
  * For sub-nodes: updates node content only.
258
264
  * Does NOT modify children — use appendChildren to extend the tree.
259
265
  */
260
- updateNode(id: string, newContent: string, links?: string[], obsolete?: boolean, favorite?: boolean, curatorBypass?: boolean, irrelevant?: boolean, tags?: string[], pinned?: boolean): boolean;
266
+ updateNode(id: string, newContent: string, links?: string[], obsolete?: boolean, favorite?: boolean, curatorBypass?: boolean, irrelevant?: boolean, tags?: string[], pinned?: boolean, active?: boolean): boolean;
261
267
  /**
262
268
  * Append new child nodes under an existing entry (root or node).
263
269
  * Content is tab-indented relative to the parent:
@@ -301,7 +307,7 @@ export declare class HmemStore {
301
307
  tags: string[];
302
308
  }[];
303
309
  /** Bulk-assign tags to entries + their children from a single fetchTagsBulk call. */
304
- private assignBulkTags;
310
+ assignBulkTags(entries: MemoryEntry[]): void;
305
311
  /** Recursively collect all node IDs from a tree of MemoryNodes. */
306
312
  private collectNodeIds;
307
313
  /** Get root IDs that have a specific tag (for bulk-read filtering). */
@@ -462,6 +468,24 @@ export declare class HmemStore {
462
468
  created_at: string;
463
469
  tags: string[];
464
470
  }[];
471
+ /**
472
+ * Find all entries contextually related to a given entry.
473
+ * Uses per-node weighted tag scoring: for each node of the source entry,
474
+ * compute weighted overlap with each candidate entry's full tag set.
475
+ * Tier weights: rare (<=5 entries) = 3, medium (6-20) = 2, common (>20) = 1.
476
+ * A candidate matches if ANY source node scores >= minTagScore against it.
477
+ * Bidirectional direct links are always included.
478
+ */
479
+ findContext(entryId: string, minTagScore?: number, limit?: number): {
480
+ linked: MemoryEntry[];
481
+ tagRelated: {
482
+ entry: MemoryEntry;
483
+ score: number;
484
+ matchNode: string;
485
+ }[];
486
+ };
487
+ /** Resolve bidirectional direct links for an entry, filtering obsolete/irrelevant. */
488
+ private resolveDirectLinks;
465
489
  /** Audit report: broken links, orphaned entries, stale favorites, broken obsolete chains, tag orphans. */
466
490
  healthCheck(): {
467
491
  brokenLinks: {
@@ -157,6 +157,8 @@ const MIGRATIONS = [
157
157
  // Sync support: track last content modification (separate from last_accessed)
158
158
  "ALTER TABLE memories ADD COLUMN updated_at TEXT",
159
159
  "ALTER TABLE memory_nodes ADD COLUMN updated_at TEXT",
160
+ // Active flag: marks entries as currently relevant — non-active entries in same prefix shown title-only
161
+ "ALTER TABLE memories ADD COLUMN active INTEGER DEFAULT 0",
160
162
  ];
161
163
  // ---- HmemStore class ----
162
164
  export class HmemStore {
@@ -258,8 +260,11 @@ export class HmemStore {
258
260
  INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, title, content, created_at, updated_at)
259
261
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
260
262
  `);
261
- // Validate tags before transaction
262
- const validatedTags = tags && tags.length > 0 ? this.validateTags(tags) : [];
263
+ // Tags are mandatory — at least 1 required for discoverability
264
+ if (!tags || tags.length === 0) {
265
+ throw new Error("Tags are required. Provide at least 1 tag (3+ recommended) for discoverability. Example: tags=['#hmem', '#sqlite', '#bug']");
266
+ }
267
+ const validatedTags = this.validateTags(tags);
263
268
  // Run in a transaction
264
269
  this.db.transaction(() => {
265
270
  insertRoot.run(rootId, prefix, seq, timestamp, timestamp, title, level1, links ? JSON.stringify(links) : null, minRole, favorite ? 1 : 0, pinned ? 1 : 0);
@@ -558,6 +563,75 @@ export class HmemStore {
558
563
  // Step 0: Filter out irrelevant entries (never shown in bulk reads)
559
564
  const irrelevantCount = rows.filter(r => r.irrelevant === 1).length;
560
565
  const activeRows = rows.filter(r => r.irrelevant !== 1);
566
+ // Step 0.5: Detect active-prefixes — prefixes where at least one entry has active=1.
567
+ // Non-active entries in these prefixes are still shown (as compact titles) but don't get expansion slots.
568
+ const activePrefixes = new Set();
569
+ for (const r of activeRows) {
570
+ if (r.active === 1)
571
+ activePrefixes.add(r.prefix);
572
+ }
573
+ // Step 0.6: Cascading related-suppression — entries in OTHER prefixes that are thematically
574
+ // related ONLY to suppressed (non-active) entries get demoted to title-only too.
575
+ // Logic: collect tags from non-active entries, subtract tags from active entries,
576
+ // then find entries in other prefixes that share ONLY the remaining "suppressed" tags.
577
+ const relatedSuppressed = new Set();
578
+ if (activePrefixes.size > 0) {
579
+ const activeEntryIds = [];
580
+ const nonActiveEntryIds = [];
581
+ for (const r of activeRows) {
582
+ if (activePrefixes.has(r.prefix)) {
583
+ if (r.active === 1)
584
+ activeEntryIds.push(r.id);
585
+ else
586
+ nonActiveEntryIds.push(r.id);
587
+ }
588
+ }
589
+ if (nonActiveEntryIds.length > 0 && activeEntryIds.length > 0) {
590
+ // Fetch tags for active and non-active entries (root-level tags + child tags)
591
+ const activeTagRows = activeEntryIds.length > 0
592
+ ? this.db.prepare(`SELECT DISTINCT tag FROM memory_tags WHERE ${activeEntryIds.map(() => "entry_id = ? OR entry_id LIKE ?").join(" OR ")}`).all(...activeEntryIds.flatMap(id => [id, `${id}.%`]))
593
+ : [];
594
+ const activeTags = new Set(activeTagRows.map(r => r.tag));
595
+ const nonActiveTagRows = this.db.prepare(`SELECT DISTINCT tag FROM memory_tags WHERE ${nonActiveEntryIds.map(() => "entry_id = ? OR entry_id LIKE ?").join(" OR ")}`).all(...nonActiveEntryIds.flatMap(id => [id, `${id}.%`]));
596
+ // Suppressed tags = tags that appear in non-active entries but NOT in active entries
597
+ const suppressedTags = nonActiveTagRows.map(r => r.tag).filter(t => !activeTags.has(t));
598
+ if (suppressedTags.length > 0) {
599
+ // Find root entries in OTHER prefixes that have ONLY suppressed tags (no active tags)
600
+ const prefixList = [...activePrefixes];
601
+ const otherEntries = activeRows.filter(r => !activePrefixes.has(r.prefix) && r.obsolete !== 1);
602
+ if (otherEntries.length > 0) {
603
+ const otherIds = otherEntries.map(r => r.id);
604
+ const otherTagMap = this.fetchTagsBulk(otherIds);
605
+ // Also fetch child tags for better coverage
606
+ const otherChildIds = otherIds.flatMap(id => {
607
+ const children = this.db.prepare("SELECT id FROM memory_nodes WHERE root_id = ?").all(id);
608
+ return children.map(c => c.id);
609
+ });
610
+ const childTagMap = otherChildIds.length > 0 ? this.fetchTagsBulk(otherChildIds) : new Map();
611
+ // Merge child tags into parent
612
+ for (const e of otherEntries) {
613
+ const rootTags = otherTagMap.get(e.id) ?? [];
614
+ const childNodes = this.db.prepare("SELECT id FROM memory_nodes WHERE root_id = ?").all(e.id);
615
+ const allTags = new Set(rootTags);
616
+ for (const cn of childNodes) {
617
+ const ct = childTagMap.get(cn.id);
618
+ if (ct)
619
+ ct.forEach(t => allTags.add(t));
620
+ }
621
+ if (allTags.size === 0)
622
+ continue;
623
+ // Check: does this entry have ANY active tags?
624
+ const hasActiveTags = [...allTags].some(t => activeTags.has(t));
625
+ const hasSuppressedTags = [...allTags].some(t => suppressedTags.includes(t));
626
+ // Only suppress if entry shares suppressed tags but NO active tags
627
+ if (hasSuppressedTags && !hasActiveTags) {
628
+ relatedSuppressed.add(e.id);
629
+ }
630
+ }
631
+ }
632
+ }
633
+ }
634
+ }
561
635
  // Step 1: Separate obsolete from non-obsolete FIRST
562
636
  const obsoleteRows = activeRows.filter(r => r.obsolete === 1);
563
637
  const nonObsoleteRows = activeRows.filter(r => r.obsolete !== 1);
@@ -614,10 +688,15 @@ export class HmemStore {
614
688
  const expandedIds = new Set();
615
689
  const isEssentials = opts.mode === "essentials";
616
690
  // Per prefix: top N newest + top M most-accessed — slot counts scale with prefix size
617
- for (const [, prefixRows] of byPrefix) {
618
- const { newestCount, accessCount } = this.calcV2Slots(prefixRows.length, isEssentials, fraction);
691
+ for (const [prefix, prefixRows] of byPrefix) {
692
+ // In active-prefixes, only active entries compete for expansion slots.
693
+ // Related-suppressed entries in OTHER prefixes also don't compete.
694
+ const candidateRows = activePrefixes.has(prefix)
695
+ ? prefixRows.filter(r => r.active === 1)
696
+ : prefixRows.filter(r => !relatedSuppressed.has(r.id));
697
+ const { newestCount, accessCount } = this.calcV2Slots(candidateRows.length, isEssentials, fraction);
619
698
  // Newest: skip cached AND hidden entries, fill from fresh entries only
620
- const uncachedRows = prefixRows.filter(r => !cached.has(r.id) && !hidden.has(r.id));
699
+ const uncachedRows = candidateRows.filter(r => !cached.has(r.id) && !hidden.has(r.id));
621
700
  for (const r of uncachedRows.slice(0, newestCount)) {
622
701
  expandedIds.add(r.id);
623
702
  }
@@ -630,9 +709,18 @@ export class HmemStore {
630
709
  for (const r of mostAccessed)
631
710
  expandedIds.add(r.id);
632
711
  }
633
- // Global: all uncached+unhidden favorites
712
+ // Global: uncached+unhidden favorites/pinned + all active entries
634
713
  for (const r of nonObsoleteRows) {
635
714
  if ((r.favorite === 1 || r.pinned === 1) && !cached.has(r.id) && !hidden.has(r.id)) {
715
+ // In active-prefixes, only active entries get expansion even if favorite/pinned
716
+ if (!activePrefixes.has(r.prefix) || r.active === 1) {
717
+ // Related-suppressed entries don't get expansion even if favorite/pinned
718
+ if (!relatedSuppressed.has(r.id)) {
719
+ expandedIds.add(r.id);
720
+ }
721
+ }
722
+ }
723
+ if (r.active === 1) {
636
724
  expandedIds.add(r.id);
637
725
  }
638
726
  }
@@ -643,6 +731,13 @@ export class HmemStore {
643
731
  const nodeCounts = this.db.prepare("SELECT root_id, COUNT(*) as cnt FROM memory_nodes GROUP BY root_id ORDER BY cnt DESC LIMIT ?").all(topSubnodeCount);
644
732
  for (const row of nodeCounts) {
645
733
  if (!hidden.has(row.root_id)) {
734
+ // In active-prefixes, don't expand non-active entries even if they have many sub-nodes
735
+ const entryRow = nonObsoleteRows.find(r => r.id === row.root_id);
736
+ if (entryRow && activePrefixes.has(entryRow.prefix) && entryRow.active !== 1)
737
+ continue;
738
+ // Related-suppressed entries don't get topSubnode expansion either
739
+ if (relatedSuppressed.has(row.root_id))
740
+ continue;
646
741
  expandedIds.add(row.root_id);
647
742
  topSubnodeIds.add(row.root_id);
648
743
  }
@@ -659,9 +754,17 @@ export class HmemStore {
659
754
  // Step 4: Build visible rows (hidden entries completely excluded)
660
755
  // - Expanded entries: full content with children
661
756
  // - Cached entries: title-only (no expansion, no children)
757
+ // - Non-active in active-prefixes: title-only
758
+ // - Related-suppressed in other prefixes: title-only
662
759
  const expandedNonObsolete = nonObsoleteRows.filter(r => expandedIds.has(r.id));
663
760
  const cachedVisible = nonObsoleteRows.filter(r => cached.has(r.id) && !expandedIds.has(r.id) && !hidden.has(r.id));
664
- const visibleRows = [...expandedNonObsolete, ...cachedVisible, ...visibleObsolete];
761
+ const nonActiveVisible = activePrefixes.size > 0
762
+ ? nonObsoleteRows.filter(r => activePrefixes.has(r.prefix) && r.active !== 1 && !expandedIds.has(r.id) && !cached.has(r.id) && !hidden.has(r.id))
763
+ : [];
764
+ const relatedSuppressedVisible = relatedSuppressed.size > 0
765
+ ? nonObsoleteRows.filter(r => relatedSuppressed.has(r.id) && !expandedIds.has(r.id) && !cached.has(r.id) && !hidden.has(r.id))
766
+ : [];
767
+ const visibleRows = [...expandedNonObsolete, ...cachedVisible, ...nonActiveVisible, ...relatedSuppressedVisible, ...visibleObsolete];
665
768
  const visibleIds = new Set(visibleRows.map(r => r.id));
666
769
  // titles_only: V2 selection applies, but skip link resolution
667
770
  if (opts.titlesOnly) {
@@ -1187,7 +1290,7 @@ export class HmemStore {
1187
1290
  if (key === "links" && Array.isArray(val)) {
1188
1291
  params.push(JSON.stringify(val));
1189
1292
  }
1190
- else if (key === "obsolete" || key === "favorite" || key === "irrelevant") {
1293
+ else if (key === "obsolete" || key === "favorite" || key === "irrelevant" || key === "active") {
1191
1294
  params.push(val ? 1 : 0);
1192
1295
  }
1193
1296
  else {
@@ -1221,7 +1324,7 @@ export class HmemStore {
1221
1324
  * For sub-nodes: updates node content only.
1222
1325
  * Does NOT modify children — use appendChildren to extend the tree.
1223
1326
  */
1224
- updateNode(id, newContent, links, obsolete, favorite, curatorBypass, irrelevant, tags, pinned) {
1327
+ updateNode(id, newContent, links, obsolete, favorite, curatorBypass, irrelevant, tags, pinned, active) {
1225
1328
  this.guardCorrupted();
1226
1329
  const trimmed = newContent.trim();
1227
1330
  if (id.includes(".")) {
@@ -1338,6 +1441,10 @@ export class HmemStore {
1338
1441
  sets.push("pinned = ?");
1339
1442
  params.push(pinned ? 1 : 0);
1340
1443
  }
1444
+ if (active !== undefined) {
1445
+ sets.push("active = ?");
1446
+ params.push(active ? 1 : 0);
1447
+ }
1341
1448
  if (sets.length === 0) {
1342
1449
  // Only tags to update — no SQL UPDATE needed
1343
1450
  if (tags !== undefined) {
@@ -1987,6 +2094,7 @@ export class HmemStore {
1987
2094
  obsolete: row.obsolete === 1,
1988
2095
  favorite: row.favorite === 1,
1989
2096
  irrelevant: row.irrelevant === 1,
2097
+ active: row.active === 1,
1990
2098
  pinned: row.pinned === 1,
1991
2099
  children,
1992
2100
  };
@@ -2308,6 +2416,146 @@ export class HmemStore {
2308
2416
  return [];
2309
2417
  }
2310
2418
  }
2419
+ /**
2420
+ * Find all entries contextually related to a given entry.
2421
+ * Uses per-node weighted tag scoring: for each node of the source entry,
2422
+ * compute weighted overlap with each candidate entry's full tag set.
2423
+ * Tier weights: rare (<=5 entries) = 3, medium (6-20) = 2, common (>20) = 1.
2424
+ * A candidate matches if ANY source node scores >= minTagScore against it.
2425
+ * Bidirectional direct links are always included.
2426
+ */
2427
+ findContext(entryId, minTagScore = 4, limit = 30) {
2428
+ this.guardCorrupted();
2429
+ // 1. Source node IDs
2430
+ const childRows = this.db.prepare("SELECT id FROM memory_nodes WHERE root_id = ?").all(entryId);
2431
+ const nodeIds = [entryId, ...childRows.map(r => r.id)];
2432
+ // 2. Tags per source node
2433
+ const nodeTagMap = this.fetchTagsBulk(nodeIds);
2434
+ // 3. All unique source tags
2435
+ const allSourceTags = new Set();
2436
+ for (const tags of nodeTagMap.values()) {
2437
+ if (tags)
2438
+ tags.forEach(t => allSourceTags.add(t));
2439
+ }
2440
+ if (allSourceTags.size === 0) {
2441
+ return { linked: this.resolveDirectLinks(entryId), tagRelated: [] };
2442
+ }
2443
+ // 4. Global tag frequencies (count distinct root entries per tag)
2444
+ const freqRows = this.db.prepare(`
2445
+ SELECT tag, COUNT(DISTINCT
2446
+ CASE WHEN entry_id LIKE '%.%'
2447
+ THEN SUBSTR(entry_id, 1, INSTR(entry_id, '.') - 1)
2448
+ ELSE entry_id END
2449
+ ) as freq
2450
+ FROM memory_tags GROUP BY tag
2451
+ `).all();
2452
+ const tagFreq = new Map();
2453
+ for (const r of freqRows)
2454
+ tagFreq.set(r.tag, r.freq);
2455
+ // 5. Find candidate entries sharing any source tag
2456
+ const srcTagArr = [...allSourceTags];
2457
+ const placeholders = srcTagArr.map(() => "?").join(", ");
2458
+ const candidateRows = this.db.prepare(`
2459
+ SELECT
2460
+ CASE WHEN entry_id LIKE '%.%'
2461
+ THEN SUBSTR(entry_id, 1, INSTR(entry_id, '.') - 1)
2462
+ ELSE entry_id END as root_id,
2463
+ tag
2464
+ FROM memory_tags
2465
+ WHERE tag IN (${placeholders})
2466
+ `).all(...srcTagArr);
2467
+ // 6. Group candidate tags by root_id (skip self)
2468
+ const candidateTagMap = new Map();
2469
+ for (const r of candidateRows) {
2470
+ if (r.root_id === entryId)
2471
+ continue;
2472
+ let set = candidateTagMap.get(r.root_id);
2473
+ if (!set) {
2474
+ set = new Set();
2475
+ candidateTagMap.set(r.root_id, set);
2476
+ }
2477
+ set.add(r.tag);
2478
+ }
2479
+ // 7. Score each candidate per source node
2480
+ const scored = [];
2481
+ for (const [candidateId, candidateTags] of candidateTagMap) {
2482
+ let bestScore = 0;
2483
+ let bestNode = "";
2484
+ for (const [nodeId, nodeTags] of nodeTagMap) {
2485
+ if (!nodeTags)
2486
+ continue;
2487
+ let score = 0;
2488
+ for (const tag of nodeTags) {
2489
+ if (candidateTags.has(tag)) {
2490
+ const freq = tagFreq.get(tag) ?? 999;
2491
+ if (freq <= 5)
2492
+ score += 3;
2493
+ else if (freq <= 20)
2494
+ score += 2;
2495
+ else
2496
+ score += 1;
2497
+ }
2498
+ }
2499
+ if (score > bestScore) {
2500
+ bestScore = score;
2501
+ bestNode = nodeId;
2502
+ }
2503
+ }
2504
+ if (bestScore >= minTagScore) {
2505
+ scored.push({ id: candidateId, score: bestScore, matchNode: bestNode });
2506
+ }
2507
+ }
2508
+ // Sort by score DESC
2509
+ scored.sort((a, b) => b.score - a.score);
2510
+ const topScored = scored.slice(0, limit);
2511
+ // 8. Fetch full entries, filter obsolete + irrelevant
2512
+ const tagRelated = [];
2513
+ for (const s of topScored) {
2514
+ const row = this.db.prepare("SELECT * FROM memories WHERE id = ? AND obsolete != 1 AND irrelevant != 1").get(s.id);
2515
+ if (!row)
2516
+ continue;
2517
+ const children = this.fetchChildren(row.id);
2518
+ const entry = this.rowToEntry(row, children);
2519
+ entry.tags = this.fetchTags(row.id);
2520
+ tagRelated.push({ entry, score: s.score, matchNode: s.matchNode });
2521
+ }
2522
+ // 9. Direct links (bidirectional)
2523
+ const linked = this.resolveDirectLinks(entryId);
2524
+ return { linked, tagRelated };
2525
+ }
2526
+ /** Resolve bidirectional direct links for an entry, filtering obsolete/irrelevant. */
2527
+ resolveDirectLinks(entryId) {
2528
+ const linkIds = new Set();
2529
+ // Forward links
2530
+ const row = this.db.prepare("SELECT links FROM memories WHERE id = ?").get(entryId);
2531
+ if (row?.links) {
2532
+ try {
2533
+ JSON.parse(row.links).forEach((id) => linkIds.add(id));
2534
+ }
2535
+ catch { }
2536
+ }
2537
+ // Reverse links: entries whose links field contains this ID
2538
+ const reverseRows = this.db.prepare("SELECT id, links FROM memories WHERE links LIKE ? AND id != ?").all(`%${entryId}%`, entryId);
2539
+ for (const r of reverseRows) {
2540
+ try {
2541
+ if (JSON.parse(r.links).includes(entryId))
2542
+ linkIds.add(r.id);
2543
+ }
2544
+ catch { }
2545
+ }
2546
+ // Fetch entries
2547
+ const results = [];
2548
+ for (const lid of linkIds) {
2549
+ const lr = this.db.prepare("SELECT * FROM memories WHERE id = ? AND obsolete != 1 AND irrelevant != 1").get(lid);
2550
+ if (!lr)
2551
+ continue;
2552
+ const children = this.fetchChildren(lr.id);
2553
+ const entry = this.rowToEntry(lr, children);
2554
+ entry.tags = this.fetchTags(lr.id);
2555
+ results.push(entry);
2556
+ }
2557
+ return results;
2558
+ }
2311
2559
  /** Audit report: broken links, orphaned entries, stale favorites, broken obsolete chains, tag orphans. */
2312
2560
  healthCheck() {
2313
2561
  const result = {