@kkelly-offical/kkcode 0.1.3 → 0.1.7

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