hmem-mcp 6.0.3 → 6.1.0

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,6 +425,14 @@ 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;
430
438
  /** Auto-resolve linked entries on an entry (extracted for reuse in chain resolution). */
@@ -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,6 +2338,34 @@ 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();
@@ -3178,8 +3239,12 @@ export class HmemStore {
3178
3239
  const nodes = [];
3179
3240
  const l1Title = [];
3180
3241
  const l1Body = [];
3242
+ let l1BodyMode = false; // true after blank line at L1 depth
3181
3243
  // 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);
3244
+ const rawLines = content.split("\n").map(l => l.trimEnd());
3245
+ // Keep blank lines for body detection but trim trailing empties
3246
+ while (rawLines.length > 0 && rawLines[rawLines.length - 1] === "")
3247
+ rawLines.pop();
3183
3248
  let spaceUnit = 4;
3184
3249
  if (!rawLines.some(l => l.startsWith("\t"))) {
3185
3250
  for (const l of rawLines) {
@@ -3190,10 +3255,19 @@ export class HmemStore {
3190
3255
  }
3191
3256
  }
3192
3257
  }
3258
+ // Track body mode per depth: after a blank line, subsequent lines at that depth are body
3259
+ const bodyModeAtDepth = new Map();
3193
3260
  for (const line of rawLines) {
3194
3261
  const trimmedEnd = line;
3195
- if (!trimmedEnd)
3262
+ // Blank line: activate body mode for L1 and for the last node's depth
3263
+ if (!trimmedEnd) {
3264
+ l1BodyMode = true;
3265
+ // Activate body mode for the last node's depth (L2+)
3266
+ if (nodes.length > 0) {
3267
+ bodyModeAtDepth.set(nodes[nodes.length - 1].depth, true);
3268
+ }
3196
3269
  continue;
3270
+ }
3197
3271
  // Count leading tabs; fall back to auto-detected space unit
3198
3272
  const tabMatch = trimmedEnd.match(/^\t*/);
3199
3273
  const leadingTabs = tabMatch ? tabMatch[0].length : 0;
@@ -3207,9 +3281,11 @@ export class HmemStore {
3207
3281
  depth = spaceTabs > 0 ? Math.min(spaceTabs, 4) + 1 : 1;
3208
3282
  }
3209
3283
  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(/^> ?/, "") : "";
3284
+ // Body line detection: "> " prefix (legacy) OR blank-line-activated body mode
3285
+ const isLegacyBody = text.startsWith("> ") || text === ">";
3286
+ const isBlankLineBody = depth === 1 ? l1BodyMode : bodyModeAtDepth.get(depth) === true;
3287
+ const isBodyLine = isLegacyBody || isBlankLineBody;
3288
+ const bodyText = isLegacyBody ? text.replace(/^> ?/, "") : text;
3213
3289
  if (depth === 1) {
3214
3290
  if (isBodyLine) {
3215
3291
  l1Body.push(bodyText);
@@ -3219,6 +3295,10 @@ export class HmemStore {
3219
3295
  }
3220
3296
  continue;
3221
3297
  }
3298
+ // Depth changed → exit body mode for other depths
3299
+ if (!isBodyLine) {
3300
+ bodyModeAtDepth.delete(depth);
3301
+ }
3222
3302
  if (isBodyLine) {
3223
3303
  // Append body to the last node at this depth
3224
3304
  const lastNode = nodes.length > 0 ? nodes[nodes.length - 1] : null;
@@ -3233,6 +3313,8 @@ export class HmemStore {
3233
3313
  }
3234
3314
  continue;
3235
3315
  }
3316
+ // New node resets body mode for this depth
3317
+ bodyModeAtDepth.delete(depth);
3236
3318
  // L2+: determine parent and generate compound ID
3237
3319
  const parentId = depth === 2 ? rootId : (lastIdAtDepth.get(depth - 1) ?? rootId);
3238
3320
  const seq = (seqAtParent.get(parentId) ?? 0) + 1;
@@ -3278,7 +3360,9 @@ export class HmemStore {
3278
3360
  seqAtParent.set(parentId, startSeq - 1);
3279
3361
  const lastIdAtRelDepth = new Map();
3280
3362
  const nodes = [];
3281
- const rawLines = content.split("\n").map(l => l.trimEnd()).filter(Boolean);
3363
+ const rawLines = content.split("\n").map(l => l.trimEnd());
3364
+ while (rawLines.length > 0 && rawLines[rawLines.length - 1] === "")
3365
+ rawLines.pop();
3282
3366
  // Auto-detect space unit if no tabs used
3283
3367
  let spaceUnit = 4;
3284
3368
  if (!rawLines.some(l => l.startsWith("\t"))) {
@@ -3291,10 +3375,16 @@ export class HmemStore {
3291
3375
  }
3292
3376
  }
3293
3377
  const maxAbsDepth = this.cfg.maxDepth;
3378
+ const bodyModeAtDepth = new Map();
3294
3379
  for (const line of rawLines) {
3295
3380
  const text = line.trim();
3296
- if (!text)
3381
+ // Blank line: activate body mode for the last node's depth
3382
+ if (!text) {
3383
+ if (nodes.length > 0) {
3384
+ bodyModeAtDepth.set(nodes[nodes.length - 1].depth, true);
3385
+ }
3297
3386
  continue;
3387
+ }
3298
3388
  // Count leading tabs; fall back to space-based detection
3299
3389
  const tabMatch = line.match(/^\t*/);
3300
3390
  const leadingTabs = tabMatch ? tabMatch[0].length : 0;
@@ -3309,10 +3399,12 @@ export class HmemStore {
3309
3399
  const absDepth = parentDepth + 1 + relDepth;
3310
3400
  if (absDepth > maxAbsDepth)
3311
3401
  continue; // silently skip beyond max depth
3312
- // Body line detection: "> " prefix marks body text for the preceding node
3313
- const isBodyLine = text.startsWith("> ") || text === ">";
3402
+ // Body line detection: "> " prefix (legacy) OR blank-line-activated body mode
3403
+ const isLegacyBody = text.startsWith("> ") || text === ">";
3404
+ const isBlankLineBody = bodyModeAtDepth.get(absDepth) === true;
3405
+ const isBodyLine = isLegacyBody || isBlankLineBody;
3314
3406
  if (isBodyLine) {
3315
- const bodyText = text.replace(/^> ?/, "");
3407
+ const bodyText = isLegacyBody ? text.replace(/^> ?/, "") : text;
3316
3408
  const lastNode = nodes.length > 0 ? nodes[nodes.length - 1] : null;
3317
3409
  if (lastNode && lastNode.depth === absDepth) {
3318
3410
  if (lastNode.content === lastNode.title) {
@@ -3324,6 +3416,8 @@ export class HmemStore {
3324
3416
  }
3325
3417
  continue;
3326
3418
  }
3419
+ // New node resets body mode for this depth
3420
+ bodyModeAtDepth.delete(absDepth);
3327
3421
  const myParentId = relDepth === 0
3328
3422
  ? parentId
3329
3423
  : (lastIdAtRelDepth.get(relDepth - 1) ?? parentId);