@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/capture.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Incremental, cursor-aware capture loop (#189).
|
|
4
|
+
*
|
|
5
|
+
* Extracted from the two near-identical loops that lived in nightly.ts (server
|
|
6
|
+
* and client mode). Both now share this logic: pack each session's delta into
|
|
7
|
+
* ordered segments below the server's distill cap, POST them in order with a
|
|
8
|
+
* deterministic `segment_id`, and advance the per-session cursor ONLY after
|
|
9
|
+
* server-confirmed success — so a multi-day session grows across nights with no
|
|
10
|
+
* loss and no silent truncation.
|
|
11
|
+
*
|
|
12
|
+
* The POST transport is injected (`post`) so the mode-specific bits (localhost
|
|
13
|
+
* vs remote URL, Authorization header, timeout) stay in nightly.ts and the
|
|
14
|
+
* multi-night simulation can run as a pure unit test with no HTTP listener.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.MIN_SEGMENT_CHARS = exports.SEGMENT_MAX_CHARS = void 0;
|
|
18
|
+
exports.hardSplitText = hardSplitText;
|
|
19
|
+
exports.packSegments = packSegments;
|
|
20
|
+
exports.captureBatches = captureBatches;
|
|
21
|
+
exports.acquireCaptureLock = acquireCaptureLock;
|
|
22
|
+
const node_fs_1 = require("node:fs");
|
|
23
|
+
const node_path_1 = require("node:path");
|
|
24
|
+
const distiller_js_1 = require("./distiller.js");
|
|
25
|
+
/**
|
|
26
|
+
* Max denoised chars per segment. Kept below the server's 80K distill cap
|
|
27
|
+
* (distiller.ts MAX_TRANSCRIPT_CHARS) with ~20K headroom so NO capture path can
|
|
28
|
+
* hit the silent truncation. LOAD-BEARING for #189 recovery: a re-ingested
|
|
29
|
+
* week-long session is re-sliced into ≤60K segments here instead of being
|
|
30
|
+
* truncated at 80K server-side. (Judgment constant — tunable later.)
|
|
31
|
+
*/
|
|
32
|
+
exports.SEGMENT_MAX_CHARS = 60_000;
|
|
33
|
+
/**
|
|
34
|
+
* Minimum denoised chars for a FRESH whole session (startCursor 0) to be worth
|
|
35
|
+
* capturing — mirrors the long-standing pre-#189 200-char degenerate-session
|
|
36
|
+
* gate. It is applied ONLY to a whole-session capture that denoises to a single
|
|
37
|
+
* sub-200 segment. A delta beyond cursor 0 is always sent, however small: a
|
|
38
|
+
* session's concluding tail must never be held back, because once the session
|
|
39
|
+
* stops growing its mtime never re-crosses the watermark and the tail would be
|
|
40
|
+
* lost forever (#189 review, fix 5).
|
|
41
|
+
*/
|
|
42
|
+
exports.MIN_SEGMENT_CHARS = 200;
|
|
43
|
+
/** Chars added by the "\n\n" joiner extractConversationText places between entries. */
|
|
44
|
+
const JOINER_CHARS = 2;
|
|
45
|
+
/**
|
|
46
|
+
* Split an already-denoised string into ≤maxChars pieces (A2 hard-split).
|
|
47
|
+
* Prefers paragraph, then line, then hard boundaries — mirrors the distiller's
|
|
48
|
+
* own splitIntoChunks, but WITHOUT its <200-char drop (every piece must survive,
|
|
49
|
+
* dup-over-loss).
|
|
50
|
+
*/
|
|
51
|
+
function hardSplitText(text, maxChars = exports.SEGMENT_MAX_CHARS) {
|
|
52
|
+
if (text.length <= maxChars)
|
|
53
|
+
return [text];
|
|
54
|
+
const pieces = [];
|
|
55
|
+
let remaining = text;
|
|
56
|
+
while (remaining.length > maxChars) {
|
|
57
|
+
let splitAt = remaining.lastIndexOf("\n\n", maxChars);
|
|
58
|
+
if (splitAt < maxChars * 0.5)
|
|
59
|
+
splitAt = remaining.lastIndexOf("\n", maxChars);
|
|
60
|
+
if (splitAt < maxChars * 0.3)
|
|
61
|
+
splitAt = maxChars;
|
|
62
|
+
pieces.push(remaining.slice(0, splitAt).trim());
|
|
63
|
+
remaining = remaining.slice(splitAt).trim();
|
|
64
|
+
}
|
|
65
|
+
if (remaining.length > 0)
|
|
66
|
+
pieces.push(remaining);
|
|
67
|
+
return pieces;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Pack a session's delta entries into ordered ≤maxChars segments.
|
|
71
|
+
*
|
|
72
|
+
* Sizing uses per-entry denoise lengths plus the "\n\n" joiners (A8) so the
|
|
73
|
+
* estimate matches what the server receives; the actual body is a re-denoise of
|
|
74
|
+
* the grouped entries (extractConversationText) so cleaning/redaction stay
|
|
75
|
+
* coherent. A single entry larger than maxChars is emitted as its own run of
|
|
76
|
+
* hard-split pieces (A2).
|
|
77
|
+
*/
|
|
78
|
+
function packSegments(entries, startCursor, entryCursors, maxChars = exports.SEGMENT_MAX_CHARS) {
|
|
79
|
+
const segments = [];
|
|
80
|
+
// Boundary cursor before entry i (startCursor for i=0, else entryCursors[i-1]).
|
|
81
|
+
const boundaryBefore = (i) => (i === 0 ? startCursor : entryCursors[i - 1]);
|
|
82
|
+
let groupStartIdx = -1;
|
|
83
|
+
let groupSize = 0;
|
|
84
|
+
const flushGroup = (endIdxExclusive) => {
|
|
85
|
+
if (groupStartIdx < 0)
|
|
86
|
+
return;
|
|
87
|
+
const groupEntries = entries.slice(groupStartIdx, endIdxExclusive);
|
|
88
|
+
segments.push({
|
|
89
|
+
text: (0, distiller_js_1.extractConversationText)(groupEntries),
|
|
90
|
+
segStart: boundaryBefore(groupStartIdx),
|
|
91
|
+
segEnd: entryCursors[endIdxExclusive - 1],
|
|
92
|
+
idSuffix: "",
|
|
93
|
+
});
|
|
94
|
+
groupStartIdx = -1;
|
|
95
|
+
groupSize = 0;
|
|
96
|
+
};
|
|
97
|
+
for (let i = 0; i < entries.length; i++) {
|
|
98
|
+
const entryText = (0, distiller_js_1.extractConversationText)([entries[i]]);
|
|
99
|
+
const entryLen = entryText.length;
|
|
100
|
+
if (entryLen > maxChars) {
|
|
101
|
+
// Oversized single entry — flush the pending group, then hard-split it
|
|
102
|
+
// into its own segments so no piece can reach the server's 80K cap.
|
|
103
|
+
// Defensive: extractTextFromContent currently caps a single message at
|
|
104
|
+
// ~20K denoised chars, so this branch does not fire at the 60K default —
|
|
105
|
+
// it guarantees the ≤maxChars invariant regardless of the denoiser (A2).
|
|
106
|
+
flushGroup(i);
|
|
107
|
+
const pieces = hardSplitText(entryText, maxChars);
|
|
108
|
+
pieces.forEach((piece, p) => {
|
|
109
|
+
segments.push({
|
|
110
|
+
text: piece,
|
|
111
|
+
segStart: boundaryBefore(i),
|
|
112
|
+
segEnd: entryCursors[i],
|
|
113
|
+
idSuffix: `.p${p}`,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// Would adding this entry (plus its joiner) overflow the current group?
|
|
119
|
+
const addition = (groupSize > 0 ? JOINER_CHARS : 0) + entryLen;
|
|
120
|
+
if (groupSize > 0 && groupSize + addition > maxChars) {
|
|
121
|
+
flushGroup(i);
|
|
122
|
+
}
|
|
123
|
+
if (groupStartIdx < 0)
|
|
124
|
+
groupStartIdx = i;
|
|
125
|
+
groupSize += addition;
|
|
126
|
+
}
|
|
127
|
+
flushGroup(entries.length);
|
|
128
|
+
return segments;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Capture a list of session delta batches: pack, POST in order, advance
|
|
132
|
+
* per-session cursors on success. Segments of one session POST in order; the
|
|
133
|
+
* first hard failure stops THAT session (cursor holds at the last confirmed
|
|
134
|
+
* boundary) while other sessions continue. A 429/401 stops the whole loop.
|
|
135
|
+
*/
|
|
136
|
+
async function captureBatches(batches, opts) {
|
|
137
|
+
const { post, cursorStore, dryRun = false, segmentMaxChars = exports.SEGMENT_MAX_CHARS } = opts;
|
|
138
|
+
let memoriesIngested = 0;
|
|
139
|
+
let sessionsSent = 0;
|
|
140
|
+
let hadTransientFailure = false;
|
|
141
|
+
let stopped;
|
|
142
|
+
for (const batch of batches) {
|
|
143
|
+
const short = batch.sessionId.slice(0, 8);
|
|
144
|
+
const segments = packSegments(batch.entries, batch.startCursor, batch.entryCursors, segmentMaxChars);
|
|
145
|
+
if (segments.length === 0)
|
|
146
|
+
continue;
|
|
147
|
+
// Fresh whole-session degenerate floor (fix 5): only a startCursor-0 capture
|
|
148
|
+
// that collapses to a single sub-200 segment is dropped (pre-#189 behaviour
|
|
149
|
+
// for degenerate sessions). Any real delta — including a small concluding
|
|
150
|
+
// tail — is sent below.
|
|
151
|
+
if (batch.startCursor === 0 && segments.length === 1 && segments[0].text.length < exports.MIN_SEGMENT_CHARS) {
|
|
152
|
+
if (!dryRun)
|
|
153
|
+
console.log(`[hicortex] Skip ${short} (${batch.projectName}): too short`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (dryRun) {
|
|
157
|
+
const total = segments.reduce((n, s) => n + s.text.length, 0);
|
|
158
|
+
console.log(`[hicortex] [dry-run] ${short} (${batch.projectName}): ${segments.length} segment(s), ${total} chars`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
console.log(`[hicortex] Capturing ${short} (${batch.projectName}, ${batch.date})`);
|
|
162
|
+
// Segment ids carry the shrink generation so post-reset ids can't collide
|
|
163
|
+
// with pre-reset ones on the server's content-blind dedup (fix 8). gen 0
|
|
164
|
+
// has no prefix — keeps ids byte-identical to first-cut and to any already
|
|
165
|
+
// stored on an older server.
|
|
166
|
+
const genPrefix = batch.generation > 0 ? `g${batch.generation}.` : "";
|
|
167
|
+
// Cursor value the last confirmed boundary reached — advanced once, at
|
|
168
|
+
// session end (A4), so per-session file writes stay bounded.
|
|
169
|
+
let lastConfirmedEnd = batch.startCursor;
|
|
170
|
+
let sessionPosted = false;
|
|
171
|
+
for (let s = 0; s < segments.length; s++) {
|
|
172
|
+
const seg = segments[s];
|
|
173
|
+
// A segment advances the cursor to its segEnd only when it is the LAST
|
|
174
|
+
// segment ending at that boundary. Hard-split pieces (.p0,.p1,…) of one
|
|
175
|
+
// entry share the same segEnd; confirming an earlier piece must NOT move
|
|
176
|
+
// the cursor past the entry while a later piece is still unsent (fix 11).
|
|
177
|
+
const advancesBoundary = s === segments.length - 1 || segments[s + 1].segStart >= seg.segEnd;
|
|
178
|
+
// Pure-noise slice (all entries filtered to nothing) — nothing to store
|
|
179
|
+
// and the server rejects an empty body. Advance past it (content is gone
|
|
180
|
+
// either way) rather than POST.
|
|
181
|
+
if (seg.text.length === 0) {
|
|
182
|
+
if (advancesBoundary)
|
|
183
|
+
lastConfirmedEnd = seg.segEnd;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const body = {
|
|
187
|
+
text: seg.text,
|
|
188
|
+
source_agent: batch.sourceAgent ?? `claude-code/${batch.projectName}`,
|
|
189
|
+
project: batch.projectName,
|
|
190
|
+
session_id: batch.sessionId,
|
|
191
|
+
segment_id: `${genPrefix}${seg.segStart}-${seg.segEnd}${seg.idSuffix}`,
|
|
192
|
+
session_date: batch.date,
|
|
193
|
+
privacy: "WORK",
|
|
194
|
+
};
|
|
195
|
+
let result;
|
|
196
|
+
try {
|
|
197
|
+
result = await post(body);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
console.error(`[hicortex] Capture failed: ${err instanceof Error ? err.message : String(err)} — will retry next run`);
|
|
201
|
+
hadTransientFailure = true;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
if (result.status === 201) {
|
|
205
|
+
memoriesIngested += result.distilled ?? 0;
|
|
206
|
+
sessionPosted = true;
|
|
207
|
+
if (advancesBoundary)
|
|
208
|
+
lastConfirmedEnd = seg.segEnd;
|
|
209
|
+
console.log(`[hicortex] → ${result.distilled ?? 0} memories (segment ${body.segment_id})`);
|
|
210
|
+
for (const d of result.dropped ?? []) {
|
|
211
|
+
console.log(`[hicortex] Substance gate: dropped "${d}"`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else if (result.status === 200) {
|
|
215
|
+
// Already ingested (segment-exact or legacy session dedup) — treat as
|
|
216
|
+
// confirmed and advance past it (only at a boundary, per fix 11).
|
|
217
|
+
if (advancesBoundary)
|
|
218
|
+
lastConfirmedEnd = seg.segEnd;
|
|
219
|
+
if (result.skipped)
|
|
220
|
+
console.log(`[hicortex] Segment ${body.segment_id} already ingested`);
|
|
221
|
+
}
|
|
222
|
+
else if (result.status === 429) {
|
|
223
|
+
console.log(`[hicortex] Memory limit reached: ${result.error}. Stopping capture.`);
|
|
224
|
+
stopped = "limit";
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
else if (result.status === 401) {
|
|
228
|
+
console.error(`[hicortex] Auth failed. Check authToken in ~/.hicortex/config.json`);
|
|
229
|
+
stopped = "auth";
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
console.error(`[hicortex] /distill returned ${result.status}: ${result.error ?? "unknown error"} — will retry next run`);
|
|
234
|
+
hadTransientFailure = true;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (sessionPosted)
|
|
239
|
+
sessionsSent++;
|
|
240
|
+
// Advance the session cursor once, to the last confirmed boundary. Holds at
|
|
241
|
+
// startCursor when nothing was confirmed (whole delta failed or noise-only).
|
|
242
|
+
// A mid-session failure only breaks the segment loop above — this still
|
|
243
|
+
// records the boundaries that DID confirm, and other sessions continue.
|
|
244
|
+
if (lastConfirmedEnd > batch.startCursor) {
|
|
245
|
+
try {
|
|
246
|
+
cursorStore.advance(batch.cursorKey, lastConfirmedEnd, batch.generation);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
// Persisting the cursor failed (disk full / perms). Surface it as a
|
|
250
|
+
// transient failure so the watermark holds and we retry — never a
|
|
251
|
+
// silent warn-and-continue that re-captures forever (fix 7).
|
|
252
|
+
console.error(`[hicortex] Failed to persist capture cursor for ${batch.cursorKey}: ${err instanceof Error ? err.message : String(err)} — holding watermark`);
|
|
253
|
+
hadTransientFailure = true;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (stopped)
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
return { memoriesIngested, sessionsSent, hadTransientFailure, stopped };
|
|
261
|
+
}
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Single-flight guard (A5)
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
const LOCK_FILE = "capture.lock";
|
|
266
|
+
/**
|
|
267
|
+
* A lock older than this is considered stale REGARDLESS of the recorded pid.
|
|
268
|
+
* Guards the EPERM case: a recycled pid owned by a long-lived root/other-user
|
|
269
|
+
* process would otherwise read as "alive forever" and wedge capture silently
|
|
270
|
+
* (#189 review, fix 2). 24h > the longest plausible distill run (20-min POST
|
|
271
|
+
* timeout × sessions).
|
|
272
|
+
*/
|
|
273
|
+
const LOCK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
274
|
+
const LOCK_POLL_MS = 2000;
|
|
275
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
276
|
+
/**
|
|
277
|
+
* Acquire an exclusive capture lock for `stateDir`. Returns a release function,
|
|
278
|
+
* or null if another LIVE, non-stale run holds it after waiting up to `waitMs`.
|
|
279
|
+
*
|
|
280
|
+
* Staleness = dead pid OR lockfile mtime older than LOCK_TTL_MS. A stale lock is
|
|
281
|
+
* reclaimed (with a re-verify + O_EXCL re-race to narrow the TOCTOU window,
|
|
282
|
+
* fix 12). `waitMs` lets the full nightly wait out a transient `--capture-only`
|
|
283
|
+
* overlap instead of dropping the night's capture (fix 10); pass 0 to fail fast.
|
|
284
|
+
*
|
|
285
|
+
* This stops a `nightly` and a `nightly --capture-only` (an encouraged workflow)
|
|
286
|
+
* from running the capture loop concurrently, which would race cursor writes and
|
|
287
|
+
* emit divergent segment boundaries → real duplication.
|
|
288
|
+
*/
|
|
289
|
+
async function acquireCaptureLock(stateDir, waitMs = 0) {
|
|
290
|
+
const lockPath = (0, node_path_1.join)(stateDir, LOCK_FILE);
|
|
291
|
+
try {
|
|
292
|
+
(0, node_fs_1.mkdirSync)(stateDir, { recursive: true });
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
/* best effort */
|
|
296
|
+
}
|
|
297
|
+
const deadline = Date.now() + waitMs;
|
|
298
|
+
for (;;) {
|
|
299
|
+
const release = tryAcquireOnce(lockPath);
|
|
300
|
+
if (release)
|
|
301
|
+
return release;
|
|
302
|
+
if (Date.now() >= deadline)
|
|
303
|
+
return null;
|
|
304
|
+
await sleep(Math.max(1, Math.min(LOCK_POLL_MS, deadline - Date.now())));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** One acquire attempt: create-if-free, else reclaim-if-stale. */
|
|
308
|
+
function tryAcquireOnce(lockPath) {
|
|
309
|
+
const release = () => {
|
|
310
|
+
try {
|
|
311
|
+
(0, node_fs_1.unlinkSync)(lockPath);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
/* already gone */
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
const create = () => {
|
|
318
|
+
try {
|
|
319
|
+
const fd = (0, node_fs_1.openSync)(lockPath, "wx"); // O_CREAT | O_EXCL
|
|
320
|
+
(0, node_fs_1.writeSync)(fd, String(process.pid));
|
|
321
|
+
(0, node_fs_1.closeSync)(fd);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
if (err.code === "EEXIST")
|
|
326
|
+
return false;
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
try {
|
|
331
|
+
if (create())
|
|
332
|
+
return release;
|
|
333
|
+
// Lock exists — read the holder pid and decide staleness.
|
|
334
|
+
const holderPid = readLockPid(lockPath);
|
|
335
|
+
if (!isLockStale(lockPath, holderPid))
|
|
336
|
+
return null; // live, recent → held
|
|
337
|
+
// Stale. Re-verify the file still carries the SAME pid we judged (another
|
|
338
|
+
// reclaimer may have taken it since), then unlink and re-race the O_EXCL
|
|
339
|
+
// create. A fresh holder → our create loses (EEXIST) → null (fix 12).
|
|
340
|
+
if (readLockPid(lockPath) !== holderPid)
|
|
341
|
+
return null;
|
|
342
|
+
try {
|
|
343
|
+
(0, node_fs_1.unlinkSync)(lockPath);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
/* raced with another reclaimer */
|
|
347
|
+
}
|
|
348
|
+
return create() ? release : null;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Filesystem refused the lock op entirely — don't wedge capture; proceed
|
|
352
|
+
// without the guard (behaviour before A5).
|
|
353
|
+
return release;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** Read the recorded pid, or 0 if unreadable/absent. */
|
|
357
|
+
function readLockPid(lockPath) {
|
|
358
|
+
try {
|
|
359
|
+
const pid = parseInt((0, node_fs_1.readFileSync)(lockPath, "utf-8").trim(), 10);
|
|
360
|
+
return Number.isFinite(pid) ? pid : 0;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/** Stale = no/dead pid, OR the lockfile is older than the TTL (fix 2). */
|
|
367
|
+
function isLockStale(lockPath, holderPid) {
|
|
368
|
+
if (!holderPid || !isProcessAlive(holderPid))
|
|
369
|
+
return true;
|
|
370
|
+
try {
|
|
371
|
+
return Date.now() - (0, node_fs_1.statSync)(lockPath).mtimeMs > LOCK_TTL_MS;
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return true; // can't stat → treat as stale so we don't wedge forever
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function isProcessAlive(pid) {
|
|
378
|
+
try {
|
|
379
|
+
process.kill(pid, 0);
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
// ESRCH = no such process; EPERM = exists but not ours (still alive).
|
|
384
|
+
return err.code === "EPERM";
|
|
385
|
+
}
|
|
386
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -62,8 +62,19 @@ switch (command) {
|
|
|
62
62
|
else {
|
|
63
63
|
const dryRun = args.includes("--dry-run");
|
|
64
64
|
const captureOnly = args.includes("--capture-only");
|
|
65
|
+
// #189 Tier-2 recovery: re-discover sessions that went quiet before the
|
|
66
|
+
// upgrade by widening the discovery window to now−N days for one run.
|
|
67
|
+
let recaptureWindowDays;
|
|
68
|
+
const rwIdx = args.indexOf("--recapture-window");
|
|
69
|
+
if (rwIdx !== -1) {
|
|
70
|
+
recaptureWindowDays = parseInt(args[rwIdx + 1], 10);
|
|
71
|
+
if (isNaN(recaptureWindowDays) || recaptureWindowDays <= 0) {
|
|
72
|
+
console.error("[hicortex] nightly: --recapture-window requires a positive integer (days)");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
65
76
|
import("./nightly.js").then(({ runNightly }) => {
|
|
66
|
-
runNightly({ dryRun, captureOnly }).catch((err) => {
|
|
77
|
+
runNightly({ dryRun, captureOnly, recaptureWindowDays }).catch((err) => {
|
|
67
78
|
console.error("[hicortex] Nightly pipeline failed:", err);
|
|
68
79
|
process.exit(1);
|
|
69
80
|
});
|
|
@@ -194,6 +205,7 @@ Options:
|
|
|
194
205
|
server --host <h> Host (default: 127.0.0.1)
|
|
195
206
|
nightly --dry-run Preview without changes
|
|
196
207
|
nightly --capture-only Capture only, skip consolidation (safe to run multiple times/day)
|
|
208
|
+
nightly --recapture-window <days> Re-discover sessions quiet since <days> ago (one-shot #189 recovery)
|
|
197
209
|
nightly --status Show nightly pipeline health
|
|
198
210
|
relink --dry-run Discovery + counts only, zero writes, cursor untouched
|
|
199
211
|
relink --batch <n> Memories per batch (default: 200)
|
|
@@ -19,9 +19,13 @@
|
|
|
19
19
|
* distillation and keeps per-session dedup clean (chunks are stored as
|
|
20
20
|
* `<sessionId>#<chunkIndex>`; see nightly.ts).
|
|
21
21
|
*/
|
|
22
|
-
import type { TranscriptBatch } from "./transcript-reader.js";
|
|
22
|
+
import type { TranscriptBatch, CursorMap } from "./transcript-reader.js";
|
|
23
23
|
/**
|
|
24
24
|
* Read Hermes sessions that ended since `since`, across all profiles.
|
|
25
25
|
* Returns one batch per session, parallel to readCcTranscripts().
|
|
26
|
+
*
|
|
27
|
+
* @param cursors Per-session capture cursors (#189), keyed `hermes:<profile>:<sid>`.
|
|
28
|
+
* The cursor value is the max `messages.id` already captured; a resumed +
|
|
29
|
+
* re-ended session yields only the new rows (`id > cursor`).
|
|
26
30
|
*/
|
|
27
|
-
export declare function readHermesSessions(since: Date, hermesHome?: string): TranscriptBatch[];
|
|
31
|
+
export declare function readHermesSessions(since: Date, hermesHome?: string, cursors?: CursorMap): TranscriptBatch[];
|
|
@@ -47,8 +47,12 @@ const NOISE_ROLES = new Set(["tool", "session_meta"]);
|
|
|
47
47
|
/**
|
|
48
48
|
* Read Hermes sessions that ended since `since`, across all profiles.
|
|
49
49
|
* Returns one batch per session, parallel to readCcTranscripts().
|
|
50
|
+
*
|
|
51
|
+
* @param cursors Per-session capture cursors (#189), keyed `hermes:<profile>:<sid>`.
|
|
52
|
+
* The cursor value is the max `messages.id` already captured; a resumed +
|
|
53
|
+
* re-ended session yields only the new rows (`id > cursor`).
|
|
50
54
|
*/
|
|
51
|
-
function readHermesSessions(since, hermesHome = HERMES_HOME) {
|
|
55
|
+
function readHermesSessions(since, hermesHome = HERMES_HOME, cursors = {}) {
|
|
52
56
|
const batches = [];
|
|
53
57
|
const sinceEpoch = since.getTime() / 1000; // Hermes timestamps are unix seconds (REAL)
|
|
54
58
|
for (const { profile, dbPath } of discoverProfileDbs(hermesHome)) {
|
|
@@ -63,15 +67,40 @@ function readHermesSessions(since, hermesHome = HERMES_HOME) {
|
|
|
63
67
|
const sessions = db
|
|
64
68
|
.prepare("SELECT id, ended_at, source FROM sessions WHERE ended_at IS NOT NULL AND ended_at > ? ORDER BY ended_at")
|
|
65
69
|
.all(sinceEpoch);
|
|
66
|
-
|
|
70
|
+
// Cursor is a message id (INTEGER PRIMARY KEY AUTOINCREMENT — strictly
|
|
71
|
+
// increasing, never reused), so `id > ?` returns exactly the rows added
|
|
72
|
+
// since last capture. ORDER BY id (NOT timestamp): id is the capture
|
|
73
|
+
// boundary, so ordering rows by id makes entryCursors monotonic and the
|
|
74
|
+
// last row's id the true max consumed — the segment boundary the packer
|
|
75
|
+
// advances to is then genuinely the largest id, never skipping a
|
|
76
|
+
// lower-id-but-later-timestamp row. Verified safe: id order == timestamp
|
|
77
|
+
// order in production (A1: 0 divergences / 4413 rows), so text ordering is
|
|
78
|
+
// unchanged in practice.
|
|
79
|
+
const msgStmt = db.prepare("SELECT id, role, content, tool_name, timestamp FROM messages WHERE session_id = ? AND id > ? ORDER BY id");
|
|
80
|
+
// Highest id in the session — used only for the shrink guard below.
|
|
81
|
+
const maxIdStmt = db.prepare("SELECT MAX(id) as m FROM messages WHERE session_id = ?");
|
|
67
82
|
for (const s of sessions) {
|
|
68
83
|
// Skip automated (non-primary) sessions — cron runs are not
|
|
69
84
|
// conversations and would pollute memory. Checked before pulling
|
|
70
85
|
// messages so we don't even read them.
|
|
71
86
|
if (NON_PRIMARY_SOURCES.has(s.source))
|
|
72
87
|
continue;
|
|
73
|
-
const
|
|
74
|
-
|
|
88
|
+
const cursorKey = `hermes:${profile}:${s.id}`;
|
|
89
|
+
const pos = cursors[cursorKey] ?? { cursor: 0, gen: 0 };
|
|
90
|
+
let startCursor = pos.cursor;
|
|
91
|
+
let gen = pos.gen;
|
|
92
|
+
// Shrink guard: if the stored cursor exceeds the session's max id (DB
|
|
93
|
+
// reset/restore), re-read from 0 and bump the generation (fix 8). Cheap
|
|
94
|
+
// MAX(id) probe; the common path (cursor <= max) leaves it untouched.
|
|
95
|
+
if (startCursor > 0) {
|
|
96
|
+
const maxId = maxIdStmt.get(s.id).m ?? 0;
|
|
97
|
+
if (startCursor > maxId) {
|
|
98
|
+
startCursor = 0;
|
|
99
|
+
gen = pos.gen + 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const rows = msgStmt.all(s.id, startCursor);
|
|
103
|
+
// Skip only genuinely empty deltas. Do NOT gate on message count —
|
|
75
104
|
// a short 2-message exchange can carry a real decision. Meaningful-
|
|
76
105
|
// content is gated downstream by the post-denoise 200-char check in
|
|
77
106
|
// nightly.ts, so short-but-dense sessions aren't dropped here.
|
|
@@ -84,6 +113,10 @@ function readHermesSessions(since, hermesHome = HERMES_HOME) {
|
|
|
84
113
|
role: NOISE_ROLES.has(r.role) ? "tool_result" : r.role,
|
|
85
114
|
content: r.content ?? "",
|
|
86
115
|
}));
|
|
116
|
+
// entryCursors are the row ids, monotonic under ORDER BY id — the last
|
|
117
|
+
// is the max consumed id, so segment boundaries and the final advance
|
|
118
|
+
// land exactly on it.
|
|
119
|
+
const entryCursors = rows.map((r) => r.id);
|
|
87
120
|
const endTs = s.ended_at ?? rows[rows.length - 1].timestamp;
|
|
88
121
|
batches.push({
|
|
89
122
|
sessionId: s.id,
|
|
@@ -91,6 +124,10 @@ function readHermesSessions(since, hermesHome = HERMES_HOME) {
|
|
|
91
124
|
sourceAgent: `hermes/${profile}`,
|
|
92
125
|
date: new Date(endTs * 1000).toISOString().slice(0, 10),
|
|
93
126
|
entries,
|
|
127
|
+
cursorKey,
|
|
128
|
+
startCursor,
|
|
129
|
+
generation: gen,
|
|
130
|
+
entryCursors,
|
|
94
131
|
});
|
|
95
132
|
}
|
|
96
133
|
}
|
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