jarvis-hq 0.0.3
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/LICENSE +21 -0
- package/README.md +484 -0
- package/dist/bot-routes.js +196 -0
- package/dist/bot-store.js +297 -0
- package/dist/client-html.js +2241 -0
- package/dist/client-vendor.js +32 -0
- package/dist/client.js +118 -0
- package/dist/dictation.js +128 -0
- package/dist/env-file.js +79 -0
- package/dist/index.js +41 -0
- package/dist/jarvis-tools.js +143 -0
- package/dist/jarvis.js +341 -0
- package/dist/ls.js +46 -0
- package/dist/progress.js +33 -0
- package/dist/relay-client.js +216 -0
- package/dist/relay-types.js +2 -0
- package/dist/server-common.js +663 -0
- package/dist/server.js +530 -0
- package/dist/start-claude-code.js +557 -0
- package/dist/start-codex.js +886 -0
- package/dist/start-opencode.js +452 -0
- package/dist/start.js +742 -0
- package/dist/sync.js +15 -0
- package/dist/trophy.js +7 -0
- package/dist/turns.js +91 -0
- package/dist/workspace.js +152 -0
- package/package.json +55 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.vendorScripts = vendorScripts;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
/** marked (GFM markdown) + DOMPurify (sanitiser), inlined into the page.
|
|
7
|
+
* The hub is usually reached over a LAN or a tunnel, often with no internet,
|
|
8
|
+
* so a CDN tag would leave the transcript unformatted. Read once, cached. */
|
|
9
|
+
let cached;
|
|
10
|
+
/** Reads a file sitting next to a package entry point. Resolving through an
|
|
11
|
+
* entry rather than a bare path keeps this working from anywhere npm chose to
|
|
12
|
+
* hoist the dependency; dompurify doesn't export its package.json, so its
|
|
13
|
+
* main entry is the anchor. */
|
|
14
|
+
function readVendor(entry, file) {
|
|
15
|
+
return (0, fs_1.readFileSync)((0, path_1.join)((0, path_1.dirname)(require.resolve(entry)), file), "utf8");
|
|
16
|
+
}
|
|
17
|
+
function vendorScripts() {
|
|
18
|
+
if (cached !== undefined)
|
|
19
|
+
return cached;
|
|
20
|
+
try {
|
|
21
|
+
const marked = readVendor("marked/package.json", "lib/marked.umd.js");
|
|
22
|
+
const purify = readVendor("dompurify", "purify.min.js");
|
|
23
|
+
cached = `<script>${marked}</script>\n<script>${purify}</script>`;
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
// Missing deps shouldn't take the hub down — the client falls back to
|
|
27
|
+
// plain text when marked/DOMPurify aren't on the page.
|
|
28
|
+
console.warn(` Markdown renderer unavailable (${err?.message ?? err}); showing plain text.`);
|
|
29
|
+
cached = "";
|
|
30
|
+
}
|
|
31
|
+
return cached;
|
|
32
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.client = client;
|
|
7
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
8
|
+
const node_os_1 = require("node:os");
|
|
9
|
+
const node_child_process_1 = require("node:child_process");
|
|
10
|
+
const qrcode_terminal_1 = __importDefault(require("qrcode-terminal"));
|
|
11
|
+
const client_html_1 = require("./client-html");
|
|
12
|
+
const PORT = 3001;
|
|
13
|
+
function getLocalIP() {
|
|
14
|
+
const nets = (0, node_os_1.networkInterfaces)();
|
|
15
|
+
for (const name of Object.keys(nets)) {
|
|
16
|
+
for (const net of nets[name]) {
|
|
17
|
+
if (net.family === "IPv4" && !net.internal) {
|
|
18
|
+
return net.address;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return "localhost";
|
|
23
|
+
}
|
|
24
|
+
function getTailscaleIP() {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
(0, node_child_process_1.execFile)("tailscale", ["ip", "-4"], { timeout: 2000 }, (err, stdout) => {
|
|
27
|
+
if (err)
|
|
28
|
+
return resolve(null);
|
|
29
|
+
const ip = stdout.trim().split("\n")[0];
|
|
30
|
+
resolve(ip || null);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function client() {
|
|
35
|
+
const server = node_http_1.default.createServer((_req, res) => {
|
|
36
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
37
|
+
res.end(client_html_1.html);
|
|
38
|
+
});
|
|
39
|
+
server.listen(PORT, async () => {
|
|
40
|
+
const ip = getLocalIP();
|
|
41
|
+
const tsIP = await getTailscaleIP();
|
|
42
|
+
const urls = [
|
|
43
|
+
{ label: "My Local Network", url: `http://${ip}:${PORT}` },
|
|
44
|
+
];
|
|
45
|
+
if (tsIP) {
|
|
46
|
+
urls.push({ label: "Tailscale", url: `http://${tsIP}:${PORT}` });
|
|
47
|
+
}
|
|
48
|
+
// Pre-generate all QR codes
|
|
49
|
+
const qrCodes = await Promise.all(urls.map((u) => new Promise((resolve) => {
|
|
50
|
+
qrcode_terminal_1.default.generate(u.url, { small: true }, (code) => {
|
|
51
|
+
resolve(code.trimEnd());
|
|
52
|
+
});
|
|
53
|
+
})));
|
|
54
|
+
// Print header
|
|
55
|
+
const headerLines = [
|
|
56
|
+
"",
|
|
57
|
+
" \x1b[1mgrass client\x1b[0m",
|
|
58
|
+
"",
|
|
59
|
+
...urls.map((u) => ` ${u.label.padEnd(9)} ${u.url}`),
|
|
60
|
+
"",
|
|
61
|
+
];
|
|
62
|
+
for (const line of headerLines)
|
|
63
|
+
console.log(line);
|
|
64
|
+
// QR cycling state
|
|
65
|
+
let qrIdx = urls.length > 1 ? 1 : 0;
|
|
66
|
+
let qrLineCount = 0;
|
|
67
|
+
function renderQR() {
|
|
68
|
+
const hint = urls.length > 1 ? " (\u2191\u2193 to switch)" : "";
|
|
69
|
+
const label = ` QR: ${urls[qrIdx].label}${hint}`;
|
|
70
|
+
const block = `${label}\n${qrCodes[qrIdx]}`;
|
|
71
|
+
const lines = block.split("\n");
|
|
72
|
+
// Move cursor up to overwrite previous QR block
|
|
73
|
+
if (qrLineCount > 0) {
|
|
74
|
+
process.stdout.write(`\x1b[${qrLineCount}A`);
|
|
75
|
+
}
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
process.stdout.write(`\r${line}\x1b[K\n`);
|
|
78
|
+
}
|
|
79
|
+
// Clear leftover lines if previous block was taller
|
|
80
|
+
for (let i = lines.length; i < qrLineCount; i++) {
|
|
81
|
+
process.stdout.write(`\r\x1b[K\n`);
|
|
82
|
+
}
|
|
83
|
+
qrLineCount = Math.max(lines.length, qrLineCount);
|
|
84
|
+
}
|
|
85
|
+
renderQR();
|
|
86
|
+
// Listen for arrow keys
|
|
87
|
+
if (process.stdin.isTTY) {
|
|
88
|
+
process.stdin.setRawMode(true);
|
|
89
|
+
process.stdin.resume();
|
|
90
|
+
process.stdin.on("data", (key) => {
|
|
91
|
+
const s = key.toString();
|
|
92
|
+
if (s === "\x03") {
|
|
93
|
+
shutdown();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (s === "\x1b[A") {
|
|
97
|
+
qrIdx = (qrIdx - 1 + urls.length) % urls.length;
|
|
98
|
+
renderQR();
|
|
99
|
+
}
|
|
100
|
+
else if (s === "\x1b[B") {
|
|
101
|
+
qrIdx = (qrIdx + 1) % urls.length;
|
|
102
|
+
renderQR();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
const shutdown = () => {
|
|
108
|
+
console.log("\nShutting down client...");
|
|
109
|
+
if (process.stdin.isTTY) {
|
|
110
|
+
process.stdin.setRawMode(false);
|
|
111
|
+
}
|
|
112
|
+
server.close(() => {
|
|
113
|
+
process.exit(0);
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
process.on("SIGINT", shutdown);
|
|
117
|
+
process.on("SIGTERM", shutdown);
|
|
118
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dictationConfigured = dictationConfigured;
|
|
4
|
+
exports.handleDictationRoutes = handleDictationRoutes;
|
|
5
|
+
const server_common_1 = require("./server-common");
|
|
6
|
+
/**
|
|
7
|
+
* Tap-to-talk dictation for the composer boxes.
|
|
8
|
+
*
|
|
9
|
+
* The browser records a clip and POSTs it here; the clip goes to Deepgram for
|
|
10
|
+
* the raw transcript, and the transcript goes to gpt-4o-mini to strip filler
|
|
11
|
+
* words and resolve self-corrections. Only the polished text goes back — the
|
|
12
|
+
* raw dictation is never shown.
|
|
13
|
+
*
|
|
14
|
+
* Audio rides in as base64 inside a JSON body rather than a binary upload
|
|
15
|
+
* because relay mode carries request bodies as strings.
|
|
16
|
+
*
|
|
17
|
+
* Needs DEEPGRAM_API_KEY and OPENAI_API_KEY in the environment.
|
|
18
|
+
*/
|
|
19
|
+
const DEEPGRAM_URL = "https://api.deepgram.com/v1/listen?model=nova-2-general&smart_format=true";
|
|
20
|
+
const OPENAI_URL = "https://api.openai.com/v1/chat/completions";
|
|
21
|
+
const CLEANUP_MODEL = "gpt-4o-mini";
|
|
22
|
+
// Roughly two minutes of opus; anything longer is a mistake, not a message.
|
|
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.
|
|
26
|
+
|
|
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.
|
|
31
|
+
|
|
32
|
+
Keep the user's wording and voice where it is already clear; do not summarize, expand, or answer the request.`;
|
|
33
|
+
function dictationConfigured() {
|
|
34
|
+
return {
|
|
35
|
+
deepgram: Boolean(process.env.DEEPGRAM_API_KEY),
|
|
36
|
+
openai: Boolean(process.env.OPENAI_API_KEY),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function transcribe(audio, mimeType) {
|
|
40
|
+
const res = await fetch(DEEPGRAM_URL, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: {
|
|
43
|
+
Authorization: `Token ${process.env.DEEPGRAM_API_KEY}`,
|
|
44
|
+
"Content-Type": mimeType,
|
|
45
|
+
},
|
|
46
|
+
body: new Uint8Array(audio),
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const detail = await res.text().catch(() => "");
|
|
50
|
+
throw new Error(`Deepgram ${res.status}: ${detail.slice(0, 200)}`);
|
|
51
|
+
}
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
return data?.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? "";
|
|
54
|
+
}
|
|
55
|
+
async function cleanUp(transcript) {
|
|
56
|
+
const res = await fetch(OPENAI_URL, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: {
|
|
59
|
+
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
model: CLEANUP_MODEL,
|
|
64
|
+
temperature: 0,
|
|
65
|
+
messages: [
|
|
66
|
+
{ role: "system", content: CLEANUP_PROMPT },
|
|
67
|
+
{ role: "user", content: transcript },
|
|
68
|
+
],
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
const detail = await res.text().catch(() => "");
|
|
73
|
+
throw new Error(`OpenAI ${res.status}: ${detail.slice(0, 200)}`);
|
|
74
|
+
}
|
|
75
|
+
const data = await res.json();
|
|
76
|
+
return (data?.choices?.[0]?.message?.content ?? "").trim();
|
|
77
|
+
}
|
|
78
|
+
/** Returns true when the request was handled. */
|
|
79
|
+
async function handleDictationRoutes(req, res) {
|
|
80
|
+
const path = (req.url ?? "/").split("?")[0];
|
|
81
|
+
const method = req.method ?? "GET";
|
|
82
|
+
if (method === "GET" && path === "/dictate/status") {
|
|
83
|
+
(0, server_common_1.jsonOk)(res, dictationConfigured());
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (method === "POST" && path === "/dictate") {
|
|
87
|
+
const keys = dictationConfigured();
|
|
88
|
+
if (!keys.deepgram || !keys.openai) {
|
|
89
|
+
const missing = [!keys.deepgram && "DEEPGRAM_API_KEY", !keys.openai && "OPENAI_API_KEY"].filter(Boolean);
|
|
90
|
+
(0, server_common_1.jsonError)(res, 503, `Dictation is not configured: set ${missing.join(" and ")}`);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
const body = await (0, server_common_1.readBody)(req);
|
|
94
|
+
const { audio, mimeType } = body ?? {};
|
|
95
|
+
if (typeof audio !== "string" || !audio) {
|
|
96
|
+
(0, server_common_1.jsonError)(res, 400, "audio is required");
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (typeof mimeType !== "string" || !mimeType.startsWith("audio/")) {
|
|
100
|
+
(0, server_common_1.jsonError)(res, 400, "mimeType must be an audio type");
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
const bytes = Buffer.from(audio, "base64");
|
|
104
|
+
if (bytes.length === 0) {
|
|
105
|
+
(0, server_common_1.jsonError)(res, 400, "audio is empty");
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (bytes.length > MAX_AUDIO_BYTES) {
|
|
109
|
+
(0, server_common_1.jsonError)(res, 413, "recording is too long");
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const transcript = await transcribe(bytes, mimeType);
|
|
114
|
+
if (!transcript.trim()) {
|
|
115
|
+
(0, server_common_1.jsonOk)(res, { text: "" });
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
const text = await cleanUp(transcript);
|
|
119
|
+
(0, server_common_1.jsonOk)(res, { text });
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
console.error(`[dictate] ${err?.message ?? err}`);
|
|
123
|
+
(0, server_common_1.jsonError)(res, 502, err?.message ?? "Dictation failed");
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
package/dist/env-file.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.envFileCandidates = envFileCandidates;
|
|
4
|
+
exports.loadEnvFiles = loadEnvFiles;
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const os_1 = require("os");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
/**
|
|
9
|
+
* Loads API keys from an env file so they don't have to be exported by hand on
|
|
10
|
+
* every run. Keys already present in the real environment always win, so an
|
|
11
|
+
* explicit `FOO=bar jarvis start` still overrides the file.
|
|
12
|
+
*
|
|
13
|
+
* Files are read in order, first definition of a name winning:
|
|
14
|
+
*
|
|
15
|
+
* $JARVIS_ENV_FILE explicit override
|
|
16
|
+
* ./.env the directory jarvis was started in
|
|
17
|
+
* ~/.config/jarvis/env machine-wide, written by scripts/sandbox-setup.sh
|
|
18
|
+
*
|
|
19
|
+
* Format is the usual one: KEY=value per line, # comments, blank lines, and
|
|
20
|
+
* optional matching quotes around the value. No interpolation, no `export`
|
|
21
|
+
* expansion beyond stripping the keyword.
|
|
22
|
+
*/
|
|
23
|
+
function envFileCandidates() {
|
|
24
|
+
const paths = [];
|
|
25
|
+
if (process.env.JARVIS_ENV_FILE)
|
|
26
|
+
paths.push(process.env.JARVIS_ENV_FILE);
|
|
27
|
+
paths.push((0, path_1.join)(process.cwd(), ".env"));
|
|
28
|
+
paths.push((0, path_1.join)((0, os_1.homedir)(), ".config", "jarvis", "env"));
|
|
29
|
+
return paths;
|
|
30
|
+
}
|
|
31
|
+
function parse(text) {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
34
|
+
const line = rawLine.trim();
|
|
35
|
+
if (!line || line.startsWith("#"))
|
|
36
|
+
continue;
|
|
37
|
+
const eq = line.indexOf("=");
|
|
38
|
+
if (eq <= 0)
|
|
39
|
+
continue;
|
|
40
|
+
const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
|
|
41
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
42
|
+
continue;
|
|
43
|
+
let value = line.slice(eq + 1).trim();
|
|
44
|
+
const quote = value[0];
|
|
45
|
+
if ((quote === '"' || quote === "'") && value.endsWith(quote) && value.length > 1) {
|
|
46
|
+
value = value.slice(1, -1);
|
|
47
|
+
}
|
|
48
|
+
if (!(key in out))
|
|
49
|
+
out[key] = value;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** Returns the files that were actually read, for the startup banner. */
|
|
54
|
+
function loadEnvFiles() {
|
|
55
|
+
const loaded = [];
|
|
56
|
+
for (const path of envFileCandidates()) {
|
|
57
|
+
if (!(0, fs_1.existsSync)(path))
|
|
58
|
+
continue;
|
|
59
|
+
let text;
|
|
60
|
+
try {
|
|
61
|
+
text = (0, fs_1.readFileSync)(path, "utf-8");
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
console.error(`[env] could not read ${path}: ${err.message}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const vars = parse(text);
|
|
68
|
+
let used = false;
|
|
69
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
70
|
+
if (process.env[key] === undefined) {
|
|
71
|
+
process.env[key] = value;
|
|
72
|
+
used = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (used)
|
|
76
|
+
loaded.push(path);
|
|
77
|
+
}
|
|
78
|
+
return loaded;
|
|
79
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
process.on("SIGINT", () => {
|
|
5
|
+
console.log();
|
|
6
|
+
process.exit(0);
|
|
7
|
+
});
|
|
8
|
+
const commander_1 = require("commander");
|
|
9
|
+
const env_file_1 = require("./env-file");
|
|
10
|
+
const server_1 = require("./server");
|
|
11
|
+
// Before anything reads process.env: .env in the working directory and
|
|
12
|
+
// ~/.config/jarvis/env supply API keys (Deepgram, OpenAI) without exporting
|
|
13
|
+
// them by hand. A real environment variable always wins over the file.
|
|
14
|
+
const envFiles = (0, env_file_1.loadEnvFiles)();
|
|
15
|
+
const program = new commander_1.Command();
|
|
16
|
+
program
|
|
17
|
+
.name("jarvis")
|
|
18
|
+
.description("Jarvis — a persistent AI engineering partner on top of Claude Code and other coding agents")
|
|
19
|
+
.version(require("../package.json").version);
|
|
20
|
+
program
|
|
21
|
+
.command("start")
|
|
22
|
+
.description("Start Jarvis — the hub you talk to from any device")
|
|
23
|
+
.option("-c, --caffeinate", "run caffeinate for 8 hours to prevent sleep")
|
|
24
|
+
.option("-p, --port <port>", "bind this local port and serve the UI at http://localhost:<port> (implies --local)", "3000")
|
|
25
|
+
.option("-l, --local", "bind a local port instead of connecting to the relay")
|
|
26
|
+
.option("-r, --relay <url>", "connect to a relay server instead of binding a local port (e.g. wss://relay.example.com)", "wss://relay.codeongrass.com")
|
|
27
|
+
.action(async (opts, command) => {
|
|
28
|
+
// -p defaults to 3000; an explicit --relay still wins over that default.
|
|
29
|
+
const portFromUser = command.getOptionValueSource("port") !== "default";
|
|
30
|
+
const relayFromUser = command.getOptionValueSource("relay") !== "default";
|
|
31
|
+
const port = opts.port && !(relayFromUser && !portFromUser) ? Number(opts.port) : undefined;
|
|
32
|
+
if (port !== undefined && !Number.isInteger(port)) {
|
|
33
|
+
console.error(" --port must be a number");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
for (const file of envFiles)
|
|
37
|
+
console.log(` env: ${file}`);
|
|
38
|
+
const local = opts.local || port !== undefined;
|
|
39
|
+
await (0, server_1.start)("local", port, opts.caffeinate ?? false, local ? undefined : opts.relay);
|
|
40
|
+
});
|
|
41
|
+
program.parse();
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.JARVIS_TOOL_NAMES = exports.JARVIS_MCP_NAME = void 0;
|
|
4
|
+
exports.buildJarvisTools = buildJarvisTools;
|
|
5
|
+
const claude_agent_sdk_1 = require("@anthropic-ai/claude-agent-sdk");
|
|
6
|
+
const zod_1 = require("zod");
|
|
7
|
+
const server_common_1 = require("./server-common");
|
|
8
|
+
const bot_store_1 = require("./bot-store");
|
|
9
|
+
const jarvis_1 = require("./jarvis");
|
|
10
|
+
const turns_1 = require("./turns");
|
|
11
|
+
/**
|
|
12
|
+
* The tools that make Jarvis more than a chat: it sees the projects, can add
|
|
13
|
+
* one, and can put a project agent to work — either waiting for a report
|
|
14
|
+
* (delegate) or handing the conversation over (handoff). They run in-process
|
|
15
|
+
* via the SDK's MCP bridge, so a tool call is a plain function call here.
|
|
16
|
+
*
|
|
17
|
+
* Both dispatch tools start a real turn on a real hub thread under the
|
|
18
|
+
* project's bot, so what Jarvis dispatches shows up in the UI like any other
|
|
19
|
+
* work and can be rejoined, continued or read back later.
|
|
20
|
+
*/
|
|
21
|
+
exports.JARVIS_MCP_NAME = "jarvis";
|
|
22
|
+
function buildJarvisTools(store, workspaceCwd) {
|
|
23
|
+
const text = (s) => ({ content: [{ type: "text", text: s }] });
|
|
24
|
+
const fail = (s) => ({ content: [{ type: "text", text: s }], isError: true });
|
|
25
|
+
return (0, claude_agent_sdk_1.createSdkMcpServer)({
|
|
26
|
+
name: exports.JARVIS_MCP_NAME,
|
|
27
|
+
version: "1.0.0",
|
|
28
|
+
tools: [
|
|
29
|
+
(0, claude_agent_sdk_1.tool)("list_projects", "Every project Jarvis knows (name, slug, checkout path, remote, first line of notes), plus folders in the workspace that no project has claimed yet.", {}, async () => text(JSON.stringify({
|
|
30
|
+
projects: (0, jarvis_1.listProjects)().map(summary),
|
|
31
|
+
unclaimed_folders: (0, jarvis_1.unregisteredFolders)(workspaceCwd),
|
|
32
|
+
workspace: workspaceCwd,
|
|
33
|
+
}, null, 2))),
|
|
34
|
+
(0, claude_agent_sdk_1.tool)("read_project", "The full notes of one project, by name or slug.", { project: zod_1.z.string().describe("Project name or slug, as the user refers to it") }, async ({ project }) => {
|
|
35
|
+
const p = (0, jarvis_1.findProject)(project);
|
|
36
|
+
if (!p)
|
|
37
|
+
return fail(`No project matches "${project}". Call list_projects to see what exists.`);
|
|
38
|
+
return text(JSON.stringify({ ...summary(p), notes: p.notes }, null, 2));
|
|
39
|
+
}),
|
|
40
|
+
(0, claude_agent_sdk_1.tool)("create_project", "Register a new project. Give a repo URL to clone it into the workspace, or the path of a folder that already exists. Creates the project file and the project's agent.", {
|
|
41
|
+
name: zod_1.z.string().describe("The name the user uses for the project"),
|
|
42
|
+
repo: zod_1.z.string().optional().describe("Git URL to clone, e.g. https://github.com/org/repo"),
|
|
43
|
+
path: zod_1.z.string().optional().describe("Absolute path of an existing checkout to adopt instead of cloning"),
|
|
44
|
+
notes: zod_1.z.string().optional().describe("Initial Markdown notes: what the project is, people, commands"),
|
|
45
|
+
}, async ({ name, repo, path, notes }) => {
|
|
46
|
+
try {
|
|
47
|
+
const p = (0, jarvis_1.createProject)({ name, repo, path, notes }, workspaceCwd);
|
|
48
|
+
(0, server_common_1.emitEvent)(store, "project_created", { project: summary(p) });
|
|
49
|
+
return text(`Created project "${p.name}" (slug ${p.slug}) at ${p.path}.`);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return fail(err?.message ?? "Could not create the project");
|
|
53
|
+
}
|
|
54
|
+
}),
|
|
55
|
+
(0, claude_agent_sdk_1.tool)("update_project", "Rewrite a project's notes (the Markdown under its front matter). Read them first and pass the whole new text; use this to record what you learn.", {
|
|
56
|
+
project: zod_1.z.string().describe("Project name or slug"),
|
|
57
|
+
notes: zod_1.z.string().describe("The complete new notes in Markdown"),
|
|
58
|
+
}, async ({ project, notes }) => {
|
|
59
|
+
const p = (0, jarvis_1.findProject)(project);
|
|
60
|
+
if (!p)
|
|
61
|
+
return fail(`No project matches "${project}".`);
|
|
62
|
+
try {
|
|
63
|
+
(0, jarvis_1.updateProjectNotes)(p.slug, notes);
|
|
64
|
+
return text(`Updated notes for ${p.name}.`);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
return fail(err?.message ?? "Could not update the project");
|
|
68
|
+
}
|
|
69
|
+
}),
|
|
70
|
+
(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
|
+
project: zod_1.z.string().describe("Project name or slug"),
|
|
72
|
+
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'"),
|
|
73
|
+
}, async ({ project, task }) => {
|
|
74
|
+
const p = (0, jarvis_1.findProject)(project);
|
|
75
|
+
if (!p)
|
|
76
|
+
return fail(`No project matches "${project}". Ask the user which repository it is, then create_project.`);
|
|
77
|
+
if (p.pathMissing)
|
|
78
|
+
return fail(`The checkout for "${p.name}" no longer exists at ${p.path}. The folder may have been renamed or moved. Ask the user for the new path, then update the project file at ${(0, jarvis_1.projectFile)(p.slug)}.`);
|
|
79
|
+
try {
|
|
80
|
+
const bot = (0, jarvis_1.ensureProjectBot)(p);
|
|
81
|
+
const thread = (0, bot_store_1.createThread)(bot.id, p.path, titleFor(task));
|
|
82
|
+
const { store: child, done } = (0, turns_1.beginThreadTurn)(thread, bot, { prompt: delegatedPrompt(task), silent: true });
|
|
83
|
+
(0, server_common_1.emitEvent)(store, "delegation", { project: summary(p), botId: bot.id, threadId: thread.id, sessionId: child.gitbotId, title: thread.title, status: "running" });
|
|
84
|
+
const report = await done;
|
|
85
|
+
(0, server_common_1.emitEvent)(store, "delegation", { project: summary(p), botId: bot.id, threadId: thread.id, sessionId: child.gitbotId, title: thread.title, status: child.status === "done" ? "done" : "error" });
|
|
86
|
+
return text(`Report from ${p.name} (thread ${thread.id}):\n\n${report || "(the agent produced no final message)"}`);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
return fail(err?.message ?? "Delegation failed");
|
|
90
|
+
}
|
|
91
|
+
}),
|
|
92
|
+
(0, claude_agent_sdk_1.tool)("handoff", "Move this conversation into a project. The default for any request about one project, including quick questions: the project's agent receives the message, answers the user directly, and keeps the context for their follow-ups. End your turn immediately after calling this, with at most one short line. Pass the user's request in full, with any context you gathered.", {
|
|
93
|
+
project: zod_1.z.string().describe("Project name or slug"),
|
|
94
|
+
message: zod_1.z.string().describe("The message for the project agent: the user's request as they put it, plus context and constraints"),
|
|
95
|
+
continue_thread: zod_1.z.boolean().optional().describe("Continue the project's most recent thread instead of opening a new one; use when the user is clearly following up on earlier work there"),
|
|
96
|
+
}, async ({ project, message, continue_thread }) => {
|
|
97
|
+
const p = (0, jarvis_1.findProject)(project);
|
|
98
|
+
if (!p)
|
|
99
|
+
return fail(`No project matches "${project}". Ask the user which repository it is, then create_project.`);
|
|
100
|
+
if (p.pathMissing)
|
|
101
|
+
return fail(`The checkout for "${p.name}" no longer exists at ${p.path}. The folder may have been renamed or moved. Ask the user for the new path, then update the project file at ${(0, jarvis_1.projectFile)(p.slug)}.`);
|
|
102
|
+
const bot = (0, jarvis_1.ensureProjectBot)(p);
|
|
103
|
+
let thread;
|
|
104
|
+
if (continue_thread)
|
|
105
|
+
thread = (0, bot_store_1.listThreads)(bot.id).find((t) => t.kind !== "setup");
|
|
106
|
+
if (!thread)
|
|
107
|
+
thread = (0, bot_store_1.createThread)(bot.id, p.path, titleFor(message));
|
|
108
|
+
try {
|
|
109
|
+
const { store: child } = (0, turns_1.beginThreadTurn)(thread, bot, { prompt: message });
|
|
110
|
+
(0, server_common_1.emitEvent)(store, "handoff", { project: summary(p), botId: bot.id, threadId: thread.id, sessionId: child.gitbotId, title: thread.title });
|
|
111
|
+
return text(`Handed off to ${p.name}. The project agent is now answering the user in thread ${thread.id}; end your turn.`);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
if (err instanceof turns_1.TurnBusyError) {
|
|
115
|
+
return fail(`${p.name}'s latest thread is still busy. Call handoff again without continue_thread to open a new one.`);
|
|
116
|
+
}
|
|
117
|
+
return fail(err?.message ?? "Handoff failed");
|
|
118
|
+
}
|
|
119
|
+
}),
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** 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"]
|
|
125
|
+
.map((t) => `mcp__${exports.JARVIS_MCP_NAME}__${t}`);
|
|
126
|
+
function summary(p) {
|
|
127
|
+
return {
|
|
128
|
+
name: p.name,
|
|
129
|
+
slug: p.slug,
|
|
130
|
+
path: p.path,
|
|
131
|
+
repo: p.repo ?? null,
|
|
132
|
+
summary: p.notes.split("\n").map((l) => l.replace(/^#+\s*/, "").trim()).find(Boolean) ?? "",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function delegatedPrompt(task) {
|
|
136
|
+
return `Task delegated by Jarvis. Do it, then end with a report Jarvis can pass on: the answer or outcome first, then what you checked or changed and anything the user should know.\n\n${task}`;
|
|
137
|
+
}
|
|
138
|
+
function titleFor(text) {
|
|
139
|
+
const line = text.split("\n").map((l) => l.trim()).find((l) => l && !/^task delegated/i.test(l));
|
|
140
|
+
if (!line)
|
|
141
|
+
return undefined;
|
|
142
|
+
return line.length > 60 ? line.slice(0, 57).replace(/\s+\S*$/, "") + "…" : line;
|
|
143
|
+
}
|