@cspeach/cli 1.1.14 → 1.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -0
- package/dist/agent/loop.js +32 -7
- package/dist/agent/tool-dispatch.js +13 -9
- package/dist/cli.js +81 -27
- package/dist/commands/compact.js +6 -2
- package/dist/commands/project-context-impact.js +2 -2
- package/dist/commands/project.js +301 -0
- package/dist/commands/team.js +1166 -0
- package/dist/one-shot.js +2 -0
- package/dist/projects/image-attachments.js +155 -0
- package/dist/projects/save-command.js +20 -0
- package/dist/register/atc-file.js +802 -0
- package/dist/register/baseline.js +106 -0
- package/dist/register/measure.js +380 -0
- package/dist/register/records.js +159 -0
- package/dist/register/store.js +227 -0
- package/dist/repl/post-turn-status.js +2 -1
- package/dist/repl.js +16 -2
- package/dist/rewind/candidates.js +14 -1
- package/dist/rewind/restore.js +53 -19
- 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/skill-catalog.js +33 -0
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/filesystem/extract-document.js +6 -0
- package/dist/tools/filesystem/extract-xlsx.js +139 -0
- package/dist/tools/filesystem/file-write.js +20 -0
- package/dist/tools/filesystem/read-document.js +15 -6
- package/dist/tools/include-snapshot.js +24 -0
- package/dist/tools/sap-write.js +19 -9
- package/dist/tools/snapshot.js +27 -0
- 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 +7 -4
|
@@ -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
|
+
}
|
package/dist/skill-catalog.js
CHANGED
|
@@ -19,36 +19,42 @@ export const SKILL_CATALOG = [
|
|
|
19
19
|
category: 'Read / Analyze',
|
|
20
20
|
description: 'Impact analysis before changes — traces usage, callers, transports',
|
|
21
21
|
whenToUse: 'pick this for blast radius before a change (not /abap-explain — explain = teach the code)',
|
|
22
|
+
asks: 'which object? e.g. "ZCL_ORDER_UTILS" or "what breaks if I change ZSD_ORDER_RELEASE"',
|
|
22
23
|
},
|
|
23
24
|
{
|
|
24
25
|
name: 'abap-explain',
|
|
25
26
|
category: 'Read / Analyze',
|
|
26
27
|
description: 'Understand an object — plain-language (default) or `technical` orientation (deps, tables, risks)',
|
|
27
28
|
whenToUse: `plain teaching, or technical=fast orientation (not /abap-impact = blast radius)`,
|
|
29
|
+
asks: 'which object, plain or technical? e.g. "ZFI_DUNNING_SELECT in plain words"',
|
|
28
30
|
},
|
|
29
31
|
{
|
|
30
32
|
name: 'abap-review',
|
|
31
33
|
category: 'Read / Analyze',
|
|
32
34
|
description: 'Read-only review across lenses: quality (default), performance, cloud-readiness',
|
|
33
35
|
whenToUse: `grade source: quality (default) + performance/cloud lenses`,
|
|
36
|
+
asks: 'which object, and which lens? e.g. "ZCL_CREDIT_SVC for performance"',
|
|
34
37
|
},
|
|
35
38
|
{
|
|
36
39
|
name: 'abap-clean-core',
|
|
37
40
|
category: 'Read / Analyze',
|
|
38
41
|
description: 'Clean-core decision & defense — recommend the clean-core-correct approach, classify the level (A/B/C/D), produce citable evidence; verifies live when connected. Read-only.',
|
|
39
42
|
whenToUse: `pick this for "is this clean-core-OK?" decisions + defendable evidence (not /abap-review cloud lens — that grades code)`,
|
|
43
|
+
asks: 'which object or requirement? e.g. "can I add a field to VBAK the clean way"',
|
|
40
44
|
},
|
|
41
45
|
{
|
|
42
46
|
name: 'abap-handover',
|
|
43
47
|
category: 'Read / Analyze',
|
|
44
48
|
description: 'Documentation for one object (`object` scope) or a whole package (`package` scope)',
|
|
45
49
|
whenToUse: `object doc OR full package handover dossier`,
|
|
50
|
+
asks: 'which object or package? e.g. "package ZTEST_LAS, full handover"',
|
|
46
51
|
},
|
|
47
52
|
{
|
|
48
53
|
name: 'abap-dump',
|
|
49
54
|
category: 'Read / Analyze',
|
|
50
55
|
description: 'Diagnose ABAP short dumps (ST22) and fix them — root cause, call stack, fix with approval; ask for "triage" for ranked read-only hypotheses',
|
|
51
56
|
whenToUse: `pick this for any ST22 dump — diagnose + fix by default, say "triage" for read-only ranking (not /abap-incident — that's the full dump-to-transport pipeline)`,
|
|
57
|
+
asks: 'which dump? e.g. "the dump in ZFIN_DOC_JOURNAL this morning"',
|
|
52
58
|
},
|
|
53
59
|
// ── Modify / Fix ─────────────────────────────────────────────────────────
|
|
54
60
|
{
|
|
@@ -56,48 +62,56 @@ export const SKILL_CATALOG = [
|
|
|
56
62
|
category: 'Modify / Fix',
|
|
57
63
|
description: 'Run ATC checks against an object and auto-fix findings with safety gates',
|
|
58
64
|
whenToUse: `pick this for ad-hoc ATC fix on one object (not /abap-upgrade-fix — that's worklist-driven)`,
|
|
65
|
+
asks: 'which object or package? e.g. "ZCL_ORDER_UTILS, fix what is safe"',
|
|
59
66
|
},
|
|
60
67
|
{
|
|
61
68
|
name: 'abap-refactor',
|
|
62
69
|
category: 'Modify / Fix',
|
|
63
70
|
description: 'Refactor a Z object in place — FORM→method, SELECT *→specific fields, guard clauses, inline DATA. Same architecture, cleaner code.',
|
|
64
71
|
whenToUse: `pick this to clean up YOUR Z code, same architecture (not /abap-modernize — that rebuilds as RAP/Fiori)`,
|
|
72
|
+
asks: 'which object and what change? e.g. "ZLEGACY_FI_REPORT — add a net amount column"',
|
|
65
73
|
},
|
|
66
74
|
{
|
|
67
75
|
name: 'abap-enhance',
|
|
68
76
|
category: 'Modify / Fix',
|
|
69
77
|
description: 'Extend SAP standard via BAdI implementations, enhancement spots, user exits',
|
|
70
78
|
whenToUse: `pick this to hook into SAP-STANDARD code via BAdI/exit (not /abap-refactor — that's for YOUR Z code)`,
|
|
79
|
+
asks: 'which SAP object and what behaviour? e.g. "block goods issue when the credit check fails"',
|
|
71
80
|
},
|
|
72
81
|
{
|
|
73
82
|
name: 'abap-extend-model',
|
|
74
83
|
category: 'Modify / Fix',
|
|
75
84
|
description: 'Add a field or @UI column to an EXISTING custom CDS view / DDLX metadata extension in place — anchored insertion, snapshot, syntax-check, activate, republish-if-needed. The Fiori Elements column-add.',
|
|
76
85
|
whenToUse: `pick this to add a field/column to an EXISTING custom CDS/DDLX in place (not /abap-rap — that's a new stack; not /abap-refactor — that's ABAP code, not CDS)`,
|
|
86
|
+
asks: 'which view or BO, and what to add? e.g. "add ShippingPoint to the ZC_Delivery list"',
|
|
77
87
|
},
|
|
78
88
|
{
|
|
79
89
|
name: 'abap-incident',
|
|
80
90
|
category: 'Modify / Fix',
|
|
81
91
|
description: 'End-to-end incident pipeline — dump to fix deployed to transport in one skill',
|
|
82
92
|
whenToUse: `pick this for full ST22-to-transport pipeline with formal report (not /abap-dump — single fix only)`,
|
|
93
|
+
asks: 'the incident. e.g. "dump in ZOPEN_ORDER_REPORT, users blocked"',
|
|
83
94
|
},
|
|
84
95
|
{
|
|
85
96
|
name: 'abap-test',
|
|
86
97
|
category: 'Modify / Fix',
|
|
87
98
|
description: 'Generate ABAP Unit test classes for existing code with dependency injection',
|
|
88
99
|
whenToUse: `pick this to generate + run ABAP Unit tests (not /abap-eml — EML body only)`,
|
|
100
|
+
asks: 'which class? e.g. "unit tests for ZCL_CREDIT_SVC"',
|
|
89
101
|
},
|
|
90
102
|
{
|
|
91
103
|
name: 'abap-eml',
|
|
92
104
|
category: 'Modify / Fix',
|
|
93
105
|
description: 'Write and test EML (Entity Manipulation Language) for RAP business objects',
|
|
94
106
|
whenToUse: `pick this for EML inside a RAP context (not /abap-test — that's the test class)`,
|
|
107
|
+
asks: 'what the EML should do. e.g. "create a draft inspection and activate it"',
|
|
95
108
|
},
|
|
96
109
|
{
|
|
97
110
|
name: 'abap-segw',
|
|
98
111
|
category: 'Modify / Fix',
|
|
99
112
|
description: 'Implement SEGW OData DPC_EXT method bodies — reads generated MPC/DPC base classes. SEGW project itself created in transaction SEGW. For S/4 1909+ RAP services use /abap-rap.',
|
|
100
113
|
whenToUse: `pick this for DPC_EXT method bodies on an EXISTING SEGW project (ECC / older S/4; not /abap-rap — that's S/4 1909+ RAP; SEGW project + MPC/DPC must already exist)`,
|
|
114
|
+
asks: 'which project and operation? e.g. "GET_ENTITYSET for ZORDER_SRV"',
|
|
101
115
|
},
|
|
102
116
|
// ── Create / Generate ────────────────────────────────────────────────────
|
|
103
117
|
{
|
|
@@ -105,30 +119,35 @@ export const SKILL_CATALOG = [
|
|
|
105
119
|
category: 'Create / Generate',
|
|
106
120
|
description: 'Generate ONE ABAP class / program / include from a clear scope — asks clarifying questions first per Forge Rule 1. For a full RAP stack use /abap-rap; for DDIC only use /abap-data-model.',
|
|
107
121
|
whenToUse: `pick this for ONE class/program/include from clear scope (not /abap-rap — that's a full RAP stack; not /abap-data-model — that's DDIC only)`,
|
|
122
|
+
asks: 'what to build, or the scope. e.g. "all changes" after a design, or "a report of open orders by plant"',
|
|
108
123
|
},
|
|
109
124
|
{
|
|
110
125
|
name: 'abap-rap',
|
|
111
126
|
category: 'Create / Generate',
|
|
112
127
|
description: 'Scaffold a complete RAP stack on S/4HANA 1909+ — table, CDS interface, projection, BDEF, BP class, SRVD, SRVB, DDLX, DCL. ECC/older S/4 → use /abap-segw.',
|
|
113
128
|
whenToUse: `pick this for a full RAP stack on S/4 1909+ (10 objects, table → SRVB; not /abap-segw — that's ECC/older S/4 OData; not /abap-generate — that's one object)`,
|
|
129
|
+
asks: 'the business object. e.g. "maintenance requests with draft and an approve action"',
|
|
114
130
|
},
|
|
115
131
|
{
|
|
116
132
|
name: 'abap-fiori-build',
|
|
117
133
|
category: 'Create / Generate',
|
|
118
134
|
description: 'Build a working freestyle SAPUI5 app on the laptop from a published OData service — scaffold, pages, dialogs, chart, value help, local preview. Freestyle only, not Fiori Elements.',
|
|
119
135
|
whenToUse: `pick this for a freestyle UI5 frontend on a published OData service (not /abap-rap — that's the backend; produces local files, not ADT objects)`,
|
|
136
|
+
asks: 'which service and what UI? e.g. "list + object page on ZUI_LAS_INSPECTIONLOG_O4"',
|
|
120
137
|
},
|
|
121
138
|
{
|
|
122
139
|
name: 'abap-data-model',
|
|
123
140
|
category: 'Create / Generate',
|
|
124
141
|
description: 'Design and create DDIC objects — domains, data elements, tables, structures',
|
|
125
142
|
whenToUse: `pick this for DDIC ONLY — domain/data element/table/structure (step after /abap-design; embedded inside /abap-rap step 1 — call standalone when you don't need the rest of the stack)`,
|
|
143
|
+
asks: 'the tables and fields. e.g. "inspection results, header and items"',
|
|
126
144
|
},
|
|
127
145
|
{
|
|
128
146
|
name: 'abap-modernize',
|
|
129
147
|
category: 'Create / Generate',
|
|
130
148
|
description: 'Transform classic ABAP to the best modern architecture for the target release (ECC→SEGW, S/4→RAP)',
|
|
131
149
|
whenToUse: `pick this to rebuild a WRITE/dynpro/FM as Fiori/RAP (not /abap-upgrade-fix — that's syntax patches)`,
|
|
150
|
+
asks: 'which program? e.g. "ZLEGACY_SD_PROCESS onto a modern stack"',
|
|
132
151
|
},
|
|
133
152
|
// ── Upgrade / Migrate ────────────────────────────────────────────────────
|
|
134
153
|
{
|
|
@@ -136,42 +155,49 @@ export const SKILL_CATALOG = [
|
|
|
136
155
|
category: 'Upgrade / Migrate',
|
|
137
156
|
description: 'Custom code analysis pipeline for S/4 upgrade — whole-estate inventory + tier classification + ATC findings + report. Multi-session project with state file.',
|
|
138
157
|
whenToUse: `pick this to discover + classify the whole custom estate (not /abap-migrate — that's per object)`,
|
|
158
|
+
asks: 'which package or namespace? e.g. "Z*" or "package ZTEST_LAS"',
|
|
139
159
|
},
|
|
140
160
|
{
|
|
141
161
|
name: 'abap-upgrade-scan',
|
|
142
162
|
category: 'Upgrade / Migrate',
|
|
143
163
|
description: 'Scan custom ABAP code for S/4HANA upgrade findings via ATC readiness variant',
|
|
144
164
|
whenToUse: 'pick this to baseline ATC for one package + S/4 release (step 1 of 3 — feeds /abap-upgrade-fix)',
|
|
165
|
+
asks: 'the scope. e.g. "package ZTEST_LAS" or "Z* with the S/4 readiness variant"',
|
|
145
166
|
},
|
|
146
167
|
{
|
|
147
168
|
name: 'abap-upgrade-fix',
|
|
148
169
|
category: 'Upgrade / Migrate',
|
|
149
170
|
description: 'AI-guided upgrade remediation — fixes ATC findings one object at a time with approval gates',
|
|
150
171
|
whenToUse: 'pick this to fix the upgrade-scan baseline object-by-object (step 2 of 3 — needs /abap-upgrade-scan first)',
|
|
172
|
+
asks: 'which findings. e.g. "@upgrade-progress" or "the API findings in ZCL_ORDER_UTILS"',
|
|
151
173
|
},
|
|
152
174
|
{
|
|
153
175
|
name: 'abap-upgrade-verify',
|
|
154
176
|
category: 'Upgrade / Migrate',
|
|
155
177
|
description: 'Verify remediation results — re-runs ATC with same variant, compares before/after finding counts',
|
|
156
178
|
whenToUse: 'pick this for the customer sign-off report after fixing (step 3 of 3 — re-runs ATC vs baseline)',
|
|
179
|
+
asks: 'which scan to verify against. e.g. "@upgrade-progress from the first scan"',
|
|
157
180
|
},
|
|
158
181
|
{
|
|
159
182
|
name: 'abap-upgrade-merge',
|
|
160
183
|
category: 'Upgrade / Migrate',
|
|
161
184
|
description: 'Reconcile multiple parallel /abap-upgrade-fix progress files into one consolidated upgrade-progress',
|
|
162
185
|
whenToUse: 'pick this when a multi-dev team ran scoped fix slices in parallel — merges N progress files for /abap-upgrade-verify',
|
|
186
|
+
asks: 'which progress files. e.g. "merge the three @upgrade-progress files here"',
|
|
163
187
|
},
|
|
164
188
|
{
|
|
165
189
|
name: 'abap-cca-merge',
|
|
166
190
|
category: 'Upgrade / Migrate',
|
|
167
191
|
description: 'Reconcile multiple parallel /abap-cca assessment files into one consolidated cca-assessment',
|
|
168
192
|
whenToUse: 'pick this when multiple consultants ran scoped /abap-cca slices — merges N assessments into one team view',
|
|
193
|
+
asks: 'which assessments. e.g. "merge the ZFI and ZSD cca files"',
|
|
169
194
|
},
|
|
170
195
|
{
|
|
171
196
|
name: 'abap-migrate',
|
|
172
197
|
category: 'Upgrade / Migrate',
|
|
173
198
|
description: 'ECC to S/4HANA migration assessment — API analysis, clean-core alignment, migration path',
|
|
174
199
|
whenToUse: `pick this to pick retire/replace/refactor per object (not /abap-cca — cca discovers them)`,
|
|
200
|
+
asks: 'which objects. e.g. "package ZLEGACY, ECC to S/4"',
|
|
175
201
|
},
|
|
176
202
|
// ── Ship / Release ───────────────────────────────────────────────────────
|
|
177
203
|
{
|
|
@@ -179,18 +205,21 @@ export const SKILL_CATALOG = [
|
|
|
179
205
|
category: 'Ship / Release',
|
|
180
206
|
description: 'Transport management — create, list, validate, release transport requests',
|
|
181
207
|
whenToUse: `pick this to CREATE/LIST/RELEASE a TR (inspect-only? use /abap-transport-analysis)`,
|
|
208
|
+
asks: 'what to do. e.g. "create a transport for this session" or "release S4HK903449"',
|
|
182
209
|
},
|
|
183
210
|
{
|
|
184
211
|
name: 'abap-transport-analysis',
|
|
185
212
|
category: 'Ship / Release',
|
|
186
213
|
description: 'Pre-release transport analysis — objects, ATC scan, activation status, conflict check',
|
|
187
214
|
whenToUse: `pick this if you have a TR NUMBER and want a read-only safety scan`,
|
|
215
|
+
asks: 'which transport. e.g. "S4HK903449 before it goes to QA"',
|
|
188
216
|
},
|
|
189
217
|
{
|
|
190
218
|
name: 'abap-preflight',
|
|
191
219
|
category: 'Ship / Release',
|
|
192
220
|
description: 'Pre-release checklist — ATC, syntax, inactive objects, transport conflicts',
|
|
193
221
|
whenToUse: `pick this for a full ship-readiness package on a CHANGE SET (no TR# needed)`,
|
|
222
|
+
asks: 'what is going out. e.g. "the objects in S4HK903449"',
|
|
194
223
|
},
|
|
195
224
|
// ── Pre-Coding ───────────────────────────────────────────────────────────
|
|
196
225
|
{
|
|
@@ -198,24 +227,28 @@ export const SKILL_CATALOG = [
|
|
|
198
227
|
category: 'Pre-Coding',
|
|
199
228
|
description: 'Find missing requirements before coding — business questions, technical gaps, assumptions',
|
|
200
229
|
whenToUse: `pick this for missing business + technical questions on a vague spec (step 1 of 3 — feeds /abap-design)`,
|
|
230
|
+
asks: 'the requirement. e.g. paste the ticket text, or "@spec.pdf"',
|
|
201
231
|
},
|
|
202
232
|
{
|
|
203
233
|
name: 'abap-design',
|
|
204
234
|
category: 'Pre-Coding',
|
|
205
235
|
description: 'Solution architecture — object list, dependency sequence, patterns, transport strategy. Plans the build, does not generate code.',
|
|
206
236
|
whenToUse: `pick this for the object list + dependency sequence + pattern choices (step 2 of 3 — needs /abap-spec-gap done, feeds /abap-estimate or /abap-generate)`,
|
|
237
|
+
asks: 'the requirement, once the gaps are closed. e.g. "the inspection log from the spec above"',
|
|
207
238
|
},
|
|
208
239
|
{
|
|
209
240
|
name: 'abap-estimate',
|
|
210
241
|
category: 'Pre-Coding',
|
|
211
242
|
description: 'Effort estimation for ABAP tickets — component breakdown with hour ranges (optimistic / realistic / pessimistic) + risk adjustments',
|
|
212
243
|
whenToUse: `pick this for hour ranges + risk adjustments on a confirmed object list (step 3 of 3 — needs /abap-design first; high uncertainty without it)`,
|
|
244
|
+
asks: 'what to estimate. e.g. "the objects from the design above" or "all changes in this ticket"',
|
|
213
245
|
},
|
|
214
246
|
{
|
|
215
247
|
name: 'abap-plan',
|
|
216
248
|
category: 'Pre-Coding',
|
|
217
249
|
description: 'Multi-session project plan — phase envelope holds state across sessions; create from a goal or spec-gap file, resume one bounded phase per session',
|
|
218
250
|
whenToUse: `pick this when the work spans multiple sessions or components (not /abap-design — that's one design in one session; plan calls design per phase)`,
|
|
251
|
+
asks: 'the goal, or a plan to resume. e.g. "build the inspection log end to end"',
|
|
219
252
|
},
|
|
220
253
|
];
|
|
221
254
|
/**
|
|
@@ -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-24T14:23:23.324Z
|
|
4
4
|
// Manifest signature verified at build time.
|
|
5
5
|
export const BUNDLED_SKILLS = {
|
|
6
6
|
"abap-atc-fix": {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { extractText, getDocumentProxy } from 'unpdf';
|
|
4
4
|
import mammoth from 'mammoth';
|
|
5
5
|
import TurndownService from 'turndown';
|
|
6
|
+
import { extractXlsx } from './extract-xlsx.js';
|
|
6
7
|
/** Thrown when a PDF exceeds the caller's page ceiling — checked before full extraction. */
|
|
7
8
|
export class DocumentTooLargeError extends Error {
|
|
8
9
|
pages;
|
|
@@ -20,6 +21,11 @@ export async function extractDocument(buffer, ext, opts) {
|
|
|
20
21
|
return extractPdf(buffer, opts?.maxPages);
|
|
21
22
|
if (ext === '.docx')
|
|
22
23
|
return extractDocx(buffer);
|
|
24
|
+
// 2026-09-23 — specs and object lists arrive as spreadsheets (Glenn).
|
|
25
|
+
if (ext === '.xlsx') {
|
|
26
|
+
const x = extractXlsx(buffer);
|
|
27
|
+
return { text: x.text, sheets: x.sheets, warnings: x.warnings };
|
|
28
|
+
}
|
|
23
29
|
throw new Error(`extractDocument: unsupported extension "${ext}"`);
|
|
24
30
|
}
|
|
25
31
|
async function extractPdf(buffer, maxPages) {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .xlsx text extraction (2026-09-23 — Glenn: "it cannot read the excel file").
|
|
3
|
+
*
|
|
4
|
+
* Specs and object lists arrive as spreadsheets, so read_document reads them
|
|
5
|
+
* like any other document. An .xlsx IS a zip of XML parts, and adm-zip already
|
|
6
|
+
* ships inside the tree (SAP's own @sap-ux packages pull it), so nothing new
|
|
7
|
+
* enters the product to do this.
|
|
8
|
+
*
|
|
9
|
+
* Output shape: one "## Sheet: <name>" heading per sheet, then one line per
|
|
10
|
+
* row, cells separated by tabs, empty trailing cells dropped and empty leading
|
|
11
|
+
* ones kept so columns still line up. A model reads that as a table; a human
|
|
12
|
+
* reading the transcript can too.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately NOT done: formulas (the cached VALUE is used, which is what the
|
|
15
|
+
* sheet shows), formatting, merged-cell geometry, charts, images. Dates come
|
|
16
|
+
* out as the serial number Excel stores unless the cell is text — the caller is
|
|
17
|
+
* told, rather than us guessing a locale.
|
|
18
|
+
*/
|
|
19
|
+
import AdmZip from 'adm-zip';
|
|
20
|
+
/** Hard ceilings so one huge workbook cannot fill a turn. */
|
|
21
|
+
export const MAX_SHEET_ROWS = 2000;
|
|
22
|
+
export const MAX_CELLS = 100_000;
|
|
23
|
+
const decode = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, "'").replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
25
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
|
|
26
|
+
.replace(/&/g, '&'); // last: an encoded "&lt;" must not become "<"
|
|
27
|
+
/** All <t> text inside one shared-string <si>, joined (rich text is split across runs). */
|
|
28
|
+
function siText(si) {
|
|
29
|
+
const parts = [...si.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((m) => decode(m[1]));
|
|
30
|
+
return parts.join('');
|
|
31
|
+
}
|
|
32
|
+
export function sharedStrings(xml) {
|
|
33
|
+
if (!xml)
|
|
34
|
+
return [];
|
|
35
|
+
return [...xml.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => siText(m[1]));
|
|
36
|
+
}
|
|
37
|
+
/** Column letters → 0-based index: A→0, B→1, AA→26. */
|
|
38
|
+
export function colIndex(ref) {
|
|
39
|
+
const letters = /^([A-Z]+)/.exec(ref.toUpperCase())?.[1] ?? 'A';
|
|
40
|
+
let n = 0;
|
|
41
|
+
for (const ch of letters)
|
|
42
|
+
n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
43
|
+
return n - 1;
|
|
44
|
+
}
|
|
45
|
+
/** One worksheet's cells as a rectangular grid of strings. */
|
|
46
|
+
export function sheetRows(xml, strings, budget) {
|
|
47
|
+
const rows = [];
|
|
48
|
+
for (const rowMatch of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
|
|
49
|
+
if (rows.length >= MAX_SHEET_ROWS || budget.cells <= 0)
|
|
50
|
+
break;
|
|
51
|
+
const cells = [];
|
|
52
|
+
for (const cell of rowMatch[1].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>|<c\b([^>]*)\/>/g)) {
|
|
53
|
+
const attrs = cell[1] ?? cell[3] ?? '';
|
|
54
|
+
const body = cell[2] ?? '';
|
|
55
|
+
const at = colIndex(/r="([A-Z]+\d+)"/.exec(attrs)?.[1] ?? 'A1');
|
|
56
|
+
const type = /t="([^"]+)"/.exec(attrs)?.[1];
|
|
57
|
+
let value = '';
|
|
58
|
+
if (type === 's') {
|
|
59
|
+
const idx = Number(/<v>([\s\S]*?)<\/v>/.exec(body)?.[1] ?? '-1');
|
|
60
|
+
value = strings[idx] ?? '';
|
|
61
|
+
}
|
|
62
|
+
else if (type === 'inlineStr') {
|
|
63
|
+
value = siText(body);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// n (number), str (formula result), b, d, e — the cached value as shown.
|
|
67
|
+
value = decode(/<v>([\s\S]*?)<\/v>/.exec(body)?.[1] ?? '');
|
|
68
|
+
}
|
|
69
|
+
while (cells.length < at)
|
|
70
|
+
cells.push('');
|
|
71
|
+
cells[at] = value.replace(/[\t\r\n]+/g, ' ').trim();
|
|
72
|
+
budget.cells -= 1;
|
|
73
|
+
if (budget.cells <= 0)
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
while (cells.length > 0 && cells[cells.length - 1] === '')
|
|
77
|
+
cells.pop();
|
|
78
|
+
rows.push(cells);
|
|
79
|
+
}
|
|
80
|
+
while (rows.length > 0 && rows[rows.length - 1].length === 0)
|
|
81
|
+
rows.pop();
|
|
82
|
+
return rows;
|
|
83
|
+
}
|
|
84
|
+
/** Sheet name → part path, in the workbook's own order. */
|
|
85
|
+
export function sheetOrder(workbookXml, relsXml) {
|
|
86
|
+
if (!workbookXml)
|
|
87
|
+
return [];
|
|
88
|
+
const rels = new Map();
|
|
89
|
+
for (const m of (relsXml ?? '').matchAll(/<Relationship\b([^>]*)\/>/g)) {
|
|
90
|
+
const id = /Id="([^"]+)"/.exec(m[1])?.[1];
|
|
91
|
+
const target = /Target="([^"]+)"/.exec(m[1])?.[1];
|
|
92
|
+
if (id && target)
|
|
93
|
+
rels.set(id, target.replace(/^\/?xl\//, '').replace(/^\//, ''));
|
|
94
|
+
}
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const m of workbookXml.matchAll(/<sheet\b([^>]*)\/>/g)) {
|
|
97
|
+
const attrs = m[1];
|
|
98
|
+
const name = decode(/name="([^"]*)"/.exec(attrs)?.[1] ?? `Sheet${out.length + 1}`);
|
|
99
|
+
const rid = /r:id="([^"]+)"/.exec(attrs)?.[1] ?? '';
|
|
100
|
+
out.push({ name, path: `xl/${rels.get(rid) ?? `worksheets/sheet${out.length + 1}.xml`}` });
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
export function extractXlsx(buffer) {
|
|
105
|
+
let zip;
|
|
106
|
+
try {
|
|
107
|
+
zip = new AdmZip(buffer);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { text: '', sheets: 0, warnings: ['not a readable .xlsx (the file may be .xls, password-protected or damaged)'] };
|
|
111
|
+
}
|
|
112
|
+
const read = (p) => {
|
|
113
|
+
const entry = zip.getEntry(p);
|
|
114
|
+
return entry ? entry.getData().toString('utf-8') : null;
|
|
115
|
+
};
|
|
116
|
+
const strings = sharedStrings(read('xl/sharedStrings.xml'));
|
|
117
|
+
const sheets = sheetOrder(read('xl/workbook.xml'), read('xl/_rels/workbook.xml.rels'));
|
|
118
|
+
if (sheets.length === 0) {
|
|
119
|
+
return { text: '', sheets: 0, warnings: ['no sheets found — the file may be .xls (old format); save it as .xlsx'] };
|
|
120
|
+
}
|
|
121
|
+
const budget = { cells: MAX_CELLS };
|
|
122
|
+
const warnings = [];
|
|
123
|
+
const blocks = [];
|
|
124
|
+
for (const s of sheets) {
|
|
125
|
+
const xml = read(s.path);
|
|
126
|
+
if (xml === null)
|
|
127
|
+
continue;
|
|
128
|
+
const rows = sheetRows(xml, strings, budget);
|
|
129
|
+
if (rows.length >= MAX_SHEET_ROWS)
|
|
130
|
+
warnings.push(`sheet "${s.name}" was cut at ${MAX_SHEET_ROWS} rows`);
|
|
131
|
+
blocks.push(`## Sheet: ${s.name}\n\n${rows.map((r) => r.join('\t')).join('\n')}`.trimEnd());
|
|
132
|
+
}
|
|
133
|
+
if (budget.cells <= 0)
|
|
134
|
+
warnings.push('the workbook was cut short — it holds more cells than one read returns');
|
|
135
|
+
const text = blocks.join('\n\n').trim();
|
|
136
|
+
if (text.length === 0)
|
|
137
|
+
warnings.push('no cell text found — the workbook may be empty or hold only charts/images');
|
|
138
|
+
return { text, sheets: sheets.length, warnings };
|
|
139
|
+
}
|
|
@@ -21,6 +21,25 @@ import { promises as fs } from 'node:fs';
|
|
|
21
21
|
import * as path from 'node:path';
|
|
22
22
|
import { registerTool } from '../index.js';
|
|
23
23
|
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
|
|
24
|
+
/**
|
|
25
|
+
* 2026-09-23 (Glenn's first run) — a standalone run writes GUIDE.md and said
|
|
26
|
+
* nothing about it. He opened it in a text editor, then tried to drop it into
|
|
27
|
+
* an unrelated page on the site and got an error: "what does this file do …
|
|
28
|
+
* where would this enter back into the CSPEACH process?". Say it once, when
|
|
29
|
+
* the file appears, in the human stream.
|
|
30
|
+
*/
|
|
31
|
+
let guideNoteShown = false;
|
|
32
|
+
export function __resetGuideNoteForTests() { guideNoteShown = false; }
|
|
33
|
+
function noteGuideWritten(relPath, ctx) {
|
|
34
|
+
if (guideNoteShown)
|
|
35
|
+
return;
|
|
36
|
+
if (path.basename(relPath).toLowerCase() !== 'guide.md')
|
|
37
|
+
return;
|
|
38
|
+
guideNoteShown = true;
|
|
39
|
+
ctx.chunkEmitter?.emit('info', `${relPath} is your step-by-step list for applying these objects in SAP — `
|
|
40
|
+
+ 'open it at https://cspeach.dev/guide, or in any text editor. '
|
|
41
|
+
+ 'If a step fails, come back here and say "step N failed" with the error.');
|
|
42
|
+
}
|
|
24
43
|
export async function fileWriteHandler(args, ctx) {
|
|
25
44
|
// 1. Path containment — sync phase (pure string math, catches .. traversals)
|
|
26
45
|
let abs;
|
|
@@ -110,6 +129,7 @@ export async function fileWriteHandler(args, ctx) {
|
|
|
110
129
|
}
|
|
111
130
|
const relPath = path.relative(path.resolve(ctx.cwd), target).replace(/\\/g, '/');
|
|
112
131
|
const bytes = Buffer.byteLength(args.content, 'utf-8');
|
|
132
|
+
noteGuideWritten(relPath, ctx);
|
|
113
133
|
return { content: `wrote ${relPath} (${bytes} bytes)` };
|
|
114
134
|
}
|
|
115
135
|
registerTool({
|
|
@@ -12,7 +12,7 @@ import { extractDocument, DocumentTooLargeError } from './extract-document.js';
|
|
|
12
12
|
const MAX_LINES = 2000;
|
|
13
13
|
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
14
14
|
const MAX_PAGES = 150;
|
|
15
|
-
const SUPPORTED = new Set(['.pdf', '.docx']);
|
|
15
|
+
const SUPPORTED = new Set(['.pdf', '.docx', '.xlsx']);
|
|
16
16
|
export async function readDocumentHandler(args, ctx) {
|
|
17
17
|
if (args.offset !== undefined && args.offset < 0) {
|
|
18
18
|
return { content: `error: offset must be non-negative (got ${args.offset})`, is_error: true };
|
|
@@ -32,7 +32,10 @@ export async function readDocumentHandler(args, ctx) {
|
|
|
32
32
|
const ext = path.extname(abs).toLowerCase();
|
|
33
33
|
if (!SUPPORTED.has(ext)) {
|
|
34
34
|
return {
|
|
35
|
-
content:
|
|
35
|
+
content: ext === '.xls' || ext === '.csv'
|
|
36
|
+
? `error: read_document reads .xlsx, not ${ext}; save "${args.path}" as .xlsx`
|
|
37
|
+
+ (ext === '.csv' ? ' — or read a .csv with file_read, it is plain text' : '')
|
|
38
|
+
: `error: read_document supports .pdf, .docx and .xlsx; convert "${args.path}" first (.doc/.pptx/scanned images are not supported)`,
|
|
36
39
|
is_error: true,
|
|
37
40
|
};
|
|
38
41
|
}
|
|
@@ -74,8 +77,14 @@ export async function readDocumentHandler(args, ctx) {
|
|
|
74
77
|
}
|
|
75
78
|
return { content: `error: could not read "${args.path}": ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
76
79
|
}
|
|
77
|
-
// 'document' is the intentional label for .docx: result.pages is PDF-only
|
|
78
|
-
|
|
80
|
+
// 'document' is the intentional label for .docx: result.pages is PDF-only,
|
|
81
|
+
// result.sheets is .xlsx-only, and a Word file has neither.
|
|
82
|
+
const scale = result.pages !== undefined
|
|
83
|
+
? `${result.pages} pages`
|
|
84
|
+
: result.sheets !== undefined
|
|
85
|
+
? `${result.sheets} sheet${result.sheets === 1 ? '' : 's'}`
|
|
86
|
+
: 'document';
|
|
87
|
+
const header = `${args.path} — ${scale}`;
|
|
79
88
|
const warnLines = result.warnings.map((w) => `[warning] ${w}`);
|
|
80
89
|
const allLines = result.text.replace(/\n$/, '').split('\n');
|
|
81
90
|
const offset = Math.max(0, args.offset ?? 0);
|
|
@@ -90,14 +99,14 @@ export async function readDocumentHandler(args, ctx) {
|
|
|
90
99
|
}
|
|
91
100
|
registerTool({
|
|
92
101
|
name: 'read_document',
|
|
93
|
-
description: 'Read a .pdf or .
|
|
102
|
+
description: 'Read a .pdf, .docx or .xlsx and return its text with page/sheet/heading markers and line numbers. A spreadsheet comes back one sheet at a time, rows as tab-separated cells (cached values, not formulas). Optional offset + limit for windowed reads of large documents.',
|
|
94
103
|
isMutating: false,
|
|
95
104
|
category: 'filesystem',
|
|
96
105
|
flagGated: true,
|
|
97
106
|
input_schema: {
|
|
98
107
|
type: 'object',
|
|
99
108
|
properties: {
|
|
100
|
-
path: { type: 'string', description: 'Path to a .pdf or .
|
|
109
|
+
path: { type: 'string', description: 'Path to a .pdf, .docx or .xlsx, relative to the project root or absolute inside it.' },
|
|
101
110
|
offset: { type: 'number', description: 'Optional 0-based line offset into the extracted text.' },
|
|
102
111
|
limit: { type: 'number', description: 'Optional max lines to return (cap 2000).' },
|
|
103
112
|
},
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot-store key for a class LOCAL INCLUDE (CCDEF / CCIMP / CCMAC / test
|
|
3
|
+
* classes). Pure — no fs, no SAP.
|
|
4
|
+
*
|
|
5
|
+
* Why a separate key (2026-09-21, found on the first clean-core fix): the
|
|
6
|
+
* include write used to snapshot the class MAIN source. For a RAP behaviour
|
|
7
|
+
* pool the main source is an 8-line shell and the change lives in CCIMP — so
|
|
8
|
+
* the Rule-7 backup never covered the change, and rewind wrote the unchanged
|
|
9
|
+
* main source back and reported success while the include change stayed.
|
|
10
|
+
*
|
|
11
|
+
* The include is stored under type `CLAS.<INCLUDE>` (e.g. `CLAS.LOCALS_IMP`),
|
|
12
|
+
* name = class name. A dot keeps the store's two-level type/name layout (no
|
|
13
|
+
* nested dirs) and can never collide with a real ABAP type code.
|
|
14
|
+
*/
|
|
15
|
+
const INCLUDE_TYPE_RE = /^CLAS\.(LOCALS_DEF|LOCALS_IMP|MACROS|TESTCLASSES)$/i;
|
|
16
|
+
/** Snapshot type key for one include of a class. */
|
|
17
|
+
export function includeSnapshotType(includeType) {
|
|
18
|
+
return `CLAS.${includeType.toUpperCase()}`;
|
|
19
|
+
}
|
|
20
|
+
/** The include a snapshot type key names, or null for an ordinary object type. */
|
|
21
|
+
export function parseIncludeSnapshotType(type) {
|
|
22
|
+
const m = INCLUDE_TYPE_RE.exec(type);
|
|
23
|
+
return m ? m[1].toLowerCase() : null;
|
|
24
|
+
}
|
package/dist/tools/sap-write.js
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
import crypto from 'node:crypto';
|
|
25
25
|
import { registerTool } from './index.js';
|
|
26
26
|
import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
|
|
27
|
-
import { autoSnapshot } from './snapshot.js';
|
|
27
|
+
import { autoSnapshot, autoSnapshotClassInclude } from './snapshot.js';
|
|
28
|
+
import { includeSnapshotType } from './include-snapshot.js';
|
|
28
29
|
import { buildWriteChainLine } from '../renderer/verify-chain.js';
|
|
29
30
|
import { verifySyntax, verifyActive } from './verify.js';
|
|
30
31
|
import { appendPending, finalizeToolCall } from '../session/pending.js';
|
|
@@ -663,9 +664,9 @@ registerTool({
|
|
|
663
664
|
// Writes a class local-include source (CCDEF/CCIMP/CCMAC/test classes). This is
|
|
664
665
|
// how RAP behaviour-pool handlers (which live in CCIMP / locals_imp) get landed.
|
|
665
666
|
// AdtClient locks the CLASS object, tries a ladder of PUT URL/content-type
|
|
666
|
-
// variants (first 2xx wins), and always unlocks.
|
|
667
|
-
//
|
|
668
|
-
//
|
|
667
|
+
// variants (first 2xx wins), and always unlocks. The Rule-7 snapshot is of the
|
|
668
|
+
// INCLUDE itself (autoSnapshotClassInclude). It is non-blocking — a fresh
|
|
669
|
+
// class's include may not be readable yet — but a failed one warns loudly.
|
|
669
670
|
registerTool({
|
|
670
671
|
name: 'sap_set_class_include',
|
|
671
672
|
description: 'Write a class local-include source (CCDEF locals_def, CCIMP locals_imp, '
|
|
@@ -713,15 +714,24 @@ registerTool({
|
|
|
713
714
|
// The CCIMP piece (LIMU CINC) is exactly the kind of object that sits
|
|
714
715
|
// locked in a request from earlier work — battery S1's 409 root cause.
|
|
715
716
|
const resolved = await resolveWriteTransport(ctx, 'CLAS', args.className, effectiveTransport);
|
|
716
|
-
// ── Gate 2: Snapshot (Rule 7) —
|
|
717
|
+
// ── Gate 2: Snapshot (Rule 7) — of the INCLUDE being overwritten ─────────
|
|
718
|
+
// Not the class main source: for a behaviour pool that is an empty shell and
|
|
719
|
+
// the change lives in CCIMP (2026-09-21 — the old main-source snapshot left
|
|
720
|
+
// the change with no restore point, and rewind "restored" the wrong thing).
|
|
721
|
+
// Still non-blocking (a fresh class's include may not be readable yet), but
|
|
722
|
+
// a missing restore point is now said out loud instead of swallowed.
|
|
717
723
|
let snapOutcome;
|
|
718
724
|
try {
|
|
719
|
-
snapOutcome = await
|
|
725
|
+
snapOutcome = await autoSnapshotClassInclude(args.className, args.includeType, ctx.adt);
|
|
720
726
|
}
|
|
721
|
-
catch {
|
|
722
|
-
|
|
727
|
+
catch (err) {
|
|
728
|
+
snapOutcome = { taken: false, reason: 'source_read_failed', detail: String(err) };
|
|
729
|
+
}
|
|
730
|
+
emitSnapshotNotice(ctx, snapOutcome, includeSnapshotType(args.includeType), args.className);
|
|
731
|
+
if (snapOutcome?.reason === 'source_read_failed') {
|
|
732
|
+
ctx.chunkEmitter?.emit('warn', `CLAS ${args.className} ${args.includeType}: could not snapshot the include before writing `
|
|
733
|
+
+ `(${snapOutcome.detail ?? 'read failed'}) — this write has NO restore point; rewind cannot undo it`);
|
|
723
734
|
}
|
|
724
|
-
emitSnapshotNotice(ctx, snapOutcome, 'CLAS', args.className);
|
|
725
735
|
// ── WAL: record pending entry ────────────────────────────────────────────
|
|
726
736
|
await appendPending(ctx.session, {
|
|
727
737
|
tool_use_id: toolUseId,
|
package/dist/tools/snapshot.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { registerTool } from './index.js';
|
|
17
17
|
import { snapshots, SapError } from '@cspeach/sap-client';
|
|
18
|
+
import { includeSnapshotType } from './include-snapshot.js';
|
|
18
19
|
/** True only for a genuine ADT 404 — the object does not exist. Any other
|
|
19
20
|
* failure (network, auth, 5xx, timeout) is a READ failure, not proof of
|
|
20
21
|
* absence, and must never be labelled "object not found". */
|
|
@@ -100,6 +101,32 @@ export async function autoSnapshot(objectName, objectType, adt) {
|
|
|
100
101
|
const entry = await snapshots.take(objectType, objectName, src, 'before_write');
|
|
101
102
|
return { taken: true, entry };
|
|
102
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Rule-7 snapshot of ONE class local include, taken before sap_set_class_include
|
|
106
|
+
* overwrites it. Stored under includeSnapshotType(includeType) + class name so
|
|
107
|
+
* rewind can put the include (not the class main source) back.
|
|
108
|
+
*
|
|
109
|
+
* 404 → 'object_not_found' (include has no source yet — nothing to protect)
|
|
110
|
+
* other read error → 'source_read_failed' (+ detail) — caller decides; no restore point
|
|
111
|
+
* read OK (even '') → snapshot taken
|
|
112
|
+
* A snapshots.take failure THROWS, like autoSnapshot.
|
|
113
|
+
*/
|
|
114
|
+
export async function autoSnapshotClassInclude(className, includeType, adt) {
|
|
115
|
+
let src;
|
|
116
|
+
try {
|
|
117
|
+
src = await adt.classIncludes(className, includeType);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (isNotFound(err))
|
|
121
|
+
return { taken: false, reason: 'object_not_found' };
|
|
122
|
+
return { taken: false, reason: 'source_read_failed', detail: shortErrorMessage(err) };
|
|
123
|
+
}
|
|
124
|
+
if (typeof src !== 'string') {
|
|
125
|
+
return { taken: false, reason: 'source_read_failed', detail: 'include read returned no source text' };
|
|
126
|
+
}
|
|
127
|
+
const entry = await snapshots.take(includeSnapshotType(includeType), className, src, 'before_write');
|
|
128
|
+
return { taken: true, entry };
|
|
129
|
+
}
|
|
103
130
|
// ── sap_snapshot_list ─────────────────────────────────────────────────────────
|
|
104
131
|
registerTool({
|
|
105
132
|
name: 'sap_snapshot_list',
|