@mrclrchtr/supi-prompt-suggestions 6.3.0 → 7.0.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 CHANGED
@@ -53,6 +53,9 @@ The default is `disabled`. Pick a cheap, fast model if you want lightweight sugg
53
53
 
54
54
  Settings follow SuPi's normal scoped config behavior: set a global default, then override it per project when needed.
55
55
 
56
+ Suggestion requests use PI's model registry. PI owns authentication and provider routing; the
57
+ extension does not resolve or copy credentials.
58
+
56
59
  ## Privacy
57
60
 
58
61
  When suggestions are enabled, the suggestion model receives only the last assistant message, trimmed to the final 8,000 characters.
@@ -63,7 +66,10 @@ The extension does **not** send:
63
66
  - tool outputs
64
67
  - file contents
65
68
  - project metadata
66
- - session metadata
69
+ - session metadata in the prompt
70
+
71
+ The request can include a separate opaque provider-routing identity. It is not part of the prompt and
72
+ is derived from the PI session, provider, model, and prompt-suggestion stream.
67
73
 
68
74
  ## Troubleshooting
69
75
 
@@ -71,6 +77,13 @@ If no suggestion appears:
71
77
 
72
78
  - confirm **Suggestion model** is not `disabled`
73
79
  - confirm the selected model is still enabled in PI for the current scope
74
- - confirm the selected model has an API key configured
80
+ - if a warning reports authentication failed, check the selected model's authentication in PI
75
81
  - make sure the editor is empty after the assistant finishes
76
82
  - wait for the suggestion spinner to finish; generation times out after about 20 seconds
83
+
84
+ Request failures show one warning for the active model and session. The warning contains only the
85
+ provider/model identifier, a safe failure category, and an optional HTTP status. Categories include
86
+ authentication failed, billing failed, quota exceeded, rate limit exceeded, timeout, and provider
87
+ request failed. Repeated failures stay quiet until a request succeeds, settings change, or a new
88
+ session starts. Warning state is held in memory, so `/reload` also starts a new warning cycle. The
89
+ separate routing identity remains stable for the same PI session.
@@ -21,6 +21,7 @@ pnpm add @mrclrchtr/supi-core
21
21
  ## Package surfaces
22
22
 
23
23
  - `@mrclrchtr/supi-core/api` — reusable helpers for other packages and extensions
24
+ - `@mrclrchtr/supi-core/llm` — PI-owned direct model requests and JSON helpers
24
25
  - `@mrclrchtr/supi-core/report` — shared text/report rendering helpers for TUI and plain-text summaries
25
26
 
26
27
  ## What you get from the API
@@ -48,6 +49,13 @@ Config file locations:
48
49
 
49
50
  - `wrapExtensionContext()` — wrap injected text in SuPi's `<extension-context>` tag
50
51
 
52
+ ### Model requests
53
+
54
+ - `completeModelRequest(ctx, model, context, options)` — complete through PI's model registry with stable feature affinity. PI owns auth and endpoint resolution.
55
+ - `callWithJsonResponse()` — retry a registry request, extract JSON, and validate it with TypeBox.
56
+
57
+ `completeModelRequest()` requires a stable `affinityScope`. It keeps cache retention defaults, does not include prompt content in the affinity ID, and adds OpenCode headers only when the provider or exact model endpoint matches OpenCode. Pass `maxTokens: model.maxTokens` when a caller needs the model's declared output cap without using PI private modules.
58
+
51
59
  ### Shared registries
52
60
 
53
61
  - context-provider registry for `/supi-context`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "6.3.0",
3
+ "version": "7.0.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -2,17 +2,24 @@
2
2
  //
3
3
  // Extensions register pre-styled text chunks with a placement hint
4
4
  // ("stats" for the metrics line, "status" for the extension status line).
5
- // The custom footer in supi-extras (or PI's built-in footer) reads these
6
- // contributions and renders them alongside the built-in metrics.
5
+ // The custom footer in supi-extras reads these contributions and renders them
6
+ // alongside the built-in metrics. Extensions can use PI's status API as a
7
+ // fallback when the custom footer is not installed.
7
8
 
8
9
  import { createRegistry } from "./registry-utils.ts";
9
10
 
11
+ /** Event emitted when a dynamic footer contribution needs a new render. */
12
+ export const FOOTER_INVALIDATE_EVENT = "supi:footer:invalidate";
13
+
10
14
  /** Where the contribution should appear in the footer. */
11
15
  export type FooterPlacement = "stats" | "stats-end" | "status";
12
16
 
13
17
  /** A single footer contribution registered by an extension. */
14
18
  export interface FooterContribution {
15
- /** Unique key for this contribution. Re-registering with the same key replaces it. */
19
+ /**
20
+ * Unique key for this contribution. Re-registering with the same key replaces it.
21
+ * A same-key Pi status is treated as this contribution's built-in-footer fallback.
22
+ */
16
23
  key: string;
17
24
  /** Which footer line this belongs on. */
18
25
  placement: FooterPlacement;
@@ -15,6 +15,8 @@ export * from "./debug.ts";
15
15
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
16
  export * from "./footer-registry.ts";
17
17
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
18
+ export * from "./llm.ts";
19
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
18
20
  export * from "./model-selection.ts";
19
21
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
20
22
  export * from "./path.ts";
@@ -1,12 +1,131 @@
1
- import { complete } from "@earendil-works/pi-ai/compat";
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ Api,
4
+ AssistantMessage,
5
+ Context,
6
+ Model,
7
+ ModelsApiStreamOptions,
8
+ ProviderHeaders,
9
+ } from "@earendil-works/pi-ai";
2
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
11
  import type { TSchema } from "typebox";
4
12
  import { Value } from "typebox/value";
5
13
 
6
14
  // Shared LLM utilities for SuPi extensions.
7
15
  //
8
- // Provides retry logic, structured LLM call helpers, and other
9
- // common patterns for extensions that interact with AI models.
16
+ // Provides PI-owned model requests, retry logic, structured LLM call helpers,
17
+ // and other common patterns for extensions that interact with AI models.
18
+
19
+ const MODEL_REQUEST_NAMESPACE = "supi-direct-model-request-v1";
20
+
21
+ /**
22
+ * Options for {@link completeModelRequest}.
23
+ *
24
+ * Authentication, provider environment, and session identity stay under PI
25
+ * control. The feature supplies a stable scope for its prompt stream.
26
+ */
27
+ export type CompleteModelRequestOptions<TApi extends Api = Api> = Omit<
28
+ ModelsApiStreamOptions<TApi>,
29
+ "apiKey" | "env" | "sessionId"
30
+ > & {
31
+ /** Stable feature scope. Do not include prompt, turn, or retry data. */
32
+ affinityScope: string;
33
+ /** PI owns these fields, including for APIs with open-ended option types. */
34
+ apiKey?: never;
35
+ env?: never;
36
+ sessionId?: never;
37
+ };
38
+
39
+ function createModelRequestAffinityId(
40
+ sessionId: string,
41
+ affinityScope: string,
42
+ model: Model<Api>,
43
+ ): string {
44
+ const material = JSON.stringify([
45
+ MODEL_REQUEST_NAMESPACE,
46
+ sessionId,
47
+ affinityScope,
48
+ model.provider,
49
+ model.id,
50
+ ]);
51
+ const digest = createHash("sha256").update(material, "utf8").digest("hex");
52
+ return `supi-${digest.slice(0, 56)}`;
53
+ }
54
+
55
+ function isOpenCodeModel(model: Model<Api>): boolean {
56
+ if (model.provider === "opencode" || model.provider === "opencode-go") return true;
57
+
58
+ try {
59
+ return new URL(model.baseUrl).hostname === "opencode.ai";
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function hasHeader(headers: ProviderHeaders, name: string): boolean {
66
+ const lowerName = name.toLowerCase();
67
+ return Object.keys(headers).some((headerName) => headerName.toLowerCase() === lowerName);
68
+ }
69
+
70
+ function addOpenCodeDefaultHeaders(
71
+ model: Model<Api>,
72
+ affinityId: string,
73
+ headers: ProviderHeaders,
74
+ ): ProviderHeaders {
75
+ if (!isOpenCodeModel(model)) return headers;
76
+
77
+ const result = { ...headers };
78
+ if (!hasHeader(result, "x-opencode-session")) {
79
+ result["x-opencode-session"] = affinityId;
80
+ }
81
+ if (!hasHeader(result, "x-opencode-client")) {
82
+ result["x-opencode-client"] = "pi";
83
+ }
84
+ return result;
85
+ }
86
+
87
+ /**
88
+ * Complete a direct request through PI's model registry.
89
+ *
90
+ * PI resolves authentication, provider headers, environment, and the
91
+ * effective endpoint. This helper adds one stable opaque session identity for
92
+ * the feature prompt stream and applies the OpenCode compatibility defaults.
93
+ * It does not retry, validate output, or present errors.
94
+ *
95
+ * When `maxTokens` is omitted, the underlying registry receives no explicit
96
+ * output cap. A caller that needs the selected model's declared cap can pass
97
+ * `maxTokens: model.maxTokens` without importing PI internals.
98
+ */
99
+ export async function completeModelRequest<TApi extends Api>(
100
+ ctx: ExtensionContext,
101
+ model: Model<TApi>,
102
+ context: Context,
103
+ options: CompleteModelRequestOptions<TApi>,
104
+ ): Promise<AssistantMessage> {
105
+ const { affinityScope, transformHeaders: callerTransformHeaders, ...requestOptions } = options;
106
+ const safeRequestOptions = { ...requestOptions };
107
+ delete safeRequestOptions.apiKey;
108
+ delete safeRequestOptions.env;
109
+ delete safeRequestOptions.sessionId;
110
+
111
+ const affinityId = createModelRequestAffinityId(
112
+ ctx.sessionManager.getSessionId(),
113
+ affinityScope,
114
+ model,
115
+ );
116
+ const transformHeaders = async (headers: ProviderHeaders): Promise<ProviderHeaders> => {
117
+ const transformed = callerTransformHeaders ? await callerTransformHeaders(headers) : headers;
118
+ return addOpenCodeDefaultHeaders(model, affinityId, transformed);
119
+ };
120
+
121
+ // Restore PI's conditional provider-option type after removing owned fields.
122
+ return ctx.modelRegistry.complete(model, context, {
123
+ ...safeRequestOptions,
124
+ signal: safeRequestOptions.signal ?? ctx.signal,
125
+ sessionId: affinityId,
126
+ transformHeaders,
127
+ } as unknown as ModelsApiStreamOptions<TApi>);
128
+ }
10
129
 
11
130
  /**
12
131
  * Options for {@link withRetry}.
@@ -122,6 +241,8 @@ export function extractJsonFromResponse<T extends TSchema>(
122
241
  export interface CallWithJsonResponseOptions {
123
242
  /** The prompt to send to the LLM. */
124
243
  prompt: string;
244
+ /** Stable feature scope for request affinity. Do not include prompt or retry data. */
245
+ affinityScope: string;
125
246
  /** Optional data context appended to the prompt. */
126
247
  dataContext?: string;
127
248
  /** Maximum tokens for the response. Default: 4096 */
@@ -135,8 +256,9 @@ export interface CallWithJsonResponseOptions {
135
256
  /**
136
257
  * Call the LLM with a prompt and validate the JSON response against a TypeBox schema.
137
258
  *
138
- * Handles model resolution, auth, retry via `withRetry`, text extraction,
139
- * JSON regex matching, and TypeBox validation.
259
+ * Handles model resolution, retry via `withRetry`, text extraction, JSON
260
+ * matching, and TypeBox validation. The request itself stays under PI
261
+ * registry authority through {@link completeModelRequest}.
140
262
  *
141
263
  * Returns `null` when:
142
264
  * - No model is available
@@ -145,7 +267,7 @@ export interface CallWithJsonResponseOptions {
145
267
  * - JSON doesn't match the schema
146
268
  * - The request is aborted
147
269
  *
148
- * @param ctx - The extension context for model resolution and auth.
270
+ * @param ctx - The extension context for model selection and PI registry access.
149
271
  * @param options - Call options including prompt, schema, and retry config.
150
272
  * @param schema - TypeBox schema to validate the JSON response against.
151
273
  * @returns The parsed and validated result, or `null`.
@@ -155,14 +277,18 @@ export async function callWithJsonResponse<T extends TSchema>(
155
277
  options: CallWithJsonResponseOptions,
156
278
  schema: T,
157
279
  ): Promise<{ parsed: import("typebox").Static<T> } | null> {
158
- const { prompt, dataContext, maxTokens = 4096, systemPrompt = "", retries = 2 } = options;
280
+ const {
281
+ prompt,
282
+ affinityScope,
283
+ dataContext,
284
+ maxTokens = 4096,
285
+ systemPrompt = "",
286
+ retries = 2,
287
+ } = options;
159
288
 
160
289
  const model = ctx.model ?? ctx.modelRegistry.getAvailable()[0] ?? null;
161
290
  if (!model) return null;
162
291
 
163
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
164
- if (!auth.ok || !auth.apiKey) return null;
165
-
166
292
  const fullPrompt = dataContext
167
293
  ? `${prompt}
168
294
 
@@ -171,8 +297,9 @@ ${dataContext}`
171
297
  : prompt;
172
298
 
173
299
  const response = await withRetry(
174
- async () => {
175
- return complete(
300
+ async () =>
301
+ completeModelRequest(
302
+ ctx,
176
303
  model,
177
304
  {
178
305
  systemPrompt,
@@ -185,13 +312,11 @@ ${dataContext}`
185
312
  ],
186
313
  },
187
314
  {
188
- apiKey: auth.apiKey,
189
- headers: auth.headers,
315
+ affinityScope,
190
316
  signal: ctx.signal,
191
317
  maxTokens,
192
318
  },
193
- );
194
- },
319
+ ),
195
320
  { retries, baseDelayMs: 1000, signal: ctx.signal },
196
321
  );
197
322
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-prompt-suggestions",
3
- "version": "6.3.0",
3
+ "version": "7.0.0",
4
4
  "description": "Optional model-powered ghost-text prompt suggestions",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,7 +48,7 @@
48
48
  "video": "https://raw.githubusercontent.com/mrclrchtr/supi/main/packages/supi-prompt-suggestions/assets/demo.mp4"
49
49
  },
50
50
  "dependencies": {
51
- "@mrclrchtr/supi-core": "6.3.0"
51
+ "@mrclrchtr/supi-core": "7.0.0"
52
52
  },
53
53
  "bundledDependencies": [
54
54
  "@mrclrchtr/supi-core"
@@ -8,11 +8,18 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import { defineConfigSettings, registerSettings } from "@mrclrchtr/supi-core/settings";
11
+ import {
12
+ defineConfigSettings,
13
+ registerSettings,
14
+ type SettingsPersistedChange,
15
+ } from "@mrclrchtr/supi-core/settings";
12
16
  import { CONFIG_SECTION, DEFAULTS } from "./config.ts";
13
17
 
14
18
  /** Register the prompt-suggestions settings section. */
15
- export function registerPromptSuggestionsSettings(pi: ExtensionAPI): void {
19
+ export function registerPromptSuggestionsSettings(
20
+ pi: ExtensionAPI,
21
+ afterPersist?: (change: SettingsPersistedChange) => void,
22
+ ): void {
16
23
  registerSettings(
17
24
  pi,
18
25
  defineConfigSettings({
@@ -28,6 +35,7 @@ export function registerPromptSuggestionsSettings(pi: ExtensionAPI): void {
28
35
  description: "Model used for ghost-text suggestions. Select 'disabled' to turn off.",
29
36
  },
30
37
  ],
38
+ afterPersist,
31
39
  }),
32
40
  );
33
41
  }
@@ -43,7 +43,11 @@ export class GhostTextEditor extends CustomEditor {
43
43
  options: GhostTextEditorOptions,
44
44
  ) {
45
45
  const { callbacks, ...editorOptions } = options;
46
- super(tui, theme, keybindings, editorOptions);
46
+ const customEditorOptions = {
47
+ ...editorOptions,
48
+ embedWorkingStatus: true,
49
+ };
50
+ super(tui, theme, keybindings, customEditorOptions);
47
51
  this.callbacks = callbacks;
48
52
  }
49
53
 
package/src/extension.ts CHANGED
@@ -14,13 +14,13 @@ import { SuggestionGenerator } from "./generation/generator.ts";
14
14
  import { SessionLifecycle } from "./session.ts";
15
15
 
16
16
  export default function (pi: ExtensionAPI): void {
17
- registerPromptSuggestionsSettings(pi);
18
-
19
17
  const generator = new SuggestionGenerator();
20
18
  const session = new SessionLifecycle(generator);
21
19
 
20
+ registerPromptSuggestionsSettings(pi, () => session.onSettingsChanged());
21
+
22
22
  pi.on("session_start", (_event, ctx) => session.onStart(ctx));
23
- pi.on("agent_end", (event, ctx) => session.onAgentEnd(event, ctx));
23
+ pi.on("agent_settled", (_event, ctx) => session.onAgentSettled(ctx));
24
24
  pi.on("agent_start", () => session.onAgentStart());
25
25
  pi.on("session_shutdown", () => session.onShutdown());
26
26
  }
@@ -1,16 +1,16 @@
1
- /**
2
- * Low-level suggestion model client.
3
- *
4
- * Pure functions for calling the suggestion model — no module-level state,
5
- * no orchestration, no debug logging.
6
- *
7
- * @module
8
- */
1
+ /** Low-level suggestion model client. */
9
2
 
10
- import type { ProviderHeaders } from "@earendil-works/pi-ai";
11
- import { completeSimple } from "@earendil-works/pi-ai/compat";
3
+ import type { Api, Context, Model } from "@earendil-works/pi-ai";
4
+ import { clampMaxTokensToContext } from "@earendil-works/pi-ai/api/simple-options";
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { completeModelRequest } from "@mrclrchtr/supi-core/llm";
7
+ import {
8
+ classifySuggestionFailure,
9
+ createSuggestionFailure,
10
+ type SuggestionFailure,
11
+ } from "./failure.ts";
12
12
 
13
- // ── Constants ──────────────────────────────────────────────────────────
13
+ // ── Constants ──────────────────────────────────────────────────────────────
14
14
 
15
15
  export const GENERATION_TIMEOUT_MS = 20_000;
16
16
 
@@ -32,95 +32,88 @@ const SYSTEM_PROMPT =
32
32
  "If there is no useful follow-up, respond with exactly the word NO_SUGGESTION and nothing else. " +
33
33
  "Keep suggestions under 240 characters.";
34
34
 
35
- // ── Prompt building ────────────────────────────────────────────────────
35
+ // ── Prompt building ────────────────────────────────────────────────────────
36
36
 
37
- /**
38
- * Format the tail text as a completion prompt.
39
- *
40
- * Instructions live in {@link SYSTEM_PROMPT} — the user message
41
- * only provides the assistant message content to suggest from.
42
- */
37
+ /** Format the tail text as a completion prompt. */
43
38
  export function buildPrompt(tail: string): string {
44
39
  return `<assistant_message>\n${tail}\n</assistant_message>\n\nSuggestion:`;
45
40
  }
46
41
 
47
- // ── Types ──────────────────────────────────────────────────────────────
42
+ // ── Types ───────────────────────────────────────────────────────────────────
48
43
 
49
44
  export interface SuggestionClientResult {
50
45
  ok: true;
51
46
  text: string;
52
47
  }
53
48
 
49
+ /** Classified request failure without provider response text. */
50
+ export type SuggestionClientFailure = SuggestionFailure;
51
+
54
52
  export interface SuggestionClientError {
55
53
  ok: false;
56
- message: string;
54
+ failure: SuggestionClientFailure;
57
55
  }
58
56
 
59
57
  export type SuggestionClientOutput = SuggestionClientResult | SuggestionClientError;
60
58
 
61
59
  export interface SuggestionClientOptions {
62
- // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
63
- model: any;
64
- auth: { apiKey: string; headers?: ProviderHeaders; env?: Record<string, string> };
60
+ ctx: ExtensionContext;
61
+ model: Model<Api>;
65
62
  tail: string;
66
63
  signal: AbortSignal;
67
64
  }
68
65
 
69
- // ── API call ───────────────────────────────────────────────────────────
66
+ // ── API call ────────────────────────────────────────────────────────────────
70
67
 
71
68
  /**
72
- * Call the suggestion model with a simple completion prompt.
69
+ * Call the suggestion model through PI's model registry.
73
70
  *
74
- * Pure function all side effects (HTTP, abort) are scoped to the call.
75
- * Returns a structured result; does not log or interact with the extension
76
- * context.
71
+ * The request keeps the fixed prompt and bounded assistant tail. PI resolves
72
+ * authentication, endpoint, headers, and provider environment.
77
73
  */
78
74
  export async function callSuggestionModel(
79
75
  opts: SuggestionClientOptions,
80
76
  ): Promise<SuggestionClientOutput> {
81
- const { model, auth, tail, signal } = opts;
82
-
83
- const response = await completeSimple(
84
- model,
85
- {
86
- systemPrompt: SYSTEM_PROMPT,
87
- messages: [
88
- {
89
- role: "user",
90
- content: [{ type: "text", text: buildPrompt(tail) }],
91
- timestamp: Date.now(),
92
- },
93
- ],
94
- },
95
- {
96
- apiKey: auth.apiKey,
97
- headers: auth.headers,
98
- env: auth.env,
99
- signal,
100
- },
101
- );
102
-
103
- if (response?.stopReason === "error") {
104
- const message = `Suggestion model failed: ${response.errorMessage ?? response.stopReason}`;
105
- return { ok: false, message };
106
- }
107
-
108
- if (!response?.content) {
109
- const message = `Suggestion model returned no content (stopReason: ${response?.stopReason ?? "undefined"})`;
110
- return { ok: false, message };
111
- }
112
-
113
- // Prompt suggestions are taken from normal user-visible text only.
114
- const textContent = response.content
115
- .filter((c: { type: string }) => c.type === "text")
116
- .map((c: { type: string; text?: string }) => c.text)
117
- .join("");
118
-
119
- if (!textContent) {
120
- const contentTypes = response.content.map((c: { type: string }) => c.type);
121
- const message = `Suggestion model returned no text (stopReason: ${response.stopReason ?? "undefined"}, content types: [${contentTypes.join(", ") || "none"}])`;
122
- return { ok: false, message };
77
+ const context: Context = {
78
+ systemPrompt: SYSTEM_PROMPT,
79
+ messages: [
80
+ {
81
+ role: "user",
82
+ content: [{ type: "text", text: buildPrompt(opts.tail) }],
83
+ timestamp: Date.now(),
84
+ },
85
+ ],
86
+ };
87
+
88
+ try {
89
+ const response = await completeModelRequest(opts.ctx, opts.model, context, {
90
+ affinityScope: "prompt-suggestions",
91
+ signal: opts.signal,
92
+ maxTokens: clampMaxTokensToContext(opts.model, context, opts.model.maxTokens),
93
+ });
94
+
95
+ if (response.stopReason === "aborted") {
96
+ return failureResult(createSuggestionFailure("timeout"));
97
+ }
98
+ if (response.stopReason === "error") {
99
+ return failureResult(classifySuggestionFailure(response.errorMessage));
100
+ }
101
+
102
+ // An empty successful response is a valid no-suggestion result. Thinking or
103
+ // other non-text blocks are also harmless when no user-visible text exists.
104
+ const textContent = Array.isArray(response.content)
105
+ ? response.content
106
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
107
+ .map((content) => content.text)
108
+ .join("")
109
+ : "";
110
+
111
+ return { ok: true, text: textContent };
112
+ } catch (error) {
113
+ return failureResult(classifySuggestionFailure(error));
123
114
  }
115
+ }
124
116
 
125
- return { ok: true, text: textContent };
117
+ function failureResult(failure: SuggestionFailure): SuggestionClientError {
118
+ return { ok: false, failure };
126
119
  }