novacode 0.5.5 → 0.7.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/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 Bun'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
- }
package/src/tui/app.tsx DELETED
@@ -1,364 +0,0 @@
1
- import chalk from "chalk"
2
- import { Box, render, Static, Text, useInput } from "ink"
3
- import { useCallback, useEffect, useRef, useState } from "react"
4
- import type { Agent } from "../agent/agent.ts"
5
- import { COMMANDS, dispatch } from "../commands/index.ts"
6
- import type { SessionStore } from "../session/store.ts"
7
- import type { Msg, Prompts } from "../types.ts"
8
- import { checkForUpdate, getCurrentVersion } from "../update.ts"
9
- import { Cursor, LiveArea } from "./components/liveArea.tsx"
10
- import { hasMeaningfulContent, Message } from "./components/message.tsx"
11
- import { StatusBar } from "./components/statusBar.tsx"
12
- import { ConfirmPrompt, PasswordPrompt, SelectPrompt } from "./prompts.tsx"
13
-
14
- type PromptMode =
15
- | { type: "chat" }
16
- | {
17
- type: "select"
18
- message: string
19
- options: Array<{ value: string; label: string; hint?: string }>
20
- header?: string
21
- }
22
- | {
23
- type: "password"
24
- message: string
25
- validate?: (v: string) => string | undefined
26
- }
27
- | { type: "confirm"; message: string }
28
-
29
- export async function interactive(
30
- agent: Agent,
31
- store: SessionStore,
32
- sessionId: string,
33
- ): Promise<void> {
34
- process.stdout.write("\x1B[?25l")
35
- const version = await getCurrentVersion()
36
- process.stdout.write(`${chalk.cyan.bold("⚡ novacode")} ${chalk.gray(`v${version}`)}\n`)
37
-
38
- try {
39
- const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />)
40
- await waitUntilExit()
41
- } finally {
42
- process.stdout.write("\x1B[?25h")
43
- }
44
- }
45
-
46
- function App({
47
- agent,
48
- store,
49
- sessionId,
50
- }: {
51
- agent: Agent
52
- store: SessionStore
53
- sessionId: string
54
- }) {
55
- const [msgs, setMsgs] = useState<Msg[]>(agent.messages)
56
- const [stream, setStream] = useState("")
57
- const [thinkStream, setThinkStream] = useState("")
58
- const [busy, setBusy] = useState(false)
59
- const [input, setInput] = useState("")
60
- const [status, setStatus] = useState("")
61
- const [usage, setUsage] = useState<{ in: number; out: number }>({ in: 0, out: 0 })
62
- const [selCmdIdx, setSelCmdIdx] = useState(0)
63
- const [mode, setMode] = useState<PromptMode>({ type: "chat" })
64
- const resolveRef = useRef<((v: unknown) => void) | null>(null)
65
- const history = useRef<string[]>([])
66
- const hIdx = useRef(-1)
67
- const abortCtrl = useRef<AbortController | null>(null)
68
- const [updateInfo, setUpdateInfo] = useState<{
69
- current: string
70
- latest: string
71
- } | null>(null)
72
-
73
- useEffect(() => {
74
- const check = async () => {
75
- const info = await checkForUpdate()
76
- if (info?.hasUpdate) {
77
- setUpdateInfo({ current: info.current, latest: info.latest })
78
- }
79
- }
80
- check()
81
- }, [])
82
-
83
- const isTypingCmd = input.startsWith("/") && !input.includes(" ")
84
- const suggestions = isTypingCmd
85
- ? COMMANDS.filter(
86
- (c) =>
87
- c.name.startsWith(input.slice(1).toLowerCase()) ||
88
- c.aliases?.some((a) => a.startsWith(input.slice(1).toLowerCase())),
89
- )
90
- : []
91
-
92
- const prompts: Prompts = {
93
- select: useCallback(
94
- (config) =>
95
- new Promise((resolve) => {
96
- resolveRef.current = resolve as (v: unknown) => void
97
- setMode({ type: "select", ...config })
98
- }),
99
- [],
100
- ),
101
- password: useCallback(
102
- (config) =>
103
- new Promise((resolve) => {
104
- resolveRef.current = resolve as (v: unknown) => void
105
- setMode({ type: "password", ...config })
106
- }),
107
- [],
108
- ),
109
- confirm: useCallback(
110
- (config) =>
111
- new Promise((resolve) => {
112
- resolveRef.current = resolve as (v: unknown) => void
113
- setMode({ type: "confirm", ...config })
114
- }),
115
- [],
116
- ),
117
- }
118
-
119
- function resolvePrompt(value: unknown) {
120
- const fn = resolveRef.current
121
- resolveRef.current = null
122
- setMode({ type: "chat" })
123
- fn?.(value)
124
- }
125
-
126
- function commitMsg(msg: Msg) {
127
- setMsgs((prev) => [...prev, msg])
128
- agent.setMessages([...agent.messages, msg])
129
- store.append(sessionId, msg)
130
- }
131
-
132
- // biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
133
- useEffect(() => {
134
- setSelCmdIdx(0)
135
- }, [input])
136
-
137
- useInput((ch, key) => {
138
- if (mode.type !== "chat") return
139
-
140
- if (key.escape) {
141
- if (abortCtrl.current) {
142
- abortCtrl.current.abort()
143
- abortCtrl.current = null
144
- }
145
- return
146
- }
147
- if (key.upArrow) {
148
- if (isTypingCmd && suggestions.length > 0) {
149
- setSelCmdIdx((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1))
150
- return
151
- }
152
- if (history.current.length > 0) {
153
- hIdx.current = Math.min(hIdx.current + 1, history.current.length - 1)
154
- setInput(history.current[hIdx.current] ?? "")
155
- }
156
- return
157
- }
158
- if (key.downArrow) {
159
- if (isTypingCmd && suggestions.length > 0) {
160
- setSelCmdIdx((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0))
161
- return
162
- }
163
- hIdx.current = Math.max(hIdx.current - 1, -1)
164
- setInput(hIdx.current >= 0 ? (history.current[hIdx.current] ?? "") : "")
165
- return
166
- }
167
- if (key.tab) {
168
- if (isTypingCmd && suggestions.length > 0) {
169
- const match = suggestions[selCmdIdx]
170
- if (match) {
171
- setInput(`/${match.name} `)
172
- }
173
- }
174
- return
175
- }
176
- if (!key.return) {
177
- setInput((prev) => {
178
- if (key.backspace || key.delete) return prev.slice(0, -1)
179
- return prev + (ch || "")
180
- })
181
- return
182
- }
183
-
184
- if (busy) return
185
-
186
- let line = input.trim()
187
- if (!line) return
188
-
189
- if (isTypingCmd && suggestions.length > 0) {
190
- const match = suggestions[selCmdIdx]
191
- if (match) {
192
- line = `/${match.name}`
193
- }
194
- }
195
-
196
- setInput("")
197
- history.current.unshift(line)
198
- hIdx.current = -1
199
-
200
- if (line.startsWith("/")) {
201
- dispatch(line, agent, store, sessionId, prompts).then((r) => {
202
- if (r) {
203
- commitMsg({
204
- role: "assistant",
205
- content: [{ type: "text", text: r }],
206
- model: "system",
207
- provider: "system",
208
- usage: { in: 0, out: 0 },
209
- stop: "stop",
210
- ts: Date.now(),
211
- })
212
- }
213
- })
214
- return
215
- }
216
-
217
- const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
218
- commitMsg(userMsg)
219
-
220
- abortCtrl.current = new AbortController()
221
- const eventStream = agent.prompt(line, abortCtrl.current.signal)
222
-
223
- runEventLoop(eventStream)
224
- })
225
-
226
- async function runEventLoop(eventStream: ReturnType<Agent["prompt"]>) {
227
- try {
228
- for await (const ev of eventStream) {
229
- switch (ev.type) {
230
- case "start":
231
- setBusy(true)
232
- setStream("")
233
- setThinkStream("")
234
- setStatus("")
235
- break
236
- case "text_delta":
237
- if (ev.text) setStream((prev) => prev + ev.text)
238
- break
239
- case "thinking_delta":
240
- if (ev.text) setThinkStream((prev) => prev + ev.text)
241
- break
242
- case "assistant_msg":
243
- commitMsg(ev.msg)
244
- setStream("")
245
- setThinkStream("")
246
-
247
- break
248
- case "tool_call":
249
- setStatus(chalk.dim(`⏳ ${ev.call.name}…`))
250
- break
251
- case "tool_result":
252
- commitMsg(ev.result)
253
- setStatus(
254
- ev.result.isError
255
- ? chalk.red(`✗ ${ev.result.tool}`)
256
- : chalk.green(`✓ ${ev.result.tool}`),
257
- )
258
- break
259
- case "turn_end":
260
- setStatus("")
261
- break
262
- case "usage":
263
- if (ev.usage) setUsage(ev.usage)
264
- }
265
- }
266
- } catch (err) {
267
- const errMsg: Msg = {
268
- role: "assistant",
269
- model: "system",
270
- provider: "system",
271
- content: [{ type: "text", text: chalk.red(`Error: ${(err as Error).message}`) }],
272
- usage: { in: 0, out: 0 },
273
- stop: "error",
274
- ts: Date.now(),
275
- }
276
- commitMsg(errMsg)
277
- } finally {
278
- abortCtrl.current = null
279
- setBusy(false)
280
- setStream("")
281
- setThinkStream("")
282
- setStatus("")
283
- }
284
- }
285
-
286
- if (mode.type === "select") {
287
- return (
288
- <SelectPrompt
289
- message={mode.message}
290
- options={mode.options}
291
- header={mode.header}
292
- onSelect={resolvePrompt}
293
- />
294
- )
295
- }
296
- if (mode.type === "password") {
297
- return (
298
- <PasswordPrompt message={mode.message} validate={mode.validate} onSubmit={resolvePrompt} />
299
- )
300
- }
301
- if (mode.type === "confirm") {
302
- return <ConfirmPrompt message={mode.message} onConfirm={resolvePrompt} />
303
- }
304
-
305
- const visibleMsgs = msgs.filter(hasMeaningfulContent)
306
- const isLiveActive = !!(stream || thinkStream || busy)
307
-
308
- return (
309
- <Box flexDirection="column" paddingX={1}>
310
- <Static items={visibleMsgs}>
311
- {(m, i) => <Message key={`${m.ts}-${i}`} msg={m} isFirst={i === 0} />}
312
- </Static>
313
-
314
- <LiveArea
315
- stream={stream}
316
- thinkStream={thinkStream}
317
- busy={busy}
318
- status={status}
319
- hasMessages={visibleMsgs.length > 0}
320
- />
321
-
322
- <Box flexDirection="column" marginTop={visibleMsgs.length > 0 || isLiveActive ? 1 : 0}>
323
- {updateInfo && (
324
- <Box
325
- borderStyle="round"
326
- borderColor="yellow"
327
- paddingX={1}
328
- marginBottom={1}
329
- flexDirection="column"
330
- >
331
- <Text color="yellow" bold>
332
- ⬆ Update Available (v{updateInfo.current} → v{updateInfo.latest})
333
- </Text>
334
- <Text dimColor>
335
- Run <Text color="cyan">/update</Text> or <Text color="cyan">nova update</Text> to
336
- upgrade.
337
- </Text>
338
- </Box>
339
- )}
340
- <Box flexDirection="row">
341
- <Box flexShrink={0} marginRight={1}>
342
- <Text bold color="green">
343
- {">"}
344
- </Text>
345
- </Box>
346
- <Box flexGrow={1} flexShrink={1}>
347
- <Text>
348
- {input}
349
- <Cursor />
350
- </Text>
351
- </Box>
352
- </Box>
353
-
354
- <StatusBar
355
- model={agent.model}
356
- usage={usage}
357
- busy={busy}
358
- suggestions={suggestions}
359
- selCmdIdx={selCmdIdx}
360
- />
361
- </Box>
362
- </Box>
363
- )
364
- }
@@ -1,73 +0,0 @@
1
- import chalk from "chalk"
2
- import { Box, Text } from "ink"
3
- import { useEffect, useState } from "react"
4
- import { formatMarkdown } from "../markdown.ts"
5
-
6
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
7
-
8
- export function Spinner() {
9
- const [frame, setFrame] = useState(0)
10
-
11
- useEffect(() => {
12
- const timer = setInterval(() => {
13
- setFrame((f) => (f + 1) % SPINNER_FRAMES.length)
14
- }, 80)
15
- return () => clearInterval(timer)
16
- }, [])
17
-
18
- return <Text color="yellow">{SPINNER_FRAMES[frame]}</Text>
19
- }
20
-
21
- export function Cursor() {
22
- const [visible, setVisible] = useState(true)
23
- useEffect(() => {
24
- const timer = setInterval(() => setVisible((v) => !v), 530)
25
- return () => clearInterval(timer)
26
- }, [])
27
- return <Text color="green">{visible ? "│" : " "}</Text>
28
- }
29
-
30
- export function LiveArea({
31
- stream,
32
- thinkStream,
33
- busy,
34
- status,
35
- hasMessages,
36
- }: {
37
- stream: string
38
- thinkStream: string
39
- busy: boolean
40
- status: string
41
- hasMessages: boolean
42
- }) {
43
- const isActive = !!(stream || thinkStream || busy)
44
- if (!isActive) return null
45
-
46
- return (
47
- <Box flexDirection="column" marginTop={hasMessages ? 1 : 0}>
48
- {thinkStream && (
49
- <Text dimColor italic>
50
- {thinkStream}
51
- </Text>
52
- )}
53
- {stream && (
54
- <Box flexDirection="row">
55
- <Box flexGrow={1} flexShrink={1}>
56
- <Text>
57
- {formatMarkdown(stream)}
58
- <Cursor />
59
- </Text>
60
- </Box>
61
- </Box>
62
- )}
63
- {busy && !stream && (
64
- <Box flexDirection="row">
65
- <Box marginRight={1}>
66
- <Spinner />
67
- </Box>
68
- <Text dimColor>{status ? status.replace("⏳ ", "") : chalk.yellow("working…")}</Text>
69
- </Box>
70
- )}
71
- </Box>
72
- )
73
- }