novacode 0.6.0 → 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,239 +0,0 @@
1
- import type {
2
- AssistantResult,
3
- Msg,
4
- StopReason,
5
- StreamEvent,
6
- StreamFn,
7
- StreamOpts,
8
- ToolDef,
9
- Usage,
10
- } from "../types.ts"
11
- import { EventStream } from "./stream.ts"
12
-
13
- function msgToOpenAI(msg: Msg): Record<string, unknown> {
14
- if (msg.role === "user") {
15
- return {
16
- role: "user",
17
- content:
18
- typeof msg.content === "string"
19
- ? msg.content
20
- : msg.content.map((c) => {
21
- if (c.type === "text") return { type: "text", text: c.text }
22
- if (c.type === "image")
23
- return { type: "image_url", image_url: { url: `data:${c.mime};base64,${c.data}` } }
24
- return { type: "text", text: "" }
25
- }),
26
- }
27
- }
28
- if (msg.role === "assistant") {
29
- const textParts: string[] = []
30
- const toolCalls: unknown[] = []
31
-
32
- for (const c of msg.content) {
33
- if (c.type === "text") textParts.push(c.text)
34
- // thinking parts are internal — never sent back to the API
35
- if (c.type === "tool_call")
36
- toolCalls.push({
37
- type: "function",
38
- id: c.id,
39
- function: { name: c.name, arguments: JSON.stringify(c.args) },
40
- })
41
- }
42
-
43
- const result: Record<string, unknown> = {
44
- role: "assistant",
45
- content: textParts.length > 0 ? textParts.join("") : null,
46
- }
47
- if (toolCalls.length > 0) result.tool_calls = toolCalls
48
- return result
49
- }
50
- // tool_result
51
- if (msg.role === "tool_result") {
52
- return {
53
- role: "tool",
54
- tool_call_id: msg.callId,
55
- content: msg.content.map((c) => (c.type === "text" ? c.text : JSON.stringify(c))).join("\n"),
56
- }
57
- }
58
- return { role: "user", content: "" }
59
- }
60
-
61
- function toolsToOpenAI(tools: ToolDef[]): unknown[] {
62
- return tools.map((t) => ({
63
- type: "function",
64
- function: {
65
- name: t.name,
66
- description: t.description,
67
- parameters: t.parameters,
68
- },
69
- }))
70
- }
71
-
72
- export const streamOpenAI: StreamFn = (
73
- opts: StreamOpts,
74
- ): EventStream<StreamEvent, AssistantResult> => {
75
- const es = new EventStream<StreamEvent, AssistantResult>()
76
-
77
- ;(async () => {
78
- let textContent = ""
79
- const currentToolCalls = new Map<number, { id: string; name: string; args: string }>()
80
- let usage: Usage = { in: 0, out: 0 }
81
-
82
- try {
83
- const body = {
84
- model: opts.model.id,
85
- messages: [{ role: "system", content: opts.system }, ...opts.messages.map(msgToOpenAI)],
86
- tools: opts.tools.length > 0 ? toolsToOpenAI(opts.tools) : undefined,
87
- stream: true,
88
- }
89
-
90
- const response = await fetch(`${opts.baseUrl}/chat/completions`, {
91
- method: "POST",
92
- headers: {
93
- "Content-Type": "application/json",
94
- Authorization: `Bearer ${opts.apiKey}`,
95
- },
96
- body: JSON.stringify(body),
97
- signal: opts.signal,
98
- })
99
-
100
- if (!response.ok) {
101
- const text = await response.text()
102
- const errorMsg = `API error ${response.status}: ${text}`
103
- es.push({ type: "text_delta", text: errorMsg })
104
- es.finish({
105
- content: [{ type: "text", text: errorMsg }],
106
- usage: { in: 0, out: 0 },
107
- stop: "error",
108
- })
109
- return
110
- }
111
-
112
- const reader = response.body?.getReader()
113
- if (!reader) {
114
- es.finish({ content: [], usage: { in: 0, out: 0 }, stop: "error" })
115
- return
116
- }
117
-
118
- const decoder = new TextDecoder()
119
- let buffer = ""
120
- let stop = "stop"
121
-
122
- while (true) {
123
- const { done, value } = await reader.read()
124
- if (done) break
125
-
126
- buffer += decoder.decode(value, { stream: true })
127
- const lines = buffer.split("\n")
128
- buffer = lines.pop() ?? ""
129
-
130
- for (const line of lines) {
131
- const trimmed = line.trim()
132
- if (!trimmed?.startsWith("data: ")) continue
133
- const data = trimmed.slice(6)
134
- if (data === "[DONE]") continue
135
-
136
- try {
137
- const chunk = JSON.parse(data)
138
- const delta = chunk.choices?.[0]?.delta
139
- if (!delta) continue
140
-
141
- if (delta.content) {
142
- es.push({ type: "text_delta", text: delta.content })
143
- textContent += delta.content
144
- }
145
-
146
- if (delta.tool_calls) {
147
- for (const tc of delta.tool_calls) {
148
- const idx = tc.index ?? 0
149
- if (!currentToolCalls.has(idx)) {
150
- currentToolCalls.set(idx, {
151
- id: tc.id ?? "",
152
- name: tc.function?.name ?? "",
153
- args: "",
154
- })
155
- }
156
- const existing = currentToolCalls.get(idx)!
157
- if (tc.id) existing.id = tc.id
158
- if (tc.function?.name) existing.name = tc.function.name
159
- if (tc.function?.arguments) existing.args += tc.function.arguments
160
- }
161
- }
162
-
163
- if (chunk.usage) {
164
- usage = {
165
- in: chunk.usage.prompt_tokens ?? 0,
166
- out: chunk.usage.completion_tokens ?? 0,
167
- }
168
- es.push({ type: "usage", usage })
169
- }
170
-
171
- const finishReason = chunk.choices?.[0]?.finish_reason
172
- if (finishReason) stop = finishReason
173
- } catch {
174
- // Skip malformed JSON chunks
175
- }
176
- }
177
- }
178
-
179
- const content: AssistantResult["content"] = []
180
- if (textContent) {
181
- content.push({ type: "text", text: textContent })
182
- }
183
- for (const [, tc] of currentToolCalls) {
184
- content.push({
185
- type: "tool_call",
186
- id: tc.id,
187
- name: tc.name,
188
- args: JSON.parse(tc.args || "{}"),
189
- })
190
- es.push({
191
- type: "tool_call",
192
- call: {
193
- type: "tool_call",
194
- id: tc.id,
195
- name: tc.name,
196
- args: JSON.parse(tc.args || "{}"),
197
- },
198
- })
199
- stop = "tool_use"
200
- }
201
-
202
- es.finish({ content, usage, stop: stop as StopReason })
203
- } catch (e) {
204
- if (opts.signal?.aborted) {
205
- const content: AssistantResult["content"] = []
206
- if (textContent) {
207
- content.push({ type: "text", text: textContent })
208
- }
209
- for (const [, tc] of currentToolCalls) {
210
- try {
211
- content.push({
212
- type: "tool_call",
213
- id: tc.id,
214
- name: tc.name,
215
- args: JSON.parse(tc.args || "{}"),
216
- })
217
- } catch {
218
- // skip malformed
219
- }
220
- }
221
- es.finish({
222
- content,
223
- usage,
224
- stop: "aborted",
225
- })
226
- return
227
- }
228
- const errorMsg = `Unexpected error: ${e instanceof Error ? e.message : String(e)}`
229
- es.push({ type: "text_delta", text: errorMsg })
230
- es.finish({
231
- content: [{ type: "text", text: errorMsg }],
232
- usage: { in: 0, out: 0 },
233
- stop: "error",
234
- })
235
- }
236
- })()
237
-
238
- return es
239
- }
@@ -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,159 +0,0 @@
1
- import { getProvider } from "../config/providers.ts"
2
- import { stream } from "../provider/stream.ts"
3
- import type { CompactResult, 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 function needsCompact(messages: Msg[], contextWindow: number): boolean {
28
- return estimateTokens(messages) > contextWindow * COMPACT_THRESHOLD
29
- }
30
-
31
- export async function compact(
32
- store: SessionStore,
33
- sessionId: string,
34
- messages: Msg[],
35
- model: Model,
36
- apiKey: string,
37
- baseUrl: string,
38
- ): Promise<CompactResult> {
39
- if (!needsCompact(messages, model.contextWindow)) {
40
- return { compacted: false, msgsRemoved: 0 }
41
- }
42
-
43
- const old = messages.slice(0, -KEEP_RECENT)
44
- if (old.length === 0) {
45
- return { compacted: false, msgsRemoved: 0 }
46
- }
47
- const convo = old
48
- .map((m) => {
49
- if (m.role === "user") return `User: ${extractText(m)}`
50
- if (m.role === "assistant") return `Assistant: ${extractText(m)}`
51
- if (m.role === "tool_result" && "tool" in m)
52
- return `Tool(${m.tool}): ${extractText(m).slice(0, 200)}`
53
- return ""
54
- })
55
- .join("\n\n")
56
-
57
- const summary = await generateSummary(convo, model, apiKey, baseUrl)
58
- if (!summary) {
59
- return { compacted: false, msgsRemoved: 0 }
60
- }
61
-
62
- const filesRead: string[] = []
63
- const filesWrote: string[] = []
64
- for (const m of old) {
65
- filesRead.push(...extractToolFiles(m, "read"))
66
- filesRead.push(...extractToolFiles(m, "glob"))
67
- filesWrote.push(...extractToolFiles(m, "write"))
68
- filesWrote.push(...extractToolFiles(m, "edit"))
69
- }
70
-
71
- const seqBefore = old.length
72
- await store.saveCompaction(
73
- sessionId,
74
- summary,
75
- [...new Set(filesRead)],
76
- [...new Set(filesWrote)],
77
- seqBefore,
78
- )
79
- await store.truncateBeforeSeq(sessionId, seqBefore + 1)
80
-
81
- // Insert the summary as a user message so the model retains context
82
- const summaryMsg: Msg = {
83
- role: "user",
84
- content: `[Prior context summary]\n${summary}`,
85
- ts: Date.now(),
86
- }
87
- await store.append(sessionId, summaryMsg)
88
-
89
- return { compacted: true, summary, msgsRemoved: old.length }
90
- }
91
-
92
- async function generateSummary(
93
- convo: string,
94
- model: Model,
95
- apiKey: string,
96
- baseUrl: string,
97
- ): Promise<string | null> {
98
- const provider = getProvider(model.provider)
99
- if (!provider) return null
100
-
101
- const es = stream({
102
- api: provider.api,
103
- model,
104
- apiKey,
105
- baseUrl,
106
- system:
107
- "Summarize this coding session concisely. Cover: what was asked, files touched, what was done, key decisions. Keep it under 300 words.",
108
- messages: [{ role: "user", content: convo, ts: Date.now() }],
109
- tools: [],
110
- })
111
-
112
- let summary = ""
113
- for await (const ev of es) {
114
- if (ev.type === "text_delta" && ev.text) {
115
- summary += ev.text
116
- }
117
- }
118
-
119
- return summary.trim() || null
120
- }
121
-
122
- export async function generateSessionTitle(
123
- messages: Msg[],
124
- model: Model,
125
- apiKey: string,
126
- baseUrl: string,
127
- ): Promise<string | null> {
128
- const provider = getProvider(model.provider)
129
- if (!provider) return null
130
-
131
- const convo = messages
132
- .slice(0, 4)
133
- .map((m) => {
134
- if (m.role === "user") return `User: ${extractText(m)}`
135
- if (m.role === "assistant") return `Assistant: ${extractText(m)}`
136
- return ""
137
- })
138
- .join("\n")
139
-
140
- const es = stream({
141
- api: provider.api,
142
- model,
143
- apiKey,
144
- baseUrl,
145
- system:
146
- "Generate a very short, descriptive, and concise title for this coding conversation. Do not use quotes or prefixes like 'Title:'. Max 6 words.",
147
- messages: [{ role: "user", content: convo, ts: Date.now() }],
148
- tools: [],
149
- })
150
-
151
- let title = ""
152
- for await (const ev of es) {
153
- if (ev.type === "text_delta" && ev.text) {
154
- title += ev.text
155
- }
156
- }
157
-
158
- return title.trim().replace(/^["']|["']$/g, "") || null
159
- }