@polderlabs/bizar 10.17.0 → 10.17.3
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/cli/commands/hook.mjs +1 -1
- package/cli/commands/models.mjs +182 -15
- package/config/claude/hooks/advisor-context.mjs +112 -30
- package/package.json +2 -2
- package/packages/sdk/dist/router/failover-mirror.mjs +311 -0
- package/packages/sdk/dist/version.d.ts +1 -1
- package/packages/sdk/dist/version.js +1 -1
- package/packages/sdk/package.json +1 -1
package/cli/commands/hook.mjs
CHANGED
|
@@ -248,7 +248,7 @@ export function selectEventChain(eventKey, input = '') {
|
|
|
248
248
|
if (eventKey === 'subagent-start') {
|
|
249
249
|
return chain.filter((leaf) => {
|
|
250
250
|
if (leaf === 'advisor-context') {
|
|
251
|
-
return /^(linda|
|
|
251
|
+
return /^(linda|carl)$/.test(agentType);
|
|
252
252
|
}
|
|
253
253
|
if (leaf === 'worktree-bootstrap') {
|
|
254
254
|
return /^(brad|carl|pam|brenda|karen|todd|ria)$/.test(agentType);
|
package/cli/commands/models.mjs
CHANGED
|
@@ -21,10 +21,11 @@ import chalk from 'chalk';
|
|
|
21
21
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
22
22
|
import { homedir } from 'node:os';
|
|
23
23
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
24
|
+
import readline from 'node:readline';
|
|
24
25
|
|
|
25
26
|
import {
|
|
26
27
|
rankUserSelectedForRole as rankUserSelectedForRoleMirror,
|
|
27
|
-
} from '../../packages/sdk/
|
|
28
|
+
} from '../../packages/sdk/dist/router/failover-mirror.mjs';
|
|
28
29
|
|
|
29
30
|
// ── Endpoint resolution ──────────────────────────────────────────────────────
|
|
30
31
|
|
|
@@ -696,7 +697,7 @@ function explainRankedEntry(entry) {
|
|
|
696
697
|
* Pure function over `routerPath` — no stdout I/O. The `run` entry point
|
|
697
698
|
* owns the output and exit codes.
|
|
698
699
|
*
|
|
699
|
-
* The mirror lives at `packages/sdk/
|
|
700
|
+
* The mirror lives at `packages/sdk/dist/router/failover-mirror.mjs` and is
|
|
700
701
|
* byte-identical to the SDK's algorithm; if it diverges, the divergence
|
|
701
702
|
* test in `cli/__tests__/models-picker.test.mjs` fails.
|
|
702
703
|
*
|
|
@@ -721,7 +722,7 @@ export function explainSelection({ routerPath, role, requirements = {} } = {}) {
|
|
|
721
722
|
registry = { userSelected: undefined };
|
|
722
723
|
}
|
|
723
724
|
if (typeof rankUserSelectedForRoleMirror !== 'function') {
|
|
724
|
-
const err = new Error('bizar models explain requires packages/sdk/
|
|
725
|
+
const err = new Error('bizar models explain requires packages/sdk/dist/router/failover-mirror.mjs to be loadable');
|
|
725
726
|
err.code = 'SDK_UNAVAILABLE';
|
|
726
727
|
throw err;
|
|
727
728
|
}
|
|
@@ -732,12 +733,13 @@ export function explainSelection({ routerPath, role, requirements = {} } = {}) {
|
|
|
732
733
|
/**
|
|
733
734
|
* Interactive multi-select picker. Pure function over streams — testable.
|
|
734
735
|
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
736
|
+
* Branching: when stdin is a TTY with raw-mode support, the picker drives a
|
|
737
|
+
* keypress-driven checklist (arrow keys / j-k / space / a / n / enter / q /
|
|
738
|
+
* esc / ?). When stdin is not a TTY (pipes, CI, tests) the picker falls
|
|
739
|
+
* through to a line-mode loop that accepts a space-separated index list,
|
|
740
|
+
* `all`, `none`, `toggle <i>`, an empty line (confirm), or `q` (quit). Both
|
|
741
|
+
* branches return the chosen IDs in the user's most-recent selection order,
|
|
742
|
+
* so external callers and existing tests see a single `Promise<string[]>`.
|
|
741
743
|
*
|
|
742
744
|
* @param {{ candidates: Array<{ id: string }>, current?: string[], stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WriteStream, prompt?: string }} opts
|
|
743
745
|
* @returns {Promise<string[]>}
|
|
@@ -754,10 +756,36 @@ export async function pickModels({ candidates, current = [], stdin, stdout, prom
|
|
|
754
756
|
const selected = new Set(current);
|
|
755
757
|
let lastOrder = [...current];
|
|
756
758
|
|
|
757
|
-
const
|
|
759
|
+
const useTty = in$.isTTY === true && typeof in$.setRawMode === 'function';
|
|
760
|
+
if (!useTty) {
|
|
761
|
+
return pickModelsLineMode({ ordered, candidates, selected, lastOrder, stdin: in$, stdout: out$, prompt });
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Try to enable raw mode. If that fails (rare — redirected TTY, broken
|
|
765
|
+
// pseudo-terminal), fall back to the line-mode picker so the user never
|
|
766
|
+
// sees a silent no-op.
|
|
767
|
+
let rawOk = true;
|
|
768
|
+
try {
|
|
769
|
+
in$.setRawMode(true);
|
|
770
|
+
} catch {
|
|
771
|
+
rawOk = false;
|
|
772
|
+
}
|
|
773
|
+
if (!rawOk) {
|
|
774
|
+
return pickModelsLineMode({ ordered, candidates, selected, lastOrder, stdin: in$, stdout: out$, prompt });
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
try {
|
|
778
|
+
return await pickModelsInteractive({ ordered, candidates, selected, lastOrder, stdin: in$, stdout: out$, prompt });
|
|
779
|
+
} finally {
|
|
780
|
+
try { in$.setRawMode(false); } catch { /* swallow — terminal may already be gone */ }
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function pickModelsLineMode({ ordered, candidates, selected, lastOrder, stdin, stdout, prompt }) {
|
|
785
|
+
const lines = makeLineReader(stdin);
|
|
758
786
|
for (;;) {
|
|
759
|
-
renderPicker(
|
|
760
|
-
const line = await readPrompt(lines,
|
|
787
|
+
renderPicker(stdout, ordered, selected, prompt, candidates);
|
|
788
|
+
const line = await readPrompt(lines, stdout, stdin.isTTY === true, '> ');
|
|
761
789
|
if (line === null) break; // EOF on non-TTY
|
|
762
790
|
const cmd = String(line || '').trim();
|
|
763
791
|
if (cmd === '') break;
|
|
@@ -775,7 +803,7 @@ export async function pickModels({ candidates, current = [], stdin, stdout, prom
|
|
|
775
803
|
if (cmd.startsWith('toggle ')) {
|
|
776
804
|
const idx = Number(cmd.slice('toggle '.length).trim());
|
|
777
805
|
if (!Number.isInteger(idx) || idx < 1 || idx > ordered.length) {
|
|
778
|
-
|
|
806
|
+
stdout.write(chalk.red(` x index out of range\n`));
|
|
779
807
|
continue;
|
|
780
808
|
}
|
|
781
809
|
const id = ordered[idx - 1];
|
|
@@ -802,7 +830,7 @@ export async function pickModels({ candidates, current = [], stdin, stdout, prom
|
|
|
802
830
|
}
|
|
803
831
|
changed = true;
|
|
804
832
|
}
|
|
805
|
-
if (!changed)
|
|
833
|
+
if (!changed) stdout.write(chalk.yellow(` ! unrecognised input - try 'all', 'none', 'toggle <i>', or '1 3 5'\n`));
|
|
806
834
|
}
|
|
807
835
|
const seen = new Set();
|
|
808
836
|
return lastOrder.filter((id) => {
|
|
@@ -813,6 +841,145 @@ export async function pickModels({ candidates, current = [], stdin, stdout, prom
|
|
|
813
841
|
});
|
|
814
842
|
}
|
|
815
843
|
|
|
844
|
+
const VIEWPORT_SIZE = 20;
|
|
845
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
846
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
847
|
+
const ERASE_SCREEN = '\x1b[2J\x1b[H';
|
|
848
|
+
|
|
849
|
+
function cursorUp(n) {
|
|
850
|
+
return `\x1b[${n}A`;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function fitRow({ i, id, profile, width, isCursor, isSelected, columns }) {
|
|
854
|
+
const mark = isSelected ? chalk.green('[x]') : '[ ]';
|
|
855
|
+
const idx = String(i + 1).padStart(width, ' ');
|
|
856
|
+
const label = capabilityLabel(profile);
|
|
857
|
+
const budget = Math.max(40, (columns ?? 80) - 32);
|
|
858
|
+
const idText = id.length > budget ? `${id.slice(0, Math.max(1, budget - 1))}…` : id;
|
|
859
|
+
const cursorMark = isCursor ? chalk.inverse(' ▌ ') : ' ';
|
|
860
|
+
return ` ${cursorMark}${mark} ${chalk.dim(`${idx}.`)} ${idText}${chalk.dim(` [${label}]`)}`;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async function pickModelsInteractive({ ordered, candidates, selected, lastOrder, stdin, stdout, prompt }) {
|
|
864
|
+
let cursor = 0;
|
|
865
|
+
let scrollTop = 0;
|
|
866
|
+
let lastRenderHeight = 0;
|
|
867
|
+
let showHelp = false;
|
|
868
|
+
|
|
869
|
+
readline.emitKeypressEvents(stdin);
|
|
870
|
+
if (typeof stdin.resume === 'function') stdin.resume();
|
|
871
|
+
stdout.write(`${ERASE_SCREEN}${HIDE_CURSOR}`);
|
|
872
|
+
|
|
873
|
+
const exitState = await new Promise((resolve) => {
|
|
874
|
+
const onKey = (str, key) => {
|
|
875
|
+
if (!key) return;
|
|
876
|
+
// Resolve confirmation
|
|
877
|
+
if (key.name === 'return' || str === 'q' || key.name === 'escape') {
|
|
878
|
+
return finish();
|
|
879
|
+
}
|
|
880
|
+
if (key.ctrl && key.name === 'c') {
|
|
881
|
+
// SIGINT: discard selection and break out, but do not bubble.
|
|
882
|
+
selected.clear();
|
|
883
|
+
lastOrder.length = 0;
|
|
884
|
+
return finish();
|
|
885
|
+
}
|
|
886
|
+
if (key.name === 'up' || str === 'k') {
|
|
887
|
+
cursor = (cursor - 1 + ordered.length) % ordered.length;
|
|
888
|
+
} else if (key.name === 'down' || str === 'j') {
|
|
889
|
+
cursor = (cursor + 1) % ordered.length;
|
|
890
|
+
} else if (key.name === 'space' || str === 'x') {
|
|
891
|
+
const id = ordered[cursor];
|
|
892
|
+
if (selected.has(id)) {
|
|
893
|
+
selected.delete(id);
|
|
894
|
+
lastOrder = lastOrder.filter((x) => x !== id);
|
|
895
|
+
} else {
|
|
896
|
+
selected.add(id);
|
|
897
|
+
lastOrder.push(id);
|
|
898
|
+
}
|
|
899
|
+
} else if (str === 'a') {
|
|
900
|
+
for (const id of ordered) selected.add(id);
|
|
901
|
+
lastOrder = [...ordered];
|
|
902
|
+
} else if (str === 'n') {
|
|
903
|
+
selected.clear();
|
|
904
|
+
lastOrder = [];
|
|
905
|
+
} else if (str === '?') {
|
|
906
|
+
showHelp = !showHelp;
|
|
907
|
+
} else {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
adjustScroll();
|
|
911
|
+
render();
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const finish = () => {
|
|
915
|
+
stdin.removeListener('keypress', onKey);
|
|
916
|
+
if (typeof stdin.pause === 'function') stdin.pause();
|
|
917
|
+
resolve();
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
stdin.on('keypress', onKey);
|
|
921
|
+
render();
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
stdout.write(SHOW_CURSOR);
|
|
925
|
+
|
|
926
|
+
const seen = new Set();
|
|
927
|
+
return lastOrder.filter((id) => {
|
|
928
|
+
if (!selected.has(id)) return false;
|
|
929
|
+
if (seen.has(id)) return false;
|
|
930
|
+
seen.add(id);
|
|
931
|
+
return true;
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
function adjustScroll() {
|
|
935
|
+
if (ordered.length <= VIEWPORT_SIZE) {
|
|
936
|
+
scrollTop = 0;
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (cursor < scrollTop) scrollTop = cursor;
|
|
940
|
+
else if (cursor >= scrollTop + VIEWPORT_SIZE) scrollTop = cursor - VIEWPORT_SIZE + 1;
|
|
941
|
+
if (scrollTop < 0) scrollTop = 0;
|
|
942
|
+
if (scrollTop > Math.max(0, ordered.length - VIEWPORT_SIZE)) {
|
|
943
|
+
scrollTop = Math.max(0, ordered.length - VIEWPORT_SIZE);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function render() {
|
|
948
|
+
const width = String(ordered.length).length;
|
|
949
|
+
const columns = typeof stdout.columns === 'number' ? stdout.columns : 80;
|
|
950
|
+
const lines = [];
|
|
951
|
+
lines.push(`\x1b[K${chalk.bold(`-- ${prompt} --`)}`);
|
|
952
|
+
const start = scrollTop;
|
|
953
|
+
const end = Math.min(ordered.length, start + VIEWPORT_SIZE);
|
|
954
|
+
const above = start;
|
|
955
|
+
const below = ordered.length - end;
|
|
956
|
+
if (above > 0) lines.push(`\x1b[K${chalk.dim(` ⋮ ${above} more above`)}`);
|
|
957
|
+
for (let i = start; i < end; i++) {
|
|
958
|
+
const profile = candidates.find((candidate) => candidate.id === ordered[i])?.profile;
|
|
959
|
+
const row = fitRow({
|
|
960
|
+
i,
|
|
961
|
+
id: ordered[i],
|
|
962
|
+
profile,
|
|
963
|
+
width,
|
|
964
|
+
isCursor: i === cursor,
|
|
965
|
+
isSelected: selected.has(ordered[i]),
|
|
966
|
+
columns,
|
|
967
|
+
});
|
|
968
|
+
lines.push(`\x1b[K${row}`);
|
|
969
|
+
}
|
|
970
|
+
if (below > 0) lines.push(`\x1b[K${chalk.dim(` ⋮ ${below} more below`)}`);
|
|
971
|
+
lines.push(`\x1b[K${chalk.dim(` ${selected.size}/${ordered.length} selected. ↑/↓ move · space toggle · a all · n none · enter confirm.`)}`);
|
|
972
|
+
if (showHelp) lines.push(`\x1b[K${chalk.dim(` Extra: j/k · x toggle · q/esc confirm · ? help.`)}`);
|
|
973
|
+
lines.push(`\x1b[K`);
|
|
974
|
+
|
|
975
|
+
if (lastRenderHeight > 0) {
|
|
976
|
+
stdout.write(cursorUp(lastRenderHeight));
|
|
977
|
+
}
|
|
978
|
+
stdout.write(lines.join('\n'));
|
|
979
|
+
lastRenderHeight = lines.length;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
816
983
|
/**
|
|
817
984
|
* Build a small async iterator over `stdin` lines.
|
|
818
985
|
* Returns null from `next()` when the stream ends.
|
|
@@ -909,7 +1076,7 @@ function showHelp() {
|
|
|
909
1076
|
bizar models - User-controlled model picker
|
|
910
1077
|
|
|
911
1078
|
Usage:
|
|
912
|
-
bizar models Interactive picker (
|
|
1079
|
+
bizar models Interactive picker (TTY: arrow keys / space / enter; pipe: line-mode)
|
|
913
1080
|
bizar models --list Print candidate IDs, one per line
|
|
914
1081
|
bizar models --set a,b,c Persist the comma-separated IDs to userSelected
|
|
915
1082
|
bizar models --clear Remove userSelected; orchestrator falls back to session-only
|
|
@@ -1,43 +1,125 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* advisor-context.mjs — Claude Code SubagentStart leaf hook.
|
|
4
|
+
*
|
|
5
|
+
* Inject a SHORT, FILTERED slice of the parent transcript into reviewer /
|
|
6
|
+
* debug-specialist subagents (`@linda`, `@carl`). The previous version
|
|
7
|
+
* dumped up to 30kB of raw parent transcript on every dispatch of
|
|
8
|
+
* `linda|karen|carl|qa-reviewer|principal-engineer|debug-specialist`. Two
|
|
9
|
+
* failure modes followed:
|
|
10
|
+
*
|
|
11
|
+
* 1. The dump was too noisy — reviewers only need the recent intent
|
|
12
|
+
* and the changed files, not arbitrary chitchat from earlier in
|
|
13
|
+
* the session.
|
|
14
|
+
* 2. The JSONL transcript persists across sessions on the same
|
|
15
|
+
* project (Claude Code appends to `~/.claude/projects/.../<id>.jsonl`
|
|
16
|
+
* rather than rotating per session). Reading the "tail" of that
|
|
17
|
+
* file can surface content from a previous, unrelated session —
|
|
18
|
+
* including prior user prompts, spinner/status text, and stuff the
|
|
19
|
+
* current session has no business knowing.
|
|
20
|
+
*
|
|
21
|
+
* The fix below:
|
|
22
|
+
* - Take only the last 8 user/assistant records (was 60).
|
|
23
|
+
* - Skip non-substantive records entirely: `isSidechain`, `isMeta`,
|
|
24
|
+
* `type: 'attachment'`, `type: 'system'`, `type: 'last-prompt'`,
|
|
25
|
+
* `type: 'ai-title'`, `type: 'agent-name'`,
|
|
26
|
+
* `type: 'stop_hook_summary'`.
|
|
27
|
+
* - Per-record cap: 800 chars (was 3000).
|
|
28
|
+
* - Hard total cap: 6kB (was 30kB).
|
|
29
|
+
* - Strip `<system-reminder>` and `<total_tokens>` blocks inside text
|
|
30
|
+
* content.
|
|
31
|
+
* - Fall back to "could not be reconstructed" when the dump is too
|
|
32
|
+
* short to be useful (< 100 chars).
|
|
33
|
+
*
|
|
34
|
+
* Output envelope (unchanged from the previous version):
|
|
35
|
+
* { hookSpecificOutput: { hookEventName: 'SubagentStart', additionalContext } }
|
|
5
36
|
*/
|
|
6
37
|
|
|
7
38
|
import { readFileSync } from 'node:fs';
|
|
8
39
|
|
|
9
|
-
const
|
|
40
|
+
const MAX_RECORDS = 8;
|
|
41
|
+
const PER_RECORD_CAP = 800;
|
|
42
|
+
const TOTAL_CAP = 6_000;
|
|
43
|
+
const MIN_USEFUL_LENGTH = 100;
|
|
44
|
+
|
|
45
|
+
// Records that should never be replayed as "parent context" — they are
|
|
46
|
+
// bookkeeping, meta, or status output rather than substantive session
|
|
47
|
+
// content.
|
|
48
|
+
const SKIP_TYPES = new Set([
|
|
49
|
+
'attachment',
|
|
50
|
+
'system',
|
|
51
|
+
'last-prompt',
|
|
52
|
+
'ai-title',
|
|
53
|
+
'agent-name',
|
|
54
|
+
'stop_hook_summary',
|
|
55
|
+
'queue-operation',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const SYSTEM_REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
|
|
59
|
+
const TOTAL_TOKENS_RE = /<total_tokens>[\s\S]*?<\/total_tokens>/g;
|
|
60
|
+
const CCR_RE = /\[\s*CCR\s+retrieve[^\]]*\]/g;
|
|
61
|
+
|
|
62
|
+
function clip(value, limit) {
|
|
63
|
+
return value.length > limit ? `${value.slice(0, limit)} …[truncated]` : value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cleanText(text) {
|
|
67
|
+
if (typeof text !== 'string' || text.length === 0) return '';
|
|
68
|
+
return text
|
|
69
|
+
.replace(SYSTEM_REMINDER_RE, '')
|
|
70
|
+
.replace(TOTAL_TOKENS_RE, '')
|
|
71
|
+
.replace(CCR_RE, '[compacted context omitted]')
|
|
72
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
73
|
+
.trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isSubstantiveRecord(record) {
|
|
77
|
+
if (!record || typeof record !== 'object') return false;
|
|
78
|
+
if (record.isSidechain === true || record.isMeta === true) return false;
|
|
79
|
+
if (SKIP_TYPES.has(record.type)) return false;
|
|
80
|
+
return record.type === 'user' || record.type === 'assistant';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function extractText(content) {
|
|
84
|
+
if (typeof content === 'string') return content;
|
|
85
|
+
if (!Array.isArray(content)) return '';
|
|
86
|
+
return content
|
|
87
|
+
.filter((block) => block && block.type === 'text')
|
|
88
|
+
.map((block) => block.text || '')
|
|
89
|
+
.join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readTranscriptLines(transcriptPath) {
|
|
93
|
+
if (!transcriptPath) return [];
|
|
94
|
+
let text;
|
|
95
|
+
try {
|
|
96
|
+
text = readFileSync(transcriptPath, 'utf8');
|
|
97
|
+
} catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
return text.split('\n').filter(Boolean);
|
|
101
|
+
}
|
|
10
102
|
|
|
11
103
|
let input = {};
|
|
12
104
|
try { input = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { input = {}; }
|
|
13
105
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
} catch { rendered = []; }
|
|
32
|
-
|
|
33
|
-
rendered.reverse();
|
|
34
|
-
let recent = rendered.join('\n\n---\n\n');
|
|
35
|
-
if (recent.length > 30_000) recent = `…[older turns truncated]\n${recent.slice(-30_000)}`;
|
|
36
|
-
// Strip CCR (context-compaction-retrieval) compression markers so subagents
|
|
37
|
-
// do not interpret them as user content. Replace with a brief note.
|
|
38
|
-
recent = recent.replace(/\[\s*CCR\s+retrieve[^\]]*\]/g, '[compacted context omitted]');
|
|
39
|
-
const additionalContext = recent
|
|
40
|
-
? `Review the caller's request against this recent parent-session evidence, not only its summary:\n\n<recent-conversation>\n${recent}\n</recent-conversation>`
|
|
106
|
+
const lines = readTranscriptLines(input.transcript_path);
|
|
107
|
+
const records = [];
|
|
108
|
+
for (let index = lines.length - 1; index >= 0 && records.length < MAX_RECORDS; index--) {
|
|
109
|
+
let record;
|
|
110
|
+
try { record = JSON.parse(lines[index]); } catch { continue; }
|
|
111
|
+
if (!isSubstantiveRecord(record)) continue;
|
|
112
|
+
const text = cleanText(extractText(record.message?.content));
|
|
113
|
+
if (!text) continue;
|
|
114
|
+
records.push(`${record.type}: ${clip(text, PER_RECORD_CAP)}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
records.reverse();
|
|
118
|
+
let recent = records.join('\n\n---\n\n');
|
|
119
|
+
if (recent.length > TOTAL_CAP) recent = `…[older turns truncated]\n${recent.slice(-TOTAL_CAP)}`;
|
|
120
|
+
|
|
121
|
+
const additionalContext = recent.length >= MIN_USEFUL_LENGTH
|
|
122
|
+
? `Recent parent transcript (last ${records.length} records, capped at ${TOTAL_CAP} chars):\n\n<recent-conversation>\n${recent}\n</recent-conversation>`
|
|
41
123
|
: 'The parent transcript could not be reconstructed. State any context needed before making a strong claim.';
|
|
42
124
|
|
|
43
125
|
process.stdout.write(JSON.stringify({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polderlabs/bizar",
|
|
3
|
-
"version": "10.17.
|
|
3
|
+
"version": "10.17.3",
|
|
4
4
|
"description": "Autonomous, human-in-the-loop multi-agent harness for Claude Code with guarded workflows, typed SDK primitives, and MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
37
|
"typecheck": "tsc --noEmit",
|
|
38
|
-
"build:sdk": "node scripts/
|
|
38
|
+
"build:sdk": "node scripts/build-sdk.mjs",
|
|
39
39
|
"test:sdk": "node_modules/.bin/vitest run --root packages/sdk",
|
|
40
40
|
"test:sdk:watch": "node_modules/.bin/vitest --root packages/sdk",
|
|
41
41
|
"test:node": "node scripts/run-node-tests.mjs",
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router/failover-mirror.mjs
|
|
3
|
+
*
|
|
4
|
+
* Pure-JavaScript mirror of `packages/sdk/src/router/failover.ts` for
|
|
5
|
+
* direct Node loading (`node --test`, `bizar models explain`). This is
|
|
6
|
+
* NOT a runtime import path for the SDK — the SDK ships the TypeScript
|
|
7
|
+
* source — but the CLI is `mjs` and cannot import TS directly without a
|
|
8
|
+
* build step. The mirror keeps the algorithm byte-identical:
|
|
9
|
+
*
|
|
10
|
+
* - `defaultTierHintForId` regex set matches `cli/commands/models.mjs:defaultTierHint`
|
|
11
|
+
* and the SDK's `defaultTierHintForId` verbatim.
|
|
12
|
+
* - The eligible ranking rules mirror `rankUserSelectedForRole`'s sort
|
|
13
|
+
* key (`eligible desc, capabilityScore desc, hasProfile desc,
|
|
14
|
+
* originalIndex asc`).
|
|
15
|
+
* - `pickFailover` walks the eligible list, skips `attemptedIds`,
|
|
16
|
+
* applies the same transport/availability whitelist, and caps at
|
|
17
|
+
* one failover attempt.
|
|
18
|
+
*
|
|
19
|
+
* If `failover.ts` and `failover-mirror.mjs` ever diverge, the unit
|
|
20
|
+
* tests in `cli/__tests__/models-picker.test.mjs` will fail with a
|
|
21
|
+
* divergence diff.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default tier classification for a model ID. Mirrors
|
|
26
|
+
* `cli/commands/models.mjs:defaultTierHint` and
|
|
27
|
+
* `packages/sdk/src/router/agent-model-registry.ts:defaultTierHintForId`
|
|
28
|
+
* byte-for-byte. Most-specific patterns run first.
|
|
29
|
+
*/
|
|
30
|
+
export function defaultTierHintForId(modelId) {
|
|
31
|
+
const id = String(modelId || "").toLowerCase();
|
|
32
|
+
if (!id) return "default";
|
|
33
|
+
if (/(qwen3\.8|gpt-5|opus|o3-pro|o4-mini|sonnet-4)/.test(id)) return "premium";
|
|
34
|
+
if (/(haiku-4|sonnet-3-7|mini-high|m3-high|grok-3)/.test(id)) return "high";
|
|
35
|
+
if (/(sonnet|gpt-4|m3(-|$)|(^|[^a-z])default($|[^a-z]))/.test(id)) return "default";
|
|
36
|
+
if (/(nano|mini[-/]|flash|lite|tiny|haiku($|[-_]\d))/.test(id)) return "budget";
|
|
37
|
+
return "mid";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Weighted capability score, byte-identical to `scoreCapabilityProfile`.
|
|
42
|
+
* reasoning=0.3, toolCall=0.25, structuredOutput=0.15, attachment=0.1,
|
|
43
|
+
* temperature=0.05, +0.15 when `inputModalities` includes "image".
|
|
44
|
+
* Missing/partial profiles score 0.
|
|
45
|
+
*/
|
|
46
|
+
export function scoreCapabilityProfile(profile) {
|
|
47
|
+
if (!profile || !profile.capabilities) return 0;
|
|
48
|
+
const caps = profile.capabilities;
|
|
49
|
+
let score = 0;
|
|
50
|
+
if (caps.reasoning) score += 0.3;
|
|
51
|
+
if (caps.toolCall) score += 0.25;
|
|
52
|
+
if (caps.structuredOutput) score += 0.15;
|
|
53
|
+
if (caps.attachment) score += 0.1;
|
|
54
|
+
if (caps.temperature) score += 0.05;
|
|
55
|
+
if (Array.isArray(caps.inputModalities) && caps.inputModalities.includes("image")) score += 0.15;
|
|
56
|
+
return Math.round(score * 1e6) / 1e6;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mirror of `evaluateRoleRequirements`. Returns
|
|
61
|
+
* `{ eligible, ineligibleReasons }`. Profiles missing the relevant data
|
|
62
|
+
* (e.g., `null` context tokens) pass unknown-values — the resolver
|
|
63
|
+
* only downgrades when a known value violates a floor.
|
|
64
|
+
*/
|
|
65
|
+
export function evaluateRoleRequirements(profile, requirements, tier) {
|
|
66
|
+
const reasons = [];
|
|
67
|
+
if (typeof requirements?.minContextTokens === "number" && profile?.limits && Number.isFinite(profile.limits.contextTokens) && profile.limits.contextTokens < requirements.minContextTokens) {
|
|
68
|
+
reasons.push(`contextTokens ${profile.limits.contextTokens} < required ${requirements.minContextTokens}`);
|
|
69
|
+
}
|
|
70
|
+
if (requirements?.requireReasoning && profile?.capabilities && profile.capabilities.reasoning !== true) {
|
|
71
|
+
reasons.push("missing required reasoning capability");
|
|
72
|
+
}
|
|
73
|
+
if (requirements?.requireToolCall && profile?.capabilities && profile.capabilities.toolCall !== true) {
|
|
74
|
+
reasons.push("missing required tool-call capability");
|
|
75
|
+
}
|
|
76
|
+
if (requirements?.requireStructuredOutput && profile?.capabilities && profile.capabilities.structuredOutput !== true) {
|
|
77
|
+
reasons.push("missing required structured-output capability");
|
|
78
|
+
}
|
|
79
|
+
if (requirements?.requireImageInput && profile?.capabilities && !(Array.isArray(profile.capabilities.inputModalities) && profile.capabilities.inputModalities.includes("image"))) {
|
|
80
|
+
reasons.push("missing required image input modality");
|
|
81
|
+
}
|
|
82
|
+
if (Array.isArray(requirements?.preferredTiers) && requirements.preferredTiers.length > 0 && !requirements.preferredTiers.includes(tier)) {
|
|
83
|
+
reasons.push(`tier ${tier} not in preferred list`);
|
|
84
|
+
}
|
|
85
|
+
return { eligible: reasons.length === 0, ineligibleReasons: reasons };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* F-190 / IMP-017 protocol-floor mirror. Byte-identical to
|
|
90
|
+
* `protocolMeets` in `packages/sdk/src/router/model-profile.ts`. Returns
|
|
91
|
+
* an array of strings describing each violated floor; empty when all
|
|
92
|
+
* floors pass. The discriminator is the discriminated
|
|
93
|
+
* `ModelProfile` shape; the legacy F-184 `ModelCapabilityProfile`
|
|
94
|
+
* falls through to an empty array (legacy `evaluateRoleRequirements`
|
|
95
|
+
* owns those floors).
|
|
96
|
+
*
|
|
97
|
+
* IMP-017 reject strings (mirrored):
|
|
98
|
+
* - `context-too-small: <actual> < <required>`
|
|
99
|
+
* - `no-tool-use`
|
|
100
|
+
* - `no-reasoning`
|
|
101
|
+
* - `no-structured-output`
|
|
102
|
+
* - `no-image-input`
|
|
103
|
+
*/
|
|
104
|
+
export function protocolMeetsMirror(discriminatedProfile, requirements) {
|
|
105
|
+
if (!discriminatedProfile || typeof discriminatedProfile !== "object") return [];
|
|
106
|
+
const protocol = discriminatedProfile.protocol;
|
|
107
|
+
if (!protocol || typeof protocol !== "object") return [];
|
|
108
|
+
const reasons = [];
|
|
109
|
+
if (typeof requirements?.minContextTokens === "number" && Number.isFinite(protocol.contextTokens) && protocol.contextTokens < requirements.minContextTokens) {
|
|
110
|
+
reasons.push(`context-too-small: ${protocol.contextTokens} < ${requirements.minContextTokens}`);
|
|
111
|
+
}
|
|
112
|
+
if (requirements?.requireToolCall === true && protocol.toolUse !== true) {
|
|
113
|
+
reasons.push("no-tool-use");
|
|
114
|
+
}
|
|
115
|
+
if (requirements?.requireReasoning === true && protocol.reasoning !== true) {
|
|
116
|
+
reasons.push("no-reasoning");
|
|
117
|
+
}
|
|
118
|
+
if (requirements?.requireStructuredOutput === true && protocol.structuredOutput !== true) {
|
|
119
|
+
reasons.push("no-structured-output");
|
|
120
|
+
}
|
|
121
|
+
if (requirements?.requireImageInput === true && !(Array.isArray(protocol.modalities) && protocol.modalities.includes("image"))) {
|
|
122
|
+
reasons.push("no-image-input");
|
|
123
|
+
}
|
|
124
|
+
return reasons;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Rank user-selected models for a role. Byte-identical algorithm to
|
|
129
|
+
* `packages/sdk/src/router/agent-model-registry.ts:rankUserSelectedForRole`.
|
|
130
|
+
*
|
|
131
|
+
* Sort key (descending primary, ascending tie-breaker):
|
|
132
|
+
* 1. `eligible` desc
|
|
133
|
+
* 2. `capabilityScore` desc
|
|
134
|
+
* 3. `hasProfile` desc
|
|
135
|
+
* 4. `originalIndex` asc
|
|
136
|
+
*
|
|
137
|
+
* @param {object} registry
|
|
138
|
+
* @param {string} role
|
|
139
|
+
* @param {object} [requirements]
|
|
140
|
+
* @returns {{ ranked: object[], eligible: object[] }}
|
|
141
|
+
*/
|
|
142
|
+
export function rankUserSelectedForRole(registry, role, requirements = {}) {
|
|
143
|
+
const userSelected = registry.userSelected;
|
|
144
|
+
if (!userSelected || !Array.isArray(userSelected.models) || userSelected.models.length === 0) {
|
|
145
|
+
return { ranked: [], eligible: [] };
|
|
146
|
+
}
|
|
147
|
+
const tierHints = userSelected.tierHints;
|
|
148
|
+
const profiles = userSelected.profiles;
|
|
149
|
+
const discriminatedProfiles = userSelected.discriminatedProfiles && typeof userSelected.discriminatedProfiles === "object"
|
|
150
|
+
? userSelected.discriminatedProfiles
|
|
151
|
+
: {};
|
|
152
|
+
const ranked = userSelected.models
|
|
153
|
+
.map((id, originalIndex) => {
|
|
154
|
+
const profile = profiles?.[id];
|
|
155
|
+
const discriminatedProfile = discriminatedProfiles?.[id];
|
|
156
|
+
const hasProfile = Boolean(profile) || Boolean(discriminatedProfile);
|
|
157
|
+
const tier = (tierHints && typeof tierHints[id] === "string" ? tierHints[id] : defaultTierHintForId(id));
|
|
158
|
+
const legacy = evaluateRoleRequirements(profile, requirements, tier);
|
|
159
|
+
const protocolReasons = protocolMeetsMirror(discriminatedProfile, requirements);
|
|
160
|
+
const ineligibleReasons = [...protocolReasons, ...legacy.ineligibleReasons];
|
|
161
|
+
const eligible = ineligibleReasons.length === 0;
|
|
162
|
+
const capabilityScore = scoreCapabilityProfile(profile);
|
|
163
|
+
return {
|
|
164
|
+
id,
|
|
165
|
+
tier,
|
|
166
|
+
eligible,
|
|
167
|
+
ineligibleReasons,
|
|
168
|
+
capabilityScore,
|
|
169
|
+
hasProfile,
|
|
170
|
+
originalIndex,
|
|
171
|
+
...(discriminatedProfile ? { discriminatedProfile, reasons: protocolReasons } : {}),
|
|
172
|
+
...(discriminatedProfile?.measured ? { measured: discriminatedProfile.measured } : {}),
|
|
173
|
+
...(discriminatedProfile?.provenance ? { provenance: discriminatedProfile.provenance } : {}),
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
ranked.sort((a, b) => {
|
|
177
|
+
if (a.eligible !== b.eligible) return a.eligible ? -1 : 1;
|
|
178
|
+
if (a.capabilityScore !== b.capabilityScore) return b.capabilityScore - a.capabilityScore;
|
|
179
|
+
if (a.hasProfile !== b.hasProfile) return a.hasProfile ? -1 : 1;
|
|
180
|
+
return a.originalIndex - b.originalIndex;
|
|
181
|
+
});
|
|
182
|
+
const eligible = ranked.filter((entry) => entry.eligible);
|
|
183
|
+
// role is consumed only for future per-role filtering / logging hooks.
|
|
184
|
+
void role;
|
|
185
|
+
return { ranked, eligible };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const TRANSPORT_OR_AVAILABILITY = new Set([
|
|
189
|
+
"invalid-model",
|
|
190
|
+
"auth-failure",
|
|
191
|
+
"rate-limit",
|
|
192
|
+
"timeout",
|
|
193
|
+
"provider-outage",
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Walk the ranked eligible list and pick the next ID that has not been
|
|
198
|
+
* attempted. Returns the same `FailoverVerdict` shape as the TS module.
|
|
199
|
+
*
|
|
200
|
+
* - Non-transport reason → exhausts immediately (no failover).
|
|
201
|
+
* - First eligible ID already attempted → it stays as `primary`; the
|
|
202
|
+
* next un-attempted eligible ID becomes the failover.
|
|
203
|
+
* - One-failover cap. A second transport failure on the failover
|
|
204
|
+
* target marks subsequent entries as `exhausted` and refuses to walk.
|
|
205
|
+
*/
|
|
206
|
+
export function pickFailover({ registry, role, requirements, attemptedIds, failure, primaryDecisionId }) {
|
|
207
|
+
const attempted = new Set((attemptedIds || []).filter((id) => typeof id === "string"));
|
|
208
|
+
const routingDecisionId = typeof primaryDecisionId === "string" && primaryDecisionId.length > 0 ? primaryDecisionId : null;
|
|
209
|
+
// IMP-020 / F-192: ordered list of attempted-and-failed IDs. The
|
|
210
|
+
// learner updates only the FINAL model's posterior; the IMP-018
|
|
211
|
+
// evidence store records per-attempt outcomes against this list. We
|
|
212
|
+
// always populate it with the primary's id when the primary was
|
|
213
|
+
// already attempted, and with subsequent attempted failover IDs.
|
|
214
|
+
const failoverFrom = [];
|
|
215
|
+
|
|
216
|
+
if (!TRANSPORT_OR_AVAILABILITY.has(failure)) {
|
|
217
|
+
return {
|
|
218
|
+
primary: null,
|
|
219
|
+
failover: null,
|
|
220
|
+
attempts: 0,
|
|
221
|
+
exhaustReason: failure,
|
|
222
|
+
chain: [{ id: "", eligible: false, capabilityScore: 0, attempted: false, outcome: "skipped-non-transport-reason" }],
|
|
223
|
+
routingDecisionId,
|
|
224
|
+
failoverFrom,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const { eligible, ranked } = rankUserSelectedForRole(registry, role, requirements);
|
|
229
|
+
if (ranked.length === 0) {
|
|
230
|
+
return { primary: null, failover: null, attempts: 0, exhaustReason: failure, chain: [], routingDecisionId, failoverFrom };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const head = ranked[0];
|
|
234
|
+
const chain = [{
|
|
235
|
+
id: head.id,
|
|
236
|
+
eligible: head.eligible,
|
|
237
|
+
capabilityScore: head.capabilityScore,
|
|
238
|
+
attempted: attempted.has(head.id),
|
|
239
|
+
outcome: "primary",
|
|
240
|
+
}];
|
|
241
|
+
// IMP-020: if the primary was already attempted, it counts as a
|
|
242
|
+
// failed-and-skipped ID in the `failoverFrom` list.
|
|
243
|
+
if (attempted.has(head.id)) {
|
|
244
|
+
failoverFrom.push(head.id);
|
|
245
|
+
}
|
|
246
|
+
const primary = { id: head.id, reason: failure };
|
|
247
|
+
|
|
248
|
+
let failover = null;
|
|
249
|
+
let exhaustedAtTopLevel = false;
|
|
250
|
+
|
|
251
|
+
for (const entry of eligible) {
|
|
252
|
+
if (entry.id === head.id) continue;
|
|
253
|
+
if (attempted.has(entry.id)) {
|
|
254
|
+
failoverFrom.push(entry.id);
|
|
255
|
+
chain.push({
|
|
256
|
+
id: entry.id,
|
|
257
|
+
eligible: entry.eligible,
|
|
258
|
+
capabilityScore: entry.capabilityScore,
|
|
259
|
+
attempted: true,
|
|
260
|
+
outcome: "skipped-already-attempted",
|
|
261
|
+
});
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (failover === null) {
|
|
265
|
+
failover = { id: entry.id, reason: failure };
|
|
266
|
+
chain.push({
|
|
267
|
+
id: entry.id,
|
|
268
|
+
eligible: entry.eligible,
|
|
269
|
+
capabilityScore: entry.capabilityScore,
|
|
270
|
+
attempted: false,
|
|
271
|
+
outcome: "failover",
|
|
272
|
+
});
|
|
273
|
+
} else {
|
|
274
|
+
chain.push({
|
|
275
|
+
id: entry.id,
|
|
276
|
+
eligible: entry.eligible,
|
|
277
|
+
capabilityScore: entry.capabilityScore,
|
|
278
|
+
attempted: false,
|
|
279
|
+
outcome: "exhausted",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (failover === null) exhaustedAtTopLevel = true;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
primary,
|
|
288
|
+
failover,
|
|
289
|
+
attempts: attempted.size + (failover !== null ? 1 : 0),
|
|
290
|
+
exhaustReason: exhaustedAtTopLevel ? failure : null,
|
|
291
|
+
chain,
|
|
292
|
+
routingDecisionId,
|
|
293
|
+
failoverFrom,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Convenience classifier for the dispatch wrapper.
|
|
299
|
+
*/
|
|
300
|
+
export function classifyError(message) {
|
|
301
|
+
const m = String(message || "").toLowerCase();
|
|
302
|
+
if (!m) return "invalid-model";
|
|
303
|
+
if (/(context.*length|context.*overflow|context.*window|too long|maximum context)/.test(m)) return "context-overflow";
|
|
304
|
+
if (/(429|rate.?limit|too many requests|quota)/.test(m)) return "rate-limit";
|
|
305
|
+
if (/(401|403|unauthorized|forbidden|auth.?token|invalid.*api.*key)/.test(m)) return "auth-failure";
|
|
306
|
+
if (/(timeout|timed out|etimedout|aborted|deadline)/.test(m)) return "timeout";
|
|
307
|
+
if (/(502|503|504|bad gateway|service unavailable|gateway timeout|provider outage|upstream)/.test(m)) return "provider-outage";
|
|
308
|
+
if (/(invalid.*model|unknown.*model|model not found|no such model|not a valid model)/.test(m)) return "invalid-model";
|
|
309
|
+
if (/(quality|incomplete|truncated|garbage|low quality|incoherent)/.test(m)) return "model-quality";
|
|
310
|
+
return "invalid-model";
|
|
311
|
+
}
|