kaizenai 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.
Files changed (74) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +246 -0
  3. package/bin/kaizen +15 -0
  4. package/dist/client/apple-touch-icon.png +0 -0
  5. package/dist/client/assets/index-D-ORCGrq.js +603 -0
  6. package/dist/client/assets/index-r28mcHqz.css +32 -0
  7. package/dist/client/favicon.png +0 -0
  8. package/dist/client/fonts/body-medium.woff2 +0 -0
  9. package/dist/client/fonts/body-regular-italic.woff2 +0 -0
  10. package/dist/client/fonts/body-regular.woff2 +0 -0
  11. package/dist/client/fonts/body-semibold.woff2 +0 -0
  12. package/dist/client/index.html +22 -0
  13. package/dist/client/manifest-dark.webmanifest +24 -0
  14. package/dist/client/manifest.webmanifest +24 -0
  15. package/dist/client/pwa-192.png +0 -0
  16. package/dist/client/pwa-512.png +0 -0
  17. package/dist/client/pwa-icon.svg +15 -0
  18. package/dist/client/pwa-splash.png +0 -0
  19. package/dist/client/pwa-splash.svg +15 -0
  20. package/package.json +103 -0
  21. package/src/server/acp-shared.ts +315 -0
  22. package/src/server/agent.ts +1120 -0
  23. package/src/server/attachments.ts +133 -0
  24. package/src/server/backgrounds.ts +74 -0
  25. package/src/server/cli-runtime.ts +333 -0
  26. package/src/server/cli-supervisor.ts +81 -0
  27. package/src/server/cli.ts +68 -0
  28. package/src/server/codex-app-server-protocol.ts +453 -0
  29. package/src/server/codex-app-server.ts +1350 -0
  30. package/src/server/cursor-acp.ts +819 -0
  31. package/src/server/discovery.ts +322 -0
  32. package/src/server/event-store.ts +1369 -0
  33. package/src/server/events.ts +244 -0
  34. package/src/server/external-open.ts +272 -0
  35. package/src/server/gemini-acp.ts +844 -0
  36. package/src/server/gemini-cli.ts +525 -0
  37. package/src/server/generate-title.ts +36 -0
  38. package/src/server/git-manager.ts +79 -0
  39. package/src/server/git-repository.ts +101 -0
  40. package/src/server/harness-types.ts +20 -0
  41. package/src/server/keybindings.ts +177 -0
  42. package/src/server/machine-name.ts +22 -0
  43. package/src/server/paths.ts +112 -0
  44. package/src/server/process-utils.ts +22 -0
  45. package/src/server/project-icon.ts +344 -0
  46. package/src/server/project-metadata.ts +10 -0
  47. package/src/server/provider-catalog.ts +85 -0
  48. package/src/server/provider-settings.ts +155 -0
  49. package/src/server/quick-response.ts +153 -0
  50. package/src/server/read-models.ts +275 -0
  51. package/src/server/recovery.ts +507 -0
  52. package/src/server/restart.ts +30 -0
  53. package/src/server/server.ts +244 -0
  54. package/src/server/terminal-manager.ts +350 -0
  55. package/src/server/theme-settings.ts +179 -0
  56. package/src/server/update-manager.ts +230 -0
  57. package/src/server/usage/base-provider-usage.ts +57 -0
  58. package/src/server/usage/claude-usage.ts +558 -0
  59. package/src/server/usage/codex-usage.ts +144 -0
  60. package/src/server/usage/cursor-browser.ts +120 -0
  61. package/src/server/usage/cursor-cookies.ts +390 -0
  62. package/src/server/usage/cursor-usage.ts +490 -0
  63. package/src/server/usage/gemini-usage.ts +24 -0
  64. package/src/server/usage/provider-usage.ts +61 -0
  65. package/src/server/usage/test-helpers.ts +9 -0
  66. package/src/server/usage/types.ts +54 -0
  67. package/src/server/usage/utils.ts +325 -0
  68. package/src/server/ws-router.ts +717 -0
  69. package/src/shared/branding.ts +83 -0
  70. package/src/shared/dev-ports.ts +43 -0
  71. package/src/shared/ports.ts +2 -0
  72. package/src/shared/protocol.ts +152 -0
  73. package/src/shared/tools.ts +251 -0
  74. package/src/shared/types.ts +1028 -0
@@ -0,0 +1,1369 @@
1
+ import { appendFile, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"
2
+ import { existsSync, readFileSync as readFileSyncImmediate } from "node:fs"
3
+ import { homedir } from "node:os"
4
+ import path from "node:path"
5
+ import { getDataDir, LOG_PREFIX, PROJECT_METADATA_DIR_NAME } from "../shared/branding"
6
+ import { FEATURE_BROWSER_STATES, FEATURE_STAGES, type AgentProvider, type FeatureBrowserState, type FeatureStage, type TranscriptEntry } from "../shared/types"
7
+ import { STORE_VERSION } from "../shared/types"
8
+ import {
9
+ type ChatEvent,
10
+ type FeatureEvent,
11
+ type MessageEvent,
12
+ type ProjectEvent,
13
+ type SnapshotFile,
14
+ type StoreEvent,
15
+ type StoreState,
16
+ type TurnEvent,
17
+ cloneTranscriptEntries,
18
+ createEmptyState,
19
+ } from "./events"
20
+ import { resolveProjectRepositoryIdentity, resolveProjectWorktreePaths } from "./git-repository"
21
+ import { resolveLocalPath } from "./paths"
22
+ import { getProjectMetadataDirPath } from "./project-metadata"
23
+
24
+ const COMPACTION_THRESHOLD_BYTES = 2 * 1024 * 1024
25
+ const FEATURE_METADATA_VERSION = 1 as const
26
+
27
+ interface PersistedProjectFeature {
28
+ v: typeof FEATURE_METADATA_VERSION
29
+ title: string
30
+ description: string
31
+ browserState: FeatureBrowserState
32
+ stage: FeatureStage
33
+ sortOrder: number
34
+ directoryRelativePath: string
35
+ overviewRelativePath: string
36
+ chatKeys: string[]
37
+ }
38
+
39
+ interface LegacyTranscriptStats {
40
+ hasLegacyData: boolean
41
+ sources: Array<"snapshot" | "messages_log">
42
+ chatCount: number
43
+ entryCount: number
44
+ }
45
+
46
+ export class EventStore {
47
+ readonly dataDir: string
48
+ readonly state: StoreState = createEmptyState()
49
+ private writeChain = Promise.resolve()
50
+ private storageReset = false
51
+ private readonly snapshotPath: string
52
+ private readonly projectsLogPath: string
53
+ private readonly featuresLogPath: string
54
+ private readonly chatsLogPath: string
55
+ private readonly messagesLogPath: string
56
+ private readonly turnsLogPath: string
57
+ private readonly transcriptsDir: string
58
+ private legacyMessagesByChatId = new Map<string, TranscriptEntry[]>()
59
+ private snapshotHasLegacyMessages = false
60
+ private cachedTranscripts = new Map<string, TranscriptEntry[]>()
61
+
62
+ constructor(dataDir = getDataDir(homedir())) {
63
+ this.dataDir = dataDir
64
+ this.snapshotPath = path.join(this.dataDir, "snapshot.json")
65
+ this.projectsLogPath = path.join(this.dataDir, "projects.jsonl")
66
+ this.featuresLogPath = path.join(this.dataDir, "features.jsonl")
67
+ this.chatsLogPath = path.join(this.dataDir, "chats.jsonl")
68
+ this.messagesLogPath = path.join(this.dataDir, "messages.jsonl")
69
+ this.turnsLogPath = path.join(this.dataDir, "turns.jsonl")
70
+ this.transcriptsDir = path.join(this.dataDir, "transcripts")
71
+ }
72
+
73
+ async initialize() {
74
+ await mkdir(this.dataDir, { recursive: true })
75
+ await mkdir(this.transcriptsDir, { recursive: true })
76
+ await this.ensureFile(this.projectsLogPath)
77
+ await this.ensureFile(this.featuresLogPath)
78
+ await this.ensureFile(this.chatsLogPath)
79
+ await this.ensureFile(this.messagesLogPath)
80
+ await this.ensureFile(this.turnsLogPath)
81
+ await this.loadSnapshot()
82
+ await this.replayLogs()
83
+ this.hydrateProjectWorktrees()
84
+ if (!(await this.hasLegacyTranscriptData()) && await this.shouldCompact()) {
85
+ await this.compact()
86
+ }
87
+ }
88
+
89
+ private hydrateProjectWorktrees() {
90
+ for (const project of this.state.projectsById.values()) {
91
+ if (project.deletedAt || !project.repoKey.startsWith("git:")) continue
92
+ const resolvedPaths = resolveProjectWorktreePaths(project.localPath)
93
+ for (const worktreePath of resolvedPaths) {
94
+ if (!project.worktreePaths.includes(worktreePath)) {
95
+ project.worktreePaths.push(worktreePath)
96
+ }
97
+ this.state.projectIdsByPath.set(worktreePath, project.id)
98
+ }
99
+ }
100
+ }
101
+
102
+ private async ensureFile(filePath: string) {
103
+ const file = Bun.file(filePath)
104
+ if (!(await file.exists())) {
105
+ await Bun.write(filePath, "")
106
+ }
107
+ }
108
+
109
+ private async clearStorage() {
110
+ if (this.storageReset) return
111
+ this.storageReset = true
112
+ this.resetState()
113
+ this.clearLegacyTranscriptState()
114
+ await Promise.all([
115
+ Bun.write(this.snapshotPath, ""),
116
+ Bun.write(this.projectsLogPath, ""),
117
+ Bun.write(this.featuresLogPath, ""),
118
+ Bun.write(this.chatsLogPath, ""),
119
+ Bun.write(this.messagesLogPath, ""),
120
+ Bun.write(this.turnsLogPath, ""),
121
+ ])
122
+ }
123
+
124
+ private async loadSnapshot() {
125
+ const file = Bun.file(this.snapshotPath)
126
+ if (!(await file.exists())) return
127
+
128
+ try {
129
+ const text = await file.text()
130
+ if (!text.trim()) return
131
+ const parsed = JSON.parse(text) as SnapshotFile
132
+ if (parsed.v !== STORE_VERSION) {
133
+ console.warn(`${LOG_PREFIX} Resetting local chat history for store version ${STORE_VERSION}`)
134
+ await this.clearStorage()
135
+ return
136
+ }
137
+ for (const project of parsed.projects) {
138
+ this.state.projectsById.set(project.id, {
139
+ ...project,
140
+ browserState: project.browserState ?? "OPEN",
141
+ generalChatsBrowserState: project.generalChatsBrowserState ?? "OPEN",
142
+ })
143
+ this.state.projectIdsByRepoKey.set(project.repoKey, project.id)
144
+ for (const worktreePath of project.worktreePaths) {
145
+ this.state.projectIdsByPath.set(worktreePath, project.id)
146
+ }
147
+ }
148
+ for (const feature of parsed.features ?? []) {
149
+ this.state.featuresById.set(feature.id, {
150
+ ...feature,
151
+ browserState: feature.browserState ?? "OPEN",
152
+ })
153
+ }
154
+ for (const chat of parsed.chats) {
155
+ this.state.chatsById.set(chat.id, { ...chat })
156
+ }
157
+ if (parsed.messages?.length) {
158
+ this.snapshotHasLegacyMessages = true
159
+ for (const messageSet of parsed.messages) {
160
+ this.legacyMessagesByChatId.set(messageSet.chatId, cloneTranscriptEntries(messageSet.entries))
161
+ }
162
+ }
163
+ for (const repoKey of parsed.hiddenProjectKeys ?? []) {
164
+ this.state.hiddenProjectKeys.add(repoKey)
165
+ }
166
+ } catch (error) {
167
+ console.warn(`${LOG_PREFIX} Failed to load snapshot, resetting local history:`, error)
168
+ await this.clearStorage()
169
+ }
170
+ }
171
+
172
+ private resetState() {
173
+ this.state.projectsById.clear()
174
+ this.state.projectIdsByRepoKey.clear()
175
+ this.state.projectIdsByPath.clear()
176
+ this.state.featuresById.clear()
177
+ this.state.chatsById.clear()
178
+ this.state.hiddenProjectKeys.clear()
179
+ this.cachedTranscripts.clear()
180
+ }
181
+
182
+ private clearLegacyTranscriptState() {
183
+ this.legacyMessagesByChatId.clear()
184
+ this.snapshotHasLegacyMessages = false
185
+ }
186
+
187
+ private getCachedTranscript(chatId: string) {
188
+ const cached = this.cachedTranscripts.get(chatId)
189
+ if (!cached) return null
190
+ this.cachedTranscripts.delete(chatId)
191
+ this.cachedTranscripts.set(chatId, cached)
192
+ return cached
193
+ }
194
+
195
+ private setCachedTranscript(chatId: string, entries: TranscriptEntry[]) {
196
+ this.cachedTranscripts.set(chatId, entries)
197
+ }
198
+
199
+ private async replayLogs() {
200
+ if (this.storageReset) return
201
+ await this.replayLog<ProjectEvent>(this.projectsLogPath)
202
+ if (this.storageReset) return
203
+ await this.replayLog<FeatureEvent>(this.featuresLogPath)
204
+ if (this.storageReset) return
205
+ await this.replayLog<ChatEvent>(this.chatsLogPath)
206
+ if (this.storageReset) return
207
+ await this.replayLog<MessageEvent>(this.messagesLogPath)
208
+ if (this.storageReset) return
209
+ await this.replayLog<TurnEvent>(this.turnsLogPath)
210
+ }
211
+
212
+ private async replayLog<TEvent extends StoreEvent>(filePath: string) {
213
+ const file = Bun.file(filePath)
214
+ if (!(await file.exists())) return
215
+ const text = await file.text()
216
+ if (!text.trim()) return
217
+
218
+ const lines = text.split("\n")
219
+ let lastNonEmpty = -1
220
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
221
+ if (lines[index].trim()) {
222
+ lastNonEmpty = index
223
+ break
224
+ }
225
+ }
226
+
227
+ for (let index = 0; index < lines.length; index += 1) {
228
+ const line = lines[index].trim()
229
+ if (!line) continue
230
+ try {
231
+ const event = JSON.parse(line) as Partial<StoreEvent>
232
+ if (event.v !== STORE_VERSION) {
233
+ console.warn(`${LOG_PREFIX} Resetting local history from incompatible event log`)
234
+ await this.clearStorage()
235
+ return
236
+ }
237
+ this.applyEvent(event as StoreEvent)
238
+ } catch (error) {
239
+ if (index === lastNonEmpty) {
240
+ console.warn(`${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(filePath)}`)
241
+ return
242
+ }
243
+ console.warn(`${LOG_PREFIX} Failed to replay ${path.basename(filePath)}, resetting local history:`, error)
244
+ await this.clearStorage()
245
+ return
246
+ }
247
+ }
248
+ }
249
+
250
+ private applyEvent(event: StoreEvent) {
251
+ switch (event.type) {
252
+ case "project_opened": {
253
+ const localPath = resolveLocalPath(event.localPath)
254
+ const project = {
255
+ id: event.projectId,
256
+ repoKey: event.repoKey,
257
+ localPath,
258
+ worktreePaths: event.worktreePaths.map((worktreePath) => resolveLocalPath(worktreePath)),
259
+ title: event.title,
260
+ browserState: event.browserState ?? "OPEN",
261
+ generalChatsBrowserState: event.generalChatsBrowserState ?? "OPEN",
262
+ createdAt: event.timestamp,
263
+ updatedAt: event.timestamp,
264
+ }
265
+ this.state.projectsById.set(project.id, project)
266
+ this.state.projectIdsByRepoKey.set(project.repoKey, project.id)
267
+ for (const worktreePath of project.worktreePaths) {
268
+ this.state.projectIdsByPath.set(worktreePath, project.id)
269
+ }
270
+ break
271
+ }
272
+ case "project_worktree_added": {
273
+ const project = this.state.projectsById.get(event.projectId)
274
+ if (!project) break
275
+ const localPath = resolveLocalPath(event.localPath)
276
+ if (!project.worktreePaths.includes(localPath)) {
277
+ project.worktreePaths.push(localPath)
278
+ }
279
+ project.localPath = localPath
280
+ project.updatedAt = event.timestamp
281
+ this.state.projectIdsByPath.set(localPath, project.id)
282
+ break
283
+ }
284
+ case "project_browser_state_set": {
285
+ const project = this.state.projectsById.get(event.projectId)
286
+ if (!project) break
287
+ project.browserState = event.browserState
288
+ project.updatedAt = event.timestamp
289
+ break
290
+ }
291
+ case "project_general_chats_browser_state_set": {
292
+ const project = this.state.projectsById.get(event.projectId)
293
+ if (!project) break
294
+ project.generalChatsBrowserState = event.browserState
295
+ project.updatedAt = event.timestamp
296
+ break
297
+ }
298
+ case "project_removed": {
299
+ const project = this.state.projectsById.get(event.projectId)
300
+ if (!project) break
301
+ project.deletedAt = event.timestamp
302
+ project.updatedAt = event.timestamp
303
+ this.state.projectIdsByRepoKey.delete(project.repoKey)
304
+ for (const worktreePath of project.worktreePaths) {
305
+ this.state.projectIdsByPath.delete(worktreePath)
306
+ }
307
+ break
308
+ }
309
+ case "project_hidden": {
310
+ this.state.hiddenProjectKeys.add(event.repoKey)
311
+ break
312
+ }
313
+ case "project_unhidden": {
314
+ this.state.hiddenProjectKeys.delete(event.repoKey)
315
+ break
316
+ }
317
+ case "feature_created": {
318
+ this.state.featuresById.set(event.featureId, {
319
+ id: event.featureId,
320
+ projectId: event.projectId,
321
+ title: event.title,
322
+ description: event.description,
323
+ browserState: event.browserState ?? "OPEN",
324
+ stage: event.stage,
325
+ sortOrder: event.sortOrder,
326
+ directoryRelativePath: event.directoryRelativePath,
327
+ overviewRelativePath: event.overviewRelativePath,
328
+ createdAt: event.timestamp,
329
+ updatedAt: event.timestamp,
330
+ })
331
+ break
332
+ }
333
+ case "feature_renamed": {
334
+ const feature = this.state.featuresById.get(event.featureId)
335
+ if (!feature) break
336
+ feature.title = event.title
337
+ feature.updatedAt = event.timestamp
338
+ break
339
+ }
340
+ case "feature_browser_state_set": {
341
+ const feature = this.state.featuresById.get(event.featureId)
342
+ if (!feature) break
343
+ feature.browserState = event.browserState
344
+ feature.updatedAt = event.timestamp
345
+ break
346
+ }
347
+ case "feature_stage_set": {
348
+ const feature = this.state.featuresById.get(event.featureId)
349
+ if (!feature) break
350
+ feature.stage = event.stage
351
+ if (typeof event.sortOrder === "number") {
352
+ feature.sortOrder = event.sortOrder
353
+ }
354
+ feature.updatedAt = event.timestamp
355
+ break
356
+ }
357
+ case "feature_reordered": {
358
+ const visibleFeatures = event.orderedFeatureIds
359
+ .map((featureId) => this.state.featuresById.get(featureId))
360
+ .filter((feature): feature is NonNullable<typeof feature> => Boolean(feature && !feature.deletedAt))
361
+
362
+ visibleFeatures.forEach((feature, index) => {
363
+ feature.sortOrder = index
364
+ feature.updatedAt = event.timestamp
365
+ })
366
+ break
367
+ }
368
+ case "feature_deleted": {
369
+ const feature = this.state.featuresById.get(event.featureId)
370
+ if (!feature) break
371
+ feature.deletedAt = event.timestamp
372
+ feature.updatedAt = event.timestamp
373
+ for (const chat of this.state.chatsById.values()) {
374
+ if (chat.deletedAt || chat.featureId !== event.featureId) continue
375
+ chat.featureId = null
376
+ chat.updatedAt = event.timestamp
377
+ }
378
+ break
379
+ }
380
+ case "chat_created": {
381
+ const chat = {
382
+ id: event.chatId,
383
+ projectId: event.projectId,
384
+ title: event.title,
385
+ createdAt: event.timestamp,
386
+ updatedAt: event.timestamp,
387
+ featureId: event.featureId ?? null,
388
+ provider: null,
389
+ planMode: false,
390
+ sessionToken: null,
391
+ lastTurnOutcome: null,
392
+ }
393
+ this.state.chatsById.set(chat.id, chat)
394
+ break
395
+ }
396
+ case "chat_renamed": {
397
+ const chat = this.state.chatsById.get(event.chatId)
398
+ if (!chat) break
399
+ chat.title = event.title
400
+ chat.updatedAt = event.timestamp
401
+ break
402
+ }
403
+ case "chat_deleted": {
404
+ const chat = this.state.chatsById.get(event.chatId)
405
+ if (!chat) break
406
+ chat.deletedAt = event.timestamp
407
+ chat.updatedAt = event.timestamp
408
+ break
409
+ }
410
+ case "chat_provider_set": {
411
+ const chat = this.state.chatsById.get(event.chatId)
412
+ if (!chat) break
413
+ chat.provider = event.provider
414
+ chat.updatedAt = event.timestamp
415
+ break
416
+ }
417
+ case "chat_plan_mode_set": {
418
+ const chat = this.state.chatsById.get(event.chatId)
419
+ if (!chat) break
420
+ chat.planMode = event.planMode
421
+ chat.updatedAt = event.timestamp
422
+ break
423
+ }
424
+ case "chat_feature_set": {
425
+ const chat = this.state.chatsById.get(event.chatId)
426
+ if (!chat) break
427
+ chat.featureId = event.featureId
428
+ chat.updatedAt = event.timestamp
429
+ break
430
+ }
431
+ case "message_appended": {
432
+ this.applyMessageMetadata(event.chatId, event.entry)
433
+ const existing = this.legacyMessagesByChatId.get(event.chatId) ?? []
434
+ existing.push({ ...event.entry })
435
+ this.legacyMessagesByChatId.set(event.chatId, existing)
436
+ break
437
+ }
438
+ case "turn_started": {
439
+ const chat = this.state.chatsById.get(event.chatId)
440
+ if (!chat) break
441
+ chat.updatedAt = event.timestamp
442
+ break
443
+ }
444
+ case "turn_finished": {
445
+ const chat = this.state.chatsById.get(event.chatId)
446
+ if (!chat) break
447
+ chat.updatedAt = event.timestamp
448
+ chat.lastTurnOutcome = "success"
449
+ break
450
+ }
451
+ case "turn_failed": {
452
+ const chat = this.state.chatsById.get(event.chatId)
453
+ if (!chat) break
454
+ chat.updatedAt = event.timestamp
455
+ chat.lastTurnOutcome = "failed"
456
+ break
457
+ }
458
+ case "turn_cancelled": {
459
+ const chat = this.state.chatsById.get(event.chatId)
460
+ if (!chat) break
461
+ chat.updatedAt = event.timestamp
462
+ chat.lastTurnOutcome = "cancelled"
463
+ break
464
+ }
465
+ case "session_token_set": {
466
+ const chat = this.state.chatsById.get(event.chatId)
467
+ if (!chat) break
468
+ chat.sessionToken = event.sessionToken
469
+ chat.updatedAt = event.timestamp
470
+ break
471
+ }
472
+ }
473
+ }
474
+
475
+ private applyMessageMetadata(chatId: string, entry: TranscriptEntry) {
476
+ const chat = this.state.chatsById.get(chatId)
477
+ if (!chat) return
478
+ if (entry.kind === "user_prompt") {
479
+ chat.lastMessageAt = entry.createdAt
480
+ }
481
+ chat.updatedAt = Math.max(chat.updatedAt, entry.createdAt)
482
+ }
483
+
484
+ private append<TEvent extends StoreEvent>(filePath: string, event: TEvent) {
485
+ const payload = `${JSON.stringify(event)}\n`
486
+ this.writeChain = this.writeChain.then(async () => {
487
+ await appendFile(filePath, payload, "utf8")
488
+ this.applyEvent(event)
489
+ })
490
+ return this.writeChain
491
+ }
492
+
493
+ private transcriptPath(chatId: string) {
494
+ return path.join(this.transcriptsDir, `${chatId}.jsonl`)
495
+ }
496
+
497
+ private loadTranscriptFromDisk(chatId: string) {
498
+ const transcriptPath = this.transcriptPath(chatId)
499
+ if (!existsSync(transcriptPath)) {
500
+ return []
501
+ }
502
+
503
+ const text = readFileSyncImmediate(transcriptPath, "utf8")
504
+ if (!text.trim()) return []
505
+
506
+ const entries: TranscriptEntry[] = []
507
+ for (const rawLine of text.split("\n")) {
508
+ const line = rawLine.trim()
509
+ if (!line) continue
510
+ entries.push(JSON.parse(line) as TranscriptEntry)
511
+ }
512
+ return entries
513
+ }
514
+
515
+ async openProject(localPath: string, title?: string) {
516
+ const identity = resolveProjectRepositoryIdentity(localPath)
517
+ const worktreePaths = identity.isGitRepo ? resolveProjectWorktreePaths(identity.worktreePath) : [identity.worktreePath]
518
+ const existingId = this.state.projectIdsByRepoKey.get(identity.repoKey)
519
+ if (existingId) {
520
+ const existing = this.state.projectsById.get(existingId)
521
+ if (existing && !existing.deletedAt) {
522
+ const missingWorktreePaths = worktreePaths.filter((worktreePath) => !existing.worktreePaths.includes(worktreePath))
523
+ for (const missingWorktreePath of missingWorktreePaths) {
524
+ const event: ProjectEvent = {
525
+ v: STORE_VERSION,
526
+ type: "project_worktree_added",
527
+ timestamp: Date.now(),
528
+ projectId: existing.id,
529
+ localPath: missingWorktreePath,
530
+ }
531
+ await this.append(this.projectsLogPath, event)
532
+ }
533
+ if (existing.localPath !== identity.worktreePath && existing.worktreePaths.includes(identity.worktreePath)) {
534
+ const event: ProjectEvent = {
535
+ v: STORE_VERSION,
536
+ type: "project_worktree_added",
537
+ timestamp: Date.now(),
538
+ projectId: existing.id,
539
+ localPath: identity.worktreePath,
540
+ }
541
+ await this.append(this.projectsLogPath, event)
542
+ }
543
+ return this.state.projectsById.get(existing.id)!
544
+ }
545
+ }
546
+
547
+ const projectId = crypto.randomUUID()
548
+ const event: ProjectEvent = {
549
+ v: STORE_VERSION,
550
+ type: "project_opened",
551
+ timestamp: Date.now(),
552
+ projectId,
553
+ repoKey: identity.repoKey,
554
+ localPath: identity.worktreePath,
555
+ worktreePaths,
556
+ title: title?.trim() || identity.title,
557
+ browserState: "OPEN",
558
+ generalChatsBrowserState: "OPEN",
559
+ }
560
+ await this.append(this.projectsLogPath, event)
561
+ return this.state.projectsById.get(projectId)!
562
+ }
563
+
564
+ async setProjectBrowserState(projectId: string, browserState: FeatureBrowserState) {
565
+ const project = this.getProject(projectId)
566
+ if (!project) {
567
+ throw new Error("Project not found")
568
+ }
569
+ if (project.browserState === browserState) return
570
+ const event: ProjectEvent = {
571
+ v: STORE_VERSION,
572
+ type: "project_browser_state_set",
573
+ timestamp: Date.now(),
574
+ projectId,
575
+ browserState,
576
+ }
577
+ await this.append(this.projectsLogPath, event)
578
+ }
579
+
580
+ async setProjectGeneralChatsBrowserState(projectId: string, browserState: FeatureBrowserState) {
581
+ const project = this.getProject(projectId)
582
+ if (!project) {
583
+ throw new Error("Project not found")
584
+ }
585
+ if (project.generalChatsBrowserState === browserState) return
586
+ const event: ProjectEvent = {
587
+ v: STORE_VERSION,
588
+ type: "project_general_chats_browser_state_set",
589
+ timestamp: Date.now(),
590
+ projectId,
591
+ browserState,
592
+ }
593
+ await this.append(this.projectsLogPath, event)
594
+ }
595
+
596
+ async removeProject(projectId: string) {
597
+ const project = this.getProject(projectId)
598
+ if (!project) {
599
+ throw new Error("Project not found")
600
+ }
601
+ if (this.state.hiddenProjectKeys.has(project.repoKey)) return
602
+ const event: ProjectEvent = {
603
+ v: STORE_VERSION,
604
+ type: "project_hidden",
605
+ timestamp: Date.now(),
606
+ repoKey: project.repoKey,
607
+ }
608
+ await this.append(this.projectsLogPath, event)
609
+ }
610
+
611
+ async createFeature(projectId: string, title: string, description = "") {
612
+ const project = this.getProject(projectId)
613
+ if (!project) {
614
+ throw new Error("Project not found")
615
+ }
616
+ const trimmedTitle = title.trim()
617
+ const trimmedDescription = description.trim()
618
+ if (!trimmedTitle) {
619
+ throw new Error("Feature title is required")
620
+ }
621
+
622
+ const directoryName = this.generateUniqueFeatureDirectoryName(projectId, trimmedTitle)
623
+ const directoryRelativePath = path.posix.join(PROJECT_METADATA_DIR_NAME, directoryName)
624
+ const overviewRelativePath = path.posix.join(directoryRelativePath, "overview.md")
625
+ const featureDirPath = path.join(project.localPath, directoryRelativePath)
626
+ const overviewPath = path.join(project.localPath, overviewRelativePath)
627
+ const sortOrder = this.getNextFeatureSortOrder(projectId, "idea")
628
+
629
+ await mkdir(featureDirPath, { recursive: true })
630
+ await writeFile(overviewPath, this.buildFeatureOverviewContent({
631
+ projectTitle: project.title,
632
+ featureTitle: trimmedTitle,
633
+ description: trimmedDescription,
634
+ }), "utf8")
635
+
636
+ const featureId = crypto.randomUUID()
637
+ const event: FeatureEvent = {
638
+ v: STORE_VERSION,
639
+ type: "feature_created",
640
+ timestamp: Date.now(),
641
+ featureId,
642
+ projectId,
643
+ title: trimmedTitle,
644
+ description: trimmedDescription,
645
+ browserState: "OPEN",
646
+ stage: "idea",
647
+ sortOrder,
648
+ directoryRelativePath,
649
+ overviewRelativePath,
650
+ }
651
+ await this.append(this.featuresLogPath, event)
652
+ await this.syncProjectFeatureState(projectId)
653
+ return this.state.featuresById.get(featureId)!
654
+ }
655
+
656
+ async renameFeature(featureId: string, title: string) {
657
+ const trimmedTitle = title.trim()
658
+ if (!trimmedTitle) return
659
+ const feature = this.requireFeature(featureId)
660
+ if (feature.title === trimmedTitle) return
661
+ const event: FeatureEvent = {
662
+ v: STORE_VERSION,
663
+ type: "feature_renamed",
664
+ timestamp: Date.now(),
665
+ featureId,
666
+ title: trimmedTitle,
667
+ }
668
+ await this.append(this.featuresLogPath, event)
669
+ await this.syncProjectFeatureState(feature.projectId)
670
+ }
671
+
672
+ async setFeatureStage(featureId: string, stage: FeatureStage) {
673
+ const feature = this.requireFeature(featureId)
674
+ if (feature.stage === stage) return
675
+ const shouldRebaseOrder = feature.stage === "done" || stage === "done"
676
+ const event: FeatureEvent = {
677
+ v: STORE_VERSION,
678
+ type: "feature_stage_set",
679
+ timestamp: Date.now(),
680
+ featureId,
681
+ stage,
682
+ ...(shouldRebaseOrder
683
+ ? { sortOrder: this.getRebasedFeatureSortOrder(feature.projectId, featureId, stage) }
684
+ : {}),
685
+ }
686
+ await this.append(this.featuresLogPath, event)
687
+ await this.syncProjectFeatureState(feature.projectId, true)
688
+ }
689
+
690
+ async setFeatureBrowserState(featureId: string, browserState: FeatureBrowserState) {
691
+ const feature = this.requireFeature(featureId)
692
+ if (feature.browserState === browserState) return
693
+ const event: FeatureEvent = {
694
+ v: STORE_VERSION,
695
+ type: "feature_browser_state_set",
696
+ timestamp: Date.now(),
697
+ featureId,
698
+ browserState,
699
+ }
700
+ await this.append(this.featuresLogPath, event)
701
+ await this.syncProjectFeatureState(feature.projectId)
702
+ }
703
+
704
+ async reorderFeatures(projectId: string, orderedFeatureIds: string[]) {
705
+ const features = this.listFeaturesByProject(projectId)
706
+ const visibleIds = features.map((feature) => feature.id)
707
+ if (orderedFeatureIds.length !== visibleIds.length) {
708
+ throw new Error("Feature reorder payload is incomplete")
709
+ }
710
+ const orderedSet = new Set(orderedFeatureIds)
711
+ if (orderedSet.size !== visibleIds.length || visibleIds.some((id) => !orderedSet.has(id))) {
712
+ throw new Error("Feature reorder payload does not match project features")
713
+ }
714
+ const event: FeatureEvent = {
715
+ v: STORE_VERSION,
716
+ type: "feature_reordered",
717
+ timestamp: Date.now(),
718
+ projectId,
719
+ orderedFeatureIds,
720
+ }
721
+ await this.append(this.featuresLogPath, event)
722
+ await this.syncProjectFeatureState(projectId)
723
+ }
724
+
725
+ async deleteFeature(featureId: string) {
726
+ const feature = this.requireFeature(featureId)
727
+ const project = this.getProject(feature.projectId)
728
+ if (!project) {
729
+ throw new Error("Project not found")
730
+ }
731
+ await rm(path.join(project.localPath, feature.directoryRelativePath), { recursive: true, force: true })
732
+ const event: FeatureEvent = {
733
+ v: STORE_VERSION,
734
+ type: "feature_deleted",
735
+ timestamp: Date.now(),
736
+ featureId,
737
+ }
738
+ await this.append(this.featuresLogPath, event)
739
+ await this.syncProjectFeatureState(feature.projectId)
740
+ }
741
+
742
+ async hideProject(localPath: string) {
743
+ const identity = resolveProjectRepositoryIdentity(localPath)
744
+ if (this.state.hiddenProjectKeys.has(identity.repoKey)) return
745
+ const event: ProjectEvent = {
746
+ v: STORE_VERSION,
747
+ type: "project_hidden",
748
+ timestamp: Date.now(),
749
+ repoKey: identity.repoKey,
750
+ }
751
+ await this.append(this.projectsLogPath, event)
752
+ }
753
+
754
+ async unhideProject(localPath: string) {
755
+ const identity = resolveProjectRepositoryIdentity(localPath)
756
+ if (!this.state.hiddenProjectKeys.has(identity.repoKey)) return
757
+ const event: ProjectEvent = {
758
+ v: STORE_VERSION,
759
+ type: "project_unhidden",
760
+ timestamp: Date.now(),
761
+ repoKey: identity.repoKey,
762
+ }
763
+ await this.append(this.projectsLogPath, event)
764
+ }
765
+
766
+ async createChat(projectId: string, featureId?: string) {
767
+ const project = this.state.projectsById.get(projectId)
768
+ if (!project || project.deletedAt) {
769
+ throw new Error("Project not found")
770
+ }
771
+ if (featureId) {
772
+ const feature = this.requireFeature(featureId)
773
+ if (feature.projectId !== projectId) {
774
+ throw new Error("Feature does not belong to project")
775
+ }
776
+ }
777
+ const chatId = crypto.randomUUID()
778
+ const event: ChatEvent = {
779
+ v: STORE_VERSION,
780
+ type: "chat_created",
781
+ timestamp: Date.now(),
782
+ chatId,
783
+ projectId,
784
+ title: "New Chat",
785
+ ...(featureId ? { featureId } : {}),
786
+ }
787
+ await this.append(this.chatsLogPath, event)
788
+ return this.state.chatsById.get(chatId)!
789
+ }
790
+
791
+ async renameChat(chatId: string, title: string) {
792
+ const trimmed = title.trim()
793
+ if (!trimmed) return
794
+ const chat = this.requireChat(chatId)
795
+ if (chat.title === trimmed) return
796
+ const event: ChatEvent = {
797
+ v: STORE_VERSION,
798
+ type: "chat_renamed",
799
+ timestamp: Date.now(),
800
+ chatId,
801
+ title: trimmed,
802
+ }
803
+ await this.append(this.chatsLogPath, event)
804
+ }
805
+
806
+ async deleteChat(chatId: string) {
807
+ const chat = this.requireChat(chatId)
808
+ const event: ChatEvent = {
809
+ v: STORE_VERSION,
810
+ type: "chat_deleted",
811
+ timestamp: Date.now(),
812
+ chatId,
813
+ }
814
+ await this.append(this.chatsLogPath, event)
815
+ await this.syncProjectFeatureState(chat.projectId)
816
+ }
817
+
818
+ async setChatProvider(chatId: string, provider: AgentProvider) {
819
+ const chat = this.requireChat(chatId)
820
+ if (chat.provider === provider) return
821
+ const event: ChatEvent = {
822
+ v: STORE_VERSION,
823
+ type: "chat_provider_set",
824
+ timestamp: Date.now(),
825
+ chatId,
826
+ provider,
827
+ }
828
+ await this.append(this.chatsLogPath, event)
829
+ await this.syncProjectFeatureState(chat.projectId)
830
+ }
831
+
832
+ async setPlanMode(chatId: string, planMode: boolean) {
833
+ const chat = this.requireChat(chatId)
834
+ if (chat.planMode === planMode) return
835
+ const event: ChatEvent = {
836
+ v: STORE_VERSION,
837
+ type: "chat_plan_mode_set",
838
+ timestamp: Date.now(),
839
+ chatId,
840
+ planMode,
841
+ }
842
+ await this.append(this.chatsLogPath, event)
843
+ }
844
+
845
+ async setChatFeature(chatId: string, featureId: string | null) {
846
+ const chat = this.requireChat(chatId)
847
+ if (featureId) {
848
+ const feature = this.requireFeature(featureId)
849
+ if (feature.projectId !== chat.projectId) {
850
+ throw new Error("Feature does not belong to the same project")
851
+ }
852
+ }
853
+ if ((chat.featureId ?? null) === featureId) return
854
+ const event: ChatEvent = {
855
+ v: STORE_VERSION,
856
+ type: "chat_feature_set",
857
+ timestamp: Date.now(),
858
+ chatId,
859
+ featureId,
860
+ }
861
+ await this.append(this.chatsLogPath, event)
862
+ await this.syncProjectFeatureState(chat.projectId)
863
+ }
864
+
865
+ async appendMessage(chatId: string, entry: TranscriptEntry) {
866
+ this.requireChat(chatId)
867
+ const payload = `${JSON.stringify(entry)}\n`
868
+ const transcriptPath = this.transcriptPath(chatId)
869
+ this.writeChain = this.writeChain.then(async () => {
870
+ await mkdir(this.transcriptsDir, { recursive: true })
871
+ await appendFile(transcriptPath, payload, "utf8")
872
+ this.applyMessageMetadata(chatId, entry)
873
+ const cached = this.cachedTranscripts.get(chatId)
874
+ if (cached) {
875
+ cached.push({ ...entry })
876
+ this.setCachedTranscript(chatId, cached)
877
+ }
878
+ })
879
+ return this.writeChain
880
+ }
881
+
882
+ async recordTurnStarted(chatId: string) {
883
+ this.requireChat(chatId)
884
+ const event: TurnEvent = {
885
+ v: STORE_VERSION,
886
+ type: "turn_started",
887
+ timestamp: Date.now(),
888
+ chatId,
889
+ }
890
+ await this.append(this.turnsLogPath, event)
891
+ }
892
+
893
+ async recordTurnFinished(chatId: string) {
894
+ this.requireChat(chatId)
895
+ const event: TurnEvent = {
896
+ v: STORE_VERSION,
897
+ type: "turn_finished",
898
+ timestamp: Date.now(),
899
+ chatId,
900
+ }
901
+ await this.append(this.turnsLogPath, event)
902
+ }
903
+
904
+ async recordTurnFailed(chatId: string, error: string) {
905
+ this.requireChat(chatId)
906
+ const event: TurnEvent = {
907
+ v: STORE_VERSION,
908
+ type: "turn_failed",
909
+ timestamp: Date.now(),
910
+ chatId,
911
+ error,
912
+ }
913
+ await this.append(this.turnsLogPath, event)
914
+ }
915
+
916
+ async recordTurnCancelled(chatId: string) {
917
+ this.requireChat(chatId)
918
+ const event: TurnEvent = {
919
+ v: STORE_VERSION,
920
+ type: "turn_cancelled",
921
+ timestamp: Date.now(),
922
+ chatId,
923
+ }
924
+ await this.append(this.turnsLogPath, event)
925
+ }
926
+
927
+ async setSessionToken(chatId: string, sessionToken: string | null) {
928
+ const chat = this.requireChat(chatId)
929
+ if (chat.sessionToken === sessionToken) return
930
+ const event: TurnEvent = {
931
+ v: STORE_VERSION,
932
+ type: "session_token_set",
933
+ timestamp: Date.now(),
934
+ chatId,
935
+ sessionToken,
936
+ }
937
+ await this.append(this.turnsLogPath, event)
938
+ await this.syncProjectFeatureState(chat.projectId)
939
+ }
940
+
941
+ async reconcileProjectFeatureState(projectId: string) {
942
+ const project = this.getProject(projectId)
943
+ if (!project) {
944
+ throw new Error("Project not found")
945
+ }
946
+
947
+ const persistedFeatures = await this.readProjectFeatureMetadata(project.localPath)
948
+ const persistedByDirectory = new Map(
949
+ persistedFeatures.map((feature) => [feature.directoryRelativePath, feature] as const)
950
+ )
951
+ const existingFeatures = this.listFeaturesByProject(projectId)
952
+ const existingByDirectory = new Map(
953
+ existingFeatures.map((feature) => [feature.directoryRelativePath, feature] as const)
954
+ )
955
+
956
+ for (const feature of existingFeatures) {
957
+ if (persistedByDirectory.has(feature.directoryRelativePath)) continue
958
+ await this.deleteFeature(feature.id)
959
+ }
960
+
961
+ const chatsByKey = new Map(
962
+ this.listChatsByProject(projectId)
963
+ .map((chat) => {
964
+ const key = this.getChatPersistenceKey(chat)
965
+ return key ? [key, chat] as const : null
966
+ })
967
+ .filter((entry): entry is readonly [string, ReturnType<EventStore["listChatsByProject"]>[number]] => Boolean(entry))
968
+ )
969
+
970
+ let importedFeatures = 0
971
+ for (const persistedFeature of [...persistedFeatures].sort((a, b) => a.sortOrder - b.sortOrder)) {
972
+ const existingFeature = existingByDirectory.get(persistedFeature.directoryRelativePath)
973
+ if (existingFeature) {
974
+ continue
975
+ }
976
+ const featureId = crypto.randomUUID()
977
+ const timestamp = Date.now()
978
+ const event: FeatureEvent = {
979
+ v: STORE_VERSION,
980
+ type: "feature_created",
981
+ timestamp,
982
+ featureId,
983
+ projectId,
984
+ title: persistedFeature.title,
985
+ description: persistedFeature.description,
986
+ browserState: persistedFeature.browserState,
987
+ stage: persistedFeature.stage,
988
+ sortOrder: persistedFeature.sortOrder,
989
+ directoryRelativePath: persistedFeature.directoryRelativePath,
990
+ overviewRelativePath: persistedFeature.overviewRelativePath,
991
+ }
992
+ await this.append(this.featuresLogPath, event)
993
+ importedFeatures += 1
994
+
995
+ for (const chatKey of persistedFeature.chatKeys) {
996
+ const chat = chatsByKey.get(chatKey)
997
+ if (!chat) continue
998
+ const assignmentEvent: ChatEvent = {
999
+ v: STORE_VERSION,
1000
+ type: "chat_feature_set",
1001
+ timestamp: Date.now(),
1002
+ chatId: chat.id,
1003
+ featureId,
1004
+ }
1005
+ await this.append(this.chatsLogPath, assignmentEvent)
1006
+ }
1007
+ }
1008
+
1009
+ return importedFeatures
1010
+ }
1011
+
1012
+ getProject(projectId: string) {
1013
+ const project = this.state.projectsById.get(projectId)
1014
+ if (!project || project.deletedAt) return null
1015
+ return project
1016
+ }
1017
+
1018
+ requireFeature(featureId: string) {
1019
+ const feature = this.state.featuresById.get(featureId)
1020
+ if (!feature || feature.deletedAt) {
1021
+ throw new Error("Feature not found")
1022
+ }
1023
+ return feature
1024
+ }
1025
+
1026
+ getFeature(featureId: string) {
1027
+ const feature = this.state.featuresById.get(featureId)
1028
+ if (!feature || feature.deletedAt) return null
1029
+ return feature
1030
+ }
1031
+
1032
+ requireChat(chatId: string) {
1033
+ const chat = this.state.chatsById.get(chatId)
1034
+ if (!chat || chat.deletedAt) {
1035
+ throw new Error("Chat not found")
1036
+ }
1037
+ return chat
1038
+ }
1039
+
1040
+ getChat(chatId: string) {
1041
+ const chat = this.state.chatsById.get(chatId)
1042
+ if (!chat || chat.deletedAt) return null
1043
+ return chat
1044
+ }
1045
+
1046
+ getMessages(chatId: string) {
1047
+ const cached = this.getCachedTranscript(chatId)
1048
+ if (cached) {
1049
+ return cloneTranscriptEntries(cached)
1050
+ }
1051
+
1052
+ const legacyEntries = this.legacyMessagesByChatId.get(chatId)
1053
+ if (legacyEntries) {
1054
+ const clonedLegacyEntries = cloneTranscriptEntries(legacyEntries)
1055
+ this.setCachedTranscript(chatId, clonedLegacyEntries)
1056
+ return cloneTranscriptEntries(clonedLegacyEntries)
1057
+ }
1058
+
1059
+ const entries = this.loadTranscriptFromDisk(chatId)
1060
+ this.setCachedTranscript(chatId, entries)
1061
+ return cloneTranscriptEntries(entries)
1062
+ }
1063
+
1064
+ listProjects() {
1065
+ return [...this.state.projectsById.values()].filter((project) => !project.deletedAt)
1066
+ }
1067
+
1068
+ listFeaturesByProject(projectId: string) {
1069
+ return [...this.state.featuresById.values()]
1070
+ .filter((feature) => feature.projectId === projectId && !feature.deletedAt)
1071
+ .sort((a, b) => {
1072
+ const aDone = a.stage === "done" ? 1 : 0
1073
+ const bDone = b.stage === "done" ? 1 : 0
1074
+ if (aDone !== bDone) return aDone - bDone
1075
+ if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
1076
+ return b.updatedAt - a.updatedAt
1077
+ })
1078
+ }
1079
+
1080
+ isProjectHidden(repoKey: string) {
1081
+ return this.state.hiddenProjectKeys.has(repoKey)
1082
+ }
1083
+
1084
+ listChatsByProject(projectId: string) {
1085
+ return [...this.state.chatsById.values()]
1086
+ .filter((chat) => chat.projectId === projectId && !chat.deletedAt)
1087
+ .sort((a, b) => (b.lastMessageAt ?? b.updatedAt) - (a.lastMessageAt ?? a.updatedAt))
1088
+ }
1089
+
1090
+ getChatCount(projectId: string) {
1091
+ return this.listChatsByProject(projectId).length
1092
+ }
1093
+
1094
+ async getLegacyTranscriptStats(): Promise<LegacyTranscriptStats> {
1095
+ const messagesLogSize = await Bun.file(this.messagesLogPath).size
1096
+ const sources: LegacyTranscriptStats["sources"] = []
1097
+ if (this.snapshotHasLegacyMessages) {
1098
+ sources.push("snapshot")
1099
+ }
1100
+ if (messagesLogSize > 0) {
1101
+ sources.push("messages_log")
1102
+ }
1103
+
1104
+ let entryCount = 0
1105
+ for (const entries of this.legacyMessagesByChatId.values()) {
1106
+ entryCount += entries.length
1107
+ }
1108
+
1109
+ return {
1110
+ hasLegacyData: sources.length > 0 || this.legacyMessagesByChatId.size > 0,
1111
+ sources,
1112
+ chatCount: this.legacyMessagesByChatId.size,
1113
+ entryCount,
1114
+ }
1115
+ }
1116
+
1117
+ async hasLegacyTranscriptData() {
1118
+ return (await this.getLegacyTranscriptStats()).hasLegacyData
1119
+ }
1120
+
1121
+ private createSnapshot(includeLegacyMessages: boolean): SnapshotFile {
1122
+ const snapshot: SnapshotFile = {
1123
+ v: STORE_VERSION,
1124
+ generatedAt: Date.now(),
1125
+ projects: this.listProjects().map((project) => ({ ...project })),
1126
+ features: [...this.state.featuresById.values()]
1127
+ .filter((feature) => !feature.deletedAt)
1128
+ .map((feature) => ({ ...feature })),
1129
+ chats: [...this.state.chatsById.values()]
1130
+ .filter((chat) => !chat.deletedAt)
1131
+ .map((chat) => ({ ...chat })),
1132
+ hiddenProjectKeys: [...this.state.hiddenProjectKeys.values()],
1133
+ }
1134
+
1135
+ if (includeLegacyMessages) {
1136
+ snapshot.messages = [...this.legacyMessagesByChatId.entries()].map(([chatId, entries]) => ({
1137
+ chatId,
1138
+ entries: cloneTranscriptEntries(entries),
1139
+ }))
1140
+ }
1141
+
1142
+ return snapshot
1143
+ }
1144
+
1145
+ async compact() {
1146
+ await this.writeChain
1147
+ const snapshot = this.createSnapshot(false)
1148
+ const tmpPath = `${this.snapshotPath}.tmp`
1149
+ await Bun.write(tmpPath, JSON.stringify(snapshot, null, 2))
1150
+ await rename(tmpPath, this.snapshotPath)
1151
+ await Promise.all([
1152
+ Bun.write(this.projectsLogPath, ""),
1153
+ Bun.write(this.featuresLogPath, ""),
1154
+ Bun.write(this.chatsLogPath, ""),
1155
+ Bun.write(this.messagesLogPath, ""),
1156
+ Bun.write(this.turnsLogPath, ""),
1157
+ ])
1158
+ }
1159
+
1160
+ private async compactLegacyMessagesIntoSnapshot() {
1161
+ const snapshot = this.createSnapshot(true)
1162
+ await Bun.write(this.snapshotPath, JSON.stringify(snapshot, null, 2))
1163
+ await Promise.all([
1164
+ Bun.write(this.projectsLogPath, ""),
1165
+ Bun.write(this.chatsLogPath, ""),
1166
+ Bun.write(this.messagesLogPath, ""),
1167
+ Bun.write(this.turnsLogPath, ""),
1168
+ ])
1169
+ }
1170
+
1171
+ async migrateLegacyTranscripts(onProgress?: (message: string) => void) {
1172
+ const stats = await this.getLegacyTranscriptStats()
1173
+ if (!stats.hasLegacyData) return false
1174
+
1175
+ const sourceSummary = stats.sources.map((source) => source === "messages_log" ? "messages.jsonl" : "snapshot.json").join(", ")
1176
+ onProgress?.(`${LOG_PREFIX} transcript migration detected: ${stats.chatCount} chats, ${stats.entryCount} entries from ${sourceSummary}`)
1177
+ onProgress?.(`${LOG_PREFIX} transcript migration: compacting legacy transcript state into snapshot`)
1178
+ await this.compactLegacyMessagesIntoSnapshot()
1179
+
1180
+ const snapshot = JSON.parse(await readFile(this.snapshotPath, "utf8")) as SnapshotFile
1181
+ const messageSets = snapshot.messages ?? []
1182
+ onProgress?.(`${LOG_PREFIX} transcript migration: writing ${messageSets.length} per-chat transcript files`)
1183
+
1184
+ await mkdir(this.transcriptsDir, { recursive: true })
1185
+ const logEveryChat = messageSets.length <= 10
1186
+ for (let index = 0; index < messageSets.length; index += 1) {
1187
+ const { chatId, entries } = messageSets[index]
1188
+ const transcriptPath = this.transcriptPath(chatId)
1189
+ const tempPath = `${transcriptPath}.tmp`
1190
+ const payload = entries.map((entry) => JSON.stringify(entry)).join("\n")
1191
+ await writeFile(tempPath, payload ? `${payload}\n` : "", "utf8")
1192
+ await rename(tempPath, transcriptPath)
1193
+ if (logEveryChat || (index + 1) % 25 === 0 || index === messageSets.length - 1) {
1194
+ onProgress?.(`${LOG_PREFIX} transcript migration: ${index + 1}/${messageSets.length} chats`)
1195
+ }
1196
+ }
1197
+
1198
+ delete snapshot.messages
1199
+ await Bun.write(this.snapshotPath, JSON.stringify(snapshot, null, 2))
1200
+ this.clearLegacyTranscriptState()
1201
+ this.cachedTranscripts.clear()
1202
+ onProgress?.(`${LOG_PREFIX} transcript migration complete`)
1203
+ return true
1204
+ }
1205
+
1206
+ private async shouldCompact() {
1207
+ const sizes = await Promise.all([
1208
+ Bun.file(this.projectsLogPath).size,
1209
+ Bun.file(this.featuresLogPath).size,
1210
+ Bun.file(this.chatsLogPath).size,
1211
+ Bun.file(this.messagesLogPath).size,
1212
+ Bun.file(this.turnsLogPath).size,
1213
+ ])
1214
+ return sizes.reduce((total, size) => total + size, 0) >= COMPACTION_THRESHOLD_BYTES
1215
+ }
1216
+
1217
+ private generateUniqueFeatureDirectoryName(projectId: string, title: string) {
1218
+ const normalizedBase = title
1219
+ .trim()
1220
+ .replace(/\s+/g, "_")
1221
+ .replace(/[^A-Za-z0-9_-]/g, "")
1222
+ .replace(/_+/g, "_")
1223
+ || "feature"
1224
+ const existing = new Set(
1225
+ this.listFeaturesByProject(projectId).map((feature) => path.posix.basename(feature.directoryRelativePath))
1226
+ )
1227
+ if (!existing.has(normalizedBase)) return normalizedBase
1228
+ let index = 2
1229
+ while (existing.has(`${normalizedBase}_${index}`)) {
1230
+ index += 1
1231
+ }
1232
+ return `${normalizedBase}_${index}`
1233
+ }
1234
+
1235
+ private getNextFeatureSortOrder(projectId: string, stage: FeatureStage) {
1236
+ const features = this.listFeaturesByProject(projectId)
1237
+ if (stage === "done") {
1238
+ const doneOrders = features.filter((feature) => feature.stage === "done").map((feature) => feature.sortOrder)
1239
+ return doneOrders.length === 0 ? features.length : Math.max(...doneOrders) + 1
1240
+ }
1241
+ const nonDoneOrders = features.filter((feature) => feature.stage !== "done").map((feature) => feature.sortOrder)
1242
+ return nonDoneOrders.length === 0 ? 0 : Math.max(...nonDoneOrders) + 1
1243
+ }
1244
+
1245
+ private getRebasedFeatureSortOrder(projectId: string, featureId: string, nextStage: FeatureStage) {
1246
+ const features = this.listFeaturesByProject(projectId).filter((feature) => feature.id !== featureId)
1247
+ if (nextStage === "done") {
1248
+ const doneOrders = features.filter((feature) => feature.stage === "done").map((feature) => feature.sortOrder)
1249
+ const maxOverall = features.map((feature) => feature.sortOrder)
1250
+ return doneOrders.length > 0
1251
+ ? Math.max(...doneOrders) + 1
1252
+ : maxOverall.length > 0
1253
+ ? Math.max(...maxOverall) + 1
1254
+ : 0
1255
+ }
1256
+ const nonDoneOrders = features.filter((feature) => feature.stage !== "done").map((feature) => feature.sortOrder)
1257
+ return nonDoneOrders.length === 0 ? 0 : Math.max(...nonDoneOrders) + 1
1258
+ }
1259
+
1260
+ private buildFeatureOverviewContent(args: {
1261
+ projectTitle: string
1262
+ featureTitle: string
1263
+ description: string
1264
+ }) {
1265
+ const summary = args.description.trim() || "TODO: Add a short summary for this feature."
1266
+ return [
1267
+ `# ${args.featureTitle}`,
1268
+ "",
1269
+ `Project: ${args.projectTitle}`,
1270
+ "",
1271
+ "## Summary",
1272
+ summary,
1273
+ "",
1274
+ "## Notes",
1275
+ "- Initial feature overview generated at creation time.",
1276
+ "- Replace this with a fuller implementation plan as the feature evolves.",
1277
+ "",
1278
+ "## Open Questions",
1279
+ "- Scope details",
1280
+ "- Technical approach",
1281
+ "- Testing strategy",
1282
+ "",
1283
+ ].join("\n")
1284
+ }
1285
+
1286
+ private getFeatureMetadataPath(localPath: string, directoryRelativePath: string) {
1287
+ return path.join(localPath, directoryRelativePath, "feature.json")
1288
+ }
1289
+
1290
+ private getChatPersistenceKey(chat: {
1291
+ provider: AgentProvider | null
1292
+ sessionToken: string | null
1293
+ }) {
1294
+ if (!chat.provider || !chat.sessionToken) return null
1295
+ return `${chat.provider}:${chat.sessionToken}`
1296
+ }
1297
+
1298
+ private async syncProjectFeatureState(projectId: string, force = false) {
1299
+ const project = this.getProject(projectId)
1300
+ if (!project) return
1301
+ if (!force && this.listFeaturesByProject(projectId).length === 0) {
1302
+ return
1303
+ }
1304
+
1305
+ for (const feature of this.listFeaturesByProject(projectId)) {
1306
+ const persistedFeature: PersistedProjectFeature = {
1307
+ v: FEATURE_METADATA_VERSION,
1308
+ title: feature.title,
1309
+ description: feature.description,
1310
+ browserState: feature.browserState,
1311
+ stage: feature.stage,
1312
+ sortOrder: feature.sortOrder,
1313
+ directoryRelativePath: feature.directoryRelativePath,
1314
+ overviewRelativePath: feature.overviewRelativePath,
1315
+ chatKeys: this.listChatsByProject(projectId)
1316
+ .filter((chat) => chat.featureId === feature.id)
1317
+ .map((chat) => this.getChatPersistenceKey(chat))
1318
+ .filter((chatKey): chatKey is string => Boolean(chatKey)),
1319
+ }
1320
+ const featureMetadataPath = this.getFeatureMetadataPath(project.localPath, feature.directoryRelativePath)
1321
+ await mkdir(path.dirname(featureMetadataPath), { recursive: true })
1322
+ await writeFile(featureMetadataPath, JSON.stringify(persistedFeature, null, 2), "utf8")
1323
+ }
1324
+ }
1325
+
1326
+ private async readProjectFeatureMetadata(localPath: string): Promise<PersistedProjectFeature[]> {
1327
+ try {
1328
+ const metadataDir = getProjectMetadataDirPath(localPath)
1329
+ const entries = await readdir(metadataDir, { withFileTypes: true })
1330
+ const features: PersistedProjectFeature[] = []
1331
+
1332
+ for (const entry of entries) {
1333
+ if (!entry.isDirectory()) continue
1334
+ const raw = await readFile(path.join(metadataDir, entry.name, "feature.json"), "utf8").catch(() => null)
1335
+ if (!raw?.trim()) continue
1336
+ const parsed = JSON.parse(raw) as Partial<PersistedProjectFeature>
1337
+ if (
1338
+ parsed.v !== FEATURE_METADATA_VERSION
1339
+ || typeof parsed.title !== "string"
1340
+ || typeof parsed.description !== "string"
1341
+ || typeof parsed.directoryRelativePath !== "string"
1342
+ || typeof parsed.overviewRelativePath !== "string"
1343
+ || typeof parsed.sortOrder !== "number"
1344
+ || !FEATURE_BROWSER_STATES.includes((parsed.browserState ?? "OPEN") as FeatureBrowserState)
1345
+ || !FEATURE_STAGES.includes(parsed.stage as FeatureStage)
1346
+ ) {
1347
+ continue
1348
+ }
1349
+ features.push({
1350
+ v: FEATURE_METADATA_VERSION,
1351
+ title: parsed.title,
1352
+ description: parsed.description,
1353
+ browserState: (parsed.browserState ?? "OPEN") as FeatureBrowserState,
1354
+ stage: parsed.stage as FeatureStage,
1355
+ sortOrder: parsed.sortOrder,
1356
+ directoryRelativePath: parsed.directoryRelativePath,
1357
+ overviewRelativePath: parsed.overviewRelativePath,
1358
+ chatKeys: Array.isArray(parsed.chatKeys)
1359
+ ? parsed.chatKeys.filter((chatKey): chatKey is string => typeof chatKey === "string")
1360
+ : [],
1361
+ })
1362
+ }
1363
+
1364
+ return features
1365
+ } catch {
1366
+ return []
1367
+ }
1368
+ }
1369
+ }