@dench.com/cli 3.9.0-staging.417 → 3.10.0-staging.421

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/crm-comments.ts CHANGED
@@ -488,8 +488,8 @@ export async function runActivityCommand(ctx: CrmCliContext): Promise<void> {
488
488
  ctx,
489
489
  makeFunctionReference<"query">("functions/crm/entries:get"),
490
490
  { objectName, entryId },
491
- )) as { data?: Record<string, unknown> } | null;
492
- const raw = entry?.data?.Notes ?? entry?.data?.notes;
491
+ )) as { fields?: Record<string, unknown> } | null;
492
+ const raw = entry?.fields?.Notes ?? entry?.fields?.notes;
493
493
  notes = typeof raw === "string" && raw.trim() ? raw : null;
494
494
  }
495
495
 
package/crm.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  parseLimit as parseLimitRaw,
56
56
  shift as shiftRaw,
57
57
  } from "./lib/cli-args";
58
+ import { serializeCompactCrmEntry } from "./lib/crm-compact-reads";
58
59
  import {
59
60
  autoDetectInputField,
60
61
  detectEnrichmentCategory,
@@ -2777,6 +2778,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
2777
2778
  // `--with-context` appends the record's ledger head (newest first) so
2778
2779
  // one call answers "what is going on here" — the agent's default read.
2779
2780
  const withContext = hasFlag(ctx.args, "--with-context");
2781
+ const compact = hasFlag(ctx.args, "--compact");
2780
2782
  assertNoUnknownFlags(ctx.args, "crm entries get");
2781
2783
  const entry = withCrmEntryDeepLinks(
2782
2784
  ctx,
@@ -2786,8 +2788,20 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
2786
2788
  entryId: entryId as any,
2787
2789
  }),
2788
2790
  );
2791
+ const printEntry = (value: unknown) => {
2792
+ if (compact) {
2793
+ // Normalize references before measuring the exact emitted JSON. No
2794
+ // pretty-printer or URL expansion may grow the bounded result later.
2795
+ console.log(
2796
+ serializeCompactCrmEntry(
2797
+ withWorkspaceReferenceEnvelopeUrls(value, ctx),
2798
+ { objectName, entryId, withContext },
2799
+ ),
2800
+ );
2801
+ } else outWithReferences(ctx, value);
2802
+ };
2789
2803
  if (!withContext) {
2790
- outWithReferences(ctx, entry);
2804
+ printEntry(entry);
2791
2805
  return;
2792
2806
  }
2793
2807
  const [activity, notes, brief] = await Promise.all([
@@ -2811,7 +2825,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
2811
2825
  { objectName, entryId },
2812
2826
  ),
2813
2827
  ]);
2814
- outWithReferences(ctx, {
2828
+ printEntry({
2815
2829
  ...(entry as Record<string, unknown>),
2816
2830
  context: {
2817
2831
  brief,
@@ -5054,7 +5068,9 @@ Entries (rows):
5054
5068
  not a readable or assigned total. Continue only when continueCursor is returned, preserving filters.
5055
5069
  Scoped reads may be truncated with no cursor (hasMore: null); repeating the same call will not advance.
5056
5070
  Assigned metadata pages cap at 100 rows; other list reads cap at 1000.
5057
- dench crm entries get <object> <entryId> [--with-context] (--with-context appends the newest activity + notes)
5071
+ dench crm entries get <object> <entryId> [--with-context] [--compact] [--json]
5072
+ --with-context appends the Brief, newest activity and notes. --compact preserves identity,
5073
+ source handles and coverage in at most 14,000 UTF-8 bytes, with clipping metadata and a raw reread command.
5058
5074
  dench crm entries sync-history <object> <entryId> [--status]
5059
5075
  Queue a full, paged pull of the record's email history from every
5060
5076
  connected mailbox (or read the sync ledger with --status). Opening the
@@ -0,0 +1,519 @@
1
+ /** Portable, opt-in presentation of an already authorized record read. */
2
+ export const COMPACT_CRM_ENTRY_BYTES = 14_000;
3
+ const encoder = new TextEncoder();
4
+ type Row = Record<string, unknown>;
5
+ const row = (value: unknown): Row | undefined =>
6
+ value !== null && typeof value === "object" && !Array.isArray(value)
7
+ ? (value as Row)
8
+ : undefined;
9
+ const pick = (value: Row | undefined, keys: string[]): Row =>
10
+ Object.fromEntries(
11
+ keys.flatMap((key) =>
12
+ value?.[key] === undefined ? [] : [[key, value[key]]],
13
+ ),
14
+ );
15
+ const bytes = (value: string) => encoder.encode(value).byteLength;
16
+ const serialized = (value: unknown) => JSON.stringify(value);
17
+ const fits = (value: unknown, reserve = 0) =>
18
+ bytes(serialized(value)) + 1 + reserve <= COMPACT_CRM_ENTRY_BYTES;
19
+
20
+ function clip(text: string, limit: number): string {
21
+ if (bytes(text) <= limit) return text;
22
+ let result = "";
23
+ let size = 3; // the ellipsis
24
+ for (const point of text) {
25
+ size += bytes(point);
26
+ if (size > limit) break;
27
+ result += point;
28
+ }
29
+ return `${result}…`;
30
+ }
31
+
32
+ /** Only recognized editor documents are decoded; ordinary JSON stays literal. */
33
+ function richText(value: unknown, omitted: () => void): unknown {
34
+ let document = row(value);
35
+ if (typeof value === "string" && value.trimStart().startsWith("{")) {
36
+ try {
37
+ document = row(JSON.parse(value));
38
+ } catch {
39
+ return value;
40
+ }
41
+ }
42
+ if (document?.type !== "doc" || !Array.isArray(document.content))
43
+ return value;
44
+ const parts: string[] = [];
45
+ let textBytes = 0;
46
+ let visited = 0;
47
+ let stopped = false;
48
+ const append = (text: string) => {
49
+ const room = 16_000 - textBytes;
50
+ if (bytes(text) > room) {
51
+ omitted();
52
+ stopped = true;
53
+ text = room >= 3 ? clip(text, room) : "";
54
+ }
55
+ parts.push(text);
56
+ textBytes += bytes(text);
57
+ };
58
+ // Iterator frames avoid spread argument limits and do not copy arbitrary
59
+ // child arrays. Both malformed and valid editor documents have bounded work.
60
+ const pending = [
61
+ { nodes: document.content as unknown[], index: 0, after: "" },
62
+ ];
63
+ while (pending.length && !stopped) {
64
+ const frame = pending[pending.length - 1];
65
+ if (frame.index >= frame.nodes.length) {
66
+ pending.pop();
67
+ append(frame.after);
68
+ continue;
69
+ }
70
+ if (++visited > 10_000) {
71
+ omitted();
72
+ break;
73
+ }
74
+ const next = frame.nodes[frame.index++];
75
+ const node = row(next);
76
+ if (!node) {
77
+ omitted();
78
+ continue;
79
+ }
80
+ if (typeof node.text === "string") {
81
+ const link = Array.isArray(node.marks)
82
+ ? node.marks.map(row).find((mark) => mark?.type === "link")
83
+ : undefined;
84
+ const href = row(link?.attrs)?.href;
85
+ append(typeof href === "string" ? `[${node.text}](${href})` : node.text);
86
+ } else if (node.type === "hardBreak") append("\n");
87
+ else if (node.type === "horizontalRule") append("\n---\n");
88
+ else if (
89
+ node.type === "image" &&
90
+ typeof row(node.attrs)?.src === "string"
91
+ ) {
92
+ const attrs = row(node.attrs);
93
+ append(
94
+ `[${typeof attrs?.alt === "string" ? attrs.alt : "Image"}](${attrs?.src})`,
95
+ );
96
+ } else if (
97
+ node.type === "mention" &&
98
+ typeof row(node.attrs)?.label === "string"
99
+ ) {
100
+ append(String(row(node.attrs)?.label));
101
+ } else if (
102
+ node.type === "fileAttachment" &&
103
+ typeof row(node.attrs)?.path === "string"
104
+ ) {
105
+ const attrs = row(node.attrs);
106
+ append(
107
+ `[${typeof attrs?.name === "string" ? attrs.name : "Attachment"}](${attrs?.path})`,
108
+ );
109
+ } else if (!Array.isArray(node.content)) {
110
+ omitted();
111
+ append("[Unsupported editor content]");
112
+ }
113
+ if (Array.isArray(node.content)) {
114
+ pending.push({
115
+ nodes: node.content,
116
+ index: 0,
117
+ after: ["paragraph", "heading", "listItem", "tableRow"].includes(
118
+ String(node.type),
119
+ )
120
+ ? "\n"
121
+ : "",
122
+ });
123
+ }
124
+ }
125
+ return parts
126
+ .join("")
127
+ .replace(/\n{3,}/g, "\n\n")
128
+ .trim();
129
+ }
130
+
131
+ const shellWord = (value: string) =>
132
+ /^[a-zA-Z0-9_./:@-]+$/.test(value)
133
+ ? value
134
+ : `'${value.replaceAll("'", "'\\''")}'`;
135
+
136
+ /** Input must already contain its final URLs/reference envelope. The returned
137
+ * string is the exact stdout payload; callers append only console.log's newline. */
138
+ export function serializeCompactCrmEntry(
139
+ value: unknown,
140
+ options: { objectName: string; entryId: string; withContext: boolean },
141
+ ): string {
142
+ const entry = row(value);
143
+ if (!entry)
144
+ return serialized(
145
+ value == null
146
+ ? null
147
+ : {
148
+ ok: false,
149
+ error: "compact_invalid_record",
150
+ message: "Expected a CRM entry object.",
151
+ },
152
+ );
153
+ const objectName =
154
+ typeof entry.objectName === "string"
155
+ ? entry.objectName
156
+ : options.objectName;
157
+ const entryId = typeof entry.id === "string" ? entry.id : options.entryId;
158
+ const rawReadCommand = `dench crm entries get ${shellWord(objectName)} ${shellWord(entryId)}${options.withContext ? " --with-context" : ""} --json`;
159
+ const metadata = {
160
+ version: 1,
161
+ truncated: false,
162
+ clippedPaths: [] as string[],
163
+ additionalClippedPaths: 0,
164
+ omittedCounts: {
165
+ fields: 0,
166
+ activityItems: 0,
167
+ notes: 0,
168
+ values: 0,
169
+ duplicateNotes: 0,
170
+ },
171
+ rawReadCommand,
172
+ };
173
+ const paths = new Set<string>();
174
+ const changed = (path: string) => {
175
+ metadata.truncated = true;
176
+ if (paths.has(path)) return;
177
+ paths.add(path);
178
+ if (metadata.clippedPaths.length < 16)
179
+ metadata.clippedPaths.push(clip(path, 96));
180
+ else metadata.additionalClippedPaths += 1;
181
+ };
182
+ const pointer = (key: string) =>
183
+ key.replaceAll("~", "~0").replaceAll("/", "~1");
184
+ const bounded = (
185
+ input: unknown,
186
+ path: string,
187
+ limit = 800,
188
+ depth = 0,
189
+ ): unknown => {
190
+ const text = richText(input, () => changed(path));
191
+ if (typeof text === "string") {
192
+ const result = clip(text, limit);
193
+ if (result !== text) changed(path);
194
+ return result;
195
+ }
196
+ if (depth >= 3 && (Array.isArray(text) || row(text))) {
197
+ changed(path);
198
+ metadata.omittedCounts.values += 1;
199
+ return null;
200
+ }
201
+ if (Array.isArray(text)) {
202
+ if (text.length > 8) {
203
+ changed(path);
204
+ metadata.omittedCounts.values += text.length - 8;
205
+ }
206
+ return text
207
+ .slice(0, 8)
208
+ .map((item, index) =>
209
+ bounded(item, `${path}/${index}`, limit, depth + 1),
210
+ );
211
+ }
212
+ const object = row(text);
213
+ if (!object) return text;
214
+ const entries = Object.entries(object);
215
+ if (entries.length > 8) {
216
+ changed(path);
217
+ metadata.omittedCounts.values += entries.length - 8;
218
+ }
219
+ return Object.fromEntries(
220
+ entries
221
+ .slice(0, 8)
222
+ .map(([key, item]) => [
223
+ key,
224
+ bounded(item, `${path}/${pointer(key)}`, limit, depth + 1),
225
+ ]),
226
+ );
227
+ };
228
+ const displayText = (
229
+ input: Row | undefined,
230
+ path: string,
231
+ keys: string[],
232
+ ) => {
233
+ if (!input) return input;
234
+ const result = { ...input };
235
+ for (const key of keys) {
236
+ if (typeof input[key] === "string")
237
+ result[key] = bounded(input[key], `${path}/${key}`, 512);
238
+ }
239
+ return result;
240
+ };
241
+ const actor = (input: unknown, path: string) =>
242
+ displayText(
243
+ pick(row(input), ["kind", "displayName", "label", "userId", "isDench"]),
244
+ path,
245
+ ["displayName", "label"],
246
+ );
247
+ const fields: Row = Object.create(null);
248
+ const output: Row = {
249
+ ...pick(entry, [
250
+ "id",
251
+ "objectId",
252
+ "objectName",
253
+ "href",
254
+ "url",
255
+ "createdAt",
256
+ "updatedAt",
257
+ "calculationsTruncated",
258
+ "references",
259
+ "referenceCoverage",
260
+ ]),
261
+ fields,
262
+ compact: metadata,
263
+ };
264
+ const context = row(entry.context);
265
+ const brief = row(context?.brief);
266
+ const shared = row(brief?.shared);
267
+ const briefOutput: Row = {
268
+ ...pick(brief, ["coverage", "hasMaterial", "enabled"]),
269
+ shared: shared
270
+ ? pick(shared, ["status", "stale", "generatedAt", "checkedAt"])
271
+ : null,
272
+ };
273
+ const activity = row(context?.recentActivity);
274
+ const items: Row[] = [];
275
+ const notes: Row[] = [];
276
+ if (context)
277
+ output.context = {
278
+ brief: briefOutput,
279
+ recentActivity: {
280
+ ...pick(activity, ["hasMore", "contextLimited", "nextBoundary"]),
281
+ items,
282
+ },
283
+ notes,
284
+ };
285
+ const tooLarge = () => {
286
+ const error = {
287
+ ok: false,
288
+ error: "compact_metadata_too_large",
289
+ message:
290
+ "Identity, references or coverage exceed the compact read budget. Use the raw read.",
291
+ rawReadCommand,
292
+ };
293
+ // Do not echo arbitrarily large identifiers in the exceptional error either.
294
+ return serialized(
295
+ fits(error)
296
+ ? error
297
+ : { ok: false, error: error.error, message: error.message },
298
+ );
299
+ };
300
+ if (!fits(output)) return tooLarge();
301
+ type Group = "fields" | "activityItems" | "notes" | "values";
302
+ const accepted: Array<{ remove: () => void; group: Group; path: string }> =
303
+ [];
304
+ const add = (
305
+ apply: () => void,
306
+ remove: () => void,
307
+ group: Group,
308
+ path: string,
309
+ ) => {
310
+ apply();
311
+ if (fits(output, 1800)) {
312
+ accepted.push({ remove, group, path });
313
+ return true;
314
+ }
315
+ remove();
316
+ metadata.omittedCounts[group] += 1;
317
+ changed(path);
318
+ return false;
319
+ };
320
+ const fieldEntries = Object.entries(row(entry.fields) ?? {});
321
+ const short = ([, field]: [string, unknown]) =>
322
+ bytes(serialized(field)) <= 256;
323
+ const addField = ([name, field]: [string, unknown]) => {
324
+ const path = `/fields/${pointer(name)}`;
325
+ const projected = bounded(field, path, 1200);
326
+ add(
327
+ () => {
328
+ fields[name] = projected;
329
+ },
330
+ () => {
331
+ delete fields[name];
332
+ },
333
+ "fields",
334
+ path,
335
+ );
336
+ };
337
+ fieldEntries.filter(short).forEach(addField);
338
+ const includedNotes = new Set<string>();
339
+ const sourceItems = Array.isArray(activity?.items) ? activity.items : [];
340
+ sourceItems.forEach((raw, index) => {
341
+ const item = row(raw);
342
+ if (!item) return;
343
+ const path = `/context/recentActivity/items/${index}`;
344
+ const projected = pick(item, ["type", "key", "at"]);
345
+ const thread = row(item.thread);
346
+ const comment = row(item.comment);
347
+ const history = row(item.row);
348
+ if (thread) {
349
+ const digest = row(thread.digest);
350
+ const open = row(thread.open);
351
+ projected.thread = {
352
+ ...displayText(
353
+ pick(thread, [
354
+ "threadKey",
355
+ "kind",
356
+ "title",
357
+ "latestAt",
358
+ "firstAt",
359
+ "messageCount",
360
+ "messageCountCapped",
361
+ "direction",
362
+ "role",
363
+ "latestFrom",
364
+ ]),
365
+ `${path}/thread`,
366
+ ["title", "latestFrom"],
367
+ ),
368
+ ...(thread.context
369
+ ? {
370
+ context: displayText(
371
+ row(thread.context),
372
+ `${path}/thread/context`,
373
+ ["label"],
374
+ ),
375
+ }
376
+ : {}),
377
+ ...(Array.isArray(thread.participants)
378
+ ? {
379
+ participants: thread.participants.map((participant, index) =>
380
+ displayText(
381
+ row(participant),
382
+ `${path}/thread/participants/${index}`,
383
+ ["label"],
384
+ ),
385
+ ),
386
+ }
387
+ : {}),
388
+ ...(open
389
+ ? {
390
+ open: {
391
+ ...open,
392
+ ...(open.emailThread
393
+ ? {
394
+ emailThread: displayText(
395
+ row(open.emailThread),
396
+ `${path}/thread/open/emailThread`,
397
+ ["subject"],
398
+ ),
399
+ }
400
+ : {}),
401
+ },
402
+ }
403
+ : {}),
404
+ ...("summary" in thread
405
+ ? { summary: bounded(thread.summary, `${path}/thread/summary`) }
406
+ : {}),
407
+ ...(digest
408
+ ? {
409
+ digest: bounded(
410
+ pick(digest, ["requests", "commitments"]),
411
+ `${path}/thread/digest`,
412
+ 480,
413
+ ),
414
+ }
415
+ : {}),
416
+ };
417
+ } else if (comment)
418
+ projected.comment = {
419
+ ...pick(comment, [
420
+ "_id",
421
+ "kind",
422
+ "title",
423
+ "createdAt",
424
+ "editedAt",
425
+ "deletedAt",
426
+ "replyCount",
427
+ ]),
428
+ actor: actor(comment.actor, `${path}/comment/actor`),
429
+ body: bounded(comment.body, `${path}/comment/body`),
430
+ };
431
+ else if (history)
432
+ projected.row = {
433
+ ...displayText(
434
+ pick(history, ["_id", "changeKind", "at", "isSystem", "entryLabel"]),
435
+ `${path}/row`,
436
+ ["entryLabel"],
437
+ ),
438
+ actor: actor(history.actor, `${path}/row/actor`),
439
+ changes: bounded(history.changes, `${path}/row/changes`, 480),
440
+ };
441
+ if (
442
+ add(
443
+ () => {
444
+ items.push(projected);
445
+ },
446
+ () => {
447
+ items.pop();
448
+ },
449
+ "activityItems",
450
+ path,
451
+ ) &&
452
+ typeof comment?._id === "string"
453
+ )
454
+ includedNotes.add(comment._id);
455
+ });
456
+ // A stale brief must never regain its withheld prose through compact mode.
457
+ if (shared && shared.stale !== true) {
458
+ for (const key of ["headline", "body", "facets"]) {
459
+ if (shared[key] === undefined) continue;
460
+ const target = briefOutput.shared as Row;
461
+ const path = `/context/brief/shared/${key}`;
462
+ const projected = bounded(shared[key], path, key === "body" ? 1800 : 800);
463
+ add(
464
+ () => {
465
+ target[key] = projected;
466
+ },
467
+ () => {
468
+ delete target[key];
469
+ },
470
+ "values",
471
+ path,
472
+ );
473
+ }
474
+ }
475
+ fieldEntries.filter((field) => !short(field)).forEach(addField);
476
+ const sourceNotes = Array.isArray(context?.notes) ? context.notes : [];
477
+ sourceNotes.forEach((raw, index) => {
478
+ const note = row(raw);
479
+ if (!note) return;
480
+ if (typeof note._id === "string" && includedNotes.has(note._id)) {
481
+ metadata.omittedCounts.duplicateNotes += 1;
482
+ return;
483
+ }
484
+ const path = `/context/notes/${index}`;
485
+ const projected = {
486
+ ...pick(note, [
487
+ "_id",
488
+ "kind",
489
+ "title",
490
+ "createdAt",
491
+ "editedAt",
492
+ "deletedAt",
493
+ "replyCount",
494
+ ]),
495
+ actor: actor(note.actor, `${path}/actor`),
496
+ body: bounded(note.body, `${path}/body`),
497
+ };
498
+ add(
499
+ () => {
500
+ notes.push(projected);
501
+ },
502
+ () => {
503
+ notes.pop();
504
+ },
505
+ "notes",
506
+ path,
507
+ );
508
+ });
509
+ // Coverage grows when optional values are dropped; measure the complete final
510
+ // envelope again rather than assuming the per-value allowance was sufficient.
511
+ while (!fits(output) && accepted.length) {
512
+ const last = accepted.pop();
513
+ if (!last) break;
514
+ last.remove();
515
+ metadata.omittedCounts[last.group] += 1;
516
+ changed(last.path);
517
+ }
518
+ return fits(output) ? serialized(output) : tooLarge();
519
+ }
@@ -37,6 +37,22 @@ export class EnrichmentGatewayError extends Error {
37
37
  }
38
38
  }
39
39
 
40
+ /** These specific 503 responses are emitted by the gateway billing gate
41
+ * before provider execution. Other 5xx responses may have purchased a page. */
42
+ export function enrichmentBillingPreflightMessage(error: {
43
+ status?: number;
44
+ code?: string;
45
+ }): string | undefined {
46
+ if (error.status !== 503) return undefined;
47
+ if (error.code === "billing_state_unavailable") {
48
+ return "AI billing state is temporarily unavailable. Please retry.";
49
+ }
50
+ if (error.code === "billing_state_busy") {
51
+ return "AI billing state is temporarily busy. Please retry shortly.";
52
+ }
53
+ return undefined;
54
+ }
55
+
40
56
  export type EnrichPersonBody = {
41
57
  email?: string;
42
58
  linkedinUrl?: string;
@@ -1724,6 +1740,16 @@ async function buildGatewayError(
1724
1740
  { status: response.status, code: "forbidden" },
1725
1741
  );
1726
1742
  }
1743
+ const billingPreflightMessage = enrichmentBillingPreflightMessage({
1744
+ status: response.status,
1745
+ code,
1746
+ });
1747
+ if (billingPreflightMessage) {
1748
+ return new EnrichmentGatewayError(billingPreflightMessage, {
1749
+ status: response.status,
1750
+ code,
1751
+ });
1752
+ }
1727
1753
  if (response.status === 503 || code === "provider_unavailable") {
1728
1754
  return new EnrichmentGatewayError("Gateway providers unavailable", {
1729
1755
  status: response.status,