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.
@@ -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
  */
@@ -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 > body lines: first non-> line = title, > lines = body (content)
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 (> prefix), and child lines (indented)
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()).filter(Boolean);
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
- if (!trimmedEnd)
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 marks body text for the preceding node
3211
- const isBodyLine = text.startsWith("> ") || text === ">";
3212
- const bodyText = isBodyLine ? text.replace(/^> ?/, "") : "";
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()).filter(Boolean);
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
- if (!text)
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 marks body text for the preceding node
3313
- const isBodyLine = text.startsWith("> ") || text === ">";
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);