kritya 0.8.2-beta

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -0
  3. package/dist/agent/budget.js +25 -0
  4. package/dist/agent/compactor.js +78 -0
  5. package/dist/agent/contextWarning.js +6 -0
  6. package/dist/agent/killSwitch.js +111 -0
  7. package/dist/agent/loop.js +543 -0
  8. package/dist/agent/memory.js +145 -0
  9. package/dist/agent/plugins.js +408 -0
  10. package/dist/agent/skills.js +210 -0
  11. package/dist/agent/skillsCli.js +51 -0
  12. package/dist/agent/systemPrompt.js +122 -0
  13. package/dist/agent/tokens.js +55 -0
  14. package/dist/agent/toolExecutor.js +331 -0
  15. package/dist/agent/workflow.js +496 -0
  16. package/dist/agent/worktree.js +116 -0
  17. package/dist/atomicWrite.js +109 -0
  18. package/dist/audit/audit.js +230 -0
  19. package/dist/audit/cli.js +135 -0
  20. package/dist/commands/custom.js +69 -0
  21. package/dist/commands/mcpCommand.js +433 -0
  22. package/dist/commands/pluginsCommand.js +204 -0
  23. package/dist/commands/registry.js +739 -0
  24. package/dist/config/config.js +214 -0
  25. package/dist/config/debug.js +19 -0
  26. package/dist/config/models.js +82 -0
  27. package/dist/config/retention.js +24 -0
  28. package/dist/config/winAcl.js +38 -0
  29. package/dist/crash.js +102 -0
  30. package/dist/engine.js +113 -0
  31. package/dist/git/git.js +46 -0
  32. package/dist/headless.js +274 -0
  33. package/dist/hooks/hooks.js +122 -0
  34. package/dist/index.js +670 -0
  35. package/dist/lsp/client.js +413 -0
  36. package/dist/lsp/manager.js +0 -0
  37. package/dist/lsp/registry.js +62 -0
  38. package/dist/mcp/callback.js +141 -0
  39. package/dist/mcp/client.js +945 -0
  40. package/dist/mcp/login.js +117 -0
  41. package/dist/mcp/oauth.js +345 -0
  42. package/dist/mcp/servers.js +114 -0
  43. package/dist/mcp/spawnWin.js +86 -0
  44. package/dist/mcp/tokens.js +97 -0
  45. package/dist/mcp/transport.js +295 -0
  46. package/dist/net/urlSafety.js +158 -0
  47. package/dist/permissions/danger.js +96 -0
  48. package/dist/permissions/permissions.js +62 -0
  49. package/dist/permissions/rules.js +69 -0
  50. package/dist/plugins/discover.js +108 -0
  51. package/dist/plugins/mcp.js +80 -0
  52. package/dist/provider/client.js +460 -0
  53. package/dist/provider/switchyardClient.js +46 -0
  54. package/dist/provider/switchyardSidecar.js +245 -0
  55. package/dist/provider/textToolCalls.js +140 -0
  56. package/dist/repomap/repoMap.js +168 -0
  57. package/dist/repomap/symbols.js +190 -0
  58. package/dist/session/store.js +328 -0
  59. package/dist/shell/background.js +131 -0
  60. package/dist/shell/sandbox.js +396 -0
  61. package/dist/telemetry/metrics.js +172 -0
  62. package/dist/telemetry/otlp.js +128 -0
  63. package/dist/telemetry/tracer.js +240 -0
  64. package/dist/tools/askUser.js +64 -0
  65. package/dist/tools/bg.js +59 -0
  66. package/dist/tools/common.js +158 -0
  67. package/dist/tools/deepResearch.js +126 -0
  68. package/dist/tools/diff.js +70 -0
  69. package/dist/tools/document/docx.js +44 -0
  70. package/dist/tools/document/pdf.js +190 -0
  71. package/dist/tools/document/pptx.js +148 -0
  72. package/dist/tools/document/types.js +26 -0
  73. package/dist/tools/document/xlsx.js +87 -0
  74. package/dist/tools/document.js +389 -0
  75. package/dist/tools/edit.js +56 -0
  76. package/dist/tools/fetchUrl.js +218 -0
  77. package/dist/tools/fuzzyMatch.js +74 -0
  78. package/dist/tools/glob.js +34 -0
  79. package/dist/tools/grep.js +85 -0
  80. package/dist/tools/ignore.js +28 -0
  81. package/dist/tools/index.js +66 -0
  82. package/dist/tools/ls.js +27 -0
  83. package/dist/tools/lsp.js +239 -0
  84. package/dist/tools/notebook.js +215 -0
  85. package/dist/tools/read.js +32 -0
  86. package/dist/tools/repoMap.js +26 -0
  87. package/dist/tools/secretScan.js +143 -0
  88. package/dist/tools/shell.js +134 -0
  89. package/dist/tools/skills.js +69 -0
  90. package/dist/tools/subagent.js +57 -0
  91. package/dist/tools/tasks.js +43 -0
  92. package/dist/tools/webSearch.js +97 -0
  93. package/dist/tools/write.js +47 -0
  94. package/dist/tools/writeAgent.js +72 -0
  95. package/dist/trust/aiDisclosure.js +45 -0
  96. package/dist/trust/mcpTrust.js +111 -0
  97. package/dist/trust/trust.js +231 -0
  98. package/dist/types.js +1 -0
  99. package/dist/ui/AiDisclosurePrompt.js +23 -0
  100. package/dist/ui/App.js +449 -0
  101. package/dist/ui/Banner.js +71 -0
  102. package/dist/ui/ElicitationPrompt.js +46 -0
  103. package/dist/ui/Markdown.js +148 -0
  104. package/dist/ui/McpTrustPrompt.js +42 -0
  105. package/dist/ui/ModelPicker.js +19 -0
  106. package/dist/ui/PermissionPrompt.js +27 -0
  107. package/dist/ui/SelectList.js +24 -0
  108. package/dist/ui/Spinner.js +12 -0
  109. package/dist/ui/StatusLine.js +11 -0
  110. package/dist/ui/TranscriptItem.js +13 -0
  111. package/dist/ui/TrustPrompt.js +12 -0
  112. package/dist/ui/highlight.js +137 -0
  113. package/dist/ui/inline.js +241 -0
  114. package/dist/ui/mermaid.js +98 -0
  115. package/dist/ui/table.js +148 -0
  116. package/dist/ui/toolOutputPreview.js +34 -0
  117. package/dist/ui/useAgent.js +534 -0
  118. package/dist/ui/useKillSwitch.js +65 -0
  119. package/dist/ui/useSessionResume.js +39 -0
  120. package/dist/ui/useUsageBudget.js +149 -0
  121. package/dist/ui/viewport.js +71 -0
  122. package/dist/undo/undo.js +293 -0
  123. package/dist/version.js +4 -0
  124. package/package.json +122 -0
@@ -0,0 +1,239 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { lspManager } from "../lsp/manager.js";
5
+ import { writeFileAtomic } from "../atomicWrite.js";
6
+ import { resolveSafe, truncateResult } from "./common.js";
7
+ const MAX_LOCATIONS = 100;
8
+ const positionParams = {
9
+ type: "object",
10
+ properties: {
11
+ path: { type: "string", description: "File path relative to the workspace root" },
12
+ line: { type: "number", description: "1-based line number of the symbol" },
13
+ column: {
14
+ type: "number",
15
+ description: "1-based column of a character inside the symbol name",
16
+ },
17
+ },
18
+ required: ["path", "line", "column"],
19
+ };
20
+ function parsePosition(args) {
21
+ const line = Number(args.line);
22
+ const column = Number(args.column);
23
+ if (!Number.isInteger(line) || line < 1 || !Number.isInteger(column) || column < 1) {
24
+ throw new Error("line and column must be 1-based positive integers");
25
+ }
26
+ return { line: line - 1, character: column - 1 };
27
+ }
28
+ /**
29
+ * Render locations as "path:line:col <source line>". The snippet is read
30
+ * from disk per unique file; files outside the workspace (stdlib,
31
+ * node_modules typings) are still reported by path, just without a snippet
32
+ * guarantee — definitions legitimately live there.
33
+ */
34
+ async function formatLocations(workspace, locations) {
35
+ if (locations.length === 0)
36
+ return "(no results)";
37
+ const shown = locations.slice(0, MAX_LOCATIONS);
38
+ const fileCache = new Map();
39
+ const lines = [];
40
+ for (const loc of shown) {
41
+ let abs;
42
+ try {
43
+ abs = fileURLToPath(loc.uri);
44
+ }
45
+ catch {
46
+ lines.push(loc.uri);
47
+ continue;
48
+ }
49
+ const rel = path.relative(workspace, abs);
50
+ const display = rel.startsWith("..") ? abs : rel;
51
+ const line = loc.range.start.line + 1;
52
+ const col = loc.range.start.character + 1;
53
+ if (!fileCache.has(abs)) {
54
+ try {
55
+ fileCache.set(abs, (await fs.readFile(abs, "utf8")).split("\n"));
56
+ }
57
+ catch {
58
+ fileCache.set(abs, null);
59
+ }
60
+ }
61
+ const snippet = fileCache.get(abs)?.[loc.range.start.line]?.trim().slice(0, 200);
62
+ lines.push(`${display}:${line}:${col}${snippet ? ` ${snippet}` : ""}`);
63
+ }
64
+ if (locations.length > shown.length) {
65
+ lines.push(`... [${locations.length - shown.length} more]`);
66
+ }
67
+ return truncateResult(lines.join("\n"));
68
+ }
69
+ const SEVERITY = ["error", "warning", "info", "hint"];
70
+ function formatDiagnostics(relPath, diags) {
71
+ if (diags.length === 0)
72
+ return `No diagnostics for ${relPath} — the file is clean.`;
73
+ const lines = diags.map((d) => {
74
+ const sev = SEVERITY[(d.severity ?? 1) - 1] ?? "error";
75
+ const line = d.range.start.line + 1;
76
+ const col = d.range.start.character + 1;
77
+ const code = d.code !== undefined ? ` [${d.source ?? ""}${d.source ? ":" : ""}${d.code}]` : "";
78
+ return `${relPath}:${line}:${col} ${sev}: ${d.message.replace(/\s*\n\s*/g, " ")}${code}`;
79
+ });
80
+ return truncateResult(lines.join("\n"));
81
+ }
82
+ export const lspDefinitionTool = {
83
+ name: "lsp_definition",
84
+ description: "Go to the definition of the symbol at a position, resolved semantically by a real " +
85
+ "language server (TypeScript/JavaScript, Python, Go, Rust, C/C++) — unlike grep, this " +
86
+ "follows imports, scopes, and re-exports. Returns 'path:line:col <code>' locations. " +
87
+ "The first call for a language may take a few seconds while the server indexes the project.",
88
+ parameters: positionParams,
89
+ requiresPermission: false,
90
+ summarize: (args) => `Definition of ${args.path}:${args.line}:${args.column}`,
91
+ async execute(args, ctx) {
92
+ const abs = resolveSafe(ctx.workspace, String(args.path));
93
+ const client = await lspManager.clientFor(ctx.workspace, abs);
94
+ return formatLocations(ctx.workspace, await client.definition(abs, parsePosition(args)));
95
+ },
96
+ };
97
+ export const lspReferencesTool = {
98
+ name: "lsp_references",
99
+ description: "Find all references to the symbol at a position, resolved semantically by a real " +
100
+ "language server — every actual usage site across the project, without the false " +
101
+ "positives of a text search. Returns 'path:line:col <code>' locations, including the " +
102
+ "declaration.",
103
+ parameters: positionParams,
104
+ requiresPermission: false,
105
+ summarize: (args) => `References to ${args.path}:${args.line}:${args.column}`,
106
+ async execute(args, ctx) {
107
+ const abs = resolveSafe(ctx.workspace, String(args.path));
108
+ const client = await lspManager.clientFor(ctx.workspace, abs);
109
+ return formatLocations(ctx.workspace, await client.references(abs, parsePosition(args)));
110
+ },
111
+ };
112
+ export const lspDiagnosticsTool = {
113
+ name: "lsp_diagnostics",
114
+ description: "Get language-server diagnostics (type errors, unresolved imports, warnings) for a file " +
115
+ "without running a build. Use after editing to verify a change didn't break anything. " +
116
+ "Reads the file from disk, so unsaved concerns don't apply.",
117
+ parameters: {
118
+ type: "object",
119
+ properties: {
120
+ path: { type: "string", description: "File path relative to the workspace root" },
121
+ },
122
+ required: ["path"],
123
+ },
124
+ requiresPermission: false,
125
+ summarize: (args) => `Diagnostics for ${args.path}`,
126
+ async execute(args, ctx) {
127
+ const abs = resolveSafe(ctx.workspace, String(args.path));
128
+ const client = await lspManager.clientFor(ctx.workspace, abs);
129
+ const rel = path.relative(ctx.workspace, abs);
130
+ return formatDiagnostics(rel, await client.diagnosticsFor(abs));
131
+ },
132
+ };
133
+ export const lspHoverTool = {
134
+ name: "lsp_hover",
135
+ description: "Show what a symbol resolves to — its type, signature, and doc comment — as computed by a " +
136
+ "real language server, without opening the file it's defined in. Answers 'what is this?' at " +
137
+ "a position: the inferred type of a variable, a function's full signature, an imported " +
138
+ "symbol's origin. Returns the server's markdown/plaintext, or a note if there's nothing there.",
139
+ parameters: positionParams,
140
+ requiresPermission: false,
141
+ summarize: (args) => `Hover ${args.path}:${args.line}:${args.column}`,
142
+ async execute(args, ctx) {
143
+ const abs = resolveSafe(ctx.workspace, String(args.path));
144
+ const client = await lspManager.clientFor(ctx.workspace, abs);
145
+ const text = await client.hover(abs, parsePosition(args));
146
+ return text ? truncateResult(text) : "(no hover information at this position)";
147
+ },
148
+ };
149
+ /**
150
+ * Apply a set of LSP TextEdits to a document's text. LSP positions are UTF-16
151
+ * code-unit offsets, which is exactly how JS indexes strings, so line/character
152
+ * map onto slice offsets directly. Edits are applied end-first so that earlier
153
+ * edits don't shift the offsets of later ones.
154
+ */
155
+ function applyTextEdits(text, edits) {
156
+ const lineStarts = [0];
157
+ for (let i = 0; i < text.length; i++) {
158
+ if (text[i] === "\n")
159
+ lineStarts.push(i + 1);
160
+ }
161
+ const toOffset = (pos) => {
162
+ const lineStart = lineStarts[pos.line] ?? text.length;
163
+ return Math.min(lineStart + pos.character, text.length);
164
+ };
165
+ const sorted = [...edits].sort((a, b) => toOffset(b.range.start) - toOffset(a.range.start));
166
+ let result = text;
167
+ for (const edit of sorted) {
168
+ const start = toOffset(edit.range.start);
169
+ const end = toOffset(edit.range.end);
170
+ result = result.slice(0, start) + edit.newText + result.slice(end);
171
+ }
172
+ return result;
173
+ }
174
+ const renameParams = {
175
+ type: "object",
176
+ properties: {
177
+ path: { type: "string", description: "File path relative to the workspace root" },
178
+ line: { type: "number", description: "1-based line number of the symbol" },
179
+ column: {
180
+ type: "number",
181
+ description: "1-based column of a character inside the symbol name",
182
+ },
183
+ new_name: { type: "string", description: "The new name for the symbol" },
184
+ },
185
+ required: ["path", "line", "column", "new_name"],
186
+ };
187
+ export const lspRenameTool = {
188
+ name: "lsp_rename",
189
+ description: "Rename a symbol everywhere it is used, across the whole project, using a real language " +
190
+ "server. Unlike find-and-replace this is semantic: it renames only the actual occurrences " +
191
+ "of THIS symbol — never a same-named but unrelated variable, and never text in comments or " +
192
+ "strings — and updates every file that references it. Point it at any one occurrence " +
193
+ "(definition or use). Writes the edited files to disk and reports what changed.",
194
+ parameters: renameParams,
195
+ requiresPermission: true,
196
+ summarize: (args) => `Rename ${args.path}:${args.line}:${args.column} → ${args.new_name}`,
197
+ async execute(args, ctx) {
198
+ const abs = resolveSafe(ctx.workspace, String(args.path));
199
+ const newName = String(args.new_name);
200
+ if (newName.trim().length === 0)
201
+ throw new Error("new_name must not be empty");
202
+ const client = await lspManager.clientFor(ctx.workspace, abs);
203
+ const fileEdits = await client.rename(abs, parsePosition(args), newName);
204
+ if (fileEdits.length === 0) {
205
+ return "No rename performed — the symbol at that position can't be renamed (it may be a keyword, literal, or outside a project the server has loaded).";
206
+ }
207
+ // Validate every target up front so a rename is all-or-nothing: resolveSafe
208
+ // rejects anything outside the workspace or matching a secret pattern.
209
+ const targets = [];
210
+ for (const fe of fileEdits) {
211
+ let target;
212
+ try {
213
+ target = fileURLToPath(fe.uri);
214
+ }
215
+ catch {
216
+ throw new Error(`Rename produced an edit for a non-file URI (${fe.uri}); aborted.`);
217
+ }
218
+ const rel = path.relative(ctx.workspace, target);
219
+ // resolveSafe throws for out-of-workspace / sensitive paths — abort the whole rename.
220
+ const safeAbs = resolveSafe(ctx.workspace, rel);
221
+ targets.push({ abs: safeAbs, rel, edits: fe.edits });
222
+ }
223
+ let totalEdits = 0;
224
+ for (const t of targets) {
225
+ const content = await fs.readFile(t.abs, "utf8");
226
+ const updated = applyTextEdits(content, t.edits);
227
+ if (updated === content)
228
+ continue;
229
+ ctx.undo?.snapshot(t.abs, t.rel);
230
+ await writeFileAtomic(t.abs, updated);
231
+ totalEdits += t.edits.length;
232
+ }
233
+ const fileList = targets
234
+ .map((t) => ` ${t.rel} (${t.edits.length} edit${t.edits.length === 1 ? "" : "s"})`)
235
+ .join("\n");
236
+ return truncateResult(`Renamed to "${newName}" — ${totalEdits} edit${totalEdits === 1 ? "" : "s"} across ` +
237
+ `${targets.length} file${targets.length === 1 ? "" : "s"}:\n${fileList}`);
238
+ },
239
+ };
@@ -0,0 +1,215 @@
1
+ import fs from "node:fs/promises";
2
+ import { writeFileAtomic } from "../atomicWrite.js";
3
+ import { resolveSafe, truncateResult } from "./common.js";
4
+ /** nbformat stores text as either a single string or an array of lines. */
5
+ function joinSource(source) {
6
+ if (Array.isArray(source))
7
+ return source.join("");
8
+ return source ?? "";
9
+ }
10
+ /** Store text the way Jupyter does: one array entry per line, newline kept on all but the last. */
11
+ function splitSource(text) {
12
+ if (text === "")
13
+ return [];
14
+ const lines = text.split("\n");
15
+ return lines.map((line, i) => (i < lines.length - 1 ? line + "\n" : line));
16
+ }
17
+ const MAX_OUTPUT_CHARS = 2000;
18
+ /** Collapse one cell's outputs into a short, token-cheap text summary. */
19
+ function summarizeOutputs(outputs) {
20
+ const parts = [];
21
+ for (const raw of outputs) {
22
+ const out = raw;
23
+ switch (out.output_type) {
24
+ case "stream":
25
+ parts.push(joinSource(out.text));
26
+ break;
27
+ case "execute_result":
28
+ case "display_data": {
29
+ const data = (out.data ?? {});
30
+ if (data["text/plain"]) {
31
+ parts.push(joinSource(data["text/plain"]));
32
+ }
33
+ // Rich outputs (images, HTML) are large and unreadable as text: name them, drop the bytes.
34
+ for (const mime of Object.keys(data)) {
35
+ if (mime !== "text/plain")
36
+ parts.push(`[${mime} output]`);
37
+ }
38
+ break;
39
+ }
40
+ case "error": {
41
+ const ename = String(out.ename ?? "Error");
42
+ const evalue = String(out.evalue ?? "");
43
+ parts.push(`${ename}: ${evalue}`);
44
+ if (Array.isArray(out.traceback)) {
45
+ parts.push(out.traceback.join("\n"));
46
+ }
47
+ break;
48
+ }
49
+ default:
50
+ break;
51
+ }
52
+ }
53
+ const text = parts.join("\n").trimEnd();
54
+ if (text.length <= MAX_OUTPUT_CHARS)
55
+ return text;
56
+ return (text.slice(0, MAX_OUTPUT_CHARS) +
57
+ `\n... [output truncated, ${text.length - MAX_OUTPUT_CHARS} more characters]`);
58
+ }
59
+ async function loadNotebook(abs) {
60
+ const text = await fs.readFile(abs, "utf8");
61
+ let nb;
62
+ try {
63
+ nb = JSON.parse(text);
64
+ }
65
+ catch {
66
+ throw new Error("File is not valid JSON — a .ipynb notebook must be a JSON document");
67
+ }
68
+ if (!Array.isArray(nb.cells)) {
69
+ throw new Error("Not a valid notebook: missing a top-level `cells` array");
70
+ }
71
+ return nb;
72
+ }
73
+ async function saveNotebook(abs, nb) {
74
+ // Jupyter writes notebooks with 1-space indent and a trailing newline; match it to keep diffs small.
75
+ await writeFileAtomic(abs, JSON.stringify(nb, null, 1) + "\n");
76
+ }
77
+ export const readNotebookTool = {
78
+ name: "read_notebook",
79
+ description: "Read a Jupyter notebook (.ipynb) and return a clean, per-cell view: each cell's index, " +
80
+ "type (code/markdown/raw), source, and a compacted summary of its outputs. Large binary " +
81
+ "outputs like images are shown as placeholders rather than raw data. Use the cell index " +
82
+ "shown here with edit_notebook.",
83
+ parameters: {
84
+ type: "object",
85
+ properties: {
86
+ path: { type: "string", description: "File path relative to the workspace root" },
87
+ },
88
+ required: ["path"],
89
+ },
90
+ requiresPermission: false,
91
+ summarize: (args) => `Read ${args.path}`,
92
+ async execute(args, ctx) {
93
+ const abs = resolveSafe(ctx.workspace, String(args.path));
94
+ const nb = await loadNotebook(abs);
95
+ const parts = [];
96
+ nb.cells.forEach((cell, i) => {
97
+ const type = cell.cell_type ?? "code";
98
+ const exec = type === "code" && cell.execution_count != null ? ` (exec ${cell.execution_count})` : "";
99
+ parts.push(`[${i}] ${type}${exec}`);
100
+ parts.push(joinSource(cell.source));
101
+ if (type === "code" && Array.isArray(cell.outputs) && cell.outputs.length > 0) {
102
+ const summary = summarizeOutputs(cell.outputs);
103
+ if (summary) {
104
+ parts.push("--- output ---");
105
+ parts.push(summary);
106
+ }
107
+ }
108
+ parts.push("");
109
+ });
110
+ return truncateResult(parts.join("\n").trimEnd());
111
+ },
112
+ };
113
+ export const editNotebookTool = {
114
+ name: "edit_notebook",
115
+ description: "Edit a single cell of a Jupyter notebook (.ipynb) in place, leaving all other cells and " +
116
+ "their saved outputs untouched. op=replace changes a cell's source (and clears that cell's " +
117
+ "stale outputs); op=insert adds a new cell at the given index; op=delete removes the cell at " +
118
+ "the given index. Cell indexes match what read_notebook prints. This does not run any code.",
119
+ parameters: {
120
+ type: "object",
121
+ properties: {
122
+ path: { type: "string", description: "File path relative to the workspace root" },
123
+ op: { type: "string", enum: ["replace", "insert", "delete"] },
124
+ index: {
125
+ type: "number",
126
+ description: "Target cell index. For insert, the new cell is placed at this index (use the cell count to append at the end).",
127
+ },
128
+ source: {
129
+ type: "string",
130
+ description: "New cell text. Required for replace and insert; ignored for delete.",
131
+ },
132
+ cell_type: {
133
+ type: "string",
134
+ enum: ["code", "markdown", "raw"],
135
+ description: "Cell type for insert (default code), or to change a cell's type on replace.",
136
+ },
137
+ },
138
+ required: ["path", "op", "index"],
139
+ },
140
+ requiresPermission: true,
141
+ summarize: (args) => `Edit ${args.path} (${args.op} cell ${args.index})`,
142
+ async preview(args) {
143
+ const op = String(args.op);
144
+ if (op === "delete")
145
+ return `Delete cell ${args.index}`;
146
+ const src = String(args.source ?? "");
147
+ const preview = src.length > 200 ? src.slice(0, 200) + "…" : src;
148
+ return `${op} cell ${args.index}:\n${preview}`;
149
+ },
150
+ async execute(args, ctx) {
151
+ const relPath = String(args.path);
152
+ const abs = resolveSafe(ctx.workspace, relPath);
153
+ const op = String(args.op);
154
+ const index = Number(args.index);
155
+ if (!Number.isInteger(index) || index < 0) {
156
+ throw new Error(`index must be a non-negative integer, got ${args.index}`);
157
+ }
158
+ const nb = await loadNotebook(abs);
159
+ const cells = nb.cells;
160
+ switch (op) {
161
+ case "replace": {
162
+ if (index >= cells.length) {
163
+ throw new Error(`Cannot replace cell ${index}: notebook has ${cells.length} cell(s)`);
164
+ }
165
+ if (args.source === undefined)
166
+ throw new Error("replace requires `source`");
167
+ const cell = cells[index];
168
+ cell.source = splitSource(String(args.source));
169
+ if (args.cell_type !== undefined)
170
+ cell.cell_type = String(args.cell_type);
171
+ // The stored source no longer matches these outputs, so clear them.
172
+ if (cell.cell_type === "code") {
173
+ cell.outputs = [];
174
+ cell.execution_count = null;
175
+ }
176
+ else {
177
+ delete cell.outputs;
178
+ delete cell.execution_count;
179
+ }
180
+ break;
181
+ }
182
+ case "insert": {
183
+ if (index > cells.length) {
184
+ throw new Error(`Cannot insert at ${index}: notebook has ${cells.length} cell(s) (max index ${cells.length})`);
185
+ }
186
+ if (args.source === undefined)
187
+ throw new Error("insert requires `source`");
188
+ const cellType = args.cell_type !== undefined ? String(args.cell_type) : "code";
189
+ const cell = {
190
+ cell_type: cellType,
191
+ source: splitSource(String(args.source)),
192
+ metadata: {},
193
+ };
194
+ if (cellType === "code") {
195
+ cell.outputs = [];
196
+ cell.execution_count = null;
197
+ }
198
+ cells.splice(index, 0, cell);
199
+ break;
200
+ }
201
+ case "delete": {
202
+ if (index >= cells.length) {
203
+ throw new Error(`Cannot delete cell ${index}: notebook has ${cells.length} cell(s)`);
204
+ }
205
+ cells.splice(index, 1);
206
+ break;
207
+ }
208
+ default:
209
+ throw new Error(`Unknown op "${op}". Use replace, insert, or delete.`);
210
+ }
211
+ ctx.undo?.snapshot(abs, relPath);
212
+ await saveNotebook(abs, nb);
213
+ return `${op === "delete" ? "Deleted" : op === "insert" ? "Inserted" : "Replaced"} cell ${index} in ${relPath}`;
214
+ },
215
+ };
@@ -0,0 +1,32 @@
1
+ import fs from "node:fs/promises";
2
+ import { resolveSafe, truncateResult, countLines } from "./common.js";
3
+ export const readFileTool = {
4
+ name: "read_file",
5
+ description: "Read a text file from the workspace. Returns the content with line numbers. " +
6
+ "Use offset/limit for large files.",
7
+ parameters: {
8
+ type: "object",
9
+ properties: {
10
+ path: { type: "string", description: "File path relative to the workspace root" },
11
+ offset: { type: "number", description: "1-based line number to start from (optional)" },
12
+ limit: { type: "number", description: "Maximum number of lines to return (optional)" },
13
+ },
14
+ required: ["path"],
15
+ },
16
+ requiresPermission: false,
17
+ summarize: (args) => `Read ${args.path}`,
18
+ resultSummary: (output) => countLines(output, "line", "lines"),
19
+ async execute(args, ctx) {
20
+ const abs = resolveSafe(ctx.workspace, String(args.path));
21
+ const content = await fs.readFile(abs, "utf8");
22
+ const lines = content.split("\n");
23
+ const offset = Math.max(1, Number(args.offset) || 1);
24
+ const limit = Number(args.limit) || 2000;
25
+ const slice = lines.slice(offset - 1, offset - 1 + limit);
26
+ const numbered = slice.map((l, i) => `${String(offset + i).padStart(5)}\t${l}`).join("\n");
27
+ const header = lines.length > slice.length
28
+ ? `[showing lines ${offset}-${offset + slice.length - 1} of ${lines.length}]\n`
29
+ : "";
30
+ return truncateResult(header + numbered);
31
+ },
32
+ };
@@ -0,0 +1,26 @@
1
+ import { countLines } from "./common.js";
2
+ import { buildRepoMap } from "../repomap/repoMap.js";
3
+ export const repoMapTool = {
4
+ name: "repo_map",
5
+ description: "Get a structural overview of the codebase: a ranked skeleton of source files with their " +
6
+ "class/function/type signatures (no bodies). Use this FIRST to orient yourself in an " +
7
+ "unfamiliar or large repository before grepping or reading files — it shows where things " +
8
+ "live at a fraction of the token cost of reading files. Pass a `path` to focus on one " +
9
+ "subdirectory. Covers mainstream languages (TS/JS, Python, Go, Rust, Java, Kotlin, C#, " +
10
+ "C/C++, Ruby, PHP, Swift, Scala); files in other languages are omitted.",
11
+ parameters: {
12
+ type: "object",
13
+ properties: {
14
+ path: {
15
+ type: "string",
16
+ description: "Optional subdirectory to map, relative to the workspace root (default: whole workspace)",
17
+ },
18
+ },
19
+ },
20
+ requiresPermission: false,
21
+ summarize: (args) => (args.path ? `Repo map: ${args.path}` : "Repo map (whole workspace)"),
22
+ resultSummary: (output) => countLines(output, "line", "lines"),
23
+ async execute(args, ctx) {
24
+ return buildRepoMap(ctx.workspace, String(args.path ?? "."));
25
+ },
26
+ };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Content-based secret scanning, independent of the filename checks in
3
+ * common.ts (isSensitivePath). Filenames only catch secrets kept in files
4
+ * that *look* sensitive (.env, *secret*, etc.) — they do nothing to stop the
5
+ * model from writing a real API key it saw in some tool output (a log, an
6
+ * error message, a fetched page) into an ordinary file like README.md or
7
+ * config.ts. This scans content being written/edited for known secret
8
+ * formats and high-entropy assignments, and blocks the write.
9
+ *
10
+ * This is a heuristic defense-in-depth measure, not a guarantee: it can miss
11
+ * novel key formats and can false-positive on random-looking test fixtures.
12
+ * Callers should surface the specific match kind so a false positive is easy
13
+ * to diagnose and override deliberately (e.g. by restructuring the string).
14
+ */
15
+ // Well-known key/token formats with distinctive prefixes or structure —
16
+ // these have a very low false-positive rate.
17
+ const NAMED_PATTERNS = [
18
+ { kind: "AWS Access Key ID", re: /\bAKIA[0-9A-Z]{16}\b/g },
19
+ { kind: "AWS Secret Access Key", re: /\baws(.{0,20})?['"]\s*[:=]\s*['"][0-9a-zA-Z/+]{40}['"]/gi },
20
+ { kind: "GitHub token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
21
+ { kind: "GitLab token", re: /\bglpat-[A-Za-z0-9\-_]{20,}\b/g },
22
+ { kind: "Slack token", re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
23
+ { kind: "Slack webhook URL", re: /\bhooks\.slack\.com\/services\/[A-Za-z0-9/]{20,}\b/g },
24
+ { kind: "Google API key", re: /\bAIza[0-9A-Za-z\-_]{35}\b/g },
25
+ { kind: "Stripe key", re: /\b(sk|rk)_(live|test)_[0-9a-zA-Z]{20,}\b/g },
26
+ { kind: "Anthropic API key", re: /\bsk-ant-[A-Za-z0-9\-_]{20,}\b/g },
27
+ {
28
+ kind: "OpenAI API key",
29
+ re: /\bsk-[A-Za-z0-9]{20,}T3BlbkFJ[A-Za-z0-9]{20,}\b|\bsk-proj-[A-Za-z0-9\-_]{20,}\b/g,
30
+ },
31
+ {
32
+ kind: "JSON Web Token",
33
+ re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
34
+ },
35
+ {
36
+ kind: "Private key block",
37
+ re: /-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,
38
+ },
39
+ ];
40
+ // Generic "KEY = <opaque token>" assignments, gated by an entropy check so
41
+ // ordinary identifiers/URLs/sentences don't trip it.
42
+ // No leading `\b`: env vars are routinely prefixed (OPENAI_API_KEY,
43
+ // STRIPE_SECRET_KEY, DB_PASSWORD) and `_` is a word character, so a boundary
44
+ // requirement before the key phrase would silently skip almost every real
45
+ // `.env` entry. A trailing `\b` still stops the phrase mid-identifier.
46
+ const ASSIGNMENT_RE = /(api[_-]?key|apikey|secret|token|access[_-]?key|private[_-]?key|passwd|password|pwd|auth)\w*\s*[:=]\s*["'`]?([A-Za-z0-9/+_.-]{16,})["'`]?/gi;
47
+ function shannonEntropy(s) {
48
+ const counts = new Map();
49
+ for (const ch of s)
50
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
51
+ let entropy = 0;
52
+ for (const count of counts.values()) {
53
+ const p = count / s.length;
54
+ entropy -= p * Math.log2(p);
55
+ }
56
+ return entropy;
57
+ }
58
+ /** Placeholder-looking values (env var refs, obvious examples) that shouldn't be flagged. */
59
+ function looksLikePlaceholder(value) {
60
+ if (/^(x+|\*+|0+|1+|<[^>]+>|\{\{.*\}\}|\$\{?[A-Z0-9_]+\}?|your[_-]|example|changeme|placeholder|dummy|fake|test)/i.test(value)) {
61
+ return true;
62
+ }
63
+ // Low variety of distinct characters (e.g. "aaaaaaaaaaaaaaaaaaaa") is not a real secret.
64
+ return new Set(value).size < 6;
65
+ }
66
+ const MIN_ENTROPY = 3.5;
67
+ const MAX_SNIPPET = 12;
68
+ function redact(value) {
69
+ if (value.length <= MAX_SNIPPET)
70
+ return value.slice(0, 4) + "…";
71
+ return value.slice(0, 4) + "…" + value.slice(-4);
72
+ }
73
+ /**
74
+ * Scan `content` for strings that look like real secrets. Returns an empty
75
+ * array if none are found.
76
+ */
77
+ export function scanForSecrets(content) {
78
+ const matches = [];
79
+ const seen = new Set();
80
+ for (const { kind, re } of NAMED_PATTERNS) {
81
+ re.lastIndex = 0;
82
+ let m;
83
+ while ((m = re.exec(content))) {
84
+ const value = m[0];
85
+ const key = `${kind}:${value}`;
86
+ if (!seen.has(key)) {
87
+ seen.add(key);
88
+ matches.push({ kind, snippet: redact(value) });
89
+ }
90
+ if (!re.global)
91
+ break;
92
+ }
93
+ }
94
+ ASSIGNMENT_RE.lastIndex = 0;
95
+ let am;
96
+ while ((am = ASSIGNMENT_RE.exec(content))) {
97
+ const value = am[2];
98
+ if (looksLikePlaceholder(value))
99
+ continue;
100
+ if (shannonEntropy(value) < MIN_ENTROPY)
101
+ continue;
102
+ const key = `assignment:${value}`;
103
+ if (!seen.has(key)) {
104
+ seen.add(key);
105
+ matches.push({ kind: `high-entropy ${am[1].toLowerCase()} value`, snippet: redact(value) });
106
+ }
107
+ }
108
+ return matches;
109
+ }
110
+ /**
111
+ * Scans `content` the same way `scanForSecrets` does, but instead of
112
+ * blocking, replaces each matched secret with a `[REDACTED: <kind>]`
113
+ * placeholder. Used for shell output, which — unlike a file write — has
114
+ * already happened by the time we see it; redacting the display is the
115
+ * only lever left, so masking rather than throwing is the right shape here.
116
+ */
117
+ export function redactSecrets(content) {
118
+ const matches = scanForSecrets(content);
119
+ if (matches.length === 0)
120
+ return { redacted: content, matches };
121
+ let redacted = content;
122
+ for (const { kind, re } of NAMED_PATTERNS) {
123
+ redacted = redacted.replace(re, () => `[REDACTED: ${kind}]`);
124
+ }
125
+ redacted = redacted.replace(ASSIGNMENT_RE, (full, _label, value) => {
126
+ if (looksLikePlaceholder(value) || shannonEntropy(value) < MIN_ENTROPY)
127
+ return full;
128
+ // `full` is `key<sep><quote?>value<quote?>` — replace just the value
129
+ // substring so the key (useful context) stays visible. `value` is at
130
+ // least 16 chars, so a spurious earlier occurrence inside the key/label
131
+ // portion of `full` is not a realistic concern.
132
+ return full.replace(value, "[REDACTED]");
133
+ });
134
+ return { redacted, matches };
135
+ }
136
+ export function formatSecretWarning(matches, path) {
137
+ const lines = matches.map((m) => ` - ${m.kind}: ${m.snippet}`);
138
+ return (`Blocked write to ${path}: content looks like it contains real secret(s):\n` +
139
+ lines.join("\n") +
140
+ `\n\nIf this is genuinely not a secret (e.g. a test fixture), rewrite it so it doesn't ` +
141
+ `match common key formats or lower its entropy — for example, using an obvious placeholder ` +
142
+ `like "sk-ant-EXAMPLE...".`);
143
+ }