@oh-my-pi/pi-coding-agent 15.7.6 → 15.8.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 (117) hide show
  1. package/CHANGELOG.md +172 -198
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +10 -1
  9. package/dist/types/config/settings-schema.d.ts +2 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/edit/index.d.ts +6 -0
  12. package/dist/types/edit/streaming.d.ts +8 -0
  13. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/export/ttsr.d.ts +9 -0
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  17. package/dist/types/main.d.ts +5 -0
  18. package/dist/types/mcp/transports/stdio.d.ts +19 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  20. package/dist/types/modes/components/hook-selector.d.ts +3 -0
  21. package/dist/types/modes/components/session-selector.d.ts +32 -5
  22. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  23. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  24. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  25. package/dist/types/modes/interactive-mode.d.ts +9 -2
  26. package/dist/types/modes/types.d.ts +4 -2
  27. package/dist/types/registry/agent-registry.d.ts +1 -1
  28. package/dist/types/sdk.d.ts +2 -2
  29. package/dist/types/session/agent-session.d.ts +4 -2
  30. package/dist/types/session/history-storage.d.ts +16 -1
  31. package/dist/types/session/session-manager.d.ts +4 -0
  32. package/dist/types/task/output-manager.d.ts +6 -15
  33. package/dist/types/tools/find.d.ts +0 -9
  34. package/dist/types/tools/index.d.ts +1 -1
  35. package/dist/types/tools/path-utils.d.ts +16 -0
  36. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  37. package/dist/types/tools/write.d.ts +2 -0
  38. package/dist/types/utils/clipboard.d.ts +4 -0
  39. package/dist/types/utils/jj.d.ts +49 -0
  40. package/dist/types/web/kagi.d.ts +76 -0
  41. package/dist/types/web/search/providers/exa.d.ts +7 -1
  42. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  43. package/package.json +9 -9
  44. package/src/async/job-manager.ts +3 -3
  45. package/src/cli/args.ts +6 -2
  46. package/src/cli/claude-trace-cli.ts +783 -0
  47. package/src/cli/session-picker.ts +36 -10
  48. package/src/cli/update-cli.ts +35 -2
  49. package/src/commands/launch.ts +3 -0
  50. package/src/config/keybindings.ts +14 -1
  51. package/src/config/model-registry.ts +18 -7
  52. package/src/config/settings-schema.ts +2 -2
  53. package/src/config/settings.ts +23 -0
  54. package/src/discovery/builtin-rules/index.ts +2 -0
  55. package/src/discovery/builtin-rules/ts-no-deprecated-leftovers.md +44 -0
  56. package/src/discovery/claude-plugins.ts +7 -9
  57. package/src/edit/index.ts +10 -0
  58. package/src/edit/streaming.ts +65 -0
  59. package/src/eval/__tests__/agent-bridge.test.ts +58 -4
  60. package/src/eval/concurrency-bridge.ts +34 -0
  61. package/src/eval/js/shared/prelude.txt +20 -17
  62. package/src/eval/js/tool-bridge.ts +5 -0
  63. package/src/eval/py/prelude.py +23 -15
  64. package/src/export/ttsr.ts +18 -1
  65. package/src/extensibility/custom-commands/bundled/review/index.ts +74 -45
  66. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  67. package/src/extensibility/skills.ts +0 -1
  68. package/src/internal-urls/docs-index.generated.ts +12 -11
  69. package/src/main.ts +92 -24
  70. package/src/mcp/transports/stdio.ts +55 -22
  71. package/src/modes/acp/acp-event-mapper.ts +54 -4
  72. package/src/modes/components/custom-editor.ts +10 -0
  73. package/src/modes/components/hook-selector.ts +89 -31
  74. package/src/modes/components/oauth-selector.ts +12 -6
  75. package/src/modes/components/session-selector.ts +179 -24
  76. package/src/modes/components/tool-execution.ts +16 -3
  77. package/src/modes/controllers/command-controller.ts +2 -11
  78. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  79. package/src/modes/controllers/input-controller.ts +19 -1
  80. package/src/modes/controllers/selector-controller.ts +61 -21
  81. package/src/modes/interactive-mode.ts +125 -15
  82. package/src/modes/types.ts +5 -2
  83. package/src/prompts/agents/reviewer.md +2 -2
  84. package/src/prompts/review-request.md +1 -1
  85. package/src/prompts/system/empty-stop-retry.md +6 -0
  86. package/src/prompts/system/orchestrate-notice.md +5 -3
  87. package/src/prompts/system/workflow-notice.md +2 -2
  88. package/src/prompts/tools/eval.md +5 -5
  89. package/src/prompts/tools/find.md +1 -1
  90. package/src/prompts/tools/irc.md +6 -6
  91. package/src/prompts/tools/search-tool-bm25.md +9 -2
  92. package/src/prompts/tools/search.md +1 -1
  93. package/src/prompts/tools/task.md +1 -1
  94. package/src/registry/agent-registry.ts +1 -1
  95. package/src/sdk.ts +85 -31
  96. package/src/session/agent-session.ts +209 -54
  97. package/src/session/history-storage.ts +56 -12
  98. package/src/session/session-manager.ts +34 -0
  99. package/src/task/output-manager.ts +40 -48
  100. package/src/task/render.ts +3 -8
  101. package/src/tools/browser/tab-worker.ts +8 -5
  102. package/src/tools/find.ts +5 -29
  103. package/src/tools/index.ts +1 -1
  104. package/src/tools/path-utils.ts +144 -1
  105. package/src/tools/read.ts +47 -0
  106. package/src/tools/search-tool-bm25.ts +7 -1
  107. package/src/tools/search.ts +2 -27
  108. package/src/tools/sqlite-reader.ts +92 -9
  109. package/src/tools/write.ts +6 -0
  110. package/src/utils/clipboard.ts +38 -1
  111. package/src/utils/git.ts +19 -23
  112. package/src/utils/jj.ts +225 -0
  113. package/src/utils/open.ts +37 -2
  114. package/src/web/kagi.ts +168 -49
  115. package/src/web/search/providers/anthropic.ts +1 -1
  116. package/src/web/search/providers/exa.ts +20 -86
  117. package/src/web/search/providers/kagi.ts +4 -0
@@ -0,0 +1,225 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { $which } from "@oh-my-pi/pi-utils";
4
+ import { LRUCache } from "lru-cache/raw";
5
+
6
+ // ════════════════════════════════════════════════════════════════════════════
7
+ // Types
8
+ // ════════════════════════════════════════════════════════════════════════════
9
+
10
+ /** Result from a completed `jj` subprocess invocation. */
11
+ export interface JjCommandResult {
12
+ /** Process exit code reported by `jj`. */
13
+ exitCode: number;
14
+ /** Captured standard output as UTF-8 text. */
15
+ stdout: string;
16
+ /** Captured standard error as UTF-8 text. */
17
+ stderr: string;
18
+ }
19
+
20
+ /** Resolved Jujutsu workspace metadata. */
21
+ export interface JjRepository {
22
+ /** Root directory containing the `.jj` workspace metadata. */
23
+ repoRoot: string;
24
+ /** Path to the workspace store directory used to verify a real JJ checkout. */
25
+ storeDir: string;
26
+ }
27
+
28
+ /** Options for `jj diff` invocations. */
29
+ export interface DiffOptions {
30
+ /** Optional file paths to restrict the diff with `-- <files>`. */
31
+ readonly files?: readonly string[];
32
+ /** Return only changed file names instead of Git-format diff text. */
33
+ readonly nameOnly?: boolean;
34
+ /** Optional abort signal passed to the spawned `jj` process. */
35
+ readonly signal?: AbortSignal;
36
+ }
37
+
38
+ interface CommandOptions {
39
+ readonly signal?: AbortSignal;
40
+ }
41
+
42
+ // ════════════════════════════════════════════════════════════════════════════
43
+ // Error
44
+ // ════════════════════════════════════════════════════════════════════════════
45
+
46
+ /** Error thrown when a checked `jj` command exits non-zero. */
47
+ export class JjCommandError extends Error {
48
+ /** Arguments passed after the common `jj --no-pager --color=never` prefix. */
49
+ readonly args: readonly string[];
50
+ /** Captured command result that caused the failure. */
51
+ readonly result: JjCommandResult;
52
+
53
+ /** Create an error for a failed checked `jj` command. */
54
+ constructor(args: readonly string[], result: JjCommandResult) {
55
+ super(formatCommandFailure(args, result));
56
+ this.name = "JjCommandError";
57
+ this.args = [...args];
58
+ this.result = result;
59
+ }
60
+ }
61
+
62
+ // ════════════════════════════════════════════════════════════════════════════
63
+ // Internal: Core execution
64
+ // ════════════════════════════════════════════════════════════════════════════
65
+
66
+ function ensureAvailable(): void {
67
+ if (!$which("jj")) {
68
+ throw new Error("jj is not installed.");
69
+ }
70
+ }
71
+
72
+ function formatCommandFailure(
73
+ args: readonly string[],
74
+ result: Pick<JjCommandResult, "exitCode" | "stdout" | "stderr">,
75
+ ): string {
76
+ const stderr = result.stderr.trim();
77
+ if (stderr) return stderr;
78
+ const stdout = result.stdout.trim();
79
+ if (stdout) return stdout;
80
+ return `jj ${args.join(" ")} failed with exit code ${result.exitCode}`;
81
+ }
82
+
83
+ async function jj(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<JjCommandResult> {
84
+ const child = Bun.spawn(["jj", "--no-pager", "--color=never", ...args], {
85
+ cwd,
86
+ signal: options.signal,
87
+ stdin: "ignore",
88
+ stdout: "pipe",
89
+ stderr: "pipe",
90
+ windowsHide: true,
91
+ });
92
+
93
+ if (!child.stdout || !child.stderr) {
94
+ throw new Error("Failed to capture jj command output.");
95
+ }
96
+
97
+ const [stdout, stderr, exitCode] = await Promise.all([
98
+ new Response(child.stdout).text(),
99
+ new Response(child.stderr).text(),
100
+ child.exited,
101
+ ]);
102
+
103
+ return { exitCode: exitCode ?? 0, stdout, stderr };
104
+ }
105
+
106
+ async function runChecked(
107
+ cwd: string,
108
+ args: readonly string[],
109
+ options: CommandOptions = {},
110
+ ): Promise<JjCommandResult> {
111
+ ensureAvailable();
112
+ const result = await jj(cwd, args, options);
113
+ if (result.exitCode !== 0) {
114
+ throw new JjCommandError(args, result);
115
+ }
116
+ return result;
117
+ }
118
+
119
+ async function runText(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<string> {
120
+ return (await runChecked(cwd, args, options)).stdout;
121
+ }
122
+
123
+ function splitLines(text: string): string[] {
124
+ return text
125
+ .split("\n")
126
+ .map(line => line.trim())
127
+ .filter(Boolean);
128
+ }
129
+
130
+ function buildDiffArgs(options: DiffOptions): string[] {
131
+ const args = ["diff"];
132
+ args.push(options.nameOnly ? "--name-only" : "--git");
133
+ if (options.files?.length) args.push("--", ...options.files);
134
+ return args;
135
+ }
136
+
137
+ // ════════════════════════════════════════════════════════════════════════════
138
+ // Internal: Repository resolution
139
+ // ════════════════════════════════════════════════════════════════════════════
140
+
141
+ interface WorkspaceRootCacheEntry {
142
+ readonly root?: string;
143
+ }
144
+
145
+ const WORKSPACE_ROOT_CACHE_MAX_ENTRIES = 256;
146
+ const workspaceRootCache = new LRUCache<string, WorkspaceRootCacheEntry>({ max: WORKSPACE_ROOT_CACHE_MAX_ENTRIES });
147
+
148
+ async function hasJjWorkspaceMetadata(dir: string): Promise<boolean> {
149
+ try {
150
+ return (await fs.stat(path.join(dir, ".jj", "repo", "store"))).isDirectory();
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ function parentOf(dir: string): string | undefined {
157
+ const parent = path.dirname(dir);
158
+ return parent === dir ? undefined : parent;
159
+ }
160
+
161
+ async function findWorkspaceRoot(cwd: string): Promise<string | undefined> {
162
+ const key = path.resolve(cwd);
163
+ if (workspaceRootCache.has(key)) return workspaceRootCache.get(key)?.root;
164
+
165
+ for (let dir: string | undefined = key; dir; dir = parentOf(dir)) {
166
+ if (await hasJjWorkspaceMetadata(dir)) {
167
+ workspaceRootCache.set(key, { root: dir });
168
+ return dir;
169
+ }
170
+ }
171
+
172
+ workspaceRootCache.set(key, {});
173
+ return undefined;
174
+ }
175
+
176
+ function repositoryFromRoot(root: string): JjRepository {
177
+ return {
178
+ repoRoot: root,
179
+ storeDir: path.join(root, ".jj", "repo", "store"),
180
+ };
181
+ }
182
+
183
+ // ════════════════════════════════════════════════════════════════════════════
184
+ // API: diff
185
+ // ════════════════════════════════════════════════════════════════════════════
186
+
187
+ /** Run `jj diff --git` for the current workspace commit and return the raw Git-format diff text. */
188
+ export const diff = Object.assign(
189
+ async function diff(cwd: string, options: DiffOptions = {}): Promise<string> {
190
+ return runText(cwd, buildDiffArgs(options), { signal: options.signal });
191
+ },
192
+ {
193
+ /** List changed file paths. */
194
+ async changedFiles(cwd: string, options: Pick<DiffOptions, "files" | "signal"> = {}): Promise<string[]> {
195
+ return splitLines(await diff(cwd, { ...options, nameOnly: true }));
196
+ },
197
+ },
198
+ );
199
+
200
+ // ════════════════════════════════════════════════════════════════════════════
201
+ // API: repo
202
+ // ════════════════════════════════════════════════════════════════════════════
203
+
204
+ export const repo = {
205
+ /** Clear cached workspace roots. Intended for tests that mutate JJ metadata under an existing path. */
206
+ clearRootCache(): void {
207
+ workspaceRootCache.clear();
208
+ },
209
+
210
+ /** Resolve the current Jujutsu workspace root, or `null` when `cwd` is not in a JJ repository. */
211
+ async root(cwd: string): Promise<string | null> {
212
+ return (await findWorkspaceRoot(cwd)) ?? null;
213
+ },
214
+
215
+ /** Full Jujutsu workspace metadata. */
216
+ async resolve(cwd: string): Promise<JjRepository | null> {
217
+ const root = await repo.root(cwd);
218
+ return root ? repositoryFromRoot(root) : null;
219
+ },
220
+
221
+ /** Check whether `cwd` is inside a Jujutsu repository. */
222
+ async is(cwd: string): Promise<boolean> {
223
+ return (await repo.root(cwd)) !== null;
224
+ },
225
+ };
package/src/utils/open.ts CHANGED
@@ -1,3 +1,36 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as url from "node:url";
4
+ import * as piUtils from "@oh-my-pi/pi-utils";
5
+
6
+ const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
7
+
8
+ function getExistingWslLocalPath(urlOrPath: string): string | undefined {
9
+ if (
10
+ process.platform !== "linux" ||
11
+ !(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) ||
12
+ !piUtils.$which("wslview")
13
+ ) {
14
+ return undefined;
15
+ }
16
+
17
+ try {
18
+ const localPath = urlOrPath.startsWith("file://")
19
+ ? url.fileURLToPath(urlOrPath)
20
+ : URL_SCHEME_PATTERN.test(urlOrPath)
21
+ ? undefined
22
+ : path.resolve(urlOrPath);
23
+ if (!localPath || !fs.existsSync(localPath)) return undefined;
24
+
25
+ const result = Bun.spawnSync(["wslpath", "-w", localPath], { stdout: "pipe", stderr: "ignore" });
26
+ if (result.exitCode !== 0) return undefined;
27
+
28
+ return result.stdout.toString().trim() || undefined;
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
1
34
  /** Open a URL or file path in the default browser/application. Best-effort, never throws. */
2
35
  export function openPath(urlOrPath: string): void {
3
36
  let cmd: string[];
@@ -8,9 +41,11 @@ export function openPath(urlOrPath: string): void {
8
41
  case "win32":
9
42
  cmd = ["rundll32", "url.dll,FileProtocolHandler", urlOrPath];
10
43
  break;
11
- default:
12
- cmd = ["xdg-open", urlOrPath];
44
+ default: {
45
+ const wslPath = getExistingWslLocalPath(urlOrPath);
46
+ cmd = wslPath ? ["wslview", wslPath] : ["xdg-open", urlOrPath];
13
47
  break;
48
+ }
14
49
  }
15
50
  try {
16
51
  Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
package/src/web/kagi.ts CHANGED
@@ -1,42 +1,92 @@
1
+ /**
2
+ * Kagi API Client
3
+ *
4
+ * Implements the Kagi V1 Search API (POST /api/v1/search), the public-preview
5
+ * successor to the sunset V0 endpoint. Authentication is resolved exclusively
6
+ * through the shared {@link AuthStorage} broker (Bearer token), and responses
7
+ * are categorized result buckets rather than the legacy flat object array.
8
+ */
1
9
  import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
10
  import { withHardTimeout } from "./search/providers/utils";
3
11
 
4
- const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search";
12
+ const KAGI_SEARCH_URL = "https://kagi.com/api/v1/search";
5
13
 
6
- interface KagiSearchResultObject {
7
- t: 0;
14
+ // ---------------------------------------------------------------------------
15
+ // Request / Response Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** V1 search request body. */
19
+ export interface KagiSearchRequest {
20
+ query: string;
21
+ /** Workflow mode: "search" | "research". */
22
+ workflow?: string;
23
+ /** Number of results (1-100). */
24
+ limit?: number;
25
+ /** Lens identifier (e.g. "news", "reddit"). */
26
+ lens?: string;
27
+ /** Time-based filters as ISO date strings (YYYY-MM-DD). */
28
+ filters?: {
29
+ after?: string;
30
+ before?: string;
31
+ };
32
+ }
33
+
34
+ /** Individual V1 result item. */
35
+ export interface KagiSearchResultItem {
8
36
  url: string;
9
37
  title: string;
10
38
  snippet?: string;
11
- published?: string;
39
+ /** ISO timestamp or relative string ("2h ago"). */
40
+ time?: string;
41
+ /** Thumbnail image. */
42
+ image?: { url: string; height?: number; width?: number };
43
+ /** Extra metadata key-value pairs. */
44
+ props?: Record<string, unknown>;
12
45
  }
13
46
 
14
- interface KagiRelatedSearchesObject {
15
- t: 1;
16
- list: string[];
47
+ /** V1 categorizes results into named buckets; only consumed buckets are typed. */
48
+ export interface KagiSearchData {
49
+ search?: KagiSearchResultItem[];
50
+ video?: KagiSearchResultItem[];
51
+ news?: KagiSearchResultItem[];
52
+ infobox?: KagiSearchResultItem[];
53
+ adjacent_question?: KagiSearchResultItem[];
54
+ related_search?: KagiSearchResultItem[];
55
+ direct_answer?: KagiSearchResultItem[];
17
56
  }
18
57
 
19
- type KagiSearchObject = KagiSearchResultObject | KagiRelatedSearchesObject;
20
-
21
- interface KagiErrorEntry {
58
+ /** V1 error entry. */
59
+ export interface KagiErrorEntry {
22
60
  code?: number;
61
+ url?: string;
62
+ message?: string;
23
63
  msg?: string;
64
+ location?: string;
24
65
  }
25
66
 
26
- interface KagiSearchResponse {
27
- meta: {
28
- id: string;
67
+ /** V1 success response. */
68
+ export interface KagiSearchResponse {
69
+ meta?: {
70
+ trace?: string;
71
+ id?: string;
72
+ ms?: number;
29
73
  };
30
- data: KagiSearchObject[];
74
+ data?: KagiSearchData;
31
75
  error?: KagiErrorEntry[];
32
76
  }
33
77
 
34
- interface KagiErrorResponse {
78
+ /** V1 error response. */
79
+ export interface KagiErrorResponse {
80
+ meta?: Record<string, unknown>;
35
81
  error?: string | KagiErrorEntry[];
36
82
  message?: string;
37
83
  detail?: string;
38
84
  }
39
85
 
86
+ // ---------------------------------------------------------------------------
87
+ // Error Handling
88
+ // ---------------------------------------------------------------------------
89
+
40
90
  export class KagiApiError extends Error {
41
91
  readonly statusCode?: number;
42
92
 
@@ -64,9 +114,11 @@ function extractKagiErrorMessage(payload: unknown): string | null {
64
114
  if (Array.isArray(record.error)) {
65
115
  for (const entry of record.error) {
66
116
  if (!entry || typeof entry !== "object") continue;
67
- const message = (entry as Record<string, unknown>).msg;
68
- if (typeof message === "string" && message.trim().length > 0) {
69
- return message.trim();
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();
121
+ }
70
122
  }
71
123
  }
72
124
  }
@@ -82,21 +134,26 @@ function createKagiApiError(statusCode: number, detail?: string): KagiApiError {
82
134
  }
83
135
 
84
136
  function parseKagiErrorResponse(statusCode: number, responseText: string): KagiApiError {
85
- const trimmedResponseText = responseText.trim();
86
- if (trimmedResponseText.length === 0) {
137
+ const trimmed = responseText.trim();
138
+ if (trimmed.length === 0) {
87
139
  return createKagiApiError(statusCode);
88
140
  }
89
141
 
90
142
  try {
91
- const payload = JSON.parse(trimmedResponseText) as KagiErrorResponse;
92
- return createKagiApiError(statusCode, extractKagiErrorMessage(payload) ?? trimmedResponseText);
143
+ const payload = JSON.parse(trimmed) as KagiErrorResponse;
144
+ return createKagiApiError(statusCode, extractKagiErrorMessage(payload) ?? trimmed);
93
145
  } catch {
94
- return createKagiApiError(statusCode, trimmedResponseText);
146
+ return createKagiApiError(statusCode, trimmed);
95
147
  }
96
148
  }
97
149
 
150
+ // ---------------------------------------------------------------------------
151
+ // Public API
152
+ // ---------------------------------------------------------------------------
153
+
98
154
  export interface KagiSearchOptions {
99
155
  limit?: number;
156
+ recency?: "day" | "week" | "month" | "year";
100
157
  sessionId?: string;
101
158
  signal?: AbortSignal;
102
159
  }
@@ -112,6 +169,7 @@ export interface KagiSearchResult {
112
169
  requestId: string;
113
170
  sources: KagiSearchSource[];
114
171
  relatedQuestions: string[];
172
+ answer?: string;
115
173
  }
116
174
 
117
175
  export async function findKagiApiKey(
@@ -122,11 +180,65 @@ export async function findKagiApiKey(
122
180
  return (await authStorage.getApiKey("kagi", sessionId, { signal })) ?? null;
123
181
  }
124
182
 
125
- function getAuthHeaders(apiKey: string): Record<string, string> {
126
- return {
127
- Authorization: `Bot ${apiKey}`,
128
- Accept: "application/json",
183
+ /**
184
+ * Compute a YYYY-MM-DD date string `recency` units before now, in UTC.
185
+ * UTC keeps the recency window deterministic regardless of host timezone and
186
+ * matches Kagi's date-formatted `filters.after`. Date setters handle month
187
+ * drift (Mar 31 −1mo → Feb 28/29) and leap years correctly.
188
+ */
189
+ function recencyToDate(recency: "day" | "week" | "month" | "year"): string {
190
+ const d = new Date();
191
+ switch (recency) {
192
+ case "day":
193
+ d.setUTCDate(d.getUTCDate() - 1);
194
+ break;
195
+ case "week":
196
+ d.setUTCDate(d.getUTCDate() - 7);
197
+ break;
198
+ case "month":
199
+ d.setUTCMonth(d.getUTCMonth() - 1);
200
+ break;
201
+ case "year":
202
+ d.setUTCFullYear(d.getUTCFullYear() - 1);
203
+ break;
204
+ }
205
+ const yyyy = d.getUTCFullYear();
206
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
207
+ const dd = String(d.getUTCDate()).padStart(2, "0");
208
+ return `${yyyy}-${mm}-${dd}`;
209
+ }
210
+
211
+ function buildRequestBody(query: string, options: KagiSearchOptions): KagiSearchRequest {
212
+ const req: KagiSearchRequest = {
213
+ query,
214
+ workflow: "search",
215
+ limit: options.limit,
129
216
  };
217
+
218
+ if (options.recency) {
219
+ req.filters = { after: recencyToDate(options.recency) };
220
+ }
221
+
222
+ return req;
223
+ }
224
+
225
+ /** Push every item in a result bucket as a source, with an optional title tag. */
226
+ function collectSources(sources: KagiSearchSource[], items: KagiSearchResultItem[] | undefined, tag?: string): void {
227
+ if (!items) return;
228
+ for (const item of items) {
229
+ sources.push({
230
+ title: tag ? `${tag} ${item.title}` : item.title,
231
+ url: item.url,
232
+ snippet: item.snippet,
233
+ publishedDate: item.time,
234
+ });
235
+ }
236
+ }
237
+
238
+ /** Pull a related/adjacent question from an item's props or fall back to title. */
239
+ function questionOf(item: KagiSearchResultItem): string | undefined {
240
+ const q = item.props?.question ?? item.props?.query ?? item.title;
241
+ return typeof q === "string" && q.length > 0 ? q : undefined;
130
242
  }
131
243
 
132
244
  export async function searchWithKagi(
@@ -139,45 +251,52 @@ export async function searchWithKagi(
139
251
  throw new KagiApiError("Kagi credentials not found. Set KAGI_API_KEY or login with 'omp /login kagi'.");
140
252
  }
141
253
 
142
- const requestUrl = new URL(KAGI_SEARCH_URL);
143
- requestUrl.searchParams.set("q", query);
144
- if (options.limit !== undefined) {
145
- requestUrl.searchParams.set("limit", String(options.limit));
146
- }
147
-
148
- const response = await fetch(requestUrl, {
149
- headers: getAuthHeaders(apiKey),
254
+ const response = await fetch(KAGI_SEARCH_URL, {
255
+ method: "POST",
256
+ headers: {
257
+ Authorization: `Bearer ${apiKey}`,
258
+ "Content-Type": "application/json",
259
+ Accept: "application/json",
260
+ },
261
+ body: JSON.stringify(buildRequestBody(query, options)),
150
262
  signal: withHardTimeout(options.signal),
151
263
  });
264
+
152
265
  if (!response.ok) {
153
266
  throw parseKagiErrorResponse(response.status, await response.text());
154
267
  }
155
268
 
156
269
  const payload = (await response.json()) as KagiSearchResponse;
157
270
  if (payload.error && payload.error.length > 0) {
158
- const firstError = payload.error[0];
159
- throw createKagiApiError(firstError.code ?? response.status, extractKagiErrorMessage(payload) ?? undefined);
271
+ const first = payload.error[0];
272
+ throw createKagiApiError(first.code ?? response.status, extractKagiErrorMessage(payload) ?? first.message);
160
273
  }
161
274
 
275
+ const data = payload.data;
162
276
  const sources: KagiSearchSource[] = [];
163
277
  const relatedQuestions: string[] = [];
164
278
 
165
- for (const item of payload.data) {
166
- if (item.t === 0) {
167
- sources.push({
168
- title: item.title,
169
- url: item.url,
170
- snippet: item.snippet,
171
- publishedDate: item.published ?? undefined,
172
- });
173
- } else if (item.t === 1) {
174
- relatedQuestions.push(...item.list);
175
- }
279
+ collectSources(sources, data?.search);
280
+ collectSources(sources, data?.video, "[Video]");
281
+ collectSources(sources, data?.news, "[News]");
282
+ collectSources(sources, data?.infobox, "[Info]");
283
+
284
+ for (const item of data?.adjacent_question ?? []) {
285
+ const q = questionOf(item);
286
+ if (q) relatedQuestions.push(q);
287
+ }
288
+ for (const item of data?.related_search ?? []) {
289
+ const q = questionOf(item);
290
+ if (q) relatedQuestions.push(q);
176
291
  }
177
292
 
293
+ const directAnswer = data?.direct_answer?.[0];
294
+ const answer = directAnswer ? (directAnswer.snippet ?? directAnswer.title) : undefined;
295
+
178
296
  return {
179
- requestId: payload.meta.id,
297
+ requestId: payload.meta?.trace ?? payload.meta?.id ?? "",
180
298
  sources,
181
299
  relatedQuestions,
300
+ answer,
182
301
  };
183
302
  }
@@ -255,7 +255,7 @@ export async function searchAnthropic(
255
255
  const apiKey = await params.authStorage.getApiKey("anthropic", params.sessionId, {
256
256
  signal: params.signal,
257
257
  });
258
- if (apiKey) auth = buildAnthropicAuthConfig(apiKey);
258
+ if (apiKey) auth = buildAnthropicAuthConfig(apiKey, searchBaseUrl);
259
259
  }
260
260
 
261
261
  if (!auth) {