@inetafrica/open-claudia 3.0.15 → 3.0.17

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,178 @@
1
+ // Persistent per-task cursor + processed-notification set for the Spaces
2
+ // conversational plane.
3
+ //
4
+ // The adapter's in-memory _seen set only dedups within a single process life.
5
+ // This store survives restarts so a notification a previous process already
6
+ // worked (redelivered on a socket reconnect or a REST catch-up after a bounce)
7
+ // is not re-handled. It also keeps a per-task cursor — where the bot last
8
+ // engaged on each task — for the advisory task loop and on-demand status.
9
+ //
10
+ // Parent-process only. Small, capped, atomic writes.
11
+
12
+ const path = require("path");
13
+ const { CONFIG_DIR } = require("../../core/config");
14
+ const { atomicWriteFileSync, readJsonWithFallback } = require("../../core/fsutil");
15
+
16
+ const STATE_FILE = path.join(CONFIG_DIR, "spaces-state.json");
17
+ const PROCESSED_CAP = 500;
18
+
19
+ let store = null;
20
+
21
+ function load() {
22
+ if (store) return store;
23
+ const raw = readJsonWithFallback(STATE_FILE, null) || {};
24
+ const rawConn = raw.connection && typeof raw.connection === "object" ? raw.connection : {};
25
+ const rawCtrl = raw.control && typeof raw.control === "object" ? raw.control : {};
26
+ store = {
27
+ processed: Array.isArray(raw.processed) ? raw.processed.slice(-PROCESSED_CAP) : [],
28
+ tasks: raw.tasks && typeof raw.tasks === "object" ? raw.tasks : {},
29
+ // Connection signal (hybrid): central authoritatively lists Spaces among the
30
+ // bot's connected apps, OR the socket has actually received a Spaces
31
+ // notification (which only happens for a task the bot is a member of). Either
32
+ // one means "connected" — no env flag required.
33
+ connection: {
34
+ central: !!rawConn.central,
35
+ centralAt: rawConn.centralAt || 0,
36
+ socketFirstAt: rawConn.socketFirstAt || 0,
37
+ socketLastAt: rawConn.socketLastAt || 0,
38
+ },
39
+ // Owner control, set from chat (/spaces …) — never from env/filesystem, which
40
+ // the owner can't reach on a pod. `muted` is the off-switch for auto-replies;
41
+ // `taskLoopMode` overrides the env default posture.
42
+ control: {
43
+ muted: !!rawCtrl.muted,
44
+ taskLoopMode: rawCtrl.taskLoopMode || null,
45
+ },
46
+ };
47
+ return store;
48
+ }
49
+
50
+ function save() {
51
+ if (!store) return;
52
+ try { atomicWriteFileSync(STATE_FILE, JSON.stringify(store, null, 2), { backup: true }); }
53
+ catch (e) { console.error("spaces-state: write failed:", e.message); }
54
+ }
55
+
56
+ // Record a notification id as handled. Returns true if it was NEW (proceed),
57
+ // false if it had already been processed in this or a previous life (skip).
58
+ function markProcessed(notificationId) {
59
+ const id = String(notificationId || "");
60
+ if (!id) return false;
61
+ const s = load();
62
+ if (s.processed.includes(id)) return false;
63
+ s.processed.push(id);
64
+ if (s.processed.length > PROCESSED_CAP) s.processed = s.processed.slice(-PROCESSED_CAP);
65
+ save();
66
+ return true;
67
+ }
68
+
69
+ // Advance a task's cursor. Called for every task notification (awareness too)
70
+ // so the cursor always reflects the last activity we observed on the task.
71
+ function recordCursor(taskId, { notificationId = "", type = "" } = {}) {
72
+ const id = String(taskId || "");
73
+ if (!id) return;
74
+ const s = load();
75
+ const cur = s.tasks[id] || { count: 0 };
76
+ s.tasks[id] = {
77
+ lastNotificationId: String(notificationId || cur.lastNotificationId || ""),
78
+ lastType: String(type || cur.lastType || ""),
79
+ lastAt: new Date().toISOString(),
80
+ count: (cur.count || 0) + 1,
81
+ };
82
+ save();
83
+ }
84
+
85
+ function getCursor(taskId) {
86
+ const s = load();
87
+ return s.tasks[String(taskId || "")] || null;
88
+ }
89
+
90
+ function snapshot() {
91
+ const s = load();
92
+ return { tasks: s.tasks, processedCount: s.processed.length };
93
+ }
94
+
95
+ // --- Connection signal -----------------------------------------------------
96
+
97
+ // Called on every inbound Spaces notification. Receiving one is proof the bot is
98
+ // a member of that task's space, so it flips the socket-connected signal on.
99
+ function markSocketConnected() {
100
+ const s = load();
101
+ const now = Date.now();
102
+ if (!s.connection.socketFirstAt) s.connection.socketFirstAt = now;
103
+ s.connection.socketLastAt = now;
104
+ save();
105
+ }
106
+
107
+ // Called by the connected-apps poller with central's authoritative answer to
108
+ // "is Spaces among my connected apps?". Persisted so a restart keeps the signal
109
+ // until the next poll refreshes it.
110
+ function setCentralConnected(connected) {
111
+ const s = load();
112
+ const val = !!connected;
113
+ if (s.connection.central === val && s.connection.centralAt) return;
114
+ s.connection.central = val;
115
+ s.connection.centralAt = Date.now();
116
+ save();
117
+ }
118
+
119
+ // Hybrid: connected if central says so, or the socket has ever delivered a
120
+ // Spaces notification. Membership is the real-world gate — you can't receive a
121
+ // notification for a space you're not in — so socket-ever-connected is a safe,
122
+ // sticky signal.
123
+ function isConnected() {
124
+ const s = load();
125
+ return !!(s.connection.central || s.connection.socketFirstAt);
126
+ }
127
+
128
+ // --- Owner control (from chat) ---------------------------------------------
129
+
130
+ function setMuted(muted) {
131
+ const s = load();
132
+ s.control.muted = !!muted;
133
+ save();
134
+ return s.control.muted;
135
+ }
136
+
137
+ function setTaskLoopMode(mode) {
138
+ const s = load();
139
+ const m = String(mode || "").toLowerCase();
140
+ s.control.taskLoopMode = ["advisory", "autonomous", "off"].includes(m) ? m : null;
141
+ save();
142
+ return s.control.taskLoopMode;
143
+ }
144
+
145
+ // The task-working posture for engaged Spaces turns: chat override wins, else the
146
+ // env default the caller passes ("advisory" unless overridden).
147
+ function taskLoopMode(envDefault = "advisory") {
148
+ const s = load();
149
+ return s.control.taskLoopMode || String(envDefault || "advisory").toLowerCase();
150
+ }
151
+
152
+ // The single gate the adapter/loopback/access all read: auto-replies are live
153
+ // when the bot is connected to Spaces AND the owner hasn't paused them.
154
+ function conversationalActive() {
155
+ const s = load();
156
+ return isConnected() && !s.control.muted;
157
+ }
158
+
159
+ // For /spaces status.
160
+ function controlSnapshot() {
161
+ const s = load();
162
+ return {
163
+ connected: isConnected(),
164
+ central: s.connection.central,
165
+ socketFirstAt: s.connection.socketFirstAt,
166
+ socketLastAt: s.connection.socketLastAt,
167
+ muted: s.control.muted,
168
+ conversationalActive: conversationalActive(),
169
+ taskLoopMode: taskLoopMode(),
170
+ taskLoopOverride: s.control.taskLoopMode,
171
+ };
172
+ }
173
+
174
+ module.exports = {
175
+ markProcessed, recordCursor, getCursor, snapshot, STATE_FILE,
176
+ markSocketConnected, setCentralConnected, isConnected,
177
+ setMuted, setTaskLoopMode, taskLoopMode, conversationalActive, controlSnapshot,
178
+ };
package/core/access.js CHANGED
@@ -52,6 +52,16 @@ function isChatAuthorized(chatId) {
52
52
  if (matchesTransportOwner()) return true;
53
53
  const id = String(chatId || "");
54
54
  if (!id) return false;
55
+ // Spaces task threads authorize by participation: the bot only receives a
56
+ // Spaces notification for a task it follows/was mentioned on, so the inbound
57
+ // itself is the authorization signal. Effective only while the conversational
58
+ // plane is active (connected to Spaces AND the owner hasn't paused replies).
59
+ try {
60
+ if (currentTransport() === "spaces"
61
+ && require("../channels/spaces/state").conversationalActive()) {
62
+ return true;
63
+ }
64
+ } catch (e) {}
55
65
  if (CHAT_IDS.includes(id)) return true;
56
66
  try {
57
67
  const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
package/core/actions.js CHANGED
@@ -443,6 +443,14 @@ async function handleAction(envelope) {
443
443
  : `Could not deliver the reply.`);
444
444
  }
445
445
 
446
+ // Mirror the owner's decision back into the originating Spaces task thread
447
+ // (non-binding status; the binding decision happened here). Scoped to plain
448
+ // tool runs — external reply/action escalations resume their own turn and
449
+ // report there, so mirroring here would double-post.
450
+ if (rec.kind === "tool" && rec.adapter === "spaces") {
451
+ try { require("../channels/spaces/approval-mirror").mirrorOutcome(rec).catch(() => {}); } catch (e) {}
452
+ }
453
+
446
454
  // If a second press disagreed, rec.status still holds the FIRST decision.
447
455
  const label = `${rec.tool}${rec.verb ? " " + rec.verb : ""}`;
448
456
  // Always-allow on an external ACTION escalation folds the command into the
@@ -12,6 +12,7 @@ const { setAdapters } = require("./scheduler");
12
12
 
13
13
  const { TelegramAdapter } = require("../channels/telegram/adapter");
14
14
  const { KazeeAdapter } = require("../channels/kazee/adapter");
15
+ const { SpacesAdapter } = require("../channels/spaces/adapter");
15
16
  const { VoiceAdapter } = require("../channels/voice/adapter");
16
17
  const { WebAdapter } = require("../channels/web/adapter");
17
18
 
@@ -22,6 +23,7 @@ let actionHandler = null;
22
23
  function createAdapter(spec) {
23
24
  if (spec.type === "telegram") return new TelegramAdapter({ id: spec.id, ...spec.opts });
24
25
  if (spec.type === "kazee") return new KazeeAdapter({ id: spec.id, ...spec.opts });
26
+ if (spec.type === "spaces") return new SpacesAdapter({ id: spec.id, ...spec.opts });
25
27
  if (spec.type === "voice") return new VoiceAdapter({ id: spec.id, ...spec.opts });
26
28
  if (spec.type === "web") return new WebAdapter({ id: spec.id, ...spec.opts });
27
29
  console.error(`Unknown adapter type: ${spec.type}`);
package/core/config.js CHANGED
@@ -193,6 +193,10 @@ const FULL_PATH = [
193
193
  // for the bot token (the actual secret). Override with KAZEE_URL if ever needed.
194
194
  const KAZEE_DEFAULT_URL = "https://chat.inet.africa";
195
195
 
196
+ // Kazee Spaces is fixed infrastructure too. The bot authenticates with its own
197
+ // kzb_ token (KAZEE_BOT_TOKEN) — Spaces accepts it directly, so no new secret.
198
+ const SPACES_DEFAULT_URL = "https://api.spaces.kazee.africa";
199
+
196
200
  // Channels: declarative list of {id, type, opts}. Defaults to telegram for
197
201
  // backwards compat with installs predating CHANNELS.
198
202
  function loadChannels() {
@@ -231,6 +235,32 @@ function loadChannels() {
231
235
  type: "kazee",
232
236
  opts: { url, token, ownerUserId, botUserId },
233
237
  });
238
+ } else if (type === "spaces") {
239
+ // Conversational plane for Kazee Spaces. Bot authenticates with its own
240
+ // kzb_ token (the same KAZEE_BOT_TOKEN the connected-apps poller uses).
241
+ const url = config.SPACES_URL || SPACES_DEFAULT_URL;
242
+ const token = config.KAZEE_BOT_TOKEN;
243
+ if (!token) {
244
+ console.error(`CHANNELS includes ${entry} but KAZEE_BOT_TOKEN is unset — skipping.`);
245
+ continue;
246
+ }
247
+ channels.push({
248
+ id,
249
+ type: "spaces",
250
+ opts: {
251
+ url,
252
+ token,
253
+ ownerUserId: config.SPACES_OWNER_USER_ID || "",
254
+ // Conversational auto-replies are no longer env-gated: the adapter
255
+ // enables them itself once Spaces is CONNECTED (central lists it, or
256
+ // the socket receives a notification) and the owner hasn't paused via
257
+ // /spaces. The socket always starts here for awareness.
258
+ // taskLoopMode is only the DEFAULT posture; /spaces mode overrides it
259
+ // live. "advisory" = read/reason/recommend; "autonomous" = work to
260
+ // completion (writes still hit the owner-approval gate); "off".
261
+ taskLoopMode: String(config.OC_SPACES_TASK_LOOP || process.env.OC_SPACES_TASK_LOOP || "advisory").toLowerCase(),
262
+ },
263
+ });
234
264
  } else if (type === "voice") {
235
265
  const token = config.VOICE_BRIDGE_TOKEN;
236
266
  if (!token) {
@@ -251,6 +281,23 @@ function loadChannels() {
251
281
  console.error(`Unknown channel type: ${type} — skipping.`);
252
282
  }
253
283
  }
284
+ // Auto-include the Spaces channel whenever the bot has its own kzb_ token, even
285
+ // if CHANNELS didn't list it. Spaces is fixed infra on the same token as Kazee
286
+ // chat, and the owner can't edit CHANNELS on a pod — so "have token ⇒ Spaces
287
+ // available" is what makes auto-enable reachable without touching env. It's
288
+ // self-gating: no Spaces membership ⇒ no notifications ⇒ no replies.
289
+ if ((config.KAZEE_BOT_TOKEN) && !channels.some((c) => c.type === "spaces")) {
290
+ channels.push({
291
+ id: "spaces",
292
+ type: "spaces",
293
+ opts: {
294
+ url: config.SPACES_URL || SPACES_DEFAULT_URL,
295
+ token: config.KAZEE_BOT_TOKEN,
296
+ ownerUserId: config.SPACES_OWNER_USER_ID || "",
297
+ taskLoopMode: String(config.OC_SPACES_TASK_LOOP || process.env.OC_SPACES_TASK_LOOP || "advisory").toLowerCase(),
298
+ },
299
+ });
300
+ }
254
301
  return channels;
255
302
  }
256
303
 
@@ -305,6 +352,7 @@ module.exports = {
305
352
  UTILITY_PURPOSE_PROVIDERS,
306
353
  ensureRuntimeDirectories,
307
354
  KAZEE_DEFAULT_URL,
355
+ SPACES_DEFAULT_URL,
308
356
  BOT_DIR,
309
357
  CONFIG_DIR,
310
358
  TOKEN, CHAT_IDS, CHAT_ID,
@@ -0,0 +1,235 @@
1
+ // Connected-apps subsystem — the data/awareness plane.
2
+ //
3
+ // The bot uses its OWN control-plane token (KAZEE_BOT_TOKEN, an opaque kzb_…)
4
+ // to ask inet-central "which apps am I connected to?" (GET
5
+ // /api/principal/applications — the self-service endpoint added alongside this),
6
+ // then, for each connected app that has a registered connector, polls it for
7
+ // awareness (Spaces first: unread notifications + tasks). This runs ONLY in the
8
+ // parent bot process — the token is a CONTROL_PLANE_KEY stripped from every
9
+ // provider/tool subprocess, so a connector can never leak into a coding-agent
10
+ // child.
11
+ //
12
+ // Scalable by construction: adding an app = drop in a connector + list the app
13
+ // in central. No core changes. This module knows nothing app-specific beyond
14
+ // the connectors that register themselves.
15
+ //
16
+ // SAFETY: everything here is READ-ONLY. It discovers and observes; it never
17
+ // writes to central or to any app. New items are recorded in connected-apps.json
18
+ // for on-demand querying — no agent turn is auto-triggered from this plane. The
19
+ // conversational plane (auto-replies) is a separate, opt-in channel adapter.
20
+
21
+ const https = require("https");
22
+ const http = require("http");
23
+ const path = require("path");
24
+ const { URL } = require("url");
25
+ const { config, CONFIG_DIR } = require("./config");
26
+ const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
27
+ const { truthy } = require("../project-transcripts");
28
+
29
+ const STORE_FILE = path.join(CONFIG_DIR, "connected-apps.json");
30
+ const CENTRAL_URL = (config.CENTRAL_URL || "https://central.inet.africa").replace(/\/$/, "");
31
+
32
+ const APPS_REFRESH_MS = 15 * 60 * 1000; // re-ask central which apps we're on
33
+ const POLL_INTERVAL_MS = 90 * 1000; // per-app awareness poll cadence
34
+ const BACKOFF_BASE_MS = 60 * 1000;
35
+ const BACKOFF_MAX_MS = 30 * 60 * 1000;
36
+ const INBOX_CAP = 100;
37
+
38
+ let timer = null;
39
+ let store = null;
40
+ const connectors = new Map(); // appKey -> connector
41
+
42
+ function botToken() {
43
+ return config.KAZEE_BOT_TOKEN || process.env.KAZEE_BOT_TOKEN || "";
44
+ }
45
+
46
+ function loadStore() {
47
+ const raw = readJsonWithFallback(STORE_FILE, null) || {};
48
+ return {
49
+ appsRefreshedAt: raw.appsRefreshedAt || 0,
50
+ apps: Array.isArray(raw.apps) ? raw.apps : [],
51
+ perApp: raw.perApp && typeof raw.perApp === "object" ? raw.perApp : {},
52
+ };
53
+ }
54
+
55
+ function saveStore() {
56
+ try { atomicWriteFileSync(STORE_FILE, JSON.stringify(store, null, 2), { backup: true }); }
57
+ catch (e) { console.error("connected-apps: store write failed:", e.message); }
58
+ }
59
+
60
+ function appState(key) {
61
+ if (!store.perApp[key]) {
62
+ store.perApp[key] = { lastPollAt: 0, status: "idle", backoffUntil: 0, backoffMs: 0, seen: [], inbox: [] };
63
+ }
64
+ return store.perApp[key];
65
+ }
66
+
67
+ function getJson(baseUrl, urlPath, token) {
68
+ return new Promise((resolve, reject) => {
69
+ const u = new URL(baseUrl.replace(/\/$/, "") + urlPath);
70
+ const lib = u.protocol === "https:" ? https : http;
71
+ const req = lib.request(
72
+ {
73
+ method: "GET",
74
+ hostname: u.hostname,
75
+ port: u.port || (u.protocol === "https:" ? 443 : 80),
76
+ path: u.pathname + (u.search || ""),
77
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
78
+ },
79
+ (res) => {
80
+ let chunks = "";
81
+ res.on("data", (c) => { chunks += c; });
82
+ res.on("end", () => {
83
+ if (res.statusCode >= 200 && res.statusCode < 300) {
84
+ try { resolve(chunks ? JSON.parse(chunks) : {}); }
85
+ catch (e) { resolve({}); }
86
+ } else {
87
+ reject(new Error(`GET ${urlPath} → ${res.statusCode}: ${chunks.slice(0, 200)}`));
88
+ }
89
+ });
90
+ },
91
+ );
92
+ req.on("error", reject);
93
+ req.setTimeout(30000, () => req.destroy(new Error(`GET ${urlPath} timeout`)));
94
+ req.end();
95
+ });
96
+ }
97
+
98
+ // Ask central which apps this bot token is a member of. Returns [] on failure
99
+ // (caller keeps the last-known list).
100
+ async function fetchConnectedApps() {
101
+ const token = botToken();
102
+ if (!token) return [];
103
+ const res = await getJson(CENTRAL_URL, "/api/principal/applications", token);
104
+ return Array.isArray(res?.applications) ? res.applications : [];
105
+ }
106
+
107
+ function registerConnector(connector) {
108
+ if (!connector || !connector.appKey) throw new Error("connector needs an appKey");
109
+ connectors.set(connector.appKey, connector);
110
+ }
111
+
112
+ function connectorFor(app) {
113
+ for (const c of connectors.values()) {
114
+ try { if (c.match(app)) return c; } catch (e) {}
115
+ }
116
+ return null;
117
+ }
118
+
119
+ // Record awareness items into the app's capped inbox, deduped by id. Returns
120
+ // the count of genuinely new items.
121
+ function recordItems(state, items) {
122
+ const seen = new Set(state.seen);
123
+ let added = 0;
124
+ for (const it of items) {
125
+ const id = String(it.id || "");
126
+ if (!id || seen.has(id)) continue;
127
+ seen.add(id);
128
+ state.inbox.unshift(it);
129
+ added += 1;
130
+ }
131
+ state.seen = [...seen].slice(-INBOX_CAP * 2);
132
+ state.inbox = state.inbox.slice(0, INBOX_CAP);
133
+ return added;
134
+ }
135
+
136
+ async function pollApp(app) {
137
+ const key = String(app.id || app.name);
138
+ const state = appState(key);
139
+ const now = Date.now();
140
+ if (state.backoffUntil && now < state.backoffUntil) return;
141
+ const connector = connectorFor(app);
142
+ if (!connector) { state.status = "no-connector"; return; }
143
+ try {
144
+ const items = await connector.poll(app, botToken());
145
+ const added = recordItems(state, items || []);
146
+ state.lastPollAt = now;
147
+ state.status = "ok";
148
+ state.backoffMs = 0;
149
+ state.backoffUntil = 0;
150
+ if (added) console.log(`connected-apps: ${connector.appKey} — ${added} new item(s)`);
151
+ } catch (e) {
152
+ state.status = `error: ${e.message}`;
153
+ state.backoffMs = Math.min(state.backoffMs ? state.backoffMs * 2 : BACKOFF_BASE_MS, BACKOFF_MAX_MS);
154
+ state.backoffUntil = now + state.backoffMs;
155
+ console.error(`connected-apps: ${key} poll failed, backing off ${Math.round(state.backoffMs / 1000)}s:`, e.message);
156
+ }
157
+ }
158
+
159
+ async function tick() {
160
+ const now = Date.now();
161
+ if (now - store.appsRefreshedAt > APPS_REFRESH_MS) {
162
+ try {
163
+ const apps = await fetchConnectedApps();
164
+ if (apps.length || store.apps.length === 0) {
165
+ store.apps = apps;
166
+ store.appsRefreshedAt = now;
167
+ }
168
+ } catch (e) {
169
+ console.error("connected-apps: apps refresh failed:", e.message);
170
+ }
171
+ }
172
+ // Central's authoritative half of the "connected ⇒ conversational" signal:
173
+ // tell the Spaces conversational plane whether central lists Spaces among this
174
+ // bot's apps. The socket half is set independently when a notification lands,
175
+ // so auto-replies work even before this endpoint is deployed.
176
+ try {
177
+ const spacesConnected = store.apps.some((a) => {
178
+ const c = connectorFor(a);
179
+ return c && c.appKey === "spaces";
180
+ });
181
+ require("../channels/spaces/state").setCentralConnected(spacesConnected);
182
+ } catch (e) {}
183
+
184
+ for (const app of store.apps) {
185
+ if (!connectorFor(app)) continue;
186
+ await pollApp(app);
187
+ }
188
+ saveStore();
189
+ }
190
+
191
+ // Public: current awareness snapshot (for an on-demand "what's in my inbox").
192
+ function snapshot() {
193
+ if (!store) store = loadStore();
194
+ return {
195
+ apps: store.apps.map((a) => ({ id: a.id, name: a.name })),
196
+ perApp: Object.fromEntries(
197
+ Object.entries(store.perApp).map(([k, v]) => [k, {
198
+ status: v.status,
199
+ lastPollAt: v.lastPollAt,
200
+ unread: v.inbox.length,
201
+ inbox: v.inbox.slice(0, 20),
202
+ }]),
203
+ ),
204
+ };
205
+ }
206
+
207
+ function initConnectedApps() {
208
+ if (!truthy(config.OC_CONNECTED_APPS || process.env.OC_CONNECTED_APPS, false)) {
209
+ return { started: false, reason: "OC_CONNECTED_APPS disabled" };
210
+ }
211
+ if (!botToken()) {
212
+ return { started: false, reason: "KAZEE_BOT_TOKEN unset" };
213
+ }
214
+ store = loadStore();
215
+ // Register built-in connectors.
216
+ try { registerConnector(require("./connectors/spaces")); }
217
+ catch (e) { console.error("connected-apps: spaces connector load failed:", e.message); }
218
+ // First tick shortly after boot, then on the poll cadence.
219
+ setTimeout(() => { tick().catch((e) => console.error("connected-apps tick:", e.message)); }, 20000);
220
+ timer = setInterval(() => { tick().catch((e) => console.error("connected-apps tick:", e.message)); }, POLL_INTERVAL_MS);
221
+ return { started: true, connectors: [...connectors.keys()] };
222
+ }
223
+
224
+ function stopConnectedApps() {
225
+ if (timer) { clearInterval(timer); timer = null; }
226
+ }
227
+
228
+ module.exports = {
229
+ initConnectedApps,
230
+ stopConnectedApps,
231
+ registerConnector,
232
+ fetchConnectedApps,
233
+ snapshot,
234
+ STORE_FILE,
235
+ };
@@ -0,0 +1,41 @@
1
+ // Spaces awareness connector. Registered with the connected-apps poller; polls
2
+ // the bot's own unread Spaces notifications (read-only) and returns them as
3
+ // normalised awareness items. This is the data plane only — it observes and
4
+ // records; it never replies. Replies are the separate conversational channel
5
+ // adapter (channels/spaces/adapter.js).
6
+
7
+ const { SpacesClient, contentText } = require("../../channels/spaces/client");
8
+ const { config } = require("../config");
9
+
10
+ const SPACES_URL = (config.SPACES_URL || "https://api.spaces.kazee.africa").replace(/\/$/, "");
11
+
12
+ module.exports = {
13
+ appKey: "spaces",
14
+
15
+ // Central lists the app by name/domain. Match either.
16
+ match(app) {
17
+ const hay = `${app?.name || ""} ${app?.domainName || ""}`.toLowerCase();
18
+ return hay.includes("spaces");
19
+ },
20
+
21
+ async poll(_app, token) {
22
+ const client = new SpacesClient({ baseUrl: SPACES_URL, token });
23
+ const notifications = await client.listNotifications({ limit: 40, unreadOnly: true });
24
+ return notifications.map((n) => {
25
+ const inner = (n.data && n.data.payload) || {};
26
+ const actor = n.actorId || {};
27
+ const actorName = typeof actor === "object"
28
+ ? (`${actor.firstName || ""} ${actor.lastName || ""}`.trim() || actor.email || "")
29
+ : String(actor);
30
+ return {
31
+ id: String(n.id || n._id || ""),
32
+ type: String(n.category || n.type || "").toLowerCase(),
33
+ actorName,
34
+ taskId: String(n.sourceEntityId || inner.taskId || inner.targetId || ""),
35
+ taskTitle: inner.taskTitle || inner.targetTitle || n.title || "",
36
+ text: contentText(n.content || n.title || "").slice(0, 300),
37
+ createdAt: n.createdAt || null,
38
+ };
39
+ });
40
+ },
41
+ };
package/core/handlers.js CHANGED
@@ -793,6 +793,55 @@ register({
793
793
  },
794
794
  });
795
795
 
796
+ register({
797
+ name: "spaces", description: "Control Kazee Spaces auto-replies (status/pause/resume/mode)",
798
+ args: "<status | pause | resume | mode advisory|autonomous|off>", ownerOnly: true,
799
+ handler: async (env, { tail }) => {
800
+ if (!ownerEnv(env)) return;
801
+ let spacesState;
802
+ try { spacesState = require("../channels/spaces/state"); }
803
+ catch (e) { return send("Spaces state unavailable — the Spaces channel isn't loaded."); }
804
+ const parts = (tail || "").trim().split(/\s+/).filter(Boolean);
805
+ const op = (parts[0] || "status").toLowerCase();
806
+
807
+ const fmtTime = (ms) => (ms ? new Date(ms).toISOString().replace("T", " ").slice(0, 16) + "Z" : "never");
808
+ const statusText = () => {
809
+ const s = spacesState.controlSnapshot();
810
+ const src = s.central ? "central" : (s.socketFirstAt ? "socket" : "—");
811
+ const replies = s.conversationalActive ? "ON" : (s.connected ? "paused" : "off (not connected)");
812
+ const modeLine = s.taskLoopOverride
813
+ ? `${s.taskLoopMode} (override)`
814
+ : `${s.taskLoopMode} (default)`;
815
+ return [
816
+ "<b>Kazee Spaces</b>",
817
+ `Connected: ${s.connected ? "yes" : "no"}${s.connected ? ` (via ${src})` : ""}`,
818
+ `Auto-replies: ${replies}`,
819
+ `Task mode: ${modeLine}`,
820
+ `Last notification: ${fmtTime(s.socketLastAt)}`,
821
+ ].join("\n");
822
+ };
823
+
824
+ if (op === "status") return send(statusText());
825
+ if (op === "pause") {
826
+ spacesState.setMuted(true);
827
+ return send("Spaces auto-replies <b>paused</b>. Mentions/replies still hit the radar; I just won't answer until you /spaces resume.\n\n" + statusText());
828
+ }
829
+ if (op === "resume") {
830
+ spacesState.setMuted(false);
831
+ return send("Spaces auto-replies <b>resumed</b>.\n\n" + statusText());
832
+ }
833
+ if (op === "mode") {
834
+ const m = (parts[1] || "").toLowerCase();
835
+ if (!["advisory", "autonomous", "off"].includes(m)) {
836
+ return send("Usage: /spaces mode <advisory|autonomous|off>\n\n• advisory — read, reason, recommend (no autonomous write chains)\n• autonomous — work to completion (writes still need your approval)\n• off — no task-mode framing");
837
+ }
838
+ spacesState.setTaskLoopMode(m);
839
+ return send(`Spaces task mode set to <b>${m}</b>.\n\n` + statusText());
840
+ }
841
+ return send("Usage: /spaces <status | pause | resume | mode advisory|autonomous|off>");
842
+ },
843
+ });
844
+
796
845
  register({
797
846
  name: "cluster", description: "Control this bot's own deployment (status/logs/restart/start/stop/scale/sync)",
798
847
  args: "<status|logs|restart|start|stop|scale 0|1|sync>", ownerOnly: true,