@gamaze/hicortex 0.13.0 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -7
- 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/distiller.d.ts +27 -1
- package/dist/distiller.js +81 -9
- package/dist/hermes-transcript-reader.d.ts +6 -2
- package/dist/hermes-transcript-reader.js +41 -4
- package/dist/init.d.ts +8 -0
- package/dist/init.js +15 -2
- package/dist/llm.d.ts +24 -1
- package/dist/llm.js +119 -2
- package/dist/mcp-server.js +57 -19
- package/dist/nightly-status.js +4 -1
- package/dist/nightly.d.ts +2 -0
- package/dist/nightly.js +224 -168
- 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/dist/types.d.ts +20 -0
- 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)
|
package/dist/distiller.d.ts
CHANGED
|
@@ -26,5 +26,31 @@ export declare function extractConversationText(messages: unknown[], redactionCo
|
|
|
26
26
|
* Send filtered conversation to LLM for knowledge extraction.
|
|
27
27
|
* For large transcripts, chunks into segments to avoid overwhelming small models.
|
|
28
28
|
* Returns an array of memory entries to ingest, or empty array if nothing worth extracting.
|
|
29
|
+
*
|
|
30
|
+
* `droppedOut`, when provided, is filled with every entry the substance gate
|
|
31
|
+
* discarded (full text). Callers use it to build a durable audit trail (#156);
|
|
32
|
+
* omitting it leaves gate behaviour unchanged.
|
|
33
|
+
*/
|
|
34
|
+
export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number, droppedOut?: string[]): Promise<string[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Reject ONLY structurally-empty distiller fragments before they become
|
|
37
|
+
* memories (#156). The distiller occasionally emits leftovers that parse into
|
|
38
|
+
* entries but carry no recallable content:
|
|
39
|
+
* - bare section prefixes: "[Specific AI Content:]", "[Facts Learned]"
|
|
40
|
+
* - echoed template placeholders: "[decision]: [reasoning] (2026-07-05)"
|
|
41
|
+
* - pseudo-header bullets: "**Facts Learned:**"
|
|
42
|
+
* - metadata-only lines: "(2026-07-05)"
|
|
43
|
+
*
|
|
44
|
+
* PRECISION OVER RECALL — deliberate trade: the gate rejects only shapes that
|
|
45
|
+
* are structurally empty of content, never on a length or word-count threshold.
|
|
46
|
+
* A kept artifact ("Classification: WORK" style) is cheaply pruned later by the
|
|
47
|
+
* no-fit decay path; a wrongly-dropped genuine memory is unrecoverable. So when
|
|
48
|
+
* in doubt, keep. Consequence documented for the reviewer: metadata lines like
|
|
49
|
+
* "Classification: WORK" now PASS the gate — that is intended.
|
|
50
|
+
*
|
|
51
|
+
* Stripping is scoped and anchored (one leading section prefix, one trailing
|
|
52
|
+
* date stamp), never global, so bracketed payloads ("use [ollama] not
|
|
53
|
+
* [claude-cli]") and content-bearing dates ("deadline moved (2026-08-01)")
|
|
54
|
+
* survive. Stripping affects only this gate's decision, never stored text.
|
|
29
55
|
*/
|
|
30
|
-
export declare function
|
|
56
|
+
export declare function hasMinimalSubstance(entry: string): boolean;
|
package/dist/distiller.js
CHANGED
|
@@ -8,6 +8,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
8
8
|
exports.detectChunkSize = detectChunkSize;
|
|
9
9
|
exports.extractConversationText = extractConversationText;
|
|
10
10
|
exports.distillSession = distillSession;
|
|
11
|
+
exports.hasMinimalSubstance = hasMinimalSubstance;
|
|
11
12
|
const prompts_js_1 = require("./prompts.js");
|
|
12
13
|
const redact_js_1 = require("./redact.js");
|
|
13
14
|
const MAX_TRANSCRIPT_CHARS = 80_000;
|
|
@@ -215,8 +216,12 @@ function extractConversationText(messages, redactionConfig) {
|
|
|
215
216
|
* Send filtered conversation to LLM for knowledge extraction.
|
|
216
217
|
* For large transcripts, chunks into segments to avoid overwhelming small models.
|
|
217
218
|
* Returns an array of memory entries to ingest, or empty array if nothing worth extracting.
|
|
219
|
+
*
|
|
220
|
+
* `droppedOut`, when provided, is filled with every entry the substance gate
|
|
221
|
+
* discarded (full text). Callers use it to build a durable audit trail (#156);
|
|
222
|
+
* omitting it leaves gate behaviour unchanged.
|
|
218
223
|
*/
|
|
219
|
-
async function distillSession(llm, conversation, projectName, date, chunkSizeChars) {
|
|
224
|
+
async function distillSession(llm, conversation, projectName, date, chunkSizeChars, droppedOut) {
|
|
220
225
|
if (conversation.length < MIN_CONVERSATION_CHARS) {
|
|
221
226
|
return [];
|
|
222
227
|
}
|
|
@@ -229,7 +234,10 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
|
|
|
229
234
|
const chunkSize = chunkSizeChars ?? MAX_TRANSCRIPT_CHARS;
|
|
230
235
|
// If transcript fits in one chunk, distill directly (errors propagate)
|
|
231
236
|
if (transcript.length <= chunkSize) {
|
|
232
|
-
|
|
237
|
+
const { entries, dropped } = await distillChunk(llm, transcript, projectName, date);
|
|
238
|
+
if (droppedOut)
|
|
239
|
+
droppedOut.push(...dropped);
|
|
240
|
+
return entries;
|
|
233
241
|
}
|
|
234
242
|
// Chunk large transcripts and distill each segment.
|
|
235
243
|
//
|
|
@@ -248,7 +256,9 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
|
|
|
248
256
|
for (let i = 0; i < chunks.length; i++) {
|
|
249
257
|
console.log(`[hicortex] Chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
|
|
250
258
|
try {
|
|
251
|
-
const entries = await distillChunk(llm, chunks[i], projectName, date);
|
|
259
|
+
const { entries, dropped } = await distillChunk(llm, chunks[i], projectName, date);
|
|
260
|
+
if (droppedOut)
|
|
261
|
+
droppedOut.push(...dropped);
|
|
252
262
|
for (const entry of entries) {
|
|
253
263
|
// Deduplicate by normalized content
|
|
254
264
|
const key = entry.toLowerCase().replace(/\s+/g, " ").slice(0, 100);
|
|
@@ -279,13 +289,17 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
|
|
|
279
289
|
* Distill a single chunk of conversation text.
|
|
280
290
|
*
|
|
281
291
|
* Behaviour contract:
|
|
282
|
-
* - Returns `[]` for legitimate empty results
|
|
283
|
-
* transcript produced no entries). These are
|
|
284
|
-
* processed successfully, there's just
|
|
292
|
+
* - Returns `{entries: [], dropped: []}` for legitimate empty results
|
|
293
|
+
* (NO_EXTRACT, empty LLM response, transcript produced no entries). These are
|
|
294
|
+
* terminal states — the chunk was processed successfully, there's just
|
|
295
|
+
* nothing worth keeping.
|
|
285
296
|
* - Throws for transient errors (LLM unreachable, HTTP 4xx/5xx, timeout, model
|
|
286
297
|
* not found, rate limit). These MUST propagate so the nightly pipeline can
|
|
287
298
|
* distinguish "nothing to extract" from "try again later" and avoid
|
|
288
299
|
* advancing the last-run watermark past sessions it never actually processed.
|
|
300
|
+
*
|
|
301
|
+
* `dropped` carries entries the substance gate rejected (full text) so the
|
|
302
|
+
* caller can surface them in a durable audit trail (#156).
|
|
289
303
|
*/
|
|
290
304
|
async function distillChunk(llm, transcript, projectName, date) {
|
|
291
305
|
const prompt = (0, prompts_js_1.distillation)(projectName, date, transcript);
|
|
@@ -295,11 +309,24 @@ async function distillChunk(llm, transcript, projectName, date) {
|
|
|
295
309
|
// "processed successfully with zero extractions".
|
|
296
310
|
const result = await llm.completeDistill(prompt);
|
|
297
311
|
if (!result)
|
|
298
|
-
return [];
|
|
312
|
+
return { entries: [], dropped: [] };
|
|
299
313
|
if (result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT")) {
|
|
300
|
-
return [];
|
|
314
|
+
return { entries: [], dropped: [] };
|
|
301
315
|
}
|
|
302
|
-
|
|
316
|
+
const parsed = parseDistilledEntries(result);
|
|
317
|
+
const entries = [];
|
|
318
|
+
const dropped = [];
|
|
319
|
+
for (const entry of parsed) {
|
|
320
|
+
(hasMinimalSubstance(entry) ? entries : dropped).push(entry);
|
|
321
|
+
}
|
|
322
|
+
if (dropped.length > 0) {
|
|
323
|
+
for (const d of dropped) {
|
|
324
|
+
const preview = d.length > 120 ? `${d.slice(0, 120)}…` : d;
|
|
325
|
+
console.log(`[hicortex] Substance gate: dropped "${preview}"`);
|
|
326
|
+
}
|
|
327
|
+
console.log(`[hicortex] Substance gate: dropped ${dropped.length}/${parsed.length} content-free fragment(s)`);
|
|
328
|
+
}
|
|
329
|
+
return { entries, dropped };
|
|
303
330
|
}
|
|
304
331
|
/**
|
|
305
332
|
* Split transcript text into chunks at natural boundaries (double newlines).
|
|
@@ -330,6 +357,51 @@ function splitIntoChunks(text, maxChars) {
|
|
|
330
357
|
}
|
|
331
358
|
return chunks.filter((c) => c.length >= MIN_CONVERSATION_CHARS);
|
|
332
359
|
}
|
|
360
|
+
// Entries longer than this trivially carry substance; the cap short-circuits
|
|
361
|
+
// the checks below and bounds every regex to a small input, so no pathological
|
|
362
|
+
// input can make the gate expensive (#156).
|
|
363
|
+
const MAX_GATE_LENGTH = 2000;
|
|
364
|
+
/**
|
|
365
|
+
* Reject ONLY structurally-empty distiller fragments before they become
|
|
366
|
+
* memories (#156). The distiller occasionally emits leftovers that parse into
|
|
367
|
+
* entries but carry no recallable content:
|
|
368
|
+
* - bare section prefixes: "[Specific AI Content:]", "[Facts Learned]"
|
|
369
|
+
* - echoed template placeholders: "[decision]: [reasoning] (2026-07-05)"
|
|
370
|
+
* - pseudo-header bullets: "**Facts Learned:**"
|
|
371
|
+
* - metadata-only lines: "(2026-07-05)"
|
|
372
|
+
*
|
|
373
|
+
* PRECISION OVER RECALL — deliberate trade: the gate rejects only shapes that
|
|
374
|
+
* are structurally empty of content, never on a length or word-count threshold.
|
|
375
|
+
* A kept artifact ("Classification: WORK" style) is cheaply pruned later by the
|
|
376
|
+
* no-fit decay path; a wrongly-dropped genuine memory is unrecoverable. So when
|
|
377
|
+
* in doubt, keep. Consequence documented for the reviewer: metadata lines like
|
|
378
|
+
* "Classification: WORK" now PASS the gate — that is intended.
|
|
379
|
+
*
|
|
380
|
+
* Stripping is scoped and anchored (one leading section prefix, one trailing
|
|
381
|
+
* date stamp), never global, so bracketed payloads ("use [ollama] not
|
|
382
|
+
* [claude-cli]") and content-bearing dates ("deadline moved (2026-08-01)")
|
|
383
|
+
* survive. Stripping affects only this gate's decision, never stored text.
|
|
384
|
+
*/
|
|
385
|
+
function hasMinimalSubstance(entry) {
|
|
386
|
+
const raw = entry.trim();
|
|
387
|
+
if (raw.length > MAX_GATE_LENGTH)
|
|
388
|
+
return true;
|
|
389
|
+
// Strip ONE leading section prefix (anchored + length-bounded, never global).
|
|
390
|
+
let body = raw.replace(/^\[[^\]]{0,80}\]\s*/, "");
|
|
391
|
+
// Strip ONE trailing date stamp (anchored to end).
|
|
392
|
+
body = body.replace(/\(\s*\d{4}-\d{2}-\d{2}\s*\)\s*$/, "");
|
|
393
|
+
// Markdown decoration.
|
|
394
|
+
body = body.replace(/[*_`#>]/g, " ").trim();
|
|
395
|
+
if (!body)
|
|
396
|
+
return false; // metadata-only line or bare section prefix
|
|
397
|
+
if (/:$/.test(body))
|
|
398
|
+
return false; // pseudo-header: "Facts Learned:"
|
|
399
|
+
// Pure placeholder echo — nothing but bracketed tokens and separators,
|
|
400
|
+
// e.g. "[decision]: [reasoning]".
|
|
401
|
+
if (/^(?:\[[^\]]{0,80}\]|[\s:.,;–-])+$/.test(body))
|
|
402
|
+
return false;
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
333
405
|
/**
|
|
334
406
|
* Parse distilled markdown into individual memory entry strings.
|
|
335
407
|
* Each section item becomes a separate memory.
|
|
@@ -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[];
|