hmem-mcp 4.0.0 → 5.1.21

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.
Files changed (47) hide show
  1. package/README.md +161 -205
  2. package/dist/cli-checkpoint.d.ts +16 -0
  3. package/dist/cli-checkpoint.js +233 -0
  4. package/dist/cli-checkpoint.js.map +1 -0
  5. package/dist/cli-context-inject.d.ts +19 -0
  6. package/dist/cli-context-inject.js +77 -0
  7. package/dist/cli-context-inject.js.map +1 -0
  8. package/dist/cli-env.d.ts +16 -0
  9. package/dist/cli-env.js +40 -0
  10. package/dist/cli-env.js.map +1 -0
  11. package/dist/cli-hook-startup.d.ts +20 -0
  12. package/dist/cli-hook-startup.js +101 -0
  13. package/dist/cli-hook-startup.js.map +1 -0
  14. package/dist/cli-init.js +148 -10
  15. package/dist/cli-init.js.map +1 -1
  16. package/dist/cli-log-exchange.js +87 -23
  17. package/dist/cli-log-exchange.js.map +1 -1
  18. package/dist/cli-statusline.d.ts +14 -0
  19. package/dist/cli-statusline.js +172 -0
  20. package/dist/cli-statusline.js.map +1 -0
  21. package/dist/cli.js +30 -2
  22. package/dist/cli.js.map +1 -1
  23. package/dist/hmem-config.d.ts +31 -0
  24. package/dist/hmem-config.js +76 -12
  25. package/dist/hmem-config.js.map +1 -1
  26. package/dist/hmem-store.d.ts +62 -1
  27. package/dist/hmem-store.js +364 -46
  28. package/dist/hmem-store.js.map +1 -1
  29. package/dist/mcp-server.js +405 -99
  30. package/dist/mcp-server.js.map +1 -1
  31. package/dist/session-cache.d.ts +11 -0
  32. package/dist/session-cache.js +25 -0
  33. package/dist/session-cache.js.map +1 -1
  34. package/package.json +1 -1
  35. package/scripts/autoresearch-nightly.sh +84 -0
  36. package/scripts/hmem-statusline.sh +4 -0
  37. package/skills/hmem-config/SKILL.md +112 -147
  38. package/skills/hmem-curate/SKILL.md +56 -6
  39. package/skills/hmem-new-project/SKILL.md +164 -0
  40. package/skills/hmem-read/SKILL.md +174 -146
  41. package/skills/hmem-release/SKILL.md +141 -0
  42. package/skills/hmem-self-curate/SKILL.md +49 -7
  43. package/skills/hmem-setup/SKILL.md +169 -87
  44. package/skills/hmem-sync-setup/SKILL.md +16 -3
  45. package/skills/hmem-update/SKILL.md +254 -0
  46. package/skills/hmem-wipe/SKILL.md +75 -0
  47. package/skills/hmem-write/SKILL.md +113 -61
@@ -114,15 +114,16 @@ function syncPull(hmemPath) {
114
114
  continue;
115
115
  const result = spawnSync("hmem-sync", [
116
116
  "pull", "--config", hmemSyncConfig(hmemPath),
117
+ "--hmem-path", hmemPath,
117
118
  "--server-url", s.serverUrl, "--token", s.token,
118
- ], { env: { ...process.env }, encoding: "utf8", shell: process.platform === "win32" });
119
+ ], { env: { ...process.env }, encoding: "utf8", shell: process.platform === "win32", windowsHide: true });
119
120
  if (result.error)
120
121
  process.stderr.write(`hmem-sync pull error (${s.name ?? s.serverUrl}): ${result.error.message}\n`);
121
122
  }
122
123
  }
123
124
  else {
124
- const result = spawnSync("hmem-sync", ["pull", "--config", hmemSyncConfig(hmemPath)], {
125
- env: { ...process.env }, encoding: "utf8", shell: process.platform === "win32",
125
+ const result = spawnSync("hmem-sync", ["pull", "--config", hmemSyncConfig(hmemPath), "--hmem-path", hmemPath], {
126
+ env: { ...process.env }, encoding: "utf8", shell: process.platform === "win32", windowsHide: true,
126
127
  });
127
128
  if (result.error)
128
129
  process.stderr.write(`hmem-sync pull error: ${result.error.message}\n`);
@@ -172,13 +173,14 @@ function syncPullThenPush(hmemPath) {
172
173
  continue;
173
174
  spawnSync("hmem-sync", [
174
175
  "pull", "--config", hmemSyncConfig(hmemPath),
176
+ "--hmem-path", hmemPath,
175
177
  "--server-url", s.serverUrl, "--token", s.token,
176
- ], { env: { ...process.env }, encoding: "utf8", shell: process.platform === "win32" });
178
+ ], { env: { ...process.env }, encoding: "utf8", shell: process.platform === "win32", windowsHide: true });
177
179
  }
178
180
  }
179
181
  else {
180
- spawnSync("hmem-sync", ["pull", "--config", hmemSyncConfig(hmemPath)], {
181
- env: { ...process.env }, encoding: "utf8", shell: process.platform === "win32",
182
+ spawnSync("hmem-sync", ["pull", "--config", hmemSyncConfig(hmemPath), "--hmem-path", hmemPath], {
183
+ env: { ...process.env }, encoding: "utf8", shell: process.platform === "win32", windowsHide: true,
182
184
  });
183
185
  }
184
186
  lastPullAt = Date.now();
@@ -193,14 +195,15 @@ function syncPush(hmemPath) {
193
195
  continue;
194
196
  const child = spawn("hmem-sync", [
195
197
  "push", "--config", hmemSyncConfig(hmemPath),
198
+ "--hmem-path", hmemPath,
196
199
  "--server-url", s.serverUrl, "--token", s.token,
197
- ], { env: { ...process.env }, shell: process.platform === "win32", stdio: "ignore", detached: true });
200
+ ], { env: { ...process.env }, shell: process.platform === "win32", stdio: "ignore", detached: true, windowsHide: true });
198
201
  child.unref();
199
202
  }
200
203
  }
201
204
  else {
202
- const child = spawn("hmem-sync", ["push", "--config", hmemSyncConfig(hmemPath)], {
203
- env: { ...process.env }, shell: process.platform === "win32", stdio: "ignore", detached: true,
205
+ const child = spawn("hmem-sync", ["push", "--config", hmemSyncConfig(hmemPath), "--hmem-path", hmemPath], {
206
+ env: { ...process.env }, shell: process.platform === "win32", stdio: "ignore", detached: true, windowsHide: true,
204
207
  });
205
208
  child.unref();
206
209
  }
@@ -208,8 +211,127 @@ function syncPush(hmemPath) {
208
211
  // Load hmem config (hmem.config.json in project dir, falls back to defaults)
209
212
  const hmemConfig = loadHmemConfig(PROJECT_DIR);
210
213
  log(`Config: levels=[${hmemConfig.maxCharsPerLevel.join(",")}] depth=${hmemConfig.maxDepth}`);
214
+ // ---- Version upgrade detection ----
215
+ import { createRequire } from "node:module";
216
+ const _require = createRequire(import.meta.url);
217
+ const PKG_VERSION = _require("../package.json").version;
218
+ /** Check if hmem was upgraded since last session. Auto-syncs skills and returns upgrade notice. */
219
+ function checkVersionUpgrade() {
220
+ try {
221
+ const configPath = path.join(PROJECT_DIR, "hmem.config.json");
222
+ if (!fs.existsSync(configPath))
223
+ return "";
224
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
225
+ const lastSeen = raw?.memory?.lastSeenVersion || raw?.lastSeenVersion;
226
+ if (!lastSeen) {
227
+ // First run with version tracking — save current version, sync skills silently
228
+ saveLastSeenVersion(configPath, raw);
229
+ autoSyncSkills();
230
+ return "";
231
+ }
232
+ if (lastSeen !== PKG_VERSION) {
233
+ saveLastSeenVersion(configPath, raw);
234
+ autoSyncSkills();
235
+ return `\n\n⚠ hmem-mcp updated: v${lastSeen} → v${PKG_VERSION}. Skills have been auto-synced. Run /hmem-update for full post-update steps (entry migration, schema enforcement, config check).`;
236
+ }
237
+ }
238
+ catch { }
239
+ return "";
240
+ }
241
+ /** Auto-sync skill files on version upgrade. Runs hmem update-skills in background. */
242
+ function autoSyncSkills() {
243
+ try {
244
+ const child = spawn("hmem", ["update-skills"], {
245
+ detached: true, stdio: "ignore",
246
+ env: { ...process.env },
247
+ });
248
+ child.unref();
249
+ log("Auto-syncing skills after version upgrade");
250
+ }
251
+ catch { }
252
+ }
253
+ function saveLastSeenVersion(configPath, raw) {
254
+ try {
255
+ if (!raw.memory)
256
+ raw.memory = {};
257
+ raw.memory.lastSeenVersion = PKG_VERSION;
258
+ fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf8");
259
+ }
260
+ catch { }
261
+ }
262
+ let versionUpgradeNotice = checkVersionUpgrade();
211
263
  // Session-scoped cache — persists across tool calls within this MCP connection
212
264
  const sessionCache = new SessionCache();
265
+ const CONTEXT_THRESHOLD_WARNING = "\n\n⚠ CONTEXT THRESHOLD REACHED (~{tokens}k tokens delivered this session).\n" +
266
+ "Tell the user to run /hmem-wipe — it saves key knowledge and prepares for /clear.\n" +
267
+ "Alternative: flush_context manually, then /clear, then load_project to restore context.";
268
+ const CACHE_RESET_SIGNAL = "/tmp/hmem-cache-reset-signal";
269
+ /** Track tokens in a tool response and append threshold warning if needed. */
270
+ function trackTokens(result) {
271
+ // Check for /clear signal from hook
272
+ if (fs.existsSync(CACHE_RESET_SIGNAL)) {
273
+ try {
274
+ fs.unlinkSync(CACHE_RESET_SIGNAL);
275
+ }
276
+ catch { }
277
+ sessionCache.reset();
278
+ log("Session cache reset via /clear signal");
279
+ }
280
+ if (result.isError)
281
+ return result;
282
+ const text = result.content.map(c => c.text).join("");
283
+ sessionCache.addTokens(text.length);
284
+ // One-time version upgrade notice (shown once per session)
285
+ if (versionUpgradeNotice) {
286
+ result.content[result.content.length - 1].text += versionUpgradeNotice;
287
+ versionUpgradeNotice = ""; // only show once
288
+ }
289
+ if (sessionCache.checkThreshold(hmemConfig.contextTokenThreshold)) {
290
+ const tokK = Math.round(sessionCache.totalTokensDelivered / 1000);
291
+ result.content[result.content.length - 1].text += CONTEXT_THRESHOLD_WARNING.replace("{tokens}", String(tokK));
292
+ }
293
+ return result;
294
+ }
295
+ /**
296
+ * Format recent O-entries block: latest O-entry with full exchanges, rest as titles.
297
+ * @param store - HmemStore instance
298
+ * @param limit - total O-entries to show
299
+ * @param exchangeCount - number of exchanges to show from the latest O-entry
300
+ * @param linkedTo - optional project ID filter
301
+ * @returns formatted string + list of O-entry IDs for cache registration
302
+ */
303
+ function formatRecentOEntries(store, limit, exchangeCount, linkedTo, expandAll) {
304
+ if (limit <= 0)
305
+ return { text: "", ids: [] };
306
+ const recentO = store.getRecentOEntries(limit, linkedTo);
307
+ if (recentO.length === 0)
308
+ return { text: "", ids: [] };
309
+ const lines = ["Recent sessions:"];
310
+ const ids = recentO.map(o => o.id);
311
+ for (let i = 0; i < recentO.length; i++) {
312
+ const o = recentO[i];
313
+ lines.push(` ${o.id} ${o.created_at.substring(0, 10)} ${o.title}`);
314
+ // Expand exchanges: all entries when expandAll, otherwise only latest
315
+ if (expandAll || i === 0) {
316
+ // Check for checkpoint summaries — if present, show summary + only exchanges after it
317
+ // Always show: latest summary (if any) + last 5 exchanges verbatim
318
+ const VERBATIM_WINDOW = 5;
319
+ const summaries = store.getCheckpointSummaries(o.id, 1);
320
+ if (summaries.length > 0) {
321
+ lines.push(` [Summary] ${summaries[0].content}`);
322
+ }
323
+ const exchanges = store.getOEntryExchanges(o.id, VERBATIM_WINDOW, true);
324
+ for (const ex of exchanges) {
325
+ const userShort = ex.userText.length > 300 ? ex.userText.substring(0, 300) + "..." : ex.userText;
326
+ const agentShort = ex.agentText.length > 500 ? ex.agentText.substring(0, 500) + "..." : ex.agentText;
327
+ lines.push(` USER: ${userShort}`);
328
+ if (agentShort)
329
+ lines.push(` AGENT: ${agentShort}`);
330
+ }
331
+ }
332
+ }
333
+ return { text: lines.join("\n"), ids };
334
+ }
213
335
  // ---- Server ----
214
336
  const server = new McpServer({
215
337
  name: "hmem",
@@ -277,26 +399,30 @@ server.tool("write_memory", "Write a new memory entry to your hierarchical long-
277
399
  " Level 3: 2 tabs — even more detail\n" +
278
400
  " Level 4: 3 tabs — fine-grained detail\n" +
279
401
  " Level 5: 4 tabs — raw context/data\n" +
402
+ "Use > lines for body text (shown on drill-down, hidden in listings):\n" +
403
+ " Title line\\n> Body line 1\\n> Body line 2\\n\\tChild title\\n\\t> Child body\n" +
280
404
  "The system auto-assigns an ID and timestamp. " +
281
405
  `Use prefix to categorize: ${prefixList}.\n\n` +
282
406
  "Store types:\n" +
283
407
  " personal (default): Your private memory\n", {
284
408
  prefix: z.string().toUpperCase().describe(`Memory category: ${prefixList}`),
285
- content: z.string().min(3).describe("The memory content. Use tab indentation for depth levels. Example:\n" +
286
- "Built the Council Dashboard for Althing Inc.\n" +
287
- "\tMy role was frontend architecture with React + Vite\n" +
288
- "\t\tShadcnUI for components, SSE for real-time updates\n" +
289
- "\t\t\tAuth was tricky — EventSource can't send custom headers"),
409
+ content: z.string().min(3).describe("The memory content. Use tab indentation for depth levels. Use > for body text (hidden in listings, shown on drill-down).\n" +
410
+ "Example:\n" +
411
+ "Council Dashboard for Althing Inc.\n" +
412
+ "> Built a real-time dashboard with React + Vite. ShadcnUI for components, SSE for live updates.\n" +
413
+ "\tFrontend architecture\n" +
414
+ "\t> React + Vite, ShadcnUI components, SSE for real-time updates\n" +
415
+ "\t\tAuth was tricky — EventSource can't send custom headers"),
290
416
  links: z.array(z.string()).optional().describe("Optional: IDs of related memories, e.g. ['P0001', 'L0005']"),
291
- favorite: z.boolean().optional().describe("Mark this entry as a favorite — shown with [♥] in bulk reads and always inlined with L2 detail. " +
417
+ favorite: z.coerce.boolean().optional().describe("Mark this entry as a favorite — shown with [♥] in bulk reads and always inlined with L2 detail. " +
292
418
  "Use for reference info you need to see every session, regardless of category."),
293
419
  tags: z.array(z.string()).min(1).describe("Required hashtags for cross-cutting search (min 1, recommend 3+). " +
294
420
  "E.g. ['#hmem', '#curation']. Max 10, lowercase, must start with #. Shown after title in reads."),
295
- pinned: z.boolean().optional().describe("Mark this entry as pinned [P] (super-favorite). Pinned entries show full L2 content in bulk reads. " +
421
+ pinned: z.coerce.boolean().optional().describe("Mark this entry as pinned [P] (super-favorite). Pinned entries show full L2 content in bulk reads. " +
296
422
  "Use for reference entries you need to see in full every session."),
297
423
  store: z.enum(["personal", "company"]).default("personal").describe("Target store: 'personal' or 'company'"),
298
424
  min_role: z.enum(["worker", "al", "pl", "ceo"]).default("worker").describe("Minimum role to see this entry"),
299
- force: z.boolean().optional().describe("Force creation of a new root entry even if existing entries share tags. " +
425
+ force: z.coerce.boolean().optional().describe("Force creation of a new root entry even if existing entries share tags. " +
300
426
  "Only use when you intentionally want a separate entry, not a child of an existing one."),
301
427
  }, async ({ prefix, content, links, favorite, tags, pinned, store: storeName, min_role: minRole, force }) => {
302
428
  const templateName = AGENT_ID.replace(/_\d+$/, "");
@@ -313,7 +439,7 @@ server.tool("write_memory", "Write a new memory entry to your hierarchical long-
313
439
  if (prefix.toUpperCase() === "P") {
314
440
  const VALID_L2_CATEGORIES = [
315
441
  "overview", "codebase", "usage", "context", "deployment",
316
- "known issues", "protocol", "open tasks",
442
+ "bugs", "protocol", "open tasks", "ideas",
317
443
  ];
318
444
  const lines = content.split("\n");
319
445
  const l2Lines = lines.filter(l => /^\t[^\t]/.test(l)).map(l => l.replace(/^\t/, "").toLowerCase().trim());
@@ -393,9 +519,11 @@ server.tool("write_memory", "Write a new memory entry to your hierarchical long-
393
519
  });
394
520
  server.tool("update_memory", "Update the text of an existing memory entry or sub-node (your own personal memory). " +
395
521
  "Only modifies the text at the specified ID — children are preserved unchanged.\n\n" +
522
+ "Supports > body format: 'New title\\n> Body line 1\\n> Body line 2' splits into title (shown in listings) + body (shown on drill-down).\n\n" +
396
523
  "Use cases:\n" +
397
524
  "- Correct outdated wording: update_memory(id='L0003', content='corrected summary')\n" +
398
- "- Fix a sub-node: update_memory(id='L0003.2', content='corrected detail')\n" +
525
+ "- Add title/body split: update_memory(id='L0003', content='Short title\\n> Detailed body text')\n" +
526
+ "- Fix a sub-node: update_memory(id='L0003.2', content='node title\\n> node body')\n" +
399
527
  "- Mark as obsolete: FIRST write the correction, THEN update with [✓ID] reference:\n" +
400
528
  " 1. write_memory(prefix='E', content='Correct fix is...') → E0076\n" +
401
529
  " 2. update_memory(id='E0042', content='Wrong — see [✓E0076]', obsolete=true)\n" +
@@ -407,17 +535,17 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
407
535
  id: z.string().describe("ID of the entry or node to update, e.g. 'L0003' or 'L0003.2'"),
408
536
  content: z.string().min(1).describe("New text content for this node (plain text, no indentation)"),
409
537
  links: z.array(z.string()).optional().describe("Optional: update linked entry IDs (root entries only). Replaces existing links."),
410
- obsolete: z.boolean().optional().describe("Mark this root entry as no longer valid (root entries only). " +
538
+ obsolete: z.coerce.boolean().optional().describe("Mark this root entry as no longer valid (root entries only). " +
411
539
  "Requires [✓ID] correction reference in content (e.g. 'Wrong — see [✓E0076]')."),
412
- favorite: z.boolean().optional().describe("Set or clear the [♥] favorite flag. Works on root entries and sub-nodes. " +
540
+ favorite: z.coerce.boolean().optional().describe("Set or clear the [♥] favorite flag. Works on root entries and sub-nodes. " +
413
541
  "Root favorites are always shown with L2 detail in bulk reads."),
414
- irrelevant: z.boolean().optional().describe("Mark as irrelevant [-]. Works on root entries and sub-nodes. " +
542
+ irrelevant: z.coerce.boolean().optional().describe("Mark as irrelevant [-]. Works on root entries and sub-nodes. " +
415
543
  "No correction entry needed (unlike obsolete). Irrelevant entries/nodes are hidden from output."),
416
544
  tags: z.array(z.string()).optional().describe("Set tags on this entry/node. Replaces all existing tags. " +
417
545
  "Pass empty array [] to remove all tags. E.g. ['#hmem', '#curation']."),
418
- pinned: z.boolean().optional().describe("Set or clear the [P] pinned flag (root entries only). " +
546
+ pinned: z.coerce.boolean().optional().describe("Set or clear the [P] pinned flag (root entries only). " +
419
547
  "Pinned entries show full L2 content in bulk reads (super-favorite)."),
420
- active: z.boolean().optional().describe("Mark this root entry as actively relevant [*] (root entries only). " +
548
+ active: z.coerce.boolean().optional().describe("Mark this root entry as actively relevant [*] (root entries only). " +
421
549
  "When any entry in a prefix has active=true, only active entries of that prefix are shown with children in bulk reads. " +
422
550
  "Non-active entries in the same prefix are shown as title-only (no children)."),
423
551
  store: z.enum(["personal", "company"]).default("personal").describe("Target store: 'personal' or 'company'"),
@@ -498,10 +626,10 @@ server.tool("update_many", "Batch-update multiple memory entries at once. Applie
498
626
  "Use this instead of calling update_memory multiple times during curation.\n\n" +
499
627
  "Example: update_many(ids=['T0005', 'T0012', 'L0044'], irrelevant=true)", {
500
628
  ids: z.array(z.string()).min(1).describe("List of entry/node IDs to update, e.g. ['T0005', 'T0012', 'L0044']"),
501
- irrelevant: z.boolean().optional().describe("Mark all as irrelevant [-]"),
502
- favorite: z.boolean().optional().describe("Set or clear [♥] favorite on all"),
503
- active: z.boolean().optional().describe("Set or clear [*] active on all"),
504
- pinned: z.boolean().optional().describe("Set or clear [P] pinned on all"),
629
+ irrelevant: z.coerce.boolean().optional().describe("Mark all as irrelevant [-]"),
630
+ favorite: z.coerce.boolean().optional().describe("Set or clear [♥] favorite on all"),
631
+ active: z.coerce.boolean().optional().describe("Set or clear [*] active on all"),
632
+ pinned: z.coerce.boolean().optional().describe("Set or clear [P] pinned on all"),
505
633
  store: z.enum(["personal", "company"]).default("personal"),
506
634
  }, async ({ ids, irrelevant, favorite, active, pinned, store: storeName }) => {
507
635
  const templateName = AGENT_ID.replace(/_\d+$/, "");
@@ -572,14 +700,14 @@ server.tool("flush_context", "Store a conversation chunk as linear context histo
572
700
  const levels = [l1, l2, l3, l4, l5].filter(Boolean).length;
573
701
  log(`flush_context: ${result.id} (${levels} levels, ${tags.join(" ")})`);
574
702
  syncPush(hmemPath);
575
- return {
703
+ return trackTokens({
576
704
  content: [{
577
705
  type: "text",
578
706
  text: `Context saved: ${result.id} (${levels} levels)\n` +
579
707
  `Title: ${l1}\nTags: ${tags.join(" ")}` +
580
708
  (links?.length ? `\nLinks: ${links.join(", ")}` : ""),
581
709
  }],
582
- };
710
+ });
583
711
  }
584
712
  finally {
585
713
  hmemStore.close();
@@ -597,10 +725,11 @@ server.tool("append_memory", "Append new child nodes to an existing memory entry
597
725
  "Use this to extend an existing entry with additional detail without overwriting it.\n\n" +
598
726
  "Content uses tab indentation relative to the parent:\n" +
599
727
  " 0 tabs = direct child of id\n" +
600
- " 1 tab = grandchild, etc.\n\n" +
728
+ " 1 tab = grandchild, etc.\n" +
729
+ "Use > for body text: 'Node title\\n> Body shown on drill-down\\n\\tChild node'\n\n" +
601
730
  "Examples:\n" +
602
- " append_memory(id='L0003', content='New finding\\n\\tSub-detail') " +
603
- "→ adds L2 node + L3 child\n" +
731
+ " append_memory(id='L0003', content='New finding\\n> Detailed explanation\\n\\tSub-detail') " +
732
+ "→ adds L2 node (with title + body) + L3 child\n" +
604
733
  " append_memory(id='L0003.2', content='Extra note') " +
605
734
  "→ adds L3 node under the L2 node L0003.2", {
606
735
  id: z.string().describe("Root entry ID or parent node ID to append children to, e.g. 'L0003' or 'L0003.2'"),
@@ -687,11 +816,11 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
687
816
  time: z.string().optional().describe("Time filter 'HH:MM' — filter entries by time of day"),
688
817
  period: z.string().optional().describe("Time window: '+4h' (after), '-2h' (before), '4h' (±4h symmetric), 'both' (±2h default)"),
689
818
  time_around: z.string().optional().describe("Reference entry ID — find entries created around the same time"),
690
- show_obsolete: z.boolean().optional().describe("Include all obsolete entries (default: only top 3 most-accessed)"),
691
- 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."),
692
- titles_only: z.boolean().optional().describe("Compact title listing — shows all entries as ID + date + title, without V2 selection or children. " +
819
+ show_obsolete: z.coerce.boolean().optional().describe("Include all obsolete entries (default: only top 3 most-accessed)"),
820
+ show_obsolete_path: z.coerce.boolean().optional().describe("When reading an obsolete entry by ID, show the full correction chain instead of just the final valid entry."),
821
+ titles_only: z.coerce.boolean().optional().describe("Compact title listing — shows all entries as ID + date + title, without V2 selection or children. " +
693
822
  "Like a table of contents. Combine with prefix to filter by category."),
694
- expand: z.boolean().optional().describe("Expand full tree with complete node content (ID queries only). " +
823
+ expand: z.coerce.boolean().optional().describe("Expand full tree with complete node content (ID queries only). " +
695
824
  "Use to deep-dive into a project after a long break. " +
696
825
  "depth controls how deep (default: 5 = full tree). " +
697
826
  "Example: read_memory({ id: 'P0001', expand: true, depth: 3 })"),
@@ -700,8 +829,8 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
700
829
  "use after context compression to recover key knowledge. " +
701
830
  "Auto-selected if omitted: first bulk read → discover, subsequent → essentials."),
702
831
  store: z.enum(["personal", "company"]).default("personal").describe("Source store: 'personal' or 'company'"),
703
- curator: z.boolean().optional().describe("Set true to show full metadata (access counts, roles, dates). For curators only."),
704
- show_all: z.boolean().optional().describe("Curation mode: show ALL entries of the selected prefix with depth 3 children. " +
832
+ curator: z.coerce.boolean().optional().describe("Set true to show full metadata (access counts, roles, dates). For curators only."),
833
+ show_all: z.coerce.boolean().optional().describe("Curation mode: show ALL entries of the selected prefix with depth 3 children. " +
705
834
  "Bypasses V2 selection and session cache. Use with prefix filter for manageable output."),
706
835
  tag: z.string().optional().describe("Filter by hashtag, e.g. '#hmem'. Only entries with this tag are shown in bulk reads. " +
707
836
  "Also works with search to find tagged entries."),
@@ -795,13 +924,14 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
795
924
  const outputTokens = Math.round(output.length / 4);
796
925
  const finalOutput = output.replace(/^(## Context for .+\n)(Source:.+)\n/, `$1$2 | ~${fmtTok(outputTokens)} tokens\n`);
797
926
  log(`read_memory [${storeLabel}]: context_for=${context_for}, ${totalRelated} related (${linked.length} linked, ${dedupedTagRelated.length} tag-related), ~${fmtTok(outputTokens)} tokens`);
798
- return {
927
+ return trackTokens({
799
928
  content: [{ type: "text", text: corruptionWarning + finalOutput }],
800
- };
929
+ });
801
930
  }
802
931
  const effectiveDepth = depth || (id ? 2 : 1);
803
932
  // Session cache: cached entries shown as titles in subsequent bulk reads
804
- const isBulkListing = !id && !search && !time_around;
933
+ // Explicit filters (after, before, prefix, stale_days, tag) bypass V2 selection + cache
934
+ const isBulkListing = !id && !search && !time_around && !after && !before && !prefix && !stale_days && !tag;
805
935
  const useCache = isBulkListing && storeName === "personal" && !show_all;
806
936
  const cachedIds = useCache ? sessionCache.getCachedIds() : undefined;
807
937
  const hiddenIds = useCache ? sessionCache.getHiddenIds() : undefined;
@@ -824,6 +954,7 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
824
954
  mode: isBulkListing ? effectiveMode : undefined,
825
955
  tag,
826
956
  staleDays: stale_days,
957
+ directResults: !isBulkListing && !id && !search && !time_around,
827
958
  });
828
959
  if (entries.length === 0) {
829
960
  const hmemPath = storeName === "company"
@@ -852,7 +983,7 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
852
983
  // Update session cache after bulk read
853
984
  if (useCache) {
854
985
  const allIds = entries.filter(e => !e.obsolete).map(e => e.id);
855
- const promotedIds = new Set(entries.filter(e => e.promoted === "favorite" || e.promoted === "access" || e.promoted === "subnode").map(e => e.id));
986
+ const promotedIds = new Set(entries.filter(e => e.promoted === "favorite" || e.promoted === "access" || e.promoted === "subnode" || e.promoted === "task").map(e => e.id));
856
987
  sessionCache.registerDelivered(allIds, promotedIds);
857
988
  }
858
989
  // Format output
@@ -932,7 +1063,16 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
932
1063
  const projectList = projects.length > 0
933
1064
  ? projects.map(e => ` ${e.id} ${e.title}`).join("\n")
934
1065
  : " (no projects yet — create one with write_memory(prefix=\"P\", content=\"Name | Status | Stack | Description\", tags=[...]))";
935
- return {
1066
+ // Inject recent O-entries even without active project (global, no project filter)
1067
+ let recentOHint = "";
1068
+ if (hmemConfig.recentOEntries > 0) {
1069
+ const { text, ids } = formatRecentOEntries(hmemStore, hmemConfig.recentOEntries, 10);
1070
+ if (text) {
1071
+ recentOHint = `\n${text}\n`;
1072
+ sessionCache.registerDelivered(ids);
1073
+ }
1074
+ }
1075
+ return trackTokens({
936
1076
  content: [{
937
1077
  type: "text",
938
1078
  text: `⚠ ACTION REQUIRED: No project is active.\n\n` +
@@ -942,9 +1082,21 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
942
1082
  ` write_memory(prefix="P", content="Name | Status | Stack | Description", tags=["#project"])\n\n` +
943
1083
  `Available projects:\n${projectList}\n\n` +
944
1084
  `Session logs (O-entries) will be linked to the active project.\n` +
945
- `Memory data is withheld until a project is activated.`,
1085
+ `Memory data is withheld until a project is activated.` + recentOHint,
946
1086
  }],
947
- };
1087
+ });
1088
+ }
1089
+ }
1090
+ // Inject recent O-entries (session logs) on bulk reads when none are cached
1091
+ let recentOSection = "";
1092
+ if (isBulkListing && storeName === "personal" && hmemConfig.recentOEntries > 0) {
1093
+ const cachedOIds = [...(cachedIds || []), ...(hiddenIds || [])].filter(id => id.startsWith("O"));
1094
+ if (cachedOIds.length === 0) {
1095
+ const { text, ids } = formatRecentOEntries(hmemStore, hmemConfig.recentOEntries, 10);
1096
+ if (text) {
1097
+ recentOSection = `\n${text}\n`;
1098
+ sessionCache.registerDelivered(ids);
1099
+ }
948
1100
  }
949
1101
  }
950
1102
  // Check for P-entries that need migration to standard schema
@@ -962,12 +1114,12 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
962
1114
  const header = `## Memory: ${storeLabel} (${stats.total} total entries)\n` +
963
1115
  `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}${staleHint}\n`;
964
1116
  log(`read_memory [${storeLabel}]: ${visibleCount} results (depth=${effectiveDepth}, role=${agentRole}${cacheInfo})`);
965
- return {
1117
+ return trackTokens({
966
1118
  content: [{
967
1119
  type: "text",
968
- text: corruptionWarning + projectWarning + migrationHint + newSinceSection + header + "\n" + output + (isBulkListing && (sessionCache.readCount <= 1 || sessionCache.size === 0) ? REMINDER_HINT : ""),
1120
+ text: corruptionWarning + projectWarning + migrationHint + newSinceSection + header + "\n" + output + recentOSection + (isBulkListing && (sessionCache.readCount <= 1 || sessionCache.size === 0) ? REMINDER_HINT : ""),
969
1121
  }],
970
- };
1122
+ });
971
1123
  }
972
1124
  finally {
973
1125
  hmemStore.close();
@@ -1040,7 +1192,7 @@ server.tool("import_memory", "Import entries from a .hmem file into your memory.
1040
1192
  "Deduplicates by L1 content (merges sub-nodes), remaps IDs on conflict.", {
1041
1193
  source_path: z.string().describe("Path to .hmem file to import"),
1042
1194
  store: z.enum(["personal", "company"]).default("personal").describe("Target store: 'personal' (your own memory) or 'company' (shared company store)"),
1043
- dry_run: z.boolean().default(false).describe("Preview only — report what would happen without modifying the database"),
1195
+ dry_run: z.coerce.boolean().default(false).describe("Preview only — report what would happen without modifying the database"),
1044
1196
  }, async ({ source_path, store: storeName, dry_run }) => {
1045
1197
  try {
1046
1198
  const hmemStore = storeName === "company"
@@ -1219,19 +1371,135 @@ server.tool("load_project", "Load a project and activate it. Returns L2 content
1219
1371
  isError: true,
1220
1372
  };
1221
1373
  }
1222
- // Format using the same rendering as read_memory
1223
- const output = formatGroupedOutput(hmemStore, entries, false, hmemConfig);
1374
+ // Custom compact rendering for project briefing: L2 content + L3 titles, no dates, compact IDs
1375
+ const e = entries[0];
1376
+ const syncThreshold = getSyncThreshold();
1377
+ const syncTag = syncThreshold && e.updated_at && e.updated_at <= syncThreshold ? " ✓" : "";
1378
+ const lines = [];
1379
+ lines.push(`${e.id}${syncTag} ${e.title}`);
1380
+ if (e.level_1 && e.level_1 !== e.title)
1381
+ lines.push(` ${e.level_1}`);
1382
+ if (e.children) {
1383
+ const { withBody, withChildren } = hmemConfig.loadProjectExpand;
1384
+ for (const child of e.children.filter(c => !c.irrelevant)) {
1385
+ const cId = child.id.replace(e.id, "");
1386
+ const expandBody = withBody.includes(child.seq);
1387
+ const expandChildTitles = withChildren.includes(child.seq);
1388
+ lines.push(` ${cId} ${child.title || child.content.substring(0, 60)}`);
1389
+ if (child.children && child.children.length > 0) {
1390
+ for (const gc of child.children.filter((g) => !g.irrelevant)) {
1391
+ const gcId = gc.id.replace(e.id, "");
1392
+ if (expandBody) {
1393
+ // Show L3 title + body content
1394
+ lines.push(` ${gcId} ${gc.title || gc.content.substring(0, 80)}`);
1395
+ if (gc.content && gc.content !== gc.title) {
1396
+ // Show body lines indented
1397
+ for (const bodyLine of gc.content.split("\n")) {
1398
+ lines.push(` ${bodyLine}`);
1399
+ }
1400
+ }
1401
+ }
1402
+ else if (expandChildTitles) {
1403
+ // Show all L3 children as titles
1404
+ const gcTitle = gc.title || (gc.content.length > 80 ? gc.content.substring(0, 80) : gc.content);
1405
+ lines.push(` ${gcId} ${gcTitle}`);
1406
+ }
1407
+ else {
1408
+ // Default: compact titles only
1409
+ const gcTitle = gc.title || (gc.content.length > 80 ? gc.content.substring(0, 80) : gc.content);
1410
+ lines.push(` ${gcId} ${gcTitle}`);
1411
+ }
1412
+ // L4 children titles (already loaded via depth=4)
1413
+ if (gc.children && gc.children.length > 0) {
1414
+ const visibleL4 = gc.children.filter((l4) => !l4.irrelevant);
1415
+ for (const l4 of visibleL4) {
1416
+ const l4Id = l4.id.replace(e.id, "");
1417
+ const l4Title = l4.title || (l4.content?.length > 60 ? l4.content.substring(0, 60) + "…" : l4.content || "");
1418
+ lines.push(` ${l4Id} ${l4Title}`);
1419
+ }
1420
+ }
1421
+ else if (gc.child_count && gc.child_count > 0) {
1422
+ lines.push(` [+${gc.child_count}]`);
1423
+ }
1424
+ }
1425
+ }
1426
+ else if (child.child_count && child.child_count > 0) {
1427
+ lines.push(` [+${child.child_count}]`);
1428
+ }
1429
+ }
1430
+ }
1431
+ // Links
1432
+ if (e.linkedEntries && e.linkedEntries.length > 0) {
1433
+ lines.push(" Links:");
1434
+ for (const le of e.linkedEntries) {
1435
+ lines.push(` ${le.id} ${le.title}`);
1436
+ }
1437
+ }
1438
+ // Context injection: find related E/L entries by weighted tag scoring
1439
+ try {
1440
+ const ctx = hmemStore.findContext(id, 4, 10);
1441
+ const relatedEL = ctx.tagRelated.filter(r => (r.entry.prefix === "E" || r.entry.prefix === "L") && !r.entry.obsolete && !r.entry.irrelevant);
1442
+ if (relatedEL.length > 0) {
1443
+ lines.push(" Related errors & lessons:");
1444
+ for (const r of relatedEL) {
1445
+ lines.push(` ${r.entry.id} [⚡] ${r.entry.title}`);
1446
+ }
1447
+ }
1448
+ }
1449
+ catch { /* findContext may fail on empty/new entries */ }
1450
+ // Inject R-entries (rules) — always shown at project load
1451
+ const ruleEntries = hmemStore.read({
1452
+ prefix: "R",
1453
+ depth: 1,
1454
+ agentRole: (ROLE || "worker"),
1455
+ }).filter(r => !r.obsolete && !r.irrelevant);
1456
+ if (ruleEntries.length > 0) {
1457
+ lines.push(" Rules:");
1458
+ for (const r of ruleEntries) {
1459
+ lines.push(` ${r.id} ${r.title}`);
1460
+ }
1461
+ }
1462
+ // Inject the most recent O-entry linked to this project with last N exchanges
1463
+ // Purpose: seamless continuation of the previous session's conversation
1464
+ if (hmemConfig.recentOEntries > 0) {
1465
+ const { text, ids } = formatRecentOEntries(hmemStore, 1, hmemConfig.recentOEntries, id, true);
1466
+ if (text) {
1467
+ lines.push(" " + text.replace(/\n/g, "\n "));
1468
+ sessionCache.registerDelivered(ids);
1469
+ }
1470
+ }
1471
+ // Inject universal conventions (C-entries tagged #universal)
1472
+ try {
1473
+ const conventions = hmemStore.read({
1474
+ prefix: "C", depth: 2, agentRole: (ROLE || "worker"),
1475
+ }).filter(c => !c.obsolete && !c.irrelevant && c.tags?.includes("#universal"));
1476
+ if (conventions.length > 0) {
1477
+ lines.push(" Conventions (#universal):");
1478
+ for (const c of conventions) {
1479
+ lines.push(` ${c.id} ${c.title}`);
1480
+ if (c.level_1 && c.level_1 !== c.title)
1481
+ lines.push(` ${c.level_1}`);
1482
+ }
1483
+ }
1484
+ }
1485
+ catch { /* conventions are optional */ }
1486
+ const irrelevantTip = `Tip: update_memory(id, { irrelevant: true }) to hide noisy entries from future loads.`;
1487
+ const output = lines.join("\n");
1488
+ const outputTokens = Math.round(output.length / 4);
1489
+ const totalStats = hmemStore.stats();
1490
+ const totalTokens = Math.round(totalStats.totalChars / 4);
1491
+ const tokenInfo = ` | ${(outputTokens / 1000).toFixed(1)}k/${(totalTokens / 1000).toFixed(0)}k tokens`;
1224
1492
  log(`load_project: ${id} activated and loaded (depth=3)`);
1225
1493
  // Sync if enabled
1226
1494
  const hmemPath = resolveHmemPath(PROJECT_DIR, templateName);
1227
1495
  if (storeName === "personal")
1228
1496
  syncPush(hmemPath);
1229
- return {
1497
+ return trackTokens({
1230
1498
  content: [{
1231
1499
  type: "text",
1232
- text: `✓ Project ${id} activated.\n\n${output}`,
1500
+ text: `✓ Project ${id} activated.${tokenInfo}\n${irrelevantTip}\n\n${output}\n\n${irrelevantTip}`,
1233
1501
  }],
1234
- };
1502
+ });
1235
1503
  }
1236
1504
  finally {
1237
1505
  hmemStore.close();
@@ -1434,7 +1702,7 @@ server.tool("get_audit_queue", "CURATOR ONLY (ceo role). Returns agents whose .h
1434
1702
  "Each agent should be audited in a separate spawn to keep context bounded.", {}, async () => {
1435
1703
  if (!isCurator()) {
1436
1704
  return {
1437
- content: [{ type: "text", text: "ERROR: get_audit_queue is only available to the ceo/curator role." }],
1705
+ content: [{ type: "text", text: "ERROR: get_audit_queue is only available to the ceo/curator role. Set HMEM_AGENT_ROLE=ceo in your MCP server config to use curation tools." }],
1438
1706
  isError: true,
1439
1707
  };
1440
1708
  }
@@ -1520,8 +1788,11 @@ server.tool("read_agent_memory", "CURATOR ONLY (ceo role). Read the full memory
1520
1788
  const favTag = e.favorite ? " [♥]" : "";
1521
1789
  lines.push(`[${e.id}] ${date}${role}${favTag}${obsoleteTag}${irrelevantTag}${access}`);
1522
1790
  lines.push(` ${e.title}`);
1523
- if (e.level_1 !== e.title)
1524
- lines.push(` ${e.level_1}`);
1791
+ if (e.level_1 && e.level_1 !== e.title) {
1792
+ for (const bodyLine of e.level_1.split("\n")) {
1793
+ lines.push(` ${bodyLine}`);
1794
+ }
1795
+ }
1525
1796
  if (e.children && e.children.length > 0) {
1526
1797
  for (const child of e.children) {
1527
1798
  const indent = " ".repeat(child.depth - 1);
@@ -1555,10 +1826,10 @@ server.tool("fix_agent_memory", "CURATOR ONLY (ceo role). Correct a specific ent
1555
1826
  content: z.string().optional().describe("New text content. For root entries: replaces the L1 summary. " +
1556
1827
  "For node IDs: replaces that node's content."),
1557
1828
  min_role: z.enum(["worker", "al", "pl", "ceo"]).optional().describe("Update access clearance (root entries only)."),
1558
- obsolete: z.boolean().optional().describe("Mark or unmark as obsolete (root entries only). " +
1829
+ obsolete: z.coerce.boolean().optional().describe("Mark or unmark as obsolete (root entries only). " +
1559
1830
  "Obsolete entries stay in memory but are shown with [⚠ OBSOLETE]."),
1560
- favorite: z.boolean().optional().describe("Set or clear the [♥] favorite flag (root entries only)."),
1561
- irrelevant: z.boolean().optional().describe("Mark or unmark as irrelevant (root entries only). Irrelevant entries are hidden from bulk reads. No correction entry needed."),
1831
+ favorite: z.coerce.boolean().optional().describe("Set or clear the [♥] favorite flag (root entries only)."),
1832
+ irrelevant: z.coerce.boolean().optional().describe("Mark or unmark as irrelevant (root entries only). Irrelevant entries are hidden from bulk reads. No correction entry needed."),
1562
1833
  }, async ({ agent_name, entry_id, content, min_role, obsolete, favorite, irrelevant }) => {
1563
1834
  if (!isCurator()) {
1564
1835
  return {
@@ -1779,26 +2050,28 @@ function formatTitlesOnly(entries, config, curator = false) {
1779
2050
  const desc = config.prefixDescriptions[prefix] ?? config.prefixes[prefix] ?? prefix;
1780
2051
  lines.push(`## ${desc} (${prefixEntries.length} total)\n`);
1781
2052
  for (const e of prefixEntries) {
1782
- const mmdd = e.created_at.substring(5, 10);
1783
2053
  const fav = e.favorite ? " [♥]" : "";
1784
2054
  const act = e.active ? " [*]" : "";
1785
2055
  const obs = e.obsolete ? " [!]" : "";
1786
2056
  const irr = e.irrelevant ? " [-]" : "";
2057
+ const syncThreshold = getSyncThreshold();
2058
+ const sync = syncThreshold && e.updated_at && e.updated_at <= syncThreshold ? " ✓" : "";
1787
2059
  if (e.expanded && e.children && e.children.length > 0) {
1788
2060
  const visibleChildren = e.children.filter(c => !c.irrelevant);
1789
2061
  const hiddenIrr = e.children.length - visibleChildren.length;
1790
- // Expanded entry (favorite/top-accessed): show with L2 children
1791
- lines.push(`${e.id} ${mmdd}${fav}${act}${obs} ${e.title}${formatTagSuffix(e.tags, curator)}`);
2062
+ const rootId = e.id;
2063
+ lines.push(`${e.id}${fav}${act}${obs}${sync} ${e.title}${formatTagSuffix(e.tags, curator)}`);
1792
2064
  for (const child of visibleChildren) {
1793
2065
  const short = child.title || (child.content.length > CHILD_TITLE_LEN
1794
2066
  ? child.content.substring(0, CHILD_TITLE_LEN)
1795
2067
  : child.content);
1796
2068
  const grandchildren = (child.child_count ?? 0) > 0 ? ` (${child.child_count})` : "";
1797
2069
  const cfav = child.favorite ? " [♥]" : "";
1798
- lines.push(` ${child.id}${cfav} ${short}${grandchildren}`);
2070
+ const compactChildId = child.id.replace(rootId, "");
2071
+ lines.push(` ${compactChildId}${cfav} ${short}${grandchildren}`);
1799
2072
  }
1800
2073
  if (e.hiddenChildrenCount && e.hiddenChildrenCount > 0) {
1801
- lines.push(` [+${e.hiddenChildrenCount} more → ${e.id}]`);
2074
+ lines.push(` [+${e.hiddenChildrenCount} more]`);
1802
2075
  }
1803
2076
  if (hiddenIrr > 0) {
1804
2077
  lines.push(` (+${hiddenIrr} irrelevant hidden)`);
@@ -1807,7 +2080,7 @@ function formatTitlesOnly(entries, config, curator = false) {
1807
2080
  else {
1808
2081
  // Non-expanded: compact line with child count
1809
2082
  const childHint = (e.hiddenChildrenCount ?? 0) > 0 ? ` (${e.hiddenChildrenCount})` : "";
1810
- lines.push(`${e.id} ${mmdd}${fav}${act}${obs} ${e.title}${formatTagSuffix(e.tags, curator)}${childHint}`);
2083
+ lines.push(`${e.id}${fav}${act}${obs}${sync} ${e.title}${formatTagSuffix(e.tags, curator)}${childHint}`);
1811
2084
  }
1812
2085
  }
1813
2086
  lines.push("");
@@ -1873,6 +2146,17 @@ function nodeMarkers(node) {
1873
2146
  const irr = node.irrelevant ? " [-]" : "";
1874
2147
  return `${fav}${irr}`;
1875
2148
  }
2149
+ /** Get the minimum lastPushAt across all sync servers — entries updated before this are fully synced. */
2150
+ function getSyncThreshold() {
2151
+ const servers = getSyncServers(hmemConfig);
2152
+ if (servers.length === 0)
2153
+ return null;
2154
+ const pushTimes = servers.map(s => s.lastPushAt).filter((t) => !!t);
2155
+ if (pushTimes.length === 0)
2156
+ return null;
2157
+ // Min = earliest push → everything before this is on ALL servers
2158
+ return pushTimes.reduce((a, b) => a < b ? a : b);
2159
+ }
1876
2160
  function renderEntryFormatted(lines, e, curator, expand = false) {
1877
2161
  // O-prefix: title-only rendering — never expand children (raw conversation data, too large)
1878
2162
  // Use read_memory(id="O0042") to drill in explicitly.
@@ -1894,14 +2178,16 @@ function renderEntryFormatted(lines, e, curator, expand = false) {
1894
2178
  else {
1895
2179
  lines.push(`${e.id} ${e.title}${tagStr}`);
1896
2180
  }
1897
- // Node drilldown: show full content below title
1898
- if (hasDetail && e.level_1 !== e.title) {
1899
- lines.push(` ${e.level_1}`);
2181
+ // Node drilldown: show body below title
2182
+ if (e.level_1 && e.level_1 !== e.title) {
2183
+ for (const bodyLine of e.level_1.split("\n")) {
2184
+ lines.push(` ${bodyLine}`);
2185
+ }
1900
2186
  }
1901
2187
  }
1902
2188
  else {
1903
2189
  if (curator) {
1904
- const promotedTag = e.promoted === "favorite" ? " [♥]" : e.promoted === "access" ? " [★]" : e.promoted === "subnode" ? " [≡]" : "";
2190
+ const promotedTag = e.promoted === "favorite" ? " [♥]" : e.promoted === "access" ? " [★]" : e.promoted === "subnode" ? " [≡]" : e.promoted === "task" ? " [⚡]" : "";
1905
2191
  const activeTag = e.active ? " [*]" : "";
1906
2192
  const pinnedTag = e.pinned ? " [P]" : "";
1907
2193
  const obsoleteTag = e.obsolete ? " [⚠ OBSOLETE]" : "";
@@ -1913,31 +2199,36 @@ function renderEntryFormatted(lines, e, curator, expand = false) {
1913
2199
  lines.push(` ${e.title}${tagStr}`);
1914
2200
  }
1915
2201
  else {
1916
- const promotedTag = e.promoted === "favorite" ? " [♥]" : e.promoted === "access" ? " [★]" : e.promoted === "subnode" ? " [≡]" : "";
2202
+ const promotedTag = e.promoted === "favorite" ? " [♥]" : e.promoted === "access" ? " [★]" : e.promoted === "subnode" ? " [≡]" : e.promoted === "task" ? " [⚡]" : "";
1917
2203
  const activeTag = e.active ? " [*]" : "";
1918
2204
  const pinnedTag = e.pinned ? " [P]" : "";
1919
2205
  const obsoleteTag = e.obsolete ? " [!]" : "";
1920
2206
  const irrelevantTag = e.irrelevant ? " [-]" : "";
1921
- const mmdd = e.created_at.substring(5, 10);
1922
- lines.push(`${e.id} ${mmdd}${promotedTag}${activeTag}${pinnedTag}${obsoleteTag}${irrelevantTag} ${e.title}${tagStr}`);
1923
- }
1924
- // Show full level_1 content below title when entry is expanded/drilled
1925
- if (hasDetail && e.level_1 !== e.title) {
1926
- lines.push(` ${e.level_1}`);
2207
+ const syncThreshold = getSyncThreshold();
2208
+ const syncTag = syncThreshold && e.updated_at && e.updated_at <= syncThreshold ? " ✓" : "";
2209
+ lines.push(`${e.id}${promotedTag}${activeTag}${pinnedTag}${obsoleteTag}${irrelevantTag}${syncTag} ${e.title}${tagStr}`);
2210
+ }
2211
+ // Show body below title when entry is drilled into
2212
+ if (e.level_1 && e.level_1 !== e.title) {
2213
+ for (const bodyLine of e.level_1.split("\n")) {
2214
+ lines.push(` ${bodyLine}`);
2215
+ }
1927
2216
  }
1928
2217
  }
1929
2218
  // Children — filter out irrelevant nodes
2219
+ // Root ID for compact child rendering (e.g. P0048.1 → .1)
2220
+ const rootId = e.id.includes(".") ? e.id.split(".")[0] : e.id;
1930
2221
  if (e.children && e.children.length > 0) {
1931
2222
  const visibleChildren = e.children.filter(c => !c.irrelevant);
1932
2223
  const hiddenIrrelevant = e.children.length - visibleChildren.length;
1933
2224
  if (expand || e.pinned) {
1934
2225
  // Expand mode or pinned: full L2 content + recursive children
1935
- renderChildrenExpanded(lines, visibleChildren, curator);
2226
+ renderChildrenExpanded(lines, visibleChildren, curator, rootId);
1936
2227
  }
1937
2228
  else if (e.expanded && !expand) {
1938
- renderChildrenFormatted(lines, visibleChildren, curator);
2229
+ renderChildrenFormatted(lines, visibleChildren, curator, rootId);
1939
2230
  if (e.hiddenChildrenCount && e.hiddenChildrenCount > 0) {
1940
- lines.push(` [+${e.hiddenChildrenCount} more → ${e.id}]`);
2231
+ lines.push(` [+${e.hiddenChildrenCount} more]`);
1941
2232
  }
1942
2233
  }
1943
2234
  else if (e.hiddenChildrenCount !== undefined) {
@@ -1945,23 +2236,24 @@ function renderEntryFormatted(lines, e, curator, expand = false) {
1945
2236
  const child = visibleChildren[0];
1946
2237
  if (child) {
1947
2238
  const fav = nodeMarkers(child);
2239
+ const compactChildId = child.id.replace(rootId, "");
1948
2240
  const hint = (child.child_count ?? 0) > 0
1949
- ? ` [+${child.child_count} → ${child.id}]`
2241
+ ? ` [+${child.child_count}]`
1950
2242
  : "";
1951
2243
  if (curator) {
1952
2244
  lines.push(` [${child.id}]${fav} ${child.title}${hint}`);
1953
2245
  }
1954
2246
  else {
1955
- lines.push(` ${child.id}${fav} ${child.title}${hint}`);
2247
+ lines.push(` ${compactChildId}${fav} ${child.title}${hint}`);
1956
2248
  }
1957
2249
  }
1958
2250
  if (e.hiddenChildrenCount > 0) {
1959
- lines.push(` [+${e.hiddenChildrenCount} more → ${e.id}]`);
2251
+ lines.push(` [+${e.hiddenChildrenCount} more]`);
1960
2252
  }
1961
2253
  }
1962
2254
  else {
1963
2255
  // ID-based read: show all direct children as titles
1964
- renderChildrenFormatted(lines, visibleChildren, curator);
2256
+ renderChildrenFormatted(lines, visibleChildren, curator, rootId);
1965
2257
  }
1966
2258
  if (hiddenIrrelevant > 0) {
1967
2259
  lines.push(` (+${hiddenIrrelevant} irrelevant hidden)`);
@@ -2017,21 +2309,21 @@ function renderEntryFormatted(lines, e, curator, expand = false) {
2017
2309
  * Render a list of child nodes — shows titles for navigation.
2018
2310
  * Use read_memory(id=child.id) to see full content.
2019
2311
  */
2020
- function renderChildrenFormatted(lines, children, curator) {
2312
+ function renderChildrenFormatted(lines, children, curator, rootId) {
2021
2313
  for (const child of children) {
2022
2314
  const indent = " ".repeat(child.depth - 1);
2023
2315
  const fav = nodeMarkers(child);
2024
2316
  const ctags = formatTagSuffix(child.tags, curator);
2317
+ const compactId = rootId ? child.id.replace(rootId, "") : child.id;
2025
2318
  const hint = (child.child_count ?? 0) > 0
2026
- ? ` [+${child.child_count} → ${child.id}]`
2319
+ ? ` [+${child.child_count}]`
2027
2320
  : "";
2028
2321
  if (curator) {
2029
2322
  lines.push(`${indent}[${child.id}]${fav} ${child.title}${ctags}${hint}`);
2030
2323
  }
2031
2324
  else {
2032
- lines.push(`${indent}${child.id}${fav} ${child.title}${ctags}${hint}`);
2325
+ lines.push(`${indent}${compactId}${fav} ${child.title}${ctags}${hint}`);
2033
2326
  }
2034
- // Don't recurse into grandchildren — titles only, drill for content
2035
2327
  }
2036
2328
  }
2037
2329
  /**
@@ -2040,40 +2332,53 @@ function renderChildrenFormatted(lines, children, curator) {
2040
2332
  * At the depth boundary (children loaded but THEIR children are not),
2041
2333
  * renders as titles instead of full content.
2042
2334
  */
2043
- function renderChildrenExpanded(lines, children, curator) {
2335
+ function renderChildrenExpanded(lines, children, curator, rootId) {
2044
2336
  for (const child of children) {
2045
2337
  const indent = " ".repeat(child.depth - 1);
2338
+ const bodyIndent = indent + " ";
2046
2339
  const fav = nodeMarkers(child);
2340
+ const compactId = rootId ? child.id.replace(rootId, "") : child.id;
2047
2341
  const visibleGrandchildren = child.children?.filter(c => !c.irrelevant);
2048
2342
  const hasLoadedChildren = visibleGrandchildren && visibleGrandchildren.length > 0;
2049
2343
  const isBoundary = !hasLoadedChildren && (child.child_count ?? 0) > 0;
2344
+ const hasBody = child.content && child.content !== child.title;
2050
2345
  if (hasLoadedChildren) {
2051
- // Inner node: full content + recurse
2346
+ // Inner node: title + body + recurse
2052
2347
  if (curator) {
2053
- lines.push(`${indent}[${child.id}]${fav} ${child.content}`);
2348
+ lines.push(`${indent}[${child.id}]${fav} ${child.title}`);
2054
2349
  }
2055
2350
  else {
2056
- lines.push(`${indent}${child.id}${fav} ${child.content}`);
2351
+ lines.push(`${indent}${compactId}${fav} ${child.title}`);
2352
+ }
2353
+ if (hasBody) {
2354
+ for (const bodyLine of child.content.split("\n")) {
2355
+ lines.push(`${bodyIndent}${bodyLine}`);
2356
+ }
2057
2357
  }
2058
- renderChildrenExpanded(lines, visibleGrandchildren, curator);
2358
+ renderChildrenExpanded(lines, visibleGrandchildren, curator, rootId);
2059
2359
  }
2060
2360
  else if (isBoundary) {
2061
2361
  // Boundary: title only + child count hint
2062
- const hint = ` [+${child.child_count} → ${child.id}]`;
2362
+ const hint = ` [+${child.child_count}]`;
2063
2363
  if (curator) {
2064
2364
  lines.push(`${indent}[${child.id}]${fav} ${child.title}${hint}`);
2065
2365
  }
2066
2366
  else {
2067
- lines.push(`${indent}${child.id}${fav} ${child.title}${hint}`);
2367
+ lines.push(`${indent}${compactId}${fav} ${child.title}${hint}`);
2068
2368
  }
2069
2369
  }
2070
2370
  else {
2071
- // Leaf node (no children at all): full content
2371
+ // Leaf node: title + body
2072
2372
  if (curator) {
2073
- lines.push(`${indent}[${child.id}]${fav} ${child.content}`);
2373
+ lines.push(`${indent}[${child.id}]${fav} ${child.title}`);
2074
2374
  }
2075
2375
  else {
2076
- lines.push(`${indent}${child.id}${fav} ${child.content}`);
2376
+ lines.push(`${indent}${compactId}${fav} ${child.title}`);
2377
+ }
2378
+ if (hasBody) {
2379
+ for (const bodyLine of child.content.split("\n")) {
2380
+ lines.push(`${bodyIndent}${bodyLine}`);
2381
+ }
2077
2382
  }
2078
2383
  }
2079
2384
  }
@@ -2098,6 +2403,7 @@ function checkForUpdates() {
2098
2403
  stdio: ["ignore", "pipe", "ignore"],
2099
2404
  detached: true,
2100
2405
  shell: process.platform === "win32",
2406
+ windowsHide: true,
2101
2407
  });
2102
2408
  child.unref();
2103
2409
  let out = "";