@gamaze/hicortex 0.10.1 → 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.
- 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 +1126 -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 +82 -27
- 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/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
|
+
}
|
package/dist/extensions.d.ts
CHANGED
|
@@ -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;
|