@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.
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,93 +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
- }
232
- else if (resp.status === 429) {
233
- const data = await resp.json();
234
- console.log(`[hicortex] Memory limit reached: ${data.error}. Stopping capture.`);
235
- 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()}`);
236
273
  }
237
274
  else {
238
- const data = await resp.json().catch(() => ({}));
239
- console.error(`[hicortex] /distill returned ${resp.status}: ${data.error ?? "unknown error"} — will retry next run`);
240
- hadTransientFailure = true;
275
+ console.log(`[hicortex] Reading transcripts since ${since.toISOString()}`);
241
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.`);
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;
242
314
  }
243
- catch (err) {
244
- const msg = err instanceof Error ? err.message : String(err);
245
- console.error(`[hicortex] Capture failed: ${msg} — will retry next run`);
246
- hadTransientFailure = true;
315
+ finally {
316
+ releaseLock();
317
+ }
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)`);
247
325
  }
248
326
  }
249
- console.log(`[hicortex] Capture complete: ${memoriesIngested} new memories`);
250
327
  // Step 3: Consolidation — skipped in capture-only mode, dry-run, or no LLM.
251
328
  // Runs even if capture had transient failures (opens DB directly, independent
252
329
  // of the HTTP capture path). Full nightly only — capture-only runs are
@@ -323,7 +400,7 @@ async function runNightly(options = {}) {
323
400
  `They will be retried on the next run.`);
324
401
  }
325
402
  else {
326
- writeLastRun();
403
+ writeLastRun(stateDir);
327
404
  }
328
405
  }
329
406
  console.log(`[hicortex] Nightly pipeline complete.`);
@@ -356,7 +433,7 @@ async function runNightly(options = {}) {
356
433
  // ---------------------------------------------------------------------------
357
434
  // Client Mode Nightly — denoise locally, POST to remote server's /distill
358
435
  // ---------------------------------------------------------------------------
359
- async function runClientNightly(config, dryRun) {
436
+ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recaptureWindowDays) {
360
437
  const serverUrl = config.serverUrl.replace(/\/+$/, "");
361
438
  const authToken = config.authToken;
362
439
  console.log(`[hicortex] Client nightly starting${dryRun ? " (dry run)" : ""}`);
@@ -375,105 +452,84 @@ async function runClientNightly(config, dryRun) {
375
452
  return; // Don't update last-run so we retry
376
453
  }
377
454
  // No local LLM needed — distillation happens on the server.
378
- // Read new transcripts (CC + Hermes + Pi + OpenClaw). Client reads local
379
- // logs, denoises, and POSTs the denoised text to the server's /distill
380
- // endpoint. All readers no-op when their harness isn't installed.
381
- const since = readLastRun();
382
- console.log(`[hicortex] Reading transcripts since ${since.toISOString()}`);
383
- const ccBatches = (0, transcript_reader_js_1.readCcTranscripts)(since);
384
- const hermesBatches = (0, hermes_transcript_reader_js_1.readHermesSessions)(since);
385
- const piBatches = (0, pi_transcript_reader_js_1.readPiTranscripts)(since);
386
- const ocBatches = (0, oc_transcript_reader_js_1.readOcTranscripts)(since);
387
- const batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches];
388
- if (ccBatches.length > 0)
389
- console.log(`[hicortex] Found ${ccBatches.length} CC session(s)`);
390
- if (hermesBatches.length > 0)
391
- console.log(`[hicortex] Found ${hermesBatches.length} Hermes session(s)`);
392
- if (piBatches.length > 0)
393
- console.log(`[hicortex] Found ${piBatches.length} Pi session(s)`);
394
- if (ocBatches.length > 0)
395
- console.log(`[hicortex] Found ${ocBatches.length} OpenClaw session(s)`);
396
- console.log(`[hicortex] Total: ${batches.length} new session(s)`);
397
- if (batches.length === 0) {
398
- console.log(`[hicortex] Nothing to capture.`);
399
- if (!dryRun)
400
- 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).");
401
463
  return;
402
464
  }
403
- let hadTransientFailure = false;
465
+ let ccBatches = [];
466
+ let hermesBatches = [];
467
+ let piBatches = [];
468
+ let ocBatches = [];
469
+ let batches = [];
404
470
  let memoriesIngested = 0;
405
471
  let sessionsSent = 0;
406
- for (const batch of batches) {
407
- const transcript = (0, distiller_js_1.extractConversationText)(batch.entries);
408
- if (transcript.length < 200) {
409
- console.log(`[hicortex] Skip ${batch.sessionId.slice(0, 8)} (${batch.projectName}): too short`);
410
- 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()}`);
411
481
  }
412
- console.log(`[hicortex] Capturing ${batch.sessionId.slice(0, 8)} (${batch.projectName}, ${batch.date})`);
413
- if (dryRun) {
414
- console.log(`[hicortex] [dry-run] Would POST ${transcript.length} chars to ${serverUrl}/distill`);
415
- continue;
482
+ else {
483
+ console.log(`[hicortex] Reading transcripts since ${since.toISOString()}`);
416
484
  }
417
- try {
418
- const resp = await fetch(`${serverUrl}/distill`, {
419
- method: "POST",
420
- headers: {
421
- "Content-Type": "application/json",
422
- ...(authToken ? { "Authorization": `Bearer ${authToken}` } : {}),
423
- },
424
- body: JSON.stringify({
425
- text: transcript,
426
- source_agent: batch.sourceAgent ?? `claude-code/${batch.projectName}`,
427
- project: batch.projectName,
428
- session_id: batch.sessionId,
429
- session_date: batch.date,
430
- privacy: "WORK",
431
- }),
432
- // Synchronous 35B distillation of a large session can take minutes.
433
- signal: AbortSignal.timeout(20 * 60 * 1000),
434
- });
435
- if (resp.status === 200) {
436
- const data = await resp.json();
437
- if (data.skipped) {
438
- console.log(`[hicortex] Skip ${batch.sessionId.slice(0, 8)}: already ingested on server`);
439
- }
440
- }
441
- else if (resp.status === 201) {
442
- const data = await resp.json();
443
- const count = data.distilled ?? 0;
444
- memoriesIngested += count;
445
- sessionsSent++;
446
- console.log(`[hicortex] → ${count} memories sent to server`);
447
- }
448
- else if (resp.status === 401) {
449
- console.error(`[hicortex] Auth failed. Check authToken in ~/.hicortex/config.json`);
450
- return; // No point retrying with wrong credentials
451
- }
452
- else if (resp.status === 429) {
453
- const data = await resp.json().catch(() => ({}));
454
- console.log(`[hicortex] Server memory limit reached: ${data.error}`);
455
- return;
456
- }
457
- else {
458
- const data = await resp.json().catch(() => ({}));
459
- console.error(`[hicortex] /distill returned ${resp.status}: ${data.error ?? "unknown error"} — will retry next run`);
460
- hadTransientFailure = true;
461
- }
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.`);
462
503
  }
463
- catch (err) {
464
- console.error(`[hicortex] Capture failed: ${err instanceof Error ? err.message : String(err)} — will retry next run`);
465
- 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;
466
515
  }
467
516
  }
468
- // Only advance lastRun if every session was processed without a transient
469
- // 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.
470
523
  if (!dryRun) {
471
524
  if (hadTransientFailure) {
472
- console.warn(`[hicortex] Not advancing lastRun — one or more sessions failed. ` +
473
- `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.`);
474
527
  }
475
528
  else {
476
- 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)`);
477
533
  }
478
534
  }
479
535
  console.log(`[hicortex] Client nightly complete: ${memoriesIngested} memories from ${sessionsSent} sessions → ${serverUrl}`);
@@ -487,7 +543,7 @@ async function runClientNightly(config, dryRun) {
487
543
  ].filter(Boolean);
488
544
  const agentType = kinds.length > 1 ? "mixed" : (kinds[0] ?? "cc");
489
545
  await (0, telemetry_js_1.sendTelemetry)({
490
- id: (0, telemetry_js_1.getTelemetryId)(HICORTEX_HOME),
546
+ id: (0, telemetry_js_1.getTelemetryId)(stateDir),
491
547
  v: VERSION,
492
548
  mode: "client",
493
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
@@ -26,13 +26,8 @@
26
26
  * becomes --home-agents-Agents-raider--. The session header's `cwd` field
27
27
  * is the canonical path; the directory name is a filesystem-safe encoding.
28
28
  */
29
- export interface TranscriptBatch {
30
- sessionId: string;
31
- projectName: string;
32
- date: string;
33
- entries: unknown[];
34
- sourceAgent?: string;
35
- }
29
+ import type { TranscriptBatch, CursorMap } from "./transcript-reader.js";
30
+ export type { TranscriptBatch, CursorMap };
36
31
  /**
37
32
  * Read Pi session transcripts modified after `since`.
38
33
  *
@@ -41,5 +36,7 @@ export interface TranscriptBatch {
41
36
  *
42
37
  * @param since Only return sessions with mtime > this date
43
38
  * @param sessionsDir Override the session directory (default: ~/.pi/agent/sessions/)
39
+ * @param cursors Per-session capture cursors (#189); default empty = whole file
40
+ * @param keyPrefix Cursor-key namespace ("pi" here; OC passes "oc:<agentId>")
44
41
  */
45
- export declare function readPiTranscripts(since: Date, sessionsDir?: string): TranscriptBatch[];
42
+ export declare function readPiTranscripts(since: Date, sessionsDir?: string, cursors?: CursorMap, keyPrefix?: string): TranscriptBatch[];
@@ -41,8 +41,10 @@ const DEFAULT_PI_SESSIONS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(),
41
41
  *
42
42
  * @param since Only return sessions with mtime > this date
43
43
  * @param sessionsDir Override the session directory (default: ~/.pi/agent/sessions/)
44
+ * @param cursors Per-session capture cursors (#189); default empty = whole file
45
+ * @param keyPrefix Cursor-key namespace ("pi" here; OC passes "oc:<agentId>")
44
46
  */
45
- function readPiTranscripts(since, sessionsDir = DEFAULT_PI_SESSIONS_DIR) {
47
+ function readPiTranscripts(since, sessionsDir = DEFAULT_PI_SESSIONS_DIR, cursors = {}, keyPrefix = "pi") {
46
48
  const batches = [];
47
49
  let projectDirs;
48
50
  try {
@@ -80,6 +82,7 @@ function readPiTranscripts(since, sessionsDir = DEFAULT_PI_SESSIONS_DIR) {
80
82
  const raw = (0, node_fs_1.readFileSync)(filePath, "utf-8");
81
83
  const lines = raw.split("\n").filter((l) => l.trim());
82
84
  const entries = [];
85
+ const timestamps = [];
83
86
  let sessionId = "";
84
87
  let sessionCwd = "";
85
88
  let sessionDate = "";
@@ -87,6 +90,7 @@ function readPiTranscripts(since, sessionsDir = DEFAULT_PI_SESSIONS_DIR) {
87
90
  try {
88
91
  const entry = JSON.parse(line);
89
92
  entries.push(entry);
93
+ timestamps.push(typeof entry.timestamp === "string" ? entry.timestamp : "");
90
94
  // Extract metadata from the session header
91
95
  if (entry.type === "session") {
92
96
  sessionId = entry.id ?? "";
@@ -111,14 +115,38 @@ function readPiTranscripts(since, sessionsDir = DEFAULT_PI_SESSIONS_DIR) {
111
115
  if (!sessionDate) {
112
116
  sessionDate = extractDateFromFilename(file) ?? "";
113
117
  }
114
- if (entries.length > 0) {
115
- batches.push({
116
- sessionId,
117
- projectName,
118
- date: sessionDate,
119
- entries,
120
- });
118
+ if (entries.length === 0)
119
+ continue;
120
+ // Incremental slice (#189): append-only JSONL v3, same discipline as CC.
121
+ const cursorKey = `${keyPrefix}:${sessionId}`;
122
+ const pos = cursors[cursorKey] ?? { cursor: 0, gen: 0 };
123
+ let start = pos.cursor;
124
+ let gen = pos.gen;
125
+ if (start > entries.length) {
126
+ // shrink guard (truncation/rotation) — reset + bump generation (fix 8)
127
+ start = 0;
128
+ gen = pos.gen + 1;
129
+ }
130
+ const delta = entries.slice(start);
131
+ if (delta.length === 0)
132
+ continue; // cursor already covers the file
133
+ const entryCursors = delta.map((_, i) => start + i + 1);
134
+ // Prefer the last timestamped entry in the delta for per-night dating.
135
+ let deltaDate = "";
136
+ for (let i = start; i < entries.length; i++) {
137
+ if (timestamps[i])
138
+ deltaDate = timestamps[i].slice(0, 10);
121
139
  }
140
+ batches.push({
141
+ sessionId,
142
+ projectName,
143
+ date: deltaDate || sessionDate || new Date().toISOString().slice(0, 10),
144
+ entries: delta,
145
+ cursorKey,
146
+ startCursor: start,
147
+ generation: gen,
148
+ entryCursors,
149
+ });
122
150
  }
123
151
  catch {
124
152
  // File read or parse failed — skip