@cspeach/cli 1.1.19 → 1.1.21
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/anthropic-provider.js +30 -10
- package/dist/agent/cache-keepalive.js +162 -0
- package/dist/agent/cold-prune.js +116 -0
- package/dist/agent/loop.js +675 -148
- package/dist/agent/provider-shape.js +263 -0
- package/dist/agent/providers/ai-hub-provider.js +17 -2
- package/dist/agent/providers/byok-provider.js +33 -3
- package/dist/agent/providers/local-provider.js +8 -1
- package/dist/agent/repair-partial.js +66 -5
- package/dist/agent/summarise-via-provider.js +6 -1
- package/dist/agent/system-prompt.js +38 -0
- package/dist/agent/tool-dispatch.js +8 -0
- package/dist/agent/tool-loading-pin.js +100 -0
- package/dist/cli.js +11 -0
- package/dist/commands/auto-compact.js +33 -16
- package/dist/commands/compact.js +37 -2
- package/dist/commands/config-set.js +10 -1
- package/dist/commands/config-show.js +11 -0
- package/dist/commands/cost.js +14 -2
- package/dist/commands/plan-audit.js +1 -0
- package/dist/config/loader.js +55 -2
- package/dist/cost/cost-log.js +62 -2
- package/dist/cost/pricing.js +6 -2
- package/dist/lib/spill-labels.js +13 -0
- package/dist/models/resolve.js +93 -2
- package/dist/models/server-config.js +158 -3
- package/dist/one-shot.js +15 -5
- package/dist/projects/image-attachments.js +15 -2
- package/dist/renderer/footer-line.js +6 -2
- package/dist/renderer/startup-lines.js +5 -3
- package/dist/renderer/tool-labels.js +33 -2
- package/dist/renderer/ui-width.js +13 -0
- package/dist/repl/current-transport.js +13 -0
- package/dist/repl/post-turn-status.js +8 -1
- package/dist/repl.js +71 -10
- package/dist/session/repin-model.js +18 -0
- package/dist/session/store.js +16 -2
- package/dist/skills/bundled-skills.js +80 -80
- package/dist/skills/preamble.js +75 -0
- package/dist/skills/source-manifest.js +11 -1
- package/dist/tools/filesystem/file-read.js +11 -1
- package/dist/tools/result-spill.js +238 -0
- package/dist/tools/sap-read.js +58 -14
- package/dist/tools/shell/shell_exec.js +9 -0
- package/dist/tools/subagent/adt-serial.js +33 -0
- package/dist/tools/subagent/agent_run.js +2 -0
- package/dist/tools/subagent/read_agent.js +178 -0
- package/dist/tools/subagent/reader-prompt.js +48 -0
- package/dist/tools/todo.js +3 -1
- package/dist/tools/tool-loading.js +255 -0
- package/dist/tools/tool-output-read.js +117 -0
- package/dist/tools/transport.js +6 -1
- package/dist/ui/footer.js +5 -5
- package/dist/ui/sap-state-store.js +1 -1
- package/dist/ui/turn-status-emitter.js +37 -0
- package/dist/ui/turn-status.js +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read_agent — Task 18 (model control, 2026-09-27, spec D18).
|
|
3
|
+
*
|
|
4
|
+
* Heavy reading (many sources, a where-used walk, ATC-finding triage, a
|
|
5
|
+
* package inventory) runs in a lean child conversation and only a short
|
|
6
|
+
* report returns to the parent's history. Unlike agent_run (flag-gated, a
|
|
7
|
+
* full skill prefix per child), a reader carries:
|
|
8
|
+
* - a ~300-token system prompt owned by the proxy (skill header `_reader`,
|
|
9
|
+
* ruling F8; BYOK / AI-hub use the pinned copy in reader-prompt.ts),
|
|
10
|
+
* - a fixed small tool list (READER_TOOLS: read-only, no tool search),
|
|
11
|
+
* - the session model at effort `low`, role `reader` (never enforced, F7),
|
|
12
|
+
* - a capped report: overflow is saved to a file and the tail is cut.
|
|
13
|
+
*
|
|
14
|
+
* Read-only and depth 1 are enforced by `ctx.readerMode` at dispatch (F22).
|
|
15
|
+
* The loop runs consecutive read_agent calls concurrently, each with its own
|
|
16
|
+
* ctx clone whose `adt` is serialized through one lock (F21). The child's
|
|
17
|
+
* spend is added to the parent's per-turn `readerSpend` (F24); the child also
|
|
18
|
+
* writes its own reader-*-cost.jsonl.
|
|
19
|
+
*/
|
|
20
|
+
import { EventEmitter } from 'node:events';
|
|
21
|
+
import { listTools, registerTool, toolsForContext } from '../index.js';
|
|
22
|
+
import { runTurn } from '../../agent/loop.js';
|
|
23
|
+
import { collectTurnAssistantText } from '../../agent/turn-assistant-text.js';
|
|
24
|
+
import { newSession } from '../../session/schema.js';
|
|
25
|
+
import { computeSessionCost } from '../../cost/session-cost.js';
|
|
26
|
+
import { sessionIdOf, spillIfLarge } from '../result-spill.js';
|
|
27
|
+
import { INTERRUPTED_LINE, READER_SKILL, READER_SYSTEM_PROMPT, READER_TOOLS, hasReaderReadTools } from './reader-prompt.js';
|
|
28
|
+
export { INTERRUPTED_LINE, READER_SKILL, READER_SYSTEM_PROMPT, READER_TOOLS, hasReaderReadTools };
|
|
29
|
+
export const BRIEF_MAX_CHARS = 4_000;
|
|
30
|
+
export const DEFAULT_REPORT_MAX_CHARS = 3_000;
|
|
31
|
+
export const HARD_REPORT_MAX_CHARS = 6_000;
|
|
32
|
+
/**
|
|
33
|
+
* Final review tools-I4 — told to every reader. The proxy owns the reader's
|
|
34
|
+
* system prompt (pinned byte-equal), so the CLI says this in the child's
|
|
35
|
+
* user message.
|
|
36
|
+
*/
|
|
37
|
+
export const PARENT_OUTPUT_LINE = 'A saved-output path in this brief (a scan or ATC result the main session already ran) can be read with ' +
|
|
38
|
+
'tool_output_read. Read it instead of repeating the scan: never re-run ATC or a scan whose output you were given.';
|
|
39
|
+
/** The result when the account's proxy refuses the `_reader` skill. */
|
|
40
|
+
export const NOT_ENABLED_LINE = 'readers are not enabled for this account — read the sources directly';
|
|
41
|
+
function reportCap(raw) {
|
|
42
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 1)
|
|
43
|
+
return DEFAULT_REPORT_MAX_CHARS;
|
|
44
|
+
return Math.min(Math.floor(raw), HARD_REPORT_MAX_CHARS);
|
|
45
|
+
}
|
|
46
|
+
function isNotEntitled(err) {
|
|
47
|
+
const e = err;
|
|
48
|
+
if (e?.error?.error === 'skill_not_in_entitlements')
|
|
49
|
+
return true;
|
|
50
|
+
return typeof e?.message === 'string' && e.message.includes('skill_not_in_entitlements');
|
|
51
|
+
}
|
|
52
|
+
export async function readAgentHandler(args, ctx) {
|
|
53
|
+
const brief = args?.brief;
|
|
54
|
+
if (typeof brief !== 'string' || brief.trim().length === 0) {
|
|
55
|
+
return { content: "error: 'brief' is required: say what to read and what to report.", is_error: true };
|
|
56
|
+
}
|
|
57
|
+
if (brief.length > BRIEF_MAX_CHARS) {
|
|
58
|
+
return { content: `error: 'brief' is ${brief.length} chars; keep it under ${BRIEF_MAX_CHARS}.`, is_error: true };
|
|
59
|
+
}
|
|
60
|
+
// Depth 1 (dispatch refuses this too; belt and braces for direct calls).
|
|
61
|
+
if (ctx.readerMode) {
|
|
62
|
+
return { content: 'error: a reader cannot start another reader.', is_error: true };
|
|
63
|
+
}
|
|
64
|
+
// Fix M4 — an agent_run child does its own reading.
|
|
65
|
+
if (ctx.subagent) {
|
|
66
|
+
return { content: 'error: a subagent cannot start a reader — read the sources directly.', is_error: true };
|
|
67
|
+
}
|
|
68
|
+
// Fix M5 — nothing a reader could read here (standalone, file tools off).
|
|
69
|
+
if (!hasReaderReadTools(new Set(toolsForContext(listTools(), ctx).map((t) => t.name)))) {
|
|
70
|
+
return { content: 'error: no read tools are available to a reader here — read the sources directly.', is_error: true };
|
|
71
|
+
}
|
|
72
|
+
if (ctx.signal?.aborted)
|
|
73
|
+
return { content: INTERRUPTED_LINE, is_error: true };
|
|
74
|
+
if (!ctx.provider) {
|
|
75
|
+
return { content: 'error: read_agent requires ctx.provider — not wired into this ToolContext.', is_error: true };
|
|
76
|
+
}
|
|
77
|
+
const cap = reportCap(args.report_max_chars);
|
|
78
|
+
// The session model, never a cheaper one (owner decision 11); effort low.
|
|
79
|
+
const model = ctx.session.model;
|
|
80
|
+
const childSession = newSession(`reader-${Date.now()}-${Math.floor(Math.random() * 1e9).toString(16)}`, ctx.session.sap_system ?? null, READER_SKILL, model);
|
|
81
|
+
// Only what a read needs. No previewHook (no writes), no chunkEmitter /
|
|
82
|
+
// todoEmitter (the child never paints the parent UI), no pendingDispatch /
|
|
83
|
+
// currentTransport (the child never changes parent REPL state).
|
|
84
|
+
const parentSessionId = sessionIdOf(ctx);
|
|
85
|
+
const childCtx = {
|
|
86
|
+
adt: ctx.adt,
|
|
87
|
+
sapAlias: ctx.sapAlias,
|
|
88
|
+
session: childSession,
|
|
89
|
+
cwd: ctx.cwd,
|
|
90
|
+
provider: ctx.provider,
|
|
91
|
+
skillSource: ctx.skillSource,
|
|
92
|
+
readerMode: true,
|
|
93
|
+
// Final review tools-I4 — the reader may open the parent's saved outputs.
|
|
94
|
+
...(parentSessionId !== undefined ? { spillParentSessionId: parentSessionId } : {}),
|
|
95
|
+
};
|
|
96
|
+
const userMessage = `${brief}\n\n${PARENT_OUTPUT_LINE}\n\nReport limit: ${cap} characters.`;
|
|
97
|
+
let failure = null;
|
|
98
|
+
try {
|
|
99
|
+
await runTurn({
|
|
100
|
+
provider: ctx.provider,
|
|
101
|
+
userMessage,
|
|
102
|
+
skill: READER_SKILL,
|
|
103
|
+
ctx: childCtx,
|
|
104
|
+
// Null sink: the child's text lands in childSession.messages only.
|
|
105
|
+
chunkEmitter: new EventEmitter(),
|
|
106
|
+
modelRole: 'reader',
|
|
107
|
+
toolsOverride: READER_TOOLS,
|
|
108
|
+
effortOverride: 'low',
|
|
109
|
+
modelOverride: model,
|
|
110
|
+
suppressSaveHook: true,
|
|
111
|
+
// Fix I1 — Esc / Ctrl+C on the parent turn stops the reader's stream.
|
|
112
|
+
signal: ctx.signal,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
failure = err;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
// Spend counts whether or not the reader finished.
|
|
120
|
+
if (ctx.readerSpend) {
|
|
121
|
+
ctx.readerSpend.cost += computeSessionCost(childSession);
|
|
122
|
+
ctx.readerSpend.calls += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (failure !== null) {
|
|
126
|
+
if (ctx.signal?.aborted)
|
|
127
|
+
return { content: INTERRUPTED_LINE, is_error: true };
|
|
128
|
+
if (isNotEntitled(failure))
|
|
129
|
+
return { content: NOT_ENABLED_LINE, is_error: true };
|
|
130
|
+
const msg = failure instanceof Error ? failure.message : String(failure);
|
|
131
|
+
return { content: `error: the reader failed — ${msg.slice(0, 300)}`, is_error: true };
|
|
132
|
+
}
|
|
133
|
+
const text = collectTurnAssistantText(childSession.messages, 0).trim();
|
|
134
|
+
if (text.length === 0) {
|
|
135
|
+
return {
|
|
136
|
+
content: 'error: the reader returned no report — read the sources directly.',
|
|
137
|
+
is_error: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (text.length <= cap)
|
|
141
|
+
return { content: text };
|
|
142
|
+
// Over the cap: the full report goes to the PARENT's spill area under the
|
|
143
|
+
// parent's tool_use id, where tool_output_read can open it.
|
|
144
|
+
const head = text.slice(0, cap);
|
|
145
|
+
const sessionId = sessionIdOf(ctx);
|
|
146
|
+
const spilled = sessionId !== undefined
|
|
147
|
+
? spillIfLarge({ text, sessionId, toolUseId: ctx.toolUseId, kind: 'report', threshold: cap })
|
|
148
|
+
: null;
|
|
149
|
+
const where = spilled?.spilled ? ` — full report saved to ${spilled.path}` : '';
|
|
150
|
+
return { content: `${head}\n[report cut at ${cap} chars${where}]` };
|
|
151
|
+
}
|
|
152
|
+
registerTool({
|
|
153
|
+
name: 'read_agent',
|
|
154
|
+
description: 'Hand heavy reading to a lean read-only reader; only its short report comes back. Use it for more ' +
|
|
155
|
+
'than two source reads, a where-used scan, ATC-finding triage or a package inventory when the turn ' +
|
|
156
|
+
'will keep working afterwards. The reader has read-only SAP and file tools; it cannot write, ask the ' +
|
|
157
|
+
'user or start another reader. Give a precise brief: what to read and what to report. To hand over ' +
|
|
158
|
+
'a result you already have (ATC findings), put its saved-output path in the brief: the reader reads it ' +
|
|
159
|
+
'and never re-runs the scan. Send ' +
|
|
160
|
+
'independent readers in one response; they run at the same time.',
|
|
161
|
+
isMutating: false,
|
|
162
|
+
category: 'subagent',
|
|
163
|
+
input_schema: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
brief: {
|
|
167
|
+
type: 'string',
|
|
168
|
+
description: 'What to read (objects, includes, packages) and what to report. Max 4000 characters.',
|
|
169
|
+
},
|
|
170
|
+
report_max_chars: {
|
|
171
|
+
type: 'number',
|
|
172
|
+
description: 'Report length limit. Default 3000, max 6000. Longer reports are cut and saved to a file.',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
required: ['brief'],
|
|
176
|
+
},
|
|
177
|
+
handler: (args, ctx) => readAgentHandler(args, ctx),
|
|
178
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 18 (model control, 2026-09-27, spec D18, ruling F8) — the reader
|
|
3
|
+
* subagent's system prompt and tool list.
|
|
4
|
+
*
|
|
5
|
+
* The PROXY owns READER_SYSTEM_PROMPT (cspeach-proxy/src/skills/
|
|
6
|
+
* reader-prompt.ts): for the skill header `_reader` it sends that constant
|
|
7
|
+
* and ignores the client's `system`. This is the CLI's pinned copy, used only
|
|
8
|
+
* where the CLI talks to a model directly (BYOK, AI-hub, local).
|
|
9
|
+
* src/tools/__tests__/reader-prompt-pin.test.ts asserts the two template
|
|
10
|
+
* literals stay byte-equal — edit both files together.
|
|
11
|
+
*
|
|
12
|
+
* No imports on purpose: tool-dispatch.ts reads READER_TOOLS, and importing
|
|
13
|
+
* read_agent.ts there would close a cycle through agent/loop.ts.
|
|
14
|
+
*/
|
|
15
|
+
/** The skill header a reader's model calls carry. */
|
|
16
|
+
export const READER_SKILL = '_reader';
|
|
17
|
+
export const READER_SYSTEM_PROMPT = `You are a read-only investigator inside CSPeach. Read exactly what the brief asks for with the tools you have, then answer the brief. Report facts with object names, includes and line numbers. Say what you could not find. No recommendations unless the brief asks. Stay under the report limit.`;
|
|
18
|
+
/**
|
|
19
|
+
* The reader's fixed, small, static tool list (no deferred entries, no tool
|
|
20
|
+
* search): the ten read-only tools of spec D18 plus tool_output_read (Task
|
|
21
|
+
* 17), so a reader can open a result that was saved to a file. Every entry is
|
|
22
|
+
* `isMutating: false`. Flag-gated entries (file_read, grep, glob) reach the
|
|
23
|
+
* reader only when the session has them on, and standalone hides the SAP ones.
|
|
24
|
+
* tool-dispatch.ts refuses every other tool while `ctx.readerMode` is set.
|
|
25
|
+
*/
|
|
26
|
+
export const READER_TOOLS = Object.freeze([
|
|
27
|
+
'sap_get_source',
|
|
28
|
+
'sap_object_structure',
|
|
29
|
+
'sap_search_object',
|
|
30
|
+
'sap_sql_query',
|
|
31
|
+
'sap_usage_references',
|
|
32
|
+
'sap_class_includes',
|
|
33
|
+
'sap_atc_run',
|
|
34
|
+
'file_read',
|
|
35
|
+
'grep',
|
|
36
|
+
'glob',
|
|
37
|
+
'tool_output_read',
|
|
38
|
+
]);
|
|
39
|
+
/** Task 18 fix I1 — the result of a reader stopped by Esc / Ctrl+C (or never started). */
|
|
40
|
+
export const INTERRUPTED_LINE = 'interrupted: the reader was stopped before it finished';
|
|
41
|
+
/**
|
|
42
|
+
* Task 18 fix M5 — true when a reader would have at least one real read tool
|
|
43
|
+
* among `visibleNames` (tool_output_read alone reads nothing new: standalone
|
|
44
|
+
* with the file tools off). The loop hides read_agent otherwise.
|
|
45
|
+
*/
|
|
46
|
+
export function hasReaderReadTools(visibleNames) {
|
|
47
|
+
return READER_TOOLS.some((n) => n !== 'tool_output_read' && visibleNames.has(n));
|
|
48
|
+
}
|
package/dist/tools/todo.js
CHANGED
|
@@ -124,7 +124,9 @@ registerTool({
|
|
|
124
124
|
'in_progress at a time. The user sees this list — keep items short and outcome-shaped. ' +
|
|
125
125
|
'Update at EVERY step transition: when you finish a step, immediately call todo_set ' +
|
|
126
126
|
'marking it completed and the next step in_progress. Never batch several finished steps ' +
|
|
127
|
-
'into one later call — the user watches the ▶ indicator live.'
|
|
127
|
+
'into one later call — the user watches the ▶ indicator live. ' +
|
|
128
|
+
// Task 16 (cost profile lever 7): a lone todo_set round re-reads the whole context.
|
|
129
|
+
'Call todo_set in the same response as your next action, never as the only tool call in a response.',
|
|
128
130
|
isMutating: false,
|
|
129
131
|
category: 'session',
|
|
130
132
|
input_schema: {
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { toAnthropicTools } from './index.js';
|
|
2
|
+
/** The server-side tool search tool (probe P6: this exact name). */
|
|
3
|
+
export const TOOL_SEARCH_TOOL = Object.freeze({
|
|
4
|
+
type: 'tool_search_tool_regex_20251119',
|
|
5
|
+
name: 'tool_search_tool_regex',
|
|
6
|
+
});
|
|
7
|
+
/** Beta that allows `role: 'system'` tool_addition messages (and rides with deferred tools). */
|
|
8
|
+
export const TOOL_CHANGES_BETA = 'mid-conversation-tool-changes-2026-07-01';
|
|
9
|
+
/**
|
|
10
|
+
* Spec D11 — always loaded. Names missing from the list a build is given
|
|
11
|
+
* (flag-gated `agent_run` / file tools when their flag is off; every SAP tool
|
|
12
|
+
* in standalone) are skipped, never an error (ruling F25). `read_agent`
|
|
13
|
+
* (Task 18) is core because the preamble's heavy-reading rule names it.
|
|
14
|
+
*/
|
|
15
|
+
export const CORE_TOOLS = Object.freeze([
|
|
16
|
+
'ask_question',
|
|
17
|
+
'todo_set',
|
|
18
|
+
'request_approval',
|
|
19
|
+
'dispatch_skill',
|
|
20
|
+
'agent_run',
|
|
21
|
+
'read_agent',
|
|
22
|
+
'file_read',
|
|
23
|
+
'tool_output_read',
|
|
24
|
+
'file_write',
|
|
25
|
+
'shell_exec',
|
|
26
|
+
'sap_get_source',
|
|
27
|
+
'sap_search_object',
|
|
28
|
+
'sap_object_structure',
|
|
29
|
+
'sap_sql_query',
|
|
30
|
+
'sap_syntax_check',
|
|
31
|
+
'sap_set_source',
|
|
32
|
+
'sap_update_method',
|
|
33
|
+
'sap_activate',
|
|
34
|
+
'sap_inactive_objects',
|
|
35
|
+
'sap_create_object',
|
|
36
|
+
]);
|
|
37
|
+
const FIORI_FAMILY = [
|
|
38
|
+
'fiori_apply', 'fiori_deploy_config', 'fiori_draft_smoke', 'fiori_fe_extend',
|
|
39
|
+
'fiori_list', 'fiori_metadata_audit', 'fiori_preview', 'fiori_render_smoke',
|
|
40
|
+
'fiori_sample_get', 'fiori_sample_search', 'fiori_scaffold', 'fiori_scaffold_fe',
|
|
41
|
+
];
|
|
42
|
+
const TRANSPORT_FAMILY = [
|
|
43
|
+
'sap_transport_create', 'sap_transport_for_object', 'sap_transport_list', 'sap_transport_release',
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Per-skill starter tools: the non-core tools a skill's body names as its
|
|
47
|
+
* working set (counted from `.claude/skills/<skill>/SKILL.md`). A tool left
|
|
48
|
+
* out is still reachable through tool search — a starter only saves the
|
|
49
|
+
* search round. Skills not listed start with the core set alone.
|
|
50
|
+
*/
|
|
51
|
+
export const SKILL_STARTERS = Object.freeze({
|
|
52
|
+
'abap-fiori-build': FIORI_FAMILY,
|
|
53
|
+
'abap-transport': TRANSPORT_FAMILY,
|
|
54
|
+
'abap-transport-analysis': [...TRANSPORT_FAMILY, 'sap_atc_run', 'sap_usage_references'],
|
|
55
|
+
'abap-cap': ['fiori_scaffold_fe', 'fiori_render_smoke', 'fiori_draft_smoke'],
|
|
56
|
+
'abap-atc-fix': ['sap_atc_run'],
|
|
57
|
+
'abap-cca': ['sap_atc_run', 'sap_usage_references', 'sap_api_state'],
|
|
58
|
+
'abap-clean-core': ['sap_api_state', 'sap_atc_run', 'sap_usage_references'],
|
|
59
|
+
'abap-data-model': ['sap_create_domain', 'sap_create_data_element'],
|
|
60
|
+
'abap-document': ['sap_usage_references'],
|
|
61
|
+
'abap-handover': ['sap_usage_references'],
|
|
62
|
+
'abap-impact': ['sap_usage_references', 'sap_atc_run'],
|
|
63
|
+
'abap-performance': ['sap_usage_references', 'sap_atc_run'],
|
|
64
|
+
'abap-migrate': ['sap_usage_references'],
|
|
65
|
+
'abap-dump': ['sap_short_dump', 'sap_snapshot_take'],
|
|
66
|
+
'abap-incident': ['sap_short_dump', 'sap_snapshot_take', 'sap_atc_run', 'sap_run_unit_test', 'sap_api_state', 'sap_diff'],
|
|
67
|
+
'abap-eml': ['sap_run_unit_test'],
|
|
68
|
+
'abap-test': ['sap_run_unit_test', 'sap_set_class_include'],
|
|
69
|
+
'abap-extend-model': ['extend_model_insert', 'sap_snapshot_take', 'sap_service_binding_publish', 'sap_set_class_include'],
|
|
70
|
+
'abap-rap': ['sap_set_class_include', 'sap_service_binding_publish', 'sap_class_includes'],
|
|
71
|
+
'abap-modernize': ['sap_api_state', 'sap_snapshot_take', 'sap_service_binding_publish', 'sap_atc_run', 'sap_transport_create'],
|
|
72
|
+
'abap-segw': ['sap_snapshot_take', 'sap_odata_test'],
|
|
73
|
+
'abap-upgrade-fix': ['sap_snapshot_take', 'glob'],
|
|
74
|
+
'abap-upgrade-scan': ['sap_atc_run'],
|
|
75
|
+
'abap-upgrade-verify': ['sap_atc_run'],
|
|
76
|
+
});
|
|
77
|
+
/** The starter tools for a skill; unknown skill (or none) ⇒ []. */
|
|
78
|
+
export function startersForSkill(skill) {
|
|
79
|
+
if (!skill)
|
|
80
|
+
return [];
|
|
81
|
+
const s = SKILL_STARTERS[skill];
|
|
82
|
+
return s ? [...s] : [];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Final review C1 (controller ruling, 2026-09-28) — ALLOW-list of the model
|
|
86
|
+
* families the deferred shapes (defer_loading entries, the tool search tool,
|
|
87
|
+
* `role: 'system'` tool_addition messages) were probe-proven on (P6, D1, D3:
|
|
88
|
+
* claude-opus-5 and claude-opus-5-5). Same matching as the other allow-lists
|
|
89
|
+
* in agent/provider-shape.ts (lowercased, trailing -YYYYMMDD stripped). Every
|
|
90
|
+
* other model — claude-opus-4-8 (the session default until the global flip),
|
|
91
|
+
* the claude-sonnet-4-6 plan/audit tier, haiku — gets the static list. The
|
|
92
|
+
* loop decides by the model actually sent on each call.
|
|
93
|
+
*/
|
|
94
|
+
export const DEFERRED_TOOL_MODELS = new Set([
|
|
95
|
+
'claude-opus-5-5', 'claude-opus-5',
|
|
96
|
+
]);
|
|
97
|
+
export function modelAcceptsDeferredTools(model) {
|
|
98
|
+
if (typeof model !== 'string')
|
|
99
|
+
return false;
|
|
100
|
+
return DEFERRED_TOOL_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Mode precedence (ruling F13): AI-hub and local are always 'static' (their
|
|
104
|
+
* wire has no deferred loading). Otherwise env `CSPEACH_TOOL_LOADING`
|
|
105
|
+
* (`deferred` | `static`) wins; then managed uses the served value (default
|
|
106
|
+
* 'static': only a proxy that serves tool_loading takes the new shapes);
|
|
107
|
+
* BYOK uses the served value when it fetched one, else 'static'.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveToolLoading(i) {
|
|
110
|
+
if (i.providerMode === 'ai-hub' || i.providerMode === 'local')
|
|
111
|
+
return 'static';
|
|
112
|
+
const raw = i.env.CSPEACH_TOOL_LOADING?.trim().toLowerCase();
|
|
113
|
+
if (raw === 'deferred' || raw === 'static')
|
|
114
|
+
return raw;
|
|
115
|
+
// Task 21 fix round (controller ruling): an old proxy serves no tool_loading
|
|
116
|
+
// and adds none of the deferred-tool betas, so 'deferred' only when served.
|
|
117
|
+
if (i.providerMode === 'managed')
|
|
118
|
+
return i.served ?? 'static';
|
|
119
|
+
if (i.providerMode === 'byok')
|
|
120
|
+
return i.served ?? 'static';
|
|
121
|
+
// Unknown/fake provider modes keep today's full list.
|
|
122
|
+
return 'static';
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Build the `tools[]` array. 'static' ⇒ exactly today's list
|
|
126
|
+
* (toAnthropicTools). 'deferred' ⇒ loaded entries (core ∪ starters, in
|
|
127
|
+
* registry order, byte-identical to today's entries), then every other tool
|
|
128
|
+
* with `defer_loading: true` (registry order), then the tool search tool —
|
|
129
|
+
* the search tool only when at least one entry is deferred.
|
|
130
|
+
*/
|
|
131
|
+
export function buildToolList(tools, opts) {
|
|
132
|
+
if (opts.mode === 'static')
|
|
133
|
+
return toAnthropicTools(tools);
|
|
134
|
+
const loadedNames = new Set([...CORE_TOOLS, ...startersForSkill(opts.skill)]);
|
|
135
|
+
const loaded = tools.filter((t) => loadedNames.has(t.name));
|
|
136
|
+
const deferred = tools.filter((t) => !loadedNames.has(t.name));
|
|
137
|
+
// Final review I1 — nothing deferred ⇒ nothing to search: no search tool
|
|
138
|
+
// (plan audit's `toolFilter: () => false` means "no tools at all" ⇒ []).
|
|
139
|
+
if (deferred.length === 0)
|
|
140
|
+
return toAnthropicTools(loaded);
|
|
141
|
+
return [
|
|
142
|
+
...toAnthropicTools(loaded),
|
|
143
|
+
...toAnthropicTools(deferred).map((t) => ({ ...t, defer_loading: true })),
|
|
144
|
+
{ ...TOOL_SEARCH_TOOL },
|
|
145
|
+
];
|
|
146
|
+
}
|
|
147
|
+
/** Names of the entries a built list declares with `defer_loading: true`. */
|
|
148
|
+
export function deferredNames(toolList) {
|
|
149
|
+
const out = new Set();
|
|
150
|
+
for (const t of toolList) {
|
|
151
|
+
if (t && t.defer_loading === true && typeof t.name === 'string')
|
|
152
|
+
out.add(t.name);
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
/** True for the tool search entry of a built list (the one predicate; provider-shape re-exports it). */
|
|
157
|
+
export function isToolSearchEntry(t) {
|
|
158
|
+
return !!t && typeof t === 'object' && t.type === TOOL_SEARCH_TOOL.type;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Probe D2 (2026-09-27): the API rejects a `role: 'system'` message followed
|
|
162
|
+
* by a user message — "role 'system' must precede an 'assistant' message or
|
|
163
|
+
* end the array" (400). A tool_addition pushed after a tool-result message
|
|
164
|
+
* can be followed by a user message when the next round adds one first (mid-
|
|
165
|
+
* turn steering, the watchdog nudge) or when the turn ended before the model
|
|
166
|
+
* answered (refusal, interruption) and the next turn's prompt is pushed, and
|
|
167
|
+
* a later skill switch can then append a second tool_addition.
|
|
168
|
+
*
|
|
169
|
+
* Only the TAIL after the last assistant message is ever touched (the whole
|
|
170
|
+
* array when there is none): it holds only user and system messages, none of
|
|
171
|
+
* which an assistant message has followed yet, so no thinking block's prefix
|
|
172
|
+
* changes (at most the uncached tail is re-written). The tail is stably
|
|
173
|
+
* partitioned: user messages first (in order), then the system messages'
|
|
174
|
+
* blocks (in order) merged into ONE system message that ends the array — the
|
|
175
|
+
* shape probe D3 proved, its next round included. Merging avoids a
|
|
176
|
+
* [system, system] run, whose acceptance was never probed; one system message
|
|
177
|
+
* with several tool_addition blocks is the shape the CLI already sends for a
|
|
178
|
+
* skill's starters. Anything before the last assistant message is left
|
|
179
|
+
* exactly as it is. Mutates `messages`; returns the number of system messages
|
|
180
|
+
* moved or merged. Idempotent: a second pass is a no-op.
|
|
181
|
+
*/
|
|
182
|
+
export function placeSystemMessagesInPlace(messages) {
|
|
183
|
+
if (!messages)
|
|
184
|
+
return 0;
|
|
185
|
+
let tailStart = messages.length;
|
|
186
|
+
while (tailStart > 0 && messages[tailStart - 1]?.role !== 'assistant')
|
|
187
|
+
tailStart--;
|
|
188
|
+
const tail = messages.slice(tailStart);
|
|
189
|
+
const users = tail.filter((m) => m?.role !== 'system');
|
|
190
|
+
const systems = tail.filter((m) => m?.role === 'system');
|
|
191
|
+
if (systems.length === 0)
|
|
192
|
+
return 0;
|
|
193
|
+
// Already in shape: users, then exactly one system message last.
|
|
194
|
+
if (systems.length === 1 && tail[tail.length - 1] === systems[0])
|
|
195
|
+
return 0;
|
|
196
|
+
const merged = {
|
|
197
|
+
role: 'system',
|
|
198
|
+
content: systems.flatMap((m) => (Array.isArray(m.content) ? m.content : [])),
|
|
199
|
+
};
|
|
200
|
+
messages.splice(tailStart, tail.length, ...users, merged);
|
|
201
|
+
return systems.length;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A `role: 'system'` message surfacing deferred tools by reference. Names not
|
|
205
|
+
* in `deferredSet` (loaded already, or unknown in this build) are dropped;
|
|
206
|
+
* nothing left ⇒ null.
|
|
207
|
+
*/
|
|
208
|
+
export function buildToolAdditionMessage(names, deferredSet) {
|
|
209
|
+
const keep = [...new Set(names)].filter((n) => deferredSet.has(n));
|
|
210
|
+
if (keep.length === 0)
|
|
211
|
+
return null;
|
|
212
|
+
return {
|
|
213
|
+
role: 'system',
|
|
214
|
+
content: keep.map((name) => ({ type: 'tool_addition', tool: { type: 'tool_reference', name } })),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Integration finding N1 (c) — the tool_addition for a skill switch: the
|
|
219
|
+
* skill's starters that the CURRENT mode's list (`toolList`, the list the
|
|
220
|
+
* next request carries) declares deferred, minus those the conversation has
|
|
221
|
+
* already surfaced. A session pinned 'static' never gets one, whatever list
|
|
222
|
+
* the caller holds (a static request cannot carry a `role: 'system'`
|
|
223
|
+
* tool_addition). Nothing to add ⇒ null.
|
|
224
|
+
*/
|
|
225
|
+
export function buildSkillToolAddition(i) {
|
|
226
|
+
if (i.toolLoading === 'static')
|
|
227
|
+
return null;
|
|
228
|
+
const surfaced = surfacedToolNames(i.messages);
|
|
229
|
+
return buildToolAdditionMessage(startersForSkill(i.skill).filter((n) => !surfaced.has(n)), deferredNames(i.toolList));
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Tool names already surfaced in a conversation: every `tool_addition`
|
|
233
|
+
* reference (`tool.name`) and every search-result reference (`tool_name`).
|
|
234
|
+
* Used so a skill switch never re-adds a tool.
|
|
235
|
+
*/
|
|
236
|
+
export function surfacedToolNames(messages) {
|
|
237
|
+
const out = new Set();
|
|
238
|
+
for (const m of messages ?? []) {
|
|
239
|
+
if (!Array.isArray(m?.content))
|
|
240
|
+
continue;
|
|
241
|
+
for (const b of m.content) {
|
|
242
|
+
if (b?.type === 'tool_addition' && typeof b.tool?.name === 'string')
|
|
243
|
+
out.add(b.tool.name);
|
|
244
|
+
if (b?.type === 'tool_search_tool_result') {
|
|
245
|
+
const refs = b.content?.tool_references;
|
|
246
|
+
if (Array.isArray(refs)) {
|
|
247
|
+
for (const r of refs)
|
|
248
|
+
if (typeof r?.tool_name === 'string')
|
|
249
|
+
out.add(r.tool_name);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool_output_read — Task 17 ruling (2026-09-27).
|
|
3
|
+
*
|
|
4
|
+
* Large tool results are saved to ~/.cspeach/streams/<sessionId>/tool-<id>.txt
|
|
5
|
+
* (result-spill.ts) and the history keeps a preview naming this tool. It is
|
|
6
|
+
* always registered (not flag-gated — file_read is off in a plain SAP session),
|
|
7
|
+
* read-only, in CORE_TOOLS, and reads ONLY the current session's spill files
|
|
8
|
+
* (a reader child: also its parent session's — final review tools-I4): the
|
|
9
|
+
* path must be a tool-*.txt directly inside one of those spill dirs, and
|
|
10
|
+
* it is re-checked after realpath (a symlink cannot point it elsewhere).
|
|
11
|
+
* Anything else — including a ctx with no session id — is an error result.
|
|
12
|
+
* Its own result is never spilled; instead it has a char budget.
|
|
13
|
+
*/
|
|
14
|
+
import { promises as fsp } from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { registerTool } from './index.js';
|
|
17
|
+
import { spillDirFor, sessionIdOf, SPILL_FILE_RE, NUMBERED_SPILL_RE, SPILL_MAX_AGE_DAYS, withRealpath, } from './result-spill.js';
|
|
18
|
+
const MAX_LINES = 2000;
|
|
19
|
+
/** Total characters one call returns (fix round 1, minor 2). */
|
|
20
|
+
export const TOOL_OUTPUT_READ_MAX_CHARS = 40_000;
|
|
21
|
+
/** One returned line never carries more than this. */
|
|
22
|
+
export const TOOL_OUTPUT_READ_LINE_MAX_CHARS = 2_000;
|
|
23
|
+
const REFUSED = 'not a saved tool output of this session';
|
|
24
|
+
const err = (msg) => ({ content: `error: ${msg}`, is_error: true });
|
|
25
|
+
/** Case-insensitive file systems (win32, darwin default) compare paths case-insensitively. */
|
|
26
|
+
const norm = (p) => (process.platform === 'win32' || process.platform === 'darwin' ? p.toLowerCase() : p);
|
|
27
|
+
/** The spill file itself must sit directly in one of the session-dir spellings. */
|
|
28
|
+
function directlyIn(dirs, file) {
|
|
29
|
+
const parent = norm(path.dirname(file));
|
|
30
|
+
return dirs.some((d) => norm(d) === parent);
|
|
31
|
+
}
|
|
32
|
+
function clip(line) {
|
|
33
|
+
return line.length <= TOOL_OUTPUT_READ_LINE_MAX_CHARS
|
|
34
|
+
? line
|
|
35
|
+
: `${line.slice(0, TOOL_OUTPUT_READ_LINE_MAX_CHARS)}… [+${line.length - TOOL_OUTPUT_READ_LINE_MAX_CHARS} chars]`;
|
|
36
|
+
}
|
|
37
|
+
export async function toolOutputReadHandler(args, ctx) {
|
|
38
|
+
const sessionId = sessionIdOf(ctx);
|
|
39
|
+
if (sessionId === undefined)
|
|
40
|
+
return err(REFUSED);
|
|
41
|
+
const p = typeof args.path === 'string' ? args.path : '';
|
|
42
|
+
if (p.length === 0 || p.includes('\0') || !path.isAbsolute(p)) {
|
|
43
|
+
return err('path must be the absolute spill-file path shown in the tool result');
|
|
44
|
+
}
|
|
45
|
+
const offset = args.offset === undefined ? 0 : Number(args.offset);
|
|
46
|
+
const limitArg = args.limit === undefined ? MAX_LINES : Number(args.limit);
|
|
47
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
48
|
+
return err(`offset must be a non-negative integer (got ${String(args.offset)})`);
|
|
49
|
+
if (!Number.isInteger(limitArg) || limitArg < 1)
|
|
50
|
+
return err(`limit must be a positive integer (got ${String(args.limit)})`);
|
|
51
|
+
const limit = Math.min(limitArg, MAX_LINES);
|
|
52
|
+
// Final review tools-I4 — a reader child may also read its parent's saved
|
|
53
|
+
// outputs: that one dir only, and only on a reader ctx.
|
|
54
|
+
const parentId = ctx.readerMode && typeof ctx.spillParentSessionId === 'string' && ctx.spillParentSessionId.length > 0
|
|
55
|
+
? ctx.spillParentSessionId
|
|
56
|
+
: undefined;
|
|
57
|
+
const dirs = [
|
|
58
|
+
...withRealpath(spillDirFor(sessionId)),
|
|
59
|
+
...(parentId !== undefined ? withRealpath(spillDirFor(parentId)) : []),
|
|
60
|
+
];
|
|
61
|
+
const abs = path.resolve(p);
|
|
62
|
+
if (!SPILL_FILE_RE.test(path.basename(abs)) || !directlyIn(dirs, abs))
|
|
63
|
+
return err(REFUSED);
|
|
64
|
+
let real;
|
|
65
|
+
try {
|
|
66
|
+
real = await fsp.realpath(abs);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
return err(e.code === 'ENOENT'
|
|
70
|
+
? `saved output not found (saved outputs are kept ${SPILL_MAX_AGE_DAYS} days)`
|
|
71
|
+
: 'cannot read saved output');
|
|
72
|
+
}
|
|
73
|
+
if (!SPILL_FILE_RE.test(path.basename(real)) || !directlyIn(dirs, real))
|
|
74
|
+
return err(REFUSED);
|
|
75
|
+
let raw;
|
|
76
|
+
try {
|
|
77
|
+
raw = await fsp.readFile(real, 'utf-8');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return err('cannot read saved output');
|
|
81
|
+
}
|
|
82
|
+
// A `.n.txt` spill (file_read) already carries the file's line numbers.
|
|
83
|
+
const numberedAlready = NUMBERED_SPILL_RE.test(real);
|
|
84
|
+
const all = raw.replace(/\n$/, '').split('\n');
|
|
85
|
+
const end = Math.min(all.length, offset + limit);
|
|
86
|
+
const out = [];
|
|
87
|
+
let used = 0;
|
|
88
|
+
let next = offset;
|
|
89
|
+
for (let i = offset; i < end; i++) {
|
|
90
|
+
const line = clip(numberedAlready ? all[i] : `${i + 1}\t${all[i]}`);
|
|
91
|
+
if (out.length > 0 && used + line.length + 1 > TOOL_OUTPUT_READ_MAX_CHARS)
|
|
92
|
+
break;
|
|
93
|
+
out.push(line);
|
|
94
|
+
used += line.length + 1;
|
|
95
|
+
next = i + 1;
|
|
96
|
+
}
|
|
97
|
+
const rest = all.length - next;
|
|
98
|
+
return { content: out.join('\n') + (rest > 0 ? `\n... (${rest} more lines — next offset=${next})` : '') };
|
|
99
|
+
}
|
|
100
|
+
registerTool({
|
|
101
|
+
name: 'tool_output_read',
|
|
102
|
+
description: 'Read a slice of a large tool result that was saved to a file (the result names the path). '
|
|
103
|
+
+ 'Returns numbered lines. offset is 0-based; limit defaults to and caps at 2000 lines (40 000 chars). '
|
|
104
|
+
+ 'Only this session\'s saved outputs can be read.',
|
|
105
|
+
isMutating: false,
|
|
106
|
+
category: 'filesystem',
|
|
107
|
+
input_schema: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
properties: {
|
|
110
|
+
path: { type: 'string', description: 'The saved-output path from the tool result.' },
|
|
111
|
+
offset: { type: 'number', description: 'Optional 0-based line offset.' },
|
|
112
|
+
limit: { type: 'number', description: 'Optional max lines (cap 2000).' },
|
|
113
|
+
},
|
|
114
|
+
required: ['path'],
|
|
115
|
+
},
|
|
116
|
+
handler: toolOutputReadHandler,
|
|
117
|
+
});
|
package/dist/tools/transport.js
CHANGED
|
@@ -16,7 +16,7 @@ import { auditLog } from '@cspeach/sap-client';
|
|
|
16
16
|
import { ERR } from '../errors/codes.js';
|
|
17
17
|
import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
|
|
18
18
|
import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
|
|
19
|
-
import { setCurrentTransport } from '../repl/current-transport.js';
|
|
19
|
+
import { setCurrentTransport, noteConfirmedTransport } from '../repl/current-transport.js';
|
|
20
20
|
import { enrichTransportList } from './transport-fit.js';
|
|
21
21
|
// ── Session ledger: transports CREATED by sap_transport_create this session ──
|
|
22
22
|
//
|
|
@@ -286,6 +286,11 @@ registerTool({
|
|
|
286
286
|
// Ownership-aware guidance (I1) — mirrors the create guard wording.
|
|
287
287
|
const me = (ctx.adt.username ?? '').toUpperCase();
|
|
288
288
|
const foreignOwner = r.owner && r.owner.toUpperCase() !== me ? r.owner : undefined;
|
|
289
|
+
// UI fix round (1.1.20): a lock the connected user may write under is
|
|
290
|
+
// a CONFIRMED transport for this work — the footer shows it (display
|
|
291
|
+
// only; the write pointer is untouched). A foreign lock is not ours.
|
|
292
|
+
if (!foreignOwner)
|
|
293
|
+
noteConfirmedTransport(r.transport);
|
|
289
294
|
note = foreignOwner
|
|
290
295
|
? `${args.object_name} is locked in ${r.transport} (owned by ${foreignOwner}) — SAP `
|
|
291
296
|
+ 'only accepts changes under that request; ask the owner to add you as a task '
|
package/dist/ui/footer.js
CHANGED
|
@@ -8,7 +8,7 @@ import { pasteClipboardImage } from './clipboard-image.js';
|
|
|
8
8
|
import Fuse from 'fuse.js';
|
|
9
9
|
// Task 8 (ux-wave1) — persistence lives in the shared writer.
|
|
10
10
|
import { appendHistoryEntry, readHistoryEntries, flattenHistoryEntry } from '../repl/history.js';
|
|
11
|
-
import {
|
|
11
|
+
import { frameWidth, truncateToWidth } from '../renderer/ui-width.js';
|
|
12
12
|
import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
|
|
13
13
|
import { getGlyphs } from '../renderer/glyphs.js';
|
|
14
14
|
import { roleProps, selectedProps } from './role-props.js';
|
|
@@ -31,12 +31,12 @@ const PROMPT = `${PROMPT_GLYPH} `;
|
|
|
31
31
|
/** Rounded border (2) + paddingX 1 on each side (2). */
|
|
32
32
|
const BOX_CHROME = 4;
|
|
33
33
|
/**
|
|
34
|
-
* Width of the input box (and of the footer line under it)
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* Width of the input box (and of the footer line under it) — the FRAME width
|
|
35
|
+
* (frameWidth(): full terminal less the 1-column deferred-wrap margin, Bug 1).
|
|
36
|
+
* No 100-col cap: the frame spans the terminal; text inside wraps on its own.
|
|
37
37
|
*/
|
|
38
38
|
export function inputBoxWidth(columns) {
|
|
39
|
-
return
|
|
39
|
+
return frameWidth(columns);
|
|
40
40
|
}
|
|
41
41
|
export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled, onNotice, onBufferChange, maxLines, onInputLinesChange, waitingForCard, }) {
|
|
42
42
|
const [value, setValue] = useState('');
|