hmem-mcp 2.0.3 → 2.2.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.
@@ -27,7 +27,7 @@
27
27
  import Database from "better-sqlite3";
28
28
  import fs from "node:fs";
29
29
  import path from "node:path";
30
- import { DEFAULT_CONFIG, DEFAULT_PREFIX_DESCRIPTIONS, resolveDepthForPosition } from "./hmem-config.js";
30
+ import { DEFAULT_CONFIG, DEFAULT_PREFIX_DESCRIPTIONS } from "./hmem-config.js";
31
31
  // Prefixes are now loaded from config — see this.cfg.prefixes
32
32
  const ROLE_LEVEL = {
33
33
  worker: 0, al: 1, pl: 2, ceo: 3,
@@ -54,7 +54,8 @@ CREATE TABLE IF NOT EXISTS memories (
54
54
  links TEXT,
55
55
  min_role TEXT DEFAULT 'worker',
56
56
  obsolete INTEGER DEFAULT 0,
57
- favorite INTEGER DEFAULT 0
57
+ favorite INTEGER DEFAULT 0,
58
+ irrelevant INTEGER DEFAULT 0
58
59
  );
59
60
  CREATE INDEX IF NOT EXISTS idx_prefix ON memories(prefix);
60
61
  CREATE INDEX IF NOT EXISTS idx_created ON memories(created_at);
@@ -85,6 +86,10 @@ const MIGRATIONS = [
85
86
  "ALTER TABLE memories ADD COLUMN min_role TEXT DEFAULT 'worker'",
86
87
  "ALTER TABLE memories ADD COLUMN obsolete INTEGER DEFAULT 0",
87
88
  "ALTER TABLE memories ADD COLUMN favorite INTEGER DEFAULT 0",
89
+ "ALTER TABLE memories ADD COLUMN title TEXT",
90
+ "ALTER TABLE memory_nodes ADD COLUMN title TEXT",
91
+ "ALTER TABLE memories ADD COLUMN irrelevant INTEGER DEFAULT 0",
92
+ "ALTER TABLE memory_nodes ADD COLUMN favorite INTEGER DEFAULT 0",
88
93
  ];
89
94
  // ---- HmemStore class ----
90
95
  export class HmemStore {
@@ -153,7 +158,7 @@ export class HmemStore {
153
158
  const seq = this.nextSeq(prefix);
154
159
  const rootId = `${prefix}${String(seq).padStart(4, "0")}`;
155
160
  const timestamp = new Date().toISOString();
156
- const { level1, nodes } = this.parseTree(content, rootId);
161
+ const { title, level1, nodes } = this.parseTree(content, rootId);
157
162
  if (!level1) {
158
163
  throw new Error("Content must have at least one line (Level 1).");
159
164
  }
@@ -170,18 +175,18 @@ export class HmemStore {
170
175
  }
171
176
  }
172
177
  const insertRoot = this.db.prepare(`
173
- INSERT INTO memories (id, prefix, seq, created_at, level_1, level_2, level_3, level_4, level_5, links, min_role, favorite)
174
- VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, ?)
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, ?, ?, ?)
175
180
  `);
176
181
  const insertNode = this.db.prepare(`
177
- INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, content, created_at)
178
- VALUES (?, ?, ?, ?, ?, ?, ?)
182
+ INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, title, content, created_at)
183
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
179
184
  `);
180
185
  // Run in a transaction
181
186
  this.db.transaction(() => {
182
- insertRoot.run(rootId, prefix, seq, timestamp, level1, links ? JSON.stringify(links) : null, minRole, favorite ? 1 : 0);
187
+ insertRoot.run(rootId, prefix, seq, timestamp, title, level1, links ? JSON.stringify(links) : null, minRole, favorite ? 1 : 0);
183
188
  for (const node of nodes) {
184
- insertNode.run(node.id, node.parent_id, rootId, node.depth, node.seq, node.content, timestamp);
189
+ insertNode.run(node.id, node.parent_id, rootId, node.depth, node.seq, node.title, node.content, timestamp);
185
190
  }
186
191
  })();
187
192
  return { id: rootId, timestamp };
@@ -206,8 +211,14 @@ export class HmemStore {
206
211
  if (!row)
207
212
  return [];
208
213
  this.bumpNodeAccess(opts.id);
209
- const children = this.fetchChildren(opts.id);
210
- return [this.nodeToEntry(this.rowToNode(row), children)];
214
+ const nodeDepth = row.depth ?? 2;
215
+ // expand: fetch requested depth + 1 extra level (for boundary titles)
216
+ const expandDepth = opts.expand ? (opts.depth || 5) + 1 : nodeDepth + 1;
217
+ const children = this.fetchChildrenDeep(opts.id, nodeDepth + 1, expandDepth);
218
+ const entry = this.nodeToEntry(this.rowToNode(row), children);
219
+ if (opts.expand)
220
+ entry.expanded = true;
221
+ return [entry];
211
222
  }
212
223
  else {
213
224
  // Root ID — fetch from memories
@@ -215,30 +226,53 @@ export class HmemStore {
215
226
  const row = this.db.prepare(sql).get(opts.id, ...roleFilter.params);
216
227
  if (!row)
217
228
  return [];
218
- this.bumpAccess(opts.id);
219
- const children = this.fetchChildren(opts.id);
220
- const entry = this.rowToEntry(row, children);
221
- // Auto-resolve links with depth control and cycle detection
222
- const linkDepth = opts.resolveLinks === false ? 0 : (opts.linkDepth ?? 1);
223
- if (linkDepth > 0 && entry.links && entry.links.length > 0) {
224
- const visited = opts._visitedLinks ?? new Set();
225
- visited.add(opts.id);
226
- entry.linkedEntries = entry.links.flatMap(linkId => {
227
- if (visited.has(linkId))
228
- return []; // cycle detected — skip
229
- try {
230
- return this.read({
231
- id: linkId,
232
- agentRole: opts.agentRole,
233
- linkDepth: linkDepth - 1,
234
- _visitedLinks: visited,
235
- });
229
+ // ── Obsolete chain resolution ──
230
+ const shouldFollow = opts.followObsolete !== false; // default: true
231
+ if (shouldFollow && row.obsolete === 1) {
232
+ const { finalId, chain } = this.resolveObsoleteChain(opts.id);
233
+ if (chain.length > 1) {
234
+ // Chain resolved return final entry (or full path)
235
+ if (opts.showObsoletePath) {
236
+ // Return ALL entries in the chain
237
+ const entries = [];
238
+ for (const chainId of chain) {
239
+ const chainRow = this.db.prepare(sql).get(chainId, ...roleFilter.params);
240
+ if (!chainRow)
241
+ continue;
242
+ const children = this.fetchChildren(chainId);
243
+ const entry = this.rowToEntry(chainRow, children);
244
+ entry.obsoleteChain = chain;
245
+ entries.push(entry);
246
+ }
247
+ // Bump access on the final (valid) entry only
248
+ this.bumpAccess(finalId);
249
+ return entries;
236
250
  }
237
- catch {
238
- return [];
251
+ else {
252
+ // Return ONLY the final valid entry
253
+ this.bumpAccess(finalId);
254
+ const finalRow = this.db.prepare(sql).get(finalId, ...roleFilter.params);
255
+ if (!finalRow)
256
+ return []; // correction target inaccessible
257
+ const children = this.fetchChildren(finalId);
258
+ const entry = this.rowToEntry(finalRow, children);
259
+ entry.obsoleteChain = chain;
260
+ // Resolve links on the final entry
261
+ this.resolveEntryLinks(entry, opts);
262
+ return [entry];
239
263
  }
240
- }).filter(e => !e.obsolete);
264
+ }
265
+ // chain.length <= 1: no correction found, fall through to normal behavior
241
266
  }
267
+ this.bumpAccess(opts.id);
268
+ // expand: fetch requested depth + 1 extra level (for boundary titles)
269
+ const expandDepth = opts.expand ? (opts.depth || 5) + 1 : 2;
270
+ const children = this.fetchChildrenDeep(opts.id, 2, expandDepth);
271
+ const entry = this.rowToEntry(row, children);
272
+ if (opts.expand)
273
+ entry.expanded = true;
274
+ // Auto-resolve links
275
+ this.resolveEntryLinks(entry, opts);
242
276
  return [entry];
243
277
  }
244
278
  }
@@ -343,67 +377,20 @@ export class HmemStore {
343
377
  for (const row of rows)
344
378
  this.bumpAccess(row.id);
345
379
  }
346
- // Dispatch: V1 (legacy) or V2 (new default)
347
- if (opts.recentDepthTiers) {
348
- return this.readBulkV1(rows, opts);
349
- }
350
380
  return this.readBulkV2(rows, opts);
351
381
  }
352
- /**
353
- * V1 bulk-read algorithm (legacy): recency gradient with depth tiers.
354
- * Kept for backward compatibility when recentDepthTiers is explicitly passed.
355
- */
356
- readBulkV1(rows, opts) {
357
- const tiers = opts.recentDepthTiers ?? this.cfg.recentDepthTiers;
358
- // Identify top-N entries by access_count ("organic favorites")
359
- const topN = this.cfg.accessCountTopN ?? 5;
360
- const topAccessIds = topN > 0
361
- ? new Set([...rows]
362
- .filter(r => r.access_count > 0)
363
- .sort((a, b) => b.access_count - a.access_count)
364
- .slice(0, topN)
365
- .map(r => r.id))
366
- : new Set();
367
- return rows.map((r, i) => {
368
- let depth = resolveDepthForPosition(i, tiers);
369
- let promoted;
370
- if (r.favorite === 1) {
371
- promoted = "favorite";
372
- if (depth < 2)
373
- depth = 2;
374
- }
375
- else if (topAccessIds.has(r.id)) {
376
- promoted = "access";
377
- if (depth < 2)
378
- depth = 2;
379
- }
380
- let children;
381
- let hiddenChildrenCount;
382
- if (depth >= 2) {
383
- const latest = this.fetchLatestChild(r.id, depth);
384
- if (latest) {
385
- children = [latest.node];
386
- hiddenChildrenCount = latest.totalSiblings - 1;
387
- }
388
- else {
389
- hiddenChildrenCount = 0;
390
- }
391
- }
392
- const entry = this.rowToEntry(r, children);
393
- entry.promoted = promoted;
394
- entry.hiddenChildrenCount = hiddenChildrenCount;
395
- return entry;
396
- });
397
- }
398
382
  /**
399
383
  * V2 bulk-read algorithm: per-prefix expansion, smart obsolete filtering,
400
384
  * expanded entries with all L2 children + links.
401
385
  */
402
386
  readBulkV2(rows, opts) {
403
387
  const v2 = this.cfg.bulkReadV2;
388
+ // Step 0: Filter out irrelevant entries (never shown in bulk reads)
389
+ const irrelevantCount = rows.filter(r => r.irrelevant === 1).length;
390
+ const activeRows = rows.filter(r => r.irrelevant !== 1);
404
391
  // Step 1: Separate obsolete from non-obsolete FIRST
405
- const obsoleteRows = rows.filter(r => r.obsolete === 1);
406
- const nonObsoleteRows = rows.filter(r => r.obsolete !== 1);
392
+ const obsoleteRows = activeRows.filter(r => r.obsolete === 1);
393
+ const nonObsoleteRows = activeRows.filter(r => r.obsolete !== 1);
407
394
  // Step 2: Group NON-OBSOLETE by prefix (obsolete must not steal expansion slots)
408
395
  const byPrefix = new Map();
409
396
  for (const r of nonObsoleteRows) {
@@ -413,46 +400,102 @@ export class HmemStore {
413
400
  else
414
401
  byPrefix.set(r.prefix, [r]);
415
402
  }
403
+ // Session cache: filter out already-seen entries completely
404
+ const suppressed = opts.suppressedIds ?? new Set();
405
+ const hasCacheActive = suppressed.size > 0;
416
406
  // Step 3: Build expansion set from non-obsolete rows
417
407
  const expandedIds = new Set();
418
- // Per prefix: top N newest + top M most-accessed
408
+ // Mode-based ratios: essentials shifts weight from newest to most-accessed
409
+ 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)
419
419
  for (const [, prefixRows] of byPrefix) {
420
- // Newest (already sorted by effective_date DESC)
421
- for (const r of prefixRows.slice(0, v2.topNewestCount)) {
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)) {
422
425
  expandedIds.add(r.id);
423
426
  }
424
- // Most-accessed
425
- const mostAccessed = [...prefixRows]
426
- .filter(r => r.access_count > 0)
427
- .sort((a, b) => b.access_count - a.access_count)
428
- .slice(0, v2.topAccessCount);
427
+ // Most-accessed: from unseen entries, excluding those already picked as newest.
428
+ // Minimum threshold: access_count >= 2 — a single access can be noise.
429
+ const mostAccessed = [...unseenRows]
430
+ .filter(r => r.access_count >= 2 && !expandedIds.has(r.id))
431
+ .sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
432
+ .slice(0, accessCount);
429
433
  for (const r of mostAccessed)
430
434
  expandedIds.add(r.id);
431
435
  }
432
- // Global: all favorites (from non-obsolete)
436
+ // Global: all unseen favorites
433
437
  for (const r of nonObsoleteRows) {
434
- if (r.favorite === 1)
438
+ if (r.favorite === 1 && !suppressed.has(r.id)) {
435
439
  expandedIds.add(r.id);
440
+ }
436
441
  }
437
- // topAccess reference for promoted marker
442
+ // topAccess reference for promoted marker (time-weighted, min 2 accesses)
438
443
  const topAccess = [...nonObsoleteRows]
439
- .filter(r => r.access_count > 0)
440
- .sort((a, b) => b.access_count - a.access_count)
444
+ .filter(r => r.access_count >= 2)
445
+ .sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
441
446
  .slice(0, v2.topAccessCount);
442
- let visibleObsolete;
443
- if (opts.showObsolete) {
444
- visibleObsolete = obsoleteRows;
445
- }
446
- else {
447
- // Keep only top N most-accessed obsolete entries ("biggest mistakes")
448
- visibleObsolete = [...obsoleteRows]
449
- .sort((a, b) => b.access_count - a.access_count)
450
- .slice(0, v2.topObsoleteCount);
451
- }
452
- const hiddenObsoleteCount = obsoleteRows.length - visibleObsolete.length;
453
- // Step 4: Only show expanded entries + visible obsolete (non-expanded are hidden from bulk output)
447
+ // Obsolete entries: only shown when explicitly requested
448
+ const visibleObsolete = opts.showObsolete ? obsoleteRows : [];
449
+ // Step 4: Show expanded entries + visible obsolete (cached entries completely hidden)
454
450
  const expandedNonObsolete = nonObsoleteRows.filter(r => expandedIds.has(r.id));
455
451
  const visibleRows = [...expandedNonObsolete, ...visibleObsolete];
452
+ const visibleIds = new Set(visibleRows.map(r => r.id));
453
+ // titles_only: V2 selection applies, but skip link resolution
454
+ if (opts.titlesOnly) {
455
+ // Bulk-fetch L2 child counts (one query for all visible entries)
456
+ const allIds = visibleRows.map(r => r.id);
457
+ const childCounts = this.bulkChildCount(allIds);
458
+ return visibleRows.map(r => {
459
+ const isExpanded = expandedIds.has(r.id);
460
+ const totalChildren = childCounts.get(r.id) ?? 0;
461
+ let children;
462
+ let hiddenCount;
463
+ 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) {
467
+ const newestSet = new Set([...allChildren]
468
+ .sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))
469
+ .slice(0, v2.topNewestCount)
470
+ .map(c => c.id));
471
+ const accessSet = new Set([...allChildren]
472
+ .filter(c => c.access_count >= 2)
473
+ .sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
474
+ .slice(0, v2.topAccessCount)
475
+ .map(c => c.id));
476
+ const selectedIds = new Set([...newestSet, ...accessSet]);
477
+ children = allChildren.filter(c => selectedIds.has(c.id));
478
+ hiddenCount = allChildren.length - children.length;
479
+ }
480
+ else {
481
+ children = allChildren;
482
+ }
483
+ }
484
+ else if (totalChildren > 0) {
485
+ hiddenCount = totalChildren;
486
+ }
487
+ const entry = this.rowToEntry(r, children);
488
+ if (r.favorite === 1)
489
+ entry.promoted = "favorite";
490
+ else if (topAccess.some(t => t.id === r.id))
491
+ entry.promoted = "access";
492
+ if (isExpanded)
493
+ entry.expanded = true;
494
+ if (hiddenCount !== undefined && hiddenCount > 0)
495
+ entry.hiddenChildrenCount = hiddenCount;
496
+ return entry;
497
+ });
498
+ }
456
499
  return visibleRows.map(r => {
457
500
  const isExpanded = expandedIds.has(r.id);
458
501
  let promoted;
@@ -462,27 +505,64 @@ export class HmemStore {
462
505
  promoted = "access";
463
506
  let children;
464
507
  let linkedEntries;
508
+ let hiddenChildrenCount;
509
+ let hiddenObsoleteLinks = 0;
510
+ let hiddenIrrelevantLinks = 0;
465
511
  if (isExpanded) {
466
- // Expanded: ALL direct L2 children + resolve links
467
- children = this.fetchChildren(r.id);
468
- // Resolve links (depth 1, no family)
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) {
515
+ const newestSet = new Set([...allChildren]
516
+ .sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))
517
+ .slice(0, v2.topNewestCount)
518
+ .map(c => c.id));
519
+ const accessSet = new Set([...allChildren]
520
+ .filter(c => c.access_count > 0)
521
+ .sort((a, b) => this.weightedAccessScore(b) - this.weightedAccessScore(a))
522
+ .slice(0, v2.topAccessCount)
523
+ .map(c => c.id));
524
+ const selectedIds = new Set([...newestSet, ...accessSet]);
525
+ children = allChildren.filter(c => selectedIds.has(c.id));
526
+ if (children.length < allChildren.length) {
527
+ hiddenChildrenCount = allChildren.length - children.length;
528
+ }
529
+ }
530
+ else {
531
+ children = allChildren;
532
+ }
533
+ // Resolve links — skip entries already visible in bulk read
469
534
  const links = r.links ? JSON.parse(r.links) : [];
470
535
  if (links.length > 0) {
471
- linkedEntries = links.flatMap(linkId => {
536
+ const allLinked = links.flatMap(linkId => {
537
+ if (visibleIds.has(linkId))
538
+ return []; // already shown in bulk read
472
539
  try {
473
540
  return this.read({ id: linkId, resolveLinks: false, linkDepth: 0 });
474
541
  }
475
542
  catch {
476
543
  return [];
477
544
  }
478
- }).filter(e => !e.obsolete);
545
+ });
546
+ for (const e of allLinked) {
547
+ if (e.obsolete)
548
+ hiddenObsoleteLinks++;
549
+ else if (e.irrelevant)
550
+ hiddenIrrelevantLinks++;
551
+ }
552
+ linkedEntries = allLinked.filter(e => !e.obsolete && !e.irrelevant);
479
553
  }
480
554
  }
481
555
  const entry = this.rowToEntry(r, children);
482
556
  entry.promoted = promoted;
483
557
  entry.expanded = isExpanded;
558
+ if (hiddenChildrenCount !== undefined)
559
+ entry.hiddenChildrenCount = hiddenChildrenCount;
484
560
  if (linkedEntries && linkedEntries.length > 0)
485
561
  entry.linkedEntries = linkedEntries;
562
+ if (hiddenObsoleteLinks > 0)
563
+ entry.hiddenObsoleteLinks = hiddenObsoleteLinks;
564
+ if (hiddenIrrelevantLinks > 0)
565
+ entry.hiddenIrrelevantLinks = hiddenIrrelevantLinks;
486
566
  return entry;
487
567
  });
488
568
  }
@@ -570,7 +650,7 @@ export class HmemStore {
570
650
  if (key === "links" && Array.isArray(val)) {
571
651
  params.push(JSON.stringify(val));
572
652
  }
573
- else if (key === "obsolete" || key === "favorite") {
653
+ else if (key === "obsolete" || key === "favorite" || key === "irrelevant") {
574
654
  params.push(val ? 1 : 0);
575
655
  }
576
656
  else {
@@ -600,7 +680,7 @@ export class HmemStore {
600
680
  * For sub-nodes: updates node content only.
601
681
  * Does NOT modify children — use appendChildren to extend the tree.
602
682
  */
603
- updateNode(id, newContent, links, obsolete, favorite, curatorBypass) {
683
+ updateNode(id, newContent, links, obsolete, favorite, curatorBypass, irrelevant) {
604
684
  this.guardCorrupted();
605
685
  const trimmed = newContent.trim();
606
686
  if (id.includes(".")) {
@@ -612,7 +692,14 @@ export class HmemStore {
612
692
  if (trimmed.length > nodeLimit) {
613
693
  throw new Error(`Content exceeds ${nodeLimit} character limit (${trimmed.length} chars) for L${nodeRow.depth}.`);
614
694
  }
615
- const result = this.db.prepare("UPDATE memory_nodes SET content = ? WHERE id = ?").run(trimmed, id);
695
+ const sets = ["content = ?", "title = ?"];
696
+ const params = [trimmed, this.autoExtractTitle(trimmed)];
697
+ if (favorite !== undefined) {
698
+ sets.push("favorite = ?");
699
+ params.push(favorite ? 1 : 0);
700
+ }
701
+ params.push(id);
702
+ const result = this.db.prepare(`UPDATE memory_nodes SET ${sets.join(", ")} WHERE id = ?`).run(...params);
616
703
  return result.changes > 0;
617
704
  }
618
705
  else {
@@ -651,8 +738,8 @@ export class HmemStore {
651
738
  }
652
739
  }
653
740
  }
654
- const sets = ["level_1 = ?"];
655
- const params = [trimmed];
741
+ const sets = ["level_1 = ?", "title = ?"];
742
+ const params = [trimmed, this.autoExtractTitle(trimmed)];
656
743
  if (links !== undefined) {
657
744
  sets.push("links = ?");
658
745
  params.push(links.length > 0 ? JSON.stringify(links) : null);
@@ -668,6 +755,10 @@ export class HmemStore {
668
755
  sets.push("favorite = ?");
669
756
  params.push(favorite ? 1 : 0);
670
757
  }
758
+ if (irrelevant !== undefined) {
759
+ sets.push("irrelevant = ?");
760
+ params.push(irrelevant ? 1 : 0);
761
+ }
671
762
  params.push(id);
672
763
  const result = this.db.prepare(`UPDATE memories SET ${sets.join(", ")} WHERE id = ?`).run(...params);
673
764
  return result.changes > 0;
@@ -713,13 +804,13 @@ export class HmemStore {
713
804
  }
714
805
  const timestamp = new Date().toISOString();
715
806
  const insertNode = this.db.prepare(`
716
- INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, content, created_at)
717
- VALUES (?, ?, ?, ?, ?, ?, ?)
807
+ INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, title, content, created_at)
808
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
718
809
  `);
719
810
  const topLevelIds = [];
720
811
  this.db.transaction(() => {
721
812
  for (const node of nodes) {
722
- insertNode.run(node.id, node.parent_id, rootId, node.depth, node.seq, node.content, timestamp);
813
+ insertNode.run(node.id, node.parent_id, rootId, node.depth, node.seq, this.autoExtractTitle(node.content), node.content, timestamp);
723
814
  if (node.parent_id === parentId)
724
815
  topLevelIds.push(node.id);
725
816
  }
@@ -928,13 +1019,97 @@ export class HmemStore {
928
1019
  const row = this.db.prepare("SELECT MAX(seq) as maxSeq FROM memories WHERE prefix = ?").get(prefix);
929
1020
  return (row?.maxSeq || 0) + 1;
930
1021
  }
1022
+ /** Auto-resolve linked entries on an entry (extracted for reuse in chain resolution). */
1023
+ resolveEntryLinks(entry, opts) {
1024
+ const linkDepth = opts.resolveLinks === false ? 0 : (opts.linkDepth ?? 1);
1025
+ if (linkDepth > 0 && entry.links && entry.links.length > 0) {
1026
+ const visited = opts._visitedLinks ?? new Set();
1027
+ visited.add(entry.id);
1028
+ const allLinked = entry.links.flatMap(linkId => {
1029
+ if (visited.has(linkId))
1030
+ return []; // cycle detected — skip
1031
+ try {
1032
+ return this.read({
1033
+ id: linkId,
1034
+ agentRole: opts.agentRole,
1035
+ linkDepth: linkDepth - 1,
1036
+ _visitedLinks: visited,
1037
+ followObsolete: false, // don't chain-resolve inside link resolution
1038
+ });
1039
+ }
1040
+ catch {
1041
+ return [];
1042
+ }
1043
+ });
1044
+ let hiddenObsolete = 0;
1045
+ let hiddenIrrelevant = 0;
1046
+ for (const e of allLinked) {
1047
+ if (e.obsolete)
1048
+ hiddenObsolete++;
1049
+ else if (e.irrelevant)
1050
+ hiddenIrrelevant++;
1051
+ }
1052
+ entry.linkedEntries = allLinked.filter(e => !e.obsolete && !e.irrelevant);
1053
+ if (hiddenObsolete > 0)
1054
+ entry.hiddenObsoleteLinks = hiddenObsolete;
1055
+ if (hiddenIrrelevant > 0)
1056
+ entry.hiddenIrrelevantLinks = hiddenIrrelevant;
1057
+ }
1058
+ }
931
1059
  bumpAccess(id) {
932
1060
  this.db.prepare("UPDATE memories SET access_count = access_count + 1, last_accessed = ? WHERE id = ?").run(new Date().toISOString(), id);
933
1061
  }
934
1062
  bumpNodeAccess(id) {
935
1063
  this.db.prepare("UPDATE memory_nodes SET access_count = access_count + 1, last_accessed = ? WHERE id = ?").run(new Date().toISOString(), id);
936
1064
  }
1065
+ /**
1066
+ * Follow the obsolete chain from an entry to its final valid correction.
1067
+ * Parses [✓ID] from level_1 of each obsolete entry and follows the chain.
1068
+ * Returns the final (non-obsolete) entry ID and the full chain of IDs traversed.
1069
+ */
1070
+ resolveObsoleteChain(id) {
1071
+ const chain = [id];
1072
+ let currentId = id;
1073
+ const visited = new Set();
1074
+ for (let i = 0; i < 10; i++) { // max 10 hops
1075
+ visited.add(currentId);
1076
+ const row = this.db.prepare("SELECT id, level_1, obsolete FROM memories WHERE id = ?").get(currentId);
1077
+ if (!row || !row.obsolete)
1078
+ break; // not obsolete or not found → stop
1079
+ // Parse [✓ID] from level_1
1080
+ const match = row.level_1?.match(/\[✓([A-Z]\d{4}(?:\.\d+)*)\]/);
1081
+ if (!match)
1082
+ break; // no correction reference → stop
1083
+ const nextId = match[1];
1084
+ if (visited.has(nextId))
1085
+ break; // cycle detected → stop
1086
+ chain.push(nextId);
1087
+ currentId = nextId;
1088
+ }
1089
+ return { finalId: currentId, chain };
1090
+ }
937
1091
  /** Fetch direct children of a node (root or compound), including their grandchild counts. */
1092
+ /** Bulk-fetch direct child counts for multiple parent IDs in one query. */
1093
+ bulkChildCount(parentIds) {
1094
+ if (parentIds.length === 0)
1095
+ return new Map();
1096
+ 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);
1098
+ const map = new Map();
1099
+ for (const r of rows)
1100
+ map.set(r.parent_id, r.cnt);
1101
+ return map;
1102
+ }
1103
+ /**
1104
+ * Time-weighted access score: newer entries with fewer accesses can outrank
1105
+ * older entries with more accesses. Uses logarithmic age decay:
1106
+ * score = access_count / log2(age_in_days + 2)
1107
+ */
1108
+ weightedAccessScore(row) {
1109
+ const ageMs = Date.now() - new Date(row.created_at).getTime();
1110
+ const ageDays = Math.max(ageMs / 86_400_000, 0);
1111
+ return (row.access_count || 0) / Math.log2(ageDays + 2);
1112
+ }
938
1113
  fetchChildren(parentId) {
939
1114
  return this.fetchChildrenDeep(parentId, 2, 2);
940
1115
  }
@@ -978,10 +1153,12 @@ export class HmemStore {
978
1153
  root_id: row.root_id,
979
1154
  depth: row.depth,
980
1155
  seq: row.seq,
1156
+ title: row.title ?? this.autoExtractTitle(row.content),
981
1157
  content: row.content,
982
1158
  created_at: row.created_at,
983
1159
  access_count: row.access_count || 0,
984
1160
  last_accessed: row.last_accessed || null,
1161
+ favorite: row.favorite === 1 ? true : undefined,
985
1162
  child_count: childCount,
986
1163
  };
987
1164
  }
@@ -991,6 +1168,7 @@ export class HmemStore {
991
1168
  prefix: row.prefix,
992
1169
  seq: row.seq,
993
1170
  created_at: row.created_at,
1171
+ title: row.title ?? this.autoExtractTitle(row.level_1),
994
1172
  level_1: row.level_1,
995
1173
  level_2: null, // always null post-migration
996
1174
  level_3: null,
@@ -1002,6 +1180,7 @@ export class HmemStore {
1002
1180
  min_role: row.min_role || "worker",
1003
1181
  obsolete: row.obsolete === 1,
1004
1182
  favorite: row.favorite === 1,
1183
+ irrelevant: row.irrelevant === 1,
1005
1184
  children,
1006
1185
  };
1007
1186
  }
@@ -1016,6 +1195,7 @@ export class HmemStore {
1016
1195
  prefix: node.root_id.match(/^([A-Z]+)/)?.[1] ?? "?",
1017
1196
  seq: node.seq,
1018
1197
  created_at: node.created_at,
1198
+ title: node.title,
1019
1199
  level_1: node.content,
1020
1200
  level_2: null,
1021
1201
  level_3: null,
@@ -1029,7 +1209,28 @@ export class HmemStore {
1029
1209
  };
1030
1210
  }
1031
1211
  /**
1032
- * Parse tab-indented content into L1 text + a list of tree nodes.
1212
+ * Auto-extract a short title from text.
1213
+ * Priority: text before " — " > word-boundary truncation > hard truncation.
1214
+ */
1215
+ autoExtractTitle(text) {
1216
+ const maxLen = this.cfg.maxTitleChars;
1217
+ const dashIdx = text.indexOf(" — ");
1218
+ if (dashIdx > 0 && dashIdx <= maxLen)
1219
+ return text.substring(0, dashIdx);
1220
+ if (text.length <= maxLen)
1221
+ return text;
1222
+ // Truncate at last word boundary before maxLen
1223
+ const lastSpace = text.lastIndexOf(" ", maxLen);
1224
+ if (lastSpace > maxLen * 0.4)
1225
+ return text.substring(0, lastSpace);
1226
+ return text.substring(0, maxLen);
1227
+ }
1228
+ /**
1229
+ * Parse tab-indented content into title + L1 text + a list of tree nodes.
1230
+ *
1231
+ * Title extraction:
1232
+ * - 2+ non-indented lines: first line = explicit title, rest = level_1
1233
+ * - 1 non-indented line: title = auto-extracted (~30 chars), level_1 = full line
1033
1234
  *
1034
1235
  * Algorithm:
1035
1236
  * - seqAtParent: Map<parentId, number> — sibling counter per parent
@@ -1047,7 +1248,7 @@ export class HmemStore {
1047
1248
  const seqAtParent = new Map();
1048
1249
  const lastIdAtDepth = new Map();
1049
1250
  const nodes = [];
1050
- let level1 = "";
1251
+ const l1Lines = [];
1051
1252
  // Auto-detect space indentation unit: use first indented line (if no tabs present)
1052
1253
  const rawLines = content.split("\n").map(l => l.trimEnd()).filter(Boolean);
1053
1254
  let spaceUnit = 4;
@@ -1078,8 +1279,7 @@ export class HmemStore {
1078
1279
  }
1079
1280
  const text = trimmedEnd.trim();
1080
1281
  if (depth === 1) {
1081
- // L1 — multiple L1 lines joined (should be rare)
1082
- level1 = level1 ? level1 + " | " + text : text;
1282
+ l1Lines.push(text);
1083
1283
  continue;
1084
1284
  }
1085
1285
  // L2+: determine parent and generate compound ID
@@ -1088,9 +1288,21 @@ export class HmemStore {
1088
1288
  seqAtParent.set(parentId, seq);
1089
1289
  const nodeId = `${parentId}.${seq}`;
1090
1290
  lastIdAtDepth.set(depth, nodeId);
1091
- nodes.push({ id: nodeId, parent_id: parentId, depth, seq, content: text });
1291
+ nodes.push({ id: nodeId, parent_id: parentId, depth, seq, content: text, title: this.autoExtractTitle(text) });
1292
+ }
1293
+ // Title: first L1 line (explicit). Content: remaining L1 lines joined.
1294
+ // If only 1 L1 line: title is auto-extracted, level1 = full line.
1295
+ let title;
1296
+ let level1;
1297
+ if (l1Lines.length >= 2) {
1298
+ title = l1Lines[0];
1299
+ level1 = l1Lines.slice(1).join(" | ");
1300
+ }
1301
+ else {
1302
+ level1 = l1Lines[0] ?? "";
1303
+ title = this.autoExtractTitle(level1);
1092
1304
  }
1093
- return { level1, nodes };
1305
+ return { title, level1, nodes };
1094
1306
  }
1095
1307
  /**
1096
1308
  * Parse tab-indented content relative to a parent node.