hmem-mcp 6.3.0 → 6.3.2

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.
@@ -19,7 +19,7 @@ import { fileURLToPath } from "node:url";
19
19
  import { spawnSync, spawn } from "node:child_process";
20
20
  import Database from "better-sqlite3";
21
21
  import { searchMemory } from "./memory-search.js";
22
- import { openCompanyMemory, resolveHmemPath, resolveHmemPathLegacy, routeTask, HmemStore } from "./hmem-store.js";
22
+ import { openCompanyMemory, resolveHmemPath, HmemStore, SimilarEntriesError } from "./hmem-store.js";
23
23
  import { loadHmemConfig, formatPrefixList, getSyncServers } from "./hmem-config.js";
24
24
  import { SessionCache } from "./session-cache.js";
25
25
  // ---- Environment ----
@@ -40,6 +40,27 @@ function log(msg) {
40
40
  const name = path.basename(HMEM_PATH, ".hmem");
41
41
  console.error(`[hmem:${name}] ${msg}`);
42
42
  }
43
+ /**
44
+ * Coerce LLM-provided array arguments: some models serialize arrays as JSON strings
45
+ * (e.g. tags: '["#foo","#bar"]' instead of tags: ["#foo", "#bar"]). Accept both.
46
+ * Wrap a zod string-array schema so the preprocessing happens before validation.
47
+ */
48
+ function jsonArrayString(schema) {
49
+ return z.preprocess((val) => {
50
+ if (typeof val === "string") {
51
+ const trimmed = val.trim();
52
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
53
+ try {
54
+ const parsed = JSON.parse(trimmed);
55
+ if (Array.isArray(parsed))
56
+ return parsed;
57
+ }
58
+ catch { /* fall through — zod will report the type mismatch */ }
59
+ }
60
+ }
61
+ return val;
62
+ }, schema);
63
+ }
43
64
  // ---- Session-scoped active project (not shared via DB — safe for multi-agent) ----
44
65
  let activeProjectId = null;
45
66
  // ---- Session-start mtime snapshot (for [NEW] markers) ----
@@ -72,13 +93,6 @@ function safeError(e) {
72
93
  const msg = e instanceof Error ? e.message : String(e);
73
94
  return msg.replace(/\/[^\s:)]+/g, "[path]").substring(0, 300);
74
95
  }
75
- /** Validate agent_name against path traversal. */
76
- function validateAgentName(name) {
77
- if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
78
- throw new Error(`Invalid agent name "${name}". Use alphanumeric, underscore, or hyphen only (max 64 chars).`);
79
- }
80
- return name;
81
- }
82
96
  // ---- hmem-sync integration ----
83
97
  let lastPullAt = 0;
84
98
  const PULL_COOLDOWN_MS = 30_000;
@@ -457,6 +471,36 @@ function compareIds(a, b) {
457
471
  // Load hmem config (hmem.config.json in project dir, falls back to defaults)
458
472
  const hmemConfig = loadHmemConfig(PROJECT_DIR);
459
473
  log(`Config: levels=[${hmemConfig.maxCharsPerLevel.join(",")}] depth=${hmemConfig.maxDepth}`);
474
+ /** Resolve which store to open. hmem_path wins over storeName. */
475
+ function resolveStore(storeName, hmemPath) {
476
+ if (hmemPath) {
477
+ if (!fs.existsSync(hmemPath)) {
478
+ throw new Error(`hmem_path not found: ${hmemPath}`);
479
+ }
480
+ const extConfig = loadHmemConfig(path.dirname(hmemPath));
481
+ return {
482
+ store: new HmemStore(hmemPath, extConfig),
483
+ label: path.basename(hmemPath, ".hmem"),
484
+ path: hmemPath,
485
+ isExternal: true,
486
+ };
487
+ }
488
+ if (storeName === "company") {
489
+ const companyPath = path.join(PROJECT_DIR, "company.hmem");
490
+ return {
491
+ store: openCompanyMemory(PROJECT_DIR, hmemConfig),
492
+ label: "company",
493
+ path: companyPath,
494
+ isExternal: false,
495
+ };
496
+ }
497
+ return {
498
+ store: new HmemStore(HMEM_PATH, hmemConfig),
499
+ label: path.basename(HMEM_PATH, ".hmem"),
500
+ path: HMEM_PATH,
501
+ isExternal: false,
502
+ };
503
+ }
460
504
  // ---- Version upgrade detection ----
461
505
  import { createRequire } from "node:module";
462
506
  const _require = createRequire(import.meta.url);
@@ -549,6 +593,39 @@ function trackTokens(result) {
549
593
  * @param expandAll - if true, expand all O-entries (not just the first)
550
594
  * @returns formatted string + list of O-entry IDs for cache registration
551
595
  */
596
+ /** Compress exchange text for display: strip noise, collapse to meaningful lines, truncate. */
597
+ function compressExchangeText(text, maxLen) {
598
+ if (!text)
599
+ return "";
600
+ // Replace code blocks with placeholder
601
+ let cleaned = text.replace(/```[\s\S]*?```/g, "[code]");
602
+ // Replace markdown tables (lines with |---|) with placeholder
603
+ const tablePattern = /(?:^|\n)\|[^\n]+\|(?:\n\|[-: |]+\|)?(?:\n\|[^\n]+\|)*/g;
604
+ cleaned = cleaned.replace(tablePattern, "\n[table]");
605
+ // Replace inline JSON objects (multi-line { ... }) with placeholder
606
+ cleaned = cleaned.replace(/\{[\s\S]{80,}?\}/g, "[config]");
607
+ // Collect meaningful lines (skip blanks, deduplicate placeholders)
608
+ const lines = cleaned.split("\n")
609
+ .map(l => l.trim())
610
+ .filter(l => l.length > 0);
611
+ // Build result from meaningful lines, joining with " | "
612
+ let result = "";
613
+ for (const line of lines) {
614
+ if (!result) {
615
+ result = line;
616
+ }
617
+ else if (result.length + line.length + 3 <= maxLen) {
618
+ result += " | " + line;
619
+ }
620
+ else {
621
+ break;
622
+ }
623
+ }
624
+ if (result.length > maxLen) {
625
+ result = result.substring(0, maxLen - 3) + "...";
626
+ }
627
+ return result;
628
+ }
552
629
  function formatRecentOEntries(store, limit, exchangeCount, linkedTo, expandAll) {
553
630
  if (limit <= 0)
554
631
  return { text: "", ids: [] };
@@ -625,8 +702,35 @@ function formatRecentOEntries(store, limit, exchangeCount, linkedTo, expandAll)
625
702
  continue;
626
703
  }
627
704
  // Strip XML channel tags from Telegram messages, keep inner text
628
- const userClean = ex.userText.replace(/<channel[^>]*>\s*/g, "").replace(/<\/channel>\s*/g, "").trim();
629
- const agentClean = ex.agentText?.replace(/<[^>]+>/g, "").trim();
705
+ let userClean = ex.userText.replace(/<channel[^>]*>\s*/g, "").replace(/<\/channel>\s*/g, "").trim();
706
+ let agentClean = ex.agentText?.replace(/<[^>]+>/g, "").trim() ?? "";
707
+ // Skip meta-only exchanges (session management, no real content)
708
+ const userLower = userClean.toLowerCase();
709
+ if (/^(restarted|reconnected|mcp reconnected|\/mcp|\/clear|\/compact)$/i.test(userClean))
710
+ continue;
711
+ // Detect and compress skill injections (huge user messages from /skill invocations)
712
+ if (userClean.startsWith("Base directory for this skill:")) {
713
+ const skillMatch = userClean.match(/skills\/([^/\n]+)/);
714
+ userClean = skillMatch ? `[invoked /${skillMatch[1]}]` : "[invoked skill]";
715
+ }
716
+ else if (/^---\nname:/m.test(userClean)) {
717
+ // YAML frontmatter — injected skill content
718
+ const nameMatch = userClean.match(/name:\s*(.+)/);
719
+ userClean = nameMatch ? `[invoked /${nameMatch[1].trim()}]` : "[invoked skill]";
720
+ }
721
+ else if (userClean.startsWith("# ") && userClean.length > 500) {
722
+ // Large markdown doc injection
723
+ const heading = userClean.split("\n")[0].replace(/^#+\s*/, "");
724
+ userClean = `[doc: ${heading.substring(0, 80)}]`;
725
+ }
726
+ // Strip system-reminder tags that leak into exchange text
727
+ userClean = userClean.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").trim();
728
+ agentClean = agentClean.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").trim();
729
+ // Compress multiline text: strip code blocks, tables, collapse to key lines
730
+ userClean = compressExchangeText(userClean, 300);
731
+ agentClean = compressExchangeText(agentClean, 300);
732
+ if (!userClean && !agentClean)
733
+ continue; // nothing left after filtering
630
734
  lines.push(` USER: ${userClean}`);
631
735
  if (agentClean)
632
736
  lines.push(` AGENT: ${agentClean}`);
@@ -716,10 +820,10 @@ server.tool("write_memory", "Write a new memory entry to your hierarchical long-
716
820
  "\tFrontend architecture\n\n" +
717
821
  "\tReact + Vite, ShadcnUI components, SSE for real-time updates\n" +
718
822
  "\t\tAuth was tricky — EventSource can't send custom headers"),
719
- links: z.array(z.string()).optional().describe("Optional: IDs of related memories, e.g. ['P0001', 'L0005']"),
823
+ links: jsonArrayString(z.array(z.string()).optional()).describe("Optional: IDs of related memories, e.g. ['P0001', 'L0005']"),
720
824
  favorite: z.coerce.boolean().optional().describe("Mark this entry as a favorite — shown with [♥] in bulk reads and always inlined with L2 detail. " +
721
825
  "Use for reference info you need to see every session, regardless of category."),
722
- tags: z.array(z.string()).min(1).describe("Required hashtags for cross-cutting search (min 1, recommend 3+). " +
826
+ tags: jsonArrayString(z.array(z.string()).min(1)).describe("Required hashtags for cross-cutting search (min 1, recommend 3+). " +
723
827
  "E.g. ['#hmem', '#curation']. Max 10, lowercase, must start with #. Shown after title in reads."),
724
828
  pinned: z.coerce.boolean().optional().describe("Mark this entry as pinned [P] (super-favorite). Pinned entries show full L2 content in bulk reads. " +
725
829
  "Use for reference entries you need to see in full every session."),
@@ -735,26 +839,31 @@ server.tool("write_memory", "Write a new memory entry to your hierarchical long-
735
839
  isError: true,
736
840
  };
737
841
  }
738
- // P-prefix: validate L2 structure against standard schema
739
- if (prefix.toUpperCase() === "P") {
740
- const VALID_L2_CATEGORIES = [
741
- "overview", "codebase", "usage", "context", "deployment",
742
- "bugs", "protocol", "open tasks", "ideas",
743
- ];
842
+ // Schema validation: if a schema is defined for this prefix, validate L2 node names.
843
+ // This prevents agents from creating off-schema sections in structured entries.
844
+ const writeSchema = hmemConfig.schemas?.[prefix.toUpperCase()];
845
+ if (writeSchema) {
846
+ const sectionNames = writeSchema.sections.map(s => s.name.toLowerCase());
744
847
  const lines = content.split("\n");
745
- const l2Lines = lines.filter(l => /^\t[^\t]/.test(l)).map(l => l.replace(/^\t/, "").toLowerCase().trim());
848
+ // L2 candidates: exactly one tab indent AND not a legacy body line ("\t> ...").
849
+ // Blank-line-separated bodies under an L2 title are safe to ignore here because
850
+ // they appear AFTER a valid L2 title and fail the schema check only if the user
851
+ // placed free-form prose right under a valid section — in which case the title
852
+ // line above already satisfies the schema.
853
+ const l2Lines = lines
854
+ .filter(l => /^\t[^\t]/.test(l) && !/^\t>(?: |$)/.test(l))
855
+ .map(l => l.replace(/^\t/, "").toLowerCase().trim());
746
856
  if (l2Lines.length > 0) {
747
857
  const invalid = l2Lines.filter(l => {
748
858
  const firstWord = l.split(/\s*[—\-:]/)[0].trim();
749
- return !VALID_L2_CATEGORIES.some(cat => firstWord.startsWith(cat));
859
+ return !sectionNames.some(sec => firstWord.startsWith(sec));
750
860
  });
751
861
  if (invalid.length > 0) {
752
862
  return {
753
- content: [{ type: "text", text: `WARNING: P-entry L2 nodes must use standard categories.\n` +
754
- `Valid: ${VALID_L2_CATEGORIES.join(", ")}\n` +
755
- `Invalid L2 nodes found: ${invalid.map(l => `"${l.substring(0, 50)}"`).join(", ")}\n\n` +
756
- `See R0009 (P-Entry Standard Schema) for the full specification.\n` +
757
- `Fix the L2 node names and retry. If this is intentional, explain why in the content.` }],
863
+ content: [{ type: "text", text: `ERROR: ${prefix.toUpperCase()}-entry schema violation.\n` +
864
+ `Valid sections: ${writeSchema.sections.map(s => s.name).join(", ")}\n` +
865
+ `Invalid L2 nodes: ${invalid.map(l => `"${l.substring(0, 50)}"`).join(", ")}\n\n` +
866
+ `L2 node names must match defined schema sections. Fix and retry.` }],
758
867
  isError: true,
759
868
  };
760
869
  }
@@ -818,6 +927,13 @@ server.tool("write_memory", "Write a new memory entry to your hierarchical long-
818
927
  }
819
928
  }
820
929
  catch (e) {
930
+ // Similar-entries hit is not a real error — it's a deduplication hint.
931
+ // Return it as a non-error so the UI doesn't flag it in red (issue #15).
932
+ if (e instanceof SimilarEntriesError) {
933
+ return {
934
+ content: [{ type: "text", text: `Note: ${e.message}` }],
935
+ };
936
+ }
821
937
  return {
822
938
  content: [{ type: "text", text: `ERROR: ${safeError(e)}` }],
823
939
  isError: true,
@@ -838,17 +954,17 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
838
954
  "- Mark as irrelevant: update_memory(id='L0042', content='...', irrelevant=true)\n" +
839
955
  " No correction entry needed (unlike obsolete). Hidden from bulk reads.\n\n" +
840
956
  "To add new child nodes, use append_memory. " +
841
- "To replace the entire tree, use delete_agent_memory + write_memory.", {
957
+ "To replace an entire entry, mark the old root obsolete and write a new one.", {
842
958
  id: z.string().describe("ID of the entry or node to update, e.g. 'L0003' or 'L0003.2'"),
843
959
  content: z.string().min(1).describe("New text content for this node (plain text, no indentation)"),
844
- links: z.array(z.string()).optional().describe("Optional: update linked entry IDs (root entries only). Replaces existing links."),
960
+ links: jsonArrayString(z.array(z.string()).optional()).describe("Optional: update linked entry IDs (root entries only). Replaces existing links."),
845
961
  obsolete: z.coerce.boolean().optional().describe("Mark this root entry as no longer valid (root entries only). " +
846
962
  "Requires [✓ID] correction reference in content (e.g. 'Wrong — see [✓E0076]')."),
847
963
  favorite: z.coerce.boolean().optional().describe("Set or clear the [♥] favorite flag. Works on root entries and sub-nodes. " +
848
964
  "Root favorites are always shown with L2 detail in bulk reads."),
849
965
  irrelevant: z.coerce.boolean().optional().describe("Mark as irrelevant [-]. Works on root entries and sub-nodes. " +
850
966
  "No correction entry needed (unlike obsolete). Irrelevant entries/nodes are hidden from output."),
851
- tags: z.array(z.string()).optional().describe("Set tags on this entry/node. Replaces all existing tags. " +
967
+ tags: jsonArrayString(z.array(z.string()).optional()).describe("Set tags on this entry/node. Replaces all existing tags. " +
852
968
  "Pass empty array [] to remove all tags. E.g. ['#hmem', '#curation']."),
853
969
  pinned: z.coerce.boolean().optional().describe("Set or clear the [P] pinned flag (root entries only). " +
854
970
  "Pinned entries show full L2 content in bulk reads (super-favorite)."),
@@ -856,11 +972,12 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
856
972
  "When any entry in a prefix has active=true, only active entries of that prefix are shown with children in bulk reads. " +
857
973
  "Non-active entries in the same prefix are shown as title-only (no children)."),
858
974
  store: z.enum(["personal", "company"]).default("personal").describe("Target store: 'personal' or 'company'"),
859
- }, async ({ id, content, links, obsolete, favorite, irrelevant, tags, pinned, active, store: storeName }) => {
975
+ hmem_path: z.string().optional().describe("Curator mode: absolute path to an external .hmem file to update. " +
976
+ "Overrides the `store` parameter. Sync is skipped for external files."),
977
+ }, async ({ id, content, links, obsolete, favorite, irrelevant, tags, pinned, active, store: storeName, hmem_path }) => {
860
978
  try {
861
- const hmemStore = storeName === "company"
862
- ? openCompanyMemory(PROJECT_DIR, hmemConfig)
863
- : new HmemStore(HMEM_PATH, hmemConfig);
979
+ const { store: hmemStore, label: storeLabelResolved } = resolveStore(storeName, hmem_path);
980
+ const isExternal = !!hmem_path;
864
981
  try {
865
982
  if (hmemStore.corrupted) {
866
983
  return {
@@ -868,7 +985,7 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
868
985
  isError: true,
869
986
  };
870
987
  }
871
- if (storeName === "personal")
988
+ if (storeName === "personal" && !isExternal)
872
989
  syncPullThenPush(HMEM_PATH);
873
990
  // Cross-project write notice: if updating a P-sub-node of a project that isn't currently
874
991
  // active, do NOT auto-switch. The agent may be doing a quick cross-project edit (e.g.
@@ -876,7 +993,7 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
876
993
  // response so the agent can decide whether to load_project() and switch context.
877
994
  const rootId = id.includes(".") ? id.split(".")[0] : id;
878
995
  let crossProjectNotice = "";
879
- if (rootId.startsWith("P") && storeName === "personal") {
996
+ if (rootId.startsWith("P") && storeName === "personal" && !isExternal) {
880
997
  const current = hmemStore.getActiveProject(currentSessionId());
881
998
  if (!current || current.id !== rootId) {
882
999
  crossProjectNotice = `\n\nNotice: ${rootId} is not the currently active project${current ? ` (active: ${current.id})` : ""}. ` +
@@ -892,7 +1009,7 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
892
1009
  }
893
1010
  }
894
1011
  const ok = hmemStore.updateNode(id, content, links, obsolete, favorite, undefined, irrelevant, tags, pinned, active);
895
- const storeLabel = storeName === "company" ? "company" : path.basename(HMEM_PATH, ".hmem");
1012
+ const storeLabel = storeLabelResolved;
896
1013
  log(`update_memory [${storeLabel}]: ${id} → ${ok ? "updated" : "not found"}${obsolete ? " (marked obsolete)" : ""}${irrelevant ? " (marked irrelevant)" : ""}${favorite !== undefined ? ` (favorite=${favorite})` : ""}${active !== undefined ? ` (active=${active})` : ""}`);
897
1014
  if (!ok) {
898
1015
  return {
@@ -923,7 +1040,7 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
923
1040
  parts.push("active flag cleared");
924
1041
  if (tags !== undefined)
925
1042
  parts.push(tags.length > 0 ? `tags: ${tags.join(" ")}` : "tags cleared");
926
- if (storeName === "personal") {
1043
+ if (storeName === "personal" && !isExternal) {
927
1044
  const retry = syncPushWithRetry(HMEM_PATH);
928
1045
  if (!retry.resolved) {
929
1046
  parts.push(`⚠ unresolved push conflicts after ${retry.attempts} attempts`);
@@ -1008,8 +1125,8 @@ server.tool("flush_context", "Store a conversation chunk as linear context histo
1008
1125
  l3: z.string().optional().describe("Detailed summary (~500 words). Only if L2 is too compressed."),
1009
1126
  l4: z.string().optional().describe("Extended context (~2000 words). Rarely needed."),
1010
1127
  l5: z.string().optional().describe("Raw conversation chunk. Full text, no summarization."),
1011
- tags: z.array(z.string()).min(1).describe("Required hashtags for discovery. E.g. ['#hmem', '#context-for', '#ux']"),
1012
- links: z.array(z.string()).optional().describe("Link to related entries. E.g. ['P0029', 'D0120']"),
1128
+ tags: jsonArrayString(z.array(z.string()).min(1)).describe("Required hashtags for discovery. E.g. ['#hmem', '#context-for', '#ux']"),
1129
+ links: jsonArrayString(z.array(z.string()).optional()).describe("Link to related entries. E.g. ['P0029', 'D0120']"),
1013
1130
  }, async ({ l1, l2, l3, l4, l5, tags, links }) => {
1014
1131
  try {
1015
1132
  const hmemStore = new HmemStore(HMEM_PATH, hmemConfig);
@@ -1056,6 +1173,22 @@ server.tool("append_memory", "Append new child nodes to an existing memory entry
1056
1173
  "Example: 'New point\\n\\tSub-detail'"),
1057
1174
  store: z.enum(["personal", "company"]).default("personal").describe("Target store: 'personal' or 'company'"),
1058
1175
  }, async ({ id, content, store: storeName }) => {
1176
+ // Schema enforcement: if a schema is defined for this prefix, block appends to root
1177
+ // entries. New L2 nodes are not allowed — agents must append to specific sections.
1178
+ if (!id.includes(".")) {
1179
+ const appendPrefix = id.match(/^([A-Z])/)?.[1];
1180
+ if (appendPrefix && hmemConfig.schemas?.[appendPrefix]) {
1181
+ const appendSchema = hmemConfig.schemas[appendPrefix];
1182
+ const sections = appendSchema.sections.map((s, i) => ` .${i + 1} ${s.name}`).join("\n");
1183
+ return {
1184
+ content: [{ type: "text", text: `ERROR: ${id} uses a fixed schema — cannot add new L2 nodes directly.\n` +
1185
+ `Defined sections:\n${sections}\n\n` +
1186
+ `Append to a specific section instead, e.g.:\n` +
1187
+ ` append_memory(id="${id}.1", content="...") → ${appendSchema.sections[0]?.name ?? "first section"}` }],
1188
+ isError: true,
1189
+ };
1190
+ }
1191
+ }
1059
1192
  try {
1060
1193
  const hmemStore = storeName === "company"
1061
1194
  ? openCompanyMemory(PROJECT_DIR, hmemConfig)
@@ -1174,13 +1307,14 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1174
1307
  "Example: read_memory({ context_for: 'P0029' }) — loads P0029 + all contextually related entries."),
1175
1308
  min_tag_score: z.number().optional().describe("Minimum weighted tag score for context_for matches (default: 5). " +
1176
1309
  "Score 4 = e.g. 2 medium tags, or 1 rare + 1 common. Lower = more results, higher = stricter."),
1177
- }, 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, stale_days, context_for, min_tag_score }) => {
1310
+ hmem_path: z.string().optional().describe("Curator mode: absolute path to an external .hmem file to read from. " +
1311
+ "Overrides the `store` parameter. Use to audit/curate another .hmem file."),
1312
+ }, 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, stale_days, context_for, min_tag_score, hmem_path }) => {
1178
1313
  // Pull before read to get latest from server (30s cooldown)
1179
- const newEntries = storeName === "personal" ? syncPull(HMEM_PATH) : [];
1314
+ const newEntries = storeName === "personal" && !hmem_path ? syncPull(HMEM_PATH) : [];
1180
1315
  try {
1181
- const hmemStore = storeName === "company"
1182
- ? openCompanyMemory(PROJECT_DIR, hmemConfig)
1183
- : new HmemStore(HMEM_PATH, hmemConfig);
1316
+ const { store: hmemStore, label: storeLabelResolved, path: resolvedPath } = resolveStore(storeName, hmem_path);
1317
+ const isExternal = !!hmem_path;
1184
1318
  try {
1185
1319
  const corruptionWarning = hmemStore.corrupted
1186
1320
  ? "⚠ WARNING: Memory database is corrupted! Reads may be incomplete. A backup (.corrupt) was saved.\n\n"
@@ -1239,7 +1373,7 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1239
1373
  }
1240
1374
  }
1241
1375
  }
1242
- const storeLabel = storeName === "company" ? "company" : path.basename(HMEM_PATH, ".hmem");
1376
+ const storeLabel = storeLabelResolved;
1243
1377
  const output = lines.join("\n");
1244
1378
  // Add token estimate to header line (2nd line)
1245
1379
  const fmtTok = (n) => n < 1000 ? String(n) : n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`;
@@ -1254,7 +1388,7 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1254
1388
  // Session cache: cached entries shown as titles in subsequent bulk reads
1255
1389
  // Explicit filters (after, before, prefix, stale_days, tag) bypass V2 selection + cache
1256
1390
  const isBulkListing = !id && !search && !time_around && !after && !before && !prefix && !stale_days && !tag;
1257
- const useCache = isBulkListing && storeName === "personal" && !show_all;
1391
+ const useCache = isBulkListing && storeName === "personal" && !show_all && !isExternal;
1258
1392
  const cachedIds = useCache ? sessionCache.getCachedIds() : undefined;
1259
1393
  const hiddenIds = useCache ? sessionCache.getHiddenIds() : undefined;
1260
1394
  const slotFraction = useCache ? sessionCache.getSlotFraction() : undefined;
@@ -1278,11 +1412,9 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1278
1412
  directResults: !isBulkListing && !id && !search && !time_around,
1279
1413
  });
1280
1414
  if (entries.length === 0) {
1281
- const hmemPath = storeName === "company"
1282
- ? path.join(PROJECT_DIR, "company.hmem")
1283
- : HMEM_PATH;
1415
+ const hmemPath = resolvedPath;
1284
1416
  const dbExists = fs.existsSync(hmemPath);
1285
- const label = storeName === "company" ? "company" : path.basename(HMEM_PATH, ".hmem");
1417
+ const label = storeLabelResolved;
1286
1418
  const storeInfo = `\nStore: ${label} | DB: ${hmemPath}${dbExists ? "" : " [FILE NOT FOUND]"}`;
1287
1419
  // Sync hint: if memory is empty and hmem-sync is not configured, suggest it
1288
1420
  let syncHint = "";
@@ -1314,7 +1446,7 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1314
1446
  ? formatGroupedOutput(hmemStore, entries, curator ?? false, hmemConfig)
1315
1447
  : formatFlatOutput(entries, curator ?? false, expand ?? false);
1316
1448
  const stats = hmemStore.stats();
1317
- const storeLabel = storeName === "company" ? "company" : path.basename(HMEM_PATH, ".hmem");
1449
+ const storeLabel = storeLabelResolved;
1318
1450
  const visibleCount = entries.length;
1319
1451
  // Cache status in header (when active)
1320
1452
  const hiddenCount = hiddenIds?.size ?? 0;
@@ -1386,8 +1518,8 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1386
1518
  : " (no projects yet — create one with write_memory(prefix=\"P\", content=\"Name | Status | Stack | Description\", tags=[...]))";
1387
1519
  // Inject recent O-entries even without active project (global, no project filter)
1388
1520
  let recentOHint = "";
1389
- if (hmemConfig.recentOEntries > 0) {
1390
- const { text, ids } = formatRecentOEntries(hmemStore, hmemConfig.recentOEntries, 10);
1521
+ if (hmemConfig.bulkReadOEntries > 0) {
1522
+ const { text, ids } = formatRecentOEntries(hmemStore, hmemConfig.bulkReadOEntries, 10);
1391
1523
  if (text) {
1392
1524
  recentOHint = `\n${text}\n`;
1393
1525
  sessionCache.registerDelivered(ids);
@@ -1410,10 +1542,10 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1410
1542
  }
1411
1543
  // Inject recent O-entries (session logs) on bulk reads when none are cached
1412
1544
  let recentOSection = "";
1413
- if (isBulkListing && storeName === "personal" && hmemConfig.recentOEntries > 0) {
1545
+ if (isBulkListing && storeName === "personal" && !isExternal && hmemConfig.bulkReadOEntries > 0) {
1414
1546
  const cachedOIds = [...(cachedIds || []), ...(hiddenIds || [])].filter(id => id.startsWith("O"));
1415
1547
  if (cachedOIds.length === 0) {
1416
- const { text, ids } = formatRecentOEntries(hmemStore, hmemConfig.recentOEntries, 10);
1548
+ const { text, ids } = formatRecentOEntries(hmemStore, hmemConfig.bulkReadOEntries, 10);
1417
1549
  if (text) {
1418
1550
  recentOSection = `\n${text}\n`;
1419
1551
  sessionCache.registerDelivered(ids);
@@ -1613,11 +1745,10 @@ server.tool("find_related", "Find entries related to the given entry. " +
1613
1745
  id: z.string().describe("Root entry ID to find related entries for, e.g. 'P0001'"),
1614
1746
  limit: z.number().min(1).max(20).default(5).describe("Max results to return (default: 5)"),
1615
1747
  store: z.enum(["personal", "company"]).default("personal"),
1616
- }, async ({ id, limit, store: storeName }) => {
1748
+ hmem_path: z.string().optional().describe("Curator mode: absolute path to an external .hmem file. Overrides `store`."),
1749
+ }, async ({ id, limit, store: storeName, hmem_path }) => {
1617
1750
  try {
1618
- const hmemStore = storeName === "company"
1619
- ? openCompanyMemory(PROJECT_DIR, hmemConfig)
1620
- : new HmemStore(HMEM_PATH, hmemConfig);
1751
+ const { store: hmemStore } = resolveStore(storeName, hmem_path);
1621
1752
  try {
1622
1753
  const results = hmemStore.findRelatedCombined(id, limit);
1623
1754
  if (results.length === 0) {
@@ -1639,39 +1770,6 @@ server.tool("find_related", "Find entries related to the given entry. " +
1639
1770
  return { content: [{ type: "text", text: `ERROR: ${safeError(e)}` }], isError: true };
1640
1771
  }
1641
1772
  });
1642
- server.tool("route_task", "[DEPRECATED: route_task requires the legacy Agents/ directory structure. Future versions will use config-based agent discovery.]\n\n" +
1643
- "Multi-agent only: find the best agent for a task based on memory content. " +
1644
- "Scans all agent .hmem files in the Agents/ directory and scores them against tags + keywords. " +
1645
- "Only useful in multi-agent setups (Heimdall, Das Althing) — single-agent users should ignore this tool.\n\n" +
1646
- "Example: route_task(tags=['#backend', '#sqlite'], keywords='connection pooling bug')\n" +
1647
- "Returns agents ranked by memory relevance with their top matching entries.", {
1648
- tags: z.array(z.string()).min(1).describe("Tags to match against agent memories. E.g. ['#backend', '#sqlite', '#bug']"),
1649
- keywords: z.string().optional().describe("Free-text keywords for FTS5 search supplement. E.g. 'connection pooling timeout'"),
1650
- limit: z.number().min(1).max(20).default(5).describe("Max agents to return (default: 5)"),
1651
- }, async ({ tags, keywords, limit: maxResults }) => {
1652
- try {
1653
- const results = routeTask(PROJECT_DIR, tags, keywords, maxResults, hmemConfig);
1654
- if (results.length <= 1) {
1655
- return {
1656
- content: [{ type: "text", text: results.length === 0
1657
- ? "No agents found. route_task requires a multi-agent setup with Agents/*/*.hmem files."
1658
- : `Only one agent found (${results[0].agent}). route_task is designed for multi-agent setups.` }],
1659
- };
1660
- }
1661
- const lines = [`## Agent Routing (${results.length} matches)\n`];
1662
- for (const r of results) {
1663
- lines.push(`**${r.agent}** — score: ${r.score} (${r.entryCount} matching entries)`);
1664
- for (const e of r.topEntries) {
1665
- lines.push(` ${e.id} (${e.score}) ${e.title}`);
1666
- }
1667
- lines.push("");
1668
- }
1669
- return { content: [{ type: "text", text: lines.join("\n") }] };
1670
- }
1671
- catch (e) {
1672
- return { content: [{ type: "text", text: `ERROR: ${safeError(e)}` }], isError: true };
1673
- }
1674
- });
1675
1773
  /** Strip body (after \n>) and newlines from titles for compact display */
1676
1774
  function cleanTitle(t, max = 0) {
1677
1775
  // Split at body separator — real newline+> or literal \n>
@@ -2005,8 +2103,8 @@ server.tool("create_project", "Create a new project with the standard R0009 sche
2005
2103
  goal: z.string().optional().describe("Main project goal (1-2 sentences)"),
2006
2104
  audience: z.string().optional().describe("Target audience / who uses it"),
2007
2105
  deployment: z.string().optional().describe("How it's deployed (npm, exe, server, manual)"),
2008
- tags: z.array(z.string()).optional().describe("Additional tags beyond #project (auto-added)"),
2009
- links: z.array(z.string()).optional().describe("Related entry IDs, e.g. ['T0044', 'L0095']"),
2106
+ tags: jsonArrayString(z.array(z.string()).optional()).describe("Additional tags beyond #project (auto-added)"),
2107
+ links: jsonArrayString(z.array(z.string()).optional()).describe("Related entry IDs, e.g. ['T0044', 'L0095']"),
2010
2108
  store: z.enum(["personal", "company"]).default("personal"),
2011
2109
  }, async ({ name, tech, description, status, repo, goal, audience, deployment, tags, links, store: storeName }) => {
2012
2110
  try {
@@ -2134,14 +2232,13 @@ server.tool("memory_health", "Audit report for your memory: broken links (links
2134
2232
  "and tag orphans (tags with no matching entry). " +
2135
2233
  "Run before/after a curation session.", {
2136
2234
  store: z.enum(["personal", "company"]).default("personal"),
2137
- }, async ({ store: storeName }) => {
2235
+ hmem_path: z.string().optional().describe("Curator mode: absolute path to an external .hmem file. Overrides `store`."),
2236
+ }, async ({ store: storeName, hmem_path }) => {
2138
2237
  try {
2139
- const hmemStore = storeName === "company"
2140
- ? openCompanyMemory(PROJECT_DIR, hmemConfig)
2141
- : new HmemStore(HMEM_PATH, hmemConfig);
2238
+ const { store: hmemStore, label: storeLabelResolved } = resolveStore(storeName, hmem_path);
2142
2239
  try {
2143
2240
  const h = hmemStore.healthCheck();
2144
- const lines = [`Memory health report (${storeName}):`];
2241
+ const lines = [`Memory health report (${storeLabelResolved}):`];
2145
2242
  const ok = (label) => lines.push(` ✓ ${label}`);
2146
2243
  const warn = (label) => lines.push(` ⚠ ${label}`);
2147
2244
  if (h.brokenLinks.length === 0) {
@@ -2382,389 +2479,6 @@ server.tool("move_nodes", "Move session (L2), batch (L3), or exchange (L4) nodes
2382
2479
  return { content: [{ type: "text", text: `ERROR: ${safeError(e)}` }], isError: true };
2383
2480
  }
2384
2481
  });
2385
- // ---- Tool: reorder_sessions ----
2386
- server.tool("reorder_sessions", "Reorder L2 session-nodes under an O-entry so their seq matches chronological order by created_at (ascending). Useful after a move_nodes call that landed a session at the wrong seq slot, or to clean up out-of-order sessions after curation. Uses 2-phase rename via staging IDs so existing sub-node IDs are safely rewritten. Returns the number of sessions actually renamed.", {
2387
- o_id: z.string().describe("O-entry ID whose L2 sessions should be reordered, e.g. 'O0048'"),
2388
- store: z.enum(["personal", "company"]).default("personal").describe("Which store to operate on"),
2389
- }, async ({ o_id, store }) => {
2390
- try {
2391
- const hmemStore = store === "company"
2392
- ? openCompanyMemory(PROJECT_DIR, hmemConfig)
2393
- : new HmemStore(HMEM_PATH, hmemConfig);
2394
- try {
2395
- if (store === "personal")
2396
- syncPullThenPush(HMEM_PATH);
2397
- const renamed = hmemStore.reorderSessionsByDate(o_id);
2398
- let text = renamed === 0
2399
- ? `${o_id}: sessions already in chronological order (no changes).`
2400
- : `${o_id}: reordered ${renamed} session(s) by created_at.`;
2401
- if (store === "personal") {
2402
- const retry = syncPushWithRetry(HMEM_PATH);
2403
- if (!retry.resolved)
2404
- text += `\n⚠ unresolved push conflicts after ${retry.attempts} attempts`;
2405
- else if (retry.attempts > 1)
2406
- text += `\n(resolved push conflict after ${retry.attempts} attempts)`;
2407
- }
2408
- return { content: [{ type: "text", text }] };
2409
- }
2410
- finally {
2411
- hmemStore.close();
2412
- }
2413
- }
2414
- catch (e) {
2415
- return { content: [{ type: "text", text: `ERROR: ${safeError(e)}` }], isError: true };
2416
- }
2417
- });
2418
- // ---- Curator Tools (ceo role only) ----
2419
- const AUDIT_STATE_FILE = process.env.HMEM_AUDIT_STATE_PATH
2420
- || path.join(PROJECT_DIR, "audit_state.json");
2421
- function loadAuditState() {
2422
- try {
2423
- if (fs.existsSync(AUDIT_STATE_FILE)) {
2424
- return JSON.parse(fs.readFileSync(AUDIT_STATE_FILE, "utf-8"));
2425
- }
2426
- }
2427
- catch { /* ignore */ }
2428
- return {};
2429
- }
2430
- function saveAuditState(state) {
2431
- const dir = path.dirname(AUDIT_STATE_FILE);
2432
- if (!fs.existsSync(dir))
2433
- fs.mkdirSync(dir, { recursive: true });
2434
- const tmp = AUDIT_STATE_FILE + ".tmp";
2435
- fs.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
2436
- fs.renameSync(tmp, AUDIT_STATE_FILE);
2437
- }
2438
- function isCurator() {
2439
- return process.env.HMEM_AGENT_ROLE === "ceo";
2440
- }
2441
- server.tool("get_audit_queue", "CURATOR ONLY (ceo role). Returns agents whose .hmem has changed since last audit. " +
2442
- "Use this at the start of each curation run to get the list of agents to process. " +
2443
- "Each agent should be audited in a separate spawn to keep context bounded.", {}, async () => {
2444
- if (!isCurator()) {
2445
- return {
2446
- 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." }],
2447
- isError: true,
2448
- };
2449
- }
2450
- const auditState = loadAuditState();
2451
- // Scan for .hmem files in PROJECT_DIR and subdirectories (1 level deep)
2452
- const queue = [];
2453
- // Check common agent directory patterns
2454
- for (const subdir of ["Agents", "Assistenten", "agents", "."]) {
2455
- const dir = path.join(PROJECT_DIR, subdir);
2456
- if (!fs.existsSync(dir))
2457
- continue;
2458
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2459
- if (!entry.isDirectory())
2460
- continue;
2461
- const name = entry.name;
2462
- const hmemPath = path.join(dir, name, `${name}.hmem`);
2463
- if (!fs.existsSync(hmemPath))
2464
- continue;
2465
- const stat = fs.statSync(hmemPath);
2466
- const modified = stat.mtime.toISOString();
2467
- const lastAudit = auditState[name] || null;
2468
- if (!lastAudit || new Date(modified) > new Date(lastAudit)) {
2469
- queue.push({ name, hmemPath, modified, lastAudit });
2470
- }
2471
- }
2472
- }
2473
- // Also check for standalone memory.hmem in PROJECT_DIR
2474
- const defaultHmem = path.join(PROJECT_DIR, "memory.hmem");
2475
- if (fs.existsSync(defaultHmem)) {
2476
- const stat = fs.statSync(defaultHmem);
2477
- const modified = stat.mtime.toISOString();
2478
- const lastAudit = auditState["default"] || null;
2479
- if (!lastAudit || new Date(modified) > new Date(lastAudit)) {
2480
- queue.push({ name: "default", hmemPath: defaultHmem, modified, lastAudit });
2481
- }
2482
- }
2483
- if (queue.length === 0) {
2484
- return {
2485
- content: [{ type: "text", text: "Audit queue is empty — all agent memories are up to date." }],
2486
- };
2487
- }
2488
- const lines = queue.map(a => `- **${a.name}**: modified ${a.modified.substring(0, 16)}` +
2489
- (a.lastAudit ? ` | last audited ${a.lastAudit.substring(0, 16)}` : " | never audited"));
2490
- return {
2491
- content: [{
2492
- type: "text",
2493
- text: `## Audit Queue (${queue.length} agents to check)\n\n${lines.join("\n")}\n\n` +
2494
- `Process one agent per spawn: terminate after each to keep context bounded.`,
2495
- }],
2496
- };
2497
- });
2498
- server.tool("read_agent_memory", "CURATOR ONLY (ceo role). Read the full memory of any agent (for audit purposes). " +
2499
- "Returns all entries at the specified depth. Use depth=3 for a thorough audit.", {
2500
- agent_name: z.string().describe("Template name of the agent, e.g. 'THOR', 'SIGURD'"),
2501
- depth: z.number().int().min(1).max(5).optional().describe("Depth to read (1-5, default: 3)"),
2502
- }, async ({ agent_name, depth }) => {
2503
- if (!isCurator()) {
2504
- return {
2505
- content: [{ type: "text", text: "ERROR: read_agent_memory is only available to the ceo/curator role." }],
2506
- isError: true,
2507
- };
2508
- }
2509
- const hmemPath = resolveHmemPathLegacy(PROJECT_DIR, validateAgentName(agent_name));
2510
- if (!fs.existsSync(hmemPath)) {
2511
- return {
2512
- content: [{ type: "text", text: `No .hmem found for agent "${agent_name}" (expected: ${hmemPath}).` }],
2513
- };
2514
- }
2515
- const store = new HmemStore(hmemPath, hmemConfig);
2516
- try {
2517
- const entries = store.read({ depth: depth || 3, limit: 500 });
2518
- const stats = store.stats();
2519
- if (entries.length === 0) {
2520
- return { content: [{ type: "text", text: `Agent "${agent_name}" has no memory entries.` }] };
2521
- }
2522
- const lines = [`## Memory: ${agent_name} (${stats.total} entries, depth=${depth || 3})\n`];
2523
- for (const e of entries) {
2524
- const date = e.created_at.substring(0, 10);
2525
- const access = e.access_count > 0 ? ` (${e.access_count}x)` : "";
2526
- const obsoleteTag = e.obsolete ? " [⚠ OBSOLETE]" : "";
2527
- const irrelevantTag = e.irrelevant ? " [- IRRELEVANT]" : "";
2528
- const favTag = e.favorite ? " [♥]" : "";
2529
- lines.push(`[${e.id}] ${date}${favTag}${obsoleteTag}${irrelevantTag}${access}`);
2530
- lines.push(` ${e.title}`);
2531
- if (e.level_1 && e.level_1 !== e.title) {
2532
- for (const bodyLine of e.level_1.split("\n")) {
2533
- lines.push(` ${bodyLine}`);
2534
- }
2535
- }
2536
- if (e.children && e.children.length > 0) {
2537
- for (const child of e.children) {
2538
- const indent = " ".repeat(child.depth - 1);
2539
- const hint = (child.child_count ?? 0) > 0
2540
- ? ` (${child.child_count} — use id="${child.id}" to expand)`
2541
- : "";
2542
- lines.push(`${indent}[${child.id}] ${child.title}${hint}`);
2543
- }
2544
- }
2545
- if (e.links?.length)
2546
- lines.push(` Links: ${e.links.join(", ")}`);
2547
- lines.push("");
2548
- }
2549
- log(`read_agent_memory [CURATOR]: ${agent_name} depth=${depth || 3} → ${entries.length} entries`);
2550
- return { content: [{ type: "text", text: lines.join("\n") }] };
2551
- }
2552
- finally {
2553
- store.close();
2554
- }
2555
- });
2556
- server.tool("fix_agent_memory", "CURATOR ONLY (ceo role). Correct a specific entry or node in any agent's memory.\n\n" +
2557
- "Accepts both root IDs ('L0003') and compound node IDs ('L0003.2'):\n" +
2558
- "- Root ID: updates L1 summary text, obsolete/irrelevant/favorite flags\n" +
2559
- "- Compound node ID: updates the content of that specific node\n\n" +
2560
- "To fix wrong prefix: delete + re-add (prefix cannot be changed in-place).\n" +
2561
- "To consolidate fragmented P entries: use read_agent_memory to read them, " +
2562
- "fix_agent_memory to update the keeper entry, delete_agent_memory to remove duplicates.", {
2563
- agent_name: z.string().describe("Template name of the agent, e.g. 'THOR'"),
2564
- entry_id: z.string().describe("Root entry ID ('L0003') or compound node ID ('L0003.2'). " +
2565
- "Node IDs update memory_nodes.content directly."),
2566
- content: z.string().optional().describe("New text content. For root entries: replaces the L1 summary. " +
2567
- "For node IDs: replaces that node's content."),
2568
- obsolete: z.coerce.boolean().optional().describe("Mark or unmark as obsolete (root entries only). " +
2569
- "Obsolete entries stay in memory but are shown with [⚠ OBSOLETE]."),
2570
- favorite: z.coerce.boolean().optional().describe("Set or clear the [♥] favorite flag (root entries only)."),
2571
- 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."),
2572
- }, async ({ agent_name, entry_id, content, obsolete, favorite, irrelevant }) => {
2573
- if (!isCurator()) {
2574
- return {
2575
- content: [{ type: "text", text: "ERROR: fix_agent_memory is only available to the ceo/curator role." }],
2576
- isError: true,
2577
- };
2578
- }
2579
- const hmemPath = resolveHmemPathLegacy(PROJECT_DIR, validateAgentName(agent_name));
2580
- if (!fs.existsSync(hmemPath)) {
2581
- return {
2582
- content: [{ type: "text", text: `No .hmem found for agent "${agent_name}".` }],
2583
- isError: true,
2584
- };
2585
- }
2586
- const store = new HmemStore(hmemPath, hmemConfig);
2587
- try {
2588
- const isNode = entry_id.includes(".");
2589
- let ok = false;
2590
- const changed = [];
2591
- if (isNode) {
2592
- // Compound node ID — update memory_nodes.content
2593
- if (!content) {
2594
- return {
2595
- content: [{ type: "text", text: "ERROR: 'content' is required when fixing a compound node ID." }],
2596
- isError: true,
2597
- };
2598
- }
2599
- ok = store.updateNode(entry_id, content);
2600
- if (ok)
2601
- changed.push("content");
2602
- }
2603
- else {
2604
- // Root entry — update memories table
2605
- if (!content && obsolete === undefined && favorite === undefined && irrelevant === undefined) {
2606
- return {
2607
- content: [{ type: "text", text: "ERROR: Provide at least one of: content, obsolete, favorite, irrelevant." }],
2608
- isError: true,
2609
- };
2610
- }
2611
- if (content) {
2612
- ok = store.updateNode(entry_id, content, undefined, obsolete, favorite, true /* curatorBypass */, irrelevant);
2613
- changed.push("L1");
2614
- if (obsolete !== undefined)
2615
- changed.push("obsolete");
2616
- if (favorite !== undefined)
2617
- changed.push("favorite");
2618
- if (irrelevant !== undefined)
2619
- changed.push("irrelevant");
2620
- }
2621
- else {
2622
- const fields = {};
2623
- if (obsolete !== undefined)
2624
- fields.obsolete = obsolete;
2625
- if (favorite !== undefined)
2626
- fields.favorite = favorite;
2627
- if (irrelevant !== undefined)
2628
- fields.irrelevant = irrelevant;
2629
- ok = store.update(entry_id, fields);
2630
- }
2631
- if (!content && obsolete !== undefined)
2632
- changed.push("obsolete");
2633
- if (!content && favorite !== undefined)
2634
- changed.push("favorite");
2635
- if (!content && irrelevant !== undefined)
2636
- changed.push("irrelevant");
2637
- }
2638
- log(`fix_agent_memory [CURATOR]: ${agent_name} ${entry_id} → ${ok ? "updated" : "not found"} (${changed.join(", ")})`);
2639
- return {
2640
- content: [{
2641
- type: "text",
2642
- text: ok
2643
- ? `Fixed: ${agent_name}/${entry_id} (${changed.join(", ")})`
2644
- : `ERROR: Entry "${entry_id}" not found in ${agent_name}'s memory.`,
2645
- }],
2646
- isError: !ok,
2647
- };
2648
- }
2649
- finally {
2650
- store.close();
2651
- }
2652
- });
2653
- server.tool("append_agent_memory", "CURATOR ONLY (ceo role). Append new child nodes to an existing entry in any agent's memory. " +
2654
- "Use exclusively for merging/consolidating entries — e.g. when collapsing two P entries into one, " +
2655
- "carry over the best content from the entry being deleted into the keeper before deleting.\n\n" +
2656
- "Content is tab-indented relative to the parent (same as append_memory):\n" +
2657
- " 0 tabs = direct child of id\n" +
2658
- " 1 tab = grandchild, etc.", {
2659
- agent_name: z.string().describe("Template name of the agent, e.g. 'THOR'"),
2660
- id: z.string().describe("Root entry ID or parent node ID to append children to, e.g. 'P0004' or 'P0004.2'"),
2661
- content: z.string().min(1).describe("Tab-indented content to append. 0 tabs = direct child of id."),
2662
- }, async ({ agent_name, id, content }) => {
2663
- if (!isCurator()) {
2664
- return {
2665
- content: [{ type: "text", text: "ERROR: append_agent_memory is only available to the ceo/curator role." }],
2666
- isError: true,
2667
- };
2668
- }
2669
- const hmemPath = resolveHmemPathLegacy(PROJECT_DIR, validateAgentName(agent_name));
2670
- if (!fs.existsSync(hmemPath)) {
2671
- return {
2672
- content: [{ type: "text", text: `No .hmem found for agent "${agent_name}".` }],
2673
- isError: true,
2674
- };
2675
- }
2676
- const store = new HmemStore(hmemPath, hmemConfig);
2677
- try {
2678
- const result = store.appendChildren(id, content);
2679
- log(`append_agent_memory [CURATOR]: ${agent_name} ${id} + ${result.count} nodes → [${result.ids.join(", ")}]`);
2680
- if (result.count === 0) {
2681
- return {
2682
- content: [{ type: "text", text: "No nodes appended — content was empty or contained no valid lines." }],
2683
- };
2684
- }
2685
- return {
2686
- content: [{
2687
- type: "text",
2688
- text: `Appended ${result.count} node${result.count === 1 ? "" : "s"} to ${agent_name}/${id}.\n` +
2689
- `New top-level children: ${result.ids.join(", ")}`,
2690
- }],
2691
- };
2692
- }
2693
- catch (e) {
2694
- return {
2695
- content: [{ type: "text", text: `ERROR: ${safeError(e)}` }],
2696
- isError: true,
2697
- };
2698
- }
2699
- finally {
2700
- store.close();
2701
- }
2702
- });
2703
- server.tool("delete_agent_memory", "Delete an entry from an agent's memory. " +
2704
- "Own entries: always allowed. Other agents: curator/ceo role required. " +
2705
- "Use sparingly — only for exact duplicates or entries that are factually wrong and cannot be fixed.", {
2706
- agent_name: z.string().describe("Template name of the agent, e.g. 'THOR'"),
2707
- entry_id: z.string().describe("Entry ID to delete, e.g. 'E0007'"),
2708
- }, async ({ agent_name, entry_id }) => {
2709
- validateAgentName(agent_name);
2710
- const hmemPath = resolveHmemPathLegacy(PROJECT_DIR, agent_name);
2711
- if (!fs.existsSync(hmemPath)) {
2712
- return {
2713
- content: [{ type: "text", text: `No .hmem found for agent "${agent_name}".` }],
2714
- isError: true,
2715
- };
2716
- }
2717
- const isOwnMemory = hmemPath === HMEM_PATH;
2718
- // Curator can delete any agent's entries; non-curators can only delete their own
2719
- if (!isOwnMemory && !isCurator()) {
2720
- return {
2721
- content: [{ type: "text", text: "ERROR: delete_agent_memory for other agents is only available to the ceo/curator role. To delete your own entries, use your own agent_name." }],
2722
- isError: true,
2723
- };
2724
- }
2725
- if (!fs.existsSync(hmemPath)) {
2726
- return {
2727
- content: [{ type: "text", text: `No .hmem found for agent "${agent_name}".` }],
2728
- isError: true,
2729
- };
2730
- }
2731
- const store = new HmemStore(hmemPath, hmemConfig);
2732
- try {
2733
- const ok = store.delete(entry_id);
2734
- log(`delete_agent_memory [${isOwnMemory ? "SELF" : "CURATOR"}]: ${agent_name} ${entry_id} → ${ok ? "deleted" : "not found"}`);
2735
- return {
2736
- content: [{
2737
- type: "text",
2738
- text: ok
2739
- ? `Deleted: ${agent_name}/${entry_id}`
2740
- : `ERROR: Entry "${entry_id}" not found in ${agent_name}'s memory.`,
2741
- }],
2742
- isError: !ok,
2743
- };
2744
- }
2745
- finally {
2746
- store.close();
2747
- }
2748
- });
2749
- server.tool("mark_audited", "CURATOR ONLY (ceo role). Mark an agent as audited (updates timestamp in audit_state.json). " +
2750
- "Call this after finishing each agent in the audit queue.", {
2751
- agent_name: z.string().describe("Template name of the agent that was audited, e.g. 'THOR'"),
2752
- }, async ({ agent_name }) => {
2753
- if (!isCurator()) {
2754
- return {
2755
- content: [{ type: "text", text: "ERROR: mark_audited is only available to the ceo/curator role." }],
2756
- isError: true,
2757
- };
2758
- }
2759
- validateAgentName(agent_name);
2760
- const state = loadAuditState();
2761
- state[agent_name] = new Date().toISOString();
2762
- saveAuditState(state);
2763
- log(`mark_audited [CURATOR]: ${agent_name}`);
2764
- return {
2765
- content: [{ type: "text", text: `Marked as audited: ${agent_name} (${state[agent_name].substring(0, 16)})` }],
2766
- };
2767
- });
2768
2482
  // ---- Output Formatting ----
2769
2483
  /**
2770
2484
  * Format bulk-read output grouped by prefix with header entries.