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.
- package/README.md +161 -205
- package/dist/cli-checkpoint.d.ts +16 -0
- package/dist/cli-checkpoint.js +233 -0
- package/dist/cli-checkpoint.js.map +1 -0
- package/dist/cli-context-inject.d.ts +19 -0
- package/dist/cli-context-inject.js +77 -0
- package/dist/cli-context-inject.js.map +1 -0
- 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 +148 -10
- package/dist/cli-init.js.map +1 -1
- package/dist/cli-log-exchange.js +87 -23
- 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 +30 -2
- package/dist/cli.js.map +1 -1
- package/dist/hmem-config.d.ts +31 -0
- package/dist/hmem-config.js +76 -12
- package/dist/hmem-config.js.map +1 -1
- package/dist/hmem-store.d.ts +62 -1
- package/dist/hmem-store.js +364 -46
- package/dist/hmem-store.js.map +1 -1
- package/dist/mcp-server.js +405 -99
- package/dist/mcp-server.js.map +1 -1
- package/dist/session-cache.d.ts +11 -0
- package/dist/session-cache.js +25 -0
- package/dist/session-cache.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 +75 -0
- package/skills/hmem-write/SKILL.md +113 -61
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;
|
|
@@ -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
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
1515
|
-
|
|
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
|
|
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
|
-
|
|
1679
|
+
titleLines.push(line);
|
|
1526
1680
|
}
|
|
1527
1681
|
}
|
|
1528
|
-
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);
|
|
1529
1687
|
const l1Limit = this.cfg.maxCharsPerLevel[0];
|
|
1530
|
-
|
|
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 (
|
|
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
|
-
//
|
|
1788
|
+
// No flag updates — but 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
|
|
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,
|
|
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
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
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;
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2460
|
-
|
|
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 (
|
|
2464
|
-
title =
|
|
2465
|
-
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(" | ");
|
|
2466
2762
|
}
|
|
2467
2763
|
else {
|
|
2468
|
-
level1 =
|
|
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
|
-
|
|
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
|
}
|