codeep 2.14.0 → 2.16.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.
Files changed (70) hide show
  1. package/README.md +47 -27
  2. package/dist/acp/commands.js +22 -1
  3. package/dist/acp/server.js +13 -2
  4. package/dist/acp/session.js +22 -1
  5. package/dist/config/index.d.ts +10 -0
  6. package/dist/config/index.js +2 -2
  7. package/dist/config/providers.js +35 -24
  8. package/dist/renderer/App.d.ts +77 -30
  9. package/dist/renderer/App.js +429 -659
  10. package/dist/renderer/agentExecution.d.ts +1 -0
  11. package/dist/renderer/agentExecution.js +3 -2
  12. package/dist/renderer/commands/helpers.d.ts +251 -0
  13. package/dist/renderer/commands/helpers.js +450 -0
  14. package/dist/renderer/commands/registry.js +7 -1
  15. package/dist/renderer/commands.d.ts +4 -0
  16. package/dist/renderer/commands.js +363 -318
  17. package/dist/renderer/components/ActionFormatting.d.ts +17 -0
  18. package/dist/renderer/components/ActionFormatting.js +67 -0
  19. package/dist/renderer/components/Autocomplete.d.ts +58 -0
  20. package/dist/renderer/components/Autocomplete.js +75 -0
  21. package/dist/renderer/components/Intro.d.ts +9 -0
  22. package/dist/renderer/components/Intro.js +5 -15
  23. package/dist/renderer/components/MessageFormatter.d.ts +96 -0
  24. package/dist/renderer/components/MessageFormatter.js +375 -0
  25. package/dist/renderer/components/Permission.d.ts +4 -0
  26. package/dist/renderer/components/Permission.js +1 -1
  27. package/dist/renderer/components/Status.d.ts +4 -0
  28. package/dist/renderer/components/Status.js +2 -3
  29. package/dist/renderer/components/WelcomeFormatter.d.ts +19 -0
  30. package/dist/renderer/components/WelcomeFormatter.js +79 -0
  31. package/dist/renderer/components/uiConstants.d.ts +8 -0
  32. package/dist/renderer/components/uiConstants.js +24 -0
  33. package/dist/renderer/inputParsing.d.ts +22 -0
  34. package/dist/renderer/inputParsing.js +28 -0
  35. package/dist/renderer/layout.d.ts +219 -0
  36. package/dist/renderer/layout.js +338 -0
  37. package/dist/renderer/main.d.ts +2 -1
  38. package/dist/renderer/main.js +79 -11
  39. package/dist/renderer/ollamaHint.d.ts +12 -0
  40. package/dist/renderer/ollamaHint.js +29 -0
  41. package/dist/utils/agentChat.js +23 -1
  42. package/dist/utils/codeepCloud.d.ts +54 -0
  43. package/dist/utils/codeepCloud.js +95 -0
  44. package/dist/utils/diffPreview.d.ts +31 -0
  45. package/dist/utils/diffPreview.js +102 -0
  46. package/dist/utils/export.d.ts +12 -0
  47. package/dist/utils/export.js +3 -3
  48. package/dist/utils/git.d.ts +28 -0
  49. package/dist/utils/git.js +111 -1
  50. package/dist/utils/hooks.d.ts +26 -0
  51. package/dist/utils/hooks.js +69 -1
  52. package/dist/utils/keychain.js +45 -29
  53. package/dist/utils/logger.d.ts +12 -0
  54. package/dist/utils/logger.js +1 -1
  55. package/dist/utils/mcpConfig.d.ts +26 -0
  56. package/dist/utils/mcpConfig.js +109 -4
  57. package/dist/utils/mentions.d.ts +195 -0
  58. package/dist/utils/mentions.js +672 -0
  59. package/dist/utils/skillBundles.d.ts +14 -0
  60. package/dist/utils/skillBundles.js +3 -3
  61. package/dist/utils/skillBundlesCloud.d.ts +7 -0
  62. package/dist/utils/skillBundlesCloud.js +1 -1
  63. package/dist/utils/tokenTracker.js +21 -5
  64. package/dist/utils/toolParsing.d.ts +11 -0
  65. package/dist/utils/toolParsing.js +6 -0
  66. package/dist/utils/webFetch.d.ts +101 -0
  67. package/dist/utils/webFetch.js +375 -0
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/package.json +2 -2
@@ -42,7 +42,7 @@ import { homedir } from 'os';
42
42
  * unquoted. We don't ship a real YAML dep for this — the keys we care
43
43
  * about are scalars or simple arrays.
44
44
  */
45
- function parseFrontmatter(raw) {
45
+ export function parseFrontmatter(raw) {
46
46
  // BOM + CRLF normalisation. Real-world files copy/paste from various
47
47
  // editors and pick up either; YAML strictly forbids tabs in scalars
48
48
  // but we don't care for the keys we read.
@@ -94,7 +94,7 @@ function parseFrontmatter(raw) {
94
94
  }
95
95
  return { meta, body: match[2].trimStart() };
96
96
  }
97
- function stripQuotes(s) {
97
+ export function stripQuotes(s) {
98
98
  return s.replace(/^["']|["']$/g, '');
99
99
  }
100
100
  function loadFromDir(dir, scope) {
@@ -158,7 +158,7 @@ function loadFromDir(dir, scope) {
158
158
  }
159
159
  return bundles;
160
160
  }
161
- function asStringArray(v) {
161
+ export function asStringArray(v) {
162
162
  if (Array.isArray(v))
163
163
  return v.filter(x => typeof x === 'string');
164
164
  if (typeof v === 'string')
@@ -10,6 +10,7 @@
10
10
  * Auth uses the same `x-sync-token` header `codeepCloud.ts` already sends
11
11
  * for /api/tasks and friends.
12
12
  */
13
+ import { type SkillBundle } from './skillBundles.js';
13
14
  export interface RemoteSkill {
14
15
  id: number;
15
16
  github_id: string;
@@ -63,6 +64,12 @@ export declare function unpublishBundle(idOrPath: string): Promise<{
63
64
  ok: boolean;
64
65
  error?: string;
65
66
  }>;
67
+ /**
68
+ * Re-serialise a loaded SkillBundle back into the SKILL.md text format.
69
+ * Used by publish so the round-trip is lossless (sort of — we drop
70
+ * unknown frontmatter keys for now to keep the published format stable).
71
+ */
72
+ export declare function serialiseSkillMd(bundle: SkillBundle): string;
66
73
  /** Read raw SKILL.md from disk — used when we want the unmodified bytes. */
67
74
  export declare function readRawSkillMd(workspaceRoot: string, slug: string): string | null;
68
75
  /** Delete the local copy of an installed skill bundle (for /skills uninstall). */
@@ -144,7 +144,7 @@ export async function unpublishBundle(idOrPath) {
144
144
  * Used by publish so the round-trip is lossless (sort of — we drop
145
145
  * unknown frontmatter keys for now to keep the published format stable).
146
146
  */
147
- function serialiseSkillMd(bundle) {
147
+ export function serialiseSkillMd(bundle) {
148
148
  const meta = ['---'];
149
149
  meta.push(`name: ${bundle.name}`);
150
150
  meta.push(`description: ${bundle.description}`);
@@ -11,12 +11,15 @@ const MODEL_CONTEXT_WINDOWS = {
11
11
  'glm-5.2': 200_000,
12
12
  'glm-5-turbo': 202_752,
13
13
  // OpenAI
14
+ 'gpt-5.6-sol': 1_050_000,
15
+ 'gpt-5.6-terra': 1_050_000,
16
+ 'gpt-5.6-luna': 1_050_000,
14
17
  'gpt-5.5': 1_200_000,
15
18
  'gpt-5.4': 1_050_000,
16
19
  'gpt-5.4-mini': 400_000,
17
20
  // Anthropic
18
21
  'claude-fable-5': 1_000_000,
19
- 'claude-opus-4-8': 1_000_000,
22
+ 'claude-opus-5': 1_000_000,
20
23
  'claude-sonnet-4-6': 1_000_000,
21
24
  'claude-sonnet-5': 1_000_000,
22
25
  'claude-haiku-4-5-20251001': 200_000,
@@ -26,16 +29,21 @@ const MODEL_CONTEXT_WINDOWS = {
26
29
  // Google
27
30
  'gemini-3.1-pro-preview': 1_048_576,
28
31
  'gemini-3.5-flash': 1_000_000,
32
+ 'gemini-3.1-flash-lite': 1_048_576,
29
33
  'gemini-3-flash-preview': 1_000_000,
30
34
  // MiniMax
31
35
  'MiniMax-M3': 524_288,
32
- // Kimi (Moonshot) — 256K across the K2.x line
36
+ // Kimi (Moonshot) — 1M on the K3 line, 256K across K2.x
37
+ 'kimi-k3-code': 1_000_000,
38
+ 'kimi-k3-code-highspeed': 1_000_000,
39
+ 'kimi-k3-thinking': 1_000_000,
33
40
  'kimi-k2.7-code': 262_144,
34
41
  'kimi-k2.7-code-highspeed': 262_144,
35
42
  'kimi-k2.6': 262_144,
36
43
  'kimi-k2.5': 262_144,
37
44
  'kimi-for-coding': 262_144,
38
45
  // Grok (xAI)
46
+ 'grok-4.5': 500_000,
39
47
  'grok-build-0.1': 256_000,
40
48
  'grok-4.3': 1_000_000,
41
49
  'grok-code-fast-1': 256_000,
@@ -44,7 +52,7 @@ const MODEL_CONTEXT_WINDOWS = {
44
52
  'qwen3-coder-plus': 262_144,
45
53
  'qwen3-coder-next': 262_144,
46
54
  'qwen3-coder-flash': 262_144,
47
- 'qwen3-max': 262_144,
55
+ 'qwen3.7-max': 1_000_000,
48
56
  'Qwen/Qwen3-Coder-480B-A35B-Instruct': 262_144,
49
57
  };
50
58
  const DEFAULT_CONTEXT_WINDOW = 128_000;
@@ -66,12 +74,15 @@ const MODEL_PRICING = {
66
74
  'glm-5.2': { inputPer1M: 1.00, outputPer1M: 3.20 },
67
75
  'glm-5-turbo': { inputPer1M: 1.20, outputPer1M: 4.00 },
68
76
  // OpenAI
77
+ 'gpt-5.6-sol': { inputPer1M: 5.00, outputPer1M: 30.00 },
78
+ 'gpt-5.6-terra': { inputPer1M: 2.50, outputPer1M: 15.00 },
79
+ 'gpt-5.6-luna': { inputPer1M: 1.00, outputPer1M: 6.00 },
69
80
  'gpt-5.5': { inputPer1M: 5.00, outputPer1M: 30.00 },
70
81
  'gpt-5.4': { inputPer1M: 2.50, outputPer1M: 15.00 },
71
82
  'gpt-5.4-mini': { inputPer1M: 0.75, outputPer1M: 4.50 },
72
83
  // Anthropic
73
84
  'claude-fable-5': { inputPer1M: 10.00, outputPer1M: 50.00 },
74
- 'claude-opus-4-8': { inputPer1M: 5.00, outputPer1M: 25.00 },
85
+ 'claude-opus-5': { inputPer1M: 5.00, outputPer1M: 25.00 },
75
86
  'claude-sonnet-4-6': { inputPer1M: 3.00, outputPer1M: 15.00 },
76
87
  'claude-sonnet-5': { inputPer1M: 3.00, outputPer1M: 15.00 },
77
88
  'claude-haiku-4-5-20251001': { inputPer1M: 1.00, outputPer1M: 5.00 },
@@ -81,17 +92,22 @@ const MODEL_PRICING = {
81
92
  // Google
82
93
  'gemini-3.1-pro-preview': { inputPer1M: 2.00, outputPer1M: 12.00 },
83
94
  'gemini-3.5-flash': { inputPer1M: 1.50, outputPer1M: 9.00 },
95
+ 'gemini-3.1-flash-lite': { inputPer1M: 0.25, outputPer1M: 1.50 },
84
96
  'gemini-3-flash-preview': { inputPer1M: 0.50, outputPer1M: 3.00 },
85
97
  // MiniMax
86
98
  'MiniMax-M3': { inputPer1M: 0.60, outputPer1M: 2.40 },
87
99
  // Kimi (Moonshot) — pay-per-use cache-miss rates; `kimi-for-coding` is the
88
100
  // subscription alias (flat-fee in reality, priced notionally like K2.7 Code).
101
+ 'kimi-k3-code': { inputPer1M: 0.60, outputPer1M: 2.50 },
102
+ 'kimi-k3-code-highspeed': { inputPer1M: 0.60, outputPer1M: 2.50 },
103
+ 'kimi-k3-thinking': { inputPer1M: 0.60, outputPer1M: 2.50 },
89
104
  'kimi-k2.7-code': { inputPer1M: 0.60, outputPer1M: 2.50 },
90
105
  'kimi-k2.7-code-highspeed': { inputPer1M: 0.60, outputPer1M: 2.50 },
91
106
  'kimi-k2.6': { inputPer1M: 0.55, outputPer1M: 2.20 },
92
107
  'kimi-k2.5': { inputPer1M: 0.40, outputPer1M: 1.90 },
93
108
  'kimi-for-coding': { inputPer1M: 0.60, outputPer1M: 2.50 },
94
109
  // Grok (xAI)
110
+ 'grok-4.5': { inputPer1M: 2.00, outputPer1M: 6.00 },
95
111
  'grok-build-0.1': { inputPer1M: 1.00, outputPer1M: 2.00 },
96
112
  'grok-4.3': { inputPer1M: 1.25, outputPer1M: 2.50 },
97
113
  'grok-code-fast-1': { inputPer1M: 0.20, outputPer1M: 1.50 },
@@ -101,7 +117,7 @@ const MODEL_PRICING = {
101
117
  'qwen3-coder-plus': { inputPer1M: 0.28, outputPer1M: 1.65 },
102
118
  'qwen3-coder-next': { inputPer1M: 0.28, outputPer1M: 1.65 },
103
119
  'qwen3-coder-flash': { inputPer1M: 0.10, outputPer1M: 0.50 },
104
- 'qwen3-max': { inputPer1M: 1.20, outputPer1M: 6.00 },
120
+ 'qwen3.7-max': { inputPer1M: 2.50, outputPer1M: 7.50 },
105
121
  // ModelScope free tier — no per-token charge.
106
122
  'Qwen/Qwen3-Coder-480B-A35B-Instruct': { inputPer1M: 0, outputPer1M: 0 },
107
123
  };
@@ -9,10 +9,21 @@ import { ToolCall } from './tools';
9
9
  * Normalize tool name to lowercase with underscores
10
10
  */
11
11
  export declare function normalizeToolName(name: string): string;
12
+ /**
13
+ * Extract parameters from truncated/partial JSON for tool calls.
14
+ * Fallback when JSON.parse fails due to API truncation.
15
+ */
16
+ declare function extractPartialToolParams(toolName: string, rawArgs: string): Record<string, unknown> | null;
12
17
  export declare function parseOpenAIToolCalls(toolCalls: unknown[]): ToolCall[];
13
18
  export declare function parseAnthropicToolCalls(content: unknown[]): ToolCall[];
19
+ declare function tryExtractParams(str: string): Record<string, unknown> | null;
20
+ declare function tryParseToolCall(str: string): ToolCall | null;
14
21
  /**
15
22
  * Parse tool calls from LLM response text.
16
23
  * Supports: <tool_call>, <toolcall>, ```tool blocks, inline JSON.
17
24
  */
18
25
  export declare function parseToolCalls(response: string): ToolCall[];
26
+ export declare const _extractPartialToolParamsForTest: typeof extractPartialToolParams;
27
+ export declare const _tryExtractParamsForTest: typeof tryExtractParams;
28
+ export declare const _tryParseToolCallForTest: typeof tryParseToolCall;
29
+ export {};
@@ -319,3 +319,9 @@ export function parseToolCalls(response) {
319
319
  }
320
320
  return toolCalls;
321
321
  }
322
+ // Test seams — these helpers are otherwise file-private; export them under
323
+ // a `_forTest` suffix so the parser internals can be exercised directly
324
+ // without going through the full response-parsing pipeline.
325
+ export const _extractPartialToolParamsForTest = extractPartialToolParams;
326
+ export const _tryExtractParamsForTest = tryExtractParams;
327
+ export const _tryParseToolCallForTest = tryParseToolCall;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `@web <url>` inline context — fetch a web page and attach its text
3
+ * content to the prompt, the same way `@file` attaches a file.
4
+ *
5
+ * Supported forms (anywhere in the message, like file mentions):
6
+ * @web https://example.com/docs → full URL
7
+ * @web example.com/docs → https:// is auto-prepended
8
+ * @web http://localhost:3000/api → dev servers work too
9
+ *
10
+ * The fetch is best-effort:
11
+ * - HTML is stripped to readable text (tags, scripts, styles removed).
12
+ * - Output is capped at `MAX_WEB_BYTES` (default 32 KB) so a single
13
+ * page can't blow the context window.
14
+ * - Markdown conversion is lightweight (headings, links, lists) — we
15
+ * don't run a full HTML→Markdown pipeline; the goal is "agent can
16
+ * read the page", not "pretty render".
17
+ * - Failures (network, non-2xx, non-text content type) surface as
18
+ * inline notifications, same as missing files.
19
+ *
20
+ * The fetcher is async, so `expandWebMentions` is async — unlike the
21
+ * sync `expandMentions` for files. Callers await it.
22
+ */
23
+ /** Max bytes of text we'll inline from a fetched page (32 KB). */
24
+ export declare const MAX_WEB_BYTES: number;
25
+ /** Result of expanding `@web` mentions in a prompt. */
26
+ export interface WebExpansionResult {
27
+ /** The prompt with fetched page text prepended. */
28
+ enrichedPrompt: string;
29
+ /** Successfully fetched pages. */
30
+ loaded: Array<{
31
+ url: string;
32
+ title: string;
33
+ content: string;
34
+ }>;
35
+ /** Mentions that couldn't be fetched, with a human-readable reason. */
36
+ failures: Array<{
37
+ mention: string;
38
+ reason: string;
39
+ }>;
40
+ }
41
+ /** One `@web` mention match. */
42
+ interface WebToken {
43
+ /** Full match including `@web `, for display. */
44
+ raw: string;
45
+ /** The URL (normalized: `https://` prepended if missing a scheme). */
46
+ url: string;
47
+ /** Start index of `raw` in the source. */
48
+ start: number;
49
+ /** End index (exclusive). */
50
+ end: number;
51
+ }
52
+ /**
53
+ * Extract all `@web` mentions from `text`. Pure (no network).
54
+ * Returns them in document order.
55
+ */
56
+ export declare function extractWebMentions(text: string): WebToken[];
57
+ export interface WebFetchOptions {
58
+ /**
59
+ * The fetch implementation. Defaults to the global `fetch` (Node 18+).
60
+ * Injected so tests can mock without hitting the network.
61
+ */
62
+ fetchImpl?: typeof fetch;
63
+ }
64
+ /**
65
+ * Expand all `@web` mentions in `prompt`: fetch each URL, convert the
66
+ * HTML to readable text, and prepend it as a `[Web pages]` block.
67
+ * Failures are collected, not thrown.
68
+ */
69
+ export declare function expandWebMentions(prompt: string, opts?: WebFetchOptions): Promise<WebExpansionResult>;
70
+ /**
71
+ * Reset the session web cache. Public so callers (e.g. `/web-cache clear`
72
+ * command, or tests) can force a fresh fetch.
73
+ */
74
+ export declare function clearWebCache(): void;
75
+ /**
76
+ * Stats about the session web cache — for `/web-cache status`.
77
+ */
78
+ export declare function webCacheStats(): {
79
+ entries: number;
80
+ maxEntries: number;
81
+ ttlMinutes: number;
82
+ };
83
+ /**
84
+ * Convert HTML to readable plain text + a title.
85
+ *
86
+ * Lightweight — no full parser dependency. Strips `<script>`, `<style>`,
87
+ * and tags, collapses whitespace, extracts `<title>` and the first
88
+ * `<h1>` as the page title. Good enough for the agent to read docs;
89
+ * not a faithful rendering.
90
+ */
91
+ export declare function htmlToText(html: string): {
92
+ title: string;
93
+ content: string;
94
+ };
95
+ /** Format the `[Web pages]` block prepended to the enriched prompt. */
96
+ export declare function formatWebBlock(pages: Array<{
97
+ url: string;
98
+ title: string;
99
+ content: string;
100
+ }>): string;
101
+ export {};
@@ -0,0 +1,375 @@
1
+ /**
2
+ * `@web <url>` inline context — fetch a web page and attach its text
3
+ * content to the prompt, the same way `@file` attaches a file.
4
+ *
5
+ * Supported forms (anywhere in the message, like file mentions):
6
+ * @web https://example.com/docs → full URL
7
+ * @web example.com/docs → https:// is auto-prepended
8
+ * @web http://localhost:3000/api → dev servers work too
9
+ *
10
+ * The fetch is best-effort:
11
+ * - HTML is stripped to readable text (tags, scripts, styles removed).
12
+ * - Output is capped at `MAX_WEB_BYTES` (default 32 KB) so a single
13
+ * page can't blow the context window.
14
+ * - Markdown conversion is lightweight (headings, links, lists) — we
15
+ * don't run a full HTML→Markdown pipeline; the goal is "agent can
16
+ * read the page", not "pretty render".
17
+ * - Failures (network, non-2xx, non-text content type) surface as
18
+ * inline notifications, same as missing files.
19
+ *
20
+ * The fetcher is async, so `expandWebMentions` is async — unlike the
21
+ * sync `expandMentions` for files. Callers await it.
22
+ */
23
+ /** Max bytes of text we'll inline from a fetched page (32 KB). */
24
+ export const MAX_WEB_BYTES = 32 * 1024;
25
+ /** Fetch timeout — don't hang the chat on a slow server. */
26
+ const FETCH_TIMEOUT_MS = 12_000;
27
+ /** User-Agent — some sites block the default `node` UA. */
28
+ const USER_AGENT = `Codeep/1 (+https://codeep.dev)`;
29
+ /**
30
+ * Regex matching a `@web <url>` mention.
31
+ *
32
+ * `@web` must be followed by whitespace, then a URL-like token (no
33
+ * spaces). We accept `http://`, `https://`, or bare host/path. The
34
+ * bare form (`example.com/docs`) gets `https://` auto-prepended.
35
+ */
36
+ const WEB_MENTION_RE = /(?:^|[\s([{<,;])@web\s+(https?:\/\/[^\s<>"]+|[a-z0-9][a-z0-9.-]*\.[a-z]{2,}[^\s<>"]*)/gi;
37
+ /**
38
+ * Extract all `@web` mentions from `text`. Pure (no network).
39
+ * Returns them in document order.
40
+ */
41
+ export function extractWebMentions(text) {
42
+ const tokens = [];
43
+ WEB_MENTION_RE.lastIndex = 0;
44
+ let m;
45
+ while ((m = WEB_MENTION_RE.exec(text)) !== null) {
46
+ let url = m[1];
47
+ if (!url)
48
+ continue;
49
+ // Auto-prepend https:// for bare hosts (e.g. "example.com/docs").
50
+ if (!/^https?:\/\//i.test(url)) {
51
+ url = 'https://' + url;
52
+ }
53
+ // The match may include a leading boundary char (space, `(`, …);
54
+ // `tok.start` should point at the `@` so stripping leaves the
55
+ // boundary char in place.
56
+ const matchText = m[0];
57
+ const atIdx = matchText.indexOf('@');
58
+ const start = m.index + (atIdx >= 0 ? atIdx : 0);
59
+ tokens.push({ raw: matchText.slice(atIdx).trim(), url, start, end: m.index + m[0].length });
60
+ }
61
+ return tokens;
62
+ }
63
+ /**
64
+ * Expand all `@web` mentions in `prompt`: fetch each URL, convert the
65
+ * HTML to readable text, and prepend it as a `[Web pages]` block.
66
+ * Failures are collected, not thrown.
67
+ */
68
+ export async function expandWebMentions(prompt, opts = {}) {
69
+ const tokens = extractWebMentions(prompt);
70
+ if (tokens.length === 0) {
71
+ return { enrichedPrompt: prompt, loaded: [], failures: [] };
72
+ }
73
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
74
+ const loaded = [];
75
+ const failures = [];
76
+ const seen = new Set();
77
+ // Fetch sequentially — avoids hammering a server and keeps failure
78
+ // order stable (matches the order in the prompt).
79
+ for (const tok of tokens) {
80
+ if (seen.has(tok.url))
81
+ continue;
82
+ seen.add(tok.url);
83
+ // Session cache: skip the network if we fetched this URL recently.
84
+ const cached = cacheGet(tok.url);
85
+ if (cached) {
86
+ loaded.push({ url: tok.url, title: cached.title, content: cached.content });
87
+ continue;
88
+ }
89
+ const result = await safeFetch(tok.url, fetchImpl);
90
+ if (!result.ok) {
91
+ failures.push({ mention: tok.raw, reason: result.reason });
92
+ continue;
93
+ }
94
+ // Cache successful fetches for the rest of the session.
95
+ cacheSet(tok.url, result);
96
+ loaded.push({ url: tok.url, title: result.title, content: result.content });
97
+ }
98
+ // Strip the `@web ` and the URL from the visible prompt, leaving a
99
+ // bare URL (readable, and the agent still sees what was referenced).
100
+ let stripped = prompt;
101
+ for (let i = tokens.length - 1; i >= 0; i--) {
102
+ const tok = tokens[i];
103
+ stripped = stripped.slice(0, tok.start) + tok.url + stripped.slice(tok.end);
104
+ }
105
+ const block = formatWebBlock(loaded);
106
+ return {
107
+ enrichedPrompt: block ? block + stripped.trimStart() : stripped,
108
+ loaded,
109
+ failures,
110
+ };
111
+ }
112
+ /** Session cache: normalized URL → entry. */
113
+ const webCache = new Map();
114
+ /** Cache TTL: 30 minutes. Docs rarely change faster than that. */
115
+ const WEB_CACHE_TTL_MS = 30 * 60 * 1000;
116
+ /** Max entries — prevents unbounded growth in long sessions. */
117
+ const WEB_CACHE_MAX = 50;
118
+ /**
119
+ * Normalize a URL for cache keying: lowercase host, strip trailing
120
+ * slash, drop fragments. Query strings are kept (they can change
121
+ * content).
122
+ */
123
+ function normalizeUrlForCache(url) {
124
+ try {
125
+ const u = new URL(url);
126
+ // The fragment is deliberately excluded: it never reaches the server, so
127
+ // `#a` and `#b` on the same URL are one cache entry.
128
+ return `${u.protocol}//${u.host.toLowerCase()}${u.pathname.replace(/\/$/, '')}${u.search}`;
129
+ }
130
+ catch {
131
+ // Not a parseable URL — fall back to the raw string.
132
+ return url;
133
+ }
134
+ }
135
+ /** Look up a cached successful fetch. Returns `null` if missing/expired. */
136
+ function cacheGet(url) {
137
+ const key = normalizeUrlForCache(url);
138
+ const entry = webCache.get(key);
139
+ if (!entry)
140
+ return null;
141
+ if (Date.now() - entry.writtenAt > WEB_CACHE_TTL_MS) {
142
+ webCache.delete(key);
143
+ return null;
144
+ }
145
+ return entry.result;
146
+ }
147
+ /** Store a successful fetch in the cache (evicting oldest if full). */
148
+ function cacheSet(url, result) {
149
+ const key = normalizeUrlForCache(url);
150
+ // Evict oldest if at capacity. Map preserves insertion order, so the
151
+ // first key is the oldest.
152
+ if (webCache.size >= WEB_CACHE_MAX && !webCache.has(key)) {
153
+ const oldest = webCache.keys().next().value;
154
+ if (oldest !== undefined)
155
+ webCache.delete(oldest);
156
+ }
157
+ webCache.set(key, { result, writtenAt: Date.now() });
158
+ }
159
+ /**
160
+ * Reset the session web cache. Public so callers (e.g. `/web-cache clear`
161
+ * command, or tests) can force a fresh fetch.
162
+ */
163
+ export function clearWebCache() {
164
+ webCache.clear();
165
+ }
166
+ /**
167
+ * Stats about the session web cache — for `/web-cache status`.
168
+ */
169
+ export function webCacheStats() {
170
+ return { entries: webCache.size, maxEntries: WEB_CACHE_MAX, ttlMinutes: WEB_CACHE_TTL_MS / 60000 };
171
+ }
172
+ /**
173
+ * Hosts that only make sense from *inside* the machine or network: loopback,
174
+ * RFC1918, link-local (which covers the 169.254.169.254 cloud-metadata
175
+ * endpoint), and `.internal`-style names.
176
+ *
177
+ * A user typing `@web http://localhost:3000` is a documented, intended use, so
178
+ * this is NOT a blanket block — it's only applied to where a fetch *ended up*
179
+ * after redirects, so a public URL can't bounce us into the private network.
180
+ */
181
+ function isPrivateHost(hostname) {
182
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, '');
183
+ if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.internal') || h.endsWith('.local'))
184
+ return true;
185
+ if (h === '::1' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80:'))
186
+ return true;
187
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
188
+ if (!m)
189
+ return false;
190
+ const [a, b] = [Number(m[1]), Number(m[2])];
191
+ return a === 127 || a === 10 || a === 0
192
+ || (a === 192 && b === 168)
193
+ || (a === 172 && b >= 16 && b <= 31)
194
+ || (a === 169 && b === 254);
195
+ }
196
+ /** Hard ceiling on bytes read from the network, before any text decoding. */
197
+ const MAX_WEB_FETCH_BYTES = MAX_WEB_BYTES * 4;
198
+ async function safeFetch(url, fetchImpl) {
199
+ const controller = new AbortController();
200
+ // Kept alive until the BODY is read, not just the headers — clearing it on
201
+ // header arrival let a slow-drip response hang the chat forever. Cleared in
202
+ // `finally` so a throw can't leak the timer either.
203
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
204
+ try {
205
+ const requestedPrivate = (() => {
206
+ try {
207
+ return isPrivateHost(new URL(url).hostname);
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ })();
213
+ const res = await fetchImpl(url, {
214
+ signal: controller.signal,
215
+ headers: { 'User-Agent': USER_AGENT, Accept: 'text/html, text/plain, */*' },
216
+ redirect: 'follow',
217
+ });
218
+ if (!res.ok) {
219
+ return { ok: false, reason: `HTTP ${res.status}` };
220
+ }
221
+ // SSRF guard: the user vouched for the host they typed, not for wherever
222
+ // it redirected us. Refuse a public → private hop (cloud metadata, LAN).
223
+ if (!requestedPrivate && res.url) {
224
+ try {
225
+ if (isPrivateHost(new URL(res.url).hostname)) {
226
+ return { ok: false, reason: 'redirected to a private/internal address — refused' };
227
+ }
228
+ }
229
+ catch { /* unparseable res.url — fall through */ }
230
+ }
231
+ const declared = Number(res.headers.get('content-length') ?? '');
232
+ if (Number.isFinite(declared) && declared > MAX_WEB_FETCH_BYTES) {
233
+ return { ok: false, reason: `response too large (${Math.round(declared / 1024)}KB)` };
234
+ }
235
+ const contentType = res.headers.get('content-type') ?? '';
236
+ const text = await readCapped(res);
237
+ // Plain text or JSON — keep as-is (capped).
238
+ if (contentType.includes('text/plain') || contentType.includes('application/json')) {
239
+ const capped = text.length > MAX_WEB_BYTES ? text.slice(0, MAX_WEB_BYTES) + '\n…(truncated)' : text;
240
+ return { ok: true, title: url, content: capped };
241
+ }
242
+ // HTML — strip to readable text.
243
+ if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
244
+ const { title, content } = htmlToText(text);
245
+ const capped = content.length > MAX_WEB_BYTES ? content.slice(0, MAX_WEB_BYTES) + '\n…(truncated)' : content;
246
+ return { ok: true, title: title || url, content: capped };
247
+ }
248
+ // Anything else (images, PDFs, …) — we can't usefully inline it.
249
+ return { ok: false, reason: `unsupported content type (${contentType || 'unknown'})` };
250
+ }
251
+ catch (err) {
252
+ const msg = err instanceof Error ? err.message : String(err);
253
+ if (msg.includes('abort'))
254
+ return { ok: false, reason: `timed out after ${FETCH_TIMEOUT_MS / 1000}s` };
255
+ return { ok: false, reason: msg };
256
+ }
257
+ finally {
258
+ clearTimeout(timer);
259
+ }
260
+ }
261
+ /**
262
+ * Read a response body, stopping once `MAX_WEB_FETCH_BYTES` have arrived.
263
+ *
264
+ * `res.text()` buffers the whole body first and only then truncates, so a
265
+ * server streaming gigabytes would OOM the CLI before the cap was applied.
266
+ * Falls back to `res.text()` when the body isn't a stream (test doubles).
267
+ */
268
+ async function readCapped(res) {
269
+ const body = res.body;
270
+ if (!body || typeof body.getReader !== 'function') {
271
+ const whole = await res.text();
272
+ return whole.length > MAX_WEB_FETCH_BYTES ? whole.slice(0, MAX_WEB_FETCH_BYTES) : whole;
273
+ }
274
+ const reader = body.getReader();
275
+ const chunks = [];
276
+ let total = 0;
277
+ try {
278
+ for (;;) {
279
+ const { done, value } = await reader.read();
280
+ if (done)
281
+ break;
282
+ if (!value)
283
+ continue;
284
+ chunks.push(value);
285
+ total += value.byteLength;
286
+ if (total >= MAX_WEB_FETCH_BYTES)
287
+ break; // stop pulling; cap reached
288
+ }
289
+ }
290
+ finally {
291
+ try {
292
+ await reader.cancel();
293
+ }
294
+ catch { /* already closed */ }
295
+ }
296
+ const joined = new Uint8Array(total);
297
+ let offset = 0;
298
+ for (const c of chunks) {
299
+ joined.set(c, offset);
300
+ offset += c.byteLength;
301
+ }
302
+ return new TextDecoder('utf-8').decode(joined);
303
+ }
304
+ /**
305
+ * Convert HTML to readable plain text + a title.
306
+ *
307
+ * Lightweight — no full parser dependency. Strips `<script>`, `<style>`,
308
+ * and tags, collapses whitespace, extracts `<title>` and the first
309
+ * `<h1>` as the page title. Good enough for the agent to read docs;
310
+ * not a faithful rendering.
311
+ */
312
+ export function htmlToText(html) {
313
+ // Title: prefer <title>, fall back to first <h1>.
314
+ let title = '';
315
+ const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
316
+ if (titleMatch)
317
+ title = decodeEntities(titleMatch[1].trim());
318
+ if (!title) {
319
+ const h1 = html.match(/<h1[^>]*>([^<]*)<\/h1>/i);
320
+ if (h1)
321
+ title = decodeEntities(h1[1].trim());
322
+ }
323
+ // Remove script/style/noscript blocks entirely.
324
+ let body = html
325
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
326
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
327
+ .replace(/<noscript[\s\S]*?<\/noscript>/gi, '')
328
+ .replace(/<!--[\s\S]*?-->/g, '');
329
+ // Drop the <head> (we already have the title).
330
+ body = body.replace(/<head[\s\S]*?<\/head>/gi, '');
331
+ // Convert block-level tags to newlines so text doesn't run together.
332
+ body = body.replace(/<\/(p|div|section|article|li|h[1-6]|tr|blockquote|pre)>/gi, '\n');
333
+ body = body.replace(/<br\s*\/?>/gi, '\n');
334
+ // Convert links to "text (url)" so the agent keeps the reference.
335
+ body = body.replace(/<a\s+[^>]*href=["']([^"']+)["'][^>]*>([^<]*)<\/a>/gi, (_m, href, text) => {
336
+ const t = text.trim();
337
+ return t ? `${t} (${href})` : '';
338
+ });
339
+ // Strip all remaining tags.
340
+ body = body.replace(/<[^>]+>/g, '');
341
+ // Decode common HTML entities.
342
+ body = decodeEntities(body);
343
+ // Collapse runs of whitespace (but preserve newlines).
344
+ body = body
345
+ .split('\n')
346
+ .map((line) => line.replace(/[ \t]+/g, ' ').trim())
347
+ .filter((line) => line.length > 0)
348
+ .join('\n');
349
+ return { title, content: body };
350
+ }
351
+ /** Decode the handful of HTML entities we're likely to see. */
352
+ function decodeEntities(s) {
353
+ return s
354
+ .replace(/&amp;/g, '&')
355
+ .replace(/&lt;/g, '<')
356
+ .replace(/&gt;/g, '>')
357
+ .replace(/&quot;/g, '"')
358
+ .replace(/&#39;/g, "'")
359
+ .replace(/&apos;/g, "'")
360
+ .replace(/&nbsp;/g, ' ')
361
+ .replace(/&#(\d+);/g, (_m, code) => String.fromCharCode(Number(code)))
362
+ .replace(/&#x([0-9a-f]+);/gi, (_m, code) => String.fromCharCode(parseInt(code, 16)));
363
+ }
364
+ // ─── Formatting ───────────────────────────────────────────────────────────────
365
+ /** Format the `[Web pages]` block prepended to the enriched prompt. */
366
+ export function formatWebBlock(pages) {
367
+ if (pages.length === 0)
368
+ return '';
369
+ const parts = ['[Web pages]'];
370
+ for (const p of pages) {
371
+ const heading = p.title && p.title !== p.url ? `${p.title} — ${p.url}` : p.url;
372
+ parts.push(`\nURL: ${heading}\n${p.content}`);
373
+ }
374
+ return parts.join('\n') + '\n\n';
375
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "2.14.0";
1
+ export declare const VERSION = "2.16.0";
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
2
2
  // Baked from package.json at build time so the bun-compiled binary reports
3
3
  // the right version (it has no package.json on disk to read at runtime).
4
- export const VERSION = '2.14.0';
4
+ export const VERSION = '2.16.0';