kaizenai 0.2.2 → 0.3.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.
@@ -26,6 +26,7 @@ const FEATURE_METADATA_VERSION = 1 as const
26
26
 
27
27
  interface PersistedProjectFeature {
28
28
  v: typeof FEATURE_METADATA_VERSION
29
+ id: string
29
30
  title: string
30
31
  description: string
31
32
  browserState: FeatureBrowserState
@@ -81,6 +82,7 @@ export class EventStore {
81
82
  await this.loadSnapshot()
82
83
  await this.replayLogs()
83
84
  this.hydrateProjectWorktrees()
85
+ await this.reconcileAllProjectFeatureState()
84
86
  if (!(await this.hasLegacyTranscriptData()) && await this.shouldCompact()) {
85
87
  await this.compact()
86
88
  }
@@ -99,6 +101,13 @@ export class EventStore {
99
101
  }
100
102
  }
101
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
+
102
111
  private async ensureFile(filePath: string) {
103
112
  const file = Bun.file(filePath)
104
113
  if (!(await file.exists())) {
@@ -334,6 +343,12 @@ export class EventStore {
334
343
  const feature = this.state.featuresById.get(event.featureId)
335
344
  if (!feature) break
336
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
+ }
337
352
  feature.updatedAt = event.timestamp
338
353
  break
339
354
  }
@@ -377,6 +392,12 @@ export class EventStore {
377
392
  }
378
393
  break
379
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
+ }
380
401
  case "chat_created": {
381
402
  const chat = {
382
403
  id: event.chatId,
@@ -608,7 +629,7 @@ export class EventStore {
608
629
  await this.append(this.projectsLogPath, event)
609
630
  }
610
631
 
611
- async createFeature(projectId: string, title: string, description = "") {
632
+ async createFeature(projectId: string, title: string, description = "", generateOverview = true) {
612
633
  const project = this.getProject(projectId)
613
634
  if (!project) {
614
635
  throw new Error("Project not found")
@@ -621,17 +642,19 @@ export class EventStore {
621
642
 
622
643
  const directoryName = this.generateUniqueFeatureDirectoryName(projectId, trimmedTitle)
623
644
  const directoryRelativePath = path.posix.join(PROJECT_METADATA_DIR_NAME, directoryName)
624
- const overviewRelativePath = path.posix.join(directoryRelativePath, "overview.md")
645
+ const overviewRelativePath = generateOverview ? path.posix.join(directoryRelativePath, "overview.md") : ""
625
646
  const featureDirPath = path.join(project.localPath, directoryRelativePath)
626
- const overviewPath = path.join(project.localPath, overviewRelativePath)
627
647
  const sortOrder = this.getNextFeatureSortOrder(projectId, "idea")
628
648
 
629
649
  await mkdir(featureDirPath, { recursive: true })
630
- await writeFile(overviewPath, this.buildFeatureOverviewContent({
631
- projectTitle: project.title,
632
- featureTitle: trimmedTitle,
633
- description: trimmedDescription,
634
- }), "utf8")
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
+ }
635
658
 
636
659
  const featureId = crypto.randomUUID()
637
660
  const event: FeatureEvent = {
@@ -658,12 +681,33 @@ export class EventStore {
658
681
  if (!trimmedTitle) return
659
682
  const feature = this.requireFeature(featureId)
660
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
+
661
703
  const event: FeatureEvent = {
662
704
  v: STORE_VERSION,
663
705
  type: "feature_renamed",
664
706
  timestamp: Date.now(),
665
707
  featureId,
666
708
  title: trimmedTitle,
709
+ directoryRelativePath: newDirectoryRelativePath,
710
+ overviewRelativePath: newOverviewRelativePath,
667
711
  }
668
712
  await this.append(this.featuresLogPath, event)
669
713
  await this.syncProjectFeatureState(feature.projectId)
@@ -739,6 +783,57 @@ export class EventStore {
739
783
  await this.syncProjectFeatureState(feature.projectId)
740
784
  }
741
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
+
742
837
  async hideProject(localPath: string) {
743
838
  const identity = resolveProjectRepositoryIdentity(localPath)
744
839
  if (this.state.hiddenProjectKeys.has(identity.repoKey)) return
@@ -945,19 +1040,11 @@ export class EventStore {
945
1040
  }
946
1041
 
947
1042
  const persistedFeatures = await this.readProjectFeatureMetadata(project.localPath)
948
- const persistedByDirectory = new Map(
949
- persistedFeatures.map((feature) => [feature.directoryRelativePath, feature] as const)
950
- )
951
1043
  const existingFeatures = this.listFeaturesByProject(projectId)
952
1044
  const existingByDirectory = new Map(
953
1045
  existingFeatures.map((feature) => [feature.directoryRelativePath, feature] as const)
954
1046
  )
955
1047
 
956
- for (const feature of existingFeatures) {
957
- if (persistedByDirectory.has(feature.directoryRelativePath)) continue
958
- await this.deleteFeature(feature.id)
959
- }
960
-
961
1048
  const chatsByKey = new Map(
962
1049
  this.listChatsByProject(projectId)
963
1050
  .map((chat) => {
@@ -973,7 +1060,7 @@ export class EventStore {
973
1060
  if (existingFeature) {
974
1061
  continue
975
1062
  }
976
- const featureId = crypto.randomUUID()
1063
+ const featureId = persistedFeature.id
977
1064
  const timestamp = Date.now()
978
1065
  const event: FeatureEvent = {
979
1066
  v: STORE_VERSION,
@@ -1006,6 +1093,7 @@ export class EventStore {
1006
1093
  }
1007
1094
  }
1008
1095
 
1096
+ await this.syncProjectFeatureState(projectId, true)
1009
1097
  return importedFeatures
1010
1098
  }
1011
1099
 
@@ -1305,6 +1393,7 @@ export class EventStore {
1305
1393
  for (const feature of this.listFeaturesByProject(projectId)) {
1306
1394
  const persistedFeature: PersistedProjectFeature = {
1307
1395
  v: FEATURE_METADATA_VERSION,
1396
+ id: feature.id,
1308
1397
  title: feature.title,
1309
1398
  description: feature.description,
1310
1399
  browserState: feature.browserState,
@@ -1319,6 +1408,17 @@ export class EventStore {
1319
1408
  }
1320
1409
  const featureMetadataPath = this.getFeatureMetadataPath(project.localPath, feature.directoryRelativePath)
1321
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
+ }
1322
1422
  await writeFile(featureMetadataPath, JSON.stringify(persistedFeature, null, 2), "utf8")
1323
1423
  }
1324
1424
  }
@@ -1335,7 +1435,7 @@ export class EventStore {
1335
1435
  if (!raw?.trim()) continue
1336
1436
  const parsed = JSON.parse(raw) as Partial<PersistedProjectFeature>
1337
1437
  if (
1338
- parsed.v !== FEATURE_METADATA_VERSION
1438
+ ((parsed.v as number | undefined) !== 1 && parsed.v !== FEATURE_METADATA_VERSION)
1339
1439
  || typeof parsed.title !== "string"
1340
1440
  || typeof parsed.description !== "string"
1341
1441
  || typeof parsed.directoryRelativePath !== "string"
@@ -1348,6 +1448,7 @@ export class EventStore {
1348
1448
  }
1349
1449
  features.push({
1350
1450
  v: FEATURE_METADATA_VERSION,
1451
+ id: typeof parsed.id === "string" && parsed.id.trim() ? parsed.id : crypto.randomUUID(),
1351
1452
  title: parsed.title,
1352
1453
  description: parsed.description,
1353
1454
  browserState: (parsed.browserState ?? "OPEN") as FeatureBrowserState,
@@ -109,6 +109,8 @@ export type FeatureEvent =
109
109
  timestamp: number
110
110
  featureId: string
111
111
  title: string
112
+ directoryRelativePath?: string
113
+ overviewRelativePath?: string
112
114
  }
113
115
  | {
114
116
  v: 3
@@ -138,6 +140,12 @@ export type FeatureEvent =
138
140
  timestamp: number
139
141
  featureId: string
140
142
  }
143
+ | {
144
+ v: 3
145
+ type: "feature_overview_updated"
146
+ timestamp: number
147
+ featureId: string
148
+ }
141
149
 
142
150
  export type ChatEvent =
143
151
  | {
@@ -174,6 +174,18 @@ export function createWsRouter({
174
174
  }
175
175
  }
176
176
 
177
+ if (topic.type === "feature-overview") {
178
+ return {
179
+ v: PROTOCOL_VERSION,
180
+ type: "snapshot",
181
+ id,
182
+ snapshot: {
183
+ type: "feature-overview",
184
+ data: store.getFeatureOverviewSnapshot(topic.featureId),
185
+ },
186
+ }
187
+ }
188
+
177
189
  return {
178
190
  v: PROTOCOL_VERSION,
179
191
  type: "snapshot",
@@ -234,6 +246,15 @@ export function createWsRouter({
234
246
  }
235
247
  }
236
248
 
249
+ function pushFeatureOverviewSnapshot(featureId: string) {
250
+ for (const ws of sockets) {
251
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
252
+ if (topic.type !== "feature-overview" || topic.featureId !== featureId) continue
253
+ send(ws, createEnvelope(id, topic))
254
+ }
255
+ }
256
+ }
257
+
237
258
  function pushTerminalEvent(terminalId: string, event: Extract<ServerEnvelope, { type: "event" }>["event"]) {
238
259
  for (const ws of sockets) {
239
260
  for (const [id, topic] of ws.data.subscriptions.entries()) {
@@ -523,7 +544,12 @@ export function createWsRouter({
523
544
  break
524
545
  }
525
546
  case "feature.create": {
526
- const feature = await store.createFeature(command.projectId, command.title, command.description ?? "")
547
+ const feature = await store.createFeature(
548
+ command.projectId,
549
+ command.title,
550
+ command.description ?? "",
551
+ command.generateOverview ?? true
552
+ )
527
553
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { featureId: feature.id } })
528
554
  break
529
555
  }
@@ -552,6 +578,13 @@ export function createWsRouter({
552
578
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
553
579
  break
554
580
  }
581
+ case "feature.updateOverview": {
582
+ const snapshot = await store.updateFeatureOverview(command.featureId, command.content)
583
+ pushFeatureOverviewSnapshot(command.featureId)
584
+ broadcastSidebarSnapshots()
585
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
586
+ break
587
+ }
555
588
  case "chat.create": {
556
589
  const chat = await store.createChat(command.projectId, command.featureId)
557
590
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { chatId: chat.id } })
@@ -10,6 +10,7 @@ import type {
10
10
  LocalProjectsSnapshot,
11
11
  ModelOptions,
12
12
  SidebarData,
13
+ FeatureOverviewSnapshot,
13
14
  ThemeSettingsSnapshot,
14
15
  UpdateSnapshot,
15
16
  } from "./types"
@@ -29,6 +30,7 @@ export type SubscriptionTopic =
29
30
  | { type: "theme-settings" }
30
31
  | { type: "provider-settings" }
31
32
  | { type: "chat"; chatId: string }
33
+ | { type: "feature-overview"; featureId: string }
32
34
  | { type: "terminal"; terminalId: string }
33
35
 
34
36
  export interface TerminalSnapshot {
@@ -75,12 +77,13 @@ export type ClientCommand =
75
77
  editor?: EditorOpenSettings
76
78
  }
77
79
  | { type: "chat.create"; projectId: string; featureId?: string }
78
- | { type: "feature.create"; projectId: string; title: string; description?: string }
80
+ | { type: "feature.create"; projectId: string; title: string; description?: string; generateOverview?: boolean }
79
81
  | { type: "feature.rename"; featureId: string; title: string }
80
82
  | { type: "feature.setBrowserState"; featureId: string; browserState: FeatureBrowserState }
81
83
  | { type: "feature.setStage"; featureId: string; stage: FeatureStage }
82
84
  | { type: "feature.reorder"; projectId: string; orderedFeatureIds: string[] }
83
85
  | { type: "feature.delete"; featureId: string }
86
+ | { type: "feature.updateOverview"; featureId: string; content: string }
84
87
  | { type: "chat.setFeature"; chatId: string; featureId: string | null }
85
88
  | { type: "chat.rename"; chatId: string; title: string }
86
89
  | { type: "chat.delete"; chatId: string }
@@ -121,6 +124,7 @@ export type ServerSnapshot =
121
124
  | { type: "theme-settings"; data: ThemeSettingsSnapshot }
122
125
  | { type: "provider-settings"; data: ProviderSettingsSnapshot }
123
126
  | { type: "chat"; data: ChatSnapshot | null }
127
+ | { type: "feature-overview"; data: FeatureOverviewSnapshot | null }
124
128
  | { type: "terminal"; data: TerminalSnapshot | null }
125
129
 
126
130
  export type ServerEnvelope =
@@ -467,6 +467,17 @@ export interface SidebarFeatureRow {
467
467
  chats: SidebarChatRow[]
468
468
  }
469
469
 
470
+ export interface FeatureOverviewSnapshot {
471
+ featureId: string
472
+ projectId: string
473
+ title: string
474
+ directoryRelativePath: string
475
+ overviewRelativePath: string
476
+ localPath: string
477
+ content: string
478
+ updatedAt: number
479
+ }
480
+
470
481
  export interface SidebarProjectGroup {
471
482
  groupKey: string
472
483
  title: string
@@ -888,6 +899,7 @@ export interface ExitPlanModeToolResult {
888
899
  clearContext?: boolean
889
900
  message?: string
890
901
  discarded?: boolean
902
+ attachments?: ChatAttachmentUpload[]
891
903
  }
892
904
 
893
905
  export type HydratedAskUserQuestionToolCall =