kanna-code 0.1.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 +137 -0
- package/bin/kanna +2 -0
- package/dist/client/assets/index-ClV0uXCn.css +1 -0
- package/dist/client/assets/index-DSpGrX6x.js +408 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/fonts/body-medium.woff2 +0 -0
- package/dist/client/fonts/body-regular-italic.woff2 +0 -0
- package/dist/client/fonts/body-regular.woff2 +0 -0
- package/dist/client/fonts/body-semibold.woff2 +0 -0
- package/dist/client/index.html +14 -0
- package/package.json +56 -0
- package/src/server/agent.ts +379 -0
- package/src/server/cli.ts +145 -0
- package/src/server/discovery.ts +65 -0
- package/src/server/event-store.ts +478 -0
- package/src/server/events.ts +134 -0
- package/src/server/external-open.ts +105 -0
- package/src/server/generate-title.ts +42 -0
- package/src/server/machine-name.ts +22 -0
- package/src/server/paths.ts +27 -0
- package/src/server/read-models.ts +120 -0
- package/src/server/server.ts +132 -0
- package/src/server/ws-router.ts +208 -0
- package/src/shared/branding.ts +23 -0
- package/src/shared/ports.ts +3 -0
- package/src/shared/protocol.ts +40 -0
- package/src/shared/types.ts +87 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { getDataDir, LOG_PREFIX } from "../shared/branding"
|
|
5
|
+
import type { TranscriptEntry } from "../shared/types"
|
|
6
|
+
import { STORE_VERSION } from "../shared/types"
|
|
7
|
+
import {
|
|
8
|
+
type ChatEvent,
|
|
9
|
+
type MessageEvent,
|
|
10
|
+
type ProjectEvent,
|
|
11
|
+
type SnapshotFile,
|
|
12
|
+
type StoreEvent,
|
|
13
|
+
type StoreState,
|
|
14
|
+
type TurnEvent,
|
|
15
|
+
cloneTranscriptEntries,
|
|
16
|
+
createEmptyState,
|
|
17
|
+
} from "./events"
|
|
18
|
+
import { resolveLocalPath } from "./paths"
|
|
19
|
+
|
|
20
|
+
const DATA_DIR = getDataDir(homedir())
|
|
21
|
+
const SNAPSHOT_PATH = path.join(DATA_DIR, "snapshot.json")
|
|
22
|
+
const PROJECTS_LOG = path.join(DATA_DIR, "projects.jsonl")
|
|
23
|
+
const CHATS_LOG = path.join(DATA_DIR, "chats.jsonl")
|
|
24
|
+
const MESSAGES_LOG = path.join(DATA_DIR, "messages.jsonl")
|
|
25
|
+
const TURNS_LOG = path.join(DATA_DIR, "turns.jsonl")
|
|
26
|
+
const COMPACTION_THRESHOLD_BYTES = 2 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
export class EventStore {
|
|
29
|
+
readonly dataDir = DATA_DIR
|
|
30
|
+
readonly state: StoreState = createEmptyState()
|
|
31
|
+
private writeChain = Promise.resolve()
|
|
32
|
+
|
|
33
|
+
async initialize() {
|
|
34
|
+
await mkdir(DATA_DIR, { recursive: true })
|
|
35
|
+
await this.ensureFile(PROJECTS_LOG)
|
|
36
|
+
await this.ensureFile(CHATS_LOG)
|
|
37
|
+
await this.ensureFile(MESSAGES_LOG)
|
|
38
|
+
await this.ensureFile(TURNS_LOG)
|
|
39
|
+
await this.loadSnapshot()
|
|
40
|
+
await this.replayLogs()
|
|
41
|
+
if (await this.shouldCompact()) {
|
|
42
|
+
await this.compact()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private async ensureFile(filePath: string) {
|
|
47
|
+
const file = Bun.file(filePath)
|
|
48
|
+
if (!(await file.exists())) {
|
|
49
|
+
await Bun.write(filePath, "")
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async loadSnapshot() {
|
|
54
|
+
const file = Bun.file(SNAPSHOT_PATH)
|
|
55
|
+
if (!(await file.exists())) return
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const parsed = (await file.json()) as SnapshotFile
|
|
59
|
+
if (parsed.v !== STORE_VERSION) {
|
|
60
|
+
throw new Error(`Unsupported snapshot version ${String(parsed.v)}`)
|
|
61
|
+
}
|
|
62
|
+
for (const project of parsed.projects) {
|
|
63
|
+
this.state.projectsById.set(project.id, { ...project })
|
|
64
|
+
this.state.projectIdsByPath.set(project.localPath, project.id)
|
|
65
|
+
}
|
|
66
|
+
for (const chat of parsed.chats) {
|
|
67
|
+
this.state.chatsById.set(chat.id, { ...chat })
|
|
68
|
+
}
|
|
69
|
+
for (const messageSet of parsed.messages) {
|
|
70
|
+
this.state.messagesByChatId.set(messageSet.chatId, cloneTranscriptEntries(messageSet.entries))
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.warn(`${LOG_PREFIX} Failed to load snapshot, rebuilding from logs:`, error)
|
|
74
|
+
this.resetState()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private resetState() {
|
|
79
|
+
this.state.projectsById.clear()
|
|
80
|
+
this.state.projectIdsByPath.clear()
|
|
81
|
+
this.state.chatsById.clear()
|
|
82
|
+
this.state.messagesByChatId.clear()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async replayLogs() {
|
|
86
|
+
await this.replayLog<ProjectEvent>(PROJECTS_LOG)
|
|
87
|
+
await this.replayLog<ChatEvent>(CHATS_LOG)
|
|
88
|
+
await this.replayLog<MessageEvent>(MESSAGES_LOG)
|
|
89
|
+
await this.replayLog<TurnEvent>(TURNS_LOG)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async replayLog<TEvent extends StoreEvent>(filePath: string) {
|
|
93
|
+
const file = Bun.file(filePath)
|
|
94
|
+
if (!(await file.exists())) return
|
|
95
|
+
const text = await file.text()
|
|
96
|
+
if (!text.trim()) return
|
|
97
|
+
|
|
98
|
+
const lines = text.split("\n")
|
|
99
|
+
let lastNonEmpty = -1
|
|
100
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
101
|
+
if (lines[index].trim()) {
|
|
102
|
+
lastNonEmpty = index
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
108
|
+
const line = lines[index].trim()
|
|
109
|
+
if (!line) continue
|
|
110
|
+
try {
|
|
111
|
+
const event = JSON.parse(line) as TEvent
|
|
112
|
+
this.applyEvent(event)
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (index === lastNonEmpty) {
|
|
115
|
+
console.warn(`${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(filePath)}`)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private applyEvent(event: StoreEvent) {
|
|
124
|
+
switch (event.type) {
|
|
125
|
+
case "project_opened": {
|
|
126
|
+
const localPath = resolveLocalPath(event.localPath)
|
|
127
|
+
const project = {
|
|
128
|
+
id: event.projectId,
|
|
129
|
+
localPath,
|
|
130
|
+
title: event.title,
|
|
131
|
+
createdAt: event.timestamp,
|
|
132
|
+
updatedAt: event.timestamp,
|
|
133
|
+
}
|
|
134
|
+
this.state.projectsById.set(project.id, project)
|
|
135
|
+
this.state.projectIdsByPath.set(localPath, project.id)
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
case "project_removed": {
|
|
139
|
+
const project = this.state.projectsById.get(event.projectId)
|
|
140
|
+
if (!project) break
|
|
141
|
+
project.deletedAt = event.timestamp
|
|
142
|
+
project.updatedAt = event.timestamp
|
|
143
|
+
this.state.projectIdsByPath.delete(project.localPath)
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
case "chat_created": {
|
|
147
|
+
const chat = {
|
|
148
|
+
id: event.chatId,
|
|
149
|
+
projectId: event.projectId,
|
|
150
|
+
title: event.title,
|
|
151
|
+
createdAt: event.timestamp,
|
|
152
|
+
updatedAt: event.timestamp,
|
|
153
|
+
planMode: false,
|
|
154
|
+
resumeSessionId: null,
|
|
155
|
+
lastTurnOutcome: null,
|
|
156
|
+
}
|
|
157
|
+
this.state.chatsById.set(chat.id, chat)
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
case "chat_renamed": {
|
|
161
|
+
const chat = this.state.chatsById.get(event.chatId)
|
|
162
|
+
if (!chat) break
|
|
163
|
+
chat.title = event.title
|
|
164
|
+
chat.updatedAt = event.timestamp
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
case "chat_deleted": {
|
|
168
|
+
const chat = this.state.chatsById.get(event.chatId)
|
|
169
|
+
if (!chat) break
|
|
170
|
+
chat.deletedAt = event.timestamp
|
|
171
|
+
chat.updatedAt = event.timestamp
|
|
172
|
+
break
|
|
173
|
+
}
|
|
174
|
+
case "chat_plan_mode_set": {
|
|
175
|
+
const chat = this.state.chatsById.get(event.chatId)
|
|
176
|
+
if (!chat) break
|
|
177
|
+
chat.planMode = event.planMode
|
|
178
|
+
chat.updatedAt = event.timestamp
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
case "message_appended": {
|
|
182
|
+
const chat = this.state.chatsById.get(event.chatId)
|
|
183
|
+
if (chat) {
|
|
184
|
+
// Only update lastMessageAt for user-sent messages so the sidebar
|
|
185
|
+
// sorts by "last sent" rather than "last received".
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(event.entry.message)
|
|
188
|
+
if (parsed.type === "user_prompt") {
|
|
189
|
+
chat.lastMessageAt = event.entry.createdAt
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// non-JSON entry, skip
|
|
193
|
+
}
|
|
194
|
+
chat.updatedAt = Math.max(chat.updatedAt, event.entry.createdAt)
|
|
195
|
+
}
|
|
196
|
+
const existing = this.state.messagesByChatId.get(event.chatId) ?? []
|
|
197
|
+
existing.push({ ...event.entry })
|
|
198
|
+
this.state.messagesByChatId.set(event.chatId, existing)
|
|
199
|
+
break
|
|
200
|
+
}
|
|
201
|
+
case "turn_started": {
|
|
202
|
+
const chat = this.state.chatsById.get(event.chatId)
|
|
203
|
+
if (!chat) break
|
|
204
|
+
chat.updatedAt = event.timestamp
|
|
205
|
+
break
|
|
206
|
+
}
|
|
207
|
+
case "turn_finished": {
|
|
208
|
+
const chat = this.state.chatsById.get(event.chatId)
|
|
209
|
+
if (!chat) break
|
|
210
|
+
chat.updatedAt = event.timestamp
|
|
211
|
+
chat.lastTurnOutcome = "success"
|
|
212
|
+
break
|
|
213
|
+
}
|
|
214
|
+
case "turn_failed": {
|
|
215
|
+
const chat = this.state.chatsById.get(event.chatId)
|
|
216
|
+
if (!chat) break
|
|
217
|
+
chat.updatedAt = event.timestamp
|
|
218
|
+
chat.lastTurnOutcome = "failed"
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
case "turn_cancelled": {
|
|
222
|
+
const chat = this.state.chatsById.get(event.chatId)
|
|
223
|
+
if (!chat) break
|
|
224
|
+
chat.updatedAt = event.timestamp
|
|
225
|
+
chat.lastTurnOutcome = "cancelled"
|
|
226
|
+
break
|
|
227
|
+
}
|
|
228
|
+
case "resume_session_set": {
|
|
229
|
+
const chat = this.state.chatsById.get(event.chatId)
|
|
230
|
+
if (!chat) break
|
|
231
|
+
chat.resumeSessionId = event.sessionId
|
|
232
|
+
chat.updatedAt = event.timestamp
|
|
233
|
+
break
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private append<TEvent extends StoreEvent>(filePath: string, event: TEvent) {
|
|
239
|
+
const payload = `${JSON.stringify(event)}\n`
|
|
240
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
241
|
+
await appendFile(filePath, payload, "utf8")
|
|
242
|
+
this.applyEvent(event)
|
|
243
|
+
})
|
|
244
|
+
return this.writeChain
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async openProject(localPath: string, title?: string) {
|
|
248
|
+
const normalized = resolveLocalPath(localPath)
|
|
249
|
+
const existingId = this.state.projectIdsByPath.get(normalized)
|
|
250
|
+
if (existingId) {
|
|
251
|
+
const existing = this.state.projectsById.get(existingId)
|
|
252
|
+
if (existing && !existing.deletedAt) {
|
|
253
|
+
return existing
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const projectId = crypto.randomUUID()
|
|
258
|
+
const event: ProjectEvent = {
|
|
259
|
+
v: STORE_VERSION,
|
|
260
|
+
type: "project_opened",
|
|
261
|
+
timestamp: Date.now(),
|
|
262
|
+
projectId,
|
|
263
|
+
localPath: normalized,
|
|
264
|
+
title: title?.trim() || path.basename(normalized) || normalized,
|
|
265
|
+
}
|
|
266
|
+
await this.append(PROJECTS_LOG, event)
|
|
267
|
+
return this.state.projectsById.get(projectId)!
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async removeProject(projectId: string) {
|
|
271
|
+
const project = this.getProject(projectId)
|
|
272
|
+
if (!project) {
|
|
273
|
+
throw new Error("Project not found")
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const event: ProjectEvent = {
|
|
277
|
+
v: STORE_VERSION,
|
|
278
|
+
type: "project_removed",
|
|
279
|
+
timestamp: Date.now(),
|
|
280
|
+
projectId,
|
|
281
|
+
}
|
|
282
|
+
await this.append(PROJECTS_LOG, event)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async createChat(projectId: string) {
|
|
286
|
+
const project = this.state.projectsById.get(projectId)
|
|
287
|
+
if (!project || project.deletedAt) {
|
|
288
|
+
throw new Error("Project not found")
|
|
289
|
+
}
|
|
290
|
+
const chatId = crypto.randomUUID()
|
|
291
|
+
const event: ChatEvent = {
|
|
292
|
+
v: STORE_VERSION,
|
|
293
|
+
type: "chat_created",
|
|
294
|
+
timestamp: Date.now(),
|
|
295
|
+
chatId,
|
|
296
|
+
projectId,
|
|
297
|
+
title: "New Chat",
|
|
298
|
+
}
|
|
299
|
+
await this.append(CHATS_LOG, event)
|
|
300
|
+
return this.state.chatsById.get(chatId)!
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async renameChat(chatId: string, title: string) {
|
|
304
|
+
const trimmed = title.trim()
|
|
305
|
+
if (!trimmed) return
|
|
306
|
+
const chat = this.requireChat(chatId)
|
|
307
|
+
if (chat.title === trimmed) return
|
|
308
|
+
const event: ChatEvent = {
|
|
309
|
+
v: STORE_VERSION,
|
|
310
|
+
type: "chat_renamed",
|
|
311
|
+
timestamp: Date.now(),
|
|
312
|
+
chatId,
|
|
313
|
+
title: trimmed,
|
|
314
|
+
}
|
|
315
|
+
await this.append(CHATS_LOG, event)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async deleteChat(chatId: string) {
|
|
319
|
+
this.requireChat(chatId)
|
|
320
|
+
const event: ChatEvent = {
|
|
321
|
+
v: STORE_VERSION,
|
|
322
|
+
type: "chat_deleted",
|
|
323
|
+
timestamp: Date.now(),
|
|
324
|
+
chatId,
|
|
325
|
+
}
|
|
326
|
+
await this.append(CHATS_LOG, event)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async setPlanMode(chatId: string, planMode: boolean) {
|
|
330
|
+
const chat = this.requireChat(chatId)
|
|
331
|
+
if (chat.planMode === planMode) return
|
|
332
|
+
const event: ChatEvent = {
|
|
333
|
+
v: STORE_VERSION,
|
|
334
|
+
type: "chat_plan_mode_set",
|
|
335
|
+
timestamp: Date.now(),
|
|
336
|
+
chatId,
|
|
337
|
+
planMode,
|
|
338
|
+
}
|
|
339
|
+
await this.append(CHATS_LOG, event)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async appendMessage(chatId: string, entry: TranscriptEntry) {
|
|
343
|
+
this.requireChat(chatId)
|
|
344
|
+
const event: MessageEvent = {
|
|
345
|
+
v: STORE_VERSION,
|
|
346
|
+
type: "message_appended",
|
|
347
|
+
timestamp: Date.now(),
|
|
348
|
+
chatId,
|
|
349
|
+
entry,
|
|
350
|
+
}
|
|
351
|
+
await this.append(MESSAGES_LOG, event)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async recordTurnStarted(chatId: string) {
|
|
355
|
+
this.requireChat(chatId)
|
|
356
|
+
const event: TurnEvent = {
|
|
357
|
+
v: STORE_VERSION,
|
|
358
|
+
type: "turn_started",
|
|
359
|
+
timestamp: Date.now(),
|
|
360
|
+
chatId,
|
|
361
|
+
}
|
|
362
|
+
await this.append(TURNS_LOG, event)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async recordTurnFinished(chatId: string) {
|
|
366
|
+
this.requireChat(chatId)
|
|
367
|
+
const event: TurnEvent = {
|
|
368
|
+
v: STORE_VERSION,
|
|
369
|
+
type: "turn_finished",
|
|
370
|
+
timestamp: Date.now(),
|
|
371
|
+
chatId,
|
|
372
|
+
}
|
|
373
|
+
await this.append(TURNS_LOG, event)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async recordTurnFailed(chatId: string, error: string) {
|
|
377
|
+
this.requireChat(chatId)
|
|
378
|
+
const event: TurnEvent = {
|
|
379
|
+
v: STORE_VERSION,
|
|
380
|
+
type: "turn_failed",
|
|
381
|
+
timestamp: Date.now(),
|
|
382
|
+
chatId,
|
|
383
|
+
error,
|
|
384
|
+
}
|
|
385
|
+
await this.append(TURNS_LOG, event)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async recordTurnCancelled(chatId: string) {
|
|
389
|
+
this.requireChat(chatId)
|
|
390
|
+
const event: TurnEvent = {
|
|
391
|
+
v: STORE_VERSION,
|
|
392
|
+
type: "turn_cancelled",
|
|
393
|
+
timestamp: Date.now(),
|
|
394
|
+
chatId,
|
|
395
|
+
}
|
|
396
|
+
await this.append(TURNS_LOG, event)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async setResumeSession(chatId: string, sessionId: string | null) {
|
|
400
|
+
const chat = this.requireChat(chatId)
|
|
401
|
+
if (chat.resumeSessionId === sessionId) return
|
|
402
|
+
const event: TurnEvent = {
|
|
403
|
+
v: STORE_VERSION,
|
|
404
|
+
type: "resume_session_set",
|
|
405
|
+
timestamp: Date.now(),
|
|
406
|
+
chatId,
|
|
407
|
+
sessionId,
|
|
408
|
+
}
|
|
409
|
+
await this.append(TURNS_LOG, event)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
getProject(projectId: string) {
|
|
413
|
+
const project = this.state.projectsById.get(projectId)
|
|
414
|
+
if (!project || project.deletedAt) return null
|
|
415
|
+
return project
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
requireChat(chatId: string) {
|
|
419
|
+
const chat = this.state.chatsById.get(chatId)
|
|
420
|
+
if (!chat || chat.deletedAt) {
|
|
421
|
+
throw new Error("Chat not found")
|
|
422
|
+
}
|
|
423
|
+
return chat
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
getChat(chatId: string) {
|
|
427
|
+
const chat = this.state.chatsById.get(chatId)
|
|
428
|
+
if (!chat || chat.deletedAt) return null
|
|
429
|
+
return chat
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
getMessages(chatId: string) {
|
|
433
|
+
return cloneTranscriptEntries(this.state.messagesByChatId.get(chatId) ?? [])
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
listProjects() {
|
|
437
|
+
return [...this.state.projectsById.values()].filter((project) => !project.deletedAt)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
listChatsByProject(projectId: string) {
|
|
441
|
+
return [...this.state.chatsById.values()]
|
|
442
|
+
.filter((chat) => chat.projectId === projectId && !chat.deletedAt)
|
|
443
|
+
.sort((a, b) => (b.lastMessageAt ?? b.updatedAt) - (a.lastMessageAt ?? a.updatedAt))
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
getChatCount(projectId: string) {
|
|
447
|
+
return this.listChatsByProject(projectId).length
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async compact() {
|
|
451
|
+
const snapshot: SnapshotFile = {
|
|
452
|
+
v: STORE_VERSION,
|
|
453
|
+
generatedAt: Date.now(),
|
|
454
|
+
projects: this.listProjects().map((project) => ({ ...project })),
|
|
455
|
+
chats: [...this.state.chatsById.values()]
|
|
456
|
+
.filter((chat) => !chat.deletedAt)
|
|
457
|
+
.map((chat) => ({ ...chat })),
|
|
458
|
+
messages: [...this.state.messagesByChatId.entries()].map(([chatId, entries]) => ({
|
|
459
|
+
chatId,
|
|
460
|
+
entries: cloneTranscriptEntries(entries),
|
|
461
|
+
})),
|
|
462
|
+
}
|
|
463
|
+
await Bun.write(SNAPSHOT_PATH, JSON.stringify(snapshot, null, 2))
|
|
464
|
+
await Bun.write(PROJECTS_LOG, "")
|
|
465
|
+
await Bun.write(CHATS_LOG, "")
|
|
466
|
+
await Bun.write(MESSAGES_LOG, "")
|
|
467
|
+
await Bun.write(TURNS_LOG, "")
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private async shouldCompact() {
|
|
471
|
+
const files = [PROJECTS_LOG, CHATS_LOG, MESSAGES_LOG, TURNS_LOG]
|
|
472
|
+
let total = 0
|
|
473
|
+
for (const filePath of files) {
|
|
474
|
+
total += Bun.file(filePath).size
|
|
475
|
+
}
|
|
476
|
+
return total >= COMPACTION_THRESHOLD_BYTES
|
|
477
|
+
}
|
|
478
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ProjectSummary, TranscriptEntry } from "../shared/types"
|
|
2
|
+
|
|
3
|
+
export interface ProjectRecord extends ProjectSummary {
|
|
4
|
+
deletedAt?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ChatRecord {
|
|
8
|
+
id: string
|
|
9
|
+
projectId: string
|
|
10
|
+
title: string
|
|
11
|
+
createdAt: number
|
|
12
|
+
updatedAt: number
|
|
13
|
+
deletedAt?: number
|
|
14
|
+
planMode: boolean
|
|
15
|
+
resumeSessionId: string | null
|
|
16
|
+
lastMessageAt?: number
|
|
17
|
+
lastTurnOutcome: "success" | "failed" | "cancelled" | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StoreState {
|
|
21
|
+
projectsById: Map<string, ProjectRecord>
|
|
22
|
+
projectIdsByPath: Map<string, string>
|
|
23
|
+
chatsById: Map<string, ChatRecord>
|
|
24
|
+
messagesByChatId: Map<string, TranscriptEntry[]>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SnapshotFile {
|
|
28
|
+
v: 1
|
|
29
|
+
generatedAt: number
|
|
30
|
+
projects: ProjectRecord[]
|
|
31
|
+
chats: ChatRecord[]
|
|
32
|
+
messages: Array<{ chatId: string; entries: TranscriptEntry[] }>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ProjectEvent = {
|
|
36
|
+
v: 1
|
|
37
|
+
type: "project_opened"
|
|
38
|
+
timestamp: number
|
|
39
|
+
projectId: string
|
|
40
|
+
localPath: string
|
|
41
|
+
title: string
|
|
42
|
+
} | {
|
|
43
|
+
v: 1
|
|
44
|
+
type: "project_removed"
|
|
45
|
+
timestamp: number
|
|
46
|
+
projectId: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ChatEvent =
|
|
50
|
+
| {
|
|
51
|
+
v: 1
|
|
52
|
+
type: "chat_created"
|
|
53
|
+
timestamp: number
|
|
54
|
+
chatId: string
|
|
55
|
+
projectId: string
|
|
56
|
+
title: string
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
v: 1
|
|
60
|
+
type: "chat_renamed"
|
|
61
|
+
timestamp: number
|
|
62
|
+
chatId: string
|
|
63
|
+
title: string
|
|
64
|
+
}
|
|
65
|
+
| {
|
|
66
|
+
v: 1
|
|
67
|
+
type: "chat_deleted"
|
|
68
|
+
timestamp: number
|
|
69
|
+
chatId: string
|
|
70
|
+
}
|
|
71
|
+
| {
|
|
72
|
+
v: 1
|
|
73
|
+
type: "chat_plan_mode_set"
|
|
74
|
+
timestamp: number
|
|
75
|
+
chatId: string
|
|
76
|
+
planMode: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type MessageEvent = {
|
|
80
|
+
v: 1
|
|
81
|
+
type: "message_appended"
|
|
82
|
+
timestamp: number
|
|
83
|
+
chatId: string
|
|
84
|
+
entry: TranscriptEntry
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type TurnEvent =
|
|
88
|
+
| {
|
|
89
|
+
v: 1
|
|
90
|
+
type: "turn_started"
|
|
91
|
+
timestamp: number
|
|
92
|
+
chatId: string
|
|
93
|
+
}
|
|
94
|
+
| {
|
|
95
|
+
v: 1
|
|
96
|
+
type: "turn_finished"
|
|
97
|
+
timestamp: number
|
|
98
|
+
chatId: string
|
|
99
|
+
}
|
|
100
|
+
| {
|
|
101
|
+
v: 1
|
|
102
|
+
type: "turn_failed"
|
|
103
|
+
timestamp: number
|
|
104
|
+
chatId: string
|
|
105
|
+
error: string
|
|
106
|
+
}
|
|
107
|
+
| {
|
|
108
|
+
v: 1
|
|
109
|
+
type: "turn_cancelled"
|
|
110
|
+
timestamp: number
|
|
111
|
+
chatId: string
|
|
112
|
+
}
|
|
113
|
+
| {
|
|
114
|
+
v: 1
|
|
115
|
+
type: "resume_session_set"
|
|
116
|
+
timestamp: number
|
|
117
|
+
chatId: string
|
|
118
|
+
sessionId: string | null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type StoreEvent = ProjectEvent | ChatEvent | MessageEvent | TurnEvent
|
|
122
|
+
|
|
123
|
+
export function createEmptyState(): StoreState {
|
|
124
|
+
return {
|
|
125
|
+
projectsById: new Map(),
|
|
126
|
+
projectIdsByPath: new Map(),
|
|
127
|
+
chatsById: new Map(),
|
|
128
|
+
messagesByChatId: new Map(),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function cloneTranscriptEntries(entries: TranscriptEntry[]): TranscriptEntry[] {
|
|
133
|
+
return entries.map((entry) => ({ ...entry }))
|
|
134
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import process from "node:process"
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
3
|
+
import type { ClientCommand } from "../shared/protocol"
|
|
4
|
+
import { resolveLocalPath } from "./paths"
|
|
5
|
+
|
|
6
|
+
type OpenExternalAction = Extract<ClientCommand, { type: "system.openExternal" }>["action"]
|
|
7
|
+
|
|
8
|
+
function spawnDetached(command: string, args: string[]) {
|
|
9
|
+
spawn(command, args, { stdio: "ignore", detached: true }).unref()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hasCommand(command: string) {
|
|
13
|
+
const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
|
|
14
|
+
return result.status === 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function canOpenMacApp(appName: string) {
|
|
18
|
+
const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
|
|
19
|
+
return result.status === 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function openExternal(localPath: string, action: OpenExternalAction) {
|
|
23
|
+
const resolvedPath = resolveLocalPath(localPath)
|
|
24
|
+
const platform = process.platform
|
|
25
|
+
|
|
26
|
+
if (platform === "darwin") {
|
|
27
|
+
if (action === "open_finder") {
|
|
28
|
+
spawnDetached("open", [resolvedPath])
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
if (action === "open_terminal") {
|
|
32
|
+
spawnDetached("open", ["-a", "Terminal", resolvedPath])
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
if (action === "open_editor") {
|
|
36
|
+
if (hasCommand("cursor")) {
|
|
37
|
+
spawnDetached("cursor", [resolvedPath])
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
for (const appName of ["Cursor", "Visual Studio Code", "Windsurf"]) {
|
|
41
|
+
if (!canOpenMacApp(appName)) continue
|
|
42
|
+
spawnDetached("open", ["-a", appName, resolvedPath])
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
spawnDetached("open", [resolvedPath])
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (platform === "win32") {
|
|
51
|
+
if (action === "open_finder") {
|
|
52
|
+
spawnDetached("explorer", [resolvedPath])
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
if (action === "open_terminal") {
|
|
56
|
+
if (hasCommand("wt")) {
|
|
57
|
+
spawnDetached("wt", ["-d", resolvedPath])
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
spawnDetached("cmd", ["/c", "start", "", "cmd", "/K", `cd /d ${resolvedPath}`])
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if (action === "open_editor") {
|
|
64
|
+
if (hasCommand("cursor")) {
|
|
65
|
+
spawnDetached("cursor", [resolvedPath])
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
if (hasCommand("code")) {
|
|
69
|
+
spawnDetached("code", [resolvedPath])
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
spawnDetached("explorer", [resolvedPath])
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (action === "open_finder") {
|
|
78
|
+
spawnDetached("xdg-open", [resolvedPath])
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
if (action === "open_terminal") {
|
|
82
|
+
for (const command of ["x-terminal-emulator", "gnome-terminal", "konsole"]) {
|
|
83
|
+
if (!hasCommand(command)) continue
|
|
84
|
+
if (command === "gnome-terminal") {
|
|
85
|
+
spawnDetached(command, ["--working-directory", resolvedPath])
|
|
86
|
+
} else if (command === "konsole") {
|
|
87
|
+
spawnDetached(command, ["--workdir", resolvedPath])
|
|
88
|
+
} else {
|
|
89
|
+
spawnDetached(command, ["--working-directory", resolvedPath])
|
|
90
|
+
}
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
spawnDetached("xdg-open", [resolvedPath])
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (hasCommand("cursor")) {
|
|
97
|
+
spawnDetached("cursor", [resolvedPath])
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
if (hasCommand("code")) {
|
|
101
|
+
spawnDetached("code", [resolvedPath])
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
spawnDetached("xdg-open", [resolvedPath])
|
|
105
|
+
}
|