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.
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleBotRoutes = handleBotRoutes;
4
+ const server_common_1 = require("./server-common");
5
+ const bot_store_1 = require("./bot-store");
6
+ const fs_1 = require("fs");
7
+ const start_claude_code_1 = require("./start-claude-code");
8
+ /**
9
+ * REST surface for the bot hub: bots, their threads, and a thread's messages.
10
+ * Messages are not stored here — they are read back from Claude Code's own
11
+ * transcript using the thread's sdkSessionId.
12
+ *
13
+ * Returns true when the request was handled.
14
+ */
15
+ async function handleBotRoutes(req, res, workspaceCwd) {
16
+ const url = req.url ?? "/";
17
+ const method = req.method ?? "GET";
18
+ const path = url.split("?")[0];
19
+ const query = (0, server_common_1.parseQuery)(url);
20
+ // --- Bots ---
21
+ if (path === "/bots") {
22
+ if (method === "GET") {
23
+ (0, server_common_1.jsonOk)(res, { bots: (0, bot_store_1.listBots)() });
24
+ return true;
25
+ }
26
+ if (method === "POST") {
27
+ const body = await (0, server_common_1.readBody)(req);
28
+ if (typeof body.name !== "string" || !body.name.trim()) {
29
+ (0, server_common_1.jsonError)(res, 400, "name is required");
30
+ return true;
31
+ }
32
+ const bot = (0, bot_store_1.createBot)({ ...body, name: body.name.trim() });
33
+ // A bot that states what it needs from a machine gets its setup thread the
34
+ // moment it lands here, whether it was made here or imported.
35
+ const setupThread = (0, bot_store_1.ensureSetupThread)(bot.id, workspaceCwd);
36
+ (0, server_common_1.jsonOk)(res, { bot: (0, bot_store_1.getBot)(bot.id) ?? bot, setupThread });
37
+ return true;
38
+ }
39
+ }
40
+ // POST /bots/:id/setup — the manual controls beside the automatic run.
41
+ const setupBotId = matchId(path, "/bots/", "/setup");
42
+ if (setupBotId && method === "POST") {
43
+ const bot = (0, bot_store_1.getBot)(setupBotId);
44
+ if (!bot) {
45
+ (0, server_common_1.jsonError)(res, 404, "Bot not found");
46
+ return true;
47
+ }
48
+ if (!bot.setupInstructions?.trim()) {
49
+ (0, server_common_1.jsonError)(res, 400, "This bot has no setup instructions");
50
+ return true;
51
+ }
52
+ const body = await (0, server_common_1.readBody)(req);
53
+ const action = body.action;
54
+ if (action !== "complete" && action !== "reset" && action !== "fail") {
55
+ (0, server_common_1.jsonError)(res, 400, "action must be complete, reset or fail");
56
+ return true;
57
+ }
58
+ // A reset re-arms the run and makes sure there is a thread to run it in —
59
+ // the old one may have been deleted.
60
+ const updated = (0, bot_store_1.setSetupStatus)(bot.id, action === "complete" ? "complete" : action === "fail" ? "failed" : "pending");
61
+ const setupThread = action === "reset" ? (0, bot_store_1.ensureSetupThread)(bot.id, workspaceCwd) : undefined;
62
+ (0, server_common_1.jsonOk)(res, { bot: (0, bot_store_1.getBot)(bot.id) ?? updated, setupThread });
63
+ return true;
64
+ }
65
+ const botId = matchId(path, "/bots/");
66
+ if (botId) {
67
+ if (method === "GET") {
68
+ const bot = (0, bot_store_1.getBot)(botId);
69
+ if (!bot) {
70
+ (0, server_common_1.jsonError)(res, 404, "Bot not found");
71
+ return true;
72
+ }
73
+ (0, server_common_1.jsonOk)(res, { bot });
74
+ return true;
75
+ }
76
+ if (method === "PATCH") {
77
+ const body = await (0, server_common_1.readBody)(req);
78
+ const bot = (0, bot_store_1.updateBot)(botId, body);
79
+ if (!bot) {
80
+ (0, server_common_1.jsonError)(res, 404, "Bot not found");
81
+ return true;
82
+ }
83
+ // Setup instructions can arrive on an edit, not just at creation.
84
+ const setupThread = (0, bot_store_1.ensureSetupThread)(bot.id, workspaceCwd);
85
+ (0, server_common_1.jsonOk)(res, { bot: (0, bot_store_1.getBot)(bot.id) ?? bot, setupThread });
86
+ return true;
87
+ }
88
+ if (method === "DELETE") {
89
+ if (!(0, bot_store_1.deleteBot)(botId)) {
90
+ (0, server_common_1.jsonError)(res, 404, "Bot not found");
91
+ return true;
92
+ }
93
+ (0, server_common_1.jsonOk)(res, { deleted: true });
94
+ return true;
95
+ }
96
+ }
97
+ // --- Threads ---
98
+ if (path === "/threads") {
99
+ if (method === "GET") {
100
+ (0, server_common_1.jsonOk)(res, { threads: (0, bot_store_1.listThreads)(query.botId) });
101
+ return true;
102
+ }
103
+ if (method === "POST") {
104
+ const body = await (0, server_common_1.readBody)(req);
105
+ const bot = body.botId ? (0, bot_store_1.getBot)(body.botId) : undefined;
106
+ if (!bot) {
107
+ (0, server_common_1.jsonError)(res, 400, "a valid botId is required");
108
+ return true;
109
+ }
110
+ // A thread runs where it is told to: the folder the user picked, else the
111
+ // bot's default, else the directory the CLI was started in.
112
+ const repoPath = body.repoPath ?? bot.repoPath ?? workspaceCwd;
113
+ if (!(0, fs_1.existsSync)(repoPath) || !(0, fs_1.statSync)(repoPath).isDirectory()) {
114
+ (0, server_common_1.jsonError)(res, 400, `Not a directory: ${repoPath}`);
115
+ return true;
116
+ }
117
+ // Setup comes first: a bot that has not prepared this machine cannot be
118
+ // given work yet. Its own setup thread is made by the server, never here.
119
+ if ((0, bot_store_1.botNeedsSetup)(bot)) {
120
+ const setupThread = (0, bot_store_1.ensureSetupThread)(bot.id, workspaceCwd);
121
+ (0, server_common_1.jsonError)(res, 409, `${bot.name} still needs to set up this machine`, {
122
+ setupRequired: true,
123
+ setupThreadId: setupThread?.id,
124
+ });
125
+ return true;
126
+ }
127
+ (0, server_common_1.jsonOk)(res, { thread: (0, bot_store_1.createThread)(bot.id, repoPath, body.title) });
128
+ return true;
129
+ }
130
+ }
131
+ // /threads/:id/messages must be matched before /threads/:id
132
+ const messagesId = matchId(path, "/threads/", "/messages");
133
+ if (messagesId && method === "GET") {
134
+ const thread = (0, bot_store_1.getThread)(messagesId);
135
+ if (!thread) {
136
+ (0, server_common_1.jsonError)(res, 404, "Thread not found");
137
+ return true;
138
+ }
139
+ // A thread that has not had a turn yet has no transcript on disk.
140
+ const messages = thread.sdkSessionId
141
+ ? await (0, start_claude_code_1.loadTranscript)(thread.sdkSessionId, thread.repoPath)
142
+ : [];
143
+ (0, server_common_1.jsonOk)(res, { messages });
144
+ return true;
145
+ }
146
+ const threadId = matchId(path, "/threads/");
147
+ if (threadId) {
148
+ if (method === "GET") {
149
+ const thread = (0, bot_store_1.getThread)(threadId);
150
+ if (!thread) {
151
+ (0, server_common_1.jsonError)(res, 404, "Thread not found");
152
+ return true;
153
+ }
154
+ (0, server_common_1.jsonOk)(res, { thread });
155
+ return true;
156
+ }
157
+ if (method === "PATCH") {
158
+ const body = await (0, server_common_1.readBody)(req);
159
+ const thread = (0, bot_store_1.updateThread)(threadId, body);
160
+ if (!thread) {
161
+ (0, server_common_1.jsonError)(res, 404, "Thread not found");
162
+ return true;
163
+ }
164
+ (0, server_common_1.jsonOk)(res, { thread });
165
+ return true;
166
+ }
167
+ if (method === "DELETE") {
168
+ const thread = (0, bot_store_1.getThread)(threadId);
169
+ if (!(0, bot_store_1.deleteThread)(threadId)) {
170
+ (0, server_common_1.jsonError)(res, 404, "Thread not found");
171
+ return true;
172
+ }
173
+ // Deleting the setup thread leaves the bot pointing at nothing; unbind so
174
+ // the next run can make a fresh one.
175
+ if (thread && thread.kind === "setup")
176
+ (0, bot_store_1.updateBot)(thread.botId, { setupThreadId: undefined });
177
+ (0, server_common_1.jsonOk)(res, { deleted: true });
178
+ return true;
179
+ }
180
+ }
181
+ return false;
182
+ }
183
+ /** Extracts a single path segment: matchId("/bots/abc", "/bots/") -> "abc". */
184
+ function matchId(path, prefix, suffix = "") {
185
+ if (!path.startsWith(prefix))
186
+ return null;
187
+ let rest = path.slice(prefix.length);
188
+ if (suffix) {
189
+ if (!rest.endsWith(suffix))
190
+ return null;
191
+ rest = rest.slice(0, -suffix.length);
192
+ }
193
+ if (!rest || rest.includes("/"))
194
+ return null;
195
+ return rest;
196
+ }
@@ -0,0 +1,297 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.dataDir = dataDir;
4
+ exports.listBots = listBots;
5
+ exports.getBot = getBot;
6
+ exports.createBot = createBot;
7
+ exports.updateBot = updateBot;
8
+ exports.deleteBot = deleteBot;
9
+ exports.botNeedsSetup = botNeedsSetup;
10
+ exports.ensureSetupThread = ensureSetupThread;
11
+ exports.setSetupStatus = setSetupStatus;
12
+ exports.listThreads = listThreads;
13
+ exports.getThread = getThread;
14
+ exports.createThread = createThread;
15
+ exports.updateThread = updateThread;
16
+ exports.deleteThread = deleteThread;
17
+ exports.bindSession = bindSession;
18
+ exports.touchThread = touchThread;
19
+ const crypto_1 = require("crypto");
20
+ const fs_1 = require("fs");
21
+ const path_1 = require("path");
22
+ const os_1 = require("os");
23
+ // --- Storage ---
24
+ // Two JSON files under ~/.jarvis. Small collections, read fully and written atomically.
25
+ // All access goes through this module so the backing store can be swapped later.
26
+ // ~/.gitbot and ~/.grass are the pre-rename locations: keep using one when it
27
+ // exists and the new directory does not, so an upgrade doesn't orphan someone's
28
+ // bots and threads.
29
+ function resolveDataDir() {
30
+ if (process.env.JARVIS_DATA_DIR)
31
+ return process.env.JARVIS_DATA_DIR;
32
+ if (process.env.GITBOT_DATA_DIR)
33
+ return process.env.GITBOT_DATA_DIR;
34
+ if (process.env.GRASS_DATA_DIR)
35
+ return process.env.GRASS_DATA_DIR;
36
+ const current = (0, path_1.join)((0, os_1.homedir)(), ".jarvis");
37
+ if ((0, fs_1.existsSync)(current))
38
+ return current;
39
+ for (const legacy of [(0, path_1.join)((0, os_1.homedir)(), ".gitbot"), (0, path_1.join)((0, os_1.homedir)(), ".grass")]) {
40
+ if ((0, fs_1.existsSync)(legacy))
41
+ return legacy;
42
+ }
43
+ return current;
44
+ }
45
+ const DATA_DIR = resolveDataDir();
46
+ /** The resolved data directory, shared by other modules that persist state. */
47
+ function dataDir() {
48
+ return DATA_DIR;
49
+ }
50
+ const BOTS_FILE = (0, path_1.join)(DATA_DIR, "bots.json");
51
+ const THREADS_FILE = (0, path_1.join)(DATA_DIR, "threads.json");
52
+ function readCollection(file) {
53
+ if (!(0, fs_1.existsSync)(file))
54
+ return [];
55
+ try {
56
+ const parsed = JSON.parse((0, fs_1.readFileSync)(file, "utf-8"));
57
+ return Array.isArray(parsed) ? parsed : [];
58
+ }
59
+ catch (err) {
60
+ console.error(`[bot-store] could not read ${file}: ${err.message}`);
61
+ return [];
62
+ }
63
+ }
64
+ function writeCollection(file, items) {
65
+ if (!(0, fs_1.existsSync)(DATA_DIR))
66
+ (0, fs_1.mkdirSync)(DATA_DIR, { recursive: true });
67
+ const tmp = `${file}.${process.pid}.tmp`;
68
+ (0, fs_1.writeFileSync)(tmp, JSON.stringify(items, null, 2), "utf-8");
69
+ (0, fs_1.renameSync)(tmp, file);
70
+ }
71
+ const now = () => new Date().toISOString();
72
+ // --- Bots ---
73
+ function listBots() {
74
+ return readCollection(BOTS_FILE).sort((a, b) => a.name.localeCompare(b.name));
75
+ }
76
+ function getBot(id) {
77
+ return readCollection(BOTS_FILE).find((b) => b.id === id);
78
+ }
79
+ function createBot(input) {
80
+ const bots = readCollection(BOTS_FILE);
81
+ const ts = now();
82
+ const bot = {
83
+ id: (0, crypto_1.randomUUID)(),
84
+ name: input.name,
85
+ description: input.description ?? "",
86
+ emoji: input.emoji ?? "🤖",
87
+ instructions: input.instructions ?? "",
88
+ setupInstructions: input.setupInstructions,
89
+ setupStatus: input.setupInstructions?.trim() ? "pending" : undefined,
90
+ model: input.model,
91
+ repoPath: input.repoPath,
92
+ permissionMode: input.permissionMode ?? "ask-permissions",
93
+ allowedTools: input.allowedTools,
94
+ disallowedTools: input.disallowedTools,
95
+ role: input.role,
96
+ projectSlug: input.projectSlug,
97
+ createdAt: ts,
98
+ updatedAt: ts,
99
+ };
100
+ bots.push(bot);
101
+ writeCollection(BOTS_FILE, bots);
102
+ return bot;
103
+ }
104
+ function updateBot(id, patch) {
105
+ const bots = readCollection(BOTS_FILE);
106
+ const idx = bots.findIndex((b) => b.id === id);
107
+ if (idx === -1)
108
+ return undefined;
109
+ const { id: _ignored, createdAt: _created, ...rest } = patch;
110
+ const before = bots[idx];
111
+ const merged = { ...before, ...rest, updatedAt: now() };
112
+ // Setup state follows the instructions it exists for: gaining them arms a run,
113
+ // losing them drops it, and rewriting them asks the machine to be prepared
114
+ // again. An explicit status in the patch is the caller's own call and stands.
115
+ const had = !!before.setupInstructions?.trim();
116
+ const has = !!merged.setupInstructions?.trim();
117
+ if (!has) {
118
+ merged.setupStatus = undefined;
119
+ merged.setupThreadId = undefined;
120
+ }
121
+ else if (rest.setupStatus === undefined) {
122
+ if (!had || merged.setupInstructions !== before.setupInstructions)
123
+ merged.setupStatus = "pending";
124
+ }
125
+ bots[idx] = merged;
126
+ writeCollection(BOTS_FILE, bots);
127
+ return bots[idx];
128
+ }
129
+ /** Deletes the bot and every thread belonging to it. */
130
+ function deleteBot(id) {
131
+ const bots = readCollection(BOTS_FILE);
132
+ const remaining = bots.filter((b) => b.id !== id);
133
+ if (remaining.length === bots.length)
134
+ return false;
135
+ writeCollection(BOTS_FILE, remaining);
136
+ const threads = readCollection(THREADS_FILE);
137
+ writeCollection(THREADS_FILE, threads.filter((t) => t.botId !== id));
138
+ return true;
139
+ }
140
+ // --- Setup ---
141
+ /**
142
+ * True while this machine still owes the bot a setup run. A bot with no setup
143
+ * instructions never owes one, so it is usable the moment it lands.
144
+ */
145
+ function botNeedsSetup(bot) {
146
+ return !!bot.setupInstructions?.trim() && bot.setupStatus !== "complete";
147
+ }
148
+ /**
149
+ * The bot's setup thread, created on first ask. The thread is an ordinary
150
+ * Claude Code conversation — only its kind and its opening prompt differ — so
151
+ * the user can talk to it when the automatic run does not get all the way there.
152
+ */
153
+ function ensureSetupThread(botId, fallbackPath) {
154
+ const bot = getBot(botId);
155
+ if (!bot || !bot.setupInstructions?.trim())
156
+ return undefined;
157
+ const existing = bot.setupThreadId ? getThread(bot.setupThreadId) : undefined;
158
+ if (existing)
159
+ return existing;
160
+ const thread = createThread(bot.id, bot.repoPath || fallbackPath, `Set up ${bot.name}`, "setup");
161
+ updateBot(bot.id, { setupThreadId: thread.id, setupStatus: bot.setupStatus ?? "pending" });
162
+ return thread;
163
+ }
164
+ function setSetupStatus(botId, status) {
165
+ const bot = getBot(botId);
166
+ if (!bot || !bot.setupInstructions?.trim())
167
+ return bot;
168
+ return updateBot(botId, { setupStatus: status });
169
+ }
170
+ // --- Threads ---
171
+ function listThreads(botId) {
172
+ const threads = readCollection(THREADS_FILE);
173
+ return threads
174
+ .filter((t) => !botId || t.botId === botId)
175
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
176
+ }
177
+ function getThread(id) {
178
+ return readCollection(THREADS_FILE).find((t) => t.id === id);
179
+ }
180
+ function createThread(botId, repoPath, title, kind = "chat") {
181
+ const threads = readCollection(THREADS_FILE);
182
+ const ts = now();
183
+ const thread = {
184
+ id: (0, crypto_1.randomUUID)(),
185
+ botId,
186
+ kind,
187
+ sdkSessionId: null,
188
+ title: title ?? defaultTitle(repoPath),
189
+ titleIsAuto: !title,
190
+ repoPath,
191
+ preview: "",
192
+ messageCount: 0,
193
+ createdAt: ts,
194
+ updatedAt: ts,
195
+ };
196
+ threads.push(thread);
197
+ writeCollection(THREADS_FILE, threads);
198
+ return thread;
199
+ }
200
+ function updateThread(id, patch) {
201
+ const threads = readCollection(THREADS_FILE);
202
+ const idx = threads.findIndex((t) => t.id === id);
203
+ if (idx === -1)
204
+ return undefined;
205
+ const { id: _ignored, botId: _bot, createdAt: _created, ...rest } = patch;
206
+ threads[idx] = { ...threads[idx], ...rest, updatedAt: now() };
207
+ // A title the caller set by hand is the user's, not ours to overwrite.
208
+ if (rest.title !== undefined && rest.titleIsAuto === undefined)
209
+ threads[idx].titleIsAuto = false;
210
+ writeCollection(THREADS_FILE, threads);
211
+ return threads[idx];
212
+ }
213
+ function deleteThread(id) {
214
+ const threads = readCollection(THREADS_FILE);
215
+ const remaining = threads.filter((t) => t.id !== id);
216
+ if (remaining.length === threads.length)
217
+ return false;
218
+ writeCollection(THREADS_FILE, remaining);
219
+ return true;
220
+ }
221
+ /**
222
+ * Binds a thread to the Claude Code session that backs it. Called once, when the
223
+ * SDK reports its session id on the first turn; that id is the resume handle.
224
+ */
225
+ function bindSession(id, sdkSessionId) {
226
+ const thread = getThread(id);
227
+ if (!thread || thread.sdkSessionId)
228
+ return thread;
229
+ return updateThread(id, { sdkSessionId });
230
+ }
231
+ /**
232
+ * Records a turn on a thread, keeping the title and preview useful in the
233
+ * thread list.
234
+ */
235
+ function touchThread(id, prompt) {
236
+ const thread = getThread(id);
237
+ if (!thread)
238
+ return undefined;
239
+ const patch = { messageCount: thread.messageCount + 1 };
240
+ if (prompt) {
241
+ patch.preview = prompt.slice(0, 140);
242
+ // Conversations open with "hi" as often as not, so keep looking for a name
243
+ // until a turn actually says what the thread is about.
244
+ if (thread.titleIsAuto !== false) {
245
+ const derived = titleFromPrompt(prompt);
246
+ if (derived) {
247
+ patch.title = derived;
248
+ patch.titleIsAuto = false;
249
+ }
250
+ }
251
+ }
252
+ return updateThread(id, patch);
253
+ }
254
+ /** A thread with no turns yet is named after the folder it will run in. */
255
+ function defaultTitle(repoPath) {
256
+ const folder = (0, path_1.basename)(repoPath || "").trim();
257
+ return folder ? `New thread in ${folder}` : "New thread";
258
+ }
259
+ /** Prompts that name nothing: greetings, acknowledgements, nudges. */
260
+ const LOW_SIGNAL = /^(?:hi|hey|hello|yo|sup|hiya|howdy|good (?:morning|afternoon|evening)|greetings|thanks|thank you|ty|ok|okay|k|kk|cool|nice|great|got it|sure|yes|yep|yeah|no|nope|nah|continue|carry on|go on|go ahead|proceed|next|more|again|do it|please do|test|testing|ping|\?+|.)[\s!.,?]*$/i;
261
+ /** Openers that carry no meaning in a list of thread names. */
262
+ const FILLER = /^(?:hey|hi|hello|ok|okay|so|now|please|pls|can you(?: please)?|could you(?: please)?|would you(?: please)?|i want you to|i need you to|i'd like you to|let's|lets|help me|i want to|i need to)\b[\s,:-]*/i;
263
+ /**
264
+ * Names a thread after a prompt, or returns null when the prompt says too
265
+ * little to name anything. The aim is a label that reads well in a list: one
266
+ * short phrase, no markdown scaffolding, no mid-word cut.
267
+ */
268
+ function titleFromPrompt(prompt) {
269
+ let text = prompt
270
+ .replace(/```[\s\S]*?```/g, " ") // fenced code says nothing useful
271
+ .replace(/`([^`]*)`/g, "$1") // keep inline code, drop the ticks
272
+ .replace(/^\s*(?:[#>*\-+]+|\d+[.)])\s*/gm, "") // markdown bullets/headings
273
+ .replace(/\s+/g, " ")
274
+ .trim();
275
+ if (LOW_SIGNAL.test(text))
276
+ return null;
277
+ // Strip leading pleasantries ("hi, can you please ..."), then cut at the
278
+ // first sentence end.
279
+ for (let prev = ""; prev !== text;) {
280
+ prev = text;
281
+ text = text.replace(FILLER, "").trim();
282
+ }
283
+ const sentence = text.match(/^[^.!?\n]{8,}?(?=[.!?](?:\s|$))/);
284
+ if (sentence)
285
+ text = sentence[0].trim();
286
+ text = text.replace(/[\s,;:.\-]+$/, "");
287
+ // Two words or a handful of characters is a fragment, not a name.
288
+ if (text.length < 12 || text.split(" ").length < 3)
289
+ return null;
290
+ const MAX = 52;
291
+ if (text.length > MAX) {
292
+ const cut = text.slice(0, MAX);
293
+ const space = cut.lastIndexOf(" ");
294
+ text = (space > 20 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-]+$/, "") + "\u2026";
295
+ }
296
+ return text.charAt(0).toUpperCase() + text.slice(1);
297
+ }