@gamaze/hicortex 0.4.5 → 0.4.6

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/dist/distiller.js CHANGED
@@ -205,15 +205,24 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
205
205
  }
206
206
  // Use provided chunk size or default to no chunking
207
207
  const chunkSize = chunkSizeChars ?? MAX_TRANSCRIPT_CHARS;
208
- // If transcript fits in one chunk, distill directly
208
+ // If transcript fits in one chunk, distill directly (errors propagate)
209
209
  if (transcript.length <= chunkSize) {
210
210
  return distillChunk(llm, transcript, projectName, date);
211
211
  }
212
- // Chunk large transcripts and distill each segment
212
+ // Chunk large transcripts and distill each segment.
213
+ //
214
+ // Partial success policy:
215
+ // - If SOME chunks succeed and SOME fail, return the partial results and
216
+ // log a warning. The caller gets *something* and can decide whether
217
+ // to count this as success.
218
+ // - If ALL chunks fail, throw — no useful output, and the caller needs
219
+ // to know this session hit a transient error.
213
220
  const chunks = splitIntoChunks(transcript, chunkSize);
214
221
  console.log(`[hicortex] Chunking ${transcript.length} chars into ${chunks.length} segments`);
215
222
  const allEntries = [];
216
223
  const seen = new Set();
224
+ let chunkFailures = 0;
225
+ let lastError = null;
217
226
  for (let i = 0; i < chunks.length; i++) {
218
227
  console.log(`[hicortex] Chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
219
228
  try {
@@ -230,30 +239,45 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
230
239
  catch (err) {
231
240
  const msg = err instanceof Error ? err.message : String(err);
232
241
  console.error(`[hicortex] Chunk ${i + 1} failed: ${msg}`);
233
- // Continue with remaining chunks — partial extraction is better than none
242
+ chunkFailures++;
243
+ lastError = err instanceof Error ? err : new Error(msg);
234
244
  }
235
245
  }
246
+ // If every chunk failed, the session wasn't actually processed. Throw so
247
+ // the nightly pipeline knows to retry this session next run.
248
+ if (chunkFailures === chunks.length) {
249
+ throw lastError ?? new Error("All distillation chunks failed");
250
+ }
251
+ if (chunkFailures > 0) {
252
+ console.warn(`[hicortex] Partial distillation: ${chunks.length - chunkFailures}/${chunks.length} chunks succeeded`);
253
+ }
236
254
  return allEntries;
237
255
  }
238
256
  /**
239
257
  * Distill a single chunk of conversation text.
258
+ *
259
+ * Behaviour contract:
260
+ * - Returns `[]` for legitimate empty results (NO_EXTRACT, empty LLM response,
261
+ * transcript produced no entries). These are terminal states — the chunk was
262
+ * processed successfully, there's just nothing worth keeping.
263
+ * - Throws for transient errors (LLM unreachable, HTTP 4xx/5xx, timeout, model
264
+ * not found, rate limit). These MUST propagate so the nightly pipeline can
265
+ * distinguish "nothing to extract" from "try again later" and avoid
266
+ * advancing the last-run watermark past sessions it never actually processed.
240
267
  */
241
268
  async function distillChunk(llm, transcript, projectName, date) {
242
269
  const prompt = (0, prompts_js_1.distillation)(projectName, date, transcript);
243
- try {
244
- const result = await llm.completeDistill(prompt);
245
- if (!result)
246
- return [];
247
- if (result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT")) {
248
- return [];
249
- }
250
- return parseDistilledEntries(result);
251
- }
252
- catch (err) {
253
- const msg = err instanceof Error ? err.message : String(err);
254
- console.error(`[hicortex] Distillation LLM error: ${msg}`);
270
+ // NOTE: Intentionally no try/catch here. Transient LLM errors (network
271
+ // failures, 4xx/5xx, model-not-found, timeouts) propagate up to the caller
272
+ // so the nightly pipeline can treat them as "retry later" instead of
273
+ // "processed successfully with zero extractions".
274
+ const result = await llm.completeDistill(prompt);
275
+ if (!result)
276
+ return [];
277
+ if (result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT")) {
255
278
  return [];
256
279
  }
280
+ return parseDistilledEntries(result);
257
281
  }
258
282
  /**
259
283
  * Split transcript text into chunks at natural boundaries (double newlines).
package/dist/llm.d.ts CHANGED
@@ -69,6 +69,25 @@ export declare function claudeCliConfig(claudePath: string): LlmConfig;
69
69
  * Returns the model name if available, null otherwise.
70
70
  */
71
71
  export declare function probeOllama(baseUrl?: string): Promise<string | null>;
72
+ /**
73
+ * Pre-flight health check for a specific Ollama endpoint + model.
74
+ * Returns { ok, reason } so callers can log a clear abort message.
75
+ *
76
+ * - `ok: true` — endpoint reachable AND the requested model appears in
77
+ * `/api/tags`. Safe to proceed with a batch distillation run.
78
+ * - `ok: false, reason: "unreachable"` — network failure or non-2xx.
79
+ * - `ok: false, reason: "model_missing"` — endpoint is up but the
80
+ * model isn't listed (the exact case that caused data loss when
81
+ * mhac-pro's Ollama didn't have the distill model loaded).
82
+ *
83
+ * Matches on exact name OR name prefix ("qwen3.5:35b" matches "qwen3.5:35b-a3b").
84
+ */
85
+ export declare function probeOllamaModel(baseUrl: string, modelName: string): Promise<{
86
+ ok: true;
87
+ } | {
88
+ ok: false;
89
+ reason: "unreachable" | "model_missing";
90
+ }>;
72
91
  /**
73
92
  * For batch operations (nightly pipeline), prefer Ollama when available.
74
93
  * Claude CLI has strict rate limits that kill batch distillation.
package/dist/llm.js CHANGED
@@ -26,6 +26,7 @@ exports.resolveLlmConfigForCC = resolveLlmConfigForCC;
26
26
  exports.findClaudeBinary = findClaudeBinary;
27
27
  exports.claudeCliConfig = claudeCliConfig;
28
28
  exports.probeOllama = probeOllama;
29
+ exports.probeOllamaModel = probeOllamaModel;
29
30
  exports.preferOllamaForBatch = preferOllamaForBatch;
30
31
  const node_fs_1 = require("node:fs");
31
32
  const node_path_1 = require("node:path");
@@ -367,6 +368,35 @@ async function probeOllama(baseUrl = "http://localhost:11434") {
367
368
  return null;
368
369
  }
369
370
  }
371
+ /**
372
+ * Pre-flight health check for a specific Ollama endpoint + model.
373
+ * Returns { ok, reason } so callers can log a clear abort message.
374
+ *
375
+ * - `ok: true` — endpoint reachable AND the requested model appears in
376
+ * `/api/tags`. Safe to proceed with a batch distillation run.
377
+ * - `ok: false, reason: "unreachable"` — network failure or non-2xx.
378
+ * - `ok: false, reason: "model_missing"` — endpoint is up but the
379
+ * model isn't listed (the exact case that caused data loss when
380
+ * mhac-pro's Ollama didn't have the distill model loaded).
381
+ *
382
+ * Matches on exact name OR name prefix ("qwen3.5:35b" matches "qwen3.5:35b-a3b").
383
+ */
384
+ async function probeOllamaModel(baseUrl, modelName) {
385
+ try {
386
+ const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/api/tags`, {
387
+ signal: AbortSignal.timeout(5000),
388
+ });
389
+ if (!resp.ok)
390
+ return { ok: false, reason: "unreachable" };
391
+ const data = (await resp.json());
392
+ const models = data.models ?? [];
393
+ const found = models.some((m) => m.name === modelName || m.name.startsWith(modelName + ":"));
394
+ return found ? { ok: true } : { ok: false, reason: "model_missing" };
395
+ }
396
+ catch {
397
+ return { ok: false, reason: "unreachable" };
398
+ }
399
+ }
370
400
  /**
371
401
  * For batch operations (nightly pipeline), prefer Ollama when available.
372
402
  * Claude CLI has strict rate limits that kill batch distillation.
package/dist/nightly.js CHANGED
@@ -182,6 +182,24 @@ async function runNightly(options = {}) {
182
182
  }
183
183
  // Step 2: Distill each session
184
184
  let memoriesIngested = 0;
185
+ let hadTransientFailure = false;
186
+ // Pre-flight health check for a remote distill endpoint.
187
+ // If the distill provider is Ollama on a remote host and that host (or the
188
+ // required model) is unreachable, abort BEFORE touching any sessions —
189
+ // prevents the data-loss bug where lastRun advances past sessions that
190
+ // were never actually processed.
191
+ if (batches.length > 0 && llmConfig.distillBaseUrl && (llmConfig.distillProvider ?? llmConfig.provider) === "ollama") {
192
+ const distillModel = llmConfig.distillModel ?? llmConfig.model;
193
+ const health = await (0, llm_js_1.probeOllamaModel)(llmConfig.distillBaseUrl, distillModel);
194
+ if (!health.ok) {
195
+ const reason = health.reason === "unreachable"
196
+ ? `distill endpoint unreachable (${llmConfig.distillBaseUrl})`
197
+ : `distill model not loaded (${distillModel} missing on ${llmConfig.distillBaseUrl})`;
198
+ console.error(`[hicortex] ABORT: ${reason} — will retry next run, lastRun unchanged`);
199
+ hadTransientFailure = true;
200
+ batches.length = 0; // Skip the distillation loop entirely
201
+ }
202
+ }
185
203
  // Detect safe chunk size based on model context window
186
204
  const chunkSize = await (0, distiller_js_1.detectChunkSize)(llmConfig.provider, llmConfig.distillModel ?? llmConfig.model, llmConfig.baseUrl);
187
205
  for (const batch of batches) {
@@ -190,6 +208,20 @@ async function runNightly(options = {}) {
190
208
  console.log(`[hicortex] Skip ${batch.sessionId.slice(0, 8)} (${batch.projectName}): too short`);
191
209
  continue;
192
210
  }
211
+ // Server-mode per-session dedup: skip sessions already in the DB.
212
+ // Client mode gets this for free via the server's /ingest endpoint;
213
+ // server mode writes directly via storage.insertMemory and needs
214
+ // an explicit check. This makes retries of previously-failed runs
215
+ // idempotent.
216
+ if (!dryRun) {
217
+ const existing = db
218
+ .prepare("SELECT COUNT(*) as c FROM memories WHERE source_session = ?")
219
+ .get(batch.sessionId);
220
+ if (existing.c > 0) {
221
+ console.log(`[hicortex] Skip ${batch.sessionId.slice(0, 8)} (${batch.projectName}): already ingested`);
222
+ continue;
223
+ }
224
+ }
193
225
  console.log(`[hicortex] Distilling ${batch.sessionId.slice(0, 8)} (${batch.projectName}, ${batch.date})`);
194
226
  if (dryRun) {
195
227
  console.log(`[hicortex] [dry-run] Would distill ${transcript.length} chars`);
@@ -224,7 +256,8 @@ async function runNightly(options = {}) {
224
256
  }
225
257
  catch (err) {
226
258
  const msg = err instanceof Error ? err.message : String(err);
227
- console.error(`[hicortex] Distillation failed: ${msg}`);
259
+ console.error(`[hicortex] Distillation failed: ${msg} — will retry next run`);
260
+ hadTransientFailure = true;
228
261
  }
229
262
  }
230
263
  console.log(`[hicortex] Distillation complete: ${memoriesIngested} new memories`);
@@ -241,8 +274,17 @@ async function runNightly(options = {}) {
241
274
  console.log(`[hicortex] CLAUDE.md updated: ${injection.lessonsCount} lessons at ${injection.path}`);
242
275
  }
243
276
  // Step 5: Update last-run timestamp
277
+ // CRITICAL: only advance lastRun if every session was processed without
278
+ // a transient failure. Otherwise failed sessions would be permanently
279
+ // lost — they'd be older than the new lastRun and never retried.
244
280
  if (!dryRun) {
245
- writeLastRun();
281
+ if (hadTransientFailure) {
282
+ console.warn(`[hicortex] Not advancing lastRun — one or more sessions failed. ` +
283
+ `They will be retried on the next run.`);
284
+ }
285
+ else {
286
+ writeLastRun();
287
+ }
246
288
  }
247
289
  console.log(`[hicortex] Nightly pipeline complete.`);
248
290
  }
@@ -329,6 +371,22 @@ async function runClientNightly(config, dryRun) {
329
371
  writeLastRun();
330
372
  return;
331
373
  }
374
+ // Pre-flight health check for a remote distill endpoint (client mode).
375
+ // If the distill provider is Ollama on a remote host and the required model
376
+ // isn't loaded, abort BEFORE touching any sessions — same data-loss fix
377
+ // as server mode.
378
+ let hadTransientFailure = false;
379
+ if (llmConfig.distillBaseUrl && (llmConfig.distillProvider ?? llmConfig.provider) === "ollama") {
380
+ const distillModel = llmConfig.distillModel ?? llmConfig.model;
381
+ const health = await (0, llm_js_1.probeOllamaModel)(llmConfig.distillBaseUrl, distillModel);
382
+ if (!health.ok) {
383
+ const reason = health.reason === "unreachable"
384
+ ? `distill endpoint unreachable (${llmConfig.distillBaseUrl})`
385
+ : `distill model not loaded (${distillModel} missing on ${llmConfig.distillBaseUrl})`;
386
+ console.error(`[hicortex] ABORT: ${reason} — will retry next run, lastRun unchanged`);
387
+ return; // Don't touch lastRun; next trigger retries the same sessions
388
+ }
389
+ }
332
390
  // Distill each session and POST to server
333
391
  let memoriesIngested = 0;
334
392
  let sessionsSent = 0;
@@ -388,6 +446,7 @@ async function runClientNightly(config, dryRun) {
388
446
  }
389
447
  else {
390
448
  console.error(`[hicortex] Ingest failed (${resp.status}): ${result.error}`);
449
+ hadTransientFailure = true;
391
450
  }
392
451
  }
393
452
  if (sessionCount > 0) {
@@ -396,7 +455,8 @@ async function runClientNightly(config, dryRun) {
396
455
  }
397
456
  }
398
457
  catch (err) {
399
- console.error(`[hicortex] Failed: ${err instanceof Error ? err.message : String(err)}`);
458
+ console.error(`[hicortex] Distillation failed: ${err instanceof Error ? err.message : String(err)} — will retry next run`);
459
+ hadTransientFailure = true;
400
460
  }
401
461
  }
402
462
  // Inject lessons from server into CLAUDE.md
@@ -408,8 +468,17 @@ async function runClientNightly(config, dryRun) {
408
468
  console.error(`[hicortex] CLAUDE.md injection failed: ${err instanceof Error ? err.message : String(err)}`);
409
469
  }
410
470
  }
411
- if (!dryRun)
412
- writeLastRun();
471
+ // Only advance lastRun if every session was processed without a transient
472
+ // failure. Otherwise failed sessions would be permanently lost.
473
+ if (!dryRun) {
474
+ if (hadTransientFailure) {
475
+ console.warn(`[hicortex] Not advancing lastRun — one or more sessions failed. ` +
476
+ `They will be retried on the next run.`);
477
+ }
478
+ else {
479
+ writeLastRun();
480
+ }
481
+ }
413
482
  console.log(`[hicortex] Client nightly complete: ${memoriesIngested} memories from ${sessionsSent} sessions → ${serverUrl}`);
414
483
  }
415
484
  /**
@@ -2,7 +2,7 @@
2
2
  "id": "hicortex",
3
3
  "name": "Hicortex — Long-term Memory That Learns",
4
4
  "description": "Your agents remember past decisions, avoid repeated mistakes, and get smarter every day. Nightly reflection generates actionable lessons that automatically update agent behavior.",
5
- "version": "0.4.5",
5
+ "version": "0.4.6",
6
6
  "kind": "lifecycle",
7
7
  "skills": ["./skills/hicortex-memory", "./skills/hicortex-learn", "./skills/hicortex-activate"],
8
8
  "configSchema": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Human-like memory for self-improving AI agents. Automatic capturing, nightly reflection, and cross-agent learning. Works with Claude Code and OpenClaw.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {