@remnic/capture-screen 9.24.0

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,710 @@
1
+ import http from 'node:http';
2
+
3
+ /** Package-wide constants for @remnic/capture-screen. */
4
+ /**
5
+ * Reported by GET /v1/health. Kept in sync with package.json by the release
6
+ * tooling; the health endpoint tolerates drift because the connector never
7
+ * gates on an exact match (it reads `ok`).
8
+ */
9
+ declare const CAPTURE_SCREEN_VERSION = "9.14.0";
10
+ /** Loopback default; capture is local-first (charter). */
11
+ declare const DEFAULT_HOST = "127.0.0.1";
12
+ declare const DEFAULT_PORT = 4341;
13
+ /** Spool schema version, persisted in the `meta` table. */
14
+ declare const SPOOL_SCHEMA_VERSION = 1;
15
+
16
+ /**
17
+ * Error taxonomy for @remnic/capture-screen.
18
+ *
19
+ * Two authored-message classes, mirroring @remnic/capture-audio: configuration
20
+ * problems and caller-correctable input. Both carry operator-safe messages
21
+ * (never foreign error text, never captured screen text, never credentials).
22
+ * The HTTP layer maps CaptureInputError to 400; anything else is a backend
23
+ * fault (500).
24
+ */
25
+ /** Config load/validation failure — surfaced loudly, never silently defaulted. */
26
+ declare class CaptureConfigError extends Error {
27
+ constructor(message: string);
28
+ }
29
+ /** Caller-correctable request/CLI input — maps to HTTP 400. */
30
+ declare class CaptureInputError extends Error {
31
+ constructor(message: string);
32
+ }
33
+
34
+ /**
35
+ * Daemon config (`~/.remnic/capture-screen/screen.json`), created by
36
+ * `remnic-capture-screen init`. Strict and loud: an absent field takes the
37
+ * documented default, but a present-but-invalid value throws CaptureConfigError
38
+ * (no silent defaulting). Ports are integers in [1, 65535]; string arrays
39
+ * (deny-lists, terminal-app globs, redaction patterns) reject non-string
40
+ * members.
41
+ *
42
+ * Deny-lists, terminal-app globs, and redaction patterns here are ADDITIVE to
43
+ * the built-in defaults (see denylist.ts / redact.ts / capture.ts).
44
+ */
45
+ interface DaemonConfig {
46
+ host: string;
47
+ port: number;
48
+ spoolRetentionDays: number;
49
+ simhashThreshold: number;
50
+ dedupTtlSeconds: number;
51
+ sessionGapSeconds: number;
52
+ maxNodes: number;
53
+ maxDwellSeconds: number;
54
+ /** Live capture loop: poll interval (ms) for foreground-change detection. */
55
+ pollIntervalMs: number;
56
+ /** Live capture loop: settle window (ms) after a foreground change. */
57
+ settleMs: number;
58
+ /** Live capture loop: idle re-sample cadence (seconds). */
59
+ idleFallbackSeconds: number;
60
+ /** Additive deny-list globs (checked in addition to the built-in defaults). */
61
+ denyApps: string[];
62
+ denyTitles: string[];
63
+ denyUrls: string[];
64
+ /** Additive terminal-class app globs (route to OCR). */
65
+ terminalApps: string[];
66
+ /** Additive user redaction regex source strings. */
67
+ redactionPatterns: string[];
68
+ }
69
+ declare function defaultDaemonConfig(): DaemonConfig;
70
+ declare function parseDaemonConfig(raw: unknown): DaemonConfig;
71
+ declare function loadDaemonConfig(configPath: string): DaemonConfig;
72
+ declare function serializeDaemonConfig(cfg: DaemonConfig): string;
73
+
74
+ /** Filesystem layout for the capture working directory. */
75
+ interface CapturePaths {
76
+ baseDir: string;
77
+ configPath: string;
78
+ spoolPath: string;
79
+ tokenPath: string;
80
+ pidPath: string;
81
+ logPath: string;
82
+ }
83
+ /** Expand a leading `~` / `~/` to the home directory; other paths pass through. */
84
+ declare function expandTilde(p: string): string;
85
+ /**
86
+ * Root of the capture working directory. `REMNIC_CAPTURE_SCREEN_DIR` overrides
87
+ * the default `~/.remnic/capture-screen` (tests and multi-instance setups point
88
+ * it at a scratch dir). A leading `~` expands to the home directory.
89
+ */
90
+ declare function captureBaseDir(env?: NodeJS.ProcessEnv): string;
91
+ declare function capturePaths(baseDir?: string): CapturePaths;
92
+
93
+ /**
94
+ * Bearer-token lifecycle. The daemon auto-generates a 256-bit token on first
95
+ * use and stores it 0600; a pre-existing file is re-chmod'd 0600 defensively
96
+ * because a world-readable token is a credential leak. The token is REQUIRED on
97
+ * every request (even on loopback) so another local user cannot read captured
98
+ * screen text off 127.0.0.1.
99
+ */
100
+ declare function generateToken(): string;
101
+ declare function loadOrCreateToken(tokenPath: string): string;
102
+ /** Constant-time compare; unequal lengths short-circuit to false. */
103
+ declare function tokensMatch(expected: string, presented: string): boolean;
104
+ /** Parse `Authorization: Bearer <token>`; returns null when absent/malformed. */
105
+ declare function bearerFromHeader(header: string | string[] | undefined): string | null;
106
+
107
+ /**
108
+ * Request-input validation for the HTTP surface. Every failure raises
109
+ * CaptureInputError, which the daemon maps to HTTP 400 — invalid date,
110
+ * timezone, limit, or cursor is rejected loudly, never silently defaulted. The
111
+ * keyset cursor is an opaque base64url token over the (capturedAtUtc, id) tuple
112
+ * the snapshots query orders by, so pagination stays stable across snapshots
113
+ * that share a capture instant.
114
+ */
115
+ /** Validate a YYYY-MM-DD calendar date (rejects e.g. 2026-02-30). */
116
+ declare function parseSnapshotDate(value: string | null | undefined): string;
117
+ /** Validate an IANA timezone by attempting to build a formatter for it. */
118
+ declare function assertValidTimezone(value: string | null | undefined): string;
119
+ /** Absent limit → default; present-but-invalid → 400. */
120
+ declare function parseLimit(value: string | null | undefined): number;
121
+ interface Cursor {
122
+ capturedAtUtc: string;
123
+ id: number;
124
+ }
125
+ declare function encodeCursor(capturedAtUtc: string, id: number): string;
126
+ /** Absent cursor → null (first page); malformed cursor → 400. */
127
+ declare function decodeCursor(value: string | null | undefined): Cursor | null;
128
+
129
+ /**
130
+ * DST-aware local-day window. Inlined from @remnic/core's activity digest
131
+ * (capture-screen is à-la-carte and depends on nothing at runtime). Returns the
132
+ * half-open [startUtc, endUtc) UTC instants bounding a local calendar day in an
133
+ * IANA timezone, correct across spring-forward (skipped midnight) and fall-back
134
+ * (repeated midnight) transitions.
135
+ */
136
+ /** Half-open [startUtc, endUtc) UTC ISO bounds of a local day. */
137
+ declare function activityDayWindow(date: string, timezone: string): {
138
+ startUtc: string;
139
+ endUtc: string;
140
+ };
141
+
142
+ /**
143
+ * 64-bit word-shingle SimHash for near-duplicate screen-text detection.
144
+ * Text is lower-cased and tokenized to Unicode letter/number runs (so CJK,
145
+ * Cyrillic, and other non-ASCII scripts tokenize instead of collapsing to an
146
+ * empty set), then shingled into overlapping 2-word grams. Each gram is hashed
147
+ * with 64-bit FNV-1a; the signed
148
+ * per-bit vote across all grams yields a 64-bit fingerprint whose Hamming
149
+ * distance tracks textual similarity: identical text → distance 0, a small edit
150
+ * → a small distance, unrelated text → a large distance. Everything is BigInt
151
+ * so the full 64 bits are exact.
152
+ */
153
+ /** 64-bit SimHash fingerprint of `text` (0n for empty/whitespace-only text). */
154
+ declare function simhash(text: string): bigint;
155
+ /** Hamming distance between two 64-bit fingerprints (0..64). */
156
+ declare function hammingDistance(a: bigint, b: bigint): number;
157
+ /** Fixed-width 16-char hex rendering (wire/simhash column form). */
158
+ declare function simhashToHex(h: bigint): string;
159
+ declare function simhashFromHex(hex: string): bigint;
160
+
161
+ /**
162
+ * Per-window near-duplicate suppression. Keyed by (app, windowTitle): for each
163
+ * window we remember the last STORED snapshot's SimHash and capture instant. A
164
+ * new snapshot of the same window is stored only when it is meaningfully
165
+ * different (Hamming distance > threshold) OR enough time has elapsed since the
166
+ * last store (ttlSeconds), so a long unchanging window is refreshed periodically
167
+ * while a stream of near-identical scroll states collapses to a few rows.
168
+ * Distinct windows never dedup against each other (independent cache entries).
169
+ *
170
+ * The clock is the snapshot's own capturedAt (passed in as ms), never a
171
+ * wall-clock read — so replay/fixtures are deterministic.
172
+ */
173
+ declare class DedupCache {
174
+ #private;
175
+ constructor(threshold: number, ttlSeconds: number);
176
+ /** Seed the last-stored fingerprint for a window (used to prime from the spool). */
177
+ seed(app: string, windowTitle: string, hash: bigint, atMs: number): void;
178
+ /**
179
+ * Decide whether a snapshot should be stored, updating the cache when it is.
180
+ * First snapshot of a window always stores. A negative elapsed (out-of-order
181
+ * capture) stores defensively rather than dropping data.
182
+ */
183
+ shouldStore(app: string, windowTitle: string, hash: bigint, atMs: number): boolean;
184
+ }
185
+
186
+ /**
187
+ * Capture-time deny-lists, checked FIRST — before any text extraction, hashing,
188
+ * or spool write. A match records NOTHING (not even metadata): the snapshot is
189
+ * dropped whole. Three independent lists, each glob/substring matched
190
+ * case-insensitively: application name, window title, and browser URL. Built-in
191
+ * defaults cover common secret managers and private-browsing windows; the
192
+ * user's config entries are additive.
193
+ */
194
+ /** Secret managers whose windows must never be captured. */
195
+ declare const DEFAULT_DENY_APPS: readonly string[];
196
+ /** Private/incognito window-title heuristics (browsers signal these in the title). */
197
+ declare const DEFAULT_DENY_TITLES: readonly string[];
198
+ /** No default URL denials — URL patterns are user-supplied (site-specific). */
199
+ declare const DEFAULT_DENY_URLS: readonly string[];
200
+ interface DenyLists {
201
+ apps: readonly string[];
202
+ titles: readonly string[];
203
+ urls: readonly string[];
204
+ }
205
+ interface DenyCandidate {
206
+ app: string;
207
+ windowTitle: string;
208
+ browserUrl?: string | null;
209
+ }
210
+ /** Compile a `*`/`?` glob to an anchored, case-insensitive RegExp. */
211
+ declare function globToRegExp(glob: string): RegExp;
212
+ /** True when `value` matches any glob in `patterns` (case-insensitive). */
213
+ declare function matchesAnyGlob(patterns: readonly string[], value: string): boolean;
214
+ /**
215
+ * The first deny rule that fires for this candidate, or null. Built-in defaults
216
+ * are always checked in addition to the user lists. The returned string names
217
+ * the rule (`app:1Password*`, `title:*incognito*`, `url:...`) for the
218
+ * `test-snapshot` diagnostic.
219
+ */
220
+ declare function matchDenyRule(candidate: DenyCandidate, lists: DenyLists): string | null;
221
+
222
+ /**
223
+ * Daemon-side redaction, applied to snapshot text BEFORE it is hashed or
224
+ * written to the spool. Built-in patterns catch US SSNs and payment-card
225
+ * numbers (13–19 digits, optionally space/dash grouped, Luhn-valid); the user's
226
+ * `redactionPatterns` (regex source strings) are applied in addition. Every
227
+ * match is replaced with a fixed placeholder so the redacted text is stable
228
+ * (identical inputs dedup identically).
229
+ */
230
+ declare const REDACTION_PLACEHOLDER = "[REDACTED]";
231
+ /** Compile user regex source strings once; an invalid pattern fails loudly. */
232
+ declare function compileRedactionPatterns(sources: readonly string[]): RegExp[];
233
+ /** Apply built-in (SSN, card) then user redactions to `text`. */
234
+ declare function redactText(text: string, userPatterns?: readonly RegExp[]): string;
235
+
236
+ /**
237
+ * Accessibility-tree text extraction. Walks a macOS AX-tree JSON snapshot and
238
+ * concatenates the visible text, with three safety filters baked in:
239
+ *
240
+ * - AXSecureTextField nodes are skipped entirely (never read a password box),
241
+ * including their subtree.
242
+ * - Off-screen nodes (`offScreen: true`) are skipped with their subtree — text
243
+ * the user cannot see is not "on screen".
244
+ * - Traversal is bounded to `maxNodes` visited nodes, so a pathological tree
245
+ * cannot exhaust memory/CPU; the result is flagged `truncated` when the cap
246
+ * is hit.
247
+ *
248
+ * The shape is intentionally permissive: real AX dumps carry many roles and the
249
+ * text can live on any of value/title/description/label. Unknown fields are
250
+ * ignored.
251
+ */
252
+ declare const SECURE_ROLE = "AXSecureTextField";
253
+ interface AxNode {
254
+ role?: string;
255
+ value?: string;
256
+ title?: string;
257
+ description?: string;
258
+ label?: string;
259
+ offScreen?: boolean;
260
+ children?: AxNode[];
261
+ }
262
+ interface AxExtractResult {
263
+ text: string;
264
+ /** Nodes actually visited (bounded by maxNodes). */
265
+ nodes: number;
266
+ /** True when the maxNodes cap stopped traversal before the tree was exhausted. */
267
+ truncated: boolean;
268
+ }
269
+ /**
270
+ * Extract visible, non-secure text from an AX tree. Iterative DFS with an
271
+ * explicit stack so a deep tree cannot overflow the call stack, and a visited
272
+ * counter that enforces the node cap.
273
+ */
274
+ declare function extractAxText(root: AxNode, maxNodes: number): AxExtractResult;
275
+
276
+ /**
277
+ * SQLite spool — the daemon's local buffer of captured screen snapshots.
278
+ *
279
+ * Uses the built-in `node:sqlite` driver (no native dependency), keeping
280
+ * @remnic/capture-screen à-la-carte: installing it pulls zero extra runtime
281
+ * packages. WAL mode + foreign keys are enabled per connection.
282
+ *
283
+ * Schema (names/semantics fixed by issue #1899):
284
+ * snapshots(id, captured_at_utc, app_name, window_title, browser_url NULL,
285
+ * text, text_source (ax or ocr), content_hash UNIQUE, simhash,
286
+ * superseded_by NULL -> snapshots(id))
287
+ * meta(key, value)
288
+ *
289
+ * `content_hash` is UNIQUE and inserts are INSERT OR IGNORE, so re-ingesting an
290
+ * identical snapshot is a content no-op (kill-9 / replay idempotency).
291
+ * Supersession links the previous non-superseded snapshot of the same
292
+ * (app, window) session to its replacement, so a consumer can skip stale states.
293
+ * The read API pages by a stable (captured_at_utc, id) keyset over a half-open
294
+ * local-day window.
295
+ */
296
+ type TextSource = "ax" | "ocr";
297
+ interface SnapshotInput {
298
+ capturedAtUtc: string;
299
+ app: string;
300
+ windowTitle: string;
301
+ browserUrl?: string | null;
302
+ text: string;
303
+ textSource: TextSource;
304
+ contentHash: string;
305
+ simhash: string;
306
+ }
307
+ interface InsertResult {
308
+ id: number;
309
+ inserted: boolean;
310
+ /** Id of the prior snapshot this insert superseded, or null. */
311
+ supersededId: number | null;
312
+ }
313
+ interface DaemonSnapshot {
314
+ id: number;
315
+ capturedAtUtc: string;
316
+ app: string;
317
+ windowTitle: string;
318
+ browserUrl: string | null;
319
+ text: string;
320
+ textSource: TextSource;
321
+ contentHash: string;
322
+ simhash: string;
323
+ supersededBy: number | null;
324
+ }
325
+ interface SnapshotPage {
326
+ snapshots: DaemonSnapshot[];
327
+ nextCursor: string | null;
328
+ }
329
+ interface QuerySnapshotsOptions {
330
+ date: string;
331
+ timezone: string;
332
+ cursor?: string | null;
333
+ limit: number;
334
+ }
335
+ interface WindowFingerprint {
336
+ app: string;
337
+ windowTitle: string;
338
+ simhash: string;
339
+ capturedAtUtc: string;
340
+ }
341
+ declare class Spool {
342
+ #private;
343
+ constructor(location: string);
344
+ close(): void;
345
+ meta(key: string): string | null;
346
+ setMeta(key: string, value: string): void;
347
+ /**
348
+ * Insert a snapshot. Idempotent by content_hash (INSERT OR IGNORE): a repeat
349
+ * returns the existing row's id with `inserted:false` and performs no
350
+ * supersession. On a genuinely new row, the previous non-superseded snapshot
351
+ * of the same (app, window) captured within `sessionGapSeconds` is marked
352
+ * superseded_by this row.
353
+ */
354
+ insertSnapshot(input: SnapshotInput, sessionGapSeconds: number): InsertResult;
355
+ getSnapshot(id: number): DaemonSnapshot | null;
356
+ countSnapshots(): number;
357
+ /**
358
+ * Snapshots whose capture instant falls in the half-open [start, end) UTC
359
+ * window of the requested local day, paged by the stable (captured_at_utc, id)
360
+ * keyset. The id tiebreak keeps pagination correct across snapshots that
361
+ * share a capture instant.
362
+ */
363
+ querySnapshots(opts: QuerySnapshotsOptions): SnapshotPage;
364
+ /** All snapshots in a local day's window, ordered — the basis for /v1/stats. */
365
+ daySnapshots(date: string, timezone: string): DaemonSnapshot[];
366
+ /** Latest non-superseded fingerprint per (app, window) — primes the dedup cache. */
367
+ latestFingerprints(): WindowFingerprint[];
368
+ /** Retention janitor: drop snapshots older than `days` (cutoff from `nowMs`). Returns rows removed. */
369
+ pruneOlderThan(days: number, nowMs?: number): number;
370
+ }
371
+
372
+ /**
373
+ * Capture-time processing pipeline (pure, hardware-free). A raw candidate —
374
+ * frontmost app/window plus either an AX tree or pre-extracted text — is turned
375
+ * into a decision: dropped by a deny rule, skipped (OCR unavailable / deduped),
376
+ * or a fully-formed spool snapshot. The steps, in order:
377
+ *
378
+ * 1. Deny-lists FIRST — a match records NOTHING (not even metadata).
379
+ * 2. Text: pre-extracted text is used as-is; otherwise the AX tree is walked
380
+ * (secure fields + off-screen nodes excluded, bounded to maxNodes). A
381
+ * terminal-class window, or an AX tree with no visible text, routes to the
382
+ * OCR seam; when OCR is unavailable the snapshot is skipped (reflected in
383
+ * health) rather than crashing.
384
+ * 3. Redaction — SSN/card + user patterns, before hashing or storage.
385
+ * 4. Dedup — per-window SimHash gate (threshold / TTL).
386
+ * 5. Content hash — length-prefixed SHA-256 so control chars can't collide.
387
+ *
388
+ * The OCR step is a seam (an injected callback), so the daemon wires the native
389
+ * helper while tests inject a fake without any macOS binary.
390
+ */
391
+
392
+ /** Terminal-class apps whose windows expose no useful AX text (route to OCR). */
393
+ declare const DEFAULT_TERMINAL_APPS: readonly string[];
394
+ /**
395
+ * True when `app` is a terminal-class window (routes to OCR — terminals expose
396
+ * no useful AX text). `includeDefaults` prepends DEFAULT_TERMINAL_APPS; pass
397
+ * false when `terminalApps` is already the merged list.
398
+ */
399
+ declare function isTerminalApp(app: string, terminalApps: readonly string[], includeDefaults?: boolean): boolean;
400
+ /** A raw capture candidate before processing. Provide `text` OR `ax`. */
401
+ interface CaptureCandidate {
402
+ capturedAtUtc: string;
403
+ app: string;
404
+ windowTitle: string;
405
+ browserUrl?: string | null;
406
+ /** Pre-extracted text (skips AX walking); source defaults to "ax". */
407
+ text?: string;
408
+ textSource?: TextSource;
409
+ /** Accessibility tree to extract from when `text` is absent. */
410
+ ax?: AxNode;
411
+ }
412
+ type CaptureDecision = {
413
+ action: "denied";
414
+ rule: string;
415
+ } | {
416
+ action: "skipped";
417
+ reason: "ocr-unavailable" | "dedup";
418
+ } | {
419
+ action: "store";
420
+ snapshot: SnapshotInput;
421
+ };
422
+ /** OCR seam: returns extracted text for a candidate, or null when unavailable. */
423
+ type OcrFn = (candidate: CaptureCandidate) => string | null;
424
+ interface ContentHashFields {
425
+ capturedAtUtc: string;
426
+ app: string;
427
+ windowTitle: string;
428
+ browserUrl: string | null;
429
+ text: string;
430
+ textSource: string;
431
+ }
432
+ /**
433
+ * SHA-256 over length-prefixed fields so control characters (incl. NUL) in the
434
+ * captured text cannot make two distinct snapshots collide — a collision would
435
+ * silently drop a valid capture via the UNIQUE content_hash + INSERT OR IGNORE.
436
+ */
437
+ declare function contentHash(fields: ContentHashFields): string;
438
+ declare class CaptureProcessor {
439
+ #private;
440
+ constructor(config: DaemonConfig, ocr?: OcrFn);
441
+ /** Seed the dedup cache from prior spool state so restarts don't re-store. */
442
+ seed(app: string, windowTitle: string, simhashHex: string, capturedAtUtc: string): void;
443
+ process(candidate: CaptureCandidate): CaptureDecision;
444
+ }
445
+ interface AppStat {
446
+ app: string;
447
+ seconds: number;
448
+ snapshotCount: number;
449
+ }
450
+ interface DayStats {
451
+ date: string;
452
+ timezone: string;
453
+ snapshotCount: number;
454
+ totalSeconds: number;
455
+ apps: AppStat[];
456
+ }
457
+ /**
458
+ * Per-app time attribution for a day. Each snapshot is credited the gap to the
459
+ * next snapshot (capped at maxDwellSeconds); the final snapshot contributes no
460
+ * dwell (no following instant to bound it). Apps sort by seconds desc, then name.
461
+ */
462
+ declare function computeStats(snapshots: DaemonSnapshot[], date: string, timezone: string, maxDwellSeconds: number): DayStats;
463
+
464
+ /**
465
+ * Native-helper seam. The actual screen reader is a platform Swift binary
466
+ * shipped separately as `@remnic/capture-native-<platform>-<arch>`, exporting a
467
+ * `helperBinaryPath`. This module resolves that binary, spawns it, and parses
468
+ * its JSON — with two hard rules:
469
+ *
470
+ * - A MISSING helper package NEVER surfaces as a raw MODULE_NOT_FOUND: it
471
+ * resolves to `{ binaryPath: null, hint }` with an actionable install hint,
472
+ * and the daemon reports axAvailable/ocrAvailable = false (degraded but
473
+ * honest).
474
+ * - Every helper invocation is bounded and its output validated: a nonzero
475
+ * exit, empty output, or invalid/partial JSON throws a sanitized
476
+ * CaptureInputError, never a crash and never foreign text.
477
+ *
478
+ * `REMNIC_CAPTURE_HELPER_BIN` overrides resolution with an explicit binary path
479
+ * (manual installs and the hardware-free test seam, which points it at a fake
480
+ * script emitting canned JSON).
481
+ */
482
+
483
+ interface HelperResolution {
484
+ /** Absolute path to the helper binary, or null when unavailable. */
485
+ binaryPath: string | null;
486
+ /** Operator-facing install hint when unavailable, else null. */
487
+ hint: string | null;
488
+ }
489
+ /** The npm package that would provide the helper for this platform/arch. */
490
+ declare function helperPackageName(platform?: string, arch?: string): string;
491
+ /**
492
+ * Resolve the helper binary path. Order: explicit env override, then the
493
+ * computed platform package (dynamic import), then unavailable-with-hint. A
494
+ * missing or broken package degrades gracefully — it never throws.
495
+ */
496
+ declare function resolveHelperBinaryPath(env?: NodeJS.ProcessEnv): Promise<HelperResolution>;
497
+ /** Run a helper subcommand and return its parsed JSON, or throw a sanitized error. */
498
+ declare function runHelperCommand(binaryPath: string, args: string[], timeoutMs?: number): Promise<unknown>;
499
+ interface AxSnapshotOptions {
500
+ frontmost?: boolean;
501
+ pid?: number;
502
+ maxNodes?: number;
503
+ }
504
+ /**
505
+ * The helper's `ax-snapshot` payload: the frontmost window's context (app,
506
+ * title, optional browser URL) plus its accessibility tree. The tree is
507
+ * permissive (see AxNode); window context lets the daemon build a capture
508
+ * candidate without a separate frontmost-window query.
509
+ */
510
+ interface AxSnapshot {
511
+ app: string;
512
+ windowTitle: string;
513
+ browserUrl?: string | null;
514
+ tree: AxNode;
515
+ }
516
+ interface OcrWindowOptions {
517
+ frontmost?: boolean;
518
+ windowId?: string;
519
+ }
520
+ /** Thin wrapper over a resolved helper binary. */
521
+ declare class NativeHelper {
522
+ readonly binaryPath: string;
523
+ constructor(binaryPath: string);
524
+ /** `<helper> ax-snapshot [--frontmost|--pid N] [--max-nodes N]` -> window + AX tree JSON. */
525
+ axSnapshot(opts?: AxSnapshotOptions): Promise<AxSnapshot>;
526
+ /** `<helper> ocr-window [--frontmost|--window ID]` -> `{ text }` JSON. */
527
+ ocrWindow(opts?: OcrWindowOptions): Promise<string>;
528
+ }
529
+
530
+ /**
531
+ * Live capture cycle: one snapshot fetched through the native helper and run
532
+ * through the processing pipeline. Shared by `test-snapshot` (which prints the
533
+ * decision without storing) and available to a future capture scheduler.
534
+ *
535
+ * The routing (AX vs OCR) happens here because the native OCR call is async
536
+ * while the processor's OCR seam is sync: a terminal-class or AX-empty window
537
+ * has its OCR text fetched eagerly, then handed to the processor as
538
+ * pre-extracted text. When OCR fails, the candidate is left text-less so the
539
+ * processor skips it (ocr-unavailable) rather than storing empty AX text.
540
+ */
541
+
542
+ declare function captureViaHelper(helper: NativeHelper, processor: CaptureProcessor, config: DaemonConfig, capturedAtUtc: string): Promise<CaptureDecision>;
543
+
544
+ /**
545
+ * `--replay <dir>` ingestion. Feeds synthetic candidate snapshots through the
546
+ * FULL capture pipeline (deny-lists, AX/secure-field extraction, OCR routing,
547
+ * redaction, dedup, supersession) into the spool — the CI-friendly, hardware-
548
+ * free path that exercises every capture-time rule without a native helper.
549
+ *
550
+ * Each `*.json` fixture is either a single candidate or an array of them:
551
+ *
552
+ * {
553
+ * "capturedAtUtc": "2026-07-20T15:00:00.000Z",
554
+ * "app": "Safari",
555
+ * "windowTitle": "Example",
556
+ * "browserUrl": "https://example.com", // optional
557
+ * "text": "already extracted text", // optional; OR provide "ax"
558
+ * "textSource": "ax", // optional; "ax" | "ocr"
559
+ * "ax": { "role": "AXWindow", "children": [ ... ] } // optional AX tree
560
+ * }
561
+ *
562
+ * Candidates are processed in ascending capturedAt order (ties broken by file
563
+ * order) so dedup/TTL behave deterministically regardless of how fixtures are
564
+ * split across files. Ingestion is idempotent by content hash.
565
+ */
566
+
567
+ interface ReplayResult {
568
+ files: number;
569
+ candidates: number;
570
+ stored: number;
571
+ denied: number;
572
+ deduped: number;
573
+ ocrSkipped: number;
574
+ superseded: number;
575
+ /** True when a cooperative cancel (AbortSignal) stopped ingestion early. */
576
+ aborted: boolean;
577
+ }
578
+ /** Commit size between event-loop yields in the responsive ingester. */
579
+ declare const REPLAY_COMMIT_BATCH = 25;
580
+ /** Synchronous ingest: validate the whole directory, then process it all. */
581
+ declare function ingestReplayDir(spool: Spool, dir: string, config: DaemonConfig, ocr?: OcrFn): ReplayResult;
582
+ /**
583
+ * Responsive ingest: validate up front (atomic), then process in bounded
584
+ * batches with an event-loop yield between them so a co-hosted HTTP server
585
+ * stays responsive during a large replay.
586
+ */
587
+ declare function ingestReplayDirResponsive(spool: Spool, dir: string, config: DaemonConfig, options?: {
588
+ signal?: AbortSignal;
589
+ ocr?: OcrFn;
590
+ }): Promise<ReplayResult>;
591
+
592
+ /**
593
+ * Loopback-only HTTP daemon. Serves the spool over three read-only routes:
594
+ *
595
+ * GET /v1/health → liveness + capture status + AX/OCR availability
596
+ * GET /v1/snapshots → snapshots for a local day (keyset paged; wire shape
597
+ * consumed by @remnic/core's ActivityHttpSourceClient)
598
+ * GET /v1/stats → per-app time attribution for a local day
599
+ *
600
+ * Security: capture-screen serves PLAIN HTTP and has no TLS contract, so it
601
+ * refuses to bind a non-loopback host — captured screen text must never cross
602
+ * the network in cleartext. Every request MUST carry `Authorization: Bearer
603
+ * <token>` matching the daemon token, even on loopback, so another local user
604
+ * cannot read snapshots off 127.0.0.1. Input errors are 400; anything
605
+ * unexpected is 500 with no foreign text.
606
+ */
607
+
608
+ interface DaemonDeps {
609
+ spool: Spool;
610
+ config: DaemonConfig;
611
+ token: string;
612
+ /** Live capture status for /v1/health; false until the capture layer runs. */
613
+ capturing?: boolean;
614
+ /** Native-helper capabilities (false when the helper is unavailable). */
615
+ axAvailable?: boolean;
616
+ ocrAvailable?: boolean;
617
+ /** Operator-facing hint surfaced on /v1/health when the helper is missing. */
618
+ helperHint?: string | null;
619
+ }
620
+ interface DaemonHandle {
621
+ server: http.Server;
622
+ host: string;
623
+ port: number;
624
+ url: string;
625
+ close(): Promise<void>;
626
+ }
627
+ declare function createRequestHandler(deps: DaemonDeps): http.RequestListener;
628
+ declare function startDaemon(deps: DaemonDeps): Promise<DaemonHandle>;
629
+
630
+ /**
631
+ * Daemon process control: an atomic, identity-bearing pid file plus liveness
632
+ * probing.
633
+ *
634
+ * The pid file is JSON `{ pid, instanceId, startedAtIso, host, port }` written
635
+ * via a temp-file + rename so a reader never sees a partial write, and reads
636
+ * are tolerant of a concurrent delete. `instanceId` (the spool instance id)
637
+ * lets `stop`/`status` confirm — over the authenticated health endpoint — that
638
+ * the recorded pid really is our daemon before signalling it, which guards
639
+ * against PID reuse. Removal is owner-checked so a late shutdown can't delete a
640
+ * newer daemon's control file.
641
+ */
642
+ interface PidRecord {
643
+ pid: number;
644
+ /** Daemon instance id (spool instance_id) for cross-process identity; null when unknown. */
645
+ instanceId: string | null;
646
+ /** ISO timestamp the record was written. */
647
+ startedAtIso: string;
648
+ /** Effective bound host, when known (so status/stop reach the daemon the CLI actually started). */
649
+ host: string | null;
650
+ /** Effective bound port, when known. */
651
+ port: number | null;
652
+ }
653
+ interface PidWriteOptions {
654
+ instanceId?: string | null;
655
+ startedAtIso?: string;
656
+ host?: string | null;
657
+ port?: number | null;
658
+ }
659
+ /** Atomically write the pid record (temp file + rename) — no partial reads. */
660
+ declare function writePidFile(pidPath: string, pid: number, options?: PidWriteOptions): void;
661
+ /** Read the pid record; a missing file or a partial/concurrent write returns null. */
662
+ declare function readPidRecord(pidPath: string): PidRecord | null;
663
+ /** Convenience accessor: the recorded pid, or null. */
664
+ declare function readPidFile(pidPath: string): number | null;
665
+ /** Liveness via signal 0. ESRCH → gone; EPERM → alive but owned by another user. */
666
+ declare function isProcessAlive(pid: number): boolean;
667
+ /** Remove the pid file unconditionally (stale reclaim). */
668
+ declare function removePidFile(pidPath: string): void;
669
+ /**
670
+ * Remove the pid file only when it still records `pid`. Prevents a late
671
+ * shutdown or `stop` from deleting a NEWER daemon's control file after a
672
+ * restart or PID reuse.
673
+ */
674
+ declare function removePidFileIfOwner(pidPath: string, pid: number): void;
675
+
676
+ /**
677
+ * `remnic-capture-screen` CLI. Subcommands:
678
+ * init | start | stop | status | install-service | logs | test-snapshot
679
+ *
680
+ * `start --replay <dir>` feeds synthetic fixtures through the full capture
681
+ * pipeline + HTTP API (the CI-friendly, hardware-free path). Live capture needs
682
+ * the native helper (@remnic/capture-native-*); where it is absent the daemon
683
+ * still serves the spool and reports axAvailable/ocrAvailable = false.
684
+ *
685
+ * The bearer token comes from the environment (REMNIC_CAPTURE_TOKEN), never
686
+ * argv: a long-lived daemon's argv is world-readable via `ps`/`/proc`, so a
687
+ * token on the command line would let any local account read captured screen
688
+ * text. `--auth-token` is rejected. When the env var is unset, the token file
689
+ * created by `init` is used instead.
690
+ */
691
+
692
+ interface CliIo {
693
+ argv: string[];
694
+ env?: NodeJS.ProcessEnv;
695
+ stdout?: (line: string) => void;
696
+ stderr?: (line: string) => void;
697
+ }
698
+ /**
699
+ * Run replay ingestion as a supervised task AFTER the daemon is ready. Never
700
+ * throws: success/failure is surfaced via the spool's `replay_status` meta
701
+ * (also on /v1/health) and the daemon log, so a slow/failed replay never kills
702
+ * the daemon or retracts its readiness.
703
+ */
704
+ declare function superviseReplay(spool: Spool, replayDir: string, config: DaemonConfig, io: {
705
+ stdout: (l: string) => void;
706
+ stderr: (l: string) => void;
707
+ }, signal?: AbortSignal): Promise<void>;
708
+ declare function runCapture(io: CliIo): Promise<number>;
709
+
710
+ export { type AppStat, type AxExtractResult, type AxNode, type AxSnapshot, type AxSnapshotOptions, CAPTURE_SCREEN_VERSION, type CaptureCandidate, CaptureConfigError, type CaptureDecision, CaptureInputError, type CapturePaths, CaptureProcessor, type CliIo, type Cursor, DEFAULT_DENY_APPS, DEFAULT_DENY_TITLES, DEFAULT_DENY_URLS, DEFAULT_HOST, DEFAULT_PORT, DEFAULT_TERMINAL_APPS, type DaemonConfig, type DaemonDeps, type DaemonHandle, type DaemonSnapshot, type DayStats, DedupCache, type DenyCandidate, type DenyLists, type HelperResolution, type InsertResult, NativeHelper, type OcrFn, type OcrWindowOptions, type PidRecord, type QuerySnapshotsOptions, REDACTION_PLACEHOLDER, REPLAY_COMMIT_BATCH, type ReplayResult, SECURE_ROLE, SPOOL_SCHEMA_VERSION, type SnapshotInput, type SnapshotPage, Spool, type TextSource, type WindowFingerprint, activityDayWindow, assertValidTimezone, bearerFromHeader, captureBaseDir, capturePaths, captureViaHelper, compileRedactionPatterns, computeStats, contentHash, createRequestHandler, decodeCursor, defaultDaemonConfig, encodeCursor, expandTilde, extractAxText, generateToken, globToRegExp, hammingDistance, helperPackageName, ingestReplayDir, ingestReplayDirResponsive, isProcessAlive, isTerminalApp, loadDaemonConfig, loadOrCreateToken, matchDenyRule, matchesAnyGlob, parseDaemonConfig, parseLimit, parseSnapshotDate, readPidFile, readPidRecord, redactText, removePidFile, removePidFileIfOwner, resolveHelperBinaryPath, runCapture, runHelperCommand, serializeDaemonConfig, simhash, simhashFromHex, simhashToHex, startDaemon, superviseReplay, tokensMatch, writePidFile };