@gamaze/hicortex 0.17.6 → 0.18.1
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 +30 -28
- package/assets/dashboard.html +121 -5
- package/assets/{context.html → identity.html} +18 -18
- package/assets/viz.html +19 -7
- package/dist/claude-md.d.ts +2 -1
- package/dist/claude-md.js +2 -1
- package/dist/cli-args.d.ts +9 -0
- package/dist/cli-args.js +16 -0
- package/dist/cli.js +29 -20
- package/dist/consolidate.d.ts +15 -0
- package/dist/consolidate.js +30 -3
- package/dist/dashboard.d.ts +58 -1
- package/dist/dashboard.js +27 -1
- package/dist/extensions.d.ts +1 -1
- package/dist/extensions.js +1 -1
- package/dist/health.d.ts +68 -0
- package/dist/health.js +73 -0
- package/dist/identity-cli.d.ts +90 -0
- package/dist/{context-cli.js → identity-cli.js} +66 -48
- package/dist/{context-store.d.ts → identity-store.d.ts} +94 -31
- package/dist/{context-store.js → identity-store.js} +212 -71
- package/dist/index.d.ts +12 -5
- package/dist/index.js +57 -29
- package/dist/init.d.ts +44 -8
- package/dist/init.js +142 -37
- package/dist/learnings-identity.d.ts +149 -0
- package/dist/{lessons-context.js → learnings-identity.js} +96 -52
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +217 -68
- package/dist/memory-instructions.d.ts +6 -6
- package/dist/memory-instructions.js +6 -6
- package/dist/nightly.js +65 -6
- package/dist/paths.js +1 -1
- package/dist/recall-hook-cli.d.ts +1 -1
- package/dist/recall-hook-cli.js +3 -3
- package/dist/recall-index.js +5 -2
- package/dist/status.d.ts +2 -2
- package/dist/status.js +11 -9
- package/dist/telemetry.d.ts +10 -0
- package/dist/type-classify.js +4 -1
- package/dist/type-labels.d.ts +30 -0
- package/dist/type-labels.js +43 -0
- package/dist/types.d.ts +28 -0
- package/dist/uninstall.d.ts +12 -0
- package/dist/uninstall.js +21 -3
- package/dist/viz.d.ts +24 -11
- package/dist/viz.js +97 -32
- package/hermes-plugin/hicortex/README.md +4 -2
- package/package.json +2 -2
- package/dist/context-cli.d.ts +0 -69
- package/dist/lessons-context.d.ts +0 -102
package/dist/index.js
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
* Run server: `npx @gamaze/hicortex init`
|
|
11
11
|
*
|
|
12
12
|
* Responsibilities (recall-only adapter, like the Hermes plugin):
|
|
13
|
-
* - before_agent_start → GET /
|
|
14
|
-
* (fail-soft, 3s timeout each, concurrent) → inject
|
|
15
|
-
* every inbound message spawns an embedded run, so this hook fires
|
|
16
|
-
* TURN with the current prompt and session id — it is the per-turn
|
|
13
|
+
* - before_agent_start → GET /identity + GET /lessons + POST /recall-index
|
|
14
|
+
* (fail-soft, 3s timeout each, concurrent) → inject identity + lessons. In
|
|
15
|
+
* OpenClaw every inbound message spawns an embedded run, so this hook fires
|
|
16
|
+
* PER TURN with the current prompt and session id — it is the per-turn
|
|
17
17
|
* /recall-index surface, not just session start.
|
|
18
18
|
* - after_compaction / before_reset → POST /recall-index {reset:true}
|
|
19
|
-
* (context rebuilt → the server's per-session shown-set is stale)
|
|
19
|
+
* (context window rebuilt → the server's per-session shown-set is stale)
|
|
20
20
|
* - Tools → HTTP proxies to /search, /memory, /recent, /ingest, /lessons
|
|
21
21
|
*
|
|
22
22
|
* CAPTURE IS NOT THIS PLUGIN'S JOB. OpenClaw persists sessions at
|
|
@@ -25,24 +25,26 @@
|
|
|
25
25
|
* canonical nightly-from-logs, same as CC JSONL and Hermes state.db.
|
|
26
26
|
*/
|
|
27
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.formatToolResults = formatToolResults;
|
|
28
29
|
const paths_js_1 = require("./paths.js");
|
|
29
30
|
const features_js_1 = require("./features.js");
|
|
30
31
|
const extensions_js_1 = require("./extensions.js");
|
|
31
32
|
const state_js_1 = require("./state.js");
|
|
32
|
-
const
|
|
33
|
-
const
|
|
33
|
+
const identity_store_js_1 = require("./identity-store.js");
|
|
34
|
+
const learnings_identity_js_1 = require("./learnings-identity.js");
|
|
34
35
|
const node_fs_1 = require("node:fs");
|
|
35
36
|
const node_path_1 = require("node:path");
|
|
36
37
|
const node_os_1 = require("node:os");
|
|
38
|
+
const type_labels_js_1 = require("./type-labels.js");
|
|
37
39
|
// ---------------------------------------------------------------------------
|
|
38
40
|
// Constants
|
|
39
41
|
// ---------------------------------------------------------------------------
|
|
40
42
|
const DEFAULT_SERVER_URL = "http://127.0.0.1:8787";
|
|
41
43
|
const LESSONS_TIMEOUT_MS = 3000;
|
|
42
|
-
const
|
|
44
|
+
const IDENTITY_TIMEOUT_MS = 3000;
|
|
43
45
|
const RECALL_TIMEOUT_MS = 3000;
|
|
44
46
|
const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
|
|
45
|
-
/** Harness name this plugin injects for — used to self-gate on GET /
|
|
47
|
+
/** Harness name this plugin injects for — used to self-gate on GET /identity `clients`. */
|
|
46
48
|
const THIS_HARNESS = "oc";
|
|
47
49
|
// ---------------------------------------------------------------------------
|
|
48
50
|
// Module state — initialized in registerService.start()
|
|
@@ -130,26 +132,27 @@ async function serverPost(path, body, timeoutMs) {
|
|
|
130
132
|
}
|
|
131
133
|
}
|
|
132
134
|
// ---------------------------------------------------------------------------
|
|
133
|
-
//
|
|
135
|
+
// Identity layer — per-agent standing identity (0.13; renamed from context
|
|
136
|
+
// layer in 0.18 #264)
|
|
134
137
|
// ---------------------------------------------------------------------------
|
|
135
138
|
/**
|
|
136
|
-
* Fetch GET /
|
|
137
|
-
* `##
|
|
139
|
+
* Fetch GET /identity (per-agent when an id is supplied) and build the
|
|
140
|
+
* `## Identity` block via the shared gate (gateAndRenderIdentity), or null when
|
|
138
141
|
* nothing should be injected. The old-server guard is required only when an
|
|
139
142
|
* agent id was actually sent (amendment A2 — a bare fetch skips it). The server
|
|
140
143
|
* does the merge; the plugin stays dumb (no client-side mode logic).
|
|
141
144
|
*/
|
|
142
|
-
async function
|
|
143
|
-
const path = agentId ? `/
|
|
144
|
-
const { data } = await serverGet(path,
|
|
145
|
+
async function fetchOcIdentityBlock(agentId) {
|
|
146
|
+
const path = agentId ? `/identity?agent=${encodeURIComponent(agentId)}` : "/identity";
|
|
147
|
+
const { data } = await serverGet(path, IDENTITY_TIMEOUT_MS);
|
|
145
148
|
if (!data)
|
|
146
149
|
return null;
|
|
147
|
-
return (0,
|
|
150
|
+
return (0, learnings_identity_js_1.gateAndRenderIdentity)(data, THIS_HARNESS, { requireAgentEcho: agentId !== null });
|
|
148
151
|
}
|
|
149
152
|
/**
|
|
150
153
|
* Fetch /lessons and build the `## Hicortex Lessons` block, or null on any
|
|
151
154
|
* failure or when no lessons survive selection. Preserves the pre-0.13 lesson
|
|
152
|
-
* output; the caller prepends the `##
|
|
155
|
+
* output; the caller prepends the `## Identity` block and adds separators.
|
|
153
156
|
*/
|
|
154
157
|
async function buildLessonsBlock(project) {
|
|
155
158
|
const { data } = await serverGet("/lessons", LESSONS_TIMEOUT_MS);
|
|
@@ -248,7 +251,7 @@ function formatToolResults(results) {
|
|
|
248
251
|
return { content: [{ type: "text", text: "No memories found." }] };
|
|
249
252
|
}
|
|
250
253
|
const text = results
|
|
251
|
-
.map((r) => `[${r.memory_type}] (score: ${r.score.toFixed(3)}, strength: ${r.effective_strength.toFixed(3)}) ${r.content.slice(0, 500)}`)
|
|
254
|
+
.map((r) => `[${(0, type_labels_js_1.labelForType)(r.memory_type)}] (score: ${r.score.toFixed(3)}, strength: ${r.effective_strength.toFixed(3)}) ${r.content.slice(0, 500)}`)
|
|
252
255
|
.join("\n\n");
|
|
253
256
|
return { content: [{ type: "text", text }] };
|
|
254
257
|
}
|
|
@@ -284,15 +287,40 @@ exports.default = {
|
|
|
284
287
|
log(`[hicortex] Thin-client mode — server: ${serverUrl}`);
|
|
285
288
|
// License: init feature cache (only needs licenseKey, no DB access)
|
|
286
289
|
await (0, features_js_1.initFeatures)(config.licenseKey, hicortexHome);
|
|
287
|
-
// Verify server reachability at startup (non-fatal — warn only)
|
|
290
|
+
// Verify server reachability at startup (non-fatal — warn only).
|
|
291
|
+
// /health is the public minimal {status:"ok"} probe (#253) — fine for
|
|
292
|
+
// a liveness check. Diagnostics (version, memories) live on
|
|
293
|
+
// /health/detail; the OC plugin is authenticated by its server token,
|
|
294
|
+
// so use that path for the richer log line.
|
|
288
295
|
try {
|
|
289
|
-
|
|
296
|
+
// Authenticated diagnostics probe. Only send the Authorization
|
|
297
|
+
// header when a token actually exists — an empty `Bearer ` header
|
|
298
|
+
// is worse than absent (it can trip strict middlewares and signals
|
|
299
|
+
// a misconfigured client). When there's no token, fall straight to
|
|
300
|
+
// the public /health liveness probe below.
|
|
301
|
+
const headers = {};
|
|
302
|
+
if (config.authToken)
|
|
303
|
+
headers.Authorization = `Bearer ${config.authToken}`;
|
|
304
|
+
const resp = await fetch(`${serverUrl}/health/detail`, {
|
|
290
305
|
signal: AbortSignal.timeout(5000),
|
|
306
|
+
headers,
|
|
291
307
|
});
|
|
292
308
|
if (resp.ok) {
|
|
293
309
|
const data = await resp.json();
|
|
294
310
|
log(`[hicortex] Server OK: v${data.version}, ${data.memories} memories`);
|
|
295
311
|
}
|
|
312
|
+
else if (resp.status === 401) {
|
|
313
|
+
// Token-less fallback: the public probe confirms liveness; the
|
|
314
|
+
// diagnostic fields aren't available without auth. The public
|
|
315
|
+
// probe carries no version, so log reachability honestly rather
|
|
316
|
+
// than a fabricated "vok"/"vn/a".
|
|
317
|
+
const pub = await fetch(`${serverUrl}/health`, {
|
|
318
|
+
signal: AbortSignal.timeout(5000),
|
|
319
|
+
});
|
|
320
|
+
log(pub.ok
|
|
321
|
+
? `[hicortex] Server OK (reachable, diagnostics gated)`
|
|
322
|
+
: `[hicortex] Server returned HTTP ${pub.status} on public /health`);
|
|
323
|
+
}
|
|
296
324
|
else {
|
|
297
325
|
log(`[hicortex] Server returned HTTP ${resp.status} — capture and tools may fail`);
|
|
298
326
|
}
|
|
@@ -310,7 +338,7 @@ exports.default = {
|
|
|
310
338
|
},
|
|
311
339
|
});
|
|
312
340
|
// -----------------------------------------------------------------------
|
|
313
|
-
// Hook: before_agent_start — fetch
|
|
341
|
+
// Hook: before_agent_start — fetch identity + lessons + recall index
|
|
314
342
|
// (fail-soft). Fires per embedded run = per inbound message in OpenClaw,
|
|
315
343
|
// so the recall index rides the same hook as the per-turn surface.
|
|
316
344
|
// -----------------------------------------------------------------------
|
|
@@ -320,20 +348,20 @@ exports.default = {
|
|
|
320
348
|
// synchronous sanitize below runs before any per-fetch .catch — so the
|
|
321
349
|
// whole body is wrapped, not just the fetches.
|
|
322
350
|
try {
|
|
323
|
-
// Per-agent
|
|
324
|
-
// sanitizes to null → bare /
|
|
351
|
+
// Per-agent identity id: sanitize the OC agent id (a symbols-only id
|
|
352
|
+
// sanitizes to null → bare /identity → global set). Null id never sends
|
|
325
353
|
// ?agent=, so an old server behaves exactly as before.
|
|
326
|
-
const agentId = (0,
|
|
354
|
+
const agentId = (0, identity_store_js_1.sanitizeAgentId)(ctx?.agentId ?? "");
|
|
327
355
|
// Fetch all three concurrently with INDEPENDENT fail-soft: no block
|
|
328
|
-
// may ever cost another. Order in the injected
|
|
329
|
-
// (standing
|
|
356
|
+
// may ever cost another. Order in the injected output: `## Identity`
|
|
357
|
+
// (standing identity, 0.13) → `## Hicortex Lessons` → the per-turn
|
|
330
358
|
// `## Memory recall (auto)` index (#193, closest to the prompt).
|
|
331
|
-
const [
|
|
332
|
-
|
|
359
|
+
const [identityBlock, lessonsBlock, recallBlock] = await Promise.all([
|
|
360
|
+
fetchOcIdentityBlock(agentId).catch(() => null),
|
|
333
361
|
buildLessonsBlock(ctx?.project).catch(() => null),
|
|
334
362
|
fetchRecallIndexBlock(ctx?.sessionId, event?.prompt, ctx?.project).catch(() => null),
|
|
335
363
|
]);
|
|
336
|
-
const blocks = [
|
|
364
|
+
const blocks = [identityBlock, lessonsBlock, recallBlock].filter((b) => b !== null && b !== "");
|
|
337
365
|
if (blocks.length === 0)
|
|
338
366
|
return {};
|
|
339
367
|
return { appendSystemContext: `\n\n${blocks.join("\n\n")}\n` };
|
package/dist/init.d.ts
CHANGED
|
@@ -57,7 +57,8 @@ export declare function persistLlmConfig(configPath?: string): Promise<void>;
|
|
|
57
57
|
* and the writer then OVERWROTE the file — `persistAuthToken` minted a fresh
|
|
58
58
|
* token (fleet-wide 401), `scaffoldDefaultDomains` re-seeded the generic
|
|
59
59
|
* vocabulary over the owner list, etc. `authToken` / `licenseKey` /
|
|
60
|
-
* `llmApiKey` / `domains` / `weakPrimaryFloor` / `
|
|
60
|
+
* `llmApiKey` / `domains` / `weakPrimaryFloor` / `identityClients` (was
|
|
61
|
+
* `contextClients`) all gone.
|
|
61
62
|
* The early-return guards (existing-key checks) did NOT save them: those only
|
|
62
63
|
* fire on a VALID parse that reads the key, not on a corrupted file.
|
|
63
64
|
*
|
|
@@ -180,10 +181,10 @@ export declare function ensureAndPersistAgentId(configPath: string): {
|
|
|
180
181
|
generated: boolean;
|
|
181
182
|
};
|
|
182
183
|
/**
|
|
183
|
-
* Decide the per-agent
|
|
184
|
+
* Decide the per-agent identity id to persist at init (#179; CC default = global,
|
|
184
185
|
* owner decision 20.07.2026). `agentName` is an explicit opt-in only — there is
|
|
185
186
|
* NO hostname default, so an install with no `--agent-name` sends no `?agent=`
|
|
186
|
-
* and shares the global
|
|
187
|
+
* and shares the global identity (one user = one identity across machines).
|
|
187
188
|
*
|
|
188
189
|
* Empty string == unset everywhere: `--agent-name ""` (or whitespace-only) is
|
|
189
190
|
* the explicit way to opt BACK OUT — it CLEARS any existing `agentName` key and
|
|
@@ -289,11 +290,18 @@ export declare function getPackageSpec(configDir?: string): string;
|
|
|
289
290
|
*/
|
|
290
291
|
export declare function isEphemeralNpxPath(binPath: string): boolean;
|
|
291
292
|
/**
|
|
292
|
-
* Install (or verify) the CC SessionStart hook that runs
|
|
293
|
-
*
|
|
294
|
-
*
|
|
293
|
+
* Install (or verify) the CC SessionStart hook that runs the canonical command
|
|
294
|
+
* `hicortex learnings-identity` (aliased as the legacy `lessons-context`,
|
|
295
|
+
* #264). The hook fetches the identity layer + lessons from the configured
|
|
296
|
+
* server at session start and injects them as a Markdown block — replacing the
|
|
297
|
+
* old static CLAUDE.md block.
|
|
298
|
+
*
|
|
299
|
+
* Idempotent: skips if a SessionStart hook containing EITHER `learnings-identity`
|
|
300
|
+
* OR the legacy `lessons-context` already exists (so re-init never duplicates,
|
|
301
|
+
* whether the existing hook was written by a pre- or post-#264 install). It does
|
|
302
|
+
* NOT rewrite an existing legacy `lessons-context` hook — the alias keeps old
|
|
303
|
+
* installs working as-is.
|
|
295
304
|
*
|
|
296
|
-
* Idempotent: skips if a SessionStart hook containing "lessons-context" already exists.
|
|
297
305
|
* Uses JSON.parse/JSON.stringify to safely merge into ~/.claude/settings.json.
|
|
298
306
|
*
|
|
299
307
|
* @param settingsPath Override for the settings.json path (used in tests; defaults to CC_SETTINGS).
|
|
@@ -311,6 +319,23 @@ export declare function runInit(options?: {
|
|
|
311
319
|
agentName?: string;
|
|
312
320
|
repairConfig?: boolean;
|
|
313
321
|
}): Promise<void>;
|
|
322
|
+
/**
|
|
323
|
+
* Resolve the timer-jitter spread (seconds) from config. 0 = disabled. Uses
|
|
324
|
+
* readNonNegativeConfig (0 is a valid "off", mirroring ollamaFlushEvery).
|
|
325
|
+
*/
|
|
326
|
+
export declare function resolveTimerJitterSeconds(configDir?: string): number;
|
|
327
|
+
/**
|
|
328
|
+
* #256 — per-install randomized Minute offset for the launchd consolidation
|
|
329
|
+
* plist. launchd has no native `RandomizedDelaySec`; the idiomatic equivalent
|
|
330
|
+
* is a per-job `Minute` shift baked into each `StartCalendarInterval` dict.
|
|
331
|
+
* The value is generated ONCE at init time so the plist is stable across
|
|
332
|
+
* reboots (no flapping), and the SAME offset applies to every slot so the
|
|
333
|
+
* relative spacing between slots is preserved.
|
|
334
|
+
*
|
|
335
|
+
* `jitterSec` < 60 → 0 (cannot span a minute). Inject `rand` for deterministic
|
|
336
|
+
* tests; defaults to Math.random (init-time generation, not the hot path).
|
|
337
|
+
*/
|
|
338
|
+
export declare function randomMinuteOffset(jitterSec: number, rand?: () => number): number;
|
|
314
339
|
/**
|
|
315
340
|
* Resolve the CONSOLIDATION hours (the only slot-based timer in 0.17). The
|
|
316
341
|
* CAPTURE mechanism is the watchdog (an interval timer, not slots) — uniform
|
|
@@ -338,5 +363,16 @@ export declare function formatOnCalendarLines(hours: number[]): string;
|
|
|
338
363
|
* The launchd `StartCalendarInterval` ARRAY body — one `<dict>` per hour.
|
|
339
364
|
* launchd fires the job at each dict; a single dict is the 1-slot special case
|
|
340
365
|
* but the array form is uniform across 1..N. Exported for testing.
|
|
366
|
+
*
|
|
367
|
+
* `minuteOffset` (#256): a per-install randomized Minute applied uniformly to
|
|
368
|
+
* every slot (0 = no offset = pre-#256 behaviour). See `randomMinuteOffset`.
|
|
369
|
+
*/
|
|
370
|
+
export declare function formatLaunchdIntervals(hours: number[], minuteOffset?: number): string;
|
|
371
|
+
/**
|
|
372
|
+
* The systemd `[Timer]` body (everything between `[Timer]` and the next stanza).
|
|
373
|
+
* Exported for testing the #256 jitter line. `isInterval` selects the watchdog
|
|
374
|
+
* poll form (OnBootSec + OnUnitActiveSec); otherwise one `OnCalendar` line per
|
|
375
|
+
* hour. When `jitterSec > 0` a single `RandomizedDelaySec=<n>` is appended — it
|
|
376
|
+
* applies to every OnCalendar entry AND to OnUnitActiveSec (systemd semantics).
|
|
341
377
|
*/
|
|
342
|
-
export declare function
|
|
378
|
+
export declare function formatSystemdTimerBody(isInterval: boolean, intervalSec: number, hours: number[], jitterSec: number): string;
|
package/dist/init.js
CHANGED
|
@@ -37,10 +37,13 @@ exports.isEphemeralNpxPath = isEphemeralNpxPath;
|
|
|
37
37
|
exports.installSessionStartHook = installSessionStartHook;
|
|
38
38
|
exports.installRecallHooks = installRecallHooks;
|
|
39
39
|
exports.runInit = runInit;
|
|
40
|
+
exports.resolveTimerJitterSeconds = resolveTimerJitterSeconds;
|
|
41
|
+
exports.randomMinuteOffset = randomMinuteOffset;
|
|
40
42
|
exports.resolveConsolidationHours = resolveConsolidationHours;
|
|
41
43
|
exports.resolveNightlyHour = resolveNightlyHour;
|
|
42
44
|
exports.formatOnCalendarLines = formatOnCalendarLines;
|
|
43
45
|
exports.formatLaunchdIntervals = formatLaunchdIntervals;
|
|
46
|
+
exports.formatSystemdTimerBody = formatSystemdTimerBody;
|
|
44
47
|
const paths_js_1 = require("./paths.js");
|
|
45
48
|
const telemetry_js_1 = require("./telemetry.js");
|
|
46
49
|
const node_fs_1 = require("node:fs");
|
|
@@ -51,7 +54,7 @@ const node_readline_1 = require("node:readline");
|
|
|
51
54
|
const node_crypto_1 = require("node:crypto");
|
|
52
55
|
const claude_md_js_1 = require("./claude-md.js");
|
|
53
56
|
const config_read_js_1 = require("./config-read.js");
|
|
54
|
-
const
|
|
57
|
+
const identity_store_js_1 = require("./identity-store.js");
|
|
55
58
|
const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
|
|
56
59
|
/** This package's version, for the install lifecycle ping (0.15.2). */
|
|
57
60
|
function pkgVersion() {
|
|
@@ -85,9 +88,11 @@ async function detect() {
|
|
|
85
88
|
hermesFound: false,
|
|
86
89
|
existingDb: false,
|
|
87
90
|
};
|
|
88
|
-
// Check local server
|
|
91
|
+
// Check local server. /health/detail carries the diagnostics (memories,
|
|
92
|
+
// version, llm) — /health itself is the public minimal {status:"ok"} probe
|
|
93
|
+
// (#253). localhost bypasses auth, so co-located detect gets the fields.
|
|
89
94
|
try {
|
|
90
|
-
const resp = await fetch(`http://127.0.0.1:${DEFAULT_PORT}/health`, {
|
|
95
|
+
const resp = await fetch(`http://127.0.0.1:${DEFAULT_PORT}/health/detail`, {
|
|
91
96
|
signal: AbortSignal.timeout(2000),
|
|
92
97
|
});
|
|
93
98
|
if (resp.ok) {
|
|
@@ -98,7 +103,13 @@ async function detect() {
|
|
|
98
103
|
}
|
|
99
104
|
}
|
|
100
105
|
catch { /* not running */ }
|
|
101
|
-
// Check remote server (env var)
|
|
106
|
+
// Check remote server (env var). detect() runs BEFORE any config/token
|
|
107
|
+
// exists, so this MUST use the PUBLIC /health probe (liveness only) —
|
|
108
|
+
// /health/detail sits behind the auth middleware and would 401 on any
|
|
109
|
+
// authed remote server, making a healthy remote look "unreachable" and
|
|
110
|
+
// silently mis-routing the install branch (#253 CR fix). The memory count
|
|
111
|
+
// is not available on the public probe; the local-server path above still
|
|
112
|
+
// gets it via localhost-bypassed /health/detail.
|
|
102
113
|
const remoteUrl = process.env.HICORTEX_SERVER_URL;
|
|
103
114
|
if (remoteUrl && !result.localServer) {
|
|
104
115
|
try {
|
|
@@ -108,8 +119,6 @@ async function detect() {
|
|
|
108
119
|
if (resp.ok) {
|
|
109
120
|
result.remoteServer = true;
|
|
110
121
|
result.remoteServerUrl = remoteUrl;
|
|
111
|
-
const data = await resp.json();
|
|
112
|
-
result.memoryCount = data.memories;
|
|
113
122
|
}
|
|
114
123
|
}
|
|
115
124
|
catch { /* not reachable */ }
|
|
@@ -734,7 +743,8 @@ function saveConfig(configPath, config) {
|
|
|
734
743
|
* and the writer then OVERWROTE the file — `persistAuthToken` minted a fresh
|
|
735
744
|
* token (fleet-wide 401), `scaffoldDefaultDomains` re-seeded the generic
|
|
736
745
|
* vocabulary over the owner list, etc. `authToken` / `licenseKey` /
|
|
737
|
-
* `llmApiKey` / `domains` / `weakPrimaryFloor` / `
|
|
746
|
+
* `llmApiKey` / `domains` / `weakPrimaryFloor` / `identityClients` (was
|
|
747
|
+
* `contextClients`) all gone.
|
|
738
748
|
* The early-return guards (existing-key checks) did NOT save them: those only
|
|
739
749
|
* fire on a VALID parse that reads the key, not on a corrupted file.
|
|
740
750
|
*
|
|
@@ -931,10 +941,10 @@ function ensureAndPersistAgentId(configPath) {
|
|
|
931
941
|
return result;
|
|
932
942
|
}
|
|
933
943
|
/**
|
|
934
|
-
* Decide the per-agent
|
|
944
|
+
* Decide the per-agent identity id to persist at init (#179; CC default = global,
|
|
935
945
|
* owner decision 20.07.2026). `agentName` is an explicit opt-in only — there is
|
|
936
946
|
* NO hostname default, so an install with no `--agent-name` sends no `?agent=`
|
|
937
|
-
* and shares the global
|
|
947
|
+
* and shares the global identity (one user = one identity across machines).
|
|
938
948
|
*
|
|
939
949
|
* Empty string == unset everywhere: `--agent-name ""` (or whitespace-only) is
|
|
940
950
|
* the explicit way to opt BACK OUT — it CLEARS any existing `agentName` key and
|
|
@@ -952,12 +962,12 @@ function decideAgentName(existing, flag) {
|
|
|
952
962
|
// Explicit empty / whitespace-only value → clear back to global (unset).
|
|
953
963
|
if (flag.trim() === "")
|
|
954
964
|
return { write: false, value: null, clear: true };
|
|
955
|
-
const s = (0,
|
|
965
|
+
const s = (0, identity_store_js_1.sanitizeAgentId)(flag);
|
|
956
966
|
if (s === null) {
|
|
957
967
|
return {
|
|
958
968
|
write: false,
|
|
959
969
|
value: null,
|
|
960
|
-
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
|
|
970
|
+
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 identity).`,
|
|
961
971
|
};
|
|
962
972
|
}
|
|
963
973
|
return { write: true, value: s };
|
|
@@ -1176,17 +1186,24 @@ function resolveBinaryArgs() {
|
|
|
1176
1186
|
return [npxPath, "-y", packageSpec];
|
|
1177
1187
|
}
|
|
1178
1188
|
/**
|
|
1179
|
-
* Install (or verify) the CC SessionStart hook that runs
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1189
|
+
* Install (or verify) the CC SessionStart hook that runs the canonical command
|
|
1190
|
+
* `hicortex learnings-identity` (aliased as the legacy `lessons-context`,
|
|
1191
|
+
* #264). The hook fetches the identity layer + lessons from the configured
|
|
1192
|
+
* server at session start and injects them as a Markdown block — replacing the
|
|
1193
|
+
* old static CLAUDE.md block.
|
|
1194
|
+
*
|
|
1195
|
+
* Idempotent: skips if a SessionStart hook containing EITHER `learnings-identity`
|
|
1196
|
+
* OR the legacy `lessons-context` already exists (so re-init never duplicates,
|
|
1197
|
+
* whether the existing hook was written by a pre- or post-#264 install). It does
|
|
1198
|
+
* NOT rewrite an existing legacy `lessons-context` hook — the alias keeps old
|
|
1199
|
+
* installs working as-is.
|
|
1182
1200
|
*
|
|
1183
|
-
* Idempotent: skips if a SessionStart hook containing "lessons-context" already exists.
|
|
1184
1201
|
* Uses JSON.parse/JSON.stringify to safely merge into ~/.claude/settings.json.
|
|
1185
1202
|
*
|
|
1186
1203
|
* @param settingsPath Override for the settings.json path (used in tests; defaults to CC_SETTINGS).
|
|
1187
1204
|
*/
|
|
1188
1205
|
function installSessionStartHook(settingsPath) {
|
|
1189
|
-
installCcHook("SessionStart", "
|
|
1206
|
+
installCcHook("SessionStart", "learnings-identity", 10, settingsPath, ["lessons-context"]);
|
|
1190
1207
|
}
|
|
1191
1208
|
/**
|
|
1192
1209
|
* Install (or verify) the #192 pushed-recall hooks: `hicortex recall-hook`
|
|
@@ -1201,14 +1218,17 @@ function installRecallHooks(settingsPath) {
|
|
|
1201
1218
|
/**
|
|
1202
1219
|
* Shared CC-hook installer: add `hicortex <subcommand>` under the given hook
|
|
1203
1220
|
* event in ~/.claude/settings.json. Idempotent per (event, subcommand): skips
|
|
1204
|
-
* if any existing entry for that event already runs the subcommand
|
|
1205
|
-
* is
|
|
1206
|
-
*
|
|
1221
|
+
* if any existing entry for that event already runs the subcommand — or, when
|
|
1222
|
+
* `aliases` is passed, ANY of the alias names. The alias match is what lets a
|
|
1223
|
+
* post-#264 install recognize a pre-#264 `lessons-context` hook as "already
|
|
1224
|
+
* installed" without rewriting it (the legacy name keeps working via
|
|
1225
|
+
* resolveCommandAlias). `timeout` is CC's hook-process kill timeout in SECONDS
|
|
1226
|
+
* (the network timeout inside the command is separate and shorter).
|
|
1207
1227
|
*/
|
|
1208
|
-
function installCcHook(eventName, subcommand, timeout, settingsPath) {
|
|
1228
|
+
function installCcHook(eventName, subcommand, timeout, settingsPath, aliases = []) {
|
|
1209
1229
|
const targetPath = settingsPath ?? CC_SETTINGS;
|
|
1210
1230
|
const binaryArgs = resolveBinaryArgs();
|
|
1211
|
-
// e.g. "/path/to/hicortex
|
|
1231
|
+
// e.g. "/path/to/hicortex learnings-identity" or "npx -y @gamaze/hicortex recall-hook"
|
|
1212
1232
|
const command = [...binaryArgs, subcommand].join(" ");
|
|
1213
1233
|
let settings = {};
|
|
1214
1234
|
if ((0, node_fs_1.existsSync)(targetPath)) {
|
|
@@ -1232,10 +1252,13 @@ function installCcHook(eventName, subcommand, timeout, settingsPath) {
|
|
|
1232
1252
|
hooks[eventName] = [];
|
|
1233
1253
|
}
|
|
1234
1254
|
const entries = hooks[eventName];
|
|
1235
|
-
// Idempotent: skip if any existing entry's command runs this subcommand
|
|
1236
|
-
//
|
|
1237
|
-
// "recall-hook
|
|
1238
|
-
|
|
1255
|
+
// Idempotent: skip if any existing entry's command runs this subcommand OR
|
|
1256
|
+
// one of its aliases (e.g. a pre-#264 `lessons-context` hook when installing
|
|
1257
|
+
// the canonical `learnings-identity`). Word-boundary guard so "recall-hook"
|
|
1258
|
+
// never matches a hypothetical "recall-hook-foo" command, and so the
|
|
1259
|
+
// `learnings-identity`/`lessons-context` pair are distinct (no prefix clash).
|
|
1260
|
+
const names = [subcommand, ...aliases];
|
|
1261
|
+
const subcommandRe = new RegExp(`(^|\\s)(?:${names.map(escapeRegex).join("|")})(\\s|$)`);
|
|
1239
1262
|
const alreadyInstalled = entries.some((entry) => {
|
|
1240
1263
|
if (typeof entry !== "object" || entry === null)
|
|
1241
1264
|
return false;
|
|
@@ -1262,6 +1285,14 @@ function installCcHook(eventName, subcommand, timeout, settingsPath) {
|
|
|
1262
1285
|
(0, node_fs_1.writeFileSync)(targetPath, JSON.stringify(settings, null, 2));
|
|
1263
1286
|
console.log(` ✓ Installed ${eventName} hook: ${command}`);
|
|
1264
1287
|
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Escape a literal string for safe embedding in a RegExp (alias names like
|
|
1290
|
+
* `lessons-context` happen to be regex-safe, but escape anyway so the
|
|
1291
|
+
* idempotency check can never break if a future alias contains a metachar).
|
|
1292
|
+
*/
|
|
1293
|
+
function escapeRegex(s) {
|
|
1294
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1295
|
+
}
|
|
1265
1296
|
function installLaunchd(binaryArgs) {
|
|
1266
1297
|
const plistDir = (0, node_path_1.join)((0, node_os_1.homedir)(), "Library", "LaunchAgents");
|
|
1267
1298
|
const plistPath = (0, node_path_1.join)(plistDir, "com.gamaze.hicortex.plist");
|
|
@@ -1469,9 +1500,9 @@ async function runInit(options = {}) {
|
|
|
1469
1500
|
// Classification activates automatically once an LLM is configured; until
|
|
1470
1501
|
// then domains sit inert (strict-skip path).
|
|
1471
1502
|
scaffoldDefaultDomains(configPath);
|
|
1472
|
-
// Per-agent
|
|
1503
|
+
// Per-agent identity id (#179): server mode writes it ONLY when the operator
|
|
1473
1504
|
// passes --agent-name. Without the flag no agentName is written and the
|
|
1474
|
-
// co-located CC shares the global
|
|
1505
|
+
// co-located CC shares the global identity (global by default). Explicit flag
|
|
1475
1506
|
// overwrites on re-init; `--agent-name ""` clears it back to global.
|
|
1476
1507
|
if (options.agentName !== undefined) {
|
|
1477
1508
|
const decision = decideAgentName(undefined, options.agentName);
|
|
@@ -1481,7 +1512,7 @@ async function runInit(options = {}) {
|
|
|
1481
1512
|
}
|
|
1482
1513
|
if (decision.clear) {
|
|
1483
1514
|
clearAgentNameConfig(configPath);
|
|
1484
|
-
console.log(" ✓ Agent name cleared — global
|
|
1515
|
+
console.log(" ✓ Agent name cleared — global identity");
|
|
1485
1516
|
}
|
|
1486
1517
|
else if (decision.write && decision.value) {
|
|
1487
1518
|
writeAgentNameConfig(configPath, decision.value);
|
|
@@ -1631,7 +1662,7 @@ async function runClientInit(serverUrl, agentName) {
|
|
|
1631
1662
|
// Step 3: Save client config
|
|
1632
1663
|
(0, node_fs_1.mkdirSync)(HICORTEX_HOME, { recursive: true });
|
|
1633
1664
|
const configPath = (0, node_path_1.join)(HICORTEX_HOME, "config.json");
|
|
1634
|
-
// Per-agent
|
|
1665
|
+
// Per-agent identity id (#179): explicit opt-in only. resolve the flag via
|
|
1635
1666
|
// decideAgentName (it owns the process.exit on an invalid --agent-name). A
|
|
1636
1667
|
// no-flag run yields a {write:false} decision → writeClientConfig leaves any
|
|
1637
1668
|
// existing agentName untouched (preserving what the loaded config carries).
|
|
@@ -1641,7 +1672,7 @@ async function runClientInit(serverUrl, agentName) {
|
|
|
1641
1672
|
process.exit(1);
|
|
1642
1673
|
}
|
|
1643
1674
|
if (nameDecision.clear) {
|
|
1644
|
-
console.log(" ✓ Agent name cleared — global
|
|
1675
|
+
console.log(" ✓ Agent name cleared — global identity");
|
|
1645
1676
|
}
|
|
1646
1677
|
else if (nameDecision.write && nameDecision.value && agentName && nameDecision.value !== agentName) {
|
|
1647
1678
|
console.log(` ℹ Agent name sanitized to '${nameDecision.value}'`);
|
|
@@ -1718,7 +1749,7 @@ async function runClientInit(serverUrl, agentName) {
|
|
|
1718
1749
|
// failures are swallowed inside sendLifecycleEvent.
|
|
1719
1750
|
await (0, telemetry_js_1.sendLifecycleEvent)("install", HICORTEX_HOME, readHomeConfig(HICORTEX_HOME), pkgVersion());
|
|
1720
1751
|
console.log("How it works:");
|
|
1721
|
-
console.log(" • MCP tools (search,
|
|
1752
|
+
console.log(" • MCP tools (search, identity, ingest) talk to the remote server");
|
|
1722
1753
|
console.log(" • Nightly pipeline denoises CC transcripts, POSTs to server for distillation");
|
|
1723
1754
|
console.log(" • Lessons fetched live at each CC session start (SessionStart hook)");
|
|
1724
1755
|
console.log(" • No local database — all memories stored on the server");
|
|
@@ -1755,6 +1786,54 @@ async function runClientInit(serverUrl, agentName) {
|
|
|
1755
1786
|
* only when `consolidationHours` is absent (preserves "one daily job at H").
|
|
1756
1787
|
*/
|
|
1757
1788
|
const DEFAULT_CONSOLIDATION_HOURS = [10, 22];
|
|
1789
|
+
/**
|
|
1790
|
+
* #256 — timer jitter default (seconds). Applied to newly-generated timers so a
|
|
1791
|
+
* fleet of installs on the same default schedule doesn't all hit the LLM
|
|
1792
|
+
* backend at the same minute (thundering-herd avoidance). systemd emits this as
|
|
1793
|
+
* `RandomizedDelaySec`; launchd has no native equivalent so the launchd path
|
|
1794
|
+
* bakes a per-install randomized `Minute` offset into each
|
|
1795
|
+
* `StartCalendarInterval` dict (generated once at init, stable across reboots).
|
|
1796
|
+
*
|
|
1797
|
+
* 3600s = 1h spread on a 2-slot/day consolidation cadence = up to ±30 min around
|
|
1798
|
+
* each slot — enough to flatten the peak without stretching into the next slot
|
|
1799
|
+
* window. Tunable via `timerJitterSeconds` (0 disables). NOTE: only affects
|
|
1800
|
+
* NEWLY generated timers; re-init rewrites the unit files (the 0.17 migration
|
|
1801
|
+
* decision), so an explicit re-init is how an existing install adopts jitter.
|
|
1802
|
+
*/
|
|
1803
|
+
const DEFAULT_TIMER_JITTER_SEC = 3600;
|
|
1804
|
+
/**
|
|
1805
|
+
* Resolve the timer-jitter spread (seconds) from config. 0 = disabled. Uses
|
|
1806
|
+
* readNonNegativeConfig (0 is a valid "off", mirroring ollamaFlushEvery).
|
|
1807
|
+
*/
|
|
1808
|
+
function resolveTimerJitterSeconds(configDir = HICORTEX_HOME) {
|
|
1809
|
+
let config = {};
|
|
1810
|
+
try {
|
|
1811
|
+
config = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(configDir, "config.json"), "utf-8"));
|
|
1812
|
+
}
|
|
1813
|
+
catch { /* no config yet — use the default */ }
|
|
1814
|
+
// Floor to an integer for a clean contract (systemd accepts fractional
|
|
1815
|
+
// seconds, but a whole-second value is unambiguous across systemd + launchd).
|
|
1816
|
+
return Math.floor((0, config_read_js_1.readNonNegativeConfig)(config, "timerJitterSeconds", DEFAULT_TIMER_JITTER_SEC));
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* #256 — per-install randomized Minute offset for the launchd consolidation
|
|
1820
|
+
* plist. launchd has no native `RandomizedDelaySec`; the idiomatic equivalent
|
|
1821
|
+
* is a per-job `Minute` shift baked into each `StartCalendarInterval` dict.
|
|
1822
|
+
* The value is generated ONCE at init time so the plist is stable across
|
|
1823
|
+
* reboots (no flapping), and the SAME offset applies to every slot so the
|
|
1824
|
+
* relative spacing between slots is preserved.
|
|
1825
|
+
*
|
|
1826
|
+
* `jitterSec` < 60 → 0 (cannot span a minute). Inject `rand` for deterministic
|
|
1827
|
+
* tests; defaults to Math.random (init-time generation, not the hot path).
|
|
1828
|
+
*/
|
|
1829
|
+
function randomMinuteOffset(jitterSec, rand = Math.random) {
|
|
1830
|
+
if (!Number.isFinite(jitterSec) || jitterSec < 60)
|
|
1831
|
+
return 0;
|
|
1832
|
+
const max = Math.min(59, Math.floor(jitterSec / 60));
|
|
1833
|
+
if (max <= 0)
|
|
1834
|
+
return 0;
|
|
1835
|
+
return Math.floor(rand() * (max + 1));
|
|
1836
|
+
}
|
|
1758
1837
|
/**
|
|
1759
1838
|
* Capture-watchdog poll interval (minutes). The capture timer fires
|
|
1760
1839
|
* `nightly --capture-only --watchdog` this often; the watchdog itself throttles
|
|
@@ -1826,18 +1905,36 @@ function formatOnCalendarLines(hours) {
|
|
|
1826
1905
|
* The launchd `StartCalendarInterval` ARRAY body — one `<dict>` per hour.
|
|
1827
1906
|
* launchd fires the job at each dict; a single dict is the 1-slot special case
|
|
1828
1907
|
* but the array form is uniform across 1..N. Exported for testing.
|
|
1908
|
+
*
|
|
1909
|
+
* `minuteOffset` (#256): a per-install randomized Minute applied uniformly to
|
|
1910
|
+
* every slot (0 = no offset = pre-#256 behaviour). See `randomMinuteOffset`.
|
|
1829
1911
|
*/
|
|
1830
|
-
function formatLaunchdIntervals(hours) {
|
|
1912
|
+
function formatLaunchdIntervals(hours, minuteOffset = 0) {
|
|
1913
|
+
const minute = Math.max(0, Math.min(59, Math.floor(minuteOffset)));
|
|
1831
1914
|
return [...hours]
|
|
1832
1915
|
.sort((a, b) => a - b)
|
|
1833
1916
|
.map((h) => ` <dict>
|
|
1834
1917
|
<key>Hour</key>
|
|
1835
1918
|
<integer>${h}</integer>
|
|
1836
1919
|
<key>Minute</key>
|
|
1837
|
-
<integer
|
|
1920
|
+
<integer>${minute}</integer>
|
|
1838
1921
|
</dict>`)
|
|
1839
1922
|
.join("\n");
|
|
1840
1923
|
}
|
|
1924
|
+
/**
|
|
1925
|
+
* The systemd `[Timer]` body (everything between `[Timer]` and the next stanza).
|
|
1926
|
+
* Exported for testing the #256 jitter line. `isInterval` selects the watchdog
|
|
1927
|
+
* poll form (OnBootSec + OnUnitActiveSec); otherwise one `OnCalendar` line per
|
|
1928
|
+
* hour. When `jitterSec > 0` a single `RandomizedDelaySec=<n>` is appended — it
|
|
1929
|
+
* applies to every OnCalendar entry AND to OnUnitActiveSec (systemd semantics).
|
|
1930
|
+
*/
|
|
1931
|
+
function formatSystemdTimerBody(isInterval, intervalSec, hours, jitterSec) {
|
|
1932
|
+
const base = isInterval
|
|
1933
|
+
? `OnBootSec=2min\nOnUnitActiveSec=${Math.round(intervalSec / 60)}min`
|
|
1934
|
+
: formatOnCalendarLines(hours);
|
|
1935
|
+
const jitter = jitterSec > 0 ? `\nRandomizedDelaySec=${jitterSec}` : "";
|
|
1936
|
+
return base + jitter;
|
|
1937
|
+
}
|
|
1841
1938
|
/**
|
|
1842
1939
|
* Write + enable one schedule unit (timer + service on Linux, plist on macOS),
|
|
1843
1940
|
* multi-slot. Shared by the capture and consolidation installers. Always
|
|
@@ -1873,9 +1970,17 @@ function writeScheduleUnit(opts) {
|
|
|
1873
1970
|
// gets a first capture tick on load (~parity with systemd's OnBootSec=2min),
|
|
1874
1971
|
// not 20 min later. The cooldown gate makes a load-time fire a cheap no-op
|
|
1875
1972
|
// if a capture ran recently.
|
|
1973
|
+
//
|
|
1974
|
+
// #256 — jitter: launchd has no native RandomizedDelaySec, so the slot
|
|
1975
|
+
// plist bakes a per-install randomized Minute offset into every dict
|
|
1976
|
+
// (generated here, stable across reboots). The interval/watchdog plist
|
|
1977
|
+
// keeps its StartInterval untouched — interval timers drift naturally and
|
|
1978
|
+
// don't share the fixed-slot thundering-herd risk.
|
|
1979
|
+
const jitterSec = opts.jitterSec ?? resolveTimerJitterSeconds();
|
|
1980
|
+
const minuteOffset = !isInterval ? randomMinuteOffset(jitterSec) : 0;
|
|
1876
1981
|
const scheduleBlock = isInterval
|
|
1877
1982
|
? ` <key>StartInterval</key>\n <integer>${opts.intervalSec}</integer>\n <key>RunAtLoad</key>\n <true/>`
|
|
1878
|
-
: ` <key>StartCalendarInterval</key>\n <array>\n${formatLaunchdIntervals(opts.hours)}\n </array>`;
|
|
1983
|
+
: ` <key>StartCalendarInterval</key>\n <array>\n${formatLaunchdIntervals(opts.hours, minuteOffset)}\n </array>`;
|
|
1879
1984
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1880
1985
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1881
1986
|
<plist version="1.0">
|
|
@@ -1933,9 +2038,9 @@ Environment=HOME=${(0, node_os_1.homedir)()}
|
|
|
1933
2038
|
WorkingDirectory=${(0, node_os_1.homedir)()}`;
|
|
1934
2039
|
// Timer body: OnUnitActiveSec (interval, watchdog) or one OnCalendar line
|
|
1935
2040
|
// per hour (multi-slot). systemd ORs multiple OnCalendar entries.
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
2041
|
+
// #256 — a single RandomizedDelaySec=<jitterSec> applies to all entries.
|
|
2042
|
+
const jitterSec = opts.jitterSec ?? resolveTimerJitterSeconds();
|
|
2043
|
+
const timerBody = formatSystemdTimerBody(isInterval, opts.intervalSec ?? 0, opts.hours ?? [], jitterSec);
|
|
1939
2044
|
const timer = `[Unit]
|
|
1940
2045
|
Description=${opts.timerDesc}
|
|
1941
2046
|
|