@cspeach/cli 1.1.15 → 1.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +41 -8
- package/dist/agent/repair-partial.js +51 -0
- package/dist/agent/tool-dispatch.js +13 -9
- package/dist/agent/turn-error-ux.js +8 -0
- package/dist/commands/compact.js +6 -2
- package/dist/commands/project-context-impact.js +2 -2
- package/dist/one-shot.js +2 -0
- package/dist/projects/image-attachments.js +155 -0
- package/dist/repl/post-turn-status.js +2 -1
- package/dist/repl.js +19 -5
- package/dist/router/classifier.js +24 -4
- package/dist/session/recap.js +8 -5
- package/dist/session/resume.js +37 -17
- package/dist/session/store.js +5 -3
- package/dist/session/user-prompt.js +23 -0
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/ui/clipboard-image.js +125 -0
- package/dist/ui/footer.js +11 -1
- package/dist/ui/text-input.js +30 -2
- package/package.json +4 -4
package/dist/agent/loop.js
CHANGED
|
@@ -30,12 +30,14 @@ 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';
|
|
36
38
|
import { composeSessionContextBlock } from './session-context.js';
|
|
37
39
|
import { objectKeyFromInput } from './retry-key.js';
|
|
38
|
-
import { repairPartialBlocks, healSessionMessagesInPlace, isPartialJsonApiError } from './repair-partial.js';
|
|
40
|
+
import { repairPartialBlocks, healSessionMessagesInPlace, healOrphanToolUses, isPartialJsonApiError } from './repair-partial.js';
|
|
39
41
|
import { drainSteering } from './steering-queue.js';
|
|
40
42
|
import { TurnStreamWriter } from './turn-stream.js';
|
|
41
43
|
import { applyToolResultCheckpoint } from './skill-checkpoint.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
|
-
*
|
|
108
|
-
*
|
|
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
|
|
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
|
-
|
|
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).
|
|
@@ -540,6 +565,14 @@ export async function runTurn(params) {
|
|
|
540
565
|
// the single chokepoint every request passes through, retries included —
|
|
541
566
|
// makes the 400 categorically impossible. Idempotent and O(messages).
|
|
542
567
|
healSessionMessagesInPlace(session.messages);
|
|
568
|
+
// 2026-09-24 — same chokepoint: a tool call cut off before its
|
|
569
|
+
// result was recorded 400s every later request (a customer's
|
|
570
|
+
// `continue` could never succeed). Give each orphan an error result
|
|
571
|
+
// so the model sees the call did not complete and redoes it.
|
|
572
|
+
const healedOrphans = healOrphanToolUses(session.messages);
|
|
573
|
+
if (healedOrphans > 0) {
|
|
574
|
+
emit(chalk.dim(`(repaired ${healedOrphans} tool call(s) cut off by an interruption — the model will redo them)`));
|
|
575
|
+
}
|
|
543
576
|
const streamParams = {
|
|
544
577
|
// A4 — per-turn override (plan model tiering) wins over the
|
|
545
578
|
// configured default; absent on every non-plan turn. model-governance
|
|
@@ -116,3 +116,54 @@ export function isPartialJsonApiError(err) {
|
|
|
116
116
|
&& s.includes('partial_json')
|
|
117
117
|
&& /not permitted|Extra inputs/i.test(s));
|
|
118
118
|
}
|
|
119
|
+
/** Result text for a tool call whose real result was lost to an interruption. */
|
|
120
|
+
export const INTERRUPTED_TOOL_RESULT = 'This tool call did not complete — the turn was interrupted before its result was recorded. ' +
|
|
121
|
+
'Its effect is unknown: check before relying on it, and re-run it if needed.';
|
|
122
|
+
/**
|
|
123
|
+
* 2026-09-24 — heal tool_use blocks that have no tool_result after them.
|
|
124
|
+
*
|
|
125
|
+
* A turn cut after the model streamed a tool_use but before the result was
|
|
126
|
+
* recorded leaves a history the API rejects on EVERY later request
|
|
127
|
+
* ("messages.N: `tool_use` ids were found without `tool_result` blocks
|
|
128
|
+
* immediately after") — a customer's `continue` could never succeed. Each
|
|
129
|
+
* orphan gets a synthetic error result, placed where the API requires it: at
|
|
130
|
+
* the front of the next user message (created if the next message is not a
|
|
131
|
+
* user message). Mutates `messages`; returns the number of results added.
|
|
132
|
+
* Idempotent; a no-op on a healthy conversation.
|
|
133
|
+
*/
|
|
134
|
+
export function healOrphanToolUses(messages) {
|
|
135
|
+
if (!messages)
|
|
136
|
+
return 0;
|
|
137
|
+
let added = 0;
|
|
138
|
+
for (let i = 0; i < messages.length; i++) {
|
|
139
|
+
const msg = messages[i];
|
|
140
|
+
if (msg?.role !== 'assistant' || !Array.isArray(msg.content))
|
|
141
|
+
continue;
|
|
142
|
+
const ids = msg.content
|
|
143
|
+
.filter((b) => b?.type === 'tool_use' && typeof b.id === 'string')
|
|
144
|
+
.map((b) => b.id);
|
|
145
|
+
if (ids.length === 0)
|
|
146
|
+
continue;
|
|
147
|
+
let next = messages[i + 1];
|
|
148
|
+
if (next?.role !== 'user') {
|
|
149
|
+
next = { role: 'user', content: [] };
|
|
150
|
+
messages.splice(i + 1, 0, next);
|
|
151
|
+
}
|
|
152
|
+
else if (!Array.isArray(next.content)) {
|
|
153
|
+
next.content = [{ type: 'text', text: String(next.content ?? '') }];
|
|
154
|
+
}
|
|
155
|
+
const have = new Set(next.content.filter((b) => b?.type === 'tool_result').map((b) => b.tool_use_id));
|
|
156
|
+
const missing = ids
|
|
157
|
+
.filter((id) => !have.has(id))
|
|
158
|
+
.map((id) => ({ type: 'tool_result', tool_use_id: id, is_error: true, content: INTERRUPTED_TOOL_RESULT }));
|
|
159
|
+
if (missing.length === 0)
|
|
160
|
+
continue;
|
|
161
|
+
// tool_result blocks must lead the message: existing results, then the
|
|
162
|
+
// synthetic ones, then everything else (e.g. the user's "continue").
|
|
163
|
+
const results = next.content.filter((b) => b?.type === 'tool_result');
|
|
164
|
+
const rest = next.content.filter((b) => b?.type !== 'tool_result');
|
|
165
|
+
next.content = [...results, ...missing, ...rest];
|
|
166
|
+
added += missing.length;
|
|
167
|
+
}
|
|
168
|
+
return added;
|
|
169
|
+
}
|
|
@@ -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
|
|
87
|
-
*
|
|
88
|
-
* Windows backslash form
|
|
89
|
-
*
|
|
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
|
-
|
|
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
|
}
|
|
@@ -39,6 +39,11 @@ export function describeError(err) {
|
|
|
39
39
|
}
|
|
40
40
|
if (msg.includes('ECONNRESET'))
|
|
41
41
|
return 'network connection reset';
|
|
42
|
+
// 2026-09-24 — the other shapes a dropped connection takes (our service
|
|
43
|
+
// restarting, a network blip): say it plainly instead of the raw text.
|
|
44
|
+
if (/fetch failed|socket hang up|other side closed|UND_ERR_SOCKET|ECONNREFUSED/i.test(msg)) {
|
|
45
|
+
return 'connection to CSPeach dropped';
|
|
46
|
+
}
|
|
42
47
|
if (msg.includes('ETIMEDOUT'))
|
|
43
48
|
return 'network timeout';
|
|
44
49
|
if (msg.toLowerCase().includes('aborterror'))
|
|
@@ -111,6 +116,9 @@ export async function renderTurnError(input) {
|
|
|
111
116
|
const reason = describeError(input.err);
|
|
112
117
|
const logPath = await writeErrorLog(input.session.id, input.err);
|
|
113
118
|
print(chalk.red(`⚠ Turn interrupted: ${reason}`));
|
|
119
|
+
if (input.inSession) {
|
|
120
|
+
print(chalk.yellow(` Your work so far is saved. Type continue to pick up where it stopped.`));
|
|
121
|
+
}
|
|
114
122
|
print(chalk.gray(` Session: ${input.session.id}`));
|
|
115
123
|
if (input.streamFilePath) {
|
|
116
124
|
print(chalk.gray(` Stream: ${input.streamFilePath}`));
|
package/dist/commands/compact.js
CHANGED
|
@@ -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
|
|
62
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
@@ -1376,11 +1380,18 @@ export async function runRepl(opts) {
|
|
|
1376
1380
|
});
|
|
1377
1381
|
}
|
|
1378
1382
|
catch (err) {
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1383
|
+
// 2026-09-24 — print the turn-error block INTO the conversation (it
|
|
1384
|
+
// stays on screen) instead of the overlay box that vanished after 5 s
|
|
1385
|
+
// — a customer's dropped turn left no trace of what happened or what
|
|
1386
|
+
// to do. Same block classic mode prints; in-session it leads with
|
|
1387
|
+
// "type continue" (the partial turn is already saved).
|
|
1388
|
+
const { renderTurnError } = await import('./agent/turn-error-ux.js');
|
|
1389
|
+
await renderTurnError({
|
|
1390
|
+
err,
|
|
1391
|
+
session,
|
|
1392
|
+
userInterrupted: currentTurnAbort?.signal.aborted ?? false,
|
|
1393
|
+
inSession: true,
|
|
1394
|
+
print: (line) => chunkEmitter.emit('chunk', line + '\n'),
|
|
1384
1395
|
});
|
|
1385
1396
|
}
|
|
1386
1397
|
finally {
|
|
@@ -2577,6 +2588,8 @@ export async function runRepl(opts) {
|
|
|
2577
2588
|
if (c.offline) {
|
|
2578
2589
|
console.log(chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
|
|
2579
2590
|
}
|
|
2591
|
+
if (c.screenshot)
|
|
2592
|
+
console.log(chalk.dim(`┄ Screenshot: ${c.screenshot}`));
|
|
2580
2593
|
const decision = decideRouting(c);
|
|
2581
2594
|
// 2026-05-17 — pass the just-used skill so the picker can
|
|
2582
2595
|
// default cursor to it. session.skill = immediately-previous
|
|
@@ -2936,6 +2949,7 @@ export async function runRepl(opts) {
|
|
|
2936
2949
|
err,
|
|
2937
2950
|
session,
|
|
2938
2951
|
userInterrupted: interruptedByUser,
|
|
2952
|
+
inSession: true,
|
|
2939
2953
|
print: (line) => console.error(line),
|
|
2940
2954
|
});
|
|
2941
2955
|
}
|
|
@@ -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
|
-
|
|
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) {
|
package/dist/session/recap.js
CHANGED
|
@@ -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" =
|
|
41
|
-
//
|
|
42
|
-
const
|
|
43
|
-
|
|
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 =
|
|
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)';
|
package/dist/session/resume.js
CHANGED
|
@@ -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:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
}
|
package/dist/session/store.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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-
|
|
3
|
+
// Generated: 2026-09-24T16:31:52.103Z
|
|
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
|
}
|
package/dist/ui/text-input.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
4
|
-
"description": "CSPeach
|
|
3
|
+
"version": "1.1.17",
|
|
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
|
|
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+')
|
|
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"
|