@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 +16 -0
- package/bin/agency-ledger.js +91 -0
- package/bin/cli.js +15 -0
- package/bin/prompt-budget.js +49 -0
- package/bin/recall.js +6 -0
- package/channels/spaces/adapter.js +33 -8
- package/channels/telegram/adapter.js +26 -3
- package/core/actions.js +25 -0
- package/core/agency/deliberate.js +332 -0
- package/core/agency/ledger.js +294 -0
- package/core/connected-apps.js +15 -0
- package/core/guardrail.js +251 -0
- package/core/handlers.js +41 -1
- package/core/prompt-budget.js +165 -0
- package/core/runner.js +26 -2
- package/core/state.js +16 -0
- package/core/system-prompt.js +29 -0
- package/package.json +6 -2
- package/test-agency-deliberate.js +119 -0
- package/test-agency-ledger.js +114 -0
- package/test-guardrail.js +126 -0
- package/test-prompt-budget.js +78 -0
- package/test-provider-language.js +12 -9
- package/test-telegram-poll-recovery.js +81 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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();
|