hmem-mcp 2.5.4 → 2.6.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
  /**
@@ -245,7 +247,7 @@ export declare class HmemStore {
245
247
  /**
246
248
  * Update specific fields of an existing root entry (curator use only).
247
249
  */
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;
250
+ 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
251
  /**
250
252
  * Delete an entry by ID (curator use only).
251
253
  * Also deletes all associated memory_nodes.
@@ -257,7 +259,7 @@ export declare class HmemStore {
257
259
  * For sub-nodes: updates node content only.
258
260
  * Does NOT modify children — use appendChildren to extend the tree.
259
261
  */
260
- updateNode(id: string, newContent: string, links?: string[], obsolete?: boolean, favorite?: boolean, curatorBypass?: boolean, irrelevant?: boolean, tags?: string[], pinned?: boolean): boolean;
262
+ updateNode(id: string, newContent: string, links?: string[], obsolete?: boolean, favorite?: boolean, curatorBypass?: boolean, irrelevant?: boolean, tags?: string[], pinned?: boolean, active?: boolean): boolean;
261
263
  /**
262
264
  * Append new child nodes under an existing entry (root or node).
263
265
  * Content is tab-indented relative to the parent:
@@ -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
  };