kanna-code 0.16.0 → 0.18.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-DMxCijCf.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-BET6YVYr.css">
8
+ <script type="module" crossorigin src="/assets/index-CLqK0AWC.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BvhI1JYs.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.16.0",
4
+ "version": "0.18.0",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -52,7 +52,10 @@
52
52
  "@xterm/addon-web-links": "^0.12.0",
53
53
  "@xterm/headless": "^6.0.0",
54
54
  "@xterm/xterm": "^6.0.0",
55
+ "cloudflared": "^0.7.1",
55
56
  "default-shell": "^2.2.0",
57
+ "file-type": "^22.0.0",
58
+ "qrcode": "^1.5.4",
56
59
  "react-resizable-panels": "^4.7.3"
57
60
  },
58
61
  "devDependencies": {
@@ -66,6 +69,7 @@
66
69
  "@tailwindcss/typography": "^0.5.19",
67
70
  "@types/bun": "latest",
68
71
  "@types/node": "^24.10.1",
72
+ "@types/qrcode": "^1.5.6",
69
73
  "@types/react": "19.2.7",
70
74
  "@types/react-dom": "19.2.3",
71
75
  "@vitejs/plugin-react": "5.1.1",
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import { AgentCoordinator, normalizeClaudeStreamMessage } from "./agent"
2
+ import { AgentCoordinator, buildAttachmentHintText, buildPromptText, normalizeClaudeStreamMessage } from "./agent"
3
3
  import type { HarnessTurn } from "./harness-types"
4
- import type { TranscriptEntry } from "../shared/types"
4
+ import type { ChatAttachment, TranscriptEntry } from "../shared/types"
5
5
 
6
6
  function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(entry: T): TranscriptEntry {
7
7
  return {
@@ -63,6 +63,68 @@ describe("normalizeClaudeStreamMessage", () => {
63
63
  })
64
64
  })
65
65
 
66
+ describe("attachment prompt helpers", () => {
67
+ test("appends a structured attachment hint block for all attachment kinds", () => {
68
+ const attachments: ChatAttachment[] = [
69
+ {
70
+ id: "image-1",
71
+ kind: "image",
72
+ displayName: "shot.png",
73
+ absolutePath: "/tmp/project/.kanna/uploads/shot.png",
74
+ relativePath: "./.kanna/uploads/shot.png",
75
+ contentUrl: "/api/projects/project-1/uploads/shot.png/content",
76
+ mimeType: "image/png",
77
+ size: 512,
78
+ },
79
+ {
80
+ id: "file-1",
81
+ kind: "file",
82
+ displayName: "spec.pdf",
83
+ absolutePath: "/tmp/project/.kanna/uploads/spec.pdf",
84
+ relativePath: "./.kanna/uploads/spec.pdf",
85
+ contentUrl: "/api/projects/project-1/uploads/spec.pdf/content",
86
+ mimeType: "application/pdf",
87
+ size: 1234,
88
+ },
89
+ ]
90
+
91
+ const prompt = buildPromptText("Review these", attachments)
92
+ expect(prompt).toContain("<kanna-attachments>")
93
+ expect(prompt).toContain('path="/tmp/project/.kanna/uploads/shot.png"')
94
+ expect(prompt).toContain('project_path="./.kanna/uploads/spec.pdf"')
95
+ })
96
+
97
+ test("supports attachment-only prompts", () => {
98
+ const attachments: ChatAttachment[] = [{
99
+ id: "file-1",
100
+ kind: "file",
101
+ displayName: "todo.txt",
102
+ absolutePath: "/tmp/project/.kanna/uploads/todo.txt",
103
+ relativePath: "./.kanna/uploads/todo.txt",
104
+ contentUrl: "/api/projects/project-1/uploads/todo.txt/content",
105
+ mimeType: "text/plain",
106
+ size: 32,
107
+ }]
108
+
109
+ expect(buildPromptText("", attachments)).toContain("Please inspect the attached files.")
110
+ })
111
+
112
+ test("escapes xml attribute values for attachment hint markup", () => {
113
+ const hint = buildAttachmentHintText([{
114
+ id: "file-1",
115
+ kind: "file",
116
+ displayName: "\"report\" <draft>.txt",
117
+ absolutePath: "/tmp/project/.kanna/uploads/report.txt",
118
+ relativePath: "./.kanna/uploads/report.txt",
119
+ contentUrl: "/api/projects/project-1/uploads/report.txt/content",
120
+ mimeType: "text/plain",
121
+ size: 64,
122
+ }])
123
+
124
+ expect(hint).toContain("&quot;report&quot; &lt;draft&gt;.txt")
125
+ })
126
+ })
127
+
66
128
  describe("AgentCoordinator codex integration", () => {
67
129
  test("generates a chat title in the background on the first user message", async () => {
68
130
  const fakeCodexManager = {
@@ -1,6 +1,7 @@
1
1
  import { query, type CanUseTool, type PermissionResult, type Query } from "@anthropic-ai/claude-agent-sdk"
2
2
  import type {
3
3
  AgentProvider,
4
+ ChatAttachment,
4
5
  NormalizedToolCall,
5
6
  PendingToolSnapshot,
6
7
  KannaStatus,
@@ -89,6 +90,41 @@ function stringFromUnknown(value: unknown) {
89
90
  }
90
91
  }
91
92
 
93
+ function escapeXmlAttribute(value: string) {
94
+ return value
95
+ .replaceAll("&", "&amp;")
96
+ .replaceAll("\"", "&quot;")
97
+ .replaceAll("<", "&lt;")
98
+ .replaceAll(">", "&gt;")
99
+ }
100
+
101
+ export function buildAttachmentHintText(attachments: ChatAttachment[]) {
102
+ if (attachments.length === 0) return ""
103
+
104
+ const lines = attachments.map((attachment) => (
105
+ `<attachment kind="${escapeXmlAttribute(attachment.kind)}" mime_type="${escapeXmlAttribute(attachment.mimeType)}" path="${escapeXmlAttribute(attachment.absolutePath)}" project_path="${escapeXmlAttribute(attachment.relativePath)}" size_bytes="${attachment.size}" display_name="${escapeXmlAttribute(attachment.displayName)}" />`
106
+ ))
107
+
108
+ return [
109
+ "<kanna-attachments>",
110
+ ...lines,
111
+ "</kanna-attachments>",
112
+ ].join("\n")
113
+ }
114
+
115
+ export function buildPromptText(content: string, attachments: ChatAttachment[]) {
116
+ const attachmentHint = buildAttachmentHintText(attachments)
117
+ if (!attachmentHint) {
118
+ return content.trim()
119
+ }
120
+
121
+ const trimmed = content.trim()
122
+ return [
123
+ trimmed || "Please inspect the attached files.",
124
+ attachmentHint,
125
+ ].join("\n\n").trim()
126
+ }
127
+
92
128
  function discardedToolResult(
93
129
  tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
94
130
  ) {
@@ -389,6 +425,7 @@ export class AgentCoordinator {
389
425
  chatId: string
390
426
  provider: AgentProvider
391
427
  content: string
428
+ attachments: ChatAttachment[]
392
429
  model: string
393
430
  effort?: string
394
431
  serviceTier?: "fast"
@@ -409,7 +446,10 @@ export class AgentCoordinator {
409
446
  const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
410
447
 
411
448
  if (args.appendUserPrompt) {
412
- await this.store.appendMessage(args.chatId, timestamped({ kind: "user_prompt", content: args.content }, Date.now()))
449
+ await this.store.appendMessage(
450
+ args.chatId,
451
+ timestamped({ kind: "user_prompt", content: args.content, attachments: args.attachments }, Date.now())
452
+ )
413
453
  }
414
454
  await this.store.recordTurnStarted(args.chatId)
415
455
 
@@ -443,7 +483,7 @@ export class AgentCoordinator {
443
483
  let turn: HarnessTurn
444
484
  if (args.provider === "claude") {
445
485
  turn = await startClaudeTurn({
446
- content: args.content,
486
+ content: buildPromptText(args.content, args.attachments),
447
487
  localPath: project.localPath,
448
488
  model: args.model,
449
489
  effort: args.effort,
@@ -461,7 +501,7 @@ export class AgentCoordinator {
461
501
  })
462
502
  turn = await this.codexManager.startTurn({
463
503
  chatId: args.chatId,
464
- content: args.content,
504
+ content: buildPromptText(args.content, args.attachments),
465
505
  model: args.model,
466
506
  effort: args.effort as any,
467
507
  serviceTier: args.serviceTier,
@@ -519,6 +559,7 @@ export class AgentCoordinator {
519
559
  chatId,
520
560
  provider,
521
561
  content: command.content,
562
+ attachments: command.attachments ?? [],
522
563
  model: settings.model,
523
564
  effort: settings.effort,
524
565
  serviceTier: settings.serviceTier,
@@ -600,6 +641,7 @@ export class AgentCoordinator {
600
641
  chatId: active.chatId,
601
642
  provider: active.provider,
602
643
  content: active.postToolFollowUp.content,
644
+ attachments: [],
603
645
  model: active.model,
604
646
  effort: active.effort,
605
647
  serviceTier: active.serviceTier,
@@ -36,6 +36,9 @@ function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
36
36
  openUrl: [] as string[],
37
37
  log: [] as string[],
38
38
  warn: [] as string[],
39
+ shareTunnel: [] as string[],
40
+ renderShareQr: [] as string[],
41
+ shareTunnelStops: 0,
39
42
  }
40
43
 
41
44
  const deps: Parameters<typeof runCli>[1] = {
@@ -70,6 +73,19 @@ function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
70
73
  warn: (message) => {
71
74
  calls.warn.push(message)
72
75
  },
76
+ renderShareQr: async (url) => {
77
+ calls.renderShareQr.push(url)
78
+ return `[qr:${url}]`
79
+ },
80
+ startShareTunnel: async (localUrl) => {
81
+ calls.shareTunnel.push(localUrl)
82
+ return {
83
+ publicUrl: "https://kanna.trycloudflare.com",
84
+ stop: () => {
85
+ calls.shareTunnelStops += 1
86
+ },
87
+ }
88
+ },
73
89
  ...overrides,
74
90
  }
75
91
 
@@ -84,6 +100,7 @@ describe("parseArgs", () => {
84
100
  port: 4000,
85
101
  host: "127.0.0.1",
86
102
  openBrowser: false,
103
+ share: false,
87
104
  strictPort: false,
88
105
  },
89
106
  })
@@ -96,6 +113,7 @@ describe("parseArgs", () => {
96
113
  port: 3210,
97
114
  host: "127.0.0.1",
98
115
  openBrowser: true,
116
+ share: false,
99
117
  strictPort: true,
100
118
  },
101
119
  })
@@ -108,6 +126,20 @@ describe("parseArgs", () => {
108
126
  port: 3210,
109
127
  host: "0.0.0.0",
110
128
  openBrowser: true,
129
+ share: false,
130
+ strictPort: false,
131
+ },
132
+ })
133
+ })
134
+
135
+ test("--share enables public sharing", () => {
136
+ expect(parseArgs(["--share"])).toEqual({
137
+ kind: "run",
138
+ options: {
139
+ port: 3210,
140
+ host: "127.0.0.1",
141
+ openBrowser: true,
142
+ share: true,
111
143
  strictPort: false,
112
144
  },
113
145
  })
@@ -120,6 +152,7 @@ describe("parseArgs", () => {
120
152
  port: 3210,
121
153
  host: "100.64.0.1",
122
154
  openBrowser: true,
155
+ share: false,
123
156
  strictPort: false,
124
157
  },
125
158
  })
@@ -132,6 +165,7 @@ describe("parseArgs", () => {
132
165
  port: 3210,
133
166
  host: "dev-box",
134
167
  openBrowser: true,
168
+ share: false,
135
169
  strictPort: false,
136
170
  },
137
171
  })
@@ -142,6 +176,13 @@ describe("parseArgs", () => {
142
176
  expect(() => parseArgs(["--host", "--no-open"])).toThrow("Missing value for --host")
143
177
  })
144
178
 
179
+ test("--share is incompatible with --host and --remote", () => {
180
+ expect(() => parseArgs(["--share", "--host", "dev-box"])).toThrow("--share cannot be used with --host")
181
+ expect(() => parseArgs(["--host", "dev-box", "--share"])).toThrow("--share cannot be used with --host")
182
+ expect(() => parseArgs(["--share", "--remote"])).toThrow("--share cannot be used with --remote")
183
+ expect(() => parseArgs(["--remote", "--share"])).toThrow("--share cannot be used with --remote")
184
+ })
185
+
145
186
  test("returns version and help actions without running startup", () => {
146
187
  expect(parseArgs(["--version"])).toEqual({ kind: "version" })
147
188
  expect(parseArgs(["--help"])).toEqual({ kind: "help" })
@@ -192,6 +233,7 @@ describe("runCli", () => {
192
233
  port: 4000,
193
234
  host: "127.0.0.1",
194
235
  openBrowser: false,
236
+ share: false,
195
237
  strictPort: false,
196
238
  update: {
197
239
  version: "0.3.0",
@@ -251,6 +293,94 @@ describe("runCli", () => {
251
293
  expect(calls.openUrl).toEqual([])
252
294
  })
253
295
 
296
+ test("starts a share tunnel and prints qr/public/local urls", async () => {
297
+ delete process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR]
298
+ const { calls, deps } = createDeps()
299
+
300
+ const result = await runCli(["--share", "--port", "4000"], deps)
301
+
302
+ expect(result.kind).toBe("started")
303
+ expect(calls.openUrl).toEqual([])
304
+ expect(calls.shareTunnel).toEqual(["http://localhost:4000"])
305
+ expect(calls.renderShareQr).toEqual(["https://kanna.trycloudflare.com"])
306
+ expect(calls.log).toContain("QR Code:")
307
+ expect(calls.log).toContain("[qr:https://kanna.trycloudflare.com]")
308
+ expect(calls.log).toContain("Public URL:")
309
+ expect(calls.log).toContain("https://kanna.trycloudflare.com")
310
+ expect(calls.log).toContain("Local URL:")
311
+ expect(calls.log).toContain("http://localhost:4000")
312
+
313
+ if (result.kind !== "started") {
314
+ throw new Error(`expected started result, got ${result.kind}`)
315
+ }
316
+ await result.stop()
317
+ expect(calls.shareTunnelStops).toBe(1)
318
+ })
319
+
320
+ test("logs share setup progress from the default tunnel helper", async () => {
321
+ const { calls, deps } = createDeps({
322
+ startShareTunnel: undefined,
323
+ renderShareQr: async () => "[qr]",
324
+ })
325
+
326
+ let installLogged = false
327
+ deps.startShareTunnel = async (_localUrl) => {
328
+ deps.log("[kanna] installing cloudflared binary")
329
+ installLogged = true
330
+ return {
331
+ publicUrl: "https://kanna.trycloudflare.com",
332
+ stop: () => {},
333
+ }
334
+ }
335
+
336
+ await runCli(["--share"], deps)
337
+
338
+ expect(installLogged).toBe(true)
339
+ expect(calls.log).toContain("[kanna] installing cloudflared binary")
340
+ })
341
+
342
+ test("uses the actual bound port for --share", async () => {
343
+ const { calls, deps } = createDeps({
344
+ startServer: async (options) => {
345
+ calls.startServer.push(options)
346
+ return {
347
+ port: 4001,
348
+ stop: async () => {},
349
+ }
350
+ },
351
+ })
352
+
353
+ const result = await runCli(["--share", "--port", "4000"], deps)
354
+
355
+ expect(result.kind).toBe("started")
356
+ expect(calls.shareTunnel).toEqual(["http://localhost:4001"])
357
+ })
358
+
359
+ test("fails cleanly when share tunnel startup fails", async () => {
360
+ let serverStopped = false
361
+ const { calls, deps } = createDeps({
362
+ startServer: async (options) => {
363
+ calls.startServer.push(options)
364
+ return {
365
+ port: options.port,
366
+ stop: async () => {
367
+ serverStopped = true
368
+ },
369
+ }
370
+ },
371
+ startShareTunnel: async () => {
372
+ throw new Error("cloudflared unavailable")
373
+ },
374
+ })
375
+
376
+ const result = await runCli(["--share"], deps)
377
+
378
+ expect(result).toEqual({ kind: "exited", code: 1 })
379
+ expect(serverStopped).toBe(true)
380
+ expect(calls.warn).toContain("[kanna] failed to start Cloudflare share tunnel")
381
+ expect(calls.warn).toContain("[kanna] cloudflared unavailable")
382
+ })
383
+
254
384
  test("returns restarting when a newer version is available", async () => {
255
385
  const { calls, deps } = createDeps({
256
386
  fetchLatestVersion: async (packageName) => {
@@ -5,11 +5,13 @@ import { APP_NAME, CLI_COMMAND, getDataDirDisplay, LOG_PREFIX, PACKAGE_NAME } fr
5
5
  import type { UpdateInstallErrorCode } from "../shared/types"
6
6
  import { PROD_SERVER_PORT } from "../shared/ports"
7
7
  import { CLI_SUPPRESS_OPEN_ONCE_ENV_VAR } from "./restart"
8
+ import { logShareDetails, renderTerminalQr, startShareTunnel, type StartedShareTunnel } from "./share"
8
9
 
9
10
  export interface CliOptions {
10
11
  port: number
11
12
  host: string
12
13
  openBrowser: boolean
14
+ share: boolean
13
15
  strictPort: boolean
14
16
  }
15
17
 
@@ -50,6 +52,8 @@ export interface CliRuntimeDeps {
50
52
  openUrl: (url: string) => void
51
53
  log: (message: string) => void
52
54
  warn: (message: string) => void
55
+ renderShareQr?: (url: string) => Promise<string>
56
+ startShareTunnel?: (localUrl: string) => Promise<StartedShareTunnel>
53
57
  }
54
58
 
55
59
  export interface UpdateInstallAttemptResult {
@@ -76,6 +80,7 @@ Options:
76
80
  --port <number> Port to listen on (default: ${PROD_SERVER_PORT})
77
81
  --host <host> Bind to a specific host or IP
78
82
  --remote Shortcut for --host 0.0.0.0
83
+ --share Create a public Cloudflare share URL with terminal QR
79
84
  --strict-port Fail instead of trying another port
80
85
  --no-open Don't open browser automatically
81
86
  --version Print version and exit
@@ -86,6 +91,9 @@ export function parseArgs(argv: string[]): ParsedArgs {
86
91
  let port = PROD_SERVER_PORT
87
92
  let host = "127.0.0.1"
88
93
  let openBrowser = true
94
+ let share = false
95
+ let sawHost = false
96
+ let sawRemote = false
89
97
  let strictPort = false
90
98
 
91
99
  for (let index = 0; index < argv.length; index += 1) {
@@ -106,12 +114,22 @@ export function parseArgs(argv: string[]): ParsedArgs {
106
114
  if (arg === "--host") {
107
115
  const next = argv[index + 1]
108
116
  if (!next || next.startsWith("-")) throw new Error("Missing value for --host")
117
+ if (share) throw new Error("--share cannot be used with --host")
109
118
  host = next
119
+ sawHost = true
110
120
  index += 1
111
121
  continue
112
122
  }
113
123
  if (arg === "--remote") {
124
+ if (share) throw new Error("--share cannot be used with --remote")
114
125
  host = "0.0.0.0"
126
+ sawRemote = true
127
+ continue
128
+ }
129
+ if (arg === "--share") {
130
+ if (sawHost) throw new Error("--share cannot be used with --host")
131
+ if (sawRemote) throw new Error("--share cannot be used with --remote")
132
+ share = true
115
133
  continue
116
134
  }
117
135
  if (arg === "--no-open") {
@@ -131,6 +149,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
131
149
  port,
132
150
  host,
133
151
  openBrowser,
152
+ share,
134
153
  strictPort,
135
154
  },
136
155
  }
@@ -231,20 +250,41 @@ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliR
231
250
  },
232
251
  })
233
252
  const bindHost = parsedArgs.options.host
234
- const displayHost = bindHost === "127.0.0.1" || bindHost === "0.0.0.0" ? "localhost" : bindHost
253
+ const displayHost = parsedArgs.options.share || bindHost === "127.0.0.1" || bindHost === "0.0.0.0" ? "localhost" : bindHost
235
254
  const launchUrl = `http://${displayHost}:${port}`
255
+ let shareTunnelStop: (() => void) | null = null
236
256
 
237
257
  deps.log(`${LOG_PREFIX} listening on http://${bindHost}:${port}`)
238
258
  deps.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
239
259
 
240
260
  const suppressOpenBrowser = process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR] === "1"
241
- if (parsedArgs.options.openBrowser && !suppressOpenBrowser) {
261
+ if (parsedArgs.options.share) {
262
+ try {
263
+ const shareTunnel = await (deps.startShareTunnel ?? ((localUrl) => startShareTunnel(localUrl, {
264
+ log: (message) => deps.log(`${LOG_PREFIX} ${message}`),
265
+ })))(launchUrl)
266
+ shareTunnelStop = shareTunnel.stop
267
+ await logShareDetails(deps.log, shareTunnel.publicUrl, launchUrl, deps.renderShareQr ?? renderTerminalQr)
268
+ } catch (error) {
269
+ await stop()
270
+ deps.warn(`${LOG_PREFIX} failed to start Cloudflare share tunnel`)
271
+ if (error instanceof Error && error.message) {
272
+ deps.warn(`${LOG_PREFIX} ${error.message}`)
273
+ }
274
+ return { kind: "exited", code: 1 }
275
+ }
276
+ }
277
+
278
+ if (parsedArgs.options.openBrowser && !parsedArgs.options.share && !suppressOpenBrowser) {
242
279
  deps.openUrl(launchUrl)
243
280
  }
244
281
 
245
282
  return {
246
283
  kind: "started",
247
- stop,
284
+ stop: async () => {
285
+ shareTunnelStop?.()
286
+ await stop()
287
+ },
248
288
  }
249
289
  }
250
290
 
@@ -1,4 +1,4 @@
1
- import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"
1
+ import { appendFile, mkdir, rename, writeFile } from "node:fs/promises"
2
2
  import { existsSync, readFileSync as readFileSyncImmediate } from "node:fs"
3
3
  import { homedir } from "node:os"
4
4
  import path from "node:path"
@@ -588,8 +588,8 @@ export class EventStore {
588
588
  return (await this.getLegacyTranscriptStats()).hasLegacyData
589
589
  }
590
590
 
591
- private createSnapshot(includeLegacyMessages: boolean): SnapshotFile {
592
- const snapshot: SnapshotFile = {
591
+ private createSnapshot(): SnapshotFile {
592
+ return {
593
593
  v: STORE_VERSION,
594
594
  generatedAt: Date.now(),
595
595
  projects: this.listProjects().map((project) => ({ ...project })),
@@ -597,30 +597,10 @@ export class EventStore {
597
597
  .filter((chat) => !chat.deletedAt)
598
598
  .map((chat) => ({ ...chat })),
599
599
  }
600
-
601
- if (includeLegacyMessages) {
602
- snapshot.messages = [...this.legacyMessagesByChatId.entries()].map(([chatId, entries]) => ({
603
- chatId,
604
- entries: cloneTranscriptEntries(entries),
605
- }))
606
- }
607
-
608
- return snapshot
609
600
  }
610
601
 
611
602
  async compact() {
612
- const snapshot = this.createSnapshot(false)
613
- await Bun.write(this.snapshotPath, JSON.stringify(snapshot, null, 2))
614
- await Promise.all([
615
- Bun.write(this.projectsLogPath, ""),
616
- Bun.write(this.chatsLogPath, ""),
617
- Bun.write(this.messagesLogPath, ""),
618
- Bun.write(this.turnsLogPath, ""),
619
- ])
620
- }
621
-
622
- private async compactLegacyMessagesIntoSnapshot() {
623
- const snapshot = this.createSnapshot(true)
603
+ const snapshot = this.createSnapshot()
624
604
  await Bun.write(this.snapshotPath, JSON.stringify(snapshot, null, 2))
625
605
  await Promise.all([
626
606
  Bun.write(this.projectsLogPath, ""),
@@ -636,17 +616,14 @@ export class EventStore {
636
616
 
637
617
  const sourceSummary = stats.sources.map((source) => source === "messages_log" ? "messages.jsonl" : "snapshot.json").join(", ")
638
618
  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
619
 
642
- const snapshot = JSON.parse(await readFile(this.snapshotPath, "utf8")) as SnapshotFile
643
- const messageSets = snapshot.messages ?? []
620
+ const messageSets = [...this.legacyMessagesByChatId.entries()]
644
621
  onProgress?.(`${LOG_PREFIX} transcript migration: writing ${messageSets.length} per-chat transcript files`)
645
622
 
646
623
  await mkdir(this.transcriptsDir, { recursive: true })
647
624
  const logEveryChat = messageSets.length <= 10
648
625
  for (let index = 0; index < messageSets.length; index += 1) {
649
- const { chatId, entries } = messageSets[index]
626
+ const [chatId, entries] = messageSets[index]
650
627
  const transcriptPath = this.transcriptPath(chatId)
651
628
  const tempPath = `${transcriptPath}.tmp`
652
629
  const payload = entries.map((entry) => JSON.stringify(entry)).join("\n")
@@ -657,9 +634,8 @@ export class EventStore {
657
634
  }
658
635
  }
659
636
 
660
- delete snapshot.messages
661
- await Bun.write(this.snapshotPath, JSON.stringify(snapshot, null, 2))
662
637
  this.clearLegacyTranscriptState()
638
+ await this.compact()
663
639
  this.cachedTranscript = null
664
640
  onProgress?.(`${LOG_PREFIX} transcript migration complete`)
665
641
  return true
@@ -25,3 +25,7 @@ export async function ensureProjectDirectory(localPath: string) {
25
25
  throw new Error("Project path must be a directory")
26
26
  }
27
27
  }
28
+
29
+ export function getProjectUploadDir(localPath: string) {
30
+ return path.join(resolveLocalPath(localPath), ".kanna", "uploads")
31
+ }
@@ -30,7 +30,7 @@ describe("provider catalog normalization", () => {
30
30
  reasoningEffort: "medium",
31
31
  contextWindow: "1m",
32
32
  },
33
- })).toEqual({
33
+ })).toMatchObject({
34
34
  reasoningEffort: "medium",
35
35
  })
36
36
  })