@gravitylabsllc/porthole 0.1.0 → 0.2.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.
Files changed (55) hide show
  1. package/README.md +123 -0
  2. package/dist/adb.js +430 -21
  3. package/dist/adb.js.map +1 -1
  4. package/dist/args.js +144 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/capture.js +139 -30
  7. package/dist/capture.js.map +1 -1
  8. package/dist/cli.js +221 -62
  9. package/dist/cli.js.map +1 -1
  10. package/dist/device.js +337 -4
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.js +2030 -377
  13. package/dist/index.js.map +1 -1
  14. package/dist/moment.js +240 -0
  15. package/dist/moment.js.map +1 -0
  16. package/dist/perfetto.js +826 -0
  17. package/dist/perfetto.js.map +1 -0
  18. package/dist/report.js +68 -7
  19. package/dist/report.js.map +1 -1
  20. package/dist/save.js +252 -0
  21. package/dist/save.js.map +1 -0
  22. package/dist/sessions.js +704 -0
  23. package/dist/sessions.js.map +1 -0
  24. package/dist/system.js +169 -0
  25. package/dist/system.js.map +1 -0
  26. package/dist/systrace.js +198 -0
  27. package/dist/systrace.js.map +1 -0
  28. package/dist/timeline.js +731 -29
  29. package/dist/timeline.js.map +1 -1
  30. package/dist/trace.js +317 -27
  31. package/dist/trace.js.map +1 -1
  32. package/dist/watermark.js +220 -0
  33. package/dist/watermark.js.map +1 -0
  34. package/package.json +10 -4
  35. package/src/adb.ts +583 -0
  36. package/src/args.ts +177 -0
  37. package/src/capture.ts +292 -0
  38. package/src/cli.ts +367 -0
  39. package/src/device.ts +635 -0
  40. package/src/index.ts +2545 -0
  41. package/src/moment.ts +306 -0
  42. package/src/perfetto.ts +972 -0
  43. package/src/report.ts +285 -0
  44. package/src/save.ts +322 -0
  45. package/src/sessions.ts +894 -0
  46. package/src/system.ts +221 -0
  47. package/src/systrace.ts +258 -0
  48. package/src/timeline.ts +1036 -0
  49. package/src/trace.ts +769 -0
  50. package/src/watermark.ts +337 -0
  51. package/ui/dist/assets/index-BzqwnvoU.js +70 -0
  52. package/ui/dist/assets/index-DtnyBXCM.css +1 -0
  53. package/ui/dist/index.html +2 -2
  54. package/ui/dist/assets/index--1mlZuNZ.css +0 -1
  55. package/ui/dist/assets/index-BeVGHRFm.js +0 -68
@@ -0,0 +1,337 @@
1
+ // Copyright 2026 Gravity Labs
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import type { Finding } from "./trace.js";
6
+
7
+ /**
8
+ * GRA-55: "every answer leads with what happened while the agent was not
9
+ * looking."
10
+ *
11
+ * MCP has no push. An agent asks a question, thinks for ninety seconds, asks
12
+ * another — and in between, the app ANR'd, with nothing to say so. The fix
13
+ * has two halves, both keyed off one small piece of state this module owns:
14
+ *
15
+ * - `since: "last"` (index.ts's `windowShape`) lets a window-taking tool
16
+ * pick up where the agent's own last look left off, instead of the agent
17
+ * guessing a lookback and either missing the gap or re-reading it.
18
+ * - the banner (index.ts's `ok()`) tells every tool's caller, unprompted,
19
+ * about anything of `error` severity that happened since the last call —
20
+ * this is push as far as the protocol allows: the agent finds out at the
21
+ * first opportunity it gives us, not the first opportunity it thinks to
22
+ * ask.
23
+ *
24
+ * **Scope, decided (GRA-55 EM assessment, open question 1): one watermark
25
+ * per MCP server process, keyed by the session identity currently open.**
26
+ * Not per MCP connection in the sense of "shared across every session that
27
+ * process ever sees" — `open()` below reloads from a different session
28
+ * directory's `watermark.json` the moment the identity changes, the same
29
+ * shape `SessionWriter.open()` already uses and for the same reason
30
+ * (`sessions.ts`'s module doc comment). Two MCP servers attached to one app
31
+ * at once is explicitly not designed for: both would open the same
32
+ * `watermark.json`, and whichever writes last wins — no locking, no merge.
33
+ * That is a deliberate simplification, not an oversight; solving concurrent
34
+ * writers here would be buying a problem nobody has yet to solve one nobody
35
+ * asked for.
36
+ *
37
+ * Written through to `<session dir>/watermark.json`, beside `events.ndjson`,
38
+ * on every update — this is what "survives an MCP server restart, sitting
39
+ * on the session store" means concretely, and why there is no separate
40
+ * on/off switch: it inherits `PORTHOLE_SESSIONS=0` for free, because
41
+ * without a session directory there is nowhere to write it and `open(null)`
42
+ * degrades to an in-memory-only watermark for that process's lifetime, same
43
+ * as `SessionWriter` degrades to not writing at all.
44
+ */
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // state
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /** One finding's identity and size, as carried in a digest — nothing else. Deliberately not a `Finding`: the digest is compared across calls, possibly across a restart, and a `Finding`'s `title`/`detail` text is free to reword without changing what the digest needs to answer ("is this the same finding, and how big is it now"). */
51
+ export interface FindingDigestEntry {
52
+ id: string;
53
+ count: number;
54
+ }
55
+
56
+ /** What `findings` remembers about its own last call, for classifying the next one. */
57
+ export interface FindingsDigest {
58
+ findings: FindingDigestEntry[];
59
+ window: { from: number; to: number };
60
+ /** Whether the call that produced this digest was itself `since: "last"`-shaped — see `windowsComparable()`. */
61
+ sinceLast: boolean;
62
+ }
63
+
64
+ export interface WatermarkState {
65
+ /** Max `t` examined by any window-taking tool, including `save_moment`. */
66
+ lastExaminedT: number | null;
67
+ /** The newest error-severity event the banner has already reported — never repeated after this. */
68
+ lastReportedErrorT: number | null;
69
+ digest: FindingsDigest | null;
70
+ }
71
+
72
+ export function emptyState(): WatermarkState {
73
+ return { lastExaminedT: null, lastReportedErrorT: null, digest: null };
74
+ }
75
+
76
+ function watermarkPath(dir: string): string {
77
+ return path.join(dir, "watermark.json");
78
+ }
79
+
80
+ /**
81
+ * A best-effort read: a missing file (no session yet, or a session that
82
+ * never got a watermark written) and a corrupt or hand-edited one are the
83
+ * same to a caller — there is no watermark to trust, so start fresh rather
84
+ * than throwing and taking down every tool call on a session directory.
85
+ */
86
+ async function loadState(dir: string): Promise<WatermarkState | null> {
87
+ let raw: unknown;
88
+ try {
89
+ raw = JSON.parse(await readFile(watermarkPath(dir), "utf8"));
90
+ } catch {
91
+ return null;
92
+ }
93
+ if (
94
+ typeof raw !== "object" ||
95
+ raw === null ||
96
+ !("lastExaminedT" in raw) ||
97
+ !("lastReportedErrorT" in raw) ||
98
+ !("digest" in raw)
99
+ ) {
100
+ return null;
101
+ }
102
+ return raw as WatermarkState;
103
+ }
104
+
105
+ /**
106
+ * The one process-lifetime instance `index.ts` holds. `open()` is idempotent
107
+ * for an unchanged directory — cheap to call at the top of every tool
108
+ * handler, the same way `mergeWithDisk`'s `currentIdentity()` is — so no
109
+ * caller has to know or track whether the session has actually changed.
110
+ */
111
+ export class Watermark {
112
+ private dir: string | null = null;
113
+ private state: WatermarkState = emptyState();
114
+ /** Chains writes the same way `SessionWriter.flushing` does, so two updates racing (a tool call and the banner it triggers, say) never interleave two `writeFile` calls on the same file. */
115
+ private writing: Promise<void> = Promise.resolve();
116
+
117
+ /** Switches to `dir`'s watermark, loading it if present. `null` means "no session — track in memory for this process's life and never persist," matching `PORTHOLE_SESSIONS=0` or a device that has not sent a `hello` yet. */
118
+ async open(dir: string | null): Promise<void> {
119
+ if (dir === this.dir) return;
120
+ this.dir = dir;
121
+ this.state = dir ? ((await loadState(dir)) ?? emptyState()) : emptyState();
122
+ }
123
+
124
+ get(): WatermarkState {
125
+ return this.state;
126
+ }
127
+
128
+ currentDir(): string | null {
129
+ return this.dir;
130
+ }
131
+
132
+ /** Advances the high-water mark of what has been examined. A no-op (no write) when `t` does not move it forward — `lastExaminedT` only ever grows. */
133
+ async recordExamined(t: number): Promise<void> {
134
+ if (this.state.lastExaminedT !== null && t <= this.state.lastExaminedT) return;
135
+ this.state = { ...this.state, lastExaminedT: t };
136
+ await this.persist();
137
+ }
138
+
139
+ /** Advances the banner's own high-water mark. Same monotonic guard as `recordExamined` — this is the mechanism behind "the banner never repeats an event." */
140
+ async recordReportedErrorT(t: number): Promise<void> {
141
+ if (this.state.lastReportedErrorT !== null && t <= this.state.lastReportedErrorT) return;
142
+ this.state = { ...this.state, lastReportedErrorT: t };
143
+ await this.persist();
144
+ }
145
+
146
+ /** Replaces the findings digest wholesale — there is only ever one, the most recent. */
147
+ async recordDigest(digest: FindingsDigest): Promise<void> {
148
+ this.state = { ...this.state, digest };
149
+ await this.persist();
150
+ }
151
+
152
+ /** `since: "all"` — the reset. Clears every field, so a subsequent `since: "last"` behaves as a first-ever call again. */
153
+ async reset(): Promise<void> {
154
+ this.state = emptyState();
155
+ await this.persist();
156
+ }
157
+
158
+ private persist(): Promise<void> {
159
+ if (!this.dir) return Promise.resolve();
160
+ const dir = this.dir;
161
+ const data = JSON.stringify(this.state, null, 2);
162
+ this.writing = this.writing.then(() => writeFile(watermarkPath(dir), data, "utf8"));
163
+ return this.writing;
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // the banner — index.ts's `ok()` calls this once, for every tool
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /** Hard cap (GRA-55 EM assessment, answering open question 2): "two lines and 240 characters." Enforced as one character budget — nothing here inserts a line break of its own, so "two lines" is honoured as roughly the length two lines of prose hold, not as a literal `\n`. */
172
+ export const BANNER_MAX_CHARS = 240;
173
+
174
+ export const BANNER_PREFIX = "⚠ Since your last call: ";
175
+ export const BANNER_SUFFIX = ' Call `findings {"since":"last"}`.';
176
+
177
+ /**
178
+ * Assembles the banner from error-severity findings, in the order given
179
+ * (worst first, same as `findings` itself sorts), truncating with
180
+ * "…and N more kinds" rather than exceeding [BANNER_MAX_CHARS]. `title` is
181
+ * already "counts by kind, not enumeration" for every finding this
182
+ * repository produces (`"${n} HTTP calls failed"`, not a list of the
183
+ * calls), so no separate count needs prepending here — doing so would
184
+ * double it for findings whose own title already opens with a number.
185
+ *
186
+ * Returns null for an empty list: no error findings means no banner, not an
187
+ * empty one.
188
+ */
189
+ export function buildBanner(findings: Finding[]): string | null {
190
+ if (findings.length === 0) return null;
191
+
192
+ let shown = 0;
193
+ let body = "";
194
+ for (const finding of findings) {
195
+ const candidateBody = body ? `${body}, ${finding.title}` : finding.title;
196
+ const candidateFull = `${BANNER_PREFIX}${candidateBody}.${BANNER_SUFFIX}`;
197
+ if (candidateFull.length > BANNER_MAX_CHARS) break;
198
+ body = candidateBody;
199
+ shown++;
200
+ }
201
+
202
+ const remaining = findings.length - shown;
203
+ if (remaining > 0) {
204
+ const suffix = `…and ${remaining} more kind${remaining === 1 ? "" : "s"}`;
205
+ const withMore = body ? `${BANNER_PREFIX}${body}, ${suffix}.${BANNER_SUFFIX}` : `${BANNER_PREFIX}${suffix}.${BANNER_SUFFIX}`;
206
+ if (withMore.length <= BANNER_MAX_CHARS) return withMore;
207
+ // Even "N kinds" plus the fixed prefix/suffix does not fit (an
208
+ // implausibly long finding title, or a great many kinds) — the fixed
209
+ // parts alone are always within budget in practice, so this is a last
210
+ // resort that favours staying under the cap over a polished sentence.
211
+ return `${BANNER_PREFIX}${suffix}.${BANNER_SUFFIX}`.slice(0, BANNER_MAX_CHARS);
212
+ }
213
+ if (!body) {
214
+ // Not even the first finding's title fit alone (an adversarially long
215
+ // title) — say so rather than emitting an empty, misleading banner.
216
+ return `${BANNER_PREFIX}${findings.length} finding(s), too long to summarise here.${BANNER_SUFFIX}`.slice(
217
+ 0,
218
+ BANNER_MAX_CHARS,
219
+ );
220
+ }
221
+ return `${BANNER_PREFIX}${body}.${BANNER_SUFFIX}`;
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // classification — `findings`' own new/ongoing/resolved
226
+ // ---------------------------------------------------------------------------
227
+
228
+ export type FindingStatus = "new" | "ongoing" | "resolved";
229
+
230
+ /** A live finding, classified — `status`/`delta` layered on, nothing removed. */
231
+ export type ClassifiedFinding = Finding & { status: FindingStatus; delta?: number };
232
+
233
+ /** A finding that was in the previous digest and is not any more. There is no `Finding` to show — the digest keeps only `id`/`count` (see `FindingDigestEntry`'s own comment on why) — so this is deliberately a smaller shape, not a padded-out fake `Finding`. */
234
+ export interface ResolvedFinding {
235
+ id: string;
236
+ status: "resolved";
237
+ /** What the count was the last time this id was seen. */
238
+ previousCount: number;
239
+ }
240
+
241
+ export interface ClassifyResult {
242
+ /** Current findings (each carrying `status`/`delta`) followed by any `ResolvedFinding`s — every entry has a `status`. Equal to the plain input array, with no `status` on any entry, when classification did not run. */
243
+ findings: Array<ClassifiedFinding | ResolvedFinding> | Finding[];
244
+ counts: { new: number; ongoing: number; resolved: number } | null;
245
+ /** Set only when a previous digest existed but was judged not comparable — never set merely because there was no previous digest at all (an ordinary first call has nothing to say about that). */
246
+ skippedNote: string | null;
247
+ }
248
+
249
+ /**
250
+ * GRA-55 EM assessment: "the comparison must be against the previous call's
251
+ * set at the same severity over a comparable window, and if the windows are
252
+ * not comparable the honest answer is to suppress the classification and
253
+ * say why." This is that gate.
254
+ *
255
+ * Comparable when either call was chained (`since: "last"` on both this
256
+ * call and the one before it — two calls each picking up where the last
257
+ * left off is continuity by construction, regardless of how the resulting
258
+ * spans happen to measure up against each other), or, for two calls with no
259
+ * such chain, when the two windows overlap by at least half of the shorter
260
+ * one — enough that "this finding vanished" is more likely to mean it
261
+ * actually stopped than that the second call simply looked somewhere else.
262
+ */
263
+ export function windowsComparable(
264
+ previous: FindingsDigest,
265
+ currentSinceLast: boolean,
266
+ currentWindow: { from: number; to: number },
267
+ ): boolean {
268
+ if (previous.sinceLast && currentSinceLast) return true;
269
+
270
+ const overlapFrom = Math.max(previous.window.from, currentWindow.from);
271
+ const overlapTo = Math.min(previous.window.to, currentWindow.to);
272
+ const overlap = Math.max(0, overlapTo - overlapFrom);
273
+ const previousLen = Math.max(0, previous.window.to - previous.window.from);
274
+ const currentLen = Math.max(0, currentWindow.to - currentWindow.from);
275
+ const shorter = Math.min(previousLen, currentLen);
276
+ if (shorter === 0) return overlap === 0 && previousLen === currentLen; // both zero-width, same instant
277
+ return overlap >= shorter / 2;
278
+ }
279
+
280
+ /**
281
+ * Classifies `current` against `previous` (the last call's digest, or null
282
+ * for "no previous call to compare against"). Never mutates either input.
283
+ */
284
+ export function classify(
285
+ current: Finding[],
286
+ previous: FindingsDigest | null,
287
+ currentSinceLast: boolean,
288
+ currentWindow: { from: number; to: number },
289
+ ): ClassifyResult {
290
+ if (!previous) {
291
+ // Nothing to compare against — an ordinary first call, not an anomaly.
292
+ return { findings: current, counts: null, skippedNote: null };
293
+ }
294
+ if (!windowsComparable(previous, currentSinceLast, currentWindow)) {
295
+ return {
296
+ findings: current,
297
+ counts: null,
298
+ skippedNote:
299
+ "classification skipped: the previous findings call covered a window not comparable to this one",
300
+ };
301
+ }
302
+
303
+ const previousById = new Map(previous.findings.map((f) => [f.id, f.count]));
304
+ const currentIds = new Set(current.map((f) => f.id));
305
+
306
+ let newCount = 0;
307
+ let ongoingCount = 0;
308
+ const classifiedCurrent: ClassifiedFinding[] = current.map((finding) => {
309
+ const previousCount = previousById.get(finding.id);
310
+ if (previousCount === undefined) {
311
+ newCount++;
312
+ return { ...finding, status: "new" };
313
+ }
314
+ ongoingCount++;
315
+ return { ...finding, status: "ongoing", delta: (finding.count ?? 0) - previousCount };
316
+ });
317
+
318
+ const resolved: ResolvedFinding[] = [];
319
+ for (const [id, previousCount] of previousById) {
320
+ if (!currentIds.has(id)) resolved.push({ id, status: "resolved", previousCount });
321
+ }
322
+
323
+ return {
324
+ findings: [...classifiedCurrent, ...resolved],
325
+ counts: { new: newCount, ongoing: ongoingCount, resolved: resolved.length },
326
+ skippedNote: null,
327
+ };
328
+ }
329
+
330
+ /** `"N new, M ongoing, K resolved"` — the summary phrase `findings`' description promises in place of restating ongoing findings. Omits a zero count rather than padding every summary with "0 resolved". */
331
+ export function classificationSummary(counts: { new: number; ongoing: number; resolved: number }): string {
332
+ const parts: string[] = [];
333
+ if (counts.new > 0) parts.push(`${counts.new} new`);
334
+ if (counts.ongoing > 0) parts.push(`${counts.ongoing} ongoing`);
335
+ if (counts.resolved > 0) parts.push(`${counts.resolved} resolved`);
336
+ return parts.length > 0 ? parts.join(", ") : "nothing new, ongoing or resolved";
337
+ }