@mrclrchtr/supi-consulting 11.0.1 → 11.2.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.
- package/README.md +46 -6
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +41 -1
- package/package.json +2 -2
- package/scripts/live-probe.ts +6 -3
- package/src/agents/antigravity/adapter.ts +6 -2
- package/src/agents/antigravity/isolated-home.ts +1 -7
- package/src/agents/antigravity/process/event-values.ts +39 -13
- package/src/agents/antigravity/process/events.ts +154 -131
- package/src/agents/antigravity/process/hooks.ts +9 -1
- package/src/agents/antigravity/process/permission-diagnostics.ts +260 -0
- package/src/agents/antigravity/process/protocol.ts +57 -1
- package/src/agents/antigravity/process/runner.ts +50 -19
- package/src/agents/errors.ts +190 -10
- package/src/agents/types.ts +2 -0
- package/src/config.ts +60 -4
- package/src/runtime.ts +3 -1
- package/src/settings.ts +21 -2
- package/src/tool/consulting_run/execute.ts +13 -0
- package/src/tool/consulting_run/guidance.ts +1 -1
- package/src/tool/consulting_run/register.ts +3 -0
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ A new Consultation requires the agent, an available model, and Workspace Access:
|
|
|
65
65
|
|
|
66
66
|
The Model Catalogue is the intersection of the curated Antigravity models and the models available to the signed-in account. It stays fixed until the next session start or reload. There is no default model or automatic agent fallback.
|
|
67
67
|
|
|
68
|
-
Set `workspace` to `true` only when the Consultation needs repository evidence. This exposes the current PI workspace. Set it to `false` to use the empty Consultation Workspace.
|
|
68
|
+
Set `workspace` to `true` only when the Consultation needs repository evidence. This exposes the current PI workspace. Set it to `false` to use the empty Consultation Workspace. Explicitly configured additional read-only directories remain accessible with either selection.
|
|
69
69
|
|
|
70
70
|
A follow-up supplies only a returned Conversation Handle:
|
|
71
71
|
|
|
@@ -82,16 +82,43 @@ Results report web use, workspace use, observed and claimed references, warnings
|
|
|
82
82
|
|
|
83
83
|
Use `/supi-settings` → **Consulting** to disable the tool. Disabling it skips availability checks and removes `consulting_run` from the active tool list.
|
|
84
84
|
|
|
85
|
+
## Additional read-only directories
|
|
86
|
+
|
|
87
|
+
Use `/supi-settings` → **Consulting** → **Additional read-only directories** to enter comma-separated absolute directory paths. The default is an empty list. No shell expansion or glob matching is performed. For paths containing commas, edit the JSON configuration instead.
|
|
88
|
+
|
|
89
|
+
Global configuration lives at `~/.pi/agent/supi/config.json`; project configuration lives at `.pi/supi/config.json`:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"consulting": {
|
|
94
|
+
"additionalReadOnlyDirectories": ["/tmp/octalog-filter-previews"]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- Project values replace the global list, rather than extending it. An explicit `[]` removes inherited grants; Inherit/Reset deletes the scoped key. Untrusted project configuration cannot grant access; global grants still apply.
|
|
100
|
+
- Directories must exist and be accessible when a Consultation starts. Symlinks are resolved and duplicate roots are removed, so `/tmp` resolves to `/private/tmp` on macOS without needing both entries.
|
|
101
|
+
- Settings are read from the current Pi workspace before each new Consultation and follow-up. Changes apply to the next invocation without reloading. They do not modify an already-running process.
|
|
102
|
+
- The directories are registered with Antigravity per process using `--add-dir`, not persisted as permission grants in the shared profile. The fixed write, command, MCP, and URL-execution denials remain in force; `allowNonWorkspaceAccess` stays `false`.
|
|
103
|
+
- Configured directories are available even with `workspace: false`. Prefer task-specific artifact folders over all of `/tmp`, which may contain unrelated sensitive files. This setting does not authorize shell-based browser automation.
|
|
104
|
+
- Reads from extra directories can contribute inspection activity, but references outside the selected workspace are not reported as validated workspace-relative evidence.
|
|
105
|
+
|
|
106
|
+
Directory registration and deny-rule precedence follow Antigravity's [CLI reference](https://antigravity.google/docs/cli/reference/) and [permissions reference](https://antigravity.google/docs/permissions/). These are Antigravity policy restrictions, not a new operating-system security boundary.
|
|
107
|
+
|
|
85
108
|
## Failure diagnostics
|
|
86
109
|
|
|
87
110
|
Execution failures report a fixed reason when the adapter can identify it:
|
|
88
111
|
|
|
89
|
-
- Missing final result, missing structured answer, or answer
|
|
112
|
+
- Missing final result, missing structured answer, answer schema mismatch, or an answer not bound to the current request.
|
|
90
113
|
- Unsuccessful final result or data received after the final result.
|
|
91
114
|
- Malformed JSON, a non-object event, or an output limit exceeded.
|
|
92
115
|
- Missing conversation identifier, process failure, timeout, or cancellation.
|
|
93
116
|
|
|
94
|
-
|
|
117
|
+
Each invocation adds a unique response token to its private answer schema and validates the returned token locally before accepting the answer. The token is removed from public results and excluded from failure diagnostics. This prevents a resumed Antigravity conversation's previous structured answer from being accepted as the current result. Missing or mismatched tokens fail with `unbound-answer`; plain-text answers are not substituted for the structured contract. A started follow-up that fails retires its Conversation Handle; start a new Consultation.
|
|
118
|
+
|
|
119
|
+
An observed tool permission denial is reported separately. It does not prove the cause of a later failure. A denial does not fail a Consultation that returns a valid answer. Workspace Access alone does not grant access to files outside the selected workspace; configure additional read-only directories explicitly.
|
|
120
|
+
|
|
121
|
+
Failure messages returned to the calling agent include the observed terminal status and denial counts by fixed operation category (`command`, `file-read`, `file-write`, `web-read`, `browser`, `mcp`, or `other`). They distinguish a successful agent terminal status from a usable final answer and recommend permitted inspection or a limited/blocked report rather than repeating denied actions unchanged. This feedback remains available when Debug is disabled.
|
|
95
122
|
|
|
96
123
|
When SuPi Debug is enabled, failed executions emit one `consulting/consultation.failed` event at `error` level. Cancellations emit `consulting/consultation.cancelled` at `info` level. Use the `debug` tool:
|
|
97
124
|
|
|
@@ -101,13 +128,26 @@ When SuPi Debug is enabled, failed executions emit one `consulting/consultation.
|
|
|
101
128
|
|
|
102
129
|
Omit `level` to include cancellations. Add `sessionFile` to inspect events persisted in a saved PI session. A response aborted before the adapter starts has no Consulting event.
|
|
103
130
|
|
|
104
|
-
Events contain the execution phase, timing, selected model, workspace-access and follow-up flags, failure category and reason, and available event counts, terminal-state flags, permission-denial count, and exit code.
|
|
131
|
+
Events contain the execution phase, timing, selected model, workspace-access and follow-up flags, failure category and reason, and available event counts, terminal-state flags, permission-denial count, and exit code. `permissionDenialsByOperation` breaks down deduplicated denials by fixed category; unknown tool names become `other`. `deniedFileAccessOutsideReadRoots` counts denied file operations whose requested paths resolve outside the selected directory and configured additional read-only directories, following symlinks and existing parents of missing targets. Unavailable path information, dangling symlink destinations, and paths containing parent traversal (`..`) remain unclassified rather than risking an incorrect outside-root claim. This location observation does not identify which rule caused a denial.
|
|
132
|
+
|
|
133
|
+
Antigravity's terminal `denied_actions` summary can establish a denial even when its detailed tool-error update is absent. It establishes a minimum of one observation per operation category, not additional calls on top of already-correlated denials. Summary entries have no tool-call IDs or file parameters, so the adapter does not attribute them to nearby or unfinished file requests.
|
|
134
|
+
|
|
135
|
+
Failures also include bounded requested file-target metadata in both Debug and caller-visible feedback:
|
|
136
|
+
|
|
137
|
+
- `deniedResources` lists at most eight distinct `{ operation, requestedPath }` references for known file operations observed as denied. Targets come from structured parameters on the denied event or earlier updates correlated by tool-call ID, never from error text, command parsing, nearby calls, or transcripts.
|
|
138
|
+
- `requestedPath` preserves the supplied path, including significant whitespace. It is not a canonical filesystem path or the permission checker's resolved target, and does not identify a permanent path blacklist or the denying rule. Paths are limited to 1,024 UTF-16 characters; longer paths are unavailable rather than silently shortened.
|
|
139
|
+
- `deniedResourcesOmitted` counts file-denial observations whose known targets were omitted because the list was full, not unique omitted paths. Repeated denials for listed targets do not duplicate entries.
|
|
140
|
+
- `deniedResourcePathsUnavailable` counts known file-denial observations without an available, bounded structured target. Missing correlation or parameters remain unknown; the adapter does not infer a path from prose. Explicit replacement targets supersede earlier metadata, including when the replacement is invalid or overlong; only omitted path parameters preserve an earlier target.
|
|
141
|
+
- `unfinishedResources` separately lists at most eight distinct requested targets from ID-correlated file calls with no observed terminal outcome. These are **unknown outcomes, not confirmed denials**, even if a terminal summary reports the same operation category. Successful and already-denied calls are excluded. `unfinishedResourcesOmitted` counts capacity-omitted observations; `unfinishedResourcePathsUnavailable` counts observations lacking a usable target. The same path limits, replacement semantics, and deduplication rules apply as for denied resources.
|
|
142
|
+
- Caller-visible references are JSON-quoted, with terminal control characters and line separators escaped. **Denied and unfinished requested paths can contain sensitive metadata and are exposed to the calling agent and persisted with enabled Debug events.**
|
|
143
|
+
|
|
144
|
+
Events otherwise exclude prompts, answers, arbitrary tool names, raw stdout, stderr, complete tool payloads, and error causes, even when raw Debug access is enabled. The event's `cwd` identifies the selected working directory. Successful Consultations do not gain a failure event merely because an action was denied.
|
|
105
145
|
|
|
106
146
|
## Security limits
|
|
107
147
|
|
|
108
|
-
The Antigravity adapter enforces its Inspection Permission Set in the Isolated Antigravity Home. It allows `read_url(*)` and denies known file writes, commands,
|
|
148
|
+
The Antigravity adapter enforces its Inspection Permission Set in the Isolated Antigravity Home. It allows `read_url(*)` and denies known file writes, commands, MCP, and URL execution. The `command(*)` rule covers commands inside and outside Antigravity's terminal sandbox; the obsolete, ignored `unsandboxed(*)` rule is not emitted. These rules are **not an operating-system sandbox**.
|
|
109
149
|
|
|
110
|
-
For
|
|
150
|
+
For Consultations with Workspace Access or additional read-only directories, project-local hooks can run commands, read the Antigravity transcript, and cause side effects outside these permission rules. The adapter probes hook state with the additional roots before the paid process and warns when hooks are active or unknown. Directory grants are intended for inspection artifacts, not untrusted project customizations.
|
|
111
151
|
|
|
112
152
|
`read_url(*)` can reach local, private-network, and link-local endpoints that Antigravity accepts. It does not enforce public-internet-only access.
|
|
113
153
|
|
|
@@ -14,6 +14,8 @@ export interface DebugRegistryConfig {
|
|
|
14
14
|
enabled: boolean;
|
|
15
15
|
/** What the agent-callable debug tool may return. */
|
|
16
16
|
agentAccess: DebugAgentAccess;
|
|
17
|
+
/** Whether Agent Run retrospective capture is explicitly enabled. */
|
|
18
|
+
agentRunRetrospectivesEnabled: boolean;
|
|
17
19
|
/** Maximum number of session-local events to keep in memory. */
|
|
18
20
|
maxEvents: number;
|
|
19
21
|
}
|
|
@@ -21,6 +23,7 @@ export interface DebugRegistryConfig {
|
|
|
21
23
|
export const DEBUG_REGISTRY_DEFAULTS: DebugRegistryConfig = {
|
|
22
24
|
enabled: false,
|
|
23
25
|
agentAccess: "sanitized",
|
|
26
|
+
agentRunRetrospectivesEnabled: false,
|
|
24
27
|
maxEvents: 100,
|
|
25
28
|
};
|
|
26
29
|
|
|
@@ -75,6 +78,9 @@ export interface DebugEventQueryResult {
|
|
|
75
78
|
/** Receives a sanitized event whenever the registry records one. */
|
|
76
79
|
export type DebugEventListener = (event: DebugEventView) => void;
|
|
77
80
|
|
|
81
|
+
/** Receives sanitized events for persistence; separate from ordinary event subscriptions. */
|
|
82
|
+
export type DebugPersistenceSink = (event: DebugEventView) => void;
|
|
83
|
+
|
|
78
84
|
export interface DebugSummary {
|
|
79
85
|
total: number;
|
|
80
86
|
byLevel: Partial<Record<DebugLevel, number>>;
|
|
@@ -85,6 +91,7 @@ interface DebugRegistryState {
|
|
|
85
91
|
config: DebugRegistryConfig;
|
|
86
92
|
events: DebugEvent[];
|
|
87
93
|
listeners: Set<DebugEventListener>;
|
|
94
|
+
persistenceSinks: Set<DebugPersistenceSink>;
|
|
88
95
|
nextId: number;
|
|
89
96
|
}
|
|
90
97
|
|
|
@@ -111,13 +118,15 @@ function getState(): DebugRegistryState {
|
|
|
111
118
|
config: cloneConfig(DEBUG_REGISTRY_DEFAULTS),
|
|
112
119
|
events: [],
|
|
113
120
|
listeners: new Set(),
|
|
121
|
+
persistenceSinks: new Set(),
|
|
114
122
|
nextId: 1,
|
|
115
123
|
};
|
|
116
124
|
(globalThis as Record<symbol, unknown>)[REGISTRY_KEY] = state;
|
|
117
125
|
}
|
|
118
126
|
// Keep the shared registry compatible with extension reloads that reuse an
|
|
119
|
-
// instance created before
|
|
127
|
+
// instance created before listener sets existed.
|
|
120
128
|
if (!state.listeners) state.listeners = new Set();
|
|
129
|
+
if (!state.persistenceSinks) state.persistenceSinks = new Set();
|
|
121
130
|
return state;
|
|
122
131
|
}
|
|
123
132
|
|
|
@@ -200,6 +209,19 @@ export function isDebugRegistryEnabled(): boolean {
|
|
|
200
209
|
return getState().config.enabled;
|
|
201
210
|
}
|
|
202
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Return whether a retrospective can be captured: Debug and its explicit opt-in
|
|
214
|
+
* must be enabled, and at least one persistence sink must be registered.
|
|
215
|
+
*/
|
|
216
|
+
export function canRecordAgentRunRetrospective(): boolean {
|
|
217
|
+
const state = getState();
|
|
218
|
+
return (
|
|
219
|
+
state.config.enabled &&
|
|
220
|
+
state.config.agentRunRetrospectivesEnabled &&
|
|
221
|
+
state.persistenceSinks.size > 0
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
203
225
|
/** Best-effort redaction helper for data exposed through sanitized debug views. */
|
|
204
226
|
export function redactDebugData<T>(value: T): T {
|
|
205
227
|
return redactValue(value, 8) as T;
|
|
@@ -226,6 +248,16 @@ export function subscribeDebugEvents(listener: DebugEventListener): () => void {
|
|
|
226
248
|
return () => state.listeners.delete(listener);
|
|
227
249
|
}
|
|
228
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Register a sanitized event persistence sink. Sinks are tracked separately from
|
|
253
|
+
* ordinary listeners so eligibility checks only succeed when logging is active.
|
|
254
|
+
*/
|
|
255
|
+
export function subscribeDebugPersistence(sink: DebugPersistenceSink): () => void {
|
|
256
|
+
const state = getState();
|
|
257
|
+
state.persistenceSinks.add(sink);
|
|
258
|
+
return () => state.persistenceSinks.delete(sink);
|
|
259
|
+
}
|
|
260
|
+
|
|
229
261
|
/** Record a session-local debug event if debugging is enabled. */
|
|
230
262
|
export function recordDebugEvent(input: DebugEventInput): DebugEvent | null {
|
|
231
263
|
const state = getState();
|
|
@@ -251,6 +283,13 @@ export function recordDebugEvent(input: DebugEventInput): DebugEvent | null {
|
|
|
251
283
|
// Debug-event consumers must not alter producer behavior.
|
|
252
284
|
}
|
|
253
285
|
}
|
|
286
|
+
for (const sink of state.persistenceSinks) {
|
|
287
|
+
try {
|
|
288
|
+
sink(toSanitizedView(event));
|
|
289
|
+
} catch {
|
|
290
|
+
// Persistence failures must not alter producer behavior.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
254
293
|
return {
|
|
255
294
|
...event,
|
|
256
295
|
data: event.data === undefined ? undefined : redactDebugData(event.data),
|
|
@@ -309,5 +348,6 @@ export function resetDebugRegistry(): void {
|
|
|
309
348
|
state.config = cloneConfig(DEBUG_REGISTRY_DEFAULTS);
|
|
310
349
|
state.events = [];
|
|
311
350
|
state.listeners.clear();
|
|
351
|
+
state.persistenceSinks.clear();
|
|
312
352
|
state.nextId = 1;
|
|
313
353
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-consulting",
|
|
3
|
-
"version": "11.0
|
|
3
|
+
"version": "11.2.0",
|
|
4
4
|
"description": "Bounded consultations with explicit agent and workspace selection and observed evidence",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"docs/adr/*.md"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@mrclrchtr/supi-core": "11.0
|
|
40
|
+
"@mrclrchtr/supi-core": "11.2.0"
|
|
41
41
|
},
|
|
42
42
|
"bundledDependencies": [
|
|
43
43
|
"@mrclrchtr/supi-core"
|
package/scripts/live-probe.ts
CHANGED
|
@@ -30,14 +30,17 @@ async function main(): Promise<void> {
|
|
|
30
30
|
requireWebEvidence(first);
|
|
31
31
|
|
|
32
32
|
const second = await adapter.execute({
|
|
33
|
-
prompt:
|
|
33
|
+
prompt:
|
|
34
|
+
"Without using tools, return only the exact context token I gave you in the preceding request.",
|
|
34
35
|
model: MODEL,
|
|
35
36
|
canonicalWorkingDirectory: consultationWorkspace,
|
|
36
37
|
workspaceAccess: false,
|
|
37
38
|
continuation: first.continuation,
|
|
38
39
|
});
|
|
39
|
-
if (
|
|
40
|
-
throw new Error(
|
|
40
|
+
if (second.answer.answer.trim() !== CONTEXT_TOKEN) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"The follow-up did not return the requested current-turn answer from conversation context.",
|
|
43
|
+
);
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
const fixture = await realpath(await mkdtemp(join(tmpdir(), "supi-consulting-live-")));
|
|
@@ -111,8 +111,10 @@ class AntigravityAdapter implements ConsultingAgentAdapter {
|
|
|
111
111
|
? decodeContinuation(options.continuation)
|
|
112
112
|
: undefined;
|
|
113
113
|
await prepareIsolatedAntigravityHome(this.#paths);
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
const inspectHooks =
|
|
115
|
+
options.workspaceAccess || (options.additionalReadOnlyDirectories?.length ?? 0) > 0;
|
|
116
|
+
if (inspectHooks) onPhase("hook-probe");
|
|
117
|
+
const hookWarning = inspectHooks
|
|
116
118
|
? await inspectProjectHooks(this.#paths, options, options.onWarning)
|
|
117
119
|
: undefined;
|
|
118
120
|
onPhase("conversation");
|
|
@@ -123,6 +125,7 @@ class AntigravityAdapter implements ConsultingAgentAdapter {
|
|
|
123
125
|
model: options.model,
|
|
124
126
|
...(expectedConversationId ? { conversationId: expectedConversationId } : {}),
|
|
125
127
|
...(options.workspaceAccess ? { workspaceDirectory: options.canonicalWorkingDirectory } : {}),
|
|
128
|
+
additionalReadOnlyDirectories: options.additionalReadOnlyDirectories,
|
|
126
129
|
schema: ANTIGRAVITY_ANSWER_SCHEMA as Record<string, unknown>,
|
|
127
130
|
signal: options.signal,
|
|
128
131
|
onActivity: options.onProgress,
|
|
@@ -161,6 +164,7 @@ async function inspectProjectHooks(
|
|
|
161
164
|
const probe = await probeProjectHooks({
|
|
162
165
|
paths,
|
|
163
166
|
cwd: options.canonicalWorkingDirectory,
|
|
167
|
+
additionalReadOnlyDirectories: options.additionalReadOnlyDirectories,
|
|
164
168
|
signal: options.signal,
|
|
165
169
|
onProcessStart: options.onProcessStart,
|
|
166
170
|
});
|
|
@@ -67,13 +67,7 @@ export const INSPECTION_PERMISSION_SET = Object.freeze({
|
|
|
67
67
|
allowNonWorkspaceAccess: false,
|
|
68
68
|
permissions: Object.freeze({
|
|
69
69
|
allow: Object.freeze(["read_url(*)"]),
|
|
70
|
-
deny: Object.freeze([
|
|
71
|
-
"write_file(*)",
|
|
72
|
-
"command(*)",
|
|
73
|
-
"unsandboxed(*)",
|
|
74
|
-
"mcp(*)",
|
|
75
|
-
"execute_url(*)",
|
|
76
|
-
]),
|
|
70
|
+
deny: Object.freeze(["write_file(*)", "command(*)", "mcp(*)", "execute_url(*)"]),
|
|
77
71
|
}),
|
|
78
72
|
});
|
|
79
73
|
|
|
@@ -171,24 +171,42 @@ export function extractUrl(value: unknown): string | undefined {
|
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
174
|
+
const WORKSPACE_PATH_FIELDS = [
|
|
175
|
+
"path",
|
|
176
|
+
"file",
|
|
177
|
+
"file_path",
|
|
178
|
+
"relative_path",
|
|
179
|
+
"directory_path",
|
|
180
|
+
"search_path",
|
|
181
|
+
"search_directory",
|
|
182
|
+
"absolute_path",
|
|
183
|
+
"directory",
|
|
184
|
+
] as const;
|
|
185
|
+
|
|
186
|
+
/** Distinguish an omitted path parameter from an explicitly supplied but invalid target. */
|
|
187
|
+
export function structuredWorkspacePath(value: unknown): { value: unknown } | undefined {
|
|
188
|
+
if (!isRecord(value)) return undefined;
|
|
189
|
+
for (const name of WORKSPACE_PATH_FIELDS) {
|
|
190
|
+
for (const [key, field] of Object.entries(value)) {
|
|
191
|
+
if (normalizeFieldName(key) === normalizeFieldName(name)) return { value: field };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Read a structured path; requested-target diagnostics preserve significant filename whitespace. */
|
|
198
|
+
export function extractWorkspacePath(
|
|
199
|
+
value: unknown,
|
|
200
|
+
options: { preserveWhitespace?: boolean } = {},
|
|
201
|
+
): string | undefined {
|
|
202
|
+
return readBoundedField(value, WORKSPACE_PATH_FIELDS, () => true, options.preserveWhitespace);
|
|
186
203
|
}
|
|
187
204
|
|
|
188
205
|
function readBoundedField(
|
|
189
206
|
value: unknown,
|
|
190
207
|
names: readonly string[],
|
|
191
208
|
predicate: (candidate: string) => boolean = () => true,
|
|
209
|
+
preserveWhitespace = false,
|
|
192
210
|
): string | undefined {
|
|
193
211
|
if (!isRecord(value)) return undefined;
|
|
194
212
|
const fields = Object.entries(value);
|
|
@@ -196,13 +214,20 @@ function readBoundedField(
|
|
|
196
214
|
const normalizedName = normalizeFieldName(name);
|
|
197
215
|
for (const [key, field] of fields) {
|
|
198
216
|
if (normalizeFieldName(key) !== normalizedName) continue;
|
|
199
|
-
const candidate =
|
|
217
|
+
const candidate = boundedFieldText(field, preserveWhitespace);
|
|
200
218
|
if (candidate && predicate(candidate)) return candidate;
|
|
201
219
|
}
|
|
202
220
|
}
|
|
203
221
|
return undefined;
|
|
204
222
|
}
|
|
205
223
|
|
|
224
|
+
function boundedFieldText(value: unknown, preserveWhitespace: boolean): string | undefined {
|
|
225
|
+
if (preserveWhitespace && typeof value === "string") {
|
|
226
|
+
return value.length > 0 && value.length <= 4_096 ? value : undefined;
|
|
227
|
+
}
|
|
228
|
+
return safeString(value, 4_096);
|
|
229
|
+
}
|
|
230
|
+
|
|
206
231
|
function normalizeFieldName(value: string): string {
|
|
207
232
|
return value.toLowerCase().replace(/[-_]/g, "");
|
|
208
233
|
}
|
|
@@ -246,6 +271,7 @@ function selectAnswerFields(value: Record<string, unknown>): Record<string, unkn
|
|
|
246
271
|
answer: value.answer,
|
|
247
272
|
sources: value.sources,
|
|
248
273
|
workspaceEvidence: value.workspaceEvidence,
|
|
274
|
+
...(Object.hasOwn(value, "responseToken") ? { responseToken: value.responseToken } : {}),
|
|
249
275
|
};
|
|
250
276
|
}
|
|
251
277
|
|