@akanjs/devkit 3.1.0-rc.8 → 3.1.0
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/CHANGELOG.md +382 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/akanConfig/akanConfig.test.ts +8 -4
- package/akanConfig/akanConfig.ts +2 -1
- package/applicationBuildRunner.test.ts +77 -2
- package/applicationBuildRunner.ts +28 -5
- package/artifact/implicitRootLayout.test.ts +11 -1
- package/artifact/implicitRootLayout.ts +32 -7
- package/biome.base.json +8 -3
- package/biomeBase.test.ts +78 -0
- package/biomeBase.ts +1 -1
- package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
- package/codeAgent/agent/AkanCodeServices.ts +2 -3
- package/codeAgent/agent/CodeAgent.ts +46 -2
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
- package/codeAgent/agent/codeAgent.test.ts +61 -3
- package/codeAgent/agent/codeAgentContract.test.ts +1 -0
- package/codeAgent/index.ts +1 -0
- package/codeAgent/skills/akan-code/SKILL.md +4 -0
- package/codeAgent/skills/akan-store/SKILL.md +4 -2
- package/codeAgent/tools/AkanCodePlugins.ts +3 -1
- package/codeAgent/tools/DeepSeekWeb.ts +127 -0
- package/codeAgent/tools/WebToolPack.ts +149 -25
- package/codeAgent/tools/akanCodePlugins.test.ts +191 -0
- package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
- package/commandDecorators/argMeta.ts +1 -0
- package/commandDecorators/command.ts +49 -10
- package/commandDecorators/commandBuilder.ts +6 -3
- package/commandDecorators/commandDecorators.test.ts +169 -6
- package/commandDecorators/helpFormatter.ts +2 -1
- package/commandDecorators/targetMeta.ts +10 -0
- package/executors.test.ts +53 -1
- package/executors.ts +10 -6
- package/frontendBuild/csrArtifactBuilder.test.ts +40 -0
- package/frontendBuild/csrArtifactBuilder.ts +24 -1
- package/frontendBuild/cssCandidateCache.test.ts +16 -0
- package/frontendBuild/cssCandidateCache.ts +8 -6
- package/frontendBuild/fontPruner.ts +1 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +170 -0
- package/frontendBuild/mcpAppViewBuilder.ts +245 -0
- package/incrementalBuilder/buildBatch.proc.ts +3 -3
- package/incrementalBuilder/buildBatchProtocol.ts +2 -2
- package/lint/__fixtures__/no-conditional-st-hook/bad.tsx +45 -0
- package/lint/__fixtures__/no-conditional-st-hook/good.tsx +33 -0
- package/lint/no-conditional-st-hook.grit +146 -0
- package/mobile/desktopServerStage.ts +1 -0
- package/package.json +21 -21
- package/piDependencyPins.test.ts +18 -19
- package/routeSourceValidator.test.ts +19 -4
- package/routeSourceValidator.ts +62 -14
- package/scanInfo.ts +36 -3
- package/subspace.test.ts +3 -2
- package/subspaceClone.ts +1 -12
- package/transforms/mcpAppPageTrimmer.test.ts +89 -0
- package/transforms/mcpAppPageTrimmer.ts +70 -0
- package/transforms/pureLazyMarker.test.ts +58 -0
- package/transforms/pureLazyMarker.ts +42 -0
- package/transforms/sideEffectImportOrder.test.ts +183 -0
- package/transforms/sideEffectImportOrder.ts +109 -0
|
@@ -19,14 +19,16 @@ For a model `comment` (`<Slice>` is a signal slice suffix, e.g. `InPublic`):
|
|
|
19
19
|
`add<Field>OnComment(value)` / `sub<Field>OnComment(idx)` for arrays, `upload<Field>OnComment(fileList)`
|
|
20
20
|
for file fields, `checkCommentSubmitable()`, `submitComment()`, `createCommentInForm()`,
|
|
21
21
|
`updateCommentInForm()`
|
|
22
|
-
- single: `viewComment(model)`, `mergeComment(model, data)`, `setComment(...models)`, `resetComment()
|
|
22
|
+
- single: `viewComment(model)`, `mergeComment(model, data)`, `setComment(...models)`, `resetComment()`,
|
|
23
|
+
`invalidateComment()` (marks every slice stale after a custom mutation, so the next mount refetches)
|
|
23
24
|
- per slice: `initComment<Slice>(...args)`, `refreshComment<Slice>()`, `selectComment<Slice>(model)`,
|
|
24
25
|
`setPageOfComment<Slice>(page)`, `loadMoreOfComment<Slice>()`, `setLimitOfComment<Slice>(limit)`,
|
|
25
26
|
`setQueryArgsOfComment<Slice>(...)`, `setSortOfComment<Slice>(sort)`
|
|
26
27
|
|
|
27
28
|
## Generated state — `st.use.*`
|
|
28
29
|
|
|
29
|
-
`comment`, `commentLoading`, `commentForm`, `commentFormLoading`, `
|
|
30
|
+
`comment`, `commentLoading`, `commentForm`, `commentFormLoading`, `commentFormUploading` (uploads in flight, per
|
|
31
|
+
`File` field — non-empty means do not submit), `commentSubmit`, `commentModal`,
|
|
30
32
|
`commentViewAt`, `commentOperation`; per slice `commentList<Slice>`, `commentListLoading<Slice>`,
|
|
31
33
|
`commentInitList<Slice>`, `commentSelection<Slice>`, `commentInsight<Slice>`, `pageOfComment<Slice>`,
|
|
32
34
|
`lastPageOfComment<Slice>`, `hasMoreOfComment<Slice>`, `limitOfComment<Slice>`, `sortOfComment<Slice>`.
|
|
@@ -10,6 +10,7 @@ import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback"
|
|
|
10
10
|
import { VerifyFeedback } from "../feedback/VerifyFeedback";
|
|
11
11
|
import { AkanToolPack } from "./AkanToolPack";
|
|
12
12
|
import { AskToolPack } from "./AskToolPack";
|
|
13
|
+
import type { DeepSeekWeb } from "./DeepSeekWeb";
|
|
13
14
|
import { MailToolPack } from "./MailToolPack";
|
|
14
15
|
import { McpToolPack } from "./McpToolPack";
|
|
15
16
|
import { SessionToolPack } from "./SessionToolPack";
|
|
@@ -22,6 +23,7 @@ export interface AkanCodePluginsOptions {
|
|
|
22
23
|
/** Apps whose dev server should be watched, and whose page should be probed. */
|
|
23
24
|
apps: string[];
|
|
24
25
|
canSeeImages: boolean;
|
|
26
|
+
deepseek?: DeepSeekWeb | undefined;
|
|
25
27
|
/** How many `task` levels deep the owning agent is. */
|
|
26
28
|
depth: number;
|
|
27
29
|
/** The tally of the sub-agent tree the owning agent belongs to, when a `task` opened it. */
|
|
@@ -56,7 +58,7 @@ export class AkanCodePlugins {
|
|
|
56
58
|
});
|
|
57
59
|
const dispose = () => mcp?.close();
|
|
58
60
|
const mcpStatus = mcp?.status ?? [];
|
|
59
|
-
const web = new WebToolPack({ profile: options.profile });
|
|
61
|
+
const web = new WebToolPack({ profile: options.profile, deepseek: options.deepseek });
|
|
60
62
|
const sessions = new SessionToolPack({
|
|
61
63
|
workspaceRoot: options.workspace.workspaceRoot,
|
|
62
64
|
cwd: options.cwd,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { codeAgentClip } from "akanjs/common";
|
|
3
|
+
|
|
4
|
+
interface DeepSeekSearchResult {
|
|
5
|
+
title: string;
|
|
6
|
+
url: string;
|
|
7
|
+
snippet?: string;
|
|
8
|
+
summary?: string;
|
|
9
|
+
date?: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DeepSeekPage {
|
|
13
|
+
title: string;
|
|
14
|
+
content: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class DeepSeekWeb {
|
|
18
|
+
static readonly baseUrl = "https://api.deepseek.com/v1/tools";
|
|
19
|
+
static readonly maxQueryChars = 128;
|
|
20
|
+
static readonly summaryChars = 600;
|
|
21
|
+
// Only a successful call is billed, so a retry costs nothing. Search has no fallback and waits out every transient
|
|
22
|
+
// status; fetch waits only on its quota and otherwise falls back to a direct read, which beats DeepSeek's 504.
|
|
23
|
+
static readonly #retryable: { [tool in "search" | "fetch"]: number[] } = {
|
|
24
|
+
search: [408, 429, 500, 502, 503, 504],
|
|
25
|
+
fetch: [429],
|
|
26
|
+
};
|
|
27
|
+
static readonly #attempts = 3;
|
|
28
|
+
|
|
29
|
+
readonly #key: string;
|
|
30
|
+
readonly #baseUrl: string;
|
|
31
|
+
|
|
32
|
+
constructor(key: string, baseUrl = DeepSeekWeb.baseUrl) {
|
|
33
|
+
this.#key = key;
|
|
34
|
+
this.#baseUrl = baseUrl;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Undefined behind a proxy, where the runtime holds only a placeholder and the real key stays with the host. */
|
|
38
|
+
static async fromRuntime(runtime: ModelRuntime, proxied: boolean) {
|
|
39
|
+
if (proxied) return undefined;
|
|
40
|
+
const key = (await runtime.getAuth("deepseek").catch(() => undefined))?.auth.apiKey;
|
|
41
|
+
return key ? new DeepSeekWeb(key) : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async search(query: string, count: number, signal?: AbortSignal) {
|
|
45
|
+
if ([...query].length > DeepSeekWeb.maxQueryChars)
|
|
46
|
+
throw new Error(`DeepSeek search takes at most ${DeepSeekWeb.maxQueryChars} characters; shorten the query.`);
|
|
47
|
+
const body = await this.#call<{ results?: DeepSeekSearchResult[] }>("search", { query, count }, 20_000, signal);
|
|
48
|
+
const byUrl = new Map<string, DeepSeekSearchResult>();
|
|
49
|
+
for (const result of body.results ?? []) {
|
|
50
|
+
const url = DeepSeekWeb.#cleanUrl(result.url);
|
|
51
|
+
if (!byUrl.has(url)) byUrl.set(url, { ...result, url });
|
|
52
|
+
}
|
|
53
|
+
return [...byUrl.values()]
|
|
54
|
+
.map((result) => {
|
|
55
|
+
const date = result.date ? ` · ${result.date.slice(0, 10)}` : "";
|
|
56
|
+
const summary = DeepSeekWeb.unescape(result.summary || result.snippet || "")
|
|
57
|
+
.replace(/\s+/g, " ")
|
|
58
|
+
.trim();
|
|
59
|
+
return `- ${result.title}\n ${result.url}${date}\n ${codeAgentClip(summary, DeepSeekWeb.summaryChars)}`;
|
|
60
|
+
})
|
|
61
|
+
.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async fetch(url: string, signal?: AbortSignal): Promise<DeepSeekPage> {
|
|
65
|
+
const page = await this.#call<{ title?: string; content?: string }>("fetch", { url }, 30_000, signal);
|
|
66
|
+
return { title: page.title ?? "", content: DeepSeekWeb.unescape(page.content ?? "") };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fetch escapes every ASCII punctuation mark, structure included (`\#\# Title`, `foo\(\)`), which costs tokens
|
|
70
|
+
// and garbles code. Undone only when nearly all punctuation is escaped, so a page already clean keeps its `\.`.
|
|
71
|
+
static unescape(markdown: string) {
|
|
72
|
+
const escaped = markdown.match(/\\[!-/:-@[-`{-~]/g)?.length ?? 0;
|
|
73
|
+
const punctuation = markdown.match(/[!-/:-@[-`{-~]/g)?.length ?? 0;
|
|
74
|
+
if (!escaped || escaped < (punctuation - escaped) / 2) return markdown;
|
|
75
|
+
return markdown.replace(/\\([!-/:-@[-`{-~])/g, "$1");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async #call<T>(tool: "search" | "fetch", body: object, timeoutMs: number, signal?: AbortSignal): Promise<T> {
|
|
79
|
+
for (let attempt = 1; ; attempt++) {
|
|
80
|
+
const response = await fetch(`${this.#baseUrl}/${tool}`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { authorization: `Bearer ${this.#key}`, "content-type": "application/json" },
|
|
83
|
+
body: JSON.stringify(body),
|
|
84
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
85
|
+
});
|
|
86
|
+
if (response.ok) return (await response.json()) as T;
|
|
87
|
+
if (!DeepSeekWeb.#retryable[tool].includes(response.status) || attempt >= DeepSeekWeb.#attempts) {
|
|
88
|
+
const error = (await response.json().catch(() => undefined)) as { error?: { message?: string } } | undefined;
|
|
89
|
+
throw new Error(
|
|
90
|
+
`DeepSeek ${tool} answered ${response.status}: ${error?.error?.message ?? response.statusText}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
await DeepSeekWeb.#wait(DeepSeekWeb.#delayOf(response, attempt), signal);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static #delayOf(response: Response, attempt: number) {
|
|
98
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
99
|
+
return Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 5) * 1000 : 1000 * 2 ** (attempt - 1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static #wait(ms: number, signal?: AbortSignal) {
|
|
103
|
+
return new Promise<void>((resolve) => {
|
|
104
|
+
const timer = setTimeout(resolve, ms);
|
|
105
|
+
signal?.addEventListener(
|
|
106
|
+
"abort",
|
|
107
|
+
() => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
resolve();
|
|
110
|
+
},
|
|
111
|
+
{ once: true },
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Drops the tracking parameters and the bare `#1` chunk marker DeepSeek appends to result URLs. */
|
|
117
|
+
static #cleanUrl(url: string) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = new URL(url);
|
|
120
|
+
for (const name of [...parsed.searchParams.keys()]) if (name.startsWith("utm_")) parsed.searchParams.delete(name);
|
|
121
|
+
if (/^#\d+$/.test(parsed.hash)) parsed.hash = "";
|
|
122
|
+
return parsed.toString();
|
|
123
|
+
} catch {
|
|
124
|
+
return url;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
1
3
|
import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { type CodeAgentProfile, codeAgentClip
|
|
4
|
+
import { type CodeAgentProfile, codeAgentClip } from "akanjs/common";
|
|
3
5
|
import { Type } from "typebox";
|
|
6
|
+
import { DeepSeekWeb } from "./DeepSeekWeb";
|
|
4
7
|
|
|
5
8
|
export interface WebToolPackOptions {
|
|
6
9
|
profile: CodeAgentProfile;
|
|
10
|
+
/** Serves search after any key-named provider, and reads public pages to Markdown ahead of a direct fetch. */
|
|
11
|
+
deepseek?: DeepSeekWeb | undefined;
|
|
12
|
+
lookup?: ((host: string) => Promise<string[]>) | undefined;
|
|
7
13
|
}
|
|
8
14
|
|
|
9
15
|
interface SearchProvider {
|
|
@@ -12,9 +18,27 @@ interface SearchProvider {
|
|
|
12
18
|
search(key: string, query: string, count: number): Promise<string>;
|
|
13
19
|
}
|
|
14
20
|
|
|
21
|
+
interface WebSearcher {
|
|
22
|
+
name: string;
|
|
23
|
+
search(query: string, count: number, signal?: AbortSignal): Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface WebPage {
|
|
27
|
+
status: number;
|
|
28
|
+
ok: boolean;
|
|
29
|
+
title: string;
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
// web_search only with a provider key set: a tool that can never work costs prompt tokens and invites retries.
|
|
16
34
|
export class WebToolPack {
|
|
35
|
+
static readonly pageChars = 10_000;
|
|
36
|
+
static readonly maxResults = 10;
|
|
37
|
+
static readonly #rememberedPages = 16;
|
|
17
38
|
readonly #options: WebToolPackOptions;
|
|
39
|
+
// A continuation reads the snapshot its first page came from; a fresh read always refetches, since a local dev
|
|
40
|
+
// page changes between edits.
|
|
41
|
+
readonly #pages = new Map<string, WebPage>();
|
|
18
42
|
|
|
19
43
|
constructor(options: WebToolPackOptions) {
|
|
20
44
|
this.#options = options;
|
|
@@ -52,7 +76,7 @@ export class WebToolPack {
|
|
|
52
76
|
names() {
|
|
53
77
|
const names: string[] = [];
|
|
54
78
|
if (this.#options.profile.tools.web.fetch) names.push("web_fetch");
|
|
55
|
-
if (this.#options.profile.tools.web.search &&
|
|
79
|
+
if (this.#options.profile.tools.web.search && this.#searcher()) names.push("web_search");
|
|
56
80
|
return names;
|
|
57
81
|
}
|
|
58
82
|
|
|
@@ -63,8 +87,8 @@ export class WebToolPack {
|
|
|
63
87
|
|
|
64
88
|
#register(pi: ExtensionAPI) {
|
|
65
89
|
if (this.#options.profile.tools.web.fetch) this.#registerFetch(pi);
|
|
66
|
-
const
|
|
67
|
-
if (this.#options.profile.tools.web.search &&
|
|
90
|
+
const searcher = this.#searcher();
|
|
91
|
+
if (this.#options.profile.tools.web.search && searcher) this.#registerSearch(pi, searcher);
|
|
68
92
|
}
|
|
69
93
|
|
|
70
94
|
#registerFetch(pi: ExtensionAPI) {
|
|
@@ -72,48 +96,148 @@ export class WebToolPack {
|
|
|
72
96
|
pi.registerTool({
|
|
73
97
|
name: "web_fetch",
|
|
74
98
|
label: "Fetch",
|
|
75
|
-
description:
|
|
99
|
+
description: `Fetch a URL and return its readable text, ${WebToolPack.pageChars} characters at a time. A longer page ends with the offset that continues it.`,
|
|
76
100
|
promptSnippet: "web_fetch: fetch a URL and return its readable text",
|
|
77
|
-
parameters: Type.Object({
|
|
78
|
-
|
|
101
|
+
parameters: Type.Object({
|
|
102
|
+
url: Type.String({ description: "Absolute http(s) URL." }),
|
|
103
|
+
offset: Type.Optional(Type.Number({ description: "Character offset a previous call named, to read on." })),
|
|
104
|
+
}),
|
|
105
|
+
execute: async (_id, params, signal) => {
|
|
79
106
|
const refusal = WebToolPack.#refuseHost(params.url, allowHosts);
|
|
80
107
|
if (refusal) return { content: [{ type: "text", text: refusal }], details: undefined, isError: true };
|
|
81
|
-
const
|
|
82
|
-
const
|
|
108
|
+
const offset = Math.max(0, Math.floor(params.offset ?? 0));
|
|
109
|
+
const cached = offset ? this.#pages.get(params.url) : undefined;
|
|
110
|
+
const page = cached ?? (await this.#read(params.url, signal));
|
|
111
|
+
if (page.ok) this.#remember(params.url, page);
|
|
83
112
|
return {
|
|
84
|
-
content: [
|
|
85
|
-
{ type: "text", text: `${response.status} ${params.url}\n\n${codeAgentClip(text, codeAgentOutputChars)}` },
|
|
86
|
-
],
|
|
113
|
+
content: [{ type: "text", text: WebToolPack.#window(params.url, page, offset) }],
|
|
87
114
|
details: undefined,
|
|
88
|
-
isError: !
|
|
115
|
+
isError: !page.ok,
|
|
89
116
|
};
|
|
90
117
|
},
|
|
91
118
|
});
|
|
92
119
|
}
|
|
93
120
|
|
|
94
|
-
#registerSearch(pi: ExtensionAPI,
|
|
121
|
+
#registerSearch(pi: ExtensionAPI, searcher: WebSearcher) {
|
|
122
|
+
const hint =
|
|
123
|
+
searcher.name === "deepseek"
|
|
124
|
+
? ` Keep the query within ${DeepSeekWeb.maxQueryChars} characters; site:, -site: and quoted phrases work.`
|
|
125
|
+
: "";
|
|
95
126
|
pi.registerTool({
|
|
96
127
|
name: "web_search",
|
|
97
128
|
label: "Search",
|
|
98
|
-
description: `Search the web (${
|
|
129
|
+
description: `Search the web (${searcher.name}) and return the top results as title, url and summary.${hint}`,
|
|
99
130
|
promptSnippet: "web_search: search the web and return titles, urls and snippets",
|
|
100
131
|
parameters: Type.Object({
|
|
101
132
|
query: Type.String(),
|
|
102
|
-
count: Type.Optional(
|
|
133
|
+
count: Type.Optional(
|
|
134
|
+
Type.Number({ description: `How many results, default 5, at most ${WebToolPack.maxResults}.` }),
|
|
135
|
+
),
|
|
103
136
|
}),
|
|
104
|
-
execute: async (_id, params) => {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
137
|
+
execute: async (_id, params, signal) => {
|
|
138
|
+
const count = Math.min(Math.max(Math.round(params.count ?? 5), 1), WebToolPack.maxResults);
|
|
139
|
+
try {
|
|
140
|
+
const results = await searcher.search(params.query, count, signal);
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: codeAgentClip(results || "(no results)", WebToolPack.pageChars) }],
|
|
143
|
+
details: undefined,
|
|
144
|
+
};
|
|
145
|
+
} catch (error: unknown) {
|
|
146
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
+
return { content: [{ type: "text", text: message }], details: undefined, isError: true };
|
|
148
|
+
}
|
|
111
149
|
},
|
|
112
150
|
});
|
|
113
151
|
}
|
|
114
152
|
|
|
115
|
-
|
|
116
|
-
|
|
153
|
+
#searcher(): WebSearcher | undefined {
|
|
154
|
+
const provider = WebToolPack.providers.find((entry) => !!process.env[entry.envKey]);
|
|
155
|
+
if (provider)
|
|
156
|
+
return {
|
|
157
|
+
name: provider.name,
|
|
158
|
+
search: (query, count) => provider.search(process.env[provider.envKey] ?? "", query, count),
|
|
159
|
+
};
|
|
160
|
+
const deepseek = this.#options.deepseek;
|
|
161
|
+
return deepseek
|
|
162
|
+
? { name: "deepseek", search: (query, count, signal) => deepseek.search(query, count, signal) }
|
|
163
|
+
: undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async #read(url: string, signal?: AbortSignal): Promise<WebPage> {
|
|
167
|
+
const deepseek = this.#options.deepseek;
|
|
168
|
+
if (deepseek && !(await this.#isLocal(new URL(url).hostname))) {
|
|
169
|
+
try {
|
|
170
|
+
const page = await deepseek.fetch(url, signal);
|
|
171
|
+
return { status: 200, ok: true, title: page.title, text: page.content };
|
|
172
|
+
} catch (error: unknown) {
|
|
173
|
+
if (signal?.aborted) throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const response = await fetch(url, {
|
|
177
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000),
|
|
178
|
+
redirect: "follow",
|
|
179
|
+
});
|
|
180
|
+
return { status: response.status, ok: response.ok, title: "", text: WebToolPack.#readable(await response.text()) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#remember(url: string, page: WebPage) {
|
|
184
|
+
this.#pages.delete(url);
|
|
185
|
+
this.#pages.set(url, page);
|
|
186
|
+
const oldest = this.#pages.keys().next().value;
|
|
187
|
+
if (this.#pages.size > WebToolPack.#rememberedPages && oldest) this.#pages.delete(oldest);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
static #window(url: string, page: WebPage, offset: number) {
|
|
191
|
+
const head = [`${page.status} ${url}`, page.title].filter(Boolean).join("\n");
|
|
192
|
+
const total = page.text.length;
|
|
193
|
+
if (offset && offset >= total)
|
|
194
|
+
return `${head}\n\nOffset ${offset} is past the end; the page has ${total} characters.`;
|
|
195
|
+
const end = Math.min(offset + WebToolPack.pageChars, total);
|
|
196
|
+
const rest = end < total ? `\n\n[characters ${offset}–${end} of ${total}; offset ${end} reads on]` : "";
|
|
197
|
+
return `${head}\n\n${page.text.slice(offset, end)}${rest}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// DeepSeek's servers cannot reach these, and an intranet name has no business leaving the machine.
|
|
201
|
+
async #isLocal(hostname: string) {
|
|
202
|
+
const host = hostname.replace(/^\[|\]$|\.$/g, "").toLowerCase();
|
|
203
|
+
if (isIP(host)) return WebToolPack.#isLocalAddress(host);
|
|
204
|
+
if (!host.includes(".") || /\.(localhost|local|internal|lan|home\.arpa)$/.test(host)) return true;
|
|
205
|
+
try {
|
|
206
|
+
const addresses = await (this.#options.lookup ?? WebToolPack.#lookup)(host);
|
|
207
|
+
return !addresses.length || addresses.some((address) => WebToolPack.#isLocalAddress(address));
|
|
208
|
+
} catch {
|
|
209
|
+
// Fails closed: an intranet name this machine cannot resolve right now is still an intranet name.
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
static async #lookup(host: string) {
|
|
215
|
+
return (await lookup(host, { all: true })).map((entry) => entry.address);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
static #isLocalAddress(literal: string) {
|
|
219
|
+
const address = WebToolPack.#unmapped(literal);
|
|
220
|
+
if (isIP(address) === 6) return address === "::" || address === "::1" || /^(f[cd]|fe[89ab])/.test(address);
|
|
221
|
+
const [a = 0, b = 0] = address.split(".").map(Number);
|
|
222
|
+
return (
|
|
223
|
+
a === 0 ||
|
|
224
|
+
a === 10 ||
|
|
225
|
+
a === 127 ||
|
|
226
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
227
|
+
(a === 169 && b === 254) ||
|
|
228
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
229
|
+
(a === 192 && b === 168)
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The URL parser writes `[::ffff:127.0.0.1]` as `::ffff:7f00:1`; a resolver hands a mapped address back dotted.
|
|
234
|
+
static #unmapped(address: string) {
|
|
235
|
+
const mapped = /^::ffff:(?:(\d+\.\d+\.\d+\.\d+)|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/i.exec(address);
|
|
236
|
+
if (!mapped) return address;
|
|
237
|
+
if (mapped[1]) return mapped[1];
|
|
238
|
+
const high = Number.parseInt(mapped[2] ?? "0", 16);
|
|
239
|
+
const low = Number.parseInt(mapped[3] ?? "0", 16);
|
|
240
|
+
return [high >> 8, high & 255, low >> 8, low & 255].join(".");
|
|
117
241
|
}
|
|
118
242
|
|
|
119
243
|
static #refuseHost(url: string, allowHosts: string[] | undefined) {
|
|
@@ -11,6 +11,7 @@ import { AkanCodePlugins } from "./AkanCodePlugins";
|
|
|
11
11
|
import { AkanEditScope } from "./AkanEditScope";
|
|
12
12
|
import { AkanEnvKeys } from "./AkanEnvKeys";
|
|
13
13
|
import { AkanVerifier } from "./AkanVerifier";
|
|
14
|
+
import { DeepSeekWeb } from "./DeepSeekWeb";
|
|
14
15
|
import { McpClient } from "./McpClient";
|
|
15
16
|
import { McpToolPack } from "./McpToolPack";
|
|
16
17
|
import { SessionToolPack } from "./SessionToolPack";
|
|
@@ -652,6 +653,196 @@ describe("WebToolPack", () => {
|
|
|
652
653
|
server.stop(true);
|
|
653
654
|
}
|
|
654
655
|
});
|
|
656
|
+
|
|
657
|
+
test("reads a long page a window at a time, continuing from the snapshot it first read", async () => {
|
|
658
|
+
let hits = 0;
|
|
659
|
+
const server = Bun.serve({
|
|
660
|
+
port: 0,
|
|
661
|
+
fetch: () => {
|
|
662
|
+
hits += 1;
|
|
663
|
+
return new Response(`${"a".repeat(10_000)}${"b".repeat(10_000)}${"c".repeat(500)}`);
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
try {
|
|
667
|
+
const fetchTool = toolsOf(new WebToolPack({ profile: codeAgentPresets.local(root) }).extension()).get(
|
|
668
|
+
"web_fetch",
|
|
669
|
+
);
|
|
670
|
+
const url = `http://localhost:${server.port}/long`;
|
|
671
|
+
const read = async (offset?: number) =>
|
|
672
|
+
(await fetchTool?.("1", { url, ...(offset === undefined ? {} : { offset }) } as never))?.content[0]?.text ?? "";
|
|
673
|
+
|
|
674
|
+
const first = await read();
|
|
675
|
+
expect(first).toContain(`${"a".repeat(10_000)}\n\n[characters 0–10000 of 20500; offset 10000 reads on]`);
|
|
676
|
+
const second = await read(10_000);
|
|
677
|
+
expect(second).toContain(`\n\n${"b".repeat(10_000)}\n\n[characters 10000–20000 of 20500; offset 20000 reads on]`);
|
|
678
|
+
expect(await read(20_000)).toEndWith(`\n\n${"c".repeat(500)}`);
|
|
679
|
+
expect(await read(30_000)).toContain("Offset 30000 is past the end; the page has 20500 characters.");
|
|
680
|
+
expect(hits).toBe(1);
|
|
681
|
+
await read();
|
|
682
|
+
expect(hits).toBe(2);
|
|
683
|
+
} finally {
|
|
684
|
+
server.stop(true);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("reads a public page through DeepSeek, a local one directly, and a refused one directly too", async () => {
|
|
689
|
+
const asked: string[] = [];
|
|
690
|
+
const deepseekServer = Bun.serve({
|
|
691
|
+
port: 0,
|
|
692
|
+
fetch: async (request) => {
|
|
693
|
+
const { url } = (await request.json()) as { url: string };
|
|
694
|
+
asked.push(url);
|
|
695
|
+
if (url.endsWith("/refused"))
|
|
696
|
+
return Response.json({ error: { message: "rejected by security policy" } }, { status: 403 });
|
|
697
|
+
return Response.json({ title: "Install", content: "\\#\\# Install\n\nRun \\`bun add akanjs\\`\\." });
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
const page = Bun.serve({ port: 0, fetch: () => new Response("<h1>local dev page</h1>") });
|
|
701
|
+
const realFetch = globalThis.fetch;
|
|
702
|
+
const direct = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
703
|
+
input: RequestInfo | URL,
|
|
704
|
+
init?: RequestInit,
|
|
705
|
+
) => {
|
|
706
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
707
|
+
if (new URL(url).hostname === "docs.example.com") return new Response("<p>read directly</p>");
|
|
708
|
+
return await realFetch(input, init);
|
|
709
|
+
}) as typeof fetch);
|
|
710
|
+
try {
|
|
711
|
+
const deepseek = new DeepSeekWeb("sk-test", `http://127.0.0.1:${deepseekServer.port}/v1/tools`);
|
|
712
|
+
const lookup = async () => ["93.184.215.14"];
|
|
713
|
+
const pack = new WebToolPack({ profile: codeAgentPresets.local(root), deepseek, lookup });
|
|
714
|
+
const fetchTool = toolsOf(pack.extension()).get("web_fetch");
|
|
715
|
+
const read = async (url: string) => (await fetchTool?.("1", { url } as never))?.content[0]?.text ?? "";
|
|
716
|
+
|
|
717
|
+
expect(await read("https://docs.example.com/install")).toBe(
|
|
718
|
+
"200 https://docs.example.com/install\nInstall\n\n## Install\n\nRun `bun add akanjs`.",
|
|
719
|
+
);
|
|
720
|
+
expect(await read(`http://localhost:${page.port}/`)).toContain("local dev page");
|
|
721
|
+
expect(asked).toEqual(["https://docs.example.com/install"]);
|
|
722
|
+
expect(await read("https://docs.example.com/refused")).toContain("read directly");
|
|
723
|
+
expect(asked.at(-1)).toBe("https://docs.example.com/refused");
|
|
724
|
+
} finally {
|
|
725
|
+
direct.mockRestore();
|
|
726
|
+
deepseekServer.stop(true);
|
|
727
|
+
page.stop(true);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("reads an intranet name, a mapped or unspecified loopback, and a name that does not resolve directly", async () => {
|
|
732
|
+
const asked: string[] = [];
|
|
733
|
+
const deepseekServer = Bun.serve({
|
|
734
|
+
port: 0,
|
|
735
|
+
fetch: async (request) => {
|
|
736
|
+
asked.push(((await request.json()) as { url: string }).url);
|
|
737
|
+
return Response.json({ title: "Public", content: "read by DeepSeek" });
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
const realFetch = globalThis.fetch;
|
|
741
|
+
const direct = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
742
|
+
input: RequestInfo | URL,
|
|
743
|
+
init?: RequestInit,
|
|
744
|
+
) => {
|
|
745
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
746
|
+
if (new URL(url).port === String(deepseekServer.port)) return await realFetch(input, init);
|
|
747
|
+
return new Response("<p>read directly</p>");
|
|
748
|
+
}) as typeof fetch);
|
|
749
|
+
const addresses: { [host: string]: string[] } = {
|
|
750
|
+
"jira.corp.example.com": ["10.0.0.7"],
|
|
751
|
+
"wiki.example.com": ["93.184.215.14", "fd12::1"],
|
|
752
|
+
"hosts.example.com": ["::ffff:192.168.0.2"],
|
|
753
|
+
"docs.example.com": ["93.184.215.14", "2606:2800:21f:cb07:6820:80da:af6b:8b2c"],
|
|
754
|
+
};
|
|
755
|
+
const looked: string[] = [];
|
|
756
|
+
const lookup = async (host: string) => {
|
|
757
|
+
looked.push(host);
|
|
758
|
+
const found = addresses[host];
|
|
759
|
+
if (!found) throw new Error(`getaddrinfo ENOTFOUND ${host}`);
|
|
760
|
+
return found;
|
|
761
|
+
};
|
|
762
|
+
try {
|
|
763
|
+
const deepseek = new DeepSeekWeb("sk-test", `http://127.0.0.1:${deepseekServer.port}/v1/tools`);
|
|
764
|
+
const pack = new WebToolPack({ profile: codeAgentPresets.local(root), deepseek, lookup });
|
|
765
|
+
const fetchTool = toolsOf(pack.extension()).get("web_fetch");
|
|
766
|
+
const read = async (url: string) => (await fetchTool?.("1", { url } as never))?.content[0]?.text ?? "";
|
|
767
|
+
|
|
768
|
+
for (const url of [
|
|
769
|
+
"http://jira.corp.example.com/browse/SEC-1",
|
|
770
|
+
"http://wiki.example.com/",
|
|
771
|
+
"http://hosts.example.com/",
|
|
772
|
+
"http://foo.test/",
|
|
773
|
+
"http://[::ffff:127.0.0.1]:8282/x",
|
|
774
|
+
"http://[::ffff:10.0.0.5]/wiki",
|
|
775
|
+
"http://[::]:8282/",
|
|
776
|
+
"http://0.0.0.0:8282/",
|
|
777
|
+
"http://localhost.:8282/",
|
|
778
|
+
"http://app.local./",
|
|
779
|
+
])
|
|
780
|
+
expect(await read(url)).toContain("read directly");
|
|
781
|
+
expect(asked).toEqual([]);
|
|
782
|
+
|
|
783
|
+
expect(await read("https://docs.example.com/install")).toContain("read by DeepSeek");
|
|
784
|
+
expect(await read("http://[2606:4700:4700::1111]/")).toContain("read by DeepSeek");
|
|
785
|
+
expect(asked).toEqual(["https://docs.example.com/install", "http://[2606:4700:4700::1111]/"]);
|
|
786
|
+
expect(looked).toEqual([
|
|
787
|
+
"jira.corp.example.com",
|
|
788
|
+
"wiki.example.com",
|
|
789
|
+
"hosts.example.com",
|
|
790
|
+
"foo.test",
|
|
791
|
+
"docs.example.com",
|
|
792
|
+
]);
|
|
793
|
+
} finally {
|
|
794
|
+
direct.mockRestore();
|
|
795
|
+
deepseekServer.stop(true);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("searches through DeepSeek when no key-named provider is set, and a key-named one wins", async () => {
|
|
800
|
+
const had = { brave: process.env.BRAVE_API_KEY, tavily: process.env.TAVILY_API_KEY };
|
|
801
|
+
const counts: number[] = [];
|
|
802
|
+
const deepseekServer = Bun.serve({
|
|
803
|
+
port: 0,
|
|
804
|
+
fetch: async (request) => {
|
|
805
|
+
counts.push(((await request.json()) as { count: number }).count);
|
|
806
|
+
return Response.json({
|
|
807
|
+
results: [{ title: "Akan.js", url: "https://akanjs.com/?utm_source=x", summary: "s" }],
|
|
808
|
+
});
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
const descriptionsOf = (pack: WebToolPack) => {
|
|
812
|
+
const descriptions = new Map<string, string>();
|
|
813
|
+
(pack.extension() as { factory: (pi: unknown) => void }).factory({
|
|
814
|
+
registerTool: (tool: { name: string; description: string }) => descriptions.set(tool.name, tool.description),
|
|
815
|
+
});
|
|
816
|
+
return descriptions;
|
|
817
|
+
};
|
|
818
|
+
try {
|
|
819
|
+
delete process.env.BRAVE_API_KEY;
|
|
820
|
+
delete process.env.TAVILY_API_KEY;
|
|
821
|
+
const deepseek = new DeepSeekWeb("sk-test", `http://127.0.0.1:${deepseekServer.port}/v1/tools`);
|
|
822
|
+
const pack = new WebToolPack({ profile: codeAgentPresets.local(root), deepseek });
|
|
823
|
+
expect(new WebToolPack({ profile: codeAgentPresets.review(root), deepseek }).names()).toEqual([]);
|
|
824
|
+
expect(descriptionsOf(pack).get("web_search")).toContain("(deepseek)");
|
|
825
|
+
|
|
826
|
+
const search = toolsOf(pack.extension()).get("web_search");
|
|
827
|
+
const found = await search?.("1", { query: "akan", count: 50 } as never);
|
|
828
|
+
expect(found?.content[0]?.text).toBe("- Akan.js\n https://akanjs.com/\n s");
|
|
829
|
+
expect(counts).toEqual([WebToolPack.maxResults]);
|
|
830
|
+
const tooLong = await search?.("1", { query: "x".repeat(DeepSeekWeb.maxQueryChars + 1) } as never);
|
|
831
|
+
expect(tooLong?.isError).toBe(true);
|
|
832
|
+
expect(counts).toHaveLength(1);
|
|
833
|
+
|
|
834
|
+
process.env.BRAVE_API_KEY = "test";
|
|
835
|
+
expect(descriptionsOf(pack).get("web_search")).toContain("(brave)");
|
|
836
|
+
} finally {
|
|
837
|
+
for (const [name, value] of [
|
|
838
|
+
["BRAVE_API_KEY", had.brave],
|
|
839
|
+
["TAVILY_API_KEY", had.tavily],
|
|
840
|
+
] as const)
|
|
841
|
+
if (value === undefined) delete process.env[name];
|
|
842
|
+
else process.env[name] = value;
|
|
843
|
+
deepseekServer.stop(true);
|
|
844
|
+
}
|
|
845
|
+
});
|
|
655
846
|
});
|
|
656
847
|
|
|
657
848
|
describe("SessionToolPack", () => {
|