@gamaze/hicortex 0.13.1 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capture-cursors.d.ts +73 -0
- package/dist/capture-cursors.js +133 -0
- package/dist/capture.d.ts +124 -0
- package/dist/capture.js +386 -0
- package/dist/cli.js +13 -1
- package/dist/hermes-transcript-reader.d.ts +6 -2
- package/dist/hermes-transcript-reader.js +41 -4
- package/dist/mcp-server.js +46 -16
- package/dist/nightly.d.ts +2 -0
- package/dist/nightly.js +224 -176
- package/dist/oc-transcript-reader.d.ts +3 -2
- package/dist/oc-transcript-reader.js +5 -3
- package/dist/pi-transcript-reader.d.ts +5 -8
- package/dist/pi-transcript-reader.js +36 -8
- package/dist/transcript-reader.d.ts +22 -1
- package/dist/transcript-reader.js +47 -14
- package/package.json +1 -1
|
@@ -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>;
|