@inetafrica/open-claudia 3.0.25 → 3.0.27

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.27 — Agency ledger, advisory "what's next", owner-guardrail capture, and a reboot that waits for the turn to end
4
+
5
+ - **`/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.
6
+ - **`/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.
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+ - **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.
11
+
12
+ ## v3.0.26 — Spaces replies actually post (and stop saying "Queued.")
13
+
14
+ - **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.
15
+ - **The bot threads its reply under the exact comment it's answering.** When a notification names a specific comment (a reply's `replyId`, or a mention/comment that targets a comment), the reply is threaded under it instead of floating at the task root. Task-level mentions and assignments stay top-level, as before.
16
+ - **No more "Queued." noise on a task thread.** The transient queue/compaction acknowledgement is a chat-UX affordance for Telegram/Kazee. On a Spaces task it posted a throwaway "Queued." comment when a second turn stacked (and, pre-fix, that post itself 400'd). Spaces now stays silent while queued and simply posts the real reply when its turn runs. Per-identity run serialization is unchanged and intended — one being, one hand.
17
+ - **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.
18
+
3
19
  ## v3.0.25 — The bot types back on a Spaces task
4
20
 
5
21
  - **"Open Claudia is typing…" now shows on a Spaces task thread while the bot composes.** Core already runs a typing heartbeat around every agent turn (calling `adapter.typing()` every ~4s and `adapter.typingStop()` at turn-end); on Spaces those were no-op stubs. They now re-emit the exact `comment:typing` event a human client sends over the adapter's existing authenticated socket, so a teammate watching a task sees the bot compose like any other member — the same three-dot indicator the web and mobile apps already render.
@@ -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
 
@@ -202,9 +202,22 @@ class SpacesAdapter {
202
202
  actorName = actorName || `${actorId.firstName || ""} ${actorId.lastName || ""}`.trim() || actorId.email;
203
203
  actorId = actorId.id || actorId._id;
204
204
  }
205
+ // The specific comment to thread a reply under, when the event is about one:
206
+ // a reply → the reply comment (replyId); a comment/mention that targets a
207
+ // comment → that comment id. Absent for task-level mentions and assignments,
208
+ // where the reply is a top-level task comment.
209
+ const commentId = String(
210
+ data.replyId || inner.replyId
211
+ || data.commentId || inner.commentId
212
+ || (data.targetType === "comment" ? data.targetId : "")
213
+ || (inner.targetType === "comment" ? inner.targetId : "")
214
+ || (payload.targetType === "comment" ? payload.targetId : "")
215
+ || "",
216
+ );
205
217
  return {
206
218
  type,
207
219
  taskId,
220
+ commentId,
208
221
  actorId: actorId ? String(actorId) : "",
209
222
  actorName: actorName || "",
210
223
  spaceId: payload.spaceId || inner.spaceId || null,
@@ -261,7 +274,10 @@ class SpacesAdapter {
261
274
  userId: n.actorId || n.taskId,
262
275
  type: "text",
263
276
  text: n.content || `[${n.type} on task ${n.title || n.taskId}]`,
264
- messageId: id,
277
+ // Thread the bot's reply under the exact comment that triggered this engage
278
+ // (a reply, or a mention targeting a comment). Falls back to the
279
+ // notification id, which send() posts as a top-level task comment.
280
+ messageId: n.commentId ? `comment:${n.commentId}` : id,
265
281
  from: { id: n.actorId, name: n.actorName },
266
282
  spaces: { taskId: n.taskId, spaceId: n.spaceId, notificationType: n.type, deepLink: n.deepLink, title: n.title, mode: this._taskLoopMode() },
267
283
  raw: payload,
@@ -473,15 +489,24 @@ class SpacesAdapter {
473
489
  async send(channelId, text, opts = {}) {
474
490
  const taskId = String(channelId || "").replace(/^task:/, "");
475
491
  if (!taskId) { console.error("Spaces send: no taskId in channelId", channelId); return null; }
492
+ // Thread only under an EXPLICIT comment id (a "comment:"-prefixed replyTo,
493
+ // set on an engaged reply/comment-mention). Core's default replyTo is the
494
+ // triggering NOTIFICATION id, which is not a comment — threading under it
495
+ // 400s ("Parent comment not found"), so treat anything else as a top-level
496
+ // task comment.
497
+ const replyTo = String(opts.replyTo || "");
498
+ const parentId = replyTo.startsWith("comment:") ? replyTo.slice("comment:".length) : null;
499
+ const post = (parent) => this.client.createComment({ taskId, text, mentions: opts.mentions || [], parentId: parent });
500
+ const idOf = (c) => (c && (c._id || c.id) ? String(c._id || c.id) : true);
476
501
  try {
477
- const comment = await this.client.createComment({
478
- taskId,
479
- text,
480
- mentions: opts.mentions || [],
481
- parentId: opts.replyTo ? String(opts.replyTo).replace(/^comment:/, "") : null,
482
- });
483
- return comment && (comment._id || comment.id) ? String(comment._id || comment.id) : true;
502
+ return idOf(await post(parentId));
484
503
  } catch (e) {
504
+ // A stale/deleted parent 400s; retry once at top level so the reply always
505
+ // lands on the task rather than being dropped.
506
+ if (parentId && /parent comment not found/i.test(e.message || "")) {
507
+ try { return idOf(await post(null)); }
508
+ catch (e2) { console.error("Spaces send error (top-level retry):", e2.message); return null; }
509
+ }
485
510
  console.error("Spaces send error:", e.message);
486
511
  return null;
487
512
  }
@@ -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();