hmem-mcp 4.0.0 → 5.1.21

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.
Files changed (47) hide show
  1. package/README.md +161 -205
  2. package/dist/cli-checkpoint.d.ts +16 -0
  3. package/dist/cli-checkpoint.js +233 -0
  4. package/dist/cli-checkpoint.js.map +1 -0
  5. package/dist/cli-context-inject.d.ts +19 -0
  6. package/dist/cli-context-inject.js +77 -0
  7. package/dist/cli-context-inject.js.map +1 -0
  8. package/dist/cli-env.d.ts +16 -0
  9. package/dist/cli-env.js +40 -0
  10. package/dist/cli-env.js.map +1 -0
  11. package/dist/cli-hook-startup.d.ts +20 -0
  12. package/dist/cli-hook-startup.js +101 -0
  13. package/dist/cli-hook-startup.js.map +1 -0
  14. package/dist/cli-init.js +148 -10
  15. package/dist/cli-init.js.map +1 -1
  16. package/dist/cli-log-exchange.js +87 -23
  17. package/dist/cli-log-exchange.js.map +1 -1
  18. package/dist/cli-statusline.d.ts +14 -0
  19. package/dist/cli-statusline.js +172 -0
  20. package/dist/cli-statusline.js.map +1 -0
  21. package/dist/cli.js +30 -2
  22. package/dist/cli.js.map +1 -1
  23. package/dist/hmem-config.d.ts +31 -0
  24. package/dist/hmem-config.js +76 -12
  25. package/dist/hmem-config.js.map +1 -1
  26. package/dist/hmem-store.d.ts +62 -1
  27. package/dist/hmem-store.js +364 -46
  28. package/dist/hmem-store.js.map +1 -1
  29. package/dist/mcp-server.js +405 -99
  30. package/dist/mcp-server.js.map +1 -1
  31. package/dist/session-cache.d.ts +11 -0
  32. package/dist/session-cache.js +25 -0
  33. package/dist/session-cache.js.map +1 -1
  34. package/package.json +1 -1
  35. package/scripts/autoresearch-nightly.sh +84 -0
  36. package/scripts/hmem-statusline.sh +4 -0
  37. package/skills/hmem-config/SKILL.md +112 -147
  38. package/skills/hmem-curate/SKILL.md +56 -6
  39. package/skills/hmem-new-project/SKILL.md +164 -0
  40. package/skills/hmem-read/SKILL.md +174 -146
  41. package/skills/hmem-release/SKILL.md +141 -0
  42. package/skills/hmem-self-curate/SKILL.md +49 -7
  43. package/skills/hmem-setup/SKILL.md +169 -87
  44. package/skills/hmem-sync-setup/SKILL.md +16 -3
  45. package/skills/hmem-update/SKILL.md +254 -0
  46. package/skills/hmem-wipe/SKILL.md +75 -0
  47. package/skills/hmem-write/SKILL.md +113 -61
@@ -159,6 +159,8 @@ const MIGRATIONS = [
159
159
  "ALTER TABLE memory_nodes ADD COLUMN updated_at TEXT",
160
160
  // Active flag: marks entries as currently relevant — non-active entries in same prefix shown title-only
161
161
  "ALTER TABLE memories ADD COLUMN active INTEGER DEFAULT 0",
162
+ // Links: JSON array of related entry IDs (cross-references between entries)
163
+ "ALTER TABLE memories ADD COLUMN links TEXT",
162
164
  ];
163
165
  // ---- HmemStore class ----
164
166
  export class HmemStore {
@@ -241,8 +243,10 @@ export class HmemStore {
241
243
  }
242
244
  const l1Limit = this.cfg.maxCharsPerLevel[0];
243
245
  const t = HmemStore.CHAR_LIMIT_TOLERANCE;
244
- if (level1.length > l1Limit * t) {
245
- throw new Error(`Level 1 exceeds ${l1Limit} character limit (${level1.length} chars). Keep L1 compact.`);
246
+ // Only check title length for the L1 limit — body lines (>) are stored separately
247
+ // and hidden in listings, so they don't affect display compactness
248
+ if (title.length > l1Limit * t) {
249
+ throw new Error(`Level 1 title exceeds ${l1Limit} character limit (${title.length} chars). Keep the title compact and move detail to body lines (> prefix) or L2 children.`);
246
250
  }
247
251
  for (const node of nodes) {
248
252
  // depth 2-5 → index 1-4
@@ -693,6 +697,20 @@ export class HmemStore {
693
697
  */
694
698
  readBulkV2(rows, opts) {
695
699
  const v2 = this.cfg.bulkReadV2;
700
+ // Direct results mode: bypass V2 selection, project gate, session cache.
701
+ // Used for explicit filters (after, before, prefix, tag, stale_days) where the user
702
+ // expects ALL matching rows, not a curated V2 subset.
703
+ if (opts.directResults) {
704
+ const visibleRows = rows.filter(r => r.irrelevant !== 1);
705
+ const entries = visibleRows.map(r => {
706
+ const children = this.fetchChildren(r.id).filter(c => !c.irrelevant);
707
+ const entry = this.rowToEntry(r, children);
708
+ entry.expanded = true;
709
+ return entry;
710
+ });
711
+ this.assignBulkTags(entries);
712
+ return entries;
713
+ }
696
714
  // Step 0: Filter out irrelevant entries (never shown in bulk reads)
697
715
  // O-prefix excluded from unfiltered bulk reads (but shown when explicitly requested via prefix="O")
698
716
  const explicitPrefix = !!opts.prefix;
@@ -757,6 +775,66 @@ export class HmemStore {
757
775
  }
758
776
  }
759
777
  }
778
+ // Step 0.7: Active entry context injection — when a T/P/D-entry is active, find E/L entries
779
+ // with weighted tag overlap and promote them to expanded (title + children visible).
780
+ // Uses same weighted scoring as findRelatedCombined: rare(≤5)=3, medium(6-20)=2, common(>20)=1.
781
+ // Minimum score threshold: 4 (e.g. 2 medium tags, or 1 rare + 1 common).
782
+ // Example: Active D-entry about SQL → agent sees SQL-related errors and lessons automatically.
783
+ const taskPromotedIds = new Set();
784
+ {
785
+ const contextPrefixes = new Set(["T", "P", "D"]);
786
+ const activeTEntries = activeRows.filter(r => contextPrefixes.has(r.prefix) && r.active === 1);
787
+ if (activeTEntries.length > 0) {
788
+ // Collect tags from all active tasks (root + children)
789
+ const taskTags = new Set();
790
+ for (const te of activeTEntries) {
791
+ const allIds = [te.id, ...this.db.prepare("SELECT id FROM memory_nodes WHERE root_id = ?").all(te.id).map(r => r.id)];
792
+ const tagMap = this.fetchTagsBulk(allIds);
793
+ for (const tags of tagMap.values()) {
794
+ if (tags)
795
+ tags.forEach(t => taskTags.add(t));
796
+ }
797
+ }
798
+ if (taskTags.size > 0) {
799
+ // Get global tag frequencies for weighting
800
+ const tagFreqs = new Map();
801
+ const freqRows = this.db.prepare("SELECT tag, COUNT(DISTINCT entry_id) as freq FROM memory_tags GROUP BY tag").all();
802
+ for (const r of freqRows)
803
+ tagFreqs.set(r.tag, r.freq);
804
+ // Score E/L entries by weighted tag overlap
805
+ const targetPrefixes = new Set(["E", "L"]);
806
+ const candidates = activeRows.filter(r => targetPrefixes.has(r.prefix) && r.obsolete !== 1 && r.irrelevant !== 1);
807
+ for (const c of candidates) {
808
+ const cTags = new Set(this.fetchTags(c.id));
809
+ // Also include child tags
810
+ const childNodes = this.db.prepare("SELECT id FROM memory_nodes WHERE root_id = ?").all(c.id);
811
+ const childTagMap = childNodes.length > 0 ? this.fetchTagsBulk(childNodes.map(n => n.id)) : new Map();
812
+ for (const tags of childTagMap.values()) {
813
+ if (tags)
814
+ tags.forEach(t => cTags.add(t));
815
+ }
816
+ // Calculate weighted score
817
+ let score = 0;
818
+ for (const t of cTags) {
819
+ if (!taskTags.has(t))
820
+ continue;
821
+ const freq = tagFreqs.get(t) ?? 999;
822
+ if (freq <= 5)
823
+ score += 3; // rare
824
+ else if (freq <= 20)
825
+ score += 2; // medium
826
+ else
827
+ score += 1; // common
828
+ }
829
+ if (score >= 4) {
830
+ taskPromotedIds.add(c.id);
831
+ // Un-suppress if project filtering suppressed it
832
+ relatedSuppressed.delete(c.id);
833
+ }
834
+ }
835
+ }
836
+ }
837
+ }
760
838
  // Step 1: Separate obsolete from non-obsolete FIRST
761
839
  const obsoleteRows = activeRows.filter(r => r.obsolete === 1);
762
840
  const nonObsoleteRows = activeRows.filter(r => r.obsolete !== 1);
@@ -849,6 +927,11 @@ export class HmemStore {
849
927
  expandedIds.add(r.id);
850
928
  }
851
929
  }
930
+ // Task-promoted: E/L entries relevant to active tasks (weighted tag scoring)
931
+ for (const id of taskPromotedIds) {
932
+ if (!hidden.has(id))
933
+ expandedIds.add(id);
934
+ }
852
935
  // Top-subnode: entries with the most sub-nodes (by count) always expanded
853
936
  const topSubnodeCount = v2.topSubnodeCount ?? 3;
854
937
  const topSubnodeIds = new Set();
@@ -951,6 +1034,8 @@ export class HmemStore {
951
1034
  promoted = "access";
952
1035
  else if (topSubnodeIds.has(r.id))
953
1036
  promoted = "subnode";
1037
+ else if (taskPromotedIds.has(r.id))
1038
+ promoted = "task";
954
1039
  let children;
955
1040
  let linkedEntries;
956
1041
  let hiddenChildrenCount;
@@ -1389,6 +1474,54 @@ export class HmemStore {
1389
1474
  })();
1390
1475
  return result;
1391
1476
  }
1477
+ /**
1478
+ * Get the most recent O-entries (session logs), optionally filtered by project link.
1479
+ * Returns entries ordered by created_at DESC (newest first).
1480
+ */
1481
+ getRecentOEntries(limit, linkedTo) {
1482
+ if (limit <= 0)
1483
+ return [];
1484
+ if (linkedTo) {
1485
+ return this.db.prepare(`SELECT id, title, created_at FROM memories
1486
+ WHERE prefix = 'O' AND seq > 0 AND obsolete != 1 AND irrelevant != 1
1487
+ AND links LIKE ?
1488
+ ORDER BY created_at DESC LIMIT ?`).all(`%"${linkedTo}"%`, limit);
1489
+ }
1490
+ return this.db.prepare(`SELECT id, title, created_at FROM memories
1491
+ WHERE prefix = 'O' AND seq > 0 AND obsolete != 1 AND irrelevant != 1
1492
+ ORDER BY created_at DESC LIMIT ?`).all(limit);
1493
+ }
1494
+ /**
1495
+ * Get the last N exchanges (user message + agent response) from an O-entry.
1496
+ * Exchange structure: L2 = title, L4 (X.1) = user message, L5 (X.1.1) = agent response.
1497
+ * Returns newest first.
1498
+ */
1499
+ getOEntryExchanges(oEntryId, limit, skipSkillDialogs = false) {
1500
+ if (limit <= 0)
1501
+ return [];
1502
+ // Get the last N L2 nodes (exchanges) by seq DESC
1503
+ let l2Nodes;
1504
+ // Always exclude checkpoint-summary nodes (they're not exchanges)
1505
+ const excludeTags = ["'#checkpoint-summary'"];
1506
+ if (skipSkillDialogs)
1507
+ excludeTags.push("'#skill-dialog'");
1508
+ l2Nodes = this.db.prepare(`SELECT id, seq FROM memory_nodes WHERE root_id = ? AND depth = 2
1509
+ AND id NOT IN (SELECT entry_id FROM memory_tags WHERE tag IN (${excludeTags.join(",")}))
1510
+ ORDER BY seq DESC LIMIT ?`).all(oEntryId, limit);
1511
+ const exchanges = [];
1512
+ for (const l2 of l2Nodes) {
1513
+ const l4 = this.db.prepare(`SELECT content FROM memory_nodes WHERE parent_id = ? AND depth = 4 LIMIT 1`).get(l2.id);
1514
+ const l5 = this.db.prepare(`SELECT content FROM memory_nodes WHERE root_id = ? AND depth = 5 AND parent_id = ? LIMIT 1`).get(oEntryId, l2.id + ".1");
1515
+ exchanges.push({
1516
+ nodeId: l2.id,
1517
+ seq: l2.seq,
1518
+ userText: l4?.content || "",
1519
+ agentText: l5?.content || "",
1520
+ });
1521
+ }
1522
+ // Return in chronological order (oldest first)
1523
+ return exchanges.reverse();
1524
+ }
1392
1525
  /**
1393
1526
  * Get statistics about the memory store.
1394
1527
  */
@@ -1467,8 +1600,26 @@ export class HmemStore {
1467
1600
  if (trimmed.length > nodeLimit * HmemStore.CHAR_LIMIT_TOLERANCE) {
1468
1601
  throw new Error(`Content exceeds ${nodeLimit} character limit (${trimmed.length} chars) for L${nodeRow.depth}.`);
1469
1602
  }
1470
- sets.push("content = ?", "title = ?");
1471
- params.push(trimmed, this.autoExtractTitle(trimmed));
1603
+ // Parse > body lines: first non-> line = title, > lines = body (content)
1604
+ const lines = trimmed.split("\n");
1605
+ const titleLines = [];
1606
+ const bodyLines = [];
1607
+ for (const line of lines) {
1608
+ if (line.startsWith("> ") || line === ">") {
1609
+ bodyLines.push(line.replace(/^> ?/, ""));
1610
+ }
1611
+ else {
1612
+ titleLines.push(line);
1613
+ }
1614
+ }
1615
+ if (bodyLines.length > 0) {
1616
+ sets.push("content = ?", "title = ?");
1617
+ params.push(bodyLines.join("\n"), titleLines.join(" ").trim());
1618
+ }
1619
+ else {
1620
+ sets.push("content = ?", "title = ?");
1621
+ params.push(trimmed, this.autoExtractTitle(trimmed));
1622
+ }
1472
1623
  }
1473
1624
  if (favorite !== undefined) {
1474
1625
  sets.push("favorite = ?");
@@ -1511,41 +1662,43 @@ export class HmemStore {
1511
1662
  }
1512
1663
  else {
1513
1664
  // Root entry in memories
1514
- // If content has tab-indented children, split L1 from children
1515
- if (trimmed && (trimmed.includes("\n\t") || trimmed.includes("\n "))) {
1516
- // Extract L1 (non-indented lines) and child content (indented lines)
1665
+ if (trimmed) {
1666
+ // Split into title lines, body lines (> prefix), and child lines (indented)
1517
1667
  const lines = trimmed.split("\n");
1518
- const l1Lines = [];
1668
+ const titleLines = [];
1669
+ const bodyLines = [];
1519
1670
  const childLines = [];
1520
1671
  for (const line of lines) {
1521
1672
  if (line.startsWith("\t") || (line.length > 0 && line[0] === " " && line.trimStart() !== line)) {
1522
1673
  childLines.push(line);
1523
1674
  }
1675
+ else if (line.startsWith("> ") || line === ">") {
1676
+ bodyLines.push(line.replace(/^> ?/, ""));
1677
+ }
1524
1678
  else {
1525
- l1Lines.push(line);
1679
+ titleLines.push(line);
1526
1680
  }
1527
1681
  }
1528
- const l1Text = l1Lines.join(" | ").trim();
1682
+ const hasBody = bodyLines.length > 0;
1683
+ const hasChildren = childLines.length > 0;
1684
+ const titleText = titleLines.join(" | ").trim();
1685
+ const l1Text = hasBody ? bodyLines.join("\n") : titleText;
1686
+ const newTitle = hasBody ? titleText : this.autoExtractTitle(titleText);
1529
1687
  const l1Limit = this.cfg.maxCharsPerLevel[0];
1530
- if (l1Text.length > l1Limit * HmemStore.CHAR_LIMIT_TOLERANCE) {
1688
+ // For body mode, check body length against a generous limit (L2-level)
1689
+ // For non-body mode, check title against L1 limit
1690
+ if (!hasBody && l1Text.length > l1Limit * HmemStore.CHAR_LIMIT_TOLERANCE) {
1531
1691
  throw new Error(`Level 1 exceeds ${l1Limit} character limit (${l1Text.length} chars). Keep L1 compact.`);
1532
1692
  }
1533
1693
  // Update L1
1534
1694
  const updateTs = new Date().toISOString();
1535
- const newTitle = this.autoExtractTitle(l1Text);
1536
1695
  this.db.prepare("UPDATE memories SET level_1 = ?, title = ?, updated_at = ? WHERE id = ?")
1537
1696
  .run(l1Text, newTitle, updateTs, id);
1538
1697
  // Append children via appendChildren (handles seq numbering correctly)
1539
- if (childLines.length > 0) {
1698
+ if (hasChildren) {
1540
1699
  this.appendChildren(id, childLines.join("\n"));
1541
1700
  }
1542
1701
  }
1543
- else if (trimmed) {
1544
- const l1Limit = this.cfg.maxCharsPerLevel[0];
1545
- if (trimmed.length > l1Limit * HmemStore.CHAR_LIMIT_TOLERANCE) {
1546
- throw new Error(`Level 1 exceeds ${l1Limit} character limit (${trimmed.length} chars). Keep L1 compact.`);
1547
- }
1548
- }
1549
1702
  // Obsolete enforcement: require [✓ID] correction reference
1550
1703
  if (obsolete === true && !curatorBypass) {
1551
1704
  const contentToCheck = trimmed ?? this.db.prepare("SELECT level_1 FROM memories WHERE id = ?").get(id)?.level_1 ?? "";
@@ -1581,10 +1734,6 @@ export class HmemStore {
1581
1734
  }
1582
1735
  const sets = [];
1583
1736
  const params = [];
1584
- if (trimmed) {
1585
- sets.push("level_1 = ?", "title = ?");
1586
- params.push(trimmed, this.autoExtractTitle(trimmed));
1587
- }
1588
1737
  if (links !== undefined) {
1589
1738
  sets.push("links = ?");
1590
1739
  params.push(links.length > 0 ? JSON.stringify(links) : null);
@@ -1636,12 +1785,11 @@ export class HmemStore {
1636
1785
  }
1637
1786
  }
1638
1787
  if (sets.length === 0) {
1639
- // Only tags to update no SQL UPDATE needed
1788
+ // No flag updatesbut content may have been updated above (> body parsing)
1640
1789
  if (tags !== undefined) {
1641
1790
  this.setTags(id, tags.length > 0 ? this.validateTags(tags) : []);
1642
- return true;
1643
1791
  }
1644
- return false;
1792
+ return !!trimmed || tags !== undefined;
1645
1793
  }
1646
1794
  sets.push("updated_at = ?");
1647
1795
  params.push(new Date().toISOString());
@@ -1700,7 +1848,7 @@ export class HmemStore {
1700
1848
  const topLevelIds = [];
1701
1849
  this.db.transaction(() => {
1702
1850
  for (const node of nodes) {
1703
- insertNode.run(node.id, node.parent_id, rootId, node.depth, node.seq, this.autoExtractTitle(node.content), node.content, timestamp, timestamp);
1851
+ insertNode.run(node.id, node.parent_id, rootId, node.depth, node.seq, node.title, node.content, timestamp, timestamp);
1704
1852
  if (node.parent_id === parentId)
1705
1853
  topLevelIds.push(node.id);
1706
1854
  }
@@ -1731,11 +1879,11 @@ export class HmemStore {
1731
1879
  const parentIsRoot = !parentId.includes(".");
1732
1880
  const rootId = parentIsRoot ? parentId : parentId.split(".")[0];
1733
1881
  const timestamp = new Date().toISOString();
1734
- // Find next available seq
1735
- const maxSeq = parentIsRoot
1736
- ? this.db.prepare("SELECT MAX(seq) as m FROM memory_nodes WHERE parent_id = ? AND depth = 2").get(parentId)?.m ?? 0
1737
- : this.db.prepare("SELECT MAX(seq) as m FROM memory_nodes WHERE parent_id = ?").get(parentId)?.m ?? 0;
1738
- const seq = maxSeq + 1;
1882
+ // Find next available seq — scan ALL nodes under this parent (any depth) to avoid
1883
+ // ID collisions with checkpoint summaries or other nodes appended at different depths
1884
+ const maxSeqRow = this.db.prepare(`SELECT MAX(CAST(SUBSTR(id, LENGTH(?) + 1) AS INTEGER)) as m
1885
+ FROM memory_nodes WHERE id LIKE ? AND id NOT LIKE ?`).get(parentId + ".", parentId + ".%", parentId + ".%.%");
1886
+ const seq = (maxSeqRow?.m ?? 0) + 1;
1739
1887
  const title = this.autoExtractTitle(userText.split("\n")[0].replace(/[<>\[\]]/g, ""));
1740
1888
  const l2Id = `${parentId}.${seq}`;
1741
1889
  const l4Id = `${l2Id}.1`;
@@ -1749,6 +1897,38 @@ export class HmemStore {
1749
1897
  })();
1750
1898
  return { id: l2Id };
1751
1899
  }
1900
+ /**
1901
+ * Append a checkpoint summary as a tagged L2 node under an O-entry.
1902
+ * The summary sits alongside exchanges in the L2 sequence.
1903
+ * Returns the node ID.
1904
+ */
1905
+ appendCheckpointSummary(oEntryId, summaryText) {
1906
+ this.guardCorrupted();
1907
+ const timestamp = new Date().toISOString();
1908
+ // Scan all direct children IDs (any depth) to avoid collisions with exchanges
1909
+ const maxSeqRow = this.db.prepare(`SELECT MAX(CAST(SUBSTR(id, LENGTH(?) + 1) AS INTEGER)) as m
1910
+ FROM memory_nodes WHERE id LIKE ? AND id NOT LIKE ?`).get(oEntryId + ".", oEntryId + ".%", oEntryId + ".%.%");
1911
+ const seq = (maxSeqRow?.m ?? 0) + 1;
1912
+ const nodeId = `${oEntryId}.${seq}`;
1913
+ const title = this.autoExtractTitle(summaryText);
1914
+ this.db.transaction(() => {
1915
+ this.db.prepare("INSERT INTO memory_nodes (id, parent_id, root_id, depth, seq, title, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(nodeId, oEntryId, oEntryId, 2, seq, title, summaryText, timestamp, timestamp);
1916
+ this.addTag(nodeId, "#checkpoint-summary");
1917
+ this.db.prepare("UPDATE memories SET updated_at = ? WHERE id = ?").run(timestamp, oEntryId);
1918
+ })();
1919
+ return nodeId;
1920
+ }
1921
+ /**
1922
+ * Get checkpoint summaries for an O-entry, newest first.
1923
+ * Returns the summary content + the seq number (to know which exchanges it covers).
1924
+ */
1925
+ getCheckpointSummaries(oEntryId, limit = 2) {
1926
+ return this.db.prepare(`SELECT mn.id as nodeId, mn.seq, mn.content, mn.created_at
1927
+ FROM memory_nodes mn
1928
+ JOIN memory_tags mt ON mt.entry_id = mn.id AND mt.tag = '#checkpoint-summary'
1929
+ WHERE mn.root_id = ?
1930
+ ORDER BY mn.seq DESC LIMIT ?`).all(oEntryId, limit);
1931
+ }
1752
1932
  /**
1753
1933
  * Bump access_count on a root entry or node.
1754
1934
  * Returns true if the entry was found and bumped.
@@ -1814,6 +1994,20 @@ export class HmemStore {
1814
1994
  insert.run(entryId, tag);
1815
1995
  }
1816
1996
  }
1997
+ /** Add a single tag to an entry/node without removing existing tags. */
1998
+ addTag(entryId, tag) {
1999
+ this.db.prepare("INSERT OR IGNORE INTO memory_tags (entry_id, tag) VALUES (?, ?)").run(entryId, tag);
2000
+ }
2001
+ /** Find and tag untagged checkpoint summary nodes ([CP] prefix) under an O-entry. */
2002
+ tagNewCheckpointSummaries(oEntryId) {
2003
+ const nodes = this.db.prepare(`SELECT id FROM memory_nodes WHERE root_id = ?
2004
+ AND (content LIKE '[CP]%' OR title LIKE '[CP]%')
2005
+ AND id NOT IN (SELECT entry_id FROM memory_tags WHERE tag = '#checkpoint-summary')
2006
+ ORDER BY seq`).all(oEntryId);
2007
+ for (const n of nodes)
2008
+ this.addTag(n.id, "#checkpoint-summary");
2009
+ return nodes.map(n => n.id);
2010
+ }
1817
2011
  /** Get tags for a single entry/node. */
1818
2012
  fetchTags(entryId) {
1819
2013
  return this.db.prepare("SELECT tag FROM memory_tags WHERE entry_id = ? ORDER BY tag").all(entryId)
@@ -2167,11 +2361,25 @@ export class HmemStore {
2167
2361
  return this.db.prepare("SELECT COUNT(*) as n FROM memory_nodes WHERE root_id = ? AND depth = 2").get(rootId)?.n ?? 0;
2168
2362
  }
2169
2363
  getActiveO() {
2170
- const row = this.db.prepare("SELECT id FROM memories WHERE prefix = 'O' AND active = 1 AND obsolete != 1 AND irrelevant != 1 LIMIT 1").get();
2171
- if (row)
2172
- return row.id;
2173
2364
  // Find active project for context
2174
2365
  const activeProject = this.db.prepare("SELECT id, title FROM memories WHERE prefix = 'P' AND active = 1 AND obsolete != 1 LIMIT 1").get();
2366
+ const row = this.db.prepare("SELECT id, links FROM memories WHERE prefix = 'O' AND active = 1 AND obsolete != 1 AND irrelevant != 1 LIMIT 1").get();
2367
+ if (row) {
2368
+ // Check if the active O-entry matches the active project
2369
+ if (activeProject) {
2370
+ const links = row.links ? JSON.parse(row.links) : [];
2371
+ if (links.includes(activeProject.id))
2372
+ return row.id;
2373
+ // Project mismatch — deactivate old O-entry, mark irrelevant if ≤1 exchange
2374
+ const childCount = this.countDirectChildren(row.id);
2375
+ const irrelevant = childCount <= 1 ? 1 : 0;
2376
+ this.db.prepare("UPDATE memories SET active = 0, irrelevant = ?, updated_at = ? WHERE id = ?")
2377
+ .run(irrelevant, new Date().toISOString(), row.id);
2378
+ }
2379
+ else {
2380
+ return row.id; // No active project — keep using current O-entry
2381
+ }
2382
+ }
2175
2383
  const today = new Date().toISOString().substring(0, 10);
2176
2384
  const projectName = activeProject?.title?.split("|")[0]?.trim() ?? "unassigned";
2177
2385
  const tags = ["#session"];
@@ -2181,8 +2389,59 @@ export class HmemStore {
2181
2389
  .run(new Date().toISOString(), result.id);
2182
2390
  return result.id;
2183
2391
  }
2392
+ /** Get the active O-entry ID without creating one. Returns null if none active. */
2393
+ getActiveOId() {
2394
+ const row = this.db.prepare("SELECT id FROM memories WHERE prefix = 'O' AND active = 1 AND obsolete != 1 AND irrelevant != 1 LIMIT 1").get();
2395
+ return row?.id ?? null;
2396
+ }
2397
+ /** Get the active project entry. Returns null if none active. */
2398
+ getActiveProject() {
2399
+ return this.db.prepare("SELECT id, title FROM memories WHERE prefix = 'P' AND active = 1 AND obsolete != 1 LIMIT 1").get() ?? null;
2400
+ }
2401
+ /** Find a child node by content/title pattern. Returns node ID or null. */
2402
+ findChildNode(parentId, pattern, depth) {
2403
+ const depthClause = depth != null ? " AND depth = ?" : "";
2404
+ const params = [parentId, `%${pattern}%`, `%${pattern}%`];
2405
+ if (depth != null)
2406
+ params.push(depth);
2407
+ const row = this.db.prepare(`SELECT id FROM memory_nodes WHERE parent_id = ?
2408
+ AND (LOWER(content) LIKE ? OR LOWER(title) LIKE ?)${depthClause}
2409
+ LIMIT 1`).get(...params);
2410
+ return row?.id ?? null;
2411
+ }
2412
+ /** Find a child node of a root entry by content/title pattern. */
2413
+ findRootChildNode(rootId, pattern, depth) {
2414
+ const row = this.db.prepare(`SELECT id FROM memory_nodes WHERE root_id = ? AND depth = ?
2415
+ AND (LOWER(content) LIKE ? OR LOWER(title) LIKE ?)
2416
+ LIMIT 1`).get(rootId, depth, `%${pattern}%`, `%${pattern}%`);
2417
+ return row?.id ?? null;
2418
+ }
2184
2419
  bumpAccess(id) {
2185
- this.db.prepare("UPDATE memories SET access_count = access_count + 1, last_accessed = ? WHERE id = ?").run(new Date().toISOString(), id);
2420
+ // Clear irrelevant flag on explicit read if someone reads it, it matters
2421
+ this.db.prepare("UPDATE memories SET access_count = access_count + 1, last_accessed = ?, irrelevant = 0 WHERE id = ?").run(new Date().toISOString(), id);
2422
+ }
2423
+ /**
2424
+ * Auto-purge: physically delete irrelevant entries older than maxAgeDays.
2425
+ * Only deletes entries where irrelevant=1 — entries rescued by bumpAccess survive.
2426
+ * Returns the number of deleted entries.
2427
+ */
2428
+ purgeIrrelevant(maxAgeDays = 30) {
2429
+ const cutoff = new Date(Date.now() - maxAgeDays * 86400_000).toISOString();
2430
+ const rows = this.db.prepare("SELECT id FROM memories WHERE irrelevant = 1 AND updated_at < ?").all(cutoff);
2431
+ if (rows.length === 0)
2432
+ return 0;
2433
+ const deleteNodes = this.db.prepare("DELETE FROM memory_nodes WHERE root_id = ?");
2434
+ const deleteRoot = this.db.prepare("DELETE FROM memories WHERE id = ?");
2435
+ const deleteTags = this.db.prepare("DELETE FROM tags WHERE root_id = ?");
2436
+ const purge = this.db.transaction(() => {
2437
+ for (const { id } of rows) {
2438
+ deleteNodes.run(id);
2439
+ deleteTags.run(id);
2440
+ deleteRoot.run(id);
2441
+ }
2442
+ });
2443
+ purge();
2444
+ return rows.length;
2186
2445
  }
2187
2446
  bumpNodeAccess(id) {
2188
2447
  this.db.prepare("UPDATE memory_nodes SET access_count = access_count + 1, last_accessed = ? WHERE id = ?").run(new Date().toISOString(), id);
@@ -2347,6 +2606,7 @@ export class HmemStore {
2347
2606
  irrelevant: row.irrelevant === 1,
2348
2607
  active: row.active === 1,
2349
2608
  pinned: row.pinned === 1,
2609
+ updated_at: row.updated_at ?? undefined,
2350
2610
  children,
2351
2611
  };
2352
2612
  }
@@ -2379,6 +2639,7 @@ export class HmemStore {
2379
2639
  * Priority: text before " — " > word-boundary truncation > hard truncation.
2380
2640
  */
2381
2641
  autoExtractTitle(text) {
2642
+ text = text.replace(/[\t\r\n]/g, " ").replace(/ +/g, " ").trim();
2382
2643
  const maxLen = Math.floor(this.cfg.maxTitleChars * HmemStore.CHAR_LIMIT_TOLERANCE);
2383
2644
  const dashIdx = text.indexOf(" — ");
2384
2645
  if (dashIdx > 0 && dashIdx <= maxLen)
@@ -2414,7 +2675,8 @@ export class HmemStore {
2414
2675
  const seqAtParent = new Map();
2415
2676
  const lastIdAtDepth = new Map();
2416
2677
  const nodes = [];
2417
- const l1Lines = [];
2678
+ const l1Title = [];
2679
+ const l1Body = [];
2418
2680
  // Auto-detect space indentation unit: use first indented line (if no tabs present)
2419
2681
  const rawLines = content.split("\n").map(l => l.trimEnd()).filter(Boolean);
2420
2682
  let spaceUnit = 4;
@@ -2444,8 +2706,30 @@ export class HmemStore {
2444
2706
  depth = spaceTabs > 0 ? Math.min(spaceTabs, 4) + 1 : 1;
2445
2707
  }
2446
2708
  const text = trimmedEnd.trim();
2709
+ // Body line detection: "> " prefix marks body text for the preceding node
2710
+ const isBodyLine = text.startsWith("> ") || text === ">";
2711
+ const bodyText = isBodyLine ? text.replace(/^> ?/, "") : "";
2447
2712
  if (depth === 1) {
2448
- l1Lines.push(text);
2713
+ if (isBodyLine) {
2714
+ l1Body.push(bodyText);
2715
+ }
2716
+ else {
2717
+ l1Title.push(text);
2718
+ }
2719
+ continue;
2720
+ }
2721
+ if (isBodyLine) {
2722
+ // Append body to the last node at this depth
2723
+ const lastNode = nodes.length > 0 ? nodes[nodes.length - 1] : null;
2724
+ if (lastNode && lastNode.depth === depth) {
2725
+ // If content was just the title (no body yet), start fresh body
2726
+ if (lastNode.content === lastNode.title) {
2727
+ lastNode.content = bodyText;
2728
+ }
2729
+ else {
2730
+ lastNode.content += "\n" + bodyText;
2731
+ }
2732
+ }
2449
2733
  continue;
2450
2734
  }
2451
2735
  // L2+: determine parent and generate compound ID
@@ -2454,18 +2738,30 @@ export class HmemStore {
2454
2738
  seqAtParent.set(parentId, seq);
2455
2739
  const nodeId = `${parentId}.${seq}`;
2456
2740
  lastIdAtDepth.set(depth, nodeId);
2457
- nodes.push({ id: nodeId, parent_id: parentId, depth, seq, content: text, title: this.autoExtractTitle(text) });
2741
+ const sanitizedTitle = text.replace(/[\t\r\n]/g, " ").replace(/ +/g, " ").trim();
2742
+ nodes.push({ id: nodeId, parent_id: parentId, depth, seq, content: text, title: sanitizedTitle });
2458
2743
  }
2459
- // Title: first L1 line (explicit). Content: remaining L1 lines joined.
2460
- // If only 1 L1 line: title is auto-extracted, level1 = full line.
2744
+ // Backward-compatible: nodes without body lines get autoExtractTitle fallback
2745
+ for (const node of nodes) {
2746
+ if (node.content === node.title || node.content.replace(/[\t\r\n]/g, " ").replace(/ +/g, " ").trim() === node.title) {
2747
+ // No body was added — old format: content = full text, title = auto-extracted
2748
+ node.title = this.autoExtractTitle(node.content);
2749
+ }
2750
+ // else: body was added — title stays as explicit title text
2751
+ }
2752
+ // L1: first non-body line = title, body lines = level1
2461
2753
  let title;
2462
2754
  let level1;
2463
- if (l1Lines.length >= 2) {
2464
- title = l1Lines[0];
2465
- level1 = l1Lines.slice(1).join(" | ");
2755
+ if (l1Body.length > 0) {
2756
+ title = (l1Title[0] ?? "").replace(/[\t\r\n]/g, " ").replace(/ +/g, " ").trim();
2757
+ level1 = l1Body.join("\n");
2758
+ }
2759
+ else if (l1Title.length >= 2) {
2760
+ title = l1Title[0].replace(/[\t\r\n]/g, " ").replace(/ +/g, " ").trim();
2761
+ level1 = l1Title.slice(1).join(" | ");
2466
2762
  }
2467
2763
  else {
2468
- level1 = l1Lines[0] ?? "";
2764
+ level1 = l1Title[0] ?? "";
2469
2765
  title = this.autoExtractTitle(level1);
2470
2766
  }
2471
2767
  return { title, level1, nodes };
@@ -2512,6 +2808,21 @@ export class HmemStore {
2512
2808
  const absDepth = parentDepth + 1 + relDepth;
2513
2809
  if (absDepth > maxAbsDepth)
2514
2810
  continue; // silently skip beyond max depth
2811
+ // Body line detection: "> " prefix marks body text for the preceding node
2812
+ const isBodyLine = text.startsWith("> ") || text === ">";
2813
+ if (isBodyLine) {
2814
+ const bodyText = text.replace(/^> ?/, "");
2815
+ const lastNode = nodes.length > 0 ? nodes[nodes.length - 1] : null;
2816
+ if (lastNode && lastNode.depth === absDepth) {
2817
+ if (lastNode.content === lastNode.title) {
2818
+ lastNode.content = bodyText;
2819
+ }
2820
+ else {
2821
+ lastNode.content += "\n" + bodyText;
2822
+ }
2823
+ }
2824
+ continue;
2825
+ }
2515
2826
  const myParentId = relDepth === 0
2516
2827
  ? parentId
2517
2828
  : (lastIdAtRelDepth.get(relDepth - 1) ?? parentId);
@@ -2519,7 +2830,14 @@ export class HmemStore {
2519
2830
  seqAtParent.set(myParentId, seq);
2520
2831
  const nodeId = `${myParentId}.${seq}`;
2521
2832
  lastIdAtRelDepth.set(relDepth, nodeId);
2522
- nodes.push({ id: nodeId, parent_id: myParentId, depth: absDepth, seq, content: text });
2833
+ const sanitizedTitle = text.replace(/[\t\r\n]/g, " ").replace(/ +/g, " ").trim();
2834
+ nodes.push({ id: nodeId, parent_id: myParentId, depth: absDepth, seq, content: text, title: sanitizedTitle });
2835
+ }
2836
+ // Backward-compatible: nodes without body lines get autoExtractTitle fallback
2837
+ for (const node of nodes) {
2838
+ if (node.content === node.title || node.content.replace(/[\t\r\n]/g, " ").replace(/ +/g, " ").trim() === node.title) {
2839
+ node.title = this.autoExtractTitle(node.content);
2840
+ }
2523
2841
  }
2524
2842
  return nodes;
2525
2843
  }