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.
- package/LICENSE +22 -0
- package/README.md +246 -0
- package/bin/kaizen +15 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/index-D-ORCGrq.js +603 -0
- package/dist/client/assets/index-r28mcHqz.css +32 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/fonts/body-medium.woff2 +0 -0
- package/dist/client/fonts/body-regular-italic.woff2 +0 -0
- package/dist/client/fonts/body-regular.woff2 +0 -0
- package/dist/client/fonts/body-semibold.woff2 +0 -0
- package/dist/client/index.html +22 -0
- package/dist/client/manifest-dark.webmanifest +24 -0
- package/dist/client/manifest.webmanifest +24 -0
- package/dist/client/pwa-192.png +0 -0
- package/dist/client/pwa-512.png +0 -0
- package/dist/client/pwa-icon.svg +15 -0
- package/dist/client/pwa-splash.png +0 -0
- package/dist/client/pwa-splash.svg +15 -0
- package/package.json +103 -0
- package/src/server/acp-shared.ts +315 -0
- package/src/server/agent.ts +1120 -0
- package/src/server/attachments.ts +133 -0
- package/src/server/backgrounds.ts +74 -0
- package/src/server/cli-runtime.ts +333 -0
- package/src/server/cli-supervisor.ts +81 -0
- package/src/server/cli.ts +68 -0
- package/src/server/codex-app-server-protocol.ts +453 -0
- package/src/server/codex-app-server.ts +1350 -0
- package/src/server/cursor-acp.ts +819 -0
- package/src/server/discovery.ts +322 -0
- package/src/server/event-store.ts +1369 -0
- package/src/server/events.ts +244 -0
- package/src/server/external-open.ts +272 -0
- package/src/server/gemini-acp.ts +844 -0
- package/src/server/gemini-cli.ts +525 -0
- package/src/server/generate-title.ts +36 -0
- package/src/server/git-manager.ts +79 -0
- package/src/server/git-repository.ts +101 -0
- package/src/server/harness-types.ts +20 -0
- package/src/server/keybindings.ts +177 -0
- package/src/server/machine-name.ts +22 -0
- package/src/server/paths.ts +112 -0
- package/src/server/process-utils.ts +22 -0
- package/src/server/project-icon.ts +344 -0
- package/src/server/project-metadata.ts +10 -0
- package/src/server/provider-catalog.ts +85 -0
- package/src/server/provider-settings.ts +155 -0
- package/src/server/quick-response.ts +153 -0
- package/src/server/read-models.ts +275 -0
- package/src/server/recovery.ts +507 -0
- package/src/server/restart.ts +30 -0
- package/src/server/server.ts +244 -0
- package/src/server/terminal-manager.ts +350 -0
- package/src/server/theme-settings.ts +179 -0
- package/src/server/update-manager.ts +230 -0
- package/src/server/usage/base-provider-usage.ts +57 -0
- package/src/server/usage/claude-usage.ts +558 -0
- package/src/server/usage/codex-usage.ts +144 -0
- package/src/server/usage/cursor-browser.ts +120 -0
- package/src/server/usage/cursor-cookies.ts +390 -0
- package/src/server/usage/cursor-usage.ts +490 -0
- package/src/server/usage/gemini-usage.ts +24 -0
- package/src/server/usage/provider-usage.ts +61 -0
- package/src/server/usage/test-helpers.ts +9 -0
- package/src/server/usage/types.ts +54 -0
- package/src/server/usage/utils.ts +325 -0
- package/src/server/ws-router.ts +717 -0
- package/src/shared/branding.ts +83 -0
- package/src/shared/dev-ports.ts +43 -0
- package/src/shared/ports.ts +2 -0
- package/src/shared/protocol.ts +152 -0
- package/src/shared/tools.ts +251 -0
- 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
|
+
}
|