kanna-code 0.15.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.
- package/README.md +42 -0
- package/dist/client/assets/index-CtKZIZdD.js +533 -0
- package/dist/client/assets/index-Cwex1U8S.css +32 -0
- package/dist/client/index.html +2 -2
- package/package.json +4 -1
- package/src/server/agent.ts +4 -2
- package/src/server/cli-runtime.test.ts +130 -0
- package/src/server/cli-runtime.ts +48 -4
- package/src/server/codex-app-server-protocol.ts +8 -1
- package/src/server/codex-app-server.ts +1 -1
- package/src/server/event-store.test.ts +112 -1
- package/src/server/event-store.ts +149 -29
- package/src/server/events.ts +1 -3
- package/src/server/provider-catalog.test.ts +29 -1
- package/src/server/provider-catalog.ts +8 -1
- package/src/server/read-models.test.ts +1 -1
- package/src/server/read-models.ts +3 -3
- package/src/server/server.ts +2 -0
- package/src/server/share.test.ts +108 -0
- package/src/server/share.ts +113 -0
- package/src/server/ws-router.ts +1 -1
- package/src/shared/dev-ports.test.ts +32 -0
- package/src/shared/dev-ports.ts +55 -0
- package/src/shared/types.ts +40 -2
- package/dist/client/assets/index-C6R1P_HL.js +0 -523
- package/dist/client/assets/index-CA-kJR8F.css +0 -32
|
@@ -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, rename, writeFile } from "node:fs/promises"
|
|
2
|
+
import { existsSync, readFileSync as readFileSyncImmediate } from "node:fs"
|
|
2
3
|
import { homedir } from "node:os"
|
|
3
4
|
import path from "node:path"
|
|
4
5
|
import { getDataDir, LOG_PREFIX } from "../shared/branding"
|
|
@@ -19,6 +20,13 @@ import { resolveLocalPath } from "./paths"
|
|
|
19
20
|
|
|
20
21
|
const COMPACTION_THRESHOLD_BYTES = 2 * 1024 * 1024
|
|
21
22
|
|
|
23
|
+
interface LegacyTranscriptStats {
|
|
24
|
+
hasLegacyData: boolean
|
|
25
|
+
sources: Array<"snapshot" | "messages_log">
|
|
26
|
+
chatCount: number
|
|
27
|
+
entryCount: number
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
export class EventStore {
|
|
23
31
|
readonly dataDir: string
|
|
24
32
|
readonly state: StoreState = createEmptyState()
|
|
@@ -29,6 +37,10 @@ export class EventStore {
|
|
|
29
37
|
private readonly chatsLogPath: string
|
|
30
38
|
private readonly messagesLogPath: string
|
|
31
39
|
private readonly turnsLogPath: string
|
|
40
|
+
private readonly transcriptsDir: string
|
|
41
|
+
private legacyMessagesByChatId = new Map<string, TranscriptEntry[]>()
|
|
42
|
+
private snapshotHasLegacyMessages = false
|
|
43
|
+
private cachedTranscript: { chatId: string; entries: TranscriptEntry[] } | null = null
|
|
32
44
|
|
|
33
45
|
constructor(dataDir = getDataDir(homedir())) {
|
|
34
46
|
this.dataDir = dataDir
|
|
@@ -37,17 +49,19 @@ export class EventStore {
|
|
|
37
49
|
this.chatsLogPath = path.join(this.dataDir, "chats.jsonl")
|
|
38
50
|
this.messagesLogPath = path.join(this.dataDir, "messages.jsonl")
|
|
39
51
|
this.turnsLogPath = path.join(this.dataDir, "turns.jsonl")
|
|
52
|
+
this.transcriptsDir = path.join(this.dataDir, "transcripts")
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
async initialize() {
|
|
43
56
|
await mkdir(this.dataDir, { recursive: true })
|
|
57
|
+
await mkdir(this.transcriptsDir, { recursive: true })
|
|
44
58
|
await this.ensureFile(this.projectsLogPath)
|
|
45
59
|
await this.ensureFile(this.chatsLogPath)
|
|
46
60
|
await this.ensureFile(this.messagesLogPath)
|
|
47
61
|
await this.ensureFile(this.turnsLogPath)
|
|
48
62
|
await this.loadSnapshot()
|
|
49
63
|
await this.replayLogs()
|
|
50
|
-
if (await this.shouldCompact()) {
|
|
64
|
+
if (!(await this.hasLegacyTranscriptData()) && await this.shouldCompact()) {
|
|
51
65
|
await this.compact()
|
|
52
66
|
}
|
|
53
67
|
}
|
|
@@ -63,6 +77,7 @@ export class EventStore {
|
|
|
63
77
|
if (this.storageReset) return
|
|
64
78
|
this.storageReset = true
|
|
65
79
|
this.resetState()
|
|
80
|
+
this.clearLegacyTranscriptState()
|
|
66
81
|
await Promise.all([
|
|
67
82
|
Bun.write(this.snapshotPath, ""),
|
|
68
83
|
Bun.write(this.projectsLogPath, ""),
|
|
@@ -92,8 +107,11 @@ export class EventStore {
|
|
|
92
107
|
for (const chat of parsed.chats) {
|
|
93
108
|
this.state.chatsById.set(chat.id, { ...chat })
|
|
94
109
|
}
|
|
95
|
-
|
|
96
|
-
this.
|
|
110
|
+
if (parsed.messages?.length) {
|
|
111
|
+
this.snapshotHasLegacyMessages = true
|
|
112
|
+
for (const messageSet of parsed.messages) {
|
|
113
|
+
this.legacyMessagesByChatId.set(messageSet.chatId, cloneTranscriptEntries(messageSet.entries))
|
|
114
|
+
}
|
|
97
115
|
}
|
|
98
116
|
} catch (error) {
|
|
99
117
|
console.warn(`${LOG_PREFIX} Failed to load snapshot, resetting local history:`, error)
|
|
@@ -105,7 +123,12 @@ export class EventStore {
|
|
|
105
123
|
this.state.projectsById.clear()
|
|
106
124
|
this.state.projectIdsByPath.clear()
|
|
107
125
|
this.state.chatsById.clear()
|
|
108
|
-
this.
|
|
126
|
+
this.cachedTranscript = null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private clearLegacyTranscriptState() {
|
|
130
|
+
this.legacyMessagesByChatId.clear()
|
|
131
|
+
this.snapshotHasLegacyMessages = false
|
|
109
132
|
}
|
|
110
133
|
|
|
111
134
|
private async replayLogs() {
|
|
@@ -224,16 +247,10 @@ export class EventStore {
|
|
|
224
247
|
break
|
|
225
248
|
}
|
|
226
249
|
case "message_appended": {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (event.entry.kind === "user_prompt") {
|
|
230
|
-
chat.lastMessageAt = event.entry.createdAt
|
|
231
|
-
}
|
|
232
|
-
chat.updatedAt = Math.max(chat.updatedAt, event.entry.createdAt)
|
|
233
|
-
}
|
|
234
|
-
const existing = this.state.messagesByChatId.get(event.chatId) ?? []
|
|
250
|
+
this.applyMessageMetadata(event.chatId, event.entry)
|
|
251
|
+
const existing = this.legacyMessagesByChatId.get(event.chatId) ?? []
|
|
235
252
|
existing.push({ ...event.entry })
|
|
236
|
-
this.
|
|
253
|
+
this.legacyMessagesByChatId.set(event.chatId, existing)
|
|
237
254
|
break
|
|
238
255
|
}
|
|
239
256
|
case "turn_started": {
|
|
@@ -273,6 +290,15 @@ export class EventStore {
|
|
|
273
290
|
}
|
|
274
291
|
}
|
|
275
292
|
|
|
293
|
+
private applyMessageMetadata(chatId: string, entry: TranscriptEntry) {
|
|
294
|
+
const chat = this.state.chatsById.get(chatId)
|
|
295
|
+
if (!chat) return
|
|
296
|
+
if (entry.kind === "user_prompt") {
|
|
297
|
+
chat.lastMessageAt = entry.createdAt
|
|
298
|
+
}
|
|
299
|
+
chat.updatedAt = Math.max(chat.updatedAt, entry.createdAt)
|
|
300
|
+
}
|
|
301
|
+
|
|
276
302
|
private append<TEvent extends StoreEvent>(filePath: string, event: TEvent) {
|
|
277
303
|
const payload = `${JSON.stringify(event)}\n`
|
|
278
304
|
this.writeChain = this.writeChain.then(async () => {
|
|
@@ -282,6 +308,28 @@ export class EventStore {
|
|
|
282
308
|
return this.writeChain
|
|
283
309
|
}
|
|
284
310
|
|
|
311
|
+
private transcriptPath(chatId: string) {
|
|
312
|
+
return path.join(this.transcriptsDir, `${chatId}.jsonl`)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private loadTranscriptFromDisk(chatId: string) {
|
|
316
|
+
const transcriptPath = this.transcriptPath(chatId)
|
|
317
|
+
if (!existsSync(transcriptPath)) {
|
|
318
|
+
return []
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const text = readFileSyncImmediate(transcriptPath, "utf8")
|
|
322
|
+
if (!text.trim()) return []
|
|
323
|
+
|
|
324
|
+
const entries: TranscriptEntry[] = []
|
|
325
|
+
for (const rawLine of text.split("\n")) {
|
|
326
|
+
const line = rawLine.trim()
|
|
327
|
+
if (!line) continue
|
|
328
|
+
entries.push(JSON.parse(line) as TranscriptEntry)
|
|
329
|
+
}
|
|
330
|
+
return entries
|
|
331
|
+
}
|
|
332
|
+
|
|
285
333
|
async openProject(localPath: string, title?: string) {
|
|
286
334
|
const normalized = resolveLocalPath(localPath)
|
|
287
335
|
const existingId = this.state.projectIdsByPath.get(normalized)
|
|
@@ -392,14 +440,17 @@ export class EventStore {
|
|
|
392
440
|
|
|
393
441
|
async appendMessage(chatId: string, entry: TranscriptEntry) {
|
|
394
442
|
this.requireChat(chatId)
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
entry
|
|
401
|
-
|
|
402
|
-
|
|
443
|
+
const payload = `${JSON.stringify(entry)}\n`
|
|
444
|
+
const transcriptPath = this.transcriptPath(chatId)
|
|
445
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
446
|
+
await mkdir(this.transcriptsDir, { recursive: true })
|
|
447
|
+
await appendFile(transcriptPath, payload, "utf8")
|
|
448
|
+
this.applyMessageMetadata(chatId, entry)
|
|
449
|
+
if (this.cachedTranscript?.chatId === chatId) {
|
|
450
|
+
this.cachedTranscript.entries.push({ ...entry })
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
return this.writeChain
|
|
403
454
|
}
|
|
404
455
|
|
|
405
456
|
async recordTurnStarted(chatId: string) {
|
|
@@ -481,7 +532,19 @@ export class EventStore {
|
|
|
481
532
|
}
|
|
482
533
|
|
|
483
534
|
getMessages(chatId: string) {
|
|
484
|
-
|
|
535
|
+
if (this.cachedTranscript?.chatId === chatId) {
|
|
536
|
+
return cloneTranscriptEntries(this.cachedTranscript.entries)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const legacyEntries = this.legacyMessagesByChatId.get(chatId)
|
|
540
|
+
if (legacyEntries) {
|
|
541
|
+
this.cachedTranscript = { chatId, entries: cloneTranscriptEntries(legacyEntries) }
|
|
542
|
+
return cloneTranscriptEntries(this.cachedTranscript.entries)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const entries = this.loadTranscriptFromDisk(chatId)
|
|
546
|
+
this.cachedTranscript = { chatId, entries }
|
|
547
|
+
return cloneTranscriptEntries(entries)
|
|
485
548
|
}
|
|
486
549
|
|
|
487
550
|
listProjects() {
|
|
@@ -498,20 +561,46 @@ export class EventStore {
|
|
|
498
561
|
return this.listChatsByProject(projectId).length
|
|
499
562
|
}
|
|
500
563
|
|
|
501
|
-
async
|
|
502
|
-
const
|
|
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(): SnapshotFile {
|
|
592
|
+
return {
|
|
503
593
|
v: STORE_VERSION,
|
|
504
594
|
generatedAt: Date.now(),
|
|
505
595
|
projects: this.listProjects().map((project) => ({ ...project })),
|
|
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]) => ({
|
|
510
|
-
chatId,
|
|
511
|
-
entries: cloneTranscriptEntries(entries),
|
|
512
|
-
})),
|
|
513
599
|
}
|
|
600
|
+
}
|
|
514
601
|
|
|
602
|
+
async compact() {
|
|
603
|
+
const snapshot = this.createSnapshot()
|
|
515
604
|
await Bun.write(this.snapshotPath, JSON.stringify(snapshot, null, 2))
|
|
516
605
|
await Promise.all([
|
|
517
606
|
Bun.write(this.projectsLogPath, ""),
|
|
@@ -521,6 +610,37 @@ export class EventStore {
|
|
|
521
610
|
])
|
|
522
611
|
}
|
|
523
612
|
|
|
613
|
+
async migrateLegacyTranscripts(onProgress?: (message: string) => void) {
|
|
614
|
+
const stats = await this.getLegacyTranscriptStats()
|
|
615
|
+
if (!stats.hasLegacyData) return false
|
|
616
|
+
|
|
617
|
+
const sourceSummary = stats.sources.map((source) => source === "messages_log" ? "messages.jsonl" : "snapshot.json").join(", ")
|
|
618
|
+
onProgress?.(`${LOG_PREFIX} transcript migration detected: ${stats.chatCount} chats, ${stats.entryCount} entries from ${sourceSummary}`)
|
|
619
|
+
|
|
620
|
+
const messageSets = [...this.legacyMessagesByChatId.entries()]
|
|
621
|
+
onProgress?.(`${LOG_PREFIX} transcript migration: writing ${messageSets.length} per-chat transcript files`)
|
|
622
|
+
|
|
623
|
+
await mkdir(this.transcriptsDir, { recursive: true })
|
|
624
|
+
const logEveryChat = messageSets.length <= 10
|
|
625
|
+
for (let index = 0; index < messageSets.length; index += 1) {
|
|
626
|
+
const [chatId, entries] = messageSets[index]
|
|
627
|
+
const transcriptPath = this.transcriptPath(chatId)
|
|
628
|
+
const tempPath = `${transcriptPath}.tmp`
|
|
629
|
+
const payload = entries.map((entry) => JSON.stringify(entry)).join("\n")
|
|
630
|
+
await writeFile(tempPath, payload ? `${payload}\n` : "", "utf8")
|
|
631
|
+
await rename(tempPath, transcriptPath)
|
|
632
|
+
if (logEveryChat || (index + 1) % 25 === 0 || index === messageSets.length - 1) {
|
|
633
|
+
onProgress?.(`${LOG_PREFIX} transcript migration: ${index + 1}/${messageSets.length} chats`)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
this.clearLegacyTranscriptState()
|
|
638
|
+
await this.compact()
|
|
639
|
+
this.cachedTranscript = null
|
|
640
|
+
onProgress?.(`${LOG_PREFIX} transcript migration complete`)
|
|
641
|
+
return true
|
|
642
|
+
}
|
|
643
|
+
|
|
524
644
|
private async shouldCompact() {
|
|
525
645
|
const sizes = await Promise.all([
|
|
526
646
|
Bun.file(this.projectsLogPath).size,
|
package/src/server/events.ts
CHANGED
|
@@ -22,7 +22,6 @@ export interface StoreState {
|
|
|
22
22
|
projectsById: Map<string, ProjectRecord>
|
|
23
23
|
projectIdsByPath: Map<string, string>
|
|
24
24
|
chatsById: Map<string, ChatRecord>
|
|
25
|
-
messagesByChatId: Map<string, TranscriptEntry[]>
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
export interface SnapshotFile {
|
|
@@ -30,7 +29,7 @@ export interface SnapshotFile {
|
|
|
30
29
|
generatedAt: number
|
|
31
30
|
projects: ProjectRecord[]
|
|
32
31
|
chats: ChatRecord[]
|
|
33
|
-
messages
|
|
32
|
+
messages?: Array<{ chatId: string; entries: TranscriptEntry[] }>
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
export type ProjectEvent = {
|
|
@@ -133,7 +132,6 @@ export function createEmptyState(): StoreState {
|
|
|
133
132
|
projectsById: new Map(),
|
|
134
133
|
projectIdsByPath: new Map(),
|
|
135
134
|
chatsById: new Map(),
|
|
136
|
-
messagesByChatId: new Map(),
|
|
137
135
|
}
|
|
138
136
|
}
|
|
139
137
|
|
|
@@ -4,11 +4,34 @@ import {
|
|
|
4
4
|
normalizeClaudeModelOptions,
|
|
5
5
|
normalizeCodexModelOptions,
|
|
6
6
|
} from "./provider-catalog"
|
|
7
|
+
import { resolveClaudeApiModelId } from "../shared/types"
|
|
7
8
|
|
|
8
9
|
describe("provider catalog normalization", () => {
|
|
9
10
|
test("maps legacy Claude effort into shared model options", () => {
|
|
10
|
-
expect(normalizeClaudeModelOptions(undefined, "max")).toEqual({
|
|
11
|
+
expect(normalizeClaudeModelOptions("opus", undefined, "max")).toEqual({
|
|
11
12
|
reasoningEffort: "max",
|
|
13
|
+
contextWindow: "200k",
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("normalizes Claude context window only for supported models", () => {
|
|
18
|
+
expect(normalizeClaudeModelOptions("sonnet", {
|
|
19
|
+
claude: {
|
|
20
|
+
reasoningEffort: "medium",
|
|
21
|
+
contextWindow: "1m",
|
|
22
|
+
},
|
|
23
|
+
})).toEqual({
|
|
24
|
+
reasoningEffort: "medium",
|
|
25
|
+
contextWindow: "1m",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
expect(normalizeClaudeModelOptions("haiku", {
|
|
29
|
+
claude: {
|
|
30
|
+
reasoningEffort: "medium",
|
|
31
|
+
contextWindow: "1m",
|
|
32
|
+
},
|
|
33
|
+
})).toMatchObject({
|
|
34
|
+
reasoningEffort: "medium",
|
|
12
35
|
})
|
|
13
36
|
})
|
|
14
37
|
|
|
@@ -31,4 +54,9 @@ describe("provider catalog normalization", () => {
|
|
|
31
54
|
})
|
|
32
55
|
expect(codexServiceTierFromModelOptions(normalized)).toBe("fast")
|
|
33
56
|
})
|
|
57
|
+
|
|
58
|
+
test("resolves Claude API model ids for 1m context window", () => {
|
|
59
|
+
expect(resolveClaudeApiModelId("opus", "1m")).toBe("opus[1m]")
|
|
60
|
+
expect(resolveClaudeApiModelId("sonnet", "200k")).toBe("sonnet")
|
|
61
|
+
})
|
|
34
62
|
})
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AgentProvider,
|
|
3
3
|
ClaudeModelOptions,
|
|
4
4
|
CodexModelOptions,
|
|
5
|
+
ClaudeContextWindow,
|
|
5
6
|
ModelOptions,
|
|
6
7
|
ProviderCatalogEntry,
|
|
7
8
|
ProviderModelOption,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
DEFAULT_CLAUDE_MODEL_OPTIONS,
|
|
12
13
|
DEFAULT_CODEX_MODEL_OPTIONS,
|
|
13
14
|
PROVIDERS,
|
|
15
|
+
normalizeClaudeContextWindow,
|
|
14
16
|
isClaudeReasoningEffort,
|
|
15
17
|
isCodexReasoningEffort,
|
|
16
18
|
} from "../shared/types"
|
|
@@ -47,7 +49,11 @@ export function normalizeServerModel(provider: AgentProvider, model?: string): s
|
|
|
47
49
|
return catalog.defaultModel
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
export function normalizeClaudeModelOptions(
|
|
52
|
+
export function normalizeClaudeModelOptions(
|
|
53
|
+
model: string,
|
|
54
|
+
modelOptions?: ModelOptions,
|
|
55
|
+
legacyEffort?: string
|
|
56
|
+
): ClaudeModelOptions {
|
|
51
57
|
const reasoningEffort = modelOptions?.claude?.reasoningEffort
|
|
52
58
|
return {
|
|
53
59
|
reasoningEffort: isClaudeReasoningEffort(reasoningEffort)
|
|
@@ -55,6 +61,7 @@ export function normalizeClaudeModelOptions(modelOptions?: ModelOptions, legacyE
|
|
|
55
61
|
: isClaudeReasoningEffort(legacyEffort)
|
|
56
62
|
? legacyEffort
|
|
57
63
|
: DEFAULT_CLAUDE_MODEL_OPTIONS.reasoningEffort,
|
|
64
|
+
contextWindow: normalizeClaudeContextWindow(model, modelOptions?.claude?.contextWindow as ClaudeContextWindow | undefined),
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
67
|
|
|
@@ -51,7 +51,7 @@ describe("read models", () => {
|
|
|
51
51
|
lastTurnOutcome: null,
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
-
const chat = deriveChatSnapshot(state, new Map(), "chat-1")
|
|
54
|
+
const chat = deriveChatSnapshot(state, new Map(), "chat-1", () => [])
|
|
55
55
|
expect(chat?.runtime.provider).toBe("claude")
|
|
56
56
|
expect(chat?.availableProviders.length).toBeGreaterThan(1)
|
|
57
57
|
expect(chat?.availableProviders.find((provider) => provider.id === "codex")?.models.map((model) => model.id)).toEqual([
|
|
@@ -8,7 +8,6 @@ import type {
|
|
|
8
8
|
SidebarProjectGroup,
|
|
9
9
|
} from "../shared/types"
|
|
10
10
|
import type { ChatRecord, StoreState } from "./events"
|
|
11
|
-
import { cloneTranscriptEntries } from "./events"
|
|
12
11
|
import { resolveLocalPath } from "./paths"
|
|
13
12
|
import { SERVER_PROVIDERS } from "./provider-catalog"
|
|
14
13
|
|
|
@@ -98,7 +97,8 @@ export function deriveLocalProjectsSnapshot(
|
|
|
98
97
|
export function deriveChatSnapshot(
|
|
99
98
|
state: StoreState,
|
|
100
99
|
activeStatuses: Map<string, KannaStatus>,
|
|
101
|
-
chatId: string
|
|
100
|
+
chatId: string,
|
|
101
|
+
getMessages: (chatId: string) => ChatSnapshot["messages"]
|
|
102
102
|
): ChatSnapshot | null {
|
|
103
103
|
const chat = state.chatsById.get(chatId)
|
|
104
104
|
if (!chat || chat.deletedAt) return null
|
|
@@ -118,7 +118,7 @@ export function deriveChatSnapshot(
|
|
|
118
118
|
|
|
119
119
|
return {
|
|
120
120
|
runtime,
|
|
121
|
-
messages:
|
|
121
|
+
messages: getMessages(chat.id),
|
|
122
122
|
availableProviders: [...SERVER_PROVIDERS],
|
|
123
123
|
}
|
|
124
124
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface StartKannaServerOptions {
|
|
|
14
14
|
port?: number
|
|
15
15
|
host?: string
|
|
16
16
|
strictPort?: boolean
|
|
17
|
+
onMigrationProgress?: (message: string) => void
|
|
17
18
|
update?: {
|
|
18
19
|
version: string
|
|
19
20
|
fetchLatestVersion: (packageName: string) => Promise<string>
|
|
@@ -28,6 +29,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
28
29
|
const store = new EventStore()
|
|
29
30
|
const machineDisplayName = getMachineDisplayName()
|
|
30
31
|
await store.initialize()
|
|
32
|
+
await store.migrateLegacyTranscripts(options.onMigrationProgress)
|
|
31
33
|
let discoveredProjects: DiscoveredProject[] = []
|
|
32
34
|
|
|
33
35
|
async function refreshDiscovery() {
|
|
@@ -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
|
+
})
|