@inetafrica/open-claudia 3.0.26 → 3.0.28

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.28 — /upgrade stops crying wolf on a slow roll
4
+
5
+ - **A client-side `/upgrade` timeout is no longer reported as a failure.** When the bot runs inside an AgentSpace pod, `/upgrade` calls the control plane, which triggers a fire-and-forget rollout and returns 202. If the pod is mid-roll its own in-flight request gets cut off, so the 15s HTTP wait would expire and the bot announced "Upgrade request failed … timeout" — a false negative that invited repeated re-runs (and a storm of redundant rollouts, seen as 11 ReplicaSets in 7 minutes). A timed-out request now surfaces as **pending** ("sent but the reply timed out — likely because I'm already restarting; give it a minute"), and the request timeout is raised 15s → 30s.
6
+ - **Pairs with a control-plane probe fix (the actual root cause).** The pod's `/health` probes carried no `timeoutSeconds`, so k8s defaulted it to 1s; a cold-start event-loop stall past 1s flapped the liveness probe and SIGTERM'd the pod in a restart loop. That fix — explicit `timeoutSeconds: 5` on every probe and liveness `failureThreshold` 3 → 5 — ships in the agent-space control plane and reaches existing pods on their next reconcile (`/cluster sync`).
7
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade.
8
+
9
+ ## v3.0.27 — Agency ledger, advisory "what's next", owner-guardrail capture, and a reboot that waits for the turn to end
10
+
11
+ - **`/agenda` — one read-only work list across everything (Track B0).** A new aggregator normalises your persistent tasks, `spaces mine`, and connector inbox/notifications into a single list (source, title, status, due, staleness, blocked-on, last-touched). Pure visibility — it reads, it never acts. `/agency` is an alias.
12
+ - **`/next` — the bot proposes what it would pick, and does nothing (Track B1, advisory).** A triage→rank→judge pipeline (deterministic prefilter → algorithmic rank → a small judge on the ambiguous top-N) says "here's what I'd do next and why." It acts on nothing without you — bounded autonomy (B2+) stays gated behind a shadow-mode review, exactly as planned.
13
+ - **Owner-guardrail capture — say a rule mid-chat and it sticks (Track C0/C1).** When *you* say "from now on, always X" / "never Y" / "stop doing Z", a post-turn detector turns it into an always-loaded lesson immediately — no code change, no deploy. High-confidence phrasings are auto-captured with an announcement and a one-tap **Undo**; softer ones ask first (Save / No). Hard owner gate: a standing rule is **never** captured from an external speaker, and captured rules layer *above* lessons but can never override the code-fixed hard rules. Default-on; set `GUARDRAIL_CAPTURE=off` to disable.
14
+ - **Always-on prompt budget — instrumented, not yet trimmed (Track X0).** Every real turn now measures the fixed always-on floor (soul, lessons, runtime state, slash list) versus the variable recall payload, recorded to telemetry so we can baseline cost before capping it. Pure measurement — the prompt itself is unchanged, so caching is unaffected. Exposed via `agency ledger` / `prompt-budget` CLIs and the recall stats.
15
+ - **A reboot that never interrupts a turn.** The Telegram long-poll has a 10-minute wedge backstop that self-exits for a clean restart when polling is truly stuck. It now checks first whether any turn is running, queued, or compacting — and if so **defers** the exit, keeps attempting in-place recovery, and only restarts once the process is idle (all replies delivered). If polling self-heals meanwhile, no restart happens at all. Recovery only touches the poll socket, so an in-flight turn's subprocess and its outbound sends are untouched.
16
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade. `GUARDRAIL_CAPTURE` is the one new (optional, default-on) env knob.
17
+
3
18
  ## v3.0.26 — Spaces replies actually post (and stop saying "Queued.")
4
19
 
5
20
  - **An engaged Spaces reply no longer fails with 400 "Parent comment not found."** Core threads every agent reply under the triggering message id — a Telegram-ism. On Spaces that id is a *notification* id, not a comment id, so the backend rejected the whole post and the bot silently failed to answer any mention. `send()` now only threads under an explicit `comment:`-prefixed id and otherwise posts a top-level task comment; if a stale/deleted parent still 400s, it retries once at top level so the reply always lands.
@@ -0,0 +1,91 @@
1
+ // open-claudia agency ledger [--json] [--source task|spaces|notification] [--limit N] [--no-spaces] [--ids]
2
+ // open-claudia agency next [--json] [--judge] [--no-record] (advisory: what to do next)
3
+ // open-claudia agenda (alias for `agency ledger`)
4
+ //
5
+ // Read-only aggregated work list (Track B0) + advisory deliberation (Track B1):
6
+ // persistent per-channel tasks + Spaces tasks assigned to me + connector inbox
7
+ // notifications, normalized and ranked. `next` PROPOSES a single best action and
8
+ // why — it acts on nothing.
9
+
10
+ const path = require("path");
11
+ const { context } = require("./loopback-client");
12
+
13
+ const HELP = `
14
+ Aggregated, read-only view of everything on your plate — plus an advisory pick.
15
+
16
+ open-claudia agenda [--json] [--source <s>] [--limit N] [--no-spaces] [--ids]
17
+ open-claudia agency ledger [ ... ] (same as agenda)
18
+ open-claudia agency next [--json] [--judge] [--no-record]
19
+
20
+ Ledger sources: persistent tasks (this channel) · Spaces tasks assigned to me ·
21
+ connector notifications. Ranked overdue → due-soon → in-progress → recently-touched.
22
+
23
+ --json machine-readable output
24
+ --source <s> ledger filter: task | spaces | notification
25
+ --limit N max rows per source (default 10)
26
+ --no-spaces skip the Spaces fetch (local tasks + notifications only)
27
+ --ids show entry ids
28
+
29
+ next (advisory deliberation — proposes, never acts):
30
+ --judge let a cheap model disambiguate a close top-N (default: deterministic)
31
+ --no-record don't append to the shadow-mode log
32
+ `;
33
+
34
+ function takeFlag(args, name) {
35
+ const i = args.indexOf(`--${name}`);
36
+ if (i < 0) return null;
37
+ const v = args[i + 1];
38
+ args.splice(i, 2);
39
+ return v;
40
+ }
41
+
42
+ async function loadLedger(rest) {
43
+ const ledger = require(path.join(__dirname, "..", "core", "agency", "ledger"));
44
+ const ctx = context();
45
+ const result = await ledger.collect({
46
+ adapter: ctx.adapterId,
47
+ channelId: ctx.channelId,
48
+ includeSpaces: !rest.includes("--no-spaces"),
49
+ });
50
+ return { ledger, ctx, result };
51
+ }
52
+
53
+ async function runLedger(rest) {
54
+ const json = rest.includes("--json");
55
+ const showIds = rest.includes("--ids");
56
+ const source = takeFlag(rest, "source");
57
+ const limit = Number(takeFlag(rest, "limit")) || 10;
58
+
59
+ const { ledger, result } = await loadLedger(rest);
60
+ const out = source
61
+ ? { ...result, entries: (result.entries || []).filter((e) => e.source === source) }
62
+ : result;
63
+
64
+ if (json) { console.log(JSON.stringify(out, null, 2)); return; }
65
+ console.log(ledger.render(out, { now: result.generatedAt, limitPerSource: limit, showIds }));
66
+ }
67
+
68
+ async function runDeliberate(rest) {
69
+ const json = rest.includes("--json");
70
+ const useJudge = rest.includes("--judge");
71
+ const record = !rest.includes("--no-record");
72
+
73
+ const { ctx, result } = await loadLedger(rest);
74
+ const deliberate = require(path.join(__dirname, "..", "core", "agency", "deliberate"));
75
+ const proposal = await deliberate.deliberate(result, { now: result.generatedAt, useJudge });
76
+ if (record) {
77
+ try { deliberate.record(proposal, { channel: ctx.adapterId ? `${ctx.adapterId}:${ctx.channelId}` : null }); } catch (e) {}
78
+ }
79
+ if (json) { console.log(JSON.stringify(proposal, null, 2)); return; }
80
+ console.log(deliberate.render(proposal));
81
+ }
82
+
83
+ async function run(args) {
84
+ const sub = args[0];
85
+ if (sub === "--help" || sub === "help") { console.log(HELP); return; }
86
+ if (sub === "next" || sub === "deliberate") return runDeliberate(args.slice(1));
87
+ if (sub === "ledger") return runLedger(args.slice(1));
88
+ return runLedger(args.slice(0)); // `agenda` alias / bare flags
89
+ }
90
+
91
+ module.exports = { run, HELP };
package/bin/cli.js CHANGED
@@ -365,6 +365,21 @@ switch (command) {
365
365
  require("./recall").run(args.slice(1));
366
366
  break;
367
367
  }
368
+ case "agency": {
369
+ Promise.resolve(require("./agency-ledger").run(args.slice(1)))
370
+ .catch((e) => { console.error(`agency command failed: ${e.message}`); process.exitCode = 1; });
371
+ break;
372
+ }
373
+ case "agenda": {
374
+ Promise.resolve(require("./agency-ledger").run(args.slice(1)))
375
+ .catch((e) => { console.error(`agenda command failed: ${e.message}`); process.exitCode = 1; });
376
+ break;
377
+ }
378
+ case "prompt-budget":
379
+ case "always-on": {
380
+ require("./prompt-budget").run(args.slice(1));
381
+ break;
382
+ }
368
383
 
369
384
  case "dream": {
370
385
  require("./dream").run(args.slice(1));
@@ -0,0 +1,49 @@
1
+ // open-claudia prompt-budget [--json]
2
+ //
3
+ // Inspect the always-on prompt floor: the fixed token cost (soul + lessons +
4
+ // runtime state + slash-command list + tool headlines + relationship/mandate)
5
+ // carried on every turn, plus the variable recall payload. Baselines Track X of
6
+ // the architecture plan so the floor can be capped as systems multiply.
7
+
8
+ const path = require("path");
9
+
10
+ const HELP = `
11
+ Inspect the always-on prompt budget.
12
+
13
+ open-claudia prompt-budget [--json] Rolling always-on floor + per-component breakdown
14
+
15
+ Measured per real user turn into prompt-budget.jsonl with a rolling summary in
16
+ prompt-budget-summary.json. Token counts are a local ~4-chars/token estimate —
17
+ consistent for tracking growth, not an exact provider tokenization.
18
+ `;
19
+
20
+ function fmt(n) {
21
+ n = Math.round(n || 0);
22
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
23
+ }
24
+
25
+ function run(args) {
26
+ const sub = args[0];
27
+ if (sub === "--help" || sub === "help") { console.log(HELP); process.exit(0); }
28
+ const budget = require(path.join(__dirname, "..", "core", "prompt-budget"));
29
+ const s = budget.summary();
30
+ if (args.includes("--json")) { console.log(JSON.stringify(s, null, 2)); return; }
31
+ console.log("Always-on prompt budget");
32
+ console.log("───────────────────────");
33
+ console.log(`Turns measured: ${s.turns}`);
34
+ console.log(`Avg always-on: ${fmt(s.avgAlwaysOnTokens)} tok`);
35
+ console.log(`Peak always-on: ${fmt(s.maxAlwaysOnTokens)} tok`);
36
+ console.log(`Avg recall payload: ${fmt(s.avgRecallTokens)} tok`);
37
+ console.log(`Avg total prompt: ${fmt(s.avgTotalTokens)} tok`);
38
+ const comp = s.componentAvgTokens || {};
39
+ const rows = Object.entries(comp).sort((a, b) => b[1] - a[1]);
40
+ if (rows.length) {
41
+ console.log("");
42
+ console.log("Avg tokens by component (largest first):");
43
+ for (const [k, v] of rows) console.log(` ${k.padEnd(16)} ${fmt(v)}`);
44
+ }
45
+ if (s.updatedAt) console.log(`\nUpdated: ${s.updatedAt}`);
46
+ if (!s.turns) console.log("\n(no turns measured yet — populates after the next real user turn)");
47
+ }
48
+
49
+ module.exports = { run, HELP };
package/bin/recall.js CHANGED
@@ -32,6 +32,12 @@ function stats(args) {
32
32
  console.log(`Nodes opened (📖): ${s.opens}`);
33
33
  console.log(`Avg latency: ${s.avgLatencyMs}ms`);
34
34
  console.log(`Cost: $${s.costUsd}`);
35
+ try {
36
+ const budget = require(path.join(__dirname, "..", "core", "prompt-budget")).summary();
37
+ if (budget.turns) {
38
+ console.log(`Always-on floor: ${budget.avgAlwaysOnTokens} tok avg (peak ${budget.maxAlwaysOnTokens}) — see \`open-claudia prompt-budget\``);
39
+ }
40
+ } catch (e) {}
35
41
  if (s.updatedAt) console.log(`Updated: ${s.updatedAt}`);
36
42
  }
37
43
 
@@ -41,6 +41,7 @@ class TelegramAdapter {
41
41
  this._healTimer = null; // one active recovery in flight at a time
42
42
  this._healthyTimer = null; // fires once polling's been quiet long enough
43
43
  this._wedgedSince = 0; // start of the current unrecovered error streak
44
+ this._deferredExitLogAt = 0; // throttle the "deferring restart, turn in progress" log (1/min)
44
45
  this._lastHiccupLog = 0; // throttle the transient-error log (1/min)
45
46
  this._healBackoff = 0; // grows per consecutive heal, capped
46
47
  this._pollWatchdogTimer = null; // detects a silent first/active getUpdates stall
@@ -139,6 +140,14 @@ class TelegramAdapter {
139
140
  await Promise.race([stopped, new Promise((r) => setTimeout(r, 5000))]);
140
141
  }
141
142
 
143
+ // Is ANY turn running/preparing/compacting/queued, for any user? The wedge-exit
144
+ // backstop consults this so a reboot never interrupts in-flight work. Isolated
145
+ // as a method (lazy require to dodge a load-time cycle) so tests can stub it.
146
+ _anyTurnActive() {
147
+ try { return require("../../core/state").anyTurnActive(); }
148
+ catch (e) { return false; }
149
+ }
150
+
142
151
  async _heal() {
143
152
  this._healTimer = null;
144
153
  // Backstop only: if polling has been unbroken-wedged for 10 minutes, a
@@ -149,9 +158,22 @@ class TelegramAdapter {
149
158
  // Never fires during a 409 streak: that's a token fight, not a network
150
159
  // wedge, and exiting busts the prompt cache (full uncached re-bill of the
151
160
  // conversation window on the next turn).
152
- console.error("Polling wedged >10min — exiting for a clean restart.");
153
- try { require("../../core/restart-reason").recordExitReason("Telegram polling wedged >10min (long-poll unrecoverable) self-exit for a clean restart", { code: 1 }); } catch (e) {}
154
- process.exit(1);
161
+ if (this._anyTurnActive()) {
162
+ // A turn is in flight never reboot mid-turn: it kills in-progress work
163
+ // and breaks the seamless resume the user expects. Defer the exit, keep
164
+ // attempting in-place recovery below, and let a later heal exit once the
165
+ // process is idle. If polling self-heals meanwhile, the wedge clears and
166
+ // no exit ever happens. (Recovery only touches the poll socket — the
167
+ // running turn's subprocess and its outbound sends dial their own.)
168
+ if (!this._deferredExitLogAt || Date.now() - this._deferredExitLogAt > 60000) {
169
+ this._deferredExitLogAt = Date.now();
170
+ console.error("Polling wedged >10min but a turn is in progress — deferring restart until idle.");
171
+ }
172
+ } else {
173
+ console.error("Polling wedged >10min — exiting for a clean restart.");
174
+ try { require("../../core/restart-reason").recordExitReason("Telegram polling wedged >10min (long-poll unrecoverable) — self-exit for a clean restart", { code: 1 }); } catch (e) {}
175
+ process.exit(1);
176
+ }
155
177
  }
156
178
  try {
157
179
  await this._stopPollingClean("stale Telegram long-poll recovery");
@@ -169,6 +191,7 @@ class TelegramAdapter {
169
191
  if (this._wedgedSince) console.log("Polling healthy again.");
170
192
  this._wedgedSince = 0;
171
193
  this._healBackoff = 0;
194
+ this._deferredExitLogAt = 0;
172
195
  }, 30000);
173
196
  }
174
197
 
package/core/actions.js CHANGED
@@ -513,6 +513,31 @@ async function handleAction(envelope) {
513
513
  : `⛔ Denied — ${label} will not run.`);
514
514
  return;
515
515
  }
516
+ if (d.startsWith("grd:")) {
517
+ // Guardrail capture buttons (Track C). Owner-only: standing rules layer
518
+ // above lessons and are never editable by an external speaker.
519
+ if (!isChatOwner(envelope.channelId)) return send("Owner only — standing rules are restricted.");
520
+ const guardrail = require("./guardrail");
521
+ const parts = d.split(":");
522
+ const verb = parts[1];
523
+ const arg = parts.slice(2).join(":");
524
+ if (verb === "save") {
525
+ const r = guardrail.confirmPending(arg);
526
+ if (!r.ok) return send("That rule prompt expired — say it again if you still want it kept.");
527
+ return send(r.added
528
+ ? `📌 Done — I'll keep "${String(r.text).slice(0, 160)}" loaded as a standing rule.`
529
+ : `📌 Already a standing rule — reinforced.`);
530
+ }
531
+ if (verb === "no") {
532
+ guardrail.dropPending(arg);
533
+ return send("Okay — not making that a standing rule.");
534
+ }
535
+ if (verb === "undo") {
536
+ const r = guardrail.undo(arg);
537
+ return send(r.ok ? "↩️ Undone — removed that standing rule." : "That rule was already gone.");
538
+ }
539
+ return;
540
+ }
516
541
  if (d.startsWith("tt:")) {
517
542
  state.settings.showToolTrace = d.slice(3) === "on";
518
543
  saveState();
@@ -0,0 +1,332 @@
1
+ // Agency deliberation — advisory "what should I do next?" (Track B1).
2
+ //
3
+ // Consumes the read-only ledger (core/agency/ledger) and PROPOSES a single best
4
+ // next action with a transparent reason. It acts on nothing — B1 is advisory
5
+ // only; the act/schedule loop is B2+ (gated behind a shadow-mode review).
6
+ //
7
+ // Pipeline: triage (deterministic prefilter cuts the backlog to what could
8
+ // matter now) → rank (explainable algorithmic score) → optional judge (a cheap
9
+ // model disambiguates a close top-N and refines the action type). Default-
10
+ // silence: if nothing clears the confidence floor, it proposes nothing.
11
+ //
12
+ // The judge is injectable and OFF by default, so deliberation is deterministic,
13
+ // network-free, and safe to run repeatedly in shadow mode. When enabled it can
14
+ // never break the proposal — any judge failure falls back to the deterministic
15
+ // pick.
16
+
17
+ const path = require("path");
18
+ const fs = require("fs");
19
+ const configDir = require(path.join(__dirname, "..", "..", "config-dir"));
20
+
21
+ const DAY_MS = 86400000;
22
+ const ACTIONS = ["act", "schedule", "ask", "park"];
23
+
24
+ // Tunables — deliberately conservative so shadow mode stays quiet unless
25
+ // something genuinely warrants attention.
26
+ const CONFIDENCE_FLOOR = 25; // below this top score → propose nothing
27
+ const CLEAR_MARGIN = 20; // top beats second by this → skip the judge
28
+ const DUE_SOON_DAYS = 3;
29
+ const CANDIDATE_STALE_DAYS = 30;
30
+ const TOP_N = 5;
31
+
32
+ // --- shadow log (mirrors core/prompt-budget storage pattern) ------------
33
+ const LOG_FILE = path.join(configDir, "agency-deliberation.jsonl");
34
+ const MAX_LOG_BYTES = 2 * 1024 * 1024;
35
+
36
+ function shadowEnabled() {
37
+ const v = String(process.env.AGENCY_SHADOW || "on").toLowerCase();
38
+ return v !== "off" && v !== "0" && v !== "false";
39
+ }
40
+
41
+ function toMs(v) {
42
+ if (!v) return 0;
43
+ if (typeof v === "number") return v;
44
+ const t = Date.parse(v);
45
+ return Number.isFinite(t) ? t : 0;
46
+ }
47
+
48
+ function daysUntil(due, now) {
49
+ const dueMs = toMs(due);
50
+ if (!dueMs) return null;
51
+ return (dueMs - now) / DAY_MS;
52
+ }
53
+
54
+ // --- triage: cut the backlog to what could plausibly matter now ---------
55
+ // Notifications are awareness, not work to "pick" — excluded from candidates.
56
+ // An item survives triage if it is overdue, due soon, already in progress,
57
+ // high priority, or a fresh owner assignment. Everything else is background
58
+ // (parked by default) so a 300-item list collapses to a handful.
59
+ function isCandidate(e, now) {
60
+ if (e.source === "notification") return false;
61
+ if (e.status === "done") return false;
62
+ if (e.overdue) return true;
63
+ const d = daysUntil(e.due, now);
64
+ if (d != null && d <= DUE_SOON_DAYS) return true;
65
+ if (e.status === "in_progress") return true;
66
+ if (e.priority === "high") return true;
67
+ if (e.source === "spaces" && e.staleDays != null && e.staleDays <= CANDIDATE_STALE_DAYS) return true;
68
+ return false;
69
+ }
70
+
71
+ // --- rank: explainable score + the signals that fired -------------------
72
+ function scoreOf(e, now) {
73
+ const signals = [];
74
+ let score = 0;
75
+
76
+ if (e.overdue) {
77
+ const over = Math.min(Math.max(0, -(daysUntil(e.due, now) || 0)), 30);
78
+ score += 100 + over * 2;
79
+ signals.push({ k: "overdue", w: 100 + over * 2, label: `overdue ${Math.round(over)}d` });
80
+ } else {
81
+ const d = daysUntil(e.due, now);
82
+ if (d != null && d <= DUE_SOON_DAYS) {
83
+ const w = 60 - Math.max(0, d) * 12;
84
+ score += w;
85
+ signals.push({ k: "due-soon", w, label: d < 1 ? "due today" : `due in ${Math.round(d)}d` });
86
+ } else if (d != null) {
87
+ const w = Math.max(4, 20 - Math.min(d, 30));
88
+ score += w;
89
+ signals.push({ k: "has-due", w, label: `due in ${Math.round(d)}d` });
90
+ }
91
+ }
92
+
93
+ if (e.status === "in_progress") {
94
+ score += 30;
95
+ signals.push({ k: "in-progress", w: 30, label: "already in progress" });
96
+ }
97
+ if (e.priority === "high") {
98
+ score += 25;
99
+ signals.push({ k: "priority", w: 25, label: "high priority" });
100
+ } else if (e.priority === "medium") {
101
+ score += 8;
102
+ signals.push({ k: "priority", w: 8, label: "medium priority" });
103
+ }
104
+
105
+ // Unblocks others: a plan/task with open subtasks gates that downstream work.
106
+ const openSubs = (e.meta && typeof e.meta.subtasks === "number")
107
+ ? Math.max(0, e.meta.subtasks - (e.meta.subtasksDone || 0)) : 0;
108
+ if (openSubs > 0) {
109
+ score += 15;
110
+ signals.push({ k: "unblocks", w: 15, label: `unblocks ${openSubs} subtask${openSubs > 1 ? "s" : ""}` });
111
+ }
112
+
113
+ // Staleness: mild nudge for neglected-but-active work; mild penalty for very
114
+ // cold items so abandoned cruft doesn't float up on staleness alone.
115
+ if (e.staleDays != null) {
116
+ if (e.staleDays > CANDIDATE_STALE_DAYS && !e.overdue) {
117
+ score -= 10;
118
+ signals.push({ k: "cold", w: -10, label: `untouched ${Math.round(e.staleDays)}d` });
119
+ } else if (e.staleDays > 7 && (e.status === "in_progress" || e.overdue)) {
120
+ score += 6;
121
+ signals.push({ k: "neglected", w: 6, label: `neglected ${Math.round(e.staleDays)}d` });
122
+ }
123
+ }
124
+
125
+ if (e.meta && e.meta.space) signals.push({ k: "context", w: 0, label: e.meta.space });
126
+
127
+ signals.sort((a, b) => Math.abs(b.w) - Math.abs(a.w));
128
+ return { score, signals };
129
+ }
130
+
131
+ // Deterministic action-type guess (the judge can override when enabled).
132
+ function guessAction(e, now) {
133
+ if (e.source === "spaces") {
134
+ // Assigned work: if it's overdue/soon it likely needs a nudge to the
135
+ // assignee/owner; otherwise it's work to do.
136
+ if (e.overdue || (daysUntil(e.due, now) ?? 99) <= DUE_SOON_DAYS) return "ask";
137
+ return "act";
138
+ }
139
+ if (e.status === "in_progress") return "act";
140
+ const d = daysUntil(e.due, now);
141
+ if (d != null && d > DUE_SOON_DAYS) return "schedule";
142
+ return "act";
143
+ }
144
+
145
+ // --- default judge: cheap model, injectable, never fatal ----------------
146
+ // Uses the low tier so it maps to Haiku (or the provider's cheapest). purpose
147
+ // "recall" is reused only because it is the established cheap per-turn judgment
148
+ // lane; spawnUtilityAgent does not log recall metrics, so nothing is polluted.
149
+ async function defaultJudge(top, ctx) {
150
+ let spawnUtilityAgent;
151
+ try { ({ spawnUtilityAgent } = require("../utility-agent")); }
152
+ catch (e) { return null; }
153
+
154
+ const lines = top.map((c, i) =>
155
+ `${i + 1}. [${c.id}] (${c.source}) ${c.title} — signals: ${c.signals.map((s) => s.label).join(", ") || "none"}`,
156
+ ).join("\n");
157
+ const prompt = [
158
+ "You are triaging the assistant owner's work queue. Pick the SINGLE item to do next.",
159
+ "Prefer items that are overdue, unblock others, or are already in progress. Be decisive.",
160
+ "",
161
+ "Candidates:",
162
+ lines,
163
+ "",
164
+ 'Reply with JSON only: {"pickId": "<id>", "actionType": "act|schedule|ask|park", "why": "<one short sentence>", "confidence": "high|medium|low"}',
165
+ ].join("\n");
166
+
167
+ const outputSchema = {
168
+ type: "object",
169
+ required: ["pickId", "actionType", "why"],
170
+ properties: {
171
+ pickId: { type: "string" },
172
+ actionType: { type: "string", enum: ACTIONS },
173
+ why: { type: "string" },
174
+ confidence: { type: "string", enum: ["high", "medium", "low"] },
175
+ },
176
+ additionalProperties: true,
177
+ };
178
+
179
+ try {
180
+ const r = await spawnUtilityAgent(prompt, {
181
+ purpose: "recall",
182
+ tier: "low",
183
+ readOnly: true,
184
+ allowedTools: [],
185
+ outputSchema,
186
+ timeoutMs: 30000,
187
+ });
188
+ if (r && r.ok && r.json && r.json.pickId) return r.json;
189
+ } catch (e) { /* fall through to deterministic */ }
190
+ return null;
191
+ }
192
+
193
+ // --- deliberate: the pipeline -------------------------------------------
194
+ async function deliberate(ledger, opts = {}) {
195
+ const now = opts.now || (ledger && ledger.generatedAt) || Date.now();
196
+ const entries = (ledger && ledger.entries) || [];
197
+ const counts = (ledger && ledger.counts) || null;
198
+
199
+ const ranked = entries
200
+ .filter((e) => isCandidate(e, now))
201
+ .map((e) => ({ ...e, ...scoreOf(e, now) }))
202
+ .sort((a, b) => b.score - a.score);
203
+
204
+ const base = {
205
+ generatedAt: now,
206
+ proposed: false,
207
+ pick: null,
208
+ actionType: null,
209
+ why: [],
210
+ confidence: "low",
211
+ method: "deterministic",
212
+ candidateCount: ranked.length,
213
+ candidates: ranked.slice(0, TOP_N).map((c) => ({ id: c.id, source: c.source, title: c.title, score: Math.round(c.score) })),
214
+ counts,
215
+ };
216
+
217
+ if (!ranked.length || ranked[0].score < CONFIDENCE_FLOOR) return base;
218
+
219
+ const top = ranked.slice(0, TOP_N);
220
+ let pick = ranked[0];
221
+ let actionType = guessAction(pick, now);
222
+ let method = "deterministic";
223
+ let judgeWhy = null;
224
+ const margin = ranked.length > 1 ? ranked[0].score - ranked[1].score : Infinity;
225
+ let confidence = margin >= CLEAR_MARGIN ? "high" : "medium";
226
+
227
+ const useJudge = opts.useJudge === true && ranked.length > 1 && margin < CLEAR_MARGIN;
228
+ if (useJudge) {
229
+ const judge = opts.judge || defaultJudge;
230
+ let verdict = null;
231
+ try { verdict = await judge(top, { now }); } catch (e) { verdict = null; }
232
+ if (verdict && verdict.pickId) {
233
+ const chosen = top.find((c) => c.id === verdict.pickId);
234
+ if (chosen) {
235
+ pick = chosen;
236
+ if (ACTIONS.includes(verdict.actionType)) actionType = verdict.actionType;
237
+ if (verdict.why) judgeWhy = String(verdict.why);
238
+ if (["high", "medium", "low"].includes(verdict.confidence)) confidence = verdict.confidence;
239
+ method = "judge";
240
+ }
241
+ }
242
+ }
243
+
244
+ const why = pick.signals.filter((s) => s.w !== 0 || s.k === "context").map((s) => s.label);
245
+
246
+ return {
247
+ ...base,
248
+ proposed: true,
249
+ pick: {
250
+ id: pick.id, source: pick.source, kind: pick.kind, title: pick.title,
251
+ status: pick.status, due: pick.due, priority: pick.priority,
252
+ score: Math.round(pick.score), meta: pick.meta,
253
+ },
254
+ actionType,
255
+ why,
256
+ judgeWhy,
257
+ confidence,
258
+ method,
259
+ };
260
+ }
261
+
262
+ // --- shadow record: append one line per real deliberation ---------------
263
+ function record(proposal, meta = {}) {
264
+ if (!shadowEnabled() || !proposal) return;
265
+ try {
266
+ const row = {
267
+ at: new Date().toISOString(),
268
+ proposed: !!proposal.proposed,
269
+ pickId: proposal.pick ? proposal.pick.id : null,
270
+ source: proposal.pick ? proposal.pick.source : null,
271
+ title: proposal.pick ? proposal.pick.title : null,
272
+ actionType: proposal.actionType || null,
273
+ confidence: proposal.confidence || null,
274
+ method: proposal.method || null,
275
+ score: proposal.pick ? proposal.pick.score : null,
276
+ candidateCount: proposal.candidateCount || 0,
277
+ channel: meta.channel || null,
278
+ };
279
+ try {
280
+ const st = fs.statSync(LOG_FILE);
281
+ if (st.size > MAX_LOG_BYTES) fs.renameSync(LOG_FILE, `${LOG_FILE}.1`);
282
+ } catch (e) {}
283
+ fs.appendFileSync(LOG_FILE, JSON.stringify(row) + "\n");
284
+ } catch (e) { /* best effort */ }
285
+ }
286
+
287
+ // --- render: advisory text (no action taken) ----------------------------
288
+ const ACTION_VERB = {
289
+ act: "Do it now",
290
+ schedule: "Schedule it",
291
+ ask: "Nudge / ask",
292
+ park: "Park it",
293
+ };
294
+
295
+ function render(proposal) {
296
+ if (!proposal || !proposal.proposed) {
297
+ return "Nothing pressing to pick right now — nothing overdue, due soon, or in progress that clears the bar. 🟢";
298
+ }
299
+ const p = proposal.pick;
300
+ const conf = proposal.confidence ? ` · ${proposal.confidence} confidence` : "";
301
+ const via = proposal.method === "judge" ? " · judged" : "";
302
+ const lines = [];
303
+ lines.push(`Next up (advisory${conf}${via})`);
304
+ lines.push("─".repeat(18));
305
+ lines.push(`${p.title}`);
306
+ lines.push(` ${ACTION_VERB[proposal.actionType] || proposal.actionType} · ${p.source}${p.id ? ` · ${p.id}` : ""}`);
307
+ if (proposal.judgeWhy) lines.push(` Why: ${proposal.judgeWhy}`);
308
+ else if (proposal.why && proposal.why.length) lines.push(` Why: ${proposal.why.join(" · ")}`);
309
+ if (proposal.candidateCount > 1) {
310
+ lines.push("");
311
+ lines.push(`Considered ${proposal.candidateCount}; runners-up:`);
312
+ for (const c of proposal.candidates.slice(1, 4)) lines.push(` · ${c.title} (${c.score})`);
313
+ }
314
+ lines.push("");
315
+ lines.push("Advisory only — I won't act on this without your go-ahead.");
316
+ return lines.join("\n");
317
+ }
318
+
319
+ module.exports = {
320
+ deliberate,
321
+ render,
322
+ record,
323
+ scoreOf,
324
+ isCandidate,
325
+ guessAction,
326
+ defaultJudge,
327
+ shadowEnabled,
328
+ LOG_FILE,
329
+ ACTIONS,
330
+ CONFIDENCE_FLOOR,
331
+ CLEAR_MARGIN,
332
+ };