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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -2
  3. package/cordis.patch.example.yml +48 -0
  4. package/cordis.patch.yml +13 -0
  5. package/dist/attestation.d.ts +25 -0
  6. package/dist/attestation.js +44 -0
  7. package/dist/containment.d.ts +33 -0
  8. package/dist/containment.js +63 -0
  9. package/dist/context-quality.d.ts +79 -0
  10. package/dist/context-quality.js +181 -0
  11. package/dist/context.d.ts +39 -0
  12. package/dist/context.js +136 -0
  13. package/dist/contracts.d.ts +174 -0
  14. package/dist/contracts.js +415 -0
  15. package/dist/declaration-egress-hook.d.ts +45 -0
  16. package/dist/declaration-egress-hook.js +125 -0
  17. package/dist/declaration-egress.d.ts +37 -0
  18. package/dist/declaration-egress.js +169 -0
  19. package/dist/delegation.d.ts +339 -0
  20. package/dist/delegation.js +933 -0
  21. package/dist/emission.d.ts +70 -0
  22. package/dist/emission.js +310 -0
  23. package/dist/guard.d.ts +162 -0
  24. package/dist/guard.js +873 -0
  25. package/dist/host-events.d.ts +15 -0
  26. package/dist/host-events.js +27 -0
  27. package/dist/index.d.ts +560 -0
  28. package/dist/index.js +1398 -0
  29. package/dist/local-classifier.d.ts +52 -0
  30. package/dist/local-classifier.js +159 -0
  31. package/dist/logging.d.ts +21 -0
  32. package/dist/logging.js +90 -0
  33. package/dist/paths.d.ts +10 -0
  34. package/dist/paths.js +83 -0
  35. package/dist/profiles.d.ts +35 -0
  36. package/dist/profiles.js +95 -0
  37. package/dist/retry-context.d.ts +52 -0
  38. package/dist/retry-context.js +97 -0
  39. package/dist/roles.d.ts +129 -0
  40. package/dist/roles.js +363 -0
  41. package/dist/routing.d.ts +35 -0
  42. package/dist/routing.js +88 -0
  43. package/dist/savings-tracker.d.ts +128 -0
  44. package/dist/savings-tracker.js +340 -0
  45. package/dist/session-events.d.ts +17 -0
  46. package/dist/session-events.js +31 -0
  47. package/dist/verification.d.ts +104 -0
  48. package/dist/verification.js +560 -0
  49. 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
@@ -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;
@@ -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
+ }