@quadiusai/dsh-greenharness 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Line classifiers shared by the distiller, the verifier fingerprint and the tests.
3
+ * Each pattern describes one kind of line seen in test runners, compilers, linters
4
+ * and shells. Patterns only decide what to keep; they never rewrite text.
5
+ */
6
+
7
+ /** Lines that carry a failure signal (failures, errors, assertion detail, stack-trace headers). */
8
+ export const FAIL_RE = /\b(?:fail(?:s|ed|ure|ures|ing)?|errors?|exceptions?|tracebacks?|panic(?:ked)?|assert(?:ion|ions|ed)?|expected|received|actual|not ok|typeerror|referenceerror|syntaxerror|rangeerror|cannot find|no such file|segmentation fault|timed? ?out|killed)\b|[✗✖×]|^\s*E\s{3,}/i
9
+
10
+ /** Summary lines that state zero failures; these are summaries, not failures. */
11
+ export const ZERO_RE = /\b(?:0\s+(?:failed|failures?|errors?|problems?)|(?:failed|failures?|errors?)\s*[:=]\s*0)\b/i
12
+
13
+ /** Result summaries and run metadata that must survive distillation. */
14
+ export const SUMMARY_RE = /\b\d+\s+(?:passed|failed|skipped|todo|errors?|failures?|tests?|suites?|problems?|examples?)\b|^\s*Tests?\s*:|^\s*Test Files\b|^\s*#\s*(?:tests|pass|fail|suites|duration_ms)\b|^\s*(?:ok|not ok)\b|\bexit code\b|\bran \d+\b|^={3,}.*={3,}\s*$|\bdone in\b|\bfinished in\b|\bduration\b/i
15
+
16
+ /** Lines a human must not miss even when they are not failures (security, deprecation, permissions). */
17
+ export const SAFETY_RE = /\b(?:warn(?:ing|ings)?|deprecat\w*|secur\w*|cve-\d{4}-\d+|permission|denied|unauthori[sz]ed|forbidden|credential\w*|secret\w*|vulnerab\w*|fatal)\b/i
18
+
19
+ /** Spinner frames and progress bars: rendering noise with no information. */
20
+ export const PROGRESS_RE = /^\s*[|/\\-]\s*$|^\s*\[[#=\- ]{4,}\]?\s*\d{0,3}%?\s*$|^\s*\d{1,3}%\s*\|/
21
+
22
+ /** Stack-trace frame headers in common runtimes. */
23
+ export const FRAME_RE = /^\s*(?:at\s+\S|File "[^"]+", line \d+|❯\s|-->\s)/
24
+
25
+ /** Frames inside installed libraries or the runtime itself; they rarely explain an application failure. */
26
+ export const LIB_FRAME_RE = /node_modules[\\/]|node:internal|\binternal\/|site-packages[\\/]|dist-packages[\\/]|\/usr\/lib|\/\.nvm\/|\/\.pyenv\/|<frozen |<internal\//
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Per-request API cost from provider-reported usage.
3
+ *
4
+ * Usage counts are disjoint, as DSH reports them: `inputTokens` is uncached input,
5
+ * `cacheReadTokens` is cached input, `cacheWriteTokens` is cache creation, and `outputTokens`
6
+ * includes reasoning tokens. Rates are USD per million tokens.
7
+ *
8
+ * @param {{ inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number } | undefined} usage
9
+ * @param {{ inputPerMTok?: number, cachedInputPerMTok?: number, outputPerMTok?: number } | undefined} price
10
+ * @returns {number} estimated cost in USD; 0 when no price is configured for the route.
11
+ */
12
+ export function usageCostUsd(usage, price) {
13
+ if (usage === undefined || price === undefined) return 0
14
+ const perToken = rate => (rate ?? 0) / 1_000_000
15
+ const input = usage.inputTokens ?? 0
16
+ const cacheWrite = usage.cacheWriteTokens ?? 0
17
+ const cacheRead = usage.cacheReadTokens ?? 0
18
+ const output = usage.outputTokens ?? 0
19
+ return input * perToken(price.inputPerMTok)
20
+ + cacheWrite * perToken(price.inputPerMTok)
21
+ + cacheRead * perToken(price.cachedInputPerMTok ?? price.inputPerMTok)
22
+ + output * perToken(price.outputPerMTok)
23
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Content-addressed index of file reads still visible in the model context.
3
+ *
4
+ * A read whose rendered content is byte-identical to an earlier read of the same file range
5
+ * can be replaced by a short pointer to that earlier result. The pointer is only valid while
6
+ * the earlier result is still in context, so the index is cleared on compaction and pruning
7
+ * and forgets a path whenever the agent edits or rewrites it.
8
+ */
9
+
10
+ /**
11
+ * Create an empty read index.
12
+ * @returns {{ bySession: Map<string, Map<string, { path: string, digest: string, callId: string }>> }}
13
+ */
14
+ export function createReadIndex() {
15
+ return { bySession: new Map() }
16
+ }
17
+
18
+ /**
19
+ * Key for one file range.
20
+ * @param {string} path - the file path as the agent passed it.
21
+ * @param {number | null} offset - first line, or null when defaulted.
22
+ * @param {number | null} limit - line count, or null when defaulted.
23
+ * @returns {string} the range key.
24
+ */
25
+ function rangeKey(path, offset, limit) {
26
+ return JSON.stringify([path, offset ?? null, limit ?? null])
27
+ }
28
+
29
+ /**
30
+ * Record a successful read.
31
+ * @param {ReturnType<typeof createReadIndex>} index - the read index (mutated).
32
+ * @param {string} sessionId - the owning session.
33
+ * @param {{ path: string, offset?: number | null, limit?: number | null, digest: string, callId: string }} read - the read.
34
+ */
35
+ export function rememberRead(index, sessionId, read) {
36
+ const entries = index.bySession.get(sessionId) ?? new Map()
37
+ entries.set(rangeKey(read.path, read.offset, read.limit), { path: read.path, digest: read.digest, callId: read.callId })
38
+ index.bySession.set(sessionId, entries)
39
+ }
40
+
41
+ /**
42
+ * Find an earlier read of the same range with identical content.
43
+ * @param {ReturnType<typeof createReadIndex>} index - the read index.
44
+ * @param {string} sessionId - the owning session.
45
+ * @param {{ path: string, offset?: number | null, limit?: number | null, digest: string }} read - the new read.
46
+ * @returns {string | undefined} the call id of the identical earlier read, if one is indexed.
47
+ */
48
+ export function findUnchangedRead(index, sessionId, read) {
49
+ const entry = index.bySession.get(sessionId)?.get(rangeKey(read.path, read.offset, read.limit))
50
+ return entry !== undefined && entry.digest === read.digest ? entry.callId : undefined
51
+ }
52
+
53
+ /**
54
+ * Forget every indexed range of a path, after the agent edited or rewrote it.
55
+ * @param {ReturnType<typeof createReadIndex>} index - the read index (mutated).
56
+ * @param {string} sessionId - the owning session.
57
+ * @param {string} path - the changed file path.
58
+ */
59
+ export function forgetPath(index, sessionId, path) {
60
+ const entries = index.bySession.get(sessionId)
61
+ if (entries === undefined) return
62
+ for (const [key, entry] of entries) {
63
+ if (entry.path === path) entries.delete(key)
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Forget a session's whole index, after compaction or pruning removed earlier results from context.
69
+ * @param {ReturnType<typeof createReadIndex>} index - the read index (mutated).
70
+ * @param {string} sessionId - the owning session.
71
+ */
72
+ export function clearSession(index, sessionId) {
73
+ index.bySession.delete(sessionId)
74
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Helpers for the two DSH hooks that carry per-step routing.
3
+ *
4
+ * DSH keeps the last logged request header and seeds the next model call from it, so a route
5
+ * must be applied on every call, not only where the tier changes. The `agent/request` waterfall
6
+ * is where a plugin replaces the call configuration; `agent/pre-step` is where it can edit the
7
+ * messages admitted to a step.
8
+ */
9
+
10
+ /**
11
+ * Replace provider, model and reasoning effort in one model-call configuration. Every other
12
+ * control is kept. The inherited effort is always dropped first, so a route without an effort
13
+ * does not keep the previous tier's effort.
14
+ *
15
+ * @param {Record<string, any>} config - the configuration DSH would use (not mutated).
16
+ * @param {{ provider: string, model: string, reasoningEffort?: string }} route - the tier route.
17
+ * @returns {Record<string, any>} a new configuration.
18
+ */
19
+ export function applyRoute(config, route) {
20
+ const { reasoningEffort: _inheritedEffort, ...controls } = config
21
+ return {
22
+ ...controls,
23
+ provider: route.provider,
24
+ model: route.model,
25
+ ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Drop DSH's model-switch notices. DSH adds one whenever the entry point's default selection
31
+ * differs from the last logged request. Per-step routing makes that differ on purpose, so the
32
+ * notice would repeat on every step and name the wrong model. The routing ledger records each
33
+ * change instead.
34
+ *
35
+ * @template {{ source?: { kind?: string } | undefined }} T
36
+ * @param {ReadonlyArray<T>} messages - the messages admitted to a step.
37
+ * @returns {T[]} the messages without model-switch notices.
38
+ */
39
+ export function withoutModelNotices(messages) {
40
+ return messages.filter(message => message?.source?.kind !== 'model-selection')
41
+ }
@@ -0,0 +1,89 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { stripAnsi } from './ansi.mjs'
3
+ import { FAIL_RE } from './patterns.mjs'
4
+
5
+ /**
6
+ * Command patterns that count as verifiers: test runners, type checkers, linters and build checks.
7
+ * A bash call matching one of these is a verifier call, and its exit status drives the cascade.
8
+ */
9
+ export const DEFAULT_VERIFIER_PATTERNS = [
10
+ String.raw`\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b`,
11
+ String.raw`\b(?:pytest|py\.test)\b`,
12
+ String.raw`\bpython3?\s+-m\s+(?:pytest|unittest)\b`,
13
+ String.raw`\bnode\s+--test\b`,
14
+ String.raw`\b(?:vitest|jest|mocha)\b`,
15
+ String.raw`\bgo\s+test\b`,
16
+ String.raw`\bcargo\s+(?:test|check|clippy)\b`,
17
+ String.raw`\b(?:tsc|mypy|ruff|eslint)\b`,
18
+ String.raw`\bmake\s+(?:test|check)\b`,
19
+ ]
20
+
21
+ /**
22
+ * Compile verifier command patterns.
23
+ * @param {string[]} sources - regular-expression sources, matched case-insensitively.
24
+ * @returns {RegExp[]} compiled patterns.
25
+ */
26
+ export function compileVerifierPatterns(sources) {
27
+ return sources.map(source => new RegExp(source, 'i'))
28
+ }
29
+
30
+ /**
31
+ * Whether a command is a verifier call.
32
+ * @param {string} command - the bash command text.
33
+ * @param {RegExp[]} patterns - compiled verifier patterns.
34
+ * @returns {boolean} true when any pattern matches.
35
+ */
36
+ export function isVerifierCommand(command, patterns) {
37
+ return patterns.some(pattern => pattern.test(command))
38
+ }
39
+
40
+ /**
41
+ * Read the `[exit code: N]` marker that the bash tool appends to non-zero results.
42
+ * @param {string} text - rendered tool output.
43
+ * @returns {number | undefined} the exit code, or undefined when no marker is present.
44
+ */
45
+ export function parseExitMarker(text) {
46
+ const match = /\[exit code: (-?\d+)\]/.exec(text)
47
+ return match ? Number(match[1]) : undefined
48
+ }
49
+
50
+ /**
51
+ * Stable identity of one failure: the normalized failure lines with digits, durations and
52
+ * addresses removed. The same failure at a different line or timing gets the same fingerprint.
53
+ * @param {string} text - rendered tool output of the failing command.
54
+ * @returns {string} a 12-character hex digest.
55
+ */
56
+ export function fingerprintOf(text) {
57
+ const lines = stripAnsi(String(text ?? '')).split('\n')
58
+ let signal = lines.filter(line => FAIL_RE.test(line)).slice(0, 8)
59
+ if (signal.length === 0) signal = lines.filter(line => line.trim() !== '').slice(-5)
60
+ const normalized = signal
61
+ .map(line => line
62
+ .replace(/0x[0-9a-f]+/gi, '#')
63
+ .replace(/\d+(?:\.\d+)?\s*(?:ms|s|µs)\b/g, '#')
64
+ .replace(/\d+/g, '#')
65
+ .replace(/\s+/g, ' ')
66
+ .trim())
67
+ .join('\n')
68
+ return createHash('sha1').update(normalized).digest('hex').slice(0, 12)
69
+ }
70
+
71
+ /**
72
+ * Classify the outcome of one executed command.
73
+ * @param {{ exitCode: number, timedOut?: boolean, text?: string }} run - exit status, timeout flag and rendered output.
74
+ * @returns {{ passed: boolean, exitCode: number, timedOut: boolean, fingerprint: string | null }}
75
+ * `fingerprint` is null for passing runs.
76
+ */
77
+ export function classifyOutcome({ exitCode, timedOut = false, text = '' }) {
78
+ const passed = !timedOut && exitCode === 0
79
+ return { passed, exitCode, timedOut, fingerprint: passed ? null : fingerprintOf(text) }
80
+ }
81
+
82
+ /**
83
+ * Canonical spelling of a command for keys: whitespace collapsed and trimmed.
84
+ * @param {unknown} command - the command argument.
85
+ * @returns {string} the normalized command, empty when absent.
86
+ */
87
+ export function normalizeCommand(command) {
88
+ return String(command ?? '').trim().replace(/\s+/g, ' ')
89
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * GreenHarness: a DeepSeek Harness (dsh) plugin that cuts the token, GPU and dollar cost of
3
+ * multi-step agent work. Installed as a DSH bundle with `dsh plugin add @quadiusai/dsh-greenharness`;
4
+ * every setting has a default, and `/greenharness` controls it while DSH runs. See README.md.
5
+ *
6
+ * The plugin changes four things, and each has a ledger record:
7
+ * 1. Cascade: routes each model call to the small or large model from verifier outcomes. The
8
+ * route is applied in the `agent/request` waterfall, DSH's hook for replacing a call's
9
+ * provider, model and effort.
10
+ * 2. Distillation: shortens bash output before it enters the session log, keeping the full
11
+ * output in the spill store.
12
+ * 3. Read dedupe: replaces a byte-identical re-read of a file still in context with a pointer.
13
+ * 4. Loop breaker: denies a repeated failing command and escalates the model tier.
14
+ *
15
+ * @module @quadiusai/dsh-greenharness
16
+ */
17
+ import z from '@deepseek-ai/schemastery'
18
+ import { createLedger } from './core/ledger.mjs'
19
+ import { applyRoute, withoutModelNotices } from './core/route.mjs'
20
+ import { normalizeSettings } from './config.mjs'
21
+ import { createGreenHarness } from './runtime.mjs'
22
+ import { runCommand } from './control.mjs'
23
+
24
+ /** Cordis plugin identity. */
25
+ export const name = 'greenharness'
26
+
27
+ /** Needs the command registry to offer `/greenharness`. */
28
+ export const inject = ['commands', 'llm']
29
+
30
+ // Every field defaults to empty: an absent tier is an empty route, and normalizeSettings decides.
31
+ const route = z.object({
32
+ provider: z.string().default(''),
33
+ model: z.string().default(''),
34
+ reasoningEffort: z.string().default(''),
35
+ })
36
+
37
+ const price = z.object({
38
+ inputPerMTok: z.number(),
39
+ cachedInputPerMTok: z.number(),
40
+ outputPerMTok: z.number(),
41
+ })
42
+
43
+ /**
44
+ * Plugin configuration schema. Every field is optional. Without tiers GreenHarness never switches
45
+ * models, and the other mechanisms still run. Routing is switched on at runtime with
46
+ * `/greenharness large <provider> <model>`.
47
+ */
48
+ export const Config = z.object({
49
+ tiers: z.object({
50
+ small: route,
51
+ large: route,
52
+ }).default({}),
53
+ cascade: z.object({
54
+ escalateAfterFailures: z.number().default(1),
55
+ deescalateAfterPasses: z.number().default(2),
56
+ minStepsPerTier: z.number().default(2),
57
+ }).default({}),
58
+ verifier: z.object({
59
+ patterns: z.array(z.string()).default([]),
60
+ }).default({}),
61
+ distill: z.object({
62
+ enabled: z.boolean().default(true),
63
+ maxChars: z.number().default(6000),
64
+ contextLines: z.number().default(2),
65
+ }).default({}),
66
+ readDedupe: z.object({
67
+ enabled: z.boolean().default(true),
68
+ }).default({}),
69
+ loopBreaker: z.object({
70
+ enabled: z.boolean().default(true),
71
+ threshold: z.number().default(3),
72
+ }).default({}),
73
+ ledger: z.object({
74
+ path: z.string().default('.greenharness/ledger.jsonl'),
75
+ pricing: z.dict(price).default({}),
76
+ }).default({}),
77
+ })
78
+
79
+ /**
80
+ * Mount GreenHarness.
81
+ * @param {import('@deepseek-ai/cordis').Context} ctx - plugin context.
82
+ * @param {z.infer<typeof Config>} config - validated configuration.
83
+ */
84
+ export function apply(ctx, config) {
85
+ const settings = normalizeSettings(config)
86
+ const ledger = createLedger(settings.ledger.path)
87
+ const runtime = createGreenHarness(settings, { ledger, logger: ctx.logger })
88
+ const control = { paused: false }
89
+
90
+ // Routing. Registered at boot, before the agent's own model-selection listener, so this is the
91
+ // outer `agent/request` decision: the route it returns is the one the call sends.
92
+ ctx.on('agent/request', async (payload, next) => {
93
+ const proposed = await next()
94
+ try {
95
+ if (control.paused) return proposed
96
+ const route = runtime.routeForNextCall(payload.agent.session.header.id, proposed)
97
+ return route === undefined ? proposed : applyRoute(proposed, route)
98
+ } catch (error) {
99
+ reportError(ctx, ledger, 'agent/request', error)
100
+ return proposed
101
+ }
102
+ })
103
+
104
+ // DSH adds a "model changed" notice whenever its default selection differs from the last logged
105
+ // request. Per-step routing makes that happen on purpose, so the notice is removed; the ledger
106
+ // records each route change instead. DSH registers its notice listener on each agent, with
107
+ // `prepend`, when the agent is created. This listener goes on the same agent context after it,
108
+ // also with `prepend`, so it is the outer listener and sees the messages after the notice is
109
+ // added. Registered at boot, it would run inside the notice listener and miss it.
110
+ ctx.on('agent/created', ({ agent }) => {
111
+ agent?.ctx?.on('agent/pre-step', async (_payload, next) => {
112
+ const decision = await next()
113
+ if (decision.kind !== 'enter') return decision
114
+ const messages = withoutModelNotices(decision.messages)
115
+ return messages.length === decision.messages.length ? decision : { ...decision, messages }
116
+ }, { prepend: true })
117
+ })
118
+
119
+ ctx.on('tools/pre-execute', async (exec, next) => {
120
+ const decision = await next()
121
+ if (control.paused || decision.kind !== 'allow') return decision
122
+ const call = toCall(ctx, exec)
123
+ if (call === undefined) return decision
124
+ try {
125
+ return runtime.beforeTool(call) ?? decision
126
+ } catch (error) {
127
+ reportError(ctx, ledger, 'tools/pre-execute', error)
128
+ return decision
129
+ }
130
+ })
131
+
132
+ ctx.on('tools/post-execute', async (exec, result, next) => {
133
+ const decision = await next()
134
+ if (control.paused || decision.kind !== 'accept' || Object.hasOwn(decision, 'value')) return decision
135
+ const call = toCall(ctx, exec, result)
136
+ if (call === undefined) return decision
137
+ try {
138
+ const replacement = await runtime.afterTool(call)
139
+ if (replacement === undefined) return decision
140
+ return { ...decision, kind: 'accept', content: [{ type: 'text', text: replacement.text }] }
141
+ } catch (error) {
142
+ reportError(ctx, ledger, 'tools/post-execute', error)
143
+ return decision
144
+ }
145
+ })
146
+
147
+ registerCommand(ctx, { control, settings, runtime, llm: ctx.llm })
148
+
149
+ ctx.on('session/event', (session, event) => {
150
+ try {
151
+ runtime.observeEvent(session.header.id, event)
152
+ } catch (error) {
153
+ reportError(ctx, ledger, 'session/event', error)
154
+ }
155
+ })
156
+ }
157
+
158
+ /**
159
+ * Offer `/greenharness` in DSH's command registry. The handler is pure over the host object; see
160
+ * control.mjs.
161
+ * @param {import('@deepseek-ai/cordis').Context} ctx
162
+ * @param {{ control: { paused: boolean }, settings: any, runtime: any }} host
163
+ */
164
+ function registerCommand(ctx, host) {
165
+ ctx.effect(function* () {
166
+ yield ctx.commands.register({
167
+ definitionId: '@quadiusai/dsh-greenharness',
168
+ name: 'greenharness',
169
+ description: 'GreenHarness: see tokens saved, choose cheap and strong models, switch features (try: help)',
170
+ input: { hint: 'help | models | large <number> | small <number> | on | off' },
171
+ handler: (invocation) => runCommand(invocation.rawInput, host),
172
+ })
173
+ }, 'greenharness command')
174
+ }
175
+
176
+ /**
177
+ * Keep a plugin failure visible in the ledger. DSH's logger output is not always shown, and a
178
+ * swallowed error would look like the plugin never ran.
179
+ * @param {import('@deepseek-ai/cordis').Context} ctx
180
+ * @param {{ write(record: object): void }} ledger
181
+ * @param {string} stage - the hook that failed.
182
+ * @param {unknown} error
183
+ */
184
+ function reportError(ctx, ledger, stage, error) {
185
+ const message = error instanceof Error ? error.message : String(error)
186
+ try {
187
+ ledger.write({ type: 'error', stage, message })
188
+ } catch {
189
+ // The ledger is unavailable too; the logger below still reports the failure.
190
+ }
191
+ ctx.logger?.warn?.(`greenharness: ${stage} failed: ${message}`)
192
+ }
193
+
194
+ /**
195
+ * Adapt one DSH tool execution to the runtime's call shape.
196
+ * @param {import('@deepseek-ai/cordis').Context} ctx
197
+ * @param {any} exec - the DSH ToolExecution.
198
+ * @param {any} [result] - the normalized ToolExecutionResult, after dispatch.
199
+ */
200
+ function toCall(ctx, exec, result) {
201
+ const agent = exec.agent
202
+ if (agent === undefined) return undefined
203
+ const value = result?.value
204
+ return {
205
+ sessionId: agent.session.header.id,
206
+ name: exec.name,
207
+ args: exec.arguments ?? {},
208
+ callId: exec.callId,
209
+ isError: result?.isError === true,
210
+ exitCode: typeof value?.exitCode === 'number' ? value.exitCode : undefined,
211
+ timedOut: value?.timedOut === true,
212
+ text: contentText(result?.content),
213
+ spill: async (text) => {
214
+ const store = ctx.get('spillStore')
215
+ if (store === undefined) return undefined
216
+ const ref = await store.saveText({
217
+ owner: { sessionId: agent.session.header.id },
218
+ source: { kind: 'tool', toolName: exec.name, callId: exec.callId, label: 'result' },
219
+ suggestedName: `${exec.name}.txt`,
220
+ content: text,
221
+ })
222
+ return ref.retrievalHint
223
+ },
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Text of a tool result's text blocks, in order.
229
+ * @param {Array<{ type: string, text?: string }> | undefined} content
230
+ */
231
+ function contentText(content) {
232
+ return (content ?? []).filter(block => block.type === 'text').map(block => block.text ?? '').join('\n')
233
+ }