@wingsbutterfly/dsh-rtk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +214 -0
  4. package/README.zh.md +211 -0
  5. package/THIRD_PARTY_NOTICES.md +51 -0
  6. package/docs/assets/how-it-works.svg +47 -0
  7. package/docs/verification.md +279 -0
  8. package/lib/command.d.ts +41 -0
  9. package/lib/command.d.ts.map +1 -0
  10. package/lib/command.js +137 -0
  11. package/lib/command.js.map +1 -0
  12. package/lib/compact/build.d.ts +15 -0
  13. package/lib/compact/build.d.ts.map +1 -0
  14. package/lib/compact/build.js +140 -0
  15. package/lib/compact/build.js.map +1 -0
  16. package/lib/compact/detect.d.ts +22 -0
  17. package/lib/compact/detect.d.ts.map +1 -0
  18. package/lib/compact/detect.js +54 -0
  19. package/lib/compact/detect.js.map +1 -0
  20. package/lib/compact/dsh-result.d.ts +49 -0
  21. package/lib/compact/dsh-result.d.ts.map +1 -0
  22. package/lib/compact/dsh-result.js +84 -0
  23. package/lib/compact/dsh-result.js.map +1 -0
  24. package/lib/compact/git.d.ts +21 -0
  25. package/lib/compact/git.d.ts.map +1 -0
  26. package/lib/compact/git.js +197 -0
  27. package/lib/compact/git.js.map +1 -0
  28. package/lib/compact/index.d.ts +39 -0
  29. package/lib/compact/index.d.ts.map +1 -0
  30. package/lib/compact/index.js +236 -0
  31. package/lib/compact/index.js.map +1 -0
  32. package/lib/compact/linter.d.ts +12 -0
  33. package/lib/compact/linter.d.ts.map +1 -0
  34. package/lib/compact/linter.js +118 -0
  35. package/lib/compact/linter.js.map +1 -0
  36. package/lib/compact/search.d.ts +16 -0
  37. package/lib/compact/search.d.ts.map +1 -0
  38. package/lib/compact/search.js +67 -0
  39. package/lib/compact/search.js.map +1 -0
  40. package/lib/compact/source.d.ts +22 -0
  41. package/lib/compact/source.d.ts.map +1 -0
  42. package/lib/compact/source.js +224 -0
  43. package/lib/compact/source.js.map +1 -0
  44. package/lib/compact/test-output.d.ts +12 -0
  45. package/lib/compact/test-output.d.ts.map +1 -0
  46. package/lib/compact/test-output.js +168 -0
  47. package/lib/compact/test-output.js.map +1 -0
  48. package/lib/compact/text.d.ts +22 -0
  49. package/lib/compact/text.d.ts.map +1 -0
  50. package/lib/compact/text.js +87 -0
  51. package/lib/compact/text.js.map +1 -0
  52. package/lib/config.d.ts +243 -0
  53. package/lib/config.d.ts.map +1 -0
  54. package/lib/config.js +183 -0
  55. package/lib/config.js.map +1 -0
  56. package/lib/index.d.ts +46 -0
  57. package/lib/index.d.ts.map +1 -0
  58. package/lib/index.js +405 -0
  59. package/lib/index.js.map +1 -0
  60. package/lib/metrics.d.ts +35 -0
  61. package/lib/metrics.d.ts.map +1 -0
  62. package/lib/metrics.js +51 -0
  63. package/lib/metrics.js.map +1 -0
  64. package/lib/rtk-executable.d.ts +51 -0
  65. package/lib/rtk-executable.d.ts.map +1 -0
  66. package/lib/rtk-executable.js +75 -0
  67. package/lib/rtk-executable.js.map +1 -0
  68. package/lib/rtk-rewrite.d.ts +88 -0
  69. package/lib/rtk-rewrite.d.ts.map +1 -0
  70. package/lib/rtk-rewrite.js +150 -0
  71. package/lib/rtk-rewrite.js.map +1 -0
  72. package/lib/runtime-guard.d.ts +31 -0
  73. package/lib/runtime-guard.d.ts.map +1 -0
  74. package/lib/runtime-guard.js +32 -0
  75. package/lib/runtime-guard.js.map +1 -0
  76. package/package.json +82 -0
  77. package/scripts/link-dsh.mjs +135 -0
  78. package/src/command.ts +174 -0
  79. package/src/compact/build.ts +154 -0
  80. package/src/compact/detect.ts +54 -0
  81. package/src/compact/dsh-result.ts +99 -0
  82. package/src/compact/git.ts +209 -0
  83. package/src/compact/index.ts +284 -0
  84. package/src/compact/linter.ts +126 -0
  85. package/src/compact/search.ts +73 -0
  86. package/src/compact/source.ts +244 -0
  87. package/src/compact/test-output.ts +184 -0
  88. package/src/compact/text.ts +86 -0
  89. package/src/config.ts +263 -0
  90. package/src/index.ts +431 -0
  91. package/src/metrics.ts +84 -0
  92. package/src/rtk-executable.ts +117 -0
  93. package/src/rtk-rewrite.ts +179 -0
  94. package/src/runtime-guard.ts +44 -0
package/src/index.ts ADDED
@@ -0,0 +1,431 @@
1
+ import { tmpdir } from 'node:os'
2
+ import { join } from 'node:path'
3
+
4
+ import type { Context } from '@deepseek-ai/cordis'
5
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
6
+ import type { PostToolDecision, ToolDispatchExecution, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
7
+ import type { CommandRuntime } from '@deepseek-ai/dsh-commands'
8
+ import type { SettingsProvider, SettingsScope } from '@deepseek-ai/dsh-settings'
9
+ import type { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
10
+
11
+ import { Config, normalizeConfig, type RtkConfig } from './config.js'
12
+ import { READ_COMPACTION_BANNER_PREFIX, compactToolResult } from './compact/index.js'
13
+ import { parseBashResult, renderBashResult } from './compact/dsh-result.js'
14
+ import { createMetricsTracker } from './metrics.js'
15
+ import { createRtkCommand } from './command.js'
16
+ import { resolveRtkExecutable, runExecutable } from './rtk-executable.js'
17
+ import { applyRtkHistoryScope, resolveRtkRewrite, type ShellKind } from './rtk-rewrite.js'
18
+ import { isStatusStale, shouldRequireRtkAvailability, shouldSkipRewrite, type RtkRuntimeStatus } from './runtime-guard.js'
19
+
20
+ /** Cordis plugin name used by loader diagnostics. */
21
+ export const name = 'rtk'
22
+
23
+ /**
24
+ * The tool runtime is a hard dependency: both halves of this plugin are
25
+ * `tools/*` listeners, and without the registry there is nothing to rewrite or
26
+ * compact.
27
+ *
28
+ * `settings` is declared too, and not because the plugin cannot run without it
29
+ * — it can, entirely from the composition's `config:` block. It is declared
30
+ * because the provider registers *asynchronously*: it reads the settings
31
+ * document first, so a plain `ctx.get('settings')` during `apply` can find
32
+ * nothing and the namespace silently never registers. Observed exactly that in
33
+ * production (`/rtk` reported "settings service unavailable" while a sibling
34
+ * `ctx.get('spillStore')` succeeded). Declaring it makes Cordis park this
35
+ * plugin until the provider appears, then activate it.
36
+ *
37
+ * `commands` and `systemPrompt` stay optional `ctx.get` lookups: they are
38
+ * registered early enough to be present, and a composition without them should
39
+ * still get the optimization.
40
+ */
41
+ export const inject = ['tools', 'settings']
42
+
43
+ export { Config }
44
+
45
+ /** Tools whose `command` argument can be rewritten. */
46
+ const REWRITABLE_TOOLS = new Set(['bash', 'pwsh'])
47
+
48
+ /** Path of the rtk history database this plugin scopes rewritten commands to. */
49
+ function rtkHistoryDbPath(): string {
50
+ return join(tmpdir(), 'dsh-rtk', 'history.db')
51
+ }
52
+
53
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
54
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined
55
+ }
56
+
57
+ /**
58
+ * Appended once per session when rtk cannot be found.
59
+ *
60
+ * A missing binary is the only failure that silently costs the user the whole
61
+ * feature, so it is worth one short line — but only one, and only for a
62
+ * session that has not been told yet.
63
+ */
64
+ const MISSING_RTK_NOTICE = '[rtk] rtk not found; rewriting is off — /rtk verify shows install options'
65
+
66
+ /** The guidance injected while lossy read compaction is active. */
67
+ const SOURCE_FILTER_TROUBLESHOOTING_NOTE =
68
+ 'RTK note: if a file edit repeatedly fails because the old text does not match, read compaction may have altered the copy you are editing against. Ask the user to turn off `readCompaction` in the rtk settings, re-read the file, apply the edit, then turn it back on.'
69
+
70
+ /** Whether lossy `read` compaction is enabled together with its safeguards. */
71
+ function needsSourceFilterNote(config: RtkConfig): boolean {
72
+ const compaction = config.outputCompaction
73
+ return (
74
+ config.enabled &&
75
+ compaction.enabled &&
76
+ compaction.readCompaction.enabled &&
77
+ compaction.sourceCodeFilteringEnabled &&
78
+ compaction.sourceCodeFiltering !== 'none' &&
79
+ (compaction.smartTruncate.enabled || compaction.truncate.enabled)
80
+ )
81
+ }
82
+
83
+ /**
84
+ * Register RTK command rewriting and tool-output compaction.
85
+ *
86
+ * Both halves hang off the tool pipeline rather than wrapping a tool:
87
+ *
88
+ * - **Rewriting** rides `tools/execute`. The harness deliberately keeps
89
+ * `tools/pre-execute` from mutating arguments (they are logged and presented
90
+ * before dispatch), so the around-dispatch stage is the only seam where the
91
+ * command that actually runs can differ from the one that was recorded. The
92
+ * replacement is scoped to the call: the original arguments are restored as
93
+ * soon as dispatch returns, so later pipeline stages and the session log see
94
+ * what the model asked for.
95
+ * - **Compaction** rides `tools/post-execute`, which is the stage that may
96
+ * replace result content.
97
+ *
98
+ * @param ctx - the agent-scoped context this row was mounted into.
99
+ * @param rawConfig - the row's `config:` block, already schema-validated.
100
+ */
101
+ export function apply(ctx: Context, rawConfig: RtkConfig): void {
102
+ /**
103
+ * Whether the harness bounds oversized results itself.
104
+ *
105
+ * `dsh-spill-policy` writes a full output to disk and keeps a head/tail
106
+ * preview inline, so it truncates *recoverably*. This plugin's own hard
107
+ * truncation is lossy, and it runs first: at 12 000 characters it fires far
108
+ * below spill's threshold (50 000 by default), so spill never sees a large
109
+ * result at all and the reader loses the spill file that would have let them
110
+ * read the rest. When spill is mounted this plugin steps aside; when it is
111
+ * not, the truncation stays as the only bound on a runaway command.
112
+ */
113
+ const spillHandlesLargeOutput = ctx.get('spillStore') !== undefined
114
+
115
+ /**
116
+ * Apply the spill coordination to a freshly resolved configuration.
117
+ *
118
+ * Gated on an explicit switch rather than on "did the row set truncate?":
119
+ * the loader hands this function a schema-resolved config where every field
120
+ * already carries its default, so an absent key is indistinguishable from an
121
+ * explicit one. `deferToHarnessSpill: false` is how a row keeps its own
122
+ * bound regardless.
123
+ */
124
+ function coordinate(next: RtkConfig): RtkConfig {
125
+ if (spillHandlesLargeOutput && next.outputCompaction.deferToHarnessSpill) {
126
+ next.outputCompaction.truncate.enabled = false
127
+ }
128
+ return next
129
+ }
130
+
131
+ let config = coordinate(normalizeConfig(rawConfig))
132
+ let runtimeStatus: RtkRuntimeStatus = { rtkAvailable: false }
133
+ const metrics = createMetricsTracker()
134
+ /** Rewrite decisions awaiting their result, keyed by call id, for `suggest` mode. */
135
+ const pendingSuggestions = new Map<string, string>()
136
+ /** Call ids whose result should carry the one-time "rtk is missing" notice. */
137
+ const missingRtkCalls = new Set<string>()
138
+ /** Sessions already told that rtk is absent, so the notice is emitted once each. */
139
+ const notifiedAgents = new Set<string>()
140
+
141
+ // The settings namespace is process-global, so only the first instance of
142
+ // this plugin can own it. A host-plane row and a preset row both mounted, or
143
+ // two presets mounting the same row, would otherwise fail the second mount
144
+ // outright — a configuration convenience must never be the reason a whole
145
+ // preset refuses to activate. The loser follows the winner through the
146
+ // settings event instead.
147
+ const settings = ctx.get('settings') as SettingsProvider | undefined
148
+ let ownedScope: SettingsScope<unknown> | undefined
149
+ // Surfaced by `/rtk show`. A silently swallowed registration failure hides
150
+ // the only runtime-editable configuration path this plugin has, and the
151
+ // failure mode (settings edits simply do nothing) is invisible otherwise.
152
+ let settingsNote: string
153
+ if (settings === undefined) {
154
+ settingsNote = 'settings service unavailable — configuration comes from the composition only'
155
+ } else {
156
+ try {
157
+ const scope = settings.register('dsh-rtk', Config, { base: rawConfig })
158
+ ownedScope = scope as unknown as SettingsScope<unknown>
159
+ config = coordinate(normalizeConfig(scope.get()))
160
+ scope.watch((next) => {
161
+ config = coordinate(normalizeConfig(next))
162
+ applySourceFilterNote()
163
+ })
164
+ settingsNote = 'the `dsh-rtk` namespace in the harness settings document'
165
+ } catch (error) {
166
+ settingsNote = `settings namespace unavailable (${error instanceof Error ? error.message : String(error)}) — configuration comes from the composition only`
167
+ const existing = settings.get('dsh-rtk')
168
+ if (existing !== undefined) config = coordinate(normalizeConfig(existing))
169
+ ctx.on('settings/updated', (ns: string, next: unknown) => {
170
+ if (ns !== 'dsh-rtk') return
171
+ config = coordinate(normalizeConfig(next))
172
+ applySourceFilterNote()
173
+ })
174
+ }
175
+ }
176
+
177
+ let disposeNote: (() => void) | undefined
178
+ const systemPrompt = ctx.get('systemPrompt') as SystemPrompt | undefined
179
+ function applySourceFilterNote(): void {
180
+ if (systemPrompt === undefined) return
181
+ if (needsSourceFilterNote(config)) {
182
+ if (disposeNote !== undefined) return
183
+ disposeNote = systemPrompt.section({ name: 'rtk:read-compaction', order: 1010, text: SOURCE_FILTER_TROUBLESHOOTING_NOTE })
184
+ return
185
+ }
186
+ disposeNote?.()
187
+ disposeNote = undefined
188
+ }
189
+ applySourceFilterNote()
190
+ ctx.effect(() => () => {
191
+ disposeNote?.()
192
+ disposeNote = undefined
193
+ }, 'dsh-rtk.prompt-note')
194
+
195
+ async function refreshRuntimeStatus(): Promise<RtkRuntimeStatus> {
196
+ const resolution = await resolveRtkExecutable({ configured: config.rtkExecutable })
197
+ const probe = await runExecutable(resolution.command, ['--version'], { timeoutMs: 5000 })
198
+ const base = {
199
+ rtkExecutableCommand: resolution.command,
200
+ ...(resolution.resolvedPath === undefined ? {} : { rtkExecutablePath: resolution.resolvedPath }),
201
+ rtkExecutableResolver: resolution.resolver,
202
+ ...(resolution.warning === undefined ? {} : { rtkExecutableResolutionWarning: resolution.warning }),
203
+ lastCheckedAt: Date.now(),
204
+ }
205
+ runtimeStatus =
206
+ probe.code === 0
207
+ ? { rtkAvailable: true, ...base }
208
+ : {
209
+ rtkAvailable: false,
210
+ ...base,
211
+ lastError: (probe.stderr || probe.stdout).replace(/\s+/g, ' ').trim() || `exit ${probe.code}`,
212
+ }
213
+ return runtimeStatus
214
+ }
215
+
216
+ async function ensureRuntimeStatusFresh(): Promise<void> {
217
+ if (!shouldRequireRtkAvailability(config)) return
218
+ if (!isStatusStale(runtimeStatus, Date.now())) return
219
+ try {
220
+ await refreshRuntimeStatus()
221
+ } catch (error) {
222
+ runtimeStatus = {
223
+ rtkAvailable: false,
224
+ lastCheckedAt: Date.now(),
225
+ lastError: error instanceof Error ? error.message : String(error),
226
+ }
227
+ }
228
+ }
229
+
230
+ // ── command rewriting ────────────────────────────────────────────────────
231
+
232
+ ctx.on('tools/execute', async (exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>) => {
233
+ if (!config.enabled) return next()
234
+ if (config.mode !== 'rewrite') return next()
235
+ if (!REWRITABLE_TOOLS.has(exec.name)) return next()
236
+
237
+ const args = asRecord(exec.arguments)
238
+ if (args === undefined || typeof args.command !== 'string') return next()
239
+
240
+ await ensureRuntimeStatusFresh()
241
+ if (shouldSkipRewrite(config, runtimeStatus)) {
242
+ // The guard is standing the rewrite down because rtk is unavailable. Flag
243
+ // this result so post-execute can say so once per session.
244
+ if (config.notifyWhenRtkMissing) {
245
+ if (missingRtkCalls.size > 256) missingRtkCalls.clear()
246
+ missingRtkCalls.add(exec.callId)
247
+ }
248
+ return next()
249
+ }
250
+
251
+ const decision = await resolveRtkRewrite(args.command, {
252
+ runner: runExecutable,
253
+ executable: runtimeStatus.rtkExecutableCommand ?? config.rtkExecutable,
254
+ timeoutMs: config.rewriteTimeoutMs,
255
+ signal: exec.signal,
256
+ })
257
+ if (!decision.changed) return next()
258
+
259
+ // `pwsh` is a rewrite target too, and PowerShell has no `export`.
260
+ const shell: ShellKind = exec.name === 'pwsh' ? 'powershell' : 'posix'
261
+ const command = applyRtkHistoryScope(decision.rewrittenCommand, rtkHistoryDbPath(), process.env.RTK_DB_PATH, shell)
262
+ if (config.showRewriteNotifications) {
263
+ // A cancelled call can be finalized on the `final-result` path, which
264
+ // bypasses post-execute and would leave its entry behind. The map is
265
+ // bounded so a notification nobody reads cannot grow without limit.
266
+ if (pendingSuggestions.size > 256) pendingSuggestions.clear()
267
+ pendingSuggestions.set(exec.callId, `[rtk] rewrote: ${decision.originalCommand} -> ${decision.rewrittenCommand}`)
268
+ }
269
+
270
+ // `arguments` is declared readonly, but the around-dispatch stage is the
271
+ // one place the registry re-reads it before invoking the body. Restoring it
272
+ // in `finally` keeps every later stage — post-execute, content
273
+ // finalization, and the observation in `tools/result` — looking at the call
274
+ // the model actually made.
275
+ const mutable = exec as unknown as { arguments: unknown }
276
+ mutable.arguments = { ...args, command }
277
+ try {
278
+ return await next()
279
+ } catch (error) {
280
+ pendingSuggestions.delete(exec.callId)
281
+ throw error
282
+ } finally {
283
+ mutable.arguments = args
284
+ }
285
+ })
286
+
287
+ // ── suggest mode ─────────────────────────────────────────────────────────
288
+
289
+ ctx.on('tools/execute', async (exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>) => {
290
+ if (!config.enabled || config.mode !== 'suggest') return next()
291
+ if (!REWRITABLE_TOOLS.has(exec.name)) return next()
292
+
293
+ const args = asRecord(exec.arguments)
294
+ if (args === undefined || typeof args.command !== 'string') return next()
295
+
296
+ await ensureRuntimeStatusFresh()
297
+ if (shouldSkipRewrite(config, runtimeStatus)) {
298
+ // The guard is standing the rewrite down because rtk is unavailable. Flag
299
+ // this result so post-execute can say so once per session.
300
+ if (config.notifyWhenRtkMissing) {
301
+ if (missingRtkCalls.size > 256) missingRtkCalls.clear()
302
+ missingRtkCalls.add(exec.callId)
303
+ }
304
+ return next()
305
+ }
306
+
307
+ const decision = await resolveRtkRewrite(args.command, {
308
+ runner: runExecutable,
309
+ executable: runtimeStatus.rtkExecutableCommand ?? config.rtkExecutable,
310
+ timeoutMs: config.rewriteTimeoutMs,
311
+ signal: exec.signal,
312
+ })
313
+ if (decision.changed) {
314
+ if (pendingSuggestions.size > 256) pendingSuggestions.clear()
315
+ pendingSuggestions.set(exec.callId, `[rtk] suggestion: ${decision.rewrittenCommand}`)
316
+ }
317
+ try {
318
+ return await next()
319
+ } catch (error) {
320
+ pendingSuggestions.delete(exec.callId)
321
+ throw error
322
+ }
323
+ })
324
+
325
+ // ── output compaction ────────────────────────────────────────────────────
326
+
327
+ ctx.on('tools/post-execute', async (exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>) => {
328
+ const decision = await next()
329
+ let notice = pendingSuggestions.get(exec.callId)
330
+ pendingSuggestions.delete(exec.callId)
331
+
332
+ if (missingRtkCalls.delete(exec.callId) && config.notifyWhenRtkMissing) {
333
+ const agentKey = exec.agent?.id
334
+ if (agentKey !== undefined && !notifiedAgents.has(agentKey)) {
335
+ if (notifiedAgents.size > 256) notifiedAgents.clear()
336
+ notifiedAgents.add(agentKey)
337
+ notice = MISSING_RTK_NOTICE
338
+ }
339
+ }
340
+
341
+ if (decision.kind !== 'accept') return decision
342
+ if (!config.enabled) return decision
343
+
344
+ const source: readonly ContentBlock[] = decision.content ?? result.content
345
+ if (source.length === 0) return decision
346
+
347
+ let content: readonly ContentBlock[] = source
348
+ try {
349
+ const outcome = compactToolResult({ toolName: exec.name, args: exec.arguments, content: source }, config)
350
+ if (outcome.changed && outcome.content !== undefined) {
351
+ content = outcome.content as ContentBlock[]
352
+ if (outcome.metadata !== undefined && config.outputCompaction.trackSavings) {
353
+ metrics.track(
354
+ source.map((block) => (block as { text?: string }).text ?? '').join('\n'),
355
+ content.map((block) => (block as { text?: string }).text ?? '').join('\n'),
356
+ exec.name,
357
+ outcome.techniques,
358
+ )
359
+ }
360
+ }
361
+ } catch {
362
+ // Compaction is an optimization; a bug in it must not break the tool call.
363
+ return notice === undefined ? decision : { kind: 'accept', content: appendNotice(source, notice) }
364
+ }
365
+
366
+ // A sibling listener may have attached contexts for the next request.
367
+ // Rebuilding the decision without them would drop them silently, and the
368
+ // shipped search tool does exactly that when a result was capped.
369
+ const contexts = decision.additionalContexts
370
+ const rebuild = (blocks: ContentBlock[]): PostToolDecision => ({
371
+ kind: 'accept',
372
+ content: blocks,
373
+ ...(contexts === undefined ? {} : { additionalContexts: contexts }),
374
+ })
375
+
376
+ if (notice === undefined) {
377
+ return content === source ? decision : rebuild([...content])
378
+ }
379
+ return rebuild(appendNotice(content, notice))
380
+ })
381
+
382
+ // ── the /rtk command ─────────────────────────────────────────────────────
383
+
384
+ const commands = ctx.get('commands') as CommandRuntime | undefined
385
+ if (commands !== undefined) {
386
+ const command = createRtkCommand({
387
+ getConfig: () => config,
388
+ resetConfig: async () => {
389
+ if (ownedScope === undefined) return
390
+ await ownedScope.replace({})
391
+ config = coordinate(normalizeConfig(settings?.get('dsh-rtk')))
392
+ },
393
+ getRuntimeStatus: () => runtimeStatus,
394
+ refreshRuntimeStatus,
395
+ getMetrics: () => metrics.summary(),
396
+ clearMetrics: () => metrics.clear(),
397
+ configLocation: () => settingsNote,
398
+ })
399
+ ctx.effect(() => commands.register(command), 'dsh-rtk.command')
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Insert a one-line notice into a result, ahead of any trailing status markers.
405
+ *
406
+ * The markers must stay last. The harness parses the final line for the exit
407
+ * status — the model is told to check `[exit code: N]` on every call, and the
408
+ * Web UI derives its exit-status pill from the same line. Appending a notice
409
+ * after them would break both silently, which is exactly what an earlier
410
+ * version of this function did.
411
+ */
412
+ function appendNotice(content: readonly ContentBlock[], notice: string): ContentBlock[] {
413
+ const blocks: ContentBlock[] = [...content]
414
+ for (let index = blocks.length - 1; index >= 0; index -= 1) {
415
+ const block = blocks[index]
416
+ if (block === undefined || block.type !== 'text') continue
417
+
418
+ const parts = parseBashResult(block.text)
419
+ if (parts.empty || parts.markers.length === 0) {
420
+ blocks[index] = { type: 'text', text: `${block.text}\n${notice}` }
421
+ return blocks
422
+ }
423
+
424
+ const stdout = parts.stdout.length > 0 ? `${parts.stdout}\n${notice}` : notice
425
+ blocks[index] = { type: 'text', text: renderBashResult({ ...parts, stdout }) }
426
+ return blocks
427
+ }
428
+ return [...blocks, { type: 'text', text: notice }]
429
+ }
430
+
431
+ export { READ_COMPACTION_BANNER_PREFIX }
package/src/metrics.ts ADDED
@@ -0,0 +1,84 @@
1
+ /** Per-tool savings accumulators for the current session. */
2
+ export interface ToolSavings {
3
+ calls: number
4
+ originalChars: number
5
+ compactedChars: number
6
+ }
7
+
8
+ /** One session's compaction savings. */
9
+ export interface MetricsSummary {
10
+ calls: number
11
+ originalChars: number
12
+ compactedChars: number
13
+ savedChars: number
14
+ savedPercent: number
15
+ byTool: Record<string, ToolSavings>
16
+ /** How often each technique fired, across all calls. */
17
+ byTechnique: Record<string, number>
18
+ }
19
+
20
+ /** Accumulates compaction savings; one instance per plugin application. */
21
+ export interface MetricsTracker {
22
+ /** Record one compacted result. A no-op result must not be passed here. */
23
+ track(originalText: string, compactedText: string, tool: string, techniques: readonly string[]): void
24
+ /** Current totals, recomputed on each call so callers cannot mutate them. */
25
+ summary(): MetricsSummary
26
+ /** Drop all recorded savings. */
27
+ clear(): void
28
+ }
29
+
30
+ function emptyTotals(): ToolSavings {
31
+ return { calls: 0, originalChars: 0, compactedChars: 0 }
32
+ }
33
+
34
+ /**
35
+ * Build the session metrics accumulator.
36
+ *
37
+ * Savings are approximate by construction — character counts stand in for
38
+ * tokens — but they are measured on exactly the strings that were swapped, so
39
+ * the ratio is honest even when the absolute number is not.
40
+ */
41
+ export function createMetricsTracker(): MetricsTracker {
42
+ let calls = 0
43
+ let originalChars = 0
44
+ let compactedChars = 0
45
+ const byTool = new Map<string, ToolSavings>()
46
+ const byTechnique = new Map<string, number>()
47
+
48
+ return {
49
+ track(originalText, compactedText, tool, techniques) {
50
+ calls += 1
51
+ originalChars += originalText.length
52
+ compactedChars += compactedText.length
53
+
54
+ const totals = byTool.get(tool) ?? emptyTotals()
55
+ totals.calls += 1
56
+ totals.originalChars += originalText.length
57
+ totals.compactedChars += compactedText.length
58
+ byTool.set(tool, totals)
59
+
60
+ for (const technique of techniques) byTechnique.set(technique, (byTechnique.get(technique) ?? 0) + 1)
61
+ },
62
+
63
+ summary() {
64
+ const savedChars = Math.max(0, originalChars - compactedChars)
65
+ return {
66
+ calls,
67
+ originalChars,
68
+ compactedChars,
69
+ savedChars,
70
+ savedPercent: originalChars === 0 ? 0 : Math.round((savedChars / originalChars) * 1000) / 10,
71
+ byTool: Object.fromEntries(byTool),
72
+ byTechnique: Object.fromEntries(byTechnique),
73
+ }
74
+ },
75
+
76
+ clear() {
77
+ calls = 0
78
+ originalChars = 0
79
+ compactedChars = 0
80
+ byTool.clear()
81
+ byTechnique.clear()
82
+ },
83
+ }
84
+ }
@@ -0,0 +1,117 @@
1
+ import { execFile } from 'node:child_process'
2
+
3
+ /** One settled `rtk` invocation. */
4
+ export interface RtkRunResult {
5
+ code: number
6
+ stdout: string
7
+ stderr: string
8
+ }
9
+
10
+ /** How an rtk executable path was discovered. */
11
+ export type RtkResolverName = 'where' | 'which' | 'configured'
12
+
13
+ /** The executable this plugin will use, and how sure it is. */
14
+ export interface RtkExecutableResolution {
15
+ command: string
16
+ resolvedPath?: string
17
+ resolver: RtkResolverName
18
+ warning?: string
19
+ }
20
+
21
+ /** Everything the resolver needs; all fields have production defaults. */
22
+ export interface ResolveRtkExecutableOptions {
23
+ /** Configured executable name or path. */
24
+ configured: string
25
+ /** Command used to look the name up on PATH. */
26
+ resolverCommand?: string
27
+ timeoutMs?: number
28
+ platform?: NodeJS.Platform
29
+ }
30
+
31
+ /** Resolver executable name for a platform. */
32
+ export function resolverNameFor(platform: NodeJS.Platform): 'where' | 'which' {
33
+ return platform === 'win32' ? 'where' : 'which'
34
+ }
35
+
36
+ /**
37
+ * Invoke one executable and settle with its output.
38
+ *
39
+ * A missing binary or a non-zero exit resolves rather than rejects: every
40
+ * caller here treats "rtk said no" as an ordinary outcome, and rejecting would
41
+ * make each call site wrap the same try/catch. Arguments are passed as an
42
+ * array, so no command string is ever handed to a shell.
43
+ */
44
+ export function runExecutable(
45
+ command: string,
46
+ args: readonly string[],
47
+ options: { timeoutMs?: number; signal?: AbortSignal } = {},
48
+ ): Promise<RtkRunResult> {
49
+ return new Promise((resolve) => {
50
+ execFile(
51
+ command,
52
+ [...args],
53
+ {
54
+ timeout: options.timeoutMs ?? 3000,
55
+ maxBuffer: 4 * 1024 * 1024,
56
+ windowsHide: true,
57
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
58
+ },
59
+ (error, stdout, stderr) => {
60
+ if (error === null) {
61
+ resolve({ code: 0, stdout, stderr })
62
+ return
63
+ }
64
+ const code = typeof (error as { code?: unknown }).code === 'number' ? (error as { code: number }).code : 1
65
+ resolve({ code, stdout: stdout ?? '', stderr: stderr ?? '' })
66
+ },
67
+ )
68
+ })
69
+ }
70
+
71
+ /** First non-empty line of resolver output, with surrounding quotes removed. */
72
+ export function parseExecutablePath(stdout: string): string | undefined {
73
+ for (const line of stdout.split(/\r?\n/)) {
74
+ let candidate = line.trim()
75
+ if (candidate.length >= 2) {
76
+ const first = candidate[0]
77
+ const last = candidate[candidate.length - 1]
78
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) candidate = candidate.slice(1, -1)
79
+ }
80
+ if (candidate) return candidate
81
+ }
82
+ return undefined
83
+ }
84
+
85
+ /**
86
+ * Resolve the rtk executable, preferring the configured value.
87
+ *
88
+ * A configured value that is not a bare name is taken as an absolute path and
89
+ * used verbatim — the deployment said where rtk lives, so a PATH probe would
90
+ * only second-guess it. Bare names are looked up so `/rtk verify` can report a
91
+ * real path; a failed lookup is not fatal, because the name may still resolve
92
+ * when the command actually runs.
93
+ */
94
+ export async function resolveRtkExecutable(options: ResolveRtkExecutableOptions): Promise<RtkExecutableResolution> {
95
+ const platform = options.platform ?? process.platform
96
+ const configured = options.configured.trim() || 'rtk'
97
+ const looksLikePath = configured.includes('/') || configured.includes('\\')
98
+
99
+ if (looksLikePath) {
100
+ return { command: configured, resolvedPath: configured, resolver: 'configured' }
101
+ }
102
+
103
+ const resolver = options.resolverCommand ?? resolverNameFor(platform)
104
+ const result = await runExecutable(resolver, [configured], { timeoutMs: options.timeoutMs ?? 1000 })
105
+ const resolvedPath = parseExecutablePath(result.stdout)
106
+
107
+ if (result.code === 0 && resolvedPath !== undefined) {
108
+ return { command: resolvedPath, resolvedPath, resolver: resolver as RtkResolverName }
109
+ }
110
+
111
+ const detail = (result.stderr || result.stdout).replace(/\s+/g, ' ').trim()
112
+ return {
113
+ command: configured,
114
+ resolver: resolver as RtkResolverName,
115
+ warning: `could not resolve ${configured} via ${resolver}${detail ? `: ${detail}` : ''}`,
116
+ }
117
+ }