hmem-mcp 2.0.3 → 2.2.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.
@@ -22,6 +22,7 @@ import path from "node:path";
22
22
  import { searchMemory } from "./memory-search.js";
23
23
  import { openAgentMemory, openCompanyMemory, resolveHmemPath, HmemStore } from "./hmem-store.js";
24
24
  import { loadHmemConfig, formatPrefixList } from "./hmem-config.js";
25
+ import { SessionCache } from "./session-cache.js";
25
26
  // ---- Environment ----
26
27
  // HMEM_* vars are the canonical names; COUNCIL_* kept for backwards compatibility
27
28
  const PROJECT_DIR = process.env.HMEM_PROJECT_DIR || process.env.COUNCIL_PROJECT_DIR || "";
@@ -52,11 +53,13 @@ function log(msg) {
52
53
  }
53
54
  // Load hmem config (hmem.config.json in project dir, falls back to defaults)
54
55
  const hmemConfig = loadHmemConfig(PROJECT_DIR);
55
- log(`Config: levels=[${hmemConfig.maxCharsPerLevel.join(",")}] depth=${hmemConfig.maxDepth} tiers=${JSON.stringify(hmemConfig.recentDepthTiers)}`);
56
+ log(`Config: levels=[${hmemConfig.maxCharsPerLevel.join(",")}] depth=${hmemConfig.maxDepth}`);
57
+ // Session-scoped cache — persists across tool calls within this MCP connection
58
+ const sessionCache = new SessionCache();
56
59
  // ---- Server ----
57
60
  const server = new McpServer({
58
61
  name: "hmem",
59
- version: "1.1.0",
62
+ version: "2.2.0",
60
63
  });
61
64
  // ---- Tool: search_memory ----
62
65
  server.tool("search_memory", "Searches the collective memory: agent memories (lessons learned, evaluations), " +
@@ -199,7 +202,9 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
199
202
  "- Mark as obsolete: FIRST write the correction, THEN update with [✓ID] reference:\n" +
200
203
  " 1. write_memory(prefix='E', content='Correct fix is...') → E0076\n" +
201
204
  " 2. update_memory(id='E0042', content='Wrong — see [✓E0076]', obsolete=true)\n" +
202
- "- Mark as favorite: update_memory(id='D0010', content='...', favorite=true)\n\n" +
205
+ "- Mark as favorite: update_memory(id='D0010', content='...', favorite=true)\n" +
206
+ "- Mark as irrelevant: update_memory(id='L0042', content='...', irrelevant=true)\n" +
207
+ " No correction entry needed (unlike obsolete). Hidden from bulk reads.\n\n" +
203
208
  "To add new child nodes, use append_memory. " +
204
209
  "To replace the entire tree, use delete_agent_memory + write_memory (curator only).", {
205
210
  id: z.string().describe("ID of the entry or node to update, e.g. 'L0003' or 'L0003.2'"),
@@ -207,10 +212,12 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
207
212
  links: z.array(z.string()).optional().describe("Optional: update linked entry IDs (root entries only). Replaces existing links."),
208
213
  obsolete: z.boolean().optional().describe("Mark this root entry as no longer valid (root entries only). " +
209
214
  "Requires [✓ID] correction reference in content (e.g. 'Wrong — see [✓E0076]')."),
210
- favorite: z.boolean().optional().describe("Set or clear the [♥] favorite flag on this root entry (root entries only). " +
211
- "Favorites are always shown with L2 detail in bulk reads."),
215
+ favorite: z.boolean().optional().describe("Set or clear the [♥] favorite flag. Works on root entries and sub-nodes. " +
216
+ "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."),
212
219
  store: z.enum(["personal", "company"]).default("personal").describe("Target store: 'personal' or 'company'"),
213
- }, async ({ id, content, links, obsolete, favorite, store: storeName }) => {
220
+ }, async ({ id, content, links, obsolete, favorite, irrelevant, store: storeName }) => {
214
221
  const templateName = AGENT_ID.replace(/_\d+$/, "");
215
222
  const agentRole = (ROLE || "worker");
216
223
  if (storeName === "company") {
@@ -233,9 +240,9 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
233
240
  isError: true,
234
241
  };
235
242
  }
236
- const ok = hmemStore.updateNode(id, content, links, obsolete, favorite);
243
+ const ok = hmemStore.updateNode(id, content, links, obsolete, favorite, undefined, irrelevant);
237
244
  const storeLabel = storeName === "company" ? "company" : (templateName || "memory");
238
- log(`update_memory [${storeLabel}]: ${id} → ${ok ? "updated" : "not found"}${obsolete ? " (marked obsolete)" : ""}${favorite !== undefined ? ` (favorite=${favorite})` : ""}`);
245
+ log(`update_memory [${storeLabel}]: ${id} → ${ok ? "updated" : "not found"}${obsolete ? " (marked obsolete)" : ""}${irrelevant ? " (marked irrelevant)" : ""}${favorite !== undefined ? ` (favorite=${favorite})` : ""}`);
239
246
  if (!ok) {
240
247
  return {
241
248
  content: [{ type: "text", text: `ERROR: Entry "${id}" not found in ${storeLabel}.` }],
@@ -247,6 +254,10 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
247
254
  parts.push("links updated");
248
255
  if (obsolete === true)
249
256
  parts.push("marked as [!] obsolete");
257
+ if (irrelevant === true)
258
+ parts.push("marked as [-] irrelevant");
259
+ if (irrelevant === false)
260
+ parts.push("irrelevant flag cleared");
250
261
  if (favorite === true)
251
262
  parts.push("marked as [♥] favorite");
252
263
  if (favorite === false)
@@ -338,7 +349,8 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
338
349
  "- By prefix: read_memory({ prefix: 'L' }) → All Lessons Learned (Level 1)\n" +
339
350
  "- By time: read_memory({ after: '2026-02-15', before: '2026-02-17' })\n" +
340
351
  "- Search: read_memory({ search: 'SSE' }) → Full-text search across all levels\n" +
341
- "- Time-around: read_memory({ time_around: 'P0001' }) → entries near P0001's timestamp\n\n" +
352
+ "- Time-around: read_memory({ time_around: 'P0001' }) → entries near P0001's timestamp\n" +
353
+ "- Title listing: read_memory({ titles_only: true }) → compact table of contents (ID + date + title)\n\n" +
342
354
  "Lazy loading: ID queries always return the node + its DIRECT children only.\n" +
343
355
  "To go deeper, call read_memory(id=child_id). depth parameter is ignored for ID queries.\n\n" +
344
356
  "Store types:\n" +
@@ -354,9 +366,20 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
354
366
  period: z.string().optional().describe("Time window: '+4h' (after), '-2h' (before), '4h' (±4h symmetric), 'both' (±2h default)"),
355
367
  time_around: z.string().optional().describe("Reference entry ID — find entries created around the same time"),
356
368
  show_obsolete: z.boolean().optional().describe("Include all obsolete entries (default: only top 3 most-accessed)"),
369
+ show_obsolete_path: z.boolean().optional().describe("When reading an obsolete entry by ID, show the full correction chain instead of just the final valid entry."),
370
+ titles_only: z.boolean().optional().describe("Compact title listing — shows all entries as ID + date + title, without V2 selection or children. " +
371
+ "Like a table of contents. Combine with prefix to filter by category."),
372
+ expand: z.boolean().optional().describe("Expand full tree with complete node content (ID queries only). " +
373
+ "Use to deep-dive into a project after a long break. " +
374
+ "depth controls how deep (default: 5 = full tree). " +
375
+ "Example: read_memory({ id: 'P0001', expand: true, depth: 3 })"),
376
+ mode: z.enum(["discover", "essentials"]).optional().describe("Bulk read mode. 'discover' (default for first read): newest-heavy — good for getting an overview. " +
377
+ "'essentials': importance-heavy (more favorites + most-accessed, fewer newest) — " +
378
+ "use after context compression to recover key knowledge. " +
379
+ "Auto-selected if omitted: first bulk read → discover, subsequent → essentials."),
357
380
  store: z.enum(["personal", "company"]).default("personal").describe("Source store: 'personal' or 'company'"),
358
381
  curator: z.boolean().optional().describe("Set true to show full metadata (access counts, roles, dates). For curators only."),
359
- }, async ({ id, depth, prefix, after, before, search, limit: maxResults, time, period, time_around, show_obsolete, store: storeName, curator }) => {
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 }) => {
360
383
  if (AGENT_ID === "UNKNOWN") {
361
384
  return {
362
385
  content: [{ type: "text", text: "ERROR: Agent-ID unknown. read_memory is only available for spawned agents." }],
@@ -374,12 +397,27 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
374
397
  ? "⚠ WARNING: Memory database is corrupted! Reads may be incomplete. A backup (.corrupt) was saved.\n\n"
375
398
  : "";
376
399
  const effectiveDepth = depth || (id ? 2 : 1);
400
+ // Session cache: apply sliding window for bulk reads (personal store only)
401
+ 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;
406
+ // Auto-select mode: first bulk read → discover, subsequent → essentials
407
+ const effectiveMode = mode ?? (useCache && sessionCache.readCount > 0 ? "essentials" : "discover");
377
408
  const entries = hmemStore.read({
378
409
  id, depth: effectiveDepth, prefix, after, before, search,
379
410
  limit: maxResults,
380
411
  agentRole: storeName === "company" ? agentRole : undefined,
381
412
  time, period, timeAround: time_around,
382
413
  showObsolete: show_obsolete,
414
+ showObsoletePath: show_obsolete_path,
415
+ titlesOnly: titles_only,
416
+ expand,
417
+ suppressedIds,
418
+ maxNewNewest,
419
+ maxNewAccess,
420
+ mode: isBulkListing ? effectiveMode : undefined,
383
421
  });
384
422
  if (entries.length === 0) {
385
423
  const hint = id ? `No memory with ID "${id}".` :
@@ -388,17 +426,30 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
388
426
  "No memories found for this query.";
389
427
  return { content: [{ type: "text", text: hint }] };
390
428
  }
429
+ // Update session cache after bulk read
430
+ if (useCache) {
431
+ const allIds = entries.filter(e => !e.obsolete).map(e => e.id);
432
+ const promotedIds = new Set(entries.filter(e => e.promoted === "favorite" || e.promoted === "access").map(e => e.id));
433
+ sessionCache.registerDelivered(allIds, promotedIds);
434
+ }
391
435
  // Format output
392
- const isBulkListing = !id && !search && !time_around;
393
- const output = isBulkListing
394
- ? formatGroupedOutput(hmemStore, entries, curator ?? false, hmemConfig)
395
- : formatFlatOutput(entries, curator ?? false);
436
+ const output = titles_only
437
+ ? formatTitlesOnly(entries, hmemConfig)
438
+ : isBulkListing
439
+ ? formatGroupedOutput(hmemStore, entries, curator ?? false, hmemConfig)
440
+ : formatFlatOutput(entries, curator ?? false, expand ?? false);
396
441
  const stats = hmemStore.stats();
397
442
  const storeLabel = storeName === "company" ? "company" : templateName;
398
443
  const visibleCount = entries.length;
444
+ // Cache status in header (when active)
445
+ const cacheInfo = useCache && sessionCache.size > 0
446
+ ? ` | Cache: ${sessionCache.size} seen`
447
+ : "";
448
+ // Mode info in header (only for bulk reads)
449
+ const modeInfo = isBulkListing ? ` | Mode: ${effectiveMode}` : "";
399
450
  const header = `## Memory: ${storeLabel} (${stats.total} total entries)\n` +
400
- `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}\n`;
401
- log(`read_memory [${storeLabel}]: ${visibleCount} results (depth=${effectiveDepth}, role=${agentRole})`);
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`;
452
+ log(`read_memory [${storeLabel}]: ${visibleCount} results (depth=${effectiveDepth}, role=${agentRole}${cacheInfo})`);
402
453
  return {
403
454
  content: [{
404
455
  type: "text",
@@ -418,6 +469,24 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
418
469
  }
419
470
  });
420
471
  // bump_memory removed — access_count is auto-incremented on reads, favorites cover explicit importance
472
+ // ---- Session Cache Reset ----
473
+ server.tool("reset_memory_cache", "Clear the session cache so all entries are treated as unseen again. " +
474
+ "The next bulk read will behave like the first read of a fresh session " +
475
+ "(full Fibonacci slots, no suppressed entries).\n\n" +
476
+ "Use when you need a clean slate — e.g., after a major topic change " +
477
+ "or when you suspect important entries were suppressed.", {}, async () => {
478
+ const before = sessionCache.size;
479
+ const readsBefore = sessionCache.readCount;
480
+ sessionCache.reset();
481
+ return {
482
+ content: [{
483
+ type: "text",
484
+ text: `Session cache reset. Cleared ${before} tracked entries, ` +
485
+ `bulk read counter ${readsBefore} → 0. ` +
486
+ `Next read_memory() will return the full first-read selection.`,
487
+ }],
488
+ };
489
+ });
421
490
  // ---- Curator Tools (ceo role only) ----
422
491
  const AUDIT_STATE_FILE = process.env.HMEM_AUDIT_STATE_PATH
423
492
  || path.join(PROJECT_DIR, "audit_state.json");
@@ -527,16 +596,22 @@ server.tool("read_agent_memory", "CURATOR ONLY (ceo role). Read the full memory
527
596
  const date = e.created_at.substring(0, 10);
528
597
  const role = e.min_role !== "worker" ? ` [${e.min_role}+]` : "";
529
598
  const access = e.access_count > 0 ? ` (${e.access_count}x)` : "";
530
- lines.push(`[${e.id}] ${date}${role}${access}`);
531
- lines.push(` L1: ${e.level_1}`);
532
- if (e.level_2)
533
- lines.push(` L2: ${e.level_2}`);
534
- if (e.level_3)
535
- lines.push(` L3: ${e.level_3}`);
536
- if (e.level_4)
537
- lines.push(` L4: ${e.level_4}`);
538
- if (e.level_5)
539
- lines.push(` L5: ${e.level_5}`);
599
+ const obsoleteTag = e.obsolete ? " [⚠ OBSOLETE]" : "";
600
+ const irrelevantTag = e.irrelevant ? " [- IRRELEVANT]" : "";
601
+ const favTag = e.favorite ? " [♥]" : "";
602
+ lines.push(`[${e.id}] ${date}${role}${favTag}${obsoleteTag}${irrelevantTag}${access}`);
603
+ lines.push(` ${e.title}`);
604
+ if (e.level_1 !== e.title)
605
+ lines.push(` ${e.level_1}`);
606
+ if (e.children && e.children.length > 0) {
607
+ for (const child of e.children) {
608
+ const indent = " ".repeat(child.depth - 1);
609
+ const hint = (child.child_count ?? 0) > 0
610
+ ? ` (${child.child_count} — use id="${child.id}" to expand)`
611
+ : "";
612
+ lines.push(`${indent}[${child.id}] ${child.title}${hint}`);
613
+ }
614
+ }
540
615
  if (e.links?.length)
541
616
  lines.push(` Links: ${e.links.join(", ")}`);
542
617
  lines.push("");
@@ -550,7 +625,7 @@ server.tool("read_agent_memory", "CURATOR ONLY (ceo role). Read the full memory
550
625
  });
551
626
  server.tool("fix_agent_memory", "CURATOR ONLY (ceo role). Correct a specific entry or node in any agent's memory.\n\n" +
552
627
  "Accepts both root IDs ('L0003') and compound node IDs ('L0003.2'):\n" +
553
- "- Root ID: updates L1 summary text, min_role clearance, and/or obsolete flag\n" +
628
+ "- Root ID: updates L1 summary text, min_role clearance, obsolete/irrelevant/favorite flags\n" +
554
629
  "- Compound node ID: updates the content of that specific node\n\n" +
555
630
  "To fix wrong prefix: delete + re-add (prefix cannot be changed in-place).\n" +
556
631
  "To consolidate fragmented P entries: use read_agent_memory to read them, " +
@@ -564,7 +639,8 @@ server.tool("fix_agent_memory", "CURATOR ONLY (ceo role). Correct a specific ent
564
639
  obsolete: z.boolean().optional().describe("Mark or unmark as obsolete (root entries only). " +
565
640
  "Obsolete entries stay in memory but are shown with [⚠ OBSOLETE]."),
566
641
  favorite: z.boolean().optional().describe("Set or clear the [♥] favorite flag (root entries only)."),
567
- }, async ({ agent_name, entry_id, content, min_role, obsolete, favorite }) => {
642
+ irrelevant: z.boolean().optional().describe("Mark or unmark as irrelevant (root entries only). Irrelevant entries are hidden from bulk reads. No correction entry needed."),
643
+ }, async ({ agent_name, entry_id, content, min_role, obsolete, favorite, irrelevant }) => {
568
644
  if (!isCurator()) {
569
645
  return {
570
646
  content: [{ type: "text", text: "ERROR: fix_agent_memory is only available to the ceo/curator role." }],
@@ -597,19 +673,21 @@ server.tool("fix_agent_memory", "CURATOR ONLY (ceo role). Correct a specific ent
597
673
  }
598
674
  else {
599
675
  // Root entry — update memories table
600
- if (!content && min_role === undefined && obsolete === undefined && favorite === undefined) {
676
+ if (!content && min_role === undefined && obsolete === undefined && favorite === undefined && irrelevant === undefined) {
601
677
  return {
602
- content: [{ type: "text", text: "ERROR: Provide at least one of: content, min_role, obsolete, favorite." }],
678
+ content: [{ type: "text", text: "ERROR: Provide at least one of: content, min_role, obsolete, favorite, irrelevant." }],
603
679
  isError: true,
604
680
  };
605
681
  }
606
682
  if (content) {
607
- ok = store.updateNode(entry_id, content, undefined, obsolete, favorite, true /* curatorBypass */);
683
+ ok = store.updateNode(entry_id, content, undefined, obsolete, favorite, true /* curatorBypass */, irrelevant);
608
684
  changed.push("L1");
609
685
  if (obsolete !== undefined)
610
686
  changed.push("obsolete");
611
687
  if (favorite !== undefined)
612
688
  changed.push("favorite");
689
+ if (irrelevant !== undefined)
690
+ changed.push("irrelevant");
613
691
  }
614
692
  else {
615
693
  const fields = {};
@@ -619,6 +697,8 @@ server.tool("fix_agent_memory", "CURATOR ONLY (ceo role). Correct a specific ent
619
697
  fields.obsolete = obsolete;
620
698
  if (favorite !== undefined)
621
699
  fields.favorite = favorite;
700
+ if (irrelevant !== undefined)
701
+ fields.irrelevant = irrelevant;
622
702
  ok = store.update(entry_id, fields);
623
703
  }
624
704
  if (min_role !== undefined)
@@ -627,6 +707,8 @@ server.tool("fix_agent_memory", "CURATOR ONLY (ceo role). Correct a specific ent
627
707
  changed.push("obsolete");
628
708
  if (!content && favorite !== undefined)
629
709
  changed.push("favorite");
710
+ if (!content && irrelevant !== undefined)
711
+ changed.push("irrelevant");
630
712
  }
631
713
  log(`fix_agent_memory [CURATOR]: ${agent_name} ${entry_id} → ${ok ? "updated" : "not found"} (${changed.join(", ")})`);
632
714
  return {
@@ -752,6 +834,55 @@ server.tool("mark_audited", "CURATOR ONLY (ceo role). Mark an agent as audited (
752
834
  * Format bulk-read output grouped by prefix with header entries.
753
835
  * Non-curator: strips [♥], [★] markers, shortens [OBSOLETE] to [!].
754
836
  */
837
+ /**
838
+ * Format compact title listing — ID + date + title, grouped by prefix.
839
+ * V2 selection applies. Favorites/top-accessed show L2 children titles.
840
+ * Non-expanded entries show (N) child count indicator.
841
+ */
842
+ function formatTitlesOnly(entries, config) {
843
+ const CHILD_TITLE_LEN = 50;
844
+ const lines = [];
845
+ const byPrefix = new Map();
846
+ for (const e of entries) {
847
+ const arr = byPrefix.get(e.prefix);
848
+ if (arr)
849
+ arr.push(e);
850
+ else
851
+ byPrefix.set(e.prefix, [e]);
852
+ }
853
+ for (const [prefix, prefixEntries] of byPrefix) {
854
+ const desc = config.prefixDescriptions[prefix] ?? config.prefixes[prefix] ?? prefix;
855
+ lines.push(`## ${desc} (${prefixEntries.length} total)\n`);
856
+ for (const e of prefixEntries) {
857
+ const mmdd = e.created_at.substring(5, 10);
858
+ const fav = e.favorite ? " [♥]" : "";
859
+ const obs = e.obsolete ? " [!]" : "";
860
+ const irr = e.irrelevant ? " [-]" : "";
861
+ if (e.expanded && e.children && e.children.length > 0) {
862
+ // 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) {
865
+ const short = child.title || (child.content.length > CHILD_TITLE_LEN
866
+ ? child.content.substring(0, CHILD_TITLE_LEN)
867
+ : child.content);
868
+ const grandchildren = (child.child_count ?? 0) > 0 ? ` (${child.child_count})` : "";
869
+ const cfav = child.favorite ? " [♥]" : "";
870
+ lines.push(` ${child.id}${cfav} ${short}${grandchildren}`);
871
+ }
872
+ if (e.hiddenChildrenCount && e.hiddenChildrenCount > 0) {
873
+ lines.push(` [+${e.hiddenChildrenCount} more → ${e.id}]`);
874
+ }
875
+ }
876
+ else {
877
+ // Non-expanded: compact line with child count
878
+ const childHint = (e.hiddenChildrenCount ?? 0) > 0 ? ` (${e.hiddenChildrenCount})` : "";
879
+ lines.push(`${e.id} ${mmdd}${fav}${obs} ${e.title}${childHint}`);
880
+ }
881
+ }
882
+ lines.push("");
883
+ }
884
+ return lines.join("\n");
885
+ }
755
886
  function formatGroupedOutput(store, entries, curator, config) {
756
887
  const lines = [];
757
888
  const headers = store.getHeaders();
@@ -787,122 +918,199 @@ function formatGroupedOutput(store, entries, curator, config) {
787
918
  }
788
919
  return lines.join("\n");
789
920
  }
790
- function formatFlatOutput(entries, curator) {
921
+ function formatFlatOutput(entries, curator, expand = false) {
791
922
  const lines = [];
923
+ // Obsolete chain resolution note
924
+ if (entries.length > 0 && entries[0].obsoleteChain && entries[0].obsoleteChain.length > 1) {
925
+ const chain = entries[0].obsoleteChain;
926
+ if (entries.length === 1) {
927
+ const chainStr = chain.slice(0, -1).map(id => `${id} [!]`).join(" → ") + ` → ${chain[chain.length - 1]} ✓`;
928
+ lines.push(`[Resolved: ${chainStr}]\n`);
929
+ }
930
+ else {
931
+ lines.push(`[Chain: ${chain.join(" → ")}]\n`);
932
+ }
933
+ }
792
934
  for (const e of entries) {
793
- renderEntryFormatted(lines, e, curator);
935
+ renderEntryFormatted(lines, e, curator, expand);
794
936
  }
795
937
  return lines.join("\n");
796
938
  }
797
- function renderEntryFormatted(lines, e, curator) {
939
+ /** Favorite marker for child nodes. */
940
+ function nodeFav(node) {
941
+ return node.favorite ? " [♥]" : "";
942
+ }
943
+ function renderEntryFormatted(lines, e, curator, expand = false) {
798
944
  const isNode = e.id.includes(".");
945
+ const hasDetail = !!(e.children?.length || e.linkedEntries?.length);
946
+ // Headline: use title for navigation, show full content below when drilling in
799
947
  if (isNode) {
800
948
  if (curator) {
801
- const nodeDepth = (e.id.match(/\./g) || []).length + 1;
802
- lines.push(`[${e.id}] L${nodeDepth}: ${e.level_1}`);
949
+ lines.push(`[${e.id}] ${e.title}`);
803
950
  }
804
951
  else {
805
- lines.push(`${e.id} ${e.level_1}`);
952
+ lines.push(`${e.id} ${e.title}`);
953
+ }
954
+ // Node drilldown: show full content below title
955
+ if (hasDetail && e.level_1 !== e.title) {
956
+ lines.push(` ${e.level_1}`);
806
957
  }
807
958
  }
808
959
  else {
809
960
  if (curator) {
810
961
  const promotedTag = e.promoted === "favorite" ? " [♥]" : e.promoted === "access" ? " [★]" : "";
811
962
  const obsoleteTag = e.obsolete ? " [⚠ OBSOLETE]" : "";
963
+ const irrelevantTag = e.irrelevant ? " [- IRRELEVANT]" : "";
812
964
  const date = e.created_at.substring(0, 10);
813
965
  const accessed = e.access_count > 0 ? ` (${e.access_count}x accessed)` : "";
814
966
  const roleTag = e.min_role !== "worker" ? ` [${e.min_role}+]` : "";
815
- lines.push(`[${e.id}] ${date}${roleTag}${promotedTag}${obsoleteTag}${accessed}`);
816
- lines.push(` L1: ${e.level_1}`);
967
+ lines.push(`[${e.id}] ${date}${roleTag}${promotedTag}${obsoleteTag}${irrelevantTag}${accessed}`);
968
+ lines.push(` ${e.title}`);
817
969
  }
818
970
  else {
819
- // Non-curator: [♥] favorites, [★] promoted, [!] obsolete
820
971
  const promotedTag = e.promoted === "favorite" ? " [♥]" : e.promoted === "access" ? " [★]" : "";
821
972
  const obsoleteTag = e.obsolete ? " [!]" : "";
973
+ const irrelevantTag = e.irrelevant ? " [-]" : "";
822
974
  const mmdd = e.created_at.substring(5, 10);
823
- lines.push(`${e.id} ${mmdd}${promotedTag}${obsoleteTag} ${e.level_1}`);
975
+ lines.push(`${e.id} ${mmdd}${promotedTag}${obsoleteTag}${irrelevantTag} ${e.title}`);
976
+ }
977
+ // Show full level_1 content below title when entry is expanded/drilled
978
+ if (hasDetail && e.level_1 !== e.title) {
979
+ lines.push(` ${e.level_1}`);
824
980
  }
825
981
  }
982
+ // Children
826
983
  if (e.children && e.children.length > 0) {
827
- if (e.expanded) {
828
- if (curator) {
829
- lines.push(` ${e.children.length} ${e.children.length === 1 ? "child" : "children"}:`);
830
- }
984
+ if (expand) {
985
+ // Expand mode: full content + recursive children
986
+ renderChildrenExpanded(lines, e.children, curator);
987
+ }
988
+ else if (e.expanded && !expand) {
831
989
  renderChildrenFormatted(lines, e.children, curator);
990
+ if (e.hiddenChildrenCount && e.hiddenChildrenCount > 0) {
991
+ lines.push(` [+${e.hiddenChildrenCount} more → ${e.id}]`);
992
+ }
832
993
  }
833
994
  else if (e.hiddenChildrenCount !== undefined) {
995
+ // Non-expanded bulk read: show only the latest child title
834
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
+ : "";
835
1001
  if (curator) {
836
- const hint = (child.child_count ?? 0) > 0
837
- ? ` (${child.child_count} — use id="${child.id}" to expand)`
838
- : "";
839
- lines.push(` [${child.id}] L${child.depth}: ${child.content}${hint}`);
1002
+ lines.push(` [${child.id}]${fav} ${child.title}${hint}`);
840
1003
  }
841
1004
  else {
842
- const hint = (child.child_count ?? 0) > 0
843
- ? ` [+${child.child_count} → ${child.id}]`
844
- : "";
845
- lines.push(` ${child.depth}.${child.seq} ${child.content}${hint}`);
846
- }
847
- if (child.children && child.children.length > 0) {
848
- renderChildrenFormatted(lines, child.children, curator);
1005
+ lines.push(` ${child.id}${fav} ${child.title}${hint}`);
849
1006
  }
850
1007
  if (e.hiddenChildrenCount > 0) {
851
1008
  lines.push(` [+${e.hiddenChildrenCount} more → ${e.id}]`);
852
1009
  }
853
1010
  }
854
1011
  else {
855
- if (curator) {
856
- lines.push(` ${e.children.length} ${e.children.length === 1 ? "child" : "children"}:`);
857
- }
1012
+ // ID-based read: show all direct children as titles
858
1013
  renderChildrenFormatted(lines, e.children, curator);
859
1014
  }
860
1015
  }
861
- if (e.links && e.links.length > 0)
862
- lines.push(` Links: ${e.links.join(", ")}`);
1016
+ // Links
1017
+ if (e.links && e.links.length > 0) {
1018
+ const parts = [`Links: ${e.links.join(", ")}`];
1019
+ const hiddenParts = [];
1020
+ if (e.hiddenObsoleteLinks && e.hiddenObsoleteLinks > 0)
1021
+ hiddenParts.push(`${e.hiddenObsoleteLinks} obsolete`);
1022
+ if (e.hiddenIrrelevantLinks && e.hiddenIrrelevantLinks > 0)
1023
+ hiddenParts.push(`${e.hiddenIrrelevantLinks} irrelevant`);
1024
+ if (hiddenParts.length > 0)
1025
+ parts.push(`(+${hiddenParts.join(", ")} hidden)`);
1026
+ lines.push(` ${parts.join(" ")}`);
1027
+ }
1028
+ // Auto-resolved linked entries
863
1029
  if (e.linkedEntries && e.linkedEntries.length > 0) {
864
1030
  lines.push(` --- Linked entries ---`);
865
1031
  for (const linked of e.linkedEntries) {
866
1032
  const isLinkedNode = linked.id.includes(".");
867
1033
  if (isLinkedNode) {
868
- const d = (linked.id.match(/\./g) || []).length + 1;
869
- lines.push(` [${linked.id}] L${d}: ${linked.level_1}`);
1034
+ lines.push(` [${linked.id}] ${linked.title}`);
870
1035
  }
871
1036
  else {
872
1037
  const ldate = linked.created_at.substring(0, 10);
873
1038
  lines.push(` [${linked.id}] ${ldate}`);
874
- lines.push(` L1: ${linked.level_1}`);
1039
+ lines.push(` ${linked.title}`);
875
1040
  }
1041
+ // Linked children as titles
876
1042
  if (linked.children && linked.children.length > 0) {
877
1043
  for (const lchild of linked.children) {
878
- const cd = (lchild.id.match(/\./g) || []).length + 1;
879
1044
  const hint = (lchild.child_count ?? 0) > 0
880
1045
  ? ` (${lchild.child_count} ${lchild.child_count === 1 ? "child" : "children"} — use id="${lchild.id}" to expand)`
881
1046
  : "";
882
- lines.push(` [${lchild.id}] L${cd}: ${lchild.content}${hint}`);
1047
+ lines.push(` [${lchild.id}]${nodeFav(lchild)} ${lchild.title}${hint}`);
883
1048
  }
884
1049
  }
885
1050
  }
886
1051
  }
887
1052
  lines.push("");
888
1053
  }
1054
+ /**
1055
+ * Render a list of child nodes — shows titles for navigation.
1056
+ * Use read_memory(id=child.id) to see full content.
1057
+ */
889
1058
  function renderChildrenFormatted(lines, children, curator) {
890
1059
  for (const child of children) {
891
1060
  const indent = " ".repeat(child.depth - 1);
1061
+ const fav = nodeFav(child);
1062
+ const hint = (child.child_count ?? 0) > 0
1063
+ ? ` [+${child.child_count} → ${child.id}]`
1064
+ : "";
892
1065
  if (curator) {
893
- const hint = (child.child_count ?? 0) > 0
894
- ? ` (${child.child_count} — use id="${child.id}" to expand)`
895
- : "";
896
- lines.push(`${indent}[${child.id}] L${child.depth}: ${child.content}${hint}`);
1066
+ lines.push(`${indent}[${child.id}]${fav} ${child.title}${hint}`);
897
1067
  }
898
1068
  else {
899
- const hint = (child.child_count ?? 0) > 0
900
- ? ` [+${child.child_count} → ${child.id}]`
901
- : "";
902
- lines.push(`${indent}${child.depth}.${child.seq} ${child.content}${hint}`);
1069
+ lines.push(`${indent}${child.id}${fav} ${child.title}${hint}`);
903
1070
  }
904
- if (child.children && child.children.length > 0) {
905
- renderChildrenFormatted(lines, child.children, curator);
1071
+ // Don't recurse into grandchildren titles only, drill for content
1072
+ }
1073
+ }
1074
+ /**
1075
+ * Render children with full content (expand mode).
1076
+ * Shows complete node text and recurses into grandchildren.
1077
+ * At the depth boundary (children loaded but THEIR children are not),
1078
+ * renders as titles instead of full content.
1079
+ */
1080
+ function renderChildrenExpanded(lines, children, curator) {
1081
+ for (const child of children) {
1082
+ const indent = " ".repeat(child.depth - 1);
1083
+ const fav = nodeFav(child);
1084
+ const hasLoadedChildren = child.children && child.children.length > 0;
1085
+ const isBoundary = !hasLoadedChildren && (child.child_count ?? 0) > 0;
1086
+ if (hasLoadedChildren) {
1087
+ // Inner node: full content + recurse
1088
+ if (curator) {
1089
+ lines.push(`${indent}[${child.id}]${fav} ${child.content}`);
1090
+ }
1091
+ else {
1092
+ lines.push(`${indent}${child.id}${fav} ${child.content}`);
1093
+ }
1094
+ renderChildrenExpanded(lines, child.children, curator);
1095
+ }
1096
+ else if (isBoundary) {
1097
+ // Boundary: title only + child count hint
1098
+ const hint = ` [+${child.child_count} → ${child.id}]`;
1099
+ if (curator) {
1100
+ lines.push(`${indent}[${child.id}]${fav} ${child.title}${hint}`);
1101
+ }
1102
+ else {
1103
+ lines.push(`${indent}${child.id}${fav} ${child.title}${hint}`);
1104
+ }
1105
+ }
1106
+ else {
1107
+ // Leaf node (no children at all): full content
1108
+ if (curator) {
1109
+ lines.push(`${indent}[${child.id}]${fav} ${child.content}`);
1110
+ }
1111
+ else {
1112
+ lines.push(`${indent}${child.id}${fav} ${child.content}`);
1113
+ }
906
1114
  }
907
1115
  }
908
1116
  }