hmem-mcp 6.0.3 → 6.1.1
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/dist/hmem-store.d.ts +30 -0
- package/dist/hmem-store.js +198 -13
- package/dist/hmem-store.js.map +1 -1
- package/dist/mcp-server.js +333 -32
- package/dist/mcp-server.js.map +1 -1
- package/package.json +1 -1
- package/skills/hmem-config/SKILL.md +1 -1
- package/skills/hmem-curate/SKILL.md +4 -4
- package/skills/hmem-new-project/SKILL.md +2 -2
- package/skills/hmem-read/SKILL.md +1 -1
- package/skills/hmem-self-curate/SKILL.md +2 -2
- package/skills/hmem-setup/SKILL.md +2 -2
- package/skills/hmem-update/SKILL.md +2 -2
- package/skills/hmem-write/SKILL.md +40 -17
package/dist/hmem-store.d.ts
CHANGED
|
@@ -425,8 +425,24 @@ export declare class HmemStore {
|
|
|
425
425
|
*/
|
|
426
426
|
private parseTimeWindow;
|
|
427
427
|
private nextSeq;
|
|
428
|
+
/** Read-only preview of the next root ID that write() would assign for this prefix.
|
|
429
|
+
* Used by mcp-server's id-reservation loop (multi-agent collision prevention). */
|
|
430
|
+
peekNextId(prefix: string): string;
|
|
431
|
+
/** Read-only preview of the top-level child IDs that appendChildren() would create.
|
|
432
|
+
* Used by mcp-server's sub-node reservation loop (multi-agent collision prevention).
|
|
433
|
+
* Returns the IDs of direct children only — nested grandchildren don't need separate
|
|
434
|
+
* reservation because they're parented under nodes this same call would create. */
|
|
435
|
+
peekAppendTopLevelIds(parentId: string, content: string): string[];
|
|
428
436
|
/** Clear all active markers — called at MCP server start so each session starts neutral. */
|
|
429
437
|
clearAllActive(): void;
|
|
438
|
+
/**
|
|
439
|
+
* Atomically set ONE project as the active P-entry in this agent's DB.
|
|
440
|
+
* Deactivates all other P-entries in the same .hmem file. Multi-agent isolation
|
|
441
|
+
* happens at the .hmem-file level (each agent has its own DB), so within a single
|
|
442
|
+
* file there must only ever be one active project — otherwise getActiveProject()
|
|
443
|
+
* (LIMIT 1) becomes nondeterministic and log-exchange routes to the wrong O-entry.
|
|
444
|
+
*/
|
|
445
|
+
setActiveProject(id: string): void;
|
|
430
446
|
/** Auto-resolve linked entries on an entry (extracted for reuse in chain resolution). */
|
|
431
447
|
private resolveEntryLinks;
|
|
432
448
|
/** Get child nodes created after a given ISO timestamp (for "new since last session" detection). */
|
|
@@ -571,6 +587,20 @@ export declare class HmemStore {
|
|
|
571
587
|
* Rewrites all IDs, parent_ids, root_ids, tags, and FTS rowid map entries.
|
|
572
588
|
*/
|
|
573
589
|
private _moveSubtree;
|
|
590
|
+
/**
|
|
591
|
+
* Rename an entire L2 session subtree (L2 node + all L3/L4/L5 descendants)
|
|
592
|
+
* to a new id prefix. Updates memory_nodes (id, parent_id, seq), memory_tags,
|
|
593
|
+
* and hmem_fts_rowid_map. The root-level parent of the L2 node stays at oId.
|
|
594
|
+
* Caller must ensure newId does not yet exist.
|
|
595
|
+
*/
|
|
596
|
+
private _renameL2Subtree;
|
|
597
|
+
/**
|
|
598
|
+
* Reorder L2 sessions under an O-entry so their seq matches chronological
|
|
599
|
+
* order by created_at (ascending). Uses 2-phase rename via _TMP staging IDs
|
|
600
|
+
* to avoid collisions during renumbering. Returns the number of sessions
|
|
601
|
+
* actually renamed.
|
|
602
|
+
*/
|
|
603
|
+
reorderSessionsByDate(oId: string): number;
|
|
574
604
|
/**
|
|
575
605
|
* Remove empty L2 (sessions) and L3 (batches) nodes in an O-entry.
|
|
576
606
|
*/
|
package/dist/hmem-store.js
CHANGED
|
@@ -361,6 +361,24 @@ export class HmemStore {
|
|
|
361
361
|
if (prefix === "P") {
|
|
362
362
|
this.db.prepare("UPDATE memories SET active = 1 WHERE id = ?").run(rootId);
|
|
363
363
|
}
|
|
364
|
+
// Auto-scaffold E-entries: create standard L2 structure when no children provided
|
|
365
|
+
if (prefix === "E" && nodes.length === 0) {
|
|
366
|
+
const eSchema = ["Analysis", "Possible fixes", "Fixing attempts", "Solution", "Cause", "Key Learnings"];
|
|
367
|
+
for (let i = 0; i < eSchema.length; i++) {
|
|
368
|
+
const nodeId = `${rootId}.${i + 1}`;
|
|
369
|
+
insertNode.run(nodeId, rootId, rootId, 2, i + 1, eSchema[i], eSchema[i], timestamp, timestamp);
|
|
370
|
+
}
|
|
371
|
+
// Move L1 body into .1 Analysis as content (the short description stays on L1)
|
|
372
|
+
if (level1 !== title) {
|
|
373
|
+
// level1 contains body text — move it to Analysis node
|
|
374
|
+
this.db.prepare("UPDATE memory_nodes SET content = ?, title = ? WHERE id = ?")
|
|
375
|
+
.run(level1, "Analysis", `${rootId}.1`);
|
|
376
|
+
}
|
|
377
|
+
// Auto-add #open tag on root (visible in bulk-read title line)
|
|
378
|
+
if (!validatedTags.includes("#open")) {
|
|
379
|
+
this.addTag(rootId, "#open");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
364
382
|
})();
|
|
365
383
|
return { id: rootId, timestamp };
|
|
366
384
|
}
|
|
@@ -714,7 +732,7 @@ export class HmemStore {
|
|
|
714
732
|
// Step 0.5: Detect active-prefixes — prefixes where at least one entry has active=1.
|
|
715
733
|
// Non-active entries in these prefixes are still shown (as compact titles) but don't get expansion slots.
|
|
716
734
|
// P and I prefixes ALWAYS treated as active-prefix — only expand when explicitly activated.
|
|
717
|
-
const activePrefixes = new Set(["P", "I"]);
|
|
735
|
+
const activePrefixes = new Set(["P", "I", "E"]);
|
|
718
736
|
for (const r of activeRows) {
|
|
719
737
|
if (r.active === 1)
|
|
720
738
|
activePrefixes.add(r.prefix);
|
|
@@ -1608,14 +1626,21 @@ export class HmemStore {
|
|
|
1608
1626
|
if (trimmed.length > nodeLimit * HmemStore.CHAR_LIMIT_TOLERANCE) {
|
|
1609
1627
|
throw new Error(`Content exceeds ${nodeLimit} character limit (${trimmed.length} chars) for L${nodeRow.depth}.`);
|
|
1610
1628
|
}
|
|
1611
|
-
// Parse
|
|
1629
|
+
// Parse body: "> " prefix (legacy) or blank-line separator (git-commit style)
|
|
1612
1630
|
const lines = trimmed.split("\n");
|
|
1613
1631
|
const titleLines = [];
|
|
1614
1632
|
const bodyLines = [];
|
|
1633
|
+
let bodyMode = false;
|
|
1615
1634
|
for (const line of lines) {
|
|
1616
1635
|
if (line.startsWith("> ") || line === ">") {
|
|
1617
1636
|
bodyLines.push(line.replace(/^> ?/, ""));
|
|
1618
1637
|
}
|
|
1638
|
+
else if (line === "" && titleLines.length > 0) {
|
|
1639
|
+
bodyMode = true;
|
|
1640
|
+
}
|
|
1641
|
+
else if (bodyMode) {
|
|
1642
|
+
bodyLines.push(line);
|
|
1643
|
+
}
|
|
1619
1644
|
else {
|
|
1620
1645
|
titleLines.push(line);
|
|
1621
1646
|
}
|
|
@@ -1671,18 +1696,26 @@ export class HmemStore {
|
|
|
1671
1696
|
else {
|
|
1672
1697
|
// Root entry in memories
|
|
1673
1698
|
if (trimmed) {
|
|
1674
|
-
// Split into title lines, body lines (>
|
|
1699
|
+
// Split into title lines, body lines ("> " legacy or blank-line separator), and child lines (indented)
|
|
1675
1700
|
const lines = trimmed.split("\n");
|
|
1676
1701
|
const titleLines = [];
|
|
1677
1702
|
const bodyLines = [];
|
|
1678
1703
|
const childLines = [];
|
|
1704
|
+
let bodyMode = false;
|
|
1679
1705
|
for (const line of lines) {
|
|
1680
1706
|
if (line.startsWith("\t") || (line.length > 0 && line[0] === " " && line.trimStart() !== line)) {
|
|
1681
1707
|
childLines.push(line);
|
|
1708
|
+
bodyMode = false; // indented line exits body mode
|
|
1682
1709
|
}
|
|
1683
1710
|
else if (line.startsWith("> ") || line === ">") {
|
|
1684
1711
|
bodyLines.push(line.replace(/^> ?/, ""));
|
|
1685
1712
|
}
|
|
1713
|
+
else if (line === "" && titleLines.length > 0 && childLines.length === 0) {
|
|
1714
|
+
bodyMode = true;
|
|
1715
|
+
}
|
|
1716
|
+
else if (bodyMode) {
|
|
1717
|
+
bodyLines.push(line);
|
|
1718
|
+
}
|
|
1686
1719
|
else {
|
|
1687
1720
|
titleLines.push(line);
|
|
1688
1721
|
}
|
|
@@ -2305,10 +2338,53 @@ export class HmemStore {
|
|
|
2305
2338
|
const row = this.db.prepare("SELECT MAX(seq) as maxSeq FROM memories WHERE prefix = ?").get(prefix);
|
|
2306
2339
|
return (row?.maxSeq || 0) + 1;
|
|
2307
2340
|
}
|
|
2341
|
+
/** Read-only preview of the next root ID that write() would assign for this prefix.
|
|
2342
|
+
* Used by mcp-server's id-reservation loop (multi-agent collision prevention). */
|
|
2343
|
+
peekNextId(prefix) {
|
|
2344
|
+
prefix = prefix.toUpperCase();
|
|
2345
|
+
const seq = this.nextSeq(prefix);
|
|
2346
|
+
return `${prefix}${String(seq).padStart(4, "0")}`;
|
|
2347
|
+
}
|
|
2348
|
+
/** Read-only preview of the top-level child IDs that appendChildren() would create.
|
|
2349
|
+
* Used by mcp-server's sub-node reservation loop (multi-agent collision prevention).
|
|
2350
|
+
* Returns the IDs of direct children only — nested grandchildren don't need separate
|
|
2351
|
+
* reservation because they're parented under nodes this same call would create. */
|
|
2352
|
+
peekAppendTopLevelIds(parentId, content) {
|
|
2353
|
+
const parentIsRoot = !parentId.includes(".");
|
|
2354
|
+
// Verify parent exists (mirrors appendChildren guard, but throws same shape)
|
|
2355
|
+
if (parentIsRoot) {
|
|
2356
|
+
if (!this.db.prepare("SELECT id FROM memories WHERE id = ?").get(parentId))
|
|
2357
|
+
return [];
|
|
2358
|
+
}
|
|
2359
|
+
else {
|
|
2360
|
+
if (!this.db.prepare("SELECT id FROM memory_nodes WHERE id = ?").get(parentId))
|
|
2361
|
+
return [];
|
|
2362
|
+
}
|
|
2363
|
+
const parentDepth = parentIsRoot ? 1 : (parentId.match(/\./g).length + 1);
|
|
2364
|
+
const maxSeqRow = this.db.prepare("SELECT MAX(seq) as maxSeq FROM memory_nodes WHERE parent_id = ?").get(parentId);
|
|
2365
|
+
const startSeq = (maxSeqRow?.maxSeq ?? 0) + 1;
|
|
2366
|
+
const nodes = this.parseRelativeTree(content, parentId, parentDepth, startSeq);
|
|
2367
|
+
return nodes.filter(n => n.parent_id === parentId).map(n => n.id);
|
|
2368
|
+
}
|
|
2308
2369
|
/** Clear all active markers — called at MCP server start so each session starts neutral. */
|
|
2309
2370
|
clearAllActive() {
|
|
2310
2371
|
this.db.prepare("UPDATE memories SET active = 0 WHERE active = 1").run();
|
|
2311
2372
|
}
|
|
2373
|
+
/**
|
|
2374
|
+
* Atomically set ONE project as the active P-entry in this agent's DB.
|
|
2375
|
+
* Deactivates all other P-entries in the same .hmem file. Multi-agent isolation
|
|
2376
|
+
* happens at the .hmem-file level (each agent has its own DB), so within a single
|
|
2377
|
+
* file there must only ever be one active project — otherwise getActiveProject()
|
|
2378
|
+
* (LIMIT 1) becomes nondeterministic and log-exchange routes to the wrong O-entry.
|
|
2379
|
+
*/
|
|
2380
|
+
setActiveProject(id) {
|
|
2381
|
+
const now = new Date().toISOString();
|
|
2382
|
+
const tx = this.db.transaction(() => {
|
|
2383
|
+
this.db.prepare("UPDATE memories SET active = 0, updated_at = ? WHERE prefix = 'P' AND active = 1 AND id != ?").run(now, id);
|
|
2384
|
+
this.db.prepare("UPDATE memories SET active = 1, updated_at = ? WHERE id = ?").run(now, id);
|
|
2385
|
+
});
|
|
2386
|
+
tx();
|
|
2387
|
+
}
|
|
2312
2388
|
/** Auto-resolve linked entries on an entry (extracted for reuse in chain resolution). */
|
|
2313
2389
|
resolveEntryLinks(entry, opts) {
|
|
2314
2390
|
const linkDepth = opts.resolveLinks === false ? 0 : (opts.linkDepth ?? 1);
|
|
@@ -2691,6 +2767,7 @@ export class HmemStore {
|
|
|
2691
2767
|
if (!targetExists) {
|
|
2692
2768
|
return { moved: 0, errors: [`Target ${targetOId} does not exist`] };
|
|
2693
2769
|
}
|
|
2770
|
+
const l2MovedIntoTarget = new Set();
|
|
2694
2771
|
const doMove = this.db.transaction(() => {
|
|
2695
2772
|
for (const nodeId of nodeIds) {
|
|
2696
2773
|
const node = this.readNode(nodeId);
|
|
@@ -2707,6 +2784,7 @@ export class HmemStore {
|
|
|
2707
2784
|
if (node.depth === 2) {
|
|
2708
2785
|
// L2 session — re-parent directly under target O
|
|
2709
2786
|
this._moveSubtree(nodeId, sourceOId, targetOId, targetOId, 2);
|
|
2787
|
+
l2MovedIntoTarget.add(targetOId);
|
|
2710
2788
|
}
|
|
2711
2789
|
else if (node.depth === 3) {
|
|
2712
2790
|
// L3 batch — find/create session in target O
|
|
@@ -2727,6 +2805,12 @@ export class HmemStore {
|
|
|
2727
2805
|
this._cleanupEmptyParents(sourceOId);
|
|
2728
2806
|
moved++;
|
|
2729
2807
|
}
|
|
2808
|
+
// After moving L2 sessions into a target O, re-sort siblings by created_at
|
|
2809
|
+
// so the moved session lands in its chronologically correct slot rather than
|
|
2810
|
+
// at the end of the seq space.
|
|
2811
|
+
for (const oId of l2MovedIntoTarget) {
|
|
2812
|
+
this.reorderSessionsByDate(oId);
|
|
2813
|
+
}
|
|
2730
2814
|
});
|
|
2731
2815
|
doMove();
|
|
2732
2816
|
return { moved, errors };
|
|
@@ -2815,6 +2899,74 @@ export class HmemStore {
|
|
|
2815
2899
|
const timestamp = new Date().toISOString();
|
|
2816
2900
|
this.db.prepare("UPDATE memories SET updated_at = ? WHERE id = ?").run(timestamp, targetOId);
|
|
2817
2901
|
}
|
|
2902
|
+
/**
|
|
2903
|
+
* Rename an entire L2 session subtree (L2 node + all L3/L4/L5 descendants)
|
|
2904
|
+
* to a new id prefix. Updates memory_nodes (id, parent_id, seq), memory_tags,
|
|
2905
|
+
* and hmem_fts_rowid_map. The root-level parent of the L2 node stays at oId.
|
|
2906
|
+
* Caller must ensure newId does not yet exist.
|
|
2907
|
+
*/
|
|
2908
|
+
_renameL2Subtree(oId, oldId, newId) {
|
|
2909
|
+
const nodes = this.db.prepare("SELECT id, parent_id FROM memory_nodes WHERE id = ? OR id LIKE ?").all(oldId, `${oldId}.%`);
|
|
2910
|
+
const newSeqMatch = newId.match(/\.(\d+)$/);
|
|
2911
|
+
const newSeq = newSeqMatch ? parseInt(newSeqMatch[1], 10) : null;
|
|
2912
|
+
for (const n of nodes) {
|
|
2913
|
+
const nid = n.id === oldId ? newId : n.id.replace(oldId + ".", newId + ".");
|
|
2914
|
+
const pid = n.id === oldId
|
|
2915
|
+
? oId
|
|
2916
|
+
: (n.parent_id === oldId ? newId : n.parent_id.replace(oldId + ".", newId + "."));
|
|
2917
|
+
if (n.id === oldId && newSeq !== null) {
|
|
2918
|
+
this.db.prepare("UPDATE memory_nodes SET id = ?, parent_id = ?, seq = ? WHERE id = ?")
|
|
2919
|
+
.run(nid, pid, newSeq, n.id);
|
|
2920
|
+
}
|
|
2921
|
+
else {
|
|
2922
|
+
this.db.prepare("UPDATE memory_nodes SET id = ?, parent_id = ? WHERE id = ?")
|
|
2923
|
+
.run(nid, pid, n.id);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
// Tags
|
|
2927
|
+
const tagRows = this.db.prepare("SELECT entry_id, tag FROM memory_tags WHERE entry_id = ? OR entry_id LIKE ?").all(oldId, `${oldId}.%`);
|
|
2928
|
+
for (const t of tagRows) {
|
|
2929
|
+
const newEntryId = t.entry_id === oldId ? newId : t.entry_id.replace(oldId + ".", newId + ".");
|
|
2930
|
+
this.db.prepare("DELETE FROM memory_tags WHERE entry_id = ? AND tag = ?").run(t.entry_id, t.tag);
|
|
2931
|
+
this.db.prepare("INSERT OR IGNORE INTO memory_tags (entry_id, tag) VALUES (?, ?)").run(newEntryId, t.tag);
|
|
2932
|
+
}
|
|
2933
|
+
// FTS rowid map
|
|
2934
|
+
const ftsRows = this.db.prepare("SELECT fts_rowid, node_id FROM hmem_fts_rowid_map WHERE node_id = ? OR node_id LIKE ?").all(oldId, `${oldId}.%`);
|
|
2935
|
+
for (const f of ftsRows) {
|
|
2936
|
+
const newNodeId = f.node_id === oldId ? newId : f.node_id.replace(oldId + ".", newId + ".");
|
|
2937
|
+
this.db.prepare("UPDATE hmem_fts_rowid_map SET node_id = ? WHERE fts_rowid = ?").run(newNodeId, f.fts_rowid);
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
/**
|
|
2941
|
+
* Reorder L2 sessions under an O-entry so their seq matches chronological
|
|
2942
|
+
* order by created_at (ascending). Uses 2-phase rename via _TMP staging IDs
|
|
2943
|
+
* to avoid collisions during renumbering. Returns the number of sessions
|
|
2944
|
+
* actually renamed.
|
|
2945
|
+
*/
|
|
2946
|
+
reorderSessionsByDate(oId) {
|
|
2947
|
+
const sessions = this.db.prepare("SELECT id, seq, created_at FROM memory_nodes WHERE parent_id = ? AND depth = 2 ORDER BY created_at ASC, seq ASC").all(oId);
|
|
2948
|
+
const renames = [];
|
|
2949
|
+
for (let i = 0; i < sessions.length; i++) {
|
|
2950
|
+
const desiredSeq = i + 1;
|
|
2951
|
+
if (sessions[i].seq !== desiredSeq) {
|
|
2952
|
+
renames.push({ from: sessions[i].id, to: `${oId}.${desiredSeq}` });
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
if (renames.length === 0)
|
|
2956
|
+
return 0;
|
|
2957
|
+
const tx = this.db.transaction(() => {
|
|
2958
|
+
// Phase 1: move every affected session into staging
|
|
2959
|
+
for (let i = 0; i < renames.length; i++) {
|
|
2960
|
+
this._renameL2Subtree(oId, renames[i].from, `${oId}._TMP${i}`);
|
|
2961
|
+
}
|
|
2962
|
+
// Phase 2: rename staging → final
|
|
2963
|
+
for (let i = 0; i < renames.length; i++) {
|
|
2964
|
+
this._renameL2Subtree(oId, `${oId}._TMP${i}`, renames[i].to);
|
|
2965
|
+
}
|
|
2966
|
+
});
|
|
2967
|
+
tx();
|
|
2968
|
+
return renames.length;
|
|
2969
|
+
}
|
|
2818
2970
|
/**
|
|
2819
2971
|
* Remove empty L2 (sessions) and L3 (batches) nodes in an O-entry.
|
|
2820
2972
|
*/
|
|
@@ -3178,8 +3330,12 @@ export class HmemStore {
|
|
|
3178
3330
|
const nodes = [];
|
|
3179
3331
|
const l1Title = [];
|
|
3180
3332
|
const l1Body = [];
|
|
3333
|
+
let l1BodyMode = false; // true after blank line at L1 depth
|
|
3181
3334
|
// Auto-detect space indentation unit: use first indented line (if no tabs present)
|
|
3182
|
-
const rawLines = content.split("\n").map(l => l.trimEnd())
|
|
3335
|
+
const rawLines = content.split("\n").map(l => l.trimEnd());
|
|
3336
|
+
// Keep blank lines for body detection but trim trailing empties
|
|
3337
|
+
while (rawLines.length > 0 && rawLines[rawLines.length - 1] === "")
|
|
3338
|
+
rawLines.pop();
|
|
3183
3339
|
let spaceUnit = 4;
|
|
3184
3340
|
if (!rawLines.some(l => l.startsWith("\t"))) {
|
|
3185
3341
|
for (const l of rawLines) {
|
|
@@ -3190,10 +3346,19 @@ export class HmemStore {
|
|
|
3190
3346
|
}
|
|
3191
3347
|
}
|
|
3192
3348
|
}
|
|
3349
|
+
// Track body mode per depth: after a blank line, subsequent lines at that depth are body
|
|
3350
|
+
const bodyModeAtDepth = new Map();
|
|
3193
3351
|
for (const line of rawLines) {
|
|
3194
3352
|
const trimmedEnd = line;
|
|
3195
|
-
|
|
3353
|
+
// Blank line: activate body mode for L1 and for the last node's depth
|
|
3354
|
+
if (!trimmedEnd) {
|
|
3355
|
+
l1BodyMode = true;
|
|
3356
|
+
// Activate body mode for the last node's depth (L2+)
|
|
3357
|
+
if (nodes.length > 0) {
|
|
3358
|
+
bodyModeAtDepth.set(nodes[nodes.length - 1].depth, true);
|
|
3359
|
+
}
|
|
3196
3360
|
continue;
|
|
3361
|
+
}
|
|
3197
3362
|
// Count leading tabs; fall back to auto-detected space unit
|
|
3198
3363
|
const tabMatch = trimmedEnd.match(/^\t*/);
|
|
3199
3364
|
const leadingTabs = tabMatch ? tabMatch[0].length : 0;
|
|
@@ -3207,9 +3372,11 @@ export class HmemStore {
|
|
|
3207
3372
|
depth = spaceTabs > 0 ? Math.min(spaceTabs, 4) + 1 : 1;
|
|
3208
3373
|
}
|
|
3209
3374
|
const text = trimmedEnd.trim();
|
|
3210
|
-
// Body line detection: "> " prefix
|
|
3211
|
-
const
|
|
3212
|
-
const
|
|
3375
|
+
// Body line detection: "> " prefix (legacy) OR blank-line-activated body mode
|
|
3376
|
+
const isLegacyBody = text.startsWith("> ") || text === ">";
|
|
3377
|
+
const isBlankLineBody = depth === 1 ? l1BodyMode : bodyModeAtDepth.get(depth) === true;
|
|
3378
|
+
const isBodyLine = isLegacyBody || isBlankLineBody;
|
|
3379
|
+
const bodyText = isLegacyBody ? text.replace(/^> ?/, "") : text;
|
|
3213
3380
|
if (depth === 1) {
|
|
3214
3381
|
if (isBodyLine) {
|
|
3215
3382
|
l1Body.push(bodyText);
|
|
@@ -3219,6 +3386,10 @@ export class HmemStore {
|
|
|
3219
3386
|
}
|
|
3220
3387
|
continue;
|
|
3221
3388
|
}
|
|
3389
|
+
// Depth changed → exit body mode for other depths
|
|
3390
|
+
if (!isBodyLine) {
|
|
3391
|
+
bodyModeAtDepth.delete(depth);
|
|
3392
|
+
}
|
|
3222
3393
|
if (isBodyLine) {
|
|
3223
3394
|
// Append body to the last node at this depth
|
|
3224
3395
|
const lastNode = nodes.length > 0 ? nodes[nodes.length - 1] : null;
|
|
@@ -3233,6 +3404,8 @@ export class HmemStore {
|
|
|
3233
3404
|
}
|
|
3234
3405
|
continue;
|
|
3235
3406
|
}
|
|
3407
|
+
// New node resets body mode for this depth
|
|
3408
|
+
bodyModeAtDepth.delete(depth);
|
|
3236
3409
|
// L2+: determine parent and generate compound ID
|
|
3237
3410
|
const parentId = depth === 2 ? rootId : (lastIdAtDepth.get(depth - 1) ?? rootId);
|
|
3238
3411
|
const seq = (seqAtParent.get(parentId) ?? 0) + 1;
|
|
@@ -3278,7 +3451,9 @@ export class HmemStore {
|
|
|
3278
3451
|
seqAtParent.set(parentId, startSeq - 1);
|
|
3279
3452
|
const lastIdAtRelDepth = new Map();
|
|
3280
3453
|
const nodes = [];
|
|
3281
|
-
const rawLines = content.split("\n").map(l => l.trimEnd())
|
|
3454
|
+
const rawLines = content.split("\n").map(l => l.trimEnd());
|
|
3455
|
+
while (rawLines.length > 0 && rawLines[rawLines.length - 1] === "")
|
|
3456
|
+
rawLines.pop();
|
|
3282
3457
|
// Auto-detect space unit if no tabs used
|
|
3283
3458
|
let spaceUnit = 4;
|
|
3284
3459
|
if (!rawLines.some(l => l.startsWith("\t"))) {
|
|
@@ -3291,10 +3466,16 @@ export class HmemStore {
|
|
|
3291
3466
|
}
|
|
3292
3467
|
}
|
|
3293
3468
|
const maxAbsDepth = this.cfg.maxDepth;
|
|
3469
|
+
const bodyModeAtDepth = new Map();
|
|
3294
3470
|
for (const line of rawLines) {
|
|
3295
3471
|
const text = line.trim();
|
|
3296
|
-
|
|
3472
|
+
// Blank line: activate body mode for the last node's depth
|
|
3473
|
+
if (!text) {
|
|
3474
|
+
if (nodes.length > 0) {
|
|
3475
|
+
bodyModeAtDepth.set(nodes[nodes.length - 1].depth, true);
|
|
3476
|
+
}
|
|
3297
3477
|
continue;
|
|
3478
|
+
}
|
|
3298
3479
|
// Count leading tabs; fall back to space-based detection
|
|
3299
3480
|
const tabMatch = line.match(/^\t*/);
|
|
3300
3481
|
const leadingTabs = tabMatch ? tabMatch[0].length : 0;
|
|
@@ -3309,10 +3490,12 @@ export class HmemStore {
|
|
|
3309
3490
|
const absDepth = parentDepth + 1 + relDepth;
|
|
3310
3491
|
if (absDepth > maxAbsDepth)
|
|
3311
3492
|
continue; // silently skip beyond max depth
|
|
3312
|
-
// Body line detection: "> " prefix
|
|
3313
|
-
const
|
|
3493
|
+
// Body line detection: "> " prefix (legacy) OR blank-line-activated body mode
|
|
3494
|
+
const isLegacyBody = text.startsWith("> ") || text === ">";
|
|
3495
|
+
const isBlankLineBody = bodyModeAtDepth.get(absDepth) === true;
|
|
3496
|
+
const isBodyLine = isLegacyBody || isBlankLineBody;
|
|
3314
3497
|
if (isBodyLine) {
|
|
3315
|
-
const bodyText = text.replace(/^> ?/, "");
|
|
3498
|
+
const bodyText = isLegacyBody ? text.replace(/^> ?/, "") : text;
|
|
3316
3499
|
const lastNode = nodes.length > 0 ? nodes[nodes.length - 1] : null;
|
|
3317
3500
|
if (lastNode && lastNode.depth === absDepth) {
|
|
3318
3501
|
if (lastNode.content === lastNode.title) {
|
|
@@ -3324,6 +3507,8 @@ export class HmemStore {
|
|
|
3324
3507
|
}
|
|
3325
3508
|
continue;
|
|
3326
3509
|
}
|
|
3510
|
+
// New node resets body mode for this depth
|
|
3511
|
+
bodyModeAtDepth.delete(absDepth);
|
|
3327
3512
|
const myParentId = relDepth === 0
|
|
3328
3513
|
? parentId
|
|
3329
3514
|
: (lastIdAtRelDepth.get(relDepth - 1) ?? parentId);
|