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.
- package/README.md +28 -2
- package/dist/client/assets/index-BET6YVYr.css +32 -0
- package/dist/client/assets/index-DMxCijCf.js +533 -0
- package/dist/client/index.html +2 -2
- package/package.json +3 -2
- package/src/server/agent.ts +8 -36
- package/src/server/cli-runtime.test.ts +55 -0
- package/src/server/cli-runtime.ts +30 -9
- 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 +170 -26
- package/src/server/events.ts +1 -3
- package/src/server/generate-title.ts +0 -13
- package/src/server/provider-catalog.test.ts +29 -1
- package/src/server/provider-catalog.ts +8 -1
- package/src/server/quick-response.ts +0 -9
- package/src/server/read-models.test.ts +1 -1
- package/src/server/read-models.ts +3 -3
- package/src/server/server.ts +5 -0
- package/src/server/ws-router.ts +1 -1
- package/src/shared/dev-ports.test.ts +52 -0
- package/src/shared/dev-ports.ts +43 -0
- package/src/shared/ports.ts +0 -1
- package/src/shared/types.ts +41 -3
- package/dist/client/assets/index-CVu2H5bX.js +0 -523
- package/dist/client/assets/index-D5tYLJi-.css +0 -32
|
@@ -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
|
-
|
|
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,7 +561,34 @@ export class EventStore {
|
|
|
498
561
|
return this.listChatsByProject(projectId).length
|
|
499
562
|
}
|
|
500
563
|
|
|
501
|
-
async
|
|
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
|
-
|
|
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,
|
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
|
|
|
@@ -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(
|
|
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:
|
|
121
|
+
messages: getMessages(chat.id),
|
|
122
122
|
availableProviders: [...SERVER_PROVIDERS],
|
|
123
123
|
}
|
|
124
124
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
|
package/src/server/ws-router.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/shared/ports.ts
CHANGED