novacode 0.5.2 → 0.5.5

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.
@@ -1,5 +1,6 @@
1
- import { Database } from "bun:sqlite"
1
+ import { unlinkSync } from "node:fs"
2
2
  import { join } from "node:path"
3
+ import BetterSqlite3 from "better-sqlite3"
3
4
  import type { Msg, Session } from "../types.ts"
4
5
 
5
6
  const SCHEMA = `
@@ -40,13 +41,35 @@ function generateId(): string {
40
41
  }
41
42
 
42
43
  export class SessionStore {
43
- #db: Database
44
+ #db: BetterSqlite3.Database
44
45
 
45
46
  constructor(dbPath: string) {
46
- this.#db = new Database(dbPath, { create: true })
47
- this.#db.run("PRAGMA journal_mode = WAL")
48
- this.#db.run("PRAGMA foreign_keys = ON")
49
- this.#db.exec(SCHEMA)
47
+ this.#db = SessionStore.#open(dbPath)
48
+ }
49
+
50
+ // Opens and fully initialises the DB. If anything throws (e.g. corrupt file),
51
+ // the bad file is deleted and a fresh DB is created and returned.
52
+ static #open(dbPath: string): BetterSqlite3.Database {
53
+ const init = (db: BetterSqlite3.Database) => {
54
+ db.pragma("journal_mode = WAL")
55
+ db.pragma("foreign_keys = ON")
56
+ db.exec(SCHEMA)
57
+ return db
58
+ }
59
+ try {
60
+ return init(new BetterSqlite3(dbPath))
61
+ } catch {
62
+ // Delete the main DB and WAL sidecar files — all three must go or
63
+ // SQLite will fail again trying to replay a corrupt WAL on reopen.
64
+ for (const f of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
65
+ try {
66
+ unlinkSync(f)
67
+ } catch {
68
+ // file may already be absent — ignore
69
+ }
70
+ }
71
+ return init(new BetterSqlite3(dbPath))
72
+ }
50
73
  }
51
74
 
52
75
  create(cwd: string, model: string, provider: string): Session {
@@ -57,13 +80,13 @@ export class SessionStore {
57
80
  "INSERT INTO sessions (id, cwd, model, provider, title, created, updated) VALUES ($id, $cwd, $model, $provider, $title, $created, $updated)",
58
81
  )
59
82
  .run({
60
- $id: id,
61
- $cwd: cwd,
62
- $model: model,
63
- $provider: provider,
64
- $title: null,
65
- $created: now,
66
- $updated: now,
83
+ id: id,
84
+ cwd: cwd,
85
+ model: model,
86
+ provider: provider,
87
+ title: null,
88
+ created: now,
89
+ updated: now,
67
90
  })
68
91
  return { id, cwd, model, provider, title: null, created: now, updated: now }
69
92
  }
@@ -74,7 +97,7 @@ export class SessionStore {
74
97
  .prepare(
75
98
  "SELECT id, cwd, model, provider, title, created, updated FROM sessions WHERE id = $id",
76
99
  )
77
- .get({ $id: id }) as Session | null) ?? null
100
+ .get({ id: id }) as Session | null) ?? null
78
101
  )
79
102
  }
80
103
 
@@ -83,11 +106,11 @@ export class SessionStore {
83
106
  .prepare(
84
107
  "SELECT id, cwd, model, provider, title, created, updated FROM sessions ORDER BY updated DESC LIMIT $limit",
85
108
  )
86
- .all({ $limit: limit }) as Session[]
109
+ .all({ limit: limit }) as Session[]
87
110
  }
88
111
 
89
112
  delete(id: string): boolean {
90
- const result = this.#db.prepare("DELETE FROM sessions WHERE id = $id").run({ $id: id })
113
+ const result = this.#db.prepare("DELETE FROM sessions WHERE id = $id").run({ id: id })
91
114
  return result.changes > 0
92
115
  }
93
116
 
@@ -98,15 +121,15 @@ export class SessionStore {
98
121
  "INSERT INTO messages (session_id, seq, role, content, ts) VALUES ($sid, $seq, $role, $content, $ts)",
99
122
  )
100
123
  .run({
101
- $sid: sessionId,
102
- $seq: seq,
103
- $role: msg.role,
104
- $content: JSON.stringify(msg),
105
- $ts: msg.ts,
124
+ sid: sessionId,
125
+ seq: seq,
126
+ role: msg.role,
127
+ content: JSON.stringify(msg),
128
+ ts: msg.ts,
106
129
  })
107
130
  this.#db
108
131
  .prepare("UPDATE sessions SET updated = $now WHERE id = $id")
109
- .run({ $now: Date.now(), $id: sessionId })
132
+ .run({ now: Date.now(), id: sessionId })
110
133
  }
111
134
 
112
135
  appendMany(sessionId: string, msgs: Msg[]): void {
@@ -121,7 +144,7 @@ export class SessionStore {
121
144
  messages(sessionId: string): Msg[] {
122
145
  const rows = this.#db
123
146
  .prepare("SELECT content FROM messages WHERE session_id = $sid ORDER BY seq ASC")
124
- .all({ $sid: sessionId }) as { content: string }[]
147
+ .all({ sid: sessionId }) as { content: string }[]
125
148
  return rows.map((r) => JSON.parse(r.content) as Msg)
126
149
  }
127
150
 
@@ -130,20 +153,20 @@ export class SessionStore {
130
153
  .prepare(
131
154
  "SELECT content FROM messages WHERE session_id = $sid AND seq > $seq ORDER BY seq ASC",
132
155
  )
133
- .all({ $sid: sessionId, $seq: afterSeq }) as { content: string }[]
156
+ .all({ sid: sessionId, seq: afterSeq }) as { content: string }[]
134
157
  return rows.map((r) => JSON.parse(r.content) as Msg)
135
158
  }
136
159
 
137
160
  setTitle(sessionId: string, title: string): void {
138
161
  this.#db
139
162
  .prepare("UPDATE sessions SET title = $title WHERE id = $id")
140
- .run({ $title: title, $id: sessionId })
163
+ .run({ title: title, id: sessionId })
141
164
  }
142
165
 
143
166
  messageCount(sessionId: string): number {
144
167
  const row = this.#db
145
168
  .prepare("SELECT COUNT(*) as count FROM messages WHERE session_id = $sid")
146
- .get({ $sid: sessionId }) as { count: number }
169
+ .get({ sid: sessionId }) as { count: number }
147
170
  return row.count
148
171
  }
149
172
 
@@ -159,12 +182,12 @@ export class SessionStore {
159
182
  "INSERT INTO compactions (session_id, summary, files_read, files_wrote, seq_before, ts) VALUES ($sid, $summary, $read, $wrote, $seq, $ts)",
160
183
  )
161
184
  .run({
162
- $sid: sessionId,
163
- $summary: summary,
164
- $read: JSON.stringify(filesRead),
165
- $wrote: JSON.stringify(filesWrote),
166
- $seq: seqBefore,
167
- $ts: Date.now(),
185
+ sid: sessionId,
186
+ summary: summary,
187
+ read: JSON.stringify(filesRead),
188
+ wrote: JSON.stringify(filesWrote),
189
+ seq: seqBefore,
190
+ ts: Date.now(),
168
191
  })
169
192
  }
170
193
 
@@ -174,24 +197,24 @@ export class SessionStore {
174
197
  .prepare(
175
198
  "SELECT summary, seq_before FROM compactions WHERE session_id = $sid ORDER BY ts DESC LIMIT 1",
176
199
  )
177
- .get({ $sid: sessionId }) as { summary: string; seqBefore: number } | null) ?? null
200
+ .get({ sid: sessionId }) as { summary: string; seqBefore: number } | null) ?? null
178
201
  )
179
202
  }
180
203
 
181
204
  truncateBeforeSeq(sessionId: string, seq: number): void {
182
205
  this.#db
183
206
  .prepare("DELETE FROM messages WHERE session_id = $sid AND seq < $seq")
184
- .run({ $sid: sessionId, $seq: seq })
207
+ .run({ sid: sessionId, seq: seq })
185
208
  }
186
209
 
187
210
  close(): void {
188
- this.#db.close(false)
211
+ this.#db.close()
189
212
  }
190
213
 
191
214
  #nextSeq(sessionId: string): number {
192
215
  const row = this.#db
193
216
  .prepare("SELECT MAX(seq) as maxSeq FROM messages WHERE session_id = $sid")
194
- .get({ $sid: sessionId }) as { maxSeq: number | null }
217
+ .get({ sid: sessionId }) as { maxSeq: number | null }
195
218
  return (row.maxSeq ?? 0) + 1
196
219
  }
197
220
  }
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 file.arrayBuffer()
50
- const b64 = Buffer.from(buf).toString("base64")
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 file.text()
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 Bun.write(filePath, content)
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
- const file = Bun.file(filePath)
143
- if (!(await file.exists())) {
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 Bun.write(filePath, content)
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 = Bun.spawn(cmd, {
45
+ const proc = spawn(cmd[0]!, cmd.slice(1), {
45
46
  cwd,
46
- stdout: "pipe",
47
- stderr: "pipe",
47
+ stdio: ["ignore", "pipe", "pipe"],
48
48
  env: { ...process.env, PAGER: "cat" },
49
49
  })
50
50
 
51
- const onAbort = () => proc.kill()
52
- signal?.addEventListener("abort", onAbort, { once: true })
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 exitCode = await proc.exited
55
- signal?.removeEventListener("abort", onAbort)
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
- const stdout = await new Response(proc.stdout).text()
58
- const stderr = await new Response(proc.stderr).text()
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
@@ -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
- import { readdir } from "node:fs/promises"
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 = Bun.spawn(cmd, {
96
+ const proc = spawn(cmd[0]!, cmd.slice(1), {
95
97
  cwd,
96
- stdout: "pipe",
97
- stderr: "pipe",
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
- signal?.addEventListener("abort", () => proc.kill(), { once: true })
100
- const exitCode = await proc.exited
101
- signal?.removeEventListener("abort", () => proc.kill())
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 out = await new Response(proc.stdout).text()
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 || "**/*", { cwd: dir })
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 Bun.file(resolve(dir, file)).text()
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]
@@ -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 = Bun.spawn(["sh", "-c", command], {
30
- cwd,
31
- stdout: "pipe",
32
- stderr: "pipe",
33
- })
30
+ const proc = spawn("sh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] })
34
31
 
35
- // Start reading pipes immediately so they don't block the process
36
- const stdoutPromise = new Response(proc.stdout).text()
37
- const stderrPromise = new Response(proc.stderr).text()
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(9) // SIGKILL to be more aggressive against orphans
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(9)
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
- const exitCode = await proc.exited
53
- clearTimeout(timer)
54
- signal?.removeEventListener("abort", onAbort)
55
-
56
- // After exitCode, pipes should close. We give them a tiny grace period
57
- // to avoid hanging on orphans.
58
- const stdout = await Promise.race([
59
- stdoutPromise,
60
- new Promise<string>((r) => setTimeout(() => r(""), 500)),
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