@ahkohd/yagami 0.1.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.
- package/.beads/.beads-credential-key +1 -0
- package/.beads/README.md +81 -0
- package/.beads/config.yaml +54 -0
- package/.beads/hooks/post-checkout +24 -0
- package/.beads/hooks/post-merge +24 -0
- package/.beads/hooks/pre-commit +24 -0
- package/.beads/hooks/pre-push +24 -0
- package/.beads/hooks/prepare-commit-msg +24 -0
- package/.beads/metadata.json +7 -0
- package/.github/workflows/ci.yml +43 -0
- package/.github/workflows/release.yml +115 -0
- package/AGENTS.md +150 -0
- package/README.md +210 -0
- package/biome.json +36 -0
- package/config/mcporter.json +8 -0
- package/dist/cli/theme.js +202 -0
- package/dist/cli/theme.js.map +1 -0
- package/dist/cli.js +1883 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +223 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon.js +745 -0
- package/dist/daemon.js.map +1 -0
- package/dist/engine/constants.js +131 -0
- package/dist/engine/constants.js.map +1 -0
- package/dist/engine/deep-research.js +167 -0
- package/dist/engine/deep-research.js.map +1 -0
- package/dist/engine/defuddle-utils.js +57 -0
- package/dist/engine/defuddle-utils.js.map +1 -0
- package/dist/engine/github-fetch.js +232 -0
- package/dist/engine/github-fetch.js.map +1 -0
- package/dist/engine/helpers.js +372 -0
- package/dist/engine/helpers.js.map +1 -0
- package/dist/engine/limiter.js +75 -0
- package/dist/engine/limiter.js.map +1 -0
- package/dist/engine/policy.js +313 -0
- package/dist/engine/policy.js.map +1 -0
- package/dist/engine/runtime-utils.js +65 -0
- package/dist/engine/runtime-utils.js.map +1 -0
- package/dist/engine/search-discovery.js +275 -0
- package/dist/engine/search-discovery.js.map +1 -0
- package/dist/engine/url-utils.js +72 -0
- package/dist/engine/url-utils.js.map +1 -0
- package/dist/engine.js +2030 -0
- package/dist/engine.js.map +1 -0
- package/dist/mcp.js +282 -0
- package/dist/mcp.js.map +1 -0
- package/dist/types/cli.js +2 -0
- package/dist/types/cli.js.map +1 -0
- package/dist/types/config.js +2 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/daemon.js +2 -0
- package/dist/types/daemon.js.map +1 -0
- package/dist/types/engine.js +2 -0
- package/dist/types/engine.js.map +1 -0
- package/package.json +66 -0
- package/packages/pi-yagami-search/README.md +39 -0
- package/packages/pi-yagami-search/extensions/yagami-search.ts +273 -0
- package/packages/pi-yagami-search/package.json +41 -0
- package/src/cli/theme.ts +260 -0
- package/src/cli.ts +2226 -0
- package/src/config.ts +250 -0
- package/src/daemon.ts +990 -0
- package/src/engine/constants.ts +147 -0
- package/src/engine/deep-research.ts +207 -0
- package/src/engine/defuddle-utils.ts +75 -0
- package/src/engine/github-fetch.ts +265 -0
- package/src/engine/helpers.ts +394 -0
- package/src/engine/limiter.ts +97 -0
- package/src/engine/policy.ts +392 -0
- package/src/engine/runtime-utils.ts +79 -0
- package/src/engine/search-discovery.ts +351 -0
- package/src/engine/url-utils.ts +86 -0
- package/src/engine.ts +2516 -0
- package/src/mcp.ts +337 -0
- package/src/shims-cli.d.ts +3 -0
- package/src/types/cli.ts +7 -0
- package/src/types/config.ts +53 -0
- package/src/types/daemon.ts +22 -0
- package/src/types/engine.ts +194 -0
- package/tsconfig.json +18 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,2226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import fsp from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import readline from "node:readline";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
import cliMarkdown from "cli-markdown";
|
|
12
|
+
|
|
13
|
+
import { getConfig } from "./config.js";
|
|
14
|
+
import { cmdTheme, createCliThemeRuntime } from "./cli/theme.js";
|
|
15
|
+
import { normalizeUniqueUrls } from "./engine.js";
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = path.dirname(__filename);
|
|
19
|
+
const config = getConfig();
|
|
20
|
+
const theme = createCliThemeRuntime(config);
|
|
21
|
+
|
|
22
|
+
const MARKDOWN_RENDER_ENABLED = (() => {
|
|
23
|
+
const value = String(process.env.YAGAMI_MARKDOWN_RENDER ?? "")
|
|
24
|
+
.trim()
|
|
25
|
+
.toLowerCase();
|
|
26
|
+
if (value === "0" || value === "false" || value === "no" || value === "off") return false;
|
|
27
|
+
return Boolean(process.stdout.isTTY);
|
|
28
|
+
})();
|
|
29
|
+
|
|
30
|
+
const MARKDOWN_RENDER_WIDTH = Math.max(40, Math.min(96, (process.stdout.columns || 80) - 2));
|
|
31
|
+
|
|
32
|
+
function normalizeMarkdownForRender(markdown: string): string {
|
|
33
|
+
const lines = String(markdown ?? "")
|
|
34
|
+
.replace(/\r\n/g, "\n")
|
|
35
|
+
.split("\n");
|
|
36
|
+
const output: string[] = [];
|
|
37
|
+
let inFence = false;
|
|
38
|
+
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
const trimmedStart = line.trimStart();
|
|
41
|
+
if (/^(```|~~~)/.test(trimmedStart)) {
|
|
42
|
+
inFence = !inFence;
|
|
43
|
+
output.push(line);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (inFence) {
|
|
48
|
+
output.push(line);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let normalized = line;
|
|
53
|
+
normalized = normalized.replace(/^ {4}(?=(?:[*+-]|\d+\.)\s)/, "");
|
|
54
|
+
|
|
55
|
+
if (/^-{20,}\s*$/.test(normalized.trim())) {
|
|
56
|
+
normalized = "---";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
output.push(normalized);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return output.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderMarkdownForTerminal(markdown: string): string {
|
|
66
|
+
const source = normalizeMarkdownForRender(markdown);
|
|
67
|
+
if (!source.trim()) return "";
|
|
68
|
+
if (!MARKDOWN_RENDER_ENABLED) return source;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const rendered = cliMarkdown(source, {
|
|
72
|
+
width: MARKDOWN_RENDER_WIDTH,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return typeof rendered === "string" ? rendered.trimEnd() : source;
|
|
76
|
+
} catch {
|
|
77
|
+
return source;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function printUsage(): void {
|
|
82
|
+
console.log(`YAGAMI (TS runtime)
|
|
83
|
+
|
|
84
|
+
Usage:
|
|
85
|
+
yagami start
|
|
86
|
+
yagami stop
|
|
87
|
+
yagami status [--cache] [--limit N] [--tokens] [--json]
|
|
88
|
+
yagami reload [--json]
|
|
89
|
+
yagami doctor [--json]
|
|
90
|
+
yagami theme [preview] [--json]
|
|
91
|
+
yagami config path [--json]
|
|
92
|
+
yagami config show [--json]
|
|
93
|
+
yagami config get <key> [--json]
|
|
94
|
+
yagami config set <key> <value> [--json-value] [--json]
|
|
95
|
+
yagami config unset <key> [--json]
|
|
96
|
+
yagami search <text> [--json] [--profile]
|
|
97
|
+
yagami search-advanced <query> [--json] [--profile]
|
|
98
|
+
yagami code <query> [--json] [--profile]
|
|
99
|
+
yagami company <name> [--json] [--profile]
|
|
100
|
+
yagami similar <url> [--json] [--profile]
|
|
101
|
+
yagami prompt [search|code|company|similar|deep]
|
|
102
|
+
yagami fetch <url> [--max-chars N] [--no-cache] [--json]
|
|
103
|
+
yagami deep start <instructions> [--effort fast|balanced|thorough] [--json]
|
|
104
|
+
yagami deep check <researchId> [--json]
|
|
105
|
+
yagami <text> # shorthand for search
|
|
106
|
+
|
|
107
|
+
Notes:
|
|
108
|
+
- all primary commands are served by TS-native CLI paths.
|
|
109
|
+
- search-family commands support both JSON and NDJSON live-stream rendering in TS.
|
|
110
|
+
- search commands return source records. Use 'deep start' for synthesized reports.
|
|
111
|
+
|
|
112
|
+
Environment:
|
|
113
|
+
YAGAMI_LLM_API (default: ${config.llmApi || "openai-completions"})
|
|
114
|
+
YAGAMI_LLM_BASE_URL (default: ${config.llmBaseUrl})
|
|
115
|
+
YAGAMI_LLM_API_KEY (default: ${config.llmApiKey || ""})
|
|
116
|
+
YAGAMI_LLM_MODEL (optional)
|
|
117
|
+
YAGAMI_SEARCH_ENGINE (default: ${config.searchEngine})
|
|
118
|
+
YAGAMI_SEARCH_ENGINE_URL_TEMPLATE (optional; use {query} placeholder)
|
|
119
|
+
YAGAMI_CDP_URL (default: ${config.lightpandaCdpUrl})
|
|
120
|
+
YAGAMI_BROWSE_LINK_TIMEOUT_MS (default: ${config.browseLinkTimeoutMs})
|
|
121
|
+
YAGAMI_HOST (default: ${config.host})
|
|
122
|
+
YAGAMI_PORT (default: ${config.port})
|
|
123
|
+
`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function delay(ms: number): Promise<void> {
|
|
127
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseHostPortFromUrl(rawUrl: string, fallbackPort: number): { host: string; port: number } {
|
|
131
|
+
try {
|
|
132
|
+
const url = new URL(rawUrl);
|
|
133
|
+
const host = url.hostname || "127.0.0.1";
|
|
134
|
+
const port = url.port ? Number.parseInt(url.port, 10) : fallbackPort;
|
|
135
|
+
return { host, port: Number.isFinite(port) ? port : fallbackPort };
|
|
136
|
+
} catch {
|
|
137
|
+
return { host: "127.0.0.1", port: fallbackPort };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function checkTcpPort(host: string, port: number, timeoutMs = 1500): Promise<{ ok: boolean; error?: string }> {
|
|
142
|
+
return await new Promise((resolve) => {
|
|
143
|
+
const socket = new net.Socket();
|
|
144
|
+
let settled = false;
|
|
145
|
+
|
|
146
|
+
const finish = (result: { ok: boolean; error?: string }) => {
|
|
147
|
+
if (settled) return;
|
|
148
|
+
settled = true;
|
|
149
|
+
socket.destroy();
|
|
150
|
+
resolve(result);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
socket.setTimeout(timeoutMs);
|
|
154
|
+
socket.once("connect", () => finish({ ok: true }));
|
|
155
|
+
socket.once("timeout", () => finish({ ok: false, error: `timeout after ${timeoutMs}ms` }));
|
|
156
|
+
socket.once("error", (error) => finish({ ok: false, error: error?.message || String(error) }));
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
socket.connect(port, host);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
finish({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeLlmApi(value: unknown): "anthropic-messages" | "openai-completions" {
|
|
167
|
+
const normalized = String(value ?? "")
|
|
168
|
+
.trim()
|
|
169
|
+
.toLowerCase();
|
|
170
|
+
if (normalized === "anthropic-messages") return "anthropic-messages";
|
|
171
|
+
return "openai-completions";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resolveRuntimeApiKey(api: "anthropic-messages" | "openai-completions", value: unknown): string {
|
|
175
|
+
const key = String(value ?? "").trim();
|
|
176
|
+
if (key) return key;
|
|
177
|
+
return api === "anthropic-messages" ? "local" : "none";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function joinUrl(baseUrl: string, pathname: string): string {
|
|
181
|
+
const left = String(baseUrl || "").replace(/\/+$/, "");
|
|
182
|
+
const right = String(pathname || "").replace(/^\/+/, "");
|
|
183
|
+
if (!left) return `/${right}`;
|
|
184
|
+
if (!right) return left;
|
|
185
|
+
return `${left}/${right}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function anthropicModelsUrl(baseUrl: string): string {
|
|
189
|
+
const trimmed = String(baseUrl || "").replace(/\/+$/, "");
|
|
190
|
+
if (/\/v1$/i.test(trimmed)) return joinUrl(trimmed, "models");
|
|
191
|
+
return joinUrl(trimmed, "v1/models");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function checkModelEndpoint(): Promise<{
|
|
195
|
+
ok: boolean;
|
|
196
|
+
api: string;
|
|
197
|
+
baseUrl: string;
|
|
198
|
+
model?: string | null;
|
|
199
|
+
modelsCount?: number;
|
|
200
|
+
error?: string;
|
|
201
|
+
}> {
|
|
202
|
+
const api = normalizeLlmApi(config.llmApi);
|
|
203
|
+
const baseUrl = config.llmBaseUrl;
|
|
204
|
+
|
|
205
|
+
const endpoint = api === "anthropic-messages" ? anthropicModelsUrl(baseUrl) : joinUrl(baseUrl, "models");
|
|
206
|
+
const headers =
|
|
207
|
+
api === "anthropic-messages"
|
|
208
|
+
? {
|
|
209
|
+
"x-api-key": resolveRuntimeApiKey(api, config.llmApiKey),
|
|
210
|
+
"anthropic-version": "2023-06-01",
|
|
211
|
+
}
|
|
212
|
+
: {
|
|
213
|
+
authorization: `Bearer ${resolveRuntimeApiKey(api, config.llmApiKey)}`,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const controller = new AbortController();
|
|
217
|
+
const timer = setTimeout(() => controller.abort(), 3000);
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const response = await fetch(endpoint, {
|
|
221
|
+
method: "GET",
|
|
222
|
+
signal: controller.signal,
|
|
223
|
+
headers,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
const text = await response.text();
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
api,
|
|
231
|
+
baseUrl,
|
|
232
|
+
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const payload = (await response.json()) as { data?: Array<{ id?: string }> };
|
|
237
|
+
const firstModel = payload?.data?.[0]?.id || null;
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
ok: true,
|
|
241
|
+
api,
|
|
242
|
+
baseUrl,
|
|
243
|
+
model: firstModel,
|
|
244
|
+
modelsCount: Array.isArray(payload?.data) ? payload.data.length : 0,
|
|
245
|
+
};
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
api,
|
|
250
|
+
baseUrl,
|
|
251
|
+
error: error instanceof Error ? error.message : String(error),
|
|
252
|
+
};
|
|
253
|
+
} finally {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isProcessAlive(pid: number | null): boolean {
|
|
259
|
+
if (!pid) return false;
|
|
260
|
+
try {
|
|
261
|
+
process.kill(pid, 0);
|
|
262
|
+
return true;
|
|
263
|
+
} catch {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function readPid(): Promise<number | null> {
|
|
269
|
+
try {
|
|
270
|
+
const raw = await fsp.readFile(config.pidFile, "utf8");
|
|
271
|
+
const pid = Number.parseInt(raw.trim(), 10);
|
|
272
|
+
return Number.isFinite(pid) ? pid : null;
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function ensureRuntimeDir(): Promise<void> {
|
|
279
|
+
await fsp.mkdir(config.runtimeDir, { recursive: true });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function daemonRequest(
|
|
283
|
+
pathname: string,
|
|
284
|
+
options: {
|
|
285
|
+
method?: string;
|
|
286
|
+
body?: Record<string, unknown>;
|
|
287
|
+
timeoutMs?: number;
|
|
288
|
+
} = {},
|
|
289
|
+
): Promise<{ ok: boolean; status: number; json: Record<string, unknown> }> {
|
|
290
|
+
const method = options.method || "GET";
|
|
291
|
+
const timeoutMs = options.timeoutMs ?? 15000;
|
|
292
|
+
|
|
293
|
+
const controller = new AbortController();
|
|
294
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const response = await fetch(`${config.daemonUrl}${pathname}`, {
|
|
298
|
+
method,
|
|
299
|
+
signal: controller.signal,
|
|
300
|
+
headers: {
|
|
301
|
+
"content-type": "application/json",
|
|
302
|
+
},
|
|
303
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const text = await response.text();
|
|
307
|
+
let json: Record<string, unknown>;
|
|
308
|
+
try {
|
|
309
|
+
json = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
310
|
+
} catch {
|
|
311
|
+
json = { raw: text };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
ok: response.ok,
|
|
316
|
+
status: response.status,
|
|
317
|
+
json,
|
|
318
|
+
};
|
|
319
|
+
} finally {
|
|
320
|
+
clearTimeout(timer);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function checkHealth(): Promise<Record<string, unknown> | null> {
|
|
325
|
+
try {
|
|
326
|
+
const response = await daemonRequest("/health", { timeoutMs: 2000 });
|
|
327
|
+
if (!response.ok) return null;
|
|
328
|
+
return response.json;
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function waitForHealthy(timeoutMs = 15000): Promise<Record<string, unknown> | null> {
|
|
335
|
+
const started = Date.now();
|
|
336
|
+
while (Date.now() - started < timeoutMs) {
|
|
337
|
+
const health = await checkHealth();
|
|
338
|
+
if (health?.ok) return health;
|
|
339
|
+
await delay(250);
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function parseCliArgs(args: string[]): { positional: string[]; flags: Record<string, unknown> } {
|
|
345
|
+
const positional: string[] = [];
|
|
346
|
+
const flags: Record<string, unknown> = {};
|
|
347
|
+
|
|
348
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
349
|
+
const token = args[i] || "";
|
|
350
|
+
|
|
351
|
+
if (!token.startsWith("--")) {
|
|
352
|
+
positional.push(token);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const withoutPrefix = token.slice(2);
|
|
357
|
+
if (!withoutPrefix) continue;
|
|
358
|
+
|
|
359
|
+
const eqIndex = withoutPrefix.indexOf("=");
|
|
360
|
+
if (eqIndex >= 0) {
|
|
361
|
+
const key = withoutPrefix.slice(0, eqIndex);
|
|
362
|
+
const value = withoutPrefix.slice(eqIndex + 1);
|
|
363
|
+
if (!key) continue;
|
|
364
|
+
if (flags[key] === undefined) flags[key] = value;
|
|
365
|
+
else if (Array.isArray(flags[key])) (flags[key] as unknown[]).push(value);
|
|
366
|
+
else flags[key] = [flags[key], value];
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const key = withoutPrefix;
|
|
371
|
+
const next = args[i + 1];
|
|
372
|
+
const hasValue = next !== undefined && !String(next).startsWith("--");
|
|
373
|
+
const value: unknown = hasValue ? next : true;
|
|
374
|
+
|
|
375
|
+
if (flags[key] === undefined) flags[key] = value;
|
|
376
|
+
else if (Array.isArray(flags[key])) (flags[key] as unknown[]).push(value);
|
|
377
|
+
else flags[key] = [flags[key], value];
|
|
378
|
+
|
|
379
|
+
if (hasValue) i += 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return { positional, flags };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function getFlagValue(flags: Record<string, unknown>, key: string, fallback: unknown = undefined): unknown {
|
|
386
|
+
const value = flags[key];
|
|
387
|
+
if (value === undefined) return fallback;
|
|
388
|
+
if (Array.isArray(value)) return value[value.length - 1];
|
|
389
|
+
return value;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function getIntFlag(flags: Record<string, unknown>, key: string, fallback: number | undefined): number | undefined {
|
|
393
|
+
const value = getFlagValue(flags, key, fallback);
|
|
394
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
395
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
396
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function getBoolFlag(flags: Record<string, unknown>, key: string, fallback = false): boolean {
|
|
400
|
+
const value = getFlagValue(flags, key, undefined);
|
|
401
|
+
if (value === undefined) return fallback;
|
|
402
|
+
if (typeof value === "boolean") return value;
|
|
403
|
+
|
|
404
|
+
const normalized = String(value).trim().toLowerCase();
|
|
405
|
+
if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
|
|
406
|
+
if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
|
|
407
|
+
return fallback;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function getListFlag(flags: Record<string, unknown>, key: string): string[] {
|
|
411
|
+
const value = flags[key];
|
|
412
|
+
if (value === undefined || value === null) return [];
|
|
413
|
+
|
|
414
|
+
const values = Array.isArray(value) ? value : [value];
|
|
415
|
+
const output: string[] = [];
|
|
416
|
+
|
|
417
|
+
for (const entry of values) {
|
|
418
|
+
if (typeof entry !== "string") continue;
|
|
419
|
+
for (const part of entry.split(",")) {
|
|
420
|
+
const item = part.trim();
|
|
421
|
+
if (item) output.push(item);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return Array.from(new Set(output));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function readStdinIfAvailable(): Promise<string> {
|
|
429
|
+
if (process.stdin.isTTY) return "";
|
|
430
|
+
|
|
431
|
+
const chunks: Buffer[] = [];
|
|
432
|
+
for await (const chunk of process.stdin) {
|
|
433
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function omitUndefined<T extends Record<string, unknown>>(value: T): T {
|
|
440
|
+
const output: Record<string, unknown> = {};
|
|
441
|
+
for (const [key, item] of Object.entries(value)) {
|
|
442
|
+
if (item !== undefined) output[key] = item;
|
|
443
|
+
}
|
|
444
|
+
return output as T;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
448
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function parseConfigKeyPath(raw: unknown): string[] {
|
|
452
|
+
const key = String(raw || "").trim();
|
|
453
|
+
if (!key) throw new Error("config key is required");
|
|
454
|
+
|
|
455
|
+
const parts = key.split(".").map((part) => part.trim());
|
|
456
|
+
if (parts.length === 0 || parts.some((part) => !part)) {
|
|
457
|
+
throw new Error(`invalid config key path: ${key}`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return parts;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function getValueAtKeyPath(source: Record<string, unknown>, pathParts: string[]): unknown {
|
|
464
|
+
let cursor: unknown = source;
|
|
465
|
+
|
|
466
|
+
for (const part of pathParts) {
|
|
467
|
+
if (!isPlainObject(cursor)) return undefined;
|
|
468
|
+
cursor = cursor[part];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return cursor;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function setValueAtKeyPath(target: Record<string, unknown>, pathParts: string[], value: unknown): void {
|
|
475
|
+
if (pathParts.length === 0) return;
|
|
476
|
+
|
|
477
|
+
let cursor: Record<string, unknown> = target;
|
|
478
|
+
|
|
479
|
+
for (let index = 0; index < pathParts.length - 1; index += 1) {
|
|
480
|
+
const segment = pathParts[index];
|
|
481
|
+
const next = cursor[segment];
|
|
482
|
+
|
|
483
|
+
if (!isPlainObject(next)) {
|
|
484
|
+
cursor[segment] = {};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
cursor = cursor[segment] as Record<string, unknown>;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
cursor[pathParts[pathParts.length - 1]] = value;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function unsetValueAtKeyPath(target: Record<string, unknown>, pathParts: string[]): boolean {
|
|
494
|
+
if (pathParts.length === 0) return false;
|
|
495
|
+
|
|
496
|
+
const stack: Array<{ parent: Record<string, unknown>; key: string }> = [];
|
|
497
|
+
let cursor: Record<string, unknown> = target;
|
|
498
|
+
|
|
499
|
+
for (let index = 0; index < pathParts.length - 1; index += 1) {
|
|
500
|
+
const segment = pathParts[index];
|
|
501
|
+
const next = cursor[segment];
|
|
502
|
+
if (!isPlainObject(next)) return false;
|
|
503
|
+
|
|
504
|
+
stack.push({ parent: cursor, key: segment });
|
|
505
|
+
cursor = next;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const leaf = pathParts[pathParts.length - 1];
|
|
509
|
+
if (!(leaf in cursor)) return false;
|
|
510
|
+
|
|
511
|
+
delete cursor[leaf];
|
|
512
|
+
|
|
513
|
+
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
|
514
|
+
const { parent, key } = stack[index];
|
|
515
|
+
const value = parent[key];
|
|
516
|
+
|
|
517
|
+
if (isPlainObject(value) && Object.keys(value).length === 0) {
|
|
518
|
+
delete parent[key];
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function readConfigFileObject(configFile: string): Promise<Record<string, unknown>> {
|
|
529
|
+
try {
|
|
530
|
+
const raw = await fsp.readFile(configFile, "utf8");
|
|
531
|
+
if (!raw.trim()) return {};
|
|
532
|
+
|
|
533
|
+
const parsed: unknown = JSON.parse(raw);
|
|
534
|
+
if (!isPlainObject(parsed)) {
|
|
535
|
+
throw new Error(`config file must contain a JSON object: ${configFile}`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return parsed;
|
|
539
|
+
} catch (error) {
|
|
540
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
541
|
+
return {};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (error instanceof SyntaxError) {
|
|
545
|
+
throw new Error(`invalid JSON in config file: ${configFile}`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function writeConfigFileObject(configFile: string, value: Record<string, unknown>): Promise<void> {
|
|
553
|
+
await fsp.mkdir(path.dirname(configFile), { recursive: true });
|
|
554
|
+
await fsp.writeFile(configFile, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function formatConfigValue(value: unknown): string {
|
|
558
|
+
if (typeof value === "string") return value;
|
|
559
|
+
return JSON.stringify(value, null, 2);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function ensureDaemonRunning(): Promise<Record<string, unknown> | null> {
|
|
563
|
+
const health = await checkHealth();
|
|
564
|
+
if (!health?.ok) {
|
|
565
|
+
console.error("yagami is not running. Start it first: yagami start");
|
|
566
|
+
process.exitCode = 1;
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
return health;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function runCommandRequest(
|
|
573
|
+
pathname: string,
|
|
574
|
+
body: Record<string, unknown>,
|
|
575
|
+
timeoutMs = config.queryTimeoutMs + 5000,
|
|
576
|
+
): Promise<Record<string, unknown>> {
|
|
577
|
+
const response = await daemonRequest(pathname, {
|
|
578
|
+
method: "POST",
|
|
579
|
+
body,
|
|
580
|
+
timeoutMs,
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
if (!response.ok || !response.json?.ok) {
|
|
584
|
+
throw new Error(String(response.json?.error || `HTTP ${response.status}`));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return (response.json.result as Record<string, unknown>) || {};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function cmdStart(): Promise<void> {
|
|
591
|
+
await ensureRuntimeDir();
|
|
592
|
+
|
|
593
|
+
const health = await checkHealth();
|
|
594
|
+
if (health?.ok) {
|
|
595
|
+
console.log(`yagami is already running on ${config.daemonUrl} (pid ${health.pid})`);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const outFd = fs.openSync(config.logFile, "a");
|
|
600
|
+
const daemonPath = path.join(__dirname, "daemon.js");
|
|
601
|
+
|
|
602
|
+
const child = spawn(process.execPath, [daemonPath], {
|
|
603
|
+
detached: true,
|
|
604
|
+
stdio: ["ignore", outFd, outFd],
|
|
605
|
+
env: { ...process.env },
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
child.unref();
|
|
609
|
+
fs.closeSync(outFd);
|
|
610
|
+
|
|
611
|
+
const ready = await waitForHealthy(20000);
|
|
612
|
+
if (!ready) {
|
|
613
|
+
console.error(`failed to start yagami daemon. See log: ${config.logFile}`);
|
|
614
|
+
process.exitCode = 1;
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
console.log(`yagami started (pid ${ready.pid})`);
|
|
619
|
+
console.log(`daemon: ${config.daemonUrl}`);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function cmdStop(): Promise<void> {
|
|
623
|
+
const pid = await readPid();
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
await daemonRequest("/stop", { method: "POST", timeoutMs: 2000 });
|
|
627
|
+
} catch {
|
|
628
|
+
// noop
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const started = Date.now();
|
|
632
|
+
while (Date.now() - started < 5000) {
|
|
633
|
+
const health = await checkHealth();
|
|
634
|
+
if (!health?.ok) break;
|
|
635
|
+
await delay(150);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (pid && isProcessAlive(pid)) {
|
|
639
|
+
try {
|
|
640
|
+
process.kill(pid, "SIGTERM");
|
|
641
|
+
} catch {
|
|
642
|
+
// ignore
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
await delay(500);
|
|
646
|
+
|
|
647
|
+
if (isProcessAlive(pid)) {
|
|
648
|
+
try {
|
|
649
|
+
process.kill(pid, "SIGKILL");
|
|
650
|
+
} catch {
|
|
651
|
+
// ignore
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (fs.existsSync(config.pidFile)) {
|
|
657
|
+
await fsp.unlink(config.pidFile).catch(() => {});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
console.log("yagami stopped");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function cmdReload(asJson: boolean): Promise<void> {
|
|
664
|
+
const health = await ensureDaemonRunning();
|
|
665
|
+
if (!health) return;
|
|
666
|
+
|
|
667
|
+
try {
|
|
668
|
+
const response = await daemonRequest("/reload", {
|
|
669
|
+
method: "POST",
|
|
670
|
+
body: {},
|
|
671
|
+
timeoutMs: Math.max(config.queryTimeoutMs + 5000, 30000),
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
if (!response.ok || !response.json?.ok) {
|
|
675
|
+
throw new Error(String(response.json?.error || `HTTP ${response.status}`));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const result = (response.json.result as Record<string, unknown>) || {};
|
|
679
|
+
|
|
680
|
+
if (asJson) {
|
|
681
|
+
console.log(
|
|
682
|
+
JSON.stringify(
|
|
683
|
+
{
|
|
684
|
+
ok: true,
|
|
685
|
+
...result,
|
|
686
|
+
},
|
|
687
|
+
null,
|
|
688
|
+
2,
|
|
689
|
+
),
|
|
690
|
+
);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const message = String(result.message || "reloaded runtime settings").trim();
|
|
695
|
+
const applied = ((result.applied as Record<string, unknown> | undefined) || {}) as Record<string, unknown>;
|
|
696
|
+
const restartOnlyChanges = Array.isArray(result.restartOnlyChanges)
|
|
697
|
+
? result.restartOnlyChanges.map((value) => String(value || "")).filter(Boolean)
|
|
698
|
+
: [];
|
|
699
|
+
|
|
700
|
+
console.log(message);
|
|
701
|
+
|
|
702
|
+
if (Object.keys(applied).length > 0) {
|
|
703
|
+
console.log(
|
|
704
|
+
`active: llm=${String(applied.llmApi || "-")} ${String(applied.llmBaseUrl || "-")} · search=${String(applied.searchEngine || "duckduckgo")}`,
|
|
705
|
+
);
|
|
706
|
+
console.log(
|
|
707
|
+
`timeouts: browseLink=${String(applied.browseLinkTimeoutMs || "-")}ms query=${String(applied.queryTimeoutMs || "-")}ms`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (restartOnlyChanges.length > 0) {
|
|
712
|
+
console.log(`restart required for: ${restartOnlyChanges.join(", ")}`);
|
|
713
|
+
}
|
|
714
|
+
} catch (error) {
|
|
715
|
+
console.error(`reload failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
716
|
+
process.exitCode = 1;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function formatCompactDuration(ms: number): string {
|
|
721
|
+
const value = Math.max(0, Number(ms || 0));
|
|
722
|
+
if (value < 1000) return `${value}ms`;
|
|
723
|
+
|
|
724
|
+
const sec = value / 1000;
|
|
725
|
+
if (sec < 60) return `${sec.toFixed(sec < 10 ? 1 : 0)}s`;
|
|
726
|
+
|
|
727
|
+
const min = sec / 60;
|
|
728
|
+
if (min < 60) return `${min.toFixed(min < 10 ? 1 : 0)}m`;
|
|
729
|
+
|
|
730
|
+
const hr = min / 60;
|
|
731
|
+
return `${hr.toFixed(hr < 10 ? 1 : 0)}h`;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function cmdStatus(args: string[], asJson: boolean): Promise<void> {
|
|
735
|
+
const { flags } = parseCliArgs(args);
|
|
736
|
+
const showCache = getBoolFlag(flags, "cache", false);
|
|
737
|
+
const showTokens = getBoolFlag(flags, "tokens", getBoolFlag(flags, "token", false));
|
|
738
|
+
const cacheLimit = getIntFlag(flags, "limit", 20) ?? 20;
|
|
739
|
+
|
|
740
|
+
const needsExtendedStats = showCache || showTokens;
|
|
741
|
+
|
|
742
|
+
let health = await checkHealth();
|
|
743
|
+
if (needsExtendedStats && health?.ok) {
|
|
744
|
+
try {
|
|
745
|
+
const response = await daemonRequest("/stats", {
|
|
746
|
+
method: "POST",
|
|
747
|
+
body: {
|
|
748
|
+
includeCacheEntries: showCache,
|
|
749
|
+
cacheEntriesLimit: cacheLimit,
|
|
750
|
+
},
|
|
751
|
+
timeoutMs: 5000,
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
if (response.ok && response.json?.result && typeof response.json.result === "object") {
|
|
755
|
+
health = {
|
|
756
|
+
...health,
|
|
757
|
+
...(response.json.result as Record<string, unknown>),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
} catch {
|
|
761
|
+
// fall back to regular health snapshot
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const pid = await readPid();
|
|
766
|
+
|
|
767
|
+
if (asJson) {
|
|
768
|
+
console.log(
|
|
769
|
+
JSON.stringify(
|
|
770
|
+
{
|
|
771
|
+
running: Boolean(health?.ok),
|
|
772
|
+
pid: health?.pid ?? pid ?? null,
|
|
773
|
+
daemonUrl: config.daemonUrl,
|
|
774
|
+
config: {
|
|
775
|
+
theme: config.theme,
|
|
776
|
+
configFile: config.configFile,
|
|
777
|
+
themeTokens: config.themeTokens || {},
|
|
778
|
+
},
|
|
779
|
+
health: health || null,
|
|
780
|
+
},
|
|
781
|
+
null,
|
|
782
|
+
2,
|
|
783
|
+
),
|
|
784
|
+
);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (health?.ok) {
|
|
789
|
+
console.log(`yagami is running (pid ${health.pid})`);
|
|
790
|
+
console.log(`daemon: ${config.daemonUrl}`);
|
|
791
|
+
console.log(`model: ${health.model}`);
|
|
792
|
+
console.log(
|
|
793
|
+
`queries: ${health.queries} (active: ${health.activeQueries ?? 0}), cache hit/miss: ${health.cacheHits}/${health.cacheMisses}`,
|
|
794
|
+
);
|
|
795
|
+
console.log(
|
|
796
|
+
`lightpanda: managed=${health.lightpandaManaged} pid=${health.lightpandaManagedPid ?? "-"} autoStart=${health.lightpandaAutoStart}`,
|
|
797
|
+
);
|
|
798
|
+
console.log(
|
|
799
|
+
`research policy: maxPages=${health.researchMaxPages} maxHops=${health.researchMaxHops} sameDomainOnly=${health.researchSameDomainOnly}`,
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
const browseLinkTimeoutMs = Number(health.browseLinkTimeoutMs || config.browseLinkTimeoutMs || 0);
|
|
803
|
+
const queryTimeoutMs = Number(health.queryTimeoutMs || config.queryTimeoutMs || 0);
|
|
804
|
+
const cacheTtlMs = Number(health.cacheTtlMs || config.cacheTtlMs || 0);
|
|
805
|
+
const maxHtmlChars = Number(health.maxHtmlChars || config.maxHtmlChars || 0);
|
|
806
|
+
const maxMarkdownChars = Number(health.maxMarkdownChars || config.maxMarkdownChars || 0);
|
|
807
|
+
console.log(`timeouts: browseLink=${browseLinkTimeoutMs}ms query=${queryTimeoutMs}ms`);
|
|
808
|
+
console.log(`cache: ttl=${cacheTtlMs}ms`);
|
|
809
|
+
console.log(`content limits: html=${maxHtmlChars} markdown=${maxMarkdownChars}`);
|
|
810
|
+
|
|
811
|
+
const customThemeTokens =
|
|
812
|
+
config.themeTokens && typeof config.themeTokens === "object" ? Object.keys(config.themeTokens).length : 0;
|
|
813
|
+
|
|
814
|
+
const operationConcurrency = Number(health.operationConcurrency || config.operationConcurrency || 1);
|
|
815
|
+
const browseConcurrency = Number(health.browseConcurrency || config.browseConcurrency || 1);
|
|
816
|
+
const opActive = Number(health.operationSlotsActive || 0);
|
|
817
|
+
const opPending = Number(health.operationSlotsPending || 0);
|
|
818
|
+
const browseActive = Number(health.browseSlotsActive || 0);
|
|
819
|
+
const browsePending = Number(health.browseSlotsPending || 0);
|
|
820
|
+
|
|
821
|
+
console.log(
|
|
822
|
+
`concurrency: operations=${operationConcurrency} (active=${opActive}, pending=${opPending}) · browse=${browseConcurrency} (active=${browseActive}, pending=${browsePending})`,
|
|
823
|
+
);
|
|
824
|
+
console.log(`tool execution: ${health.toolExecutionMode}`);
|
|
825
|
+
console.log(`theme: ${config.theme}${customThemeTokens > 0 ? ` (custom tokens: ${customThemeTokens})` : ""}`);
|
|
826
|
+
console.log(`config file: ${config.configFile}`);
|
|
827
|
+
console.log(`uptime: ${health.uptimeSec}s`);
|
|
828
|
+
|
|
829
|
+
if (showTokens) {
|
|
830
|
+
const tokens =
|
|
831
|
+
(health.tokens && typeof health.tokens === "object" ? (health.tokens as Record<string, unknown>) : {}) || {};
|
|
832
|
+
const tokenCost =
|
|
833
|
+
(tokens.cost && typeof tokens.cost === "object" ? (tokens.cost as Record<string, unknown>) : {}) || {};
|
|
834
|
+
|
|
835
|
+
const tokenInput = Number(tokens.input || 0);
|
|
836
|
+
const tokenOutput = Number(tokens.output || 0);
|
|
837
|
+
const tokenCacheRead = Number(tokens.cacheRead || 0);
|
|
838
|
+
const tokenCacheWrite = Number(tokens.cacheWrite || 0);
|
|
839
|
+
const tokenTotal = Number(tokens.total || 0);
|
|
840
|
+
const avgPerQuery = Number(tokens.avgPerQuery || 0);
|
|
841
|
+
const costTotal = Number(tokenCost.total || 0);
|
|
842
|
+
|
|
843
|
+
console.log(
|
|
844
|
+
`tokens: in=${tokenInput} out=${tokenOutput} cacheRead=${tokenCacheRead} cacheWrite=${tokenCacheWrite} total=${tokenTotal} avg/query=${avgPerQuery}`,
|
|
845
|
+
);
|
|
846
|
+
console.log(`token cost: total=${costTotal.toFixed(6)}`);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (showCache) {
|
|
850
|
+
const cacheEntries = Array.isArray(health.cacheEntries)
|
|
851
|
+
? (health.cacheEntries as Array<Record<string, unknown>>)
|
|
852
|
+
: [];
|
|
853
|
+
|
|
854
|
+
if (cacheEntries.length === 0) {
|
|
855
|
+
console.log(`cache entries: 0`);
|
|
856
|
+
} else {
|
|
857
|
+
console.log(`cache entries (showing ${cacheEntries.length}, limit=${cacheLimit}):`);
|
|
858
|
+
for (const entry of cacheEntries) {
|
|
859
|
+
const url = String(entry.url || "");
|
|
860
|
+
const domain = domainFromUrl(url);
|
|
861
|
+
const title = compactTitle(String(entry.title || ""));
|
|
862
|
+
const ttl = formatCompactDuration(Number(entry.ttlMs || 0));
|
|
863
|
+
const age = formatCompactDuration(Number(entry.ageMs || 0));
|
|
864
|
+
const bytes = Number(entry.bytes || 0);
|
|
865
|
+
const titlePart = title ? ` · ${title}` : "";
|
|
866
|
+
console.log(` - ${domain} · ttl=${ttl} · age=${age} · bytes=${bytes}${titlePart}`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (pid && isProcessAlive(pid)) {
|
|
875
|
+
console.log(`yagami process exists (pid ${pid}) but health check failed`);
|
|
876
|
+
console.log(`check logs: ${config.logFile}`);
|
|
877
|
+
} else {
|
|
878
|
+
console.log("yagami is not running");
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function cmdDoctor(asJson: boolean): Promise<void> {
|
|
883
|
+
const daemon = await checkHealth();
|
|
884
|
+
const pid = await readPid();
|
|
885
|
+
const modelEndpoint = await checkModelEndpoint();
|
|
886
|
+
const cdpEndpoint = parseHostPortFromUrl(config.lightpandaCdpUrl, config.lightpandaPort || 9222);
|
|
887
|
+
const cdp = await checkTcpPort(cdpEndpoint.host, cdpEndpoint.port, 2000);
|
|
888
|
+
|
|
889
|
+
const llmReport = {
|
|
890
|
+
ok: modelEndpoint.ok,
|
|
891
|
+
api: modelEndpoint.api,
|
|
892
|
+
baseUrl: modelEndpoint.baseUrl,
|
|
893
|
+
model: modelEndpoint.model || null,
|
|
894
|
+
modelsCount: modelEndpoint.modelsCount || 0,
|
|
895
|
+
error: modelEndpoint.error || null,
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
const report = {
|
|
899
|
+
daemon: {
|
|
900
|
+
ok: Boolean(daemon?.ok),
|
|
901
|
+
pid: daemon?.pid ?? pid ?? null,
|
|
902
|
+
url: config.daemonUrl,
|
|
903
|
+
},
|
|
904
|
+
llm: llmReport,
|
|
905
|
+
lightpanda: {
|
|
906
|
+
ok: cdp.ok,
|
|
907
|
+
cdpUrl: config.lightpandaCdpUrl,
|
|
908
|
+
host: cdpEndpoint.host,
|
|
909
|
+
port: cdpEndpoint.port,
|
|
910
|
+
autoStart: config.lightpandaAutoStart,
|
|
911
|
+
error: cdp.error || null,
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
if (asJson) {
|
|
916
|
+
console.log(JSON.stringify(report, null, 2));
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const pass = (ok: boolean): string => (ok ? theme.icon("pass") : theme.icon("fail"));
|
|
921
|
+
|
|
922
|
+
console.log(
|
|
923
|
+
`Daemon ${pass(report.daemon.ok)} ${report.daemon.url} ${report.daemon.pid ? `(pid ${report.daemon.pid})` : ""}`.trim(),
|
|
924
|
+
);
|
|
925
|
+
const apiLabel = String(report.llm.api || "openai-completions");
|
|
926
|
+
console.log(`LLM ${pass(report.llm.ok)} ${report.llm.baseUrl} (${apiLabel})`);
|
|
927
|
+
|
|
928
|
+
if (report.llm.ok) {
|
|
929
|
+
console.log(` model: ${report.llm.model || "unknown"} (${report.llm.modelsCount} available)`);
|
|
930
|
+
} else {
|
|
931
|
+
console.log(` error: ${report.llm.error}`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
console.log(`Lightpanda ${pass(report.lightpanda.ok)} ${report.lightpanda.cdpUrl}`);
|
|
935
|
+
if (!report.lightpanda.ok) {
|
|
936
|
+
console.log(` error: ${report.lightpanda.error}`);
|
|
937
|
+
console.log(` auto-start is ${report.lightpanda.autoStart ? "enabled" : "disabled"}`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function cmdConfig(args: string[], asJson: boolean): Promise<void> {
|
|
942
|
+
const runtimeConfig = getConfig();
|
|
943
|
+
const configFile = runtimeConfig.configFile;
|
|
944
|
+
|
|
945
|
+
const { positional, flags } = parseCliArgs(args);
|
|
946
|
+
const action = String(positional[0] || "show")
|
|
947
|
+
.trim()
|
|
948
|
+
.toLowerCase();
|
|
949
|
+
|
|
950
|
+
if (action === "path") {
|
|
951
|
+
if (asJson) {
|
|
952
|
+
console.log(JSON.stringify({ configFile }, null, 2));
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
console.log(configFile);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (action === "show" || action === "list") {
|
|
961
|
+
const values = await readConfigFileObject(configFile);
|
|
962
|
+
|
|
963
|
+
if (asJson) {
|
|
964
|
+
console.log(
|
|
965
|
+
JSON.stringify(
|
|
966
|
+
{
|
|
967
|
+
configFile,
|
|
968
|
+
values,
|
|
969
|
+
},
|
|
970
|
+
null,
|
|
971
|
+
2,
|
|
972
|
+
),
|
|
973
|
+
);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
console.log(JSON.stringify(values, null, 2));
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (action === "get") {
|
|
982
|
+
const key = String(positional[1] || "").trim();
|
|
983
|
+
if (!key) {
|
|
984
|
+
console.error("missing config key\n");
|
|
985
|
+
printUsage();
|
|
986
|
+
process.exitCode = 1;
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const pathParts = parseConfigKeyPath(key);
|
|
991
|
+
const values = await readConfigFileObject(configFile);
|
|
992
|
+
const value = getValueAtKeyPath(values, pathParts);
|
|
993
|
+
|
|
994
|
+
if (value === undefined) {
|
|
995
|
+
console.error(`config key not found: ${key}`);
|
|
996
|
+
process.exitCode = 1;
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
if (asJson) {
|
|
1001
|
+
console.log(
|
|
1002
|
+
JSON.stringify(
|
|
1003
|
+
{
|
|
1004
|
+
configFile,
|
|
1005
|
+
key,
|
|
1006
|
+
value,
|
|
1007
|
+
},
|
|
1008
|
+
null,
|
|
1009
|
+
2,
|
|
1010
|
+
),
|
|
1011
|
+
);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
console.log(formatConfigValue(value));
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (action === "set") {
|
|
1020
|
+
const key = String(positional[1] || "").trim();
|
|
1021
|
+
if (!key) {
|
|
1022
|
+
console.error("missing config key\n");
|
|
1023
|
+
printUsage();
|
|
1024
|
+
process.exitCode = 1;
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (positional.length < 3) {
|
|
1029
|
+
console.error("missing config value\n");
|
|
1030
|
+
printUsage();
|
|
1031
|
+
process.exitCode = 1;
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const pathParts = parseConfigKeyPath(key);
|
|
1036
|
+
const rawValue = positional.slice(2).join(" ");
|
|
1037
|
+
const asJsonValue = getBoolFlag(flags, "json-value", false);
|
|
1038
|
+
|
|
1039
|
+
let value: unknown = rawValue;
|
|
1040
|
+
if (asJsonValue) {
|
|
1041
|
+
try {
|
|
1042
|
+
value = JSON.parse(rawValue);
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
throw new Error(
|
|
1045
|
+
`invalid --json-value for key '${key}': ${error instanceof Error ? error.message : String(error)}`,
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const values = await readConfigFileObject(configFile);
|
|
1051
|
+
setValueAtKeyPath(values, pathParts, value);
|
|
1052
|
+
await writeConfigFileObject(configFile, values);
|
|
1053
|
+
|
|
1054
|
+
if (asJson) {
|
|
1055
|
+
console.log(
|
|
1056
|
+
JSON.stringify(
|
|
1057
|
+
{
|
|
1058
|
+
ok: true,
|
|
1059
|
+
configFile,
|
|
1060
|
+
key,
|
|
1061
|
+
value,
|
|
1062
|
+
},
|
|
1063
|
+
null,
|
|
1064
|
+
2,
|
|
1065
|
+
),
|
|
1066
|
+
);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
console.log(`set ${key} in ${configFile}`);
|
|
1071
|
+
console.log(formatConfigValue(value));
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (action === "unset") {
|
|
1076
|
+
const key = String(positional[1] || "").trim();
|
|
1077
|
+
if (!key) {
|
|
1078
|
+
console.error("missing config key\n");
|
|
1079
|
+
printUsage();
|
|
1080
|
+
process.exitCode = 1;
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const pathParts = parseConfigKeyPath(key);
|
|
1085
|
+
const values = await readConfigFileObject(configFile);
|
|
1086
|
+
const removed = unsetValueAtKeyPath(values, pathParts);
|
|
1087
|
+
|
|
1088
|
+
if (!removed) {
|
|
1089
|
+
console.error(`config key not found: ${key}`);
|
|
1090
|
+
process.exitCode = 1;
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
await writeConfigFileObject(configFile, values);
|
|
1095
|
+
|
|
1096
|
+
if (asJson) {
|
|
1097
|
+
console.log(
|
|
1098
|
+
JSON.stringify(
|
|
1099
|
+
{
|
|
1100
|
+
ok: true,
|
|
1101
|
+
configFile,
|
|
1102
|
+
key,
|
|
1103
|
+
removed: true,
|
|
1104
|
+
},
|
|
1105
|
+
null,
|
|
1106
|
+
2,
|
|
1107
|
+
),
|
|
1108
|
+
);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
console.log(`unset ${key} in ${configFile}`);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
console.error("config command requires subcommand: path|show|get|set|unset\n");
|
|
1117
|
+
printUsage();
|
|
1118
|
+
process.exitCode = 1;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function domainFromUrl(url: unknown): string {
|
|
1122
|
+
try {
|
|
1123
|
+
return new URL(String(url)).hostname.replace(/^www\./, "");
|
|
1124
|
+
} catch {
|
|
1125
|
+
return String(url || "").slice(0, 48);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function compactTitle(title: unknown, maxLen = 72): string {
|
|
1130
|
+
const value = String(title || "")
|
|
1131
|
+
.replace(/\s+/g, " ")
|
|
1132
|
+
.trim();
|
|
1133
|
+
if (!value) return "";
|
|
1134
|
+
if (value.length <= maxLen) return value;
|
|
1135
|
+
return `${value.slice(0, maxLen - 1)}…`;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function compactErrorMessage(errorMessage: unknown, maxLen = 120): string {
|
|
1139
|
+
const ansiEscape = String.fromCharCode(27);
|
|
1140
|
+
const stripped = String(errorMessage || "")
|
|
1141
|
+
.split(ansiEscape)
|
|
1142
|
+
.join("")
|
|
1143
|
+
.replace(/\[[0-9;]*m/g, "")
|
|
1144
|
+
.replace(/\s+/g, " ")
|
|
1145
|
+
.trim();
|
|
1146
|
+
|
|
1147
|
+
if (!stripped) return "request failed";
|
|
1148
|
+
|
|
1149
|
+
let compact = stripped
|
|
1150
|
+
.replace(/^Error:\s*/i, "")
|
|
1151
|
+
.replace(/^page\.goto:\s*/i, "")
|
|
1152
|
+
.replace(/^page\.content:\s*/i, "")
|
|
1153
|
+
.replace(/^browser\.newContext:\s*/i, "")
|
|
1154
|
+
.replace(/Call log:.*$/i, "")
|
|
1155
|
+
.trim();
|
|
1156
|
+
|
|
1157
|
+
if (!compact) compact = "request failed";
|
|
1158
|
+
if (compact.length <= maxLen) return compact;
|
|
1159
|
+
return `${compact.slice(0, maxLen - 1)}…`;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function formatMs(value: unknown): string {
|
|
1163
|
+
if (typeof value !== "number" || Number.isNaN(value)) return "-";
|
|
1164
|
+
return `${Math.round(value)}ms`;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function printProfile(profile: Record<string, unknown> | null | undefined): void {
|
|
1168
|
+
if (!profile) {
|
|
1169
|
+
console.log("\nProfile: unavailable");
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const summary = (profile.summary as Record<string, unknown> | undefined) || {};
|
|
1174
|
+
const byTool = (profile.byTool as Record<string, Record<string, unknown>> | undefined) || {};
|
|
1175
|
+
|
|
1176
|
+
console.log("\nProfile:");
|
|
1177
|
+
console.log(`- total: ${formatMs(profile.totalMs)}`);
|
|
1178
|
+
console.log(`- first assistant token: ${formatMs(profile.firstAssistantTokenMs)}`);
|
|
1179
|
+
console.log(`- turns: ${summary.turnCount ?? 0}, assistant messages: ${summary.assistantMessageCount ?? 0}`);
|
|
1180
|
+
console.log(`- tool calls: ${summary.toolCallCount ?? 0} (errors: ${summary.toolErrorCount ?? 0})`);
|
|
1181
|
+
console.log(`- tool duration sum: ${formatMs(summary.toolDurationSumMs)}`);
|
|
1182
|
+
|
|
1183
|
+
const toolNames = Object.keys(byTool);
|
|
1184
|
+
if (toolNames.length > 0) {
|
|
1185
|
+
console.log("- by tool:");
|
|
1186
|
+
for (const toolName of toolNames) {
|
|
1187
|
+
const stats = byTool[toolName] || {};
|
|
1188
|
+
console.log(
|
|
1189
|
+
` • ${toolName}: count=${stats.count ?? 0}, err=${stats.errors ?? 0}, avg=${formatMs(stats.durationAvgMs)}, sum=${formatMs(stats.durationSumMs)}, cache hit/miss=${stats.cacheHits ?? 0}/${stats.cacheMisses ?? 0}`,
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const tools = Array.isArray(profile.tools) ? (profile.tools as Array<Record<string, unknown>>) : [];
|
|
1195
|
+
if (tools.length > 0) {
|
|
1196
|
+
const slowest = [...tools]
|
|
1197
|
+
.filter((tool) => typeof tool.durationMs === "number")
|
|
1198
|
+
.sort((a, b) => Number(b.durationMs || 0) - Number(a.durationMs || 0))
|
|
1199
|
+
.slice(0, 3);
|
|
1200
|
+
|
|
1201
|
+
if (slowest.length > 0) {
|
|
1202
|
+
console.log("- slowest calls:");
|
|
1203
|
+
for (const tool of slowest) {
|
|
1204
|
+
const target = tool.url ? ` ${tool.url}` : "";
|
|
1205
|
+
console.log(` • ${tool.toolName || "tool"}: ${formatMs(tool.durationMs)}${target}`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function printQueryResult(
|
|
1212
|
+
result: Record<string, unknown>,
|
|
1213
|
+
options: { answerOverride?: string; skipAnswer?: boolean; asProfile?: boolean } = {},
|
|
1214
|
+
): void {
|
|
1215
|
+
const answerOverride = options.answerOverride;
|
|
1216
|
+
const skipAnswer = options.skipAnswer ?? false;
|
|
1217
|
+
const asProfile = options.asProfile ?? false;
|
|
1218
|
+
|
|
1219
|
+
const answer = String(answerOverride ?? result.answer ?? "").trim() || "(no answer returned)";
|
|
1220
|
+
|
|
1221
|
+
if (!skipAnswer) {
|
|
1222
|
+
console.log("");
|
|
1223
|
+
console.log(renderMarkdownForTerminal(answer));
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const durationMs = typeof result.durationMs === "number" ? result.durationMs : null;
|
|
1227
|
+
const durationSec = typeof durationMs === "number" ? `${(durationMs / 1000).toFixed(1)}s` : "-";
|
|
1228
|
+
const model = String(result.model || "unknown");
|
|
1229
|
+
const toolsUsed = Array.isArray(result.toolsUsed) ? result.toolsUsed : [];
|
|
1230
|
+
const toolCount = toolsUsed.length;
|
|
1231
|
+
const errorCount = toolsUsed.filter((entry) => Boolean((entry as Record<string, unknown>).isError)).length;
|
|
1232
|
+
const errorSuffix = errorCount > 0 ? ` · ${errorCount} failed` : "";
|
|
1233
|
+
console.log(`\n${theme.styleDim(`${durationSec} · ${model} · ${toolCount} tool calls${errorSuffix}`)}`);
|
|
1234
|
+
|
|
1235
|
+
if (asProfile) {
|
|
1236
|
+
printProfile((result.profile as Record<string, unknown> | undefined) || null);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
interface StreamPageEntry {
|
|
1241
|
+
pageNo: number;
|
|
1242
|
+
url: string;
|
|
1243
|
+
domain: string;
|
|
1244
|
+
ok: boolean;
|
|
1245
|
+
status?: string;
|
|
1246
|
+
browseCallId?: string;
|
|
1247
|
+
presentCallId?: string;
|
|
1248
|
+
documentId?: string | null;
|
|
1249
|
+
title?: string;
|
|
1250
|
+
browseMs?: number;
|
|
1251
|
+
presentMs?: number;
|
|
1252
|
+
cache?: string;
|
|
1253
|
+
presentCache?: string;
|
|
1254
|
+
totalMs?: number;
|
|
1255
|
+
finalized?: boolean;
|
|
1256
|
+
error?: string;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
async function queryWithLiveStream(
|
|
1260
|
+
query: string,
|
|
1261
|
+
options: { streamPath?: string; requestBody?: Record<string, unknown> | null; asProfile?: boolean } = {},
|
|
1262
|
+
): Promise<void> {
|
|
1263
|
+
const streamPath = options.streamPath || "/search/stream";
|
|
1264
|
+
const requestBody = options.requestBody;
|
|
1265
|
+
const asProfile = options.asProfile ?? false;
|
|
1266
|
+
|
|
1267
|
+
const controller = new AbortController();
|
|
1268
|
+
const timer = setTimeout(() => controller.abort(), config.queryTimeoutMs + 5000);
|
|
1269
|
+
|
|
1270
|
+
let abortedByUserSignal = false;
|
|
1271
|
+
const onUserAbortSignal = () => {
|
|
1272
|
+
abortedByUserSignal = true;
|
|
1273
|
+
controller.abort();
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
process.once("SIGINT", onUserAbortSignal);
|
|
1277
|
+
process.once("SIGTERM", onUserAbortSignal);
|
|
1278
|
+
|
|
1279
|
+
const payload = requestBody && typeof requestBody === "object" ? requestBody : { query };
|
|
1280
|
+
|
|
1281
|
+
let response: Response;
|
|
1282
|
+
try {
|
|
1283
|
+
response = await fetch(`${config.daemonUrl}${streamPath}`, {
|
|
1284
|
+
method: "POST",
|
|
1285
|
+
signal: controller.signal,
|
|
1286
|
+
headers: {
|
|
1287
|
+
"content-type": "application/json",
|
|
1288
|
+
},
|
|
1289
|
+
body: JSON.stringify(payload),
|
|
1290
|
+
});
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
clearTimeout(timer);
|
|
1293
|
+
process.removeListener("SIGINT", onUserAbortSignal);
|
|
1294
|
+
process.removeListener("SIGTERM", onUserAbortSignal);
|
|
1295
|
+
|
|
1296
|
+
if (abortedByUserSignal || controller.signal.aborted) {
|
|
1297
|
+
throw new Error("request aborted by user");
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
throw error;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (!response.ok || !response.body) {
|
|
1304
|
+
const text = await response.text().catch(() => "");
|
|
1305
|
+
throw new Error(`search stream failed: HTTP ${response.status}${text ? ` ${text.slice(0, 300)}` : ""}`);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1309
|
+
const spinnerEnabled = process.stdout.isTTY;
|
|
1310
|
+
|
|
1311
|
+
const state = {
|
|
1312
|
+
phase: "Starting",
|
|
1313
|
+
turn: 0,
|
|
1314
|
+
|
|
1315
|
+
maxPages: null as number | null,
|
|
1316
|
+
queryStartedAt: Date.now(),
|
|
1317
|
+
nextPageNo: 0,
|
|
1318
|
+
pages: [] as StreamPageEntry[],
|
|
1319
|
+
inFlightByCallId: new Map<string, StreamPageEntry>(),
|
|
1320
|
+
byDocumentId: new Map<string, StreamPageEntry>(),
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
let spinnerIndex = 0;
|
|
1324
|
+
let spinnerInterval: NodeJS.Timeout | null = null;
|
|
1325
|
+
let spinnerVisible = false;
|
|
1326
|
+
let pagesPrinted = 0;
|
|
1327
|
+
|
|
1328
|
+
function clearSpinnerLine() {
|
|
1329
|
+
if (!spinnerEnabled) return;
|
|
1330
|
+
if (!spinnerVisible) return;
|
|
1331
|
+
|
|
1332
|
+
readline.clearLine(process.stdout, 0);
|
|
1333
|
+
readline.cursorTo(process.stdout, 0);
|
|
1334
|
+
readline.moveCursor(process.stdout, 0, -1);
|
|
1335
|
+
readline.clearLine(process.stdout, 0);
|
|
1336
|
+
readline.cursorTo(process.stdout, 0);
|
|
1337
|
+
spinnerVisible = false;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function printEventLine(line: string) {
|
|
1341
|
+
clearSpinnerLine();
|
|
1342
|
+
process.stdout.write(`${line}\n`);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function renderPageLine(entry: StreamPageEntry): string {
|
|
1346
|
+
const domain = theme.styleDomain(entry.domain || "unknown");
|
|
1347
|
+
|
|
1348
|
+
if (entry.ok) {
|
|
1349
|
+
const title = compactTitle(entry.title);
|
|
1350
|
+
const titlePart = title ? ` ${theme.styleTitle(title)}` : "";
|
|
1351
|
+
const cacheHit = entry.cache === "hit" || entry.presentCache === "hit";
|
|
1352
|
+
const cachePart = cacheHit ? ` ${theme.styleDim(theme.icon("cache"))}` : "";
|
|
1353
|
+
return `${theme.icon("bullet")} ${domain}${titlePart}${cachePart}`;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const reason = compactErrorMessage(entry.error || "request failed");
|
|
1357
|
+
return `${theme.icon("bullet")} ${domain} ${theme.styleError(`— ${reason}`)}`;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function formatSpinnerPhase(phase: string): string {
|
|
1361
|
+
const normalized = String(phase || "").trim();
|
|
1362
|
+
|
|
1363
|
+
const phaseMatch = normalized.match(/^(Reading|Extracting)\s+(.+)$/i);
|
|
1364
|
+
if (phaseMatch) {
|
|
1365
|
+
const verb = String(phaseMatch[1] || "");
|
|
1366
|
+
const domain = String(phaseMatch[2] || "");
|
|
1367
|
+
return `${theme.styleDim(verb)} ${theme.styleDimItalic(domain)}`;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
return theme.styleDim(normalized || "Working");
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function spinnerLabel() {
|
|
1374
|
+
const elapsedMs = Date.now() - state.queryStartedAt;
|
|
1375
|
+
const elapsed = elapsedMs < 1000 ? `${elapsedMs}ms` : `${(elapsedMs / 1000).toFixed(1)}s`;
|
|
1376
|
+
return `${formatSpinnerPhase(state.phase)} ${theme.styleDim("·")} ${theme.styleDim(elapsed)}`;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function renderSpinner() {
|
|
1380
|
+
if (!spinnerEnabled) return;
|
|
1381
|
+
const frame = spinnerFrames[spinnerIndex % spinnerFrames.length] || "·";
|
|
1382
|
+
spinnerIndex += 1;
|
|
1383
|
+
clearSpinnerLine();
|
|
1384
|
+
process.stdout.write(`\n${theme.styleDuration(frame)} ${spinnerLabel()}`);
|
|
1385
|
+
spinnerVisible = true;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function startSpinner() {
|
|
1389
|
+
if (!spinnerEnabled) return;
|
|
1390
|
+
if (spinnerInterval) return;
|
|
1391
|
+
renderSpinner();
|
|
1392
|
+
spinnerInterval = setInterval(renderSpinner, 90);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function stopSpinner() {
|
|
1396
|
+
if (spinnerInterval) {
|
|
1397
|
+
clearInterval(spinnerInterval);
|
|
1398
|
+
spinnerInterval = null;
|
|
1399
|
+
}
|
|
1400
|
+
clearSpinnerLine();
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function finalizePage(entry: StreamPageEntry) {
|
|
1404
|
+
if (!entry || entry.finalized) return;
|
|
1405
|
+
entry.finalized = true;
|
|
1406
|
+
|
|
1407
|
+
if (pagesPrinted > 0) {
|
|
1408
|
+
printEventLine(theme.styleDim(theme.icon("connector")));
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
printEventLine(renderPageLine(entry));
|
|
1412
|
+
pagesPrinted += 1;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function onProgress(event: Record<string, unknown>) {
|
|
1416
|
+
const eventType = String(event?.type || "");
|
|
1417
|
+
|
|
1418
|
+
if (eventType === "query_start") {
|
|
1419
|
+
state.phase = "Planning";
|
|
1420
|
+
const maxPages = (event.researchPlan as Record<string, unknown> | undefined)?.maxPages;
|
|
1421
|
+
state.maxPages = typeof maxPages === "number" ? maxPages : state.maxPages;
|
|
1422
|
+
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
if (eventType === "turn_start") {
|
|
1427
|
+
const turn = event.turn;
|
|
1428
|
+
state.turn = typeof turn === "number" ? turn : state.turn;
|
|
1429
|
+
state.phase = "Thinking";
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
if (eventType === "first_token") {
|
|
1434
|
+
state.phase = "Collecting findings";
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (eventType === "assistant_delta") {
|
|
1439
|
+
// Collate mode: assistant deltas are internal (URL selection), not streamed to user
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (eventType === "tool_start") {
|
|
1444
|
+
const toolName = String(event.toolName || "");
|
|
1445
|
+
|
|
1446
|
+
if (toolName === "browse") {
|
|
1447
|
+
const args = (event.args as Record<string, unknown>) || {};
|
|
1448
|
+
const url = String(args.url || "");
|
|
1449
|
+
const entry: StreamPageEntry = {
|
|
1450
|
+
pageNo: state.nextPageNo + 1,
|
|
1451
|
+
url,
|
|
1452
|
+
domain: domainFromUrl(url),
|
|
1453
|
+
ok: false,
|
|
1454
|
+
status: "browsing",
|
|
1455
|
+
browseCallId: String(event.toolCallId || ""),
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1458
|
+
state.nextPageNo += 1;
|
|
1459
|
+
state.pages.push(entry);
|
|
1460
|
+
if (entry.browseCallId) state.inFlightByCallId.set(entry.browseCallId, entry);
|
|
1461
|
+
state.phase = `Reading ${entry.domain}`;
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (toolName === "present") {
|
|
1466
|
+
const args = (event.args as Record<string, unknown>) || {};
|
|
1467
|
+
const documentId = String(args.documentId || "");
|
|
1468
|
+
const page = documentId ? state.byDocumentId.get(documentId) : null;
|
|
1469
|
+
|
|
1470
|
+
if (page) {
|
|
1471
|
+
page.presentCallId = String(event.toolCallId || "");
|
|
1472
|
+
page.status = "extracting";
|
|
1473
|
+
if (page.presentCallId) state.inFlightByCallId.set(page.presentCallId, page);
|
|
1474
|
+
state.phase = `Extracting ${page.domain}`;
|
|
1475
|
+
} else {
|
|
1476
|
+
state.phase = "Extracting";
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
if (eventType === "tool_end") {
|
|
1483
|
+
const toolName = String(event.toolName || "");
|
|
1484
|
+
const toolCallId = String(event.toolCallId || "");
|
|
1485
|
+
|
|
1486
|
+
if (toolName === "browse") {
|
|
1487
|
+
let entry = state.inFlightByCallId.get(toolCallId);
|
|
1488
|
+
state.inFlightByCallId.delete(toolCallId);
|
|
1489
|
+
|
|
1490
|
+
if (!entry) {
|
|
1491
|
+
const url = String(event.url || "");
|
|
1492
|
+
entry = {
|
|
1493
|
+
pageNo: state.nextPageNo + 1,
|
|
1494
|
+
url,
|
|
1495
|
+
domain: domainFromUrl(url),
|
|
1496
|
+
ok: false,
|
|
1497
|
+
};
|
|
1498
|
+
state.nextPageNo += 1;
|
|
1499
|
+
state.pages.push(entry);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
entry.url = String(event.url || entry.url);
|
|
1503
|
+
entry.domain = domainFromUrl(entry.url);
|
|
1504
|
+
entry.documentId = String(event.documentId || "") || null;
|
|
1505
|
+
entry.title = String(event.title || entry.title || "");
|
|
1506
|
+
entry.browseMs = typeof event.durationMs === "number" ? event.durationMs : undefined;
|
|
1507
|
+
entry.cache = String(event.cache || "") || undefined;
|
|
1508
|
+
|
|
1509
|
+
if (typeof event.maxPages === "number") {
|
|
1510
|
+
state.maxPages = event.maxPages;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
if (event.isError) {
|
|
1514
|
+
entry.ok = false;
|
|
1515
|
+
entry.error = String(event.errorMessage || "request failed");
|
|
1516
|
+
entry.totalMs = entry.browseMs;
|
|
1517
|
+
finalizePage(entry);
|
|
1518
|
+
} else if (entry.documentId) {
|
|
1519
|
+
entry.ok = true;
|
|
1520
|
+
entry.status = "browsed";
|
|
1521
|
+
state.byDocumentId.set(entry.documentId, entry);
|
|
1522
|
+
} else {
|
|
1523
|
+
entry.ok = true;
|
|
1524
|
+
entry.totalMs = entry.browseMs;
|
|
1525
|
+
finalizePage(entry);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
state.phase = "Thinking";
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
if (toolName === "present") {
|
|
1533
|
+
let entry = state.inFlightByCallId.get(toolCallId);
|
|
1534
|
+
state.inFlightByCallId.delete(toolCallId);
|
|
1535
|
+
|
|
1536
|
+
const documentId = String(event.documentId || "");
|
|
1537
|
+
if (!entry && documentId) {
|
|
1538
|
+
entry = state.byDocumentId.get(documentId);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if (!entry) return;
|
|
1542
|
+
|
|
1543
|
+
entry.presentMs = typeof event.durationMs === "number" ? event.durationMs : undefined;
|
|
1544
|
+
entry.presentCache = String(event.cache || "") || undefined;
|
|
1545
|
+
entry.title = String(event.title || entry.title || "");
|
|
1546
|
+
|
|
1547
|
+
if (event.isError) {
|
|
1548
|
+
entry.ok = false;
|
|
1549
|
+
entry.error = String(event.errorMessage || "content extraction failed");
|
|
1550
|
+
} else {
|
|
1551
|
+
entry.ok = true;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
entry.totalMs = (entry.browseMs || 0) + (entry.presentMs || 0);
|
|
1555
|
+
if (entry.documentId) {
|
|
1556
|
+
state.byDocumentId.delete(entry.documentId);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
finalizePage(entry);
|
|
1560
|
+
state.phase = "Thinking";
|
|
1561
|
+
}
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
if (eventType === "query_end") {
|
|
1566
|
+
state.phase = "Finalizing";
|
|
1567
|
+
for (const page of state.pages) {
|
|
1568
|
+
if (!page.finalized && (page.browseMs || page.presentMs)) {
|
|
1569
|
+
page.ok = page.ok !== false;
|
|
1570
|
+
page.totalMs = page.totalMs || page.browseMs || page.presentMs;
|
|
1571
|
+
finalizePage(page);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
startSpinner();
|
|
1578
|
+
|
|
1579
|
+
const reader = response.body.getReader();
|
|
1580
|
+
const decoder = new TextDecoder();
|
|
1581
|
+
let buffer = "";
|
|
1582
|
+
let finalResult: Record<string, unknown> | null = null;
|
|
1583
|
+
let streamError: string | null = null;
|
|
1584
|
+
|
|
1585
|
+
try {
|
|
1586
|
+
while (true) {
|
|
1587
|
+
const { value, done } = await reader.read();
|
|
1588
|
+
if (done) break;
|
|
1589
|
+
|
|
1590
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1591
|
+
|
|
1592
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
1593
|
+
while (newlineIndex >= 0) {
|
|
1594
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
1595
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
1596
|
+
|
|
1597
|
+
if (line) {
|
|
1598
|
+
let parsedPayload: Record<string, unknown> | null = null;
|
|
1599
|
+
try {
|
|
1600
|
+
parsedPayload = JSON.parse(line) as Record<string, unknown>;
|
|
1601
|
+
} catch {
|
|
1602
|
+
parsedPayload = null;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
if (parsedPayload?.type === "progress") {
|
|
1606
|
+
onProgress((parsedPayload.event as Record<string, unknown>) || {});
|
|
1607
|
+
} else if (parsedPayload?.type === "result") {
|
|
1608
|
+
finalResult = (parsedPayload.result as Record<string, unknown>) || null;
|
|
1609
|
+
} else if (parsedPayload?.type === "error") {
|
|
1610
|
+
streamError = String(parsedPayload.error || "unknown stream error");
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
newlineIndex = buffer.indexOf("\n");
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
const trailing = buffer.trim();
|
|
1619
|
+
if (trailing) {
|
|
1620
|
+
try {
|
|
1621
|
+
const trailingPayload = JSON.parse(trailing) as Record<string, unknown>;
|
|
1622
|
+
if (trailingPayload?.type === "progress") {
|
|
1623
|
+
onProgress((trailingPayload.event as Record<string, unknown>) || {});
|
|
1624
|
+
}
|
|
1625
|
+
if (trailingPayload?.type === "result") {
|
|
1626
|
+
finalResult = (trailingPayload.result as Record<string, unknown>) || null;
|
|
1627
|
+
}
|
|
1628
|
+
if (trailingPayload?.type === "error") {
|
|
1629
|
+
streamError = String(trailingPayload.error || "unknown stream error");
|
|
1630
|
+
}
|
|
1631
|
+
} catch {
|
|
1632
|
+
// ignore malformed trailing chunk
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
if (abortedByUserSignal || controller.signal.aborted) {
|
|
1637
|
+
throw new Error("request aborted by user");
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
throw error;
|
|
1641
|
+
} finally {
|
|
1642
|
+
clearTimeout(timer);
|
|
1643
|
+
process.removeListener("SIGINT", onUserAbortSignal);
|
|
1644
|
+
process.removeListener("SIGTERM", onUserAbortSignal);
|
|
1645
|
+
stopSpinner();
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
if (streamError) {
|
|
1649
|
+
throw new Error(streamError);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
if (!finalResult) {
|
|
1653
|
+
if (abortedByUserSignal || controller.signal.aborted) {
|
|
1654
|
+
throw new Error("request aborted by user");
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
throw new Error("search stream ended without result");
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
printQueryResult(finalResult, { asProfile });
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
async function cmdPrompt(args: string[]): Promise<void> {
|
|
1664
|
+
const { deriveResearchPlan, buildSystemPrompt } = await import("./engine/policy.js");
|
|
1665
|
+
|
|
1666
|
+
const mode = (args[0] || "search").toLowerCase();
|
|
1667
|
+
const modeMap: Record<string, string> = {
|
|
1668
|
+
search: "general",
|
|
1669
|
+
general: "general",
|
|
1670
|
+
code: "code",
|
|
1671
|
+
company: "company",
|
|
1672
|
+
similar: "similar",
|
|
1673
|
+
deep: "deep",
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
const policyMode = modeMap[mode];
|
|
1677
|
+
if (!policyMode) {
|
|
1678
|
+
console.error(`Unknown mode: ${mode}. Options: search, code, company, similar, deep`);
|
|
1679
|
+
process.exitCode = 1;
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const plan = deriveResearchPlan("(query)", config, {
|
|
1684
|
+
researchPolicy: { mode: policyMode },
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
const searchEngine = config.searchEngine || "duckduckgo";
|
|
1688
|
+
const template = `https://${searchEngine}.com/?q=<url-encoded query>`;
|
|
1689
|
+
|
|
1690
|
+
const prompt = buildSystemPrompt(plan, { engine: searchEngine, template });
|
|
1691
|
+
console.log(prompt);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
async function cmdSearch(args: string[], asJson: boolean, asProfile = false): Promise<void> {
|
|
1695
|
+
const health = await ensureDaemonRunning();
|
|
1696
|
+
if (!health) return;
|
|
1697
|
+
|
|
1698
|
+
const { positional } = parseCliArgs(args);
|
|
1699
|
+
let query = positional.join(" ").trim();
|
|
1700
|
+
if (!query) query = await readStdinIfAvailable();
|
|
1701
|
+
|
|
1702
|
+
if (!query) {
|
|
1703
|
+
console.error("missing search text\n");
|
|
1704
|
+
printUsage();
|
|
1705
|
+
process.exitCode = 1;
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
if (!asJson) {
|
|
1710
|
+
try {
|
|
1711
|
+
await queryWithLiveStream(query, {
|
|
1712
|
+
streamPath: "/search/stream",
|
|
1713
|
+
requestBody: { query },
|
|
1714
|
+
asProfile,
|
|
1715
|
+
});
|
|
1716
|
+
} catch (error) {
|
|
1717
|
+
console.error(`search failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1718
|
+
process.exitCode = 1;
|
|
1719
|
+
}
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
try {
|
|
1724
|
+
const result = await runCommandRequest("/search", { query });
|
|
1725
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
console.error(`search failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1728
|
+
process.exitCode = 1;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
async function cmdSearchAdvanced(args: string[], asJson: boolean, asProfile = false): Promise<void> {
|
|
1733
|
+
const health = await ensureDaemonRunning();
|
|
1734
|
+
if (!health) return;
|
|
1735
|
+
|
|
1736
|
+
const { positional, flags } = parseCliArgs(args);
|
|
1737
|
+
let query = positional.join(" ").trim();
|
|
1738
|
+
if (!query) query = await readStdinIfAvailable();
|
|
1739
|
+
|
|
1740
|
+
if (!query) {
|
|
1741
|
+
console.error("missing search text\n");
|
|
1742
|
+
printUsage();
|
|
1743
|
+
process.exitCode = 1;
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
const requestBody = omitUndefined({
|
|
1748
|
+
query,
|
|
1749
|
+
|
|
1750
|
+
numResults: getIntFlag(flags, "num-results", undefined),
|
|
1751
|
+
type: getFlagValue(flags, "type", undefined),
|
|
1752
|
+
category: getFlagValue(flags, "category", undefined),
|
|
1753
|
+
includeDomains: getListFlag(flags, "include-domains"),
|
|
1754
|
+
excludeDomains: getListFlag(flags, "exclude-domains"),
|
|
1755
|
+
startPublishedDate: getFlagValue(flags, "start-published-date", undefined),
|
|
1756
|
+
endPublishedDate: getFlagValue(flags, "end-published-date", undefined),
|
|
1757
|
+
includeText: getListFlag(flags, "include-text"),
|
|
1758
|
+
excludeText: getListFlag(flags, "exclude-text"),
|
|
1759
|
+
livecrawl: getFlagValue(flags, "livecrawl", undefined),
|
|
1760
|
+
textMaxCharacters: getIntFlag(flags, "text-max-characters", undefined),
|
|
1761
|
+
contextMaxCharacters: getIntFlag(flags, "context-max-characters", undefined),
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
if (!asJson) {
|
|
1765
|
+
try {
|
|
1766
|
+
await queryWithLiveStream(query, {
|
|
1767
|
+
streamPath: "/search/advanced/stream",
|
|
1768
|
+
requestBody,
|
|
1769
|
+
asProfile,
|
|
1770
|
+
});
|
|
1771
|
+
} catch (error) {
|
|
1772
|
+
console.error(`search-advanced failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1773
|
+
process.exitCode = 1;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
try {
|
|
1780
|
+
const result = await runCommandRequest("/search/advanced", requestBody);
|
|
1781
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1782
|
+
} catch (error) {
|
|
1783
|
+
console.error(`search-advanced failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1784
|
+
process.exitCode = 1;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
async function cmdCode(args: string[], asJson: boolean, asProfile = false): Promise<void> {
|
|
1789
|
+
const health = await ensureDaemonRunning();
|
|
1790
|
+
if (!health) return;
|
|
1791
|
+
|
|
1792
|
+
const { positional, flags } = parseCliArgs(args);
|
|
1793
|
+
let query = positional.join(" ").trim();
|
|
1794
|
+
if (!query) query = await readStdinIfAvailable();
|
|
1795
|
+
|
|
1796
|
+
if (!query) {
|
|
1797
|
+
console.error("missing code search text\n");
|
|
1798
|
+
printUsage();
|
|
1799
|
+
process.exitCode = 1;
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
const requestBody = omitUndefined({
|
|
1804
|
+
query,
|
|
1805
|
+
|
|
1806
|
+
numResults: getIntFlag(flags, "num-results", undefined),
|
|
1807
|
+
includeDomains: getListFlag(flags, "include-domains"),
|
|
1808
|
+
excludeDomains: getListFlag(flags, "exclude-domains"),
|
|
1809
|
+
sites: getListFlag(flags, "sites"),
|
|
1810
|
+
type: getFlagValue(flags, "type", undefined),
|
|
1811
|
+
livecrawl: getFlagValue(flags, "livecrawl", undefined),
|
|
1812
|
+
includeText: getListFlag(flags, "include-text"),
|
|
1813
|
+
excludeText: getListFlag(flags, "exclude-text"),
|
|
1814
|
+
startPublishedDate: getFlagValue(flags, "start-published-date", undefined),
|
|
1815
|
+
endPublishedDate: getFlagValue(flags, "end-published-date", undefined),
|
|
1816
|
+
customInstruction: getFlagValue(flags, "instruction", getFlagValue(flags, "instructions", undefined)),
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
if (!asJson) {
|
|
1820
|
+
try {
|
|
1821
|
+
await queryWithLiveStream(query, {
|
|
1822
|
+
streamPath: "/code-context/stream",
|
|
1823
|
+
requestBody,
|
|
1824
|
+
asProfile,
|
|
1825
|
+
});
|
|
1826
|
+
} catch (error) {
|
|
1827
|
+
console.error(`code failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1828
|
+
process.exitCode = 1;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
try {
|
|
1835
|
+
const result = await runCommandRequest("/code-context", requestBody);
|
|
1836
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
console.error(`code failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1839
|
+
process.exitCode = 1;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
async function cmdCompany(args: string[], asJson: boolean, asProfile = false): Promise<void> {
|
|
1844
|
+
const health = await ensureDaemonRunning();
|
|
1845
|
+
if (!health) return;
|
|
1846
|
+
|
|
1847
|
+
const { positional, flags } = parseCliArgs(args);
|
|
1848
|
+
let companyName = positional.join(" ").trim();
|
|
1849
|
+
if (!companyName) companyName = await readStdinIfAvailable();
|
|
1850
|
+
|
|
1851
|
+
if (!companyName) {
|
|
1852
|
+
console.error("missing company name\n");
|
|
1853
|
+
printUsage();
|
|
1854
|
+
process.exitCode = 1;
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
const requestBody = omitUndefined({
|
|
1859
|
+
companyName,
|
|
1860
|
+
query: companyName,
|
|
1861
|
+
|
|
1862
|
+
country: getFlagValue(flags, "country", undefined),
|
|
1863
|
+
numResults: getIntFlag(flags, "num-results", undefined),
|
|
1864
|
+
type: getFlagValue(flags, "type", undefined),
|
|
1865
|
+
sites: getListFlag(flags, "sites"),
|
|
1866
|
+
seedUrls: getListFlag(flags, "seed-urls"),
|
|
1867
|
+
includeDomains: getListFlag(flags, "include-domains"),
|
|
1868
|
+
excludeDomains: getListFlag(flags, "exclude-domains"),
|
|
1869
|
+
includeText: getListFlag(flags, "include-text"),
|
|
1870
|
+
excludeText: getListFlag(flags, "exclude-text"),
|
|
1871
|
+
startPublishedDate: getFlagValue(flags, "start-published-date", undefined),
|
|
1872
|
+
endPublishedDate: getFlagValue(flags, "end-published-date", undefined),
|
|
1873
|
+
customInstruction: getFlagValue(flags, "instruction", getFlagValue(flags, "instructions", undefined)),
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
if (!asJson) {
|
|
1877
|
+
try {
|
|
1878
|
+
await queryWithLiveStream(companyName, {
|
|
1879
|
+
streamPath: "/company-research/stream",
|
|
1880
|
+
requestBody,
|
|
1881
|
+
asProfile,
|
|
1882
|
+
});
|
|
1883
|
+
} catch (error) {
|
|
1884
|
+
console.error(`company failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1885
|
+
process.exitCode = 1;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
try {
|
|
1892
|
+
const result = await runCommandRequest("/company-research", requestBody);
|
|
1893
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1894
|
+
} catch (error) {
|
|
1895
|
+
console.error(`company failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1896
|
+
process.exitCode = 1;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
async function cmdSimilar(args: string[], asJson: boolean, asProfile = false): Promise<void> {
|
|
1901
|
+
const health = await ensureDaemonRunning();
|
|
1902
|
+
if (!health) return;
|
|
1903
|
+
|
|
1904
|
+
const { positional, flags } = parseCliArgs(args);
|
|
1905
|
+
const url = String(positional[0] || "").trim();
|
|
1906
|
+
|
|
1907
|
+
if (!url) {
|
|
1908
|
+
console.error("missing URL\n");
|
|
1909
|
+
printUsage();
|
|
1910
|
+
process.exitCode = 1;
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
const instruction = getFlagValue(flags, "instruction", getFlagValue(flags, "instructions", undefined));
|
|
1915
|
+
const instructionText = typeof instruction === "string" ? instruction.trim() : "";
|
|
1916
|
+
|
|
1917
|
+
const query = instructionText
|
|
1918
|
+
? `Find web pages similar to ${url}. ${instructionText}`
|
|
1919
|
+
: `Find web pages similar to ${url}. Focus on same product category, target users, and use-case overlap. Avoid generic dictionary or synonym pages.`;
|
|
1920
|
+
|
|
1921
|
+
const requestBody = omitUndefined({
|
|
1922
|
+
url,
|
|
1923
|
+
query,
|
|
1924
|
+
mode: "similar",
|
|
1925
|
+
|
|
1926
|
+
sites: getListFlag(flags, "sites"),
|
|
1927
|
+
seedUrls: [url, ...getListFlag(flags, "seed-urls")],
|
|
1928
|
+
numResults: getIntFlag(flags, "num-results", undefined),
|
|
1929
|
+
type: getFlagValue(flags, "type", undefined),
|
|
1930
|
+
includeDomains: getListFlag(flags, "include-domains"),
|
|
1931
|
+
excludeDomains: getListFlag(flags, "exclude-domains"),
|
|
1932
|
+
includeText: getListFlag(flags, "include-text"),
|
|
1933
|
+
excludeText: getListFlag(flags, "exclude-text"),
|
|
1934
|
+
customInstruction: instructionText || undefined,
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
if (!asJson) {
|
|
1938
|
+
try {
|
|
1939
|
+
await queryWithLiveStream(query, {
|
|
1940
|
+
streamPath: "/find-similar/stream",
|
|
1941
|
+
requestBody,
|
|
1942
|
+
asProfile,
|
|
1943
|
+
});
|
|
1944
|
+
} catch (error) {
|
|
1945
|
+
console.error(`similar failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1946
|
+
process.exitCode = 1;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
try {
|
|
1953
|
+
const result = await runCommandRequest("/find-similar", requestBody);
|
|
1954
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1955
|
+
} catch (error) {
|
|
1956
|
+
console.error(`similar failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1957
|
+
process.exitCode = 1;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
async function cmdFetch(args: string[], asJson: boolean): Promise<void> {
|
|
1962
|
+
const health = await ensureDaemonRunning();
|
|
1963
|
+
if (!health) return;
|
|
1964
|
+
|
|
1965
|
+
const { positional, flags } = parseCliArgs(args);
|
|
1966
|
+
const url = String(positional[0] || "").trim();
|
|
1967
|
+
|
|
1968
|
+
if (!url) {
|
|
1969
|
+
console.error("missing URL\n");
|
|
1970
|
+
printUsage();
|
|
1971
|
+
process.exitCode = 1;
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
try {
|
|
1976
|
+
const result = await runCommandRequest(
|
|
1977
|
+
"/fetch",
|
|
1978
|
+
omitUndefined({
|
|
1979
|
+
url,
|
|
1980
|
+
maxCharacters: getIntFlag(flags, "max-chars", getIntFlag(flags, "max-characters", undefined)),
|
|
1981
|
+
noCache: getBoolFlag(flags, "no-cache", false),
|
|
1982
|
+
}),
|
|
1983
|
+
);
|
|
1984
|
+
|
|
1985
|
+
if (asJson) {
|
|
1986
|
+
console.log(JSON.stringify({ ok: true, ...result }, null, 2));
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
const title = String(result.title || "").trim();
|
|
1991
|
+
const canonicalUrl = String(result.url || result.requestedUrl || url).trim();
|
|
1992
|
+
const content = String(result.content || "(no content)");
|
|
1993
|
+
const timing = result.timing as Record<string, unknown> | undefined;
|
|
1994
|
+
const cache = result.cache as Record<string, unknown> | undefined;
|
|
1995
|
+
const totalMs = typeof timing?.totalMs === "number" ? timing.totalMs : null;
|
|
1996
|
+
const total = typeof totalMs === "number" ? `${(totalMs / 1000).toFixed(1)}s` : "-";
|
|
1997
|
+
const cacheSummary = `browse=${cache?.browse || "-"}, present=${cache?.present || "-"}`;
|
|
1998
|
+
|
|
1999
|
+
if (title) console.log(title);
|
|
2000
|
+
console.log(canonicalUrl);
|
|
2001
|
+
console.log("");
|
|
2002
|
+
console.log(content);
|
|
2003
|
+
console.log(`\n${total} · ${cacheSummary}`);
|
|
2004
|
+
} catch (error) {
|
|
2005
|
+
console.error(`fetch failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2006
|
+
process.exitCode = 1;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
async function cmdDeep(args: string[], asJson: boolean): Promise<void> {
|
|
2011
|
+
const health = await ensureDaemonRunning();
|
|
2012
|
+
if (!health) return;
|
|
2013
|
+
|
|
2014
|
+
const { positional, flags } = parseCliArgs(args);
|
|
2015
|
+
const action = String(positional[0] || "")
|
|
2016
|
+
.trim()
|
|
2017
|
+
.toLowerCase();
|
|
2018
|
+
|
|
2019
|
+
if (action === "start") {
|
|
2020
|
+
const effortChoices = new Set(["fast", "balanced", "thorough"]);
|
|
2021
|
+
|
|
2022
|
+
const effortFlagRaw = getFlagValue(flags, "effort", undefined);
|
|
2023
|
+
const effortFromFlag =
|
|
2024
|
+
typeof effortFlagRaw === "string" && effortChoices.has(effortFlagRaw.trim().toLowerCase())
|
|
2025
|
+
? effortFlagRaw.trim().toLowerCase()
|
|
2026
|
+
: undefined;
|
|
2027
|
+
|
|
2028
|
+
let instructionTokens = positional.slice(1);
|
|
2029
|
+
let inferredEffort = effortFromFlag;
|
|
2030
|
+
|
|
2031
|
+
// Ergonomic fallback for npm script invocation where '--effort' can be swallowed
|
|
2032
|
+
// (e.g. npm run deep start "..." --effort thorough -> trailing 'thorough').
|
|
2033
|
+
if (!inferredEffort && instructionTokens.length > 1) {
|
|
2034
|
+
const trailing = String(instructionTokens[instructionTokens.length - 1] || "")
|
|
2035
|
+
.trim()
|
|
2036
|
+
.toLowerCase();
|
|
2037
|
+
if (effortChoices.has(trailing)) {
|
|
2038
|
+
inferredEffort = trailing;
|
|
2039
|
+
instructionTokens = instructionTokens.slice(0, -1);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
const instructions = instructionTokens.join(" ").trim();
|
|
2044
|
+
if (!instructions) {
|
|
2045
|
+
console.error("missing research instructions\n");
|
|
2046
|
+
printUsage();
|
|
2047
|
+
process.exitCode = 1;
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
try {
|
|
2052
|
+
const result = await runCommandRequest(
|
|
2053
|
+
"/deep-research/start",
|
|
2054
|
+
omitUndefined({
|
|
2055
|
+
instructions,
|
|
2056
|
+
effort: inferredEffort,
|
|
2057
|
+
}),
|
|
2058
|
+
config.queryTimeoutMs + 2000,
|
|
2059
|
+
);
|
|
2060
|
+
|
|
2061
|
+
if (asJson) {
|
|
2062
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
const researchId = String(result.researchId || "").trim();
|
|
2067
|
+
const effort = String(result.effort || inferredEffort || "").trim();
|
|
2068
|
+
const message = String(result.message || "Research started").trim();
|
|
2069
|
+
|
|
2070
|
+
if (researchId) console.log(`researchId: ${researchId}`);
|
|
2071
|
+
if (effort) console.log(`effort: ${effort}`);
|
|
2072
|
+
|
|
2073
|
+
if (message && (!researchId || !message.includes(researchId))) {
|
|
2074
|
+
console.log(message);
|
|
2075
|
+
} else if (researchId) {
|
|
2076
|
+
console.log("Research started. Run: yagami deep check <researchId>");
|
|
2077
|
+
} else {
|
|
2078
|
+
console.log("Research started");
|
|
2079
|
+
}
|
|
2080
|
+
} catch (error) {
|
|
2081
|
+
console.error(`deep start failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2082
|
+
process.exitCode = 1;
|
|
2083
|
+
}
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
if (action === "check") {
|
|
2088
|
+
const researchId = String(positional[1] || "").trim();
|
|
2089
|
+
if (!researchId) {
|
|
2090
|
+
console.error("missing researchId\n");
|
|
2091
|
+
printUsage();
|
|
2092
|
+
process.exitCode = 1;
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
try {
|
|
2097
|
+
const result = await runCommandRequest("/deep-research/check", { researchId }, config.queryTimeoutMs + 5000);
|
|
2098
|
+
|
|
2099
|
+
if (asJson) {
|
|
2100
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
if (result.status === "completed") {
|
|
2105
|
+
console.log(String(result.report || "(no report)"));
|
|
2106
|
+
|
|
2107
|
+
const citations = normalizeUniqueUrls((result.citations as unknown[]) || []);
|
|
2108
|
+
if (citations.length > 0) {
|
|
2109
|
+
console.log("\nCitations:");
|
|
2110
|
+
for (const url of citations) console.log(` ${url}`);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
const durationMs = typeof result.durationMs === "number" ? result.durationMs : 0;
|
|
2114
|
+
const effortLabel = result.effort ? ` · effort=${result.effort}` : "";
|
|
2115
|
+
console.log(`\nstatus: completed · ${(durationMs / 1000).toFixed(1)}s${effortLabel}`);
|
|
2116
|
+
} else {
|
|
2117
|
+
console.log(`${result.status || "unknown"}: ${result.message || "no message"}`);
|
|
2118
|
+
}
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
console.error(`deep check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2121
|
+
process.exitCode = 1;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
console.error("deep command requires subcommand: start|check\n");
|
|
2128
|
+
printUsage();
|
|
2129
|
+
process.exitCode = 1;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
async function main(): Promise<void> {
|
|
2133
|
+
const argv = process.argv.slice(2);
|
|
2134
|
+
|
|
2135
|
+
if (argv.length === 0 || argv.includes("-h") || argv.includes("--help") || argv[0] === "help") {
|
|
2136
|
+
printUsage();
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
const first = String(argv[0] || "").toLowerCase();
|
|
2141
|
+
const asJson = argv.includes("--json");
|
|
2142
|
+
const asProfile = argv.includes("--profile");
|
|
2143
|
+
|
|
2144
|
+
if (first === "start") {
|
|
2145
|
+
await cmdStart();
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
if (first === "stop") {
|
|
2150
|
+
await cmdStop();
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
if (first === "status") {
|
|
2155
|
+
await cmdStatus(argv.slice(1), asJson);
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
if (first === "reload") {
|
|
2160
|
+
await cmdReload(asJson);
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
if (first === "doctor") {
|
|
2165
|
+
await cmdDoctor(asJson);
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
if (first === "theme") {
|
|
2170
|
+
await cmdTheme(config, argv.slice(1), { asJson, printUsage });
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
if (first === "config") {
|
|
2175
|
+
await cmdConfig(argv.slice(1), asJson);
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
if (first === "search") {
|
|
2180
|
+
await cmdSearch(argv.slice(1), asJson, asProfile);
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
if (first === "search-advanced") {
|
|
2185
|
+
await cmdSearchAdvanced(argv.slice(1), asJson, asProfile);
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
if (first === "code") {
|
|
2190
|
+
await cmdCode(argv.slice(1), asJson, asProfile);
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
if (first === "company") {
|
|
2195
|
+
await cmdCompany(argv.slice(1), asJson, asProfile);
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
if (first === "similar") {
|
|
2200
|
+
await cmdSimilar(argv.slice(1), asJson, asProfile);
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
if (first === "prompt") {
|
|
2205
|
+
await cmdPrompt(argv.slice(1));
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
if (first === "fetch") {
|
|
2210
|
+
await cmdFetch(argv.slice(1), asJson);
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
if (first === "deep") {
|
|
2215
|
+
await cmdDeep(argv.slice(1), asJson);
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// Shorthand mode: any unrecognized command becomes search text.
|
|
2220
|
+
await cmdSearch(argv, asJson, asProfile);
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
main().catch((error: unknown) => {
|
|
2224
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
2225
|
+
process.exitCode = 1;
|
|
2226
|
+
});
|