hey-axi 0.2.0 → 0.3.0

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/src/shape.js CHANGED
@@ -1,15 +1,19 @@
1
- // Shape HEY's JSON envelope for agents (AXI principles 2-5 and 9):
2
- // - minimal default fields per list command, `--fields a,b,c` to choose, `--fields all`
1
+ // Shape HEY's JSON for agents (AXI principles 2-5 and 9):
2
+ // - at most four default fields per list command, `--fields a,b,c` to choose, `--fields all`
3
3
  // - long text truncated with its total size, `--full` to get HEY's output untouched
4
- // - a definitive `empty` line for empty lists, `count: N of T total` when HEY knows T
5
- // - HEY's breadcrumbs as `help` lines that name hey-axi commands
4
+ // - every list gets a `count` (`N of T total`, `N total`, or `N shown; more available`)
5
+ // and a definitive `empty` line when it has no rows, enveloped or not
6
+ // - HEY's breadcrumbs as `help` lines that name hey-axi commands and carry the
7
+ // invocation's selectors (--account, --base-url)
6
8
  import { heyToAxi } from "./text.js";
7
9
 
8
10
  // A field is "key" or "key=path", where path is dotted (`creator.name`), may index an
9
11
  // array (`messages.0.creator.name`), may map over one (`recipients.to[].email_address`,
10
12
  // joined with ", "), and may list fallbacks (`name|subject`).
11
- const POSTINGS = ["id", "topic_id", "from=creator.name|sender.name|alternative_sender_name", "subject=name|subject", "seen", "at=active_at|created_at|updated_at"];
12
- const EVENTS = ["id", "title", "starts_at", "ends_at", "calendar=calendar.name"];
13
+ // Box rows keep both ids: `id` (the box item) feeds seen/unseen/move/label, `topic_id`
14
+ // (the thread) feeds thread read/reply/forward. `seen` and `at` are one --fields away.
15
+ const POSTINGS = ["id", "topic_id", "from=creator.name|sender.name|alternative_sender_name", "subject=name|subject"];
16
+ const EVENTS = ["id", "title", "starts_at", "ends_at"];
13
17
  const NAMED = ["id", "name"];
14
18
 
15
19
  export const LIST_FIELDS = {
@@ -24,11 +28,11 @@ export const LIST_FIELDS = {
24
28
  "set-aside view": POSTINGS,
25
29
  "set-aside group view": POSTINGS,
26
30
  "contact threads": POSTINGS,
27
- "bubble list": [...POSTINGS, "bubbles_up=bubble_up_schedule.bubble_up_at"],
31
+ "bubble list": ["id", "topic_id", "subject=name|subject", "bubbles_up=bubble_up_schedule.bubble_up_at"],
28
32
  "box list": ["id", "kind", "name"],
29
- search: ["id", "topic_id", "subject", "from=messages.0.creator.name|creator.name", "at=updated_at|created_at"],
30
- "screener list": ["id", "name", "email_address", "subject", "topic_id"],
31
- "screener history": ["id", "status", "name=name|petitioner.name", "email_address=email_address|petitioner.email_address", "decided=decided|updated_at"],
33
+ search: ["topic_id", "subject", "from=messages.0.creator.name|creator.name", "at=updated_at|created_at"],
34
+ "screener list": ["id", "email_address", "subject", "topic_id"],
35
+ "screener history": ["id", "status", "email_address=email_address|petitioner.email_address", "decided=decided|updated_at"],
32
36
  "event list": EVENTS,
33
37
  "event day": EVENTS,
34
38
  "event week": EVENTS,
@@ -44,7 +48,7 @@ export const LIST_FIELDS = {
44
48
  "attachment list": ["id", "filename", "content_type", "byte_size"],
45
49
  "clip list": ["id", "content", "topic_id", "at=created_at"],
46
50
  "snippet list": ["id", "name", "content"],
47
- "timetrack list": ["id", "starts_at", "ends_at", "category", "notes"],
51
+ "timetrack list": ["id", "starts_at", "ends_at", "category"],
48
52
  "timetrack categories": ["id", "title"],
49
53
  "account list": ["id", "email=email|email_address", "name", "active"],
50
54
  "set-aside group list": ["id", "thread_count"],
@@ -88,12 +92,15 @@ export function parseField(spec) {
88
92
  return index === -1 ? { key: spec, path: spec } : { key: spec.slice(0, index), path: spec.slice(index + 1) };
89
93
  }
90
94
 
91
- // Fields for a list without a declared schema: up to five familiar keys.
95
+ export const MAX_LIST_FIELDS = 4;
96
+
97
+ // Fields for a list without a declared schema: up to four familiar keys, or the first
98
+ // four keys when none of the familiar ones are there.
92
99
  function heuristicFields(items) {
93
- const keys = new Set(items.flatMap((item) => (item && typeof item === "object" ? Object.keys(item) : [])));
94
- if (keys.size <= 5) return null;
95
- const picked = HEURISTIC_KEYS.filter((key) => keys.has(key)).slice(0, 5);
96
- return picked.length >= 2 ? picked : null;
100
+ const keys = [...new Set(items.flatMap((item) => (item && typeof item === "object" ? Object.keys(item) : [])))];
101
+ if (keys.length <= MAX_LIST_FIELDS) return null;
102
+ const picked = HEURISTIC_KEYS.filter((key) => keys.includes(key)).slice(0, MAX_LIST_FIELDS);
103
+ return picked.length >= 2 ? picked : keys.slice(0, MAX_LIST_FIELDS);
97
104
  }
98
105
 
99
106
  // Project items onto fields. Returns { rows, unknown } where unknown lists requested
@@ -127,15 +134,22 @@ export function truncateDeep(value, limit, onTruncate, list = false) {
127
134
  }
128
135
 
129
136
  const isObjectList = (value) => Array.isArray(value) && value.every((item) => item && typeof item === "object" && !Array.isArray(item));
130
- const DROP_CONTAINER_KEY = (key) => /(^|_)url$|^signed_stream_name$/.test(key);
137
+ // URLs and paging fields (summarized by `count` and the "see the rest" help) are left out.
138
+ const DROP_CONTAINER_KEY = (key) => /(^|_)url$|^signed_stream_name$|^(total_count|next_page|next_cursor|has_more)$/.test(key);
131
139
 
132
- // Find the collection in HEY's `data`: the array itself, or the one array of objects
133
- // inside a container object (e.g. a box with its postings).
140
+ // Find the collection in HEY's `data`: the array itself (of objects or of plain
141
+ // values), the one array of objects inside a container object (e.g. a box with its
142
+ // postings), or, when a container holds several, all of them (`lists`).
134
143
  export function findList(data) {
135
144
  if (isObjectList(data)) return { list: data };
136
- if (data && typeof data === "object" && !Array.isArray(data)) {
145
+ if (Array.isArray(data)) return { list: data, primitive: true };
146
+ if (data && typeof data === "object") {
137
147
  const keys = Object.keys(data).filter((key) => isObjectList(data[key]));
138
148
  if (keys.length === 1) return { list: data[keys[0]], key: keys[0], container: data };
149
+ if (keys.length > 1) return { lists: keys.map((key) => ({ key, list: data[key] })), container: data, list: keys.flatMap((key) => data[key]) };
150
+ // One list of plain values next to other fields ({items: ["a","b"], total_count: 40}).
151
+ const plain = Object.keys(data).filter((key) => Array.isArray(data[key]));
152
+ if (keys.length === 0 && plain.length === 1) return { list: data[plain[0]], key: plain[0], container: data, primitive: true };
139
153
  }
140
154
  return null;
141
155
  }
@@ -170,6 +184,38 @@ export function shapeData(data, { path, fields = null }) {
170
184
  if (!found) {
171
185
  return { data: truncateDeep(data, DETAIL_TEXT_LIMIT, mark), truncated, empty: false };
172
186
  }
187
+ if (found.primitive && !found.container) {
188
+ return { data: truncateDeep(found.list, LIST_TEXT_LIMIT, mark, true), truncated, empty: found.list.length === 0 };
189
+ }
190
+ if (found.primitive) {
191
+ const shaped = {};
192
+ for (const [name, value] of Object.entries(found.container)) {
193
+ if (name === found.key) shaped[name] = truncateDeep(value, LIST_TEXT_LIMIT, mark, true);
194
+ else if (fields === "all" || !DROP_CONTAINER_KEY(name)) shaped[name] = truncateDeep(value, DETAIL_TEXT_LIMIT, mark);
195
+ }
196
+ return { data: shaped, truncated, empty: found.list.length === 0 };
197
+ }
198
+ if (found.lists) {
199
+ // Several collections side by side: each gets the minimal schema of its own items.
200
+ const shaped = {};
201
+ for (const [name, value] of Object.entries(found.container)) {
202
+ const entry = found.lists.find((item) => item.key === name);
203
+ if (entry && Array.isArray(fields) && fields.length) {
204
+ // Explicit fields must exist in at least one of the lists.
205
+ const unknown = fields.filter((field) => !found.lists.some(({ list }) => project(list, [field], { strict: false }).rows.some((row) => Object.keys(row).length)));
206
+ if (unknown.length) throw new FieldError(unknown, [...new Set(found.lists.flatMap(({ list }) => list.slice(0, 5).flatMap((item) => Object.keys(item || {}))))]);
207
+ }
208
+ if (entry) {
209
+ const specs = fields === "all" ? null : Array.isArray(fields) && fields.length ? fields : heuristicFields(entry.list);
210
+ const rows = specs ? project(entry.list, specs).rows : entry.list;
211
+ shaped[name] = truncateDeep(rows, specs ? LIST_TEXT_LIMIT : DETAIL_TEXT_LIMIT, mark, Boolean(specs));
212
+ } else if (fields === "all" || !DROP_CONTAINER_KEY(name)) {
213
+ // Content next to the lists is kept (long text cut with its size), never dropped.
214
+ shaped[name] = truncateDeep(value, DETAIL_TEXT_LIMIT, mark);
215
+ }
216
+ }
217
+ return { data: shaped, truncated, empty: found.list.length === 0 };
218
+ }
173
219
 
174
220
  const { list, key, container } = found;
175
221
  let rows = list;
@@ -197,7 +243,8 @@ export function shapeData(data, { path, fields = null }) {
197
243
  shaped = {};
198
244
  for (const [name, value] of Object.entries(container)) {
199
245
  if (name === key) shaped[name] = rows;
200
- else if (fields === "all" || (!DROP_CONTAINER_KEY(name) && (value === null || typeof value !== "object"))) {
246
+ else if (fields === "all" || !DROP_CONTAINER_KEY(name)) {
247
+ // Content next to the list is kept (long text cut with its size), never dropped.
201
248
  shaped[name] = truncateDeep(value, DETAIL_TEXT_LIMIT, mark);
202
249
  }
203
250
  }
@@ -205,47 +252,131 @@ export function shapeData(data, { path, fields = null }) {
205
252
  return { data: shaped, truncated, empty: list.length === 0 };
206
253
  }
207
254
 
208
- function breadcrumbHelp(crumb) {
209
- if (typeof crumb === "string") return heyToAxi(crumb);
255
+ const MORE_NOTICE = /more (are )?available|more results|use --all|--page/i;
256
+ const TOTAL_NOTICE = /\b\d[\d,]* of (\d[\d,]*)\b/;
257
+
258
+ // What HEY told us about the size of a list: { shown, total?, more, next? }. Sources, in
259
+ // order: meta.total_count, a "Showing N of T" notice, and HEY's paging signals
260
+ // (next_page, has_more, a "more available" notice). With none of them the list is complete.
261
+ export function listSize(envelope, shown, container = null) {
262
+ // HEY puts paging in meta, or (box and other postings listings) next to the list in data.
263
+ const ownMeta = envelope && typeof envelope.meta === "object" && envelope.meta ? envelope.meta : {};
264
+ const side = container && typeof container === "object" ? Object.fromEntries(["total_count", "next_page", "next_cursor", "has_more"].filter((key) => key in container).map((key) => [key, container[key]])) : {};
265
+ const meta = { ...side, ...ownMeta };
266
+ if (typeof meta.total_count === "number" && meta.total_count < shown) delete meta.total_count;
267
+ const next = meta.next_page ?? meta.next_cursor ?? null;
268
+ const noticed = String(envelope?.notice || "").match(TOTAL_NOTICE);
269
+ const total = typeof meta.total_count === "number" ? meta.total_count : noticed ? Number(noticed[1].replace(/,/g, "")) : undefined;
270
+ const more = total !== undefined ? total > shown : Boolean(next || meta.has_more || MORE_NOTICE.test(envelope?.notice || ""));
271
+ // Complete only when HEY says so: a total, has_more: false, or a null next_page.
272
+ const complete = total !== undefined ? !more : !more && (meta.has_more === false || ("next_page" in meta && meta.next_page === null) || ("next_cursor" in meta && meta.next_cursor === null));
273
+ return { shown, total, more, next, complete };
274
+ }
275
+
276
+ // `count: 25 of 847 total` / `count: 8 total` / `count: 25 shown; more available` /
277
+ // `count: 25 shown; no more pages reported` (no total and no explicit end signal:
278
+ // hey-axi does not claim the list is complete).
279
+ export function countLine({ shown, total, more, complete, all = false }) {
280
+ if (total !== undefined) return `${shown} of ${total} total`;
281
+ if (more) return `${shown} shown; more available`;
282
+ return complete || all || shown === 0 ? `${shown} total` : `${shown} shown; no more pages reported`;
283
+ }
284
+
285
+ // Append the invocation's selectors (`--account 2`) to a suggested command, unless the
286
+ // command already names them.
287
+ export function withSelectors(command, carry = []) {
288
+ const extra = [];
289
+ for (let i = 0; i < carry.length; i += 2) {
290
+ if (!command.includes(carry[i])) extra.push(carry[i], carry[i + 1]);
291
+ }
292
+ return extra.length ? `${command} ${extra.join(" ")}` : command;
293
+ }
294
+
295
+ // Carry the selectors into every `hey-axi …` command quoted inside a text (a hint, a
296
+ // string breadcrumb, an error's help line).
297
+ export function carrySelectors(text, carry = []) {
298
+ if (typeof text !== "string" || !carry.length) return text;
299
+ if (/^hey-axi \S/.test(text) && !/[`'"]/.test(text)) return withSelectors(text, carry);
300
+ // `…` may contain quotes ("<query>"); '…' and "…" may not contain their own quote.
301
+ let out = text.replace(/`(hey-axi [^`]+)`/g, (match, command) => `\`${withSelectors(command, carry)}\``);
302
+ out = out.replace(/(^|[^`\w])(['"])(hey-axi [^'"`]+?)\2/g, (match, lead, quote, command) => `${lead}${quote}${withSelectors(command, carry)}${quote}`);
303
+ // Unquoted "Run hey-axi …" to the end of the sentence.
304
+ return out.replace(/(\bRun:? )(hey-axi [^`'".;,\n]+?)(?=[.;,]?\s*$|[.;,]\s)/g, (match, run, command) => `${run}${withSelectors(command.trim(), carry)}`);
305
+ }
306
+
307
+ function breadcrumbHelp(crumb, carry) {
308
+ if (typeof crumb === "string") return carrySelectors(heyToAxi(crumb), carry);
210
309
  if (!crumb || typeof crumb !== "object" || !crumb.command) return null;
211
310
  const description = crumb.description ? ` to ${crumb.description.charAt(0).toLowerCase()}${crumb.description.slice(1)}` : "";
212
- return `Run \`${heyToAxi(crumb.command)}\`${description}`;
311
+ return `Run \`${withSelectors(heyToAxi(crumb.command), carry)}\`${description}`;
213
312
  }
214
313
 
215
- // Shape a whole HEY envelope. options: { path, fields, commandLine }.
216
- // Returns the object to print.
217
- export function shapeEnvelope(envelope, { path, fields = null, commandLine }) {
218
- if (!envelope || typeof envelope !== "object" || Array.isArray(envelope) || !("data" in envelope)) {
219
- // Not an envelope (some commands answer with a bare object): just keep it short.
220
- let truncated = false;
221
- const data = truncateDeep(envelope, DETAIL_TEXT_LIMIT, () => { truncated = true; });
222
- if (!truncated) return data;
223
- const help = [`Run \`${commandLine} --full\` to see complete content`];
224
- return data && typeof data === "object" && !Array.isArray(data) ? { ...data, help } : { data, help };
225
- }
314
+ // The help line that reveals the rest of a truncated list (AXI principle 9).
315
+ // The command line without one flag and its value (`--page 1`, `--page=1`).
316
+ function dropFlag(commandLine, name) {
317
+ return commandLine.replace(new RegExp(`\\s--${name}(=\\S+|\\s+(?!-)\\S+)?(?=\\s|$)`, "g"), "");
318
+ }
226
319
 
227
- const { data, truncated, empty } = shapeData(envelope.data, { path, fields });
320
+ export function moreHelp(size, { commandLine: line, pageFlags = [] }) {
321
+ if (!size.more) return null;
322
+ // The hint replaces paging flags already on the line instead of repeating them.
323
+ const commandLine = dropFlag(dropFlag(line, "page"), "all");
324
+ const all = size.total !== undefined ? `all ${size.total}` : "all of them";
325
+ if (pageFlags.includes("all")) return `Run \`${commandLine} --all\` for ${all}`;
326
+ if (size.next && pageFlags.includes("page")) return `Run \`${commandLine} --page ${size.next}\` for the next page`;
327
+ if (pageFlags.includes("limit")) return `Run \`${dropFlag(commandLine, "limit")} --limit <n>\` to show more`;
328
+ return null;
329
+ }
330
+
331
+ // Shape whatever HEY printed. options: { path, fields, commandLine, carry, pageFlags, quiet }.
332
+ // carry: selector args from the invocation (["--account", "2"]) for suggested commands
333
+ // pageFlags: which of all/page/limit the command takes, for the "see the rest" hint
334
+ // quiet: drop HEY's summary, notice, breadcrumbs and meta; keep hey-axi's count,
335
+ // empty state and --full/--all hints
336
+ // Returns the object to print.
337
+ export function shapeEnvelope(envelope, { path, fields = null, commandLine, carry = [], pageFlags = [], quiet = false, all = false }) {
338
+ const enveloped = envelope && typeof envelope === "object" && !Array.isArray(envelope) && "data" in envelope;
339
+ const body = enveloped ? envelope.data : envelope;
340
+ const { data, truncated, empty } = shapeData(body, { path, fields });
341
+ const found = findList(body);
228
342
  const out = {};
229
- if (envelope.ok !== undefined) out.ok = envelope.ok;
230
- if (envelope.summary) out.summary = envelope.summary;
231
- const meta = envelope.meta && typeof envelope.meta === "object" ? { ...envelope.meta } : null;
232
- if (meta && typeof meta.total_count === "number") {
233
- const found = findList(envelope.data);
234
- const shown = found ? found.list.length : 0;
235
- out.count = `${shown} of ${meta.total_count} total`;
236
- delete meta.total_count;
237
- }
238
- if (meta) delete meta.pages_fetched;
343
+ if (enveloped && envelope.ok !== undefined) out.ok = envelope.ok;
344
+ if (enveloped && envelope.summary && !quiet) out.summary = envelope.summary;
345
+ const size = found && !found.lists ? listSize(enveloped ? envelope : null, found.list.length, found.container) : null;
346
+ if (size) out.count = countLine({ ...size, all });
347
+ let multiSize = null;
348
+ if (found?.lists) {
349
+ // Several lists: each one's length, plus whatever HEY says about the whole result.
350
+ multiSize = listSize(enveloped ? envelope : null, found.list.length, found.container);
351
+ const parts = found.lists.map(({ key, list }) => `${list.length} ${key}`).join(", ");
352
+ out.count = multiSize.total !== undefined ? `${parts} (${multiSize.total} total)` : multiSize.more ? `${parts}; more available` : parts;
353
+ }
354
+ const nothing = body === null || body === undefined || (typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0);
355
+ if (!enveloped && !found && !truncated && !nothing) return data;
239
356
  out.data = data;
240
- if (empty) out.empty = `0 results for \`${commandLine}\``;
241
- for (const [key, value] of Object.entries(envelope)) {
242
- if (["ok", "data", "summary", "meta", "breadcrumbs", "notice"].includes(key)) continue;
243
- out[key] = value;
357
+ const reported = size || multiSize;
358
+ if (empty && reported && (reported.more || reported.total > 0)) {
359
+ // An empty page while HEY reports more is not "nothing".
360
+ out.empty = `0 results on this page for \`${commandLine}\`; HEY reports ${reported.total > 0 ? `${reported.total} in total` : "more"}`;
361
+ } else if (empty) out.empty = `0 results for \`${commandLine}\``;
362
+ else if (nothing && !(enveloped && envelope.summary)) out.empty = `no data returned for \`${commandLine}\``;
363
+ const help = [];
364
+ if (enveloped && !quiet) {
365
+ for (const [key, value] of Object.entries(envelope)) {
366
+ if (["ok", "data", "summary", "meta", "breadcrumbs", "notice"].includes(key)) continue;
367
+ out[key] = value;
368
+ }
369
+ if (envelope.notice) out.notice = carrySelectors(heyToAxi(envelope.notice), carry);
370
+ help.push(...(Array.isArray(envelope.breadcrumbs) ? envelope.breadcrumbs : []).map((crumb) => breadcrumbHelp(crumb, carry)).filter(Boolean));
244
371
  }
245
- if (envelope.notice) out.notice = heyToAxi(envelope.notice);
246
- const help = (Array.isArray(envelope.breadcrumbs) ? envelope.breadcrumbs : []).map(breadcrumbHelp).filter(Boolean);
372
+ const more = (size || multiSize) && moreHelp(size || multiSize, { commandLine, pageFlags });
373
+ if (more) help.push(more);
247
374
  if (truncated) help.push(`Run \`${commandLine} --full\` to see complete content`);
248
375
  if (help.length) out.help = help;
249
- if (meta && Object.keys(meta).length) out.meta = meta;
376
+ if (enveloped && !quiet && envelope.meta && typeof envelope.meta === "object") {
377
+ const meta = { ...envelope.meta };
378
+ for (const key of ["total_count", "pages_fetched", "next_page", "next_cursor", "has_more"]) delete meta[key];
379
+ if (Object.keys(meta).length) out.meta = meta;
380
+ }
250
381
  return out;
251
382
  }
package/src/text.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // Small text helpers shared by output, help and errors.
2
2
 
3
3
  // Point command suggestions at hey-axi instead of the wrapped CLI: "hey thread read 1"
4
- // becomes "hey-axi thread read 1". Only `hey` followed by a lowercase command word is
5
- // rewritten, so prose ("Hey there", "hey.com") is left alone.
4
+ // becomes "hey-axi thread read 1". Only `hey` followed by a lowercase command word or a
5
+ // flag is rewritten, so prose ("Hey there", "hey.com") is left alone.
6
6
  export function heyToAxi(text) {
7
7
  if (typeof text !== "string") return text;
8
- return text.replace(/(^|[\s`'"(])hey (?=[a-z][a-z-]*)/g, "$1hey-axi ");
8
+ return text.replace(/(^|[\s`'"(])hey (?=[a-z][a-z-]*|--?[a-z])/g, "$1hey-axi ");
9
9
  }
10
10
 
11
11
  // Quote an argv word for display in a suggested command line.
package/src/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Leaf module: no imports, so `hey-axi --version` answers before the CLI graph loads.
2
2
  // test/version.test.js keeps this in sync with package.json.
3
- export const VERSION = "0.2.0";
3
+ export const VERSION = "0.3.0";