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