cumora 0.1.45 → 0.1.47

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.
Files changed (2) hide show
  1. package/dist/cli.js +227 -23
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // ../server/src/agents/computer/daemon.ts
4
+ import { createHash } from "node:crypto";
4
5
  import { mkdir as mkdir2, writeFile as writeFile2, readFile, chmod } from "node:fs/promises";
5
6
  import { homedir, hostname } from "node:os";
6
7
  import { join as join2 } from "node:path";
@@ -56,8 +57,37 @@ async function exists(p) {
56
57
  }
57
58
  }
58
59
  async function ensureCommonHome(home) {
60
+ await mkdir(join(home, "memory"), { recursive: true });
59
61
  await mkdir(join(home, "notes"), { recursive: true });
60
62
  await mkdir(join(home, "workspace"), { recursive: true });
63
+ const memoryIndex = join(home, "memory", "MEMORY.md");
64
+ if (!await exists(memoryIndex)) {
65
+ await writeFile(
66
+ memoryIndex,
67
+ "# Memory index\n\nOne line per durable fact, pointing at the file that holds it:\n`- [Title](file.md) \u2014 one-line hook`\n\nWrite the fact itself in its own `memory/<topic>.md` file; keep this index short.\n",
68
+ "utf8"
69
+ );
70
+ }
71
+ }
72
+ var MAX_FAILURE_LINES = 30;
73
+ var MAX_FAILURE_CHARS = 4e3;
74
+ var ANSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/g;
75
+ function cleanLine(line) {
76
+ return line.replace(ANSI_RE, "").replace(/\r/g, "").trim();
77
+ }
78
+ function pushTail(lines, line) {
79
+ if (!line) return;
80
+ lines.push(line);
81
+ if (lines.length > MAX_FAILURE_LINES) lines.shift();
82
+ }
83
+ function failurePreview(args) {
84
+ const parts = [];
85
+ if (args.stderr.length > 0) parts.push(args.stderr.join("\n"));
86
+ if (args.stdout.length > 0) parts.push(args.stdout.join("\n"));
87
+ const detail = parts.join("\n").trim();
88
+ const prefix = args.signalName ? `process terminated by ${args.signalName}` : `process exited with code ${args.exitCode}`;
89
+ return detail ? `${prefix}
90
+ ${detail}`.slice(0, MAX_FAILURE_CHARS) : prefix;
61
91
  }
62
92
  function spawnEngine(bin, args, { home, env, onLog, signal }) {
63
93
  return new Promise((resolve, reject) => {
@@ -66,20 +96,29 @@ function spawnEngine(bin, args, { home, env, onLog, signal }) {
66
96
  child.kill("SIGTERM");
67
97
  };
68
98
  signal.addEventListener("abort", onAbort, { once: true });
69
- const pump = (buf) => {
99
+ const stderrTail = [];
100
+ const stdoutTail = [];
101
+ const pump = (stream, buf) => {
70
102
  for (const line of buf.toString("utf8").split("\n")) {
71
- if (line.trim()) onLog(line);
103
+ const cleaned = cleanLine(line);
104
+ if (!cleaned) continue;
105
+ pushTail(stream === "stderr" ? stderrTail : stdoutTail, cleaned);
106
+ onLog(cleaned);
72
107
  }
73
108
  };
74
- child.stdout.on("data", pump);
75
- child.stderr.on("data", pump);
109
+ child.stdout.on("data", (buf) => pump("stdout", buf));
110
+ child.stderr.on("data", (buf) => pump("stderr", buf));
76
111
  child.on("error", (err) => {
77
112
  signal.removeEventListener("abort", onAbort);
78
113
  reject(err);
79
114
  });
80
- child.on("close", (code) => {
115
+ child.on("close", (code, signalName) => {
81
116
  signal.removeEventListener("abort", onAbort);
82
- resolve({ exitCode: code ?? 0 });
117
+ const exitCode = code ?? (signalName ? 128 : 1);
118
+ resolve({
119
+ exitCode,
120
+ error: exitCode === 0 ? void 0 : failurePreview({ exitCode, signalName, stderr: stderrTail, stdout: stdoutTail })
121
+ });
83
122
  });
84
123
  });
85
124
  }
@@ -92,7 +131,13 @@ var PERSONA_HEADER = (p) => `# ${p.name}${p.role ? ` \u2014 ${p.role}` : ""}
92
131
  You are **${p.name}**, a member of a team that collaborates in Cumora (a team chat).
93
132
  This directory is your private home and your working directory \u2014 it persists
94
133
  across wakes and is yours alone. Its layout:
95
- - \`CLAUDE.md\` (this file) + \`memory/\` + \`notes/\` \u2014 your durable memory and notes.
134
+ - \`CLAUDE.md\` (this file) \u2014 always loaded each wake; keep it short.
135
+ - \`memory/\` \u2014 your durable memory. There is NO hidden memory store: to remember
136
+ something across wakes you MUST write it to a file here (e.g. \`memory/<topic>.md\`)
137
+ and add a one-line pointer in \`memory/MEMORY.md\`. Saying "I'll remember" without
138
+ writing a file means you will NOT remember. At the start of each wake, read
139
+ \`memory/MEMORY.md\` (and the files it points to) to recall what you know.
140
+ - \`notes/\` \u2014 scratch notes and drafts.
96
141
  - \`.claude/skills/\` \u2014 your skills.
97
142
  - \`workspace/\` \u2014 **put all project files and scratch here**: git clones, builds,
98
143
  downloads, temp files. Always \`cd workspace\` (or use \`workspace/\u2026\` paths) for
@@ -177,6 +222,7 @@ var DEFAULT_SERVER = process.env.CUMORA_SERVER_URL || "https://api.cumora.ai";
177
222
  var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
178
223
  var AGENT_POLL_MS = 6e4;
179
224
  var HEARTBEAT_MS = 3e4;
225
+ var MAX_VISIBLE_ERROR_CHARS = 900;
180
226
  function parseArgs(argv) {
181
227
  const out = {};
182
228
  for (let i = 0; i < argv.length; i++) {
@@ -210,6 +256,49 @@ async function runtimeBest(serverUrl, path, token, body) {
210
256
  return null;
211
257
  }
212
258
  }
259
+ async function runtimeGet(serverUrl, path, token) {
260
+ try {
261
+ const res = await fetch(`${serverUrl}/runtime${path}`, {
262
+ method: "GET",
263
+ headers: { Authorization: `Bearer ${token}` }
264
+ });
265
+ return res.ok ? await res.json().catch(() => null) : null;
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+ function hashText(text) {
271
+ return createHash("sha1").update(text).digest("hex").slice(0, 12);
272
+ }
273
+ function conciseError(text) {
274
+ return text.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "").replace(/\r/g, "").trim().slice(0, MAX_VISIBLE_ERROR_CHARS);
275
+ }
276
+ function authFailureHint(engine, detail) {
277
+ if (!/(auth|login|token|quota|credit|billing|subscription|rate limit|usage limit|api key)/i.test(detail)) {
278
+ return "Check the daemon terminal for details, then wake the agent again.";
279
+ }
280
+ if (engine === "claude") {
281
+ return "Open Claude Code on that computer and sign in, refresh quota, or add credits, then wake the agent again.";
282
+ }
283
+ return "Open Codex on that computer and refresh its login or quota, then wake the agent again.";
284
+ }
285
+ function missingEngineMessage() {
286
+ return [
287
+ "no supported local agent engine found on PATH",
288
+ "",
289
+ "Install and sign in to at least one of:",
290
+ " - Claude Code: install the `claude` CLI, then run `claude` once to sign in",
291
+ " - Codex: install the `codex` CLI, then run `codex` once to sign in",
292
+ "",
293
+ "After that, rerun:",
294
+ " npx cumora agent computer --pair <code>"
295
+ ].join("\n");
296
+ }
297
+ async function requireLocalEngine() {
298
+ const engines = await detectEngines();
299
+ if (engines.length === 0) throw new Error(missingEngineMessage());
300
+ return engines;
301
+ }
213
302
  async function loadConfig() {
214
303
  try {
215
304
  return JSON.parse(await readFile(CONFIG_PATH, "utf8"));
@@ -265,10 +354,7 @@ async function detectHostName() {
265
354
  return base || "My computer";
266
355
  }
267
356
  async function doPair(code, serverUrl) {
268
- const engines = await detectEngines();
269
- if (engines.length === 0) {
270
- console.warn("[computer] warning: no engine found on PATH (need `claude` or `codex`).");
271
- }
357
+ const engines = await requireLocalEngine();
272
358
  const paired = await api(
273
359
  serverUrl,
274
360
  "/api/computers/pair",
@@ -332,16 +418,93 @@ var AgentRunner = class {
332
418
  CUMORA_AGENT_ID: this.agent.id
333
419
  };
334
420
  }
335
- prompt() {
421
+ visibleEngineError(exitCode, detail) {
422
+ const raw = conciseError(detail || `process exited with code ${exitCode}`);
423
+ const redacted = raw.split(this.home).join("<agent home>").split(homedir()).join("~");
424
+ return `local ${this.adapter.id} failed (exit ${exitCode}): ${redacted}`;
425
+ }
426
+ async publishEngineFailure(args) {
427
+ if (args.runId) {
428
+ await runtimeBest(this.cfg.serverUrl, "/events", args.token, {
429
+ runId: args.runId,
430
+ kind: "engine.failed",
431
+ level: "error",
432
+ title: `${this.adapter.id} exited ${args.exitCode}`,
433
+ data: { error: args.error },
434
+ stage: "failed"
435
+ });
436
+ }
437
+ const hint = authFailureHint(this.adapter.id, args.error);
438
+ const conversationIds = await this.failureConversationIds(args.token, args.conversationId);
439
+ await Promise.all(conversationIds.map((conversationId) => runtimeBest(this.cfg.serverUrl, "/notices", args.token, {
440
+ conversationId,
441
+ agentId: this.agent.id,
442
+ noticeKind: "byoa_engine_failed",
443
+ text: `${this.agent.name} could not run on local ${this.adapter.id}: ${args.error}
444
+ ${hint}`,
445
+ dedupeKey: `byoa_engine_failed:${this.agent.id}:${conversationId}:${hashText(args.error)}`,
446
+ dedupeTtlSec: 900
447
+ })));
448
+ }
449
+ async failureConversationIds(token, conversationId) {
450
+ if (conversationId) return [conversationId];
451
+ const inbox = await runtimeGet(this.cfg.serverUrl, "/inbox", token);
452
+ const ids = /* @__PURE__ */ new Set();
453
+ for (const row of inbox?.rows ?? []) {
454
+ if (typeof row.conversation_id === "string" && row.conversation_id) ids.add(row.conversation_id);
455
+ if (ids.size >= 5) break;
456
+ }
457
+ return [...ids];
458
+ }
459
+ /** Preload the memory index (memory/MEMORY.md) so it's ALWAYS in the wake
460
+ * prompt — the lightweight BYOA analog of the cloud agent's RAG memory
461
+ * preload. We inject only the small index; the agent opens detail files on
462
+ * demand. Capped so a runaway index can't blow up the prompt. */
463
+ async memoryDigest() {
464
+ try {
465
+ const txt = (await readFile(join2(this.home, "memory", "MEMORY.md"), "utf8")).trim();
466
+ if (!txt) return "";
467
+ return txt.length > 4e3 ? `${txt.slice(0, 4e3)}
468
+ \u2026(truncated \u2014 cat the file for the rest)` : txt;
469
+ } catch {
470
+ return "";
471
+ }
472
+ }
473
+ async inboxTriage(token) {
474
+ return runtimeGet(this.cfg.serverUrl, "/inbox-triage", token);
475
+ }
476
+ formatTriageNote(triage) {
477
+ if (!triage) return "";
478
+ const note = typeof triage?.promptNote === "string" ? triage.promptNote.trim() : "";
479
+ if (!note) return "";
480
+ const state = triage.actionable === false ? "not relevant" : "relevant";
481
+ const source = typeof triage.source === "string" ? triage.source : "server";
482
+ const reason = typeof triage.reason === "string" && triage.reason.trim() ? `
483
+ Reason: ${triage.reason.trim().slice(0, 500)}` : "";
484
+ return `Small-brain inbox triage (${state}, ${source}): ${note}${reason}`;
485
+ }
486
+ prompt(memoryDigest, triageNote) {
336
487
  return `You've been woken because there's new activity in your Cumora conversations.
337
488
 
338
- Use the \`cumora\` tool to catch up and respond:
489
+ ` + (triageNote ? `${triageNote}
490
+
491
+ ` : ``) + `Use the \`cumora\` tool to catch up and respond:
339
492
  1. \`cumora inbox\` \u2014 see what's unread
340
493
  2. \`cumora messages <conversationId> --tail 30\` \u2014 read the relevant thread(s)
341
- 3. \`cumora reply <conversationId> '<text>'\` \u2014 reply, in your own voice, only where you add something
494
+ 3. \`cumora glance <conversationId>\` \u2014 in group threads, read the room right before replying
495
+ 4. \`cumora reply <conversationId> '<text>'\` \u2014 reply in your own voice
496
+
497
+ ` + (memoryDigest ? `Your memory index (\`memory/MEMORY.md\`) \u2014 consult before replying; \`cat memory/<file>\` for any detail it points to:
498
+ ${memoryDigest}
499
+
500
+ ` : ``) + `Memory is your ONLY durable store. When the operator asks you to remember something (or you learn a durable fact), you MUST write it to a file under \`memory/\` and add a one-line pointer in \`memory/MEMORY.md\` \u2014 actually run the write. Saying "got it, I'll remember" does NOT persist anything.
342
501
 
343
502
  Mentions: to notify or address a specific teammate, write @<their-id> using their participant id (the short id shown in \`cumora messages\` and \`cumora participants\`), NOT their display name. "@Alex P" does nothing; "@alex-3f9a" actually pings them. Run \`cumora participants\` if you need to look up an id.
344
503
 
504
+ When a HUMAN says \`@all\` in a group conversation, they addressed YOU too. Do not silently ack an @all just because it is simple. If it is a greeting like "hello @all" or "hi @all", reply briefly as yourself. If it asks every agent to introduce themselves, vote, count, or each give an answer, you should answer your part. Before replying in a group, run \`cumora glance <conversationId>\` so you do not duplicate a peer's identical answer.
505
+
506
+ Concrete task handoff: if the human asks for one concrete deliverable in a group (generate images, draft the email, summarize the thread, make the chart, fix X) and another teammate has clearly claimed or is already executing that exact task, stay quiet unless you have been specifically named or the result lands and needs your specific missing contribution. Do not add side commentary while a teammate is working.
507
+
345
508
  Know when to stay quiet \u2014 this is how teams avoid endless back-and-forth:
346
509
  - Read the WHOLE thread first, including your own past replies. If you've already made your point, don't repeat it. Saying nothing is better than echoing.
347
510
  - Only reply where you genuinely add something the thread doesn't already have. Don't reply just to acknowledge, agree, thank, or sign off ("sounds good", "great idea", "\u{1F44D}").
@@ -367,24 +530,43 @@ If nothing genuinely needs you, it's fine to do nothing and stop. When finished,
367
530
  const token = this.token;
368
531
  const convo = this.lastWakeConvo;
369
532
  this.lastWakeConvo = null;
533
+ const triage = await this.inboxTriage(token);
534
+ if (triage?.actionable === false) {
535
+ const reason = typeof triage.reason === "string" ? triage.reason : "not relevant";
536
+ console.log(`[computer] ${this.agent.id} skipped by small-brain inbox triage: ${reason}`);
537
+ await runtimeBest(this.cfg.serverUrl, "/status", token, { status: "avail" });
538
+ continue;
539
+ }
370
540
  await runtimeBest(this.cfg.serverUrl, "/status", token, { status: "thinking" });
371
541
  let typingTimer;
372
542
  if (convo) {
373
543
  const ping = () => {
374
544
  void runtimeBest(this.cfg.serverUrl, "/typing", token, { conversationId: convo, done: false });
375
545
  };
546
+ const thinkingPing = () => {
547
+ void runtimeBest(this.cfg.serverUrl, "/thinking/mark", token, { conversationIds: [convo], ttlSec: 60 });
548
+ };
376
549
  ping();
377
- typingTimer = setInterval(ping, 6e3);
550
+ thinkingPing();
551
+ typingTimer = setInterval(() => {
552
+ ping();
553
+ thinkingPing();
554
+ }, 6e3);
378
555
  }
379
556
  const run = await runtimeBest(this.cfg.serverUrl, "/runs", token, {
380
557
  trigger: { source: "byoa", engine: this.adapter.id }
381
558
  });
382
559
  const controller = new AbortController();
383
560
  let exitCode = 0;
561
+ let engineError = null;
384
562
  try {
563
+ const [memoryDigest, triageNote] = await Promise.all([
564
+ this.memoryDigest(),
565
+ Promise.resolve(this.formatTriageNote(triage))
566
+ ]);
385
567
  const result = await this.adapter.run({
386
568
  home: this.home,
387
- prompt: this.prompt(),
569
+ prompt: this.prompt(memoryDigest, triageNote),
388
570
  env: this.engineEnv(),
389
571
  model: this.agent.model,
390
572
  fastModel: this.agent.fastModel,
@@ -392,17 +574,32 @@ If nothing genuinely needs you, it's fine to do nothing and stop. When finished,
392
574
  signal: controller.signal
393
575
  });
394
576
  exitCode = result.exitCode;
577
+ if (result.error) engineError = this.visibleEngineError(exitCode, result.error);
395
578
  } catch (err) {
396
579
  console.error(`[computer] ${this.agent.id} engine spawn failed:`, err instanceof Error ? err.message : err);
397
580
  exitCode = 1;
581
+ engineError = this.visibleEngineError(exitCode, err instanceof Error ? err.stack || err.message : String(err));
398
582
  } finally {
399
583
  if (typingTimer) clearInterval(typingTimer);
400
- if (convo) await runtimeBest(this.cfg.serverUrl, "/typing", token, { conversationId: convo, done: true });
584
+ if (convo) {
585
+ await runtimeBest(this.cfg.serverUrl, "/typing", token, { conversationId: convo, done: true });
586
+ await runtimeBest(this.cfg.serverUrl, "/thinking/unmark", token, { conversationIds: [convo] });
587
+ }
588
+ }
589
+ if (engineError) {
590
+ await this.publishEngineFailure({
591
+ token,
592
+ runId: run?.runId,
593
+ conversationId: convo,
594
+ error: engineError,
595
+ exitCode
596
+ });
401
597
  }
402
598
  if (run?.runId) {
403
599
  await runtimeBest(this.cfg.serverUrl, `/runs/${run.runId}/finish`, token, {
404
600
  status: exitCode === 0 ? "completed" : "failed",
405
- summary: `byoa ${this.adapter.id} run (exit ${exitCode})`
601
+ summary: engineError ?? `byoa ${this.adapter.id} run (exit ${exitCode})`,
602
+ error: engineError
406
603
  });
407
604
  }
408
605
  await runtimeBest(this.cfg.serverUrl, "/status", token, { status: "avail" });
@@ -453,10 +650,12 @@ async function doRun(serverOverride) {
453
650
  return;
454
651
  }
455
652
  if (serverOverride) cfg.serverUrl = serverOverride;
456
- const available = await detectEngines();
457
- if (available.length === 0) {
458
- console.error("[computer] no engine on PATH \u2014 install Claude Code (`claude`) or Codex (`codex`).");
459
- process.exitCode = 1;
653
+ let available;
654
+ try {
655
+ available = await requireLocalEngine();
656
+ } catch (err) {
657
+ console.error(`[computer] ${err instanceof Error ? err.message : String(err)}`);
658
+ process.exitCode = 70;
460
659
  return;
461
660
  }
462
661
  console.log(`[computer] starting ${cfg.computerId} @ ${cfg.serverUrl} (engines: ${available.join(", ")})`);
@@ -546,4 +745,9 @@ async function main() {
546
745
  );
547
746
  process.exit(argv.length ? 1 : 0);
548
747
  }
549
- void main();
748
+ void main().catch((err) => {
749
+ const message = err instanceof Error ? err.message : String(err);
750
+ process.stderr.write(`cumora: ${message}
751
+ `);
752
+ process.exit(70);
753
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cumora",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "Run your Cumora agents on your own machine or VPS, powered by your local Claude Code or Codex CLI (BYOA).",
5
5
  "type": "module",
6
6
  "bin": {