@gamaze/hicortex 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56,9 +56,11 @@ var __importStar = (this && this.__importStar) || (function () {
56
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
57
  exports.memoryTitle = memoryTitle;
58
58
  exports.passesRelevanceGate = passesRelevanceGate;
59
+ exports.parseStringListParam = parseStringListParam;
59
60
  exports.parsePrivacyParam = parsePrivacyParam;
60
61
  exports.handleRecallIndex = handleRecallIndex;
61
62
  exports.handleMemoryGet = handleMemoryGet;
63
+ exports.formatMemoryGetText = formatMemoryGetText;
62
64
  const storage = __importStar(require("./storage.js"));
63
65
  const DEFAULT_MIN_SIMILARITY = 0.55;
64
66
  const DEFAULT_MAX_ITEMS = 6;
@@ -87,7 +89,15 @@ function formatDate(iso) {
87
89
  return `${dd}.${mm}.${d.getFullYear()}`;
88
90
  }
89
91
  function formatIndexLine(r) {
90
- const meta = [formatDate(r.created_at), r.domain ?? r.project ?? undefined, r.memory_type]
92
+ // Provenance (#202): date, scope (domain else project), ORIGIN AGENT, type.
93
+ // The origin agent lets a reader calibrate trust — "from my session" vs
94
+ // another agent/project — before fetching or acting on an entry.
95
+ const meta = [
96
+ formatDate(r.created_at),
97
+ r.domain ?? r.project ?? undefined,
98
+ r.source_agent ?? undefined,
99
+ r.memory_type,
100
+ ]
91
101
  .filter(Boolean)
92
102
  .join(", ");
93
103
  return `- [${r.id}] ${memoryTitle(r.content)}${meta ? ` (${meta})` : ""}`;
@@ -98,10 +108,11 @@ function passesRelevanceGate(r, minSimilarity) {
98
108
  return true;
99
109
  return typeof r.similarity === "number" && r.similarity >= minSimilarity;
100
110
  }
101
- /** Normalize a request-supplied privacy filter: array of strings or a CSV
111
+ /** Normalize a request-supplied string-list param: array of strings or a CSV
102
112
  * string → string[] | undefined. Anything else (or an empty result) means
103
- * "no filter" — never a partial guess. */
104
- function parsePrivacyParam(v) {
113
+ * "absent" — never a partial guess. Shared by `parsePrivacyParam` and
114
+ * `mission_domains` (#203) so both accept `["A","B"]` and `"A, B"` alike. */
115
+ function parseStringListParam(v) {
105
116
  const items = Array.isArray(v)
106
117
  ? v.filter((x) => typeof x === "string")
107
118
  : typeof v === "string"
@@ -110,6 +121,13 @@ function parsePrivacyParam(v) {
110
121
  const cleaned = items.map((s) => s.trim()).filter(Boolean);
111
122
  return cleaned.length > 0 ? cleaned : undefined;
112
123
  }
124
+ /** Normalize a request-supplied privacy filter: array of strings or a CSV
125
+ * string → string[] | undefined. Anything else (or an empty result) means
126
+ * "no filter" — never a partial guess. Delegates to parseStringListParam;
127
+ * kept as a named export for tests and handleMemoryGet callers. */
128
+ function parsePrivacyParam(v) {
129
+ return parseStringListParam(v);
130
+ }
113
131
  /**
114
132
  * Handle a /recall-index request body. Thin Express adapter in mcp-server.ts;
115
133
  * all behavior lives here so tests exercise it directly.
@@ -134,15 +152,19 @@ async function handleRecallIndex(deps, body) {
134
152
  const maxItems = clampInt(deps.options?.maxItems, DEFAULT_MAX_ITEMS, 1, 20);
135
153
  const minSimilarity = clampNumber(deps.options?.minSimilarity, DEFAULT_MIN_SIMILARITY, 0, 1);
136
154
  const turn = deps.registry.beginTurn(sessionId);
137
- // Optional client-side scoping (F1): project + privacy ride the body and
138
- // are pushed into retrieval (which handles filtered over-fetch itself).
155
+ // Optional client-side scoping (F1 + #203): project + mission_domains (soft
156
+ // affinity) and privacy (hard filter) ride the body and are pushed into
157
+ // retrieval. project is cwd-derived (CC/OC) or gateway-supplied; mission_domains
158
+ // is Hermes-declared (plugin config). Neither excludes anything — both are
159
+ // zero-boost-neutral score terms in computeScore.
139
160
  const filters = {
140
161
  project: typeof req.project === "string" && req.project ? req.project : undefined,
141
162
  privacy: parsePrivacyParam(req.privacy),
163
+ mission_domains: parseStringListParam(req.mission_domains),
142
164
  };
143
165
  let results;
144
166
  try {
145
- results = await deps.retrieveFn(prompt, maxItems * CANDIDATE_MULTIPLIER, filters);
167
+ results = await deps.retrieveFn(prompt, maxItems * CANDIDATE_MULTIPLIER, filters, sessionId);
146
168
  }
147
169
  catch (err) {
148
170
  return {
@@ -164,13 +186,15 @@ async function handleRecallIndex(deps, body) {
164
186
  const lines = picked.map((r) => formatIndexLine(r));
165
187
  const block = [
166
188
  "## Memory recall (auto)",
167
- // Provenance is BUILT IN, split by function (owner decision 27.07,
168
- // option D): this header carries only the SELECTION-time rules
169
- // supersession (the one moment competing dates are visible side by side)
170
- // and cite-what-you-rely-on (covers snippet-only use, the common case per
171
- // the 0.14.0 field test). The full citation format + origin agent ride on
172
- // the hicortex_get response / GET /memory `citation` field (use-time).
173
- "Possibly relevant memories dates matter, newer supersedes older. Fetch full content with `hicortex_get(id)` only when relevant; cite any memory you rely on (id, date):",
189
+ // Provenance is BUILT IN (owner decision 27.07, option D; extended #202/#204):
190
+ // - #202: origin agent in each one-liner (formatIndexLine) trust calibration.
191
+ // - #204: confidence levels FETCHED (read in full) vs SNIPPET (one-line entry only),
192
+ // so "the agent cited a memory" can no longer pass as "the agent read it".
193
+ // This header carries the SELECTION-time rules: supersession (the one moment
194
+ // competing dates are visible side by side) and cite-with-confidence. The
195
+ // full citation format rides on the hicortex_get response / GET /memory
196
+ // `citation` field (use-time, marked FETCHED).
197
+ "Possibly relevant memories — dates matter, newer supersedes older. Fetch with `hicortex_get(id)` when an entry could change your action. Cite what you rely on by id + date, and mark it `FETCHED` if you read the full memory or `SNIPPET` if you're citing the one-line entry unread — don't pass a SNIPPET citation off as established fact.",
174
198
  ...lines,
175
199
  ].join("\n");
176
200
  return { status: 200, body: { block, shown: ids, turn } };
@@ -212,10 +236,33 @@ function handleMemoryGet(db, query) {
212
236
  status: 200,
213
237
  body: {
214
238
  memory: mem,
215
- citation: `(memory ${String(mem.id).slice(0, 8)}, ${date}, from ${mem.source_agent ?? "unknown"})`,
239
+ citation: `(memory ${String(mem.id).slice(0, 8)}, ${date}, from ${mem.source_agent ?? "unknown"}, FETCHED)`,
216
240
  },
217
241
  };
218
242
  }
243
+ /**
244
+ * MCP `hicortex_get` presentation: handleMemoryGet's result framed as the
245
+ * text block the MCP tool returns (provenance header + the SHARED citation +
246
+ * content). Extracted from the MCP tool handler so its output — incl. the
247
+ * #204 FETCHED marker, which rides on handleMemoryGet's citation — is unit-
248
+ * testable. The citation string is built ONCE (handleMemoryGet); this only
249
+ * frames it, mirroring how /recall-index is shared across harnesses. The
250
+ * extraction closes the #207 gap (CC's MCP path had a marker-less citation
251
+ * built inline, while the REST path used handleMemoryGet — same contract, two
252
+ * implementations, one updated).
253
+ */
254
+ function formatMemoryGetText(db, query) {
255
+ const r = handleMemoryGet(db, query);
256
+ if (r.status !== 200) {
257
+ return { status: r.status, text: String(r.body.error ?? `No memory with id ${query.id ?? ""}`) };
258
+ }
259
+ const mem = r.body.memory;
260
+ const citation = r.body.citation; // carries FETCHED (#204)
261
+ const date = (mem.created_at ?? "").slice(0, 10);
262
+ const header = `[memory ${mem.id} | ${mem.memory_type ?? "episode"} | ${mem.project ?? "-"} | from ${mem.source_agent ?? "unknown"} | ${date}]\n` +
263
+ `Cite as ${citation} where this shapes your answer; it may be stale — newer memories supersede older.`;
264
+ return { status: 200, text: `${header}\n\n${mem.content ?? ""}` };
265
+ }
219
266
  function clampInt(v, dflt, min, max) {
220
267
  const n = Number(v);
221
268
  if (!Number.isFinite(n))
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * SessionRecallRegistry — per-session, TURN-based dedup for pushed recall
3
- * (#192, POST /recall-index).
3
+ * (#192, POST /recall-index), and owner of the session-intent rolling
4
+ * centroid (#192 session-intent keying, 0.15.3).
4
5
  *
5
6
  * Why turn-based, not time-based: suppression must track the session's
6
7
  * CONTEXT, not the wall clock. A multi-day CC session with a 1M window can
@@ -17,6 +18,19 @@
17
18
  * SessionStart hook — which includes source=compact, i.e. after
18
19
  * compaction the fresh context may legitimately re-receive everything).
19
20
  *
21
+ * Session-intent centroid (0.15.3): a rolling EMA of the session's prompt
22
+ * embeddings lives on SessionState. The recall path blends the current prompt
23
+ * with this centroid before the vector search so recall follows the session's
24
+ * intent instead of being query-literal. reset() deletes the whole session
25
+ * entry, so the centroid is cleared for free on SessionStart/compact — the
26
+ * next recall re-seeds.
27
+ *
28
+ * Concurrency: the registry assumes ONE in-flight recall per session at a time
29
+ * (CC's UserPromptSubmit fires once per turn; Hermes/OC plugins call per-turn
30
+ * too). Two concurrent same-session calls could race updateCentroid and drop
31
+ * one EMA step — harmless (self-correcting on the next turn) and not worth a
32
+ * lock for a path that does not fire concurrently in any current harness.
33
+ *
20
34
  * Purely in-memory: a server restart forgets shown-state, worst case a few
21
35
  * early re-shows (~15 tokens each) — harmless by design. Sessions are pruned
22
36
  * LRU beyond maxSessions so long-running servers don't accumulate state.
@@ -41,6 +55,30 @@ export declare class SessionRecallRegistry {
41
55
  markShown(sessionId: string, memoryIds: string[]): void;
42
56
  /** Forget a session's shown-set (SessionStart / compaction). */
43
57
  reset(sessionId: string): void;
58
+ /**
59
+ * Current session-intent centroid, or undefined when no prompt has seeded it
60
+ * yet (first turn / after a reset). The recall path reads this BEFORE
61
+ * updateCentroid to decide whether to blend — a missing centroid means
62
+ * "first turn, pure prompt, no behavior change".
63
+ */
64
+ getCentroid(sessionId: string): Float32Array | undefined;
65
+ /**
66
+ * Fold this turn's prompt embedding into the session-intent centroid via
67
+ * EMA: `centroid_new = l2Normalize((1-α)·centroid_old + α·prompt)`.
68
+ *
69
+ * First call (no centroid yet) SEEDS the centroid = l2Normalize(prompt) —
70
+ * this is the "after that first recall" step in the design: turn 1's search
71
+ * runs with pure prompt, then the centroid is seeded so turn 2+ can blend.
72
+ *
73
+ * `alpha` is the EMA rate (a shipped constant — retrieval.SESSION_INTENT_ALPHA,
74
+ * 0.4; NOT a config knob per the 0.15.3 scope). Callers (the recall closure)
75
+ * read it from retrieval.getSessionIntent(). We do not re-clamp here — the
76
+ * registry is a pure data owner, not a config interpreter.
77
+ *
78
+ * Returns the new centroid. The centroid lives on SessionState, so reset()
79
+ * (which deletes the session entry) clears it for free.
80
+ */
81
+ updateCentroid(sessionId: string, promptEmbedding: Float32Array, alpha: number): Float32Array;
44
82
  /** Number of tracked sessions (for /recall-index introspection + tests). */
45
83
  size(): number;
46
84
  private getOrCreate;
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  /**
3
3
  * SessionRecallRegistry — per-session, TURN-based dedup for pushed recall
4
- * (#192, POST /recall-index).
4
+ * (#192, POST /recall-index), and owner of the session-intent rolling
5
+ * centroid (#192 session-intent keying, 0.15.3).
5
6
  *
6
7
  * Why turn-based, not time-based: suppression must track the session's
7
8
  * CONTEXT, not the wall clock. A multi-day CC session with a 1M window can
@@ -18,12 +19,26 @@
18
19
  * SessionStart hook — which includes source=compact, i.e. after
19
20
  * compaction the fresh context may legitimately re-receive everything).
20
21
  *
22
+ * Session-intent centroid (0.15.3): a rolling EMA of the session's prompt
23
+ * embeddings lives on SessionState. The recall path blends the current prompt
24
+ * with this centroid before the vector search so recall follows the session's
25
+ * intent instead of being query-literal. reset() deletes the whole session
26
+ * entry, so the centroid is cleared for free on SessionStart/compact — the
27
+ * next recall re-seeds.
28
+ *
29
+ * Concurrency: the registry assumes ONE in-flight recall per session at a time
30
+ * (CC's UserPromptSubmit fires once per turn; Hermes/OC plugins call per-turn
31
+ * too). Two concurrent same-session calls could race updateCentroid and drop
32
+ * one EMA step — harmless (self-correcting on the next turn) and not worth a
33
+ * lock for a path that does not fire concurrently in any current harness.
34
+ *
21
35
  * Purely in-memory: a server restart forgets shown-state, worst case a few
22
36
  * early re-shows (~15 tokens each) — harmless by design. Sessions are pruned
23
37
  * LRU beyond maxSessions so long-running servers don't accumulate state.
24
38
  */
25
39
  Object.defineProperty(exports, "__esModule", { value: true });
26
40
  exports.SessionRecallRegistry = exports.DEFAULT_RESHOW_TURNS = void 0;
41
+ const schema_prototypes_js_1 = require("./schema-prototypes.js");
27
42
  exports.DEFAULT_RESHOW_TURNS = 30;
28
43
  const DEFAULT_MAX_SESSIONS = 500;
29
44
  class SessionRecallRegistry {
@@ -68,6 +83,42 @@ class SessionRecallRegistry {
68
83
  reset(sessionId) {
69
84
  this.sessions.delete(sessionId);
70
85
  }
86
+ /**
87
+ * Current session-intent centroid, or undefined when no prompt has seeded it
88
+ * yet (first turn / after a reset). The recall path reads this BEFORE
89
+ * updateCentroid to decide whether to blend — a missing centroid means
90
+ * "first turn, pure prompt, no behavior change".
91
+ */
92
+ getCentroid(sessionId) {
93
+ return this.sessions.get(sessionId)?.centroid;
94
+ }
95
+ /**
96
+ * Fold this turn's prompt embedding into the session-intent centroid via
97
+ * EMA: `centroid_new = l2Normalize((1-α)·centroid_old + α·prompt)`.
98
+ *
99
+ * First call (no centroid yet) SEEDS the centroid = l2Normalize(prompt) —
100
+ * this is the "after that first recall" step in the design: turn 1's search
101
+ * runs with pure prompt, then the centroid is seeded so turn 2+ can blend.
102
+ *
103
+ * `alpha` is the EMA rate (a shipped constant — retrieval.SESSION_INTENT_ALPHA,
104
+ * 0.4; NOT a config knob per the 0.15.3 scope). Callers (the recall closure)
105
+ * read it from retrieval.getSessionIntent(). We do not re-clamp here — the
106
+ * registry is a pure data owner, not a config interpreter.
107
+ *
108
+ * Returns the new centroid. The centroid lives on SessionState, so reset()
109
+ * (which deletes the session entry) clears it for free.
110
+ */
111
+ updateCentroid(sessionId, promptEmbedding, alpha) {
112
+ const s = this.getOrCreate(sessionId);
113
+ if (!s.centroid) {
114
+ s.centroid = (0, schema_prototypes_js_1.l2Normalize)(promptEmbedding);
115
+ }
116
+ else {
117
+ s.centroid = (0, schema_prototypes_js_1.l2Normalize)((0, schema_prototypes_js_1.weightedAdd)(s.centroid, 1 - alpha, promptEmbedding, alpha));
118
+ }
119
+ s.lastUsedAt = Date.now();
120
+ return s.centroid;
121
+ }
71
122
  /** Number of tracked sessions (for /recall-index introspection + tests). */
72
123
  size() {
73
124
  return this.sessions.size;
@@ -60,16 +60,61 @@ interface ScoringWeights {
60
60
  freshnessBoostDays: number;
61
61
  freshnessBoostWeight: number;
62
62
  supersededDemotion: number;
63
+ /** #203 soft affinity boost on exact project match. */
64
+ projectAffinity: number;
65
+ /** #203 soft affinity boost multiplier on max overlapping domain-tag weight. */
66
+ domainAffinity: number;
67
+ /** #205 RRF k parameter (1/(k+rank+1)). Larger ⇒ shallower rank curve. */
68
+ rrfK: number;
69
+ /** #205 composite-score share of the final blend (RRF gets the remainder). */
70
+ rrfCompositeWeight: number;
71
+ /** #205 per-list RRF weight for the FTS list (BM25-driven candidates). */
72
+ rrfFtsWeight: number;
73
+ /** #205 per-list RRF weight for the vector list (KNN-driven candidates). */
74
+ rrfVectorWeight: number;
63
75
  }
64
76
  /**
65
77
  * Configure scoring weights + ranking knobs from config. Called at boot by the
66
78
  * server and the nightly (alongside configureDecay/configureRecall) so
67
79
  * retrieval and consolidation rank identically. Invalid/absent values keep the
68
- * shipped default per key. Returns the resolved set for logging/tests.
80
+ * shipped default per key. Returns the resolved set for logging/tests. Also
81
+ * pushes the #205 BM25F field weights into storage (storage.configureBm25Fts)
82
+ * so searchFts ranks with the same config — BM25F weights live in storage.ts
83
+ * (next to the FTS column declaration they mirror) but are read here from the
84
+ * SAME config object for one-place tuning.
69
85
  */
70
86
  export declare function configureScoring(config?: Record<string, unknown> | null): ScoringWeights;
71
87
  /** Current resolved weights (tests + status output). */
72
88
  export declare function getScoringWeights(): ScoringWeights;
89
+ /** EMA rate for the session-intent centroid: centroid_new = (1-α)·old + α·prompt. */
90
+ export declare const SESSION_INTENT_ALPHA = 0.4;
91
+ /**
92
+ * Configure session-intent keying from config. Called at server boot next to
93
+ * configureScoring (the nightly does no recall, so it does not need this).
94
+ * Reads only `sessionIntentWeight` ([0,1]; 0 = disabled). Invalid/out-of-range
95
+ * values keep the shipped default. Returns `{ weight, alpha }` — alpha is the
96
+ * fixed constant, surfaced so the recall closure passes it to the registry in
97
+ * one call.
98
+ */
99
+ export declare function configureSessionIntent(config?: Record<string, unknown> | null): {
100
+ weight: number;
101
+ alpha: number;
102
+ };
103
+ /** Current resolved session-intent weight + the shipped alpha (closure + tests). */
104
+ export declare function getSessionIntent(): {
105
+ weight: number;
106
+ alpha: number;
107
+ };
108
+ /**
109
+ * Blend the prompt embedding with the session-intent centroid for the vector
110
+ * search: `query = l2Normalize((1-w)·prompt + w·centroid)`. Returns the prompt
111
+ * UNCHANGED when `centroid` is undefined (first turn — no behavior change) or
112
+ * `weight` is 0 (the kill-switch — pure prompt). Extracted from the
113
+ * /recall-index closure (mcp-server.ts) so the exact blend decision is
114
+ * unit-testable directly, locking the ternary against a refactor without a
115
+ * closure-integration harness.
116
+ */
117
+ export declare function blendQueryVector(promptEmb: Float32Array, centroid: Float32Array | undefined, weight: number): Float32Array;
73
118
  /**
74
119
  * Ids among `candidateIds` that have been superseded by a later memory — i.e.
75
120
  * they are the SOURCE of a `superseded_by` link (stageSupersession links
@@ -98,9 +143,31 @@ export declare function effectiveStrength(baseStrength: number, lastAccessed: st
98
143
  /**
99
144
  * Return a composite relevance score in [0, 1] for a candidate memory.
100
145
  * Exported for exact-value tests of the similarity component (#145).
146
+ *
147
+ * #203 soft affinity (options.scope + options.tagWeights): two additive,
148
+ * graded, zero-boost-neutral terms — project affinity (exact project match)
149
+ * and domain affinity (max overlapping memory_tags.weight × scope). Both are
150
+ * 0 when the scope is absent (byte-identical to pre-#203) and NEVER negative
151
+ * (a foreign memory adds 0, never a penalty — penalties re-introduce
152
+ * soft-exclusion). See `AffinityScope`.
101
153
  */
154
+ export interface AffinityScope {
155
+ /** Exact-match project from the client (CC/OC cwd-derived; /search project). */
156
+ project?: string | null;
157
+ /** Hermes mission domains declared in plugin config. Drawn from the same
158
+ * vocabulary as memory_tags (config `domains`). */
159
+ missionDomains?: string[];
160
+ }
102
161
  export declare function computeScore(memory: Memory, distance: number, connectionCount: number, maxConnections: number, now: Date, options?: {
103
162
  superseded?: boolean;
163
+ /** #203: when present, project/domain affinity boosts are applied. */
164
+ scope?: AffinityScope;
165
+ /** Candidate's graded domain tags (memory_tags rows). Loaded batched for
166
+ * the whole candidate set in retrieve(); used for domain affinity. */
167
+ tagWeights?: Array<{
168
+ tag: string;
169
+ weight: number | null;
170
+ }>;
104
171
  }): number;
105
172
  export interface EmbedFn {
106
173
  (text: string): Promise<Float32Array>;
@@ -108,15 +175,35 @@ export interface EmbedFn {
108
175
  /**
109
176
  * Main retrieval: BM25 + vector search with RRF fusion, graph traversal,
110
177
  * and composite scoring. Strengthens accessed memories.
178
+ *
179
+ * #203 retrieval scoping: `project` and `missionDomains` are SOFT affinity
180
+ * terms in computeScore (zero-boost neutral, never a penalty), NOT filters.
181
+ * `privacy` remains a hard filter (security boundary). `sourceAgent` remains a
182
+ * hard filter (kept for completeness; no production caller currently passes
183
+ * it). When neither project nor missionDomains is sent, scoring is byte-
184
+ * identical to pre-#203 — the kill-switch / no-op guarantee.
111
185
  */
112
186
  export declare function retrieve(db: Database.Database, embedFn: EmbedFn, query: string, options?: {
113
187
  limit?: number;
188
+ /** #203: soft project affinity (exact match boost in computeScore).
189
+ * Formerly a hard WHERE filter (#192); softening removes cross-scope
190
+ * starvation without excluding anything. */
114
191
  project?: string | null;
115
192
  privacy?: string[];
116
193
  sourceAgent?: string;
194
+ /** #203: Hermes mission domains (declared in plugin config). Soft domain
195
+ * affinity in computeScore via max overlapping memory_tags.weight. */
196
+ missionDomains?: string[];
117
197
  /** #192: skip access strengthening — for pushed recall (/recall-index),
118
198
  * where appearing in results must not count as use. */
119
199
  noStrengthen?: boolean;
200
+ /** #192 session-intent keying (0.15.3): a pre-computed query embedding
201
+ * (e.g. the session-centroid blend from the /recall-index closure). When
202
+ * provided, the internal embed() call is SKIPPED — the caller owns the
203
+ * one embed per recall. /search and other unblended callers omit this
204
+ * and get pure-prompt behavior (the query string is embedded here). The
205
+ * FTS path still uses the raw `query` text regardless. */
206
+ queryEmbedding?: Float32Array;
120
207
  }): Promise<MemorySearchResult[]>;
121
208
  /**
122
209
  * Get recent context, optionally filtered by project and privacy.