@gamaze/hicortex 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Per-session capture cursors (#189).
3
+ *
4
+ * The client is the source of truth for "how much of each session has already
5
+ * been captured". A cursor is advanced ONLY after the server confirms the
6
+ * corresponding segment(s) were ingested, so a crash between POST and cursor
7
+ * write can at worst cause a bounded, idempotent re-send — never silent loss.
8
+ *
9
+ * Storage: a SEPARATE small file `<hicortex-home>/capture-cursors.json`
10
+ * (NOT state.json — that file carries the large moduleIndex, and per-session
11
+ * whole-file rewrites there would be an avoidable corruption/IO surface). Same
12
+ * temp+rename atomic write discipline as state.ts.
13
+ *
14
+ * Cursor unit is reader-defined:
15
+ * - JSONL readers (CC/Pi/OC): count of successfully-PARSED entries consumed.
16
+ * - Hermes: max `messages.id` consumed (INTEGER PRIMARY KEY AUTOINCREMENT —
17
+ * never reused, strictly increasing).
18
+ *
19
+ * `gen` (generation) is bumped by a reader's shrink-guard when a source file is
20
+ * truncated/rotated below the stored cursor. It is woven into the segment id
21
+ * (`g<gen>.<start>-<end>`) so post-reset segments can NEVER collide with a
22
+ * pre-reset id on the server's content-blind segment-exact dedup — a collision
23
+ * there would be silent LOSS, not the intended dup-over-loss (#189 review, fix 8).
24
+ *
25
+ * Keys are `<prefix>:<sessionId>`:
26
+ * cc:<sid> pi:<sid> oc:<agentId>:<sid> hermes:<profile>:<sid>
27
+ */
28
+ export interface CursorEntry {
29
+ /** Reader-defined cursor value (entry count for JSONL, max id for Hermes). */
30
+ cursor: number;
31
+ /** Shrink-guard generation — woven into segment ids to avoid post-reset id collisions. */
32
+ gen: number;
33
+ /** ISO timestamp of the last advance — used for 90-day pruning. */
34
+ updated: string;
35
+ }
36
+ export type CursorFile = Record<string, CursorEntry>;
37
+ /** Snapshot of a session's captured position, passed to the readers. */
38
+ export interface CursorPosition {
39
+ cursor: number;
40
+ gen: number;
41
+ }
42
+ /** Map of cursor key → captured position. */
43
+ export type CursorMap = Record<string, CursorPosition>;
44
+ /** Load the cursor file. Missing/corrupt → empty (every key defaults to 0). */
45
+ export declare function loadCursors(stateDir?: string): CursorFile;
46
+ /**
47
+ * Atomically persist the cursor file (write-temp + rename). THROWS on failure —
48
+ * a persistent inability to record cursors must surface as a transient failure
49
+ * (hold the watermark), never a silent warn-and-continue that re-captures the
50
+ * same content every night (#189 review, fix 7).
51
+ */
52
+ export declare function saveCursors(file: CursorFile, stateDir?: string): void;
53
+ /**
54
+ * A read-once, write-on-advance cursor store. Single-flight (see
55
+ * acquireCaptureLock in capture.ts) guarantees no concurrent writer, so an
56
+ * in-memory copy flushed atomically on each advance is safe.
57
+ */
58
+ export interface CursorStore {
59
+ /** Current position for `key` ({cursor:0, gen:0} when never captured). */
60
+ get(key: string): CursorPosition;
61
+ /** Advance `key` to `cursor` at `gen` and persist. Throws if the write fails. */
62
+ advance(key: string, cursor: number, gen: number): void;
63
+ /** Snapshot of key → position for passing to readers. */
64
+ map(): CursorMap;
65
+ }
66
+ /** Open a file-backed cursor store for `stateDir`. */
67
+ export declare function openCursorStore(stateDir?: string): CursorStore;
68
+ /**
69
+ * Drop cursor entries older than `days` (default 90). Best-effort: a prune-write
70
+ * failure is logged, not thrown (prune runs only after a clean nightly and must
71
+ * not fail the run). Returns the number pruned.
72
+ */
73
+ export declare function pruneCursors(stateDir?: string, days?: number): number;
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ /**
3
+ * Per-session capture cursors (#189).
4
+ *
5
+ * The client is the source of truth for "how much of each session has already
6
+ * been captured". A cursor is advanced ONLY after the server confirms the
7
+ * corresponding segment(s) were ingested, so a crash between POST and cursor
8
+ * write can at worst cause a bounded, idempotent re-send — never silent loss.
9
+ *
10
+ * Storage: a SEPARATE small file `<hicortex-home>/capture-cursors.json`
11
+ * (NOT state.json — that file carries the large moduleIndex, and per-session
12
+ * whole-file rewrites there would be an avoidable corruption/IO surface). Same
13
+ * temp+rename atomic write discipline as state.ts.
14
+ *
15
+ * Cursor unit is reader-defined:
16
+ * - JSONL readers (CC/Pi/OC): count of successfully-PARSED entries consumed.
17
+ * - Hermes: max `messages.id` consumed (INTEGER PRIMARY KEY AUTOINCREMENT —
18
+ * never reused, strictly increasing).
19
+ *
20
+ * `gen` (generation) is bumped by a reader's shrink-guard when a source file is
21
+ * truncated/rotated below the stored cursor. It is woven into the segment id
22
+ * (`g<gen>.<start>-<end>`) so post-reset segments can NEVER collide with a
23
+ * pre-reset id on the server's content-blind segment-exact dedup — a collision
24
+ * there would be silent LOSS, not the intended dup-over-loss (#189 review, fix 8).
25
+ *
26
+ * Keys are `<prefix>:<sessionId>`:
27
+ * cc:<sid> pi:<sid> oc:<agentId>:<sid> hermes:<profile>:<sid>
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.loadCursors = loadCursors;
31
+ exports.saveCursors = saveCursors;
32
+ exports.openCursorStore = openCursorStore;
33
+ exports.pruneCursors = pruneCursors;
34
+ const paths_js_1 = require("./paths.js");
35
+ const node_fs_1 = require("node:fs");
36
+ const node_path_1 = require("node:path");
37
+ const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
38
+ const CURSORS_FILE = "capture-cursors.json";
39
+ /** Coerce a stored cursor value to a valid non-negative integer (fix 13). */
40
+ function sanitizeCursor(raw, key) {
41
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) {
42
+ return Math.floor(raw);
43
+ }
44
+ console.warn(`[hicortex] capture-cursors: ignoring invalid cursor for ${key} (${JSON.stringify(raw)}) — treating as 0`);
45
+ return 0;
46
+ }
47
+ function sanitizeGen(raw) {
48
+ return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 0;
49
+ }
50
+ /** Load the cursor file. Missing/corrupt → empty (every key defaults to 0). */
51
+ function loadCursors(stateDir = HICORTEX_HOME) {
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(stateDir, CURSORS_FILE), "utf-8"));
55
+ }
56
+ catch {
57
+ return {};
58
+ }
59
+ if (!parsed || typeof parsed !== "object")
60
+ return {};
61
+ const out = {};
62
+ for (const [key, val] of Object.entries(parsed)) {
63
+ if (!val || typeof val !== "object")
64
+ continue;
65
+ const v = val;
66
+ out[key] = {
67
+ cursor: sanitizeCursor(v.cursor, key),
68
+ gen: sanitizeGen(v.gen),
69
+ updated: typeof v.updated === "string" ? v.updated : new Date().toISOString(),
70
+ };
71
+ }
72
+ return out;
73
+ }
74
+ /**
75
+ * Atomically persist the cursor file (write-temp + rename). THROWS on failure —
76
+ * a persistent inability to record cursors must surface as a transient failure
77
+ * (hold the watermark), never a silent warn-and-continue that re-captures the
78
+ * same content every night (#189 review, fix 7).
79
+ */
80
+ function saveCursors(file, stateDir = HICORTEX_HOME) {
81
+ (0, node_fs_1.mkdirSync)(stateDir, { recursive: true });
82
+ const path = (0, node_path_1.join)(stateDir, CURSORS_FILE);
83
+ const tmp = `${path}.tmp`;
84
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(file, null, 2));
85
+ (0, node_fs_1.renameSync)(tmp, path);
86
+ }
87
+ /** Open a file-backed cursor store for `stateDir`. */
88
+ function openCursorStore(stateDir = HICORTEX_HOME) {
89
+ const file = loadCursors(stateDir);
90
+ return {
91
+ get(key) {
92
+ const e = file[key];
93
+ return { cursor: e?.cursor ?? 0, gen: e?.gen ?? 0 };
94
+ },
95
+ advance(key, cursor, gen) {
96
+ file[key] = { cursor, gen, updated: new Date().toISOString() };
97
+ saveCursors(file, stateDir); // throws on failure — caller holds the watermark
98
+ },
99
+ map() {
100
+ const out = {};
101
+ for (const [k, v] of Object.entries(file))
102
+ out[k] = { cursor: v.cursor, gen: v.gen };
103
+ return out;
104
+ },
105
+ };
106
+ }
107
+ /**
108
+ * Drop cursor entries older than `days` (default 90). Best-effort: a prune-write
109
+ * failure is logged, not thrown (prune runs only after a clean nightly and must
110
+ * not fail the run). Returns the number pruned.
111
+ */
112
+ function pruneCursors(stateDir = HICORTEX_HOME, days = 90) {
113
+ const file = loadCursors(stateDir);
114
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
115
+ let pruned = 0;
116
+ for (const [key, entry] of Object.entries(file)) {
117
+ const t = Date.parse(entry.updated);
118
+ if (!Number.isNaN(t) && t < cutoff) {
119
+ delete file[key];
120
+ pruned++;
121
+ }
122
+ }
123
+ if (pruned > 0) {
124
+ try {
125
+ saveCursors(file, stateDir);
126
+ }
127
+ catch (err) {
128
+ console.warn(`[hicortex] capture-cursors prune write failed: ${err instanceof Error ? err.message : String(err)}`);
129
+ return 0;
130
+ }
131
+ }
132
+ return pruned;
133
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Incremental, cursor-aware capture loop (#189).
3
+ *
4
+ * Extracted from the two near-identical loops that lived in nightly.ts (server
5
+ * and client mode). Both now share this logic: pack each session's delta into
6
+ * ordered segments below the server's distill cap, POST them in order with a
7
+ * deterministic `segment_id`, and advance the per-session cursor ONLY after
8
+ * server-confirmed success — so a multi-day session grows across nights with no
9
+ * loss and no silent truncation.
10
+ *
11
+ * The POST transport is injected (`post`) so the mode-specific bits (localhost
12
+ * vs remote URL, Authorization header, timeout) stay in nightly.ts and the
13
+ * multi-night simulation can run as a pure unit test with no HTTP listener.
14
+ */
15
+ import type { TranscriptBatch } from "./transcript-reader.js";
16
+ import type { CursorStore } from "./capture-cursors.js";
17
+ /**
18
+ * Max denoised chars per segment. Kept below the server's 80K distill cap
19
+ * (distiller.ts MAX_TRANSCRIPT_CHARS) with ~20K headroom so NO capture path can
20
+ * hit the silent truncation. LOAD-BEARING for #189 recovery: a re-ingested
21
+ * week-long session is re-sliced into ≤60K segments here instead of being
22
+ * truncated at 80K server-side. (Judgment constant — tunable later.)
23
+ */
24
+ export declare const SEGMENT_MAX_CHARS = 60000;
25
+ /**
26
+ * Minimum denoised chars for a FRESH whole session (startCursor 0) to be worth
27
+ * capturing — mirrors the long-standing pre-#189 200-char degenerate-session
28
+ * gate. It is applied ONLY to a whole-session capture that denoises to a single
29
+ * sub-200 segment. A delta beyond cursor 0 is always sent, however small: a
30
+ * session's concluding tail must never be held back, because once the session
31
+ * stops growing its mtime never re-crosses the watermark and the tail would be
32
+ * lost forever (#189 review, fix 5).
33
+ */
34
+ export declare const MIN_SEGMENT_CHARS = 200;
35
+ /** One packed, ready-to-POST segment of a session's delta. */
36
+ export interface Segment {
37
+ /** Denoised text body of the POST. */
38
+ text: string;
39
+ /** Cursor value the segment starts at. */
40
+ segStart: number;
41
+ /** Cursor value the segment ends at. */
42
+ segEnd: number;
43
+ /**
44
+ * Disambiguator for hard-split pieces of a single oversized entry (A2). Empty
45
+ * for normal segments; ".p0", ".p1", … when one entry is split mid-text.
46
+ * Keeps the server's `<sid>#<segment_id>#<i>` keys distinct so no piece's
47
+ * memories collide on the UNIQUE index.
48
+ */
49
+ idSuffix: string;
50
+ }
51
+ /** The wire body for POST /distill. */
52
+ export interface DistillBody {
53
+ text: string;
54
+ source_agent: string;
55
+ project: string;
56
+ session_id: string;
57
+ segment_id: string;
58
+ session_date: string;
59
+ privacy: string;
60
+ }
61
+ /** Normalized POST result the caller's transport returns. */
62
+ export interface PostResult {
63
+ status: number;
64
+ distilled?: number;
65
+ dropped?: string[];
66
+ skipped?: boolean;
67
+ error?: string;
68
+ }
69
+ export type PostFn = (body: DistillBody) => Promise<PostResult>;
70
+ export interface CaptureOptions {
71
+ post: PostFn;
72
+ cursorStore: CursorStore;
73
+ dryRun?: boolean;
74
+ /** Segment size cap; defaults to SEGMENT_MAX_CHARS. Lowered in tests. */
75
+ segmentMaxChars?: number;
76
+ }
77
+ export interface CaptureResult {
78
+ memoriesIngested: number;
79
+ sessionsSent: number;
80
+ hadTransientFailure: boolean;
81
+ /**
82
+ * Set when the loop stopped early on a terminal server response: "limit"
83
+ * (429 memory cap) or "auth" (401). The caller decides watermark handling.
84
+ */
85
+ stopped?: "limit" | "auth";
86
+ }
87
+ /**
88
+ * Split an already-denoised string into ≤maxChars pieces (A2 hard-split).
89
+ * Prefers paragraph, then line, then hard boundaries — mirrors the distiller's
90
+ * own splitIntoChunks, but WITHOUT its <200-char drop (every piece must survive,
91
+ * dup-over-loss).
92
+ */
93
+ export declare function hardSplitText(text: string, maxChars?: number): string[];
94
+ /**
95
+ * Pack a session's delta entries into ordered ≤maxChars segments.
96
+ *
97
+ * Sizing uses per-entry denoise lengths plus the "\n\n" joiners (A8) so the
98
+ * estimate matches what the server receives; the actual body is a re-denoise of
99
+ * the grouped entries (extractConversationText) so cleaning/redaction stay
100
+ * coherent. A single entry larger than maxChars is emitted as its own run of
101
+ * hard-split pieces (A2).
102
+ */
103
+ export declare function packSegments(entries: unknown[], startCursor: number, entryCursors: number[], maxChars?: number): Segment[];
104
+ /**
105
+ * Capture a list of session delta batches: pack, POST in order, advance
106
+ * per-session cursors on success. Segments of one session POST in order; the
107
+ * first hard failure stops THAT session (cursor holds at the last confirmed
108
+ * boundary) while other sessions continue. A 429/401 stops the whole loop.
109
+ */
110
+ export declare function captureBatches(batches: TranscriptBatch[], opts: CaptureOptions): Promise<CaptureResult>;
111
+ /**
112
+ * Acquire an exclusive capture lock for `stateDir`. Returns a release function,
113
+ * or null if another LIVE, non-stale run holds it after waiting up to `waitMs`.
114
+ *
115
+ * Staleness = dead pid OR lockfile mtime older than LOCK_TTL_MS. A stale lock is
116
+ * reclaimed (with a re-verify + O_EXCL re-race to narrow the TOCTOU window,
117
+ * fix 12). `waitMs` lets the full nightly wait out a transient `--capture-only`
118
+ * overlap instead of dropping the night's capture (fix 10); pass 0 to fail fast.
119
+ *
120
+ * This stops a `nightly` and a `nightly --capture-only` (an encouraged workflow)
121
+ * from running the capture loop concurrently, which would race cursor writes and
122
+ * emit divergent segment boundaries → real duplication.
123
+ */
124
+ export declare function acquireCaptureLock(stateDir: string, waitMs?: number): Promise<(() => void) | null>;