kanna-code 0.15.0 → 0.16.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 +10 -0
- package/dist/client/assets/index-BET6YVYr.css +32 -0
- package/dist/client/assets/index-DMxCijCf.js +533 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent.ts +4 -2
- package/src/server/cli-runtime.ts +5 -1
- package/src/server/codex-app-server-protocol.ts +8 -1
- package/src/server/codex-app-server.ts +1 -1
- package/src/server/event-store.test.ts +112 -1
- package/src/server/event-store.ts +170 -26
- package/src/server/events.ts +1 -3
- package/src/server/provider-catalog.test.ts +29 -1
- package/src/server/provider-catalog.ts +8 -1
- package/src/server/read-models.test.ts +1 -1
- package/src/server/read-models.ts +3 -3
- package/src/server/server.ts +2 -0
- package/src/server/ws-router.ts +1 -1
- package/src/shared/types.ts +40 -2
- package/dist/client/assets/index-C6R1P_HL.js +0 -523
- package/dist/client/assets/index-CA-kJR8F.css +0 -32
package/dist/client/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
7
7
|
<title>Kanna</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-DMxCijCf.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BET6YVYr.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
package/src/server/agent.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
normalizeCodexModelOptions,
|
|
20
20
|
normalizeServerModel,
|
|
21
21
|
} from "./provider-catalog"
|
|
22
|
+
import { resolveClaudeApiModelId } from "../shared/types"
|
|
22
23
|
|
|
23
24
|
const CLAUDE_TOOLSET = [
|
|
24
25
|
"Skill",
|
|
@@ -365,9 +366,10 @@ export class AgentCoordinator {
|
|
|
365
366
|
private getProviderSettings(provider: AgentProvider, command: Extract<ClientCommand, { type: "chat.send" }>) {
|
|
366
367
|
const catalog = getServerProviderCatalog(provider)
|
|
367
368
|
if (provider === "claude") {
|
|
368
|
-
const
|
|
369
|
+
const model = normalizeServerModel(provider, command.model)
|
|
370
|
+
const modelOptions = normalizeClaudeModelOptions(model, command.modelOptions, command.effort)
|
|
369
371
|
return {
|
|
370
|
-
model:
|
|
372
|
+
model: resolveClaudeApiModelId(model, modelOptions.contextWindow),
|
|
371
373
|
effort: modelOptions.reasoningEffort,
|
|
372
374
|
serviceTier: undefined,
|
|
373
375
|
planMode: catalog.supportsPlanMode ? Boolean(command.planMode) : false,
|
|
@@ -41,7 +41,10 @@ export type CliRunResult = StartedCli | RestartingCli | ExitedCli
|
|
|
41
41
|
export interface CliRuntimeDeps {
|
|
42
42
|
version: string
|
|
43
43
|
bunVersion: string
|
|
44
|
-
startServer: (options: CliOptions & {
|
|
44
|
+
startServer: (options: CliOptions & {
|
|
45
|
+
update: CliUpdateOptions
|
|
46
|
+
onMigrationProgress?: (message: string) => void
|
|
47
|
+
}) => Promise<{ port: number; stop: () => Promise<void> }>
|
|
45
48
|
fetchLatestVersion: (packageName: string) => Promise<string>
|
|
46
49
|
installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
|
|
47
50
|
openUrl: (url: string) => void
|
|
@@ -218,6 +221,7 @@ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliR
|
|
|
218
221
|
|
|
219
222
|
const { port, stop } = await deps.startServer({
|
|
220
223
|
...parsedArgs.options,
|
|
224
|
+
onMigrationProgress: deps.log,
|
|
221
225
|
update: {
|
|
222
226
|
version: deps.version,
|
|
223
227
|
fetchLatestVersion: deps.fetchLatestVersion,
|
|
@@ -394,7 +394,14 @@ export interface ItemCompletedNotification {
|
|
|
394
394
|
}
|
|
395
395
|
|
|
396
396
|
export interface ErrorNotification {
|
|
397
|
-
|
|
397
|
+
error: {
|
|
398
|
+
message: string
|
|
399
|
+
codexErrorInfo?: string
|
|
400
|
+
additionalDetails?: unknown
|
|
401
|
+
}
|
|
402
|
+
willRetry: boolean
|
|
403
|
+
threadId?: string
|
|
404
|
+
turnId?: string
|
|
398
405
|
}
|
|
399
406
|
|
|
400
407
|
export type ServerNotification =
|
|
@@ -1114,7 +1114,7 @@ export class CodexAppServerManager {
|
|
|
1114
1114
|
this.handleContextCompacted(pendingTurn, notification.params)
|
|
1115
1115
|
return
|
|
1116
1116
|
case "error":
|
|
1117
|
-
this.failContext(context, notification.params.message)
|
|
1117
|
+
this.failContext(context, notification.params.error.message)
|
|
1118
1118
|
return
|
|
1119
1119
|
default:
|
|
1120
1120
|
return
|
|
@@ -1,16 +1,39 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { existsSync } from "node:fs"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { tmpdir } from "node:os"
|
|
6
|
+
import type { TranscriptEntry } from "../shared/types"
|
|
7
|
+
import type { SnapshotFile } from "./events"
|
|
2
8
|
import { EventStore } from "./event-store"
|
|
3
9
|
|
|
4
10
|
const originalRuntimeProfile = process.env.KANNA_RUNTIME_PROFILE
|
|
11
|
+
const tempDirs: string[] = []
|
|
5
12
|
|
|
6
|
-
afterEach(() => {
|
|
13
|
+
afterEach(async () => {
|
|
7
14
|
if (originalRuntimeProfile === undefined) {
|
|
8
15
|
delete process.env.KANNA_RUNTIME_PROFILE
|
|
9
16
|
} else {
|
|
10
17
|
process.env.KANNA_RUNTIME_PROFILE = originalRuntimeProfile
|
|
11
18
|
}
|
|
19
|
+
|
|
20
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
|
12
21
|
})
|
|
13
22
|
|
|
23
|
+
async function createTempDataDir() {
|
|
24
|
+
const dir = await mkdtemp(join(tmpdir(), "kanna-event-store-"))
|
|
25
|
+
tempDirs.push(dir)
|
|
26
|
+
return dir
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function entry(kind: "user_prompt" | "assistant_text", createdAt: number, extra: Record<string, unknown> = {}): TranscriptEntry {
|
|
30
|
+
const base = { _id: `${kind}-${createdAt}`, createdAt }
|
|
31
|
+
if (kind === "user_prompt") {
|
|
32
|
+
return { ...base, kind, content: String(extra.content ?? "") }
|
|
33
|
+
}
|
|
34
|
+
return { ...base, kind, text: String(extra.content ?? extra.text ?? "") }
|
|
35
|
+
}
|
|
36
|
+
|
|
14
37
|
describe("EventStore", () => {
|
|
15
38
|
test("uses the runtime profile for the default data dir", () => {
|
|
16
39
|
process.env.KANNA_RUNTIME_PROFILE = "dev"
|
|
@@ -19,4 +42,92 @@ describe("EventStore", () => {
|
|
|
19
42
|
|
|
20
43
|
expect(store.dataDir).toEndWith("/.kanna-dev/data")
|
|
21
44
|
})
|
|
45
|
+
|
|
46
|
+
test("migrates legacy snapshot and messages log transcripts into per-chat files", async () => {
|
|
47
|
+
const dataDir = await createTempDataDir()
|
|
48
|
+
const snapshotPath = join(dataDir, "snapshot.json")
|
|
49
|
+
const messagesLogPath = join(dataDir, "messages.jsonl")
|
|
50
|
+
const chatId = "chat-1"
|
|
51
|
+
|
|
52
|
+
const snapshot: SnapshotFile = {
|
|
53
|
+
v: 2,
|
|
54
|
+
generatedAt: 10,
|
|
55
|
+
projects: [{
|
|
56
|
+
id: "project-1",
|
|
57
|
+
localPath: "/tmp/project",
|
|
58
|
+
title: "Project",
|
|
59
|
+
createdAt: 1,
|
|
60
|
+
updatedAt: 5,
|
|
61
|
+
}],
|
|
62
|
+
chats: [{
|
|
63
|
+
id: chatId,
|
|
64
|
+
projectId: "project-1",
|
|
65
|
+
title: "Chat",
|
|
66
|
+
createdAt: 1,
|
|
67
|
+
updatedAt: 5,
|
|
68
|
+
provider: null,
|
|
69
|
+
planMode: false,
|
|
70
|
+
sessionToken: null,
|
|
71
|
+
lastTurnOutcome: null,
|
|
72
|
+
}],
|
|
73
|
+
messages: [{
|
|
74
|
+
chatId,
|
|
75
|
+
entries: [
|
|
76
|
+
entry("user_prompt", 100, { content: "hello" }),
|
|
77
|
+
],
|
|
78
|
+
}],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await writeFile(snapshotPath, JSON.stringify(snapshot, null, 2), "utf8")
|
|
82
|
+
await writeFile(messagesLogPath, `${JSON.stringify({
|
|
83
|
+
v: 2,
|
|
84
|
+
type: "message_appended",
|
|
85
|
+
timestamp: 101,
|
|
86
|
+
chatId,
|
|
87
|
+
entry: entry("assistant_text", 101, { content: "world" }),
|
|
88
|
+
})}\n`, "utf8")
|
|
89
|
+
|
|
90
|
+
const store = new EventStore(dataDir)
|
|
91
|
+
await store.initialize()
|
|
92
|
+
|
|
93
|
+
const progress: string[] = []
|
|
94
|
+
const migrated = await store.migrateLegacyTranscripts((message) => {
|
|
95
|
+
progress.push(message)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expect(migrated).toBe(true)
|
|
99
|
+
expect(progress.some((message) => message.includes("transcript migration detected"))).toBe(true)
|
|
100
|
+
expect(progress.at(-1)).toContain("transcript migration complete")
|
|
101
|
+
expect(store.getMessages(chatId)).toEqual([
|
|
102
|
+
entry("user_prompt", 100, { content: "hello" }),
|
|
103
|
+
entry("assistant_text", 101, { text: "world" }),
|
|
104
|
+
])
|
|
105
|
+
|
|
106
|
+
const migratedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8")) as SnapshotFile
|
|
107
|
+
expect(migratedSnapshot.messages).toBeUndefined()
|
|
108
|
+
expect(await readFile(messagesLogPath, "utf8")).toBe("")
|
|
109
|
+
expect(await readFile(join(dataDir, "transcripts", `${chatId}.jsonl`), "utf8")).toContain('"kind":"assistant_text"')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("appends new transcript entries only to the per-chat transcript file", async () => {
|
|
113
|
+
const dataDir = await createTempDataDir()
|
|
114
|
+
const store = new EventStore(dataDir)
|
|
115
|
+
await store.initialize()
|
|
116
|
+
|
|
117
|
+
const project = await store.openProject("/tmp/project")
|
|
118
|
+
const chat = await store.createChat(project.id)
|
|
119
|
+
await store.appendMessage(chat.id, entry("user_prompt", 200, { content: "hello" }))
|
|
120
|
+
await store.appendMessage(chat.id, entry("assistant_text", 201, { content: "world" }))
|
|
121
|
+
await store.compact()
|
|
122
|
+
|
|
123
|
+
expect(store.getMessages(chat.id)).toEqual([
|
|
124
|
+
entry("user_prompt", 200, { content: "hello" }),
|
|
125
|
+
entry("assistant_text", 201, { text: "world" }),
|
|
126
|
+
])
|
|
127
|
+
expect(await readFile(join(dataDir, "messages.jsonl"), "utf8")).toBe("")
|
|
128
|
+
|
|
129
|
+
const snapshot = JSON.parse(await readFile(join(dataDir, "snapshot.json"), "utf8")) as SnapshotFile
|
|
130
|
+
expect(snapshot.messages).toBeUndefined()
|
|
131
|
+
expect(existsSync(join(dataDir, "transcripts", `${chat.id}.jsonl`))).toBe(true)
|
|
132
|
+
})
|
|
22
133
|
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { appendFile, mkdir } from "node:fs/promises"
|
|
1
|
+
import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"
|
|
2
|
+
import { existsSync, readFileSync as readFileSyncImmediate } from "node:fs"
|
|
2
3
|
import { homedir } from "node:os"
|
|
3
4
|
import path from "node:path"
|
|
4
5
|
import { getDataDir, LOG_PREFIX } from "../shared/branding"
|
|
@@ -19,6 +20,13 @@ import { resolveLocalPath } from "./paths"
|
|
|
19
20
|
|
|
20
21
|
const COMPACTION_THRESHOLD_BYTES = 2 * 1024 * 1024
|
|
21
22
|
|
|
23
|
+
interface LegacyTranscriptStats {
|
|
24
|
+
hasLegacyData: boolean
|
|
25
|
+
sources: Array<"snapshot" | "messages_log">
|
|
26
|
+
chatCount: number
|
|
27
|
+
entryCount: number
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
export class EventStore {
|
|
23
31
|
readonly dataDir: string
|
|
24
32
|
readonly state: StoreState = createEmptyState()
|
|
@@ -29,6 +37,10 @@ export class EventStore {
|
|
|
29
37
|
private readonly chatsLogPath: string
|
|
30
38
|
private readonly messagesLogPath: string
|
|
31
39
|
private readonly turnsLogPath: string
|
|
40
|
+
private readonly transcriptsDir: string
|
|
41
|
+
private legacyMessagesByChatId = new Map<string, TranscriptEntry[]>()
|
|
42
|
+
private snapshotHasLegacyMessages = false
|
|
43
|
+
private cachedTranscript: { chatId: string; entries: TranscriptEntry[] } | null = null
|
|
32
44
|
|
|
33
45
|
constructor(dataDir = getDataDir(homedir())) {
|
|
34
46
|
this.dataDir = dataDir
|
|
@@ -37,17 +49,19 @@ export class EventStore {
|
|
|
37
49
|
this.chatsLogPath = path.join(this.dataDir, "chats.jsonl")
|
|
38
50
|
this.messagesLogPath = path.join(this.dataDir, "messages.jsonl")
|
|
39
51
|
this.turnsLogPath = path.join(this.dataDir, "turns.jsonl")
|
|
52
|
+
this.transcriptsDir = path.join(this.dataDir, "transcripts")
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
async initialize() {
|
|
43
56
|
await mkdir(this.dataDir, { recursive: true })
|
|
57
|
+
await mkdir(this.transcriptsDir, { recursive: true })
|
|
44
58
|
await this.ensureFile(this.projectsLogPath)
|
|
45
59
|
await this.ensureFile(this.chatsLogPath)
|
|
46
60
|
await this.ensureFile(this.messagesLogPath)
|
|
47
61
|
await this.ensureFile(this.turnsLogPath)
|
|
48
62
|
await this.loadSnapshot()
|
|
49
63
|
await this.replayLogs()
|
|
50
|
-
if (await this.shouldCompact()) {
|
|
64
|
+
if (!(await this.hasLegacyTranscriptData()) && await this.shouldCompact()) {
|
|
51
65
|
await this.compact()
|
|
52
66
|
}
|
|
53
67
|
}
|
|
@@ -63,6 +77,7 @@ export class EventStore {
|
|
|
63
77
|
if (this.storageReset) return
|
|
64
78
|
this.storageReset = true
|
|
65
79
|
this.resetState()
|
|
80
|
+
this.clearLegacyTranscriptState()
|
|
66
81
|
await Promise.all([
|
|
67
82
|
Bun.write(this.snapshotPath, ""),
|
|
68
83
|
Bun.write(this.projectsLogPath, ""),
|
|
@@ -92,8 +107,11 @@ export class EventStore {
|
|
|
92
107
|
for (const chat of parsed.chats) {
|
|
93
108
|
this.state.chatsById.set(chat.id, { ...chat })
|
|
94
109
|
}
|
|
95
|
-
|
|
96
|
-
this.
|
|
110
|
+
if (parsed.messages?.length) {
|
|
111
|
+
this.snapshotHasLegacyMessages = true
|
|
112
|
+
for (const messageSet of parsed.messages) {
|
|
113
|
+
this.legacyMessagesByChatId.set(messageSet.chatId, cloneTranscriptEntries(messageSet.entries))
|
|
114
|
+
}
|
|
97
115
|
}
|
|
98
116
|
} catch (error) {
|
|
99
117
|
console.warn(`${LOG_PREFIX} Failed to load snapshot, resetting local history:`, error)
|
|
@@ -105,7 +123,12 @@ export class EventStore {
|
|
|
105
123
|
this.state.projectsById.clear()
|
|
106
124
|
this.state.projectIdsByPath.clear()
|
|
107
125
|
this.state.chatsById.clear()
|
|
108
|
-
this.
|
|
126
|
+
this.cachedTranscript = null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private clearLegacyTranscriptState() {
|
|
130
|
+
this.legacyMessagesByChatId.clear()
|
|
131
|
+
this.snapshotHasLegacyMessages = false
|
|
109
132
|
}
|
|
110
133
|
|
|
111
134
|
private async replayLogs() {
|
|
@@ -224,16 +247,10 @@ export class EventStore {
|
|
|
224
247
|
break
|
|
225
248
|
}
|
|
226
249
|
case "message_appended": {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (event.entry.kind === "user_prompt") {
|
|
230
|
-
chat.lastMessageAt = event.entry.createdAt
|
|
231
|
-
}
|
|
232
|
-
chat.updatedAt = Math.max(chat.updatedAt, event.entry.createdAt)
|
|
233
|
-
}
|
|
234
|
-
const existing = this.state.messagesByChatId.get(event.chatId) ?? []
|
|
250
|
+
this.applyMessageMetadata(event.chatId, event.entry)
|
|
251
|
+
const existing = this.legacyMessagesByChatId.get(event.chatId) ?? []
|
|
235
252
|
existing.push({ ...event.entry })
|
|
236
|
-
this.
|
|
253
|
+
this.legacyMessagesByChatId.set(event.chatId, existing)
|
|
237
254
|
break
|
|
238
255
|
}
|
|
239
256
|
case "turn_started": {
|
|
@@ -273,6 +290,15 @@ export class EventStore {
|
|
|
273
290
|
}
|
|
274
291
|
}
|
|
275
292
|
|
|
293
|
+
private applyMessageMetadata(chatId: string, entry: TranscriptEntry) {
|
|
294
|
+
const chat = this.state.chatsById.get(chatId)
|
|
295
|
+
if (!chat) return
|
|
296
|
+
if (entry.kind === "user_prompt") {
|
|
297
|
+
chat.lastMessageAt = entry.createdAt
|
|
298
|
+
}
|
|
299
|
+
chat.updatedAt = Math.max(chat.updatedAt, entry.createdAt)
|
|
300
|
+
}
|
|
301
|
+
|
|
276
302
|
private append<TEvent extends StoreEvent>(filePath: string, event: TEvent) {
|
|
277
303
|
const payload = `${JSON.stringify(event)}\n`
|
|
278
304
|
this.writeChain = this.writeChain.then(async () => {
|
|
@@ -282,6 +308,28 @@ export class EventStore {
|
|
|
282
308
|
return this.writeChain
|
|
283
309
|
}
|
|
284
310
|
|
|
311
|
+
private transcriptPath(chatId: string) {
|
|
312
|
+
return path.join(this.transcriptsDir, `${chatId}.jsonl`)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private loadTranscriptFromDisk(chatId: string) {
|
|
316
|
+
const transcriptPath = this.transcriptPath(chatId)
|
|
317
|
+
if (!existsSync(transcriptPath)) {
|
|
318
|
+
return []
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const text = readFileSyncImmediate(transcriptPath, "utf8")
|
|
322
|
+
if (!text.trim()) return []
|
|
323
|
+
|
|
324
|
+
const entries: TranscriptEntry[] = []
|
|
325
|
+
for (const rawLine of text.split("\n")) {
|
|
326
|
+
const line = rawLine.trim()
|
|
327
|
+
if (!line) continue
|
|
328
|
+
entries.push(JSON.parse(line) as TranscriptEntry)
|
|
329
|
+
}
|
|
330
|
+
return entries
|
|
331
|
+
}
|
|
332
|
+
|
|
285
333
|
async openProject(localPath: string, title?: string) {
|
|
286
334
|
const normalized = resolveLocalPath(localPath)
|
|
287
335
|
const existingId = this.state.projectIdsByPath.get(normalized)
|
|
@@ -392,14 +440,17 @@ export class EventStore {
|
|
|
392
440
|
|
|
393
441
|
async appendMessage(chatId: string, entry: TranscriptEntry) {
|
|
394
442
|
this.requireChat(chatId)
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
entry
|
|
401
|
-
|
|
402
|
-
|
|
443
|
+
const payload = `${JSON.stringify(entry)}\n`
|
|
444
|
+
const transcriptPath = this.transcriptPath(chatId)
|
|
445
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
446
|
+
await mkdir(this.transcriptsDir, { recursive: true })
|
|
447
|
+
await appendFile(transcriptPath, payload, "utf8")
|
|
448
|
+
this.applyMessageMetadata(chatId, entry)
|
|
449
|
+
if (this.cachedTranscript?.chatId === chatId) {
|
|
450
|
+
this.cachedTranscript.entries.push({ ...entry })
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
return this.writeChain
|
|
403
454
|
}
|
|
404
455
|
|
|
405
456
|
async recordTurnStarted(chatId: string) {
|
|
@@ -481,7 +532,19 @@ export class EventStore {
|
|
|
481
532
|
}
|
|
482
533
|
|
|
483
534
|
getMessages(chatId: string) {
|
|
484
|
-
|
|
535
|
+
if (this.cachedTranscript?.chatId === chatId) {
|
|
536
|
+
return cloneTranscriptEntries(this.cachedTranscript.entries)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const legacyEntries = this.legacyMessagesByChatId.get(chatId)
|
|
540
|
+
if (legacyEntries) {
|
|
541
|
+
this.cachedTranscript = { chatId, entries: cloneTranscriptEntries(legacyEntries) }
|
|
542
|
+
return cloneTranscriptEntries(this.cachedTranscript.entries)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const entries = this.loadTranscriptFromDisk(chatId)
|
|
546
|
+
this.cachedTranscript = { chatId, entries }
|
|
547
|
+
return cloneTranscriptEntries(entries)
|
|
485
548
|
}
|
|
486
549
|
|
|
487
550
|
listProjects() {
|
|
@@ -498,7 +561,34 @@ export class EventStore {
|
|
|
498
561
|
return this.listChatsByProject(projectId).length
|
|
499
562
|
}
|
|
500
563
|
|
|
501
|
-
async
|
|
564
|
+
async getLegacyTranscriptStats(): Promise<LegacyTranscriptStats> {
|
|
565
|
+
const messagesLogSize = await Bun.file(this.messagesLogPath).size
|
|
566
|
+
const sources: LegacyTranscriptStats["sources"] = []
|
|
567
|
+
if (this.snapshotHasLegacyMessages) {
|
|
568
|
+
sources.push("snapshot")
|
|
569
|
+
}
|
|
570
|
+
if (messagesLogSize > 0) {
|
|
571
|
+
sources.push("messages_log")
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let entryCount = 0
|
|
575
|
+
for (const entries of this.legacyMessagesByChatId.values()) {
|
|
576
|
+
entryCount += entries.length
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
hasLegacyData: sources.length > 0 || this.legacyMessagesByChatId.size > 0,
|
|
581
|
+
sources,
|
|
582
|
+
chatCount: this.legacyMessagesByChatId.size,
|
|
583
|
+
entryCount,
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async hasLegacyTranscriptData() {
|
|
588
|
+
return (await this.getLegacyTranscriptStats()).hasLegacyData
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private createSnapshot(includeLegacyMessages: boolean): SnapshotFile {
|
|
502
592
|
const snapshot: SnapshotFile = {
|
|
503
593
|
v: STORE_VERSION,
|
|
504
594
|
generatedAt: Date.now(),
|
|
@@ -506,12 +596,20 @@ export class EventStore {
|
|
|
506
596
|
chats: [...this.state.chatsById.values()]
|
|
507
597
|
.filter((chat) => !chat.deletedAt)
|
|
508
598
|
.map((chat) => ({ ...chat })),
|
|
509
|
-
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (includeLegacyMessages) {
|
|
602
|
+
snapshot.messages = [...this.legacyMessagesByChatId.entries()].map(([chatId, entries]) => ({
|
|
510
603
|
chatId,
|
|
511
604
|
entries: cloneTranscriptEntries(entries),
|
|
512
|
-
}))
|
|
605
|
+
}))
|
|
513
606
|
}
|
|
514
607
|
|
|
608
|
+
return snapshot
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async compact() {
|
|
612
|
+
const snapshot = this.createSnapshot(false)
|
|
515
613
|
await Bun.write(this.snapshotPath, JSON.stringify(snapshot, null, 2))
|
|
516
614
|
await Promise.all([
|
|
517
615
|
Bun.write(this.projectsLogPath, ""),
|
|
@@ -521,6 +619,52 @@ export class EventStore {
|
|
|
521
619
|
])
|
|
522
620
|
}
|
|
523
621
|
|
|
622
|
+
private async compactLegacyMessagesIntoSnapshot() {
|
|
623
|
+
const snapshot = this.createSnapshot(true)
|
|
624
|
+
await Bun.write(this.snapshotPath, JSON.stringify(snapshot, null, 2))
|
|
625
|
+
await Promise.all([
|
|
626
|
+
Bun.write(this.projectsLogPath, ""),
|
|
627
|
+
Bun.write(this.chatsLogPath, ""),
|
|
628
|
+
Bun.write(this.messagesLogPath, ""),
|
|
629
|
+
Bun.write(this.turnsLogPath, ""),
|
|
630
|
+
])
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async migrateLegacyTranscripts(onProgress?: (message: string) => void) {
|
|
634
|
+
const stats = await this.getLegacyTranscriptStats()
|
|
635
|
+
if (!stats.hasLegacyData) return false
|
|
636
|
+
|
|
637
|
+
const sourceSummary = stats.sources.map((source) => source === "messages_log" ? "messages.jsonl" : "snapshot.json").join(", ")
|
|
638
|
+
onProgress?.(`${LOG_PREFIX} transcript migration detected: ${stats.chatCount} chats, ${stats.entryCount} entries from ${sourceSummary}`)
|
|
639
|
+
onProgress?.(`${LOG_PREFIX} transcript migration: compacting legacy transcript state into snapshot`)
|
|
640
|
+
await this.compactLegacyMessagesIntoSnapshot()
|
|
641
|
+
|
|
642
|
+
const snapshot = JSON.parse(await readFile(this.snapshotPath, "utf8")) as SnapshotFile
|
|
643
|
+
const messageSets = snapshot.messages ?? []
|
|
644
|
+
onProgress?.(`${LOG_PREFIX} transcript migration: writing ${messageSets.length} per-chat transcript files`)
|
|
645
|
+
|
|
646
|
+
await mkdir(this.transcriptsDir, { recursive: true })
|
|
647
|
+
const logEveryChat = messageSets.length <= 10
|
|
648
|
+
for (let index = 0; index < messageSets.length; index += 1) {
|
|
649
|
+
const { chatId, entries } = messageSets[index]
|
|
650
|
+
const transcriptPath = this.transcriptPath(chatId)
|
|
651
|
+
const tempPath = `${transcriptPath}.tmp`
|
|
652
|
+
const payload = entries.map((entry) => JSON.stringify(entry)).join("\n")
|
|
653
|
+
await writeFile(tempPath, payload ? `${payload}\n` : "", "utf8")
|
|
654
|
+
await rename(tempPath, transcriptPath)
|
|
655
|
+
if (logEveryChat || (index + 1) % 25 === 0 || index === messageSets.length - 1) {
|
|
656
|
+
onProgress?.(`${LOG_PREFIX} transcript migration: ${index + 1}/${messageSets.length} chats`)
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
delete snapshot.messages
|
|
661
|
+
await Bun.write(this.snapshotPath, JSON.stringify(snapshot, null, 2))
|
|
662
|
+
this.clearLegacyTranscriptState()
|
|
663
|
+
this.cachedTranscript = null
|
|
664
|
+
onProgress?.(`${LOG_PREFIX} transcript migration complete`)
|
|
665
|
+
return true
|
|
666
|
+
}
|
|
667
|
+
|
|
524
668
|
private async shouldCompact() {
|
|
525
669
|
const sizes = await Promise.all([
|
|
526
670
|
Bun.file(this.projectsLogPath).size,
|
package/src/server/events.ts
CHANGED
|
@@ -22,7 +22,6 @@ export interface StoreState {
|
|
|
22
22
|
projectsById: Map<string, ProjectRecord>
|
|
23
23
|
projectIdsByPath: Map<string, string>
|
|
24
24
|
chatsById: Map<string, ChatRecord>
|
|
25
|
-
messagesByChatId: Map<string, TranscriptEntry[]>
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
export interface SnapshotFile {
|
|
@@ -30,7 +29,7 @@ export interface SnapshotFile {
|
|
|
30
29
|
generatedAt: number
|
|
31
30
|
projects: ProjectRecord[]
|
|
32
31
|
chats: ChatRecord[]
|
|
33
|
-
messages
|
|
32
|
+
messages?: Array<{ chatId: string; entries: TranscriptEntry[] }>
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
export type ProjectEvent = {
|
|
@@ -133,7 +132,6 @@ export function createEmptyState(): StoreState {
|
|
|
133
132
|
projectsById: new Map(),
|
|
134
133
|
projectIdsByPath: new Map(),
|
|
135
134
|
chatsById: new Map(),
|
|
136
|
-
messagesByChatId: new Map(),
|
|
137
135
|
}
|
|
138
136
|
}
|
|
139
137
|
|
|
@@ -4,11 +4,34 @@ import {
|
|
|
4
4
|
normalizeClaudeModelOptions,
|
|
5
5
|
normalizeCodexModelOptions,
|
|
6
6
|
} from "./provider-catalog"
|
|
7
|
+
import { resolveClaudeApiModelId } from "../shared/types"
|
|
7
8
|
|
|
8
9
|
describe("provider catalog normalization", () => {
|
|
9
10
|
test("maps legacy Claude effort into shared model options", () => {
|
|
10
|
-
expect(normalizeClaudeModelOptions(undefined, "max")).toEqual({
|
|
11
|
+
expect(normalizeClaudeModelOptions("opus", undefined, "max")).toEqual({
|
|
11
12
|
reasoningEffort: "max",
|
|
13
|
+
contextWindow: "200k",
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("normalizes Claude context window only for supported models", () => {
|
|
18
|
+
expect(normalizeClaudeModelOptions("sonnet", {
|
|
19
|
+
claude: {
|
|
20
|
+
reasoningEffort: "medium",
|
|
21
|
+
contextWindow: "1m",
|
|
22
|
+
},
|
|
23
|
+
})).toEqual({
|
|
24
|
+
reasoningEffort: "medium",
|
|
25
|
+
contextWindow: "1m",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
expect(normalizeClaudeModelOptions("haiku", {
|
|
29
|
+
claude: {
|
|
30
|
+
reasoningEffort: "medium",
|
|
31
|
+
contextWindow: "1m",
|
|
32
|
+
},
|
|
33
|
+
})).toEqual({
|
|
34
|
+
reasoningEffort: "medium",
|
|
12
35
|
})
|
|
13
36
|
})
|
|
14
37
|
|
|
@@ -31,4 +54,9 @@ describe("provider catalog normalization", () => {
|
|
|
31
54
|
})
|
|
32
55
|
expect(codexServiceTierFromModelOptions(normalized)).toBe("fast")
|
|
33
56
|
})
|
|
57
|
+
|
|
58
|
+
test("resolves Claude API model ids for 1m context window", () => {
|
|
59
|
+
expect(resolveClaudeApiModelId("opus", "1m")).toBe("opus[1m]")
|
|
60
|
+
expect(resolveClaudeApiModelId("sonnet", "200k")).toBe("sonnet")
|
|
61
|
+
})
|
|
34
62
|
})
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AgentProvider,
|
|
3
3
|
ClaudeModelOptions,
|
|
4
4
|
CodexModelOptions,
|
|
5
|
+
ClaudeContextWindow,
|
|
5
6
|
ModelOptions,
|
|
6
7
|
ProviderCatalogEntry,
|
|
7
8
|
ProviderModelOption,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
DEFAULT_CLAUDE_MODEL_OPTIONS,
|
|
12
13
|
DEFAULT_CODEX_MODEL_OPTIONS,
|
|
13
14
|
PROVIDERS,
|
|
15
|
+
normalizeClaudeContextWindow,
|
|
14
16
|
isClaudeReasoningEffort,
|
|
15
17
|
isCodexReasoningEffort,
|
|
16
18
|
} from "../shared/types"
|
|
@@ -47,7 +49,11 @@ export function normalizeServerModel(provider: AgentProvider, model?: string): s
|
|
|
47
49
|
return catalog.defaultModel
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
export function normalizeClaudeModelOptions(
|
|
52
|
+
export function normalizeClaudeModelOptions(
|
|
53
|
+
model: string,
|
|
54
|
+
modelOptions?: ModelOptions,
|
|
55
|
+
legacyEffort?: string
|
|
56
|
+
): ClaudeModelOptions {
|
|
51
57
|
const reasoningEffort = modelOptions?.claude?.reasoningEffort
|
|
52
58
|
return {
|
|
53
59
|
reasoningEffort: isClaudeReasoningEffort(reasoningEffort)
|
|
@@ -55,6 +61,7 @@ export function normalizeClaudeModelOptions(modelOptions?: ModelOptions, legacyE
|
|
|
55
61
|
: isClaudeReasoningEffort(legacyEffort)
|
|
56
62
|
? legacyEffort
|
|
57
63
|
: DEFAULT_CLAUDE_MODEL_OPTIONS.reasoningEffort,
|
|
64
|
+
contextWindow: normalizeClaudeContextWindow(model, modelOptions?.claude?.contextWindow as ClaudeContextWindow | undefined),
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
67
|
|
|
@@ -51,7 +51,7 @@ describe("read models", () => {
|
|
|
51
51
|
lastTurnOutcome: null,
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
-
const chat = deriveChatSnapshot(state, new Map(), "chat-1")
|
|
54
|
+
const chat = deriveChatSnapshot(state, new Map(), "chat-1", () => [])
|
|
55
55
|
expect(chat?.runtime.provider).toBe("claude")
|
|
56
56
|
expect(chat?.availableProviders.length).toBeGreaterThan(1)
|
|
57
57
|
expect(chat?.availableProviders.find((provider) => provider.id === "codex")?.models.map((model) => model.id)).toEqual([
|
|
@@ -8,7 +8,6 @@ import type {
|
|
|
8
8
|
SidebarProjectGroup,
|
|
9
9
|
} from "../shared/types"
|
|
10
10
|
import type { ChatRecord, StoreState } from "./events"
|
|
11
|
-
import { cloneTranscriptEntries } from "./events"
|
|
12
11
|
import { resolveLocalPath } from "./paths"
|
|
13
12
|
import { SERVER_PROVIDERS } from "./provider-catalog"
|
|
14
13
|
|
|
@@ -98,7 +97,8 @@ export function deriveLocalProjectsSnapshot(
|
|
|
98
97
|
export function deriveChatSnapshot(
|
|
99
98
|
state: StoreState,
|
|
100
99
|
activeStatuses: Map<string, KannaStatus>,
|
|
101
|
-
chatId: string
|
|
100
|
+
chatId: string,
|
|
101
|
+
getMessages: (chatId: string) => ChatSnapshot["messages"]
|
|
102
102
|
): ChatSnapshot | null {
|
|
103
103
|
const chat = state.chatsById.get(chatId)
|
|
104
104
|
if (!chat || chat.deletedAt) return null
|
|
@@ -118,7 +118,7 @@ export function deriveChatSnapshot(
|
|
|
118
118
|
|
|
119
119
|
return {
|
|
120
120
|
runtime,
|
|
121
|
-
messages:
|
|
121
|
+
messages: getMessages(chat.id),
|
|
122
122
|
availableProviders: [...SERVER_PROVIDERS],
|
|
123
123
|
}
|
|
124
124
|
}
|