sphinx-understand 0.0.0-stage → 0.1.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.
- package/.sphinxconfig.example +10 -0
- package/.sphinxignore.example +22 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +21 -0
- package/README.md +353 -2
- package/bin/sphinx.js +9 -0
- package/docs/architecture.md +196 -0
- package/eval/README.md +38 -0
- package/eval/RESULTS.md +205 -0
- package/eval/fixtures/known-false-negatives.json +115 -0
- package/examples/browser-demo.mjs +28 -0
- package/examples/config.json +59 -0
- package/package.json +50 -4
- package/src/activation-worker.js +10 -0
- package/src/activation.js +187 -0
- package/src/agent-settings.js +206 -0
- package/src/agent.js +45 -0
- package/src/browser-interview.js +278 -0
- package/src/browser-page.js +172 -0
- package/src/check-recovery.js +92 -0
- package/src/checkbox.js +148 -0
- package/src/cli.js +526 -0
- package/src/config.js +196 -0
- package/src/cursor-hook.js +18 -0
- package/src/evaluator.js +475 -0
- package/src/git-gate.js +116 -0
- package/src/git-interview.js +51 -0
- package/src/git.js +252 -0
- package/src/help.js +121 -0
- package/src/ignore.js +94 -0
- package/src/install.js +446 -0
- package/src/lifecycle-integrations.js +259 -0
- package/src/mcp.js +351 -0
- package/src/model-presets.js +79 -0
- package/src/native-check.js +166 -0
- package/src/openrouter.js +239 -0
- package/src/overrides.js +57 -0
- package/src/pi-integration.js +179 -0
- package/src/pi-runtime.js +128 -0
- package/src/preparation.js +238 -0
- package/src/presentations.js +74 -0
- package/src/prompts.js +157 -0
- package/src/repo-config.js +72 -0
- package/src/repo-context.js +43 -0
- package/src/review-workspace.js +331 -0
- package/src/reviewer-limits.js +10 -0
- package/src/reviewer-response.js +22 -0
- package/src/reviewer.js +522 -0
- package/src/routing.js +38 -0
- package/src/sessions.js +368 -0
- package/src/terminal-launch.js +288 -0
- package/src/terminal-worker.js +11 -0
- package/src/terminal.js +119 -0
- package/src/usage.js +31 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** Static UI only. Model-authored content always enters the DOM as textContent. */
|
|
2
|
+
export function browserPage({ token, nonce }) {
|
|
3
|
+
return `<!doctype html>
|
|
4
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sphinx — understanding check</title>
|
|
5
|
+
<style nonce="${nonce}">
|
|
6
|
+
:root{color-scheme:light;--ink:#292524;--muted:#6b6259;--line:#dfd8cf;--paper:#faf8f5;--amber:#a45009;--focus:#b45309}
|
|
7
|
+
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.6 "Avenir Next",Avenir,sans-serif}
|
|
8
|
+
button,textarea{font:inherit}button{cursor:pointer}button:disabled{cursor:default;opacity:.5}
|
|
9
|
+
button:focus-visible,textarea:focus-visible{outline:2px solid var(--focus);outline-offset:3px}
|
|
10
|
+
.shell{height:100vh;height:100dvh;display:flex;flex-direction:column}
|
|
11
|
+
header{min-height:60px;border-bottom:1px solid var(--line);padding:12px 24px;display:flex;align-items:center;justify-content:space-between;gap:16px}
|
|
12
|
+
.brand{display:flex;align-items:baseline;gap:14px}.brand strong{font:600 19px ui-monospace,SFMono-Regular,Menlo,monospace}.brand span{color:var(--muted);font-size:14px}
|
|
13
|
+
main{width:100%;max-width:800px;margin:0 auto;padding:0 24px;display:flex;flex:1;flex-direction:column;min-height:0}
|
|
14
|
+
.context{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 0;border-bottom:1px solid var(--line);font-size:13px;color:var(--muted)}
|
|
15
|
+
#meta{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#usage{white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
16
|
+
.conversation{flex:1;min-height:0;overflow-y:auto;padding:24px 8px 8px 0;scrollbar-width:thin}
|
|
17
|
+
#messages{list-style:none;margin:0;padding:0}.turn{margin:0 0 24px}.turn.human{margin-left:32px;padding:12px 16px;background:#f0ebe4;border-left:2px solid #c6b7a5}
|
|
18
|
+
.speaker{font-size:13px;font-weight:600;margin-bottom:6px}.turn-text{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.turn.question .turn-text{font-size:18px;line-height:1.6;font-weight:500}
|
|
19
|
+
.turn-note{font-size:12px;color:var(--muted);margin-top:6px}.turn.notice{color:var(--muted)}.question-number{font-size:13px;color:var(--muted);margin-bottom:6px}
|
|
20
|
+
.question{font-size:18px;line-height:1.6;white-space:pre-wrap;margin:8px 0 20px;font-weight:500}.feedback,.message{white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 20px}
|
|
21
|
+
ul{padding-left:22px}.status{font-size:13px;color:var(--muted);margin:0 0 12px}
|
|
22
|
+
.composer{flex-shrink:0;border-top:1px solid var(--line);padding:16px 0 12px}.error{font-size:14px;color:#8d271e;white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 12px}
|
|
23
|
+
label{display:block;font-size:14px;font-weight:600;margin-bottom:8px}
|
|
24
|
+
textarea{display:block;width:100%;min-height:76px;max-height:180px;resize:vertical;padding:12px 14px;border:1px solid #b9afa3;border-radius:6px;background:#fff;color:var(--ink);line-height:1.5}
|
|
25
|
+
textarea:disabled{background:#f6f2ed;color:var(--muted)}textarea::placeholder{color:#82796f}
|
|
26
|
+
.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px}
|
|
27
|
+
button{border:1px solid #c3b8aa;border-radius:6px;padding:7px 12px;background:transparent;color:var(--ink);font-size:14px;line-height:1.5}
|
|
28
|
+
button.primary{background:var(--amber);color:#fff;border-color:var(--amber);margin-left:auto;min-width:72px}
|
|
29
|
+
button:hover:not(:disabled){background:#eee7dc}button.primary:hover:not(:disabled){background:#8b4208}
|
|
30
|
+
button.quiet{border-color:transparent;color:var(--muted)}button.quiet:hover:not(:disabled){color:var(--ink)}
|
|
31
|
+
.hint{font-size:12px;color:var(--muted);margin:10px 0 0}.recovery{display:grid;gap:12px;margin:0 0 20px}.recovery button{text-align:left;width:100%}.recovery p{margin:4px 0 0;font-size:13px;color:var(--muted)}
|
|
32
|
+
header .tools{display:flex;gap:4px}
|
|
33
|
+
.skipbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:12px 0;border-bottom:1px solid var(--line);font-size:14px}.skipbar p{flex-basis:100%;margin:0;color:var(--muted);font-size:13px}
|
|
34
|
+
.skipbar input{flex:1;min-width:180px;font:inherit;font-size:14px;padding:6px 10px;border:1px solid #b9afa3;border-radius:6px;background:#fff;color:var(--ink)}
|
|
35
|
+
button.danger{background:#8d271e;border-color:#8d271e;color:#fff}button.danger:hover:not(:disabled){background:#6f1f18}
|
|
36
|
+
.turn.activity{margin:0 0 20px;font-size:13px;color:var(--muted)}.activity summary{cursor:pointer;list-style:none;display:flex;gap:8px;align-items:baseline}.activity summary::-webkit-details-marker{display:none}
|
|
37
|
+
.activity summary .latest::before{content:"✓ ";color:#7c8a5c}.activity.live summary .latest::before{content:"● ";color:var(--amber);animation:pulse 1.2s ease-in-out infinite}
|
|
38
|
+
.activity .count{color:#8f857a;white-space:nowrap}.activity .count::after{content:" ▸"}.activity details[open] .count::after{content:" ▾"}
|
|
39
|
+
.activity ol{list-style:none;margin:8px 0 0;padding:0 0 0 14px;border-left:1px solid var(--line);max-height:220px;overflow-y:auto}.activity ol li{padding:1px 0 1px 8px}
|
|
40
|
+
.status.busy::before{content:"● ";color:var(--amber);animation:pulse 1.2s ease-in-out infinite}@keyframes pulse{50%{opacity:.25}}
|
|
41
|
+
@media(prefers-reduced-motion:reduce){.status.busy::before,.activity.live summary .latest::before{animation:none}}
|
|
42
|
+
footer{flex-shrink:0;padding:0 0 14px;color:var(--muted);font-size:12px;line-height:1.5}footer p{margin:0}#waiting{margin-top:3px}[hidden]{display:none!important}
|
|
43
|
+
@media(max-width:600px){header{padding:12px 16px}.brand{gap:8px}.brand span{display:none}main{padding:0 16px}.context{align-items:flex-start;flex-direction:column;gap:4px}.conversation{padding-top:20px}.turn.human{margin-left:16px}.actions{gap:4px}button{padding:7px 10px}.hint{max-width:34em}}
|
|
44
|
+
</style></head><body><div class="shell">
|
|
45
|
+
<header><div class="brand"><strong>sphinx</strong><span>Understanding check</span></div><div class="tools"><button id="skip" class="quiet" type="button">Skip this time</button><button id="pause" class="quiet" type="button">Pause check</button></div></header>
|
|
46
|
+
<main><div class="context"><span id="meta"></span><span id="usage"></span></div>
|
|
47
|
+
<div id="skipbar" class="skipbar" hidden><input id="skipreason" maxlength="500" autocomplete="off" placeholder="Why is this urgent? (optional)" aria-label="Reason for skipping"><button id="skipconfirm" class="danger" type="button">Skip and continue</button><button id="skipcancel" class="quiet" type="button">Cancel</button><p>The push goes through without the check. The skip is logged.</p></div>
|
|
48
|
+
<div id="conversation" class="conversation" aria-label="Understanding check conversation">
|
|
49
|
+
<ol id="messages" aria-label="Conversation"></ol>
|
|
50
|
+
<p id="feedback" class="feedback" hidden></p><section id="question" hidden><p id="prompt" class="question"></p><ul id="choices" hidden></ul></section>
|
|
51
|
+
<p id="message" class="message" hidden></p><div id="status" class="status" role="status" aria-live="polite">Connecting to your local checkpoint…</div><div id="recovery" class="recovery" hidden></div>
|
|
52
|
+
</div>
|
|
53
|
+
<div id="composer" class="composer"><p id="error" class="error" role="alert" hidden></p>
|
|
54
|
+
<form id="form"><label for="response">Your answer</label><textarea id="response" rows="2" maxlength="8000" autocomplete="off" spellcheck="true" placeholder="Your prediction and a short reason…"></textarea>
|
|
55
|
+
<div class="actions"><button type="button" class="quiet" data-action="learn">Explain this</button><button type="button" class="quiet" data-action="dispute">Dispute question</button><button id="submit" class="primary" type="submit">Send</button></div>
|
|
56
|
+
<p class="hint">Enter to send · Shift+Enter for a new line. Open-book—inspect the code whenever useful.</p></form></div>
|
|
57
|
+
<footer><p>Your answers go directly to Sphinx. The coding agent receives status only.</p><p id="waiting">Closing this tab leaves the check waiting. Use Pause check to stop.</p></footer>
|
|
58
|
+
</main></div>
|
|
59
|
+
<script nonce="${nonce}">
|
|
60
|
+
const token=${JSON.stringify(token)},base='/'+token,$=id=>document.getElementById(id);
|
|
61
|
+
let current,submitting=false,finished=false,connected=false,lastQuestion,lastGraded,lastMessages='';const openGroups=new Set();
|
|
62
|
+
const text=(id,value)=>{$(id).textContent=value||'';$(id).hidden=!value;};
|
|
63
|
+
const compact=n=>n>=1e6?(n/1e6).toFixed(1)+'M':n>=1e3?(n/1e3).toFixed(1)+'k':String(n);
|
|
64
|
+
const usageText=(u,t)=>{if(!t)return '';const total=(t.input||0)+(t.output||0);if(!total&&t.cost==null)return '';
|
|
65
|
+
return [compact(total)+' tokens',...(t.cost!=null?[t.cost===0?'$0':t.cost<0.0001?'<$0.0001':t.cost<0.01?'$'+t.cost.toFixed(4):'$'+t.cost.toFixed(2)]:[]),...(u?.limit!=null?[u.calls+'/'+u.limit+' calls']:[])].join(' · ');};
|
|
66
|
+
const resize=()=>{$('response').style.height='auto';$('response').style.height=Math.min($('response').scrollHeight||76,180)+'px';};
|
|
67
|
+
function controls(){
|
|
68
|
+
if(!current)return;
|
|
69
|
+
const canAnswer=current.phase==='question'&&!current.operationClosed&&connected&&!submitting;
|
|
70
|
+
for(const button of document.querySelectorAll('button'))button.disabled=!canAnswer;
|
|
71
|
+
$('pause').disabled=!connected||submitting||finished;
|
|
72
|
+
for(const id of ['skip','skipconfirm','skipcancel'])$(id).disabled=!connected||finished;
|
|
73
|
+
for(const button of $('recovery').querySelectorAll('button'))button.disabled=!connected||submitting||finished;
|
|
74
|
+
$('response').disabled=current.phase!=='question'||current.operationClosed||submitting;
|
|
75
|
+
}
|
|
76
|
+
function history(messages){
|
|
77
|
+
const signature=JSON.stringify([current?.phase,messages]);
|
|
78
|
+
if(signature===lastMessages)return;
|
|
79
|
+
lastMessages=signature;
|
|
80
|
+
const scroll=$('conversation'),nearBottom=scroll.scrollHeight-scroll.scrollTop-scroll.clientHeight<100;
|
|
81
|
+
$('messages').replaceChildren();
|
|
82
|
+
for(let index=0;index<messages.length;index++){
|
|
83
|
+
const item=messages[index];
|
|
84
|
+
if(item.kind==='activity'){
|
|
85
|
+
// Consecutive review steps collapse to one line showing the latest step.
|
|
86
|
+
const steps=[item];while(messages[index+1]?.kind==='activity')steps.push(messages[++index]);
|
|
87
|
+
const row=document.createElement('li'),details=document.createElement('details'),summary=document.createElement('summary'),latest=document.createElement('span'),count=document.createElement('span'),list=document.createElement('ol');
|
|
88
|
+
const live=index===messages.length-1&¤t?.phase==='working';
|
|
89
|
+
row.className='turn activity'+(live?' live':'');latest.className='latest';latest.textContent=steps.at(-1).text;
|
|
90
|
+
count.className='count';count.textContent=steps.length===1?'':steps.length+' steps';
|
|
91
|
+
summary.append(latest,count);
|
|
92
|
+
for(const step of steps){const li=document.createElement('li');li.textContent=step.text;list.append(li);}
|
|
93
|
+
details.open=openGroups.has(item.id);details.ontoggle=()=>{if(details.open)openGroups.add(item.id);else openGroups.delete(item.id);};
|
|
94
|
+
details.append(summary,list);row.append(details);$('messages').append(row);continue;
|
|
95
|
+
}
|
|
96
|
+
const row=document.createElement('li'),name=document.createElement('div'),body=document.createElement('p');
|
|
97
|
+
row.className='turn '+(item.role==='human'?'human':'sphinx')+(item.kind==='question'?' question':item.kind==='notice'?' notice':'');
|
|
98
|
+
name.className='speaker';name.textContent=item.role==='human'?'You':'Sphinx';body.className='turn-text';body.textContent=item.text;
|
|
99
|
+
row.append(name);
|
|
100
|
+
if(item.kind==='question'&&item.number){const caption=document.createElement('div');caption.className='question-number';caption.textContent='Question '+item.number+(item.followUp?' · Follow-up':'');row.append(caption);}
|
|
101
|
+
row.append(body);
|
|
102
|
+
if(item.choices?.length){const list=document.createElement('ul');for(const choice of item.choices){const li=document.createElement('li');li.textContent=choice;list.append(li);}row.append(list);}
|
|
103
|
+
if(item.role==='human'&&['checking','not-graded'].includes(item.status)){const note=document.createElement('div');note.className='turn-note';note.textContent=item.status==='checking'?'Checking your reasoning…':'Not assessed. Your draft is still available below.';row.append(note);}
|
|
104
|
+
$('messages').append(row);
|
|
105
|
+
}
|
|
106
|
+
if(nearBottom)scroll.scrollTop=scroll.scrollHeight;
|
|
107
|
+
}
|
|
108
|
+
function render(state){
|
|
109
|
+
if(current&&state.sequence<current.sequence)return;
|
|
110
|
+
const previous=current;current=state;finished=state.operationClosed||['passed','paused','error','skipped'].includes(state.phase);
|
|
111
|
+
const repo=state.repoRoot?.split(/[\\\\/]/).filter(Boolean).at(-1),branch=state.target?.replace(/^refs\\/heads\\//,'');
|
|
112
|
+
text('meta',[repo,branch].filter(Boolean).join(' / '));$('meta').title=state.repoRoot||'';
|
|
113
|
+
text('usage',usageText(state.usage,state.tokens));
|
|
114
|
+
text('waiting',state.waitingGit?'Git continues after you pass. Closing this tab leaves it waiting; Pause check stops it.':'Closing this tab leaves the check waiting. Use Pause check to stop.');
|
|
115
|
+
const label=state.phase==='question'?'Question '+(state.progress.completed+1)+' of '+state.progress.total:state.phase==='working'?'Working…':state.phase==='recovery'?'Review needs your decision':state.phase==='passed'?'Complete':state.phase==='paused'?'Paused':state.phase==='skipped'?'Skipped':state.phase==='error'?'Check stopped':'Waiting…';
|
|
116
|
+
text('status',label);$('status').className='status'+(state.phase==='working'?' busy':'');text('message',state.message);text('error',state.error);
|
|
117
|
+
const messages=state.messages||[];history(messages);
|
|
118
|
+
// Live activity already shows progress; avoid stacking generic spinners under it.
|
|
119
|
+
if(state.phase==='working'&&messages.at(-1)?.kind==='activity'){$('status').hidden=true;$('message').hidden=true;}
|
|
120
|
+
text('feedback',messages.length?'':state.feedback);
|
|
121
|
+
$('question').hidden=state.phase!=='question'||messages.some(item=>item.kind==='question'&&item.questionId===state.question?.id);
|
|
122
|
+
$('composer').hidden=!['question','working'].includes(state.phase)&&!state.error;
|
|
123
|
+
$('form').hidden=!['question','working'].includes(state.phase);
|
|
124
|
+
$('recovery').hidden=state.phase!=='recovery';$('pause').hidden=finished;$('skip').hidden=finished;if(finished)$('skipbar').hidden=true;
|
|
125
|
+
if(state.question){
|
|
126
|
+
text('prompt',state.question.prompt);$('choices').replaceChildren();
|
|
127
|
+
for(const choice of state.question.choices||[]){const li=document.createElement('li');li.textContent=choice;$('choices').append(li);}
|
|
128
|
+
$('choices').hidden=!(state.question.choices||[]).length;
|
|
129
|
+
const key=state.id+':'+state.question.id;
|
|
130
|
+
if(lastQuestion!==key){$('response').value='';lastQuestion=key;resize();}
|
|
131
|
+
}
|
|
132
|
+
const graded=[...messages].reverse().find(item=>item.role==='human'&&item.status==='graded');
|
|
133
|
+
if(graded&&lastGraded!==graded.id){lastGraded=graded.id;$('response').value='';resize();}
|
|
134
|
+
$('recovery').replaceChildren();
|
|
135
|
+
if(state.phase==='recovery')for(const choice of state.choices||[]){
|
|
136
|
+
if(choice.value==='pause')continue;
|
|
137
|
+
const row=document.createElement('div'),button=document.createElement('button'),description=document.createElement('p');
|
|
138
|
+
button.type='button';button.textContent=choice.label;button.onclick=()=>send(choice.value);description.textContent=choice.description;row.append(button,description);$('recovery').append(row);
|
|
139
|
+
}
|
|
140
|
+
controls();
|
|
141
|
+
// A new step (question, recovery decision, result) must never sit below the fold.
|
|
142
|
+
if(previous?.phase!==state.phase||previous?.question?.id!==state.question?.id){const scroll=$('conversation');scroll.scrollTop=scroll.scrollHeight;}
|
|
143
|
+
if(state.phase==='question'&&(previous?.phase!=='question'||previous?.question?.id!==state.question?.id)&&document.visibilityState!=='hidden')$('response').focus({preventScroll:true});
|
|
144
|
+
}
|
|
145
|
+
async function send(action){
|
|
146
|
+
if(!current||submitting||finished||!connected)return;
|
|
147
|
+
if(['answer','learn','dispute'].includes(action)&¤t.phase!=='question')return;
|
|
148
|
+
const response=$('response').value.trim();
|
|
149
|
+
if(['answer','dispute'].includes(action)&&!response){text('error',action==='dispute'?'Describe what is ambiguous or unsupported in the answer box.':'Enter your prediction and a short reason.');$('response').focus();return;}
|
|
150
|
+
submitting=true;controls();
|
|
151
|
+
try{
|
|
152
|
+
const res=await fetch(base+'/action',{method:'POST',headers:{'Content-Type':'application/json','X-Sphinx-Token':token},body:JSON.stringify({id:current.id,revision:current.revision,action,...(['answer','learn','dispute'].includes(action)?{questionId:current.question.id,response}:action==='skip'&&$('skipreason').value.trim()?{response:$('skipreason').value.trim()}:{})})});
|
|
153
|
+
const data=await res.json();if(!res.ok){text('error',data.error||'This response could not be submitted.');return;}render(data);
|
|
154
|
+
}catch{if(!finished)text('error','Connection interrupted. Your response is not resent automatically. Wait for the current state before trying again.');}
|
|
155
|
+
finally{submitting=false;controls();}
|
|
156
|
+
}
|
|
157
|
+
$('form').onsubmit=event=>{event.preventDefault();send('answer');};
|
|
158
|
+
$('response').oninput=resize;
|
|
159
|
+
$('response').onkeydown=event=>{
|
|
160
|
+
if(event.key!=='Enter'||event.shiftKey||event.isComposing||event.keyCode===229)return;
|
|
161
|
+
event.preventDefault();if(!event.repeat)send('answer');
|
|
162
|
+
};
|
|
163
|
+
for(const button of document.querySelectorAll('[data-action]'))button.onclick=()=>send(button.dataset.action);
|
|
164
|
+
$('pause').onclick=()=>send('pause');
|
|
165
|
+
$('skip').onclick=()=>{$('skipbar').hidden=false;$('skipreason').focus();};
|
|
166
|
+
$('skipcancel').onclick=()=>{$('skipbar').hidden=true;};
|
|
167
|
+
$('skipconfirm').onclick=()=>send('skip');
|
|
168
|
+
$('skipreason').onkeydown=event=>{if(event.key==='Enter'&&!event.isComposing){event.preventDefault();send('skip');}else if(event.key==='Escape')$('skipbar').hidden=true;};
|
|
169
|
+
const events=new EventSource(base+'/events');events.onmessage=event=>{try{connected=true;render(JSON.parse(event.data));if(current?.operationClosed){connected=false;events.close();}}catch{}};
|
|
170
|
+
events.onerror=()=>{connected=false;controls();if(current?.operationClosed){events.close();return;}text('error','Connection interrupted. Reconnecting… No response is retried automatically. If Git has stopped, check its output.');};
|
|
171
|
+
</script></body></html>`;
|
|
172
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { questionLimit } from './routing.js';
|
|
2
|
+
import { modelUsageText } from './usage.js';
|
|
3
|
+
|
|
4
|
+
const setupCodes = new Set(['INVALID_CONFIG', 'INVALID_SNAPSHOT', 'STALE_SNAPSHOT', 'CONTEXT_LIMIT', 'TOOL_RESULT_LIMIT', 'OUTPUT_LIMIT', 'PROVIDER_NOT_FOUND', 'PROVIDER_START_FAILED', 'PROVIDER_FAILED']);
|
|
5
|
+
const setupTypes = new Set(['authentication', 'permission_denied', 'payment_required', 'context_length_exceeded', 'max_tokens_exceeded', 'token_limit_exceeded', 'string_too_long', 'invalid_request', 'invalid_prompt', 'not_found', 'precondition_failed', 'payload_too_large', 'unprocessable', 'content_policy_violation', 'refusal']);
|
|
6
|
+
|
|
7
|
+
function retryable(error, before, state, minimum) {
|
|
8
|
+
if (setupCodes.has(error.code) || setupTypes.has(error.errorType)) return false;
|
|
9
|
+
if (error.code === 'OPENROUTER_PROVIDER_ERROR' && [400, 401, 402, 403, 404, 413, 422].includes(error.status)) return false;
|
|
10
|
+
// A failed preflight cannot improve by spending more. Budget exhaustion is
|
|
11
|
+
// different: an explicit human choice can authorize a new allowance.
|
|
12
|
+
if (state.usage.calls === before.usage.calls) {
|
|
13
|
+
return state.usage.limit !== null && state.usage.limit - state.usage.calls < minimum
|
|
14
|
+
&& (error.code === 'REVIEWER_BUDGET' || /^Assessment call limit \(/u.test(error.message));
|
|
15
|
+
}
|
|
16
|
+
return !/API key|sign.in|model access|output (?:token )?limit|finish_reason: length|refused or filtered/iu.test(error.message);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function diagnostic(error, state) {
|
|
20
|
+
// The interactive menu replaces only Sphinx's own appended command advice;
|
|
21
|
+
// the underlying failure and its sanitized provider diagnostics stay visible.
|
|
22
|
+
const hints = [
|
|
23
|
+
` Run sphinx check --id ${state.id} --retry-review to explicitly authorize a fresh review allowance; prior usage will be retained.`,
|
|
24
|
+
` ${state.usage.limit - state.usage.calls} model calls remain in the existing assessment allowance (${state.usage.calls}/${state.usage.limit} used). When ready, retry within that allowance: sphinx check --id ${state.id}.`,
|
|
25
|
+
` When ready, continue the check: sphinx check --id ${state.id}.`,
|
|
26
|
+
];
|
|
27
|
+
let message = error.message || String(error);
|
|
28
|
+
for (const hint of hints) if (message.endsWith(hint)) message = message.slice(0, -hint.length);
|
|
29
|
+
return message;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Keep a human-owned terminal open after review failure. This function never
|
|
33
|
+
* chooses to retry, expands a budget, changes a route, or awards completion on
|
|
34
|
+
* its own. inspect() exposes public state only; validate() checks current Git
|
|
35
|
+
* content and policy before a recovery choice is shown. */
|
|
36
|
+
export async function prepareInteractiveCheck({ start, inspect, authorizeMore, validate, config, choose, ui, waitingGit = false, signal }) {
|
|
37
|
+
for (;;) {
|
|
38
|
+
signal?.throwIfAborted();
|
|
39
|
+
const before = await inspect();
|
|
40
|
+
signal?.throwIfAborted();
|
|
41
|
+
try { const result = await start(); signal?.throwIfAborted(); return result; }
|
|
42
|
+
catch (error) {
|
|
43
|
+
signal?.throwIfAborted();
|
|
44
|
+
if (error.name === 'AbortError' || ['SPHINX_CANCELLED', 'EVALUATOR_CANCELLED'].includes(error.code)) throw error;
|
|
45
|
+
const state = await inspect();
|
|
46
|
+
signal?.throwIfAborted();
|
|
47
|
+
if (state.status !== 'pending' || state.preparation?.status !== 'failed' || state.progress.total !== 0) throw error;
|
|
48
|
+
await validate();
|
|
49
|
+
signal?.throwIfAborted();
|
|
50
|
+
const reserve = questionLimit(config.level, state.route.tier);
|
|
51
|
+
const reviewMinimum = state.preparation.minimumReviewerCalls === 1 ? 1 : 2, minimum = reserve + reviewMinimum;
|
|
52
|
+
const unlimited = state.usage.limit === null;
|
|
53
|
+
const remaining = unlimited ? Infinity : state.usage.limit - state.usage.calls;
|
|
54
|
+
const choices = [];
|
|
55
|
+
ui.warning(diagnostic(error, state));
|
|
56
|
+
ui.note(`Used so far: ${modelUsageText(state.usage, state.tokens)}. No questions or approval were recorded.`);
|
|
57
|
+
if (retryable(error, before, state, minimum)) {
|
|
58
|
+
if (remaining >= minimum) choices.push({
|
|
59
|
+
value: 'retry', label: state.preparation.resumable ? 'Continue the saved review' : unlimited ? 'Retry the review' : 'Retry within the remaining allowance',
|
|
60
|
+
description: `${unlimited ? 'All usage so far is kept.' : `${remaining} calls remain, including ${reserve} reserved for grading.`} ${state.preparation.resumable ? 'Reuse the evidence and investigation already saved.' : 'Keep the same evaluator route and spending settings.'}`,
|
|
61
|
+
});
|
|
62
|
+
else if (config.maxModelCalls >= minimum) choices.push({
|
|
63
|
+
value: 'extend', label: `Allow up to ${config.maxModelCalls} more calls and retry`,
|
|
64
|
+
description: `${remaining} calls remain; review needs at least ${reviewMinimum} plus ${reserve} reserved for grading. Keep all ${state.usage.calls} used calls counted and record this new allowance.`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (!choices.length) ui.note('Resolve the reported setup or evidence problem before resuming. Repeating this attempt is not offered here.');
|
|
68
|
+
choices.push({ value: 'pause', label: 'Pause this check', description: 'Save progress without making another model request. The Git operation remains unapproved.' });
|
|
69
|
+
const action = await choose({
|
|
70
|
+
title: 'Review did not finish',
|
|
71
|
+
description: waitingGit
|
|
72
|
+
? 'The original Git operation is waiting while this terminal stays open, subject to its time limit. Choose how to continue.'
|
|
73
|
+
: 'Choose how to continue. Nothing is retried automatically.',
|
|
74
|
+
choices, current: 'pause',
|
|
75
|
+
});
|
|
76
|
+
signal?.throwIfAborted();
|
|
77
|
+
if (action === 'pause') {
|
|
78
|
+
ui.note('Progress saved. No approval was recorded.');
|
|
79
|
+
ui.command(`sphinx check --id ${state.id}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (!choices.some(choice => choice.value === action)) throw new Error('Invalid review recovery choice. No additional model request was made.');
|
|
83
|
+
if (action === 'extend') {
|
|
84
|
+
await authorizeMore();
|
|
85
|
+
signal?.throwIfAborted();
|
|
86
|
+
ui.note(`Authorized up to ${config.maxModelCalls} additional model calls, including grading. Earlier usage remains counted.`);
|
|
87
|
+
}
|
|
88
|
+
// The next start still performs the store's locked policy/snapshot and
|
|
89
|
+
// budget checks. A new failure always returns to the human, never a retry loop.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/checkbox.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { createTerminal, colorEnabled, wrapText } from './terminal.js';
|
|
3
|
+
|
|
4
|
+
const CLEAR = '\r\x1b[J';
|
|
5
|
+
const NAVIGATION = new Map([
|
|
6
|
+
['\x1b[A', 'up'], ['\x1bOA', 'up'], ['\x1b[B', 'down'], ['\x1bOB', 'down'],
|
|
7
|
+
['\x1b[H', 'home'], ['\x1bOH', 'home'], ['\x1b[1~', 'home'], ['\x1b[7~', 'home'],
|
|
8
|
+
['\x1b[F', 'end'], ['\x1bOF', 'end'], ['\x1b[4~', 'end'], ['\x1b[8~', 'end'],
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
function cancelled() {
|
|
12
|
+
const error = new Error('Selection cancelled.');
|
|
13
|
+
error.code = 'SPHINX_CANCELLED'; error.exitCode = 130;
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A small inline checkbox prompt. The caller must close its readline first. */
|
|
18
|
+
export async function checkbox({ choices, current = [], title = 'Connect your coding agents', description, ui, input = process.stdin, output = process.stdout, env = process.env, escapeTimeoutMs = 50, signalSource = process } = {}) {
|
|
19
|
+
if (!Array.isArray(choices) || choices.some(choice => !choice || typeof choice.value !== 'string' || !choice.value || typeof choice.label !== 'string' || !choice.label) || new Set(choices.map(choice => choice.value)).size !== choices.length || !Array.isArray(current)) throw new Error('Checkbox choices need unique values, labels, and an array of selected values.');
|
|
20
|
+
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== 'function' || env.TERM === 'dumb') {
|
|
21
|
+
const error = new Error('Checkbox selection requires an interactive terminal.'); error.code = 'SPHINX_TTY_REQUIRED'; throw error;
|
|
22
|
+
}
|
|
23
|
+
if (input.destroyed || input.readableEnded) throw cancelled();
|
|
24
|
+
const terminal = ui || createTerminal({ stream: output, env });
|
|
25
|
+
const style = colorEnabled({ stream: output, env }) ? terminal.style : { strong: String, amber: String, green: String, muted: String };
|
|
26
|
+
const selected = new Set(current.filter(value => choices.some(choice => choice.value === value)));
|
|
27
|
+
const rows = [{ label: 'All supported agents', description: 'Select or clear every supported agent. Leave everything unchecked for Git and terminal only.' }, ...choices];
|
|
28
|
+
// Untouched stdin has readableFlowing=null while isPaused() is false. Resume
|
|
29
|
+
// only a stream that was actually flowing; otherwise the prompt keeps Node
|
|
30
|
+
// alive after installation when it was the first interactive question.
|
|
31
|
+
const wasRaw = !!input.isRaw, wasPaused = input.readableFlowing !== true;
|
|
32
|
+
let focus = 0, renderedLines = 0, escapeTimer, pending = '', finished = false, rawChanged = false, cursorHidden = false;
|
|
33
|
+
const decoder = new StringDecoder('utf8');
|
|
34
|
+
const width = () => Math.max(10, Math.min(Number(output.columns) || terminal.width || 80, terminal.width || 100) - 1);
|
|
35
|
+
const clearFrame = () => {
|
|
36
|
+
if (renderedLines) output.write(`\x1b[${renderedLines}A${CLEAR}`);
|
|
37
|
+
renderedLines = 0;
|
|
38
|
+
};
|
|
39
|
+
const render = () => {
|
|
40
|
+
clearFrame();
|
|
41
|
+
const columns = width(), height = Math.max(5, (Number(output.rows) || 24) - 1);
|
|
42
|
+
const fullHint = 'Arrows move · Space toggles · A all · Enter confirms · Esc cancels';
|
|
43
|
+
let hint = wrapText(fullHint, columns).split('\n');
|
|
44
|
+
if (hint.length + rows.length + 3 > height) hint = wrapText('↑↓ move Space toggle Enter done Esc cancel', columns).split('\n');
|
|
45
|
+
const renderedRows = rows.map((row, index) => {
|
|
46
|
+
const check = index === 0 ? selected.size === 0 ? ' ' : selected.size === choices.length ? 'x' : '-' : selected.has(choices[index - 1].value) ? 'x' : ' ';
|
|
47
|
+
const prefix = `${index === focus ? '>' : ' '} [${check}] `;
|
|
48
|
+
return wrapText(row.label, Math.max(1, columns - prefix.length)).split('\n').map((line, part) => {
|
|
49
|
+
const text = (part ? ' '.repeat(prefix.length) : prefix) + line;
|
|
50
|
+
return index === focus ? style.amber(text) : text;
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
const rowRoom = Math.max(1, height - hint.length - 2);
|
|
54
|
+
let first = 0;
|
|
55
|
+
while (first < focus && renderedRows.slice(first, focus + 1).reduce((count, row) => count + row.length, 0) > rowRoom) first++;
|
|
56
|
+
const lines = [];
|
|
57
|
+
for (let index = first; index < renderedRows.length; index++) {
|
|
58
|
+
if (lines.length && lines.length + renderedRows[index].length > rowRoom) break;
|
|
59
|
+
lines.push(...renderedRows[index].slice(0, rowRoom - lines.length));
|
|
60
|
+
}
|
|
61
|
+
const detailRoom = Math.max(0, height - lines.length - hint.length - 1);
|
|
62
|
+
const detail = wrapText(rows[focus].description || '', columns).split('\n').slice(0, Math.min(3, detailRoom));
|
|
63
|
+
if (detail.length && detail.some(Boolean)) lines.push('', ...detail.map(style.muted));
|
|
64
|
+
lines.push(...hint.map(style.muted));
|
|
65
|
+
renderedLines = lines.length;
|
|
66
|
+
output.write(`${lines.join('\n')}\n`);
|
|
67
|
+
};
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const cleanup = () => {
|
|
70
|
+
clearTimeout(escapeTimer);
|
|
71
|
+
input.removeListener('data', onData); input.removeListener('end', onEnd); input.removeListener('close', onEnd); input.removeListener('error', onError);
|
|
72
|
+
output.removeListener('resize', onResize); signalSource.removeListener('SIGINT', onInterrupt);
|
|
73
|
+
try { if (rawChanged) input.setRawMode(wasRaw); } catch {}
|
|
74
|
+
try { if (wasPaused) input.pause(); else input.resume(); } catch {}
|
|
75
|
+
try { if (cursorHidden) output.write('\x1b[?25h'); } catch {}
|
|
76
|
+
};
|
|
77
|
+
const finish = error => {
|
|
78
|
+
if (finished) return;
|
|
79
|
+
finished = true;
|
|
80
|
+
let failure = error;
|
|
81
|
+
const values = choices.filter(choice => selected.has(choice.value)).map(choice => choice.value);
|
|
82
|
+
try {
|
|
83
|
+
clearFrame();
|
|
84
|
+
if (!failure) {
|
|
85
|
+
const summary = values.length ? `Selected: ${choices.filter(choice => selected.has(choice.value)).map(choice => choice.label).join(', ')}` : 'Git and terminal only.';
|
|
86
|
+
output.write(`${wrapText(summary, width())}\n`);
|
|
87
|
+
}
|
|
88
|
+
} catch (error) { failure ||= error; }
|
|
89
|
+
cleanup();
|
|
90
|
+
if (failure) reject(failure); else resolve(values);
|
|
91
|
+
};
|
|
92
|
+
const toggleAll = () => {
|
|
93
|
+
if (selected.size === choices.length) selected.clear();
|
|
94
|
+
else for (const choice of choices) selected.add(choice.value);
|
|
95
|
+
};
|
|
96
|
+
const key = name => {
|
|
97
|
+
if (finished) return;
|
|
98
|
+
if (name === 'cancel') { finish(cancelled()); return; }
|
|
99
|
+
if (name === 'enter') { finish(); return; }
|
|
100
|
+
if (name === 'up') focus = (focus - 1 + rows.length) % rows.length;
|
|
101
|
+
else if (name === 'down') focus = (focus + 1) % rows.length;
|
|
102
|
+
else if (name === 'home') focus = 0;
|
|
103
|
+
else if (name === 'end') focus = rows.length - 1;
|
|
104
|
+
else if (name === 'all' || (name === 'space' && focus === 0)) toggleAll();
|
|
105
|
+
else if (name === 'space') {
|
|
106
|
+
const value = choices[focus - 1].value;
|
|
107
|
+
if (selected.has(value)) selected.delete(value); else selected.add(value);
|
|
108
|
+
} else return;
|
|
109
|
+
render();
|
|
110
|
+
};
|
|
111
|
+
const parse = () => {
|
|
112
|
+
clearTimeout(escapeTimer);
|
|
113
|
+
while (pending && !finished) {
|
|
114
|
+
if (pending[0] === '\x1b') {
|
|
115
|
+
const complete = [...NAVIGATION].find(([sequence]) => pending.startsWith(sequence));
|
|
116
|
+
if (complete) { pending = pending.slice(complete[0].length); key(complete[1]); continue; }
|
|
117
|
+
if ([...NAVIGATION.keys()].some(sequence => sequence.startsWith(pending))) {
|
|
118
|
+
escapeTimer = setTimeout(() => { pending = ''; key('cancel'); }, escapeTimeoutMs);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// Ignore complete unrecognized CSI keys, such as Delete or PageDown.
|
|
122
|
+
const other = /^\x1b\[[0-?]*[ -/]*[@-~]/.exec(pending);
|
|
123
|
+
if (other) { pending = pending.slice(other[0].length); continue; }
|
|
124
|
+
key('cancel'); return;
|
|
125
|
+
}
|
|
126
|
+
const character = pending[0]; pending = pending.slice(1);
|
|
127
|
+
if (character === '\x03' || character === '\x04') key('cancel');
|
|
128
|
+
else if (character === '\r' || character === '\n') key('enter');
|
|
129
|
+
else if (character === ' ') key('space');
|
|
130
|
+
else if (character === 'a' || character === 'A') key('all');
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const onData = chunk => { try { pending += typeof chunk === 'string' ? chunk : decoder.write(chunk); parse(); } catch (error) { finish(error); } };
|
|
134
|
+
const onEnd = () => finish(cancelled());
|
|
135
|
+
const onError = error => finish(error);
|
|
136
|
+
const onInterrupt = () => finish(cancelled());
|
|
137
|
+
const onResize = () => { try { render(); } catch (error) { finish(error); } };
|
|
138
|
+
try {
|
|
139
|
+
if (title) output.write(`${wrapText(style.strong(title), width())}\n`);
|
|
140
|
+
if (description) output.write(`${wrapText(description, width())}\n`);
|
|
141
|
+
output.write('\n\x1b[?25l'); cursorHidden = true;
|
|
142
|
+
input.setRawMode(true); rawChanged = true;
|
|
143
|
+
input.on('data', onData); input.on('end', onEnd); input.on('close', onEnd); input.on('error', onError);
|
|
144
|
+
output.on('resize', onResize); signalSource.on('SIGINT', onInterrupt);
|
|
145
|
+
render(); input.resume();
|
|
146
|
+
} catch (error) { finish(error); }
|
|
147
|
+
});
|
|
148
|
+
}
|