@lumi.ai/runner 0.6.3 → 0.6.4

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 +91 -26
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -230,6 +230,8 @@ function jobTarget(job) {
230
230
  return { kind: "chat", chatId: job.chatId };
231
231
  return null;
232
232
  }
233
+ var MAX_RUN_REPORT_CHARS = 6e3;
234
+ var MAX_RUN_REPORT_SUMMARY_CHARS = 300;
233
235
 
234
236
  // ../shared/dist/jobProgress.js
235
237
  var MAX_JOB_STEPS = 10;
@@ -772,7 +774,7 @@ function mcpUrl(config2) {
772
774
  }
773
775
 
774
776
  // src/version.ts
775
- var RUNNER_VERSION = true ? "0.6.3" : "0.0.0-dev";
777
+ var RUNNER_VERSION = true ? "0.6.4" : "0.0.0-dev";
776
778
 
777
779
  // src/auth.ts
778
780
  import { signInWithCustomToken } from "firebase/auth";
@@ -1196,6 +1198,13 @@ async function loadSettledBlockers(shipRef, task) {
1196
1198
  return [];
1197
1199
  }
1198
1200
  }
1201
+ function reportSummaryLine(r) {
1202
+ if (r.summary?.trim()) return r.summary.trim();
1203
+ const body = r.report?.trim();
1204
+ if (!body) return "No report.";
1205
+ const firstLine2 = body.split("\n").find((l) => l.trim())?.trim() ?? body;
1206
+ return firstLine2.length <= MAX_RUN_REPORT_SUMMARY_CHARS ? firstLine2 : `${firstLine2.slice(0, MAX_RUN_REPORT_SUMMARY_CHARS - 1)}\u2026`;
1207
+ }
1199
1208
  async function loadJobContext(db, shipId, job) {
1200
1209
  const shipRef = doc(db, COLLECTIONS.ships, shipId);
1201
1210
  const taskRef = doc(shipRef, COLLECTIONS.tasks, job.taskId);
@@ -1276,7 +1285,13 @@ async function loadJobContext(db, shipId, job) {
1276
1285
  activity: activityDocs.map((d) => ({ id: d.id, ...d.data() })).reverse(),
1277
1286
  // read newest-first, rendered chronologically
1278
1287
  activityTruncated: activitySnap.docs.length > MAX_ACTIVITY_IN_PROMPT,
1279
- previousReports: jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_PREVIOUS_JOBS_READ).map((d) => d.data().report).filter((r) => !!r).reverse()
1288
+ // §15.41. No longer filtered down to jobs that HAVE a report: the catalog carries a line per
1289
+ // previous run, and "that run left no report" is itself worth knowing — it is the difference
1290
+ // between a task nobody has worked and one where three runs died without saying why.
1291
+ previousReports: jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_PREVIOUS_JOBS_READ).map((d) => {
1292
+ const data = d.data();
1293
+ return { jobId: d.id, summary: data.reportSummary ?? null, report: data.report ?? null };
1294
+ }).reverse()
1280
1295
  // chronological
1281
1296
  };
1282
1297
  }
@@ -1301,7 +1316,11 @@ function standingRules(ship2, statuses, task, agent) {
1301
1316
  // Amended with the pointer clause, which is the only thing keeping the three tiers from
1302
1317
  // duplicating each other: without it a diligent agent writes the same fact into its report,
1303
1318
  // its notebook and the org brain, and the report grows into a transcript of the other two.
1304
- "End every session by calling run_report: what you did, what remains, what the next run must know. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead (the entry id, or the slug)."
1319
+ // §15.41 added the summary clause. It is second, not an afterthought at the end, because that
1320
+ // one string is what every LATER run on this task reads instead of this report — and what a
1321
+ // human sees in their notification. An agent that treats it as a label writes "Run report",
1322
+ // and four runs later the catalog says nothing at all.
1323
+ "End every session by calling run_report: what you did, what remains, what the next run must know. Give it a summary too \u2014 one or two sentences that stand in for the whole report in later runs, so write the sentence you would want the next run to read first. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead (the entry id, or the slug)."
1305
1324
  ].join("\n- ");
1306
1325
  }
1307
1326
  function formatTaskDate(millis, now = Date.now()) {
@@ -1341,26 +1360,17 @@ function buildPrompt(ctx, reason, mcpServers = [], tokenRepos, githubUnavailable
1341
1360
  const parts = [];
1342
1361
  const statuses = shipTaskStatuses(ctx.ship);
1343
1362
  const playbook = usableWorkflow(ctx.workflow);
1363
+ let preamble = null;
1344
1364
  if (reason === "activity") {
1345
- parts.push(
1346
- "# Why this session started\n\nSomeone posted on your task after your last run. Read the newest activity below" + (playbook ? ", then follow your playbook below." : ", respond via task_comment, do any follow-up work it asks for, and set the task status honestly if it needs to change.")
1347
- );
1365
+ preamble = "# Why this session started\n\nSomeone posted on your task after your last run. Read the newest activity below" + (playbook ? ", then follow your playbook above." : ", respond via task_comment, do any follow-up work it asks for, and set the task status honestly if it needs to change.");
1348
1366
  } else if (reason === "watch") {
1349
- parts.push(
1350
- "# Why this session started\n\nOne of your playbooks watches the board, and this task matched it. Nobody assigned it to you and nobody else is working it right now \u2014 you are picking it up.\n\n**Leave it in a status that says what happened.** The board is how the watch decides what still needs attention, so a task you leave in the same status will come back to you on the next scan" + (playbook ? ", which your playbook below is written for." : ".")
1351
- );
1367
+ preamble = "# Why this session started\n\nOne of your playbooks watches the board, and this task matched it. Nobody assigned it to you and nobody else is working it right now \u2014 you are picking it up.\n\n**Leave it in a status that says what happened.** The board is how the watch decides what still needs attention, so a task you leave in the same status will come back to you on the next scan" + (playbook ? ", which your playbook above is written for." : ".");
1352
1368
  } else if (reason === "resume_after_approval") {
1353
- parts.push(
1354
- "# Why this session started\n\nYou asked a captain for permission and stopped. They have now answered \u2014 their decision is the newest entry in the task activity below. Read it first.\n\n**If they approved it, do that thing now** \u2014 the permission is granted for this task and may be single-use, so do not ask again for the same thing. **If they refused, do not retry and do not look for a way around it**: say what you will do instead, or hand the task back with task_assign."
1355
- );
1369
+ preamble = "# Why this session started\n\nYou asked a captain for permission and stopped. They have now answered \u2014 their decision is the newest entry in the task activity below. Read it first.\n\n**If they approved it, do that thing now** \u2014 the permission is granted for this task and may be single-use, so do not ask again for the same thing. **If they refused, do not retry and do not look for a way around it**: say what you will do instead, or hand the task back with task_assign.";
1356
1370
  } else if (reason === "unblocked") {
1357
- parts.push(
1358
- '# Why this session started\n\nThis task was blocked and is not any more: everything it was waiting on is now done. Nobody has just assigned it to you \u2014 you have had it all along, and the work has become startable.\n\n**Read "What you were waiting on" below before you do anything else.** It carries what those tasks produced, which is the input this work was held up for; starting without it means redoing or contradicting somebody else' + (playbook ? ", then follow your playbook below." : "'s work.")
1359
- );
1371
+ preamble = '# Why this session started\n\nThis task was blocked and is not any more: everything it was waiting on is now done. Nobody has just assigned it to you \u2014 you have had it all along, and the work has become startable.\n\n**Read "What you were waiting on" below before you do anything else.** It carries what those tasks produced, which is the input this work was held up for; starting without it means redoing or contradicting somebody else' + (playbook ? ", then follow your playbook above." : "'s work.");
1360
1372
  } else if (reason === "schedule") {
1361
- parts.push(
1362
- "# Why this session started\n\nThis is a scheduled run: one of your own playbooks created this task on its cron and assigned it to you. It is routine work, not a request from a person, so nobody is waiting on a reply" + (playbook ? " \u2014 the playbook below is the work, and the task's description is only a record of why it exists." : ".")
1363
- );
1373
+ preamble = "# Why this session started\n\nThis is a scheduled run: one of your own playbooks created this task on its cron and assigned it to you. It is routine work, not a request from a person, so nobody is waiting on a reply" + (playbook ? " \u2014 the playbook above is the work, and the task's description is only a record of why it exists." : ".");
1364
1374
  }
1365
1375
  parts.push(`# Who you are
1366
1376
 
@@ -1385,6 +1395,7 @@ This is the playbook for this kind of work \u2014 follow it. Your contract and t
1385
1395
  ${playbook.instructions}`
1386
1396
  );
1387
1397
  }
1398
+ if (preamble) parts.push(preamble);
1388
1399
  const t = ctx.task;
1389
1400
  const now = Date.now();
1390
1401
  const statusLabel = statusById(statuses, t.status)?.label ?? t.status;
@@ -1436,12 +1447,28 @@ ${p.description || "(no description)"}`
1436
1447
  ${note2}${feed}`);
1437
1448
  }
1438
1449
  if (ctx.previousReports.length > 0) {
1439
- const reports = ctx.previousReports.map((r, i) => `## Run ${i + 1}
1450
+ const runs = ctx.previousReports;
1451
+ const latest = runs[runs.length - 1];
1452
+ const older = runs.slice(0, -1);
1453
+ const sections = [];
1454
+ if (older.length > 0) {
1455
+ const lines = older.map((r) => `- (\`${r.jobId}\`) ${reportSummaryLine(r)}`).join("\n");
1456
+ sections.push(
1457
+ `## Earlier runs
1440
1458
 
1441
- ${r}`).join("\n\n");
1459
+ Oldest first, one line each. To read one in full, call \`task_get\` with this task's id and \`reportJobId\` set to the id in brackets.
1460
+
1461
+ ${lines}`
1462
+ );
1463
+ }
1464
+ sections.push(
1465
+ `## Most recent run (\`${latest.jobId}\`)
1466
+
1467
+ ${latest.report ?? "That run left no report."}`
1468
+ );
1442
1469
  parts.push(`# Reports from previous runs on this task
1443
1470
 
1444
- ${reports}`);
1471
+ ${sections.join("\n\n")}`);
1445
1472
  }
1446
1473
  const githubRepos = tokenRepos ?? ctx.agent.tools.github.repos;
1447
1474
  if (ctx.agent.tools.github.enabled && githubUnavailable) {
@@ -1517,7 +1544,10 @@ async function loadChatContext(db, shipId, job) {
1517
1544
  limit2(MAX_CHAT_REPORTS + 1)
1518
1545
  )
1519
1546
  );
1520
- previousReports = jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_CHAT_REPORTS).map((d) => d.data().report).filter((r) => !!r).reverse();
1547
+ previousReports = jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_CHAT_REPORTS).map((d) => {
1548
+ const data = d.data();
1549
+ return { jobId: d.id, summary: data.reportSummary ?? null, report: data.report ?? null };
1550
+ }).reverse();
1521
1551
  } catch {
1522
1552
  previousReports = [];
1523
1553
  }
@@ -1553,7 +1583,7 @@ function chatStandingRules(ship2, agent) {
1553
1583
  scopeRule,
1554
1584
  registerRule,
1555
1585
  routingRule,
1556
- "End the session by calling run_report: what was discussed and anything the next run must know. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead."
1586
+ "End the session by calling run_report: what was discussed and anything the next run must know. Give it a summary too \u2014 one or two sentences that stand in for the whole report once this conversation is long enough to scroll out of the window. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead."
1557
1587
  ].join("\n- ");
1558
1588
  }
1559
1589
  function buildChatPrompt(ctx, mcpServers = [], tokenRepos, githubUnavailable) {
@@ -1576,15 +1606,22 @@ ${ctx.agent.contract}`);
1576
1606
 
1577
1607
  - ${chatStandingRules(ctx.ship, ctx.agent)}`);
1578
1608
  if (ctx.previousReports.length > 0) {
1579
- const reports = ctx.previousReports.map((r, i) => `## Earlier run ${i + 1}
1609
+ const runs = ctx.previousReports;
1610
+ const latest = runs[runs.length - 1];
1611
+ const older = runs.slice(0, -1);
1612
+ const sections = [];
1613
+ if (older.length > 0) {
1614
+ sections.push(older.map((r) => `- ${reportSummaryLine(r)}`).join("\n"));
1615
+ }
1616
+ sections.push(`## Most recent run
1580
1617
 
1581
- ${r}`).join("\n\n");
1618
+ ${latest.report ?? "That run left no report."}`);
1582
1619
  parts.push(
1583
1620
  `# Earlier in this chat
1584
1621
 
1585
1622
  The conversation is longer than the window below. These are your own notes from previous runs on this chat, oldest first.
1586
1623
 
1587
- ${reports}`
1624
+ ${sections.join("\n\n")}`
1588
1625
  );
1589
1626
  }
1590
1627
  const title = ctx.chat.title?.trim();
@@ -2445,6 +2482,19 @@ async function uploadTranscript(storage, shipId, jobId, redacted) {
2445
2482
  function utcDay(millis) {
2446
2483
  return new Date(millis).toISOString().slice(0, 10);
2447
2484
  }
2485
+ function backstopReportContent(resultText) {
2486
+ const text = resultText.trim();
2487
+ if (!text) {
2488
+ const none = "This run ended without writing a report, and left no final text to fall back on.";
2489
+ return { report: none, summary: none };
2490
+ }
2491
+ const marker = "\n\n\u2026(truncated)";
2492
+ const report4 = text.length <= MAX_RUN_REPORT_CHARS ? text : `${text.slice(0, MAX_RUN_REPORT_CHARS - marker.length)}${marker}`;
2493
+ const firstLine2 = text.split("\n").find((l) => l.trim())?.trim() ?? text;
2494
+ const ellipsis = "\u2026";
2495
+ const summary = firstLine2.length <= MAX_RUN_REPORT_SUMMARY_CHARS ? firstLine2 : `${firstLine2.slice(0, MAX_RUN_REPORT_SUMMARY_CHARS - ellipsis.length)}${ellipsis}`;
2496
+ return { report: report4, summary };
2497
+ }
2448
2498
  async function finalizeJob(db, shipId, job, input) {
2449
2499
  const shipRef = doc6(db, COLLECTIONS.ships, shipId);
2450
2500
  const jobRef = doc6(shipRef, COLLECTIONS.jobs, job.id);
@@ -2452,6 +2502,7 @@ async function finalizeJob(db, shipId, job, input) {
2452
2502
  const usageRef = doc6(shipRef, COLLECTIONS.usageDaily, utcDay(now));
2453
2503
  await runTransaction(db, async (tx) => {
2454
2504
  const usageSnap = await tx.get(usageRef);
2505
+ const jobSnap = await tx.get(jobRef);
2455
2506
  const totals = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
2456
2507
  const byAgent = usageSnap.data()?.byAgent ?? {};
2457
2508
  const agentAgg = {
@@ -2459,7 +2510,10 @@ async function finalizeJob(db, shipId, job, input) {
2459
2510
  ...byAgent[job.agentId] ?? {}
2460
2511
  };
2461
2512
  const u = input.usage;
2513
+ const wroteReport = !!jobSnap.data()?.report;
2514
+ const backstop = !wroteReport && input.resultText !== void 0 ? backstopReportContent(input.resultText) : null;
2462
2515
  tx.update(jobRef, {
2516
+ ...backstop ? { report: backstop.report, reportSummary: backstop.summary } : {},
2463
2517
  status: input.status,
2464
2518
  endedAt: now,
2465
2519
  usage: u,
@@ -4036,6 +4090,10 @@ async function startDaemon() {
4036
4090
  status: "stopped",
4037
4091
  usage,
4038
4092
  transcriptPath,
4093
+ // §15.41. A stopped run is the case that needs the backstop MOST: it was cut off
4094
+ // mid-thought, so it almost certainly never reached `run_report` — and whatever it had
4095
+ // got to is what the next run on this task would otherwise have to rediscover.
4096
+ resultText,
4039
4097
  mcpServers: extraMcpServers.map((s) => s.key)
4040
4098
  });
4041
4099
  const by = slot.stop.by;
@@ -4077,6 +4135,9 @@ async function startDaemon() {
4077
4135
  status: "done",
4078
4136
  usage,
4079
4137
  transcriptPath,
4138
+ // §15.41. Only used when the session never called `run_report` — the ordinary path is
4139
+ // that it did, and a real report always wins inside the transaction.
4140
+ resultText,
4080
4141
  mcpServers: extraMcpServers.map((s) => s.key)
4081
4142
  });
4082
4143
  log2(`Job ${job.id} done (${usage.inputTokens}in/${usage.outputTokens}out tokens).`);
@@ -4130,6 +4191,10 @@ async function startDaemon() {
4130
4191
  usage,
4131
4192
  transcriptPath,
4132
4193
  error: failure,
4194
+ // §15.41. A failed run still did work, and the retry — or the next run after the retry
4195
+ // is spent — starts from the context pack alone. `error` is the tail for a human; this
4196
+ // is the continuity for the next session, and they are read by different readers.
4197
+ resultText,
4133
4198
  mcpServers: extraMcpServers.map((s) => s.key)
4134
4199
  });
4135
4200
  if (target.kind === "chat") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "type": "module",
5
5
  "description": "Lumi Crew runner daemon — claims jobs from your Ships and executes them as headless Claude sessions on your own machine.",
6
6
  "//name": "The ONLY package in this monorepo published to the public registry, so it is the one that does not follow the internal @lumi/crew-* convention: `@lumi` is not a scope we own, `@lumi.ai` is (the npm org). The workspace DIRECTORY stays packages/crew/runner — renaming the package is not renaming the folder.",