hmem-mcp 6.3.1 → 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);
@@ -776,10 +820,10 @@ server.tool("write_memory", "Write a new memory entry to your hierarchical long-
776
820
  "\tFrontend architecture\n\n" +
777
821
  "\tReact + Vite, ShadcnUI components, SSE for real-time updates\n" +
778
822
  "\t\tAuth was tricky — EventSource can't send custom headers"),
779
- 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']"),
780
824
  favorite: z.coerce.boolean().optional().describe("Mark this entry as a favorite — shown with [♥] in bulk reads and always inlined with L2 detail. " +
781
825
  "Use for reference info you need to see every session, regardless of category."),
782
- 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+). " +
783
827
  "E.g. ['#hmem', '#curation']. Max 10, lowercase, must start with #. Shown after title in reads."),
784
828
  pinned: z.coerce.boolean().optional().describe("Mark this entry as pinned [P] (super-favorite). Pinned entries show full L2 content in bulk reads. " +
785
829
  "Use for reference entries you need to see in full every session."),
@@ -795,26 +839,31 @@ server.tool("write_memory", "Write a new memory entry to your hierarchical long-
795
839
  isError: true,
796
840
  };
797
841
  }
798
- // P-prefix: validate L2 structure against standard schema
799
- if (prefix.toUpperCase() === "P") {
800
- const VALID_L2_CATEGORIES = [
801
- "overview", "codebase", "usage", "context", "deployment",
802
- "bugs", "protocol", "open tasks", "ideas",
803
- ];
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());
804
847
  const lines = content.split("\n");
805
- 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());
806
856
  if (l2Lines.length > 0) {
807
857
  const invalid = l2Lines.filter(l => {
808
858
  const firstWord = l.split(/\s*[—\-:]/)[0].trim();
809
- return !VALID_L2_CATEGORIES.some(cat => firstWord.startsWith(cat));
859
+ return !sectionNames.some(sec => firstWord.startsWith(sec));
810
860
  });
811
861
  if (invalid.length > 0) {
812
862
  return {
813
- content: [{ type: "text", text: `WARNING: P-entry L2 nodes must use standard categories.\n` +
814
- `Valid: ${VALID_L2_CATEGORIES.join(", ")}\n` +
815
- `Invalid L2 nodes found: ${invalid.map(l => `"${l.substring(0, 50)}"`).join(", ")}\n\n` +
816
- `See R0009 (P-Entry Standard Schema) for the full specification.\n` +
817
- `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.` }],
818
867
  isError: true,
819
868
  };
820
869
  }
@@ -878,6 +927,13 @@ server.tool("write_memory", "Write a new memory entry to your hierarchical long-
878
927
  }
879
928
  }
880
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
+ }
881
937
  return {
882
938
  content: [{ type: "text", text: `ERROR: ${safeError(e)}` }],
883
939
  isError: true,
@@ -898,17 +954,17 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
898
954
  "- Mark as irrelevant: update_memory(id='L0042', content='...', irrelevant=true)\n" +
899
955
  " No correction entry needed (unlike obsolete). Hidden from bulk reads.\n\n" +
900
956
  "To add new child nodes, use append_memory. " +
901
- "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.", {
902
958
  id: z.string().describe("ID of the entry or node to update, e.g. 'L0003' or 'L0003.2'"),
903
959
  content: z.string().min(1).describe("New text content for this node (plain text, no indentation)"),
904
- 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."),
905
961
  obsolete: z.coerce.boolean().optional().describe("Mark this root entry as no longer valid (root entries only). " +
906
962
  "Requires [✓ID] correction reference in content (e.g. 'Wrong — see [✓E0076]')."),
907
963
  favorite: z.coerce.boolean().optional().describe("Set or clear the [♥] favorite flag. Works on root entries and sub-nodes. " +
908
964
  "Root favorites are always shown with L2 detail in bulk reads."),
909
965
  irrelevant: z.coerce.boolean().optional().describe("Mark as irrelevant [-]. Works on root entries and sub-nodes. " +
910
966
  "No correction entry needed (unlike obsolete). Irrelevant entries/nodes are hidden from output."),
911
- 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. " +
912
968
  "Pass empty array [] to remove all tags. E.g. ['#hmem', '#curation']."),
913
969
  pinned: z.coerce.boolean().optional().describe("Set or clear the [P] pinned flag (root entries only). " +
914
970
  "Pinned entries show full L2 content in bulk reads (super-favorite)."),
@@ -916,11 +972,12 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
916
972
  "When any entry in a prefix has active=true, only active entries of that prefix are shown with children in bulk reads. " +
917
973
  "Non-active entries in the same prefix are shown as title-only (no children)."),
918
974
  store: z.enum(["personal", "company"]).default("personal").describe("Target store: 'personal' or 'company'"),
919
- }, 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 }) => {
920
978
  try {
921
- const hmemStore = storeName === "company"
922
- ? openCompanyMemory(PROJECT_DIR, hmemConfig)
923
- : new HmemStore(HMEM_PATH, hmemConfig);
979
+ const { store: hmemStore, label: storeLabelResolved } = resolveStore(storeName, hmem_path);
980
+ const isExternal = !!hmem_path;
924
981
  try {
925
982
  if (hmemStore.corrupted) {
926
983
  return {
@@ -928,7 +985,7 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
928
985
  isError: true,
929
986
  };
930
987
  }
931
- if (storeName === "personal")
988
+ if (storeName === "personal" && !isExternal)
932
989
  syncPullThenPush(HMEM_PATH);
933
990
  // Cross-project write notice: if updating a P-sub-node of a project that isn't currently
934
991
  // active, do NOT auto-switch. The agent may be doing a quick cross-project edit (e.g.
@@ -936,7 +993,7 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
936
993
  // response so the agent can decide whether to load_project() and switch context.
937
994
  const rootId = id.includes(".") ? id.split(".")[0] : id;
938
995
  let crossProjectNotice = "";
939
- if (rootId.startsWith("P") && storeName === "personal") {
996
+ if (rootId.startsWith("P") && storeName === "personal" && !isExternal) {
940
997
  const current = hmemStore.getActiveProject(currentSessionId());
941
998
  if (!current || current.id !== rootId) {
942
999
  crossProjectNotice = `\n\nNotice: ${rootId} is not the currently active project${current ? ` (active: ${current.id})` : ""}. ` +
@@ -952,7 +1009,7 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
952
1009
  }
953
1010
  }
954
1011
  const ok = hmemStore.updateNode(id, content, links, obsolete, favorite, undefined, irrelevant, tags, pinned, active);
955
- const storeLabel = storeName === "company" ? "company" : path.basename(HMEM_PATH, ".hmem");
1012
+ const storeLabel = storeLabelResolved;
956
1013
  log(`update_memory [${storeLabel}]: ${id} → ${ok ? "updated" : "not found"}${obsolete ? " (marked obsolete)" : ""}${irrelevant ? " (marked irrelevant)" : ""}${favorite !== undefined ? ` (favorite=${favorite})` : ""}${active !== undefined ? ` (active=${active})` : ""}`);
957
1014
  if (!ok) {
958
1015
  return {
@@ -983,7 +1040,7 @@ server.tool("update_memory", "Update the text of an existing memory entry or sub
983
1040
  parts.push("active flag cleared");
984
1041
  if (tags !== undefined)
985
1042
  parts.push(tags.length > 0 ? `tags: ${tags.join(" ")}` : "tags cleared");
986
- if (storeName === "personal") {
1043
+ if (storeName === "personal" && !isExternal) {
987
1044
  const retry = syncPushWithRetry(HMEM_PATH);
988
1045
  if (!retry.resolved) {
989
1046
  parts.push(`⚠ unresolved push conflicts after ${retry.attempts} attempts`);
@@ -1068,8 +1125,8 @@ server.tool("flush_context", "Store a conversation chunk as linear context histo
1068
1125
  l3: z.string().optional().describe("Detailed summary (~500 words). Only if L2 is too compressed."),
1069
1126
  l4: z.string().optional().describe("Extended context (~2000 words). Rarely needed."),
1070
1127
  l5: z.string().optional().describe("Raw conversation chunk. Full text, no summarization."),
1071
- tags: z.array(z.string()).min(1).describe("Required hashtags for discovery. E.g. ['#hmem', '#context-for', '#ux']"),
1072
- 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']"),
1073
1130
  }, async ({ l1, l2, l3, l4, l5, tags, links }) => {
1074
1131
  try {
1075
1132
  const hmemStore = new HmemStore(HMEM_PATH, hmemConfig);
@@ -1116,6 +1173,22 @@ server.tool("append_memory", "Append new child nodes to an existing memory entry
1116
1173
  "Example: 'New point\\n\\tSub-detail'"),
1117
1174
  store: z.enum(["personal", "company"]).default("personal").describe("Target store: 'personal' or 'company'"),
1118
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
+ }
1119
1192
  try {
1120
1193
  const hmemStore = storeName === "company"
1121
1194
  ? openCompanyMemory(PROJECT_DIR, hmemConfig)
@@ -1234,13 +1307,14 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1234
1307
  "Example: read_memory({ context_for: 'P0029' }) — loads P0029 + all contextually related entries."),
1235
1308
  min_tag_score: z.number().optional().describe("Minimum weighted tag score for context_for matches (default: 5). " +
1236
1309
  "Score 4 = e.g. 2 medium tags, or 1 rare + 1 common. Lower = more results, higher = stricter."),
1237
- }, 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 }) => {
1238
1313
  // Pull before read to get latest from server (30s cooldown)
1239
- const newEntries = storeName === "personal" ? syncPull(HMEM_PATH) : [];
1314
+ const newEntries = storeName === "personal" && !hmem_path ? syncPull(HMEM_PATH) : [];
1240
1315
  try {
1241
- const hmemStore = storeName === "company"
1242
- ? openCompanyMemory(PROJECT_DIR, hmemConfig)
1243
- : new HmemStore(HMEM_PATH, hmemConfig);
1316
+ const { store: hmemStore, label: storeLabelResolved, path: resolvedPath } = resolveStore(storeName, hmem_path);
1317
+ const isExternal = !!hmem_path;
1244
1318
  try {
1245
1319
  const corruptionWarning = hmemStore.corrupted
1246
1320
  ? "⚠ WARNING: Memory database is corrupted! Reads may be incomplete. A backup (.corrupt) was saved.\n\n"
@@ -1299,7 +1373,7 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1299
1373
  }
1300
1374
  }
1301
1375
  }
1302
- const storeLabel = storeName === "company" ? "company" : path.basename(HMEM_PATH, ".hmem");
1376
+ const storeLabel = storeLabelResolved;
1303
1377
  const output = lines.join("\n");
1304
1378
  // Add token estimate to header line (2nd line)
1305
1379
  const fmtTok = (n) => n < 1000 ? String(n) : n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`;
@@ -1314,7 +1388,7 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1314
1388
  // Session cache: cached entries shown as titles in subsequent bulk reads
1315
1389
  // Explicit filters (after, before, prefix, stale_days, tag) bypass V2 selection + cache
1316
1390
  const isBulkListing = !id && !search && !time_around && !after && !before && !prefix && !stale_days && !tag;
1317
- const useCache = isBulkListing && storeName === "personal" && !show_all;
1391
+ const useCache = isBulkListing && storeName === "personal" && !show_all && !isExternal;
1318
1392
  const cachedIds = useCache ? sessionCache.getCachedIds() : undefined;
1319
1393
  const hiddenIds = useCache ? sessionCache.getHiddenIds() : undefined;
1320
1394
  const slotFraction = useCache ? sessionCache.getSlotFraction() : undefined;
@@ -1338,11 +1412,9 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1338
1412
  directResults: !isBulkListing && !id && !search && !time_around,
1339
1413
  });
1340
1414
  if (entries.length === 0) {
1341
- const hmemPath = storeName === "company"
1342
- ? path.join(PROJECT_DIR, "company.hmem")
1343
- : HMEM_PATH;
1415
+ const hmemPath = resolvedPath;
1344
1416
  const dbExists = fs.existsSync(hmemPath);
1345
- const label = storeName === "company" ? "company" : path.basename(HMEM_PATH, ".hmem");
1417
+ const label = storeLabelResolved;
1346
1418
  const storeInfo = `\nStore: ${label} | DB: ${hmemPath}${dbExists ? "" : " [FILE NOT FOUND]"}`;
1347
1419
  // Sync hint: if memory is empty and hmem-sync is not configured, suggest it
1348
1420
  let syncHint = "";
@@ -1374,7 +1446,7 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1374
1446
  ? formatGroupedOutput(hmemStore, entries, curator ?? false, hmemConfig)
1375
1447
  : formatFlatOutput(entries, curator ?? false, expand ?? false);
1376
1448
  const stats = hmemStore.stats();
1377
- const storeLabel = storeName === "company" ? "company" : path.basename(HMEM_PATH, ".hmem");
1449
+ const storeLabel = storeLabelResolved;
1378
1450
  const visibleCount = entries.length;
1379
1451
  // Cache status in header (when active)
1380
1452
  const hiddenCount = hiddenIds?.size ?? 0;
@@ -1446,8 +1518,8 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1446
1518
  : " (no projects yet — create one with write_memory(prefix=\"P\", content=\"Name | Status | Stack | Description\", tags=[...]))";
1447
1519
  // Inject recent O-entries even without active project (global, no project filter)
1448
1520
  let recentOHint = "";
1449
- if (hmemConfig.recentOEntries > 0) {
1450
- const { text, ids } = formatRecentOEntries(hmemStore, hmemConfig.recentOEntries, 10);
1521
+ if (hmemConfig.bulkReadOEntries > 0) {
1522
+ const { text, ids } = formatRecentOEntries(hmemStore, hmemConfig.bulkReadOEntries, 10);
1451
1523
  if (text) {
1452
1524
  recentOHint = `\n${text}\n`;
1453
1525
  sessionCache.registerDelivered(ids);
@@ -1470,10 +1542,10 @@ server.tool("read_memory", "Read from your hierarchical long-term memory (.hmem)
1470
1542
  }
1471
1543
  // Inject recent O-entries (session logs) on bulk reads when none are cached
1472
1544
  let recentOSection = "";
1473
- if (isBulkListing && storeName === "personal" && hmemConfig.recentOEntries > 0) {
1545
+ if (isBulkListing && storeName === "personal" && !isExternal && hmemConfig.bulkReadOEntries > 0) {
1474
1546
  const cachedOIds = [...(cachedIds || []), ...(hiddenIds || [])].filter(id => id.startsWith("O"));
1475
1547
  if (cachedOIds.length === 0) {
1476
- const { text, ids } = formatRecentOEntries(hmemStore, hmemConfig.recentOEntries, 10);
1548
+ const { text, ids } = formatRecentOEntries(hmemStore, hmemConfig.bulkReadOEntries, 10);
1477
1549
  if (text) {
1478
1550
  recentOSection = `\n${text}\n`;
1479
1551
  sessionCache.registerDelivered(ids);
@@ -1673,11 +1745,10 @@ server.tool("find_related", "Find entries related to the given entry. " +
1673
1745
  id: z.string().describe("Root entry ID to find related entries for, e.g. 'P0001'"),
1674
1746
  limit: z.number().min(1).max(20).default(5).describe("Max results to return (default: 5)"),
1675
1747
  store: z.enum(["personal", "company"]).default("personal"),
1676
- }, 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 }) => {
1677
1750
  try {
1678
- const hmemStore = storeName === "company"
1679
- ? openCompanyMemory(PROJECT_DIR, hmemConfig)
1680
- : new HmemStore(HMEM_PATH, hmemConfig);
1751
+ const { store: hmemStore } = resolveStore(storeName, hmem_path);
1681
1752
  try {
1682
1753
  const results = hmemStore.findRelatedCombined(id, limit);
1683
1754
  if (results.length === 0) {
@@ -1699,39 +1770,6 @@ server.tool("find_related", "Find entries related to the given entry. " +
1699
1770
  return { content: [{ type: "text", text: `ERROR: ${safeError(e)}` }], isError: true };
1700
1771
  }
1701
1772
  });
1702
- server.tool("route_task", "[DEPRECATED: route_task requires the legacy Agents/ directory structure. Future versions will use config-based agent discovery.]\n\n" +
1703
- "Multi-agent only: find the best agent for a task based on memory content. " +
1704
- "Scans all agent .hmem files in the Agents/ directory and scores them against tags + keywords. " +
1705
- "Only useful in multi-agent setups (Heimdall, Das Althing) — single-agent users should ignore this tool.\n\n" +
1706
- "Example: route_task(tags=['#backend', '#sqlite'], keywords='connection pooling bug')\n" +
1707
- "Returns agents ranked by memory relevance with their top matching entries.", {
1708
- tags: z.array(z.string()).min(1).describe("Tags to match against agent memories. E.g. ['#backend', '#sqlite', '#bug']"),
1709
- keywords: z.string().optional().describe("Free-text keywords for FTS5 search supplement. E.g. 'connection pooling timeout'"),
1710
- limit: z.number().min(1).max(20).default(5).describe("Max agents to return (default: 5)"),
1711
- }, async ({ tags, keywords, limit: maxResults }) => {
1712
- try {
1713
- const results = routeTask(PROJECT_DIR, tags, keywords, maxResults, hmemConfig);
1714
- if (results.length <= 1) {
1715
- return {
1716
- content: [{ type: "text", text: results.length === 0
1717
- ? "No agents found. route_task requires a multi-agent setup with Agents/*/*.hmem files."
1718
- : `Only one agent found (${results[0].agent}). route_task is designed for multi-agent setups.` }],
1719
- };
1720
- }
1721
- const lines = [`## Agent Routing (${results.length} matches)\n`];
1722
- for (const r of results) {
1723
- lines.push(`**${r.agent}** — score: ${r.score} (${r.entryCount} matching entries)`);
1724
- for (const e of r.topEntries) {
1725
- lines.push(` ${e.id} (${e.score}) ${e.title}`);
1726
- }
1727
- lines.push("");
1728
- }
1729
- return { content: [{ type: "text", text: lines.join("\n") }] };
1730
- }
1731
- catch (e) {
1732
- return { content: [{ type: "text", text: `ERROR: ${safeError(e)}` }], isError: true };
1733
- }
1734
- });
1735
1773
  /** Strip body (after \n>) and newlines from titles for compact display */
1736
1774
  function cleanTitle(t, max = 0) {
1737
1775
  // Split at body separator — real newline+> or literal \n>
@@ -2065,8 +2103,8 @@ server.tool("create_project", "Create a new project with the standard R0009 sche
2065
2103
  goal: z.string().optional().describe("Main project goal (1-2 sentences)"),
2066
2104
  audience: z.string().optional().describe("Target audience / who uses it"),
2067
2105
  deployment: z.string().optional().describe("How it's deployed (npm, exe, server, manual)"),
2068
- tags: z.array(z.string()).optional().describe("Additional tags beyond #project (auto-added)"),
2069
- 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']"),
2070
2108
  store: z.enum(["personal", "company"]).default("personal"),
2071
2109
  }, async ({ name, tech, description, status, repo, goal, audience, deployment, tags, links, store: storeName }) => {
2072
2110
  try {
@@ -2194,14 +2232,13 @@ server.tool("memory_health", "Audit report for your memory: broken links (links
2194
2232
  "and tag orphans (tags with no matching entry). " +
2195
2233
  "Run before/after a curation session.", {
2196
2234
  store: z.enum(["personal", "company"]).default("personal"),
2197
- }, 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 }) => {
2198
2237
  try {
2199
- const hmemStore = storeName === "company"
2200
- ? openCompanyMemory(PROJECT_DIR, hmemConfig)
2201
- : new HmemStore(HMEM_PATH, hmemConfig);
2238
+ const { store: hmemStore, label: storeLabelResolved } = resolveStore(storeName, hmem_path);
2202
2239
  try {
2203
2240
  const h = hmemStore.healthCheck();
2204
- const lines = [`Memory health report (${storeName}):`];
2241
+ const lines = [`Memory health report (${storeLabelResolved}):`];
2205
2242
  const ok = (label) => lines.push(` ✓ ${label}`);
2206
2243
  const warn = (label) => lines.push(` ⚠ ${label}`);
2207
2244
  if (h.brokenLinks.length === 0) {
@@ -2442,389 +2479,6 @@ server.tool("move_nodes", "Move session (L2), batch (L3), or exchange (L4) nodes
2442
2479
  return { content: [{ type: "text", text: `ERROR: ${safeError(e)}` }], isError: true };
2443
2480
  }
2444
2481
  });
2445
- // ---- Tool: reorder_sessions ----
2446
- 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.", {
2447
- o_id: z.string().describe("O-entry ID whose L2 sessions should be reordered, e.g. 'O0048'"),
2448
- store: z.enum(["personal", "company"]).default("personal").describe("Which store to operate on"),
2449
- }, async ({ o_id, store }) => {
2450
- try {
2451
- const hmemStore = store === "company"
2452
- ? openCompanyMemory(PROJECT_DIR, hmemConfig)
2453
- : new HmemStore(HMEM_PATH, hmemConfig);
2454
- try {
2455
- if (store === "personal")
2456
- syncPullThenPush(HMEM_PATH);
2457
- const renamed = hmemStore.reorderSessionsByDate(o_id);
2458
- let text = renamed === 0
2459
- ? `${o_id}: sessions already in chronological order (no changes).`
2460
- : `${o_id}: reordered ${renamed} session(s) by created_at.`;
2461
- if (store === "personal") {
2462
- const retry = syncPushWithRetry(HMEM_PATH);
2463
- if (!retry.resolved)
2464
- text += `\n⚠ unresolved push conflicts after ${retry.attempts} attempts`;
2465
- else if (retry.attempts > 1)
2466
- text += `\n(resolved push conflict after ${retry.attempts} attempts)`;
2467
- }
2468
- return { content: [{ type: "text", text }] };
2469
- }
2470
- finally {
2471
- hmemStore.close();
2472
- }
2473
- }
2474
- catch (e) {
2475
- return { content: [{ type: "text", text: `ERROR: ${safeError(e)}` }], isError: true };
2476
- }
2477
- });
2478
- // ---- Curator Tools (ceo role only) ----
2479
- const AUDIT_STATE_FILE = process.env.HMEM_AUDIT_STATE_PATH
2480
- || path.join(PROJECT_DIR, "audit_state.json");
2481
- function loadAuditState() {
2482
- try {
2483
- if (fs.existsSync(AUDIT_STATE_FILE)) {
2484
- return JSON.parse(fs.readFileSync(AUDIT_STATE_FILE, "utf-8"));
2485
- }
2486
- }
2487
- catch { /* ignore */ }
2488
- return {};
2489
- }
2490
- function saveAuditState(state) {
2491
- const dir = path.dirname(AUDIT_STATE_FILE);
2492
- if (!fs.existsSync(dir))
2493
- fs.mkdirSync(dir, { recursive: true });
2494
- const tmp = AUDIT_STATE_FILE + ".tmp";
2495
- fs.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
2496
- fs.renameSync(tmp, AUDIT_STATE_FILE);
2497
- }
2498
- function isCurator() {
2499
- return process.env.HMEM_AGENT_ROLE === "ceo";
2500
- }
2501
- server.tool("get_audit_queue", "CURATOR ONLY (ceo role). Returns agents whose .hmem has changed since last audit. " +
2502
- "Use this at the start of each curation run to get the list of agents to process. " +
2503
- "Each agent should be audited in a separate spawn to keep context bounded.", {}, async () => {
2504
- if (!isCurator()) {
2505
- return {
2506
- 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." }],
2507
- isError: true,
2508
- };
2509
- }
2510
- const auditState = loadAuditState();
2511
- // Scan for .hmem files in PROJECT_DIR and subdirectories (1 level deep)
2512
- const queue = [];
2513
- // Check common agent directory patterns
2514
- for (const subdir of ["Agents", "Assistenten", "agents", "."]) {
2515
- const dir = path.join(PROJECT_DIR, subdir);
2516
- if (!fs.existsSync(dir))
2517
- continue;
2518
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2519
- if (!entry.isDirectory())
2520
- continue;
2521
- const name = entry.name;
2522
- const hmemPath = path.join(dir, name, `${name}.hmem`);
2523
- if (!fs.existsSync(hmemPath))
2524
- continue;
2525
- const stat = fs.statSync(hmemPath);
2526
- const modified = stat.mtime.toISOString();
2527
- const lastAudit = auditState[name] || null;
2528
- if (!lastAudit || new Date(modified) > new Date(lastAudit)) {
2529
- queue.push({ name, hmemPath, modified, lastAudit });
2530
- }
2531
- }
2532
- }
2533
- // Also check for standalone memory.hmem in PROJECT_DIR
2534
- const defaultHmem = path.join(PROJECT_DIR, "memory.hmem");
2535
- if (fs.existsSync(defaultHmem)) {
2536
- const stat = fs.statSync(defaultHmem);
2537
- const modified = stat.mtime.toISOString();
2538
- const lastAudit = auditState["default"] || null;
2539
- if (!lastAudit || new Date(modified) > new Date(lastAudit)) {
2540
- queue.push({ name: "default", hmemPath: defaultHmem, modified, lastAudit });
2541
- }
2542
- }
2543
- if (queue.length === 0) {
2544
- return {
2545
- content: [{ type: "text", text: "Audit queue is empty — all agent memories are up to date." }],
2546
- };
2547
- }
2548
- const lines = queue.map(a => `- **${a.name}**: modified ${a.modified.substring(0, 16)}` +
2549
- (a.lastAudit ? ` | last audited ${a.lastAudit.substring(0, 16)}` : " | never audited"));
2550
- return {
2551
- content: [{
2552
- type: "text",
2553
- text: `## Audit Queue (${queue.length} agents to check)\n\n${lines.join("\n")}\n\n` +
2554
- `Process one agent per spawn: terminate after each to keep context bounded.`,
2555
- }],
2556
- };
2557
- });
2558
- server.tool("read_agent_memory", "CURATOR ONLY (ceo role). Read the full memory of any agent (for audit purposes). " +
2559
- "Returns all entries at the specified depth. Use depth=3 for a thorough audit.", {
2560
- agent_name: z.string().describe("Template name of the agent, e.g. 'THOR', 'SIGURD'"),
2561
- depth: z.number().int().min(1).max(5).optional().describe("Depth to read (1-5, default: 3)"),
2562
- }, async ({ agent_name, depth }) => {
2563
- if (!isCurator()) {
2564
- return {
2565
- content: [{ type: "text", text: "ERROR: read_agent_memory is only available to the ceo/curator role." }],
2566
- isError: true,
2567
- };
2568
- }
2569
- const hmemPath = resolveHmemPathLegacy(PROJECT_DIR, validateAgentName(agent_name));
2570
- if (!fs.existsSync(hmemPath)) {
2571
- return {
2572
- content: [{ type: "text", text: `No .hmem found for agent "${agent_name}" (expected: ${hmemPath}).` }],
2573
- };
2574
- }
2575
- const store = new HmemStore(hmemPath, hmemConfig);
2576
- try {
2577
- const entries = store.read({ depth: depth || 3, limit: 500 });
2578
- const stats = store.stats();
2579
- if (entries.length === 0) {
2580
- return { content: [{ type: "text", text: `Agent "${agent_name}" has no memory entries.` }] };
2581
- }
2582
- const lines = [`## Memory: ${agent_name} (${stats.total} entries, depth=${depth || 3})\n`];
2583
- for (const e of entries) {
2584
- const date = e.created_at.substring(0, 10);
2585
- const access = e.access_count > 0 ? ` (${e.access_count}x)` : "";
2586
- const obsoleteTag = e.obsolete ? " [⚠ OBSOLETE]" : "";
2587
- const irrelevantTag = e.irrelevant ? " [- IRRELEVANT]" : "";
2588
- const favTag = e.favorite ? " [♥]" : "";
2589
- lines.push(`[${e.id}] ${date}${favTag}${obsoleteTag}${irrelevantTag}${access}`);
2590
- lines.push(` ${e.title}`);
2591
- if (e.level_1 && e.level_1 !== e.title) {
2592
- for (const bodyLine of e.level_1.split("\n")) {
2593
- lines.push(` ${bodyLine}`);
2594
- }
2595
- }
2596
- if (e.children && e.children.length > 0) {
2597
- for (const child of e.children) {
2598
- const indent = " ".repeat(child.depth - 1);
2599
- const hint = (child.child_count ?? 0) > 0
2600
- ? ` (${child.child_count} — use id="${child.id}" to expand)`
2601
- : "";
2602
- lines.push(`${indent}[${child.id}] ${child.title}${hint}`);
2603
- }
2604
- }
2605
- if (e.links?.length)
2606
- lines.push(` Links: ${e.links.join(", ")}`);
2607
- lines.push("");
2608
- }
2609
- log(`read_agent_memory [CURATOR]: ${agent_name} depth=${depth || 3} → ${entries.length} entries`);
2610
- return { content: [{ type: "text", text: lines.join("\n") }] };
2611
- }
2612
- finally {
2613
- store.close();
2614
- }
2615
- });
2616
- server.tool("fix_agent_memory", "CURATOR ONLY (ceo role). Correct a specific entry or node in any agent's memory.\n\n" +
2617
- "Accepts both root IDs ('L0003') and compound node IDs ('L0003.2'):\n" +
2618
- "- Root ID: updates L1 summary text, obsolete/irrelevant/favorite flags\n" +
2619
- "- Compound node ID: updates the content of that specific node\n\n" +
2620
- "To fix wrong prefix: delete + re-add (prefix cannot be changed in-place).\n" +
2621
- "To consolidate fragmented P entries: use read_agent_memory to read them, " +
2622
- "fix_agent_memory to update the keeper entry, delete_agent_memory to remove duplicates.", {
2623
- agent_name: z.string().describe("Template name of the agent, e.g. 'THOR'"),
2624
- entry_id: z.string().describe("Root entry ID ('L0003') or compound node ID ('L0003.2'). " +
2625
- "Node IDs update memory_nodes.content directly."),
2626
- content: z.string().optional().describe("New text content. For root entries: replaces the L1 summary. " +
2627
- "For node IDs: replaces that node's content."),
2628
- obsolete: z.coerce.boolean().optional().describe("Mark or unmark as obsolete (root entries only). " +
2629
- "Obsolete entries stay in memory but are shown with [⚠ OBSOLETE]."),
2630
- favorite: z.coerce.boolean().optional().describe("Set or clear the [♥] favorite flag (root entries only)."),
2631
- 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."),
2632
- }, async ({ agent_name, entry_id, content, obsolete, favorite, irrelevant }) => {
2633
- if (!isCurator()) {
2634
- return {
2635
- content: [{ type: "text", text: "ERROR: fix_agent_memory is only available to the ceo/curator role." }],
2636
- isError: true,
2637
- };
2638
- }
2639
- const hmemPath = resolveHmemPathLegacy(PROJECT_DIR, validateAgentName(agent_name));
2640
- if (!fs.existsSync(hmemPath)) {
2641
- return {
2642
- content: [{ type: "text", text: `No .hmem found for agent "${agent_name}".` }],
2643
- isError: true,
2644
- };
2645
- }
2646
- const store = new HmemStore(hmemPath, hmemConfig);
2647
- try {
2648
- const isNode = entry_id.includes(".");
2649
- let ok = false;
2650
- const changed = [];
2651
- if (isNode) {
2652
- // Compound node ID — update memory_nodes.content
2653
- if (!content) {
2654
- return {
2655
- content: [{ type: "text", text: "ERROR: 'content' is required when fixing a compound node ID." }],
2656
- isError: true,
2657
- };
2658
- }
2659
- ok = store.updateNode(entry_id, content);
2660
- if (ok)
2661
- changed.push("content");
2662
- }
2663
- else {
2664
- // Root entry — update memories table
2665
- if (!content && obsolete === undefined && favorite === undefined && irrelevant === undefined) {
2666
- return {
2667
- content: [{ type: "text", text: "ERROR: Provide at least one of: content, obsolete, favorite, irrelevant." }],
2668
- isError: true,
2669
- };
2670
- }
2671
- if (content) {
2672
- ok = store.updateNode(entry_id, content, undefined, obsolete, favorite, true /* curatorBypass */, irrelevant);
2673
- changed.push("L1");
2674
- if (obsolete !== undefined)
2675
- changed.push("obsolete");
2676
- if (favorite !== undefined)
2677
- changed.push("favorite");
2678
- if (irrelevant !== undefined)
2679
- changed.push("irrelevant");
2680
- }
2681
- else {
2682
- const fields = {};
2683
- if (obsolete !== undefined)
2684
- fields.obsolete = obsolete;
2685
- if (favorite !== undefined)
2686
- fields.favorite = favorite;
2687
- if (irrelevant !== undefined)
2688
- fields.irrelevant = irrelevant;
2689
- ok = store.update(entry_id, fields);
2690
- }
2691
- if (!content && obsolete !== undefined)
2692
- changed.push("obsolete");
2693
- if (!content && favorite !== undefined)
2694
- changed.push("favorite");
2695
- if (!content && irrelevant !== undefined)
2696
- changed.push("irrelevant");
2697
- }
2698
- log(`fix_agent_memory [CURATOR]: ${agent_name} ${entry_id} → ${ok ? "updated" : "not found"} (${changed.join(", ")})`);
2699
- return {
2700
- content: [{
2701
- type: "text",
2702
- text: ok
2703
- ? `Fixed: ${agent_name}/${entry_id} (${changed.join(", ")})`
2704
- : `ERROR: Entry "${entry_id}" not found in ${agent_name}'s memory.`,
2705
- }],
2706
- isError: !ok,
2707
- };
2708
- }
2709
- finally {
2710
- store.close();
2711
- }
2712
- });
2713
- server.tool("append_agent_memory", "CURATOR ONLY (ceo role). Append new child nodes to an existing entry in any agent's memory. " +
2714
- "Use exclusively for merging/consolidating entries — e.g. when collapsing two P entries into one, " +
2715
- "carry over the best content from the entry being deleted into the keeper before deleting.\n\n" +
2716
- "Content is tab-indented relative to the parent (same as append_memory):\n" +
2717
- " 0 tabs = direct child of id\n" +
2718
- " 1 tab = grandchild, etc.", {
2719
- agent_name: z.string().describe("Template name of the agent, e.g. 'THOR'"),
2720
- id: z.string().describe("Root entry ID or parent node ID to append children to, e.g. 'P0004' or 'P0004.2'"),
2721
- content: z.string().min(1).describe("Tab-indented content to append. 0 tabs = direct child of id."),
2722
- }, async ({ agent_name, id, content }) => {
2723
- if (!isCurator()) {
2724
- return {
2725
- content: [{ type: "text", text: "ERROR: append_agent_memory is only available to the ceo/curator role." }],
2726
- isError: true,
2727
- };
2728
- }
2729
- const hmemPath = resolveHmemPathLegacy(PROJECT_DIR, validateAgentName(agent_name));
2730
- if (!fs.existsSync(hmemPath)) {
2731
- return {
2732
- content: [{ type: "text", text: `No .hmem found for agent "${agent_name}".` }],
2733
- isError: true,
2734
- };
2735
- }
2736
- const store = new HmemStore(hmemPath, hmemConfig);
2737
- try {
2738
- const result = store.appendChildren(id, content);
2739
- log(`append_agent_memory [CURATOR]: ${agent_name} ${id} + ${result.count} nodes → [${result.ids.join(", ")}]`);
2740
- if (result.count === 0) {
2741
- return {
2742
- content: [{ type: "text", text: "No nodes appended — content was empty or contained no valid lines." }],
2743
- };
2744
- }
2745
- return {
2746
- content: [{
2747
- type: "text",
2748
- text: `Appended ${result.count} node${result.count === 1 ? "" : "s"} to ${agent_name}/${id}.\n` +
2749
- `New top-level children: ${result.ids.join(", ")}`,
2750
- }],
2751
- };
2752
- }
2753
- catch (e) {
2754
- return {
2755
- content: [{ type: "text", text: `ERROR: ${safeError(e)}` }],
2756
- isError: true,
2757
- };
2758
- }
2759
- finally {
2760
- store.close();
2761
- }
2762
- });
2763
- server.tool("delete_agent_memory", "Delete an entry from an agent's memory. " +
2764
- "Own entries: always allowed. Other agents: curator/ceo role required. " +
2765
- "Use sparingly — only for exact duplicates or entries that are factually wrong and cannot be fixed.", {
2766
- agent_name: z.string().describe("Template name of the agent, e.g. 'THOR'"),
2767
- entry_id: z.string().describe("Entry ID to delete, e.g. 'E0007'"),
2768
- }, async ({ agent_name, entry_id }) => {
2769
- validateAgentName(agent_name);
2770
- const hmemPath = resolveHmemPathLegacy(PROJECT_DIR, agent_name);
2771
- if (!fs.existsSync(hmemPath)) {
2772
- return {
2773
- content: [{ type: "text", text: `No .hmem found for agent "${agent_name}".` }],
2774
- isError: true,
2775
- };
2776
- }
2777
- const isOwnMemory = hmemPath === HMEM_PATH;
2778
- // Curator can delete any agent's entries; non-curators can only delete their own
2779
- if (!isOwnMemory && !isCurator()) {
2780
- return {
2781
- 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." }],
2782
- isError: true,
2783
- };
2784
- }
2785
- if (!fs.existsSync(hmemPath)) {
2786
- return {
2787
- content: [{ type: "text", text: `No .hmem found for agent "${agent_name}".` }],
2788
- isError: true,
2789
- };
2790
- }
2791
- const store = new HmemStore(hmemPath, hmemConfig);
2792
- try {
2793
- const ok = store.delete(entry_id);
2794
- log(`delete_agent_memory [${isOwnMemory ? "SELF" : "CURATOR"}]: ${agent_name} ${entry_id} → ${ok ? "deleted" : "not found"}`);
2795
- return {
2796
- content: [{
2797
- type: "text",
2798
- text: ok
2799
- ? `Deleted: ${agent_name}/${entry_id}`
2800
- : `ERROR: Entry "${entry_id}" not found in ${agent_name}'s memory.`,
2801
- }],
2802
- isError: !ok,
2803
- };
2804
- }
2805
- finally {
2806
- store.close();
2807
- }
2808
- });
2809
- server.tool("mark_audited", "CURATOR ONLY (ceo role). Mark an agent as audited (updates timestamp in audit_state.json). " +
2810
- "Call this after finishing each agent in the audit queue.", {
2811
- agent_name: z.string().describe("Template name of the agent that was audited, e.g. 'THOR'"),
2812
- }, async ({ agent_name }) => {
2813
- if (!isCurator()) {
2814
- return {
2815
- content: [{ type: "text", text: "ERROR: mark_audited is only available to the ceo/curator role." }],
2816
- isError: true,
2817
- };
2818
- }
2819
- validateAgentName(agent_name);
2820
- const state = loadAuditState();
2821
- state[agent_name] = new Date().toISOString();
2822
- saveAuditState(state);
2823
- log(`mark_audited [CURATOR]: ${agent_name}`);
2824
- return {
2825
- content: [{ type: "text", text: `Marked as audited: ${agent_name} (${state[agent_name].substring(0, 16)})` }],
2826
- };
2827
- });
2828
2482
  // ---- Output Formatting ----
2829
2483
  /**
2830
2484
  * Format bulk-read output grouped by prefix with header entries.