aipeek 0.2.5 → 0.2.7

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/README.md CHANGED
@@ -39,9 +39,10 @@ All endpoints are available on your Vite dev server:
39
39
  | `GET /__aipeek/{section}/{index}` | Detail for a specific item in a section |
40
40
  | `GET /__aipeek/{section}?full` | Full detail (no truncation) |
41
41
  | `GET /__aipeek/dom[?scope=Name\|?sel=css]` | Semantic DOM — UI as text (see below) |
42
+ | `GET /__aipeek/query?sel=css` | Read-side twin of `sel=`: a selector's live `count` + each match's `text`/`visible`/`attrs` (role, `data-state`, `aria-*`/`data-*`, value, disabled). Per-element assertions without `/eval`. |
42
43
  | `GET /__aipeek/{action}?...` | Drive the page (see Actions) |
43
44
  | `POST /__aipeek/chain` | Run a JSON array of actions in one round-trip (see Actions) |
44
- | `GET\|POST /__aipeek/eval` | Run arbitrary JS in the page (`?code=` or POST body); returns the result. Escape hatch for what typed endpoints can't do. |
45
+ | `GET\|POST /__aipeek/eval` | Run arbitrary JS in the page (`?code=` or POST body); returns the result. Escape hatch for what typed endpoints can't do — for count/text/state/attr checks reach for `/query` first. |
45
46
 
46
47
  ### Perception layers — UI as text, not pixels
47
48
 
@@ -6,7 +6,7 @@ import { fileURLToPath } from "url";
6
6
  import { transformSync } from "esbuild";
7
7
 
8
8
  // src/core/action.ts
9
- var TYPES = ["click", "fill", "press", "wait", "screenshot"];
9
+ var TYPES = ["click", "fill", "press", "wait", "screenshot", "realclick", "query"];
10
10
  function resolveAction(type, args) {
11
11
  if (!TYPES.includes(type))
12
12
  return { valid: false, error: `unknown action: ${type}` };
@@ -14,6 +14,8 @@ function resolveAction(type, args) {
14
14
  switch (type) {
15
15
  case "click":
16
16
  return hasTarget ? { valid: true } : { valid: false, error: "click needs sel= or text=" };
17
+ case "realclick":
18
+ return hasTarget || args.x !== void 0 && args.y !== void 0 ? { valid: true } : { valid: false, error: "realclick needs sel=, text=, or x= & y=" };
17
19
  case "fill":
18
20
  if (!hasTarget)
19
21
  return { valid: false, error: "fill needs sel= or text=" };
@@ -26,6 +28,8 @@ function resolveAction(type, args) {
26
28
  return hasTarget ? { valid: true } : { valid: false, error: "wait needs sel= or text=" };
27
29
  case "screenshot":
28
30
  return { valid: true };
31
+ case "query":
32
+ return args.sel ? { valid: true } : { valid: false, error: "query needs sel=" };
29
33
  default:
30
34
  return { valid: false, error: `unknown action: ${type}` };
31
35
  }
@@ -67,6 +71,47 @@ function check(raw) {
67
71
  function truncate(s, max) {
68
72
  return s.length > max ? `${s.slice(0, max)}\u2026` : s;
69
73
  }
74
+ function formatValue(v, seen = /* @__PURE__ */ new Set()) {
75
+ if (v === null || v === void 0)
76
+ return String(v);
77
+ const t = typeof v;
78
+ if (t === "string")
79
+ return v;
80
+ if (t === "number" || t === "boolean" || t === "bigint")
81
+ return String(v);
82
+ if (t === "symbol")
83
+ return v.toString();
84
+ if (t === "function")
85
+ return `[Function: ${v.name || "anonymous"}]`;
86
+ const obj = v;
87
+ if (seen.has(obj))
88
+ return "[Circular]";
89
+ if (v instanceof Error)
90
+ return v.stack || `${v.name}: ${v.message}`;
91
+ seen.add(obj);
92
+ if (v instanceof Map) {
93
+ const items = [...v.entries()].slice(0, 15).map(([k, val]) => `${formatValue(k, seen)} => ${formatValue(val, seen)}`);
94
+ return `Map(${v.size}) {${items.join(", ")}${v.size > 15 ? ", \u2026" : ""}}`;
95
+ }
96
+ if (v instanceof Set) {
97
+ const items = [...v.values()].slice(0, 15).map((val) => formatValue(val, seen));
98
+ return `Set(${v.size}) {${items.join(", ")}${v.size > 15 ? ", \u2026" : ""}}`;
99
+ }
100
+ if (Array.isArray(v)) {
101
+ const items = v.slice(0, 30).map((val) => formatValue(val, seen));
102
+ return `[${items.join(", ")}${v.length > 30 ? ", \u2026" : ""}]`;
103
+ }
104
+ try {
105
+ return JSON.stringify(v);
106
+ } catch {
107
+ const entries = Object.entries(v).slice(0, 15);
108
+ const parts = entries.map(([k, val]) => `${k}: ${formatValue(val, seen)}`);
109
+ return `{${parts.join(", ")}}`;
110
+ }
111
+ }
112
+ function appStackFrames(stack, max) {
113
+ return stack.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("at ") && !l.includes("node_modules") && !l.includes("<anonymous>")).slice(0, max);
114
+ }
70
115
  function compactUrl(url, search) {
71
116
  try {
72
117
  const u = new URL(url);
@@ -283,17 +328,12 @@ function compactErrors(errors) {
283
328
  for (const err of seen.values()) {
284
329
  lines.push(err.message);
285
330
  if (err.stack) {
286
- const frames = filterStack(err.stack);
287
- for (const frame of frames) {
288
- lines.push(` at ${frame}`);
289
- }
331
+ for (const frame of appStackFrames(err.stack, 5))
332
+ lines.push(` ${frame}`);
290
333
  }
291
334
  }
292
335
  return lines.join("\n");
293
336
  }
294
- function filterStack(stack) {
295
- return stack.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("at ")).map((l) => l.slice(3)).filter((l) => !l.includes("node_modules") && !l.includes("<anonymous>")).slice(0, 5);
296
- }
297
337
  function compactState(state) {
298
338
  if (!state || !Object.keys(state).length)
299
339
  return "";
@@ -302,7 +342,7 @@ function compactState(state) {
302
342
  lines.push(`${name}:`);
303
343
  if (typeof value === "object" && value !== null) {
304
344
  for (const [k, v] of Object.entries(value)) {
305
- lines.push(` ${k}: ${formatValue(v)}`);
345
+ lines.push(` ${k}: ${truncate(formatValue(v), 120)}`);
306
346
  }
307
347
  } else {
308
348
  lines.push(` ${String(value)}`);
@@ -310,19 +350,6 @@ function compactState(state) {
310
350
  }
311
351
  return lines.join("\n");
312
352
  }
313
- function formatValue(v) {
314
- if (v === null || v === void 0)
315
- return String(v);
316
- if (typeof v === "string")
317
- return v;
318
- if (typeof v === "number" || typeof v === "boolean")
319
- return String(v);
320
- if (typeof v === "object") {
321
- const s = JSON.stringify(v);
322
- return s.length > 120 ? `${s.slice(0, 120)}\u2026` : s;
323
- }
324
- return String(v);
325
- }
326
353
  function compact(raw) {
327
354
  return {
328
355
  url: raw.url,
@@ -459,12 +486,10 @@ function detailError(errors, index, full) {
459
486
  }
460
487
  const lines = [err.message];
461
488
  if (err.stack) {
462
- const appFrames = err.stack.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("at ") && !l.includes("node_modules")).slice(0, 3);
463
- if (appFrames.length)
464
- lines.push(...appFrames);
465
- const totalApp = err.stack.split("\n").filter((l) => l.trim().startsWith("at ") && !l.includes("node_modules")).length;
466
- if (totalApp > 3)
467
- lines.push(` ... ${totalApp - 3} more app frames`);
489
+ const all = appStackFrames(err.stack, Infinity);
490
+ lines.push(...all.slice(0, 3));
491
+ if (all.length > 3)
492
+ lines.push(` ... ${all.length - 3} more app frames`);
468
493
  }
469
494
  if (err.line != null)
470
495
  lines.push(`location: ${err.source || ""}:${err.line}:${err.column ?? 0}`);
@@ -482,9 +507,9 @@ function detailState(state, name, full) {
482
507
  const value = state[name];
483
508
  if (full) {
484
509
  try {
485
- return JSON.stringify(value, null, 2);
510
+ return JSON.stringify(value, null, 2) ?? formatValue(value);
486
511
  } catch {
487
- return String(value);
512
+ return formatValue(value);
488
513
  }
489
514
  }
490
515
  if (typeof value !== "object" || value === null)
@@ -502,20 +527,9 @@ function isArraySentinel(v) {
502
527
  return digits.length > 0 && [...digits].every((c) => c >= "0" && c <= "9");
503
528
  }
504
529
  function formatSummaryValue(v) {
505
- if (v === null || v === void 0)
506
- return String(v);
507
- if (typeof v === "string") {
508
- if (isArraySentinel(v))
509
- return v;
510
- return v.length > 80 ? `${v.slice(0, 80)}\u2026` : v;
511
- }
512
- if (typeof v === "number" || typeof v === "boolean")
513
- return String(v);
514
- if (typeof v === "object") {
515
- const s = JSON.stringify(v);
516
- return s.length > 80 ? `${s.slice(0, 80)}\u2026` : s;
517
- }
518
- return String(v);
530
+ if (typeof v === "string" && isArraySentinel(v))
531
+ return v;
532
+ return truncate(formatValue(v), 80);
519
533
  }
520
534
  function jsonSchema(sample) {
521
535
  try {
@@ -749,6 +763,10 @@ function readBody(req) {
749
763
  req.on("end", () => resolve2(s));
750
764
  });
751
765
  }
766
+ function send(res, status, body) {
767
+ res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
768
+ res.end(body);
769
+ }
752
770
  var __dirname2 = dirname(fileURLToPath(import.meta.url));
753
771
  var clientDir = existsSync(resolve(__dirname2, "../client")) ? resolve(__dirname2, "../client") : resolve(__dirname2, "../src/client");
754
772
  var clientPath = resolve(clientDir, "client.ts");
@@ -783,8 +801,12 @@ curl ${base}/console # console logs (errors, warnings, info)
783
801
  curl ${base}/network # fetch/XHR requests with status and timing
784
802
  curl ${base}/errors # uncaught errors and unhandled rejections
785
803
  curl ${base}/state # registered store snapshots
804
+ curl '${base}/query?sel=[role=menuitem]' # this selector right now: count + each element's text/visible/attrs
786
805
  \`\`\`
787
806
 
807
+ \`/query\` is the read-side twin of click/fill's \`sel=\` \u2014 assert on a specific element
808
+ (how many match, its text, \`data-state\`, \`aria-*\`/\`data-*\`, value, disabled) without \`/eval\`.
809
+
788
810
  \`/screen\` projects the whole UI to a few state variables \u2014 start there, not \`/ui\`. Append
789
811
  \`?full\` for untruncated output. Append \`/{index}\` for a specific item's detail.
790
812
 
@@ -821,34 +843,33 @@ curl -X POST ${base}/chain -d '[
821
843
  \`\`\`
822
844
 
823
845
  **Escape hatch.** \`curl '${base}/eval?code=...'\` (or POST the code as the body) runs arbitrary
824
- JS in the page and returns the result \u2014 for anything the typed endpoints can't do.
846
+ JS in the page and returns the result \u2014 for what the typed endpoints can't do (install listeners,
847
+ read closures, probe event flow). For count/text/state/attr assertions reach for \`/query\` first.
825
848
 
826
849
  aipeek auto-detects errors after HMR and prints them to the terminal \u2014 watch for \`[aipeek]\` messages.
827
850
  `;
828
851
  }
829
852
  var START_TAG = "<!-- AIPEEK:START -->";
830
853
  var END_TAG = "<!-- AIPEEK:END -->";
831
- function injectClaudeMd(root, port) {
832
- const path = resolve(root, "CLAUDE.md");
854
+ function renderClaudeMd(existing, port) {
833
855
  const block = `${START_TAG}
834
856
  ${aipeekSnippet(port).trim()}
835
857
  ${END_TAG}
836
858
  `;
859
+ if (existing === null)
860
+ return block;
861
+ const si = existing.indexOf(START_TAG);
862
+ const ei = existing.indexOf(END_TAG);
863
+ if (si !== -1 && ei !== -1)
864
+ return existing.slice(0, si) + block.trimEnd() + existing.slice(ei + END_TAG.length);
865
+ const sep = existing.endsWith("\n") ? "" : "\n";
866
+ return `${existing}${sep}
867
+ ${block}`;
868
+ }
869
+ function injectClaudeMd(root, port) {
870
+ const path = resolve(root, "CLAUDE.md");
837
871
  try {
838
- if (!existsSync(path)) {
839
- writeFileSync(path, block);
840
- return;
841
- }
842
- const content = readFileSync(path, "utf-8");
843
- const si = content.indexOf(START_TAG);
844
- const ei = content.indexOf(END_TAG);
845
- if (si !== -1 && ei !== -1) {
846
- writeFileSync(path, content.slice(0, si) + block.trimEnd() + content.slice(ei + END_TAG.length));
847
- return;
848
- }
849
- const sep = content.endsWith("\n") ? "" : "\n";
850
- writeFileSync(path, `${content}${sep}
851
- ${block}`);
872
+ writeFileSync(path, renderClaudeMd(existsSync(path) ? readFileSync(path, "utf-8") : null, port));
852
873
  } catch {
853
874
  }
854
875
  }
@@ -859,6 +880,27 @@ function aipeekPlugin() {
859
880
  let pushTimer;
860
881
  const pendingActions = /* @__PURE__ */ new Map();
861
882
  let actionId = 0;
883
+ const cdpQueue = [];
884
+ let cdpWaiter = null;
885
+ const cdpResults = /* @__PURE__ */ new Map();
886
+ let cdpId = 0;
887
+ function runCdpClick(x, y, button) {
888
+ const id = ++cdpId;
889
+ const cmd = { id, x, y, button };
890
+ return new Promise((resolve2, reject) => {
891
+ cdpResults.set(id, resolve2);
892
+ if (cdpWaiter) {
893
+ cdpWaiter(cmd);
894
+ cdpWaiter = null;
895
+ } else {
896
+ cdpQueue.push(cmd);
897
+ }
898
+ setTimeout(() => {
899
+ if (cdpResults.delete(id))
900
+ reject(new Error("cdp timeout: no extension result within 10s (is the aipeek extension loaded and the debugger attached?)"));
901
+ }, 1e4);
902
+ });
903
+ }
862
904
  let pendingDom = null;
863
905
  let pendingScreen = null;
864
906
  const pendingEvals = /* @__PURE__ */ new Map();
@@ -919,6 +961,18 @@ function aipeekPlugin() {
919
961
  };
920
962
  }, fullMs);
921
963
  }
964
+ async function runAction(type, args) {
965
+ const result = await sendAction(type, args);
966
+ lastRaw = null;
967
+ if (type === "realclick" && result.ok && result.ui === void 0) {
968
+ const cdp = await runCdpClick(result.x, result.y, args.button ?? "left");
969
+ if (!cdp.ok)
970
+ return { ok: false, error: `cdp click failed: ${cdp.error ?? "unknown"}` };
971
+ result.detail = `${result.detail} \u2192 clicked via extension`;
972
+ result.ui = await collectScreenFromClient();
973
+ }
974
+ return result;
975
+ }
922
976
  function evalInClient(code) {
923
977
  const id = ++evalId;
924
978
  return twoPhase("aipeek:eval", { id, code }, (resolve2) => {
@@ -1011,13 +1065,11 @@ function aipeekPlugin() {
1011
1065
  if (!code && req.method === "POST")
1012
1066
  code = await readBody(req);
1013
1067
  if (!code) {
1014
- res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1015
- res.end("eval needs ?code= or a POST body");
1068
+ send(res, 400, "eval needs ?code= or a POST body");
1016
1069
  return;
1017
1070
  }
1018
1071
  const r = await evalInClient(code);
1019
- res.writeHead(r.ok ? 200 : 422, { "Content-Type": "text/plain; charset=utf-8" });
1020
- res.end(r.ok ? r.value ?? "undefined" : `error: ${r.error}`);
1072
+ send(res, r.ok ? 200 : 422, r.ok ? r.value ?? "undefined" : `error: ${r.error}`);
1021
1073
  return;
1022
1074
  }
1023
1075
  if (parts[0] === "dom") {
@@ -1025,14 +1077,50 @@ function aipeekPlugin() {
1025
1077
  url.searchParams.get("scope") || void 0,
1026
1078
  url.searchParams.get("sel") || void 0
1027
1079
  );
1028
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
1029
- res.end(dom || "(empty)");
1080
+ send(res, 200, dom || "(empty)");
1030
1081
  return;
1031
1082
  }
1032
1083
  if (parts[0] === "screen") {
1033
1084
  const screen = await collectScreenFromClient();
1034
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
1035
- res.end(screen || "(empty)");
1085
+ send(res, 200, screen || "(empty)");
1086
+ return;
1087
+ }
1088
+ if (parts[0] === "cdp" && parts[1] === "poll") {
1089
+ const queued = cdpQueue.shift();
1090
+ if (queued) {
1091
+ send(res, 200, JSON.stringify(queued));
1092
+ return;
1093
+ }
1094
+ const cmd = await new Promise((resolve2) => {
1095
+ cdpWaiter = resolve2;
1096
+ setTimeout(() => {
1097
+ if (cdpWaiter === resolve2) {
1098
+ cdpWaiter = null;
1099
+ resolve2(null);
1100
+ }
1101
+ }, 25e3);
1102
+ });
1103
+ if (cmd)
1104
+ send(res, 200, JSON.stringify(cmd));
1105
+ else
1106
+ send(res, 204, "");
1107
+ return;
1108
+ }
1109
+ if (parts[0] === "cdp" && parts[1] === "result") {
1110
+ const body = await readBody(req);
1111
+ let data;
1112
+ try {
1113
+ data = JSON.parse(body);
1114
+ } catch {
1115
+ send(res, 400, "cdp/result needs a JSON body {id, ok, error?}");
1116
+ return;
1117
+ }
1118
+ const resolveCdp = cdpResults.get(data.id);
1119
+ if (resolveCdp) {
1120
+ cdpResults.delete(data.id);
1121
+ resolveCdp({ ok: data.ok, error: data.error });
1122
+ }
1123
+ send(res, 200, "ok");
1036
1124
  return;
1037
1125
  }
1038
1126
  if (parts[0] === "chain") {
@@ -1043,8 +1131,7 @@ function aipeekPlugin() {
1043
1131
  if (!Array.isArray(steps))
1044
1132
  throw new Error("body must be a JSON array");
1045
1133
  } catch (e) {
1046
- res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1047
- res.end(`invalid chain body: ${e instanceof Error ? e.message : String(e)}`);
1134
+ send(res, 400, `invalid chain body: ${e instanceof Error ? e.message : String(e)}`);
1048
1135
  return;
1049
1136
  }
1050
1137
  lastRaw = null;
@@ -1059,7 +1146,7 @@ function aipeekPlugin() {
1059
1146
  allOk = false;
1060
1147
  break;
1061
1148
  }
1062
- const r = await sendAction(type, args);
1149
+ const r = await runAction(type, args);
1063
1150
  lines.push(`[${i}] ${r.ok ? "\u2713" : "\u2717"} ${type}: ${r.ok ? r.detail || "ok" : r.error}`);
1064
1151
  if (r.screen)
1065
1152
  lines.push(r.screen.split("\n").map((l) => ` ${l}`).join("\n"));
@@ -1070,14 +1157,13 @@ function aipeekPlugin() {
1070
1157
  break;
1071
1158
  }
1072
1159
  }
1073
- res.writeHead(allOk ? 200 : 422, { "Content-Type": "text/plain; charset=utf-8" });
1074
- res.end(lastUi ? `${lines.join("\n")}
1160
+ send(res, allOk ? 200 : 422, lastUi ? `${lines.join("\n")}
1075
1161
 
1076
1162
  --- ui after ---
1077
1163
  ${lastUi}` : lines.join("\n"));
1078
1164
  return;
1079
1165
  }
1080
- if (["click", "fill", "press", "wait", "screenshot"].includes(parts[0])) {
1166
+ if (["click", "fill", "press", "wait", "screenshot", "realclick", "query"].includes(parts[0])) {
1081
1167
  const q = url.searchParams;
1082
1168
  const args = {
1083
1169
  sel: q.get("sel") || void 0,
@@ -1085,31 +1171,30 @@ ${lastUi}` : lines.join("\n"));
1085
1171
  value: q.has("value") ? q.get("value") : void 0,
1086
1172
  key: q.get("key") || void 0,
1087
1173
  timeout: q.has("timeout") ? Number(q.get("timeout")) : void 0,
1088
- gone: q.has("gone") ? q.get("gone") !== "false" : void 0
1174
+ gone: q.has("gone") ? q.get("gone") !== "false" : void 0,
1175
+ button: q.get("button") === "right" ? "right" : q.get("button") === "left" ? "left" : void 0,
1176
+ x: q.has("x") ? Number(q.get("x")) : void 0,
1177
+ y: q.has("y") ? Number(q.get("y")) : void 0
1089
1178
  };
1090
1179
  const check2 = resolveAction(parts[0], args);
1091
1180
  if (!check2.valid) {
1092
- res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1093
- res.end(check2.error);
1181
+ send(res, 400, check2.error ?? "invalid action");
1094
1182
  return;
1095
1183
  }
1096
- const result = await sendAction(parts[0], args);
1097
- lastRaw = null;
1184
+ const result = await runAction(parts[0], args);
1098
1185
  if (parts[0] === "screenshot" && result.dataUrl) {
1099
1186
  const dir = resolve(server.config.root, ".aipeek");
1100
1187
  mkdirSync(dir, { recursive: true });
1101
1188
  const name = q.get("out") || `shot-${result.dataUrl.length}.png`;
1102
1189
  const file = resolve(dir, name);
1103
1190
  writeFileSync(file, Buffer.from(result.dataUrl.split(",")[1], "base64"));
1104
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
1105
- res.end(`saved: ${file}`);
1191
+ send(res, 200, `saved: ${file}`);
1106
1192
  return;
1107
1193
  }
1108
- res.writeHead(result.ok ? 200 : 422, { "Content-Type": "text/plain; charset=utf-8" });
1109
1194
  const head = result.ok ? result.detail || "ok" : `${result.error}${result.detail ? `
1110
1195
 
1111
1196
  clickable: ${result.detail}` : ""}`;
1112
- res.end(result.ui ? `${head}
1197
+ send(res, result.ok ? 200 : 422, result.ui ? `${head}
1113
1198
 
1114
1199
  --- ui after ---
1115
1200
  ${result.ui}` : head);
@@ -1120,8 +1205,7 @@ ${result.ui}` : head);
1120
1205
  lastRaw = raw2;
1121
1206
  const result = check(raw2);
1122
1207
  const output = emitCheck(result);
1123
- res.writeHead(result.pass ? 200 : 417, { "Content-Type": "text/plain; charset=utf-8" });
1124
- res.end(output);
1208
+ send(res, result.pass ? 200 : 417, output);
1125
1209
  return;
1126
1210
  }
1127
1211
  if (parts.length >= 1) {
@@ -1129,29 +1213,22 @@ ${result.ui}` : head);
1129
1213
  lastRaw = await collectFromClient();
1130
1214
  const result = detail(lastRaw, parts[0], parts[1], full);
1131
1215
  if (result !== null) {
1132
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
1133
- res.end(result);
1216
+ send(res, 200, result);
1134
1217
  return;
1135
1218
  }
1136
- res.writeHead(404, { "Content-Type": "text/plain" });
1137
- res.end(`not found: ${parts.join("/")}`);
1219
+ send(res, 404, `not found: ${parts.join("/")}`);
1138
1220
  return;
1139
1221
  }
1140
1222
  const raw = await collectFromClient();
1141
1223
  lastRaw = raw;
1142
1224
  if (full) {
1143
1225
  const compacted = compact(raw);
1144
- const output = emit(compacted);
1145
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
1146
- res.end(output);
1226
+ send(res, 200, emit(compacted));
1147
1227
  } else {
1148
- const output = emitSummary(raw);
1149
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
1150
- res.end(output);
1228
+ send(res, 200, emitSummary(raw));
1151
1229
  }
1152
1230
  } catch (err) {
1153
- res.writeHead(504, { "Content-Type": "text/plain" });
1154
- res.end(err instanceof Error ? err.message : "unknown error");
1231
+ send(res, 504, err instanceof Error ? err.message : "unknown error");
1155
1232
  }
1156
1233
  });
1157
1234
  }
@@ -1166,6 +1243,7 @@ export {
1166
1243
  emitDiff,
1167
1244
  START_TAG,
1168
1245
  END_TAG,
1246
+ renderClaudeMd,
1169
1247
  injectClaudeMd,
1170
1248
  aipeekPlugin
1171
1249
  };