chamba 0.6.1 → 0.7.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.
Files changed (35) hide show
  1. package/README.md +13 -6
  2. package/dist/lib/agent-context.js +33 -7
  3. package/dist/lib/dockerfile-builder.js +2 -1
  4. package/dist/lib/safe-rm.js +13 -3
  5. package/package.json +3 -3
  6. package/templates/Dockerfile +20 -1
  7. package/templates/pane-apps/client/assets/specs-B1970L17.css +1 -0
  8. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +23 -0
  9. package/templates/pane-apps/client/specs/index.html +13 -0
  10. package/templates/pane-apps/server/specs.mjs +1588 -0
  11. package/templates/skills/dx-spec/SKILL.md +365 -0
  12. package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
  13. package/templates/skills/dx-spec/references/review-guide.md +173 -0
  14. package/templates/skills/dx-spec/references/spec-guide.md +125 -0
  15. package/templates/skills/dx-spec/references/stages.md +399 -0
  16. package/templates/skills/dx-spec-config/SKILL.md +313 -0
  17. package/templates/skills/dx-spec-config/references/principles-template.md +12 -0
  18. package/templates/skills/dx-spec-execute/SKILL.md +324 -0
  19. package/templates/specs.sh +106 -0
  20. package/templates/webterm/README.md +42 -6
  21. package/templates/webterm/config.js +43 -0
  22. package/templates/webterm/public/app/composer.js +4 -1
  23. package/templates/webterm/public/app/dom.js +13 -5
  24. package/templates/webterm/public/app/frames.js +7 -0
  25. package/templates/webterm/public/app/main.js +7 -1
  26. package/templates/webterm/public/app/pane-shell.js +315 -0
  27. package/templates/webterm/public/app/pane.js +58 -183
  28. package/templates/webterm/public/app/specs-host.js +222 -0
  29. package/templates/webterm/public/app/terminal.js +8 -0
  30. package/templates/webterm/public/index.html +51 -27
  31. package/templates/webterm/public/styles.css +144 -30
  32. package/templates/webterm/server.js +273 -0
  33. package/templates/webterm/specs.js +358 -0
  34. package/templates/webterm/tool-document.js +67 -0
  35. package/templates/webterm/typed-line.js +85 -0
@@ -0,0 +1,358 @@
1
+ // =========================================================================================================================================
2
+ // specs.js - The Specs tool, as webterm mounts it.
3
+ //
4
+ // The tool's own knowledge - what a root of specs holds, what one spec holds, what it writes - is a module
5
+ // built elsewhere and bundled into one file (see ../pane-apps/). This is the mount around it: the credentials
6
+ // that open it, the rules about which request may reach which part of it, and the shape of every answer.
7
+ //
8
+ // One function decides all of that. `handle()` takes a request as plain data and gives back a status, headers
9
+ // and a body, and never throws: a module that fails answers with a status, so a bug in spec reading costs the
10
+ // tab its view and nothing else. The express side is one delegation in server.js, and holds no decision.
11
+ //
12
+ // Three kinds of request reach the tool, each with its own credential, and neither credential opens anything
13
+ // but its own kind:
14
+ // - The tool's client, a document and its assets, served open by server.js like every other static file.
15
+ // It holds nothing secret and drives nothing, and its responses are CORS-readable because the client runs
16
+ // with an opaque origin and fetches its own module scripts cross-origin.
17
+ // - The data routes, gated by a Specs-scoped key the shell hands into the frame, presented as a header and
18
+ // never in a URL. These alone answer the cross-origin preflight that header needs.
19
+ // - Workspace-file loads, gated by a raw-route token that rides as a path segment, because a frame and an
20
+ // image cannot send a header.
21
+ // The master key opens none of this, and none of this opens the master key: a rendering bug inside the frame
22
+ // leaks spec reading at worst, never the key that types into terminals.
23
+ // =========================================================================================================================================
24
+
25
+ import { timingSafeEqual } from "node:crypto";
26
+ import { BadRequest, createSpecsModule, statusFor } from "../pane-apps/server/specs.mjs";
27
+
28
+ // The module maker, re-exported so the path to the built dist is written once. webterm resolves the pane apps
29
+ // as a sibling directory, which is where the image bakes them and where a checkout stages them.
30
+ export { createSpecsModule };
31
+
32
+ // The header the Specs key travels in. A header rather than a URL for the reason the pane's key is one: a
33
+ // document can read the URL it was loaded from, and workspace content renders inside this tool.
34
+ export const SPECS_KEY_HEADER = "x-specs-key";
35
+
36
+ // Everything the tool answers sits under one prefix, and the two kinds are told apart by the next segment.
37
+ export const SPECS_PREFIX = "/specs";
38
+ export const SPECS_KEYS_PATH = "/specs/keys";
39
+ // The `specs` helper's door. Behind the master key like every other helper route, and behind being a process
40
+ // inside a session as well.
41
+ export const SPECS_AGENT_PATH = "/specs/agent";
42
+ export const API_PREFIX = "/specs/api/";
43
+ export const RAW_PREFIX = "/specs/raw/";
44
+
45
+ // Where the built tool clients are served from. Open statics, one directory per tool.
46
+ export const CLIENT_PREFIX = "/pane-apps";
47
+
48
+ // Read by the frame, which has an opaque origin, so every answer it may read says so. It carries no
49
+ // credential of its own - the browser sends none without `Access-Control-Allow-Credentials`, which nothing
50
+ // here sends - so this opens a route to any origin that already holds the key the route asks for.
51
+ //
52
+ // The data routes alone. Workspace files are not readable cross-origin by anybody: a jailed document can read
53
+ // the token out of its own URL, and making the files it opens readable as well would turn one rendering bug
54
+ // into every spec in the repository, posted anywhere. A frame and an image need no permission to render.
55
+ const CORS_OPEN = { "Access-Control-Allow-Origin": "*" };
56
+
57
+ // The media types the data routes hand back as text, which is what this client reads rather than renders.
58
+ // Everything else - a page, a diagram, a stylesheet, an image - is something a browser renders for itself,
59
+ // and is loaded from the workspace-file path, where a frame and an image reach it without sending a header.
60
+ // An allowlist rather than a family, because `text/html` is in the family and is exactly the wrong side of
61
+ // this line.
62
+ const TEXTUAL = new Set(["text/markdown", "text/plain", "application/json"]);
63
+
64
+ // The jail on workspace bytes, sent whatever the media type. Unconditional on purpose: a per-type list would
65
+ // put the one control between workspace content and this origin at the mercy of an enumeration, and the next
66
+ // scriptable type would walk past it. The sandbox token set is the one /pane/page already sends.
67
+ const JAIL = {
68
+ "Content-Security-Policy": "sandbox allow-scripts allow-forms",
69
+ "X-Content-Type-Options": "nosniff",
70
+ "Cache-Control": "no-store",
71
+ };
72
+
73
+ /** One query or header value as a string. A repeated parameter parses to an array, which is not a value. */
74
+ function field(value) {
75
+ return typeof value === "string" ? value : "";
76
+ }
77
+
78
+ /** Whether a presented credential is the live one, compared in constant time. */
79
+ function holds(presented, secret) {
80
+ if (typeof presented !== "string" || presented === "") return false;
81
+ // Byte lengths, not string lengths: timingSafeEqual throws on a length mismatch, and a multi-byte
82
+ // character makes those two differ.
83
+ const shown = Buffer.from(presented);
84
+ const live = Buffer.from(secret);
85
+ if (shown.length !== live.length) return false;
86
+ return timingSafeEqual(shown, live);
87
+ }
88
+
89
+ /**
90
+ * What a caller is told a failure was.
91
+ *
92
+ * The module's own words name the path it was asked for, which is the caller's own text. Anything else came
93
+ * from below it, and those messages carry the container path the tool resolved to - which no caller needs and
94
+ * which the log is the place for.
95
+ */
96
+ function reason(error) {
97
+ const message = error instanceof Error ? error.message : String(error);
98
+ if (message.startsWith("specs:")) return message;
99
+ console.warn(`[webterm] specs: ${message}`);
100
+ return "that could not be read";
101
+ }
102
+
103
+ /** A workspace path as the URL spells it, one segment at a time. Throws for a segment that is not a path. */
104
+ function decodePath(rest) {
105
+ return rest
106
+ .split("/")
107
+ .map((segment) => decodeURIComponent(segment))
108
+ .join("/");
109
+ }
110
+
111
+ /**
112
+ * Mount the Specs module behind its credentials.
113
+ *
114
+ * `module` is what the tool knows, made by createSpecsModule and handed in the way paneStore is handed to the
115
+ * pane: this file decides who may ask, never what the answer is. `key` opens the data routes and `rawToken`
116
+ * opens the workspace files, and the two are separate so that neither one is the other's spare.
117
+ */
118
+ export function mountSpecs({ module, key, rawToken }) {
119
+ // The data set, as one table. The gate, the preflight answer and the router all read it, so they cannot
120
+ // disagree about which routes exist or which methods each one takes.
121
+ const DATA = {
122
+ dashboard: { GET: () => module.dashboard() },
123
+ entry: { GET: (query) => module.entry(field(query.path)) },
124
+ state: {
125
+ GET: (query) => module.state(field(query.entry)),
126
+ PUT: (query, body) => {
127
+ module.writeState(field(query.entry), body);
128
+ return { ok: true };
129
+ },
130
+ },
131
+ feedback: {
132
+ GET: (query) => ({ rounds: module.filed(field(query.entry)) }),
133
+ POST: (query, body) => module.feedback(field(query.entry), body),
134
+ },
135
+ file: { GET: (query) => textOf(field(query.path)) },
136
+ // The protocol, as the reader sees and answers it. What an agent posts is not here: it arrives at the
137
+ // helper's door below, which is gated by being inside a session rather than by holding this key.
138
+ stages: { GET: () => ({ stages: module.stages() }) },
139
+ protocol: { GET: (query) => module.specState(field(query.entry)) },
140
+ // A declared round or gate as it was written. Read through the tool rather than as a workspace file:
141
+ // it lives in the dot-directory, which the file route refuses on purpose.
142
+ round: { GET: (query) => module.round(field(query.entry), field(query.id)) },
143
+ intake: { POST: (_query, body) => module.intake(body) },
144
+ confirm: { POST: (query, body) => module.confirm(field(query.entry), body) },
145
+ // The user answering a gate, a round or a decision card. The agent's side of the same file is a verb
146
+ // at the helper's door, so neither caller can do the other's half.
147
+ answer: { POST: (query, body) => module.answer(field(query.entry), field(query.id), body) },
148
+ };
149
+
150
+ // The helper's verbs, which an agent reaches from inside a session. A verb that is not here is not one.
151
+ const VERBS = {
152
+ // The first call any agent makes: it answers whether there is a pane at all, and where the specs
153
+ // this pane serves live. An agent writing into a spec needs a path, and the root is a mount
154
+ // parameter, so nothing it could work out for itself.
155
+ stages: () => ({ root: module.root, stages: module.stages() }),
156
+ state: (entry) => module.specState(entry),
157
+ intake: (_entry, payload) => module.intake(payload),
158
+ propose: (entry, payload) => module.propose(entry, payload),
159
+ await: (entry, payload) => module.declare(entry, payload),
160
+ post: (entry, payload) => module.post(entry, payload),
161
+ review: (entry, payload) => module.review(entry, payload),
162
+ answers: (entry, payload) => ({ answer: module.answers(entry, field(payload?.id)) }),
163
+ };
164
+
165
+ /** One workspace file as text. Anything a page renders rather than reads is refused to this route. */
166
+ function textOf(path) {
167
+ const read = module.file(path);
168
+ const type = read.mediaType.split(";")[0].trim();
169
+ if (!TEXTUAL.has(type)) throw new BadRequest(`${path} is not read as text - load it as a workspace file`);
170
+ return { path, mediaType: read.mediaType, size: read.size, modified: read.modified, text: read.bytes.toString("utf8") };
171
+ }
172
+
173
+ /**
174
+ * The route a path names, or undefined.
175
+ *
176
+ * Through `Object.hasOwn`, because everything inherits `constructor`, `toString` and their neighbours: a
177
+ * plain lookup would answer for `/specs/api/constructor` and make a name that is not a route act like one.
178
+ */
179
+ function routeFor(path) {
180
+ const name = path.slice(API_PREFIX.length);
181
+ return Object.hasOwn(DATA, name) ? DATA[name] : undefined;
182
+ }
183
+
184
+ /** The path a workspace file is loaded from. The token is a segment because a frame sends no headers. */
185
+ const rawBase = `${RAW_PREFIX}${rawToken}`;
186
+
187
+ function refused(what) {
188
+ return { status: 403, headers: CORS_OPEN, body: { error: what } };
189
+ }
190
+
191
+ // The preflight the Specs key header needs, answered for the data routes and for nothing else. A route
192
+ // that is not in the table gets no `Access-Control-Allow-` header at all, which is what a browser reads as
193
+ // "not allowed" rather than as an answer it may act on.
194
+ function preflight(path) {
195
+ const route = routeFor(path);
196
+ if (!route) return { status: 403, headers: {}, body: { error: "not a Specs data route" } };
197
+ return {
198
+ status: 204,
199
+ headers: {
200
+ ...CORS_OPEN,
201
+ "Access-Control-Allow-Methods": Object.keys(route).join(", "),
202
+ "Access-Control-Allow-Headers": `${SPECS_KEY_HEADER}, content-type`,
203
+ "Access-Control-Max-Age": "600",
204
+ },
205
+ body: null,
206
+ };
207
+ }
208
+
209
+ function data(method, path, query, body, headers) {
210
+ const route = routeFor(path);
211
+ if (!route) return { status: 404, headers: CORS_OPEN, body: { error: "no such Specs route" } };
212
+ // The gate before the body: a caller without the key learns nothing about what a body would have done.
213
+ if (!holds(field(headers[SPECS_KEY_HEADER]), key)) {
214
+ return refused(`the Specs routes take their key in the ${SPECS_KEY_HEADER} header`);
215
+ }
216
+ const handler = route[method];
217
+ if (!handler) {
218
+ const allowed = Object.keys(route).join(", ");
219
+ return { status: 405, headers: { ...CORS_OPEN, Allow: allowed }, body: { error: `this route takes ${allowed}` } };
220
+ }
221
+ let payload = null;
222
+ if (body !== null && body !== undefined && body !== "") {
223
+ try {
224
+ payload = typeof body === "string" ? JSON.parse(body) : body;
225
+ } catch {
226
+ return { status: 400, headers: CORS_OPEN, body: { error: "the body is not JSON" } };
227
+ }
228
+ }
229
+ try {
230
+ return { status: 200, headers: CORS_OPEN, body: handler(query, payload) };
231
+ } catch (error) {
232
+ return { status: statusFor(error), headers: CORS_OPEN, body: { error: reason(error) } };
233
+ }
234
+ }
235
+
236
+ function raw(method, path, headers) {
237
+ // The jail rides on every answer this route gives, a refusal included: the rule is about the route,
238
+ // not about the happy path, and a body that says why is still workspace-shaped text.
239
+ const base = { ...JAIL };
240
+ if (method !== "GET" && method !== "HEAD") {
241
+ return { status: 405, headers: { ...base, Allow: "GET, HEAD" }, body: { error: "this route takes GET, HEAD" } };
242
+ }
243
+ // Frame and subresource only. The jail's second mechanism is the sandbox of whatever embeds the file,
244
+ // and a top-level document has no embedder - it would rest on the header alone, which is the
245
+ // one-mechanism state the content boundary forbids, and a top-level document can do more on an origin
246
+ // than a framed one can.
247
+ //
248
+ // A request that says nothing about where it is going is refused with it. Reading an absent header as
249
+ // "not a document" would hand that refusal to whoever leaves the header off, and the cost of the
250
+ // other way round is visible and small: in a browser that sends no Fetch Metadata, workspace files do
251
+ // not render.
252
+ const dest = field(headers["sec-fetch-dest"]);
253
+ if (dest === "" || dest === "document") {
254
+ return { status: 403, headers: base, body: { error: "a workspace file renders inside the tool, not as a page of its own" } };
255
+ }
256
+ const rest = path.slice(RAW_PREFIX.length);
257
+ const cut = rest.indexOf("/");
258
+ if (!holds(cut === -1 ? rest : rest.slice(0, cut), rawToken)) {
259
+ return { status: 403, headers: base, body: { error: "the workspace files take their token in the path" } };
260
+ }
261
+ let wanted = "";
262
+ try {
263
+ wanted = decodePath(cut === -1 ? "" : rest.slice(cut + 1));
264
+ } catch {
265
+ return { status: 400, headers: base, body: { error: "that path cannot be read" } };
266
+ }
267
+ try {
268
+ const file = module.file(wanted);
269
+ return { status: 200, headers: { ...base, "Content-Type": file.mediaType }, body: file.bytes };
270
+ } catch (error) {
271
+ return { status: statusFor(error), headers: base, body: { error: reason(error) } };
272
+ }
273
+ }
274
+
275
+ return {
276
+ /** The root the module resolved, so the mount can say what it is serving. */
277
+ root: module.root,
278
+ /** The path prefix a workspace file is loaded from, token included. Handed to the frame, never logged. */
279
+ rawBase,
280
+
281
+ /**
282
+ * Where a file the tool wrote sits on disk, or null.
283
+ *
284
+ * What a typed line is allowed to name. The module refuses any path that is not inside a `.specs/`,
285
+ * so a delivery can point an agent at something this tool wrote and at nothing else - not at a file
286
+ * of the workspace, and not at anything outside the served root.
287
+ */
288
+ fileFor(path) {
289
+ try {
290
+ return module.toolPath(field(path));
291
+ } catch {
292
+ return null;
293
+ }
294
+ },
295
+
296
+ /**
297
+ * Answer one verb from the `specs` helper.
298
+ *
299
+ * The other half of the coordination channel: the pane writes a file and types a line, and an agent
300
+ * moves the work through these. There is no key here on purpose - the caller is gated by being a
301
+ * process inside a session this server started, which server.js works out from the caller's pid, the
302
+ * way it does for `webpane`. A key on disk would add nothing, since everything in the container can
303
+ * read the one that is there.
304
+ */
305
+ agent({ verb, entry, payload }) {
306
+ const name = field(verb);
307
+ if (!Object.hasOwn(VERBS, name)) {
308
+ return { status: 400, body: { error: `specs: no verb named ${name || "(none)"}` } };
309
+ }
310
+ try {
311
+ return { status: 200, body: VERBS[name](field(entry), payload ?? null) };
312
+ } catch (error) {
313
+ return { status: statusFor(error), body: { error: reason(error) } };
314
+ }
315
+ },
316
+
317
+ /**
318
+ * Answer one request under the Specs prefix.
319
+ *
320
+ * Takes the request as plain data - method, path, query, headers, body - and gives back `{ status,
321
+ * headers, body }`, where the body is a Buffer for a workspace file, an object for everything else,
322
+ * and null for an answer that carries none. It never throws.
323
+ */
324
+ handle({ method, path, query, headers, body }) {
325
+ const verb = String(method || "GET").toUpperCase();
326
+ const where = String(path || "");
327
+ // The path decides first and the method second, so the workspace-file route answers everything
328
+ // under it - a preflight included, which it refuses as a method it does not take, with the jail on
329
+ // the refusal like every other answer it gives.
330
+ if (where.startsWith(RAW_PREFIX)) return raw(verb, where, headers ?? {});
331
+ if (where.startsWith(API_PREFIX)) {
332
+ if (verb === "OPTIONS") return preflight(where);
333
+ return data(verb, where, query ?? {}, body, headers ?? {});
334
+ }
335
+ return { status: 404, headers: {}, body: { error: "no such Specs route" } };
336
+ },
337
+
338
+ /**
339
+ * Whether this request may carry a body at all.
340
+ *
341
+ * Asked before a body is read, so nothing without the key reaches a parser - not the JSON one, and not
342
+ * the buffering underneath it, which would otherwise hold whatever an anonymous caller sent up to the
343
+ * body limit. The rule is the same one the data routes are gated by; it is answered earlier.
344
+ */
345
+ takesBody(method, path, headers) {
346
+ const verb = String(method || "").toUpperCase();
347
+ const where = String(path || "");
348
+ if ((verb !== "POST" && verb !== "PUT") || !where.startsWith(API_PREFIX)) return false;
349
+ if (!routeFor(where)?.[verb]) return false;
350
+ return holds(field(headers?.[SPECS_KEY_HEADER]), key);
351
+ },
352
+
353
+ /** Stop watching the root. The mount holds nothing else that outlives it. */
354
+ close() {
355
+ module.close();
356
+ },
357
+ };
358
+ }
@@ -0,0 +1,67 @@
1
+ // =========================================================================================================================================
2
+ // tool-document.js - The policy a tool's own document is served under.
3
+ //
4
+ // A tool client runs in a frame that is sandboxed without allow-same-origin, so its document has an opaque
5
+ // origin. That is what keeps a bug in spec rendering away from the key that types into terminals, and it is
6
+ // also why this document cannot be served under the policy everything else on this origin is: `'self'` names
7
+ // the document's own origin, an opaque origin is nobody, and a directive that names it matches nothing at
8
+ // all. A policy written with `'self'` in it therefore does not tighten this document - it turns its own
9
+ // scripts off.
10
+ //
11
+ // So the execution directives name no origin. What may run is named by a nonce this server puts on the two
12
+ // elements it built the document with, and the directives that decide where bytes may be fetched from name
13
+ // the origin the document was loaded from - which is this server, and which is where the tool's assets, its
14
+ // data routes and its jailed workspace files all live.
15
+ //
16
+ // Everything here is a pure function of its inputs. server.js reads the file, makes the nonce, and sends the
17
+ // answer; the rules are decided here, where a test can run them.
18
+ // =========================================================================================================================================
19
+
20
+ // Where the built tool clients are served from, and the one document each tool has. The tool name is spelled
21
+ // out rather than taken from the URL, so no path a caller writes ever reaches the filesystem through here.
22
+ const DOCUMENT_PATH = /^\/pane-apps\/([a-z0-9-]+)\/index\.html$/;
23
+
24
+ /**
25
+ * The document a URL asks for, as a path relative to the built clients directory, or null when the URL asks
26
+ * for something else - an asset, a stylesheet, a name that is not a tool.
27
+ */
28
+ export function toolDocumentFor(urlPath) {
29
+ const asked = DOCUMENT_PATH.exec(String(urlPath || ""));
30
+ return asked ? `${asked[1]}/index.html` : null;
31
+ }
32
+
33
+ /**
34
+ * The document with its nonce on it.
35
+ *
36
+ * The build emits one module script and one stylesheet link, and both have to carry the nonce, because the
37
+ * policy below names nothing else that may run or style. This is a rewrite of a file this build made, not of
38
+ * anything a workspace wrote: no content from the served root passes through here.
39
+ */
40
+ export function withNonce(html, nonce) {
41
+ return String(html).replace(/<(script|link)\b/gi, (tag) => `${tag} nonce="${nonce}"`);
42
+ }
43
+
44
+ /**
45
+ * The policy the document is sent under.
46
+ *
47
+ * `default-src 'none'` is the floor, so anything not named below is refused. What runs is named by the nonce
48
+ * alone. What is fetched is named by `origin` - the scheme and host the document was loaded from - because
49
+ * the client's own assets, the tool's data routes and the jailed workspace files are all served from here,
50
+ * and an opaque origin has no `'self'` to say that with. `base-uri` and `form-action` are named on their own,
51
+ * since neither one falls back to `default-src`.
52
+ */
53
+ export function documentPolicy(nonce, origin) {
54
+ return [
55
+ "default-src 'none'",
56
+ `script-src 'nonce-${nonce}'`,
57
+ `style-src-elem 'nonce-${nonce}'`,
58
+ `img-src ${origin}`,
59
+ `connect-src ${origin}`,
60
+ `frame-src ${origin}`,
61
+ "base-uri 'none'",
62
+ "form-action 'none'",
63
+ // The document is framed by this interface and by nothing else. Without it a page elsewhere could
64
+ // frame the same document with no sandbox and run it as an ordinary document on this origin.
65
+ "frame-ancestors 'self'",
66
+ ].join("; ");
67
+ }
@@ -0,0 +1,85 @@
1
+ // =========================================================================================================================================
2
+ // typed-line.js - What a message is reduced to before a terminal ever sees it.
3
+ //
4
+ // A tool in the pane tells the agent beside it that something arrived, by typing one line into that agent's
5
+ // terminal. The line is framed - `[specs] <event>: "<detail>" - read <path>` - and the frame is a visual
6
+ // convention, so what this file protects is how the line reads. An agent must be able to tell the server's
7
+ // own words from a message's, and no message may pose as a second voice.
8
+ //
9
+ // The text rides a bracketed paste, where a newline is not a second submit but a second visible line inside
10
+ // the same prompt. So a text carrying one would show the agent a line below the framed one, in no frame at
11
+ // all. Reduction to a single line is what closes that, and it starts from the shipped single-line cleaner:
12
+ // controls dropped, whitespace runs collapsed, ends trimmed.
13
+ //
14
+ // That cleaner needs two corrections here, and both live in this wrapper, so the pane's titles and its nudge
15
+ // keep the behaviour they have.
16
+ //
17
+ // - Its length argument defaults to a page title's length, which would cut a message to a fragment. The
18
+ // limit is checked before the reduction instead, and an over-limit message is refused with a reason.
19
+ // - Its character rule keeps every code point at or above U+00A0, which leaves every invisible one alive.
20
+ // The rule this path needs is "renders as nothing", and no single Unicode property means that, so it is
21
+ // built from what survives rather than from what does not - in two layers, and it needs both.
22
+ // =========================================================================================================================================
23
+
24
+ import { cleanLine } from "./artifacts.js";
25
+
26
+ // The first layer: the categories a visible character is in. Letters, the marks that combine with them,
27
+ // numbers, and the common punctuation and symbol categories, plus the one space the collapse produces.
28
+ // Nothing outside them survives, whatever it is and whenever it was assigned - which ends the format
29
+ // characters, the bidi controls, and the tag block at U+E0000, since all three are format characters and
30
+ // none of those is a category here. The tag block is worth naming: it is a complete invisible copy of
31
+ // printable ASCII, and it is the carrier a hidden second instruction actually travels in.
32
+ const VISIBLE = /[\p{L}\p{M}\p{N}\p{P}\p{S} ]/u;
33
+
34
+ // The second layer: what the first keeps by accident. The allowed categories hold blank-rendering members of
35
+ // their own - U+3164 HANGUL FILLER is a letter, the variation selectors are combining marks, U+2800 BRAILLE
36
+ // PATTERN BLANK is a symbol - so a category allowlist alone passes all three at full width. Every code point
37
+ // carrying Default_Ignorable_Code_Point goes whatever its category, which covers the fillers and the
38
+ // selectors. U+2800 is then named, because it carries no such property and nothing else separates it from
39
+ // the braille it belongs to. That one code point is the only closed part of the rule.
40
+ const BLANK = /[\p{Default_Ignorable_Code_Point}\u2800]/u;
41
+
42
+ // No surviving code point can be confusable with the frame's quote, so every one that reads as a double
43
+ // quote is mapped to a single one - the way the pane's nudge already does for the ASCII quote alone. The
44
+ // rule is what a code point looks like rather than what it is called, so the property is no help: the
45
+ // quotation-mark property leaves out the double primes, the gershayim, the modifier letters that render as
46
+ // a pair of ticks, and the double acute, and each of those closes the frame on screen just as well.
47
+ const DOUBLE_QUOTES =
48
+ /["\u00ab\u00bb\u02ba\u02dd\u02ee\u02f6\u05f4\u201c\u201d\u201e\u201f\u2033\u2036\u275d\u275e\u2e42\u3003\u301d\u301e\u301f\uff02]|[\u{1f676}-\u{1f678}]/gu;
49
+
50
+ /**
51
+ * One line of somebody else's text, safe to put inside the frame.
52
+ *
53
+ * A code point outside the visible categories becomes a space, which the cleaner then collapses: it was a
54
+ * control, a separator or a format character, and a space is what it was standing in for. One that renders
55
+ * as nothing inside an allowed category is removed rather than spaced, because it was padding rather than a
56
+ * gap - which is what keeps a padded tail inside the quotes instead of pushed off the row.
57
+ */
58
+ export function reduceLine(raw, max) {
59
+ if (typeof raw !== "string") return "";
60
+ let kept = "";
61
+ for (const ch of raw) {
62
+ if (BLANK.test(ch)) continue;
63
+ kept += VISIBLE.test(ch) ? ch : " ";
64
+ }
65
+ return cleanLine(kept.replace(DOUBLE_QUOTES, "'"), max);
66
+ }
67
+
68
+ /**
69
+ * The line itself.
70
+ *
71
+ * `tag` and `event` are the caller's own words and never a message's, so a tool's text sits inside
72
+ * delimiters it cannot close and cannot pose as the user or as another source. `detail` is the only part
73
+ * that arrives here already reduced.
74
+ *
75
+ * `path` is a path this server built, but it is not made of this server's words: every segment of it is a
76
+ * name off the filesystem, and a name may hold any byte but the separator. So it goes through the same
77
+ * reduction, and it is named only when the reduction changed nothing. A path that changed is not a path any
78
+ * more - naming the reduced spelling would send the agent to a file that does not exist - and a path holding
79
+ * anything the reduction removes is the one case where naming no file is the honest line.
80
+ */
81
+ export function framedLine({ tag, event, detail, path }) {
82
+ const said = detail ? `: "${detail}"` : "";
83
+ const named = path && reduceLine(path, path.length) === path ? path : "";
84
+ return `[${tag}] ${event}${said}${named ? ` - read ${named}` : ""}`;
85
+ }