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/README.md +86 -11
- package/agents.mjs +108 -7
- package/headless.mjs +80 -17
- package/index.mjs +9 -5
- package/links.mjs +149 -0
- package/mind.mjs +181 -0
- package/package.json +5 -2
- package/peek.mjs +96 -0
- package/screens.mjs +28 -5
- package/server.mjs +18 -2
- package/tasks.mjs +1 -0
- package/ui.mjs +97 -15
- package/watch.mjs +78 -8
- package/writeups.mjs +39 -20
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 · 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
|
|
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'>✓ Saved "+r.saved+" answer"+(r.saved>1?"s":"")+" for <b>"+esc(a.name)+"</b> in .symbiot/ANSWERS.md"+(r.rerun?" · 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'>✓ Saved "+r.saved+" answer"+(r.saved>1?"s":"")+" for <b>"+esc(a.name)+"</b> in .symbiot/ANSWERS.md"+(r.rerun?" · your agent is picking them up now.":".")+(r.yours&&r.yours.length?"<div class='err'>👤 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>⏸ Waiting on your step</h4><div class='qt'>👤 "+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'>✓ 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'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) — 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 · '+fmtE(a.elapsed)):(esc(a.status)+' · '+fmtE(a.elapsed)+(a.exitCode!=null?' · exit '+a.exitCode:''));
|
|
548
571
|
if(a.fromHeld)st+=" · started on the tasks held for the last run";
|
|
572
|
+
if(a.earlier)st+=" · "+(a.status==='running'?"started outside this window":"ran before Symbiot last started");
|
|
573
|
+
if(a.waiting)st+=" · <span style='color:var(--amber)'>waiting on your step</span>";
|
|
549
574
|
if(nQs(a))st+=" · <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 "• "+esc(t).replace(/'/g,''');}).join(' ');
|
|
551
576
|
ht+=(hn?' ':'')+(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&¤t==='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…</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,''');}
|
|
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'>↻</button><button class='lrm' title='unlink: stop watching it and stop trusting it'>×</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)+"…</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'>×</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'>×</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)+")":"")+" · ":"")+s.w+" × "+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's new: click to stop'>Watching ✓</button>":"<button class='ghost' id='scrwatch' title='read this page again every few minutes while Symbiot runs, and tell me what'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
|
|
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?" · <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?" · <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's there as a new screen'>↑ Scroll up</button>":"")+(down?"<button class='ghost scrscroll' data-to='down' title='scroll down in the hidden browser and map what's there as a new screen'>Scroll down ↓</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 ↕</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 · 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)+" · "+b0.count+" new · "+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 · "+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)+" · "+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 · 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 · "+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)+" · "+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 · 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?"✓ "+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's hidden browser and types a reply into its message box, unsent. It never presses Send or Enter";
|
|
807
|
-
var DRAFTING_CHAT="✓ 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),
|
|
865
|
+
var DRAFTING_CHAT="✓ 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's browser, where the chat shows your agent'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',"✓ 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="✓ 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:'✉️',github:'🔔',chat:'💬',page:'🌐'};
|
|
811
|
-
|
|
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's new here over with your AI before you draft a reply' style='padding:4px 9px;font-size:12px'>💬"+(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')+" · "+(c.checked?"read "+agoTxt(c.checked):c.last?"tried "+agoTxt(c.last):"first read within a minute")+(c.via==='gh'?" · 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 · again":"Draft a reply")+"</button>":"")+"</div>";}).join('')
|
|
827
|
-
+(c.count>c.items.length?"<div class='bl' style='margin-top:6px'>…and "+(c.count-c.items.length)+" more</div>":"")
|
|
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 · 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'>…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…</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='💬 '+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='💬';});});});
|
|
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)
|
|
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
|
-
|
|
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" : "
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
(
|
|
150
|
-
|
|
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
|
-
|
|
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 };
|