@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 +2 -2
- package/crm.ts +19 -3
- package/lib/crm-compact-reads.ts +519 -0
- package/lib/enrichment-gateway.ts +26 -0
- package/lib/references.generated.json +6 -6
- package/package.json +1 -1
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 {
|
|
492
|
-
const raw = entry?.
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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,
|