@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.17 — Spaces auto-enables on connection; control moves into chat
4
+
5
+ - **The Spaces conversational plane no longer hides behind an env flag.** `OC_SPACES_CONVERSATIONAL` is gone. Auto-replies now enable themselves the moment the bot is **connected** to Spaces, on a hybrid signal: central authoritatively lists Spaces among the bot's connected apps, **or** the socket has actually received a Spaces notification (which only happens for a task the bot is a member of). Either one flips it on — so it works today over the socket even before the central `/api/principal/applications` endpoint is deployed. It stays self-gating: no Spaces membership ⇒ no notifications ⇒ no replies.
6
+ - **All control is in chat now — nothing to set on the pod.** New owner command `/spaces`: `status` (connection + whether replies are live + task mode), `pause` / `resume` (the off-switch for auto-replies; mentions still hit the radar, I just don't answer), and `mode advisory|autonomous|off` (task-working posture, overriding the env default live). State persists in `spaces-state.json`; the owner never needs filesystem or env access.
7
+ - **The Spaces channel auto-includes whenever `KAZEE_BOT_TOKEN` is set**, even if `CHANNELS` doesn't list it — Spaces is fixed infra on the same token as Kazee chat, so "has token ⇒ Spaces available" is what makes auto-enable reachable without editing env.
8
+ - **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.
9
+
10
+ ## v3.0.16 — Spaces task loop + approval thread-mirror (dormant)
11
+
12
+ - **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.
13
+ - **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.
14
+ - **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.
15
+
3
16
  ## v3.0.15 — the owner keeps their memory on Kazee
4
17
 
5
18
  - **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,238 @@
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 = "", taskLoopMode = "advisory" }) {
29
+ this.id = id;
30
+ this.type = "spaces";
31
+ this.url = url.replace(/\/$/, "");
32
+ this.token = token;
33
+ this.ownerUserId = ownerUserId || "";
34
+ // Env default posture; the live value is chat-overridable via spacesState.
35
+ this.taskLoopModeDefault = 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
+ // Live task-loop posture: chat override (spacesState) wins over the env default.
89
+ _taskLoopMode() {
90
+ try { return spacesState.taskLoopMode(this.taskLoopModeDefault); }
91
+ catch (e) { return this.taskLoopModeDefault; }
92
+ }
93
+
94
+ // Notifications arrive in two shapes: the socket payload
95
+ // (broadcastNotification: flat {type,targetType,targetId,actorId,actorName})
96
+ // and the REST row ({category, actorId:{...}, sourceEntityId, data.payload}).
97
+ // Normalise both to one internal view.
98
+ _normalize(payload) {
99
+ const type = String(payload.type || payload.category || "").toLowerCase();
100
+ const data = payload.data || {};
101
+ const inner = data.payload || {};
102
+ const taskId = String(
103
+ (payload.targetType === "task" && payload.targetId)
104
+ || data.taskId
105
+ || inner.taskId
106
+ || payload.sourceEntityId
107
+ || inner.targetId
108
+ || payload.targetId
109
+ || "",
110
+ );
111
+ let actorId = payload.actorId;
112
+ let actorName = payload.actorName;
113
+ if (actorId && typeof actorId === "object") {
114
+ actorName = actorName || `${actorId.firstName || ""} ${actorId.lastName || ""}`.trim() || actorId.email;
115
+ actorId = actorId.id || actorId._id;
116
+ }
117
+ return {
118
+ type,
119
+ taskId,
120
+ actorId: actorId ? String(actorId) : "",
121
+ actorName: actorName || "",
122
+ spaceId: payload.spaceId || inner.spaceId || null,
123
+ title: payload.title || inner.taskTitle || inner.targetTitle || "",
124
+ content: contentText(payload.content || payload.title || ""),
125
+ deepLink: payload.deepLink || null,
126
+ };
127
+ }
128
+
129
+ _handleNotification(payload, source) {
130
+ const id = this._notificationId(payload);
131
+ if (!id || this._seen.has(id)) return;
132
+ this._seen.add(id);
133
+ if (this._seen.size > 500) {
134
+ const arr = [...this._seen];
135
+ this._seen.clear();
136
+ arr.slice(-250).forEach((k) => this._seen.add(k));
137
+ }
138
+
139
+ // Persistent dedup across restarts: a socket reconnect or a REST catch-up
140
+ // can redeliver a notification a previous process already handled.
141
+ try { if (!spacesState.markProcessed(id)) return; } catch (e) {}
142
+
143
+ const n = this._normalize(payload);
144
+ if (!n.taskId) return;
145
+
146
+ // Receiving any Spaces notification proves membership → mark connected. This
147
+ // is the socket half of the hybrid "connected ⇒ conversational" signal; no
148
+ // env flag needed to turn auto-replies on.
149
+ try { spacesState.markSocketConnected(); } catch (e) {}
150
+
151
+ // Advance this task's cursor (awareness events included) so it always
152
+ // reflects the last activity we observed on the task.
153
+ try { spacesState.recordCursor(n.taskId, { notificationId: id, type: n.type }); } catch (e) {}
154
+
155
+ // Awareness-only types never auto-trigger a turn. They still fire an
156
+ // "awareness" listener (if any) so the poller can track/surface them.
157
+ // conversationalActive() = connected (central or socket) AND not owner-muted.
158
+ let active = false;
159
+ try { active = spacesState.conversationalActive(); } catch (e) {}
160
+ const engage = active && ENGAGE_TYPES.has(n.type);
161
+ this._emit("awareness", { adapter: this, notificationId: id, source, ...n });
162
+ if (!engage) return;
163
+
164
+ const channelId = `task:${n.taskId}`;
165
+ const envelope = {
166
+ adapter: this,
167
+ channelId,
168
+ canonicalUserId: canonicalForChannel("spaces", n.actorId || n.taskId),
169
+ userId: n.actorId || n.taskId,
170
+ type: "text",
171
+ text: n.content || `[${n.type} on task ${n.title || n.taskId}]`,
172
+ messageId: id,
173
+ from: { id: n.actorId, name: n.actorName },
174
+ spaces: { taskId: n.taskId, spaceId: n.spaceId, notificationType: n.type, deepLink: n.deepLink, title: n.title, mode: this._taskLoopMode() },
175
+ raw: payload,
176
+ };
177
+ // Per-task serialization: a task's turns run one at a time so two comments
178
+ // on the same task can't spawn concurrent, conflicting agent runs. Other
179
+ // tasks proceed in parallel.
180
+ this._deliverSerialized(n.taskId, envelope);
181
+ }
182
+
183
+ // Chain each task's message delivery onto that task's tail promise, so the
184
+ // next queued event for the same task waits for the current turn to finish.
185
+ _deliverSerialized(taskId, envelope) {
186
+ const key = String(taskId);
187
+ const prev = this._taskChains.get(key) || Promise.resolve();
188
+ const next = prev
189
+ .then(() => this._deliverMessage(envelope))
190
+ .catch((e) => console.error("spaces serialized delivery:", e.message));
191
+ this._taskChains.set(key, next);
192
+ next.finally(() => { if (this._taskChains.get(key) === next) this._taskChains.delete(key); });
193
+ }
194
+
195
+ // Await every registered message listener (the router turn resolves when the
196
+ // agent run completes) so the per-task chain doesn't advance early.
197
+ async _deliverMessage(envelope) {
198
+ for (const fn of [...(this._listeners.message || [])]) {
199
+ try { await fn(envelope); }
200
+ catch (e) { console.error("spaces message handler:", e.message); }
201
+ }
202
+ }
203
+
204
+ // send() posts a comment back to the task thread. channelId is
205
+ // "task:<taskId>"; opts.mentions is an array of {userId,label}; opts.replyTo
206
+ // threads the reply under a comment id (parentId).
207
+ async send(channelId, text, opts = {}) {
208
+ const taskId = String(channelId || "").replace(/^task:/, "");
209
+ if (!taskId) { console.error("Spaces send: no taskId in channelId", channelId); return null; }
210
+ try {
211
+ const comment = await this.client.createComment({
212
+ taskId,
213
+ text,
214
+ mentions: opts.mentions || [],
215
+ parentId: opts.replyTo ? String(opts.replyTo).replace(/^comment:/, "") : null,
216
+ });
217
+ return comment && (comment._id || comment.id) ? String(comment._id || comment.id) : true;
218
+ } catch (e) {
219
+ console.error("Spaces send error:", e.message);
220
+ return null;
221
+ }
222
+ }
223
+
224
+ // Spaces has no per-thread bot typing indicator, and comment edit/delete are
225
+ // out of scope for v1. These keep the adapter contract total so core code can
226
+ // call them unconditionally.
227
+ async edit() { /* not supported */ }
228
+ async delete() { /* not supported */ }
229
+ async typing() { /* no-op */ }
230
+ async typingStop() { /* no-op */ }
231
+ async sendVoice() { return false; }
232
+ async sendPhoto() { return false; }
233
+ async sendFile() { return false; }
234
+ async downloadMedia() { return null; }
235
+ async registerCommands() { /* Spaces has no slash-command registry */ }
236
+ }
237
+
238
+ 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 };