eve-hub 0.6.70 → 0.7.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # eve-hub
2
2
 
3
+ ## 0.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - A subagent's spawn call stays running through the parent's next step until its task settles, so a working card keeps its subagent orbs.
8
+
9
+ ## 0.7.0
10
+
11
+ ### Minor Changes
12
+
13
+ - main's compact schedule is gone: the hub's sweep compacts a session whose turn ended at 130k+ input tokens. The kit no longer has `schedules/compact` or `hub.compact`.
14
+
3
15
  ## 0.6.70
4
16
 
5
17
  ### Patch Changes
package/kit-modules.json CHANGED
@@ -10,7 +10,6 @@
10
10
  "memory/bot",
11
11
  "memory/team",
12
12
  "memory/workspace",
13
- "schedules/compact",
14
13
  "schedules/dash",
15
14
  "schedules/prune",
16
15
  "skills/onboarding-profile",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.6.70",
3
+ "version": "0.7.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
6
6
  "type": "module",
@@ -14,7 +14,6 @@ export type Hub = {
14
14
  commit(id: string, withState: { state?: unknown }, posts: Post[]): Promise<unknown>;
15
15
  post(id: string, text: string, key?: string): Promise<unknown>;
16
16
  track<T>(id: string, fn: () => Promise<T>): Promise<T | undefined>;
17
- compact(threadId: string, bot: string, by: string, report?: { trigger?: string; reportTo?: string }): Promise<{ status: number; ok: boolean; body: unknown }>;
18
17
  dashChecked(prFacts?: Record<string, { head: string | null; ci: string }>): Promise<Ok & { checkedAt: string }>;
19
18
  setSummary(path: string, text: string): Promise<unknown>;
20
19
  dashClassified(): Promise<Ok & { classifiedAt: string }>;
package/schedules/hub.mjs CHANGED
@@ -61,9 +61,6 @@ export function createHub({ base = registryBase(), agent = "main", fetchImpl = f
61
61
  },
62
62
  // A one-off post outside a run (runOnce commits through `commit`).
63
63
  post: (id, text, key) => must(`/api/threads/${encodeURIComponent(threadOf(id))}/messages`, { method: "POST", body: JSON.stringify({ author: "system", text, ...(key ? { clientId: key } : {}) }) }),
64
- // With `report` ({ trigger, reportTo: "<bot>/<schedule>" }) the hub posts
65
- // this compaction's report lines into that schedule's thread itself.
66
- compact: (threadId, bot, by, report = {}) => call(`/api/threads/${encodeURIComponent(threadId)}/compact`, { method: "POST", body: JSON.stringify({ agent: bot, by, ...report }) }),
67
64
  // The Dash was scanned now (hosted-hub/src/domain/dash.mjs checkedAt), changed or not.
68
65
  // `prFacts` ({ "owner/repo#12": { head, ci } }): what the scan's GitHub query said of the PRs reviews are about.
69
66
  dashChecked: (prFacts = {}) => must("/api/dash/checked", { method: "POST", body: JSON.stringify({ by: agent, prFacts }) }),
package/stubs.mjs CHANGED
@@ -38,7 +38,6 @@ export const MAIN_KIT_FILES = {
38
38
  "tools/dash_update.ts": ["Set Dash entries: importance, lane, reason and deferred per thread."],
39
39
  "tools/apply_tags.ts": ["Retag threads, merge or rename tags: applies a staged proposal or a new batch directly."],
40
40
  // Handler schedules: the hub fires them from its minute clock (registry/schedules.mjs).
41
- "schedules/compact.ts": ["Every 10 minutes: compacts idle sessions once their prompt cache has gone cold, reported in sch-main-compact."],
42
41
  "schedules/dash.ts": ["When a bot's turn ends (fired by the hub), and every 8 minutes: the human's PRs, a Dash entry for each thread that changed, and a new summary for each track with news."],
43
42
  "schedules/prune.ts": ["Every 10 minutes: one prune pass over what archived or deleted threads leave on disk: worktrees, scratch, their-only uploads, idle bots' traces and workflow runs; unsaved work kept as refs/bundles."],
44
43
  };
@@ -51,12 +50,10 @@ export const MAIN_KIT_FILES = {
51
50
  // the kit's bash (HostSandbox's root, the daemon's env; eve's own bash runs
52
51
  // instead), the browser and the screenshot paths of attach_image/view_image,
53
52
  // restart_hub (the hub's own code), memory (the workspace's files), and main's prune schedule (it cleans the hub machine's disk).
54
- // main's compact and dash schedules stay: off the hub's machine compact finds no session to compact
55
- // (hosted sessions carry no last-model-call time), and dash has no PR data (its lookup runs `gh`).
53
+ // main's dash schedule stays: off the hub's machine it has no PR data (its lookup runs `gh`).
56
54
  export const DEPLOYED_KIT_FILES = new Set([
57
55
  "channels/eve.ts",
58
56
  "hooks/mention-gate.ts",
59
- "schedules/compact.ts",
60
57
  "schedules/dash.ts",
61
58
  "skills/onboarding-profile.ts",
62
59
  "tools/onboarding_scripts.ts",
package/world.mjs CHANGED
@@ -10925,7 +10925,7 @@ function reduceActivity(turn, { type, data = {} }, at, onCutOff) {
10925
10925
  changed = trackTaskEvent(turn.card, { type, data }, new Date(at).toISOString()) || changed;
10926
10926
  if (changed) turn.taskCard = taskCardView(turn.card);
10927
10927
  if (type === "step.started") {
10928
- for (const tool of turn.tools) if (tool.status === "running" && tool.step !== void 0 && tool.step <= data.stepIndex) {
10928
+ for (const tool of turn.tools) if (!tool.task && tool.status === "running" && tool.step !== void 0 && tool.step <= data.stepIndex) {
10929
10929
  tool.status = "interrupted";
10930
10930
  changed = true;
10931
10931
  }
@@ -12304,14 +12304,14 @@ function codePointLength(str) {
12304
12304
  const units = str.length;
12305
12305
  if (!highSurrogate.test(str))
12306
12306
  return units;
12307
- let count2 = units;
12307
+ let count = units;
12308
12308
  for (let i = 0; i < units - 1; i++) {
12309
12309
  if ((str.charCodeAt(i) & 64512) === 55296 && (str.charCodeAt(i + 1) & 64512) === 56320) {
12310
- count2--;
12310
+ count--;
12311
12311
  i++;
12312
12312
  }
12313
12313
  }
12314
- return count2;
12314
+ return count;
12315
12315
  }
12316
12316
  function getLengthableOrigin(input2) {
12317
12317
  if (Array.isArray(input2))
@@ -16694,8 +16694,8 @@ function az_default() {
16694
16694
  }
16695
16695
 
16696
16696
  // ../../hosted-hub/node_modules/zod/v4/locales/be.js
16697
- function getBelarusianPlural(count2, one, few, many) {
16698
- const absCount = Math.abs(count2);
16697
+ function getBelarusianPlural(count, one, few, many) {
16698
+ const absCount = Math.abs(count);
16699
16699
  const lastDigit = absCount % 10;
16700
16700
  const lastTwoDigits = absCount % 100;
16701
16701
  if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
@@ -19342,8 +19342,8 @@ function hu_default() {
19342
19342
  }
19343
19343
 
19344
19344
  // ../../hosted-hub/node_modules/zod/v4/locales/hy.js
19345
- function getArmenianPlural(count2, one, many) {
19346
- return Math.abs(count2) === 1 ? one : many;
19345
+ function getArmenianPlural(count, one, many) {
19346
+ return Math.abs(count) === 1 ? one : many;
19347
19347
  }
19348
19348
  function withDefiniteArticle(word) {
19349
19349
  if (!word)
@@ -22081,8 +22081,8 @@ function ro_default() {
22081
22081
  }
22082
22082
 
22083
22083
  // ../../hosted-hub/node_modules/zod/v4/locales/ru.js
22084
- function getRussianPlural(count2, one, few, many) {
22085
- const absCount = Math.abs(count2);
22084
+ function getRussianPlural(count, one, few, many) {
22085
+ const absCount = Math.abs(count);
22086
22086
  const lastDigit = absCount % 10;
22087
22087
  const lastTwoDigits = absCount % 100;
22088
22088
  if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
@@ -30846,11 +30846,11 @@ var PARTICIPANTS = (thread, except = "''") => `SELECT b.name AS bot FROM bots b,
30846
30846
  var NOT_IN_BEFORE = `NOT EXISTS (SELECT 1 FROM (${PARTICIPANTS("?", "?")}) WHERE bot = ?)`;
30847
30847
  var hasJoins = (threadId2) => !threadId2.startsWith("dm-") && !threadId2.startsWith("sch-");
30848
30848
  function routingStatements(messageId2, threadId2, bots, now, { by, joins }, held = {}, steer = false) {
30849
- const quiet2 = !joins || !hasJoins(threadId2);
30849
+ const quiet = !joins || !hasJoins(threadId2);
30850
30850
  const fresh = "EXISTS (SELECT 1 FROM messages WHERE id = ?)";
30851
30851
  return [
30852
30852
  ...bots.flatMap((bot) => [
30853
- ...quiet2 ? [] : eventStatements(
30853
+ ...quiet ? [] : eventStatements(
30854
30854
  threadId2,
30855
30855
  { kind: "join", agent: bot, by, message: messageId2 },
30856
30856
  `@${bot} joined${by !== bot ? ` (added by @${by})` : ""}`,
@@ -31113,44 +31113,30 @@ var MOVES = Object.freeze({
31113
31113
  });
31114
31114
  var VERDICTS = Object.freeze(["ship", "fix-first", "rethink"]);
31115
31115
 
31116
- // ../../hosted-hub/src/domain/cron.mjs
31117
- var MINUTE = 6e4;
31118
- var CATCH_UP_MS = 7 * 24 * 60 * MINUTE;
31119
-
31120
- // ../../hosted-hub/src/store/schedules.ts
31121
- var scheduleThreadId = (agent, id) => `sch-${agent}-${id.replaceAll("/", ".")}`;
31122
- var Info = external_exports.object({ schedules: external_exports.array(external_exports.object({ name: external_exports.string().min(1), cron: external_exports.string().min(1), hasRun: external_exports.boolean() })) });
31123
-
31124
- // ../../hosted-hub/src/domain/compact-report.mjs
31125
- var quiet = (text) => String(text).replace(/@/g, "\uFF20");
31126
- var count = (n) => typeof n === "number" ? n.toLocaleString("en-US") : "?";
31127
- var link = (thread, id) => `[${quiet(String(thread?.title ?? id).replace(/[[\]]/g, ""))}](${id})`;
31128
- var nextTry = (agent) => `next try: after ${quiet(agent)}'s next turn there, once idle 10+ min (or at 40 turns) with its prompt cache gone cold`;
31129
- var compactedLine = (r, thread) => `compacted: ${quiet(r.agent)} on ${link(thread, r.threadId)} \xB7 ${quiet(r.trigger)} \xB7 ${count(r.before)} input tokens before`;
31130
- var afterLine = (r, thread, after) => `after compaction: ${quiet(r.agent)} on ${link(thread, r.threadId)} \xB7 ${count(r.before)} \u2192 ${count(after)} input tokens`;
31131
- var failureLine = (r, thread, reason) => `compact failed: ${quiet(r.agent)} on ${link(thread, r.threadId)} \xB7 ${quiet(r.trigger)} \xB7 ${quiet(reason)}; ${nextTry(r.agent)}`;
31132
-
31133
31116
  // ../../hosted-hub/src/store/compactions.ts
31134
31117
  var ASKED = `(c.attempt_at IS NOT NULL AND c.attempt_at > COALESCE(c.settled_at, 0))`;
31135
31118
  var k = (tokens) => `CAST((${tokens} + 500) / 1000 AS INTEGER) || 'k'`;
31136
31119
  var compactedText = (after) => `'Context compacted' || CASE WHEN c.before_tokens IS NULL THEN ''
31137
31120
  WHEN ${after} IS NULL THEN ' at ' || ${k("c.before_tokens")} || ' input tokens'
31138
31121
  ELSE ': ' || ${k("c.before_tokens")} || ' \u2192 ' || ${k(after)} || ' input tokens' END`;
31139
- function compactionStatements(type, session, event, at, messageId2, report = null) {
31122
+ var COMPACT_WINDOW_MS = 10 * 6e4;
31123
+ var COMPACTABLE_READ = `SELECT s.id, s.thread_id, s.bot, u.input_tokens FROM usage_steps u JOIN sessions s ON s.id = u.session_id
31124
+ WHERE u.at > ? AND u.at <= ? AND u.input_tokens >= ? AND s.ended_at IS NULL
31125
+ AND (u.at, u.sequence, u.step_index) = (SELECT at, sequence, step_index FROM usage_steps WHERE session_id = u.session_id ORDER BY at DESC, sequence DESC, step_index DESC LIMIT 1)
31126
+ AND NOT EXISTS (SELECT 1 FROM turns t WHERE t.session_id = s.id AND t.status IN ${OPEN_TURN_STATUSES})
31127
+ AND NOT EXISTS (SELECT 1 FROM compactions c WHERE c.session_id = s.id AND MAX(COALESCE(c.completed_at, 0), COALESCE(c.attempt_at, 0)) >= u.at)`;
31128
+ function compactionStatements(type, session, event, at, messageId2) {
31140
31129
  const before = `COALESCE(?, (SELECT input_tokens FROM usage_steps WHERE session_id = ? ORDER BY at DESC, sequence DESC, step_index DESC LIMIT 1))`;
31141
31130
  if (type === "compaction.requested")
31142
31131
  return [s(
31143
- `INSERT INTO compactions (session_id, before_tokens) VALUES (?, ${before}) ON CONFLICT (session_id) DO UPDATE SET before_tokens = excluded.before_tokens,
31144
- report_phase = CASE WHEN report_phase = 'asked' THEN 'requested' ELSE report_phase END`,
31132
+ `INSERT INTO compactions (session_id, before_tokens) VALUES (?, ${before}) ON CONFLICT (session_id) DO UPDATE SET before_tokens = excluded.before_tokens`,
31145
31133
  session.id,
31146
31134
  typeof event.usageInputTokens === "number" ? event.usageInputTokens : null,
31147
31135
  session.id
31148
31136
  )];
31149
31137
  if (type === "session.waiting")
31150
31138
  return [
31151
- // eve took the request and went idle with no checkpoint: it kept the history (the reason is in the bot's log). A
31152
- // session.waiting from before eve took the request (the previous turn's, folded late) is not that: only 'requested' counts.
31153
- ...reportLine(report, session, at, `${messageId2}:waiting`, (r) => failureLine(...r, `eve ended it without a checkpoint and kept the history (the reason is in ${session.bot}'s log)`), `report_phase = 'requested'`, null),
31139
+ // eve took the request and went idle with no checkpoint: it kept the history (the reason is in the bot's log).
31154
31140
  s("UPDATE compactions SET settled_at = ? WHERE session_id = ? AND attempt_at IS NOT NULL AND attempt_at > COALESCE(settled_at, 0)", at, session.id)
31155
31141
  ];
31156
31142
  return [
@@ -31172,23 +31158,7 @@ function compactionStatements(type, session, event, at, messageId2, report = nul
31172
31158
  session.thread,
31173
31159
  session.bot,
31174
31160
  messageId2
31175
- ),
31176
- ...reportLine(report, session, at, `${messageId2}:completed`, (r) => compactedLine(...r), `report_phase IN ('asked', 'requested', 'unanswered')`, "compacted")
31177
- ];
31178
- }
31179
- var reportThread = (to) => {
31180
- const [bot, ...id] = to.split("/");
31181
- return scheduleThreadId(bot, id.join("/"));
31182
- };
31183
- var lineOf = (report, session) => [{ agent: session.bot, threadId: session.thread, trigger: report.trigger, before: report.before }, { title: report.title }];
31184
- function reportLine(report, session, at, id, text, when, next) {
31185
- if (!report) return [];
31186
- const thread = reportThread(report.to), rowId = derivedMessageId(id, "report"), has = `EXISTS (SELECT 1 FROM compactions WHERE session_id = ? AND ${when})`;
31187
- return [
31188
- s(`INSERT INTO log (thread_id,type,data,at) SELECT ?, 'thread.created', ?, ? WHERE ${has} AND NOT EXISTS (SELECT 1 FROM threads WHERE id = ?)`, thread, JSON.stringify({ threadId: thread }), at, session.id, thread),
31189
- s(`INSERT OR IGNORE INTO threads (id,title,created_at) SELECT ?, ?, ? WHERE ${has}`, thread, report.to, at, session.id),
31190
- ...eventStatements(thread, { kind: "compaction-report", agent: session.bot }, text(lineOf(report, session)), at, rowId, { sql: has, args: [session.id] }),
31191
- s(`UPDATE compactions SET report_phase = ? WHERE session_id = ? AND ${when}`, next, session.id)
31161
+ )
31192
31162
  ];
31193
31163
  }
31194
31164
  function compactedSizeStatements(sessionId2, inputTokens) {
@@ -31199,7 +31169,6 @@ function compactedSizeStatements(sessionId2, inputTokens) {
31199
31169
  s(`UPDATE compactions SET after_tokens = ? WHERE session_id = ? AND ${waiting("")}`, inputTokens, sessionId2)
31200
31170
  ];
31201
31171
  }
31202
- var reportAfterStatements = (report, session, inputTokens, at, id) => reportLine(report, session, at, `${id}:after`, ([r, thread]) => afterLine(r, thread, inputTokens), `report_phase = 'compacted' AND completed_at IS NOT NULL AND after_tokens IS NULL`, null);
31203
31172
 
31204
31173
  // ../../node_modules/.pnpm/zod@4.5.4/node_modules/zod/v4/core/util.js
31205
31174
  var util_exports2 = {};
@@ -31846,14 +31815,14 @@ function codePointLength2(str) {
31846
31815
  const units = str.length;
31847
31816
  if (!highSurrogate2.test(str))
31848
31817
  return units;
31849
- let count2 = units;
31818
+ let count = units;
31850
31819
  for (let i = 0; i < units - 1; i++) {
31851
31820
  if ((str.charCodeAt(i) & 64512) === 55296 && (str.charCodeAt(i + 1) & 64512) === 56320) {
31852
- count2--;
31821
+ count--;
31853
31822
  i++;
31854
31823
  }
31855
31824
  }
31856
- return count2;
31825
+ return count;
31857
31826
  }
31858
31827
  function getLengthableOrigin2(input2) {
31859
31828
  if (Array.isArray(input2))
@@ -37924,9 +37893,7 @@ var FOLD_STATE_SQL = `SELECT * FROM (WITH sess AS (SELECT * FROM sessions WHERE
37924
37893
  (SELECT json_object('person', person, 'names', json(names)) FROM human_notify) AS notify,
37925
37894
  (SELECT json_object('status', t.status, 'id', t.id, 'text', t.reply_text, 'lastStep', json(t.last_step), 'activity', json(t.activity),
37926
37895
  'trigger', ${triggerOf("t")}) FROM turns t WHERE t.session_id = s.id AND t.status IN ${OPEN_TURN_STATUSES}
37927
- ORDER BY t.started_at DESC LIMIT 1) AS open_turn,
37928
- (SELECT json_object('to', c.report_to, 'trigger', c.report_trigger, 'before', c.report_before, 'title', (SELECT title FROM threads WHERE id = s.thread_id))
37929
- FROM compactions c WHERE c.session_id = s.id AND c.report_to IS NOT NULL) AS report
37896
+ ORDER BY t.started_at DESC LIMIT 1) AS open_turn
37930
37897
  FROM sess s LEFT JOIN session_folds f ON f.session_id = s.id)`;
37931
37898
  var TURN_END_SQL = `SELECT * FROM (WITH sess AS (SELECT * FROM sessions WHERE id = ?),
37932
37899
  open_turn AS (SELECT t.id FROM turns t WHERE t.session_id = (SELECT id FROM sess) AND t.status IN ${OPEN_TURN_STATUSES} ORDER BY t.started_at DESC LIMIT 1),
@@ -37958,8 +37925,7 @@ function foldState(row) {
37958
37925
  lastPost: row.last_post == null ? null : String(row.last_post),
37959
37926
  notify: row.notify ? JSON.parse(String(row.notify)) : null,
37960
37927
  foldedIndex: Number(row.folded_index),
37961
- open: row.open_turn ? JSON.parse(String(row.open_turn)) : null,
37962
- report: row.report ? JSON.parse(String(row.report)) : null
37928
+ open: row.open_turn ? JSON.parse(String(row.open_turn)) : null
37963
37929
  };
37964
37930
  }
37965
37931
  var REVOKED_TURN = `EXISTS (SELECT 1 FROM turn_deliveries td JOIN deliveries d ON d.id = td.delivery_id
@@ -38017,7 +37983,7 @@ function fold(state, chunks, now = Date.now()) {
38017
37983
  log("request.updated", { requestIds: (data.resolutions ?? []).map((r) => r.requestId) });
38018
37984
  }
38019
37985
  if (event.type === "compaction.requested" || event.type === "compaction.completed" || event.type === "session.waiting")
38020
- stmts.push(...compactionStatements(event.type, session, data, at, derivedMessageId(session.id, index, eventIndex), state.report));
37986
+ stmts.push(...compactionStatements(event.type, session, data, at, derivedMessageId(session.id, index, eventIndex)));
38021
37987
  if (event.type === "turn.started") {
38022
37988
  if (!data.turnId) throw new Error("turn.started must name eve turnId");
38023
37989
  flush();
@@ -38079,7 +38045,7 @@ function fold(state, chunks, now = Date.now()) {
38079
38045
  case "step.completed": {
38080
38046
  if (data.sequence === void 0 || data.stepIndex === void 0) break;
38081
38047
  stmts.push(s(`${STEP_INSERT} VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT step_model FROM sessions WHERE id = ?))`, ...stepValues(session.id, { sequence: data.sequence, stepIndex: data.stepIndex, at: new Date(at).toISOString(), usage: data.usage ?? {} }), open3.id, session.id));
38082
- if (typeof data.usage?.inputTokens === "number") stmts.push(...reportAfterStatements(state.report, session, data.usage.inputTokens, at, derivedMessageId(session.id, index, eventIndex)), ...compactedSizeStatements(session.id, data.usage.inputTokens));
38048
+ if (typeof data.usage?.inputTokens === "number") stmts.push(...compactedSizeStatements(session.id, data.usage.inputTokens));
38083
38049
  break;
38084
38050
  }
38085
38051
  case "message.appended": {
@@ -1,5 +0,0 @@
1
- declare const _default: {
2
- cron: string;
3
- run(): Promise<void>;
4
- };
5
- export default _default;
@@ -1,18 +0,0 @@
1
- // main's compact schedule: every 10 minutes, compacts idle sessions whose
2
- // prompt cache has gone cold (last model call past their model's cache TTL
3
- // + 1 min, measured per model by the hub; a warm one never is) once idle at
4
- // least 10 min with >= 100k input tokens, or after 40 turns, through the hub
5
- // (POST /api/threads/:id/compact), which refuses if a
6
- // turn or a delivery got there first. Reports each compaction, its before →
7
- // after tokens and any failure in sch-main-compact without tagging anyone,
8
- // so main is never woken. Rules in ./rules.mjs, the run in ./run-compact.mjs.
9
- import { defineSchedule } from "eve/schedules";
10
- import { createHub } from "./hub.mjs";
11
- import { runCompact } from "./run-compact.mjs";
12
- export default defineSchedule({
13
- cron: "*/10 * * * *",
14
- async run() {
15
- const hub = createHub();
16
- await hub.track("compact", () => runCompact(hub));
17
- },
18
- });
@@ -1,132 +0,0 @@
1
- // The decisions behind main's compact schedule (schedules/compact.ts), pure:
2
- // each takes the registry's /api/state snapshot (`snap`), the schedule's
3
- // and the time. No I/O here; schedules/hub.mjs does that. The report lines
4
- // for the compactions themselves are the hub's (hosted-hub/src/domain/compact-report.mjs).
5
- //
6
- // Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
7
- // tests (plain node) import the same module.
8
-
9
- const MIN = 60_000;
10
-
11
- const parse = (iso) => {
12
- const ms = Date.parse(iso ?? "");
13
- return Number.isFinite(ms) ? ms : null;
14
- };
15
- const isBusy = (status) => status === "working" || status === "waiting";
16
- const clock = (ms) => {
17
- const s = Math.max(0, Math.floor(ms / 1000));
18
- return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
19
- };
20
- const count = (n) => (typeof n === "number" ? n.toLocaleString("en-US") : "?");
21
- // The report never tags anyone: an "@" from a title or an error can't
22
- // become a mention (and wake a bot).
23
- const quiet = (text) => String(text).replace(/@/g, "@");
24
- const link = (threads, id) => `[${quiet(String(threads?.[id]?.title ?? id).replace(/[[\]]/g, ""))}](${id})`;
25
-
26
- // ---------------------------------------------------------------------------
27
- // How long a model's prompt cache stays warm after a model call.
28
- //
29
- // The Gateway sends Anthropic models `caching: "auto"` with no `ttl`: 5 min,
30
- // reset by every read. OpenAI keeps prefixes best-effort, longer (measured
31
- // warm past 10 min, cold by 30-60). Anything else: assume Anthropic's 5 min.
32
- // Once a model has `minSamples` observations (the hub's `cacheGaps[model]`:
33
- // [gapMs, hitRatio] for model calls >= 1 min after the session's previous
34
- // one), its TTL is measured instead, from the gap buckets with at least
35
- // `minBucketSamples` samples (the rest say nothing), each warm if its median
36
- // hit ratio is >= 0.5:
37
- // - the cache is warm up to at least the top of the longest warm bucket;
38
- // - it has expired by the bottom of the first cold bucket above that (a
39
- // shorter-gap miss never overrides a longer-gap hit);
40
- // - the provider default, kept within those bounds. So warm samples alone
41
- // can extend the TTL but never shorten it: only a cold bucket can.
42
- // The median, not the mean: a cold call on a long shared prefix (system
43
- // prompt, tools) still reads part of it.
44
- export const CACHE_TTL_DEFAULTS = Object.freeze({ anthropic: 5 * MIN, openai: 30 * MIN, unknown: 5 * MIN });
45
- export const CACHE_GAP_EDGES = Object.freeze([1, 3, 5, 10, 30, 60].map((m) => m * MIN));
46
- export const CACHE_TTL_OPTIONS = Object.freeze({ minSamples: 20, minBucketSamples: 3, warmRatio: 0.5 });
47
-
48
- const providerOf = (modelId) => String(modelId ?? "").split("/")[0];
49
- export const defaultCacheTtlMs = (modelId, defaults = CACHE_TTL_DEFAULTS) => defaults[providerOf(modelId)] ?? defaults.unknown;
50
- const median = (xs) => {
51
- const s = [...xs].sort((a, b) => a - b);
52
- const m = s.length >> 1;
53
- return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
54
- };
55
-
56
- // { ttlMs, measured, samples }
57
- export function cacheTtl(modelId, samples, options = {}) {
58
- const { minSamples, minBucketSamples, warmRatio, defaults = CACHE_TTL_DEFAULTS } = { ...CACHE_TTL_OPTIONS, ...options };
59
- const fallback = { ttlMs: defaultCacheTtlMs(modelId, defaults), measured: false, samples: samples?.length ?? 0 };
60
- if (!Array.isArray(samples) || samples.length < minSamples) return fallback;
61
- let warmTop = 0; // warm at least this long
62
- const cold = []; // bottoms of the cold buckets
63
- for (let i = 0; i < CACHE_GAP_EDGES.length; i++) {
64
- const lo = CACHE_GAP_EDGES[i];
65
- const hi = CACHE_GAP_EDGES[i + 1] ?? Infinity;
66
- const ratios = samples.filter(([gap, ratio]) => gap >= lo && gap < hi && Number.isFinite(ratio)).map(([, ratio]) => ratio);
67
- if (ratios.length < minBucketSamples) continue;
68
- if (median(ratios) >= warmRatio) warmTop = Math.max(warmTop, Number.isFinite(hi) ? hi : lo);
69
- else cold.push(lo);
70
- }
71
- const coldAbove = cold.filter((lo) => lo >= warmTop);
72
- const expiredBy = coldAbove.length ? Math.min(...coldAbove) : Infinity; // cold from here on
73
- if (warmTop === 0 && cold.length === 0) return fallback;
74
- const ttlMs = Math.min(Math.max(fallback.ttlMs, warmTop), expiredBy);
75
- return { ttlMs, measured: true, samples: samples.length };
76
- }
77
-
78
- const span = (ms) => (ms % MIN === 0 ? `${ms / MIN}m` : clock(ms));
79
-
80
- // ---------------------------------------------------------------------------
81
- // Which sessions to compact: only those whose prompt cache has gone cold.
82
- //
83
- // eve's compaction call doesn't reuse the session's cached prefix (its own
84
- // system prompt, no tools, the transcript re-rendered), so it pays full input
85
- // price warm or cold. A warm session's next turn reads its context at the
86
- // cached rate, so compacting it costs more than it saves; a cold one's next
87
- // turn re-writes the whole context anyway, so a smaller one is cheaper.
88
- //
89
- // An idle session is compacted only once its latest model call is more than
90
- // its model's cache TTL (cacheTtl, from the hub's cacheGaps) plus
91
- // `coldMarginMs` ago (a warm one never is), and either
92
- // - it has been idle at least `idleMinMs` and its latest model call was at
93
- // least `minTokens` input tokens. Per (bot, thread) session: `lastEventAt`
94
- // moves only on that session's own eve events, so another bot working in
95
- // the same thread doesn't hold it back; or
96
- // - it has had `maxTurns` turns since its last compaction and has been idle
97
- // at least `turnsIdleMs`;
98
- // and it has made a model call since its last compaction, or since the last
99
- // attempt at one (a failed one isn't retried until the session has moved on;
100
- // a compacted one doesn't trigger again on later ticks until its next turn).
101
- // Only bots whose process is up. The hub refuses (409) if a turn or a
102
- // delivery got there first.
103
- export const COMPACT_DEFAULTS = Object.freeze({ idleMinMs: 10 * MIN, minTokens: 100_000, maxTurns: 40, turnsIdleMs: 1 * MIN, coldMarginMs: 1 * MIN });
104
-
105
- export function compactCandidates(snap, nowMs, options = {}) {
106
- const { idleMinMs, minTokens, maxTurns, turnsIdleMs, coldMarginMs, ttl: ttlOptions } = { ...COMPACT_DEFAULTS, ...options };
107
- const up = new Map((snap?.agents ?? []).map((a) => [a.name, !a.process || a.process.status === "up"]));
108
- const out = [];
109
- for (const v of Object.values(snap?.sessions ?? {})) {
110
- if (!v.sessionId || !up.get(v.agent) || v.status !== "idle" || v.compacting || v.suspect) continue;
111
- const thread = snap.threads?.[v.threadId];
112
- if (!thread || thread.archived || isBusy(thread.status?.[v.agent])) continue;
113
- const lastStep = parse(v.lastStepAt);
114
- const lastTry = Math.max(parse(v.compactedAt) ?? 0, parse(v.compactAttemptAt) ?? 0);
115
- if (lastStep === null || lastStep <= lastTry) continue;
116
- const { ttlMs } = cacheTtl(v.lastModelId, snap.cacheGaps?.[v.lastModelId], ttlOptions);
117
- const sinceStepMs = nowMs - lastStep;
118
- if (sinceStepMs < ttlMs + coldMarginMs) continue; // still warm: never compacted
119
- const idleMs = nowMs - (parse(v.lastEventAt) ?? nowMs);
120
- const cold = `cold: idle ${clock(sinceStepMs)} > ttl ${span(ttlMs)}`;
121
- const turns = (v.sessionTurns ?? 0) - (v.turnsAtCompaction ?? 0);
122
- let trigger = null;
123
- if (idleMs >= idleMinMs && (v.lastInputTokens ?? 0) >= minTokens) trigger = `${cold} · ${Math.round(v.lastInputTokens / 1000)}k tokens`;
124
- else if (turns >= maxTurns && idleMs >= turnsIdleMs) trigger = `${cold} · ${turns} turns`;
125
- if (trigger) out.push({ threadId: v.threadId, agent: v.agent, sessionId: v.sessionId, trigger, before: v.lastInputTokens ?? null });
126
- }
127
- return out;
128
- }
129
-
130
- // A failure the hub didn't report itself (it refused before asking eve).
131
- export const failureLine = (c, threads, reason) =>
132
- `compact failed: ${quiet(c.agent)} on ${link(threads, c.threadId)} · ${c.trigger} · ${quiet(reason)}; next try: after ${quiet(c.agent)}'s next turn there, once idle 10+ min (or at 40 turns) with its prompt cache gone cold`;
@@ -1,25 +0,0 @@
1
- // One run of main's compact schedule (schedules/compact.ts): ask the hub to
2
- // compact each candidate, with the report going to sch-main-compact. The
3
- // hub records each attempt durably before it asks eve and posts the
4
- // report's lines itself (compacted, before → after, failures), so this run
5
- // keeps no state. Only a refusal the hub didn't report (it never got as far
6
- // as asking eve) gets a line from here.
7
- import { compactCandidates, failureLine } from "./rules.mjs";
8
-
9
- export async function runCompact(hub, { now = Date.now(), options } = {}) {
10
- const snap = await hub.snapshot();
11
- const lines = [];
12
- for (const c of compactCandidates(snap, now, options)) {
13
- let r;
14
- try {
15
- r = await hub.compact(c.threadId, c.agent, "schedule main/compact", { trigger: c.trigger, reportTo: "main/compact" });
16
- } catch (error) {
17
- r = { status: 0, body: { error: String(error?.message ?? error) } };
18
- }
19
- if (r.status === 202 || r.status === 409 || r.body?.reported) continue; // 409: a turn or a delivery got there first
20
- const line = failureLine(c, snap.threads, `the hub refused: ${r.status || "unreachable"} ${r.body?.error ?? r.body?.status ?? ""}`.trim());
21
- lines.push(line);
22
- await hub.post("compact", line).catch(() => {});
23
- }
24
- return { lines };
25
- }