@molecule/api-ai-tools 1.11.1 → 1.13.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.
@@ -0,0 +1,406 @@
1
+ /**
2
+ * The standard Playwright machine report, kept beside a run's log instead of inferred from it.
3
+ *
4
+ * The execution wrapper already puts a background suite on Playwright's `list` reporter and
5
+ * preserves a file reporter the executor named ({@link ./utilities.js} `withPlaywrightRunReport`,
6
+ * `streamingBackgroundCommand`). That log is text: which failure a trace, a route or a locator
7
+ * belongs to has to be reconstructed from printed headings, and a mis-join reads one failing
8
+ * test's evidence as another's (xr-molecule-flash-m411). Playwright already writes the answer down
9
+ * itself — its built-in `json` reporter records every test's own identity (project, file, line,
10
+ * column, title path, status, retries) and every attachment the runner saved for THAT test, path
11
+ * included.
12
+ *
13
+ * So the wrapper adds the `json` reporter to a run it already rewrites, sends its output to a
14
+ * per-run file with the reporter's own `PLAYWRIGHT_JSON_OUTPUT_FILE` option, and this module turns
15
+ * that file into a small, bounded record on the tool result ({@link PlaywrightRunReport}). The
16
+ * whole file stays where it was written, so full evidence has a path; the record carries only what
17
+ * a diagnosis needs.
18
+ *
19
+ * Honesty rules, which every reader below follows:
20
+ *
21
+ * - A missing, empty, truncated or wrongly shaped file is NOT "the run failed nothing": it is
22
+ * `missing` / `malformed`, and a record that parsed but is not a whole run (`errors` present, no
23
+ * `suites`) is `partial`. Only a record with the runner's own stats and suite tree is `complete`.
24
+ * - The record never carries a pass/fail verdict and must never be read as one. Suite selection,
25
+ * retries, budgets and the platform's own proof decisions stay exactly where they were.
26
+ * - Nothing here parses console output, and nothing reads a report the executor asked for itself
27
+ * (a `json` reporter already named on the command, or a `PLAYWRIGHT_JSON_OUTPUT_FILE` the command
28
+ * sets): the executor's own artifact is theirs.
29
+ *
30
+ * @module
31
+ */
32
+ import { posix } from 'path';
33
+ /**
34
+ * The environment variable Playwright's built-in `json` reporter takes its output path from
35
+ * (`resolveOutputFile("JSON", …)`), resolved against the runner's own working directory. An
36
+ * absolute path is used here, so the file lands in the run's scratch area whatever directory the
37
+ * suite runs from.
38
+ */
39
+ export const PLAYWRIGHT_REPORT_ENV = 'PLAYWRIGHT_JSON_OUTPUT_FILE';
40
+ /** The file name a background task's report is written to, beside its own log. */
41
+ export const PLAYWRIGHT_REPORT_SUFFIX = '.playwright.json';
42
+ /**
43
+ * How big a report may be before this module refuses to summarize it. The file stays readable at
44
+ * its path; the record only says so. A whole 139-test suite's report is well under a megabyte; a
45
+ * report past this is not one a tool result should carry.
46
+ */
47
+ export const MAX_REPORT_PARSE_CHARS = 8_000_000;
48
+ /** How many failing tests a record lists before the rest are counted in `omittedFailures`. */
49
+ export const MAX_REPORTED_FAILURES = 12;
50
+ /** How many attachments one failing test keeps in the record (`trace` first). */
51
+ export const MAX_REPORTED_ATTACHMENTS = 4;
52
+ /** How much of one failure's own error text the record keeps. */
53
+ export const MAX_REPORTED_ERROR_CHARS = 400;
54
+ /** How much of one printed path the record keeps. */
55
+ const MAX_REPORTED_PATH_CHARS = 300;
56
+ /** How much of one title the record keeps. */
57
+ const MAX_REPORTED_TITLE_CHARS = 300;
58
+ /**
59
+ * The terminal formatting Playwright bakes into an error message — `expect` diffs are written for a
60
+ * terminal, so the report's own `error.message` carries SGR sequences. Stripped here, before the
61
+ * consumer's redactor, so a result never carries `[2m`/`[22m` fragments as if they were text.
62
+ */
63
+ // eslint-disable-next-line no-control-regex -- an escape sequence IS a control character
64
+ const ANSI_SEQUENCE = /\u001B\[[0-9;?]*[A-Za-z]/g;
65
+ /** One plain record out of unknown JSON, or null when the value is not an object. */
66
+ function asRecord(value) {
67
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
68
+ ? value
69
+ : null;
70
+ }
71
+ /** A finite number from unknown JSON, or null. */
72
+ function asNumber(value) {
73
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
74
+ }
75
+ /** A non-empty string from unknown JSON, or null. */
76
+ function asString(value) {
77
+ return typeof value === 'string' && value !== '' ? value : null;
78
+ }
79
+ /** A string cut to {@link MAX_REPORTED_PATH_CHARS}. */
80
+ function boundedPath(value) {
81
+ return value.length > MAX_REPORTED_PATH_CHARS
82
+ ? `${value.slice(0, MAX_REPORTED_PATH_CHARS)}…`
83
+ : value;
84
+ }
85
+ /** A string cut to {@link MAX_REPORTED_TITLE_CHARS}. */
86
+ function boundedTitle(value) {
87
+ return value.length > MAX_REPORTED_TITLE_CHARS
88
+ ? `${value.slice(0, MAX_REPORTED_TITLE_CHARS)}…`
89
+ : value;
90
+ }
91
+ /**
92
+ * The report statuses for which the report's own per-test identities and attachments may be used.
93
+ *
94
+ * `partial` is included because a partial report's suite tree, where it parsed, is still the
95
+ * runner's own record of which test owns which attachment; `malformed` and `missing` are not,
96
+ * because nothing about them is runner data.
97
+ *
98
+ * @param status - One record's {@link PlaywrightReportStatus}.
99
+ * @returns True when the record's tests and attachments are the runner's own.
100
+ */
101
+ export function reportCarriesIdentities(status) {
102
+ return status === 'complete' || status === 'partial';
103
+ }
104
+ /**
105
+ * The attachments a stored record actually carries.
106
+ *
107
+ * A record read back from a transcript is not guaranteed to be the shape this module wrote — a
108
+ * damaged, truncated or hand-edited one can carry a null entry, a non-array field or an attachment
109
+ * without a path. Every reader goes through here so a damaged record answers "no artifact", never a
110
+ * thrown error and never another test's trace.
111
+ *
112
+ * @param test - One entry of {@link PlaywrightRunReport.failures}, as stored.
113
+ * @returns Its well-shaped attachments, in the record's own order.
114
+ */
115
+ function storedAttachments(test) {
116
+ const raw = test?.attachments;
117
+ if (!Array.isArray(raw))
118
+ return [];
119
+ const out = [];
120
+ for (const entry of raw) {
121
+ const attachment = asRecord(entry);
122
+ if (attachment === null)
123
+ continue;
124
+ const path = asString(attachment.path);
125
+ if (path === null)
126
+ continue;
127
+ out.push({
128
+ name: asString(attachment.name) ?? '',
129
+ contentType: asString(attachment.contentType),
130
+ path,
131
+ });
132
+ }
133
+ return out;
134
+ }
135
+ /**
136
+ * The attachment paths a stored test record carries, `trace` first.
137
+ *
138
+ * @param test - One entry of {@link PlaywrightRunReport.failures}, as stored.
139
+ * @returns Every saved path the record can prove, in its own order.
140
+ */
141
+ export function reportTestAttachmentPaths(test) {
142
+ return storedAttachments(test).map((attachment) => attachment.path);
143
+ }
144
+ /**
145
+ * The directory a test result's own artifacts were saved in, or null when the report names none.
146
+ *
147
+ * Playwright writes one directory per test attempt under the run's `outputDir`, and every artifact
148
+ * of that attempt — `trace.zip` included — sits in it. The directory is therefore the exact
149
+ * ownership mark for a trace: two tests' traces are never in one another's directory. A damaged
150
+ * record whose attachment entries carry no path answers null, so nothing is claimed from it.
151
+ *
152
+ * @param test - One entry of {@link PlaywrightRunReport.failures}, as stored.
153
+ * @returns The posix-normalized directory, or null.
154
+ */
155
+ export function reportTestArtifactDir(test) {
156
+ const attachment = storedAttachments(test).find((one) => one.path !== '');
157
+ return attachment === undefined ? null : posix.dirname(attachment.path);
158
+ }
159
+ /** The attachments of one attempt, `trace` first and bounded. */
160
+ function attachmentsOf(result) {
161
+ const raw = Array.isArray(result.attachments) ? result.attachments : [];
162
+ const out = [];
163
+ for (const entry of raw) {
164
+ const attachment = asRecord(entry);
165
+ if (attachment === null)
166
+ continue;
167
+ const path = asString(attachment.path);
168
+ if (path === null)
169
+ continue;
170
+ out.push({
171
+ name: asString(attachment.name) ?? '',
172
+ contentType: asString(attachment.contentType),
173
+ path: boundedPath(path),
174
+ });
175
+ }
176
+ // `trace` first: it is the artifact a failure diagnosis reads, and the `body` Playwright may
177
+ // inline is deliberately dropped — it is base64 evidence the file path already carries.
178
+ out.sort((left, right) => Number(right.name === 'trace') - Number(left.name === 'trace'));
179
+ return out.slice(0, MAX_REPORTED_ATTACHMENTS);
180
+ }
181
+ /**
182
+ * The attempt whose evidence describes the test's failure: the LAST attempt that did not pass.
183
+ *
184
+ * A retried test reports one result per attempt. A flaky test's final attempt passed, but the
185
+ * failure it is reported for happened in the earlier one, and that is where the trace was kept
186
+ * (`retain-on-failure`).
187
+ *
188
+ * @param results - One test's `results` array, in attempt order.
189
+ * @returns The chosen attempt and its index, or null when there is none.
190
+ */
191
+ function evidenceAttempt(results) {
192
+ let chosen = null;
193
+ for (let index = 0; index < results.length; index += 1) {
194
+ const result = asRecord(results[index]);
195
+ const status = result === null ? null : asString(result.status);
196
+ if (result === null || status === null)
197
+ continue;
198
+ if (status !== 'passed' && status !== 'skipped')
199
+ chosen = { result, index };
200
+ }
201
+ if (chosen !== null)
202
+ return chosen;
203
+ const last = asRecord(results[results.length - 1]);
204
+ return last === null ? null : { result: last, index: results.length - 1 };
205
+ }
206
+ /** One test's own error text, bounded and sanitized, or null. */
207
+ function errorOf(result, sanitize) {
208
+ const errors = Array.isArray(result.errors) ? result.errors : [];
209
+ const first = asRecord(errors[0]);
210
+ const message = (first === null ? null : asString(first.message)) ?? null;
211
+ const direct = asRecord(result.error);
212
+ const text = message ?? (direct === null ? null : asString(direct.message));
213
+ if (text === null)
214
+ return null;
215
+ const plain = text.replace(ANSI_SEQUENCE, '');
216
+ const bounded = plain.length > MAX_REPORTED_ERROR_CHARS ? `${plain.slice(0, MAX_REPORTED_ERROR_CHARS)}…` : plain;
217
+ return sanitize(bounded);
218
+ }
219
+ /** Whether one test entry should be listed: a failure, a retried failure, or an interruption. */
220
+ function isFailingTest(status, results) {
221
+ if (status === 'unexpected' || status === 'flaky')
222
+ return true;
223
+ return results.some((entry) => {
224
+ const result = asRecord(entry);
225
+ const resultStatus = result === null ? null : asString(result.status);
226
+ return (resultStatus === 'failed' || resultStatus === 'timedOut' || resultStatus === 'interrupted');
227
+ });
228
+ }
229
+ /**
230
+ * Every test entry under one suite, with the suite titles that lead to it.
231
+ *
232
+ * @param suite - One suite of the report's tree.
233
+ * @param prefix - The titles of the suites above it.
234
+ * @param out - Where the entries collect.
235
+ * @param fileSuite - True for a top-level entry, whose title is the file path rather than a
236
+ * `describe` block: it is not part of a test's title path.
237
+ */
238
+ function specsIn(suite, prefix, out, fileSuite = false) {
239
+ const title = fileSuite ? null : asString(suite.title);
240
+ const titlePath = title === null ? [...prefix] : [...prefix, title];
241
+ const specs = Array.isArray(suite.specs) ? suite.specs : [];
242
+ for (const entry of specs) {
243
+ const spec = asRecord(entry);
244
+ if (spec !== null)
245
+ out.push({ spec, titlePath });
246
+ }
247
+ const children = Array.isArray(suite.suites) ? suite.suites : [];
248
+ for (const entry of children) {
249
+ const child = asRecord(entry);
250
+ if (child !== null)
251
+ specsIn(child, titlePath, out);
252
+ }
253
+ }
254
+ /** One record for a report this module could not parse, with the reason. */
255
+ function unusable(status, path, reason) {
256
+ return { status, path, reason, stats: null, failures: [], omittedFailures: 0 };
257
+ }
258
+ /**
259
+ * Summarize one Playwright `json` report into the bounded record a tool result carries.
260
+ *
261
+ * Pure text in, record out: the caller reads the file (and decides whether it may), so this is
262
+ * testable against genuine reports and against malformed controls alike.
263
+ *
264
+ * @param text - The report file's text, exactly as written.
265
+ * @param options - Where the file is, and the consumer's redactor for error text.
266
+ * @param options.path - The report file's path, carried on the record for full evidence.
267
+ * @param options.sanitize - Redactor applied to error text (defaults to the identity).
268
+ * @returns The bounded record; never a verdict.
269
+ */
270
+ export function summarizePlaywrightReport(text, options) {
271
+ const path = options.path;
272
+ const sanitize = options.sanitize ?? ((value) => value);
273
+ if (text.trim() === '')
274
+ return unusable('malformed', path, 'the report file is empty');
275
+ if (text.length > MAX_REPORT_PARSE_CHARS)
276
+ return unusable('partial', path, `the report is larger than ${MAX_REPORT_PARSE_CHARS} characters and was not summarized`);
277
+ let root;
278
+ try {
279
+ root = JSON.parse(text);
280
+ }
281
+ catch (_error) {
282
+ // A truncated or non-JSON file is not runner data at all: say malformed, never zero failures.
283
+ return unusable('malformed', path, 'the report file is not valid JSON (it may be truncated)');
284
+ }
285
+ const report = asRecord(root);
286
+ if (report === null)
287
+ return unusable('malformed', path, 'the report is not a JSON object');
288
+ const rawStats = asRecord(report.stats);
289
+ const expected = rawStats === null ? null : asNumber(rawStats.expected);
290
+ const skipped = rawStats === null ? null : asNumber(rawStats.skipped);
291
+ const unexpected = rawStats === null ? null : asNumber(rawStats.unexpected);
292
+ const flaky = rawStats === null ? null : asNumber(rawStats.flaky);
293
+ const durationMs = rawStats === null ? null : asNumber(rawStats.duration);
294
+ const stats = expected === null ||
295
+ skipped === null ||
296
+ unexpected === null ||
297
+ flaky === null ||
298
+ durationMs === null
299
+ ? null
300
+ : { expected, skipped, unexpected, flaky, durationMs };
301
+ const suites = Array.isArray(report.suites) ? report.suites : null;
302
+ const errors = Array.isArray(report.errors) ? report.errors.length : 0;
303
+ if (suites === null)
304
+ return unusable('partial', path, 'the report carries no suite tree (the run was cut short)');
305
+ // Every spec under every project, with the describe titles that lead to it. The report's
306
+ // top-level entries are FILE suites — their title is the file path the spec's own `file` already
307
+ // carries — so that level is not part of a test's title path.
308
+ const specs = [];
309
+ for (const entry of suites) {
310
+ const suite = asRecord(entry);
311
+ if (suite !== null)
312
+ specsIn(suite, [], specs, true);
313
+ }
314
+ const failures = [];
315
+ for (const { spec, titlePath } of specs) {
316
+ const specTitle = asString(spec.title) ?? '';
317
+ const file = asString(spec.file) ?? '';
318
+ const line = asNumber(spec.line) ?? 0;
319
+ const column = asNumber(spec.column) ?? 0;
320
+ const specId = asString(spec.id);
321
+ const tests = Array.isArray(spec.tests) ? spec.tests : [];
322
+ for (const entry of tests) {
323
+ const test = asRecord(entry);
324
+ if (test === null)
325
+ continue;
326
+ const status = asString(test.status) ?? '';
327
+ const results = Array.isArray(test.results) ? test.results : [];
328
+ if (!isFailingTest(status, results))
329
+ continue;
330
+ const evidence = evidenceAttempt(results);
331
+ const titles = [...titlePath, specTitle].filter((part) => part !== '');
332
+ failures.push({
333
+ id: specId,
334
+ project: asString(test.projectName),
335
+ file: boundedPath(file),
336
+ line,
337
+ column,
338
+ titlePath: titles.map(boundedTitle),
339
+ title: boundedTitle(specTitle),
340
+ status,
341
+ attempts: results.length,
342
+ retry: evidence === null ? 0 : (asNumber(evidence.result.retry) ?? evidence.index),
343
+ durationMs: evidence === null ? 0 : (asNumber(evidence.result.duration) ?? 0),
344
+ error: evidence === null ? null : errorOf(evidence.result, sanitize),
345
+ attachments: evidence === null ? [] : attachmentsOf(evidence.result),
346
+ });
347
+ }
348
+ }
349
+ const omittedFailures = Math.max(0, failures.length - MAX_REPORTED_FAILURES);
350
+ // The runner's tally and its own tree must agree. A record whose stats name more failures than
351
+ // the tree lists is not a whole record — a truncated write, a merged or hand-edited file — and
352
+ // must not be read as complete just because both fields parsed. The bound this module applies is
353
+ // counted in `omittedFailures`, so it is compared against the list BEFORE the cap.
354
+ const namedFailures = stats === null ? null : stats.unexpected + stats.flaky;
355
+ const treeShort = namedFailures !== null && failures.length < namedFailures;
356
+ // Playwright's own global errors mean the run did not walk everything it planned to (a config
357
+ // error, a worker crash): the per-test entries that did parse are still the runner's own.
358
+ const status = stats === null || errors > 0 || treeShort ? 'partial' : 'complete';
359
+ const reason = stats === null
360
+ ? 'the report carries no run stats'
361
+ : errors > 0
362
+ ? `the run reported ${errors} error(s) of its own`
363
+ : treeShort
364
+ ? `the run’s stats name ${namedFailures} failing test(s) but its tree lists ${failures.length}`
365
+ : undefined;
366
+ return {
367
+ status,
368
+ path,
369
+ ...(reason === undefined ? {} : { reason }),
370
+ stats,
371
+ failures: failures.slice(0, MAX_REPORTED_FAILURES),
372
+ omittedFailures,
373
+ };
374
+ }
375
+ /**
376
+ * The report path a command was launched with, when it is the one this platform sets.
377
+ *
378
+ * Read back from the launched script (and from a completed task's own command) so a record written
379
+ * before an API restart is still found: the path is the task's own id derived by
380
+ * {@link playwrightReportPathFor}, never a path parsed out of the executor's command.
381
+ *
382
+ * @param command - The command as it was launched.
383
+ * @param expectedPath - The one path this task's report may be at.
384
+ * @returns True when the command sets {@link PLAYWRIGHT_REPORT_ENV} to that exact path.
385
+ */
386
+ export function commandWritesReportTo(command, expectedPath) {
387
+ const quoted = new RegExp(`(?:^|[\\s;])${PLAYWRIGHT_REPORT_ENV}=(['"]?)${escapeRegExp(expectedPath)}\\1(?:[\\s;]|$)`);
388
+ return quoted.test(command);
389
+ }
390
+ /**
391
+ * The report file a background task's own scratch files live beside.
392
+ *
393
+ * Derived from the task id, never from the command: the id is unique per launch, so two runs can
394
+ * never read one another's report, and the path survives a process restart the same way the task's
395
+ * log and exit file do.
396
+ *
397
+ * @param taskId - The `mol-bg-…` handle the launch returned.
398
+ * @returns The report file's absolute path.
399
+ */
400
+ export function playwrightReportPathFor(taskId) {
401
+ return `/tmp/${taskId}${PLAYWRIGHT_REPORT_SUFFIX}`;
402
+ }
403
+ /** A string with every regular-expression metacharacter escaped. */
404
+ function escapeRegExp(value) {
405
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
406
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAElD;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,UAAU,CAAA;CACvB;AAED;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CA8V1C,CAAA"}
1
+ {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAElD;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,UAAU,CAAA;CACvB;AAED;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAwW1C,CAAA"}
package/dist/schemas.js CHANGED
@@ -71,6 +71,10 @@ export const TOOL_SCHEMAS = {
71
71
  parameters: {
72
72
  type: 'object',
73
73
  properties: {
74
+ intentional_redaction_placeholder: {
75
+ type: 'boolean',
76
+ description: 'Set true only when intentionally authoring a literal [REDACTED] placeholder. Default false: newly copied output placeholders are refused. This never disables secret-output masking.',
77
+ },
74
78
  path: {
75
79
  type: 'string',
76
80
  description: 'Path to the file to write.',
@@ -89,6 +93,10 @@ export const TOOL_SCHEMAS = {
89
93
  parameters: {
90
94
  type: 'object',
91
95
  properties: {
96
+ intentional_redaction_placeholder: {
97
+ type: 'boolean',
98
+ description: 'Set true only when intentionally authoring a literal [REDACTED] placeholder. Default false: newly copied output placeholders are refused. This never disables secret-output masking.',
99
+ },
92
100
  path: {
93
101
  type: 'string',
94
102
  description: 'Path to the file to edit.',
@@ -26,14 +26,18 @@ export declare const BACKGROUNDED: RegExp;
26
26
  * A program that SERVES: the scaffold's entry (`server.ts`/`server.js`), a package manager's
27
27
  * `start`/`dev`/`serve`/`preview` script, a Vite dev or preview server, `next start|dev`, or a
28
28
  * Python WSGI server. Matched against a command segment's own text, and against a script's SOURCE
29
- * when the command only names the script file.
29
+ * when the command only names the script file — but NEVER against an inline program's code
30
+ * (`node -e …`, `python3 -c …`), where this text is the user's program rather than a file being
31
+ * run: `require('…/node_modules/vite')` imports a module, it does not start a dev server
32
+ * (xr-molecule-flash-m391 +13:13, refused as "node starts a server" for exactly that line).
30
33
  */
31
34
  export declare const SERVER_PROGRAM: RegExp;
32
35
  /**
33
36
  * Whether a command, read from its TEXT alone, starts a server that stays up in the foreground.
34
37
  *
35
38
  * The over-budget reader's shape: at that point the command has already run, and only its text is
36
- * left to say whether "run a smaller unit" was ever the right advice.
39
+ * left to say whether "run a smaller unit" was ever the right advice. Inline/stdin programs
40
+ * remain unclassified and use the normal bounded command runner.
37
41
  *
38
42
  * @param command - The command as the executor sent it.
39
43
  * @returns True when a foreground statement of the command starts a server.
@@ -46,7 +50,9 @@ export interface ForegroundServer {
46
50
  }
47
51
  /**
48
52
  * A FOREGROUND command that starts a server, including one that only names a script whose SOURCE
49
- * imports the app's server and calls its `create()`/`listen()`.
53
+ * imports the app's server and calls its `create()`/`listen()`. Inline/stdin programs
54
+ * (`node -e …`, `python3 -c …`) use normal bounded execution: interpreting arbitrary
55
+ * program text is outside this filename/script preflight's contract.
50
56
  *
51
57
  * The script read is best-effort: a path the backend will not answer for is a command this reading
52
58
  * cannot classify, never a refusal. A backgrounded launch, a test runner and a test file are all
@@ -1 +1 @@
1
- {"version":3,"file":"server-command.d.ts","sourceRoot":"","sources":["../src/server-command.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,KAAK,sBAAsB,EAAyB,MAAM,oBAAoB,CAAA;AAKvF;;;GAGG;AACH,eAAO,MAAM,YAAY,QAA6C,CAAA;AAEtE;;;;;GAKG;AACH,eAAO,MAAM,cAAc,QACmP,CAAA;AAmF9Q;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAI5D;AAED,0EAA0E;AAC1E,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAC5C,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAmBlC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAC5C,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAiBxC;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAO5F;AAED;;;GAGG;AACH,eAAO,MAAM,yBAAyB,QAG0B,CAAA"}
1
+ {"version":3,"file":"server-command.d.ts","sourceRoot":"","sources":["../src/server-command.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,KAAK,sBAAsB,EAAyB,MAAM,oBAAoB,CAAA;AAKvF;;;GAGG;AACH,eAAO,MAAM,YAAY,QAA6C,CAAA;AAEtE;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,QACmP,CAAA;AA8G9Q;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAI5D;AAED,0EAA0E;AAC1E,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAC5C,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAmBlC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAC5C,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAiBxC;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAO5F;AAED;;;GAGG;AACH,eAAO,MAAM,yBAAyB,QAG0B,CAAA"}
@@ -28,7 +28,10 @@ export const BACKGROUNDED = /(?:^|[^&>])&(?!&|>)|\b(?:nohup|setsid)\b/;
28
28
  * A program that SERVES: the scaffold's entry (`server.ts`/`server.js`), a package manager's
29
29
  * `start`/`dev`/`serve`/`preview` script, a Vite dev or preview server, `next start|dev`, or a
30
30
  * Python WSGI server. Matched against a command segment's own text, and against a script's SOURCE
31
- * when the command only names the script file.
31
+ * when the command only names the script file — but NEVER against an inline program's code
32
+ * (`node -e …`, `python3 -c …`), where this text is the user's program rather than a file being
33
+ * run: `require('…/node_modules/vite')` imports a module, it does not start a dev server
34
+ * (xr-molecule-flash-m391 +13:13, refused as "node starts a server" for exactly that line).
32
35
  */
33
36
  export const SERVER_PROGRAM = /\bserver\.[cm]?[jt]s\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:start|dev|serve|preview)\b|\bvite\s+(?:preview|dev|serve)\b|\bvite(?![.\w-])(?!\s+(?:build|optimize)\b)|\bnext\s+(?:start|dev)\b|\b(?:uvicorn|gunicorn)\b|\bflask\s+run\b|\bpython\d?\s+-m\s+http\.server\b/;
34
37
  /** Programs that run a script FILE, so the file's own source decides whether it serves. */
@@ -66,6 +69,26 @@ const SERVE_SCRIPTS = /^(?:start|dev|serve|preview)$/;
66
69
  const TEST_RUNNERS = /^(?:hurl|jest|mocha|playwright|vitest)$/;
67
70
  /** A script's SOURCE that binds a listener even when it does not name a server file. */
68
71
  const STARTS_LISTENING = /\b(?:server|app)\.listen\s*\(/;
72
+ /** Node inline-program flags; the shell parser already separated their arguments. */
73
+ const NODE_INLINE_FLAG = /^(?:-[A-Za-z]*[ep]|--(?:eval|print)(?:=|$))/;
74
+ /** Python inline programs use -c, including combined interpreter flags. */
75
+ const PYTHON_INLINE_FLAG = /^-[A-Za-z]*c$/;
76
+ /**
77
+ * Inline/stdin programs have no server filename to classify. Their source is arbitrary
78
+ * code; the normal bounded command runner, rather than another partial JS/Python
79
+ * parser, owns their execution. Named script and package-server checks remain below.
80
+ * @param segment - One parsed shell statement.
81
+ * @returns Whether the interpreter reads an inline/stdin program.
82
+ */
83
+ function isInlineProgram(segment) {
84
+ const flag = /^python(?:3(?:\.\d+)?)?$/.test(segment.program)
85
+ ? PYTHON_INLINE_FLAG
86
+ : NODE_INLINE_FLAG;
87
+ return segment.args.some((word) => {
88
+ const arg = bare(word);
89
+ return arg === '-' || flag.test(arg);
90
+ });
91
+ }
69
92
  /** A word with its shell quotes taken off (`'src/server.ts'` → `src/server.ts`). */
70
93
  function bare(word) {
71
94
  const match = /^(['"])(.*)\1$/s.exec(word);
@@ -75,6 +98,8 @@ function bare(word) {
75
98
  * Whether one parsed statement starts a server: a package manager's serving script, or a program
76
99
  * that IS a server, named in command position (so `cat server.ts` is a read, not a launch).
77
100
  *
101
+ * Inline/stdin programs use normal bounded execution: their code is not a server filename.
102
+ *
78
103
  * @param segment - One statement of the command.
79
104
  * @returns True when the statement starts a server that does not exit.
80
105
  */
@@ -87,7 +112,11 @@ function runsServerProgram(segment) {
87
112
  if (script !== undefined && SERVE_SCRIPTS.test(script))
88
113
  return true;
89
114
  }
90
- return SERVER_PROGRAMS.has(segment.program) && SERVER_PROGRAM.test(segment.text);
115
+ if (!SERVER_PROGRAMS.has(segment.program))
116
+ return false;
117
+ if (isInlineProgram(segment))
118
+ return false;
119
+ return SERVER_PROGRAM.test(segment.text);
91
120
  }
92
121
  /**
93
122
  * The script file a runner statement executes, when it names one.
@@ -98,6 +127,9 @@ function runsServerProgram(segment) {
98
127
  function scriptPath(segment) {
99
128
  if (!SCRIPT_RUNNERS.has(segment.program))
100
129
  return null;
130
+ // An inline program (`node -e …`) is code, not a file to read.
131
+ if (isInlineProgram(segment))
132
+ return null;
101
133
  for (const arg of segment.args) {
102
134
  const word = bare(arg);
103
135
  if (word.startsWith('-'))
@@ -111,7 +143,8 @@ function scriptPath(segment) {
111
143
  * Whether a command, read from its TEXT alone, starts a server that stays up in the foreground.
112
144
  *
113
145
  * The over-budget reader's shape: at that point the command has already run, and only its text is
114
- * left to say whether "run a smaller unit" was ever the right advice.
146
+ * left to say whether "run a smaller unit" was ever the right advice. Inline/stdin programs
147
+ * remain unclassified and use the normal bounded command runner.
115
148
  *
116
149
  * @param command - The command as the executor sent it.
117
150
  * @returns True when a foreground statement of the command starts a server.
@@ -124,7 +157,9 @@ export function startsServerProgram(command) {
124
157
  }
125
158
  /**
126
159
  * A FOREGROUND command that starts a server, including one that only names a script whose SOURCE
127
- * imports the app's server and calls its `create()`/`listen()`.
160
+ * imports the app's server and calls its `create()`/`listen()`. Inline/stdin programs
161
+ * (`node -e …`, `python3 -c …`) use normal bounded execution: interpreting arbitrary
162
+ * program text is outside this filename/script preflight's contract.
128
163
  *
129
164
  * The script read is best-effort: a path the backend will not answer for is a command this reading
130
165
  * cannot classify, never a refusal. A backgrounded launch, a test runner and a test file are all
@@ -0,0 +1,32 @@
1
+ import type { AITool } from '@molecule/api-ai';
2
+ /** Options for exposing a small tool set while retaining access to other allowed tools. */
3
+ export interface DeferredToolCatalogOptions {
4
+ /** The caller's current permission/mode-filtered tools. Discovery never grants access. */
5
+ available: () => readonly AITool[];
6
+ /** Tools that should be offered without a discovery call. */
7
+ initialNames: ReadonlySet<string>;
8
+ /** Previously loaded names, in load order; useful across turns and compaction. */
9
+ loadedNames?: readonly string[];
10
+ /** Persist names after a successful load. No tool content is passed to this callback. */
11
+ onLoad?: (names: string[]) => void;
12
+ }
13
+ /** A provider-independent deferred catalog. The caller still executes and authorizes tools. */
14
+ export interface DeferredToolCatalog {
15
+ loader: AITool;
16
+ /** Initial tools, loader, then requested tools in stable load order. */
17
+ select: () => AITool[];
18
+ }
19
+ /**
20
+ * Offer common tools and let the model load other schemas by exact name.
21
+ *
22
+ * This is ordinary allowlist selection, not semantic routing or authorization.
23
+ * Pass tools already filtered by the current mode and permissions. Loading never
24
+ * executes a tool; the caller must keep its normal execution-time guards. Loaded
25
+ * schemas append in request order and stay available, avoiding an LRU that changes
26
+ * the schema prefix on every request. Only names need persistence across turns.
27
+ *
28
+ * @param options - Allowed tools, initial selection and optional durable load state.
29
+ * @returns A loader tool and the current provider-facing selection.
30
+ */
31
+ export declare function createDeferredToolCatalog(options: DeferredToolCatalogOptions): DeferredToolCatalog;
32
+ //# sourceMappingURL=tool-catalog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-catalog.d.ts","sourceRoot":"","sources":["../src/tool-catalog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAE9C,2FAA2F;AAC3F,MAAM,WAAW,0BAA0B;IACzC,0FAA0F;IAC1F,SAAS,EAAE,MAAM,SAAS,MAAM,EAAE,CAAA;IAClC,6DAA6D;IAC7D,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IACjC,kFAAkF;IAClF,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC/B,yFAAyF;IACzF,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;CACnC;AAED,+FAA+F;AAC/F,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAA;IACd,wEAAwE;IACxE,MAAM,EAAE,MAAM,MAAM,EAAE,CAAA;CACvB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,0BAA0B,GAClC,mBAAmB,CAsErB"}