@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.
@@ -0,0 +1,294 @@
1
+ // Agency ledger — read-only work aggregator (Track B0).
2
+ //
3
+ // Unifies three sources into one normalized, ranked work list:
4
+ // • persistent per-channel tasks (core/tasks) — the agent's own plan/subtasks
5
+ // • Spaces tasks assigned to the owner (`spaces mine`, via the spaces tool)
6
+ // • connector inbox notifications (core/connected-apps) — awareness items
7
+ //
8
+ // Pure visibility, zero autonomy: this module only reads and normalizes. The
9
+ // deliberation/act layer (Track B1+) consumes build()/collect() output; it never
10
+ // writes here. Credentials never touch this module — the Spaces fetch is
11
+ // delegated to the `spaces` tool subprocess (keyring-injected), so core holds no
12
+ // secrets. build() is a pure function (no I/O) so it is unit-testable in full;
13
+ // collect() is the thin impure orchestrator that gathers the three sources.
14
+
15
+ const { execFile } = require("child_process");
16
+
17
+ const DAY_MS = 86400000;
18
+ const DEFAULT_STALE_DAYS = 14;
19
+
20
+ function toMs(v) {
21
+ if (!v) return 0;
22
+ if (typeof v === "number") return v;
23
+ const t = Date.parse(v);
24
+ return Number.isFinite(t) ? t : 0;
25
+ }
26
+
27
+ function staleDaysOf(lastTouched, now) {
28
+ if (!lastTouched) return null;
29
+ return Math.max(0, (now - lastTouched) / DAY_MS);
30
+ }
31
+
32
+ // --- normalizers: each source → the common entry shape ------------------
33
+ // { source, kind, id, title, status, priority, due, lastTouched, staleDays,
34
+ // blockedOn, overdue, meta }
35
+
36
+ function normalizeTask(t, now) {
37
+ const lastTouched = toMs(t.updatedAt || t.createdAt);
38
+ const status = t.status === "in_progress" ? "in_progress"
39
+ : t.status === "completed" ? "done"
40
+ : "open";
41
+ return {
42
+ source: "task",
43
+ kind: t.parentId ? "subtask" : "task",
44
+ id: String(t.id || ""),
45
+ title: t.content || "(untitled)",
46
+ status,
47
+ priority: null,
48
+ due: null,
49
+ lastTouched,
50
+ staleDays: staleDaysOf(lastTouched, now),
51
+ blockedOn: null,
52
+ overdue: false,
53
+ meta: { parentId: t.parentId || null, description: t.description || null },
54
+ };
55
+ }
56
+
57
+ function normalizeSpacesTask(t, now) {
58
+ const lastTouched = toMs(t.updatedAt || t.createdAt);
59
+ const due = t.dueDate || null;
60
+ const dueMs = toMs(due);
61
+ const status = t.status === "in_progress" ? "in_progress"
62
+ : (t.status === "completed" || t.status === "done" || t.completedAt) ? "done"
63
+ : "open";
64
+ const space = t.spaceId && typeof t.spaceId === "object" ? (t.spaceId.name || "") : "";
65
+ return {
66
+ source: "spaces",
67
+ kind: "assignment",
68
+ id: String(t.id || t._id || ""),
69
+ title: t.title || "(untitled)",
70
+ status,
71
+ priority: t.priority || null,
72
+ due,
73
+ lastTouched,
74
+ staleDays: staleDaysOf(lastTouched, now),
75
+ blockedOn: t.parentTaskId || null,
76
+ overdue: !!(dueMs && dueMs < now && status !== "done"),
77
+ meta: {
78
+ space,
79
+ subtasks: t.subtaskCount || 0,
80
+ subtasksDone: t.subtaskCompletedCount || 0,
81
+ tags: Array.isArray(t.tags) ? t.tags : [],
82
+ },
83
+ };
84
+ }
85
+
86
+ function normalizeInbox(item, now) {
87
+ const lastTouched = toMs(item.createdAt);
88
+ const actor = item.actorName ? `${item.actorName} ` : "";
89
+ const verb = item.type || "notification";
90
+ const on = item.taskTitle ? ` on "${item.taskTitle}"` : "";
91
+ const title = `${actor}${verb}${on}`.trim() || item.text || "(notification)";
92
+ return {
93
+ source: "notification",
94
+ kind: item.type || "notification",
95
+ id: String(item.id || ""),
96
+ title,
97
+ status: "info",
98
+ priority: null,
99
+ due: null,
100
+ lastTouched,
101
+ staleDays: staleDaysOf(lastTouched, now),
102
+ blockedOn: null,
103
+ overdue: false,
104
+ meta: {
105
+ app: item.appName || item.appId || "",
106
+ actor: item.actorName || "",
107
+ taskId: item.taskId || "",
108
+ text: item.text || "",
109
+ },
110
+ };
111
+ }
112
+
113
+ // Pure-visibility ordering (NOT the Track B1 deliberation score): overdue first,
114
+ // then anything with a due date by soonest, then in-progress, then most recently
115
+ // touched. Stable + explainable so the ledger always reads sensibly.
116
+ function rankKey(e) {
117
+ const dueMs = toMs(e.due);
118
+ return [
119
+ e.overdue ? 0 : 1,
120
+ dueMs || Number.MAX_SAFE_INTEGER,
121
+ e.status === "in_progress" ? 0 : 1,
122
+ -(e.lastTouched || 0),
123
+ ];
124
+ }
125
+
126
+ function cmpKey(a, b) {
127
+ for (let i = 0; i < a.length; i++) {
128
+ if (a[i] < b[i]) return -1;
129
+ if (a[i] > b[i]) return 1;
130
+ }
131
+ return 0;
132
+ }
133
+
134
+ // Pure: raw source arrays → normalized, de-completed, ranked ledger + counts.
135
+ function build({ tasks = [], spacesMine = [], inboxes = [], now = Date.now(), staleDays = DEFAULT_STALE_DAYS } = {}) {
136
+ const entries = [];
137
+ for (const t of tasks) entries.push(normalizeTask(t, now));
138
+ for (const t of spacesMine) entries.push(normalizeSpacesTask(t, now));
139
+ for (const it of inboxes) entries.push(normalizeInbox(it, now));
140
+
141
+ // The ledger is a live work list — completed items drop off.
142
+ const open = entries.filter((e) => e.status !== "done");
143
+ open.sort((a, b) => cmpKey(rankKey(a), rankKey(b)));
144
+
145
+ const byStatus = {};
146
+ const bySource = {};
147
+ let overdue = 0;
148
+ let stale = 0;
149
+ for (const e of open) {
150
+ byStatus[e.status] = (byStatus[e.status] || 0) + 1;
151
+ bySource[e.source] = (bySource[e.source] || 0) + 1;
152
+ if (e.overdue) overdue += 1;
153
+ if (e.staleDays != null && e.staleDays > staleDays) stale += 1;
154
+ }
155
+
156
+ return {
157
+ generatedAt: now,
158
+ counts: { total: open.length, overdue, stale, byStatus, bySource },
159
+ entries: open,
160
+ };
161
+ }
162
+
163
+ // Default Spaces fetch: shell the read-only `spaces mine` tool verb. Keyring
164
+ // creds are injected only into that subprocess, so this module never holds the
165
+ // central login. Best-effort — any failure yields [] and the ledger still shows
166
+ // tasks + notifications.
167
+ function defaultFetchSpacesMine({ timeoutMs = 15000 } = {}) {
168
+ return new Promise((resolve) => {
169
+ execFile(
170
+ "open-claudia",
171
+ ["tool", "run", "spaces", "mine", "--json"],
172
+ { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 },
173
+ (err, stdout) => {
174
+ if (err || !stdout) return resolve([]);
175
+ try {
176
+ // Tool stdout can carry ExperimentalWarning/SQLite noise before the JSON.
177
+ const start = stdout.indexOf("[");
178
+ const parsed = JSON.parse(start >= 0 ? stdout.slice(start) : stdout);
179
+ resolve(Array.isArray(parsed) ? parsed : []);
180
+ } catch (e) { resolve([]); }
181
+ },
182
+ );
183
+ });
184
+ }
185
+
186
+ // Impure orchestrator: gather the three sources, then build(). Every source is
187
+ // best-effort so a single failure never blanks the ledger.
188
+ async function collect({ adapter, channelId, now = Date.now(), staleDays = DEFAULT_STALE_DAYS, includeSpaces = true, fetchSpacesMine } = {}) {
189
+ let tasks = [];
190
+ try {
191
+ if (adapter && channelId) tasks = require("../tasks").list(adapter, channelId);
192
+ } catch (e) { tasks = []; }
193
+
194
+ let spacesMine = [];
195
+ if (includeSpaces) {
196
+ try {
197
+ const fetch = fetchSpacesMine || defaultFetchSpacesMine;
198
+ const r = await fetch({});
199
+ spacesMine = Array.isArray(r) ? r : [];
200
+ } catch (e) { spacesMine = []; }
201
+ }
202
+
203
+ let inboxes = [];
204
+ try {
205
+ inboxes = require("../connected-apps").getAllInboxes() || [];
206
+ } catch (e) { inboxes = []; }
207
+
208
+ return build({ tasks, spacesMine, inboxes, now, staleDays });
209
+ }
210
+
211
+ // --- rendering ----------------------------------------------------------
212
+
213
+ function ago(ms, now) {
214
+ if (!ms) return "";
215
+ const s = Math.max(0, Math.round((now - ms) / 1000));
216
+ if (s < 90) return "just now";
217
+ const m = Math.round(s / 60);
218
+ if (m < 90) return `${m}m ago`;
219
+ const h = Math.round(m / 60);
220
+ if (h < 36) return `${h}h ago`;
221
+ return `${Math.round(h / 24)}d ago`;
222
+ }
223
+
224
+ function dueLabel(due, now) {
225
+ const dueMs = toMs(due);
226
+ if (!dueMs) return "";
227
+ const days = Math.round((dueMs - now) / DAY_MS);
228
+ if (days < 0) return `overdue ${-days}d`;
229
+ if (days === 0) return "due today";
230
+ if (days === 1) return "due tomorrow";
231
+ return `due ${days}d`;
232
+ }
233
+
234
+ const SOURCE_LABEL = {
235
+ spaces: "Spaces (assigned to me)",
236
+ task: "Local tasks (this channel)",
237
+ notification: "Notifications (inbox)",
238
+ };
239
+ const SOURCE_ORDER = ["spaces", "task", "notification"];
240
+
241
+ function mark(e) {
242
+ if (e.overdue) return "!";
243
+ if (e.status === "in_progress") return "~";
244
+ if (e.source === "notification") return "•";
245
+ return " ";
246
+ }
247
+
248
+ function renderEntry(e, now, showIds) {
249
+ const bits = [];
250
+ const d = dueLabel(e.due, now);
251
+ if (d) bits.push(d);
252
+ if (e.status === "in_progress" && !e.overdue) bits.push("in progress");
253
+ if (e.priority && e.priority !== "medium") bits.push(e.priority);
254
+ if (e.meta && e.meta.space) bits.push(e.meta.space);
255
+ const a = ago(e.lastTouched, now);
256
+ if (a && (e.source !== "spaces" || !d)) bits.push(a);
257
+ const tail = bits.length ? ` · ${bits.join(" · ")}` : "";
258
+ const id = showIds && e.id ? ` (${e.id})` : "";
259
+ return ` ${mark(e)} ${e.title}${tail}${id}`;
260
+ }
261
+
262
+ function render(ledger, opts = {}) {
263
+ const now = opts.now || ledger.generatedAt || Date.now();
264
+ const limit = Number.isFinite(opts.limitPerSource) ? opts.limitPerSource : 10;
265
+ const showIds = !!opts.showIds;
266
+ const c = ledger.counts || { total: 0, overdue: 0, stale: 0 };
267
+ const lines = ["Agenda", "──────"];
268
+ const summary = [`${c.total} open`];
269
+ if (c.overdue) summary.push(`${c.overdue} overdue`);
270
+ if (c.stale) summary.push(`${c.stale} stale`);
271
+ lines.push(summary.join(" · "));
272
+
273
+ for (const src of SOURCE_ORDER) {
274
+ const items = (ledger.entries || []).filter((e) => e.source === src);
275
+ if (!items.length) continue;
276
+ lines.push("");
277
+ lines.push(`${SOURCE_LABEL[src] || src} — ${items.length}`);
278
+ for (const e of items.slice(0, limit)) lines.push(renderEntry(e, now, showIds));
279
+ if (items.length > limit) lines.push(` …and ${items.length - limit} more`);
280
+ }
281
+ if (!(ledger.entries || []).length) lines.push("", "Nothing on your plate. 🎉");
282
+ return lines.join("\n");
283
+ }
284
+
285
+ module.exports = {
286
+ build,
287
+ collect,
288
+ render,
289
+ normalizeTask,
290
+ normalizeSpacesTask,
291
+ normalizeInbox,
292
+ defaultFetchSpacesMine,
293
+ DEFAULT_STALE_DAYS,
294
+ };
@@ -204,6 +204,20 @@ function snapshot() {
204
204
  };
205
205
  }
206
206
 
207
+ // Public: flat, normalized list of every connector inbox item across all apps,
208
+ // each tagged with the app it came from. Read-only; lazily loads the on-disk
209
+ // store so it works from any process (the parent poller keeps the file fresh).
210
+ function getAllInboxes() {
211
+ if (!store) store = loadStore();
212
+ const nameById = new Map(store.apps.map((a) => [String(a.id || a.name), a.name || String(a.id || "")]));
213
+ const out = [];
214
+ for (const [appId, state] of Object.entries(store.perApp || {})) {
215
+ const appName = nameById.get(appId) || appId;
216
+ for (const it of state.inbox || []) out.push({ appId, appName, ...it });
217
+ }
218
+ return out;
219
+ }
220
+
207
221
  function initConnectedApps() {
208
222
  if (!truthy(config.OC_CONNECTED_APPS || process.env.OC_CONNECTED_APPS, false)) {
209
223
  return { started: false, reason: "OC_CONNECTED_APPS disabled" };
@@ -231,5 +245,6 @@ module.exports = {
231
245
  registerConnector,
232
246
  fetchConnectedApps,
233
247
  snapshot,
248
+ getAllInboxes,
234
249
  STORE_FILE,
235
250
  };
@@ -0,0 +1,251 @@
1
+ // Track C — dynamic owner-guardrail capture. When the OWNER states a durable
2
+ // behavioural rule mid-chat ("from now on ...", "never ...", "stop ...ing"), a
3
+ // deterministic post-turn detector either captures it straight into lessons.md
4
+ // (always-loaded) or asks first, so the correction holds on the next turn with
5
+ // no code change and no deploy.
6
+ //
7
+ // Invariants:
8
+ // - Owner-only, HARD. Rules are NEVER captured from external speakers — the
9
+ // caller gates on relationship.guardActive(speaker) and consider() re-checks.
10
+ // - Advisory-tier. A captured rule layers ABOVE lessons but can never override
11
+ // the code-fixed hard rules (tool-first, enforcer). Safety rails stay in code.
12
+ // - No model call. Detection is phrase-based and default-silent; the ambiguous
13
+ // tier defers to the owner (a Y/N button), not to a model that could invent
14
+ // a rule. Clear "from now on / never / stop" phrasings auto-capture with an
15
+ // announce + one-tap undo; softer imperatives ask first.
16
+ // - Dedupe against existing lessons (normalized text) so the same rule
17
+ // reinforces instead of cluttering the always-on budget.
18
+
19
+ const fs = require("fs");
20
+ const path = require("path");
21
+ const crypto = require("crypto");
22
+ const { atomicWriteFileSync } = require("./fsutil");
23
+
24
+ const CONFIG_DIR = require("../config-dir");
25
+ const lessons = require("./lessons");
26
+
27
+ const PENDING_FILE = path.join(CONFIG_DIR, "guardrail-pending.json");
28
+ const PENDING_TTL_MS = Number(process.env.GUARDRAIL_PENDING_TTL_MS || 24 * 60 * 60 * 1000);
29
+ const MIN_RULE_CHARS = 10;
30
+ const MIN_RULE_WORDS = 3;
31
+ const MAX_RULE_CHARS = lessons.MAX_LESSON_CHARS || 240;
32
+
33
+ // Operability control (mirrors pack-review's PACK_REVIEW gate). Default ON —
34
+ // this is not a hidden kill-switch; it lets the capture path be disabled
35
+ // without a deploy if it ever misfires, and lets tests force it off.
36
+ function enabled() {
37
+ return String(process.env.GUARDRAIL_CAPTURE || "on").toLowerCase() !== "off";
38
+ }
39
+
40
+ // ── detection (pure, deterministic) ─────────────────────────────────
41
+
42
+ // Explicit durable markers → high confidence (auto-capture). Matched anywhere.
43
+ const HIGH_MARKERS = [
44
+ /\bfrom now on\b/i,
45
+ /\bfrom here on(?: out)?\b/i,
46
+ /\bgoing forward\b/i,
47
+ /\bin (?:the )?future\b/i,
48
+ /\bnever again\b/i,
49
+ /\bmake (?:it|this|that) a (?:standing )?rule\b/i,
50
+ /\bas a (?:standing )?rule\b/i,
51
+ /\balways remember to\b/i,
52
+ ];
53
+ // Clear negative directive at the start of a (cleaned) clause → high. "stop"
54
+ // must be followed by a gerund so "stop summarizing" captures but "stop the
55
+ // server" (a one-off action) does not.
56
+ const HIGH_NEG = /^(?:never\s+\w+|stop\s+\w+ing|(?:don'?t|do not) ever\b)/i;
57
+ // Softer imperatives → medium (ask the owner first).
58
+ const MED_OPENER = /^(?:always|don'?t|do not|make sure|be sure|remember to|prefer|avoid)\b/i;
59
+
60
+ // Junk that pattern-matches a directive but is never a rule.
61
+ const STOP_RULES = new Set(["never mind", "nevermind", "stop it", "never ever", "don't worry", "do not worry"]);
62
+
63
+ function splitSentences(text) {
64
+ return String(text || "")
65
+ .split(/(?<=[.!?])\s+|\n+/)
66
+ .map((s) => s.trim())
67
+ .filter(Boolean);
68
+ }
69
+
70
+ function endsAsQuestion(s) {
71
+ return /\?\s*$/.test(String(s || ""));
72
+ }
73
+
74
+ function stripOpener(s) {
75
+ return String(s || "").replace(/^(?:ok(?:ay)?|hey|so|and|also|well|please|now)[,:\s]+/i, "").trim();
76
+ }
77
+
78
+ // Turn a directive sentence into a clean, imperative one-line rule: drop soft
79
+ // openers, leading durable markers, and polite lead-ins; collapse whitespace;
80
+ // capitalize; cap length. Keeps "never/stop/always/make sure" intact — those
81
+ // are the rule's core and drive classification.
82
+ function cleanRule(sentence) {
83
+ let s = stripOpener(sentence);
84
+ s = s.replace(/^(?:from now on|from here on(?: out)?|going forward|in (?:the )?future)[,:\s]+/i, "");
85
+ s = s.replace(/^(?:can you|could you|i(?:'d| would)? (?:like|want) you to|i want you to)\s+/i, "");
86
+ s = s.replace(/\s+/g, " ").trim().replace(/[\s,;:]+$/g, "");
87
+ if (s) s = s[0].toUpperCase() + s.slice(1);
88
+ return s.slice(0, MAX_RULE_CHARS);
89
+ }
90
+
91
+ function ruleOk(rule) {
92
+ if (!rule || rule.length < MIN_RULE_CHARS) return false;
93
+ const low = rule.toLowerCase();
94
+ if (STOP_RULES.has(low)) return false;
95
+ if (low.split(/\s+/).length < MIN_RULE_WORDS) return false;
96
+ return true;
97
+ }
98
+
99
+ // Returns { isRule, confidence: "high"|"medium", rule, trigger } or { isRule:false }.
100
+ // High short-circuits; a medium hit is remembered as the fallback.
101
+ function detect(text) {
102
+ let medium = null;
103
+ for (const s of splitSentences(text)) {
104
+ if (endsAsQuestion(s)) continue;
105
+ const rule = cleanRule(s);
106
+ if (!ruleOk(rule)) continue;
107
+ const hasMarker = HIGH_MARKERS.some((re) => re.test(s));
108
+ if (hasMarker || HIGH_NEG.test(rule)) {
109
+ return { isRule: true, confidence: "high", rule, trigger: hasMarker ? "marker" : "directive" };
110
+ }
111
+ if (!medium && MED_OPENER.test(rule)) medium = { isRule: true, confidence: "medium", rule, trigger: "soft" };
112
+ }
113
+ return medium || { isRule: false };
114
+ }
115
+
116
+ // ── capture (dedupe against existing lessons) ───────────────────────
117
+
118
+ function findDuplicate(rule) {
119
+ const norm = lessons.normalize(rule);
120
+ return lessons.readLessons().find((l) => lessons.normalize(l.text) === norm) || null;
121
+ }
122
+
123
+ // Add the rule as a lesson, or reinforce the existing twin. origin marks
124
+ // provenance (guardrail = owner stated it in chat); lessons meta carries the date.
125
+ function capture(rule, { origin = "guardrail" } = {}) {
126
+ const dup = findDuplicate(rule);
127
+ const target = dup ? dup.text : rule;
128
+ const r = lessons.addLesson({ text: target, origin });
129
+ return {
130
+ added: !!r.added,
131
+ reinforced: !!r.reinforced,
132
+ duplicate: !!dup,
133
+ id: r.id || (dup && dup.id) || lessons.lessonId(target),
134
+ text: target,
135
+ count: r.count,
136
+ overCap: !!r.overCap,
137
+ };
138
+ }
139
+
140
+ // ── pending store (medium-confidence Y/N confirms) ──────────────────
141
+
142
+ function readPending() {
143
+ try { return JSON.parse(fs.readFileSync(PENDING_FILE, "utf-8")) || {}; }
144
+ catch (e) { return {}; }
145
+ }
146
+
147
+ function writePending(obj) {
148
+ fs.mkdirSync(path.dirname(PENDING_FILE), { recursive: true, mode: 0o700 });
149
+ atomicWriteFileSync(PENDING_FILE, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 });
150
+ }
151
+
152
+ function prunePending(obj, now) {
153
+ for (const [tok, rec] of Object.entries(obj)) {
154
+ if (!rec || now - (rec.createdAt || 0) > PENDING_TTL_MS) delete obj[tok];
155
+ }
156
+ return obj;
157
+ }
158
+
159
+ function putPending(rule, meta = {}, now = Date.now()) {
160
+ const obj = prunePending(readPending(), now);
161
+ const token = crypto.randomBytes(5).toString("hex");
162
+ obj[token] = { rule, channel: meta.channel || null, createdAt: now };
163
+ writePending(obj);
164
+ return token;
165
+ }
166
+
167
+ function takePending(token, now = Date.now()) {
168
+ const obj = prunePending(readPending(), now);
169
+ const rec = obj[token] || null;
170
+ if (rec) { delete obj[token]; }
171
+ writePending(obj);
172
+ return rec;
173
+ }
174
+
175
+ // ── render (pure) ───────────────────────────────────────────────────
176
+
177
+ function clip(s) {
178
+ return String(s || "").replace(/\s+/g, " ").trim().slice(0, 200);
179
+ }
180
+
181
+ function renderCaptured(res) {
182
+ if (!res.added) return `📌 Already a standing rule — reinforced: "${clip(res.text)}"`;
183
+ return `📌 Got it — I'll keep this loaded as a standing rule from now on:\n"${clip(res.text)}"` +
184
+ `${res.overCap ? "\n(Over the lessons cap — the nightly dream will tidy.)" : ""}` +
185
+ `\nTap Undo if that's not what you meant.`;
186
+ }
187
+
188
+ function renderAsk(rule) {
189
+ return `Want me to make this a standing rule (always loaded)?\n"${clip(rule)}"`;
190
+ }
191
+
192
+ // ── orchestrator ────────────────────────────────────────────────────
193
+
194
+ // Owner-gated. `announce(text, opts?)` sends to the chat (opts may carry a
195
+ // keyboard); omit it for a dry run. Returns a small result for testability.
196
+ async function consider({ text, speaker, announce, now = Date.now() } = {}) {
197
+ if (!enabled()) return { action: "disabled" };
198
+ const relationship = require("./relationship");
199
+ if (relationship.guardActive(speaker)) return { action: "external-skip" };
200
+
201
+ const d = detect(text);
202
+ if (!d.isRule) return { action: "silent" };
203
+
204
+ if (d.confidence === "high") {
205
+ const res = capture(d.rule);
206
+ if (typeof announce === "function") {
207
+ const opts = res.added
208
+ ? { keyboard: { inline_keyboard: [[{ text: "↩️ Undo", callback_data: `grd:undo:${res.id}` }]] } }
209
+ : undefined;
210
+ try { await announce(renderCaptured(res), opts); } catch (e) {}
211
+ }
212
+ return { action: res.added ? "captured" : "reinforced", id: res.id, rule: res.text, duplicate: res.duplicate };
213
+ }
214
+
215
+ const token = putPending(d.rule, { channel: speaker && speaker.channelId }, now);
216
+ if (typeof announce === "function") {
217
+ const opts = { keyboard: { inline_keyboard: [[
218
+ { text: "✅ Make it a rule", callback_data: `grd:save:${token}` },
219
+ { text: "Skip", callback_data: `grd:no:${token}` },
220
+ ]] } };
221
+ try { await announce(renderAsk(d.rule), opts); } catch (e) {}
222
+ }
223
+ return { action: "asked", token, rule: d.rule };
224
+ }
225
+
226
+ // ── callback actions (owner-gated by the caller) ────────────────────
227
+
228
+ function confirmPending(token, now = Date.now()) {
229
+ const rec = takePending(token, now);
230
+ if (!rec) return { ok: false, reason: "expired" };
231
+ const res = capture(rec.rule);
232
+ return { ok: true, ...res };
233
+ }
234
+
235
+ function dropPending(token, now = Date.now()) {
236
+ const rec = takePending(token, now);
237
+ return { ok: !!rec, rule: rec ? rec.rule : "" };
238
+ }
239
+
240
+ function undo(lessonId) {
241
+ return { ok: !!lessons.removeLesson(lessonId) };
242
+ }
243
+
244
+ module.exports = {
245
+ PENDING_FILE, PENDING_TTL_MS,
246
+ enabled, detect, cleanRule, ruleOk, splitSentences,
247
+ findDuplicate, capture,
248
+ putPending, takePending, prunePending,
249
+ consider, confirmPending, dropPending, undo,
250
+ renderCaptured, renderAsk,
251
+ };
package/core/handlers.js CHANGED
@@ -12,7 +12,8 @@ const {
12
12
  CHAT_ID, discoverProviderExecutable, SOUL_FILE,
13
13
  } = require("./config");
14
14
  const { register } = require("./commands");
15
- const { send, deleteMessage } = require("./io");
15
+ const { send, deleteMessage, typing } = require("./io");
16
+ const { currentAdapter, currentChannelId } = require("./context");
16
17
  const {
17
18
  currentState, saveState, rootSession,
18
19
  freshUsage, getActiveProvider, getProjectKey, getProviderSession, setProviderSession, clearProviderSession,
@@ -623,7 +624,7 @@ async function requestAgentSpaceUpgrade() {
623
624
  res.on("end", () => resolve({ status: res.statusCode, body: data }));
624
625
  });
625
626
  req.on("error", (e) => resolve({ status: 0, body: String(e.message || e) }));
626
- req.setTimeout(15000, () => { req.destroy(new Error("timeout")); });
627
+ req.setTimeout(30000, () => { req.destroy(new Error("timeout")); });
627
628
  req.end();
628
629
  });
629
630
  }
@@ -677,6 +678,14 @@ register({
677
678
  await send("Upgrade requested — AgentSpace will pull latest image and restart, see you in a minute.");
678
679
  return;
679
680
  }
681
+ // A client-side timeout is not a failure: the control plane triggers the
682
+ // rollout fire-and-forget, and a pod being rolled is exactly when its own
683
+ // in-flight request gets cut off. Framing it as "failed" invites a retry
684
+ // storm of redundant rollouts, so surface it as pending instead.
685
+ if (result && result.status === 0 && /timeout/i.test(result.body || "")) {
686
+ await send("Upgrade request sent but the reply timed out — likely because I'm already restarting. Give it a minute before re-running; I should come back on the new image.");
687
+ return;
688
+ }
680
689
  await send(`Upgrade request failed (status ${result?.status || "?"}). ${(result?.body || "").slice(0, 300)}`);
681
690
  return;
682
691
  } catch (e) {
@@ -1063,6 +1072,45 @@ register({
1063
1072
  },
1064
1073
  });
1065
1074
 
1075
+ register({
1076
+ name: "agenda", aliases: ["agency"], description: "Aggregated read-only work list (tasks + Spaces + notifications)",
1077
+ handler: async (env) => {
1078
+ if (!authorized(env)) return;
1079
+ const adapter = currentAdapter();
1080
+ const channelId = currentChannelId();
1081
+ const ledger = require("./agency/ledger");
1082
+ try { typing(); } catch (_) {}
1083
+ let result;
1084
+ try {
1085
+ result = await ledger.collect({ adapter: adapter && adapter.id, channelId });
1086
+ } catch (e) {
1087
+ return send(`Couldn't build the agenda: ${e.message}`);
1088
+ }
1089
+ send(ledger.render(result, { now: result.generatedAt, limitPerSource: 8 }));
1090
+ },
1091
+ });
1092
+
1093
+ register({
1094
+ name: "next", description: "Advisory: what I'd pick to do next, and why (acts on nothing)",
1095
+ handler: async (env) => {
1096
+ if (!authorized(env)) return;
1097
+ const adapter = currentAdapter();
1098
+ const channelId = currentChannelId();
1099
+ const ledger = require("./agency/ledger");
1100
+ const deliberate = require("./agency/deliberate");
1101
+ try { typing(); } catch (_) {}
1102
+ let proposal;
1103
+ try {
1104
+ const result = await ledger.collect({ adapter: adapter && adapter.id, channelId });
1105
+ proposal = await deliberate.deliberate(result, { now: result.generatedAt });
1106
+ try { deliberate.record(proposal, { channel: adapter && adapter.id ? `${adapter.id}:${channelId}` : null }); } catch (_) {}
1107
+ } catch (e) {
1108
+ return send(`Couldn't deliberate: ${e.message}`);
1109
+ }
1110
+ send(deliberate.render(proposal));
1111
+ },
1112
+ });
1113
+
1066
1114
  register({
1067
1115
  name: "toolmode", description: "Tooling enforcement mode (strict = forced CLI, relaxed = scripting allowed)", args: "[strict|relaxed]", ownerOnly: true,
1068
1116
  handler: async (env, { tail }) => {