myshell-tools 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +26 -10
  3. package/dist/cli.js +33 -3
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/cost.js +4 -1
  6. package/dist/commands/cost.js.map +1 -1
  7. package/dist/commands/doctor.js +2 -2
  8. package/dist/commands/doctor.js.map +1 -1
  9. package/dist/commands/login.d.ts +41 -2
  10. package/dist/commands/login.js +116 -11
  11. package/dist/commands/login.js.map +1 -1
  12. package/dist/core/assess.js +2 -62
  13. package/dist/core/assess.js.map +1 -1
  14. package/dist/core/budget.d.ts +26 -0
  15. package/dist/core/budget.js +37 -0
  16. package/dist/core/budget.js.map +1 -0
  17. package/dist/core/history.d.ts +35 -0
  18. package/dist/core/history.js +116 -0
  19. package/dist/core/history.js.map +1 -0
  20. package/dist/core/json-envelope.d.ts +49 -0
  21. package/dist/core/json-envelope.js +117 -0
  22. package/dist/core/json-envelope.js.map +1 -0
  23. package/dist/core/orchestrate.js +107 -8
  24. package/dist/core/orchestrate.js.map +1 -1
  25. package/dist/core/policy.js +17 -9
  26. package/dist/core/policy.js.map +1 -1
  27. package/dist/core/prompt.d.ts +9 -4
  28. package/dist/core/prompt.js +14 -5
  29. package/dist/core/prompt.js.map +1 -1
  30. package/dist/core/review.js +2 -49
  31. package/dist/core/review.js.map +1 -1
  32. package/dist/core/route.d.ts +13 -5
  33. package/dist/core/route.js +20 -6
  34. package/dist/core/route.js.map +1 -1
  35. package/dist/core/types.d.ts +37 -0
  36. package/dist/infra/pricing.d.ts +17 -4
  37. package/dist/infra/pricing.js +73 -3
  38. package/dist/infra/pricing.js.map +1 -1
  39. package/dist/interface/menu.d.ts +12 -0
  40. package/dist/interface/menu.js +106 -22
  41. package/dist/interface/menu.js.map +1 -1
  42. package/dist/providers/detect.d.ts +17 -5
  43. package/dist/providers/detect.js +56 -4
  44. package/dist/providers/detect.js.map +1 -1
  45. package/dist/providers/install.js +1 -0
  46. package/dist/providers/install.js.map +1 -1
  47. package/dist/providers/opencode-parse.d.ts +49 -0
  48. package/dist/providers/opencode-parse.js +181 -0
  49. package/dist/providers/opencode-parse.js.map +1 -0
  50. package/dist/providers/opencode.d.ts +43 -0
  51. package/dist/providers/opencode.js +121 -0
  52. package/dist/providers/opencode.js.map +1 -0
  53. package/dist/providers/port.d.ts +1 -1
  54. package/dist/providers/registry.d.ts +2 -2
  55. package/dist/providers/registry.js +6 -2
  56. package/dist/providers/registry.js.map +1 -1
  57. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"assess.js","sourceRoot":"","sources":["../../src/core/assess.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"assess.js","sourceRoot":"","sources":["../../src/core/assess.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAa3D,8EAA8E;AAC9E,UAAU;AACV,8EAA8E;AAE9E;;GAEG;AACH,SAAS,OAAO,CAAC,CAAS;IACxB,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AACrC,CAAC;AAED;;GAEG;AACH,SAAS,UAAU,CAAC,CAAU;IAC5B,IAAI,OAAO,CAAC,KAAK,SAAS;QAAE,OAAO,CAAC,CAAC;IACrC,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IAC1C,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,CAAC,KAAK,MAAM,IAAI,CAAC,KAAK,GAAG,CAAC;IAC5D,OAAO,KAAK,CAAC;AACf,CAAC;AAED,8EAA8E;AAC9E,aAAa;AACb,8EAA8E;AAE9E;;;;;;;;GAQG;AACH,MAAM,UAAU,MAAM,CAAC,MAAc;IACnC,MAAM,WAAW,GAAe;QAC9B,UAAU,EAAE,IAAI;QAChB,QAAQ,EAAE,KAAK;QACf,MAAM,EAAE,wBAAwB;QAChC,WAAW,EAAE,KAAK;KACnB,CAAC;IAEF,kCAAkC;IAClC,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtD,OAAO,WAAW,CAAC;IACrB,CAAC;IAED,IAAI,QAA4B,CAAC;IACjC,IAAI,CAAC;QACH,QAAQ,GAAG,qBAAqB,CAAC,MAAM,EAAE,YAAY,CAAuB,CAAC;IAC/E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,WAAW,CAAC;IACrB,CAAC;IAED,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;QACtB,OAAO,WAAW,CAAC;IACrB,CAAC;IAED,+CAA+C;IAC/C,IAAI,OAAO,QAAQ,CAAC,UAAU,KAAK,QAAQ,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC;QAC9E,OAAO,WAAW,CAAC;IACrB,CAAC;IAED,MAAM,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;IAChD,MAAM,QAAQ,GAAG,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC/C,MAAM,WAAW,GAAG,UAAU,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;IACtD,MAAM,MAAM,GACV,OAAO,QAAQ,CAAC,MAAM,KAAK,QAAQ,IAAI,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC;QACtE,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE;QACxB,CAAC,CAAC,0BAA0B,CAAC;IAEjC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;AACvD,CAAC"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * src/core/budget.ts — pure per-task cost budget helpers.
3
+ *
4
+ * Purity rules (enforced by test/arch/guards.test.ts):
5
+ * - No imports of fs / path / child_process
6
+ * - No console.* calls
7
+ * - No Date.now() / Math.random() / new Date()
8
+ * - No process.exit()
9
+ */
10
+ /**
11
+ * Returns `true` when `spentUsd` has reached or exceeded the budget cap, so
12
+ * that orchestrate() must stop spending.
13
+ *
14
+ * Returns `false` (no cap) when:
15
+ * - `maxCostUsd` is `null` or `undefined`
16
+ * - `maxCostUsd` is ≤ 0 (non-positive cap is treated as "uncapped")
17
+ */
18
+ export declare function budgetExceeded(spentUsd: number, maxCostUsd: number | null | undefined): boolean;
19
+ /**
20
+ * Returns how many USD remain in the budget, or `null` when uncapped.
21
+ *
22
+ * The returned value may be zero or negative if the cap has already been
23
+ * reached; callers should use {@link budgetExceeded} to make gate decisions
24
+ * rather than checking the sign here.
25
+ */
26
+ export declare function remainingBudget(spentUsd: number, maxCostUsd: number | null | undefined): number | null;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * src/core/budget.ts — pure per-task cost budget helpers.
3
+ *
4
+ * Purity rules (enforced by test/arch/guards.test.ts):
5
+ * - No imports of fs / path / child_process
6
+ * - No console.* calls
7
+ * - No Date.now() / Math.random() / new Date()
8
+ * - No process.exit()
9
+ */
10
+ /**
11
+ * Returns `true` when `spentUsd` has reached or exceeded the budget cap, so
12
+ * that orchestrate() must stop spending.
13
+ *
14
+ * Returns `false` (no cap) when:
15
+ * - `maxCostUsd` is `null` or `undefined`
16
+ * - `maxCostUsd` is ≤ 0 (non-positive cap is treated as "uncapped")
17
+ */
18
+ export function budgetExceeded(spentUsd, maxCostUsd) {
19
+ if (maxCostUsd === null || maxCostUsd === undefined || maxCostUsd <= 0) {
20
+ return false;
21
+ }
22
+ return spentUsd >= maxCostUsd;
23
+ }
24
+ /**
25
+ * Returns how many USD remain in the budget, or `null` when uncapped.
26
+ *
27
+ * The returned value may be zero or negative if the cap has already been
28
+ * reached; callers should use {@link budgetExceeded} to make gate decisions
29
+ * rather than checking the sign here.
30
+ */
31
+ export function remainingBudget(spentUsd, maxCostUsd) {
32
+ if (maxCostUsd === null || maxCostUsd === undefined || maxCostUsd <= 0) {
33
+ return null;
34
+ }
35
+ return maxCostUsd - spentUsd;
36
+ }
37
+ //# sourceMappingURL=budget.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budget.js","sourceRoot":"","sources":["../../src/core/budget.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAC5B,QAAgB,EAChB,UAAqC;IAErC,IAAI,UAAU,KAAK,IAAI,IAAI,UAAU,KAAK,SAAS,IAAI,UAAU,IAAI,CAAC,EAAE,CAAC;QACvE,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,QAAQ,IAAI,UAAU,CAAC;AAChC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAC7B,QAAgB,EAChB,UAAqC;IAErC,IAAI,UAAU,KAAK,IAAI,IAAI,UAAU,KAAK,SAAS,IAAI,UAAU,IAAI,CAAC,EAAE,CAAC;QACvE,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,UAAU,GAAG,QAAQ,CAAC;AAC/B,CAAC"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * src/core/history.ts — compact prior conversation history for context injection.
3
+ *
4
+ * Produces a bounded, human-readable summary of prior SessionEntry turns to be
5
+ * injected into the next provider prompt, giving stateless one-shot providers
6
+ * (claude -p / codex exec) awareness of earlier conversation context.
7
+ *
8
+ * Purity rules (enforced by test/arch/guards.test.ts):
9
+ * - No imports of fs / path / child_process
10
+ * - No console.* calls
11
+ * - No Date.now() / Math.random() / new Date()
12
+ * - No process.exit()
13
+ */
14
+ import type { SessionEntry } from './types.js';
15
+ export interface CompactHistoryOptions {
16
+ /** Maximum total characters in the returned string. Default: 6000. */
17
+ readonly maxChars?: number;
18
+ /** Maximum number of conversation turns to include. Default: 12. */
19
+ readonly maxTurns?: number;
20
+ }
21
+ /**
22
+ * Compact prior conversation history into a bounded string for context injection.
23
+ *
24
+ * - Takes the MOST RECENT up-to-`maxTurns` entries (preserves chronological order).
25
+ * - Strips confidence-envelope JSON from assistant turns before including them.
26
+ * - Enforces `maxChars` by dropping the OLDEST included turns first until under budget.
27
+ * - If a single turn's content alone exceeds `maxChars`, truncates it with a marker.
28
+ * - Returns '' for empty / undefined input.
29
+ *
30
+ * Pure: no I/O, no Date, no Math.random. Never throws.
31
+ *
32
+ * @param entries - The prior conversation entries (oldest first).
33
+ * @param opts - Optional bounds overrides.
34
+ */
35
+ export declare function compactHistory(entries: readonly SessionEntry[], opts?: CompactHistoryOptions): string;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * src/core/history.ts — compact prior conversation history for context injection.
3
+ *
4
+ * Produces a bounded, human-readable summary of prior SessionEntry turns to be
5
+ * injected into the next provider prompt, giving stateless one-shot providers
6
+ * (claude -p / codex exec) awareness of earlier conversation context.
7
+ *
8
+ * Purity rules (enforced by test/arch/guards.test.ts):
9
+ * - No imports of fs / path / child_process
10
+ * - No console.* calls
11
+ * - No Date.now() / Math.random() / new Date()
12
+ * - No process.exit()
13
+ */
14
+ import { lastJsonObjectBoundsWithKey } from './json-envelope.js';
15
+ // ---------------------------------------------------------------------------
16
+ // Constants
17
+ // ---------------------------------------------------------------------------
18
+ const DEFAULT_MAX_CHARS = 6000;
19
+ const DEFAULT_MAX_TURNS = 12;
20
+ const TRUNCATION_MARKER = ' …[truncated]';
21
+ // ---------------------------------------------------------------------------
22
+ // Internal helpers
23
+ // ---------------------------------------------------------------------------
24
+ /**
25
+ * Strip any trailing confidence-envelope JSON block from an assistant's content.
26
+ *
27
+ * The envelope is a trailing `{ ... }` object that contains `"confidence"`.
28
+ * Uses {@link lastJsonObjectBoundsWithKey} to locate the last such block, then
29
+ * removes it (and surrounding whitespace) from the content.
30
+ *
31
+ * Never throws — returns the original content on any parse failure.
32
+ */
33
+ function stripEnvelope(content) {
34
+ try {
35
+ const match = lastJsonObjectBoundsWithKey(content, 'confidence');
36
+ if (match === null) {
37
+ return content;
38
+ }
39
+ // Remove the envelope and any leading whitespace/newline before it
40
+ const before = content.slice(0, match.start).replace(/\s+$/, '');
41
+ const after = content.slice(match.end).replace(/^\s+/, '');
42
+ return after.length > 0 ? `${before}\n${after}` : before;
43
+ }
44
+ catch {
45
+ return content;
46
+ }
47
+ }
48
+ /**
49
+ * Map a SessionEntry role to the display label used in the history block.
50
+ */
51
+ function roleLabel(role) {
52
+ if (role === 'user')
53
+ return 'User';
54
+ if (role === 'assistant')
55
+ return 'Assistant';
56
+ return 'System';
57
+ }
58
+ /**
59
+ * Compact prior conversation history into a bounded string for context injection.
60
+ *
61
+ * - Takes the MOST RECENT up-to-`maxTurns` entries (preserves chronological order).
62
+ * - Strips confidence-envelope JSON from assistant turns before including them.
63
+ * - Enforces `maxChars` by dropping the OLDEST included turns first until under budget.
64
+ * - If a single turn's content alone exceeds `maxChars`, truncates it with a marker.
65
+ * - Returns '' for empty / undefined input.
66
+ *
67
+ * Pure: no I/O, no Date, no Math.random. Never throws.
68
+ *
69
+ * @param entries - The prior conversation entries (oldest first).
70
+ * @param opts - Optional bounds overrides.
71
+ */
72
+ export function compactHistory(entries, opts) {
73
+ try {
74
+ if (!Array.isArray(entries) || entries.length === 0) {
75
+ return '';
76
+ }
77
+ const maxChars = opts?.maxChars ?? DEFAULT_MAX_CHARS;
78
+ const maxTurns = opts?.maxTurns ?? DEFAULT_MAX_TURNS;
79
+ // Take the most recent up-to-maxTurns entries, then keep chronological order
80
+ const window = entries.slice(-maxTurns);
81
+ // Format each entry into a display line
82
+ const formatted = window.map((entry) => {
83
+ const label = roleLabel(entry.role);
84
+ const rawContent = entry.role === 'assistant' ? stripEnvelope(entry.content) : entry.content;
85
+ const content = rawContent.trim();
86
+ return `${label}: ${content}`;
87
+ });
88
+ // Enforce maxChars by dropping oldest turns first
89
+ // Each formatted turn is separated by '\n\n'
90
+ let kept = formatted.slice(); // copy so we can mutate
91
+ while (kept.length > 0) {
92
+ const joined = kept.join('\n\n');
93
+ if (joined.length <= maxChars) {
94
+ return joined;
95
+ }
96
+ // Drop the oldest turn
97
+ kept = kept.slice(1);
98
+ }
99
+ // If we get here, even a single turn is too long — truncate it
100
+ if (formatted.length > 0) {
101
+ const last = formatted[formatted.length - 1];
102
+ if (last !== undefined && last.length > maxChars) {
103
+ const truncated = last.slice(0, maxChars - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
104
+ return truncated;
105
+ }
106
+ if (last !== undefined) {
107
+ return last;
108
+ }
109
+ }
110
+ return '';
111
+ }
112
+ catch {
113
+ return '';
114
+ }
115
+ }
116
+ //# sourceMappingURL=history.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"history.js","sourceRoot":"","sources":["../../src/core/history.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * src/core/json-envelope.ts — shared brace-depth JSON-envelope scanner.
3
+ *
4
+ * Provides reusable helpers that scan text for the LAST balanced `{...}` JSON
5
+ * object containing a given key. Used by assess.ts (confidence envelope),
6
+ * review.ts (verdict envelope), and history.ts (envelope stripping) to avoid
7
+ * duplicated scanning logic.
8
+ *
9
+ * Honesty Contract: these functions NEVER throw on any input. On parse failure
10
+ * or absent key they return null. They never fabricate data.
11
+ *
12
+ * Pure module: no I/O, no time, no randomness.
13
+ */
14
+ /**
15
+ * Scan `text` and return the LAST balanced `{...}` block that:
16
+ * 1. Parses as valid JSON,
17
+ * 2. Is a plain object (not null, not an array), and
18
+ * 3. Contains the given `key` as a direct property.
19
+ *
20
+ * Returns `null` when no matching block is found. Never throws.
21
+ *
22
+ * Scanning semantics:
23
+ * - All `{` positions are tried left-to-right.
24
+ * - For each `{`, the matching `}` is located by tracking brace depth.
25
+ * - All candidates that parse and contain `key` are collected; the LAST one
26
+ * wins. This handles duplicate/regenerated envelopes in model output.
27
+ *
28
+ * @param text - The text to scan (any string).
29
+ * @param key - The property key that must be present in the JSON object.
30
+ */
31
+ export declare function lastJsonObjectWithKey(text: string, key: string): Record<string, unknown> | null;
32
+ /**
33
+ * Like {@link lastJsonObjectWithKey}, but also returns the character offsets of
34
+ * the matched block within `text` so callers can excise it.
35
+ *
36
+ * Returns `null` when no matching block is found. Never throws.
37
+ *
38
+ * The returned `start` and `end` follow the same convention as
39
+ * `String.prototype.slice`: `text.slice(start, end)` reproduces the matched
40
+ * `{...}` block exactly.
41
+ *
42
+ * @param text - The text to scan (any string).
43
+ * @param key - The property key that must be present in the JSON object.
44
+ */
45
+ export declare function lastJsonObjectBoundsWithKey(text: string, key: string): {
46
+ readonly start: number;
47
+ readonly end: number;
48
+ readonly value: Record<string, unknown>;
49
+ } | null;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * src/core/json-envelope.ts — shared brace-depth JSON-envelope scanner.
3
+ *
4
+ * Provides reusable helpers that scan text for the LAST balanced `{...}` JSON
5
+ * object containing a given key. Used by assess.ts (confidence envelope),
6
+ * review.ts (verdict envelope), and history.ts (envelope stripping) to avoid
7
+ * duplicated scanning logic.
8
+ *
9
+ * Honesty Contract: these functions NEVER throw on any input. On parse failure
10
+ * or absent key they return null. They never fabricate data.
11
+ *
12
+ * Pure module: no I/O, no time, no randomness.
13
+ */
14
+ // ---------------------------------------------------------------------------
15
+ // Core scanning loop (private)
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * Walk `text` left-to-right, collecting every balanced `{...}` block that
19
+ * parses as a plain JSON object and contains `key`. Returns the last match
20
+ * (or null if none found). Never throws.
21
+ */
22
+ function scanLast(text, key) {
23
+ if (typeof text !== 'string' || text.length === 0)
24
+ return null;
25
+ let last = null;
26
+ let i = 0;
27
+ while (i < text.length) {
28
+ const start = text.indexOf('{', i);
29
+ if (start === -1)
30
+ break;
31
+ // Walk forward tracking brace depth to find the matching '}'
32
+ let depth = 0;
33
+ let j = start;
34
+ let foundClose = false;
35
+ while (j < text.length) {
36
+ if (text[j] === '{') {
37
+ depth++;
38
+ }
39
+ else if (text[j] === '}') {
40
+ depth--;
41
+ if (depth === 0) {
42
+ foundClose = true;
43
+ break;
44
+ }
45
+ }
46
+ j++;
47
+ }
48
+ if (foundClose) {
49
+ const candidate = text.slice(start, j + 1);
50
+ try {
51
+ const parsed = JSON.parse(candidate);
52
+ if (parsed !== null &&
53
+ typeof parsed === 'object' &&
54
+ !Array.isArray(parsed) &&
55
+ key in parsed) {
56
+ last = { start, end: j + 1, value: parsed };
57
+ }
58
+ }
59
+ catch {
60
+ // Not valid JSON — skip
61
+ }
62
+ }
63
+ i = start + 1;
64
+ }
65
+ return last;
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // Public API
69
+ // ---------------------------------------------------------------------------
70
+ /**
71
+ * Scan `text` and return the LAST balanced `{...}` block that:
72
+ * 1. Parses as valid JSON,
73
+ * 2. Is a plain object (not null, not an array), and
74
+ * 3. Contains the given `key` as a direct property.
75
+ *
76
+ * Returns `null` when no matching block is found. Never throws.
77
+ *
78
+ * Scanning semantics:
79
+ * - All `{` positions are tried left-to-right.
80
+ * - For each `{`, the matching `}` is located by tracking brace depth.
81
+ * - All candidates that parse and contain `key` are collected; the LAST one
82
+ * wins. This handles duplicate/regenerated envelopes in model output.
83
+ *
84
+ * @param text - The text to scan (any string).
85
+ * @param key - The property key that must be present in the JSON object.
86
+ */
87
+ export function lastJsonObjectWithKey(text, key) {
88
+ try {
89
+ const match = scanLast(text, key);
90
+ return match !== null ? match.value : null;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ /**
97
+ * Like {@link lastJsonObjectWithKey}, but also returns the character offsets of
98
+ * the matched block within `text` so callers can excise it.
99
+ *
100
+ * Returns `null` when no matching block is found. Never throws.
101
+ *
102
+ * The returned `start` and `end` follow the same convention as
103
+ * `String.prototype.slice`: `text.slice(start, end)` reproduces the matched
104
+ * `{...}` block exactly.
105
+ *
106
+ * @param text - The text to scan (any string).
107
+ * @param key - The property key that must be present in the JSON object.
108
+ */
109
+ export function lastJsonObjectBoundsWithKey(text, key) {
110
+ try {
111
+ return scanLast(text, key);
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ //# sourceMappingURL=json-envelope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json-envelope.js","sourceRoot":"","sources":["../../src/core/json-envelope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAeH,8EAA8E;AAC9E,+BAA+B;AAC/B,8EAA8E;AAE9E;;;;GAIG;AACH,SAAS,QAAQ,CAAC,IAAY,EAAE,GAAW;IACzC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAE/D,IAAI,IAAI,GAAqB,IAAI,CAAC;IAElC,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;QACvB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;QACnC,IAAI,KAAK,KAAK,CAAC,CAAC;YAAE,MAAM;QAExB,6DAA6D;QAC7D,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,IAAI,CAAC,GAAG,KAAK,CAAC;QACd,IAAI,UAAU,GAAG,KAAK,CAAC;QACvB,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;YACvB,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE,CAAC;gBACpB,KAAK,EAAE,CAAC;YACV,CAAC;iBAAM,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE,CAAC;gBAC3B,KAAK,EAAE,CAAC;gBACR,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;oBAChB,UAAU,GAAG,IAAI,CAAC;oBAClB,MAAM;gBACR,CAAC;YACH,CAAC;YACD,CAAC,EAAE,CAAC;QACN,CAAC;QAED,IAAI,UAAU,EAAE,CAAC;YACf,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;YAC3C,IAAI,CAAC;gBACH,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;gBAC9C,IACE,MAAM,KAAK,IAAI;oBACf,OAAO,MAAM,KAAK,QAAQ;oBAC1B,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC;oBACtB,GAAG,IAAK,MAAiB,EACzB,CAAC;oBACD,IAAI,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,MAAiC,EAAE,CAAC;gBACzE,CAAC;YACH,CAAC;YAAC,MAAM,CAAC;gBACP,wBAAwB;YAC1B,CAAC;QACH,CAAC;QAED,CAAC,GAAG,KAAK,GAAG,CAAC,CAAC;IAChB,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,8EAA8E;AAC9E,aAAa;AACb,8EAA8E;AAE9E;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,qBAAqB,CACnC,IAAY,EACZ,GAAW;IAEX,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QAClC,OAAO,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;IAC7C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,2BAA2B,CACzC,IAAY,EACZ,GAAW;IAEX,IAAI,CAAC;QACH,OAAO,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
@@ -30,13 +30,32 @@ import { classify } from './classify.js';
30
30
  import { route } from './route.js';
31
31
  import { buildPrompt } from './prompt.js';
32
32
  import { assess } from './assess.js';
33
+ import { compactHistory } from './history.js';
33
34
  import { getModelPricing, calculateCost } from '../infra/pricing.js';
34
35
  import { nextTierUp, pickReviewer } from './escalate.js';
35
36
  import { buildReviewPrompt, parseReviewVerdict } from './review.js';
37
+ import { budgetExceeded } from './budget.js';
36
38
  // ---------------------------------------------------------------------------
37
- // Pure helper: should this IC output be cross-vendor reviewed?
39
+ // Pure helper: should this output be cross-vendor reviewed?
38
40
  // ---------------------------------------------------------------------------
39
- function shouldReview(classification, assessment) {
41
+ /**
42
+ * Decides whether a cross-vendor review should be triggered, given the task
43
+ * classification, assessment signals, and the active review policy.
44
+ *
45
+ * @param classification - Task classification (tier + risk).
46
+ * @param assessment - Model self-assessment (confidence, escalate, needsReview).
47
+ * @param reviewPolicy - Policy field; `undefined` is treated as `'auto'` for
48
+ * backward compatibility.
49
+ */
50
+ function shouldReview(classification, assessment, reviewPolicy) {
51
+ // 'off' — never auto-review.
52
+ if (reviewPolicy === 'off')
53
+ return false;
54
+ // 'critical-only' — review only when risk is critical.
55
+ if (reviewPolicy === 'critical-only') {
56
+ return classification.risk === 'critical';
57
+ }
58
+ // 'auto' (or undefined, treated as 'auto') — original behaviour.
40
59
  return (classification.risk === 'high' ||
41
60
  classification.risk === 'critical' ||
42
61
  assessment.needsReview === true);
@@ -103,6 +122,12 @@ export async function* orchestrate(task, deps, signal) {
103
122
  let attempts = 0;
104
123
  let totalCostUsd = 0;
105
124
  let lastOutput = '';
125
+ // Compute history context once per orchestrate() call (before the loop).
126
+ // It is injected into the first-tier prompt to give stateless providers
127
+ // multi-turn context; the history does NOT grow during the loop.
128
+ const historyContext = deps.history !== undefined && deps.history.length > 0
129
+ ? compactHistory(deps.history)
130
+ : undefined;
106
131
  /**
107
132
  * Track which attempt indices have already been reviewed so that re-runs
108
133
  * (e.g. after a revise verdict) are not reviewed a second time and we
@@ -115,7 +140,7 @@ export async function* orchestrate(task, deps, signal) {
115
140
  mainLoop: while (attempts < deps.policy.maxAttempts) {
116
141
  attempts++;
117
142
  // --- Route for current tier ---
118
- const decision = route(currentTier, available, deps.policy);
143
+ const decision = route(currentTier, available, deps.policy, deps.availableModels);
119
144
  const provider = deps.providers[decision.provider];
120
145
  if (provider === undefined) {
121
146
  yield {
@@ -125,10 +150,10 @@ export async function* orchestrate(task, deps, signal) {
125
150
  };
126
151
  break mainLoop;
127
152
  }
128
- // --- Build prompt (with optional reviewer feedback on IC retry) ---
153
+ // --- Build prompt (with optional reviewer feedback on IC retry + history context) ---
129
154
  const prompt = currentTier === 'ic' && managerNotes !== undefined
130
- ? buildPrompt(currentTier, task, managerNotes)
131
- : buildPrompt(currentTier, task);
155
+ ? buildPrompt(currentTier, task, managerNotes, historyContext)
156
+ : buildPrompt(currentTier, task, undefined, historyContext);
132
157
  // --- Yield tier-start ---
133
158
  yield {
134
159
  type: 'tier-start',
@@ -262,7 +287,27 @@ export async function* orchestrate(task, deps, signal) {
262
287
  // Guard: each attempt is reviewed at most once (prevents infinite loops).
263
288
  // Guard: skip review if the only available reviewer is the same vendor
264
289
  // (cross-vendor review is required; same-vendor-only → skip).
265
- if (shouldReview(classification, assessment) && !reviewedAttempts.has(attempts)) {
290
+ // Guard: skip review when budget is exhausted (gating new spend only).
291
+ if (shouldReview(classification, assessment, deps.policy.reviewPolicy) &&
292
+ !reviewedAttempts.has(attempts)) {
293
+ // Budget cap: do not start a review run if we have already spent the cap.
294
+ if (budgetExceeded(totalCostUsd, deps.policy.maxCostUsd)) {
295
+ yield {
296
+ type: 'notice',
297
+ level: 'warn',
298
+ message: 'cost budget reached — accepting best result so far',
299
+ };
300
+ yield {
301
+ type: 'final',
302
+ success: true,
303
+ output: lastOutput,
304
+ tier: currentTier,
305
+ totalCostUsd,
306
+ sessionId: deps.session.id,
307
+ attempts,
308
+ };
309
+ return;
310
+ }
266
311
  const reviewerId = pickReviewer(available, decision.provider);
267
312
  // Only proceed with a DIFFERENT-vendor reviewer (cross-vendor requirement).
268
313
  const reviewerProvider = reviewerId !== null && reviewerId !== decision.provider
@@ -276,7 +321,7 @@ export async function* orchestrate(task, deps, signal) {
276
321
  message: `Review by ${reviewerId} (cross-vendor)`,
277
322
  };
278
323
  // Route reviewer at manager tier
279
- const reviewDecision = route('manager', [reviewerId], deps.policy);
324
+ const reviewDecision = route('manager', [reviewerId], deps.policy, deps.availableModels);
280
325
  const reviewPrompt = buildReviewPrompt(task, lastOutput);
281
326
  // Yield tier-start for review run
282
327
  yield {
@@ -432,6 +477,24 @@ export async function* orchestrate(task, deps, signal) {
432
477
  return;
433
478
  }
434
479
  if (verdict.verdict === 'revise') {
480
+ // Budget cap: do not retry current tier if we have spent the cap.
481
+ if (budgetExceeded(totalCostUsd, deps.policy.maxCostUsd)) {
482
+ yield {
483
+ type: 'notice',
484
+ level: 'warn',
485
+ message: 'cost budget reached — accepting best result so far',
486
+ };
487
+ yield {
488
+ type: 'final',
489
+ success: true,
490
+ output: lastOutput,
491
+ tier: currentTier,
492
+ totalCostUsd,
493
+ sessionId: deps.session.id,
494
+ attempts,
495
+ };
496
+ return;
497
+ }
435
498
  // Retry current tier with reviewer's notes
436
499
  managerNotes = verdict.notes;
437
500
  continue mainLoop;
@@ -439,6 +502,24 @@ export async function* orchestrate(task, deps, signal) {
439
502
  // verdict === 'escalate'
440
503
  const escalateTo = nextTierUp(currentTier);
441
504
  if (escalateTo !== null) {
505
+ // Budget cap: do not escalate to a new tier if we have spent the cap.
506
+ if (budgetExceeded(totalCostUsd, deps.policy.maxCostUsd)) {
507
+ yield {
508
+ type: 'notice',
509
+ level: 'warn',
510
+ message: 'cost budget reached — accepting best result so far',
511
+ };
512
+ yield {
513
+ type: 'final',
514
+ success: true,
515
+ output: lastOutput,
516
+ tier: currentTier,
517
+ totalCostUsd,
518
+ sessionId: deps.session.id,
519
+ attempts,
520
+ };
521
+ return;
522
+ }
442
523
  yield { type: 'escalate', from: currentTier, to: escalateTo, reason: 'reviewer escalation' };
443
524
  currentTier = escalateTo;
444
525
  }
@@ -452,6 +533,24 @@ export async function* orchestrate(task, deps, signal) {
452
533
  (assessment.confidence !== null && assessment.confidence < threshold);
453
534
  const nextTier = nextTierUp(currentTier);
454
535
  if (needEsc && nextTier !== null) {
536
+ // Budget cap: do not escalate to a new tier if we have spent the cap.
537
+ if (budgetExceeded(totalCostUsd, deps.policy.maxCostUsd)) {
538
+ yield {
539
+ type: 'notice',
540
+ level: 'warn',
541
+ message: 'cost budget reached — accepting best result so far',
542
+ };
543
+ yield {
544
+ type: 'final',
545
+ success: true,
546
+ output: lastOutput,
547
+ tier: currentTier,
548
+ totalCostUsd,
549
+ sessionId: deps.session.id,
550
+ attempts,
551
+ };
552
+ return;
553
+ }
455
554
  const escalateReason = assessment.reason !== 'model provided no reason' &&
456
555
  assessment.reason !== 'no confidence envelope'
457
556
  ? assessment.reason