@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.
Files changed (51) hide show
  1. package/README.md +30 -28
  2. package/assets/dashboard.html +121 -5
  3. package/assets/{context.html → identity.html} +18 -18
  4. package/assets/viz.html +19 -7
  5. package/dist/claude-md.d.ts +2 -1
  6. package/dist/claude-md.js +2 -1
  7. package/dist/cli-args.d.ts +9 -0
  8. package/dist/cli-args.js +16 -0
  9. package/dist/cli.js +29 -20
  10. package/dist/consolidate.d.ts +15 -0
  11. package/dist/consolidate.js +30 -3
  12. package/dist/dashboard.d.ts +58 -1
  13. package/dist/dashboard.js +27 -1
  14. package/dist/extensions.d.ts +1 -1
  15. package/dist/extensions.js +1 -1
  16. package/dist/health.d.ts +68 -0
  17. package/dist/health.js +73 -0
  18. package/dist/identity-cli.d.ts +90 -0
  19. package/dist/{context-cli.js → identity-cli.js} +66 -48
  20. package/dist/{context-store.d.ts → identity-store.d.ts} +94 -31
  21. package/dist/{context-store.js → identity-store.js} +212 -71
  22. package/dist/index.d.ts +12 -5
  23. package/dist/index.js +57 -29
  24. package/dist/init.d.ts +44 -8
  25. package/dist/init.js +142 -37
  26. package/dist/learnings-identity.d.ts +149 -0
  27. package/dist/{lessons-context.js → learnings-identity.js} +96 -52
  28. package/dist/mcp-server.d.ts +2 -0
  29. package/dist/mcp-server.js +217 -68
  30. package/dist/memory-instructions.d.ts +6 -6
  31. package/dist/memory-instructions.js +6 -6
  32. package/dist/nightly.js +65 -6
  33. package/dist/paths.js +1 -1
  34. package/dist/recall-hook-cli.d.ts +1 -1
  35. package/dist/recall-hook-cli.js +3 -3
  36. package/dist/recall-index.js +5 -2
  37. package/dist/status.d.ts +2 -2
  38. package/dist/status.js +11 -9
  39. package/dist/telemetry.d.ts +10 -0
  40. package/dist/type-classify.js +4 -1
  41. package/dist/type-labels.d.ts +30 -0
  42. package/dist/type-labels.js +43 -0
  43. package/dist/types.d.ts +28 -0
  44. package/dist/uninstall.d.ts +12 -0
  45. package/dist/uninstall.js +21 -3
  46. package/dist/viz.d.ts +24 -11
  47. package/dist/viz.js +97 -32
  48. package/hermes-plugin/hicortex/README.md +4 -2
  49. package/package.json +2 -2
  50. package/dist/context-cli.d.ts +0 -69
  51. 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 /context + GET /lessons + POST /recall-index
14
- * (fail-soft, 3s timeout each, concurrent) → inject context. In OpenClaw
15
- * every inbound message spawns an embedded run, so this hook fires PER
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 context_store_js_1 = require("./context-store.js");
33
- const lessons_context_js_1 = require("./lessons-context.js");
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 CONTEXT_TIMEOUT_MS = 3000;
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 /context `clients`. */
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
- // Context layer (L2) — per-agent standing context (0.13)
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 /context (per-agent when an id is supplied) and build the
137
- * `## Context` block via the shared gate (gateAndRenderContext), or null when
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 fetchOcContextBlock(agentId) {
143
- const path = agentId ? `/context?agent=${encodeURIComponent(agentId)}` : "/context";
144
- const { data } = await serverGet(path, CONTEXT_TIMEOUT_MS);
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, lessons_context_js_1.gateAndRenderContext)(data, THIS_HARNESS, { requireAgentEcho: agentId !== null });
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 `## Context` block and adds separators.
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
- const resp = await fetch(`${serverUrl}/health`, {
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 context + lessons + recall index
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 context id: sanitize the OC agent id (a symbols-only id
324
- // sanitizes to null → bare /context → global set). Null id never sends
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, context_store_js_1.sanitizeAgentId)(ctx?.agentId ?? "");
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 context: `## Context`
329
- // (standing context, 0.13) → `## Hicortex Lessons` → the per-turn
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 [contextBlock, lessonsBlock, recallBlock] = await Promise.all([
332
- fetchOcContextBlock(agentId).catch(() => null),
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 = [contextBlock, lessonsBlock, recallBlock].filter((b) => b !== null && b !== "");
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` / `contextClients` all gone.
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 context id to persist at init (#179; CC default = global,
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 context (one user = one identity across machines).
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 `hicortex lessons-context`.
293
- * The hook fetches lessons from the configured server at session start and injects
294
- * them as context replacing the old static CLAUDE.md block.
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 formatLaunchdIntervals(hours: number[]): string;
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 context_store_js_1 = require("./context-store.js");
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` / `contextClients` all gone.
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 context id to persist at init (#179; CC default = global,
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 context (one user = one identity across machines).
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, context_store_js_1.sanitizeAgentId)(flag);
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 context).`,
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 `hicortex lessons-context`.
1180
- * The hook fetches lessons from the configured server at session start and injects
1181
- * them as context replacing the old static CLAUDE.md block.
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", "lessons-context", 10, settingsPath);
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. `timeout`
1205
- * is CC's hook-process kill timeout in SECONDS (the network timeout inside the
1206
- * command is separate and shorter).
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 lessons-context" or "npx -y @gamaze/hicortex recall-hook"
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
- // Word-boundary guard so "recall-hook" never matches a hypothetical
1237
- // "recall-hook-foo" command.
1238
- const subcommandRe = new RegExp(`(^|\\s)${subcommand}(\\s|$)`);
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 context id (#179): server mode writes it ONLY when the operator
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 context (global by default). Explicit flag
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 context");
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 context id (#179): explicit opt-in only. resolve the flag via
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 context");
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, context, ingest) talk to the remote server");
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>0</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
- const timerBody = isInterval
1937
- ? `OnBootSec=2min\nOnUnitActiveSec=${Math.round(opts.intervalSec / 60)}min`
1938
- : formatOnCalendarLines(opts.hours);
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