@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/graph.js CHANGED
@@ -10,10 +10,13 @@
10
10
  * we deal with (hundreds to low thousands of nodes).
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.EXPORT_MAX_LIMIT = exports.EXPORT_DEFAULT_LIMIT = void 0;
13
14
  exports.louvainCommunities = louvainCommunities;
14
15
  exports.detectHubs = detectHubs;
15
16
  exports.getNeighbors = getNeighbors;
17
+ exports.exportGraph = exportGraph;
16
18
  exports.shortestPath = shortestPath;
19
+ const retrieval_js_1 = require("./retrieval.js");
17
20
  function loadGraph(db) {
18
21
  const rows = db
19
22
  .prepare("SELECT source_id, target_id, strength FROM memory_links")
@@ -210,6 +213,148 @@ function getNeighbors(db, memoryId, limit = 10, relationship) {
210
213
  return results;
211
214
  }
212
215
  // ---------------------------------------------------------------------------
216
+ // Full graph export (for GET /graph?op=export — the /viz page, #124)
217
+ // ---------------------------------------------------------------------------
218
+ // Default shows the whole graph for corpora up to 5k memories (owner request
219
+ // 05.07 — the strongest-500 default hid most edges). Payload note: nodes carry
220
+ // content up to 4000 chars, so 5k nodes can be a several-MB JSON response —
221
+ // acceptable on localhost/LAN, which is the /viz deployment model.
222
+ exports.EXPORT_DEFAULT_LIMIT = 5000;
223
+ exports.EXPORT_MAX_LIMIT = 10000;
224
+ /** First non-empty line of content, capped at 80 chars. */
225
+ function makeLabel(content) {
226
+ const firstLine = content.split("\n").find((l) => l.trim().length > 0) ?? content;
227
+ return firstLine.trim().slice(0, 80);
228
+ }
229
+ /**
230
+ * Assemble the full node/edge payload for the /viz page.
231
+ *
232
+ * Nodes are ranked by effective (decayed) strength and capped at `limit`.
233
+ * Edges include only links where BOTH endpoints made the cut.
234
+ * Link counts come from one aggregate query over memory_links (no per-row
235
+ * queries) and feed both the effectiveStrength hardening term and the
236
+ * per-node linkCount field.
237
+ */
238
+ function exportGraph(db, options = {}) {
239
+ const limit = Math.min(Math.max(Math.floor(options.limit ?? exports.EXPORT_DEFAULT_LIMIT), 1), exports.EXPORT_MAX_LIMIT);
240
+ const now = new Date();
241
+ // Per-memory link counts — single aggregate query (same shape as detectHubs)
242
+ const linkCountRows = db
243
+ .prepare(`SELECT id, COUNT(*) as cnt FROM (
244
+ SELECT source_id AS id FROM memory_links
245
+ UNION ALL
246
+ SELECT target_id AS id FROM memory_links
247
+ ) GROUP BY id`)
248
+ .all();
249
+ const linkCounts = new Map(linkCountRows.map((r) => [r.id, r.cnt]));
250
+ // Candidate memories — domain/type filters pushed into SQL
251
+ let sql = `SELECT id, content, memory_type, domain, project, base_strength,
252
+ last_accessed, access_count, created_at
253
+ FROM memories`;
254
+ const where = [];
255
+ const params = [];
256
+ if (options.domain) {
257
+ where.push("domain = ?");
258
+ params.push(options.domain);
259
+ }
260
+ if (options.type) {
261
+ where.push("memory_type = ?");
262
+ params.push(options.type);
263
+ }
264
+ if (options.tag) {
265
+ // Tag match (any weight) OR the domain fallback for never-multi-tagged
266
+ // rows — consistent with the tags payload fallback below.
267
+ where.push(`(id IN (SELECT memory_id FROM memory_tags WHERE tag = ?)
268
+ OR (domain = ? AND id NOT IN (SELECT DISTINCT memory_id FROM memory_tags)))`);
269
+ params.push(options.tag, options.tag);
270
+ }
271
+ if (where.length > 0)
272
+ sql += ` WHERE ${where.join(" AND ")}`;
273
+ const rows = db.prepare(sql).all(...params);
274
+ const hubIds = new Set(detectHubs(db).map((h) => h.id));
275
+ // Score, filter on effective strength, rank, cap
276
+ const scored = rows.map((row) => {
277
+ const linkCount = linkCounts.get(row.id) ?? 0;
278
+ const strength = (0, retrieval_js_1.effectiveStrength)(row.base_strength ?? 0.5, row.last_accessed, now, { accessCount: row.access_count ?? 0, linkCount });
279
+ return { row, strength, linkCount };
280
+ });
281
+ const filtered = options.minStrength !== undefined
282
+ ? scored.filter((s) => s.strength >= options.minStrength)
283
+ : scored;
284
+ filtered.sort((a, b) => b.strength - a.strength);
285
+ const top = filtered.slice(0, limit);
286
+ // Multi-label tags (graded-schema spec) — fetch only for the capped node
287
+ // set. One query keyed on the included ids; grouped into per-node parallel
288
+ // arrays ORDERED BY WEIGHT DESC (NULL weights last, in insertion/relevance
289
+ // order). The derived primary remains on `domain` (the colour); `tags` is
290
+ // the full set, `tagWeights` the parallel association weights (rounded 4dp;
291
+ // 0 for NULL/not-yet-computed).
292
+ const topIds = top.map((t) => t.row.id);
293
+ const tagsByMemory = new Map();
294
+ if (topIds.length > 0) {
295
+ const idPlaceholders = topIds.map(() => "?").join(", ");
296
+ const tagRows = db
297
+ .prepare(`SELECT memory_id, tag, weight FROM memory_tags
298
+ WHERE memory_id IN (${idPlaceholders})
299
+ ORDER BY memory_id, (weight IS NULL) ASC, weight DESC, rowid ASC`)
300
+ .all(...topIds);
301
+ for (const r of tagRows) {
302
+ const arr = tagsByMemory.get(r.memory_id);
303
+ const entry = { tag: r.tag, weight: r.weight };
304
+ if (arr)
305
+ arr.push(entry);
306
+ else
307
+ tagsByMemory.set(r.memory_id, [entry]);
308
+ }
309
+ }
310
+ const nodes = top.map(({ row, strength, linkCount }) => {
311
+ const weighted = tagsByMemory.get(row.id)
312
+ ?? (row.domain ? [{ tag: row.domain, weight: null }] : []);
313
+ return {
314
+ id: row.id,
315
+ label: makeLabel(row.content),
316
+ content: row.content.slice(0, 4000),
317
+ memory_type: row.memory_type,
318
+ domain: row.domain,
319
+ tags: weighted.map((w) => w.tag),
320
+ tagWeights: weighted.map((w) => (w.weight == null ? 0 : Math.round(w.weight * 10000) / 10000)),
321
+ project: row.project,
322
+ strength: Math.round(strength * 10000) / 10000,
323
+ linkCount,
324
+ isHub: hubIds.has(row.id),
325
+ created_at: row.created_at,
326
+ };
327
+ });
328
+ // Edges — only where both endpoints are in the included set
329
+ const included = new Set(nodes.map((n) => n.id));
330
+ const linkRows = db
331
+ .prepare("SELECT source_id, target_id, relationship, strength FROM memory_links")
332
+ .all();
333
+ const edges = linkRows
334
+ .filter((l) => included.has(l.source_id) && included.has(l.target_id))
335
+ .map((l) => ({
336
+ source: l.source_id,
337
+ target: l.target_id,
338
+ relationship: l.relationship,
339
+ strength: l.strength,
340
+ }));
341
+ // Filter dropdown values — distinct across the WHOLE DB, not the shown subset
342
+ const domains = db
343
+ .prepare("SELECT DISTINCT domain FROM memories WHERE domain IS NOT NULL AND domain != '' ORDER BY domain")
344
+ .all().map((r) => r.domain);
345
+ const types = db
346
+ .prepare("SELECT DISTINCT memory_type FROM memories WHERE memory_type IS NOT NULL AND memory_type != '' ORDER BY memory_type")
347
+ .all().map((r) => r.memory_type);
348
+ const total = db.prepare("SELECT COUNT(*) as cnt FROM memories").get().cnt;
349
+ return {
350
+ nodes,
351
+ edges,
352
+ domains,
353
+ types,
354
+ meta: { total, shown: nodes.length, edgeCount: edges.length },
355
+ };
356
+ }
357
+ // ---------------------------------------------------------------------------
213
358
  // Shortest path (for MCP tool)
214
359
  // ---------------------------------------------------------------------------
215
360
  function shortestPath(db, fromId, toId, maxDepth = 5) {
package/dist/index.js CHANGED
@@ -333,7 +333,7 @@ exports.default = {
333
333
  target_id: { type: "string", description: "Target memory ID (required for path operation)" },
334
334
  limit: { type: "number", description: "Max results (default 10)" },
335
335
  domain: { type: "string", description: "Filter hubs by domain" },
336
- relationship: { type: "string", description: "Filter neighbors by relationship type (e.g., CONTRADICTS, SUPERSEDES, derives)" },
336
+ relationship: { type: "string", description: "Filter neighbors by relationship type (e.g., extends, relates_to; legacy data may also have CONTRADICTS, SUPERSEDES, updates)" },
337
337
  },
338
338
  required: ["operation"],
339
339
  },
package/dist/init.d.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * - Strip old static CLAUDE.md learnings block if present
16
16
  * - Install CC custom commands (/learn, /hicortex-activate)
17
17
  */
18
+ import type { DomainDef } from "./types.js";
18
19
  /**
19
20
  * Parse a KEY=VALUE env file (e.g. ~/.hermes/.env or ~/.claude/settings.json env block).
20
21
  * Handles: comments (#), quoted values, empty lines.
@@ -36,6 +37,30 @@ export declare function persistAuthToken(configPath: string): {
36
37
  token: string;
37
38
  generated: boolean;
38
39
  };
40
+ /**
41
+ * Generic default memory domains scaffolded by server-mode init (issue #150).
42
+ * Deliberately broad, high-level spheres — an editable STARTING POINT, not a
43
+ * taxonomy. Users narrow or replace them to match how THEY think (life areas
44
+ * or project/topic areas both work). There is NO fallback category: a no-fit
45
+ * memory is handled automatically by the weak-primary floor + decay lifecycle
46
+ * (see nofit.ts) — never by a catch-all domain.
47
+ */
48
+ export declare const GENERIC_DEFAULT_DOMAINS: DomainDef[];
49
+ /**
50
+ * Scaffold the generic default `domains` list into config.json (server mode).
51
+ *
52
+ * Non-clobber (same philosophy as persistAuthToken): only writes when the
53
+ * config has NO `domains` key at all. An existing key — even an empty array —
54
+ * is user-owned and is never touched. Upgrading installs that re-run init get
55
+ * the scaffold too (they have no `domains` key yet); installs that never
56
+ * re-run init keep the legacy project-grouping behaviour.
57
+ *
58
+ * Prints its own hint lines (tested); returns whether it wrote the scaffold.
59
+ * Exported for testability.
60
+ */
61
+ export declare function scaffoldDefaultDomains(configPath: string): {
62
+ scaffolded: boolean;
63
+ };
39
64
  /**
40
65
  * Install (or verify) the CC SessionStart hook that runs `hicortex lessons-context`.
41
66
  * The hook fetches lessons from the configured server at session start and injects
package/dist/init.js CHANGED
@@ -17,9 +17,11 @@
17
17
  * - Install CC custom commands (/learn, /hicortex-activate)
18
18
  */
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.GENERIC_DEFAULT_DOMAINS = void 0;
20
21
  exports.parseEnvFile = parseEnvFile;
21
22
  exports.generateAuthToken = generateAuthToken;
22
23
  exports.persistAuthToken = persistAuthToken;
24
+ exports.scaffoldDefaultDomains = scaffoldDefaultDomains;
23
25
  exports.installSessionStartHook = installSessionStartHook;
24
26
  exports.runInit = runInit;
25
27
  exports.resolveNightlyHour = resolveNightlyHour;
@@ -697,6 +699,52 @@ function persistAuthToken(configPath) {
697
699
  (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
698
700
  return { token, generated: true };
699
701
  }
702
+ /**
703
+ * Generic default memory domains scaffolded by server-mode init (issue #150).
704
+ * Deliberately broad, high-level spheres — an editable STARTING POINT, not a
705
+ * taxonomy. Users narrow or replace them to match how THEY think (life areas
706
+ * or project/topic areas both work). There is NO fallback category: a no-fit
707
+ * memory is handled automatically by the weak-primary floor + decay lifecycle
708
+ * (see nofit.ts) — never by a catch-all domain.
709
+ */
710
+ exports.GENERIC_DEFAULT_DOMAINS = [
711
+ { name: "Work", description: "Your job and professional life — employer, clients, workstreams" },
712
+ { name: "Personal", description: "Private life — home, hobbies, everyday matters" },
713
+ { name: "People", description: "Relationships — family, friends, social life, network" },
714
+ { name: "Health", description: "Fitness, wellbeing, medical" },
715
+ { name: "Finance", description: "Money — budgeting, spending, investing" },
716
+ ];
717
+ /**
718
+ * Scaffold the generic default `domains` list into config.json (server mode).
719
+ *
720
+ * Non-clobber (same philosophy as persistAuthToken): only writes when the
721
+ * config has NO `domains` key at all. An existing key — even an empty array —
722
+ * is user-owned and is never touched. Upgrading installs that re-run init get
723
+ * the scaffold too (they have no `domains` key yet); installs that never
724
+ * re-run init keep the legacy project-grouping behaviour.
725
+ *
726
+ * Prints its own hint lines (tested); returns whether it wrote the scaffold.
727
+ * Exported for testability.
728
+ */
729
+ function scaffoldDefaultDomains(configPath) {
730
+ let config = {};
731
+ try {
732
+ config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
733
+ }
734
+ catch { /* new file */ }
735
+ if ("domains" in config) {
736
+ console.log(" ✓ Memory domains already configured — leaving your list as-is");
737
+ return { scaffolded: false };
738
+ }
739
+ config.domains = exports.GENERIC_DEFAULT_DOMAINS;
740
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(configPath), { recursive: true });
741
+ (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
742
+ const names = exports.GENERIC_DEFAULT_DOMAINS.map((d) => d.name).join(", ");
743
+ console.log(` ✓ Memory domains scaffolded in ${configPath}`);
744
+ console.log(` Defaults: ${names}. Edit the \`domains\` list to match how YOU think —`);
745
+ console.log(` they can be life areas OR project/topic areas (see domains.example.json in the package).`);
746
+ return { scaffolded: true };
747
+ }
700
748
  /**
701
749
  * Determine the npm package specifier for the daemon.
702
750
  * Uses tag-based resolution so restarts pick up new versions automatically.
@@ -1005,6 +1053,12 @@ async function runInit(options = {}) {
1005
1053
  else {
1006
1054
  console.log(` ✓ Auth token already configured`);
1007
1055
  }
1056
+ // Scaffold the generic default memory domains (server mode only — domains
1057
+ // live in the server's config; a client's memories are classified by the
1058
+ // server). Non-clobber: an existing `domains` key is never touched.
1059
+ // Classification activates automatically once an LLM is configured; until
1060
+ // then domains sit inert (strict-skip path).
1061
+ scaffoldDefaultDomains(configPath);
1008
1062
  // Install the nightly job (capture via localhost /distill + consolidation).
1009
1063
  // Without it a server-mode install never captures or consolidates — the
1010
1064
  // daemon only serves recall + /distill. Skips if a schedule already exists.
@@ -69,14 +69,21 @@ function parseDate(ts) {
69
69
  const d = new Date(ts);
70
70
  return isNaN(d.getTime()) ? null : d;
71
71
  }
72
- function projectMatch(lesson, targetProject, moduleIndex) {
73
- if (!lesson.project)
74
- return 0.0;
72
+ function projectMatch(lesson, targetProject, moduleIndex, targetDomainName) {
75
73
  if (targetProject && lesson.project === targetProject)
76
74
  return 1.0;
77
75
  if (lesson.project === "global")
78
76
  return 0.3;
79
- // Domain-aware: same domain = 0.5
77
+ // Content-mode same-domain boost: when the caller knows the current
78
+ // life-sphere domain, a lesson filed into the SAME domain scores 0.5.
79
+ // This is the content-classification analogue of the project-grouping
80
+ // moduleIndex boost below. Keyed on lesson.domain directly (no project map).
81
+ if (targetDomainName && lesson.domain && lesson.domain === targetDomainName) {
82
+ return 0.5;
83
+ }
84
+ if (!lesson.project)
85
+ return 0.0;
86
+ // Project-grouping same-domain boost: same domain via moduleIndex = 0.5.
80
87
  if (targetProject && moduleIndex) {
81
88
  const targetDomain = moduleIndex.domains.find((d) => d.projects.includes(targetProject));
82
89
  if (targetDomain && targetDomain.projects.includes(lesson.project)) {
@@ -129,7 +136,7 @@ exports.domainAwareLessonSelector = {
129
136
  const now = new Date();
130
137
  // Score every lesson
131
138
  const scored = lessons.map((lesson) => {
132
- const pMatch = projectMatch(lesson, ctx.project, ctx.moduleIndex);
139
+ const pMatch = projectMatch(lesson, ctx.project, ctx.moduleIndex, ctx.domain);
133
140
  const rScore = recencyScore(lesson, now);
134
141
  const sScore = lesson.base_strength ?? 0.5;
135
142
  const aScore = accessAffinity(lesson);
@@ -83,7 +83,8 @@ async function fetchLessonsContext() {
83
83
  for (const domain of moduleIndex.domains) {
84
84
  const kwStr = domain.keywords.length > 0 ? `: ${domain.keywords.join(", ")}` : "";
85
85
  parts.push(`${domain.name} (${domain.memoryCount} memories, ${domain.lessonCount} lessons)${kwStr}`);
86
- parts.push(` ${domain.projects.join(" | ")}`);
86
+ if (domain.projects.length > 0)
87
+ parts.push(` ${domain.projects.join(" | ")}`);
87
88
  }
88
89
  parts.push(`${index.total} memories, ${index.lessonCount} lessons, ${index.sourceCount} agents. Search with \`hicortex_search\`.`);
89
90
  }
package/dist/llm.d.ts CHANGED
@@ -29,6 +29,21 @@ export interface LlmConfig {
29
29
  reflectBaseUrl?: string;
30
30
  reflectApiKey?: string;
31
31
  reflectProvider?: string;
32
+ /**
33
+ * Optional separate model for memory tag classification (defaults to the
34
+ * reflect tier when unset — zero behavior change for existing installs).
35
+ * Chosen after an A/B benchmark where a dedicated classifier model
36
+ * materially outperformed the reflect model on this task.
37
+ */
38
+ classifyModel?: string;
39
+ /**
40
+ * Optional separate endpoint for classification. When only classifyModel is
41
+ * set, the classify model runs on the reflect endpoint (or the base endpoint
42
+ * when no reflect endpoint is configured).
43
+ */
44
+ classifyBaseUrl?: string;
45
+ classifyApiKey?: string;
46
+ classifyProvider?: string;
32
47
  }
33
48
  /**
34
49
  * Resolve LLM configuration from explicit config-file overrides or
@@ -53,6 +68,46 @@ export declare function resolveExplicitLlmConfig(overrides?: {
53
68
  * the transition for any lingering call sites — remove after 0.10.0 ships.
54
69
  */
55
70
  export declare const resolveLlmConfigForCC: typeof resolveExplicitLlmConfig;
71
+ /**
72
+ * Resolve an LlmConfig from a saved ~/.hicortex/config.json object.
73
+ *
74
+ * This is the SINGLE config path used by pipeline runs (nightly consolidation
75
+ * and `hicortex relink`): named backends (claude-cli, ollama) first, then the
76
+ * explicit-config/env fallthrough via resolveExplicitLlmConfig, then the
77
+ * reflect endpoint overlay. Extracted verbatim from nightly.ts — behavior
78
+ * is identical to the pre-0.11 inline block.
79
+ *
80
+ * Returns `reason: "claude_binary_missing"` when claude-cli is configured but
81
+ * the binary can't be found, so callers can log a context-specific message.
82
+ */
83
+ export declare function resolveSavedLlmConfig(savedConfig: Record<string, unknown> | null): {
84
+ config: LlmConfig | null;
85
+ reason?: "claude_binary_missing";
86
+ };
87
+ /**
88
+ * Endpoint + model that memory tag classification will ACTUALLY use, for
89
+ * pre-flight probing. Pure function — the single source of truth shared by
90
+ * the nightly's contentDomainsReady gate and `hicortex classify-domains`.
91
+ *
92
+ * Mirrors LlmClient.completeClassify's routing:
93
+ * - classify tier configured (classifyModel and/or classifyBaseUrl) →
94
+ * classifyBaseUrl ?? reflectBaseUrl, classifyModel ?? reflectModel
95
+ * - classify tier absent → the reflect tier (reflectBaseUrl/reflectModel),
96
+ * exactly what completeReflect uses
97
+ *
98
+ * Returns null when no probe applies: only a SEPARATE Ollama endpoint can go
99
+ * unreachable mid-run (API providers are cloud-reachable; the base endpoint
100
+ * is not pre-flighted anywhere, matching distill/reflect behavior).
101
+ *
102
+ * `tier` tells callers which configuration produced the target — "reflect"
103
+ * means the classification probe is identical to the reflect-stage probe and
104
+ * its result can be reused.
105
+ */
106
+ export declare function resolveClassifyProbeTarget(config: LlmConfig): {
107
+ tier: "classify" | "reflect";
108
+ baseUrl: string;
109
+ model: string;
110
+ } | null;
56
111
  /**
57
112
  * Find the claude CLI binary. Returns the full path or null.
58
113
  */
@@ -132,6 +187,18 @@ export declare class LlmClient {
132
187
  * Routes to distillBaseUrl/distillProvider if configured (e.g. remote Ollama with faster model).
133
188
  */
134
189
  completeDistill(prompt: string, maxTokens?: number): Promise<string>;
190
+ /**
191
+ * Classification-tier completion (memory tag classification).
192
+ *
193
+ * Routing (same "optional dedicated model+baseUrl with fallback" pattern as
194
+ * completeDistill; Ollama calls inherit think:false via completeOllama):
195
+ * - Neither classifyModel nor classifyBaseUrl set → delegate to
196
+ * completeReflect (exactly the pre-classify-tier behavior).
197
+ * - classifyBaseUrl set → that endpoint, model classifyModel ?? reflectModel.
198
+ * - Only classifyModel set → the classify model on the reflect endpoint
199
+ * when one is configured, else on the base endpoint.
200
+ */
201
+ completeClassify(prompt: string, maxTokens?: number): Promise<string>;
135
202
  /**
136
203
  * Complete with overridden baseUrl/apiKey/provider (used for reflect tier with separate endpoint).
137
204
  * Creates a temporary LlmClient to avoid mutating shared config under concurrent calls.
package/dist/llm.js CHANGED
@@ -17,6 +17,8 @@
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.LlmClient = exports.RateLimitError = exports.resolveLlmConfigForCC = void 0;
19
19
  exports.resolveExplicitLlmConfig = resolveExplicitLlmConfig;
20
+ exports.resolveSavedLlmConfig = resolveSavedLlmConfig;
21
+ exports.resolveClassifyProbeTarget = resolveClassifyProbeTarget;
20
22
  exports.findClaudeBinary = findClaudeBinary;
21
23
  exports.claudeCliConfig = claudeCliConfig;
22
24
  exports.probeOllama = probeOllama;
@@ -68,6 +70,102 @@ function resolveExplicitLlmConfig(overrides) {
68
70
  * the transition for any lingering call sites — remove after 0.10.0 ships.
69
71
  */
70
72
  exports.resolveLlmConfigForCC = resolveExplicitLlmConfig;
73
+ /**
74
+ * Resolve an LlmConfig from a saved ~/.hicortex/config.json object.
75
+ *
76
+ * This is the SINGLE config path used by pipeline runs (nightly consolidation
77
+ * and `hicortex relink`): named backends (claude-cli, ollama) first, then the
78
+ * explicit-config/env fallthrough via resolveExplicitLlmConfig, then the
79
+ * reflect endpoint overlay. Extracted verbatim from nightly.ts — behavior
80
+ * is identical to the pre-0.11 inline block.
81
+ *
82
+ * Returns `reason: "claude_binary_missing"` when claude-cli is configured but
83
+ * the binary can't be found, so callers can log a context-specific message.
84
+ */
85
+ function resolveSavedLlmConfig(savedConfig) {
86
+ let llmConfig = null;
87
+ if (savedConfig?.llmBackend === "claude-cli") {
88
+ const claudePath = findClaudeBinary();
89
+ if (claudePath) {
90
+ llmConfig = claudeCliConfig(claudePath);
91
+ }
92
+ else {
93
+ return { config: null, reason: "claude_binary_missing" };
94
+ }
95
+ }
96
+ else if (savedConfig?.llmBackend === "ollama") {
97
+ llmConfig = {
98
+ baseUrl: savedConfig.llmBaseUrl ?? "http://localhost:11434",
99
+ apiKey: "",
100
+ model: savedConfig.llmModel ?? "qwen3.5:4b",
101
+ reflectModel: savedConfig.reflectModel ?? savedConfig.llmModel ?? "qwen3.5:4b",
102
+ provider: "ollama",
103
+ };
104
+ }
105
+ else {
106
+ llmConfig = resolveExplicitLlmConfig({
107
+ llmBaseUrl: savedConfig?.llmBaseUrl,
108
+ llmApiKey: savedConfig?.llmApiKey,
109
+ llmModel: savedConfig?.llmModel,
110
+ reflectModel: savedConfig?.reflectModel,
111
+ });
112
+ }
113
+ if (llmConfig && savedConfig?.reflectBaseUrl) {
114
+ llmConfig.reflectBaseUrl = savedConfig.reflectBaseUrl;
115
+ llmConfig.reflectApiKey = savedConfig.reflectApiKey ?? llmConfig.apiKey;
116
+ llmConfig.reflectProvider = savedConfig.reflectProvider ?? llmConfig.provider;
117
+ }
118
+ // Optional classify tier (memory tag classification). Same overlay pattern
119
+ // as distillModel/distillBaseUrl: when absent, completeClassify falls back
120
+ // to the reflect tier — zero behavior change for existing installs.
121
+ if (llmConfig && savedConfig?.classifyModel) {
122
+ llmConfig.classifyModel = savedConfig.classifyModel;
123
+ }
124
+ if (llmConfig && savedConfig?.classifyBaseUrl) {
125
+ llmConfig.classifyBaseUrl = savedConfig.classifyBaseUrl;
126
+ llmConfig.classifyApiKey = savedConfig.classifyApiKey ?? llmConfig.apiKey;
127
+ llmConfig.classifyProvider = savedConfig.classifyProvider ?? llmConfig.provider;
128
+ }
129
+ return { config: llmConfig };
130
+ }
131
+ /**
132
+ * Endpoint + model that memory tag classification will ACTUALLY use, for
133
+ * pre-flight probing. Pure function — the single source of truth shared by
134
+ * the nightly's contentDomainsReady gate and `hicortex classify-domains`.
135
+ *
136
+ * Mirrors LlmClient.completeClassify's routing:
137
+ * - classify tier configured (classifyModel and/or classifyBaseUrl) →
138
+ * classifyBaseUrl ?? reflectBaseUrl, classifyModel ?? reflectModel
139
+ * - classify tier absent → the reflect tier (reflectBaseUrl/reflectModel),
140
+ * exactly what completeReflect uses
141
+ *
142
+ * Returns null when no probe applies: only a SEPARATE Ollama endpoint can go
143
+ * unreachable mid-run (API providers are cloud-reachable; the base endpoint
144
+ * is not pre-flighted anywhere, matching distill/reflect behavior).
145
+ *
146
+ * `tier` tells callers which configuration produced the target — "reflect"
147
+ * means the classification probe is identical to the reflect-stage probe and
148
+ * its result can be reused.
149
+ */
150
+ function resolveClassifyProbeTarget(config) {
151
+ const classifyConfigured = Boolean(config.classifyModel || config.classifyBaseUrl);
152
+ if (classifyConfigured) {
153
+ const baseUrl = config.classifyBaseUrl ?? config.reflectBaseUrl;
154
+ const model = config.classifyModel ?? config.reflectModel;
155
+ const provider = config.classifyBaseUrl
156
+ ? (config.classifyProvider ?? config.provider)
157
+ : (config.reflectProvider ?? config.provider); // riding the reflect endpoint
158
+ if (baseUrl && provider === "ollama") {
159
+ return { tier: "classify", baseUrl, model };
160
+ }
161
+ return null; // base endpoint or API provider — no probe
162
+ }
163
+ // Classify tier absent — classification delegates to completeReflect.
164
+ if (config.reflectBaseUrl && (config.reflectProvider ?? config.provider) === "ollama") {
165
+ return { tier: "reflect", baseUrl: config.reflectBaseUrl, model: config.reflectModel ?? config.model };
166
+ }
167
+ return null;
168
+ }
71
169
  function detectProvider(url) {
72
170
  const u = url.toLowerCase();
73
171
  if (u.includes("ollama") || u.includes(":11434"))
@@ -298,6 +396,30 @@ class LlmClient {
298
396
  }
299
397
  return this.complete(this.config.distillModel ?? this.config.model, prompt, maxTokens, 900_000);
300
398
  }
399
+ /**
400
+ * Classification-tier completion (memory tag classification).
401
+ *
402
+ * Routing (same "optional dedicated model+baseUrl with fallback" pattern as
403
+ * completeDistill; Ollama calls inherit think:false via completeOllama):
404
+ * - Neither classifyModel nor classifyBaseUrl set → delegate to
405
+ * completeReflect (exactly the pre-classify-tier behavior).
406
+ * - classifyBaseUrl set → that endpoint, model classifyModel ?? reflectModel.
407
+ * - Only classifyModel set → the classify model on the reflect endpoint
408
+ * when one is configured, else on the base endpoint.
409
+ */
410
+ async completeClassify(prompt, maxTokens = 8192) {
411
+ if (!this.config.classifyModel && !this.config.classifyBaseUrl) {
412
+ return this.completeReflect(prompt, maxTokens);
413
+ }
414
+ const model = this.config.classifyModel ?? this.config.reflectModel;
415
+ if (this.config.classifyBaseUrl) {
416
+ return this.completeWithOverride(this.config.classifyBaseUrl, this.config.classifyApiKey ?? this.config.apiKey, this.config.classifyProvider ?? this.config.provider, model, prompt, maxTokens, 900_000);
417
+ }
418
+ if (this.config.reflectBaseUrl) {
419
+ return this.completeWithOverride(this.config.reflectBaseUrl, this.config.reflectApiKey ?? this.config.apiKey, this.config.reflectProvider ?? this.config.provider, model, prompt, maxTokens, 900_000);
420
+ }
421
+ return this.complete(model, prompt, maxTokens, 900_000);
422
+ }
301
423
  /**
302
424
  * Complete with overridden baseUrl/apiKey/provider (used for reflect tier with separate endpoint).
303
425
  * Creates a temporary LlmClient to avoid mutating shared config under concurrent calls.