@kkelly-offical/kkcode 0.1.7 → 0.2.3-preview.1

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 (166) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +474 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +228 -220
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +89 -89
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/commands/update.mjs +32 -0
  29. package/src/config/defaults.mjs +289 -260
  30. package/src/config/import-config.mjs +1 -1
  31. package/src/config/load-config.mjs +61 -4
  32. package/src/config/schema.mjs +604 -574
  33. package/src/context.mjs +4 -1
  34. package/src/core/constants.mjs +97 -91
  35. package/src/core/types.mjs +1 -1
  36. package/src/github/api.mjs +78 -78
  37. package/src/github/auth.mjs +294 -286
  38. package/src/github/flow.mjs +298 -298
  39. package/src/github/workspace.mjs +225 -212
  40. package/src/index.mjs +87 -82
  41. package/src/knowledge/frontend-aesthetics.txt +38 -38
  42. package/src/mcp/client-http.mjs +139 -141
  43. package/src/mcp/client-sse.mjs +297 -288
  44. package/src/mcp/client-stdio.mjs +534 -533
  45. package/src/mcp/constants.mjs +4 -2
  46. package/src/mcp/registry.mjs +498 -479
  47. package/src/mcp/stdio-framing.mjs +135 -133
  48. package/src/mcp/tool-result.mjs +24 -24
  49. package/src/observability/edit-diagnostics.mjs +449 -0
  50. package/src/observability/index.mjs +42 -42
  51. package/src/observability/metrics.mjs +165 -137
  52. package/src/observability/tracer.mjs +137 -137
  53. package/src/onboarding.mjs +209 -0
  54. package/src/orchestration/background-manager.mjs +567 -372
  55. package/src/orchestration/background-worker.mjs +419 -305
  56. package/src/orchestration/interruption-reason.mjs +21 -0
  57. package/src/orchestration/longagent-manager.mjs +197 -171
  58. package/src/orchestration/stage-scheduler.mjs +733 -728
  59. package/src/orchestration/subagent-router.mjs +7 -1
  60. package/src/orchestration/task-scheduler.mjs +219 -7
  61. package/src/permission/engine.mjs +1 -1
  62. package/src/permission/exec-policy.mjs +370 -370
  63. package/src/permission/file-edit-policy.mjs +108 -0
  64. package/src/permission/prompt.mjs +1 -1
  65. package/src/permission/rules.mjs +116 -7
  66. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  67. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  68. package/src/plugin/hook-bus.mjs +19 -5
  69. package/src/plugin/manifest-loader.mjs +222 -0
  70. package/src/provider/anthropic.mjs +396 -390
  71. package/src/provider/ollama.mjs +7 -1
  72. package/src/provider/openai.mjs +382 -340
  73. package/src/provider/retry-policy.mjs +74 -68
  74. package/src/provider/router.mjs +242 -241
  75. package/src/provider/sse.mjs +104 -104
  76. package/src/provider/wizard.mjs +556 -0
  77. package/src/repl/capability-facade.mjs +30 -0
  78. package/src/repl/command-surface.mjs +23 -0
  79. package/src/repl/controller-entry.mjs +40 -0
  80. package/src/repl/core-shell.mjs +208 -0
  81. package/src/repl/dialog-router.mjs +87 -0
  82. package/src/repl/input-engine.mjs +76 -0
  83. package/src/repl/keymap.mjs +7 -0
  84. package/src/repl/operator-surface.mjs +15 -0
  85. package/src/repl/permission-flow.mjs +49 -0
  86. package/src/repl/runtime-facade.mjs +36 -0
  87. package/src/repl/slash-router.mjs +62 -0
  88. package/src/repl/state-store.mjs +29 -0
  89. package/src/repl/turn-controller.mjs +58 -0
  90. package/src/repl/verification.mjs +23 -0
  91. package/src/repl.mjs +3371 -2981
  92. package/src/rules/load-rules.mjs +3 -3
  93. package/src/runtime.mjs +1 -1
  94. package/src/session/agent-transaction.mjs +86 -0
  95. package/src/session/checkpoint.mjs +302 -302
  96. package/src/session/compaction.mjs +298 -298
  97. package/src/session/engine.mjs +417 -232
  98. package/src/session/longagent-4stage.mjs +467 -460
  99. package/src/session/longagent-hybrid.mjs +1344 -1097
  100. package/src/session/longagent-plan.mjs +376 -365
  101. package/src/session/longagent-project-memory.mjs +53 -53
  102. package/src/session/longagent-scaffold.mjs +291 -291
  103. package/src/session/longagent-task-bus.mjs +138 -54
  104. package/src/session/longagent-utils.mjs +828 -472
  105. package/src/session/longagent.mjs +911 -900
  106. package/src/session/loop.mjs +1005 -930
  107. package/src/session/prompt/agent.txt +25 -25
  108. package/src/session/prompt/anthropic.txt +150 -150
  109. package/src/session/prompt/beast.txt +1 -1
  110. package/src/session/prompt/plan.txt +31 -31
  111. package/src/session/prompt/qwen.txt +46 -46
  112. package/src/session/recovery.mjs +21 -0
  113. package/src/session/rollback.mjs +196 -195
  114. package/src/session/routing-observability.mjs +72 -0
  115. package/src/session/runtime-state.mjs +47 -0
  116. package/src/session/store.mjs +523 -519
  117. package/src/session/system-prompt.mjs +308 -273
  118. package/src/session/task-validator.mjs +267 -267
  119. package/src/session/usability-gates.mjs +2 -2
  120. package/src/skill/builtin/commit.mjs +64 -64
  121. package/src/skill/builtin/design.mjs +76 -76
  122. package/src/skill/generator.mjs +18 -2
  123. package/src/skill/registry.mjs +642 -390
  124. package/src/storage/audit-store.mjs +18 -11
  125. package/src/storage/event-log.mjs +7 -1
  126. package/src/storage/ghost-commit-store.mjs +243 -245
  127. package/src/storage/paths.mjs +17 -0
  128. package/src/theme/default-theme.mjs +1 -1
  129. package/src/theme/markdown.mjs +4 -0
  130. package/src/theme/schema.mjs +1 -1
  131. package/src/theme/status-bar.mjs +162 -158
  132. package/src/tool/audit-wrapper.mjs +18 -2
  133. package/src/tool/edit-transaction.mjs +23 -0
  134. package/src/tool/executor.mjs +26 -1
  135. package/src/tool/file-read-state.mjs +65 -0
  136. package/src/tool/git-auto.mjs +526 -526
  137. package/src/tool/git-full-auto.mjs +487 -478
  138. package/src/tool/mutation-guard.mjs +54 -0
  139. package/src/tool/prompt/edit.txt +3 -3
  140. package/src/tool/prompt/multiedit.txt +1 -0
  141. package/src/tool/prompt/notebookedit.txt +2 -1
  142. package/src/tool/prompt/patch.txt +25 -24
  143. package/src/tool/prompt/read.txt +3 -3
  144. package/src/tool/prompt/sysinfo.txt +29 -0
  145. package/src/tool/prompt/task.txt +66 -4
  146. package/src/tool/prompt/write.txt +2 -2
  147. package/src/tool/question-prompt.mjs +99 -93
  148. package/src/tool/registry.mjs +1701 -1343
  149. package/src/tool/task-tool.mjs +14 -6
  150. package/src/ui/activity-renderer.mjs +667 -664
  151. package/src/ui/repl-background-panel.mjs +7 -0
  152. package/src/ui/repl-capability-panel.mjs +9 -0
  153. package/src/ui/repl-dashboard.mjs +54 -4
  154. package/src/ui/repl-help.mjs +110 -0
  155. package/src/ui/repl-operator-panel.mjs +12 -0
  156. package/src/ui/repl-route-feedback.mjs +35 -0
  157. package/src/ui/repl-status-view.mjs +76 -0
  158. package/src/ui/repl-task-panel.mjs +5 -0
  159. package/src/ui/repl-transcript-panel.mjs +56 -0
  160. package/src/ui/repl-turn-summary.mjs +135 -0
  161. package/src/update/checker.mjs +184 -0
  162. package/src/usage/pricing.mjs +122 -121
  163. package/src/usage/usage-meter.mjs +1 -0
  164. package/src/util/git.mjs +562 -519
  165. package/src/util/template.mjs +6 -1
  166. package/src/version.mjs +3 -0
@@ -1,519 +1,523 @@
1
- import { randomUUID } from "node:crypto"
2
- import path from "node:path"
3
- import { access, readdir, unlink, rm } from "node:fs/promises"
4
- import {
5
- ensureUserRoot,
6
- ensureSessionShardRoot,
7
- sessionIndexPath,
8
- sessionDataPath,
9
- legacySessionStorePath,
10
- sessionShardRootPath,
11
- sessionCheckpointRootPath
12
- } from "../storage/paths.mjs"
13
- import { readJson, writeJson } from "../storage/json-store.mjs"
14
-
15
- function now() {
16
- return Date.now()
17
- }
18
-
19
- function defaultIndex() {
20
- return {
21
- version: 2,
22
- updatedAt: now(),
23
- sessions: {}
24
- }
25
- }
26
-
27
- function defaultSessionData() {
28
- return {
29
- messages: [],
30
- parts: []
31
- }
32
- }
33
-
34
- function newMessage(role, content, extra = {}) {
35
- return {
36
- id: `msg_${randomUUID().slice(0, 12)}`,
37
- role,
38
- content,
39
- createdAt: now(),
40
- ...extra
41
- }
42
- }
43
-
44
- function newPart(type, payload = {}) {
45
- return {
46
- id: `part_${randomUUID().slice(0, 12)}`,
47
- type,
48
- createdAt: now(),
49
- ...payload
50
- }
51
- }
52
-
53
- function normalizeSessionData(raw) {
54
- if (!raw || typeof raw !== "object") return defaultSessionData()
55
- return {
56
- messages: Array.isArray(raw.messages) ? raw.messages : [],
57
- parts: Array.isArray(raw.parts) ? raw.parts : []
58
- }
59
- }
60
-
61
- async function exists(file) {
62
- try {
63
- await access(file)
64
- return true
65
- } catch {
66
- return false
67
- }
68
- }
69
-
70
- const state = {
71
- loaded: false,
72
- index: defaultIndex(),
73
- sessionCache: new Map(),
74
- dirtyIndex: false,
75
- dirtySessions: new Set(),
76
- flushTimer: null,
77
- options: {
78
- sessionShardEnabled: true,
79
- flushIntervalMs: 1000
80
- }
81
- }
82
-
83
- const LOCK_TIMEOUT_MS = 30000
84
-
85
- let lock = Promise.resolve()
86
- function withLock(fn) {
87
- const run = lock.then(fn, fn)
88
- lock = run.then(
89
- () => undefined,
90
- () => undefined
91
- )
92
- return Promise.race([
93
- run,
94
- new Promise((_, reject) => {
95
- setTimeout(() => reject(new Error("[store] withLock timeout after 30s")), LOCK_TIMEOUT_MS)
96
- })
97
- ])
98
- }
99
-
100
- function scheduleFlush() {
101
- if (state.options.flushIntervalMs <= 0) return
102
- if (state.flushTimer) return
103
- state.flushTimer = setTimeout(() => {
104
- state.flushTimer = null
105
- flushNow().catch((err) => {
106
- console.error("[store] flush failed:", err?.message || err)
107
- })
108
- }, state.options.flushIntervalMs)
109
- }
110
-
111
- function markDirty(sessionId = null) {
112
- state.dirtyIndex = true
113
- if (sessionId) state.dirtySessions.add(sessionId)
114
- if (state.options.flushIntervalMs <= 0) return
115
- scheduleFlush()
116
- }
117
-
118
- async function flushUnsafe() {
119
- if (!state.loaded) return
120
- await ensureUserRoot()
121
- await ensureSessionShardRoot()
122
-
123
- for (const sessionId of [...state.dirtySessions]) {
124
- const data = state.sessionCache.get(sessionId) || defaultSessionData()
125
- await writeJson(sessionDataPath(sessionId), data)
126
- state.dirtySessions.delete(sessionId)
127
- }
128
-
129
- if (state.dirtyIndex) {
130
- state.index.updatedAt = now()
131
- await writeJson(sessionIndexPath(), state.index)
132
- state.dirtyIndex = false
133
- }
134
- }
135
-
136
- export async function flushNow() {
137
- return withLock(async () => {
138
- await flushUnsafe()
139
- })
140
- }
141
-
142
- async function loadSessionDataUnsafe(sessionId) {
143
- if (state.sessionCache.has(sessionId)) {
144
- return state.sessionCache.get(sessionId)
145
- }
146
- const data = normalizeSessionData(await readJson(sessionDataPath(sessionId), defaultSessionData()))
147
- state.sessionCache.set(sessionId, data)
148
- return data
149
- }
150
-
151
- async function migrateLegacyStoreIfNeededUnsafe() {
152
- const indexFile = sessionIndexPath()
153
- if (await exists(indexFile)) {
154
- state.index = await readJson(indexFile, defaultIndex())
155
- return
156
- }
157
-
158
- const legacy = await readJson(legacySessionStorePath(), null)
159
- if (!legacy || typeof legacy !== "object" || !legacy.sessions || typeof legacy.sessions !== "object") {
160
- state.index = defaultIndex()
161
- await writeJson(indexFile, state.index)
162
- return
163
- }
164
-
165
- const next = defaultIndex()
166
- for (const [sessionId, session] of Object.entries(legacy.sessions || {})) {
167
- next.sessions[sessionId] = {
168
- ...session
169
- }
170
- const data = normalizeSessionData({
171
- messages: legacy.messages?.[sessionId] || [],
172
- parts: legacy.parts?.[sessionId] || []
173
- })
174
- state.sessionCache.set(sessionId, data)
175
- await writeJson(sessionDataPath(sessionId), data)
176
- }
177
- state.index = next
178
- await writeJson(indexFile, next)
179
- }
180
-
181
- async function ensureLoadedUnsafe() {
182
- if (state.loaded) return
183
- await ensureUserRoot()
184
- await ensureSessionShardRoot()
185
- await migrateLegacyStoreIfNeededUnsafe()
186
- state.loaded = true
187
- }
188
-
189
- async function ensureLoaded() {
190
- return withLock(async () => {
191
- await ensureLoadedUnsafe()
192
- })
193
- }
194
-
195
- export function configureSessionStore(options = {}) {
196
- if (typeof options.sessionShardEnabled === "boolean") {
197
- state.options.sessionShardEnabled = options.sessionShardEnabled
198
- }
199
- if (Number.isInteger(options.flushIntervalMs) && options.flushIntervalMs >= 0) {
200
- state.options.flushIntervalMs = options.flushIntervalMs
201
- }
202
- }
203
-
204
- export async function touchSession({
205
- sessionId,
206
- mode,
207
- model,
208
- providerType,
209
- cwd,
210
- title = null,
211
- status = "active",
212
- parentSessionId = null,
213
- forkFrom = null
214
- }) {
215
- return withLock(async () => {
216
- await ensureLoadedUnsafe()
217
- const existing = state.index.sessions[sessionId]
218
- const createdAt = existing?.createdAt || now()
219
- state.index.sessions[sessionId] = {
220
- id: sessionId,
221
- mode,
222
- model,
223
- providerType,
224
- cwd,
225
- title: existing?.title || title || `${mode}:${model}`,
226
- status,
227
- parentSessionId: parentSessionId || existing?.parentSessionId || null,
228
- forkFrom: forkFrom || existing?.forkFrom || null,
229
- retryMeta: existing?.retryMeta || null,
230
- patchRefs: existing?.patchRefs || [],
231
- reviewDecisions: existing?.reviewDecisions || [],
232
- budgetState: existing?.budgetState || null,
233
- createdAt,
234
- updatedAt: now()
235
- }
236
- await loadSessionDataUnsafe(sessionId)
237
- markDirty(sessionId)
238
- if (state.options.flushIntervalMs <= 0) await flushUnsafe()
239
- return state.index.sessions[sessionId]
240
- })
241
- }
242
-
243
- export async function updateSession(sessionId, patch) {
244
- return withLock(async () => {
245
- await ensureLoadedUnsafe()
246
- const current = state.index.sessions[sessionId]
247
- if (!current) return null
248
- state.index.sessions[sessionId] = {
249
- ...current,
250
- ...patch,
251
- updatedAt: now()
252
- }
253
- markDirty(sessionId)
254
- if (state.options.flushIntervalMs <= 0) await flushUnsafe()
255
- return state.index.sessions[sessionId]
256
- })
257
- }
258
-
259
- export async function appendMessage(sessionId, role, content, extra = {}) {
260
- return withLock(async () => {
261
- await ensureLoadedUnsafe()
262
- const data = await loadSessionDataUnsafe(sessionId)
263
- const message = newMessage(role, content, extra)
264
- data.messages.push(message)
265
- if (state.index.sessions[sessionId]) state.index.sessions[sessionId].updatedAt = now()
266
- markDirty(sessionId)
267
- if (state.options.flushIntervalMs <= 0) await flushUnsafe()
268
- return message
269
- })
270
- }
271
-
272
- export async function replaceMessages(sessionId, newMessages) {
273
- return withLock(async () => {
274
- await ensureLoadedUnsafe()
275
- const data = await loadSessionDataUnsafe(sessionId)
276
- data.messages = newMessages.map((m) => ({
277
- ...m,
278
- id: m.id || `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
279
- timestamp: m.timestamp || now()
280
- }))
281
- if (state.index.sessions[sessionId]) state.index.sessions[sessionId].updatedAt = now()
282
- markDirty(sessionId)
283
- if (state.options.flushIntervalMs <= 0) await flushUnsafe()
284
- })
285
- }
286
-
287
- export async function appendPart(sessionId, part) {
288
- return withLock(async () => {
289
- await ensureLoadedUnsafe()
290
- const data = await loadSessionDataUnsafe(sessionId)
291
- const normalized = newPart(part.type || "event", part)
292
- data.parts.push(normalized)
293
- if (state.index.sessions[sessionId]) state.index.sessions[sessionId].updatedAt = now()
294
- markDirty(sessionId)
295
- if (state.options.flushIntervalMs <= 0) await flushUnsafe()
296
- return normalized
297
- })
298
- }
299
-
300
- export async function getSession(sessionId) {
301
- return withLock(async () => {
302
- await ensureLoadedUnsafe()
303
- await flushUnsafe()
304
- const session = state.index.sessions[sessionId]
305
- if (!session) return null
306
- const data = await loadSessionDataUnsafe(sessionId)
307
- return {
308
- session,
309
- messages: [...data.messages],
310
- parts: [...data.parts]
311
- }
312
- })
313
- }
314
-
315
- export async function listSessions({ cwd = null, limit = 100, includeChildren = true } = {}) {
316
- return withLock(async () => {
317
- await ensureLoadedUnsafe()
318
- let sessions = Object.values(state.index.sessions)
319
- if (cwd) sessions = sessions.filter((s) => s.cwd === cwd)
320
- if (!includeChildren) sessions = sessions.filter((s) => !s.parentSessionId)
321
- return sessions.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit)
322
- })
323
- }
324
-
325
- export async function getConversationHistory(sessionId, limit = 30) {
326
- return withLock(async () => {
327
- await ensureLoadedUnsafe()
328
- const data = await loadSessionDataUnsafe(sessionId)
329
- const msgs = data.messages
330
- // Always preserve compaction summary (first message) — it must never be sliced off
331
- // by the limit window, otherwise the model loses all prior context
332
- const firstIsCompaction = msgs.length > 0 &&
333
- typeof msgs[0].content === "string" &&
334
- msgs[0].content.includes("<compaction-summary>")
335
- const sliced = firstIsCompaction
336
- ? [msgs[0], ...msgs.slice(1).slice(-limit)]
337
- : msgs.slice(-limit)
338
- return sliced.map((msg) => ({
339
- role: msg.role,
340
- content: msg.content
341
- }))
342
- })
343
- }
344
-
345
- export async function markSessionStatus(sessionId, status) {
346
- return updateSession(sessionId, { status })
347
- }
348
-
349
- export async function exportSession(sessionId) {
350
- return getSession(sessionId)
351
- }
352
-
353
- export async function forkSession({ sessionId, newSessionId, title = null }) {
354
- return withLock(async () => {
355
- await ensureLoadedUnsafe()
356
- const source = state.index.sessions[sessionId]
357
- if (!source) return null
358
-
359
- const sourceData = await loadSessionDataUnsafe(sessionId)
360
- const child = {
361
- ...source,
362
- id: newSessionId,
363
- parentSessionId: source.id,
364
- forkFrom: source.id,
365
- title: title || `${source.title} (fork)`,
366
- createdAt: now(),
367
- updatedAt: now()
368
- }
369
- state.index.sessions[newSessionId] = child
370
- state.sessionCache.set(newSessionId, {
371
- messages: sourceData.messages.map((m) => ({ ...m })),
372
- parts: sourceData.parts.map((p) => ({ ...p }))
373
- })
374
- markDirty(newSessionId)
375
- if (state.options.flushIntervalMs <= 0) await flushUnsafe()
376
- return child
377
- })
378
- }
379
-
380
- export async function applyReviewDecision(sessionId, decision) {
381
- return withLock(async () => {
382
- await ensureLoadedUnsafe()
383
- const session = state.index.sessions[sessionId]
384
- if (!session) return null
385
- session.reviewDecisions = session.reviewDecisions || []
386
- session.reviewDecisions.push({
387
- id: `rev_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
388
- createdAt: now(),
389
- ...decision
390
- })
391
- session.updatedAt = now()
392
- markDirty(sessionId)
393
- if (state.options.flushIntervalMs <= 0) await flushUnsafe()
394
- return session
395
- })
396
- }
397
-
398
- export async function setBudgetState(sessionId, budgetState) {
399
- return updateSession(sessionId, { budgetState })
400
- }
401
-
402
- export async function appendUserMessage(sessionId, content, extra = {}) {
403
- return appendMessage(sessionId, "user", content, extra)
404
- }
405
-
406
- export async function appendAssistantMessage(sessionId, content, extra = {}) {
407
- return appendMessage(sessionId, "assistant", content, extra)
408
- }
409
-
410
- export async function fsckSessionStore() {
411
- return withLock(async () => {
412
- await ensureLoadedUnsafe()
413
- await flushUnsafe()
414
-
415
- const report = {
416
- ok: true,
417
- checkedAt: now(),
418
- sessionsInIndex: Object.keys(state.index.sessions).length,
419
- filesOnDisk: 0,
420
- missingDataFiles: [],
421
- orphanDataFiles: [],
422
- invalidDataFiles: [],
423
- suggestions: []
424
- }
425
-
426
- const entries = await readdir(sessionShardRootPath(), { withFileTypes: true }).catch(() => [])
427
- const diskSessionIds = entries
428
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name !== "index.json")
429
- .map((entry) => path.basename(entry.name, ".json"))
430
- report.filesOnDisk = diskSessionIds.length
431
-
432
- const indexIds = new Set(Object.keys(state.index.sessions))
433
- for (const sessionId of indexIds) {
434
- const file = sessionDataPath(sessionId)
435
- if (!(await exists(file))) {
436
- report.missingDataFiles.push(sessionId)
437
- continue
438
- }
439
- const parsed = await readJson(file, null)
440
- if (!parsed || !Array.isArray(parsed.messages) || !Array.isArray(parsed.parts)) {
441
- report.invalidDataFiles.push(sessionId)
442
- }
443
- }
444
-
445
- for (const sessionId of diskSessionIds) {
446
- if (!indexIds.has(sessionId)) {
447
- report.orphanDataFiles.push(sessionId)
448
- }
449
- }
450
-
451
- if (report.missingDataFiles.length || report.orphanDataFiles.length || report.invalidDataFiles.length) {
452
- report.ok = false
453
- if (report.missingDataFiles.length) report.suggestions.push("Run `kkcode session gc` to remove broken index entries.")
454
- if (report.orphanDataFiles.length) report.suggestions.push("Run `kkcode session gc --orphans-only` to clean orphan session files.")
455
- if (report.invalidDataFiles.length) report.suggestions.push("Backup invalid files then remove or restore them from snapshot.")
456
- } else {
457
- report.suggestions.push("No consistency issue detected.")
458
- }
459
-
460
- return report
461
- })
462
- }
463
-
464
- export async function gcSessionStore({ orphansOnly = false, maxAgeDays = 30 } = {}) {
465
- return withLock(async () => {
466
- await ensureLoadedUnsafe()
467
- await flushUnsafe()
468
-
469
- const removed = {
470
- orphanFiles: [],
471
- staleSessions: [],
472
- checkpointDirs: []
473
- }
474
-
475
- const entries = await readdir(sessionShardRootPath(), { withFileTypes: true }).catch(() => [])
476
- const diskSessionIds = entries
477
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name !== "index.json")
478
- .map((entry) => path.basename(entry.name, ".json"))
479
- const indexIds = new Set(Object.keys(state.index.sessions))
480
-
481
- for (const sessionId of diskSessionIds) {
482
- if (!indexIds.has(sessionId)) {
483
- await unlink(sessionDataPath(sessionId)).catch(() => {})
484
- state.sessionCache.delete(sessionId)
485
- removed.orphanFiles.push(sessionId)
486
- }
487
- }
488
-
489
- if (!orphansOnly) {
490
- const cutoff = now() - Math.max(1, Number(maxAgeDays || 30)) * 24 * 60 * 60 * 1000
491
- const removableStatuses = new Set(["completed", "error", "stopped", "max-iterations", "no-progress", "heartbeat-timeout", "cancelled"])
492
- for (const [sessionId, session] of Object.entries(state.index.sessions)) {
493
- if (session.updatedAt > cutoff) continue
494
- if (!removableStatuses.has(session.status)) continue
495
- delete state.index.sessions[sessionId]
496
- state.sessionCache.delete(sessionId)
497
- await unlink(sessionDataPath(sessionId)).catch(() => {})
498
- removed.staleSessions.push(sessionId)
499
- }
500
- }
501
-
502
- const checkpointEntries = await readdir(sessionCheckpointRootPath(), { withFileTypes: true }).catch(() => [])
503
- const liveSessionIds = new Set(Object.keys(state.index.sessions))
504
- for (const entry of checkpointEntries) {
505
- if (!entry.isDirectory()) continue
506
- const sessionId = entry.name
507
- if (liveSessionIds.has(sessionId)) continue
508
- await rm(path.join(sessionCheckpointRootPath(), sessionId), { recursive: true, force: true }).catch(() => {})
509
- removed.checkpointDirs.push(sessionId)
510
- }
511
-
512
- state.dirtyIndex = true
513
- await flushUnsafe()
514
- return {
515
- removed,
516
- totalRemoved: removed.orphanFiles.length + removed.staleSessions.length + removed.checkpointDirs.length
517
- }
518
- })
519
- }
1
+ import { randomUUID } from "node:crypto"
2
+ import path from "node:path"
3
+ import { access, readdir, unlink, rm } from "node:fs/promises"
4
+ import {
5
+ ensureUserRoot,
6
+ ensureSessionShardRoot,
7
+ sessionIndexPath,
8
+ sessionDataPath,
9
+ legacySessionStorePath,
10
+ sessionShardRootPath,
11
+ sessionCheckpointRootPath
12
+ } from "../storage/paths.mjs"
13
+ import { readJson, writeJson } from "../storage/json-store.mjs"
14
+
15
+ function now() {
16
+ return Date.now()
17
+ }
18
+
19
+ function defaultIndex() {
20
+ return {
21
+ version: 2,
22
+ updatedAt: now(),
23
+ sessions: {}
24
+ }
25
+ }
26
+
27
+ function defaultSessionData() {
28
+ return {
29
+ messages: [],
30
+ parts: []
31
+ }
32
+ }
33
+
34
+ function newMessage(role, content, extra = {}) {
35
+ return {
36
+ id: `msg_${randomUUID().slice(0, 12)}`,
37
+ role,
38
+ content,
39
+ createdAt: now(),
40
+ ...extra
41
+ }
42
+ }
43
+
44
+ function newPart(type, payload = {}) {
45
+ return {
46
+ id: `part_${randomUUID().slice(0, 12)}`,
47
+ type,
48
+ createdAt: now(),
49
+ ...payload
50
+ }
51
+ }
52
+
53
+ function normalizeSessionData(raw) {
54
+ if (!raw || typeof raw !== "object") return defaultSessionData()
55
+ return {
56
+ messages: Array.isArray(raw.messages) ? raw.messages : [],
57
+ parts: Array.isArray(raw.parts) ? raw.parts : []
58
+ }
59
+ }
60
+
61
+ async function exists(file) {
62
+ try {
63
+ await access(file)
64
+ return true
65
+ } catch {
66
+ return false
67
+ }
68
+ }
69
+
70
+ const state = {
71
+ loaded: false,
72
+ index: defaultIndex(),
73
+ sessionCache: new Map(),
74
+ dirtyIndex: false,
75
+ dirtySessions: new Set(),
76
+ flushTimer: null,
77
+ options: {
78
+ sessionShardEnabled: true,
79
+ flushIntervalMs: 1000
80
+ }
81
+ }
82
+
83
+ const LOCK_TIMEOUT_MS = 30000
84
+
85
+ let lock = Promise.resolve()
86
+ function withLock(fn) {
87
+ const run = lock.then(fn, fn)
88
+ lock = run.then(
89
+ () => undefined,
90
+ () => undefined
91
+ )
92
+ let timer
93
+ return Promise.race([
94
+ run.finally(() => clearTimeout(timer)),
95
+ new Promise((_, reject) => {
96
+ timer = setTimeout(() => reject(new Error("[store] withLock timeout after 30s")), LOCK_TIMEOUT_MS)
97
+ })
98
+ ])
99
+ }
100
+
101
+ function scheduleFlush() {
102
+ if (state.options.flushIntervalMs <= 0) return
103
+ if (state.flushTimer) return
104
+ state.flushTimer = setTimeout(() => {
105
+ state.flushTimer = null
106
+ flushNow().catch((err) => {
107
+ console.error("[store] flush failed:", err?.message || err)
108
+ })
109
+ }, state.options.flushIntervalMs)
110
+ }
111
+
112
+ function markDirty(sessionId = null) {
113
+ state.dirtyIndex = true
114
+ if (sessionId) state.dirtySessions.add(sessionId)
115
+ if (state.options.flushIntervalMs <= 0) return
116
+ scheduleFlush()
117
+ }
118
+
119
+ async function flushUnsafe() {
120
+ if (!state.loaded) return
121
+ await ensureUserRoot()
122
+ await ensureSessionShardRoot()
123
+
124
+ for (const sessionId of [...state.dirtySessions]) {
125
+ const data = state.sessionCache.get(sessionId) || defaultSessionData()
126
+ await writeJson(sessionDataPath(sessionId), data)
127
+ state.dirtySessions.delete(sessionId)
128
+ }
129
+
130
+ if (state.dirtyIndex) {
131
+ state.index.updatedAt = now()
132
+ await writeJson(sessionIndexPath(), state.index)
133
+ state.dirtyIndex = false
134
+ }
135
+ }
136
+
137
+ export async function flushNow() {
138
+ return withLock(async () => {
139
+ await flushUnsafe()
140
+ })
141
+ }
142
+
143
+ async function loadSessionDataUnsafe(sessionId) {
144
+ if (state.sessionCache.has(sessionId)) {
145
+ return state.sessionCache.get(sessionId)
146
+ }
147
+ const data = normalizeSessionData(await readJson(sessionDataPath(sessionId), defaultSessionData()))
148
+ state.sessionCache.set(sessionId, data)
149
+ return data
150
+ }
151
+
152
+ async function migrateLegacyStoreIfNeededUnsafe() {
153
+ const indexFile = sessionIndexPath()
154
+ if (await exists(indexFile)) {
155
+ state.index = await readJson(indexFile, defaultIndex())
156
+ return
157
+ }
158
+
159
+ const legacy = await readJson(legacySessionStorePath(), null)
160
+ if (!legacy || typeof legacy !== "object" || !legacy.sessions || typeof legacy.sessions !== "object") {
161
+ state.index = defaultIndex()
162
+ await writeJson(indexFile, state.index)
163
+ return
164
+ }
165
+
166
+ const next = defaultIndex()
167
+ for (const [sessionId, session] of Object.entries(legacy.sessions || {})) {
168
+ next.sessions[sessionId] = {
169
+ ...session
170
+ }
171
+ const data = normalizeSessionData({
172
+ messages: legacy.messages?.[sessionId] || [],
173
+ parts: legacy.parts?.[sessionId] || []
174
+ })
175
+ state.sessionCache.set(sessionId, data)
176
+ await writeJson(sessionDataPath(sessionId), data)
177
+ }
178
+ state.index = next
179
+ await writeJson(indexFile, next)
180
+ }
181
+
182
+ async function ensureLoadedUnsafe() {
183
+ if (state.loaded) return
184
+ await ensureUserRoot()
185
+ await ensureSessionShardRoot()
186
+ await migrateLegacyStoreIfNeededUnsafe()
187
+ state.loaded = true
188
+ }
189
+
190
+ async function ensureLoaded() {
191
+ return withLock(async () => {
192
+ await ensureLoadedUnsafe()
193
+ })
194
+ }
195
+
196
+ export function configureSessionStore(options = {}) {
197
+ if (typeof options.sessionShardEnabled === "boolean") {
198
+ state.options.sessionShardEnabled = options.sessionShardEnabled
199
+ }
200
+ if (Number.isInteger(options.flushIntervalMs) && options.flushIntervalMs >= 0) {
201
+ state.options.flushIntervalMs = options.flushIntervalMs
202
+ }
203
+ }
204
+
205
+ export async function touchSession({
206
+ sessionId,
207
+ mode,
208
+ model,
209
+ providerType,
210
+ cwd,
211
+ title = null,
212
+ status = "active",
213
+ parentSessionId = null,
214
+ forkFrom = null
215
+ }) {
216
+ return withLock(async () => {
217
+ await ensureLoadedUnsafe()
218
+ const existing = state.index.sessions[sessionId]
219
+ const createdAt = existing?.createdAt || now()
220
+ state.index.sessions[sessionId] = {
221
+ id: sessionId,
222
+ mode,
223
+ model,
224
+ providerType,
225
+ cwd,
226
+ title: existing?.title || title || `${mode}:${model}`,
227
+ status,
228
+ parentSessionId: parentSessionId || existing?.parentSessionId || null,
229
+ forkFrom: forkFrom || existing?.forkFrom || null,
230
+ retryMeta: existing?.retryMeta || null,
231
+ patchRefs: existing?.patchRefs || [],
232
+ reviewDecisions: existing?.reviewDecisions || [],
233
+ budgetState: existing?.budgetState || null,
234
+ createdAt,
235
+ updatedAt: now()
236
+ }
237
+ await loadSessionDataUnsafe(sessionId)
238
+ markDirty(sessionId)
239
+ if (state.options.flushIntervalMs <= 0) await flushUnsafe()
240
+ return state.index.sessions[sessionId]
241
+ })
242
+ }
243
+
244
+ export async function updateSession(sessionId, patch) {
245
+ return withLock(async () => {
246
+ await ensureLoadedUnsafe()
247
+ const current = state.index.sessions[sessionId]
248
+ if (!current) return null
249
+ state.index.sessions[sessionId] = {
250
+ ...current,
251
+ ...patch,
252
+ updatedAt: now()
253
+ }
254
+ markDirty(sessionId)
255
+ if (state.options.flushIntervalMs <= 0) await flushUnsafe()
256
+ return state.index.sessions[sessionId]
257
+ })
258
+ }
259
+
260
+ export async function appendMessage(sessionId, role, content, extra = {}) {
261
+ return withLock(async () => {
262
+ await ensureLoadedUnsafe()
263
+ const data = await loadSessionDataUnsafe(sessionId)
264
+ const message = newMessage(role, content, extra)
265
+ data.messages.push(message)
266
+ if (state.index.sessions[sessionId]) state.index.sessions[sessionId].updatedAt = now()
267
+ markDirty(sessionId)
268
+ if (state.options.flushIntervalMs <= 0) await flushUnsafe()
269
+ return message
270
+ })
271
+ }
272
+
273
+ export async function replaceMessages(sessionId, newMessages) {
274
+ return withLock(async () => {
275
+ await ensureLoadedUnsafe()
276
+ const data = await loadSessionDataUnsafe(sessionId)
277
+ data.messages = newMessages.map((m) => ({
278
+ ...m,
279
+ id: m.id || `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
280
+ timestamp: m.timestamp || now()
281
+ }))
282
+ if (state.index.sessions[sessionId]) state.index.sessions[sessionId].updatedAt = now()
283
+ markDirty(sessionId)
284
+ if (state.options.flushIntervalMs <= 0) await flushUnsafe()
285
+ })
286
+ }
287
+
288
+ export async function appendPart(sessionId, part) {
289
+ return withLock(async () => {
290
+ await ensureLoadedUnsafe()
291
+ const data = await loadSessionDataUnsafe(sessionId)
292
+ const normalized = newPart(part.type || "event", part)
293
+ data.parts.push(normalized)
294
+ if (state.index.sessions[sessionId]) state.index.sessions[sessionId].updatedAt = now()
295
+ markDirty(sessionId)
296
+ if (state.options.flushIntervalMs <= 0) await flushUnsafe()
297
+ return normalized
298
+ })
299
+ }
300
+
301
+ export async function getSession(sessionId) {
302
+ return withLock(async () => {
303
+ await ensureLoadedUnsafe()
304
+ await flushUnsafe()
305
+ const session = state.index.sessions[sessionId]
306
+ if (!session) return null
307
+ const data = await loadSessionDataUnsafe(sessionId)
308
+ return {
309
+ session,
310
+ messages: [...data.messages],
311
+ parts: [...data.parts]
312
+ }
313
+ })
314
+ }
315
+
316
+ export async function listSessions({ cwd = null, limit = 100, includeChildren = true } = {}) {
317
+ return withLock(async () => {
318
+ await ensureLoadedUnsafe()
319
+ let sessions = Object.values(state.index.sessions)
320
+ if (cwd) sessions = sessions.filter((s) => s.cwd === cwd)
321
+ if (!includeChildren) sessions = sessions.filter((s) => !s.parentSessionId)
322
+ return sessions.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit)
323
+ })
324
+ }
325
+
326
+ export async function getConversationHistory(sessionId, limit = 30) {
327
+ return withLock(async () => {
328
+ await ensureLoadedUnsafe()
329
+ const data = await loadSessionDataUnsafe(sessionId)
330
+ const msgs = data.messages
331
+ // Always preserve compaction summary (first message) it must never be sliced off
332
+ // by the limit window, otherwise the model loses all prior context
333
+ const firstIsCompaction = msgs.length > 0 && (() => {
334
+ const c = msgs[0].content
335
+ if (typeof c === "string") return c.includes("<compaction-summary>")
336
+ if (Array.isArray(c)) return c.some(block => typeof block === "string" ? block.includes("<compaction-summary>") : (block.type === "text" && typeof block.text === "string" && block.text.includes("<compaction-summary>")))
337
+ return false
338
+ })()
339
+ const sliced = firstIsCompaction
340
+ ? [msgs[0], ...msgs.slice(1).slice(-limit)]
341
+ : msgs.slice(-limit)
342
+ return sliced.map((msg) => ({
343
+ role: msg.role,
344
+ content: msg.content
345
+ }))
346
+ })
347
+ }
348
+
349
+ export async function markSessionStatus(sessionId, status) {
350
+ return updateSession(sessionId, { status })
351
+ }
352
+
353
+ export async function exportSession(sessionId) {
354
+ return getSession(sessionId)
355
+ }
356
+
357
+ export async function forkSession({ sessionId, newSessionId, title = null }) {
358
+ return withLock(async () => {
359
+ await ensureLoadedUnsafe()
360
+ const source = state.index.sessions[sessionId]
361
+ if (!source) return null
362
+
363
+ const sourceData = await loadSessionDataUnsafe(sessionId)
364
+ const child = {
365
+ ...source,
366
+ id: newSessionId,
367
+ parentSessionId: source.id,
368
+ forkFrom: source.id,
369
+ title: title || `${source.title} (fork)`,
370
+ createdAt: now(),
371
+ updatedAt: now()
372
+ }
373
+ state.index.sessions[newSessionId] = child
374
+ state.sessionCache.set(newSessionId, {
375
+ messages: sourceData.messages.map((m) => ({ ...m })),
376
+ parts: sourceData.parts.map((p) => ({ ...p }))
377
+ })
378
+ markDirty(newSessionId)
379
+ if (state.options.flushIntervalMs <= 0) await flushUnsafe()
380
+ return child
381
+ })
382
+ }
383
+
384
+ export async function applyReviewDecision(sessionId, decision) {
385
+ return withLock(async () => {
386
+ await ensureLoadedUnsafe()
387
+ const session = state.index.sessions[sessionId]
388
+ if (!session) return null
389
+ session.reviewDecisions = session.reviewDecisions || []
390
+ session.reviewDecisions.push({
391
+ id: `rev_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
392
+ createdAt: now(),
393
+ ...decision
394
+ })
395
+ session.updatedAt = now()
396
+ markDirty(sessionId)
397
+ if (state.options.flushIntervalMs <= 0) await flushUnsafe()
398
+ return session
399
+ })
400
+ }
401
+
402
+ export async function setBudgetState(sessionId, budgetState) {
403
+ return updateSession(sessionId, { budgetState })
404
+ }
405
+
406
+ export async function appendUserMessage(sessionId, content, extra = {}) {
407
+ return appendMessage(sessionId, "user", content, extra)
408
+ }
409
+
410
+ export async function appendAssistantMessage(sessionId, content, extra = {}) {
411
+ return appendMessage(sessionId, "assistant", content, extra)
412
+ }
413
+
414
+ export async function fsckSessionStore() {
415
+ return withLock(async () => {
416
+ await ensureLoadedUnsafe()
417
+ await flushUnsafe()
418
+
419
+ const report = {
420
+ ok: true,
421
+ checkedAt: now(),
422
+ sessionsInIndex: Object.keys(state.index.sessions).length,
423
+ filesOnDisk: 0,
424
+ missingDataFiles: [],
425
+ orphanDataFiles: [],
426
+ invalidDataFiles: [],
427
+ suggestions: []
428
+ }
429
+
430
+ const entries = await readdir(sessionShardRootPath(), { withFileTypes: true }).catch(() => [])
431
+ const diskSessionIds = entries
432
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name !== "index.json")
433
+ .map((entry) => path.basename(entry.name, ".json"))
434
+ report.filesOnDisk = diskSessionIds.length
435
+
436
+ const indexIds = new Set(Object.keys(state.index.sessions))
437
+ for (const sessionId of indexIds) {
438
+ const file = sessionDataPath(sessionId)
439
+ if (!(await exists(file))) {
440
+ report.missingDataFiles.push(sessionId)
441
+ continue
442
+ }
443
+ const parsed = await readJson(file, null)
444
+ if (!parsed || !Array.isArray(parsed.messages) || !Array.isArray(parsed.parts)) {
445
+ report.invalidDataFiles.push(sessionId)
446
+ }
447
+ }
448
+
449
+ for (const sessionId of diskSessionIds) {
450
+ if (!indexIds.has(sessionId)) {
451
+ report.orphanDataFiles.push(sessionId)
452
+ }
453
+ }
454
+
455
+ if (report.missingDataFiles.length || report.orphanDataFiles.length || report.invalidDataFiles.length) {
456
+ report.ok = false
457
+ if (report.missingDataFiles.length) report.suggestions.push("Run `kkcode session gc` to remove broken index entries.")
458
+ if (report.orphanDataFiles.length) report.suggestions.push("Run `kkcode session gc --orphans-only` to clean orphan session files.")
459
+ if (report.invalidDataFiles.length) report.suggestions.push("Backup invalid files then remove or restore them from snapshot.")
460
+ } else {
461
+ report.suggestions.push("No consistency issue detected.")
462
+ }
463
+
464
+ return report
465
+ })
466
+ }
467
+
468
+ export async function gcSessionStore({ orphansOnly = false, maxAgeDays = 30 } = {}) {
469
+ return withLock(async () => {
470
+ await ensureLoadedUnsafe()
471
+ await flushUnsafe()
472
+
473
+ const removed = {
474
+ orphanFiles: [],
475
+ staleSessions: [],
476
+ checkpointDirs: []
477
+ }
478
+
479
+ const entries = await readdir(sessionShardRootPath(), { withFileTypes: true }).catch(() => [])
480
+ const diskSessionIds = entries
481
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name !== "index.json")
482
+ .map((entry) => path.basename(entry.name, ".json"))
483
+ const indexIds = new Set(Object.keys(state.index.sessions))
484
+
485
+ for (const sessionId of diskSessionIds) {
486
+ if (!indexIds.has(sessionId)) {
487
+ await unlink(sessionDataPath(sessionId)).catch(() => {})
488
+ state.sessionCache.delete(sessionId)
489
+ removed.orphanFiles.push(sessionId)
490
+ }
491
+ }
492
+
493
+ if (!orphansOnly) {
494
+ const cutoff = now() - Math.max(1, Number(maxAgeDays || 30)) * 24 * 60 * 60 * 1000
495
+ const removableStatuses = new Set(["completed", "error", "stopped", "max-iterations", "no-progress", "heartbeat-timeout", "cancelled"])
496
+ for (const [sessionId, session] of Object.entries(state.index.sessions)) {
497
+ if (session.updatedAt > cutoff) continue
498
+ if (!removableStatuses.has(session.status)) continue
499
+ delete state.index.sessions[sessionId]
500
+ state.sessionCache.delete(sessionId)
501
+ await unlink(sessionDataPath(sessionId)).catch(() => {})
502
+ removed.staleSessions.push(sessionId)
503
+ }
504
+ }
505
+
506
+ const checkpointEntries = await readdir(sessionCheckpointRootPath(), { withFileTypes: true }).catch(() => [])
507
+ const liveSessionIds = new Set(Object.keys(state.index.sessions))
508
+ for (const entry of checkpointEntries) {
509
+ if (!entry.isDirectory()) continue
510
+ const sessionId = entry.name
511
+ if (liveSessionIds.has(sessionId)) continue
512
+ await rm(path.join(sessionCheckpointRootPath(), sessionId), { recursive: true, force: true }).catch(() => {})
513
+ removed.checkpointDirs.push(sessionId)
514
+ }
515
+
516
+ state.dirtyIndex = true
517
+ await flushUnsafe()
518
+ return {
519
+ removed,
520
+ totalRemoved: removed.orphanFiles.length + removed.staleSessions.length + removed.checkpointDirs.length
521
+ }
522
+ })
523
+ }