@gamaze/hicortex 0.16.0 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +9 -0
  2. package/dist/capture.d.ts +18 -1
  3. package/dist/capture.js +3 -2
  4. package/dist/classify-domains.d.ts +1 -1
  5. package/dist/classify-domains.js +5 -7
  6. package/dist/cli.js +10 -2
  7. package/dist/cluster.d.ts +5 -4
  8. package/dist/cluster.js +2 -3
  9. package/dist/consolidate.js +6 -5
  10. package/dist/db.js +23 -0
  11. package/dist/dedup.js +1 -1
  12. package/dist/distiller.js +19 -12
  13. package/dist/domain-classify.d.ts +1 -1
  14. package/dist/domain-classify.js +1 -5
  15. package/dist/eval/relevance-eval.d.ts +64 -0
  16. package/dist/eval/relevance-eval.js +1954 -0
  17. package/dist/eval/run-eval.js +0 -1
  18. package/dist/index.js +3 -3
  19. package/dist/init.d.ts +165 -0
  20. package/dist/init.js +283 -57
  21. package/dist/lessons-context.js +3 -2
  22. package/dist/mcp-server.js +72 -25
  23. package/dist/nightly.js +35 -3
  24. package/dist/nofit.d.ts +1 -1
  25. package/dist/nofit.js +1 -2
  26. package/dist/prompts.js +22 -13
  27. package/dist/recall-index.d.ts +56 -21
  28. package/dist/recall-index.js +51 -29
  29. package/dist/retrieval.d.ts +7 -7
  30. package/dist/retrieval.js +20 -24
  31. package/dist/schema-prototypes.d.ts +8 -13
  32. package/dist/schema-prototypes.js +13 -22
  33. package/dist/seed-lesson.d.ts +1 -1
  34. package/dist/seed-lesson.js +1 -2
  35. package/dist/storage.d.ts +9 -12
  36. package/dist/storage.js +19 -21
  37. package/dist/types.d.ts +47 -23
  38. package/domains.example.json +2 -3
  39. package/hermes-plugin/hicortex/README.md +3 -1
  40. package/hermes-plugin/hicortex/config.py +33 -2
  41. package/hermes-plugin/hicortex/plugin.yaml +1 -1
  42. package/hermes-plugin/hicortex/provider.py +5 -0
  43. package/package.json +2 -1
@@ -90,10 +90,11 @@ async function fetchLessonsBlock(cfg) {
90
90
  const project = (0, node_path_1.basename)(process.cwd()) || null;
91
91
  const selected = await (0, extensions_js_1.getLessonSelector)().select(data.lessons, { maxLessons, moduleIndex, project });
92
92
  const lessonLines = selected.map((l) => {
93
- const titleMatch = l.content.match(/## Lesson: (.+)/);
94
93
  const typeMatch = l.content.match(/\*\*Type:\*\* (\w+)/);
95
94
  const severityMatch = l.content.match(/\*\*Severity:\*\* (\w+)/);
96
- const title = titleMatch ? titleMatch[1] : l.content.slice(0, 150);
95
+ // First line, with any legacy `## Lesson:` prefix stripped — new lessons
96
+ // are stored topic-first without the prefix (memory_type carries the type).
97
+ const title = l.content.replace(/^##\s*Lesson:\s*/i, "").split("\n")[0].slice(0, 150);
97
98
  const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
98
99
  return `- ${title}${meta ? ` (${meta})` : ""}`;
99
100
  });
@@ -70,6 +70,7 @@ const recall_index_js_1 = require("./recall-index.js");
70
70
  const seed_lesson_js_1 = require("./seed-lesson.js");
71
71
  const distiller_js_1 = require("./distiller.js");
72
72
  const dedup_js_1 = require("./dedup.js");
73
+ const init_js_1 = require("./init.js");
73
74
  // ---------------------------------------------------------------------------
74
75
  // Server state
75
76
  // ---------------------------------------------------------------------------
@@ -78,6 +79,21 @@ let llm = null;
78
79
  // llmConfig is module-level so the /distill handler can call resolveDistillFallback
79
80
  // without having to read config on every request. null when no LLM is configured.
80
81
  let llmConfig = null;
82
+ // One-time-per-process deprecation warning for the `?privacy=` query param
83
+ // (0.16.x: the column is vestigial, never filtered). Old clients/plugins still
84
+ // send it; we accept it (backward compat) but warn ONCE so an operator relying
85
+ // on privacy filtering discovers from the logs that it is now a no-op.
86
+ let privacyDeprecationWarned = false;
87
+ function warnDeprecatedPrivacyParamIfPresent(query, route) {
88
+ if (privacyDeprecationWarned)
89
+ return;
90
+ if (query.privacy === undefined || query.privacy === null || query.privacy === "")
91
+ return;
92
+ privacyDeprecationWarned = true;
93
+ console.warn(`[hicortex] client sent ?privacy= on /${route}, which is ignored since 0.16.2 — ` +
94
+ `privacy is no longer filtered server-side (the column is vestigial). ` +
95
+ `Use a separate Hicortex server for isolation. (This warning fires once per process.)`);
96
+ }
81
97
  // distillFallbackMode controls whether a failed remote distill endpoint causes an
82
98
  // immediate abort ("strict", default) or a fallback to the base model ("local").
83
99
  let distillFallbackMode = "strict";
@@ -174,7 +190,6 @@ function createMcpServer() {
174
190
  sourceAgent: "claude-code/manual",
175
191
  project,
176
192
  memoryType: memory_type ?? "episode",
177
- privacy: "WORK",
178
193
  });
179
194
  return { content: [{ type: "text", text: `Memory stored (id: ${id.slice(0, 8)})` }] };
180
195
  }
@@ -360,6 +375,17 @@ async function startServer(options = {}) {
360
375
  // goes through resolveExplicitLlmConfig which requires a user-chosen provider.
361
376
  // If nothing is configured: start recall-only with an unmissable warning.
362
377
  const savedConfig = (0, llm_js_1.applyModelsBlock)(readConfigFile(stateDir));
378
+ // 0.16.2 activation gap: self-heal the agentId provenance field for
379
+ // pre-0.16.2 server installs on first boot after upgrade. The server's own
380
+ // nightly captures its sessions to localhost:8787/distill and needs this id;
381
+ // without it every self-captured memory landed with source_agent_id NULL.
382
+ // Hardened wrapper: throws on a malformed config instead of wiping, saves
383
+ // only when a new id was generated. Mutate savedConfig so any downstream
384
+ // read picks up the id even before the file is re-read.
385
+ if (savedConfig) {
386
+ const { agentId } = (0, init_js_1.ensureAndPersistAgentId)((0, node_path_1.join)(stateDir, "config.json"));
387
+ savedConfig.agentId = agentId;
388
+ }
363
389
  if (savedConfig?.llmBackend === "claude-cli") {
364
390
  const claudePath = (0, llm_js_1.findClaudeBinary)();
365
391
  if (claudePath) {
@@ -494,6 +520,7 @@ async function startServer(options = {}) {
494
520
  minSimilarity: savedConfig?.recallMinSimilarity,
495
521
  maxItems: savedConfig?.recallMaxItems,
496
522
  minPromptLength: savedConfig?.recallMinPromptChars,
523
+ titleChars: savedConfig?.recallTitleChars,
497
524
  };
498
525
  memoryInstructionsEnabled = savedConfig?.memoryInstructions !== false;
499
526
  if (resolvedAgents.dropped.length > 0) {
@@ -596,7 +623,7 @@ async function startServer(options = {}) {
596
623
  res.status(503).json({ error: "Server not initialized" });
597
624
  return;
598
625
  }
599
- const { content, source_agent, project, memory_type, privacy, source_session, session_date } = req.body ?? {};
626
+ const { content, source_agent, source_agent_id, source_domain, project, memory_type, privacy, source_session, session_date } = req.body ?? {};
600
627
  if (!content || typeof content !== "string") {
601
628
  res.status(400).json({ error: "Missing or invalid 'content' field" });
602
629
  return;
@@ -618,10 +645,15 @@ async function startServer(options = {}) {
618
645
  const embedding = await (0, embedder_js_1.embed)(content);
619
646
  const id = storage.insertMemory(db, content, embedding, {
620
647
  sourceAgent: source_agent ?? "remote-client",
648
+ // Attribution + provenance passthrough (0.16.x); null when absent.
649
+ sourceAgentId: typeof source_agent_id === "string" ? source_agent_id : null,
650
+ sourceDomain: typeof source_domain === "string" ? source_domain : null,
621
651
  sourceSession: source_session ?? undefined,
622
652
  project: project ?? undefined,
623
653
  memoryType: memory_type ?? "episode",
624
- privacy: privacy ?? "WORK",
654
+ // 0.16.x: privacy defaults to null (vestigial column). A legacy client
655
+ // that sends an explicit value is honored; absent → null.
656
+ privacy: typeof privacy === "string" ? privacy : null,
625
657
  createdAt: session_date ? new Date(session_date).toISOString() : undefined,
626
658
  });
627
659
  res.status(201).json({ id, message: "Memory ingested" });
@@ -645,11 +677,12 @@ async function startServer(options = {}) {
645
677
  // No hardcoded default: absent limit → config-driven (searchLimit).
646
678
  const limit = req.query.limit ? Number(req.query.limit) : undefined;
647
679
  const project = typeof req.query.project === "string" && req.query.project ? req.query.project : undefined;
648
- const privacy = typeof req.query.privacy === "string" && req.query.privacy
649
- ? req.query.privacy.split(",").map((s) => s.trim()).filter(Boolean)
650
- : undefined;
680
+ // 0.16.x: `privacy` query param is ACCEPTED for backward compat (old
681
+ // clients/plugins still send it) but no longer read — retrieval ignores
682
+ // privacy entirely (the column is vestigial, never filtered).
683
+ warnDeprecatedPrivacyParamIfPresent(req.query, "search");
651
684
  try {
652
- const results = await retrieval.retrieve(db, embedder_js_1.embed, query, { limit, project, privacy });
685
+ const results = await retrieval.retrieve(db, embedder_js_1.embed, query, { limit, project });
653
686
  res.json({ results });
654
687
  }
655
688
  catch (err) {
@@ -691,9 +724,9 @@ async function startServer(options = {}) {
691
724
  limit,
692
725
  noStrengthen: true,
693
726
  // #203: project + mission_domains are SOFT affinity (zero-boost
694
- // neutral), threaded into computeScore. privacy stays a hard filter.
727
+ // neutral), threaded into computeScore. 0.16.x: privacy is no
728
+ // longer threaded (vestigial column, never filtered).
695
729
  project: filters?.project,
696
- privacy: filters?.privacy,
697
730
  missionDomains: filters?.mission_domains,
698
731
  queryEmbedding: queryVec,
699
732
  });
@@ -702,17 +735,18 @@ async function startServer(options = {}) {
702
735
  }, req.body);
703
736
  res.status(r.status).json(r.body);
704
737
  });
705
- // REST /memory?id=[&privacy=] — fetch one memory's full content (lazy-load
706
- // counterpart of /recall-index for REST clients: Hermes/OC plugins). Marks
707
- // it as used. Prefix ids resolve; a privacy filter miss reads as 404 (no
708
- // existence leak). Logic in handleMemoryGet (recall-index.ts).
738
+ // REST /memory?id= — fetch one memory's full content (lazy-load counterpart
739
+ // of /recall-index for REST clients: Hermes/OC plugins). Marks it as used.
740
+ // Prefix ids resolve. 0.16.x: the `privacy` query param is accepted but
741
+ // ignored (column is vestigial, never filtered). Logic in handleMemoryGet.
709
742
  app.get("/memory", (req, res) => {
710
743
  if (!db) {
711
744
  res.status(503).json({ error: "Server not initialized" });
712
745
  return;
713
746
  }
747
+ warnDeprecatedPrivacyParamIfPresent(req.query, "memory");
714
748
  try {
715
- const r = (0, recall_index_js_1.handleMemoryGet)(db, { id: req.query.id, privacy: req.query.privacy });
749
+ const r = (0, recall_index_js_1.handleMemoryGet)(db, { id: req.query.id });
716
750
  res.status(r.status).json(r.body);
717
751
  }
718
752
  catch (err) {
@@ -727,12 +761,11 @@ async function startServer(options = {}) {
727
761
  }
728
762
  const project = typeof req.query.project === "string" && req.query.project ? req.query.project : undefined;
729
763
  // No hardcoded default: absent limit → config-driven (recentLimit).
764
+ // 0.16.x: `privacy` query param accepted but ignored (vestigial column).
730
765
  const limit = req.query.limit ? Number(req.query.limit) : undefined;
731
- const privacy = typeof req.query.privacy === "string" && req.query.privacy
732
- ? req.query.privacy.split(",").map((s) => s.trim()).filter(Boolean)
733
- : undefined;
766
+ warnDeprecatedPrivacyParamIfPresent(req.query, "recent");
734
767
  try {
735
- const results = retrieval.searchRecent(db, { project, limit, privacy });
768
+ const results = retrieval.searchRecent(db, { project, limit });
736
769
  res.json({ results });
737
770
  }
738
771
  catch (err) {
@@ -806,7 +839,7 @@ async function startServer(options = {}) {
806
839
  res.status(503).json({ error: "No LLM configured — run npx @gamaze/hicortex init. Session will be retried." });
807
840
  return;
808
841
  }
809
- const { text, messages, source_agent, project, session_id, segment_id, session_date, privacy } = req.body ?? {};
842
+ const { text, messages, source_agent, source_agent_id, source_domain, project, session_id, segment_id, session_date, privacy } = req.body ?? {};
810
843
  // Resolve the conversation text from either the pre-denoised string or raw messages array.
811
844
  let conversationText;
812
845
  if (typeof text === "string" && text.length > 0) {
@@ -897,12 +930,18 @@ async function startServer(options = {}) {
897
930
  for (const { entry, embedding, i } of toStore) {
898
931
  out.push(storage.insertMemory(db, entry, embedding, {
899
932
  sourceAgent: source_agent ?? "unknown",
933
+ // Attribution + provenance only (0.16.x): client-declared, never
934
+ // filtered. Default null for older clients that don't send them.
935
+ sourceAgentId: typeof source_agent_id === "string" ? source_agent_id : null,
936
+ sourceDomain: typeof source_domain === "string" ? source_domain : null,
900
937
  // Per-chunk key: "<session_id>[#<segment_id>]#<i>". The prefix
901
938
  // matches the dedup checks above, so a re-run is idempotent.
902
939
  sourceSession: sourcePrefix ? `${sourcePrefix}#${i}` : undefined,
903
940
  project: project ?? undefined,
904
941
  memoryType: "episode",
905
- privacy: privacy ?? "WORK",
942
+ // 0.16.x: privacy defaults to null (vestigial column). A legacy
943
+ // client that sends an explicit value is honored; absent → null.
944
+ privacy: typeof privacy === "string" ? privacy : null,
906
945
  createdAt,
907
946
  }));
908
947
  }
@@ -1249,17 +1288,25 @@ async function startServer(options = {}) {
1249
1288
  const resolveMemoryId = storage.resolveMemoryId;
1250
1289
  /**
1251
1290
  * Read ~/.hicortex/config.json (persisted by init with LLM and license config).
1291
+ * Routes through loadConfigStrict: a malformed existing file (bad JSON /
1292
+ * non-object / unreadable) emits a visible WARN then fails-soft to null; an
1293
+ * absent file (ENOENT) silently returns null. Without this routing the
1294
+ * agentId self-heal's throw would be unreachable from boot (the old swallow
1295
+ * → null → `if (savedConfig)` guard skipped it).
1252
1296
  */
1253
1297
  function readConfigFile(stateDir) {
1298
+ const configPath = (0, node_path_1.join)(stateDir, "config.json");
1299
+ let loaded;
1254
1300
  try {
1255
- const { readFileSync } = require("node:fs");
1256
- const { join } = require("node:path");
1257
- const configPath = join(stateDir, "config.json");
1258
- return JSON.parse(readFileSync(configPath, "utf-8"));
1301
+ loaded = (0, init_js_1.loadConfigStrict)(configPath);
1259
1302
  }
1260
- catch {
1303
+ catch (e) {
1304
+ console.warn(`[hicortex] ${configPath} exists but could not be parsed — server booting degraded ` +
1305
+ `(config-driven LLM/decay/recall knobs and agentId self-heal will not apply). ` +
1306
+ `Fix the JSON and restart. Cause: ${e instanceof Error ? e.message : String(e)}`);
1261
1307
  return null;
1262
1308
  }
1309
+ return loaded.hadFile ? loaded.config : null;
1263
1310
  }
1264
1311
  /**
1265
1312
  * Self-heal: if the daemon plist/systemd unit has a pinned version
package/dist/nightly.js CHANGED
@@ -71,15 +71,28 @@ const state_js_1 = require("./state.js");
71
71
  const capture_cursors_js_1 = require("./capture-cursors.js");
72
72
  const capture_js_1 = require("./capture.js");
73
73
  const telemetry_js_1 = require("./telemetry.js");
74
+ const init_js_1 = require("./init.js");
74
75
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
75
76
  function readNightlyConfig(stateDir) {
77
+ const configPath = (0, node_path_1.join)(stateDir, "config.json");
78
+ let loaded;
76
79
  try {
77
- const configPath = (0, node_path_1.join)(stateDir, "config.json");
78
- return JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
80
+ loaded = (0, init_js_1.loadConfigStrict)(configPath);
79
81
  }
80
- catch {
82
+ catch (e) {
83
+ // Malformed existing config (bad JSON / non-object / unreadable): visible
84
+ // WARN so the operator fixes it, then fail-soft to null. The strict load
85
+ // also protects the agentId self-heal below — its throw is now reachable
86
+ // here (without this routing, a swallowed parse → null → the `if
87
+ // (savedConfig)` guard would skip the self-heal entirely).
88
+ console.warn(`[hicortex] ${configPath} exists but could not be parsed — running degraded ` +
89
+ `(agentId self-heal and config-driven knobs will not apply this run). ` +
90
+ `Fix the JSON and re-run. Cause: ${e instanceof Error ? e.message : String(e)}`);
81
91
  return null;
82
92
  }
93
+ // ENOENT → hadFile=false → null (install not set up yet; silent, matches the
94
+ // old catch→null behavior).
95
+ return loaded.hadFile ? loaded.config : null;
83
96
  }
84
97
  function readConfigLicenseKey(stateDir) {
85
98
  try {
@@ -202,6 +215,17 @@ async function runNightly(options = {}) {
202
215
  (0, state_js_1.migrateLegacyState)(stateDir);
203
216
  // Check mode: client or server
204
217
  const savedConfig = readNightlyConfig(stateDir);
218
+ // 0.16.2 activation gap: pre-0.16.2 installs never re-run init, so their
219
+ // config has no agentId → capture sent source_agent_id: null forever (the
220
+ // provenance feature was inert for the whole existing fleet). Self-heal on
221
+ // the first nightly after upgrade: ensureAndPersistAgentId generates + writes
222
+ // the id once (idempotent thereafter). Mutate the in-memory savedConfig so
223
+ // BOTH capture paths (server line below, client via runClientNightly's param)
224
+ // read the value without re-reading the file.
225
+ if (savedConfig) {
226
+ const { agentId } = (0, init_js_1.ensureAndPersistAgentId)((0, node_path_1.join)(stateDir, "config.json"));
227
+ savedConfig.agentId = agentId;
228
+ }
205
229
  if (savedConfig?.mode === "client") {
206
230
  // --capture-only is accepted in client mode but irrelevant: client nightly
207
231
  // is already capture-only (no consolidation step).
@@ -308,10 +332,14 @@ async function runNightly(options = {}) {
308
332
  }
309
333
  // Step 2: pack each session's delta into ≤60K segments and POST to the
310
334
  // local daemon via /distill; cursors advance on confirmed success.
335
+ // source_agent_id / source_domain are per-client provenance from
336
+ // config.json (agentId / sourceDomain) — attribution only, no filtering.
311
337
  const result = await (0, capture_js_1.captureBatches)(batches, {
312
338
  post: makeLocalPost(port),
313
339
  cursorStore,
314
340
  dryRun,
341
+ sourceAgentId: savedConfig?.agentId,
342
+ sourceDomain: savedConfig?.sourceDomain,
315
343
  });
316
344
  memoriesIngested = result.memoriesIngested;
317
345
  // A 429/401 stop must hold the watermark too (fix 1): the loop abandoned
@@ -529,6 +557,10 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
529
557
  post: makeRemotePost(serverUrl, authToken),
530
558
  cursorStore,
531
559
  dryRun,
560
+ // Per-client provenance from config.json (agentId / sourceDomain). The
561
+ // server stores these alongside source_agent; nothing filters on them.
562
+ sourceAgentId: config.agentId,
563
+ sourceDomain: config.sourceDomain,
532
564
  });
533
565
  memoriesIngested = result.memoriesIngested;
534
566
  sessionsSent = result.sessionsSent;
package/dist/nofit.d.ts CHANGED
@@ -90,7 +90,7 @@ export declare function resolveNoFit(db: Database.Database, memoryId: string, do
90
90
  * primary derives naturally inside setMemoryTags). Logged distinctly so
91
91
  * weak primaries are auditable apart from LLM-tagged rows.
92
92
  */
93
- export declare function applyWeakPrimary(db: Database.Database, memoryId: string, domain: string, weight: number, compartments: Set<string>): void;
93
+ export declare function applyWeakPrimary(db: Database.Database, memoryId: string, domain: string, weight: number): void;
94
94
  /**
95
95
  * Apply no-association decay to a no-fit-below-floor memory:
96
96
  * - clear any leftover memory_tags rows (e.g. a legacy "Unsorted" tag from
package/dist/nofit.js CHANGED
@@ -137,10 +137,9 @@ function resolveNoFit(db, memoryId, domains, prototypes, floor) {
137
137
  * primary derives naturally inside setMemoryTags). Logged distinctly so
138
138
  * weak primaries are auditable apart from LLM-tagged rows.
139
139
  */
140
- function applyWeakPrimary(db, memoryId, domain, weight, compartments) {
140
+ function applyWeakPrimary(db, memoryId, domain, weight) {
141
141
  storage.setMemoryTags(db, memoryId, [domain], {
142
142
  weights: { [domain]: weight },
143
- compartments,
144
143
  });
145
144
  console.log(`[hicortex] weak-primary ${domain} w=${weight.toFixed(2)} for ${memoryId}`);
146
145
  }
package/dist/prompts.js CHANGED
@@ -105,28 +105,40 @@ EXTRACT into this markdown format:
105
105
 
106
106
  # Session Memory: ${date} - ${projectName}
107
107
 
108
- ## Classification: [pick one: PUBLIC / WORK / PERSONAL / SENSITIVE]
109
-
110
108
  ### Decisions Made
111
- - [decision]: [reasoning] (${date})
109
+ - [SUBJECT]: [decision] — [reasoning] (${date})
112
110
 
113
111
  ### Facts Learned
114
- - [fact]: [context/source] (${date})
112
+ - [SUBJECT]: [fact] — [context/source] (${date})
115
113
 
116
114
  ### Problems & Solutions
117
- - [problem] → [solution that worked] (${date})
115
+ - [SUBJECT]: [problem] → [solution that worked] (${date})
118
116
 
119
117
  ### Project State Changes
120
- - [what changed]: [from → to] (${date})
118
+ - [SUBJECT]: [what changed], [from → to] (${date})
121
119
 
122
120
  ### Key Entities & Relationships
123
121
  - [entity A] → [relationship] → [entity B] (${date})
124
122
 
125
123
  ### Corrections & Rejections
126
- - [what AI proposed] → [why rejected/corrected] → [what user wanted instead] (${date})
124
+ - [SUBJECT]: [what AI proposed] → [why rejected/corrected] → [what user wanted instead] (${date})
127
125
  (Include: tool use denials, "no/wrong/redo", style feedback, approach rejections,
128
126
  user corrections of AI assumptions, quality complaints like "too verbose")
129
127
 
128
+ TOPIC-FIRST RULE (critical — read carefully):
129
+ Every item MUST begin with its [SUBJECT]: the concrete thing it is about — the
130
+ system, file, component, decision area, or entity. The subject is what a future
131
+ reader would search for.
132
+ - Write: "Electrical load calculation: don't bundle unknown loads into one figure — user rejected the estimate"
133
+ - NOT: "User rejected AI's bundling of unknown loads"
134
+ - Write: "Nightly capture (Hermes): cron sessions are excluded — source='cron' is skipped before distillation"
135
+ - NOT: "Discovered that cron sessions are filtered out"
136
+ Reason: each item's first words become the memory's one-line index entry AND
137
+ dominate its search embedding. An item that opens with a category label, a
138
+ sentiment ("Strong Negative"), or "User rejected…" is unfindable — it matches
139
+ every emotionally-similar prompt and no topically-relevant one. Front-load the
140
+ subject; put reaction, intensity and reasoning AFTER it.
141
+
130
142
  RULES:
131
143
  - Extract MAX 20 items total (quality over quantity)
132
144
  - Each must be useful if recalled in a future session
@@ -136,12 +148,9 @@ RULES:
136
148
  - PRIORITIZE Corrections & Rejections — these are high-value signals for learning
137
149
  what the user does NOT want. Even a single "no" or style correction is worth extracting.
138
150
  - Strong language or profanity from the user is a high-intensity signal — it indicates
139
- the correction matters deeply. Note the intensity in the extraction.
140
- - PRIVACY CLASSIFICATION (one of):
141
- - PUBLIC: general tech knowledge, open-source patterns, publicly available info
142
- - WORK: project-specific decisions, architecture choices, client/business context
143
- - PERSONAL: personal preferences, family, health, lifestyle, private life
144
- - SENSITIVE: API keys mentioned, credentials, financial account details, medical records
151
+ the correction matters deeply. Note the intensity AFTER the subject, never before it
152
+ (e.g. "Pricing tiers: strongly rejected per-agent billing …", not
153
+ "[Strong Negative] User rejected per-agent billing"). The subject always comes first.
145
154
  - Omit any section that has zero items (don't include empty sections)
146
155
  - If nothing worth extracting, output ONLY: "NO_EXTRACT"
147
156
  `;
@@ -25,13 +25,31 @@ import { SessionRecallRegistry } from "./recall-registry.js";
25
25
  export interface RecallIndexOptions {
26
26
  /** Minimum measured cosine for vector-only candidates (config
27
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). */
28
+ * text match is direct evidence of relevance. Default 0.62 (raised from 0.55
29
+ * on 2026-08-03 per a 0.01-step floor sweep on the rewritten corpus): steady
30
+ * ~3:1 noise:signal removal with no knee; 0.62 = +2.2pts precision, 10/98
31
+ * prompts silent, sits below the 0.63 local pessimum. The floor is a noise
32
+ * dial, NOT a silence mechanism — at 0.62 each correctly-silenced empty prompt
33
+ * comes with ~1.5 wrongly-silenced (real signal); a non-cosine gate is the
34
+ * real silence fix (eval #3 §4). */
30
35
  minSimilarity?: number;
31
- /** Max index lines per response (config `recallMaxItems`). Default 6. */
36
+ /** Max index lines per response (config `recallMaxItems`). Default 5
37
+ * (lowered from 6 on 2026-08-03). Per-slot decomposition at floor 0.62:
38
+ * slot 6 gives NO prompt its first relevant memory — "6 is wrong" is the
39
+ * robust, prompt-set-independent finding, and 5 captures it. The K-sweep
40
+ * is monotone (precision@4 33.7% > @6 30.6% > @8 28.3%), so 4 is
41
+ * lower-noise — but the 4-vs-5 distinction rests on 5 of 98 prompts and is
42
+ * overfitting-fragile (K and the floor were tuned on the same set); 5 hedges
43
+ * with coverage at modest cost. Lower to 4 if a fresh-prompt eval replicates. */
32
44
  maxItems?: number;
33
45
  /** Prompts shorter than this are skipped (continuations, "yes", "do it"). */
34
46
  minPromptLength?: number;
47
+ /** Max chars of the memory's first line shown in an index entry (config
48
+ * `recallTitleChars`). Default 100 (reverted from 150 on 2026-08-03): the
49
+ * full-corpus relevance eval (#3, §5) found 100 vs 150 statistically
50
+ * identical (0.6pts apart, N=40, full CI overlap); 100 saves ~13% tokens
51
+ * per block. */
52
+ titleChars?: number;
35
53
  }
36
54
  export interface RecallIndexResult {
37
55
  status: number;
@@ -39,20 +57,42 @@ export interface RecallIndexResult {
39
57
  }
40
58
  /** First content line, de-markdowned and truncated — the index line title. */
41
59
  export declare function memoryTitle(content: string, maxLen?: number): string;
42
- /** Relevance gate: real text match, or measured cosine above the floor. */
60
+ /**
61
+ * Render one production index line. Exported (2026-08-02, relevance eval #v2)
62
+ * so the eval can measure the REAL rendered surface instead of reimplementing
63
+ * it — `maxLen` threads through to `memoryTitle` unchanged (default
64
+ * DEFAULT_TITLE_CHARS = 100, config `recallTitleChars`) so the eval's snippet-length
65
+ * sweep (spec §4.2) can call this SAME function at 100/150/title1sent without
66
+ * duplicating the date/scope/agent/type meta-line logic.
67
+ */
68
+ export declare function formatIndexLine(r: MemorySearchResult & {
69
+ domain?: string | null;
70
+ }, maxLen?: number): string;
71
+ /**
72
+ * Relevance gate: a real BM25 text match (FTS) passes unconditionally; a
73
+ * vector-only candidate must clear `minSimilarity`.
74
+ *
75
+ * NOTE: FTS hits BYPASS the similarity floor, so raising the floor shifts
76
+ * weight toward FTS-sourced entries. In practice FTS is currently inert on
77
+ * real prompts — eval #3 had 0 FTS rows / 2,208 (2,203 vector + 5 graph), and
78
+ * a 12-prompt live bedrock sample returned 96/96 vector — so the floor change
79
+ * is safe as measured. But FTS quality is unmeasured; if FTS starts firing
80
+ * (e.g. as #205's fielded-BM25 retune beds in), give it its own eval.
81
+ */
43
82
  export declare function passesRelevanceGate(r: MemorySearchResult, minSimilarity: number): boolean;
44
83
  /** Recall filters a client may push per request (#193 review F1): a scoped
45
- * plugin (Hermes privacy_filter / default_project) must be able to narrow
84
+ * plugin (Hermes default_project / mission_domains) must be able to narrow
46
85
  * recall exactly like the legacy /search prefetch did — dropping them
47
86
  * silently would leak out-of-scope memory titles into the injected index.
48
87
  *
49
- * #203: `project` and `mission_domains` are now SOFT affinity signals in
50
- * retrieval (zero-boost neutral, never a filter / penalty); `privacy` stays a
51
- * hard filter (security boundary). They ride the body retrieveFn
52
- * retrieve() computeScore path unchanged in shape. */
88
+ * #203: `project` and `mission_domains` are SOFT affinity signals in
89
+ * retrieval (zero-boost neutral, never a filter / penalty). 0.16.x: `privacy`
90
+ * is gone from this shape entirely the column is vestigial, never filtered,
91
+ * so a plugin's `privacy_filter` is a harmless no-op the server no longer
92
+ * threads through. The body field is still ACCEPTED (backward compat) but
93
+ * ignored. */
53
94
  export interface RecallFilters {
54
95
  project?: string;
55
- privacy?: string[];
56
96
  /** #203: Hermes mission domains (declared in plugin config). Soft domain
57
97
  * affinity in computeScore via max overlapping memory_tags.weight. */
58
98
  mission_domains?: string[];
@@ -68,14 +108,9 @@ export interface RecallIndexDeps {
68
108
  }
69
109
  /** Normalize a request-supplied string-list param: array of strings or a CSV
70
110
  * string → string[] | undefined. Anything else (or an empty result) means
71
- * "absent" — never a partial guess. Shared by `parsePrivacyParam` and
72
- * `mission_domains` (#203) so both accept `["A","B"]` and `"A, B"` alike. */
111
+ * "absent" — never a partial guess. Used by `mission_domains` (#203) so it
112
+ * accepts `["A","B"]` and `"A, B"` alike. */
73
113
  export declare function parseStringListParam(v: unknown): string[] | undefined;
74
- /** Normalize a request-supplied privacy filter: array of strings or a CSV
75
- * string → string[] | undefined. Anything else (or an empty result) means
76
- * "no filter" — never a partial guess. Delegates to parseStringListParam;
77
- * kept as a named export for tests and handleMemoryGet callers. */
78
- export declare function parsePrivacyParam(v: unknown): string[] | undefined;
79
114
  /**
80
115
  * Handle a /recall-index request body. Thin Express adapter in mcp-server.ts;
81
116
  * all behavior lives here so tests exercise it directly.
@@ -88,14 +123,14 @@ export declare function handleRecallIndex(deps: RecallIndexDeps, body: unknown):
88
123
  *
89
124
  * - Short/prefix ids resolve via storage.resolveMemoryId (F6) — the 8-char
90
125
  * citation ids agents are taught must work here like on /update, /delete.
91
- * - Optional `privacy` filter (array or CSV): when present and the memory's
92
- * privacy level is not in the allowed set, respond 404 with the SAME
93
- * not-found message — a scoped client must not learn the memory exists.
94
126
  * - A successful fetch is real use: access_count + 1 (strengthen).
127
+ *
128
+ * 0.16.x: the `privacy` filter gate was removed — the column is vestigial and
129
+ * never filtered. Callers may still send a `privacy` field (backward compat)
130
+ * but it is ignored.
95
131
  */
96
132
  export declare function handleMemoryGet(db: Database.Database, query: {
97
133
  id?: unknown;
98
- privacy?: unknown;
99
134
  }): RecallIndexResult;
100
135
  /**
101
136
  * MCP `hicortex_get` presentation: handleMemoryGet's result framed as the