jarvis-hq 0.0.4 → 0.0.6

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 CHANGED
@@ -368,7 +368,7 @@ Sessions are the core abstraction. A session is created when a `/chat` POST is r
368
368
 
369
369
  Jarvis detects which harnesses are available at startup by checking for the `claude` CLI, the `@opencode-ai/sdk` package, and the `codex` CLI. It reports the available agents at `/agents`. A bot's `model` and `permissionMode` are applied to whichever harness runs its threads.
370
370
 
371
- **Claude Code** (`claude-code`): Uses the `@anthropic-ai/claude-agent-sdk` `query()` function. Runs the `claude-opus-4-6` model in `default` permission mode. Supports `canUseTool` for per-tool permission prompts. Session transcripts are stored at `~/.claude/projects/<cwd>/<session-id>.jsonl`.
371
+ **Claude Code** (`claude-code`): Uses the `@anthropic-ai/claude-agent-sdk` `query()` function. Runs the `claude-opus-5[1m]` model (Opus 5 with the 1M-token context window) in `default` permission mode. Supports `canUseTool` for per-tool permission prompts. Session transcripts are stored at `~/.claude/projects/<cwd>/<session-id>.jsonl`.
372
372
 
373
373
  **Opencode** (`opencode`): Uses the `@opencode-ai/sdk`. Jarvis spawns an Opencode server process at startup (or connects to one already running on port 4096). Per-directory clients are maintained so sessions can be scoped to different repos simultaneously. Events are received via a persistent Opencode event stream (`client.event.subscribe()`). If the stream fails, it reconnects automatically after 2 seconds.
374
374
 
@@ -72,6 +72,16 @@ exports.html = `<!DOCTYPE html>
72
72
  .bar-title { flex: 1; min-width: 0; }
73
73
  .bar-title h2 { font-size: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
74
74
  .bar-title .sub { color: var(--muted); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
75
+ /* Context meter: how full the model's window is, in the thread's own header. */
76
+ .ctx { display: flex; align-items: center; gap: 6px; margin-top: 2px; font-size: 11px; color: var(--muted); }
77
+ .ctx-model { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
78
+ .ctx-dots { display: flex; gap: 2px; flex: none; }
79
+ .ctx-dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--border); }
80
+ .ctx-dots i.on { background: var(--muted); }
81
+ .ctx-num { flex: none; font-variant-numeric: tabular-nums; }
82
+ /* Past three quarters full the meter stops being background detail. */
83
+ .ctx.full .ctx-dots i.on { background: var(--danger); }
84
+ .ctx.full .ctx-num { color: var(--danger); }
75
85
 
76
86
  .back {
77
87
  display: inline-flex; align-items: center; gap: 5px; flex: none;
@@ -357,6 +367,20 @@ exports.html = `<!DOCTYPE html>
357
367
  color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 150px; overflow: auto;
358
368
  }
359
369
  .perm .acts { display: flex; gap: 8px; }
370
+ .ask-questions { display: flex; flex-direction: column; gap: 16px; margin-bottom: 14px; }
371
+ .ask-q { display: flex; flex-direction: column; gap: 8px; }
372
+ .ask-q-text { font-size: 13.5px; font-weight: 500; }
373
+ .ask-opts { display: flex; flex-direction: column; gap: 6px; }
374
+ .ask-opt-btn {
375
+ text-align: left; padding: 7px 12px; border-radius: 8px; font-size: 13px;
376
+ background: var(--surface-2); border: 1px solid var(--border); color: var(--text);
377
+ cursor: pointer; box-shadow: none;
378
+ }
379
+ .ask-opt-btn:hover { border-color: var(--accent); }
380
+ .ask-opt-btn.selected { background: color-mix(in srgb, var(--accent) 12%, var(--surface)); border-color: var(--accent); color: var(--text); }
381
+ .ask-opt-desc { font-size: 11.5px; color: var(--muted); margin-top: 1px; padding-left: 2px; }
382
+ .ask-other { width: 100%; padding: 7px 12px; border-radius: 8px; font-size: 13px; border: 1px solid var(--border); background: var(--surface-2); color: var(--text); outline: none; }
383
+ .ask-other:focus { border-color: var(--accent); }
360
384
 
361
385
  /* --- Composer --- */
362
386
  .composer { border-top: 1px solid var(--border); background: var(--bg); }
@@ -477,6 +501,11 @@ exports.html = `<!DOCTYPE html>
477
501
  box-shadow: var(--shadow-md); animation: rise .18s ease;
478
502
  }
479
503
  @keyframes rise { from { opacity: 0; transform: translate(-50%, 8px); } }
504
+ .toast-action {
505
+ margin-left: 10px; padding: 2px 10px; border-radius: 999px;
506
+ background: var(--bg); color: var(--text); border: none;
507
+ font-size: 13px; font-weight: 600; cursor: pointer;
508
+ }
480
509
 
481
510
  @media (max-width: 640px) {
482
511
  .wrap { padding: 0 16px; }
@@ -560,6 +589,11 @@ exports.html = `<!DOCTYPE html>
560
589
  <div class="bar-title">
561
590
  <h2 id="thread-title"></h2>
562
591
  <div class="sub" id="thread-sub"></div>
592
+ <div class="ctx" id="thread-ctx" hidden>
593
+ <span class="ctx-model" id="ctx-model"></span>
594
+ <span class="ctx-dots" id="ctx-dots"></span>
595
+ <span class="ctx-num" id="ctx-num"></span>
596
+ </div>
563
597
  </div>
564
598
  </div></div>
565
599
  <div class="messages" id="messages"><div class="wrap narrow" id="messages-inner"></div></div>
@@ -1279,6 +1313,39 @@ exports.html = `<!DOCTYPE html>
1279
1313
  load(startPath);
1280
1314
  }
1281
1315
 
1316
+ // --- Context meter ---
1317
+ // What the model is carrying into its next turn, against what it can hold.
1318
+ // Ten dots read at a glance on a phone; the exact numbers sit beside them and
1319
+ // the full sentence is in the tooltip.
1320
+
1321
+ function fmtTokens(n) {
1322
+ if (!n) return "0";
1323
+ return n < 1000 ? String(n) : Math.round(n / 1000) + "k";
1324
+ }
1325
+
1326
+ function renderContext(ctx) {
1327
+ var box = $("thread-ctx");
1328
+ if (!ctx || !ctx.used || !ctx.window) { box.hidden = true; return; }
1329
+ var pct = Math.min(100, Math.round((ctx.used / ctx.window) * 100));
1330
+ // "/1000k" already says the window is the long one, so the meter drops the
1331
+ // "(1M context)" tail that would otherwise eat the line on a phone. Plain
1332
+ // string work, not a regex: this file is a template literal, and its
1333
+ // backslashes belong to the build, not to the browser.
1334
+ var name = ctx.label || ctx.model || "";
1335
+ var tail = name.indexOf(" (1M");
1336
+ $("ctx-model").textContent = tail === -1 ? name : name.slice(0, tail);
1337
+ $("ctx-num").textContent = fmtTokens(ctx.used) + "/" + fmtTokens(ctx.window) + " (" + pct + "%)";
1338
+ var dots = $("ctx-dots");
1339
+ dots.innerHTML = "";
1340
+ var lit = Math.min(10, Math.max(1, Math.ceil(pct / 10)));
1341
+ for (var i = 0; i < 10; i++) dots.appendChild(el("i", i < lit ? "on" : null));
1342
+ box.className = "ctx" + (pct >= 75 ? " full" : "");
1343
+ box.title = (ctx.label ? ctx.label + " \u2014 " : "")
1344
+ + ctx.used.toLocaleString() + " of " + ctx.window.toLocaleString()
1345
+ + " tokens in context (" + pct + "% used)";
1346
+ box.hidden = false;
1347
+ }
1348
+
1282
1349
  // --- Thread ---
1283
1350
  function openThread(thread) {
1284
1351
  activeThread = thread;
@@ -1286,6 +1353,7 @@ exports.html = `<!DOCTYPE html>
1286
1353
  $("thread-title").textContent = thread.title;
1287
1354
  $("thread-sub").textContent = shortPath(thread.repoPath, 30);
1288
1355
  $("thread-sub").title = thread.repoPath || "";
1356
+ renderContext(thread.context);
1289
1357
  $("to-bot-label").textContent = threadBack === "jarvis" ? "Jarvis" : (activeBot ? activeBot.name : "Back");
1290
1358
  var av = $("thread-avatar");
1291
1359
  av.textContent = activeBot ? (activeBot.emoji || "\\u{1F916}") : "\\u{1F916}";
@@ -1529,7 +1597,7 @@ exports.html = `<!DOCTYPE html>
1529
1597
 
1530
1598
  // Every event type the agent emits on the turn stream.
1531
1599
  var STREAM_EVENTS = ["user_prompt", "assistant", "tool_use", "status", "permission_request", "aborted", "done", "result", "error",
1532
- "delegation", "handoff", "project_created"];
1600
+ "delegation", "handoff", "project_created", "context"];
1533
1601
 
1534
1602
  function attachStream(sid, onEvent) {
1535
1603
  closeStream();
@@ -1563,6 +1631,11 @@ exports.html = `<!DOCTYPE html>
1563
1631
  appendTool(liveBubble, data.tool_name, data.tool_input);
1564
1632
  break;
1565
1633
 
1634
+ case "context":
1635
+ if (activeThread) activeThread.context = data;
1636
+ renderContext(data);
1637
+ break;
1638
+
1566
1639
  case "status":
1567
1640
  if (state === "aborting") break;
1568
1641
  if (data.status === "thinking") setActivity("Thinking\u2026");
@@ -1677,6 +1750,7 @@ exports.html = `<!DOCTYPE html>
1677
1750
  }
1678
1751
 
1679
1752
  function renderPermission(data) {
1753
+ if (data.toolName === "AskUserQuestion") { renderAskUser(data); return; }
1680
1754
  var card = el("div", "perm");
1681
1755
  card.appendChild(el("div", "head", "Allow " + data.toolName + "?"));
1682
1756
  card.appendChild(el("pre", null, JSON.stringify(data.input, null, 2)));
@@ -1702,6 +1776,100 @@ exports.html = `<!DOCTYPE html>
1702
1776
  scrollDown();
1703
1777
  }
1704
1778
 
1779
+ function renderAskUser(data) {
1780
+ var input = data.input || {};
1781
+ var questions = Array.isArray(input.questions) ? input.questions : [];
1782
+ var answers = {}; // question text -> selected label(s)
1783
+
1784
+ var card = el("div", "perm");
1785
+ card.appendChild(el("div", "head", "Questions"));
1786
+
1787
+ var qsDiv = el("div", "ask-questions");
1788
+
1789
+ questions.forEach(function (q, qi) {
1790
+ answers[q.question] = q.multiSelect ? [] : null;
1791
+
1792
+ var qDiv = el("div", "ask-q");
1793
+ qDiv.appendChild(el("div", "ask-q-text", q.question));
1794
+
1795
+ var optsDiv = el("div", "ask-opts");
1796
+
1797
+ (q.options || []).forEach(function (opt) {
1798
+ var row = el("div");
1799
+ var btn = el("button", "ask-opt-btn", opt.label);
1800
+ btn.onclick = function () {
1801
+ if (q.multiSelect) {
1802
+ var arr = answers[q.question];
1803
+ var idx = arr.indexOf(opt.label);
1804
+ if (idx >= 0) { arr.splice(idx, 1); btn.classList.remove("selected"); }
1805
+ else { arr.push(opt.label); btn.classList.add("selected"); }
1806
+ } else {
1807
+ answers[q.question] = opt.label;
1808
+ optsDiv.querySelectorAll(".ask-opt-btn").forEach(function (b) { b.classList.remove("selected"); });
1809
+ btn.classList.add("selected");
1810
+ }
1811
+ };
1812
+ row.appendChild(btn);
1813
+ if (opt.description) row.appendChild(el("div", "ask-opt-desc", opt.description));
1814
+ optsDiv.appendChild(row);
1815
+ });
1816
+
1817
+ var otherInput = el("input");
1818
+ otherInput.type = "text";
1819
+ otherInput.placeholder = "Other\u2026";
1820
+ otherInput.className = "ask-other";
1821
+ otherInput.oninput = function () {
1822
+ var val = otherInput.value.trim();
1823
+ if (q.multiSelect) {
1824
+ // handled on submit
1825
+ } else {
1826
+ if (val) {
1827
+ answers[q.question] = val;
1828
+ optsDiv.querySelectorAll(".ask-opt-btn").forEach(function (b) { b.classList.remove("selected"); });
1829
+ } else if (!answers[q.question] || typeof answers[q.question] === "string") {
1830
+ answers[q.question] = null;
1831
+ }
1832
+ }
1833
+ };
1834
+ optsDiv.appendChild(otherInput);
1835
+ qDiv.appendChild(optsDiv);
1836
+ qsDiv.appendChild(qDiv);
1837
+ });
1838
+
1839
+ card.appendChild(qsDiv);
1840
+
1841
+ var acts = el("div", "acts");
1842
+ var submit = el("button", "btn primary", "Submit");
1843
+ submit.onclick = function () {
1844
+ // Collect "other" text inputs for multi-select questions
1845
+ var qDivs = qsDiv.querySelectorAll(".ask-q");
1846
+ questions.forEach(function (q, qi) {
1847
+ if (q.multiSelect) {
1848
+ var inp = qDivs[qi].querySelector(".ask-other");
1849
+ var val = inp ? inp.value.trim() : "";
1850
+ if (val && answers[q.question].indexOf(val) === -1) answers[q.question].push(val);
1851
+ }
1852
+ });
1853
+ submit.disabled = true;
1854
+ api("/sessions/" + encodeURIComponent(sessionId) + "/permission", {
1855
+ method: "POST",
1856
+ body: { toolUseID: data.toolUseID, answers: answers }
1857
+ }).then(function () {
1858
+ var summary = questions.map(function (q) {
1859
+ var a = answers[q.question];
1860
+ return Array.isArray(a) ? a.join(", ") : (a || "\u2014");
1861
+ }).join(" \u00b7 ");
1862
+ card.replaceWith(el("div", "note", summary));
1863
+ pendingPerms = Math.max(0, pendingPerms - 1);
1864
+ if (!pendingPerms && state === "running") setActivity("Thinking\u2026");
1865
+ }).catch(showError);
1866
+ };
1867
+ acts.appendChild(submit);
1868
+ card.appendChild(acts);
1869
+ $("messages-inner").appendChild(card);
1870
+ scrollDown();
1871
+ }
1872
+
1705
1873
  function finishTurn() {
1706
1874
  closeStream();
1707
1875
  liveBubble = null;
@@ -1870,6 +2038,70 @@ exports.html = `<!DOCTYPE html>
1870
2038
  });
1871
2039
  }
1872
2040
 
2041
+ // IndexedDB helpers — save the raw audio before the API call so a failed
2042
+ // transcription can be retried without re-recording.
2043
+ var _dictDb = null;
2044
+ function openDictationDb() {
2045
+ if (_dictDb) return Promise.resolve(_dictDb);
2046
+ return new Promise(function (resolve, reject) {
2047
+ var req = indexedDB.open("dictation", 1);
2048
+ req.onupgradeneeded = function () { req.result.createObjectStore("pending"); };
2049
+ req.onsuccess = function () { _dictDb = req.result; resolve(_dictDb); };
2050
+ req.onerror = function () { reject(req.error); };
2051
+ });
2052
+ }
2053
+ function savePendingAudio(audio, mimeType) {
2054
+ return openDictationDb().then(function (db) {
2055
+ return new Promise(function (resolve, reject) {
2056
+ var tx = db.transaction("pending", "readwrite");
2057
+ tx.objectStore("pending").put({ audio: audio, mimeType: mimeType }, "clip");
2058
+ tx.oncomplete = resolve;
2059
+ tx.onerror = function () { reject(tx.error); };
2060
+ });
2061
+ }).catch(function () {}); // best-effort
2062
+ }
2063
+ function deletePendingAudio() {
2064
+ return openDictationDb().then(function (db) {
2065
+ return new Promise(function (resolve) {
2066
+ var tx = db.transaction("pending", "readwrite");
2067
+ tx.objectStore("pending").delete("clip");
2068
+ tx.oncomplete = resolve;
2069
+ tx.onerror = resolve;
2070
+ });
2071
+ }).catch(function () {});
2072
+ }
2073
+ function loadPendingAudio() {
2074
+ return openDictationDb().then(function (db) {
2075
+ return new Promise(function (resolve) {
2076
+ var req = db.transaction("pending", "readonly").objectStore("pending").get("clip");
2077
+ req.onsuccess = function () { resolve(req.result || null); };
2078
+ req.onerror = function () { resolve(null); };
2079
+ });
2080
+ }).catch(function () { return null; });
2081
+ }
2082
+
2083
+ function sendDictation(audio, mimeType, btn, textarea) {
2084
+ return api("/dictate", { method: "POST", body: { audio: audio, mimeType: mimeType } })
2085
+ .then(function (d) {
2086
+ deletePendingAudio();
2087
+ insertDictation(textarea, d.text);
2088
+ }).catch(function (err) {
2089
+ toast(err.message || "Dictation failed", {
2090
+ label: "Retry",
2091
+ fn: function () {
2092
+ loadPendingAudio().then(function (saved) {
2093
+ if (!saved) { toast("No saved clip to retry"); return; }
2094
+ btn.classList.add("busy");
2095
+ sendDictation(saved.audio, saved.mimeType, btn, textarea)
2096
+ .then(function () { btn.classList.remove("busy"); });
2097
+ });
2098
+ },
2099
+ });
2100
+ }).then(function () {
2101
+ btn.classList.remove("busy");
2102
+ });
2103
+ }
2104
+
1873
2105
  function stopRecording() {
1874
2106
  var rec = recording;
1875
2107
  if (!rec) return;
@@ -1887,6 +2119,7 @@ exports.html = `<!DOCTYPE html>
1887
2119
  if (!window.isSecureContext) {
1888
2120
  toast("Dictation needs https or localhost"); return;
1889
2121
  }
2122
+ deletePendingAudio();
1890
2123
  navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
1891
2124
  var mime = recordingMime();
1892
2125
  var recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
@@ -1897,12 +2130,11 @@ exports.html = `<!DOCTYPE html>
1897
2130
  var blob = new Blob(chunks, { type: type });
1898
2131
  if (blob.size < 1000) { btn.classList.remove("busy"); return; } // tapped twice by accident
1899
2132
  blobToBase64(blob).then(function (audio) {
1900
- return api("/dictate", { method: "POST", body: { audio: audio, mimeType: type } });
1901
- }).then(function (d) {
1902
- insertDictation(textarea, d.text);
2133
+ return savePendingAudio(audio, type).then(function () {
2134
+ return sendDictation(audio, type, btn, textarea);
2135
+ });
1903
2136
  }).catch(function (err) {
1904
2137
  toast(err.message || "Dictation failed");
1905
- }).then(function () {
1906
2138
  btn.classList.remove("busy");
1907
2139
  });
1908
2140
  };
@@ -1923,12 +2155,19 @@ exports.html = `<!DOCTYPE html>
1923
2155
  };
1924
2156
  }
1925
2157
 
1926
- function toast(text) {
2158
+ function toast(text, action) {
1927
2159
  var old = document.querySelector(".toast");
1928
2160
  if (old) old.remove();
1929
2161
  var t = el("div", "toast", text);
2162
+ if (action) {
2163
+ var btn = document.createElement("button");
2164
+ btn.className = "toast-action";
2165
+ btn.textContent = action.label;
2166
+ btn.onclick = function () { t.remove(); action.fn(); };
2167
+ t.appendChild(btn);
2168
+ }
1930
2169
  document.body.appendChild(t);
1931
- setTimeout(function () { t.remove(); }, 2400);
2170
+ setTimeout(function () { if (t.parentNode) t.remove(); }, action ? 7000 : 2400);
1932
2171
  }
1933
2172
 
1934
2173
  function copyText(text) {
@@ -2122,7 +2361,7 @@ exports.html = `<!DOCTYPE html>
2122
2361
 
2123
2362
  var model = field("Model", "optional", el("input"));
2124
2363
  model.value = bot ? (bot.model || "") : "";
2125
- model.placeholder = "claude-sonnet-4-6";
2364
+ model.placeholder = "claude-opus-5[1m]";
2126
2365
 
2127
2366
  var permissionMode = field("Permissions", null, el("select"));
2128
2367
  [["ask-permissions", "Ask before each tool"], ["auto-approve", "Auto-approve tools"], ["plan", "Plan only (no edits)"]]
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.contextWindowFor = contextWindowFor;
4
+ exports.tokensInContext = tokensInContext;
5
+ exports.modelLabel = modelLabel;
6
+ exports.contextUsage = contextUsage;
7
+ const MILLION = 1000000;
8
+ /** What Claude Code assumes for any model that does not ask for the long window. */
9
+ const DEFAULT_WINDOW = 200000;
10
+ /** The [1m] suffix is how the SDK is told to budget a 1M window; mirror that rule. */
11
+ function contextWindowFor(model) {
12
+ return model?.includes("[1m]") ? MILLION : DEFAULT_WINDOW;
13
+ }
14
+ /** Everything the next request resends, from one message's usage block. */
15
+ function tokensInContext(usage) {
16
+ if (!usage)
17
+ return 0;
18
+ const n = (v) => (typeof v === "number" && isFinite(v) && v > 0 ? v : 0);
19
+ return n(usage.input_tokens)
20
+ + n(usage.cache_read_input_tokens)
21
+ + n(usage.cache_creation_input_tokens)
22
+ + n(usage.output_tokens);
23
+ }
24
+ /**
25
+ * "claude-opus-5[1m]" -> "Opus 5 (1M context)", "claude-sonnet-4-6" ->
26
+ * "Sonnet 4.6". Derived rather than looked up, so a model released tomorrow
27
+ * still reads properly.
28
+ */
29
+ function modelLabel(model) {
30
+ if (!model)
31
+ return "";
32
+ const long = model.includes("[1m]");
33
+ const parts = model.replace(/\[1m\]/g, "").replace(/^claude-/, "").split("-").filter(Boolean);
34
+ const family = parts.shift() ?? "";
35
+ const name = family.charAt(0).toUpperCase() + family.slice(1);
36
+ const version = parts.join(".");
37
+ return `${[name, version].filter(Boolean).join(" ")}${long ? " (1M context)" : ""}`;
38
+ }
39
+ function contextUsage(used, model, window) {
40
+ return {
41
+ used,
42
+ window: window && window > 0 ? window : contextWindowFor(model),
43
+ model: model ?? "",
44
+ label: modelLabel(model),
45
+ };
46
+ }
package/dist/dictation.js CHANGED
@@ -21,15 +21,17 @@ const OPENAI_URL = "https://api.openai.com/v1/chat/completions";
21
21
  const CLEANUP_MODEL = "gpt-4o-mini";
22
22
  // Roughly two minutes of opus; anything longer is a mistake, not a message.
23
23
  const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
24
- const CLEANUP_PROMPT = `You are an intent extraction engine for a voice assistant.
25
- The user will provide a messy, unedited voice transcript containing filler words, pauses, and self-corrections.
24
+ const CLEANUP_PROMPT = `You are a voice transcription cleanup assistant.
25
+ The user will provide a raw voice transcript. Your job is light editing only — preserve their exact words and phrasing as much as possible.
26
26
 
27
27
  Your task:
28
- 1. Analyze the spoken words to find the user's ultimate intent.
29
- 2. Completely remove filler words ("uh", "um", "like") and resolve self-corrections (e.g., "set a meeting for 5, no, make it 6 PM" becomes "6 PM").
30
- 3. Output ONLY the final, polished instructions. Do not include introductory text, explanations, or quotes.
28
+ 1. Remove filler words ("uh", "um", "er", "like") and false starts.
29
+ 2. If the user spells out a word (e.g., "t-h-r-e-s-h-o-l-d"), replace the previous misspelled or unclear attempt with the correctly spelled word and drop the spelling itself.
30
+ 3. Resolve simple self-corrections (e.g., "delete the file, actually remove the folder" → "remove the folder").
31
+ 4. Apply light markdown formatting where it clearly fits: wrap code, file names, variable names, and commands in backticks; use a bullet list if the user enumerates multiple items.
32
+ 5. Output ONLY the cleaned transcript. No explanations, no quotes, no commentary.
31
33
 
32
- Keep the user's wording and voice where it is already clear; do not summarize, expand, or answer the request.`;
34
+ Do not paraphrase, summarize, reorder, or interpret — just clean up the words they said.`;
33
35
  function dictationConfigured() {
34
36
  return {
35
37
  deepgram: Boolean(process.env.DEEPGRAM_API_KEY),
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.JARVIS_TOOL_NAMES = exports.JARVIS_MCP_NAME = void 0;
4
4
  exports.buildJarvisTools = buildJarvisTools;
5
5
  const claude_agent_sdk_1 = require("@anthropic-ai/claude-agent-sdk");
6
+ const child_process_1 = require("child_process");
7
+ const util_1 = require("util");
6
8
  const zod_1 = require("zod");
7
9
  const server_common_1 = require("./server-common");
8
10
  const bot_store_1 = require("./bot-store");
@@ -67,6 +69,32 @@ function buildJarvisTools(store, workspaceCwd) {
67
69
  return fail(err?.message ?? "Could not update the project");
68
70
  }
69
71
  }),
72
+ (0, claude_agent_sdk_1.tool)("find_repos", "Search the user's GitHub for a repository: their own repos and every organisation they belong to. Use it when the user names a project you do not have and no workspace folder matches, before asking them for a URL — then pass the clone URL to create_project.", {
73
+ query: zod_1.z.string().optional().describe("Words to match against the repo name and description, e.g. 'relay' or 'expo client'. Omit to list everything."),
74
+ owner: zod_1.z.string().optional().describe("Restrict to one user or organisation login. Omit to search the user's account and all their orgs."),
75
+ limit: zod_1.z.number().optional().describe("Maximum repos to return (default 30)"),
76
+ }, async ({ query, owner, limit }) => {
77
+ try {
78
+ const { repos, searched, warnings } = await findRepos({ query, owner, limit: limit ?? 30 });
79
+ const note = warnings.length ? { incomplete_results: warnings, tell_the_user: "Say this to the user; it is something only they can fix." } : {};
80
+ if (!repos.length) {
81
+ return text(JSON.stringify({
82
+ repos: [],
83
+ searched_owners: searched,
84
+ ...note,
85
+ next: `Nothing on GitHub matches ${query ? `"${query}"` : "that"}${owner ? ` under ${owner}` : ""}. Report any incomplete_results above, then ask the user for the repo URL.`,
86
+ }, null, 2));
87
+ }
88
+ return text(JSON.stringify({ repos, searched_owners: searched, ...note }, null, 2));
89
+ }
90
+ catch (err) {
91
+ if (err instanceof GhSetupError) {
92
+ (0, server_common_1.emitEvent)(store, "github_unavailable", { kind: err.kind, message: err.message });
93
+ return fail(`GITHUB IS NOT SET UP ON THIS MACHINE. Tell the user exactly this, including the command, and do not ask them for a repo URL until you have:\n\n${err.message}`);
94
+ }
95
+ return fail(`${err?.message ?? "Could not reach GitHub"}. Tell the user the search failed and why.`);
96
+ }
97
+ }),
70
98
  (0, claude_agent_sdk_1.tool)("delegate", "Give a project's agent a bounded task and wait for its report, so you can combine it with other things. Blocks until the agent finishes (seconds to many minutes); call it for several projects in parallel when a request spans projects. Not for a request about a single project, however small — that is a handoff, so the user's follow-ups land in the project.", {
71
99
  project: zod_1.z.string().describe("Project name or slug"),
72
100
  task: zod_1.z.string().describe("The task, self-contained: what to do, what to report back, and any constraints such as 'do not merge'"),
@@ -121,7 +149,7 @@ function buildJarvisTools(store, workspaceCwd) {
121
149
  });
122
150
  }
123
151
  /** Tool names as Claude Code sees them, for the allowed-tools list. */
124
- exports.JARVIS_TOOL_NAMES = ["list_projects", "read_project", "create_project", "update_project", "delegate", "handoff"]
152
+ exports.JARVIS_TOOL_NAMES = ["list_projects", "read_project", "create_project", "update_project", "find_repos", "delegate", "handoff"]
125
153
  .map((t) => `mcp__${exports.JARVIS_MCP_NAME}__${t}`);
126
154
  function summary(p) {
127
155
  return {
@@ -141,3 +169,156 @@ function titleFor(text) {
141
169
  return undefined;
142
170
  return line.length > 60 ? line.slice(0, 57).replace(/\s+\S*$/, "") + "…" : line;
143
171
  }
172
+ // --- GitHub ---
173
+ // Jarvis has no shell, so `gh` is exposed as one read-only tool rather than as
174
+ // a command line: fixed subcommands, arguments passed as an argv array (never
175
+ // through a shell), and only what is needed to turn a name the user said into
176
+ // a repository to clone.
177
+ //
178
+ // gh lives on the machine, not in the conversation, so when it is missing,
179
+ // logged out or short a scope only the user can fix it. Those cases are
180
+ // separated from ordinary failures and carry the exact command to run, which
181
+ // find_repos tells Jarvis to pass on verbatim rather than quietly falling back
182
+ // to "what is the repo URL?".
183
+ const run = (0, util_1.promisify)(child_process_1.execFile);
184
+ /** A problem with the machine's gh install that the user has to fix. */
185
+ class GhSetupError extends Error {
186
+ constructor(kind, message) {
187
+ super(message);
188
+ this.kind = kind;
189
+ }
190
+ }
191
+ const INSTALL_HINT = process.platform === "darwin"
192
+ ? "install it with `brew install gh`"
193
+ : process.platform === "win32"
194
+ ? "install it with `winget install --id GitHub.cli`"
195
+ : "install it from https://cli.github.com (on Debian/Ubuntu: `sudo apt install gh`)";
196
+ /**
197
+ * Confirms gh is installed and logged in before any real call, so a setup
198
+ * problem is reported once, precisely, instead of as a puzzling empty search.
199
+ * Successful results are cached; a failure is always re-checked, so the user
200
+ * can fix it and retry immediately.
201
+ */
202
+ let authCache;
203
+ async function ghAuth() {
204
+ if (authCache && Date.now() - authCache.at < 10 * 60 * 1000)
205
+ return authCache;
206
+ let out;
207
+ try {
208
+ const r = await run("gh", ["auth", "status"], { timeout: 20000 });
209
+ out = r.stdout + r.stderr;
210
+ }
211
+ catch (err) {
212
+ if (err?.code === "ENOENT") {
213
+ throw new GhSetupError("missing", `The GitHub CLI (gh) is not installed on the machine running Jarvis, so I cannot search GitHub. To fix it, ${INSTALL_HINT}, then run \`gh auth login\`.`);
214
+ }
215
+ const detail = String(err?.stderr || err?.stdout || err?.message || "").trim();
216
+ if (/not logged|no accounts|gh auth login|authentication/i.test(detail) || typeof err?.code === "number") {
217
+ throw new GhSetupError("auth", "The GitHub CLI is installed but not logged in, so I cannot search GitHub. To fix it, run `gh auth login` on the machine running Jarvis (or set a GH_TOKEN with `repo` and `read:org` scopes) and ask me again.");
218
+ }
219
+ throw new GhSetupError("auth", `Could not check the GitHub CLI's login: ${firstLine(detail) || "unknown error"}. Try \`gh auth status\` on the machine running Jarvis.`);
220
+ }
221
+ const login = /account (\S+)/.exec(out)?.[1] ?? "";
222
+ const scopes = Array.from(out.matchAll(/'([^']+)'/g)).map((m) => m[1]);
223
+ if (!login) {
224
+ // gh exited 0 but named no account: a token with no user, or an unexpected format.
225
+ const me = await ghRaw(["api", "user", "--jq", ".login"]).catch(() => "");
226
+ if (!me.trim())
227
+ throw new GhSetupError("auth", "The GitHub CLI is not logged in to an account I can use. Run `gh auth login` on the machine running Jarvis and ask me again.");
228
+ authCache = { at: Date.now(), login: me.trim(), scopes };
229
+ return authCache;
230
+ }
231
+ authCache = { at: Date.now(), login, scopes };
232
+ return authCache;
233
+ }
234
+ /** The logins to search: the account itself plus every org it belongs to. */
235
+ let ownersCache;
236
+ async function accountOwners() {
237
+ if (ownersCache && Date.now() - ownersCache.at < 10 * 60 * 1000)
238
+ return ownersCache;
239
+ const { login, scopes } = await ghAuth();
240
+ const warnings = [];
241
+ let orgs = [];
242
+ try {
243
+ orgs = (await ghRaw(["api", "user/orgs", "--jq", ".[].login"])).split("\n").map((l) => l.trim()).filter(Boolean);
244
+ }
245
+ catch (err) {
246
+ warnings.push(`I could not list your GitHub organisations, so I only searched your personal repositories (${firstLine(err?.message) || "unknown error"}). Try \`gh auth status\` on the machine running Jarvis.`);
247
+ }
248
+ // Without the read:org scope the orgs endpoint does not fail — it quietly
249
+ // returns only public memberships, so a private org's repos look like they
250
+ // do not exist. Say so whenever the scope is absent.
251
+ if (scopes.length && !scopes.includes("read:org")) {
252
+ warnings.push(`The GitHub CLI's token has no \`read:org\` scope, so I can only see organisations you are a public member of${orgs.length ? ` (${orgs.join(", ")})` : ""} — private ones are invisible to me. To fix it, run \`gh auth refresh -s read:org\` on the machine running Jarvis.`);
253
+ }
254
+ ownersCache = { at: Date.now(), owners: [login, ...orgs], warnings };
255
+ return ownersCache;
256
+ }
257
+ async function findRepos({ query, owner, limit }) {
258
+ const warnings = [];
259
+ let owners;
260
+ if (owner) {
261
+ await ghAuth();
262
+ owners = [owner];
263
+ }
264
+ else {
265
+ const found = await accountOwners();
266
+ owners = found.owners;
267
+ warnings.push(...found.warnings);
268
+ }
269
+ const perOwner = Math.max(limit, 100);
270
+ const searched = [];
271
+ const lists = await Promise.all(owners.map(async (o) => {
272
+ try {
273
+ const out = await ghRaw(["repo", "list", o, "--limit", String(perOwner), "--json", "nameWithOwner,description,url,isPrivate,primaryLanguage,updatedAt"]);
274
+ searched.push(o);
275
+ return JSON.parse(out);
276
+ }
277
+ catch (err) {
278
+ // One unreadable org should not sink the whole search, but the user
279
+ // should know their results are incomplete and why.
280
+ warnings.push(`Could not list repositories for "${o}": ${firstLine(err?.message) || "unknown error"}.`);
281
+ return [];
282
+ }
283
+ }));
284
+ const terms = (query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
285
+ const repos = lists
286
+ .flat()
287
+ .map((r) => ({
288
+ nameWithOwner: r.nameWithOwner,
289
+ description: r.description ?? "",
290
+ url: r.url,
291
+ isPrivate: !!r.isPrivate,
292
+ language: r.primaryLanguage?.name ?? null,
293
+ updatedAt: r.updatedAt,
294
+ }))
295
+ .filter((r) => terms.every((t) => `${r.nameWithOwner} ${r.description}`.toLowerCase().includes(t)))
296
+ .sort((a, b) => score(b, terms) - score(a, terms) || b.updatedAt.localeCompare(a.updatedAt))
297
+ .slice(0, limit);
298
+ return { repos, searched, warnings };
299
+ }
300
+ /** A hit in the repo name beats one that only matched the description. */
301
+ function score(r, terms) {
302
+ const name = r.nameWithOwner.split("/")[1]?.toLowerCase() ?? "";
303
+ return terms.filter((t) => name.includes(t)).length;
304
+ }
305
+ /** One gh call. Setup problems are classified; everything else is the caller's to handle. */
306
+ async function ghRaw(args) {
307
+ try {
308
+ const { stdout } = await run("gh", args, { maxBuffer: 8 * 1024 * 1024, timeout: 30000 });
309
+ return stdout;
310
+ }
311
+ catch (err) {
312
+ if (err?.code === "ENOENT")
313
+ throw new GhSetupError("missing", `The GitHub CLI (gh) is not installed on the machine running Jarvis. To fix it, ${INSTALL_HINT}, then run \`gh auth login\`.`);
314
+ const detail = firstLine(String(err?.stderr || err?.message || ""));
315
+ if (/not logged|gh auth login|bad credentials|401/i.test(detail)) {
316
+ authCache = undefined;
317
+ throw new GhSetupError("auth", "The GitHub CLI's login has expired or was revoked. To fix it, run `gh auth login` on the machine running Jarvis and ask me again.");
318
+ }
319
+ throw new Error(`gh ${args[0]} ${args[1] ?? ""}`.trim() + ` failed: ${detail || "unknown error"}`);
320
+ }
321
+ }
322
+ function firstLine(s) {
323
+ return String(s ?? "").trim().split("\n")[0].trim();
324
+ }