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.
@@ -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-C6R1P_HL.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-CA-kJR8F.css">
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.15.0",
4
+ "version": "0.16.0",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -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 modelOptions = normalizeClaudeModelOptions(command.modelOptions, command.effort)
369
+ const model = normalizeServerModel(provider, command.model)
370
+ const modelOptions = normalizeClaudeModelOptions(model, command.modelOptions, command.effort)
369
371
  return {
370
- model: normalizeServerModel(provider, command.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 & { update: CliUpdateOptions }) => Promise<{ port: number; stop: () => Promise<void> }>
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
- message: string
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
- for (const messageSet of parsed.messages) {
96
- this.state.messagesByChatId.set(messageSet.chatId, cloneTranscriptEntries(messageSet.entries))
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.state.messagesByChatId.clear()
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
- const chat = this.state.chatsById.get(event.chatId)
228
- if (chat) {
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.state.messagesByChatId.set(event.chatId, existing)
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 event: MessageEvent = {
396
- v: STORE_VERSION,
397
- type: "message_appended",
398
- timestamp: Date.now(),
399
- chatId,
400
- entry,
401
- }
402
- await this.append(this.messagesLogPath, event)
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
- return cloneTranscriptEntries(this.state.messagesByChatId.get(chatId) ?? [])
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 compact() {
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
- messages: [...this.state.messagesByChatId.entries()].map(([chatId, entries]) => ({
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,
@@ -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: Array<{ chatId: string; entries: TranscriptEntry[] }>
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(modelOptions?: ModelOptions, legacyEffort?: string): ClaudeModelOptions {
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: cloneTranscriptEntries(state.messagesByChatId.get(chat.id) ?? []),
121
+ messages: getMessages(chat.id),
122
122
  availableProviders: [...SERVER_PROVIDERS],
123
123
  }
124
124
  }