dsh-plugin-coding-delegate 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +920 -2
- package/cordis.patch.example.yml +48 -0
- package/cordis.patch.yml +13 -0
- package/dist/attestation.d.ts +25 -0
- package/dist/attestation.js +44 -0
- package/dist/containment.d.ts +33 -0
- package/dist/containment.js +63 -0
- package/dist/context-quality.d.ts +79 -0
- package/dist/context-quality.js +181 -0
- package/dist/context.d.ts +39 -0
- package/dist/context.js +136 -0
- package/dist/contracts.d.ts +174 -0
- package/dist/contracts.js +415 -0
- package/dist/declaration-egress-hook.d.ts +45 -0
- package/dist/declaration-egress-hook.js +125 -0
- package/dist/declaration-egress.d.ts +37 -0
- package/dist/declaration-egress.js +169 -0
- package/dist/delegation.d.ts +339 -0
- package/dist/delegation.js +933 -0
- package/dist/emission.d.ts +70 -0
- package/dist/emission.js +310 -0
- package/dist/guard.d.ts +162 -0
- package/dist/guard.js +873 -0
- package/dist/host-events.d.ts +15 -0
- package/dist/host-events.js +27 -0
- package/dist/index.d.ts +560 -0
- package/dist/index.js +1398 -0
- package/dist/local-classifier.d.ts +52 -0
- package/dist/local-classifier.js +159 -0
- package/dist/logging.d.ts +21 -0
- package/dist/logging.js +90 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +83 -0
- package/dist/profiles.d.ts +35 -0
- package/dist/profiles.js +95 -0
- package/dist/retry-context.d.ts +52 -0
- package/dist/retry-context.js +97 -0
- package/dist/roles.d.ts +129 -0
- package/dist/roles.js +363 -0
- package/dist/routing.d.ts +35 -0
- package/dist/routing.js +88 -0
- package/dist/savings-tracker.d.ts +128 -0
- package/dist/savings-tracker.js +340 -0
- package/dist/session-events.d.ts +17 -0
- package/dist/session-events.js +31 -0
- package/dist/verification.d.ts +104 -0
- package/dist/verification.js +560 -0
- package/package.json +66 -4
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# EXAMPLE ONLY - NOT APPLIED AUTOMATICALLY.
|
|
2
|
+
#
|
|
3
|
+
# These entries DO modify provider configuration, which is exactly why they are not shipped
|
|
4
|
+
# as an automatic patch. Copy what you need into your DSH profile
|
|
5
|
+
# (profiles/<name>/cordis.patch.yml) or into ~/.dsh/settings.yaml.
|
|
6
|
+
#
|
|
7
|
+
# This is Option 1 from README.md, "Configuring providers": a local LM Studio worker plus
|
|
8
|
+
# DeepSeek Cloud. For any other OpenAI-compatible endpoint (vLLM, Ollama, llama.cpp, a
|
|
9
|
+
# remote gateway, ...) change baseURL and the model id, then point the plugin's
|
|
10
|
+
# localProvider / localModel at it.
|
|
11
|
+
|
|
12
|
+
# The plugin itself. Delete this whole block to accept the built-in defaults listed in
|
|
13
|
+
# cordis.patch.yml.
|
|
14
|
+
- id: local-router
|
|
15
|
+
config:
|
|
16
|
+
localProvider: 'lm-studio'
|
|
17
|
+
localModel: 'qwen/qwen3.8-27b'
|
|
18
|
+
cloudProvider: 'deepseek-official'
|
|
19
|
+
cloudModel: 'deepseek-chat'
|
|
20
|
+
# guardAskPaths REPLACES the built-in default rather than extending it, so list every
|
|
21
|
+
# path that should stay approval-eligible instead of hard-denied.
|
|
22
|
+
guardAskPaths:
|
|
23
|
+
- 'tests/'
|
|
24
|
+
- 'tools/'
|
|
25
|
+
# A tripped DLP firewall reroutes to the local worker rather than refusing the request.
|
|
26
|
+
dlpAction: 'local'
|
|
27
|
+
# Uncomment to run a lead agent on the local endpoint. The provider list is derived from the LEAD
|
|
28
|
+
# profile, so the id is declared in one place. Do NOT point this at cloudProvider -- the hook would
|
|
29
|
+
# stop pinning the architect, and source could leave the machine.
|
|
30
|
+
# leadTier: true
|
|
31
|
+
|
|
32
|
+
- id: llm-pi-ai
|
|
33
|
+
name: '@deepseek-ai/dsh-llm-pi-ai'
|
|
34
|
+
config:
|
|
35
|
+
providers:
|
|
36
|
+
lm-studio:
|
|
37
|
+
api: openai-completions
|
|
38
|
+
baseURL: http://127.0.0.1:1234/v1
|
|
39
|
+
apiKeyEnv: LM_STUDIO_API_KEY
|
|
40
|
+
models:
|
|
41
|
+
- id: qwen/qwen3.8-27b
|
|
42
|
+
name: 'Qwen 3.8 27B Local'
|
|
43
|
+
maxTokens: 16384
|
|
44
|
+
|
|
45
|
+
- id: llm-deepseek
|
|
46
|
+
name: '@deepseek-ai/dsh-llm-deepseek'
|
|
47
|
+
config:
|
|
48
|
+
apiKeyEnv: DEEPSEEK_API_KEY
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Applied by DSH when this plugin is bundled into a profile.
|
|
2
|
+
#
|
|
3
|
+
# THIS PATCH REGISTERS THE PLUGIN ONLY. It adds, removes and modifies no LLM provider,
|
|
4
|
+
# no model and no API key -- provider configuration is yours to own.
|
|
5
|
+
#
|
|
6
|
+
# With no config block the plugin uses its built-in defaults (localProvider lm-studio,
|
|
7
|
+
# localModel qwen/qwen3.8-27b, cloudProvider deepseek-official, cloudModel deepseek-chat).
|
|
8
|
+
# Override them in your own profile patch; cordis.patch.example.yml has a complete
|
|
9
|
+
# worked example, and README.md "Configuring providers" covers other endpoints and
|
|
10
|
+
# cloud-only setups.
|
|
11
|
+
- insert:
|
|
12
|
+
- id: local-router
|
|
13
|
+
name: 'dsh-plugin-coding-delegate'
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scope decision for one attestation: a human claim may only cover files this unit wrote.
|
|
3
|
+
*
|
|
4
|
+
* `OPERATOR_ATTESTED` says a person reviewed content. Before this existed, the gate was the unit's
|
|
5
|
+
* status alone, and an ANSWER-ONLY delegation reaches SUCCESS without writing anything -- so a caller
|
|
6
|
+
* could name any readable path in the workspace and have it recorded as human-reviewed. The status was
|
|
7
|
+
* true and the claim was unrelated to it.
|
|
8
|
+
*
|
|
9
|
+
* A unit that wrote files cannot reach SUCCESS unverified (resolveDelegateStatus returns UNVERIFIED),
|
|
10
|
+
* so requiring membership in `filesWritten` also requires that verification ran and passed.
|
|
11
|
+
*
|
|
12
|
+
* Matches on canonicalised paths, the same comparison `evaluateUnitScope` uses, so redundant segments
|
|
13
|
+
* and a symlinked spelling of one file are a single target rather than two. Note that
|
|
14
|
+
* `canonicalisePath` resolves a RELATIVE input against process.cwd(), while `filesWritten` holds
|
|
15
|
+
* absolute paths, so a relative attestTarget is refused when cwd differs from the workspace. That
|
|
16
|
+
* fails closed and is deliberate.
|
|
17
|
+
*/
|
|
18
|
+
export declare function selectAttestableTargets(input: {
|
|
19
|
+
requested: Array<string | undefined | null>;
|
|
20
|
+
filesWritten: Array<string | undefined | null>;
|
|
21
|
+
operator: string;
|
|
22
|
+
}): {
|
|
23
|
+
attestable: string[];
|
|
24
|
+
refused: string[];
|
|
25
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.selectAttestableTargets = selectAttestableTargets;
|
|
4
|
+
const paths_1 = require("./paths");
|
|
5
|
+
/**
|
|
6
|
+
* The scope decision for one attestation: a human claim may only cover files this unit wrote.
|
|
7
|
+
*
|
|
8
|
+
* `OPERATOR_ATTESTED` says a person reviewed content. Before this existed, the gate was the unit's
|
|
9
|
+
* status alone, and an ANSWER-ONLY delegation reaches SUCCESS without writing anything -- so a caller
|
|
10
|
+
* could name any readable path in the workspace and have it recorded as human-reviewed. The status was
|
|
11
|
+
* true and the claim was unrelated to it.
|
|
12
|
+
*
|
|
13
|
+
* A unit that wrote files cannot reach SUCCESS unverified (resolveDelegateStatus returns UNVERIFIED),
|
|
14
|
+
* so requiring membership in `filesWritten` also requires that verification ran and passed.
|
|
15
|
+
*
|
|
16
|
+
* Matches on canonicalised paths, the same comparison `evaluateUnitScope` uses, so redundant segments
|
|
17
|
+
* and a symlinked spelling of one file are a single target rather than two. Note that
|
|
18
|
+
* `canonicalisePath` resolves a RELATIVE input against process.cwd(), while `filesWritten` holds
|
|
19
|
+
* absolute paths, so a relative attestTarget is refused when cwd differs from the workspace. That
|
|
20
|
+
* fails closed and is deliberate.
|
|
21
|
+
*/
|
|
22
|
+
function selectAttestableTargets(input) {
|
|
23
|
+
// No operator identity is a refusal, not a value to invent -- unchanged from the existing contract.
|
|
24
|
+
if (!input.operator || !input.operator.trim()) {
|
|
25
|
+
return { attestable: [], refused: input.requested.filter(Boolean).map(String) };
|
|
26
|
+
}
|
|
27
|
+
const written = [];
|
|
28
|
+
for (const f of input.filesWritten) {
|
|
29
|
+
if (typeof f === 'string' && f.trim())
|
|
30
|
+
written.push((0, paths_1.canonicalisePath)(f));
|
|
31
|
+
}
|
|
32
|
+
const attestable = [];
|
|
33
|
+
const refused = [];
|
|
34
|
+
for (const raw of input.requested) {
|
|
35
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
36
|
+
continue;
|
|
37
|
+
const canonical = (0, paths_1.canonicalisePath)(raw);
|
|
38
|
+
if (written.includes(canonical))
|
|
39
|
+
attestable.push(raw);
|
|
40
|
+
else
|
|
41
|
+
refused.push(raw);
|
|
42
|
+
}
|
|
43
|
+
return { attestable, refused };
|
|
44
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The destination decision for one delegation: which directory a worker may write into.
|
|
3
|
+
*
|
|
4
|
+
* `workspaceDir` is a model-visible tool argument, and it REPLACED the session root as the trusted
|
|
5
|
+
* base for every emission check. Containment itself was never broken -- `evaluateEmissionPath`
|
|
6
|
+
* canonicalises both sides, so a symlink or a `..` cannot escape whatever base it is given. What was
|
|
7
|
+
* wrong is that the base was chosen by the caller being contained. A prompt-injected architect could
|
|
8
|
+
* name any directory on the host and write there with no refusal, which turned "the worker cannot
|
|
9
|
+
* escape its workspace" into "the worker cannot escape whatever directory it named".
|
|
10
|
+
*
|
|
11
|
+
* Four rows, and the third is what keeps external delegation possible:
|
|
12
|
+
*
|
|
13
|
+
* omitted -> trusted, the session root
|
|
14
|
+
* within the session root -> trusted
|
|
15
|
+
* outside, but under an allowlist root -> trusted, the operator's explicit grant
|
|
16
|
+
* outside and not allowlisted -> operator prompt; refused when no approval is reachable
|
|
17
|
+
*
|
|
18
|
+
* Auto-resolved roots are NOT exempt. `resolveWorkspaceDir` reads environment variables and ctx
|
|
19
|
+
* services, and a compromise there must not silently widen containment. Omission is trusted because
|
|
20
|
+
* the caller said nothing, not because the resolved value is known good.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveWorkspaceContainment(input: {
|
|
23
|
+
requested?: string | null;
|
|
24
|
+
sessionRoot: string;
|
|
25
|
+
allowedRoots?: Array<string | undefined | null>;
|
|
26
|
+
canPrompt: boolean;
|
|
27
|
+
}): {
|
|
28
|
+
trusted: boolean;
|
|
29
|
+
dir?: string;
|
|
30
|
+
needsApproval: boolean;
|
|
31
|
+
reason?: string;
|
|
32
|
+
source: string;
|
|
33
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveWorkspaceContainment = resolveWorkspaceContainment;
|
|
4
|
+
const paths_1 = require("./paths");
|
|
5
|
+
/**
|
|
6
|
+
* The destination decision for one delegation: which directory a worker may write into.
|
|
7
|
+
*
|
|
8
|
+
* `workspaceDir` is a model-visible tool argument, and it REPLACED the session root as the trusted
|
|
9
|
+
* base for every emission check. Containment itself was never broken -- `evaluateEmissionPath`
|
|
10
|
+
* canonicalises both sides, so a symlink or a `..` cannot escape whatever base it is given. What was
|
|
11
|
+
* wrong is that the base was chosen by the caller being contained. A prompt-injected architect could
|
|
12
|
+
* name any directory on the host and write there with no refusal, which turned "the worker cannot
|
|
13
|
+
* escape its workspace" into "the worker cannot escape whatever directory it named".
|
|
14
|
+
*
|
|
15
|
+
* Four rows, and the third is what keeps external delegation possible:
|
|
16
|
+
*
|
|
17
|
+
* omitted -> trusted, the session root
|
|
18
|
+
* within the session root -> trusted
|
|
19
|
+
* outside, but under an allowlist root -> trusted, the operator's explicit grant
|
|
20
|
+
* outside and not allowlisted -> operator prompt; refused when no approval is reachable
|
|
21
|
+
*
|
|
22
|
+
* Auto-resolved roots are NOT exempt. `resolveWorkspaceDir` reads environment variables and ctx
|
|
23
|
+
* services, and a compromise there must not silently widen containment. Omission is trusted because
|
|
24
|
+
* the caller said nothing, not because the resolved value is known good.
|
|
25
|
+
*/
|
|
26
|
+
function resolveWorkspaceContainment(input) {
|
|
27
|
+
const norm = (p) => (0, paths_1.canonicalisePath)(String(p));
|
|
28
|
+
const session = norm(input.sessionRoot);
|
|
29
|
+
const requested = typeof input.requested === 'string' ? input.requested.trim() : '';
|
|
30
|
+
if (!requested) {
|
|
31
|
+
return { trusted: true, dir: session, needsApproval: false, source: 'session-root' };
|
|
32
|
+
}
|
|
33
|
+
const dest = norm(requested);
|
|
34
|
+
// Segment-wise containment, not string prefixing: `isPathWithin` compares path parts, so a sibling
|
|
35
|
+
// directory whose NAME merely begins with the root's name is not treated as inside it.
|
|
36
|
+
if ((0, paths_1.isPathWithin)(session, dest)) {
|
|
37
|
+
return { trusted: true, dir: dest, needsApproval: false, source: 'within-session-root' };
|
|
38
|
+
}
|
|
39
|
+
const granted = (input.allowedRoots || [])
|
|
40
|
+
.filter((r) => typeof r === 'string' && r.trim().length > 0)
|
|
41
|
+
.map(norm);
|
|
42
|
+
if (granted.some((root) => (0, paths_1.isPathWithin)(root, dest))) {
|
|
43
|
+
return { trusted: true, dir: dest, needsApproval: false, source: 'emitAllowlist' };
|
|
44
|
+
}
|
|
45
|
+
const reason = `Delegation requested workspaceDir '${requested}', which resolves to '${dest}' -- outside the ` +
|
|
46
|
+
`session workspace '${session}' and outside every configured emitAllowlist root. Writing there ` +
|
|
47
|
+
`is not authorised by configuration.`;
|
|
48
|
+
if (input.canPrompt) {
|
|
49
|
+
return {
|
|
50
|
+
trusted: false,
|
|
51
|
+
dir: dest,
|
|
52
|
+
needsApproval: true,
|
|
53
|
+
reason,
|
|
54
|
+
source: 'outside-awaiting-approval',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
trusted: false,
|
|
59
|
+
needsApproval: false,
|
|
60
|
+
reason: reason + ' No approval service is reachable, so the delegation is refused.',
|
|
61
|
+
source: 'outside-refused',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-quality counters: the measurement that turns this plugin's central claim into an observation.
|
|
3
|
+
*
|
|
4
|
+
* README is explicit that the claim is currently argued rather than shown -- "What the plugin does not
|
|
5
|
+
* yet do is measure context quality; it measures tokens." This module is the missing half.
|
|
6
|
+
*
|
|
7
|
+
* It folds DSH's OWN session events, read from the installed packages rather than invented here:
|
|
8
|
+
*
|
|
9
|
+
* dsh-session augments `SessionEventMap` with `turn/start`, `turn/end`, `step/start`, `step/end`, ...
|
|
10
|
+
* dsh-compaction augments it with `compaction/start`, `compaction/summary`, `compaction/end`,
|
|
11
|
+
* `compaction/prune`
|
|
12
|
+
*
|
|
13
|
+
* `compaction/summary` and `compaction/prune` each carry `shadowedTokenCount`: the host's own figure for
|
|
14
|
+
* how much context the replacement removed, and the exact span it replaced. That is why this module
|
|
15
|
+
* counts rather than estimates. The number that matters is reported by the harness, not guessed by the
|
|
16
|
+
* plugin, which is the difference between evidence and a proxy.
|
|
17
|
+
*
|
|
18
|
+
* Two properties the fold must keep, because it runs inside a firehose handler:
|
|
19
|
+
*
|
|
20
|
+
* 1. **It never throws.** It is attached to every session event; an exception there would put this
|
|
21
|
+
* plugin's error into someone else's event loop. Unusable input is ignored, never fatal.
|
|
22
|
+
* 2. **An uninteresting event returns the same reference.** DSH's own projections do this so the change
|
|
23
|
+
* feed stays quiet, and the same discipline here keeps a long session from allocating on every tool
|
|
24
|
+
* result it will never look at.
|
|
25
|
+
*
|
|
26
|
+
* A note on what these counters do NOT see: the `session/event` firehose does not publish events that
|
|
27
|
+
* entered through construction (replay, fork, resume), so a resumed session is counted from the resume
|
|
28
|
+
* rather than from its true beginning. That is a real limit of the measurement and is recorded in
|
|
29
|
+
* `docs/findings.md` rather than glossed.
|
|
30
|
+
*/
|
|
31
|
+
export interface ContextQuality {
|
|
32
|
+
/** Turns opened in this process. */
|
|
33
|
+
turns: number;
|
|
34
|
+
/** Steps opened in this process. */
|
|
35
|
+
steps: number;
|
|
36
|
+
/** Compactions that produced a summary -- the model-assisted kind. */
|
|
37
|
+
compactions: number;
|
|
38
|
+
/** Model-free prunes. Counted separately because they cost no model call. */
|
|
39
|
+
prunes: number;
|
|
40
|
+
/**
|
|
41
|
+
* Compactions that ended with an error. Worth counting separately: a failed compaction leaves the
|
|
42
|
+
* window exactly as bloated as it was, so it is the opposite of the thing this measures.
|
|
43
|
+
*/
|
|
44
|
+
failedCompactions: number;
|
|
45
|
+
/** Tokens the host reported as shadowed -- reclaimed -- by summaries and prunes. */
|
|
46
|
+
tokensReclaimed: number;
|
|
47
|
+
/**
|
|
48
|
+
* The prompt the model actually received on the most recent model call: the window as it stood at
|
|
49
|
+
* that step. Measured against a real session rather than inferred from a field name.
|
|
50
|
+
*
|
|
51
|
+
* Explicitly NOT `usage.inputTokens`, which is what the first version of this counter used and why
|
|
52
|
+
* it was wrong. That field is only the UNCACHED portion of the prompt: a live call reported
|
|
53
|
+
* `inputTokens` 228 alongside `cacheReadTokens` 659456 and `totalTokens` 661541, with
|
|
54
|
+
* `input + cacheRead + output === total` exactly. Reading it as the window understates a 660k-token
|
|
55
|
+
* window by a factor of about 2900 — in the direction that makes a full window look empty.
|
|
56
|
+
*/
|
|
57
|
+
lastPromptTokens: number;
|
|
58
|
+
/**
|
|
59
|
+
* The high-water mark of that figure. This, not the last value, is the number that forces a
|
|
60
|
+
* compaction, and it is the one to compare against a window.
|
|
61
|
+
*/
|
|
62
|
+
peakPromptTokens: number;
|
|
63
|
+
/** The context window the route advertised, when it advertised one. */
|
|
64
|
+
contextWindow: number | null;
|
|
65
|
+
/**
|
|
66
|
+
* The route the session's model calls are going to, as `provider/model`.
|
|
67
|
+
*
|
|
68
|
+
* Recorded because the counters above cannot say whether they are the *frontier* figures without it.
|
|
69
|
+
* With the default two-tier configuration every session call is the architect's, so they are; with a
|
|
70
|
+
* lead tier configured the session is pinned elsewhere and they are that model's instead. Labelling
|
|
71
|
+
* them "frontier" without the route would be the kind of claim this project tries not to make.
|
|
72
|
+
*/
|
|
73
|
+
route: string;
|
|
74
|
+
}
|
|
75
|
+
export declare const EMPTY_CONTEXT_QUALITY: ContextQuality;
|
|
76
|
+
/** Fold one session event into the counters. Pure; returns `state` unchanged when it does not care. */
|
|
77
|
+
export declare function foldContextQuality(state: ContextQuality, event: unknown): ContextQuality;
|
|
78
|
+
/** One line, for a `CONTEXT_QUALITY` trace entry. Never multi-line: it is a trace payload. */
|
|
79
|
+
export declare function describeContextQuality(state: ContextQuality): string;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Context-quality counters: the measurement that turns this plugin's central claim into an observation.
|
|
4
|
+
*
|
|
5
|
+
* README is explicit that the claim is currently argued rather than shown -- "What the plugin does not
|
|
6
|
+
* yet do is measure context quality; it measures tokens." This module is the missing half.
|
|
7
|
+
*
|
|
8
|
+
* It folds DSH's OWN session events, read from the installed packages rather than invented here:
|
|
9
|
+
*
|
|
10
|
+
* dsh-session augments `SessionEventMap` with `turn/start`, `turn/end`, `step/start`, `step/end`, ...
|
|
11
|
+
* dsh-compaction augments it with `compaction/start`, `compaction/summary`, `compaction/end`,
|
|
12
|
+
* `compaction/prune`
|
|
13
|
+
*
|
|
14
|
+
* `compaction/summary` and `compaction/prune` each carry `shadowedTokenCount`: the host's own figure for
|
|
15
|
+
* how much context the replacement removed, and the exact span it replaced. That is why this module
|
|
16
|
+
* counts rather than estimates. The number that matters is reported by the harness, not guessed by the
|
|
17
|
+
* plugin, which is the difference between evidence and a proxy.
|
|
18
|
+
*
|
|
19
|
+
* Two properties the fold must keep, because it runs inside a firehose handler:
|
|
20
|
+
*
|
|
21
|
+
* 1. **It never throws.** It is attached to every session event; an exception there would put this
|
|
22
|
+
* plugin's error into someone else's event loop. Unusable input is ignored, never fatal.
|
|
23
|
+
* 2. **An uninteresting event returns the same reference.** DSH's own projections do this so the change
|
|
24
|
+
* feed stays quiet, and the same discipline here keeps a long session from allocating on every tool
|
|
25
|
+
* result it will never look at.
|
|
26
|
+
*
|
|
27
|
+
* A note on what these counters do NOT see: the `session/event` firehose does not publish events that
|
|
28
|
+
* entered through construction (replay, fork, resume), so a resumed session is counted from the resume
|
|
29
|
+
* rather than from its true beginning. That is a real limit of the measurement and is recorded in
|
|
30
|
+
* `docs/findings.md` rather than glossed.
|
|
31
|
+
*/
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.EMPTY_CONTEXT_QUALITY = void 0;
|
|
34
|
+
exports.foldContextQuality = foldContextQuality;
|
|
35
|
+
exports.describeContextQuality = describeContextQuality;
|
|
36
|
+
exports.EMPTY_CONTEXT_QUALITY = {
|
|
37
|
+
turns: 0,
|
|
38
|
+
steps: 0,
|
|
39
|
+
compactions: 0,
|
|
40
|
+
prunes: 0,
|
|
41
|
+
failedCompactions: 0,
|
|
42
|
+
tokensReclaimed: 0,
|
|
43
|
+
lastPromptTokens: 0,
|
|
44
|
+
peakPromptTokens: 0,
|
|
45
|
+
contextWindow: null,
|
|
46
|
+
route: '',
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* The token figure the host reported for a replaced span, or null when it is missing or unusable.
|
|
50
|
+
*
|
|
51
|
+
* Both fields this reads are REQUIRED by DSH's own types, so a well-formed event always carries one. A
|
|
52
|
+
* missing or non-numeric value therefore means the event is not what it claims to be, and the caller
|
|
53
|
+
* refuses to count it -- a completion is only counted with its receipt, so the compaction count and the
|
|
54
|
+
* reclaimed-token count can never disagree about whether something happened.
|
|
55
|
+
*/
|
|
56
|
+
function shadowedTokens(data) {
|
|
57
|
+
const value = data?.shadowedTokenCount;
|
|
58
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
|
|
59
|
+
return null;
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The prompt one model call received, or null when the record is unusable.
|
|
64
|
+
*
|
|
65
|
+
* `inputTokens` alone is NOT that number, and reading it as though it were is the defect this function
|
|
66
|
+
* was rewritten to fix: measured on a live session, a call reported `inputTokens` 228 with
|
|
67
|
+
* `cacheReadTokens` 659456 and `totalTokens` 661541, and `input + cacheRead + output === total` exactly.
|
|
68
|
+
* So `inputTokens` counts only the uncached remainder, and the cached prefix — which on any caching
|
|
69
|
+
* provider is most of the window — has to be added back.
|
|
70
|
+
*
|
|
71
|
+
* Null is load-bearing rather than cosmetic: a call that reported nothing must leave the last known
|
|
72
|
+
* figure standing. Treating it as zero would render a full window as an empty one, which is the wrong
|
|
73
|
+
* direction for a measurement whose whole purpose is to show the window filling up.
|
|
74
|
+
*/
|
|
75
|
+
function promptTokens(usage) {
|
|
76
|
+
const fresh = usage?.inputTokens;
|
|
77
|
+
if (typeof fresh !== 'number' || !Number.isFinite(fresh) || fresh < 0)
|
|
78
|
+
return null;
|
|
79
|
+
const cached = usage?.cacheReadTokens;
|
|
80
|
+
const cacheRead = typeof cached === 'number' && Number.isFinite(cached) && cached >= 0 ? cached : 0;
|
|
81
|
+
return fresh + cacheRead;
|
|
82
|
+
}
|
|
83
|
+
/** The advertised context window, or null when absent or not a positive finite number. */
|
|
84
|
+
function advertisedWindow(data) {
|
|
85
|
+
const value = data?.contextWindow;
|
|
86
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
|
|
87
|
+
return null;
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
/** Fold one session event into the counters. Pure; returns `state` unchanged when it does not care. */
|
|
91
|
+
function foldContextQuality(state, event) {
|
|
92
|
+
// The event arrives from a firehose typed `any`, so this cast is a claim rather than a guarantee, and
|
|
93
|
+
// the guards inside the readers below are what keep it safe at runtime. Its value is at COMPILE time:
|
|
94
|
+
// the cases and their payloads now come from the host's own `SessionEventMap`, so a renamed event or a
|
|
95
|
+
// moved field fails the build instead of quietly zeroing a counter.
|
|
96
|
+
if (!event || typeof event !== 'object')
|
|
97
|
+
return state;
|
|
98
|
+
const candidate = event;
|
|
99
|
+
switch (candidate.type) {
|
|
100
|
+
case 'turn/start':
|
|
101
|
+
return { ...state, turns: state.turns + 1 };
|
|
102
|
+
case 'step/start':
|
|
103
|
+
return { ...state, steps: state.steps + 1 };
|
|
104
|
+
case 'compaction/summary': {
|
|
105
|
+
const tokens = shadowedTokens(candidate.data);
|
|
106
|
+
if (tokens === null)
|
|
107
|
+
return state;
|
|
108
|
+
return {
|
|
109
|
+
...state,
|
|
110
|
+
compactions: state.compactions + 1,
|
|
111
|
+
tokensReclaimed: state.tokensReclaimed + tokens,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
case 'compaction/prune': {
|
|
115
|
+
const tokens = shadowedTokens(candidate.data);
|
|
116
|
+
if (tokens === null)
|
|
117
|
+
return state;
|
|
118
|
+
return {
|
|
119
|
+
...state,
|
|
120
|
+
prunes: state.prunes + 1,
|
|
121
|
+
tokensReclaimed: state.tokensReclaimed + tokens,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
case 'compaction/end': {
|
|
125
|
+
// `error` is present only on an unsuccessful attempt, which is the one worth counting here.
|
|
126
|
+
if (typeof candidate.data?.error !== 'string' || !candidate.data.error)
|
|
127
|
+
return state;
|
|
128
|
+
return { ...state, failedCompactions: state.failedCompactions + 1 };
|
|
129
|
+
}
|
|
130
|
+
case 'assistant/message': {
|
|
131
|
+
const tokens = promptTokens(candidate.data?.usage);
|
|
132
|
+
// No reported usage means no new information. Returning `state` keeps the last known window size
|
|
133
|
+
// standing rather than reporting the call as free.
|
|
134
|
+
if (tokens === null)
|
|
135
|
+
return state;
|
|
136
|
+
return {
|
|
137
|
+
...state,
|
|
138
|
+
lastPromptTokens: tokens,
|
|
139
|
+
peakPromptTokens: Math.max(state.peakPromptTokens, tokens),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
case 'request/context': {
|
|
143
|
+
const window = advertisedWindow(candidate.data);
|
|
144
|
+
const provider = typeof candidate.data?.provider === 'string' ? candidate.data.provider : '';
|
|
145
|
+
const model = typeof candidate.data?.model === 'string' ? candidate.data.model : '';
|
|
146
|
+
const route = provider && model ? provider + '/' + model : state.route;
|
|
147
|
+
// Both figures are optional on this event, and it is only logged when the route or capacity
|
|
148
|
+
// changes, so a repeat with nothing new must not allocate.
|
|
149
|
+
if (window === null && route === state.route)
|
|
150
|
+
return state;
|
|
151
|
+
return { ...state, contextWindow: window ?? state.contextWindow, route };
|
|
152
|
+
}
|
|
153
|
+
default:
|
|
154
|
+
return state;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** One line, for a `CONTEXT_QUALITY` trace entry. Never multi-line: it is a trace payload. */
|
|
158
|
+
function describeContextQuality(state) {
|
|
159
|
+
return (state.turns +
|
|
160
|
+
' turn(s), ' +
|
|
161
|
+
state.steps +
|
|
162
|
+
' step(s), ' +
|
|
163
|
+
state.compactions +
|
|
164
|
+
' compaction(s), ' +
|
|
165
|
+
state.prunes +
|
|
166
|
+
' prune(s), ' +
|
|
167
|
+
state.failedCompactions +
|
|
168
|
+
' failed compaction(s), ' +
|
|
169
|
+
state.tokensReclaimed +
|
|
170
|
+
' token(s) reclaimed, ' +
|
|
171
|
+
(state.contextWindow === null
|
|
172
|
+
? state.lastPromptTokens + ' token(s) at the last call'
|
|
173
|
+
: state.lastPromptTokens +
|
|
174
|
+
'/' +
|
|
175
|
+
state.contextWindow +
|
|
176
|
+
' token(s) at the last call') +
|
|
177
|
+
(state.peakPromptTokens > state.lastPromptTokens
|
|
178
|
+
? ' (peak ' + state.peakPromptTokens + ')'
|
|
179
|
+
: '') +
|
|
180
|
+
(state.route ? ' on ' + state.route : ''));
|
|
181
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** One file the architect wants the worker to see. Names and ranges only, never contents. */
|
|
2
|
+
export interface ContextRequest {
|
|
3
|
+
path: string;
|
|
4
|
+
startLine?: number;
|
|
5
|
+
endLine?: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* What the architect is told about an injection. Deliberately has no `content` field — the whole
|
|
9
|
+
* point is that the code travels to the worker and does not travel back.
|
|
10
|
+
*/
|
|
11
|
+
export interface ContextInjection {
|
|
12
|
+
path: string;
|
|
13
|
+
relativeName: string;
|
|
14
|
+
lineRange: {
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
} | null;
|
|
18
|
+
lines: number;
|
|
19
|
+
bytes: number;
|
|
20
|
+
sha256: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ContextResolution {
|
|
23
|
+
injected: ContextInjection[];
|
|
24
|
+
text: string;
|
|
25
|
+
errors: string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Injected context competes with the instruction for the worker's input window, so the budget is a
|
|
29
|
+
* safety bound rather than a caller preference. Over budget refuses; it never truncates quietly,
|
|
30
|
+
* because a worker given half a file answers confidently about a file it only half saw.
|
|
31
|
+
*/
|
|
32
|
+
export declare const DEFAULT_CONTEXT_MAX_BYTES = 32768;
|
|
33
|
+
/**
|
|
34
|
+
* Read the files the architect named and render them for the worker's prompt. Containment matches
|
|
35
|
+
* emission exactly: the same resolution, and the same refusal of escapes and absolute paths outside
|
|
36
|
+
* the root, because reading a file in order to transmit it is an egress route and deserves the same
|
|
37
|
+
* scepticism as writing one.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveContextFiles(requests: ContextRequest[] | undefined, baseDir: string, allowedRoots?: string[], maxBytes?: number): ContextResolution;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DEFAULT_CONTEXT_MAX_BYTES = void 0;
|
|
37
|
+
exports.resolveContextFiles = resolveContextFiles;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const crypto = __importStar(require("crypto"));
|
|
41
|
+
const emission_1 = require("./emission");
|
|
42
|
+
/**
|
|
43
|
+
* Injected context competes with the instruction for the worker's input window, so the budget is a
|
|
44
|
+
* safety bound rather than a caller preference. Over budget refuses; it never truncates quietly,
|
|
45
|
+
* because a worker given half a file answers confidently about a file it only half saw.
|
|
46
|
+
*/
|
|
47
|
+
exports.DEFAULT_CONTEXT_MAX_BYTES = 32768;
|
|
48
|
+
/**
|
|
49
|
+
* Read the files the architect named and render them for the worker's prompt. Containment matches
|
|
50
|
+
* emission exactly: the same resolution, and the same refusal of escapes and absolute paths outside
|
|
51
|
+
* the root, because reading a file in order to transmit it is an egress route and deserves the same
|
|
52
|
+
* scepticism as writing one.
|
|
53
|
+
*/
|
|
54
|
+
function resolveContextFiles(requests, baseDir, allowedRoots = [], maxBytes = exports.DEFAULT_CONTEXT_MAX_BYTES) {
|
|
55
|
+
const injected = [];
|
|
56
|
+
const errors = [];
|
|
57
|
+
const sections = [];
|
|
58
|
+
let totalBytes = 0;
|
|
59
|
+
for (const request of requests ?? []) {
|
|
60
|
+
const declared = String(request?.path || '').trim();
|
|
61
|
+
if (!declared) {
|
|
62
|
+
errors.push('a contextFiles entry had no path');
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const resolvedPath = path.isAbsolute(declared) ? declared : path.resolve(baseDir, declared);
|
|
66
|
+
const containment = (0, emission_1.evaluateEmissionPath)(resolvedPath, baseDir, allowedRoots);
|
|
67
|
+
if (!containment.allowed) {
|
|
68
|
+
errors.push(`context file '${declared}' was refused: ${containment.reason}`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const declaresRange = request.startLine !== undefined || request.endLine !== undefined;
|
|
72
|
+
if (!declaresRange) {
|
|
73
|
+
try {
|
|
74
|
+
const fileSize = fs.statSync(resolvedPath).size;
|
|
75
|
+
if (fileSize > maxBytes - totalBytes) {
|
|
76
|
+
errors.push(`context injection would exceed its ${maxBytes}-byte budget (${totalBytes + fileSize} bytes declared). ` +
|
|
77
|
+
`Narrow the line ranges or declare fewer files.`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// stat failed; fall through to the read attempt so the existing error path reports it
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = fs.readFileSync(resolvedPath, 'utf8');
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
errors.push(`context file '${declared}' could not be read: ${err?.message || String(err)}`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const allLines = raw.split('\n');
|
|
94
|
+
let lineRange = null;
|
|
95
|
+
let body = raw;
|
|
96
|
+
if (request.startLine !== undefined || request.endLine !== undefined) {
|
|
97
|
+
const start = Number(request.startLine ?? 1);
|
|
98
|
+
const end = Number(request.endLine ?? allLines.length);
|
|
99
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start) {
|
|
100
|
+
errors.push(`context file '${declared}' had an invalid line range (${request.startLine}-${request.endLine})`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (start > allLines.length) {
|
|
104
|
+
errors.push(`context file '${declared}' has ${allLines.length} line(s), so a range starting at ${start} does not exist`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// An over-long end is clamped rather than refused, and the clamp is reported in the record.
|
|
108
|
+
const clampedEnd = Math.min(end, allLines.length);
|
|
109
|
+
lineRange = { start, end: clampedEnd };
|
|
110
|
+
body = allLines.slice(start - 1, clampedEnd).join('\n');
|
|
111
|
+
}
|
|
112
|
+
const bytes = Buffer.byteLength(body, 'utf8');
|
|
113
|
+
if (totalBytes + bytes > maxBytes) {
|
|
114
|
+
errors.push(`context injection would exceed its ${maxBytes}-byte budget (${totalBytes + bytes} bytes declared). ` +
|
|
115
|
+
`Narrow the line ranges or declare fewer files.`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
totalBytes += bytes;
|
|
119
|
+
const relativeName = path.relative(baseDir, resolvedPath) || declared;
|
|
120
|
+
injected.push({
|
|
121
|
+
path: resolvedPath,
|
|
122
|
+
relativeName,
|
|
123
|
+
lineRange,
|
|
124
|
+
lines: body.split('\n').length,
|
|
125
|
+
bytes,
|
|
126
|
+
sha256: crypto.createHash('sha256').update(body, 'utf8').digest('hex'),
|
|
127
|
+
});
|
|
128
|
+
sections.push(`--- ${relativeName}${lineRange ? ` (lines ${lineRange.start}-${lineRange.end})` : ''} ---\n${body}`);
|
|
129
|
+
}
|
|
130
|
+
// Any error refuses the whole injection, and the caller refuses the delegation. A partial view is
|
|
131
|
+
// worse than none: the worker would be asked to edit a file it had only partly been shown.
|
|
132
|
+
if (errors.length > 0)
|
|
133
|
+
return { injected: [], text: '', errors };
|
|
134
|
+
const text = sections.length > 0 ? `Declared Context:\n${sections.join('\n\n')}` : '';
|
|
135
|
+
return { injected, text, errors };
|
|
136
|
+
}
|