@cuongtran001/kanna 1.3.0 → 1.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 (200) hide show
  1. package/dist/client/assets/{arc-CAysfmX5.js → arc-BHj370W7.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-1G88q1PY.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-D0L3Co5v.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-CQtERl3H.js} +1 -1
  5. package/dist/client/assets/channel-BP9IMEvd.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-DxOwt09f.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-Wry66y9R.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-D6p-Hi4x.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-CRQZt23N.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-BzgTKOM9.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CKfD-91C.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-a71B27wc.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-vv9gJwx0.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK--W6Wt7FF.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2--W6Wt7FF.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-Cu-dCCaI.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-D8coOWLK.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-NIwZ65jE.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-d46aE_le.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-xdm2UxWc.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-hDbLwC5K.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-MHUGG7ki.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-Ro6n9X70.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-DOZqvRMu.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-Bn2NXYIy.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DK44bfbC.js} +1 -1
  27. package/dist/client/assets/{index-C0U2kwuB.js → index-CWJpHyQy.js} +115 -115
  28. package/dist/client/assets/{index-CLsOWXff.css → index-Cyzd4zZx.css} +1 -1
  29. package/dist/client/assets/{index-CbSY9HfS.js → index-DU56d4oC.js} +1 -1
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-C2tW_Dwj.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-C__4Z1wz.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-Bbn5EawR.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-RgS5IRWl.js} +1 -1
  34. package/dist/client/assets/{linear-DLR6miy3.js → linear-DfI0qBO_.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-BiN10VHc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-BQhibhh3.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-BEfLRn7a.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-ba641Y2x.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-BGtl-tVO.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-DyziaSFr.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BfYBAYga.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-BfUfmLYA.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CdGcgYEq.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-T6z7dEFl.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-BAwQbFFd.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-DF1cRKsP.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BRtAZ8za.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-Brb-q76h.js} +1 -1
  49. package/dist/client/index.html +2 -2
  50. package/package.json +1 -1
  51. package/src/server/agent-coordinator-types.ts +158 -0
  52. package/src/server/agent-coordinator.ts +919 -0
  53. package/src/server/agent-deps-builders.ts +425 -0
  54. package/src/server/agent.test.ts +22 -6
  55. package/src/server/agent.ts +35 -4987
  56. package/src/server/chat-op-log.test.ts +63 -0
  57. package/src/server/chat-op-log.ts +71 -0
  58. package/src/server/chat-ops-diff.test.ts +120 -0
  59. package/src/server/chat-ops-diff.ts +97 -0
  60. package/src/server/chat-ops-parity.test.ts +140 -0
  61. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  62. package/src/server/claude-autocontinue-commands.ts +256 -0
  63. package/src/server/claude-cancel-handler.test.ts +472 -0
  64. package/src/server/claude-cancel-handler.ts +241 -0
  65. package/src/server/claude-chat-management.test.ts +395 -0
  66. package/src/server/claude-chat-management.ts +251 -0
  67. package/src/server/claude-harness-stream.test.ts +273 -0
  68. package/src/server/claude-harness-stream.ts +158 -0
  69. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  70. package/src/server/claude-loop-orch-commands.ts +466 -0
  71. package/src/server/claude-message-normalizer.test.ts +213 -0
  72. package/src/server/claude-message-normalizer.ts +436 -0
  73. package/src/server/claude-prompt-helpers.test.ts +248 -0
  74. package/src/server/claude-prompt-helpers.ts +139 -0
  75. package/src/server/claude-sdk-queue.test.ts +169 -0
  76. package/src/server/claude-sdk-queue.ts +82 -0
  77. package/src/server/claude-send-command.test.ts +414 -0
  78. package/src/server/claude-send-command.ts +420 -0
  79. package/src/server/claude-session-config-helpers.test.ts +279 -0
  80. package/src/server/claude-session-config-helpers.ts +151 -0
  81. package/src/server/claude-session-config.test.ts +250 -0
  82. package/src/server/claude-session-config.ts +159 -0
  83. package/src/server/claude-session-error-handler.test.ts +498 -0
  84. package/src/server/claude-session-error-handler.ts +361 -0
  85. package/src/server/claude-session-lifecycle.test.ts +669 -0
  86. package/src/server/claude-session-lifecycle.ts +283 -0
  87. package/src/server/claude-session-rebuild.test.ts +224 -0
  88. package/src/server/claude-session-rebuild.ts +101 -0
  89. package/src/server/claude-session-runner.test.ts +523 -0
  90. package/src/server/claude-session-runner.ts +521 -0
  91. package/src/server/claude-session-spawner.test.ts +401 -0
  92. package/src/server/claude-session-spawner.ts +401 -0
  93. package/src/server/claude-session-start.test.ts +205 -0
  94. package/src/server/claude-session-start.ts +277 -0
  95. package/src/server/claude-session-state-queries.test.ts +344 -0
  96. package/src/server/claude-session-state-queries.ts +160 -0
  97. package/src/server/claude-session-state.ts +104 -0
  98. package/src/server/claude-slash-commands.test.ts +277 -0
  99. package/src/server/claude-slash-commands.ts +234 -0
  100. package/src/server/claude-spawn-helpers.test.ts +221 -0
  101. package/src/server/claude-spawn-helpers.ts +179 -0
  102. package/src/server/claude-steer-log.test.ts +243 -0
  103. package/src/server/claude-steer-log.ts +58 -0
  104. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  105. package/src/server/claude-subagent-tool-response.ts +165 -0
  106. package/src/server/claude-subagent-wiring.test.ts +263 -0
  107. package/src/server/claude-subagent-wiring.ts +324 -0
  108. package/src/server/claude-tool-respond.test.ts +321 -0
  109. package/src/server/claude-tool-respond.ts +142 -0
  110. package/src/server/claude-turn-runner.test.ts +310 -0
  111. package/src/server/claude-turn-runner.ts +228 -0
  112. package/src/server/claude-turn-starter.test.ts +266 -0
  113. package/src/server/claude-turn-starter.ts +566 -0
  114. package/src/server/claude-usage-math.test.ts +169 -0
  115. package/src/server/claude-usage-math.ts +132 -0
  116. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  117. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  118. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  119. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  120. package/src/server/diff-store-errors.ts +85 -0
  121. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  122. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  123. package/src/server/diff-store-io.adapter.ts +27 -0
  124. package/src/server/diff-store-parse.test.ts +212 -0
  125. package/src/server/diff-store-parse.ts +122 -0
  126. package/src/server/diff-store-state.ts +82 -0
  127. package/src/server/diff-store.ts +76 -1854
  128. package/src/server/event-store-apply.test.ts +280 -0
  129. package/src/server/event-store-apply.ts +132 -0
  130. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  131. package/src/server/event-store-chat-lifecycle.ts +441 -0
  132. package/src/server/event-store-entity-write.ts +356 -0
  133. package/src/server/event-store-helpers.test.ts +230 -0
  134. package/src/server/event-store-helpers.ts +235 -0
  135. package/src/server/event-store-init.ts +276 -0
  136. package/src/server/event-store-messages.adapter.test.ts +532 -0
  137. package/src/server/event-store-messages.adapter.ts +455 -0
  138. package/src/server/event-store-orch-subscription.ts +124 -0
  139. package/src/server/event-store-orch.test.ts +462 -0
  140. package/src/server/event-store-orch.ts +312 -0
  141. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  142. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  143. package/src/server/event-store-snapshot.test.ts +597 -0
  144. package/src/server/event-store-snapshot.ts +658 -0
  145. package/src/server/event-store-subagent.test.ts +408 -0
  146. package/src/server/event-store-subagent.ts +198 -0
  147. package/src/server/event-store-tool-requests.test.ts +210 -0
  148. package/src/server/event-store-tool-requests.ts +131 -0
  149. package/src/server/event-store-transcript-write.adapter.ts +291 -0
  150. package/src/server/event-store-write-ops.test.ts +554 -0
  151. package/src/server/event-store-write-ops.ts +529 -0
  152. package/src/server/event-store.test.ts +75 -0
  153. package/src/server/event-store.ts +257 -2285
  154. package/src/server/events.ts +1 -1
  155. package/src/server/harness-types.ts +15 -1
  156. package/src/server/oauth-errors.ts +19 -0
  157. package/src/server/storage/backend.ts +7 -0
  158. package/src/server/storage/fs-storage.adapter.ts +21 -1
  159. package/src/server/storage/in-memory-storage.ts +12 -0
  160. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  161. package/src/server/ws-router-agent-ctrl.ts +137 -0
  162. package/src/server/ws-router-broadcast.ts +560 -0
  163. package/src/server/ws-router-chat.test.ts +461 -0
  164. package/src/server/ws-router-chat.ts +279 -0
  165. package/src/server/ws-router-defaults.test.ts +283 -0
  166. package/src/server/ws-router-defaults.ts +434 -0
  167. package/src/server/ws-router-diff.test.ts +167 -0
  168. package/src/server/ws-router-diff.ts +250 -0
  169. package/src/server/ws-router-envelope.ts +360 -0
  170. package/src/server/ws-router-misc.test.ts +422 -0
  171. package/src/server/ws-router-misc.ts +265 -0
  172. package/src/server/ws-router-orch.test.ts +190 -0
  173. package/src/server/ws-router-orch.ts +93 -0
  174. package/src/server/ws-router-project.test.ts +403 -0
  175. package/src/server/ws-router-project.ts +256 -0
  176. package/src/server/ws-router-push.test.ts +193 -0
  177. package/src/server/ws-router-push.ts +120 -0
  178. package/src/server/ws-router-settings.test.ts +173 -0
  179. package/src/server/ws-router-settings.ts +416 -0
  180. package/src/server/ws-router-skills.test.ts +194 -0
  181. package/src/server/ws-router-skills.ts +204 -0
  182. package/src/server/ws-router-utils.ts +238 -0
  183. package/src/server/ws-router.test.ts +90 -0
  184. package/src/server/ws-router.ts +340 -2254
  185. package/src/shared/app-settings-types.ts +365 -0
  186. package/src/shared/chat-ops.test.ts +138 -0
  187. package/src/shared/chat-ops.ts +71 -0
  188. package/src/shared/core-types.ts +27 -0
  189. package/src/shared/git-diff-types.ts +165 -0
  190. package/src/shared/mcp-types.ts +105 -0
  191. package/src/shared/protocol.ts +3 -2
  192. package/src/shared/provider-model-types.ts +437 -0
  193. package/src/shared/subagent-types.ts +198 -0
  194. package/src/shared/tool-call-types.ts +314 -0
  195. package/src/shared/transcript-types.ts +270 -0
  196. package/src/shared/types.ts +31 -1711
  197. package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
  198. package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
  199. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
  200. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.js +0 -1
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Message and transcript read operations extracted from event-store.ts.
3
+ *
4
+ * `loadTranscriptFromDisk` performs synchronous disk IO, so this module is an
5
+ * adapter (`.adapter.ts`). All other functions in this file are pure in-memory
6
+ * reads — they are co-located here for cohesion because they share the same
7
+ * deps interface and depend on `loadTranscriptFromDisk`.
8
+ *
9
+ * This module must NOT import from event-store.ts (no circular deps).
10
+ */
11
+ import path from "node:path"
12
+ import type { ChatHistoryPage, QueuedChatMessage, TranscriptEntry } from "../shared/types"
13
+ import type { StorageBackend } from "./storage/backend"
14
+ import type { ToolRequest } from "../shared/permission-policy"
15
+ import type { ChatRecord, StoreState } from "./events"
16
+ import { cloneTranscriptEntries } from "./events"
17
+ import {
18
+ coalesceContextWindowUpdates,
19
+ decodeCursor,
20
+ getHistorySnapshot,
21
+ getMessagesPageFromEntries,
22
+ } from "./event-store-helpers"
23
+
24
+ // ─── Transcript LRU cache ──────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Small LRU of fully loaded transcripts (Map insertion order = recency).
28
+ * Replaces the former single-chat `cachedTranscriptRef` so switching between
29
+ * a handful of chats does not re-read MB-scale JSONL files from disk.
30
+ */
31
+ export class TranscriptCache {
32
+ private readonly byChat = new Map<string, TranscriptEntry[]>()
33
+
34
+ constructor(private readonly maxChats: number = 4) {}
35
+
36
+ /** Returns the cached entries (touching LRU recency), or undefined. */
37
+ get(chatId: string): TranscriptEntry[] | undefined {
38
+ const entries = this.byChat.get(chatId)
39
+ if (!entries) return undefined
40
+ this.byChat.delete(chatId)
41
+ this.byChat.set(chatId, entries)
42
+ return entries
43
+ }
44
+
45
+ set(chatId: string, entries: TranscriptEntry[]): void {
46
+ this.byChat.delete(chatId)
47
+ this.byChat.set(chatId, entries)
48
+ while (this.byChat.size > this.maxChats) {
49
+ const oldest = this.byChat.keys().next().value
50
+ if (oldest === undefined) break
51
+ this.byChat.delete(oldest)
52
+ }
53
+ }
54
+
55
+ /** Appends to a cached transcript; no-op when the chat is not cached. */
56
+ appendTo(chatId: string, entry: TranscriptEntry): void {
57
+ this.byChat.get(chatId)?.push(entry)
58
+ }
59
+
60
+ has(chatId: string): boolean {
61
+ return this.byChat.has(chatId)
62
+ }
63
+
64
+ invalidate(chatId: string): void {
65
+ this.byChat.delete(chatId)
66
+ }
67
+
68
+ invalidateAll(): void {
69
+ this.byChat.clear()
70
+ }
71
+ }
72
+
73
+ // ─── Deps interface ────────────────────────────────────────────────────────
74
+
75
+ export interface MessageReadDeps {
76
+ readonly storage: StorageBackend
77
+ readonly transcriptsDir: string
78
+ readonly transcriptCache: TranscriptCache
79
+ readonly legacyMessagesByChatId: Map<string, TranscriptEntry[]>
80
+ readonly seenMessageIdsByChatId: Map<string, Set<string>>
81
+ readonly queuedMessagesByChatId: StoreState["queuedMessagesByChatId"]
82
+ readonly chatsById: Map<string, ChatRecord>
83
+ listPendingToolRequests: (chatId: string) => ToolRequest[]
84
+ }
85
+
86
+ // ─── Private helpers ───────────────────────────────────────────────────────
87
+
88
+ function transcriptPath(deps: MessageReadDeps, chatId: string): string {
89
+ return path.join(deps.transcriptsDir, `${chatId}.jsonl`)
90
+ }
91
+
92
+ // ─── Tail-read fast path ───────────────────────────────────────────────────
93
+
94
+ const TAIL_CHUNK_BYTES = 256 * 1024
95
+ const NEWLINE = 0x0a
96
+ const utf8Decoder = new TextDecoder()
97
+
98
+ export interface TranscriptTailResult {
99
+ entries: TranscriptEntry[]
100
+ /** Absolute byte offset of each entry's raw JSONL line, parallel to `entries`. */
101
+ lineOffsets: number[]
102
+ /** True when the slice covered the start of the file (entries are complete up to the end offset). */
103
+ reachedStart: boolean
104
+ }
105
+
106
+ function parseJsonlSlice(
107
+ buf: Uint8Array,
108
+ sliceStart: number,
109
+ atStart: boolean,
110
+ ): { entries: TranscriptEntry[]; lineOffsets: number[] } {
111
+ const entries: TranscriptEntry[] = []
112
+ const lineOffsets: number[] = []
113
+ let lineStart = 0
114
+ let skippedPartialFirstLine = atStart
115
+ for (let i = 0; i <= buf.length; i += 1) {
116
+ const atEnd = i === buf.length
117
+ if (!atEnd && buf[i] !== NEWLINE) continue
118
+ if (!skippedPartialFirstLine) {
119
+ // The slice may begin mid-line; the first segment is untrustworthy.
120
+ skippedPartialFirstLine = true
121
+ lineStart = i + 1
122
+ continue
123
+ }
124
+ if (i > lineStart) {
125
+ const text = utf8Decoder.decode(buf.subarray(lineStart, i)).trim()
126
+ if (text) {
127
+ try {
128
+ const entry: TranscriptEntry = JSON.parse(text)
129
+ entries.push(entry)
130
+ lineOffsets.push(sliceStart + lineStart)
131
+ } catch {
132
+ // torn/partial final line — skip
133
+ }
134
+ }
135
+ }
136
+ lineStart = i + 1
137
+ }
138
+ return { entries, lineOffsets }
139
+ }
140
+
141
+ /**
142
+ * Reads only the tail of the transcript JSONL (growing backwards until more
143
+ * than `minEntries` lines or BOF). Returns null when the storage backend has
144
+ * no byte-slice APIs — callers must fall back to the full-parse path.
145
+ */
146
+ export function readTranscriptTail(
147
+ deps: MessageReadDeps,
148
+ chatId: string,
149
+ minEntries: number,
150
+ endOffset?: number,
151
+ chunkBytes: number = TAIL_CHUNK_BYTES,
152
+ ): TranscriptTailResult | null {
153
+ const { storage } = deps
154
+ if (typeof storage.readSliceSync !== "function" || typeof storage.sizeSync !== "function") {
155
+ return null
156
+ }
157
+ const tPath = transcriptPath(deps, chatId)
158
+ if (!storage.existsSync(tPath)) {
159
+ return { entries: [], lineOffsets: [], reachedStart: true }
160
+ }
161
+ const fileSize = storage.sizeSync(tPath)
162
+ const end = Math.min(endOffset ?? fileSize, fileSize)
163
+ if (end <= 0) {
164
+ return { entries: [], lineOffsets: [], reachedStart: true }
165
+ }
166
+ let chunk = Math.max(chunkBytes, 64)
167
+ for (;;) {
168
+ const start = Math.max(0, end - chunk)
169
+ const buf = storage.readSliceSync(tPath, start, end)
170
+ const parsed = parseJsonlSlice(buf, start, start === 0)
171
+ if (start === 0 || parsed.entries.length > minEntries) {
172
+ return { ...parsed, reachedStart: start === 0 }
173
+ }
174
+ chunk *= 2
175
+ }
176
+ }
177
+
178
+ function decodeByteCursor(cursor: string): number | null {
179
+ if (!cursor.startsWith("byte:")) return null
180
+ const value = Number.parseInt(cursor.slice("byte:".length), 10)
181
+ if (!Number.isInteger(value) || value < 0) {
182
+ throw new Error("Invalid history cursor")
183
+ }
184
+ return value
185
+ }
186
+
187
+ /** Caches a COMPLETE transcript and seeds the messageId dedup set. */
188
+ function seedFullTranscript(deps: MessageReadDeps, chatId: string, entries: TranscriptEntry[]): void {
189
+ const seen = getSeenMessageIds(deps, chatId)
190
+ for (const entry of entries) {
191
+ const mid = entry.messageId
192
+ if (typeof mid === "string" && mid.length > 0) {
193
+ seen.add(mid)
194
+ }
195
+ }
196
+ deps.transcriptCache.set(chatId, entries)
197
+ }
198
+
199
+ /**
200
+ * Parses the single JSONL line starting at `offset` (the first entry of the
201
+ * already-served newer page). Used as a coalesce sentinel so a cwu run that
202
+ * straddles the page boundary collapses exactly like the full-array path.
203
+ */
204
+ function readEntryAtOffset(deps: MessageReadDeps, chatId: string, offset: number): TranscriptEntry | null {
205
+ const { storage } = deps
206
+ if (typeof storage.readSliceSync !== "function" || typeof storage.sizeSync !== "function") return null
207
+ const tPath = transcriptPath(deps, chatId)
208
+ const fileSize = storage.sizeSync(tPath)
209
+ const end = Math.min(offset + 1024 * 1024, fileSize)
210
+ const buf = storage.readSliceSync(tPath, offset, end)
211
+ const newlineIdx = buf.indexOf(NEWLINE)
212
+ if (newlineIdx < 0 && end < fileSize) return null
213
+ const lineEnd = newlineIdx < 0 ? buf.length : newlineIdx
214
+ try {
215
+ const entry: TranscriptEntry = JSON.parse(utf8Decoder.decode(buf.subarray(0, lineEnd)))
216
+ return entry
217
+ } catch {
218
+ return null
219
+ }
220
+ }
221
+
222
+ function pageFromTail(tail: TranscriptTailResult, limit: number, nextEntry?: TranscriptEntry | null): ChatHistoryPage {
223
+ // The sentinel participates in coalescing (so a trailing cwu run collapses
224
+ // against the newer page's leading cwu) and is then removed.
225
+ const coalesced = nextEntry
226
+ ? coalesceContextWindowUpdates([...tail.entries, nextEntry]).slice(0, -1)
227
+ : coalesceContextWindowUpdates(tail.entries)
228
+ const startIdx = Math.max(0, coalesced.length - limit)
229
+ const pageEntries = coalesced.slice(startIdx)
230
+ const hasOlder = !tail.reachedStart || startIdx > 0
231
+ let olderCursor: string | null = null
232
+ const first = pageEntries[0]
233
+ if (hasOlder && first) {
234
+ const rawIdx = tail.entries.indexOf(first)
235
+ const offset = tail.lineOffsets[rawIdx]
236
+ olderCursor = offset === undefined ? null : `byte:${offset}`
237
+ } else if (hasOlder && tail.lineOffsets.length > 0) {
238
+ // Page fully absorbed by coalescing (pure cwu run) — continue paging
239
+ // from the start of this slice so pagination cannot stall.
240
+ olderCursor = `byte:${tail.lineOffsets[0]}`
241
+ }
242
+ return {
243
+ messages: cloneTranscriptEntries(pageEntries),
244
+ hasOlder,
245
+ olderCursor,
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Serves the most recent page via tail-read, avoiding a full-file parse on
251
+ * cold open. When the tail turns out to be the whole file, the transcript is
252
+ * promoted into the cache (with seen-messageId seeding). Returns null when
253
+ * the backend lacks slice APIs.
254
+ */
255
+ export function getRecentMessagesPageTail(
256
+ deps: MessageReadDeps,
257
+ chatId: string,
258
+ limit: number,
259
+ chunkBytes?: number,
260
+ ): ChatHistoryPage | null {
261
+ const tail = readTranscriptTail(deps, chatId, limit, undefined, chunkBytes)
262
+ if (!tail) return null
263
+ if (tail.reachedStart) {
264
+ seedFullTranscript(deps, chatId, tail.entries)
265
+ }
266
+ return pageFromTail(tail, limit)
267
+ }
268
+
269
+ // ─── Exported functions ────────────────────────────────────────────────────
270
+
271
+ /** Returns (or lazily creates) the seen-messageId dedup set for a chat. */
272
+ export function getSeenMessageIds(deps: MessageReadDeps, chatId: string): Set<string> {
273
+ let set = deps.seenMessageIdsByChatId.get(chatId)
274
+ if (!set) {
275
+ set = new Set<string>()
276
+ deps.seenMessageIdsByChatId.set(chatId, set)
277
+ }
278
+ return set
279
+ }
280
+
281
+ /**
282
+ * Reads transcript entries from disk synchronously.
283
+ * Populates the seenMessageIds set as a side-effect.
284
+ */
285
+ export function loadTranscriptFromDisk(
286
+ deps: MessageReadDeps,
287
+ chatId: string,
288
+ ): TranscriptEntry[] {
289
+ const tPath = transcriptPath(deps, chatId)
290
+ if (!deps.storage.existsSync(tPath)) {
291
+ return []
292
+ }
293
+
294
+ const text = deps.storage.readTextSync(tPath)
295
+ if (!text.trim()) return []
296
+
297
+ const entries: TranscriptEntry[] = []
298
+ const seen = getSeenMessageIds(deps, chatId)
299
+ for (const rawLine of text.split("\n")) {
300
+ const line = rawLine.trim()
301
+ if (!line) continue
302
+ const entry: TranscriptEntry & { messageId?: string } = JSON.parse(line)
303
+ entries.push(entry)
304
+ const mid = entry.messageId
305
+ if (typeof mid === "string" && mid.length > 0) {
306
+ seen.add(mid)
307
+ }
308
+ }
309
+ return entries
310
+ }
311
+
312
+ /**
313
+ * Returns the cached transcript WITHOUT cloning (loads it on miss).
314
+ * Do-not-mutate contract: callers must treat the array and its entries as
315
+ * read-only; anything returned to mutation-prone callers must be cloned.
316
+ */
317
+ export function getMessagesView(deps: MessageReadDeps, chatId: string): readonly TranscriptEntry[] {
318
+ const cached = deps.transcriptCache.get(chatId)
319
+ if (cached) return cached
320
+
321
+ const legacyEntries = deps.legacyMessagesByChatId.get(chatId)
322
+ if (legacyEntries) {
323
+ const copy = cloneTranscriptEntries(legacyEntries)
324
+ deps.transcriptCache.set(chatId, copy)
325
+ return copy
326
+ }
327
+
328
+ const entries = loadTranscriptFromDisk(deps, chatId)
329
+ deps.transcriptCache.set(chatId, entries)
330
+ return entries
331
+ }
332
+
333
+ /**
334
+ * Returns cloned transcript entries for `chatId`, using the in-memory cache
335
+ * or loading from disk as needed.
336
+ */
337
+ export function getMessages(deps: MessageReadDeps, chatId: string): TranscriptEntry[] {
338
+ return cloneTranscriptEntries(getMessagesView(deps, chatId))
339
+ }
340
+
341
+ /** Returns queued messages for a chat, with attachment arrays cloned. */
342
+ export function getQueuedMessages(
343
+ deps: MessageReadDeps,
344
+ chatId: string,
345
+ ): QueuedChatMessage[] {
346
+ const entries = deps.queuedMessagesByChatId.get(chatId) ?? []
347
+ return entries.map((entry) => ({
348
+ ...entry,
349
+ attachments: [...entry.attachments],
350
+ }))
351
+ }
352
+
353
+ /** Returns a single queued message by id, or null. */
354
+ export function getQueuedMessage(
355
+ deps: MessageReadDeps,
356
+ chatId: string,
357
+ queuedMessageId: string,
358
+ ): QueuedChatMessage | null {
359
+ return getQueuedMessages(deps, chatId).find((entry) => entry.id === queuedMessageId) ?? null
360
+ }
361
+
362
+ /** Returns the most recent page of transcript messages. */
363
+ export function getRecentMessagesPage(
364
+ deps: MessageReadDeps,
365
+ chatId: string,
366
+ limit: number,
367
+ ): ChatHistoryPage {
368
+ if (limit <= 0) {
369
+ return { messages: [], hasOlder: false, olderCursor: null }
370
+ }
371
+
372
+ if (!deps.transcriptCache.has(chatId) && !deps.legacyMessagesByChatId.has(chatId)) {
373
+ const tailPage = getRecentMessagesPageTail(deps, chatId, limit)
374
+ if (tailPage) return tailPage
375
+ }
376
+
377
+ const entries = coalesceContextWindowUpdates(getMessagesView(deps, chatId))
378
+ const page = getMessagesPageFromEntries(entries, limit)
379
+
380
+ return {
381
+ messages: page.entries,
382
+ hasOlder: page.hasOlder,
383
+ olderCursor: page.olderCursor,
384
+ }
385
+ }
386
+
387
+ /** Returns a page of transcript messages before the given cursor. */
388
+ export function getMessagesPageBefore(
389
+ deps: MessageReadDeps,
390
+ chatId: string,
391
+ beforeCursor: string,
392
+ limit: number,
393
+ ): ChatHistoryPage {
394
+ if (limit <= 0) {
395
+ return { messages: [], hasOlder: false, olderCursor: null }
396
+ }
397
+
398
+ // Byte cursors are only ever issued by the tail-read path, whose storage
399
+ // has slice APIs — so readTranscriptTail cannot return null here.
400
+ const byteOffset = decodeByteCursor(beforeCursor)
401
+ if (byteOffset !== null) {
402
+ const tail = readTranscriptTail(deps, chatId, limit, byteOffset)
403
+ if (!tail) throw new Error("Invalid history cursor")
404
+ return pageFromTail(tail, limit, readEntryAtOffset(deps, chatId, byteOffset))
405
+ }
406
+
407
+ // Coalesce identically to getRecentMessagesPage so cursors (which index the
408
+ // coalesced array) stay consistent across recent + load-older paging.
409
+ const beforeIndex = decodeCursor(beforeCursor)
410
+ const entries = coalesceContextWindowUpdates(getMessagesView(deps, chatId))
411
+ const page = getMessagesPageFromEntries(entries, limit, beforeIndex)
412
+
413
+ return {
414
+ messages: page.entries,
415
+ hasOlder: page.hasOlder,
416
+ olderCursor: page.olderCursor,
417
+ }
418
+ }
419
+
420
+ /** Returns merged transcript + pending tool request entries, plus a history snapshot. */
421
+ export function getRecentChatHistory(
422
+ deps: MessageReadDeps,
423
+ chatId: string,
424
+ recentLimit: number,
425
+ ) {
426
+ const page = getRecentMessagesPage(deps, chatId, recentLimit)
427
+ const pending = deps.listPendingToolRequests(chatId)
428
+ const pendingEntries: TranscriptEntry[] = pending.map((req) => ({
429
+ _id: `pending-tool-request-${req.id}`,
430
+ createdAt: req.createdAt,
431
+ kind: "pending_tool_request",
432
+ toolRequestId: req.id,
433
+ toolName: req.toolName,
434
+ arguments: req.arguments,
435
+ }))
436
+ const merged = [...page.messages, ...pendingEntries]
437
+ return {
438
+ messages: merged,
439
+ history: getHistorySnapshot(
440
+ {
441
+ entries: merged,
442
+ hasOlder: page.hasOlder,
443
+ olderCursor: page.olderCursor,
444
+ },
445
+ recentLimit,
446
+ ),
447
+ }
448
+ }
449
+
450
+ /** Returns the count of active (non-deleted, non-archived) chats for a project. */
451
+ export function getChatCount(deps: MessageReadDeps, projectId: string): number {
452
+ return [...deps.chatsById.values()].filter(
453
+ (chat) => chat.projectId === projectId && !chat.deletedAt && !chat.archivedAt,
454
+ ).length
455
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Orchestration event write-path + subscription management extracted from
3
+ * event-store.ts.
4
+ *
5
+ * Wraps the pure read functions from event-store-orch.ts and adds:
6
+ * - `appendOrchestrationEvent` — sync apply + async disk enqueue
7
+ * - `subscribeOrchRuns` / `notifyOrchRunsChanged` — observer pattern
8
+ *
9
+ * All functions use injected deps so they carry no direct IO. EventStore
10
+ * is the sole owner of the underlying maps and write-chain.
11
+ *
12
+ * Must NOT import from event-store.ts (no circular deps).
13
+ */
14
+ import { LOG_PREFIX } from "../shared/branding"
15
+ import { log } from "../shared/log"
16
+ import type { OrchRunSnapshot } from "../shared/orchestration-types"
17
+ import type { OrchestrationEvent, OrchRunRecord } from "./events"
18
+ import {
19
+ gatedOrchTasks as gatedOrchTasksFn,
20
+ getAllOrchRunSnapshots,
21
+ getOrchLastPhaseOutput as getOrchLastPhaseOutputFn,
22
+ getOrchRunEvents as getOrchRunEventsFn,
23
+ getOrchRunSnapshot,
24
+ getOrchTaskSpec as getOrchTaskSpecFn,
25
+ nonTerminalOrchTasks as nonTerminalOrchTasksFn,
26
+ } from "./event-store-orch"
27
+
28
+ // ─── Deps interface ────────────────────────────────────────────────────────
29
+
30
+ export interface OrchSubscriptionDeps {
31
+ readonly orchRunsById: Map<string, OrchRunRecord>
32
+ readonly orchLogPath: string
33
+ readonly orchRunsSubscribers: Set<() => void>
34
+ applyEvent: (event: OrchestrationEvent) => void
35
+ enqueueDiskAppend: (filePath: string, payload: string) => void
36
+ }
37
+
38
+ // ─── Subscription state ───────────────────────────────────────────────────
39
+
40
+ /** Create the mutable subscription state held by EventStore. */
41
+ export function createOrchSubscriptionState(): { orchRunsSubscribers: Set<() => void> } {
42
+ return { orchRunsSubscribers: new Set() }
43
+ }
44
+
45
+ // ─── Write path ────────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Apply synchronously, then enqueue the disk append — the sync apply is what
49
+ * makes an orchestration claim atomic within one event-loop turn (same
50
+ * pattern as appendSubagentEvent).
51
+ */
52
+ export function appendOrchestrationEvent(
53
+ deps: OrchSubscriptionDeps,
54
+ event: OrchestrationEvent,
55
+ ): Promise<void> {
56
+ deps.applyEvent(event)
57
+ deps.enqueueDiskAppend(deps.orchLogPath, `${JSON.stringify(event)}\n`)
58
+ notifyOrchRunsChanged(deps)
59
+ return Promise.resolve()
60
+ }
61
+
62
+ /**
63
+ * Observe orchestration read-model changes. The callback fires after each
64
+ * live orch event is applied (not during boot replay — no subscribers yet).
65
+ * Returns an unsubscribe fn.
66
+ */
67
+ export function subscribeOrchRuns(deps: OrchSubscriptionDeps, cb: () => void): () => void {
68
+ deps.orchRunsSubscribers.add(cb)
69
+ return () => { deps.orchRunsSubscribers.delete(cb) }
70
+ }
71
+
72
+ function notifyOrchRunsChanged(deps: OrchSubscriptionDeps): void {
73
+ for (const cb of deps.orchRunsSubscribers) {
74
+ try { cb() } catch (err) { log.warn(`${LOG_PREFIX} orch-runs subscriber threw`, { err }) }
75
+ }
76
+ }
77
+
78
+ // ─── Read-model thin wrappers ──────────────────────────────────────────────
79
+
80
+ export function getOrchRun(
81
+ orchRunsById: Map<string, OrchRunRecord>,
82
+ runId: string,
83
+ ): OrchRunSnapshot | null {
84
+ return getOrchRunSnapshot(orchRunsById, runId)
85
+ }
86
+
87
+ export function getOrchRuns(orchRunsById: Map<string, OrchRunRecord>): OrchRunSnapshot[] {
88
+ return getAllOrchRunSnapshots(orchRunsById)
89
+ }
90
+
91
+ export function nonTerminalOrchTasks(
92
+ orchRunsById: Map<string, OrchRunRecord>,
93
+ ): Iterable<{ runId: string; taskId: string; state: "claimed" | "running" }> {
94
+ return nonTerminalOrchTasksFn(orchRunsById)
95
+ }
96
+
97
+ export function gatedOrchTasks(
98
+ orchRunsById: Map<string, OrchRunRecord>,
99
+ ): Iterable<{ runId: string; taskId: string; phaseIndex: number }> {
100
+ return gatedOrchTasksFn(orchRunsById)
101
+ }
102
+
103
+ export function getOrchTaskSpec(
104
+ orchRunsById: Map<string, OrchRunRecord>,
105
+ runId: string,
106
+ taskId: string,
107
+ ): { prompt: string; scopePaths: string[] } | null {
108
+ return getOrchTaskSpecFn(orchRunsById, runId, taskId)
109
+ }
110
+
111
+ export function getOrchLastPhaseOutput(
112
+ orchRunsById: Map<string, OrchRunRecord>,
113
+ runId: string,
114
+ taskId: string,
115
+ ): string | null {
116
+ return getOrchLastPhaseOutputFn(orchRunsById, runId, taskId)
117
+ }
118
+
119
+ export function getOrchRunEvents(
120
+ orchRunsById: Map<string, OrchRunRecord>,
121
+ runId: string,
122
+ ): OrchestrationEvent[] {
123
+ return getOrchRunEventsFn(orchRunsById, runId)
124
+ }