@gamaze/hicortex 0.13.3 → 0.14.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 CHANGED
@@ -66,8 +66,11 @@ The plugin connects to `http://127.0.0.1:8787` by default. For a remote server,
66
66
  | When | What | How |
67
67
  |------|------|-----|
68
68
  | Agent start | Standing context (`## Context`) + recent lessons fetched fresh and injected | CC SessionStart hook (calls `hicortex lessons-context`) / Hermes plugin `system_prompt_block` (+ `prefetch` recall) / OC `before_agent_start` hook |
69
+ | Every prompt (0.14) | A compact **recall index** of relevant memories is injected — one line per memory; the agent lazy-loads full content with `hicortex_get` only when needed | CC UserPromptSubmit hook (calls `hicortex recall-hook` → server `POST /recall-index`). Turn-based dedup per session; resets on new session/compaction. Fail-soft, 1 s timeout |
69
70
  | Nightly | Denoise sessions → POST /distill → server distills + embeds + stores → consolidate (score, reflect, link, decay) | Automatic pipeline — no manual steps |
70
71
 
72
+ **Exposure vs use (0.14):** appearing in the recall index only marks a memory as *shown* (it stops decaying while topically active); fetching it with `hicortex_get` marks it as *used* (durable strengthening). Memory importance is driven by what agents actually use, not by what was pushed at them.
73
+
71
74
  ## Memory Domains & Tags
72
75
 
73
76
  Domains are your top-level memory spheres — the handful of areas your life or work actually splits into. Every memory gets **multiple weighted tags** from your domain list plus one **primary** domain, so a memory that spans areas (a work project that touches your finances) lives in both instead of being forced into one bin. Domains drive the knowledge index, graph coloring, and lesson selection.
@@ -107,9 +110,10 @@ The run is resumable — interrupt it any time and it continues where it stopped
107
110
 
108
111
  ## Agent Tools (MCP)
109
112
 
110
- 8 tools available via MCP:
113
+ 9 tools available via MCP:
111
114
 
112
115
  - **hicortex_search** — Semantic search across all stored memories
116
+ - **hicortex_get** — Fetch one memory's full content by id (0.14) — the lazy-load counterpart of the recall index; fetching marks the memory as used
113
117
  - **hicortex_recent** — Get recent decisions and project state (queryless recall; renamed in 0.12)
114
118
  - **hicortex_ingest** — Store a memory directly
115
119
  - **hicortex_lessons** — Get actionable lessons from reflection
@@ -203,6 +207,14 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
203
207
  | `contextAgents` | Per-agent context modes (0.13): `{ "<id>": "override" \| "global" \| "off" }`. Absent + no `agents/<id>/` dir → every agent gets the global set. Boot-time (restart to apply) — see [Per-agent context](#per-agent-context-013) |
204
208
  | `agentName` | This install's per-agent context id sent as `?agent=`. **Unset by default** (CC shares the global context — no `?agent=` sent). Explicit opt-in via `init --agent-name <name>`; `init --agent-name ""` clears it. An empty/whitespace value equals unset |
205
209
  | `nightlyHour` | Local hour (0–23) for the nightly job installed by `init` (defaults: client 2, server 3). Applied on fresh installs; existing schedules are never overwritten |
210
+ | `decayHalfLifeDays` | Memory decay half-life in days at reference importance (default: 365). Larger = slower forgetting; importance, access, and links slow it further |
211
+ | `searchLimit` / `recentLimit` | Default result counts for search (8) and recent (12) |
212
+ | `recentWindowDays` | Candidate window for recent recall (default: 180) |
213
+ | `coldExposureSlots` | Top-k slots reservable for never-accessed memories so the long tail gets exposure (default: 2) |
214
+ | `recallMaxItems` | Max lines in the pushed recall index (default: 6) |
215
+ | `recallMinSimilarity` | Relevance floor for index entries (default: 0.55; text-search matches always pass) |
216
+ | `recallReshowTurns` | Turns before an already-shown memory may reappear in the same session (default: 30) |
217
+ | `recallMinPromptChars` | Prompts shorter than this skip the recall index (default: 20) |
206
218
  | `telemetry` | Anonymous usage telemetry, `false` to opt out |
207
219
 
208
220
  Full docs: [hicortex.gamaze.com/docs/configuration.html](https://hicortex.gamaze.com/docs/configuration.html)
@@ -214,6 +226,8 @@ Full docs: [hicortex.gamaze.com/docs/configuration.html](https://hicortex.gamaze
214
226
  | `/health` | GET | No | Server status, memory count, version |
215
227
  | `/distill` | POST | Yes | Canonical capture endpoint (0.9.0+). Accepts denoised session text (`text` string or `messages` array), distills server-side, stores. Used by both server-mode and client-mode nightly jobs. |
216
228
  | `/search` | GET | Yes | Semantic memory search |
229
+ | `/recall-index` | POST | Yes | Pushed recall index (0.14): `{session_id, prompt}` → compact one-line-per-memory block (or `null`); `{session_id, reset: true}` clears the session's dedup state. Appearing in the index marks memories *shown*, never *used* |
230
+ | `/memory` | GET | Yes | Fetch one memory by `?id=` (0.14). Marks it as used (strengthens) — the lazy-load counterpart of `/recall-index`. Response includes a server-rendered `citation` (id, date, origin agent) — agents are instructed to cite memories that shape their answers, so memory influence is always visible to the user (0.14.1) |
217
231
  | `/recent` | GET | Yes | Recent memories, queryless recall (renamed from `/context` in 0.12) |
218
232
  | `/context` | GET / PUT | Yes | Standing [context layer](#context-layer): read all sections / partial-upsert named sections. `?agent=<id>` selects a [per-agent scope](#per-agent-context-013) (server resolves override/global/off + merge); invalid id → 400. Recall-style query params on GET → 400 (use `/recent`) |
219
233
  | `/context/ui` | GET | No* | Web editor for the context layer (shell served without auth, like `/viz`; data via `/context`) |
package/dist/cli.js CHANGED
@@ -165,6 +165,17 @@ switch (command) {
165
165
  });
166
166
  });
167
167
  break;
168
+ case "recall-hook":
169
+ // CC UserPromptSubmit + SessionStart hook: pushed recall index (#192).
170
+ // Reads the CC hook payload from stdin, POSTs to the server, prints the
171
+ // index block (UserPromptSubmit) or resets session dedup (SessionStart).
172
+ // Fail-soft — any error = silent exit 0, never blocks a CC session.
173
+ import("./recall-hook-cli.js").then(({ runRecallHook }) => {
174
+ runRecallHook()
175
+ .then(() => process.exit(0))
176
+ .catch(() => process.exit(0));
177
+ }).catch(() => process.exit(0));
178
+ break;
168
179
  case "lessons-context":
169
180
  // CC SessionStart hook: fetch lessons from the configured server and print
170
181
  // a Markdown block to stdout. Fail-soft — any error = silent exit 0 so a
@@ -196,6 +207,7 @@ Commands:
196
207
  relink Resumable link-discovery pass over the ENTIRE corpus (server mode)
197
208
  classify-domains Backfill content-based domain tags over the corpus (server mode, needs config.domains)
198
209
  lessons-context Fetch lessons and print Markdown to stdout (CC SessionStart hook)
210
+ recall-hook Pushed recall index for the current prompt (CC UserPromptSubmit/SessionStart hook)
199
211
  context Standing context layer (show|edit) against the configured server
200
212
  status Show current configuration and stats
201
213
  uninstall Remove CC integration (preserves DB)
package/dist/db.js CHANGED
@@ -321,6 +321,20 @@ const MIGRATIONS = [
321
321
  `);
322
322
  },
323
323
  },
324
+ {
325
+ version: 8,
326
+ name: "add_shown_count",
327
+ up: (db) => {
328
+ // #192 recall alignment: exposure tracking separate from use. shown_count
329
+ // counts appearances in the pushed recall index (/recall-index), which
330
+ // refreshes last_accessed (mild strengthen: decay clock resets) but does
331
+ // NOT touch access_count — hardening, the prune shield, and the adoption
332
+ // metric (uses per showing) stay driven by real use only.
333
+ if (!hasColumn(db, "memories", "shown_count")) {
334
+ db.exec("ALTER TABLE memories ADD COLUMN shown_count INTEGER DEFAULT 0");
335
+ }
336
+ },
337
+ },
324
338
  ];
325
339
  /**
326
340
  * Run all pending migrations against the database.
package/dist/init.d.ts CHANGED
@@ -119,6 +119,13 @@ export declare function isEphemeralNpxPath(binPath: string): boolean;
119
119
  * @param settingsPath Override for the settings.json path (used in tests; defaults to CC_SETTINGS).
120
120
  */
121
121
  export declare function installSessionStartHook(settingsPath?: string): void;
122
+ /**
123
+ * Install (or verify) the #192 pushed-recall hooks: `hicortex recall-hook`
124
+ * under UserPromptSubmit (per-prompt recall index) AND under SessionStart
125
+ * (per-session dedup reset — the CLI dispatches on the payload's
126
+ * hook_event_name, so one command serves both events).
127
+ */
128
+ export declare function installRecallHooks(settingsPath?: string): void;
122
129
  export declare function runInit(options?: {
123
130
  serverUrl?: string;
124
131
  agentName?: string;
package/dist/init.js CHANGED
@@ -27,6 +27,7 @@ exports.decideAgentName = decideAgentName;
27
27
  exports.scaffoldDefaultDomains = scaffoldDefaultDomains;
28
28
  exports.isEphemeralNpxPath = isEphemeralNpxPath;
29
29
  exports.installSessionStartHook = installSessionStartHook;
30
+ exports.installRecallHooks = installRecallHooks;
30
31
  exports.runInit = runInit;
31
32
  exports.resolveNightlyHour = resolveNightlyHour;
32
33
  const paths_js_1 = require("./paths.js");
@@ -960,10 +961,30 @@ function resolveBinaryArgs() {
960
961
  * @param settingsPath Override for the settings.json path (used in tests; defaults to CC_SETTINGS).
961
962
  */
962
963
  function installSessionStartHook(settingsPath) {
964
+ installCcHook("SessionStart", "lessons-context", 10, settingsPath);
965
+ }
966
+ /**
967
+ * Install (or verify) the #192 pushed-recall hooks: `hicortex recall-hook`
968
+ * under UserPromptSubmit (per-prompt recall index) AND under SessionStart
969
+ * (per-session dedup reset — the CLI dispatches on the payload's
970
+ * hook_event_name, so one command serves both events).
971
+ */
972
+ function installRecallHooks(settingsPath) {
973
+ installCcHook("UserPromptSubmit", "recall-hook", 3, settingsPath);
974
+ installCcHook("SessionStart", "recall-hook", 3, settingsPath);
975
+ }
976
+ /**
977
+ * Shared CC-hook installer: add `hicortex <subcommand>` under the given hook
978
+ * event in ~/.claude/settings.json. Idempotent per (event, subcommand): skips
979
+ * if any existing entry for that event already runs the subcommand. `timeout`
980
+ * is CC's hook-process kill timeout in SECONDS (the network timeout inside the
981
+ * command is separate and shorter).
982
+ */
983
+ function installCcHook(eventName, subcommand, timeout, settingsPath) {
963
984
  const targetPath = settingsPath ?? CC_SETTINGS;
964
985
  const binaryArgs = resolveBinaryArgs();
965
- // Build the command string: "/path/to/hicortex lessons-context" or "npx -y @gamaze/hicortex lessons-context"
966
- const command = [...binaryArgs, "lessons-context"].join(" ");
986
+ // e.g. "/path/to/hicortex lessons-context" or "npx -y @gamaze/hicortex recall-hook"
987
+ const command = [...binaryArgs, subcommand].join(" ");
967
988
  let settings = {};
968
989
  if ((0, node_fs_1.existsSync)(targetPath)) {
969
990
  try {
@@ -971,23 +992,26 @@ function installSessionStartHook(settingsPath) {
971
992
  }
972
993
  catch {
973
994
  // File exists but is malformed — do NOT overwrite (would destroy the user's entire CC config).
974
- console.log(` ⚠ ${targetPath} exists but is not valid JSON — skipping SessionStart hook.`);
995
+ console.log(` ⚠ ${targetPath} exists but is not valid JSON — skipping ${eventName} hook.`);
975
996
  console.log(` Fix the file, then re-run init, or add the hook manually:`);
976
997
  console.log(` command: "${command}"`);
977
998
  return;
978
999
  }
979
1000
  }
980
- // Ensure hooks object and SessionStart array exist
1001
+ // Ensure hooks object and the event array exist
981
1002
  if (!settings.hooks || typeof settings.hooks !== "object") {
982
1003
  settings.hooks = {};
983
1004
  }
984
1005
  const hooks = settings.hooks;
985
- if (!Array.isArray(hooks.SessionStart)) {
986
- hooks.SessionStart = [];
987
- }
988
- const sessionStart = hooks.SessionStart;
989
- // Idempotent: skip if any existing entry's command contains "lessons-context"
990
- const alreadyInstalled = sessionStart.some((entry) => {
1006
+ if (!Array.isArray(hooks[eventName])) {
1007
+ hooks[eventName] = [];
1008
+ }
1009
+ const entries = hooks[eventName];
1010
+ // Idempotent: skip if any existing entry's command runs this subcommand.
1011
+ // Word-boundary guard so "recall-hook" never matches a hypothetical
1012
+ // "recall-hook-foo" command.
1013
+ const subcommandRe = new RegExp(`(^|\\s)${subcommand}(\\s|$)`);
1014
+ const alreadyInstalled = entries.some((entry) => {
991
1015
  if (typeof entry !== "object" || entry === null)
992
1016
  return false;
993
1017
  const e = entry;
@@ -997,21 +1021,21 @@ function installSessionStartHook(settingsPath) {
997
1021
  if (typeof h !== "object" || h === null)
998
1022
  return false;
999
1023
  const hook = h;
1000
- return typeof hook.command === "string" && hook.command.includes("lessons-context");
1024
+ return typeof hook.command === "string" && subcommandRe.test(hook.command);
1001
1025
  });
1002
1026
  }
1003
1027
  return false;
1004
1028
  });
1005
1029
  if (alreadyInstalled) {
1006
- console.log(` ✓ SessionStart hook already installed`);
1030
+ console.log(` ✓ ${eventName} hook (${subcommand}) already installed`);
1007
1031
  return;
1008
1032
  }
1009
- sessionStart.push({
1010
- hooks: [{ type: "command", command, timeout: 10 }],
1033
+ entries.push({
1034
+ hooks: [{ type: "command", command, timeout }],
1011
1035
  });
1012
1036
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(targetPath), { recursive: true });
1013
1037
  (0, node_fs_1.writeFileSync)(targetPath, JSON.stringify(settings, null, 2));
1014
- console.log(` ✓ Installed SessionStart hook: ${command}`);
1038
+ console.log(` ✓ Installed ${eventName} hook: ${command}`);
1015
1039
  }
1016
1040
  function installLaunchd(binaryArgs) {
1017
1041
  const plistDir = (0, node_path_1.join)((0, node_os_1.homedir)(), "Library", "LaunchAgents");
@@ -1263,6 +1287,8 @@ async function runInit(options = {}) {
1263
1287
  // Install CC SessionStart hook for query-time lesson injection.
1264
1288
  // Lessons are now fetched live at session start — no static CLAUDE.md block needed.
1265
1289
  installSessionStartHook();
1290
+ // #192: per-prompt pushed recall index + session dedup reset.
1291
+ installRecallHooks();
1266
1292
  // Strip the old static lessons block from CLAUDE.md (0.9.0 migration).
1267
1293
  // Lessons are now delivered via the SessionStart hook instead.
1268
1294
  const claudeMdPath = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "CLAUDE.md");
@@ -1418,8 +1444,10 @@ async function runClientInit(serverUrl, agentName) {
1418
1444
  allowHicortexTools();
1419
1445
  // Step 5: Install CC commands
1420
1446
  installCcCommands();
1421
- // Step 6: Install SessionStart hook for query-time lessons.
1447
+ // Step 6: Install SessionStart hook for query-time lessons + the #192
1448
+ // per-prompt pushed-recall hooks.
1422
1449
  installSessionStartHook();
1450
+ installRecallHooks();
1423
1451
  // Strip the old static CLAUDE.md lessons block if present (0.9.0 migration).
1424
1452
  const claudeMdPath = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "CLAUDE.md");
1425
1453
  if ((0, claude_md_js_1.removeLessonsBlock)(claudeMdPath)) {
@@ -33,6 +33,20 @@ export interface ContextResponse {
33
33
  agent?: string;
34
34
  mode?: string;
35
35
  }
36
+ export interface ResolvedConfig {
37
+ serverUrl: string;
38
+ authToken: string | undefined;
39
+ home: string;
40
+ /** Per-agent context id sent as ?agent= (0.13); null → global (no param). */
41
+ agentName: string | null;
42
+ }
43
+ /**
44
+ * Read ~/.hicortex/config.json and resolve the server URL + auth token, or
45
+ * null when there is no usable config (server not set up yet — fail soft).
46
+ * Exported for reuse by the recall-hook CLI (#192) so the two CC hooks can
47
+ * never resolve the server differently.
48
+ */
49
+ export declare function resolveConfig(): ResolvedConfig | null;
36
50
  /**
37
51
  * Title-case a section name for its heading: split on `-`/`_`, capitalize each
38
52
  * word ("user" → "User", "my_notes" → "My Notes").
@@ -23,6 +23,7 @@
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.resolveConfig = resolveConfig;
26
27
  exports.titleCaseSection = titleCaseSection;
27
28
  exports.orderSectionNames = orderSectionNames;
28
29
  exports.renderContextBlock = renderContextBlock;
@@ -41,6 +42,8 @@ const THIS_HARNESS = "cc";
41
42
  /**
42
43
  * Read ~/.hicortex/config.json and resolve the server URL + auth token, or
43
44
  * null when there is no usable config (server not set up yet — fail soft).
45
+ * Exported for reuse by the recall-hook CLI (#192) so the two CC hooks can
46
+ * never resolve the server differently.
44
47
  */
45
48
  function resolveConfig() {
46
49
  const home = (0, paths_js_1.hicortexHome)();
@@ -64,6 +64,8 @@ const graph_js_1 = require("./graph.js");
64
64
  const viz_js_1 = require("./viz.js");
65
65
  const context_store_js_1 = require("./context-store.js");
66
66
  const retrieval = __importStar(require("./retrieval.js"));
67
+ const recall_registry_js_1 = require("./recall-registry.js");
68
+ const recall_index_js_1 = require("./recall-index.js");
67
69
  const seed_lesson_js_1 = require("./seed-lesson.js");
68
70
  const distiller_js_1 = require("./distiller.js");
69
71
  // ---------------------------------------------------------------------------
@@ -85,6 +87,9 @@ let contextClients = ["cc"];
85
87
  // Read once at boot (like contextClients); the drop-in-a-dir presence path is
86
88
  // per-request, so only explicit config entries need a daemon restart to apply.
87
89
  let contextAgents = {};
90
+ // Pushed-recall dedup registry (#192) + options; configured at boot.
91
+ let recallRegistry = new recall_registry_js_1.SessionRecallRegistry();
92
+ let recallIndexOptions = {};
88
93
  // Cache detectChunkSize results keyed by "<provider>/<model>@<baseUrl>" so we
89
94
  // probe each endpoint once per server boot rather than once per /distill request.
90
95
  const chunkSizeCache = new Map();
@@ -103,9 +108,9 @@ function createMcpServer() {
103
108
  version: VERSION,
104
109
  });
105
110
  // -- hicortex_search --
106
- server.tool("hicortex_search", "Search long-term memory using semantic similarity. Returns the most relevant memories from past sessions.", {
111
+ server.tool("hicortex_search", "Search shared long-term memory (all agents, all sessions). CALL THIS BEFORE assuming, guessing, or asking the user about anything that may have come up before: prior decisions, preferences, project facts, people, hardware, past incidents. If you are about to write 'I don't have information about…', search first.", {
107
112
  query: zod_1.z.string().describe("Search query text"),
108
- limit: zod_1.z.coerce.number().optional().describe("Max results (default 5)"),
113
+ limit: zod_1.z.coerce.number().optional().describe("Max results (default: server config searchLimit)"),
109
114
  project: zod_1.z.string().optional().describe("Filter by project name"),
110
115
  }, async ({ query, limit, project }) => {
111
116
  if (!db)
@@ -118,10 +123,34 @@ function createMcpServer() {
118
123
  return { content: [{ type: "text", text: `Search failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
119
124
  }
120
125
  });
126
+ // -- hicortex_get --
127
+ server.tool("hicortex_get", "Fetch ONE memory's full content by id — use this to lazy-load entries from the '## Memory recall (auto)' index or from search results whose snippet was not enough. Fetching a memory marks it as used (strengthens it), so only fetch what you actually need. When the memory shapes your answer, cite it to the user (id + date + origin agent).", {
128
+ id: zod_1.z.string().describe("Memory id (as shown in recall index/search results)"),
129
+ }, async ({ id }) => {
130
+ if (!db)
131
+ return { content: [{ type: "text", text: "Hicortex not initialized" }], isError: true };
132
+ try {
133
+ const mem = storage.getMemory(db, id);
134
+ if (!mem)
135
+ return { content: [{ type: "text", text: `No memory with id ${id}` }], isError: true };
136
+ // Real use → full strengthen (access_count + hardening + prune shield).
137
+ storage.strengthenMemory(db, id, new Date().toISOString());
138
+ // Provenance header (built-in citing norm, 0.14.1): id, type, project,
139
+ // ORIGIN AGENT (shared brain — the memory may come from another
140
+ // agent's session), and date, plus the explicit citation instruction.
141
+ const date = (mem.created_at ?? "").slice(0, 10);
142
+ const header = `[memory ${mem.id} | ${mem.memory_type ?? "episode"} | ${mem.project ?? "-"} | from ${mem.source_agent ?? "unknown"} | ${date}]\n` +
143
+ `Cite as (memory ${String(mem.id).slice(0, 8)}, ${date}) where this shapes your answer; it may be stale — newer memories supersede older.`;
144
+ return { content: [{ type: "text", text: `${header}\n\n${mem.content ?? ""}` }] };
145
+ }
146
+ catch (err) {
147
+ return { content: [{ type: "text", text: `Get failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
148
+ }
149
+ });
121
150
  // -- hicortex_recent --
122
- server.tool("hicortex_recent", "Get recent memories, optionally filtered by project. Queryless recall of the latest memories by project, ranked by importance. Useful to catch up on what happened recently.", {
151
+ server.tool("hicortex_recent", "Get recent memories, optionally filtered by project. CALL THIS AT THE START of substantive work on a project to catch up on its latest state — cheaper than asking the user what happened.", {
123
152
  project: zod_1.z.string().optional().describe("Filter by project name"),
124
- limit: zod_1.z.coerce.number().optional().describe("Max results (default 10)"),
153
+ limit: zod_1.z.coerce.number().optional().describe("Max results (default: server config recentLimit)"),
125
154
  }, async ({ project, limit }) => {
126
155
  if (!db)
127
156
  return { content: [{ type: "text", text: "Hicortex not initialized" }], isError: true };
@@ -446,6 +475,21 @@ async function startServer(options = {}) {
446
475
  // agents/<id> dir onto disk takes effect immediately (per-request presence).
447
476
  const resolvedAgents = (0, context_store_js_1.resolveContextAgents)(savedConfig?.contextAgents);
448
477
  contextAgents = resolvedAgents.agents;
478
+ // #192 recall/decay alignment: decay speed + recall breadth + pushed-recall
479
+ // knobs, ALL from config (see retrieval.ts configureRecall for the key list)
480
+ // so calibration is a config edit + restart, never a release.
481
+ retrieval.configureDecay({ halfLifeDays: savedConfig?.decayHalfLifeDays });
482
+ const recallCfg = retrieval.configureRecall(savedConfig);
483
+ console.log(`[hicortex] Recall: k=${recallCfg.searchLimit}/recent=${recallCfg.recentLimit}` +
484
+ `/window=${recallCfg.recentWindowDays}d/cold=${recallCfg.coldExposureSlots}`);
485
+ recallRegistry = new recall_registry_js_1.SessionRecallRegistry({
486
+ reshowTurns: savedConfig?.recallReshowTurns,
487
+ });
488
+ recallIndexOptions = {
489
+ minSimilarity: savedConfig?.recallMinSimilarity,
490
+ maxItems: savedConfig?.recallMaxItems,
491
+ minPromptLength: savedConfig?.recallMinPromptChars,
492
+ };
449
493
  if (resolvedAgents.dropped.length > 0) {
450
494
  console.warn(`[hicortex] Ignoring invalid contextAgents entries: ${resolvedAgents.dropped.join(", ")} ` +
451
495
  `(keys must match ^[a-z0-9][a-z0-9_-]*$; modes must be override|global|off)`);
@@ -582,7 +626,8 @@ async function startServer(options = {}) {
582
626
  res.status(400).json({ error: "Missing 'query'" });
583
627
  return;
584
628
  }
585
- const limit = req.query.limit ? Number(req.query.limit) : 5;
629
+ // No hardcoded default: absent limit config-driven (searchLimit).
630
+ const limit = req.query.limit ? Number(req.query.limit) : undefined;
586
631
  const project = typeof req.query.project === "string" && req.query.project ? req.query.project : undefined;
587
632
  const privacy = typeof req.query.privacy === "string" && req.query.privacy
588
633
  ? req.query.privacy.split(",").map((s) => s.trim()).filter(Boolean)
@@ -595,6 +640,56 @@ async function startServer(options = {}) {
595
640
  res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
596
641
  }
597
642
  });
643
+ // REST /recall-index — pushed recall index (#192). One recall logic for all
644
+ // harnesses: CC UserPromptSubmit hook, Hermes/OC per-turn plugins. Returns a
645
+ // compact index block (or null); exposure is recorded as shown_count +
646
+ // last_accessed, NOT access_count (that stays reserved for hicortex_get /
647
+ // GET /memory — real use). {reset: true} clears the session's dedup state
648
+ // (SessionStart/compaction).
649
+ app.post("/recall-index", async (req, res) => {
650
+ if (!db) {
651
+ res.status(503).json({ error: "Server not initialized" });
652
+ return;
653
+ }
654
+ const r = await (0, recall_index_js_1.handleRecallIndex)({
655
+ db,
656
+ registry: recallRegistry,
657
+ retrieveFn: (query, limit) => retrieval.retrieve(db, embedder_js_1.embed, query, { limit, noStrengthen: true }),
658
+ options: recallIndexOptions,
659
+ }, req.body);
660
+ res.status(r.status).json(r.body);
661
+ });
662
+ // REST /memory?id= — fetch one memory's full content (lazy-load counterpart
663
+ // of /recall-index for REST clients: Hermes/OC plugins). Marks it as used.
664
+ app.get("/memory", (req, res) => {
665
+ if (!db) {
666
+ res.status(503).json({ error: "Server not initialized" });
667
+ return;
668
+ }
669
+ const id = typeof req.query.id === "string" ? req.query.id : "";
670
+ if (!id) {
671
+ res.status(400).json({ error: "Missing 'id'" });
672
+ return;
673
+ }
674
+ try {
675
+ const mem = storage.getMemory(db, id);
676
+ if (!mem) {
677
+ res.status(404).json({ error: `No memory with id ${id}` });
678
+ return;
679
+ }
680
+ storage.strengthenMemory(db, id, new Date().toISOString());
681
+ // `citation` is server-rendered so every plugin surfaces the same
682
+ // built-in provenance norm (owner directive 27.07) — see #193.
683
+ const date = (mem.created_at ?? "").slice(0, 10);
684
+ res.json({
685
+ memory: mem,
686
+ citation: `(memory ${String(mem.id).slice(0, 8)}, ${date}, from ${mem.source_agent ?? "unknown"})`,
687
+ });
688
+ }
689
+ catch (err) {
690
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
691
+ }
692
+ });
598
693
  // REST /recent — recent memories, optionally filtered by project.
599
694
  app.get("/recent", (req, res) => {
600
695
  if (!db) {
@@ -602,7 +697,8 @@ async function startServer(options = {}) {
602
697
  return;
603
698
  }
604
699
  const project = typeof req.query.project === "string" && req.query.project ? req.query.project : undefined;
605
- const limit = req.query.limit ? Number(req.query.limit) : 10;
700
+ // No hardcoded default: absent limit config-driven (recentLimit).
701
+ const limit = req.query.limit ? Number(req.query.limit) : undefined;
606
702
  const privacy = typeof req.query.privacy === "string" && req.query.privacy
607
703
  ? req.query.privacy.split(",").map((s) => s.trim()).filter(Boolean)
608
704
  : undefined;
package/dist/nightly.js CHANGED
@@ -66,6 +66,7 @@ const hermes_transcript_reader_js_1 = require("./hermes-transcript-reader.js");
66
66
  const pi_transcript_reader_js_1 = require("./pi-transcript-reader.js");
67
67
  const oc_transcript_reader_js_1 = require("./oc-transcript-reader.js");
68
68
  const features_js_1 = require("./features.js");
69
+ const retrieval_js_1 = require("./retrieval.js");
69
70
  const state_js_1 = require("./state.js");
70
71
  const capture_cursors_js_1 = require("./capture-cursors.js");
71
72
  const capture_js_1 = require("./capture.js");
@@ -209,6 +210,10 @@ async function runNightly(options = {}) {
209
210
  }
210
211
  const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
211
212
  const port = savedConfig?.port ?? 8787;
213
+ // #192: consolidation's decay/prune stage must score with the same clock as
214
+ // the server's retrieval path (config decayHalfLifeDays, default 365).
215
+ (0, retrieval_js_1.configureDecay)({ halfLifeDays: savedConfig?.decayHalfLifeDays });
216
+ (0, retrieval_js_1.configureRecall)(savedConfig);
212
217
  const modeLabel = captureOnly ? " (capture-only)" : dryRun ? " (dry run)" : "";
213
218
  console.log(`[hicortex] Nightly pipeline starting${modeLabel}`);
214
219
  if (captureOnly) {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `hicortex recall-hook` — CC-side client for pushed recall (#192).
3
+ *
4
+ * Installed by init under TWO Claude Code hook events (one command, the CLI
5
+ * dispatches on the payload):
6
+ * - UserPromptSubmit: POST the prompt to the server's /recall-index; print
7
+ * the returned index block to stdout (CC injects hook stdout as context).
8
+ * - SessionStart (startup/resume/clear/compact): POST a reset so the
9
+ * server's per-session shown-set matches the fresh context window.
10
+ *
11
+ * Fail-soft like lessons-context: ANY failure (no config, timeout, non-2xx,
12
+ * parse error) prints nothing and exits 0 — a broken hook must never block or
13
+ * slow a CC session beyond the fetch timeout (1000 ms, owner-set).
14
+ */
15
+ interface HookPayload {
16
+ session_id?: string;
17
+ hook_event_name?: string;
18
+ prompt?: string;
19
+ source?: string;
20
+ }
21
+ /**
22
+ * Build the /recall-index request body from a CC hook payload, or null when
23
+ * there is nothing to send (no session id, or an unhandled event). Exported
24
+ * for tests.
25
+ */
26
+ export declare function buildHookRequest(payload: HookPayload): Record<string, unknown> | null;
27
+ export declare function runRecallHook(): Promise<void>;
28
+ export {};
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ /**
3
+ * `hicortex recall-hook` — CC-side client for pushed recall (#192).
4
+ *
5
+ * Installed by init under TWO Claude Code hook events (one command, the CLI
6
+ * dispatches on the payload):
7
+ * - UserPromptSubmit: POST the prompt to the server's /recall-index; print
8
+ * the returned index block to stdout (CC injects hook stdout as context).
9
+ * - SessionStart (startup/resume/clear/compact): POST a reset so the
10
+ * server's per-session shown-set matches the fresh context window.
11
+ *
12
+ * Fail-soft like lessons-context: ANY failure (no config, timeout, non-2xx,
13
+ * parse error) prints nothing and exits 0 — a broken hook must never block or
14
+ * slow a CC session beyond the fetch timeout (1000 ms, owner-set).
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.buildHookRequest = buildHookRequest;
18
+ exports.runRecallHook = runRecallHook;
19
+ const lessons_context_js_1 = require("./lessons-context.js");
20
+ const FETCH_TIMEOUT_MS = 1000;
21
+ /** Read all of stdin (CC pipes the hook payload JSON). */
22
+ async function readStdin() {
23
+ const chunks = [];
24
+ for await (const chunk of process.stdin) {
25
+ chunks.push(Buffer.from(chunk));
26
+ }
27
+ return Buffer.concat(chunks).toString("utf-8");
28
+ }
29
+ /**
30
+ * Build the /recall-index request body from a CC hook payload, or null when
31
+ * there is nothing to send (no session id, or an unhandled event). Exported
32
+ * for tests.
33
+ */
34
+ function buildHookRequest(payload) {
35
+ const sessionId = typeof payload.session_id === "string" && payload.session_id
36
+ ? payload.session_id
37
+ : null;
38
+ if (!sessionId)
39
+ return null;
40
+ if (payload.hook_event_name === "SessionStart") {
41
+ return { session_id: sessionId, reset: true };
42
+ }
43
+ const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
44
+ if (!prompt)
45
+ return null;
46
+ return { session_id: sessionId, prompt };
47
+ }
48
+ async function runRecallHook() {
49
+ const cfg = (0, lessons_context_js_1.resolveConfig)();
50
+ if (!cfg)
51
+ return;
52
+ let payload;
53
+ try {
54
+ payload = JSON.parse(await readStdin());
55
+ }
56
+ catch {
57
+ return;
58
+ }
59
+ const body = buildHookRequest(payload);
60
+ if (!body)
61
+ return;
62
+ const headers = { "Content-Type": "application/json" };
63
+ if (cfg.authToken)
64
+ headers["Authorization"] = `Bearer ${cfg.authToken}`;
65
+ const resp = await fetch(`${cfg.serverUrl}/recall-index`, {
66
+ method: "POST",
67
+ headers,
68
+ body: JSON.stringify(body),
69
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
70
+ });
71
+ if (!resp.ok)
72
+ return;
73
+ const data = (await resp.json());
74
+ if (typeof data.block === "string" && data.block.trim() !== "") {
75
+ process.stdout.write(data.block + "\n");
76
+ }
77
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * POST /recall-index — pushed recall index (#192).
3
+ *
4
+ * One recall logic for every harness: CC calls it from a UserPromptSubmit
5
+ * hook, the Hermes/OC plugins can call it per turn. The server searches the
6
+ * corpus with the prompt text and returns a COMPACT INDEX (one line per
7
+ * memory — a menu, not the meal); the agent lazy-loads full content with
8
+ * `hicortex_get(id)` only when a line is actually relevant.
9
+ *
10
+ * Strengthening semantics (the recall/decay alignment):
11
+ * - Appearing in the index = exposure: shown_count + last_accessed refresh
12
+ * (mild, temporary strengthen — the decay clock resets) via
13
+ * storage.touchMemoriesShown. NO access_count bump: hardening, the prune
14
+ * shield, and the adoption metric stay driven by real use.
15
+ * - hicortex_get = use: full strengthen (access_count + 1).
16
+ *
17
+ * Anti-bloat gates: relevance floor (measured cosine, or a real BM25 match),
18
+ * per-session TURN-based dedup (SessionRecallRegistry), short-prompt skip,
19
+ * and a hard item cap. On a prompt with no relevant memories the block is
20
+ * null and the hook prints nothing.
21
+ */
22
+ import type Database from "better-sqlite3";
23
+ import type { MemorySearchResult } from "./types.js";
24
+ import { SessionRecallRegistry } from "./recall-registry.js";
25
+ export interface RecallIndexOptions {
26
+ /** Minimum measured cosine for vector-only candidates (config
27
+ * `recallMinSimilarity`). FTS-matched candidates pass regardless — a BM25
28
+ * text match is direct evidence of relevance. Default 0.55 (the neutral
29
+ * placeholder similarity is 0.5; anything at/below that is noise). */
30
+ minSimilarity?: number;
31
+ /** Max index lines per response (config `recallMaxItems`). Default 6. */
32
+ maxItems?: number;
33
+ /** Prompts shorter than this are skipped (continuations, "yes", "do it"). */
34
+ minPromptLength?: number;
35
+ }
36
+ export interface RecallIndexResult {
37
+ status: number;
38
+ body: Record<string, unknown>;
39
+ }
40
+ /** First content line, de-markdowned and truncated — the index line title. */
41
+ export declare function memoryTitle(content: string, maxLen?: number): string;
42
+ /** Relevance gate: real text match, or measured cosine above the floor. */
43
+ export declare function passesRelevanceGate(r: MemorySearchResult, minSimilarity: number): boolean;
44
+ export interface RecallIndexDeps {
45
+ db: Database.Database;
46
+ registry: SessionRecallRegistry;
47
+ retrieveFn: (query: string, limit: number) => Promise<MemorySearchResult[]>;
48
+ options?: RecallIndexOptions;
49
+ }
50
+ /**
51
+ * Handle a /recall-index request body. Thin Express adapter in mcp-server.ts;
52
+ * all behavior lives here so tests exercise it directly.
53
+ */
54
+ export declare function handleRecallIndex(deps: RecallIndexDeps, body: unknown): Promise<RecallIndexResult>;