@kkelly-offical/kkcode 0.1.6 → 0.2.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 (163) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +452 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +19 -2
  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 +90 -0
  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/config/defaults.mjs +280 -260
  29. package/src/config/import-config.mjs +1 -1
  30. package/src/config/load-config.mjs +61 -4
  31. package/src/config/schema.mjs +591 -574
  32. package/src/context.mjs +4 -1
  33. package/src/core/constants.mjs +97 -91
  34. package/src/core/types.mjs +1 -1
  35. package/src/github/api.mjs +78 -78
  36. package/src/github/auth.mjs +294 -286
  37. package/src/github/flow.mjs +298 -298
  38. package/src/github/workspace.mjs +225 -212
  39. package/src/index.mjs +84 -82
  40. package/src/knowledge/frontend-aesthetics.txt +38 -38
  41. package/src/mcp/client-http.mjs +139 -141
  42. package/src/mcp/client-sse.mjs +297 -288
  43. package/src/mcp/client-stdio.mjs +534 -533
  44. package/src/mcp/constants.mjs +2 -2
  45. package/src/mcp/registry.mjs +498 -479
  46. package/src/mcp/stdio-framing.mjs +135 -133
  47. package/src/mcp/tool-result.mjs +24 -24
  48. package/src/observability/edit-diagnostics.mjs +449 -0
  49. package/src/observability/index.mjs +42 -42
  50. package/src/observability/metrics.mjs +165 -137
  51. package/src/observability/tracer.mjs +137 -137
  52. package/src/onboarding.mjs +209 -0
  53. package/src/orchestration/background-manager.mjs +567 -372
  54. package/src/orchestration/background-worker.mjs +419 -305
  55. package/src/orchestration/interruption-reason.mjs +21 -0
  56. package/src/orchestration/longagent-manager.mjs +197 -171
  57. package/src/orchestration/stage-scheduler.mjs +733 -728
  58. package/src/orchestration/subagent-router.mjs +7 -1
  59. package/src/orchestration/task-scheduler.mjs +219 -7
  60. package/src/permission/engine.mjs +1 -1
  61. package/src/permission/exec-policy.mjs +370 -370
  62. package/src/permission/file-edit-policy.mjs +108 -0
  63. package/src/permission/prompt.mjs +1 -1
  64. package/src/permission/rules.mjs +116 -7
  65. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  66. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  67. package/src/plugin/hook-bus.mjs +19 -5
  68. package/src/plugin/manifest-loader.mjs +222 -0
  69. package/src/provider/anthropic.mjs +396 -390
  70. package/src/provider/ollama.mjs +7 -1
  71. package/src/provider/openai.mjs +382 -340
  72. package/src/provider/retry-policy.mjs +74 -68
  73. package/src/provider/router.mjs +242 -241
  74. package/src/provider/sse.mjs +104 -104
  75. package/src/provider/wizard.mjs +556 -0
  76. package/src/repl/capability-facade.mjs +30 -0
  77. package/src/repl/command-surface.mjs +23 -0
  78. package/src/repl/controller-entry.mjs +40 -0
  79. package/src/repl/core-shell.mjs +208 -0
  80. package/src/repl/dialog-router.mjs +87 -0
  81. package/src/repl/input-engine.mjs +76 -0
  82. package/src/repl/keymap.mjs +7 -0
  83. package/src/repl/operator-surface.mjs +15 -0
  84. package/src/repl/permission-flow.mjs +49 -0
  85. package/src/repl/runtime-facade.mjs +36 -0
  86. package/src/repl/slash-router.mjs +62 -0
  87. package/src/repl/state-store.mjs +29 -0
  88. package/src/repl/turn-controller.mjs +58 -0
  89. package/src/repl/verification.mjs +23 -0
  90. package/src/repl.mjs +3368 -2929
  91. package/src/rules/load-rules.mjs +3 -3
  92. package/src/runtime.mjs +1 -1
  93. package/src/session/agent-transaction.mjs +86 -0
  94. package/src/session/checkpoint.mjs +302 -302
  95. package/src/session/compaction.mjs +36 -14
  96. package/src/session/engine.mjs +417 -227
  97. package/src/session/longagent-4stage.mjs +467 -460
  98. package/src/session/longagent-hybrid.mjs +1344 -1081
  99. package/src/session/longagent-plan.mjs +376 -365
  100. package/src/session/longagent-project-memory.mjs +53 -53
  101. package/src/session/longagent-scaffold.mjs +291 -291
  102. package/src/session/longagent-task-bus.mjs +138 -54
  103. package/src/session/longagent-utils.mjs +828 -472
  104. package/src/session/longagent.mjs +911 -884
  105. package/src/session/loop.mjs +1005 -905
  106. package/src/session/prompt/agent.txt +25 -0
  107. package/src/session/prompt/anthropic.txt +150 -150
  108. package/src/session/prompt/beast.txt +1 -1
  109. package/src/session/prompt/plan.txt +28 -6
  110. package/src/session/prompt/qwen.txt +46 -46
  111. package/src/session/recovery.mjs +21 -0
  112. package/src/session/rollback.mjs +197 -0
  113. package/src/session/routing-observability.mjs +72 -0
  114. package/src/session/runtime-state.mjs +47 -0
  115. package/src/session/store.mjs +523 -510
  116. package/src/session/system-prompt.mjs +56 -8
  117. package/src/session/task-validator.mjs +267 -267
  118. package/src/session/usability-gates.mjs +2 -2
  119. package/src/skill/builtin/commit.mjs +64 -64
  120. package/src/skill/builtin/design.mjs +76 -76
  121. package/src/skill/generator.mjs +18 -2
  122. package/src/skill/registry.mjs +642 -390
  123. package/src/storage/audit-store.mjs +18 -11
  124. package/src/storage/event-log.mjs +7 -1
  125. package/src/storage/ghost-commit-store.mjs +243 -245
  126. package/src/storage/paths.mjs +13 -0
  127. package/src/theme/default-theme.mjs +1 -1
  128. package/src/theme/markdown.mjs +4 -0
  129. package/src/theme/schema.mjs +1 -1
  130. package/src/theme/status-bar.mjs +162 -158
  131. package/src/tool/audit-wrapper.mjs +18 -2
  132. package/src/tool/edit-transaction.mjs +23 -0
  133. package/src/tool/executor.mjs +26 -1
  134. package/src/tool/file-read-state.mjs +65 -0
  135. package/src/tool/git-auto.mjs +526 -526
  136. package/src/tool/git-full-auto.mjs +487 -478
  137. package/src/tool/mutation-guard.mjs +54 -0
  138. package/src/tool/prompt/edit.txt +3 -3
  139. package/src/tool/prompt/multiedit.txt +1 -0
  140. package/src/tool/prompt/notebookedit.txt +2 -1
  141. package/src/tool/prompt/patch.txt +25 -24
  142. package/src/tool/prompt/read.txt +3 -3
  143. package/src/tool/prompt/sysinfo.txt +29 -0
  144. package/src/tool/prompt/task.txt +66 -4
  145. package/src/tool/prompt/write.txt +2 -2
  146. package/src/tool/question-prompt.mjs +17 -4
  147. package/src/tool/registry.mjs +1701 -1343
  148. package/src/tool/task-tool.mjs +14 -6
  149. package/src/ui/activity-renderer.mjs +667 -664
  150. package/src/ui/repl-background-panel.mjs +7 -0
  151. package/src/ui/repl-capability-panel.mjs +9 -0
  152. package/src/ui/repl-dashboard.mjs +54 -4
  153. package/src/ui/repl-help.mjs +110 -0
  154. package/src/ui/repl-operator-panel.mjs +12 -0
  155. package/src/ui/repl-route-feedback.mjs +35 -0
  156. package/src/ui/repl-status-view.mjs +76 -0
  157. package/src/ui/repl-task-panel.mjs +5 -0
  158. package/src/ui/repl-transcript-panel.mjs +56 -0
  159. package/src/ui/repl-turn-summary.mjs +135 -0
  160. package/src/usage/pricing.mjs +122 -121
  161. package/src/usage/usage-meter.mjs +1 -0
  162. package/src/util/git.mjs +562 -519
  163. package/src/util/template.mjs +6 -1
@@ -1,510 +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: title || existing?.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
- return data.messages.slice(-limit).map((msg) => ({
330
- role: msg.role,
331
- content: msg.content // preserves array content blocks (images) as-is
332
- }))
333
- })
334
- }
335
-
336
- export async function markSessionStatus(sessionId, status) {
337
- return updateSession(sessionId, { status })
338
- }
339
-
340
- export async function exportSession(sessionId) {
341
- return getSession(sessionId)
342
- }
343
-
344
- export async function forkSession({ sessionId, newSessionId, title = null }) {
345
- return withLock(async () => {
346
- await ensureLoadedUnsafe()
347
- const source = state.index.sessions[sessionId]
348
- if (!source) return null
349
-
350
- const sourceData = await loadSessionDataUnsafe(sessionId)
351
- const child = {
352
- ...source,
353
- id: newSessionId,
354
- parentSessionId: source.id,
355
- forkFrom: source.id,
356
- title: title || `${source.title} (fork)`,
357
- createdAt: now(),
358
- updatedAt: now()
359
- }
360
- state.index.sessions[newSessionId] = child
361
- state.sessionCache.set(newSessionId, {
362
- messages: sourceData.messages.map((m) => ({ ...m })),
363
- parts: sourceData.parts.map((p) => ({ ...p }))
364
- })
365
- markDirty(newSessionId)
366
- if (state.options.flushIntervalMs <= 0) await flushUnsafe()
367
- return child
368
- })
369
- }
370
-
371
- export async function applyReviewDecision(sessionId, decision) {
372
- return withLock(async () => {
373
- await ensureLoadedUnsafe()
374
- const session = state.index.sessions[sessionId]
375
- if (!session) return null
376
- session.reviewDecisions = session.reviewDecisions || []
377
- session.reviewDecisions.push({
378
- id: `rev_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
379
- createdAt: now(),
380
- ...decision
381
- })
382
- session.updatedAt = now()
383
- markDirty(sessionId)
384
- if (state.options.flushIntervalMs <= 0) await flushUnsafe()
385
- return session
386
- })
387
- }
388
-
389
- export async function setBudgetState(sessionId, budgetState) {
390
- return updateSession(sessionId, { budgetState })
391
- }
392
-
393
- export async function appendUserMessage(sessionId, content, extra = {}) {
394
- return appendMessage(sessionId, "user", content, extra)
395
- }
396
-
397
- export async function appendAssistantMessage(sessionId, content, extra = {}) {
398
- return appendMessage(sessionId, "assistant", content, extra)
399
- }
400
-
401
- export async function fsckSessionStore() {
402
- return withLock(async () => {
403
- await ensureLoadedUnsafe()
404
- await flushUnsafe()
405
-
406
- const report = {
407
- ok: true,
408
- checkedAt: now(),
409
- sessionsInIndex: Object.keys(state.index.sessions).length,
410
- filesOnDisk: 0,
411
- missingDataFiles: [],
412
- orphanDataFiles: [],
413
- invalidDataFiles: [],
414
- suggestions: []
415
- }
416
-
417
- const entries = await readdir(sessionShardRootPath(), { withFileTypes: true }).catch(() => [])
418
- const diskSessionIds = entries
419
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name !== "index.json")
420
- .map((entry) => path.basename(entry.name, ".json"))
421
- report.filesOnDisk = diskSessionIds.length
422
-
423
- const indexIds = new Set(Object.keys(state.index.sessions))
424
- for (const sessionId of indexIds) {
425
- const file = sessionDataPath(sessionId)
426
- if (!(await exists(file))) {
427
- report.missingDataFiles.push(sessionId)
428
- continue
429
- }
430
- const parsed = await readJson(file, null)
431
- if (!parsed || !Array.isArray(parsed.messages) || !Array.isArray(parsed.parts)) {
432
- report.invalidDataFiles.push(sessionId)
433
- }
434
- }
435
-
436
- for (const sessionId of diskSessionIds) {
437
- if (!indexIds.has(sessionId)) {
438
- report.orphanDataFiles.push(sessionId)
439
- }
440
- }
441
-
442
- if (report.missingDataFiles.length || report.orphanDataFiles.length || report.invalidDataFiles.length) {
443
- report.ok = false
444
- if (report.missingDataFiles.length) report.suggestions.push("Run `kkcode session gc` to remove broken index entries.")
445
- if (report.orphanDataFiles.length) report.suggestions.push("Run `kkcode session gc --orphans-only` to clean orphan session files.")
446
- if (report.invalidDataFiles.length) report.suggestions.push("Backup invalid files then remove or restore them from snapshot.")
447
- } else {
448
- report.suggestions.push("No consistency issue detected.")
449
- }
450
-
451
- return report
452
- })
453
- }
454
-
455
- export async function gcSessionStore({ orphansOnly = false, maxAgeDays = 30 } = {}) {
456
- return withLock(async () => {
457
- await ensureLoadedUnsafe()
458
- await flushUnsafe()
459
-
460
- const removed = {
461
- orphanFiles: [],
462
- staleSessions: [],
463
- checkpointDirs: []
464
- }
465
-
466
- const entries = await readdir(sessionShardRootPath(), { withFileTypes: true }).catch(() => [])
467
- const diskSessionIds = entries
468
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name !== "index.json")
469
- .map((entry) => path.basename(entry.name, ".json"))
470
- const indexIds = new Set(Object.keys(state.index.sessions))
471
-
472
- for (const sessionId of diskSessionIds) {
473
- if (!indexIds.has(sessionId)) {
474
- await unlink(sessionDataPath(sessionId)).catch(() => {})
475
- state.sessionCache.delete(sessionId)
476
- removed.orphanFiles.push(sessionId)
477
- }
478
- }
479
-
480
- if (!orphansOnly) {
481
- const cutoff = now() - Math.max(1, Number(maxAgeDays || 30)) * 24 * 60 * 60 * 1000
482
- const removableStatuses = new Set(["completed", "error", "stopped", "max-iterations", "no-progress", "heartbeat-timeout", "cancelled"])
483
- for (const [sessionId, session] of Object.entries(state.index.sessions)) {
484
- if (session.updatedAt > cutoff) continue
485
- if (!removableStatuses.has(session.status)) continue
486
- delete state.index.sessions[sessionId]
487
- state.sessionCache.delete(sessionId)
488
- await unlink(sessionDataPath(sessionId)).catch(() => {})
489
- removed.staleSessions.push(sessionId)
490
- }
491
- }
492
-
493
- const checkpointEntries = await readdir(sessionCheckpointRootPath(), { withFileTypes: true }).catch(() => [])
494
- const liveSessionIds = new Set(Object.keys(state.index.sessions))
495
- for (const entry of checkpointEntries) {
496
- if (!entry.isDirectory()) continue
497
- const sessionId = entry.name
498
- if (liveSessionIds.has(sessionId)) continue
499
- await rm(path.join(sessionCheckpointRootPath(), sessionId), { recursive: true, force: true }).catch(() => {})
500
- removed.checkpointDirs.push(sessionId)
501
- }
502
-
503
- state.dirtyIndex = true
504
- await flushUnsafe()
505
- return {
506
- removed,
507
- totalRemoved: removed.orphanFiles.length + removed.staleSessions.length + removed.checkpointDirs.length
508
- }
509
- })
510
- }
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
+ }