pursr 0.4.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/src/har.js ADDED
@@ -0,0 +1,159 @@
1
+ // pursor — HAR (HTTP Archive) capture.
2
+ //
3
+ // Hooks page.on("request") / page.on("response") / page.on("requestfailed")
4
+ // on an active page and produces a HAR 1.2 spec blob:
5
+ // { log: { version: "1.2", creator: {...}, browser: {...}, pages: [...], entries: [...] } }
6
+ //
7
+ // Use in code:
8
+ // const { startHarCapture, stopHarCapture } = await import("pursor/har");
9
+ // const har = await startHarCapture(page);
10
+ // await page.goto(url);
11
+ // await stopHarCapture(page); // returns the HAR object
12
+ //
13
+ // Or via CLI/sweep:
14
+ // pursor shoot <url> --har ./out/req.har.json
15
+ //
16
+ // HAR is useful for:
17
+ // - killing flakiness from analytics/ads/CDN by mocking responses later
18
+ // - inspecting what the page actually fetched during a capture
19
+ // - regression diffing along with visual diffs
20
+
21
+ import { writeFileSync, mkdirSync } from "node:fs";
22
+ import { dirname } from "node:path";
23
+ import { nowIso } from "./util.js";
24
+
25
+ const SYM = Symbol.for("pursor.har.capture");
26
+
27
+ function makeEntry(req, resp, startTs, endTs) {
28
+ const url = req.url();
29
+ const u = new URL(url);
30
+ const headersList = (obj) => {
31
+ if (!obj) return [];
32
+ try {
33
+ const out = [];
34
+ for (const [name, value] of Object.entries(obj)) {
35
+ if (Array.isArray(value)) {
36
+ for (const v of value) out.push({ name, value: String(v) });
37
+ } else {
38
+ out.push({ name, value: String(value) });
39
+ }
40
+ }
41
+ return out;
42
+ } catch { return []; }
43
+ };
44
+ const queryString = u.search ? u.search.slice(1).split("&").filter(Boolean).map(kv => {
45
+ const [k, v = ""] = kv.split("=");
46
+ return { name: decodeURIComponent(k), value: decodeURIComponent(v) };
47
+ }) : [];
48
+ const postData = req.postData() ? { mimeType: req.postData() || "application/octet-stream", text: req.postData() } : undefined;
49
+ const entry = {
50
+ pageref: req.frame()?.url?.() || "_top",
51
+ startedDateTime: new Date(startTs).toISOString(),
52
+ time: Math.max(0, endTs - startTs),
53
+ request: {
54
+ method: req.method(),
55
+ url,
56
+ httpVersion: "HTTP/1.1",
57
+ cookies: [],
58
+ headers: headersList(req.headers()),
59
+ queryString,
60
+ headersSize: -1,
61
+ bodySize: postData?.text?.length || 0,
62
+ postData,
63
+ },
64
+ response: resp ? {
65
+ status: resp.status(),
66
+ statusText: resp.statusText() || "",
67
+ httpVersion: "HTTP/1.1",
68
+ cookies: [],
69
+ headers: headersList(resp.headers()),
70
+ content: { size: -1, mimeType: resp.headers()?.["content-type"] || "" },
71
+ redirectURL: resp.headers()?.location || "",
72
+ headersSize: -1,
73
+ bodySize: -1,
74
+ } : { status: 0, statusText: "Failed", httpVersion: "HTTP/1.1", cookies: [], headers: [], content: { size: 0, mimeType: "" }, redirectURL: "", headersSize: -1, bodySize: -1 },
75
+ cache: {},
76
+ timings: { send: 0, wait: Math.max(0, endTs - startTs), receive: 0 },
77
+ serverIPAddress: "",
78
+ connection: "",
79
+ };
80
+ return entry;
81
+ }
82
+
83
+ export async function startHarCapture(page, opts = {}) {
84
+ if (!page) throw new Error("startHarCapture: page required");
85
+ if (page[SYM]) return page[SYM]; // idempotent
86
+ const started = Date.now();
87
+ const entries = [];
88
+ const pending = new Map(); // req -> startTs
89
+ const state = {
90
+ started,
91
+ creator: { name: "pursor", version: opts.version || "0.3.0" },
92
+ browser: { name: "chromium", version: "playwright-core" },
93
+ pages: [],
94
+ entries,
95
+ pending,
96
+ };
97
+ const onReq = (req) => {
98
+ pending.set(req, Date.now());
99
+ };
100
+ const onResp = async (resp) => {
101
+ try {
102
+ const req = resp.request();
103
+ const startTs = pending.get(req) || Date.now();
104
+ pending.delete(req);
105
+ const endTs = Date.now();
106
+ entries.push(makeEntry(req, resp, startTs, endTs));
107
+ } catch {}
108
+ };
109
+ const onFailed = (req) => {
110
+ try {
111
+ const startTs = pending.get(req) || Date.now();
112
+ pending.delete(req);
113
+ entries.push(makeEntry(req, null, startTs, Date.now()));
114
+ } catch {}
115
+ };
116
+ page.on("request", onReq);
117
+ page.on("response", onResp);
118
+ page.on("requestfailed", onFailed);
119
+ state._teardown = () => {
120
+ try { page.off("request", onReq); } catch {}
121
+ try { page.off("response", onResp); } catch {}
122
+ try { page.off("requestfailed", onFailed); } catch {}
123
+ };
124
+ page[SYM] = state;
125
+ return state;
126
+ }
127
+
128
+ export function stopHarCapture(page) {
129
+ if (!page || !page[SYM]) return null;
130
+ const state = page[SYM];
131
+ try { state._teardown?.(); } catch {}
132
+ delete page[SYM];
133
+ return finalizeHar(state);
134
+ }
135
+
136
+ export function finalizeHar(state) {
137
+ if (!state) return null;
138
+ return {
139
+ log: {
140
+ version: "1.2",
141
+ creator: state.creator,
142
+ browser: state.browser,
143
+ pages: state.pages,
144
+ entries: state.entries,
145
+ },
146
+ _meta: {
147
+ started: state.started,
148
+ finished: Date.now(),
149
+ entryCount: state.entries.length,
150
+ },
151
+ };
152
+ }
153
+
154
+ export async function writeHar(har, file) {
155
+ if (!har || !file) return null;
156
+ mkdirSync(dirname(file), { recursive: true });
157
+ writeFileSync(file, JSON.stringify(har, null, 2), "utf8");
158
+ return file;
159
+ }
package/src/hover.js ADDED
@@ -0,0 +1,26 @@
1
+ // Hover capture: navigate, hover a selector, screenshot.
2
+
3
+ import { launch, newPage } from "./runway.js";
4
+ import { resolveViewport } from "./viewport.js";
5
+ import { gotoOrThrow, settle, CLICK_TIMEOUT_MS } from "./overlays.js";
6
+ import { resolveLocator } from "./selector.js";
7
+ import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
8
+
9
+ export async function runHover({ url, selector, out, flags = {} }) {
10
+ requireArg("url", url, "string");
11
+ requireArg("selector", selector, "string");
12
+ const viewport = resolveViewport(flags);
13
+ const browser = await launch();
14
+ try {
15
+ const page = await newPage(browser, viewport);
16
+ const r = await gotoOrThrow(page, url); await settle(page);
17
+ const loc = await resolveLocator(page, selector);
18
+ await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
19
+ await loc.first().hover({ timeout: CLICK_TIMEOUT_MS });
20
+ await page.waitForTimeout(asNum(flags["hover-ms"], 250));
21
+ if (out) await page.screenshot({ path: out, fullPage: asBool(flags.full, false) });
22
+ const meta = { ...r, url, out, selector, viewport, ts: nowIso() };
23
+ if (out) await writeSidecar(meta);
24
+ return meta;
25
+ } finally { try { await browser.close(); } catch {} }
26
+ }
package/src/index.js ADDED
@@ -0,0 +1,95 @@
1
+ // pursor — public library API.
2
+ //
3
+ // This is the entry point for consumers who want to embed pursor
4
+ // inside their own scripts (instead of using the CLI). The CLI in
5
+ // bin/pursor.mjs is a thin wrapper around the same exports.
6
+ //
7
+ // All capture / sweep helpers return a `Result` object: the path to the
8
+ // PNG, a sidecar JSON metadata object, and timing info. They never throw
9
+ // on capture-time errors — those are reported in the result so the
10
+ // caller can decide how to react.
11
+ //
12
+ // The plugin system lives in src/plugin.js — see that file for how to
13
+ // write a custom plugin (viewport, sweep-op, before/after hooks).
14
+
15
+ import { runShoot } from "./shoot.js";
16
+ import { runSweep } from "./sweep.js";
17
+ import { runEveryViewport } from "./every-viewport.js";
18
+ import { runFrames } from "./frames.js";
19
+ import { runHover } from "./hover.js";
20
+ import { runDiff } from "./diff.js";
21
+ import { runProbe } from "./probe.js";
22
+ import { runShot } from "./shot.js";
23
+ import { runEval } from "./eval.js";
24
+ import { runClick, runType, runWait, runSeq } from "./interact.js";
25
+ import { listViewports, resolveViewport, VIEWPORTS } from "./viewport.js";
26
+ import { applyCamera, waitForStableFrame } from "./overlays.js";
27
+ import { loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp } from "./plugin.js";
28
+ import { launch, newPage } from "./runway.js";
29
+ import { parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut } from "./util.js";
30
+ import { resolveLocator, parseTextSelector } from "./selector.js";
31
+ import { captureDomSnapshot, captureDomSnapshotSidecar } from "./dom-snapshot.js";
32
+ import { runAudit } from "./plugin-audit.js";
33
+ import { resolveHealedSelector, healStepAction } from "./selector-heal.js";
34
+ import { writeCiOutput } from "./ci-output.js";
35
+ import { PursorMCPServer, loadConfig as loadMcpConfig, MCP_VERSION } from "./mcp.js";
36
+ import { createRequire } from "node:module";
37
+ import { saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath } from "./baseline.js";
38
+ import { validateSweepPlan, registerSweepOp } from "./sweep-schema.js";
39
+ import { listResources, readResource, recordResource } from "./mcp-resources.js";
40
+ import { startHarCapture, stopHarCapture, writeHar } from "./har.js";
41
+ import { saveAuthState, loadAuthState, listAuthStates, deleteAuthState } from "./auth.js";
42
+
43
+
44
+ // Derive VERSION from package.json to prevent drift
45
+ const __require = createRequire(import.meta.url);
46
+ const pkg = __require("../package.json");
47
+ const VERSION = pkg.version;
48
+
49
+ export {
50
+ // CLI-style actions
51
+ runProbe, runShot, runEval, runClick, runType, runWait, runSeq,
52
+ runShoot, runFrames, runHover, runSweep, runDiff, runEveryViewport,
53
+ // v3: audit, DOM snapshot, MCP
54
+ runAudit, captureDomSnapshot, captureDomSnapshotSidecar,
55
+ // viewport + camera helpers
56
+ listViewports, resolveViewport, VIEWPORTS,
57
+ applyCamera, waitForStableFrame,
58
+ // plugin system
59
+ loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp,
60
+ // low-level helpers (for plugin authors)
61
+ launch, newPage,
62
+ parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut,
63
+ resolveLocator, parseTextSelector,
64
+ // v3: selector healing, CI output, MCP server
65
+ resolveHealedSelector, healStepAction,
66
+ writeCiOutput,
67
+ PursorMCPServer, loadMcpConfig, MCP_VERSION,
68
+ // v4: baselines, sweep validation, MCP resources
69
+ saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
70
+ validateSweepPlan, registerSweepOp,
71
+ listResources, readResource, recordResource,
72
+ // v5: HAR capture, auth state, parallel sweep
73
+ startHarCapture, stopHarCapture, writeHar,
74
+ saveAuthState, loadAuthState, listAuthStates, deleteAuthState,
75
+ VERSION,
76
+ };
77
+
78
+ export default {
79
+ runProbe, runShot, runEval, runClick, runType, runWait, runSeq,
80
+ runShoot, runFrames, runHover, runSweep, runDiff, runEveryViewport,
81
+ runAudit, captureDomSnapshot, captureDomSnapshotSidecar,
82
+ listViewports, resolveViewport, VIEWPORTS,
83
+ applyCamera, waitForStableFrame,
84
+ loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp,
85
+ launch, newPage,
86
+ parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut,
87
+ resolveLocator, parseTextSelector,
88
+ resolveHealedSelector, healStepAction,
89
+ writeCiOutput,
90
+ PursorMCPServer, loadMcpConfig, MCP_VERSION,
91
+ saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
92
+ validateSweepPlan, registerSweepOp,
93
+ listResources, readResource, recordResource,
94
+ VERSION,
95
+ };
@@ -0,0 +1,138 @@
1
+ // click, type, wait, seq — interaction primitives.
2
+
3
+ import { launch, newPage } from "./runway.js";
4
+ import { DEFAULT_VIEWPORT } from "./viewport.js";
5
+ import { gotoOrThrow, settle, CLICK_TIMEOUT_MS, WAIT_DEFAULT_TIMEOUT_MS } from "./overlays.js";
6
+ import { resolveLocator } from "./selector.js";
7
+ import { requireArg } from "./util.js";
8
+
9
+ export async function runClick(url, selector, out) {
10
+ requireArg("url", url, "string");
11
+ requireArg("selector", selector, "string");
12
+ const browser = await launch();
13
+ try {
14
+ const page = await newPage(browser, DEFAULT_VIEWPORT);
15
+ const r = await gotoOrThrow(page, url); await settle(page);
16
+ const loc = await resolveLocator(page, selector);
17
+ await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
18
+ await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
19
+ await settle(page);
20
+ if (out) await page.screenshot({ path: out, fullPage: false });
21
+ return { ...r, url, out, selector, clicked: true };
22
+ } finally { try { await browser.close(); } catch {} }
23
+ }
24
+
25
+ export async function runType(url, selector, text, out) {
26
+ requireArg("url", url, "string");
27
+ requireArg("selector", selector, "string");
28
+ const browser = await launch();
29
+ try {
30
+ const page = await newPage(browser, DEFAULT_VIEWPORT);
31
+ const r = await gotoOrThrow(page, url); await settle(page);
32
+ const loc = await resolveLocator(page, selector);
33
+ await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
34
+ await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
35
+ await page.keyboard.type(String(text ?? ""), { delay: 10 });
36
+ await settle(page);
37
+ if (out) await page.screenshot({ path: out, fullPage: false });
38
+ return { ...r, url, out, selector, text, typed: true };
39
+ } finally { try { await browser.close(); } catch {} }
40
+ }
41
+
42
+ export async function runWait(url, selector, timeoutMs) {
43
+ requireArg("url", url, "string");
44
+ requireArg("selector", selector, "string");
45
+ const browser = await launch();
46
+ try {
47
+ const page = await newPage(browser, DEFAULT_VIEWPORT);
48
+ const r = await gotoOrThrow(page, url);
49
+ const loc = await resolveLocator(page, selector);
50
+ const t = timeoutMs || WAIT_DEFAULT_TIMEOUT_MS;
51
+ try {
52
+ await loc.first().waitFor({ state: "visible", timeout: t });
53
+ return { ...r, url, selector, found: true, timeoutMs: t };
54
+ } catch {
55
+ return { ...r, url, selector, found: false, timeoutMs: t };
56
+ }
57
+ } finally { try { await browser.close(); } catch {} }
58
+ }
59
+
60
+ export async function runSeq(url, actionsJson, out) {
61
+ requireArg("url", url, "string");
62
+ let actions;
63
+ try { actions = JSON.parse(actionsJson); }
64
+ catch (e) { throw new Error(`invalid actions JSON: ${e.message}`, { cause: e }); }
65
+ if (!Array.isArray(actions)) throw new Error("actions must be a JSON array");
66
+ if (!actions.length) throw new Error("actions array is empty");
67
+ const browser = await launch();
68
+ try {
69
+ const page = await newPage(browser, DEFAULT_VIEWPORT);
70
+ const r = await gotoOrThrow(page, url); await settle(page);
71
+ const trace = [];
72
+ let failed = false;
73
+ for (let i = 0; i < actions.length; i++) {
74
+ const a = actions[i] || {};
75
+ const step = { i, op: a.op };
76
+ try {
77
+ switch (a.op) {
78
+ case "click": {
79
+ const loc = await resolveLocator(page, a.selector);
80
+ await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
81
+ await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
82
+ step.selector = a.selector; break;
83
+ }
84
+ case "type": {
85
+ const loc = await resolveLocator(page, a.selector);
86
+ await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
87
+ await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
88
+ await page.keyboard.type(String(a.text ?? ""), { delay: 10 });
89
+ step.selector = a.selector; step.text = a.text; break;
90
+ }
91
+ case "wait": {
92
+ const t = a.timeoutMs ? Number(a.timeoutMs) : WAIT_DEFAULT_TIMEOUT_MS;
93
+ const loc = await resolveLocator(page, a.selector);
94
+ await loc.first().waitFor({ state: "visible", timeout: t });
95
+ step.selector = a.selector; step.timeoutMs = t; break;
96
+ }
97
+ case "eval": { step.result = await page.evaluate(String(a.js ?? "")); break; }
98
+ case "shot": {
99
+ await page.screenshot({ path: a.out, fullPage: !!a.fullPage });
100
+ step.out = a.out; step.fullPage = !!a.fullPage; break;
101
+ }
102
+ case "scroll": {
103
+ const vp = page.viewportSize();
104
+ await page.mouse.move((vp?.width || 640) / 2, (vp?.height || 400) / 2);
105
+ await page.mouse.wheel(a.deltaX || 0, a.deltaY || 0);
106
+ step.deltaX = a.deltaX; step.deltaY = a.deltaY; break;
107
+ }
108
+ case "navigate": { await gotoOrThrow(page, a.url); step.url = a.url; break; }
109
+ case "press": {
110
+ // a.key can be a single key ("Escape") or comma-separated ("Tab,Enter")
111
+ const raw = String(a.key ?? "").trim();
112
+ if (!raw) throw new Error("press: missing key");
113
+ const keys = raw.split(",").map(k => k.trim()).filter(Boolean);
114
+ for (const k of keys) await page.keyboard.press(k);
115
+ step.key = raw; step.count = keys.length; break;
116
+ }
117
+ case "sleep": { await page.waitForTimeout(Number(a.ms ?? 1000)); step.ms = a.ms; break; }
118
+ case "hover": {
119
+ const loc = await resolveLocator(page, a.selector);
120
+ await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
121
+ await loc.first().hover({ timeout: CLICK_TIMEOUT_MS });
122
+ step.selector = a.selector; break;
123
+ }
124
+ default: throw new Error(`unknown op: ${a.op}`);
125
+ }
126
+ if (a.settleMs !== undefined) await page.waitForTimeout(Number(a.settleMs));
127
+ else await settle(page);
128
+ step.ok = true;
129
+ } catch (e) {
130
+ step.ok = false; step.error = e.message; failed = true;
131
+ }
132
+ trace.push(step);
133
+ if (failed) break;
134
+ }
135
+ if (out) await page.screenshot({ path: out, fullPage: false });
136
+ return { ...r, url, out, steps: trace, failed };
137
+ } finally { try { await browser.close(); } catch {} }
138
+ }
@@ -0,0 +1,111 @@
1
+ // pursor — MCP resources adapter.
2
+ //
3
+ // Exposes run results as MCP resources so hosts (Claude Code, Cursor, etc.)
4
+ // can browse, preview, and re-read captures without re-running captures.
5
+ //
6
+ // Resource shape (per MCP spec):
7
+ // uri: pursor://<kind>/<id>
8
+ // name: <human label>
9
+ // description: <what it is>
10
+ // mimeType: image/png | application/json | text/html
11
+ //
12
+ // We track "recent" sweep outputs in-memory + persist an index at
13
+ // $PURSOR_MCP_STATE/mcp-index.json so resources survive restarts.
14
+
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
16
+ import { join, basename, dirname } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import { nowIso } from "./util.js";
19
+
20
+ function stateDir() {
21
+ const root = process.env.PURSOR_MCP_STATE || join(homedir(), ".pursor", "mcp");
22
+ mkdirSync(root, { recursive: true });
23
+ return root;
24
+ }
25
+
26
+ function indexPath() { return join(stateDir(), "mcp-index.json"); }
27
+
28
+ function loadIndex() {
29
+ const p = indexPath();
30
+ if (!existsSync(p)) return { resources: [] };
31
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return { resources: [] }; }
32
+ }
33
+
34
+ function saveIndex(idx) {
35
+ writeFileSync(indexPath(), JSON.stringify(idx, null, 2), "utf8");
36
+ }
37
+
38
+ export function recordResource({ kind, id, name, description, uri, mimeType, file, meta }) {
39
+ const idx = loadIndex();
40
+ // De-dup by uri
41
+ idx.resources = idx.resources.filter(r => r.uri !== uri);
42
+ idx.resources.unshift({
43
+ kind, id, name, description, uri, mimeType, file, meta: meta || null, ts: nowIso(),
44
+ });
45
+ // Cap index size
46
+ if (idx.resources.length > 200) idx.resources = idx.resources.slice(0, 200);
47
+ saveIndex(idx);
48
+ return idx.resources[0];
49
+ }
50
+
51
+ export function listResources() {
52
+ // Combine persisted index + any in-memory scan of recent sweep dirs
53
+ const idx = loadIndex();
54
+ // Also include sidecars sitting next to a sweep.json under cwd
55
+ try {
56
+ const cwd = process.cwd();
57
+ for (const f of readdirSync(cwd)) {
58
+ if (f === "sweep.json") {
59
+ const sweepPath = join(cwd, f);
60
+ try {
61
+ const s = JSON.parse(readFileSync(sweepPath, "utf8"));
62
+ const dirUri = `pursor://sweep/${encodeURIComponent(s.name || basename(cwd))}`;
63
+ if (!idx.resources.some(r => r.uri === dirUri)) {
64
+ idx.resources.push({
65
+ kind: "sweep", id: s.name || basename(cwd),
66
+ name: `sweep: ${s.name || basename(cwd)}`,
67
+ description: `Sweep summary: ${(s.steps || []).length} steps`,
68
+ uri: dirUri, mimeType: "application/json",
69
+ file: sweepPath, meta: { steps: (s.steps || []).length, ts: s.ts }, ts: s.ts || nowIso(),
70
+ });
71
+ }
72
+ } catch {}
73
+ }
74
+ }
75
+ } catch {}
76
+ return idx.resources;
77
+ }
78
+
79
+ export function readResource(uri) {
80
+ if (typeof uri !== "string") return null;
81
+ if (!uri.startsWith("pursor://")) return null;
82
+ // Parse kind/id
83
+ const rest = uri.slice("pursor://".length);
84
+ const [kind, ...restParts] = rest.split("/");
85
+ const id = restParts.join("/");
86
+ const idx = loadIndex();
87
+ const r = idx.resources.find(x => x.uri === uri);
88
+ if (r) {
89
+ return readResourceFile(r);
90
+ }
91
+ // Resolve by kind/id from filesystem fallback
92
+ if (kind === "sweep") {
93
+ const file = join(process.cwd(), decodeURIComponent(id), "sweep.json");
94
+ if (existsSync(file)) {
95
+ return { uri, mimeType: "application/json", text: readFileSync(file, "utf8") };
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function readResourceFile(r) {
102
+ if (!r.file || !existsSync(r.file)) return { uri: r.uri, mimeType: r.mimeType, error: "file not found" };
103
+ const data = readFileSync(r.file);
104
+ if (r.mimeType && r.mimeType.startsWith("image/")) {
105
+ return { uri: r.uri, mimeType: r.mimeType, blob: data.toString("base64") };
106
+ }
107
+ if (r.mimeType === "application/json" || r.mimeType === "text/html") {
108
+ return { uri: r.uri, mimeType: r.mimeType, text: data.toString("utf8") };
109
+ }
110
+ return { uri: r.uri, mimeType: r.mimeType || "application/octet-stream", blob: data.toString("base64") };
111
+ }