@oh-my-pi/pi-coding-agent 16.2.1 → 16.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/cli.js +2648 -2662
  3. package/dist/types/advisor/runtime.d.ts +15 -1
  4. package/dist/types/config/model-roles.d.ts +1 -1
  5. package/dist/types/config/settings-schema.d.ts +32 -12
  6. package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
  7. package/dist/types/edit/index.d.ts +18 -0
  8. package/dist/types/edit/streaming.d.ts +30 -0
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  10. package/dist/types/extensibility/shared-events.d.ts +1 -0
  11. package/dist/types/mcp/oauth-discovery.d.ts +0 -11
  12. package/dist/types/modes/components/status-line/component.d.ts +0 -2
  13. package/dist/types/sdk.d.ts +1 -1
  14. package/dist/types/session/agent-session.d.ts +26 -2
  15. package/dist/types/session/messages.d.ts +6 -7
  16. package/dist/types/session/settings-stream-fn.d.ts +21 -0
  17. package/dist/types/session/turn-persistence.d.ts +88 -0
  18. package/package.json +12 -12
  19. package/src/advisor/__tests__/advisor.test.ts +196 -0
  20. package/src/advisor/runtime.ts +65 -2
  21. package/src/auto-thinking/classifier.ts +2 -2
  22. package/src/config/model-resolver.ts +5 -1
  23. package/src/config/model-roles.ts +3 -3
  24. package/src/config/settings-schema.ts +30 -8
  25. package/src/discovery/omp-extension-roots.ts +38 -13
  26. package/src/edit/index.ts +21 -0
  27. package/src/edit/streaming.ts +170 -0
  28. package/src/extensibility/custom-tools/types.ts +1 -0
  29. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
  30. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
  31. package/src/extensibility/plugins/manager.ts +74 -4
  32. package/src/extensibility/shared-events.ts +1 -0
  33. package/src/internal-urls/docs-index.generated.txt +1 -1
  34. package/src/mcp/oauth-discovery.ts +5 -29
  35. package/src/mcp/transports/http.ts +3 -1
  36. package/src/mnemopi/backend.ts +2 -2
  37. package/src/modes/acp/acp-agent.ts +1 -1
  38. package/src/modes/components/assistant-message.ts +5 -5
  39. package/src/modes/components/status-line/component.ts +1 -9
  40. package/src/modes/components/status-line/segments.ts +1 -1
  41. package/src/modes/controllers/event-controller.ts +8 -11
  42. package/src/modes/interactive-mode.ts +0 -5
  43. package/src/modes/print-mode.ts +1 -1
  44. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  45. package/src/modes/utils/ui-helpers.ts +5 -4
  46. package/src/sdk.ts +12 -26
  47. package/src/session/agent-session.ts +319 -219
  48. package/src/session/messages.ts +20 -12
  49. package/src/session/session-persistence.ts +1 -2
  50. package/src/session/settings-stream-fn.ts +49 -0
  51. package/src/session/turn-persistence.ts +142 -0
  52. package/src/session/unexpected-stop-classifier.ts +2 -2
  53. package/src/slash-commands/helpers/mcp.ts +2 -1
  54. package/src/tiny/models.ts +8 -6
  55. package/src/tiny/text.ts +14 -7
  56. package/src/tools/image-gen.ts +2 -1
  57. package/src/tools/tts.ts +2 -1
  58. package/src/utils/title-generator.ts +1 -1
  59. package/src/web/search/providers/tavily.ts +36 -19
@@ -18,6 +18,7 @@ import type {
18
18
  TextContent,
19
19
  UserMessage,
20
20
  } from "@oh-my-pi/pi-ai";
21
+ import * as AIError from "@oh-my-pi/pi-ai/error";
21
22
  import { prompt } from "@oh-my-pi/pi-utils";
22
23
  import userInterjectionTemplate from "../prompts/steering/user-interjection.md" with { type: "text" };
23
24
 
@@ -70,11 +71,10 @@ export interface SkillPromptDetails {
70
71
  * (fallback error emission) read it via `isSilentAbort`. */
71
72
  export const SILENT_ABORT_MARKER = "__omp.silent_abort__";
72
73
 
73
- /** Type-guard for `SILENT_ABORT_MARKER`. Renderers MUST branch on this rather
74
- * than string-comparing inline so refactors to the marker constant (e.g.,
75
- * namespacing changes) propagate through every consumer in lockstep. */
76
- export function isSilentAbort(errorMessage: string | undefined): boolean {
77
- return errorMessage === SILENT_ABORT_MARKER;
74
+ /** Type-guard for silent aborts. Renderers MUST call this helper so structured
75
+ * `errorId` and legacy persisted marker messages stay in lockstep. */
76
+ export function isSilentAbort(message: Pick<AssistantMessage, "errorId" | "errorMessage">): boolean {
77
+ return AIError.is(message.errorId, AIError.Flag.SilentAbort) || message.errorMessage === SILENT_ABORT_MARKER;
78
78
  }
79
79
 
80
80
  /** Reason threaded through `AbortController.abort(reason)` when the user aborts
@@ -84,12 +84,12 @@ export function isSilentAbort(errorMessage: string | undefined): boolean {
84
84
  * abort, but interactive renderers suppress this redundant transcript line. */
85
85
  export const USER_INTERRUPT_LABEL = "Interrupted by user";
86
86
 
87
- export function isUserInterruptAbort(errorMessage: string | undefined): boolean {
88
- return errorMessage === USER_INTERRUPT_LABEL;
87
+ export function isUserInterruptAbort(message: Pick<AssistantMessage, "errorId" | "errorMessage">): boolean {
88
+ return AIError.is(message.errorId, AIError.Flag.UserInterrupt) || message.errorMessage === USER_INTERRUPT_LABEL;
89
89
  }
90
90
 
91
- export function shouldRenderAbortReason(errorMessage: string | undefined): boolean {
92
- return !isSilentAbort(errorMessage) && !isUserInterruptAbort(errorMessage);
91
+ export function shouldRenderAbortReason(message: Pick<AssistantMessage, "errorId" | "errorMessage">): boolean {
92
+ return !isSilentAbort(message) && !isUserInterruptAbort(message);
93
93
  }
94
94
 
95
95
  /** Sentinel `errorMessage` the agent stamps on any abort that carried no custom
@@ -101,9 +101,17 @@ export const GENERIC_ABORT_SENTINEL = "Request was aborted";
101
101
  * no threaded reason fall back to the retry-aware generic label. Call
102
102
  * `shouldRenderAbortReason` before rendering when user interrupts should stay
103
103
  * visually quiet. */
104
- export function resolveAbortLabel(errorMessage: string | undefined, retryAttempt = 0): string {
105
- if (errorMessage && errorMessage !== GENERIC_ABORT_SENTINEL && !isSilentAbort(errorMessage)) {
106
- return errorMessage;
104
+ export function resolveAbortLabel(
105
+ message: Pick<AssistantMessage, "errorId" | "errorMessage">,
106
+ retryAttempt = 0,
107
+ ): string {
108
+ const genericAbort =
109
+ AIError.is(message.errorId, AIError.Flag.Abort) ||
110
+ !message.errorMessage ||
111
+ message.errorMessage === GENERIC_ABORT_SENTINEL ||
112
+ isSilentAbort(message);
113
+ if (!genericAbort) {
114
+ return message.errorMessage!;
107
115
  }
108
116
  if (retryAttempt > 0) {
109
117
  return `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`;
@@ -110,9 +110,8 @@ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string
110
110
  const entries: Array<readonly [string, unknown]> = [];
111
111
  for (const [childKey, value] of Object.entries(obj)) {
112
112
  // Strip transient/redundant properties that shouldn't be persisted.
113
- // - partialJson: streaming accumulator for tool call JSON parsing
114
113
  // - jsonlEvents: raw subprocess streaming events (already saved to artifact files)
115
- if (childKey === "partialJson" || childKey === "jsonlEvents") {
114
+ if (childKey === "jsonlEvents") {
116
115
  changed = true;
117
116
  continue;
118
117
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Settings-aware stream wrapper shared by the main agent (sdk.ts) and the
3
+ * advisor agent (AgentSession.#buildAdvisorRuntime).
4
+ *
5
+ * Reads OpenRouter / Antigravity routing variants, Responses-family text
6
+ * verbosity, per-provider in-flight caps, and the loop guard out of `Settings`
7
+ * per request, layering them onto whatever options the caller passed. Before
8
+ * this helper existed, advisor turns called bare `streamSimple` while the main
9
+ * turn went through an inline closure that read these settings — so an advisor on
10
+ * OpenRouter never saw `providers.openrouterVariant`, breaking sticky routing
11
+ * and OpenRouter response-cache hits across advisor calls.
12
+ */
13
+ import type { StreamFn } from "@oh-my-pi/pi-agent-core";
14
+ import { type SimpleStreamOptions, streamSimple } from "@oh-my-pi/pi-ai";
15
+ import { type Settings, validateProviderMaxInFlightRequests } from "../config/settings";
16
+
17
+ /**
18
+ * Build a {@link StreamFn} that reads provider routing/guard settings from
19
+ * `settings` per call and forwards to `base` (defaults to `streamSimple`).
20
+ *
21
+ * Caller-supplied `streamOptions` always win — the helper only fills holes.
22
+ */
23
+ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn = streamSimple): StreamFn {
24
+ return (model, context, streamOptions) => {
25
+ const openrouterRoutingPreset = settings.get("providers.openrouterVariant");
26
+ const openrouterVariant =
27
+ openrouterRoutingPreset && openrouterRoutingPreset !== "default" ? openrouterRoutingPreset : undefined;
28
+ const antigravityEndpointMode = settings.get("providers.antigravityEndpoint");
29
+ const textVerbosity =
30
+ model.api === "openai-codex-responses" || model.api === "openai-responses"
31
+ ? settings.get("textVerbosity")
32
+ : undefined;
33
+ const merged: SimpleStreamOptions = {
34
+ ...streamOptions,
35
+ openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
36
+ antigravityEndpointMode: streamOptions?.antigravityEndpointMode ?? antigravityEndpointMode,
37
+ textVerbosity: streamOptions?.textVerbosity ?? textVerbosity,
38
+ maxInFlightRequests: validateProviderMaxInFlightRequests(
39
+ streamOptions?.maxInFlightRequests ?? settings.get("providers.maxInFlightRequests"),
40
+ ),
41
+ loopGuard: {
42
+ enabled: settings.get("model.loopGuard.enabled"),
43
+ checkAssistantContent: settings.get("model.loopGuard.checkAssistantContent"),
44
+ ...streamOptions?.loopGuard,
45
+ },
46
+ };
47
+ return base(model, context, merged);
48
+ };
49
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Helpers that share one cheap, structural identity for messages — both during
3
+ * incremental persistence and for the mid-run-compaction ordering check.
4
+ *
5
+ * Previously `AgentSession` carried two near-duplicate routines
6
+ * (`#sessionMessagesReferToSameTurn` + `#messageValueSignature`) that
7
+ * reconstructed the branch path on every check (O(n²) `unshift`) and
8
+ * `JSON.stringify`-compared the full message content on every pairwise hit.
9
+ * Long-running sessions with many subagents fired this thousands of times per
10
+ * minute and froze the TUI loop (see issue #3629). The persistence key already
11
+ * encodes a stable logical identity — timestamp + role-specific discriminators
12
+ * — so the structural compare is now the rare collision tiebreaker (e.g. two
13
+ * provider responses at the same millisecond with `undefined` responseId),
14
+ * not the load-bearing check.
15
+ *
16
+ * The helpers here keep that identity in one place and expose the planner so
17
+ * the persistence-ordering logic is unit-testable without standing up an
18
+ * `AgentSession`.
19
+ */
20
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
21
+
22
+ /**
23
+ * Stable identity for messages that pass through {@link AgentSession}'s
24
+ * incremental persistence path.
25
+ *
26
+ * The discriminators chosen per role are precisely the fields that uniquely
27
+ * identify a single logical message instance:
28
+ *
29
+ * - `assistant` — timestamp + provider + model + responseId + stopReason
30
+ * (responseId is the canonical provider-side id when available; the rest
31
+ * disambiguate when it is not, e.g. local/dev models).
32
+ * - `toolResult` — timestamp + toolCallId + toolName (toolCallId is unique
33
+ * per execution; toolName guards against synthetic reuse).
34
+ * - `user` / `developer` — timestamp + attribution (attribution distinguishes
35
+ * user-typed vs hook-injected at the same wall-clock millisecond).
36
+ * - `fileMention` — timestamp.
37
+ *
38
+ * Returns `undefined` for message roles that are not persisted through this
39
+ * path (e.g. `hookMessage`, `custom`, `bashExecution`) — those follow other
40
+ * append paths in `SessionManager`.
41
+ */
42
+ export function sessionMessagePersistenceKey(message: AgentMessage): string | undefined {
43
+ switch (message.role) {
44
+ case "assistant":
45
+ return [
46
+ "assistant",
47
+ message.timestamp,
48
+ message.provider,
49
+ message.model,
50
+ message.responseId ?? "",
51
+ message.stopReason,
52
+ ].join(":");
53
+ case "toolResult":
54
+ return `toolResult:${message.timestamp}:${message.toolCallId}:${message.toolName}`;
55
+ case "user":
56
+ case "developer":
57
+ return `${message.role}:${message.timestamp}:${message.attribution ?? ""}`;
58
+ case "fileMention":
59
+ return `fileMention:${message.timestamp}`;
60
+ default:
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Slow-path content equality check used when two messages collide on
67
+ * {@link sessionMessagePersistenceKey}. Only the role's content fields are
68
+ * compared (no timestamps, no metadata) because the key already pinned all of
69
+ * those down.
70
+ *
71
+ * Most calls into the persistence path never reach this — keys are unique
72
+ * enough in production that the snapshot lookup short-circuits at the key
73
+ * level. Restoring the structural compare here preserves the pre-#3629
74
+ * contract that two messages with the same metadata BUT different content are
75
+ * distinct (e.g. two assistant turns with `undefined` responseId emitted in
76
+ * the same wall-clock millisecond, which is exactly how the in-memory test
77
+ * harness crafts streamed responses).
78
+ */
79
+ export function sameMessageContent(left: AgentMessage, right: AgentMessage): boolean {
80
+ if (left === right) return true;
81
+ if (left.role !== right.role) return false;
82
+ // `JSON.stringify` is the slow-path serializer here on purpose: nothing on
83
+ // the hot persistence-check path reaches it (key lookup short-circuits
84
+ // first), so a stable lexicographic compare beats hand-rolling structural
85
+ // equality for content arrays that mix text / tool blocks / file refs.
86
+ const leftRaw = left.role === "fileMention" ? left.files : "content" in left ? left.content : undefined;
87
+ const rightRaw = right.role === "fileMention" ? right.files : "content" in right ? right.content : undefined;
88
+ if (leftRaw === undefined || rightRaw === undefined) return false;
89
+ return (JSON.stringify(leftRaw) ?? "undefined") === (JSON.stringify(rightRaw) ?? "undefined");
90
+ }
91
+
92
+ /**
93
+ * Outcome of {@link planTurnPersistence}.
94
+ *
95
+ * `ok` lists the turn-message indices that still need to be appended (in
96
+ * order). `out-of-order` reports the first message whose later sibling is
97
+ * already persisted — the caller bails so it does not silently splice a
98
+ * stale message between newer entries on the live branch.
99
+ */
100
+ export type TurnPersistencePlan =
101
+ | { kind: "ok"; toPersist: readonly number[] }
102
+ | { kind: "out-of-order"; messageIndex: number };
103
+
104
+ /**
105
+ * Decide what to do with a turn's messages relative to what's already on the
106
+ * branch, in a single pass over the pre-computed keys.
107
+ *
108
+ * @param turnKeys persistence keys for each turn message, in the order the
109
+ * agent loop emitted them. `undefined` slots represent messages with no
110
+ * persistence key (skipped silently).
111
+ * @param persistedKeys the snapshot of persistence keys currently on the
112
+ * branch (built once per call from {@link sessionMessagePersistenceKey} for
113
+ * each persisted message entry).
114
+ *
115
+ * The check is O(n²) over turn messages — but `n` here is the size of one
116
+ * turn (a handful of tool results), not the size of the branch. That's the
117
+ * point of this refactor: the expensive O(branch) work happens exactly once,
118
+ * inside the caller's snapshot loop, not per-comparison.
119
+ */
120
+ export function planTurnPersistence(
121
+ turnKeys: readonly (string | undefined)[],
122
+ persistedKeys: ReadonlySet<string>,
123
+ ): TurnPersistencePlan {
124
+ const toPersist: number[] = [];
125
+ for (let index = 0; index < turnKeys.length; index++) {
126
+ const key = turnKeys[index];
127
+ // Slots without a persistence key (non-persistent roles like `custom` /
128
+ // `hookMessage`) take other branches in `SessionManager` — they are not
129
+ // our responsibility to append, and they cannot violate ordering because
130
+ // they have no identity on the branch.
131
+ if (key === undefined) continue;
132
+ if (persistedKeys.has(key)) continue;
133
+ for (let later = index + 1; later < turnKeys.length; later++) {
134
+ const laterKey = turnKeys[later];
135
+ if (laterKey !== undefined && persistedKeys.has(laterKey)) {
136
+ return { kind: "out-of-order", messageIndex: index };
137
+ }
138
+ }
139
+ toPersist.push(index);
140
+ }
141
+ return { kind: "ok", toPersist };
142
+ }
@@ -64,10 +64,10 @@ export async function classifyUnexpectedStop(
64
64
  }
65
65
 
66
66
  async function classifyOnline(text: string, deps: ClassifyUnexpectedStopDeps): Promise<boolean | undefined> {
67
- const resolved = resolveRoleSelection(["smol"], deps.settings, deps.registry.getAvailable(), deps.registry);
67
+ const resolved = resolveRoleSelection(["tiny", "smol"], deps.settings, deps.registry.getAvailable(), deps.registry);
68
68
  const model = resolved?.model;
69
69
  if (!model) {
70
- throw new Error("unexpected-stop: no smol model available for classification");
70
+ throw new Error("unexpected-stop: no tiny/smol model available for classification");
71
71
  }
72
72
  const apiKey = await deps.registry.getApiKey(model, deps.sessionId);
73
73
  if (!apiKey) {
@@ -1,3 +1,4 @@
1
+ import * as AIError from "@oh-my-pi/pi-ai/error";
1
2
  import { getMCPConfigPath, logger } from "@oh-my-pi/pi-utils";
2
3
  import { connectToServer, disconnectServer, listPrompts, listResources, listTools } from "../../mcp/client";
3
4
  import {
@@ -350,7 +351,7 @@ async function handleSmitherySearchCommand(rest: string, runtime: SlashCommandRu
350
351
  return commandConsumed();
351
352
  } catch (err) {
352
353
  const message = errorMessage(err);
353
- if (/401|403|unauthorized|forbidden/i.test(message)) {
354
+ if (AIError.is(AIError.classify(err), AIError.Flag.AuthFailed)) {
354
355
  return usage(
355
356
  "Smithery authentication required. Run /mcp smithery-login in the TUI client or add an API key to ~/.omp/agent/smithery.json.",
356
357
  runtime,
@@ -87,8 +87,9 @@ void TINY_TITLE_MODEL_VALUES_MATCH_REGISTRY;
87
87
  export const TINY_TITLE_MODEL_OPTIONS = [
88
88
  {
89
89
  value: ONLINE_TINY_TITLE_MODEL_KEY,
90
- label: "Online (pi/smol)",
91
- description: "Current online title generation path; no local model download or on-device inference.",
90
+ label: "Online (TINY role, else pi/smol)",
91
+ description:
92
+ "Online title generation: the TINY model role (set one in /models) when assigned, otherwise the online fallback (commit role, then pi/smol). No local download or on-device inference.",
92
93
  },
93
94
  ...TINY_TITLE_LOCAL_MODELS.map(model => ({
94
95
  value: model.key,
@@ -183,9 +184,9 @@ void TINY_MEMORY_MODEL_VALUES_MATCH_REGISTRY;
183
184
  export const TINY_MEMORY_MODEL_OPTIONS = [
184
185
  {
185
186
  value: ONLINE_MEMORY_MODEL_KEY,
186
- label: "Online (smol/remote)",
187
+ label: "Online (TINY role, else smol)",
187
188
  description:
188
- "Use the configured Mnemopi LLM mode (smol or remote); no local model download or on-device inference.",
189
+ "Use the online model: the TINY role from /models when set, otherwise pi/smol. No local model download or on-device inference.",
189
190
  },
190
191
  ...TINY_MEMORY_LOCAL_MODELS.map(model => ({
191
192
  value: model.key,
@@ -245,8 +246,9 @@ export type AutoThinkingModelKey = TinyMemoryModelKey;
245
246
  export const AUTO_THINKING_MODEL_OPTIONS = [
246
247
  {
247
248
  value: ONLINE_AUTO_THINKING_MODEL_KEY,
248
- label: "Online (smol)",
249
- description: "Classify prompt difficulty with the online smol model; no local download or on-device inference.",
249
+ label: "Online (TINY role, else smol)",
250
+ description:
251
+ "Classify prompt difficulty online with the TINY role model (set one in /models) or pi/smol; no local download or on-device inference.",
250
252
  },
251
253
  ...TINY_MEMORY_LOCAL_MODELS.map(model => ({
252
254
  value: model.key,
package/src/tiny/text.ts CHANGED
@@ -173,14 +173,15 @@ export function normalizeGeneratedTitle(value: string | null | undefined, source
173
173
  * (`TinyVMM` → `tinyvmm`). The user's message is the source of truth, so per
174
174
  * title token:
175
175
  * 1. typed verbatim in the message → keep it (the user established the casing);
176
- * 2. else the message has the same word with *distinctive* casing
177
- * (`TinyVMM`, `iOS`, `API`) → adopt the user's casing (restoration);
176
+ * 2. else the message has the same word with *distinctive* mixed casing
177
+ * (`TinyVMM`, `iOS`, `IDs`) → adopt the user's casing (restoration);
178
178
  * 3. else it's a camelCase artifact (lowercase word + stray interior capital,
179
179
  * `dAemon`) the user never wrote → lowercase it;
180
180
  * 4. else leave it — preserves model-cased proper nouns like `GitHub`, `OAuth`.
181
181
  *
182
- * Restoration is limited to distinctively cased source tokens so a sentence that
183
- * merely *starts* with `For` can't force a mid-title `for` to `For`.
182
+ * Restoration is limited to distinctively *mixed*-cased source tokens: a sentence
183
+ * that merely *starts* with `For` can't force a mid-title `for` to `For`, and
184
+ * emphatic all-caps (`ALL ERROR HANDLING`) is never re-shouted over sentence case.
184
185
  */
185
186
  function reconcileTitleCasing(title: string, sourceText: string): string {
186
187
  const verbatim = new Set<string>();
@@ -200,10 +201,16 @@ function reconcileTitleCasing(title: string, sourceText: string): string {
200
201
  });
201
202
  }
202
203
 
203
- /** Casing richer than a leading capital interior or repeated uppercase
204
- * (`TinyVMM`, `iOS`, `API`). Worth restoring from the user's message. */
204
+ /** Mixed-case identifier the user cased deliberately (`TinyVMM`, `iOS`, `IDs`):
205
+ * an interior/repeated capital plus at least one lowercase letter. Only these
206
+ * are restored when the model flattens them.
207
+ *
208
+ * Pure all-caps is intentionally excluded. The model preserves its own acronyms
209
+ * verbatim regardless, so restoring all-caps from the source would only ever
210
+ * re-shout emphatic input (`ALL ERROR HANDLING`, `FIX THE BUG`) over the
211
+ * sentence case the prompt asks for. */
205
212
  function isDistinctiveCasing(token: string): boolean {
206
- return /\p{L}\p{Lu}/u.test(token);
213
+ return /\p{Ll}/u.test(token) && /\p{L}\p{Lu}/u.test(token);
207
214
  }
208
215
 
209
216
  /** A lowercase word carrying a stray interior capital (`dAemon`, `cReate`): the
@@ -1,6 +1,7 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
- import { type ApiKey, type FetchImpl, getEnvApiKey, type Model, ProviderHttpError, withAuth } from "@oh-my-pi/pi-ai";
3
+ import { type ApiKey, type FetchImpl, getEnvApiKey, type Model, withAuth } from "@oh-my-pi/pi-ai";
4
+ import { ProviderHttpError } from "@oh-my-pi/pi-ai/error";
4
5
  import {
5
6
  CODEX_BASE_URL,
6
7
  getCodexAccountId,
package/src/tools/tts.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  // the `providers.tts` switch.
5
5
 
6
6
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
7
- import { type ApiKey, ProviderHttpError, withAuth } from "@oh-my-pi/pi-ai";
7
+ import { type ApiKey, withAuth } from "@oh-my-pi/pi-ai";
8
+ import { ProviderHttpError } from "@oh-my-pi/pi-ai/error";
8
9
  import { type } from "arktype";
9
10
  import { settings } from "../config/settings";
10
11
  import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
@@ -72,7 +72,7 @@ function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel
72
72
  const availableModels = registry.getAvailable();
73
73
  if (availableModels.length === 0) return undefined;
74
74
 
75
- const titleModel = resolveRoleSelection(["title", "commit", "smol"], settings, availableModels, registry)?.model;
75
+ const titleModel = resolveRoleSelection(["tiny", "commit", "smol"], settings, availableModels, registry)?.model;
76
76
  if (titleModel) return titleModel;
77
77
 
78
78
  if (currentModel) return currentModel;
@@ -120,25 +120,7 @@ async function callTavilySearch(apiKey: string, params: TavilySearchParams): Pro
120
120
  return (await response.json()) as TavilySearchResponse;
121
121
  }
122
122
 
123
- /** Execute Tavily web search. */
124
- export async function searchTavily(params: SearchParams): Promise<SearchResponse> {
125
- const tavilyParams: TavilySearchParams = {
126
- query: params.query,
127
- num_results: params.numSearchResults ?? params.limit,
128
- recency: params.recency,
129
- signal: params.signal,
130
- fetch: params.fetch,
131
- };
132
- const keyOrResolver: ApiKey = params.authStorage.resolver("tavily", {
133
- sessionId: params.sessionId,
134
- });
135
-
136
- const numResults = clampNumResults(tavilyParams.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
137
- const response = await withAuth(keyOrResolver, key => callTavilySearch(key, tavilyParams), {
138
- signal: params.signal,
139
- missingKeyMessage:
140
- 'Tavily credentials not found. Set TAVILY_API_KEY or configure an API key for provider "tavily".',
141
- });
123
+ function toSearchResponse(response: TavilySearchResponse, numResults: number): SearchResponse {
142
124
  const sources: SearchSource[] = [];
143
125
 
144
126
  for (const result of response.results ?? []) {
@@ -161,6 +143,41 @@ export async function searchTavily(params: SearchParams): Promise<SearchResponse
161
143
  };
162
144
  }
163
145
 
146
+ function hasRenderableResponse(response: SearchResponse): boolean {
147
+ if (response.answer?.trim()) return true;
148
+ return response.sources.length > 0;
149
+ }
150
+
151
+ /** Execute Tavily web search. */
152
+ export async function searchTavily(params: SearchParams): Promise<SearchResponse> {
153
+ const tavilyParams: TavilySearchParams = {
154
+ query: params.query,
155
+ num_results: params.numSearchResults ?? params.limit,
156
+ recency: params.recency,
157
+ signal: params.signal,
158
+ fetch: params.fetch,
159
+ };
160
+ const keyOrResolver: ApiKey = params.authStorage.resolver("tavily", {
161
+ sessionId: params.sessionId,
162
+ });
163
+
164
+ const numResults = clampNumResults(tavilyParams.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
165
+ const authOptions = {
166
+ signal: params.signal,
167
+ missingKeyMessage:
168
+ 'Tavily credentials not found. Set TAVILY_API_KEY or configure an API key for provider "tavily".',
169
+ };
170
+ const callWithAuth = (searchParams: TavilySearchParams) =>
171
+ withAuth(keyOrResolver, key => callTavilySearch(key, searchParams), authOptions);
172
+
173
+ const response = toSearchResponse(await callWithAuth(tavilyParams), numResults);
174
+ if (!tavilyParams.recency || hasRenderableResponse(response)) {
175
+ return response;
176
+ }
177
+
178
+ return toSearchResponse(await callWithAuth({ ...tavilyParams, recency: undefined }), numResults);
179
+ }
180
+
164
181
  /** Search provider for Tavily web search. */
165
182
  export class TavilyProvider extends SearchProvider {
166
183
  readonly id = "tavily";