@oh-my-pi/pi-coding-agent 17.2.12 → 17.2.13

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 (134) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-d8xh7keh.md} +57 -0
  3. package/dist/cli.js +3069 -3047
  4. package/dist/types/advisor/delta-split.d.ts +24 -0
  5. package/dist/types/advisor/runtime.d.ts +2 -2
  6. package/dist/types/async/job-manager.d.ts +8 -1
  7. package/dist/types/cli/update-cli.d.ts +53 -1
  8. package/dist/types/config/keybindings.d.ts +10 -0
  9. package/dist/types/config/model-resolver.d.ts +15 -2
  10. package/dist/types/config/settings-schema.d.ts +4 -0
  11. package/dist/types/discovery/agents-md.d.ts +10 -1
  12. package/dist/types/eval/runner-cache.d.ts +12 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +12 -3
  14. package/dist/types/extensibility/extensions/types.d.ts +15 -4
  15. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +4 -1
  16. package/dist/types/lib/xai-http.d.ts +0 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +8 -5
  18. package/dist/types/modes/components/agent-hub-renderer.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/types.d.ts +4 -0
  20. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -4
  21. package/dist/types/modes/interactive-mode.d.ts +17 -8
  22. package/dist/types/modes/types.d.ts +7 -10
  23. package/dist/types/modes/utils/hotkeys-markdown.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +2 -0
  25. package/dist/types/session/agent-session.d.ts +22 -3
  26. package/dist/types/session/messages.d.ts +20 -0
  27. package/dist/types/session/retry-fallback-chains.d.ts +13 -0
  28. package/dist/types/session/session-advisors.d.ts +1 -1
  29. package/dist/types/session/session-history-format.d.ts +10 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -1
  31. package/dist/types/session/session-tools.d.ts +24 -5
  32. package/dist/types/session/turn-recovery.d.ts +34 -5
  33. package/dist/types/slash-commands/types.d.ts +5 -1
  34. package/dist/types/task/executor.d.ts +1 -1
  35. package/dist/types/tools/approval.d.ts +7 -0
  36. package/dist/types/tools/todo.d.ts +14 -15
  37. package/dist/types/tools/write.d.ts +2 -2
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/dist/types/vibe/runtime.d.ts +1 -1
  40. package/dist/types/web/parallel.d.ts +1 -0
  41. package/dist/types/web/search/providers/brave.d.ts +8 -3
  42. package/dist/types/web/search/providers/codex.d.ts +6 -0
  43. package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
  44. package/dist/types/web/search/providers/jina.d.ts +3 -3
  45. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  46. package/dist/types/web/search/providers/perplexity.d.ts +4 -0
  47. package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
  48. package/package.json +13 -13
  49. package/src/advisor/delta-split.ts +98 -0
  50. package/src/advisor/runtime.ts +321 -69
  51. package/src/async/job-manager.ts +14 -3
  52. package/src/cli/plugin-cli.ts +30 -2
  53. package/src/cli/update-cli.ts +259 -24
  54. package/src/config/keybindings.ts +52 -9
  55. package/src/config/model-resolver.ts +19 -3
  56. package/src/config/settings-schema.ts +5 -0
  57. package/src/cursor.ts +10 -5
  58. package/src/discovery/agents-md.ts +61 -23
  59. package/src/eval/jl/kernel.ts +2 -20
  60. package/src/eval/py/kernel.ts +2 -20
  61. package/src/eval/rb/kernel.ts +2 -20
  62. package/src/eval/runner-cache.ts +41 -0
  63. package/src/exec/non-interactive-env.ts +14 -3
  64. package/src/extensibility/extensions/loader.ts +5 -2
  65. package/src/extensibility/extensions/runner.ts +184 -66
  66. package/src/extensibility/extensions/types.ts +26 -2
  67. package/src/extensibility/extensions/wrapper.ts +13 -7
  68. package/src/extensibility/plugins/marketplace/manager.ts +6 -2
  69. package/src/hindsight/client.ts +1 -1
  70. package/src/lib/xai-http.ts +0 -4
  71. package/src/lsp/client.ts +2 -0
  72. package/src/lsp/servers.ts +1 -1
  73. package/src/mcp/tool-bridge.ts +15 -6
  74. package/src/modes/components/agent-hub-renderer.ts +9 -3
  75. package/src/modes/components/agent-hub.ts +2 -1
  76. package/src/modes/components/status-line/component.ts +58 -6
  77. package/src/modes/components/status-line/segments.ts +12 -1
  78. package/src/modes/components/status-line/types.ts +1 -0
  79. package/src/modes/components/user-message.ts +20 -5
  80. package/src/modes/controllers/event-controller.ts +48 -0
  81. package/src/modes/controllers/extension-ui-controller.ts +14 -7
  82. package/src/modes/controllers/input-controller.ts +25 -6
  83. package/src/modes/interactive-mode.ts +315 -129
  84. package/src/modes/rpc/rpc-frame.ts +13 -5
  85. package/src/modes/theme/tui-adapters.ts +4 -5
  86. package/src/modes/types.ts +13 -7
  87. package/src/modes/utils/hotkeys-markdown.ts +10 -6
  88. package/src/prompts/system/system-prompt.md +1 -1
  89. package/src/registry/persisted-agents.ts +43 -8
  90. package/src/sdk.ts +139 -8
  91. package/src/session/agent-session-types.ts +2 -0
  92. package/src/session/agent-session.ts +66 -10
  93. package/src/session/messages.ts +98 -28
  94. package/src/session/retry-fallback-chains.ts +14 -0
  95. package/src/session/session-advisors.ts +32 -15
  96. package/src/session/session-history-format.ts +15 -1
  97. package/src/session/session-maintenance.ts +8 -8
  98. package/src/session/session-manager.ts +6 -2
  99. package/src/session/session-tools.ts +321 -184
  100. package/src/session/turn-recovery.ts +225 -47
  101. package/src/slash-commands/builtin-modes.ts +41 -12
  102. package/src/slash-commands/types.ts +5 -1
  103. package/src/task/executor.ts +92 -45
  104. package/src/task/structured-subagent.ts +5 -5
  105. package/src/tools/approval.ts +44 -10
  106. package/src/tools/fetch.ts +21 -2
  107. package/src/tools/image-gen.ts +6 -8
  108. package/src/tools/todo.ts +70 -26
  109. package/src/tools/tts.ts +3 -2
  110. package/src/tools/write.ts +7 -3
  111. package/src/utils/local-date.ts +13 -0
  112. package/src/utils/tools-manager.ts +2 -2
  113. package/src/vibe/runtime.ts +22 -14
  114. package/src/web/kagi.ts +91 -34
  115. package/src/web/parallel.ts +11 -2
  116. package/src/web/scrapers/crates-io.ts +2 -2
  117. package/src/web/scrapers/discogs.ts +2 -2
  118. package/src/web/scrapers/docs-rs.ts +2 -2
  119. package/src/web/scrapers/github.ts +2 -2
  120. package/src/web/scrapers/musicbrainz.ts +1 -2
  121. package/src/web/scrapers/pubmed.ts +2 -2
  122. package/src/web/scrapers/sec-edgar.ts +2 -2
  123. package/src/web/search/providers/brave.ts +121 -46
  124. package/src/web/search/providers/codex.ts +88 -12
  125. package/src/web/search/providers/exa.ts +45 -10
  126. package/src/web/search/providers/firecrawl.ts +53 -11
  127. package/src/web/search/providers/gemini.ts +139 -27
  128. package/src/web/search/providers/jina.ts +48 -25
  129. package/src/web/search/providers/parallel.ts +23 -9
  130. package/src/web/search/providers/perplexity.ts +24 -7
  131. package/src/web/search/providers/searxng.ts +77 -1
  132. package/src/web/search/providers/tavily.ts +23 -22
  133. package/src/web/search/providers/tinyfish.ts +44 -10
  134. package/src/web/search/providers/xai.ts +85 -14
@@ -9,7 +9,7 @@ import type {
9
9
  AgentToolContext,
10
10
  AgentToolResult,
11
11
  AgentToolUpdateCallback,
12
- ToolTier,
12
+ ToolApprovalDecision,
13
13
  } from "@oh-my-pi/pi-agent-core";
14
14
  import { type Component, Text } from "@oh-my-pi/pi-tui";
15
15
  import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
@@ -500,7 +500,7 @@ function parseSqliteWriteTarget(subPath: string, queryString: string): { table:
500
500
  */
501
501
  export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails> {
502
502
  readonly name = "write";
503
- readonly approval = (args: unknown): ToolTier => {
503
+ readonly approval = (args: unknown): ToolApprovalDecision => {
504
504
  const rawPath = (args as Partial<WriteParams>).path;
505
505
  if (typeof rawPath !== "string") return "write";
506
506
  // Unwrap a hashline `[path#TAG]` wrapper first (parity with execute) so a
@@ -532,7 +532,11 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
532
532
  }
533
533
  if (!isRecord(parsed)) return "exec";
534
534
  try {
535
- return resolveToolTier(inst, parsed);
535
+ // The tier is the mounted tool's own (argument-dependent) approval; the
536
+ // policyKey makes the outer gate consult `tools.approval.<device>` for
537
+ // this dispatch before falling back to `tools.approval.write`, so users
538
+ // can scope allow/deny/prompt to a single device (issue #7923).
539
+ return { tier: resolveToolTier(inst, parsed), policyKey: xdevTarget.name! };
536
540
  } catch {
537
541
  return "exec";
538
542
  }
@@ -5,3 +5,16 @@ export function formatLocalCalendarDate(date: Date = new Date()): string {
5
5
  const day = String(date.getDate()).padStart(2, "0");
6
6
  return `${year}-${month}-${day}`;
7
7
  }
8
+
9
+ /** Format a local date and minute with a compact numeric UTC offset. */
10
+ export function formatLocalDateTimeWithOffset(date: Date): string {
11
+ const offsetMinutes = date.getTimezoneOffset();
12
+ const offsetSign = offsetMinutes <= 0 ? "+" : "-";
13
+ const absoluteOffset = Math.abs(offsetMinutes);
14
+ const offsetHours = Math.floor(absoluteOffset / 60);
15
+ const offsetRemainderMinutes = absoluteOffset % 60;
16
+ const pad2 = (value: number): string => String(value).padStart(2, "0");
17
+ return `${formatLocalCalendarDate(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())} ${offsetSign}${pad2(
18
+ offsetHours,
19
+ )}:${pad2(offsetRemainderMinutes)}`;
20
+ }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { $which, APP_NAME, getToolsDir, logger, ptree, TempDir } from "@oh-my-pi/pi-utils";
4
+ import { $which, getToolsDir, logger, ptree, TempDir, USER_AGENT } from "@oh-my-pi/pi-utils";
5
5
  import { extractArchive } from "./zip";
6
6
 
7
7
  const TOOLS_DIR = getToolsDir();
@@ -174,7 +174,7 @@ async function getLatestVersion(repo: string, signal?: AbortSignal): Promise<str
174
174
  let response: Response;
175
175
  try {
176
176
  response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
177
- headers: { "User-Agent": `${APP_NAME}-coding-agent` },
177
+ headers: { "User-Agent": USER_AGENT },
178
178
  signal: ptree.combineSignals(signal, TOOL_METADATA_TIMEOUT_MS),
179
179
  });
180
180
  } catch (err) {
@@ -18,7 +18,7 @@ import * as os from "node:os";
18
18
  import * as path from "node:path";
19
19
  import { logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
20
20
  import type { AsyncJob, AsyncJobManager } from "../async/job-manager";
21
- import { resolveAgentModelPatterns } from "../config/model-resolver";
21
+ import { resolveAgentModelSelection } from "../config/model-resolver";
22
22
  import type { LocalProtocolOptions } from "../internal-urls";
23
23
  import { registerArtifactsDir } from "../internal-urls/registry-helpers";
24
24
  import { MCPManager } from "../mcp/manager";
@@ -43,7 +43,7 @@ export type VibeCli = "fast" | "good";
43
43
  * CLI flavor → bundled agent type. This IS the model-tier mapping: `sonic`
44
44
  * carries `model: "@smol"` (the configured fast/low-latency role) and `task`
45
45
  * carries `model: "@task"` (inherits the session's strong model).
46
- * Resolution goes through {@link resolveAgentModelPatterns} exactly like a
46
+ * Resolution goes through {@link resolveAgentModelSelection} exactly like a
47
47
  * `task` spawn, so `task.agentModelOverrides` and model-role settings apply.
48
48
  */
49
49
  export const VIBE_CLI_AGENT: Record<VibeCli, string> = {
@@ -144,6 +144,8 @@ interface VibeRestoreCandidate {
144
144
  interface ResolvedVibeWorker {
145
145
  agent: AgentDefinition;
146
146
  modelOverride?: string | string[];
147
+ /** Pre-expansion role alias behind {@link modelOverride}, when the worker agent named one. */
148
+ modelRole?: string;
147
149
  }
148
150
 
149
151
  interface VibeTurn {
@@ -165,6 +167,8 @@ interface VibeRecord {
165
167
  childSessionFile?: string;
166
168
  agent: AgentDefinition;
167
169
  modelOverride?: string | string[];
170
+ /** Pre-expansion role alias behind {@link modelOverride}, when the worker agent named one. */
171
+ modelRole?: string;
168
172
  state: VibeSessionState;
169
173
  createdAt: number;
170
174
  lastActivityAt: number;
@@ -490,16 +494,17 @@ export class VibeSessionRegistry {
490
494
  throw new ToolError(`Bundled agent "${agentName}" for vibe cli "${cli}" is unavailable.`);
491
495
  }
492
496
  const agentModelOverrides = session.settings.get("task.agentModelOverrides");
493
- return {
494
- agent,
495
- modelOverride: resolveAgentModelPatterns({
496
- settingsOverride: agentModelOverrides[agentName],
497
- agentModel: agent.model,
498
- settings: session.settings,
499
- activeModelPattern: session.getActiveModelString?.(),
500
- fallbackModelPattern: session.getModelString?.(),
501
- }),
502
- };
497
+ // Same contract as the task spawn path: the expansion discards the role
498
+ // alias (`@task`, `@smol`), so patterns and role identity come from one
499
+ // call — the child's inherited retry-fallback chain is keyed off the role.
500
+ const { patterns, role } = resolveAgentModelSelection({
501
+ settingsOverride: agentModelOverrides[agentName],
502
+ agentModel: agent.model,
503
+ settings: session.settings,
504
+ activeModelPattern: session.getActiveModelString?.(),
505
+ fallbackModelPattern: session.getModelString?.(),
506
+ });
507
+ return { agent, modelOverride: patterns, modelRole: role };
503
508
  }
504
509
 
505
510
  async #appendLifecycleEvent(
@@ -890,7 +895,7 @@ export class VibeSessionRegistry {
890
895
  existing.sessionFile === childSessionFile &&
891
896
  (existing.status === "idle" || existing.status === "parked");
892
897
  const blockedByCollision = Boolean(existing && !existingIsResumable);
893
- const { agent, modelOverride } = this.#resolveWorker(session, spawn.cli);
898
+ const { agent, modelOverride, modelRole } = this.#resolveWorker(session, spawn.cli);
894
899
  if (!existing) {
895
900
  AgentRegistry.global().register({
896
901
  id: spawn.id,
@@ -911,6 +916,7 @@ export class VibeSessionRegistry {
911
916
  childSessionFile,
912
917
  agent,
913
918
  modelOverride,
919
+ modelRole,
914
920
  state: "idle",
915
921
  createdAt: spawn.createdAt,
916
922
  lastActivityAt: candidate.lastActivityAt,
@@ -945,7 +951,7 @@ export class VibeSessionRegistry {
945
951
  throw new ToolError("Vibe mode has exited; enter Vibe mode again before spawning a worker.");
946
952
  }
947
953
  const manager = this.#manager(session);
948
- const { agent, modelOverride } = this.#resolveWorker(session, args.cli);
954
+ const { agent, modelOverride, modelRole } = this.#resolveWorker(session, args.cli);
949
955
  if (!session.agentOutputManager) {
950
956
  session.agentOutputManager = new AgentOutputManager(session.getArtifactsDir ?? (() => null));
951
957
  }
@@ -969,6 +975,7 @@ export class VibeSessionRegistry {
969
975
  childSessionFile,
970
976
  agent,
971
977
  modelOverride,
978
+ modelRole,
972
979
  state: "starting",
973
980
  createdAt,
974
981
  lastActivityAt: createdAt,
@@ -1418,6 +1425,7 @@ export class VibeSessionRegistry {
1418
1425
  taskDepth: session.taskDepth ?? 0,
1419
1426
  detached: true,
1420
1427
  modelOverride: record.modelOverride,
1428
+ modelRole: record.modelRole,
1421
1429
  parentActiveModelPattern: session.getActiveModelString?.(),
1422
1430
  thinkingLevel: record.agent.thinkingLevel,
1423
1431
  sessionFile,
package/src/web/kagi.ts CHANGED
@@ -98,7 +98,7 @@ export class KagiApiError extends Error {
98
98
  }
99
99
 
100
100
  function extractKagiErrorMessage(payload: unknown): string | null {
101
- if (!payload || typeof payload !== "object") return null;
101
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
102
102
  const record = payload as Record<string, unknown>;
103
103
 
104
104
  for (const value of [record.message, record.detail]) {
@@ -107,17 +107,20 @@ function extractKagiErrorMessage(payload: unknown): string | null {
107
107
  }
108
108
  }
109
109
 
110
- if (typeof record.error === "string" && record.error.trim().length > 0) {
111
- return record.error.trim();
112
- }
113
-
114
- if (Array.isArray(record.error)) {
115
- for (const entry of record.error) {
110
+ for (const errors of [record.error, record.errors]) {
111
+ if (typeof errors === "string" && errors.trim().length > 0) {
112
+ return errors.trim();
113
+ }
114
+ if (!Array.isArray(errors)) continue;
115
+ for (const entry of errors) {
116
116
  if (!entry || typeof entry !== "object") continue;
117
117
  const e = entry as Record<string, unknown>;
118
- for (const value of [e.message, e.msg]) {
119
- if (typeof value === "string" && value.trim().length > 0) {
120
- return value.trim();
118
+ for (const value of [e.message, e.msg, e.code]) {
119
+ if (
120
+ (typeof value === "string" && value.trim().length > 0) ||
121
+ (typeof value === "number" && Number.isFinite(value))
122
+ ) {
123
+ return String(value).trim();
121
124
  }
122
125
  }
123
126
  }
@@ -147,6 +150,34 @@ function parseKagiErrorResponse(statusCode: number, responseText: string): KagiA
147
150
  }
148
151
  }
149
152
 
153
+ function parseKagiSuccessResponse(statusCode: number, responseText: string): KagiSearchResponse {
154
+ let payload: unknown;
155
+ try {
156
+ payload = JSON.parse(responseText);
157
+ } catch {
158
+ throw new KagiApiError("Kagi API returned an invalid response: invalid JSON", statusCode);
159
+ }
160
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
161
+ throw new KagiApiError("Kagi API returned an invalid response: expected an object envelope", statusCode);
162
+ }
163
+
164
+ const record = payload as Record<string, unknown>;
165
+ const errorMessage = extractKagiErrorMessage(payload);
166
+ if (errorMessage && (record.error !== undefined || record.errors !== undefined)) {
167
+ const errors = Array.isArray(record.error) ? record.error : Array.isArray(record.errors) ? record.errors : [];
168
+ const first = errors[0];
169
+ const code =
170
+ first && typeof first === "object" && typeof (first as Record<string, unknown>).code === "number"
171
+ ? ((first as Record<string, unknown>).code as number)
172
+ : statusCode;
173
+ throw createKagiApiError(code, errorMessage);
174
+ }
175
+ if (record.data !== undefined && (!record.data || typeof record.data !== "object" || Array.isArray(record.data))) {
176
+ throw new KagiApiError("Kagi API returned an invalid response: expected data to be an object", statusCode);
177
+ }
178
+ return payload as KagiSearchResponse;
179
+ }
180
+
150
181
  // ---------------------------------------------------------------------------
151
182
  // Public API
152
183
  // ---------------------------------------------------------------------------
@@ -216,23 +247,40 @@ function buildRequestBody(query: string, options: KagiSearchOptions): KagiSearch
216
247
  return req;
217
248
  }
218
249
 
219
- /** Push every item in a result bucket as a source, with an optional title tag. */
220
- function collectSources(sources: KagiSearchSource[], items: KagiSearchResultItem[] | undefined, tag?: string): void {
221
- if (!items) return;
222
- for (const item of items) {
250
+ function firstNonEmptyString(...values: unknown[]): string | undefined {
251
+ for (const value of values) {
252
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
253
+ }
254
+ return undefined;
255
+ }
256
+
257
+ /** Push every valid item in a result bucket as a source, with an optional title tag. */
258
+ function collectSources(sources: KagiSearchSource[], items: unknown, tag?: string): void {
259
+ if (!Array.isArray(items)) return;
260
+ for (const value of items) {
261
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
262
+ const item = value as Record<string, unknown>;
263
+ const url = firstNonEmptyString(item.url, item.href, item.link);
264
+ if (!url) continue;
265
+ const title = firstNonEmptyString(item.title, item.name) ?? url;
223
266
  sources.push({
224
- title: tag ? `${tag} ${item.title}` : item.title,
225
- url: item.url,
226
- snippet: item.snippet,
227
- publishedDate: item.time,
267
+ title: tag ? `${tag} ${title}` : title,
268
+ url,
269
+ snippet: firstNonEmptyString(item.snippet, item.description, item.summary),
270
+ publishedDate: firstNonEmptyString(item.time),
228
271
  });
229
272
  }
230
273
  }
231
274
 
232
275
  /** Pull a related/adjacent question from an item's props or fall back to title. */
233
- function questionOf(item: KagiSearchResultItem): string | undefined {
234
- const q = item.props?.question ?? item.props?.query ?? item.title;
235
- return typeof q === "string" && q.length > 0 ? q : undefined;
276
+ function questionOf(value: unknown): string | undefined {
277
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
278
+ const item = value as Record<string, unknown>;
279
+ const props =
280
+ item.props && typeof item.props === "object" && !Array.isArray(item.props)
281
+ ? (item.props as Record<string, unknown>)
282
+ : undefined;
283
+ return firstNonEmptyString(props?.question, props?.query, item.title);
236
284
  }
237
285
 
238
286
  export async function searchWithKagi(
@@ -269,11 +317,7 @@ export async function searchWithKagi(
269
317
  },
270
318
  );
271
319
 
272
- const payload = (await response.json()) as KagiSearchResponse;
273
- if (payload.error && payload.error.length > 0) {
274
- const first = payload.error[0];
275
- throw createKagiApiError(first.code ?? response.status, extractKagiErrorMessage(payload) ?? first.message);
276
- }
320
+ const payload = parseKagiSuccessResponse(response.status, await response.text());
277
321
 
278
322
  const data = payload.data;
279
323
  const sources: KagiSearchSource[] = [];
@@ -284,17 +328,30 @@ export async function searchWithKagi(
284
328
  collectSources(sources, data?.news, "[News]");
285
329
  collectSources(sources, data?.infobox, "[Info]");
286
330
 
287
- for (const item of data?.adjacent_question ?? []) {
288
- const q = questionOf(item);
289
- if (q) relatedQuestions.push(q);
331
+ const adjacentQuestions: unknown = data?.adjacent_question;
332
+ if (Array.isArray(adjacentQuestions)) {
333
+ for (const item of adjacentQuestions) {
334
+ const question = questionOf(item);
335
+ if (question) relatedQuestions.push(question);
336
+ }
290
337
  }
291
- for (const item of data?.related_search ?? []) {
292
- const q = questionOf(item);
293
- if (q) relatedQuestions.push(q);
338
+ const relatedSearches: unknown = data?.related_search;
339
+ if (Array.isArray(relatedSearches)) {
340
+ for (const item of relatedSearches) {
341
+ const question = questionOf(item);
342
+ if (question) relatedQuestions.push(question);
343
+ }
294
344
  }
295
345
 
296
- const directAnswer = data?.direct_answer?.[0];
297
- const answer = directAnswer ? (directAnswer.snippet ?? directAnswer.title) : undefined;
346
+ const directAnswers: unknown = data?.direct_answer;
347
+ const directAnswer = Array.isArray(directAnswers) ? directAnswers[0] : undefined;
348
+ const answer =
349
+ directAnswer && typeof directAnswer === "object" && !Array.isArray(directAnswer)
350
+ ? firstNonEmptyString(
351
+ (directAnswer as Record<string, unknown>).snippet,
352
+ (directAnswer as Record<string, unknown>).title,
353
+ )
354
+ : undefined;
298
355
 
299
356
  return {
300
357
  requestId: payload.meta?.trace ?? payload.meta?.id ?? "",
@@ -146,6 +146,15 @@ export function parseParallelErrorResponse(statusCode: number, responseText: str
146
146
  }
147
147
  }
148
148
 
149
+ export async function parseParallelJsonResponse(response: Response, operation: "search" | "extract"): Promise<unknown> {
150
+ try {
151
+ return await response.json();
152
+ } catch (err) {
153
+ const detail = err instanceof Error ? err.message : String(err);
154
+ throw new ParallelApiError(`Parallel ${operation} returned invalid JSON: ${detail}`);
155
+ }
156
+ }
157
+
149
158
  function getAuthHeaders(apiKey: string): {
150
159
  Accept: string;
151
160
  "Content-Type": string;
@@ -316,7 +325,7 @@ export async function searchWithParallel(
316
325
  throw parseParallelErrorResponse(response.status, await response.text());
317
326
  }
318
327
 
319
- const payload: unknown = await response.json();
328
+ const payload = await parseParallelJsonResponse(response, "search");
320
329
  return parseParallelSearchPayload(payload);
321
330
  }
322
331
 
@@ -349,6 +358,6 @@ export async function extractWithParallel(
349
358
  throw parseParallelErrorResponse(response.status, await response.text());
350
359
  }
351
360
 
352
- const payload: unknown = await response.json();
361
+ const payload = await parseParallelJsonResponse(response, "extract");
353
362
  return parseExtractPayload(payload);
354
363
  }
@@ -1,4 +1,4 @@
1
- import { tryParseJson } from "@oh-my-pi/pi-utils";
1
+ import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
3
  import { buildResult, formatNumber, loadPage, looksLikeHtml } from "./types";
4
4
 
@@ -26,7 +26,7 @@ export const handleCratesIo: SpecialHandler = async (
26
26
  const result = await loadPage(apiUrl, {
27
27
  timeout,
28
28
  signal,
29
- headers: { "User-Agent": "omp-web-fetch/1.0 (https://github.com/anthropics)" },
29
+ headers: { "User-Agent": USER_AGENT },
30
30
  });
31
31
 
32
32
  if (!result.ok) return null;
@@ -5,7 +5,7 @@
5
5
  * API docs: https://www.discogs.com/developers
6
6
  */
7
7
 
8
- import { tryParseJson } from "@oh-my-pi/pi-utils";
8
+ import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
9
9
  import type { RenderResult, SpecialHandler } from "./types";
10
10
  import { buildResult, loadPage } from "./types";
11
11
 
@@ -277,7 +277,7 @@ export const handleDiscogs: SpecialHandler = async (
277
277
  signal,
278
278
  headers: {
279
279
  Accept: "application/json",
280
- "User-Agent": "CodingAgent/1.0 +https://github.com/can1357/oh-my-pi",
280
+ "User-Agent": USER_AGENT,
281
281
  },
282
282
  });
283
283
 
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { gunzipSync } from "node:zlib";
4
- import { getDocsRsCacheDir, isEnoent, logger, ptree, tryParseJson } from "@oh-my-pi/pi-utils";
4
+ import { getDocsRsCacheDir, isEnoent, logger, ptree, tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
5
5
  import { ToolAbortError } from "../../tools/tool-errors";
6
6
  import type { RenderResult, SpecialHandler } from "./types";
7
7
  import { buildResult, MAX_BYTES } from "./types";
@@ -388,7 +388,7 @@ export const handleDocsRs: SpecialHandler = async (
388
388
  const requestSignal = ptree.combineSignals(signal, timeout * 1000);
389
389
  const response = await fetch(jsonUrl, {
390
390
  signal: requestSignal,
391
- headers: { "User-Agent": "omp-web-fetch/1.0", Accept: "application/gzip" },
391
+ headers: { "User-Agent": USER_AGENT, Accept: "application/gzip" },
392
392
  redirect: "follow",
393
393
  });
394
394
  if (!response.ok) return null;
@@ -1,4 +1,4 @@
1
- import { $env, ptree } from "@oh-my-pi/pi-utils";
1
+ import { $env, ptree, USER_AGENT } from "@oh-my-pi/pi-utils";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
3
  import { buildResult, formatMediaDuration, loadPage } from "./types";
4
4
 
@@ -121,7 +121,7 @@ export async function fetchGitHubApi(
121
121
 
122
122
  const headers: Record<string, string> = {
123
123
  Accept: "application/vnd.github.v3+json",
124
- "User-Agent": "omp-web-fetch/1.0",
124
+ "User-Agent": USER_AGENT,
125
125
  };
126
126
 
127
127
  // Use GITHUB_TOKEN if available
@@ -2,7 +2,7 @@
2
2
  * MusicBrainz URL handler for artists, releases, and recordings
3
3
  */
4
4
 
5
- import { tryParseJson } from "@oh-my-pi/pi-utils";
5
+ import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
6
6
  import type { RenderResult, SpecialHandler } from "./types";
7
7
  import { buildResult, formatMediaDuration, loadPage } from "./types";
8
8
 
@@ -64,7 +64,6 @@ interface MusicBrainzRelease {
64
64
  }
65
65
 
66
66
  const MUSICBRAINZ_HOSTS = new Set(["musicbrainz.org", "www.musicbrainz.org"]);
67
- const USER_AGENT = "omp-web-fetch/1.0 (https://github.com/anthropics)";
68
67
  const MAX_TRACKS = 50;
69
68
 
70
69
  function parseEntity(url: URL): { entity: MusicBrainzEntity; mbid: string } | null {
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * PubMed handler for web-fetch
3
3
  */
4
- import { tryParseJson } from "@oh-my-pi/pi-utils";
4
+ import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
5
5
  import { buildResult, loadPage, type RenderResult, type SpecialHandler } from "./types";
6
6
 
7
7
  const NCBI_HEADERS = {
8
8
  Accept: "application/json, text/plain;q=0.9, */*;q=0.8",
9
- "User-Agent": "CodingAgent/1.0 (web scraper)",
9
+ "User-Agent": USER_AGENT,
10
10
  };
11
11
 
12
12
  /**
@@ -1,4 +1,4 @@
1
- import { tryParseJson } from "@oh-my-pi/pi-utils";
1
+ import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
3
  import { buildResult, loadPage } from "./types";
4
4
 
@@ -178,7 +178,7 @@ export const handleSecEdgar: SpecialHandler = async (
178
178
  timeout,
179
179
  signal,
180
180
  headers: {
181
- "User-Agent": "CodingAgent/1.0 (research tool)",
181
+ "User-Agent": USER_AGENT,
182
182
  Accept: "application/json",
183
183
  },
184
184
  });