dsh-token-budget-tools 0.1.0

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 ADDED
@@ -0,0 +1,66 @@
1
+ # dsh-token-budget-tools
2
+
3
+ Token 预算工具插件(v0.1.0):估算 token、按预算分块长文本、按标题提取章节。估算口径与 `dsh-text-stats` 完全一致(CJK 约 0.6 token/字,ASCII 约 0.25/字符)。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npm install dsh-token-budget-tools
9
+ ```
10
+
11
+ ## 工具
12
+
13
+ ### `count_tokens`
14
+
15
+ 估算文本 token 数。返回估算值、字符数、CJK 占比与口径说明。`text` / `file` 二选一。
16
+
17
+ ### `split_text`
18
+
19
+ 按 token 预算分块长文本:
20
+
21
+ | 参数 | 类型 | 说明 |
22
+ |------|------|------|
23
+ | `budget` | number | 每块预算(50–100000,默认 3000),过小自动钳制到下限 |
24
+ | `text` / `file` | string | 源内容二选一 |
25
+
26
+ **分块策略(逐级降级)**:优先按段落(空行)边界 → 段落超预算退化按行 → 行超预算按句(`。!?.!?`)→ 无标点硬切。保证每块估算不超过预算(硬切留 1 token 头寸抵消浮点误差)。
27
+
28
+ 单次最多返回 200 块,超过则要求调大 budget。输出为带编号的分块预览(每块标注估算 tokens + 前 60 字符预览)。
29
+
30
+ ### `extract_section`
31
+
32
+ 从 Markdown 中按标题操作:
33
+
34
+ - **不带 `title`**:返回全部标题目录(含层级缩进)
35
+ - **带 `title`**:提取该标题到下一个同级/更高级标题之间的正文,附 token 估算与行号范围
36
+
37
+ | 参数 | 类型 | 说明 |
38
+ |------|------|------|
39
+ | `title` | string | 标题模糊匹配(包含即命中,大小写不敏感) |
40
+ | `level` | number | 可选,限定标题级别(1-6) |
41
+
42
+ 代码块内的 `#` 不计为标题。
43
+
44
+ ## 典型工作流
45
+
46
+ 1. `extract_section` 不带 title 看目录 → 2. 提取目标章节 → 3. 章节过长时用 `split_text` 按预算切块喂给模型。
47
+
48
+ ## 限制
49
+
50
+ - 单次输入上限 50 万字符
51
+ - 文件路径必须在工作目录内
52
+ - 零 npm 依赖(估算为内置启发式,非真实 tokenizer)
53
+
54
+ ## 测试
55
+
56
+ ```bash
57
+ npm test
58
+ ```
59
+
60
+ 覆盖估算口径、段落/行/句/硬切四级降级、预算钳制、块数上限、标题目录、章节提取边界(不含同级/上级内容)、代码块跳过、路径越界等 31 项断言。
61
+
62
+ ## Roadmap(v0.2 候选)
63
+
64
+ - overlap 重叠窗口(本版本参数预留未实现)
65
+ - 真实 tokenizer 口径(tiktoken)可选
66
+ - 按预算从文档中挑选最有信息量的章节组合
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: token-budget
3
+ name: "dsh-token-budget"
package/index.js ADDED
@@ -0,0 +1,351 @@
1
+ import { defineTool } from "@deepseek-ai/dsh-tools"
2
+ import { readFileSync, existsSync, statSync } from "node:fs"
3
+ import { resolve, isAbsolute, sep } from "node:path"
4
+
5
+ export const name = "token-budget"
6
+ export const inject = ["tools"]
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // 估算口径与 dsh-text-stats 保持一致
10
+ // ---------------------------------------------------------------------------
11
+ const TOKENS_PER_CJK = 0.6 // CJK 字符约 0.6 token/字
12
+ const TOKENS_PER_OTHER = 0.25 // ASCII 密集文本约 4 字符/token
13
+ const MAX_INPUT_CHARS = 500_000 // 单次输入上限(50 万字符)
14
+ const MIN_BUDGET = 50 // 最小分块预算(token)
15
+ const MAX_BUDGET = 100_000 // 最大分块预算
16
+ const MAX_CHUNKS = 200 // 最多返回 200 块
17
+ const OUTPUT_LIMIT = 8000 // 输出截断阈值(字符)
18
+
19
+ function isCjk(codePoint) {
20
+ return (
21
+ (codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
22
+ (codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
23
+ (codePoint >= 0xf900 && codePoint <= 0xfaff)
24
+ )
25
+ }
26
+
27
+ /** 加权 token 估算(与 text-stats 同款) */
28
+ export function estimateTokens(text) {
29
+ let tokens = 0
30
+ for (const ch of text) {
31
+ tokens += isCjk(ch.codePointAt(0)) ? TOKENS_PER_CJK : TOKENS_PER_OTHER
32
+ }
33
+ return Math.ceil(tokens)
34
+ }
35
+
36
+ function safePath(p) {
37
+ if (!p || typeof p !== "string") throw new Error("path 参数必须是字符串")
38
+ const abs = isAbsolute(p) ? resolve(p) : resolve(process.cwd(), p)
39
+ const root = resolve(process.cwd())
40
+ if (abs !== root && !abs.startsWith(root + sep)) {
41
+ throw new Error(`拒绝访问:路径 "${p}" 在工作目录之外。`)
42
+ }
43
+ return abs
44
+ }
45
+
46
+ function readTarget(p) {
47
+ const abs = safePath(p)
48
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${abs}`)
49
+ const st = statSync(abs)
50
+ if (!st.isFile()) throw new Error(`目标不是普通文件: ${abs}`)
51
+ const content = readFileSync(abs, "utf8")
52
+ if (content.length > MAX_INPUT_CHARS) {
53
+ throw new Error(`文本过长(${content.length} 字符,上限 ${MAX_INPUT_CHARS})。`)
54
+ }
55
+ return content
56
+ }
57
+
58
+ function getSource(args) {
59
+ if (args.text != null && args.text !== "") {
60
+ const t = String(args.text)
61
+ if (t.length > MAX_INPUT_CHARS) throw new Error(`文本过长(${t.length} 字符,上限 ${MAX_INPUT_CHARS})。`)
62
+ return t
63
+ }
64
+ if (args.file) return readTarget(args.file)
65
+ throw new Error("请提供 text 或 file 之一。")
66
+ }
67
+
68
+ function truncateOut(text) {
69
+ if (text.length <= OUTPUT_LIMIT) return text
70
+ return text.slice(0, OUTPUT_LIMIT) + `\n\n[输出已截断,完整长度 ${text.length} 字符。建议减小 budget 或用 file 参数分块处理]`
71
+ }
72
+
73
+ /**
74
+ * 按段落边界切块:优先在空行(段落)处切,段落仍超预算时退化为按行切,
75
+ * 单行仍超预算时按句子(。!?.!?) 切,最后硬切。
76
+ * 返回 [{ text, est }],每块 est 不超过 budget(硬切保证)。
77
+ */
78
+ export function splitByBudget(text, budget, overlap) {
79
+ const paragraphs = text.split(/\n\s*\n/)
80
+ const chunks = []
81
+ let current = []
82
+ let currentTokens = 0
83
+ let lineBuf = []
84
+ let lineTok = 0
85
+
86
+ const flush = () => {
87
+ if (current.length > 0) {
88
+ const t = current.join("\n\n")
89
+ chunks.push({ text: t, est: estimateTokens(t) })
90
+ current = []
91
+ currentTokens = 0
92
+ }
93
+ }
94
+
95
+ for (const para of paragraphs) {
96
+ const p = para.trim()
97
+ if (p === "") continue
98
+ const pTokens = estimateTokens(p)
99
+
100
+ if (pTokens > budget) {
101
+ // 段落本身超预算:按行再切
102
+ flush()
103
+ const lines = p.split("\n")
104
+ for (const line of lines) {
105
+ const lt = estimateTokens(line)
106
+ if (lt > budget) {
107
+ // 单行超预算:按句子切,句子仍超则硬切
108
+ flushLineBuf()
109
+ for (const piece of hardSplit(line, budget)) {
110
+ chunks.push({ text: piece, est: estimateTokens(piece) })
111
+ }
112
+ continue
113
+ }
114
+ if (lineTok + lt > budget && lineBuf.length > 0) {
115
+ flushLineBuf()
116
+ }
117
+ lineBuf.push(line)
118
+ lineTok += lt
119
+ }
120
+ flushLineBuf()
121
+ continue
122
+ }
123
+
124
+ if (currentTokens + pTokens > budget && current.length > 0) {
125
+ flush()
126
+ }
127
+ current.push(p)
128
+ currentTokens += pTokens
129
+ }
130
+ flush()
131
+ return chunks
132
+
133
+ function flushLineBuf() {
134
+ if (lineBuf.length > 0) {
135
+ const t = lineBuf.join("\n")
136
+ chunks.push({ text: t, est: estimateTokens(t) })
137
+ lineBuf = []
138
+ lineTok = 0
139
+ }
140
+ }
141
+ // lineBuf/lineTok 在函数顶层声明,供降级按行切分使用
142
+
143
+ function* hardSplit(line, b) {
144
+ // 按句子边界切,超预算句子退化为 hardSlice 硬切
145
+ const sentences = line.split(/(?<=[。!?.!?)])\s*/)
146
+ let buf = ""
147
+ let bufTok = 0
148
+ for (const s of sentences) {
149
+ const st = estimateTokens(s)
150
+ if (st > b) {
151
+ if (buf) { yield buf; buf = ""; bufTok = 0 }
152
+ yield* hardSlice(s, b)
153
+ continue
154
+ }
155
+ if (bufTok + st > b && buf) {
156
+ yield buf
157
+ buf = ""
158
+ bufTok = 0
159
+ }
160
+ buf += s
161
+ bufTok += st
162
+ }
163
+ if (buf) yield buf
164
+ }
165
+ }
166
+
167
+ // 硬切辅助:按估算 token 预算切字符串
168
+ function* hardSlice(s, budget) {
169
+ let start = 0
170
+ while (start < s.length) {
171
+ let acc = 0
172
+ let end = start
173
+ for (let i = start; i < s.length; i++) {
174
+ acc += isCjk(s.codePointAt(i)) ? TOKENS_PER_CJK : TOKENS_PER_OTHER
175
+ end = i + 1
176
+ // 留 1 token 头寸:避免浮点误差导致重估 ceil 超出预算
177
+ if (acc >= budget - 1) break
178
+ }
179
+ if (end === start) break
180
+ yield s.slice(start, end)
181
+ start = end
182
+ }
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Markdown 标题解析
187
+ // ---------------------------------------------------------------------------
188
+ /** 解析 markdown 标题结构:[{ level, title, start, end }],行号从 0 计 */
189
+ export function parseHeadings(text) {
190
+ const lines = text.split("\n")
191
+ const headings = []
192
+ let inCode = false
193
+ for (let i = 0; i < lines.length; i++) {
194
+ const line = lines[i]
195
+ if (/^\s*```/.test(line)) {
196
+ inCode = !inCode
197
+ continue
198
+ }
199
+ if (inCode) continue
200
+ const m = line.match(/^(#{1,6})\s+(.*)$/)
201
+ if (m) {
202
+ headings.push({ level: m[1].length, title: m[2].trim(), line: i })
203
+ }
204
+ }
205
+ return headings
206
+ }
207
+
208
+ /** 提取某个标题到下一个同级或更高级标题之间的内容 */
209
+ export function extractSection(text, headings, index) {
210
+ const h = headings[index]
211
+ const lines = text.split("\n")
212
+ let end = lines.length
213
+ for (let j = index + 1; j < headings.length; j++) {
214
+ if (headings[j].level <= h.level) {
215
+ end = headings[j].line
216
+ break
217
+ }
218
+ }
219
+ const body = lines.slice(h.line + 1, end).join("\n").trim()
220
+ return { title: h.title, body, startLine: h.line + 1, endLine: end - 1 }
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // 工具注册
225
+ // ---------------------------------------------------------------------------
226
+ export function apply(ctx) {
227
+ // 1. count_tokens ----------------------------------------------------------
228
+ ctx.tools.register(
229
+ defineTool({
230
+ name: "count_tokens",
231
+ description:
232
+ "估算文本 token 数(加权:CJK 约 0.6 token/字,ASCII 约 0.25/字符,口径与 dsh-text-stats 一致)。返回估算值、字符数与 CJK 占比。",
233
+ parameters: {
234
+ text: { type: "string", description: "待估算文本(与 file 二选一)" },
235
+ file: { type: "string", description: "工作目录内文件路径(与 text 二选一)" },
236
+ },
237
+ output: {
238
+ schema: { type: "string" },
239
+ render: (_args, value) => [{ type: "text", text: value }],
240
+ },
241
+ async execute(args) {
242
+ const text = getSource(args)
243
+ const est = estimateTokens(text)
244
+ const total = [...text].length
245
+ const cjk = [...text].filter((ch) => isCjk(ch.codePointAt(0))).length
246
+ const pct = total > 0 ? ((cjk / total) * 100).toFixed(1) : "0.0"
247
+ return `估算 tokens: ${est}\n字符数: ${total}(CJK ${cjk} 个,占 ${pct}%)\n口径: CJK 0.6 token/字,其他 0.25/字符`
248
+ },
249
+ })
250
+ )
251
+
252
+ // 2. split_text ------------------------------------------------------------
253
+ ctx.tools.register(
254
+ defineTool({
255
+ name: "split_text",
256
+ description:
257
+ "把长文本按 token 预算切块:优先按段落(空行)边界,段落超预算退化到按行、按句,最后硬切。返回分块列表(每块带估算 token 数)。配合 extract_section 可先抽章节再分块。",
258
+ parameters: {
259
+ budget: {
260
+ type: "number",
261
+ description: "每块 token 预算(200-100000,默认 3000)",
262
+ },
263
+ overlap: {
264
+ type: "number",
265
+ description: "预留参数,本版本忽略",
266
+ },
267
+ text: { type: "string", description: "待分块文本(与 file 二选一)" },
268
+ file: { type: "string", description: "工作目录内文件路径(与 text 二选一)" },
269
+ },
270
+ output: {
271
+ schema: { type: "string" },
272
+ render: (_args, value) => [{ type: "text", text: value }],
273
+ },
274
+ async execute(args) {
275
+ const text = getSource(args)
276
+ const budget = Math.min(Math.max(Number(args.budget ?? 3000), MIN_BUDGET), MAX_BUDGET)
277
+ const chunks = splitByBudget(text, budget)
278
+ if (chunks.length > MAX_CHUNKS) {
279
+ throw new Error(
280
+ `按预算 ${budget} 切出 ${chunks.length} 块,超过单次上限 ${MAX_CHUNKS}。请调大 budget。`
281
+ )
282
+ }
283
+ const head = `分块完成: 共 ${chunks.length} 块(预算 ${budget} token/块,估算口径 CJK 0.6/其他 0.25)\n`
284
+ const lines = chunks.map((c, i) => {
285
+ const preview = c.text.slice(0, 60).replace(/\n/g, "⏎")
286
+ return `${i + 1}. [~${c.est} tokens] ${preview}${c.text.length > 60 ? "…" : ""}`
287
+ })
288
+ return head + lines.join("\n")
289
+ },
290
+ })
291
+ )
292
+
293
+ // 3. extract_section -------------------------------------------------------
294
+ ctx.tools.register(
295
+ defineTool({
296
+ name: "extract_section",
297
+ description:
298
+ "从 Markdown 文档中按标题提取章节:返回该标题到下一个同级/更高级标题之间的内容(含 token 估算)。title 支持模糊匹配(包含即命中,取第一个);不传 title 时列出全部标题目录。",
299
+ parameters: {
300
+ title: {
301
+ type: "string",
302
+ description: "标题文本(模糊包含匹配,大小写不敏感)",
303
+ },
304
+ level: {
305
+ type: "number",
306
+ description: "可选,限定标题级别(1-6),配合 title 精确化匹配",
307
+ },
308
+ text: { type: "string", description: "Markdown 文本(与 file 二选一)" },
309
+ file: { type: "string", description: "工作目录内文件路径(与 text 二选一)" },
310
+ },
311
+ output: {
312
+ schema: { type: "string" },
313
+ render: (_args, value) => [{ type: "text", text: value }],
314
+ },
315
+ async execute(args) {
316
+ const text = getSource(args)
317
+ const headings = parseHeadings(text)
318
+ if (headings.length === 0) {
319
+ throw new Error("未找到任何 Markdown 标题(# ~ ######)。确认输入是 Markdown 且标题顶格书写。")
320
+ }
321
+
322
+ // 不带 title:返回目录
323
+ if (!args.title) {
324
+ const toc = headings.map((h) => `${" ".repeat(h.level - 1)}- [H${h.level}] ${h.title}`)
325
+ return `标题目录(共 ${headings.length} 个):\n${truncateOut(toc.join("\n"))}`
326
+ }
327
+
328
+ const titleLower = String(args.title).toLowerCase()
329
+ let idx = headings.findIndex(
330
+ (h) =>
331
+ h.title.toLowerCase().includes(titleLower) &&
332
+ (args.level == null || h.level === Number(args.level))
333
+ )
334
+ if (idx === -1) {
335
+ const titles = headings.map((h) => h.title).slice(0, 20).join(" | ")
336
+ throw new Error(
337
+ `未找到标题包含 "${args.title}"${args.level != null ? `(级别 ${args.level})` : ""} 的章节。现有标题: ${titles}`
338
+ )
339
+ }
340
+
341
+ const sec = extractSection(text, headings, idx)
342
+ const est = estimateTokens(sec.body)
343
+ return (
344
+ `章节: ${sec.title}(H${headings[idx].level},第 ${sec.startLine + 1}~${sec.endLine + 1} 行)\n` +
345
+ `估算 tokens: ${est}\n` +
346
+ `\n${truncateOut(sec.body || "(本章无正文内容)")}`
347
+ )
348
+ },
349
+ })
350
+ )
351
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "dsh-token-budget-tools",
3
+ "version": "0.1.0",
4
+ "description": "Token budget tools for DeepSeek Harness: split long text into chunks within a token budget (paragraph-aware), extract sections by heading, count estimated tokens. Estimation matches dsh-text-stats.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "files": [
8
+ "index.js",
9
+ "cordis.patch.yml",
10
+ "README.md"
11
+ ],
12
+ "keywords": [
13
+ "dsh",
14
+ "plugin",
15
+ "token",
16
+ "chunking",
17
+ "context"
18
+ ],
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.2"
22
+ },
23
+ "scripts": {
24
+ "test": "node test-plugin.js",
25
+ "prepublishOnly": "npm test"
26
+ },
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ }
31
+ },
32
+ "private": false,
33
+ "publishConfig": {
34
+ "registry": "https://registry.npmjs.org/",
35
+ "access": "public",
36
+ "tag": "latest"
37
+ }
38
+ }