@gamaze/hicortex 0.13.0 → 0.13.2

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.
@@ -47,8 +47,12 @@ const NOISE_ROLES = new Set(["tool", "session_meta"]);
47
47
  /**
48
48
  * Read Hermes sessions that ended since `since`, across all profiles.
49
49
  * Returns one batch per session, parallel to readCcTranscripts().
50
+ *
51
+ * @param cursors Per-session capture cursors (#189), keyed `hermes:<profile>:<sid>`.
52
+ * The cursor value is the max `messages.id` already captured; a resumed +
53
+ * re-ended session yields only the new rows (`id > cursor`).
50
54
  */
51
- function readHermesSessions(since, hermesHome = HERMES_HOME) {
55
+ function readHermesSessions(since, hermesHome = HERMES_HOME, cursors = {}) {
52
56
  const batches = [];
53
57
  const sinceEpoch = since.getTime() / 1000; // Hermes timestamps are unix seconds (REAL)
54
58
  for (const { profile, dbPath } of discoverProfileDbs(hermesHome)) {
@@ -63,15 +67,40 @@ function readHermesSessions(since, hermesHome = HERMES_HOME) {
63
67
  const sessions = db
64
68
  .prepare("SELECT id, ended_at, source FROM sessions WHERE ended_at IS NOT NULL AND ended_at > ? ORDER BY ended_at")
65
69
  .all(sinceEpoch);
66
- const msgStmt = db.prepare("SELECT role, content, tool_name, timestamp FROM messages WHERE session_id = ? ORDER BY timestamp, id");
70
+ // Cursor is a message id (INTEGER PRIMARY KEY AUTOINCREMENT strictly
71
+ // increasing, never reused), so `id > ?` returns exactly the rows added
72
+ // since last capture. ORDER BY id (NOT timestamp): id is the capture
73
+ // boundary, so ordering rows by id makes entryCursors monotonic and the
74
+ // last row's id the true max consumed — the segment boundary the packer
75
+ // advances to is then genuinely the largest id, never skipping a
76
+ // lower-id-but-later-timestamp row. Verified safe: id order == timestamp
77
+ // order in production (A1: 0 divergences / 4413 rows), so text ordering is
78
+ // unchanged in practice.
79
+ const msgStmt = db.prepare("SELECT id, role, content, tool_name, timestamp FROM messages WHERE session_id = ? AND id > ? ORDER BY id");
80
+ // Highest id in the session — used only for the shrink guard below.
81
+ const maxIdStmt = db.prepare("SELECT MAX(id) as m FROM messages WHERE session_id = ?");
67
82
  for (const s of sessions) {
68
83
  // Skip automated (non-primary) sessions — cron runs are not
69
84
  // conversations and would pollute memory. Checked before pulling
70
85
  // messages so we don't even read them.
71
86
  if (NON_PRIMARY_SOURCES.has(s.source))
72
87
  continue;
73
- const rows = msgStmt.all(s.id);
74
- // Skip only genuinely empty sessions. Do NOT gate on message count —
88
+ const cursorKey = `hermes:${profile}:${s.id}`;
89
+ const pos = cursors[cursorKey] ?? { cursor: 0, gen: 0 };
90
+ let startCursor = pos.cursor;
91
+ let gen = pos.gen;
92
+ // Shrink guard: if the stored cursor exceeds the session's max id (DB
93
+ // reset/restore), re-read from 0 and bump the generation (fix 8). Cheap
94
+ // MAX(id) probe; the common path (cursor <= max) leaves it untouched.
95
+ if (startCursor > 0) {
96
+ const maxId = maxIdStmt.get(s.id).m ?? 0;
97
+ if (startCursor > maxId) {
98
+ startCursor = 0;
99
+ gen = pos.gen + 1;
100
+ }
101
+ }
102
+ const rows = msgStmt.all(s.id, startCursor);
103
+ // Skip only genuinely empty deltas. Do NOT gate on message count —
75
104
  // a short 2-message exchange can carry a real decision. Meaningful-
76
105
  // content is gated downstream by the post-denoise 200-char check in
77
106
  // nightly.ts, so short-but-dense sessions aren't dropped here.
@@ -84,6 +113,10 @@ function readHermesSessions(since, hermesHome = HERMES_HOME) {
84
113
  role: NOISE_ROLES.has(r.role) ? "tool_result" : r.role,
85
114
  content: r.content ?? "",
86
115
  }));
116
+ // entryCursors are the row ids, monotonic under ORDER BY id — the last
117
+ // is the max consumed id, so segment boundaries and the final advance
118
+ // land exactly on it.
119
+ const entryCursors = rows.map((r) => r.id);
87
120
  const endTs = s.ended_at ?? rows[rows.length - 1].timestamp;
88
121
  batches.push({
89
122
  sessionId: s.id,
@@ -91,6 +124,10 @@ function readHermesSessions(since, hermesHome = HERMES_HOME) {
91
124
  sourceAgent: `hermes/${profile}`,
92
125
  date: new Date(endTs * 1000).toISOString().slice(0, 10),
93
126
  entries,
127
+ cursorKey,
128
+ startCursor,
129
+ generation: gen,
130
+ entryCursors,
94
131
  });
95
132
  }
96
133
  }
package/dist/init.d.ts CHANGED
@@ -29,6 +29,14 @@ export declare function parseMcpListStatus(mcpListOutput: string): "connected" |
29
29
  * Exported for testability.
30
30
  */
31
31
  export declare function parseEnvFile(content: string): Record<string, string>;
32
+ /**
33
+ * True when an LLM is already persisted and `init` must NOT re-run provider
34
+ * selection: a named/flat backend, a flat baseUrl+apiKey pair, OR a nested-only
35
+ * `models.score` (model or baseUrl). The last clause (0.13.1) stops init from
36
+ * walking a nested-only config back through selection and writing flat keys that
37
+ * a `models.score` would then silently shadow (nested > flat).
38
+ */
39
+ export declare function isLlmConfigured(config: Record<string, unknown>): boolean;
32
40
  /**
33
41
  * Generate a random auth token in the format hctx-<32 hex chars>.
34
42
  * Exported for testability.
package/dist/init.js CHANGED
@@ -20,6 +20,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.GENERIC_DEFAULT_DOMAINS = void 0;
21
21
  exports.parseMcpListStatus = parseMcpListStatus;
22
22
  exports.parseEnvFile = parseEnvFile;
23
+ exports.isLlmConfigured = isLlmConfigured;
23
24
  exports.generateAuthToken = generateAuthToken;
24
25
  exports.persistAuthToken = persistAuthToken;
25
26
  exports.decideAgentName = decideAgentName;
@@ -493,6 +494,18 @@ function mergeByKey(candidates) {
493
494
  }
494
495
  return [...seen.values()];
495
496
  }
497
+ /**
498
+ * True when an LLM is already persisted and `init` must NOT re-run provider
499
+ * selection: a named/flat backend, a flat baseUrl+apiKey pair, OR a nested-only
500
+ * `models.score` (model or baseUrl). The last clause (0.13.1) stops init from
501
+ * walking a nested-only config back through selection and writing flat keys that
502
+ * a `models.score` would then silently shadow (nested > flat).
503
+ */
504
+ function isLlmConfigured(config) {
505
+ const modelsScore = config.models?.score;
506
+ const hasModelsScore = Boolean(modelsScore?.model || modelsScore?.baseUrl);
507
+ return Boolean(config.llmBackend || (config.llmApiKey && config.llmBaseUrl) || hasModelsScore);
508
+ }
496
509
  /**
497
510
  * Detect or ask for LLM config and persist to ~/.hicortex/config.json.
498
511
  * The daemon can't inherit shell env vars, so we persist here.
@@ -508,8 +521,8 @@ async function persistLlmConfig() {
508
521
  config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
509
522
  }
510
523
  catch { /* new file */ }
511
- // Don't overwrite if LLM config already persisted
512
- if (config.llmBackend || (config.llmApiKey && config.llmBaseUrl)) {
524
+ // Don't overwrite if LLM config already persisted (incl. a nested-only config).
525
+ if (isLlmConfigured(config)) {
513
526
  console.log(` ✓ LLM config already configured`);
514
527
  return;
515
528
  }
package/dist/llm.d.ts CHANGED
@@ -68,6 +68,25 @@ export declare function resolveExplicitLlmConfig(overrides?: {
68
68
  * the transition for any lingering call sites — remove after 0.10.0 ships.
69
69
  */
70
70
  export declare const resolveLlmConfigForCC: typeof resolveExplicitLlmConfig;
71
+ export type { ModelTierOverride } from "./types.js";
72
+ /**
73
+ * Normalize a nested `models: { <tier>: {model,baseUrl,apiKey,provider} }` block
74
+ * onto the flat `llm*` / `distill*` / `reflect*` / `classify*` keys the resolver
75
+ * already consumes. Nested overrides WIN over any flat key of the same name; every
76
+ * non-mapped key (llmBackend, licenseKey, distillFallback, contextClients, …)
77
+ * is preserved via spread. Pure: returns the SAME reference when there is no
78
+ * `models` key, so this is a provable no-op for every existing install.
79
+ *
80
+ * Robust to a malformed config.json: a config that parses to a scalar, array,
81
+ * or null is returned untouched (matching the pre-0.13.1 optional-chaining
82
+ * tolerance — this function must never throw at server/nightly boot).
83
+ *
84
+ * Fail-explicit (warn + skip, never throw): an invalid `models` value, an
85
+ * unknown tier name, a non-object tier value, a non-string field value, a tier
86
+ * apiKey/provider set without a baseUrl (they are baseUrl-gated downstream), and
87
+ * a dead score apiKey/provider under an ollama base.
88
+ */
89
+ export declare function applyModelsBlock(saved: Record<string, unknown> | null): Record<string, unknown> | null;
71
90
  /**
72
91
  * Resolve an LlmConfig from a saved ~/.hicortex/config.json object.
73
92
  *
@@ -79,8 +98,12 @@ export declare const resolveLlmConfigForCC: typeof resolveExplicitLlmConfig;
79
98
  *
80
99
  * Returns `reason: "claude_binary_missing"` when claude-cli is configured but
81
100
  * the binary can't be found, so callers can log a context-specific message.
101
+ *
102
+ * `findBinary` is injectable (defaults to the real `findClaudeBinary`) so the
103
+ * claude-cli branch — including the missing-binary passthrough — can be pinned
104
+ * deterministically in tests without depending on the host filesystem.
82
105
  */
83
- export declare function resolveSavedLlmConfig(savedConfig: Record<string, unknown> | null): {
106
+ export declare function resolveSavedLlmConfig(savedConfig: Record<string, unknown> | null, findBinary?: () => string | null): {
84
107
  config: LlmConfig | null;
85
108
  reason?: "claude_binary_missing";
86
109
  };
package/dist/llm.js CHANGED
@@ -17,6 +17,7 @@
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.applyModelsBlock = applyModelsBlock;
20
21
  exports.resolveSavedLlmConfig = resolveSavedLlmConfig;
21
22
  exports.resolveClassifyProbeTarget = resolveClassifyProbeTarget;
22
23
  exports.findClaudeBinary = findClaudeBinary;
@@ -70,6 +71,117 @@ function resolveExplicitLlmConfig(overrides) {
70
71
  * the transition for any lingering call sites — remove after 0.10.0 ships.
71
72
  */
72
73
  exports.resolveLlmConfigForCC = resolveExplicitLlmConfig;
74
+ /**
75
+ * Map from a `models.<tier>` name to the flat config keys it feeds. The base
76
+ * tier is `score` — score IS the base model today (completeFast reads
77
+ * config.model), so it lands on the llm* keys and its `provider` is ignored
78
+ * (the base provider comes from llmBackend / detectProvider, not config).
79
+ * Tiers with a `provider` key (distill/reflect/classify) apply their apiKey +
80
+ * provider through a baseUrl-gated overlay downstream; `score` (no provider
81
+ * key) rides the base resolution.
82
+ */
83
+ const MODELS_TIER_KEYS = {
84
+ score: { model: "llmModel", baseUrl: "llmBaseUrl", apiKey: "llmApiKey" },
85
+ distill: { model: "distillModel", baseUrl: "distillBaseUrl", apiKey: "distillApiKey", provider: "distillProvider" },
86
+ reflect: { model: "reflectModel", baseUrl: "reflectBaseUrl", apiKey: "reflectApiKey", provider: "reflectProvider" },
87
+ classify: { model: "classifyModel", baseUrl: "classifyBaseUrl", apiKey: "classifyApiKey", provider: "classifyProvider" },
88
+ };
89
+ /**
90
+ * Normalize a nested `models: { <tier>: {model,baseUrl,apiKey,provider} }` block
91
+ * onto the flat `llm*` / `distill*` / `reflect*` / `classify*` keys the resolver
92
+ * already consumes. Nested overrides WIN over any flat key of the same name; every
93
+ * non-mapped key (llmBackend, licenseKey, distillFallback, contextClients, …)
94
+ * is preserved via spread. Pure: returns the SAME reference when there is no
95
+ * `models` key, so this is a provable no-op for every existing install.
96
+ *
97
+ * Robust to a malformed config.json: a config that parses to a scalar, array,
98
+ * or null is returned untouched (matching the pre-0.13.1 optional-chaining
99
+ * tolerance — this function must never throw at server/nightly boot).
100
+ *
101
+ * Fail-explicit (warn + skip, never throw): an invalid `models` value, an
102
+ * unknown tier name, a non-object tier value, a non-string field value, a tier
103
+ * apiKey/provider set without a baseUrl (they are baseUrl-gated downstream), and
104
+ * a dead score apiKey/provider under an ollama base.
105
+ */
106
+ function applyModelsBlock(saved) {
107
+ // Guard the container itself first — `"models" in saved` throws a TypeError on
108
+ // a truthy non-object (config.json = `true`/`5`/`"x"`); such configs must pass
109
+ // through so the boot path degrades to recall-only exactly as before.
110
+ if (typeof saved !== "object" || saved === null || Array.isArray(saved))
111
+ return saved;
112
+ if (!("models" in saved))
113
+ return saved;
114
+ const models = saved.models;
115
+ if (typeof models !== "object" || models === null || Array.isArray(models)) {
116
+ console.warn(`[hicortex] Ignoring invalid "models" config: expected an object of per-tier overrides, got ${Array.isArray(models) ? "array" : models === null ? "null" : typeof models}`);
117
+ return saved;
118
+ }
119
+ const ollamaBase = saved.llmBackend === "ollama";
120
+ const mapped = {};
121
+ for (const [tier, value] of Object.entries(models)) {
122
+ const keys = MODELS_TIER_KEYS[tier];
123
+ if (!keys) {
124
+ console.warn(`[hicortex] Ignoring unknown "models" tier "${tier}" (expected: score, distill, reflect, classify)`);
125
+ continue;
126
+ }
127
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
128
+ console.warn(`[hicortex] Ignoring invalid "models.${tier}" override: expected an object with model/baseUrl/apiKey/provider`);
129
+ continue;
130
+ }
131
+ const o = value;
132
+ // Per-field string validation: a non-string value would map verbatim and
133
+ // fail opaquely downstream (e.g. baseUrl: 11434), so drop it with a warning.
134
+ const strField = (name) => {
135
+ const v = o[name];
136
+ if (v === undefined)
137
+ return undefined;
138
+ if (typeof v !== "string") {
139
+ console.warn(`[hicortex] Ignoring non-string "models.${tier}.${name}" (expected a string)`);
140
+ return undefined;
141
+ }
142
+ return v;
143
+ };
144
+ const model = strField("model");
145
+ const baseUrl = strField("baseUrl");
146
+ const apiKey = strField("apiKey");
147
+ const provider = strField("provider");
148
+ if (model !== undefined)
149
+ mapped[keys.model] = model;
150
+ if (baseUrl !== undefined)
151
+ mapped[keys.baseUrl] = baseUrl;
152
+ if (keys.provider) {
153
+ // Overlay tier (distill/reflect/classify): the downstream overlay only
154
+ // consumes apiKey/provider when the tier ALSO sets its own baseUrl.
155
+ // Without one, they would silently bill to the base key — so warn + drop.
156
+ if ((apiKey !== undefined || provider !== undefined) && baseUrl === undefined) {
157
+ console.warn(`[hicortex] Ignoring "models.${tier}" apiKey/provider without a baseUrl: they only take effect when the tier sets its own baseUrl`);
158
+ }
159
+ else {
160
+ if (apiKey !== undefined)
161
+ mapped[keys.apiKey] = apiKey;
162
+ if (provider !== undefined)
163
+ mapped[keys.provider] = provider;
164
+ }
165
+ }
166
+ else {
167
+ // score = base tier: no separate provider key, and apiKey rides llmApiKey.
168
+ if (provider !== undefined) {
169
+ console.warn(`[hicortex] Ignoring "models.score.provider": the base provider comes from llmBackend (or is auto-detected from the endpoint)`);
170
+ }
171
+ if (apiKey !== undefined) {
172
+ if (ollamaBase) {
173
+ // The ollama base path hardcodes an empty api key and never reads
174
+ // llmApiKey, so score.apiKey is dead there.
175
+ console.warn(`[hicortex] Ignoring "models.score.apiKey": the base ollama path sends no api key`);
176
+ }
177
+ else {
178
+ mapped[keys.apiKey] = apiKey;
179
+ }
180
+ }
181
+ }
182
+ }
183
+ return { ...saved, ...mapped };
184
+ }
73
185
  /**
74
186
  * Resolve an LlmConfig from a saved ~/.hicortex/config.json object.
75
187
  *
@@ -81,11 +193,16 @@ exports.resolveLlmConfigForCC = resolveExplicitLlmConfig;
81
193
  *
82
194
  * Returns `reason: "claude_binary_missing"` when claude-cli is configured but
83
195
  * the binary can't be found, so callers can log a context-specific message.
196
+ *
197
+ * `findBinary` is injectable (defaults to the real `findClaudeBinary`) so the
198
+ * claude-cli branch — including the missing-binary passthrough — can be pinned
199
+ * deterministically in tests without depending on the host filesystem.
84
200
  */
85
- function resolveSavedLlmConfig(savedConfig) {
201
+ function resolveSavedLlmConfig(savedConfig, findBinary = findClaudeBinary) {
202
+ savedConfig = applyModelsBlock(savedConfig);
86
203
  let llmConfig = null;
87
204
  if (savedConfig?.llmBackend === "claude-cli") {
88
- const claudePath = findClaudeBinary();
205
+ const claudePath = findBinary();
89
206
  if (claudePath) {
90
207
  llmConfig = claudeCliConfig(claudePath);
91
208
  }
@@ -332,7 +332,7 @@ async function startServer(options = {}) {
332
332
  // Named backends (claude-cli, ollama) → immediate config; everything else
333
333
  // goes through resolveExplicitLlmConfig which requires a user-chosen provider.
334
334
  // If nothing is configured: start recall-only with an unmissable warning.
335
- const savedConfig = readConfigFile(stateDir);
335
+ const savedConfig = (0, llm_js_1.applyModelsBlock)(readConfigFile(stateDir));
336
336
  if (savedConfig?.llmBackend === "claude-cli") {
337
337
  const claudePath = (0, llm_js_1.findClaudeBinary)();
338
338
  if (claudePath) {
@@ -681,11 +681,27 @@ async function startServer(options = {}) {
681
681
  res.status(400).json({ error: "Provide either 'text' (string) or 'messages' (array)" });
682
682
  return;
683
683
  }
684
+ // Escape LIKE wildcards — Hermes ids contain "_" (e.g. 20260701_045744_...).
685
+ const escapeLike = (s) => s.replace(/[\\%_]/g, (m) => "\\" + m);
686
+ // Segment-exact dedup (#189): an incremental capture POST carries
687
+ // segment_id "<start>-<end>[.pN]". Skip iff THIS exact segment's chunks are
688
+ // already stored (keys "<sid>#<segment_id>#<i>"). This is what lets a failed
689
+ // segment be safely retried with the same id, and a legacy session-level row
690
+ // (key "<sid>#<i>", no segment) does NOT match — so the #189 recovery
691
+ // re-ingest is never blocked by night-1's whole-session rows.
692
+ if (session_id && segment_id) {
693
+ const likePrefix = `${escapeLike(session_id)}#${escapeLike(segment_id)}#%`;
694
+ const existing = db.prepare("SELECT COUNT(*) as c FROM memories WHERE source_session LIKE ? ESCAPE '\\'").get(likePrefix);
695
+ if (existing.c > 0) {
696
+ res.status(200).json({ skipped: true, existing_count: existing.c });
697
+ return;
698
+ }
699
+ }
684
700
  // Session-level dedup: when session_id is present and this is a whole-session
685
- // POST (no segment_id), skip if any chunk of this session is already stored.
701
+ // POST (no segment_id — legacy ≤0.13.1 clients), skip if any chunk of this
702
+ // session is already stored. Unchanged: legacy clients keep exact behaviour.
686
703
  if (session_id && !segment_id) {
687
- // Escape LIKE wildcards — Hermes ids contain "_" (e.g. 20260701_045744_...).
688
- const likePrefix = `${session_id.replace(/[\\%_]/g, (m) => "\\" + m)}#%`;
704
+ const likePrefix = `${escapeLike(session_id)}#%`;
689
705
  const existing = db.prepare("SELECT COUNT(*) as c FROM memories WHERE source_session = ? OR source_session LIKE ? ESCAPE '\\'").get(session_id, likePrefix);
690
706
  if (existing.c > 0) {
691
707
  res.status(200).json({ skipped: true, existing_count: existing.c });
@@ -717,26 +733,48 @@ async function startServer(options = {}) {
717
733
  ? `${session_id}${segment_id ? `#${segment_id}` : ""}`
718
734
  : undefined;
719
735
  try {
720
- const entries = await (0, distiller_js_1.distillSession)(llm, conversationText, project ?? "unknown", date, chunkSize);
721
- const ids = [];
736
+ // Collect gate-dropped entries so they can ride back in the response and
737
+ // land in the caller's file-persisted nightly log (#156 audit trail); the
738
+ // server-side per-entry console.log in distillChunk stays as well.
739
+ const dropped = [];
740
+ const entries = await (0, distiller_js_1.distillSession)(llm, conversationText, project ?? "unknown", date, chunkSize, dropped);
741
+ // Phase 1 — embed every chunk up front (async). If ANY embed fails we
742
+ // never reach the insert, so nothing is stored.
743
+ const createdAt = new Date(date).toISOString();
744
+ const toStore = [];
722
745
  for (let i = 0; i < entries.length; i++) {
723
746
  const entry = entries[i];
724
747
  if (typeof entry !== "string" || !entry.trim())
725
748
  continue;
726
- const embedding = await (0, embedder_js_1.embed)(entry);
727
- const id = storage.insertMemory(db, entry, embedding, {
728
- sourceAgent: source_agent ?? "unknown",
729
- // Per-chunk key: "<session_id>#<i>". The prefix matches the nightly
730
- // dedup check above, so a re-run of the same session is fully idempotent.
731
- sourceSession: sourcePrefix ? `${sourcePrefix}#${i}` : undefined,
732
- project: project ?? undefined,
733
- memoryType: "episode",
734
- privacy: privacy ?? "WORK",
735
- createdAt: new Date(date).toISOString(),
736
- });
737
- ids.push(id);
749
+ toStore.push({ entry, embedding: await (0, embedder_js_1.embed)(entry), i });
738
750
  }
739
- res.status(201).json({ ids, distilled: ids.length });
751
+ // Phase 2 — insert all chunks in ONE transaction (fix 4). A segment's
752
+ // chunks are all-or-nothing: any insert failure rolls back the whole set
753
+ // and returns 500, so the content-blind segment-exact dedup never sees a
754
+ // half-stored segment and the retry re-distills cleanly. (Applies to the
755
+ // legacy whole-session path too — same loop.)
756
+ const insertAll = db.transaction(() => {
757
+ const out = [];
758
+ for (const { entry, embedding, i } of toStore) {
759
+ out.push(storage.insertMemory(db, entry, embedding, {
760
+ sourceAgent: source_agent ?? "unknown",
761
+ // Per-chunk key: "<session_id>[#<segment_id>]#<i>". The prefix
762
+ // matches the dedup checks above, so a re-run is idempotent.
763
+ sourceSession: sourcePrefix ? `${sourcePrefix}#${i}` : undefined,
764
+ project: project ?? undefined,
765
+ memoryType: "episode",
766
+ privacy: privacy ?? "WORK",
767
+ createdAt,
768
+ }));
769
+ }
770
+ return out;
771
+ });
772
+ const ids = insertAll();
773
+ res.status(201).json({
774
+ ids,
775
+ distilled: ids.length,
776
+ dropped: dropped.map((d) => (d.length > 120 ? `${d.slice(0, 120)}…` : d)),
777
+ });
740
778
  }
741
779
  catch (err) {
742
780
  res.status(500).json({ error: "Distillation failed", message: err instanceof Error ? err.message : String(err) });
@@ -18,6 +18,7 @@ const node_os_1 = require("node:os");
18
18
  const node_child_process_1 = require("node:child_process");
19
19
  const db_js_1 = require("./db.js");
20
20
  const state_js_1 = require("./state.js");
21
+ const llm_js_1 = require("./llm.js");
21
22
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
22
23
  const CONFIG_PATH = (0, node_path_1.join)(HICORTEX_HOME, "config.json");
23
24
  async function showNightlyStatus() {
@@ -36,7 +37,9 @@ async function showNightlyStatus() {
36
37
  }
37
38
  // LLM config
38
39
  try {
39
- const config = JSON.parse((0, node_fs_1.readFileSync)(CONFIG_PATH, "utf-8"));
40
+ // No `?? {}` coercion: a null/invalid parse must fall through to the catch
41
+ // below (as it did pre-0.13.1) rather than print a fabricated-healthy status.
42
+ const config = (0, llm_js_1.applyModelsBlock)(JSON.parse((0, node_fs_1.readFileSync)(CONFIG_PATH, "utf-8")));
40
43
  const backend = config.llmBackend ?? "auto-detect";
41
44
  const model = config.llmModel ?? "default";
42
45
  const mode = config.mode === "client" ? "client → " + (config.serverUrl ?? "?") : "server (local)";
package/dist/nightly.d.ts CHANGED
@@ -15,4 +15,6 @@ export declare function runNightly(options?: {
15
15
  captureOnly?: boolean;
16
16
  dbPath?: string;
17
17
  stateDir?: string;
18
+ /** #189 Tier-2 recovery: override discovery to now−N days for one run. */
19
+ recaptureWindowDays?: number;
18
20
  }): Promise<void>;