novacode 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +10 -27
  2. package/dist/app-C_2My7n6.mjs +28 -0
  3. package/dist/app-C_2My7n6.mjs.map +1 -0
  4. package/dist/main.mjs +86 -36
  5. package/dist/main.mjs.map +1 -1
  6. package/package.json +1 -2
  7. package/dist/app-bQ9a_p_K.mjs +0 -22
  8. package/dist/app-bQ9a_p_K.mjs.map +0 -1
  9. package/src/agent/agent.ts +0 -87
  10. package/src/agent/loop.ts +0 -237
  11. package/src/agent/prompt.ts +0 -50
  12. package/src/commands/compact.ts +0 -28
  13. package/src/commands/index.ts +0 -128
  14. package/src/commands/models.ts +0 -85
  15. package/src/commands/providers.ts +0 -213
  16. package/src/commands/session.ts +0 -52
  17. package/src/config/providers.ts +0 -207
  18. package/src/config/store.ts +0 -66
  19. package/src/main.ts +0 -205
  20. package/src/onboarding/wizard.ts +0 -54
  21. package/src/provider/gemini.ts +0 -269
  22. package/src/provider/openai.ts +0 -239
  23. package/src/provider/stream.ts +0 -138
  24. package/src/session/compact.ts +0 -159
  25. package/src/session/store.ts +0 -209
  26. package/src/tools/fs.ts +0 -189
  27. package/src/tools/git.ts +0 -99
  28. package/src/tools/index.ts +0 -33
  29. package/src/tools/search.ts +0 -274
  30. package/src/tools/shell.ts +0 -90
  31. package/src/tools/web.ts +0 -239
  32. package/src/tui/app.tsx +0 -454
  33. package/src/tui/components/liveArea.tsx +0 -70
  34. package/src/tui/components/message.tsx +0 -117
  35. package/src/tui/components/statusBar.tsx +0 -64
  36. package/src/tui/constants.ts +0 -25
  37. package/src/tui/markdown.ts +0 -62
  38. package/src/tui/prompts.tsx +0 -205
  39. package/src/types.ts +0 -262
  40. package/src/update.ts +0 -89
  41. package/src/util.ts +0 -80
@@ -1,274 +0,0 @@
1
- /**
2
- * Search tools for finding files and content.
3
- * Uses 'rg' (ripgrep) if available, falling back to a pure JS implementation.
4
- */
5
-
6
- import { spawn } from "node:child_process"
7
- import { readdir, readFile } from "node:fs/promises"
8
- import { relative, resolve } from "node:path"
9
- import { glob } from "glob"
10
- import type { Tool, ToolResult } from "../types.ts"
11
-
12
- import { textPart } from "../util.ts"
13
-
14
- /**
15
- * Tool for finding files by glob pattern.
16
- */
17
- export function globTool(cwd: string): Tool {
18
- return {
19
- def: {
20
- name: "glob",
21
- description: "Find files by glob pattern (e.g. **/*.ts, src/**/*.test.ts).",
22
- parameters: {
23
- type: "object",
24
- properties: {
25
- pattern: { type: "string", description: "Glob pattern (e.g. **/*.ts)" },
26
- path: { type: "string", description: "Directory to search in (default .)" },
27
- nocase: { type: "boolean", description: "Case-insensitive search (default false)" },
28
- },
29
- required: ["pattern"],
30
- },
31
- },
32
- async execute(args): Promise<ToolResult> {
33
- try {
34
- const rawPath = (args.path as string) || "."
35
- const dir = resolve(cwd, rawPath)
36
- if (dir !== cwd && !dir.startsWith(`${cwd}/`)) {
37
- throw new Error(`Path outside project: ${rawPath}`)
38
- }
39
-
40
- const pattern = args.pattern as string
41
- const nocase = !!args.nocase
42
- const files = await glob(pattern, { cwd: dir, nocase })
43
- const sliced = files.slice(0, 500)
44
- const relSearchPath = relative(cwd, dir)
45
- const prefix = relSearchPath ? `${relSearchPath}/` : ""
46
- const relFiles = sliced.map((f) => prefix + f)
47
- const out = relFiles.length > 0 ? relFiles.join("\n") : "No files found"
48
- return { content: [textPart(out)], isError: false }
49
- } catch (e) {
50
- return {
51
- content: [textPart(`Error: ${(e as Error).message}`)],
52
- isError: true,
53
- }
54
- }
55
- },
56
- }
57
- }
58
-
59
- /**
60
- * Tool for searching file contents using regex.
61
- */
62
- export function grepTool(cwd: string): Tool {
63
- return {
64
- def: {
65
- name: "grep",
66
- description:
67
- "Search file contents with a regex pattern. Returns matching lines with file paths and line numbers.",
68
- parameters: {
69
- type: "object",
70
- properties: {
71
- pattern: { type: "string", description: "Regex pattern to search for" },
72
- path: { type: "string", description: "Directory or file to search in (default .)" },
73
- glob: { type: "string", description: "File filter glob (e.g. *.ts)" },
74
- },
75
- required: ["pattern"],
76
- },
77
- },
78
- async execute(args, signal): Promise<ToolResult> {
79
- try {
80
- const rawPath = (args.path as string) || "."
81
- const dir = resolve(cwd, rawPath)
82
- if (dir !== cwd && !dir.startsWith(`${cwd}/`)) {
83
- throw new Error(`Path outside project: ${rawPath}`)
84
- }
85
-
86
- const pattern = args.pattern as string
87
- const globFilter = args.glob as string | undefined
88
- const relSearchPath = relative(cwd, dir) || "."
89
-
90
- // rg is 10-100x faster than our JS fallback, but isn't always installed
91
- try {
92
- const cmd = ["rg", "--line-number", "--max-count", "200"]
93
- if (globFilter) cmd.push(`--glob=${globFilter}`)
94
- cmd.push("--", pattern, relSearchPath)
95
-
96
- const proc = spawn(cmd[0]!, cmd.slice(1), {
97
- cwd,
98
- stdio: ["ignore", "pipe", "pipe"],
99
- })
100
-
101
- const onAbort = () => {
102
- proc.kill()
103
- proc.stdout.destroy()
104
- proc.stderr.destroy()
105
- }
106
- signal?.addEventListener("abort", onAbort, { once: true })
107
-
108
- let stdout = ""
109
- proc.stdout.on("data", (chunk: Buffer) => {
110
- stdout += chunk.toString()
111
- })
112
-
113
- let exitCode: number
114
- try {
115
- exitCode = await new Promise<number>((resolve, reject) => {
116
- proc.on("error", reject)
117
- proc.on("close", (code) => resolve(code ?? -1))
118
- })
119
- } finally {
120
- signal?.removeEventListener("abort", onAbort)
121
- }
122
-
123
- if (exitCode === 0) {
124
- const lines = stdout.split("\n").slice(0, 200).join("\n")
125
- return { content: [textPart(lines || "No matches")], isError: false }
126
- }
127
- } catch {
128
- // rg not available, fall through
129
- }
130
-
131
- // Pure JS fallback when rg is not available
132
- const files = await glob(globFilter || "**/*", {
133
- cwd: dir,
134
- ignore: ["**/node_modules/**", "**/.git/**"],
135
- })
136
- const prefix = relSearchPath === "." ? "" : `${relSearchPath}/`
137
- const re = new RegExp(pattern, "i")
138
- const matches: string[] = []
139
- for (const file of files.slice(0, 500)) {
140
- if (signal?.aborted) break
141
- try {
142
- const content = await readFile(resolve(dir, file), "utf-8")
143
- const lines = content.split("\n")
144
- for (let i = 0; i < lines.length && matches.length < 200; i++) {
145
- const line = lines[i]
146
- if (line && re.test(line)) matches.push(`${prefix}${file}:${i + 1}:${line}`)
147
- }
148
- } catch {
149
- // Skip binary/unreadable files silently
150
- }
151
- }
152
- return {
153
- content: [textPart(matches.join("\n") || "No matches")],
154
- isError: false,
155
- }
156
- } catch (e) {
157
- return {
158
- content: [textPart(`Error: ${(e as Error).message}`)],
159
- isError: true,
160
- }
161
- }
162
- },
163
- }
164
- }
165
-
166
- /**
167
- * Tool for listing directory entries.
168
- */
169
- export function lsTool(cwd: string): Tool {
170
- return {
171
- def: {
172
- name: "ls",
173
- description: "List directory contents.",
174
- parameters: {
175
- type: "object",
176
- properties: {
177
- path: { type: "string", description: "Directory to list (default .)" },
178
- },
179
- required: [],
180
- },
181
- },
182
- async execute(args): Promise<ToolResult> {
183
- try {
184
- const dir = resolve(cwd, (args.path as string) || ".")
185
- const entries = await readdir(dir, { withFileTypes: true })
186
- const lines = entries.map((e) => {
187
- const suffix = e.isDirectory() ? "/" : e.isSymbolicLink() ? "@" : ""
188
- return `${e.name}${suffix}`
189
- })
190
- return { content: [textPart(lines.join("\n") || "(empty)")], isError: false }
191
- } catch (e) {
192
- return {
193
- content: [textPart(`Error: ${(e as Error).message}`)],
194
- isError: true,
195
- }
196
- }
197
- },
198
- }
199
- }
200
-
201
- /**
202
- * Tool for visualizing a truncated directory tree.
203
- */
204
- export function treeTool(cwd: string): Tool {
205
- return {
206
- def: {
207
- name: "tree",
208
- description:
209
- "Print a visual directory tree structure, ignoring common ignored folders like node_modules and .git.",
210
- parameters: {
211
- type: "object",
212
- properties: {
213
- path: { type: "string", description: "Directory to start tree from (default .)" },
214
- depth: { type: "number", description: "Maximum depth to traverse (default 3)" },
215
- },
216
- required: [],
217
- },
218
- },
219
- async execute(args): Promise<ToolResult> {
220
- try {
221
- const startDir = resolve(cwd, (args.path as string) || ".")
222
- const maxDepth = Number(args.depth ?? 3) || 3
223
-
224
- const ignoreList = new Set([
225
- ".git",
226
- "node_modules",
227
- "dist",
228
- "build",
229
- ".svelte-kit",
230
- ".next",
231
- "out",
232
- ".scannerwork",
233
- "coverage",
234
- ])
235
-
236
- async function walk(dir: string, currentDepth: number, prefix: string): Promise<string> {
237
- if (currentDepth > maxDepth) return ""
238
- let result = ""
239
-
240
- const entries = await readdir(dir, { withFileTypes: true })
241
- const sorted = entries
242
- .filter((e) => !ignoreList.has(e.name))
243
- .sort((a, b) => {
244
- if (a.isDirectory() && !b.isDirectory()) return -1
245
- if (!a.isDirectory() && b.isDirectory()) return 1
246
- return a.name.localeCompare(b.name)
247
- })
248
-
249
- for (let i = 0; i < sorted.length; i++) {
250
- const entry = sorted[i]!
251
- const isLast = i === sorted.length - 1
252
- const connector = isLast ? "└── " : "├── "
253
- const childPrefix = prefix + (isLast ? " " : "│ ")
254
-
255
- result += `${prefix}${connector}${entry.name}${entry.isDirectory() ? "/" : ""}\n`
256
-
257
- if (entry.isDirectory()) {
258
- result += await walk(resolve(dir, entry.name), currentDepth + 1, childPrefix)
259
- }
260
- }
261
- return result
262
- }
263
-
264
- const treeText = await walk(startDir, 1, "")
265
- return { content: [textPart(treeText || "(empty)")], isError: false }
266
- } catch (e) {
267
- return {
268
- content: [textPart(`Error: ${(e as Error).message}`)],
269
- isError: true,
270
- }
271
- }
272
- },
273
- }
274
- }
@@ -1,90 +0,0 @@
1
- /**
2
- * Tool for executing shell commands within the project root.
3
- * Supports timeouts and output truncation to protect the context window.
4
- */
5
- import { spawn } from "node:child_process"
6
- import type { Tool, ToolResult } from "../types.ts"
7
-
8
- import { textPart } from "../util.ts"
9
-
10
- export function bashTool(cwd: string): Tool {
11
- return {
12
- def: {
13
- name: "bash",
14
- description:
15
- "Execute a shell command. Returns stdout and stderr. Timeout after N seconds (default 120).",
16
- parameters: {
17
- type: "object",
18
- properties: {
19
- command: { type: "string", description: "Shell command to run" },
20
- timeout: { type: "number", description: "Timeout in seconds (default 120)" },
21
- },
22
- required: ["command"],
23
- },
24
- },
25
- async execute(args, signal): Promise<ToolResult> {
26
- const command = args.command as string
27
- const timeoutMs = (Number(args.timeout) || 120) * 1000
28
-
29
- try {
30
- const proc = spawn("sh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] })
31
-
32
- let stdout = ""
33
- let stderr = ""
34
- proc.stdout.on("data", (chunk: Buffer) => {
35
- stdout += chunk.toString()
36
- })
37
- proc.stderr.on("data", (chunk: Buffer) => {
38
- stderr += chunk.toString()
39
- })
40
-
41
- let killed = false
42
- const timer = setTimeout(() => {
43
- killed = true
44
- proc.kill("SIGKILL")
45
- proc.stdout.destroy()
46
- proc.stderr.destroy()
47
- }, timeoutMs)
48
-
49
- const onAbort = () => {
50
- killed = true
51
- proc.kill("SIGKILL")
52
- proc.stdout.destroy()
53
- proc.stderr.destroy()
54
- }
55
- signal?.addEventListener("abort", onAbort, { once: true })
56
-
57
- let exitCode: number
58
- try {
59
- exitCode = await new Promise<number>((resolve, reject) => {
60
- proc.on("error", reject)
61
- proc.on("close", (code) => resolve(code ?? -1))
62
- })
63
- } finally {
64
- clearTimeout(timer)
65
- signal?.removeEventListener("abort", onAbort)
66
- }
67
-
68
- // Prevent context-window blowout from noisy commands
69
- const MAX = 50_000
70
- let out = ""
71
- if (stdout) out += stdout.slice(0, MAX)
72
- if (stderr) {
73
- if (out) out += "\n"
74
- out += stderr.slice(0, MAX - out.length)
75
- }
76
- if (out.length >= MAX) out += `\n…truncated`
77
-
78
- if (killed) out += `\n[timeout after ${timeoutMs / 1000}s]`
79
- out += `\n[exit ${exitCode}]`
80
-
81
- return { content: [textPart(out)], isError: exitCode !== 0 || killed }
82
- } catch (e) {
83
- return {
84
- content: [textPart(`Error: ${(e as Error).message}`)],
85
- isError: true,
86
- }
87
- }
88
- },
89
- }
90
- }
package/src/tools/web.ts DELETED
@@ -1,239 +0,0 @@
1
- /**
2
- * Web tools for searching and fetching internet content.
3
- * Uses DuckDuckGo HTML for search (no API key needed) and Node's built-in
4
- * fetch for reading URLs.
5
- */
6
- import type { Tool, ToolResult } from "../types.ts"
7
- import { textPart } from "../util.ts"
8
-
9
- const MAX_CONTENT = 50_000
10
-
11
- // Minimal HTML → plaintext: strips tags, decodes entities, collapses whitespace
12
- function htmlToText(html: string): string {
13
- let text = html
14
- // Remove HTML comments
15
- .replace(/<!--[\s\S]*?-->/g, "")
16
- // Remove script and style blocks entirely
17
- .replace(/<script[\s\S]*?<\/script>/gi, "")
18
- .replace(/<style[\s\S]*?<\/style>/gi, "")
19
- // Keep link hrefs visible (supports single, double, or no quotes)
20
- .replace(/<a[^>]*href=["']?([^"'>\s]*)["']?[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)")
21
- // Block-level tags → newlines
22
- .replace(/<\/?(p|div|br|h[1-6]|li|tr|blockquote|pre|hr)[^>]*>/gi, "\n")
23
- // Remove remaining tags
24
- .replace(/<[^>]+>/g, "")
25
- // Decode common HTML entities (both named and numeric, single/double quotes)
26
- .replace(/&amp;/g, "&")
27
- .replace(/&lt;/g, "<")
28
- .replace(/&gt;/g, ">")
29
- .replace(/&quot;/g, '"')
30
- .replace(/&#34;/g, '"')
31
- .replace(/&#x22;/g, '"')
32
- .replace(/&#39;/g, "'")
33
- .replace(/&#x27;/g, "'")
34
- .replace(/&apos;/g, "'")
35
- .replace(/&ldquo;/g, '"')
36
- .replace(/&rdquo;/g, '"')
37
- .replace(/&lsquo;/g, "'")
38
- .replace(/&rsquo;/g, "'")
39
- .replace(/&nbsp;/g, " ")
40
- // Collapse whitespace but keep paragraph breaks
41
- .replace(/[ \t]+/g, " ")
42
- .replace(/\n{3,}/g, "\n\n")
43
- .trim()
44
-
45
- if (text.length > MAX_CONTENT) {
46
- text = `${text.slice(0, MAX_CONTENT)}\n…truncated`
47
- }
48
- return text
49
- }
50
-
51
- export function webSearchTool(): Tool {
52
- return {
53
- def: {
54
- name: "web_search",
55
- description:
56
- "Search the web using DuckDuckGo. Returns up to 10 results with titles, URLs, and snippets. Use this when you need information from the internet.",
57
- parameters: {
58
- type: "object",
59
- properties: {
60
- query: { type: "string", description: "Search query" },
61
- },
62
- required: ["query"],
63
- },
64
- },
65
- async execute(args, signal): Promise<ToolResult> {
66
- const query = args.query as string
67
- if (!query.trim()) {
68
- return { content: [textPart("Error: empty search query")], isError: true }
69
- }
70
-
71
- try {
72
- const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`
73
- const resp = await fetch(url, {
74
- signal: signal ?? undefined,
75
- headers: {
76
- "User-Agent":
77
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
78
- },
79
- })
80
-
81
- if (!resp.ok) {
82
- return {
83
- content: [textPart(`Search failed: HTTP ${resp.status}`)],
84
- isError: true,
85
- }
86
- }
87
-
88
- const html = await resp.text()
89
-
90
- // Split HTML into blocks by result__body to isolate each search result safely
91
- const blocks: string[] = []
92
- const containerRegex = /<div[^>]*class="[^"]*result__body[^"]*"[^>]*>/gi
93
- const indices: number[] = []
94
- for (const match of html.matchAll(containerRegex)) {
95
- if (match.index !== undefined) {
96
- indices.push(match.index)
97
- }
98
- }
99
-
100
- if (indices.length > 0) {
101
- for (let i = 0; i < indices.length; i++) {
102
- const start = indices[i]!
103
- const end = indices[i + 1] ?? html.length
104
- blocks.push(html.slice(start, end))
105
- }
106
- }
107
-
108
- const results: string[] = []
109
- const titleRegex = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/i
110
- const snippetRegex = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/i
111
-
112
- for (const block of blocks) {
113
- if (results.length >= 10) break
114
-
115
- const titleMatch = titleRegex.exec(block)
116
- if (!titleMatch) continue
117
-
118
- const rawUrl = titleMatch[1]!
119
- const title = htmlToText(titleMatch[2]!)
120
-
121
- const snippetMatch = snippetRegex.exec(block)
122
- const snippet = snippetMatch ? htmlToText(snippetMatch[1]!) : ""
123
-
124
- // DuckDuckGo wraps URLs through a redirect; extract the actual URL
125
- let cleanUrl = rawUrl
126
- try {
127
- // Prepend protocol/host if DuckDuckGo returns a relative path or protocol-relative URL
128
- const urlToParse = rawUrl.startsWith("//")
129
- ? `https:${rawUrl}`
130
- : rawUrl.startsWith("/")
131
- ? `https://duckduckgo.com${rawUrl}`
132
- : rawUrl
133
- const param = new URL(urlToParse).searchParams.get("uddg")
134
- if (param) cleanUrl = param
135
- } catch {
136
- // Not a redirect URL, use as-is
137
- }
138
-
139
- results.push(`## ${title}\n${cleanUrl}\n${snippet}`)
140
- }
141
-
142
- if (results.length === 0) {
143
- return { content: [textPart("No results found.")], isError: false }
144
- }
145
-
146
- return {
147
- content: [textPart(results.join("\n\n"))],
148
- isError: false,
149
- }
150
- } catch (e) {
151
- const msg = (e as Error).message
152
- if (msg.includes("abort")) {
153
- return { content: [textPart("Search aborted.")], isError: true }
154
- }
155
- return {
156
- content: [textPart(`Search error: ${msg}`)],
157
- isError: true,
158
- }
159
- }
160
- },
161
- }
162
- }
163
-
164
- export function webFetchTool(): Tool {
165
- return {
166
- def: {
167
- name: "web_fetch",
168
- description:
169
- "Fetch and read the content of a web page. Returns the page text with HTML tags stripped. Useful for reading documentation, articles, or API references.",
170
- parameters: {
171
- type: "object",
172
- properties: {
173
- url: { type: "string", description: "URL to fetch" },
174
- },
175
- required: ["url"],
176
- },
177
- },
178
- async execute(args, signal): Promise<ToolResult> {
179
- const url = args.url as string
180
- if (!url.trim()) {
181
- return { content: [textPart("Error: empty URL")], isError: true }
182
- }
183
-
184
- try {
185
- new URL(url)
186
- } catch {
187
- return { content: [textPart(`Error: invalid URL: ${url}`)], isError: true }
188
- }
189
-
190
- try {
191
- const resp = await fetch(url, {
192
- signal: signal ?? undefined,
193
- headers: {
194
- "User-Agent":
195
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
196
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
197
- },
198
- redirect: "follow",
199
- })
200
-
201
- if (!resp.ok) {
202
- return {
203
- content: [textPart(`Fetch failed: HTTP ${resp.status} ${resp.statusText}`)],
204
- isError: true,
205
- }
206
- }
207
-
208
- const contentType = (resp.headers.get("content-type") ?? "").toLowerCase()
209
- const body = await resp.text()
210
-
211
- // Sniff HTML: check content-type or if body has HTML markup signature
212
- const isHtml =
213
- contentType.includes("text/html") ||
214
- contentType.includes("application/xhtml+xml") ||
215
- body.trim().toLowerCase().startsWith("<!doctype html") ||
216
- body.trim().toLowerCase().startsWith("<html")
217
-
218
- if (isHtml) {
219
- const text = htmlToText(body)
220
- return { content: [textPart(text)], isError: false }
221
- }
222
-
223
- // For plain text, JSON, etc. return as-is (truncated if needed)
224
- const truncated =
225
- body.length > MAX_CONTENT ? `${body.slice(0, MAX_CONTENT)}\n…truncated` : body
226
- return { content: [textPart(truncated)], isError: false }
227
- } catch (e) {
228
- const msg = (e as Error).message
229
- if (msg.includes("abort")) {
230
- return { content: [textPart("Fetch aborted.")], isError: true }
231
- }
232
- return {
233
- content: [textPart(`Fetch error: ${msg}`)],
234
- isError: true,
235
- }
236
- }
237
- },
238
- }
239
- }