@inetafrica/open-claudia 3.5.8 → 3.5.10

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.10
4
+
5
+ Passive replies in Kazee groups, and no endless bot-to-bot loops. Needs chat-central with the "Bot replies" group setting (an older server never sends the new mode, so groups keep today's behaviour). Canary first, on the sales bot.
6
+
7
+ - **"Bot replies: When they can help".** A group admin now picks how bots reply in a group: When tagged (as before), When they can help, or Every message. In "When they can help" the bot reads people's text messages and, after a short pause (6 s after the last message, at most 20 s), a quick check on a small model (utility purpose `passive-reply`, Haiku by default; `PASSIVE_REPLY_PROVIDER` / `PASSIVE_REPLY_MODEL`) decides whether it can help. Only a yes starts a real turn. Unasked replies are read-only (plan mode), at most 6 an hour per group, and the guards hold anything doubtful quietly instead of asking the owner. "@bot be quiet" snoozes the bot in that group for 3 hours; "@bot you can talk again" ends it. Messages from bots, commands, system notices, cards and media are never judged.
8
+ - **No endless bot-to-bot loops.** Two bots in one group could answer each other forever. Another bot now gets this bot's answer only when it @-mentions it: replies to the bot's messages, a shared thread and "Every message" / "When they can help" groups no longer count for bots. Even with a mention, once 4 messages in a row came from bots (`OC_BOT_CHAIN_LIMIT`), the bot stays silent until a person speaks, and it stays silent when it cannot read the conversation to check. People are unaffected.
9
+ - **Fix: `decide` asks in the thread that queued it.** Decision cards went to the owner's main chat even when a thread queued them (Sumeet, 2 Oct: "This is this thread."). When asking fails, `decide` now reports the real number of open decisions instead of "undefined open".
10
+ - **Tests:** `test-passive-replies.js`, `test-passive-replies-router.js`, `test-kazee-bot-loops.js` and additions to `test-enforcer.js`; `test-run-lock.js` uses its own config directory, so a local `.env` cannot change its limits.
11
+
12
+ ## 3.5.9
13
+
14
+ Kazee Cards: the bot sends interactive and data cards in Kazee Chat, and asks the owner's decisions one card at a time. Needs chat-central with Kazee Cards v1 (cards are refused by an older server). Canary first, on the owner's own bot.
15
+
16
+ - **`open-claudia card send <spec|->`** posts a card into the turn's own chat and thread: a decision (2-4 options), a choice (poll, one or several answers), a rating (stars, optional comment), a slider, or a report (stat tiles, bar chart, facts). `card update <id> --close | --open | --note "..." | --spec <file>` changes a card the bot sent, `card show <id>` prints what the bot knows about it, and `card example <kind>` prints a starting spec. Only a live, attributed run may send one, and it always lands in that run's own room.
17
+ - **An answer wakes the conversation that sent the card**, as that card's sender. A card a few named people answer wakes on each answer; a poll anyone may answer does not wake per vote unless the sender asks (`--wake each|first|none`). Extras (for example "Later") always reach the sender. Anything a person typed is quoted as their words, never as instructions.
18
+ - **The owner's decision queue: `open-claudia decide "<question>" --option id="label" ... [--pick id] [--body "why"]`.** Decisions go to the owner's chat as cards, one at a time. The answer wakes the conversation that queued it and the next decision is asked. "⏰ Later" closes the card with a note and asks again three hours later; "🎧 Tell me more" asks the conversation for a short voice explainer. `decide list` and `decide cancel <id>`.
19
+ - **Kazee adapter:** `sendCard` / `updateCard`, and `card:response` events from the chat server reach the card ledger (`~/.open-claudia/cards.json`, `decisions.json`).
20
+ - **Tests:** `test-kazee-cards.js` (adapter, wake-ups, comment quoting, the decision queue) and `test-loopback-cards.js` (who may send, change and decide).
21
+
3
22
  ## 3.5.8
4
23
 
5
24
  GPT-6.1 Sol for Codex, and a /codex_login code you can copy. Canary first on network-bot and procurement-specialist, then the fleet.
package/bin/cards.js ADDED
@@ -0,0 +1,163 @@
1
+ // `open-claudia card ...` and `open-claudia decide ...`: Kazee Cards from
2
+ // inside a turn (see core/cards.js and chat-central CARDS-V1).
3
+
4
+ const fs = require("fs");
5
+ const { postJson } = require("./loopback-client");
6
+
7
+ const EXAMPLES = {
8
+ decision: {
9
+ v: 1, kind: "decision", kicker: "Decision", tag: "Usage",
10
+ title: "Cut back on helper agents?",
11
+ body: "Helpers are about 60% of usage since 23 Sep.",
12
+ input: { options: [{ id: "yes", label: "Yes, cut back", style: "primary" }, { id: "no", label: "Keep as is" }] },
13
+ recommendation: { option: "yes" },
14
+ extras: [{ id: "later", label: "⏰ Later" }],
15
+ policy: { responders: ["<kazeeUserId>"], mode: "first" },
16
+ },
17
+ choice: {
18
+ v: 1, kind: "choice", title: "Which should we build first?",
19
+ input: { options: [{ id: "calls", label: "📞 Calls" }, { id: "taskbar", label: "🧠 Taskbar" }], multiple: false },
20
+ policy: { mode: "changeable", results: "live", anonymous: true, closesAt: "2026-10-02T15:00:00Z" },
21
+ },
22
+ rating: {
23
+ v: 1, kind: "rating", title: "How does the ring screen feel?",
24
+ input: { max: 5, comment: true, commentPlaceholder: "What should change? (optional)" },
25
+ policy: { mode: "each", results: "closed" },
26
+ },
27
+ slider: {
28
+ v: 1, kind: "slider", title: "Most helpers running at once",
29
+ input: { min: 0, max: 8, step: 1, value: 3, unit: "helpers at a time", minLabel: "careful", maxLabel: "fast" },
30
+ recommendation: { value: 3 },
31
+ },
32
+ report: {
33
+ v: 1, kind: "report", title: "Since the switch to Opus 5.5",
34
+ blocks: [
35
+ { type: "stats", items: [{ value: "4,763", label: "model calls a day", delta: "▲ 3.7×", tone: "warn" }] },
36
+ { type: "bars", unit: "$", items: [{ label: "23", value: 416 }, { label: "24", value: 918 }], ref: { value: 259, label: "before" } },
37
+ { type: "facts", items: [{ label: "Source", value: "Claude Code logs" }] },
38
+ ],
39
+ footer: "Source: Claude Code logs",
40
+ },
41
+ };
42
+
43
+ function flag(args, name) {
44
+ const i = args.indexOf(name);
45
+ if (i < 0) return undefined;
46
+ const v = args[i + 1];
47
+ args.splice(i, 2);
48
+ return v;
49
+ }
50
+ function bool(args, name) {
51
+ const i = args.indexOf(name);
52
+ if (i < 0) return false;
53
+ args.splice(i, 1);
54
+ return true;
55
+ }
56
+ function readSpec(arg) {
57
+ if (!arg) throw new Error("missing card spec (a file, JSON, or - for stdin)");
58
+ if (arg === "-") return JSON.parse(fs.readFileSync(0, "utf8"));
59
+ if (arg.trim().startsWith("{")) return JSON.parse(arg);
60
+ return JSON.parse(fs.readFileSync(arg, "utf8"));
61
+ }
62
+ function fail(msg, code = 1) {
63
+ console.error(msg);
64
+ process.exit(code);
65
+ }
66
+
67
+ const CARD_USAGE = `Usage:
68
+ open-claudia card send <spec.json | '{json}' | -> [--wake each|first|none] [--content "plain text"]
69
+ open-claudia card update <messageId> [--close | --open] [--note "text"] [--spec <file|json>]
70
+ open-claudia card show <messageId>
71
+ open-claudia card example <decision|choice|rating|slider|report>
72
+ Cards are Kazee Chat only. Answers wake this conversation when --wake is each
73
+ (default for a card a few named people answer) or first; polls anyone answers
74
+ default to none. Spec: chat-central CARDS-V1 (v:1, kind, title, input, policy...).`;
75
+
76
+ async function runCard(args) {
77
+ const sub = args.shift();
78
+ try {
79
+ if (sub === "example") {
80
+ const kind = args[0];
81
+ if (!EXAMPLES[kind]) fail(`kinds: ${Object.keys(EXAMPLES).join(", ")}`);
82
+ console.log(JSON.stringify(EXAMPLES[kind], null, 2));
83
+ return;
84
+ }
85
+ if (sub === "send") {
86
+ const wake = flag(args, "--wake");
87
+ const content = flag(args, "--content");
88
+ const card = readSpec(args[0]);
89
+ const res = await postJson("card-send", { card, wake, content });
90
+ console.log(`Sent card ${res.messageId} (answers wake this conversation: ${res.wake})`);
91
+ return;
92
+ }
93
+ if (sub === "update") {
94
+ const messageId = args.shift();
95
+ if (!messageId) fail(CARD_USAGE);
96
+ const note = flag(args, "--note");
97
+ const spec = flag(args, "--spec");
98
+ const status = bool(args, "--close") ? "closed" : bool(args, "--open") ? "open" : undefined;
99
+ if (!note && !spec && !status) fail("nothing to change: --close, --open, --note or --spec");
100
+ const res = await postJson("card-update", { messageId, note, status, card: spec ? readSpec(spec) : undefined });
101
+ console.log(`Updated card ${messageId}: ${JSON.stringify(res.card?.state || {})}`);
102
+ return;
103
+ }
104
+ if (sub === "show") {
105
+ const res = await postJson("card-show", { messageId: args[0] });
106
+ console.log(JSON.stringify(res.card, null, 2));
107
+ return;
108
+ }
109
+ fail(CARD_USAGE, sub ? 1 : 0);
110
+ } catch (e) {
111
+ fail(`card ${sub}: ${e.message}`);
112
+ }
113
+ }
114
+
115
+ const DECIDE_USAGE = `Usage:
116
+ open-claudia decide "<question>" --option <id>="<label>" --option <id>="<label>" [--pick <id>] [--body "why it matters"] [--tag "Area"]
117
+ open-claudia decide list
118
+ open-claudia decide cancel <id>
119
+ Owner only. Decisions go to your Kazee chat as cards, one at a time; the answer
120
+ wakes the conversation that queued it. "Later" snoozes it for 3 hours, "Tell me
121
+ more" asks this conversation for a short voice explainer.`;
122
+
123
+ async function runDecide(args) {
124
+ const first = args[0];
125
+ try {
126
+ if (first === "list") {
127
+ const res = await postJson("decide-list", {});
128
+ if (!res.decisions.length) return console.log("No decisions queued.");
129
+ for (const d of res.decisions) {
130
+ const extra = d.status === "decided" ? ` -> ${d.answerLabel}` : d.status === "snoozed" ? ` until ${d.notBefore}` : "";
131
+ console.log(`${d.id} ${d.status.padEnd(9)} ${d.title}${extra}`);
132
+ }
133
+ return;
134
+ }
135
+ if (first === "cancel") {
136
+ const res = await postJson("decide-cancel", { id: args[1] });
137
+ console.log(`Cancelled ${res.decision.id}: ${res.decision.title}`);
138
+ return;
139
+ }
140
+ const rest = [...args];
141
+ const title = rest.shift();
142
+ if (!title || title.startsWith("--")) fail(DECIDE_USAGE, title ? 1 : 0);
143
+ const options = [];
144
+ let i;
145
+ while ((i = rest.indexOf("--option")) >= 0) {
146
+ const raw = rest[i + 1] || "";
147
+ rest.splice(i, 2);
148
+ const eq = raw.indexOf("=");
149
+ if (eq <= 0) fail(`--option needs id="label" (got ${raw})`);
150
+ options.push({ id: raw.slice(0, eq).trim(), label: raw.slice(eq + 1).trim() });
151
+ }
152
+ const pick = flag(rest, "--pick");
153
+ const body = flag(rest, "--body");
154
+ const tag = flag(rest, "--tag");
155
+ const res = await postJson("decide-add", { title, options, pick, body, tag });
156
+ const where = res.askedNow ? "asked now" : `queued (${res.waiting} open)`;
157
+ console.log(`Decision ${res.decision.id} ${where}: ${res.decision.title}${res.warning ? `\n${res.warning}` : ""}`);
158
+ } catch (e) {
159
+ fail(`decide: ${e.message}`);
160
+ }
161
+ }
162
+
163
+ module.exports = { runCard, runDecide, EXAMPLES };
package/bin/cli.js CHANGED
@@ -444,6 +444,14 @@ switch (command) {
444
444
  break;
445
445
  }
446
446
 
447
+ case "card": {
448
+ require("./cards").runCard(args.slice(1));
449
+ break;
450
+ }
451
+ case "decide": {
452
+ require("./cards").runDecide(args.slice(1));
453
+ break;
454
+ }
447
455
  case "schedule-wakeup": {
448
456
  require("./schedule").runScheduleWakeup(args.slice(1));
449
457
  break;
@@ -612,6 +620,9 @@ Send tools (only work inside an active bot-spawned task):
612
620
  narration; ext picks format; --voice <id> optional)
613
621
  open-claudia transcribe <file> Speech-to-text: print the transcript of an audio/video file
614
622
  open-claudia call [toUserId] Place a live voice call on the current channel (kazee; owner-only)
623
+ open-claudia card send <spec|-> Send a Kazee Card (decision, choice, rating, slider, report); answers wake this conversation
624
+ open-claudia card update <id> ... Close/open a card you sent, add a note, or replace its spec (card example <kind> for specs)
625
+ open-claudia decide "<q>" --option id="label" ... Queue a decision card for the owner (one at a time; decide list|cancel)
615
626
 
616
627
  Memory tools:
617
628
  open-claudia transcript-search <query> Ranked FTS5 search over indexed transcripts
package/bot.js CHANGED
@@ -362,6 +362,9 @@ async function safeModeBoot() {
362
362
  }
363
363
  }
364
364
 
365
+ // Kazee Cards: snoozed owner decisions are asked again when due.
366
+ try { require("./core/cards").startDecisionPump(registry); } catch (e) { console.error("Decision pump start failed:", e.message); }
367
+
365
368
  // This incarnation counts as healthy once it survives the stable window —
366
369
  // resets the crash-loop streak the safe-mode gate counts at boot.
367
370
  setTimeout(() => { try { safeBoot.markBootStable(); } catch (e) {} }, safeBoot.STABLE_UPTIME_MS).unref();
@@ -18,6 +18,7 @@ const { createKazeeSocket } = require("./socket");
18
18
  const { normalize } = require("./format");
19
19
  const { KazeeCallManager } = require("./call/manager");
20
20
  const kazeeState = require("./state");
21
+ const { createPassiveGate, quietCommand, CONTEXT_MESSAGES: PASSIVE_CONTEXT_MESSAGES, SNOOZE_MS: PASSIVE_SNOOZE_MS } = require("../../core/passive-replies");
21
22
 
22
23
  // Group catch-up context sizing: how many messages an engaged turn may carry,
23
24
  // how long each rendered body may be, and how far back a chat's very first
@@ -51,6 +52,14 @@ const THREAD_FETCH_TIMEOUT_MS = 4000;
51
52
  // A thread whose root someone else posted is re-checked for the bot among its
52
53
  // participants at most this often (the answer is "no" until the bot replies).
53
54
  const THREAD_NOT_IN_TTL_MS = 60 * 1000;
55
+ // Bots answering bots: once this many messages in a row (the newest one
56
+ // included) came from bots, a bot's @-mention no longer engages the bot until
57
+ // a person speaks again. OC_BOT_CHAIN_LIMIT overrides it (whole number >= 1).
58
+ const BOT_CHAIN_LIMIT = 4;
59
+ function botChainLimit() {
60
+ const value = parseInt(process.env.OC_BOT_CHAIN_LIMIT, 10);
61
+ return Number.isSafeInteger(value) && value >= 1 ? value : BOT_CHAIN_LIMIT;
62
+ }
54
63
  // How many replies a thread's first turn carries as context.
55
64
  const THREAD_CONTEXT_LIMIT = 30;
56
65
  const THREAD_ROOT_BODY_LIMIT = 1000;
@@ -125,7 +134,7 @@ const MIME_EXT = {
125
134
  };
126
135
 
127
136
  class KazeeAdapter {
128
- constructor({ id = "kazee", url, token, ownerUserId, botUserId }) {
137
+ constructor({ id = "kazee", url, token, ownerUserId, botUserId, passiveGate = null }) {
129
138
  this.id = id;
130
139
  this.type = "kazee";
131
140
  // KAZEE_URL is the root host (socket.io lives at /). REST routes are
@@ -154,6 +163,21 @@ class KazeeAdapter {
154
163
  this._threadNotIn = new Map();
155
164
  this._typingThreadWarned = new Set();
156
165
  this._callManager = new KazeeCallManager({ adapter: this });
166
+ // Groups set to "When they can help" (core/passive-replies.js); made on
167
+ // first use, so a bot in no passive group never builds one.
168
+ this._passive = passiveGate;
169
+ }
170
+
171
+ _passiveGate() {
172
+ if (!this._passive) {
173
+ this._passive = createPassiveGate({
174
+ store: {
175
+ getSnooze: (chatId) => kazeeState.getPassiveSnooze(chatId),
176
+ setSnooze: (chatId, until) => kazeeState.setPassiveSnooze(chatId, until),
177
+ },
178
+ });
179
+ }
180
+ return this._passive;
157
181
  }
158
182
 
159
183
  // chat-central's chat doc carries the group flag and the per-chat assistant
@@ -168,10 +192,17 @@ class KazeeAdapter {
168
192
  const res = await this._request("GET", `/chats/${encodeURIComponent(chatId)}`);
169
193
  const chat = res?.chat || res || {};
170
194
  if (chat.error) throw new Error(String(chat.error));
195
+ // Bots are participants of type "AI": in a passive group the bot never
196
+ // answers another bot unasked. Its own name lets "@Name be quiet" match.
197
+ const participants = (Array.isArray(chat.participants) ? chat.participants : [])
198
+ .filter((p) => p && typeof p === "object");
199
+ const self = participants.find((p) => String(p._id || p.id) === String(this.botUserId));
171
200
  const meta = {
172
201
  isGroupChat: !!chat.isGroupChat,
173
202
  assistantAction: String(chat.assistantAction || ""),
174
203
  name: String(chat.chatName || chat.name || ""),
204
+ botIds: participants.filter((p) => p.type === "AI" || p.type === "Bot").map((p) => String(p._id || p.id)),
205
+ botName: self && self.name ? String(self.name) : "",
175
206
  };
176
207
  this._chatMetaCache.set(chatId, { meta, at: Date.now() });
177
208
  return meta;
@@ -248,6 +279,32 @@ class KazeeAdapter {
248
279
  });
249
280
  }
250
281
  socket.on("message:new", (msg) => this._handleInboundMessage(msg));
282
+ // A group admin changed the group (its bot reply setting, name, members):
283
+ // drop the cached settings so the next message reads them fresh instead
284
+ // of up to five minutes late.
285
+ socket.on("chat:updated", (payload) => {
286
+ const chatId = String(payload?.chatId || payload?.chat?._id || "");
287
+ if (chatId) this._chatMetaCache.delete(chatId);
288
+ });
289
+ // Kazee Cards: someone answered (or tapped an extra on) a card this bot
290
+ // sent. Routed like a button press; core/cards.js wakes the conversation
291
+ // the card came from. The payload names its own thread.
292
+ socket.on("card:response", (payload) => {
293
+ if (!payload || !payload.messageId) return;
294
+ const userId = payload.userId ? String(payload.userId) : "";
295
+ this._emit("action", {
296
+ adapter: this,
297
+ channelId: String(payload.chatId),
298
+ canonicalUserId: userId ? canonicalForChannel("kazee", userId) : null,
299
+ userId: userId || null,
300
+ type: "action",
301
+ messageId: payload.messageId,
302
+ threadId: payload.threadRoot ? String(payload.threadRoot) : null,
303
+ action: { buttonId: `kc:${payload.messageId}`, payload: `kc:${payload.messageId}`, sourceMessageId: payload.messageId },
304
+ card: payload,
305
+ raw: payload,
306
+ });
307
+ });
251
308
  socket.on("message:action", (payload) => {
252
309
  const channelId = String(payload.chatId);
253
310
  const known = this._sentMessages.has(String(payload.messageId || ""));
@@ -562,7 +619,10 @@ class KazeeAdapter {
562
619
  // A thread reply also engages it when the bot posted the thread's root or
563
620
  // has already replied in that thread (it is part of that conversation).
564
621
  // Everyone is gated, owner included: a bot answering every group message
565
- // drowns the room. 1:1 chats are never gated. Engaged messages are marked
622
+ // drowns the room. Other bots are gated harder: only an @-mention, and
623
+ // never in a chain of bots (no bot-to-bot loops). A passive group lets the
624
+ // bot judge people's unaddressed messages itself (core/passive-replies.js).
625
+ // 1:1 chats are never gated. Engaged messages are marked
566
626
  // so the router can authorize the turn for non-owner speakers, who then
567
627
  // run guarded (mandate/guardrail vetting + owner escalation).
568
628
  if (this.botUserId) {
@@ -572,12 +632,55 @@ class KazeeAdapter {
572
632
  const mentions = (Array.isArray(msg.mentions) ? msg.mentions : [])
573
633
  .map((m) => String(m?._id || m?.id || m));
574
634
  const mentioned = mentions.includes(botId);
635
+ // Another bot (Sumeet, 3 Oct 2026: two bots in one chat "go on an
636
+ // infinite conversation"): only an explicit @-mention engages the bot,
637
+ // never a reply to its message, a thread it is in, a passive check or
638
+ // an every-message group; and never once the conversation is a chain
639
+ // of bots answering bots (_botChainTooLong) until a person speaks.
640
+ if ((meta.botIds || []).includes(String(senderId))) {
641
+ if (!mentioned) {
642
+ console.log(`Kazee group gate: silent, another bot (chat=${channelId} from=${userId})`);
643
+ return;
644
+ }
645
+ if (await this._botChainTooLong(channelId, envelope.threadId || null, meta, msg)) {
646
+ console.log(`Kazee group gate: silent, bots answering bots (chat=${channelId} from=${userId})`);
647
+ return;
648
+ }
649
+ }
575
650
  const replyToBot = String(envelope.reply?.from?.id || "") === botId;
576
651
  const freeReply = meta.assistantAction === "assistant_free_reply";
652
+ // "When they can help": the bot reads everything and decides itself
653
+ // (core/passive-replies.js) whether to answer what nobody asked it.
654
+ const passive = meta.assistantAction === "assistant_passive";
577
655
  const inThread = !mentioned && !replyToBot && !freeReply && envelope.threadId
578
656
  ? await this._botInThread(channelId, envelope.threadId)
579
657
  : false;
658
+ // "@bot be quiet" / "you can talk again" in a passive group only switch
659
+ // its unprompted replies off or on: no turn, just a one-line answer.
660
+ if (passive && (mentioned || replyToBot) && envelope.type === "text") {
661
+ const command = quietCommand(text, meta.botName);
662
+ if (command) return this._passiveQuietCommand(channelId, command, envelope);
663
+ }
580
664
  if (!mentioned && !replyToBot && !freeReply && !inThread) {
665
+ if (passive && this._passiveCandidate(msg, envelope, meta, senderId)) {
666
+ this._passiveGate().consider({
667
+ chatId: channelId,
668
+ key: envelope.threadId ? `${channelId}#thread:${envelope.threadId}` : channelId,
669
+ envelope,
670
+ botName: meta.botName,
671
+ roomName: meta.name,
672
+ loadContext: () => this._passiveContext(channelId, envelope.threadId),
673
+ emit: (decided, { reason }) => {
674
+ decided.engaged = true;
675
+ decided.surface = "group";
676
+ decided.isGroup = true;
677
+ decided.passive = true;
678
+ decided.passiveReason = reason;
679
+ this._emit("message", decided);
680
+ },
681
+ });
682
+ return;
683
+ }
581
684
  console.log(`Kazee group gate: silent (chat=${channelId} from=${userId})`);
582
685
  return;
583
686
  }
@@ -595,6 +698,85 @@ class KazeeAdapter {
595
698
  this._emit("message", envelope);
596
699
  }
597
700
 
701
+ // Whether the conversation (a thread, or the main timeline) is already a
702
+ // chain of bots answering bots: its newest messages, this one included, all
703
+ // came from bots, botChainLimit() of them or more. A failed read counts as
704
+ // a chain: quiet is the safe side of a possible loop.
705
+ async _botChainTooLong(chatId, threadId, meta, msg) {
706
+ const limit = botChainLimit();
707
+ const bots = new Set([...(meta.botIds || []), String(this.botUserId)]);
708
+ let recent;
709
+ try {
710
+ if (threadId) {
711
+ const data = await this._fetchThread(chatId, threadId, limit + 2);
712
+ recent = [...(data.root && typeof data.root === "object" ? [data.root] : []), ...(Array.isArray(data.replies) ? data.replies : [])];
713
+ } else {
714
+ recent = await withTimeout(this._messagesSince(chatId, null, limit + 2), THREAD_FETCH_TIMEOUT_MS, "Kazee recent messages");
715
+ }
716
+ } catch (e) {
717
+ console.error(`Kazee bot chain check failed (chat=${chatId}):`, e.message);
718
+ return true;
719
+ }
720
+ const list = (Array.isArray(recent) ? recent : []).filter((m) => m && !m.isDeleted && m.type !== "system");
721
+ const currentId = String(msg._id || msg.id || "");
722
+ if (currentId && !list.some((m) => String(m._id || m.id || "") === currentId)) list.push(msg);
723
+ let streak = 0;
724
+ for (let i = list.length - 1; i >= 0 && bots.has(senderIdOf(list[i])); i -= 1) streak += 1;
725
+ return streak >= limit;
726
+ }
727
+
728
+ // Whether an unaddressed message in a passive group is the bot's to judge:
729
+ // plain text a person wrote. Never another bot (no bot-to-bot chatter),
730
+ // never a command, system notice, card or media without words (v1).
731
+ _passiveCandidate(msg, envelope, meta, senderId) {
732
+ if (envelope.type !== "text" || !String(envelope.text || "").trim()) return false;
733
+ if (msg.type === "system" || msg.type === "interactive" || msg.card) return false;
734
+ if ((meta.botIds || []).includes(String(senderId))) return false;
735
+ return true;
736
+ }
737
+
738
+ // What the passive check reads: the thread (root first) or the main
739
+ // timeline's recent messages, oldest first, the bot's own marked "You".
740
+ async _passiveContext(chatId, threadId) {
741
+ const botId = String(this.botUserId);
742
+ let raw;
743
+ if (threadId) {
744
+ const data = await this._fetchThread(chatId, threadId, PASSIVE_CONTEXT_MESSAGES);
745
+ raw = [...(data.root && typeof data.root === "object" ? [data.root] : []), ...(Array.isArray(data.replies) ? data.replies : [])];
746
+ } else {
747
+ raw = await withTimeout(this._messagesSince(chatId, null, PASSIVE_CONTEXT_MESSAGES), THREAD_FETCH_TIMEOUT_MS, "Kazee recent messages");
748
+ }
749
+ return raw
750
+ .filter((m) => m && !m.isDeleted && m.type !== "system")
751
+ .map((m) => ({
752
+ id: String(m._id || m.id || ""),
753
+ who: senderIdOf(m) === botId ? "You" : (m.sender?.name || "Someone"),
754
+ text: this._contextBody(m, CONTEXT_BODY_LIMIT),
755
+ ago: this._ago(m.createdAt),
756
+ }))
757
+ .filter((m) => m.text);
758
+ }
759
+
760
+ // "@bot be quiet" (snooze) or "@bot you can talk again" (resume) in a
761
+ // passive group: anyone in the group may ask; tags keep working meanwhile.
762
+ async _passiveQuietCommand(chatId, command, envelope) {
763
+ const gate = this._passiveGate();
764
+ let reply;
765
+ if (command === "snooze") {
766
+ gate.snooze(chatId);
767
+ const hours = Math.round(PASSIVE_SNOOZE_MS / (60 * 60 * 1000));
768
+ reply = `Okay, I'll stay quiet here for ${hours} hours. Tag me if you need me.`;
769
+ } else {
770
+ gate.resume(chatId);
771
+ reply = "Okay, I'm back. I'll reply here when I can help.";
772
+ }
773
+ try {
774
+ await this.send(chatId, reply, { threadId: envelope.threadId || null, replyTo: envelope.messageId });
775
+ } catch (e) {
776
+ console.error(`Kazee passive ${command} answer failed (chat=${chatId}):`, e.message);
777
+ }
778
+ }
779
+
598
780
  _mediaRefs(msg, contextChatId, source) {
599
781
  const raw = Array.isArray(msg.medias) && msg.medias.length ? msg.medias
600
782
  : msg.media ? [msg.media] : Array.isArray(msg.attachments) ? msg.attachments : [];
@@ -925,6 +1107,37 @@ class KazeeAdapter {
925
1107
  return null;
926
1108
  }
927
1109
 
1110
+ // Kazee Cards (chat-central CARDS-V1): post a card. The server checks the
1111
+ // spec, picks the stored type, the old-app fallback buttons and (unless we
1112
+ // give one) the plain-text version. Returns the card's message id, or null.
1113
+ // Unlike send() there is no retry: a card is not idempotent on our side and
1114
+ // the caller reports the failure instead.
1115
+ async sendCard(channelId, card, opts = {}) {
1116
+ const body = {
1117
+ chat_id: channelId,
1118
+ sender: this.botUserId,
1119
+ type: "interactive",
1120
+ card,
1121
+ findMeCode: `oc-card-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1122
+ };
1123
+ if (typeof opts.content === "string" && opts.content.trim()) body.content = normalize(opts.content);
1124
+ const threadId = opts.threadId ? String(opts.threadId) : null;
1125
+ if (threadId) body.threadRootId = threadId;
1126
+ const res = await this._request("POST", `/chat/${encodeURIComponent(channelId)}/message`, body);
1127
+ if (res && res.success === false) throw new Error(res.error || "card refused");
1128
+ const m = res?.message || res || {};
1129
+ const id = m._id || m.id;
1130
+ this._rememberSentSafely(id, threadId, channelId);
1131
+ return id ? String(id) : null;
1132
+ }
1133
+
1134
+ // Change a card this bot sent: { status: "closed"|"open", note, card }.
1135
+ async updateCard(messageId, patch = {}) {
1136
+ const res = await this._request("PUT", `/message/${encodeURIComponent(messageId)}/card`, patch);
1137
+ if (res && res.success === false) throw new Error(res.error || "card update refused");
1138
+ return res?.card || null;
1139
+ }
1140
+
928
1141
  // Get-or-create the 1:1 chat between the bot and a user, returning its
929
1142
  // chatId. chat-central's createChat returns the existing DM when one
930
1143
  // already exists, so this is idempotent. Requires chats.create on the bot
@@ -49,6 +49,24 @@ function getContextWatermark(chatId) {
49
49
  return (c && c.contextAt) || null;
50
50
  }
51
51
 
52
+ // Passive reply mode (core/passive-replies.js): the time (ms) until which the
53
+ // bot stays quiet in a group after someone told it to; 0 = not snoozed.
54
+ function setPassiveSnooze(chatId, untilMs) {
55
+ const id = String(chatId || "");
56
+ if (!id) return;
57
+ const s = load();
58
+ const next = { ...(s.chats[id] || {}) };
59
+ if (Number(untilMs) > 0) next.passiveSnoozeUntil = Number(untilMs);
60
+ else delete next.passiveSnoozeUntil;
61
+ s.chats[id] = next;
62
+ save();
63
+ }
64
+
65
+ function getPassiveSnooze(chatId) {
66
+ const c = load().chats[String(chatId || "")];
67
+ return Number(c && c.passiveSnoozeUntil) || 0;
68
+ }
69
+
52
70
  function getThread(rootId) {
53
71
  const s = load();
54
72
  const t = s.threads[String(rootId || "")];
@@ -114,6 +132,8 @@ function threadTitled(rootId) {
114
132
  module.exports = {
115
133
  setContextWatermark,
116
134
  getContextWatermark,
135
+ setPassiveSnooze,
136
+ getPassiveSnooze,
117
137
  getThread,
118
138
  markBotRepliedInThread,
119
139
  botRepliedInThread,
package/core/actions.js CHANGED
@@ -147,6 +147,14 @@ function appendMandateLine(rec, lineText, log, opts) {
147
147
  }
148
148
 
149
149
  async function handleAction(envelope) {
150
+ // Kazee Cards: an answer to a card this bot sent (channels/kazee adapter,
151
+ // card:response). The card ledger decides what it wakes; nothing is sent
152
+ // into the chat from here.
153
+ if (envelope.card) {
154
+ try { await require("./cards").handleCardResponse(envelope); }
155
+ catch (e) { console.error("[cards] handling an answer failed:", e.message); }
156
+ return;
157
+ }
150
158
  const adapter = envelope.adapter;
151
159
  const d = envelope.action?.payload || envelope.action?.buttonId || "";
152
160
  const state = currentState();