tapeo-cli 0.0.0-stage → 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/shared/api.mjs ADDED
@@ -0,0 +1,238 @@
1
+ /** Transport shared by the CLI and VS Code extension. No browser session cookies. */
2
+ import { partialJsonStringField, readAnswerStream } from "./stream.mjs";
3
+ export class TapeoApiError extends Error {
4
+ constructor(message, status, data = {}) { super(message); this.name = "TapeoApiError"; this.status = status; this.data = data; }
5
+ }
6
+
7
+ /** Workflow stages a run can start from or stop at (lib/workflow-stages.ts). */
8
+ export const WORKFLOW_STAGE_KEYS = ["simulation", "synthesis", "timing", "physical", "post-route-timing", "drc", "extraction", "erc", "lvs"];
9
+ export const ACTIVE_RUN_STATUSES = ["queued", "running"];
10
+
11
+ /** Keep CLI and VS Code status wording consistent with the persisted run. */
12
+ export function describeRun(run, now = Date.now()) {
13
+ const stage = run.currentStage ? ` · ${run.currentStage}` : "";
14
+ const summary = run.summary ? `\n ${run.summary}` : "";
15
+ if (run.status === "failed") return `${run.id} · FAILED${stage}${summary}\n Open its details (tapeo show ${run.id}, or Tapeo: Show Run Details) for stage results and tool logs. Saved local files are unchanged.`;
16
+ if (run.status === "cancelled") return `${run.id} · CANCELLED${summary}`;
17
+ if (run.status === "queued" && Number.isFinite(Date.parse(run.startedAt)) && now - Date.parse(run.startedAt) > 120_000) {
18
+ return `${run.id} · WAITING FOR WORKER${summary}\n Still queued after two minutes. Check that the server worker is running; retry status later.`;
19
+ }
20
+ return `${run.id} · ${String(run.status).toUpperCase()}${stage}${summary}`;
21
+ }
22
+
23
+ const STAGE_MARK = { completed: "✓", issues: "!", failed: "✗", skipped: "–" };
24
+ const GOAL_MARK = { passed: "✓", failed: "✗", "tool-error": "✗", blocked: "–", inconclusive: "?" };
25
+
26
+ /** A run's per-stage results, AI changes, and the tool runs whose logs can be read. */
27
+ export function describeRunDetail(detail) {
28
+ const { run, toolRuns = [] } = detail;
29
+ const lines = [describeRun(run)];
30
+ const details = run.details;
31
+ if (details?.stages?.length) {
32
+ lines.push("", "Stages:");
33
+ for (const stage of details.stages) lines.push(` ${STAGE_MARK[stage.status] ?? "·"} ${stage.label}: ${String(stage.summary).split("\n")[0].slice(0, 220)}`);
34
+ }
35
+ if (details?.changes?.length) {
36
+ lines.push("", "Files the AI Engineer changed:");
37
+ for (const change of details.changes) lines.push(` ${change.kind} ${change.path} (${change.stage})`);
38
+ }
39
+ if (details?.proposals?.length) lines.push("", `${details.proposals.length} suggested change(s) to protected files are waiting for review in the web workspace.`);
40
+ if (details?.notes?.length) { lines.push("", "Notes:"); for (const note of details.notes) lines.push(` ${note}`); }
41
+ if (toolRuns.length) {
42
+ lines.push("", "Tool runs (read a log with `tapeo logs <id>`):");
43
+ for (const tool of toolRuns) {
44
+ const measured = (tool.metrics ?? []).slice(0, 3).map((metric) => `${metric.key}=${metric.value}${metric.unit ? ` ${metric.unit}` : ""}`).join(", ");
45
+ lines.push(` ${tool.id} ${tool.tool} · ${tool.status}${measured ? ` · ${measured}` : ""}`);
46
+ }
47
+ }
48
+ return lines.join("\n");
49
+ }
50
+
51
+ /** The latest goal report, one line per goal. */
52
+ export function describeGoals(report) {
53
+ if (!report) return "No goal report yet. Run the workflow to measure the project's goals.";
54
+ const passed = report.outcomes.filter((outcome) => outcome.status === "passed").length;
55
+ const lines = [`Goals: ${passed} of ${report.outcomes.length} met · ${report.status === "complete" ? "complete" : "needs review"} · ${new Date(report.createdAt).toLocaleString()}`];
56
+ for (const outcome of report.outcomes) {
57
+ const name = outcome.goal?.key ?? outcome.goal?.id ?? "goal";
58
+ // Behavior goals carry their description as the target; only numeric targets read as a bound.
59
+ const target = typeof outcome.goal?.target === "number" ? ` · target ${outcome.goal.comparison === "less-than-or-equal" ? "≤" : outcome.goal.comparison === "greater-than-or-equal" ? "≥" : outcome.goal.comparison === "between" ? "between" : "="} ${outcome.goal.target}${outcome.goal.unit ? ` ${outcome.goal.unit}` : ""}` : "";
60
+ const evidence = outcome.evidence ? ` · measured ${outcome.evidence.value}${outcome.evidence.unit ? ` ${outcome.evidence.unit}` : ""} (${outcome.evidence.tool})` : "";
61
+ lines.push(` ${GOAL_MARK[outcome.status] ?? "·"} ${name}: ${outcome.status}${target}${evidence}${outcome.status === "passed" ? "" : ` · ${String(outcome.reason).slice(0, 200)}`}`);
62
+ }
63
+ return lines.join("\n");
64
+ }
65
+
66
+ /** The server a sign-in uses when none is named. */
67
+ export const DEFAULT_SERVER_URL = "https://tapeo.net";
68
+
69
+ /** Which client and version sends requests (X-Tapeo-Client), so the server can ask outdated clients to update. */
70
+ let clientIdentity = "tapeo-client/0.0.0";
71
+ export function setClientIdentity(name, version) { clientIdentity = `${name}/${version}`; }
72
+
73
+ export function normalizeServerUrl(value) {
74
+ const url = new URL(value);
75
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
76
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:")) throw new Error("Use HTTPS for a remote Tapeo server.");
77
+ if (url.username || url.password || url.search || url.hash) throw new Error("Use a server URL without credentials, query, or fragment.");
78
+ return url.origin;
79
+ }
80
+
81
+ async function send(serverUrl, method, path, { token, body, raw = false } = {}) {
82
+ let response;
83
+ try {
84
+ response = await fetch(new URL(`/api/client/v1${path}`, serverUrl), {
85
+ method,
86
+ headers: { "X-Tapeo-Client": clientIdentity, ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
87
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
88
+ redirect: "error",
89
+ signal: AbortSignal.timeout(raw ? 300_000 : 20_000)
90
+ });
91
+ } catch (error) {
92
+ const detail = error?.name === "TimeoutError" ? `The server did not answer within ${raw ? 300 : 20} seconds.` : "Could not reach the server.";
93
+ throw new TapeoApiError(`${detail} Check ${serverUrl} and retry; local files were not changed.`, 0);
94
+ }
95
+ if (raw && response.ok) return response;
96
+ const data = await response.json().catch(() => ({}));
97
+ if (!response.ok) {
98
+ if (response.status === 401 && token) throw new TapeoApiError("Your sign-in expired or was revoked. Run `tapeo login` (or Tapeo: Connect to Server) again. Local files were not changed.", 401, data);
99
+ throw new TapeoApiError(data.error ?? `Tapeo returned HTTP ${response.status}.`, response.status, data);
100
+ }
101
+ return response.status === 202 ? { ...data, pending: true } : data;
102
+ }
103
+
104
+ /**
105
+ * Browser-approved sign-in (the device flow). Starts a request, then waits
106
+ * until the person approves it in the browser, and returns the new token.
107
+ * `onCode` receives the code and the page to open.
108
+ */
109
+ export async function signInWithBrowser(serverUrl, deviceName, { onCode, signal, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) {
110
+ const server = normalizeServerUrl(serverUrl);
111
+ const started = await send(server, "POST", "/device/start", { body: { name: deviceName } });
112
+ await onCode?.({ userCode: started.userCode, verificationUri: started.verificationUri, verificationUriComplete: started.verificationUriComplete, expiresIn: started.expiresIn });
113
+ let interval = Math.max(1, Number(started.interval) || 3) * 1000;
114
+ const deadline = Date.now() + Number(started.expiresIn ?? 600) * 1000;
115
+ while (Date.now() < deadline) {
116
+ if (signal?.aborted) throw new Error("Sign-in canceled.");
117
+ await sleep(interval);
118
+ try {
119
+ const result = await send(server, "POST", "/device/token", { body: { deviceCode: started.deviceCode } });
120
+ if (result.status === "approved" && result.token) return { serverUrl: server, token: result.token, name: result.name, expiresAt: result.expiresAt };
121
+ if (result.status === "slow_down") interval += 2000;
122
+ } catch (error) {
123
+ if (error?.status === 403) throw new Error("The sign-in was denied in the browser.");
124
+ if (error?.status === 410) throw new Error("The sign-in request expired. Start again.");
125
+ if (error?.status !== 0) throw error;
126
+ // A dropped connection while waiting: keep waiting until the request expires.
127
+ }
128
+ }
129
+ throw new Error("The sign-in request expired before it was approved. Start again.");
130
+ }
131
+
132
+ export class TapeoClient {
133
+ constructor(serverUrl, token) {
134
+ this.serverUrl = normalizeServerUrl(serverUrl);
135
+ if (!/^tapeo_[A-Za-z0-9_-]{43}$/.test(token)) throw new Error("Use a project token from your Tapeo account page.");
136
+ this.token = token;
137
+ }
138
+
139
+ request(method, path, body) { return send(this.serverUrl, method, path, { token: this.token, body }); }
140
+ /** One file as bytes (text or binary), with its SHA-256 and origin. */
141
+ async rawFile(id, path) {
142
+ const response = await send(this.serverUrl, "GET", `/projects/${encodeURIComponent(id)}/files/raw?path=${encodeURIComponent(path)}`, { token: this.token, raw: true });
143
+ return { bytes: Buffer.from(await response.arrayBuffer()), sha256: response.headers.get("x-tapeo-sha256"), origin: response.headers.get("x-tapeo-origin") };
144
+ }
145
+
146
+ /**
147
+ * A streamed AI answer. `onText` receives the answer's message as it is
148
+ * written. AI answers can take a minute or more, so the wait is longer.
149
+ */
150
+ async stream(path, body, onText, incompleteMessage) {
151
+ let response;
152
+ try {
153
+ response = await fetch(new URL(`/api/client/v1${path}`, this.serverUrl), {
154
+ method: "POST",
155
+ headers: { "X-Tapeo-Client": clientIdentity, Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
156
+ body: JSON.stringify(body),
157
+ redirect: "error",
158
+ signal: AbortSignal.timeout(300_000)
159
+ });
160
+ } catch (error) {
161
+ throw new TapeoApiError(error?.name === "TimeoutError" ? "The AI answer did not finish within 5 minutes. Try again." : `Could not reach the server. Check ${this.serverUrl} and retry.`, 0);
162
+ }
163
+ if (!response.ok || !response.body) {
164
+ const data = await response.json().catch(() => ({}));
165
+ if (response.status === 401) throw new TapeoApiError("Your sign-in expired or was revoked. Run `tapeo login` (or Tapeo: Connect to Server) again.", 401, data);
166
+ throw new TapeoApiError(data.error ?? `Tapeo returned HTTP ${response.status}.`, response.status, data);
167
+ }
168
+ return readAnswerStream(response.body, (raw) => onText?.(partialJsonStringField(raw, "message")), incompleteMessage);
169
+ }
170
+
171
+ /** Ask the AI Engineer about the saved project; the answer may propose file changes. */
172
+ askEngineer(id, message, onText) { return this.stream(`/projects/${encodeURIComponent(id)}/engineer`, { message, stream: true }, onText, "The Engineer's answer was incomplete. Please try again."); }
173
+ /** The project's Engineer conversation, shared with the browser. */
174
+ engineerHistory(id) { return this.request("GET", `/projects/${encodeURIComponent(id)}/engineer`); }
175
+ /** One Architect turn for a new design. `messages` is the whole conversation so far. */
176
+ architect(messages, { designFiles, focusOption } = {}, onText) { return this.stream("/architect", { messages, ...(designFiles?.length ? { designFiles } : {}), ...(focusOption ? { focusOption } : {}) }, onText, "The Architect's answer was incomplete. Please try again."); }
177
+ /** The stages a build of this design will run. */
178
+ designPlan(requirements, uploadedPaths = []) { return this.request("POST", "/design/plan", { requirements, uploadedPaths }); }
179
+ /** Create a project from a chosen design; with `build`, also start its first run (one build). */
180
+ createFromDesign({ requirements, architecture, designFiles = [], build = false }) { return this.request("POST", "/projects", { design: { requirements, architecture, designFiles }, build }); }
181
+
182
+ projects() { return this.request("GET", "/projects"); }
183
+ createProject(name, description = "", hardwareType = "Undecided") { return this.request("POST", "/projects", { name, description, hardwareType }); }
184
+ project(id) { return this.request("GET", `/projects/${encodeURIComponent(id)}`); }
185
+ /** Delete a project on the server for good (files, runs, history). */
186
+ async deleteProject(id) {
187
+ try {
188
+ return await this.request("DELETE", `/projects/${encodeURIComponent(id)}`);
189
+ } catch (error) {
190
+ // Servers older than this client have no delete route for local clients.
191
+ if (error?.status === 405) throw new TapeoApiError(`This Tapeo server cannot delete projects from the terminal yet. Delete it in the browser: open ${this.serverUrl}, then use the delete button next to the project in your project list.`, 405);
192
+ throw error;
193
+ }
194
+ }
195
+ file(id, path) { return this.request("GET", `/projects/${encodeURIComponent(id)}/files?path=${encodeURIComponent(path)}`); }
196
+ saveFile(id, path, content, baseSha256, language) { return this.request("PUT", `/projects/${encodeURIComponent(id)}/files?path=${encodeURIComponent(path)}`, { content, baseSha256, language }); }
197
+ deleteFile(id, path, baseSha256) { return this.request("DELETE", `/projects/${encodeURIComponent(id)}/files?path=${encodeURIComponent(path)}&baseSha256=${encodeURIComponent(baseSha256)}`); }
198
+ runs(id) { return this.request("GET", `/projects/${encodeURIComponent(id)}/runs`); }
199
+ runDetail(id, runId) { return this.request("GET", `/projects/${encodeURIComponent(id)}/runs/${encodeURIComponent(runId)}`); }
200
+ cancelRun(id, runId) { return this.request("POST", `/projects/${encodeURIComponent(id)}/runs/${encodeURIComponent(runId)}/cancel`, {}); }
201
+ toolRunLog(id, toolRunId) { return this.request("GET", `/projects/${encodeURIComponent(id)}/tool-runs/${encodeURIComponent(toolRunId)}`); }
202
+ goals(id) { return this.request("GET", `/projects/${encodeURIComponent(id)}/goals`); }
203
+ // Local clients run the files the user just pushed. The browser's first-run
204
+ // generation path remains a separate, explicit action.
205
+ run(id, scope) { return this.request("POST", `/projects/${encodeURIComponent(id)}/runs`, { reuseCurrentFiles: true, ...(scope ? { scope } : {}) }); }
206
+
207
+ /** The newest run, or a named one. */
208
+ async latestRunId(id, runId) {
209
+ if (runId) return runId;
210
+ const { runs } = await this.runs(id);
211
+ if (!runs.length) throw new Error("This project has no workflow runs yet. Start one with `tapeo run`.");
212
+ return runs[0].id;
213
+ }
214
+ }
215
+
216
+ /** A stage range for `run --stage` (one stage) or `run --from` (that stage and every later one). */
217
+ export function stageScope({ stage, from, aiRepairs = true }) {
218
+ const key = stage ?? from;
219
+ if (!key) return undefined;
220
+ if (!WORKFLOW_STAGE_KEYS.includes(key)) throw new Error(`Unknown stage "${key}". Use one of: ${WORKFLOW_STAGE_KEYS.join(", ")}.`);
221
+ return { from: key, to: stage ? key : "lvs", aiRepairs };
222
+ }
223
+
224
+ /**
225
+ * Follow a run until it ends. `onChange` receives the detail whenever the
226
+ * status, current stage, or finished stages change. Returns the final detail.
227
+ */
228
+ export async function watchRun(client, projectId, runId, { onChange, intervalMs = 3000, signal, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) {
229
+ let previous = "";
230
+ for (;;) {
231
+ if (signal?.aborted) throw new Error("Stopped following the run.");
232
+ const detail = await client.runDetail(projectId, runId);
233
+ const fingerprint = JSON.stringify([detail.run.status, detail.run.currentStage, detail.run.details?.stages?.length ?? 0, detail.toolRuns?.length ?? 0]);
234
+ if (fingerprint !== previous) { previous = fingerprint; await onChange?.(detail); }
235
+ if (!ACTIVE_RUN_STATUSES.includes(detail.run.status)) return detail;
236
+ await sleep(intervalMs);
237
+ }
238
+ }
@@ -0,0 +1,72 @@
1
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { localTarget } from "./sync.mjs";
5
+
6
+ /**
7
+ * Programming an FPGA board from the local clients (tapeo flash, and Tapeo:
8
+ * Program FPGA Board in VS Code). The bitstream is built on the server; this
9
+ * downloads it and runs the open-source openFPGALoader on your computer.
10
+ * Board names mirror lib/fpga-boards.ts (tests/client-fpga.test.ts keeps them in sync).
11
+ */
12
+ export const FPGA_PROGRAMMERS = {
13
+ tangnano9k: { label: "Sipeed Tang Nano 9K", openFpgaLoaderBoard: "tangnano9k" },
14
+ tangnano20k: { label: "Sipeed Tang Nano 20K", openFpgaLoaderBoard: "tangnano20k" },
15
+ icebreaker: { label: "1BitSquared iCEBreaker", openFpgaLoaderBoard: "ice40_generic" },
16
+ ulx3s12f: { label: "Radiona ULX3S (12F)", openFpgaLoaderBoard: "ulx3s" },
17
+ ulx3s85f: { label: "Radiona ULX3S (85F)", openFpgaLoaderBoard: "ulx3s" }
18
+ };
19
+
20
+ export const OPENFPGALOADER_INSTALL = [
21
+ "openFPGALoader is needed to program the board. Install it, then run this again:",
22
+ " macOS: brew install openfpgaloader",
23
+ " Debian/Ubuntu: sudo apt install openfpgaloader",
24
+ " Windows/other: it is included in OSS CAD Suite (https://github.com/YosysHQ/oss-cad-suite-build)"
25
+ ].join("\n");
26
+
27
+ /** The bitstreams a project has, newest builds per board (paths from the server manifest). */
28
+ export function findBitstreams(files) {
29
+ return files.flatMap((file) => {
30
+ const match = /^generated\/fpga\/([a-z0-9]+)\/board_top\.(fs|bin|bit)$/.exec(file.path);
31
+ return match && FPGA_PROGRAMMERS[match[1]] ? [{ path: file.path, boardId: match[1], ...FPGA_PROGRAMMERS[match[1]] }] : [];
32
+ });
33
+ }
34
+
35
+ /** Pick the board to program: the only one built, or the one named with --board. */
36
+ export function chooseBitstream(files, boardId) {
37
+ const bitstreams = findBitstreams(files);
38
+ if (!bitstreams.length) throw new Error("This project has no bitstream yet. Run the workflow first (tapeo run --watch); FPGA projects build one for their board.");
39
+ if (boardId) {
40
+ const chosen = bitstreams.find((item) => item.boardId === boardId);
41
+ if (!chosen) throw new Error(`No bitstream for ${boardId}. Built for: ${bitstreams.map((item) => item.boardId).join(", ")}.`);
42
+ return chosen;
43
+ }
44
+ if (bitstreams.length > 1) throw new Error(`Bitstreams exist for several boards (${bitstreams.map((item) => item.boardId).join(", ")}). Name one with --board.`);
45
+ return bitstreams[0];
46
+ }
47
+
48
+ /** Download the bitstream into the project folder (read-only, like other tool outputs). */
49
+ export async function downloadBitstream(client, projectId, bitstream, directory) {
50
+ const { bytes } = await client.rawFile(projectId, bitstream.path);
51
+ const target = await localTarget(directory, bitstream.path);
52
+ await mkdir(path.dirname(target), { recursive: true });
53
+ await chmod(target, 0o644).catch(() => undefined);
54
+ await writeFile(target, bytes);
55
+ await chmod(target, 0o444).catch(() => undefined);
56
+ return target;
57
+ }
58
+
59
+ /** The programmer to run; TAPEO_OPENFPGALOADER overrides the executable (for example a full path). */
60
+ export function openFpgaLoaderExecutable() {
61
+ return process.env.TAPEO_OPENFPGALOADER || "openFPGALoader";
62
+ }
63
+
64
+ export function openFpgaLoaderAvailable(executable = openFpgaLoaderExecutable()) {
65
+ const result = spawnSync(executable, ["-V"], { encoding: "utf8" });
66
+ return !result.error && result.status === 0;
67
+ }
68
+
69
+ /** openFPGALoader arguments: SRAM by default (lost at power-off), flash with `flash: true`. */
70
+ export function flashArguments(bitstream, localPath, { flash = false } = {}) {
71
+ return ["-b", bitstream.openFpgaLoaderBoard, ...(flash ? ["--write-flash"] : []), localPath];
72
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * File and line problems in tool logs, for the editor's Problems panel and
3
+ * `tapeo problems`. Recognizes the location formats of the tools Tapeo runs:
4
+ *
5
+ * Icarus Verilog rtl/a.sv:12: error: Unknown module type: foo
6
+ * Verilator %Error: rtl/a.sv:12:5: message %Warning-WIDTH: rtl/a.sv:12:5: ...
7
+ * Yosys rtl/a.sv:12: ERROR: syntax error ... at rtl/a.sv:12.3-12.10
8
+ * OpenROAD/STA Error: analysis/flow.tcl, 42 ... [ERROR STA-0123] analysis/x.tcl line 42, ...
9
+ *
10
+ * Paths in logs may be absolute (the tool's workspace); they are matched to
11
+ * project files by their ending. Lines without a project file are left out.
12
+ */
13
+ const PATTERNS = [
14
+ // Verilator: %Error: path:line:col: message (also %Warning-CODE)
15
+ { regex: /%(Error|Warning)(?:-[A-Z0-9_]+)?:\s*([^\s:]+):(\d+):(?:(\d+):)?\s*(.*)$/, map: (m) => ({ severity: m[1], path: m[2], line: m[3], column: m[4], message: m[5] }) },
16
+ // path:line[:col]: [error|warning|ERROR|syntax error]: message (Icarus, Yosys, GCC-style)
17
+ { regex: /(?:^|\s)([^\s:'"()]+\.[A-Za-z0-9]+):(\d+)(?::(\d+))?:\s*(?:(error|warning|ERROR|WARNING|Error|Warning)\b:?\s*)?(.*)$/, map: (m) => ({ severity: m[4] ?? (/warn/i.test(m[5]) ? "warning" : "error"), path: m[1], line: m[2], column: m[3], message: m[5] }) },
18
+ // Yosys location at the end: "... at rtl/a.sv:12.3-12.10"
19
+ { regex: /^(?:(ERROR|Warning|WARNING)[:\s]+)?(.*?)\s+at\s+([^\s:]+\.[A-Za-z0-9]+):(\d+)(?:\.(\d+))?/, map: (m) => ({ severity: m[1] ?? "error", path: m[3], line: m[4], column: m[5], message: m[2] }) },
20
+ // Tcl scripts: "Error: flow.tcl, 42 message" or "[ERROR STA-0123] flow.tcl line 42, message"
21
+ { regex: /(Error|ERROR|Warning|WARNING)[^\n]*?([^\s,:'"]+\.tcl)(?:,\s*|\s+line\s+)(\d+)[,:]?\s*(.*)$/, map: (m) => ({ severity: m[1], path: m[2], line: m[3], message: m[4] }) }
22
+ ];
23
+
24
+ /** The project path a log path refers to (exact, or the longest project path it ends with). */
25
+ export function matchProjectPath(logPath, projectPaths) {
26
+ const clean = logPath.replace(/^\.\//, "").replaceAll("\\", "/");
27
+ if (projectPaths.includes(clean)) return clean;
28
+ let best = null;
29
+ for (const candidate of projectPaths) if (clean.endsWith(`/${candidate}`) && (!best || candidate.length > best.length)) best = candidate;
30
+ if (best) return best;
31
+ // A bare file name (tools often print only the script name): only when exactly one project file has it.
32
+ const byName = projectPaths.filter((candidate) => candidate.endsWith(`/${clean}`));
33
+ return byName.length === 1 ? byName[0] : null;
34
+ }
35
+
36
+ /** Problems in one log. `tool` labels them; `includeWarnings` keeps warnings (failed runs only, by default). */
37
+ export function extractProblems(text, projectPaths, { tool = "", includeWarnings = true } = {}) {
38
+ const problems = [];
39
+ const seen = new Set();
40
+ for (const rawLine of String(text ?? "").split(/\r?\n/)) {
41
+ const line = rawLine.trim();
42
+ if (!line) continue;
43
+ for (const pattern of PATTERNS) {
44
+ const match = pattern.regex.exec(line);
45
+ if (!match) continue;
46
+ const found = pattern.map(match);
47
+ const path = matchProjectPath(found.path, projectPaths);
48
+ if (!path) continue;
49
+ const severity = /warn/i.test(found.severity) ? "warning" : "error";
50
+ if (severity === "warning" && !includeWarnings) break;
51
+ const message = (found.message || line).trim().replace(/^:\s*/, "") || line;
52
+ const problem = { path, line: Math.max(1, Number(found.line)), ...(found.column ? { column: Math.max(1, Number(found.column)) } : {}), severity, message: message.slice(0, 500), ...(tool ? { tool } : {}) };
53
+ const key = `${problem.path}:${problem.line}:${problem.message}`;
54
+ if (!seen.has(key)) { seen.add(key); problems.push(problem); }
55
+ break;
56
+ }
57
+ if (problems.length >= 200) break;
58
+ }
59
+ return problems;
60
+ }
61
+
62
+ /**
63
+ * Problems from a run's tool logs: every tool run's errors, and warnings
64
+ * from the tool runs that failed. Returns them grouped by file order.
65
+ */
66
+ export async function collectRunProblems(client, projectId, runId, projectPaths) {
67
+ const { toolRuns = [] } = await client.runDetail(projectId, runId);
68
+ const problems = [];
69
+ for (const tool of toolRuns.slice(-12)) {
70
+ const failed = !/^(passed|completed|success)/i.test(tool.status ?? "");
71
+ const { toolRun } = await client.toolRunLog(projectId, tool.id);
72
+ problems.push(...extractProblems(`${toolRun.summary ?? ""}\n${toolRun.stdout ?? ""}\n${toolRun.stderr ?? ""}`, projectPaths, { tool: toolRun.tool, includeWarnings: failed }));
73
+ }
74
+ const seen = new Set();
75
+ return problems.filter((problem) => { const key = `${problem.path}:${problem.line}:${problem.message}`; if (seen.has(key)) return false; seen.add(key); return true; })
76
+ .sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line);
77
+ }
78
+
79
+ /** `path:line:col: severity: message [tool]`, the form editors and terminals link. */
80
+ export function formatProblem(problem) {
81
+ return `${problem.path}:${problem.line}${problem.column ? `:${problem.column}` : ""}: ${problem.severity}: ${problem.message}${problem.tool ? ` [${problem.tool}]` : ""}`;
82
+ }
@@ -0,0 +1,112 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { localTarget, readState, safeSourcePath } from "./sync.mjs";
4
+
5
+ /**
6
+ * File changes the AI Engineer proposed, kept in `.tapeo/proposals.json` so
7
+ * the terminal and VS Code can review and apply them, then push. Applying
8
+ * follows the browser's rules (components/ProjectWorkspace.tsx): a new file
9
+ * must not exist yet, and a replacement's original code must occur exactly
10
+ * once in the file.
11
+ */
12
+ const PROPOSALS_FILE = ".tapeo/proposals.json";
13
+
14
+ /** The file content after one change, or null when the change deletes the file. Throws when it cannot apply. */
15
+ export function applyChangeToText(current, change) {
16
+ if (change.action === "create") {
17
+ if (current !== null) throw new Error(`${change.file} already exists.`);
18
+ return change.newCode;
19
+ }
20
+ if (current === null) throw new Error(`${change.file} was not found.`);
21
+ if (change.action === "delete") return null;
22
+ const matches = current.split(change.oldCode).length - 1;
23
+ if (matches === 0) throw new Error(`The expected code was not found in ${change.file}. The file may have changed since the Engineer suggested this.`);
24
+ if (matches > 1) throw new Error(`The expected code appears ${matches} times in ${change.file}, so the change is ambiguous.`);
25
+ // A function replacement keeps "$" sequences in code literal; an empty newCode removes the text.
26
+ return current.replace(change.oldCode, () => change.newCode);
27
+ }
28
+
29
+ /** The change as removed (-) and added (+) lines, for review. */
30
+ export function changeDiff(change) {
31
+ const lines = (text) => String(text ?? "").replace(/\n$/, "").split("\n");
32
+ if (change.action === "create") return lines(change.newCode).map((line) => `+ ${line}`).join("\n");
33
+ if (change.action === "delete") return "- (the whole file is deleted)";
34
+ return [...lines(change.oldCode).map((line) => `- ${line}`), ...(change.newCode ? lines(change.newCode).map((line) => `+ ${line}`) : [])].join("\n");
35
+ }
36
+
37
+ /** One-line description of a change. */
38
+ export function describeChange(change, index) {
39
+ const verb = change.action === "create" ? "create" : change.action === "delete" ? "delete" : "edit";
40
+ const status = change.status && change.status !== "pending" ? ` [${change.status}${change.error ? `: ${change.error}` : ""}]` : "";
41
+ return `${index + 1}. ${verb} ${change.file}: ${change.reason}${status}`;
42
+ }
43
+
44
+ export async function loadProposals(directory) {
45
+ try { return JSON.parse(await readFile(await localTarget(directory, PROPOSALS_FILE), "utf8")); }
46
+ catch (error) { if (error?.code === "ENOENT") return null; throw error; }
47
+ }
48
+
49
+ async function writeProposals(directory, proposals) {
50
+ const destination = await localTarget(directory, PROPOSALS_FILE);
51
+ await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
52
+ await writeFile(destination, JSON.stringify(proposals, null, 2) + "\n", { mode: 0o600 });
53
+ }
54
+
55
+ /** Keep the Engineer's latest answer and its changes for review (replaces earlier unapplied ones). */
56
+ export async function saveProposals(directory, { question, result }) {
57
+ await readState(directory);
58
+ const changes = (result.proposedChanges ?? []).map((change) => ({ ...change, status: safeSourcePath(change.file) ? "pending" : "refused", ...(safeSourcePath(change.file) ? {} : { error: "Tool output and unsafe paths cannot be changed locally." }) }));
59
+ const proposals = { version: 1, createdAt: new Date().toISOString(), question, message: result.message, changes, evaluationPlanProposed: Boolean(result.proposedEvaluationPlan) };
60
+ await writeProposals(directory, proposals);
61
+ return proposals;
62
+ }
63
+
64
+ /** The file's current local text, or null when it does not exist. */
65
+ async function localText(directory, projectPath) {
66
+ try { return await readFile(await localTarget(directory, projectPath), "utf8"); }
67
+ catch (error) { if (error?.code === "ENOENT") return null; throw error; }
68
+ }
69
+
70
+ /** What a pending change would make the file look like (for a side-by-side review). */
71
+ export async function proposedContent(directory, change) {
72
+ const current = await localText(directory, change.file);
73
+ return { current, proposed: applyChangeToText(current, change) };
74
+ }
75
+
76
+ /**
77
+ * Apply pending changes (1-based numbers, or "all") to the local files.
78
+ * Each change applies on its own; one that no longer fits is marked failed
79
+ * with the reason and the rest still apply. Push afterwards to save them.
80
+ */
81
+ export async function applyProposals(directory, which = "all") {
82
+ const proposals = await loadProposals(directory);
83
+ if (!proposals?.changes?.length) throw new Error("There are no proposed changes. Ask the Engineer first.");
84
+ const wanted = which === "all" ? proposals.changes.map((_, index) => index) : [].concat(which).map((number) => Number(number) - 1);
85
+ const results = [];
86
+ for (const index of wanted) {
87
+ const change = proposals.changes[index];
88
+ if (!change) { results.push({ index, ok: false, message: `There is no change ${index + 1}.` }); continue; }
89
+ // A failed change can be retried (after the file is fixed); applied and refused ones cannot.
90
+ if (change.status === "applied" || change.status === "refused") { results.push({ index, file: change.file, ok: false, message: `Change ${index + 1} is ${change.status}.` }); continue; }
91
+ try {
92
+ const target = await localTarget(directory, change.file);
93
+ const next = applyChangeToText(await localText(directory, change.file), change);
94
+ if (next === null) await rm(target, { force: true });
95
+ else { await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, next, "utf8"); }
96
+ change.status = "applied";
97
+ delete change.error;
98
+ results.push({ index, file: change.file, ok: true, deleted: next === null });
99
+ } catch (error) {
100
+ change.status = "failed";
101
+ change.error = error.message;
102
+ results.push({ index, file: change.file, ok: false, message: error.message });
103
+ }
104
+ }
105
+ await writeProposals(directory, proposals);
106
+ return results;
107
+ }
108
+
109
+ /** Forget the proposed changes without applying them. */
110
+ export async function discardProposals(directory) {
111
+ await rm(await localTarget(directory, PROPOSALS_FILE), { force: true });
112
+ }
@@ -0,0 +1,77 @@
1
+ import { closeSync, constants, openSync, readdirSync, writeSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import tty from "node:tty";
4
+
5
+ /**
6
+ * A serial monitor for a connected FPGA board (tapeo monitor, and Tapeo: Open
7
+ * Serial Monitor in VS Code), without native modules: the port is opened as a
8
+ * file, configured with the system's stty on that open descriptor, and read as
9
+ * a terminal stream. macOS and Linux only; on Windows, use the browser's serial
10
+ * monitor on the Workflow tab (Chrome or Edge).
11
+ */
12
+
13
+ export const DEFAULT_BAUD = 115200;
14
+ export const SERIAL_UNSUPPORTED = "The serial monitor runs on macOS and Linux. On Windows, open the project's Workflow tab in Chrome or Edge and use its Serial monitor.";
15
+
16
+ /** USB serial ports that may be a board, newest naming first. macOS uses the cu.* (call-out) devices, which do not wait for a carrier. */
17
+ export function serialCandidates(platform = process.platform, names = safeList("/dev")) {
18
+ const pattern = platform === "darwin" ? /^cu\.(?:usbserial|usbmodem|wchusbserial|SLAB_USBtoUART)/ : platform === "linux" ? /^tty(?:USB|ACM)\d+$/ : null;
19
+ if (!pattern) return [];
20
+ return names.filter((name) => pattern.test(name)).sort().map((name) => `/dev/${name}`);
21
+ }
22
+
23
+ function safeList(directory) {
24
+ try { return readdirSync(directory); } catch { return []; }
25
+ }
26
+
27
+ /** The port to open: the one named, the only one found, or an explanation. */
28
+ export function chooseSerialPort(candidates, requested) {
29
+ if (requested) return requested;
30
+ if (!candidates.length) throw new Error("No USB serial port was found. Connect the board by USB, or name the port with --port.");
31
+ if (candidates.length > 1) throw new Error(`Several serial ports were found:\n${candidates.map((item) => ` ${item}`).join("\n")}\nName one with --port. A Tang Nano shows two; its serial link is usually the second.`);
32
+ return candidates[0];
33
+ }
34
+
35
+ export function parseBaud(value) {
36
+ if (value === undefined || value === true) return DEFAULT_BAUD;
37
+ const baud = Number(value);
38
+ if (!Number.isInteger(baud) || baud < 300 || baud > 4_000_000) throw new Error(`--baud must be a whole number from 300 to 4000000 (got ${value}).`);
39
+ return baud;
40
+ }
41
+
42
+ /** stty settings for 8N1 raw data at `baud`, no flow control, ignoring modem lines. */
43
+ export function sttyArguments(baud) {
44
+ return [String(baud), "raw", "-echo", "cs8", "-cstopb", "-parenb", "clocal", "-crtscts", "-ixon", "-ixoff"];
45
+ }
46
+
47
+ /**
48
+ * Open `portPath` at `baud`. Returns the incoming stream, a write function, and close.
49
+ * The settings are applied while the port is open, so the driver keeps them.
50
+ */
51
+ export function openSerialPort(portPath, baud = DEFAULT_BAUD, platform = process.platform) {
52
+ if (platform !== "darwin" && platform !== "linux") throw new Error(SERIAL_UNSUPPORTED);
53
+ let readFd;
54
+ try {
55
+ readFd = openSync(portPath, constants.O_RDWR | constants.O_NOCTTY | constants.O_NONBLOCK);
56
+ } catch (error) {
57
+ throw new Error(error?.code === "EBUSY" || error?.code === "EACCES"
58
+ ? `Could not open ${portPath}: ${error.code === "EBUSY" ? "another program is using it (close other serial monitors)" : "permission denied (on Linux, add yourself to the dialout group, then sign in again)"}.`
59
+ : `Could not open ${portPath}: ${error?.message ?? error}`);
60
+ }
61
+ if (!tty.isatty(readFd)) { closeSync(readFd); throw new Error(`${portPath} is not a serial port.`); }
62
+ const configured = spawnSync("stty", sttyArguments(baud), { stdio: [readFd, "ignore", "pipe"], encoding: "utf8" });
63
+ if (configured.error || configured.status !== 0) { closeSync(readFd); throw new Error(`Could not set ${portPath} to ${baud} baud: ${configured.stderr?.trim() || configured.error?.message || "stty failed"}.`); }
64
+ const writeFd = openSync(portPath, constants.O_WRONLY | constants.O_NOCTTY);
65
+ const input = new tty.ReadStream(readFd);
66
+ let closed = false;
67
+ return {
68
+ input,
69
+ write(data) { if (!closed) writeSync(writeFd, typeof data === "string" ? Buffer.from(data, "utf8") : data); },
70
+ close() {
71
+ if (closed) return;
72
+ closed = true;
73
+ input.destroy();
74
+ try { closeSync(writeFd); } catch { /* already closed */ }
75
+ }
76
+ };
77
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Streamed AI answers (the Architect and the Engineer): newline-delimited
3
+ * JSON with `{ type: "delta", text }` while the model writes, then one
4
+ * `{ type: "result", data }` or `{ type: "error", error }`. Mirrors
5
+ * lib/ndjson-stream.ts and lib/architect-response.ts in the web app.
6
+ */
7
+ export async function readAnswerStream(body, onText, incompleteMessage) {
8
+ const reader = body.getReader();
9
+ const decoder = new TextDecoder();
10
+ let buffered = "", raw = "", result = null;
11
+ const handle = (line) => {
12
+ if (!line.trim()) return;
13
+ const event = JSON.parse(line);
14
+ if (event.type === "delta" && event.text) { raw += event.text; onText?.(raw); }
15
+ else if (event.type === "result" && event.data !== undefined) result = event.data;
16
+ else if (event.type === "error") throw new Error(event.error || incompleteMessage);
17
+ };
18
+ for (;;) {
19
+ const { value, done } = await reader.read();
20
+ if (done) break;
21
+ buffered += decoder.decode(value, { stream: true });
22
+ const lines = buffered.split("\n");
23
+ buffered = lines.pop() ?? "";
24
+ for (const line of lines) handle(line);
25
+ }
26
+ handle(buffered + decoder.decode());
27
+ if (result === null) throw new Error(incompleteMessage);
28
+ return result;
29
+ }
30
+
31
+ /** A top-level string field ("message") of JSON still arriving, decoded so far. */
32
+ export function partialJsonStringField(raw, key) {
33
+ const match = new RegExp(`"${key}"\\s*:\\s*"`).exec(raw);
34
+ if (!match) return "";
35
+ let result = "";
36
+ for (let index = match.index + match[0].length; index < raw.length; index += 1) {
37
+ const char = raw[index];
38
+ if (char === "\"") return result;
39
+ if (char !== "\\") { result += char; continue; }
40
+ const next = raw[index + 1];
41
+ if (next === undefined) return result;
42
+ if (next === "u") {
43
+ const hex = raw.slice(index + 2, index + 6);
44
+ if (hex.length < 4) return result;
45
+ result += String.fromCharCode(Number.parseInt(hex, 16));
46
+ index += 5;
47
+ continue;
48
+ }
49
+ result += ({ n: "\n", t: "\t", r: "", b: "", f: "" })[next] ?? next;
50
+ index += 1;
51
+ }
52
+ return result;
53
+ }
54
+
55
+ /** The choice that leaves a question to the Architect (as in the browser). */
56
+ export const NOT_SURE = "Not sure — you choose";
57
+
58
+ /** The chat message answering the Architect's clarifying questions (same wording as the browser). */
59
+ export function clarificationAnswersMessage(clarifications, answers, note = "") {
60
+ const lines = clarifications.map((item, index) => `- ${item.question} → ${answers[index] ?? "Not sure — choose a sensible default and tell me what you chose"}`);
61
+ return ["My answers:", ...lines, ...(note.trim() ? ["", note.trim()] : [])].join("\n");
62
+ }