obsidian-catui-acp 0.1.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/src/main.ts ADDED
@@ -0,0 +1,529 @@
1
+ import { Notice, Plugin } from "obsidian";
2
+ import type { UIMessageChunk } from "ai";
3
+ import { CatuiAcpClient } from "./acp/catui-client";
4
+ import { CatuiProcess } from "./acp/catui-process";
5
+ import { NdjsonRpcPeer } from "./acp/ndjson-rpc";
6
+ import type { AcpListSessionsResult, AcpPermissionRequestParams, AcpSessionUpdateParams } from "./acp/types";
7
+ import { buildPromptWithVaultContext } from "./context/vault-context";
8
+ import { classifyPermissionRisk, createPermissionOutcome, type PermissionChoice } from "./policy/permission-policy";
9
+ import { CatuiAcpSettingTab, DEFAULT_SETTINGS, splitArgs, type CatuiAcpSettings } from "./settings";
10
+ import { CATUI_ACP_VIEW_TYPE, CatuiAcpView } from "./ui/chat-view";
11
+
12
+ interface FileSystemAdapterLike {
13
+ getBasePath(): string;
14
+ }
15
+
16
+ export interface PluginDiagnostics {
17
+ pluginVersion: string;
18
+ obsidianVersion: string;
19
+ startedAt: string;
20
+ loadEvents: string[];
21
+ runtimeErrors: string[];
22
+ stderrTail: string;
23
+ stderrTotal: number;
24
+ activeCommand: string;
25
+ }
26
+
27
+ const DIAGNOSTICS_BUFFER_LIMIT = 50;
28
+
29
+ export type CatuiConnectionStatus = "Disconnected" | "Starting" | "Connected";
30
+
31
+ export interface PendingPermission {
32
+ title: string;
33
+ detail: string;
34
+ }
35
+
36
+ export type CatuiSessionSummary = AcpListSessionsResult["sessions"][number];
37
+
38
+ type ActiveTurn = {
39
+ controller: ReadableStreamDefaultController<UIMessageChunk>;
40
+ textId: string;
41
+ textStarted: boolean;
42
+ closed: boolean;
43
+ };
44
+
45
+ export default class CatuiAcpPlugin extends Plugin {
46
+ settings: CatuiAcpSettings = DEFAULT_SETTINGS;
47
+ connectionStatus: CatuiConnectionStatus = "Disconnected";
48
+ pendingPermission: PendingPermission | null = null;
49
+ sessions: CatuiSessionSummary[] = [];
50
+ activeSessionId: string | null = null;
51
+ private process = new CatuiProcess();
52
+ private client: CatuiAcpClient | null = null;
53
+ private activeTurn: ActiveTurn | null = null;
54
+ private permissionResolver: ((choice: PermissionChoice) => void) | null = null;
55
+ private readonly closedSessionIds = new Set<string>();
56
+ private readonly statusListeners = new Set<(status: CatuiConnectionStatus) => void>();
57
+ private readonly permissionListeners = new Set<(permission: PendingPermission | null) => void>();
58
+ private readonly sessionListeners = new Set<(state: { activeSessionId: string | null; sessions: CatuiSessionSummary[] }) => void>();
59
+ private readonly diagnosticsListeners = new Set<(snapshot: PluginDiagnostics) => void>();
60
+ private readonly loadEvents: string[] = [];
61
+ private readonly runtimeErrors: string[] = [];
62
+ private readonly startedAt = new Date().toISOString();
63
+
64
+ async onload(): Promise<void> {
65
+ this.recordLoadEvent("onload:start");
66
+ this.installGlobalErrorHandlers();
67
+ try {
68
+ await this.loadSettings();
69
+ this.recordLoadEvent("settings:loaded");
70
+ } catch (error) {
71
+ this.recordLoadEvent(`settings:error:${String(error)}`);
72
+ throw error;
73
+ }
74
+ this.addSettingTab(new CatuiAcpSettingTab(this.app, this));
75
+ this.recordLoadEvent("settings:tab-added");
76
+ this.registerView(CATUI_ACP_VIEW_TYPE, (leaf) => new CatuiAcpView(leaf, this));
77
+ this.addRibbonIcon("cat", "Open CatUI ACP", () => {
78
+ void this.activateView();
79
+ });
80
+ this.addRibbonIcon("bug", "CatUI ACP: Diagnostics", () => {
81
+ void this.activateView();
82
+ });
83
+ this.addCommand({
84
+ id: "open-catui-acp",
85
+ name: "Open CatUI ACP",
86
+ callback: () => void this.activateView(),
87
+ });
88
+ this.register(() => this.process.stop());
89
+ this.registerDomEvent(window, "beforeunload", () => this.process.stop());
90
+ this.process.on("stderr", (text) => {
91
+ this.recordLoadEvent(`process:stderr:${String(text).trim().slice(0, 200)}`);
92
+ this.enqueueError(String(text).trim());
93
+ });
94
+ this.process.on("error", (error) => {
95
+ this.recordLoadEvent(`process:error:${String(error)}`);
96
+ this.client = null;
97
+ this.setConnectionStatus("Disconnected");
98
+ this.setActiveSessionId(null);
99
+ this.enqueueError(`CatUI failed to start: ${String(error)}`);
100
+ });
101
+ this.process.on("exit", ({ code, signal }) => {
102
+ this.recordLoadEvent(`process:exit:${code ?? signal ?? "unknown"}`);
103
+ this.client = null;
104
+ this.setConnectionStatus("Disconnected");
105
+ this.setActiveSessionId(null);
106
+ this.enqueueError(`CatUI exited (${code ?? signal ?? "unknown"}).`);
107
+ });
108
+ this.recordLoadEvent("onload:done");
109
+ }
110
+
111
+ onunload(): void {
112
+ this.process.stop();
113
+ this.app.workspace.detachLeavesOfType(CATUI_ACP_VIEW_TYPE);
114
+ }
115
+
116
+ async activateView(): Promise<void> {
117
+ const leaves = this.app.workspace.getLeavesOfType(CATUI_ACP_VIEW_TYPE);
118
+ const leaf = leaves[0] ?? this.app.workspace.getRightLeaf(false);
119
+ if (!leaf) {
120
+ new Notice("Could not open CatUI ACP view.");
121
+ return;
122
+ }
123
+ await leaf.setViewState({ type: CATUI_ACP_VIEW_TYPE, active: true });
124
+ this.app.workspace.revealLeaf(leaf);
125
+ }
126
+
127
+ async ensureClient(): Promise<void> {
128
+ if (this.client) return;
129
+ this.setConnectionStatus("Starting");
130
+ const cwd = this.getVaultBasePath();
131
+ const child = this.process.start({
132
+ command: this.settings.command,
133
+ args: splitArgs(this.settings.args),
134
+ cwd,
135
+ });
136
+ const peer = new NdjsonRpcPeer(child.stdout, child.stdin);
137
+ this.client = new CatuiAcpClient(peer, cwd);
138
+ this.client.on("session-update", (params) => this.handleSessionUpdate(params as AcpSessionUpdateParams));
139
+ this.client.on("permission-request", (params, resolve) =>
140
+ this.handlePermissionRequest(params as AcpPermissionRequestParams, resolve as (value: unknown) => void),
141
+ );
142
+ this.client.on("error", (error) =>
143
+ this.enqueueError(`ACP protocol: ${error.message}`),
144
+ );
145
+
146
+ try {
147
+ await this.client.initialize();
148
+ const session = await this.client.newSession();
149
+ this.closedSessionIds.delete(session.sessionId);
150
+ this.setActiveSessionId(session.sessionId);
151
+ await this.client.setMode(this.settings.defaultMode);
152
+ this.setConnectionStatus("Connected");
153
+ void this.refreshSessions();
154
+ } catch (error) {
155
+ this.client = null;
156
+ this.process.stop();
157
+ const message = error instanceof Error ? error.message : String(error);
158
+ this.setConnectionStatus("Disconnected");
159
+ this.enqueueError(`Could not connect to CatUI with \`${this.settings.command} ${this.settings.args}\`.\n${message}`);
160
+ throw error;
161
+ }
162
+ }
163
+
164
+ runPromptAsStream(userText: string, abortSignal?: AbortSignal): ReadableStream<UIMessageChunk> {
165
+ const textId = `catui-text-${Date.now()}`;
166
+ return new ReadableStream<UIMessageChunk>({
167
+ start: (controller) => {
168
+ const turn: ActiveTurn = { controller, textId, textStarted: false, closed: false };
169
+ this.activeTurn = turn;
170
+ controller.enqueue({ type: "start" });
171
+ controller.enqueue({ type: "start-step" });
172
+
173
+ const abort = () => {
174
+ this.client?.cancel();
175
+ this.closeTurn(turn, "abort");
176
+ };
177
+ abortSignal?.addEventListener("abort", abort, { once: true });
178
+
179
+ void this.sendPrompt(userText)
180
+ .then(() => this.closeTurn(turn, "finish"))
181
+ .catch((error) => {
182
+ const message = error instanceof Error ? error.message : String(error);
183
+ this.enqueueError(message);
184
+ this.closeTurn(turn, "finish");
185
+ })
186
+ .finally(() => abortSignal?.removeEventListener("abort", abort));
187
+ },
188
+ cancel: () => {
189
+ this.client?.cancel();
190
+ },
191
+ });
192
+ }
193
+
194
+ cancelPrompt(): void {
195
+ this.client?.cancel();
196
+ this.enqueueError("Cancel requested.");
197
+ }
198
+
199
+ restartClient(): void {
200
+ this.process.stop();
201
+ this.client = null;
202
+ this.setConnectionStatus("Disconnected");
203
+ void this.ensureClient();
204
+ }
205
+
206
+ async startNewSession(): Promise<void> {
207
+ await this.ensureClient();
208
+ const session = await this.client!.newSession();
209
+ this.closedSessionIds.delete(session.sessionId);
210
+ this.setActiveSessionId(session.sessionId);
211
+ await this.client!.setMode(this.settings.defaultMode);
212
+ await this.refreshSessions();
213
+ new Notice("Started a new CatUI session.");
214
+ }
215
+
216
+ async loadSession(sessionId: string): Promise<void> {
217
+ await this.ensureClient();
218
+ await this.client!.loadSession(sessionId);
219
+ this.closedSessionIds.delete(sessionId);
220
+ this.setActiveSessionId(sessionId);
221
+ await this.client!.setMode(this.settings.defaultMode);
222
+ await this.refreshSessions();
223
+ }
224
+
225
+ async closeSession(sessionId: string): Promise<void> {
226
+ await this.ensureClient();
227
+ try {
228
+ await this.client!.closeSession(sessionId);
229
+ } catch (error) {
230
+ const message = error instanceof Error ? error.message : String(error);
231
+ if (!message.includes("Method not found")) {
232
+ new Notice(`Could not close CatUI session: ${message}`);
233
+ }
234
+ }
235
+ this.closedSessionIds.add(sessionId);
236
+ if (this.activeSessionId === sessionId) this.setActiveSessionId(null);
237
+ await this.refreshSessions();
238
+ }
239
+
240
+ async closeAllSessions(): Promise<void> {
241
+ await this.ensureClient();
242
+ const sessions = [...this.sessions];
243
+ await Promise.allSettled(sessions.map((session) => this.client!.closeSession(session.sessionId)));
244
+ for (const session of sessions) this.closedSessionIds.add(session.sessionId);
245
+ this.setActiveSessionId(null);
246
+ await this.refreshSessions();
247
+ }
248
+
249
+ async showSessions(): Promise<void> {
250
+ const result = await this.refreshSessions();
251
+ new Notice(`${result.sessions.length} CatUI sessions found.`);
252
+ }
253
+
254
+ async refreshSessions(): Promise<AcpListSessionsResult> {
255
+ await this.ensureClient();
256
+ const result = await this.client!.listSessions();
257
+ this.setSessions(result.sessions.filter((session) => !this.closedSessionIds.has(session.sessionId)));
258
+ return result;
259
+ }
260
+
261
+ subscribeStatus(listener: (status: CatuiConnectionStatus) => void): () => void {
262
+ this.statusListeners.add(listener);
263
+ listener(this.connectionStatus);
264
+ return () => this.statusListeners.delete(listener);
265
+ }
266
+
267
+ subscribePermission(listener: (permission: PendingPermission | null) => void): () => void {
268
+ this.permissionListeners.add(listener);
269
+ listener(this.pendingPermission);
270
+ return () => this.permissionListeners.delete(listener);
271
+ }
272
+
273
+ subscribeSessions(listener: (state: { activeSessionId: string | null; sessions: CatuiSessionSummary[] }) => void): () => void {
274
+ this.sessionListeners.add(listener);
275
+ listener({ activeSessionId: this.activeSessionId, sessions: this.sessions });
276
+ return () => this.sessionListeners.delete(listener);
277
+ }
278
+
279
+ resolvePermission(choice: PermissionChoice): void {
280
+ this.permissionResolver?.(choice);
281
+ }
282
+
283
+ async loadSettings(): Promise<void> {
284
+ this.settings = { ...DEFAULT_SETTINGS, ...(await this.loadData()) };
285
+ }
286
+
287
+ async saveSettings(): Promise<void> {
288
+ await this.saveData(this.settings);
289
+ }
290
+
291
+ private handleSessionUpdate(params: AcpSessionUpdateParams): void {
292
+ const update = params.update;
293
+ if (update.sessionUpdate === "agent_message_chunk") {
294
+ const text = getContentText(update.content);
295
+ if (text) this.enqueueText(text);
296
+ return;
297
+ }
298
+ if (update.sessionUpdate === "agent_thought_chunk") return;
299
+ if (update.sessionUpdate === "tool_call") {
300
+ this.enqueueChunk({
301
+ type: "tool-input-available",
302
+ toolCallId: String(update.toolCallId ?? `tool-${Date.now()}`),
303
+ toolName: String(update.title ?? "tool"),
304
+ input: update.rawInput ?? {},
305
+ dynamic: true,
306
+ title: String(update.title ?? "tool"),
307
+ });
308
+ return;
309
+ }
310
+ if (update.sessionUpdate === "tool_call_update") {
311
+ const toolCallId = String(update.toolCallId ?? `tool-${Date.now()}`);
312
+ if (update.status === "failed") {
313
+ this.enqueueChunk({
314
+ type: "tool-output-error",
315
+ toolCallId,
316
+ errorText: toolPayloadToText(update.rawOutput ?? update.content),
317
+ dynamic: true,
318
+ });
319
+ } else {
320
+ this.enqueueChunk({
321
+ type: "tool-output-available",
322
+ toolCallId,
323
+ output: update.rawOutput ?? update.content ?? {},
324
+ dynamic: true,
325
+ });
326
+ }
327
+ return;
328
+ }
329
+ if (update.sessionUpdate === "current_mode_update" && typeof update.currentModeId === "string") {
330
+ new Notice(`CatUI mode: ${update.currentModeId}`);
331
+ }
332
+ }
333
+
334
+ private handlePermissionRequest(params: AcpPermissionRequestParams, resolve: (value: unknown) => void): void {
335
+ const risk = classifyPermissionRisk({
336
+ protectRawWrites: this.settings.protectRawWrites,
337
+ rawInput: params.toolCall?.rawInput,
338
+ });
339
+
340
+ if (risk.level === "blocked") {
341
+ this.enqueueError(`Blocked permission request: ${risk.reason}`);
342
+ resolve(createPermissionOutcome("reject_once"));
343
+ return;
344
+ }
345
+
346
+ let settled = false;
347
+ const choose = (choice: PermissionChoice): void => {
348
+ if (settled) return;
349
+ settled = true;
350
+ resolve(createPermissionOutcome(choice));
351
+ this.permissionResolver = null;
352
+ this.setPendingPermission(null);
353
+ if (choice.startsWith("reject")) this.enqueueError(`Permission response: ${choice}`);
354
+ };
355
+
356
+ this.permissionResolver = choose;
357
+ this.setPendingPermission({
358
+ title: params.toolCall?.title ?? "Tool call",
359
+ detail: `${risk.reason}\n${JSON.stringify(params.toolCall?.rawInput ?? {}, null, 2)}`,
360
+ });
361
+ }
362
+
363
+ private async sendPrompt(userText: string): Promise<void> {
364
+ await this.ensureClient();
365
+ if (!this.activeSessionId || !this.client!.sessionId) {
366
+ const session = await this.client!.newSession();
367
+ this.closedSessionIds.delete(session.sessionId);
368
+ this.setActiveSessionId(session.sessionId);
369
+ await this.client!.setMode(this.settings.defaultMode);
370
+ void this.refreshSessions();
371
+ }
372
+ const activeFile = this.app.workspace.getActiveFile();
373
+ const selection = this.app.workspace.activeEditor?.editor?.getSelection() ?? "";
374
+ const prompt = buildPromptWithVaultContext({
375
+ userText,
376
+ activePath: activeFile?.path ?? null,
377
+ selection,
378
+ includeActiveNote: this.settings.includeActiveNote,
379
+ includeSelection: this.settings.includeSelection,
380
+ personality: this.settings.personality,
381
+ });
382
+ await this.client!.prompt(prompt);
383
+ }
384
+
385
+ private setConnectionStatus(status: CatuiConnectionStatus): void {
386
+ this.connectionStatus = status;
387
+ for (const listener of this.statusListeners) listener(status);
388
+ }
389
+
390
+ private setPendingPermission(permission: PendingPermission | null): void {
391
+ this.pendingPermission = permission;
392
+ for (const listener of this.permissionListeners) listener(permission);
393
+ }
394
+
395
+ private setActiveSessionId(sessionId: string | null): void {
396
+ this.activeSessionId = sessionId;
397
+ this.emitSessionState();
398
+ }
399
+
400
+ private setSessions(sessions: CatuiSessionSummary[]): void {
401
+ this.sessions = sessions;
402
+ this.emitSessionState();
403
+ }
404
+
405
+ private emitSessionState(): void {
406
+ const state = { activeSessionId: this.activeSessionId, sessions: this.sessions };
407
+ for (const listener of this.sessionListeners) listener(state);
408
+ }
409
+
410
+ private enqueueText(text: string): void {
411
+ const turn = this.activeTurn;
412
+ if (!turn || turn.closed) return;
413
+ if (!turn.textStarted) {
414
+ turn.controller.enqueue({ type: "text-start", id: turn.textId });
415
+ turn.textStarted = true;
416
+ }
417
+ turn.controller.enqueue({ type: "text-delta", id: turn.textId, delta: text });
418
+ }
419
+
420
+ private enqueueError(errorText: string): void {
421
+ if (!errorText) return;
422
+ const turn = this.activeTurn;
423
+ if (!turn || turn.closed) {
424
+ this.recordRuntimeError(`enqueueError (no active turn): ${errorText}`);
425
+ return;
426
+ }
427
+ try {
428
+ turn.controller.enqueue({ type: "error", errorText });
429
+ } catch (error) {
430
+ this.recordRuntimeError(`enqueueError (controller rejected): ${errorText} :: ${String(error)}`);
431
+ }
432
+ }
433
+
434
+ private enqueueChunk(chunk: UIMessageChunk): void {
435
+ const turn = this.activeTurn;
436
+ if (!turn || turn.closed) return;
437
+ try {
438
+ turn.controller.enqueue(chunk);
439
+ } catch (error) {
440
+ this.recordRuntimeError(`enqueueChunk (controller rejected): ${String(error)}`);
441
+ }
442
+ }
443
+
444
+ private closeTurn(turn: ActiveTurn, type: "finish" | "abort"): void {
445
+ if (turn.closed) return;
446
+ turn.closed = true;
447
+ if (turn.textStarted) turn.controller.enqueue({ type: "text-end", id: turn.textId });
448
+ turn.controller.enqueue({ type: "finish-step" });
449
+ turn.controller.enqueue(type === "abort" ? { type: "abort", reason: "cancelled" } : { type: "finish", finishReason: "stop" });
450
+ turn.controller.close();
451
+ if (this.activeTurn === turn) this.activeTurn = null;
452
+ }
453
+
454
+ private getVaultBasePath(): string {
455
+ const adapter = this.app.vault.adapter as unknown as Partial<FileSystemAdapterLike>;
456
+ const basePath = adapter.getBasePath?.();
457
+ if (!basePath) throw new Error("CatUI ACP requires an Obsidian desktop file-system vault.");
458
+ return basePath;
459
+ }
460
+
461
+ subscribeDiagnostics(listener: (snapshot: PluginDiagnostics) => void): () => void {
462
+ this.diagnosticsListeners.add(listener);
463
+ return () => this.diagnosticsListeners.delete(listener);
464
+ }
465
+
466
+ getDiagnostics(): PluginDiagnostics {
467
+ const version = (this.manifest as { version?: string })?.version ?? "0.0.0";
468
+ const obsidianVersion = (this.app as unknown as { version?: string }).version ?? "unknown";
469
+ return {
470
+ pluginVersion: version,
471
+ obsidianVersion,
472
+ startedAt: this.startedAt,
473
+ loadEvents: [...this.loadEvents],
474
+ runtimeErrors: [...this.runtimeErrors],
475
+ stderrTail: this.process.stderrText.slice(-4000),
476
+ stderrTotal: this.process.stderrText.length,
477
+ activeCommand: `${this.settings.command} ${this.settings.args}`.trim(),
478
+ };
479
+ }
480
+
481
+ private installGlobalErrorHandlers(): void {
482
+ this.registerDomEvent(window, "error", (event) => {
483
+ const message = (event as ErrorEvent).message ?? String((event as ErrorEvent).error ?? "unknown");
484
+ const stack = ((event as ErrorEvent).error as Error | undefined)?.stack ?? "";
485
+ this.recordRuntimeError(`window.error: ${message}${stack ? `\n${stack}` : ""}`);
486
+ });
487
+ this.registerDomEvent(window, "unhandledrejection", (event) => {
488
+ const reason = (event as PromiseRejectionEvent).reason;
489
+ const message = reason instanceof Error ? `${reason.message}\n${reason.stack ?? ""}` : String(reason);
490
+ this.recordRuntimeError(`unhandledrejection: ${message}`);
491
+ });
492
+ }
493
+
494
+ private recordLoadEvent(event: string): void {
495
+ const stamp = new Date().toISOString().slice(11, 23);
496
+ this.loadEvents.push(`[${stamp}] ${event}`);
497
+ if (this.loadEvents.length > DIAGNOSTICS_BUFFER_LIMIT) this.loadEvents.shift();
498
+ this.emitDiagnostics();
499
+ }
500
+
501
+ private recordRuntimeError(text: string): void {
502
+ const stamp = new Date().toISOString().slice(11, 23);
503
+ this.runtimeErrors.push(`[${stamp}] ${text}`);
504
+ if (this.runtimeErrors.length > DIAGNOSTICS_BUFFER_LIMIT) this.runtimeErrors.shift();
505
+ this.emitDiagnostics();
506
+ }
507
+
508
+ private emitDiagnostics(): void {
509
+ const snapshot = this.getDiagnostics();
510
+ for (const listener of this.diagnosticsListeners) listener(snapshot);
511
+ }
512
+ }
513
+
514
+ function getContentText(content: unknown): string {
515
+ if (content && typeof content === "object" && "text" in content) {
516
+ const text = (content as { text?: unknown }).text;
517
+ return typeof text === "string" ? text : "";
518
+ }
519
+ return "";
520
+ }
521
+
522
+ function toolPayloadToText(value: unknown): string {
523
+ if (typeof value === "string") return value;
524
+ try {
525
+ return JSON.stringify(value);
526
+ } catch {
527
+ return String(value);
528
+ }
529
+ }
@@ -0,0 +1,52 @@
1
+ import type { AcpPermissionResponse } from "../acp/types";
2
+
3
+ export type PermissionRisk =
4
+ | { level: "blocked"; reason: string }
5
+ | { level: "review"; reason: string };
6
+
7
+ export interface PermissionRiskInput {
8
+ protectRawWrites: boolean;
9
+ rawInput: unknown;
10
+ }
11
+
12
+ export type PermissionChoice =
13
+ | "allow_once"
14
+ | "allow_always"
15
+ | "reject_once"
16
+ | "reject_always"
17
+ | "cancelled";
18
+
19
+ export function classifyPermissionRisk(input: PermissionRiskInput): PermissionRisk {
20
+ if (input.protectRawWrites && containsRawPath(input.rawInput)) {
21
+ return {
22
+ level: "blocked",
23
+ reason: "This operation appears to modify raw/. raw sources are immutable in this LLM-Wiki.",
24
+ };
25
+ }
26
+
27
+ return {
28
+ level: "review",
29
+ reason: "Review the requested tool call before allowing CatUI to continue.",
30
+ };
31
+ }
32
+
33
+ export function createPermissionOutcome(choice: PermissionChoice): AcpPermissionResponse {
34
+ if (choice === "cancelled") {
35
+ return { outcome: { outcome: "cancelled" } };
36
+ }
37
+ return { outcome: { outcome: "selected", optionId: choice } };
38
+ }
39
+
40
+ function containsRawPath(value: unknown): boolean {
41
+ if (typeof value === "string") return isRawPathString(value);
42
+ if (Array.isArray(value)) return value.some(containsRawPath);
43
+ if (value && typeof value === "object") {
44
+ return Object.values(value as Record<string, unknown>).some(containsRawPath);
45
+ }
46
+ return false;
47
+ }
48
+
49
+ function isRawPathString(value: string): boolean {
50
+ const normalized = value.replaceAll("\\", "/");
51
+ return normalized === "raw" || normalized.includes("/raw/") || normalized.startsWith("raw/");
52
+ }