skyloom 1.18.0 → 1.18.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/dist/cli/loom.d.ts.map +1 -1
- package/dist/cli/loom.js +18 -7
- package/dist/cli/loom.js.map +1 -1
- package/dist/cli/main.js +9 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/md_render.d.ts +42 -0
- package/dist/cli/md_render.d.ts.map +1 -0
- package/dist/cli/md_render.js +97 -0
- package/dist/cli/md_render.js.map +1 -0
- package/dist/core/bus.d.ts.map +1 -1
- package/dist/core/bus.js +5 -3
- package/dist/core/bus.js.map +1 -1
- package/dist/core/logger.d.ts +15 -0
- package/dist/core/logger.d.ts.map +1 -1
- package/dist/core/logger.js +72 -2
- package/dist/core/logger.js.map +1 -1
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +3 -0
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/websearch.d.ts +7 -1
- package/dist/tools/websearch.d.ts.map +1 -1
- package/dist/tools/websearch.js +19 -4
- package/dist/tools/websearch.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/loom.ts +17 -7
- package/src/cli/main.ts +7 -0
- package/src/cli/md_render.ts +102 -0
- package/src/core/bus.ts +6 -3
- package/src/core/logger.ts +40 -2
- package/src/tools/builtin.ts +3 -0
- package/src/tools/websearch.ts +22 -6
- package/tests/logger.test.ts +44 -0
- package/tests/md_render.test.ts +78 -0
- package/tests/websearch.test.ts +24 -0
package/src/tools/websearch.ts
CHANGED
|
@@ -44,7 +44,9 @@ export interface WebHttp {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
|
|
47
|
-
|
|
47
|
+
// Per-request timeout. Kept short so a dead/slow provider fails fast and the
|
|
48
|
+
// waterfall moves on quickly rather than burning 15s each across five providers.
|
|
49
|
+
const DEFAULT_TIMEOUT = 8000;
|
|
48
50
|
|
|
49
51
|
/** Default HTTP client backed by axios. */
|
|
50
52
|
export const defaultHttp: WebHttp = {
|
|
@@ -294,40 +296,54 @@ export interface WebSearchOptions {
|
|
|
294
296
|
env?: EnvMap; // defaults to process.env
|
|
295
297
|
http?: WebHttp; // defaults to axios-backed client
|
|
296
298
|
onProviderError?: (provider: string, error: string) => void;
|
|
299
|
+
/** Total wall-clock budget for the waterfall; stop trying once exceeded. */
|
|
300
|
+
budgetMs?: number;
|
|
297
301
|
}
|
|
298
302
|
|
|
299
303
|
/**
|
|
300
304
|
* Run a web search through the provider waterfall. Returns the first provider
|
|
301
305
|
* that yields results, or a response with an empty result set + the list of
|
|
302
|
-
* providers that were tried.
|
|
306
|
+
* providers that were tried. A total time budget caps the waterfall so the tool
|
|
307
|
+
* returns a clear "no results" message instead of being killed by an outer
|
|
308
|
+
* timeout (which would surface as an opaque "Tool execution timeout").
|
|
303
309
|
*/
|
|
304
|
-
export async function webSearch(query: string, opts: WebSearchOptions = {}): Promise<SearchResponse & { tried: string[] }> {
|
|
310
|
+
export async function webSearch(query: string, opts: WebSearchOptions = {}): Promise<SearchResponse & { tried: string[]; errors?: number }> {
|
|
305
311
|
const q = (query || '').trim();
|
|
306
312
|
if (!q) throw new Error('query is required');
|
|
307
313
|
const max = Math.max(1, Math.min(20, Math.floor(opts.max ?? 8)));
|
|
308
314
|
const env = opts.env ?? (process.env as EnvMap);
|
|
309
315
|
const http = opts.http ?? defaultHttp;
|
|
310
316
|
const pinned = (opts.engine || env.SKYLOOM_SEARCH_ENGINE || '').trim();
|
|
317
|
+
const budgetMs = opts.budgetMs ?? 22000;
|
|
318
|
+
const start = Date.now();
|
|
311
319
|
|
|
312
320
|
const providers = resolveProviders(env, pinned);
|
|
313
321
|
const tried: string[] = [];
|
|
322
|
+
let errors = 0;
|
|
314
323
|
for (const provider of providers) {
|
|
324
|
+
// Out of time: stop the waterfall and return what we have (a clear empty
|
|
325
|
+
// result), rather than letting a slow tail provider blow the tool timeout.
|
|
326
|
+
if (tried.length > 0 && Date.now() - start > budgetMs) break;
|
|
315
327
|
tried.push(provider.id);
|
|
316
328
|
try {
|
|
317
329
|
const res = await provider.run(http, env, q, max);
|
|
318
330
|
if (res.results.length > 0 || res.answer) return { ...res, tried };
|
|
319
331
|
} catch (e: any) {
|
|
332
|
+
errors++;
|
|
320
333
|
opts.onProviderError?.(provider.id, String(e?.message || e));
|
|
321
334
|
}
|
|
322
335
|
}
|
|
323
|
-
return { provider: 'none', results: [], tried };
|
|
336
|
+
return { provider: 'none', results: [], tried, errors };
|
|
324
337
|
}
|
|
325
338
|
|
|
326
339
|
/** Format a SearchResponse as compact text for an LLM tool result. */
|
|
327
|
-
export function formatSearchResults(res: SearchResponse & { tried?: string[] }): string {
|
|
340
|
+
export function formatSearchResults(res: SearchResponse & { tried?: string[]; errors?: number }): string {
|
|
328
341
|
if (!res.results.length && !res.answer) {
|
|
329
342
|
const tried = res.tried?.length ? ` (tried: ${res.tried.join(', ')})` : '';
|
|
330
|
-
|
|
343
|
+
const note = res.errors && res.errors > 0
|
|
344
|
+
? ' Every provider errored or timed out — likely no/blocked network connectivity or rate limiting in this environment.'
|
|
345
|
+
: '';
|
|
346
|
+
return `No search results found${tried}.${note} Try a simpler query, or set a search API key (TAVILY_API_KEY / BRAVE_API_KEY / SERPER_API_KEY) for more reliable results.`;
|
|
331
347
|
}
|
|
332
348
|
const parts: string[] = [];
|
|
333
349
|
if (res.answer) parts.push(`Answer: ${res.answer}\n`);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { getLogger, setLogSink, setLogFile, silenceLogs } from "../src/core/logger";
|
|
6
|
+
|
|
7
|
+
afterEach(() => { silenceLogs(); }); // never leak to the terminal between tests
|
|
8
|
+
|
|
9
|
+
describe("logger · sink routing", () => {
|
|
10
|
+
it("routes log lines to a custom sink instead of stderr", () => {
|
|
11
|
+
const lines: string[] = [];
|
|
12
|
+
setLogSink((l) => lines.push(l));
|
|
13
|
+
getLogger("test-sink").warn("hello_world", { a: 1 });
|
|
14
|
+
expect(lines.length).toBe(1);
|
|
15
|
+
const entry = JSON.parse(lines[0]);
|
|
16
|
+
expect(entry.msg).toBe("hello_world");
|
|
17
|
+
expect(entry.level).toBe("warn");
|
|
18
|
+
expect(entry.a).toBe(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("silenceLogs discards output (keeps a TUI clean)", () => {
|
|
22
|
+
let count = 0;
|
|
23
|
+
setLogSink(() => { count++; });
|
|
24
|
+
silenceLogs();
|
|
25
|
+
getLogger("test-silence").error("should_be_dropped");
|
|
26
|
+
expect(count).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("setLogFile appends log lines to a file, not the terminal", () => {
|
|
30
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-log-"));
|
|
31
|
+
const file = path.join(dir, "sky.log");
|
|
32
|
+
try {
|
|
33
|
+
const resolved = setLogFile(file);
|
|
34
|
+
expect(resolved).toBe(file);
|
|
35
|
+
getLogger("test-file").warn("to_file", { n: 7 });
|
|
36
|
+
const content = fs.readFileSync(file, "utf8");
|
|
37
|
+
expect(content).toContain("to_file");
|
|
38
|
+
expect(content).toContain('"n":7');
|
|
39
|
+
} finally {
|
|
40
|
+
silenceLogs();
|
|
41
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { styleLine, newRenderState, styleBlock, styleInline } from "../src/cli/md_render";
|
|
3
|
+
|
|
4
|
+
describe("md_render · styleInline", () => {
|
|
5
|
+
it("renders bold", () => {
|
|
6
|
+
const out = styleInline("hello **world** ok");
|
|
7
|
+
expect(out).not.toContain("**");
|
|
8
|
+
expect(out).toContain("world");
|
|
9
|
+
});
|
|
10
|
+
it("renders inline code", () => {
|
|
11
|
+
const out = styleInline("use `foo.bar()` here");
|
|
12
|
+
// inline code should preserve content, not contain backticks
|
|
13
|
+
expect(out).not.toContain("`");
|
|
14
|
+
expect(out).toContain("foo.bar()");
|
|
15
|
+
});
|
|
16
|
+
it("does not re-style content inside backticks", () => {
|
|
17
|
+
const out = styleInline("` **not bold** `");
|
|
18
|
+
expect(out).not.toContain("**");
|
|
19
|
+
expect(out).toContain("not bold");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("md_render · styleLine (block+inline)", () => {
|
|
24
|
+
it("renders H2 heading", () => {
|
|
25
|
+
const s = newRenderState();
|
|
26
|
+
const out = styleLine("## 设计概要", s);
|
|
27
|
+
expect(out).not.toContain("##");
|
|
28
|
+
expect(out).toContain("设计概要");
|
|
29
|
+
});
|
|
30
|
+
it("renders HR", () => {
|
|
31
|
+
const s = newRenderState();
|
|
32
|
+
const out = styleLine("---", s);
|
|
33
|
+
expect(out).not.toContain("---");
|
|
34
|
+
expect(out.length).toBeGreaterThan(2);
|
|
35
|
+
});
|
|
36
|
+
it("renders unordered list bullets", () => {
|
|
37
|
+
const s = newRenderState();
|
|
38
|
+
const out = styleLine("- 性能 · 每秒", s);
|
|
39
|
+
expect(out).not.toStrictEqual(expect.stringContaining("- ")); // raw hyphen gone
|
|
40
|
+
expect(out).toContain("性能");
|
|
41
|
+
});
|
|
42
|
+
it("renders bold+italic in content lines", () => {
|
|
43
|
+
const s = newRenderState();
|
|
44
|
+
const out = styleLine("**粗体** 和 *斜体* 文字", s);
|
|
45
|
+
expect(out).not.toContain("**");
|
|
46
|
+
expect(out).not.toContain("*");
|
|
47
|
+
expect(out).toContain("粗体");
|
|
48
|
+
expect(out).toContain("斜体");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("md_render · code fences", () => {
|
|
53
|
+
it("tracks fence state and renders code blocks raw", () => {
|
|
54
|
+
const s = newRenderState();
|
|
55
|
+
expect(styleLine("```ts", s)).toContain("```");
|
|
56
|
+
expect(s.inCodeFence).toBe(true);
|
|
57
|
+
expect(styleLine("const x = 1;", s)).toContain("const x = 1;");
|
|
58
|
+
expect(styleLine("```", s)).toContain("```");
|
|
59
|
+
expect(s.inCodeFence).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
it("does not style bold inside a code fence", () => {
|
|
62
|
+
const s = newRenderState();
|
|
63
|
+
styleLine("```", s);
|
|
64
|
+
const out = styleLine("**not bold**", s);
|
|
65
|
+
expect(out).toContain("**not bold**");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("md_render · styleBlock", () => {
|
|
70
|
+
it("renders a multi-line markdown block", () => {
|
|
71
|
+
const out = styleBlock("## H2\n- bullet **bold**\n\n```\ncode\n```\nnormal.");
|
|
72
|
+
expect(out).not.toMatch(/^\s*##/);
|
|
73
|
+
expect(out).toContain("H2");
|
|
74
|
+
expect(out).toContain("bold");
|
|
75
|
+
expect(out).toContain("code");
|
|
76
|
+
expect(out).not.toContain("**bold**");
|
|
77
|
+
});
|
|
78
|
+
});
|
package/tests/websearch.test.ts
CHANGED
|
@@ -140,6 +140,30 @@ describe("websearch · waterfall", () => {
|
|
|
140
140
|
expect(formatSearchResults(res)).toContain("No search results");
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
it("flags that all providers errored/timed out for a clear message", async () => {
|
|
144
|
+
const http = stubHttp([
|
|
145
|
+
{ match: "s.jina.ai", throws: "timeout" },
|
|
146
|
+
{ match: "duckduckgo", throws: "timeout" }, { match: "bing", throws: "timeout" },
|
|
147
|
+
{ match: "baidu", throws: "timeout" }, { match: "sogou", throws: "timeout" },
|
|
148
|
+
]);
|
|
149
|
+
const res = await webSearch("q", { env: {}, http });
|
|
150
|
+
expect(res.errors).toBeGreaterThan(0);
|
|
151
|
+
expect(formatSearchResults(res)).toMatch(/errored or timed out/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("stops the waterfall once the time budget is exceeded", async () => {
|
|
155
|
+
// jina resolves (empty) but slowly; the budget then cuts off the scrapers.
|
|
156
|
+
const http = {
|
|
157
|
+
calls: [] as string[],
|
|
158
|
+
async getJson(url: string) { this.calls.push(url); await new Promise((r) => setTimeout(r, 30)); return { data: [] }; },
|
|
159
|
+
async getText(url: string) { this.calls.push(url); return ""; },
|
|
160
|
+
async postJson(url: string) { this.calls.push(url); return {}; },
|
|
161
|
+
};
|
|
162
|
+
const res = await webSearch("q", { env: {}, http: http as any, budgetMs: 5 });
|
|
163
|
+
expect(res.provider).toBe("none");
|
|
164
|
+
expect(res.tried).toEqual(["jina"]); // scrapers skipped — out of budget
|
|
165
|
+
});
|
|
166
|
+
|
|
143
167
|
it("rejects an empty query", async () => {
|
|
144
168
|
await expect(webSearch(" ", {})).rejects.toThrow(/query/);
|
|
145
169
|
});
|