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/README.md +16 -23
- package/dist/app-bQ9a_p_K.mjs +22 -0
- package/dist/app-bQ9a_p_K.mjs.map +1 -0
- package/dist/main.mjs +33 -56
- package/dist/main.mjs.map +1 -1
- package/package.json +3 -4
- package/src/commands/compact.ts +1 -1
- package/src/commands/index.ts +46 -4
- package/src/commands/session.ts +23 -11
- package/src/main.ts +57 -27
- package/src/provider/gemini.ts +11 -3
- package/src/provider/openai.ts +28 -4
- package/src/provider/stream.ts +1 -3
- package/src/session/compact.ts +43 -10
- package/src/session/store.ts +170 -167
- package/src/tools/web.ts +1 -1
- package/src/tui/app.tsx +118 -221
- package/src/tui/components/liveArea.tsx +70 -0
- package/src/tui/components/message.tsx +117 -0
- package/src/tui/components/statusBar.tsx +64 -0
- package/src/tui/constants.ts +25 -0
- package/src/types.ts +14 -0
- package/src/util.ts +19 -0
- package/dist/app-BZ42XPxw.mjs +0 -21
- package/dist/app-BZ42XPxw.mjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "novacode",
|
|
3
|
-
"version": "0.
|
|
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",
|
package/src/commands/compact.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
97
|
+
if (onExit) {
|
|
98
|
+
onExit()
|
|
99
|
+
} else {
|
|
100
|
+
process.exit(0)
|
|
101
|
+
}
|
|
64
102
|
return null
|
|
65
103
|
case "exit":
|
|
66
|
-
|
|
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`)
|
package/src/commands/session.ts
CHANGED
|
@@ -1,31 +1,43 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SessionStore } from "../session/store.ts"
|
|
2
|
+
import { formatRelativeTime } from "../util.ts"
|
|
2
3
|
|
|
3
|
-
export async function handleSessionCommand(
|
|
4
|
-
|
|
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
|
|
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), "
|
|
15
|
-
console.log("-".repeat(
|
|
19
|
+
console.log("ID".padEnd(25), "TITLE / UPDATED")
|
|
20
|
+
console.log("-".repeat(60))
|
|
16
21
|
for (const s of sessions) {
|
|
17
|
-
const
|
|
18
|
-
|
|
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:
|
|
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.
|
|
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:
|
|
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
|
|
56
|
-
nova update
|
|
57
|
-
nova session
|
|
58
|
-
nova --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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
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,
|
package/src/provider/gemini.ts
CHANGED
|
@@ -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)
|
|
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({
|
package/src/provider/openai.ts
CHANGED
|
@@ -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)
|
|
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({
|
package/src/provider/stream.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/session/compact.ts
CHANGED
|
@@ -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
|
+
}
|