@remnic/core 9.11.0 → 9.13.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,352 @@
1
+ /**
2
+ * Screen-activity SQLite store (issue #1899).
3
+ *
4
+ * Durable, capture-machine-agnostic store for on-screen text snapshots plus a
5
+ * per-machine sync cursor. Mirrors the LCM store conventions
6
+ * (packages/remnic-core/src/lcm/schema.ts): WAL, a `<name>_meta` schema-version
7
+ * row, `CREATE TABLE IF NOT EXISTS`, and an FTS5 virtual table created
8
+ * separately. better-sqlite3 is synchronous.
9
+ *
10
+ * Snapshots dedup on `(machine, content_hash)` so a re-sync is idempotent, and
11
+ * day queries use half-open [start, end) UTC bounds (AGENTS.md §23).
12
+ */
13
+
14
+ import { mkdirSync } from "node:fs";
15
+ import { mkdir } from "node:fs/promises";
16
+ import path from "node:path";
17
+
18
+ import { openBetterSqlite3, type BetterSqlite3Database } from "../runtime/better-sqlite.js";
19
+ import type { ActivitySnapshot } from "./types.js";
20
+
21
+ const ACTIVITY_SCHEMA_VERSION = 1;
22
+ const MAX_SEARCH_RESULTS = 100;
23
+
24
+ export function activityDatabasePath(memoryDir: string): string {
25
+ return path.join(memoryDir, "state", "activity.sqlite");
26
+ }
27
+
28
+ export async function ensureActivityStateDir(memoryDir: string): Promise<void> {
29
+ await mkdir(path.join(memoryDir, "state"), { recursive: true });
30
+ }
31
+
32
+ export function openActivityDatabase(memoryDir: string): BetterSqlite3Database {
33
+ // Create the state/ dir synchronously first: better-sqlite3 can't open a file
34
+ // in a missing directory, and open() is the sync public entry point (callers
35
+ // don't await ensureActivityStateDir). Idempotent.
36
+ mkdirSync(path.join(memoryDir, "state"), { recursive: true });
37
+ const db = openBetterSqlite3(activityDatabasePath(memoryDir));
38
+ db.pragma("journal_mode = WAL");
39
+ db.pragma("busy_timeout = 5000");
40
+ db.pragma("synchronous = NORMAL");
41
+ applySchema(db);
42
+ return db;
43
+ }
44
+
45
+ /** Apply the activity schema on an already-open handle (test/in-memory use). */
46
+ export function applyActivitySchema(db: BetterSqlite3Database): void {
47
+ applySchema(db);
48
+ }
49
+
50
+ function applySchema(db: BetterSqlite3Database): void {
51
+ db.exec(`
52
+ CREATE TABLE IF NOT EXISTS activity_meta (
53
+ key TEXT PRIMARY KEY,
54
+ value TEXT NOT NULL
55
+ );
56
+
57
+ CREATE TABLE IF NOT EXISTS activity_snapshots (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ machine TEXT NOT NULL,
60
+ captured_at_utc TEXT NOT NULL,
61
+ app_name TEXT NOT NULL,
62
+ window_title TEXT NOT NULL,
63
+ browser_url TEXT,
64
+ text TEXT NOT NULL,
65
+ text_source TEXT NOT NULL,
66
+ content_hash TEXT NOT NULL,
67
+ simhash TEXT
68
+ );
69
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_activity_snapshots_dedup
70
+ ON activity_snapshots(machine, captured_at_utc, content_hash);
71
+ CREATE INDEX IF NOT EXISTS idx_activity_snapshots_time
72
+ ON activity_snapshots(captured_at_utc, id);
73
+
74
+ CREATE TABLE IF NOT EXISTS activity_sync_state (
75
+ machine TEXT PRIMARY KEY,
76
+ cursor TEXT,
77
+ updated_at_utc TEXT NOT NULL
78
+ );
79
+ `);
80
+
81
+ const hasFts = db
82
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='activity_snapshots_fts'")
83
+ .get();
84
+ if (!hasFts) {
85
+ // Standalone FTS5 (not external-content): populated explicitly on insert so
86
+ // it never drifts from the base table.
87
+ db.exec(`
88
+ CREATE VIRTUAL TABLE activity_snapshots_fts USING fts5(
89
+ text, app_name, window_title, browser_url
90
+ );
91
+ `);
92
+ }
93
+
94
+ db.prepare("INSERT OR REPLACE INTO activity_meta (key, value) VALUES ('schema_version', ?)").run(
95
+ String(ACTIVITY_SCHEMA_VERSION),
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Canonicalize a UTC timestamp to `YYYY-MM-DDTHH:MM:SS.sssZ` so day-window
101
+ * range filtering (a TEXT comparison against activityDayWindow bounds) is valid
102
+ * regardless of the input ISO form (trailing `Z` vs `+00:00`, missing millis).
103
+ * A non-parseable value is stored verbatim (an outlier the day filter skips).
104
+ */
105
+ function canonicalizeUtc(iso: string): string {
106
+ const parsed = Date.parse(iso);
107
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : iso;
108
+ }
109
+
110
+ function assertRequiredSnapshotFields(s: ActivitySnapshot): void {
111
+ const required: ReadonlyArray<readonly [string, unknown]> = [
112
+ ["machine", s.machine],
113
+ ["app", s.app],
114
+ ["windowTitle", s.windowTitle],
115
+ ["text", s.text],
116
+ ["textSource", s.textSource],
117
+ ["contentHash", s.contentHash],
118
+ ];
119
+ for (const [field, value] of required) {
120
+ if (typeof value !== "string") {
121
+ // INSERT OR IGNORE would otherwise swallow a NOT NULL failure as if it
122
+ // were a dedup conflict, silently dropping a malformed capture.
123
+ throw new RangeError(`activity: snapshot field "${field}" is required (got ${value === null ? "null" : typeof value}).`);
124
+ }
125
+ }
126
+ if (s.machine.length === 0 || s.contentHash.length === 0) {
127
+ throw new RangeError('activity: snapshot "machine" and "contentHash" must be non-empty.');
128
+ }
129
+ if (s.textSource !== "ax" && s.textSource !== "ocr") {
130
+ // rowToSnapshot maps any non-"ocr" value to "ax"; reject unknown sources at
131
+ // the boundary instead of silently coercing them on read.
132
+ throw new RangeError(`activity: snapshot "textSource" must be "ax" or "ocr" (got "${s.textSource}").`);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Reject an impossible capture timestamp instead of letting Date.parse silently
138
+ * roll it over (e.g. 2026-02-30 → Mar 2, in either `Z` or explicit-offset form),
139
+ * which would file the snapshot under the wrong day. Validates the wall-clock
140
+ * calendar fields in the string directly, independent of the zone designator.
141
+ */
142
+ function assertValidUtcInstant(iso: string, what: string): void {
143
+ const m = iso.match(
144
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](?:0\d:[0-5]\d|1[0-3]:[0-5]\d|14:00))$/,
145
+ );
146
+ if (m === null || !Number.isFinite(Date.parse(iso))) {
147
+ throw new RangeError(`activity: invalid ${what} "${iso}".`);
148
+ }
149
+ const [year, month, day, hour, minute, second] = m.slice(1).map(Number);
150
+ const daysInMonth = month >= 1 && month <= 12 ? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0;
151
+ if (day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59) {
152
+ throw new RangeError(`activity: ${what} "${iso}" is not a real calendar instant.`);
153
+ }
154
+ }
155
+
156
+ /** Narrow a sqlite row object so field reads are checked, not asserted. */
157
+ function isRecord(value: unknown): value is Record<string, unknown> {
158
+ return typeof value === "object" && value !== null;
159
+ }
160
+
161
+ function str(value: unknown): string {
162
+ return typeof value === "string" ? value : "";
163
+ }
164
+
165
+ function optStr(value: unknown): string | undefined {
166
+ return typeof value === "string" && value.length > 0 ? value : undefined;
167
+ }
168
+
169
+ /**
170
+ * Build a safe FTS5 MATCH expression from free text: extract alphanumeric
171
+ * tokens and quote each as a phrase, so punctuation common in captured
172
+ * activity (URLs like github.com/x/pull/412, quotes, bare boolean operators)
173
+ * can never be parsed as FTS5 syntax and make SQLite throw. Tokens are AND-ed
174
+ * (implicit). Returns null when there is nothing to match.
175
+ */
176
+ function ftsMatchFor(query: string): string | null {
177
+ if (typeof query !== "string") return null;
178
+ const tokens = query.match(/[\p{L}\p{N}_]+/gu);
179
+ if (!tokens || tokens.length === 0) return null;
180
+ return tokens.map((token) => `"${token}"`).join(" ");
181
+ }
182
+
183
+ function rowToSnapshot(row: unknown): ActivitySnapshot {
184
+ if (!isRecord(row)) {
185
+ throw new Error("activity store: unexpected non-object row");
186
+ }
187
+ const textSource = row.text_source === "ocr" ? "ocr" : "ax";
188
+ return {
189
+ id: typeof row.id === "number" ? row.id : undefined,
190
+ machine: str(row.machine),
191
+ capturedAtUtc: str(row.captured_at_utc),
192
+ app: str(row.app_name),
193
+ windowTitle: str(row.window_title),
194
+ ...(optStr(row.browser_url) !== undefined ? { browserUrl: optStr(row.browser_url) } : {}),
195
+ text: str(row.text),
196
+ textSource,
197
+ contentHash: str(row.content_hash),
198
+ ...(optStr(row.simhash) !== undefined ? { simhash: optStr(row.simhash) } : {}),
199
+ };
200
+ }
201
+
202
+ export class ActivityStore {
203
+ private readonly db: BetterSqlite3Database;
204
+
205
+ constructor(db: BetterSqlite3Database) {
206
+ this.db = db;
207
+ }
208
+
209
+ static open(memoryDir: string): ActivityStore {
210
+ return new ActivityStore(openActivityDatabase(memoryDir));
211
+ }
212
+
213
+ /**
214
+ * Insert a snapshot, idempotent on (machine, captured_at_utc, content_hash):
215
+ * the same screen content recurring at a *different* time is kept; only an
216
+ * exact re-ingestion of the same capture dedups. Returns `inserted: false`
217
+ * for a duplicate. The FTS row is written only on a real insert (atomically),
218
+ * so it never drifts from the base table.
219
+ */
220
+ insertSnapshot(snapshot: ActivitySnapshot): { inserted: boolean; id: number } {
221
+ // Base row + FTS row must be atomic. Without a transaction, a crash (or an
222
+ // FTS throw) between the two statements leaves a snapshot in the base table
223
+ // with no matching FTS row — silently unsearchable. The transaction rolls
224
+ // back both on any throw.
225
+ const runInsert = this.db.transaction((s: ActivitySnapshot): { inserted: boolean; id: number } => {
226
+ assertValidUtcInstant(s.capturedAtUtc, "capture timestamp");
227
+ assertRequiredSnapshotFields(s);
228
+ const capturedAtUtc = canonicalizeUtc(s.capturedAtUtc);
229
+ const info = this.db
230
+ .prepare(
231
+ `INSERT OR IGNORE INTO activity_snapshots
232
+ (machine, captured_at_utc, app_name, window_title, browser_url, text, text_source, content_hash, simhash)
233
+ VALUES (@machine, @captured_at_utc, @app_name, @window_title, @browser_url, @text, @text_source, @content_hash, @simhash)`,
234
+ )
235
+ .run({
236
+ machine: s.machine,
237
+ captured_at_utc: capturedAtUtc,
238
+ app_name: s.app,
239
+ window_title: s.windowTitle,
240
+ browser_url: s.browserUrl ?? null,
241
+ text: s.text,
242
+ text_source: s.textSource,
243
+ content_hash: s.contentHash,
244
+ simhash: s.simhash ?? null,
245
+ });
246
+ if (info.changes === 0) {
247
+ const existing = this.db
248
+ .prepare("SELECT id FROM activity_snapshots WHERE machine = ? AND captured_at_utc = ? AND content_hash = ?")
249
+ .get(s.machine, capturedAtUtc, s.contentHash);
250
+ const id = isRecord(existing) && typeof existing.id === "number" ? existing.id : -1;
251
+ return { inserted: false, id };
252
+ }
253
+ const id = Number(info.lastInsertRowid);
254
+ this.db
255
+ .prepare(
256
+ `INSERT INTO activity_snapshots_fts (rowid, text, app_name, window_title, browser_url)
257
+ VALUES (?, ?, ?, ?, ?)`,
258
+ )
259
+ .run(id, s.text, s.app, s.windowTitle, s.browserUrl ?? "");
260
+ return { inserted: true, id };
261
+ });
262
+ return runInsert(snapshot);
263
+ }
264
+
265
+ /** Snapshots whose capture instant is in the half-open [start, end) window. */
266
+ listSnapshotsForDay(
267
+ machine: string | null,
268
+ startUtcInclusive: string,
269
+ endUtcExclusive: string,
270
+ ): ActivitySnapshot[] {
271
+ assertValidUtcInstant(startUtcInclusive, "range start");
272
+ assertValidUtcInstant(endUtcExclusive, "range end");
273
+ const start = canonicalizeUtc(startUtcInclusive);
274
+ const end = canonicalizeUtc(endUtcExclusive);
275
+ const rows =
276
+ machine === null
277
+ ? this.db
278
+ .prepare(
279
+ `SELECT * FROM activity_snapshots
280
+ WHERE captured_at_utc >= ? AND captured_at_utc < ?
281
+ ORDER BY captured_at_utc ASC, id ASC`,
282
+ )
283
+ .all(start, end)
284
+ : this.db
285
+ .prepare(
286
+ `SELECT * FROM activity_snapshots
287
+ WHERE machine = ? AND captured_at_utc >= ? AND captured_at_utc < ?
288
+ ORDER BY captured_at_utc ASC, id ASC`,
289
+ )
290
+ .all(machine, start, end);
291
+ return rows.map(rowToSnapshot);
292
+ }
293
+
294
+ getCursor(machine: string): string | null {
295
+ const row = this.db.prepare("SELECT cursor FROM activity_sync_state WHERE machine = ?").get(machine);
296
+ return isRecord(row) && typeof row.cursor === "string" ? row.cursor : null;
297
+ }
298
+
299
+ setCursor(machine: string, cursor: string | null, updatedAtUtc: string = new Date().toISOString()): void {
300
+ this.db
301
+ .prepare(
302
+ `INSERT INTO activity_sync_state (machine, cursor, updated_at_utc)
303
+ VALUES (?, ?, ?)
304
+ ON CONFLICT(machine) DO UPDATE SET cursor = excluded.cursor, updated_at_utc = excluded.updated_at_utc`,
305
+ )
306
+ .run(machine, cursor, updatedAtUtc);
307
+ }
308
+
309
+ /** Full-text search over snapshot text/app/window/url; newest first. */
310
+ searchSnapshots(query: string, limit: number): ActivitySnapshot[] {
311
+ const capped = Number.isInteger(limit) && limit > 0 ? Math.min(limit, MAX_SEARCH_RESULTS) : 20;
312
+ const match = ftsMatchFor(query);
313
+ if (match === null) return [];
314
+ const rows = this.db
315
+ .prepare(
316
+ `SELECT s.* FROM activity_snapshots_fts f
317
+ JOIN activity_snapshots s ON s.id = f.rowid
318
+ WHERE activity_snapshots_fts MATCH ?
319
+ ORDER BY s.captured_at_utc DESC, s.id DESC
320
+ LIMIT ?`,
321
+ )
322
+ .all(match, capped);
323
+ // ftsMatchFor sanitizes the query to quoted alphanumeric phrases, so there
324
+ // is no path to an FTS5 syntax error; a real backend failure (closed handle,
325
+ // missing/corrupt table, disk I/O) propagates rather than reading as empty.
326
+ return rows.map(rowToSnapshot);
327
+ }
328
+
329
+ /** Retention: drop snapshots captured strictly before `cutoffUtc`. */
330
+ pruneOlderThan(cutoffUtc: string): number {
331
+ assertValidUtcInstant(cutoffUtc, "prune cutoff");
332
+ const ids = this.db
333
+ .prepare("SELECT id FROM activity_snapshots WHERE captured_at_utc < ?")
334
+ .all(canonicalizeUtc(cutoffUtc))
335
+ .map((row: unknown) => (isRecord(row) && typeof row.id === "number" ? row.id : -1))
336
+ .filter((id: number) => id >= 0);
337
+ const deleteFts = this.db.prepare("DELETE FROM activity_snapshots_fts WHERE rowid = ?");
338
+ const deleteRow = this.db.prepare("DELETE FROM activity_snapshots WHERE id = ?");
339
+ const tx = this.db.transaction((rowIds: number[]) => {
340
+ for (const id of rowIds) {
341
+ deleteFts.run(id);
342
+ deleteRow.run(id);
343
+ }
344
+ });
345
+ tx(ids);
346
+ return ids.length;
347
+ }
348
+
349
+ close(): void {
350
+ this.db.close();
351
+ }
352
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Screen-activity subsystem — shared types (issue #1899).
3
+ *
4
+ * A third ingestion modality alongside wearables (conversations) and live
5
+ * connectors (documents). Screen text has no speakers and is high-volume, so it
6
+ * gets its own store and day-digest rather than being forced into the wearable
7
+ * conversation shape. Capture daemons live in the à-la-carte `@remnic/capture-screen`
8
+ * package; this core subsystem is host-agnostic and consumes snapshots over a
9
+ * loopback HTTP client.
10
+ *
11
+ * All timestamps are UTC ISO-8601; day bucketing is half-open [start, end).
12
+ */
13
+
14
+ /** One captured on-screen text snapshot (a single window at a single instant). */
15
+ export interface ActivitySnapshot {
16
+ /** Store row id (assigned on insert; absent before persistence). */
17
+ id?: number;
18
+ /** Capture-machine label (disambiguates multi-machine stores). */
19
+ machine: string;
20
+ /** UTC ISO-8601 capture instant. */
21
+ capturedAtUtc: string;
22
+ /** Frontmost application name. */
23
+ app: string;
24
+ /** Frontmost window title. */
25
+ windowTitle: string;
26
+ /** Browser tab URL, when the frontmost window is a known browser. */
27
+ browserUrl?: string;
28
+ /** Extracted visible text (accessibility tree or OCR). */
29
+ text: string;
30
+ /** Where the text came from. */
31
+ textSource: "ax" | "ocr";
32
+ /** SHA-256 of the normalized snapshot content (idempotency key). */
33
+ contentHash: string;
34
+ /** 64-bit SimHash (hex) for near-duplicate detection, when computed. */
35
+ simhash?: string;
36
+ }
37
+
38
+ /** Frontmatter persisted on a rendered day digest. */
39
+ export interface ActivityDayMeta {
40
+ kind: "activity-digest";
41
+ /** Local day, YYYY-MM-DD. */
42
+ date: string;
43
+ /** Machines that contributed snapshots, sorted. */
44
+ machines: string[];
45
+ snapshotCount: number;
46
+ /** SHA-256 of the rendered body (rebuild-idempotency). */
47
+ contentHash: string;
48
+ formatVersion: number;
49
+ }
50
+
51
+ /** A parsed day digest (frontmatter + rendered body). */
52
+ export interface ActivityDayDigest {
53
+ meta: ActivityDayMeta;
54
+ body: string;
55
+ }
56
+
57
+ /** Result of a capture-daemon auth/health probe. */
58
+ export interface ActivitySourceCheck {
59
+ ok: boolean;
60
+ detail?: string;
61
+ }
62
+
63
+ /** One page of snapshots pulled from a capture daemon. */
64
+ export interface ActivitySnapshotPage {
65
+ snapshots: ActivitySnapshot[];
66
+ nextCursor: string | null;
67
+ }
68
+
69
+ /**
70
+ * Client contract for a screen-capture daemon (one per capture machine).
71
+ * Implemented by a later slice (the HTTP source client); defined here so the
72
+ * store and pipeline can be built and tested against a fixture double first.
73
+ */
74
+ export interface ActivitySourceClient {
75
+ /** Stable capture-machine label. */
76
+ machineLabel: string;
77
+ /** Probe connectivity/auth without mutating anything. */
78
+ verify(signal?: AbortSignal): Promise<ActivitySourceCheck>;
79
+ /** Fetch one page of snapshots for a single local day. */
80
+ fetchSnapshots(opts: {
81
+ date: string;
82
+ timezone: string;
83
+ cursor?: string | null;
84
+ signal?: AbortSignal;
85
+ }): Promise<ActivitySnapshotPage>;
86
+ }
package/src/index.ts CHANGED
@@ -1203,10 +1203,10 @@ export {
1203
1203
 
1204
1204
  // ---------------------------------------------------------------------------
1205
1205
  // Wearable transcript subsystem (Limitless / Bee / Omi connectors).
1206
- // Connector packages import the registry + types from here.
1207
1206
  // ---------------------------------------------------------------------------
1208
-
1209
1207
  export * from "./wearables/index.js";
1208
+ export * from "./activity/index.js";
1209
+ export * from "./meetings/index.js";
1210
1210
 
1211
1211
  // ---------------------------------------------------------------------------
1212
1212
  // Shared importer base (issue #568)