@gamaze/hicortex 0.13.1 → 0.13.3

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.
@@ -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 });
@@ -722,24 +738,38 @@ async function startServer(options = {}) {
722
738
  // server-side per-entry console.log in distillChunk stays as well.
723
739
  const dropped = [];
724
740
  const entries = await (0, distiller_js_1.distillSession)(llm, conversationText, project ?? "unknown", date, chunkSize, dropped);
725
- const ids = [];
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 = [];
726
745
  for (let i = 0; i < entries.length; i++) {
727
746
  const entry = entries[i];
728
747
  if (typeof entry !== "string" || !entry.trim())
729
748
  continue;
730
- const embedding = await (0, embedder_js_1.embed)(entry);
731
- const id = storage.insertMemory(db, entry, embedding, {
732
- sourceAgent: source_agent ?? "unknown",
733
- // Per-chunk key: "<session_id>#<i>". The prefix matches the nightly
734
- // dedup check above, so a re-run of the same session is fully idempotent.
735
- sourceSession: sourcePrefix ? `${sourcePrefix}#${i}` : undefined,
736
- project: project ?? undefined,
737
- memoryType: "episode",
738
- privacy: privacy ?? "WORK",
739
- createdAt: new Date(date).toISOString(),
740
- });
741
- ids.push(id);
749
+ toStore.push({ entry, embedding: await (0, embedder_js_1.embed)(entry), i });
742
750
  }
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();
743
773
  res.status(201).json({
744
774
  ids,
745
775
  distilled: ids.length,
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>;
package/dist/nightly.js CHANGED
@@ -58,7 +58,6 @@ const db_js_1 = require("./db.js");
58
58
  const llm_js_1 = require("./llm.js");
59
59
  const embedder_js_1 = require("./embedder.js");
60
60
  const storage = __importStar(require("./storage.js"));
61
- const distiller_js_1 = require("./distiller.js");
62
61
  const consolidate_js_1 = require("./consolidate.js");
63
62
  const domain_classify_js_1 = require("./domain-classify.js");
64
63
  const nofit_js_1 = require("./nofit.js");
@@ -68,6 +67,8 @@ const pi_transcript_reader_js_1 = require("./pi-transcript-reader.js");
68
67
  const oc_transcript_reader_js_1 = require("./oc-transcript-reader.js");
69
68
  const features_js_1 = require("./features.js");
70
69
  const state_js_1 = require("./state.js");
70
+ const capture_cursors_js_1 = require("./capture-cursors.js");
71
+ const capture_js_1 = require("./capture.js");
71
72
  const telemetry_js_1 = require("./telemetry.js");
72
73
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
73
74
  function readNightlyConfig(stateDir) {
@@ -97,12 +98,81 @@ function readLastRun(stateDir = HICORTEX_HOME) {
97
98
  const d = new Date(ts);
98
99
  return isNaN(d.getTime()) ? new Date(0) : d;
99
100
  }
101
+ /**
102
+ * Discovery watermark. Normally the last-nightly timestamp; with
103
+ * `--recapture-window <days>` (#189 Tier-2 recovery) the window may only
104
+ * WIDEN — since = min(lastNightly, now−N days). Taking the earlier of the two
105
+ * means a machine that was offline longer than N days still re-discovers every
106
+ * session it missed; using now−N unconditionally would NARROW the window and
107
+ * skip (then, via writeLastRun, permanently lose) the 8-to-N-day-old sessions
108
+ * (#189 review, fix 3). Per-session cursors keep the wide re-scan cheap: an
109
+ * already-captured session yields an empty delta.
110
+ */
111
+ function computeSince(stateDir, recaptureWindowDays) {
112
+ const lastRun = readLastRun(stateDir);
113
+ if (recaptureWindowDays && recaptureWindowDays > 0) {
114
+ const windowStart = new Date(Date.now() - recaptureWindowDays * 24 * 60 * 60 * 1000);
115
+ return windowStart < lastRun ? windowStart : lastRun;
116
+ }
117
+ return lastRun;
118
+ }
119
+ /** POST /distill transport for server mode — localhost, no auth (localhost bypasses). */
120
+ function makeLocalPost(port) {
121
+ return async (body) => {
122
+ const resp = await fetch(`http://127.0.0.1:${port}/distill`, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify(body),
126
+ // Synchronous 35B distillation of a large segment can take minutes.
127
+ signal: AbortSignal.timeout(20 * 60 * 1000),
128
+ });
129
+ return normalizePostResult(resp);
130
+ };
131
+ }
132
+ /** POST /distill transport for client mode — remote URL + optional bearer token. */
133
+ function makeRemotePost(serverUrl, authToken) {
134
+ return async (body) => {
135
+ const resp = await fetch(`${serverUrl}/distill`, {
136
+ method: "POST",
137
+ headers: {
138
+ "Content-Type": "application/json",
139
+ ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
140
+ },
141
+ body: JSON.stringify(body),
142
+ signal: AbortSignal.timeout(20 * 60 * 1000),
143
+ });
144
+ return normalizePostResult(resp);
145
+ };
146
+ }
147
+ async function normalizePostResult(resp) {
148
+ if (resp.status === 201) {
149
+ const data = (await resp.json().catch(() => ({})));
150
+ return { status: 201, distilled: data.distilled ?? 0, dropped: data.dropped ?? [] };
151
+ }
152
+ if (resp.status === 200) {
153
+ const data = (await resp.json().catch(() => ({})));
154
+ return { status: 200, skipped: Boolean(data.skipped) };
155
+ }
156
+ const data = (await resp.json().catch(() => ({})));
157
+ return { status: resp.status, error: data.error ?? "unknown error" };
158
+ }
100
159
  function writeLastRun(stateDir = HICORTEX_HOME) {
101
160
  (0, state_js_1.updateState)((s) => {
102
161
  s.lastNightly = new Date().toISOString();
103
162
  return s;
104
163
  }, stateDir);
105
164
  }
165
+ /**
166
+ * How long a full nightly waits for the capture lock before giving up and
167
+ * running consolidation without capturing (fix 10). Comfortably longer than a
168
+ * typical daytime --capture-only run; short enough not to stall the box.
169
+ * Overridable via HICORTEX_CAPTURE_LOCK_WAIT_MS (tests only).
170
+ */
171
+ const CAPTURE_LOCK_WAIT_MS = 30 * 60 * 1000;
172
+ function captureLockWaitMs() {
173
+ const env = Number(process.env.HICORTEX_CAPTURE_LOCK_WAIT_MS);
174
+ return Number.isFinite(env) && env >= 0 ? env : CAPTURE_LOCK_WAIT_MS;
175
+ }
106
176
  const NIGHTLY_LOG_MAX_BYTES = 1024 * 1024; // 1 MB — years of normal runs
107
177
  /**
108
178
  * Keep ~/.hicortex/nightly.log bounded. The launchd plist and systemd unit
@@ -125,6 +195,7 @@ async function runNightly(options = {}) {
125
195
  const dryRun = options.dryRun ?? false;
126
196
  const captureOnly = options.captureOnly ?? false;
127
197
  const stateDir = options.stateDir ?? HICORTEX_HOME;
198
+ const recaptureWindowDays = options.recaptureWindowDays;
128
199
  rotateNightlyLog(stateDir);
129
200
  // One-time migration of legacy state files (no-op if state.json exists)
130
201
  (0, state_js_1.migrateLegacyState)(stateDir);
@@ -133,7 +204,7 @@ async function runNightly(options = {}) {
133
204
  if (savedConfig?.mode === "client") {
134
205
  // --capture-only is accepted in client mode but irrelevant: client nightly
135
206
  // is already capture-only (no consolidation step).
136
- await runClientNightly(savedConfig, dryRun);
207
+ await runClientNightly(savedConfig, dryRun, stateDir, recaptureWindowDays);
137
208
  return;
138
209
  }
139
210
  const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
@@ -160,97 +231,99 @@ async function runNightly(options = {}) {
160
231
  }
161
232
  const llmConfig = resolved.config;
162
233
  const llm = llmConfig ? new llm_js_1.LlmClient(llmConfig) : null;
163
- // Step 1: Read new transcripts (CC + Hermes + Pi + OpenClaw)
164
- const since = readLastRun();
165
- console.log(`[hicortex] Reading transcripts since ${since.toISOString()}`);
166
- const ccBatches = (0, transcript_reader_js_1.readCcTranscripts)(since);
167
- const hermesBatches = (0, hermes_transcript_reader_js_1.readHermesSessions)(since);
168
- // Pi is a supported harness in the product (readPiTranscripts no-ops when
169
- // ~/.pi/agent/sessions is absent). Retired only on specific deployments by
170
- // simply having no Pi session files — not removed from the pipeline.
171
- const piBatches = (0, pi_transcript_reader_js_1.readPiTranscripts)(since);
172
- // OpenClaw persists sessions in the Pi v3 format at ~/.openclaw/agents/;
173
- // no-ops when OC isn't installed.
174
- const ocBatches = (0, oc_transcript_reader_js_1.readOcTranscripts)(since);
175
- const batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches];
176
- if (ccBatches.length > 0)
177
- console.log(`[hicortex] Found ${ccBatches.length} CC session(s)`);
178
- if (hermesBatches.length > 0)
179
- console.log(`[hicortex] Found ${hermesBatches.length} Hermes session(s)`);
180
- if (piBatches.length > 0)
181
- console.log(`[hicortex] Found ${piBatches.length} Pi session(s)`);
182
- if (ocBatches.length > 0)
183
- console.log(`[hicortex] Found ${ocBatches.length} OpenClaw session(s)`);
184
- console.log(`[hicortex] Total: ${batches.length} new session(s)`);
185
- if (batches.length === 0 && !dryRun) {
186
- // Still run consolidation (unless capture-only) — there may be unscored memories from OC.
187
- console.log(captureOnly
188
- ? `[hicortex] No new transcripts. Nothing to capture.`
189
- : `[hicortex] No new transcripts. Running consolidation only.`);
190
- }
191
- // Step 2: Denoise and POST each session to the local daemon via /distill.
192
- // The dedup check and distillation quality (35B) are the server's concern.
234
+ // Steps 1+2: single-flight capture. The lock guards ONLY the capture phase
235
+ // (fix 10). We acquire it BEFORE reading the cursor store so a run that
236
+ // waited out another cannot act on a stale snapshot and clobber its cursor
237
+ // advances (fix 6). Batch-kind counts are hoisted for the telemetry at the
238
+ // end (they stay 0 if capture is skipped).
239
+ let ccBatches = [];
240
+ let hermesBatches = [];
241
+ let piBatches = [];
242
+ let ocBatches = [];
243
+ let batches = [];
193
244
  let memoriesIngested = 0;
194
245
  let hadTransientFailure = false;
195
- for (const batch of batches) {
196
- const transcript = (0, distiller_js_1.extractConversationText)(batch.entries);
197
- if (transcript.length < 200) {
198
- console.log(`[hicortex] Skip ${batch.sessionId.slice(0, 8)} (${batch.projectName}): too short`);
199
- continue;
200
- }
201
- console.log(`[hicortex] Capturing ${batch.sessionId.slice(0, 8)} (${batch.projectName}, ${batch.date})`);
202
- if (dryRun) {
203
- console.log(`[hicortex] [dry-run] Would POST ${transcript.length} chars to /distill`);
204
- continue;
246
+ // Full nightly waits out a transient --capture-only overlap (each segment
247
+ // POST can block up to 20 min); capture-only fails fast. dry-run writes
248
+ // nothing so it needs no lock.
249
+ const lockWaitMs = captureLockWaitMs();
250
+ const releaseLock = dryRun
251
+ ? (() => { })
252
+ : await (0, capture_js_1.acquireCaptureLock)(stateDir, captureOnly ? 0 : lockWaitMs);
253
+ if (!releaseLock) {
254
+ if (captureOnly) {
255
+ console.warn("[hicortex] Another capture run holds the lock — skipping this capture-only run.");
256
+ return;
205
257
  }
258
+ // Full nightly couldn't get the lock even after waiting: skip capture and
259
+ // hold the watermark, but STILL run consolidation + telemetry below so an
260
+ // overlapping capture-only run never silently starves consolidation (fix 10).
261
+ console.warn(`[hicortex] Capture lock still held after waiting ${Math.round(lockWaitMs / 60000)} min — ` +
262
+ `skipping capture this run (watermark held), consolidation still runs.`);
263
+ hadTransientFailure = true;
264
+ }
265
+ else {
206
266
  try {
207
- const resp = await fetch(`http://127.0.0.1:${port}/distill`, {
208
- method: "POST",
209
- headers: { "Content-Type": "application/json" },
210
- body: JSON.stringify({
211
- text: transcript,
212
- source_agent: batch.sourceAgent ?? `claude-code/${batch.projectName}`,
213
- project: batch.projectName,
214
- session_id: batch.sessionId,
215
- session_date: batch.date,
216
- privacy: "WORK",
217
- }),
218
- // Synchronous 35B distillation of a large session can take minutes.
219
- signal: AbortSignal.timeout(20 * 60 * 1000),
220
- });
221
- if (resp.status === 200) {
222
- const data = await resp.json();
223
- if (data.skipped) {
224
- console.log(`[hicortex] Skip ${batch.sessionId.slice(0, 8)} (${batch.projectName}): already ingested`);
225
- }
226
- }
227
- else if (resp.status === 201) {
228
- const data = await resp.json();
229
- memoriesIngested += data.distilled ?? 0;
230
- console.log(`[hicortex] → ${data.distilled ?? 0} memories extracted`);
231
- // Durable audit trail (#156): the server truncates each dropped entry.
232
- for (const d of data.dropped ?? []) {
233
- console.log(`[hicortex] Substance gate: dropped "${d}"`);
234
- }
235
- }
236
- else if (resp.status === 429) {
237
- const data = await resp.json();
238
- console.log(`[hicortex] Memory limit reached: ${data.error}. Stopping capture.`);
239
- break;
267
+ // Step 1: Read new transcripts (CC + Hermes + Pi + OpenClaw). Discovery
268
+ // is whole-session by mtime/ended_at; per-session cursors slice each
269
+ // discovered session down to its unseen delta (#189).
270
+ const since = computeSince(stateDir, recaptureWindowDays);
271
+ if (recaptureWindowDays) {
272
+ console.log(`[hicortex] --recapture-window ${recaptureWindowDays}d: reading transcripts since ${since.toISOString()}`);
240
273
  }
241
274
  else {
242
- const data = await resp.json().catch(() => ({}));
243
- console.error(`[hicortex] /distill returned ${resp.status}: ${data.error ?? "unknown error"} — will retry next run`);
244
- hadTransientFailure = true;
275
+ console.log(`[hicortex] Reading transcripts since ${since.toISOString()}`);
276
+ }
277
+ const cursorStore = (0, capture_cursors_js_1.openCursorStore)(stateDir);
278
+ const cursorMap = cursorStore.map();
279
+ ccBatches = (0, transcript_reader_js_1.readCcTranscripts)(since, undefined, cursorMap);
280
+ hermesBatches = (0, hermes_transcript_reader_js_1.readHermesSessions)(since, undefined, cursorMap);
281
+ // Pi is a supported harness (readPiTranscripts no-ops when
282
+ // ~/.pi/agent/sessions is absent). Retired only on specific deployments
283
+ // by simply having no Pi session files — not removed from the pipeline.
284
+ piBatches = (0, pi_transcript_reader_js_1.readPiTranscripts)(since, undefined, cursorMap);
285
+ // OpenClaw persists sessions in the Pi v3 format at ~/.openclaw/agents/;
286
+ // no-ops when OC isn't installed.
287
+ ocBatches = (0, oc_transcript_reader_js_1.readOcTranscripts)(since, undefined, cursorMap);
288
+ batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches];
289
+ if (ccBatches.length > 0)
290
+ console.log(`[hicortex] Found ${ccBatches.length} CC session(s)`);
291
+ if (hermesBatches.length > 0)
292
+ console.log(`[hicortex] Found ${hermesBatches.length} Hermes session(s)`);
293
+ if (piBatches.length > 0)
294
+ console.log(`[hicortex] Found ${piBatches.length} Pi session(s)`);
295
+ if (ocBatches.length > 0)
296
+ console.log(`[hicortex] Found ${ocBatches.length} OpenClaw session(s)`);
297
+ console.log(`[hicortex] Total: ${batches.length} new session(s)`);
298
+ if (batches.length === 0 && !dryRun) {
299
+ console.log(captureOnly
300
+ ? `[hicortex] No new transcripts. Nothing to capture.`
301
+ : `[hicortex] No new transcripts. Running consolidation only.`);
245
302
  }
303
+ // Step 2: pack each session's delta into ≤60K segments and POST to the
304
+ // local daemon via /distill; cursors advance on confirmed success.
305
+ const result = await (0, capture_js_1.captureBatches)(batches, {
306
+ post: makeLocalPost(port),
307
+ cursorStore,
308
+ dryRun,
309
+ });
310
+ memoriesIngested = result.memoriesIngested;
311
+ // A 429/401 stop must hold the watermark too (fix 1): the loop abandoned
312
+ // the remaining sessions, and mtime discovery would never re-find them.
313
+ hadTransientFailure = result.hadTransientFailure || result.stopped !== undefined;
314
+ }
315
+ finally {
316
+ releaseLock();
246
317
  }
247
- catch (err) {
248
- const msg = err instanceof Error ? err.message : String(err);
249
- console.error(`[hicortex] Capture failed: ${msg} will retry next run`);
250
- hadTransientFailure = true;
318
+ console.log(`[hicortex] Capture complete: ${memoriesIngested} new memories`);
319
+ // Prune aged-out cursors (90d) only on a clean run so a transient
320
+ // failure doesn't drop a still-needed cursor.
321
+ if (!dryRun && !hadTransientFailure) {
322
+ const pruned = (0, capture_cursors_js_1.pruneCursors)(stateDir);
323
+ if (pruned > 0)
324
+ console.log(`[hicortex] Pruned ${pruned} stale capture cursor(s)`);
251
325
  }
252
326
  }
253
- console.log(`[hicortex] Capture complete: ${memoriesIngested} new memories`);
254
327
  // Step 3: Consolidation — skipped in capture-only mode, dry-run, or no LLM.
255
328
  // Runs even if capture had transient failures (opens DB directly, independent
256
329
  // of the HTTP capture path). Full nightly only — capture-only runs are
@@ -327,7 +400,7 @@ async function runNightly(options = {}) {
327
400
  `They will be retried on the next run.`);
328
401
  }
329
402
  else {
330
- writeLastRun();
403
+ writeLastRun(stateDir);
331
404
  }
332
405
  }
333
406
  console.log(`[hicortex] Nightly pipeline complete.`);
@@ -360,7 +433,7 @@ async function runNightly(options = {}) {
360
433
  // ---------------------------------------------------------------------------
361
434
  // Client Mode Nightly — denoise locally, POST to remote server's /distill
362
435
  // ---------------------------------------------------------------------------
363
- async function runClientNightly(config, dryRun) {
436
+ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recaptureWindowDays) {
364
437
  const serverUrl = config.serverUrl.replace(/\/+$/, "");
365
438
  const authToken = config.authToken;
366
439
  console.log(`[hicortex] Client nightly starting${dryRun ? " (dry run)" : ""}`);
@@ -379,109 +452,84 @@ async function runClientNightly(config, dryRun) {
379
452
  return; // Don't update last-run so we retry
380
453
  }
381
454
  // No local LLM needed — distillation happens on the server.
382
- // Read new transcripts (CC + Hermes + Pi + OpenClaw). Client reads local
383
- // logs, denoises, and POSTs the denoised text to the server's /distill
384
- // endpoint. All readers no-op when their harness isn't installed.
385
- const since = readLastRun();
386
- console.log(`[hicortex] Reading transcripts since ${since.toISOString()}`);
387
- const ccBatches = (0, transcript_reader_js_1.readCcTranscripts)(since);
388
- const hermesBatches = (0, hermes_transcript_reader_js_1.readHermesSessions)(since);
389
- const piBatches = (0, pi_transcript_reader_js_1.readPiTranscripts)(since);
390
- const ocBatches = (0, oc_transcript_reader_js_1.readOcTranscripts)(since);
391
- const batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches];
392
- if (ccBatches.length > 0)
393
- console.log(`[hicortex] Found ${ccBatches.length} CC session(s)`);
394
- if (hermesBatches.length > 0)
395
- console.log(`[hicortex] Found ${hermesBatches.length} Hermes session(s)`);
396
- if (piBatches.length > 0)
397
- console.log(`[hicortex] Found ${piBatches.length} Pi session(s)`);
398
- if (ocBatches.length > 0)
399
- console.log(`[hicortex] Found ${ocBatches.length} OpenClaw session(s)`);
400
- console.log(`[hicortex] Total: ${batches.length} new session(s)`);
401
- if (batches.length === 0) {
402
- console.log(`[hicortex] Nothing to capture.`);
403
- if (!dryRun)
404
- writeLastRun();
455
+ // Single-flight (A5): acquire the capture lock BEFORE reading the cursor
456
+ // store so a run that waited out another can't act on a stale snapshot and
457
+ // clobber its advances (fix 6). The client is capture-only; on contention we
458
+ // skip and hold the watermark (no writeLastRun → retried next run). dry-run
459
+ // writes nothing so it needs no lock.
460
+ const releaseLock = dryRun ? (() => { }) : await (0, capture_js_1.acquireCaptureLock)(stateDir);
461
+ if (!releaseLock) {
462
+ console.warn("[hicortex] Another capture run holds the lock — skipping this run (watermark held).");
405
463
  return;
406
464
  }
407
- let hadTransientFailure = false;
465
+ let ccBatches = [];
466
+ let hermesBatches = [];
467
+ let piBatches = [];
468
+ let ocBatches = [];
469
+ let batches = [];
408
470
  let memoriesIngested = 0;
409
471
  let sessionsSent = 0;
410
- for (const batch of batches) {
411
- const transcript = (0, distiller_js_1.extractConversationText)(batch.entries);
412
- if (transcript.length < 200) {
413
- console.log(`[hicortex] Skip ${batch.sessionId.slice(0, 8)} (${batch.projectName}): too short`);
414
- continue;
472
+ let hadTransientFailure = false;
473
+ try {
474
+ // Read new transcripts (CC + Hermes + Pi + OpenClaw). Client reads local
475
+ // logs, denoises, and POSTs the denoised text to the server's /distill
476
+ // endpoint. All readers no-op when their harness isn't installed. Per-session
477
+ // cursors slice each discovered session to its unseen delta (#189).
478
+ const since = computeSince(stateDir, recaptureWindowDays);
479
+ if (recaptureWindowDays) {
480
+ console.log(`[hicortex] --recapture-window ${recaptureWindowDays}d: reading transcripts since ${since.toISOString()}`);
415
481
  }
416
- console.log(`[hicortex] Capturing ${batch.sessionId.slice(0, 8)} (${batch.projectName}, ${batch.date})`);
417
- if (dryRun) {
418
- console.log(`[hicortex] [dry-run] Would POST ${transcript.length} chars to ${serverUrl}/distill`);
419
- continue;
482
+ else {
483
+ console.log(`[hicortex] Reading transcripts since ${since.toISOString()}`);
420
484
  }
421
- try {
422
- const resp = await fetch(`${serverUrl}/distill`, {
423
- method: "POST",
424
- headers: {
425
- "Content-Type": "application/json",
426
- ...(authToken ? { "Authorization": `Bearer ${authToken}` } : {}),
427
- },
428
- body: JSON.stringify({
429
- text: transcript,
430
- source_agent: batch.sourceAgent ?? `claude-code/${batch.projectName}`,
431
- project: batch.projectName,
432
- session_id: batch.sessionId,
433
- session_date: batch.date,
434
- privacy: "WORK",
435
- }),
436
- // Synchronous 35B distillation of a large session can take minutes.
437
- signal: AbortSignal.timeout(20 * 60 * 1000),
438
- });
439
- if (resp.status === 200) {
440
- const data = await resp.json();
441
- if (data.skipped) {
442
- console.log(`[hicortex] Skip ${batch.sessionId.slice(0, 8)}: already ingested on server`);
443
- }
444
- }
445
- else if (resp.status === 201) {
446
- const data = await resp.json();
447
- const count = data.distilled ?? 0;
448
- memoriesIngested += count;
449
- sessionsSent++;
450
- console.log(`[hicortex] → ${count} memories sent to server`);
451
- // Durable audit trail (#156): the server truncates each dropped entry.
452
- for (const d of data.dropped ?? []) {
453
- console.log(`[hicortex] Substance gate: dropped "${d}"`);
454
- }
455
- }
456
- else if (resp.status === 401) {
457
- console.error(`[hicortex] Auth failed. Check authToken in ~/.hicortex/config.json`);
458
- return; // No point retrying with wrong credentials
459
- }
460
- else if (resp.status === 429) {
461
- const data = await resp.json().catch(() => ({}));
462
- console.log(`[hicortex] Server memory limit reached: ${data.error}`);
463
- return;
464
- }
465
- else {
466
- const data = await resp.json().catch(() => ({}));
467
- console.error(`[hicortex] /distill returned ${resp.status}: ${data.error ?? "unknown error"} — will retry next run`);
468
- hadTransientFailure = true;
469
- }
485
+ const cursorStore = (0, capture_cursors_js_1.openCursorStore)(stateDir);
486
+ const cursorMap = cursorStore.map();
487
+ ccBatches = (0, transcript_reader_js_1.readCcTranscripts)(since, undefined, cursorMap);
488
+ hermesBatches = (0, hermes_transcript_reader_js_1.readHermesSessions)(since, undefined, cursorMap);
489
+ piBatches = (0, pi_transcript_reader_js_1.readPiTranscripts)(since, undefined, cursorMap);
490
+ ocBatches = (0, oc_transcript_reader_js_1.readOcTranscripts)(since, undefined, cursorMap);
491
+ batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches];
492
+ if (ccBatches.length > 0)
493
+ console.log(`[hicortex] Found ${ccBatches.length} CC session(s)`);
494
+ if (hermesBatches.length > 0)
495
+ console.log(`[hicortex] Found ${hermesBatches.length} Hermes session(s)`);
496
+ if (piBatches.length > 0)
497
+ console.log(`[hicortex] Found ${piBatches.length} Pi session(s)`);
498
+ if (ocBatches.length > 0)
499
+ console.log(`[hicortex] Found ${ocBatches.length} OpenClaw session(s)`);
500
+ console.log(`[hicortex] Total: ${batches.length} new session(s)`);
501
+ if (batches.length === 0) {
502
+ console.log(`[hicortex] Nothing to capture.`);
470
503
  }
471
- catch (err) {
472
- console.error(`[hicortex] Capture failed: ${err instanceof Error ? err.message : String(err)} — will retry next run`);
473
- hadTransientFailure = true;
504
+ else {
505
+ const result = await (0, capture_js_1.captureBatches)(batches, {
506
+ post: makeRemotePost(serverUrl, authToken),
507
+ cursorStore,
508
+ dryRun,
509
+ });
510
+ memoriesIngested = result.memoriesIngested;
511
+ sessionsSent = result.sessionsSent;
512
+ // A 401 (bad credentials) or 429 (server cap) stop must hold the watermark
513
+ // too (fix 1): the loop abandoned the remaining sessions.
514
+ hadTransientFailure = result.hadTransientFailure || result.stopped !== undefined;
474
515
  }
475
516
  }
476
- // Only advance lastRun if every session was processed without a transient
477
- // failure. Otherwise failed sessions would be permanently lost.
517
+ finally {
518
+ releaseLock();
519
+ }
520
+ // Advance lastRun only on a fully clean run (no transient failure and no
521
+ // terminal stop). An empty scan is clean → advance. Cursors already recorded
522
+ // whatever succeeded regardless.
478
523
  if (!dryRun) {
479
524
  if (hadTransientFailure) {
480
- console.warn(`[hicortex] Not advancing lastRun — one or more sessions failed. ` +
481
- `They will be retried on the next run.`);
525
+ console.warn(`[hicortex] Not advancing lastRun — capture failed or was stopped. ` +
526
+ `Will retry on the next run.`);
482
527
  }
483
528
  else {
484
- writeLastRun();
529
+ writeLastRun(stateDir);
530
+ const pruned = (0, capture_cursors_js_1.pruneCursors)(stateDir);
531
+ if (pruned > 0)
532
+ console.log(`[hicortex] Pruned ${pruned} stale capture cursor(s)`);
485
533
  }
486
534
  }
487
535
  console.log(`[hicortex] Client nightly complete: ${memoriesIngested} memories from ${sessionsSent} sessions → ${serverUrl}`);
@@ -495,7 +543,7 @@ async function runClientNightly(config, dryRun) {
495
543
  ].filter(Boolean);
496
544
  const agentType = kinds.length > 1 ? "mixed" : (kinds[0] ?? "cc");
497
545
  await (0, telemetry_js_1.sendTelemetry)({
498
- id: (0, telemetry_js_1.getTelemetryId)(HICORTEX_HOME),
546
+ id: (0, telemetry_js_1.getTelemetryId)(stateDir),
499
547
  v: VERSION,
500
548
  mode: "client",
501
549
  agent: agentType,
@@ -10,11 +10,12 @@
10
10
  * Known limitation: rotated files (`*.jsonl.reset.<ts>`) are not read — only
11
11
  * live `*.jsonl` files. Server-side session dedup keeps re-reads idempotent.
12
12
  */
13
- import { type TranscriptBatch } from "./pi-transcript-reader.js";
13
+ import { type TranscriptBatch, type CursorMap } from "./pi-transcript-reader.js";
14
14
  /**
15
15
  * Read OpenClaw session transcripts modified after `since`.
16
16
  *
17
17
  * @param since Only return sessions with mtime > this date
18
18
  * @param agentsDir Override the OC agents directory (default: ~/.openclaw/agents/)
19
+ * @param cursors Per-session capture cursors (#189); keyed `oc:<agentId>:<sid>`
19
20
  */
20
- export declare function readOcTranscripts(since: Date, agentsDir?: string): TranscriptBatch[];
21
+ export declare function readOcTranscripts(since: Date, agentsDir?: string, cursors?: CursorMap): TranscriptBatch[];
@@ -23,8 +23,9 @@ const DEFAULT_OC_AGENTS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".
23
23
  *
24
24
  * @param since Only return sessions with mtime > this date
25
25
  * @param agentsDir Override the OC agents directory (default: ~/.openclaw/agents/)
26
+ * @param cursors Per-session capture cursors (#189); keyed `oc:<agentId>:<sid>`
26
27
  */
27
- function readOcTranscripts(since, agentsDir = DEFAULT_OC_AGENTS_DIR) {
28
+ function readOcTranscripts(since, agentsDir = DEFAULT_OC_AGENTS_DIR, cursors = {}) {
28
29
  let agentIds;
29
30
  try {
30
31
  agentIds = (0, node_fs_1.readdirSync)(agentsDir);
@@ -44,8 +45,9 @@ function readOcTranscripts(since, agentsDir = DEFAULT_OC_AGENTS_DIR) {
44
45
  continue;
45
46
  }
46
47
  // agents/<agentId>/ contains a `sessions/` child with *.jsonl — exactly
47
- // the <root>/<projectDir>/*.jsonl shape readPiTranscripts walks.
48
- for (const batch of (0, pi_transcript_reader_js_1.readPiTranscripts)(since, agentPath)) {
48
+ // the <root>/<projectDir>/*.jsonl shape readPiTranscripts walks. The
49
+ // key prefix namespaces cursors per agent (oc:<agentId>:<sessionId>).
50
+ for (const batch of (0, pi_transcript_reader_js_1.readPiTranscripts)(since, agentPath, cursors, `oc:${agentId}`)) {
49
51
  batches.push({
50
52
  ...batch,
51
53
  // The Pi walk labels the project from the cwd or the "sessions" dir