agent-dag 1.47.0 → 3.0.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,288 @@
1
+ // What Browser Watch remembers between runs: whether it is on, how it is tuned,
2
+ // and the episodes it has already seen.
3
+ //
4
+ // THE ARCHIVE IS THE POINT, AND IT IS NOT A CACHE. Everything the panel shows
5
+ // is read live out of Chrome's own history, which is complete and needs no help
6
+ // from us — with one exception that is the whole reason this file exists.
7
+ // Whoever can drive your browser can also clear its history, and they have the
8
+ // same buttons you do. A watch that only ever reads live is a watch that any
9
+ // intruder can erase behind themselves.
10
+ //
11
+ // So while the watch is ON, every episode it sees is copied here, and the panel
12
+ // shows the union of what Chrome still remembers and what the deck already
13
+ // wrote down. What happened while the watch was OFF is at the mercy of the
14
+ // browser, and the panel says so rather than implying an unbroken record.
15
+ //
16
+ // A separate directory rather than a file beside the deck records in
17
+ // `~/.claude/agent-dag`: readLiveDecks() reads every `.json` in that directory
18
+ // and would have to keep skipping this one forever. A subdirectory is not a
19
+ // name it can collide with.
20
+ import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+ import { claudeConfigDir } from "./claude-dir.mjs";
23
+
24
+ /** Reactions the panel can arm. `close-tab` is macOS-only and the server is the
25
+ * one that says so — a client cannot be trusted to know what the OS can do,
26
+ * and a mode that silently does nothing is worse than one that is not offered. */
27
+ export const REACTIONS = ["notify", "close-tab", "quit-browser"];
28
+
29
+ export const DEFAULTS = {
30
+ v: 1,
31
+ enabled: false,
32
+ reaction: "notify",
33
+ quietMinutes: 15,
34
+ gapMinutes: 15,
35
+ };
36
+
37
+ /**
38
+ * The store's schema version.
39
+ *
40
+ * Bumped when the rule that PRODUCES episodes changes, not when their shape
41
+ * does — see readStore. Version 1 kept rows from a thirty-day sweep of the
42
+ * browser's history; version 2 keeps only what the deck saw while watching.
43
+ */
44
+ const STORE_VERSION = 2;
45
+
46
+ /** How many archived episodes are kept. Roughly two years at the measured rate
47
+ * of one card every eight days, and small enough that the file stays a thing a
48
+ * person could open and read. Trimmed oldest-first. */
49
+ const KEEP = 500;
50
+
51
+ export const storeDir = (home = claudeConfigDir()) => join(home, "agent-dag", "browser-watch");
52
+ export const storePath = (home = claudeConfigDir()) => join(storeDir(home), "state.json");
53
+
54
+ /** The plain-text log, which is the one file here a person opens themselves.
55
+ * state.json is the deck's own record and is JSON because the deck reads it
56
+ * back; this is the same events in the shape `tail -f` wants. */
57
+ export const logPath = (home = claudeConfigDir()) => join(storeDir(home), "watch.log");
58
+
59
+ /**
60
+ * Append one episode, and EVERY ADDRESS IN IT, oldest first.
61
+ *
62
+ * THE URLs ARE THE POINT OF THE FILE. A summary line — host, count, duration —
63
+ * says something happened and leaves the reader unable to act on it: the
64
+ * question three days later is not "did a program touch gitlab" but "WHICH
65
+ * pages", because a jobs list and a settings page mean different things. So
66
+ * every address is written in full, unshortened and unescaped, exactly as
67
+ * Chrome recorded it.
68
+ *
69
+ * Query strings and fragments included. They are frequently the whole content
70
+ * of the visit — `?scope=all`, `#servicii` — and a log that dropped them would
71
+ * be tidier and useless for the one job it has.
72
+ *
73
+ * Indented under their episode so the shape survives `grep`: a summary line
74
+ * starts at column zero, a URL line does not, which is what lets
75
+ * `grep -v '^ '` give the summary alone and `grep '^ '` give the addresses.
76
+ *
77
+ * Append-only and never rewritten: a log a program edits is not a log. It is
78
+ * the only part of this feature that outlives the process by design — the panel
79
+ * shows what this deck has seen, this file is what somebody reads three days
80
+ * later without opening the panel at all.
81
+ */
82
+ export async function appendLog(episodes, home = claudeConfigDir(), deps = {}) {
83
+ if (!episodes.length) return;
84
+ const mk = deps.mkdir ?? mkdir;
85
+ const add = deps.appendFile ?? appendFile;
86
+ // Local time, not UTC. The reader's question is "what was happening at four
87
+ // yesterday afternoon", and their afternoon is not UTC's — the ISO stamp this
88
+ // replaced was off by the offset for everyone outside London.
89
+ const stamp = ms => {
90
+ const d = new Date(ms);
91
+ const p = n => String(n).padStart(2, "0");
92
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
93
+ + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
94
+ };
95
+ const block = e => {
96
+ const span = e.endMs - e.startMs >= 60_000
97
+ ? ` over ${Math.round((e.endMs - e.startMs) / 60_000)}m`
98
+ : "";
99
+ const where = e.browser ? ` [${e.browser}]` : "";
100
+ const head = `${stamp(e.startMs)} ${e.host} ${e.count} page${e.count === 1 ? "" : "s"}${span}${where}`;
101
+ const rows = (e.urls ?? []).map(u => ` ${stamp(u.timeMs).slice(11)} ${u.url}`);
102
+ return [head, ...rows].join("\n");
103
+ };
104
+ await mk(storeDir(home), { recursive: true });
105
+ await add(logPath(home), episodes.map(block).join("\n") + "\n", "utf8");
106
+ }
107
+
108
+ /**
109
+ * Settings as they will be used, whatever the file said.
110
+ *
111
+ * Every field is checked rather than spread, because this file is on disk and
112
+ * on disk is where a hand edit, a half-written save and an older version all
113
+ * arrive from. A `quietMinutes` of `"15"` or of `0` would otherwise reach
114
+ * classify() and widen the gate to everything, which is the failure that turns
115
+ * the panel into noise — the same reason the route refuses to coerce its query
116
+ * string.
117
+ */
118
+ export function normalise(raw) {
119
+ const it = raw && typeof raw === "object" ? raw : {};
120
+ const num = (v, fallback, lo, hi) =>
121
+ typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi ? v : fallback;
122
+ return {
123
+ v: 1,
124
+ enabled: it.enabled === true,
125
+ reaction: REACTIONS.includes(it.reaction) ? it.reaction : DEFAULTS.reaction,
126
+ quietMinutes: num(it.quietMinutes, DEFAULTS.quietMinutes, 1, 24 * 60),
127
+ gapMinutes: num(it.gapMinutes, DEFAULTS.gapMinutes, 1, 24 * 60),
128
+ };
129
+ }
130
+
131
+ /** An episode reduced to what an archive needs: enough to redraw the card and
132
+ * enough to recognise it again. `urls` is kept whole — it is the evidence, and
133
+ * an archive that dropped it would preserve the accusation without it. */
134
+ function archivable(e) {
135
+ return {
136
+ host: String(e.host ?? ""),
137
+ // Which browser it happened in. Kept because a reaction has to tell ONE
138
+ // application to close a tab, and because a log line that names the host
139
+ // but not the browser leaves a two-browser machine guessing. Dropping it
140
+ // here was the one place the tag was lost between finding and acting.
141
+ browser: typeof e.browser === "string" ? e.browser : null,
142
+ startMs: Number(e.startMs),
143
+ endMs: Number(e.endMs),
144
+ count: Number(e.count),
145
+ urls: Array.isArray(e.urls)
146
+ ? e.urls.map(u => ({ url: String(u.url ?? ""), timeMs: Number(u.timeMs) }))
147
+ : [],
148
+ // When the deck wrote it down, which is the only claim the archive can make
149
+ // that Chrome's history cannot: the episode existed at this moment, whatever
150
+ // the browser says later.
151
+ archivedMs: Number(e.archivedMs ?? Date.now()),
152
+ };
153
+ }
154
+
155
+ /** Two episodes are the same one when they start at the same moment on the same
156
+ * host. Not the count or the end, both of which grow while a program is still
157
+ * working — keyed on those, one run would archive itself a dozen times. */
158
+ // Separated by an escaped NUL rather than a space: a host cannot contain one,
159
+ // so no two different episodes can collide on the joined string. Written as an
160
+ // ESCAPE and never as the raw byte — source-nul-bytes.test.ts exists because a
161
+ // raw NUL makes grep skip the whole file without ever saying so.
162
+ const keyOf = e => `${e.host}\u0000${e.startMs}`;
163
+
164
+ /** The same key, from the two fields a caller has. Exported because the route
165
+ * that dismisses an episode is handed a host and a start, not an episode. */
166
+ export const episodeKey = (host, startMs) => `${host}\u0000${startMs}`;
167
+
168
+ /** How many dismissals are remembered. A dismissal is a few dozen bytes and
169
+ * the archive it filters is capped at 500, so this is generous — but it is
170
+ * capped all the same, because a set that only grows is a file that only
171
+ * grows. Trimmed oldest-first, and the cost of forgetting the oldest is that
172
+ * an episode from two years ago could reappear if it were still live, which
173
+ * it cannot be. */
174
+ const DISMISS_KEEP = 2000;
175
+
176
+ export async function readStore(home = claudeConfigDir(), deps = {}) {
177
+ const read = deps.readFile ?? readFile;
178
+ let parsed = null;
179
+ try { parsed = JSON.parse(await read(storePath(home), "utf8")); } catch { /* absent or corrupt */ }
180
+ const settings = normalise(parsed?.settings);
181
+
182
+ // A VERSION BUMP DROPS THE EPISODES AND KEEPS THE SETTINGS, because the two
183
+ // are not the same kind of thing. Settings are what the user chose and stay
184
+ // chosen; episodes are FINDINGS, and a finding produced by a rule the deck no
185
+ // longer applies is not a finding it can stand behind.
186
+ //
187
+ // Version 1 archived whatever a thirty-day sweep of the browser's history
188
+ // turned up, so its rows are the user's own past browsing — read before the
189
+ // watch existed, under a rule that has since been removed. Keeping them would
190
+ // put "nothing from before this deck started" on screen directly above four
191
+ // episodes from a fortnight earlier, which is the panel calling itself a liar.
192
+ //
193
+ // Dropping rather than migrating: there is no way to re-derive which of those
194
+ // rows the current rule WOULD have found, because the evidence for that
195
+ // question is exactly the history the deck no longer reads.
196
+ //
197
+ // `migrated` tells the caller to write the file back. Hiding the rows is not
198
+ // enough: the promise is that nothing from before the watch is KEPT, and rows
199
+ // left on disk are kept whatever the panel chooses to draw. readStore does not
200
+ // write them away itself — a read with a side effect is a trap for the next
201
+ // caller — so it says so and the snapshot does it.
202
+ if (parsed && parsed.v !== STORE_VERSION) return { settings, episodes: [], dismissed: [], migrated: true };
203
+
204
+ const episodes = Array.isArray(parsed?.episodes)
205
+ ? parsed.episodes.map(archivable).filter(e => Number.isFinite(e.startMs))
206
+ : [];
207
+ // WHAT THE READER HAS ALREADY LOOKED AT. It has to be its own list rather
208
+ // than a deletion from `episodes`, because the panel reads the browser's
209
+ // history live as well as its own archive — delete the row and the very next
210
+ // poll finds the same visits and puts it back, which is worse than having no
211
+ // delete at all.
212
+ const dismissed = Array.isArray(parsed?.dismissed)
213
+ ? parsed.dismissed.filter(k => typeof k === "string" && k.includes("\u0000")).slice(-DISMISS_KEEP)
214
+ : [];
215
+ return { settings, episodes, dismissed, migrated: false };
216
+ }
217
+
218
+ /**
219
+ * Write the store, atomically.
220
+ *
221
+ * Through a temp file and a rename because the alternative is a truncated JSON
222
+ * document as the only record of what was seen while the browser was being
223
+ * driven — the one file whose loss this feature cannot absorb. installer.mjs
224
+ * makes the same argument about settings.json, for the same reason.
225
+ */
226
+ /**
227
+ * Write the whole store, atomically.
228
+ *
229
+ * IT WRITES WHAT IT IS HANDED. There is no merge with what is on disk, on
230
+ * purpose — a writer that read first would have to decide what wins, and two
231
+ * decks racing on that is worse than one deck writing a whole state. The cost
232
+ * is that every caller must pass every field, and the cost was paid once: the
233
+ * settings route omitted `dismissed` and so erased every episode the reader had
234
+ * marked reviewed, from a change that had nothing to do with them. There is a
235
+ * test that greps this file's callers for the field.
236
+ */
237
+ export async function writeStore(state, home = claudeConfigDir(), deps = {}) {
238
+ const mk = deps.mkdir ?? mkdir;
239
+ const write = deps.writeFile ?? writeFile;
240
+ const mv = deps.rename ?? rename;
241
+ await mk(storeDir(home), { recursive: true });
242
+ const body = JSON.stringify({
243
+ v: STORE_VERSION,
244
+ settings: normalise(state.settings),
245
+ episodes: (state.episodes ?? []).map(archivable),
246
+ dismissed: [...new Set(state.dismissed ?? [])].slice(-DISMISS_KEEP),
247
+ }, null, 2) + "\n";
248
+ const tmp = `${storePath(home)}.${process.pid}.tmp`;
249
+ await write(tmp, body, "utf8");
250
+ await mv(tmp, storePath(home));
251
+ }
252
+
253
+ /**
254
+ * The archive with `seen` folded into it, newest first and capped.
255
+ *
256
+ * An episode already archived is REPLACED rather than skipped, because a run
257
+ * that is still going gains pages: the card the deck wrote at 17:05 said one
258
+ * page, and by 17:44 the truth is thirteen. Skipping would freeze the first
259
+ * reading; appending would show the same run twice.
260
+ */
261
+ export function mergeEpisodes(archive, seen, now = Date.now()) {
262
+ const byKey = new Map();
263
+ for (const e of archive) byKey.set(keyOf(e), archivable(e));
264
+ for (const e of seen) {
265
+ const key = keyOf(e);
266
+ const had = byKey.get(key);
267
+ byKey.set(key, archivable({ ...e, archivedMs: had?.archivedMs ?? now }));
268
+ }
269
+ return [...byKey.values()].sort((a, b) => b.startMs - a.startMs).slice(0, KEEP);
270
+ }
271
+
272
+ /**
273
+ * Episodes the reader has not dismissed.
274
+ *
275
+ * Applied to the LIVE read as well as to the archive, which is the whole point:
276
+ * an episode is rebuilt from the browser's own history on every poll, so a
277
+ * dismissal that only removed the archived copy would be undone within ten
278
+ * seconds by the next read of the same visits.
279
+ *
280
+ * Keyed on host and START, never on the end or the count: a run that is still
281
+ * going gains pages, and a key that moved with them would let a dismissed
282
+ * episode return the moment its program opened one more tab.
283
+ */
284
+ export function undismissed(episodes, dismissed) {
285
+ if (!Array.isArray(dismissed) || dismissed.length === 0) return episodes;
286
+ const gone = new Set(dismissed);
287
+ return episodes.filter(e => !gone.has(keyOf(e)));
288
+ }