@gamaze/hicortex 0.10.0 → 0.11.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.
Files changed (49) hide show
  1. package/README.md +58 -1
  2. package/THIRD_PARTY_NOTICES.md +108 -0
  3. package/assets/vendor/3d-force-graph.min.js +5 -0
  4. package/assets/vendor/force-graph.min.js +5 -0
  5. package/assets/vendor/three.core.min.js +6 -0
  6. package/assets/vendor/three.module.min.js +6 -0
  7. package/assets/viz.html +1126 -0
  8. package/dist/classify-domains.d.ts +98 -0
  9. package/dist/classify-domains.js +340 -0
  10. package/dist/cli.d.ts +1 -0
  11. package/dist/cli.js +63 -0
  12. package/dist/consolidate.d.ts +139 -2
  13. package/dist/consolidate.js +302 -87
  14. package/dist/db.js +70 -0
  15. package/dist/domain-classify.d.ts +164 -0
  16. package/dist/domain-classify.js +300 -0
  17. package/dist/extensions.d.ts +12 -0
  18. package/dist/graph.d.ts +56 -0
  19. package/dist/graph.js +145 -0
  20. package/dist/index.js +1 -1
  21. package/dist/init.d.ts +25 -0
  22. package/dist/init.js +54 -0
  23. package/dist/lesson-selection.js +12 -5
  24. package/dist/lessons-context.js +2 -1
  25. package/dist/llm.d.ts +67 -0
  26. package/dist/llm.js +122 -0
  27. package/dist/mcp-server.js +82 -27
  28. package/dist/nightly-status.js +9 -28
  29. package/dist/nightly.js +42 -32
  30. package/dist/nofit.d.ts +111 -0
  31. package/dist/nofit.js +176 -0
  32. package/dist/prompts.d.ts +0 -5
  33. package/dist/prompts.js +5 -29
  34. package/dist/relink.d.ts +100 -0
  35. package/dist/relink.js +277 -0
  36. package/dist/retrieval.d.ts +16 -1
  37. package/dist/retrieval.js +34 -2
  38. package/dist/schema-prototypes.d.ts +149 -0
  39. package/dist/schema-prototypes.js +329 -0
  40. package/dist/state.d.ts +32 -0
  41. package/dist/state.js +29 -0
  42. package/dist/status.js +12 -19
  43. package/dist/storage.d.ts +44 -1
  44. package/dist/storage.js +70 -1
  45. package/dist/types.d.ts +90 -0
  46. package/dist/viz.d.ts +69 -0
  47. package/dist/viz.js +180 -0
  48. package/domains.example.json +36 -0
  49. package/package.json +6 -3
@@ -4,9 +4,49 @@
4
4
  * Ported from hicortex/consolidate/ (stages.py, __init__.py, budget.py).
5
5
  */
6
6
  import type Database from "better-sqlite3";
7
- import type { ConsolidationReport } from "./types.js";
7
+ import type { Memory, ConsolidationReport } from "./types.js";
8
8
  import type { LlmClient } from "./llm.js";
9
9
  import type { EmbedFn } from "./retrieval.js";
10
+ import { type DomainDef } from "./domain-classify.js";
11
+ /**
12
+ * Minimum COSINE similarity for a link candidate.
13
+ *
14
+ * Calibration (2026-07): measured top-10 neighbor cosine histogram on the
15
+ * 2945-memory production corpus (bedrock). Typical top-1 neighbor cosine:
16
+ * median 0.823, p10 0.743, p90 0.902. Threshold 0.75 combined with the
17
+ * top-3 cap yields ≈ 2.2 candidate links/memory. The previous value (0.55)
18
+ * lived on an accidental 1−L2 scale where it required cosine > 0.90 — a
19
+ * near-duplicate detector that linked only 12% of memories.
20
+ */
21
+ export declare const CONSOLIDATE_LINK_THRESHOLD = 0.75;
22
+ /** Max link candidates kept per memory (highest-cosine neighbors first). */
23
+ export declare const CONSOLIDATE_LINK_TOP_K = 3;
24
+ /**
25
+ * Minimum COSINE similarity for a CROSS-PROJECT link candidate.
26
+ *
27
+ * A 672-link audit (17 LLM judges, 2026-07) found cross-project links were 65%
28
+ * wrong-link vs 6% for same-project, and that strength (cosine) predicts quality
29
+ * (wrong-link 42% → 6% across strength quartiles). Cross-project pairs must clear
30
+ * a much higher bar than the same-project 0.75 to survive discovery. Same-project
31
+ * links keep CONSOLIDATE_LINK_THRESHOLD (0.75).
32
+ */
33
+ export declare const CROSS_PROJECT_LINK_THRESHOLD = 0.8;
34
+ /**
35
+ * l2ToCosine moved to retrieval.ts (#145) — consolidate.ts already imports
36
+ * from retrieval, so retrieval is the circular-dependency-safe home.
37
+ * Re-exported here so pre-#145 importers and tests keep working unchanged.
38
+ */
39
+ export { l2ToCosine } from "./retrieval.js";
40
+ /**
41
+ * Minimum TRUE cosine similarity between a new lesson and an existing one
42
+ * for the existing lesson to count as a contradiction-check candidate
43
+ * (stageReflection). 0.80 = "strongly similar lesson" — the original intent.
44
+ * Before #145 the check was `1 − L2 > 0.80`, which required cosine > 0.98,
45
+ * so lesson-contradiction suppression effectively never fired.
46
+ */
47
+ export declare const REFLECTION_CONTRADICTION_MIN_COSINE = 0.8;
48
+ /** True when an L2 neighbor distance clears the contradiction-check bar. */
49
+ export declare function isContradictionCandidate(distance: number): boolean;
10
50
  export declare class BudgetTracker {
11
51
  maxCalls: number;
12
52
  callsUsed: number;
@@ -21,10 +61,102 @@ export declare class BudgetTracker {
21
61
  * Parse JSON from LLM output, tolerating markdown fences and indexed formats.
22
62
  */
23
63
  export declare function parseJsonLenient<T>(text: string, fallback: T): T;
64
+ /**
65
+ * Rebuild moduleIndex from the configured domain set + live DB counts, and
66
+ * persist it. Shared by the nightly stage and `hicortex classify-domains`.
67
+ * `projects` is left empty (content domains don't map to projects); the lesson
68
+ * selector's same-domain boost instead keys off memory.domain directly (it
69
+ * still reads the field). Descriptions are carried through for /index.
70
+ */
71
+ export declare function rebuildContentModuleIndex(db: Database.Database, domains: DomainDef[], stateDir?: string): {
72
+ domains: number;
73
+ };
74
+ /** A candidate link discovered by vector similarity, pending classification. */
75
+ export interface LinkCandidate {
76
+ source: Memory;
77
+ target: Memory & {
78
+ distance: number;
79
+ };
80
+ similarity: number;
81
+ heuristicType: string;
82
+ }
83
+ /**
84
+ * Discovery: find link candidates for one memory given its embedding.
85
+ * Top-10 vector neighbors (excluding self), keep the CONSOLIDATE_LINK_TOP_K
86
+ * highest-cosine neighbors above CONSOLIDATE_LINK_THRESHOLD, pre-compute the
87
+ * heuristic relationship type.
88
+ *
89
+ * Shared between the nightly `stageLinks` (which embeds via embedFn) and
90
+ * `hicortex relink` (which reuses stored embeddings from memory_vectors).
91
+ */
92
+ export declare function discoverLinkCandidates(db: Database.Database, mem: Memory, embedding: Float32Array): LinkCandidate[];
93
+ /**
94
+ * Classification: assign a relationship type to each candidate link.
95
+ *
96
+ * HEURISTIC-ONLY (2026-07). LLM edge classification was retired after the
97
+ * 672-link audit (see the Stage 3 header) found the LLM-classified UPPERCASE
98
+ * types near-useless (CONTRADICTS 4% acceptable). Every candidate now takes its
99
+ * pre-computed `heuristicType` (only `extends` or `relates_to` — see
100
+ * classifyRelationship). No LLM call is made.
101
+ *
102
+ * Signature stability: `llm` and `budget` are RETAINED but intentionally
103
+ * ignored so the callers (nightly `stageLinks`, `hicortex relink`) and the
104
+ * tests that import this need no change to their call sites. The return shape
105
+ * is unchanged; `llmClassified` is always 0 now and `heuristicFallback` counts
106
+ * every candidate. Do NOT re-add an LLM path here without a classifier that
107
+ * passes the audit harness at >= 70% acceptable.
108
+ *
109
+ * Shared between the nightly `stageLinks` and `hicortex relink`.
110
+ * Returns one relationship type per candidate (same order as input).
111
+ */
112
+ export declare function classifyLinkCandidates(candidates: LinkCandidate[], _llm: LlmClient | null, _budget: BudgetTracker): Promise<{
113
+ types: string[];
114
+ llmClassified: number;
115
+ heuristicFallback: number;
116
+ }>;
117
+ /**
118
+ * Classify the relationship between two memories.
119
+ *
120
+ * TWO-LABEL heuristic (2026-07). The 672-link audit (see the Stage 3 header)
121
+ * showed only `extends` (57% acceptable) and `relates_to` (53%) held up; the
122
+ * emitted vocabulary is collapsed to exactly those two. The retired labels
123
+ * `updates` and `derives` (~31% acceptable) and all UPPERCASE LLM types are no
124
+ * longer produced. They remain in VALID_RELATIONSHIP_TYPES so pre-existing rows
125
+ * still validate.
126
+ *
127
+ * Rule: same-project (both projects non-null and equal) AND higher cosine
128
+ * (> CONSOLIDATE_LINK_THRESHOLD) → `extends`; everything else → `relates_to`.
129
+ *
130
+ * `similarity` is COSINE similarity (see l2ToCosine); the CONSOLIDATE_LINK_THRESHOLD
131
+ * boundary from the l2ToCosine calibration is preserved.
132
+ */
133
+ export declare function classifyRelationship(source: Memory, target: Memory, similarity: number): string;
24
134
  /**
25
135
  * Run the full consolidation pipeline. Returns a structured report.
26
136
  */
27
- export declare function runConsolidation(db: Database.Database, llm: LlmClient, embedFn: EmbedFn, dryRun?: boolean, skipReflection?: boolean, stateDir?: string): Promise<ConsolidationReport>;
137
+ /**
138
+ * Options controlling how the domain-assignment stage runs.
139
+ *
140
+ * When `domains` is a non-empty list, the pipeline uses content-based
141
+ * classification (config-owned) INSTEAD of project grouping — provided the
142
+ * classification endpoint (classify tier when configured, else reflect) passed
143
+ * pre-flight (`contentDomainsReady`). If that endpoint is unreachable, the
144
+ * caller sets `contentDomainsReady: false` and the stage is SKIPPED entirely
145
+ * (strict — no fall-back to a weak model or to project grouping). When
146
+ * `domains` is absent/empty, the legacy project-grouping curation runs
147
+ * unchanged.
148
+ */
149
+ export interface DomainStageOptions {
150
+ domains?: DomainDef[] | null;
151
+ contentDomainsReady?: boolean;
152
+ /**
153
+ * Weak-primary floor for the no-fit path (see nofit.ts). Resolved by the
154
+ * caller from config (`weakPrimaryFloor`); defaults to
155
+ * DEFAULT_WEAK_PRIMARY_FLOOR when absent.
156
+ */
157
+ weakPrimaryFloor?: number;
158
+ }
159
+ export declare function runConsolidation(db: Database.Database, llm: LlmClient, embedFn: EmbedFn, dryRun?: boolean, skipReflection?: boolean, stateDir?: string, domainOptions?: DomainStageOptions): Promise<ConsolidationReport>;
28
160
  /**
29
161
  * Calculate milliseconds until the next occurrence of a given hour (local time).
30
162
  */
@@ -32,5 +164,10 @@ export declare function msUntilHour(hour: number): number;
32
164
  /**
33
165
  * Schedule the consolidation pipeline to run nightly.
34
166
  * Returns a cleanup function to cancel the timer.
167
+ *
168
+ * NOTE: currently unused (nightly.ts drives consolidation directly). Any future
169
+ * caller MUST read config.domains and thread `domainOptions` into runConsolidation
170
+ * when content domains are configured — otherwise it silently falls back to the
171
+ * legacy project-grouping path even when a domain list is set.
35
172
  */
36
173
  export declare function scheduleConsolidation(db: Database.Database, llm: LlmClient, embedFn: EmbedFn, hour?: number): () => void;
@@ -38,12 +38,16 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  };
39
39
  })();
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
- exports.BudgetTracker = void 0;
41
+ exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = void 0;
42
+ exports.isContradictionCandidate = isContradictionCandidate;
42
43
  exports.parseJsonLenient = parseJsonLenient;
44
+ exports.rebuildContentModuleIndex = rebuildContentModuleIndex;
45
+ exports.discoverLinkCandidates = discoverLinkCandidates;
46
+ exports.classifyLinkCandidates = classifyLinkCandidates;
47
+ exports.classifyRelationship = classifyRelationship;
43
48
  exports.runConsolidation = runConsolidation;
44
49
  exports.msUntilHour = msUntilHour;
45
50
  exports.scheduleConsolidation = scheduleConsolidation;
46
- const types_js_1 = require("./types.js");
47
51
  const retrieval_js_1 = require("./retrieval.js");
48
52
  const storage = __importStar(require("./storage.js"));
49
53
  const prompts_js_1 = require("./prompts.js");
@@ -51,10 +55,54 @@ const node_crypto_1 = require("node:crypto");
51
55
  const features_js_1 = require("./features.js");
52
56
  const graph_js_1 = require("./graph.js");
53
57
  const state_js_1 = require("./state.js");
58
+ const domain_classify_js_1 = require("./domain-classify.js");
59
+ const schema_prototypes_js_1 = require("./schema-prototypes.js");
60
+ const nofit_js_1 = require("./nofit.js");
54
61
  // Default config constants (matching Python config.py)
55
62
  const CONSOLIDATE_MAX_LLM_CALLS = 200;
56
63
  const CONSOLIDATE_PRUNE_MIN_AGE_DAYS = 90;
57
- const CONSOLIDATE_LINK_THRESHOLD = 0.55;
64
+ /**
65
+ * Minimum COSINE similarity for a link candidate.
66
+ *
67
+ * Calibration (2026-07): measured top-10 neighbor cosine histogram on the
68
+ * 2945-memory production corpus (bedrock). Typical top-1 neighbor cosine:
69
+ * median 0.823, p10 0.743, p90 0.902. Threshold 0.75 combined with the
70
+ * top-3 cap yields ≈ 2.2 candidate links/memory. The previous value (0.55)
71
+ * lived on an accidental 1−L2 scale where it required cosine > 0.90 — a
72
+ * near-duplicate detector that linked only 12% of memories.
73
+ */
74
+ exports.CONSOLIDATE_LINK_THRESHOLD = 0.75;
75
+ /** Max link candidates kept per memory (highest-cosine neighbors first). */
76
+ exports.CONSOLIDATE_LINK_TOP_K = 3;
77
+ /**
78
+ * Minimum COSINE similarity for a CROSS-PROJECT link candidate.
79
+ *
80
+ * A 672-link audit (17 LLM judges, 2026-07) found cross-project links were 65%
81
+ * wrong-link vs 6% for same-project, and that strength (cosine) predicts quality
82
+ * (wrong-link 42% → 6% across strength quartiles). Cross-project pairs must clear
83
+ * a much higher bar than the same-project 0.75 to survive discovery. Same-project
84
+ * links keep CONSOLIDATE_LINK_THRESHOLD (0.75).
85
+ */
86
+ exports.CROSS_PROJECT_LINK_THRESHOLD = 0.8;
87
+ /**
88
+ * l2ToCosine moved to retrieval.ts (#145) — consolidate.ts already imports
89
+ * from retrieval, so retrieval is the circular-dependency-safe home.
90
+ * Re-exported here so pre-#145 importers and tests keep working unchanged.
91
+ */
92
+ var retrieval_js_2 = require("./retrieval.js");
93
+ Object.defineProperty(exports, "l2ToCosine", { enumerable: true, get: function () { return retrieval_js_2.l2ToCosine; } });
94
+ /**
95
+ * Minimum TRUE cosine similarity between a new lesson and an existing one
96
+ * for the existing lesson to count as a contradiction-check candidate
97
+ * (stageReflection). 0.80 = "strongly similar lesson" — the original intent.
98
+ * Before #145 the check was `1 − L2 > 0.80`, which required cosine > 0.98,
99
+ * so lesson-contradiction suppression effectively never fired.
100
+ */
101
+ exports.REFLECTION_CONTRADICTION_MIN_COSINE = 0.8;
102
+ /** True when an L2 neighbor distance clears the contradiction-check bar. */
103
+ function isContradictionCandidate(distance) {
104
+ return (0, retrieval_js_1.l2ToCosine)(distance) > exports.REFLECTION_CONTRADICTION_MIN_COSINE;
105
+ }
58
106
  // ---------------------------------------------------------------------------
59
107
  // BudgetTracker
60
108
  // ---------------------------------------------------------------------------
@@ -273,11 +321,11 @@ async function stageReflection(db, memories, llm, budget, embedFn, dryRun) {
273
321
  // If a very similar lesson exists, ask the LLM whether the new one
274
322
  // contradicts it. If yes, suppress the new lesson to prevent the
275
323
  // "false coherence" failure mode (wrong lessons reinforcing themselves).
324
+ // TRUE cosine > 0.80 (#145): the old `1 − n.distance > 0.80` sat on
325
+ // the accidental 1−L2 scale and required cosine > 0.98 — the check
326
+ // effectively never fired. See isContradictionCandidate.
276
327
  const similarLessons = storage.vectorSearch(db, embedding, 3)
277
- .filter((n) => {
278
- const sim = 1.0 - n.distance;
279
- return sim > 0.80 && n.memory_type === "lesson";
280
- });
328
+ .filter((n) => isContradictionCandidate(n.distance) && n.memory_type === "lesson");
281
329
  let contradicted = false;
282
330
  if (similarLessons.length > 0 && budget.use("contradiction_check")) {
283
331
  const existingText = similarLessons[0].content.slice(0, 300);
@@ -317,7 +365,142 @@ async function stageReflection(db, memories, llm, budget, embedFn, dryRun) {
317
365
  }
318
366
  }
319
367
  // ---------------------------------------------------------------------------
320
- // Stage 2.7: Domain Curation (MODULE_INDEX)
368
+ // Stage 2.7a: Content-based Domain Classification (config-owned domains)
369
+ // ---------------------------------------------------------------------------
370
+ //
371
+ // Active ONLY when config.json carries a `domains` list. Each memory is filed
372
+ // into one configured life-sphere by its CONTENT (via the reflect model),
373
+ // replacing the project-grouping path. Only NULL or stale-domain rows are
374
+ // (re)classified, so re-runs are cheap and a config change re-files affected
375
+ // rows. moduleIndex becomes {configured domains + live per-domain counts} so
376
+ // /index and the lesson selector keep working.
377
+ /**
378
+ * Rebuild moduleIndex from the configured domain set + live DB counts, and
379
+ * persist it. Shared by the nightly stage and `hicortex classify-domains`.
380
+ * `projects` is left empty (content domains don't map to projects); the lesson
381
+ * selector's same-domain boost instead keys off memory.domain directly (it
382
+ * still reads the field). Descriptions are carried through for /index.
383
+ */
384
+ function rebuildContentModuleIndex(db, domains, stateDir) {
385
+ const memRows = db
386
+ .prepare(`SELECT domain, COUNT(*) AS cnt FROM memories WHERE domain IS NOT NULL GROUP BY domain`)
387
+ .all();
388
+ const lessonRows = db
389
+ .prepare(`SELECT domain, COUNT(*) AS cnt FROM memories
390
+ WHERE domain IS NOT NULL AND memory_type = 'lesson' GROUP BY domain`)
391
+ .all();
392
+ const memByDomain = new Map(memRows.map((r) => [r.domain, r.cnt]));
393
+ const lessonByDomain = new Map(lessonRows.map((r) => [r.domain, r.cnt]));
394
+ const moduleDomains = domains.map((d) => ({
395
+ name: d.name,
396
+ projects: [],
397
+ memoryCount: memByDomain.get(d.name) ?? 0,
398
+ lessonCount: lessonByDomain.get(d.name) ?? 0,
399
+ keywords: [],
400
+ description: d.description,
401
+ }));
402
+ const totalMemories = moduleDomains.reduce((s, d) => s + d.memoryCount, 0);
403
+ const totalLessons = moduleDomains.reduce((s, d) => s + d.lessonCount, 0);
404
+ const moduleIndex = {
405
+ domains: moduleDomains,
406
+ projectSetHash: (0, domain_classify_js_1.domainSetHash)(domains),
407
+ curatedAt: new Date().toISOString(),
408
+ totalMemories,
409
+ totalLessons,
410
+ mode: "content",
411
+ };
412
+ (0, state_js_1.updateState)((s) => { s.moduleIndex = moduleIndex; }, stateDir);
413
+ return { domains: moduleDomains.length };
414
+ }
415
+ async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, stateDir, weakPrimaryFloor = nofit_js_1.DEFAULT_WEAK_PRIMARY_FLOOR) {
416
+ // Rows needing (re)classification:
417
+ // - domain IS NULL (never classified), OR
418
+ // - domain NOT IN the current vocabulary (a rename/removal re-files), OR
419
+ // - no memory_tags rows yet (single-domain memories from feat/content-domains
420
+ // that have a primary but no tag set — backfill them to multi-tag).
421
+ const placeholders = domains.map(() => "?").join(", ");
422
+ const rows = db
423
+ .prepare(`SELECT id, content, project FROM memories
424
+ WHERE domain IS NULL
425
+ OR domain NOT IN (${placeholders})
426
+ OR id NOT IN (SELECT DISTINCT memory_id FROM memory_tags)`)
427
+ .all(...domains.map((d) => d.name));
428
+ if (dryRun) {
429
+ return { curated: false, domains: domains.length, classified: 0, reason: `dry_run (${rows.length} would classify)` };
430
+ }
431
+ const getEmbedFn = async () => embedFn;
432
+ const compartments = (0, schema_prototypes_js_1.compartmentSet)(domains);
433
+ let classified = 0;
434
+ let weakPrimary = 0;
435
+ let noAssociationDecayed = 0;
436
+ if (rows.length > 0) {
437
+ // Prototypes at run start — newly classified memories get their weights
438
+ // from the CURRENT prototypes (the post-classification recompute below
439
+ // refreshes everything from the updated tag sets anyway).
440
+ const { prototypes: startPrototypes } = await (0, schema_prototypes_js_1.computeDomainPrototypes)(db, domains, getEmbedFn);
441
+ for (const row of rows) {
442
+ if (budget.exhausted || !budget.use("content_domain")) {
443
+ console.warn(`[hicortex] content-domain: budget exhausted after ${classified} classified`);
444
+ break;
445
+ }
446
+ // classifyMemoryTags returns null ONLY on infra error (throws after retry) —
447
+ // skip that memory, leaving domain/tags/strength untouched so a later
448
+ // run retries it (issue #150: never file or decay on infra errors).
449
+ const result = await (0, domain_classify_js_1.classifyMemoryTags)(row.content, row.project, domains, llm);
450
+ if (result === null) {
451
+ console.warn(`[hicortex] content-domain: infra error classifying ${row.id} — skipped (will retry)`);
452
+ continue;
453
+ }
454
+ if (result.tags.length === 0) {
455
+ // Genuine no-fit (owner amendment 07.07): weak primary from the
456
+ // prototype argmax when it clears the floor, else accelerated decay.
457
+ // Each row appears exactly once in `rows`, so a run never
458
+ // double-halves.
459
+ const resolution = (0, nofit_js_1.resolveNoFit)(db, row.id, domains, startPrototypes, weakPrimaryFloor);
460
+ if (resolution.kind === "weak_primary") {
461
+ (0, nofit_js_1.applyWeakPrimary)(db, row.id, resolution.domain, resolution.weight, compartments);
462
+ weakPrimary++;
463
+ }
464
+ else {
465
+ (0, nofit_js_1.applyNoAssociationDecay)(db, row.id);
466
+ noAssociationDecayed++;
467
+ }
468
+ continue;
469
+ }
470
+ const weights = (0, schema_prototypes_js_1.computeTagWeights)(db, row.id, result.tags, startPrototypes);
471
+ storage.setMemoryTags(db, row.id, result.tags, { weights, compartments });
472
+ classified++;
473
+ }
474
+ }
475
+ // Graded-schema reconsolidation pass — runs EVERY nightly, including when
476
+ // nothing new was classified: prototypes drift with the data, so weights and
477
+ // derived primaries must follow (spec: "recomputed for ALL memory_tags rows
478
+ // each nightly"). Order: prototypes (from the post-classification tag sets)
479
+ // → all weights → derived primaries → moduleIndex counts from the refreshed
480
+ // primaries. No LLM calls — embeddings only.
481
+ const { prototypes, stats } = await (0, schema_prototypes_js_1.computeDomainPrototypes)(db, domains, getEmbedFn);
482
+ const { updated: weightsRecomputed } = (0, schema_prototypes_js_1.recomputeAllTagWeights)(db, prototypes);
483
+ const { updated: primariesUpdated } = (0, schema_prototypes_js_1.refreshPrimaries)(db, domains);
484
+ const seeded = stats.filter((s) => s.seeded).length;
485
+ const { domains: domainCount } = rebuildContentModuleIndex(db, domains, stateDir);
486
+ console.log(`[hicortex] Graded tags: ${classified} classified, ${weakPrimary} weak-primary, ` +
487
+ `${noAssociationDecayed} no-association decayed, ${prototypes.size} prototypes ` +
488
+ `(${seeded} description-seeded), ${weightsRecomputed} weights recomputed, ` +
489
+ `${primariesUpdated} primaries updated, ${domainCount} domains indexed`);
490
+ return {
491
+ curated: rows.length > 0,
492
+ domains: domainCount,
493
+ classified,
494
+ prototypes: prototypes.size,
495
+ weights_recomputed: weightsRecomputed,
496
+ primaries_updated: primariesUpdated,
497
+ weak_primary: weakPrimary,
498
+ no_association_decayed: noAssociationDecayed,
499
+ ...(rows.length === 0 ? { reason: "nothing_stale" } : {}),
500
+ };
501
+ }
502
+ // ---------------------------------------------------------------------------
503
+ // Stage 2.7b: Domain Curation (MODULE_INDEX) — project grouping (legacy path)
321
504
  // ---------------------------------------------------------------------------
322
505
  async function stageDomainCuration(db, llm, budget, dryRun, stateDir) {
323
506
  // Gather all projects with memory and lesson counts
@@ -481,28 +664,80 @@ async function stageDomainCuration(db, llm, budget, dryRun, stateDir) {
481
664
  console.log(`[hicortex] Domain curation: ${domains.length} domains from ${projectRows.length} projects`);
482
665
  return { curated: true, domains: domains.length };
483
666
  }
484
- /** Batch size for LLM edge classification calls. */
485
- const EDGE_CLASSIFICATION_BATCH_SIZE = 8;
486
- /** Valid relationship type set for fast lookup. */
487
- const VALID_REL_SET = new Set(types_js_1.VALID_RELATIONSHIP_TYPES);
667
+ /**
668
+ * Discovery: find link candidates for one memory given its embedding.
669
+ * Top-10 vector neighbors (excluding self), keep the CONSOLIDATE_LINK_TOP_K
670
+ * highest-cosine neighbors above CONSOLIDATE_LINK_THRESHOLD, pre-compute the
671
+ * heuristic relationship type.
672
+ *
673
+ * Shared between the nightly `stageLinks` (which embeds via embedFn) and
674
+ * `hicortex relink` (which reuses stored embeddings from memory_vectors).
675
+ */
676
+ function discoverLinkCandidates(db, mem, embedding) {
677
+ const neighbors = storage.vectorSearch(db, embedding, 10, [mem.id]);
678
+ const candidates = [];
679
+ // vectorSearch orders by L2 distance ascending (`ORDER BY distance` in
680
+ // storage.ts), and cosine is monotonically decreasing in L2 distance for
681
+ // normalized vectors — so iterating in order and stopping at TOP_K keeps
682
+ // exactly the highest-cosine neighbors.
683
+ for (const neighbor of neighbors) {
684
+ if (candidates.length >= exports.CONSOLIDATE_LINK_TOP_K)
685
+ break;
686
+ // sqlite-vec vec0 `distance` is L2, not a similarity. Embeddings are
687
+ // L2-normalized (embedder.ts, normalize: true), so cos = 1 − d²/2.
688
+ // The old `1 − distance` formula silently required cosine > 0.90.
689
+ const similarity = (0, retrieval_js_1.l2ToCosine)(neighbor.distance);
690
+ // Cross-project guard (2026-07 audit): cross-project links were 65%
691
+ // wrong-link vs 6% same-project. A candidate whose source/target belong to
692
+ // DIFFERENT projects must clear the higher CROSS_PROJECT_LINK_THRESHOLD;
693
+ // same-project keeps CONSOLIDATE_LINK_THRESHOLD. A memory with no project
694
+ // (null) is treated as same-project — the guard only fires on two distinct
695
+ // non-null project names.
696
+ const crossProject = mem.project != null &&
697
+ neighbor.project != null &&
698
+ mem.project !== neighbor.project;
699
+ const threshold = crossProject
700
+ ? exports.CROSS_PROJECT_LINK_THRESHOLD
701
+ : exports.CONSOLIDATE_LINK_THRESHOLD;
702
+ if (similarity > threshold) {
703
+ const heuristicType = classifyRelationship(mem, neighbor, similarity);
704
+ candidates.push({ source: mem, target: neighbor, similarity, heuristicType });
705
+ }
706
+ }
707
+ return candidates;
708
+ }
709
+ /**
710
+ * Classification: assign a relationship type to each candidate link.
711
+ *
712
+ * HEURISTIC-ONLY (2026-07). LLM edge classification was retired after the
713
+ * 672-link audit (see the Stage 3 header) found the LLM-classified UPPERCASE
714
+ * types near-useless (CONTRADICTS 4% acceptable). Every candidate now takes its
715
+ * pre-computed `heuristicType` (only `extends` or `relates_to` — see
716
+ * classifyRelationship). No LLM call is made.
717
+ *
718
+ * Signature stability: `llm` and `budget` are RETAINED but intentionally
719
+ * ignored so the callers (nightly `stageLinks`, `hicortex relink`) and the
720
+ * tests that import this need no change to their call sites. The return shape
721
+ * is unchanged; `llmClassified` is always 0 now and `heuristicFallback` counts
722
+ * every candidate. Do NOT re-add an LLM path here without a classifier that
723
+ * passes the audit harness at >= 70% acceptable.
724
+ *
725
+ * Shared between the nightly `stageLinks` and `hicortex relink`.
726
+ * Returns one relationship type per candidate (same order as input).
727
+ */
728
+ async function classifyLinkCandidates(candidates, _llm, _budget) {
729
+ const types = candidates.map((c) => c.heuristicType);
730
+ return { types, llmClassified: 0, heuristicFallback: candidates.length };
731
+ }
488
732
  async function stageLinks(db, memories, embedFn, dryRun, llm, budget) {
489
733
  let autoLinked = 0;
490
- let llmClassified = 0;
491
- let heuristicFallback = 0;
492
734
  let failed = 0;
493
735
  // Phase A: Discovery — collect candidates via vector similarity
494
736
  const candidates = [];
495
737
  for (const mem of memories) {
496
738
  try {
497
739
  const embedding = await embedFn(mem.content);
498
- const neighbors = storage.vectorSearch(db, embedding, 10, [mem.id]);
499
- for (const neighbor of neighbors) {
500
- const similarity = 1.0 - neighbor.distance;
501
- if (similarity > CONSOLIDATE_LINK_THRESHOLD) {
502
- const heuristicType = classifyRelationship(mem, neighbor, similarity);
503
- candidates.push({ source: mem, target: neighbor, similarity, heuristicType });
504
- }
505
- }
740
+ candidates.push(...discoverLinkCandidates(db, mem, embedding));
506
741
  }
507
742
  catch {
508
743
  failed++;
@@ -511,48 +746,8 @@ async function stageLinks(db, memories, embedFn, dryRun, llm, budget) {
511
746
  if (candidates.length === 0) {
512
747
  return { auto_linked: 0, llm_classified: 0, heuristic_fallback: 0, failed };
513
748
  }
514
- // Phase B: LLM batch classification
515
- // Build batches and classify with LLM where budget allows
516
- const classifiedTypes = new Array(candidates.length);
517
- for (let i = 0; i < candidates.length; i += EDGE_CLASSIFICATION_BATCH_SIZE) {
518
- const batch = candidates.slice(i, i + EDGE_CLASSIFICATION_BATCH_SIZE);
519
- // Attempt LLM classification if budget allows
520
- if (budget.use("edge_classification")) {
521
- try {
522
- const pairsBlock = batch.map((c, idx) => {
523
- const srcContent = c.source.content.slice(0, 200);
524
- const tgtContent = c.target.content.slice(0, 200);
525
- return `[${idx}] SOURCE: ${c.source.memory_type} | ${c.source.project ?? "global"} | ${srcContent}\n TARGET: ${c.target.memory_type} | ${c.target.project ?? "global"} | ${tgtContent}\n similarity: ${c.similarity.toFixed(2)}`;
526
- }).join("\n\n");
527
- const prompt = (0, prompts_js_1.edgeClassification)(pairsBlock);
528
- const raw = await llm.completeFast(prompt, 512);
529
- const parsed = parseJsonLenient(raw, []);
530
- if (Array.isArray(parsed) && parsed.length > 0) {
531
- for (let j = 0; j < batch.length; j++) {
532
- const llmType = parsed[j];
533
- if (typeof llmType === "string" && VALID_REL_SET.has(llmType)) {
534
- classifiedTypes[i + j] = llmType;
535
- llmClassified++;
536
- }
537
- else {
538
- // Invalid type from LLM — fall back to heuristic
539
- classifiedTypes[i + j] = batch[j].heuristicType;
540
- heuristicFallback++;
541
- }
542
- }
543
- continue;
544
- }
545
- }
546
- catch {
547
- // LLM call failed — fall through to heuristic for this batch
548
- }
549
- }
550
- // Budget exhausted or LLM failed — use heuristic for entire batch
551
- for (let j = 0; j < batch.length; j++) {
552
- classifiedTypes[i + j] = batch[j].heuristicType;
553
- heuristicFallback++;
554
- }
555
- }
749
+ // Phase B: LLM batch classification (heuristic fallback inside)
750
+ const { types: classifiedTypes, llmClassified, heuristicFallback } = await classifyLinkCandidates(candidates, llm, budget);
556
751
  // Phase C: Store all classified links
557
752
  for (let i = 0; i < candidates.length; i++) {
558
753
  const c = candidates[i];
@@ -573,24 +768,26 @@ async function stageLinks(db, memories, embedFn, dryRun, llm, budget) {
573
768
  return { auto_linked: autoLinked, llm_classified: llmClassified, heuristic_fallback: heuristicFallback, failed };
574
769
  }
575
770
  /**
576
- * Classify the relationship between two memories based on type, temporal ordering, and similarity.
771
+ * Classify the relationship between two memories.
772
+ *
773
+ * TWO-LABEL heuristic (2026-07). The 672-link audit (see the Stage 3 header)
774
+ * showed only `extends` (57% acceptable) and `relates_to` (53%) held up; the
775
+ * emitted vocabulary is collapsed to exactly those two. The retired labels
776
+ * `updates` and `derives` (~31% acceptable) and all UPPERCASE LLM types are no
777
+ * longer produced. They remain in VALID_RELATIONSHIP_TYPES so pre-existing rows
778
+ * still validate.
779
+ *
780
+ * Rule: same-project (both projects non-null and equal) AND higher cosine
781
+ * (> CONSOLIDATE_LINK_THRESHOLD) → `extends`; everything else → `relates_to`.
782
+ *
783
+ * `similarity` is COSINE similarity (see l2ToCosine); the CONSOLIDATE_LINK_THRESHOLD
784
+ * boundary from the l2ToCosine calibration is preserved.
577
785
  */
578
786
  function classifyRelationship(source, target, similarity) {
579
- // Lesson derived from episode(s)
580
- if (source.memory_type === "lesson" && target.memory_type === "episode")
581
- return "derives";
582
- if (target.memory_type === "lesson" && source.memory_type === "episode")
583
- return "derives";
584
- // Same type + very high similarity + different timestamps → newer updates older
585
- if (source.memory_type === target.memory_type &&
586
- similarity > 0.8 &&
587
- source.created_at !== target.created_at) {
588
- return "updates";
589
- }
590
- // Same project, moderate similarity → extends
787
+ // Same project + above the link threshold → extends
591
788
  if (source.project && target.project &&
592
789
  source.project === target.project &&
593
- similarity > 0.55 && similarity <= 0.8) {
790
+ similarity > exports.CONSOLIDATE_LINK_THRESHOLD) {
594
791
  return "extends";
595
792
  }
596
793
  return "relates_to";
@@ -656,13 +853,7 @@ function stageDecayPrune(db, dryRun) {
656
853
  }
657
854
  return { candidates, pruned, failed };
658
855
  }
659
- // ---------------------------------------------------------------------------
660
- // Full pipeline
661
- // ---------------------------------------------------------------------------
662
- /**
663
- * Run the full consolidation pipeline. Returns a structured report.
664
- */
665
- async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir) {
856
+ async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions) {
666
857
  const start = new Date();
667
858
  const report = {
668
859
  started_at: start.toISOString(),
@@ -708,8 +899,27 @@ async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection
708
899
  else {
709
900
  report.stages.reflection = await stageReflection(db, precheck.newMemories, llm, budget, embedFn, dryRun);
710
901
  }
711
- // Stage 2.7: Domain Curation
712
- report.stages.domain_curation = await stageDomainCuration(db, llm, budget, dryRun, stateDir);
902
+ // Stage 2.7: Domain assignment.
903
+ // Content-based (config-owned domains) REPLACES project grouping when a
904
+ // domain list is configured AND the reflect endpoint passed pre-flight.
905
+ // If the list is configured but the reflect endpoint is down, SKIP the
906
+ // stage (strict) — do not fall back to project grouping.
907
+ const cfgDomains = domainOptions?.domains;
908
+ if (cfgDomains && cfgDomains.length > 0) {
909
+ if (domainOptions?.contentDomainsReady === false) {
910
+ report.stages.domain_curation = {
911
+ curated: false,
912
+ domains: cfgDomains.length,
913
+ reason: "reflect_endpoint_offline",
914
+ };
915
+ }
916
+ else {
917
+ report.stages.domain_curation = await stageContentDomains(db, cfgDomains, llm, budget, embedFn, dryRun, stateDir, domainOptions?.weakPrimaryFloor ?? nofit_js_1.DEFAULT_WEAK_PRIMARY_FLOOR);
918
+ }
919
+ }
920
+ else {
921
+ report.stages.domain_curation = await stageDomainCuration(db, llm, budget, dryRun, stateDir);
922
+ }
713
923
  // Stage 3: Link Discovery (with LLM-assisted edge classification)
714
924
  report.stages.links = await stageLinks(db, precheck.newMemories, embedFn, dryRun, llm, budget);
715
925
  // Stage 3.5: Hub Detection — boost highly-connected memories
@@ -754,6 +964,11 @@ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
754
964
  /**
755
965
  * Schedule the consolidation pipeline to run nightly.
756
966
  * Returns a cleanup function to cancel the timer.
967
+ *
968
+ * NOTE: currently unused (nightly.ts drives consolidation directly). Any future
969
+ * caller MUST read config.domains and thread `domainOptions` into runConsolidation
970
+ * when content domains are configured — otherwise it silently falls back to the
971
+ * legacy project-grouping path even when a domain list is set.
757
972
  */
758
973
  function scheduleConsolidation(db, llm, embedFn, hour = 2) {
759
974
  let timeout = null;