kaizen-agent 1.7.3 → 1.7.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.7.3",
3
+ "version": "1.7.4",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -165,3 +165,14 @@ agent:
165
165
  # opencode: { model: anthropic/claude-sonnet-4-6 }
166
166
  # gemini: { model: gemini-2.5-pro }
167
167
 
168
+
169
+ dictation:
170
+ # Whisper model the web board dictates with. Runs in the browser; downloaded once
171
+ # from Hugging Face and cached. Bigger is more accurate and better with languages
172
+ # other than English, at the cost of the first-use download and GPU memory:
173
+ # tiny ~ 60 MB fast, rough
174
+ # base ~165 MB quick, English-leaning
175
+ # small ~410 MB accurate, good across languages (default)
176
+ # large-v3-turbo ~1.5 GB best, needs a capable GPU
177
+ # Changing it downloads the new model on the next press of the mic.
178
+ model: small
package/web/board.html CHANGED
@@ -28,8 +28,10 @@ pre.mark span:nth-child(1){color:var(--ink-hi)}pre.mark span:nth-child(2){color:
28
28
  .ib.sm{width:26px;height:26px}.ib.sm svg{width:15px;height:15px}
29
29
  /* dictation mic: accent while listening (pulsing) or still transcribing; digits while the model downloads */
30
30
  /* the field being dictated into: accent border and a glow that swells with the voice level (--lvl, 0..1) */
31
- .dictating{border-color:var(--accent)!important;box-shadow:0 0 0 calc(2px + 6px*var(--lvl,0)) color-mix(in srgb,var(--accent) 35%,transparent);transition:box-shadow .08s}
32
- .ib.mic.on{color:var(--accent);animation:pl 1.6s infinite}.ib.mic.busy{color:var(--accent)}.ib.mic i{font-style:normal;font-size:10px;font-variant-numeric:tabular-nums}.row .mic{margin-right:auto;align-self:center}
31
+ .dictating{outline:none;border-color:var(--accent)!important;box-shadow:0 0 calc(6px + 16px*var(--lvl,0)) calc(1px + 3px*var(--lvl,0)) color-mix(in srgb,var(--accent) 45%,transparent);transition:box-shadow .08s}
32
+ /* the mic button turns into a level meter while listening: five bars, each weighted so the set reads as a wave */
33
+ .vu{display:inline-flex;gap:2px;align-items:center;height:100%}.vu i{width:2px;border-radius:1px;background:currentColor;height:calc(3px + 12px*min(1,var(--lvl,0)*var(--w)));transition:height .08s}
34
+ .ib.mic.on{color:var(--accent)}.ib.mic.busy{color:var(--accent)}.ib.mic i{font-style:normal;font-size:10px;font-variant-numeric:tabular-nums}.row .mic{margin-right:auto;align-self:center}
33
35
  .btn{border:1px solid var(--line);border-radius:var(--r);padding:5px 12px;color:var(--ink);background:var(--bg-2);min-height:30px;font-size:13px}
34
36
  .btn:hover{border-color:var(--dim)}
35
37
  .btn.pri{background:var(--accent);border-color:var(--accent);color:#fff}
@@ -147,18 +149,22 @@ aside .foot:empty{display:none}
147
149
  .find input{margin-top:4px}
148
150
  .find .sev{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--warn)}.find .sev.hi{color:var(--danger)}
149
151
  .find .loc{color:var(--dim);font-size:12px;margin-left:8px}
150
- .set{display:grid;grid-template-columns:auto 1fr;gap:10px 20px;align-items:center;font-size:13px}
151
- .set label{color:var(--dim)}
152
- .set .opts{display:flex;flex-wrap:wrap;gap:4px}
153
- .set .opts button{border:1px solid var(--line);border-radius:var(--r);padding:3px 10px;color:var(--dim)}
154
- .set .opts button.on{color:var(--ink);border-color:var(--ink)}
152
+ /* settings: the TUI screen, one row per setting — label, then the value in its own grammar */
153
+ .sg{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:18px 0 6px;font-weight:600}.sg:first-child{margin-top:0}
154
+ .srow{display:grid;grid-template-columns:200px 1fr;gap:0 16px;align-items:center;font-size:13px;padding:4px 8px;margin:0 -8px;border-radius:var(--r)}
155
+ .srow:hover,.srow:focus-within{background:color-mix(in srgb,var(--dim) 8%,transparent)}
156
+ .srow label{color:var(--dim)}
157
+ .srow .hint{display:none;grid-column:1/-1;margin:6px 0 2px;font-size:12px;color:var(--dim);line-height:1.45;max-width:60ch}.srow:hover .hint,.srow:focus-within .hint{display:block}.srow .hint code{background:none;padding:0;color:var(--dim)}
158
+ .srow .sel{padding:2px 6px;font-size:13px;justify-self:start;border-color:transparent}.srow:hover .sel,.srow .sel:focus{border-color:var(--line)}
159
+ .chk{justify-self:start;color:var(--ink);padding:2px 0;font-variant-numeric:tabular-nums}.chk:not(.on){color:var(--dim)}.chk i{font-style:normal;color:var(--ink)}
160
+ .bar{display:inline-flex;align-items:center;gap:2px;justify-self:start}.bar button{width:10px;height:14px;border:1px solid var(--dim);border-radius:2px;background:none}.bar button.on{background:var(--ink);border-color:var(--ink)}.bar button:hover{border-color:var(--accent)}.bar b{font-weight:400;margin-left:6px;font-variant-numeric:tabular-nums}
155
161
  .toast{position:fixed;z-index:12;left:24px;bottom:52px;background:var(--ink);color:var(--bg);border-radius:var(--r);padding:8px 14px;font-size:13px;opacity:0;transition:opacity .2s;pointer-events:none}
156
162
  .toast.on{opacity:1}
157
163
  .help{position:fixed;inset:0;z-index:12;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 70%,transparent)}
158
164
  .help.on{display:flex}.help div{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:20px 24px;line-height:2}
159
165
  .help kbd{display:inline-block;width:7ch;color:var(--accent)}
160
166
  @media(max-width:900px){main{grid-template-columns:1fr;padding-inline:16px;gap:8px}.hdr,.strip,.ftr{padding-inline:16px}section h2{position:sticky;top:0;background:var(--bg);z-index:1}.more{display:grid}.card:hover .age{display:inline}}
161
- @media(prefers-reduced-motion:reduce){aside,.toast,.mascot,.d-run .pulse,.ib.mic.on,.dictating{transition:none;animation:none}}
167
+ @media(prefers-reduced-motion:reduce){aside,.toast,.mascot,.d-run .pulse,.ib.mic.on,.dictating,.vu i{transition:none;animation:none}}
162
168
  </style>
163
169
  </head>
164
170
  <body>
@@ -227,15 +233,17 @@ const ico=n=>`<svg><use href="#i-${n}"/></svg>`;
227
233
 
228
234
  // ---- dictation: Whisper in the page. Audio never leaves the browser; the model is
229
235
  // fetched once from huggingface.co and cached. Library and model are pinned here.
230
- const TFJS="https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0",ASR_MODEL="onnx-community/whisper-base";
236
+ const TFJS="https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0",ASR_MODELS={tiny:"onnx-community/whisper-tiny",base:"onnx-community/whisper-base",small:"onnx-community/whisper-small","large-v3-turbo":"onnx-community/whisper-large-v3-turbo"};
237
+ // The model is a setting (dictation.model); the worker is rebuilt when it changes.
238
+ let asrModel=null;
231
239
  // Inference lives in a module Worker so a slow chunk never freezes the board.
232
240
  const WORKER_SRC=`import{pipeline}from"${TFJS}";let asr=null;
233
241
  onmessage=async e=>{const m=e.data;try{
234
242
  if(m.type==="load"){const prog=p=>{if(p.status==="progress")postMessage({type:"progress",file:p.file,pct:p.progress||0})};
235
243
  // WebGPU first, with the encoder in fp16 only where the adapter has shader-f16; any GPU failure falls back to the CPU.
236
244
  const gpu=m.webgpu?{device:"webgpu",dtype:{encoder_model:m.f16?"fp16":"fp32",decoder_model_merged:"q4"}}:null,cpu={device:"wasm",dtype:"q8"};
237
- try{if(!gpu)throw 0;asr=await pipeline("automatic-speech-recognition","${ASR_MODEL}",{...gpu,progress_callback:prog})}
238
- catch(e){if(gpu)postMessage({type:"fallback",why:String(e&&e.message||e)});asr=await pipeline("automatic-speech-recognition","${ASR_MODEL}",{...cpu,progress_callback:prog})}
245
+ try{if(!gpu)throw 0;asr=await pipeline("automatic-speech-recognition",m.model,{...gpu,progress_callback:prog})}
246
+ catch(e){if(gpu)postMessage({type:"fallback",why:String(e&&e.message||e)});asr=await pipeline("automatic-speech-recognition",m.model,{...cpu,progress_callback:prog})}
239
247
  postMessage({type:"ready"})}
240
248
  if(m.type==="run"){const r=await asr(m.audio,{chunk_length_s:30,return_timestamps:false});postMessage({type:"text",sid:m.sid,seq:m.seq,text:(r.text||"").trim()})}
241
249
  }catch(err){postMessage({type:"error",sid:m.sid,seq:m.seq,error:String(err&&err.message||err)})}}`;
@@ -245,13 +253,18 @@ const HAS_MIC=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&wi
245
253
  // `active` is the one session listening; `sessions` also holds stopped ones still waiting on a chunk.
246
254
  let asrWorker=null,asrReady=null,asrProgress=()=>{},active=null,starting=false;const sessions=new Set();
247
255
  // One worker for the page; loading is awaited by whoever pressed first, later presses join.
248
- function loadAsr(){if(asrReady)return asrReady;asrReady=new Promise(async(res,rej)=>{
256
+ // Loads the model named in settings; a model changed since the last load replaces the worker.
257
+ async function loadAsr(){
258
+ let want="small";try{const cfg=await fetch("/settings").then(r=>r.json());want=(cfg.rows.find(r=>r.key==="dictation.model")||{}).value||want}catch(e){}
259
+ if(!ASR_MODELS[want])want="small";
260
+ if(asrReady&&asrModel!==want){try{asrWorker.terminate()}catch(e){}asrWorker=null;asrReady=null;sessions.clear();active=null}
261
+ if(asrReady)return asrReady;asrModel=want;asrReady=new Promise(async(res,rej)=>{
249
262
  asrWorker=new Worker(URL.createObjectURL(new Blob([WORKER_SRC],{type:"text/javascript"})),{type:"module"});
250
263
  const files={};asrWorker.onmessage=e=>{const m=e.data;if(m.type==="progress"){files[m.file]=m.pct;const v=Object.values(files);asrProgress(Math.round(v.reduce((a,b)=>a+b,0)/v.length))}else if(m.type==="ready")res();else if(m.type==="fallback")toast("GPU could not run the model, using the CPU: "+m.why);else if(m.type==="error"&&m.seq==null)rej(new Error(m.error));for(const s of sessions)s.onmsg(m)};
251
264
  asrWorker.onerror=e=>rej(new Error(e.message||"worker failed"));
252
265
  // Ask the adapter what it can do: no adapter means the CPU path; no shader-f16 means an fp32 encoder.
253
266
  let adapter=null;try{adapter=navigator.gpu&&await navigator.gpu.requestAdapter()}catch(e){}
254
- if(!adapter)toast("No WebGPU: dictation will run on the CPU and be slower");asrWorker.postMessage({type:"load",webgpu:!!adapter,f16:!!(adapter&&adapter.features.has("shader-f16"))})});
267
+ if(!adapter)toast("No WebGPU: dictation will run on the CPU and be slower");asrWorker.postMessage({type:"load",model:ASR_MODELS[asrModel],webgpu:!!adapter,f16:!!(adapter&&adapter.features.has("shader-f16"))})});
255
268
  asrReady.catch(()=>{asrReady=null;try{asrWorker.terminate()}catch(e){}asrWorker=null});return asrReady}
256
269
  // After a re-render only a listener whose field is gone is stopped; closing the panel stops any.
257
270
  function stopDictation(all){if(active&&(all||!active.field.isConnected))active.stop()}
@@ -275,20 +288,21 @@ function mic(fields){
275
288
  // Text lands where the caret is when each chunk arrives (or after the last insertion when the
276
289
  // field is not focused), so typing while the mic is on is kept.
277
290
  const f=target;let at=f.selectionStart??f.value.length;
291
+ const vu=document.createElement("span");vu.className="vu";vu.setAttribute("aria-hidden","true");vu.innerHTML=[.5,.9,1.3,.8,.45].map(w=>`<i style="--w:${w}"></i>`).join("");
278
292
  let buf=[],len=0,voiced=0,quiet=0,seq=0,done=0,pending=new Map(),stopping=false;
279
293
  const SR=16000,MAX=6*SR,SIL=0.8*SR,MIN=1*SR;
280
294
  const insert=t=>{if(!t||!f.isConnected)return;const v=f.value;if(document.activeElement===f)at=f.selectionStart;at=Math.min(at,v.length);const head=v.slice(0,at),sep=head&&!/\s$/.test(head)?" ":"";f.value=head+sep+t+v.slice(at);at=head.length+sep.length+t.length;f.selectionStart=f.selectionEnd=at;f.dispatchEvent(new Event("input",{bubbles:true}))};
281
295
  const flush=()=>{if(!len||voiced<MIN){buf=[];len=0;voiced=0;quiet=0;return}const a=new Float32Array(len);let o=0;for(const c of buf){a.set(c,o);o+=c.length}buf=[];len=0;voiced=0;quiet=0;pending.set(seq,null);asrWorker.postMessage({type:"run",sid,seq,audio:a},[a.buffer]);seq++;b.classList.add("busy")};
282
296
  const drain=()=>{while(pending.has(done)&&pending.get(done)!==null){insert(pending.get(done));pending.delete(done);done++}if(!pending.size){b.classList.remove("busy");if(stopping){idle();sessions.delete(sess)}}};
283
297
  tap.port.onmessage=e=>{const c=e.data;buf.push(c);len+=c.length;let s=0;for(let i=0;i<c.length;i++)s+=c[i]*c[i];const rms=Math.sqrt(s/c.length);
284
- if(rms>0.01){voiced+=c.length;quiet=0}else quiet+=c.length;f.style.setProperty("--lvl",Math.min(1,rms*8).toFixed(2));
298
+ if(rms>0.01){voiced+=c.length;quiet=0}else quiet+=c.length;{const l=Math.min(1,rms*8).toFixed(2);f.style.setProperty("--lvl",l);vu.style.setProperty("--lvl",l)}
285
299
  if(len>=MAX||(voiced>=MIN&&quiet>=SIL))flush()};
286
300
  // Chunk seqs are per session, so a result is matched by session id too.
287
301
  const sid=Math.random().toString(36).slice(2);
288
302
  const sess={btn:b,field:f,onmsg:m=>{if(m.sid!==sid)return;if(m.type==="text"){pending.set(m.seq,m.text);drain()}else if(m.type==="error"&&m.seq!=null){pending.set(m.seq,"");toast("Dictation failed: "+m.error);drain()}},
289
- stop:()=>{if(stopping)return;stopping=true;b.classList.remove("on");f.classList.remove("dictating");f.style.removeProperty("--lvl");try{tap.port.onmessage=null;src.disconnect();tap.disconnect();stream.getTracks().forEach(t=>t.stop());ctx.close()}catch(e){}active=null;flush();drain()}};
303
+ stop:()=>{if(stopping)return;stopping=true;b.classList.remove("on");f.classList.remove("dictating");f.style.removeProperty("--lvl");b.innerHTML=ico("mic");try{tap.port.onmessage=null;src.disconnect();tap.disconnect();stream.getTracks().forEach(t=>t.stop());ctx.close()}catch(e){}active=null;flush();drain()}};
290
304
  sessions.add(sess);active=sess;starting=false;
291
- b.classList.remove("busy");b.classList.add("on");f.classList.add("dictating");f.focus()};
305
+ b.classList.remove("busy");b.classList.add("on");f.classList.add("dictating");b.replaceChildren(vu);f.focus()};
292
306
  return b}
293
307
  const m=60e3,h=3600e3,d=86400e3;
294
308
  const age=(t,now)=>{const x=now-t;return x<h?Math.round(x/m)+"m":x<d?Math.round(x/h)+"h":Math.round(x/d)+"d"};
@@ -480,19 +494,29 @@ function md(src){
480
494
  endList();return out.join("")}
481
495
 
482
496
  // ---- settings, toast, notifications, keys
497
+ // Rows in config order, grouped as the TUI groups them.
498
+ const groups=rows=>{const m=new Map();for(const r of rows){if(!m.has(r.group))m.set(r.group,[]);m.get(r.group).push(r)}return [...m]};
483
499
  async function openSettings(){
484
500
  stopDictation(true);
485
501
  openKey=null;runDoc=null;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent="Settings";$("ptabs").innerHTML="";$("pfoot").innerHTML="";
486
502
  const cur=document.documentElement.dataset.theme||"system";
487
- const opts=(id,vals,on)=>`<div class="opts" id="${id}">${vals.map(v=>`<button class="${v===on?"on":""}" data-v="${v}" ${v===""?'title="empty"':""}>${v===""?"—":v}</button>`).join("")}</div>`;
488
503
  const cfg=await fetch("/settings").then(r=>r.json()).catch(()=>null);
489
504
  $("psub").textContent=cfg?`theme is this browser's; the rest is ${cfg.file}`:"theme is this browser's";
490
- $("pbody").innerHTML=`<div class="set"><label>Theme</label>${opts("th",["system","light","dark"],cur)}
491
- <label>Notifications</label><div class="opts" id="nt"><button>${typeof Notification==="undefined"?"unsupported":Notification.permission==="granted"?"on":"ask"}</button></div></div>
492
- ${cfg?`<hr style="border:0;border-top:1px solid var(--line);margin:16px 0"><div class="set">${cfg.rows.map(r=>`<label title="${esc(r.key+" — "+r.help)}">${r.label}</label>${opts("s-"+r.key.replace(/\./g,"-"),r.values,r.value)}`).join("")}</div>`:`<p style="color:var(--dim);margin-top:20px">Settings could not be read. Run <code>kaizen settings</code>.</p>`}`;
493
- $("th").querySelectorAll("button").forEach(b=>b.onclick=()=>{const t=b.dataset.v;if(t==="system")delete document.documentElement.dataset.theme;else document.documentElement.dataset.theme=t;try{t==="system"?localStorage.removeItem("kz-theme"):localStorage.setItem("kz-theme",t)}catch(e){}openSettings()});
494
- $("nt").querySelector("button").onclick=()=>{askNotify();setTimeout(openSettings,300)};
495
- if(cfg)cfg.rows.forEach(r=>$("s-"+r.key.replace(/\./g,"-")).querySelectorAll("button").forEach(b=>b.onclick=async()=>{if(b.classList.contains("on"))return;const j=await post("/settings",{key:r.key,value:b.dataset.v});if(j.ok)toast(`saved ${r.key} = ${b.dataset.v||'""'}`);openSettings()}))}
505
+ // One control per row, in the TUI's grammar: [✓] on / [ ] off for a switch, ▮▮▯ bars for a
506
+ // number, the word itself (a select) for a choice. Help shows for the row under the pointer.
507
+ const ctl=(id,vals,on)=>{
508
+ if(vals.length===2&&vals.includes("true")&&vals.includes("false")){const yes=on==="true";return `<button class="chk${yes?" on":""}" id="${id}" data-v="${yes?"false":"true"}" role="switch" aria-checked="${yes}"><i>[${yes?"✓":" "}]</i> ${yes?"on":"off"}</button>`}
509
+ if(vals.every(v=>/^\d+$/.test(v))){const at=vals.indexOf(on);return `<span class="bar" id="${id}">${vals.map((v,i)=>`<button data-v="${v}" class="${i<=at?"on":""}" title="${v}"></button>`).join("")}<b>${esc(on)}</b></span>`}
510
+ return `<select class="sel" id="${id}">${vals.map(v=>`<option value="${esc(v)}"${v===on?" selected":""}>${v===""?"—":esc(v)}</option>`).join("")}</select>`};
511
+ const row=(label,control,hint)=>`<div class="srow"><label>${esc(label)}</label>${control}<p class="hint">${hint}</p></div>`;
512
+ $("pbody").innerHTML=`<h3 class="sg">Browser</h3>${row("Theme",ctl("th",["system","light","dark"],cur),"Light or dark for this browser only; system follows the OS.")}
513
+ ${row("Notifications",`<button class="chk" id="nt"><i>[${typeof Notification!=="undefined"&&Notification.permission==="granted"?"✓":" "}]</i> ${typeof Notification==="undefined"?"unsupported":Notification.permission==="granted"?"on":"ask"}</button>`,"A desktop notification when a run starts waiting on you.")}
514
+ ${cfg?groups(cfg.rows).map(([g,rows])=>`<h3 class="sg">${esc(g)}</h3>${rows.map(r=>row(r.label,ctl("s-"+r.key.replace(/\./g,"-"),r.values,r.value),`<code>${esc(r.key)}</code> options: ${r.values.map(v=>v||'""').map(esc).join(" / ")}<br>${esc(r.help)}`)).join("")}`).join(""):`<p style="color:var(--dim);margin-top:20px">Settings could not be read. Run <code>kaizen settings</code>.</p>`}`;
515
+ const setTheme=t=>{if(t==="system")delete document.documentElement.dataset.theme;else document.documentElement.dataset.theme=t;try{t==="system"?localStorage.removeItem("kz-theme"):localStorage.setItem("kz-theme",t)}catch(e){}};
516
+ $("th").onchange=e=>setTheme(e.target.value);
517
+ $("nt").onclick=()=>{askNotify();setTimeout(openSettings,300)};
518
+ if(cfg)cfg.rows.forEach(r=>{const el=$("s-"+r.key.replace(/\./g,"-"));const save=async v=>{const j=await post("/settings",{key:r.key,value:v});if(j.ok)toast(`saved ${r.key} = ${v||'""'}`);openSettings()};
519
+ if(el.tagName==="SELECT")el.onchange=()=>save(el.value);else el.querySelectorAll("[data-v]").forEach(b=>b.onclick=()=>save(b.dataset.v));if(el.matches(".chk"))el.onclick=()=>save(el.dataset.v)})}
496
520
  let tt;function toast(s){const t=$("toast");t.textContent=s;t.classList.add("on");clearTimeout(tt);tt=setTimeout(()=>t.classList.remove("on"),4000)}
497
521
  function askNotify(){try{if(typeof Notification!=="undefined"&&Notification.permission==="default")Notification.requestPermission()}catch(e){}}
498
522
  function notifyUser(title,body){try{if(typeof Notification!=="undefined"&&Notification.permission==="granted")new Notification(title,{body})}catch(e){}}