symbiot 0.41.7 → 0.43.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/ui.mjs CHANGED
@@ -95,6 +95,10 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
95
95
  .drift .ev{color:var(--faint);font-family:ui-monospace,Menlo,monospace;font-size:11px}
96
96
  .dot-w{width:9px;height:9px;border-radius:50%;background:var(--amber);display:inline-block}
97
97
  .dot-c{width:9px;height:9px;border-radius:50%;background:var(--green);display:inline-block}
98
+ .lgroup{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:8px 0 0}.lgroup .fl{min-width:64px;margin-left:0}
99
+ .lnk{display:inline-flex;align-items:center;gap:4px}.lnk .fchip{display:inline-flex;align-items:center;gap:6px}
100
+ .lnk.ok .fchip{border-color:#2a6b52}.lnk.signin .fchip,.lnk.signedout .fchip,.lnk.error .fchip{border-color:var(--amber)}
101
+ .lnk .lst{font-size:11px;color:var(--faint)}.lnk .lmore,.lnk .lrm{background:none;border:0;color:var(--faint);cursor:pointer;font-size:14px;padding:0 2px}.lnk .lrm:hover{color:var(--amber)}
98
102
  .agent{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
99
103
  .adot{width:10px;height:10px;border-radius:50%;display:inline-block;flex:none}
100
104
  .adot.run{background:var(--amber);animation:pulse 1s ease-in-out infinite}
@@ -140,7 +144,8 @@ label.check input{width:auto}
140
144
  .bcard .bc{font-size:26px;font-weight:700;color:var(--faint);margin-top:6px}.bcard.has .bc{color:var(--green)}
141
145
  .bcard .bl{font-size:12px;color:var(--faint)}
142
146
  .bcard .bi{display:flex;gap:8px;align-items:flex-start;padding:7px 0;border-top:1px solid var(--line);font-size:13px;color:var(--text)}
143
- .bcard .bi .t{flex:1;min-width:0;overflow-wrap:anywhere}.bcard .bi button{padding:4px 9px;font-size:12px}
147
+ .bcard .bi{flex-wrap:wrap}.bcard .bi .t{flex:1 1 150px;min-width:0;overflow-wrap:anywhere}.bcard .bi button{padding:4px 9px;font-size:12px}
148
+ .bcard .tchat{border-top:1px solid var(--line);border-radius:9px;margin-top:8px}
144
149
  .bcard .bbrief{white-space:pre-line;margin:8px 0;padding:8px 10px;border-left:3px solid var(--green);background:var(--ink3);border-radius:0 8px 8px 0;font-size:13px;color:var(--bone)}
145
150
  @media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
146
151
  @media(max-width:600px){#panel-tasks .row>#newtask,#screenname,#pagesite{flex:1 1 100%!important}.tabs{overflow-x:auto;scrollbar-width:none;padding:0 8px;gap:0}.tabs::-webkit-scrollbar{display:none}.tab{flex:none;padding:9px 11px}header{padding:12px 14px 8px}main{padding:12px}}
@@ -181,7 +186,7 @@ label.check input{width:auto}
181
186
  <div id="review" class="review hidden"></div>
182
187
  <div style="margin-top:22px;border-top:1px solid var(--line);padding-top:4px">
183
188
  <div class="tgroup">Screens <span class="tcount">experimental &middot; blueprints for screen automation</span></div>
184
- <div class="note muted" style="margin-top:2px">Capture a screen, then drag a box over each part that matters (a button, a field, a menu) and name it. Each region keeps its pixel coordinates and its centre, ready for automation to aim at. With more than one display, pick which one to capture, or one screen per display. <b>Click here</b> on a region clicks its centre on your real screen, after you confirm. Or let Symbiot do it all for a web page: <b>Map page</b> opens it in a hidden browser, takes its screenshot and marks every button, link and field by itself, and <b>Press</b> on one of them follows it there and maps the next page; <b>Type</b> on a field types into it there. A map only has what fits in the window, so on a longer page <b>Scroll down</b> maps the next part, or <b>Whole page</b> maps all of it in one tall screenshot (a page that scrolls as a whole, not a list inside it like Gmail's). Only a mapped page is typed into: nothing types on your real screen. <b>Watch</b> on a mapped page (your inbox, say) has Symbiot read it again every few minutes while it runs and tell you what's new there.</div>
189
+ <div class="note muted" style="margin-top:2px">Capture a screen, then drag a box over each part that matters (a button, a field, a menu) and name it. Each region keeps its pixel coordinates and its centre, ready for automation to aim at. With more than one display, pick which one to capture, or one screen per display. <b>Click here</b> on a region clicks its centre on your real screen, after you confirm. Or let Symbiot do it all for a web page: <b>Map page</b> opens it in a hidden browser, takes its screenshot and marks every button, link and field by itself, and <b>Press</b> on one of them follows it there and maps the next page; <b>Type</b> on a field types into it there. A map only has what fits in the window, so on a longer page <b>Scroll down</b> maps the next part, or <b>Whole page</b> maps all of it in one tall screenshot (where a list scrolls inside the page, like Gmail's mail, that list opened out: a whole inbox on one screen). Only a mapped page is typed into: nothing types on your real screen. <b>Watch</b> on a mapped page (your inbox, say) has Symbiot read it again every few minutes while it runs and tell you what's new there.</div>
185
190
  <div class="row" style="margin-top:10px"><input id="pagesite" placeholder="a web page to map by itself: gmail, github.com/pulls or a web address" style="flex:1"><button class="act" id="mappage">Map page</button><button class="ghost" id="pagesignin" title="open this site in Symbiot's own browser as a window, to sign in once; close it when you're done">Sign in</button></div>
186
191
  <div class="row" style="margin-top:10px"><input id="screenname" placeholder="name the screen, e.g. GitHub PR page" style="flex:1"><select id="screenwhich" class="hidden" title="which display to capture" style="flex:0 0 auto;width:auto"></select><select id="screendelay" title="wait first, so you can bring the right window to the front" style="flex:0 0 auto;width:auto"><option value="0">now</option><option value="3">in 3s</option><option value="5">in 5s</option><option value="10">in 10s</option></select><button class="ghost" id="capture">Capture screen</button><button class="ghost" id="screenload" title="use a PNG screenshot you already have">Load image</button><input type="file" id="screenfile" accept="image/png" class="hidden"></div>
187
192
  <div id="screenmsg"></div>
@@ -193,6 +198,7 @@ label.check input{width:auto}
193
198
  <section id="panel-board" class="hidden">
194
199
  <div class="row"><span class="muted" id="boardsum" style="flex:1">Everything you watch, side by side.</span><select id="boardhours" title="how far back" style="flex:0 0 auto;width:auto"><option value="24">last 24 hours</option><option value="72">last 3 days</option><option value="168">last 7 days</option></select><button class="ghost" id="boardrefresh">Refresh</button></div>
195
200
  <div id="boardmsg"></div>
201
+ <div id="boardlinks"></div>
196
202
  <div id="board" class="board"></div>
197
203
  </section>
198
204
  <section id="panel-run" class="hidden">
@@ -264,6 +270,18 @@ label.check input{width:auto}
264
270
  </div>
265
271
  </div>
266
272
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
273
+ <label>Link your work</label>
274
+ <div class="note muted" style="margin-top:2px">One click per site you work in: it opens the site in Symbiot's own browser, where you sign in the usual way (its own login, SSO and 2FA; Symbiot never sees your password). Symbiot then trusts the site and watches its inbox or notifications, so what arrives there shows on the Dashboard and in Week and Standup. Close the window when you're signed in.</div>
275
+ <div id="links"></div>
276
+ <div id="linksmsg"></div>
277
+ <div class="note muted" id="linksnote">For a whole team, one <b>links.json</b> in Symbiot's config folder adds your company's own sites and hides the ones you don't use, so everyone gets the same buttons.</div>
278
+ </div>
279
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
280
+ <label>What Symbiot remembers</label>
281
+ <div class="note muted" style="margin-top:2px">Every chat in the app is the same Symbiot. What's worth knowing on another page (who someone is, which account is what, what you decided) is kept here, on this computer only, and each chat gets just the parts its question touches.</div>
282
+ <div id="mindlist"></div>
283
+ </div>
284
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
267
285
  <label>Trusted sites for Screens <span class="muted">(experimental)</span></label>
268
286
  <div id="trustedsites"></div>
269
287
  <div class="row" style="margin-top:6px"><input id="newtrusted" placeholder="a site, e.g. mail.google.com or github.com" style="flex:1"><button class="ghost" id="addtrusted">Trust site</button></div>
@@ -311,7 +329,7 @@ function tabs(){return document.querySelectorAll('.tab');}
311
329
  function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
312
330
  var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isBoard=tab==='board',isRun=(tab==='week'||tab==='standup'||tab==='todo');
313
331
  $('panel-map').classList.toggle('hidden',!isMap);
314
- $('panel-board').classList.toggle('hidden',!isBoard);if(isBoard)loadBoard();
332
+ $('panel-board').classList.toggle('hidden',!isBoard);if(isBoard){loadBoard();loadLinks();}
315
333
  $('panel-run').classList.toggle('hidden',!isRun);
316
334
  $('panel-settings').classList.toggle('hidden',!isSet);
317
335
  $('panel-tasks').classList.toggle('hidden',!isTasks);
@@ -532,11 +550,16 @@ if(!ans.length){msg.innerHTML="<div class='note err'>Pick an option or type an a
532
550
  box.querySelectorAll('button').forEach(function(b){b.disabled=true;});
533
551
  api('/api/agents/answer',{path:a.path,answers:ans,rerun:rerun}).then(function(r){
534
552
  if(!r||r.error){box.querySelectorAll('button').forEach(function(b){b.disabled=false;});msg.innerHTML="<div class='note err'>"+esc((r&&r.error)||'failed')+"</div>";return;}
535
- msg.innerHTML="<div class='note ok'>&#10003; Saved "+r.saved+" answer"+(r.saved>1?"s":"")+" for <b>"+esc(a.name)+"</b> in .symbiot/ANSWERS.md"+(r.rerun?" &middot; your agent is picking them up now.":".")+(r.note?"<div class='muted'>"+esc(r.note)+"</div>":"")+"</div>";loadAgents();});}
553
+ msg.innerHTML="<div class='note ok'>&#10003; Saved "+r.saved+" answer"+(r.saved>1?"s":"")+" for <b>"+esc(a.name)+"</b> in .symbiot/ANSWERS.md"+(r.rerun?" &middot; your agent is picking them up now.":".")+(r.yours&&r.yours.length?"<div class='err'>&#128100; Still yours to do: "+esc(r.yours.join(' '))+"</div>":"")+(r.note?"<div class='muted'>"+esc(r.note)+"</div>":"")+"</div>";loadAgents();});}
536
554
  var s1=box.querySelector('.qsend'),s2=box.querySelector('.qsave');
537
555
  if(s1)s1.addEventListener('click',function(){send(true);});if(s2)s2.addEventListener('click',function(){send(false);});
538
556
  box.querySelectorAll('.qidea').forEach(function(btn){btn.addEventListener('click',function(){var s=a.ask.suggestions[+btn.getAttribute('data-i')];if(!s)return;btn.disabled=true;
539
557
  api('/api/tasks/add',{text:s.text,repo:s.repo||a.name}).then(function(){s.added=true;btn.outerHTML="<span class='tag'>in Tasks</span>";});});});});}
558
+ // A step of yours an answer picked (👤 You): the next run waits for it (agents.mjs waitingFor)
559
+ function waitHtml(a){var w=a.waiting;if(!w)return '';var fs=(w.files||[]).map(function(f){return "<code>"+esc(f)+"</code>";}).join(" or ");
560
+ return "<div class='aq'><h4>&#9208; Waiting on your step</h4><div class='qt'>&#128100; "+esc(w.step)+"</div><div class='qc'>"+(fs?"Your agent "+(w.rerun?"starts by itself":"can start")+" once "+fs+" changes"+(w.rerun?", while Symbiot runs":"")+". Done it some other way?":"Your agent waits for it, so it doesn't stop on the same questions again.")+" Start it now once it's done.</div><div class='row'><button class='act wstart' data-id='"+esc(a.id)+"'>Start it now</button></div></div>";}
561
+ function wireWaits(el){el.querySelectorAll('.wstart').forEach(function(btn){btn.addEventListener('click',function(){var a=agentById(btn.getAttribute('data-id'));if(!a)return;btn.disabled=true;
562
+ api('/api/open',{path:a.path,force:true}).then(function(x){var msg=document.getElementById('agentsmsg');msg.innerHTML=x&&x.opened?"<div class='note ok'>&#10003; Started your agent in <b>"+esc(a.name)+"</b>.</div>":"<div class='note err'>"+esc(a.name)+": "+(x&&x.busy?"an agent is already running there.":"it didn&#39;t start. Check the command in Settings.")+"</div>";loadAgents();}).catch(function(e){btn.disabled=false;document.getElementById('agentsmsg').innerHTML="<div class='note err'>"+esc(String((e&&e.message)||e))+"</div>";});});});}
540
563
  function answering(){var f=document.activeElement;return !!(f&&f.closest&&f.closest('.aq'));}
541
564
  function loadAgents(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
542
565
  if(!list||!list.length){AGENTLIST=[];el.innerHTML="<div class='muted' style='margin-top:12px'>No agents yet. In <b>Tasks</b>, tick ideas and hit <b>Send to repos</b> (with an agent command set in Settings) &mdash; you'll watch it work here.</div>";stopAgentsPoll();return;}
@@ -546,6 +569,8 @@ saveDrafts(el);AGENTLIST=list;
546
569
  el.innerHTML=list.map(function(a){var cls=a.status==='running'?'run':(a.status==='done'?'ok':'fail');
547
570
  var st=a.status==='running'?('working &middot; '+fmtE(a.elapsed)):(esc(a.status)+' &middot; '+fmtE(a.elapsed)+(a.exitCode!=null?' &middot; exit '+a.exitCode:''));
548
571
  if(a.fromHeld)st+=" &middot; started on the tasks held for the last run";
572
+ if(a.earlier)st+=" &middot; "+(a.status==='running'?"started outside this window":"ran before Symbiot last started");
573
+ if(a.waiting)st+=" &middot; <span style='color:var(--amber)'>waiting on your step</span>";
549
574
  if(nQs(a))st+=" &middot; <span style='color:var(--amber)'>needs your answers</span>";
550
575
  if(a.held!=null){var hn=a.held.length,ht=a.held.map(function(t){return "&bull; "+esc(t).replace(/'/g,'&#39;');}).join('&#10;');
551
576
  ht+=(hn?'&#10;&#10;':'')+(a.status==='running'?"Sent while this agent was running. They wait in .symbiot/TASKS.next.md, replace TASKS.md when it finishes (keeping its ticks), and an agent starts on them then.":"They wait in .symbiot/TASKS.next.md for the agent running in this folder to finish. After that, an agent starts on them the next time the Tasks tab checks this repo, or send again.");
@@ -562,11 +587,11 @@ if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
562
587
  }else if(a.status==='done'){
563
588
  b+="<div class='changed muted'>No file changes detected (the agent may have only planned or asked).</div>";
564
589
  }
565
- b+=askHtml(a);
590
+ b+=waitHtml(a)+askHtml(a);
566
591
  b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
567
592
  return "<div class='agent'>"+b+"</div>";}).join("");
568
593
  el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
569
- restoreDrafts(el);wireAsks(el);
594
+ restoreDrafts(el);wireAsks(el);wireWaits(el);
570
595
  stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=setTimeout(loadAgents,2000);});}
571
596
  function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
572
597
  var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
@@ -651,6 +676,38 @@ box.querySelectorAll('.punpair').forEach(function(b){b.addEventListener('click',
651
676
  var pp=$('ppair');if(pp)pp.addEventListener('click',function(){var a=$('paddr').value,cd=$('pcode').value;pp.disabled=true;pp.textContent='Pairing...';api('/api/phone/pair',{address:a,code:cd}).then(function(x){renderPhoneLink(x,x&&x.error?{text:x.error}:{ok:true,text:'Paired. What Watch finds on '+x.name+' shows up here as a notification.'});if(x&&x.error&&$('paddr')){$('paddr').value=a;$('pcode').value=cd;}});});
652
677
  var pc=$('pcheck');if(pc)pc.addEventListener('click',function(){pc.disabled=true;api('/api/phone/check',{}).then(function(x){renderPhoneLink(x,x&&!x.error?{ok:true,text:x.shown?'Showed '+x.shown+' notification(s).':'Asked: nothing new.'}:null);});});
653
678
  var pf=$('pforget');if(pf)pf.addEventListener('click',function(){if(typeof confirm==='function'&&!confirm('Forget your computer? Its notifications stop.'))return;api('/api/phone/forget',{}).then(function(x){renderPhoneLink(x);});});}
679
+ // Link your work (links.mjs): one button per standard site. A click opens it in
680
+ // Symbiot's browser to sign in, trusts it and watches it. Its dot: grey not
681
+ // linked, amber waiting for you to sign in (or signed out), green linked. Shown
682
+ // in Settings, and on the Dashboard until something is linked.
683
+ var LINKS=null;
684
+ var LINKWORD={ok:'linked',signin:'sign in, then close the window',signedout:'signed out',error:'needs a fix'};
685
+ function escQ(t){return esc(t).replace(/'/g,'&#39;');}
686
+ function linksHtml(d,board){var h=board?"<div class='note muted'>Link your work to see what arrives there here, and in Week and Standup:</div>":"";
687
+ if(d.error)h+="<div class='note err'>"+esc(d.error)+"</div>";
688
+ d.groups.forEach(function(g){h+="<div class='lgroup'><span class='fl'>"+esc(g)+"</span>";
689
+ d.items.filter(function(x){return x.group===g;}).forEach(function(it){var on=it.state!=='off';
690
+ h+="<span class='lnk "+escQ(it.state)+"' data-id='"+escQ(it.id)+"'><button class='fchip lbtn' title='"+escQ(it.note||(on?'open '+it.url+' to sign in again':'sign in at '+it.url+' and link it'))+"'><span class='dot-c' style='background:"+(it.state==='ok'?'var(--green)':on?'var(--amber)':'var(--line)')+"'></span>"+esc(it.name)+"</button>"+
691
+ (on?"<span class='lst'>"+esc(LINKWORD[it.state]||'')+"</span><button class='lmore' title='check it now'>&#8635;</button><button class='lrm' title='unlink: stop watching it and stop trusting it'>&times;</button>":"")+"</span>";});
692
+ h+="</div>";});return h;}
693
+ function linksOut(html){var o=document.getElementById('linksmsg');if(o)o.innerHTML=html;var b=document.getElementById('boardmsg');if(b&&LINKS&&!LINKS.linked)b.innerHTML=html;}
694
+ function renderLinks(){if(!LINKS)return;['links','boardlinks'].forEach(function(id){var el=document.getElementById(id);if(!el)return;
695
+ if(id==='boardlinks'&&LINKS.linked){el.innerHTML='';return;}
696
+ el.innerHTML=linksHtml(LINKS,id==='boardlinks');
697
+ el.querySelectorAll('.lnk').forEach(function(sp){var lid=sp.getAttribute('data-id'),name=sp.querySelector('.lbtn').textContent;
698
+ sp.querySelector('.lbtn').addEventListener('click',function(){linksOut("<div class='note muted'>Opening "+esc(name)+"&hellip;</div>");
699
+ api('/api/links/link',{id:lid}).then(function(r){if(!r||r.error){linksOut("<div class='note err'>"+esc(name)+": "+esc((r&&r.error)||'failed')+"</div>");loadLinks();return;}
700
+ linksOut("<div class='note ok'>Opened "+esc(name)+" in Symbiot's browser. Sign in there as you normally do, then close that window: Symbiot checks it within a minute.</div>");loadLinks();
701
+ [20000,60000,120000].forEach(function(ms){setTimeout(loadLinks,ms);});});});
702
+ var m=sp.querySelector('.lmore');if(m)m.addEventListener('click',function(){m.disabled=true;api('/api/links/check',{id:lid}).then(function(r){m.disabled=false;
703
+ if(r&&r.busy)linksOut("<div class='note muted'>Symbiot's browser is busy (a sign-in window is open?). Close it, then check again.</div>");else if(r&&r.error)linksOut("<div class='note err'>"+esc(r.error)+"</div>");loadLinks();loadBoard();});});
704
+ var x=sp.querySelector('.lrm');if(x)x.addEventListener('click',function(){api('/api/links/unlink',{id:lid}).then(function(){linksOut('');loadLinks();loadTrusted();loadBoard();});});});});}
705
+ // What Symbiot remembers (mind.mjs): a row per thing, with what it knows; × forgets one.
706
+ function loadMind(){api('/api/mind').then(function(d){var el=document.getElementById('mindlist');if(!el||!d)return;var ns=d.nodes||[];
707
+ el.innerHTML=ns.length?ns.slice(0,60).map(function(n){return "<div class='task' data-id='"+escQ(n.id)+"'><span class='t'><b>"+esc(n.name)+"</b> <span class='muted' style='font-size:11px'>"+esc(n.kind)+"</span><br><span class='muted' style='font-size:12px'>"+esc(n.facts.join(' · '))+"</span></span><button class='rm forgetone' title='forget this'>&times;</button></div>";}).join('')+"<div class='row' style='margin-top:8px'><button class='ghost' id='forgetall'>Forget everything</button></div>":"<div class='muted' style='font-size:12px;margin-top:6px'>Nothing yet. It fills in as you talk to Symbiot on the Dashboard and in Tasks.</div>";
708
+ el.querySelectorAll('.forgetone').forEach(function(b){b.addEventListener('click',function(){api('/api/mind/forget',{id:b.closest('.task').getAttribute('data-id')}).then(loadMind);});});
709
+ var fa=document.getElementById('forgetall');if(fa)fa.addEventListener('click',function(){api('/api/mind/forget',{id:'all'}).then(loadMind);});});}
710
+ function loadLinks(){api('/api/links').then(function(d){if(d&&d.items){LINKS=d;renderLinks();}});}
654
711
  // Trusted sites: where Screens' Press and Type don't ask first (headless.mjs).
655
712
  function loadTrusted(){api('/api/screens/trusted').then(function(d){var box=document.getElementById('trustedsites');var sites=(d&&d.sites)||[];
656
713
  box.innerHTML=sites.length?sites.map(function(h){return "<div class='task' data-h='"+esc(h)+"'><span class='t' style='font-family:ui-monospace,monospace;font-size:12px'>"+esc(h)+"</span><button class='rm rmtrusted' title='stop trusting it: press and type ask first again'>&times;</button></div>";}).join(""):"<div class='muted' style='font-size:12px'>None yet: Press and Type ask first on every site.</div>";
@@ -724,7 +781,7 @@ function scrBox(r,s,cls){return "<div class='scrbox"+(cls?" "+cls:"")+"' style='
724
781
  function renderScreen(){var v=$('screenview'),s=SCREEN;if(!s){v.innerHTML='';return;}
725
782
  var h="<div class='row' style='margin-top:10px'><input id='scrname' title='rename this screen' style='flex:1'><span class='muted'>"+(s.monitor?esc(s.monitor.name)+(s.monitor.where?" ("+esc(s.monitor.where)+")":"")+" &middot; ":"")+s.w+" &times; "+s.h+" px</span>"+(s.page?"<button class='ghost' id='scrremap' title='open this page in the hidden browser again and map it as it is now'>Map again</button>"+(watchOf(s)?"<button class='ghost' id='scrwatch' title='Symbiot reads this page every few minutes and tells you what&#39;s new: click to stop'>Watching &#10003;</button>":"<button class='ghost' id='scrwatch' title='read this page again every few minutes while Symbiot runs, and tell me what&#39;s new on it'>Watch</button>"):"")+(!s.monitor&&!s.page&&MONITORS.length>1?"<button class='ghost' id='scrsplit' title='cut this screenshot into one screen per display, regions included (this one stays)'>Split by display</button>":"")+"<button class='ghost' id='scrcopy' title='copy the regions and their coordinates as JSON'>Copy blueprint</button><button class='ghost' id='scrdel'>Delete</button></div>";
726
783
  // a page taller than its window: where this screen is on it, and Scroll to map the rest
727
- var sc=s.page&&s.page.scroll,up=sc&&sc.y>0,down=sc&&sc.y<sc.max,whole=sc&&!sc.selector; // whole: the page itself scrolls (not a list inside it, like Gmail's)
784
+ var sc=s.page&&s.page.scroll,up=sc&&sc.y>0,down=sc&&sc.y<sc.max,whole=!!sc; // whole: the page, or a list inside it (Gmail's) opened out
728
785
  if(s.page)h+="<div class='row' style='margin-top:6px'><span class='muted' style='font-size:12px;flex:1;overflow-wrap:anywhere'>Mapped in the hidden browser: "+esc(s.page.url)+(sc?" &middot; <b>"+(up&&down?"more above and below":down?"more below":"the end of the page")+"</b>: a map only has what fits in the window, so scroll to map the rest"+(whole?", or map the whole page at once":""):s.page.full?" &middot; <b>the whole page</b>, in one tall screenshot":"")+"</span>"+(up?"<button class='ghost scrscroll' data-to='up' title='scroll up in the hidden browser and map what&#39;s there as a new screen'>&uarr; Scroll up</button>":"")+(down?"<button class='ghost scrscroll' data-to='down' title='scroll down in the hidden browser and map what&#39;s there as a new screen'>Scroll down &darr;</button>":"")+(whole?"<button class='ghost scrwhole' title='map all of this page in one tall screenshot, every button, link and field on it, as a new screen'>Whole page &varr;</button>":"")+"</div>";
729
786
  h+="<div class='scrwrap' id='scrwrap'><img src='/api/screens/image?id="+encodeURIComponent(s.id)+"&t="+encodeURIComponent(T)+"' alt='' draggable='false'>"+(s.regions||[]).map(function(r){return scrBox(r,s,'');}).join('')+(SPEND?scrBox(SPEND,s,'draw'):"")+"<div class='scrbox draw hidden' id='scrdraw'></div></div>";
730
787
  h+="<div class='mapbar' id='scrbar'>Drag on the screenshot to mark a region &middot; coordinates are screenshot pixels</div>";
@@ -783,7 +840,7 @@ if(!gh)h+="<div class='row' style='margin-top:8px'><span class='muted' style='fo
783
840
  if(ws.length){h+="<label class='check' style='margin-top:8px'><input type='checkbox' id='wbrief'"+(WATCH.brief?" checked":"")+"> <span>Brief me: the AI connected in Settings says what needs me and what can wait, here and in the notification. <span class='muted'>It's sent what's new: for mail, the sender, subject and preview. With a local Ollama model nothing leaves this computer.</span></span></label>";
784
841
  var b0=bs[0]&&ns.some(function(n){return n.ts===bs[0].ts&&n.watch===bs[0].watch;})?bs[0]:null;
785
842
  if(b0)h+="<div class='note' style='white-space:pre-line;margin-top:6px'><b>Brief</b> <span class='muted' style='font-size:12px'>"+esc(b0.name)+" &middot; "+b0.count+" new &middot; "+agoTxt(b0.ts)+"</span><br>"+esc(b0.text)+"</div>";
786
- h+=ns.length?"<div class='row' style='margin-top:8px'><span class='fl' style='margin-left:0;flex:1'>New &middot; "+ns.length+"</span><button class='ghost' id='wclear' title='clear this list (Symbiot still remembers what it has seen)'>Clear</button></div>"+ns.slice(0,20).map(function(n,i){return "<div class='task' data-i='"+i+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:12px'>"+esc(n.name)+" &middot; "+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost wopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost wdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent opens it in your inbox through Screens, writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted &middot; again":"Draft a reply")+"</button>":"")+"</div>";}).join('')
843
+ h+=ns.length?"<div class='row' style='margin-top:8px'><span class='fl' style='margin-left:0;flex:1'>New &middot; "+ns.length+"</span><button class='ghost' id='wclear' title='clear this list (Symbiot still remembers what it has seen)'>Clear</button></div>"+ns.slice(0,20).map(function(n,i){return "<div class='task' data-i='"+i+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:12px'>"+esc(n.name)+" &middot; "+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost wopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost wdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent opens it in your inbox through Screens, writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted &middot; again":"Draft a reply")+"</button>":"")+(n.chat&&n.drafted?"<button class='ghost wopenwa' title='"+OPEN_WA_TIP+"'>Open in WhatsApp</button>":"")+"</div>";}).join('')
787
844
  :"<div class='muted' style='font-size:12px;margin-top:6px'>Nothing new yet. New rows show up here, and as a notification. Standup counts what's waiting on you, and your agent reads them with <b>symbiot watch new</b>.</div>";}
788
845
  box.innerHTML=h;
789
846
  var wg=$('wgithub');if(wg)wg.addEventListener('click',function(){wg.disabled=true;api('/api/watch/add',{site:'github',every:5}).then(function(x){wg.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
@@ -799,16 +856,26 @@ box.querySelectorAll('.wopen').forEach(function(b){b.addEventListener('click',fu
799
856
  box.querySelectorAll('.wdraft').forEach(function(b){b.addEventListener('click',function(){var n=ns[+b.closest('.task').getAttribute('data-i')];b.disabled=true;
800
857
  api('/api/watch/draft',{id:n.id}).then(function(x){b.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
801
858
  $('screenmsg').innerHTML="<div class='note ok'>"+(x.chat?DRAFTING_CHAT:DRAFTING)+"</div>";loadWatchUI();}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});});});
859
+ box.querySelectorAll('.wopenwa').forEach(function(b){b.addEventListener('click',function(){openChatUI(ns[+b.closest('.task').getAttribute('data-i')],b,function(cls,html){$('screenmsg').innerHTML="<div class='note "+cls+"'>"+html+"</div>";});});});
802
860
  var wc=$('wclear');if(wc)wc.addEventListener('click',function(){api('/api/watch/clear',{}).then(loadWatchUI);});}
803
861
  // what Check now found, for Watching and the Dashboard
804
862
  function watchCheckMsg(w,x){return x.busy?"The hidden browser is busy with a map, press or type. It reads "+esc(w.name)+" once that's done.":x.learned!=null?"Read "+esc(x.name)+": learned the "+x.learned+" things listed there now. From here on, anything new is noted.":x["new"]&&x["new"].length?"&#10003; "+x["new"].length+" new on "+esc(x.name)+".":"Read "+esc(x.name)+": nothing new.";}
805
863
  // a chat (WhatsApp): the reply is typed into the chat's message box in Symbiot's browser, unsent
806
864
  var DRAFT_CHAT_TIP="your coding agent opens the chat in Symbiot&#39;s hidden browser and types a reply into its message box, unsent. It never presses Send or Enter";
807
- var DRAFTING_CHAT="&#10003; Your agent is drafting a reply in that chat. It opens WhatsApp in Symbiot's hidden browser, types the reply into the chat's message box and leaves it there unsent: it never presses Send or Enter. Once it's done (the Agents tab), type web.whatsapp.com under Screens and click <b>Sign in</b>: the chat shows the reply in its box, for you to read and send.";
865
+ var DRAFTING_CHAT="&#10003; Your agent is drafting a reply in that chat. It opens WhatsApp in Symbiot's hidden browser, types the reply into the chat's message box and leaves it there unsent: it never presses Send or Enter. Once it's done (the Agents tab), click <b>Open in WhatsApp</b> on the message: the chat shows the reply in its box, for you to read and send.";
866
+ // Open in WhatsApp (watch.mjs openChat): Symbiot's browser, as a window, at web.whatsapp.com
867
+ var OPEN_WA_TIP="open WhatsApp in Symbiot&#39;s browser, where the chat shows your agent&#39;s reply in its message box, for you to read and send";
868
+ function openChatUI(n,b,say){if(!n)return;b.disabled=true;
869
+ api('/api/watch/open-chat',{id:n.id}).then(function(x){b.disabled=false;if(!x||x.error){say('err',esc((x&&x.error)||'failed'));return;}
870
+ say('ok',"&#10003; Opened WhatsApp in Symbiot's browser, in a window of its own. The chat shows your agent's reply in its message box: read it, change it if you like, and send it there. Close the window when you're done, so Watch can read WhatsApp again.");}).catch(function(e){b.disabled=false;say('err',esc(String((e&&e.message)||e)));});}
808
871
  var DRAFTING="&#10003; Your agent is drafting a reply to it. It opens the email in your inbox through Screens, writes the reply and leaves it in Drafts for you to read and send: it never presses Send. Follow it, and answer anything it asks, in the Agents tab.";
809
872
  // ---- Dashboard: a card per page you watch (your inbox, GitHub, WhatsApp…), what's new on each ----
810
873
  var BOARD=null;var BOARDICON={mail:'&#9993;&#65039;',github:'&#128276;',chat:'&#128172;',page:'&#127760;'};
811
- function loadBoard(){api('/api/watch/board?hours='+(+$('boardhours').value||24)).then(function(b){if(b&&b.cards){BOARD=b;renderBoard();}});}
874
+ // Talk it over (watch.mjs boardChat): a chat on a card, open by its id, with what's half typed kept across a refresh
875
+ var BTALK={},BTALKDRAFT={},BTALKBUSY=0;
876
+ var TALKHINT="<div class='muted' style='font-size:12px'>Go over what's new here with your AI: what needs you, and what to say to whom. Then click <b>Draft a reply</b> on one, and your agent writes it the way you agreed here. Your AI is sent what this card lists (for mail: the sender, subject and preview).</div>";
877
+ function talking(){var f=document.activeElement;return BTALKBUSY>0||!!(f&&f.closest&&f.closest('.btalk'));}
878
+ function loadBoard(){api('/api/watch/board?hours='+(+$('boardhours').value||24)).then(function(b){if(b&&b.cards){BOARD=b;if(!talking())renderBoard();}});}
812
879
  function boardMsg(cls,html){$('boardmsg').innerHTML="<div class='note "+cls+"'>"+html+"</div>";}
813
880
  function renderBoard(){var b=BOARD,cs=b.cards,el=$('board');
814
881
  var tab=$('boardtab');if(tab)tab.textContent='Dashboard'+(b.total?' · '+b.total:'');
@@ -818,14 +885,29 @@ $('boardgo').addEventListener('click',function(){setTab('map');var w=$('watchbox
818
885
  var when=b.hours===24?'since yesterday':b.hours===72?'in the last 3 days':'in the last 7 days';
819
886
  var waiting=cs.filter(function(c){return c.count;}).map(function(c){return c.label+(c.source==='page'?' on '+c.name:'');});
820
887
  $('boardsum').innerHTML=waiting.length?"<b style='color:var(--bone)'>Waiting on you "+when+":</b> "+esc(waiting.join(', ')):"Nothing new "+when+" on the "+cs.length+" thing"+(cs.length===1?"":"s")+" you watch.";
821
- el.innerHTML=cs.map(function(c,i){
822
- return "<div class='bcard"+(c.count?" has":"")+"' data-i='"+i+"'><div class='bh'><span>"+(BOARDICON[c.source]||BOARDICON.page)+"</span><span class='bn' title='"+esc(c.url)+"'>"+esc(c.name)+"</span>"+(c.count?"<button class='ghost bseen' title='set this card back to 0: only what comes in after counts. It stays under Watching, and the other cards keep theirs' style='padding:4px 9px;font-size:12px'>Seen</button>":"")+"<button class='ghost bcheck' title='read it now' style='padding:4px 9px;font-size:12px'>Check now</button></div>"
888
+ el.innerHTML=cs.map(function(c,i){var nt=Math.ceil(((c.chat||[]).length)/2);
889
+ return "<div class='bcard"+(c.count?" has":"")+"' data-i='"+i+"'><div class='bh'><span>"+(BOARDICON[c.source]||BOARDICON.page)+"</span><span class='bn' title='"+esc(c.url)+"'>"+esc(c.name)+"</span>"+(c.count?"<button class='ghost bseen' title='set this card back to 0: only what comes in after counts. It stays under Watching, and the other cards keep theirs' style='padding:4px 9px;font-size:12px'>Seen</button>":"")+"<button class='ghost btalkbtn' title='talk what&#39;s new here over with your AI before you draft a reply' style='padding:4px 9px;font-size:12px'>&#128172;"+(nt?" "+nt:"")+"</button><button class='ghost bcheck' title='read it now' style='padding:4px 9px;font-size:12px'>Check now</button></div>"
823
890
  +"<div class='bc'>"+c.count+"</div><div class='bl'>"+esc(c.count?c.label.replace(/^\\d+ /,''):'nothing new')+" &middot; "+(c.checked?"read "+agoTxt(c.checked):c.last?"tried "+agoTxt(c.last):"first read within a minute")+(c.via==='gh'?" &middot; through gh":"")+"</div>"
824
891
  +(c.error?"<div class='note err'>"+esc(c.error)+"</div>":"")
825
892
  +(c.brief?"<div class='bbrief'>"+esc(c.brief.text)+"</div>":"")
826
- +c.items.map(function(n,j){return "<div class='bi' data-j='"+j+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:11px'>"+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost bopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost bdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted &middot; again":"Draft a reply")+"</button>":"")+"</div>";}).join('')
827
- +(c.count>c.items.length?"<div class='bl' style='margin-top:6px'>&hellip;and "+(c.count-c.items.length)+" more</div>":"")+"</div>";}).join('');
893
+ +c.items.map(function(n,j){return "<div class='bi' data-j='"+j+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:11px'>"+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost bopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost bdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted &middot; again":"Draft a reply")+"</button>":"")+(n.chat&&n.drafted?"<button class='ghost bopenwa' title='"+OPEN_WA_TIP+"'>Open in WhatsApp</button>":"")+"</div>";}).join('')
894
+ +(c.count>c.items.length?"<div class='bl' style='margin-top:6px'>&hellip;and "+(c.count-c.items.length)+" more</div>":"")
895
+ +(BTALK[c.id]?"<div class='tchat btalk'><div class='msgs'>"+(nt?chatMsgs(c.chat):TALKHINT)+"</div><div class='row'><input class='talkq' placeholder='Ask about these, or say how to answer one...' style='flex:1'><button class='ghost talksend'>Ask</button><a class='talkclear' title='forget this conversation'>clear</a></div></div>":"")+"</div>";}).join('');
828
896
  function card(btn){return cs[+btn.closest('.bcard').getAttribute('data-i')];}
897
+ el.querySelectorAll('.btalkbtn').forEach(function(btn){btn.addEventListener('click',function(){var c=card(btn);BTALK[c.id]=!BTALK[c.id];renderBoard();var q=BTALK[c.id]&&el.querySelector(".bcard[data-i='"+cs.indexOf(c)+"'] .talkq");if(q&&q.focus)q.focus();});});
898
+ el.querySelectorAll('.btalk').forEach(function(box){var c=card(box),inp=box.querySelector('.talkq'),send=box.querySelector('.talksend'),msgs=box.querySelector('.msgs');
899
+ inp.value=BTALKDRAFT[c.id]||'';inp.addEventListener('input',function(){BTALKDRAFT[c.id]=inp.value;});
900
+ function ask(){var q=(inp.value||'').trim();if(!q||send.disabled)return;send.disabled=true;inp.value='';BTALKDRAFT[c.id]='';BTALKBUSY++;
901
+ if(!(c.chat&&c.chat.length))msgs.innerHTML='';
902
+ msgs.insertAdjacentHTML('beforeend',chatMsgs([{role:'user',text:q}])+"<div class='msg a muted thinking'>Thinking&hellip;</div>");
903
+ function done(){BTALKBUSY=Math.max(0,BTALKBUSY-1);send.disabled=false;var th=msgs.querySelector('.thinking');if(th)th.remove();}
904
+ api('/api/watch/chat',{id:c.id,question:q}).then(function(r){done();
905
+ if(r.error==='not-connected'){msgs.insertAdjacentHTML('beforeend',"<div class='msg a'>Connect a model in Settings to talk it over - Ollama is free and runs locally.</div>");return;}
906
+ if(r.error){msgs.insertAdjacentHTML('beforeend',"<div class='msg a err'>"+esc(r.error)+"</div>");return;}
907
+ c.chat=r.chat||[];msgs.innerHTML=chatMsgs(c.chat);var tb=box.closest('.bcard').querySelector('.btalkbtn');if(tb)tb.innerHTML='&#128172; '+Math.ceil(c.chat.length/2);}).catch(function(e){done();msgs.insertAdjacentHTML('beforeend',"<div class='msg a err'>"+esc(String((e&&e.message)||e))+"</div>");});}
908
+ send.addEventListener('click',ask);inp.addEventListener('keydown',function(e){if(e.key==='Enter')ask();});
909
+ box.querySelector('.talkclear').addEventListener('click',function(){api('/api/watch/chat/clear',{id:c.id}).then(function(){c.chat=[];msgs.innerHTML=TALKHINT;var tb=box.closest('.bcard').querySelector('.btalkbtn');if(tb)tb.innerHTML='&#128172;';});});});
910
+ el.querySelectorAll('.bopenwa').forEach(function(btn){btn.addEventListener('click',function(){openChatUI(item(btn),btn,boardMsg);});});
829
911
  function item(btn){return card(btn).items[+btn.closest('.bi').getAttribute('data-j')];}
830
912
  el.querySelectorAll('.bcheck').forEach(function(btn){btn.addEventListener('click',function(){var c=card(btn);btn.disabled=true;btn.textContent='Reading…';
831
913
  api('/api/watch/check',{id:c.id}).then(function(x){if(!x||x.error)boardMsg('err',esc((x&&x.error)||'failed'));else boardMsg(x["new"]&&x["new"].length?'ok':'muted',watchCheckMsg(c,x));loadWatchUI();}).catch(function(e){btn.disabled=false;btn.textContent='Check now';boardMsg('err',esc(String((e&&e.message)||e)));});});});
@@ -982,5 +1064,5 @@ function appBar(p){var b=$('appbar');if(!b||!p.termux||window.SymbiotAndroid||ty
982
1064
  function doUpdate(){updBusy=true;try{localStorage.setItem('symbiot_update_tried',document.getElementById('ver').textContent.replace(/^v/,''));}catch(e){}var b=ubar();b.className='updatebar show';b.textContent='Updating & restarting… this page will reload itself when it is back.';api('/api/update',{});}
983
1065
  setInterval(heartbeat,4000);heartbeat(true);
984
1066
  window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
985
- initGraphEvents();syncP();refresh();loadMap();firstTab();loadAgentCfg();loadScanRoots();loadPhone();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);loadPhoneLink();
1067
+ initGraphEvents();syncP();refresh();loadMap();firstTab();loadAgentCfg();loadScanRoots();loadPhone();loadLinks();loadMind();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);loadPhoneLink();
986
1068
  </script></body></html>`;
package/watch.mjs CHANGED
@@ -11,12 +11,13 @@
11
11
  // when it's signed in, not the page: see readGitHub.
12
12
  //
13
13
  // Stored in ~/.config/symbiot/watch.json, readable by you only:
14
- // { watches: [{ id, name, url, every (minutes), added, last, checked?, error?, via?, cleared?, seen: [key…] }],
14
+ // { watches: [{ id, name, url, every (minutes), added, last, checked?, error?, via?, cleared?, chat?, seen: [key…] }],
15
15
  // news: [{ id, watch, name, ts, text, href? }],
16
16
  // briefs: [{ id, watch, name, ts, count, text }] }
17
17
  // `last` is when it was last read, `checked` when a read last worked; `seen`
18
18
  // holds what's been listed, as itemKey()s; `cleared` is when you clicked Seen on
19
- // its Dashboard card (what's new counts from then). A brief is your AI's read of one
19
+ // its Dashboard card (what's new counts from then); `chat` is your talk with your AI
20
+ // about that card ([{ role, text, ts }]: boardChat). A brief is your AI's read of one
20
21
  // batch of news (config.watchBrief switches it on), with the same `ts`.
21
22
  import { join } from "node:path";
22
23
  import { fileURLToPath } from "node:url";
@@ -24,15 +25,19 @@ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
24
25
  import { execFile } from "node:child_process";
25
26
  import { randomBytes } from "node:crypto";
26
27
  import { VERSION, CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
27
- import { readPage, siteUrl, isTrusted } from "./headless.mjs";
28
+ import { readPage, siteUrl, isTrusted, signIn } from "./headless.mjs";
28
29
  import { loadScreens } from "./screens.mjs";
29
30
  import { desktopNotify } from "./desktop.mjs";
30
31
  import { resolveProvider, write } from "./ai.mjs";
31
32
  import { handoffCmd, runHandoff, runningHandoff } from "./agents.mjs";
33
+ import { linksIn, peekLinks, peekLine } from "./peek.mjs";
34
+ import { converse, actNow, addToTasks } from "./mind.mjs";
32
35
 
33
36
  const WATCH_FILE = join(CONFIG_DIR, "watch.json");
34
37
  const EVERY = [5, 15, 30, 60]; // minutes between reads
35
- const MAX_WATCHES = 12, MAX_SEEN = 1000, MAX_NEWS = 200, MAX_PER_READ = 25, MAX_BRIEFS = 50;
38
+ // 40 watches: every standard Link (links.mjs) plus a company's own and yours; 1000 news: a
39
+ // busy inbox can't push the rest of the week out before Week is written
40
+ const MAX_WATCHES = 40, MAX_SEEN = 1000, MAX_NEWS = 1000, MAX_PER_READ = 25, MAX_BRIEFS = 50;
36
41
 
37
42
  function loadWatch() {
38
43
  const list = (d, k) => (Array.isArray(d[k]) ? d[k] : []);
@@ -229,10 +234,63 @@ function watchBoard(hours = 24, now = Date.now()) {
229
234
  const recent = d.news.filter((n) => n.watch === w.id && n.ts >= since && unseen(n, w)), k = kindOf(w.url), count = recent.length;
230
235
  const b = d.briefs.find((x) => x.watch === w.id && x.ts >= since && unseen(x, w));
231
236
  return { ...view(w), source: sourceOf(w.url), count, label: k ? `${count} ${k[count === 1 ? 0 : 1]}` : `${count} new`,
232
- items: markNews(recent.slice(0, 8), [w]), ...(b ? { brief: { text: b.text, ts: b.ts, count: b.count } } : {}) };
237
+ items: markNews(recent.slice(0, 8), [w]), ...(b ? { brief: { text: b.text, ts: b.ts, count: b.count } } : {}), ...(w.chat && w.chat.length ? { chat: w.chat } : {}) };
233
238
  });
234
239
  return { hours, total: cards.reduce((s, c) => s + c.count, 0), cards, brief: briefOn() };
235
240
  }
241
+ // The board as one line, for a status bar with no jq: "2 emails · 1 WhatsApp
242
+ // message", the cards with something new; "" when none has.
243
+ function boardLine(b) { return b.cards.filter((c) => c.count).map((c) => (c.source === "page" ? `${c.count} new on ${c.name}` : c.label)).join(" · "); }
244
+
245
+ // ---- talk it over: a chat on each Dashboard card ----------------------------------
246
+ // You and your AI go over what's new on one card (its brief and the newest
247
+ // items) before anything's drafted: what matters, what to say to whom. The talk
248
+ // is kept on the watch, and a reply drafted from that card brings it along
249
+ // (talkOf), so the agent writes what you agreed. Grounded in what the card
250
+ // lists, like the brief: for mail, the sender, subject and Gmail's preview.
251
+ const TALK_KEEP = 40; // messages kept per card
252
+ // Asked about the links on the card ("go look at the links"), or pasted one: the
253
+ // links are looked up (peek.mjs: like a link preview, not signed in, nothing run
254
+ // or downloaded) and what's behind them goes to the AI, so it can say what each is.
255
+ const LINK_ASK = /\b(links?|urls?|websites?|open (?:it|them|these|those|the)|look (?:at|into)|check (?:it|them|these|those|out)|what'?s (?:behind|at|on)|visit|safe)\b/i;
256
+ function cardLinks(w, recent, question) {
257
+ const own = hostOf(w.url), host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return ""; } };
258
+ return [...linksIn(question), ...recent.flatMap((n) => linksIn(n.text))].filter((u) => host(u) && host(u) !== own);
259
+ }
260
+ async function boardChat(id, question, { hours = 72, now = Date.now(), ask = write, look = peekLinks, run = actNow } = {}) {
261
+ question = String(question || "").trim().slice(0, 2000);
262
+ const d = loadWatch(), w = d.watches.find((x) => x.id === id); if (!w) return { error: `No watch ${id}.` };
263
+ if (!question) return { error: "empty" };
264
+ if (ask === write && !resolveProvider()) return { error: "not-connected" };
265
+ const since = now - hours * 3600000, recent = d.news.filter((n) => n.watch === w.id && n.ts >= since).slice(0, 25);
266
+ const b = d.briefs.find((x) => x.watch === w.id && x.ts >= since);
267
+ const urls = cardLinks(w, recent, question), peeks = urls.length && (LINK_ASK.test(question) || linksIn(question).length) ? await look(urls).catch(() => []) : [];
268
+ // one Symbiot everywhere (mind.mjs): this card's messages are what this page knows
269
+ const listed = recent.length ? recent.map((n) => `- ${new Date(n.ts).toLocaleString()}: ${n.text}`).join("\n") : "(nothing new)";
270
+ const role = `Here they're on their ${w.name} card in the Dashboard (their inbox, chats or notifications), going over what's new there. Name things by sender and subject so they can find them. Use only what's listed about a message; never guess what it says beyond that, and say so when the list doesn't settle it. When they say how to answer one, say back in a line or two what the reply should say: the card's Draft a reply button has their agent write it, and they send it themselves.` +
271
+ (peeks.length ? ` "What's behind the links" was looked up just now, the way a link preview does (not signed in, no scripts run, nothing downloaded): say plainly what each link is and what looks off, if anything (a file download such as an .apk rather than a page, a redirect to another site, a lookalike domain, an error). A downloaded file is never something to install unasked.` : "");
272
+ const context = `${w.name}, new in the last ${hours} hours (newest first):\n${listed}` + (b ? `\n\nThe brief:\n${b.text}` : "") + (peeks.length ? `\n\nWhat's behind the links (looked up just now):\n${peeks.map(peekLine).join("\n")}` : "");
273
+ const history = (w.chat || []).slice(-12).map((m) => `${m.role === "user" ? "User" : "You"}: ${m.text}`).join("\n\n");
274
+ const r = await converse({ where: `${w.name} card`, role, context, history, question, ask, now,
275
+ act: { agent: (req, known) => run(req, { title: w.name, context: `${w.name}, new lately:\n${listed}`, known, now }), task: addToTasks } });
276
+ const answer = r.reply;
277
+ // read again: a check may have saved the file while the model answered
278
+ const d2 = loadWatch(), w2 = d2.watches.find((x) => x.id === id); if (!w2) return { answer, chat: [] };
279
+ w2.chat = [...(w2.chat || []), { role: "user", text: question, ts: now }, { role: "ai", text: answer, ts: now }].slice(-TALK_KEEP);
280
+ saveWatch(d2);
281
+ return { answer, chat: w2.chat, ...(peeks.length ? { links: peeks } : {}), ...(r.did ? { did: r.did } : {}) };
282
+ }
283
+ function clearBoardChat(id) { const d = loadWatch(), w = d.watches.find((x) => x.id === id); if (!w) return { error: `No watch ${id}.` }; delete w.chat; saveWatch(d); return { ok: true }; }
284
+ // The card's talk, for a draft's brief: "" when there's been none.
285
+ function talkOf(w) {
286
+ const chat = (w.chat || []).slice(-16); if (!chat.length) return "";
287
+ return `## What the user said about it
288
+ The user went over what's new on ${w.name} with their AI in Symbiot's Dashboard before asking for this reply. Write the reply the way they agreed there for this message; what's about other messages isn't for this one. It's their own words, so follow it as you would the user (it's not part of the message you're replying to).
289
+
290
+ ${chat.map((m) => `> **${m.role === "user" ? "User" : "AI"}:** ${String(m.text).replace(/\s+/g, " ")}`).join("\n>\n")}
291
+
292
+ `;
293
+ }
236
294
 
237
295
  // A batch of news as one notification: how many and where, then the brief if
238
296
  // there is one, else the first few. The phone (phone.mjs) shows the same.
@@ -272,7 +330,7 @@ As the inbox listed it (the sender, the subject and the start of the message), n
272
330
 
273
331
  > ${n.text.replace(/\s+/g, " ")}
274
332
 
275
- ## Tasks
333
+ ${talkOf(w)}## Tasks
276
334
  - [ ] Draft a reply to: ${short}
277
335
 
278
336
  ## How
@@ -314,7 +372,7 @@ As WhatsApp listed it (who it's from and the start of their last message), new o
314
372
 
315
373
  > ${n.text.replace(/\s+/g, " ")}
316
374
 
317
- ## Tasks
375
+ ${talkOf(w)}## Tasks
318
376
  - [ ] Draft a reply to: ${short}
319
377
 
320
378
  ## How
@@ -364,6 +422,18 @@ function draftReply(id, { run = runHandoff } = {}) {
364
422
  const d2 = loadWatch(), n2 = d2.news.find((x) => x.id === id); if (n2) { n2.drafted = Date.now(); saveWatch(d2); }
365
423
  return { ok: true, job: e.id, dir, ...(chat ? { chat: true } : {}) };
366
424
  }
425
+ // Open in WhatsApp, on a chat whose reply was drafted: Symbiot's browser opens
426
+ // web.whatsapp.com as a window (headless.mjs signIn), where the chat shows the
427
+ // reply in its box, for you to read and send. Not while the agent is still
428
+ // typing it: the window takes the browser it works in. `open` is signIn (the
429
+ // tests pass their own).
430
+ async function openChat(id, { open = signIn } = {}) {
431
+ const d = loadWatch(), n = d.news.find((x) => x.id === id); if (!n) return { error: "That message isn't under Watching any more." };
432
+ const w = d.watches.find((x) => x.id === n.watch);
433
+ if (!w || !isChat(w.url)) return { error: "Open in WhatsApp is for a WhatsApp chat." };
434
+ if (runningHandoff(join(DRAFTS_DIR, n.id))) return { error: "Your agent is still typing the reply. Open WhatsApp once it's done: the Agents tab shows when." };
435
+ return open(w.url);
436
+ }
367
437
 
368
438
  // Read a watched page now and note what's new. `read`, `github`, `notify` and
369
439
  // `brief` are headless.mjs's readPage, readGitHub, a desktop notification and
@@ -423,4 +493,4 @@ function startWatches(opts = {}) {
423
493
  return () => { clearTimeout(first); clearInterval(every); };
424
494
  }
425
495
 
426
- export { WATCH_FILE, EVERY, GITHUB_INBOX, DRAFTS_DIR, itemsOf, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, markNews, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, newsSince, newsAfter, waitingOn, watchBoard, isMail, isChat, draftsUrl, draftBrief, chatBrief, draftReply, checkWatch, dueWatches, startWatches };
496
+ export { LINK_ASK, WATCH_FILE, EVERY, GITHUB_INBOX, DRAFTS_DIR, itemsOf, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, markNews, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, newsSince, newsAfter, waitingOn, watchBoard, boardLine, boardChat, clearBoardChat, talkOf, isMail, isChat, draftsUrl, draftBrief, chatBrief, draftReply, openChat, checkWatch, dueWatches, startWatches };
package/writeups.mjs CHANGED
@@ -6,9 +6,11 @@ import { readFileSync, existsSync } from "node:fs";
6
6
  import { VERSION, loadConfig, saveConfig, loadTasks, saveTasks, sh, repoState } from "./core.mjs";
7
7
  import { resolveProvider, write } from "./ai.mjs";
8
8
  import { detectMailSources, mailActivity } from "./mail.mjs";
9
- import { waitingOn } from "./watch.mjs";
9
+ import { waitingOn, newsSince } from "./watch.mjs";
10
10
  import { me, authorship, authorArgs, readmeInfo, repoShape, houseRules, reportFooter, expandRoot, commits, openWork, detectFolder, repoPathMap, discoveredRepos } from "./scan.mjs";
11
- import { taskType, workingChanges, workingDiff } from "./tasks.mjs";
11
+ import { taskType, workingChanges, workingDiff, addTask, pushTasks } from "./tasks.mjs";
12
+ import { runHandoff } from "./agents.mjs";
13
+ import { converse, actNow, addToTasks } from "./mind.mjs";
12
14
 
13
15
  // ---- render ---------------------------------------------------------------
14
16
  function renderCommits(list) {
@@ -137,23 +139,28 @@ async function taskChat(id, question) {
137
139
  ctx.push(`The agent has ticked this task; its uncommitted changes await review (${ch.stat || "no changes"}):\n${ch.files.map((f) => `${f.st} ${f.file}`).slice(0, 40).join("\n") || "(none)"}\n\nDiff (truncated):\n${workingDiff(path, 6000)}`);
138
140
  }
139
141
  }
140
- const history = (it.chat || []).slice(-12).map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.text}`).join("\n\n");
141
- const system =
142
- `You are a pragmatic senior engineer helping someone with ONE task on their list — before or after they hand it to a coding agent. ` +
143
- `Answer their question about the task directly and concisely, in plain text, no preamble. Ground what you say in the project evidence shown; ` +
144
- `if the evidence doesn't settle it, say so and what you'd check. If the task is ambiguous, say how you'd read it and what to clarify. ` +
145
- `Never invent files, features or history.`;
146
- const prompt =
147
- `Task: ${it.text}\nKind: ${taskType(it.text)} · repo: ${it.repo || "(none)"} · status: ${it.archived ? "archived" : it.review ? "done by the agent, awaiting review" : it.done ? "done" : "open"}\n\n` +
148
- (ctx.length ? ctx.join("\n\n") + "\n\n" : path ? "" : "(no repo attached — answer from the task text alone)\n\n") +
149
- (history ? `Conversation so far:\n${history}\n\n` : "") + `Question: ${question}`;
150
- const answer = (await write(system, prompt)) || "(couldn't reach the model)";
142
+ const history = (it.chat || []).slice(-12).map((m) => `${m.role === "user" ? "User" : "You"}: ${m.text}`).join("\n\n");
143
+ // one Symbiot everywhere (mind.mjs): this task and its repo are what this page knows
144
+ const role = `Here they're on one task in their Tasks list. Be a pragmatic senior engineer about it, before or after it goes to their coding agent: answer directly, grounded in the project evidence shown; if it doesn't settle it, say so and what you'd check; if the task is ambiguous, say how you'd read it. Never invent files, features or history.`;
145
+ const context = `Task: ${it.text}\nKind: ${taskType(it.text)} · repo: ${it.repo || "(none)"} · status: ${it.archived ? "archived" : it.review ? "done by the agent, awaiting review" : it.done ? "done" : "open"}` +
146
+ (ctx.length ? "\n\n" + ctx.join("\n\n") : path ? "" : "\n\n(no repo attached: answer from the task text alone)");
147
+ // "do it" here works on the task's repo the way Send to repos does: the request
148
+ // joins the repo's tasks and the agent starts there; with no repo, in a run of its own
149
+ const agent = async (req, known) => {
150
+ if (!path) return actNow(req, { title: it.text.slice(0, 60), context: `Their task: ${it.text}`, known });
151
+ const t = addTask(req, it.repo); if (t && t.error) return { error: t.error };
152
+ const p = pushTasks({ repo: it.repo }); if (!p.written || !p.written.length) return { error: `Couldn't write ${it.repo}'s tasks for the agent.` };
153
+ const e = runHandoff(path); if (!e) return { error: "Your agent didn't start. Check its command in Settings → Handoff." };
154
+ return e.busy ? { ok: true, note: "Your agent is already working in that repo: it takes this up when it's done." } : { ok: true, job: e.id };
155
+ };
156
+ const r = await converse({ where: `Task: ${it.text.slice(0, 60)}`, role, context, history, question, act: { agent, task: (text, repo) => addToTasks(text, repo || it.repo) } });
157
+ const answer = r.reply;
151
158
  // Re-read: other requests may have changed tasks.json while the model ran.
152
159
  const t = loadTasks(); const cur = t.find((x) => x.id === id); const now = Date.now();
153
160
  if (!cur) return { answer, chat: [] };
154
161
  cur.chat = [...(cur.chat || []), { role: "user", text: question, ts: now }, { role: "ai", text: answer, ts: now }].slice(-CHAT_KEEP);
155
162
  saveTasks(t);
156
- return { answer, chat: cur.chat };
163
+ return { answer, chat: cur.chat, ...(r.did ? { did: r.did } : {}) };
157
164
  }
158
165
  function clearTaskChat(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (!it) return { error: "not found" }; delete it.chat; saveTasks(t); return { ok: true }; }
159
166
 
@@ -195,6 +202,15 @@ const renderMail = (list) => list.slice(0, 60).map((x) => `- ${x.date} · to ${x
195
202
  const WAITING_HOURS = 24;
196
203
  function waitingLine(groups) { return groups.length ? "Waiting on you: " + groups.map((g) => g.label).join(", ") : ""; }
197
204
  const withWaiting = (text, line) => (line ? `${text}\n\n${line}` : text);
205
+ // What arrived on the sites you've linked or watch (mail, chat, tickets,
206
+ // notifications) in the last `days`, per site, newest first: [{ name, count,
207
+ // items }]. For Week, so the update covers all of your work, not only your
208
+ // commits, and someone who doesn't write code gets one too.
209
+ function arrivedOn(days, now = Date.now()) {
210
+ const by = new Map();
211
+ for (const n of newsSince(days * 24, now)) { const g = by.get(n.name) || { name: n.name, count: 0, items: [] }; g.count++; if (g.items.length < 15) g.items.push(n.text); by.set(n.name, g); }
212
+ return [...by.values()].sort((a, b) => b.count - a.count);
213
+ }
198
214
 
199
215
  // Build a write-up for a command; returns { text, sub, error? } without printing.
200
216
  // Shared by the CLI (cmdRun) and the web UI (symbiot app). since: the days
@@ -221,31 +237,34 @@ async function produce(cmd, { since = 7, all: everyone = false } = {}) {
221
237
  const mail = sentMail(days); // [] unless email is switched on
222
238
  const waiting = label === "standup" ? waitingOn(WAITING_HOURS) : [], line = waitingLine(waiting);
223
239
  const waitNote = waiting.length ? " · " + waiting.map((g) => g.label).join(", ") + " waiting" : "";
224
- if (!repos.length && !mail.length) return { text: withWaiting(`No commits in the last ${days} days across your ${all.length} repos.\nAdd folders to scan in Settings, or check your git identity.`, line), sub: "no activity" + waitNote };
240
+ const arrived = label === "week" ? arrivedOn(days) : [], arrivedN = arrived.reduce((s, g) => s + g.count, 0);
241
+ if (!repos.length && !mail.length && !arrived.length) return { text: withWaiting(`No commits in the last ${days} days across your ${all.length} repos.\nAdd folders to scan in Settings, or check your git identity.`, line), sub: "no activity" + waitNote };
225
242
  let cs = commits(repos, `${days} days ago`, !everyone);
226
243
  if (!cs.length) cs = commits(repos, `${days} days ago`, false); // fall back to all if none matched you
227
244
  const open = label === "week" ? openWork(repos) : [];
228
- if (!cs.length && !mail.length) return { text: withWaiting("Found repos, but no commits in the window.", line), sub: "no commits" + waitNote };
245
+ if (!cs.length && !mail.length && !arrived.length) return { text: withWaiting("Found repos, but no commits in the window.", line), sub: "no commits" + waitNote };
229
246
 
230
247
  const system =
231
- `You write a short, first-person work update from a person's git commits${mail.length ? " and the emails they sent" : ""}. ` +
248
+ `You write a short, first-person work update from a person's git commits${mail.length ? " and the emails they sent" : ""}${arrived.length ? ", and what arrived on the work sites they've linked (mail, chat, tickets, notifications)" : ""}. ` +
232
249
  `Write as them ("I"), plainly and specifically, grouped by theme or project, most important first. ` +
233
250
  `Turn commit messages into outcomes a manager or teammate would understand — not a raw commit list. ` +
234
251
  (mail.length ? `Fold the emails into those themes (a decision, a hand-off, who they worked with); skip routine ones (receipts, scheduling, one-line replies). You only have their subjects and recipients — don't guess at what they said. ` : "") +
252
+ (arrived.length ? `What arrived is titles and senders only, and it came TO them: use it to show what they dealt with and who they worked with, but don't claim they did something unless it shows it (a "merged", "approved" or "sent" line, a commit, an email they sent). Skip noise (newsletters, receipts, routine alerts). ${cs.length ? "" : "They may not write code: with no commits, write the update from these sites. "}` : "") +
235
253
  `${label === "standup" ? "Keep it to 3-5 bullets: done, and what's next." : "A short paragraph or a few grouped bullets; end with a one-line 'In progress / next' if there are open items."} ` +
236
- `No preamble, no sign-off, no invented work — only what the commits${mail.length ? ", emails" : ""} and open items show.` +
254
+ `No preamble, no sign-off, no invented work — only what the commits${mail.length ? ", emails" : ""}${arrived.length ? ", linked sites" : ""} and open items show.` +
237
255
  (waiting.length ? ` What's waiting on them (new on pages they watch) is counted in a line added under what you write: don't list or count it, but you may name one as next if it's clearly work (a review request, a failed CI run).` : "");
238
256
  const prompt =
239
257
  `Person: ${who.name || "me"}. Window: ${label === "standup" ? "since yesterday" : `last ${days} days`}.\n\n` +
240
258
  `Commits:\n${cs.length ? renderCommits(cs) : "(none)"}\n\n` +
241
259
  (mail.length ? `Emails I sent (date · to · subject):\n${renderMail(mail)}\n\n` : "") +
242
260
  (open.length ? `Open / in progress:\n${open.map((o) => `- ${o}`).join("\n")}\n\n` : "") +
261
+ (arrived.length ? `What arrived on my linked sites (site: count, then titles):\n${arrived.map((g) => `- ${g.name}: ${g.count}\n${g.items.map((t) => ` - ${t.slice(0, 160)}`).join("\n")}`).join("\n")}\n\n` : "") +
243
262
  (waiting.length ? `Waiting on me (new since yesterday):\n${waiting.map((g) => `- ${g.label}:\n${g.items.slice(0, 10).map((t) => ` - ${t.slice(0, 160)}`).join("\n")}`).join("\n")}\n\n` : "") +
244
263
  `Write the ${label === "standup" ? "standup" : "update"}.`;
245
264
 
246
265
  const text = await write(system, prompt);
247
- const mailNote = mail.length ? ` · ${mail.length} sent email${mail.length === 1 ? "" : "s"}` : "";
266
+ const mailNote = (mail.length ? ` · ${mail.length} sent email${mail.length === 1 ? "" : "s"}` : "") + (arrivedN ? ` · ${arrivedN} from linked sites` : "");
248
267
  return { text: text ? withWaiting(text, line) : "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos${mailNote}${waitNote} · ${label}`, footer: `symbiot ${VERSION} · ${all.length} repos (same as the Map) · ${repos.length} active · ${cs.length} commits in last ${days}d${mailNote}` };
249
268
  }
250
269
 
251
- export { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce };
270
+ export { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, arrivedOn, produce };