@cspeach/cli 1.1.15 → 1.1.16

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.
@@ -30,6 +30,8 @@ import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotio
30
30
  import { input } from '@inquirer/prompts';
31
31
  import { withInquirer } from '../repl/inquirer-guard.js';
32
32
  import { expandTextFileAttachments } from '../projects/workspace.js';
33
+ import { expandImageAttachments, buildUserContent, trimHistoryImages } from '../projects/image-attachments.js';
34
+ import { expandImageChips } from '../ui/clipboard-image.js';
33
35
  import { getSapSystemInfo, renderSapSystemBlock } from '../sap/system-info.js';
34
36
  import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
35
37
  import { renderStandardsBlock } from '../standards/standards-file.js';
@@ -45,6 +47,7 @@ import { buildSapConnectionContext } from './sap-connection-adapter.js';
45
47
  import { getCurrentTransport } from '../repl/current-transport.js';
46
48
  import { collectTurnAssistantText } from './turn-assistant-text.js';
47
49
  import { checkAndMark, newGuardState } from './parallel-write-guard.js';
50
+ import { userPromptText } from '../session/user-prompt.js';
48
51
  /**
49
52
  * Author identity for project-file metadata. Reads CSPEACH_AUTHOR_NAME first,
50
53
  * then platform USER/USERNAME, then a generic fallback. Role is fixed to
@@ -103,17 +106,15 @@ const SAVE_HOOK_SKILLS = new Set([
103
106
  * per-process counter would re-inject; the content scan correctly
104
107
  * short-circuits.
105
108
  *
106
- * - tool_result entries push `role: 'user'` with array-shaped content.
107
- * The `typeof === 'string'` check skips those without iterating
108
- * multimodal blocks.
109
+ * - tool_result entries push `role: 'user'` with array-shaped content;
110
+ * userPromptText skips those. An image-attachment prompt is also an
111
+ * array ([image…, text]) and IS checked (2026-09-23).
109
112
  *
110
113
  * Exported for unit testing only — the call site is one floor up in runTurn.
111
114
  */
112
115
  export function hasProjectContextBlock(messages) {
113
116
  for (const m of messages) {
114
- if (m.role !== 'user')
115
- continue;
116
- if (typeof m.content === 'string' && m.content.includes('<project_context')) {
117
+ if (userPromptText(m)?.includes('<project_context')) {
117
118
  return true;
118
119
  }
119
120
  }
@@ -332,6 +333,20 @@ export async function runTurn(params) {
332
333
  userMessageForSave = rest;
333
334
  }
334
335
  }
336
+ // Tier 1 screenshots (2026-09-23) — image paths in the prompt (typed,
337
+ // @-referenced or dragged in) become image content blocks and the path
338
+ // becomes `[attached <name>]`. Runs BEFORE the text-file expansion so a
339
+ // path written inside an attached document is never pulled in. Too large
340
+ // or too many images → the turn is not sent (same early exit as --from).
341
+ // Pasted clipboard images arrive as [Image #N] chips — swap each for its
342
+ // saved file path first, so the step below attaches it like a typed path.
343
+ const imageExpansion = await expandImageAttachments(expandImageChips(userMessageForLLM), params.ctx.cwd, (line) => emit(chalk.dim(line)));
344
+ if (imageExpansion.error) {
345
+ emit(chalk.yellow(`
346
+ ${imageExpansion.error}`));
347
+ return;
348
+ }
349
+ userMessageForLLM = imageExpansion.text;
335
350
  // Phase D — `@<text-file>` attachment expansion.
336
351
  // After --from has consumed its own @<token>, scan what's left for
337
352
  // `@<filename>` references that resolve to .txt / .md files in the
@@ -416,7 +431,17 @@ export async function runTurn(params) {
416
431
  const finalUserContent = projectContextBlock
417
432
  ? `${projectContextBlock}\n\n${enriched}`
418
433
  : enriched;
419
- session.messages.push({ role: 'user', content: finalUserContent });
434
+ // Images (if any) ride as blocks ahead of the text; with none this is the
435
+ // same plain string as before.
436
+ session.messages.push({ role: 'user', content: buildUserContent(finalUserContent, imageExpansion.images) });
437
+ if (imageExpansion.images.length > 0 && params.provider.name === 'local-openai-compat') {
438
+ // The local (OpenAI-compatible) provider keeps text blocks only.
439
+ emit(chalk.dim('(local model: the image is not sent — the model gets the text only)'));
440
+ }
441
+ const droppedImages = trimHistoryImages(session.messages);
442
+ if (droppedImages > 0) {
443
+ emit(chalk.dim(`(${droppedImages} older image(s) removed from the conversation to keep it under the size limit)`));
444
+ }
420
445
  session.skill = params.skill;
421
446
  /**
422
447
  * key: `${tool_name}:${object}` — consecutive failures per (tool, target).
@@ -1,3 +1,4 @@
1
+ import path from 'node:path';
1
2
  import { getTool, isStandalone } from '../tools/index.js';
2
3
  import { NOT_CONNECTED_MESSAGE } from '../sap/offline-adt-client.js';
3
4
  import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
@@ -30,7 +31,7 @@ export async function dispatchTool(name, args, ctx) {
30
31
  // GUIDE.md) — local scratch, not SAP objects. Rule 8 protects SAP batches;
31
32
  // applying it here made the 2nd+ file (GUIDE.md) hit the batch card and,
32
33
  // headless, get refused. Exempt from the gate AND from the batch count.
33
- const scratchWrite = isScratchOutputWrite(tool, args);
34
+ const scratchWrite = isScratchOutputWrite(tool, args, ctx.cwd);
34
35
  if (tool.isMutating && !scratchWrite && shouldGateRule8()) {
35
36
  const r = await presentSafetyConfirmation({
36
37
  rule: 'RULE_8_BATCH_PLAN',
@@ -83,21 +84,24 @@ export async function dispatchTool(name, args, ctx) {
83
84
  }
84
85
  }
85
86
  /**
86
- * True for a mutating filesystem tool whose target sits under ./cspeach-out/
87
- * (relative to cwd). Accepts `cspeach-out/…`, `./cspeach-out/…` and the
88
- * Windows backslash form; rejects anything that climbs out (`..`) or is
89
- * absolute. Deliberately narrow: only this one folder is exempt.
87
+ * True for a mutating filesystem tool whose target sits under the project's
88
+ * own ./cspeach-out/ (cwd). Accepts `cspeach-out/…`, `./cspeach-out/…`, the
89
+ * Windows backslash form, and an ABSOLUTE path that resolves inside
90
+ * <cwd>/cspeach-out (2026-09-23: models write full paths, and a project deep
91
+ * in a synced folder hit the batch card on every file after the first).
92
+ * Rejects anything with a `..` segment and any cspeach-out outside cwd.
93
+ * Deliberately narrow: only this one folder is exempt.
90
94
  */
91
- export function isScratchOutputWrite(tool, args) {
95
+ export function isScratchOutputWrite(tool, args, cwd = process.cwd()) {
92
96
  if (!tool.isMutating || tool.category !== 'filesystem')
93
97
  return false;
94
98
  const raw = args?.path;
95
99
  if (typeof raw !== 'string' || raw.length === 0)
96
100
  return false;
97
101
  const p = raw.replace(/\\/g, '/').replace(/^\.\//, '');
98
- if (p.startsWith('/') || /^[A-Za-z]:\//.test(p))
99
- return false;
100
102
  if (p.split('/').some((seg) => seg === '..'))
101
103
  return false;
102
- return p === 'cspeach-out' || p.startsWith('cspeach-out/');
104
+ const scratchRoot = path.resolve(cwd, 'cspeach-out');
105
+ const rel = path.relative(scratchRoot, path.resolve(cwd, p));
106
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
103
107
  }
@@ -29,6 +29,7 @@ import path from 'node:path';
29
29
  import chalk from 'chalk';
30
30
  import { sessionsDir } from '../config/paths.js';
31
31
  import { saveSession } from '../session/store.js';
32
+ import { isUserPrompt } from '../session/user-prompt.js';
32
33
  /** Keep this many recent user-prompt anchored turns verbatim. */
33
34
  export const KEEP_RECENT_TURNS = 5;
34
35
  /** Don't bother compacting if there's only a handful of older turns. */
@@ -58,8 +59,9 @@ keepRecentTurns = KEEP_RECENT_TURNS) {
58
59
  let cutAt = 0;
59
60
  for (let i = messages.length - 1; i >= 0; i--) {
60
61
  const msg = messages[i];
61
- // A user PROMPT (not a tool_result block) — content is a string.
62
- if (msg.role === 'user' && typeof msg.content === 'string') {
62
+ // A user PROMPT (not a tool_result round) — string content, or an
63
+ // image-attachment prompt ([image…, text]).
64
+ if (isUserPrompt(msg)) {
63
65
  userPromptsSeen++;
64
66
  if (userPromptsSeen === keepRecentTurns) {
65
67
  cutAt = i;
@@ -134,6 +136,8 @@ export function serialiseForSummariser(messages) {
134
136
  return '';
135
137
  if (b.type === 'text')
136
138
  return b.text ?? '';
139
+ if (b.type === 'image')
140
+ return '[image]';
137
141
  if (b.type === 'thinking')
138
142
  return `[thinking: ${(b.thinking ?? '').slice(0, 200)}]`;
139
143
  if (b.type === 'tool_use')
@@ -24,6 +24,7 @@ import fs from 'node:fs/promises';
24
24
  import path from 'node:path';
25
25
  import chalk from 'chalk';
26
26
  import { sessionsDir } from '../config/paths.js';
27
+ import { userPromptText } from '../session/user-prompt.js';
27
28
  /**
28
29
  * Read all session JSONs in `dir` (defaults to sessionsDir()), classify
29
30
  * each by whether the flag was on, and produce a comparison report.
@@ -134,8 +135,7 @@ async function readAllSessions(dir) {
134
135
  function toMetrics(s) {
135
136
  const firstUser = s.messages.find((m) => m.role === 'user');
136
137
  const flagOn = firstUser !== undefined &&
137
- typeof firstUser.content === 'string' &&
138
- firstUser.content.includes('<project_context');
138
+ (userPromptText(firstUser)?.includes('<project_context') ?? false);
139
139
  // Count "turns" as user messages — each user message kicks off a model turn.
140
140
  const turnCount = s.messages.filter((m) => m.role === 'user').length;
141
141
  const usage = s.usage ?? { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
package/dist/one-shot.js CHANGED
@@ -170,6 +170,8 @@ export async function runOneShot(prompt, opts) {
170
170
  console.error(chalk.dim(`[offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
171
171
  }
172
172
  const pct = Math.round(c.confidence * 100);
173
+ if (c.screenshot)
174
+ console.log(chalk.dim(`┄ Screenshot: ${c.screenshot}`));
173
175
  if (c.confidence < 0.6) {
174
176
  const alternates = [c.skill, ...c.alternates].slice(0, 3).map((s) => `/${s}`).join(', ');
175
177
  console.error(chalk.yellow(`[classifier] Low confidence (${pct}%). Using /${c.skill}. ` +
@@ -0,0 +1,155 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ /**
4
+ * Tier 1 screenshots (2026-09-23, spec 2026-09-16-image-attachments-1.1.5.md).
5
+ *
6
+ * An image path in the prompt — typed, `@`-referenced, or dragged in (a
7
+ * terminal pastes the path as text, double-quoted when it has spaces) — is
8
+ * read and sent as an image content block. The path in the text becomes
9
+ * `[attached <name>]`. In standalone mode a screenshot of an ST22 dump or an
10
+ * ATC list is how users show us what they see.
11
+ */
12
+ const MEDIA_TYPES = {
13
+ '.png': 'image/png',
14
+ '.jpg': 'image/jpeg',
15
+ '.jpeg': 'image/jpeg',
16
+ '.gif': 'image/gif',
17
+ '.webp': 'image/webp',
18
+ };
19
+ /** Raw-file cap. The API limit is 5 MB per image as base64 (4/3 inflation). */
20
+ export const MAX_IMAGE_BYTES = Math.floor((5 * 1024 * 1024 * 3) / 4);
21
+ export const MAX_IMAGES_PER_TURN = 5;
22
+ /** Appended to the text block whenever an image is attached. */
23
+ export const IMAGE_PROMPT_NOTE = '(Anything taken from an attached image must be stated as "from the screenshot", ' +
24
+ 'and object names read from it confirmed with the user before use.)';
25
+ function mediaTypeOf(p) {
26
+ return MEDIA_TYPES[path.extname(p).toLowerCase()] ?? null;
27
+ }
28
+ function formatBytes(n) {
29
+ if (n < 1024 * 1024)
30
+ return `${Math.max(1, Math.round(n / 1024))} KB`;
31
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
32
+ }
33
+ export async function expandImageAttachments(message, cwd, log) {
34
+ // One token per match: optional @, then a "double" or 'single' quoted run,
35
+ // or a bare word.
36
+ const tokenRe = /@?(?:"([^"\r\n]+)"|'([^'\r\n]+)'|(\S+))/g;
37
+ const found = [];
38
+ let m;
39
+ while ((m = tokenRe.exec(message)) !== null) {
40
+ const quoted = m[1] ?? m[2];
41
+ if (quoted !== undefined) {
42
+ if (mediaTypeOf(quoted)) {
43
+ found.push({ index: m.index, length: m[0].length, token: quoted });
44
+ }
45
+ else {
46
+ // A quoted run that is not an image path ("some text with x.png", or
47
+ // an apostrophe pair) — rescan its words instead of skipping them.
48
+ const openQuote = m.index + (m[0].startsWith('@') ? 1 : 0);
49
+ tokenRe.lastIndex = openQuote + 1;
50
+ }
51
+ continue;
52
+ }
53
+ let token = m[3];
54
+ let length = m[0].length;
55
+ const trail = /[,;:.!?)\]]+$/.exec(token);
56
+ if (trail && trail[0].length < token.length) {
57
+ token = token.slice(0, -trail[0].length);
58
+ length -= trail[0].length;
59
+ }
60
+ if (token.includes('://') || !mediaTypeOf(token))
61
+ continue;
62
+ found.push({ index: m.index, length, token });
63
+ }
64
+ if (found.length === 0)
65
+ return { text: message, images: [] };
66
+ // Resolve against cwd; keep only files that exist. One block per file even
67
+ // if it is named twice.
68
+ const byPath = new Map();
69
+ const substitutions = [];
70
+ for (const f of found) {
71
+ const abs = path.resolve(cwd, f.token);
72
+ let size;
73
+ try {
74
+ const st = await fs.stat(abs);
75
+ if (!st.isFile())
76
+ throw new Error('not a file');
77
+ size = st.size;
78
+ }
79
+ catch {
80
+ if (log)
81
+ log(`(image ${f.token} not found — looked in ${cwd})`);
82
+ continue;
83
+ }
84
+ const name = path.basename(abs);
85
+ if (!byPath.has(abs))
86
+ byPath.set(abs, { name, size });
87
+ substitutions.push({ index: f.index, length: f.length, replacement: `[attached ${name}]` });
88
+ }
89
+ if (byPath.size === 0)
90
+ return { text: message, images: [] };
91
+ if (byPath.size > MAX_IMAGES_PER_TURN) {
92
+ return {
93
+ text: message,
94
+ images: [],
95
+ error: `${byPath.size} images in one message — the limit is ${MAX_IMAGES_PER_TURN}. Nothing was sent.`,
96
+ };
97
+ }
98
+ for (const { name, size } of byPath.values()) {
99
+ if (size > MAX_IMAGE_BYTES) {
100
+ return {
101
+ text: message,
102
+ images: [],
103
+ error: `${name} is ${formatBytes(size)} — the limit is ${formatBytes(MAX_IMAGE_BYTES)} per image. ` +
104
+ 'Crop it or save it as JPEG and try again. Nothing was sent.',
105
+ };
106
+ }
107
+ }
108
+ const images = [];
109
+ for (const [abs, { name, size }] of byPath) {
110
+ const data = (await fs.readFile(abs)).toString('base64');
111
+ images.push({ type: 'image', source: { type: 'base64', media_type: mediaTypeOf(abs), data } });
112
+ if (log)
113
+ log(`(attached ${name} — ${formatBytes(size)})`);
114
+ }
115
+ substitutions.sort((a, b) => b.index - a.index);
116
+ let text = message;
117
+ for (const s of substitutions) {
118
+ text = text.slice(0, s.index) + s.replacement + text.slice(s.index + s.length);
119
+ }
120
+ return { text: `${text}\n\n${IMAGE_PROMPT_NOTE}`, images };
121
+ }
122
+ /**
123
+ * The user message content sent to the model: the plain string when no image
124
+ * is attached (unchanged from before this feature), else images first, then
125
+ * the text — enrichment blocks stay in the text part.
126
+ */
127
+ export function buildUserContent(text, images) {
128
+ if (images.length === 0)
129
+ return text;
130
+ return [...images, { type: 'text', text }];
131
+ }
132
+ /**
133
+ * Images stay in the conversation and are re-sent every turn. Past this
134
+ * many, the oldest are swapped for a short text note so a long session never
135
+ * hits the 32 MB request cap (and stops paying for stale screenshots).
136
+ */
137
+ export const MAX_HISTORY_IMAGES = 10;
138
+ /** Mutates `messages`; returns how many image blocks were removed. */
139
+ export function trimHistoryImages(messages, max = MAX_HISTORY_IMAGES) {
140
+ const slots = [];
141
+ for (const m of messages) {
142
+ if (m.role !== 'user' || !Array.isArray(m.content))
143
+ continue;
144
+ m.content.forEach((b, i) => {
145
+ if (b?.type === 'image')
146
+ slots.push({ blocks: m.content, i });
147
+ });
148
+ }
149
+ const excess = slots.length - max;
150
+ for (let k = 0; k < excess; k++) {
151
+ const { blocks, i } = slots[k];
152
+ blocks[i] = { type: 'text', text: '[image removed from history]' };
153
+ }
154
+ return Math.max(0, excess);
155
+ }
@@ -23,6 +23,7 @@ import { globalStore } from '../ui/sap-state-store.js';
23
23
  import { globalCredits } from '../cost/credits.js';
24
24
  import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
25
25
  import { shouldUseInk } from '../renderer/tty.js';
26
+ import { isUserPrompt } from '../session/user-prompt.js';
26
27
  export function snapshotTurnStart(session) {
27
28
  return {
28
29
  input: session.usage.input_tokens,
@@ -34,7 +35,7 @@ export function snapshotTurnStart(session) {
34
35
  }
35
36
  export function pushPostTurnStatus(params) {
36
37
  const { session, selectedAlias, snapshot, chunkEmitter, turnModel } = params;
37
- const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
38
+ const userTurns = session.messages.filter(isUserPrompt).length;
38
39
  const lifetimeTokensAfter = session.usage.input_tokens + session.usage.output_tokens;
39
40
  const lifetimeTokensBefore = snapshot.input + snapshot.output;
40
41
  const turnTokens = Math.max(0, lifetimeTokensAfter - lifetimeTokensBefore);
package/dist/repl.js CHANGED
@@ -1014,6 +1014,10 @@ export async function runRepl(opts) {
1014
1014
  // from regex, not the LLM, and surface the real cause.
1015
1015
  chunkEmitter.emit('chunk', chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]\n`));
1016
1016
  }
1017
+ if (c.screenshot) {
1018
+ chunkEmitter.emit('chunk', chalk.dim(`┄ Screenshot: ${c.screenshot}
1019
+ `));
1020
+ }
1017
1021
  const decision = decideRouting(c);
1018
1022
  // 2026-05-17 — pass the just-used skill so the picker can
1019
1023
  // default cursor to it (one-Enter "continue" UX for follow-up
@@ -2577,6 +2581,8 @@ export async function runRepl(opts) {
2577
2581
  if (c.offline) {
2578
2582
  console.log(chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
2579
2583
  }
2584
+ if (c.screenshot)
2585
+ console.log(chalk.dim(`┄ Screenshot: ${c.screenshot}`));
2580
2586
  const decision = decideRouting(c);
2581
2587
  // 2026-05-17 — pass the just-used skill so the picker can
2582
2588
  // default cursor to it. session.skill = immediately-previous
@@ -3,6 +3,8 @@ import { getStore } from '../auth/api-key.js';
3
3
  import { classify as classifyLocal } from '../classifier/client.js';
4
4
  import { SKILL_CATALOG, SKILL_ALIASES, resolveSkillAlias } from '../skill-catalog.js';
5
5
  import { isBuiltinCommandHead } from '../repl/builtin-commands.js';
6
+ import { expandImageAttachments } from '../projects/image-attachments.js';
7
+ import { expandImageChips } from '../ui/clipboard-image.js';
6
8
  // 8s — sized to cover the observed Fly cold-start (~3.2s on the 2026-05-20
7
9
  // probe) plus headroom for a slow network. Tighter values risk timing out
8
10
  // legitimate cold-start hits and burning the retry budget on the same.
@@ -25,7 +27,7 @@ function extractCause(err) {
25
27
  }
26
28
  return e.message;
27
29
  }
28
- async function tryFetchOnce(proxyUrl, key, prompt, context) {
30
+ async function tryFetchOnce(proxyUrl, key, prompt, context, images = []) {
29
31
  const ctrl = new AbortController();
30
32
  const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
31
33
  try {
@@ -35,7 +37,7 @@ async function tryFetchOnce(proxyUrl, key, prompt, context) {
35
37
  'Authorization': `Bearer ${key}`,
36
38
  'Content-Type': 'application/json',
37
39
  },
38
- body: JSON.stringify({ prompt, context }),
40
+ body: JSON.stringify(images.length > 0 ? { prompt, context, images } : { prompt, context }),
39
41
  signal: ctrl.signal,
40
42
  });
41
43
  }
@@ -43,7 +45,24 @@ async function tryFetchOnce(proxyUrl, key, prompt, context) {
43
45
  clearTimeout(timer);
44
46
  }
45
47
  }
46
- export async function classifyPrompt(prompt, context) {
48
+ /**
49
+ * Screenshot-aware routing (2026-09-23): images in the prompt (pasted
50
+ * [Image #N] chips or image paths) go to the router so it reads the picture
51
+ * with the text. Any problem (too large, too many, unreadable) → classify on
52
+ * text; the turn itself reports the error when it runs.
53
+ */
54
+ async function promptImages(prompt, cwd) {
55
+ try {
56
+ const r = await expandImageAttachments(expandImageChips(prompt), cwd);
57
+ if (r.error)
58
+ return [];
59
+ return r.images.map((im) => ({ media_type: im.source.media_type, data: im.source.data }));
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ export async function classifyPrompt(prompt, context, opts = {}) {
47
66
  // D22/D35 structural invariant (2026-06-11) — an explicit slash command
48
67
  // must NEVER reach the classifier. The S3 battery proved the failure mode:
49
68
  // `/abap-plan <multiline>` failed the old parseShortcut regex, the whole
@@ -61,6 +80,7 @@ export async function classifyPrompt(prompt, context) {
61
80
  const key = await store.get();
62
81
  if (!key)
63
82
  throw new Error('no_api_key');
83
+ const images = await promptImages(prompt, opts.cwd ?? process.cwd());
64
84
  let r;
65
85
  let lastErr;
66
86
  // Two attempts total: the second masks the common case of a stale
@@ -71,7 +91,7 @@ export async function classifyPrompt(prompt, context) {
71
91
  // they bubble through as before.
72
92
  for (let attempt = 0; attempt < 2; attempt++) {
73
93
  try {
74
- r = await tryFetchOnce(cfg.proxy_url, key, prompt, context);
94
+ r = await tryFetchOnce(cfg.proxy_url, key, prompt, context, images);
75
95
  break;
76
96
  }
77
97
  catch (err) {
@@ -26,6 +26,7 @@ import { readProjectFile } from '../projects/status.js';
26
26
  import { parsePlanContent } from '../projects/plan-schema.js';
27
27
  import { statusesFromItems, isPhaseSatisfied } from '../projects/plan-run.js';
28
28
  import { defaultWorkspaceFor } from '../projects/workspace.js';
29
+ import { userPromptText } from './user-prompt.js';
29
30
  /** Wave-1 truncation rule (see /reroute): ≤60 chars total, '...' suffix. */
30
31
  function truncatePrompt(s) {
31
32
  return s.length > 60 ? s.slice(0, 57) + '...' : s;
@@ -37,10 +38,12 @@ function truncatePrompt(s) {
37
38
  export function buildRecap(session, cwd, opts) {
38
39
  const lines = [];
39
40
  // ── line 1: session orientation ─────────────────────────────────────────
40
- // "User turns" = plain-string user messages (tool_result rounds are arrays)
41
- // — same counting rule as the resume picker (session/store.ts).
42
- const userMessages = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string');
43
- const turns = userMessages.length;
41
+ // "User turns" = typed prompts, not tool_result rounds — same counting
42
+ // rule as the resume picker (session/store.ts); see session/user-prompt.ts.
43
+ const userTexts = session.messages
44
+ .map(userPromptText)
45
+ .filter((t) => t !== null);
46
+ const turns = userTexts.length;
44
47
  if (turns === 0)
45
48
  return [];
46
49
  const skillLabel = session.skill ? `/${session.skill}` : 'no skill';
@@ -50,7 +53,7 @@ export function buildRecap(session, cwd, opts) {
50
53
  // rule as the picker's promptSnippet).
51
54
  let prompt = session.lastUserPrompt;
52
55
  if (!prompt) {
53
- const last = userMessages[userMessages.length - 1].content;
56
+ const last = userTexts[userTexts.length - 1];
54
57
  prompt = last.replace(/<session_context>[\s\S]*?<\/session_context>\s*/g, '').trim() || null;
55
58
  }
56
59
  const quoted = prompt ? `"${truncatePrompt(prompt)}"` : '(no prompt)';
@@ -19,23 +19,15 @@ export async function runResume(id) {
19
19
  return b.lastTurnAt.localeCompare(a.lastTurnAt);
20
20
  });
21
21
  targetId = await select({
22
- message: 'Pick a session to resume:',
23
- choices: sorted.map((s) => {
24
- const flag = s.interrupted ? chalk.yellow('⚠ ') : ' ';
25
- const idCol = chalk.cyan(s.id.padEnd(8));
26
- const timeCol = chalk.gray(formatTimeAgo(s.lastTurnAt).padEnd(10));
27
- const sapCol = chalk.gray((s.sapSystem ?? '-').padEnd(8));
28
- const skillCol = chalk.gray(`/${s.skill ?? '-'}`.padEnd(22));
29
- const turns = chalk.gray(`${s.userTurnCount}t`.padStart(3));
30
- const snippet = s.promptSnippet ?? chalk.dim('(no prompt)');
31
- return {
32
- value: s.id,
33
- name: `${flag}${idCol} ${timeCol} ${sapCol} ${skillCol} ${turns} ${snippet}`,
34
- description: s.interrupted
35
- ? `Interrupted (${s.interruptedReason ?? 'unknown'}) — type "continue" after resume`
36
- : `${s.userTurnCount} prior turn(s) — pick up from where you left off`,
37
- };
38
- }),
22
+ message: `Pick a session to resume:
23
+ ${formatSessionHeader()}`,
24
+ choices: sorted.map((s) => ({
25
+ value: s.id,
26
+ name: formatSessionRow(s),
27
+ description: s.interrupted
28
+ ? `Interrupted (${s.interruptedReason ?? 'unknown'}) — type "continue" after resume`
29
+ : `${s.userTurnCount} prior turn(s) — pick up from where you left off`,
30
+ })),
39
31
  });
40
32
  }
41
33
  let session;
@@ -75,3 +67,31 @@ export async function runResume(id) {
75
67
  // to the persistence work in agent/loop.ts.
76
68
  return runRepl({ resumedSession: session });
77
69
  }
70
+ // Column widths shared by the rows and the header so the labels line up.
71
+ const W = { id: 8, time: 10, sap: 8, skill: 22, turns: 5 };
72
+ export function formatSessionRow(s) {
73
+ const flag = s.interrupted ? chalk.yellow('⚠ ') : ' ';
74
+ const idCol = chalk.cyan(s.id.padEnd(W.id));
75
+ const timeCol = chalk.gray(formatTimeAgo(s.lastTurnAt).padEnd(W.time));
76
+ const sapCol = chalk.gray((s.sapSystem ?? '-').padEnd(W.sap));
77
+ const skillCol = chalk.gray(`/${s.skill ?? '-'}`.padEnd(W.skill));
78
+ const turns = chalk.gray(`${s.userTurnCount}t`.padStart(W.turns));
79
+ const snippet = s.promptSnippet ?? chalk.dim('(no prompt)');
80
+ return `${flag}${idCol} ${timeCol} ${sapCol} ${skillCol} ${turns} ${snippet}`;
81
+ }
82
+ /**
83
+ * One dim line naming the row columns (2026-09-23 — rows had no labels). The
84
+ * picker draws a 2-char cursor gutter before each choice but not before the
85
+ * message, so the header adds it: gutter + flag = 4 leading spaces.
86
+ */
87
+ export function formatSessionHeader() {
88
+ return chalk.dim(' ' +
89
+ [
90
+ 'ID'.padEnd(W.id),
91
+ 'LAST USED'.padEnd(W.time),
92
+ 'SYSTEM'.padEnd(W.sap),
93
+ 'SKILL'.padEnd(W.skill),
94
+ 'TURNS'.padStart(W.turns),
95
+ ].join(' ') +
96
+ ' LAST PROMPT');
97
+ }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { sessionsDir, pinnedSessionsDir } from '../config/paths.js';
5
5
  import { SCHEMA_VERSION } from './schema.js';
6
+ import { userPromptText, isUserPrompt } from './user-prompt.js';
6
7
  export function makeSessionId() {
7
8
  return crypto.randomBytes(4).toString('hex');
8
9
  }
@@ -107,16 +108,17 @@ export async function listSessions(limit = 10) {
107
108
  let promptSnippet = null;
108
109
  for (let i = messages.length - 1; i >= 0; i--) {
109
110
  const m = messages[i];
110
- if (m && m.role === 'user' && typeof m.content === 'string') {
111
+ const text = m ? userPromptText(m) : null;
112
+ if (text !== null) {
111
113
  // Strip <session_context>...</session_context> wrapper that the
112
114
  // first turn injects, leaving only the user-typed prompt visible
113
115
  // in the picker.
114
- const stripped = m.content.replace(/<session_context>[\s\S]*?<\/session_context>\s*/g, '').trim();
116
+ const stripped = text.replace(/<session_context>[\s\S]*?<\/session_context>\s*/g, '').trim();
115
117
  promptSnippet = (stripped || '<empty>').slice(0, 80);
116
118
  break;
117
119
  }
118
120
  }
119
- const userTurnCount = messages.filter((m) => m?.role === 'user' && typeof m.content === 'string').length;
121
+ const userTurnCount = messages.filter((m) => m && isUserPrompt(m)).length;
120
122
  results.push({
121
123
  id: parsed.id,
122
124
  startedAt: parsed.started_at,
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The prompt text of a user message: string content as-is, or the joined
3
+ * text blocks of array content that carries no tool_result. null for
4
+ * tool_result rounds, text-less arrays and non-user messages.
5
+ */
6
+ export function userPromptText(m) {
7
+ if (m.role !== 'user')
8
+ return null;
9
+ if (typeof m.content === 'string')
10
+ return m.content;
11
+ if (!Array.isArray(m.content))
12
+ return null;
13
+ const blocks = m.content;
14
+ if (blocks.some((b) => b?.type === 'tool_result'))
15
+ return null;
16
+ const texts = blocks
17
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
18
+ .map((b) => b.text);
19
+ return texts.length > 0 ? texts.join('\n') : null;
20
+ }
21
+ export function isUserPrompt(m) {
22
+ return userPromptText(m) !== null;
23
+ }
@@ -1,6 +1,6 @@
1
1
  // GENERATED FILE — bundled skills for local mode.
2
2
  // Source: https://manifest.cspeach.dev/v1.json
3
- // Generated: 2026-09-23T17:28:19.737Z
3
+ // Generated: 2026-09-24T14:23:23.324Z
4
4
  // Manifest signature verified at build time.
5
5
  export const BUNDLED_SKILLS = {
6
6
  "abap-atc-fix": {
@@ -0,0 +1,125 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { cspeachRoot } from '../config/paths.js';
5
+ let pasteDir = null;
6
+ let nextNumber = 1;
7
+ const pasted = new Map();
8
+ function dir() {
9
+ if (!pasteDir)
10
+ pasteDir = path.join(cspeachRoot(), 'pasted', `${Date.now()}-${process.pid}`);
11
+ return pasteDir;
12
+ }
13
+ /** Test seam: point the paste folder at a temp dir and reset numbering. */
14
+ export function __resetPastedImagesForTests(testDir) {
15
+ pasteDir = testDir;
16
+ nextNumber = 1;
17
+ pasted.clear();
18
+ }
19
+ /**
20
+ * Save the clipboard image and return its chip, or null when the clipboard
21
+ * holds no image (or the platform tool is missing / fails). Never throws —
22
+ * a paste key must never break the prompt.
23
+ */
24
+ export async function pasteClipboardImage(reader = readClipboardImage) {
25
+ const n = nextNumber;
26
+ const out = path.join(dir(), `image-${n}.png`);
27
+ try {
28
+ await fs.mkdir(dir(), { recursive: true });
29
+ if (!(await reader(out)))
30
+ return null;
31
+ const st = await fs.stat(out);
32
+ if (st.size === 0)
33
+ return null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ nextNumber = n + 1;
39
+ pasted.set(n, out);
40
+ return `[Image #${n}]`;
41
+ }
42
+ /** Swap each known `[Image #N]` chip for its quoted file path. */
43
+ export function expandImageChips(text) {
44
+ if (pasted.size === 0)
45
+ return text;
46
+ return text.replace(/\[Image #(\d+)\]/g, (chip, num) => {
47
+ const file = pasted.get(Number(num));
48
+ return file ? `"${file}"` : chip;
49
+ });
50
+ }
51
+ /**
52
+ * The platform command that writes the clipboard image to `outPath`.
53
+ * Windows and macOS write the file themselves and print "ok"; Linux tools
54
+ * print the PNG bytes to stdout (`stdoutToFile`). null = unsupported.
55
+ */
56
+ export function clipboardImageCommand(platform, outPath, env) {
57
+ if (platform === 'win32') {
58
+ const p = outPath.replace(/'/g, "''");
59
+ const script = 'Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; ' +
60
+ '$i = [System.Windows.Forms.Clipboard]::GetImage(); ' +
61
+ `if ($i -ne $null) { $i.Save('${p}', [System.Drawing.Imaging.ImageFormat]::Png); 'ok' }`;
62
+ return { cmd: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-STA', '-Command', script] };
63
+ }
64
+ if (platform === 'darwin') {
65
+ const p = outPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
66
+ return {
67
+ cmd: 'osascript',
68
+ args: [
69
+ '-e', 'try',
70
+ '-e', 'set d to the clipboard as «class PNGf»',
71
+ '-e', `set f to open for access POSIX file "${p}" with write permission`,
72
+ '-e', 'write d to f',
73
+ '-e', 'close access f',
74
+ '-e', 'return "ok"',
75
+ '-e', 'on error',
76
+ '-e', 'return "none"',
77
+ '-e', 'end try',
78
+ ],
79
+ };
80
+ }
81
+ if (platform === 'linux') {
82
+ return env.WAYLAND_DISPLAY
83
+ ? { cmd: 'wl-paste', args: ['--no-newline', '--type', 'image/png'], stdoutToFile: true }
84
+ : { cmd: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/png', '-o'], stdoutToFile: true };
85
+ }
86
+ return null;
87
+ }
88
+ /**
89
+ * Default reader: runs the platform command. stdin is CLOSED — Windows
90
+ * PowerShell 5.1 waits on an open stdin pipe and never exits (seen live
91
+ * 2026-09-23). 10 s ceiling covers a cold PowerShell start (~3 s; warm
92
+ * ~0.35 s).
93
+ */
94
+ export async function readClipboardImage(outPath) {
95
+ const c = clipboardImageCommand(process.platform, outPath, process.env);
96
+ if (!c)
97
+ return false;
98
+ const stdout = await new Promise((resolve, reject) => {
99
+ const child = spawn(c.cmd, c.args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
100
+ const chunks = [];
101
+ const timer = setTimeout(() => {
102
+ child.kill();
103
+ reject(new Error('clipboard read timed out'));
104
+ }, 10_000);
105
+ child.stdout.on('data', (d) => chunks.push(d));
106
+ child.on('error', (err) => {
107
+ clearTimeout(timer);
108
+ reject(err);
109
+ });
110
+ child.on('close', (code) => {
111
+ clearTimeout(timer);
112
+ if (code === 0)
113
+ resolve(Buffer.concat(chunks));
114
+ else
115
+ reject(new Error(`${c.cmd} exited ${code}`));
116
+ });
117
+ });
118
+ if (c.stdoutToFile) {
119
+ if (stdout.length === 0)
120
+ return false;
121
+ await fs.writeFile(outPath, stdout);
122
+ return true;
123
+ }
124
+ return stdout.toString('utf8').trim() === 'ok';
125
+ }
package/dist/ui/footer.js CHANGED
@@ -5,6 +5,7 @@ import { Box, Text, useInput, useStdout } from 'ink';
5
5
  // ink-text-input — multi-chunk pastes were losing every chunk but the
6
6
  // last (stale-prop closure in the upstream component). See text-input.tsx.
7
7
  import TextInput from './text-input.js';
8
+ import { pasteClipboardImage } from './clipboard-image.js';
8
9
  import Fuse from 'fuse.js';
9
10
  // Task 8 (ux-wave1) — persistence lives in the shared writer (one file,
10
11
  // one dedup guard for the Footer, Ink handleSubmit, and classic readline).
@@ -194,6 +195,15 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
194
195
  }
195
196
  onSubmit(line.trim());
196
197
  }, [onSubmit, onSlashTrigger, onHistoryTrigger, history, updateBuffer]);
198
+ // Claude Code-style image paste (2026-09-23): Ctrl+V / Alt+V saves the
199
+ // clipboard image and returns an [Image #N] chip; the turn loop swaps the
200
+ // chip for the file path before the image attachment step.
201
+ const handlePasteImage = useCallback(async () => {
202
+ const chip = await pasteClipboardImage();
203
+ if (!chip)
204
+ onNotice?.('no image on the clipboard');
205
+ return chip;
206
+ }, [onNotice]);
197
207
  if (fuzzyMode) {
198
208
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "History search (Ctrl+R)" }), _jsx(TextInput, { value: fuzzyQuery, onChange: setFuzzyQuery, placeholder: "type to search...", maxDisplayWidth: Math.max(8, columns - 5) }), fuzzyResults.map((r, i) => (_jsx(Text, { dimColor: i > 0, wrap: "truncate-end", children: flattenHistoryEntry(r) }, i))), _jsx(Text, { dimColor: true, children: "Esc: cancel Enter: select" })] }));
199
209
  }
@@ -205,5 +215,5 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
205
215
  if (disabled) {
206
216
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(Text, { dimColor: true, children: "(answer above to continue)" })] }));
207
217
  }
208
- return (_jsxs(Box, { flexDirection: "column", children: [buffer.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [buffer.length > HELD_LINES_SHOWN && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2026 ", buffer.length - HELD_LINES_SHOWN, " earlier line", buffer.length - HELD_LINES_SHOWN === 1 ? '' : 's'] })), buffer.slice(-HELD_LINES_SHOWN).map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['│ ', line] }, `held-${buffer.length}-${i}`))), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [buffer.length, " line", buffer.length === 1 ? '' : 's', " pending \u2014 Enter sends all, Esc discards, \\ continues"] })] })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit, onPasteLines: handlePasteLines, maxDisplayWidth: inputBudget })] })] }));
218
+ return (_jsxs(Box, { flexDirection: "column", children: [buffer.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [buffer.length > HELD_LINES_SHOWN && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2026 ", buffer.length - HELD_LINES_SHOWN, " earlier line", buffer.length - HELD_LINES_SHOWN === 1 ? '' : 's'] })), buffer.slice(-HELD_LINES_SHOWN).map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['│ ', line] }, `held-${buffer.length}-${i}`))), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [buffer.length, " line", buffer.length === 1 ? '' : 's', " pending \u2014 Enter sends all, Esc discards, \\ continues"] })] })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit, onPasteLines: handlePasteLines, onPasteImage: handlePasteImage, maxDisplayWidth: inputBudget })] })] }));
209
219
  }
@@ -27,6 +27,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
27
27
  import { useState, useEffect, useRef } from 'react';
28
28
  import { Text, useInput } from 'ink';
29
29
  import chalk from 'chalk';
30
+ /** An image chip ending exactly at the cursor — Backspace removes it whole. */
31
+ const CHIP_BEFORE_CURSOR = /\[Image #\d+\]$/;
30
32
  /**
31
33
  * Pure windowing core (exported for tests). Given the value length, the
32
34
  * cursor position, the display budget (character cells) and whether the
@@ -48,7 +50,7 @@ export function computeDisplayWindow(valueLength, cursorOffset, budget, cursorVi
48
50
  const end = Math.min(valueLength, start + budget);
49
51
  return { start, end };
50
52
  }
51
- export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, onPasteLines, maxDisplayWidth, }) {
53
+ export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, onPasteLines, maxDisplayWidth, onPasteImage, }) {
52
54
  const [cursorOffset, setCursorOffset] = useState((originalValue || '').length);
53
55
  // The paste-safety core: synchronous mirrors of value + cursor. The
54
56
  // input handler reads/writes THESE, never the (possibly stale) props.
@@ -129,6 +131,23 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
129
131
  (key.shift && key.tab)) {
130
132
  return;
131
133
  }
134
+ if (onPasteImage && input === 'v' && (key.ctrl || key.meta)) {
135
+ void onPasteImage().then((chip) => {
136
+ if (!chip)
137
+ return;
138
+ // Resolves after the clipboard read — insert at the cursor as it
139
+ // is NOW, through the same synchronous mirrors as a keystroke.
140
+ const cur = valueRef.current;
141
+ const at = cursorRef.current;
142
+ const next = cur.slice(0, at) + chip + cur.slice(at);
143
+ valueRef.current = next;
144
+ cursorRef.current = at + chip.length;
145
+ setCursorOffset(at + chip.length);
146
+ pendingEchoes.current.push(next);
147
+ onChange(next);
148
+ });
149
+ return;
150
+ }
132
151
  if (key.return) {
133
152
  // valueRef (not the prop) — so an Enter in the same stdin drain as
134
153
  // the paste tail submits the complete line.
@@ -151,7 +170,16 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
151
170
  nextCursorOffset++;
152
171
  }
153
172
  else if (key.backspace || key.delete) {
154
- if (cursorRef.current > 0) {
173
+ const chip = onPasteImage
174
+ ? CHIP_BEFORE_CURSOR.exec(currentValue.slice(0, cursorRef.current))
175
+ : null;
176
+ if (chip) {
177
+ nextValue =
178
+ currentValue.slice(0, cursorRef.current - chip[0].length) +
179
+ currentValue.slice(cursorRef.current);
180
+ nextCursorOffset -= chip[0].length;
181
+ }
182
+ else if (cursorRef.current > 0) {
155
183
  nextValue =
156
184
  currentValue.slice(0, cursorRef.current - 1) +
157
185
  currentValue.slice(cursorRef.current, currentValue.length);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.15",
4
- "description": "CSPeach \u2014 AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
3
+ "version": "1.1.16",
4
+ "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",
7
7
  "sap",
@@ -32,7 +32,7 @@
32
32
  "prebuild": "tsx scripts/prebuild.ts",
33
33
  "build": "tsc && node scripts/make-bin-executable.js",
34
34
  "install:global": "tsx scripts/install-global.ts",
35
- "pretest": "tsx scripts/sync-signing-key.ts || node -e \"console.warn('WARN: sync-signing-key skipped \u2014 using stub')\"",
35
+ "pretest": "tsx scripts/sync-signing-key.ts || node -e \"console.warn('WARN: sync-signing-key skipped — using stub')\"",
36
36
  "start": "node dist/index.js",
37
37
  "typecheck": "tsc --noEmit",
38
38
  "test": "vitest run",
@@ -44,7 +44,7 @@
44
44
  "bench:audit": "tsx scripts/bench-audit-task.ts",
45
45
  "fiori": "tsx src/tools/fiori/bin.ts",
46
46
  "prepare": "husky",
47
- "prepublishOnly": "node -e \"const deps=require('./package.json').dependencies||{}; for (const n of ['@cspeach/sap-client','@cspeach/register-core']) { const d=deps[n]||''; if(d.startsWith('file:')||d.startsWith('link:')){console.error('publish blocked: '+n+' is a local link ('+d+') \u2014 point it at the published version before npm publish'); process.exit(1)} }\""
47
+ "prepublishOnly": "node -e \"const deps=require('./package.json').dependencies||{}; for (const n of ['@cspeach/sap-client','@cspeach/register-core']) { const d=deps[n]||''; if(d.startsWith('file:')||d.startsWith('link:')){console.error('publish blocked: '+n+' is a local link ('+d+') — point it at the published version before npm publish'); process.exit(1)} }\""
48
48
  },
49
49
  "engines": {
50
50
  "node": ">=20.0.0"