dsh-context-toolkit 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +129 -2
- package/README.zh.md +130 -0
- package/client.js +334 -0
- package/cordis.patch.yml +14 -0
- package/icon.svg +5 -0
- package/index.js +1250 -0
- package/lib/runner.js +265 -0
- package/lib/store.js +243 -0
- package/locale/en.json +83 -0
- package/locale/zh.json +83 -0
- package/package.json +59 -4
package/index.js
ADDED
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-context-toolkit — a DSH bundle that keeps bulk output out of the
|
|
3
|
+
* conversation.
|
|
4
|
+
*
|
|
5
|
+
* What it does
|
|
6
|
+
* ------------
|
|
7
|
+
* Three things, all in-process:
|
|
8
|
+
*
|
|
9
|
+
* 1. ctk_* tools run commands and scripts in a child process and return only
|
|
10
|
+
* the conclusion, not the bytes they produced;
|
|
11
|
+
* 2. a routing gate refuses the fetches and whole-file reads that would dump
|
|
12
|
+
* raw output into the transcript, and points at the cheap alternative;
|
|
13
|
+
* 3. a local FTS5 knowledge base files large material so it can be retrieved
|
|
14
|
+
* by section later instead of being read whole.
|
|
15
|
+
*
|
|
16
|
+
* Contract notes: apply() is synchronous (the host requires it), every knob is
|
|
17
|
+
* a flat volatile field so the settings page can write it, and the plugin keeps
|
|
18
|
+
* no third-party runtime dependencies.
|
|
19
|
+
*/
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { existsSync, statSync, readdirSync } from 'node:fs'
|
|
22
|
+
import { join, isAbsolute, resolve, basename } from 'node:path'
|
|
23
|
+
import { runProcess, availableRuntimes, probeRuntimes, shellPlan, scratchDir } from './lib/runner.js'
|
|
24
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
25
|
+
import { openStore } from './lib/store.js'
|
|
26
|
+
|
|
27
|
+
export const name = 'dsh-context-toolkit'
|
|
28
|
+
export const inject = ['tools', 'systemPrompt']
|
|
29
|
+
|
|
30
|
+
const z = await loadSchemastery()
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Load a schemastery that actually supports the chain this plugin's schema uses.
|
|
34
|
+
*
|
|
35
|
+
* Resolution order matters more than it looks: this machine has three copies of
|
|
36
|
+
* the package (a stale one in the user home, plus one per profile), and the
|
|
37
|
+
* stale copy silently lacks `.volatile()` on its chain. Taking "the first anchor
|
|
38
|
+
* that resolves" therefore produced a constructor with no volatile support, and
|
|
39
|
+
* the schema died at module load. So every candidate is capability-tested — the
|
|
40
|
+
* exact chain the schema performs — and only a copy that passes is returned.
|
|
41
|
+
*/
|
|
42
|
+
async function loadSchemastery() {
|
|
43
|
+
const { createRequire } = await import('node:module')
|
|
44
|
+
const candidates = []
|
|
45
|
+
const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
46
|
+
|
|
47
|
+
// The active profile first: its copy is the one the host itself resolves.
|
|
48
|
+
const active = process.env.DSH_PROFILE
|
|
49
|
+
if (active) candidates.push(join(dshHome, 'profiles', active, 'package.json'))
|
|
50
|
+
candidates.push(join(dshHome, 'profiles', 'desktop', 'package.json'))
|
|
51
|
+
|
|
52
|
+
// Then any other profile, so a renamed profile still works.
|
|
53
|
+
try {
|
|
54
|
+
for (const profile of readdirSync(join(dshHome, 'profiles'))) {
|
|
55
|
+
candidates.push(join(dshHome, 'profiles', profile, 'package.json'))
|
|
56
|
+
}
|
|
57
|
+
} catch { /* no profiles directory: the anchors below still apply */ }
|
|
58
|
+
|
|
59
|
+
candidates.push(import.meta.url)
|
|
60
|
+
|
|
61
|
+
const acceptsChain = (z) => {
|
|
62
|
+
if (typeof z !== 'function' && typeof z !== 'object') return false
|
|
63
|
+
try {
|
|
64
|
+
// The exact call the schema makes; a stale copy throws here.
|
|
65
|
+
z.string().default('').volatile().description('probe')
|
|
66
|
+
const node = z.object({ probe: z.boolean().default(true).volatile() })
|
|
67
|
+
return typeof node.toJSON === 'function'
|
|
68
|
+
} catch { return false }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const anchor of candidates) {
|
|
72
|
+
try {
|
|
73
|
+
const z = createRequire(anchor)('@deepseek-ai/schemastery')
|
|
74
|
+
if (acceptsChain(z)) return z
|
|
75
|
+
} catch { /* next candidate */ }
|
|
76
|
+
}
|
|
77
|
+
return shimSchemastery()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A tiny stand-in for schemastery, used only when the host package cannot be
|
|
82
|
+
* resolved (bare `node smoke.mjs` outside DSH). It supports exactly the surface
|
|
83
|
+
* this plugin's schema uses, so the offline suite can exercise real code paths.
|
|
84
|
+
*/
|
|
85
|
+
function shimSchemastery() {
|
|
86
|
+
const make = (meta) => {
|
|
87
|
+
const node = {
|
|
88
|
+
meta: meta || {},
|
|
89
|
+
type: undefined,
|
|
90
|
+
dict: undefined,
|
|
91
|
+
inner: undefined,
|
|
92
|
+
default(value) { this.meta.default = value; return this },
|
|
93
|
+
description(text) { this.meta.description = text; return this },
|
|
94
|
+
min(value) { this.meta.min = value; return this },
|
|
95
|
+
volatile() { this.meta.volatile = true; return this },
|
|
96
|
+
required() { this.meta.required = true; return this },
|
|
97
|
+
toJSON() { return { type: this.type, meta: this.meta, dict: this.dict } },
|
|
98
|
+
}
|
|
99
|
+
return node
|
|
100
|
+
}
|
|
101
|
+
const api = {
|
|
102
|
+
boolean: () => { const n = make(); n.type = 'boolean'; return n },
|
|
103
|
+
number: () => { const n = make(); n.type = 'number'; return n },
|
|
104
|
+
string: () => { const n = make(); n.type = 'string'; return n },
|
|
105
|
+
array: (inner) => { const n = make(); n.type = 'array'; n.inner = inner; return n },
|
|
106
|
+
object: (dict) => { const n = make(); n.type = 'object'; n.dict = dict; return n },
|
|
107
|
+
}
|
|
108
|
+
api.object.prototype = undefined
|
|
109
|
+
return api
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const Config = z.object({
|
|
113
|
+
storageDir: z.string().default('').volatile().description('知识库根目录;留空 = <DSH_HOME>/context-toolkit'),
|
|
114
|
+
routing: z.boolean().default(true).volatile().description('是否启用 tools/pre-execute 路由门禁'),
|
|
115
|
+
denyCurlWget: z.boolean().default(true).volatile().description('硬拦 curl/wget 这类洪水工具'),
|
|
116
|
+
bashNudgeMinCommandBytes: z.number().min(0).default(4096).volatile().description('长命令软引导阈值(字节)'),
|
|
117
|
+
maxReadBytesBeforeAsk: z.number().min(0).default(51200).volatile().description('大文件整读软引导阈值(字节)'),
|
|
118
|
+
trustedReadBasenames: z.array(z.string())
|
|
119
|
+
.default(['package.json', 'tsconfig.json', 'cordis.patch.yml', 'cordis.yml'])
|
|
120
|
+
.volatile()
|
|
121
|
+
.description('这些文件名不受大文件整读门禁限制(逗号分隔)'),
|
|
122
|
+
// Tool switches are FLAT top-level keys: the settings form addresses fields by
|
|
123
|
+
// a single path segment, and the host validates that segment against this
|
|
124
|
+
// schema's own keys. A nested object would make every save fail.
|
|
125
|
+
'tools.execute': z.boolean().default(true).volatile().description('注册 ctk_execute'),
|
|
126
|
+
'tools.executeFile': z.boolean().default(true).volatile().description('注册 ctk_execute_file'),
|
|
127
|
+
'tools.batchExecute': z.boolean().default(true).volatile().description('注册 ctk_batch_execute'),
|
|
128
|
+
'tools.index': z.boolean().default(true).volatile().description('注册 ctk_index'),
|
|
129
|
+
'tools.search': z.boolean().default(true).volatile().description('注册 ctk_search'),
|
|
130
|
+
'tools.fetchAndIndex': z.boolean().default(true).volatile().description('注册 ctk_fetch_and_index'),
|
|
131
|
+
'tools.purge': z.boolean().default(true).volatile().description('注册 ctk_purge'),
|
|
132
|
+
'tools.stats': z.boolean().default(true).volatile().description('注册 ctk_stats'),
|
|
133
|
+
'tools.doctor': z.boolean().default(true).volatile().description('注册 ctk_doctor'),
|
|
134
|
+
'tools.contextReport': z.boolean().default(true).volatile().description('注册 ctk_context_report 并挂载占用路由'),
|
|
135
|
+
compressNudgeTokens: z.number().min(0).default(120000).volatile().description('上下文占用提示阈值(tokens)'),
|
|
136
|
+
searchLimit: z.number().min(1).default(5).volatile().description('ctk_search 每次查询返回的片段数上限'),
|
|
137
|
+
snippetChars: z.number().min(100).default(900).volatile().description('ctk_search 每个片段的最大字符数'),
|
|
138
|
+
fetchMaxBytes: z.number().min(1024).default(2000000).volatile().description('ctk_fetch_and_index 单页最大抓取字节'),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const VOLATILE_READ = Symbol.for('cosmokit.volatile.read')
|
|
142
|
+
|
|
143
|
+
/** Read a live setting through the host's handle, falling back to the plain value. */
|
|
144
|
+
export function readVolatile(handle, fallback) {
|
|
145
|
+
if (handle === null || handle === undefined) return fallback
|
|
146
|
+
if (typeof handle === 'object') {
|
|
147
|
+
const reader = handle[VOLATILE_READ]
|
|
148
|
+
if (typeof reader === 'function') {
|
|
149
|
+
try { return reader.call(handle) } catch { /* fall through to get() */ }
|
|
150
|
+
}
|
|
151
|
+
if (typeof handle.get === 'function') {
|
|
152
|
+
try { return handle.get() } catch { /* fall back */ }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return handle
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function makeLogger(ctx) {
|
|
159
|
+
const sink = ctx && ctx.logger ? ctx.logger : console
|
|
160
|
+
const tag = '[' + name + '] '
|
|
161
|
+
return {
|
|
162
|
+
info: (m) => { try { sink.info(tag + m) } catch { /* ignore */ } },
|
|
163
|
+
warn: (m) => { try { sink.warn(tag + m) } catch { /* ignore */ } },
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function apply(ctx, rawConfig) {
|
|
168
|
+
const log = makeLogger(ctx)
|
|
169
|
+
const defaults = {
|
|
170
|
+
storageDir: '',
|
|
171
|
+
routing: true,
|
|
172
|
+
denyCurlWget: true,
|
|
173
|
+
bashNudgeMinCommandBytes: 4096,
|
|
174
|
+
maxReadBytesBeforeAsk: 51200,
|
|
175
|
+
trustedReadBasenames: ['package.json', 'tsconfig.json', 'cordis.patch.yml', 'cordis.yml'],
|
|
176
|
+
'tools.execute': true,
|
|
177
|
+
'tools.executeFile': true,
|
|
178
|
+
'tools.batchExecute': true,
|
|
179
|
+
'tools.index': true,
|
|
180
|
+
'tools.search': true,
|
|
181
|
+
'tools.fetchAndIndex': true,
|
|
182
|
+
'tools.purge': true,
|
|
183
|
+
'tools.stats': true,
|
|
184
|
+
'tools.doctor': true,
|
|
185
|
+
'tools.contextReport': true,
|
|
186
|
+
compressNudgeTokens: 120000,
|
|
187
|
+
searchLimit: 5,
|
|
188
|
+
snippetChars: 900,
|
|
189
|
+
fetchMaxBytes: 2000000,
|
|
190
|
+
}
|
|
191
|
+
const incoming = rawConfig || {}
|
|
192
|
+
const config = {
|
|
193
|
+
storageDir: readVolatile(incoming.storageDir, defaults.storageDir),
|
|
194
|
+
routing: readVolatile(incoming.routing, defaults.routing),
|
|
195
|
+
denyCurlWget: readVolatile(incoming.denyCurlWget, defaults.denyCurlWget),
|
|
196
|
+
bashNudgeMinCommandBytes: readVolatile(incoming.bashNudgeMinCommandBytes, defaults.bashNudgeMinCommandBytes),
|
|
197
|
+
maxReadBytesBeforeAsk: readVolatile(incoming.maxReadBytesBeforeAsk, defaults.maxReadBytesBeforeAsk),
|
|
198
|
+
trustedReadBasenames: readVolatile(incoming.trustedReadBasenames, defaults.trustedReadBasenames),
|
|
199
|
+
'tools.execute': readVolatile(incoming['tools.execute'], defaults['tools.execute']),
|
|
200
|
+
'tools.executeFile': readVolatile(incoming['tools.executeFile'], defaults['tools.executeFile']),
|
|
201
|
+
'tools.batchExecute': readVolatile(incoming['tools.batchExecute'], defaults['tools.batchExecute']),
|
|
202
|
+
'tools.index': readVolatile(incoming['tools.index'], defaults['tools.index']),
|
|
203
|
+
'tools.search': readVolatile(incoming['tools.search'], defaults['tools.search']),
|
|
204
|
+
'tools.fetchAndIndex': readVolatile(incoming['tools.fetchAndIndex'], defaults['tools.fetchAndIndex']),
|
|
205
|
+
'tools.purge': readVolatile(incoming['tools.purge'], defaults['tools.purge']),
|
|
206
|
+
'tools.stats': readVolatile(incoming['tools.stats'], defaults['tools.stats']),
|
|
207
|
+
'tools.doctor': readVolatile(incoming['tools.doctor'], defaults['tools.doctor']),
|
|
208
|
+
'tools.contextReport': readVolatile(incoming['tools.contextReport'], defaults['tools.contextReport']),
|
|
209
|
+
compressNudgeTokens: readVolatile(incoming.compressNudgeTokens, defaults.compressNudgeTokens),
|
|
210
|
+
searchLimit: readVolatile(incoming.searchLimit, defaults.searchLimit),
|
|
211
|
+
snippetChars: readVolatile(incoming.snippetChars, defaults.snippetChars),
|
|
212
|
+
fetchMaxBytes: readVolatile(incoming.fetchMaxBytes, defaults.fetchMaxBytes),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const storageDir = (typeof config.storageDir === 'string' && config.storageDir.trim())
|
|
216
|
+
? config.storageDir
|
|
217
|
+
: join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'context-toolkit')
|
|
218
|
+
// Savings ledger. Kept deliberately wider than the upstream convention: a call
|
|
219
|
+
// the gate refused is a saving too, and leaving it out made the ledger read as
|
|
220
|
+
// permanently zero even while the gate was doing its job.
|
|
221
|
+
const stats = {
|
|
222
|
+
denied: 0,
|
|
223
|
+
deniedBytes: 0,
|
|
224
|
+
registered: 0,
|
|
225
|
+
bytesIndexed: 0,
|
|
226
|
+
bytesSandboxed: 0,
|
|
227
|
+
calls: {},
|
|
228
|
+
startedAt: Date.now(),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let store = null
|
|
232
|
+
try {
|
|
233
|
+
store = openStore(storageDir)
|
|
234
|
+
} catch (error) {
|
|
235
|
+
log.warn('knowledge base unavailable: ' + String((error && error.message) || error))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
registerRoutingGate(ctx, config, stats)
|
|
239
|
+
registerTools(ctx, config, stats, storageDir, log)
|
|
240
|
+
if (store) registerKnowledgeTools(ctx, config, stats, store, log)
|
|
241
|
+
else log.info('knowledge tools skipped (no store)')
|
|
242
|
+
registerObservabilityTools(ctx, config, stats, store, storageDir, log)
|
|
243
|
+
|
|
244
|
+
try { registerStatusRoute(ctx, config, stats, store, storageDir) }
|
|
245
|
+
catch (error) { log.warn('status route not mounted: ' + String((error && error.message) || error)) }
|
|
246
|
+
|
|
247
|
+
// The SQLite handle must be released when the plugin unwinds: an open WAL
|
|
248
|
+
// keeps the store directory locked and leaks a file descriptor per reload.
|
|
249
|
+
if (store && typeof ctx.effect === 'function') {
|
|
250
|
+
try {
|
|
251
|
+
ctx.effect(() => () => { try { store.close() } catch { /* already closed */ } })
|
|
252
|
+
} catch (error) {
|
|
253
|
+
log.warn('could not register store disposal: ' + String((error && error.message) || error))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
log.info('ready: storage=' + storageDir + ' routing=' + config.routing + ' tools=' + stats.registered)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── routing gate ────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/** Names whose whole purpose is to pour raw bytes into the conversation. */
|
|
263
|
+
const FLOOD_TOOLS = new Set(['curl', 'wget', 'httpie', 'http', 'aria2c'])
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Every tool name that runs a shell command, so the gate can read its `command`.
|
|
267
|
+
*
|
|
268
|
+
* This list is the gate's blind spot if it drifts: a host name that is absent here
|
|
269
|
+
* is simply not inspected, and the flood tools run unchecked through it. `ops_bash`
|
|
270
|
+
* is the live example - dsh-ops publishes it (and does not publish `bash` at all),
|
|
271
|
+
* so before this list existed `curl` through `ops_bash` slipped past a gate that
|
|
272
|
+
* was refusing the identical command through `pwsh`.
|
|
273
|
+
*
|
|
274
|
+
* Add a name here when a shell tool appears; the other branches key off this set.
|
|
275
|
+
*/
|
|
276
|
+
const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh', 'shell', 'ops_bash', 'bash_persistent', 'pwsh_persistent'])
|
|
277
|
+
|
|
278
|
+
function floodReason(toolName, args) {
|
|
279
|
+
const command = typeof args.command === 'string' ? args.command : ''
|
|
280
|
+
if (!SHELL_TOOL_NAMES.has(toolName)) return undefined
|
|
281
|
+
for (const word of FLOOD_TOOLS) {
|
|
282
|
+
if (new RegExp('(^|[ \\t;&|(])' + word + '([ \\t;&|)]|$)', 'i').test(command)) {
|
|
283
|
+
return 'dsh-context-toolkit: "' + word + '" pours whole responses into the transcript. '
|
|
284
|
+
+ 'Use ctk_fetch_and_index(url, source) and then ctk_search(queries), or run it inside '
|
|
285
|
+
+ 'ctk_execute(language:"javascript", code) and print only the conclusion.'
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return undefined
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The flood tool a command line invoked, for the ledger's per-tool counters. */
|
|
292
|
+
export function floodToolName(command) {
|
|
293
|
+
const text = String(command || '')
|
|
294
|
+
for (const word of FLOOD_TOOLS) {
|
|
295
|
+
if (new RegExp('(^|[ \\t;&|(])' + word + '([ \\t;&|)]|$)', 'i').test(text)) return word
|
|
296
|
+
}
|
|
297
|
+
return 'other'
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function hasService(ctx, serviceName) {
|
|
301
|
+
try { return ctx.get(serviceName) !== undefined } catch { return false }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function baseNameOf(p) {
|
|
305
|
+
const s = String(p).replace(/[\\/]+$/, '')
|
|
306
|
+
const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'))
|
|
307
|
+
return i >= 0 ? s.slice(i + 1) : s
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function registerRoutingGate(ctx, config, stats) {
|
|
311
|
+
if (!config.routing) return
|
|
312
|
+
|
|
313
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
314
|
+
try {
|
|
315
|
+
const toolName = exec && exec.name
|
|
316
|
+
const args = (exec && exec.arguments) || {}
|
|
317
|
+
|
|
318
|
+
if (config.denyCurlWget !== false) {
|
|
319
|
+
const reason = floodReason(toolName, args)
|
|
320
|
+
if (reason) {
|
|
321
|
+
stats.denied += 1
|
|
322
|
+
stats.deniedBytes += typeof args.command === 'string' ? args.command.length : 0
|
|
323
|
+
stats.calls[floodToolName(args.command || '')] = (stats.calls[floodToolName(args.command || '')] || 0) + 1
|
|
324
|
+
return { kind: 'deny', reason }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (SHELL_TOOL_NAMES.has(toolName) && config.bashNudgeMinCommandBytes > 0) {
|
|
329
|
+
const command = typeof args.command === 'string' ? args.command : ''
|
|
330
|
+
if (command.length > config.bashNudgeMinCommandBytes) {
|
|
331
|
+
const reason = 'dsh-context-toolkit: this command is about ' + command.length
|
|
332
|
+
+ ' bytes (threshold ' + config.bashNudgeMinCommandBytes + '). If you only need a count, a filter '
|
|
333
|
+
+ 'or a summary, run it through ctk_execute or ctk_batch_execute so only the conclusion '
|
|
334
|
+
+ 'comes back; read the raw text with the read tool (offset/limit) if you truly need it.'
|
|
335
|
+
if (hasService(ctx, 'approval')) return { kind: 'ask', reason }
|
|
336
|
+
return next()
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (toolName === 'read' && config.maxReadBytesBeforeAsk > 0) {
|
|
341
|
+
const p = typeof args.file_path === 'string' ? args.file_path
|
|
342
|
+
: (typeof args.path === 'string' ? args.path : '')
|
|
343
|
+
const bounded = args.offset !== undefined || args.limit !== undefined
|
|
344
|
+
if (p && !bounded && !(config.trustedReadBasenames || []).includes(baseNameOf(p))) {
|
|
345
|
+
try {
|
|
346
|
+
const st = statSync(p)
|
|
347
|
+
if (st.isFile() && st.size > config.maxReadBytesBeforeAsk) {
|
|
348
|
+
stats.denied += 1
|
|
349
|
+
stats.deniedBytes += st.size
|
|
350
|
+
const reason = 'dsh-context-toolkit: ' + p + ' is about ' + st.size
|
|
351
|
+
+ ' bytes (threshold ' + config.maxReadBytesBeforeAsk + '). To analyse or summarise it, use '
|
|
352
|
+
+ 'ctk_execute_file(path, language, code); to file it for later, use ctk_index(path). '
|
|
353
|
+
+ 'Read it whole only when you must edit exact bytes.'
|
|
354
|
+
if (hasService(ctx, 'approval')) return { kind: 'ask', reason }
|
|
355
|
+
return next()
|
|
356
|
+
}
|
|
357
|
+
} catch { /* a missing file is the read tool's error to report */ }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} catch { /* the gate must never block a tool call by failing itself */ }
|
|
361
|
+
return next()
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
if (config.denyCurlWget !== false && ctx.tools && typeof ctx.tools.guard === 'function') {
|
|
365
|
+
ctx.tools.guard((exec) => floodReason(exec && exec.name, (exec && exec.arguments) || {}))
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── tools ───────────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
function toolSwitch(config, key) {
|
|
372
|
+
return config['tools.' + key] !== false
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Render the bounded account a sandboxed run returns. */
|
|
376
|
+
/** Hard ceiling on how many characters any single captured stream contributes. */
|
|
377
|
+
const MAX_STREAM_CHARS = 4000
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Bound one captured stream so a huge or single-line output cannot reach the
|
|
381
|
+
* conversation whole. Line-based slicing alone is not enough: a 1 MB blob with
|
|
382
|
+
* no newline is ONE line, so a per-line split would keep all of it. The char cap
|
|
383
|
+
* is applied first, then the line slicing refines the already-bounded text.
|
|
384
|
+
*/
|
|
385
|
+
function clampStream(text, maxChars = MAX_STREAM_CHARS) {
|
|
386
|
+
const value = String(text || '')
|
|
387
|
+
if (value.length <= maxChars) return { text: value, clamped: false, omittedChars: 0 }
|
|
388
|
+
const head = Math.floor(maxChars * 0.6)
|
|
389
|
+
const tail = maxChars - head
|
|
390
|
+
return {
|
|
391
|
+
text: value.slice(0, head) + '\n… (' + (value.length - maxChars) + ' more characters)…\n' + value.slice(-tail),
|
|
392
|
+
clamped: true,
|
|
393
|
+
omittedChars: value.length - maxChars,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function summarizeRun(label, result, options = {}) {
|
|
398
|
+
const { headLines = 40, tailLines = 20 } = options
|
|
399
|
+
const lines = []
|
|
400
|
+
lines.push('## ' + label)
|
|
401
|
+
if (result.error) lines.push('- launch error: ' + result.error)
|
|
402
|
+
lines.push('- exit: ' + (result.exitCode === null ? 'n/a' : result.exitCode)
|
|
403
|
+
+ (result.signal ? ' (signal ' + result.signal + ')' : '')
|
|
404
|
+
+ (result.timedOut ? ' [timed out]' : ''))
|
|
405
|
+
lines.push('- stdout: ' + result.stdoutBytes + ' B captured'
|
|
406
|
+
+ (result.stdoutTruncated ? ' (showing head)' : '')
|
|
407
|
+
+ ' | stderr: ' + result.stderrBytes + ' B')
|
|
408
|
+
const outRaw = clampStream((result.stdout || '').replace(/\r\n/g, '\n'))
|
|
409
|
+
const out = outRaw.text
|
|
410
|
+
if (out.trim()) {
|
|
411
|
+
const split = out.split('\n')
|
|
412
|
+
const shown = split.length > headLines + tailLines
|
|
413
|
+
? split.slice(0, headLines).concat(['… (' + (split.length - headLines - tailLines) + ' more lines)…'], split.slice(-tailLines))
|
|
414
|
+
: split
|
|
415
|
+
lines.push('')
|
|
416
|
+
lines.push('~~~stdout')
|
|
417
|
+
lines.push(shown.join('\n'))
|
|
418
|
+
lines.push('~~~')
|
|
419
|
+
}
|
|
420
|
+
const err = clampStream((result.stderr || '').replace(/\r\n/g, '\n')).text
|
|
421
|
+
if (err.trim()) {
|
|
422
|
+
lines.push('')
|
|
423
|
+
lines.push('~~~stderr')
|
|
424
|
+
lines.push(err.split('\n').slice(0, 30).join('\n'))
|
|
425
|
+
lines.push('~~~')
|
|
426
|
+
}
|
|
427
|
+
return lines.join('\n')
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Map a language name to a temp-script extension and launch plan.
|
|
432
|
+
*
|
|
433
|
+
* javascript uses .cjs so a script can reach require() and __dirname the way most
|
|
434
|
+
* Node code in the wild does; esm is the explicit opt-in for import syntax.
|
|
435
|
+
*/
|
|
436
|
+
const LANGUAGE_PLAN = {
|
|
437
|
+
javascript: { ext: '.cjs', run: (file, bin) => ({ command: bin, args: [file] }) },
|
|
438
|
+
esm: { ext: '.mjs', run: (file, bin) => ({ command: bin, args: [file] }) },
|
|
439
|
+
typescript: { ext: '.mts', run: (file, bin) => ({ command: bin, args: ['--experimental-strip-types', file] }) },
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function registerTools(ctx, config, stats, storageDir, log) {
|
|
443
|
+
const runtimes = probeRuntimes()
|
|
444
|
+
const runtimeNames = Object.keys(runtimes)
|
|
445
|
+
log.info('runtimes: ' + (runtimeNames.length ? runtimeNames.join(', ') : '(none found)'))
|
|
446
|
+
|
|
447
|
+
ctx.tools.register({
|
|
448
|
+
name: 'ctk_execute',
|
|
449
|
+
description: 'Run code in a sandboxed child process and return only its conclusion. Raw stdout/stderr bytes never enter the conversation: this reports the exit status, the byte volumes and a bounded head/tail. Use it for counts, filters, aggregations and any analysis whose raw output would flood the transcript.',
|
|
450
|
+
parameters: {
|
|
451
|
+
type: 'object',
|
|
452
|
+
properties: {
|
|
453
|
+
language: { type: 'string', enum: ['javascript', 'esm', 'typescript', 'shell'], description: 'Interpreter to run the code with (javascript = CommonJS, esm = import syntax).' },
|
|
454
|
+
code: { type: 'string', description: 'Program text. Print only what the caller needs to read.' },
|
|
455
|
+
cwd: { type: 'string', description: 'Working directory; defaults to the session working directory.' },
|
|
456
|
+
timeoutMs: { type: 'number', description: 'Kill the child after this many milliseconds (default 120000).' },
|
|
457
|
+
},
|
|
458
|
+
required: ['language', 'code'],
|
|
459
|
+
additionalProperties: false,
|
|
460
|
+
},
|
|
461
|
+
output: {
|
|
462
|
+
schema: {
|
|
463
|
+
type: 'object',
|
|
464
|
+
properties: { text: { type: 'string' }, isError: { type: 'boolean' } },
|
|
465
|
+
required: ['text', 'isError'],
|
|
466
|
+
additionalProperties: false,
|
|
467
|
+
},
|
|
468
|
+
render: (_a, v) => [{ type: 'text', text: v && typeof v.text === 'string' ? v.text : 'ctk_execute finished' }],
|
|
469
|
+
},
|
|
470
|
+
timeoutMs: 300000,
|
|
471
|
+
isConcurrencySafe: () => true,
|
|
472
|
+
async execute(args, exec) {
|
|
473
|
+
const language = String(args.language || 'javascript')
|
|
474
|
+
const code = String(args.code || '')
|
|
475
|
+
const cwd = resolveCwd(args.cwd, exec)
|
|
476
|
+
const timeoutMs = Number(args.timeoutMs) > 0 ? Number(args.timeoutMs) : 120000
|
|
477
|
+
|
|
478
|
+
if (language === 'shell') {
|
|
479
|
+
const plan = shellPlan()
|
|
480
|
+
const result = await runProcess(plan.command, plan.args(code), { cwd, timeoutMs, shell: false })
|
|
481
|
+
const report = summarizeRun('ctk_execute · shell', result)
|
|
482
|
+
recordSandbox(stats, 'ctk_execute', result, report.length)
|
|
483
|
+
return { text: report, isError: !result.ok }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const plan = LANGUAGE_PLAN[language]
|
|
487
|
+
if (!plan) return { text: 'unsupported language "' + language + '"; use javascript, typescript or shell', isError: true }
|
|
488
|
+
const bin = language === 'typescript' ? (runtimes.typescript || runtimes.javascript) : runtimes.javascript
|
|
489
|
+
if (!bin) return { text: 'no ' + language + ' runtime available in this process', isError: true }
|
|
490
|
+
|
|
491
|
+
const { withTempScript } = await import('./lib/runner.js')
|
|
492
|
+
const result = await withTempScript(plan.ext, code, (file) => {
|
|
493
|
+
const spec = plan.run(file, bin)
|
|
494
|
+
return runProcess(spec.command, spec.args, { cwd, timeoutMs })
|
|
495
|
+
})
|
|
496
|
+
const report = summarizeRun('ctk_execute · ' + language, result)
|
|
497
|
+
recordSandbox(stats, 'ctk_execute', result, report.length)
|
|
498
|
+
return { text: report, isError: !result.ok }
|
|
499
|
+
},
|
|
500
|
+
})
|
|
501
|
+
stats.registered += 1
|
|
502
|
+
|
|
503
|
+
if (toolSwitch(config, 'executeFile')) {
|
|
504
|
+
ctx.tools.register({
|
|
505
|
+
name: 'ctk_execute_file',
|
|
506
|
+
description: 'Run code against a file on disk and return only the conclusion. The file text lives in the sandbox; the conversation receives the exit status and a bounded head/tail. Use it to read a large file for statistics, extraction or validation without pulling its bytes into the transcript.',
|
|
507
|
+
parameters: {
|
|
508
|
+
type: 'object',
|
|
509
|
+
properties: {
|
|
510
|
+
path: { type: 'string', description: 'File to read inside the sandbox.' },
|
|
511
|
+
language: { type: 'string', enum: Object.keys(LANGUAGE_PLAN), description: 'Interpreter to run the code with.' },
|
|
512
|
+
code: { type: 'string', description: 'Program text. Read the file from process.argv[2] and print only the conclusion.' },
|
|
513
|
+
cwd: { type: 'string', description: 'Working directory; defaults to the session working directory.' },
|
|
514
|
+
timeoutMs: { type: 'number', description: 'Kill the child after this many milliseconds (default 120000).' },
|
|
515
|
+
},
|
|
516
|
+
required: ['path', 'code'],
|
|
517
|
+
additionalProperties: false,
|
|
518
|
+
},
|
|
519
|
+
output: {
|
|
520
|
+
schema: {
|
|
521
|
+
type: 'object',
|
|
522
|
+
properties: { text: { type: 'string' }, isError: { type: 'boolean' } },
|
|
523
|
+
required: ['text', 'isError'],
|
|
524
|
+
additionalProperties: false,
|
|
525
|
+
},
|
|
526
|
+
render: (_a, v) => [{ type: 'text', text: v && typeof v.text === 'string' ? v.text : 'ctk_execute_file finished' }],
|
|
527
|
+
},
|
|
528
|
+
timeoutMs: 300000,
|
|
529
|
+
isConcurrencySafe: () => true,
|
|
530
|
+
async execute(args, exec) {
|
|
531
|
+
const language = String(args.language || 'javascript')
|
|
532
|
+
const code = String(args.code || '')
|
|
533
|
+
const target = String(args.path || '')
|
|
534
|
+
if (!target) return { text: 'path is required', isError: true }
|
|
535
|
+
const abs = isAbsolute(target) ? target : resolve(resolveCwd(args.cwd, exec), target)
|
|
536
|
+
if (!existsSync(abs)) return { text: 'no such file: ' + abs, isError: true }
|
|
537
|
+
let size = 0
|
|
538
|
+
try { const st = statSync(abs); if (!st.isFile()) return { text: 'not a regular file: ' + abs, isError: true }; size = st.size }
|
|
539
|
+
catch (error) { return { text: 'cannot stat ' + abs + ': ' + String(error && error.message), isError: true } }
|
|
540
|
+
|
|
541
|
+
const cwd = resolveCwd(args.cwd, exec)
|
|
542
|
+
const timeoutMs = Number(args.timeoutMs) > 0 ? Number(args.timeoutMs) : 120000
|
|
543
|
+
const plan = LANGUAGE_PLAN[language]
|
|
544
|
+
if (!plan) return { text: 'unsupported language "' + language + '"', isError: true }
|
|
545
|
+
const bin = language === 'typescript' ? (runtimes.typescript || runtimes.javascript) : runtimes.javascript
|
|
546
|
+
if (!bin) return { text: 'no ' + language + ' runtime available', isError: true }
|
|
547
|
+
|
|
548
|
+
const { withTempScript } = await import('./lib/runner.js')
|
|
549
|
+
const result = await withTempScript(plan.ext, code, (file) => {
|
|
550
|
+
const spec = plan.run(file, bin)
|
|
551
|
+
return runProcess(spec.command, spec.args.concat([abs, String(size)]), { cwd, timeoutMs })
|
|
552
|
+
})
|
|
553
|
+
const header = '## ctk_execute_file · ' + basename(abs) + ' (' + size + ' B)'
|
|
554
|
+
const report = header + '\n' + summarizeRun('result', result).replace(/^## result\n/, '')
|
|
555
|
+
// The file itself also stayed out of the conversation: count its bytes too.
|
|
556
|
+
recordSandbox(stats, 'ctk_execute_file', result, report.length + size)
|
|
557
|
+
return { text: report, isError: !result.ok }
|
|
558
|
+
},
|
|
559
|
+
})
|
|
560
|
+
stats.registered += 1
|
|
561
|
+
} else {
|
|
562
|
+
log.info('ctk_execute_file 未启用')
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (toolSwitch(config, 'batchExecute')) {
|
|
566
|
+
ctx.tools.register({
|
|
567
|
+
name: 'ctk_batch_execute',
|
|
568
|
+
description: 'Run several commands in one call and return one bounded report. Each command keeps its own exit status and byte counts, so a single call can answer many questions without expanding into many tool round trips. Use it for multi-command reconnaissance where only the conclusions matter.',
|
|
569
|
+
parameters: {
|
|
570
|
+
type: 'object',
|
|
571
|
+
properties: {
|
|
572
|
+
commands: {
|
|
573
|
+
type: 'array',
|
|
574
|
+
description: 'Commands to run, in order. Each is executed with the platform shell.',
|
|
575
|
+
items: {
|
|
576
|
+
type: 'object',
|
|
577
|
+
properties: {
|
|
578
|
+
label: { type: 'string', description: 'Short name shown in the report.' },
|
|
579
|
+
command: { type: 'string', description: 'Command line to run.' },
|
|
580
|
+
cwd: { type: 'string', description: 'Optional working directory for this command.' },
|
|
581
|
+
},
|
|
582
|
+
required: ['command'],
|
|
583
|
+
additionalProperties: false,
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
cwd: { type: 'string', description: 'Default working directory for every command.' },
|
|
587
|
+
timeoutMs: { type: 'number', description: 'Per-command timeout in milliseconds (default 120000).' },
|
|
588
|
+
concurrency: { type: 'number', description: 'How many commands to run at once (default 3, max 8).' },
|
|
589
|
+
},
|
|
590
|
+
required: ['commands'],
|
|
591
|
+
additionalProperties: false,
|
|
592
|
+
},
|
|
593
|
+
output: {
|
|
594
|
+
schema: {
|
|
595
|
+
type: 'object',
|
|
596
|
+
properties: { text: { type: 'string' }, isError: { type: 'boolean' } },
|
|
597
|
+
required: ['text', 'isError'],
|
|
598
|
+
additionalProperties: false,
|
|
599
|
+
},
|
|
600
|
+
render: (_a, v) => [{ type: 'text', text: v && typeof v.text === 'string' ? v.text : 'ctk_batch_execute finished' }],
|
|
601
|
+
},
|
|
602
|
+
timeoutMs: 600000,
|
|
603
|
+
isConcurrencySafe: () => true,
|
|
604
|
+
async execute(args, exec) {
|
|
605
|
+
const list = Array.isArray(args.commands) ? args.commands : []
|
|
606
|
+
if (!list.length) return { text: 'commands is required and must not be empty', isError: true }
|
|
607
|
+
const defaultCwd = resolveCwd(args.cwd, exec)
|
|
608
|
+
const timeoutMs = Number(args.timeoutMs) > 0 ? Number(args.timeoutMs) : 120000
|
|
609
|
+
const concurrency = Math.min(Math.max(Number(args.concurrency) || 3, 1), 8)
|
|
610
|
+
const plan = shellPlan()
|
|
611
|
+
const shellBin = plan.command
|
|
612
|
+
const results = new Array(list.length)
|
|
613
|
+
|
|
614
|
+
// A small worker pool: commands are independent, so overlapping them cuts
|
|
615
|
+
// wall-clock time without changing what any single one reports.
|
|
616
|
+
let cursor = 0
|
|
617
|
+
const worker = async () => {
|
|
618
|
+
for (;;) {
|
|
619
|
+
const index = cursor++
|
|
620
|
+
if (index >= list.length) return
|
|
621
|
+
const item = list[index] || {}
|
|
622
|
+
const command = String(item.command || '')
|
|
623
|
+
const label = String(item.label || ('command ' + (index + 1)))
|
|
624
|
+
const cwd = item.cwd ? resolve(defaultCwd, String(item.cwd)) : defaultCwd
|
|
625
|
+
const started = Date.now()
|
|
626
|
+
const result = await runProcess(shellBin, plan.args(command), { cwd, timeoutMs })
|
|
627
|
+
results[index] = { label, command, result, elapsedMs: Date.now() - started }
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, list.length) }, worker))
|
|
631
|
+
|
|
632
|
+
const blocks = results.map((entry) => {
|
|
633
|
+
const r = entry.result
|
|
634
|
+
const status = r.timedOut ? 'TIMEOUT' : (r.exitCode === 0 ? 'ok' : 'exit ' + r.exitCode)
|
|
635
|
+
return summarizeRun(entry.label + ' — ' + status + ' (' + entry.elapsedMs + ' ms)',
|
|
636
|
+
r, { headLines: 20, tailLines: 10 })
|
|
637
|
+
})
|
|
638
|
+
const failed = results.filter((e) => !e.result.ok).length
|
|
639
|
+
const report = '## ctk_batch_execute · ' + results.length + ' commands, ' + failed + ' failed\n\n' + blocks.join('\n\n')
|
|
640
|
+
let producedTotal = 0
|
|
641
|
+
for (const entry of results) producedTotal += (Number(entry.result.stdoutBytes) || 0) + (Number(entry.result.stderrBytes) || 0)
|
|
642
|
+
stats.bytesSandboxed += Math.max(0, producedTotal - report.length)
|
|
643
|
+
stats.calls['ctk_batch_execute'] = (stats.calls['ctk_batch_execute'] || 0) + 1
|
|
644
|
+
return { text: report, isError: failed === results.length }
|
|
645
|
+
},
|
|
646
|
+
})
|
|
647
|
+
stats.registered += 1
|
|
648
|
+
} else {
|
|
649
|
+
log.info('ctk_batch_execute 未启用')
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ── knowledge tools ─────────────────────────────────────────────────────────
|
|
654
|
+
|
|
655
|
+
const OUT_SCHEMA = {
|
|
656
|
+
type: 'object',
|
|
657
|
+
properties: { text: { type: 'string' }, isError: { type: 'boolean' } },
|
|
658
|
+
required: ['text', 'isError'],
|
|
659
|
+
additionalProperties: false,
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Shared response envelope for a tool that answers with text. */
|
|
663
|
+
function textTool(def, renderName) {
|
|
664
|
+
return Object.assign({
|
|
665
|
+
output: {
|
|
666
|
+
schema: OUT_SCHEMA,
|
|
667
|
+
render: (_a, v) => [{ type: 'text', text: v && typeof v.text === 'string' ? v.text : renderName + ' finished' }],
|
|
668
|
+
},
|
|
669
|
+
timeoutMs: 120000,
|
|
670
|
+
isConcurrencySafe: () => true,
|
|
671
|
+
}, def)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Strip tags, scripts and styles so a fetched page reads as text, not markup. */
|
|
675
|
+
export function htmlToText(html) {
|
|
676
|
+
let text = String(html || '')
|
|
677
|
+
text = text.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
678
|
+
text = text.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
679
|
+
text = text.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
680
|
+
text = text.replace(/<(br|\/p|\/div|\/li|\/h[1-6]|\/tr)[^>]*>/gi, '\n')
|
|
681
|
+
text = text.replace(/<[^>]+>/g, '')
|
|
682
|
+
text = text.replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<')
|
|
683
|
+
.replace(/>/gi, '>').replace(/"/gi, '"').replace(/'/g, "'")
|
|
684
|
+
text = text.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n')
|
|
685
|
+
return text.trim()
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Explain a fetch failure in words that point at a fix.
|
|
690
|
+
*
|
|
691
|
+
* A bare "fetch failed" hides the cause: a host that resolves to 127.0.0.1 is a
|
|
692
|
+
* local DNS interception, a refused connection is a blocked port, and those need
|
|
693
|
+
* different remedies. The chain is walked so the report names the real problem.
|
|
694
|
+
*/
|
|
695
|
+
export function describeFetchFailure(error, url) {
|
|
696
|
+
const message = String((error && error.message) || error)
|
|
697
|
+
const cause = error && error.cause ? error.cause : null
|
|
698
|
+
const code = cause && cause.code ? String(cause.code) : ''
|
|
699
|
+
const address = cause && cause.address ? String(cause.address) : ''
|
|
700
|
+
const port = cause && cause.port ? String(cause.port) : ''
|
|
701
|
+
const target = address ? address + (port ? ':' + port : '') : ''
|
|
702
|
+
if (/ECONNREFUSED/i.test(code) && (address === '127.0.0.1' || address === '::1')) {
|
|
703
|
+
return message + ' — ' + (target || 'the local address') + ' refused the connection. '
|
|
704
|
+
+ 'This deployment resolves host names to the loopback address, so outbound HTTPS cannot '
|
|
705
|
+
+ 'leave the process. File the material with ctk_index instead, or fetch it outside the plugin.'
|
|
706
|
+
}
|
|
707
|
+
if (/ENOTFOUND|EAI_AGAIN/i.test(code)) {
|
|
708
|
+
return message + ' — the name did not resolve' + (target ? ' (' + target + ')' : '') + '.'
|
|
709
|
+
}
|
|
710
|
+
if (/ETIMEDOUT|Connect Timeout/i.test(message) || /ETIMEDOUT/i.test(code)) {
|
|
711
|
+
return message + ' — the connection timed out' + (target ? ' (' + target + ')' : '') + '.'
|
|
712
|
+
}
|
|
713
|
+
if (/CERT|TLS|SSL|self.signed/i.test(message)) {
|
|
714
|
+
return message + ' — the TLS handshake failed. A local interception certificate is the usual cause.'
|
|
715
|
+
}
|
|
716
|
+
return message + (code ? ' (' + code + (target ? ' at ' + target : '') + ')' : '')
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function registerKnowledgeTools(ctx, config, stats, store, log) {
|
|
720
|
+
if (toolSwitch(config, 'index')) {
|
|
721
|
+
ctx.tools.register(textTool({
|
|
722
|
+
name: 'ctk_index',
|
|
723
|
+
description: 'File large material into the local full-text knowledge base and keep its bytes out of the conversation. Accepts literal text or a path on disk; retrieve it later with ctk_search, which returns only the matching sections. Re-indexing the same path replaces the earlier copy.',
|
|
724
|
+
parameters: {
|
|
725
|
+
type: 'object',
|
|
726
|
+
properties: {
|
|
727
|
+
text: { type: 'string', description: 'Literal text to file. Use either text or path, not both.' },
|
|
728
|
+
path: { type: 'string', description: 'File to read and file.' },
|
|
729
|
+
title: { type: 'string', description: 'Display title; defaults to the file name.' },
|
|
730
|
+
},
|
|
731
|
+
additionalProperties: false,
|
|
732
|
+
},
|
|
733
|
+
async execute(args) {
|
|
734
|
+
const hasText = typeof args.text === 'string' && args.text.length > 0
|
|
735
|
+
const hasPath = typeof args.path === 'string' && args.path.trim().length > 0
|
|
736
|
+
if (!hasText && !hasPath) return { text: 'provide either text or path', isError: true }
|
|
737
|
+
try {
|
|
738
|
+
const label = typeof args.title === 'string' && args.title.trim() ? args.title.trim() : ''
|
|
739
|
+
const result = hasText
|
|
740
|
+
? store.indexDocument({ path: 'text:' + (label || 'inline') + ':' + Date.now(), title: label, text: args.text })
|
|
741
|
+
: store.indexFile(args.path, { title: args.title })
|
|
742
|
+
stats.bytesIndexed = (stats.bytesIndexed || 0) + result.bytes
|
|
743
|
+
const totals = store.stats()
|
|
744
|
+
return {
|
|
745
|
+
text: [
|
|
746
|
+
'## ctk_index',
|
|
747
|
+
'- filed: ' + result.path,
|
|
748
|
+
'- chunks: ' + result.chunks + ' (' + result.bytes + ' B)' + (result.replaced ? ' [replaced the previous copy]' : ''),
|
|
749
|
+
'- knowledge base: ' + totals.documents + ' documents / ' + totals.chunks + ' chunks',
|
|
750
|
+
].join('\n'),
|
|
751
|
+
isError: false,
|
|
752
|
+
}
|
|
753
|
+
} catch (error) {
|
|
754
|
+
return { text: 'ctk_index failed: ' + String((error && error.message) || error), isError: true }
|
|
755
|
+
}
|
|
756
|
+
},
|
|
757
|
+
}, 'ctk_index'))
|
|
758
|
+
stats.registered += 1
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (toolSwitch(config, 'search')) {
|
|
762
|
+
ctx.tools.register(textTool({
|
|
763
|
+
name: 'ctk_search',
|
|
764
|
+
description: 'Retrieve matching sections from the local knowledge base. Results carry the heading and a bounded excerpt centred on the match, so material filed earlier can be consulted without reading it whole again. Accepts several queries in one call.',
|
|
765
|
+
parameters: {
|
|
766
|
+
type: 'object',
|
|
767
|
+
properties: {
|
|
768
|
+
queries: {
|
|
769
|
+
type: 'array',
|
|
770
|
+
description: 'One or more queries; each keeps its own result list.',
|
|
771
|
+
items: { type: 'string' },
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
required: ['queries'],
|
|
775
|
+
additionalProperties: false,
|
|
776
|
+
},
|
|
777
|
+
async execute(args) {
|
|
778
|
+
const queries = Array.isArray(args.queries) ? args.queries : (args.queries ? [args.queries] : [])
|
|
779
|
+
if (!queries.length) return { text: 'queries is required', isError: true }
|
|
780
|
+
const limit = Math.max(1, Number(config.searchLimit) || 5)
|
|
781
|
+
const maxChars = Math.max(100, Number(config.snippetChars) || 900)
|
|
782
|
+
let results = []
|
|
783
|
+
try {
|
|
784
|
+
results = store.search(queries, { limit, maxChars })
|
|
785
|
+
} catch (error) {
|
|
786
|
+
return { text: 'ctk_search failed: ' + String((error && error.message) || error), isError: true }
|
|
787
|
+
}
|
|
788
|
+
const lines = ['## ctk_search']
|
|
789
|
+
let total = 0
|
|
790
|
+
for (const group of results) {
|
|
791
|
+
lines.push('')
|
|
792
|
+
if (group.error) { lines.push('### ' + group.query + ' — error: ' + group.error); continue }
|
|
793
|
+
lines.push('### ' + group.query + ' — ' + group.hits.length + ' hit(s)')
|
|
794
|
+
total += group.hits.length
|
|
795
|
+
for (const hit of group.hits) {
|
|
796
|
+
lines.push('- **' + (hit.title || hit.path) + '**' + (hit.heading ? ' › ' + hit.heading : ''))
|
|
797
|
+
lines.push(' - ' + hit.path)
|
|
798
|
+
lines.push(' - ~~~')
|
|
799
|
+
for (const l of hit.snippet.split('\n').slice(0, 20)) lines.push(' ' + l)
|
|
800
|
+
lines.push(' - ~~~')
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (total === 0) lines.push('', 'no matches. File material first with ctk_index or ctk_fetch_and_index.')
|
|
804
|
+
return { text: lines.join('\n'), isError: false }
|
|
805
|
+
},
|
|
806
|
+
}, 'ctk_search'))
|
|
807
|
+
stats.registered += 1
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (toolSwitch(config, 'fetchAndIndex')) {
|
|
811
|
+
ctx.tools.register(textTool({
|
|
812
|
+
name: 'ctk_fetch_and_index',
|
|
813
|
+
description: 'Fetch one or more pages and file them straight into the knowledge base, returning only a short preview per page. This is the cheap alternative to pasting a whole page into the conversation; follow up with ctk_search to pull the sections you need.',
|
|
814
|
+
parameters: {
|
|
815
|
+
type: 'object',
|
|
816
|
+
properties: {
|
|
817
|
+
requests: {
|
|
818
|
+
type: 'array',
|
|
819
|
+
description: 'Pages to fetch and file.',
|
|
820
|
+
items: {
|
|
821
|
+
type: 'object',
|
|
822
|
+
properties: {
|
|
823
|
+
url: { type: 'string', description: 'Absolute http(s) URL.' },
|
|
824
|
+
source: { type: 'string', description: 'Optional label used as the display title.' },
|
|
825
|
+
},
|
|
826
|
+
required: ['url'],
|
|
827
|
+
additionalProperties: false,
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
url: { type: 'string', description: 'Single URL form, when only one page is needed.' },
|
|
831
|
+
source: { type: 'string', description: 'Label for the single-URL form.' },
|
|
832
|
+
},
|
|
833
|
+
additionalProperties: false,
|
|
834
|
+
},
|
|
835
|
+
timeoutMs: 180000,
|
|
836
|
+
async execute(args) {
|
|
837
|
+
const list = Array.isArray(args.requests) && args.requests.length
|
|
838
|
+
? args.requests
|
|
839
|
+
: (args.url ? [{ url: args.url, source: args.source }] : [])
|
|
840
|
+
if (!list.length) return { text: 'provide requests[] or url', isError: true }
|
|
841
|
+
const maxBytes = Math.max(1024, Number(config.fetchMaxBytes) || 2000000)
|
|
842
|
+
const lines = ['## ctk_fetch_and_index']
|
|
843
|
+
let ok = 0
|
|
844
|
+
for (const item of list) {
|
|
845
|
+
const url = String(item.url || '')
|
|
846
|
+
if (!/^https?:\/\//i.test(url)) { lines.push('- ' + url + ' — skipped: not an http(s) URL'); continue }
|
|
847
|
+
try {
|
|
848
|
+
const response = await fetch(url, { redirect: 'follow', headers: { 'user-agent': 'dsh-context-toolkit' } })
|
|
849
|
+
const raw = await response.text()
|
|
850
|
+
const body = raw.length > maxBytes ? raw.slice(0, maxBytes) : raw
|
|
851
|
+
const text = htmlToText(body)
|
|
852
|
+
const result = store.indexDocument({ path: url, title: item.source || url, text })
|
|
853
|
+
stats.bytesIndexed = (stats.bytesIndexed || 0) + result.bytes
|
|
854
|
+
ok += 1
|
|
855
|
+
lines.push('- ' + url + ' — HTTP ' + response.status + ' ' + result.chunks + ' chunks (' + result.bytes + ' B filed)')
|
|
856
|
+
} catch (error) {
|
|
857
|
+
lines.push('- ' + url + ' — failed: ' + describeFetchFailure(error, url))
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const totals = store.stats()
|
|
861
|
+
lines.push('', ok + '/' + list.length + ' filed · knowledge base: ' + totals.documents + ' documents / ' + totals.chunks + ' chunks')
|
|
862
|
+
lines.push('use ctk_search(queries) to pull the sections you need.')
|
|
863
|
+
return { text: lines.join('\n'), isError: ok === 0 }
|
|
864
|
+
},
|
|
865
|
+
}, 'ctk_fetch_and_index'))
|
|
866
|
+
stats.registered += 1
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (toolSwitch(config, 'purge')) {
|
|
870
|
+
ctx.tools.register(textTool({
|
|
871
|
+
name: 'ctk_purge',
|
|
872
|
+
description: 'Delete filed material from the local knowledge base. Destructive and not recoverable, so it requires confirm: true. Pass path for one document, or all: true to clear everything.',
|
|
873
|
+
parameters: {
|
|
874
|
+
type: 'object',
|
|
875
|
+
properties: {
|
|
876
|
+
path: { type: 'string', description: 'Document path to remove.' },
|
|
877
|
+
all: { type: 'boolean', description: 'Remove every document.' },
|
|
878
|
+
confirm: { type: 'boolean', description: 'Must be true, or nothing is deleted.' },
|
|
879
|
+
},
|
|
880
|
+
required: ['confirm'],
|
|
881
|
+
additionalProperties: false,
|
|
882
|
+
},
|
|
883
|
+
async execute(args) {
|
|
884
|
+
if (args.confirm !== true) {
|
|
885
|
+
const totals = store.stats()
|
|
886
|
+
const would = args.all ? totals.documents : (args.path ? 1 : 0)
|
|
887
|
+
return { text: 'refused: confirm:true is required. This would remove ' + would + ' document(s) (the store currently holds ' + totals.documents + ').', isError: true }
|
|
888
|
+
}
|
|
889
|
+
const result = store.purge({ path: args.path, all: args.all === true })
|
|
890
|
+
const totals = store.stats()
|
|
891
|
+
return {
|
|
892
|
+
text: '## ctk_purge\n- removed: ' + result.removed + ' (' + result.scope + ')\n- remaining: ' + totals.documents + ' documents / ' + totals.chunks + ' chunks',
|
|
893
|
+
isError: false,
|
|
894
|
+
}
|
|
895
|
+
},
|
|
896
|
+
}, 'ctk_purge'))
|
|
897
|
+
stats.registered += 1
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
log.info('knowledge base ready: ' + JSON.stringify(store.stats()))
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Count what the sandbox kept out of the conversation.
|
|
905
|
+
*
|
|
906
|
+
* The saving is everything the child produced minus the bounded head/tail that
|
|
907
|
+
* actually crossed back — so a 1 MB command that returns 4 KB is credited with
|
|
908
|
+
* the remaining bytes, which is the number the ledger should report.
|
|
909
|
+
*/
|
|
910
|
+
function recordSandbox(stats, tool, result, reportChars) {
|
|
911
|
+
const produced = (Number(result.stdoutBytes) || 0) + (Number(result.stderrBytes) || 0)
|
|
912
|
+
// The saving is what the conversation did NOT have to carry: everything the
|
|
913
|
+
// child produced, minus the characters actually handed back in the report.
|
|
914
|
+
// Counting only the discarded tail understated the ledger badly — a 200 KB
|
|
915
|
+
// output that came back as an 800-character summary saved ~199 KB, yet the
|
|
916
|
+
// old formula credited zero because nothing was truncated.
|
|
917
|
+
const returned = Math.max(0, Number(reportChars) || 0)
|
|
918
|
+
const kept = Math.max(0, produced - returned)
|
|
919
|
+
stats.bytesSandboxed += kept
|
|
920
|
+
stats.calls[tool] = (stats.calls[tool] || 0) + 1
|
|
921
|
+
return kept
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ── context occupancy ──────────────────────────────────────────────────────
|
|
925
|
+
|
|
926
|
+
export const STATUS_PATH = '/_dsh/context-toolkit/status'
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Measure one session's occupancy through the host's own meter.
|
|
930
|
+
*
|
|
931
|
+
* The meter is the authority for surface tokens; the breakdown projection is the
|
|
932
|
+
* authority for the category split. Either can be absent, and the caller is told
|
|
933
|
+
* which one is missing rather than shown a zero.
|
|
934
|
+
*/
|
|
935
|
+
function measureContext(ctx, session, config, stats) {
|
|
936
|
+
const out = { ok: false, why: '', totalTokens: null, surfaceTokens: null, breakdown: null, nodes: null, budget: null, overBudget: null }
|
|
937
|
+
if (!session) { out.why = 'no session is addressable from a page request; call ctk_context_report in the conversation for a live reading'; return out }
|
|
938
|
+
let measured = null
|
|
939
|
+
try {
|
|
940
|
+
const meter = ctx.get('tokenMeter')
|
|
941
|
+
if (!meter || typeof meter.measure !== 'function') { out.why = 'the host provides no tokenMeter service'; return out }
|
|
942
|
+
measured = meter.measure(session)
|
|
943
|
+
} catch (error) { out.why = 'tokenMeter threw: ' + String((error && error.message) || error); return out }
|
|
944
|
+
if (!measured || typeof measured !== 'object') { out.why = 'tokenMeter returned no measurement'; return out }
|
|
945
|
+
|
|
946
|
+
out.ok = true
|
|
947
|
+
out.at = Date.now()
|
|
948
|
+
out.totalTokens = Number(measured.totalTokens) || 0
|
|
949
|
+
out.surfaceTokens = Number(measured.surfaceTokens) || 0
|
|
950
|
+
out.budget = Number(readVolatile(config.compressNudgeTokens, 120000)) || 0
|
|
951
|
+
out.overBudget = out.surfaceTokens >= out.budget
|
|
952
|
+
|
|
953
|
+
let bd = null
|
|
954
|
+
try {
|
|
955
|
+
const projections = ctx.get('sessionProjections')
|
|
956
|
+
if (projections && typeof projections.stateOf === 'function') bd = projections.stateOf(session, 'contextBreakdown')
|
|
957
|
+
if ((bd === undefined || bd === null) && projections && typeof projections.read === 'function') bd = projections.read(session, 'contextBreakdown')
|
|
958
|
+
} catch { /* the estimate below still stands */ }
|
|
959
|
+
if (bd && typeof bd === 'object' && bd.view && typeof bd.view === 'object') bd = bd.view
|
|
960
|
+
if (bd && typeof bd === 'object' && bd.systemTokens !== undefined) {
|
|
961
|
+
const system = Number(bd.systemTokens) || 0
|
|
962
|
+
const tools = Number(bd.toolsTokens) || 0
|
|
963
|
+
const messages = Number(bd.messageTokens) || 0
|
|
964
|
+
out.breakdown = { system, tools, messages, total: system + tools + messages, estimated: false }
|
|
965
|
+
} else if (Array.isArray(measured.nodes) && measured.nodes.length > 0) {
|
|
966
|
+
const systemNode = measured.nodes.filter((n) => n && n.system).pop() || null
|
|
967
|
+
const system = systemNode ? (Number(systemNode.heuristicTokens) || 0) : 0
|
|
968
|
+
const total = measured.nodes.reduce((sum, n) => sum + ((n && Number(n.heuristicTokens)) || 0), 0)
|
|
969
|
+
out.breakdown = { system, tools: 0, messages: Math.max(0, total - system), total, estimated: true }
|
|
970
|
+
}
|
|
971
|
+
if (Array.isArray(measured.nodes) && measured.nodes.length > 0) {
|
|
972
|
+
out.nodes = measured.nodes
|
|
973
|
+
.map((n) => ({ seq: n && n.seq, system: Boolean(n && n.system), tokens: (n && Number(n.heuristicTokens)) || 0 }))
|
|
974
|
+
.sort((a, b) => b.tokens - a.tokens).slice(0, 15)
|
|
975
|
+
}
|
|
976
|
+
stats.lastMeasure = out
|
|
977
|
+
return out
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function sendJson(res, status, body) {
|
|
981
|
+
try {
|
|
982
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
983
|
+
res.end(JSON.stringify(body))
|
|
984
|
+
} catch { /* the response may already be closed */ }
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Mount the panel's data route.
|
|
989
|
+
*
|
|
990
|
+
* A page request has no initiator boundary, so the live session is resolved in
|
|
991
|
+
* three steps: a request-scoped session, the current initiator, then the most
|
|
992
|
+
* recently created live root conversation. The last step is what makes the panel
|
|
993
|
+
* work at all on a cold start, where no measurement has happened yet.
|
|
994
|
+
*/
|
|
995
|
+
function registerStatusRoute(ctx, config, stats, store, storageDir) {
|
|
996
|
+
if (!config['tools.contextReport']) return
|
|
997
|
+
if (typeof ctx.inject !== 'function') return
|
|
998
|
+
try {
|
|
999
|
+
ctx.inject(['webServer'], (scoped) => {
|
|
1000
|
+
const server = scoped && scoped.webServer
|
|
1001
|
+
if (!server || typeof server.register !== 'function') return
|
|
1002
|
+
const handler = async (req, res) => {
|
|
1003
|
+
const method = String((req && req.method) || 'GET').toUpperCase()
|
|
1004
|
+
if (method !== 'GET') { sendJson(res, 405, { error: 'method not allowed' }); return }
|
|
1005
|
+
try {
|
|
1006
|
+
let session
|
|
1007
|
+
let sessionSource = 'none'
|
|
1008
|
+
try {
|
|
1009
|
+
if (req && req.session !== undefined && req.session !== null) { session = req.session; sessionSource = 'request' }
|
|
1010
|
+
else {
|
|
1011
|
+
const agents = ctx.get('agents')
|
|
1012
|
+
if (agents && typeof agents.currentInitiator === 'function') {
|
|
1013
|
+
const live = agents.currentInitiator()
|
|
1014
|
+
if (live !== undefined && live !== null) { session = live; sessionSource = 'initiator' }
|
|
1015
|
+
}
|
|
1016
|
+
if (session === undefined && agents && typeof agents.roots === 'function') {
|
|
1017
|
+
try {
|
|
1018
|
+
const roots = agents.roots()
|
|
1019
|
+
const last = Array.isArray(roots) && roots.length > 0 ? roots[roots.length - 1] : undefined
|
|
1020
|
+
if (last && last.session) { session = last.session; sessionSource = 'root-agent' }
|
|
1021
|
+
} catch { /* fall through to the snapshot */ }
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
} catch { session = undefined }
|
|
1025
|
+
|
|
1026
|
+
sendJson(res, 200, {
|
|
1027
|
+
config: {
|
|
1028
|
+
routing: readVolatile(config.routing, true),
|
|
1029
|
+
denyCurlWget: readVolatile(config.denyCurlWget, true),
|
|
1030
|
+
bashNudgeMinCommandBytes: readVolatile(config.bashNudgeMinCommandBytes, 4096),
|
|
1031
|
+
maxReadBytesBeforeAsk: readVolatile(config.maxReadBytesBeforeAsk, 51200),
|
|
1032
|
+
compressNudgeTokens: readVolatile(config.compressNudgeTokens, 120000),
|
|
1033
|
+
searchLimit: readVolatile(config.searchLimit, 5),
|
|
1034
|
+
snippetChars: readVolatile(config.snippetChars, 900),
|
|
1035
|
+
},
|
|
1036
|
+
ledger: {
|
|
1037
|
+
registered: stats.registered,
|
|
1038
|
+
denied: stats.denied,
|
|
1039
|
+
deniedBytes: stats.deniedBytes,
|
|
1040
|
+
bytesSandboxed: stats.bytesSandboxed,
|
|
1041
|
+
bytesIndexed: stats.bytesIndexed,
|
|
1042
|
+
},
|
|
1043
|
+
knowledge: store
|
|
1044
|
+
? (() => { try { return store.stats() } catch { return null } })()
|
|
1045
|
+
: null,
|
|
1046
|
+
context: (() => {
|
|
1047
|
+
const live = measureContext(ctx, session, config, stats)
|
|
1048
|
+
if (live.ok === true) return live
|
|
1049
|
+
if (stats.lastMeasure && stats.lastMeasure.ok === true) {
|
|
1050
|
+
return Object.assign({}, stats.lastMeasure, { stale: true, snapshotOf: 'last-tool-measurement' })
|
|
1051
|
+
}
|
|
1052
|
+
return live
|
|
1053
|
+
})(),
|
|
1054
|
+
sessionLookup: { source: sessionSource },
|
|
1055
|
+
storage: storageDir,
|
|
1056
|
+
})
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
sendJson(res, 500, { error: String((error && error.message) || error) })
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
scoped.effect(() => server.register({ kind: 'exact', path: STATUS_PATH, handler }), 'context-toolkit: status route')
|
|
1062
|
+
})
|
|
1063
|
+
} catch { /* a missing web server must not stop the plugin from loading */ }
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// ── observability tools ────────────────────────────────────────────────────
|
|
1067
|
+
|
|
1068
|
+
function registerObservabilityTools(ctx, config, stats, store, storageDir, log) {
|
|
1069
|
+
if (toolSwitch(config, 'stats')) {
|
|
1070
|
+
ctx.tools.register(textTool({
|
|
1071
|
+
name: 'ctk_stats',
|
|
1072
|
+
description: 'Report this plugin\'s savings ledger and knowledge-base usage. Shows how many calls the routing gate refused, how many bytes the sandbox kept out of the conversation, how many bytes were filed, and the per-tool call counts.',
|
|
1073
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
1074
|
+
async execute() {
|
|
1075
|
+
const kb = store ? store.stats() : { documents: 0, chunks: 0, bytes: 0 }
|
|
1076
|
+
const produced = stats.bytesSandboxed + stats.bytesIndexed + stats.deniedBytes
|
|
1077
|
+
const keptOut = produced
|
|
1078
|
+
const uptimeMs = Date.now() - stats.startedAt
|
|
1079
|
+
const lines = [
|
|
1080
|
+
'## ctk_stats',
|
|
1081
|
+
'',
|
|
1082
|
+
'### 节约台账 / savings ledger',
|
|
1083
|
+
'- 门禁拦截 denied: ' + stats.denied + ' 次(约 ' + stats.deniedBytes + ' 字节未进入上下文)',
|
|
1084
|
+
'- 沙箱保留 bytesSandboxed: ' + stats.bytesSandboxed + ' 字节(子进程产出但只回结论的部分)',
|
|
1085
|
+
'- 已归档 bytesIndexed: ' + stats.bytesIndexed + ' 字节(存入知识库、未留在对话里)',
|
|
1086
|
+
'- 合计 kept out of context: ' + keptOut + ' 字节 ≈ ' + Math.round(keptOut / 4) + ' tokens(按 4 字节/token 估算)',
|
|
1087
|
+
'',
|
|
1088
|
+
'### 知识库 / knowledge base',
|
|
1089
|
+
'- 文档 documents: ' + kb.documents,
|
|
1090
|
+
'- 片段 chunks: ' + kb.chunks,
|
|
1091
|
+
'- 原文字节 bytes: ' + kb.bytes,
|
|
1092
|
+
'- 位置: ' + storageDir,
|
|
1093
|
+
'',
|
|
1094
|
+
'### 本插件注册的工具',
|
|
1095
|
+
'- 已注册 registered: ' + stats.registered,
|
|
1096
|
+
'',
|
|
1097
|
+
'### 调用计数 / call counts',
|
|
1098
|
+
]
|
|
1099
|
+
const entries = Object.entries(stats.calls || {}).sort((a, b) => b[1] - a[1])
|
|
1100
|
+
if (entries.length) for (const [k, v] of entries) lines.push('- ' + k + ': ' + v)
|
|
1101
|
+
else lines.push('- (本次会话还没有调用过)')
|
|
1102
|
+
lines.push('')
|
|
1103
|
+
lines.push('- 运行时长 uptime: ' + Math.round(uptimeMs / 1000) + ' s')
|
|
1104
|
+
return { text: lines.join('\n'), isError: false }
|
|
1105
|
+
},
|
|
1106
|
+
}, 'ctk_stats'))
|
|
1107
|
+
stats.registered += 1
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
if (config['tools.contextReport']) {
|
|
1111
|
+
ctx.tools.register(textTool({
|
|
1112
|
+
name: 'ctk_context_report',
|
|
1113
|
+
description: 'Report this conversation\'s context occupancy: total pressure, surface occupancy, the split between system prompt / tool schemas / messages, the largest contributors, and the plugin\'s own savings ledger. Read-only.',
|
|
1114
|
+
parameters: {
|
|
1115
|
+
type: 'object',
|
|
1116
|
+
properties: {
|
|
1117
|
+
detail: { type: 'string', enum: ['brief', 'full'], description: 'brief (default) reports totals and suggestions; full adds the per-node list.' },
|
|
1118
|
+
},
|
|
1119
|
+
additionalProperties: false,
|
|
1120
|
+
},
|
|
1121
|
+
async execute(args, exec) {
|
|
1122
|
+
const session = exec && exec.agent ? exec.agent.session : undefined
|
|
1123
|
+
const lines = ['## ctk_context_report']
|
|
1124
|
+
const measured = measureContext(ctx, session, config, stats)
|
|
1125
|
+
if (measured.ok !== true) {
|
|
1126
|
+
lines.push('', 'unavailable: ' + (measured.why || 'no measurement'))
|
|
1127
|
+
lines.push('', '### 本插件节约台账 / savings ledger')
|
|
1128
|
+
lines.push('- 门禁拦截 denied: ' + stats.denied + ' 次(' + stats.deniedBytes + ' 字节未进入上下文)')
|
|
1129
|
+
lines.push('- 沙箱保留 bytesSandboxed: ' + stats.bytesSandboxed + ' 字节')
|
|
1130
|
+
lines.push('- 已归档 bytesIndexed: ' + stats.bytesIndexed + ' 字节')
|
|
1131
|
+
lines.push('- 已注册 registered: ' + stats.registered)
|
|
1132
|
+
return { text: lines.join('\n'), isError: true }
|
|
1133
|
+
}
|
|
1134
|
+
lines.push('')
|
|
1135
|
+
lines.push('- 总压力 totalTokens: ' + measured.totalTokens)
|
|
1136
|
+
lines.push('- 表面占用 surfaceTokens: ' + measured.surfaceTokens)
|
|
1137
|
+
lines.push('- 提示阈值 budget: ' + measured.budget + (measured.overBudget ? ' ← 已超' : ''))
|
|
1138
|
+
const bd = measured.breakdown
|
|
1139
|
+
if (bd) {
|
|
1140
|
+
lines.push('')
|
|
1141
|
+
lines.push('### 分类构成' + (bd.estimated ? '(降级估算)' : ''))
|
|
1142
|
+
const pct = (v) => (bd.total > 0 ? Math.round((v / bd.total) * 100) : 0)
|
|
1143
|
+
lines.push('- 系统提示 system: ' + bd.system + ' (' + pct(bd.system) + '%)')
|
|
1144
|
+
lines.push('- 工具 schema tools: ' + bd.tools + ' (' + pct(bd.tools) + '%)')
|
|
1145
|
+
lines.push('- 会话消息 messages: ' + bd.messages + ' (' + pct(bd.messages) + '%)')
|
|
1146
|
+
}
|
|
1147
|
+
if (measured.overBudget) {
|
|
1148
|
+
lines.push('')
|
|
1149
|
+
lines.push('### 压缩建议')
|
|
1150
|
+
lines.push('- 表面占用已超阈值:把只用于分析的大块输出改走 ctk_execute / ctk_batch_execute,让原始字节不进上下文。')
|
|
1151
|
+
lines.push('- 已进上下文的材料可用 ctk_index 归档,之后用 ctk_search 按需取片段。')
|
|
1152
|
+
}
|
|
1153
|
+
if (args && args.detail === 'full' && Array.isArray(measured.nodes)) {
|
|
1154
|
+
lines.push('')
|
|
1155
|
+
lines.push('### 占用大头(前 ' + measured.nodes.length + ' 项)')
|
|
1156
|
+
for (const n of measured.nodes) lines.push('- seq ' + n.seq + (n.system ? ' [system]' : '') + ': ' + n.tokens + ' tokens')
|
|
1157
|
+
}
|
|
1158
|
+
lines.push('')
|
|
1159
|
+
lines.push('### 本插件节约台账 / savings ledger')
|
|
1160
|
+
lines.push('- 门禁拦截 denied: ' + stats.denied + ' 次(' + stats.deniedBytes + ' 字节未进入上下文)')
|
|
1161
|
+
lines.push('- 沙箱保留 bytesSandboxed: ' + stats.bytesSandboxed + ' 字节')
|
|
1162
|
+
lines.push('- 已归档 bytesIndexed: ' + stats.bytesIndexed + ' 字节')
|
|
1163
|
+
lines.push('- 已注册 registered: ' + stats.registered)
|
|
1164
|
+
return { text: lines.join('\n'), isError: false }
|
|
1165
|
+
},
|
|
1166
|
+
}, 'ctk_context_report'))
|
|
1167
|
+
stats.registered += 1
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (toolSwitch(config, 'doctor')) {
|
|
1171
|
+
ctx.tools.register(textTool({
|
|
1172
|
+
name: 'ctk_doctor',
|
|
1173
|
+
description: 'Self-check the plugin\'s own runtime: whether the interpreters it needs can actually be launched, whether the scratch directory is writable, whether the knowledge base answers, and whether the routing gate is mounted. Run it first when something misbehaves.',
|
|
1174
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
1175
|
+
timeoutMs: 120000,
|
|
1176
|
+
async execute() {
|
|
1177
|
+
const lines = ['## ctk_doctor', '']
|
|
1178
|
+
const ok = (label, detail) => lines.push('[OK] ' + label + (detail ? ' — ' + detail : ''))
|
|
1179
|
+
const bad = (label, detail) => lines.push('[FAIL] ' + label + (detail ? ' — ' + detail : ''))
|
|
1180
|
+
const warn = (label, detail) => lines.push('[WARN] ' + label + (detail ? ' — ' + detail : ''))
|
|
1181
|
+
|
|
1182
|
+
// runtimes
|
|
1183
|
+
let runtimes = {}
|
|
1184
|
+
try { runtimes = probeRuntimes() } catch { /* reported below */ }
|
|
1185
|
+
const runtimeNames = Object.keys(runtimes)
|
|
1186
|
+
if (runtimeNames.length) ok('运行时 Runtimes', runtimeNames.join(', '))
|
|
1187
|
+
else bad('运行时 Runtimes', '找不到任何可启动的解释器')
|
|
1188
|
+
|
|
1189
|
+
// really launch one
|
|
1190
|
+
if (runtimes.javascript) {
|
|
1191
|
+
try {
|
|
1192
|
+
const r = await runProcess(runtimes.javascript, ['-e', 'console.log(1)'], { timeoutMs: 30000 })
|
|
1193
|
+
if (r.ok) ok('解释器实测', (r.stdout || '').trim())
|
|
1194
|
+
else bad('解释器实测', 'exit=' + r.exitCode + ' ' + String(r.error || r.stderr).slice(0, 120))
|
|
1195
|
+
} catch (error) { bad('解释器实测', String((error && error.message) || error)) }
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// scratch dir
|
|
1199
|
+
try {
|
|
1200
|
+
const dir = scratchDir()
|
|
1201
|
+
const probe = mkdtempSync(join(dir, 'ctk-doctor-'))
|
|
1202
|
+
rmSync(probe, { recursive: true, force: true })
|
|
1203
|
+
ok('临时目录 Scratch dir', dir)
|
|
1204
|
+
} catch (error) { bad('临时目录 Scratch dir', String((error && error.message) || error)) }
|
|
1205
|
+
|
|
1206
|
+
// knowledge base round-trip
|
|
1207
|
+
if (store) {
|
|
1208
|
+
try {
|
|
1209
|
+
const before = store.stats()
|
|
1210
|
+
const probeTarget = 'text:ctk-doctor:' + Date.now()
|
|
1211
|
+
store.indexDocument({ path: probeTarget, title: 'ctk-doctor-probe', text: 'doctor probe body zzq-marker' })
|
|
1212
|
+
const found = store.search(['zzq-marker'], { limit: 1 })
|
|
1213
|
+
const hit = found[0] && found[0].hits.length > 0
|
|
1214
|
+
store.purge({ path: probeTarget })
|
|
1215
|
+
if (hit) ok('知识库往返 Knowledge base', before.documents + ' documents before probe')
|
|
1216
|
+
else bad('知识库往返 Knowledge base', '写入成功但检索不到')
|
|
1217
|
+
} catch (error) { bad('知识库 Knowledge base', String((error && error.message) || error)) }
|
|
1218
|
+
} else warn('知识库 Knowledge base', '未打开(storageDir 不可写?)')
|
|
1219
|
+
|
|
1220
|
+
// gate
|
|
1221
|
+
if (config.routing) ok('路由门禁 Routing gate', 'enabled (denied ' + stats.denied + ' so far)')
|
|
1222
|
+
else warn('路由门禁 Routing gate', 'disabled in settings')
|
|
1223
|
+
|
|
1224
|
+
// storage
|
|
1225
|
+
ok('存储目录 Storage', storageDir)
|
|
1226
|
+
ok('版本 Version', '0.1.0')
|
|
1227
|
+
|
|
1228
|
+
lines.push('')
|
|
1229
|
+
const failed = lines.filter((l) => l.startsWith('[FAIL]')).length
|
|
1230
|
+
const warned = lines.filter((l) => l.startsWith('[WARN]')).length
|
|
1231
|
+
lines.push(failed ? failed + ' check(s) failed, ' + warned + ' warning(s)' : 'all checks passed' + (warned ? ' (' + warned + ' warning(s))' : ''))
|
|
1232
|
+
return { text: lines.join('\n'), isError: failed > 0 }
|
|
1233
|
+
},
|
|
1234
|
+
}, 'ctk_doctor'))
|
|
1235
|
+
stats.registered += 1
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** The session working directory wins over the process default. */
|
|
1240
|
+
function resolveCwd(requested, exec) {
|
|
1241
|
+
if (typeof requested === 'string' && requested.trim()) {
|
|
1242
|
+
return isAbsolute(requested) ? requested : resolve(process.cwd(), requested)
|
|
1243
|
+
}
|
|
1244
|
+
try {
|
|
1245
|
+
const fromSession = exec && exec.agent && exec.agent.session && exec.agent.session.header
|
|
1246
|
+
? exec.agent.session.header.cwd : null
|
|
1247
|
+
if (fromSession) return fromSession
|
|
1248
|
+
} catch { /* keep the process default */ }
|
|
1249
|
+
return process.cwd()
|
|
1250
|
+
}
|