@gamaze/hicortex 0.10.1 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/THIRD_PARTY_NOTICES.md +108 -0
- package/assets/vendor/3d-force-graph.min.js +5 -0
- package/assets/vendor/force-graph.min.js +5 -0
- package/assets/vendor/three.core.min.js +6 -0
- package/assets/vendor/three.module.min.js +6 -0
- package/assets/viz.html +1128 -0
- package/dist/classify-domains.d.ts +98 -0
- package/dist/classify-domains.js +340 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +63 -0
- package/dist/consolidate.d.ts +139 -2
- package/dist/consolidate.js +302 -87
- package/dist/db.js +70 -0
- package/dist/domain-classify.d.ts +164 -0
- package/dist/domain-classify.js +300 -0
- package/dist/extensions.d.ts +12 -0
- package/dist/graph.d.ts +56 -0
- package/dist/graph.js +145 -0
- package/dist/index.js +1 -1
- package/dist/init.d.ts +25 -0
- package/dist/init.js +54 -0
- package/dist/lesson-selection.js +12 -5
- package/dist/lessons-context.js +2 -1
- package/dist/llm.d.ts +67 -0
- package/dist/llm.js +122 -0
- package/dist/mcp-server.js +86 -28
- package/dist/nightly-status.js +9 -28
- package/dist/nightly.js +42 -32
- package/dist/nofit.d.ts +111 -0
- package/dist/nofit.js +176 -0
- package/dist/prompts.d.ts +0 -5
- package/dist/prompts.js +5 -29
- package/dist/relink.d.ts +100 -0
- package/dist/relink.js +277 -0
- package/dist/retrieval.d.ts +16 -1
- package/dist/retrieval.js +34 -2
- package/dist/schema-prototypes.d.ts +149 -0
- package/dist/schema-prototypes.js +329 -0
- package/dist/state.d.ts +32 -0
- package/dist/state.js +29 -0
- package/dist/status.js +12 -19
- package/dist/storage.d.ts +44 -1
- package/dist/storage.js +70 -1
- package/dist/types.d.ts +90 -0
- package/dist/viz.d.ts +69 -0
- package/dist/viz.js +180 -0
- package/domains.example.json +36 -0
- package/package.json +6 -3
package/dist/consolidate.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/consolidate.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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 >
|
|
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
|
|
712
|
-
|
|
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;
|