@gamaze/hicortex 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)
@@ -12,7 +12,7 @@ import { type DomainDef } from "./domain-classify.js";
12
12
  * Minimum COSINE similarity for a link candidate.
13
13
  *
14
14
  * Calibration (2026-07): measured top-10 neighbor cosine histogram on the
15
- * 2945-memory production corpus (bedrock). Typical top-1 neighbor cosine:
15
+ * ~3000-memory production corpus. Typical top-1 neighbor cosine:
16
16
  * median 0.823, p10 0.743, p90 0.902. Threshold 0.75 combined with the
17
17
  * top-3 cap yields ≈ 2.2 candidate links/memory. The previous value (0.55)
18
18
  * lived on an accidental 1−L2 scale where it required cosine > 0.90 — a
@@ -65,7 +65,7 @@ const CONSOLIDATE_PRUNE_MIN_AGE_DAYS = 90;
65
65
  * Minimum COSINE similarity for a link candidate.
66
66
  *
67
67
  * Calibration (2026-07): measured top-10 neighbor cosine histogram on the
68
- * 2945-memory production corpus (bedrock). Typical top-1 neighbor cosine:
68
+ * ~3000-memory production corpus. Typical top-1 neighbor cosine:
69
69
  * median 0.823, p10 0.743, p90 0.902. Threshold 0.75 combined with the
70
70
  * top-3 cap yields ≈ 2.2 candidate links/memory. The previous value (0.55)
71
71
  * lived on an accidental 1−L2 scale where it required cosine > 0.90 — a
@@ -261,7 +261,7 @@ function extractAgentFlag(args) {
261
261
  // A missing value (end of args) or the next token being another flag is a
262
262
  // typo — never let it silently fall through to the global scope.
263
263
  if (val === undefined || val.startsWith("-")) {
264
- throw new ContextCliError("--agent requires a value, e.g. --agent lenny");
264
+ throw new ContextCliError("--agent requires a value, e.g. --agent alice");
265
265
  }
266
266
  agent = val;
267
267
  i++;
@@ -5,13 +5,13 @@
5
5
  * ---------------
6
6
  * The nightly's legacy `stageDomainCuration` groups PROJECTS into domains and
7
7
  * assigns every memory its project's domain. For an owner whose "projects" are
8
- * often AGENT names (lenny, nano, ...), one agent produces memories spanning
8
+ * often AGENT names (alice, bob, ...), one agent produces memories spanning
9
9
  * many life areas, so life-memories get smeared under the agent. This module
10
10
  * classifies a single memory into life-spheres by its CONTENT, drawn from a
11
11
  * user-curated vocabulary in ~/.hicortex/config.json (`domains`).
12
12
  *
13
13
  * GRADED SCHEMA TAGS (spec 2026-07-07, supersedes the LLM-picked primary from
14
- * PR #152/#153): a memory genuinely spans spheres — "set up bedrock for the
14
+ * PR #152/#153): a memory genuinely spans spheres — "set up the server for the
15
15
  * agent fleet" is both Hardware AND Ventures. The classifier now returns ONLY
16
16
  * the discrete part:
17
17
  * - `tags`: 0..N vocabulary names that genuinely apply, MOST-RELEVANT FIRST
@@ -31,7 +31,7 @@
31
31
  *
32
32
  * The `project` name is passed to the classifier as a HINT (content wins;
33
33
  * project only breaks ties). This rescues terse technical memories from
34
- * projects like raider/hiops/catalyst whose content alone reads as ambiguous.
34
+ * projects whose content alone reads as ambiguous.
35
35
  *
36
36
  * The classifier makes ONE constrained LLM call per memory (via the classify
37
37
  * tier — classifyModel/classifyBaseUrl when configured, else the reflect
@@ -6,13 +6,13 @@
6
6
  * ---------------
7
7
  * The nightly's legacy `stageDomainCuration` groups PROJECTS into domains and
8
8
  * assigns every memory its project's domain. For an owner whose "projects" are
9
- * often AGENT names (lenny, nano, ...), one agent produces memories spanning
9
+ * often AGENT names (alice, bob, ...), one agent produces memories spanning
10
10
  * many life areas, so life-memories get smeared under the agent. This module
11
11
  * classifies a single memory into life-spheres by its CONTENT, drawn from a
12
12
  * user-curated vocabulary in ~/.hicortex/config.json (`domains`).
13
13
  *
14
14
  * GRADED SCHEMA TAGS (spec 2026-07-07, supersedes the LLM-picked primary from
15
- * PR #152/#153): a memory genuinely spans spheres — "set up bedrock for the
15
+ * PR #152/#153): a memory genuinely spans spheres — "set up the server for the
16
16
  * agent fleet" is both Hardware AND Ventures. The classifier now returns ONLY
17
17
  * the discrete part:
18
18
  * - `tags`: 0..N vocabulary names that genuinely apply, MOST-RELEVANT FIRST
@@ -32,7 +32,7 @@
32
32
  *
33
33
  * The `project` name is passed to the classifier as a HINT (content wins;
34
34
  * project only breaks ties). This rescues terse technical memories from
35
- * projects like raider/hiops/catalyst whose content alone reads as ambiguous.
35
+ * projects whose content alone reads as ambiguous.
36
36
  *
37
37
  * The classifier makes ONE constrained LLM call per memory (via the classify
38
38
  * tier — classifyModel/classifyBaseUrl when configured, else the reflect
@@ -2,7 +2,7 @@
2
2
  * Hermes transcript reader — the nightly capture path for Nous Research Hermes.
3
3
  *
4
4
  * Hermes stores conversation in a SQLite state DB, one per profile:
5
- * ~/.hermes/profiles/<profile>/state.db (per-profile agents: lenny, raider, nano)
5
+ * ~/.hermes/profiles/<profile>/state.db (per-profile agents: alice, bob, carol)
6
6
  * ~/.hermes/state.db (global, non-profile setups)
7
7
  *
8
8
  * Schema (relevant columns):
@@ -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[];
@@ -3,7 +3,7 @@
3
3
  * Hermes transcript reader — the nightly capture path for Nous Research Hermes.
4
4
  *
5
5
  * Hermes stores conversation in a SQLite state DB, one per profile:
6
- * ~/.hermes/profiles/<profile>/state.db (per-profile agents: lenny, raider, nano)
6
+ * ~/.hermes/profiles/<profile>/state.db (per-profile agents: alice, bob, carol)
7
7
  * ~/.hermes/state.db (global, non-profile setups)
8
8
  *
9
9
  * Schema (relevant columns):
@@ -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
- const msgStmt = db.prepare("SELECT role, content, tool_name, timestamp FROM messages WHERE session_id = ? ORDER BY timestamp, id");
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 rows = msgStmt.all(s.id);
74
- // Skip only genuinely empty sessions. Do NOT gate on message count —
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
  }
@@ -69,7 +69,7 @@ export declare function renderContextBlock(sections: Record<string, string>): st
69
69
  * into every persona; on a bare fetch (no id) the guard is off (amendment
70
70
  * A2).
71
71
  * - CC passes `false` ALWAYS and deliberately (see the call site): a thin CC
72
- * client auto-upgrades via npx BEFORE bedrock does, so during the upgrade
72
+ * client auto-upgrades via npx BEFORE the server does, so during the upgrade
73
73
  * window it talks to a 0.12 server that cannot hold ANY per-agent config —
74
74
  * global IS the operator's intended state there, and a guard would instead
75
75
  * blank ALL context for every CC session in that window.
@@ -180,7 +180,7 @@ function renderContextBlock(sections) {
180
180
  * into every persona; on a bare fetch (no id) the guard is off (amendment
181
181
  * A2).
182
182
  * - CC passes `false` ALWAYS and deliberately (see the call site): a thin CC
183
- * client auto-upgrades via npx BEFORE bedrock does, so during the upgrade
183
+ * client auto-upgrades via npx BEFORE the server does, so during the upgrade
184
184
  * window it talks to a 0.12 server that cannot hold ANY per-agent config —
185
185
  * global IS the operator's intended state there, and a guard would instead
186
186
  * blank ALL context for every CC session in that window.
@@ -216,7 +216,7 @@ async function fetchContextBlock(cfg) {
216
216
  return null;
217
217
  const data = await resp.json();
218
218
  // CC deliberately passes requireAgentEcho: false (NOT the OC/Hermes old-server
219
- // guard). A thin CC client auto-upgrades via npx BEFORE bedrock does, so
219
+ // guard). A thin CC client auto-upgrades via npx BEFORE the server does, so
220
220
  // mid-upgrade it may hit a 0.12 server that returns global context with no
221
221
  // `agent` echo — and a 0.12 server cannot hold per-agent config, so global is
222
222
  // the intended state. Guarding here would blank ALL CC context in that window.