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/README.md +16 -10
- package/dist/app-CbJSUNmf.mjs +22 -0
- package/dist/app-CbJSUNmf.mjs.map +1 -0
- package/dist/main.mjs +51 -61
- package/dist/main.mjs.map +1 -1
- package/package.json +3 -5
- package/dist/app-QfQR2FN9.mjs +0 -21
- package/dist/app-QfQR2FN9.mjs.map +0 -1
- package/src/agent/agent.ts +0 -87
- package/src/agent/loop.ts +0 -237
- package/src/agent/prompt.ts +0 -50
- package/src/commands/compact.ts +0 -28
- package/src/commands/index.ts +0 -86
- package/src/commands/models.ts +0 -85
- package/src/commands/providers.ts +0 -213
- package/src/commands/session.ts +0 -40
- package/src/config/providers.ts +0 -207
- package/src/config/store.ts +0 -66
- package/src/main.ts +0 -175
- package/src/onboarding/wizard.ts +0 -54
- package/src/provider/gemini.ts +0 -261
- package/src/provider/openai.ts +0 -215
- package/src/provider/stream.ts +0 -138
- package/src/session/compact.ts +0 -126
- package/src/session/store.ts +0 -229
- package/src/tools/fs.ts +0 -189
- package/src/tools/git.ts +0 -99
- package/src/tools/index.ts +0 -33
- package/src/tools/search.ts +0 -274
- package/src/tools/shell.ts +0 -90
- package/src/tools/web.ts +0 -239
- package/src/tui/app.tsx +0 -364
- package/src/tui/components/liveArea.tsx +0 -73
- package/src/tui/components/message.tsx +0 -113
- package/src/tui/components/statusBar.tsx +0 -58
- package/src/tui/markdown.ts +0 -62
- package/src/tui/prompts.tsx +0 -205
- package/src/types.ts +0 -248
- package/src/update.ts +0 -89
- package/src/util.ts +0 -61
package/src/provider/stream.ts
DELETED
|
@@ -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
|
-
}
|
package/src/session/compact.ts
DELETED
|
@@ -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
|
-
}
|
package/src/session/store.ts
DELETED
|
@@ -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
|
-
}
|