novacode 0.5.3 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novacode",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "Open-source multi-provider coding agent.",
5
5
  "type": "module",
6
6
  "main": "./dist/main.mjs",
@@ -21,7 +21,8 @@
21
21
  "lint:fix": "biome check --write .",
22
22
  "format": "biome format --write .",
23
23
  "typecheck": "tsc --noEmit",
24
- "check": "npm run build && npm run typecheck && npm run lint && npm test"
24
+ "check": "npm run build && npm run typecheck && npm run lint && npm test",
25
+ "benchmark": "node --import tsx/esm test/benchmark.ts"
25
26
  },
26
27
  "keywords": [
27
28
  "coding-agent",
@@ -40,7 +41,6 @@
40
41
  },
41
42
  "devDependencies": {
42
43
  "@biomejs/biome": "^2.4.15",
43
- "@types/better-sqlite3": "^7.6.13",
44
44
  "@types/node": "^24.5.2",
45
45
  "@types/react": "^19.2.14",
46
46
  "@types/semver": "^7.7.1",
@@ -54,7 +54,6 @@
54
54
  "node": ">=24"
55
55
  },
56
56
  "dependencies": {
57
- "better-sqlite3": "^12.10.0",
58
57
  "chalk": "^5.6.2",
59
58
  "glob": "^13.0.6",
60
59
  "ink": "^7.0.3",
@@ -19,7 +19,7 @@ export async function handleCompact(
19
19
 
20
20
  if (res.compacted) {
21
21
  // Update agent messages
22
- const msgs = store.messages(sessionId)
22
+ const msgs = await store.messages(sessionId)
23
23
  agent.setMessages(msgs)
24
24
  return chalk.green(`✓ Context compacted (${res.msgsRemoved} messages removed)`)
25
25
  }
@@ -3,6 +3,7 @@ import type { Agent } from "../agent/agent.ts"
3
3
  import type { SessionStore } from "../session/store.ts"
4
4
  import type { Cmd, Prompts } from "../types.ts"
5
5
  import { checkForUpdate, runUpdate } from "../update.ts"
6
+ import { formatRelativeTime } from "../util.ts"
6
7
  import { handleCompact } from "./compact.ts"
7
8
  import { handleModels } from "./models.ts"
8
9
  import { handleProviders } from "./providers.ts"
@@ -11,6 +12,8 @@ export const COMMANDS: Cmd[] = [
11
12
  { name: "models", desc: "Switch model", aliases: ["model"] },
12
13
  { name: "providers", desc: "Manage providers", aliases: ["prov", "config", "cfg"] },
13
14
  { name: "compact", desc: "Compact context" },
15
+ { name: "sessions", desc: "List and switch sessions" },
16
+ { name: "resume", desc: "Resume previous session" },
14
17
  { name: "update", desc: "Update novacode" },
15
18
  { name: "help", desc: "Show help" },
16
19
  { name: "clear", desc: "Clear screen" },
@@ -22,8 +25,8 @@ ${chalk.bold("Commands:")}
22
25
  ${COMMANDS.map((c) => ` /${c.name.padEnd(12)} ${c.desc}`).join("\n")}
23
26
 
24
27
  ${chalk.bold("CLI:")}
25
- nova update Update to latest version
26
- nova session ls List sessions
28
+ nova update Update to latest version
29
+ nova --session ls List sessions
27
30
 
28
31
  ${chalk.dim("Keys:")}
29
32
  Esc Abort
@@ -36,6 +39,8 @@ export async function dispatch(
36
39
  store?: SessionStore,
37
40
  sessionId?: string,
38
41
  prompts?: Prompts,
42
+ onExit?: () => void,
43
+ onSwitchSession?: (sessionId: string) => Promise<void>,
39
44
  ): Promise<string | null> {
40
45
  const [cmd, ...rest] = input.slice(1).split(" ")
41
46
  const args = rest.join(" ")
@@ -52,6 +57,35 @@ export async function dispatch(
52
57
  case "compact":
53
58
  if (!store || !sessionId) return chalk.red("Session store not available")
54
59
  return handleCompact(agent, store, sessionId)
60
+ case "sessions": {
61
+ if (!store || !prompts || !onSwitchSession)
62
+ return chalk.red("Session switching not available")
63
+ const sessions = await store.list(50)
64
+ if (sessions.length === 0) return chalk.yellow("No sessions found.")
65
+ const options = sessions.map((s) => {
66
+ const relTime = formatRelativeTime(s.updated)
67
+ let label = s.title ? `"${s.title}"` : `Session: ${s.id}`
68
+ if (s.id === sessionId) {
69
+ label = s.title ? `Current: "${s.title}"` : "Current Session"
70
+ }
71
+ return {
72
+ value: s.id,
73
+ label,
74
+ hint: relTime,
75
+ }
76
+ })
77
+ const selectedId = await prompts.select({
78
+ message: "Select a session to load:",
79
+ options,
80
+ })
81
+ if (selectedId) {
82
+ await onSwitchSession(selectedId)
83
+ return chalk.green(`✓ Switched to session: ${selectedId}`)
84
+ }
85
+ return chalk.yellow("Session selection cancelled.")
86
+ }
87
+ case "resume":
88
+ return "Use `nova --resume` from the CLI to resume your last session."
55
89
  case "update":
56
90
  return handleUpdate()
57
91
  case "help":
@@ -60,10 +94,18 @@ export async function dispatch(
60
94
  console.clear()
61
95
  return ""
62
96
  case "quit":
63
- process.exit(0)
97
+ if (onExit) {
98
+ onExit()
99
+ } else {
100
+ process.exit(0)
101
+ }
64
102
  return null
65
103
  case "exit":
66
- process.exit(0)
104
+ if (onExit) {
105
+ onExit()
106
+ } else {
107
+ process.exit(0)
108
+ }
67
109
  return null
68
110
  default:
69
111
  return chalk.yellow(`Unknown: /${cmd}. Type /help`)
@@ -1,31 +1,43 @@
1
- import { getSessionStore } from "../session/store.ts"
1
+ import type { SessionStore } from "../session/store.ts"
2
+ import { formatRelativeTime } from "../util.ts"
2
3
 
3
- export async function handleSessionCommand(args: string[]): Promise<void> {
4
- const store = getSessionStore()
4
+ export async function handleSessionCommand(
5
+ store: SessionStore,
6
+ args: string[],
7
+ opts: { limit?: number; all?: boolean } = {},
8
+ ): Promise<void> {
5
9
  const [subcommand, id] = args
6
10
 
7
11
  if (subcommand === "list" || subcommand === "ls") {
8
- const sessions = store.list()
12
+ const limit = opts.limit ?? 10
13
+ const sessions = await store.list(limit)
9
14
  if (sessions.length === 0) {
10
15
  console.log("No sessions found.")
11
16
  return
12
17
  }
13
18
 
14
- console.log("ID".padEnd(25), "MODEL".padEnd(20), "UPDATED")
15
- console.log("-".repeat(70))
19
+ console.log("ID".padEnd(25), "TITLE / UPDATED")
20
+ console.log("-".repeat(60))
16
21
  for (const s of sessions) {
17
- const date = new Date(s.updated).toLocaleString()
18
- console.log(s.id.padEnd(25), s.model.padEnd(20), date)
22
+ const relTime = formatRelativeTime(s.updated)
23
+ const titleOrUpdated = s.title ? `"${s.title}" (${relTime})` : relTime
24
+ console.log(s.id.padEnd(25), titleOrUpdated)
19
25
  }
20
26
  return
21
27
  }
22
28
 
23
29
  if (subcommand === "delete" || subcommand === "rm") {
30
+ if (opts.all) {
31
+ await store.deleteAll()
32
+ console.log("All sessions deleted.")
33
+ return
34
+ }
35
+
24
36
  if (!id) {
25
- console.error("Usage: novacode session delete <id>")
37
+ console.error("Usage: nova --session rm <id> or --session rm --all")
26
38
  process.exit(1)
27
39
  }
28
- const success = store.delete(id)
40
+ const success = await store.delete(id)
29
41
  if (success) {
30
42
  console.log(`Deleted session: ${id}`)
31
43
  } else {
@@ -35,6 +47,6 @@ export async function handleSessionCommand(args: string[]): Promise<void> {
35
47
  return
36
48
  }
37
49
 
38
- console.error("Unknown session subcommand. Use 'list' or 'delete'.")
50
+ console.error("Unknown session subcommand.")
39
51
  process.exit(1)
40
52
  }
package/src/main.ts CHANGED
@@ -13,6 +13,7 @@ import { configExists, loadAuth, loadConfig } from "./config/store.ts"
13
13
  import { runOnboarding } from "./onboarding/wizard.ts"
14
14
  import { getSessionStore } from "./session/store.ts"
15
15
  import { getAllTools } from "./tools/index.ts"
16
+ import type { Session } from "./types.ts"
16
17
  import { getCurrentVersion, runUpdate } from "./update.ts"
17
18
 
18
19
  function parseCli() {
@@ -24,8 +25,12 @@ function parseCli() {
24
25
  model: { type: "string" },
25
26
  "api-key": { type: "string" },
26
27
  session: { type: "string", short: "s" },
28
+ resume: { type: "boolean" },
29
+ n: { type: "string" },
30
+ limit: { type: "string" },
31
+ all: { type: "boolean" },
27
32
  },
28
- strict: true,
33
+ strict: false,
29
34
  allowPositionals: true,
30
35
  })
31
36
 
@@ -52,10 +57,14 @@ async function main() {
52
57
  console.log(`nova — open-source coding agent
53
58
 
54
59
  Usage:
55
- nova Interactive mode
56
- nova update Update to latest version
57
- nova session <cmd> Session management (list, delete)
58
- nova --session <id> Resume a session
60
+ nova Interactive mode
61
+ nova update Update to latest version
62
+ nova --session ls List sessions (last 10 by default)
63
+ nova --session ls -n N List last N sessions
64
+ nova --session rm <id> Delete a specific session
65
+ nova --session rm --all Delete all sessions
66
+ nova --session <id> Resume a session by ID
67
+ nova --resume Resume the most recent session
59
68
 
60
69
  Options:
61
70
  -h, --help Show help
@@ -63,16 +72,10 @@ Options:
63
72
  --provider <id> Provider to use
64
73
  --model <id> Model to use
65
74
  --api-key <key> API key override
66
- -s, --session <id> Resume session by ID`)
75
+ -s, --session <id> Resume/manage session`)
67
76
  process.exit(0)
68
77
  }
69
78
 
70
- // Handle session subcommand
71
- if (args[0] === "session") {
72
- await handleSessionCommand(args.slice(1))
73
- return
74
- }
75
-
76
79
  // Handle update subcommand
77
80
  if (args[0] === "update") {
78
81
  await runUpdate()
@@ -80,7 +83,7 @@ Options:
80
83
  }
81
84
 
82
85
  // Reject positional args — use interactive mode with / commands
83
- if (args.length > 0) {
86
+ if (args.length > 0 && !flags.session) {
84
87
  console.error(chalk.yellow(`Unknown command: ${args.join(" ")}`))
85
88
  console.error("Run `nova --help` for usage.")
86
89
  process.exit(1)
@@ -100,9 +103,43 @@ Options:
100
103
  const config = await ((await configExists()) ? loadConfig() : runOnboarding())
101
104
  const auth = await loadAuth()
102
105
 
103
- // CLI overrides
104
- const providerId = (flags.provider as string) || config.provider
105
- const modelId = (flags.model as string) || config.model
106
+ const store = await getSessionStore()
107
+ await store.prune()
108
+
109
+ // Handle --session commands (ls, rm)
110
+ if (flags.session) {
111
+ const sessionFlag = flags.session as string
112
+ if (sessionFlag === "ls" || sessionFlag === "list") {
113
+ const limit = parseInt((flags.n as string) || (flags.limit as string) || "10", 10)
114
+ await handleSessionCommand(store, ["ls"], { limit })
115
+ return
116
+ }
117
+ if (sessionFlag === "rm" || sessionFlag === "delete") {
118
+ const id = args[0]
119
+ const all = !!flags.all
120
+ await handleSessionCommand(store, ["rm", id ?? ""], { all })
121
+ return
122
+ }
123
+ }
124
+
125
+ let session: Session | null = null
126
+ if (flags.resume) {
127
+ session = await store.latest()
128
+ if (!session) {
129
+ console.error("No recent session found to resume.")
130
+ process.exit(1)
131
+ }
132
+ } else if (flags.session) {
133
+ session = await store.get(flags.session as string)
134
+ if (!session) {
135
+ console.error(`Session not found: ${flags.session}`)
136
+ process.exit(1)
137
+ }
138
+ }
139
+
140
+ // CLI overrides or session default or config default
141
+ const providerId = (flags.provider as string) || session?.provider || config.provider
142
+ const modelId = (flags.model as string) || session?.model || config.model
106
143
  const apiKey = (flags["api-key"] as string) || auth.apiKeys[providerId]
107
144
 
108
145
  const provider = getProvider(providerId)
@@ -133,19 +170,12 @@ Options:
133
170
  const tools = getAllTools(cwd)
134
171
  const system = buildSystemPrompt(cwd, tools)
135
172
 
136
- // Session persistence
137
- const store = getSessionStore()
138
- const session = flags.session
139
- ? store.get(flags.session as string)
140
- : store.create(cwd, model.id, providerId)
141
-
142
- if (flags.session && !session) {
143
- console.error(`Session not found: ${flags.session}`)
144
- process.exit(1)
173
+ if (!session) {
174
+ session = await store.create(cwd, model.id, providerId)
145
175
  }
146
176
 
147
- const sessionId = session!.id
148
- const existingMessages = store.messages(sessionId)
177
+ const sessionId = session.id
178
+ const existingMessages = await store.messages(sessionId)
149
179
 
150
180
  const agent = new Agent({
151
181
  api: provider.api,
@@ -97,6 +97,9 @@ export const streamGemini: StreamFn = (
97
97
  const es = new EventStream<StreamEvent, AssistantResult>()
98
98
 
99
99
  ;(async () => {
100
+ let usage: Usage = { in: 0, out: 0 }
101
+ const content: ContentPart[] = []
102
+
100
103
  try {
101
104
  const baseUrl = opts.baseUrl || "https://generativelanguage.googleapis.com"
102
105
  const url = `${baseUrl}/v1beta/models/${opts.model.id}:streamGenerateContent?alt=sse&key=${opts.apiKey}`
@@ -148,9 +151,7 @@ export const streamGemini: StreamFn = (
148
151
 
149
152
  const decoder = new TextDecoder()
150
153
  let buffer = ""
151
- let usage: Usage = { in: 0, out: 0 }
152
154
  let stop: StopReason = "stop"
153
- const content: ContentPart[] = []
154
155
 
155
156
  while (true) {
156
157
  const { done, value } = await reader.read()
@@ -246,7 +247,14 @@ export const streamGemini: StreamFn = (
246
247
 
247
248
  es.finish({ content, usage, stop })
248
249
  } catch (e) {
249
- if (opts.signal?.aborted) return
250
+ if (opts.signal?.aborted) {
251
+ es.finish({
252
+ content,
253
+ usage,
254
+ stop: "aborted",
255
+ })
256
+ return
257
+ }
250
258
  const errorMsg = `Gemini Network/Request Error: ${e instanceof Error ? e.message : String(e)}`
251
259
  es.push({ type: "text_delta", text: errorMsg })
252
260
  es.finish({
@@ -75,6 +75,10 @@ export const streamOpenAI: StreamFn = (
75
75
  const es = new EventStream<StreamEvent, AssistantResult>()
76
76
 
77
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
+
78
82
  try {
79
83
  const body = {
80
84
  model: opts.model.id,
@@ -113,9 +117,6 @@ export const streamOpenAI: StreamFn = (
113
117
 
114
118
  const decoder = new TextDecoder()
115
119
  let buffer = ""
116
- const currentToolCalls = new Map<number, { id: string; name: string; args: string }>()
117
- let usage: Usage = { in: 0, out: 0 }
118
- let textContent = ""
119
120
  let stop = "stop"
120
121
 
121
122
  while (true) {
@@ -200,7 +201,30 @@ export const streamOpenAI: StreamFn = (
200
201
 
201
202
  es.finish({ content, usage, stop: stop as StopReason })
202
203
  } catch (e) {
203
- if (opts.signal?.aborted) return
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
+ }
204
228
  const errorMsg = `Unexpected error: ${e instanceof Error ? e.message : String(e)}`
205
229
  es.push({ type: "text_delta", text: errorMsg })
206
230
  es.finish({
@@ -65,10 +65,8 @@ export class EventStream<T, R> {
65
65
  const item = await new Promise<T | undefined>((resolve) => {
66
66
  this.#resolve = resolve as (value: T) => void
67
67
  })
68
- if (item !== undefined && this.#events.length === 0) {
68
+ if (item !== undefined) {
69
69
  yield item
70
- } else if (this.#events.length > 0) {
71
- yield this.#events.shift() as T
72
70
  }
73
71
  }
74
72
  }
@@ -1,6 +1,6 @@
1
1
  import { getProvider } from "../config/providers.ts"
2
2
  import { stream } from "../provider/stream.ts"
3
- import type { Model, Msg } from "../types.ts"
3
+ import type { CompactResult, Model, Msg } from "../types.ts"
4
4
  import { estimateTokens } from "../util.ts"
5
5
  import type { SessionStore } from "./store.ts"
6
6
 
@@ -24,12 +24,6 @@ function extractToolFiles(msg: Msg, toolName: string): string[] {
24
24
  return lines.filter((l) => l.trim().length > 0)
25
25
  }
26
26
 
27
- export interface CompactResult {
28
- compacted: boolean
29
- summary?: string
30
- msgsRemoved: number
31
- }
32
-
33
27
  export function needsCompact(messages: Msg[], contextWindow: number): boolean {
34
28
  return estimateTokens(messages) > contextWindow * COMPACT_THRESHOLD
35
29
  }
@@ -75,14 +69,14 @@ export async function compact(
75
69
  }
76
70
 
77
71
  const seqBefore = old.length
78
- store.saveCompaction(
72
+ await store.saveCompaction(
79
73
  sessionId,
80
74
  summary,
81
75
  [...new Set(filesRead)],
82
76
  [...new Set(filesWrote)],
83
77
  seqBefore,
84
78
  )
85
- store.truncateBeforeSeq(sessionId, seqBefore + 1)
79
+ await store.truncateBeforeSeq(sessionId, seqBefore + 1)
86
80
 
87
81
  // Insert the summary as a user message so the model retains context
88
82
  const summaryMsg: Msg = {
@@ -90,7 +84,7 @@ export async function compact(
90
84
  content: `[Prior context summary]\n${summary}`,
91
85
  ts: Date.now(),
92
86
  }
93
- store.append(sessionId, summaryMsg)
87
+ await store.append(sessionId, summaryMsg)
94
88
 
95
89
  return { compacted: true, summary, msgsRemoved: old.length }
96
90
  }
@@ -124,3 +118,42 @@ async function generateSummary(
124
118
 
125
119
  return summary.trim() || null
126
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
+ }