daily-focus 1.1.1-dev.2 → 1.2.0-dev.10

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.
@@ -1,456 +0,0 @@
1
- // src/assistant.ts
2
- import { spawn } from "node:child_process";
3
- import { randomUUID } from "node:crypto";
4
- import { appendFile, mkdir, readFile } from "node:fs/promises";
5
- import { createInterface } from "node:readline";
6
- var TURN_TIMEOUT_MS = 10 * 6e4;
7
- var TURNS_SHOWN = 20;
8
- var QUICK_ACTIONS = [
9
- {
10
- id: "assess-review",
11
- label: "Assess the review",
12
- sources: ["github"],
13
- request: "Read every review comment and requested change on this pull request against the diff. For each one say whether it is right, partly right or mistaken, with the evidence, and what I should do about it."
14
- },
15
- {
16
- id: "explain-ci",
17
- label: "Explain the CI failure",
18
- sources: ["github"],
19
- request: "Find the failing checks on this pull request and read their logs. Explain what is failing, why, and the smallest change that would fix it. Do not attempt the fix."
20
- },
21
- {
22
- id: "summarise-pr",
23
- label: "Summarise the change",
24
- sources: ["github"],
25
- request: "Summarise what this pull request changes and why, in a form I could paste as its description, and flag anything a reviewer is likely to push back on."
26
- },
27
- {
28
- id: "reply-reviewer",
29
- label: "Draft replies to reviewers",
30
- sources: ["github"],
31
- request: "Draft a reply to each open review comment on this pull request, in my voice, agreeing where the reviewer is right and explaining where they are not. Give me the text to post. Do not post it."
32
- },
33
- {
34
- id: "draft-reply",
35
- label: "Draft a reply",
36
- sources: ["email"],
37
- request: "Read the whole thread and draft a reply from me, in my voice, that answers what was asked. Create it as a Gmail draft on this thread so I can open it there. Never send it. If you cannot create drafts, give me the text instead and say so."
38
- },
39
- {
40
- id: "summarise-thread",
41
- label: "Summarise the thread",
42
- sources: ["email"],
43
- request: "Read the whole thread and summarise it: who wants what, what has been decided, and what is still open."
44
- },
45
- {
46
- id: "what-they-need",
47
- label: "What do they need from me?",
48
- sources: ["email", "slack", "messages"],
49
- request: "Read this and tell me exactly what is being asked of me, by whom, by when, and what the smallest adequate response would be."
50
- },
51
- {
52
- id: "summarise-ticket",
53
- label: "Summarise the ticket",
54
- sources: ["jira"],
55
- request: "Read this ticket, its comments and anything it links to, and summarise where it stands: what is done, what is left, and what is blocking it."
56
- },
57
- {
58
- id: "status-comment",
59
- label: "Draft a status comment",
60
- sources: ["jira"],
61
- request: "Read this ticket and its linked pull requests, and draft a short status comment from me for the ticket. Give me the text to post. Do not post it."
62
- },
63
- {
64
- id: "next-steps",
65
- label: "Break it into next steps",
66
- sources: null,
67
- request: "Read whatever this item points at and turn it into three to five concrete next steps I could start on today, smallest first."
68
- }
69
- ];
70
- function foldAssistantLog(text) {
71
- const turns = /* @__PURE__ */ new Map();
72
- for (const line of text.split("\n")) {
73
- if (line.trim() === "") continue;
74
- let record;
75
- try {
76
- record = JSON.parse(line);
77
- } catch {
78
- continue;
79
- }
80
- if (typeof record !== "object" || record === null || typeof record.turn !== "string") continue;
81
- if (record.event === "started") {
82
- if (typeof record.item !== "string" || typeof record.request !== "string") continue;
83
- turns.set(record.turn, {
84
- id: record.turn,
85
- itemId: record.item,
86
- agent: record.agent,
87
- sessionId: null,
88
- request: record.request,
89
- action: typeof record.action === "string" ? record.action : null,
90
- startedAt: record.at,
91
- endedAt: null,
92
- status: "running",
93
- reply: "",
94
- error: null
95
- });
96
- continue;
97
- }
98
- const turn = turns.get(record.turn);
99
- if (!turn) continue;
100
- turn.sessionId = typeof record.sessionId === "string" ? record.sessionId : turn.sessionId;
101
- turn.reply = typeof record.reply === "string" ? record.reply : turn.reply;
102
- turn.endedAt = record.at;
103
- if (record.event === "finished") {
104
- turn.status = "done";
105
- } else if (record.event === "failed") {
106
- turn.status = "failed";
107
- turn.error = record.error;
108
- } else if (record.event === "aborted") {
109
- turn.status = "aborted";
110
- turn.error = record.reason;
111
- }
112
- }
113
- return turns;
114
- }
115
- function parseLine(line) {
116
- const trimmed = line.trim();
117
- if (!trimmed.startsWith("{")) return null;
118
- try {
119
- const parsed = JSON.parse(trimmed);
120
- return typeof parsed === "object" && parsed !== null ? parsed : null;
121
- } catch {
122
- return null;
123
- }
124
- }
125
- var claudeAdapter = {
126
- args({ prompt, sessionId, model, effort, tools, denyEdits }) {
127
- const args = [
128
- "-p",
129
- "--output-format",
130
- "stream-json",
131
- "--verbose",
132
- "--permission-mode",
133
- "dontAsk",
134
- "--permission-prompts",
135
- "none"
136
- ];
137
- if (denyEdits) args.push("--disallowedTools", "Edit", "Write", "NotebookEdit");
138
- if (tools.length > 0) args.push("--allowedTools", ...tools);
139
- if (model) args.push("--model", model);
140
- if (effort) args.push("--effort", effort);
141
- if (sessionId) args.push("--resume", sessionId);
142
- args.push("--", prompt);
143
- return args;
144
- },
145
- parse(line) {
146
- const event = parseLine(line);
147
- if (!event) return null;
148
- const type = event.type;
149
- if (type === "system" && typeof event.session_id === "string") {
150
- return { sessionId: event.session_id };
151
- }
152
- if (type === "assistant") {
153
- const message = event.message;
154
- const content = Array.isArray(message?.content) ? message.content : [];
155
- const text = content.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n\n");
156
- return text ? { text } : null;
157
- }
158
- if (type === "result") {
159
- const ok = event.is_error !== true && event.subtype === "success";
160
- const reply = typeof event.result === "string" ? event.result : void 0;
161
- const parsed = { done: ok ? { ok, reply } : { ok, error: reply ?? String(event.subtype ?? "failed") } };
162
- if (typeof event.session_id === "string") parsed.sessionId = event.session_id;
163
- return parsed;
164
- }
165
- return null;
166
- }
167
- };
168
- var codexAdapter = {
169
- args({ prompt, sessionId, model, effort }) {
170
- const args = ["exec"];
171
- if (sessionId) args.push("resume");
172
- args.push("--json", "--skip-git-repo-check");
173
- args.push("-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true");
174
- if (model) args.push("-c", `model=${JSON.stringify(model)}`);
175
- if (effort) args.push("-c", `model_reasoning_effort=${JSON.stringify(effort)}`);
176
- if (sessionId) args.push(sessionId);
177
- args.push(prompt);
178
- return args;
179
- },
180
- parse(line) {
181
- const event = parseLine(line);
182
- if (!event) return null;
183
- const type = event.type;
184
- if (type === "thread.started" && typeof event.thread_id === "string") {
185
- return { sessionId: event.thread_id };
186
- }
187
- if (type === "item.completed") {
188
- const item = event.item;
189
- if (item?.type === "agent_message" && typeof item.text === "string") return { text: item.text };
190
- return null;
191
- }
192
- if (type === "turn.completed") return { done: { ok: true } };
193
- if (type === "turn.failed" || type === "error") {
194
- const error = event.error;
195
- const message = typeof event.message === "string" ? event.message : error?.message;
196
- return { done: { ok: false, error: message ?? String(type) } };
197
- }
198
- return null;
199
- }
200
- };
201
- var ADAPTERS = {
202
- claude: claudeAdapter,
203
- codex: codexAdapter
204
- };
205
- function composePrompt(instructions, context, request, profile) {
206
- const now = /* @__PURE__ */ new Date();
207
- const facts = [`- Profile: ${profile}`, `- Today: ${now.toISOString().slice(0, 10)}`];
208
- const parts = [instructions.trim(), "", "## Context", "", ...facts, ""];
209
- if (context.item) parts.push("### The item", "", "```json", JSON.stringify(context.item, null, 2), "```", "");
210
- if (context.pull) parts.push("### The pull request, as the board last saw it", "", "```json", JSON.stringify(context.pull, null, 2), "```", "");
211
- if (context.ticket) parts.push("### The ticket, as the board last saw it", "", "```json", JSON.stringify(context.ticket, null, 2), "```", "");
212
- parts.push("## Request", "", request.trim());
213
- return parts.join("\n");
214
- }
215
- var Assistant = class _Assistant {
216
- turns = /* @__PURE__ */ new Map();
217
- running = /* @__PURE__ */ new Map();
218
- loaded = false;
219
- config;
220
- hooks;
221
- spawnFn;
222
- constructor(config, hooks, spawnFn = spawn) {
223
- this.config = config;
224
- this.hooks = hooks;
225
- this.spawnFn = spawnFn;
226
- }
227
- get enabled() {
228
- return this.config.assistant.agent !== null;
229
- }
230
- /** Whether any turn has a process going right now. */
231
- get busy() {
232
- return this.running.size > 0;
233
- }
234
- /**
235
- * Read the log, and close whatever was left running when the last process
236
- * died. Cheap when the assistant is off: the log is read only if it exists.
237
- */
238
- async start() {
239
- if (this.loaded) return;
240
- this.loaded = true;
241
- let text = "";
242
- try {
243
- text = await readFile(this.config.assistantLogFile, "utf8");
244
- } catch {
245
- return;
246
- }
247
- for (const [id, turn] of foldAssistantLog(text)) this.turns.set(id, turn);
248
- for (const turn of this.turns.values()) {
249
- if (turn.status !== "running") continue;
250
- turn.status = "aborted";
251
- turn.error = "the dashboard restarted while this was running";
252
- turn.endedAt = (/* @__PURE__ */ new Date()).toISOString();
253
- await this.append({
254
- event: "aborted",
255
- turn: turn.id,
256
- sessionId: turn.sessionId,
257
- reason: turn.error,
258
- reply: turn.reply,
259
- at: turn.endedAt
260
- });
261
- }
262
- }
263
- view() {
264
- const items = {};
265
- for (const turn of this.turns.values()) {
266
- const entry = items[turn.itemId] ??= { running: false, sessionId: null, turns: [] };
267
- entry.turns.push(turn);
268
- if (turn.status === "running") entry.running = true;
269
- if (turn.sessionId && turn.agent === this.config.assistant.agent) entry.sessionId = turn.sessionId;
270
- }
271
- for (const entry of Object.values(items)) {
272
- entry.turns.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
273
- entry.turns = entry.turns.slice(-TURNS_SHOWN);
274
- }
275
- return {
276
- enabled: this.enabled,
277
- agent: this.config.assistant.agent,
278
- quickActions: [...QUICK_ACTIONS],
279
- items
280
- };
281
- }
282
- /** The request text an ask resolves to, or null when there is nothing to send. */
283
- static requestFor(ask) {
284
- const action = ask.action ? QUICK_ACTIONS.find((candidate) => candidate.id === ask.action) : void 0;
285
- if (ask.action && !action) return null;
286
- const typed = (ask.text ?? "").trim();
287
- if (!action && typed === "") return null;
288
- const request = action ? typed ? `${action.request}
289
-
290
- ${typed}` : action.request : typed;
291
- return { request, action: action?.id ?? null };
292
- }
293
- /**
294
- * Start a turn. Returns once the process is running, not once it has answered;
295
- * the answer arrives through `onChange` and the next state.
296
- */
297
- async ask(ask, context) {
298
- const agent = this.config.assistant.agent;
299
- if (!agent) throw new Error("the assistant is off; set DAILY_FOCUS_ASSISTANT to claude or codex");
300
- if (this.running.has(ask.itemId)) throw new Error("the assistant is already working on this item");
301
- const resolved = _Assistant.requestFor(ask);
302
- if (!resolved) throw new Error("nothing to ask: give a quick action or some text");
303
- await this.start();
304
- const previous = this.view().items[ask.itemId]?.sessionId ?? null;
305
- const turn = {
306
- id: randomUUID(),
307
- itemId: ask.itemId,
308
- agent,
309
- // Null until the CLI says: a resumed session that fails must not be
310
- // recorded as if this CLI had owned it.
311
- sessionId: null,
312
- request: resolved.request,
313
- action: resolved.action,
314
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
315
- endedAt: null,
316
- status: "running",
317
- reply: "",
318
- error: null
319
- };
320
- this.turns.set(turn.id, turn);
321
- await this.append({
322
- event: "started",
323
- turn: turn.id,
324
- item: turn.itemId,
325
- agent,
326
- request: turn.request,
327
- action: turn.action,
328
- at: turn.startedAt
329
- });
330
- const prompt = previous ? resolved.request : composePrompt(await this.instructions(), context, resolved.request, this.config.profile);
331
- await mkdir(this.config.assistantDir, { recursive: true });
332
- const adapter = ADAPTERS[agent];
333
- const args = adapter.args({
334
- prompt,
335
- sessionId: previous,
336
- model: this.config.assistant.model,
337
- effort: this.config.assistant.effort,
338
- tools: this.config.assistant.tools,
339
- denyEdits: true
340
- });
341
- let child;
342
- try {
343
- child = this.spawnFn(this.config.assistant.binPath, args, {
344
- cwd: this.config.assistantDir,
345
- // Stdin closed: both CLIs wait on it otherwise, one of them for three seconds.
346
- stdio: ["ignore", "pipe", "pipe"],
347
- env: process.env
348
- });
349
- } catch (err) {
350
- await this.finish(turn, { ok: false, error: this.spawnError(err) });
351
- return turn;
352
- }
353
- const timer = setTimeout(() => this.stop(turn.itemId, "gave up after ten minutes"), TURN_TIMEOUT_MS);
354
- timer.unref();
355
- const run = { turn, child, timer, abortReason: null };
356
- this.running.set(turn.itemId, run);
357
- this.hooks.onChange();
358
- let done;
359
- const stderr = [];
360
- child.stderr?.on("data", (chunk) => {
361
- stderr.push(chunk.toString("utf8"));
362
- if (stderr.length > 50) stderr.shift();
363
- });
364
- if (child.stdout) {
365
- const lines = createInterface({ input: child.stdout });
366
- lines.on("line", (line) => {
367
- const event = adapter.parse(line);
368
- if (!event) return;
369
- if (event.sessionId) turn.sessionId = event.sessionId;
370
- if (event.text) {
371
- turn.reply = turn.reply ? `${turn.reply}
372
-
373
- ${event.text}` : event.text;
374
- this.hooks.onChange();
375
- }
376
- if (event.done) done = event.done;
377
- });
378
- }
379
- child.on("error", (err) => {
380
- done = { ok: false, error: this.spawnError(err) };
381
- });
382
- child.on("close", (code) => {
383
- clearTimeout(timer);
384
- this.running.delete(turn.itemId);
385
- const outcome = run.abortReason ? { ok: false, error: run.abortReason } : done ?? {
386
- ok: false,
387
- error: `${agent} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
388
- };
389
- void this.finish(turn, outcome, run.abortReason !== null);
390
- });
391
- return turn;
392
- }
393
- /** Kill the turn running on an item, if any. */
394
- async stop(itemId, reason = "stopped") {
395
- const run = this.running.get(itemId);
396
- if (!run) return false;
397
- run.abortReason = reason;
398
- run.child.kill("SIGTERM");
399
- return true;
400
- }
401
- /** Kill everything, for shutdown. The exit handlers write the aborted lines. */
402
- async close() {
403
- for (const itemId of [...this.running.keys()]) await this.stop(itemId, "the dashboard shut down");
404
- }
405
- async finish(turn, outcome, aborted = false) {
406
- const at = (/* @__PURE__ */ new Date()).toISOString();
407
- const reply = outcome.reply ?? turn.reply;
408
- turn.reply = reply;
409
- turn.endedAt = at;
410
- if (outcome.ok) {
411
- turn.status = "done";
412
- await this.append({ event: "finished", turn: turn.id, sessionId: turn.sessionId, reply, at });
413
- } else if (aborted) {
414
- turn.status = "aborted";
415
- turn.error = outcome.error ?? "stopped";
416
- await this.append({ event: "aborted", turn: turn.id, sessionId: turn.sessionId, reason: turn.error, reply, at });
417
- } else {
418
- turn.status = "failed";
419
- turn.error = outcome.error ?? "failed";
420
- await this.append({ event: "failed", turn: turn.id, sessionId: turn.sessionId, error: turn.error, reply, at });
421
- }
422
- this.hooks.onChange();
423
- }
424
- spawnError(err) {
425
- const code = err.code;
426
- const bin = this.config.assistant.binPath;
427
- if (code === "ENOENT") return `could not find ${bin}; install it or set DAILY_FOCUS_ASSISTANT_BIN to its path`;
428
- return `could not run ${bin}: ${err instanceof Error ? err.message : String(err)}`;
429
- }
430
- /** The store's copy, or the repo's before `npm run init` has linked it. */
431
- async instructions() {
432
- for (const path of [this.config.assistantPromptFile, this.config.assistantPromptSource]) {
433
- try {
434
- return await readFile(path, "utf8");
435
- } catch {
436
- }
437
- }
438
- return "You are helping with one item from a personal dashboard. Read only; never send, post or edit anything.";
439
- }
440
- /**
441
- * The log records what happened; the turn in memory is updated by the caller
442
- * first. Only this process writes the file, so the memory never has to be
443
- * re-read from it.
444
- */
445
- async append(record) {
446
- await mkdir(this.config.dataDir, { recursive: true });
447
- await appendFile(this.config.assistantLogFile, `${JSON.stringify(record)}
448
- `, "utf8");
449
- }
450
- };
451
-
452
- export {
453
- QUICK_ACTIONS,
454
- ADAPTERS,
455
- Assistant
456
- };