@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.
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 +1126 -0
  8. package/dist/classify-domains.d.ts +98 -0
  9. package/dist/classify-domains.js +340 -0
  10. package/dist/cli.d.ts +1 -0
  11. package/dist/cli.js +63 -0
  12. package/dist/consolidate.d.ts +139 -2
  13. package/dist/consolidate.js +302 -87
  14. package/dist/db.js +70 -0
  15. package/dist/domain-classify.d.ts +164 -0
  16. package/dist/domain-classify.js +300 -0
  17. package/dist/extensions.d.ts +12 -0
  18. package/dist/graph.d.ts +56 -0
  19. package/dist/graph.js +145 -0
  20. package/dist/index.js +1 -1
  21. package/dist/init.d.ts +25 -0
  22. package/dist/init.js +54 -0
  23. package/dist/lesson-selection.js +12 -5
  24. package/dist/lessons-context.js +2 -1
  25. package/dist/llm.d.ts +67 -0
  26. package/dist/llm.js +122 -0
  27. package/dist/mcp-server.js +82 -27
  28. package/dist/nightly-status.js +9 -28
  29. package/dist/nightly.js +42 -32
  30. package/dist/nofit.d.ts +111 -0
  31. package/dist/nofit.js +176 -0
  32. package/dist/prompts.d.ts +0 -5
  33. package/dist/prompts.js +5 -29
  34. package/dist/relink.d.ts +100 -0
  35. package/dist/relink.js +277 -0
  36. package/dist/retrieval.d.ts +16 -1
  37. package/dist/retrieval.js +34 -2
  38. package/dist/schema-prototypes.d.ts +149 -0
  39. package/dist/schema-prototypes.js +329 -0
  40. package/dist/state.d.ts +32 -0
  41. package/dist/state.js +29 -0
  42. package/dist/status.js +12 -19
  43. package/dist/storage.d.ts +44 -1
  44. package/dist/storage.js +70 -1
  45. package/dist/types.d.ts +90 -0
  46. package/dist/viz.d.ts +69 -0
  47. package/dist/viz.js +180 -0
  48. package/domains.example.json +36 -0
  49. package/package.json +6 -3
@@ -0,0 +1,329 @@
1
+ "use strict";
2
+ /**
3
+ * Graded schema membership — domain prototypes + per-tag association weights
4
+ * (spec: specs/2026-07-07-graded-schema-memory-tags.md).
5
+ *
6
+ * MODEL (cognitive grounding → mechanism)
7
+ * ---------------------------------------
8
+ * A configured domain is a SCHEMA with graded membership (Rosch prototypes):
9
+ * - prototype(domain) = L2-normalized mean of the embeddings of memories
10
+ * whose tag set includes the domain. Cold start / thin domains
11
+ * (member_count < PROTOTYPE_MIN_MEMBERS) seed the prototype from the
12
+ * embedding of the domain's config description instead.
13
+ * - weight(memory, tag) = cosine(memory embedding, prototype(tag)). Both
14
+ * vectors are L2-normalized, so cosine reduces to a dot product.
15
+ * - PRIMARY (memories.domain) = argmax-weight tag, overridden by any tagged
16
+ * domain flagged `compartment: true` (deliberate compartmentalization —
17
+ * the owner's Work firewall). Fully mechanical, no LLM.
18
+ *
19
+ * The LLM decides ONLY the discrete part (which schemas apply — see
20
+ * domain-classify.ts); ALL gradation is derived from embeddings here.
21
+ * Prototypes + weights are recomputed each nightly, so categories drift with
22
+ * the data ("reconsolidation") without any re-classification runs.
23
+ *
24
+ * Persistence: `domain_prototypes(domain, embedding, member_count, updated_at)`
25
+ * and `memory_tags.weight` (both migration v7).
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.PROTOTYPE_MIN_MEMBERS = void 0;
29
+ exports.blobToVec = blobToVec;
30
+ exports.l2Normalize = l2Normalize;
31
+ exports.tagWeight = tagWeight;
32
+ exports.compartmentSet = compartmentSet;
33
+ exports.derivePrimary = derivePrimary;
34
+ exports.loadDomainPrototypes = loadDomainPrototypes;
35
+ exports.computeDomainPrototypes = computeDomainPrototypes;
36
+ exports.computeTagWeights = computeTagWeights;
37
+ exports.bestPrototypeMatch = bestPrototypeMatch;
38
+ exports.recomputeAllTagWeights = recomputeAllTagWeights;
39
+ exports.refreshPrimaries = refreshPrimaries;
40
+ /**
41
+ * Below this member count a domain's prototype is seeded from its config
42
+ * description instead of the member centroid (cold start / thin domains).
43
+ */
44
+ exports.PROTOTYPE_MIN_MEMBERS = 5;
45
+ // ---------------------------------------------------------------------------
46
+ // Vector helpers (local — this module must not import storage.ts, which
47
+ // imports derivePrimary from here; keep the dependency one-way)
48
+ // ---------------------------------------------------------------------------
49
+ /** Deserialize a BLOB column into a Float32Array (same layout as sqlite-vec). */
50
+ function blobToVec(buf) {
51
+ return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
52
+ }
53
+ /** Serialize a Float32Array to a BLOB (same layout as storage.embedToBlob). */
54
+ function vecToBlob(vec) {
55
+ return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
56
+ }
57
+ /**
58
+ * L2-normalize a vector IN A COPY. A zero vector (norm 0 — e.g. test fixtures)
59
+ * is returned as an all-zero copy rather than dividing by zero.
60
+ */
61
+ function l2Normalize(vec) {
62
+ let sumSq = 0;
63
+ for (let i = 0; i < vec.length; i++)
64
+ sumSq += vec[i] * vec[i];
65
+ const norm = Math.sqrt(sumSq);
66
+ const out = new Float32Array(vec.length);
67
+ if (norm === 0)
68
+ return out;
69
+ for (let i = 0; i < vec.length; i++)
70
+ out[i] = vec[i] / norm;
71
+ return out;
72
+ }
73
+ /**
74
+ * Association weight of a memory for a tag = cosine(memory embedding, domain
75
+ * prototype). Both inputs are L2-normalized (embedder.ts normalizes memory
76
+ * embeddings; computeDomainPrototypes normalizes prototypes), so cosine is
77
+ * exactly the dot product.
78
+ */
79
+ function tagWeight(memoryEmbedding, prototype) {
80
+ const n = Math.min(memoryEmbedding.length, prototype.length);
81
+ let dot = 0;
82
+ for (let i = 0; i < n; i++)
83
+ dot += memoryEmbedding[i] * prototype[i];
84
+ return dot;
85
+ }
86
+ /** The configured compartment domain names (DomainDef.compartment === true). */
87
+ function compartmentSet(domains) {
88
+ return new Set(domains.filter((d) => d.compartment === true).map((d) => d.name));
89
+ }
90
+ /**
91
+ * Derive the PRIMARY tag (memories.domain) from a weighted tag set.
92
+ *
93
+ * Rules (deterministic, no LLM):
94
+ * 1. Any tagged compartment domain wins — first one in array order if the
95
+ * (unusual) case of several arises.
96
+ * 2. Else the argmax-weight tag. `tags` MUST be in LLM most-relevant-first
97
+ * order: ties (and all-null weights) resolve to the EARLIEST array
98
+ * position — strict `>` comparison keeps the first maximum.
99
+ * 3. A null weight loses to any numeric weight (treated as -Infinity).
100
+ *
101
+ * Throws on an empty tag set — callers guarantee >= 1 tag (an empty tag set
102
+ * from the classifier is a NO-FIT and must be routed through nofit.ts, never
103
+ * here); an empty set reaching this function is a programming error.
104
+ */
105
+ function derivePrimary(tags, compartments) {
106
+ if (tags.length === 0) {
107
+ throw new Error("derivePrimary: empty tag set (callers must pass >= 1 tag)");
108
+ }
109
+ for (const t of tags) {
110
+ if (compartments.has(t.tag))
111
+ return t.tag;
112
+ }
113
+ let best = tags[0];
114
+ let bestWeight = best.weight ?? Number.NEGATIVE_INFINITY;
115
+ for (let i = 1; i < tags.length; i++) {
116
+ const w = tags[i].weight ?? Number.NEGATIVE_INFINITY;
117
+ if (w > bestWeight) {
118
+ best = tags[i];
119
+ bestWeight = w;
120
+ }
121
+ }
122
+ return best.tag;
123
+ }
124
+ /** Read a single memory's stored embedding (point lookup on memory_vectors). */
125
+ function getMemoryEmbedding(db, memoryId) {
126
+ const row = db
127
+ .prepare("SELECT embedding FROM memory_vectors WHERE id = ?")
128
+ .get(memoryId);
129
+ if (!row?.embedding)
130
+ return null;
131
+ return blobToVec(row.embedding);
132
+ }
133
+ /** Load all stored prototypes into a name → vector map. */
134
+ function loadDomainPrototypes(db) {
135
+ const rows = db
136
+ .prepare("SELECT domain, embedding FROM domain_prototypes")
137
+ .all();
138
+ const map = new Map();
139
+ for (const r of rows) {
140
+ if (r.embedding)
141
+ map.set(r.domain, blobToVec(r.embedding));
142
+ }
143
+ return map;
144
+ }
145
+ /**
146
+ * Compute + persist the prototype of every configured domain.
147
+ *
148
+ * Per domain: mean of the embeddings of memories whose memory_tags include the
149
+ * domain, L2-normalized. When member_count < PROTOTYPE_MIN_MEMBERS the
150
+ * prototype is instead the embedding of `"<Name>: <description>"` (the same
151
+ * "name: description" line the classifier prompt shows) — this seeds cold
152
+ * starts and keeps thin domains from collapsing onto 1-2 outliers.
153
+ *
154
+ * `getEmbedFn` is LAZY (a function returning a promise of the embedder) so the
155
+ * ~130 MB ONNX embedder is loaded ONLY when at least one domain actually needs
156
+ * a description seed — same pattern as relink.ts's lazy fallback embedder.
157
+ *
158
+ * Returns the fresh prototype map (for immediate classification-time weights)
159
+ * plus per-domain stats. Rows in domain_prototypes are REPLACED per domain;
160
+ * domains removed from the config keep a stale row until the next config-set
161
+ * change (harmless — nothing reads prototypes outside the configured set).
162
+ */
163
+ async function computeDomainPrototypes(db, domains, getEmbedFn) {
164
+ const memberStmt = db.prepare("SELECT memory_id FROM memory_tags WHERE tag = ?");
165
+ const upsert = db.prepare(`INSERT OR REPLACE INTO domain_prototypes (domain, embedding, member_count, updated_at)
166
+ VALUES (?, ?, ?, ?)`);
167
+ const prototypes = new Map();
168
+ const stats = [];
169
+ for (const d of domains) {
170
+ const memberIds = memberStmt.all(d.name)
171
+ .map((r) => r.memory_id);
172
+ // Collect member embeddings (a tag row without a vector is skipped — it
173
+ // cannot contribute to a centroid and would poison the mean with zeros).
174
+ const embeddings = [];
175
+ for (const id of memberIds) {
176
+ const emb = getMemoryEmbedding(db, id);
177
+ if (emb)
178
+ embeddings.push(emb);
179
+ }
180
+ const memberCount = embeddings.length;
181
+ let prototype;
182
+ let seeded = false;
183
+ if (memberCount >= exports.PROTOTYPE_MIN_MEMBERS) {
184
+ const dim = embeddings[0].length;
185
+ const mean = new Float32Array(dim);
186
+ for (const emb of embeddings) {
187
+ for (let i = 0; i < dim; i++)
188
+ mean[i] += emb[i];
189
+ }
190
+ for (let i = 0; i < dim; i++)
191
+ mean[i] /= memberCount;
192
+ prototype = l2Normalize(mean);
193
+ }
194
+ else {
195
+ // Cold start / thin domain → description seed.
196
+ const embedFn = await getEmbedFn();
197
+ prototype = l2Normalize(await embedFn(`${d.name}: ${d.description}`));
198
+ seeded = true;
199
+ }
200
+ upsert.run(d.name, vecToBlob(prototype), memberCount, new Date().toISOString());
201
+ prototypes.set(d.name, prototype);
202
+ stats.push({ domain: d.name, memberCount, seeded });
203
+ }
204
+ return { prototypes, stats };
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // Weight computation
208
+ // ---------------------------------------------------------------------------
209
+ /**
210
+ * Compute the per-tag weights for ONE memory from the current prototypes
211
+ * (classification-time path: newly tagged memories get weights immediately).
212
+ * A missing memory vector or missing prototype yields null (stored as NULL;
213
+ * repaired by the next nightly recompute).
214
+ */
215
+ function computeTagWeights(db, memoryId, tags, prototypes) {
216
+ const emb = getMemoryEmbedding(db, memoryId);
217
+ const out = {};
218
+ for (const tag of tags) {
219
+ const proto = prototypes.get(tag);
220
+ out[tag] = emb && proto ? tagWeight(emb, proto) : null;
221
+ }
222
+ return out;
223
+ }
224
+ /**
225
+ * Best prototype match for one memory across ALL configured domains:
226
+ * argmax of cosine(memory embedding, prototype(domain)).
227
+ *
228
+ * Used by the no-fit path (nofit.ts, owner amendment 07.07): when the LLM
229
+ * says no domain fits, the memory can still earn a WEAK primary from pure
230
+ * embedding association — provided the best cosine clears the configured
231
+ * weakPrimaryFloor (the caller checks the floor; this function just reports
232
+ * the argmax).
233
+ *
234
+ * Returns null when the memory has no stored vector or no configured domain
235
+ * has a prototype (nothing to associate against). Ties resolve to the FIRST
236
+ * domain in config order (strict `>` comparison), mirroring derivePrimary.
237
+ */
238
+ function bestPrototypeMatch(db, memoryId, domains, prototypes) {
239
+ const emb = getMemoryEmbedding(db, memoryId);
240
+ if (!emb)
241
+ return null;
242
+ let best = null;
243
+ for (const d of domains) {
244
+ const proto = prototypes.get(d.name);
245
+ if (!proto)
246
+ continue;
247
+ const w = tagWeight(emb, proto);
248
+ if (best === null || w > best.weight) {
249
+ best = { domain: d.name, weight: w };
250
+ }
251
+ }
252
+ return best;
253
+ }
254
+ /**
255
+ * One pass over ALL memory_tags rows: weight = cosine(memory embedding,
256
+ * prototype(tag)). Rows whose memory has no stored vector, or whose tag has no
257
+ * prototype (out-of-vocabulary leftovers), are set to NULL. Cheap: one point
258
+ * lookup per distinct memory + one dot product per tag row (3–5k on the
259
+ * production corpus).
260
+ */
261
+ function recomputeAllTagWeights(db, prototypes) {
262
+ const rows = db
263
+ .prepare("SELECT memory_id, tag FROM memory_tags ORDER BY memory_id")
264
+ .all();
265
+ const update = db.prepare("UPDATE memory_tags SET weight = ? WHERE memory_id = ? AND tag = ?");
266
+ let updated = 0;
267
+ let nulled = 0;
268
+ const tx = db.transaction(() => {
269
+ let currentId = null;
270
+ let currentEmb = null;
271
+ for (const row of rows) {
272
+ if (row.memory_id !== currentId) {
273
+ currentId = row.memory_id;
274
+ currentEmb = getMemoryEmbedding(db, row.memory_id);
275
+ }
276
+ const proto = prototypes.get(row.tag);
277
+ if (currentEmb && proto) {
278
+ update.run(tagWeight(currentEmb, proto), row.memory_id, row.tag);
279
+ updated++;
280
+ }
281
+ else {
282
+ update.run(null, row.memory_id, row.tag);
283
+ nulled++;
284
+ }
285
+ }
286
+ });
287
+ tx();
288
+ return { updated, nulled };
289
+ }
290
+ /**
291
+ * Re-derive the PRIMARY (memories.domain) of every tagged memory from its
292
+ * current tag weights: compartment override first, else argmax weight, LLM
293
+ * order (memory_tags insertion order = rowid, written most-relevant-first by
294
+ * storage.setMemoryTags) breaking exact-weight ties.
295
+ *
296
+ * Memories with NO memory_tags rows are untouched (e.g. infra-skipped rows
297
+ * awaiting classification — issue #150 discipline).
298
+ */
299
+ function refreshPrimaries(db, domains) {
300
+ const compartments = compartmentSet(domains);
301
+ const rows = db
302
+ .prepare(`SELECT mt.memory_id, mt.tag, mt.weight, m.domain
303
+ FROM memory_tags mt JOIN memories m ON m.id = mt.memory_id
304
+ ORDER BY mt.memory_id, mt.rowid`)
305
+ .all();
306
+ // Group per memory, preserving rowid (LLM) order within each group.
307
+ const byMemory = new Map();
308
+ for (const r of rows) {
309
+ let entry = byMemory.get(r.memory_id);
310
+ if (!entry) {
311
+ entry = { tags: [], domain: r.domain };
312
+ byMemory.set(r.memory_id, entry);
313
+ }
314
+ entry.tags.push({ tag: r.tag, weight: r.weight });
315
+ }
316
+ const update = db.prepare("UPDATE memories SET domain = ? WHERE id = ?");
317
+ let updated = 0;
318
+ const tx = db.transaction(() => {
319
+ for (const [memoryId, entry] of byMemory) {
320
+ const primary = derivePrimary(entry.tags, compartments);
321
+ if (primary !== entry.domain) {
322
+ update.run(primary, memoryId);
323
+ updated++;
324
+ }
325
+ }
326
+ });
327
+ tx();
328
+ return { examined: byMemory.size, updated };
329
+ }
package/dist/state.d.ts CHANGED
@@ -37,6 +37,19 @@ export interface HicortexState {
37
37
  telemetryId?: string;
38
38
  /** Cached MODULE_INDEX from domain curation (generated during consolidation). */
39
39
  moduleIndex?: ModuleIndex;
40
+ /**
41
+ * Resume cursor for `hicortex relink` — highest memories.rowid whose batch
42
+ * has been fully committed. Absent/0 = never run (or reset). Cleared is not
43
+ * required on completion; a finished run simply leaves the cursor at the
44
+ * max rowid processed.
45
+ */
46
+ relinkCursor?: number;
47
+ /**
48
+ * Resume cursor for `hicortex classify-domains` — highest memories.rowid
49
+ * whose batch has been fully committed. Absent/0 = never run (or reset).
50
+ * Same discipline as relinkCursor.
51
+ */
52
+ domainCursor?: number;
40
53
  }
41
54
  /**
42
55
  * Load the state file. Returns an empty state if the file is missing
@@ -66,3 +79,22 @@ export declare function updateState(updater: (state: HicortexState) => HicortexS
66
79
  * Returns true if migration ran, false if state.json already existed.
67
80
  */
68
81
  export declare function migrateLegacyState(stateDir?: string): boolean;
82
+ export interface LastNightlyInfo {
83
+ /** Raw timestamp string as stored. */
84
+ timestamp: string;
85
+ /** True when the stored value is not a parseable date. */
86
+ invalid: boolean;
87
+ ageHours?: number;
88
+ /** Human age, e.g. "just now", "5h ago", "2d ago". */
89
+ ageStr?: string;
90
+ /** True when older than the missed-a-night threshold (30h). */
91
+ stale?: boolean;
92
+ }
93
+ /**
94
+ * Last nightly run for status display, shared by `hicortex status` and
95
+ * `hicortex nightly --status`. Read-only: prefers state.json but falls
96
+ * back to the pre-migration nightly-last-run.txt so upgraded installs
97
+ * report correctly before their first nightly performs the migration.
98
+ * Returns null when no run has ever been recorded.
99
+ */
100
+ export declare function describeLastNightly(stateDir?: string): LastNightlyInfo | null;
package/dist/state.js CHANGED
@@ -22,6 +22,7 @@ exports.loadState = loadState;
22
22
  exports.saveState = saveState;
23
23
  exports.updateState = updateState;
24
24
  exports.migrateLegacyState = migrateLegacyState;
25
+ exports.describeLastNightly = describeLastNightly;
25
26
  const node_fs_1 = require("node:fs");
26
27
  const node_path_1 = require("node:path");
27
28
  const node_os_1 = require("node:os");
@@ -141,6 +142,34 @@ function migrateLegacyState(stateDir = HICORTEX_HOME) {
141
142
  }
142
143
  return false;
143
144
  }
145
+ const STALE_THRESHOLD_HOURS = 30;
146
+ /**
147
+ * Last nightly run for status display, shared by `hicortex status` and
148
+ * `hicortex nightly --status`. Read-only: prefers state.json but falls
149
+ * back to the pre-migration nightly-last-run.txt so upgraded installs
150
+ * report correctly before their first nightly performs the migration.
151
+ * Returns null when no run has ever been recorded.
152
+ */
153
+ function describeLastNightly(stateDir = HICORTEX_HOME) {
154
+ const ts = loadState(stateDir).lastNightly ??
155
+ readLegacyText(stateDir, "nightly-last-run.txt");
156
+ if (!ts)
157
+ return null;
158
+ const d = new Date(ts);
159
+ if (isNaN(d.getTime()))
160
+ return { timestamp: ts, invalid: true };
161
+ const ageHours = Math.round((Date.now() - d.getTime()) / (60 * 60 * 1000));
162
+ const ageStr = ageHours < 1 ? "just now" :
163
+ ageHours < 24 ? `${ageHours}h ago` :
164
+ `${Math.round(ageHours / 24)}d ago`;
165
+ return {
166
+ timestamp: ts,
167
+ invalid: false,
168
+ ageHours,
169
+ ageStr,
170
+ stale: ageHours > STALE_THRESHOLD_HOURS,
171
+ };
172
+ }
144
173
  function readLegacyText(stateDir, name) {
145
174
  try {
146
175
  const raw = (0, node_fs_1.readFileSync)((0, node_path_1.join)(stateDir, name), "utf-8").trim();
package/dist/status.js CHANGED
@@ -10,6 +10,7 @@ const node_os_1 = require("node:os");
10
10
  const node_child_process_1 = require("node:child_process");
11
11
  const db_js_1 = require("./db.js");
12
12
  const features_js_1 = require("./features.js");
13
+ const state_js_1 = require("./state.js");
13
14
  const HICORTEX_HOME = (0, node_path_1.join)((0, node_os_1.homedir)(), ".hicortex");
14
15
  const CC_SETTINGS = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "settings.json");
15
16
  const OC_CONFIG = (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw", "openclaw.json");
@@ -129,27 +130,19 @@ async function runStatus() {
129
130
  }
130
131
  }
131
132
  // Last nightly run
132
- const lastRunPath = (0, node_path_1.join)(HICORTEX_HOME, "nightly-last-run.txt");
133
- try {
134
- const ts = (0, node_fs_1.readFileSync)(lastRunPath, "utf-8").trim();
135
- const lastRun = new Date(ts);
136
- if (isNaN(lastRun.getTime())) {
137
- console.log(` Last run: ${ts} (invalid timestamp)`);
138
- }
139
- else {
140
- const STALE_THRESHOLD_HOURS = 30;
141
- const ageHours = Math.round((Date.now() - lastRun.getTime()) / (60 * 60 * 1000));
142
- const ageStr = ageHours < 1 ? "just now" : ageHours < 24 ? `${ageHours}h ago` : `${Math.round(ageHours / 24)}d ago`;
143
- console.log(` Last run: ${ts} (${ageStr})`);
144
- // Show staleness warning if missed a night
145
- if (ageHours > STALE_THRESHOLD_HOURS) {
146
- console.log(` ⚠ Nightly pipeline hasn't run in ${ageHours}h. Check: hicortex nightly --dry-run`);
147
- }
148
- }
149
- }
150
- catch {
133
+ const lastRun = (0, state_js_1.describeLastNightly)();
134
+ if (!lastRun) {
151
135
  console.log(" Last run: never (run: hicortex nightly)");
152
136
  }
137
+ else if (lastRun.invalid) {
138
+ console.log(` Last run: ${lastRun.timestamp} (invalid timestamp)`);
139
+ }
140
+ else {
141
+ console.log(` Last run: ${lastRun.timestamp} (${lastRun.ageStr})`);
142
+ if (lastRun.stale) {
143
+ console.log(` ⚠ Nightly pipeline hasn't run in ${lastRun.ageHours}h. Check: hicortex nightly --dry-run`);
144
+ }
145
+ }
153
146
  // Distillation stats (if DB exists)
154
147
  if (dbExists) {
155
148
  const TOP_SOURCES_LIMIT = 5;
package/dist/storage.d.ts CHANGED
@@ -31,9 +31,52 @@ export declare function updateMemory(db: Database.Database, memoryId: string, fi
31
31
  */
32
32
  export declare function strengthenMemory(db: Database.Database, memoryId: string, nowIsoStr: string): void;
33
33
  /**
34
- * Delete a memory, its vector, and all its links.
34
+ * Delete a memory, its vector, its tags, and all its links.
35
35
  */
36
36
  export declare function deleteMemory(db: Database.Database, memoryId: string): void;
37
+ /** Options for setMemoryTags (graded-schema spec 2026-07-07). */
38
+ export interface SetMemoryTagsOptions {
39
+ /**
40
+ * Per-tag association weights (cosine vs the domain prototype), computed by
41
+ * schema-prototypes.computeTagWeights. A missing/null entry stores NULL
42
+ * (repaired by the next nightly recompute).
43
+ */
44
+ weights?: Record<string, number | null>;
45
+ /**
46
+ * Compartment domain names (DomainDef.compartment === true): a tagged
47
+ * compartment domain becomes the primary regardless of weights.
48
+ */
49
+ compartments?: Set<string>;
50
+ }
51
+ /**
52
+ * Set a memory's classification tags (graded schema model).
53
+ *
54
+ * `tags` is the ORDERED tag set from the classifier (most-relevant first —
55
+ * the order is persisted via insertion/rowid order and breaks exact-weight
56
+ * ties). The `memory_tags` sidecar rows for this memory are REPLACED; each
57
+ * row stores its association weight (NULL when not yet computed).
58
+ *
59
+ * The PRIMARY (memories.domain) is DERIVED here — never passed in by the LLM:
60
+ * compartment override first, else argmax weight, else first tag (all-null
61
+ * weights). The whole update is one transaction so domain, tag set, and
62
+ * weights never diverge.
63
+ *
64
+ * @returns the derived primary written to memories.domain
65
+ */
66
+ export declare function setMemoryTags(db: Database.Database, memoryId: string, tags: string[], options?: SetMemoryTagsOptions): string;
67
+ /**
68
+ * Get a memory's tag set (the full multi-label set, including the primary).
69
+ * Returns tags sorted alphabetically for stable output; empty array if none.
70
+ */
71
+ export declare function getMemoryTags(db: Database.Database, memoryId: string): string[];
72
+ /**
73
+ * Get a memory's tags WITH weights, ordered by weight descending (NULL
74
+ * weights last, in insertion/relevance order). Empty array if none.
75
+ */
76
+ export declare function getMemoryTagsWeighted(db: Database.Database, memoryId: string): Array<{
77
+ tag: string;
78
+ weight: number | null;
79
+ }>;
37
80
  /**
38
81
  * Find similar memories by vector distance. Returns memories with distance field.
39
82
  */
package/dist/storage.js CHANGED
@@ -10,6 +10,9 @@ exports.getMemory = getMemory;
10
10
  exports.updateMemory = updateMemory;
11
11
  exports.strengthenMemory = strengthenMemory;
12
12
  exports.deleteMemory = deleteMemory;
13
+ exports.setMemoryTags = setMemoryTags;
14
+ exports.getMemoryTags = getMemoryTags;
15
+ exports.getMemoryTagsWeighted = getMemoryTagsWeighted;
13
16
  exports.vectorSearch = vectorSearch;
14
17
  exports.searchFts = searchFts;
15
18
  exports.addLink = addLink;
@@ -24,6 +27,7 @@ exports.getPruneCandidates = getPruneCandidates;
24
27
  exports.getAllLinkCounts = getAllLinkCounts;
25
28
  exports.getUnscoredMemories = getUnscoredMemories;
26
29
  const node_crypto_1 = require("node:crypto");
30
+ const schema_prototypes_js_1 = require("./schema-prototypes.js");
27
31
  // ---------------------------------------------------------------------------
28
32
  // Helpers
29
33
  // ---------------------------------------------------------------------------
@@ -127,13 +131,78 @@ function strengthenMemory(db, memoryId, nowIsoStr) {
127
131
  WHERE id = ?`).run(nowIsoStr, memoryId);
128
132
  }
129
133
  /**
130
- * Delete a memory, its vector, and all its links.
134
+ * Delete a memory, its vector, its tags, and all its links.
131
135
  */
132
136
  function deleteMemory(db, memoryId) {
133
137
  db.prepare("DELETE FROM memory_links WHERE source_id = ? OR target_id = ?").run(memoryId, memoryId);
138
+ db.prepare("DELETE FROM memory_tags WHERE memory_id = ?").run(memoryId);
134
139
  db.prepare("DELETE FROM memory_vectors WHERE id = ?").run(memoryId);
135
140
  db.prepare("DELETE FROM memories WHERE id = ?").run(memoryId);
136
141
  }
142
+ /**
143
+ * Set a memory's classification tags (graded schema model).
144
+ *
145
+ * `tags` is the ORDERED tag set from the classifier (most-relevant first —
146
+ * the order is persisted via insertion/rowid order and breaks exact-weight
147
+ * ties). The `memory_tags` sidecar rows for this memory are REPLACED; each
148
+ * row stores its association weight (NULL when not yet computed).
149
+ *
150
+ * The PRIMARY (memories.domain) is DERIVED here — never passed in by the LLM:
151
+ * compartment override first, else argmax weight, else first tag (all-null
152
+ * weights). The whole update is one transaction so domain, tag set, and
153
+ * weights never diverge.
154
+ *
155
+ * @returns the derived primary written to memories.domain
156
+ */
157
+ function setMemoryTags(db, memoryId, tags, options = {}) {
158
+ // Dedup preserving first (most-relevant) occurrence; defensive — the
159
+ // classifier already dedups.
160
+ const unique = Array.from(new Set(tags));
161
+ if (unique.length === 0) {
162
+ throw new Error("setMemoryTags: empty tag set (callers must pass >= 1 tag)");
163
+ }
164
+ const weighted = unique.map((tag) => ({
165
+ tag,
166
+ weight: options.weights?.[tag] ?? null,
167
+ }));
168
+ const primary = (0, schema_prototypes_js_1.derivePrimary)(weighted, options.compartments ?? new Set());
169
+ const setDomain = db.prepare("UPDATE memories SET domain = ? WHERE id = ?");
170
+ const clearTags = db.prepare("DELETE FROM memory_tags WHERE memory_id = ?");
171
+ const insertTag = db.prepare("INSERT OR IGNORE INTO memory_tags (memory_id, tag, weight) VALUES (?, ?, ?)");
172
+ const tx = db.transaction(() => {
173
+ setDomain.run(primary, memoryId);
174
+ clearTags.run(memoryId);
175
+ // Insert in LLM order — rowid order IS the persisted relevance order.
176
+ // schema-prototypes.ts refreshPrimaries depends on this (ORDER BY mt.rowid
177
+ // as the argmax tiebreak): any future bulk writer of memory_tags MUST
178
+ // insert most-relevant-first or tiebreak ordering silently corrupts.
179
+ for (const w of weighted)
180
+ insertTag.run(memoryId, w.tag, w.weight);
181
+ });
182
+ tx();
183
+ return primary;
184
+ }
185
+ /**
186
+ * Get a memory's tag set (the full multi-label set, including the primary).
187
+ * Returns tags sorted alphabetically for stable output; empty array if none.
188
+ */
189
+ function getMemoryTags(db, memoryId) {
190
+ const rows = db
191
+ .prepare("SELECT tag FROM memory_tags WHERE memory_id = ? ORDER BY tag")
192
+ .all(memoryId);
193
+ return rows.map((r) => r.tag);
194
+ }
195
+ /**
196
+ * Get a memory's tags WITH weights, ordered by weight descending (NULL
197
+ * weights last, in insertion/relevance order). Empty array if none.
198
+ */
199
+ function getMemoryTagsWeighted(db, memoryId) {
200
+ const rows = db
201
+ .prepare(`SELECT tag, weight FROM memory_tags WHERE memory_id = ?
202
+ ORDER BY (weight IS NULL) ASC, weight DESC, rowid ASC`)
203
+ .all(memoryId);
204
+ return rows;
205
+ }
137
206
  // ---------------------------------------------------------------------------
138
207
  // Vector search
139
208
  // ---------------------------------------------------------------------------