partforge 0.4.0 → 0.5.1

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
@@ -50,6 +50,11 @@ hide internal params, and keep the interface simple while staying deeply adjusta
50
50
  `src/parts/demo.js` is a minimal worked example; run `npm run dev` and open
51
51
  `/demo.html` to see it live.
52
52
 
53
+ - **Agent clarification (`request-a-pick`):** an external tool can ask the user to click
54
+ geometry and get the `Selection` back — serve with `?pickserver`, drive with
55
+ `partforge pick-serve` + `partforge pick "<prompt>" …`. See
56
+ `skills/partforge/SKILL.md` and the authoring guide.
57
+
53
58
  ## License
54
59
 
55
60
  MIT
package/bin/cli.js CHANGED
@@ -8,54 +8,76 @@ import { detectBackend } from "../src/framework/geometry/probe.js";
8
8
  import { bootOcctKernel } from "../src/testing/occt.js";
9
9
  import { measure } from "../src/testing/measure.js";
10
10
  import { renderViews } from "../src/testing/render.js";
11
+ import { createPickServer, requestPicks, formatPickResult } from "../src/framework/pick-request/server.js";
11
12
 
12
13
  const die = (msg) => { console.error(msg); process.exit(1); };
13
14
  const slug = (s) => String(s).toLowerCase().replace(/\s+/g, "-");
14
15
 
15
- const [, , cmd, partPath, ...rest] = process.argv;
16
+ const [, , cmd, ...args] = process.argv;
16
17
  const flags = {};
17
18
  const positional = [];
18
- for (let i = 0; i < rest.length; i++) {
19
- if (rest[i].startsWith("--")) {
20
- const key = rest[i].slice(2);
21
- flags[key] = rest[i + 1] && !rest[i + 1].startsWith("--") ? rest[++i] : true;
22
- } else positional.push(rest[i]);
19
+ for (let i = 0; i < args.length; i++) {
20
+ if (args[i].startsWith("--")) {
21
+ const key = args[i].slice(2);
22
+ flags[key] = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : true;
23
+ } else positional.push(args[i]);
23
24
  }
24
- const view = positional[0];
25
25
 
26
- if (!["measure", "render"].includes(cmd)) die("usage: partforge <measure|render> <part-module> [view] [flags]");
27
- if (!partPath) die(`usage: partforge ${cmd} <part-module> [view]`);
26
+ const USAGE = "usage: partforge <measure|render|pick-serve|pick> ";
28
27
 
29
- const mod = await import(pathToFileURL(resolve(process.cwd(), partPath)))
30
- .catch((e) => die(`cannot load part "${partPath}": ${e.message}`));
31
- const part = mod.default;
32
- if (!part?.parts || !part?.views) die(`"${partPath}" has no default-exported PartDefinition`);
33
-
34
- let kernel;
35
- if (detectBackend(part) === "occt") {
36
- kernel = await bootOcctKernel();
37
- } else {
38
- const wasm = await Module(); wasm.setup();
39
- kernel = createManifoldKernel(wasm, { quality: "preview" });
28
+ // --- pick-serve / pick: no part module, no kernel boot --------------------------
29
+ if (cmd === "pick-serve") {
30
+ const port = Number(flags.port) || 4518;
31
+ const timeoutMs = (Number(flags.timeout) || 120) * 1000;
32
+ const { port: bound } = await createPickServer({ port, timeoutMs }).start();
33
+ console.log(`partforge pick-server listening on http://127.0.0.1:${bound}`);
34
+ // keep the process alive serving requests
35
+ } else if (cmd === "pick") {
36
+ if (positional.length === 0) die('usage: partforge pick "<prompt>" ["<prompt>" …] [--port N]');
37
+ const port = Number(flags.port) || 4518;
38
+ const out = await requestPicks({ port, prompts: positional }).catch((e) => die(e.message));
39
+ console.log(formatPickResult(out));
40
+ process.exit(out.status === "done" ? 0 : 1);
41
+ } else if (!["measure", "render"].includes(cmd)) {
42
+ die(USAGE);
40
43
  }
41
44
 
42
- try {
43
- if (cmd === "measure") {
44
- const report = measure(kernel, part, view);
45
- printMeasure(report);
46
- const file = `measure-${slug(report.part)}-${report.view}.json`;
47
- writeFileSync(file, JSON.stringify(report, null, 2));
48
- console.log(`\nwrote ${file}`);
49
- if (flags.json) console.log(JSON.stringify(report, null, 2));
50
- process.exit(report.ok ? 0 : 1);
45
+ const partPath = positional[0];
46
+ const view = positional[1];
47
+ if (["measure", "render"].includes(cmd) && !partPath) die(`usage: partforge ${cmd} <part-module> [view]`);
48
+
49
+ if (["measure", "render"].includes(cmd)) {
50
+ const mod = await import(pathToFileURL(resolve(process.cwd(), partPath)))
51
+ .catch((e) => die(`cannot load part "${partPath}": ${e.message}`));
52
+ const part = mod.default;
53
+ if (!part?.parts || !part?.views) die(`"${partPath}" has no default-exported PartDefinition`);
54
+
55
+ let kernel;
56
+ if (detectBackend(part) === "occt") {
57
+ kernel = await bootOcctKernel();
51
58
  } else {
52
- const views = typeof flags.views === "string" ? flags.views.split(",") : undefined;
53
- const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
54
- for (const f of files) console.log(`wrote ${f}`);
55
- process.exit(0);
59
+ const wasm = await Module(); wasm.setup();
60
+ kernel = createManifoldKernel(wasm, { quality: "preview" });
61
+ }
62
+
63
+ try {
64
+ if (cmd === "measure") {
65
+ const report = measure(kernel, part, view);
66
+ printMeasure(report);
67
+ const file = `measure-${slug(report.part)}-${report.view}.json`;
68
+ writeFileSync(file, JSON.stringify(report, null, 2));
69
+ console.log(`\nwrote ${file}`);
70
+ if (flags.json) console.log(JSON.stringify(report, null, 2));
71
+ process.exit(report.ok ? 0 : 1);
72
+ } else {
73
+ const views = typeof flags.views === "string" ? flags.views.split(",") : undefined;
74
+ const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
75
+ for (const f of files) console.log(`wrote ${f}`);
76
+ process.exit(0);
77
+ }
78
+ } catch (e) {
79
+ die(`${cmd} failed: ${e.message || e}`);
56
80
  }
57
- } catch (e) {
58
- die(`${cmd} failed: ${e.message || e}`);
59
81
  }
60
82
 
61
83
  function printMeasure(r) {
@@ -467,3 +467,21 @@ entirely on OCCT, its fillets are exact in the STEP **and** present in the print
467
467
  `place(..., { purpose: "export" })` may depend on `view`.
468
468
  - Keep geometry backend-agnostic (kernel calls only) so it works in both backends; only
469
469
  STEP requires OCCT.
470
+
471
+ ---
472
+
473
+ ## Interactive clarification: request-a-pick
474
+
475
+ An external tool (e.g. an AI agent editing your part) can ask the *user* to click
476
+ geometry and receive the `Selection` back, closing the loop in the other direction
477
+ from `?pick`.
478
+
479
+ - Serve your app with **`?pickserver`** (or `?pickserver=http://host:port`) to enable
480
+ it. While idle nothing changes; when the local pick-server requests a click, a banner
481
+ appears ("🤖 Claude needs you to click …") and the picker arms for one click.
482
+ - The agent side runs `partforge pick-serve` once, then `partforge pick "<prompt>" …`
483
+ for one or more clicks (collected in order, returned together). The CLI blocks until
484
+ the user clicks, then prints the `Selection`(s) as JSON.
485
+
486
+ See the bundled skill `skills/partforge/SKILL.md` for the agent workflow. This is plain
487
+ click-routing — no LLM logic lives in partforge.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,6 +16,7 @@
16
16
  "files": [
17
17
  "src",
18
18
  "bin",
19
+ "skills/partforge/SKILL.md",
19
20
  "docs/AUTHORING-PARTS.md",
20
21
  "README.md"
21
22
  ],
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: partforge-request-pick
3
+ description: Use when editing a partforge part for a user who has the live app open and you need them to point at geometry — ask for one or more clicks and get the Selection(s) back.
4
+ ---
5
+
6
+ # partforge: request-a-pick
7
+
8
+ When you're editing a partforge part and you're unsure *which* face, edge, hole, or
9
+ sub-part the user means, don't guess — ask them to click it in the live app. Their
10
+ click comes back to you as a structured `Selection` (sub-part, local CAD point,
11
+ surface normal, the parameters they were viewing).
12
+
13
+ ## When to use
14
+
15
+ - The user's request is geometrically ambiguous ("make this thicker", "fillet that
16
+ edge", "move the hole") and more than one feature could match.
17
+ - You need a concrete location/normal to drive an edit.
18
+
19
+ ## One-time setup (per session)
20
+
21
+ Start the pick-server (it bridges the app and this CLI). The user must have the app
22
+ open with `?pickserver` (e.g. `http://localhost:5173/?pickserver`).
23
+
24
+ ```bash
25
+ partforge pick-serve & # default http://127.0.0.1:4518
26
+ ```
27
+
28
+ ## Requesting clicks
29
+
30
+ Ask for one or many — they're collected in order and returned together:
31
+
32
+ ```bash
33
+ partforge pick "click the face you want filleted"
34
+ partforge pick "click the mounting hole" "click the top edge" "click the boss"
35
+ ```
36
+
37
+ Tell the user out loud to check their browser ("I've put a prompt in your browser —
38
+ click the face you want filleted"). The command **blocks** until they click (or
39
+ timeout), then prints a summary plus JSON:
40
+
41
+ ```json
42
+ { "status": "done", "picks": [ { "prompt": "...", "selection": { "subPart": "...", "point": [...], "normal": [...], "params": {...} } } ] }
43
+ ```
44
+
45
+ Picks come back **in request order**, each echoing its prompt, so you can map them.
46
+
47
+ ## Handling outcomes
48
+
49
+ - `done` — proceed with the returned `selection`(s).
50
+ - `timeout` — the user didn't click in time; `picks` holds any collected so far. Ask
51
+ again or fall back to asking in words.
52
+ - `cancelled` — the user clicked "Can't find it"; reconsider what you're asking for.
53
+ - `busy` (exit non-zero) — a request is already in flight; wait and retry.
54
+
55
+ ## Notes
56
+
57
+ - This only *reads* a click — it never edits files. You make the edits yourself after.
58
+ - The server is localhost-only and holds one request at a time.
@@ -163,4 +163,39 @@ button.action:disabled { opacity: .5; cursor: default; }
163
163
  /* relevance: controls/sections that don't affect the on-screen parts */
164
164
  .irrelevant { opacity: 0.45; }
165
165
  .irrelevant:hover { opacity: 0.7; }
166
- .section-hidden { display: none; }
166
+ .section-hidden { display: none; }
167
+
168
+ /* request-a-pick: agent prompt banner, floated top-centre well below the part tabs,
169
+ laid out like a chat message (avatar + text). Slides/fades in when shown; themed. */
170
+ #pf-pick-banner {
171
+ position: fixed; top: 78px; left: 50%; transform: translateX(-50%);
172
+ z-index: 30; max-width: min(56ch, calc(100vw - 24px));
173
+ padding: 10px 36px 10px 12px;
174
+ background: var(--surface); color: var(--text);
175
+ border: 1px solid var(--border); border-radius: 10px;
176
+ box-shadow: 0 6px 24px rgba(0,0,0,.35);
177
+ font-size: 12px; line-height: 1.45;
178
+ animation: pf-pick-in .18s ease;
179
+ }
180
+ #pf-pick-banner .pf-pick-row { display: flex; align-items: center; gap: 10px; }
181
+ #pf-pick-banner .pf-pick-avatar {
182
+ flex: none; width: 30px; height: 30px; border-radius: 50%; font-size: 17px;
183
+ display: flex; align-items: center; justify-content: center;
184
+ background: var(--surface-2); border: 1px solid var(--border);
185
+ }
186
+ #pf-pick-banner .pf-pick-msg { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
187
+ #pf-pick-banner .pf-pick-label { color: var(--muted); font-size: 11px; }
188
+ #pf-pick-banner .pf-pick-prompt { color: var(--text-strong); font-weight: 600; }
189
+ #pf-pick-close {
190
+ position: absolute; top: 6px; right: 6px; appearance: none;
191
+ width: 20px; height: 20px; padding: 0;
192
+ border: 1px solid var(--border); border-radius: 6px;
193
+ background: var(--surface-2); color: var(--muted);
194
+ cursor: pointer; font-size: 13px; line-height: 1;
195
+ display: flex; align-items: center; justify-content: center;
196
+ }
197
+ #pf-pick-close:hover { color: var(--text-strong); border-color: var(--accent); }
198
+ @keyframes pf-pick-in {
199
+ from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
200
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
201
+ }
@@ -9,6 +9,7 @@ import { viewSubParts } from "./jobs.js";
9
9
  import { detectBackend } from "./geometry/probe.js";
10
10
  import { createDebugOverlay } from "./debug-overlay.js";
11
11
  import { attachPicker, formatSelection } from "./selection/index.js";
12
+ import { createPickRequestClient } from "./pick-request/index.js";
12
13
 
13
14
  // Mount a full parametric-part app from a PartDefinition: 3-D viewer + control
14
15
  // panel + the two geometry workers + the auto-regenerating view/cache loop +
@@ -108,6 +109,18 @@ export function mount(part, { createWorker, container = document.getElementById(
108
109
  picker.setActive(isActive);
109
110
  btn.style.outline = isActive ? "2px solid #ffcc33" : "";
110
111
  });
112
+ } else if (qs.has("pickserver")) {
113
+ // Agent-driven mode: arm the picker only when the local pick-server asks for a
114
+ // click. Mutually exclusive with the clipboard ?pick toggle (else-if), so only one
115
+ // click listener is ever live. `?pickserver` or `?pickserver=http://host:port`.
116
+ const serverUrl = typeof qs.get("pickserver") === "string" && qs.get("pickserver")
117
+ ? qs.get("pickserver") : "http://127.0.0.1:4518";
118
+ createPickRequestClient({
119
+ serverUrl,
120
+ viewer,
121
+ part,
122
+ getContext: () => ({ view, params, derived: part.derive ? part.derive({ ...part.defaults, ...params }) : {} }),
123
+ });
111
124
  }
112
125
 
113
126
  let framedView = null; // the view the camera was last framed to (null until first show)
@@ -0,0 +1,42 @@
1
+ // Pure state machine for one batch of click requests: ordered prompts in,
2
+ // ordered picks out. No http, no DOM, no timers (the server owns the clock).
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ export function createBatch(prompts) {
6
+ return { id: randomUUID(), prompts: [...prompts], picks: [], index: 0, status: "collecting" };
7
+ }
8
+
9
+ export function view(batch) {
10
+ const collecting = batch.status === "collecting";
11
+ return {
12
+ id: batch.id,
13
+ index: batch.index,
14
+ total: batch.prompts.length,
15
+ prompt: collecting ? batch.prompts[batch.index] : null,
16
+ status: batch.status,
17
+ };
18
+ }
19
+
20
+ // Record a click for the current step. Ignores a non-collecting batch or an index
21
+ // that isn't the one we're waiting on (stale/duplicate click guard).
22
+ export function resolve(batch, index, selection) {
23
+ if (batch.status !== "collecting" || index !== batch.index) return batch;
24
+ batch.picks.push({ prompt: batch.prompts[batch.index], selection });
25
+ batch.index += 1;
26
+ if (batch.index >= batch.prompts.length) batch.status = "done";
27
+ return batch;
28
+ }
29
+
30
+ export function cancel(batch) {
31
+ if (batch.status === "collecting") batch.status = "cancelled";
32
+ return batch;
33
+ }
34
+
35
+ export function timeout(batch) {
36
+ if (batch.status === "collecting") batch.status = "timeout";
37
+ return batch;
38
+ }
39
+
40
+ export function result(batch) {
41
+ return { status: batch.status, picks: batch.picks };
42
+ }
@@ -0,0 +1,65 @@
1
+ // Browser side of request-a-pick: drives the shared prompt banner from two sources —
2
+ // an agent asking for clicks over SSE, and a click-to-copy button in the viewbar.
3
+ // Created only under ?pickserver (see mount.js), so both are present exactly when the
4
+ // user is working with an agent.
5
+ import { createPromptBanner } from "./prompt-banner.js";
6
+ import { formatSelection } from "../selection/format.js";
7
+
8
+ export function createPickRequestClient({ serverUrl = "http://127.0.0.1:4518", viewer, part, getContext }) {
9
+ let active = null; // { id, index } of the agent prompt we're waiting on
10
+ const banner = createPromptBanner({ viewer, part, getContext });
11
+
12
+ const postJson = (path, body) =>
13
+ fetch(`${serverUrl}${path}`, {
14
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
15
+ }).catch(() => banner.message("⚠ couldn't reach pick-server — click not sent"));
16
+
17
+ // --- agent prompts over SSE -------------------------------------------------
18
+ const es = new globalThis.EventSource(`${serverUrl}/events`);
19
+ es.addEventListener("prompt", (e) => {
20
+ const v = JSON.parse(e.data);
21
+ active = { id: v.id, index: v.index };
22
+ banner.request({
23
+ avatar: "🤖",
24
+ label: v.total > 1 ? `Your agent asks: (${v.index + 1} of ${v.total})` : "Your agent asks:",
25
+ text: v.prompt,
26
+ onResolve: (selection) => postJson("/resolve", { id: active.id, index: active.index, selection }),
27
+ onDismiss: () => { if (active) postJson("/cancel", { id: active.id }); },
28
+ });
29
+ });
30
+ es.addEventListener("cleared", () => { active = null; banner.dismiss(); });
31
+ es.onerror = () => { banner.message("⚠ agent pick-server not reachable"); };
32
+ es.onopen = () => { if (!active) banner.dismiss(); };
33
+
34
+ // --- click-to-copy button, prepended into the viewbar (left of the play btn) -
35
+ // Copies the SAME description an agent receives for a click, so the two paths match.
36
+ const viewbar = document.getElementById("viewbar");
37
+ let copyBtn = null;
38
+ if (viewbar) {
39
+ copyBtn = document.createElement("button");
40
+ copyBtn.id = "copy";
41
+ copyBtn.type = "button";
42
+ // Flat single-colour copy glyph (inherits the viewbar button's currentColor).
43
+ copyBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5V4A1.5 1.5 0 0 0 9 2.5H4A1.5 1.5 0 0 0 2.5 4V9A1.5 1.5 0 0 0 4 10.5H5.5"/></svg>';
44
+ copyBtn.title = "Click a detail to copy its agent description";
45
+ copyBtn.setAttribute("aria-label", "Copy a detail's agent description");
46
+ copyBtn.addEventListener("click", () => {
47
+ if (banner.isOpen()) { banner.dismiss(); return; } // toggle off
48
+ banner.request({
49
+ avatar: "📋",
50
+ label: "Copy to clipboard",
51
+ text: "Click on a detail to copy its agent description to the clipboard.",
52
+ onResolve: (selection) => {
53
+ navigator.clipboard?.writeText(formatSelection(selection, { style: "prompt" }));
54
+ banner.message("✓ Copied to clipboard");
55
+ setTimeout(() => banner.dismiss(), 1000);
56
+ },
57
+ });
58
+ });
59
+ viewbar.prepend(copyBtn);
60
+ }
61
+
62
+ return {
63
+ detach: () => { es.close(); banner.detach(); copyBtn?.remove(); },
64
+ };
65
+ }
@@ -0,0 +1 @@
1
+ export { createPickRequestClient } from "./client.js";
@@ -0,0 +1,88 @@
1
+ // A small chat-style prompt banner that arms the selection picker for one click.
2
+ // Shared by request-a-pick's two drivers (an agent asking via SSE, and the
3
+ // click-to-copy button) so they look and behave identically. The look (position,
4
+ // theme colours, slide-in animation) lives in app.css (#pf-pick-banner).
5
+ import { attachPicker } from "../selection/pick.js";
6
+
7
+ export function createPromptBanner({ viewer, part, getContext }) {
8
+ let onResolve = null; // called with the Selection on the next pick
9
+ let onDismiss = null; // called when the user closes the banner via ×
10
+
11
+ const banner = document.createElement("div");
12
+ banner.id = "pf-pick-banner";
13
+ banner.style.display = "none";
14
+
15
+ const row = document.createElement("div");
16
+ row.className = "pf-pick-row";
17
+ const avatar = document.createElement("div");
18
+ avatar.className = "pf-pick-avatar";
19
+ const msg = document.createElement("div");
20
+ msg.className = "pf-pick-msg";
21
+ const label = document.createElement("div");
22
+ label.className = "pf-pick-label";
23
+ const prompt = document.createElement("div");
24
+ prompt.className = "pf-pick-prompt";
25
+ msg.append(label, prompt);
26
+ row.append(avatar, msg);
27
+
28
+ const close = document.createElement("button");
29
+ close.id = "pf-pick-close";
30
+ close.type = "button";
31
+ close.textContent = "×";
32
+ close.setAttribute("aria-label", "Dismiss");
33
+
34
+ banner.append(row, close);
35
+ document.body.appendChild(banner);
36
+
37
+ const isOpen = () => banner.style.display !== "none";
38
+
39
+ function dismiss() {
40
+ onResolve = null;
41
+ onDismiss = null;
42
+ picker.setActive(false);
43
+ banner.style.display = "none";
44
+ }
45
+
46
+ const picker = attachPicker(viewer, {
47
+ part, getContext,
48
+ onPick: (selection) => {
49
+ const resolve = onResolve;
50
+ onResolve = null; // a pick resolves once; a stray click after is a no-op
51
+ picker.setActive(false); // consumer re-arms via request() if more clicks are wanted
52
+ resolve?.(selection);
53
+ },
54
+ });
55
+
56
+ close.addEventListener("click", () => {
57
+ const dismissed = onDismiss;
58
+ dismiss();
59
+ dismissed?.();
60
+ });
61
+
62
+ // Show a prompt and arm the picker for one click. Prompt/label text is rendered via
63
+ // textContent (never innerHTML) — it can carry agent-supplied strings.
64
+ function request({ avatar: glyph, label: text, text: body, onResolve: resolve = null, onDismiss: dismissed = null }) {
65
+ onResolve = resolve;
66
+ onDismiss = dismissed;
67
+ avatar.textContent = glyph;
68
+ label.textContent = text || "";
69
+ prompt.textContent = body;
70
+ banner.style.display = "block";
71
+ picker.setActive(true);
72
+ }
73
+
74
+ // Show a transient note (error or confirmation) with no pending pick. Keeps the
75
+ // current avatar so a "✓ Copied" / "⚠ …" note stays in context.
76
+ function message(body) {
77
+ onResolve = null;
78
+ picker.setActive(false);
79
+ label.textContent = "";
80
+ prompt.textContent = body;
81
+ banner.style.display = "block";
82
+ }
83
+
84
+ return {
85
+ request, message, dismiss, isOpen,
86
+ detach: () => { picker.detach(); banner.remove(); },
87
+ };
88
+ }
@@ -0,0 +1,158 @@
1
+ // src/framework/pick-request/server.js
2
+ // The Node side of request-a-pick: an http+SSE server holding one active batch,
3
+ // a blocking CLI client (requestPicks), and CLI output formatting. 127.0.0.1 only.
4
+ import { createServer, request as httpRequest } from "node:http";
5
+ import { createBatch, view, resolve, cancel, timeout, result } from "./batch.js";
6
+ import { formatSelection } from "../selection/format.js";
7
+
8
+ const json = (res, code, obj, origin) => {
9
+ res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": origin || "*" });
10
+ res.end(JSON.stringify(obj));
11
+ };
12
+ const readBody = (req) => new Promise((resolve_) => {
13
+ let b = "";
14
+ req.on("data", (c) => (b += c));
15
+ req.on("end", () => {
16
+ if (!b) { resolve_({}); return; }
17
+ try { resolve_(JSON.parse(b)); } catch { resolve_({ _parseError: true }); }
18
+ });
19
+ });
20
+
21
+ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
22
+ let batch = null; // the one active batch (or null)
23
+ let pending = null; // { res, timer } — the held POST /request response
24
+ const sseClients = new Set();
25
+ const allSockets = new Set(); // track every socket for forceful teardown
26
+
27
+ const sse = (event, data) => {
28
+ for (const res of sseClients) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
29
+ };
30
+ const finish = () => { // resolve the held /request with the result
31
+ if (pending) {
32
+ clearTimeout(pending.timer);
33
+ json(pending.res, 200, result(batch));
34
+ pending = null;
35
+ }
36
+ sse("cleared", {});
37
+ batch = null;
38
+ };
39
+
40
+ const server = createServer(async (req, res) => {
41
+ const origin = req.headers.origin;
42
+ if (req.method === "OPTIONS") {
43
+ res.writeHead(204, {
44
+ "access-control-allow-origin": origin || "*",
45
+ "access-control-allow-methods": "GET,POST,OPTIONS",
46
+ "access-control-allow-headers": "content-type",
47
+ });
48
+ return res.end();
49
+ }
50
+ const url = req.url.split("?")[0];
51
+
52
+ if (req.method === "POST" && url === "/request") {
53
+ if (batch) return json(res, 409, { status: "busy" }, origin);
54
+ const body = await readBody(req);
55
+ if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
56
+ if (!Array.isArray(body.prompts) || body.prompts.length === 0) {
57
+ return json(res, 400, { error: "prompts must be a non-empty array" }, origin);
58
+ }
59
+ batch = createBatch(body.prompts);
60
+ pending = { res, timer: setTimeout(() => { timeout(batch); finish(); }, timeoutMs) };
61
+ sse("prompt", view(batch));
62
+ return; // held open until finish()
63
+ }
64
+ if (req.method === "GET" && url === "/events") {
65
+ res.writeHead(200, {
66
+ "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive",
67
+ "access-control-allow-origin": origin || "*",
68
+ });
69
+ res.write(": connected\n\n"); // SSE comment — flushes headers, makes fetch() resolve
70
+ sseClients.add(res);
71
+ if (batch) res.write(`event: prompt\ndata: ${JSON.stringify(view(batch))}\n\n`); // replay current
72
+ req.on("close", () => sseClients.delete(res));
73
+ return;
74
+ }
75
+ if (req.method === "POST" && url === "/resolve") {
76
+ const body = await readBody(req);
77
+ if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
78
+ const { id, index, selection } = body;
79
+ if (batch && id === batch.id) {
80
+ resolve(batch, index, selection);
81
+ if (view(batch).status === "collecting") sse("prompt", view(batch));
82
+ else finish();
83
+ }
84
+ return json(res, 200, { ok: true }, origin);
85
+ }
86
+ if (req.method === "POST" && url === "/cancel") {
87
+ const body = await readBody(req);
88
+ if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
89
+ const { id } = body;
90
+ if (batch && id === batch.id) { cancel(batch); finish(); }
91
+ return json(res, 200, { ok: true }, origin);
92
+ }
93
+ return json(res, 404, { error: "not found" }, origin);
94
+ });
95
+
96
+ server.on("connection", (socket) => {
97
+ allSockets.add(socket);
98
+ socket.on("close", () => allSockets.delete(socket));
99
+ });
100
+
101
+ return {
102
+ start: () => new Promise((res_) => server.listen(port, "127.0.0.1", () => res_({ port: server.address().port }))),
103
+ stop: () => new Promise((res_) => {
104
+ // If a batch is active, cancel it and resolve the held /request cleanly
105
+ // so any awaiting requestPicks() gets a result instead of a socket hang-up.
106
+ if (batch) { cancel(batch); finish(); }
107
+ for (const c of sseClients) c.end();
108
+ server.close(() => res_());
109
+ // destroy any lingering sockets (SSE keep-alive) so server.close resolves
110
+ for (const s of allSockets) s.destroy();
111
+ }),
112
+ };
113
+ }
114
+
115
+ // CLI client: POST the prompts and await the held response (blocks until the batch
116
+ // reaches a terminal status server-side). Fails fast with a hint if nothing answers.
117
+ export function requestPicks({ port = 4518, host = "127.0.0.1", prompts }) {
118
+ return new Promise((resolve_, reject) => {
119
+ const payload = JSON.stringify({ prompts });
120
+ const req = httpRequest(
121
+ { host, port, path: "/request", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) } },
122
+ (res) => {
123
+ let b = "";
124
+ res.on("data", (c) => (b += c));
125
+ res.on("end", () => {
126
+ try {
127
+ resolve_(JSON.parse(b));
128
+ } catch {
129
+ reject(new Error(`unexpected response from pick-server on ${host}:${port} (is the app open and \`partforge pick-serve\` running?)`));
130
+ }
131
+ });
132
+ },
133
+ );
134
+ req.on("error", (e) => reject(new Error(`could not reach pick-server on ${host}:${port} (is the app open and \`partforge pick-serve\` running?) — ${e.message}`)));
135
+ req.end(payload);
136
+ });
137
+ }
138
+
139
+ // Human-readable CLI output: one summary line per pick, then the raw JSON to parse.
140
+ export function formatPickResult({ status, picks }) {
141
+ const safePicks = Array.isArray(picks) ? picks : [];
142
+ const lines = [`status: ${status} (${safePicks.length} pick${safePicks.length === 1 ? "" : "s"})`];
143
+ for (const { prompt, selection } of safePicks) {
144
+ let summary;
145
+ try {
146
+ if (selection && selection.point && selection.normal && selection.params) {
147
+ summary = formatSelection(selection, { style: "prompt" });
148
+ } else {
149
+ summary = selection?.subPart ?? JSON.stringify(selection);
150
+ }
151
+ } catch {
152
+ summary = JSON.stringify(selection);
153
+ }
154
+ lines.push(`• "${prompt}" → ${summary}`);
155
+ }
156
+ lines.push("", JSON.stringify({ status, picks }, null, 2));
157
+ return lines.join("\n");
158
+ }