@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.
Files changed (49) hide show
  1. package/README.md +42 -0
  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 +1128 -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 +86 -28
  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
package/dist/db.js CHANGED
@@ -250,6 +250,76 @@ const MIGRATIONS = [
250
250
  `);
251
251
  },
252
252
  },
253
+ {
254
+ version: 5,
255
+ name: "rescale_link_strength_to_cosine",
256
+ up: (db) => {
257
+ // memory_links.strength was stored on an accidental 1−L2 scale
258
+ // (consolidate.ts computed similarity as `1 − distance`, where distance
259
+ // is sqlite-vec's L2 distance). Embeddings are L2-normalized, so the
260
+ // true cosine is 1 − d²/2; with old = 1 − d this rewrites to
261
+ // cosine = 1 − (1 − old)² / 2.
262
+ // LLM-classified links also stored the candidate similarity as
263
+ // strength, so the rewrite applies to ALL rows. Guarded to strengths in
264
+ // (0, 1] — the only range the old formula could have written above the
265
+ // link threshold (values are 0.55–0.8 in practice); anything outside is
266
+ // left untouched. The rewrite is NOT self-idempotent: it must run
267
+ // exactly once, which the schema_version gate in migrate() guarantees
268
+ // (up() and the version-row insert share one transaction).
269
+ db.exec(`
270
+ UPDATE memory_links
271
+ SET strength = 1.0 - ((1.0 - strength) * (1.0 - strength)) / 2.0
272
+ WHERE strength > 0 AND strength <= 1
273
+ `);
274
+ },
275
+ },
276
+ {
277
+ version: 6,
278
+ name: "add_memory_tags",
279
+ up: (db) => {
280
+ // Multi-tag classification (feat/memory-tags). `memories.domain` keeps its
281
+ // meaning as the PRIMARY tag; this sidecar table carries the full label
282
+ // set (including the primary). Sidecar (not a virtual-table column) per
283
+ // the migration rules above. FK → memories(id); deletes cascade in code
284
+ // (storage.deleteMemory) since foreign_keys pragma is on but existing rows
285
+ // predate the constraint. Idempotent: IF NOT EXISTS on table + index.
286
+ db.exec(`
287
+ CREATE TABLE IF NOT EXISTS memory_tags (
288
+ memory_id TEXT NOT NULL,
289
+ tag TEXT NOT NULL,
290
+ PRIMARY KEY (memory_id, tag),
291
+ FOREIGN KEY (memory_id) REFERENCES memories(id)
292
+ )
293
+ `);
294
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memory_tags_tag ON memory_tags(tag)");
295
+ },
296
+ },
297
+ {
298
+ version: 7,
299
+ name: "graded_tag_weights",
300
+ up: (db) => {
301
+ // Graded schema membership (spec 2026-07-07): every tag assignment gets a
302
+ // derived association weight = cosine(memory embedding, domain prototype).
303
+ // NULL until the first prototype/weight computation runs (nightly stage or
304
+ // classification-time write). Guarded with hasColumn for idempotency
305
+ // across partially-migrated databases.
306
+ if (!hasColumn(db, "memory_tags", "weight")) {
307
+ db.exec("ALTER TABLE memory_tags ADD COLUMN weight REAL");
308
+ }
309
+ // Domain prototypes: L2-normalized centroid of member embeddings (or the
310
+ // embedded config description as a cold-start seed when member_count < 5).
311
+ // Sidecar table, NOT a vec0 virtual table — prototypes are point-read by
312
+ // name, never KNN-searched (see the virtual-table rule above).
313
+ db.exec(`
314
+ CREATE TABLE IF NOT EXISTS domain_prototypes (
315
+ domain TEXT PRIMARY KEY,
316
+ embedding BLOB,
317
+ member_count INTEGER,
318
+ updated_at TIMESTAMP
319
+ )
320
+ `);
321
+ },
322
+ },
253
323
  ];
254
324
  /**
255
325
  * Run all pending migrations against the database.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Content-based memory MULTI-TAG classification (feat/memory-tags).
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * The nightly's legacy `stageDomainCuration` groups PROJECTS into domains and
7
+ * assigns every memory its project's domain. For an owner whose "projects" are
8
+ * often AGENT names (lenny, nano, ...), one agent produces memories spanning
9
+ * many life areas, so life-memories get smeared under the agent. This module
10
+ * classifies a single memory into life-spheres by its CONTENT, drawn from a
11
+ * user-curated vocabulary in ~/.hicortex/config.json (`domains`).
12
+ *
13
+ * GRADED SCHEMA TAGS (spec 2026-07-07, supersedes the LLM-picked primary from
14
+ * PR #152/#153): a memory genuinely spans spheres — "set up bedrock for the
15
+ * agent fleet" is both Hardware AND Ventures. The classifier now returns ONLY
16
+ * the discrete part:
17
+ * - `tags`: 0..N vocabulary names that genuinely apply, MOST-RELEVANT FIRST
18
+ * (the order is used solely as an exact-weight tiebreak downstream).
19
+ * The PRIMARY (memories.domain) is NO LONGER requested from the LLM — audits
20
+ * proved LLM primaries a coin-flip on overlapping spheres. It is DERIVED
21
+ * deterministically (argmax association weight + compartment override) in
22
+ * schema-prototypes.ts / storage.setMemoryTags.
23
+ *
24
+ * NO-FIT = EMPTY TAG SET (owner amendment 07.07): "Unsorted" is a non-tag —
25
+ * there is NO fallback category in the vocabulary and no configured domain is
26
+ * ever auto-assigned on a no-fit. A genuine no-fit is the distinct result
27
+ * `{tags: []}`; the CALLER then derives a weak primary from prototype cosines
28
+ * (>= the weakPrimaryFloor) or, below the floor, applies accelerated decay
29
+ * (see nofit.ts). If a user still configures a domain named "Unsorted", it is
30
+ * just a normal domain with no special semantics.
31
+ *
32
+ * The `project` name is passed to the classifier as a HINT (content wins;
33
+ * project only breaks ties). This rescues terse technical memories from
34
+ * projects like raider/hiops/catalyst whose content alone reads as ambiguous.
35
+ *
36
+ * The classifier makes ONE constrained LLM call per memory (via the classify
37
+ * tier — classifyModel/classifyBaseUrl when configured, else the reflect
38
+ * tier), validates every returned name against the configured vocabulary
39
+ * (case-insensitive), and retries once on an invalid/unparseable reply.
40
+ *
41
+ * ROBUSTNESS (folds in issue #150):
42
+ * - Successful classification with no genuine fit → {tags: []} (no-fit).
43
+ * - LLM/endpoint ERROR (throws after the retry) → returns NULL. The caller
44
+ * leaves the memory unclassified and retries it on a later run. Infra
45
+ * errors must NOT be filed anywhere (that mis-labels good memories).
46
+ *
47
+ * SCOPE: sphere-level tags only. Sub-labels within a sphere are a future phase.
48
+ *
49
+ * Example owner vocabulary (documented, NOT hardcoded — lives in config.json):
50
+ * Work, Ventures, Hardware, Finances, Property, Vehicles, Boating, Health,
51
+ * Family, People, Travel
52
+ */
53
+ import type { LlmClient } from "./llm.js";
54
+ import type { DomainDef } from "./types.js";
55
+ export type { DomainDef };
56
+ /** Max characters of memory content fed to the classifier prompt. */
57
+ export declare const CLASSIFY_CONTENT_MAX_CHARS = 1500;
58
+ /**
59
+ * Parse and validate the `domains` field from a raw config.json object.
60
+ * Returns a clean DomainDef[] (name + description both non-empty strings) or
61
+ * null when the field is absent or malformed — null means "content
62
+ * classification is NOT active; keep the legacy project-grouping path".
63
+ *
64
+ * A present-but-empty array is treated as null (nothing to classify into).
65
+ */
66
+ export declare function parseConfigDomains(config: Record<string, unknown> | null | undefined): DomainDef[] | null;
67
+ /**
68
+ * Stable cache-invalidation key for a configured domain set: sha256 of the
69
+ * sorted, lowercased domain names. Changing the list (add/remove/rename)
70
+ * changes the hash and triggers a re-file of affected rows.
71
+ */
72
+ export declare function domainSetHash(domains: DomainDef[]): string;
73
+ /**
74
+ * Match an LLM reply against the configured domain set (case-insensitive).
75
+ * Returns the CANONICAL name from the config (preserving its casing) or null.
76
+ *
77
+ * Tolerates common LLM decorations: surrounding quotes, a trailing period,
78
+ * a leading "Domain:" label, and markdown emphasis. The match is exact on the
79
+ * normalized token — we do NOT substring-match, to avoid "People" matching
80
+ * inside "Peoples' court" style noise.
81
+ */
82
+ export declare function matchDomain(reply: string, domains: DomainDef[]): string | null;
83
+ /**
84
+ * Result of a successful multi-tag classification.
85
+ * - `tags`: 0..N vocabulary names that genuinely apply, MOST-RELEVANT FIRST
86
+ * (LLM order — used downstream only as an exact-weight tiebreak), deduped,
87
+ * all members of the configured vocabulary.
88
+ * - An EMPTY array is a distinct, VALID result: genuine no-fit (owner
89
+ * amendment 07.07 — no fallback category). Callers route it through the
90
+ * weak-primary / no-association-decay path (nofit.ts). It is NOT the same
91
+ * as null (null = infra error → skip and retry later).
92
+ *
93
+ * There is deliberately NO `primary` field (graded-schema spec 2026-07-07):
94
+ * the primary is derived from association weights, never from the LLM.
95
+ */
96
+ export interface TagResult {
97
+ tags: string[];
98
+ }
99
+ /**
100
+ * Build the constrained multi-tag classification prompt.
101
+ *
102
+ * Includes: the vocabulary (names + descriptions), the (truncated) memory
103
+ * content, and the source `project` name as a HINT. The model is instructed to
104
+ * apply EVERY genuinely-applicable tag (with an explicit multi-tag emphasis —
105
+ * things the owner builds usually carry both the venture/project domain AND
106
+ * the life topic they touch) and reply with ONLY a `{"tags": [...]}` object,
107
+ * most-relevant first. No primary, no weights, no ranks — the LLM decides
108
+ * only DISCRETE membership; all gradation is derived from embeddings.
109
+ *
110
+ * The emphasis sentence is GENERIC by design: concrete pairings (which venture
111
+ * touches which topic) must come from the configured domain DESCRIPTIONS, not
112
+ * from hardcoded examples in the product prompt.
113
+ *
114
+ * The project hint is deliberately weak ("content wins, project only breaks
115
+ * ties") — it rescues terse technical memories whose content alone reads as
116
+ * ambiguous, without letting the project name override clear content signals.
117
+ */
118
+ export declare function buildClassifyPrompt(content: string, project: string | null | undefined, domains: DomainDef[]): string;
119
+ /**
120
+ * Parse the model reply into a validated, ORDERED {tags} against the
121
+ * vocabulary.
122
+ *
123
+ * Return values (three distinct outcomes):
124
+ * - {tags: [...]} — >= 1 valid vocabulary tag (canonical-cased, ordered).
125
+ * - {tags: []} — the model EXPLICITLY replied with an empty tags array:
126
+ * genuine no-fit (owner amendment 07.07). NOT retried by the caller.
127
+ * - null — unparseable reply, missing/non-array `tags`, or a
128
+ * NON-EMPTY tags array in which no name matched the vocabulary (invalid
129
+ * names are dropped — an all-invalid reply is a bad reply, not a no-fit).
130
+ * The caller retries once.
131
+ *
132
+ * Accepts a JSON object with `tags`, tolerating code-fence wrapping and
133
+ * surrounding prose. The legacy `{"primary": ..., "tags": [...]}` shape is
134
+ * ACCEPTED for tolerance (older models / cached prompts may still emit it) but
135
+ * `primary` is IGNORED — nothing is derived from it (graded-schema spec: the
136
+ * primary comes from weights, never the LLM). Every tag name is matched
137
+ * case-insensitively via matchDomain; invalid names are dropped; duplicates
138
+ * are removed keeping the first (most-relevant) occurrence.
139
+ */
140
+ export declare function parseTagReply(reply: string, domains: DomainDef[]): TagResult | null;
141
+ /**
142
+ * Multi-tag classify one memory's content against the configured vocabulary.
143
+ *
144
+ * Uses the classify tier (`completeClassify` → classifyBaseUrl/classifyModel
145
+ * when configured, else the reflect tier) — the caller is responsible for
146
+ * having pre-flighted that endpoint via resolveClassifyProbeTarget (strict:
147
+ * skip classification entirely if it is unreachable).
148
+ *
149
+ * Behaviour:
150
+ * - Valid JSON reply with ≥1 vocabulary tag → {tags} (ordered,
151
+ * most-relevant first).
152
+ * - Valid reply with an EXPLICIT empty tags array → {tags: []} (genuine
153
+ * no-fit — owner amendment 07.07: no fallback category is ever assigned;
154
+ * the caller routes empty tag sets through nofit.ts).
155
+ * - Unparseable / no-valid-tag reply → retry ONCE, then treat as a genuine
156
+ * no-fit: {tags: []} (the model responded twice but produced nothing
157
+ * usable — the embedding-side weak-primary/decay path takes over).
158
+ * - LLM THROWS after the retry → return NULL (infra error; caller aborts this
159
+ * memory for a later retry — never filed anywhere, closes #150).
160
+ *
161
+ * @returns {tags} on success (all members of the vocabulary; empty = no-fit),
162
+ * or null on infra error.
163
+ */
164
+ export declare function classifyMemoryTags(content: string, project: string | null | undefined, domains: DomainDef[], llm: LlmClient): Promise<TagResult | null>;
@@ -0,0 +1,300 @@
1
+ "use strict";
2
+ /**
3
+ * Content-based memory MULTI-TAG classification (feat/memory-tags).
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * The nightly's legacy `stageDomainCuration` groups PROJECTS into domains and
8
+ * assigns every memory its project's domain. For an owner whose "projects" are
9
+ * often AGENT names (lenny, nano, ...), one agent produces memories spanning
10
+ * many life areas, so life-memories get smeared under the agent. This module
11
+ * classifies a single memory into life-spheres by its CONTENT, drawn from a
12
+ * user-curated vocabulary in ~/.hicortex/config.json (`domains`).
13
+ *
14
+ * GRADED SCHEMA TAGS (spec 2026-07-07, supersedes the LLM-picked primary from
15
+ * PR #152/#153): a memory genuinely spans spheres — "set up bedrock for the
16
+ * agent fleet" is both Hardware AND Ventures. The classifier now returns ONLY
17
+ * the discrete part:
18
+ * - `tags`: 0..N vocabulary names that genuinely apply, MOST-RELEVANT FIRST
19
+ * (the order is used solely as an exact-weight tiebreak downstream).
20
+ * The PRIMARY (memories.domain) is NO LONGER requested from the LLM — audits
21
+ * proved LLM primaries a coin-flip on overlapping spheres. It is DERIVED
22
+ * deterministically (argmax association weight + compartment override) in
23
+ * schema-prototypes.ts / storage.setMemoryTags.
24
+ *
25
+ * NO-FIT = EMPTY TAG SET (owner amendment 07.07): "Unsorted" is a non-tag —
26
+ * there is NO fallback category in the vocabulary and no configured domain is
27
+ * ever auto-assigned on a no-fit. A genuine no-fit is the distinct result
28
+ * `{tags: []}`; the CALLER then derives a weak primary from prototype cosines
29
+ * (>= the weakPrimaryFloor) or, below the floor, applies accelerated decay
30
+ * (see nofit.ts). If a user still configures a domain named "Unsorted", it is
31
+ * just a normal domain with no special semantics.
32
+ *
33
+ * The `project` name is passed to the classifier as a HINT (content wins;
34
+ * project only breaks ties). This rescues terse technical memories from
35
+ * projects like raider/hiops/catalyst whose content alone reads as ambiguous.
36
+ *
37
+ * The classifier makes ONE constrained LLM call per memory (via the classify
38
+ * tier — classifyModel/classifyBaseUrl when configured, else the reflect
39
+ * tier), validates every returned name against the configured vocabulary
40
+ * (case-insensitive), and retries once on an invalid/unparseable reply.
41
+ *
42
+ * ROBUSTNESS (folds in issue #150):
43
+ * - Successful classification with no genuine fit → {tags: []} (no-fit).
44
+ * - LLM/endpoint ERROR (throws after the retry) → returns NULL. The caller
45
+ * leaves the memory unclassified and retries it on a later run. Infra
46
+ * errors must NOT be filed anywhere (that mis-labels good memories).
47
+ *
48
+ * SCOPE: sphere-level tags only. Sub-labels within a sphere are a future phase.
49
+ *
50
+ * Example owner vocabulary (documented, NOT hardcoded — lives in config.json):
51
+ * Work, Ventures, Hardware, Finances, Property, Vehicles, Boating, Health,
52
+ * Family, People, Travel
53
+ */
54
+ Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.CLASSIFY_CONTENT_MAX_CHARS = void 0;
56
+ exports.parseConfigDomains = parseConfigDomains;
57
+ exports.domainSetHash = domainSetHash;
58
+ exports.matchDomain = matchDomain;
59
+ exports.buildClassifyPrompt = buildClassifyPrompt;
60
+ exports.parseTagReply = parseTagReply;
61
+ exports.classifyMemoryTags = classifyMemoryTags;
62
+ const node_crypto_1 = require("node:crypto");
63
+ /** Max characters of memory content fed to the classifier prompt. */
64
+ exports.CLASSIFY_CONTENT_MAX_CHARS = 1500;
65
+ /**
66
+ * Parse and validate the `domains` field from a raw config.json object.
67
+ * Returns a clean DomainDef[] (name + description both non-empty strings) or
68
+ * null when the field is absent or malformed — null means "content
69
+ * classification is NOT active; keep the legacy project-grouping path".
70
+ *
71
+ * A present-but-empty array is treated as null (nothing to classify into).
72
+ */
73
+ function parseConfigDomains(config) {
74
+ const raw = config?.domains;
75
+ if (!Array.isArray(raw) || raw.length === 0)
76
+ return null;
77
+ const out = [];
78
+ for (const item of raw) {
79
+ if (typeof item !== "object" || item === null)
80
+ continue;
81
+ const d = item;
82
+ const name = typeof d.name === "string" ? d.name.trim() : "";
83
+ const description = typeof d.description === "string" ? d.description.trim() : "";
84
+ if (!name)
85
+ continue;
86
+ const def = { name, description };
87
+ // Compartment policy passthrough (graded-schema spec): a domain flagged
88
+ // `compartment: true` becomes the primary whenever tagged.
89
+ if (d.compartment === true)
90
+ def.compartment = true;
91
+ out.push(def);
92
+ }
93
+ return out.length > 0 ? out : null;
94
+ }
95
+ /**
96
+ * Stable cache-invalidation key for a configured domain set: sha256 of the
97
+ * sorted, lowercased domain names. Changing the list (add/remove/rename)
98
+ * changes the hash and triggers a re-file of affected rows.
99
+ */
100
+ function domainSetHash(domains) {
101
+ const names = domains.map((d) => d.name.trim().toLowerCase()).sort();
102
+ return (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(names)).digest("hex");
103
+ }
104
+ /**
105
+ * Match an LLM reply against the configured domain set (case-insensitive).
106
+ * Returns the CANONICAL name from the config (preserving its casing) or null.
107
+ *
108
+ * Tolerates common LLM decorations: surrounding quotes, a trailing period,
109
+ * a leading "Domain:" label, and markdown emphasis. The match is exact on the
110
+ * normalized token — we do NOT substring-match, to avoid "People" matching
111
+ * inside "Peoples' court" style noise.
112
+ */
113
+ function matchDomain(reply, domains) {
114
+ if (!reply)
115
+ return null;
116
+ let cleaned = reply.trim();
117
+ // Take the first non-empty line — models sometimes add a justification below.
118
+ const firstLine = cleaned.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0);
119
+ if (firstLine)
120
+ cleaned = firstLine;
121
+ // Strip a leading label like "Domain:" / "Answer:".
122
+ cleaned = cleaned.replace(/^(domain|answer|category|sphere)\s*[:\-]\s*/i, "");
123
+ // Strip markdown emphasis, surrounding quotes/backticks, trailing punctuation.
124
+ cleaned = cleaned
125
+ .replace(/^[*_`"'\s]+/, "")
126
+ .replace(/[*_`"'.\s]+$/, "")
127
+ .trim();
128
+ const norm = cleaned.toLowerCase();
129
+ for (const d of domains) {
130
+ if (d.name.trim().toLowerCase() === norm)
131
+ return d.name;
132
+ }
133
+ return null;
134
+ }
135
+ /**
136
+ * Build the constrained multi-tag classification prompt.
137
+ *
138
+ * Includes: the vocabulary (names + descriptions), the (truncated) memory
139
+ * content, and the source `project` name as a HINT. The model is instructed to
140
+ * apply EVERY genuinely-applicable tag (with an explicit multi-tag emphasis —
141
+ * things the owner builds usually carry both the venture/project domain AND
142
+ * the life topic they touch) and reply with ONLY a `{"tags": [...]}` object,
143
+ * most-relevant first. No primary, no weights, no ranks — the LLM decides
144
+ * only DISCRETE membership; all gradation is derived from embeddings.
145
+ *
146
+ * The emphasis sentence is GENERIC by design: concrete pairings (which venture
147
+ * touches which topic) must come from the configured domain DESCRIPTIONS, not
148
+ * from hardcoded examples in the product prompt.
149
+ *
150
+ * The project hint is deliberately weak ("content wins, project only breaks
151
+ * ties") — it rescues terse technical memories whose content alone reads as
152
+ * ambiguous, without letting the project name override clear content signals.
153
+ */
154
+ function buildClassifyPrompt(content, project, domains) {
155
+ const list = domains.map((d) => `- ${d.name}: ${d.description}`).join("\n");
156
+ const truncated = content.length > exports.CLASSIFY_CONTENT_MAX_CHARS
157
+ ? content.slice(0, exports.CLASSIFY_CONTENT_MAX_CHARS) + "…"
158
+ : content;
159
+ const projectHint = project && project.trim()
160
+ ? `Source project: ${project.trim()} — a useful signal, but classify by ` +
161
+ `content; content wins, the project only breaks ties.\n\n`
162
+ : "";
163
+ return (`You are tagging a single memory with life-sphere domains.\n\n` +
164
+ `DOMAINS (name: description):\n${list}\n\n` +
165
+ projectHint +
166
+ `MEMORY:\n${truncated}\n\n` +
167
+ `Apply EVERY domain that genuinely applies — memories often span several. ` +
168
+ `Memories about things the owner builds or runs usually carry BOTH the ` +
169
+ `venture/project domain AND the life topic they touch. ` +
170
+ `List 1-4 domains, most relevant first. ` +
171
+ `If none genuinely fits, reply {"tags": []}.\n` +
172
+ `Reply with ONLY a JSON object, no prose:\n` +
173
+ `{"tags": ["<most relevant domain>", "<next domain>", ...]}\n` +
174
+ `Every name MUST come from the list above.`);
175
+ }
176
+ /**
177
+ * Parse the model reply into a validated, ORDERED {tags} against the
178
+ * vocabulary.
179
+ *
180
+ * Return values (three distinct outcomes):
181
+ * - {tags: [...]} — >= 1 valid vocabulary tag (canonical-cased, ordered).
182
+ * - {tags: []} — the model EXPLICITLY replied with an empty tags array:
183
+ * genuine no-fit (owner amendment 07.07). NOT retried by the caller.
184
+ * - null — unparseable reply, missing/non-array `tags`, or a
185
+ * NON-EMPTY tags array in which no name matched the vocabulary (invalid
186
+ * names are dropped — an all-invalid reply is a bad reply, not a no-fit).
187
+ * The caller retries once.
188
+ *
189
+ * Accepts a JSON object with `tags`, tolerating code-fence wrapping and
190
+ * surrounding prose. The legacy `{"primary": ..., "tags": [...]}` shape is
191
+ * ACCEPTED for tolerance (older models / cached prompts may still emit it) but
192
+ * `primary` is IGNORED — nothing is derived from it (graded-schema spec: the
193
+ * primary comes from weights, never the LLM). Every tag name is matched
194
+ * case-insensitively via matchDomain; invalid names are dropped; duplicates
195
+ * are removed keeping the first (most-relevant) occurrence.
196
+ */
197
+ function parseTagReply(reply, domains) {
198
+ if (!reply)
199
+ return null;
200
+ // Extract the first {...} block (tolerates ```json fences and stray prose).
201
+ const start = reply.indexOf("{");
202
+ const end = reply.lastIndexOf("}");
203
+ if (start === -1 || end === -1 || end <= start)
204
+ return null;
205
+ let obj;
206
+ try {
207
+ obj = JSON.parse(reply.slice(start, end + 1));
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ if (typeof obj !== "object" || obj === null)
213
+ return null;
214
+ const o = obj;
215
+ // `tags` must be an ARRAY — a missing or malformed field is an invalid
216
+ // reply (null → retry), NOT a no-fit.
217
+ if (!Array.isArray(o.tags))
218
+ return null;
219
+ const rawTags = o.tags;
220
+ // Explicit empty array = genuine no-fit — a distinct, valid result.
221
+ if (rawTags.length === 0)
222
+ return { tags: [] };
223
+ // Collect valid tags (canonical-cased, deduped, ORDER-PRESERVING — the LLM's
224
+ // most-relevant-first order is the downstream exact-weight tiebreak).
225
+ const seen = new Set();
226
+ const tags = [];
227
+ for (const t of rawTags) {
228
+ if (typeof t !== "string")
229
+ continue;
230
+ const matched = matchDomain(t, domains);
231
+ if (matched && !seen.has(matched)) {
232
+ seen.add(matched);
233
+ tags.push(matched);
234
+ }
235
+ }
236
+ // Legacy `primary` field: tolerated in the shape, derives NOTHING.
237
+ // A non-empty raw array with ZERO vocabulary matches is an invalid reply.
238
+ if (tags.length === 0)
239
+ return null;
240
+ return { tags };
241
+ }
242
+ /**
243
+ * Multi-tag classify one memory's content against the configured vocabulary.
244
+ *
245
+ * Uses the classify tier (`completeClassify` → classifyBaseUrl/classifyModel
246
+ * when configured, else the reflect tier) — the caller is responsible for
247
+ * having pre-flighted that endpoint via resolveClassifyProbeTarget (strict:
248
+ * skip classification entirely if it is unreachable).
249
+ *
250
+ * Behaviour:
251
+ * - Valid JSON reply with ≥1 vocabulary tag → {tags} (ordered,
252
+ * most-relevant first).
253
+ * - Valid reply with an EXPLICIT empty tags array → {tags: []} (genuine
254
+ * no-fit — owner amendment 07.07: no fallback category is ever assigned;
255
+ * the caller routes empty tag sets through nofit.ts).
256
+ * - Unparseable / no-valid-tag reply → retry ONCE, then treat as a genuine
257
+ * no-fit: {tags: []} (the model responded twice but produced nothing
258
+ * usable — the embedding-side weak-primary/decay path takes over).
259
+ * - LLM THROWS after the retry → return NULL (infra error; caller aborts this
260
+ * memory for a later retry — never filed anywhere, closes #150).
261
+ *
262
+ * @returns {tags} on success (all members of the vocabulary; empty = no-fit),
263
+ * or null on infra error.
264
+ */
265
+ async function classifyMemoryTags(content, project, domains, llm) {
266
+ if (domains.length === 0)
267
+ return { tags: [] };
268
+ const prompt = buildClassifyPrompt(content, project, domains);
269
+ let threw = false;
270
+ // Two attempts: one call, one retry on a throw OR an unparseable reply.
271
+ for (let attempt = 0; attempt < 2; attempt++) {
272
+ let raw;
273
+ try {
274
+ // ~64 tokens covers a short JSON object with a handful of tags.
275
+ raw = await llm.completeClassify(prompt, 64);
276
+ threw = false;
277
+ }
278
+ catch (err) {
279
+ threw = true;
280
+ if (attempt === 0)
281
+ continue; // retry once
282
+ console.warn(`[hicortex] tag classify LLM error: ${err instanceof Error ? err.message : String(err)} — aborting this memory (will retry)`);
283
+ return null; // infra error → abort untouched (never filed, never decayed)
284
+ }
285
+ const parsed = parseTagReply(raw, domains);
286
+ if (parsed)
287
+ return parsed;
288
+ if (attempt === 0) {
289
+ console.warn(`[hicortex] tag classify: unparseable reply "${raw.slice(0, 60)}" — retrying once`);
290
+ }
291
+ }
292
+ // Two successful calls, neither parseable → treated as a genuine no-fit
293
+ // (empty tag set). (If the second attempt THREW we returned null above;
294
+ // reaching here means the model responded but produced no valid vocabulary
295
+ // tag.) The weak-primary floor guards against mis-decaying a good memory:
296
+ // if it genuinely associates with a domain, the embedding argmax tags it.
297
+ if (threw)
298
+ return null;
299
+ return { tags: [] };
300
+ }
@@ -46,6 +46,11 @@ export interface SelectableLesson {
46
46
  access_count?: number;
47
47
  id?: string;
48
48
  memory_type?: string;
49
+ /**
50
+ * Life-sphere domain (content-based classification). Present on Memory rows.
51
+ * Used by the same-domain affinity boost when the context carries a `domain`.
52
+ */
53
+ domain?: string | null;
49
54
  }
50
55
  export interface LessonSelectorContext {
51
56
  /** Maximum number of lessons to return. Caller decides this from features.lessonsLimit(). */
@@ -58,6 +63,13 @@ export interface LessonSelectorContext {
58
63
  currentTask?: string;
59
64
  /** MODULE_INDEX for domain-aware lesson selection (same-domain projects score 0.5). */
60
65
  moduleIndex?: ModuleIndex;
66
+ /**
67
+ * Current life-sphere domain, if known (content-based classification). When
68
+ * set, lessons in the SAME domain get the same-domain affinity boost directly
69
+ * off `lesson.domain` — the content-mode analogue of the project-grouping
70
+ * moduleIndex boost.
71
+ */
72
+ domain?: string | null;
61
73
  }
62
74
  export interface LessonSelector {
63
75
  /**
package/dist/graph.d.ts CHANGED
@@ -51,4 +51,60 @@ export interface GraphNeighbor {
51
51
  project: string | null;
52
52
  }
53
53
  export declare function getNeighbors(db: Database.Database, memoryId: string, limit?: number, relationship?: string): GraphNeighbor[];
54
+ export declare const EXPORT_DEFAULT_LIMIT = 5000;
55
+ export declare const EXPORT_MAX_LIMIT = 10000;
56
+ export interface VizNode {
57
+ id: string;
58
+ label: string;
59
+ content: string;
60
+ memory_type: string | null;
61
+ domain: string | null;
62
+ tags: string[];
63
+ tagWeights: number[];
64
+ project: string | null;
65
+ strength: number;
66
+ linkCount: number;
67
+ isHub: boolean;
68
+ created_at: string | null;
69
+ }
70
+ export interface VizEdge {
71
+ source: string;
72
+ target: string;
73
+ relationship: string;
74
+ strength: number;
75
+ }
76
+ export interface VizGraph {
77
+ nodes: VizNode[];
78
+ edges: VizEdge[];
79
+ domains: string[];
80
+ types: string[];
81
+ meta: {
82
+ total: number;
83
+ shown: number;
84
+ edgeCount: number;
85
+ };
86
+ }
87
+ export interface ExportGraphOptions {
88
+ domain?: string;
89
+ type?: string;
90
+ /**
91
+ * "Everything touching X" (graded-schema spec): keep only nodes CARRYING the
92
+ * tag, any weight. A node with no memory_tags rows counts as carrying its
93
+ * `domain` (mirrors the payload's tags fallback). Unlike `domain`, which
94
+ * matches only the derived primary.
95
+ */
96
+ tag?: string;
97
+ minStrength?: number;
98
+ limit?: number;
99
+ }
100
+ /**
101
+ * Assemble the full node/edge payload for the /viz page.
102
+ *
103
+ * Nodes are ranked by effective (decayed) strength and capped at `limit`.
104
+ * Edges include only links where BOTH endpoints made the cut.
105
+ * Link counts come from one aggregate query over memory_links (no per-row
106
+ * queries) and feed both the effectiveStrength hardening term and the
107
+ * per-node linkCount field.
108
+ */
109
+ export declare function exportGraph(db: Database.Database, options?: ExportGraphOptions): VizGraph;
54
110
  export declare function shortestPath(db: Database.Database, fromId: string, toId: string, maxDepth?: number): string[] | null;