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.
@@ -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 Database(dbPath, { create: true })
47
- this.#db.run("PRAGMA journal_mode = WAL")
48
- this.#db.run("PRAGMA foreign_keys = ON")
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
- $id: id,
61
- $cwd: cwd,
62
- $model: model,
63
- $provider: provider,
64
- $title: null,
65
- $created: now,
66
- $updated: now,
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({ $id: id }) as Session | null) ?? null
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({ $limit: limit }) as Session[]
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({ $id: id })
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
- $sid: sessionId,
102
- $seq: seq,
103
- $role: msg.role,
104
- $content: JSON.stringify(msg),
105
- $ts: msg.ts,
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({ $now: Date.now(), $id: sessionId })
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({ $sid: sessionId }) as { content: string }[]
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({ $sid: sessionId, $seq: afterSeq }) as { content: string }[]
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({ $title: title, $id: sessionId })
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({ $sid: sessionId }) as { count: number }
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
- $sid: sessionId,
163
- $summary: summary,
164
- $read: JSON.stringify(filesRead),
165
- $wrote: JSON.stringify(filesWrote),
166
- $seq: seqBefore,
167
- $ts: Date.now(),
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({ $sid: sessionId }) as { summary: string; seqBefore: number } | null) ?? null
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({ $sid: sessionId, $seq: seq })
184
+ .run({ sid: sessionId, seq: seq })
185
185
  }
186
186
 
187
187
  close(): void {
188
- this.#db.close(false)
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({ $sid: sessionId }) as { maxSeq: number | null }
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 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