@oalo-ai/cli 0.0.0-stage → 0.1.1

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -2
  3. package/man/oalo.1 +1530 -0
  4. package/package.json +44 -4
  5. package/plugin/.claude-plugin/plugin.json +12 -0
  6. package/plugin/skills/oalo/SKILL.md +414 -0
  7. package/src/api.js +132 -0
  8. package/src/app-ref.js +11 -0
  9. package/src/args.js +67 -0
  10. package/src/bot-settings.json +468 -0
  11. package/src/channel.js +162 -0
  12. package/src/commands/apps.js +64 -0
  13. package/src/commands/bots.js +278 -0
  14. package/src/commands/call.js +179 -0
  15. package/src/commands/channels.js +110 -0
  16. package/src/commands/data.js +446 -0
  17. package/src/commands/db.js +58 -0
  18. package/src/commands/deploy.js +431 -0
  19. package/src/commands/dockerfile.js +81 -0
  20. package/src/commands/docs.js +70 -0
  21. package/src/commands/emit.js +124 -0
  22. package/src/commands/env.js +223 -0
  23. package/src/commands/graphs.js +570 -0
  24. package/src/commands/incidents.js +44 -0
  25. package/src/commands/link.js +150 -0
  26. package/src/commands/login.js +180 -0
  27. package/src/commands/logs.js +69 -0
  28. package/src/commands/mcp.js +125 -0
  29. package/src/commands/metrics.js +168 -0
  30. package/src/commands/products.js +169 -0
  31. package/src/commands/qa.js +98 -0
  32. package/src/commands/rehearse.js +100 -0
  33. package/src/commands/run.js +152 -0
  34. package/src/commands/secrets.js +68 -0
  35. package/src/commands/setup.js +96 -0
  36. package/src/commands/skill.js +142 -0
  37. package/src/commands/sources.js +377 -0
  38. package/src/commands/switches.js +156 -0
  39. package/src/commands/tail.js +84 -0
  40. package/src/commands/tokens.js +85 -0
  41. package/src/commands/typegen.js +505 -0
  42. package/src/commands/whoami.js +112 -0
  43. package/src/config.js +157 -0
  44. package/src/data.js +392 -0
  45. package/src/envfile.js +71 -0
  46. package/src/events.js +63 -0
  47. package/src/graphfiles.js +273 -0
  48. package/src/help.js +920 -0
  49. package/src/index.js +216 -0
  50. package/src/mcp/tools.js +773 -0
  51. package/src/server.js +73 -0
  52. package/src/workspace.js +44 -0
package/src/args.js ADDED
@@ -0,0 +1,67 @@
1
+ import { COMMANDS } from "./help.js";
2
+
3
+ /**
4
+ * The command line's flags, read from the help registry so the parser and the
5
+ * help can't disagree. A flag documented with no value (`--force`) is a
6
+ * switch: it never takes the next word, so `oalo pull --force flows` keeps
7
+ * `flows`. A flag documented with `<value>` takes the next word, and one with
8
+ * `[<value>]` (`--new [<name>]`, `--list [<text>]`) takes it when it isn't
9
+ * another flag. A flag the registry doesn't list is read like an optional one.
10
+ */
11
+
12
+ // `--field`, `--scope`, `--add`, `--remove` and `--header` may repeat; every
13
+ // other flag keeps its last value.
14
+ const REPEATABLE = new Set(["field", "scope", "add", "remove", "header"]);
15
+
16
+ // Switches the registry doesn't list per command (global ones).
17
+ const GLOBAL_SWITCHES = ["version", "help"];
18
+
19
+ /** Each documented flag's name and whether it takes a value. */
20
+ export function flagKinds(commands = COMMANDS) {
21
+ const kinds = new Map();
22
+ for (const c of commands) {
23
+ for (const f of c.flags) {
24
+ const m = f.flag.match(/^--([a-z0-9-]+)\s*(.*)$/);
25
+ if (!m) continue;
26
+ const kind = !m[2] ? "switch" : m[2].startsWith("[") ? "optional" : "value";
27
+ // A name that takes a value anywhere takes it everywhere: treating it as a
28
+ // switch would drop the value where it's needed.
29
+ const prev = kinds.get(m[1]);
30
+ if (!prev || prev === "switch") kinds.set(m[1], kind);
31
+ }
32
+ }
33
+ return kinds;
34
+ }
35
+
36
+ const KINDS = flagKinds();
37
+
38
+ export const SWITCHES = new Set([...GLOBAL_SWITCHES, ...[...KINDS].filter(([, k]) => k === "switch").map(([n]) => n)]);
39
+
40
+ // Flags that always take a value, so a value starting with "-" (`--order -seqnum`)
41
+ // is still theirs. Only another `--flag` ends them.
42
+ export const VALUED = new Set([...KINDS].filter(([, k]) => k === "value").map(([n]) => n));
43
+
44
+ /** Tiny flag parser: --key value, --switch, -h. No dependency needed. */
45
+ export function parse(argv, { switches = SWITCHES, valued = VALUED } = {}) {
46
+ const flags = {};
47
+ const rest = [];
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const a = argv[i];
50
+ if (a.startsWith("--")) {
51
+ const key = a.slice(2);
52
+ const next = argv[i + 1];
53
+ const takes = valued.has(key) ? next !== undefined && !next.startsWith("--") : next && !next.startsWith("-");
54
+ if (takes && !switches.has(key)) {
55
+ flags[key] = REPEATABLE.has(key) ? [...(flags[key] || []), next] : next;
56
+ i++;
57
+ } else {
58
+ flags[key] = true;
59
+ }
60
+ } else if (a === "-h") {
61
+ flags.help = true;
62
+ } else {
63
+ rest.push(a);
64
+ }
65
+ }
66
+ return { flags, rest };
67
+ }
@@ -0,0 +1,468 @@
1
+ {
2
+ "groups": [
3
+ "knowledge",
4
+ "memory",
5
+ "model",
6
+ "replies",
7
+ "limits",
8
+ "data"
9
+ ],
10
+ "settings": [
11
+ {
12
+ "clear_word": null,
13
+ "default": "hybrid",
14
+ "doc": "How the bot's knowledge reaches the AI each turn. preload packs the best matching passages into the prompt, with no search tools. adhoc gives the AI search tools only, and it searches when it decides to. hybrid does both. none uses no knowledge (the persona only).",
15
+ "effect": null,
16
+ "group": "knowledge",
17
+ "key": "retrieval_strategy",
18
+ "type": "one of",
19
+ "values": [
20
+ "hybrid",
21
+ "preload",
22
+ "adhoc",
23
+ "none"
24
+ ]
25
+ },
26
+ {
27
+ "clear_word": null,
28
+ "default": "fast",
29
+ "doc": "How documents are cut and searched. fast: small chunks, few hits, no reranking (FAQs). deep: big chunks, more hits, reranked (long or system-level material).",
30
+ "effect": "re-indexes the bot's knowledge",
31
+ "group": "knowledge",
32
+ "key": "retrieval_mode",
33
+ "type": "one of",
34
+ "values": [
35
+ "fast",
36
+ "deep"
37
+ ]
38
+ },
39
+ {
40
+ "clear_word": null,
41
+ "default": "auto",
42
+ "doc": "How the knowledge graph adds to what's found. hierarchical always brings the matched passage's parent topics (its governing rules). similar_only adds related passages beside it. auto is hierarchical when the graph has real depth, else similar_only. off adds nothing.",
43
+ "effect": null,
44
+ "group": "knowledge",
45
+ "key": "graph_retrieval",
46
+ "type": "one of",
47
+ "values": [
48
+ "auto",
49
+ "hierarchical",
50
+ "similar_only",
51
+ "off"
52
+ ]
53
+ },
54
+ {
55
+ "clear_word": null,
56
+ "default": 3,
57
+ "doc": "How many levels of parent topics come with a match when graph_retrieval brings them, nearest first. Cheap to raise when the documents are small.",
58
+ "effect": null,
59
+ "group": "knowledge",
60
+ "key": "ancestor_preload_depth",
61
+ "type": "number",
62
+ "values": [
63
+ 1,
64
+ 32
65
+ ]
66
+ },
67
+ {
68
+ "clear_word": null,
69
+ "default": "retrieval",
70
+ "doc": "How each turn's context is put together. retrieval: the frame, the persona and what search finds. graph_resolver: everything the member's tier unlocks in the knowledge graph, whole (high recall, more tokens). graph_focused: the same unlocking, but only the unlocked topics' own text, plus a search over the graph for this turn (token-bounded). The context.* settings below are read by the graph strategies.",
71
+ "effect": null,
72
+ "group": "knowledge",
73
+ "key": "context.strategy",
74
+ "type": "one of",
75
+ "values": [
76
+ "retrieval",
77
+ "graph_resolver",
78
+ "graph_focused"
79
+ ]
80
+ },
81
+ {
82
+ "clear_word": null,
83
+ "default": [],
84
+ "doc": "Graph topics that load on top of the frame, each its own way. resident: the topic and everything under it, every turn (rules that must always bind). semantic: only the parts that match this turn, plus a list of all its titles. semantic_expand: semantic, with the question rephrased first for a better match. structural: only what the member's tier unlocks under it. Written `THE RULES=resident; THE MAP=semantic`, or as JSON. Titles match exactly (case aside).",
85
+ "effect": null,
86
+ "group": "knowledge",
87
+ "key": "context.frame_segments",
88
+ "type": "segments",
89
+ "values": [
90
+ "resident",
91
+ "semantic",
92
+ "semantic_expand",
93
+ "structural"
94
+ ]
95
+ },
96
+ {
97
+ "clear_word": null,
98
+ "default": [],
99
+ "doc": "Graph topics loaded every turn whatever the member's tier, by title, comma-separated.",
100
+ "effect": null,
101
+ "group": "knowledge",
102
+ "key": "context.resident_subtrees",
103
+ "type": "list",
104
+ "values": null
105
+ },
106
+ {
107
+ "clear_word": "default",
108
+ "default": null,
109
+ "doc": "How much of each resident topic loads: full (it and everything under it) or shallow (its own text only). default: full for graph_resolver, shallow for graph_focused.",
110
+ "effect": null,
111
+ "group": "knowledge",
112
+ "key": "context.resident_subtree_depth",
113
+ "type": "one of",
114
+ "values": [
115
+ "full",
116
+ "shallow"
117
+ ]
118
+ },
119
+ {
120
+ "clear_word": null,
121
+ "default": "band",
122
+ "doc": "The member field that holds their tier, which decides what the graph strategies unlock.",
123
+ "effect": null,
124
+ "group": "knowledge",
125
+ "key": "context.tier_field",
126
+ "type": "text",
127
+ "values": null
128
+ },
129
+ {
130
+ "clear_word": null,
131
+ "default": "Band",
132
+ "doc": "The word before the tier value in the title of each tier's topic: \"Band 3\" for tier 3.",
133
+ "effect": null,
134
+ "group": "knowledge",
135
+ "key": "context.tier_label",
136
+ "type": "text",
137
+ "values": null
138
+ },
139
+ {
140
+ "clear_word": null,
141
+ "default": "grants",
142
+ "doc": "The graph relation from a tier's topic to the topics it unlocks.",
143
+ "effect": null,
144
+ "group": "knowledge",
145
+ "key": "context.grant_rel",
146
+ "type": "text",
147
+ "values": null
148
+ },
149
+ {
150
+ "clear_word": null,
151
+ "default": "recurses_into",
152
+ "doc": "The graph relation followed from an unlocked topic to the further topics it unlocks.",
153
+ "effect": null,
154
+ "group": "knowledge",
155
+ "key": "context.recurse_rel",
156
+ "type": "text",
157
+ "values": null
158
+ },
159
+ {
160
+ "clear_word": null,
161
+ "default": "child_of",
162
+ "doc": "The graph relation from a topic to its parent: what \"everything under it\" follows.",
163
+ "effect": null,
164
+ "group": "knowledge",
165
+ "key": "context.child_rel",
166
+ "type": "text",
167
+ "values": null
168
+ },
169
+ {
170
+ "clear_word": null,
171
+ "default": [],
172
+ "doc": "Fields of what the conversation has collected (or the member's own data) whose current values drive an extra search every turn, so the knowledge for the member's situation is there however they phrase the question. Comma-separated; empty is off.",
173
+ "effect": null,
174
+ "group": "knowledge",
175
+ "key": "state_fields",
176
+ "type": "list",
177
+ "values": null
178
+ },
179
+ {
180
+ "clear_word": null,
181
+ "default": null,
182
+ "doc": "Rules that apply to every answer, always in the prompt (they can't depend on a search finding them). @file reads the text from a file; an empty value clears it.",
183
+ "effect": null,
184
+ "group": "knowledge",
185
+ "key": "frame",
186
+ "type": "text",
187
+ "values": null
188
+ },
189
+ {
190
+ "clear_word": null,
191
+ "default": false,
192
+ "doc": "After each reply, remove any passage copied word for word from the knowledge loaded that turn. For bots grounded in material that must not be quoted out.",
193
+ "effect": null,
194
+ "group": "knowledge",
195
+ "key": "protect_canon",
196
+ "type": "true or false",
197
+ "values": null
198
+ },
199
+ {
200
+ "clear_word": null,
201
+ "default": "auto",
202
+ "doc": "Whether the AI sees what the conversation has established. auto: shown when there are facts. extract: auto, plus reading every customer message for the facts the bot defines. off: never shown (it's still recorded).",
203
+ "effect": null,
204
+ "group": "knowledge",
205
+ "key": "conversation_record",
206
+ "type": "one of",
207
+ "values": [
208
+ "auto",
209
+ "extract",
210
+ "off"
211
+ ]
212
+ },
213
+ {
214
+ "clear_word": null,
215
+ "default": true,
216
+ "doc": "Keep what members tell the bot across conversations. The master switch for the three below.",
217
+ "effect": null,
218
+ "group": "memory",
219
+ "key": "member_learning.memory",
220
+ "type": "true or false",
221
+ "values": null
222
+ },
223
+ {
224
+ "clear_word": null,
225
+ "default": false,
226
+ "doc": "Track the topics each member asks about.",
227
+ "effect": null,
228
+ "group": "memory",
229
+ "key": "member_learning.interests",
230
+ "type": "true or false",
231
+ "values": null
232
+ },
233
+ {
234
+ "clear_word": null,
235
+ "default": false,
236
+ "doc": "Note when a member's state_fields change between turns.",
237
+ "effect": null,
238
+ "group": "memory",
239
+ "key": "member_learning.trajectory",
240
+ "type": "true or false",
241
+ "values": null
242
+ },
243
+ {
244
+ "clear_word": null,
245
+ "default": false,
246
+ "doc": "Lean searches toward the member's main interests.",
247
+ "effect": null,
248
+ "group": "memory",
249
+ "key": "member_learning.query_priors",
250
+ "type": "true or false",
251
+ "values": null
252
+ },
253
+ {
254
+ "clear_word": "default",
255
+ "default": null,
256
+ "doc": "The AI model this bot answers with. default: the workspace's default model.",
257
+ "effect": null,
258
+ "group": "model",
259
+ "key": "model",
260
+ "type": "text",
261
+ "values": null
262
+ },
263
+ {
264
+ "clear_word": "default",
265
+ "default": null,
266
+ "doc": "How the model is reached: anthropic directly, or opencode (for models from other providers). default: anthropic.",
267
+ "effect": null,
268
+ "group": "model",
269
+ "key": "provider",
270
+ "type": "one of",
271
+ "values": [
272
+ "anthropic",
273
+ "opencode"
274
+ ]
275
+ },
276
+ {
277
+ "clear_word": null,
278
+ "default": "5m",
279
+ "doc": "How long the provider keeps the bot's prompt cached. 1h costs more to write and pays off when a member's messages come 5 to 60 minutes apart. The AI sees the same text either way.",
280
+ "effect": null,
281
+ "group": "model",
282
+ "key": "prompt_cache_ttl",
283
+ "type": "one of",
284
+ "values": [
285
+ "5m",
286
+ "1h"
287
+ ]
288
+ },
289
+ {
290
+ "clear_word": null,
291
+ "default": "handoff",
292
+ "doc": "Whether the bot answers new conversations. on: it answers. off: a human inbox, the bot never answers. handoff: it answers until it hands the conversation to a person.",
293
+ "effect": null,
294
+ "group": "replies",
295
+ "key": "autopilot_default",
296
+ "type": "one of",
297
+ "values": [
298
+ "on",
299
+ "off",
300
+ "handoff"
301
+ ]
302
+ },
303
+ {
304
+ "clear_word": "default",
305
+ "default": null,
306
+ "doc": "How long to wait for more messages before answering, so a burst of short messages gets one reply. 0 answers at once. default: the channel's own.",
307
+ "effect": null,
308
+ "group": "replies",
309
+ "key": "ingestion_window_ms",
310
+ "type": "number",
311
+ "values": [
312
+ 0,
313
+ 600000
314
+ ]
315
+ },
316
+ {
317
+ "clear_word": null,
318
+ "default": "text",
319
+ "doc": "On WhatsApp and Instagram, which can't show a form: text asks for the answers in the chat, link sends a link to the form on the web.",
320
+ "effect": null,
321
+ "group": "replies",
322
+ "key": "offchannel_widget_fallback",
323
+ "type": "one of",
324
+ "values": [
325
+ "text",
326
+ "link"
327
+ ]
328
+ },
329
+ {
330
+ "clear_word": null,
331
+ "default": "off",
332
+ "doc": "On WhatsApp and Instagram: off answers there, link moves the customer to the web chat on the first reply (it costs nothing per message).",
333
+ "effect": null,
334
+ "group": "replies",
335
+ "key": "offchannel_handoff",
336
+ "type": "one of",
337
+ "values": [
338
+ "off",
339
+ "link"
340
+ ]
341
+ },
342
+ {
343
+ "clear_word": null,
344
+ "default": "handoff",
345
+ "doc": "When a sale is ready: handoff passes the lead to a salesperson, direct has the bot close it in the chat.",
346
+ "effect": null,
347
+ "group": "replies",
348
+ "key": "offchannel_close",
349
+ "type": "one of",
350
+ "values": [
351
+ "handoff",
352
+ "direct"
353
+ ]
354
+ },
355
+ {
356
+ "clear_word": null,
357
+ "default": "tag",
358
+ "doc": "Flag WhatsApp and Instagram leads about to be lost. tag: a tag and a note only. assign: also assign the conversation to the least busy teammate. off.",
359
+ "effect": null,
360
+ "group": "replies",
361
+ "key": "lead_watch.mode",
362
+ "type": "one of",
363
+ "values": [
364
+ "tag",
365
+ "assign",
366
+ "off"
367
+ ]
368
+ },
369
+ {
370
+ "clear_word": null,
371
+ "default": 30,
372
+ "doc": "Minutes a customer can leave the bot waiting before the lead counts as stalled.",
373
+ "effect": null,
374
+ "group": "replies",
375
+ "key": "lead_watch.stalled_minutes",
376
+ "type": "number",
377
+ "values": [
378
+ 10,
379
+ 1440
380
+ ]
381
+ },
382
+ {
383
+ "clear_word": null,
384
+ "default": 20,
385
+ "doc": "Hours after the customer's last WhatsApp message to flag the 24-hour window as closing.",
386
+ "effect": null,
387
+ "group": "replies",
388
+ "key": "lead_watch.window_hours",
389
+ "type": "number",
390
+ "values": [
391
+ 1,
392
+ 23
393
+ ]
394
+ },
395
+ {
396
+ "clear_word": null,
397
+ "default": false,
398
+ "doc": "Show an attach button in the web chat, so members can send files with their message.",
399
+ "effect": null,
400
+ "group": "replies",
401
+ "key": "allow_ambient_uploads",
402
+ "type": "true or false",
403
+ "values": null
404
+ },
405
+ {
406
+ "clear_word": "none",
407
+ "default": null,
408
+ "doc": "Conversations API requests a minute, per end user. none: the workspace default.",
409
+ "effect": null,
410
+ "group": "limits",
411
+ "key": "api_rate_limit_per_min",
412
+ "type": "number",
413
+ "values": [
414
+ 1,
415
+ 100000
416
+ ]
417
+ },
418
+ {
419
+ "clear_word": "none",
420
+ "default": null,
421
+ "doc": "Messages a day per end user. none: no cap.",
422
+ "effect": null,
423
+ "group": "limits",
424
+ "key": "daily_message_cap_per_user",
425
+ "type": "number",
426
+ "values": [
427
+ 1,
428
+ 100000
429
+ ]
430
+ },
431
+ {
432
+ "clear_word": "none",
433
+ "default": null,
434
+ "doc": "AI spend a day, in cents, past which the workspace is warned. none: no warning.",
435
+ "effect": null,
436
+ "group": "limits",
437
+ "key": "soft_daily_cap_cents",
438
+ "type": "number",
439
+ "values": [
440
+ 1,
441
+ 100000000
442
+ ]
443
+ },
444
+ {
445
+ "clear_word": "none",
446
+ "default": null,
447
+ "doc": "AI spend a day, in cents, past which the bot stops answering until tomorrow. none: no cap.",
448
+ "effect": null,
449
+ "group": "limits",
450
+ "key": "hard_daily_cap_cents",
451
+ "type": "number",
452
+ "values": [
453
+ 1,
454
+ 100000000
455
+ ]
456
+ },
457
+ {
458
+ "clear_word": null,
459
+ "default": false,
460
+ "doc": "Let the workspace's dashboards read this bot's member memories and profiles over the Sync API. They are personal data; off unless you need them.",
461
+ "effect": null,
462
+ "group": "data",
463
+ "key": "expose_member_data",
464
+ "type": "true or false",
465
+ "values": null
466
+ }
467
+ ]
468
+ }
package/src/channel.js ADDED
@@ -0,0 +1,162 @@
1
+ import { Socket } from "phoenix";
2
+ import WebSocket from "ws";
3
+
4
+ /**
5
+ * Thin client over the Oalo BuilderChannel — the same Phoenix channel the
6
+ * dashboard drives, which is where the whole build surface lives (there is no
7
+ * REST API for it).
8
+ *
9
+ * Auth is a CLI token (`cli_token` connect param). It is user-bound, so the
10
+ * server loads the minting user's NodeAccess at join and this client can never
11
+ * reach further than that user can.
12
+ */
13
+ export class OaloChannel {
14
+ constructor({ url, token, timeoutMs = 30000, verbose = !!process.env.OALO_DEBUG }) {
15
+ if (!url) throw new Error("OALO_URL is required");
16
+ if (!token) throw new Error("OALO_CLI_TOKEN is required");
17
+
18
+ this.wsUrl = url.replace(/^http/, "ws").replace(/\/$/, "") + "/socket";
19
+ this.token = token;
20
+ this.timeoutMs = timeoutMs;
21
+ this.verbose = verbose;
22
+ this.socket = null;
23
+ this.channel = null;
24
+ // In-flight calls, so a channel crash can fail them immediately instead of
25
+ // leaving each one to wait out its own timeout.
26
+ this.pending = new Set();
27
+ this.listeners = new Set();
28
+ }
29
+
30
+ /**
31
+ * Observe EVERY message the server pushes, including ones this client has no
32
+ * code for. Deliberately a firehose hook rather than a fixed `channel.on`
33
+ * list: the server pushes ~25 distinct event names today and gains more, and
34
+ * a tail that silently omits new ones is worse than useless when you are
35
+ * debugging why something did not happen.
36
+ */
37
+ onEvent(fn) {
38
+ this.listeners.add(fn);
39
+ return () => this.listeners.delete(fn);
40
+ }
41
+
42
+ connect() {
43
+ return new Promise((resolve, reject) => {
44
+ this.socket = new Socket(this.wsUrl, {
45
+ params: { cli_token: this.token },
46
+ transport: WebSocket,
47
+ heartbeatIntervalMs: 30000,
48
+ // Quiet by default. Two reasons: Phoenix logs to stdout, which would
49
+ // corrupt the MCP stdio protocol stream; and in a CLI command the
50
+ // transport chatter buries the message the user actually needs (it hid
51
+ // a token-rejected explanation during testing). OALO_DEBUG=1 brings it
52
+ // back, on stderr.
53
+ logger: this.verbose
54
+ ? (kind, msg) => process.stderr.write(`[phoenix] ${kind}: ${msg}\n`)
55
+ : () => {},
56
+ });
57
+
58
+ let settled = false;
59
+ const fail = (why) => {
60
+ if (settled) return;
61
+ settled = true;
62
+ reject(new Error(why));
63
+ };
64
+
65
+ this.socket.onError(() => fail("couldn't connect to your Base. Check its address (`oalo whoami`) and that your login is still valid (`oalo login`)."));
66
+ this.socket.connect();
67
+
68
+ // Topic is a free-form discriminator: the server takes org_id and user_id
69
+ // from socket assigns, never from the topic. Randomised so two clients
70
+ // don't share a session-scoped event stream.
71
+ const session = `cli-${Math.random().toString(36).slice(2, 10)}`;
72
+ this.channel = this.socket.channel(`builder:${session}`, {});
73
+
74
+ // A raising handler kills the channel process. Phoenix rejoins, but the
75
+ // push that caused it never gets a reply — without this every such call
76
+ // would hang for the full timeout. Fail them now with a usable message.
77
+ this.channel.onError(() => {
78
+ const dead = [...this.pending];
79
+ this.pending.clear();
80
+ for (const reject of dead) {
81
+ reject(
82
+ new Error(
83
+ "the Base couldn't handle that request (most likely a parameter it didn't expect). If it keeps happening, your CLI may be newer than your Base.",
84
+ ),
85
+ );
86
+ }
87
+ });
88
+
89
+ // onMessage sees everything, replies and internal frames included; the
90
+ // caller decides what to ignore. Returning the payload is required —
91
+ // Phoenix uses the return value as the message body.
92
+ this.channel.onMessage = (event, payload, ref) => {
93
+ for (const fn of this.listeners) {
94
+ try {
95
+ fn(event, payload, ref);
96
+ } catch {
97
+ /* a bad listener must not break the transport */
98
+ }
99
+ }
100
+ return payload;
101
+ };
102
+
103
+ this.channel
104
+ .join(this.timeoutMs)
105
+ .receive("ok", () => {
106
+ if (settled) return;
107
+ settled = true;
108
+ resolve();
109
+ })
110
+ .receive("error", (err) => fail(joinRefused(err)))
111
+ // Without this the promise hangs forever on a silent server.
112
+ .receive("timeout", () => fail("your Base didn't answer in time. Try again in a moment."));
113
+ });
114
+ }
115
+
116
+ /** Push one event and resolve its reply. Rejects on error OR timeout. */
117
+ call(event, params = {}, timeoutMs = this.timeoutMs) {
118
+ return new Promise((resolve, reject) => {
119
+ if (!this.channel) return reject(new Error("not connected"));
120
+
121
+ this.pending.add(reject);
122
+ const done = (fn) => (arg) => {
123
+ this.pending.delete(reject);
124
+ fn(arg);
125
+ };
126
+
127
+ this.channel
128
+ .push(event, params, timeoutMs)
129
+ .receive("ok", done(resolve))
130
+ .receive(
131
+ "error",
132
+ done((err) => {
133
+ const msg =
134
+ typeof err?.error === "string" ? err.error : typeof err?.message === "string" ? err.message : JSON.stringify(err);
135
+ const e = new Error(msg);
136
+ // The structured reply ({code, current, findings, ...}) for callers
137
+ // that act on it: push reads a conflict's current version and a
138
+ // refused save's lint findings from here.
139
+ e.reply = err;
140
+ e.code = err?.code;
141
+ reject(e);
142
+ }),
143
+ )
144
+ // A push with no timeout handler is a hang waiting to happen.
145
+ .receive("timeout", done(() => reject(new Error(`${event} timed out after ${timeoutMs}ms`))));
146
+ });
147
+ }
148
+
149
+ close() {
150
+ try { this.channel?.leave(); } catch { /* already gone */ }
151
+ try { this.socket?.disconnect(); } catch { /* already gone */ }
152
+ }
153
+ }
154
+
155
+ // What a refused join means, in words (not the raw reply).
156
+ export function joinRefused(err) {
157
+ const reason = typeof err?.reason === "string" ? err.reason : null;
158
+ if (reason === "unauthorized") return "your login isn't allowed on this Base. Sign in again (`oalo login`), or ask an admin for access.";
159
+ if (reason === "not_a_member") return "your account isn't a member of this workspace. Ask an admin to invite you, then `oalo login`.";
160
+ if (reason) return `your Base refused the connection: ${reason.replace(/_/g, " ")}.`;
161
+ return "your Base refused the connection.";
162
+ }