@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.
- package/README.md +28 -0
- package/dist/chunk-5EJ57MSJ.js +2150 -0
- package/dist/chunk-5EJ57MSJ.js.map +1 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +15 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/index.d.ts +710 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
package/dist/index.d.ts
ADDED
|
@@ -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 };
|