@inetafrica/open-claudia 3.0.15 → 3.0.16

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.16 — Spaces task loop + approval thread-mirror (dormant)
4
+
5
+ - **Kazee Spaces connected-apps gains its two remaining pieces, both shipped OFF.** The task-working loop runs in **advisory** mode by default: when the bot engages a Spaces task thread, its turns for that task are serialized (one at a time, while different tasks proceed in parallel), a persistent cursor + cross-restart dedup lives in `spaces-state.json`, and a new `OC_SPACES_TASK_LOOP` mode (`advisory` | `autonomous` | `off`) selects the framing. The **approval thread-mirror** routes any write/destructive action from a Spaces-origin turn to the owner's Telegram Approve/Deny button (fail-closed if no reachable owner channel — the run stays blocked rather than posting a dead button into the thread) and mirrors a non-binding status comment into the task thread on request and on outcome.
6
+ - **No behaviour change anywhere until you opt in.** All of it stays dormant behind the existing kill-switches (`OC_CONNECTED_APPS`, `OC_SPACES_CONVERSATIONAL`, plus the new `OC_SPACES_TASK_LOOP` which is advisory-but-inert while the others are off) and the bot still needs Spaces app-membership to receive anything. Telegram and Kazee flows are byte-identical.
7
+ - **Agent-space / OpenClaw:** no new deps, no new env required, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade.
8
+
3
9
  ## v3.0.15 — the owner keeps their memory on Kazee
4
10
 
5
11
  - **Episodic recall no longer goes dark on Kazee / group chats.** Cross-conversation episodic memory (relevant snippets from the owner's past transcripts) is deliberately owner-only. The gate that decides "is this speaker the owner?" resolved the person by `findByHandle(adapter, channelId)` — but on Kazee the `channelId` is the *chat/room id*, while the owner's stored handle is their Kazee *user id*, so the lookup missed and fail-closed suppressed episodes. The owner's canonical brain, settings, and session were shared via identity unification, but this one recall layer silently went quiet — which read as "less context on Kazee." The gate now resolves the speaker by the envelope's **canonical user id** first (derived from the authenticated sender and unified across a person's linked channels), falling back to the channelId handle lookup when no canonical is in context. Fail-closed semantics for non-owners are preserved: a non-owner's canonical never matches an owner record. Regression pinned in `test-recall-relationship-gate.js` (owner recognised via canonical on a room-id channel; external still blocked).
package/bot.js CHANGED
@@ -270,6 +270,18 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
270
270
  try { require("./core/dream").initDream(adapters); }
271
271
  catch (e) { console.error("dream init failed:", e.message); }
272
272
 
273
+ // Connected-apps awareness plane (read-only, parent-process only, flag-gated
274
+ // OFF by default). Discovers which apps this bot is connected to via central
275
+ // and polls them (Spaces first). Never auto-triggers a turn.
276
+ try {
277
+ const ca = require("./core/connected-apps").initConnectedApps();
278
+ console.log(ca.started
279
+ ? `Connected-apps: ON (connectors: ${ca.connectors.join(", ")})`
280
+ : `Connected-apps: off (${ca.reason})`);
281
+ } catch (e) {
282
+ console.error("connected-apps init failed:", e.message);
283
+ }
284
+
273
285
  try {
274
286
  const lb = await loopback.start(registry);
275
287
  console.log(`Loopback send endpoint: ${lb.url}`);
@@ -0,0 +1,224 @@
1
+ // SpacesAdapter — the conversational plane for Kazee Spaces. Duck-types the
2
+ // same ChannelAdapter contract as Telegram/Kazee so a Spaces task thread flows
3
+ // through the normal router → agent turn, inheriting packs, tools, entity
4
+ // memory and the approval policy for free. No core changes needed to make a
5
+ // Spaces comment "think" — it's just another channel.
6
+ //
7
+ // Event source: the per-user "notification" socket event (instant), backed by a
8
+ // REST poll() the connected-apps poller can call as a catch-up. Each Spaces
9
+ // TASK maps to one conversation channel ("task:<taskId>") so a task keeps its
10
+ // own session/state. The commenter (actorId) is the person; the task is the
11
+ // thread — the same split every other adapter uses.
12
+ //
13
+ // Urgency policy (conservative, per the approved design): mentions, replies and
14
+ // assignments ENGAGE (emit a turn); plain comment_added / task_updated /
15
+ // reaction are awareness-only — they update the cursor and are queryable, but
16
+ // do not auto-trigger a reply. This is the "untagged comments hit the radar
17
+ // without auto-answering" rule.
18
+
19
+ const { canonicalForChannel } = require("../../core/identity");
20
+ const { SpacesClient, contentText } = require("./client");
21
+ const { createSpacesSocket } = require("./socket");
22
+ const spacesState = require("./state");
23
+
24
+ // Notification `type`/`category` values that warrant a full agent turn.
25
+ const ENGAGE_TYPES = new Set(["mention", "reply", "task_assigned"]);
26
+
27
+ class SpacesAdapter {
28
+ constructor({ id = "spaces", url = "https://api.spaces.kazee.africa", token, ownerUserId = "", conversational = true, taskLoopMode = "advisory" }) {
29
+ this.id = id;
30
+ this.type = "spaces";
31
+ this.url = url.replace(/\/$/, "");
32
+ this.token = token;
33
+ this.ownerUserId = ownerUserId || "";
34
+ this.conversational = conversational;
35
+ this.taskLoopMode = taskLoopMode || "advisory";
36
+ this.client = new SpacesClient({ baseUrl: this.url, token });
37
+ this._listeners = { message: new Set(), action: new Set() };
38
+ this._socket = null;
39
+ this._seen = new Set(); // notification-id dedup across socket + poll
40
+ this._taskChains = new Map(); // taskId -> tail promise, for per-task serialization
41
+ }
42
+
43
+ on(event, fn) {
44
+ if (!this._listeners[event]) return () => {};
45
+ this._listeners[event].add(fn);
46
+ return () => this._listeners[event].delete(fn);
47
+ }
48
+
49
+ _emit(event, envelope) {
50
+ for (const fn of this._listeners[event] || []) {
51
+ try { Promise.resolve(fn(envelope)).catch((e) => console.error(`spaces ${event} handler:`, e.message)); }
52
+ catch (e) { console.error(`spaces ${event} handler:`, e.message); }
53
+ }
54
+ }
55
+
56
+ async start() {
57
+ this._socket = createSpacesSocket({
58
+ url: this.url,
59
+ token: this.token,
60
+ onNotification: (payload) => this._handleNotification(payload, "socket"),
61
+ onConnect: () => console.log(`Spaces socket connected (${this.url})`),
62
+ onDisconnect: (reason) => console.log(`Spaces socket disconnected: ${reason}`),
63
+ onError: (err) => console.error("Spaces socket error:", err.message),
64
+ });
65
+ }
66
+
67
+ async stop() {
68
+ try { this._socket?.disconnect?.(); } catch (e) {}
69
+ this._socket = null;
70
+ }
71
+
72
+ // REST catch-up. The connected-apps poller calls this on its cadence so a
73
+ // missed socket push (reconnect gap) still surfaces. Read-only.
74
+ async poll() {
75
+ try {
76
+ const notifications = await this.client.listNotifications({ limit: 40, unreadOnly: true });
77
+ // Oldest-first so a burst is delivered in order.
78
+ for (const n of notifications.reverse()) this._handleNotification(n, "poll");
79
+ } catch (e) {
80
+ console.error("Spaces poll error:", e.message);
81
+ }
82
+ }
83
+
84
+ _notificationId(payload) {
85
+ return String(payload?.id || payload?._id || "");
86
+ }
87
+
88
+ // Notifications arrive in two shapes: the socket payload
89
+ // (broadcastNotification: flat {type,targetType,targetId,actorId,actorName})
90
+ // and the REST row ({category, actorId:{...}, sourceEntityId, data.payload}).
91
+ // Normalise both to one internal view.
92
+ _normalize(payload) {
93
+ const type = String(payload.type || payload.category || "").toLowerCase();
94
+ const data = payload.data || {};
95
+ const inner = data.payload || {};
96
+ const taskId = String(
97
+ (payload.targetType === "task" && payload.targetId)
98
+ || data.taskId
99
+ || inner.taskId
100
+ || payload.sourceEntityId
101
+ || inner.targetId
102
+ || payload.targetId
103
+ || "",
104
+ );
105
+ let actorId = payload.actorId;
106
+ let actorName = payload.actorName;
107
+ if (actorId && typeof actorId === "object") {
108
+ actorName = actorName || `${actorId.firstName || ""} ${actorId.lastName || ""}`.trim() || actorId.email;
109
+ actorId = actorId.id || actorId._id;
110
+ }
111
+ return {
112
+ type,
113
+ taskId,
114
+ actorId: actorId ? String(actorId) : "",
115
+ actorName: actorName || "",
116
+ spaceId: payload.spaceId || inner.spaceId || null,
117
+ title: payload.title || inner.taskTitle || inner.targetTitle || "",
118
+ content: contentText(payload.content || payload.title || ""),
119
+ deepLink: payload.deepLink || null,
120
+ };
121
+ }
122
+
123
+ _handleNotification(payload, source) {
124
+ const id = this._notificationId(payload);
125
+ if (!id || this._seen.has(id)) return;
126
+ this._seen.add(id);
127
+ if (this._seen.size > 500) {
128
+ const arr = [...this._seen];
129
+ this._seen.clear();
130
+ arr.slice(-250).forEach((k) => this._seen.add(k));
131
+ }
132
+
133
+ // Persistent dedup across restarts: a socket reconnect or a REST catch-up
134
+ // can redeliver a notification a previous process already handled.
135
+ try { if (!spacesState.markProcessed(id)) return; } catch (e) {}
136
+
137
+ const n = this._normalize(payload);
138
+ if (!n.taskId) return;
139
+
140
+ // Advance this task's cursor (awareness events included) so it always
141
+ // reflects the last activity we observed on the task.
142
+ try { spacesState.recordCursor(n.taskId, { notificationId: id, type: n.type }); } catch (e) {}
143
+
144
+ // Awareness-only types never auto-trigger a turn. They still fire an
145
+ // "awareness" listener (if any) so the poller can track/surface them.
146
+ const engage = this.conversational && ENGAGE_TYPES.has(n.type);
147
+ this._emit("awareness", { adapter: this, notificationId: id, source, ...n });
148
+ if (!engage) return;
149
+
150
+ const channelId = `task:${n.taskId}`;
151
+ const envelope = {
152
+ adapter: this,
153
+ channelId,
154
+ canonicalUserId: canonicalForChannel("spaces", n.actorId || n.taskId),
155
+ userId: n.actorId || n.taskId,
156
+ type: "text",
157
+ text: n.content || `[${n.type} on task ${n.title || n.taskId}]`,
158
+ messageId: id,
159
+ from: { id: n.actorId, name: n.actorName },
160
+ spaces: { taskId: n.taskId, spaceId: n.spaceId, notificationType: n.type, deepLink: n.deepLink, title: n.title, mode: this.taskLoopMode },
161
+ raw: payload,
162
+ };
163
+ // Per-task serialization: a task's turns run one at a time so two comments
164
+ // on the same task can't spawn concurrent, conflicting agent runs. Other
165
+ // tasks proceed in parallel.
166
+ this._deliverSerialized(n.taskId, envelope);
167
+ }
168
+
169
+ // Chain each task's message delivery onto that task's tail promise, so the
170
+ // next queued event for the same task waits for the current turn to finish.
171
+ _deliverSerialized(taskId, envelope) {
172
+ const key = String(taskId);
173
+ const prev = this._taskChains.get(key) || Promise.resolve();
174
+ const next = prev
175
+ .then(() => this._deliverMessage(envelope))
176
+ .catch((e) => console.error("spaces serialized delivery:", e.message));
177
+ this._taskChains.set(key, next);
178
+ next.finally(() => { if (this._taskChains.get(key) === next) this._taskChains.delete(key); });
179
+ }
180
+
181
+ // Await every registered message listener (the router turn resolves when the
182
+ // agent run completes) so the per-task chain doesn't advance early.
183
+ async _deliverMessage(envelope) {
184
+ for (const fn of [...(this._listeners.message || [])]) {
185
+ try { await fn(envelope); }
186
+ catch (e) { console.error("spaces message handler:", e.message); }
187
+ }
188
+ }
189
+
190
+ // send() posts a comment back to the task thread. channelId is
191
+ // "task:<taskId>"; opts.mentions is an array of {userId,label}; opts.replyTo
192
+ // threads the reply under a comment id (parentId).
193
+ async send(channelId, text, opts = {}) {
194
+ const taskId = String(channelId || "").replace(/^task:/, "");
195
+ if (!taskId) { console.error("Spaces send: no taskId in channelId", channelId); return null; }
196
+ try {
197
+ const comment = await this.client.createComment({
198
+ taskId,
199
+ text,
200
+ mentions: opts.mentions || [],
201
+ parentId: opts.replyTo ? String(opts.replyTo).replace(/^comment:/, "") : null,
202
+ });
203
+ return comment && (comment._id || comment.id) ? String(comment._id || comment.id) : true;
204
+ } catch (e) {
205
+ console.error("Spaces send error:", e.message);
206
+ return null;
207
+ }
208
+ }
209
+
210
+ // Spaces has no per-thread bot typing indicator, and comment edit/delete are
211
+ // out of scope for v1. These keep the adapter contract total so core code can
212
+ // call them unconditionally.
213
+ async edit() { /* not supported */ }
214
+ async delete() { /* not supported */ }
215
+ async typing() { /* no-op */ }
216
+ async typingStop() { /* no-op */ }
217
+ async sendVoice() { return false; }
218
+ async sendPhoto() { return false; }
219
+ async sendFile() { return false; }
220
+ async downloadMedia() { return null; }
221
+ async registerCommands() { /* Spaces has no slash-command registry */ }
222
+ }
223
+
224
+ module.exports = { SpacesAdapter, ENGAGE_TYPES };
@@ -0,0 +1,52 @@
1
+ // Mirror owner-facing approval activity into the originating Spaces task thread.
2
+ //
3
+ // The BINDING decision always happens on the owner's real button channel
4
+ // (Telegram): Spaces has no inline-button UI, and a task thread can be watched
5
+ // by non-owners, so an external must never hold the Approve button. This posts
6
+ // a NON-binding status comment into the thread so it shows that the bot paused
7
+ // for owner approval and what the owner decided. Best-effort and fully scoped —
8
+ // a no-op unless the approval record's origin is a Spaces task thread.
9
+
10
+ function isSpacesOrigin(rec) {
11
+ return !!rec
12
+ && String(rec.adapter) === "spaces"
13
+ && String(rec.channelId || "").startsWith("task:");
14
+ }
15
+
16
+ function spacesAdapter() {
17
+ try {
18
+ const registry = require("../../core/adapter-registry");
19
+ return registry.findAdapter("spaces")
20
+ || registry.getAdapters().find((a) => a.type === "spaces")
21
+ || null;
22
+ } catch (e) { return null; }
23
+ }
24
+
25
+ async function postComment(rec, text) {
26
+ const adapter = spacesAdapter();
27
+ if (!adapter) return false;
28
+ try { await adapter.send(rec.channelId, text); return true; }
29
+ catch (e) { console.error("spaces approval-mirror:", e.message); return false; }
30
+ }
31
+
32
+ async function mirrorRequest(rec) {
33
+ if (!isSpacesOrigin(rec)) return;
34
+ const cmd = String(rec.command || "").slice(0, 300);
35
+ await postComment(rec, [
36
+ `I need to run a ${rec.tier || "sensitive"} action for this, so I've asked my owner to approve it first:`,
37
+ "",
38
+ cmd,
39
+ "",
40
+ "I'll update this thread once they decide.",
41
+ ].join("\n"));
42
+ }
43
+
44
+ async function mirrorOutcome(rec) {
45
+ if (!isSpacesOrigin(rec)) return;
46
+ const label = `${rec.tool || "the action"}${rec.verb ? " " + rec.verb : ""}`;
47
+ await postComment(rec, rec.status === "approved"
48
+ ? `My owner approved it — proceeding with ${label}.`
49
+ : `My owner declined, so I won't run ${label}. Let me know how else I can help.`);
50
+ }
51
+
52
+ module.exports = { isSpacesOrigin, mirrorRequest, mirrorOutcome };
@@ -0,0 +1,183 @@
1
+ // Spaces REST client. Thin, dependency-free HTTP over the bot's own opaque
2
+ // token (kzb_…). Endpoints + payload shapes are the ones the `spaces` tool
3
+ // reverse-engineered and has been driving in production — kept in lockstep so
4
+ // this adapter and that tool never diverge. The only difference is auth: the
5
+ // tool logs a human in for a bearer; here the bot presents its own kzb_ token
6
+ // directly (Spaces accepts it — verified via central /api/principal/verify).
7
+ //
8
+ // Everything here is READ-ONLY except createComment (the conversational reply)
9
+ // — the data/awareness plane never mutates Spaces state, so the poller carries
10
+ // no write risk. Notification "seen" tracking is a local cursor, not a
11
+ // server-side mark-read, precisely to keep that plane read-only.
12
+
13
+ const https = require("https");
14
+ const http = require("http");
15
+ const { URL } = require("url");
16
+
17
+ function request(baseUrl, token, method, urlPath, body, timeoutMs = 30000) {
18
+ return new Promise((resolve, reject) => {
19
+ const u = new URL(baseUrl.replace(/\/$/, "") + urlPath);
20
+ const lib = u.protocol === "https:" ? https : http;
21
+ const data = body != null ? Buffer.from(JSON.stringify(body)) : null;
22
+ const headers = {
23
+ Authorization: `Bearer ${token}`,
24
+ Accept: "application/json",
25
+ };
26
+ if (data) {
27
+ headers["Content-Type"] = "application/json";
28
+ headers["Content-Length"] = data.length;
29
+ }
30
+ const req = lib.request(
31
+ {
32
+ method,
33
+ hostname: u.hostname,
34
+ port: u.port || (u.protocol === "https:" ? 443 : 80),
35
+ path: u.pathname + (u.search || ""),
36
+ headers,
37
+ },
38
+ (res) => {
39
+ let chunks = "";
40
+ res.on("data", (c) => { chunks += c; });
41
+ res.on("end", () => {
42
+ if (res.statusCode >= 200 && res.statusCode < 300) {
43
+ try { resolve(chunks ? JSON.parse(chunks) : {}); }
44
+ catch (e) { resolve({}); }
45
+ } else {
46
+ reject(new Error(`Spaces ${method} ${urlPath} → ${res.statusCode}: ${chunks.slice(0, 300)}`));
47
+ }
48
+ });
49
+ },
50
+ );
51
+ req.on("error", reject);
52
+ req.setTimeout(timeoutMs, () => req.destroy(new Error(`Spaces ${method} ${urlPath} timeout`)));
53
+ if (data) req.write(data);
54
+ req.end();
55
+ });
56
+ }
57
+
58
+ // Spaces responses are inconsistent: sometimes a bare array, sometimes wrapped
59
+ // in {data|tasks|comments|notifications}. Normalise to an array once here so
60
+ // callers never re-implement the guard.
61
+ function asArray(res, ...keys) {
62
+ if (Array.isArray(res)) return res;
63
+ for (const k of keys) {
64
+ if (res && Array.isArray(res[k])) return res[k];
65
+ }
66
+ return (res && (res.data || res.items)) || [];
67
+ }
68
+
69
+ // Flatten a ProseMirror/TipTap doc (or a JSON string of one, or plain text)
70
+ // down to readable plain text. Mirrors the tool's _flatten/_content_text so a
71
+ // comment body renders the same whether the tool or the adapter reads it.
72
+ function flattenContent(node) {
73
+ if (node == null) return "";
74
+ if (typeof node === "string") {
75
+ const trimmed = node.trim();
76
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
77
+ try { return flattenContent(JSON.parse(trimmed)); }
78
+ catch (e) { return node; }
79
+ }
80
+ return node;
81
+ }
82
+ if (Array.isArray(node)) return node.map(flattenContent).join("");
83
+ if (typeof node !== "object") return String(node);
84
+ const t = node.type;
85
+ if (t === "text") return node.text || "";
86
+ if (t === "mention") return `@${node.attrs?.label || node.attrs?.id || ""}`;
87
+ if (t === "hardBreak") return "\n";
88
+ const inner = (node.content || []).map(flattenContent).join("");
89
+ if (["paragraph", "heading", "listItem", "blockquote"].includes(t)) return inner + "\n";
90
+ return inner;
91
+ }
92
+
93
+ function contentText(raw) {
94
+ return flattenContent(raw).trim();
95
+ }
96
+
97
+ // Build the stringified ProseMirror doc for an outgoing comment. Optional
98
+ // leading @mention chips (each {userId,label}) drive Spaces' notification
99
+ // fan-out; the tool proved this exact node shape renders as tag chips.
100
+ function buildCommentContent(text, mentions = []) {
101
+ const inline = [];
102
+ for (const m of mentions) {
103
+ inline.push({ type: "mention", attrs: { id: m.userId, label: m.label, mentionSuggestionChar: "@" } });
104
+ inline.push({ type: "text", text: " " });
105
+ }
106
+ if (text) inline.push({ type: "text", text });
107
+ return JSON.stringify({ type: "doc", content: [{ type: "paragraph", content: inline }] });
108
+ }
109
+
110
+ class SpacesClient {
111
+ constructor({ baseUrl = "https://api.spaces.kazee.africa", token }) {
112
+ this.baseUrl = baseUrl.replace(/\/$/, "");
113
+ this.token = token;
114
+ }
115
+
116
+ _get(urlPath, timeoutMs) { return request(this.baseUrl, this.token, "GET", urlPath, null, timeoutMs); }
117
+ _post(urlPath, body, timeoutMs) { return request(this.baseUrl, this.token, "POST", urlPath, body, timeoutMs); }
118
+
119
+ async listTasks() {
120
+ const res = await this._get("/api/tasks");
121
+ return asArray(res, "tasks");
122
+ }
123
+
124
+ async getTask(taskId) {
125
+ const res = await this._get(`/api/tasks/${encodeURIComponent(taskId)}`);
126
+ return (res && (res.data || res.task)) || res || null;
127
+ }
128
+
129
+ async listComments(taskId, limit = 100) {
130
+ const res = await this._get(`/api/comments/task/${encodeURIComponent(taskId)}?limit=${limit}`);
131
+ return asArray(res, "comments");
132
+ }
133
+
134
+ async unreadCount() {
135
+ const res = await this._get("/api/notifications/unread-count");
136
+ return typeof res?.count === "number" ? res.count : 0;
137
+ }
138
+
139
+ // Read-only notification fetch. Walks pages, dedupes by id — the Spaces API
140
+ // sometimes ignores the page param (the tool hit this too), so dedup is not
141
+ // optional. `unreadOnly` maps to the server-side isRead=false filter.
142
+ async listNotifications({ limit = 40, unreadOnly = true } = {}) {
143
+ const out = [];
144
+ const seen = new Set();
145
+ let page = 1;
146
+ while (out.length < limit && page <= 20) {
147
+ const params = new URLSearchParams({
148
+ limit: String(Math.min(limit, 100)),
149
+ page: String(page),
150
+ offset: String(out.length),
151
+ });
152
+ if (unreadOnly) params.set("isRead", "false");
153
+ const res = await this._get(`/api/notifications?${params.toString()}`);
154
+ const batch = asArray(res, "notifications");
155
+ const fresh = batch.filter((n) => {
156
+ const id = n.id || n._id;
157
+ return id && !seen.has(String(id));
158
+ });
159
+ if (!fresh.length) break;
160
+ for (const n of fresh) seen.add(String(n.id || n._id));
161
+ out.push(...fresh);
162
+ page += 1;
163
+ }
164
+ return out.slice(0, limit);
165
+ }
166
+
167
+ // The one write: post a comment back to a task thread. `mentions` is an
168
+ // array of {userId,label}; `parentId` threads the reply under a comment.
169
+ async createComment({ taskId, text, mentions = [], parentId = null, attachments = [] }) {
170
+ const payload = {
171
+ targetType: "task",
172
+ targetId: taskId,
173
+ content: buildCommentContent(text, mentions),
174
+ attachments: JSON.stringify(attachments),
175
+ mentions: mentions.map((m) => m.userId),
176
+ };
177
+ if (parentId) payload.parentId = parentId;
178
+ const res = await this._post("/api/comments", payload);
179
+ return (res && (res.comment || res.data)) || res || null;
180
+ }
181
+ }
182
+
183
+ module.exports = { SpacesClient, flattenContent, contentText, buildCommentContent, asArray };
@@ -0,0 +1,40 @@
1
+ // Thin wrapper around socket.io-client for Spaces real-time delivery. The
2
+ // Spaces socket auth chain reads the token from handshake.auth.token first
3
+ // (verified in @libraries/inet-central-auth SocketAuthMiddleware), so the bot's
4
+ // kzb_ token goes straight into `auth`, exactly like the Kazee adapter's socket.
5
+ //
6
+ // The one event we care about is the per-user "notification": the server pushes
7
+ // it directly to the recipient's socket (emitToUser → io.to(socketId)), so a
8
+ // Bearer-authed bot receives its own task notifications with no room join.
9
+ // Payload shape (broadcastHandlers.broadcastNotification):
10
+ // { id, type, title, content, targetType, targetId, spaceId,
11
+ // actorId, actorName, deepLink, data, timestamp, read }
12
+
13
+ let io;
14
+ try { io = require("socket.io-client").io; }
15
+ catch (e) { io = null; }
16
+
17
+ function createSpacesSocket({ url, token, onNotification, onConnect, onDisconnect, onError }) {
18
+ if (!io) {
19
+ throw new Error("socket.io-client is not installed. Run `npm install` to install dependencies.");
20
+ }
21
+ const socket = io(url, {
22
+ transports: ["websocket"],
23
+ auth: { token },
24
+ reconnection: true,
25
+ reconnectionDelay: 2000,
26
+ reconnectionDelayMax: 30000,
27
+ });
28
+ socket.on("connect", () => { if (onConnect) onConnect(socket); });
29
+ socket.on("disconnect", (reason) => { if (onDisconnect) onDisconnect(reason); });
30
+ socket.on("connect_error", (err) => { if (onError) onError(err); });
31
+ socket.on("notification", (payload) => {
32
+ if (onNotification) {
33
+ try { Promise.resolve(onNotification(payload)).catch((e) => console.error("spaces notification handler:", e.message)); }
34
+ catch (e) { console.error("spaces notification handler:", e.message); }
35
+ }
36
+ });
37
+ return socket;
38
+ }
39
+
40
+ module.exports = { createSpacesSocket };
@@ -0,0 +1,76 @@
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
+ store = {
25
+ processed: Array.isArray(raw.processed) ? raw.processed.slice(-PROCESSED_CAP) : [],
26
+ tasks: raw.tasks && typeof raw.tasks === "object" ? raw.tasks : {},
27
+ };
28
+ return store;
29
+ }
30
+
31
+ function save() {
32
+ if (!store) return;
33
+ try { atomicWriteFileSync(STATE_FILE, JSON.stringify(store, null, 2), { backup: true }); }
34
+ catch (e) { console.error("spaces-state: write failed:", e.message); }
35
+ }
36
+
37
+ // Record a notification id as handled. Returns true if it was NEW (proceed),
38
+ // false if it had already been processed in this or a previous life (skip).
39
+ function markProcessed(notificationId) {
40
+ const id = String(notificationId || "");
41
+ if (!id) return false;
42
+ const s = load();
43
+ if (s.processed.includes(id)) return false;
44
+ s.processed.push(id);
45
+ if (s.processed.length > PROCESSED_CAP) s.processed = s.processed.slice(-PROCESSED_CAP);
46
+ save();
47
+ return true;
48
+ }
49
+
50
+ // Advance a task's cursor. Called for every task notification (awareness too)
51
+ // so the cursor always reflects the last activity we observed on the task.
52
+ function recordCursor(taskId, { notificationId = "", type = "" } = {}) {
53
+ const id = String(taskId || "");
54
+ if (!id) return;
55
+ const s = load();
56
+ const cur = s.tasks[id] || { count: 0 };
57
+ s.tasks[id] = {
58
+ lastNotificationId: String(notificationId || cur.lastNotificationId || ""),
59
+ lastType: String(type || cur.lastType || ""),
60
+ lastAt: new Date().toISOString(),
61
+ count: (cur.count || 0) + 1,
62
+ };
63
+ save();
64
+ }
65
+
66
+ function getCursor(taskId) {
67
+ const s = load();
68
+ return s.tasks[String(taskId || "")] || null;
69
+ }
70
+
71
+ function snapshot() {
72
+ const s = load();
73
+ return { tasks: s.tasks, processedCount: s.processed.length };
74
+ }
75
+
76
+ module.exports = { markProcessed, recordCursor, getCursor, snapshot, STATE_FILE };
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. Gated by OC_SPACES_CONVERSATIONAL so
58
+ // this has zero effect unless the conversational plane is deliberately on.
59
+ try {
60
+ if (currentTransport() === "spaces"
61
+ && require("./project-transcripts").truthy(config.OC_SPACES_CONVERSATIONAL || process.env.OC_SPACES_CONVERSATIONAL, false)) {
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,34 @@ 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
+ // Two independent gates: constructing the adapter (CHANNELS includes
255
+ // spaces) starts the socket for awareness; conversational (this flag,
256
+ // default OFF) is what lets a mention/reply actually run an agent
257
+ // turn. Keeps auto-replies behind a deliberate opt-in.
258
+ conversational: configTruthy(config.OC_SPACES_CONVERSATIONAL || process.env.OC_SPACES_CONVERSATIONAL, false),
259
+ // Task-working posture for engaged Spaces turns: "advisory" (default —
260
+ // read/reason/recommend, no autonomous write chains), "autonomous"
261
+ // (work to completion; writes still hit the owner-approval gate), or
262
+ // "off". Only shapes the agent's framing; approval safety is separate.
263
+ taskLoopMode: String(config.OC_SPACES_TASK_LOOP || process.env.OC_SPACES_TASK_LOOP || "advisory").toLowerCase(),
264
+ },
265
+ });
234
266
  } else if (type === "voice") {
235
267
  const token = config.VOICE_BRIDGE_TOKEN;
236
268
  if (!token) {
@@ -305,6 +337,7 @@ module.exports = {
305
337
  UTILITY_PURPOSE_PROVIDERS,
306
338
  ensureRuntimeDirectories,
307
339
  KAZEE_DEFAULT_URL,
340
+ SPACES_DEFAULT_URL,
308
341
  BOT_DIR,
309
342
  CONFIG_DIR,
310
343
  TOKEN, CHAT_IDS, CHAT_ID,
@@ -0,0 +1,223 @@
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
+ for (const app of store.apps) {
173
+ if (!connectorFor(app)) continue;
174
+ await pollApp(app);
175
+ }
176
+ saveStore();
177
+ }
178
+
179
+ // Public: current awareness snapshot (for an on-demand "what's in my inbox").
180
+ function snapshot() {
181
+ if (!store) store = loadStore();
182
+ return {
183
+ apps: store.apps.map((a) => ({ id: a.id, name: a.name })),
184
+ perApp: Object.fromEntries(
185
+ Object.entries(store.perApp).map(([k, v]) => [k, {
186
+ status: v.status,
187
+ lastPollAt: v.lastPollAt,
188
+ unread: v.inbox.length,
189
+ inbox: v.inbox.slice(0, 20),
190
+ }]),
191
+ ),
192
+ };
193
+ }
194
+
195
+ function initConnectedApps() {
196
+ if (!truthy(config.OC_CONNECTED_APPS || process.env.OC_CONNECTED_APPS, false)) {
197
+ return { started: false, reason: "OC_CONNECTED_APPS disabled" };
198
+ }
199
+ if (!botToken()) {
200
+ return { started: false, reason: "KAZEE_BOT_TOKEN unset" };
201
+ }
202
+ store = loadStore();
203
+ // Register built-in connectors.
204
+ try { registerConnector(require("./connectors/spaces")); }
205
+ catch (e) { console.error("connected-apps: spaces connector load failed:", e.message); }
206
+ // First tick shortly after boot, then on the poll cadence.
207
+ setTimeout(() => { tick().catch((e) => console.error("connected-apps tick:", e.message)); }, 20000);
208
+ timer = setInterval(() => { tick().catch((e) => console.error("connected-apps tick:", e.message)); }, POLL_INTERVAL_MS);
209
+ return { started: true, connectors: [...connectors.keys()] };
210
+ }
211
+
212
+ function stopConnectedApps() {
213
+ if (timer) { clearInterval(timer); timer = null; }
214
+ }
215
+
216
+ module.exports = {
217
+ initConnectedApps,
218
+ stopConnectedApps,
219
+ registerConnector,
220
+ fetchConnectedApps,
221
+ snapshot,
222
+ STORE_FILE,
223
+ };
@@ -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/loopback.js CHANGED
@@ -520,6 +520,32 @@ async function handleJson(req, res, url, kind) {
520
520
  }
521
521
  } catch (e) { /* if classification fails, fall through — approvals stay owner-scoped by the apr: handler's isChatOwner gate */ }
522
522
  const approvals = require("./approvals");
523
+ // Spaces-origin runs: Spaces has no inline-button UI and its thread can be
524
+ // watched by non-owners, so the BINDING Approve/Deny goes to the owner's
525
+ // real button channel (Telegram) while the task thread gets a non-binding
526
+ // status mirror. Origin (channelId/adapter) still records the Spaces thread
527
+ // so a detached re-run wakes the agent back on the task. Gated by
528
+ // OC_SPACES_CONVERSATIONAL — off ⇒ byte-identical to the legacy path.
529
+ const { config: cfg } = require("./config");
530
+ let buttonAdapter = adapter;
531
+ let buttonChannel = channelId;
532
+ let approverAdapter = "";
533
+ let approverChannel = "";
534
+ const spacesOrigin = adapter.type === "spaces"
535
+ && require("./project-transcripts").truthy(cfg.OC_SPACES_CONVERSATIONAL || process.env.OC_SPACES_CONVERSATIONAL, false);
536
+ if (spacesOrigin) {
537
+ const owner = require("./relationship").ownerTarget();
538
+ const oa = owner
539
+ ? (registry.findAdapter(owner.adapter) || registry.getAdapters().find((a) => a.type === owner.adapter))
540
+ : null;
541
+ if (!owner || !oa) {
542
+ return reply(res, 503, { error: "no reachable owner channel to bind the approval — run stays blocked" });
543
+ }
544
+ buttonAdapter = oa;
545
+ buttonChannel = owner.channelId;
546
+ approverAdapter = owner.adapter;
547
+ approverChannel = owner.channelId;
548
+ }
523
549
  const rec = approvals.create({
524
550
  tool: payload.tool, verb: payload.verb || "", tier: payload.tier || "destructive",
525
551
  command: payload.command, channelId, adapter: adapterId,
@@ -532,9 +558,11 @@ async function handleJson(req, res, url, kind) {
532
558
  sessionId: payload.sessionId || null,
533
559
  previousSessionId: payload.previousSessionId || null,
534
560
  originRunId: payload.originRunId || null,
561
+ approverAdapter,
562
+ approverChannel,
535
563
  });
536
564
  const text = [
537
- `🔐 Destructive tool run wants approval:`,
565
+ `🔐 Destructive tool run wants approval${spacesOrigin ? " (from a Spaces task)" : ""}:`,
538
566
  ``,
539
567
  `<pre>${String(rec.command).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</pre>`,
540
568
  ``,
@@ -547,11 +575,15 @@ async function handleJson(req, res, url, kind) {
547
575
  ]] };
548
576
  // <pre> needs Telegram's HTML parse mode to render; other adapters ignore it.
549
577
  const sendOpts = { keyboard };
550
- if (adapter.type === "telegram") sendOpts.parseMode = "HTML";
578
+ if (buttonAdapter.type === "telegram") sendOpts.parseMode = "HTML";
551
579
  const { normalizeSendResult } = require("./io");
552
- const sent = normalizeSendResult(await adapter.send(channelId, text, sendOpts));
580
+ const sent = normalizeSendResult(await buttonAdapter.send(buttonChannel, text, sendOpts));
553
581
  if (!sent.ok) { approvals.decide(rec.id, "denied", "send-failed"); return reply(res, 500, { error: "could not deliver approval prompt" }); }
554
- audit.log("tool.approval.requested", { id: rec.id, tool: rec.tool, verb: rec.verb, channelId });
582
+ // Non-binding status comment into the originating Spaces thread.
583
+ if (spacesOrigin) {
584
+ try { await require("../channels/spaces/approval-mirror").mirrorRequest(rec); } catch (e) {}
585
+ }
586
+ audit.log("tool.approval.requested", { id: rec.id, tool: rec.tool, verb: rec.verb, channelId, approver: approverChannel || channelId });
555
587
  return reply(res, 200, { ok: true, id: rec.id });
556
588
  } catch (e) { return reply(res, 400, { error: e.message }); }
557
589
  }
@@ -767,6 +767,36 @@ function buildExternalModeBlock() {
767
767
  return lines.join("\n");
768
768
  }
769
769
 
770
+ // Advisory framing for the Spaces conversational plane. When the current turn
771
+ // is on a Kazee Spaces task thread and the task loop is in its default ADVISORY
772
+ // mode, this reminds the agent to work the task as an advisor — read, reason,
773
+ // and reply with analysis / a recommendation / a clarifying question — rather
774
+ // than silently executing long chains of writes. It RIDES THE TAIL (never the
775
+ // cached, owner-agnostic core instructions) and is GUIDANCE; the binding control
776
+ // is still the per-write approval gate, which for a Spaces-origin action posts
777
+ // its buttons to the owner's Telegram. No-op ("") on every non-Spaces transport.
778
+ function buildSpacesTaskModeBlock() {
779
+ let transport;
780
+ try { transport = require("./context").currentTransport(); } catch (e) { transport = ""; }
781
+ if (transport !== "spaces") return "";
782
+ const mode = String(config.OC_SPACES_TASK_LOOP ?? process.env.OC_SPACES_TASK_LOOP ?? "advisory").toLowerCase();
783
+ if (mode === "off") return "";
784
+ if (mode === "autonomous") {
785
+ return [
786
+ "\n\n## Spaces task — autonomous mode",
787
+ "You're engaged on a Kazee Spaces task thread. You may work the task through to completion, but every write or destructive action still goes through the owner-approval gate, and approvals bind on the owner's Telegram button — never on a Spaces comment. Reply in-thread with concise progress and post a clear result when done.",
788
+ ].join("\n");
789
+ }
790
+ return [
791
+ "\n\n## Spaces task — advisory mode",
792
+ "You're engaged on a Kazee Spaces task thread. Work it as an ADVISOR by default:",
793
+ "- Read the task and its comments, reason about it, and reply in-thread with your analysis, a recommendation, or a clarifying question.",
794
+ "- Do NOT autonomously run long chains of write/destructive actions to \"just do it\" — surface a short plan and let the owner drive execution.",
795
+ "- Any single write you do attempt still goes through the normal approval gate, and approvals bind on the owner's Telegram button, never on a Spaces comment.",
796
+ "- Keep replies tight and thread-appropriate; this is a shared work surface, not a private chat.",
797
+ ].join("\n");
798
+ }
799
+
770
800
  // The composed prompt can carry text the user didn't write this turn:
771
801
  // router.js wraps Telegram reply-quotes in "Replying to …:\n---\n…\n---",
772
802
  // and quoting a 📖 recall announcement echoes every entity named in it.
@@ -985,6 +1015,7 @@ function createPromptBuilder(dependencies = {}) {
985
1015
  const runtimeContext = mandatoryPromptPart("runtimeContext", () => runtimeBuilder({ ...opts, state }));
986
1016
  const speakerContext = mandatoryPromptPart("speakerContext", () => speakerBuilder({ ...opts, state }));
987
1017
  const relationshipContext = mandatoryPromptPart("relationshipContext", () => relationshipBuilder({ ...opts, state }));
1018
+ const spacesTaskMode = mandatoryPromptPart("spacesTaskMode", () => buildSpacesTaskModeBlock({ ...opts, state }));
988
1019
  const transcriptContext = mandatoryPromptPart("transcriptContext", () => transcriptPointerNoteForRun(opts.runContext, state));
989
1020
  const speakerPersona = mandatoryPromptPart("speakerPersona", () => buildSpeakerPersonaBlock({ ...opts, state }));
990
1021
  const voiceContext = mandatoryPromptPart("voiceContext", () => voiceReplyGuidance({ ...opts, state }));
@@ -1031,6 +1062,7 @@ function createPromptBuilder(dependencies = {}) {
1031
1062
  recallResult.dynamicContext,
1032
1063
  voiceContext,
1033
1064
  relationshipContext,
1065
+ spacesTaskMode,
1034
1066
  ].filter((part) => typeof part === "string" && part.trim()).join("\n\n");
1035
1067
 
1036
1068
  return {
@@ -1061,6 +1093,7 @@ module.exports = {
1061
1093
  buildOptionalRecallContext,
1062
1094
  buildDynamicContextBlock,
1063
1095
  buildExternalModeBlock,
1096
+ buildSpacesTaskModeBlock,
1064
1097
  buildSpeakerContextBlock,
1065
1098
  buildSpeakerPersonaBlock,
1066
1099
  buildSystemPrompt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.15",
3
+ "version": "3.0.16",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -32,11 +32,12 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
32
32
 
33
33
  const pkg = JSON.parse(read("package.json"));
34
34
  assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
35
- // 3.0.15 approved by Sumeet 2026-07-14 ("Yes fix and push"): episodic-recall
36
- // owner gate now resolves the speaker by canonical user id (not raw channelId),
37
- // so the owner keeps cross-conversation episodic memory on Kazee / group chats
38
- // where channelId is a room id that never matches the stored user-id handle.
39
- assert.strictEqual(pkg.version, "3.0.15", "release version must remain unchanged without explicit approval");
35
+ // 3.0.16 approved by Sumeet 2026-07-14 ("I want to upgrade and test"): the
36
+ // Spaces connected-apps task-working loop (advisory) and the approval
37
+ // thread-mirror ship dormant behind the existing OFF flags (OC_CONNECTED_APPS,
38
+ // OC_SPACES_CONVERSATIONAL, OC_SPACES_TASK_LOOP=advisory) no behaviour change
39
+ // on Telegram/Kazee until a flag is flipped and the bot is a Spaces member.
40
+ assert.strictEqual(pkg.version, "3.0.16", "release version must remain unchanged without explicit approval");
40
41
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
41
42
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
42
43
  }