@lightpath-dev/mcp 0.0.0-stage → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lightpath contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @lightpath-dev/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The MCP server of [Lightpath](https://github.com/Unknown-333/Lightpath#readme), so coding agents can ask which code ran for an action you just did in your app.
4
+
5
+ Run it with `npx -y @lightpath-dev/cli mcp`. Setup for Claude Code, Cursor, VS Code, and Kiro: [Ask your coding agent](https://github.com/Unknown-333/Lightpath#ask-your-coding-agent).
@@ -0,0 +1,33 @@
1
+ /** What `lightpath mcp` knows to start Lightpath by itself (D-122), from the CLI and the config. */
2
+ export interface AutoStartOptions {
3
+ /** The CLI's `bin.js`, run with this Node as `dev --headless`. */
4
+ bin: string;
5
+ /** `command` from lightpath.config.json in the server's root, if any. */
6
+ command?: string | string[] | undefined;
7
+ /** `autoStart` from that config: only `true` starts anything. */
8
+ autoStart?: boolean | undefined;
9
+ }
10
+ /** Longest wait for the started daemon, and for run_action, its browser (design section 11). */
11
+ export declare const AUTO_START_WAIT_MS = 60000;
12
+ export declare const DEV_LOG = ".lightpath/dev.log";
13
+ export declare const commandText: (c: string | string[]) => string;
14
+ /**
15
+ * Starts `lightpath dev --headless` once, as a child of this MCP server, with its output appended
16
+ * to `.lightpath/dev.log`. The child doesn't hold this process open, and its whole tree is stopped
17
+ * when this process exits. A hard kill of this process leaves it running; its discovery file
18
+ * then lets the next MCP server reuse it.
19
+ */
20
+ export declare class AutoStarter {
21
+ #private;
22
+ constructor(root: string, opts: AutoStartOptions | undefined);
23
+ /** True when the config allows a start: `command` and `autoStart: true`. */
24
+ get enabled(): boolean;
25
+ get pid(): number | undefined;
26
+ /** The message for a tool when Lightpath isn't running and nothing may start it. */
27
+ notRunning(): string;
28
+ /** Starts the child if it isn't running. Returns the line a tool result starts with. */
29
+ start(): string;
30
+ /** Stops the child and its whole tree. Synchronous, so it can run in an `exit` handler. */
31
+ stop(): void;
32
+ }
33
+ //# sourceMappingURL=autostart.d.ts.map
@@ -0,0 +1,79 @@
1
+ import { execFileSync, spawn } from 'node:child_process';
2
+ import { closeSync, mkdirSync, openSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ /** Longest wait for the started daemon, and for run_action, its browser (design section 11). */
5
+ export const AUTO_START_WAIT_MS = 60_000;
6
+ export const DEV_LOG = '.lightpath/dev.log';
7
+ export const commandText = (c) => (typeof c === 'string' ? c : c.join(' '));
8
+ /**
9
+ * Starts `lightpath dev --headless` once, as a child of this MCP server, with its output appended
10
+ * to `.lightpath/dev.log`. The child doesn't hold this process open, and its whole tree is stopped
11
+ * when this process exits. A hard kill of this process leaves it running; its discovery file
12
+ * then lets the next MCP server reuse it.
13
+ */
14
+ export class AutoStarter {
15
+ #root;
16
+ #opts;
17
+ #child;
18
+ constructor(root, opts) {
19
+ this.#root = root;
20
+ this.#opts = opts;
21
+ }
22
+ /** True when the config allows a start: `command` and `autoStart: true`. */
23
+ get enabled() {
24
+ return Boolean(this.#opts?.autoStart && this.#opts.command && this.#opts.bin);
25
+ }
26
+ get pid() {
27
+ return this.#child?.exitCode === null ? this.#child.pid : undefined;
28
+ }
29
+ /** The message for a tool when Lightpath isn't running and nothing may start it. */
30
+ notRunning() {
31
+ const cmd = this.#opts?.command;
32
+ if (cmd) {
33
+ return (`Lightpath isn't running in ${this.#root}. Start it with: lightpath dev (it runs "${commandText(cmd)}" from lightpath.config.json). ` +
34
+ 'To let tools start it by themselves, set "autoStart": true in lightpath.config.json.');
35
+ }
36
+ return `Lightpath isn't running in ${this.#root}. Start it with: lightpath dev -- <your dev command>`;
37
+ }
38
+ /** Starts the child if it isn't running. Returns the line a tool result starts with. */
39
+ start() {
40
+ const opts = this.#opts;
41
+ const note = `started lightpath dev ("${commandText(opts.command)}") in the background; log: ${DEV_LOG}`;
42
+ if (this.pid !== undefined)
43
+ return note;
44
+ mkdirSync(join(this.#root, '.lightpath'), { recursive: true });
45
+ const log = openSync(join(this.#root, DEV_LOG), 'a');
46
+ try {
47
+ this.#child = spawn(process.execPath, [opts.bin, 'dev', '--headless'], {
48
+ cwd: this.#root,
49
+ stdio: ['ignore', log, log],
50
+ // On POSIX a process group, so the dev command's own children stop with it.
51
+ detached: process.platform !== 'win32',
52
+ windowsHide: true,
53
+ });
54
+ }
55
+ finally {
56
+ closeSync(log);
57
+ }
58
+ this.#child.unref();
59
+ process.once('exit', () => this.stop());
60
+ return note;
61
+ }
62
+ /** Stops the child and its whole tree. Synchronous, so it can run in an `exit` handler. */
63
+ stop() {
64
+ const pid = this.pid;
65
+ this.#child = undefined;
66
+ if (pid === undefined)
67
+ return;
68
+ try {
69
+ if (process.platform === 'win32')
70
+ execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
71
+ else
72
+ process.kill(-pid, 'SIGTERM');
73
+ }
74
+ catch {
75
+ // Already gone.
76
+ }
77
+ }
78
+ }
79
+ //# sourceMappingURL=autostart.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Every tool result is at most 16,000 characters: the 4,000-token budget N at 4 characters a token
3
+ * (D-109). Every tool's text goes through `fit()` or `fitText()` (design section 6.2).
4
+ */
5
+ export declare const MAX_CHARS = 16000;
6
+ /** One file's lines inside one phase of a summary. */
7
+ export interface FileBlock {
8
+ path: string;
9
+ lines: string[];
10
+ functions: number;
11
+ }
12
+ export interface SummaryParts {
13
+ traceId: string;
14
+ /** Always printed: trace, action, page, requests. */
15
+ header: string[];
16
+ sections: {
17
+ title: string;
18
+ files: FileBlock[];
19
+ }[];
20
+ /** Printed after the sections (the "hidden:" line). */
21
+ footer: string[];
22
+ /** Rank of a file, across all its phases: higher stays longer. */
23
+ rank: (path: string) => {
24
+ background: boolean;
25
+ hits: number;
26
+ };
27
+ }
28
+ /**
29
+ * The summary as text, within MAX_CHARS. When the whole text doesn't fit, drops whole files,
30
+ * lowest rank first (background before foreground, then fewest hits), from every phase at once,
31
+ * and ends with one line saying what was cut and how to see it.
32
+ */
33
+ export declare function fit(parts: SummaryParts, max?: number): string;
34
+ /**
35
+ * The last guard for any tool's text: cuts at a line boundary under `max` and says so. The cut
36
+ * line names the call that shows the rest when there is one.
37
+ */
38
+ export declare function fitText(text: string, max?: number, traceId?: string): string;
39
+ //# sourceMappingURL=budget.d.ts.map
package/dist/budget.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Every tool result is at most 16,000 characters: the 4,000-token budget N at 4 characters a token
3
+ * (D-109). Every tool's text goes through `fit()` or `fitText()` (design section 6.2).
4
+ */
5
+ export const MAX_CHARS = 16_000;
6
+ const size = (lines) => lines.reduce((n, l) => n + l.length + 1, 0);
7
+ function render(parts, keep) {
8
+ const out = [...parts.header];
9
+ for (const s of parts.sections) {
10
+ out.push(s.title);
11
+ for (const f of s.files)
12
+ if (keep(f.path))
13
+ out.push(...f.lines);
14
+ }
15
+ out.push(...parts.footer);
16
+ return out.join('\n');
17
+ }
18
+ /**
19
+ * The summary as text, within MAX_CHARS. When the whole text doesn't fit, drops whole files,
20
+ * lowest rank first (background before foreground, then fewest hits), from every phase at once,
21
+ * and ends with one line saying what was cut and how to see it.
22
+ */
23
+ export function fit(parts, max = MAX_CHARS) {
24
+ const full = render(parts, () => true);
25
+ if (full.length <= max)
26
+ return full;
27
+ const bytes = new Map();
28
+ const fns = new Map();
29
+ for (const s of parts.sections) {
30
+ for (const f of s.files) {
31
+ bytes.set(f.path, (bytes.get(f.path) ?? 0) + size(f.lines));
32
+ fns.set(f.path, (fns.get(f.path) ?? 0) + f.functions);
33
+ }
34
+ }
35
+ // Lowest rank first.
36
+ const order = [...bytes.keys()].sort((a, b) => {
37
+ const ra = parts.rank(a);
38
+ const rb = parts.rank(b);
39
+ if (ra.background !== rb.background)
40
+ return ra.background ? -1 : 1;
41
+ return ra.hits - rb.hits || b.localeCompare(a);
42
+ });
43
+ const dropped = new Set();
44
+ let length = full.length;
45
+ let droppedChars = 0;
46
+ const cut = () => {
47
+ const top = [...dropped].at(-1) ?? '';
48
+ const n = dropped.size;
49
+ const m = [...dropped].reduce((sum, p) => sum + (fns.get(p) ?? 0), 0);
50
+ return (`not shown: ${n} file${n === 1 ? '' : 's'}, ${m} function${m === 1 ? '' : 's'} ` +
51
+ `(${droppedChars.toLocaleString('en-US')} chars). Call get_trace with traceId "${parts.traceId}" and file "${top}" to see one.`);
52
+ };
53
+ for (const path of order) {
54
+ if (length + 1 + cut().length <= max)
55
+ break;
56
+ dropped.add(path);
57
+ const b = bytes.get(path) ?? 0;
58
+ length -= b;
59
+ droppedChars += b;
60
+ }
61
+ return fitText(`${render(parts, (p) => !dropped.has(p))}\n${cut()}`, max, parts.traceId);
62
+ }
63
+ /**
64
+ * The last guard for any tool's text: cuts at a line boundary under `max` and says so. The cut
65
+ * line names the call that shows the rest when there is one.
66
+ */
67
+ export function fitText(text, max = MAX_CHARS, traceId) {
68
+ if (text.length <= max)
69
+ return text;
70
+ const tail = traceId
71
+ ? `not shown: the rest (${(text.length).toLocaleString('en-US')} chars in all). Call get_trace with traceId "${traceId}" and a file or function to see one part.`
72
+ : `not shown: the rest (${(text.length).toLocaleString('en-US')} chars in all).`;
73
+ const room = max - tail.length - 1;
74
+ const at = text.lastIndexOf('\n', room);
75
+ return `${text.slice(0, at > 0 ? at : room)}\n${tail}`;
76
+ }
77
+ //# sourceMappingURL=budget.js.map
package/dist/diff.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Trace } from '@lightpath-dev/protocol';
2
+ /** True when both traces carry function lists, so the function-level diff applies. */
3
+ export declare function hasFunctions(t: Trace): boolean;
4
+ /**
5
+ * `diff_traces` at function level (design section 6.4, D-118), in this order: requests whose
6
+ * status changed, functions that ran in only one trace, functions whose not-run lines changed
7
+ * (each side in its own line numbers), and functions whose call count changed. Problems join in
8
+ * build 4, when traces carry them.
9
+ */
10
+ export declare function diffFunctions(a: Trace, b: Trace): string;
11
+ //# sourceMappingURL=diff.d.ts.map
package/dist/diff.js ADDED
@@ -0,0 +1,126 @@
1
+ import { fitText } from './budget.js';
2
+ import { errorsOf, failedRequestsOf, functionName, numberRanges, problemText } from './summary.js';
3
+ /**
4
+ * Functions of one trace merged over phases and keyed by file, name and order among functions
5
+ * of that name in the file. Line numbers aren't in the key: a fix moves them (D-118).
6
+ */
7
+ function functionsOf(trace) {
8
+ const byFn = new Map();
9
+ for (const f of trace.functions ?? []) {
10
+ const id = `${f.path}|${f.name}|${f.startLine}`;
11
+ const m = byFn.get(id);
12
+ if (m) {
13
+ m.count += f.count;
14
+ // Not run in every phase that ran it.
15
+ const mine = new Set((f.notRun ?? []).map((l) => l - f.startLine));
16
+ for (const o of m.notRun)
17
+ if (!mine.has(o))
18
+ m.notRun.delete(o);
19
+ }
20
+ else {
21
+ byFn.set(id, {
22
+ path: f.path,
23
+ name: f.name,
24
+ startLine: f.startLine,
25
+ endLine: f.endLine,
26
+ count: f.count,
27
+ notRun: new Set((f.notRun ?? []).map((l) => l - f.startLine)),
28
+ });
29
+ }
30
+ }
31
+ const out = new Map();
32
+ const seen = new Map();
33
+ for (const m of [...byFn.values()].sort((a, b) => a.path.localeCompare(b.path) || a.startLine - b.startLine)) {
34
+ const base = `${m.path}|${m.name}`;
35
+ const n = seen.get(base) ?? 0;
36
+ seen.set(base, n + 1);
37
+ out.set(`${base}#${n}`, m);
38
+ }
39
+ return out;
40
+ }
41
+ const where = (m) => `${m.path} ${functionName(m)} (line ${m.startLine})`;
42
+ const lines = (m, offsets) => numberRanges([...offsets].map((o) => o + m.startLine));
43
+ /** True when both traces carry function lists, so the function-level diff applies. */
44
+ export function hasFunctions(t) {
45
+ return t.functions !== undefined;
46
+ }
47
+ /**
48
+ * `diff_traces` at function level (design section 6.4, D-118), in this order: requests whose
49
+ * status changed, functions that ran in only one trace, functions whose not-run lines changed
50
+ * (each side in its own line numbers), and functions whose call count changed. Problems join in
51
+ * build 4, when traces carry them.
52
+ */
53
+ export function diffFunctions(a, b) {
54
+ const out = [`Trace ${a.id} (${a.label}) → ${b.id} (${b.label}):`];
55
+ // Problems first (design 6.4): counts, and the first error that appeared or went away.
56
+ const ea = errorsOf(a);
57
+ const eb = errorsOf(b);
58
+ const fa0 = failedRequestsOf(a);
59
+ const fb0 = failedRequestsOf(b);
60
+ if (ea.total !== eb.total || fa0.join() !== fb0.join()) {
61
+ out.push(`problems: errors ${ea.total} → ${eb.total}, failed requests ${fa0.length} → ${fb0.length}`);
62
+ const msgs = (l) => new Set(l.map((p) => problemText(p)));
63
+ const ma = msgs(ea.list);
64
+ const mb = msgs(eb.list);
65
+ const gone = [...ma].find((m) => !mb.has(m));
66
+ const came = [...mb].find((m) => !ma.has(m));
67
+ if (gone)
68
+ out.push(` went away: ${gone}`);
69
+ if (came)
70
+ out.push(` appeared: ${came}`);
71
+ }
72
+ // Requests matched by method and path, in order.
73
+ const statuses = [];
74
+ const used = new Set();
75
+ for (const ra of a.requests) {
76
+ const j = b.requests.findIndex((rb, k) => !used.has(k) && rb.method === ra.method && rb.path === ra.path);
77
+ if (j < 0) {
78
+ statuses.push(` ${ra.method} ${ra.path} ${ra.status ?? '?'} → not sent`);
79
+ continue;
80
+ }
81
+ used.add(j);
82
+ const rb = b.requests[j];
83
+ if (rb && rb.status !== ra.status)
84
+ statuses.push(` ${ra.method} ${ra.path} ${ra.status ?? '?'} → ${rb.status ?? '?'}`);
85
+ }
86
+ for (const [k, rb] of b.requests.entries())
87
+ if (!used.has(k))
88
+ statuses.push(` ${rb.method} ${rb.path} newly sent → ${rb.status ?? '?'}`);
89
+ if (statuses.length)
90
+ out.push('requests:', ...statuses);
91
+ const fa = functionsOf(a);
92
+ const fb = functionsOf(b);
93
+ const onlyA = [...fa].filter(([k]) => !fb.has(k)).map(([, m]) => ` ${where(m)} ×${m.count}`);
94
+ const onlyB = [...fb].filter(([k]) => !fa.has(k)).map(([, m]) => ` ${where(m)} ×${m.count}`);
95
+ if (onlyA.length)
96
+ out.push(`ran only in ${a.id}:`, ...onlyA);
97
+ if (onlyB.length)
98
+ out.push(`ran only in ${b.id}:`, ...onlyB);
99
+ const branches = [];
100
+ const counts = [];
101
+ for (const [k, ma] of fa) {
102
+ const mb = fb.get(k);
103
+ if (!mb)
104
+ continue;
105
+ const nowRuns = [...ma.notRun].filter((o) => !mb.notRun.has(o));
106
+ const noLonger = [...mb.notRun].filter((o) => !ma.notRun.has(o));
107
+ if (nowRuns.length || noLonger.length) {
108
+ const parts = [
109
+ ...(nowRuns.length ? [`now runs ${lines(ma, nowRuns)}`] : []),
110
+ ...(noLonger.length ? [`no longer runs ${lines(mb, noLonger)}`] : []),
111
+ ];
112
+ const shift = mb.startLine !== ma.startLine ? ` (line ${ma.startLine} → ${mb.startLine})` : '';
113
+ branches.push(` ${ma.path} ${functionName(ma)}${shift}: ${parts.join('; ')}`);
114
+ }
115
+ if (ma.count !== mb.count)
116
+ counts.push(` ${where(ma)}: ×${ma.count} → ×${mb.count}`);
117
+ }
118
+ if (branches.length)
119
+ out.push('branches changed (each side in its own line numbers):', ...branches);
120
+ if (counts.length)
121
+ out.push('call counts changed:', ...counts);
122
+ if (out.length === 1)
123
+ out.push('none: both traces ran the same functions and branches');
124
+ return fitText(out.join('\n'));
125
+ }
126
+ //# sourceMappingURL=diff.js.map
@@ -0,0 +1,33 @@
1
+ import type { ActionRequest, ActionResult, LineHit, Trace, TraceSummary } from '@lightpath-dev/protocol';
2
+ /** Compresses sorted line numbers into ranges: [1,2,3,7,9,10] → "1-3, 7, 9-10". */
3
+ export declare function lineRanges(lines: readonly LineHit[]): string;
4
+ /** A compact, agent-readable description of one trace. */
5
+ export declare function describeTrace(trace: Trace): string;
6
+ export declare function describeSummaries(traces: readonly TraceSummary[]): string;
7
+ /** Lines that ran in only one of two traces, per file. */
8
+ export declare function diffTraces(a: Trace, b: Trace): string;
9
+ type ActionInput = Pick<ActionRequest, 'target' | 'within' | 'action' | 'key' | 'fill'>;
10
+ /** `click button "Checkout"`, `press Enter on textbox "Search" within "Header"`. */
11
+ export declare function describeAction(input: ActionInput): string;
12
+ /**
13
+ * run_action's answer (design section 4.5): the trace's first line, the action and page lines,
14
+ * warnings, then the summary by phase (section 6.1). On failure, the outcome and the candidates.
15
+ * Every fill value in the text is redacted (rule 6). itText runs again after redaction, which can lengthen
16
+ * the text.
17
+ */
18
+ export declare function describeActionResult(input: ActionInput, result: ActionResult): string;
19
+ /**
20
+ * run_request's answer (design section 4A): one `sent:` line with the method, the path (never the
21
+ * query), status, response size and time, then the backend trace summary. The request body, its
22
+ * headers and the response's body and headers never appear.
23
+ */
24
+ export declare function describeRequestResult(method: string, path: string, sent: {
25
+ status: number;
26
+ bytes: number;
27
+ durationMs: number;
28
+ redirect?: string | undefined;
29
+ }, trace: Trace): string;
30
+ /** For a 3xx: that it wasn't followed, and where it points when that is loopback. */
31
+ export declare function redirectNote(status: number, loopbackTarget: string | undefined): string | undefined;
32
+ export {};
33
+ //# sourceMappingURL=format.d.ts.map
package/dist/format.js ADDED
@@ -0,0 +1,118 @@
1
+ import { fitText, MAX_CHARS } from './budget.js';
2
+ import { redactFillValues } from './redact.js';
3
+ import { summarizeTrace } from './summary.js';
4
+ /** Compresses sorted line numbers into ranges: [1,2,3,7,9,10] → "1-3, 7, 9-10". */
5
+ export function lineRanges(lines) {
6
+ const parts = [];
7
+ let start;
8
+ let prev;
9
+ for (const { line } of lines) {
10
+ if (start === undefined)
11
+ start = line;
12
+ else if (prev !== undefined && line !== prev + 1) {
13
+ parts.push(start === prev ? `${start}` : `${start}-${prev}`);
14
+ start = line;
15
+ }
16
+ prev = line;
17
+ }
18
+ if (start !== undefined && prev !== undefined)
19
+ parts.push(start === prev ? `${start}` : `${start}-${prev}`);
20
+ return parts.join(', ');
21
+ }
22
+ /** A compact, agent-readable description of one trace. */
23
+ export function describeTrace(trace) {
24
+ const out = [`Trace ${trace.id}: ${trace.label}`];
25
+ for (const r of trace.requests) {
26
+ out.push(` request: ${r.method} ${r.path}${r.status ? ` → ${r.status}` : ''} (${r.collector}, ${Math.round(r.durationMs)} ms${r.overlapped ? ', overlapped with another request' : ''})`);
27
+ }
28
+ if (trace.files.length === 0)
29
+ out.push(' no workspace code ran');
30
+ for (const f of trace.files) {
31
+ const hottest = Math.max(...f.lines.map((l) => l.count));
32
+ const bg = f.background ? ' (background: only code that also runs while idle, likely unrelated)' : '';
33
+ out.push(` ${f.path} [${f.collector}] lines ${lineRanges(f.lines)}${hottest > 1 ? ` (max ${hottest} hits)` : ''}${bg}`);
34
+ }
35
+ const dropped = trace.dropped.filter((d) => d.count > 0);
36
+ if (dropped.length > 0)
37
+ out.push(` not shown: ${dropped.map((d) => `${d.count} ${d.reason}`).join(', ')}`);
38
+ return out.join('\n');
39
+ }
40
+ export function describeSummaries(traces) {
41
+ if (traces.length === 0)
42
+ return 'No traces yet. Click something in the app opened by `lightpath dev`.';
43
+ return traces
44
+ .map((t) => `${t.id} ${new Date(t.startedAt).toISOString()} ${t.label} (${t.fileCount} files${t.problemCount ? `, ${t.problemCount} problem${t.problemCount === 1 ? '' : 's'}` : ''})`)
45
+ .join('\n');
46
+ }
47
+ /** Lines that ran in only one of two traces, per file. */
48
+ export function diffTraces(a, b) {
49
+ const index = (t) => new Map(t.files.map((f) => [f.path, new Set(f.lines.map((l) => l.line))]));
50
+ const ia = index(a);
51
+ const ib = index(b);
52
+ const out = [`Lines that ran in only one of ${a.id} (${a.label}) and ${b.id} (${b.label}):`];
53
+ for (const path of [...new Set([...ia.keys(), ...ib.keys()])].sort()) {
54
+ const la = ia.get(path) ?? new Set();
55
+ const lb = ib.get(path) ?? new Set();
56
+ const onlyA = [...la].filter((l) => !lb.has(l)).sort((x, y) => x - y);
57
+ const onlyB = [...lb].filter((l) => !la.has(l)).sort((x, y) => x - y);
58
+ const fmt = (ls) => lineRanges(ls.map((line) => ({ line, count: 1 })));
59
+ if (onlyA.length)
60
+ out.push(` ${path}: only in A: ${fmt(onlyA)}`);
61
+ if (onlyB.length)
62
+ out.push(` ${path}: only in B: ${fmt(onlyB)}`);
63
+ }
64
+ return out.length === 1 ? `${out[0]}\n none, both traces ran the same lines` : out.join('\n');
65
+ }
66
+ /** `click button "Checkout"`, `press Enter on textbox "Search" within "Header"`. */
67
+ export function describeAction(input) {
68
+ const where = `${input.target}${input.within ? ` within "${input.within}"` : ''}`;
69
+ const verb = input.action === 'press' ? `press ${input.key ?? 'Enter'} on ${where}` : `click ${where}`;
70
+ const n = input.fill?.length ?? 0;
71
+ return n > 0 ? `${verb}, after filling ${n} field${n === 1 ? '' : 's'}` : verb;
72
+ }
73
+ /**
74
+ * run_action's answer (design section 4.5): the trace's first line, the action and page lines,
75
+ * warnings, then the summary by phase (section 6.1). On failure, the outcome and the candidates.
76
+ * Every fill value in the text is redacted (rule 6). itText runs again after redaction, which can lengthen
77
+ * the text.
78
+ */
79
+ export function describeActionResult(input, result) {
80
+ const values = (input.fill ?? []).map((f) => f.value);
81
+ if (!result.ok) {
82
+ const { error } = result;
83
+ const out = [`run_action did not record a trace: ${error.code}. ${error.message}`];
84
+ if (error.candidates?.length) {
85
+ out.push(error.code === 'ambiguous' ? 'Matches:' : 'Closest targets on the page:');
86
+ for (const c of error.candidates)
87
+ out.push(` ${c}`);
88
+ }
89
+ return redactFillValues(out.join('\n'), values);
90
+ }
91
+ const text = summarizeTrace(result.trace, [
92
+ `action: ${describeAction(input)} (boundary ${result.boundary}, settled ${result.settled})`,
93
+ `page: ${result.pageUrl}`,
94
+ ...result.warnings.map((w) => `warning (${w.code}): ${w.message}`),
95
+ ]);
96
+ return fitText(redactFillValues(text, values), MAX_CHARS, result.trace.id);
97
+ }
98
+ /**
99
+ * run_request's answer (design section 4A): one `sent:` line with the method, the path (never the
100
+ * query), status, response size and time, then the backend trace summary. The request body, its
101
+ * headers and the response's body and headers never appear.
102
+ */
103
+ export function describeRequestResult(method, path, sent, trace) {
104
+ const lines = [
105
+ `sent: ${method} ${path} → ${sent.status} (${sent.bytes.toLocaleString('en-US')} bytes, ${Math.round(sent.durationMs)} ms)`,
106
+ ];
107
+ const note = redirectNote(sent.status, sent.redirect);
108
+ if (note)
109
+ lines.push(note);
110
+ return fitText(summarizeTrace(trace, lines), MAX_CHARS, trace.id);
111
+ }
112
+ /** For a 3xx: that it wasn't followed, and where it points when that is loopback. */
113
+ export function redirectNote(status, loopbackTarget) {
114
+ if (status < 300 || status >= 400)
115
+ return undefined;
116
+ return `redirect not followed${loopbackTarget ? `: it points to ${loopbackTarget}` : ' (it points off loopback, or nowhere)'}`;
117
+ }
118
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1,10 @@
1
+ export { fit, fitText, MAX_CHARS } from './budget.js';
2
+ export { diffFunctions } from './diff.js';
3
+ export { describeAction, describeActionResult, describeSummaries, describeTrace, diffTraces, lineRanges, } from './format.js';
4
+ export { MIN_SECRET_LENGTH, redact, redactFillValues } from './redact.js';
5
+ export { isLoopbackAddress, resolveTarget as resolveRequestTarget } from './request.js';
6
+ export { createMcpServer, type McpOptions, RUN_ACTION_DESCRIPTION, runMcpServer } from './server.js';
7
+ export { sliceTrace } from './slices.js';
8
+ export { summarizeTrace } from './summary.js';
9
+ export { DESCRIPTIONS, INSTRUCTIONS } from './texts.js';
10
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export { fit, fitText, MAX_CHARS } from './budget.js';
2
+ export { diffFunctions } from './diff.js';
3
+ export { describeAction, describeActionResult, describeSummaries, describeTrace, diffTraces, lineRanges, } from './format.js';
4
+ export { MIN_SECRET_LENGTH, redact, redactFillValues } from './redact.js';
5
+ export { isLoopbackAddress, resolveTarget as resolveRequestTarget } from './request.js';
6
+ export { createMcpServer, RUN_ACTION_DESCRIPTION, runMcpServer } from './server.js';
7
+ export { sliceTrace } from './slices.js';
8
+ export { summarizeTrace } from './summary.js';
9
+ export { DESCRIPTIONS, INSTRUCTIONS } from './texts.js';
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Redaction rules 2 to 5 (D-123, design section 9), for every runtime string a tool prints:
3
+ * problem messages, request paths and URLs, the page URL, labels. Never for source slices.
4
+ * Rules 2 and 5 over-redact on purpose (`monkey=1`): a missed secret is worse.
5
+ */
6
+ export declare function redact(text: string): string;
7
+ /** Shortest `fill` value that is redacted; shorter ones would hide ordinary words and numbers. */
8
+ export declare const MIN_SECRET_LENGTH = 4;
9
+ /**
10
+ * Redaction rule 6 (D-123, design section 9): every occurrence of a value run_action filled in this
11
+ * call, 4 characters or longer, becomes `[redacted]` in the result text. Rules 2 to 5 come with
12
+ * build 4.
13
+ */
14
+ export declare function redactFillValues(text: string, values: readonly string[]): string;
15
+ //# sourceMappingURL=redact.d.ts.map
package/dist/redact.js ADDED
@@ -0,0 +1,41 @@
1
+ const WORDS = 'token|key|secret|pass|auth|session|sig';
2
+ const R = '[redacted]';
3
+ /** Rule 2: URL query values whose name holds a secret word, any case. */
4
+ const QUERY = new RegExp(`([?&][^=&#\\s]*(?:${WORDS})[^=&#\\s]*=)([^&#\\s]*)`, 'gi');
5
+ /** Rule 3: bearer tokens. */
6
+ const BEARER = /\b(Bearer\s+)(?!\[redacted\])[^\s"',;]+/gi;
7
+ /** Rule 4: JWT-like strings, three base64url parts, the first starting `eyJ`. */
8
+ const JWT = /\beyJ[\w-]*\.[\w-]+\.[\w-]+/g;
9
+ /** Rule 5: `key=value` and `key: value` (quoted or not) whose key holds a secret word. */
10
+ const PAIR = new RegExp(`(["']?)([\\w.-]*(?:${WORDS})[\\w.-]*)\\1(\\s*[:=]\\s*)(["']?)(?!\\[redacted\\])([^\\s"'&,;)}\\]]+)\\4`, 'gi');
11
+ /**
12
+ * Redaction rules 2 to 5 (D-123, design section 9), for every runtime string a tool prints:
13
+ * problem messages, request paths and URLs, the page URL, labels. Never for source slices.
14
+ * Rules 2 and 5 over-redact on purpose (`monkey=1`): a missed secret is worse.
15
+ */
16
+ export function redact(text) {
17
+ return text
18
+ .replace(JWT, R)
19
+ .replace(BEARER, `$1${R}`)
20
+ .replace(QUERY, `$1${R}`)
21
+ .replace(PAIR, (_m, q, key, sep, vq) => `${q}${key}${q}${sep}${vq}${R}${vq}`);
22
+ }
23
+ /** Shortest `fill` value that is redacted; shorter ones would hide ordinary words and numbers. */
24
+ export const MIN_SECRET_LENGTH = 4;
25
+ /**
26
+ * Redaction rule 6 (D-123, design section 9): every occurrence of a value run_action filled in this
27
+ * call, 4 characters or longer, becomes `[redacted]` in the result text. Rules 2 to 5 come with
28
+ * build 4.
29
+ */
30
+ export function redactFillValues(text, values) {
31
+ // A value the app put in a URL appears percent-encoded there ('my pass' as 'my%20pass').
32
+ const forms = values
33
+ .filter((v) => v.length >= MIN_SECRET_LENGTH)
34
+ .flatMap((v) => [v, encodeURIComponent(v)]);
35
+ const secrets = [...new Set(forms)].sort((a, b) => b.length - a.length);
36
+ let out = text;
37
+ for (const s of secrets)
38
+ out = out.split(s).join('[redacted]');
39
+ return out;
40
+ }
41
+ //# sourceMappingURL=redact.js.map