@thincoder/core 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/abort-provenance.mjs +116 -0
- package/advisor/citations.mjs +139 -0
- package/advisor/compaction.mjs +174 -0
- package/advisor/convergence.mjs +80 -0
- package/advisor/history.mjs +77 -0
- package/advisor/loop.mjs +288 -0
- package/advisor/messages.mjs +299 -0
- package/advisor/notice.mjs +141 -0
- package/advisor/project-context.mjs +197 -0
- package/advisor/repos.mjs +150 -0
- package/advisor/run.mjs +190 -0
- package/advisor/truncate.mjs +57 -0
- package/advisor.mjs +281 -0
- package/agent/child-marks.mjs +24 -0
- package/agent/completion.mjs +145 -0
- package/agent/dispatch.mjs +493 -0
- package/agent/family-tools.mjs +174 -0
- package/agent/helpers.mjs +412 -0
- package/agent/post-turn.mjs +70 -0
- package/agent/record-results.mjs +174 -0
- package/agent/relay-prefix.mjs +39 -0
- package/agent/run-stages.mjs +244 -0
- package/agent/setup-reminders.mjs +199 -0
- package/agent/setup.mjs +234 -0
- package/agent/spawn-child.mjs +258 -0
- package/agent/suspension.mjs +240 -0
- package/agent/write-gate.mjs +87 -0
- package/agent-tools/advisor-async.mjs +481 -0
- package/agent-tools/advisor-settle.mjs +240 -0
- package/agent-tools/advisor.mjs +280 -0
- package/agent-tools/async-discard.mjs +143 -0
- package/agent-tools/async-settle.mjs +299 -0
- package/agent-tools/batch-segment.mjs +265 -0
- package/agent-tools/child-permission.mjs +45 -0
- package/agent-tools/consult.mjs +471 -0
- package/agent-tools/design-token.mjs +117 -0
- package/agent-tools/digest-budget.mjs +76 -0
- package/agent-tools/eng.mjs +102 -0
- package/agent-tools/escalate-async.mjs +302 -0
- package/agent-tools/goal.mjs +119 -0
- package/agent-tools/panel-blocks.mjs +24 -0
- package/agent-tools/parent-channel.mjs +231 -0
- package/agent-tools/plan.mjs +86 -0
- package/agent-tools/read-history.mjs +309 -0
- package/agent-tools/recent-changes.mjs +24 -0
- package/agent-tools/review-facts.mjs +31 -0
- package/agent-tools/settings.mjs +268 -0
- package/agent-tools/skill.mjs +63 -0
- package/agent-tools/spawn-gates.mjs +109 -0
- package/agent-tools/subagent-actions.mjs +496 -0
- package/agent-tools/subagent-async.mjs +456 -0
- package/agent-tools/subagent-panel.mjs +160 -0
- package/agent-tools/subagent-run.mjs +208 -0
- package/agent-tools/subagent-scheduler.mjs +446 -0
- package/agent-tools/subagent-spawn.mjs +478 -0
- package/agent-tools/subagent.mjs +419 -0
- package/agent-tools/task.mjs +87 -0
- package/agent-tools/timer.mjs +46 -0
- package/agent-tools/verify.mjs +295 -0
- package/agent-tools.mjs +23 -0
- package/agent.mjs +430 -0
- package/auto-think.mjs +115 -0
- package/compress-form.mjs +24 -0
- package/config-io.mjs +277 -0
- package/config-migrate.mjs +178 -0
- package/config-presets.mjs +49 -0
- package/config.mjs +419 -0
- package/context.mjs +495 -0
- package/conventions.mjs +223 -0
- package/embedding.mjs +120 -0
- package/escape.mjs +152 -0
- package/expand-home.mjs +16 -0
- package/explore-distill.mjs +152 -0
- package/generate-title.mjs +123 -0
- package/git/checkpoint.mjs +448 -0
- package/git/gitmem.mjs +100 -0
- package/history-window.mjs +179 -0
- package/hooks.mjs +108 -0
- package/i18n.mjs +106 -0
- package/index-bin.mjs +48 -0
- package/index-discover.mjs +176 -0
- package/ledger-cmd.mjs +209 -0
- package/ledger-db.mjs +86 -0
- package/ledger-surface.mjs +76 -0
- package/ledger.mjs +202 -0
- package/log.mjs +195 -0
- package/manifest.mjs +338 -0
- package/markdown.mjs +106 -0
- package/mcp/helpers.mjs +51 -0
- package/mcp/transport-http.mjs +248 -0
- package/mcp/transport-stdio.mjs +140 -0
- package/mcp/transport-ws.mjs +122 -0
- package/mcp.mjs +295 -0
- package/memory/code-index.mjs +219 -0
- package/memory/code-sync.mjs +427 -0
- package/memory/core.mjs +318 -0
- package/memory/delete.mjs +242 -0
- package/memory/docs.mjs +431 -0
- package/memory/file-walk.mjs +109 -0
- package/memory/origin.mjs +24 -0
- package/memory/scan.mjs +177 -0
- package/memory/schema.mjs +460 -0
- package/memory.mjs +21 -0
- package/model-ref.mjs +66 -0
- package/model-specs.mjs +277 -0
- package/package.json +32 -0
- package/peer-domains.mjs +265 -0
- package/peer-instances.mjs +178 -0
- package/permission.mjs +79 -0
- package/process-probe.mjs +315 -0
- package/prompt-files.mjs +113 -0
- package/prompt-overlays.mjs +78 -0
- package/prompts/advisor-design.md +43 -0
- package/prompts/advisor-round1.md +41 -0
- package/prompts/advisor-round2.md +46 -0
- package/prompts/advisor-round3.md +42 -0
- package/prompts/common.md +158 -0
- package/prompts/consult-base.md +19 -0
- package/prompts/discipline-engineering.md +123 -0
- package/prompts/discipline-normal.md +206 -0
- package/prompts/persona-coder.md +21 -0
- package/prompts/persona-eng-coder.md +41 -0
- package/prompts/persona-eng-designer.md +80 -0
- package/prompts/persona-engineering.md +160 -0
- package/prompts/persona-explore.md +15 -0
- package/prompts/persona-normal.md +35 -0
- package/prompts/persona-plan.md +27 -0
- package/provider/anthropic.mjs +225 -0
- package/provider/core.mjs +491 -0
- package/provider/errors.mjs +101 -0
- package/provider/google.mjs +257 -0
- package/provider/index.mjs +7 -0
- package/provider/list-models.mjs +163 -0
- package/provider/normalize.mjs +81 -0
- package/provider/rate.mjs +168 -0
- package/provider/responses.mjs +495 -0
- package/provider/retry.mjs +88 -0
- package/provider/sse.mjs +264 -0
- package/provider/wait-status.mjs +59 -0
- package/proxy.mjs +274 -0
- package/rules.mjs +53 -0
- package/session-gc.mjs +248 -0
- package/session-guard.mjs +59 -0
- package/session-lifecycle.mjs +305 -0
- package/session-migrate.mjs +48 -0
- package/session-rename.mjs +38 -0
- package/session-segments.mjs +99 -0
- package/session-slot-write.mjs +168 -0
- package/session-slots-manifest.mjs +264 -0
- package/session-slots.mjs +298 -0
- package/session-store.mjs +441 -0
- package/session.mjs +244 -0
- package/skills.mjs +234 -0
- package/text-budget.mjs +79 -0
- package/token-ttl.mjs +285 -0
- package/tool-docs/apply_patch.md +15 -0
- package/tool-docs/bash.md +38 -0
- package/tool-docs/delete.md +13 -0
- package/tool-docs/edit.md +30 -0
- package/tool-docs/execute.md +21 -0
- package/tool-docs/fetch.md +12 -0
- package/tool-docs/file_ops.md +17 -0
- package/tool-docs/get_current_time.md +8 -0
- package/tool-docs/git.md +54 -0
- package/tool-docs/glob.md +11 -0
- package/tool-docs/grep.md +19 -0
- package/tool-docs/hashline_edit.md +14 -0
- package/tool-docs/insert_after.md +15 -0
- package/tool-docs/lint.md +10 -0
- package/tool-docs/ls.md +12 -0
- package/tool-docs/lsp.md +10 -0
- package/tool-docs/process.md +10 -0
- package/tool-docs/question.md +16 -0
- package/tool-docs/read.md +20 -0
- package/tool-docs/read_image.md +8 -0
- package/tool-docs/tree.md +14 -0
- package/tool-docs/wait_for.md +22 -0
- package/tool-docs/websearch.md +16 -0
- package/tool-docs/write.md +11 -0
- package/tools/bash.mjs +276 -0
- package/tools/edit-batch.mjs +204 -0
- package/tools/edit-diff.mjs +388 -0
- package/tools/exec-run.mjs +43 -0
- package/tools/execute.mjs +243 -0
- package/tools/file.mjs +464 -0
- package/tools/git-checkpoint.mjs +143 -0
- package/tools/git-ext.mjs +173 -0
- package/tools/git.mjs +415 -0
- package/tools/glob-dialect.mjs +130 -0
- package/tools/index.mjs +76 -0
- package/tools/linter.mjs +120 -0
- package/tools/lsp.mjs +335 -0
- package/tools/ops.mjs +293 -0
- package/tools/patch.mjs +290 -0
- package/tools/question.mjs +26 -0
- package/tools/repomap.mjs +314 -0
- package/tools/search.mjs +248 -0
- package/tools/shared.mjs +467 -0
- package/tools/tree.mjs +81 -0
- package/tools/web.mjs +224 -0
- package/tools/write-path.mjs +191 -0
- package/traces/trace-store.mjs +303 -0
- package/undo-stack.mjs +47 -0
package/tools/shared.mjs
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/shared.mjs — shared tool utilities, constants, OpenAI schema conversion
|
|
3
|
+
* Imported by tools/file.mjs / system.mjs / web.mjs / git.mjs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn, execFileSync, execFile } from "node:child_process"
|
|
7
|
+
import { readFileSync, existsSync, realpathSync, readdirSync, statSync, openSync, readSync, closeSync } from "node:fs"
|
|
8
|
+
import { dirname, join, resolve } from "node:path"
|
|
9
|
+
import { fileURLToPath } from "node:url"
|
|
10
|
+
import { loadToolDoc, applyPromptInjections } from "../prompt-files.mjs"
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
// 工具描述加载面——**核内单一解析面**(D-C13 / 契约 8):解析根 = `prompt-files.mjs`
|
|
14
|
+
// (`import.meta.url` 为根,不依赖 cwd / 包名);产品侧不再做路径运算。
|
|
15
|
+
// 缺档语义不变(契约 9):工具描述 ⇒ 抛错(`loadToolDoc`)。
|
|
16
|
+
// 来源 = CLI `src/tools/shared.mjs` 的 `DESC`(`join(__dirname, "..", "tools", name + ".md")`)
|
|
17
|
+
// ——§2.5 #63 融合面:只改**根**,不改语义。
|
|
18
|
+
export const DESC = (name) => loadToolDoc(name)
|
|
19
|
+
|
|
20
|
+
export const MAX_READ_LINES = 2000
|
|
21
|
+
export const MAX_OUTPUT_CHARS = 200_000
|
|
22
|
+
|
|
23
|
+
const ENCODING_DETECT_MAX_TRIM = 3
|
|
24
|
+
const SYNTAX_CHECK_TIMEOUT = 10000
|
|
25
|
+
export const BASH_TIMEOUT_MS = 120_000
|
|
26
|
+
export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
|
|
27
|
+
|
|
28
|
+
/** SSRF guard: check if a hostname is private/internal. Shared by web.mjs and codemode.mjs.
|
|
29
|
+
* Returns TRUE for private hosts — callers block them. */
|
|
30
|
+
export function isPrivateHost(hostname) {
|
|
31
|
+
const h = hostname.toLowerCase()
|
|
32
|
+
// Local loopback + link-local names: BLOCK (true). These returned false
|
|
33
|
+
// before — the guard was inverted for the most common SSRF targets
|
|
34
|
+
// (localhost/127.x reach internal services unchecked).
|
|
35
|
+
if (h === "localhost" || h === "0.0.0.0" || h.endsWith(".localhost")) return true
|
|
36
|
+
if (h === "127.0.0.1" || h.startsWith("127.")) return true
|
|
37
|
+
if (h === "169.254.169.254" || h === "metadata.google.internal") return true
|
|
38
|
+
// IPv6 private ranges — only check if host contains ":"
|
|
39
|
+
// fe80::/10 link-local covers fe80:…febf:… — startsWith("fe80:") is the
|
|
40
|
+
// practical subset (fe8/fe9/fea/feb all begin fe8/feb — full range regex
|
|
41
|
+
// would be /^fe[89ab][0-9a-f]:/; startsWith fe8 + fe9 + fea + feb covers it).
|
|
42
|
+
if (h.includes(":")) {
|
|
43
|
+
if (h === "::1" || h.startsWith("fc") || h.startsWith("fd")) return true
|
|
44
|
+
if (/^fe[89ab][0-9a-f]:/.test(h)) return true
|
|
45
|
+
}
|
|
46
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
47
|
+
if (m) {
|
|
48
|
+
// Octet range is NOT validated (999.10.0.1 parses but matches no private
|
|
49
|
+
// prefix → treated as public). Intentional: the guard checks known-private
|
|
50
|
+
// prefixes; invalid IPs are harmless false-negatives for SSRF purposes.
|
|
51
|
+
const [a, b] = [Number(m[1]), Number(m[2])]
|
|
52
|
+
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || a === 192 && b === 168 || a === 169 && b === 254 || a === 0) return true
|
|
53
|
+
}
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Normalize Windows line endings to Unix: \r\n → \n.
|
|
58
|
+
* Applied on every text-file read so that edit/hash matching
|
|
59
|
+
* and hash computation are platform-consistent. */
|
|
60
|
+
export function normalizeEOL(text) {
|
|
61
|
+
return text.replace(/\r\n/g, "\n")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Detect a file's EOL style by the type of its FIRST newline: "\r\n" first →
|
|
65
|
+
* the whole file is written back as CRLF; a bare "\n" or no newline → LF.
|
|
66
|
+
* Never counts occurrences (mixed files follow the first line's style). */
|
|
67
|
+
export function detectFileEol(text) {
|
|
68
|
+
const i = text.indexOf("\n")
|
|
69
|
+
return i > 0 && text[i - 1] === "\r" ? "\r\n" : "\n"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Join lines with the EOL style detected from the original text (write-back restore). */
|
|
73
|
+
export function joinWithEol(lines, originalText) {
|
|
74
|
+
return lines.join(detectFileEol(originalText))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const MAJORITY_EOL_MAX_FILES = 20
|
|
78
|
+
const EOL_SNIFF_BYTES = 4096
|
|
79
|
+
|
|
80
|
+
/** Majority EOL style of a directory's existing files (≤20 files, first 4KB each).
|
|
81
|
+
* New files follow the directory's majority style; empty dir / tie / LF majority → "\n". */
|
|
82
|
+
export function majorityEol(dirPath) {
|
|
83
|
+
let names
|
|
84
|
+
try { names = readdirSync(dirPath) } catch { return "\n" }
|
|
85
|
+
let crlf = 0, lf = 0
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
if (crlf + lf >= MAJORITY_EOL_MAX_FILES) break
|
|
88
|
+
try {
|
|
89
|
+
const p = join(dirPath, name)
|
|
90
|
+
if (!statSync(p).isFile()) continue
|
|
91
|
+
const fd = openSync(p, "r")
|
|
92
|
+
let head = ""
|
|
93
|
+
try {
|
|
94
|
+
const buf = Buffer.alloc(EOL_SNIFF_BYTES)
|
|
95
|
+
const n = readSync(fd, buf, 0, EOL_SNIFF_BYTES, 0)
|
|
96
|
+
head = buf.subarray(0, n).toString("utf8")
|
|
97
|
+
} finally {
|
|
98
|
+
closeSync(fd)
|
|
99
|
+
}
|
|
100
|
+
if (detectFileEol(head) === "\r\n") crlf++
|
|
101
|
+
else lf++
|
|
102
|
+
} catch { /* unreadable entry — skip */ }
|
|
103
|
+
}
|
|
104
|
+
return crlf > lf ? "\r\n" : "\n"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const CANDIDATE_MAX_LEN = 500
|
|
108
|
+
const CANDIDATE_PREVIEW_LEN = 80
|
|
109
|
+
|
|
110
|
+
/** Longest-common-substring length (rolling-row DP). Inputs are pre-truncated by the caller. */
|
|
111
|
+
function lcsLength(a, b) {
|
|
112
|
+
// Reused DP buffers (review R9#6): per-line allocation caused GC pressure on
|
|
113
|
+
// large files — hoist two module-level rows, grow to fit, swap by index.
|
|
114
|
+
const need = b.length + 1
|
|
115
|
+
if (_lcsBuf0.length < need) {
|
|
116
|
+
const size = Math.max(need, _lcsBuf0.length * 2)
|
|
117
|
+
_lcsBuf0 = new Uint16Array(size)
|
|
118
|
+
_lcsBuf1 = new Uint16Array(size)
|
|
119
|
+
}
|
|
120
|
+
let prev = _lcsBuf0, cur = _lcsBuf1
|
|
121
|
+
prev.fill(0, 0, need)
|
|
122
|
+
let best = 0
|
|
123
|
+
for (let i = 1; i <= a.length; i++) {
|
|
124
|
+
cur[0] = 0
|
|
125
|
+
const ca = a.charCodeAt(i - 1)
|
|
126
|
+
for (let j = 1; j < need; j++) {
|
|
127
|
+
if (ca === b.charCodeAt(j - 1)) {
|
|
128
|
+
const v = prev[j - 1] + 1
|
|
129
|
+
cur[j] = v
|
|
130
|
+
if (v > best) best = v
|
|
131
|
+
} else cur[j] = 0 // must reset — buffer is reused
|
|
132
|
+
}
|
|
133
|
+
const t = prev; prev = cur; cur = t
|
|
134
|
+
}
|
|
135
|
+
return best
|
|
136
|
+
}
|
|
137
|
+
let _lcsBuf0 = new Uint16Array(0), _lcsBuf1 = new Uint16Array(0)
|
|
138
|
+
/** Line-level similarity candidates for a failed edit: score = LCS(oldString, line) / max(len).
|
|
139
|
+
* Multi-line old_string matches on its FIRST line only (failures usually diverge there).
|
|
140
|
+
* Both sides are truncated to 500 chars before scoring so minified files can't blow the budget.
|
|
141
|
+
* Returns up to topN [{ line (1-based), preview, score }] with score >= threshold, best first. */
|
|
142
|
+
export function findCandidates(lines, oldString, topN = 3, threshold = 0.5) {
|
|
143
|
+
const needle = oldString.split("\n")[0].slice(0, CANDIDATE_MAX_LEN)
|
|
144
|
+
if (!needle) return []
|
|
145
|
+
const scored = []
|
|
146
|
+
for (let i = 0; i < lines.length; i++) {
|
|
147
|
+
const raw = lines[i]
|
|
148
|
+
if (!raw) continue
|
|
149
|
+
const line = raw.length > CANDIDATE_MAX_LEN ? raw.slice(0, CANDIDATE_MAX_LEN) : raw
|
|
150
|
+
const longer = Math.max(needle.length, line.length)
|
|
151
|
+
const shorter = Math.min(needle.length, line.length)
|
|
152
|
+
// LCS ≤ shorter side — a length ratio below the threshold can never reach it; skip the DP.
|
|
153
|
+
if (shorter / longer < threshold) continue
|
|
154
|
+
const score = lcsLength(needle, line) / longer
|
|
155
|
+
if (score >= threshold) scored.push({ line: i + 1, preview: raw.slice(0, CANDIDATE_PREVIEW_LEN), score })
|
|
156
|
+
}
|
|
157
|
+
scored.sort((a, b) => b.score - a.score || a.line - b.line)
|
|
158
|
+
return scored.slice(0, topN)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Appended to hashline_edit results when the file contains U+FFFD (encoding-corruption probe). */
|
|
162
|
+
export const FFFD_WARNING = "⚠ file contains U+FFFD (replacement char) — encoding may be corrupted; hash-based addressing may be unreliable. Consider fixing the file encoding first."
|
|
163
|
+
|
|
164
|
+
/** Convert to OpenAI tools parameter format.
|
|
165
|
+
* 锚替换缝(CORE-UNIFICATION §2.13.8——U0):`description` 经 `applyPromptInjections`
|
|
166
|
+
* (未配置 = 恒等);两个调用点(`agent/setup.mjs` · `advisor/loop.mjs`)同经本函数。
|
|
167
|
+
* 非字符串 description 恒等过(无锚可替换)。*/
|
|
168
|
+
export function toOpenAISchema(tool) {
|
|
169
|
+
return {
|
|
170
|
+
type: "function",
|
|
171
|
+
function: {
|
|
172
|
+
name: tool.name,
|
|
173
|
+
description: applyPromptInjections(tool.description),
|
|
174
|
+
parameters: tool.parameters,
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Strip ANSI escape sequences */
|
|
180
|
+
export function sanitizeOutput(s) {
|
|
181
|
+
return s
|
|
182
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
183
|
+
.replace(/\x1b\[[0-9;?]*[\x40-\x7E]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
|
|
184
|
+
.replace(/\r\n/g, "\n")
|
|
185
|
+
.replace(/\r/g, "\n")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Truncate text to max chars, appending a truncation notice */
|
|
189
|
+
export function truncate(text, max = MAX_OUTPUT_CHARS) {
|
|
190
|
+
if (text.length <= max) return text
|
|
191
|
+
return text.slice(0, max) + `\n[... truncated: ${text.length - max} chars omitted — redirect to a file if you need the full output]`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Streaming decoder: encoding sniffing ASCII→UTF-8→GBK.
|
|
195
|
+
* KNOWN LIMITATION (accepted): the fallback is hardcoded to GBK (Chinese) —
|
|
196
|
+
* Shift-JIS/EUC-KR pages decode as mojibake. Real-world usage is dominated
|
|
197
|
+
* by UTF-8; a charset-aware variant would need the Content-Type header.
|
|
198
|
+
* Each call creates an independent decoder instance — must not be shared across parallel streams (internal decoder state accumulates). */
|
|
199
|
+
export function makeDecoder() {
|
|
200
|
+
let decoder = null
|
|
201
|
+
let pending = Buffer.alloc(0)
|
|
202
|
+
return (d, flush = false) => {
|
|
203
|
+
pending = Buffer.concat([pending, d])
|
|
204
|
+
if (!decoder) {
|
|
205
|
+
const hasHighByte = pending.some((b) => b >= 0x80)
|
|
206
|
+
if (!hasHighByte) { const s = pending.toString("ascii"); pending = Buffer.alloc(0); return s }
|
|
207
|
+
for (let trim = 0; trim <= ENCODING_DETECT_MAX_TRIM && !decoder; trim++) {
|
|
208
|
+
try { new TextDecoder("utf-8", { fatal: true }).decode(pending.subarray(0, pending.length - trim)); decoder = new TextDecoder("utf-8") }
|
|
209
|
+
catch { /* continue */ }
|
|
210
|
+
}
|
|
211
|
+
if (!decoder) decoder = new TextDecoder("gbk")
|
|
212
|
+
}
|
|
213
|
+
const s = decoder.decode(pending, { stream: !flush })
|
|
214
|
+
pending = Buffer.alloc(0)
|
|
215
|
+
return s
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Single-file git diff. Silently returns empty on failure. Large diffs that exceed maxBuffer are truncated rather than swallowed. */
|
|
220
|
+
export function gitDiffOne(cwd, abs) {
|
|
221
|
+
try {
|
|
222
|
+
const diff = execFileSync("git", ["--no-pager", "diff", "--no-color", "--", abs], {
|
|
223
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 10 * 1024 * 1024,
|
|
224
|
+
}).trim()
|
|
225
|
+
if (!diff) return ""
|
|
226
|
+
const lines = diff.split("\n")
|
|
227
|
+
if (lines.length <= 200) return diff
|
|
228
|
+
return lines.slice(0, 200).join("\n") + `\n... (${lines.length - 200} more diff lines)`
|
|
229
|
+
} catch (e) {
|
|
230
|
+
// maxBuffer overflow: e.stdout contains partial collected output; other errors (non-git repo etc.) return empty
|
|
231
|
+
if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && e.stdout) {
|
|
232
|
+
const lines = e.stdout.toString().split("\n")
|
|
233
|
+
return lines.slice(0, 200).join("\n") + `\n... (diff too large, showing first 200 of more lines)`
|
|
234
|
+
}
|
|
235
|
+
return ""
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Auto syntax check after file modification */
|
|
240
|
+
export async function autoSyntaxCheck(abs) {
|
|
241
|
+
if (!/\.(m?js)$/i.test(abs)) return ""
|
|
242
|
+
try {
|
|
243
|
+
await new Promise((resolve, reject) => {
|
|
244
|
+
const child = execFile("node", ["--check", abs], { timeout: SYNTAX_CHECK_TIMEOUT, stdio: ["ignore", "pipe", "pipe"] })
|
|
245
|
+
let stderr = ""
|
|
246
|
+
child.stderr.on("data", (d) => { stderr += d.toString() })
|
|
247
|
+
child.on("error", reject)
|
|
248
|
+
child.on("close", (code) => {
|
|
249
|
+
if (code === 0) resolve()
|
|
250
|
+
else {
|
|
251
|
+
const err = new Error(stderr.trim() || `node --check exited with code ${code}`)
|
|
252
|
+
err.stderr = stderr
|
|
253
|
+
reject(err)
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
return "\nSyntax: OK"
|
|
258
|
+
} catch (e) {
|
|
259
|
+
const err = (e.stderr || e.stdout || e.message || "").toString().split("\n").slice(0, 3).join("\n")
|
|
260
|
+
return `\nSyntax: FAILED — ${err}\n(If this file was corrupted by a bad edit, recover it from a checkpoint: checkpoint action=list then action=rewind with the latest id.)`
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Resolve realpath by walking up the directory tree */
|
|
265
|
+
function realpathNearest(abs) {
|
|
266
|
+
let cur = abs
|
|
267
|
+
const tail = []
|
|
268
|
+
while (!existsSync(cur)) {
|
|
269
|
+
const parent = dirname(cur)
|
|
270
|
+
if (parent === cur) return abs
|
|
271
|
+
tail.unshift(cur.slice(parent.length + 1))
|
|
272
|
+
cur = parent
|
|
273
|
+
}
|
|
274
|
+
try { const real = realpathSync(cur); return tail.length ? join(real, ...tail) : real }
|
|
275
|
+
catch { return abs }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// cwd is effectively constant per CLI session — the cache never grows in
|
|
279
|
+
// practice. A long-running server with rotating cwds would leak; revisit with
|
|
280
|
+
// an LRU if that usage ever appears.
|
|
281
|
+
const realCwdCache = new Map()
|
|
282
|
+
/** Resolve cwd to realpath, cached */
|
|
283
|
+
function realCwd(cwd) {
|
|
284
|
+
if (!realCwdCache.has(cwd)) realCwdCache.set(cwd, realpathNearest(resolve(cwd)))
|
|
285
|
+
return realCwdCache.get(cwd)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Resolve a user-supplied path relative to cwd — no directory boundary assertion
|
|
289
|
+
* (§10.1 2026-09-02: workspace confinement removed; resolveInCwd ≡ resolveExternal,
|
|
290
|
+
* trust model + approval gate is the only guard — same boundary as bash). */
|
|
291
|
+
export function resolveInCwd(ctx, p) {
|
|
292
|
+
return resolveExternal(ctx, p)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Resolve a path relative to cwd without boundary check — kept for compatibility (≡ resolveInCwd) */
|
|
296
|
+
export function resolveExternal(ctx, p) {
|
|
297
|
+
const cwd = realCwd(ctx.cwd)
|
|
298
|
+
return resolve(cwd, p)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Coarse segmentation for destructive pre-check (also splits on > >> < so destructive detection still works through redirection) */
|
|
302
|
+
export function shellSegments(command) {
|
|
303
|
+
return command.split(/&&|\|\||>>|\$\(|[;|\n<>]|`|[(]/)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Blank out quoted regions (single/double/backtick) with spaces, preserving length.
|
|
308
|
+
* Lets detectDanger ignore shell metacharacters inside quoted script bodies —
|
|
309
|
+
* e.g. `node -e "if (a > b) …"` comparisons are not danger signals.
|
|
310
|
+
*/
|
|
311
|
+
function blankQuoted(command) {
|
|
312
|
+
let out = ""
|
|
313
|
+
let quote = null
|
|
314
|
+
for (let i = 0; i < command.length; i++) {
|
|
315
|
+
const ch = command[i]
|
|
316
|
+
if (quote) {
|
|
317
|
+
if (ch === "\\" && quote !== "'") { out += " "; i++; out += " "; continue }
|
|
318
|
+
if (ch === quote) { quote = null; out += " "; continue }
|
|
319
|
+
// Backticks are COMMAND SUBSTITUTION — the content executes, so it must
|
|
320
|
+
// stay visible to detectDanger (echo `cat > /tmp/x` still runs). Only '
|
|
321
|
+
// and " are literal regions.
|
|
322
|
+
out += quote === "`" ? ch : " "
|
|
323
|
+
} else if (ch === "'" || ch === '"' || ch === "`") {
|
|
324
|
+
quote = ch
|
|
325
|
+
out += " "
|
|
326
|
+
} else {
|
|
327
|
+
out += ch
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Whether a single command segment is destructive — ALWAYS FALSE (deliberate).
|
|
335
|
+
*
|
|
336
|
+
* 决策(2026-08):文本拦截对恶意模型是安全剧场——空白变体/heredoc/node -e/写脚本执行
|
|
337
|
+
* 都能绕过,拦住的只有正常操作(如清理临时目录、rm node_modules 重装)。
|
|
338
|
+
* 真实防线在工具审批层(autoApprove)与快照兜底(gitGuardSnapshot / checkpoint auto-snapshot),
|
|
339
|
+
* 与 env 过滤、git 破坏操作"快照后放行、永不拦截"同一哲学。
|
|
340
|
+
* 项目工具自带确认门(如 thin5 scripts/db.mjs --write/--danger)不应被双重拦截。
|
|
341
|
+
*/
|
|
342
|
+
export function isDestructiveCommand() {
|
|
343
|
+
return false
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 危险命令识别(只标注、不拦截)——参考 kimi-code apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts
|
|
348
|
+
* DANGER_PATTERNS。定位:给审批中的人打红色警告标签,提升决策信息,不是机器防线。
|
|
349
|
+
* 拦截无用(可绕过),标注有用(人看到了才知道该多看一眼)。
|
|
350
|
+
*/
|
|
351
|
+
const DANGER_PATTERNS = [
|
|
352
|
+
{ pattern: /\brm\s+(-[a-zA-Z]*[rRfF][a-zA-Z]*|--recursive|--force)/i, label: "recursive delete" },
|
|
353
|
+
{ pattern: /\bsudo\b/i, label: "sudo" },
|
|
354
|
+
{ pattern: /\b(curl|wget)\b[^|]*\|\s*(sh|bash|zsh)\b/i, label: "pipe to shell" },
|
|
355
|
+
{ pattern: /\bdd\b[^|]*\bof=/i, label: "dd write" },
|
|
356
|
+
{ pattern: /\bmkfs\b/i, label: "mkfs" },
|
|
357
|
+
{ pattern: />\s*\/dev\/(sd|nvme|disk|hd)/i, label: "write to raw device" },
|
|
358
|
+
{ pattern: /\bchmod\s+(?:-[rR]\s+)?777\b/i, label: "chmod 777" },
|
|
359
|
+
{ pattern: /:\(\)\s*\{\s*:\|:&\s*\}/i, label: "fork bomb" },
|
|
360
|
+
]
|
|
361
|
+
|
|
362
|
+
/** 返回危险标注 label(如 "recursive delete");无危险返回 undefined。
|
|
363
|
+
* 引号感知:引号(单/双)内的内容清空后再检测——commit message、echo 文本等
|
|
364
|
+
* 纯文本不误标;危险命令(rm -rf "$dir")的参数在引号外,仍命中。
|
|
365
|
+
* 反引号内容保留(命令替换会执行)。 */
|
|
366
|
+
export function detectDanger(command) {
|
|
367
|
+
const s = blankQuoted(String(command ?? ""))
|
|
368
|
+
for (const { pattern, label } of DANGER_PATTERNS) {
|
|
369
|
+
if (pattern.test(s)) return label
|
|
370
|
+
}
|
|
371
|
+
return undefined
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
/** Glob dialect (TOOLS.md §17) lives in glob-dialect.mjs — single authority for
|
|
376
|
+
* brace expansion / exclusion / explicit syntax errors (extracted 2026-09-06 —
|
|
377
|
+
* advisor #5: shared.mjs exceeded 500 lines). Re-exported so existing importers
|
|
378
|
+
* (system.mjs tools, ls filter, tests) keep their import paths unchanged. */
|
|
379
|
+
export {
|
|
380
|
+
globToRegex,
|
|
381
|
+
splitGlobPatterns,
|
|
382
|
+
compileGlobMatchers,
|
|
383
|
+
GLOB_EXTGLOB_ERROR,
|
|
384
|
+
GLOB_EMPTY_BRACE_ERROR,
|
|
385
|
+
GLOB_UNCLOSED_BRACE_ERROR,
|
|
386
|
+
GLOB_NESTED_BRACE_ERROR,
|
|
387
|
+
} from "./glob-dialect.mjs"
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
/** Decode a numeric HTML entity to its code point — invalid/out-of-range
|
|
391
|
+
* values (e.g. �) must not throw RangeError; keep the source
|
|
392
|
+
* text as-is (display-only residue is acceptable). */
|
|
393
|
+
function decodeNumericEntity(_, digits) {
|
|
394
|
+
const n = Number(digits)
|
|
395
|
+
return Number.isSafeInteger(n) && n <= 0x10ffff ? String.fromCodePoint(n) : _
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Strip HTML tags. KNOWN LIMITATION (accepted): the `/<[^>]+>/g` regex treats
|
|
399
|
+
* the first `>` (or a `<` inside an attribute value) as the tag boundary —
|
|
400
|
+
* `<img alt="a > b">` truncates the match and leaves text residue. A full
|
|
401
|
+
* HTML parser is out of scope; real-world HTML with angle brackets in
|
|
402
|
+
* attributes is rare and the residue is display-only (never parsed). */
|
|
403
|
+
export function stripTags(html) {
|
|
404
|
+
return html
|
|
405
|
+
.replace(/<[^>]+>/g, "")
|
|
406
|
+
.replace(/�*(\d+);/g, (m, n) => decodeNumericEntity(m, n))
|
|
407
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (m, h) => decodeNumericEntity(m, parseInt(h, 16)))
|
|
408
|
+
.replace(/ | /g, " ")
|
|
409
|
+
.replace(/</g, "<")
|
|
410
|
+
.replace(/>/g, ">")
|
|
411
|
+
.replace(/"/g, '"')
|
|
412
|
+
.replace(/&/g, "&")
|
|
413
|
+
.replace(/\s+/g, " ")
|
|
414
|
+
.trim()
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** HTML → plain text: strip scripts/styles, newline block tags, strip tags, decode entities, compress blank lines */
|
|
418
|
+
export function htmlToText(html) {
|
|
419
|
+
return html
|
|
420
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
421
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
422
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, "")
|
|
423
|
+
.replace(/<\/(p|div|li|ul|ol|h[1-6]|tr|table|section|article|header|footer|blockquote|pre)>/gi, "\n")
|
|
424
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
425
|
+
.replace(/<li[^>]*>/gi, "- ")
|
|
426
|
+
.replace(/<[^>]+>/g, "")
|
|
427
|
+
.replace(/�*(\d+);/g, (m, n) => decodeNumericEntity(m, n))
|
|
428
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (m, h) => decodeNumericEntity(m, parseInt(h, 16)))
|
|
429
|
+
.replace(/ | /g, " ")
|
|
430
|
+
.replace(/</g, "<")
|
|
431
|
+
.replace(/>/g, ">")
|
|
432
|
+
.replace(/"/g, '"')
|
|
433
|
+
.replace(/&/g, "&") // & must be decoded last, otherwise &lt; gets double-decoded to <
|
|
434
|
+
.replace(/[ \t]+/g, " ")
|
|
435
|
+
.replace(/\n\s*\n\s*\n+/g, "\n\n")
|
|
436
|
+
.trim()
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** git 失败消息构造单点(#55 fail-closed——`runGit` / `runGitRaw` 两读取面同款):形态 =
|
|
440
|
+
* `git <args…> failed: <stderr 首行> (cwd: <绝对 cwd>)`;**非仓**失败(退出码 128 ∧ stderr 首行含
|
|
441
|
+
* `not a git repository`)尾附 workdir 指引;spawn 失败(无 stderr)取 `e.message` 首行——**禁空尾**。 */
|
|
442
|
+
export function gitFailureMessage(e, cmdArgs, cwd) {
|
|
443
|
+
const stderr = String(e?.stderr ?? "").trim()
|
|
444
|
+
const first = stderr.split("\n")[0].trim() || String(e?.message || e?.code || "unknown error").split("\n")[0]
|
|
445
|
+
const notRepo = e?.status === 128 && first.includes("not a git repository")
|
|
446
|
+
// cwd 缺省(无任何现调用点——防失败路径自戕):`execFileSync` 用进程 cwd ⇒ 消息同源取之
|
|
447
|
+
return `git ${cmdArgs.join(" ")} failed: ${first} (cwd: ${resolve(cwd ?? ".")})${notRepo ? " — pass workdir to run git inside a repository" : ""}`
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Execute a git command. maxBuffer 10MB prevents large diff/log overflow; on overflow, returns truncated partial output rather than empty.
|
|
451
|
+
* config: optional array of `-c key=value` overrides (e.g. ["http.proxy=http://10.2.2.112:3128"]) —
|
|
452
|
+
* inserted verbatim after `git`, so network actions (push/fetch/pull/ls-remote) can route through a proxy. */
|
|
453
|
+
export function runGit(cwd, cmdArgs, config = []) {
|
|
454
|
+
try {
|
|
455
|
+
return execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
|
|
456
|
+
} catch (e) {
|
|
457
|
+
// maxBuffer overflow: e.stdout contains partial collected output — return it
|
|
458
|
+
// (callers show "(truncated)"-style tails). Every OTHER failure (non-git repo,
|
|
459
|
+
// permission, bad command) THROWS (#55): a swallowed "" used to render as
|
|
460
|
+
// "(clean — no changes)" / "(no commits)" — indistinguishable from a real
|
|
461
|
+
// empty result. dispatch renders the throw as `Error: …` with ok:false (model-visible).
|
|
462
|
+
if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && e.stdout) {
|
|
463
|
+
return String(e.stdout).trim().replace(/\r/g, "").split("\n").slice(0, 200).join("\n")
|
|
464
|
+
}
|
|
465
|
+
throw new Error(gitFailureMessage(e, cmdArgs, cwd))
|
|
466
|
+
}
|
|
467
|
+
}
|
package/tools/tree.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tree.mjs — directory tree tool (parity with thinworker `repomap`).
|
|
3
|
+
* Renders a repo's directory tree (default depth 3), skipping dotfiles,
|
|
4
|
+
* build/vendor dirs and binary files, so the model can see which modules
|
|
5
|
+
* exist without shelling out to `tree`/`find`.
|
|
6
|
+
*/
|
|
7
|
+
import { DESC, truncate, resolveInCwd } from "./shared.mjs"
|
|
8
|
+
import { readdir, stat } from "node:fs/promises"
|
|
9
|
+
import { join, basename, extname } from "node:path"
|
|
10
|
+
|
|
11
|
+
const MAX_ENTRIES = 200
|
|
12
|
+
const DEFAULT_DEPTH = 3
|
|
13
|
+
const SKIP_DIRS = new Set(["node_modules", "bin", "obj", "dist", "build", "coverage", "turbo", ".git", ".thincoder", ".vs", ".venv", "__pycache__", ".idea"])
|
|
14
|
+
const BINARY_EXTS = new Set([".exe", ".dll", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".docx", ".xlsx", ".pptx", ".zip", ".7z", ".mp3", ".mp4", ".woff", ".woff2", ".ico"])
|
|
15
|
+
|
|
16
|
+
// ─── cwd 归一注入缝(#56——「cwd 归一开关按端注入」,形态参 §2.13.5 注入缝)─────────
|
|
17
|
+
/**
|
|
18
|
+
* 根路径解析注入位(**缺省不覆盖** = 核内 `resolveInCwd`——CLI 语义(realpath 归一),
|
|
19
|
+
* 零行为变;端装配层可覆盖为本端归一形态(如 join 式解析)。核内零端名分支(契约 5——
|
|
20
|
+
* 本档只认 `resolve` 函数名)。
|
|
21
|
+
* 契约:`resolve(ctx, p) → string`(p 缺省为 ".")。
|
|
22
|
+
*/
|
|
23
|
+
let injectedResolve = null
|
|
24
|
+
export function configureTreeResolve(impl) {
|
|
25
|
+
injectedResolve = impl && typeof impl.resolve === "function" ? impl.resolve : null
|
|
26
|
+
}
|
|
27
|
+
/** 撤销注入(测试与端装配生命周期用——缺省态 = 核内 resolveInCwd)。 */
|
|
28
|
+
export function resetTreeResolve() { injectedResolve = null }
|
|
29
|
+
|
|
30
|
+
export const treeTool = {
|
|
31
|
+
name: "tree",
|
|
32
|
+
description: DESC("tree"),
|
|
33
|
+
parameters: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
path: { type: "string", description: "Root directory (default cwd)" },
|
|
37
|
+
depth: { type: "integer", description: `Tree depth (default ${DEFAULT_DEPTH}, max 6)` },
|
|
38
|
+
},
|
|
39
|
+
required: [],
|
|
40
|
+
},
|
|
41
|
+
readonly: true,
|
|
42
|
+
async execute(args, ctx) {
|
|
43
|
+
// cwd 归一开关(#56):注入面命中 ⇒ 端侧解析;缺省 = 核内 resolveInCwd(CLI 语义)。
|
|
44
|
+
const root = (injectedResolve ?? resolveInCwd)(ctx, args.path ?? ".")
|
|
45
|
+
let st
|
|
46
|
+
try { st = await stat(root) } catch { return `Error: directory not found: ${args.path ?? "."}` }
|
|
47
|
+
if (!st.isDirectory()) return `Error: not a directory: ${args.path ?? "."}`
|
|
48
|
+
const d = Number(args.depth)
|
|
49
|
+
const maxDepth = Number.isInteger(d) && d > 0 ? Math.min(d, 6) : DEFAULT_DEPTH
|
|
50
|
+
const lines = [basename(root) + "/"]
|
|
51
|
+
const state = { count: 1, capped: false }
|
|
52
|
+
await walk(root, 0, maxDepth, "", lines, state)
|
|
53
|
+
return truncate(lines.join("\n"))
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function walk(dir, depth, maxDepth, prefix, lines, state) {
|
|
58
|
+
if (state.capped) return
|
|
59
|
+
let entries
|
|
60
|
+
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
|
|
61
|
+
const items = []
|
|
62
|
+
for (const e of entries) {
|
|
63
|
+
if (e.name.startsWith(".")) continue // dotfiles + dotdirs
|
|
64
|
+
const isDir = e.isDirectory()
|
|
65
|
+
if (isDir) { if (SKIP_DIRS.has(e.name)) continue; items.push({ name: e.name, isDir: true }) }
|
|
66
|
+
else if (!BINARY_EXTS.has(extname(e.name).toLowerCase())) items.push({ name: e.name, isDir: false })
|
|
67
|
+
}
|
|
68
|
+
items.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1))
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < items.length; i++) {
|
|
71
|
+
if (state.capped) return
|
|
72
|
+
if (state.count >= MAX_ENTRIES) { state.capped = true; lines.push(prefix + "…(更多项已省略)"); return }
|
|
73
|
+
const { name, isDir } = items[i]
|
|
74
|
+
const isLast = i === items.length - 1
|
|
75
|
+
lines.push(prefix + (isLast ? "└── " : "├── ") + (isDir ? name + "/" : name))
|
|
76
|
+
state.count++
|
|
77
|
+
if (isDir && depth + 1 < maxDepth) {
|
|
78
|
+
await walk(join(dir, name), depth + 1, maxDepth, prefix + (isLast ? " " : "│ "), lines, state)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|