@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.
package/README.md CHANGED
@@ -122,7 +122,7 @@ The run is resumable — interrupt it any time and it continues where it stopped
122
122
  - **hicortex_update** — Fix incorrect memories (re-embeds on content change)
123
123
  - **hicortex_delete** — Remove memories with cascade cleanup
124
124
 
125
- Skills: `/learn` to save explicit learnings.
125
+ Explicit learnings: call `hicortex_ingest` directly (capture is otherwise automatic, nightly).
126
126
 
127
127
  ## Context Layer
128
128
 
@@ -202,6 +202,7 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
202
202
  | `models` | Optional nested per-stage model overrides (`score`/`distill`/`reflect`/`classify`) — see [Advanced: per-stage models](#advanced-per-stage-models) |
203
203
  | `distillFallback` | `"strict"` (default) — abort on remote distill failure, retry next run; `"local"` — fall back to base model (lower quality, 0.9.0 behaviour) |
204
204
  | `authToken` | Bearer token for endpoint auth. Generated on first `init` in server mode. Find the active token with `hicortex status` or in `~/.hicortex/config.json`. |
205
+ | `corsAllowedOrigins` | Browser origins allowed to read cross-origin responses, e.g. `["https://ui.example.com"]`. **Empty by default** — the server sends no `Access-Control-Allow-Origin` and never `Allow-Credentials`, so no external web page can read its data. The bundled `/viz` and `/context/ui` pages are same-origin and need no entry. |
205
206
  | `licenseKey` | Commercial license key (optional; for display in `hicortex status`) |
206
207
  | `domains` | Your memory domain list (`[{name, description}]`). Scaffolded by `init`; edit freely — see [Memory Domains & Tags](#memory-domains--tags) |
207
208
  | `weakPrimaryFloor` | Minimum similarity for a no-fit memory to keep a weak domain association (default: 0.45) |
@@ -225,6 +226,7 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
225
226
  | `recallMinSimilarity` | Relevance floor for index entries (default: 0.55; text-search matches always pass) |
226
227
  | `recallReshowTurns` | Turns before an already-shown memory may reappear in the same session (default: 30) |
227
228
  | `recallMinPromptChars` | Prompts shorter than this skip the recall index (default: 20) |
229
+ | `sessionIntentWeight` | Blend weight of the session-intent rolling centroid in the recall search vector: `query = (1-w)·prompt + w·centroid` (default: 0.33; set 0 to disable — pure-prompt recall, the kill-switch). The first turn of a session searches with pure prompt and seeds the centroid; subsequent turns blend so recall follows the session's intent instead of being query-literal. The EMA rate (0.4) is a shipped constant, not configurable |
228
230
  | `dedupMergeThreshold` | Minimum cosine similarity for `hicortex dedup` to cluster memories as near-duplicates (default: 0.92) |
229
231
  | `supersessionMinSimilarity` | Minimum cosine similarity for a nightly supersession candidate pair (default: 0.80) |
230
232
  | `supersessionMaxCalls` | Max classify-tier LLM calls the nightly's supersession stage spends per run (default: 30) |
@@ -162,9 +162,10 @@ export declare function buildSupersessionPrompt(oldContent: string, newContent:
162
162
  export declare function parseSupersessionReply(reply: string): boolean | null;
163
163
  /**
164
164
  * Nightly supersession-detection stage. Scans memories/rowid > cursor whose
165
- * shape suggests a decision/correction, checks each against its older
166
- * same-shape neighbors, and links confirmed supersessions. Dry-run performs
167
- * discovery + the free idempotency check only — no LLM calls, no writes, no
165
+ * shape is supersedable (decision/correction/fact/state isSupersedableShape),
166
+ * checks each against its older same-shape neighbors, and links confirmed
167
+ * supersessions. Dry-run performs discovery + the free idempotency check only —
168
+ * no LLM calls, no writes, no
168
169
  * cursor persistence (mirrors stageImportance/stageContentDomains's dry-run
169
170
  * convention of never spending budget on a preview).
170
171
  *
@@ -857,11 +857,21 @@ const SUPERSESSION_NEIGHBOR_POOL = 15;
857
857
  const SUPERSESSION_NEIGHBOR_TOP_K = 5;
858
858
  /** Candidate rows read per SQL page (call budget stops the loop well before this in practice). */
859
859
  const SUPERSESSION_BATCH_SIZE = 500;
860
- /** A memory whose content/type marks it as a decision or correction. */
861
- function isDecisionShape(mem) {
860
+ /**
861
+ * A memory whose content/type marks it as a SUPERSEDABLE claim — one a newer
862
+ * memory about the same subject can replace. Decisions and corrections were the
863
+ * original scope; plain facts and project-state updates were added because an
864
+ * updated fact ("distillModel is X" → later "is Y") otherwise never gets a
865
+ * superseded_by link and both versions compete in recall forever. Ordinary
866
+ * episodic chatter and problem/solution history stay excluded: they record
867
+ * events, not mutable state, so there is nothing to supersede.
868
+ */
869
+ function isSupersedableShape(mem) {
862
870
  return (mem.memory_type === "decision" ||
863
871
  mem.content.includes("[Decisions Made]") ||
864
- mem.content.includes("[Corrections & Rejections]"));
872
+ mem.content.includes("[Corrections & Rejections]") ||
873
+ mem.content.includes("[Facts Learned]") ||
874
+ mem.content.includes("[Project State Changes]"));
865
875
  }
866
876
  /** True when a `superseded_by` link already exists between the pair, either direction. */
867
877
  function alreadySupersedeLinked(db, oldId, newId) {
@@ -881,9 +891,12 @@ function buildSupersessionPrompt(oldContent, newContent) {
881
891
  return (`You are checking whether a NEWER memory supersedes an OLDER one in an AI agent's long-term memory.\n\n` +
882
892
  `OLDER MEMORY:\n${trunc(oldContent)}\n\n` +
883
893
  `NEWER MEMORY:\n${trunc(newContent)}\n\n` +
884
- `Does the NEWER memory reverse, replace, or invalidate the OLDER one — e.g. a later decision overturns an ` +
885
- `earlier one, or a correction retracts a prior claim? Two memories that are merely related, or that both ` +
886
- `still hold true, are NOT a supersession.\n` +
894
+ `Does the NEWER memory reverse, replace, update, or invalidate the OLDER one — e.g. a later decision ` +
895
+ `overturns an earlier one, a correction retracts a prior claim, or a later fact updates the SAME subject's ` +
896
+ `value/status that has since changed (e.g. "model is X" → "model is Y")? Reply true ONLY for a genuine ` +
897
+ `replacement of the same fact/decision. Two memories that are merely related, or that can both still be ` +
898
+ `true — even about the same project or entity (different facts, an addition, an elaboration) — are NOT a ` +
899
+ `supersession.\n` +
887
900
  `Reply with ONLY a JSON object, no prose: {"superseded": true} or {"superseded": false}.`);
888
901
  }
889
902
  /**
@@ -932,16 +945,17 @@ async function findOlderNeighbors(db, candidate, embedFn, minSimilarity) {
932
945
  return storage
933
946
  .vectorSearch(db, embedding, SUPERSESSION_NEIGHBOR_POOL, [candidate.id])
934
947
  .filter((n) => n.created_at < candidate.created_at &&
935
- isDecisionShape(n) &&
948
+ isSupersedableShape(n) &&
936
949
  (0, retrieval_js_1.l2ToCosine)(n.distance) >= minSimilarity)
937
950
  .sort((a, b) => (0, retrieval_js_1.l2ToCosine)(b.distance) - (0, retrieval_js_1.l2ToCosine)(a.distance))
938
951
  .slice(0, SUPERSESSION_NEIGHBOR_TOP_K);
939
952
  }
940
953
  /**
941
954
  * Nightly supersession-detection stage. Scans memories/rowid > cursor whose
942
- * shape suggests a decision/correction, checks each against its older
943
- * same-shape neighbors, and links confirmed supersessions. Dry-run performs
944
- * discovery + the free idempotency check only — no LLM calls, no writes, no
955
+ * shape is supersedable (decision/correction/fact/state isSupersedableShape),
956
+ * checks each against its older same-shape neighbors, and links confirmed
957
+ * supersessions. Dry-run performs discovery + the free idempotency check only —
958
+ * no LLM calls, no writes, no
945
959
  * cursor persistence (mirrors stageImportance/stageContentDomains's dry-run
946
960
  * convention of never spending budget on a preview).
947
961
  *
@@ -965,9 +979,16 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
965
979
  const maxCalls = validNumber(options.maxCalls, exports.DEFAULT_SUPERSESSION_MAX_CALLS, (n) => n >= 0);
966
980
  const startCursor = (0, state_js_1.loadState)(stateDir).supersessionCursor ?? 0;
967
981
  const rows = db
968
- .prepare(`SELECT rowid AS __rowid, * FROM memories
982
+ .prepare(
983
+ // Candidate shape must mirror isSupersedableShape() exactly — keep the two
984
+ // in lockstep (an inline SQL copy, so drift here silently narrows scope).
985
+ `SELECT rowid AS __rowid, * FROM memories
969
986
  WHERE rowid > ?
970
- AND (memory_type = 'decision' OR content LIKE '%[Decisions Made]%' OR content LIKE '%[Corrections & Rejections]%')
987
+ AND (memory_type = 'decision'
988
+ OR content LIKE '%[Decisions Made]%'
989
+ OR content LIKE '%[Corrections & Rejections]%'
990
+ OR content LIKE '%[Facts Learned]%'
991
+ OR content LIKE '%[Project State Changes]%')
971
992
  ORDER BY rowid ASC LIMIT ?`)
972
993
  .all(startCursor, SUPERSESSION_BATCH_SIZE);
973
994
  let scanned = 0;
package/dist/db.js CHANGED
@@ -126,20 +126,33 @@ CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
126
126
  CREATE INDEX IF NOT EXISTS idx_links_source ON memory_links(source_id);
127
127
  CREATE INDEX IF NOT EXISTS idx_links_target ON memory_links(target_id);
128
128
  `;
129
+ // Fielded FTS5 (#205, migration v10 "fts_fielded"): three columns so bm25()
130
+ // can weight matches per field (body / project / domain). Column order matters
131
+ // — the weights passed to `bm25(memories_fts, w_body, w_project, w_domain)` in
132
+ // storage.searchFts are positional on this declaration. `domain` reads the
133
+ // derived PRIMARY `memories.domain` (the argmax-weight tag from the classifier,
134
+ // set nightly), NOT a memory_tags join — that was judged too fiddly for this
135
+ // phase (a multi-table FTS trigger is fragile and rebuilds on every tag edit).
136
+ // `content_rowid='rowid'` is preserved for lockstep with the legacy schema.
129
137
  const FTS_SCHEMA = `
130
138
  CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
131
139
  content,
140
+ project,
141
+ domain,
132
142
  content_rowid='rowid'
133
143
  );
134
144
 
135
145
  CREATE TRIGGER IF NOT EXISTS memories_fts_insert AFTER INSERT ON memories
136
146
  BEGIN
137
- INSERT INTO memories_fts (rowid, content) VALUES (NEW.rowid, NEW.content);
147
+ INSERT INTO memories_fts (rowid, content, project, domain)
148
+ VALUES (NEW.rowid, NEW.content, NEW.project, NEW.domain);
138
149
  END;
139
150
 
140
- CREATE TRIGGER IF NOT EXISTS memories_fts_update AFTER UPDATE OF content ON memories
151
+ CREATE TRIGGER IF NOT EXISTS memories_fts_update AFTER UPDATE OF content, project, domain ON memories
141
152
  BEGIN
142
- UPDATE memories_fts SET content = NEW.content WHERE rowid = NEW.rowid;
153
+ UPDATE memories_fts
154
+ SET content = NEW.content, project = NEW.project, domain = NEW.domain
155
+ WHERE rowid = NEW.rowid;
143
156
  END;
144
157
 
145
158
  CREATE TRIGGER IF NOT EXISTS memories_fts_delete AFTER DELETE ON memories
@@ -361,6 +374,73 @@ const MIGRATIONS = [
361
374
  db.exec("CREATE INDEX IF NOT EXISTS idx_dedup_log_source_session ON dedup_log(source_session)");
362
375
  },
363
376
  },
377
+ {
378
+ version: 10,
379
+ name: "fts_fielded",
380
+ up: (db) => {
381
+ // #205 fielded BM25F. FTS5 cannot be ALTERed (see the virtual-table rule
382
+ // above), so the single-column `memories_fts(content)` from pre-v10 must
383
+ // be dropped + recreated multi-column (`content, project, domain`) and
384
+ // rebuilt from the canonical `memories` rows. The runner wraps up() in a
385
+ // single transaction (db.ts:216) — that transaction IS the crash window:
386
+ // a power loss mid-migration rolls back, leaving the OLD single-column
387
+ // FTS intact (recall falls back to vector-only until the next open;
388
+ // never silently empty). The nightly capture lock does NOT cover
389
+ // initDb migrations, so the tx guard is the only safety net.
390
+ //
391
+ // Idempotent by construction: DROP IF EXISTS + CREATE + rebuild. On a
392
+ // fresh DB (where FTS_SCHEMA already created the multi-column form) this
393
+ // is a 0-row rebuild — wasted but harmless. On a legacy single-column DB
394
+ // it converts in place. Re-running against an already-migrated DB is a
395
+ // no-op shape + a refill of the same rows.
396
+ //
397
+ // Triggers are dropped + recreated to pick up the new column list (the
398
+ // pre-v10 update trigger fired only on `UPDATE OF content`; the new one
399
+ // also fires on project/domain updates so a tag reclassification lands
400
+ // in FTS without a content edit).
401
+ db.exec("DROP TRIGGER IF EXISTS memories_fts_insert");
402
+ db.exec("DROP TRIGGER IF EXISTS memories_fts_update");
403
+ db.exec("DROP TRIGGER IF EXISTS memories_fts_delete");
404
+ db.exec("DROP TABLE IF EXISTS memories_fts");
405
+ db.exec(`
406
+ CREATE VIRTUAL TABLE memories_fts USING fts5(
407
+ content,
408
+ project,
409
+ domain,
410
+ content_rowid='rowid'
411
+ )
412
+ `);
413
+ // COALESCE on project/domain because FTS5 stores NULL as no-tokens,
414
+ // which is what we want for unscoped memories (NULL domain = not yet
415
+ // classified; NULL project = no cwd-derived label). content is NOT NULL
416
+ // by the insert contract.
417
+ db.exec(`
418
+ INSERT INTO memories_fts (rowid, content, project, domain)
419
+ SELECT rowid, content, COALESCE(project, ''), COALESCE(domain, '') FROM memories
420
+ `);
421
+ db.exec(`
422
+ CREATE TRIGGER memories_fts_insert AFTER INSERT ON memories
423
+ BEGIN
424
+ INSERT INTO memories_fts (rowid, content, project, domain)
425
+ VALUES (NEW.rowid, NEW.content, NEW.project, NEW.domain);
426
+ END
427
+ `);
428
+ db.exec(`
429
+ CREATE TRIGGER memories_fts_update AFTER UPDATE OF content, project, domain ON memories
430
+ BEGIN
431
+ UPDATE memories_fts
432
+ SET content = NEW.content, project = NEW.project, domain = NEW.domain
433
+ WHERE rowid = NEW.rowid;
434
+ END
435
+ `);
436
+ db.exec(`
437
+ CREATE TRIGGER memories_fts_delete AFTER DELETE ON memories
438
+ BEGIN
439
+ DELETE FROM memories_fts WHERE rowid = OLD.rowid;
440
+ END
441
+ `);
442
+ },
443
+ },
364
444
  ];
365
445
  /**
366
446
  * Run all pending migrations against the database.
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Recall@k sweep eval for session-intent keying (#192, commit 0867d6c).
4
+ *
5
+ * SHIP GATE: does blending the prompt with the per-session EMA centroid
6
+ * (retrieval.blendQueryVector) improve recall across rephrased turns, AND
7
+ * what does it COST when a session shifts topics mid-stream? Picks the
8
+ * default `sessionIntentWeight`.
9
+ *
10
+ * Two scenario families, same corpus:
11
+ *
12
+ * FOCUSED — 6 sessions (one per topic), 4 turns each of GENUINE REPHRASES
13
+ * of one intent. Tests the UPSIDE: does the centroid pull drifting
14
+ * rephrases back on-topic? (recall@5 / p@5 should rise with w.)
15
+ *
16
+ * SHIFT — 5 sessions, each 2 turns on topic A then 2 turns on topic B (a
17
+ * clean mid-session topic change). Tests the DOWNSIDE that focused-only
18
+ * can't see: a higher weight leaves the centroid partly A right after the
19
+ * shift, so the first turn(s) on B may LAG (lower recall@5 for B). The
20
+ * load-bearing read-out is post-shift recovery at turn-3 (first B turn)
21
+ * and turn-4, plus turns-to-recover.
22
+ *
23
+ * Method (both families):
24
+ * - Synthetic corpus of 30 memories across 6 well-separated topics (5 each),
25
+ * embedded with the REAL bge-small-en-v1.5 model (the production embedder).
26
+ * - For each weight w in {0.0, 0.2, 0.4, 0.5, 0.6, 0.8}: fresh
27
+ * SessionRecallRegistry per session; per turn, embed the prompt ONCE,
28
+ * blend with the live centroid, pass the blended vector to retrieve() via
29
+ * queryEmbedding, record recall@5 + p@5 for the turn's CURRENT topic,
30
+ * then fold the prompt into the centroid (EMA α=0.4).
31
+ *
32
+ * Fairness controls (load-bearing — the eval is useless if these slip):
33
+ * - `noStrengthen: true` on every retrieve(): the DB stays STATIC across all
34
+ * retrieve calls. Strengthening would mutate effective_strength/access_count
35
+ * between runs and contaminate cross-weight comparisons.
36
+ * - Uniform memory metadata: every memory has base_strength=0.5, created_at
37
+ * ≈ now, access_count=0, and NO links. So effective_strength, recency,
38
+ * connections, and the freshness boost are all uniform → the ONLY
39
+ * discriminator is vector cosine + RRF rank. That isolates the blend.
40
+ * - Cold-exposure slots are a no-op here (all candidates equally cold),
41
+ * so the top-k is the plain score order.
42
+ * - The centroid update is weight-INDEPENDENT (EMA of prompts at α=0.4), so
43
+ * the per-turn centroid is identical across all weights for a given
44
+ * session; only the blend differs. Embeddings are precomputed once.
45
+ *
46
+ * Metrics (per turn, for the turn's CURRENT topic):
47
+ * - recall@5 — did >=1 same-topic memory surface in the top-5? (coarse; the
48
+ * requested ship-gate metric — saturates on a small corpus.)
49
+ * - p@5 — on-topic count in the top-5 (0..5, finer).
50
+ * - recall@1 — was the SINGLE top result on-topic? (finest; focused only.)
51
+ *
52
+ * Honest by construction: it prints whatever the numbers are, including
53
+ * w=0 winning, the blend hurting a focused scenario, or high weights lagging
54
+ * badly post-shift. Report written to data/eval-recall-sweep/report.md and
55
+ * printed to stdout.
56
+ *
57
+ * ## Section 4 — SCOPE dimension (#203 soft project affinity)
58
+ *
59
+ * A THIRD family on a SEPARATE project-labeled corpus (the blend-sweep corpus
60
+ * above has no project labels). Goal: prove #203's soft project affinity drops
61
+ * cross-scope noise (the boat/battery case — a "hardware" query surfacing
62
+ * "marine" memories on a shared token like "battery") below the cap WITHOUT a
63
+ * hard filter, and WITHOUT losing same-scope recall.
64
+ *
65
+ * Corpus: 6 hardware + 6 marine memories. 3 marine memories are
66
+ * CONTAMINATION SEEDS (share a token with the hardware queries); 3 are
67
+ * marine-only fillers (control).
68
+ *
69
+ * Each hardware query runs TWICE on the same static DB:
70
+ * scope OFF — no `project` sent (byte-identical to pre-#203).
71
+ * scope ON — `project: "hardware"` (computeScore adds +projectAffinity 0.15
72
+ * to hardware memories; marine gets 0; no hard filter).
73
+ *
74
+ * Metrics: contamination@5 (marine in top-5 / 5 — LOWER is better),
75
+ * recall@5 (gold hardware memory surfaced), sameScope@5 (hardware in top-5).
76
+ * Scope is orthogonal to the blend, so the blend weight is held at 0 here —
77
+ * `project` is the ONLY variable. Same invariants (noStrengthen, real
78
+ * embedder, uniform metadata, neverCalledEmbed self-check).
79
+ *
80
+ * Run: npm run eval:recall-sweep (== node dist/eval/recall-sweep.js)
81
+ */
82
+ export {};