@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.
- package/README.md +110 -172
- package/package.json +46 -46
- package/src/agent/agent.mjs +220 -170
- package/src/agent/prompt/bug-hunter.txt +90 -0
- package/src/agent/prompt/frontend-designer.txt +58 -0
- package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
- package/src/agent/prompt/longagent-coding-agent.txt +37 -0
- package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
- package/src/agent/prompt/longagent-preview-agent.txt +63 -0
- package/src/config/defaults.mjs +260 -195
- package/src/config/schema.mjs +71 -6
- package/src/core/constants.mjs +91 -46
- package/src/index.mjs +1 -1
- package/src/knowledge/frontend-aesthetics.txt +39 -0
- package/src/knowledge/loader.mjs +2 -1
- package/src/knowledge/tailwind.txt +12 -3
- package/src/mcp/client-http.mjs +141 -157
- package/src/mcp/client-sse.mjs +288 -286
- package/src/mcp/client-stdio.mjs +533 -451
- package/src/mcp/constants.mjs +2 -0
- package/src/mcp/registry.mjs +479 -394
- package/src/mcp/stdio-framing.mjs +133 -127
- package/src/mcp/tool-result.mjs +24 -0
- package/src/observability/index.mjs +42 -0
- package/src/observability/metrics.mjs +137 -0
- package/src/observability/tracer.mjs +137 -0
- package/src/orchestration/background-manager.mjs +372 -358
- package/src/orchestration/background-worker.mjs +305 -245
- package/src/orchestration/longagent-manager.mjs +171 -116
- package/src/orchestration/stage-scheduler.mjs +728 -489
- package/src/permission/exec-policy.mjs +9 -11
- package/src/provider/anthropic.mjs +1 -0
- package/src/provider/openai.mjs +340 -339
- package/src/provider/retry-policy.mjs +68 -68
- package/src/provider/router.mjs +241 -228
- package/src/provider/sse.mjs +104 -91
- package/src/repl.mjs +59 -7
- package/src/session/checkpoint.mjs +66 -3
- package/src/session/compaction.mjs +298 -276
- package/src/session/engine.mjs +232 -225
- package/src/session/longagent-4stage.mjs +460 -0
- package/src/session/longagent-hybrid.mjs +1097 -0
- package/src/session/longagent-plan.mjs +365 -329
- package/src/session/longagent-project-memory.mjs +53 -0
- package/src/session/longagent-scaffold.mjs +291 -100
- package/src/session/longagent-task-bus.mjs +54 -0
- package/src/session/longagent-utils.mjs +472 -0
- package/src/session/longagent.mjs +900 -1462
- package/src/session/loop.mjs +65 -40
- package/src/session/project-context.mjs +30 -0
- package/src/session/prompt/agent.txt +25 -0
- package/src/session/prompt/plan.txt +31 -9
- package/src/session/rollback.mjs +196 -0
- package/src/session/store.mjs +519 -503
- package/src/session/system-prompt.mjs +273 -260
- package/src/session/task-validator.mjs +4 -3
- package/src/skill/builtin/design.mjs +76 -0
- package/src/skill/builtin/frontend.mjs +8 -0
- package/src/skill/registry.mjs +390 -336
- package/src/storage/ghost-commit-store.mjs +18 -8
- package/src/tool/executor.mjs +11 -0
- package/src/tool/git-auto.mjs +0 -19
- package/src/tool/question-prompt.mjs +93 -86
- package/src/tool/registry.mjs +71 -37
- package/src/ui/activity-renderer.mjs +664 -410
- package/src/util/git.mjs +23 -0
package/src/provider/sse.mjs
CHANGED
|
@@ -1,91 +1,104 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 将 fetch response.body (ReadableStream) 解析为 SSE 事件的 AsyncIterator。
|
|
3
|
-
* 同时支持 OpenAI(纯 data: 行)和 Anthropic(event: + data: 对)格式。
|
|
4
|
-
*
|
|
5
|
-
* @param {ReadableStream} body
|
|
6
|
-
* @param {AbortSignal} [signal]
|
|
7
|
-
* @param {object} [options]
|
|
8
|
-
* @param {number} [options.idleTimeoutMs] - per-chunk idle timeout (resets on each chunk)
|
|
9
|
-
* @yields {{ event: string|null, data: string }}
|
|
10
|
-
*/
|
|
11
|
-
export async function* parseSSE(body, signal, options = {}) {
|
|
12
|
-
const reader = body.getReader()
|
|
13
|
-
const decoder = new TextDecoder()
|
|
14
|
-
let buffer = ""
|
|
15
|
-
const idleMs = options.idleTimeoutMs || 0
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 将 fetch response.body (ReadableStream) 解析为 SSE 事件的 AsyncIterator。
|
|
3
|
+
* 同时支持 OpenAI(纯 data: 行)和 Anthropic(event: + data: 对)格式。
|
|
4
|
+
*
|
|
5
|
+
* @param {ReadableStream} body
|
|
6
|
+
* @param {AbortSignal} [signal]
|
|
7
|
+
* @param {object} [options]
|
|
8
|
+
* @param {number} [options.idleTimeoutMs] - per-chunk idle timeout (resets on each chunk)
|
|
9
|
+
* @yields {{ event: string|null, data: string }}
|
|
10
|
+
*/
|
|
11
|
+
export async function* parseSSE(body, signal, options = {}) {
|
|
12
|
+
const reader = body.getReader()
|
|
13
|
+
const decoder = new TextDecoder()
|
|
14
|
+
let buffer = ""
|
|
15
|
+
const idleMs = options.idleTimeoutMs || 0
|
|
16
|
+
let currentTimeout = null
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
while (true) {
|
|
20
|
+
if (signal?.aborted) break
|
|
21
|
+
|
|
22
|
+
let readResult
|
|
23
|
+
if (idleMs > 0) {
|
|
24
|
+
if (currentTimeout) currentTimeout.cancel()
|
|
25
|
+
currentTimeout = idleTimeout(idleMs, signal)
|
|
26
|
+
readResult = await Promise.race([
|
|
27
|
+
reader.read(),
|
|
28
|
+
currentTimeout.promise
|
|
29
|
+
])
|
|
30
|
+
} else {
|
|
31
|
+
readResult = await reader.read()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { done, value } = readResult
|
|
35
|
+
if (done) break
|
|
36
|
+
buffer += decoder.decode(value, { stream: true })
|
|
37
|
+
|
|
38
|
+
const parts = buffer.split("\n\n")
|
|
39
|
+
buffer = parts.pop()
|
|
40
|
+
|
|
41
|
+
for (const part of parts) {
|
|
42
|
+
const result = parsePart(part)
|
|
43
|
+
if (result === null) return // [DONE]
|
|
44
|
+
if (result) yield result
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// flush remaining buffer
|
|
48
|
+
if (buffer.trim()) {
|
|
49
|
+
const result = parsePart(buffer)
|
|
50
|
+
if (result && result !== null) yield result
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
if (currentTimeout) currentTimeout.cancel()
|
|
54
|
+
try { reader.releaseLock() } catch { /* reader may have pending read if generator was force-closed */ }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function idleTimeout(ms, signal) {
|
|
59
|
+
let timer = null
|
|
60
|
+
let onAbort = null
|
|
61
|
+
const promise = new Promise((resolve, reject) => {
|
|
62
|
+
timer = setTimeout(() => {
|
|
63
|
+
const err = new Error(`stream idle timeout: no data received for ${ms}ms`)
|
|
64
|
+
err.code = "STREAM_IDLE_TIMEOUT"
|
|
65
|
+
reject(err)
|
|
66
|
+
}, ms)
|
|
67
|
+
if (signal) {
|
|
68
|
+
onAbort = () => {
|
|
69
|
+
clearTimeout(timer)
|
|
70
|
+
const err = new Error("aborted")
|
|
71
|
+
err.code = "ABORT_ERR"
|
|
72
|
+
reject(err)
|
|
73
|
+
}
|
|
74
|
+
if (signal.aborted) { clearTimeout(timer); onAbort(); return }
|
|
75
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
function cancel() {
|
|
79
|
+
if (timer !== null) { clearTimeout(timer); timer = null }
|
|
80
|
+
if (onAbort && signal) {
|
|
81
|
+
signal.removeEventListener("abort", onAbort)
|
|
82
|
+
onAbort = null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { promise, cancel }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parsePart(part) {
|
|
89
|
+
const trimmed = part.trim()
|
|
90
|
+
if (!trimmed) return undefined
|
|
91
|
+
let event = null
|
|
92
|
+
let data = ""
|
|
93
|
+
for (const line of trimmed.split("\n")) {
|
|
94
|
+
if (line.startsWith("event:")) {
|
|
95
|
+
event = line.slice(6).trim()
|
|
96
|
+
} else if (line.startsWith("data:")) {
|
|
97
|
+
const payload = line.slice(5).trim()
|
|
98
|
+
if (payload === "[DONE]") return null
|
|
99
|
+
data = payload
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!data) return undefined
|
|
103
|
+
return { event, data }
|
|
104
|
+
}
|
package/src/repl.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { extractImageRefs, buildContentBlocks, readClipboardImage, readClipboard
|
|
|
29
29
|
import { generateSkill, saveSkillGlobal } from "./skill/generator.mjs"
|
|
30
30
|
import { userConfigCandidates, projectConfigCandidates, memoryFilePath } from "./storage/paths.mjs"
|
|
31
31
|
import { persistTrust, revokeTrust } from "./permission/workspace-trust.mjs"
|
|
32
|
+
import { confirmRollback, executeRollback } from "./session/rollback.mjs"
|
|
32
33
|
|
|
33
34
|
const HIST_DIR = join(homedir(), ".kkcode")
|
|
34
35
|
const HIST_FILE = join(HIST_DIR, "repl_history")
|
|
@@ -88,6 +89,7 @@ const BUILTIN_SLASH = [
|
|
|
88
89
|
{ name: "longagent", desc: "switch to longagent mode" },
|
|
89
90
|
{ name: "create-skill", desc: "generate a new skill via AI" },
|
|
90
91
|
{ name: "create-agent", desc: "generate a new sub-agent via AI" },
|
|
92
|
+
{ name: "undo", desc: "undo last code changes" },
|
|
91
93
|
{ name: "trust", desc: "trust this workspace" },
|
|
92
94
|
{ name: "untrust", desc: "revoke workspace trust" },
|
|
93
95
|
{ name: "exit", desc: "quit" }
|
|
@@ -747,7 +749,9 @@ async function processInputLine({
|
|
|
747
749
|
else {
|
|
748
750
|
for (const s of sessions) {
|
|
749
751
|
const age = ageLabel(Date.now() - s.updatedAt)
|
|
750
|
-
|
|
752
|
+
const title = s.title || `${s.mode}:${s.model || "?"}`
|
|
753
|
+
const titleClipped = title.length > 35 ? title.slice(0, 32) + "..." : title
|
|
754
|
+
print(` ${s.id.slice(0, 12)} ${padRight(titleClipped, 36)} ${padRight(s.mode, 9)} ${padRight(s.status || "-", 10)} ${age}`)
|
|
751
755
|
}
|
|
752
756
|
}
|
|
753
757
|
return { exit: false }
|
|
@@ -756,12 +760,42 @@ async function processInputLine({
|
|
|
756
760
|
if (normalized === "/resume" || normalized.startsWith("/resume ") || normalized === "/r" || normalized.startsWith("/r ")) {
|
|
757
761
|
const arg = normalized.replace(/^\/(resume|r)/, "").trim()
|
|
758
762
|
const sessions = await listSessions({ cwd: process.cwd(), limit: 20, includeChildren: false })
|
|
763
|
+
|
|
764
|
+
if (!sessions.length) {
|
|
765
|
+
print("no sessions found in current directory")
|
|
766
|
+
return { exit: false }
|
|
767
|
+
}
|
|
768
|
+
|
|
759
769
|
let target = null
|
|
760
|
-
|
|
761
|
-
|
|
770
|
+
|
|
771
|
+
if (!arg) {
|
|
772
|
+
// Show interactive numbered list
|
|
773
|
+
print(`\n Sessions in ${paint(process.cwd(), "cyan")}:\n`)
|
|
774
|
+
for (let i = 0; i < sessions.length; i++) {
|
|
775
|
+
const s = sessions[i]
|
|
776
|
+
const num = paint(` ${String(i + 1).padStart(2)}.`, "yellow")
|
|
777
|
+
const title = s.title || `${s.mode}:${s.model || "?"}`
|
|
778
|
+
const titleClipped = title.length > 45 ? title.slice(0, 42) + "..." : title
|
|
779
|
+
const age = ageLabel(Date.now() - s.updatedAt)
|
|
780
|
+
const mode = paint(padRight(s.mode, 9), "cyan")
|
|
781
|
+
const status = s.status === "active" ? paint("active", "green") : paint(s.status || "-", null, { dim: true })
|
|
782
|
+
print(`${num} ${padRight(titleClipped, 46)} ${mode} ${padRight(status, 14)} ${paint(age, null, { dim: true })}`)
|
|
783
|
+
}
|
|
784
|
+
print(`\n usage: ${paint("/resume <number>", "yellow")} or ${paint("/resume <session-id>", "yellow")}`)
|
|
785
|
+
return { exit: false }
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Try numeric index first (1-based)
|
|
789
|
+
const idx = parseInt(arg, 10)
|
|
790
|
+
if (!Number.isNaN(idx) && idx >= 1 && idx <= sessions.length) {
|
|
791
|
+
target = sessions[idx - 1]
|
|
792
|
+
} else {
|
|
793
|
+
// Fallback to ID prefix match
|
|
794
|
+
target = sessions.find((s) => s.id === arg || s.id.startsWith(arg)) || null
|
|
795
|
+
}
|
|
762
796
|
|
|
763
797
|
if (!target) {
|
|
764
|
-
print(
|
|
798
|
+
print(`no session matching "${arg}"`)
|
|
765
799
|
return { exit: false }
|
|
766
800
|
}
|
|
767
801
|
|
|
@@ -769,15 +803,33 @@ async function processInputLine({
|
|
|
769
803
|
state.mode = target.mode || state.mode
|
|
770
804
|
state.providerType = target.providerType || state.providerType
|
|
771
805
|
state.model = target.model || state.model
|
|
772
|
-
|
|
806
|
+
const title = target.title || `${target.mode}:${target.model || "?"}`
|
|
807
|
+
print(`resumed: ${paint(title, "cyan")} (${target.mode}, ${target.model || "?"})`)
|
|
773
808
|
const msgs = await getConversationHistory(target.id, 3)
|
|
774
809
|
for (const m of msgs) {
|
|
775
|
-
const
|
|
810
|
+
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content)
|
|
811
|
+
const preview = text.length > 84 ? `${text.slice(0, 84)}...` : text
|
|
776
812
|
print(` [${m.role}] ${preview}`)
|
|
777
813
|
}
|
|
778
814
|
return { exit: false }
|
|
779
815
|
}
|
|
780
816
|
|
|
817
|
+
if (normalized === "/undo") {
|
|
818
|
+
const language = ctx.configState.config.language || "en"
|
|
819
|
+
const cwd = process.cwd()
|
|
820
|
+
const confirmation = await confirmRollback({ cwd, language })
|
|
821
|
+
print(confirmation.message)
|
|
822
|
+
if (!confirmation.confirmed) return { exit: false }
|
|
823
|
+
const result = await executeRollback({
|
|
824
|
+
cwd,
|
|
825
|
+
commitHash: confirmation.commitHash,
|
|
826
|
+
sessionId: state.sessionId,
|
|
827
|
+
language
|
|
828
|
+
})
|
|
829
|
+
print(result.message)
|
|
830
|
+
return { exit: false }
|
|
831
|
+
}
|
|
832
|
+
|
|
781
833
|
if (["/ask", "/plan", "/agent", "/longagent"].includes(normalized)) {
|
|
782
834
|
state.mode = resolveMode(normalized.slice(1))
|
|
783
835
|
print(`mode switched: ${state.mode}`)
|
|
@@ -2720,7 +2772,7 @@ function startSplash() {
|
|
|
2720
2772
|
" ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ "
|
|
2721
2773
|
]
|
|
2722
2774
|
const tagline = "AI Coding Agent"
|
|
2723
|
-
const version = "v0.1.
|
|
2775
|
+
const version = "v0.1.6"
|
|
2724
2776
|
|
|
2725
2777
|
// Gradient colors for the wave animation (cyan → blue → purple → pink → back)
|
|
2726
2778
|
const wave = [
|
|
@@ -46,6 +46,71 @@ export async function listCheckpoints(sessionId) {
|
|
|
46
46
|
.sort()
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// ========== Phase 7: Task 级 Checkpoint ==========
|
|
50
|
+
|
|
51
|
+
export async function saveTaskCheckpoint(sessionId, stageId, taskId, data) {
|
|
52
|
+
const dir = checkpointDir(sessionId)
|
|
53
|
+
await mkdir(dir, { recursive: true })
|
|
54
|
+
const name = `task_${stageId}_${taskId}`
|
|
55
|
+
const checkpoint = {
|
|
56
|
+
sessionId,
|
|
57
|
+
stageId,
|
|
58
|
+
taskId,
|
|
59
|
+
savedAt: Date.now(),
|
|
60
|
+
...data
|
|
61
|
+
}
|
|
62
|
+
await writeJson(checkpointFile(sessionId, name), checkpoint)
|
|
63
|
+
return checkpoint
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function loadTaskCheckpoints(sessionId, stageId) {
|
|
67
|
+
const dir = checkpointDir(sessionId)
|
|
68
|
+
const files = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
69
|
+
const prefix = `task_${stageId}_`
|
|
70
|
+
const results = {}
|
|
71
|
+
for (const entry of files) {
|
|
72
|
+
if (entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith(".json")) {
|
|
73
|
+
const data = await readJson(path.join(dir, entry.name), null)
|
|
74
|
+
if (data?.taskId) results[data.taskId] = data
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return results
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ========== Phase 10: Checkpoint 清理策略 ==========
|
|
81
|
+
|
|
82
|
+
export async function cleanupCheckpoints(sessionId, options = {}) {
|
|
83
|
+
const maxKeep = options.maxKeep || 10
|
|
84
|
+
const keepStageCheckpoints = options.keepStageCheckpoints !== false
|
|
85
|
+
const dir = checkpointDir(sessionId)
|
|
86
|
+
const all = await listCheckpoints(sessionId)
|
|
87
|
+
if (all.length <= maxKeep + 1) return { removed: 0 }
|
|
88
|
+
|
|
89
|
+
const toKeep = new Set(["latest"])
|
|
90
|
+
// 保留 stage 级和 task 级 checkpoint
|
|
91
|
+
if (keepStageCheckpoints) {
|
|
92
|
+
for (const name of all) {
|
|
93
|
+
if (name.startsWith("hybrid_stage_") || name.startsWith("task_")) {
|
|
94
|
+
toKeep.add(name)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 保留最近 maxKeep 个编号 checkpoint
|
|
99
|
+
const numbered = all.filter(n => n.startsWith("cp_")).sort()
|
|
100
|
+
for (const n of numbered.slice(-maxKeep)) toKeep.add(n)
|
|
101
|
+
|
|
102
|
+
let removed = 0
|
|
103
|
+
for (const name of all) {
|
|
104
|
+
if (toKeep.has(name)) continue
|
|
105
|
+
try {
|
|
106
|
+
const { unlink: unlinkFile } = await import("node:fs/promises")
|
|
107
|
+
await unlinkFile(checkpointFile(sessionId, name)).catch(() => {})
|
|
108
|
+
removed++
|
|
109
|
+
} catch { /* ignore */ }
|
|
110
|
+
}
|
|
111
|
+
return { removed }
|
|
112
|
+
}
|
|
113
|
+
|
|
49
114
|
// ============================================================================
|
|
50
115
|
// Git Snapshot Integration - AI Agent 自动 Git 快照功能
|
|
51
116
|
// ============================================================================
|
|
@@ -61,12 +126,10 @@ export async function listCheckpoints(sessionId) {
|
|
|
61
126
|
* @returns {Promise<{ok: boolean, snapshot?: Object, skipped?: boolean, reason?: string}>}
|
|
62
127
|
*/
|
|
63
128
|
export async function autoSnapshotBeforeEdit(sessionId, cwd, config = {}, options = {}) {
|
|
64
|
-
// 检查 Git
|
|
129
|
+
// 检查 Git 自动化是否启用(默认启用,只有显式关闭才跳过)
|
|
65
130
|
if (config.git_auto?.enabled === false) {
|
|
66
131
|
return { ok: true, skipped: true, reason: "git_auto_disabled" }
|
|
67
132
|
}
|
|
68
|
-
|
|
69
|
-
// 检查自动快照是否启用
|
|
70
133
|
if (config.git_auto?.auto_snapshot === false) {
|
|
71
134
|
return { ok: true, skipped: true, reason: "auto_snapshot_disabled" }
|
|
72
135
|
}
|