@k71n/agent-probe 0.1.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,233 @@
1
+ /**
2
+ * Workspace marker scan — server-side truth.
3
+ *
4
+ * Finds Probe Markers (`<MARKER_TOKEN>-begin <probe-id>` … `-end <probe-id>`
5
+ * own-line comment pairs) by reading the workspace filesystem ONLY — never
6
+ * agent-reported state. Detection is intentionally dumb: substring/regex per
7
+ * line, no AST, no language detection. Grep-equivalence is the contract.
8
+ *
9
+ * This module knows nothing of SessionManager/MCP/SQL. Probe removal
10
+ * and verify_cleanup (+ the MARKERS_REMAIN gate) build on these exact
11
+ * locations.
12
+ */
13
+ import { spawnSync } from "node:child_process";
14
+ import { lstatSync, readdirSync, readFileSync } from "node:fs";
15
+ import { join, relative, sep } from "node:path";
16
+ import { MARKER_TOKEN } from "../constants.js";
17
+ import { log } from "../logger.js";
18
+ const escaped = MARKER_TOKEN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19
+ /**
20
+ * Matches the token pattern anywhere on a line, regardless of comment
21
+ * leader (`//`, `#`, `--`, `<!--`, `;`, `*` …) — the convention must hold
22
+ * for any language with line comments. Own-line placement is for
23
+ * formatter resilience, not detection.
24
+ */
25
+ const MARKER_RE = new RegExp(`${escaped}-(begin|end)[ \\t]+(\\S+)`);
26
+ /** Trailing comment closers glued to the id (`p7-->` → `p7`) are not part of it. */
27
+ function cleanProbeId(raw) {
28
+ return raw.replace(/(?:-->|\*\/)+$/, "");
29
+ }
30
+ /** Scan one file's text for marker lines. CRLF-tolerant; lines are 1-based. */
31
+ export function scanText(text, file) {
32
+ const hits = [];
33
+ const lines = text.split(/\r?\n/);
34
+ for (let i = 0; i < lines.length; i++) {
35
+ const m = MARKER_RE.exec(lines[i]);
36
+ if (!m)
37
+ continue;
38
+ const probe_id = cleanProbeId(m[2]);
39
+ if (probe_id === "")
40
+ continue; // closer-only "id" is not a marker
41
+ hits.push({ file, line: i + 1, kind: m[1], probe_id });
42
+ }
43
+ return hits;
44
+ }
45
+ /**
46
+ * Sequential per-file, per-probe-id pairing (nesting is not part of the
47
+ * convention). A begin without a later end for the same probe_id in the
48
+ * same file — or vice versa — is an ORPHAN, reported distinctly, never
49
+ * absorbed ("none orphaned").
50
+ */
51
+ export function pairHits(hits) {
52
+ const paired = [];
53
+ const orphans = [];
54
+ const byFile = new Map();
55
+ for (const hit of hits) {
56
+ const list = byFile.get(hit.file) ?? [];
57
+ list.push(hit);
58
+ byFile.set(hit.file, list);
59
+ }
60
+ for (const fileHits of byFile.values()) {
61
+ fileHits.sort((a, b) => a.line - b.line);
62
+ /** FIFO of begins awaiting their end, per probe_id. */
63
+ const pending = new Map();
64
+ for (const hit of fileHits) {
65
+ if (hit.kind === "begin") {
66
+ const queue = pending.get(hit.probe_id) ?? [];
67
+ queue.push(hit);
68
+ pending.set(hit.probe_id, queue);
69
+ }
70
+ else {
71
+ const queue = pending.get(hit.probe_id);
72
+ const open = queue?.shift();
73
+ if (open)
74
+ paired.push(open, hit);
75
+ else
76
+ orphans.push(hit);
77
+ }
78
+ }
79
+ for (const queue of pending.values())
80
+ orphans.push(...queue);
81
+ }
82
+ const byLocation = (a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1;
83
+ paired.sort(byLocation);
84
+ orphans.sort(byLocation);
85
+ return { paired, orphans };
86
+ }
87
+ /**
88
+ * Derive deletion ranges from a scan — read-only; the server NEVER writes
89
+ * to workspace files. Removal is the AGENT's job (trust separation:
90
+ * remover = agent, verifier = server); this helper only turns paired
91
+ * markers into the ranges the agent deletes.
92
+ *
93
+ * The mechanical removal procedure (raw material for the playbook):
94
+ * 1. Scan the workspace (`scanWorkspace`).
95
+ * 2. Per file, sort ranges by `start_line` DESCENDING and delete
96
+ * bottom-up, so earlier line numbers stay valid — deleting [10–12]
97
+ * shifts every later line up by 3, so a range recorded at [20–22]
98
+ * would otherwise now live at [17–19]. This ordering rule is THE
99
+ * gotcha of line-based removal. (Cross-file order is irrelevant —
100
+ * ranges never span files.)
101
+ * 3. Delete whole lines, never blank them.
102
+ * 4. Re-scan to confirm zero markers — locations from a scan taken
103
+ * BEFORE any deletion are stale; re-scan-then-remove is the loop
104
+ * invariant, and removal is repeatable until clean.
105
+ *
106
+ * Orphans are NOT ranges — they surface as orphans for the agent to
107
+ * resolve, never as guessed deletions.
108
+ */
109
+ export function removalRanges(scan) {
110
+ const ranges = [];
111
+ /** markers holds only paired hits; re-walk them begin→end per file/probe_id. */
112
+ const pending = new Map();
113
+ const keyOf = (h) => `${h.file}\0${h.probe_id}`;
114
+ for (const hit of scan.markers) {
115
+ if (hit.kind === "begin") {
116
+ const queue = pending.get(keyOf(hit)) ?? [];
117
+ queue.push(hit);
118
+ pending.set(keyOf(hit), queue);
119
+ }
120
+ else {
121
+ const open = pending.get(keyOf(hit))?.shift();
122
+ if (open)
123
+ ranges.push({ file: hit.file, start_line: open.line, end_line: hit.line });
124
+ }
125
+ }
126
+ return ranges.sort((a, b) => a.file === b.file ? a.start_line - b.start_line : a.file < b.file ? -1 : 1);
127
+ }
128
+ /** Hard skip-list (binding): node_modules/ and .git/ at any depth. */
129
+ function skipListed(rel) {
130
+ return rel.split(/[\\/]/).some((seg) => seg === "node_modules" || seg === ".git");
131
+ }
132
+ /** git's own heuristic: a NUL byte in the first 8 KiB means binary. */
133
+ function isBinary(buf) {
134
+ return buf.subarray(0, 8192).includes(0);
135
+ }
136
+ /**
137
+ * Primary enumeration: tracked + untracked-but-not-gitignored, exactly as
138
+ * the spec words it, in one git command. Returns null on any failure (no
139
+ * repo, no git binary, non-zero exit) so the caller falls back. Local-only
140
+ * subprocess — no egress concern.
141
+ */
142
+ function listFilesGit(root) {
143
+ let res;
144
+ try {
145
+ res = spawnSync("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
146
+ encoding: "utf8",
147
+ maxBuffer: 64 * 1024 * 1024,
148
+ });
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ if (res.error || res.status !== 0 || typeof res.stdout !== "string")
154
+ return null;
155
+ return res.stdout
156
+ .split("\0")
157
+ .filter(Boolean)
158
+ .map((p) => p.split("/").join(sep));
159
+ }
160
+ /**
161
+ * Fallback enumeration: recursive walk. Cannot honor .gitignore (no parser,
162
+ * by design), so build output may be over-scanned; that is accepted
163
+ * v1 behavior, reported honestly rather than smoothed over. Symlinks are
164
+ * never followed (cycle/escape guard via withFileTypes lstat semantics).
165
+ */
166
+ function listFilesWalk(root) {
167
+ const out = [];
168
+ const walk = (dir) => {
169
+ let entries;
170
+ try {
171
+ entries = readdirSync(dir, { withFileTypes: true });
172
+ }
173
+ catch {
174
+ log.warn("cleanup-verify", `unreadable directory skipped: ${relative(root, dir)}`);
175
+ return;
176
+ }
177
+ for (const entry of entries) {
178
+ if (entry.isSymbolicLink())
179
+ continue;
180
+ const full = join(dir, entry.name);
181
+ if (entry.isDirectory()) {
182
+ if (entry.name === "node_modules" || entry.name === ".git")
183
+ continue;
184
+ walk(full);
185
+ }
186
+ else if (entry.isFile()) {
187
+ out.push(relative(root, full));
188
+ }
189
+ }
190
+ };
191
+ walk(root);
192
+ return out;
193
+ }
194
+ /**
195
+ * Scan a workspace for Probe Markers. Every call is a fresh full scan —
196
+ * no caching, no watching (sessions are short-lived; correctness over
197
+ * speed). Unreadable files are skipped with a warning, never thrown:
198
+ * over-reporting beats silence, but a scan must not die mid-workspace.
199
+ */
200
+ export function scanWorkspace(root) {
201
+ const files = listFilesGit(root) ?? listFilesWalk(root);
202
+ const markers = [];
203
+ const orphans = [];
204
+ let filesScanned = 0;
205
+ for (const rel of files) {
206
+ if (skipListed(rel))
207
+ continue;
208
+ const full = join(root, rel);
209
+ try {
210
+ // lstat: a deleted-but-tracked index entry or a symlink is skipped here.
211
+ if (!lstatSync(full).isFile())
212
+ continue;
213
+ }
214
+ catch {
215
+ continue;
216
+ }
217
+ let buf;
218
+ try {
219
+ buf = readFileSync(full); // one read serves sniff + scan
220
+ }
221
+ catch {
222
+ log.warn("cleanup-verify", `unreadable file skipped: ${rel}`);
223
+ continue;
224
+ }
225
+ if (isBinary(buf))
226
+ continue;
227
+ filesScanned++;
228
+ const { paired, orphans: fileOrphans } = pairHits(scanText(buf.toString("utf8"), rel));
229
+ markers.push(...paired);
230
+ orphans.push(...fileOrphans);
231
+ }
232
+ return { markers, orphans, files_scanned: filesScanned };
233
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Single source of truth for every cross-module constant.
3
+ * Grep here before defining a constant anywhere else.
4
+ */
5
+ /** The published package name (finalized 2026-06-06). */
6
+ export const TOOL_NAME = "agent-probe";
7
+ /**
8
+ * Probe Marker token (value finalized with the package name, 2026-06-06).
9
+ * Markers are own-line comment pairs:
10
+ * // <MARKER_TOKEN>-begin <probe-id>
11
+ * // <MARKER_TOKEN>-end <probe-id>
12
+ */
13
+ export const MARKER_TOKEN = "agent-probe";
14
+ /**
15
+ * The playbook resource URI. Advertised in the start_session
16
+ * response and registered in server.ts — single-sourced here.
17
+ */
18
+ export const PLAYBOOK_URI = "playbook://probes";
19
+ /** Stamped into each session DB via PRAGMA user_version for safe orphan inspection. */
20
+ export const SCHEMA_VERSION = 1;
21
+ /**
22
+ * Closed error-code enum. Every tool error is { code, message, hint } with
23
+ * `code` drawn from here — never inline strings.
24
+ */
25
+ export const ERROR_CODES = {
26
+ NO_ACTIVE_SESSION: "NO_ACTIVE_SESSION",
27
+ STALE_SESSION_EXISTS: "STALE_SESSION_EXISTS",
28
+ MARKERS_REMAIN: "MARKERS_REMAIN",
29
+ RUN_NOT_FOUND: "RUN_NOT_FOUND",
30
+ INSTANCE_CONFLICT: "INSTANCE_CONFLICT",
31
+ /** One active session at a time (v1). */
32
+ SESSION_ALREADY_ACTIVE: "SESSION_ALREADY_ACTIVE",
33
+ /** Illegal session state-machine transition. */
34
+ INVALID_STATE: "INVALID_STATE",
35
+ /** No run is currently open. */
36
+ NO_ACTIVE_RUN: "NO_ACTIVE_RUN",
37
+ };
38
+ /** Resource caps. Featherweight is enforced, not asserted. */
39
+ export const LIMITS = {
40
+ /** Oversized payloads are truncated (event kept, warning emitted). */
41
+ MAX_PAYLOAD_BYTES: 65536,
42
+ /** Further events past the cap are dropped with warning. */
43
+ MAX_EVENTS_PER_SESSION: 100_000,
44
+ /**
45
+ * Hard cap on events per query result (a context window holds
46
+ * hypotheses, not haystacks). Tool `limit` inputs clamp to this.
47
+ */
48
+ MAX_QUERY_EVENTS: 500,
49
+ };
50
+ /**
51
+ * Elicitation wait for run confirmation. Generous by design:
52
+ * the user is busy clicking through a reproduction. The SDK default (60s)
53
+ * is far too short. On timeout the run stays open and end_run closes it.
54
+ */
55
+ export const ELICIT_TIMEOUT_MS = 600_000;
Binary file
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Evidence Store — the ONLY module that touches SQL. One SQLite file per
3
+ * Debug Session via node:sqlite DatabaseSync; destruction is close +
4
+ * unlink() of the db and any WAL sidecars (residual sidecars are
5
+ * shadow data).
6
+ *
7
+ * Strict layering: tools -> SessionManager -> EvidenceStore. This module
8
+ * knows nothing about MCP.
9
+ */
10
+ import { existsSync, mkdirSync, unlinkSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { DatabaseSync } from "node:sqlite";
13
+ import { SCHEMA_VERSION } from "../constants.js";
14
+ export class EvidenceStore {
15
+ db;
16
+ path;
17
+ constructor(db, path) {
18
+ this.db = db;
19
+ this.path = path;
20
+ }
21
+ /** Create a fresh per-session database with the full v1 schema. */
22
+ static create(dir, sessionId, goal) {
23
+ mkdirSync(dir, { recursive: true });
24
+ const path = join(dir, `${sessionId}.db`);
25
+ const db = new DatabaseSync(path);
26
+ db.exec("PRAGMA journal_mode = WAL");
27
+ db.exec("PRAGMA synchronous = NORMAL");
28
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
29
+ db.exec(`
30
+ CREATE TABLE session (
31
+ id TEXT PRIMARY KEY,
32
+ goal TEXT NOT NULL,
33
+ state TEXT NOT NULL,
34
+ created INTEGER NOT NULL
35
+ );
36
+ CREATE TABLE runs (
37
+ id TEXT PRIMARY KEY,
38
+ tag TEXT,
39
+ started INTEGER,
40
+ ended INTEGER
41
+ );
42
+ CREATE TABLE events (
43
+ seq INTEGER PRIMARY KEY,
44
+ run_id TEXT,
45
+ probe_id TEXT NOT NULL,
46
+ service TEXT NOT NULL,
47
+ file TEXT NOT NULL,
48
+ line INTEGER NOT NULL,
49
+ ts_probe INTEGER NOT NULL,
50
+ ts_server INTEGER NOT NULL,
51
+ trace_id TEXT,
52
+ parent_id TEXT,
53
+ payload TEXT NOT NULL
54
+ );
55
+ `);
56
+ db.prepare("INSERT INTO session (id, goal, state, created) VALUES (?, ?, ?, ?)").run(sessionId, goal, "idle", Date.now());
57
+ return new EvidenceStore(db, path);
58
+ }
59
+ readSession() {
60
+ return this.db.prepare("SELECT id, goal, state, created FROM session").get();
61
+ }
62
+ /**
63
+ * Safe read-only inspection of a (possibly orphaned/corrupt/foreign) session
64
+ * file — `user_version` exists for exactly this. Never throws:
65
+ * corrupt files return null and are treated as disposable orphans.
66
+ */
67
+ static inspectSession(path) {
68
+ let db = null;
69
+ try {
70
+ db = new DatabaseSync(path, { readOnly: true });
71
+ const userVersion = db.prepare("PRAGMA user_version").get()
72
+ .user_version;
73
+ const row = db.prepare("SELECT goal, created FROM session").get();
74
+ const info = { userVersion };
75
+ if (row?.goal !== undefined)
76
+ info.goal = row.goal;
77
+ if (row?.created !== undefined)
78
+ info.created = row.created;
79
+ return info;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ finally {
85
+ try {
86
+ db?.close();
87
+ }
88
+ catch {
89
+ /* already closed or never opened */
90
+ }
91
+ }
92
+ }
93
+ updateSessionState(state) {
94
+ this.db.prepare("UPDATE session SET state = ?").run(state);
95
+ }
96
+ listTables() {
97
+ const rows = this.db
98
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
99
+ .all();
100
+ return rows.map((r) => r.name);
101
+ }
102
+ userVersion() {
103
+ return this.db.prepare("PRAGMA user_version").get().user_version;
104
+ }
105
+ journalMode() {
106
+ return this.db.prepare("PRAGMA journal_mode").get().journal_mode;
107
+ }
108
+ /**
109
+ * Batched insert — one transaction per call (DatabaseSync
110
+ * is synchronous and shares the event loop; the ingest pipeline flushes
111
+ * once per tick). seq is the INTEGER PRIMARY KEY rowid: monotonic, server-assigned.
112
+ */
113
+ insertEvents(events) {
114
+ if (events.length === 0)
115
+ return;
116
+ const stmt = this.db.prepare(`INSERT INTO events (run_id, probe_id, service, file, line, ts_probe, ts_server, trace_id, parent_id, payload)
117
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
118
+ this.db.exec("BEGIN");
119
+ try {
120
+ for (const e of events) {
121
+ stmt.run(e.runId, e.probeId, e.service, e.file, e.line, e.tsProbe, e.tsServer, e.traceId ?? null, e.parentId ?? null, e.payloadText);
122
+ }
123
+ this.db.exec("COMMIT");
124
+ }
125
+ catch (err) {
126
+ this.db.exec("ROLLBACK");
127
+ throw err;
128
+ }
129
+ }
130
+ insertRun(id, started, tag) {
131
+ this.db
132
+ .prepare("INSERT INTO runs (id, started, tag) VALUES (?, ?, ?)")
133
+ .run(id, started, tag ?? null);
134
+ }
135
+ /** A tag supplied at close OVERWRITES one from start (later decision wins). */
136
+ closeRun(id, ended, tag) {
137
+ this.db
138
+ .prepare("UPDATE runs SET ended = ?, tag = COALESCE(?, tag) WHERE id = ?")
139
+ .run(ended, tag ?? null, id);
140
+ }
141
+ /** Every run + per-run event count; zero-event and open runs included. */
142
+ listRunsWithCounts() {
143
+ return this.db
144
+ .prepare(`SELECT runs.id, runs.tag, runs.started, runs.ended,
145
+ COUNT(events.seq) AS event_count
146
+ FROM runs LEFT JOIN events ON events.run_id = runs.id
147
+ GROUP BY runs.id
148
+ ORDER BY runs.started`)
149
+ .all();
150
+ }
151
+ /**
152
+ * Abort: the run row is removed and its events become
153
+ * unattributed — never silently discarded.
154
+ */
155
+ abortRun(id) {
156
+ this.db.exec("BEGIN");
157
+ try {
158
+ this.db.prepare("UPDATE events SET run_id = NULL WHERE run_id = ?").run(id);
159
+ this.db.prepare("DELETE FROM runs WHERE id = ?").run(id);
160
+ this.db.exec("COMMIT");
161
+ }
162
+ catch (err) {
163
+ this.db.exec("ROLLBACK");
164
+ throw err;
165
+ }
166
+ }
167
+ readRuns() {
168
+ return this.db.prepare("SELECT id, tag, started, ended FROM runs ORDER BY started").all();
169
+ }
170
+ runExists(id) {
171
+ return (this.db.prepare("SELECT 1 AS one FROM runs WHERE id = ?").get(id) !== undefined);
172
+ }
173
+ /**
174
+ * Bounded, filtered event query. Run semantics are three-way:
175
+ * key absent = no run filter; null = unattributed (`IS NULL` — SQL NULL
176
+ * never matches `= ?`); string = that run. All filters combinable; the
177
+ * dynamic WHERE stays parameterized (clause list + params built together,
178
+ * values never interpolated). Ordering matches the exported comparator
179
+ * (`ts_probe, seq`). Returns the true total alongside the limited rows.
180
+ */
181
+ queryEvents(opts) {
182
+ const where = [];
183
+ const params = [];
184
+ if ("run" in opts && opts.run !== undefined) {
185
+ if (opts.run === null)
186
+ where.push("run_id IS NULL");
187
+ else {
188
+ where.push("run_id = ?");
189
+ params.push(opts.run);
190
+ }
191
+ }
192
+ if (opts.probeId !== undefined) {
193
+ where.push("probe_id = ?");
194
+ params.push(opts.probeId);
195
+ }
196
+ if (opts.service !== undefined) {
197
+ where.push("service = ?");
198
+ params.push(opts.service);
199
+ }
200
+ if (opts.tsFrom !== undefined) {
201
+ where.push("ts_probe >= ?");
202
+ params.push(opts.tsFrom);
203
+ }
204
+ if (opts.tsTo !== undefined) {
205
+ where.push("ts_probe <= ?");
206
+ params.push(opts.tsTo);
207
+ }
208
+ const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
209
+ const total = this.db.prepare(`SELECT COUNT(*) AS n FROM events ${clause}`).get(...params).n;
210
+ const rows = this.db
211
+ .prepare(`SELECT * FROM events ${clause} ORDER BY ts_probe, seq LIMIT ?`)
212
+ .all(...params, opts.limit);
213
+ return { rows, total };
214
+ }
215
+ countEvents() {
216
+ return this.db.prepare("SELECT COUNT(*) AS n FROM events").get().n;
217
+ }
218
+ readEvents() {
219
+ return this.db
220
+ .prepare("SELECT * FROM events ORDER BY seq")
221
+ .all();
222
+ }
223
+ /**
224
+ * Destructive end: close the handle, then unlink the db and any
225
+ * -wal/-shm sidecars. The strongest deletion the OS offers.
226
+ */
227
+ destroy() {
228
+ this.db.close();
229
+ removeSessionFiles(this.path);
230
+ }
231
+ }
232
+ /** Shared destructive-deletion semantics (orphan disposal reuses this). */
233
+ export function removeSessionFiles(dbPath) {
234
+ for (const p of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
235
+ if (existsSync(p))
236
+ unlinkSync(p);
237
+ }
238
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Query engine: get_timeline and get_span, plus THE
3
+ * ordering comparator — the one definition of "before" in the whole
4
+ * system. diff.ts imports it; nothing else may re-implement ordering.
5
+ *
6
+ * Ordering authority: ts_probe primary (execution truth), seq tiebreaker
7
+ * (one-clock fallback). SQL ORDER BY (ts_probe, seq) is equivalent and
8
+ * tested as such.
9
+ */
10
+ import { ERROR_CODES, LIMITS } from "../constants.js";
11
+ import { TypedToolError } from "../session/session-manager.js";
12
+ /** THE comparator. ts_probe primary, seq tiebreaker. */
13
+ export function compareEvents(a, b) {
14
+ if (a.ts_probe !== b.ts_probe)
15
+ return a.ts_probe - b.ts_probe;
16
+ return a.seq - b.seq;
17
+ }
18
+ /**
19
+ * Payload presentation: parsed JSON for the agent, or the raw string when
20
+ * unparseable (ingest truncation artifacts). Shared by timeline/span shaping
21
+ * and diff payload deltas.
22
+ */
23
+ export function parsePayload(text) {
24
+ try {
25
+ return { value: JSON.parse(text), truncated: false };
26
+ }
27
+ catch {
28
+ return { value: text, truncated: true };
29
+ }
30
+ }
31
+ /**
32
+ * Shape ordered rows for agent consumption: seq_tied flagging (successor-
33
+ * flagged) + payload presentation. The stored payload
34
+ * text is untouched — parsing here is presentation, not re-shaping.
35
+ */
36
+ export function shapeEvents(rows) {
37
+ return rows.map((row, i) => {
38
+ const shaped = {
39
+ seq: row.seq,
40
+ run_id: row.run_id,
41
+ probe_id: row.probe_id,
42
+ service: row.service,
43
+ file: row.file,
44
+ line: row.line,
45
+ ts_probe: row.ts_probe,
46
+ ts_server: row.ts_server,
47
+ payload: row.payload,
48
+ };
49
+ if (row.trace_id !== null)
50
+ shaped.trace_id = row.trace_id;
51
+ if (row.parent_id !== null)
52
+ shaped.parent_id = row.parent_id;
53
+ const parsed = parsePayload(row.payload);
54
+ shaped.payload = parsed.value;
55
+ if (parsed.truncated)
56
+ shaped.payload_truncated = true;
57
+ if (i > 0 && rows[i - 1].ts_probe === row.ts_probe)
58
+ shaped.seq_tied = true;
59
+ return shaped;
60
+ });
61
+ }
62
+ /** Resolve a tool-level `run` param: "unattributed" -> NULL; ids must exist. */
63
+ function resolveRun(store, run) {
64
+ if (run === "unattributed")
65
+ return null;
66
+ if (!store.runExists(run)) {
67
+ throw new TypedToolError(ERROR_CODES.RUN_NOT_FOUND, `Run ${run} does not exist.`, 'Call list_runs to see available runs, or use "unattributed" for events outside any run.');
68
+ }
69
+ return run;
70
+ }
71
+ /** Timeline for a run (or "unattributed"): comparator-ordered, bounded, honest. */
72
+ export function getTimeline(store, run, limit) {
73
+ const clamped = Math.min(limit ?? LIMITS.MAX_QUERY_EVENTS, LIMITS.MAX_QUERY_EVENTS);
74
+ const { rows, total } = store.queryEvents({ run: resolveRun(store, run), limit: clamped });
75
+ return { events: shapeEvents(rows), truncated: rows.length < total, total };
76
+ }
77
+ /**
78
+ * Bounded span by any combination of run/probe/service/time range.
79
+ * The no-filter call is the maximal query — it still clamps. Empty match
80
+ * is a RESULT; only a missing run id is an error.
81
+ */
82
+ export function getSpan(store, filters) {
83
+ const clamped = Math.min(filters.limit ?? LIMITS.MAX_QUERY_EVENTS, LIMITS.MAX_QUERY_EVENTS);
84
+ const opts = { limit: clamped };
85
+ if (filters.run !== undefined)
86
+ opts.run = resolveRun(store, filters.run);
87
+ if (filters.probe !== undefined)
88
+ opts.probeId = filters.probe;
89
+ if (filters.service !== undefined)
90
+ opts.service = filters.service;
91
+ if (filters.from !== undefined)
92
+ opts.tsFrom = filters.from;
93
+ if (filters.to !== undefined)
94
+ opts.tsTo = filters.to;
95
+ const { rows, total } = store.queryEvents(opts);
96
+ return { events: shapeEvents(rows), truncated: rows.length < total, total };
97
+ }
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Entry point. The Node version gate runs BEFORE anything that could pull in
4
+ * `node:sqlite` loads — hence the dynamic import of the
5
+ * server below. Do not add static imports here beyond dependency-free modules.
6
+ */
7
+ import { TOOL_NAME } from "./constants.js";
8
+ import { meetsMinimumNode, MIN_NODE_VERSION } from "./node-version.js";
9
+ if (!meetsMinimumNode(process.versions.node)) {
10
+ process.stderr.write(`${TOOL_NAME} requires Node >= ${MIN_NODE_VERSION} (found ${process.versions.node}).\n` +
11
+ `It relies on the built-in node:sqlite module, which is only available from ${MIN_NODE_VERSION}.\n` +
12
+ `Please upgrade Node and try again.\n`);
13
+ process.exit(1);
14
+ }
15
+ const { startStdioServer } = await import("./server.js");
16
+ await startStdioServer();