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.
@@ -1,138 +0,0 @@
1
- import type { AgentEvent, ApiFormat, AssistantResult, StreamFn, StreamOpts } from "../types.ts"
2
- import { streamGemini } from "./gemini.ts"
3
- import { streamOpenAI } from "./openai.ts"
4
-
5
- export type { AssistantResult, StreamEvent, StreamFn, StreamOpts } from "../types.ts"
6
-
7
- /*
8
- * Push-based async event stream.
9
- *
10
- * Producers call push()/finish(). Consumers iterate with for-await-of.
11
- * Backpressure is implicit: push() resolves immediately; the iterator
12
- * awaits the next value only when the consumer asks for it.
13
- */
14
- export class EventStream<T, R> {
15
- #events: T[] = []
16
- #done = false
17
- #result?: R
18
- #resolve?: (value: T) => void
19
- #doneResolve?: (value: R) => void
20
- #abort = false
21
-
22
- push(event: T): void {
23
- if (this.#abort) return
24
- // If a consumer is already waiting, deliver directly — skip the queue
25
- if (this.#resolve) {
26
- const resolve = this.#resolve
27
- this.#resolve = undefined
28
- resolve(event)
29
- } else {
30
- this.#events.push(event)
31
- }
32
- }
33
-
34
- finish(result: R): void {
35
- this.#done = true
36
- this.#result = result
37
- // Wake up a suspended iterator so it can see done=true and exit
38
- if (this.#resolve) {
39
- // undefined is a sentinel — the iterator loop checks done after waking
40
- this.#resolve(undefined as T)
41
- }
42
- if (this.#doneResolve) {
43
- this.#doneResolve(result)
44
- }
45
- }
46
-
47
- abort(): void {
48
- this.#abort = true
49
- this.#done = true
50
- if (this.#resolve) {
51
- this.#resolve(undefined as T)
52
- }
53
- if (this.#doneResolve) {
54
- this.#doneResolve(undefined as R)
55
- }
56
- }
57
-
58
- async *[Symbol.asyncIterator](): AsyncGenerator<T> {
59
- while (!this.#done || this.#events.length > 0) {
60
- if (this.#events.length > 0) {
61
- yield this.#events.shift() as T
62
- continue
63
- }
64
- if (this.#done) break
65
- const item = await new Promise<T | undefined>((resolve) => {
66
- this.#resolve = resolve as (value: T) => void
67
- })
68
- if (item !== undefined) {
69
- yield item
70
- }
71
- }
72
- }
73
-
74
- get result(): R | undefined {
75
- return this.#result
76
- }
77
-
78
- get isDone(): boolean {
79
- return this.#done
80
- }
81
- }
82
-
83
- // Internal map of registered provider implementations
84
- const registry = new Map<ApiFormat, StreamFn>([
85
- ["openai", streamOpenAI],
86
- ["gemini", streamGemini],
87
- ])
88
-
89
- export function register(api: ApiFormat, fn: StreamFn): void {
90
- registry.set(api, fn)
91
- }
92
-
93
- // Bridges provider-specific StreamEvents into AgentEvents so the loop and TUI deal with one type.
94
- export function stream(opts: StreamOpts): EventStream<AgentEvent, AssistantResult> {
95
- const fn = registry.get(opts.api)
96
- if (!fn) throw new Error(`No provider registered for API format: ${opts.api}`)
97
-
98
- // Bridge layer: converts provider-specific StreamEvents into the agent's
99
- // AgentEvent shape, so the loop and TUI only deal with one event type.
100
- const providerStream = fn(opts)
101
- const agentStream = new EventStream<AgentEvent, AssistantResult>()
102
-
103
- ;(async () => {
104
- for await (const event of providerStream) {
105
- if (event.type === "text_delta") {
106
- agentStream.push({ type: "text_delta", text: event.text ?? "" })
107
- } else if (event.type === "thinking_delta") {
108
- agentStream.push({ type: "thinking_delta", text: event.text ?? "" })
109
- } else if (event.type === "tool_call" && event.call) {
110
- agentStream.push({
111
- type: "tool_call",
112
- call: {
113
- type: "tool_call",
114
- id: event.call.id,
115
- name: event.call.name,
116
- args: event.call.args,
117
- },
118
- })
119
- } else if (event.type === "usage" && event.usage) {
120
- agentStream.push({ type: "usage", usage: event.usage })
121
- }
122
- }
123
-
124
- const res = providerStream.result
125
- if (res) {
126
- agentStream.finish(res)
127
- } else {
128
- // Fallback for unexpected closure
129
- agentStream.finish({ content: [], usage: { in: 0, out: 0 }, stop: "stop" })
130
- }
131
- })()
132
-
133
- return agentStream
134
- }
135
-
136
- export function getRegisteredApis(): ApiFormat[] {
137
- return [...registry.keys()]
138
- }
@@ -1,126 +0,0 @@
1
- import { getProvider } from "../config/providers.ts"
2
- import { stream } from "../provider/stream.ts"
3
- import type { Model, Msg } from "../types.ts"
4
- import { estimateTokens } from "../util.ts"
5
- import type { SessionStore } from "./store.ts"
6
-
7
- const COMPACT_THRESHOLD = 0.8
8
- const KEEP_RECENT = 10
9
-
10
- function extractText(msg: Msg): string {
11
- if (typeof msg.content === "string") return msg.content
12
- return msg.content
13
- .filter((c) => c.type === "text")
14
- .map((c) => (c.type === "text" ? c.text : ""))
15
- .join("")
16
- }
17
-
18
- function extractToolFiles(msg: Msg, toolName: string): string[] {
19
- if (msg.role !== "tool_result") return []
20
- if (!("tool" in msg) || msg.tool !== toolName) return []
21
- const text = extractText(msg)
22
- // Extract file paths from tool result content
23
- const lines = text.split("\n")
24
- return lines.filter((l) => l.trim().length > 0)
25
- }
26
-
27
- export interface CompactResult {
28
- compacted: boolean
29
- summary?: string
30
- msgsRemoved: number
31
- }
32
-
33
- export function needsCompact(messages: Msg[], contextWindow: number): boolean {
34
- return estimateTokens(messages) > contextWindow * COMPACT_THRESHOLD
35
- }
36
-
37
- export async function compact(
38
- store: SessionStore,
39
- sessionId: string,
40
- messages: Msg[],
41
- model: Model,
42
- apiKey: string,
43
- baseUrl: string,
44
- ): Promise<CompactResult> {
45
- if (!needsCompact(messages, model.contextWindow)) {
46
- return { compacted: false, msgsRemoved: 0 }
47
- }
48
-
49
- const old = messages.slice(0, -KEEP_RECENT)
50
- if (old.length === 0) {
51
- return { compacted: false, msgsRemoved: 0 }
52
- }
53
- const convo = old
54
- .map((m) => {
55
- if (m.role === "user") return `User: ${extractText(m)}`
56
- if (m.role === "assistant") return `Assistant: ${extractText(m)}`
57
- if (m.role === "tool_result" && "tool" in m)
58
- return `Tool(${m.tool}): ${extractText(m).slice(0, 200)}`
59
- return ""
60
- })
61
- .join("\n\n")
62
-
63
- const summary = await generateSummary(convo, model, apiKey, baseUrl)
64
- if (!summary) {
65
- return { compacted: false, msgsRemoved: 0 }
66
- }
67
-
68
- const filesRead: string[] = []
69
- const filesWrote: string[] = []
70
- for (const m of old) {
71
- filesRead.push(...extractToolFiles(m, "read"))
72
- filesRead.push(...extractToolFiles(m, "glob"))
73
- filesWrote.push(...extractToolFiles(m, "write"))
74
- filesWrote.push(...extractToolFiles(m, "edit"))
75
- }
76
-
77
- const seqBefore = old.length
78
- store.saveCompaction(
79
- sessionId,
80
- summary,
81
- [...new Set(filesRead)],
82
- [...new Set(filesWrote)],
83
- seqBefore,
84
- )
85
- store.truncateBeforeSeq(sessionId, seqBefore + 1)
86
-
87
- // Insert the summary as a user message so the model retains context
88
- const summaryMsg: Msg = {
89
- role: "user",
90
- content: `[Prior context summary]\n${summary}`,
91
- ts: Date.now(),
92
- }
93
- store.append(sessionId, summaryMsg)
94
-
95
- return { compacted: true, summary, msgsRemoved: old.length }
96
- }
97
-
98
- async function generateSummary(
99
- convo: string,
100
- model: Model,
101
- apiKey: string,
102
- baseUrl: string,
103
- ): Promise<string | null> {
104
- const provider = getProvider(model.provider)
105
- if (!provider) return null
106
-
107
- const es = stream({
108
- api: provider.api,
109
- model,
110
- apiKey,
111
- baseUrl,
112
- system:
113
- "Summarize this coding session concisely. Cover: what was asked, files touched, what was done, key decisions. Keep it under 300 words.",
114
- messages: [{ role: "user", content: convo, ts: Date.now() }],
115
- tools: [],
116
- })
117
-
118
- let summary = ""
119
- for await (const ev of es) {
120
- if (ev.type === "text_delta" && ev.text) {
121
- summary += ev.text
122
- }
123
- }
124
-
125
- return summary.trim() || null
126
- }
@@ -1,229 +0,0 @@
1
- import { unlinkSync } from "node:fs"
2
- import { join } from "node:path"
3
- import BetterSqlite3 from "better-sqlite3"
4
- import type { Msg, Session } from "../types.ts"
5
-
6
- const SCHEMA = `
7
- CREATE TABLE IF NOT EXISTS sessions (
8
- id TEXT PRIMARY KEY,
9
- cwd TEXT NOT NULL,
10
- model TEXT NOT NULL,
11
- provider TEXT NOT NULL,
12
- title TEXT,
13
- created INTEGER NOT NULL,
14
- updated INTEGER NOT NULL
15
- );
16
-
17
- CREATE TABLE IF NOT EXISTS messages (
18
- id INTEGER PRIMARY KEY AUTOINCREMENT,
19
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
20
- seq INTEGER NOT NULL,
21
- role TEXT NOT NULL,
22
- content TEXT NOT NULL,
23
- ts INTEGER NOT NULL
24
- );
25
-
26
- CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq);
27
-
28
- CREATE TABLE IF NOT EXISTS compactions (
29
- id INTEGER PRIMARY KEY AUTOINCREMENT,
30
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
31
- summary TEXT NOT NULL,
32
- files_read TEXT NOT NULL DEFAULT '[]',
33
- files_wrote TEXT NOT NULL DEFAULT '[]',
34
- seq_before INTEGER NOT NULL,
35
- ts INTEGER NOT NULL
36
- );
37
- `
38
-
39
- function generateId(): string {
40
- return `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`
41
- }
42
-
43
- export class SessionStore {
44
- #db: BetterSqlite3.Database
45
-
46
- constructor(dbPath: string) {
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
- }
73
- }
74
-
75
- create(cwd: string, model: string, provider: string): Session {
76
- const id = generateId()
77
- const now = Date.now()
78
- this.#db
79
- .prepare(
80
- "INSERT INTO sessions (id, cwd, model, provider, title, created, updated) VALUES ($id, $cwd, $model, $provider, $title, $created, $updated)",
81
- )
82
- .run({
83
- id: id,
84
- cwd: cwd,
85
- model: model,
86
- provider: provider,
87
- title: null,
88
- created: now,
89
- updated: now,
90
- })
91
- return { id, cwd, model, provider, title: null, created: now, updated: now }
92
- }
93
-
94
- get(id: string): Session | null {
95
- return (
96
- (this.#db
97
- .prepare(
98
- "SELECT id, cwd, model, provider, title, created, updated FROM sessions WHERE id = $id",
99
- )
100
- .get({ id: id }) as Session | null) ?? null
101
- )
102
- }
103
-
104
- list(limit = 50): Session[] {
105
- return this.#db
106
- .prepare(
107
- "SELECT id, cwd, model, provider, title, created, updated FROM sessions ORDER BY updated DESC LIMIT $limit",
108
- )
109
- .all({ limit: limit }) as Session[]
110
- }
111
-
112
- delete(id: string): boolean {
113
- const result = this.#db.prepare("DELETE FROM sessions WHERE id = $id").run({ id: id })
114
- return result.changes > 0
115
- }
116
-
117
- append(sessionId: string, msg: Msg): void {
118
- const seq = this.#nextSeq(sessionId)
119
- this.#db
120
- .prepare(
121
- "INSERT INTO messages (session_id, seq, role, content, ts) VALUES ($sid, $seq, $role, $content, $ts)",
122
- )
123
- .run({
124
- sid: sessionId,
125
- seq: seq,
126
- role: msg.role,
127
- content: JSON.stringify(msg),
128
- ts: msg.ts,
129
- })
130
- this.#db
131
- .prepare("UPDATE sessions SET updated = $now WHERE id = $id")
132
- .run({ now: Date.now(), id: sessionId })
133
- }
134
-
135
- appendMany(sessionId: string, msgs: Msg[]): void {
136
- const tx = this.#db.transaction(() => {
137
- for (const msg of msgs) {
138
- this.append(sessionId, msg)
139
- }
140
- })
141
- tx()
142
- }
143
-
144
- messages(sessionId: string): Msg[] {
145
- const rows = this.#db
146
- .prepare("SELECT content FROM messages WHERE session_id = $sid ORDER BY seq ASC")
147
- .all({ sid: sessionId }) as { content: string }[]
148
- return rows.map((r) => JSON.parse(r.content) as Msg)
149
- }
150
-
151
- messagesAfter(sessionId: string, afterSeq: number): Msg[] {
152
- const rows = this.#db
153
- .prepare(
154
- "SELECT content FROM messages WHERE session_id = $sid AND seq > $seq ORDER BY seq ASC",
155
- )
156
- .all({ sid: sessionId, seq: afterSeq }) as { content: string }[]
157
- return rows.map((r) => JSON.parse(r.content) as Msg)
158
- }
159
-
160
- setTitle(sessionId: string, title: string): void {
161
- this.#db
162
- .prepare("UPDATE sessions SET title = $title WHERE id = $id")
163
- .run({ title: title, id: sessionId })
164
- }
165
-
166
- messageCount(sessionId: string): number {
167
- const row = this.#db
168
- .prepare("SELECT COUNT(*) as count FROM messages WHERE session_id = $sid")
169
- .get({ sid: sessionId }) as { count: number }
170
- return row.count
171
- }
172
-
173
- saveCompaction(
174
- sessionId: string,
175
- summary: string,
176
- filesRead: string[],
177
- filesWrote: string[],
178
- seqBefore: number,
179
- ): void {
180
- this.#db
181
- .prepare(
182
- "INSERT INTO compactions (session_id, summary, files_read, files_wrote, seq_before, ts) VALUES ($sid, $summary, $read, $wrote, $seq, $ts)",
183
- )
184
- .run({
185
- sid: sessionId,
186
- summary: summary,
187
- read: JSON.stringify(filesRead),
188
- wrote: JSON.stringify(filesWrote),
189
- seq: seqBefore,
190
- ts: Date.now(),
191
- })
192
- }
193
-
194
- getLatestCompaction(sessionId: string): { summary: string; seqBefore: number } | null {
195
- return (
196
- (this.#db
197
- .prepare(
198
- "SELECT summary, seq_before FROM compactions WHERE session_id = $sid ORDER BY ts DESC LIMIT 1",
199
- )
200
- .get({ sid: sessionId }) as { summary: string; seqBefore: number } | null) ?? null
201
- )
202
- }
203
-
204
- truncateBeforeSeq(sessionId: string, seq: number): void {
205
- this.#db
206
- .prepare("DELETE FROM messages WHERE session_id = $sid AND seq < $seq")
207
- .run({ sid: sessionId, seq: seq })
208
- }
209
-
210
- close(): void {
211
- this.#db.close()
212
- }
213
-
214
- #nextSeq(sessionId: string): number {
215
- const row = this.#db
216
- .prepare("SELECT MAX(seq) as maxSeq FROM messages WHERE session_id = $sid")
217
- .get({ sid: sessionId }) as { maxSeq: number | null }
218
- return (row.maxSeq ?? 0) + 1
219
- }
220
- }
221
-
222
- let _store: SessionStore | null = null
223
-
224
- export function getSessionStore(dir?: string): SessionStore {
225
- if (_store) return _store
226
- const dbPath = join(dir ?? join(process.env.HOME ?? "~", ".novacode"), "sessions.db")
227
- _store = new SessionStore(dbPath)
228
- return _store
229
- }
package/src/tools/fs.ts DELETED
@@ -1,189 +0,0 @@
1
- /**
2
- * Filesystem tools for reading, writing, and editing files.
3
- * Includes safety checks to prevent path traversal.
4
- */
5
- import { mkdir, readFile, writeFile } from "node:fs/promises"
6
- import { dirname, extname, resolve } from "node:path"
7
- import type { Tool, ToolResult } from "../types.ts"
8
- import { getRelativeIfInside, textPart } from "../util.ts"
9
-
10
- // Extensions we return as base64 images instead of text
11
- const IMAGES = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"])
12
-
13
- function safePath(cwd: string, p: string): string {
14
- const abs = resolve(cwd, p)
15
- if (abs !== cwd && !abs.startsWith(`${cwd}/`)) {
16
- throw new Error(`Path outside project: ${p}`)
17
- }
18
- return abs
19
- }
20
-
21
- export function readTool(cwd: string): Tool {
22
- return {
23
- def: {
24
- name: "read",
25
- description:
26
- "Read file contents. Supports text and images (jpg, png, gif, webp). Text output is truncated to 2000 lines.",
27
- parameters: {
28
- type: "object",
29
- properties: {
30
- path: { type: "string", description: "Path to file (relative or absolute)" },
31
- offset: { type: "number", description: "Start line (1-based, default 1)" },
32
- limit: { type: "number", description: "Max lines to read (default 2000)" },
33
- },
34
- required: ["path"],
35
- },
36
- },
37
- async execute(args): Promise<ToolResult> {
38
- try {
39
- const filePath = safePath(cwd, args.path as string)
40
- // Return images as base64 so the LLM can process them visually
41
- const ext = extname(filePath).toLowerCase()
42
- if (IMAGES.has(ext)) {
43
- const buf = await readFile(filePath)
44
- const b64 = buf.toString("base64")
45
- const mime = ext === ".jpg" ? "image/jpeg" : `image/${ext.slice(1)}`
46
- return { content: [{ type: "image", data: b64, mime }], isError: false }
47
- }
48
-
49
- const content = await readFile(filePath, "utf-8")
50
- const lines = content.split("\n")
51
- const offset = Math.max(0, (Number(args.offset ?? 1) || 1) - 1)
52
- const limit = Number(args.limit ?? 2000) || 2000
53
- const slice = lines.slice(offset, offset + limit)
54
- const truncated = offset + limit < lines.length
55
-
56
- const out = slice.join("\n")
57
- const suffix = truncated ? `\n…${lines.length - offset - limit} more lines` : ""
58
-
59
- return { content: [textPart(out + suffix)], isError: false }
60
- } catch (e) {
61
- return {
62
- content: [textPart(`Error reading file: ${(e as Error).message}`)],
63
- isError: true,
64
- }
65
- }
66
- },
67
- }
68
- }
69
-
70
- export function writeTool(cwd: string): Tool {
71
- return {
72
- def: {
73
- name: "write",
74
- description: "Write content to a file. Creates the file and parent directories if needed.",
75
- parameters: {
76
- type: "object",
77
- properties: {
78
- path: { type: "string", description: "Path to file" },
79
- content: { type: "string", description: "Content to write" },
80
- },
81
- required: ["path", "content"],
82
- },
83
- },
84
- async execute(args): Promise<ToolResult> {
85
- try {
86
- const filePath = safePath(cwd, args.path as string)
87
- const content = args.content as string
88
- await mkdir(dirname(filePath), { recursive: true })
89
- await writeFile(filePath, content)
90
- const relPath = getRelativeIfInside(cwd, filePath)
91
- return {
92
- content: [textPart(`Wrote ${content.length} bytes → ${relPath}`)],
93
- isError: false,
94
- }
95
- } catch (e) {
96
- return {
97
- content: [textPart(`Error writing file: ${(e as Error).message}`)],
98
- isError: true,
99
- }
100
- }
101
- },
102
- }
103
- }
104
-
105
- // Requires oldText to be unique to avoid ambiguous replacements.
106
- export function editTool(cwd: string): Tool {
107
- return {
108
- def: {
109
- name: "edit",
110
- description:
111
- "Edit a file using exact text replacement. Each edit's oldText must be unique in the file.",
112
- parameters: {
113
- type: "object",
114
- properties: {
115
- path: { type: "string", description: "Path to file" },
116
- edits: {
117
- type: "array",
118
- description:
119
- "Array of {oldText, newText} replacements. oldText must be unique. Non-overlapping.",
120
- items: {
121
- type: "object",
122
- properties: {
123
- oldText: { type: "string", description: "Exact text to find (must be unique)" },
124
- newText: { type: "string", description: "Replacement text" },
125
- },
126
- required: ["oldText", "newText"],
127
- },
128
- },
129
- },
130
- required: ["path", "edits"],
131
- },
132
- },
133
- async execute(args): Promise<ToolResult> {
134
- try {
135
- const filePath = safePath(cwd, args.path as string)
136
- let content: string
137
- try {
138
- content = await readFile(filePath, "utf-8")
139
- } catch {
140
- return { content: [textPart(`File not found: ${args.path}`)], isError: true }
141
- }
142
- const edits = args.edits as Array<{ oldText: string; newText: string }>
143
-
144
- // Validate all edits before applying any — avoids partial writes on bad input
145
- for (const edit of edits) {
146
- const count = content.split(edit.oldText).length - 1
147
- if (count === 0) {
148
- return {
149
- content: [textPart(`oldText not found: "${edit.oldText.slice(0, 80)}…"`)],
150
- isError: true,
151
- }
152
- }
153
- // Ambiguous match would replace the wrong occurrence
154
- if (count > 1) {
155
- return {
156
- content: [
157
- textPart(
158
- `oldText found ${count} times — add surrounding context to make it unique: "${edit.oldText.slice(0, 60)}…"`,
159
- ),
160
- ],
161
- isError: true,
162
- }
163
- }
164
- }
165
-
166
- // Apply edits sequentially
167
- for (const edit of edits) {
168
- content = content.replace(edit.oldText, edit.newText)
169
- }
170
-
171
- await writeFile(filePath, content)
172
- const relPath = getRelativeIfInside(cwd, filePath)
173
- return {
174
- content: [
175
- textPart(
176
- `Edited ${relPath} (${edits.length} replacement${edits.length > 1 ? "s" : ""})`,
177
- ),
178
- ],
179
- isError: false,
180
- }
181
- } catch (e) {
182
- return {
183
- content: [textPart(`Error editing file: ${(e as Error).message}`)],
184
- isError: true,
185
- }
186
- }
187
- },
188
- }
189
- }