kanna-code 0.14.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.
@@ -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
 
@@ -1,7 +1,5 @@
1
1
  import { QuickResponseAdapter } from "./quick-response"
2
2
 
3
- const LOG_PREFIX = "[kanna:title]"
4
-
5
3
  const TITLE_SCHEMA = {
6
4
  type: "object",
7
5
  properties: {
@@ -23,11 +21,6 @@ export async function generateTitleForChat(
23
21
  cwd: string,
24
22
  adapter = new QuickResponseAdapter()
25
23
  ): Promise<string | null> {
26
- console.log(`${LOG_PREFIX} generating title`, {
27
- cwd,
28
- messagePreview: messageContent.replace(/\s+/g, " ").trim().slice(0, 120),
29
- })
30
-
31
24
  const result = await adapter.generateStructured<string>({
32
25
  cwd,
33
26
  task: "conversation title generation",
@@ -39,11 +32,5 @@ export async function generateTitleForChat(
39
32
  },
40
33
  })
41
34
 
42
- if (result) {
43
- console.log(`${LOG_PREFIX} generated title`, { title: result })
44
- } else {
45
- console.warn(`${LOG_PREFIX} no title generated`)
46
- }
47
-
48
35
  return result
49
36
  }
@@ -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
 
@@ -1,7 +1,6 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk"
2
2
  import { CodexAppServerManager } from "./codex-app-server"
3
3
 
4
- const LOG_PREFIX = "[kanna:title]"
5
4
  const CLAUDE_STRUCTURED_TIMEOUT_MS = 5_000
6
5
 
7
6
  type JsonSchema = {
@@ -82,7 +81,6 @@ async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs<unknow
82
81
 
83
82
  return result
84
83
  } catch (error) {
85
- console.warn(`${LOG_PREFIX} claude structured query failed before fallback:`, error)
86
84
  return null
87
85
  } finally {
88
86
  try {
@@ -116,7 +114,6 @@ export class QuickResponseAdapter {
116
114
  this.runCodexStructured = args.runCodexStructured ?? ((structuredArgs) =>
117
115
  runCodexStructured(this.codexManager, structuredArgs))
118
116
  }
119
-
120
117
  async generateStructured<T>(args: StructuredQuickResponseArgs<T>): Promise<T | null> {
121
118
  const request = {
122
119
  cwd: args.cwd,
@@ -125,11 +122,9 @@ export class QuickResponseAdapter {
125
122
  schema: args.schema,
126
123
  }
127
124
 
128
- console.log(`${LOG_PREFIX} starting ${args.task} via claude`, { cwd: args.cwd })
129
125
  const claudeResult = await this.tryProvider("claude", args.task, args.parse, () => this.runClaudeStructured(request))
130
126
  if (claudeResult !== null) return claudeResult
131
127
 
132
- console.warn(`${LOG_PREFIX} claude returned no usable result for ${args.task}, falling back to codex`)
133
128
  return await this.tryProvider("codex", args.task, args.parse, () => this.runCodexStructured(request))
134
129
  }
135
130
 
@@ -142,20 +137,16 @@ export class QuickResponseAdapter {
142
137
  try {
143
138
  const result = await run()
144
139
  if (result === null) {
145
- console.warn(`${LOG_PREFIX} ${provider} returned no structured output for ${task}`)
146
140
  return null
147
141
  }
148
142
 
149
143
  const parsed = parse(result)
150
144
  if (parsed === null) {
151
- console.warn(`${LOG_PREFIX} ${provider} returned unparseable structured output for ${task}`, { result })
152
145
  return null
153
146
  }
154
147
 
155
- console.log(`${LOG_PREFIX} ${provider} produced structured output for ${task}`)
156
148
  return parsed
157
149
  } catch (error) {
158
- console.warn(`${LOG_PREFIX} ${provider} failed during ${task}:`, error)
159
150
  return null
160
151
  }
161
152
  }
@@ -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
  }
@@ -12,7 +12,9 @@ import { createWsRouter, type ClientState } from "./ws-router"
12
12
 
13
13
  export interface StartKannaServerOptions {
14
14
  port?: number
15
+ host?: string
15
16
  strictPort?: boolean
17
+ onMigrationProgress?: (message: string) => void
16
18
  update?: {
17
19
  version: string
18
20
  fetchLatestVersion: (packageName: string) => Promise<string>
@@ -22,10 +24,12 @@ export interface StartKannaServerOptions {
22
24
 
23
25
  export async function startKannaServer(options: StartKannaServerOptions = {}) {
24
26
  const port = options.port ?? 3210
27
+ const hostname = options.host ?? "127.0.0.1"
25
28
  const strictPort = options.strictPort ?? false
26
29
  const store = new EventStore()
27
30
  const machineDisplayName = getMachineDisplayName()
28
31
  await store.initialize()
32
+ await store.migrateLegacyTranscripts(options.onMigrationProgress)
29
33
  let discoveredProjects: DiscoveredProject[] = []
30
34
 
31
35
  async function refreshDiscovery() {
@@ -74,6 +78,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
74
78
  try {
75
79
  server = Bun.serve<ClientState>({
76
80
  port: actualPort,
81
+ hostname,
77
82
  fetch(req, serverInstance) {
78
83
  const url = new URL(req.url)
79
84
 
@@ -121,7 +121,7 @@ export function createWsRouter({
121
121
  id,
122
122
  snapshot: {
123
123
  type: "chat",
124
- data: deriveChatSnapshot(store.state, agent.getActiveStatuses(), topic.chatId),
124
+ data: deriveChatSnapshot(store.state, agent.getActiveStatuses(), topic.chatId, (chatId) => store.getMessages(chatId)),
125
125
  },
126
126
  }
127
127
  }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ DEFAULT_DEV_CLIENT_PORT,
4
+ getDefaultDevServerPort,
5
+ resolveDevPorts,
6
+ stripPortArg,
7
+ } from "./dev-ports"
8
+
9
+ describe("getDefaultDevServerPort", () => {
10
+ test("derives the default backend port from the client port", () => {
11
+ expect(getDefaultDevServerPort()).toBe(DEFAULT_DEV_CLIENT_PORT + 1)
12
+ expect(getDefaultDevServerPort(4000)).toBe(4001)
13
+ })
14
+ })
15
+
16
+ describe("resolveDevPorts", () => {
17
+ test("uses default dev ports when no port override is provided", () => {
18
+ expect(resolveDevPorts([])).toEqual({
19
+ clientPort: DEFAULT_DEV_CLIENT_PORT,
20
+ serverPort: DEFAULT_DEV_CLIENT_PORT + 1,
21
+ })
22
+ })
23
+
24
+ test("treats --port as the client port and derives the backend port", () => {
25
+ expect(resolveDevPorts(["--remote", "--port", "4000"])).toEqual({
26
+ clientPort: 4000,
27
+ serverPort: 4001,
28
+ })
29
+ })
30
+
31
+ test("uses the last provided --port value", () => {
32
+ expect(resolveDevPorts(["--port", "4000", "--port", "4100"])).toEqual({
33
+ clientPort: 4100,
34
+ serverPort: 4101,
35
+ })
36
+ })
37
+
38
+ test("throws when --port is missing a value", () => {
39
+ expect(() => resolveDevPorts(["--port"])).toThrow("Missing value for --port")
40
+ expect(() => resolveDevPorts(["--port", "--remote"])).toThrow("Missing value for --port")
41
+ })
42
+ })
43
+
44
+ describe("stripPortArg", () => {
45
+ test("removes --port and its value while preserving other args", () => {
46
+ expect(stripPortArg(["--remote", "--port", "4000", "--host", "dev-box"])).toEqual([
47
+ "--remote",
48
+ "--host",
49
+ "dev-box",
50
+ ])
51
+ })
52
+ })
@@ -0,0 +1,43 @@
1
+ export const DEFAULT_DEV_CLIENT_PORT = 5174
2
+
3
+ export function getDefaultDevServerPort(clientPort = DEFAULT_DEV_CLIENT_PORT) {
4
+ return clientPort + 1
5
+ }
6
+
7
+ export function resolveDevPorts(args: string[]) {
8
+ let clientPort = DEFAULT_DEV_CLIENT_PORT
9
+
10
+ for (let index = 0; index < args.length; index += 1) {
11
+ const arg = args[index]
12
+ if (arg !== "--port") continue
13
+
14
+ const next = args[index + 1]
15
+ if (!next || next.startsWith("-")) {
16
+ throw new Error("Missing value for --port")
17
+ }
18
+
19
+ clientPort = Number(next)
20
+ index += 1
21
+ }
22
+
23
+ return {
24
+ clientPort,
25
+ serverPort: getDefaultDevServerPort(clientPort),
26
+ }
27
+ }
28
+
29
+ export function stripPortArg(args: string[]) {
30
+ const stripped: string[] = []
31
+
32
+ for (let index = 0; index < args.length; index += 1) {
33
+ const arg = args[index]
34
+ if (arg === "--port") {
35
+ index += 1
36
+ continue
37
+ }
38
+
39
+ stripped.push(arg)
40
+ }
41
+
42
+ return stripped
43
+ }
@@ -1,3 +1,2 @@
1
1
  export const PROD_SERVER_PORT = 3210
2
- export const DEV_SERVER_PORT = 3211
3
2
  export const DEV_CLIENT_PORT = 5174