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.
- package/dist/cli-context-inject.js +8 -1
- package/dist/cli-context-inject.js.map +1 -1
- package/dist/cli-hook-startup.js +12 -9
- package/dist/cli-hook-startup.js.map +1 -1
- package/dist/cli-init.js +96 -1
- package/dist/cli-init.js.map +1 -1
- package/dist/cli-log-exchange.js +9 -4
- package/dist/cli-log-exchange.js.map +1 -1
- package/dist/hmem-config.d.ts +7 -1
- package/dist/hmem-config.js +4 -1
- package/dist/hmem-config.js.map +1 -1
- package/dist/hmem-store.d.ts +10 -31
- package/dist/hmem-store.js +22 -187
- package/dist/hmem-store.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.js +195 -481
- package/dist/mcp-server.js.map +1 -1
- package/opencode-plugin/hmem.js +105 -0
- package/package.json +2 -1
- package/skills/hmem-config/SKILL.md +77 -0
- package/skills/hmem-curate/SKILL.md +158 -134
- package/skills/hmem-migrate-o/SKILL.md +1 -1
- package/skills/hmem-read/SKILL.md +1 -1
- package/skills/hmem-release/SKILL.md +2 -3
- package/skills/hmem-update/SKILL.md +48 -1
- package/skills/hmem-wipe/SKILL.md +6 -4
- package/skills/hmem-write/SKILL.md +28 -5
- package/skills/hmem-self-curate/SKILL.md +0 -194
package/dist/mcp-server.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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 !
|
|
859
|
+
return !sectionNames.some(sec => firstWord.startsWith(sec));
|
|
750
860
|
});
|
|
751
861
|
if (invalid.length > 0) {
|
|
752
862
|
return {
|
|
753
|
-
content: [{ type: "text", text: `
|
|
754
|
-
`Valid: ${
|
|
755
|
-
`Invalid L2 nodes
|
|
756
|
-
`
|
|
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
|
|
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
|
-
|
|
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
|
|
862
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1182
|
-
|
|
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 =
|
|
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 =
|
|
1282
|
-
? path.join(PROJECT_DIR, "company.hmem")
|
|
1283
|
-
: HMEM_PATH;
|
|
1415
|
+
const hmemPath = resolvedPath;
|
|
1284
1416
|
const dbExists = fs.existsSync(hmemPath);
|
|
1285
|
-
const label =
|
|
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 =
|
|
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.
|
|
1390
|
-
const { text, ids } = formatRecentOEntries(hmemStore, hmemConfig.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (${
|
|
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.
|