supipowers 1.2.6 → 1.5.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/README.md +118 -56
- package/bin/install.ts +48 -128
- package/package.json +11 -3
- package/skills/code-review/SKILL.md +137 -40
- package/skills/context-mode/SKILL.md +67 -56
- package/skills/creating-supi-agents/SKILL.md +204 -0
- package/skills/debugging/SKILL.md +86 -40
- package/skills/fix-pr/SKILL.md +96 -65
- package/skills/planning/SKILL.md +103 -46
- package/skills/qa-strategy/SKILL.md +68 -46
- package/skills/receiving-code-review/SKILL.md +60 -53
- package/skills/release/SKILL.md +111 -39
- package/skills/tdd/SKILL.md +118 -67
- package/skills/verification/SKILL.md +71 -37
- package/src/bootstrap.ts +27 -5
- package/src/commands/agents.ts +249 -0
- package/src/commands/ai-review.ts +1113 -0
- package/src/commands/config.ts +224 -95
- package/src/commands/doctor.ts +19 -13
- package/src/commands/fix-pr.ts +8 -11
- package/src/commands/generate.ts +200 -0
- package/src/commands/model-picker.ts +5 -15
- package/src/commands/model.ts +4 -5
- package/src/commands/optimize-context.ts +202 -0
- package/src/commands/plan.ts +148 -92
- package/src/commands/qa.ts +14 -23
- package/src/commands/release.ts +504 -275
- package/src/commands/review.ts +643 -86
- package/src/commands/status.ts +44 -17
- package/src/commands/supi.ts +69 -41
- package/src/commands/update.ts +57 -2
- package/src/config/defaults.ts +6 -39
- package/src/config/loader.ts +388 -40
- package/src/config/model-resolver.ts +26 -22
- package/src/config/schema.ts +113 -48
- package/src/context/analyzer.ts +61 -2
- package/src/context/optimizer.ts +199 -0
- package/src/context-mode/compressor.ts +14 -11
- package/src/context-mode/detector.ts +16 -54
- package/src/context-mode/event-extractor.ts +45 -16
- package/src/context-mode/event-store.ts +225 -16
- package/src/context-mode/hooks.ts +195 -22
- package/src/context-mode/knowledge/chunker.ts +235 -0
- package/src/context-mode/knowledge/store.ts +187 -0
- package/src/context-mode/routing.ts +12 -23
- package/src/context-mode/sandbox/executor.ts +183 -0
- package/src/context-mode/sandbox/runners.ts +40 -0
- package/src/context-mode/snapshot-builder.ts +243 -7
- package/src/context-mode/tools.ts +440 -0
- package/src/context-mode/web/fetcher.ts +117 -0
- package/src/context-mode/web/html-to-md.ts +293 -0
- package/src/debug/logger.ts +107 -0
- package/src/deps/registry.ts +0 -20
- package/src/docs/drift.ts +454 -0
- package/src/fix-pr/fetch-comments.ts +66 -0
- package/src/git/commit-msg.ts +2 -1
- package/src/git/commit.ts +123 -141
- package/src/git/conventions.ts +2 -2
- package/src/git/status.ts +4 -1
- package/src/lsp/bridge.ts +138 -12
- package/src/planning/approval-flow.ts +125 -19
- package/src/planning/plan-writer-prompt.ts +4 -11
- package/src/planning/planning-ask-tool.ts +81 -0
- package/src/planning/prompt-builder.ts +9 -169
- package/src/planning/system-prompt.ts +290 -0
- package/src/platform/omp.ts +50 -4
- package/src/platform/progress.ts +182 -0
- package/src/platform/test-utils.ts +4 -1
- package/src/platform/tui-colors.ts +30 -0
- package/src/platform/types.ts +1 -0
- package/src/qa/detect-app-type.ts +102 -0
- package/src/qa/discover-routes.ts +353 -0
- package/src/quality/ai-session.ts +96 -0
- package/src/quality/ai-setup.ts +86 -0
- package/src/quality/gates/ai-review.ts +129 -0
- package/src/quality/gates/build.ts +8 -0
- package/src/quality/gates/command.ts +150 -0
- package/src/quality/gates/format.ts +28 -0
- package/src/quality/gates/lint.ts +22 -0
- package/src/quality/gates/lsp-diagnostics.ts +84 -0
- package/src/quality/gates/test-suite.ts +8 -0
- package/src/quality/gates/typecheck.ts +22 -0
- package/src/quality/registry.ts +25 -0
- package/src/quality/review-gates.ts +33 -0
- package/src/quality/runner.ts +268 -0
- package/src/quality/schemas.ts +48 -0
- package/src/quality/setup.ts +227 -0
- package/src/release/changelog.ts +7 -3
- package/src/release/channels/custom.ts +43 -0
- package/src/release/channels/gitea.ts +35 -0
- package/src/release/channels/github.ts +35 -0
- package/src/release/channels/gitlab.ts +35 -0
- package/src/release/channels/registry.ts +52 -0
- package/src/release/channels/types.ts +27 -0
- package/src/release/detector.ts +10 -63
- package/src/release/executor.ts +61 -51
- package/src/release/prompt.ts +38 -38
- package/src/release/version.ts +129 -10
- package/src/review/agent-loader.ts +331 -0
- package/src/review/consolidator.ts +180 -0
- package/src/review/default-agents/correctness.md +72 -0
- package/src/review/default-agents/maintainability.md +64 -0
- package/src/review/default-agents/security.md +67 -0
- package/src/review/fixer.ts +219 -0
- package/src/review/multi-agent-runner.ts +135 -0
- package/src/review/output.ts +147 -0
- package/src/review/prompts/agent-review-wrapper.md +36 -0
- package/src/review/prompts/fix-findings.md +32 -0
- package/src/review/prompts/fix-output-schema.md +18 -0
- package/src/review/prompts/invalid-output-retry.md +22 -0
- package/src/review/prompts/output-instructions.md +14 -0
- package/src/review/prompts/review-output-schema.md +38 -0
- package/src/review/prompts/single-review.md +53 -0
- package/src/review/prompts/validation-review.md +30 -0
- package/src/review/runner.ts +128 -0
- package/src/review/scope.ts +353 -0
- package/src/review/template.ts +15 -0
- package/src/review/types.ts +296 -0
- package/src/review/validator.ts +160 -0
- package/src/storage/plans.ts +5 -3
- package/src/storage/reports.ts +50 -7
- package/src/storage/review-sessions.ts +117 -0
- package/src/text.ts +19 -0
- package/src/types.ts +336 -26
- package/src/utils/paths.ts +39 -0
- package/src/visual/companion.ts +5 -3
- package/src/visual/start-server.ts +101 -0
- package/src/visual/stop-server.ts +39 -0
- package/bin/ctx-mode-wrapper.mjs +0 -66
- package/src/config/profiles.ts +0 -64
- package/src/context-mode/installer.ts +0 -38
- package/src/quality/ai-review-gate.ts +0 -43
- package/src/quality/gate-runner.ts +0 -67
- package/src/quality/lsp-gate.ts +0 -24
- package/src/quality/test-gate.ts +0 -39
- package/src/visual/scripts/start-server.sh +0 -98
- package/src/visual/scripts/stop-server.sh +0 -21
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// src/context-mode/tools.ts
|
|
2
|
+
//
|
|
3
|
+
// Registers 8 native context-mode tools via platform.registerTool().
|
|
4
|
+
// Orchestration layer: delegates execution to sandbox, owns intent-driven
|
|
5
|
+
// filtering (auto-indexing large output into knowledge store).
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import type { Platform } from "../platform/types.js";
|
|
9
|
+
import { executeCode } from "./sandbox/executor.js";
|
|
10
|
+
import { getSupportedLanguages } from "./sandbox/runners.js";
|
|
11
|
+
import { chunkMarkdown } from "./knowledge/chunker.js";
|
|
12
|
+
import { KnowledgeStore } from "./knowledge/store.js";
|
|
13
|
+
import { fetchAndIndex } from "./web/fetcher.js";
|
|
14
|
+
|
|
15
|
+
/** Threshold (bytes) above which intent-driven filtering kicks in. */
|
|
16
|
+
const INTENT_THRESHOLD = 5 * 1024;
|
|
17
|
+
|
|
18
|
+
/** Per-session, in-memory stats. Reset on session restart. */
|
|
19
|
+
interface ToolStats {
|
|
20
|
+
calls: Record<string, number>;
|
|
21
|
+
bytesReturned: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const stats: ToolStats = { calls: {}, bytesReturned: 0 };
|
|
25
|
+
|
|
26
|
+
function trackCall(toolName: string, outputBytes: number): void {
|
|
27
|
+
stats.calls[toolName] = (stats.calls[toolName] ?? 0) + 1;
|
|
28
|
+
stats.bytesReturned += outputBytes;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* If output exceeds INTENT_THRESHOLD and an intent is provided, auto-index
|
|
33
|
+
* the output and return search results instead of raw text.
|
|
34
|
+
*/
|
|
35
|
+
function maybeFilterByIntent(
|
|
36
|
+
output: string,
|
|
37
|
+
intent: string | undefined,
|
|
38
|
+
source: string,
|
|
39
|
+
store: KnowledgeStore,
|
|
40
|
+
): string {
|
|
41
|
+
if (!intent || output.length < INTENT_THRESHOLD) return output;
|
|
42
|
+
|
|
43
|
+
const chunks = chunkMarkdown(output, source);
|
|
44
|
+
if (chunks.length === 0) return output;
|
|
45
|
+
|
|
46
|
+
store.index(chunks, source);
|
|
47
|
+
const results = store.search([intent], { source, limit: 5 });
|
|
48
|
+
|
|
49
|
+
const sections = chunks.map((c) => `- ${c.title || "(untitled)"} (${c.body.length}B)`).join("\n");
|
|
50
|
+
|
|
51
|
+
let text = `## Indexed Sections\n\n${sections}\n\n`;
|
|
52
|
+
for (const group of results) {
|
|
53
|
+
text += `## ${group.query}\n\n`;
|
|
54
|
+
for (const r of group.results) {
|
|
55
|
+
text += `### ${r.title}\n${r.body}\n\n`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const terms = extractSearchableTerms(output);
|
|
60
|
+
if (terms.length > 0) {
|
|
61
|
+
text += `\nSearchable terms for follow-up: ${terms.join(", ")}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Extract distinctive terms from output for search vocabulary hints. */
|
|
68
|
+
function extractSearchableTerms(text: string): string[] {
|
|
69
|
+
const words = text.match(/\b[a-zA-Z_][a-zA-Z0-9_]{3,}\b/g) ?? [];
|
|
70
|
+
const freq = new Map<string, number>();
|
|
71
|
+
for (const w of words) {
|
|
72
|
+
const lower = w.toLowerCase();
|
|
73
|
+
freq.set(lower, (freq.get(lower) ?? 0) + 1);
|
|
74
|
+
}
|
|
75
|
+
// Return top distinctive terms (appear 2-10 times — not too common, not too rare)
|
|
76
|
+
return [...freq.entries()]
|
|
77
|
+
.filter(([, count]) => count >= 2 && count <= 10)
|
|
78
|
+
.sort((a, b) => b[1] - a[1])
|
|
79
|
+
.slice(0, 30)
|
|
80
|
+
.map(([term]) => term);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Inject FILE_CONTENT variable per language. */
|
|
84
|
+
function injectFileContent(language: string, fileContent: string, code: string): string {
|
|
85
|
+
const escaped = (s: string, char: string) => s.replaceAll(char, `\\${char}`);
|
|
86
|
+
|
|
87
|
+
switch (language) {
|
|
88
|
+
case "javascript":
|
|
89
|
+
case "typescript":
|
|
90
|
+
return `const FILE_CONTENT = \`${escaped(escaped(fileContent, "\\"), "\`")}\`;\n${code}`;
|
|
91
|
+
case "python":
|
|
92
|
+
return `FILE_CONTENT = """${fileContent.replaceAll('"""', '\\"\\"\\"')}"""\n${code}`;
|
|
93
|
+
case "shell":
|
|
94
|
+
return `FILE_CONTENT='${fileContent.replaceAll("'", "'\"'\"'")}'\n${code}`;
|
|
95
|
+
case "ruby":
|
|
96
|
+
return `FILE_CONTENT = <<~'HEREDOC'\n${fileContent}\nHEREDOC\n${code}`;
|
|
97
|
+
case "elixir":
|
|
98
|
+
return `file_content = ~S"""\n${fileContent}\n"""\n${code}`;
|
|
99
|
+
case "go":
|
|
100
|
+
return `package main\nimport "fmt"\nvar FILE_CONTENT = \`${fileContent.replaceAll("`", "` + \"`\" + `")}\`\nfunc init() { _ = fmt.Sprintf("%s", FILE_CONTENT) }\n${code}`;
|
|
101
|
+
case "php":
|
|
102
|
+
return `<?php\n$FILE_CONTENT = <<<'HEREDOC'\n${fileContent}\nHEREDOC;\n?>${code}`;
|
|
103
|
+
case "perl":
|
|
104
|
+
return `my $FILE_CONTENT = <<'HEREDOC';\n${fileContent}\nHEREDOC\n${code}`;
|
|
105
|
+
case "r":
|
|
106
|
+
return `FILE_CONTENT <- "${fileContent.replaceAll('"', '\\"').replaceAll("\n", "\\n")}"\n${code}`;
|
|
107
|
+
case "rust":
|
|
108
|
+
return `const FILE_CONTENT: &str = r###"${fileContent}"###;\n${code}`;
|
|
109
|
+
default:
|
|
110
|
+
// Fallback: shell-style for unknown
|
|
111
|
+
return `FILE_CONTENT='${fileContent.replaceAll("'", "'\"'\"'")}'\n${code}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Format search results into readable text. */
|
|
116
|
+
function formatSearchResults(grouped: ReturnType<KnowledgeStore["search"]>): string {
|
|
117
|
+
if (grouped.length === 0) return "No results.";
|
|
118
|
+
let text = "";
|
|
119
|
+
for (const group of grouped) {
|
|
120
|
+
text += `## ${group.query}\n\n`;
|
|
121
|
+
if (group.results.length === 0) {
|
|
122
|
+
text += "No matches.\n\n";
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
for (const r of group.results) {
|
|
126
|
+
text += `### ${r.title || "(untitled)"}\n`;
|
|
127
|
+
text += `--- [${r.source}] ---\n`;
|
|
128
|
+
text += `${r.body}\n\n`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return text;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function registerContextModeTools(platform: Platform, store: KnowledgeStore): void {
|
|
135
|
+
if (!platform.registerTool) return;
|
|
136
|
+
|
|
137
|
+
const languages = getSupportedLanguages();
|
|
138
|
+
|
|
139
|
+
// ── ctx_execute ────────────────────────────────────────────
|
|
140
|
+
platform.registerTool({
|
|
141
|
+
name: "ctx_execute",
|
|
142
|
+
label: "Execute Code in Sandbox",
|
|
143
|
+
description:
|
|
144
|
+
"Execute code in a sandboxed subprocess. Only stdout enters context — raw data stays in the subprocess. Use for: API calls, test runners, git queries, data processing, and any command that may produce large output.",
|
|
145
|
+
promptSnippet: "ctx_execute — run code in sandbox, only stdout enters context",
|
|
146
|
+
promptGuidelines: [
|
|
147
|
+
"Prefer this over bash for commands producing >20 lines of output",
|
|
148
|
+
"Use `intent` parameter when output may be large — triggers auto-indexing and returns search results",
|
|
149
|
+
"Use `background: true` for long-running servers/daemons",
|
|
150
|
+
],
|
|
151
|
+
parameters: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
language: { type: "string", enum: languages, description: "Runtime language" },
|
|
155
|
+
code: { type: "string", description: "Source code to execute" },
|
|
156
|
+
intent: { type: "string", description: "What you're looking for in the output. When provided and output is large (>5KB), auto-indexes and returns matching sections." },
|
|
157
|
+
timeout: { type: "number", description: "Max execution time in ms (default: 30000)" },
|
|
158
|
+
background: { type: "boolean", description: "Keep process running after timeout (for servers/daemons)" },
|
|
159
|
+
},
|
|
160
|
+
required: ["language", "code"],
|
|
161
|
+
},
|
|
162
|
+
async execute(_toolCallId: string, params: any) {
|
|
163
|
+
const { language, code, intent, timeout, background } = params;
|
|
164
|
+
const result = await executeCode(language, code, { timeout, background });
|
|
165
|
+
|
|
166
|
+
let output = result.stdout;
|
|
167
|
+
if (result.stderr) output += `\n[stderr]\n${result.stderr}`;
|
|
168
|
+
if (result.exitCode !== 0) output += `\n[exit code: ${result.exitCode}]`;
|
|
169
|
+
|
|
170
|
+
const source = `ctx_execute:${language}:${Date.now()}`;
|
|
171
|
+
output = maybeFilterByIntent(output, intent, source, store);
|
|
172
|
+
|
|
173
|
+
trackCall("ctx_execute", output.length);
|
|
174
|
+
return { content: [{ type: "text", text: output }] };
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── ctx_execute_file ───────────────────────────────────────
|
|
179
|
+
platform.registerTool({
|
|
180
|
+
name: "ctx_execute_file",
|
|
181
|
+
label: "Execute Code with File Content",
|
|
182
|
+
description:
|
|
183
|
+
"Read a file and process it without loading contents into context. The file is read into a FILE_CONTENT variable inside the sandbox. Only your printed summary enters context.",
|
|
184
|
+
promptSnippet: "ctx_execute_file — process file in sandbox, only printed summary enters context",
|
|
185
|
+
promptGuidelines: [
|
|
186
|
+
"Prefer this over Read for large files when you need to extract specific information",
|
|
187
|
+
"FILE_CONTENT variable is automatically injected in the target language",
|
|
188
|
+
],
|
|
189
|
+
parameters: {
|
|
190
|
+
type: "object",
|
|
191
|
+
properties: {
|
|
192
|
+
path: { type: "string", description: "File path to read" },
|
|
193
|
+
language: { type: "string", enum: languages, description: "Runtime language" },
|
|
194
|
+
code: { type: "string", description: "Code to process FILE_CONTENT. Print summary via console.log/print/echo." },
|
|
195
|
+
intent: { type: "string", description: "What you're looking for in the output" },
|
|
196
|
+
timeout: { type: "number", description: "Max execution time in ms (default: 30000)" },
|
|
197
|
+
},
|
|
198
|
+
required: ["path", "language", "code"],
|
|
199
|
+
},
|
|
200
|
+
async execute(_toolCallId: string, params: any) {
|
|
201
|
+
const { path: filePath, language, code, intent, timeout } = params;
|
|
202
|
+
const fileContent = readFileSync(filePath, "utf-8");
|
|
203
|
+
const augmentedCode = injectFileContent(language, fileContent, code);
|
|
204
|
+
|
|
205
|
+
const result = await executeCode(language, augmentedCode, { timeout });
|
|
206
|
+
|
|
207
|
+
let output = result.stdout;
|
|
208
|
+
if (result.stderr) output += `\n[stderr]\n${result.stderr}`;
|
|
209
|
+
if (result.exitCode !== 0) output += `\n[exit code: ${result.exitCode}]`;
|
|
210
|
+
|
|
211
|
+
const source = `ctx_execute_file:${filePath}:${Date.now()}`;
|
|
212
|
+
output = maybeFilterByIntent(output, intent, source, store);
|
|
213
|
+
|
|
214
|
+
trackCall("ctx_execute_file", output.length);
|
|
215
|
+
return { content: [{ type: "text", text: output }] };
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ── ctx_batch_execute ──────────────────────────────────────
|
|
220
|
+
platform.registerTool({
|
|
221
|
+
name: "ctx_batch_execute",
|
|
222
|
+
label: "Batch Execute and Search",
|
|
223
|
+
description:
|
|
224
|
+
"Execute multiple commands in ONE call, auto-index all output, and search with multiple queries. Returns search results directly — no follow-up calls needed.",
|
|
225
|
+
promptSnippet: "ctx_batch_execute — run multiple commands + search in one call",
|
|
226
|
+
promptGuidelines: [
|
|
227
|
+
"This is the PRIMARY tool for gathering information",
|
|
228
|
+
"One batch_execute call replaces 30+ execute calls + 10+ search calls",
|
|
229
|
+
"Provide all commands to run and all queries to search",
|
|
230
|
+
],
|
|
231
|
+
parameters: {
|
|
232
|
+
type: "object",
|
|
233
|
+
properties: {
|
|
234
|
+
commands: {
|
|
235
|
+
type: "array",
|
|
236
|
+
items: {
|
|
237
|
+
type: "object",
|
|
238
|
+
properties: {
|
|
239
|
+
label: { type: "string", description: "Section header for this command's output" },
|
|
240
|
+
command: { type: "string", description: "Shell command to execute" },
|
|
241
|
+
},
|
|
242
|
+
required: ["label", "command"],
|
|
243
|
+
},
|
|
244
|
+
description: "Commands to execute as a batch",
|
|
245
|
+
},
|
|
246
|
+
queries: {
|
|
247
|
+
type: "array",
|
|
248
|
+
items: { type: "string" },
|
|
249
|
+
description: "Search queries to extract information from indexed output",
|
|
250
|
+
},
|
|
251
|
+
timeout: { type: "number", description: "Max execution time per command in ms (default: 60000)" },
|
|
252
|
+
},
|
|
253
|
+
required: ["commands", "queries"],
|
|
254
|
+
},
|
|
255
|
+
async execute(_toolCallId: string, params: any) {
|
|
256
|
+
const { commands, queries, timeout = 60000 } = params;
|
|
257
|
+
const sections: string[] = [];
|
|
258
|
+
|
|
259
|
+
// Run each command sequentially, index output
|
|
260
|
+
for (const cmd of commands) {
|
|
261
|
+
const result = await executeCode("shell", cmd.command, { timeout });
|
|
262
|
+
let output = result.stdout;
|
|
263
|
+
if (result.stderr) output += `\n${result.stderr}`;
|
|
264
|
+
|
|
265
|
+
const source = `batch:${cmd.label}`;
|
|
266
|
+
const chunks = chunkMarkdown(output, source);
|
|
267
|
+
if (chunks.length > 0) {
|
|
268
|
+
store.index(chunks, source);
|
|
269
|
+
}
|
|
270
|
+
sections.push(`- ${cmd.label} (${(output.length / 1024).toFixed(1)}KB)`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Search across all indexed content
|
|
274
|
+
const results = store.search(queries, { limit: 5 });
|
|
275
|
+
|
|
276
|
+
let text = `Executed ${commands.length} commands. Indexed ${sections.length} sections.\n\n`;
|
|
277
|
+
text += `## Indexed Sections\n\n${sections.join("\n")}\n\n`;
|
|
278
|
+
text += formatSearchResults(results);
|
|
279
|
+
|
|
280
|
+
const terms = extractSearchableTerms(
|
|
281
|
+
results.flatMap((g) => g.results.map((r) => r.body)).join("\n"),
|
|
282
|
+
);
|
|
283
|
+
if (terms.length > 0) {
|
|
284
|
+
text += `\nSearchable terms for follow-up: ${terms.join(", ")}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
trackCall("ctx_batch_execute", text.length);
|
|
288
|
+
return { content: [{ type: "text", text }] };
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ── ctx_index ──────────────────────────────────────────────
|
|
293
|
+
platform.registerTool({
|
|
294
|
+
name: "ctx_index",
|
|
295
|
+
label: "Index Content",
|
|
296
|
+
description:
|
|
297
|
+
"Index documentation or knowledge content into a searchable BM25 knowledge base. After indexing, use ctx_search to retrieve specific sections on-demand.",
|
|
298
|
+
promptSnippet: "ctx_index — store content in knowledge base for later search",
|
|
299
|
+
parameters: {
|
|
300
|
+
type: "object",
|
|
301
|
+
properties: {
|
|
302
|
+
content: { type: "string", description: "Raw text/markdown to index. Provide this OR path, not both." },
|
|
303
|
+
path: { type: "string", description: "File path to read and index. Provide this OR content." },
|
|
304
|
+
source: { type: "string", description: "Label for the indexed content (e.g., 'React docs', 'API reference')" },
|
|
305
|
+
},
|
|
306
|
+
required: ["source"],
|
|
307
|
+
},
|
|
308
|
+
async execute(_toolCallId: string, params: any) {
|
|
309
|
+
const { content, path: filePath, source } = params;
|
|
310
|
+
|
|
311
|
+
if ((content && filePath) || (!content && !filePath)) {
|
|
312
|
+
throw new Error("Exactly one of 'content' or 'path' must be provided.");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const text = filePath ? readFileSync(filePath, "utf-8") : content;
|
|
316
|
+
const chunks = chunkMarkdown(text, source);
|
|
317
|
+
store.index(chunks, source);
|
|
318
|
+
|
|
319
|
+
const output = `Indexed ${chunks.length} chunks under source "${source}".`;
|
|
320
|
+
trackCall("ctx_index", output.length);
|
|
321
|
+
return { content: [{ type: "text", text: output }] };
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ── ctx_search ─────────────────────────────────────────────
|
|
326
|
+
platform.registerTool({
|
|
327
|
+
name: "ctx_search",
|
|
328
|
+
label: "Search Knowledge Base",
|
|
329
|
+
description:
|
|
330
|
+
"Search indexed content. Requires prior indexing via ctx_batch_execute, ctx_index, or ctx_fetch_and_index. Pass ALL search questions as queries array in ONE call.",
|
|
331
|
+
promptSnippet: "ctx_search — query indexed content with BM25 search",
|
|
332
|
+
parameters: {
|
|
333
|
+
type: "object",
|
|
334
|
+
properties: {
|
|
335
|
+
queries: {
|
|
336
|
+
type: "array",
|
|
337
|
+
items: { type: "string" },
|
|
338
|
+
description: "Array of search queries. Batch ALL questions in one call.",
|
|
339
|
+
},
|
|
340
|
+
source: { type: "string", description: "Filter to a specific indexed source (partial match)" },
|
|
341
|
+
contentType: { type: "string", enum: ["code", "prose"], description: "Filter results by content type" },
|
|
342
|
+
limit: { type: "number", description: "Results per query (default: 3)" },
|
|
343
|
+
},
|
|
344
|
+
required: ["queries"],
|
|
345
|
+
},
|
|
346
|
+
async execute(_toolCallId: string, params: any) {
|
|
347
|
+
const { queries, source, contentType, limit } = params;
|
|
348
|
+
const results = store.search(queries, { source, contentType, limit });
|
|
349
|
+
const output = formatSearchResults(results);
|
|
350
|
+
|
|
351
|
+
trackCall("ctx_search", output.length);
|
|
352
|
+
return { content: [{ type: "text", text: output }] };
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ── ctx_fetch_and_index ────────────────────────────────────
|
|
357
|
+
platform.registerTool({
|
|
358
|
+
name: "ctx_fetch_and_index",
|
|
359
|
+
label: "Fetch and Index URL",
|
|
360
|
+
description:
|
|
361
|
+
"Fetch URL content, convert HTML to markdown, index into searchable knowledge base, and return a ~3KB preview. Use ctx_search for deeper lookups.",
|
|
362
|
+
promptSnippet: "ctx_fetch_and_index — fetch URL, index content, return preview",
|
|
363
|
+
parameters: {
|
|
364
|
+
type: "object",
|
|
365
|
+
properties: {
|
|
366
|
+
url: { type: "string", description: "The URL to fetch and index" },
|
|
367
|
+
source: { type: "string", description: "Label for the indexed content" },
|
|
368
|
+
force: { type: "boolean", description: "Skip cache and re-fetch" },
|
|
369
|
+
},
|
|
370
|
+
required: ["url"],
|
|
371
|
+
},
|
|
372
|
+
async execute(_toolCallId: string, params: any) {
|
|
373
|
+
const { url, source, force } = params;
|
|
374
|
+
const result = await fetchAndIndex(url, store, { source, force });
|
|
375
|
+
|
|
376
|
+
let output = result.preview;
|
|
377
|
+
if (!result.cached) {
|
|
378
|
+
output += `\n\n---\nIndexed ${result.chunksIndexed} chunks under source "${result.source}". Use ctx_search for deeper lookups.`;
|
|
379
|
+
} else {
|
|
380
|
+
output += `\n\n---\n[Cached] ${result.chunksIndexed} chunks available under source "${result.source}". Use ctx_search for deeper lookups.`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
trackCall("ctx_fetch_and_index", output.length);
|
|
384
|
+
return { content: [{ type: "text", text: output }] };
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ── ctx_stats ──────────────────────────────────────────────
|
|
389
|
+
platform.registerTool({
|
|
390
|
+
name: "ctx_stats",
|
|
391
|
+
label: "Context Mode Stats",
|
|
392
|
+
description:
|
|
393
|
+
"Returns context consumption statistics for the current session. Shows total bytes returned to context, breakdown by tool, call counts, and knowledge base stats.",
|
|
394
|
+
promptSnippet: "ctx_stats — show session context stats",
|
|
395
|
+
parameters: { type: "object", properties: {} },
|
|
396
|
+
async execute() {
|
|
397
|
+
const storeStats = store.getStats();
|
|
398
|
+
const totalCalls = Object.values(stats.calls).reduce((sum, n) => sum + n, 0);
|
|
399
|
+
const estimatedTokens = Math.ceil(stats.bytesReturned / 4); // rough estimate
|
|
400
|
+
|
|
401
|
+
let output = "## Context Mode Stats\n\n";
|
|
402
|
+
output += `**Total calls**: ${totalCalls}\n`;
|
|
403
|
+
output += `**Bytes returned to context**: ${(stats.bytesReturned / 1024).toFixed(1)}KB\n`;
|
|
404
|
+
output += `**Estimated tokens consumed**: ~${estimatedTokens.toLocaleString()}\n\n`;
|
|
405
|
+
|
|
406
|
+
output += "### Per-tool breakdown\n\n";
|
|
407
|
+
output += "| Tool | Calls |\n|------|-------|\n";
|
|
408
|
+
for (const [name, count] of Object.entries(stats.calls).sort()) {
|
|
409
|
+
output += `| ${name} | ${count} |\n`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
output += `\n### Knowledge base\n\n`;
|
|
413
|
+
output += `- **Chunks indexed**: ${storeStats.totalChunks}\n`;
|
|
414
|
+
output += `- **Sources**: ${storeStats.sources.length > 0 ? storeStats.sources.join(", ") : "(none)"}\n`;
|
|
415
|
+
output += `- **DB size**: ${(storeStats.dbSizeBytes / 1024).toFixed(1)}KB\n`;
|
|
416
|
+
|
|
417
|
+
trackCall("ctx_stats", output.length);
|
|
418
|
+
return { content: [{ type: "text", text: output }] };
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// ── ctx_purge ──────────────────────────────────────────────
|
|
423
|
+
platform.registerTool({
|
|
424
|
+
name: "ctx_purge",
|
|
425
|
+
label: "Purge Knowledge Base",
|
|
426
|
+
description:
|
|
427
|
+
"Delete all indexed content from the knowledge base. Does NOT touch the event store (events.db). Use when you want a fresh start.",
|
|
428
|
+
promptSnippet: "ctx_purge — clear all indexed content",
|
|
429
|
+
parameters: { type: "object", properties: {} },
|
|
430
|
+
async execute() {
|
|
431
|
+
const count = store.purge();
|
|
432
|
+
const output = `Purged ${count} chunks from knowledge base.`;
|
|
433
|
+
trackCall("ctx_purge", output.length);
|
|
434
|
+
return { content: [{ type: "text", text: output }] };
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Exported for testing
|
|
440
|
+
export { stats as _stats, INTENT_THRESHOLD };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type Chunk, chunkMarkdown } from "../knowledge/chunker.js";
|
|
2
|
+
import type { KnowledgeStore } from "../knowledge/store.js";
|
|
3
|
+
import { htmlToMarkdown } from "./html-to-md.js";
|
|
4
|
+
|
|
5
|
+
export interface FetchOptions {
|
|
6
|
+
/** Label for indexed content; defaults to URL hostname. */
|
|
7
|
+
source?: string;
|
|
8
|
+
/** Bypass 24h TTL cache. */
|
|
9
|
+
force?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FetchResult {
|
|
13
|
+
/** ~3KB preview of first chunks. */
|
|
14
|
+
preview: string;
|
|
15
|
+
/** The label used for indexing. */
|
|
16
|
+
source: string;
|
|
17
|
+
chunksIndexed: number;
|
|
18
|
+
/** True if served from url_cache (no network request). */
|
|
19
|
+
cached: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
const PREVIEW_MAX_CHARS = 3000;
|
|
24
|
+
|
|
25
|
+
export async function fetchAndIndex(
|
|
26
|
+
url: string,
|
|
27
|
+
store: KnowledgeStore,
|
|
28
|
+
options?: FetchOptions,
|
|
29
|
+
): Promise<FetchResult> {
|
|
30
|
+
const source = options?.source ?? new URL(url).hostname;
|
|
31
|
+
|
|
32
|
+
// Check cache unless forced
|
|
33
|
+
if (!options?.force) {
|
|
34
|
+
const cached = store.db
|
|
35
|
+
.prepare("SELECT fetched_at FROM url_cache WHERE url = ? AND source = ?")
|
|
36
|
+
.get(url, source) as { fetched_at: number } | null;
|
|
37
|
+
|
|
38
|
+
if (cached && Date.now() - cached.fetched_at < TTL_MS) {
|
|
39
|
+
return buildCachedResult(store, source);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fresh fetch
|
|
44
|
+
const response = await fetch(url);
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error(`Fetch failed: ${response.status} ${response.statusText}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
50
|
+
const rawText = await response.text();
|
|
51
|
+
const markdown = toMarkdown(rawText, contentType);
|
|
52
|
+
|
|
53
|
+
const chunks = chunkMarkdown(markdown, source);
|
|
54
|
+
store.index(chunks, source);
|
|
55
|
+
|
|
56
|
+
store.db.run(
|
|
57
|
+
"INSERT OR REPLACE INTO url_cache (url, source, fetched_at) VALUES (?, ?, ?)",
|
|
58
|
+
[url, source, Date.now()],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
preview: buildPreview(chunks),
|
|
63
|
+
source,
|
|
64
|
+
chunksIndexed: chunks.length,
|
|
65
|
+
cached: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Convert raw response text to markdown based on content-type. */
|
|
70
|
+
function toMarkdown(text: string, contentType: string): string {
|
|
71
|
+
const ct = contentType.toLowerCase();
|
|
72
|
+
if (ct.includes("text/html") || ct.includes("application/xhtml")) {
|
|
73
|
+
return htmlToMarkdown(text);
|
|
74
|
+
}
|
|
75
|
+
if (ct.includes("json")) {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
78
|
+
} catch {
|
|
79
|
+
return text;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return text;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build ~3KB preview from the first N chunks. */
|
|
86
|
+
function buildPreview(chunks: Chunk[]): string {
|
|
87
|
+
let out = "";
|
|
88
|
+
let truncated = false;
|
|
89
|
+
for (const chunk of chunks) {
|
|
90
|
+
const section = chunk.title ? `## ${chunk.title}\n${chunk.body}` : chunk.body;
|
|
91
|
+
if (out.length + section.length > PREVIEW_MAX_CHARS) {
|
|
92
|
+
const remaining = PREVIEW_MAX_CHARS - out.length;
|
|
93
|
+
if (remaining > 0) out += section.slice(0, remaining);
|
|
94
|
+
truncated = true;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
out += (out ? "\n\n" : "") + section;
|
|
98
|
+
}
|
|
99
|
+
if (truncated) {
|
|
100
|
+
out += "\n\n...use search() for full content";
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Reconstruct a cached result by querying stored chunks. */
|
|
106
|
+
function buildCachedResult(store: KnowledgeStore, source: string): FetchResult {
|
|
107
|
+
const rows = store.db
|
|
108
|
+
.prepare("SELECT title, body, content_type AS contentType FROM content_chunks WHERE source = ? ORDER BY id")
|
|
109
|
+
.all(source) as Chunk[];
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
preview: buildPreview(rows),
|
|
113
|
+
source,
|
|
114
|
+
chunksIndexed: rows.length,
|
|
115
|
+
cached: true,
|
|
116
|
+
};
|
|
117
|
+
}
|