@cspeach/cli 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +108 -0
  3. package/dist/agent/anthropic-provider.js +59 -0
  4. package/dist/agent/llm-provider.js +1 -0
  5. package/dist/agent/loop.js +709 -0
  6. package/dist/agent/maybe-build-project-context.js +126 -0
  7. package/dist/agent/providers/ai-hub-provider.js +58 -0
  8. package/dist/agent/providers/byok-provider.js +53 -0
  9. package/dist/agent/providers/factory.js +13 -0
  10. package/dist/agent/providers/local-provider.js +125 -0
  11. package/dist/agent/repair-partial.js +31 -0
  12. package/dist/agent/retry-key.js +58 -0
  13. package/dist/agent/retry.js +17 -0
  14. package/dist/agent/sap-connection-adapter.js +82 -0
  15. package/dist/agent/skill-checkpoint.js +119 -0
  16. package/dist/agent/tool-dispatch.js +47 -0
  17. package/dist/agent/turn-assistant-text.js +49 -0
  18. package/dist/agent/turn-error-ux.js +126 -0
  19. package/dist/agent/turn-stream.js +79 -0
  20. package/dist/agent/turn-watchdog.js +71 -0
  21. package/dist/approvals/advisory-prompt.js +40 -0
  22. package/dist/approvals/advisory-render.js +38 -0
  23. package/dist/approvals/approval-prompt.js +100 -0
  24. package/dist/approvals/jwt.js +33 -0
  25. package/dist/approvals/render.js +211 -0
  26. package/dist/approvals/risk-floor.js +26 -0
  27. package/dist/auth/api-key.js +40 -0
  28. package/dist/auth/auth-file.js +59 -0
  29. package/dist/auth/device.js +8 -0
  30. package/dist/auth/me.js +19 -0
  31. package/dist/classifier/client.js +58 -0
  32. package/dist/cli-args.js +38 -0
  33. package/dist/cli.js +148 -0
  34. package/dist/commands/config-set.js +245 -0
  35. package/dist/commands/config-show.js +159 -0
  36. package/dist/commands/help.js +93 -0
  37. package/dist/commands/login.js +122 -0
  38. package/dist/commands/logout.js +17 -0
  39. package/dist/commands/project-context-impact.js +215 -0
  40. package/dist/commands/reroute.js +60 -0
  41. package/dist/commands/spec-gap-status.js +52 -0
  42. package/dist/commands/whoami.js +35 -0
  43. package/dist/config/loader.js +67 -0
  44. package/dist/config/paths.js +20 -0
  45. package/dist/doctor/checks/_http-probe.js +56 -0
  46. package/dist/doctor/checks/auth.js +15 -0
  47. package/dist/doctor/checks/cert.js +24 -0
  48. package/dist/doctor/checks/forge-rules.js +102 -0
  49. package/dist/doctor/checks/keychain-fallback.js +14 -0
  50. package/dist/doctor/checks/keychain.js +23 -0
  51. package/dist/doctor/checks/llm-mode.js +27 -0
  52. package/dist/doctor/checks/proxy.js +13 -0
  53. package/dist/doctor/checks/sap.js +33 -0
  54. package/dist/doctor/checks/skill.js +24 -0
  55. package/dist/doctor/checks/write-mode.js +34 -0
  56. package/dist/doctor/checks/zcspeach.js +76 -0
  57. package/dist/doctor/run.js +46 -0
  58. package/dist/errors/codes.js +12 -0
  59. package/dist/index.js +6 -0
  60. package/dist/lock-contention.js +22 -0
  61. package/dist/one-shot.js +104 -0
  62. package/dist/project-context/conventions.js +309 -0
  63. package/dist/project-context/detect.js +250 -0
  64. package/dist/project-context/domain/abap-cloud.js +26 -0
  65. package/dist/project-context/domain/abapgit.js +177 -0
  66. package/dist/project-context/domain/cap.js +164 -0
  67. package/dist/project-context/domain/fiori.js +326 -0
  68. package/dist/project-context/git.js +115 -0
  69. package/dist/project-context/index-files.js +235 -0
  70. package/dist/project-context/index.js +117 -0
  71. package/dist/project-context/render.js +308 -0
  72. package/dist/project-context/types.js +14 -0
  73. package/dist/projects/build.js +20 -0
  74. package/dist/projects/canonicalize.js +39 -0
  75. package/dist/projects/email-template.js +54 -0
  76. package/dist/projects/extract-cca.js +139 -0
  77. package/dist/projects/extract-design.js +107 -0
  78. package/dist/projects/extract-estimate.js +93 -0
  79. package/dist/projects/extract-modernize.js +130 -0
  80. package/dist/projects/extract-spec-gap.js +101 -0
  81. package/dist/projects/extract-test-coverage.js +137 -0
  82. package/dist/projects/extract-upgrade.js +230 -0
  83. package/dist/projects/filename.js +18 -0
  84. package/dist/projects/index.js +8 -0
  85. package/dist/projects/migration.js +111 -0
  86. package/dist/projects/promote-command.js +96 -0
  87. package/dist/projects/promote.js +107 -0
  88. package/dist/projects/save-command.js +124 -0
  89. package/dist/projects/save.js +21 -0
  90. package/dist/projects/status.js +170 -0
  91. package/dist/projects/types.js +1 -0
  92. package/dist/projects/validate.js +146 -0
  93. package/dist/projects/workspace.js +478 -0
  94. package/dist/renderer/abap-inline.js +121 -0
  95. package/dist/renderer/banners.js +39 -0
  96. package/dist/renderer/highlighters/abap.js +126 -0
  97. package/dist/renderer/highlighters/bdef.js +81 -0
  98. package/dist/renderer/highlighters/cds.js +91 -0
  99. package/dist/renderer/markdown.js +291 -0
  100. package/dist/renderer/pipeline.js +201 -0
  101. package/dist/renderer/progress-chatter.js +237 -0
  102. package/dist/renderer/question-normalizer.js +306 -0
  103. package/dist/renderer/severity.js +61 -0
  104. package/dist/renderer/status-footer.js +50 -0
  105. package/dist/renderer/syntax.js +58 -0
  106. package/dist/renderer/tables.js +55 -0
  107. package/dist/renderer/thinking-heartbeat.js +70 -0
  108. package/dist/renderer/tool-widget.js +199 -0
  109. package/dist/renderer/tty.js +66 -0
  110. package/dist/renderer/widget-extractor.js +87 -0
  111. package/dist/renderer/widget-fallback.js +78 -0
  112. package/dist/renderer/widget-schemas.js +43 -0
  113. package/dist/repl/at-completer.js +64 -0
  114. package/dist/repl/at-picker.js +122 -0
  115. package/dist/repl/bracketed-paste.js +284 -0
  116. package/dist/repl/current-transport.js +46 -0
  117. package/dist/repl/diff-display.js +41 -0
  118. package/dist/repl/file-picker.js +219 -0
  119. package/dist/repl/inquirer-guard.js +130 -0
  120. package/dist/repl/inquirer-theme.js +41 -0
  121. package/dist/repl/rule8-detector.js +99 -0
  122. package/dist/repl/safety-confirm.js +106 -0
  123. package/dist/repl/safety-mode-state.js +36 -0
  124. package/dist/repl/slash-completer.js +59 -0
  125. package/dist/repl/slash-picker.js +124 -0
  126. package/dist/repl/update-method-preview-hook.js +45 -0
  127. package/dist/repl.js +1383 -0
  128. package/dist/router/classifier.js +38 -0
  129. package/dist/router/intent-extractor.js +140 -0
  130. package/dist/router/routing-decision.js +19 -0
  131. package/dist/sap/connection-manager.js +52 -0
  132. package/dist/sap/onboarding.js +178 -0
  133. package/dist/sap/system-info.js +515 -0
  134. package/dist/session/awaiting-answer.js +73 -0
  135. package/dist/session/gc.js +28 -0
  136. package/dist/session/pending.js +37 -0
  137. package/dist/session/resume.js +77 -0
  138. package/dist/session/schema.js +20 -0
  139. package/dist/session/store.js +147 -0
  140. package/dist/session/time-ago.js +41 -0
  141. package/dist/skill-catalog.js +222 -0
  142. package/dist/skills/bundled-skills.js +1 -0
  143. package/dist/skills/canonical.js +12 -0
  144. package/dist/skills/manifest-client.js +93 -0
  145. package/dist/skills/promotion-dispatch.js +24 -0
  146. package/dist/skills/signing-public-key.js +4 -0
  147. package/dist/skills/source-bundled.js +20 -0
  148. package/dist/skills/source-managed.js +26 -0
  149. package/dist/skills/source-manifest.js +26 -0
  150. package/dist/tools/_command-shared.js +110 -0
  151. package/dist/tools/_filesystem-shared.js +81 -0
  152. package/dist/tools/_flag.js +39 -0
  153. package/dist/tools/approval.js +228 -0
  154. package/dist/tools/ask-question.js +205 -0
  155. package/dist/tools/dispatch-skill.js +81 -0
  156. package/dist/tools/filesystem/file-edit.js +140 -0
  157. package/dist/tools/filesystem/file-read.js +89 -0
  158. package/dist/tools/filesystem/file-write.js +128 -0
  159. package/dist/tools/filesystem/glob.js +177 -0
  160. package/dist/tools/filesystem/grep.js +163 -0
  161. package/dist/tools/index.js +32 -0
  162. package/dist/tools/project/convention_get.js +91 -0
  163. package/dist/tools/project/playbook_get.js +132 -0
  164. package/dist/tools/project/project_context_get.js +101 -0
  165. package/dist/tools/sap-read.js +454 -0
  166. package/dist/tools/sap-write.js +746 -0
  167. package/dist/tools/shell/shell_exec.js +209 -0
  168. package/dist/tools/snapshot.js +107 -0
  169. package/dist/tools/subagent/_background-shared.js +133 -0
  170. package/dist/tools/subagent/agent_run.js +186 -0
  171. package/dist/tools/subagent/background_run.js +143 -0
  172. package/dist/tools/subagent/monitor_emit.js +65 -0
  173. package/dist/tools/subagent/schedule_create.js +131 -0
  174. package/dist/tools/transport.js +233 -0
  175. package/dist/tools/update-method-intercept.js +119 -0
  176. package/dist/tools/verify.js +39 -0
  177. package/dist/tools/web/_web-shared.js +251 -0
  178. package/dist/tools/web/web_fetch.js +257 -0
  179. package/dist/tools/web/web_search.js +195 -0
  180. package/dist/tools/write-mode.js +22 -0
  181. package/dist/ui/app.js +95 -0
  182. package/dist/ui/approval-emitter.js +10 -0
  183. package/dist/ui/approval-modal.js +53 -0
  184. package/dist/ui/ascii-chars.js +6 -0
  185. package/dist/ui/body.js +102 -0
  186. package/dist/ui/coaching-picker-classic.js +36 -0
  187. package/dist/ui/coaching-picker-emitter.js +27 -0
  188. package/dist/ui/command-palette.js +34 -0
  189. package/dist/ui/error-emitter.js +21 -0
  190. package/dist/ui/footer.js +103 -0
  191. package/dist/ui/header.js +17 -0
  192. package/dist/ui/ink-classifier-route.js +19 -0
  193. package/dist/ui/login-banner.js +72 -0
  194. package/dist/ui/rich-error-box.js +9 -0
  195. package/dist/ui/sap-state-store.js +65 -0
  196. package/dist/ui/session-timeline.js +31 -0
  197. package/dist/ui/sidebar.js +10 -0
  198. package/dist/ui/skill-picker.js +50 -0
  199. package/dist/ui/status-row.js +12 -0
  200. package/dist/ui/widget-control.js +4 -0
  201. package/dist/ui/widgets/bar-chart.js +15 -0
  202. package/dist/ui/widgets/coaching-picker.js +41 -0
  203. package/dist/ui/widgets/component-registry.js +12 -0
  204. package/dist/ui/widgets/dep-graph.js +9 -0
  205. package/dist/ui/widgets/diff-viewer.js +11 -0
  206. package/dist/ui/widgets/question-card.js +11 -0
  207. package/dist/ui/widgets/stack-frames.js +5 -0
  208. package/dist/upgrade-check.js +28 -0
  209. package/dist/upgrade.js +13 -0
  210. package/package.json +83 -0
@@ -0,0 +1,515 @@
1
+ /**
2
+ * SAP system release detection — auto-fills `<sap_system>` context for
3
+ * skills so they stop asking "are we on S/4HANA or ECC?" when the
4
+ * answer can be determined from a single CVERS query.
5
+ *
6
+ * Strategy (no redundant calls):
7
+ * 1. In-memory cache by alias — same session, same alias = no second query
8
+ * 2. Disk cache at ~/.cspeach/sap-cache/<alias>.json (30-day TTL) —
9
+ * reconnecting next morning skips the query entirely
10
+ * 3. Lazy live query — only when both caches miss, runs ONE small SELECT
11
+ * against CVERS (component versions). Round-trip is ~50 bytes / ~50ms.
12
+ *
13
+ * Failure mode: any error returns null. The caller injects nothing into
14
+ * the session context and the skill falls back to its old behavior of
15
+ * asking. Never blocks a turn.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { auditLog, xmlParser } from '@cspeach/sap-client';
20
+ import { cspeachRoot } from '../config/paths.js';
21
+ const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
22
+ const memCache = new Map();
23
+ /**
24
+ * Top-level entry point. Returns cached info when available, queries
25
+ * CVERS once otherwise, persists the result, returns null on any error.
26
+ */
27
+ export async function getSapSystemInfo(adt, alias) {
28
+ // Layer 1 — process memory
29
+ const cached = memCache.get(alias);
30
+ if (cached)
31
+ return cached;
32
+ // Layer 2 — disk
33
+ const fromDisk = readDiskCache(alias);
34
+ if (fromDisk) {
35
+ memCache.set(alias, fromDisk);
36
+ return fromDisk;
37
+ }
38
+ // Layer 3 — live query
39
+ const startedAt = Date.now();
40
+ try {
41
+ const info = await queryLive(adt, alias);
42
+ memCache.set(alias, info);
43
+ const count = Object.keys(info.components).length;
44
+ // Audit-log every probe outcome. Empty result is itself a signal —
45
+ // usually means the parser regex doesn't match the server's CVERS
46
+ // response shape on this release. Without this line every failure
47
+ // was silent (catch swallowed everything, empty case wrote nothing).
48
+ if (count > 0) {
49
+ writeDiskCache(alias, info);
50
+ await auditLog('sapProbe', 'ALIAS', alias, 'success', Date.now() - startedAt, `components=${count} platform=${info.platform} release=${info.release}`);
51
+ }
52
+ else {
53
+ // Do NOT persist — caching an empty result would freeze a parser
54
+ // bug into a 30-day "I don't know" answer.
55
+ await auditLog('sapProbe', 'ALIAS', alias, 'error', Date.now() - startedAt, 'components=0 (parser empty)');
56
+ }
57
+ return info;
58
+ }
59
+ catch (err) {
60
+ await auditLog('sapProbe', 'ALIAS', alias, 'error', Date.now() - startedAt, `threw: ${err.message}`);
61
+ return null;
62
+ }
63
+ }
64
+ /** Test-only: invalidate caches between tests. */
65
+ export function _resetSystemInfoCache() {
66
+ memCache.clear();
67
+ }
68
+ async function queryLive(adt, alias) {
69
+ // S4FND covers some private-cloud variants where S4CORE isn't shipped
70
+ // — including both broadens detection without extra cost.
71
+ const sql = `SELECT COMPONENT, RELEASE FROM CVERS WHERE COMPONENT IN ('SAP_BASIS', 'S4CORE', 'S4FND', 'SAP_ABA')`;
72
+ // Dispatch order is INTENTIONALLY connection-first: AdtClient.sqlQuery
73
+ // internally uses parseDataPreview which silently returns empty rows on
74
+ // shapes it doesn't recognise — exactly the failure mode that surfaced
75
+ // as `components=0 (parser empty)` against live S/4HANA on 2026-05-06.
76
+ // By going through the raw POST whenever a real connection is available,
77
+ // we own the parser chain (parseCvers → parseCversTree) end-to-end and
78
+ // get the dump-on-total-failure diagnostic.
79
+ //
80
+ // The sqlQuery fallback exists only for test fakes that pass an adt
81
+ // shaped as `{ sqlQuery: vi.fn(...) }` with no connection. Production
82
+ // AdtClient always reaches the connection branch first.
83
+ let components;
84
+ const conn = adt.connection;
85
+ if (conn?.post) {
86
+ const url = '/sap/bc/adt/datapreview/freestyle?rowNumber=50';
87
+ const accept = 'application/vnd.sap.adt.datapreview.table.v1+xml';
88
+ const resp = await conn.post(url, sql, 'text/plain', accept);
89
+ if (resp.status !== 200)
90
+ throw new Error(`CVERS query HTTP ${resp.status}`);
91
+ components = await parseCversAny(resp.body);
92
+ // Diagnostic dump on total parser failure. Permanent feature — gives
93
+ // any customer site a self-contained way to send us the actual response
94
+ // shape when the parser chain can't decode it. Only writes when ALL
95
+ // parsers returned empty AND the HTTP was 200, so successful sessions
96
+ // never touch this file.
97
+ if (Object.keys(components).length === 0) {
98
+ try {
99
+ const dir = cacheDir();
100
+ if (!existsSync(dir))
101
+ mkdirSync(dir, { recursive: true });
102
+ const safeAlias = alias.replace(/[^A-Za-z0-9._-]/g, '_');
103
+ const dump = join(dir, `_last-empty-probe-${safeAlias}.xml`);
104
+ const header = `<!-- HTTP ${resp.status}, ${resp.body.length} bytes, queried ${new Date().toISOString()} -->\n`;
105
+ writeFileSync(dump, header + resp.body, 'utf8');
106
+ }
107
+ catch {
108
+ // best effort — never block the probe over a debug-write failure
109
+ }
110
+ }
111
+ }
112
+ else if (adt.sqlQuery) {
113
+ // Test-fake path — adt provides pre-extracted rows directly.
114
+ const result = await adt.sqlQuery(sql, 50);
115
+ components = {};
116
+ for (const row of result.rows) {
117
+ let name = '';
118
+ let rel = '';
119
+ for (const [k, v] of Object.entries(row)) {
120
+ const upper = k.toUpperCase();
121
+ if (upper === 'COMPONENT' || upper.endsWith('.COMPONENT'))
122
+ name = String(v ?? '').trim();
123
+ else if (upper === 'RELEASE' || upper.endsWith('.RELEASE'))
124
+ rel = String(v ?? '').trim();
125
+ }
126
+ if (name && rel)
127
+ components[name.toUpperCase()] = rel;
128
+ }
129
+ }
130
+ else {
131
+ throw new Error('adt has neither connection.post nor sqlQuery available');
132
+ }
133
+ return classify(alias, components);
134
+ }
135
+ /**
136
+ * Parse the SAP ADT data-preview response for our CVERS query.
137
+ *
138
+ * Response shape (verified 2026-05-06 against S/4HANA):
139
+ * ```xml
140
+ * <dataPreview:tableData>
141
+ * <dataPreview:columns>
142
+ * <dataPreview:metadata dataPreview:name="COMPONENT" .../>
143
+ * <dataPreview:dataSet>
144
+ * <dataPreview:data>SAP_BASIS</dataPreview:data>
145
+ * <dataPreview:data>S4CORE</dataPreview:data>
146
+ * ...
147
+ * </dataPreview:dataSet>
148
+ * </dataPreview:columns>
149
+ * <dataPreview:columns>
150
+ * <dataPreview:metadata dataPreview:name="RELEASE" .../>
151
+ * <dataPreview:dataSet>
152
+ * <dataPreview:data>757</dataPreview:data>
153
+ * ...
154
+ * </dataPreview:dataSet>
155
+ * </dataPreview:columns>
156
+ * </dataPreview:tableData>
157
+ * ```
158
+ *
159
+ * Note: data is COLUMN-MAJOR (one block per column with all values for
160
+ * that column inside <dataSet>), not row-major. The shared
161
+ * parseDataPreview in sap-client/src/xml-parser.ts looks for
162
+ * <columnName> + <dataRow> + <dataCell> which is a different format —
163
+ * silently returns empty rows on this shape. Local parser sidesteps
164
+ * that bug for system-info; sap-client should be fixed properly later
165
+ * since other tools rely on the same endpoint.
166
+ */
167
+ export function parseCvers(xml) {
168
+ const out = {};
169
+ // Word-boundary `\b` after the tag name is critical — `<data` is a
170
+ // prefix of `<dataSet>`, so without `\b` the data regex would match
171
+ // the dataset wrapper too. Same for `columns` defensively.
172
+ const colsRe = /<(?:[\w:]+:)?columns\b[^>]*>([\s\S]*?)<\/(?:[\w:]+:)?columns\s*>/g;
173
+ const cols = [];
174
+ let m;
175
+ while ((m = colsRe.exec(xml)) !== null) {
176
+ const block = m[1];
177
+ // The metadata's name attribute may have a namespace prefix on the
178
+ // attribute itself (dataPreview:name="...") — match either form.
179
+ const nameMatch = /<(?:[\w:]+:)?metadata\b[^>]*?\b(?:[\w:]+:)?name="([^"]+)"/i.exec(block);
180
+ if (!nameMatch)
181
+ continue;
182
+ const dataRe = /<(?:[\w:]+:)?data\b[^>]*>([\s\S]*?)<\/(?:[\w:]+:)?data\s*>/g;
183
+ const values = [];
184
+ let d;
185
+ while ((d = dataRe.exec(block)) !== null)
186
+ values.push(d[1].trim());
187
+ cols.push({ name: nameMatch[1], values });
188
+ }
189
+ const componentCol = cols.find((c) => c.name.toUpperCase() === 'COMPONENT');
190
+ const releaseCol = cols.find((c) => c.name.toUpperCase() === 'RELEASE');
191
+ if (!componentCol || !releaseCol)
192
+ return out;
193
+ const len = Math.min(componentCol.values.length, releaseCol.values.length);
194
+ for (let i = 0; i < len; i++) {
195
+ const c = componentCol.values[i];
196
+ const r = releaseCol.values[i];
197
+ if (c && r)
198
+ out[c.toUpperCase()] = r;
199
+ }
200
+ return out;
201
+ }
202
+ /**
203
+ * System-agnostic CVERS parser. Tries every known shape in turn and
204
+ * returns the first non-empty result.
205
+ *
206
+ * Why a chain instead of one parser: SAP ADT data-preview responses vary
207
+ * across SAP_BASIS releases, ServicePack levels, and ADT plugin versions.
208
+ * Live evidence on 2026-05-06: a regex written against documented shape
209
+ * returned zero components on a real S/4HANA system. Hand-tuning the
210
+ * regex to that customer's response would just shift the breakage to the
211
+ * next system. Instead: try cheap regex first, fall back to a tree walk
212
+ * that's namespace-agnostic and covers both column-major and row-major
213
+ * encodings, then surrender. Caller writes the raw body to a debug dump
214
+ * on total failure so unknown shapes can be characterised remotely.
215
+ *
216
+ * Shapes covered:
217
+ * 1. Column-major + namespaced + `metadata name="X"` + `dataSet/data` (regex path)
218
+ * 2. Same shape but with attribute-name variations or wildly different
219
+ * namespace prefixes (tree-walker path A)
220
+ * 3. Row-major + `columnName` lists + `dataRow/dataCell` rows (tree-walker path B)
221
+ */
222
+ export async function parseCversAny(xml) {
223
+ // Attempt 1 — cheap regex (the historical parser). Free, sync, no
224
+ // dependency on xml2js. Works on the documented shape, doesn't on much else.
225
+ const regexResult = parseCvers(xml);
226
+ if (Object.keys(regexResult).length > 0)
227
+ return regexResult;
228
+ // Attempt 2 — tree walker via xml2js with namespace stripping. Handles
229
+ // arbitrary namespace prefixes and both major encoding shapes.
230
+ try {
231
+ const treeResult = await parseCversTree(xml);
232
+ if (Object.keys(treeResult).length > 0)
233
+ return treeResult;
234
+ }
235
+ catch {
236
+ // tree parse failed — body might be malformed XML or not XML at all
237
+ }
238
+ return {};
239
+ }
240
+ /**
241
+ * xml2js-backed tree walker. Strips namespace prefixes (so `dataPreview:columns`
242
+ * becomes `columns`), then looks for either:
243
+ * A) Column-major: nested `metadata` (with a `name`/`columnName`/`label`
244
+ * attribute) plus a sibling `dataSet` containing `data`/`value`/`cell`
245
+ * elements — this is the modern S/4HANA shape.
246
+ * B) Row-major: a flat list of `columnName` element texts in order, plus
247
+ * `dataRow` elements containing `dataCell` children — older NetWeaver shape.
248
+ *
249
+ * Either branch returns a flat `{ COMPONENT_NAME: RELEASE_VALUE }` map.
250
+ *
251
+ * Exported for direct unit testing of arbitrary fixtures — the production
252
+ * call site uses `parseCversAny`.
253
+ */
254
+ export async function parseCversTree(xml) {
255
+ if (!xml || xml.trim().length === 0)
256
+ return {};
257
+ // xml2js throws on non-XML or seriously malformed input. The whole
258
+ // chain is meant to be tolerant — non-XML is a "this is not the
259
+ // shape we expected" signal, not an exception. Treat as empty and
260
+ // let the caller dump the raw body.
261
+ let tree;
262
+ try {
263
+ tree = await xmlParser.parseXml(xml);
264
+ }
265
+ catch {
266
+ return {};
267
+ }
268
+ const collected = [];
269
+ function walk(obj, tag) {
270
+ let node;
271
+ if (typeof obj === 'string') {
272
+ node = { tag, attrs: {}, children: [], text: obj };
273
+ }
274
+ else if (typeof obj !== 'object' || obj === null) {
275
+ node = { tag, attrs: {}, children: [], text: String(obj ?? '') };
276
+ }
277
+ else {
278
+ const o = obj;
279
+ const attrs = {};
280
+ if (o['$'] && typeof o['$'] === 'object') {
281
+ for (const [k, v] of Object.entries(o['$'])) {
282
+ attrs[k] = String(v);
283
+ }
284
+ }
285
+ let text = '';
286
+ const children = [];
287
+ for (const [k, v] of Object.entries(o)) {
288
+ if (k === '$')
289
+ continue;
290
+ if (k === '_') {
291
+ text = String(v);
292
+ continue;
293
+ }
294
+ const arr = Array.isArray(v) ? v : [v];
295
+ for (const item of arr)
296
+ children.push(walk(item, k));
297
+ }
298
+ node = { tag, attrs, children, text };
299
+ }
300
+ collected.push(node);
301
+ return node;
302
+ }
303
+ walk(tree, 'root');
304
+ // Strategy A — column-major. A column block is any node whose direct
305
+ // children include a metadata-like element (with a name attribute) AND
306
+ // a dataSet-like element (with data children).
307
+ const columns = [];
308
+ for (const node of collected) {
309
+ let metaName;
310
+ let dataSetValues;
311
+ for (const c of node.children) {
312
+ if (c.tag === 'metadata' || c.tag === 'columnInfo' || c.tag === 'fieldMetadata') {
313
+ metaName = metaName ?? c.attrs['name'] ?? c.attrs['columnName'] ?? c.attrs['label'];
314
+ }
315
+ if (c.tag === 'dataSet') {
316
+ const vals = [];
317
+ for (const dc of c.children) {
318
+ if ((dc.tag === 'data' || dc.tag === 'value' || dc.tag === 'cell') && dc.text) {
319
+ vals.push(dc.text.trim());
320
+ }
321
+ }
322
+ if (vals.length > 0)
323
+ dataSetValues = vals;
324
+ }
325
+ }
326
+ if (metaName && dataSetValues) {
327
+ columns.push({ name: metaName.toUpperCase(), values: dataSetValues });
328
+ }
329
+ }
330
+ if (columns.length >= 2) {
331
+ const comp = columns.find((c) => c.name === 'COMPONENT');
332
+ const rel = columns.find((c) => c.name === 'RELEASE');
333
+ if (comp && rel) {
334
+ const out = {};
335
+ const n = Math.min(comp.values.length, rel.values.length);
336
+ for (let i = 0; i < n; i++) {
337
+ const cv = comp.values[i];
338
+ const rv = rel.values[i];
339
+ if (cv && rv)
340
+ out[cv.toUpperCase()] = rv;
341
+ }
342
+ if (Object.keys(out).length > 0)
343
+ return out;
344
+ }
345
+ }
346
+ // Strategy B — row-major. columnName texts give us the column ordering;
347
+ // dataRow elements with dataCell children give us the values.
348
+ const colNames = [];
349
+ const rowEls = [];
350
+ for (const node of collected) {
351
+ if (node.tag === 'columnName' && node.text)
352
+ colNames.push(node.text.trim());
353
+ if (node.tag === 'dataRow' || node.tag === 'row')
354
+ rowEls.push(node);
355
+ }
356
+ if (colNames.length > 0 && rowEls.length > 0) {
357
+ const compIdx = colNames.findIndex((n) => n.toUpperCase() === 'COMPONENT');
358
+ const relIdx = colNames.findIndex((n) => n.toUpperCase() === 'RELEASE');
359
+ if (compIdx >= 0 && relIdx >= 0) {
360
+ const out = {};
361
+ for (const r of rowEls) {
362
+ const cells = r.children.filter((c) => c.tag === 'dataCell' || c.tag === 'cell' || c.tag === 'value');
363
+ const compVal = cells[compIdx]?.text?.trim();
364
+ const relVal = cells[relIdx]?.text?.trim();
365
+ if (compVal && relVal)
366
+ out[compVal.toUpperCase()] = relVal;
367
+ }
368
+ if (Object.keys(out).length > 0)
369
+ return out;
370
+ }
371
+ }
372
+ return {};
373
+ }
374
+ /**
375
+ * Derive `{ platform, release, abapVersion }` from the raw CVERS rows.
376
+ * Pure function — exported for unit tests without going through
377
+ * caching or live query.
378
+ */
379
+ export function classify(alias, components) {
380
+ const sapBasis = components['SAP_BASIS'];
381
+ const s4Core = components['S4CORE'] || components['S4FND'];
382
+ const platform = s4Core ? 'S/4HANA'
383
+ : sapBasis ? (parseInt(sapBasis, 10) >= 750 ? 'NetWeaver' : 'ECC')
384
+ : 'unknown';
385
+ const release = humanReadableRelease(platform, sapBasis, s4Core);
386
+ const abapVersion = abapVersionFor(sapBasis);
387
+ return {
388
+ alias,
389
+ platform,
390
+ release,
391
+ abapVersion,
392
+ // Public-cloud detection isn't reliable from CVERS alone; CSPeach
393
+ // running against a system over ADT typically rules out Public Cloud
394
+ // (which restricts ADT access). Mark conservatively.
395
+ deployment: 'on-prem-or-private-cloud',
396
+ components,
397
+ queriedAt: new Date().toISOString(),
398
+ };
399
+ }
400
+ /**
401
+ * S4CORE → S/4HANA edition mapping. Source: SAP release roadmap.
402
+ * SAP_BASIS alone is ambiguous (e.g. 757 covers both S/4 2021 and 2022),
403
+ * so we anchor on S4CORE when present and fall back to SAP_BASIS only
404
+ * for ECC/NW systems.
405
+ */
406
+ function humanReadableRelease(platform, sapBasis, s4Core) {
407
+ if (platform === 'S/4HANA' && s4Core) {
408
+ const map = {
409
+ '100': 'S/4HANA 1503',
410
+ '101': 'S/4HANA 1511',
411
+ '102': 'S/4HANA 1610',
412
+ '103': 'S/4HANA 1709',
413
+ '104': 'S/4HANA 1809',
414
+ '105': 'S/4HANA 1909',
415
+ '106': 'S/4HANA 2020',
416
+ '107': 'S/4HANA 2022',
417
+ '108': 'S/4HANA 2023',
418
+ '109': 'S/4HANA 2023 FPS01',
419
+ '110': 'S/4HANA 2025',
420
+ };
421
+ return map[s4Core] ?? `S/4HANA (S4CORE ${s4Core})`;
422
+ }
423
+ if (platform === 'NetWeaver' && sapBasis) {
424
+ return `NetWeaver / ECC (SAP_BASIS ${sapBasis})`;
425
+ }
426
+ if (platform === 'ECC' && sapBasis) {
427
+ const map = {
428
+ '700': 'ECC 6.0',
429
+ '701': 'ECC 6.0 EHP1',
430
+ '702': 'ECC 6.0 EHP2',
431
+ '703': 'ECC 6.0 EHP3',
432
+ '704': 'ECC 6.0 EHP4',
433
+ '730': 'ECC 6.0 EHP5/6',
434
+ '731': 'ECC 6.0 EHP6',
435
+ '740': 'ECC 6.0 EHP7',
436
+ };
437
+ return map[sapBasis] ?? `ECC 6.0 (SAP_BASIS ${sapBasis})`;
438
+ }
439
+ return 'unknown';
440
+ }
441
+ /**
442
+ * SAP_BASIS to ABAP language version. Skills use this to know which
443
+ * ABAP syntax is available — `7.40` for inline data, `7.50` for FILTER,
444
+ * `7.55+` for full S/4 RAP, etc.
445
+ */
446
+ function abapVersionFor(sapBasis) {
447
+ if (!sapBasis)
448
+ return 'unknown';
449
+ // SAP_BASIS release directly maps: 754 = 7.54, 757 = 7.57.
450
+ const n = parseInt(sapBasis, 10);
451
+ if (Number.isNaN(n))
452
+ return 'unknown';
453
+ // Modern releases — return as N.NN (e.g. 757 → 7.57).
454
+ if (n >= 740 && n < 800)
455
+ return `7.${n - 700}`;
456
+ return sapBasis;
457
+ }
458
+ // ── Disk cache ─────────────────────────────────────────────────────────────
459
+ function cacheDir() {
460
+ return join(cspeachRoot(), 'sap-cache');
461
+ }
462
+ function cacheFile(alias) {
463
+ // Sanitise alias to a safe filename. Aliases are short identifiers
464
+ // ([A-Za-z0-9_-]) by convention but we strip anything else defensively.
465
+ const safe = alias.replace(/[^A-Za-z0-9._-]/g, '_');
466
+ return join(cacheDir(), `${safe}.json`);
467
+ }
468
+ function readDiskCache(alias) {
469
+ try {
470
+ const path = cacheFile(alias);
471
+ if (!existsSync(path))
472
+ return null;
473
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
474
+ if (!raw.queriedAt)
475
+ return null;
476
+ const age = Date.now() - new Date(raw.queriedAt).getTime();
477
+ if (age > CACHE_TTL_MS)
478
+ return null;
479
+ return raw;
480
+ }
481
+ catch {
482
+ return null;
483
+ }
484
+ }
485
+ function writeDiskCache(alias, info) {
486
+ try {
487
+ const dir = cacheDir();
488
+ if (!existsSync(dir))
489
+ mkdirSync(dir, { recursive: true });
490
+ writeFileSync(cacheFile(alias), JSON.stringify(info, null, 2), 'utf8');
491
+ }
492
+ catch {
493
+ // best-effort — we still return the in-memory result
494
+ }
495
+ }
496
+ /**
497
+ * Render the SAP system block for `<session_context>`. Returns an
498
+ * empty string when info is null so the caller can concatenate
499
+ * unconditionally without conditional logic at every call site.
500
+ */
501
+ export function renderSapSystemBlock(info) {
502
+ if (!info)
503
+ return '';
504
+ const attrs = [
505
+ `alias="${escapeAttr(info.alias)}"`,
506
+ `platform="${escapeAttr(info.platform)}"`,
507
+ `release="${escapeAttr(info.release)}"`,
508
+ `abap_version="${escapeAttr(info.abapVersion)}"`,
509
+ `deployment="${escapeAttr(info.deployment)}"`,
510
+ ].join(' ');
511
+ return `<sap_system ${attrs} />`;
512
+ }
513
+ function escapeAttr(s) {
514
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
515
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Detect whether the last assistant message in a session contains a
3
+ * "question marker" — signalling that the skill is waiting for the user's
4
+ * answer. When true, the next REPL turn should route to the same skill
5
+ * without re-classifying.
6
+ *
7
+ * Markers (any one triggers true):
8
+ * - The literal phrase "Reply with your answer" — mandated outro of the
9
+ * v0.3.1 question-widget pattern in skill prompts.
10
+ * - The `<!-- widget-real -->` sentinel — raw widget fence emission, in
11
+ * case the skill emitted the fence before our prose normaliser.
12
+ *
13
+ * Pure function — no I/O, no side effects. Takes an assistant-message
14
+ * content payload (string OR Anthropic-style array of content blocks) and
15
+ * returns boolean.
16
+ */
17
+ // Markers we accept as a "skill is asking a question, expect an answer next"
18
+ // signal. Kept deliberately broad because LLMs paraphrase the canonical
19
+ // instruction ("Reply with your answer and I'll ask the next one") into
20
+ // variants like "Reply with your choice" / "Reply with your pick".
21
+ // - PHRASE: "Reply with your <noun>" in any casing
22
+ // - PICK_HEADER: "Pick one:" or "Pick any" on a line (our question-widget
23
+ // normaliser emits these; and skill prompts tend to use them verbatim)
24
+ // - SENTINEL: the raw cspeach-widget:question sentinel, in case a fence
25
+ // did emit but the prose normaliser didn't fully consume it
26
+ const MARKER_PHRASE = /Reply with your \w+/i;
27
+ const MARKER_PICK_HEADER = /^\s*Pick (one|any)[\s:(]/im;
28
+ const MARKER_SENTINEL = /<!-- widget-real -->/;
29
+ // Fallback: if the prose normaliser failed and raw JSON leaked into the
30
+ // rendered output, the `"kind":"choice|multi|text"` fragment is still a
31
+ // strong signal that a question widget was intended. Catches the streaming
32
+ // edge case where the normaliser couldn't complete the parse.
33
+ const MARKER_JSON_KIND = /"kind"\s*:\s*"(choice|multi|text)"/;
34
+ export function messageContainsQuestionMarker(content) {
35
+ const text = extractText(content);
36
+ if (!text)
37
+ return false;
38
+ return (MARKER_PHRASE.test(text) ||
39
+ MARKER_PICK_HEADER.test(text) ||
40
+ MARKER_SENTINEL.test(text) ||
41
+ MARKER_JSON_KIND.test(text));
42
+ }
43
+ /** Extract plain text from an assistant-message content payload. */
44
+ function extractText(content) {
45
+ if (typeof content === 'string')
46
+ return content;
47
+ if (!Array.isArray(content))
48
+ return '';
49
+ // Anthropic content blocks: [{type:'text', text:'...'}, {type:'tool_use',...}, ...]
50
+ return content
51
+ .map((block) => {
52
+ if (block && typeof block === 'object' && 'type' in block) {
53
+ const b = block;
54
+ if (b.type === 'text' && typeof b.text === 'string')
55
+ return b.text;
56
+ }
57
+ return '';
58
+ })
59
+ .join('\n');
60
+ }
61
+ /**
62
+ * Scan the session's messages from the end backward and return whether the
63
+ * most recent assistant message (if any) contains a question marker.
64
+ * Returns false if no assistant message has been recorded yet.
65
+ */
66
+ export function lastAssistantIsAwaitingAnswer(messages) {
67
+ for (let i = messages.length - 1; i >= 0; i--) {
68
+ const m = messages[i];
69
+ if (m.role === 'assistant')
70
+ return messageContainsQuestionMarker(m.content);
71
+ }
72
+ return false;
73
+ }
@@ -0,0 +1,28 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { sessionsDir, pinnedSessionsDir, snapshotsDir } from '../config/paths.js';
4
+ export async function runGcOnStartup() {
5
+ const now = Date.now();
6
+ const threshold = now - 30 * 24 * 60 * 60 * 1000; // 30 days
7
+ const sessionsRemoved = await gcDir(sessionsDir(), threshold, [pinnedSessionsDir()]);
8
+ const snapshotsRemoved = await gcDir(snapshotsDir(), threshold);
9
+ return { sessions: sessionsRemoved, snapshots: snapshotsRemoved };
10
+ }
11
+ async function gcDir(dir, thresholdMs, excludeDirs = []) {
12
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
13
+ let removed = 0;
14
+ for (const entry of entries) {
15
+ const full = path.join(dir, entry.name);
16
+ if (entry.isDirectory() && excludeDirs.includes(full))
17
+ continue;
18
+ try {
19
+ const stat = await fs.stat(full);
20
+ if (stat.mtimeMs < thresholdMs) {
21
+ await fs.rm(full, { recursive: entry.isDirectory(), force: true });
22
+ removed++;
23
+ }
24
+ }
25
+ catch { /* ignore — file may have been removed concurrently */ }
26
+ }
27
+ return removed;
28
+ }
@@ -0,0 +1,37 @@
1
+ import { saveSession } from './store.js';
2
+ export async function appendPending(session, entry) {
3
+ session.toolCalls.push({ ...entry, completed: false });
4
+ await saveSession(session);
5
+ }
6
+ export async function finalizeToolCall(session, toolUseId, result, isError, durationMs) {
7
+ const idx = session.toolCalls.findIndex((c) => c.tool_use_id === toolUseId && !c.completed);
8
+ if (idx < 0) {
9
+ session.toolCalls.push({
10
+ tool_use_id: toolUseId,
11
+ tool: 'unknown',
12
+ args: null,
13
+ result,
14
+ is_error: isError,
15
+ duration_ms: durationMs,
16
+ completed_at: new Date().toISOString(),
17
+ completed: true,
18
+ });
19
+ }
20
+ else {
21
+ const pending = session.toolCalls[idx];
22
+ session.toolCalls[idx] = {
23
+ tool_use_id: pending.tool_use_id,
24
+ tool: pending.tool,
25
+ args: pending.args,
26
+ result,
27
+ is_error: isError,
28
+ duration_ms: durationMs,
29
+ completed_at: new Date().toISOString(),
30
+ completed: true,
31
+ };
32
+ }
33
+ await saveSession(session);
34
+ }
35
+ export function hasUnfinishedMutations(session) {
36
+ return session.toolCalls.some((c) => !c.completed);
37
+ }