@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,127 +1,133 @@
1
- const CRLFCRLF = Buffer.from("\r\n\r\n", "utf8")
2
- const NEWLINE = 0x0a
3
-
4
- function toBuffer(chunk) {
5
- if (Buffer.isBuffer(chunk)) return chunk
6
- if (typeof chunk === "string") return Buffer.from(chunk, "utf8")
7
- if (chunk instanceof Uint8Array) return Buffer.from(chunk)
8
- return Buffer.from(String(chunk || ""), "utf8")
9
- }
10
-
11
- function parseContentLengthHeader(headerText) {
12
- const match = /(?:^|\r?\n)content-length:\s*(\d+)\s*(?:\r?\n|$)/i.exec(headerText)
13
- if (!match) return null
14
- const len = Number(match[1])
15
- if (!Number.isFinite(len) || len < 0) return null
16
- return len
17
- }
18
-
19
- function consumeContentLengthFrame(buffer, maxFrameBytes) {
20
- const headerEnd = buffer.indexOf(CRLFCRLF)
21
- if (headerEnd === -1) return { type: "need_more" }
22
- const headerText = buffer.subarray(0, headerEnd).toString("utf8")
23
- const length = parseContentLengthHeader(headerText)
24
- if (length === null) return { type: "invalid_header" }
25
- if (length > maxFrameBytes) return { type: "invalid_size", size: length }
26
- const total = headerEnd + CRLFCRLF.length + length
27
- if (buffer.length < total) return { type: "need_more" }
28
- const payload = buffer
29
- .subarray(headerEnd + CRLFCRLF.length, total)
30
- .toString("utf8")
31
- const rest = buffer.subarray(total)
32
- return { type: "ok", payload, rest }
33
- }
34
-
35
- function consumeNewlineFrame(buffer) {
36
- const newlineIdx = buffer.indexOf(NEWLINE)
37
- if (newlineIdx === -1) return { type: "need_more" }
38
- const rawLine = buffer.subarray(0, newlineIdx).toString("utf8")
39
- const rest = buffer.subarray(newlineIdx + 1)
40
- const payload = rawLine.trim()
41
- if (!payload) return { type: "empty", rest }
42
- return { type: "ok", payload, rest }
43
- }
44
-
45
- function dropLeadingCrlf(buffer) {
46
- let cursor = 0
47
- while (cursor < buffer.length) {
48
- const c = buffer[cursor]
49
- if (c !== 0x0d && c !== 0x0a) break
50
- cursor += 1
51
- }
52
- return cursor > 0 ? buffer.subarray(cursor) : buffer
53
- }
54
-
55
- function seemsContentLength(buffer) {
56
- if (!buffer.length) return false
57
- const probe = buffer.subarray(0, Math.min(buffer.length, 32)).toString("ascii").toLowerCase()
58
- return probe.startsWith("content-length:")
59
- }
60
-
61
- export function encodeRpcMessage(message, framing = "content-length") {
62
- const payload = JSON.stringify(message)
63
- if (framing === "newline") return `${payload}\n`
64
- const size = Buffer.byteLength(payload, "utf8")
65
- return `Content-Length: ${size}\r\n\r\n${payload}`
66
- }
67
-
68
- export function createStdioFramingDecoder({ framing = "auto", maxFrameBytes = 8 * 1024 * 1024 } = {}) {
69
- let buffer = Buffer.alloc(0)
70
-
71
- function push(chunk) {
72
- buffer = Buffer.concat([buffer, toBuffer(chunk)])
73
- const messages = []
74
-
75
- while (true) {
76
- if (!buffer.length) break
77
-
78
- if (framing === "content-length") {
79
- const parsed = consumeContentLengthFrame(buffer, maxFrameBytes)
80
- if (parsed.type === "need_more") break
81
- if (parsed.type === "invalid_header") throw new Error("invalid content-length header")
82
- if (parsed.type === "invalid_size") throw new Error(`content-length exceeds limit: ${parsed.size}`)
83
- messages.push(parsed.payload)
84
- buffer = parsed.rest
85
- continue
86
- }
87
-
88
- if (framing === "newline") {
89
- const parsed = consumeNewlineFrame(buffer)
90
- if (parsed.type === "need_more") break
91
- buffer = parsed.rest
92
- if (parsed.type === "ok") messages.push(parsed.payload)
93
- continue
94
- }
95
-
96
- // auto mode: prefer standard content-length frames, then fallback to newline JSON
97
- buffer = dropLeadingCrlf(buffer)
98
- if (!buffer.length) break
99
-
100
- if (seemsContentLength(buffer)) {
101
- const parsed = consumeContentLengthFrame(buffer, maxFrameBytes)
102
- if (parsed.type === "need_more") break
103
- if (parsed.type === "invalid_header") throw new Error("invalid content-length header")
104
- if (parsed.type === "invalid_size") throw new Error(`content-length exceeds limit: ${parsed.size}`)
105
- messages.push(parsed.payload)
106
- buffer = parsed.rest
107
- continue
108
- }
109
-
110
- const parsed = consumeNewlineFrame(buffer)
111
- if (parsed.type === "need_more") break
112
- buffer = parsed.rest
113
- if (parsed.type === "ok") messages.push(parsed.payload)
114
- }
115
-
116
- return messages
117
- }
118
-
119
- function reset() {
120
- buffer = Buffer.alloc(0)
121
- }
122
-
123
- return {
124
- push,
125
- reset
126
- }
127
- }
1
+ const CRLFCRLF = Buffer.from("\r\n\r\n", "utf8")
2
+ const NEWLINE = 0x0a
3
+
4
+ function toBuffer(chunk) {
5
+ if (Buffer.isBuffer(chunk)) return chunk
6
+ if (typeof chunk === "string") return Buffer.from(chunk, "utf8")
7
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk)
8
+ return Buffer.from(String(chunk || ""), "utf8")
9
+ }
10
+
11
+ function parseContentLengthHeader(headerText) {
12
+ const match = /(?:^|\r?\n)content-length:\s*(\d+)\s*(?:\r?\n|$)/i.exec(headerText)
13
+ if (!match) return null
14
+ const len = Number(match[1])
15
+ if (!Number.isFinite(len) || len < 0) return null
16
+ return len
17
+ }
18
+
19
+ function consumeContentLengthFrame(buffer, maxFrameBytes) {
20
+ const headerEnd = buffer.indexOf(CRLFCRLF)
21
+ if (headerEnd === -1) return { type: "need_more" }
22
+ const headerText = buffer.subarray(0, headerEnd).toString("utf8")
23
+ const length = parseContentLengthHeader(headerText)
24
+ if (length === null) return { type: "invalid_header" }
25
+ if (length > maxFrameBytes) return { type: "invalid_size", size: length }
26
+ const total = headerEnd + CRLFCRLF.length + length
27
+ if (buffer.length < total) return { type: "need_more" }
28
+ const payload = buffer
29
+ .subarray(headerEnd + CRLFCRLF.length, total)
30
+ .toString("utf8")
31
+ const rest = buffer.subarray(total)
32
+ return { type: "ok", payload, rest }
33
+ }
34
+
35
+ function consumeNewlineFrame(buffer) {
36
+ const newlineIdx = buffer.indexOf(NEWLINE)
37
+ if (newlineIdx === -1) return { type: "need_more" }
38
+ const rawLine = buffer.subarray(0, newlineIdx).toString("utf8")
39
+ const rest = buffer.subarray(newlineIdx + 1)
40
+ const payload = rawLine.trim()
41
+ if (!payload) return { type: "empty", rest }
42
+ return { type: "ok", payload, rest }
43
+ }
44
+
45
+ function dropLeadingCrlf(buffer) {
46
+ let cursor = 0
47
+ while (cursor < buffer.length) {
48
+ const c = buffer[cursor]
49
+ if (c !== 0x0d && c !== 0x0a) break
50
+ cursor += 1
51
+ }
52
+ return cursor > 0 ? buffer.subarray(cursor) : buffer
53
+ }
54
+
55
+ function seemsContentLength(buffer) {
56
+ if (!buffer.length) return false
57
+ const probe = buffer.subarray(0, Math.min(buffer.length, 32)).toString("ascii").toLowerCase()
58
+ return probe.startsWith("content-length:")
59
+ }
60
+
61
+ export function encodeRpcMessage(message, framing = "content-length") {
62
+ const payload = JSON.stringify(message)
63
+ if (framing === "newline") return `${payload}\n`
64
+ const size = Buffer.byteLength(payload, "utf8")
65
+ return `Content-Length: ${size}\r\n\r\n${payload}`
66
+ }
67
+
68
+ export function createStdioFramingDecoder({ framing = "auto", maxFrameBytes = 8 * 1024 * 1024, maxBufferBytes = 16 * 1024 * 1024 } = {}) {
69
+ let buffer = Buffer.alloc(0)
70
+
71
+ function push(chunk) {
72
+ const incoming = toBuffer(chunk)
73
+ if (buffer.length + incoming.length > maxBufferBytes) {
74
+ buffer = Buffer.alloc(0)
75
+ throw new Error(`stdio framing buffer exceeded limit: ${maxBufferBytes} bytes`)
76
+ }
77
+ buffer = Buffer.concat([buffer, incoming])
78
+ const messages = []
79
+
80
+ while (true) {
81
+ if (!buffer.length) break
82
+
83
+ if (framing === "content-length") {
84
+ const parsed = consumeContentLengthFrame(buffer, maxFrameBytes)
85
+ if (parsed.type === "need_more") break
86
+ if (parsed.type === "invalid_header") throw new Error("invalid content-length header")
87
+ if (parsed.type === "invalid_size") throw new Error(`content-length exceeds limit: ${parsed.size}`)
88
+ messages.push(parsed.payload)
89
+ buffer = parsed.rest
90
+ continue
91
+ }
92
+
93
+ if (framing === "newline") {
94
+ const parsed = consumeNewlineFrame(buffer)
95
+ if (parsed.type === "need_more") break
96
+ buffer = parsed.rest
97
+ if (parsed.type === "ok") messages.push(parsed.payload)
98
+ continue
99
+ }
100
+
101
+ // auto mode: prefer standard content-length frames, then fallback to newline JSON
102
+ buffer = dropLeadingCrlf(buffer)
103
+ if (!buffer.length) break
104
+
105
+ if (seemsContentLength(buffer)) {
106
+ const parsed = consumeContentLengthFrame(buffer, maxFrameBytes)
107
+ if (parsed.type === "need_more") break
108
+ if (parsed.type === "invalid_header") throw new Error("invalid content-length header")
109
+ if (parsed.type === "invalid_size") throw new Error(`content-length exceeds limit: ${parsed.size}`)
110
+ messages.push(parsed.payload)
111
+ buffer = parsed.rest
112
+ continue
113
+ }
114
+
115
+ const parsed = consumeNewlineFrame(buffer)
116
+ if (parsed.type === "need_more") break
117
+ buffer = parsed.rest
118
+ if (parsed.type === "ok") messages.push(parsed.payload)
119
+ }
120
+
121
+ return messages
122
+ }
123
+
124
+ function reset() {
125
+ buffer = Buffer.alloc(0)
126
+ }
127
+
128
+ return {
129
+ push,
130
+ reset,
131
+ bufferSize() { return buffer.length }
132
+ }
133
+ }
@@ -0,0 +1,24 @@
1
+ import { McpError } from "../core/errors.mjs"
2
+
3
+ export function normalizeToolResult(result, serverName, toolName) {
4
+ if (result?.isError) {
5
+ const text = Array.isArray(result.content)
6
+ ? result.content.map((item) => item?.text || "").join("\n").trim()
7
+ : ""
8
+ throw new McpError(text || "mcp tool returned isError", {
9
+ reason: "bad_response",
10
+ server: serverName,
11
+ action: `tools/call:${toolName}`,
12
+ phase: "request"
13
+ })
14
+ }
15
+ const content = Array.isArray(result?.content) ? result.content : null
16
+ const contentText = content
17
+ ? content.map((item) => (typeof item?.text === "string" ? item.text : "")).join("\n").trim()
18
+ : ""
19
+ const output =
20
+ contentText ||
21
+ (typeof result?.output === "string" ? result.output : "") ||
22
+ (typeof result === "string" ? result : JSON.stringify(result))
23
+ return content ? { output, raw: result, content } : { output, raw: result }
24
+ }
@@ -0,0 +1,42 @@
1
+ import { createMetricsCollector } from "./metrics.mjs"
2
+ import { createTracer } from "./tracer.mjs"
3
+
4
+ let metrics = null
5
+ let tracer = null
6
+ let unsubscribes = []
7
+
8
+ export function initialize(eventBus) {
9
+ if (metrics) return // idempotent
10
+
11
+ metrics = createMetricsCollector()
12
+ tracer = createTracer()
13
+
14
+ unsubscribes.push(
15
+ eventBus.registerSink(async (event) => {
16
+ metrics.handleEvent(event)
17
+ tracer.handleEvent(event)
18
+ })
19
+ )
20
+ }
21
+
22
+ export function shutdown() {
23
+ for (const unsub of unsubscribes) unsub()
24
+ unsubscribes = []
25
+ metrics = null
26
+ tracer = null
27
+ }
28
+
29
+ export function getMetrics() {
30
+ return metrics ? metrics.getSnapshot() : null
31
+ }
32
+
33
+ export function getTraces() {
34
+ return tracer ? tracer.getTraces() : []
35
+ }
36
+
37
+ export function exportReport() {
38
+ return {
39
+ metrics: metrics ? metrics.getSnapshot() : null,
40
+ traces: tracer ? tracer.exportTraces("json") : "[]"
41
+ }
42
+ }
@@ -0,0 +1,137 @@
1
+ import { EVENT_TYPES } from "../core/constants.mjs"
2
+
3
+ function createHistogram() {
4
+ const values = []
5
+ return {
6
+ record(v) { values.push(v) },
7
+ snapshot() {
8
+ if (values.length === 0) return { count: 0, sum: 0, min: 0, max: 0, avg: 0, p50: 0, p99: 0 }
9
+ const sorted = [...values].sort((a, b) => a - b)
10
+ const sum = sorted.reduce((s, v) => s + v, 0)
11
+ const count = sorted.length
12
+ return {
13
+ count,
14
+ sum,
15
+ min: sorted[0],
16
+ max: sorted[count - 1],
17
+ avg: sum / count,
18
+ p50: sorted[Math.max(0, Math.ceil(count * 0.5) - 1)] || 0,
19
+ p99: sorted[Math.max(0, Math.ceil(count * 0.99) - 1)] || 0
20
+ }
21
+ },
22
+ reset() { values.length = 0 }
23
+ }
24
+ }
25
+
26
+ export function createMetricsCollector() {
27
+ const counters = new Map()
28
+ const histograms = new Map()
29
+ const turnStarts = new Map()
30
+ const stageStarts = new Map()
31
+
32
+ function inc(name, amount = 1) {
33
+ counters.set(name, (counters.get(name) || 0) + amount)
34
+ }
35
+
36
+ function hist(name) {
37
+ if (!histograms.has(name)) histograms.set(name, createHistogram())
38
+ return histograms.get(name)
39
+ }
40
+
41
+ const MAX_OPEN_ENTRIES = 500
42
+
43
+ function pruneStaleMap(map) {
44
+ if (map.size <= MAX_OPEN_ENTRIES) return
45
+ const cutoff = Date.now() - 30 * 60 * 1000 // 30 min
46
+ for (const [k, v] of map) {
47
+ if (v < cutoff) map.delete(k)
48
+ }
49
+ // If still over limit, drop oldest half
50
+ if (map.size > MAX_OPEN_ENTRIES) {
51
+ let toDrop = Math.floor(map.size / 2)
52
+ for (const k of map.keys()) {
53
+ if (toDrop-- <= 0) break
54
+ map.delete(k)
55
+ }
56
+ }
57
+ }
58
+
59
+ function handleEvent(event) {
60
+ const { type, payload, turnId, sessionId } = event
61
+
62
+ if (type === EVENT_TYPES.TURN_START) {
63
+ inc("turn_count")
64
+ if (turnId) {
65
+ turnStarts.set(turnId, event.timestamp)
66
+ pruneStaleMap(turnStarts)
67
+ }
68
+ }
69
+
70
+ if (type === EVENT_TYPES.TURN_FINISH) {
71
+ if (turnId && turnStarts.has(turnId)) {
72
+ hist("turn_duration_ms").record(event.timestamp - turnStarts.get(turnId))
73
+ turnStarts.delete(turnId)
74
+ }
75
+ }
76
+
77
+ if (type === EVENT_TYPES.TURN_ERROR) {
78
+ inc("error_count")
79
+ }
80
+
81
+ if (type === EVENT_TYPES.TOOL_START) {
82
+ inc("tool_call_count")
83
+ }
84
+
85
+ if (type === EVENT_TYPES.TOOL_ERROR) {
86
+ inc("tool_error_count")
87
+ }
88
+
89
+ if (type === EVENT_TYPES.TURN_USAGE_UPDATE) {
90
+ if (payload?.input) inc("token_input", payload.input)
91
+ if (payload?.output) inc("token_output", payload.output)
92
+ if (payload?.cacheRead) inc("token_cache_read", payload.cacheRead)
93
+ }
94
+
95
+ if (type === EVENT_TYPES.LONGAGENT_STAGE_STARTED) {
96
+ const key = payload?.stageId || sessionId
97
+ if (key) {
98
+ stageStarts.set(key, event.timestamp)
99
+ pruneStaleMap(stageStarts)
100
+ }
101
+ }
102
+
103
+ if (type === EVENT_TYPES.LONGAGENT_STAGE_FINISHED) {
104
+ const key = payload?.stageId || sessionId
105
+ if (key && stageStarts.has(key)) {
106
+ hist("longagent_stage_duration_ms").record(event.timestamp - stageStarts.get(key))
107
+ stageStarts.delete(key)
108
+ }
109
+ if (payload?.retryCount > 0) {
110
+ inc("longagent_task_retries", payload.retryCount)
111
+ }
112
+ }
113
+
114
+ if (type === EVENT_TYPES.LONGAGENT_GATE_CHECKED) {
115
+ inc("gate_check_count")
116
+ if (payload?.status === "pass") inc("gate_pass_count")
117
+ }
118
+ }
119
+
120
+ function getSnapshot() {
121
+ const counterSnapshot = new Map(counters)
122
+ const histogramSnapshot = new Map()
123
+ for (const [name, h] of histograms) {
124
+ histogramSnapshot.set(name, h.snapshot())
125
+ }
126
+ return { counters: counterSnapshot, histograms: histogramSnapshot }
127
+ }
128
+
129
+ function reset() {
130
+ counters.clear()
131
+ histograms.clear()
132
+ turnStarts.clear()
133
+ stageStarts.clear()
134
+ }
135
+
136
+ return { handleEvent, getSnapshot, reset }
137
+ }
@@ -0,0 +1,137 @@
1
+ import { EVENT_TYPES } from "../core/constants.mjs"
2
+ import { randomUUID } from "node:crypto"
3
+
4
+ function newSpanId() {
5
+ return `span_${randomUUID().slice(0, 12)}`
6
+ }
7
+
8
+ export function createTracer(options = {}) {
9
+ const maxTraces = options.maxTraces || 100
10
+ const maxOpenSpans = options.maxOpenSpans || 500
11
+ const traces = []
12
+ const openSpans = new Map()
13
+ const phaseSpan = { current: null }
14
+ let currentTraceId = null
15
+
16
+ function pruneOpenSpans() {
17
+ if (openSpans.size <= maxOpenSpans) return
18
+ // Close oldest spans as "expired"
19
+ let toDrop = Math.floor(openSpans.size / 2)
20
+ for (const [key, span] of openSpans) {
21
+ if (toDrop-- <= 0) break
22
+ closeSpan(span, "expired")
23
+ openSpans.delete(key)
24
+ }
25
+ }
26
+
27
+ function startSpan(name, attributes = {}, parentSpanId = null, timestamp = null) {
28
+ if (!currentTraceId) currentTraceId = `trace_${randomUUID().slice(0, 12)}`
29
+ const span = {
30
+ traceId: currentTraceId,
31
+ spanId: newSpanId(),
32
+ parentSpanId,
33
+ name,
34
+ startTime: timestamp || Date.now(),
35
+ endTime: null,
36
+ duration: null,
37
+ attributes,
38
+ status: "ok"
39
+ }
40
+ return span
41
+ }
42
+
43
+ function closeSpan(span, status = "ok", timestamp = null) {
44
+ span.endTime = timestamp || Date.now()
45
+ span.duration = span.endTime - span.startTime
46
+ span.status = status
47
+ traces.push(span)
48
+ if (traces.length > maxTraces) traces.shift()
49
+ }
50
+
51
+ function handleEvent(event) {
52
+ const { type, payload, turnId, sessionId, timestamp } = event
53
+
54
+ if (type === EVENT_TYPES.TURN_START) {
55
+ const key = `turn:${turnId}`
56
+ if (turnId && openSpans.has(key)) {
57
+ closeSpan(openSpans.get(key), "error", timestamp)
58
+ openSpans.delete(key)
59
+ }
60
+ const span = startSpan("turn", { turnId, sessionId }, null, timestamp)
61
+ if (turnId) openSpans.set(key, span)
62
+ pruneOpenSpans()
63
+ }
64
+
65
+ if (type === EVENT_TYPES.TURN_FINISH) {
66
+ const key = `turn:${turnId}`
67
+ const span = openSpans.get(key)
68
+ if (span) {
69
+ closeSpan(span, "ok", timestamp)
70
+ openSpans.delete(key)
71
+ }
72
+ }
73
+
74
+ if (type === EVENT_TYPES.TURN_ERROR) {
75
+ const key = `turn:${turnId}`
76
+ const span = openSpans.get(key)
77
+ if (span) {
78
+ span.attributes.error = payload?.error || "unknown"
79
+ closeSpan(span, "error", timestamp)
80
+ openSpans.delete(key)
81
+ }
82
+ }
83
+
84
+ if (type === EVENT_TYPES.LONGAGENT_STAGE_STARTED) {
85
+ const stageId = payload?.stageId
86
+ if (stageId) {
87
+ const span = startSpan("stage", { stageId, sessionId }, null, timestamp)
88
+ openSpans.set(`stage:${stageId}`, span)
89
+ pruneOpenSpans()
90
+ }
91
+ }
92
+
93
+ if (type === EVENT_TYPES.LONGAGENT_STAGE_FINISHED) {
94
+ const stageId = payload?.stageId
95
+ const key = `stage:${stageId}`
96
+ const span = openSpans.get(key)
97
+ if (span) {
98
+ span.attributes.successCount = payload?.successCount
99
+ span.attributes.failCount = payload?.failCount
100
+ closeSpan(span, payload?.allSuccess ? "ok" : "error", timestamp)
101
+ openSpans.delete(key)
102
+ }
103
+ }
104
+
105
+ if (type === EVENT_TYPES.LONGAGENT_PHASE_CHANGED) {
106
+ if (phaseSpan.current) {
107
+ closeSpan(phaseSpan.current, "ok", timestamp)
108
+ }
109
+ const span = startSpan("phase", {
110
+ phase: payload?.phase || payload?.newPhase,
111
+ sessionId
112
+ }, null, timestamp)
113
+ phaseSpan.current = span
114
+ }
115
+ }
116
+
117
+ function getTraces() {
118
+ const result = [...traces]
119
+ if (phaseSpan.current) result.push({ ...phaseSpan.current, status: "open" })
120
+ return result
121
+ }
122
+
123
+ function exportTraces(format = "json") {
124
+ const all = getTraces()
125
+ if (format === "json") return JSON.stringify(all, null, 2)
126
+ return JSON.stringify(all)
127
+ }
128
+
129
+ function reset() {
130
+ traces.length = 0
131
+ openSpans.clear()
132
+ phaseSpan.current = null
133
+ currentTraceId = null
134
+ }
135
+
136
+ return { handleEvent, getTraces, exportTraces, reset }
137
+ }