novacode 0.5.1 → 0.5.3
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 +28 -11
- package/dist/app-BZ42XPxw.mjs +21 -0
- package/dist/app-BZ42XPxw.mjs.map +1 -0
- package/dist/main.mjs +110 -0
- package/dist/main.mjs.map +1 -0
- package/package.json +29 -20
- package/src/agent/loop.ts +27 -8
- package/src/commands/index.ts +4 -3
- package/src/commands/models.ts +8 -9
- package/src/commands/providers.ts +63 -72
- package/src/config/providers.ts +12 -4
- package/src/config/store.ts +6 -7
- package/src/main.ts +11 -11
- package/src/onboarding/wizard.ts +26 -30
- package/src/provider/gemini.ts +12 -5
- package/src/provider/openai.ts +6 -9
- package/src/provider/stream.ts +63 -0
- package/src/session/compact.ts +1 -1
- package/src/session/store.ts +35 -35
- package/src/tools/fs.ts +10 -16
- package/src/tools/git.ts +26 -9
- package/src/tools/search.ts +33 -11
- package/src/tools/shell.ts +26 -25
- package/src/tui/app.tsx +135 -124
- package/src/tui/prompts.tsx +205 -0
- package/src/types.ts +15 -0
- package/src/update.ts +48 -24
- package/src/util.ts +1 -28
- package/src/provider/registry.ts +0 -62
package/src/session/store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite"
|
|
2
1
|
import { join } from "node:path"
|
|
2
|
+
import BetterSqlite3 from "better-sqlite3"
|
|
3
3
|
import type { Msg, Session } from "../types.ts"
|
|
4
4
|
|
|
5
5
|
const SCHEMA = `
|
|
@@ -40,12 +40,12 @@ function generateId(): string {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export class SessionStore {
|
|
43
|
-
#db: Database
|
|
43
|
+
#db: BetterSqlite3.Database
|
|
44
44
|
|
|
45
45
|
constructor(dbPath: string) {
|
|
46
|
-
this.#db = new
|
|
47
|
-
this.#db.
|
|
48
|
-
this.#db.
|
|
46
|
+
this.#db = new BetterSqlite3(dbPath)
|
|
47
|
+
this.#db.pragma("journal_mode = WAL")
|
|
48
|
+
this.#db.pragma("foreign_keys = ON")
|
|
49
49
|
this.#db.exec(SCHEMA)
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -57,13 +57,13 @@ export class SessionStore {
|
|
|
57
57
|
"INSERT INTO sessions (id, cwd, model, provider, title, created, updated) VALUES ($id, $cwd, $model, $provider, $title, $created, $updated)",
|
|
58
58
|
)
|
|
59
59
|
.run({
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
id: id,
|
|
61
|
+
cwd: cwd,
|
|
62
|
+
model: model,
|
|
63
|
+
provider: provider,
|
|
64
|
+
title: null,
|
|
65
|
+
created: now,
|
|
66
|
+
updated: now,
|
|
67
67
|
})
|
|
68
68
|
return { id, cwd, model, provider, title: null, created: now, updated: now }
|
|
69
69
|
}
|
|
@@ -74,7 +74,7 @@ export class SessionStore {
|
|
|
74
74
|
.prepare(
|
|
75
75
|
"SELECT id, cwd, model, provider, title, created, updated FROM sessions WHERE id = $id",
|
|
76
76
|
)
|
|
77
|
-
.get({
|
|
77
|
+
.get({ id: id }) as Session | null) ?? null
|
|
78
78
|
)
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -83,11 +83,11 @@ export class SessionStore {
|
|
|
83
83
|
.prepare(
|
|
84
84
|
"SELECT id, cwd, model, provider, title, created, updated FROM sessions ORDER BY updated DESC LIMIT $limit",
|
|
85
85
|
)
|
|
86
|
-
.all({
|
|
86
|
+
.all({ limit: limit }) as Session[]
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
delete(id: string): boolean {
|
|
90
|
-
const result = this.#db.prepare("DELETE FROM sessions WHERE id = $id").run({
|
|
90
|
+
const result = this.#db.prepare("DELETE FROM sessions WHERE id = $id").run({ id: id })
|
|
91
91
|
return result.changes > 0
|
|
92
92
|
}
|
|
93
93
|
|
|
@@ -98,15 +98,15 @@ export class SessionStore {
|
|
|
98
98
|
"INSERT INTO messages (session_id, seq, role, content, ts) VALUES ($sid, $seq, $role, $content, $ts)",
|
|
99
99
|
)
|
|
100
100
|
.run({
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
sid: sessionId,
|
|
102
|
+
seq: seq,
|
|
103
|
+
role: msg.role,
|
|
104
|
+
content: JSON.stringify(msg),
|
|
105
|
+
ts: msg.ts,
|
|
106
106
|
})
|
|
107
107
|
this.#db
|
|
108
108
|
.prepare("UPDATE sessions SET updated = $now WHERE id = $id")
|
|
109
|
-
.run({
|
|
109
|
+
.run({ now: Date.now(), id: sessionId })
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
appendMany(sessionId: string, msgs: Msg[]): void {
|
|
@@ -121,7 +121,7 @@ export class SessionStore {
|
|
|
121
121
|
messages(sessionId: string): Msg[] {
|
|
122
122
|
const rows = this.#db
|
|
123
123
|
.prepare("SELECT content FROM messages WHERE session_id = $sid ORDER BY seq ASC")
|
|
124
|
-
.all({
|
|
124
|
+
.all({ sid: sessionId }) as { content: string }[]
|
|
125
125
|
return rows.map((r) => JSON.parse(r.content) as Msg)
|
|
126
126
|
}
|
|
127
127
|
|
|
@@ -130,20 +130,20 @@ export class SessionStore {
|
|
|
130
130
|
.prepare(
|
|
131
131
|
"SELECT content FROM messages WHERE session_id = $sid AND seq > $seq ORDER BY seq ASC",
|
|
132
132
|
)
|
|
133
|
-
.all({
|
|
133
|
+
.all({ sid: sessionId, seq: afterSeq }) as { content: string }[]
|
|
134
134
|
return rows.map((r) => JSON.parse(r.content) as Msg)
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
setTitle(sessionId: string, title: string): void {
|
|
138
138
|
this.#db
|
|
139
139
|
.prepare("UPDATE sessions SET title = $title WHERE id = $id")
|
|
140
|
-
.run({
|
|
140
|
+
.run({ title: title, id: sessionId })
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
messageCount(sessionId: string): number {
|
|
144
144
|
const row = this.#db
|
|
145
145
|
.prepare("SELECT COUNT(*) as count FROM messages WHERE session_id = $sid")
|
|
146
|
-
.get({
|
|
146
|
+
.get({ sid: sessionId }) as { count: number }
|
|
147
147
|
return row.count
|
|
148
148
|
}
|
|
149
149
|
|
|
@@ -159,12 +159,12 @@ export class SessionStore {
|
|
|
159
159
|
"INSERT INTO compactions (session_id, summary, files_read, files_wrote, seq_before, ts) VALUES ($sid, $summary, $read, $wrote, $seq, $ts)",
|
|
160
160
|
)
|
|
161
161
|
.run({
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
sid: sessionId,
|
|
163
|
+
summary: summary,
|
|
164
|
+
read: JSON.stringify(filesRead),
|
|
165
|
+
wrote: JSON.stringify(filesWrote),
|
|
166
|
+
seq: seqBefore,
|
|
167
|
+
ts: Date.now(),
|
|
168
168
|
})
|
|
169
169
|
}
|
|
170
170
|
|
|
@@ -174,24 +174,24 @@ export class SessionStore {
|
|
|
174
174
|
.prepare(
|
|
175
175
|
"SELECT summary, seq_before FROM compactions WHERE session_id = $sid ORDER BY ts DESC LIMIT 1",
|
|
176
176
|
)
|
|
177
|
-
.get({
|
|
177
|
+
.get({ sid: sessionId }) as { summary: string; seqBefore: number } | null) ?? null
|
|
178
178
|
)
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
truncateBeforeSeq(sessionId: string, seq: number): void {
|
|
182
182
|
this.#db
|
|
183
183
|
.prepare("DELETE FROM messages WHERE session_id = $sid AND seq < $seq")
|
|
184
|
-
.run({
|
|
184
|
+
.run({ sid: sessionId, seq: seq })
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
close(): void {
|
|
188
|
-
this.#db.close(
|
|
188
|
+
this.#db.close()
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
#nextSeq(sessionId: string): number {
|
|
192
192
|
const row = this.#db
|
|
193
193
|
.prepare("SELECT MAX(seq) as maxSeq FROM messages WHERE session_id = $sid")
|
|
194
|
-
.get({
|
|
194
|
+
.get({ sid: sessionId }) as { maxSeq: number | null }
|
|
195
195
|
return (row.maxSeq ?? 0) + 1
|
|
196
196
|
}
|
|
197
197
|
}
|
package/src/tools/fs.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Filesystem tools for reading, writing, and editing files.
|
|
3
3
|
* Includes safety checks to prevent path traversal.
|
|
4
4
|
*/
|
|
5
|
-
import { mkdir } from "node:fs/promises"
|
|
5
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
6
6
|
import { dirname, extname, resolve } from "node:path"
|
|
7
7
|
import type { Tool, ToolResult } from "../types.ts"
|
|
8
8
|
import { getRelativeIfInside, textPart } from "../util.ts"
|
|
@@ -37,22 +37,16 @@ export function readTool(cwd: string): Tool {
|
|
|
37
37
|
async execute(args): Promise<ToolResult> {
|
|
38
38
|
try {
|
|
39
39
|
const filePath = safePath(cwd, args.path as string)
|
|
40
|
-
const relPath = getRelativeIfInside(cwd, filePath)
|
|
41
|
-
const file = Bun.file(filePath)
|
|
42
|
-
if (!(await file.exists())) {
|
|
43
|
-
return { content: [textPart(`File not found: ${relPath}`)], isError: true }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
40
|
// Return images as base64 so the LLM can process them visually
|
|
47
41
|
const ext = extname(filePath).toLowerCase()
|
|
48
42
|
if (IMAGES.has(ext)) {
|
|
49
|
-
const buf = await
|
|
50
|
-
const b64 =
|
|
43
|
+
const buf = await readFile(filePath)
|
|
44
|
+
const b64 = buf.toString("base64")
|
|
51
45
|
const mime = ext === ".jpg" ? "image/jpeg" : `image/${ext.slice(1)}`
|
|
52
46
|
return { content: [{ type: "image", data: b64, mime }], isError: false }
|
|
53
47
|
}
|
|
54
48
|
|
|
55
|
-
const content = await
|
|
49
|
+
const content = await readFile(filePath, "utf-8")
|
|
56
50
|
const lines = content.split("\n")
|
|
57
51
|
const offset = Math.max(0, (Number(args.offset ?? 1) || 1) - 1)
|
|
58
52
|
const limit = Number(args.limit ?? 2000) || 2000
|
|
@@ -92,7 +86,7 @@ export function writeTool(cwd: string): Tool {
|
|
|
92
86
|
const filePath = safePath(cwd, args.path as string)
|
|
93
87
|
const content = args.content as string
|
|
94
88
|
await mkdir(dirname(filePath), { recursive: true })
|
|
95
|
-
await
|
|
89
|
+
await writeFile(filePath, content)
|
|
96
90
|
const relPath = getRelativeIfInside(cwd, filePath)
|
|
97
91
|
return {
|
|
98
92
|
content: [textPart(`Wrote ${content.length} bytes → ${relPath}`)],
|
|
@@ -139,12 +133,12 @@ export function editTool(cwd: string): Tool {
|
|
|
139
133
|
async execute(args): Promise<ToolResult> {
|
|
140
134
|
try {
|
|
141
135
|
const filePath = safePath(cwd, args.path as string)
|
|
142
|
-
|
|
143
|
-
|
|
136
|
+
let content: string
|
|
137
|
+
try {
|
|
138
|
+
content = await readFile(filePath, "utf-8")
|
|
139
|
+
} catch {
|
|
144
140
|
return { content: [textPart(`File not found: ${args.path}`)], isError: true }
|
|
145
141
|
}
|
|
146
|
-
|
|
147
|
-
let content = await file.text()
|
|
148
142
|
const edits = args.edits as Array<{ oldText: string; newText: string }>
|
|
149
143
|
|
|
150
144
|
// Validate all edits before applying any — avoids partial writes on bad input
|
|
@@ -174,7 +168,7 @@ export function editTool(cwd: string): Tool {
|
|
|
174
168
|
content = content.replace(edit.oldText, edit.newText)
|
|
175
169
|
}
|
|
176
170
|
|
|
177
|
-
await
|
|
171
|
+
await writeFile(filePath, content)
|
|
178
172
|
const relPath = getRelativeIfInside(cwd, filePath)
|
|
179
173
|
return {
|
|
180
174
|
content: [
|
package/src/tools/git.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Git tools for executing safe repository operations programmatically.
|
|
3
3
|
*/
|
|
4
|
+
import { spawn } from "node:child_process"
|
|
4
5
|
import type { Tool, ToolResult } from "../types.ts"
|
|
5
6
|
import { textPart } from "../util.ts"
|
|
6
7
|
|
|
@@ -41,21 +42,37 @@ export function gitTool(cwd: string): Tool {
|
|
|
41
42
|
|
|
42
43
|
try {
|
|
43
44
|
const cmd = ["git", action, ...extraArgs]
|
|
44
|
-
const proc =
|
|
45
|
+
const proc = spawn(cmd[0]!, cmd.slice(1), {
|
|
45
46
|
cwd,
|
|
46
|
-
|
|
47
|
-
stderr: "pipe",
|
|
47
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
48
48
|
env: { ...process.env, PAGER: "cat" },
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
let stdout = ""
|
|
52
|
+
let stderr = ""
|
|
53
|
+
proc.stdout.on("data", (chunk: Buffer) => {
|
|
54
|
+
stdout += chunk.toString()
|
|
55
|
+
})
|
|
56
|
+
proc.stderr.on("data", (chunk: Buffer) => {
|
|
57
|
+
stderr += chunk.toString()
|
|
58
|
+
})
|
|
53
59
|
|
|
54
|
-
const
|
|
55
|
-
|
|
60
|
+
const onAbort = () => {
|
|
61
|
+
proc.kill("SIGKILL")
|
|
62
|
+
proc.stdout.destroy()
|
|
63
|
+
proc.stderr.destroy()
|
|
64
|
+
}
|
|
65
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
56
66
|
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
let exitCode: number
|
|
68
|
+
try {
|
|
69
|
+
exitCode = await new Promise<number>((resolve, reject) => {
|
|
70
|
+
proc.on("error", reject)
|
|
71
|
+
proc.on("close", (code) => resolve(code ?? -1))
|
|
72
|
+
})
|
|
73
|
+
} finally {
|
|
74
|
+
signal?.removeEventListener("abort", onAbort)
|
|
75
|
+
}
|
|
59
76
|
|
|
60
77
|
// Prevent context window blowout by truncating very large outputs
|
|
61
78
|
const MAX = 50_000
|
package/src/tools/search.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Search tools for finding files and content.
|
|
3
3
|
* Uses 'rg' (ripgrep) if available, falling back to a pure JS implementation.
|
|
4
4
|
*/
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
import { spawn } from "node:child_process"
|
|
7
|
+
import { readdir, readFile } from "node:fs/promises"
|
|
6
8
|
import { relative, resolve } from "node:path"
|
|
7
9
|
import { glob } from "glob"
|
|
8
10
|
import type { Tool, ToolResult } from "../types.ts"
|
|
@@ -91,18 +93,35 @@ export function grepTool(cwd: string): Tool {
|
|
|
91
93
|
if (globFilter) cmd.push(`--glob=${globFilter}`)
|
|
92
94
|
cmd.push("--", pattern, relSearchPath)
|
|
93
95
|
|
|
94
|
-
const proc =
|
|
96
|
+
const proc = spawn(cmd[0]!, cmd.slice(1), {
|
|
95
97
|
cwd,
|
|
96
|
-
|
|
97
|
-
|
|
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()
|
|
98
111
|
})
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|
|
102
122
|
|
|
103
123
|
if (exitCode === 0) {
|
|
104
|
-
const
|
|
105
|
-
const lines = out.split("\n").slice(0, 200).join("\n")
|
|
124
|
+
const lines = stdout.split("\n").slice(0, 200).join("\n")
|
|
106
125
|
return { content: [textPart(lines || "No matches")], isError: false }
|
|
107
126
|
}
|
|
108
127
|
} catch {
|
|
@@ -110,14 +129,17 @@ export function grepTool(cwd: string): Tool {
|
|
|
110
129
|
}
|
|
111
130
|
|
|
112
131
|
// Pure JS fallback when rg is not available
|
|
113
|
-
const files = await glob(globFilter || "**/*", {
|
|
132
|
+
const files = await glob(globFilter || "**/*", {
|
|
133
|
+
cwd: dir,
|
|
134
|
+
ignore: ["**/node_modules/**", "**/.git/**"],
|
|
135
|
+
})
|
|
114
136
|
const prefix = relSearchPath === "." ? "" : `${relSearchPath}/`
|
|
115
137
|
const re = new RegExp(pattern, "i")
|
|
116
138
|
const matches: string[] = []
|
|
117
139
|
for (const file of files.slice(0, 500)) {
|
|
118
140
|
if (signal?.aborted) break
|
|
119
141
|
try {
|
|
120
|
-
const content = await
|
|
142
|
+
const content = await readFile(resolve(dir, file), "utf-8")
|
|
121
143
|
const lines = content.split("\n")
|
|
122
144
|
for (let i = 0; i < lines.length && matches.length < 200; i++) {
|
|
123
145
|
const line = lines[i]
|
package/src/tools/shell.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Tool for executing shell commands within the project root.
|
|
3
3
|
* Supports timeouts and output truncation to protect the context window.
|
|
4
4
|
*/
|
|
5
|
+
import { spawn } from "node:child_process"
|
|
5
6
|
import type { Tool, ToolResult } from "../types.ts"
|
|
6
7
|
|
|
7
8
|
import { textPart } from "../util.ts"
|
|
@@ -26,43 +27,43 @@ export function bashTool(cwd: string): Tool {
|
|
|
26
27
|
const timeoutMs = (Number(args.timeout) || 120) * 1000
|
|
27
28
|
|
|
28
29
|
try {
|
|
29
|
-
const proc =
|
|
30
|
-
cwd,
|
|
31
|
-
stdout: "pipe",
|
|
32
|
-
stderr: "pipe",
|
|
33
|
-
})
|
|
30
|
+
const proc = spawn("sh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
})
|
|
38
40
|
|
|
39
|
-
// Track whether we killed it vs normal exit, so the output reflects the cause
|
|
40
41
|
let killed = false
|
|
41
42
|
const timer = setTimeout(() => {
|
|
42
43
|
killed = true
|
|
43
|
-
proc.kill(
|
|
44
|
+
proc.kill("SIGKILL")
|
|
45
|
+
proc.stdout.destroy()
|
|
46
|
+
proc.stderr.destroy()
|
|
44
47
|
}, timeoutMs)
|
|
45
48
|
|
|
46
49
|
const onAbort = () => {
|
|
47
50
|
killed = true
|
|
48
|
-
proc.kill(
|
|
51
|
+
proc.kill("SIGKILL")
|
|
52
|
+
proc.stdout.destroy()
|
|
53
|
+
proc.stderr.destroy()
|
|
49
54
|
}
|
|
50
55
|
signal?.addEventListener("abort", onAbort, { once: true })
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const stderr = await Promise.race([
|
|
63
|
-
stderrPromise,
|
|
64
|
-
new Promise<string>((r) => setTimeout(() => r(""), 500)),
|
|
65
|
-
])
|
|
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
|
+
}
|
|
66
67
|
|
|
67
68
|
// Prevent context-window blowout from noisy commands
|
|
68
69
|
const MAX = 50_000
|