@gamaze/hicortex 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- package/assets/context.html +115 -6
- package/dist/cli-args.d.ts +16 -0
- package/dist/cli-args.js +30 -0
- package/dist/cli.js +13 -1
- package/dist/context-cli.d.ts +14 -3
- package/dist/context-cli.js +71 -17
- package/dist/context-store.d.ts +108 -2
- package/dist/context-store.js +308 -16
- package/dist/index.js +73 -28
- package/dist/init.d.ts +31 -0
- package/dist/init.js +156 -2
- package/dist/lessons-context.d.ts +56 -0
- package/dist/lessons-context.js +77 -18
- package/dist/mcp-server.js +16 -2
- package/dist/status.d.ts +9 -0
- package/dist/status.js +29 -4
- package/hermes-plugin/hicortex/README.md +15 -2
- package/hermes-plugin/hicortex/client.py +7 -0
- package/hermes-plugin/hicortex/config.py +10 -0
- package/hermes-plugin/hicortex/plugin.yaml +2 -2
- package/hermes-plugin/hicortex/provider.py +134 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -23,6 +23,8 @@ const paths_js_1 = require("./paths.js");
|
|
|
23
23
|
const features_js_1 = require("./features.js");
|
|
24
24
|
const extensions_js_1 = require("./extensions.js");
|
|
25
25
|
const state_js_1 = require("./state.js");
|
|
26
|
+
const context_store_js_1 = require("./context-store.js");
|
|
27
|
+
const lessons_context_js_1 = require("./lessons-context.js");
|
|
26
28
|
const node_fs_1 = require("node:fs");
|
|
27
29
|
const node_path_1 = require("node:path");
|
|
28
30
|
const node_os_1 = require("node:os");
|
|
@@ -31,7 +33,10 @@ const node_os_1 = require("node:os");
|
|
|
31
33
|
// ---------------------------------------------------------------------------
|
|
32
34
|
const DEFAULT_SERVER_URL = "http://127.0.0.1:8787";
|
|
33
35
|
const LESSONS_TIMEOUT_MS = 3000;
|
|
36
|
+
const CONTEXT_TIMEOUT_MS = 3000;
|
|
34
37
|
const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
|
|
38
|
+
/** Harness name this plugin injects for — used to self-gate on GET /context `clients`. */
|
|
39
|
+
const THIS_HARNESS = "oc";
|
|
35
40
|
// ---------------------------------------------------------------------------
|
|
36
41
|
// Module state — initialized in registerService.start()
|
|
37
42
|
// ---------------------------------------------------------------------------
|
|
@@ -95,6 +100,54 @@ async function serverPost(path, body, timeoutMs) {
|
|
|
95
100
|
}
|
|
96
101
|
}
|
|
97
102
|
// ---------------------------------------------------------------------------
|
|
103
|
+
// Context layer (L2) — per-agent standing context (0.13)
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
/**
|
|
106
|
+
* Fetch GET /context (per-agent when an id is supplied) and build the
|
|
107
|
+
* `## Context` block via the shared gate (gateAndRenderContext), or null when
|
|
108
|
+
* nothing should be injected. The old-server guard is required only when an
|
|
109
|
+
* agent id was actually sent (amendment A2 — a bare fetch skips it). The server
|
|
110
|
+
* does the merge; the plugin stays dumb (no client-side mode logic).
|
|
111
|
+
*/
|
|
112
|
+
async function fetchOcContextBlock(agentId) {
|
|
113
|
+
const path = agentId ? `/context?agent=${encodeURIComponent(agentId)}` : "/context";
|
|
114
|
+
const { data } = await serverGet(path, CONTEXT_TIMEOUT_MS);
|
|
115
|
+
if (!data)
|
|
116
|
+
return null;
|
|
117
|
+
return (0, lessons_context_js_1.gateAndRenderContext)(data, THIS_HARNESS, { requireAgentEcho: agentId !== null });
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Fetch /lessons and build the `## Hicortex Lessons` block, or null on any
|
|
121
|
+
* failure or when no lessons survive selection. Preserves the pre-0.13 lesson
|
|
122
|
+
* output; the caller prepends the `## Context` block and adds separators.
|
|
123
|
+
*/
|
|
124
|
+
async function buildLessonsBlock(project) {
|
|
125
|
+
const { data } = await serverGet("/lessons", LESSONS_TIMEOUT_MS);
|
|
126
|
+
if (!data || !data.lessons || data.lessons.length === 0)
|
|
127
|
+
return null;
|
|
128
|
+
const maxLessons = (0, features_js_1.lessonsLimit)();
|
|
129
|
+
const state = (0, state_js_1.loadState)(hicortexHome);
|
|
130
|
+
const moduleIndex = data.moduleIndex ?? state.moduleIndex;
|
|
131
|
+
const selected = await (0, extensions_js_1.getLessonSelector)().select(data.lessons, {
|
|
132
|
+
maxLessons,
|
|
133
|
+
project,
|
|
134
|
+
moduleIndex,
|
|
135
|
+
});
|
|
136
|
+
if (selected.length === 0)
|
|
137
|
+
return null;
|
|
138
|
+
const formatted = selected.map((l) => {
|
|
139
|
+
const titleMatch = l.content.match(/## Lesson: (.+)/);
|
|
140
|
+
const typeMatch = l.content.match(/\*\*Type:\*\* (\w+)/);
|
|
141
|
+
const severityMatch = l.content.match(/\*\*Severity:\*\* (\w+)/);
|
|
142
|
+
const title = titleMatch ? titleMatch[1] : l.content.slice(0, 150);
|
|
143
|
+
const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
|
|
144
|
+
return `- ${title}${meta ? ` (${meta})` : ""}`;
|
|
145
|
+
});
|
|
146
|
+
return (`## Hicortex Lessons (auto-injected from long-term memory)\n` +
|
|
147
|
+
`These are actionable lessons learned from past sessions:\n\n` +
|
|
148
|
+
formatted.join("\n"));
|
|
149
|
+
}
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
98
151
|
// Tool result formatter
|
|
99
152
|
// ---------------------------------------------------------------------------
|
|
100
153
|
function formatToolResults(results) {
|
|
@@ -158,40 +211,32 @@ exports.default = {
|
|
|
158
211
|
},
|
|
159
212
|
});
|
|
160
213
|
// -----------------------------------------------------------------------
|
|
161
|
-
// Hook: before_agent_start — fetch lessons from server (fail-soft)
|
|
214
|
+
// Hook: before_agent_start — fetch context + lessons from server (fail-soft)
|
|
162
215
|
// -----------------------------------------------------------------------
|
|
163
216
|
api.on("before_agent_start", async (_event, ctx) => {
|
|
217
|
+
// Outer guard: the hook must NEVER throw (a rejection could block the
|
|
218
|
+
// agent). `ctx` itself can be nullish on some gateway variants, and the
|
|
219
|
+
// synchronous sanitize below runs before any per-fetch .catch — so the
|
|
220
|
+
// whole body is wrapped, not just the fetches.
|
|
164
221
|
try {
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
222
|
+
// Per-agent context id: sanitize the OC agent id (a symbols-only id
|
|
223
|
+
// sanitizes to null → bare /context → global set). Null id never sends
|
|
224
|
+
// ?agent=, so an old server behaves exactly as before.
|
|
225
|
+
const agentId = (0, context_store_js_1.sanitizeAgentId)(ctx?.agentId ?? "");
|
|
226
|
+
// Fetch both concurrently with INDEPENDENT fail-soft: a /context
|
|
227
|
+
// failure must never cost the lessons block, and vice versa. The
|
|
228
|
+
// `## Context` block (standing context, 0.13) is prepended before
|
|
229
|
+
// `## Hicortex Lessons`, mirroring the CC hook.
|
|
230
|
+
const [contextBlock, lessonsBlock] = await Promise.all([
|
|
231
|
+
fetchOcContextBlock(agentId).catch(() => null),
|
|
232
|
+
buildLessonsBlock(ctx?.project).catch(() => null),
|
|
233
|
+
]);
|
|
234
|
+
const blocks = [contextBlock, lessonsBlock].filter((b) => b !== null && b !== "");
|
|
235
|
+
if (blocks.length === 0)
|
|
178
236
|
return {};
|
|
179
|
-
|
|
180
|
-
const titleMatch = l.content.match(/## Lesson: (.+)/);
|
|
181
|
-
const typeMatch = l.content.match(/\*\*Type:\*\* (\w+)/);
|
|
182
|
-
const severityMatch = l.content.match(/\*\*Severity:\*\* (\w+)/);
|
|
183
|
-
const title = titleMatch ? titleMatch[1] : l.content.slice(0, 150);
|
|
184
|
-
const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
|
|
185
|
-
return `- ${title}${meta ? ` (${meta})` : ""}`;
|
|
186
|
-
});
|
|
187
|
-
const context = `\n\n## Hicortex Lessons (auto-injected from long-term memory)\n` +
|
|
188
|
-
`These are actionable lessons learned from past sessions:\n\n` +
|
|
189
|
-
formatted.join("\n") +
|
|
190
|
-
"\n";
|
|
191
|
-
return { appendSystemContext: context };
|
|
237
|
+
return { appendSystemContext: `\n\n${blocks.join("\n\n")}\n` };
|
|
192
238
|
}
|
|
193
239
|
catch {
|
|
194
|
-
// Fail-soft — a broken lessons fetch must not block the agent
|
|
195
240
|
return {};
|
|
196
241
|
}
|
|
197
242
|
});
|
package/dist/init.d.ts
CHANGED
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
* - Install CC custom commands (/learn, /hicortex-activate)
|
|
17
17
|
*/
|
|
18
18
|
import type { DomainDef } from "./types.js";
|
|
19
|
+
/**
|
|
20
|
+
* Classify `claude mcp list` output for the hicortex entry. Pure (testable):
|
|
21
|
+
* - "missing" — hicortex not listed → registration didn't take
|
|
22
|
+
* - "connected" — listed AND reachable (✓/✔/Connected shown)
|
|
23
|
+
* - "registered" — listed but connection not confirmed (server down / not restarted)
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseMcpListStatus(mcpListOutput: string): "connected" | "registered" | "missing";
|
|
19
26
|
/**
|
|
20
27
|
* Parse a KEY=VALUE env file (e.g. ~/.hermes/.env or ~/.claude/settings.json env block).
|
|
21
28
|
* Handles: comments (#), quoted values, empty lines.
|
|
@@ -37,6 +44,29 @@ export declare function persistAuthToken(configPath: string): {
|
|
|
37
44
|
token: string;
|
|
38
45
|
generated: boolean;
|
|
39
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Decide the per-agent context id to persist at init (#179; CC default = global,
|
|
49
|
+
* owner decision 20.07.2026). `agentName` is an explicit opt-in only — there is
|
|
50
|
+
* NO hostname default, so an install with no `--agent-name` sends no `?agent=`
|
|
51
|
+
* and shares the global context (one user = one identity across machines).
|
|
52
|
+
*
|
|
53
|
+
* Empty string == unset everywhere: `--agent-name ""` (or whitespace-only) is
|
|
54
|
+
* the explicit way to opt BACK OUT — it CLEARS any existing `agentName` key and
|
|
55
|
+
* returns to global, rather than erroring as an invalid id.
|
|
56
|
+
* - explicit `--agent-name <non-empty>` → the sanitized flag (error if it
|
|
57
|
+
* sanitizes to null, so a bad flag is loud rather than silently ignored);
|
|
58
|
+
* - explicit `--agent-name ""` / whitespace-only → `clear` (remove the key);
|
|
59
|
+
* - no flag but an existing non-empty `agentName` → keep it untouched
|
|
60
|
+
* (non-clobber like persistAuthToken / scaffoldDefaultDomains);
|
|
61
|
+
* - no flag, no (non-empty) existing value → do not write (global by default).
|
|
62
|
+
* Pure + exported for testability; never touches disk.
|
|
63
|
+
*/
|
|
64
|
+
export declare function decideAgentName(existing: unknown, flag: string | undefined): {
|
|
65
|
+
write: boolean;
|
|
66
|
+
value: string | null;
|
|
67
|
+
clear?: boolean;
|
|
68
|
+
error?: string;
|
|
69
|
+
};
|
|
40
70
|
/**
|
|
41
71
|
* Generic default memory domains scaffolded by server-mode init (issue #150).
|
|
42
72
|
* Deliberately broad, high-level spheres — an editable STARTING POINT, not a
|
|
@@ -83,6 +113,7 @@ export declare function isEphemeralNpxPath(binPath: string): boolean;
|
|
|
83
113
|
export declare function installSessionStartHook(settingsPath?: string): void;
|
|
84
114
|
export declare function runInit(options?: {
|
|
85
115
|
serverUrl?: string;
|
|
116
|
+
agentName?: string;
|
|
86
117
|
}): Promise<void>;
|
|
87
118
|
/**
|
|
88
119
|
* Resolve the nightly hour (0–23, local time) for the generated schedule.
|
package/dist/init.js
CHANGED
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
*/
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
20
|
exports.GENERIC_DEFAULT_DOMAINS = void 0;
|
|
21
|
+
exports.parseMcpListStatus = parseMcpListStatus;
|
|
21
22
|
exports.parseEnvFile = parseEnvFile;
|
|
22
23
|
exports.generateAuthToken = generateAuthToken;
|
|
23
24
|
exports.persistAuthToken = persistAuthToken;
|
|
25
|
+
exports.decideAgentName = decideAgentName;
|
|
24
26
|
exports.scaffoldDefaultDomains = scaffoldDefaultDomains;
|
|
25
27
|
exports.isEphemeralNpxPath = isEphemeralNpxPath;
|
|
26
28
|
exports.installSessionStartHook = installSessionStartHook;
|
|
@@ -34,6 +36,7 @@ const node_child_process_1 = require("node:child_process");
|
|
|
34
36
|
const node_readline_1 = require("node:readline");
|
|
35
37
|
const node_crypto_1 = require("node:crypto");
|
|
36
38
|
const claude_md_js_1 = require("./claude-md.js");
|
|
39
|
+
const context_store_js_1 = require("./context-store.js");
|
|
37
40
|
const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
|
|
38
41
|
const CC_SETTINGS = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "settings.json");
|
|
39
42
|
const CC_COMMANDS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "commands");
|
|
@@ -157,6 +160,54 @@ function registerCcMcp(serverUrl) {
|
|
|
157
160
|
}
|
|
158
161
|
// Add MCP tool permissions to settings.json so users don't get prompted
|
|
159
162
|
allowHicortexTools();
|
|
163
|
+
// Post-install verification (finding #5): a registration that wrote files
|
|
164
|
+
// but didn't actually take can still look "done". Best-effort confirm and,
|
|
165
|
+
// either way, tell the user exactly how to check.
|
|
166
|
+
verifyCcMcp();
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Classify `claude mcp list` output for the hicortex entry. Pure (testable):
|
|
170
|
+
* - "missing" — hicortex not listed → registration didn't take
|
|
171
|
+
* - "connected" — listed AND reachable (✓/✔/Connected shown)
|
|
172
|
+
* - "registered" — listed but connection not confirmed (server down / not restarted)
|
|
173
|
+
*/
|
|
174
|
+
function parseMcpListStatus(mcpListOutput) {
|
|
175
|
+
// `claude mcp list` prints one line per server: "hicortex: <url> (SSE) - <status>".
|
|
176
|
+
// Anchor on the "hicortex:" line prefix (not a bare word match) so a
|
|
177
|
+
// differently-named MCP like "hicortex-foo" can't be mistaken for our entry,
|
|
178
|
+
// and read status from THAT line only.
|
|
179
|
+
const line = mcpListOutput.split(/\r?\n/).find((l) => /^\s*hicortex:/.test(l));
|
|
180
|
+
if (!line)
|
|
181
|
+
return "missing";
|
|
182
|
+
return /(✓|✔|connected)/i.test(line) ? "connected" : "registered";
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Best-effort post-install check that the hicortex MCP is actually registered
|
|
186
|
+
* (and, when reachable, connected). NEVER fails init — if the claude CLI is
|
|
187
|
+
* absent (e.g. registration went via the ~/.claude.json fallback), we can't
|
|
188
|
+
* query it, so we just tell the user how to confirm. Closes the "looks
|
|
189
|
+
* configured but isn't, with no verification" gap.
|
|
190
|
+
*/
|
|
191
|
+
function verifyCcMcp() {
|
|
192
|
+
let out;
|
|
193
|
+
try {
|
|
194
|
+
out = (0, node_child_process_1.execSync)("claude mcp list", { encoding: "utf-8", stdio: "pipe" });
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
console.log(" ℹ After restarting Claude Code, confirm with `claude mcp list` (hicortex should show ✓ Connected).");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
switch (parseMcpListStatus(out)) {
|
|
201
|
+
case "connected":
|
|
202
|
+
console.log(" ✓ Verified: hicortex MCP registered and connected");
|
|
203
|
+
break;
|
|
204
|
+
case "registered":
|
|
205
|
+
console.log(" ✓ Verified: hicortex MCP registered — restart Claude Code, then `claude mcp list` should show it Connected");
|
|
206
|
+
break;
|
|
207
|
+
case "missing":
|
|
208
|
+
console.log(" ⚠ Could NOT confirm the hicortex MCP registration — run `claude mcp list`; if it's absent, re-run init.");
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
160
211
|
}
|
|
161
212
|
function allowHicortexTools() {
|
|
162
213
|
let settings = {};
|
|
@@ -701,6 +752,68 @@ function persistAuthToken(configPath) {
|
|
|
701
752
|
(0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
|
|
702
753
|
return { token, generated: true };
|
|
703
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* Decide the per-agent context id to persist at init (#179; CC default = global,
|
|
757
|
+
* owner decision 20.07.2026). `agentName` is an explicit opt-in only — there is
|
|
758
|
+
* NO hostname default, so an install with no `--agent-name` sends no `?agent=`
|
|
759
|
+
* and shares the global context (one user = one identity across machines).
|
|
760
|
+
*
|
|
761
|
+
* Empty string == unset everywhere: `--agent-name ""` (or whitespace-only) is
|
|
762
|
+
* the explicit way to opt BACK OUT — it CLEARS any existing `agentName` key and
|
|
763
|
+
* returns to global, rather than erroring as an invalid id.
|
|
764
|
+
* - explicit `--agent-name <non-empty>` → the sanitized flag (error if it
|
|
765
|
+
* sanitizes to null, so a bad flag is loud rather than silently ignored);
|
|
766
|
+
* - explicit `--agent-name ""` / whitespace-only → `clear` (remove the key);
|
|
767
|
+
* - no flag but an existing non-empty `agentName` → keep it untouched
|
|
768
|
+
* (non-clobber like persistAuthToken / scaffoldDefaultDomains);
|
|
769
|
+
* - no flag, no (non-empty) existing value → do not write (global by default).
|
|
770
|
+
* Pure + exported for testability; never touches disk.
|
|
771
|
+
*/
|
|
772
|
+
function decideAgentName(existing, flag) {
|
|
773
|
+
if (flag !== undefined) {
|
|
774
|
+
// Explicit empty / whitespace-only value → clear back to global (unset).
|
|
775
|
+
if (flag.trim() === "")
|
|
776
|
+
return { write: false, value: null, clear: true };
|
|
777
|
+
const s = (0, context_store_js_1.sanitizeAgentId)(flag);
|
|
778
|
+
if (s === null) {
|
|
779
|
+
return {
|
|
780
|
+
write: false,
|
|
781
|
+
value: null,
|
|
782
|
+
error: `Invalid --agent-name '${flag}'. Must contain letters or digits and sanitize to ^[a-z0-9][a-z0-9_-]*$ (max 64 chars). Pass --agent-name "" to clear it (global context).`,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
return { write: true, value: s };
|
|
786
|
+
}
|
|
787
|
+
if (typeof existing === "string" && existing.trim().length > 0)
|
|
788
|
+
return { write: false, value: existing };
|
|
789
|
+
return { write: false, value: null };
|
|
790
|
+
}
|
|
791
|
+
/** Read config.json, set agentName, write it back. Used by the server path. */
|
|
792
|
+
function writeAgentNameConfig(configPath, value) {
|
|
793
|
+
let config = {};
|
|
794
|
+
try {
|
|
795
|
+
config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
|
|
796
|
+
}
|
|
797
|
+
catch { /* new / unreadable → start fresh */ }
|
|
798
|
+
config.agentName = value;
|
|
799
|
+
(0, node_fs_1.mkdirSync)(HICORTEX_HOME, { recursive: true });
|
|
800
|
+
(0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
|
|
801
|
+
}
|
|
802
|
+
/** Read config.json, delete any `agentName` key, write it back (server path). */
|
|
803
|
+
function clearAgentNameConfig(configPath) {
|
|
804
|
+
let config = {};
|
|
805
|
+
try {
|
|
806
|
+
config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
return; /* new / unreadable → nothing to clear */
|
|
810
|
+
}
|
|
811
|
+
if (!("agentName" in config))
|
|
812
|
+
return;
|
|
813
|
+
delete config.agentName;
|
|
814
|
+
(0, node_fs_1.mkdirSync)(HICORTEX_HOME, { recursive: true });
|
|
815
|
+
(0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
|
|
816
|
+
}
|
|
704
817
|
/**
|
|
705
818
|
* Generic default memory domains scaffolded by server-mode init (issue #150).
|
|
706
819
|
* Deliberately broad, high-level spheres — an editable STARTING POINT, not a
|
|
@@ -994,7 +1107,7 @@ async function ask(question) {
|
|
|
994
1107
|
// ---------------------------------------------------------------------------
|
|
995
1108
|
async function runInit(options = {}) {
|
|
996
1109
|
if (options.serverUrl) {
|
|
997
|
-
await runClientInit(options.serverUrl);
|
|
1110
|
+
await runClientInit(options.serverUrl, options.agentName);
|
|
998
1111
|
return;
|
|
999
1112
|
}
|
|
1000
1113
|
console.log("Hicortex — Setup for Claude Code\n");
|
|
@@ -1076,6 +1189,25 @@ async function runInit(options = {}) {
|
|
|
1076
1189
|
// Classification activates automatically once an LLM is configured; until
|
|
1077
1190
|
// then domains sit inert (strict-skip path).
|
|
1078
1191
|
scaffoldDefaultDomains(configPath);
|
|
1192
|
+
// Per-agent context id (#179): server mode writes it ONLY when the operator
|
|
1193
|
+
// passes --agent-name. Without the flag no agentName is written and the
|
|
1194
|
+
// co-located CC shares the global context (global by default). Explicit flag
|
|
1195
|
+
// overwrites on re-init; `--agent-name ""` clears it back to global.
|
|
1196
|
+
if (options.agentName !== undefined) {
|
|
1197
|
+
const decision = decideAgentName(undefined, options.agentName);
|
|
1198
|
+
if (decision.error) {
|
|
1199
|
+
console.error(` ✗ ${decision.error}`);
|
|
1200
|
+
process.exit(1);
|
|
1201
|
+
}
|
|
1202
|
+
if (decision.clear) {
|
|
1203
|
+
clearAgentNameConfig(configPath);
|
|
1204
|
+
console.log(" ✓ Agent name cleared — global context");
|
|
1205
|
+
}
|
|
1206
|
+
else if (decision.write && decision.value) {
|
|
1207
|
+
writeAgentNameConfig(configPath, decision.value);
|
|
1208
|
+
console.log(` ✓ Agent name set to '${decision.value}'`);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1079
1211
|
// Install the nightly job (capture via localhost /distill + consolidation).
|
|
1080
1212
|
// Without it a server-mode install never captures or consolidates — the
|
|
1081
1213
|
// daemon only serves recall + /distill. Skips if a schedule already exists.
|
|
@@ -1137,7 +1269,7 @@ async function runInit(options = {}) {
|
|
|
1137
1269
|
// ---------------------------------------------------------------------------
|
|
1138
1270
|
// Client Mode Init
|
|
1139
1271
|
// ---------------------------------------------------------------------------
|
|
1140
|
-
async function runClientInit(serverUrl) {
|
|
1272
|
+
async function runClientInit(serverUrl, agentName) {
|
|
1141
1273
|
console.log("Hicortex — Client Mode Setup\n");
|
|
1142
1274
|
serverUrl = serverUrl.replace(/\/+$/, "");
|
|
1143
1275
|
// Step 1: Verify server is reachable
|
|
@@ -1216,8 +1348,30 @@ async function runClientInit(serverUrl) {
|
|
|
1216
1348
|
config.serverUrl = serverUrl;
|
|
1217
1349
|
if (authToken)
|
|
1218
1350
|
config.authToken = authToken;
|
|
1351
|
+
// Per-agent context id (#179): explicit opt-in only. Written ONLY when
|
|
1352
|
+
// --agent-name is passed; otherwise no agentName is set and the client shares
|
|
1353
|
+
// the global context (global by default). Re-init keeps an existing value
|
|
1354
|
+
// unless --agent-name is explicit; `--agent-name ""` clears it back to global.
|
|
1355
|
+
const nameDecision = decideAgentName(config.agentName, agentName);
|
|
1356
|
+
if (nameDecision.error) {
|
|
1357
|
+
console.error(` ✗ ${nameDecision.error}`);
|
|
1358
|
+
process.exit(1);
|
|
1359
|
+
}
|
|
1360
|
+
if (nameDecision.clear) {
|
|
1361
|
+
delete config.agentName;
|
|
1362
|
+
console.log(" ✓ Agent name cleared — global context");
|
|
1363
|
+
}
|
|
1364
|
+
else if (nameDecision.write && nameDecision.value) {
|
|
1365
|
+
config.agentName = nameDecision.value;
|
|
1366
|
+
if (agentName && nameDecision.value !== agentName) {
|
|
1367
|
+
console.log(` ℹ Agent name sanitized to '${nameDecision.value}'`);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1219
1370
|
saveConfig(configPath, config);
|
|
1220
1371
|
console.log(` ✓ Client config saved to ${configPath}`);
|
|
1372
|
+
if (typeof config.agentName === "string") {
|
|
1373
|
+
console.log(` ✓ Agent name: ${config.agentName}`);
|
|
1374
|
+
}
|
|
1221
1375
|
// Step 4: Register CC MCP pointing to remote server
|
|
1222
1376
|
if (authToken) {
|
|
1223
1377
|
// Write directly with auth header
|
|
@@ -21,6 +21,62 @@
|
|
|
21
21
|
* parse error) results in silent exit-0. A broken hook must never block a
|
|
22
22
|
* CC session, and a broken /context fetch must never blank the whole output.
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* The GET /context response shape, shared by the CC hook and the OC plugin so
|
|
26
|
+
* their gating cannot drift. `agent`/`mode` are echoed by a 0.13 server whenever
|
|
27
|
+
* `?agent=` was sent (in EVERY mode); a pre-0.13 server omits them.
|
|
28
|
+
*/
|
|
29
|
+
export interface ContextResponse {
|
|
30
|
+
sections?: Record<string, string>;
|
|
31
|
+
updated_at?: string;
|
|
32
|
+
clients?: string[];
|
|
33
|
+
agent?: string;
|
|
34
|
+
mode?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Title-case a section name for its heading: split on `-`/`_`, capitalize each
|
|
38
|
+
* word ("user" → "User", "my_notes" → "My Notes").
|
|
39
|
+
* Exported so the OC plugin (index.ts) renders the `## Context` block
|
|
40
|
+
* identically to the CC hook rather than duplicating the logic.
|
|
41
|
+
*/
|
|
42
|
+
export declare function titleCaseSection(name: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* Stable section ordering: `user` first, then `rules` (the seeded primary
|
|
45
|
+
* sections, spec §8), then every other section alphabetically. Server-side
|
|
46
|
+
* enumeration order (readdirSync) is FS-dependent, so we sort here for a
|
|
47
|
+
* deterministic injection block. Exported for reuse by the OC plugin.
|
|
48
|
+
*/
|
|
49
|
+
export declare function orderSectionNames(names: string[]): string[];
|
|
50
|
+
/**
|
|
51
|
+
* Render the `## Context` block from a resolved section map, or null when there
|
|
52
|
+
* is nothing to inject (no sections, or every section blank after trimming).
|
|
53
|
+
* Pure — no gating, no I/O. Shared verbatim by the CC hook and the OC plugin so
|
|
54
|
+
* both harnesses emit an identical block. Sections are ordered (user, rules,
|
|
55
|
+
* then alphabetical) and rendered under title-cased `###` headings.
|
|
56
|
+
*/
|
|
57
|
+
export declare function renderContextBlock(sections: Record<string, string>): string | null;
|
|
58
|
+
/**
|
|
59
|
+
* Gate a GET /context response and render the `## Context` block, or null when
|
|
60
|
+
* nothing should be injected: `harness` not in the server-resolved `clients`,
|
|
61
|
+
* an empty/blank section set, or — when `requireAgentEcho` — a response that
|
|
62
|
+
* does not echo `agent`. The SINGLE gate used by both CC and OC so the two can
|
|
63
|
+
* never drift (the Python Hermes plugin `provider.py::_context_block` mirrors
|
|
64
|
+
* this logic — keep them in sync).
|
|
65
|
+
*
|
|
66
|
+
* `requireAgentEcho` is the old-server guard, and it is the CALLER's decision:
|
|
67
|
+
* - OC passes `agentId !== null` — when it actually sent an id, a 0.12 server
|
|
68
|
+
* that ignores `?agent=` (200 global, no echo) must NOT leak global context
|
|
69
|
+
* into every persona; on a bare fetch (no id) the guard is off (amendment
|
|
70
|
+
* A2).
|
|
71
|
+
* - CC passes `false` ALWAYS and deliberately (see the call site): a thin CC
|
|
72
|
+
* client auto-upgrades via npx BEFORE bedrock does, so during the upgrade
|
|
73
|
+
* window it talks to a 0.12 server that cannot hold ANY per-agent config —
|
|
74
|
+
* global IS the operator's intended state there, and a guard would instead
|
|
75
|
+
* blank ALL context for every CC session in that window.
|
|
76
|
+
*/
|
|
77
|
+
export declare function gateAndRenderContext(data: ContextResponse, harness: string, opts: {
|
|
78
|
+
requireAgentEcho: boolean;
|
|
79
|
+
}): string | null;
|
|
24
80
|
/**
|
|
25
81
|
* Fetch context + lessons concurrently and return the combined Markdown block,
|
|
26
82
|
* or null when neither yields anything (nothing to inject; caller prints
|
package/dist/lessons-context.js
CHANGED
|
@@ -23,9 +23,14 @@
|
|
|
23
23
|
* CC session, and a broken /context fetch must never blank the whole output.
|
|
24
24
|
*/
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.titleCaseSection = titleCaseSection;
|
|
27
|
+
exports.orderSectionNames = orderSectionNames;
|
|
28
|
+
exports.renderContextBlock = renderContextBlock;
|
|
29
|
+
exports.gateAndRenderContext = gateAndRenderContext;
|
|
26
30
|
exports.fetchLessonsContext = fetchLessonsContext;
|
|
27
31
|
const node_fs_1 = require("node:fs");
|
|
28
32
|
const node_path_1 = require("node:path");
|
|
33
|
+
const context_store_js_1 = require("./context-store.js");
|
|
29
34
|
const features_js_1 = require("./features.js");
|
|
30
35
|
const extensions_js_1 = require("./extensions.js");
|
|
31
36
|
const state_js_1 = require("./state.js");
|
|
@@ -50,7 +55,13 @@ function resolveConfig() {
|
|
|
50
55
|
const serverUrl = config.mode === "client" && typeof config.serverUrl === "string"
|
|
51
56
|
? config.serverUrl.replace(/\/+$/, "")
|
|
52
57
|
: `http://127.0.0.1:${config.port ?? DEFAULT_PORT}`;
|
|
53
|
-
|
|
58
|
+
// Per-agent context id (0.13) via the shared resolver, so the id sent here
|
|
59
|
+
// always matches what `hicortex status` reports. No configured agentName →
|
|
60
|
+
// agentId null → NO ?agent= (bare fetch): CC's default is the shared global
|
|
61
|
+
// context. A configured agentName that sanitizes to null → agentId null too
|
|
62
|
+
// (NO ?agent=), never a 400 that the fail-soft hook would silently swallow.
|
|
63
|
+
const agentName = (0, context_store_js_1.resolveAgentIdentity)(config).agentId;
|
|
64
|
+
return { serverUrl, authToken: config.authToken, home, agentName };
|
|
54
65
|
}
|
|
55
66
|
function authHeaders(authToken) {
|
|
56
67
|
return authToken ? { "Authorization": `Bearer ${authToken}` } : {};
|
|
@@ -112,6 +123,8 @@ async function fetchLessonsBlock(cfg) {
|
|
|
112
123
|
/**
|
|
113
124
|
* Title-case a section name for its heading: split on `-`/`_`, capitalize each
|
|
114
125
|
* word ("user" → "User", "my_notes" → "My Notes").
|
|
126
|
+
* Exported so the OC plugin (index.ts) renders the `## Context` block
|
|
127
|
+
* identically to the CC hook rather than duplicating the logic.
|
|
115
128
|
*/
|
|
116
129
|
function titleCaseSection(name) {
|
|
117
130
|
return name
|
|
@@ -124,7 +137,7 @@ function titleCaseSection(name) {
|
|
|
124
137
|
* Stable section ordering: `user` first, then `rules` (the seeded primary
|
|
125
138
|
* sections, spec §8), then every other section alphabetically. Server-side
|
|
126
139
|
* enumeration order (readdirSync) is FS-dependent, so we sort here for a
|
|
127
|
-
* deterministic injection block.
|
|
140
|
+
* deterministic injection block. Exported for reuse by the OC plugin.
|
|
128
141
|
*/
|
|
129
142
|
function orderSectionNames(names) {
|
|
130
143
|
const primaries = ["user", "rules"].filter((p) => names.includes(p));
|
|
@@ -132,23 +145,13 @@ function orderSectionNames(names) {
|
|
|
132
145
|
return [...primaries, ...rest];
|
|
133
146
|
}
|
|
134
147
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
148
|
+
* Render the `## Context` block from a resolved section map, or null when there
|
|
149
|
+
* is nothing to inject (no sections, or every section blank after trimming).
|
|
150
|
+
* Pure — no gating, no I/O. Shared verbatim by the CC hook and the OC plugin so
|
|
151
|
+
* both harnesses emit an identical block. Sections are ordered (user, rules,
|
|
152
|
+
* then alphabetical) and rendered under title-cased `###` headings.
|
|
138
153
|
*/
|
|
139
|
-
|
|
140
|
-
const resp = await fetch(`${cfg.serverUrl}/context`, {
|
|
141
|
-
headers: authHeaders(cfg.authToken),
|
|
142
|
-
signal: AbortSignal.timeout(3000),
|
|
143
|
-
});
|
|
144
|
-
if (!resp.ok)
|
|
145
|
-
return null;
|
|
146
|
-
const data = await resp.json();
|
|
147
|
-
// Self-gate: only inject when this harness is in the server-resolved list.
|
|
148
|
-
const clients = Array.isArray(data.clients) ? data.clients : [];
|
|
149
|
-
if (!clients.includes(THIS_HARNESS))
|
|
150
|
-
return null;
|
|
151
|
-
const sections = data.sections;
|
|
154
|
+
function renderContextBlock(sections) {
|
|
152
155
|
if (!sections || typeof sections !== "object" || Array.isArray(sections))
|
|
153
156
|
return null;
|
|
154
157
|
const names = orderSectionNames(Object.keys(sections));
|
|
@@ -163,6 +166,62 @@ async function fetchContextBlock(cfg) {
|
|
|
163
166
|
return null;
|
|
164
167
|
return ["## Context", "", ...bodyParts].join("\n");
|
|
165
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Gate a GET /context response and render the `## Context` block, or null when
|
|
171
|
+
* nothing should be injected: `harness` not in the server-resolved `clients`,
|
|
172
|
+
* an empty/blank section set, or — when `requireAgentEcho` — a response that
|
|
173
|
+
* does not echo `agent`. The SINGLE gate used by both CC and OC so the two can
|
|
174
|
+
* never drift (the Python Hermes plugin `provider.py::_context_block` mirrors
|
|
175
|
+
* this logic — keep them in sync).
|
|
176
|
+
*
|
|
177
|
+
* `requireAgentEcho` is the old-server guard, and it is the CALLER's decision:
|
|
178
|
+
* - OC passes `agentId !== null` — when it actually sent an id, a 0.12 server
|
|
179
|
+
* that ignores `?agent=` (200 global, no echo) must NOT leak global context
|
|
180
|
+
* into every persona; on a bare fetch (no id) the guard is off (amendment
|
|
181
|
+
* A2).
|
|
182
|
+
* - CC passes `false` ALWAYS and deliberately (see the call site): a thin CC
|
|
183
|
+
* client auto-upgrades via npx BEFORE bedrock does, so during the upgrade
|
|
184
|
+
* window it talks to a 0.12 server that cannot hold ANY per-agent config —
|
|
185
|
+
* global IS the operator's intended state there, and a guard would instead
|
|
186
|
+
* blank ALL context for every CC session in that window.
|
|
187
|
+
*/
|
|
188
|
+
function gateAndRenderContext(data, harness, opts) {
|
|
189
|
+
if (!data || typeof data !== "object")
|
|
190
|
+
return null;
|
|
191
|
+
const clients = Array.isArray(data.clients) ? data.clients : [];
|
|
192
|
+
if (!clients.includes(harness))
|
|
193
|
+
return null;
|
|
194
|
+
if (opts.requireAgentEcho && typeof data.agent !== "string")
|
|
195
|
+
return null;
|
|
196
|
+
return renderContextBlock(data.sections ?? {});
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Fetch /context and build the `## Context` block, or null when nothing should
|
|
200
|
+
* be injected: non-2xx, this harness not in `clients`, no sections, or all
|
|
201
|
+
* sections empty. Throws propagate to the caller's fail-soft catch.
|
|
202
|
+
*/
|
|
203
|
+
async function fetchContextBlock(cfg) {
|
|
204
|
+
// Send ?agent= only when we have a valid id; the server does the merge and
|
|
205
|
+
// returns the resolved sections, so the hook stays dumb (no client-side mode
|
|
206
|
+
// logic). A null id (CC's default: no configured agentName, or a configured
|
|
207
|
+
// value that sanitizes to nothing) → bare /context → the shared global set.
|
|
208
|
+
const url = cfg.agentName
|
|
209
|
+
? `${cfg.serverUrl}/context?agent=${encodeURIComponent(cfg.agentName)}`
|
|
210
|
+
: `${cfg.serverUrl}/context`;
|
|
211
|
+
const resp = await fetch(url, {
|
|
212
|
+
headers: authHeaders(cfg.authToken),
|
|
213
|
+
signal: AbortSignal.timeout(3000),
|
|
214
|
+
});
|
|
215
|
+
if (!resp.ok)
|
|
216
|
+
return null;
|
|
217
|
+
const data = await resp.json();
|
|
218
|
+
// CC deliberately passes requireAgentEcho: false (NOT the OC/Hermes old-server
|
|
219
|
+
// guard). A thin CC client auto-upgrades via npx BEFORE bedrock does, so
|
|
220
|
+
// mid-upgrade it may hit a 0.12 server that returns global context with no
|
|
221
|
+
// `agent` echo — and a 0.12 server cannot hold per-agent config, so global is
|
|
222
|
+
// the intended state. Guarding here would blank ALL CC context in that window.
|
|
223
|
+
return gateAndRenderContext(data, THIS_HARNESS, { requireAgentEcho: false });
|
|
224
|
+
}
|
|
166
225
|
/**
|
|
167
226
|
* Fetch context + lessons concurrently and return the combined Markdown block,
|
|
168
227
|
* or null when neither yields anything (nothing to inject; caller prints
|
package/dist/mcp-server.js
CHANGED
|
@@ -81,6 +81,10 @@ let stateDir = "";
|
|
|
81
81
|
// Resolved contextClients list (spec §2) — the harness names allowed to inject
|
|
82
82
|
// the standing context layer. Echoed by GET /context so each hook self-gates.
|
|
83
83
|
let contextClients = ["cc"];
|
|
84
|
+
// Resolved contextAgents map (0.13) — agent id → mode (override/global/off).
|
|
85
|
+
// Read once at boot (like contextClients); the drop-in-a-dir presence path is
|
|
86
|
+
// per-request, so only explicit config entries need a daemon restart to apply.
|
|
87
|
+
let contextAgents = {};
|
|
84
88
|
// Cache detectChunkSize results keyed by "<provider>/<model>@<baseUrl>" so we
|
|
85
89
|
// probe each endpoint once per server boot rather than once per /distill request.
|
|
86
90
|
const chunkSizeCache = new Map();
|
|
@@ -436,6 +440,16 @@ async function startServer(options = {}) {
|
|
|
436
440
|
console.warn(`[hicortex] Ignoring unknown contextClients: ${resolvedClients.dropped.join(", ")} ` +
|
|
437
441
|
`(known: cc, hermes, oc)`);
|
|
438
442
|
}
|
|
443
|
+
// Per-agent context (0.13): resolve the config-declared modes. Warn once per
|
|
444
|
+
// boot on dropped entries (bad key or bad mode) so typos surface. NOTE: this
|
|
445
|
+
// map is boot-time; editing contextAgents needs a daemon restart. Dropping an
|
|
446
|
+
// agents/<id> dir onto disk takes effect immediately (per-request presence).
|
|
447
|
+
const resolvedAgents = (0, context_store_js_1.resolveContextAgents)(savedConfig?.contextAgents);
|
|
448
|
+
contextAgents = resolvedAgents.agents;
|
|
449
|
+
if (resolvedAgents.dropped.length > 0) {
|
|
450
|
+
console.warn(`[hicortex] Ignoring invalid contextAgents entries: ${resolvedAgents.dropped.join(", ")} ` +
|
|
451
|
+
`(keys must match ^[a-z0-9][a-z0-9_-]*$; modes must be override|global|off)`);
|
|
452
|
+
}
|
|
439
453
|
// Express app
|
|
440
454
|
const app = (0, express_1.default)();
|
|
441
455
|
// Raise the body limit — whole-session denoised transcripts exceed the 100 kB default.
|
|
@@ -620,7 +634,7 @@ async function startServer(options = {}) {
|
|
|
620
634
|
// which the tests exercise directly — no mirror-app drift.
|
|
621
635
|
app.get("/context", (req, res) => {
|
|
622
636
|
try {
|
|
623
|
-
const r = (0, context_store_js_1.handleContextGet)((0, node_path_1.join)(stateDir, "context"), contextClients, req.query);
|
|
637
|
+
const r = (0, context_store_js_1.handleContextGet)((0, node_path_1.join)(stateDir, "context"), contextClients, req.query, contextAgents);
|
|
624
638
|
res.status(r.status).json(r.body);
|
|
625
639
|
}
|
|
626
640
|
catch (err) {
|
|
@@ -629,7 +643,7 @@ async function startServer(options = {}) {
|
|
|
629
643
|
});
|
|
630
644
|
app.put("/context", (req, res) => {
|
|
631
645
|
try {
|
|
632
|
-
const r = (0, context_store_js_1.handleContextPut)((0, node_path_1.join)(stateDir, "context"), req.body);
|
|
646
|
+
const r = (0, context_store_js_1.handleContextPut)((0, node_path_1.join)(stateDir, "context"), req.body, req.query, contextAgents);
|
|
633
647
|
if (r.warn)
|
|
634
648
|
console.warn(`[hicortex] ${r.warn}`);
|
|
635
649
|
res.status(r.status).json(r.body);
|