hmem-mcp 2.2.0 → 2.4.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.
@@ -115,6 +115,7 @@ server.tool("search_memory", "Searches the collective memory: agent memories (le
115
115
  // ---- Humanlike Memory (.hmem) ----
116
116
  const prefixList = formatPrefixList(hmemConfig.prefixes);
117
117
  const prefixKeys = Object.keys(hmemConfig.prefixes);
118
+ const REMINDER_HINT = "\nACTION: Scan the entries above. Mark stale/noise as irrelevant, important ones as favorite, wrong ones as obsolete. Do it NOW — don't just note it.\n update_memory(id=\"X\", irrelevant=true) — hide noise\n update_memory(id=\"X\", favorite=true) — pin important\n update_memory(id=\"X\", content=\"Wrong — see [✓correctionId]\", obsolete=true) — correct mistakes";
118
119
  server.tool("write_memory", "Write a new memory entry to your hierarchical long-term memory (.hmem). " +
119
120
  "Use tab indentation to create depth levels:\n" +
120
121
  " Level 1: No indentation — the rough summary (always visible at startup)\n" +
@@ -135,9 +136,13 @@ server.tool("write_memory", "Write a new memory entry to your hierarchical long-
135
136
  links: z.array(z.string()).optional().describe("Optional: IDs of related memories, e.g. ['P0001', 'L0005']"),
136
137
  favorite: z.boolean().optional().describe("Mark this entry as a favorite — shown with [♥] in bulk reads and always inlined with L2 detail. " +
137
138
  "Use for reference info you need to see every session, regardless of category."),
139
+ tags: z.array(z.string()).optional().describe("Optional hashtags for cross-cutting search, e.g. ['#hmem', '#curation']. " +
140
+ "Max 10, lowercase, must start with #. Shown after title in reads."),
141
+ pinned: z.boolean().optional().describe("Mark this entry as pinned [P] (super-favorite). Pinned entries show full L2 content in bulk reads. " +
142
+ "Use for reference entries you need to see in full every session."),
138
143
  store: z.enum(["personal", "company"]).default("personal").describe("Target store: 'personal' or 'company'"),
139
144
  min_role: z.enum(["worker", "al", "pl", "ceo"]).default("worker").describe("Minimum role to see this entry"),
140
- }, async ({ prefix, content, links, favorite, store: storeName, min_role: minRole }) => {
145
+ }, async ({ prefix, content, links, favorite, tags, pinned, store: storeName, min_role: minRole }) => {
141
146
  const templateName = AGENT_ID.replace(/_\d+$/, "");
142
147
  const agentRole = (ROLE || "worker");
143
148
  const isFirstTime = !AGENT_ID && !fs.existsSync(resolveHmemPath(PROJECT_DIR, ""));
@@ -166,7 +171,7 @@ server.tool("write_memory", "Write a new memory entry to your hierarchical long-
166
171
  };
167
172
  }
168
173
  const effectiveMinRole = storeName === "company" ? minRole : "worker";
169
- const result = hmemStore.write(prefix, content, links, effectiveMinRole, favorite);
174
+ const result = hmemStore.write(prefix, content, links, effectiveMinRole, favorite, tags, pinned);
170
175
  const storeLabel = storeName === "company" ? "company" : (templateName || "memory");
171
176
  log(`write_memory [${storeLabel}]: ${result.id} (prefix=${prefix}, min_role=${effectiveMinRole})`);
172
177
  const hmemPath = resolveHmemPath(PROJECT_DIR, templateName);
@@ -214,10 +219,14 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
214
219
  "Requires [✓ID] correction reference in content (e.g. 'Wrong — see [✓E0076]')."),
215
220
  favorite: z.boolean().optional().describe("Set or clear the [♥] favorite flag. Works on root entries and sub-nodes. " +
216
221
  "Root favorites are always shown with L2 detail in bulk reads."),
217
- irrelevant: z.boolean().optional().describe("Mark this root entry as irrelevant [-] (root entries only). " +
218
- "No correction entry needed (unlike obsolete). Irrelevant entries are hidden from bulk reads."),
222
+ irrelevant: z.boolean().optional().describe("Mark as irrelevant [-]. Works on root entries and sub-nodes. " +
223
+ "No correction entry needed (unlike obsolete). Irrelevant entries/nodes are hidden from output."),
224
+ tags: z.array(z.string()).optional().describe("Set tags on this entry/node. Replaces all existing tags. " +
225
+ "Pass empty array [] to remove all tags. E.g. ['#hmem', '#curation']."),
226
+ pinned: z.boolean().optional().describe("Set or clear the [P] pinned flag (root entries only). " +
227
+ "Pinned entries show full L2 content in bulk reads (super-favorite)."),
219
228
  store: z.enum(["personal", "company"]).default("personal").describe("Target store: 'personal' or 'company'"),
220
- }, async ({ id, content, links, obsolete, favorite, irrelevant, store: storeName }) => {
229
+ }, async ({ id, content, links, obsolete, favorite, irrelevant, tags, pinned, store: storeName }) => {
221
230
  const templateName = AGENT_ID.replace(/_\d+$/, "");
222
231
  const agentRole = (ROLE || "worker");
223
232
  if (storeName === "company") {
@@ -240,7 +249,7 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
240
249
  isError: true,
241
250
  };
242
251
  }
243
- const ok = hmemStore.updateNode(id, content, links, obsolete, favorite, undefined, irrelevant);
252
+ const ok = hmemStore.updateNode(id, content, links, obsolete, favorite, undefined, irrelevant, tags, pinned);
244
253
  const storeLabel = storeName === "company" ? "company" : (templateName || "memory");
245
254
  log(`update_memory [${storeLabel}]: ${id} → ${ok ? "updated" : "not found"}${obsolete ? " (marked obsolete)" : ""}${irrelevant ? " (marked irrelevant)" : ""}${favorite !== undefined ? ` (favorite=${favorite})` : ""}`);
246
255
  if (!ok) {
@@ -262,6 +271,12 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
262
271
  parts.push("marked as [♥] favorite");
263
272
  if (favorite === false)
264
273
  parts.push("favorite flag cleared");
274
+ if (pinned === true)
275
+ parts.push("marked as [P] pinned");
276
+ if (pinned === false)
277
+ parts.push("pinned flag cleared");
278
+ if (tags !== undefined)
279
+ parts.push(tags.length > 0 ? `tags: ${tags.join(" ")}` : "tags cleared");
265
280
  return { content: [{ type: "text", text: parts.join(" | ") }] };
266
281
  }
267
282
  finally {
@@ -379,7 +394,11 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
379
394
  "Auto-selected if omitted: first bulk read → discover, subsequent → essentials."),
380
395
  store: z.enum(["personal", "company"]).default("personal").describe("Source store: 'personal' or 'company'"),
381
396
  curator: z.boolean().optional().describe("Set true to show full metadata (access counts, roles, dates). For curators only."),
382
- }, async ({ id, depth, prefix, after, before, search, limit: maxResults, time, period, time_around, show_obsolete, show_obsolete_path, titles_only, expand, mode, store: storeName, curator }) => {
397
+ show_all: z.boolean().optional().describe("Curation mode: show ALL entries of the selected prefix with depth 3 children. " +
398
+ "Bypasses V2 selection and session cache. Use with prefix filter for manageable output."),
399
+ tag: z.string().optional().describe("Filter by hashtag, e.g. '#hmem'. Only entries with this tag are shown in bulk reads. " +
400
+ "Also works with search to find tagged entries."),
401
+ }, async ({ id, depth, prefix, after, before, search, limit: maxResults, time, period, time_around, show_obsolete, show_obsolete_path, titles_only, expand, mode, store: storeName, curator, show_all, tag }) => {
383
402
  if (AGENT_ID === "UNKNOWN") {
384
403
  return {
385
404
  content: [{ type: "text", text: "ERROR: Agent-ID unknown. read_memory is only available for spawned agents." }],
@@ -397,12 +416,12 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
397
416
  ? "⚠ WARNING: Memory database is corrupted! Reads may be incomplete. A backup (.corrupt) was saved.\n\n"
398
417
  : "";
399
418
  const effectiveDepth = depth || (id ? 2 : 1);
400
- // Session cache: apply sliding window for bulk reads (personal store only)
419
+ // Session cache: cached entries shown as titles in subsequent bulk reads
401
420
  const isBulkListing = !id && !search && !time_around;
402
- const useCache = isBulkListing && storeName === "personal";
403
- const suppressedIds = useCache ? sessionCache.getSuppressedIds() : undefined;
404
- const maxNewNewest = useCache ? sessionCache.getNewestSlotCount() : undefined;
405
- const maxNewAccess = useCache ? sessionCache.getAccessSlotCount() : undefined;
421
+ const useCache = isBulkListing && storeName === "personal" && !show_all;
422
+ const cachedIds = useCache ? sessionCache.getCachedIds() : undefined;
423
+ const hiddenIds = useCache ? sessionCache.getHiddenIds() : undefined;
424
+ const slotFraction = useCache ? sessionCache.getSlotFraction() : undefined;
406
425
  // Auto-select mode: first bulk read → discover, subsequent → essentials
407
426
  const effectiveMode = mode ?? (useCache && sessionCache.readCount > 0 ? "essentials" : "discover");
408
427
  const entries = hmemStore.read({
@@ -414,10 +433,12 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
414
433
  showObsoletePath: show_obsolete_path,
415
434
  titlesOnly: titles_only,
416
435
  expand,
417
- suppressedIds,
418
- maxNewNewest,
419
- maxNewAccess,
436
+ cachedIds,
437
+ hiddenIds,
438
+ slotFraction,
439
+ showAll: show_all,
420
440
  mode: isBulkListing ? effectiveMode : undefined,
441
+ tag,
421
442
  });
422
443
  if (entries.length === 0) {
423
444
  const hint = id ? `No memory with ID "${id}".` :
@@ -442,18 +463,25 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
442
463
  const storeLabel = storeName === "company" ? "company" : templateName;
443
464
  const visibleCount = entries.length;
444
465
  // Cache status in header (when active)
466
+ const hiddenCount = hiddenIds?.size ?? 0;
467
+ const cachedCount = cachedIds?.size ?? 0;
445
468
  const cacheInfo = useCache && sessionCache.size > 0
446
- ? ` | Cache: ${sessionCache.size} seen`
469
+ ? ` | Cache: ${sessionCache.size} seen` + (hiddenCount > 0 ? ` (${hiddenCount} hidden)` : "")
447
470
  : "";
448
471
  // Mode info in header (only for bulk reads)
449
472
  const modeInfo = isBulkListing ? ` | Mode: ${effectiveMode}` : "";
473
+ // Token estimation: output tokens / total tokens
474
+ const outputTokens = Math.round(output.length / 4);
475
+ const totalTokens = Math.round(stats.totalChars / 4);
476
+ const fmtTok = (n) => n < 1000 ? String(n) : n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`;
477
+ const tokenInfo = ` | ${fmtTok(outputTokens)}/${fmtTok(totalTokens)} tokens`;
450
478
  const header = `## Memory: ${storeLabel} (${stats.total} total entries)\n` +
451
- `Query: ${id ? `id=${id}` : ""}${prefix ? `prefix=${prefix}` : ""}${search ? `search="${search}"` : ""}${time_around ? `time_around=${time_around}` : ""}${after ? ` after=${after}` : ""}${before ? ` before=${before}` : ""}${time ? ` time=${time}` : ""} | Depth: ${effectiveDepth} | Results: ${visibleCount}${modeInfo}${cacheInfo}\n`;
479
+ `Query: ${id ? `id=${id}` : ""}${prefix ? `prefix=${prefix}` : ""}${search ? `search="${search}"` : ""}${time_around ? `time_around=${time_around}` : ""}${after ? ` after=${after}` : ""}${before ? ` before=${before}` : ""}${time ? ` time=${time}` : ""} | Depth: ${effectiveDepth} | Results: ${visibleCount}${modeInfo}${cacheInfo}${tokenInfo}\n`;
452
480
  log(`read_memory [${storeLabel}]: ${visibleCount} results (depth=${effectiveDepth}, role=${agentRole}${cacheInfo})`);
453
481
  return {
454
482
  content: [{
455
483
  type: "text",
456
- text: corruptionWarning + header + "\n" + output,
484
+ text: corruptionWarning + header + "\n" + output + REMINDER_HINT,
457
485
  }],
458
486
  };
459
487
  }
@@ -487,6 +515,82 @@ server.tool("reset_memory_cache", "Clear the session cache so all entries are tr
487
515
  }],
488
516
  };
489
517
  });
518
+ // ---- Export Memory ----
519
+ server.tool("export_memory", "Export your memory, excluding secret entries and secret sub-nodes. " +
520
+ "Use for sharing, backup, or publishing a sanitized version of your memory.", {
521
+ store: z.enum(["personal", "company"]).default("personal").describe("Source store: 'personal' (your own memory) or 'company' (shared company store)"),
522
+ format: z.enum(["text", "hmem"]).default("text").describe("Export format: 'text' = Markdown (returned inline), " +
523
+ "'hmem' = SQLite .hmem file (written to disk)"),
524
+ output_path: z.string().optional().describe("Output path for 'hmem' format. Default: export.hmem next to the source file. " +
525
+ "Ignored for 'text' format."),
526
+ }, async ({ store: storeName, format, output_path }) => {
527
+ try {
528
+ const hmemStore = storeName === "company"
529
+ ? openCompanyMemory(PROJECT_DIR, hmemConfig)
530
+ : openAgentMemory(PROJECT_DIR, AGENT_ID.replace(/_\d+$/, ""), hmemConfig);
531
+ try {
532
+ if (format === "hmem") {
533
+ const defaultPath = path.join(path.dirname(hmemStore.getDbPath()), "export.hmem");
534
+ const outPath = output_path || defaultPath;
535
+ const result = hmemStore.exportPublicToHmem(outPath);
536
+ return { content: [{ type: "text", text: `Exported to ${outPath}\n${result.entries} entries, ${result.nodes} nodes, ${result.tags} tags` }] };
537
+ }
538
+ else {
539
+ const output = hmemStore.exportMarkdown();
540
+ return { content: [{ type: "text", text: output }] };
541
+ }
542
+ }
543
+ finally {
544
+ hmemStore.close();
545
+ }
546
+ }
547
+ catch (e) {
548
+ return {
549
+ content: [{ type: "text", text: `ERROR: ${e}` }],
550
+ isError: true,
551
+ };
552
+ }
553
+ });
554
+ // ---- Import Memory ----
555
+ server.tool("import_memory", "Import entries from a .hmem file into your memory. " +
556
+ "Deduplicates by L1 content (merges sub-nodes), remaps IDs on conflict.", {
557
+ source_path: z.string().describe("Path to .hmem file to import"),
558
+ store: z.enum(["personal", "company"]).default("personal").describe("Target store: 'personal' (your own memory) or 'company' (shared company store)"),
559
+ dry_run: z.boolean().default(false).describe("Preview only — report what would happen without modifying the database"),
560
+ }, async ({ source_path, store: storeName, dry_run }) => {
561
+ try {
562
+ const hmemStore = storeName === "company"
563
+ ? openCompanyMemory(PROJECT_DIR, hmemConfig)
564
+ : openAgentMemory(PROJECT_DIR, AGENT_ID.replace(/_\d+$/, ""), hmemConfig);
565
+ try {
566
+ const result = hmemStore.importFromHmem(source_path, dry_run);
567
+ const mode = dry_run ? "preview" : "imported";
568
+ log(`import_memory: ${mode} from ${source_path} (${result.inserted} new, ${result.merged} merged)`);
569
+ const lines = [];
570
+ lines.push(dry_run
571
+ ? `Import preview from ${source_path}:`
572
+ : `Imported from ${source_path}:`);
573
+ lines.push(` ${result.inserted} entries ${dry_run ? "to insert" : "inserted"}`);
574
+ lines.push(` ${result.merged} entries ${dry_run ? "to merge" : "merged"} (L1 match)`);
575
+ lines.push(` ${result.nodesInserted} nodes ${dry_run ? "to insert" : "inserted"}`);
576
+ lines.push(` ${result.nodesSkipped} nodes skipped (duplicate L2)`);
577
+ lines.push(` ${result.tagsImported} tags ${dry_run ? "to import" : "imported"}`);
578
+ if (result.remapped) {
579
+ lines.push(` ID remapping ${dry_run ? "required" : "applied"} (${result.conflicts} conflicts)`);
580
+ }
581
+ return { content: [{ type: "text", text: lines.join("\n") }] };
582
+ }
583
+ finally {
584
+ hmemStore.close();
585
+ }
586
+ }
587
+ catch (e) {
588
+ return {
589
+ content: [{ type: "text", text: `ERROR: ${e}` }],
590
+ isError: true,
591
+ };
592
+ }
593
+ });
490
594
  // ---- Curator Tools (ceo role only) ----
491
595
  const AUDIT_STATE_FILE = process.env.HMEM_AUDIT_STATE_PATH
492
596
  || path.join(PROJECT_DIR, "audit_state.json");
@@ -839,6 +943,12 @@ server.tool("mark_audited", "CURATOR ONLY (ceo role). Mark an agent as audited (
839
943
  * V2 selection applies. Favorites/top-accessed show L2 children titles.
840
944
  * Non-expanded entries show (N) child count indicator.
841
945
  */
946
+ /** Format tags as a compact suffix: " #hmem #curation" or "" if no tags. */
947
+ function formatTagSuffix(tags) {
948
+ if (!tags || tags.length === 0)
949
+ return "";
950
+ return " " + tags.join(" ");
951
+ }
842
952
  function formatTitlesOnly(entries, config) {
843
953
  const CHILD_TITLE_LEN = 50;
844
954
  const lines = [];
@@ -859,9 +969,11 @@ function formatTitlesOnly(entries, config) {
859
969
  const obs = e.obsolete ? " [!]" : "";
860
970
  const irr = e.irrelevant ? " [-]" : "";
861
971
  if (e.expanded && e.children && e.children.length > 0) {
972
+ const visibleChildren = e.children.filter(c => !c.irrelevant);
973
+ const hiddenIrr = e.children.length - visibleChildren.length;
862
974
  // Expanded entry (favorite/top-accessed): show with L2 children
863
- lines.push(`${e.id} ${mmdd}${fav}${obs} ${e.title}`);
864
- for (const child of e.children) {
975
+ lines.push(`${e.id} ${mmdd}${fav}${obs} ${e.title}${formatTagSuffix(e.tags)}`);
976
+ for (const child of visibleChildren) {
865
977
  const short = child.title || (child.content.length > CHILD_TITLE_LEN
866
978
  ? child.content.substring(0, CHILD_TITLE_LEN)
867
979
  : child.content);
@@ -872,11 +984,14 @@ function formatTitlesOnly(entries, config) {
872
984
  if (e.hiddenChildrenCount && e.hiddenChildrenCount > 0) {
873
985
  lines.push(` [+${e.hiddenChildrenCount} more → ${e.id}]`);
874
986
  }
987
+ if (hiddenIrr > 0) {
988
+ lines.push(` (+${hiddenIrr} irrelevant hidden)`);
989
+ }
875
990
  }
876
991
  else {
877
992
  // Non-expanded: compact line with child count
878
993
  const childHint = (e.hiddenChildrenCount ?? 0) > 0 ? ` (${e.hiddenChildrenCount})` : "";
879
- lines.push(`${e.id} ${mmdd}${fav}${obs} ${e.title}${childHint}`);
994
+ lines.push(`${e.id} ${mmdd}${fav}${obs} ${e.title}${formatTagSuffix(e.tags)}${childHint}`);
880
995
  }
881
996
  }
882
997
  lines.push("");
@@ -937,19 +1052,22 @@ function formatFlatOutput(entries, curator, expand = false) {
937
1052
  return lines.join("\n");
938
1053
  }
939
1054
  /** Favorite marker for child nodes. */
940
- function nodeFav(node) {
941
- return node.favorite ? " [♥]" : "";
1055
+ function nodeMarkers(node) {
1056
+ const fav = node.favorite ? " [♥]" : "";
1057
+ const irr = node.irrelevant ? " [-]" : "";
1058
+ return `${fav}${irr}`;
942
1059
  }
943
1060
  function renderEntryFormatted(lines, e, curator, expand = false) {
944
1061
  const isNode = e.id.includes(".");
945
1062
  const hasDetail = !!(e.children?.length || e.linkedEntries?.length);
1063
+ const tagStr = formatTagSuffix(e.tags);
946
1064
  // Headline: use title for navigation, show full content below when drilling in
947
1065
  if (isNode) {
948
1066
  if (curator) {
949
- lines.push(`[${e.id}] ${e.title}`);
1067
+ lines.push(`[${e.id}] ${e.title}${tagStr}`);
950
1068
  }
951
1069
  else {
952
- lines.push(`${e.id} ${e.title}`);
1070
+ lines.push(`${e.id} ${e.title}${tagStr}`);
953
1071
  }
954
1072
  // Node drilldown: show full content below title
955
1073
  if (hasDetail && e.level_1 !== e.title) {
@@ -959,50 +1077,56 @@ function renderEntryFormatted(lines, e, curator, expand = false) {
959
1077
  else {
960
1078
  if (curator) {
961
1079
  const promotedTag = e.promoted === "favorite" ? " [♥]" : e.promoted === "access" ? " [★]" : "";
1080
+ const pinnedTag = e.pinned ? " [P]" : "";
962
1081
  const obsoleteTag = e.obsolete ? " [⚠ OBSOLETE]" : "";
963
1082
  const irrelevantTag = e.irrelevant ? " [- IRRELEVANT]" : "";
964
1083
  const date = e.created_at.substring(0, 10);
965
1084
  const accessed = e.access_count > 0 ? ` (${e.access_count}x accessed)` : "";
966
1085
  const roleTag = e.min_role !== "worker" ? ` [${e.min_role}+]` : "";
967
- lines.push(`[${e.id}] ${date}${roleTag}${promotedTag}${obsoleteTag}${irrelevantTag}${accessed}`);
968
- lines.push(` ${e.title}`);
1086
+ lines.push(`[${e.id}] ${date}${roleTag}${promotedTag}${pinnedTag}${obsoleteTag}${irrelevantTag}${accessed}`);
1087
+ lines.push(` ${e.title}${tagStr}`);
969
1088
  }
970
1089
  else {
971
1090
  const promotedTag = e.promoted === "favorite" ? " [♥]" : e.promoted === "access" ? " [★]" : "";
1091
+ const pinnedTag = e.pinned ? " [P]" : "";
972
1092
  const obsoleteTag = e.obsolete ? " [!]" : "";
973
1093
  const irrelevantTag = e.irrelevant ? " [-]" : "";
974
1094
  const mmdd = e.created_at.substring(5, 10);
975
- lines.push(`${e.id} ${mmdd}${promotedTag}${obsoleteTag}${irrelevantTag} ${e.title}`);
1095
+ lines.push(`${e.id} ${mmdd}${promotedTag}${pinnedTag}${obsoleteTag}${irrelevantTag} ${e.title}${tagStr}`);
976
1096
  }
977
1097
  // Show full level_1 content below title when entry is expanded/drilled
978
1098
  if (hasDetail && e.level_1 !== e.title) {
979
1099
  lines.push(` ${e.level_1}`);
980
1100
  }
981
1101
  }
982
- // Children
1102
+ // Children — filter out irrelevant nodes
983
1103
  if (e.children && e.children.length > 0) {
1104
+ const visibleChildren = e.children.filter(c => !c.irrelevant);
1105
+ const hiddenIrrelevant = e.children.length - visibleChildren.length;
984
1106
  if (expand) {
985
1107
  // Expand mode: full content + recursive children
986
- renderChildrenExpanded(lines, e.children, curator);
1108
+ renderChildrenExpanded(lines, visibleChildren, curator);
987
1109
  }
988
1110
  else if (e.expanded && !expand) {
989
- renderChildrenFormatted(lines, e.children, curator);
1111
+ renderChildrenFormatted(lines, visibleChildren, curator);
990
1112
  if (e.hiddenChildrenCount && e.hiddenChildrenCount > 0) {
991
1113
  lines.push(` [+${e.hiddenChildrenCount} more → ${e.id}]`);
992
1114
  }
993
1115
  }
994
1116
  else if (e.hiddenChildrenCount !== undefined) {
995
- // Non-expanded bulk read: show only the latest child title
996
- const child = e.children[0];
997
- const fav = nodeFav(child);
998
- const hint = (child.child_count ?? 0) > 0
999
- ? ` [+${child.child_count} ${child.id}]`
1000
- : "";
1001
- if (curator) {
1002
- lines.push(` [${child.id}]${fav} ${child.title}${hint}`);
1003
- }
1004
- else {
1005
- lines.push(` ${child.id}${fav} ${child.title}${hint}`);
1117
+ // Non-expanded bulk read: show only the latest visible child title
1118
+ const child = visibleChildren[0];
1119
+ if (child) {
1120
+ const fav = nodeMarkers(child);
1121
+ const hint = (child.child_count ?? 0) > 0
1122
+ ? ` [+${child.child_count} → ${child.id}]`
1123
+ : "";
1124
+ if (curator) {
1125
+ lines.push(` [${child.id}]${fav} ${child.title}${hint}`);
1126
+ }
1127
+ else {
1128
+ lines.push(` ${child.id}${fav} ${child.title}${hint}`);
1129
+ }
1006
1130
  }
1007
1131
  if (e.hiddenChildrenCount > 0) {
1008
1132
  lines.push(` [+${e.hiddenChildrenCount} more → ${e.id}]`);
@@ -1010,7 +1134,10 @@ function renderEntryFormatted(lines, e, curator, expand = false) {
1010
1134
  }
1011
1135
  else {
1012
1136
  // ID-based read: show all direct children as titles
1013
- renderChildrenFormatted(lines, e.children, curator);
1137
+ renderChildrenFormatted(lines, visibleChildren, curator);
1138
+ }
1139
+ if (hiddenIrrelevant > 0) {
1140
+ lines.push(` (+${hiddenIrrelevant} irrelevant hidden)`);
1014
1141
  }
1015
1142
  }
1016
1143
  // Links
@@ -1044,11 +1171,19 @@ function renderEntryFormatted(lines, e, curator, expand = false) {
1044
1171
  const hint = (lchild.child_count ?? 0) > 0
1045
1172
  ? ` (${lchild.child_count} ${lchild.child_count === 1 ? "child" : "children"} — use id="${lchild.id}" to expand)`
1046
1173
  : "";
1047
- lines.push(` [${lchild.id}]${nodeFav(lchild)} ${lchild.title}${hint}`);
1174
+ lines.push(` [${lchild.id}]${nodeMarkers(lchild)} ${lchild.title}${hint}`);
1048
1175
  }
1049
1176
  }
1050
1177
  }
1051
1178
  }
1179
+ // Related entries (shared tags)
1180
+ if (e.relatedEntries && e.relatedEntries.length > 0) {
1181
+ lines.push(` --- Related (shared tags) ---`);
1182
+ for (const rel of e.relatedEntries) {
1183
+ const rmmdd = rel.created_at.substring(5, 10);
1184
+ lines.push(` ${rel.id} ${rmmdd} ${rel.title}${formatTagSuffix(rel.tags)}`);
1185
+ }
1186
+ }
1052
1187
  lines.push("");
1053
1188
  }
1054
1189
  /**
@@ -1058,15 +1193,16 @@ function renderEntryFormatted(lines, e, curator, expand = false) {
1058
1193
  function renderChildrenFormatted(lines, children, curator) {
1059
1194
  for (const child of children) {
1060
1195
  const indent = " ".repeat(child.depth - 1);
1061
- const fav = nodeFav(child);
1196
+ const fav = nodeMarkers(child);
1197
+ const ctags = formatTagSuffix(child.tags);
1062
1198
  const hint = (child.child_count ?? 0) > 0
1063
1199
  ? ` [+${child.child_count} → ${child.id}]`
1064
1200
  : "";
1065
1201
  if (curator) {
1066
- lines.push(`${indent}[${child.id}]${fav} ${child.title}${hint}`);
1202
+ lines.push(`${indent}[${child.id}]${fav} ${child.title}${ctags}${hint}`);
1067
1203
  }
1068
1204
  else {
1069
- lines.push(`${indent}${child.id}${fav} ${child.title}${hint}`);
1205
+ lines.push(`${indent}${child.id}${fav} ${child.title}${ctags}${hint}`);
1070
1206
  }
1071
1207
  // Don't recurse into grandchildren — titles only, drill for content
1072
1208
  }
@@ -1080,8 +1216,9 @@ function renderChildrenFormatted(lines, children, curator) {
1080
1216
  function renderChildrenExpanded(lines, children, curator) {
1081
1217
  for (const child of children) {
1082
1218
  const indent = " ".repeat(child.depth - 1);
1083
- const fav = nodeFav(child);
1084
- const hasLoadedChildren = child.children && child.children.length > 0;
1219
+ const fav = nodeMarkers(child);
1220
+ const visibleGrandchildren = child.children?.filter(c => !c.irrelevant);
1221
+ const hasLoadedChildren = visibleGrandchildren && visibleGrandchildren.length > 0;
1085
1222
  const isBoundary = !hasLoadedChildren && (child.child_count ?? 0) > 0;
1086
1223
  if (hasLoadedChildren) {
1087
1224
  // Inner node: full content + recurse
@@ -1091,7 +1228,7 @@ function renderChildrenExpanded(lines, children, curator) {
1091
1228
  else {
1092
1229
  lines.push(`${indent}${child.id}${fav} ${child.content}`);
1093
1230
  }
1094
- renderChildrenExpanded(lines, child.children, curator);
1231
+ renderChildrenExpanded(lines, visibleGrandchildren, curator);
1095
1232
  }
1096
1233
  else if (isBoundary) {
1097
1234
  // Boundary: title only + child count hint