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/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(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<')
683
+ .replace(/&gt;/gi, '>').replace(/&quot;/gi, '"').replace(/&#39;/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
+ }