History

lilt: Assistant sidebar panel with system prompt and full command log

Settings > Assistant keeps the key, model and agent folder. The new sidebar panel holds a system prompt added to every command, a typed command box, and a searchable log: what was said, the full prompt sent, reply, model, time, focused window. Every finished command is also appended to ~/.local/share/lilt/commands.jsonl.

Kris Yotam committed 2 days agomain

Commit b802a721 parent 6cc51645 files · +167 −48Browse files

Unified · Split

5 files
  1. README.md +12 −5
  2. app.css +31 −0
  3. app.js +106 −35
  4. check.js +2 −0
  5. main.js +16 −8
README.md+12−5
@@ -44,8 +44,14 @@ Hold the assistant key, say a command ("put three terminals in columns on tag 3" · 43 unmodified lines
4444 the chosen agent CLI headless. Every prompt starts by telling the agent to read
4545 `~/.config/agents/AGENTS.md`, which says how to drive this desktop. The agent runs tools without
4646 approval prompts, since nobody is there to answer them. Nothing on screen
47−changes: the bar looks the same as for dictation, and the reply is kept in
48−Settings > Assistant > Recent commands.
47+changes: the bar looks the same as for dictation.
48+
49+The Assistant page in the sidebar holds an optional system prompt (added to every
50+command after the AGENTS.md pointer), a box for typed commands, and the log of
51+every command: what was said, the full prompt sent, the reply, model, time and
52+focused window. Model, key and agent folder stay in Settings > Assistant. Every
53+finished command is also appended to `~/.local/share/lilt/commands.jsonl`, which
54+Delete data leaves alone.
4955
5056 | Agent | Command lilt runs |
5157 |---|---|
@@ -108,7 +114,7 @@ State (`lilt.state()` and the `lilt.onState(cb)` push) is the db plus a · 56 unmodified lines
108114 engine: 'ready'|'loading'|'error'|'off', engineError,
109115 llm: 'ready'|'loading'|'off',
110116 recording: false|'hold'|'handsfree', recordingKind: 'dictate'|'assistant', processing: bool, notetaker: false|{startedAt},
111− assistant: false|'running', assistantDir, assistantDirOk,
117+ assistant: false|'running', assistantDir, assistantDirOk, assistantLog,
112118 assistantModels: [{id, name, cli, desc, available}],
113119 hotkeyLabel, // e.g. 'Right Alt'
114120 models: [{id, name, engine, size, langs, installed, progress}] // progress 0..1 while downloading
@@ -119,14 +125,15 @@ Records: history `{id, ts, text, raw, app, words, ms, audio, dismissed, flagged, · 4 unmodified lines
119125 dictionary `{id, word, to?, auto?}`, snippets `{id, trigger, text}`,
120126 transforms `{id, name, desc, prompt, key}`, notes `{id, title, body, ts, updated}`,
121127 meetings `{id, title, ts, ms, transcript, summary, audio}`,
122−commands `{id, ts, text, reply, model, ms, ok}` (assistant commands, newest first, capped at 200).
128+commands `{id, ts, text, prompt, reply, model, ms, ok, app, title, followUp}` (assistant commands, newest first, capped at 1000;
129+the full record is also appended to `commands.jsonl` when each finishes).
123130
124131 Settings keys (defaults in `DEFAULTS` in main.js): `theme, firstName, lastName, email,
125132 hotkey{keycode,label}, micId, micLabel, language, model, llmModel, cleanup ('none'|'light'|'medium'),
126133 styles{personal,work,email,other} ('formal'|'casual'|'veryCasual'), transformsOptIn,
127134 autoTransform{enabled,id}, launchAtLogin, showBarAlways, showInDock, sounds, muteWhileDictating,
128135 scratchpadInBar, privacy{contextAwareness,storeAudio}, vibe{variableRecognition,fileTagging},
129−assistant{enabled, model, hotkey{keycode,label}}, dismissed{}, notetaker{notifyBefore, showNextMeeting, autoDetect, autoStart, maxMinutes,
136+assistant{enabled, model, hotkey{keycode,label}, systemPrompt}, dismissed{}, notetaker{notifyBefore, showNextMeeting, autoDetect, autoStart, maxMinutes,
130137 stopOnCallEnd, hideFromCapture, notepadView, liveTranscript, calendarNotice, consentNotice,
131138 linkSharing, autoShareWho, autoShareFor}`. History items also carry `title` (window title).
132139
app.css+31−0
@@ -246,6 +246,37 @@ a { color: inherit; } · 245 unmodified lines
246246 .green-dot::after { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
247247 .doc-dot { width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--line); display: grid; place-items: center; color: var(--muted); }
248248
249+/* assistant */
250+.asst .day { top: 0; }
251+.asst-card { padding-bottom: 20px; margin-bottom: 30px; }
252+.asst-model { display: inline-flex; align-items: center; gap: 8px; font-weight: 500; letter-spacing: 0; }
253+.asst-hint { margin: 10px 0 0; font-size: 13.5px; }
254+.asst-hint b { color: var(--text); font-weight: 600; }
255+.asst-cmd { display: flex; gap: 10px; align-items: center; margin-top: 12px; }
256+.asst-cmd .field { flex: 1; }
257+.asst-card .live .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
258+.asst-sp { margin-bottom: 34px; }
259+.asst-sp p { margin: 10px 0; font-size: 13.5px; }
260+.asst-sp textarea.field { min-height: 96px; }
261+.asst-sp-foot { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 13.5px; color: var(--muted); }
262+.asst-log-path { display: flex; align-items: center; gap: 12px; font-size: 13.5px; color: var(--muted); margin: -12px 0 8px; }
263+.asst-log-path .grow { flex: 1; min-width: 0; overflow-wrap: anywhere; }
264+.asst-log .row.cmd { display: block; padding: 0; }
265+.cmd-head { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; cursor: pointer; }
266+.cmd-head .time { width: 72px; flex: none; font-size: 13.5px; color: var(--muted); padding-top: 1px; }
267+.cmd-head .grow { flex: 1; min-width: 0; }
268+.cmd-text { overflow-wrap: anywhere; }
269+.cmd-tag { font-size: 11.5px; color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: 0 6px; margin-left: 8px; white-space: nowrap; vertical-align: 1px; }
270+.cmd-reply { margin-top: 4px; color: var(--muted); font-size: 13.5px; white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
271+.cmd.open .cmd-reply { display: block; }
272+.cmd-reply.err { color: var(--red); }
273+.cmd-meta { flex: none; font-size: 13px; color: var(--muted); white-space: nowrap; padding-top: 1px; }
274+.cmd-more { padding: 0 16px 16px 100px; }
275+.cmd-more .cap { margin: 12px 0 6px; font-size: 11.5px; }
276+.cmd-pre { margin: 0; max-height: 320px; overflow: auto; font: 12.5px/1.5 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--field); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; }
277+.cmd-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
278+.cmd-acts svg.i { width: 16px; height: 16px; }
279+
249280 /* toggles + fields */
250281 .toggle { width: 42px; height: 24px; border-radius: 12px; background: var(--chip); position: relative; flex: none; transition: background .15s; border: 1px solid var(--line); }
251282 .toggle::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #d8d6d2; transition: transform .15s; }
app.js+106−35
@@ -71,7 +71,7 @@ · 70 unmodified lines
7171 let S = null;
7272 const ui = {
7373 page: 'home', tab: {}, search: {}, searchOpen: {}, sort: {}, settings: null,
74− note: null, meeting: null, dirty: false, side: true,
74+ note: null, meeting: null, dirty: false, side: true, cmdOpen: {},
7575 };
7676
7777 const toast = (msg, ms = 2600) => {
@@ -181,13 +181,13 @@ · 103 unmodified lines
181181 applyTheme();
182182 $('#app').classList.toggle('side-off', !ui.side);
183183 renderSide();
184− const pages = { home, notetaker, insights, dictionary, snippets, style, transforms, scratchpad };
184+ const pages = { home, notetaker, assistant: assistantPage, insights, dictionary, snippets, style, transforms, scratchpad };
185185 $('#main').innerHTML = (pages[ui.page] || home)();
186186 if (ui.settings) renderSettings();
187187 }
188188
189189 const NAV = [
190− ['home', 'mic', 'Dictation'], ['notetaker', 'rec', 'Notetaker'], ['insights', 'chart', 'Insights'],
190+ ['home', 'mic', 'Dictation'], ['notetaker', 'rec', 'Notetaker'], ['assistant', 'bot', 'Assistant'], ['insights', 'chart', 'Insights'],
191191 ['dictionary', 'book', 'Dictionary'], ['snippets', 'scissors', 'Snippets'], ['style', 'type', 'Style'],
192192 ['transforms', 'wand', 'Transforms'], ['scratchpad', 'pad', 'Scratchpad'],
193193 ];
@@ -486,6 +486,79 @@ · 292 unmodified lines
486486 <button class="tcard" data-act="tf-edit"><span class="plus-dot">${ic('plus')}</span><h4>Create your own</h4><p>Write your own prompt</p></button></div></div>`;
487487 }
488488
489+ // ---------------------------------------------------------------- assistant
490+ const modelName = id => (S.runtime.assistantModels || []).find(m => m.id === id)?.name || id || '';
491+ const secs = ms => ms >= 60000 ? fmtDur(ms) : `${(ms / 1000).toFixed(1)} s`;
492+
493+ function assistantPage() {
494+ const a = S.settings.assistant || {}, rt = S.runtime, running = rt.assistant === 'running';
495+ const all = [...(S.commands || [])].sort((x, y) => y.ts - x.ts);
496+ const live = running && all[0] && !all[0].reply ? all[0] : null;
497+ const items = all.filter(c => matches('asst', c.text, c.reply));
498+ const groups = [];
499+ items.forEach(c => { const d = fmtDay(c.ts); if (!groups.length || groups.at(-1).d !== d) groups.push({ d, xs: [] }); groups.at(-1).xs.push(c); });
500+ const log = groups.map(g => `<div class="day"><span>${g.d}</span></div><div class="list asst-log">${g.xs.map(c => cmdRow(c, live?.id)).join('')}</div>`).join('');
501+ const m = (rt.assistantModels || []).find(x => x.id === a.model);
502+ const key = esc(a.hotkey?.label || 'Left Shift');
503+ return `<div class="page narrow asst">
504+ <div class="page-head"><h1>Assistant</h1><div class="head-actions">
505+ <button class="icon-btn" data-go="settings/assistant" title="Assistant settings">${ic('gear')}</button></div></div>
506+ <div class="nt-card asst-card"><div class="label"><span>COMMAND</span>
507+ <span class="asst-model"><span class="status-dot ${!m || !m.available ? 'error' : running ? 'loading' : a.enabled === false ? '' : 'ready'}"></span>${esc(m?.name || 'No agent selected')}</span></div>
508+ ${a.enabled === false ? `<p class="muted asst-hint">The assistant key is off. Typed commands still run.</p>` : `<p class="muted asst-hint">Hold <b>${key}</b> and say a command, or type one here.</p>`}
509+ <div class="asst-cmd"><input class="field" id="asst-in" placeholder="e.g. open my notes folder">
510+ ${running ? '<button class="btn ghost" data-act="asst-stop">Stop</button>' : ''}<button class="btn" data-act="asst-run">Run</button></div>
511+ ${running ? `<div class="live"><span class="rec"></span><span class="grow"><b style="font-weight:500">Running</b> <span class="muted">${esc(live?.text || '')}</span></span>
512+ ${live ? `<span data-elapsed="${live.ts}">${secs(Date.now() - live.ts)}</span>` : ''}</div>` : ''}
513+ </div>
514+ <div class="recents"><h3>System prompt</h3></div>
515+ <div class="asst-sp">
516+ <p class="muted">Added to every command, after the pointer to <span class="code">~/.config/agents/AGENTS.md</span>.</p>
517+ <textarea class="field" id="asst-sp" placeholder="e.g. Prefer dwm keybindings over xdotool. Answer in one sentence.">${esc(a.systemPrompt || '')}</textarea>
518+ <div class="asst-sp-foot"><span id="asst-sp-state">${ui.spState || ''}</span><span style="flex:1"></span><button class="btn sm" data-act="asst-sp-save">Save</button></div>
519+ </div>
520+ ${tabs('asst', [['log', `Commands <span class="muted">${all.length}</span>`]], `<span class="tools"><button data-act="search" data-key="asst" title="Search">${ic('search')}</button></span>`)}
521+ ${rt.assistantLog ? `<div class="asst-log-path"><span class="grow">Every command is also appended to <span class="code">${esc(rt.assistantLog)}</span></span><button class="btn ghost sm" data-act="asst-log">Open</button></div>` : ''}
522+ ${searchBox('asst')}
523+ ${log || `<div class="empty">${ui.search.asst ? 'No matches' : 'No commands yet'}</div>`}</div>`;
524+ }
525+
526+ function cmdRow(c, liveId) {
527+ const open = ui.cmdOpen[c.id], isLive = c.id === liveId, id = esc(c.id);
528+ const err = !isLive && c.ok === false;
529+ const reply = isLive ? '<span class="status-dot loading"></span> Running…' : c.reply ? esc(c.reply) : err ? 'No reply' : 'Done';
530+ const name = modelName(c.model);
531+ const meta = [name && esc(name), isLive ? `<span data-elapsed="${c.ts}">${secs(Date.now() - c.ts)}</span>` : c.ms ? secs(c.ms) : ''].filter(Boolean).join(' · ');
532+ const win = [c.app, c.title].filter(Boolean).map(esc).join(' · ');
533+ return `<div class="row cmd${open ? ' open' : ''}">
534+ <div class="cmd-head" data-act="cmd-toggle" data-id="${id}" title="${open ? 'Hide details' : 'Show details'}">
535+ <span class="time">${fmtTime(c.ts)}</span>
536+ <span class="grow"><div class="cmd-text">${esc(c.text || '')}${c.followUp ? '<span class="cmd-tag">Follow-up</span>' : ''}</div>
537+ <div class="cmd-reply${err ? ' err' : ''}">${reply}</div></span>
538+ <span class="cmd-meta">${meta}</span></div>
539+ ${open ? `<div class="cmd-more">
540+ <div class="cap">Prompt sent</div>${c.prompt ? `<pre class="cmd-pre">${esc(c.prompt)}</pre>` : '<div class="muted">Not recorded for this command.</div>'}
541+ <div class="cap">Focused window</div><div class="${win ? '' : 'muted'}">${win || 'Not recorded'}</div>
542+ <div class="cmd-acts">
543+ <button class="btn ghost sm" data-act="cmd-copy" data-field="reply" data-id="${id}">${ic('copy')}Copy reply</button>
544+ ${c.prompt ? `<button class="btn ghost sm" data-act="cmd-copy" data-field="prompt" data-id="${id}">${ic('copy')}Copy prompt</button>` : ''}
545+ <button class="btn ghost sm" data-act="cmd-rerun" data-id="${id}">${ic('refresh')}Run again</button>
546+ <button class="btn danger sm" data-act="remove" data-coll="commands" data-id="${id}">${ic('trash')}Delete</button></div></div>` : ''}
547+ </div>`;
548+ }
549+
550+ // The system prompt saves on blur and on Save, never per keystroke.
551+ async function saveSysPrompt(loud) {
552+ const ta = $('#asst-sp'); if (!ta) return;
553+ const v = ta.value, a = S.settings.assistant = S.settings.assistant || {};
554+ if (v !== (a.systemPrompt || '')) {
555+ a.systemPrompt = v; ui.spState = 'Saved';
556+ const st = $('#asst-sp-state'); if (st) st.textContent = 'Saved';
557+ await lilt.set('assistant.systemPrompt', v);
558+ }
559+ if (loud) toast('Saved', 1400);
560+ }
561+
489562 // ---------------------------------------------------------------- scratchpad
490563 function scratchpad() {
491564 if (ui.note) return noteEditor();
@@ -591,13 +664,7 @@ · 99 unmodified lines
591664 : `<button class="btn ghost sm" data-act="set" data-path="assistant.model" data-val="${esc(m.id)}">Use</button>`;
592665 return `<div class="srow"><div class="grow"><div class="t">${esc(m.name)}</div><div class="d">${esc(m.desc || m.cli || '')}</div></div>${right}</div>`;
593666 }).join('');
594− const when = ts => dayKey(ts) === dayKey(Date.now()) ? fmtTime(ts) : new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' + fmtTime(ts);
595− const cmds = (S.commands || []).slice(0, 20).map(c => `<div class="srow"><div class="grow">
596− <div class="d">${when(c.ts)}${c.model ? ` · ${esc((rt.assistantModels || []).find(m => m.id === c.model)?.name || c.model)}` : ''}${c.ms ? ` · ${(c.ms / 1000).toFixed(1)} s` : ''}</div>
597− <div class="t" style="font-weight:400">${esc(c.text || '')}</div>
598− <div class="d" style="white-space:pre-wrap;max-height:84px;overflow:hidden${c.ok === false ? ';color:var(--red)' : ''}">${esc(c.reply || (c.ok === false ? 'Failed' : ''))}</div></div>
599− <button class="icon-btn" data-act="remove" data-coll="commands" data-id="${esc(c.id)}" title="Delete">${ic('trash')}</button></div>`).join('');
600− return `<h2>Assistant</h2><p>Say a command and an agent carries it out on this machine. Replies are listed under Recent commands.</p>
667+ return `<h2>Assistant</h2><p>Say a command and an agent carries it out on this machine. The system prompt, typed commands and every reply are in the <button class="link-btn" data-act="asst-panel">Assistant panel →</button></p>
601668 <div class="group">
602669 ${tog('assistant.enabled', 'Assistant key', 'Off leaves only the dictation key active.')}
603670 <div class="srow"><div class="grow"><div class="t">Shortcut</div><div class="d">Hold <b>${esc(key)}</b> and say a command. Double-tap for hands-free.</div></div><button class="btn ghost" data-act="asst-hotkey">${ui.capturingAsst ? 'Press a key…' : 'Change'}</button></div>
@@ -605,12 +672,7 @@ · 1 unmodified line
605672 ${rt.assistantDirOk === false ? '<div class="d" style="color:var(--red)">No AGENTS.md there yet</div>' : ''}</div><button class="btn ghost" data-act="asst-open">Open</button></div>
606673 </div>
607674 <h5>Agent <span class="muted" style="font-weight:400;font-size:13.5px">· runs headless with full tool access</span></h5>
608− <div class="group models">${models || '<div class="srow muted">No agents found</div>'}</div>
609− <h5>Type a command</h5>
610− <div class="group"><div class="srow"><input class="field grow" id="asst-in" placeholder="e.g. open my notes folder">
611− ${running ? '<button class="btn ghost sm" style="min-width:0" data-act="asst-stop">Stop</button>' : ''}<button class="btn sm" data-act="asst-run">Run</button></div></div>
612− <h5>Recent commands</h5>
613− <div class="group">${cmds || '<div class="srow muted">No commands yet</div>'}</div>`;
675+ <div class="group models">${models || '<div class="srow muted">No agents found</div>'}</div>`;
614676 },
615677 system() {
616678 return `<h2>System</h2><h5>App settings</h5><div class="group">
@@ -919,6 +981,12 @@ · 302 unmodified lines
919981 case 'asst-open': return lilt.open('file://' + (S.runtime.assistantDir || ''));
920982 case 'asst-run': return runAssistant();
921983 case 'asst-stop': await lilt.stopAssistant(); return toast('Stopped', 1400);
984+ case 'asst-panel': ui.settings = null; $('#overlay').innerHTML = ''; return go('assistant');
985+ case 'asst-sp-save': return saveSysPrompt(true);
986+ case 'asst-log': return lilt.open('file://' + (S.runtime.assistantLog || ''));
987+ case 'cmd-toggle': if (String(getSelection())) return; ui.cmdOpen[id] = !ui.cmdOpen[id]; return render();
988+ case 'cmd-copy': { const c = (S.commands || []).find(x => x.id === id); lilt.copy(c?.[t.dataset.field] || ''); return toast('Copied to clipboard', 1400); }
989+ case 'cmd-rerun': { const c = (S.commands || []).find(x => x.id === id); return c?.text && runAssistant(c.text); }
922990 case 'mic': return micModal();
923991 case 'lang': return langModal();
924992 case 'llm': return modal(`<h3>Language model</h3><p class="sub">Any Ollama model tag. Small instruct models (3–8B) keep cleanup under a second on your GPU.</p><input class="field" id="m-llm" value="${esc(S.settings.llmModel)}"><div class="foot"><button class="btn line" data-close>Cancel</button><button class="btn" data-ok>Save</button></div>`, el => {
@@ -950,7 +1018,9 @@ · 25 unmodified lines
9501018 const n = $(`[data-search=${t.dataset.search}]`); if (n) { n.focus(); n.setSelectionRange(pos, pos); }
9511019 }
9521020 if (t.dataset.noteField) saveNote();
1021+ if (t.id === 'asst-sp') { ui.spState = 'Not saved'; const st = $('#asst-sp-state'); if (st) st.textContent = 'Not saved'; }
9531022 });
1023+ document.addEventListener('focusout', e => { if (e.target.id === 'asst-sp') saveSysPrompt(false); });
9541024 document.addEventListener('change', e => {
9551025 const t = e.target;
9561026 if (t.dataset.set) setSetting(t.dataset.set, /^\d+$/.test(t.value) ? Number(t.value) : t.value);
@@ -964,18 +1034,13 @@ · 7 unmodified lines
9641034
9651035 document.addEventListener('keydown', e => { if (e.key === 'Enter' && e.target.id === 'asst-in') runAssistant(); });
9661036
967− // Typed assistant command. The reply also lands in S.commands via the state push.
968− async function runAssistant() {
969− const inp = $('#asst-in'), text = inp?.value.trim();
1037+ // Typed assistant command. The record shows up in S.commands (and the panel log) via the state push.
1038+ async function runAssistant(again) {
1039+ const inp = $('#asst-in'), text = (again || inp?.value || '').trim();
9701040 if (!text) return;
971− inp.value = ''; inp.blur();
972− toast('Running…', 60000);
973− try {
974− const r = await lilt.assistant(text);
975− const ok = typeof r === 'object' && r ? r.ok !== false : true;
976− const msg = typeof r === 'string' ? r : r?.reply || (ok ? 'Done' : 'Failed');
977− toast((ok ? '' : 'Failed: ') + (msg.length > 220 ? msg.slice(0, 220) + '…' : msg), 6000);
978− } catch (err) { toast(`Failed: ${err.message || err}`, 6000); }
1041+ if (inp && !again) { inp.value = ''; inp.blur(); }
1042+ if (ui.page !== 'assistant') toast('Running…', 4000);
1043+ try { await lilt.assistant(text); } catch (err) { toast(`Failed: ${err.message || err}`, 6000); }
9791044 }
9801045
9811046 async function loadMeetingAudio() {
@@ -983,7 +1048,10 @@ · 1 unmodified line
9831048 const url = await lilt.audioUrl(el.dataset.audio); if (url) el.src = url; else el.remove();
9841049 }
9851050
986− setInterval(() => { const t = $('#nt-timer'); if (t) t.textContent = fmtDur(Date.now() - Number(t.dataset.start)); }, 1000);
1051+ setInterval(() => {
1052+ const t = $('#nt-timer'); if (t) t.textContent = fmtDur(Date.now() - Number(t.dataset.start));
1053+ document.querySelectorAll('[data-elapsed]').forEach(el => { el.textContent = secs(Date.now() - Number(el.dataset.elapsed)); });
1054+ }, 1000);
9871055
9881056 const lilt = window.lilt || mockLilt();
9891057 lilt.onState(onState);
@@ -995,9 +1063,10 @@ · 5 unmodified lines
9951063 function mockLilt() {
9961064 const now = Date.now(), H = 36e5;
9971065 const st = {
998− settings: { theme: 'system', firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com', hotkey: { keycode: 108, label: 'Right Alt' }, micId: '', language: 'en', model: 'large-v3-turbo', llmModel: 'qwen3.5:4b', cleanup: 'light', styles: { personal: 'casual', work: 'formal', email: 'formal', other: 'casual' }, transformsOptIn: true, autoTransform: { enabled: false, id: 'polish' }, showBarAlways: true, showInDock: true, sounds: true, privacy: { storeAudio: true, contextAwareness: true }, notetaker: { maxMinutes: 120, systemAudio: true }, vibe: { fileTagging: true }, dismissed: {}, assistant: { model: 'claude-sonnet', hotkey: { keycode: 50, label: 'Left Shift' }, enabled: true } },
1066+ settings: { theme: 'system', firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com', hotkey: { keycode: 108, label: 'Right Alt' }, micId: '', language: 'en', model: 'large-v3-turbo', llmModel: 'qwen3.5:4b', cleanup: 'light', styles: { personal: 'casual', work: 'formal', email: 'formal', other: 'casual' }, transformsOptIn: true, autoTransform: { enabled: false, id: 'polish' }, showBarAlways: true, showInDock: true, sounds: true, privacy: { storeAudio: true, contextAwareness: true }, notetaker: { maxMinutes: 120, systemAudio: true }, vibe: { fileTagging: true }, dismissed: {}, assistant: { model: 'claude-sonnet', hotkey: { keycode: 50, label: 'Left Shift' }, enabled: true, systemPrompt: 'Prefer dwm keybindings over xdotool when moving windows. Answer in one sentence.' } },
9991067 commands: [
1000− { id: 'c1', ts: now - H / 2, text: 'Open my notes folder in the file manager', reply: 'Opened ~/lit/notes in pcmanfm.', model: 'claude-sonnet', ms: 8200, ok: true },
1068+ { id: 'c3', ts: now - H / 4, text: 'Now move it to tag 3', prompt: 'Read ~/.config/agents/AGENTS.md first.\n\nPrefer dwm keybindings over xdotool when moving windows. Answer in one sentence.\n\nCommand: Now move it to tag 3', reply: 'Moved the pcmanfm window to tag 3.', model: 'claude-sonnet', ms: 5400, ok: true, app: 'St', title: 'fish ~/src/lilt', followUp: true },
1069+ { id: 'c1', ts: now - H / 2, text: 'Open my notes folder in the file manager', prompt: 'Read ~/.config/agents/AGENTS.md first.\n\nPrefer dwm keybindings over xdotool when moving windows. Answer in one sentence.\n\nCommand: Open my notes folder in the file manager', reply: 'Opened ~/lit/notes in pcmanfm.', model: 'claude-sonnet', ms: 8200, ok: true, app: 'St', title: 'fish ~/src/lilt', followUp: false },
10011070 { id: 'c2', ts: now - 25 * H, text: 'Mute the speakers', reply: 'pactl: connection refused', model: 'codex', ms: 3100, ok: false },
10021071 ],
10031072 history: [
@@ -1010,7 +1079,7 @@ · 6 unmodified lines
10101079 snippets: [{ id: 's1', trigger: 'my email address', text: 'ada@example.com' }],
10111080 transforms: DEFAULT_TRANSFORMS.map(x => ({ ...x })), notes: [], meetings: [],
10121081 runtime: { version: '1.0.0', engine: 'ready', llm: 'ready', hotkeyLabel: 'Right Alt', mcpUrl: 'http://127.0.0.1:7865/mcp', dataDir: '~/.local/share/lilt', models: [{ id: 'large-v3-turbo', name: 'Whisper large-v3-turbo', engine: 'whisper.cpp', size: '1.6 GB', langs: '99 languages', installed: true }, { id: 'parakeet-v3', name: 'Parakeet TDT 0.6B v3', engine: 'parakeet', size: '1.3 GB', langs: '25 languages', installed: true }, { id: 'large-v3', name: 'Whisper large-v3', engine: 'whisper.cpp', size: '3.1 GB', langs: '99 languages', installed: false }],
1013− assistant: false, recordingKind: 'dictate', assistantDir: '/home/ada/.config/agents', assistantDirOk: false,
1082+ assistant: false, recordingKind: 'dictate', assistantDir: '/home/ada/.config/agents', assistantDirOk: false, assistantLog: '/home/ada/.local/share/lilt/commands.jsonl',
10141083 assistantModels: [{ id: 'claude-sonnet', name: 'Claude Sonnet', cli: 'claude', desc: 'Claude Code CLI · uses your Claude sign-in', available: true }, { id: 'codex', name: 'GPT-5.6', cli: 'codex', desc: 'Codex CLI · uses your ChatGPT sign-in', available: true }, { id: 'grok', name: 'Grok', cli: 'grok', desc: 'Grok CLI', available: false }] },
10151084 };
10161085 let cb = () => {};
@@ -1025,10 +1094,12 @@ · 8 unmodified lines
10251094 copy: t => navigator.clipboard?.writeText(t), audioUrl: async () => null, notetaker: async () => {}, downloadModel: () => {},
10261095 captureHotkey: async target => target === 'assistant' ? { keycode: 50, label: 'Left Shift' } : { keycode: 108, label: 'Right Alt' },
10271096 assistant: async text => {
1028− st.runtime.assistant = 'running'; push();
1029− await new Promise(r => setTimeout(r, 800));
1030− const c = { id: `c${++n}`, ts: Date.now(), text, reply: `Stub reply to: ${text}`, model: st.settings.assistant.model, ms: 800, ok: true };
1031− st.commands = [c, ...st.commands]; st.runtime.assistant = false; push(); return c.reply;
1097+ const prompt = `Read ~/.config/agents/AGENTS.md first.\n\n${st.settings.assistant.systemPrompt || ''}\n\nCommand: ${text}`;
1098+ const c = { id: `cmd${Date.now()}`, ts: Date.now(), text, prompt, reply: '', model: st.settings.assistant.model, ms: 0, ok: false, app: 'St', title: 'fish ~/src/lilt', followUp: Date.now() - (st.commands[0]?.ts || 0) < 3e5 };
1099+ st.commands = [c, ...st.commands]; st.runtime.assistant = 'running'; push();
1100+ await new Promise(r => setTimeout(r, 3000));
1101+ Object.assign(c, { reply: `Stub reply to: ${text}`, ms: Date.now() - c.ts, ok: true });
1102+ st.runtime.assistant = false; push(); return c;
10321103 },
10331104 stopAssistant: async () => { st.runtime.assistant = false; push(); }, transform: async (id, t) => t, mcpAdd: async () => 'stub', open: () => {},
10341105 exportData: async () => '~/dl/lilt-export.json', deleteData: async () => {}, win: () => {},
check.js+2−0
@@ -55,6 +55,8 @@ assert.strictEqual(P.keyLabel(70), 'F4'); · 54 unmodified lines
5555 // assistant prompt carries the focused window when known
5656 assert.ok(P.assistantPrompt('open firefox', { cls: 'St', title: 'nvim' }).endsWith('open firefox\n\nFocused window: St "nvim"'));
5757 assert.ok(P.assistantPrompt('open firefox').endsWith('open firefox'));
58+assert.ok(P.assistantPrompt('x', {}, ' be brief ').includes("standing instructions:\nbe brief\n\nVoice command"));
59+assert.ok(!P.assistantPrompt('x', {}, ' ').includes('standing'));
5860
5961 // CLI output: grok JSON stream -> last result, plain text unchanged
6062 assert.strictEqual(P.cliResult('{"type":"message","text":"I will run date"}\n{"type":"result","result":"It is 3pm."}\n'), 'It is 3pm.');
main.js+16−8
@@ -125,9 +125,10 @@ function drift(src, out) { · 124 unmodified lines
125125 }
126126
127127 // What the assistant agent receives for one spoken (or typed) command.
128−function assistantPrompt(text, win = {}) {
128+function assistantPrompt(text, win = {}, system = '') {
129129 const w = win.cls ? `\n\nFocused window: ${win.cls}${win.title ? ` "${win.title}"` : ''}` : '';
130− return `First read ~/.config/agents/AGENTS.md and follow it; the other files in ~/.config/agents are yours to use.\n\n` +
130+ const sys = system.trim() ? `Kris's standing instructions:\n${system.trim()}\n\n` : '';
131+ return `First read ~/.config/agents/AGENTS.md and follow it; the other files in ~/.config/agents are yours to use.\n\n${sys}` +
131132 `Voice command from Kris (speech to text, may contain recognition errors): ${text}${w}`;
132133 }
133134
@@ -182,7 +183,7 @@ const DEFAULTS = { · 48 unmodified lines
182183 stopOnCallEnd: true, hideFromCapture: true, notepadView: 'open', liveTranscript: true,
183184 calendarNotice: 'off', consentNotice: false, linkSharing: 'anyone', autoShareWho: 'no one', autoShareFor: 'all meetings' },
184185 vibe: { variableRecognition: false, fileTagging: true },
185− assistant: { enabled: true, model: 'claude-sonnet', hotkey: { keycode: 50, label: 'Left Shift' } },
186+ assistant: { enabled: true, model: 'claude-sonnet', hotkey: { keycode: 50, label: 'Left Shift' }, systemPrompt: '' },
186187 dismissed: {},
187188 };
188189
@@ -233,6 +234,9 @@ function load() { · 44 unmodified lines
233234 const s = seed();
234235 for (const k of ['history', 'dictionary', 'snippets', 'transforms', 'notes', 'meetings', 'commands']) if (!Array.isArray(db[k])) db[k] = s[k];
235236 db.settings = merge(DEFAULTS, db.settings);
237+ // commands from before the log file existed, oldest first
238+ if (CLI < 0 && db.commands.length && !fs.existsSync(ASSIST_LOG))
239+ fs.writeFileSync(ASSIST_LOG, db.commands.filter(c => c.reply).reverse().map(c => JSON.stringify(c) + '\n').join(''));
236240 }
237241
238242 let saveTimer = null;
@@ -255,7 +259,7 @@ function models() { · 16 unmodified lines
255259 installed: fs.existsSync(path.join(MODELS, m.file)), progress: progress[m.id] }));
256260 }
257261 function runtime() {
258− return { ...rt, hotkeyLabel: db.settings.hotkey.label, models: models(), assistantDir: ASSIST_DIR,
262+ return { ...rt, hotkeyLabel: db.settings.hotkey.label, models: models(), assistantDir: ASSIST_DIR, assistantLog: ASSIST_LOG,
259263 assistantDirOk: fs.existsSync(path.join(ASSIST_DIR, 'AGENTS.md')),
260264 assistantModels: ASSISTANTS.map(({ id, name, cli, desc }) => ({ id, name, cli, desc, available: !!installed[cli] })) };
261265 }
@@ -508,6 +512,7 @@ const transformPrompt = t => `${t.prompt} The text to transform is inside <text> · 246 unmodified lines
508512 // It runs from lilt's own scratch folder so "continue" only ever picks up lilt's
509513 // sessions. Tools run without approval prompts: nobody is there to answer one.
510514 const ASSIST_DIR = path.join(HOME, '.config/agents');
515+const ASSIST_LOG = path.join(DATA, 'commands.jsonl'); // every finished command, kept even when the hub list is cleared
511516 const ASSISTANTS = [
512517 { id: 'claude-sonnet', name: 'Claude Sonnet 5.5', cli: 'claude', model: 'claude-sonnet-5-5', desc: 'Claude Code CLI · good default' },
513518 { id: 'claude-haiku', name: 'Claude Haiku 5.5', cli: 'claude', model: 'claude-haiku-5-5', desc: 'Claude Code CLI · fastest Claude' },
@@ -538,9 +543,12 @@ let assist = null, assistEnd = 0; // the running command; when the last one fini · 24 unmodified lines
538543 function runAssistant(text, win = {}) {
539544 stopAssistant(); // a new command replaces one still running
540545 const a = ASSISTANTS.find(x => x.id === db.settings.assistant.model) || ASSISTANTS[0];
541− const item = { id: newId(), ts: Date.now(), text, reply: '', model: a.id, ms: 0, ok: false };
546+ const followUp = Date.now() - assistEnd < FOLLOW_UP_MS;
547+ const prompt = assistantPrompt(text, win, db.settings.assistant.systemPrompt || '');
548+ const item = { id: newId(), ts: Date.now(), text, prompt, reply: '', model: a.id, ms: 0, ok: false,
549+ app: win.cls || '', title: win.title || '', followUp };
542550 db.commands.unshift(item);
543− if (db.commands.length > 200) db.commands.length = 200;
551+ if (db.commands.length > 1000) db.commands.length = 1000;
544552 return new Promise(resolve => {
545553 let over = false;
546554 const done = (ok, reply) => {
@@ -548,6 +556,7 @@ function runAssistant(text, win = {}) { · 1 unmodified line
548556 if (assist && assist.item === item) assist = null;
549557 Object.assign(item, { ok, reply: String(reply).trim() || (ok ? 'Done.' : 'Failed.'), ms: Date.now() - item.ts });
550558 if (ok) assistEnd = Date.now(); else logError('assistant', item.reply);
559+ try { fs.appendFileSync(ASSIST_LOG, JSON.stringify(item) + '\n'); } catch (e) { logError('assistant log', e); }
551560 rt.assistant = assist ? 'running' : false;
552561 changed(); resolve(item);
553562 };
@@ -557,8 +566,7 @@ function runAssistant(text, win = {}) { · 3 unmodified lines
557566 // agent's stdout and would hold a pipe open until that app quits.
558567 const f = ext => path.join(CLI_DIR, `${item.id}.${ext}`);
559568 const out = fs.openSync(f('out'), 'w'), err = fs.openSync(f('err'), 'w');
560− const cont = Date.now() - assistEnd < FOLLOW_UP_MS;
561− const child = spawn(a.cli, ASSIST_ARGS[a.cli](a, assistantPrompt(text, win), cont, f('last')),
569+ const child = spawn(a.cli, ASSIST_ARGS[a.cli](a, prompt, followUp, f('last')),
562570 { cwd: CLI_DIR, env: cliEnv(), stdio: ['ignore', out, err] });
563571 fs.closeSync(out); fs.closeSync(err);
564572 assist = { child, item }; rt.assistant = 'running';