@selesai/code 0.13.2 → 0.13.4

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.
Files changed (120) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/defaults/models.json +85 -13
  3. package/dist/extensions/cost-reconcile.test.ts +200 -4
  4. package/dist/extensions/cost-reconcile.ts +88 -102
  5. package/dist/extensions/pi-intercom/CHANGELOG.md +13 -0
  6. package/dist/extensions/pi-intercom/README.md +4 -5
  7. package/dist/extensions/pi-intercom/config.test.ts +3 -31
  8. package/dist/extensions/pi-intercom/config.ts +0 -15
  9. package/dist/extensions/pi-intercom/index.ts +9 -46
  10. package/dist/extensions/pi-intercom/intercom.integration.test.ts +49 -57
  11. package/dist/extensions/pi-intercom/package.json +1 -1
  12. package/dist/extensions/pi-intercom/reply-tracker.test.ts +20 -0
  13. package/dist/extensions/pi-intercom/reply-tracker.ts +8 -0
  14. package/dist/extensions/pi-subagents/CHANGELOG.md +27 -0
  15. package/dist/extensions/pi-subagents/docs/tool-reference.md +4 -1
  16. package/dist/extensions/pi-subagents/docs/workflows.md +2 -2
  17. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  18. package/dist/extensions/pi-subagents/package.json +1 -1
  19. package/dist/extensions/pi-subagents/skills/council-mode/SKILL.md +48 -243
  20. package/dist/extensions/pi-subagents/skills/council-mode/references/pass-contracts.md +150 -0
  21. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +87 -37
  22. package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
  23. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +49 -8
  24. package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  25. package/dist/extensions/pi-subagents/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  26. package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +34 -27
  27. package/dist/extensions/pi-subagents/skills/pi-subagents/references/review-and-validation.md +73 -0
  28. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +157 -28
  29. package/dist/extensions/pi-subagents/src/api/shared-types.ts +2 -0
  30. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +1 -0
  31. package/dist/extensions/pi-subagents/src/extension/schemas.ts +1 -0
  32. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +4 -1
  33. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +2 -2
  34. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +3 -0
  35. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +45 -2
  36. package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +3 -2
  37. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +13 -2
  38. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +5 -1
  39. package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +10 -2
  40. package/dist/extensions/pi-subagents/src/runs/background/wait-completions.ts +3 -0
  41. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +11 -2
  42. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +98 -1
  43. package/dist/extensions/pi-subagents/src/runs/shared/async-status-projection.ts +138 -4
  44. package/dist/extensions/pi-subagents/src/runs/shared/background-process-options.ts +9 -0
  45. package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-grant.ts +2 -5
  46. package/dist/extensions/pi-subagents/src/runs/shared/mutation-evidence.ts +52 -3
  47. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +47 -1
  48. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +45 -18
  49. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +21 -2
  50. package/dist/extensions/pi-subagents/src/runs/shared/workflow-graph.ts +15 -0
  51. package/dist/extensions/pi-subagents/src/shared/types.ts +34 -1
  52. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +11 -3
  53. package/dist/extensions/pi-subagents/src/tui/render-helpers.ts +31 -0
  54. package/dist/extensions/pi-subagents/src/tui/render.ts +597 -112
  55. package/dist/extensions/pi-subagents/src/watchdog/change-signature.ts +40 -1
  56. package/dist/extensions/pi-subagents/src/workflows/host-command.ts +6 -1
  57. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +53 -2
  58. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +55 -6
  59. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +111 -1
  60. package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +206 -38
  61. package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +522 -31
  62. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +123 -0
  63. package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +48 -0
  64. package/dist/extensions/pi-subagents/test/unit/async-status-projection.test.ts +57 -1
  65. package/dist/extensions/pi-subagents/test/unit/background-process-options.test.ts +17 -0
  66. package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +1 -1
  67. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +44 -4
  68. package/dist/extensions/pi-subagents/test/unit/fork-cache-key.test.ts +91 -0
  69. package/dist/extensions/pi-subagents/test/unit/host-command.test.ts +1 -0
  70. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +0 -1
  71. package/dist/extensions/pi-subagents/test/unit/mcp-direct-tool-grant.test.ts +20 -3
  72. package/dist/extensions/pi-subagents/test/unit/mutation-evidence.test.ts +27 -0
  73. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +93 -17
  74. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +1 -0
  75. package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +103 -26
  76. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +58 -0
  77. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +22 -2
  78. package/dist/extensions/pi-subagents/test/unit/scripted-workflow.test.ts +21 -0
  79. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +13 -0
  80. package/dist/extensions/pi-subagents/test/unit/subagent-wait.test.ts +54 -0
  81. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +2 -0
  82. package/dist/extensions/pi-subagents/test/unit/wait-completions.test.ts +32 -0
  83. package/dist/extensions/pi-subagents/test/unit/watchdog-change-signature.test.ts +48 -1
  84. package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +21 -6
  85. package/dist/extensions/pi-subagents/test/unit/windows-hide-spawn.test.ts +11 -0
  86. package/dist/extensions/pi-web-agent/CHANGELOG.md +410 -0
  87. package/dist/extensions/pi-web-agent/README.md +131 -0
  88. package/dist/extensions/pi-web-agent/package.json +4 -2
  89. package/dist/extensions/pi-web-agent/src/backends/config.ts +62 -5
  90. package/dist/extensions/pi-web-agent/src/backends/doctor.ts +136 -0
  91. package/dist/extensions/pi-web-agent/src/backends/factory.ts +96 -6
  92. package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +183 -47
  93. package/dist/extensions/pi-web-agent/src/extension.ts +62 -25
  94. package/dist/extensions/pi-web-agent/src/extract/readability.ts +19 -11
  95. package/dist/extensions/pi-web-agent/src/orchestration/candidate-selector.ts +5 -4
  96. package/dist/extensions/pi-web-agent/src/orchestration/direct-url.ts +2 -25
  97. package/dist/extensions/pi-web-agent/src/orchestration/evidence-quality.ts +5 -2
  98. package/dist/extensions/pi-web-agent/src/orchestration/evidence-ranker.ts +2 -0
  99. package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +72 -7
  100. package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +8 -1
  101. package/dist/extensions/pi-web-agent/src/orchestration/research-worker.ts +28 -3
  102. package/dist/extensions/pi-web-agent/src/orchestration/source-profile.ts +4 -0
  103. package/dist/extensions/pi-web-agent/src/orchestration/url.ts +35 -0
  104. package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +18 -6
  105. package/dist/extensions/pi-web-agent/src/presentation/search-presentation.ts +14 -2
  106. package/dist/extensions/pi-web-agent/src/readers/github-reader.ts +150 -0
  107. package/dist/extensions/pi-web-agent/src/readers/limits.ts +3 -0
  108. package/dist/extensions/pi-web-agent/src/readers/pdf-reader.ts +87 -0
  109. package/dist/extensions/pi-web-agent/src/readers/resolver.ts +25 -0
  110. package/dist/extensions/pi-web-agent/src/readers/types.ts +11 -0
  111. package/dist/extensions/pi-web-agent/src/readers/youtube-reader.ts +79 -0
  112. package/dist/extensions/pi-web-agent/src/search/duckduckgo.ts +32 -5
  113. package/dist/extensions/pi-web-agent/src/search/exa.ts +109 -0
  114. package/dist/extensions/pi-web-agent/src/search/fanout.ts +154 -0
  115. package/dist/extensions/pi-web-agent/src/search/tavily.ts +113 -0
  116. package/dist/extensions/pi-web-agent/src/search/youcom.ts +109 -0
  117. package/dist/extensions/pi-web-agent/src/tools/web-search.ts +22 -9
  118. package/dist/extensions/pi-web-agent/src/types.ts +19 -4
  119. package/dist/extensions/tokenin-onboarding.ts +302 -0
  120. package/package.json +1 -1
@@ -2,16 +2,27 @@ import {
2
2
  DEFAULT_BACKEND_CONFIG,
3
3
  mergeBackendConfigLayers,
4
4
  validateBackendConfig,
5
+ usableSearchProviders,
5
6
  type BackendConfig,
6
7
  type BackendConfigOverride
7
8
  } from '../backends/config.js';
8
9
  import { checkBackendHealth } from '../backends/doctor.js';
10
+ import type { SearchProviderName } from '../types.js';
9
11
  import {
10
12
  DynamicBorder,
11
13
  getSettingsListTheme,
12
14
  type ExtensionAPI
13
15
  } from '@selesai/code';
14
- import { Container, SelectList, SettingsList, Text, type SelectItem, type SettingItem } from '@earendil-works/pi-tui';
16
+ import {
17
+ Container,
18
+ Input,
19
+ SelectList,
20
+ SettingsList,
21
+ Text,
22
+ type Component,
23
+ type SelectItem,
24
+ type SettingItem
25
+ } from '@earendil-works/pi-tui';
15
26
  import {
16
27
  DEFAULT_PRESENTATION_CONFIG,
17
28
  mergePresentationConfigLayers,
@@ -78,7 +89,10 @@ function cloneBackendConfig(config: BackendConfig): BackendConfig {
78
89
  return {
79
90
  search: {
80
91
  ...config.search,
81
- options: config.search.options ? { ...config.search.options } : undefined
92
+ options: config.search.options ? { ...config.search.options } : undefined,
93
+ fanout: config.search.fanout
94
+ ? { mode: config.search.fanout.mode, providers: config.search.fanout.providers ? [...config.search.fanout.providers] : undefined }
95
+ : undefined
82
96
  },
83
97
  fetch: {
84
98
  ...config.fetch,
@@ -115,12 +129,17 @@ async function defaultCheckTypebox(): Promise<boolean> {
115
129
  }
116
130
 
117
131
  function formatSearchOptions(config: BackendConfig['search']) {
118
- return [
132
+ const parts = [
119
133
  config.fallback ? `fallback ${config.fallback}` : undefined,
120
134
  config.options?.categories?.length ? `categories ${config.options.categories.join(',')}` : undefined,
121
135
  config.options?.language ? `language ${config.options.language}` : undefined,
122
- config.options?.safesearch !== undefined ? `safesearch ${config.options.safesearch}` : undefined
123
- ].filter(Boolean).join(' ');
136
+ config.options?.safesearch !== undefined ? `safesearch ${config.options.safesearch}` : undefined,
137
+ config.fanout && config.fanout.mode !== 'off'
138
+ ? `fanout ${config.fanout.mode} (${(config.fanout.providers || usableSearchProviders(config)).join(', ')})`
139
+ : undefined
140
+ ].filter(Boolean);
141
+
142
+ return parts.join(' ');
124
143
  }
125
144
 
126
145
  function formatFetchOptions(config: BackendConfig['fetch']) {
@@ -181,7 +200,73 @@ function buildPresentationSettingsItems(scope: PresentationScope, config: Presen
181
200
  ];
182
201
  }
183
202
 
184
- function buildBackendSettingsItems(scope: PresentationScope, backends: BackendConfig): SettingItem[] {
203
+ export function createBackendUrlEditor(
204
+ theme: any,
205
+ label: string,
206
+ placeholderUrl: string,
207
+ onOpenChange?: (open: boolean) => void
208
+ ) {
209
+ return (currentValue: string, done: (selectedValue?: string) => void): Component => {
210
+ onOpenChange?.(true);
211
+
212
+ const initialValue = currentValue && currentValue !== 'not set' ? currentValue : placeholderUrl;
213
+ const container = new Container();
214
+ const hint = new Text(theme.fg('muted', `${label} · enter to save · empty clears · esc cancels`), 1, 0);
215
+ const input = new Input();
216
+ input.setValue(initialValue);
217
+ input.handleInput('\x1b[F'); // move cursor to end; setValue leaves it at 0
218
+ input.focused = true;
219
+ const errorText = new Text('', 1, 0);
220
+
221
+ const finish = (selectedValue?: string) => {
222
+ onOpenChange?.(false);
223
+ done(selectedValue);
224
+ };
225
+
226
+ const showError = (message: string) => {
227
+ errorText.setText(theme.fg('warning', message));
228
+ container.invalidate();
229
+ };
230
+
231
+ input.onSubmit = (value: string) => {
232
+ if (!value.trim()) {
233
+ finish('');
234
+ return;
235
+ }
236
+
237
+ const validated = validateBackendUrl(value);
238
+ if (!validated.ok) {
239
+ showError(validated.message);
240
+ return;
241
+ }
242
+
243
+ finish(validated.value);
244
+ };
245
+
246
+ input.onEscape = () => finish(undefined);
247
+
248
+ container.addChild(hint);
249
+ container.addChild(input);
250
+ container.addChild(errorText);
251
+
252
+ return {
253
+ render: (width: number) => container.render(width),
254
+ invalidate: () => container.invalidate(),
255
+ handleInput: (data: string) => input.handleInput(data)
256
+ };
257
+ };
258
+ }
259
+
260
+ function buildBackendSettingsItems(
261
+ scope: PresentationScope,
262
+ backends: BackendConfig,
263
+ theme: any,
264
+ onUrlEditorOpenChange?: (open: boolean) => void
265
+ ): SettingItem[] {
266
+ const usable = usableSearchProviders(backends.search);
267
+ const fanoutMode = backends.search.fanout?.mode ?? 'off';
268
+ const fanoutProviders = backends.search.fanout?.providers ?? usable;
269
+
185
270
  return [
186
271
  {
187
272
  id: 'scope',
@@ -193,26 +278,56 @@ function buildBackendSettingsItems(scope: PresentationScope, backends: BackendCo
193
278
  id: 'backend:search:provider',
194
279
  label: 'Search backend',
195
280
  currentValue: backends.search.provider,
196
- values: ['duckduckgo', 'searxng', 'brave']
281
+ values: ['duckduckgo', 'searxng', 'brave', 'youcom', 'exa', 'tavily']
197
282
  },
198
283
  {
199
284
  id: 'backend:search:baseUrl',
200
285
  label: 'SearXNG URL',
201
286
  currentValue: backends.search.baseUrl ?? 'not set',
202
- values: ['edit']
287
+ submenu: createBackendUrlEditor(theme, 'SearXNG base URL', 'http://localhost:8080', onUrlEditorOpenChange)
203
288
  },
204
289
  {
205
290
  id: 'backend:search:fallback',
206
291
  label: 'Search fallback',
207
- currentValue: backends.search.provider === 'searxng' || backends.search.provider === 'brave' ? backends.search.fallback ?? 'off' : 'off',
208
- values: backends.search.provider === 'searxng' || backends.search.provider === 'brave' ? ['off', 'duckduckgo'] : ['off']
292
+ currentValue: backends.search.provider === 'searxng' || backends.search.provider === 'brave' || backends.search.provider === 'youcom' || backends.search.provider === 'exa' || backends.search.provider === 'tavily' ? backends.search.fallback ?? 'off' : 'off',
293
+ values: backends.search.provider === 'searxng' || backends.search.provider === 'brave' || backends.search.provider === 'youcom' || backends.search.provider === 'exa' || backends.search.provider === 'tavily' ? ['off', 'duckduckgo'] : ['off']
209
294
  },
295
+ {
296
+ id: 'backend:search:fanout:mode',
297
+ label: 'Search fanout',
298
+ currentValue: fanoutMode,
299
+ values: ['off', 'on', 'auto']
300
+ },
301
+ ...(fanoutMode !== 'off' ? usable.map((provider) => ({
302
+ id: `backend:search:fanout:provider:${provider}`,
303
+ label: ` ${provider}`,
304
+ currentValue: fanoutProviders.includes(provider) ? 'included' : 'excluded',
305
+ values: ['included', 'excluded']
306
+ })) : []),
210
307
  {
211
308
  id: 'backend:secret:brave',
212
309
  label: 'Brave API key',
213
310
  currentValue: 'env var',
214
311
  values: ['env var']
215
312
  },
313
+ {
314
+ id: 'backend:secret:youcom',
315
+ label: 'You.com API key',
316
+ currentValue: 'env var',
317
+ values: ['env var']
318
+ },
319
+ {
320
+ id: 'backend:secret:exa',
321
+ label: 'Exa API key',
322
+ currentValue: 'env var',
323
+ values: ['env var']
324
+ },
325
+ {
326
+ id: 'backend:secret:tavily',
327
+ label: 'Tavily API key',
328
+ currentValue: 'env var',
329
+ values: ['env var']
330
+ },
216
331
  {
217
332
  id: 'backend:fetch:provider',
218
333
  label: 'Fetch backend',
@@ -223,7 +338,7 @@ function buildBackendSettingsItems(scope: PresentationScope, backends: BackendCo
223
338
  id: 'backend:fetch:baseUrl',
224
339
  label: 'Firecrawl URL',
225
340
  currentValue: backends.fetch.baseUrl ?? 'not set',
226
- values: ['edit']
341
+ submenu: createBackendUrlEditor(theme, 'Firecrawl base URL', 'http://localhost:3002', onUrlEditorOpenChange)
227
342
  },
228
343
  {
229
344
  id: 'backend:fetch:fallback',
@@ -373,7 +488,7 @@ export function applySettingsValue(
373
488
  currentDraft.tools = nextTools;
374
489
  }
375
490
 
376
- if (id === 'backend:search:provider' && (newValue === 'duckduckgo' || newValue === 'searxng' || newValue === 'brave')) {
491
+ if (id === 'backend:search:provider' && (newValue === 'duckduckgo' || newValue === 'searxng' || newValue === 'brave' || newValue === 'youcom' || newValue === 'exa' || newValue === 'tavily')) {
377
492
  currentBackends.search.provider = newValue;
378
493
  if (newValue !== 'searxng') {
379
494
  delete currentBackends.search.baseUrl;
@@ -385,15 +500,53 @@ export function applySettingsValue(
385
500
  }
386
501
 
387
502
  if (id === 'backend:search:fallback') {
388
- if (newValue === 'duckduckgo' && (currentBackends.search.provider === 'searxng' || currentBackends.search.provider === 'brave')) {
503
+ if (newValue === 'duckduckgo' && (currentBackends.search.provider === 'searxng' || currentBackends.search.provider === 'brave' || currentBackends.search.provider === 'youcom' || currentBackends.search.provider === 'exa' || currentBackends.search.provider === 'tavily')) {
389
504
  currentBackends.search.fallback = 'duckduckgo';
390
505
  } else {
391
506
  delete currentBackends.search.fallback;
392
507
  }
393
508
  }
394
509
 
510
+ if (id === 'backend:search:fanout:mode') {
511
+ if (newValue === 'off') {
512
+ currentBackends.search.fanout = { mode: 'off' };
513
+ } else if (newValue === 'on' || newValue === 'auto') {
514
+ currentBackends.search.fanout = {
515
+ mode: newValue,
516
+ providers: undefined
517
+ };
518
+ }
519
+ }
520
+
521
+ if (id.startsWith('backend:search:fanout:provider:')) {
522
+ const providerName = id.slice('backend:search:fanout:provider:'.length) as SearchProviderName;
523
+ const usableProviders = usableSearchProviders(currentBackends.search);
524
+
525
+ // Ensure fanout exists
526
+ if (!currentBackends.search.fanout) {
527
+ currentBackends.search.fanout = { mode: 'on', providers: undefined };
528
+ }
529
+
530
+ // Materialize the provider list if it's currently undefined; use usable providers instead of all
531
+ const currentProviders = currentBackends.search.fanout.providers ?? usableProviders;
532
+
533
+ if (newValue === 'excluded') {
534
+ // Remove the provider from the list
535
+ const filtered: SearchProviderName[] = currentProviders.filter((p) => p !== providerName);
536
+ // never allow an empty set: keep at least one provider (empty would be read as "all")
537
+ if (filtered.length > 0) {
538
+ currentBackends.search.fanout.providers = filtered;
539
+ }
540
+ } else if (newValue === 'included') {
541
+ // Add the provider back, maintaining canonical order using usable providers
542
+ const result: SearchProviderName[] = usableProviders.filter((p) => currentProviders.includes(p) || p === providerName);
543
+ currentBackends.search.fanout.providers = result;
544
+ }
545
+ }
546
+
395
547
  if (id === 'backend:search:baseUrl') {
396
- if (currentBackends.search.provider === 'searxng' && newValue.trim()) {
548
+ if (newValue.trim()) {
549
+ currentBackends.search.provider = 'searxng';
397
550
  currentBackends.search.baseUrl = newValue.trim();
398
551
  } else {
399
552
  delete currentBackends.search.baseUrl;
@@ -417,6 +570,7 @@ export function applySettingsValue(
417
570
 
418
571
  if (id === 'backend:fetch:baseUrl') {
419
572
  if (newValue.trim()) {
573
+ currentBackends.fetch.provider = 'firecrawl';
420
574
  currentBackends.fetch.baseUrl = newValue.trim();
421
575
  } else {
422
576
  delete currentBackends.fetch.baseUrl;
@@ -474,7 +628,8 @@ export function collapseBackendConfigToOverride(
474
628
  : {
475
629
  ...(config.search.baseUrl !== inheritedConfig.search.baseUrl ? { baseUrl: config.search.baseUrl } : {}),
476
630
  ...(config.search.fallback !== inheritedConfig.search.fallback ? { fallback: config.search.fallback } : {}),
477
- ...(!sameJson(config.search.options, inheritedConfig.search.options) ? { options: config.search.options } : {})
631
+ ...(!sameJson(config.search.options, inheritedConfig.search.options) ? { options: config.search.options } : {}),
632
+ ...(!sameJson(config.search.fanout, inheritedConfig.search.fanout) ? { fanout: config.search.fanout } : {})
478
633
  };
479
634
 
480
635
  if (config.search.provider !== inheritedConfig.search.provider) {
@@ -675,9 +830,10 @@ async function openBackendSettingsUi(
675
830
  loaded: Awaited<LoadedPresentationConfig>,
676
831
  initialScope: PresentationScope
677
832
  ): Promise<SettingsUiResult | undefined> {
678
- return ctx.ui.custom((tui: any, theme: any, _kb: unknown, done: (value: SettingsUiResult) => void) => {
833
+ return ctx.ui.custom((_tui: unknown, theme: any, _kb: unknown, done: (value: SettingsUiResult) => void) => {
679
834
  let state = createSettingsDraftState(loaded, initialScope);
680
835
  let settingsList: SettingsList;
836
+ let urlEditorOpen = false;
681
837
 
682
838
  const container = new Container();
683
839
  container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent · backends')), 1, 1));
@@ -689,46 +845,18 @@ async function openBackendSettingsUi(
689
845
  )
690
846
  );
691
847
 
692
- const editUrl = async (id: string) => {
693
- const isSearchUrl = id === 'backend:search:baseUrl';
694
- const label = isSearchUrl ? 'SearXNG base URL' : 'Firecrawl base URL';
695
- const currentValue = isSearchUrl ? state.backends.search.baseUrl : state.backends.fetch.baseUrl;
696
- const entered = await ctx.ui.input(label, currentValue ?? (isSearchUrl ? 'http://localhost:8080' : 'http://localhost:3002'));
697
- if (entered === undefined) return;
698
-
699
- if (!entered.trim()) {
700
- state = applySettingsValue(state, id, '');
701
- rebuildSettingsList();
702
- tui.requestRender?.();
703
- return;
704
- }
705
-
706
- const validated = validateBackendUrl(entered);
707
- if (!validated.ok) {
708
- ctx.ui.notify(validated.message, 'warning');
709
- return;
710
- }
711
-
712
- state = applySettingsValue(state, id, validated.value);
713
- rebuildSettingsList();
714
- tui.requestRender?.();
715
- };
716
-
717
848
  const rebuildSettingsList = () => {
718
849
  if (settingsList) {
719
850
  container.removeChild(settingsList);
720
851
  }
721
852
 
722
853
  settingsList = new SettingsList(
723
- buildBackendSettingsItems(state.scope, state.backends),
854
+ buildBackendSettingsItems(state.scope, state.backends, theme, (open) => {
855
+ urlEditorOpen = open;
856
+ }),
724
857
  12,
725
858
  getSettingsListTheme(),
726
859
  (id, newValue) => {
727
- if (id === 'backend:search:baseUrl' || id === 'backend:fetch:baseUrl') {
728
- void editUrl(id);
729
- return;
730
- }
731
-
732
860
  state = applySettingsValue(state, id, newValue);
733
861
  rebuildSettingsList();
734
862
  container.invalidate();
@@ -746,6 +874,14 @@ async function openBackendSettingsUi(
746
874
  render: (width: number) => container.render(width),
747
875
  invalidate: () => container.invalidate(),
748
876
  handleInput: (data: string) => {
877
+ // While the inline URL editor is open, let it (and its own Escape/Enter
878
+ // handling) consume input first — otherwise these shortcuts close the
879
+ // whole settings UI out from under an in-progress edit.
880
+ if (urlEditorOpen) {
881
+ settingsList.handleInput?.(data);
882
+ return;
883
+ }
884
+
749
885
  const shortcut = handleSettingsShortcut(JSON.stringify(data).slice(1, -1));
750
886
 
751
887
  if (shortcut?.action === 'cancel') {
@@ -6,18 +6,35 @@ import { DEFAULT_PRESENTATION_CONFIG, resolvePresentationMode } from './presenta
6
6
  import { createResearchWorkflow } from './orchestration/index.js';
7
7
  import { loadPresentationConfigLayers } from './presentation/config-store.js';
8
8
  import { selectPresentationView } from './presentation/select-view.js';
9
- import type {
10
- PresentationConfig,
11
- PresentationEnvelope,
12
- PresentationToolName
13
- } from './presentation/types.js';
9
+ import type { PresentationConfig } from './presentation/types.js';
14
10
  import { createWebExploreTool } from './tools/web-explore.js';
15
11
  import type { WebExploreResponse } from './types.js';
16
12
  import { getUpdateChangelogNotice } from './changelog-notice.js';
13
+ import { Text } from '@earendil-works/pi-tui';
17
14
 
18
- type ToolResultWithPresentation = {
19
- presentation?: PresentationEnvelope;
20
- };
15
+ /**
16
+ * What the model receives as the tool result: the actual synthesized findings (which for a
17
+ * direct GitHub/PDF/YouTube read is the full extracted content), plus source citations and any
18
+ * caveat. This is separate from the terminal display (see renderResult), so the model always
19
+ * gets the substance regardless of the user's compact/preview/verbose presentation setting.
20
+ */
21
+ function serializeForModel(result: WebExploreResponse): string {
22
+ if (result.status === 'error') {
23
+ return `Research failed: ${result.error?.message ?? 'Unknown research failure.'}`;
24
+ }
25
+ if (result.findings.length === 0 && result.sources.length === 0) {
26
+ return 'No usable evidence found.';
27
+ }
28
+ return [
29
+ result.findings.join('\n\n'),
30
+ result.sources.length
31
+ ? `Sources:\n${result.sources.map((source) => `- ${source.title}: ${source.url}`).join('\n')}`
32
+ : undefined,
33
+ result.caveat
34
+ ]
35
+ .filter(Boolean)
36
+ .join('\n\n');
37
+ }
21
38
 
22
39
  async function loadWebAgentConfig(pi: ExtensionAPI) {
23
40
  const store = (
@@ -49,16 +66,6 @@ async function getEffectiveBackendConfig(pi: ExtensionAPI): Promise<BackendConfi
49
66
  }
50
67
  }
51
68
 
52
- async function renderToolText(
53
- pi: ExtensionAPI,
54
- toolName: PresentationToolName,
55
- details: ToolResultWithPresentation
56
- ): Promise<string> {
57
- const config = await getEffectivePresentationConfig(pi);
58
- const mode = resolvePresentationMode(toolName, config);
59
- return selectPresentationView(details.presentation, mode) ?? JSON.stringify(details, null, 2);
60
- }
61
-
62
69
  export default function extension(pi: ExtensionAPI) {
63
70
  registerWebAgentConfigCommands(pi);
64
71
 
@@ -111,16 +118,46 @@ export default function extension(pi: ExtensionAPI) {
111
118
  async execute(_toolCallId, params) {
112
119
  const webExplore = await getConfiguredWebExplore();
113
120
  const result: WebExploreResponse = await webExplore({ query: params.query });
121
+
122
+ // Terminal display honors the user's presentation mode; the model gets the full findings.
123
+ // The fallback must stay terse: never fall back to serializeForModel here, or a missing
124
+ // presentation would dump the full findings into the terminal.
125
+ const mode = resolvePresentationMode('web_explore', await getEffectivePresentationConfig(pi));
126
+ const terseFallback = 'web_explore result';
127
+ const terminalText = selectPresentationView(result.presentation, mode) ?? terseFallback;
128
+ const terminalTextExpanded = selectPresentationView(result.presentation, 'verbose') ?? terminalText;
129
+
114
130
  return {
115
- content: [
116
- {
117
- type: 'text',
118
- text: await renderToolText(pi, 'web_explore', result)
119
- }
120
- ],
121
- details: result,
131
+ content: [{ type: 'text', text: serializeForModel(result) }],
132
+ details: { ...result, terminalText, terminalTextExpanded },
122
133
  isError: result.status === 'error'
123
134
  };
135
+ },
136
+ renderResult(toolResult, options) {
137
+ const details = toolResult.details as
138
+ | {
139
+ terminalText?: string;
140
+ terminalTextExpanded?: string;
141
+ presentation?: { views?: { compact?: string; verbose?: string } };
142
+ }
143
+ | undefined;
144
+ try {
145
+ // Legacy fallback: results persisted before this change carry `presentation` but no
146
+ // `terminalText`, so old sessions stay readable instead of rendering blank.
147
+ const legacy = options.expanded
148
+ ? details?.presentation?.views?.verbose ?? details?.presentation?.views?.compact
149
+ : details?.presentation?.views?.compact;
150
+ const text =
151
+ (options.expanded ? details?.terminalTextExpanded : details?.terminalText) ??
152
+ details?.terminalText ??
153
+ legacy ??
154
+ 'web_explore result';
155
+ return new Text(text, 0, 0);
156
+ } catch {
157
+ // Never throw: a thrown renderer makes Pi fall back to raw content, which would dump
158
+ // the full model-facing findings into the terminal.
159
+ return new Text('web_explore result', 0, 0);
160
+ }
124
161
  }
125
162
  });
126
163
  }
@@ -41,18 +41,26 @@ export function extractReadableContent(html: string, maxLength = 4000): Extracte
41
41
  };
42
42
  }
43
43
 
44
+ const NAMED_ENTITIES: Record<string, string> = {
45
+ '&nbsp;': ' ',
46
+ '&amp;': '&',
47
+ '&lt;': '<',
48
+ '&gt;': '>',
49
+ '&quot;': '"'
50
+ };
51
+
44
52
  function decodeHtmlEntities(text: string): string {
45
- return text
46
- .replace(/&nbsp;/gi, ' ')
47
- .replace(/&amp;/gi, '&')
48
- .replace(/&lt;/gi, '<')
49
- .replace(/&gt;/gi, '>')
50
- .replace(/&quot;/gi, '"')
51
- .replace(/&#39;/gi, "'")
52
- .replace(/&#x27;/gi, "'")
53
- .replace(/&#x2F;/gi, '/')
54
- .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
55
- .replace(/&#x([\da-f]+);/gi, (_, code) => String.fromCharCode(parseInt(code, 16)));
53
+ // Single pass so each entity is decoded exactly once. A multi-pass decode
54
+ // rescans its own output, which double-unescapes either &amp;lt; -> < or
55
+ // &#38;amp; -> & depending on the pass order. One pass avoids both.
56
+ return text.replace(
57
+ /&(?:#(\d+)|#x([\da-f]+)|nbsp|amp|lt|gt|quot);/gi,
58
+ (match, dec, hex) => {
59
+ if (dec !== undefined) return String.fromCharCode(Number(dec));
60
+ if (hex !== undefined) return String.fromCharCode(parseInt(hex, 16));
61
+ return NAMED_ENTITIES[match.toLowerCase()] ?? match;
62
+ }
63
+ );
56
64
  }
57
65
 
58
66
  function extractTitle(html: string): string | undefined {
@@ -1,12 +1,13 @@
1
1
  import type { SearchResult } from '../types.js';
2
- import { classifySourceProfile } from './source-profile.js';
2
+ import { classifySourceProfile, hostOf } from './source-profile.js';
3
3
 
4
4
  function wantsDiscussionSources(query = '') {
5
5
  return /reddit|forum|forums|discussion|thread|comments|community|user experience|people recommend/i.test(query);
6
6
  }
7
7
 
8
8
  function candidateScore(result: SearchResult, query?: string) {
9
- const url = result.url.toLowerCase();
9
+ const host = hostOf(result.url);
10
+ const isGithub = host === 'github.com' || (host?.endsWith('.github.com') ?? false);
10
11
  const profile = classifySourceProfile(result.url);
11
12
  const wantsThreads = wantsDiscussionSources(query);
12
13
 
@@ -16,13 +17,13 @@ function candidateScore(result: SearchResult, query?: string) {
16
17
  if (wantsThreads) {
17
18
  if (profile.kind === 'forum-thread') return 2;
18
19
  if (profile.kind === 'issue-thread') return 3;
19
- if (url.includes('github.com/')) return 4;
20
+ if (isGithub) return 4;
20
21
  if (profile.kind === 'package-page') return 6;
21
22
  return 5;
22
23
  }
23
24
 
24
25
  if (profile.kind === 'issue-thread') return 2;
25
- if (url.includes('github.com/')) return 3;
26
+ if (isGithub) return 3;
26
27
  if (profile.kind === 'package-page') return 5;
27
28
  return 4;
28
29
  }
@@ -1,13 +1,4 @@
1
- const TRACKING_PARAMS = new Set([
2
- 'utm_source',
3
- 'utm_medium',
4
- 'utm_campaign',
5
- 'utm_term',
6
- 'utm_content',
7
- 'utm_name',
8
- 'fbclid',
9
- 'gclid'
10
- ]);
1
+ import { canonicalizeUrl } from './url.js';
11
2
 
12
3
  function stripTrailingPunctuation(raw: string): string {
13
4
  let next = raw.trim();
@@ -22,21 +13,7 @@ function stripTrailingPunctuation(raw: string): string {
22
13
  }
23
14
 
24
15
  function normalizeDirectUrl(raw: string): string | undefined {
25
- try {
26
- const url = new URL(stripTrailingPunctuation(raw));
27
- if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined;
28
-
29
- for (const key of [...url.searchParams.keys()]) {
30
- if (TRACKING_PARAMS.has(key.toLowerCase())) {
31
- url.searchParams.delete(key);
32
- }
33
- }
34
-
35
- url.hash = '';
36
- return url.toString().replace(/\/$/, '');
37
- } catch {
38
- return undefined;
39
- }
16
+ return canonicalizeUrl(stripTrailingPunctuation(raw), { canonicalizeHost: false });
40
17
  }
41
18
 
42
19
  export function extractDirectUrls(query: string): string[] {
@@ -15,6 +15,7 @@ export type EvidenceQualityReport = {
15
15
  community: number;
16
16
  thread: number;
17
17
  packagePage: number;
18
+ primaryContent: number;
18
19
  distinctHosts: number;
19
20
  };
20
21
  flags: {
@@ -65,11 +66,12 @@ export function analyzeEvidenceQuality({
65
66
  const community = evidence.filter((item) => item.sourceKind === 'community').length;
66
67
  const thread = evidence.filter((item) => item.sourceKind === 'issue-thread' || item.sourceKind === 'official-discussion').length;
67
68
  const packagePage = evidence.filter((item) => item.sourceKind === 'package-page').length;
69
+ const primaryContent = evidence.filter((item) => item.sourceKind === 'primary-content').length;
68
70
  const distinctHosts = new Set(evidence.map((item) => hostname(item.url))).size;
69
71
 
70
72
  const hasOfficialEvidence = official > 0;
71
- const hasOnlyCommunityEvidence = evidence.length > 0 && official === 0;
72
- const hasLowDiversity = evidence.length > 1 && distinctHosts <= 1;
73
+ const hasOnlyCommunityEvidence = evidence.length > 0 && official === 0 && primaryContent === 0;
74
+ const hasLowDiversity = evidence.length > 1 && distinctHosts <= 1 && primaryContent === 0;
73
75
  const hasUnreadableDirectSource = gaps.some((gap) => /Direct URL could not be read reliably/i.test(gap.message));
74
76
  const hasUnreadableThreadSource = gaps.some((gap) => /Thread source could not be read reliably/i.test(gap.message));
75
77
  const hasPossibleConflict = hasConflictMarkers(evidence);
@@ -90,6 +92,7 @@ export function analyzeEvidenceQuality({
90
92
  community,
91
93
  thread,
92
94
  packagePage,
95
+ primaryContent,
93
96
  distinctHosts
94
97
  },
95
98
  flags: {
@@ -2,6 +2,8 @@ import type { ResearchEvidence } from './research-types.js';
2
2
 
3
3
  function sourceRank(sourceKind: ResearchEvidence['sourceKind']) {
4
4
  switch (sourceKind) {
5
+ case 'primary-content':
6
+ return -1;
5
7
  case 'official-docs':
6
8
  return 0;
7
9
  case 'official-api':