@xl0/pi-lovely-web 0.1.4 → 0.2.2

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.
@@ -1,8 +1,10 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai"
2
+ import { Type } from "typebox"
3
+ import { DEFAULT_TIMEOUT_MS } from "../constants.js"
1
4
  import { requestJson } from "./http.js"
2
5
  import type { Provider, SearchResult } from "./types.js"
3
6
 
4
7
  const BASE_URL = "https://api.exa.ai"
5
- const DEFAULT_TIMEOUT_MS = 30_000
6
8
 
7
9
  interface SearchBody {
8
10
  query: string
@@ -10,11 +12,13 @@ interface SearchBody {
10
12
  type: string
11
13
  contents: { summary: boolean }
12
14
  category?: string
15
+ userLocation?: string
13
16
  }
14
17
 
15
18
  interface ContentsBody {
16
- ids: string[]
19
+ urls: string[]
17
20
  text: boolean
21
+ maxAgeHours?: number
18
22
  }
19
23
 
20
24
  interface ExaSearchResult {
@@ -30,11 +34,6 @@ interface ExaContentsResult {
30
34
  text?: string
31
35
  }
32
36
 
33
- function sourceToCategory(source?: string): string | undefined {
34
- if (source === "news") return "news"
35
- return undefined
36
- }
37
-
38
37
  function stripSummaryLabel(text: string): string {
39
38
  return text.replace(/^Summary:\s*/i, "")
40
39
  }
@@ -60,6 +59,15 @@ export const exaProvider: Provider = {
60
59
  id: "exa",
61
60
  label: "Exa",
62
61
  envApiKey: "EXA_API_KEY",
62
+ searchParameters: {
63
+ category: Type.Optional(StringEnum(["company", "people", "research paper", "news", "personal site", "financial report"])),
64
+ country: Type.Optional(Type.String({ description: "Two-letter ISO user location for result localization, e.g. US, DE, CO." }))
65
+ },
66
+ fetchParameters: {
67
+ maxAgeHours: Type.Optional(
68
+ Type.Integer({ description: "Maximum cache age in hours; 0 fetches fresh content, -1 uses cache only.", minimum: -1, maximum: 720 })
69
+ )
70
+ },
63
71
 
64
72
  async search(apiKey, query, opts, signal) {
65
73
  const body: SearchBody = {
@@ -68,8 +76,9 @@ export const exaProvider: Provider = {
68
76
  type: "auto",
69
77
  contents: { summary: true }
70
78
  }
71
- const category = sourceToCategory(opts.source)
72
- if (category) body.category = category
79
+ if (opts.source) throw new Error("Exa search uses category, not source. Omit category for general web search, or set category: news.")
80
+ if (opts.category) body.category = opts.category
81
+ if (opts.country) body.userLocation = opts.country
73
82
 
74
83
  const raw = await fetchJson(`${BASE_URL}/search`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
75
84
  const data = (raw as { results?: ExaSearchResult[] }).results ?? []
@@ -88,9 +97,10 @@ export const exaProvider: Provider = {
88
97
 
89
98
  async fetch(apiKey, url, opts, signal) {
90
99
  const body: ContentsBody = {
91
- ids: [url],
100
+ urls: [url],
92
101
  text: true
93
102
  }
103
+ if (opts.maxAgeHours !== undefined) body.maxAgeHours = opts.maxAgeHours
94
104
 
95
105
  const raw = await fetchJson(`${BASE_URL}/contents`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
96
106
  const results = (raw as { results?: ExaContentsResult[] }).results
@@ -1,13 +1,19 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai"
2
+ import { Type } from "typebox"
3
+ import { DEFAULT_TIMEOUT_MS } from "../constants.js"
1
4
  import { requestJson } from "./http.js"
2
5
  import type { Provider, SearchResult } from "./types.js"
3
6
 
4
- const BASE_URL = "https://api.firecrawl.dev/v1"
5
- const DEFAULT_TIMEOUT_MS = 30_000
7
+ const BASE_URL = "https://api.firecrawl.dev/v2"
6
8
 
7
9
  interface SearchBody {
8
10
  query: string
9
11
  limit: number
10
12
  sources?: string[]
13
+ categories?: string[]
14
+ location?: string
15
+ country?: string
16
+ tbs?: string
11
17
  }
12
18
 
13
19
  interface ScrapeBody {
@@ -52,18 +58,43 @@ export const firecrawlProvider: Provider = {
52
58
  id: "firecrawl",
53
59
  label: "Firecrawl",
54
60
  envApiKey: "FIRECRAWL_API_KEY",
61
+ searchParameters: {
62
+ source: Type.Optional(StringEnum(["web", "news", "images"])),
63
+ category: Type.Optional(StringEnum(["github", "research", "pdf"])),
64
+ location: Type.Optional(Type.String({ description: "Geo-target results, e.g. Germany or San Francisco,California,United States." })),
65
+ country: Type.Optional(Type.String({ description: "ISO country code for geo-targeting, e.g. US, DE, CO." })),
66
+ tbs: Type.Optional(Type.String({ description: "Google-style time filter, e.g. qdr:d, qdr:w, qdr:m, qdr:y." }))
67
+ },
68
+ fetchParameters: {
69
+ waitFor: Type.Optional(Type.Integer({ description: "Extra delay in milliseconds before capturing page content.", minimum: 0 }))
70
+ },
55
71
 
56
72
  async search(apiKey, query, opts, signal) {
57
73
  const body: SearchBody = { query, limit: opts.limit }
58
74
  if (opts.source) body.sources = [opts.source]
75
+ if (opts.category) body.categories = [opts.category]
76
+ if (opts.location) body.location = opts.location
77
+ if (opts.country) body.country = opts.country
78
+ if (opts.tbs) body.tbs = opts.tbs
59
79
 
60
80
  const raw = await fetchJson(`${BASE_URL}/search`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
61
81
  throwIfError(raw)
62
- const data = raw.data as Array<{ title: string; url: string; description: string }>
82
+ const data = raw.data as {
83
+ web?: Array<{ title: string; url: string; description?: string; markdown?: string }>
84
+ news?: Array<{ title: string; url: string; snippet?: string; markdown?: string }>
85
+ images?: Array<{ title?: string; url: string; imageUrl?: string }>
86
+ }
87
+ const items = opts.source === "news" ? data.news || [] : opts.source === "images" ? data.images || [] : data.web || []
63
88
 
64
- const results: SearchResult[] = data.map(item => {
65
- const r: SearchResult = { title: item.title, url: item.url }
66
- if (item.description) r.description = item.description
89
+ const results: SearchResult[] = items.map(item => {
90
+ const isImage = opts.source === "images"
91
+ const r: SearchResult = {
92
+ title: item.title || item.url,
93
+ url: isImage && "imageUrl" in item && item.imageUrl ? item.imageUrl : item.url
94
+ }
95
+ const description = "description" in item ? item.description : "snippet" in item ? item.snippet : undefined
96
+ if (description) r.description = description
97
+ if ("markdown" in item && item.markdown) r.markdown = item.markdown
67
98
  return r
68
99
  })
69
100
 
@@ -1,14 +1,20 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai"
2
+ import { Type } from "typebox"
3
+ import { DEFAULT_TIMEOUT_MS } from "../constants.js"
1
4
  import { requestJson } from "./http.js"
2
5
  import type { Provider, SearchResult } from "./types.js"
3
6
 
4
7
  const BASE_URL = "https://api.tavily.com"
5
- const DEFAULT_TIMEOUT_MS = 30_000
6
8
 
7
9
  interface SearchBody {
8
10
  query: string
9
11
  max_results: number
10
12
  search_depth: string
11
13
  topic?: string
14
+ time_range?: string
15
+ country?: string
16
+ include_images?: boolean
17
+ include_image_descriptions?: boolean
12
18
  }
13
19
 
14
20
  interface ExtractBody {
@@ -25,11 +31,16 @@ interface TavilySearchResult {
25
31
  score: number
26
32
  }
27
33
 
34
+ interface TavilyImageResult {
35
+ url: string
36
+ description?: string
37
+ }
38
+
28
39
  interface TavilySearchResponse {
29
40
  query: string
30
41
  answer?: string
31
42
  results?: TavilySearchResult[]
32
- images?: unknown[]
43
+ images?: TavilyImageResult[]
33
44
  response_time: number
34
45
  }
35
46
 
@@ -46,11 +57,6 @@ interface TavilyExtractResponse {
46
57
  response_time: number
47
58
  }
48
59
 
49
- function sourceToTopic(source?: string): string | undefined {
50
- if (source === "news") return "news"
51
- return undefined
52
- }
53
-
54
60
  function postJson(url: string, body: unknown, apiKey: string, timeout: number, signal?: AbortSignal): Promise<unknown> {
55
61
  return requestJson(
56
62
  "Tavily",
@@ -72,6 +78,17 @@ export const tavilyProvider: Provider = {
72
78
  id: "tavily",
73
79
  label: "Tavily",
74
80
  envApiKey: "TAVILY_API_KEY",
81
+ searchParameters: {
82
+ topic: Type.Optional(StringEnum(["general", "news", "finance"])),
83
+ includeImages: Type.Optional(Type.Boolean({ description: "Return query-related image URLs instead of page results." })),
84
+ country: Type.Optional(Type.String({ description: "Country name to boost general-topic results, e.g. colombia." })),
85
+ timeRange: Type.Optional(StringEnum(["day", "week", "month", "year", "d", "w", "m", "y"]))
86
+ },
87
+ fetchParameters: {
88
+ extractDepth: Type.Optional(
89
+ StringEnum(["basic", "advanced"], { description: "Advanced extraction retrieves more data such as tables; costs more." })
90
+ )
91
+ },
75
92
 
76
93
  async search(apiKey, query, opts, signal) {
77
94
  const body: SearchBody = {
@@ -79,11 +96,28 @@ export const tavilyProvider: Provider = {
79
96
  max_results: opts.limit,
80
97
  search_depth: "basic"
81
98
  }
82
- const topic = sourceToTopic(opts.source)
83
- if (topic) body.topic = topic
99
+ if (opts.source) throw new Error("Tavily search uses topic/includeImages, not source.")
100
+ const topic = opts.topic
101
+ if (topic && topic !== "general") body.topic = topic
102
+ if (opts.timeRange) body.time_range = opts.timeRange
103
+ if (opts.country && topic !== "news" && topic !== "finance") body.country = opts.country.toLowerCase()
104
+ if (opts.includeImages) {
105
+ body.include_images = true
106
+ body.include_image_descriptions = true
107
+ }
84
108
 
85
109
  const raw = await postJson(`${BASE_URL}/search`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
86
110
  const data = raw as TavilySearchResponse
111
+
112
+ if (opts.includeImages) {
113
+ const results: SearchResult[] = (data.images ?? []).map(item => ({
114
+ title: item.description || item.url,
115
+ url: item.url,
116
+ description: item.description || ""
117
+ }))
118
+ return { results, raw }
119
+ }
120
+
87
121
  const items = data.results ?? []
88
122
 
89
123
  const results: SearchResult[] = items.map(item => {
@@ -101,7 +135,7 @@ export const tavilyProvider: Provider = {
101
135
  async fetch(apiKey, url, opts, signal) {
102
136
  const body: ExtractBody = {
103
137
  urls: [url],
104
- extract_depth: "basic",
138
+ extract_depth: opts.extractDepth ?? "basic",
105
139
  format: "markdown"
106
140
  }
107
141
 
@@ -1,3 +1,5 @@
1
+ import type { TSchema } from "typebox"
2
+
1
3
  export interface SearchResult {
2
4
  title: string
3
5
  url: string
@@ -5,27 +7,39 @@ export interface SearchResult {
5
7
  markdown?: string // populated for first result when fetchResult=true
6
8
  }
7
9
 
10
+ export interface SearchOptions {
11
+ limit: number
12
+ source?: string
13
+ timeout?: number
14
+ category?: string
15
+ location?: string
16
+ country?: string
17
+ tbs?: string
18
+ timeRange?: string
19
+ topic?: string
20
+ includeImages?: boolean
21
+ searchLang?: string
22
+ freshness?: string
23
+ }
24
+
25
+ export interface FetchOptions {
26
+ timeout?: number
27
+ waitFor?: number
28
+ maxAgeHours?: number
29
+ extractDepth?: string
30
+ }
31
+
8
32
  export interface Provider {
9
33
  readonly id: string
10
34
  readonly label: string
11
35
  readonly envApiKey: string
12
- search(
13
- apiKey: string,
14
- query: string,
15
- opts: { limit: number; source?: string; timeout?: number },
16
- signal?: AbortSignal
17
- ): Promise<{ results: SearchResult[]; raw: unknown }>
36
+ readonly searchParameters?: Record<string, TSchema>
37
+ readonly fetchParameters?: Record<string, TSchema>
38
+ search(apiKey: string, query: string, opts: SearchOptions, signal?: AbortSignal): Promise<{ results: SearchResult[]; raw: unknown }>
18
39
  fetch?(
19
40
  apiKey: string,
20
41
  url: string,
21
- opts: { waitFor?: number; timeout?: number },
42
+ opts: FetchOptions,
22
43
  signal?: AbortSignal
23
44
  ): Promise<{ markdown: string; metadata?: unknown; raw: unknown }>
24
45
  }
25
-
26
- export interface WebToolsConfig {
27
- webSearch?: { provider?: string | null }
28
- webFetch?: { provider?: string | null }
29
- webImage?: { enabled?: boolean; resize?: boolean; maxSize?: number }
30
- webApiKeys?: Record<string, string>
31
- }
@@ -1,10 +1,11 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent"
2
2
  import { Text } from "@earendil-works/pi-tui"
3
+ import { FIND_HIGHLIGHT_END, FIND_HIGHLIGHT_START } from "./find.js"
3
4
 
4
5
  const COLLAPSED_RESULT_LINES = 6
5
6
 
6
7
  export function renderTextResult(
7
- result: { content: Array<{ type: string; text?: string }> },
8
+ result: { content: Array<{ type: string; text?: string }>; details?: unknown },
8
9
  expanded: boolean,
9
10
  theme: Theme,
10
11
  partialLabel: string
@@ -13,11 +14,36 @@ export function renderTextResult(
13
14
  if (content?.type !== "text" || content.text === undefined) return new Text(theme.fg("error", "No text output"), 0, 0)
14
15
  if (!content.text.trim()) return new Text(theme.fg("dim", partialLabel), 0, 0)
15
16
 
16
- const lines = content.text.split("\n")
17
+ const renderText = renderTextOverride(result.details) ?? content.text
18
+ const lines = renderText.split("\n")
17
19
  const shown = expanded ? lines : lines.slice(0, COLLAPSED_RESULT_LINES)
18
- let text = shown.map(line => theme.fg("toolOutput", line)).join("\n")
20
+ let text = shown.map(line => renderHighlightMarkers(line, theme)).join("\n")
19
21
  if (!expanded && lines.length > COLLAPSED_RESULT_LINES) {
20
22
  text += `\n${theme.fg("muted", `... ${lines.length - COLLAPSED_RESULT_LINES} more lines (ctrl-o to expand)`)}`
21
23
  }
22
24
  return new Text(text, 0, 0)
23
25
  }
26
+
27
+ function renderTextOverride(details: unknown): string | undefined {
28
+ if (!details || typeof details !== "object") return undefined
29
+ const renderText = (details as { renderText?: unknown }).renderText
30
+ return typeof renderText === "string" ? renderText : undefined
31
+ }
32
+
33
+ function renderHighlightMarkers(line: string, theme: Theme): string {
34
+ let rest = line
35
+ let output = ""
36
+ while (true) {
37
+ const start = rest.indexOf(FIND_HIGHLIGHT_START)
38
+ if (start === -1) return output + theme.fg("toolOutput", stripHighlightMarkers(rest))
39
+ const end = rest.indexOf(FIND_HIGHLIGHT_END, start + FIND_HIGHLIGHT_START.length)
40
+ if (end === -1) return output + theme.fg("toolOutput", stripHighlightMarkers(rest))
41
+ output += theme.fg("toolOutput", rest.slice(0, start))
42
+ output += theme.bold(theme.fg("accent", rest.slice(start + FIND_HIGHLIGHT_START.length, end)))
43
+ rest = rest.slice(end + FIND_HIGHLIGHT_END.length)
44
+ }
45
+ }
46
+
47
+ function stripHighlightMarkers(text: string): string {
48
+ return text.replaceAll(FIND_HIGHLIGHT_START, "").replaceAll(FIND_HIGHLIGHT_END, "")
49
+ }
@@ -0,0 +1,197 @@
1
+ import { type Api, type AssistantMessage, completeSimple, type Model, type SimpleStreamOptions } from "@earendil-works/pi-ai"
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
3
+ import type { WebToolsConfig } from "./config.js"
4
+
5
+ const SMART_INPUT_FALLBACK_CHARS = 60_000 * 3
6
+
7
+ export const SMART_SYSTEM_PROMPT = `Process one web_fetch result for a coding agent.
8
+ Use only facts explicitly stated in the provided page text.
9
+ Answer the smart query, not the whole page. Include neighboring/related sections only when needed for the requested task.
10
+
11
+ Return concise Markdown in the format that best fits the query:
12
+ - If the query asks for a specific format, use it.
13
+ - For verbatim code, commands, schemas, examples, config, or error text, return the requested excerpt first, exactly as written, preserving indentation and punctuation.
14
+ - For summaries, focus on what a coding agent needs: purpose, setup, APIs/options, steps, limits, caveats, security notes, migration/breaking changes, and gotchas that are directly stated.
15
+ - For extraction/comparison, use bullet points, preserve exact names, signatures, flags, codes, amounts, dates, deadlines, defaults, and version/platform caveats.
16
+ - For troubleshooting, include directly stated symptoms, causes, fixes/workarounds, and warnings.
17
+
18
+ Grounding rules:
19
+ - Include short exact quotes or source context for important claims unless the answer is only a verbatim excerpt.
20
+ - There is one fetched page; use a heading like "Evidence (on this page):" for an evidence section.
21
+ - If requested information is absent or partially absent, say "Not found on page." Mention missing fields under a short Missing section when useful.
22
+ - Use "None" only when the page explicitly supports it or the query only asks whether requested fields were found.
23
+ - For legal/admin pages, state only what the page says and flag when exact amounts/codes require a calculator, form, or agency confirmation.`
24
+
25
+ let warnedDefaultModel = false
26
+ let warnedUnavailableModel = false
27
+ let disabledForSession = false
28
+
29
+ export interface SmartProcessInput {
30
+ query: string
31
+ resultText: string
32
+ sourceUrl?: string
33
+ }
34
+
35
+ export interface SmartProcessResult {
36
+ text: string
37
+ details: {
38
+ query: string
39
+ model: string
40
+ inputChars: number
41
+ originalInputChars: number
42
+ maxInputChars: number
43
+ truncated: boolean
44
+ stopReason: AssistantMessage["stopReason"]
45
+ usage: AssistantMessage["usage"]
46
+ }
47
+ }
48
+
49
+ export function resetSmartRuntimeState(): void {
50
+ warnedDefaultModel = false
51
+ warnedUnavailableModel = false
52
+ disabledForSession = false
53
+ }
54
+
55
+ export function resetSmartConfigState(): void {
56
+ warnedUnavailableModel = false
57
+ disabledForSession = false
58
+ }
59
+
60
+ function modelName(model: Model<Api>): string {
61
+ return `${model.provider}/${model.id}`
62
+ }
63
+
64
+ function warn(ctx: ExtensionContext, message: string): void {
65
+ ctx.ui.notify(message, "warning")
66
+ }
67
+
68
+ function disableSmart(ctx: ExtensionContext, reason: string): void {
69
+ disabledForSession = true
70
+ if (warnedUnavailableModel) return
71
+ warnedUnavailableModel = true
72
+ warn(ctx, `Lovely Web smart search disabled: ${reason}`)
73
+ }
74
+
75
+ function resolveConfiguredModel(ctx: ExtensionContext, configured: string): Model<Api> | undefined {
76
+ const separator = configured.indexOf("/")
77
+ if (separator <= 0 || separator === configured.length - 1) {
78
+ disableSmart(ctx, `configured model must be "provider/model", got "${configured}".`)
79
+ return undefined
80
+ }
81
+
82
+ const provider = configured.slice(0, separator)
83
+ const modelId = configured.slice(separator + 1)
84
+ const model = ctx.modelRegistry.getAvailable().find(model => model.provider === provider && model.id === modelId)
85
+ if (!model) {
86
+ disableSmart(ctx, `configured model unavailable: ${configured}`)
87
+ return undefined
88
+ }
89
+ if (!model.input.includes("text")) {
90
+ disableSmart(ctx, `configured model does not support text input: ${configured}`)
91
+ return undefined
92
+ }
93
+ return model
94
+ }
95
+
96
+ function resolveCurrentModel(ctx: ExtensionContext): Model<Api> | undefined {
97
+ const model = ctx.model
98
+ if (!model) {
99
+ disableSmart(ctx, "no current model is selected.")
100
+ return undefined
101
+ }
102
+ if (!model.input.includes("text")) {
103
+ disableSmart(ctx, `current model does not support text input: ${modelName(model)}.`)
104
+ return undefined
105
+ }
106
+ if (!warnedDefaultModel) {
107
+ warnedDefaultModel = true
108
+ warn(ctx, `Lovely Web smart search model not selected; using current model ${modelName(model)}.`)
109
+ }
110
+ return model
111
+ }
112
+
113
+ function resolveSmartModel(config: WebToolsConfig, ctx: ExtensionContext): Model<Api> | undefined {
114
+ if (!config.smartSearchEnabled || disabledForSession) return undefined
115
+ const configured = config.smartSearchModel.trim()
116
+ return configured ? resolveConfiguredModel(ctx, configured) : resolveCurrentModel(ctx)
117
+ }
118
+
119
+ export function validateSmartConfig(config: WebToolsConfig, ctx: ExtensionContext): boolean {
120
+ if (!config.smartSearchEnabled) return true
121
+ return resolveSmartModel(config, ctx) !== undefined
122
+ }
123
+
124
+ function truncateForModel(text: string, model: Model<Api>): { text: string; originalChars: number; maxChars: number; truncated: boolean } {
125
+ const maxChars = model.contextWindow > 0 ? Math.max(4000, Math.floor((model.contextWindow / 2) * 3)) : SMART_INPUT_FALLBACK_CHARS
126
+ if (text.length <= maxChars) return { text, originalChars: text.length, maxChars, truncated: false }
127
+ return { text: text.slice(0, maxChars), originalChars: text.length, maxChars, truncated: true }
128
+ }
129
+
130
+ function extractText(message: AssistantMessage): string {
131
+ return message.content
132
+ .filter((item): item is { type: "text"; text: string } => item.type === "text")
133
+ .map(item => item.text)
134
+ .join("\n")
135
+ .trim()
136
+ }
137
+
138
+ function smartSystemPrompt(config: WebToolsConfig): string {
139
+ return config.smartSearchSystemPrompt.trim() ? config.smartSearchSystemPrompt : SMART_SYSTEM_PROMPT
140
+ }
141
+
142
+ export async function smartProcess(
143
+ config: WebToolsConfig,
144
+ ctx: ExtensionContext,
145
+ input: SmartProcessInput,
146
+ signal?: AbortSignal
147
+ ): Promise<SmartProcessResult | undefined> {
148
+ const model = resolveSmartModel(config, ctx)
149
+ if (!model) return undefined
150
+
151
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model)
152
+ if (!auth.ok) {
153
+ disableSmart(ctx, `model auth unavailable for ${modelName(model)}: ${auth.error}`)
154
+ return undefined
155
+ }
156
+
157
+ const maxTokens = Math.max(1, config.smartSearchMaxTokens)
158
+ const truncated = truncateForModel(input.resultText, model)
159
+ const sourceUrlText = input.sourceUrl ? `\n\nSource URL:\n${input.sourceUrl}` : ""
160
+ const prompt = `Smart query:\n${input.query}${sourceUrlText}\n\nResult text:\n${truncated.text}`
161
+ const options: SimpleStreamOptions = { maxTokens }
162
+ if (auth.apiKey !== undefined) options.apiKey = auth.apiKey
163
+ if (auth.headers !== undefined) options.headers = auth.headers
164
+ if (auth.env !== undefined) options.env = auth.env
165
+ if (signal !== undefined) options.signal = signal
166
+
167
+ const response = await completeSimple(
168
+ model,
169
+ {
170
+ systemPrompt: smartSystemPrompt(config),
171
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }]
172
+ },
173
+ options
174
+ )
175
+
176
+ if (response.stopReason === "error" || response.stopReason === "aborted") {
177
+ throw new Error(response.errorMessage || response.stopReason)
178
+ }
179
+
180
+ const answer = extractText(response) || "Not found in provided results."
181
+ const truncationNotice = truncated.truncated
182
+ ? `\n\nNote: Smart input was truncated to ${truncated.text.length}/${truncated.originalChars} characters to stay within half of ${modelName(model)} context. Answer may omit trimmed content.`
183
+ : ""
184
+ return {
185
+ text: `${answer}${truncationNotice}`,
186
+ details: {
187
+ query: input.query,
188
+ model: modelName(model),
189
+ inputChars: truncated.text.length,
190
+ originalInputChars: truncated.originalChars,
191
+ maxInputChars: truncated.maxChars,
192
+ truncated: truncated.truncated,
193
+ stopReason: response.stopReason,
194
+ usage: response.usage
195
+ }
196
+ }
197
+ }