@kata-sh/cli 0.1.0 → 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/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/app-paths.d.ts +4 -0
- package/dist/app-paths.js +6 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +56 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +95 -0
- package/dist/resource-loader.d.ts +18 -0
- package/dist/resource-loader.js +50 -0
- package/dist/wizard.d.ts +15 -0
- package/dist/wizard.js +159 -0
- package/package.json +50 -21
- package/pkg/dist/modes/interactive/theme/dark.json +85 -0
- package/pkg/dist/modes/interactive/theme/light.json +84 -0
- package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
- package/pkg/dist/modes/interactive/theme/theme.js +949 -0
- package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
- package/pkg/package.json +8 -0
- package/scripts/postinstall.js +45 -0
- package/src/resources/AGENTS.md +108 -0
- package/src/resources/KATA-WORKFLOW.md +661 -0
- package/src/resources/agents/researcher.md +29 -0
- package/src/resources/agents/scout.md +56 -0
- package/src/resources/agents/worker.md +31 -0
- package/src/resources/extensions/ask-user-questions.ts +200 -0
- package/src/resources/extensions/bg-shell/index.ts +2758 -0
- package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
- package/src/resources/extensions/browser-tools/core.js +1057 -0
- package/src/resources/extensions/browser-tools/index.ts +4916 -0
- package/src/resources/extensions/browser-tools/package.json +20 -0
- package/src/resources/extensions/context7/index.ts +428 -0
- package/src/resources/extensions/context7/package.json +11 -0
- package/src/resources/extensions/get-secrets-from-user.ts +352 -0
- package/src/resources/extensions/github/formatters.ts +207 -0
- package/src/resources/extensions/github/gh-api.ts +537 -0
- package/src/resources/extensions/github/index.ts +778 -0
- package/src/resources/extensions/kata/activity-log.ts +88 -0
- package/src/resources/extensions/kata/auto.ts +2786 -0
- package/src/resources/extensions/kata/commands.ts +355 -0
- package/src/resources/extensions/kata/crash-recovery.ts +85 -0
- package/src/resources/extensions/kata/dashboard-overlay.ts +516 -0
- package/src/resources/extensions/kata/docs/preferences-reference.md +103 -0
- package/src/resources/extensions/kata/doctor.ts +683 -0
- package/src/resources/extensions/kata/files.ts +730 -0
- package/src/resources/extensions/kata/gitignore.ts +165 -0
- package/src/resources/extensions/kata/guided-flow.ts +976 -0
- package/src/resources/extensions/kata/index.ts +556 -0
- package/src/resources/extensions/kata/metrics.ts +397 -0
- package/src/resources/extensions/kata/observability-validator.ts +408 -0
- package/src/resources/extensions/kata/package.json +11 -0
- package/src/resources/extensions/kata/paths.ts +346 -0
- package/src/resources/extensions/kata/preferences.ts +695 -0
- package/src/resources/extensions/kata/prompt-loader.ts +50 -0
- package/src/resources/extensions/kata/prompts/complete-milestone.md +25 -0
- package/src/resources/extensions/kata/prompts/complete-slice.md +27 -0
- package/src/resources/extensions/kata/prompts/discuss.md +151 -0
- package/src/resources/extensions/kata/prompts/doctor-heal.md +29 -0
- package/src/resources/extensions/kata/prompts/execute-task.md +64 -0
- package/src/resources/extensions/kata/prompts/guided-complete-slice.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-discuss-milestone.md +3 -0
- package/src/resources/extensions/kata/prompts/guided-discuss-slice.md +59 -0
- package/src/resources/extensions/kata/prompts/guided-execute-task.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-plan-milestone.md +23 -0
- package/src/resources/extensions/kata/prompts/guided-plan-slice.md +1 -0
- package/src/resources/extensions/kata/prompts/guided-research-slice.md +11 -0
- package/src/resources/extensions/kata/prompts/guided-resume-task.md +1 -0
- package/src/resources/extensions/kata/prompts/plan-milestone.md +47 -0
- package/src/resources/extensions/kata/prompts/plan-slice.md +63 -0
- package/src/resources/extensions/kata/prompts/queue.md +85 -0
- package/src/resources/extensions/kata/prompts/reassess-roadmap.md +48 -0
- package/src/resources/extensions/kata/prompts/replan-slice.md +39 -0
- package/src/resources/extensions/kata/prompts/research-milestone.md +37 -0
- package/src/resources/extensions/kata/prompts/research-slice.md +28 -0
- package/src/resources/extensions/kata/prompts/run-uat.md +109 -0
- package/src/resources/extensions/kata/prompts/system.md +341 -0
- package/src/resources/extensions/kata/session-forensics.ts +550 -0
- package/src/resources/extensions/kata/skill-discovery.ts +137 -0
- package/src/resources/extensions/kata/state.ts +509 -0
- package/src/resources/extensions/kata/templates/context.md +76 -0
- package/src/resources/extensions/kata/templates/decisions.md +8 -0
- package/src/resources/extensions/kata/templates/milestone-summary.md +73 -0
- package/src/resources/extensions/kata/templates/plan.md +133 -0
- package/src/resources/extensions/kata/templates/preferences.md +15 -0
- package/src/resources/extensions/kata/templates/project.md +31 -0
- package/src/resources/extensions/kata/templates/reassessment.md +28 -0
- package/src/resources/extensions/kata/templates/requirements.md +81 -0
- package/src/resources/extensions/kata/templates/research.md +46 -0
- package/src/resources/extensions/kata/templates/roadmap.md +118 -0
- package/src/resources/extensions/kata/templates/slice-context.md +58 -0
- package/src/resources/extensions/kata/templates/slice-summary.md +99 -0
- package/src/resources/extensions/kata/templates/state.md +19 -0
- package/src/resources/extensions/kata/templates/task-plan.md +52 -0
- package/src/resources/extensions/kata/templates/task-summary.md +57 -0
- package/src/resources/extensions/kata/templates/uat.md +54 -0
- package/src/resources/extensions/kata/tests/activity-log-prune.test.ts +327 -0
- package/src/resources/extensions/kata/tests/auto-preflight.test.ts +97 -0
- package/src/resources/extensions/kata/tests/auto-supervisor.test.mjs +53 -0
- package/src/resources/extensions/kata/tests/complete-milestone.test.ts +317 -0
- package/src/resources/extensions/kata/tests/cost-projection.test.ts +160 -0
- package/src/resources/extensions/kata/tests/derive-state-deps.test.ts +477 -0
- package/src/resources/extensions/kata/tests/derive-state.test.ts +1013 -0
- package/src/resources/extensions/kata/tests/doctor.test.ts +718 -0
- package/src/resources/extensions/kata/tests/idle-recovery.test.ts +490 -0
- package/src/resources/extensions/kata/tests/metrics-io.test.ts +254 -0
- package/src/resources/extensions/kata/tests/metrics.test.ts +217 -0
- package/src/resources/extensions/kata/tests/must-have-parser.test.ts +309 -0
- package/src/resources/extensions/kata/tests/parsers.test.ts +1257 -0
- package/src/resources/extensions/kata/tests/plan-milestone.test.ts +185 -0
- package/src/resources/extensions/kata/tests/plan-quality-validator.test.ts +386 -0
- package/src/resources/extensions/kata/tests/reassess-prompt.test.ts +208 -0
- package/src/resources/extensions/kata/tests/replan-slice.test.ts +686 -0
- package/src/resources/extensions/kata/tests/requirements.test.ts +151 -0
- package/src/resources/extensions/kata/tests/resolve-ts-hooks.mjs +17 -0
- package/src/resources/extensions/kata/tests/resolve-ts.mjs +11 -0
- package/src/resources/extensions/kata/tests/run-uat.test.ts +383 -0
- package/src/resources/extensions/kata/tests/unit-runtime.test.ts +388 -0
- package/src/resources/extensions/kata/tests/workspace-index.test.ts +118 -0
- package/src/resources/extensions/kata/tests/worktree.test.ts +222 -0
- package/src/resources/extensions/kata/types.ts +159 -0
- package/src/resources/extensions/kata/unit-runtime.ts +163 -0
- package/src/resources/extensions/kata/workspace-index.ts +203 -0
- package/src/resources/extensions/kata/worktree.ts +182 -0
- package/src/resources/extensions/mac-tools/index.ts +852 -0
- package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
- package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
- package/src/resources/extensions/search-the-web/cache.ts +78 -0
- package/src/resources/extensions/search-the-web/format.ts +258 -0
- package/src/resources/extensions/search-the-web/http.ts +238 -0
- package/src/resources/extensions/search-the-web/index.ts +68 -0
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +404 -0
- package/src/resources/extensions/search-the-web/tool-search.ts +503 -0
- package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
- package/src/resources/extensions/shared/confirm-ui.ts +126 -0
- package/src/resources/extensions/shared/interview-ui.ts +822 -0
- package/src/resources/extensions/shared/next-action-ui.ts +235 -0
- package/src/resources/extensions/shared/progress-widget.ts +282 -0
- package/src/resources/extensions/shared/thinking-widget.ts +107 -0
- package/src/resources/extensions/shared/ui.ts +400 -0
- package/src/resources/extensions/shared/wizard-ui.ts +551 -0
- package/src/resources/extensions/slash-commands/audit.ts +92 -0
- package/src/resources/extensions/slash-commands/create-extension.ts +375 -0
- package/src/resources/extensions/slash-commands/create-slash-command.ts +280 -0
- package/src/resources/extensions/slash-commands/index.ts +12 -0
- package/src/resources/extensions/slash-commands/kata-run.ts +34 -0
- package/src/resources/extensions/subagent/agents.ts +126 -0
- package/src/resources/extensions/subagent/index.ts +1293 -0
- package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
- package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
- package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
- package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
- package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
- package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
- package/src/resources/skills/frontend-design/SKILL.md +45 -0
- package/src/resources/skills/swiftui/SKILL.md +208 -0
- package/src/resources/skills/swiftui/references/animations.md +921 -0
- package/src/resources/skills/swiftui/references/architecture.md +1561 -0
- package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
- package/src/resources/skills/swiftui/references/navigation.md +1492 -0
- package/src/resources/skills/swiftui/references/networking-async.md +214 -0
- package/src/resources/skills/swiftui/references/performance.md +1706 -0
- package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
- package/src/resources/skills/swiftui/references/state-management.md +1443 -0
- package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
- package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
- package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
- package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
- package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
- package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
- package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
- package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
- package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
- package/dist/commands/task.d.ts +0 -9
- package/dist/commands/task.d.ts.map +0 -1
- package/dist/commands/task.js +0 -129
- package/dist/commands/task.js.map +0 -1
- package/dist/commands/task.test.d.ts +0 -2
- package/dist/commands/task.test.d.ts.map +0 -1
- package/dist/commands/task.test.js +0 -169
- package/dist/commands/task.test.js.map +0 -1
- package/dist/e2e/task-e2e.test.d.ts +0 -2
- package/dist/e2e/task-e2e.test.d.ts.map +0 -1
- package/dist/e2e/task-e2e.test.js +0 -173
- package/dist/e2e/task-e2e.test.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -93
- package/dist/index.js.map +0 -1
- package/dist/slug.d.ts +0 -2
- package/dist/slug.d.ts.map +0 -1
- package/dist/slug.js +0 -12
- package/dist/slug.js.map +0 -1
- package/dist/slug.test.d.ts +0 -2
- package/dist/slug.test.d.ts.map +0 -1
- package/dist/slug.test.js +0 -32
- package/dist/slug.test.js.map +0 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LRU cache with TTL — zero external dependencies.
|
|
3
|
+
*
|
|
4
|
+
* - max: maximum entries before oldest is evicted
|
|
5
|
+
* - ttlMs: time-to-live per entry
|
|
6
|
+
*
|
|
7
|
+
* Uses a Map (insertion-ordered) for O(1) LRU eviction:
|
|
8
|
+
* on every access the entry is deleted and re-inserted at the tail.
|
|
9
|
+
*/
|
|
10
|
+
export class LRUTTLCache<V> {
|
|
11
|
+
private readonly max: number;
|
|
12
|
+
private readonly ttlMs: number;
|
|
13
|
+
private readonly store = new Map<string, { value: V; expiresAt: number }>();
|
|
14
|
+
private purgeTimer: ReturnType<typeof setInterval> | null = null;
|
|
15
|
+
|
|
16
|
+
constructor(options: { max: number; ttlMs: number }) {
|
|
17
|
+
this.max = options.max;
|
|
18
|
+
this.ttlMs = options.ttlMs;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(key: string): V | undefined {
|
|
22
|
+
const entry = this.store.get(key);
|
|
23
|
+
if (!entry) return undefined;
|
|
24
|
+
if (Date.now() > entry.expiresAt) {
|
|
25
|
+
this.store.delete(key);
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
// Refresh to tail (most-recently-used)
|
|
29
|
+
this.store.delete(key);
|
|
30
|
+
this.store.set(key, entry);
|
|
31
|
+
return entry.value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
set(key: string, value: V): void {
|
|
35
|
+
if (this.store.has(key)) {
|
|
36
|
+
this.store.delete(key);
|
|
37
|
+
} else if (this.store.size >= this.max) {
|
|
38
|
+
const oldest = this.store.keys().next().value;
|
|
39
|
+
if (oldest !== undefined) this.store.delete(oldest);
|
|
40
|
+
}
|
|
41
|
+
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
has(key: string): boolean {
|
|
45
|
+
return this.get(key) !== undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
purgeStale(): void {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
for (const [key, entry] of this.store) {
|
|
51
|
+
if (now > entry.expiresAt) this.store.delete(key);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
startPurgeInterval(intervalMs: number): void {
|
|
56
|
+
if (this.purgeTimer !== null) return;
|
|
57
|
+
this.purgeTimer = setInterval(() => this.purgeStale(), intervalMs);
|
|
58
|
+
// Don't keep the process alive just for cache cleanup
|
|
59
|
+
if (this.purgeTimer && typeof this.purgeTimer === "object" && "unref" in this.purgeTimer) {
|
|
60
|
+
(this.purgeTimer as NodeJS.Timeout).unref();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
stopPurgeInterval(): void {
|
|
65
|
+
if (this.purgeTimer !== null) {
|
|
66
|
+
clearInterval(this.purgeTimer);
|
|
67
|
+
this.purgeTimer = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
clear(): void {
|
|
72
|
+
this.store.clear();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get size(): number {
|
|
76
|
+
return this.store.size;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-efficient output formatting for search results, page content,
|
|
3
|
+
* and LLM context responses.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { extractDomain } from "./url-utils";
|
|
7
|
+
|
|
8
|
+
export interface SearchResultFormatted {
|
|
9
|
+
title: string;
|
|
10
|
+
url: string;
|
|
11
|
+
description: string;
|
|
12
|
+
age?: string;
|
|
13
|
+
extra_snippets?: string[];
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Adaptive Snippet Budget
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compute how many extra_snippets to show per result based on total count.
|
|
23
|
+
* Fewer results → more snippets each. More results → fewer snippets each.
|
|
24
|
+
*
|
|
25
|
+
* This keeps total output roughly constant regardless of result count.
|
|
26
|
+
*/
|
|
27
|
+
function snippetsPerResult(resultCount: number): number {
|
|
28
|
+
if (resultCount <= 2) return 5; // show all available
|
|
29
|
+
if (resultCount <= 4) return 3;
|
|
30
|
+
if (resultCount <= 6) return 2;
|
|
31
|
+
if (resultCount <= 8) return 1;
|
|
32
|
+
return 0; // 9-10 results: descriptions only
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Search Results Formatting
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
export interface FormatSearchOptions {
|
|
40
|
+
cached?: boolean;
|
|
41
|
+
summary?: string;
|
|
42
|
+
queryCorrected?: boolean;
|
|
43
|
+
originalQuery?: string;
|
|
44
|
+
correctedQuery?: string;
|
|
45
|
+
moreResultsAvailable?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Format search results in a compact, token-efficient format.
|
|
50
|
+
*
|
|
51
|
+
* Produces:
|
|
52
|
+
* [1] Python Web Frameworks — example.com (2024-11)
|
|
53
|
+
* Main snippet text...
|
|
54
|
+
* + "additional excerpt 1"
|
|
55
|
+
* + "additional excerpt 2"
|
|
56
|
+
*
|
|
57
|
+
* Snippet count per result adapts to total result count.
|
|
58
|
+
*/
|
|
59
|
+
export function formatSearchResults(
|
|
60
|
+
query: string,
|
|
61
|
+
results: SearchResultFormatted[],
|
|
62
|
+
options: FormatSearchOptions = {}
|
|
63
|
+
): string {
|
|
64
|
+
const parts: string[] = [];
|
|
65
|
+
|
|
66
|
+
// Header
|
|
67
|
+
const cacheTag = options.cached ? " (cached)" : "";
|
|
68
|
+
parts.push(`Search: "${query}"${cacheTag}`);
|
|
69
|
+
|
|
70
|
+
// Spellcheck/query correction notice
|
|
71
|
+
if (options.queryCorrected && options.correctedQuery) {
|
|
72
|
+
parts.push(`Note: Query was corrected to "${options.correctedQuery}" (original: "${options.originalQuery ?? query}")`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
parts.push(""); // blank line after header
|
|
76
|
+
|
|
77
|
+
// AI summary block if available (from Brave Summarizer)
|
|
78
|
+
if (options.summary) {
|
|
79
|
+
parts.push(`Summary: ${options.summary}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (results.length === 0) {
|
|
83
|
+
parts.push("No results found.");
|
|
84
|
+
return parts.join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const maxSnippets = snippetsPerResult(results.length);
|
|
88
|
+
|
|
89
|
+
// Results
|
|
90
|
+
for (let i = 0; i < results.length; i++) {
|
|
91
|
+
const r = results[i];
|
|
92
|
+
const domain = extractDomain(r.url);
|
|
93
|
+
const age = r.age ? ` (${r.age})` : "";
|
|
94
|
+
|
|
95
|
+
// Compact header line: [N] Title — domain (age)
|
|
96
|
+
parts.push(`[${i + 1}] ${r.title} — ${domain}${age}`);
|
|
97
|
+
parts.push(r.url);
|
|
98
|
+
|
|
99
|
+
// Primary description
|
|
100
|
+
if (r.description) {
|
|
101
|
+
parts.push(r.description);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Extra snippets — adaptive count based on total results
|
|
105
|
+
if (maxSnippets > 0 && r.extra_snippets && r.extra_snippets.length > 0) {
|
|
106
|
+
for (const snippet of r.extra_snippets.slice(0, maxSnippets)) {
|
|
107
|
+
const clean = snippet.replace(/\n/g, " ").trim();
|
|
108
|
+
if (clean) parts.push(`+ ${clean}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
parts.push(""); // blank line between results
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Pagination hint
|
|
116
|
+
if (options.moreResultsAvailable) {
|
|
117
|
+
parts.push("[More results available — increase count or refine query]");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return parts.join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// =============================================================================
|
|
124
|
+
// Page Content Formatting
|
|
125
|
+
// =============================================================================
|
|
126
|
+
|
|
127
|
+
export interface FormatPageOptions {
|
|
128
|
+
title?: string;
|
|
129
|
+
charCount: number;
|
|
130
|
+
truncated: boolean;
|
|
131
|
+
originalChars?: number;
|
|
132
|
+
hasMore?: boolean;
|
|
133
|
+
nextOffset?: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Format extracted page content with metadata header.
|
|
138
|
+
*/
|
|
139
|
+
export function formatPageContent(
|
|
140
|
+
url: string,
|
|
141
|
+
content: string,
|
|
142
|
+
options: FormatPageOptions
|
|
143
|
+
): string {
|
|
144
|
+
const domain = extractDomain(url);
|
|
145
|
+
const title = options.title ? ` — ${options.title}` : "";
|
|
146
|
+
const truncNote = options.truncated && options.originalChars
|
|
147
|
+
? ` [truncated from ${options.originalChars.toLocaleString()} chars]`
|
|
148
|
+
: "";
|
|
149
|
+
const moreNote = options.hasMore && options.nextOffset
|
|
150
|
+
? ` [use offset:${options.nextOffset} to continue reading]`
|
|
151
|
+
: "";
|
|
152
|
+
|
|
153
|
+
const header = `Page: ${domain}${title} (${options.charCount.toLocaleString()} chars)${truncNote}${moreNote}\n${url}\n---`;
|
|
154
|
+
|
|
155
|
+
return `${header}\n${content}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// =============================================================================
|
|
159
|
+
// LLM Context Formatting
|
|
160
|
+
// =============================================================================
|
|
161
|
+
|
|
162
|
+
export interface LLMContextSnippet {
|
|
163
|
+
url: string;
|
|
164
|
+
title: string;
|
|
165
|
+
snippets: string[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface LLMContextSource {
|
|
169
|
+
title: string;
|
|
170
|
+
hostname: string;
|
|
171
|
+
age: string[] | null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Format LLM Context API response in a compact, agent-optimized format.
|
|
176
|
+
*
|
|
177
|
+
* Output:
|
|
178
|
+
* Context: "query" (N sources, ~Mk tokens)
|
|
179
|
+
*
|
|
180
|
+
* [1] Page Title — domain.com (age)
|
|
181
|
+
* url
|
|
182
|
+
* Snippet text...
|
|
183
|
+
* ---
|
|
184
|
+
* Another snippet...
|
|
185
|
+
*/
|
|
186
|
+
export function formatLLMContext(
|
|
187
|
+
query: string,
|
|
188
|
+
grounding: LLMContextSnippet[],
|
|
189
|
+
sources: Record<string, LLMContextSource>,
|
|
190
|
+
options: { cached?: boolean; tokenCount?: number } = {}
|
|
191
|
+
): string {
|
|
192
|
+
const parts: string[] = [];
|
|
193
|
+
|
|
194
|
+
const cacheTag = options.cached ? " (cached)" : "";
|
|
195
|
+
const tokenTag = options.tokenCount ? ` (~${Math.round(options.tokenCount / 1000)}k tokens)` : "";
|
|
196
|
+
parts.push(`Context: "${query}" (${grounding.length} sources${tokenTag})${cacheTag}`);
|
|
197
|
+
parts.push("");
|
|
198
|
+
|
|
199
|
+
if (grounding.length === 0) {
|
|
200
|
+
parts.push("No relevant content found.");
|
|
201
|
+
return parts.join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (let i = 0; i < grounding.length; i++) {
|
|
205
|
+
const g = grounding[i];
|
|
206
|
+
const source = sources[g.url];
|
|
207
|
+
const domain = source?.hostname || extractDomain(g.url);
|
|
208
|
+
const age = source?.age?.[2] ? ` (${source.age[2]})` : ""; // [2] is "N days ago" format
|
|
209
|
+
|
|
210
|
+
parts.push(`[${i + 1}] ${g.title || source?.title || "(untitled)"} — ${domain}${age}`);
|
|
211
|
+
parts.push(g.url);
|
|
212
|
+
|
|
213
|
+
// Join snippets with separator
|
|
214
|
+
for (const snippet of g.snippets) {
|
|
215
|
+
const clean = snippet.trim();
|
|
216
|
+
if (clean) parts.push(clean);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
parts.push(""); // blank line between sources
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return parts.join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// =============================================================================
|
|
226
|
+
// Multi-Page Formatting
|
|
227
|
+
// =============================================================================
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Format multiple page extractions compactly.
|
|
231
|
+
*/
|
|
232
|
+
export function formatMultiplePages(
|
|
233
|
+
pages: Array<{
|
|
234
|
+
url: string;
|
|
235
|
+
title?: string;
|
|
236
|
+
content: string;
|
|
237
|
+
charCount: number;
|
|
238
|
+
error?: string;
|
|
239
|
+
}>
|
|
240
|
+
): string {
|
|
241
|
+
const parts: string[] = [];
|
|
242
|
+
|
|
243
|
+
for (const page of pages) {
|
|
244
|
+
const domain = extractDomain(page.url);
|
|
245
|
+
if (page.error) {
|
|
246
|
+
parts.push(`[✗] ${domain}: ${page.error}`);
|
|
247
|
+
} else {
|
|
248
|
+
const title = page.title ? ` — ${page.title}` : "";
|
|
249
|
+
parts.push(`[✓] ${domain}${title} (${page.charCount.toLocaleString()} chars)`);
|
|
250
|
+
parts.push(page.url);
|
|
251
|
+
parts.push("---");
|
|
252
|
+
parts.push(page.content);
|
|
253
|
+
}
|
|
254
|
+
parts.push(""); // separator
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return parts.join("\n");
|
|
258
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP utilities: retry with backoff, abort signal merging, error types, timing.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// Error Types
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
/** Structured error for non-2xx HTTP responses. */
|
|
10
|
+
export class HttpError extends Error {
|
|
11
|
+
readonly statusCode: number;
|
|
12
|
+
readonly response?: Response;
|
|
13
|
+
|
|
14
|
+
constructor(message: string, statusCode: number, response?: Response) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "HttpError";
|
|
17
|
+
this.statusCode = statusCode;
|
|
18
|
+
this.response = response;
|
|
19
|
+
Object.setPrototypeOf(this, HttpError.prototype);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Categorized error types for agent-friendly error handling. */
|
|
24
|
+
export type SearchErrorKind =
|
|
25
|
+
| "auth_error" // 401/403 — bad or missing API key
|
|
26
|
+
| "rate_limited" // 429 — too many requests
|
|
27
|
+
| "network_error" // DNS, timeout, connection refused
|
|
28
|
+
| "server_error" // 5xx
|
|
29
|
+
| "invalid_request" // 400, bad params
|
|
30
|
+
| "not_found" // 404
|
|
31
|
+
| "unknown";
|
|
32
|
+
|
|
33
|
+
export function classifyError(err: unknown): { kind: SearchErrorKind; message: string; retryAfterMs?: number } {
|
|
34
|
+
if (err instanceof HttpError) {
|
|
35
|
+
const code = err.statusCode;
|
|
36
|
+
if (code === 401 || code === 403) {
|
|
37
|
+
return { kind: "auth_error", message: `HTTP ${code}: Invalid or missing API key. Use secure_env_collect to set BRAVE_API_KEY.` };
|
|
38
|
+
}
|
|
39
|
+
if (code === 429) {
|
|
40
|
+
let retryAfterMs: number | undefined;
|
|
41
|
+
const retryAfter = err.response?.headers.get("Retry-After");
|
|
42
|
+
if (retryAfter) {
|
|
43
|
+
const seconds = parseFloat(retryAfter);
|
|
44
|
+
if (!isNaN(seconds)) retryAfterMs = seconds * 1000;
|
|
45
|
+
}
|
|
46
|
+
return { kind: "rate_limited", message: `Rate limited (HTTP 429). ${retryAfterMs ? `Retry after ${Math.ceil(retryAfterMs / 1000)}s.` : "Wait before retrying."}`, retryAfterMs };
|
|
47
|
+
}
|
|
48
|
+
if (code === 400) {
|
|
49
|
+
return { kind: "invalid_request", message: `Bad request (HTTP 400): ${err.message}` };
|
|
50
|
+
}
|
|
51
|
+
if (code === 404) return { kind: "not_found", message: `Not found (HTTP 404)` };
|
|
52
|
+
if (code >= 500) return { kind: "server_error", message: `Server error (HTTP ${code}): ${err.message}` };
|
|
53
|
+
return { kind: "unknown", message: `HTTP ${code}: ${err.message}` };
|
|
54
|
+
}
|
|
55
|
+
if (err instanceof TypeError) {
|
|
56
|
+
return { kind: "network_error", message: `Network error: ${(err as Error).message}` };
|
|
57
|
+
}
|
|
58
|
+
const msg = (err as Error)?.message ?? String(err);
|
|
59
|
+
if (msg.includes("abort") || msg.includes("timeout")) {
|
|
60
|
+
return { kind: "network_error", message: `Request timed out` };
|
|
61
|
+
}
|
|
62
|
+
return { kind: "unknown", message: msg };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// =============================================================================
|
|
66
|
+
// Rate Limit Info
|
|
67
|
+
// =============================================================================
|
|
68
|
+
|
|
69
|
+
export interface RateLimitInfo {
|
|
70
|
+
remaining?: number;
|
|
71
|
+
limit?: number;
|
|
72
|
+
reset?: number; // epoch seconds
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Extract rate limit headers from a Brave API response. */
|
|
76
|
+
export function extractRateLimitInfo(response: Response): RateLimitInfo | undefined {
|
|
77
|
+
const remaining = response.headers.get("x-ratelimit-remaining");
|
|
78
|
+
const limit = response.headers.get("x-ratelimit-limit");
|
|
79
|
+
const reset = response.headers.get("x-ratelimit-reset");
|
|
80
|
+
if (!remaining && !limit) return undefined;
|
|
81
|
+
return {
|
|
82
|
+
remaining: remaining ? parseInt(remaining, 10) : undefined,
|
|
83
|
+
limit: limit ? parseInt(limit, 10) : undefined,
|
|
84
|
+
reset: reset ? parseInt(reset, 10) : undefined,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// =============================================================================
|
|
89
|
+
// Timing
|
|
90
|
+
// =============================================================================
|
|
91
|
+
|
|
92
|
+
export interface TimedResponse {
|
|
93
|
+
response: Response;
|
|
94
|
+
latencyMs: number;
|
|
95
|
+
rateLimit?: RateLimitInfo;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// =============================================================================
|
|
99
|
+
// Retry Logic
|
|
100
|
+
// =============================================================================
|
|
101
|
+
|
|
102
|
+
function isRetryable(error: unknown): boolean {
|
|
103
|
+
if (error instanceof HttpError) {
|
|
104
|
+
return error.statusCode === 429 || error.statusCode >= 500;
|
|
105
|
+
}
|
|
106
|
+
if (error instanceof TypeError) return true;
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sleep(ms: number): Promise<void> {
|
|
111
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Merge multiple AbortSignals — aborts as soon as any fires. */
|
|
115
|
+
export function anySignal(signals: AbortSignal[]): AbortSignal {
|
|
116
|
+
const controller = new AbortController();
|
|
117
|
+
for (const sig of signals) {
|
|
118
|
+
if (sig.aborted) {
|
|
119
|
+
controller.abort(sig.reason);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
sig.addEventListener("abort", () => controller.abort(sig.reason), { once: true });
|
|
123
|
+
}
|
|
124
|
+
return controller.signal;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fetch with automatic retry and full-jitter exponential backoff.
|
|
129
|
+
*
|
|
130
|
+
* - maxRetries: additional attempts after the first (total = maxRetries + 1)
|
|
131
|
+
* - Respects Retry-After header on 429 responses
|
|
132
|
+
* - Each attempt uses a 30-second AbortSignal timeout
|
|
133
|
+
* - Non-retryable errors thrown immediately
|
|
134
|
+
*/
|
|
135
|
+
export async function fetchWithRetry(
|
|
136
|
+
url: string,
|
|
137
|
+
options: RequestInit,
|
|
138
|
+
maxRetries: number = 2
|
|
139
|
+
): Promise<Response> {
|
|
140
|
+
let lastError: unknown;
|
|
141
|
+
|
|
142
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
143
|
+
const timeoutController = new AbortController();
|
|
144
|
+
const timeoutId = setTimeout(() => timeoutController.abort(), 30_000);
|
|
145
|
+
|
|
146
|
+
const callerSignal = options.signal as AbortSignal | undefined;
|
|
147
|
+
const signal = callerSignal
|
|
148
|
+
? anySignal([callerSignal, timeoutController.signal])
|
|
149
|
+
: timeoutController.signal;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetch(url, { ...options, signal });
|
|
153
|
+
clearTimeout(timeoutId);
|
|
154
|
+
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
throw new HttpError(
|
|
157
|
+
`HTTP ${response.status}: ${response.statusText}`,
|
|
158
|
+
response.status,
|
|
159
|
+
response
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return response;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
clearTimeout(timeoutId);
|
|
165
|
+
lastError = err;
|
|
166
|
+
|
|
167
|
+
if (!isRetryable(err)) throw err;
|
|
168
|
+
|
|
169
|
+
if (attempt < maxRetries) {
|
|
170
|
+
let delayMs: number;
|
|
171
|
+
if (err instanceof HttpError && err.statusCode === 429 && err.response) {
|
|
172
|
+
const retryAfter = err.response.headers.get("Retry-After");
|
|
173
|
+
if (retryAfter) {
|
|
174
|
+
const seconds = parseFloat(retryAfter);
|
|
175
|
+
delayMs = isNaN(seconds) ? 1000 : seconds * 1000;
|
|
176
|
+
} else {
|
|
177
|
+
delayMs = Math.random() * Math.min(32_000, 1_000 * 2 ** attempt);
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
delayMs = Math.random() * Math.min(32_000, 1_000 * 2 ** attempt);
|
|
181
|
+
}
|
|
182
|
+
await sleep(delayMs);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
throw lastError;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Simple fetch with timeout, no retry. For content extraction where
|
|
192
|
+
* we want to fail fast.
|
|
193
|
+
*/
|
|
194
|
+
export async function fetchSimple(
|
|
195
|
+
url: string,
|
|
196
|
+
options: RequestInit & { timeoutMs?: number } = {}
|
|
197
|
+
): Promise<Response> {
|
|
198
|
+
const { timeoutMs = 15_000, ...fetchOpts } = options;
|
|
199
|
+
const controller = new AbortController();
|
|
200
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
201
|
+
|
|
202
|
+
const callerSignal = fetchOpts.signal as AbortSignal | undefined;
|
|
203
|
+
const signal = callerSignal
|
|
204
|
+
? anySignal([callerSignal, controller.signal])
|
|
205
|
+
: controller.signal;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const response = await fetch(url, { ...fetchOpts, signal });
|
|
209
|
+
clearTimeout(timeoutId);
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
throw new HttpError(
|
|
212
|
+
`HTTP ${response.status}: ${response.statusText}`,
|
|
213
|
+
response.status,
|
|
214
|
+
response
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return response;
|
|
218
|
+
} catch (err) {
|
|
219
|
+
clearTimeout(timeoutId);
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Fetch with retry AND timing/rate-limit extraction.
|
|
226
|
+
* Wraps fetchWithRetry and returns latency + rate limit info.
|
|
227
|
+
*/
|
|
228
|
+
export async function fetchWithRetryTimed(
|
|
229
|
+
url: string,
|
|
230
|
+
options: RequestInit,
|
|
231
|
+
maxRetries: number = 2
|
|
232
|
+
): Promise<TimedResponse> {
|
|
233
|
+
const start = performance.now();
|
|
234
|
+
const response = await fetchWithRetry(url, options, maxRetries);
|
|
235
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
236
|
+
const rateLimit = extractRateLimitInfo(response);
|
|
237
|
+
return { response, latencyMs, rateLimit };
|
|
238
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Search Extension v3
|
|
3
|
+
*
|
|
4
|
+
* Provides three tools for grounding the agent in real-world web content:
|
|
5
|
+
*
|
|
6
|
+
* search-the-web — Rich web search with extra snippets, freshness filtering,
|
|
7
|
+
* domain scoping, AI summarizer, and compact output format.
|
|
8
|
+
* Returns links and snippets for selective browsing.
|
|
9
|
+
*
|
|
10
|
+
* fetch_page — Extract clean markdown from any URL via Jina Reader.
|
|
11
|
+
* Supports offset-based continuation, CSS selector targeting,
|
|
12
|
+
* and content-type-aware extraction.
|
|
13
|
+
*
|
|
14
|
+
* search_and_read — Single-call search + content extraction via Brave LLM Context API.
|
|
15
|
+
* Returns pre-extracted, relevance-scored page content.
|
|
16
|
+
* Best when you need content, not just links.
|
|
17
|
+
*
|
|
18
|
+
* v3 improvements over v2:
|
|
19
|
+
* - search_and_read: New tool — Brave LLM Context API (search + read in one call)
|
|
20
|
+
* - Structured error taxonomy: auth_error, rate_limited, network_error, etc.
|
|
21
|
+
* - Spellcheck surfacing: query corrections from Brave shown to agent
|
|
22
|
+
* - Latency tracking: API call timing in details for observability
|
|
23
|
+
* - Rate limit info: remaining quota surfaced when available
|
|
24
|
+
* - more_results_available: pagination hints from Brave
|
|
25
|
+
* - Adaptive snippet budget: snippet count adapts to result count
|
|
26
|
+
* - fetch_page offset: continuation reading for long pages
|
|
27
|
+
* - fetch_page selector: CSS selector targeting via Jina X-Target-Selector
|
|
28
|
+
* - fetch_page diagnostics: Jina failure reasons surfaced in details
|
|
29
|
+
* - Content-type awareness: JSON passthrough, PDF detection
|
|
30
|
+
* - Cache timer cleanup: purge timers use unref() to not block process exit
|
|
31
|
+
*
|
|
32
|
+
* Environment variables:
|
|
33
|
+
* BRAVE_API_KEY — Required for search. Get one at brave.com/search/api
|
|
34
|
+
* JINA_API_KEY — Optional. Higher rate limits for page extraction.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
38
|
+
import { registerSearchTool } from "./tool-search";
|
|
39
|
+
import { registerFetchPageTool } from "./tool-fetch-page";
|
|
40
|
+
import { registerLLMContextTool } from "./tool-llm-context";
|
|
41
|
+
|
|
42
|
+
export default function (pi: ExtensionAPI) {
|
|
43
|
+
// Register all tools
|
|
44
|
+
registerSearchTool(pi);
|
|
45
|
+
registerFetchPageTool(pi);
|
|
46
|
+
registerLLMContextTool(pi);
|
|
47
|
+
|
|
48
|
+
// Startup diagnostics
|
|
49
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
50
|
+
const hasBrave = !!process.env.BRAVE_API_KEY;
|
|
51
|
+
const hasJina = !!process.env.JINA_API_KEY;
|
|
52
|
+
const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY;
|
|
53
|
+
|
|
54
|
+
if (!hasBrave) {
|
|
55
|
+
ctx.ui.notify(
|
|
56
|
+
"Web search: Set BRAVE_API_KEY for web search + LLM context capability",
|
|
57
|
+
"warning"
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const parts: string[] = ["Web search v3 loaded"];
|
|
62
|
+
if (hasBrave) parts.push("Search ✓");
|
|
63
|
+
if (hasAnswers) parts.push("Answers ✓");
|
|
64
|
+
if (hasJina) parts.push("Jina ✓");
|
|
65
|
+
|
|
66
|
+
ctx.ui.notify(parts.join(" · "), "info");
|
|
67
|
+
});
|
|
68
|
+
}
|