@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.
- 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/consolidate.d.ts +1 -1
- package/dist/consolidate.js +1 -1
- package/dist/context-cli.js +1 -1
- package/dist/domain-classify.d.ts +3 -3
- package/dist/domain-classify.js +3 -3
- package/dist/hermes-transcript-reader.d.ts +7 -3
- package/dist/hermes-transcript-reader.js +42 -5
- package/dist/lessons-context.d.ts +1 -1
- package/dist/lessons-context.js +2 -2
- 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 +8 -11
- package/dist/pi-transcript-reader.js +41 -13
- package/dist/transcript-reader.d.ts +23 -2
- package/dist/transcript-reader.js +49 -16
- package/domains.example.json +53 -13
- package/hermes-plugin/hicortex/provider.py +4 -4
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/mcp-server.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|