@gamaze/hicortex 0.13.1 → 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/capture-cursors.d.ts +73 -0
- package/dist/capture-cursors.js +133 -0
- package/dist/capture.d.ts +124 -0
- package/dist/capture.js +386 -0
- package/dist/cli.js +13 -1
- package/dist/hermes-transcript-reader.d.ts +6 -2
- package/dist/hermes-transcript-reader.js +41 -4
- package/dist/mcp-server.js +46 -16
- package/dist/nightly.d.ts +2 -0
- package/dist/nightly.js +224 -176
- package/dist/oc-transcript-reader.d.ts +3 -2
- package/dist/oc-transcript-reader.js +5 -3
- package/dist/pi-transcript-reader.d.ts +5 -8
- package/dist/pi-transcript-reader.js +36 -8
- package/dist/transcript-reader.d.ts +22 -1
- package/dist/transcript-reader.js +47 -14
- package/package.json +1 -1
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
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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 —
|
|
481
|
-
`
|
|
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)(
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|