@commonlyai/cli 0.1.219 → 0.1.221

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.219",
3
+ "version": "0.1.221",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -188,22 +188,58 @@ export const runInboxAck = async (itemId, opts = {}, deps = {}) => {
188
188
  }
189
189
  };
190
190
 
191
+ // `value` is either a 1-based option index (position in the decision's options,
192
+ // shown on refusal) or an option label. The server stores whatever string it is sent, so
193
+ // the label is resolved here against the open queue and only the label is posted:
194
+ // a bare "2" recorded in the ruling would mean nothing to anyone reading it later.
195
+ export const resolveDecisionChoice = (options, value) => {
196
+ const choices = (Array.isArray(options) ? options : [])
197
+ .map((option) => String(option?.label ?? '').trim())
198
+ .filter(Boolean);
199
+ const listing = choices.map((label, i) => ` ${i + 1}. ${label}`).join('\n');
200
+ const raw = String(value).trim();
201
+ if (/^\d+$/.test(raw)) {
202
+ const index = Number(raw);
203
+ const labelled = choices.findIndex((label) => label === raw);
204
+ // A digit-only label is also a position in the list. When the two point at
205
+ // different options, neither reading is safe to guess, so refuse.
206
+ if (labelled !== -1 && labelled !== index - 1) {
207
+ throw new InboxRefusal(`"${raw}" is both option ${labelled + 1}'s label and position ${raw}; refusing to guess. Choose by position (1-${choices.length}) or by a label that is not a number:\n${listing}`);
208
+ }
209
+ if (index < 1 || index > choices.length) {
210
+ throw new InboxRefusal(`option ${raw} is out of range; choose 1-${choices.length}:\n${listing}`);
211
+ }
212
+ return choices[index - 1];
213
+ }
214
+ const match = choices.find((label) => label.toLocaleLowerCase() === raw.toLocaleLowerCase());
215
+ if (!match) throw new InboxRefusal(`"${raw}" is not an option; the options are:\n${listing}`);
216
+ return match;
217
+ };
218
+
191
219
  export const runInboxChoose = async (decisionId, value, opts = {}, deps = {}) => {
192
220
  const {
193
221
  env = process.env, fetchImpl = fetch, log = console.log, error = console.error, sleep = undefined,
194
222
  } = deps;
195
223
  try {
196
- if (!decisionId) throw new InboxRefusal('a decision id is required: commonly inbox choose <decisionId> <value>');
197
- if (value === undefined) throw new InboxRefusal('a value is required: commonly inbox choose <decisionId> <value>');
224
+ if (!decisionId) throw new InboxRefusal('a decision id is required: commonly inbox choose <decisionId> <n|label>');
225
+ if (value === undefined) throw new InboxRefusal('a value is required: commonly inbox choose <decisionId> <n|label>');
198
226
  const {
199
227
  baseUrl, request, account,
200
228
  } = await connect(opts, { env, fetchImpl, sleep });
201
229
  log(formatAccountLine({ account, baseUrl }));
230
+ // The options live on the open queue, not on the decision route, so the
231
+ // choice is resolved against the same queue this account would see in `list`.
232
+ const { items } = await fetchQueue(request);
233
+ const open = items.find((item) => String(item?.id) === String(decisionId) && item?.kind === 'decision');
234
+ if (!open) {
235
+ throw new InboxRefusal(`decision ${decisionId} is not open on this account's queue; nothing to choose`);
236
+ }
237
+ const label = resolveDecisionChoice(open.options, value);
202
238
  const result = await request(`/api/activity/decisions/${encodeURIComponent(decisionId)}/choose`, {
203
239
  method: 'POST',
204
- body: { value },
240
+ body: { value: label },
205
241
  });
206
- log(`chose ${decisionId} = ${value}`);
242
+ log(`chose ${decisionId} = ${label}`);
207
243
  log(`-> ${JSON.stringify(result)}`);
208
244
  return 0;
209
245
  } catch (err) {
@@ -237,7 +273,8 @@ Examples:
237
273
  $ commonly inbox list --cursor-file ~/.commonly/inbox/connector-ops.cursor # only what is new
238
274
  $ commonly inbox list --cursor-file ~/.commonly/inbox/c.cursor --window 300 # widen the re-read to 5m
239
275
  $ commonly inbox ack <attentionItemId>
240
- $ commonly inbox choose <decisionId> 2
276
+ $ commonly inbox choose <decisionId> 2 # second option of the card, by position
277
+ $ commonly inbox choose <decisionId> "Ship it" # or by its label
241
278
 
242
279
  Cost: with no cursor, \`list\` reads the WHOLE queue, one request per 50 items —
243
280
  550 open items is 11 requests. /api/activity is limited to 60 a minute **keyed on
@@ -285,7 +322,7 @@ their attention as runtime events, not from this queue.
285
322
 
286
323
  inbox
287
324
  .command('choose <decisionId> <value>')
288
- .description('Rule a decision card (the source= id from `list`)')
325
+ .description('Rule a decision card (the source= id from `list`). <value> is an option number (1-based) or its label')
289
326
  .option('--token-file <path>', INBOX_TOKEN_HELP)
290
327
  .option('--instance <url>', 'Target Commonly instance')
291
328
  .action(async (decisionId, value, opts) => { process.exit(await runInboxChoose(decisionId, value, opts)); });
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { createClient } from '../lib/api.js';
10
10
  import { getToken, resolveInstanceUrl } from '../lib/config.js';
11
+ import { createTailPoller } from '../lib/pod-tail.js';
11
12
 
12
13
  export const registerPod = (program) => {
13
14
  const pod = program.command('pod').description('Manage pods');
@@ -96,28 +97,16 @@ Commonly instance (see: commonly login --help).
96
97
 
97
98
  const client = createClient({ instance: resolveInstanceUrl(opts.instance), token });
98
99
 
99
- let lastId = null;
100
100
  console.log(`Watching pod ${podId}... (Ctrl+C to stop)\n`);
101
101
 
102
+ const pollNew = createTailPoller({
103
+ fetchPage: (limit) => client.get(`/api/messages/${podId}`, { limit }),
104
+ filter: opts.filter,
105
+ print: (line) => console.log(line),
106
+ });
102
107
  const poll = async () => {
103
108
  try {
104
- const data = await client.get(`/api/messages/${podId}`, {
105
- limit: 20,
106
- after: lastId,
107
- });
108
- const messages = Array.isArray(data) ? data : data.messages || [];
109
-
110
- messages.forEach((m) => {
111
- const isBot = m.isBot || m.sender?.isBot;
112
- if (opts.filter === 'agents' && !isBot) return;
113
- if (opts.filter === 'humans' && isBot) return;
114
-
115
- const ts = new Date(m.createdAt || m.timestamp).toTimeString().slice(0, 8);
116
- const who = m.username || m.sender?.username || '?';
117
- const marker = isBot ? '🤖' : '👤';
118
- console.log(`[${ts}] ${marker} ${who}: ${m.content}`);
119
- lastId = m._id || m.id;
120
- });
109
+ await pollNew();
121
110
  } catch (err) {
122
111
  console.error(`[poll error] ${err.message}`);
123
112
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Live pod tail for `commonly pod tail`.
3
+ *
4
+ * GET /api/messages/:podId takes `before` and `limit` only; it ignores `after`
5
+ * and returns the newest `limit` rows. Polling it with a cursor therefore
6
+ * re-serves the same window every tick. The poller keeps the ids it has
7
+ * already printed and prints only the rest, in the order the page returns
8
+ * them (oldest first).
9
+ */
10
+
11
+ // Ids kept for de-duplication. Old entries fall out first, so a long tail
12
+ // stays bounded in memory.
13
+ export const TAIL_SEEN_MAX = 1000;
14
+ export const TAIL_PAGE_LIMIT = 100;
15
+
16
+ // Same bot field the v2 chat reads (frontend/src/v2/utils/threadView.ts
17
+ // participantOf): `user.isBot`, falling back to the mapped `userId.isBot`.
18
+ export const isAgentMessage = (m) => {
19
+ if (typeof m?.user?.isBot === 'boolean') return m.user.isBot;
20
+ return m?.userId?.isBot === true;
21
+ };
22
+
23
+ const authorName = (m) => m?.user?.username || m?.userId?.username || m?.username || '?';
24
+
25
+ const clock = (m) => {
26
+ const raw = m?.createdAt || m?.timestamp;
27
+ const date = raw ? new Date(raw) : null;
28
+ return date && !Number.isNaN(date.getTime()) ? date.toTimeString().slice(0, 8) : '--:--:--';
29
+ };
30
+
31
+ export const formatTailLine = (m) => {
32
+ const marker = isAgentMessage(m) ? '🤖' : '👤';
33
+ return `[${clock(m)}] ${marker} ${authorName(m)}: ${m?.content ?? ''}`;
34
+ };
35
+
36
+ /**
37
+ * @param {object} opts
38
+ * @param {(limit: number) => Promise<object[] | {messages: object[]}>} opts.fetchPage
39
+ * @param {'all'|'agents'|'humans'} [opts.filter='all']
40
+ * @param {(line: string) => void} opts.print
41
+ */
42
+ export const createTailPoller = ({ fetchPage, filter = 'all', print }) => {
43
+ const seen = new Set();
44
+ const remember = (id) => {
45
+ seen.add(id);
46
+ if (seen.size > TAIL_SEEN_MAX) seen.delete(seen.values().next().value);
47
+ };
48
+
49
+ return async () => {
50
+ const data = await fetchPage(TAIL_PAGE_LIMIT);
51
+ const messages = Array.isArray(data) ? data : data?.messages || [];
52
+ let printed = 0;
53
+
54
+ messages.forEach((m) => {
55
+ const id = String(m?._id || m?.id || '');
56
+ if (id) {
57
+ if (seen.has(id)) return;
58
+ remember(id);
59
+ }
60
+ if (filter === 'agents' && !isAgentMessage(m)) return;
61
+ if (filter === 'humans' && isAgentMessage(m)) return;
62
+ print(formatTailLine(m));
63
+ printed += 1;
64
+ });
65
+
66
+ return printed;
67
+ };
68
+ };