hmem-mcp 5.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 (43) hide show
  1. package/README.md +161 -214
  2. package/dist/cli-checkpoint.js +102 -40
  3. package/dist/cli-checkpoint.js.map +1 -1
  4. package/dist/cli-context-inject.d.ts +7 -6
  5. package/dist/cli-context-inject.js +27 -130
  6. package/dist/cli-context-inject.js.map +1 -1
  7. package/dist/cli-env.d.ts +16 -0
  8. package/dist/cli-env.js +40 -0
  9. package/dist/cli-env.js.map +1 -0
  10. package/dist/cli-hook-startup.d.ts +20 -0
  11. package/dist/cli-hook-startup.js +101 -0
  12. package/dist/cli-hook-startup.js.map +1 -0
  13. package/dist/cli-init.js +97 -188
  14. package/dist/cli-init.js.map +1 -1
  15. package/dist/cli-log-exchange.js +63 -3
  16. package/dist/cli-log-exchange.js.map +1 -1
  17. package/dist/cli-statusline.d.ts +14 -0
  18. package/dist/cli-statusline.js +172 -0
  19. package/dist/cli-statusline.js.map +1 -0
  20. package/dist/cli.js +18 -2
  21. package/dist/cli.js.map +1 -1
  22. package/dist/hmem-config.d.ts +10 -0
  23. package/dist/hmem-config.js +63 -13
  24. package/dist/hmem-config.js.map +1 -1
  25. package/dist/hmem-store.d.ts +30 -1
  26. package/dist/hmem-store.js +219 -48
  27. package/dist/hmem-store.js.map +1 -1
  28. package/dist/mcp-server.js +202 -75
  29. package/dist/mcp-server.js.map +1 -1
  30. package/package.json +1 -1
  31. package/scripts/autoresearch-nightly.sh +84 -0
  32. package/scripts/hmem-statusline.sh +4 -0
  33. package/skills/hmem-config/SKILL.md +112 -147
  34. package/skills/hmem-curate/SKILL.md +56 -6
  35. package/skills/hmem-new-project/SKILL.md +164 -0
  36. package/skills/hmem-read/SKILL.md +174 -146
  37. package/skills/hmem-release/SKILL.md +141 -0
  38. package/skills/hmem-self-curate/SKILL.md +49 -7
  39. package/skills/hmem-setup/SKILL.md +169 -87
  40. package/skills/hmem-sync-setup/SKILL.md +16 -3
  41. package/skills/hmem-update/SKILL.md +254 -0
  42. package/skills/hmem-wipe/SKILL.md +47 -21
  43. package/skills/hmem-write/SKILL.md +38 -14
@@ -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;
@@ -1478,16 +1496,24 @@ export class HmemStore {
1478
1496
  * Exchange structure: L2 = title, L4 (X.1) = user message, L5 (X.1.1) = agent response.
1479
1497
  * Returns newest first.
1480
1498
  */
1481
- getOEntryExchanges(oEntryId, limit) {
1499
+ getOEntryExchanges(oEntryId, limit, skipSkillDialogs = false) {
1482
1500
  if (limit <= 0)
1483
1501
  return [];
1484
1502
  // Get the last N L2 nodes (exchanges) by seq DESC
1485
- const l2Nodes = this.db.prepare(`SELECT id, seq FROM memory_nodes WHERE root_id = ? AND depth = 2 ORDER BY seq DESC LIMIT ?`).all(oEntryId, limit);
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);
1486
1511
  const exchanges = [];
1487
1512
  for (const l2 of l2Nodes) {
1488
1513
  const l4 = this.db.prepare(`SELECT content FROM memory_nodes WHERE parent_id = ? AND depth = 4 LIMIT 1`).get(l2.id);
1489
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");
1490
1515
  exchanges.push({
1516
+ nodeId: l2.id,
1491
1517
  seq: l2.seq,
1492
1518
  userText: l4?.content || "",
1493
1519
  agentText: l5?.content || "",
@@ -1574,8 +1600,26 @@ export class HmemStore {
1574
1600
  if (trimmed.length > nodeLimit * HmemStore.CHAR_LIMIT_TOLERANCE) {
1575
1601
  throw new Error(`Content exceeds ${nodeLimit} character limit (${trimmed.length} chars) for L${nodeRow.depth}.`);
1576
1602
  }
1577
- sets.push("content = ?", "title = ?");
1578
- 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
+ }
1579
1623
  }
1580
1624
  if (favorite !== undefined) {
1581
1625
  sets.push("favorite = ?");
@@ -1618,41 +1662,43 @@ export class HmemStore {
1618
1662
  }
1619
1663
  else {
1620
1664
  // Root entry in memories
1621
- // If content has tab-indented children, split L1 from children
1622
- if (trimmed && (trimmed.includes("\n\t") || trimmed.includes("\n "))) {
1623
- // 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)
1624
1667
  const lines = trimmed.split("\n");
1625
- const l1Lines = [];
1668
+ const titleLines = [];
1669
+ const bodyLines = [];
1626
1670
  const childLines = [];
1627
1671
  for (const line of lines) {
1628
1672
  if (line.startsWith("\t") || (line.length > 0 && line[0] === " " && line.trimStart() !== line)) {
1629
1673
  childLines.push(line);
1630
1674
  }
1675
+ else if (line.startsWith("> ") || line === ">") {
1676
+ bodyLines.push(line.replace(/^> ?/, ""));
1677
+ }
1631
1678
  else {
1632
- l1Lines.push(line);
1679
+ titleLines.push(line);
1633
1680
  }
1634
1681
  }
1635
- 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);
1636
1687
  const l1Limit = this.cfg.maxCharsPerLevel[0];
1637
- 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) {
1638
1691
  throw new Error(`Level 1 exceeds ${l1Limit} character limit (${l1Text.length} chars). Keep L1 compact.`);
1639
1692
  }
1640
1693
  // Update L1
1641
1694
  const updateTs = new Date().toISOString();
1642
- const newTitle = this.autoExtractTitle(l1Text);
1643
1695
  this.db.prepare("UPDATE memories SET level_1 = ?, title = ?, updated_at = ? WHERE id = ?")
1644
1696
  .run(l1Text, newTitle, updateTs, id);
1645
1697
  // Append children via appendChildren (handles seq numbering correctly)
1646
- if (childLines.length > 0) {
1698
+ if (hasChildren) {
1647
1699
  this.appendChildren(id, childLines.join("\n"));
1648
1700
  }
1649
1701
  }
1650
- else if (trimmed) {
1651
- const l1Limit = this.cfg.maxCharsPerLevel[0];
1652
- if (trimmed.length > l1Limit * HmemStore.CHAR_LIMIT_TOLERANCE) {
1653
- throw new Error(`Level 1 exceeds ${l1Limit} character limit (${trimmed.length} chars). Keep L1 compact.`);
1654
- }
1655
- }
1656
1702
  // Obsolete enforcement: require [✓ID] correction reference
1657
1703
  if (obsolete === true && !curatorBypass) {
1658
1704
  const contentToCheck = trimmed ?? this.db.prepare("SELECT level_1 FROM memories WHERE id = ?").get(id)?.level_1 ?? "";
@@ -1688,10 +1734,6 @@ export class HmemStore {
1688
1734
  }
1689
1735
  const sets = [];
1690
1736
  const params = [];
1691
- if (trimmed) {
1692
- sets.push("level_1 = ?", "title = ?");
1693
- params.push(trimmed, this.autoExtractTitle(trimmed));
1694
- }
1695
1737
  if (links !== undefined) {
1696
1738
  sets.push("links = ?");
1697
1739
  params.push(links.length > 0 ? JSON.stringify(links) : null);
@@ -1743,12 +1785,11 @@ export class HmemStore {
1743
1785
  }
1744
1786
  }
1745
1787
  if (sets.length === 0) {
1746
- // Only tags to update no SQL UPDATE needed
1788
+ // No flag updatesbut content may have been updated above (> body parsing)
1747
1789
  if (tags !== undefined) {
1748
1790
  this.setTags(id, tags.length > 0 ? this.validateTags(tags) : []);
1749
- return true;
1750
1791
  }
1751
- return false;
1792
+ return !!trimmed || tags !== undefined;
1752
1793
  }
1753
1794
  sets.push("updated_at = ?");
1754
1795
  params.push(new Date().toISOString());
@@ -1807,7 +1848,7 @@ export class HmemStore {
1807
1848
  const topLevelIds = [];
1808
1849
  this.db.transaction(() => {
1809
1850
  for (const node of nodes) {
1810
- 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);
1811
1852
  if (node.parent_id === parentId)
1812
1853
  topLevelIds.push(node.id);
1813
1854
  }
@@ -1838,11 +1879,11 @@ export class HmemStore {
1838
1879
  const parentIsRoot = !parentId.includes(".");
1839
1880
  const rootId = parentIsRoot ? parentId : parentId.split(".")[0];
1840
1881
  const timestamp = new Date().toISOString();
1841
- // Find next available seq
1842
- const maxSeq = parentIsRoot
1843
- ? this.db.prepare("SELECT MAX(seq) as m FROM memory_nodes WHERE parent_id = ? AND depth = 2").get(parentId)?.m ?? 0
1844
- : this.db.prepare("SELECT MAX(seq) as m FROM memory_nodes WHERE parent_id = ?").get(parentId)?.m ?? 0;
1845
- 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;
1846
1887
  const title = this.autoExtractTitle(userText.split("\n")[0].replace(/[<>\[\]]/g, ""));
1847
1888
  const l2Id = `${parentId}.${seq}`;
1848
1889
  const l4Id = `${l2Id}.1`;
@@ -1856,6 +1897,38 @@ export class HmemStore {
1856
1897
  })();
1857
1898
  return { id: l2Id };
1858
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
+ }
1859
1932
  /**
1860
1933
  * Bump access_count on a root entry or node.
1861
1934
  * Returns true if the entry was found and bumped.
@@ -1921,6 +1994,20 @@ export class HmemStore {
1921
1994
  insert.run(entryId, tag);
1922
1995
  }
1923
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
+ }
1924
2011
  /** Get tags for a single entry/node. */
1925
2012
  fetchTags(entryId) {
1926
2013
  return this.db.prepare("SELECT tag FROM memory_tags WHERE entry_id = ? ORDER BY tag").all(entryId)
@@ -2283,9 +2370,11 @@ export class HmemStore {
2283
2370
  const links = row.links ? JSON.parse(row.links) : [];
2284
2371
  if (links.includes(activeProject.id))
2285
2372
  return row.id;
2286
- // Project mismatch — deactivate old O-entry, create new one below
2287
- this.db.prepare("UPDATE memories SET active = 0, updated_at = ? WHERE id = ?")
2288
- .run(new Date().toISOString(), 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);
2289
2378
  }
2290
2379
  else {
2291
2380
  return row.id; // No active project — keep using current O-entry
@@ -2328,7 +2417,31 @@ export class HmemStore {
2328
2417
  return row?.id ?? null;
2329
2418
  }
2330
2419
  bumpAccess(id) {
2331
- 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;
2332
2445
  }
2333
2446
  bumpNodeAccess(id) {
2334
2447
  this.db.prepare("UPDATE memory_nodes SET access_count = access_count + 1, last_accessed = ? WHERE id = ?").run(new Date().toISOString(), id);
@@ -2526,6 +2639,7 @@ export class HmemStore {
2526
2639
  * Priority: text before " — " > word-boundary truncation > hard truncation.
2527
2640
  */
2528
2641
  autoExtractTitle(text) {
2642
+ text = text.replace(/[\t\r\n]/g, " ").replace(/ +/g, " ").trim();
2529
2643
  const maxLen = Math.floor(this.cfg.maxTitleChars * HmemStore.CHAR_LIMIT_TOLERANCE);
2530
2644
  const dashIdx = text.indexOf(" — ");
2531
2645
  if (dashIdx > 0 && dashIdx <= maxLen)
@@ -2561,7 +2675,8 @@ export class HmemStore {
2561
2675
  const seqAtParent = new Map();
2562
2676
  const lastIdAtDepth = new Map();
2563
2677
  const nodes = [];
2564
- const l1Lines = [];
2678
+ const l1Title = [];
2679
+ const l1Body = [];
2565
2680
  // Auto-detect space indentation unit: use first indented line (if no tabs present)
2566
2681
  const rawLines = content.split("\n").map(l => l.trimEnd()).filter(Boolean);
2567
2682
  let spaceUnit = 4;
@@ -2591,8 +2706,30 @@ export class HmemStore {
2591
2706
  depth = spaceTabs > 0 ? Math.min(spaceTabs, 4) + 1 : 1;
2592
2707
  }
2593
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(/^> ?/, "") : "";
2594
2712
  if (depth === 1) {
2595
- 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
+ }
2596
2733
  continue;
2597
2734
  }
2598
2735
  // L2+: determine parent and generate compound ID
@@ -2601,18 +2738,30 @@ export class HmemStore {
2601
2738
  seqAtParent.set(parentId, seq);
2602
2739
  const nodeId = `${parentId}.${seq}`;
2603
2740
  lastIdAtDepth.set(depth, nodeId);
2604
- 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 });
2605
2743
  }
2606
- // Title: first L1 line (explicit). Content: remaining L1 lines joined.
2607
- // 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
2608
2753
  let title;
2609
2754
  let level1;
2610
- if (l1Lines.length >= 2) {
2611
- title = l1Lines[0];
2612
- 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(" | ");
2613
2762
  }
2614
2763
  else {
2615
- level1 = l1Lines[0] ?? "";
2764
+ level1 = l1Title[0] ?? "";
2616
2765
  title = this.autoExtractTitle(level1);
2617
2766
  }
2618
2767
  return { title, level1, nodes };
@@ -2659,6 +2808,21 @@ export class HmemStore {
2659
2808
  const absDepth = parentDepth + 1 + relDepth;
2660
2809
  if (absDepth > maxAbsDepth)
2661
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
+ }
2662
2826
  const myParentId = relDepth === 0
2663
2827
  ? parentId
2664
2828
  : (lastIdAtRelDepth.get(relDepth - 1) ?? parentId);
@@ -2666,7 +2830,14 @@ export class HmemStore {
2666
2830
  seqAtParent.set(myParentId, seq);
2667
2831
  const nodeId = `${myParentId}.${seq}`;
2668
2832
  lastIdAtRelDepth.set(relDepth, nodeId);
2669
- 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
+ }
2670
2841
  }
2671
2842
  return nodes;
2672
2843
  }