@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.
- package/README.md +71 -33
- package/extensions/lovely-web/command.ts +32 -163
- package/extensions/lovely-web/config.ts +178 -56
- package/extensions/lovely-web/constants.ts +1 -0
- package/extensions/lovely-web/find.ts +356 -0
- package/extensions/lovely-web/format.ts +15 -4
- package/extensions/lovely-web/image.ts +51 -62
- package/extensions/lovely-web/index.ts +16 -4
- package/extensions/lovely-web/providers/brave.ts +29 -14
- package/extensions/lovely-web/providers/exa.ts +20 -10
- package/extensions/lovely-web/providers/firecrawl.ts +37 -6
- package/extensions/lovely-web/providers/tavily.ts +44 -10
- package/extensions/lovely-web/providers/types.ts +28 -14
- package/extensions/lovely-web/render.ts +29 -3
- package/extensions/lovely-web/smart.ts +197 -0
- package/extensions/lovely-web/tools.ts +325 -75
- package/package.json +13 -4
- package/extensions/lovely-web/tool-impl.ts +0 -95
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
|
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[] =
|
|
65
|
-
const
|
|
66
|
-
|
|
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?:
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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:
|
|
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
|
|
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 =>
|
|
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
|
+
}
|