@mjasnikovs/pi-task 0.18.5 → 0.18.7
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/dist/config/config.d.ts +7 -0
- package/dist/config/config.js +7 -1
- package/dist/config/register.js +33 -5
- package/dist/task/phases.d.ts +6 -2
- package/dist/task/phases.js +7 -2
- package/dist/task/service-blocks.d.ts +2 -2
- package/dist/task/service-blocks.js +3 -1
- package/dist/workers/brave-search.d.ts +2 -5
- package/dist/workers/brave-warning.d.ts +4 -6
- package/dist/workers/brave-warning.js +11 -10
- package/dist/workers/ddg-search.d.ts +24 -0
- package/dist/workers/ddg-search.js +130 -0
- package/dist/workers/exa-search.d.ts +24 -0
- package/dist/workers/exa-search.js +164 -0
- package/dist/workers/pi-worker-search.d.ts +7 -1
- package/dist/workers/pi-worker-search.js +11 -6
- package/dist/workers/search-core.d.ts +14 -2
- package/dist/workers/search-core.js +34 -2
- package/dist/workers/search-types.d.ts +21 -0
- package/dist/workers/search-types.js +16 -0
- package/package.json +1 -1
package/dist/config/config.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SearchProvider } from '../workers/search-types.js';
|
|
1
2
|
export interface PiTaskConfig {
|
|
2
3
|
remote: boolean;
|
|
3
4
|
compressReasoning: boolean;
|
|
@@ -23,6 +24,12 @@ export interface PiTaskConfig {
|
|
|
23
24
|
* collide).
|
|
24
25
|
*/
|
|
25
26
|
researchCache: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Which engine backs web search (pi-worker-search + freshness/enrichment).
|
|
29
|
+
* `exa` and `ddg` need no API key; `brave` needs BRAVE_SEARCH_API_KEY.
|
|
30
|
+
* DEFAULT `exa` so search works out of the box with zero configuration.
|
|
31
|
+
*/
|
|
32
|
+
searchProvider: SearchProvider;
|
|
26
33
|
}
|
|
27
34
|
export declare function getConfig(): PiTaskConfig;
|
|
28
35
|
export declare function saveConfig(config: PiTaskConfig): Promise<void>;
|
package/dist/config/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as fsp from 'node:fs/promises';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import * as os from 'node:os';
|
|
5
|
+
import { isSearchProvider } from '../workers/search-types.js';
|
|
5
6
|
const DEFAULTS = {
|
|
6
7
|
remote: true,
|
|
7
8
|
compressReasoning: true,
|
|
@@ -12,7 +13,8 @@ const DEFAULTS = {
|
|
|
12
13
|
parallelResearchWorkers: false,
|
|
13
14
|
// ON: the F10 live A/B showed no answer-quality regression (fidelity 3/3, quality
|
|
14
15
|
// 3/3, 0 collisions; ~14.5s of repeated docs lookups collapse to 0ms on a hit).
|
|
15
|
-
researchCache: true
|
|
16
|
+
researchCache: true,
|
|
17
|
+
searchProvider: 'exa'
|
|
16
18
|
};
|
|
17
19
|
const CONFIG_PATH = path.join(os.homedir(), '.config', 'pi-task', 'config.json');
|
|
18
20
|
const _g = globalThis;
|
|
@@ -26,6 +28,10 @@ if (!G.loaded) {
|
|
|
26
28
|
try {
|
|
27
29
|
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
28
30
|
const parsed = JSON.parse(raw);
|
|
31
|
+
// A hand-edited or stale enum value must not leak an unknown provider
|
|
32
|
+
// into the dispatch switch — fall back to the default.
|
|
33
|
+
if (!isSearchProvider(parsed.searchProvider))
|
|
34
|
+
delete parsed.searchProvider;
|
|
29
35
|
G.config = { ...DEFAULTS, ...parsed };
|
|
30
36
|
}
|
|
31
37
|
catch {
|
package/dist/config/register.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SettingsList, visibleWidth } from '@earendil-works/pi-tui';
|
|
2
2
|
import { registerBridgeCommand } from '../remote/bridge.js';
|
|
3
|
+
import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
|
|
3
4
|
import { getConfig, saveConfig } from './config.js';
|
|
4
5
|
const CONFIG_TITLE = 'pi-task settings';
|
|
5
6
|
/**
|
|
@@ -46,6 +47,10 @@ class BorderedBox {
|
|
|
46
47
|
this.child.handleInput(data);
|
|
47
48
|
}
|
|
48
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Every setting rendered by /task-config. Boolean settings omit `values` and
|
|
52
|
+
* toggle on/off; enum settings list their values and cycle through them.
|
|
53
|
+
*/
|
|
49
54
|
const ITEMS = [
|
|
50
55
|
{ id: 'remote', label: 'remote', description: 'Remote UI server (QR code, phone access)' },
|
|
51
56
|
{
|
|
@@ -82,8 +87,23 @@ const ITEMS = [
|
|
|
82
87
|
id: 'researchCache',
|
|
83
88
|
label: 'research cache',
|
|
84
89
|
description: 'Cache docs/search/fetch results within one /task-auto run so sibling tasks reuse the first pipeline’s digest instead of re-fetching the same external docs. Per-run isolated, external-only, success-only'
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'searchProvider',
|
|
93
|
+
label: 'search provider',
|
|
94
|
+
description: 'Engine behind web search (pi-worker-search + freshness checks). Exa and DuckDuckGo need no API key; Brave needs BRAVE_SEARCH_API_KEY',
|
|
95
|
+
// Display full engine names; the stored config value stays the short id.
|
|
96
|
+
values: SEARCH_PROVIDERS.map(p => SEARCH_PROVIDER_LABELS[p])
|
|
85
97
|
}
|
|
86
98
|
];
|
|
99
|
+
/** What /task-config shows for a setting's current value. */
|
|
100
|
+
function displayValue(cfg, id, isEnum) {
|
|
101
|
+
if (id === 'searchProvider')
|
|
102
|
+
return SEARCH_PROVIDER_LABELS[cfg.searchProvider];
|
|
103
|
+
if (isEnum)
|
|
104
|
+
return String(cfg[id]);
|
|
105
|
+
return cfg[id] ? 'on' : 'off';
|
|
106
|
+
}
|
|
87
107
|
function makeTheme(theme) {
|
|
88
108
|
return {
|
|
89
109
|
label: (text, selected) => (selected ? theme.fg('accent', text) : text),
|
|
@@ -96,21 +116,29 @@ function makeTheme(theme) {
|
|
|
96
116
|
async function handleTaskConfig(_args, ctx) {
|
|
97
117
|
const cfg = { ...getConfig() };
|
|
98
118
|
if (ctx.mode !== 'tui') {
|
|
99
|
-
const lines = ITEMS.map(({ id, label }) => `${label.padEnd(22)} ${cfg
|
|
119
|
+
const lines = ITEMS.map(({ id, label, values }) => `${label.padEnd(22)} ${displayValue(cfg, id, Boolean(values))}`);
|
|
100
120
|
ctx.ui.notify(lines.join(' | '), 'info');
|
|
101
121
|
return;
|
|
102
122
|
}
|
|
103
123
|
await ctx.ui.custom((_tui, theme, _kb, done) => {
|
|
104
124
|
const listTheme = makeTheme(theme);
|
|
105
|
-
const items = ITEMS.map(({ id, label, description }) => ({
|
|
125
|
+
const items = ITEMS.map(({ id, label, description, values }) => ({
|
|
106
126
|
id,
|
|
107
127
|
label,
|
|
108
128
|
description,
|
|
109
|
-
currentValue: cfg
|
|
110
|
-
values: ['on', 'off']
|
|
129
|
+
currentValue: displayValue(cfg, id, Boolean(values)),
|
|
130
|
+
values: values ?? ['on', 'off']
|
|
111
131
|
}));
|
|
112
132
|
const list = new SettingsList(items, 10, listTheme, (id, newValue) => {
|
|
113
|
-
|
|
133
|
+
if (id === 'searchProvider') {
|
|
134
|
+
const provider = providerForLabel(newValue);
|
|
135
|
+
if (provider)
|
|
136
|
+
cfg.searchProvider = provider;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
;
|
|
140
|
+
cfg[id] = newValue === 'on';
|
|
141
|
+
}
|
|
114
142
|
saveConfig(cfg).catch(() => { });
|
|
115
143
|
}, () => done(undefined));
|
|
116
144
|
return new BorderedBox(list, CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)));
|
package/dist/task/phases.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
|
6
6
|
import { docsFocused } from '../workers/docs-core.js';
|
|
7
7
|
import { fetchFocused } from '../workers/fetch-core.js';
|
|
8
8
|
import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
|
|
9
|
+
import type { SearchProvider } from '../workers/search-types.js';
|
|
9
10
|
import { type ExternalContextDeps } from './external-context.js';
|
|
10
11
|
import { MAX_GRILL_QUESTIONS } from './prompts.js';
|
|
11
12
|
import { type PhaseName } from './task-types.js';
|
|
@@ -63,8 +64,11 @@ export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): P
|
|
|
63
64
|
export interface PhaseResearchDeps extends ExternalContextDeps {
|
|
64
65
|
getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
65
66
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Is live web search configured for this process? The keyless providers (exa,
|
|
69
|
+
* ddg) always are; only brave needs its API key — mirrors search-core's lookup.
|
|
70
|
+
*/
|
|
71
|
+
export declare function searchConfigured(getEnv?: (k: string) => string | undefined, provider?: SearchProvider): boolean;
|
|
68
72
|
/** Extra prompt block for the APIS worker when search is available — trigger-framed
|
|
69
73
|
* (the validated shape for getting a local model to actually reach for search). */
|
|
70
74
|
export declare const RESEARCH_SEARCH_HINT: string;
|
package/dist/task/phases.js
CHANGED
|
@@ -166,8 +166,13 @@ const DOCS_EXTENSION_PATH = new URL('../workers/docs-extension.js', import.meta.
|
|
|
166
166
|
* STRUCTURAL: three consecutive audited runs made 0 search calls because the
|
|
167
167
|
* child literally did not have the tool. */
|
|
168
168
|
const SEARCH_EXTENSION_PATH = new URL('../workers/search-extension.js', import.meta.url).pathname;
|
|
169
|
-
/**
|
|
170
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Is live web search configured for this process? The keyless providers (exa,
|
|
171
|
+
* ddg) always are; only brave needs its API key — mirrors search-core's lookup.
|
|
172
|
+
*/
|
|
173
|
+
export function searchConfigured(getEnv = k => process.env[k], provider = getConfig().searchProvider) {
|
|
174
|
+
if (provider !== 'brave')
|
|
175
|
+
return true;
|
|
171
176
|
return Boolean(getEnv('BRAVE_SEARCH_API_KEY') ?? getEnv('BRAVE_API_KEY'));
|
|
172
177
|
}
|
|
173
178
|
/** Extra prompt block for the APIS worker when search is available — trigger-framed
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare function formatServiceBlock(name: string, fullQuery: string, results:
|
|
1
|
+
import type { SearchResult } from '../workers/search-types.js';
|
|
2
|
+
export declare function formatServiceBlock(name: string, fullQuery: string, results: SearchResult[]): string;
|
|
3
3
|
export declare function formatFreshnessSkippedBlock(names: string[]): string;
|
|
@@ -5,6 +5,8 @@ export function formatServiceBlock(name, fullQuery, results) {
|
|
|
5
5
|
const bullets = results.map(r => `- **${r.title}** — ${r.url}\n ${r.description}`).join('\n');
|
|
6
6
|
return `${header}\n${bullets}`;
|
|
7
7
|
}
|
|
8
|
+
// Only the brave provider can be unconfigured (exa/ddg are keyless), so the
|
|
9
|
+
// skip reason names its missing key directly.
|
|
8
10
|
export function formatFreshnessSkippedBlock(names) {
|
|
9
|
-
return `### freshness-check skipped\nCould not verify external services (BRAVE_SEARCH_API_KEY not set):\n${names.map(n => `- ${n}`).join('\n')}`;
|
|
11
|
+
return `### freshness-check skipped\nCould not verify external services (search provider is brave but BRAVE_SEARCH_API_KEY is not set):\n${names.map(n => `- ${n}`).join('\n')}`;
|
|
10
12
|
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One-line startup hint shown when
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* start so the user knows search is disabled — it never blocks work and clears
|
|
7
|
-
* itself on the first interaction (any keystroke).
|
|
2
|
+
* One-line startup hint shown when Brave is the selected search provider but
|
|
3
|
+
* no key is configured (Brave is the only provider that needs one — exa and
|
|
4
|
+
* ddg are keyless, so no hint renders for them). It never blocks work and
|
|
5
|
+
* clears itself on the first interaction (any keystroke).
|
|
8
6
|
*/
|
|
9
7
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
10
8
|
export declare function registerBraveKeyWarning(pi: ExtensionAPI): void;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One-line startup hint shown when
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* start so the user knows search is disabled — it never blocks work and clears
|
|
7
|
-
* itself on the first interaction (any keystroke).
|
|
2
|
+
* One-line startup hint shown when Brave is the selected search provider but
|
|
3
|
+
* no key is configured (Brave is the only provider that needs one — exa and
|
|
4
|
+
* ddg are keyless, so no hint renders for them). It never blocks work and
|
|
5
|
+
* clears itself on the first interaction (any keystroke).
|
|
8
6
|
*/
|
|
7
|
+
import { getConfig } from '../config/config.js';
|
|
9
8
|
const WIDGET_KEY = 'pi-task-brave-warning';
|
|
10
|
-
const WARNING = '⚠ pi-task: BRAVE_SEARCH_API_KEY not set — web search
|
|
11
|
-
+ 'Get a free key at https://api.search.brave.com/app/keys'
|
|
9
|
+
const WARNING = '⚠ pi-task: search provider is Brave but BRAVE_SEARCH_API_KEY is not set — web search '
|
|
10
|
+
+ 'is disabled. Get a free key at https://api.search.brave.com/app/keys or switch '
|
|
11
|
+
+ 'provider in /task-config';
|
|
12
12
|
/** Mirrors the lookup in search-core so the hint matches what the worker reads. */
|
|
13
13
|
function hasBraveKey() {
|
|
14
14
|
return Boolean(process.env.BRAVE_SEARCH_API_KEY ?? process.env.BRAVE_API_KEY);
|
|
@@ -16,8 +16,9 @@ function hasBraveKey() {
|
|
|
16
16
|
export function registerBraveKeyWarning(pi) {
|
|
17
17
|
pi.on('session_start', (_event, ctx) => {
|
|
18
18
|
// Terminal-only hint: needs an interactive TUI to render and to catch the
|
|
19
|
-
// keystroke that dismisses it.
|
|
20
|
-
|
|
19
|
+
// keystroke that dismisses it. Only the brave provider can be misconfigured;
|
|
20
|
+
// skip whenever another provider is selected or a key is already present.
|
|
21
|
+
if (ctx.mode !== 'tui' || getConfig().searchProvider !== 'brave' || hasBraveKey())
|
|
21
22
|
return;
|
|
22
23
|
let unsubscribe = null;
|
|
23
24
|
const clear = () => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web search via DuckDuckGo's HTML endpoint — no API key required.
|
|
3
|
+
*
|
|
4
|
+
* DDG has no official web-results API; html.duckduckgo.com/html serves plain
|
|
5
|
+
* HTML result pages that parse cleanly with linkedom. Result links are wrapped
|
|
6
|
+
* in a `duckduckgo.com/l/?uddg=<encoded>` redirect that we unwrap so callers
|
|
7
|
+
* get the destination URL. Ad rows redirect through duckduckgo.com itself and
|
|
8
|
+
* are dropped.
|
|
9
|
+
*/
|
|
10
|
+
import type { FetchLike } from './exa-search.js';
|
|
11
|
+
import type { SearchResult } from './search-types.js';
|
|
12
|
+
export interface DdgSearchOpts {
|
|
13
|
+
count?: number;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
fetchImpl?: FetchLike;
|
|
17
|
+
}
|
|
18
|
+
export declare class DdgSearchError extends Error {
|
|
19
|
+
readonly kind: 'http' | 'network' | 'aborted' | 'rate-limit';
|
|
20
|
+
readonly status?: number | undefined;
|
|
21
|
+
constructor(message: string, kind: 'http' | 'network' | 'aborted' | 'rate-limit', status?: number | undefined);
|
|
22
|
+
}
|
|
23
|
+
export declare function ddgSearch(query: string, opts?: DdgSearchOpts): Promise<SearchResult[]>;
|
|
24
|
+
export declare function parseDdgHtml(html: string): SearchResult[];
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web search via DuckDuckGo's HTML endpoint — no API key required.
|
|
3
|
+
*
|
|
4
|
+
* DDG has no official web-results API; html.duckduckgo.com/html serves plain
|
|
5
|
+
* HTML result pages that parse cleanly with linkedom. Result links are wrapped
|
|
6
|
+
* in a `duckduckgo.com/l/?uddg=<encoded>` redirect that we unwrap so callers
|
|
7
|
+
* get the destination URL. Ad rows redirect through duckduckgo.com itself and
|
|
8
|
+
* are dropped.
|
|
9
|
+
*/
|
|
10
|
+
import { parseHTML } from 'linkedom';
|
|
11
|
+
const DDG_ENDPOINT = 'https://html.duckduckgo.com/html/';
|
|
12
|
+
const DEFAULT_COUNT = 10;
|
|
13
|
+
const MAX_COUNT = 20;
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
15
|
+
// DDG serves a bot-challenge page to clients without a browser-ish UA.
|
|
16
|
+
const USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0';
|
|
17
|
+
export class DdgSearchError extends Error {
|
|
18
|
+
kind;
|
|
19
|
+
status;
|
|
20
|
+
constructor(message, kind, status) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.kind = kind;
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.name = 'DdgSearchError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function ddgSearch(query, opts = {}) {
|
|
28
|
+
const count = Math.max(1, Math.min(MAX_COUNT, opts.count ?? DEFAULT_COUNT));
|
|
29
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
30
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
31
|
+
const url = `${DDG_ENDPOINT}?q=${encodeURIComponent(query)}`;
|
|
32
|
+
const internalController = new AbortController();
|
|
33
|
+
let userAborted = false;
|
|
34
|
+
const timeoutHandle = setTimeout(() => internalController.abort(), timeoutMs);
|
|
35
|
+
const onUserAbort = () => {
|
|
36
|
+
userAborted = true;
|
|
37
|
+
internalController.abort();
|
|
38
|
+
};
|
|
39
|
+
if (opts.signal) {
|
|
40
|
+
if (opts.signal.aborted)
|
|
41
|
+
onUserAbort();
|
|
42
|
+
else
|
|
43
|
+
opts.signal.addEventListener('abort', onUserAbort, { once: true });
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
response = await fetchImpl(url, {
|
|
49
|
+
method: 'GET',
|
|
50
|
+
headers: {
|
|
51
|
+
'user-agent': USER_AGENT,
|
|
52
|
+
accept: 'text/html'
|
|
53
|
+
},
|
|
54
|
+
signal: internalController.signal
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (userAborted)
|
|
59
|
+
throw new DdgSearchError('Search aborted.', 'aborted');
|
|
60
|
+
throw new DdgSearchError(`DuckDuckGo request failed: ${describeError(err)}`, 'network');
|
|
61
|
+
}
|
|
62
|
+
if (response.status === 429 || response.status === 403) {
|
|
63
|
+
throw new DdgSearchError(`DuckDuckGo is rate-limiting this client (HTTP ${response.status}). Try again in a moment.`, 'rate-limit', response.status);
|
|
64
|
+
}
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
throw new DdgSearchError(`DuckDuckGo HTTP ${response.status} ${response.statusText}`, 'http', response.status);
|
|
67
|
+
}
|
|
68
|
+
return parseDdgHtml(await response.text()).slice(0, count);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
clearTimeout(timeoutHandle);
|
|
72
|
+
if (opts.signal)
|
|
73
|
+
opts.signal.removeEventListener('abort', onUserAbort);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function parseDdgHtml(html) {
|
|
77
|
+
const { document } = parseHTML(html);
|
|
78
|
+
const doc = document;
|
|
79
|
+
const results = [];
|
|
80
|
+
for (const anchor of doc.querySelectorAll('a.result__a')) {
|
|
81
|
+
if (anchor.closest('.result--ad'))
|
|
82
|
+
continue;
|
|
83
|
+
const href = anchor.getAttribute('href');
|
|
84
|
+
const targetUrl = href === null ? null : unwrapDdgRedirect(href);
|
|
85
|
+
// A row whose link never leaves duckduckgo.com is an ad/module, not a hit.
|
|
86
|
+
if (targetUrl === null)
|
|
87
|
+
continue;
|
|
88
|
+
const title = collapse(anchor.textContent ?? '');
|
|
89
|
+
if (!title)
|
|
90
|
+
continue;
|
|
91
|
+
const row = anchor.closest('.result');
|
|
92
|
+
const snippet = row?.querySelector('.result__snippet')?.textContent ?? '';
|
|
93
|
+
results.push({ title, url: targetUrl, description: collapse(snippet) });
|
|
94
|
+
}
|
|
95
|
+
return results;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Result hrefs look like `//duckduckgo.com/l/?uddg=<encoded-destination>&rut=…`;
|
|
99
|
+
* return the decoded destination, a non-DDG href unchanged, or null for links
|
|
100
|
+
* that stay on duckduckgo.com (ad click-trackers).
|
|
101
|
+
*/
|
|
102
|
+
function unwrapDdgRedirect(href) {
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = new URL(href, 'https://duckduckgo.com');
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (parsed.hostname.endsWith('duckduckgo.com')) {
|
|
111
|
+
const uddg = parsed.searchParams.get('uddg');
|
|
112
|
+
if (!uddg)
|
|
113
|
+
return null;
|
|
114
|
+
try {
|
|
115
|
+
return new URL(uddg).toString();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return parsed.toString();
|
|
122
|
+
}
|
|
123
|
+
function collapse(text) {
|
|
124
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
125
|
+
}
|
|
126
|
+
function describeError(err) {
|
|
127
|
+
if (err instanceof Error)
|
|
128
|
+
return err.message;
|
|
129
|
+
return String(err);
|
|
130
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web search via Exa's public MCP endpoint — no API key required.
|
|
3
|
+
*
|
|
4
|
+
* Exa hosts https://mcp.exa.ai/mcp as an intentionally keyless entry point: a
|
|
5
|
+
* single JSON-RPC `tools/call` of `web_search_exa` returns search results with
|
|
6
|
+
* content snippets. The response is SSE-framed (`data:` lines) or plain JSON,
|
|
7
|
+
* and the result payload is one text blob of `Title:`/`URL:`/`Text:` blocks
|
|
8
|
+
* separated by `---`, which we parse back into structured results.
|
|
9
|
+
*/
|
|
10
|
+
import type { SearchResult } from './search-types.js';
|
|
11
|
+
/** Injectable fetch — the narrow signature keeps test fakes free of Bun's extras. */
|
|
12
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
13
|
+
export interface ExaSearchOpts {
|
|
14
|
+
count?: number;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
fetchImpl?: FetchLike;
|
|
18
|
+
}
|
|
19
|
+
export declare class ExaSearchError extends Error {
|
|
20
|
+
readonly kind: 'http' | 'network' | 'aborted' | 'protocol';
|
|
21
|
+
readonly status?: number | undefined;
|
|
22
|
+
constructor(message: string, kind: 'http' | 'network' | 'aborted' | 'protocol', status?: number | undefined);
|
|
23
|
+
}
|
|
24
|
+
export declare function exaSearch(query: string, opts?: ExaSearchOpts): Promise<SearchResult[]>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web search via Exa's public MCP endpoint — no API key required.
|
|
3
|
+
*
|
|
4
|
+
* Exa hosts https://mcp.exa.ai/mcp as an intentionally keyless entry point: a
|
|
5
|
+
* single JSON-RPC `tools/call` of `web_search_exa` returns search results with
|
|
6
|
+
* content snippets. The response is SSE-framed (`data:` lines) or plain JSON,
|
|
7
|
+
* and the result payload is one text blob of `Title:`/`URL:`/`Text:` blocks
|
|
8
|
+
* separated by `---`, which we parse back into structured results.
|
|
9
|
+
*/
|
|
10
|
+
const EXA_MCP_ENDPOINT = 'https://mcp.exa.ai/mcp';
|
|
11
|
+
const DEFAULT_COUNT = 10;
|
|
12
|
+
const MAX_COUNT = 20;
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
14
|
+
const MAX_DESCRIPTION_CHARS = 400;
|
|
15
|
+
export class ExaSearchError extends Error {
|
|
16
|
+
kind;
|
|
17
|
+
status;
|
|
18
|
+
constructor(message, kind, status) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.kind = kind;
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.name = 'ExaSearchError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function exaSearch(query, opts = {}) {
|
|
26
|
+
const count = Math.max(1, Math.min(MAX_COUNT, opts.count ?? DEFAULT_COUNT));
|
|
27
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
28
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
29
|
+
const internalController = new AbortController();
|
|
30
|
+
let userAborted = false;
|
|
31
|
+
const timeoutHandle = setTimeout(() => internalController.abort(), timeoutMs);
|
|
32
|
+
const onUserAbort = () => {
|
|
33
|
+
userAborted = true;
|
|
34
|
+
internalController.abort();
|
|
35
|
+
};
|
|
36
|
+
if (opts.signal) {
|
|
37
|
+
if (opts.signal.aborted)
|
|
38
|
+
onUserAbort();
|
|
39
|
+
else
|
|
40
|
+
opts.signal.addEventListener('abort', onUserAbort, { once: true });
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetchImpl(EXA_MCP_ENDPOINT, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {
|
|
48
|
+
'content-type': 'application/json',
|
|
49
|
+
accept: 'application/json, text/event-stream'
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
jsonrpc: '2.0',
|
|
53
|
+
id: 1,
|
|
54
|
+
method: 'tools/call',
|
|
55
|
+
params: {
|
|
56
|
+
name: 'web_search_exa',
|
|
57
|
+
arguments: {
|
|
58
|
+
query,
|
|
59
|
+
numResults: count,
|
|
60
|
+
type: 'auto',
|
|
61
|
+
livecrawl: 'fallback',
|
|
62
|
+
contextMaxCharacters: 3000
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}),
|
|
66
|
+
signal: internalController.signal
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
if (userAborted)
|
|
71
|
+
throw new ExaSearchError('Search aborted.', 'aborted');
|
|
72
|
+
throw new ExaSearchError(`Exa search request failed: ${describeError(err)}`, 'network');
|
|
73
|
+
}
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
throw new ExaSearchError(`Exa search HTTP ${response.status} ${response.statusText}`, 'http', response.status);
|
|
76
|
+
}
|
|
77
|
+
const rpc = parseRpcBody(await response.text());
|
|
78
|
+
if (rpc.error) {
|
|
79
|
+
throw new ExaSearchError(`Exa MCP error${rpc.error.code !== undefined ? ` ${rpc.error.code}` : ''}: ${rpc.error.message ?? 'unknown error'}`, 'protocol');
|
|
80
|
+
}
|
|
81
|
+
const text = rpc.result?.content?.find(c => c.type === 'text' && typeof c.text === 'string' && c.text.trim().length > 0)?.text;
|
|
82
|
+
if (rpc.result?.isError) {
|
|
83
|
+
throw new ExaSearchError(text?.trim() || 'Exa MCP returned an error result.', 'protocol');
|
|
84
|
+
}
|
|
85
|
+
if (!text)
|
|
86
|
+
throw new ExaSearchError('Exa MCP returned no text content.', 'protocol');
|
|
87
|
+
return parseResultBlocks(text).slice(0, count);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
clearTimeout(timeoutHandle);
|
|
91
|
+
if (opts.signal)
|
|
92
|
+
opts.signal.removeEventListener('abort', onUserAbort);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The endpoint answers either as a text/event-stream (`data: {json}` lines) or
|
|
97
|
+
* as a plain JSON body; accept both and take the first frame carrying a
|
|
98
|
+
* JSON-RPC result or error.
|
|
99
|
+
*/
|
|
100
|
+
function parseRpcBody(body) {
|
|
101
|
+
for (const line of body.split('\n')) {
|
|
102
|
+
if (!line.startsWith('data:'))
|
|
103
|
+
continue;
|
|
104
|
+
const payload = line.slice(5).trim();
|
|
105
|
+
if (!payload)
|
|
106
|
+
continue;
|
|
107
|
+
try {
|
|
108
|
+
const candidate = JSON.parse(payload);
|
|
109
|
+
if (candidate.result || candidate.error)
|
|
110
|
+
return candidate;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* not this frame */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const candidate = JSON.parse(body);
|
|
118
|
+
if (candidate.result || candidate.error)
|
|
119
|
+
return candidate;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* fall through */
|
|
123
|
+
}
|
|
124
|
+
throw new ExaSearchError('Exa MCP returned an unparseable response.', 'protocol');
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Split the tool's text payload into `Title:`/`URL:` blocks. The per-result
|
|
128
|
+
* content lives after a `Text:` label (full text) or a `Highlights:` line
|
|
129
|
+
* (snippet mode); either becomes the description, whitespace-collapsed and
|
|
130
|
+
* capped so a result line stays a snippet, not a page dump.
|
|
131
|
+
*/
|
|
132
|
+
function parseResultBlocks(text) {
|
|
133
|
+
const blocks = text.split(/(?=^Title: )/m).filter(b => b.trim().length > 0);
|
|
134
|
+
const results = [];
|
|
135
|
+
for (const block of blocks) {
|
|
136
|
+
const title = block.match(/^Title: (.+)/m)?.[1]?.trim() ?? '';
|
|
137
|
+
const url = block.match(/^URL: (.+)/m)?.[1]?.trim() ?? '';
|
|
138
|
+
if (!url)
|
|
139
|
+
continue;
|
|
140
|
+
let content = '';
|
|
141
|
+
const textStart = block.indexOf('\nText: ');
|
|
142
|
+
if (textStart >= 0) {
|
|
143
|
+
content = block.slice(textStart + '\nText: '.length);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const highlights = block.match(/\nHighlights:[ \t]*\n/);
|
|
147
|
+
if (highlights?.index !== undefined) {
|
|
148
|
+
content = block.slice(highlights.index + highlights[0].length);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const description = content
|
|
152
|
+
.replace(/\n---\s*$/, '')
|
|
153
|
+
.replace(/\s+/g, ' ')
|
|
154
|
+
.trim()
|
|
155
|
+
.slice(0, MAX_DESCRIPTION_CHARS);
|
|
156
|
+
results.push({ title: title || url, url, description });
|
|
157
|
+
}
|
|
158
|
+
return results;
|
|
159
|
+
}
|
|
160
|
+
function describeError(err) {
|
|
161
|
+
if (err instanceof Error)
|
|
162
|
+
return err.message;
|
|
163
|
+
return String(err);
|
|
164
|
+
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import { braveSearch as defaultBraveSearch } from './brave-search.js';
|
|
2
|
+
import type { braveSearch as defaultBraveSearch } from './brave-search.js';
|
|
3
|
+
import type { ddgSearch as defaultDdgSearch } from './ddg-search.js';
|
|
4
|
+
import type { exaSearch as defaultExaSearch } from './exa-search.js';
|
|
5
|
+
import type { SearchProvider } from './search-types.js';
|
|
3
6
|
export interface PiWorkerSearchInternals {
|
|
4
7
|
braveSearch?: typeof defaultBraveSearch;
|
|
8
|
+
exaSearch?: typeof defaultExaSearch;
|
|
9
|
+
ddgSearch?: typeof defaultDdgSearch;
|
|
10
|
+
provider?: SearchProvider;
|
|
5
11
|
getEnv?: (key: string) => string | undefined;
|
|
6
12
|
}
|
|
7
13
|
export declare function registerPiWorkerSearch(pi: ExtensionAPI, internals?: PiWorkerSearchInternals): void;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Type } from '@sinclair/typebox';
|
|
2
2
|
import { Text } from '@earendil-works/pi-tui';
|
|
3
|
+
import { getConfig } from '../config/config.js';
|
|
3
4
|
import { search } from './search-core.js';
|
|
4
5
|
import { makeWorkerTool } from './shared.js';
|
|
5
6
|
import { normalizeQuery } from './research-cache.js';
|
|
@@ -12,26 +13,29 @@ const Params = Type.Object({
|
|
|
12
13
|
}))
|
|
13
14
|
});
|
|
14
15
|
export function registerPiWorkerSearch(pi, internals = {}) {
|
|
16
|
+
const provider = () => internals.provider ?? getConfig().searchProvider;
|
|
15
17
|
makeWorkerTool(pi, {
|
|
16
18
|
name: 'pi-worker-search',
|
|
17
19
|
label: 'Pi Worker Search',
|
|
18
|
-
description: 'Search the live web
|
|
20
|
+
description: 'Search the live web. CALL THIS BEFORE ANSWERING any '
|
|
19
21
|
+ 'question about current or version-specific external facts: '
|
|
20
22
|
+ 'library/framework versions and their APIs, latest releases, recently '
|
|
21
23
|
+ 'shipped features, current events, prices, or who currently holds a '
|
|
22
24
|
+ 'role. Your built-in knowledge is out of date — do NOT answer such '
|
|
23
25
|
+ 'questions from memory and do NOT shell out with bash to guess. Returns '
|
|
24
26
|
+ 'a compact markdown list of up to 10 results (title, URL, snippet); then '
|
|
25
|
-
+ 'call `pi-worker-fetch` on the URL you want to read.
|
|
26
|
-
+ 'Requires BRAVE_SEARCH_API_KEY env var.',
|
|
27
|
+
+ 'call `pi-worker-fetch` on the URL you want to read.',
|
|
27
28
|
parameters: Params,
|
|
28
29
|
async run(params, signal) {
|
|
29
30
|
const result = await search({
|
|
30
31
|
query: params.query,
|
|
31
32
|
count: params.count,
|
|
32
33
|
signal,
|
|
34
|
+
provider: internals.provider,
|
|
33
35
|
getEnv: internals.getEnv,
|
|
34
|
-
braveSearch: internals.braveSearch
|
|
36
|
+
braveSearch: internals.braveSearch,
|
|
37
|
+
exaSearch: internals.exaSearch,
|
|
38
|
+
ddgSearch: internals.ddgSearch
|
|
35
39
|
});
|
|
36
40
|
if (result.kind === 'no_key' || result.kind === 'error') {
|
|
37
41
|
return { text: result.message, details: { resultCount: 0 } };
|
|
@@ -53,8 +57,9 @@ export function registerPiWorkerSearch(pi, internals = {}) {
|
|
|
53
57
|
},
|
|
54
58
|
// Cache search results per run (the same query re-run across sibling tasks hits
|
|
55
59
|
// the live web anew otherwise). Count is part of the key — a larger request is a
|
|
56
|
-
// different result set
|
|
57
|
-
|
|
60
|
+
// different result set — and so is the provider: two engines' result sets for
|
|
61
|
+
// one query are different answers and must not serve for each other.
|
|
62
|
+
cacheKey: params => `${provider()}::${normalizeQuery(params.query)}::${params.count ?? ''}`,
|
|
58
63
|
// Only a non-empty result set is worth caching; no-key, error, and empty results
|
|
59
64
|
// (resultCount 0) fall through so a later attempt can succeed.
|
|
60
65
|
cacheable: d => d.resultCount > 0
|
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import { braveSearch as defaultBraveSearch
|
|
1
|
+
import { braveSearch as defaultBraveSearch } from './brave-search.js';
|
|
2
|
+
import { ddgSearch as defaultDdgSearch } from './ddg-search.js';
|
|
3
|
+
import { exaSearch as defaultExaSearch } from './exa-search.js';
|
|
4
|
+
import type { SearchProvider, SearchResult } from './search-types.js';
|
|
2
5
|
export interface SearchCoreInput {
|
|
3
6
|
query: string;
|
|
4
7
|
count?: number;
|
|
5
8
|
signal?: AbortSignal;
|
|
6
9
|
getEnv?: (key: string) => string | undefined;
|
|
10
|
+
/** Engine override; defaults to the configured `searchProvider` (exa). */
|
|
11
|
+
provider?: SearchProvider;
|
|
7
12
|
braveSearch?: typeof defaultBraveSearch;
|
|
13
|
+
exaSearch?: typeof defaultExaSearch;
|
|
14
|
+
ddgSearch?: typeof defaultDdgSearch;
|
|
8
15
|
}
|
|
9
16
|
export type SearchCoreResult = {
|
|
10
17
|
kind: 'ok';
|
|
11
|
-
results:
|
|
18
|
+
results: SearchResult[];
|
|
12
19
|
} | {
|
|
13
20
|
kind: 'no_key';
|
|
14
21
|
message: string;
|
|
@@ -16,4 +23,9 @@ export type SearchCoreResult = {
|
|
|
16
23
|
kind: 'error';
|
|
17
24
|
message: string;
|
|
18
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Provider selection is STRICT: the configured (or overridden) engine is the
|
|
28
|
+
* only one tried — a failure reports as an error rather than silently switching
|
|
29
|
+
* engines, so results always come from where the user thinks they do.
|
|
30
|
+
*/
|
|
19
31
|
export declare function search(input: SearchCoreInput): Promise<SearchCoreResult>;
|
|
@@ -1,18 +1,50 @@
|
|
|
1
|
+
import { getConfig } from '../config/config.js';
|
|
1
2
|
import { braveSearch as defaultBraveSearch, BraveSearchError } from './brave-search.js';
|
|
3
|
+
import { ddgSearch as defaultDdgSearch } from './ddg-search.js';
|
|
4
|
+
import { exaSearch as defaultExaSearch } from './exa-search.js';
|
|
2
5
|
function isLikeBraveSearchError(err) {
|
|
3
6
|
return (typeof err === 'object'
|
|
4
7
|
&& err !== null
|
|
5
8
|
&& err.name === 'BraveSearchError');
|
|
6
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Provider selection is STRICT: the configured (or overridden) engine is the
|
|
12
|
+
* only one tried — a failure reports as an error rather than silently switching
|
|
13
|
+
* engines, so results always come from where the user thinks they do.
|
|
14
|
+
*/
|
|
7
15
|
export async function search(input) {
|
|
16
|
+
const provider = input.provider ?? getConfig().searchProvider;
|
|
17
|
+
if (provider === 'brave')
|
|
18
|
+
return braveSearchCore(input);
|
|
19
|
+
const run = provider === 'exa' ?
|
|
20
|
+
() => (input.exaSearch ?? defaultExaSearch)(input.query, {
|
|
21
|
+
count: input.count,
|
|
22
|
+
signal: input.signal
|
|
23
|
+
})
|
|
24
|
+
: () => (input.ddgSearch ?? defaultDdgSearch)(input.query, {
|
|
25
|
+
count: input.count,
|
|
26
|
+
signal: input.signal
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
return { kind: 'ok', results: await run() };
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return {
|
|
33
|
+
kind: 'error',
|
|
34
|
+
message: err instanceof Error ? err.message : String(err)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function braveSearchCore(input) {
|
|
8
39
|
const getEnv = input.getEnv ?? ((k) => process.env[k]);
|
|
9
40
|
const braveSearch = input.braveSearch ?? defaultBraveSearch;
|
|
10
41
|
const apiKey = getEnv('BRAVE_SEARCH_API_KEY') ?? getEnv('BRAVE_API_KEY');
|
|
11
42
|
if (!apiKey) {
|
|
12
43
|
return {
|
|
13
44
|
kind: 'no_key',
|
|
14
|
-
message: 'Brave Search not configured. Set BRAVE_SEARCH_API_KEY env var
|
|
15
|
-
+ '
|
|
45
|
+
message: 'Brave Search not configured. Set BRAVE_SEARCH_API_KEY env var '
|
|
46
|
+
+ '(get a key at https://api.search.brave.com/app/keys) or switch '
|
|
47
|
+
+ 'the search provider in /task-config.'
|
|
16
48
|
};
|
|
17
49
|
}
|
|
18
50
|
try {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** One web-search hit, in the shape every provider normalises to. */
|
|
2
|
+
export interface SearchResult {
|
|
3
|
+
title: string;
|
|
4
|
+
url: string;
|
|
5
|
+
description: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Which engine backs pi-worker-search and the freshness/enrichment lookups.
|
|
9
|
+
* - `exa` — Exa's public MCP endpoint; no key needed (default).
|
|
10
|
+
* - `ddg` — DuckDuckGo's HTML endpoint; no key needed.
|
|
11
|
+
* - `brave` — Brave Search API; needs BRAVE_SEARCH_API_KEY.
|
|
12
|
+
*/
|
|
13
|
+
export type SearchProvider = 'exa' | 'ddg' | 'brave';
|
|
14
|
+
export declare const SEARCH_PROVIDERS: readonly SearchProvider[];
|
|
15
|
+
/**
|
|
16
|
+
* Human-readable engine names for the config UI. The short ids stay the stored
|
|
17
|
+
* value (config-file compat); only the display layer uses these.
|
|
18
|
+
*/
|
|
19
|
+
export declare const SEARCH_PROVIDER_LABELS: Record<SearchProvider, string>;
|
|
20
|
+
export declare function providerForLabel(label: string): SearchProvider | undefined;
|
|
21
|
+
export declare function isSearchProvider(value: unknown): value is SearchProvider;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const SEARCH_PROVIDERS = ['exa', 'ddg', 'brave'];
|
|
2
|
+
/**
|
|
3
|
+
* Human-readable engine names for the config UI. The short ids stay the stored
|
|
4
|
+
* value (config-file compat); only the display layer uses these.
|
|
5
|
+
*/
|
|
6
|
+
export const SEARCH_PROVIDER_LABELS = {
|
|
7
|
+
exa: 'Exa',
|
|
8
|
+
ddg: 'DuckDuckGo',
|
|
9
|
+
brave: 'Brave'
|
|
10
|
+
};
|
|
11
|
+
export function providerForLabel(label) {
|
|
12
|
+
return SEARCH_PROVIDERS.find(p => SEARCH_PROVIDER_LABELS[p] === label);
|
|
13
|
+
}
|
|
14
|
+
export function isSearchProvider(value) {
|
|
15
|
+
return typeof value === 'string' && SEARCH_PROVIDERS.includes(value);
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.7",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|