kanna-code 0.16.0 → 0.17.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,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
@@ -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
  })
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { ensureCloudflaredInstalled, logShareDetails, startShareTunnel } from "./share"
3
+
4
+ describe("ensureCloudflaredInstalled", () => {
5
+ test("returns immediately when the binary already exists", async () => {
6
+ const installCalls: string[] = []
7
+
8
+ const result = await ensureCloudflaredInstalled({
9
+ cloudflaredBin: "/tmp/cloudflared",
10
+ existsSync: () => true,
11
+ installCloudflared: async (to) => {
12
+ installCalls.push(to)
13
+ return to
14
+ },
15
+ })
16
+
17
+ expect(result).toBe("/tmp/cloudflared")
18
+ expect(installCalls).toEqual([])
19
+ })
20
+
21
+ test("installs the binary on demand when it is missing", async () => {
22
+ const installCalls: string[] = []
23
+ const logLines: string[] = []
24
+
25
+ const result = await ensureCloudflaredInstalled({
26
+ cloudflaredBin: "/tmp/cloudflared",
27
+ existsSync: () => false,
28
+ installCloudflared: async (to) => {
29
+ installCalls.push(to)
30
+ return to
31
+ },
32
+ log: (message) => {
33
+ logLines.push(message)
34
+ },
35
+ })
36
+
37
+ expect(result).toBe("/tmp/cloudflared")
38
+ expect(installCalls).toEqual(["/tmp/cloudflared"])
39
+ expect(logLines).toEqual(["installing cloudflared binary"])
40
+ })
41
+ })
42
+
43
+ describe("startShareTunnel", () => {
44
+ test("starts a quick tunnel after ensuring the binary exists", async () => {
45
+ const installCalls: string[] = []
46
+ const quickTunnelUrls: string[] = []
47
+ let stopCalls = 0
48
+
49
+ const shareTunnel = await startShareTunnel("http://localhost:3333", {
50
+ cloudflaredBin: "/tmp/cloudflared",
51
+ existsSync: () => false,
52
+ installCloudflared: async (to) => {
53
+ installCalls.push(to)
54
+ return to
55
+ },
56
+ createQuickTunnel: (localUrl) => {
57
+ quickTunnelUrls.push(localUrl)
58
+ return {
59
+ once(event, listener) {
60
+ if (event === "url") {
61
+ queueMicrotask(() => (listener as (url: string) => void)("https://kanna.trycloudflare.com"))
62
+ }
63
+ return this
64
+ },
65
+ off(_event, _listener) {
66
+ return this
67
+ },
68
+ stop() {
69
+ stopCalls += 1
70
+ return true
71
+ },
72
+ }
73
+ },
74
+ })
75
+
76
+ expect(installCalls).toEqual(["/tmp/cloudflared"])
77
+ expect(quickTunnelUrls).toEqual(["http://localhost:3333"])
78
+ expect(shareTunnel.publicUrl).toBe("https://kanna.trycloudflare.com")
79
+ shareTunnel.stop()
80
+ expect(stopCalls).toBe(1)
81
+ })
82
+ })
83
+
84
+ describe("logShareDetails", () => {
85
+ test("prints qr, public url, and local url in the expected order", async () => {
86
+ const logLines: string[] = []
87
+
88
+ await logShareDetails(
89
+ (message) => {
90
+ logLines.push(message)
91
+ },
92
+ "https://kanna.trycloudflare.com",
93
+ "http://localhost:3333",
94
+ async (url) => `[qr:${url}]\n`,
95
+ )
96
+
97
+ expect(logLines).toEqual([
98
+ "QR Code:",
99
+ "[qr:https://kanna.trycloudflare.com]",
100
+ "",
101
+ "Public URL:",
102
+ "https://kanna.trycloudflare.com",
103
+ "",
104
+ "Local URL:",
105
+ "http://localhost:3333",
106
+ ])
107
+ })
108
+ })
@@ -0,0 +1,113 @@
1
+ import { existsSync } from "node:fs"
2
+ import QRCode from "qrcode"
3
+ import { Tunnel, bin as cloudflaredBin, install as installCloudflared } from "cloudflared"
4
+
5
+ export interface StartedShareTunnel {
6
+ publicUrl: string
7
+ stop: () => void
8
+ }
9
+
10
+ export interface ShareTunnelProcess {
11
+ once(event: "url", listener: (url: string) => void): ShareTunnelProcess
12
+ once(event: "error", listener: (error: Error) => void): ShareTunnelProcess
13
+ once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): ShareTunnelProcess
14
+ off(event: "url", listener: (url: string) => void): ShareTunnelProcess
15
+ off(event: "error", listener: (error: Error) => void): ShareTunnelProcess
16
+ off(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): ShareTunnelProcess
17
+ stop(): boolean
18
+ }
19
+
20
+ export interface ShareTunnelDeps {
21
+ cloudflaredBin?: string
22
+ existsSync?: (path: string) => boolean
23
+ installCloudflared?: (to: string) => Promise<string>
24
+ createQuickTunnel?: (localUrl: string) => ShareTunnelProcess
25
+ log?: (message: string) => void
26
+ }
27
+
28
+ export async function renderTerminalQr(url: string) {
29
+ return QRCode.toString(url, {
30
+ type: "terminal",
31
+ small: true,
32
+ errorCorrectionLevel: "M",
33
+ })
34
+ }
35
+
36
+ export async function ensureCloudflaredInstalled(
37
+ deps: ShareTunnelDeps = {},
38
+ ) {
39
+ const resolvedBin = deps.cloudflaredBin ?? cloudflaredBin
40
+ const fileExists = deps.existsSync ?? existsSync
41
+ const installBinary = deps.installCloudflared ?? installCloudflared
42
+
43
+ if (fileExists(resolvedBin)) {
44
+ return resolvedBin
45
+ }
46
+
47
+ deps.log?.("installing cloudflared binary")
48
+ await installBinary(resolvedBin)
49
+ return resolvedBin
50
+ }
51
+
52
+ export async function startShareTunnel(localUrl: string, deps: ShareTunnelDeps = {}): Promise<StartedShareTunnel> {
53
+ await ensureCloudflaredInstalled(deps)
54
+ const tunnel = (deps.createQuickTunnel ?? ((url) => Tunnel.quick(url)))(localUrl)
55
+
56
+ const publicUrl = await new Promise<string>((resolve, reject) => {
57
+ let settled = false
58
+
59
+ const cleanup = () => {
60
+ tunnel.off("url", handleUrl)
61
+ tunnel.off("error", handleError)
62
+ tunnel.off("exit", handleExit)
63
+ }
64
+
65
+ const settle = (callback: () => void) => {
66
+ if (settled) return
67
+ settled = true
68
+ cleanup()
69
+ callback()
70
+ }
71
+
72
+ const handleUrl = (url: string) => {
73
+ settle(() => resolve(url))
74
+ }
75
+
76
+ const handleError = (error: Error) => {
77
+ settle(() => reject(error))
78
+ }
79
+
80
+ const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
81
+ settle(() => reject(new Error(`Cloudflare tunnel exited before a public URL was ready (code: ${String(code)}, signal: ${String(signal)})`)))
82
+ }
83
+
84
+ tunnel.once("url", handleUrl)
85
+ tunnel.once("error", handleError)
86
+ tunnel.once("exit", handleExit)
87
+ })
88
+
89
+ return {
90
+ publicUrl,
91
+ stop: () => {
92
+ tunnel.stop()
93
+ },
94
+ }
95
+ }
96
+
97
+ export async function logShareDetails(
98
+ log: (message: string) => void,
99
+ publicUrl: string,
100
+ localUrl: string,
101
+ renderShareQrImpl: (url: string) => Promise<string> = renderTerminalQr,
102
+ ) {
103
+ const qrCode = await renderShareQrImpl(publicUrl)
104
+
105
+ log("QR Code:")
106
+ log(qrCode.trimEnd())
107
+ log("")
108
+ log("Public URL:")
109
+ log(publicUrl)
110
+ log("")
111
+ log("Local URL:")
112
+ log(localUrl)
113
+ }
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
2
2
  import {
3
3
  DEFAULT_DEV_CLIENT_PORT,
4
4
  getDefaultDevServerPort,
5
+ parseDevArgs,
5
6
  resolveDevPorts,
6
7
  stripPortArg,
7
8
  } from "./dev-ports"
@@ -50,3 +51,34 @@ describe("stripPortArg", () => {
50
51
  ])
51
52
  })
52
53
  })
54
+
55
+ describe("parseDevArgs", () => {
56
+ test("derives dev share options from the client port", () => {
57
+ expect(parseDevArgs(["--share", "--port", "3333"], "dev-machine")).toEqual({
58
+ clientPort: 3333,
59
+ serverPort: 3334,
60
+ share: true,
61
+ backendTargetHost: "127.0.0.1",
62
+ allowedHosts: true,
63
+ serverArgs: [],
64
+ })
65
+ })
66
+
67
+ test("rejects combining --share with --host or --remote", () => {
68
+ expect(() => parseDevArgs(["--share", "--host", "dev-box"], "dev-machine")).toThrow("--share cannot be used with --host")
69
+ expect(() => parseDevArgs(["--host", "dev-box", "--share"], "dev-machine")).toThrow("--share cannot be used with --host")
70
+ expect(() => parseDevArgs(["--share", "--remote"], "dev-machine")).toThrow("--share cannot be used with --remote")
71
+ expect(() => parseDevArgs(["--remote", "--share"], "dev-machine")).toThrow("--share cannot be used with --remote")
72
+ })
73
+
74
+ test("preserves existing remote host behavior when share is off", () => {
75
+ expect(parseDevArgs(["--remote", "--port", "3333"], "dev-machine")).toEqual({
76
+ clientPort: 3333,
77
+ serverPort: 3334,
78
+ share: false,
79
+ backendTargetHost: "127.0.0.1",
80
+ allowedHosts: true,
81
+ serverArgs: ["--remote"],
82
+ })
83
+ })
84
+ })
@@ -4,6 +4,15 @@ export function getDefaultDevServerPort(clientPort = DEFAULT_DEV_CLIENT_PORT) {
4
4
  return clientPort + 1
5
5
  }
6
6
 
7
+ export interface DevArgResolution {
8
+ clientPort: number
9
+ serverPort: number
10
+ share: boolean
11
+ backendTargetHost: string
12
+ allowedHosts: true | string[]
13
+ serverArgs: string[]
14
+ }
15
+
7
16
  export function resolveDevPorts(args: string[]) {
8
17
  let clientPort = DEFAULT_DEV_CLIENT_PORT
9
18
 
@@ -41,3 +50,49 @@ export function stripPortArg(args: string[]) {
41
50
 
42
51
  return stripped
43
52
  }
53
+
54
+ export function parseDevArgs(args: string[], localHostname: string): DevArgResolution {
55
+ const { clientPort, serverPort } = resolveDevPorts(args)
56
+ const serverArgs = stripPortArg(args).filter((arg) => arg !== "--share")
57
+ let share = false
58
+ let sawHost = false
59
+ let sawRemote = false
60
+ let backendTargetHost = "127.0.0.1"
61
+ let allowAllHosts = false
62
+ const hosts = new Set<string>(["localhost", "127.0.0.1", "0.0.0.0", localHostname])
63
+
64
+ for (let index = 0; index < args.length; index += 1) {
65
+ const arg = args[index]
66
+ if (arg === "--share") {
67
+ if (sawHost) throw new Error("--share cannot be used with --host")
68
+ if (sawRemote) throw new Error("--share cannot be used with --remote")
69
+ share = true
70
+ continue
71
+ }
72
+ if (arg === "--remote") {
73
+ if (share) throw new Error("--share cannot be used with --remote")
74
+ sawRemote = true
75
+ backendTargetHost = "127.0.0.1"
76
+ allowAllHosts = true
77
+ continue
78
+ }
79
+ if (arg !== "--host") continue
80
+
81
+ const next = args[index + 1]
82
+ if (!next || next.startsWith("-")) continue
83
+ if (share) throw new Error("--share cannot be used with --host")
84
+ sawHost = true
85
+ hosts.add(next)
86
+ backendTargetHost = next === "0.0.0.0" ? "127.0.0.1" : next
87
+ index += 1
88
+ }
89
+
90
+ return {
91
+ clientPort,
92
+ serverPort,
93
+ share,
94
+ backendTargetHost,
95
+ allowedHosts: share || allowAllHosts ? true : [...hosts],
96
+ serverArgs,
97
+ }
98
+ }
@@ -42,7 +42,7 @@ export type ServiceTier = "fast"
42
42
 
43
43
  export interface ClaudeModelOptions {
44
44
  reasoningEffort: ClaudeReasoningEffort
45
- contextWindow?: ClaudeContextWindow
45
+ contextWindow: ClaudeContextWindow
46
46
  }
47
47
 
48
48
  export interface CodexModelOptions {
@@ -140,9 +140,9 @@ export function getClaudeContextWindowOptions(modelId: string): readonly Provide
140
140
  return getClaudeModelOption(modelId)?.contextWindowOptions ?? []
141
141
  }
142
142
 
143
- export function normalizeClaudeContextWindow(modelId: string, contextWindow?: unknown): ClaudeContextWindow | undefined {
143
+ export function normalizeClaudeContextWindow(modelId: string, contextWindow?: unknown): ClaudeContextWindow {
144
144
  const options = getClaudeContextWindowOptions(modelId)
145
- if (options.length === 0) return undefined
145
+ if (options.length === 0) return DEFAULT_CLAUDE_MODEL_OPTIONS.contextWindow
146
146
  return options.some((option) => option.id === contextWindow)
147
147
  ? contextWindow as ClaudeContextWindow
148
148
  : DEFAULT_CLAUDE_MODEL_OPTIONS.contextWindow