@kkelly-offical/kkcode 0.1.2 → 0.1.6

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 (58) hide show
  1. package/README.md +120 -178
  2. package/package.json +46 -46
  3. package/src/agent/agent.mjs +41 -0
  4. package/src/agent/prompt/frontend-designer.txt +58 -0
  5. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
  6. package/src/agent/prompt/longagent-coding-agent.txt +37 -0
  7. package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
  8. package/src/agent/prompt/longagent-preview-agent.txt +63 -0
  9. package/src/config/defaults.mjs +260 -195
  10. package/src/config/schema.mjs +71 -6
  11. package/src/core/constants.mjs +91 -46
  12. package/src/index.mjs +1 -1
  13. package/src/knowledge/frontend-aesthetics.txt +39 -0
  14. package/src/knowledge/loader.mjs +2 -1
  15. package/src/knowledge/tailwind.txt +12 -3
  16. package/src/mcp/client-http.mjs +141 -157
  17. package/src/mcp/client-sse.mjs +288 -286
  18. package/src/mcp/client-stdio.mjs +533 -451
  19. package/src/mcp/constants.mjs +2 -0
  20. package/src/mcp/registry.mjs +479 -394
  21. package/src/mcp/stdio-framing.mjs +133 -127
  22. package/src/mcp/tool-result.mjs +24 -0
  23. package/src/observability/index.mjs +42 -0
  24. package/src/observability/metrics.mjs +137 -0
  25. package/src/observability/tracer.mjs +137 -0
  26. package/src/orchestration/background-manager.mjs +372 -358
  27. package/src/orchestration/background-worker.mjs +305 -245
  28. package/src/orchestration/longagent-manager.mjs +171 -116
  29. package/src/orchestration/stage-scheduler.mjs +728 -489
  30. package/src/permission/exec-policy.mjs +9 -11
  31. package/src/provider/anthropic.mjs +1 -0
  32. package/src/provider/openai.mjs +340 -339
  33. package/src/provider/retry-policy.mjs +68 -68
  34. package/src/provider/router.mjs +241 -228
  35. package/src/provider/sse.mjs +104 -91
  36. package/src/repl.mjs +1 -1
  37. package/src/session/checkpoint.mjs +66 -3
  38. package/src/session/engine.mjs +227 -225
  39. package/src/session/longagent-4stage.mjs +460 -0
  40. package/src/session/longagent-hybrid.mjs +1081 -0
  41. package/src/session/longagent-plan.mjs +365 -329
  42. package/src/session/longagent-project-memory.mjs +53 -0
  43. package/src/session/longagent-scaffold.mjs +291 -100
  44. package/src/session/longagent-task-bus.mjs +54 -0
  45. package/src/session/longagent-utils.mjs +472 -0
  46. package/src/session/longagent.mjs +884 -1462
  47. package/src/session/project-context.mjs +30 -0
  48. package/src/session/store.mjs +510 -503
  49. package/src/session/task-validator.mjs +4 -3
  50. package/src/skill/builtin/design.mjs +76 -0
  51. package/src/skill/builtin/frontend.mjs +8 -0
  52. package/src/skill/registry.mjs +390 -336
  53. package/src/storage/ghost-commit-store.mjs +18 -8
  54. package/src/tool/executor.mjs +11 -0
  55. package/src/tool/git-auto.mjs +0 -19
  56. package/src/tool/registry.mjs +71 -37
  57. package/src/ui/activity-renderer.mjs +664 -410
  58. package/src/util/git.mjs +23 -0
@@ -1,503 +1,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
- 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: 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
+ }