@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.
@@ -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)
@@ -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 distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number): Promise<string[]>;
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
- return distillChunk(llm, transcript, projectName, date);
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 (NO_EXTRACT, empty LLM response,
283
- * transcript produced no entries). These are terminal states — the chunk was
284
- * processed successfully, there's just nothing worth keeping.
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
- return parseDistilledEntries(result);
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[];