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.
- package/README.md +161 -214
- package/dist/cli-checkpoint.js +102 -40
- package/dist/cli-checkpoint.js.map +1 -1
- package/dist/cli-context-inject.d.ts +7 -6
- package/dist/cli-context-inject.js +27 -130
- package/dist/cli-context-inject.js.map +1 -1
- package/dist/cli-env.d.ts +16 -0
- package/dist/cli-env.js +40 -0
- package/dist/cli-env.js.map +1 -0
- package/dist/cli-hook-startup.d.ts +20 -0
- package/dist/cli-hook-startup.js +101 -0
- package/dist/cli-hook-startup.js.map +1 -0
- package/dist/cli-init.js +97 -188
- package/dist/cli-init.js.map +1 -1
- package/dist/cli-log-exchange.js +63 -3
- package/dist/cli-log-exchange.js.map +1 -1
- package/dist/cli-statusline.d.ts +14 -0
- package/dist/cli-statusline.js +172 -0
- package/dist/cli-statusline.js.map +1 -0
- package/dist/cli.js +18 -2
- package/dist/cli.js.map +1 -1
- package/dist/hmem-config.d.ts +10 -0
- package/dist/hmem-config.js +63 -13
- package/dist/hmem-config.js.map +1 -1
- package/dist/hmem-store.d.ts +30 -1
- package/dist/hmem-store.js +219 -48
- package/dist/hmem-store.js.map +1 -1
- package/dist/mcp-server.js +202 -75
- package/dist/mcp-server.js.map +1 -1
- package/package.json +1 -1
- package/scripts/autoresearch-nightly.sh +84 -0
- package/scripts/hmem-statusline.sh +4 -0
- package/skills/hmem-config/SKILL.md +112 -147
- package/skills/hmem-curate/SKILL.md +56 -6
- package/skills/hmem-new-project/SKILL.md +164 -0
- package/skills/hmem-read/SKILL.md +174 -146
- package/skills/hmem-release/SKILL.md +141 -0
- package/skills/hmem-self-curate/SKILL.md +49 -7
- package/skills/hmem-setup/SKILL.md +169 -87
- package/skills/hmem-sync-setup/SKILL.md +16 -3
- package/skills/hmem-update/SKILL.md +254 -0
- package/skills/hmem-wipe/SKILL.md +47 -21
- package/skills/hmem-write/SKILL.md +38 -14
package/dist/hmem-store.js
CHANGED
|
@@ -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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1578
|
-
|
|
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
|
-
|
|
1622
|
-
|
|
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
|
|
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
|
-
|
|
1679
|
+
titleLines.push(line);
|
|
1633
1680
|
}
|
|
1634
1681
|
}
|
|
1635
|
-
const
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
1788
|
+
// No flag updates — but 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
|
|
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,
|
|
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
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
const seq =
|
|
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,
|
|
2287
|
-
|
|
2288
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2607
|
-
|
|
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 (
|
|
2611
|
-
title =
|
|
2612
|
-
level1 =
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|