@remnic/core 9.10.0 → 9.12.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,429 @@
1
+ /**
2
+ * Deterministic screen-activity day-digest renderer (issue #1899).
3
+ *
4
+ * Renders a day's snapshots into markdown with YAML frontmatter, placed at
5
+ * `<memoryDir>/activity/<date>.md` — outside the memory scan roots but inside
6
+ * the QMD collection root (searchable, never auto-recalled). No LLM: the body
7
+ * is a pure, byte-identical function of its inputs so an unchanged day skips
8
+ * rewrite by contentHash. Day bucketing is DST-aware and half-open
9
+ * [start, end) (AGENTS.md §23); sort keys are total with stable tiebreakers
10
+ * (§12).
11
+ */
12
+
13
+ import { createHash } from "node:crypto";
14
+ import path from "node:path";
15
+
16
+ import type { ActivityDayDigest, ActivityDayMeta, ActivitySnapshot } from "./types.js";
17
+
18
+ export const ACTIVITY_DIGEST_FORMAT_VERSION = 1;
19
+ export const ACTIVITY_DIR_NAME = "activity";
20
+
21
+ /** Attribute at most this much dwell to a single snapshot (idle gaps capped). */
22
+ const MAX_DWELL_MS = 15 * 60_000;
23
+ /** Notable-excerpt caps. */
24
+ const NOTABLE_MAX_WINDOWS = 10;
25
+ const NOTABLE_EXCERPT_CHARS = 280;
26
+
27
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
28
+
29
+ export function isValidActivityDate(date: string): boolean {
30
+ if (typeof date !== "string" || !DATE_PATTERN.test(date)) return false;
31
+ // Reject impossible calendar days (e.g. 2026-02-30, 2026-13-01): the UTC
32
+ // round-trip must reproduce the same Y-M-D, else Date normalized an overflow.
33
+ const parsed = new Date(`${date}T00:00:00Z`);
34
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === date;
35
+ }
36
+
37
+ export function activityDigestPath(memoryDir: string, date: string): string {
38
+ if (!isValidActivityDate(date)) {
39
+ // Never interpolate an unvalidated date into a filesystem path (a `../`
40
+ // would escape <memoryDir>/activity/). Reject loudly.
41
+ throw new RangeError(`Invalid activity date "${date}"; expected YYYY-MM-DD.`);
42
+ }
43
+ return path.join(memoryDir, ACTIVITY_DIR_NAME, `${date}.md`);
44
+ }
45
+
46
+ // ── DST-aware local-day window ──────────────────────────────────────────────
47
+
48
+ function timezoneOffsetIso(instant: Date, timezone: string): string {
49
+ const parts = new Intl.DateTimeFormat("en-US", {
50
+ timeZone: timezone,
51
+ timeZoneName: "longOffset",
52
+ }).formatToParts(instant);
53
+ const name = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
54
+ const match = name.match(/GMT([+-]\d{2}:\d{2})?/);
55
+ return match?.[1] ?? "+00:00";
56
+ }
57
+
58
+ function assertValidTimezone(timezone: string): void {
59
+ try {
60
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone });
61
+ } catch {
62
+ throw new RangeError(`Invalid IANA timezone "${timezone}" for the activity digest.`);
63
+ }
64
+ }
65
+
66
+ function zonedDayStartIso(date: string, timezone: string): string {
67
+ // The first UTC instant whose local wall-clock is this date at 00:00. Probe
68
+ // several instants across the day to collect every offset in play; keep an
69
+ // offset only if constructing local midnight with it lands back on that same
70
+ // offset (so it is genuinely local midnight, not a wall-clock that never
71
+ // occurred), then take the EARLIEST such instant — the FIRST 00:00 across a
72
+ // DST fall-back that repeats local midnight.
73
+ // Probe the previous UTC day too: for zones east of UTC the requested date's
74
+ // UTC instants all fall after local midnight, so the pre-transition offset
75
+ // valid at the real local 00:00 is only observable on the prior UTC day.
76
+ const prevDate = shiftIsoDate(date, -1);
77
+ const probeOffsets = new Set(
78
+ [
79
+ `${prevDate}T12:00:00Z`,
80
+ `${prevDate}T23:00:00Z`,
81
+ `${date}T00:00:00Z`,
82
+ `${date}T12:00:00Z`,
83
+ `${date}T23:00:00Z`,
84
+ ].map((iso) => timezoneOffsetIso(new Date(iso), timezone)),
85
+ );
86
+ let best: number | null = null;
87
+ for (const offset of probeOffsets) {
88
+ const candidate = Date.parse(`${date}T00:00:00${offset}`);
89
+ if (!Number.isFinite(candidate)) continue;
90
+ // Reject an offset whose local midnight does not actually occur (the wall
91
+ // clock skipped by a spring-forward): the offset in effect at the candidate
92
+ // instant must be the same offset we used to build it.
93
+ if (timezoneOffsetIso(new Date(candidate), timezone) !== offset) continue;
94
+ if (best === null || candidate < best) best = candidate;
95
+ }
96
+ if (best === null) {
97
+ // Local midnight was skipped by a spring-forward at 00:00. Advance to the
98
+ // first local wall-clock minute on this date that actually exists (never
99
+ // backdating to a 00:00 that never occurred), scanning forward up to 3h.
100
+ for (let minute = 1; minute <= 180 && best === null; minute++) {
101
+ const hh = String(Math.floor(minute / 60)).padStart(2, "0");
102
+ const mm = String(minute % 60).padStart(2, "0");
103
+ for (const offset of probeOffsets) {
104
+ const candidate = Date.parse(`${date}T${hh}:${mm}:00${offset}`);
105
+ if (!Number.isFinite(candidate)) continue;
106
+ if (timezoneOffsetIso(new Date(candidate), timezone) !== offset) continue;
107
+ if (best === null || candidate < best) best = candidate;
108
+ }
109
+ }
110
+ }
111
+ if (best === null) {
112
+ // Degenerate safety net: use the noon-derived offset for a deterministic start.
113
+ const noon = timezoneOffsetIso(new Date(`${date}T12:00:00Z`), timezone);
114
+ best = Date.parse(`${date}T00:00:00${noon}`);
115
+ }
116
+ if (best === null || !Number.isFinite(best)) {
117
+ throw new RangeError(`activity: could not resolve a local day start for "${date}" in "${timezone}".`);
118
+ }
119
+ return new Date(best).toISOString();
120
+ }
121
+
122
+ function shiftIsoDate(date: string, days: number): string {
123
+ const parsed = new Date(`${date}T00:00:00Z`);
124
+ parsed.setUTCDate(parsed.getUTCDate() + days);
125
+ return parsed.toISOString().slice(0, 10);
126
+ }
127
+
128
+ function nextIsoDate(date: string): string {
129
+ return shiftIsoDate(date, 1);
130
+ }
131
+
132
+ /** Half-open [start, end) UTC ISO bounds of a local day. */
133
+ export function activityDayWindow(date: string, timezone: string): { startUtc: string; endUtc: string } {
134
+ if (!isValidActivityDate(date)) {
135
+ throw new RangeError(`Invalid activity date "${date}"; expected a real YYYY-MM-DD day.`);
136
+ }
137
+ assertValidTimezone(timezone);
138
+ return {
139
+ startUtc: new Date(zonedDayStartIso(date, timezone)).toISOString(),
140
+ endUtc: new Date(zonedDayStartIso(nextIsoDate(date), timezone)).toISOString(),
141
+ };
142
+ }
143
+
144
+ // ── Rendering ───────────────────────────────────────────────────────────────
145
+
146
+ function sortedByTime(snapshots: ActivitySnapshot[]): ActivitySnapshot[] {
147
+ return [...snapshots].sort((a, b) => {
148
+ // Compare by parsed instant, not raw string, so mixed valid ISO forms
149
+ // (Z vs offset, varying precision) still order chronologically.
150
+ const at = Date.parse(a.capturedAtUtc);
151
+ const bt = Date.parse(b.capturedAtUtc);
152
+ if (at !== bt && Number.isFinite(at) && Number.isFinite(bt)) return at < bt ? -1 : 1;
153
+ const aid = a.id ?? 0;
154
+ const bid = b.id ?? 0;
155
+ if (aid < bid) return -1;
156
+ if (aid > bid) return 1;
157
+ // Unsaved snapshots (pre-store) both default to id 0; fall back to the
158
+ // content hash (their dedup identity), then app/window, for a total order.
159
+ if (a.contentHash !== b.contentHash) return a.contentHash < b.contentHash ? -1 : 1;
160
+ if (a.app !== b.app) return a.app < b.app ? -1 : 1;
161
+ if (a.windowTitle !== b.windowTitle) return a.windowTitle < b.windowTitle ? -1 : 1;
162
+ return 0;
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Per-snapshot dwell (gap to that machine's next snapshot, capped), scoped per
168
+ * capture machine so an interleaved snapshot from another machine can't steal
169
+ * or truncate a snapshot's dwell (multi-machine days).
170
+ */
171
+ function computeDwell(snapshots: ActivitySnapshot[]): Map<ActivitySnapshot, number> {
172
+ const byMachine = new Map<string, ActivitySnapshot[]>();
173
+ for (const snapshot of snapshots) {
174
+ const list = byMachine.get(snapshot.machine);
175
+ if (list === undefined) byMachine.set(snapshot.machine, [snapshot]);
176
+ else list.push(snapshot);
177
+ }
178
+ const dwell = new Map<ActivitySnapshot, number>();
179
+ for (const list of byMachine.values()) {
180
+ const ordered = sortedByTime(list);
181
+ for (let index = 0; index < ordered.length; index++) {
182
+ const current = ordered[index];
183
+ if (current === undefined) continue;
184
+ const next = ordered[index + 1];
185
+ let value = 0;
186
+ if (next !== undefined) {
187
+ const delta = Date.parse(next.capturedAtUtc) - Date.parse(current.capturedAtUtc);
188
+ if (Number.isFinite(delta) && delta > 0) value = Math.min(delta, MAX_DWELL_MS);
189
+ }
190
+ dwell.set(current, value);
191
+ }
192
+ }
193
+ return dwell;
194
+ }
195
+
196
+ function formatDurationMinutes(ms: number): string {
197
+ return `${Math.round(ms / 60_000)}m`;
198
+ }
199
+
200
+ function clockHhMm(iso: string, timezone: string): string {
201
+ const ms = Date.parse(iso);
202
+ if (!Number.isFinite(ms)) return "??:??";
203
+ const parts = new Intl.DateTimeFormat("en-GB", {
204
+ timeZone: timezone,
205
+ hour: "2-digit",
206
+ minute: "2-digit",
207
+ hour12: false,
208
+ }).formatToParts(new Date(ms));
209
+ const hour = parts.find((p) => p.type === "hour")?.value ?? "00";
210
+ const minute = parts.find((p) => p.type === "minute")?.value ?? "00";
211
+ return `${hour}:${minute}`;
212
+ }
213
+
214
+ function collapseWhitespace(text: string): string {
215
+ return text.replace(/\s+/g, " ").trim();
216
+ }
217
+
218
+ function perAppSection(ordered: ActivitySnapshot[], dwell: Map<ActivitySnapshot, number>): string {
219
+ const totals = new Map<string, number>();
220
+ for (const snapshot of ordered) {
221
+ totals.set(snapshot.app, (totals.get(snapshot.app) ?? 0) + (dwell.get(snapshot) ?? 0));
222
+ }
223
+ const rows = [...totals.entries()].sort((a, b) => {
224
+ if (b[1] !== a[1]) return b[1] - a[1];
225
+ if (a[0] < b[0]) return -1;
226
+ if (a[0] > b[0]) return 1;
227
+ return 0;
228
+ });
229
+ const lines = ["## Per-app time", ""];
230
+ if (rows.length === 0) {
231
+ lines.push("_No activity recorded._");
232
+ } else {
233
+ for (const [app, ms] of rows) {
234
+ lines.push(`- ${app}: ${formatDurationMinutes(ms)}`);
235
+ }
236
+ }
237
+ return lines.join("\n");
238
+ }
239
+
240
+ interface TimelineSpan {
241
+ startIso: string;
242
+ machine: string;
243
+ app: string;
244
+ windowTitle: string;
245
+ browserUrl?: string;
246
+ }
247
+
248
+ function timelineSpans(ordered: ActivitySnapshot[]): TimelineSpan[] {
249
+ const spans: TimelineSpan[] = [];
250
+ for (const snapshot of ordered) {
251
+ const last = spans[spans.length - 1];
252
+ if (
253
+ last !== undefined &&
254
+ last.machine === snapshot.machine &&
255
+ last.app === snapshot.app &&
256
+ last.windowTitle === snapshot.windowTitle &&
257
+ last.browserUrl === snapshot.browserUrl
258
+ ) {
259
+ continue;
260
+ }
261
+ spans.push({
262
+ startIso: snapshot.capturedAtUtc,
263
+ machine: snapshot.machine,
264
+ app: snapshot.app,
265
+ windowTitle: snapshot.windowTitle,
266
+ ...(snapshot.browserUrl !== undefined ? { browserUrl: snapshot.browserUrl } : {}),
267
+ });
268
+ }
269
+ return spans;
270
+ }
271
+
272
+ function timelineSection(ordered: ActivitySnapshot[], timezone: string): string {
273
+ const lines = ["## Timeline", ""];
274
+ const spans = timelineSpans(ordered);
275
+ if (spans.length === 0) {
276
+ lines.push("_No activity recorded._");
277
+ return lines.join("\n");
278
+ }
279
+ for (const span of spans) {
280
+ const clock = clockHhMm(span.startIso, timezone);
281
+ const window = collapseWhitespace(span.windowTitle);
282
+ const url = span.browserUrl !== undefined ? ` (${collapseWhitespace(span.browserUrl)})` : "";
283
+ lines.push(`- [${clock}] ${span.app}${window.length > 0 ? ` — ${window}` : ""}${url}`);
284
+ }
285
+ return lines.join("\n");
286
+ }
287
+
288
+ function notableSection(ordered: ActivitySnapshot[], dwell: Map<ActivitySnapshot, number>): string {
289
+ const withDwell = ordered.map((snapshot) => ({ snapshot, dwell: dwell.get(snapshot) ?? 0 }));
290
+ const ranked = withDwell
291
+ .filter((entry) => collapseWhitespace(entry.snapshot.text).length > 0)
292
+ .sort((a, b) => {
293
+ if (b.dwell !== a.dwell) return b.dwell - a.dwell;
294
+ const at = Date.parse(a.snapshot.capturedAtUtc);
295
+ const bt = Date.parse(b.snapshot.capturedAtUtc);
296
+ if (at !== bt && Number.isFinite(at) && Number.isFinite(bt)) return at < bt ? -1 : 1;
297
+ const idDelta = (a.snapshot.id ?? 0) - (b.snapshot.id ?? 0);
298
+ if (idDelta !== 0) return idDelta;
299
+ if (a.snapshot.contentHash !== b.snapshot.contentHash) {
300
+ return a.snapshot.contentHash < b.snapshot.contentHash ? -1 : 1;
301
+ }
302
+ return 0;
303
+ })
304
+ .slice(0, NOTABLE_MAX_WINDOWS);
305
+ const lines = ["## Notable", ""];
306
+ if (ranked.length === 0) {
307
+ lines.push("_No notable text captured._");
308
+ return lines.join("\n");
309
+ }
310
+ for (const { snapshot } of ranked) {
311
+ const excerpt = collapseWhitespace(snapshot.text).slice(0, NOTABLE_EXCERPT_CHARS);
312
+ lines.push(`- **${snapshot.app}** — ${excerpt}`);
313
+ }
314
+ return lines.join("\n");
315
+ }
316
+
317
+ export function composeActivityDigestBody(
318
+ date: string,
319
+ timezone: string,
320
+ snapshots: ActivitySnapshot[],
321
+ ): string {
322
+ assertValidTimezone(timezone);
323
+ const ordered = sortedByTime(snapshots);
324
+ const dwell = computeDwell(ordered);
325
+ return [
326
+ `# Activity — ${date}`,
327
+ "",
328
+ perAppSection(ordered, dwell),
329
+ "",
330
+ timelineSection(ordered, timezone),
331
+ "",
332
+ notableSection(ordered, dwell),
333
+ "",
334
+ ].join("\n");
335
+ }
336
+
337
+ export function hashActivityBody(body: string): string {
338
+ return createHash("sha256").update(body, "utf8").digest("hex");
339
+ }
340
+
341
+ export function composeActivityDigestMeta(
342
+ date: string,
343
+ machines: string[],
344
+ snapshots: ActivitySnapshot[],
345
+ body: string,
346
+ ): ActivityDayMeta {
347
+ const uniqueMachines = [...new Set(machines)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
348
+ return {
349
+ kind: "activity-digest",
350
+ date,
351
+ machines: uniqueMachines,
352
+ snapshotCount: snapshots.length,
353
+ contentHash: hashActivityBody(body),
354
+ formatVersion: ACTIVITY_DIGEST_FORMAT_VERSION,
355
+ };
356
+ }
357
+
358
+ export function serializeActivityDigest(meta: ActivityDayMeta, body: string): string {
359
+ const frontmatter = [
360
+ "---",
361
+ `kind: ${meta.kind}`,
362
+ `date: ${meta.date}`,
363
+ `machines: ${JSON.stringify(meta.machines)}`,
364
+ `snapshotCount: ${meta.snapshotCount}`,
365
+ `contentHash: ${meta.contentHash}`,
366
+ `formatVersion: ${meta.formatVersion}`,
367
+ "---",
368
+ "",
369
+ ].join("\n");
370
+ return `${frontmatter}${body}`;
371
+ }
372
+
373
+ export function parseActivityDigest(raw: string): ActivityDayDigest | null {
374
+ if (typeof raw !== "string" || !raw.startsWith("---\n")) return null;
375
+ const end = raw.indexOf("\n---\n", 4);
376
+ if (end === -1) return null;
377
+ const frontmatter = raw.slice(4, end);
378
+ const body = raw.slice(end + 5).replace(/^\n/, "");
379
+ const fields = new Map<string, string>();
380
+ for (const line of frontmatter.split("\n")) {
381
+ const idx = line.indexOf(":");
382
+ if (idx === -1) continue;
383
+ fields.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
384
+ }
385
+ const date = fields.get("date");
386
+ const contentHash = fields.get("contentHash");
387
+ if (date === undefined || !isValidActivityDate(date) || contentHash === undefined) return null;
388
+ if (fields.get("kind") !== "activity-digest") return null;
389
+ const machines = parseMachinesList(fields.get("machines") ?? "[]");
390
+ const snapshotCount = parseNonNegativeInt(fields.get("snapshotCount"));
391
+ const formatVersion = parseNonNegativeInt(fields.get("formatVersion"));
392
+ if (snapshotCount === null || formatVersion === null) return null;
393
+ return {
394
+ meta: {
395
+ kind: "activity-digest",
396
+ date,
397
+ machines,
398
+ snapshotCount,
399
+ contentHash,
400
+ formatVersion,
401
+ },
402
+ body,
403
+ };
404
+ }
405
+
406
+ function parseNonNegativeInt(value: string | undefined): number | null {
407
+ if (value === undefined) return null;
408
+ const trimmed = value.trim();
409
+ if (!/^\d+$/.test(trimmed)) return null;
410
+ const parsed = Number(trimmed);
411
+ return Number.isSafeInteger(parsed) ? parsed : null;
412
+ }
413
+
414
+ function parseMachinesList(raw: string): string[] {
415
+ try {
416
+ const parsed: unknown = JSON.parse(raw);
417
+ if (Array.isArray(parsed)) {
418
+ return parsed.filter((entry): entry is string => typeof entry === "string");
419
+ }
420
+ } catch {
421
+ // Legacy unquoted inline form (`[a, b]`) — fall through to the split parse.
422
+ }
423
+ return raw
424
+ .replace(/^\[/, "")
425
+ .replace(/\]$/, "")
426
+ .split(",")
427
+ .map((entry) => entry.trim())
428
+ .filter((entry) => entry.length > 0);
429
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Public entry for the on-screen activity subsystem (issue #1900): the durable
3
+ * SQLite snapshot store plus the deterministic day-digest renderer. Re-exported
4
+ * from the package root (`src/index.ts`) so consumers import it from
5
+ * `@remnic/core`, matching the wearables subsystem's surfacing.
6
+ */
7
+ export * from "./types.js";
8
+ export * from "./store.js";
9
+ export * from "./digest.js";
@@ -0,0 +1,226 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { test } from "node:test";
6
+
7
+ import { activityDayWindow } from "./digest.js";
8
+ import { ActivityStore } from "./store.js";
9
+ import type { ActivitySnapshot } from "./types.js";
10
+
11
+ function snapshot(overrides: Partial<ActivitySnapshot> = {}): ActivitySnapshot {
12
+ return {
13
+ machine: "macstudio",
14
+ capturedAtUtc: "2026-03-10T14:00:00.000Z",
15
+ app: "Chrome",
16
+ windowTitle: "Roadmap",
17
+ text: "quarterly roadmap review",
18
+ textSource: "ax",
19
+ contentHash: "hash-a",
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ async function withStore(fn: (store: ActivityStore) => void | Promise<void>): Promise<void> {
25
+ const dir = await mkdtemp(path.join(os.tmpdir(), "activity-store-"));
26
+ const store = ActivityStore.open(dir);
27
+ try {
28
+ await fn(store);
29
+ } finally {
30
+ store.close();
31
+ await rm(dir, { recursive: true, force: true });
32
+ }
33
+ }
34
+
35
+ test("insertSnapshot dedups on (machine, content_hash)", async () => {
36
+ await withStore((store) => {
37
+ const first = store.insertSnapshot(snapshot());
38
+ const second = store.insertSnapshot(snapshot({ text: "different text, same hash" }));
39
+ assert.equal(first.inserted, true);
40
+ assert.equal(second.inserted, false);
41
+ assert.equal(second.id, first.id);
42
+ const rows = store.listSnapshotsForDay("macstudio", "2026-03-10T00:00:00.000Z", "2026-03-11T00:00:00.000Z");
43
+ assert.equal(rows.length, 1);
44
+ });
45
+ });
46
+
47
+ test("the same content_hash on a different machine is stored separately", async () => {
48
+ await withStore((store) => {
49
+ assert.equal(store.insertSnapshot(snapshot({ machine: "macstudio" })).inserted, true);
50
+ assert.equal(store.insertSnapshot(snapshot({ machine: "laptop" })).inserted, true);
51
+ const all = store.listSnapshotsForDay(null, "2026-03-10T00:00:00.000Z", "2026-03-11T00:00:00.000Z");
52
+ assert.equal(all.length, 2);
53
+ });
54
+ });
55
+
56
+ test("listSnapshotsForDay is half-open: a snapshot at the end bound is excluded", async () => {
57
+ await withStore((store) => {
58
+ store.insertSnapshot(snapshot({ contentHash: "h1", capturedAtUtc: "2026-03-10T00:00:00.000Z" }));
59
+ store.insertSnapshot(snapshot({ contentHash: "h2", capturedAtUtc: "2026-03-10T23:59:59.000Z" }));
60
+ store.insertSnapshot(snapshot({ contentHash: "h3", capturedAtUtc: "2026-03-11T00:00:00.000Z" }));
61
+ const rows = store.listSnapshotsForDay(null, "2026-03-10T00:00:00.000Z", "2026-03-11T00:00:00.000Z");
62
+ assert.deepEqual(rows.map((r) => r.contentHash), ["h1", "h2"]);
63
+ });
64
+ });
65
+
66
+ test("results are ordered by (captured_at_utc, id)", async () => {
67
+ await withStore((store) => {
68
+ store.insertSnapshot(snapshot({ contentHash: "b", capturedAtUtc: "2026-03-10T15:00:00.000Z" }));
69
+ store.insertSnapshot(snapshot({ contentHash: "a", capturedAtUtc: "2026-03-10T14:00:00.000Z" }));
70
+ const rows = store.listSnapshotsForDay(null, "2026-03-10T00:00:00.000Z", "2026-03-11T00:00:00.000Z");
71
+ assert.deepEqual(rows.map((r) => r.contentHash), ["a", "b"]);
72
+ });
73
+ });
74
+
75
+ test("cursors are per-machine and round-trip", async () => {
76
+ await withStore((store) => {
77
+ assert.equal(store.getCursor("macstudio"), null);
78
+ store.setCursor("macstudio", "cur-1");
79
+ store.setCursor("laptop", "cur-2");
80
+ assert.equal(store.getCursor("macstudio"), "cur-1");
81
+ assert.equal(store.getCursor("laptop"), "cur-2");
82
+ store.setCursor("macstudio", "cur-3");
83
+ assert.equal(store.getCursor("macstudio"), "cur-3");
84
+ });
85
+ });
86
+
87
+ test("searchSnapshots finds by text token and by app name", async () => {
88
+ await withStore((store) => {
89
+ store.insertSnapshot(snapshot({ contentHash: "h1", app: "Slack", text: "deploy the staging build" }));
90
+ store.insertSnapshot(snapshot({ contentHash: "h2", app: "Chrome", text: "unrelated content" }));
91
+ assert.equal(store.searchSnapshots("deploy", 10).length, 1);
92
+ assert.equal(store.searchSnapshots("Slack", 10)[0]?.app, "Slack");
93
+ assert.equal(store.searchSnapshots("nonexistentterm", 10).length, 0);
94
+ });
95
+ });
96
+
97
+ test("pruneOlderThan removes only rows before the cutoff, incl. their FTS rows", async () => {
98
+ await withStore((store) => {
99
+ store.insertSnapshot(snapshot({ contentHash: "old", capturedAtUtc: "2026-03-01T10:00:00.000Z", text: "ancient" }));
100
+ store.insertSnapshot(snapshot({ contentHash: "new", capturedAtUtc: "2026-03-10T10:00:00.000Z", text: "recent" }));
101
+ const removed = store.pruneOlderThan("2026-03-05T00:00:00.000Z");
102
+ assert.equal(removed, 1);
103
+ const all = store.listSnapshotsForDay(null, "2026-01-01T00:00:00.000Z", "2026-12-31T00:00:00.000Z");
104
+ assert.deepEqual(all.map((r) => r.contentHash), ["new"]);
105
+ // FTS row for the pruned snapshot is gone too.
106
+ assert.equal(store.searchSnapshots("ancient", 10).length, 0);
107
+ assert.equal(store.searchSnapshots("recent", 10).length, 1);
108
+ });
109
+ });
110
+
111
+ test("searchSnapshots never throws on FTS-special input (URLs, quotes, operators)", async () => {
112
+ await withStore((store) => {
113
+ store.insertSnapshot(
114
+ snapshot({ contentHash: "u", app: "Chrome", windowTitle: "PR", browserUrl: "https://github.com/x/pull/412", text: "review the change" }),
115
+ );
116
+ // URL with slashes/dots would be FTS5 syntax without sanitization.
117
+ assert.equal(store.searchSnapshots("github.com/x/pull/412", 10).length, 1);
118
+ // Bare boolean operators and quotes must not throw.
119
+ assert.doesNotThrow(() => store.searchSnapshots('AND OR "', 10));
120
+ assert.deepEqual(store.searchSnapshots(" ", 10), []);
121
+ assert.deepEqual(store.searchSnapshots('""', 10), []);
122
+ });
123
+ });
124
+
125
+ test("ActivityStore.open works on a fresh memoryDir (creates state/ itself)", async () => {
126
+ const dir = await mkdtemp(path.join(os.tmpdir(), "activity-fresh-"));
127
+ try {
128
+ // No pre-created state/ dir: the public factory must not throw.
129
+ const store = ActivityStore.open(dir);
130
+ const result = store.insertSnapshot(snapshot());
131
+ assert.equal(result.inserted, true);
132
+ store.close();
133
+ } finally {
134
+ await rm(dir, { recursive: true, force: true });
135
+ }
136
+ });
137
+
138
+ test("insertSnapshot writes the FTS row atomically (searchable right after insert)", async () => {
139
+ await withStore((store) => {
140
+ const result = store.insertSnapshot(snapshot({ text: "atomic search token zzq", contentHash: "atomic-1" }));
141
+ assert.equal(result.inserted, true);
142
+ // A committed base row without its FTS row would return zero hits here.
143
+ const hits = store.searchSnapshots("zzq", 10);
144
+ assert.equal(hits.length, 1);
145
+ assert.equal(hits[0]?.contentHash, "atomic-1");
146
+ });
147
+ });
148
+
149
+ test("dedup keeps the same content at a different time; exact re-ingest dedups", async () => {
150
+ await withStore((store) => {
151
+ const first = store.insertSnapshot(snapshot({ capturedAtUtc: "2026-03-10T14:00:00.000Z", contentHash: "same" }));
152
+ const later = store.insertSnapshot(snapshot({ capturedAtUtc: "2026-03-10T15:00:00.000Z", contentHash: "same" }));
153
+ const dup = store.insertSnapshot(snapshot({ capturedAtUtc: "2026-03-10T14:00:00.000Z", contentHash: "same" }));
154
+ assert.equal(first.inserted, true);
155
+ assert.equal(later.inserted, true); // same content, different time → kept
156
+ assert.equal(dup.inserted, false); // exact re-ingestion → deduped
157
+ });
158
+ });
159
+
160
+ test("captured timestamps are canonicalized so day-window filtering matches", async () => {
161
+ await withStore((store) => {
162
+ // Non-canonical inputs (explicit +00:00 offset, missing millis) must land in the day.
163
+ store.insertSnapshot(snapshot({ capturedAtUtc: "2026-03-10T14:00:00+00:00", contentHash: "c1" }));
164
+ store.insertSnapshot(snapshot({ capturedAtUtc: "2026-03-10T15:30:00Z", contentHash: "c2" }));
165
+ const { startUtc, endUtc } = activityDayWindow("2026-03-10", "UTC");
166
+ const rows = store.listSnapshotsForDay(null, startUtc, endUtc);
167
+ assert.equal(rows.length, 2);
168
+ assert.ok(rows.every((r) => r.capturedAtUtc.endsWith("Z") && r.capturedAtUtc.includes(".")));
169
+ });
170
+ });
171
+
172
+ test("insertSnapshot rejects an impossible capture timestamp", async () => {
173
+ await withStore((store) => {
174
+ assert.throws(
175
+ () => store.insertSnapshot(snapshot({ capturedAtUtc: "2026-02-30T10:00:00.000Z", contentHash: "bad" })),
176
+ RangeError,
177
+ );
178
+ });
179
+ });
180
+
181
+ test("insertSnapshot rejects an offset-form impossible timestamp", async () => {
182
+ await withStore((store) => {
183
+ assert.throws(
184
+ () => store.insertSnapshot(snapshot({ capturedAtUtc: "2026-02-30T14:00:00.000+00:00", contentHash: "bad2" })),
185
+ RangeError,
186
+ );
187
+ });
188
+ });
189
+
190
+ test("pruneOlderThan canonicalizes a non-canonical cutoff", async () => {
191
+ await withStore((store) => {
192
+ store.insertSnapshot(snapshot({ capturedAtUtc: "2026-03-10T09:00:00.000Z", contentHash: "old" }));
193
+ store.insertSnapshot(snapshot({ capturedAtUtc: "2026-03-10T11:00:00.000Z", contentHash: "new" }));
194
+ // A non-canonical cutoff (no millis) must still prune the 09:00 snapshot.
195
+ assert.equal(store.pruneOlderThan("2026-03-10T10:00:00Z"), 1);
196
+ });
197
+ });
198
+
199
+ test("pruneOlderThan rejects a malformed cutoff instead of deleting by a raw string compare", async () => {
200
+ await withStore((store) => {
201
+ store.insertSnapshot(snapshot({ capturedAtUtc: "2026-03-10T09:00:00.000Z", contentHash: "keep" }));
202
+ assert.throws(() => store.pruneOlderThan("not-a-date"), RangeError);
203
+ // The valid row survived the rejected prune.
204
+ const { startUtc, endUtc } = activityDayWindow("2026-03-10", "UTC");
205
+ assert.equal(store.listSnapshotsForDay(null, startUtc, endUtc).length, 1);
206
+ });
207
+ });
208
+
209
+ test("insertSnapshot rejects a snapshot missing a required field", async () => {
210
+ await withStore((store) => {
211
+ assert.throws(() => store.insertSnapshot(snapshot({ contentHash: "" })), RangeError);
212
+ assert.throws(() => store.insertSnapshot(snapshot({ machine: undefined as unknown as string })), RangeError);
213
+ });
214
+ });
215
+
216
+ test("listSnapshotsForDay rejects a malformed range bound", async () => {
217
+ await withStore((store) => {
218
+ assert.throws(() => store.listSnapshotsForDay(null, "not-a-date", "2026-03-11T00:00:00.000Z"), RangeError);
219
+ });
220
+ });
221
+
222
+ test("insertSnapshot rejects an unknown textSource", async () => {
223
+ await withStore((store) => {
224
+ assert.throws(() => store.insertSnapshot(snapshot({ textSource: "screen" as unknown as "ax" })), RangeError);
225
+ });
226
+ });