@pitcher/cli-scripts 3.1.1 → 3.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.
Files changed (70) hide show
  1. package/README.md +39 -2
  2. package/dist/index.js +2 -0
  3. package/dist/src/commands/analytics.d.ts +77 -0
  4. package/dist/src/commands/analytics.js +309 -1
  5. package/dist/src/commands/dsrLanding.js +1 -1
  6. package/dist/src/commands/roleplay.d.ts +236 -0
  7. package/dist/src/commands/roleplay.js +702 -0
  8. package/dist/src/core/dsr-engagement.d.ts +152 -0
  9. package/dist/src/core/dsr-engagement.js +301 -0
  10. package/dist/src/core/dsr-visits.d.ts +50 -0
  11. package/dist/src/core/dsr-visits.js +191 -0
  12. package/dist/src/core/envelope.d.ts +1 -0
  13. package/dist/src/core/envelope.js +6 -1
  14. package/dist/src/core/errors.d.ts +3 -1
  15. package/dist/src/core/errors.js +2 -2
  16. package/dist/src/core/http.js +7 -5
  17. package/dist/src/core/ld.js +2 -1
  18. package/dist/src/core/me.d.ts +3 -0
  19. package/dist/src/core/me.js +10 -0
  20. package/dist/src/core/roleplay-stats.d.ts +120 -0
  21. package/dist/src/core/roleplay-stats.js +171 -0
  22. package/dist/src/mcp/apps/confirm/define.d.ts +66 -0
  23. package/dist/src/mcp/apps/confirm/define.js +208 -0
  24. package/dist/src/mcp/apps/confirm/hash.d.ts +4 -0
  25. package/dist/src/mcp/apps/confirm/hash.js +23 -0
  26. package/dist/src/mcp/apps/confirm/types.d.ts +96 -0
  27. package/dist/src/mcp/apps/confirm/types.js +15 -0
  28. package/dist/src/mcp/apps/contentPicker.d.ts +104 -0
  29. package/dist/src/mcp/apps/contentPicker.js +268 -0
  30. package/dist/src/mcp/apps/dsrEngagement.d.ts +194 -0
  31. package/dist/src/mcp/apps/dsrEngagement.js +737 -0
  32. package/dist/src/mcp/apps/registry.d.ts +14 -0
  33. package/dist/src/mcp/apps/registry.js +25 -0
  34. package/dist/src/mcp/apps/roleplayManager.d.ts +228 -0
  35. package/dist/src/mcp/apps/roleplayManager.js +606 -0
  36. package/dist/src/mcp/apps/roleplayReps.d.ts +79 -0
  37. package/dist/src/mcp/apps/roleplayReps.js +328 -0
  38. package/dist/src/mcp/apps/roleplayWrites.d.ts +6 -0
  39. package/dist/src/mcp/apps/roleplayWrites.js +509 -0
  40. package/dist/src/mcp/apps/text.d.ts +41 -0
  41. package/dist/src/mcp/apps/text.js +58 -0
  42. package/dist/src/mcp/apps/types.d.ts +23 -0
  43. package/dist/src/mcp/apps/types.js +12 -0
  44. package/dist/src/mcp/apps/writeConfirm.d.ts +3 -0
  45. package/dist/src/mcp/apps/writeConfirm.js +8 -0
  46. package/dist/src/mcp/groups.d.ts +42 -0
  47. package/dist/src/mcp/groups.js +89 -0
  48. package/dist/src/mcp/hosted.d.ts +36 -0
  49. package/dist/src/mcp/hosted.js +47 -1
  50. package/dist/src/mcp/index.d.ts +9 -3
  51. package/dist/src/mcp/index.js +9 -2
  52. package/dist/src/mcp/prompts/registry.js +8 -7
  53. package/dist/src/mcp/prompts/types.d.ts +3 -0
  54. package/dist/src/mcp/registry.d.ts +4 -0
  55. package/dist/src/mcp/registry.js +30 -23
  56. package/dist/src/mcp/renderPolicy.d.ts +2 -1
  57. package/dist/src/mcp/renderPolicy.js +10 -3
  58. package/dist/src/mcp/resources/analyticsCatalog.d.ts +1 -1
  59. package/dist/src/mcp/resources/analyticsCatalog.js +23 -5
  60. package/dist/src/mcp/resources/registry.js +2 -2
  61. package/dist/src/mcp/resources/types.d.ts +3 -0
  62. package/dist/src/mcp/result.d.ts +2 -1
  63. package/dist/src/mcp/result.js +8 -6
  64. package/dist/src/mcp/server.d.ts +62 -0
  65. package/dist/src/mcp/server.js +161 -10
  66. package/dist/src/mcp/tools/analytics.js +88 -3
  67. package/dist/src/mcp/tools/roleplay.d.ts +2 -0
  68. package/dist/src/mcp/tools/roleplay.js +135 -0
  69. package/dist/src/mcp/tools/types.d.ts +35 -0
  70. package/package.json +7 -1
@@ -0,0 +1,737 @@
1
+ import { z } from 'zod';
2
+ import { aggregateFiles, compressRanges, dedupeIdentities, densePages, fmtDuration, naiveDate, naiveMinute, pageCountFor, realNames, toHeader, toViewerRow, ULID, unavailableOf, classifyItem, fileFallbackName, } from '../../core/dsr-engagement.js';
3
+ import { summarizeVisits } from '../../core/dsr-visits.js';
4
+ import { ok, fail } from '../../core/envelope.js';
5
+ import { createCliError, isCliError } from '../../core/errors.js';
6
+ import { clickhouseClientFor, toCliError } from '../../core/http.js';
7
+ import { byteLen, clipBytes, fitText, jsonBytes, listHead } from './text.js';
8
+ import { orgOrigin } from './types.js';
9
+ /**
10
+ * MCP Apps DSR engagement view (PIT-10067): who opened a DSR canvas, one viewer's visit timeline, and time per page
11
+ * of each file, plus a Content tab over a stated sample of the most recently active viewers. Read-only. Every read
12
+ * runs with the caller's own credential through clickhouse-api, which re-checks canvas access per call (the canvas
13
+ * fetch is the gate), so a user sees exactly the viewers Sharing Analytics shows them. The runners live here, not
14
+ * in commands/analytics.ts: registry.test.ts pins one `run*` per CLI tool, and app tools are not CLI tools.
15
+ */
16
+ export const dsrEngagementView = {
17
+ name: 'dsr-engagement',
18
+ title: 'Pitcher DSR engagement',
19
+ description: 'Who opened a DSR, their visits, and time per page of each file.',
20
+ csp: (ctx) => ({ resourceDomains: orgOrigin(ctx) }), // file thumbnails only, as the picker
21
+ };
22
+ /** structuredContent budget per result (the picker's, PIT-10066 D14). */
23
+ export const DSR_VIEW_MAX_BYTES = 120000;
24
+ /** Model-facing text budget per result, in UTF-8 bytes. */
25
+ export const DSR_TEXT_MAX_BYTES = 4096;
26
+ /** The "more" line's floor reserve (7.3); each text sizes it up to its own longest continuation. */
27
+ const DSR_MORE_MIN_RESERVE = 160;
28
+ export const VIEWERS_PAGE_SIZE = 25;
29
+ export const VIEWER_VISITS_MAX = 50;
30
+ export const VISIT_ITEMS_MAX = 60;
31
+ export const FILE_META_MAX = 200;
32
+ const FILE_META_CHUNK = 50; // the thumbnails route's own 1..50 cap
33
+ const FILE_META_CONCURRENCY = 2;
34
+ export const CONTENT_MAX_VIEWERS = 15;
35
+ export const CONTENT_CONCURRENCY = 3;
36
+ export const CONTENT_DEADLINE_MS = 20000;
37
+ const CONTENT_META_MIN_REMAINING_MS = 2000;
38
+ export const VIEWER_DEADLINE_MS = 25000;
39
+ const VIEWER_META_MAX_ELAPSED_MS = 10000;
40
+ const TEXT_VISITS_IN_OVERVIEW = 10;
41
+ const VIEWER_TYPES = ['external', 'internal', 'all'];
42
+ const WINDOWS = ['30d', '90d', '365d'];
43
+ const SORTS = ['last_active', 'total_engagement_time', 'session_count'];
44
+ const WINDOW_DAYS = { '30d': 30, '90d': 90, '365d': 365 };
45
+ /** `today (UTC) - N days` as YYYY-MM-DD; null for 365d, which sends no dates (the route's default). */
46
+ export function windowStart(w, nowMs) {
47
+ if (w === '365d')
48
+ return null;
49
+ const d = new Date(nowMs);
50
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) - WINDOW_DAYS[w] * 86400000)
51
+ .toISOString()
52
+ .slice(0, 10);
53
+ }
54
+ function scopeOf(opts, nowMs) {
55
+ if (!opts.instanceId)
56
+ throw createCliError('VALIDATION', 'instanceId is required');
57
+ if (!opts.canvasId)
58
+ throw createCliError('VALIDATION', 'canvasId is required');
59
+ const viewerType = (opts.viewerType ?? 'external');
60
+ if (!VIEWER_TYPES.includes(viewerType))
61
+ throw createCliError('VALIDATION', `viewerType must be one of: ${VIEWER_TYPES.join(', ')}`);
62
+ const window = (opts.window ?? '30d');
63
+ if (!WINDOWS.includes(window))
64
+ throw createCliError('VALIDATION', `window must be one of: ${WINDOWS.join(', ')}`);
65
+ return { instanceId: opts.instanceId, canvasId: opts.canvasId, viewerType, window, startDate: windowStart(window, nowMs) };
66
+ }
67
+ function identityOf(v, label) {
68
+ if (typeof v !== 'string' || v.length < 1 || v.length > 1024) {
69
+ throw createCliError('VALIDATION', `${label} must be 1 to 1024 characters`);
70
+ }
71
+ return v;
72
+ }
73
+ const clockOf = (deps) => deps.now ?? Date.now;
74
+ const abortOf = (deps) => deps.abortAfter ?? ((ms) => AbortSignal.timeout(ms));
75
+ const deadlineError = () => createCliError('NETWORK', 'Timed out loading DSR engagement data; try again');
76
+ /** A request cut by the run's deadline rejects as axios `canceled`: say what happened instead (PR 6010 review). */
77
+ const underDeadline = (p, signal) => p.catch((e) => {
78
+ throw signal.aborted ? deadlineError() : e;
79
+ });
80
+ /** Run `fn` over `items` with at most `n` in flight; never rejects. A falsy `canStart()` settles the rest unstarted. */
81
+ async function pool(items, n, fn, canStart = () => true) {
82
+ const out = new Array(items.length);
83
+ let next = 0;
84
+ const worker = async () => {
85
+ while (next < items.length) {
86
+ const i = next++;
87
+ if (!canStart()) {
88
+ out[i] = { status: 'rejected', reason: deadlineError() };
89
+ continue;
90
+ }
91
+ try {
92
+ out[i] = { status: 'fulfilled', value: await fn(items[i]) };
93
+ }
94
+ catch (reason) {
95
+ out[i] = { status: 'rejected', reason };
96
+ }
97
+ }
98
+ };
99
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
100
+ return out;
101
+ }
102
+ async function readViewers(client, scope, q = {}) {
103
+ const page = q.page ?? 1;
104
+ const params = {
105
+ instance_id: scope.instanceId,
106
+ canvas_id: scope.canvasId,
107
+ viewer_type: scope.viewerType,
108
+ page,
109
+ page_size: VIEWERS_PAGE_SIZE,
110
+ sort_by: q.sortBy ?? 'last_active',
111
+ sort_order: 'desc',
112
+ };
113
+ if (q.search)
114
+ params.search = q.search;
115
+ if (scope.startDate)
116
+ params.start_date = scope.startDate;
117
+ const res = await client.get('/analytics/canvas-details/viewers', { params, ...(q.signal ? { signal: q.signal } : {}) });
118
+ const native = (res.data && typeof res.data === 'object' ? res.data : {});
119
+ const list = (native.viewers ?? {});
120
+ const rows = (Array.isArray(list.items) ? list.items : []).map((r) => toViewerRow((r ?? {})));
121
+ const total = Math.max(Number(list.total) || 0, rows.length);
122
+ return {
123
+ native,
124
+ page: { rows, total, page, hasMore: (page - 1) * VIEWERS_PAGE_SIZE + rows.length < total },
125
+ unavailable: unavailableOf(native),
126
+ };
127
+ }
128
+ async function readTimeline(client, scope, identity, signal) {
129
+ const params = { instance_id: scope.instanceId, canvas_id: scope.canvasId, user_email: identity };
130
+ if (scope.startDate)
131
+ params.start_date = scope.startDate;
132
+ const res = await client.get('/analytics/canvas-details/sessions', { params, signal });
133
+ const events = ((res.data ?? {}).events ?? []);
134
+ const list = Array.isArray(events) ? events : [];
135
+ const names = realNames(list);
136
+ return { key: identity, visits: summarizeVisits(list, scope.canvasId), names, canvasName: names[scope.canvasId] ?? null };
137
+ }
138
+ /**
139
+ * Thumbnail and page metadata for up to FILE_META_MAX ULID files (in the given order), 50 per call to the
140
+ * clickhouse-api thumbnails route (Django `files/?id__in` as the caller, so only readable files come back), two
141
+ * calls in flight. A failed chunk leaves its files without meta, silently.
142
+ */
143
+ async function readFileMeta(client, instanceId, fileIds, signal) {
144
+ const ids = Array.from(new Set(fileIds)).filter((id) => ULID.test(id));
145
+ const take = ids.slice(0, FILE_META_MAX);
146
+ const chunks = [];
147
+ for (let i = 0; i < take.length; i += FILE_META_CHUNK)
148
+ chunks.push(take.slice(i, i + FILE_META_CHUNK));
149
+ const settled = await pool(chunks, FILE_META_CONCURRENCY, (chunk) => client.get('/analytics/canvas-details/thumbnails', { params: { file_ids: chunk.join(','), instance_id: instanceId }, signal }), () => !signal.aborted);
150
+ const meta = new Map();
151
+ for (const s of settled) {
152
+ if (s.status !== 'fulfilled')
153
+ continue;
154
+ const files = (s.value?.data ?? {}).files;
155
+ for (const f of Array.isArray(files) ? files : []) {
156
+ const r = (f ?? {});
157
+ if (typeof r.id !== 'string')
158
+ continue;
159
+ meta.set(r.id, {
160
+ thumbnailUrl: typeof r.thumbnail_url === 'string' && r.thumbnail_url ? r.thumbnail_url : null,
161
+ contentType: typeof r.content_type === 'string' ? r.content_type : null,
162
+ contentLength: typeof r.content_length === 'number' ? r.content_length : null,
163
+ thumbCount: Array.isArray(r.content_thumbnails) ? r.content_thumbnails.length : 0,
164
+ });
165
+ }
166
+ }
167
+ return { meta, truncated: ids.length - take.length };
168
+ }
169
+ // ── shaping ──────────────────────────────────────────────────────────────────
170
+ function toFileStat(f, meta) {
171
+ const { pageCount, dense } = pageCountFor(meta, f.observed);
172
+ const { pages, pagesTruncated } = densePages(f.observed, pageCount, dense);
173
+ return {
174
+ fileId: f.fileId,
175
+ name: f.name,
176
+ seconds: f.seconds,
177
+ views: f.views,
178
+ ...(f.viewers !== undefined ? { viewers: f.viewers } : {}),
179
+ estimated: f.estimated,
180
+ pageCount,
181
+ dense,
182
+ pages,
183
+ ...(pagesTruncated ? { pagesTruncated } : {}),
184
+ thumbnailUrl: meta?.thumbnailUrl ?? null,
185
+ contentType: meta?.contentType ?? null,
186
+ };
187
+ }
188
+ function visitView(v, names) {
189
+ const fileName = (id) => names[id] ?? fileFallbackName(id);
190
+ const items = v.items.map((it) => {
191
+ const c = classifyItem(it);
192
+ const base = { id: it.id, views: it.views, seconds: it.seconds, estimated: it.estimated };
193
+ if (c.kind === 'section')
194
+ return { type: 'section', name: names[it.id] ?? it.name, ...base };
195
+ if (c.kind === 'file')
196
+ return { type: 'file', name: fileName(c.fileId), fileId: c.fileId, ...base };
197
+ return { type: 'page', name: fileName(c.fileId), fileId: c.fileId, page: c.page, ...base };
198
+ });
199
+ return {
200
+ visit: v.visit,
201
+ startedAt: v.started_at,
202
+ endedAt: v.ended_at,
203
+ engagedSeconds: v.engaged_seconds,
204
+ items: items.slice(0, VISIT_ITEMS_MAX),
205
+ ...(items.length > VISIT_ITEMS_MAX ? { itemsTruncated: items.length - VISIT_ITEMS_MAX } : {}),
206
+ };
207
+ }
208
+ /**
209
+ * Viewer (7.4 item 2): items per visit are already capped at 60 and the newest 50 visits kept; then, while over
210
+ * `budget`, drop the oldest visit, then files from the end. File totals were computed over every visit before any
211
+ * cut. Measured with `truncated` included.
212
+ */
213
+ export function fitViewer(data, budget = DSR_VIEW_MAX_BYTES) {
214
+ const out = { ...data };
215
+ const note = () => {
216
+ const { truncated: _old, ...rest } = out;
217
+ const parts = [];
218
+ if (out.visits.length < data.visitsTotal)
219
+ parts.push(`Newest ${out.visits.length} of ${data.visitsTotal} visits; file totals cover every visit`);
220
+ if (out.files.length < data.files.length)
221
+ parts.push(`${out.files.length} of ${data.files.length} files shown`);
222
+ return parts.length ? { ...rest, truncated: parts.join('; ') } : rest;
223
+ };
224
+ while (out.visits.length && jsonBytes(note()) > budget)
225
+ out.visits = out.visits.slice(0, -1);
226
+ while (out.files.length && jsonBytes(note()) > budget)
227
+ out.files = out.files.slice(0, -1);
228
+ return note();
229
+ }
230
+ /** Content (7.4 item 3): pages are capped at 300 per file already; drop files from the end. */
231
+ export function fitContent(data, budget = DSR_VIEW_MAX_BYTES) {
232
+ const out = { ...data };
233
+ const total = data.files.length;
234
+ const note = () => {
235
+ const { truncated: _old, ...rest } = out;
236
+ return out.files.length < total ? { ...rest, truncated: `${out.files.length} of ${total} files shown` } : rest;
237
+ };
238
+ while (out.files.length && jsonBytes(note()) > budget)
239
+ out.files = out.files.slice(0, -1);
240
+ return note();
241
+ }
242
+ // ── runners ──────────────────────────────────────────────────────────────────
243
+ /**
244
+ * One viewer (6.3): one timeline under a 25 s deadline, all-visit file totals, meta only within the first 10 s.
245
+ * Unfitted: each caller fits it ONCE with its own budget, so `truncated` always counts against the full lists.
246
+ */
247
+ async function loadViewer(client, scope, identity, deps) {
248
+ const now = clockOf(deps);
249
+ const start = now();
250
+ const signal = abortOf(deps)(VIEWER_DEADLINE_MS);
251
+ const t = await underDeadline(readTimeline(client, scope, identity, signal), signal);
252
+ const agg = aggregateFiles([t]);
253
+ let meta = { meta: new Map(), truncated: 0 };
254
+ let metaSkipped = false;
255
+ if (agg.files.length) {
256
+ if (now() - start <= VIEWER_META_MAX_ELAPSED_MS && !signal.aborted) {
257
+ meta = await readFileMeta(client, scope.instanceId, agg.files.map((f) => f.fileId), signal);
258
+ }
259
+ else {
260
+ metaSkipped = true;
261
+ }
262
+ }
263
+ const newestFirst = [...t.visits].reverse();
264
+ const data = {
265
+ kind: 'viewer',
266
+ scope,
267
+ identity,
268
+ isExternal: null,
269
+ totals: {
270
+ visits: t.visits.length,
271
+ engagedSeconds: t.visits.reduce((n, v) => n + v.engaged_seconds, 0),
272
+ firstVisit: t.visits[0]?.started_at ?? null,
273
+ lastVisit: newestFirst[0]?.started_at ?? null,
274
+ },
275
+ files: agg.files.map((f) => toFileStat(f, meta.meta.get(f.fileId))),
276
+ sections: agg.sections.map(({ id, name, seconds, views }) => ({ id, name, seconds, views })),
277
+ visits: newestFirst.slice(0, VIEWER_VISITS_MAX).map((v) => visitView(v, t.names)),
278
+ visitsTotal: t.visits.length,
279
+ ...(meta.truncated ? { metaTruncated: meta.truncated } : {}),
280
+ ...(metaSkipped ? { metaSkipped: true } : {}),
281
+ };
282
+ return data;
283
+ }
284
+ /**
285
+ * The drilled identity's flag. The External and Internal filters decide it. Under `all` an address can be listed
286
+ * twice (signed in, and through the link with the marker) and the drill covers both, so only a page that holds
287
+ * every row can tell: one agreeing flag there, else null (PR 6010 review).
288
+ */
289
+ export function flagOf(page, identity, viewerType) {
290
+ if (viewerType !== 'all')
291
+ return viewerType === 'external';
292
+ if (page.hasMore)
293
+ return null;
294
+ const flags = new Set(page.rows.filter((r) => r.identity === identity).map((r) => r.isExternal));
295
+ return flags.size === 1 ? [...flags][0] : null;
296
+ }
297
+ export async function runDsrView(opts, deps = {}) {
298
+ try {
299
+ const now = clockOf(deps);
300
+ const scope = scopeOf(opts, now());
301
+ const identity = opts.viewerIdentity === undefined ? null : identityOf(opts.viewerIdentity, 'viewerIdentity');
302
+ const client = clickhouseClientFor(opts, deps);
303
+ const [v, viewer] = await Promise.all([
304
+ readViewers(client, scope),
305
+ identity === null ? Promise.resolve(null) : loadViewer(client, scope, identity, deps),
306
+ ]);
307
+ const data = {
308
+ kind: 'overview',
309
+ scope,
310
+ header: toHeader(v.native),
311
+ viewers: v.page,
312
+ unavailable: v.unavailable,
313
+ fetchedAt: new Date(now()).toISOString(),
314
+ };
315
+ if (viewer) {
316
+ // D22: the viewer gets whatever the overview leaves of the one budget.
317
+ data.viewer = fitViewer({ ...viewer, isExternal: flagOf(v.page, identity, scope.viewerType) }, DSR_VIEW_MAX_BYTES - jsonBytes(data) - 16);
318
+ }
319
+ return ok(data);
320
+ }
321
+ catch (e) {
322
+ return fail(toCliError(e));
323
+ }
324
+ }
325
+ export async function runDsrViewersPage(opts, deps = {}) {
326
+ try {
327
+ const scope = scopeOf(opts, clockOf(deps)());
328
+ const page = opts.page ?? 1;
329
+ if (!Number.isInteger(page) || page < 1)
330
+ throw createCliError('VALIDATION', 'page must be an integer from 1');
331
+ const sortBy = (opts.sortBy ?? 'last_active');
332
+ if (!SORTS.includes(sortBy))
333
+ throw createCliError('VALIDATION', `sortBy must be one of: ${SORTS.join(', ')}`);
334
+ if (opts.search !== undefined && opts.search.length > 200)
335
+ throw createCliError('VALIDATION', 'search must be at most 200 characters');
336
+ const search = opts.search ? opts.search : null;
337
+ const v = await readViewers(clickhouseClientFor(opts, deps), scope, { page, sortBy, search });
338
+ return ok({ kind: 'viewers-page', scope, ...v.page, sortBy, search, unavailable: v.unavailable });
339
+ }
340
+ catch (e) {
341
+ return fail(toCliError(e));
342
+ }
343
+ }
344
+ export async function runDsrViewer(opts, deps = {}) {
345
+ try {
346
+ const scope = scopeOf(opts, clockOf(deps)());
347
+ const identity = identityOf(opts.viewerIdentity, 'viewerIdentity');
348
+ return ok(fitViewer(await loadViewer(clickhouseClientFor(opts, deps), scope, identity, deps)));
349
+ }
350
+ catch (e) {
351
+ return fail(toCliError(e));
352
+ }
353
+ }
354
+ /**
355
+ * Content (6.4, D21): up to 15 deduped identities (passed by the view, else page 1 of the viewers by last active),
356
+ * their timelines three at a time under ONE 20 s deadline whose signal reaches every request; a failed or late
357
+ * timeline is "not covered", never a tool failure, unless every timeline failed.
358
+ */
359
+ export async function runDsrContent(opts, deps = {}) {
360
+ try {
361
+ const now = clockOf(deps);
362
+ const start = now();
363
+ const scope = scopeOf(opts, start);
364
+ if (opts.viewerIdentities !== undefined) {
365
+ if (!Array.isArray(opts.viewerIdentities) || opts.viewerIdentities.length > CONTENT_MAX_VIEWERS) {
366
+ throw createCliError('VALIDATION', `viewerIdentities must hold at most ${CONTENT_MAX_VIEWERS} identities`);
367
+ }
368
+ opts.viewerIdentities.forEach((id) => identityOf(id, 'viewerIdentities'));
369
+ }
370
+ const client = clickhouseClientFor(opts, deps);
371
+ const signal = abortOf(deps)(CONTENT_DEADLINE_MS);
372
+ const listed = !!opts.viewerIdentities?.length;
373
+ let ids;
374
+ let viewersTotal = null;
375
+ let unavailable = [];
376
+ let canvasName = null;
377
+ if (listed) {
378
+ ids = opts.viewerIdentities;
379
+ }
380
+ else {
381
+ // A rejection here is a tool error with its own code (gate NOT_FOUND / AUTH, deadline NETWORK), never "no viewers".
382
+ const v = await underDeadline(readViewers(client, scope, { signal }), signal);
383
+ ids = v.page.rows.map((r) => r.identity);
384
+ viewersTotal = v.page.total;
385
+ unavailable = v.unavailable;
386
+ canvasName = toHeader(v.native).canvasName || null;
387
+ }
388
+ const keys = dedupeIdentities(ids, CONTENT_MAX_VIEWERS);
389
+ const empty = {
390
+ kind: 'content',
391
+ scope,
392
+ canvasName,
393
+ files: [],
394
+ sections: [],
395
+ viewersCovered: 0,
396
+ viewersSampled: keys.length,
397
+ viewersFailed: 0,
398
+ viewersTotal,
399
+ sampleSource: listed ? 'listed' : 'recent',
400
+ deadlineHit: false,
401
+ unavailable,
402
+ };
403
+ if (!keys.length)
404
+ return ok(empty);
405
+ let deadlineHit = false;
406
+ const settled = await pool(keys, CONTENT_CONCURRENCY, (key) => underDeadline(readTimeline(client, scope, key, signal), signal).catch((e) => {
407
+ if (signal.aborted)
408
+ deadlineHit = true;
409
+ throw e;
410
+ }), () => {
411
+ if (signal.aborted)
412
+ deadlineHit = true;
413
+ return !signal.aborted;
414
+ });
415
+ const covered = settled.flatMap((s) => (s.status === 'fulfilled' ? [s.value] : []));
416
+ if (!covered.length) {
417
+ const first = settled.find((s) => s.status === 'rejected');
418
+ throw isCliError(first?.reason) ? first.reason : toCliError(first?.reason);
419
+ }
420
+ const agg = aggregateFiles(covered, { countViewers: true });
421
+ let meta = { meta: new Map(), truncated: 0 };
422
+ let metaSkipped = false;
423
+ if (agg.files.length) {
424
+ if (CONTENT_DEADLINE_MS - (now() - start) >= CONTENT_META_MIN_REMAINING_MS && !signal.aborted) {
425
+ meta = await readFileMeta(client, scope.instanceId, agg.files.map((f) => f.fileId), signal);
426
+ }
427
+ else {
428
+ metaSkipped = true;
429
+ }
430
+ }
431
+ return ok(fitContent({
432
+ ...empty,
433
+ canvasName: canvasName ?? covered.find((t) => t.canvasName)?.canvasName ?? null,
434
+ files: agg.files.map((f) => toFileStat(f, meta.meta.get(f.fileId))),
435
+ sections: agg.sections.map(({ id, name, seconds, views, viewers }) => ({ id, name, seconds, views, viewers: viewers ?? 0 })),
436
+ viewersCovered: covered.length,
437
+ viewersFailed: keys.length - covered.length,
438
+ deadlineHit,
439
+ ...(meta.truncated ? { metaTruncated: meta.truncated } : {}),
440
+ ...(metaSkipped ? { metaSkipped: true } : {}),
441
+ }));
442
+ }
443
+ catch (e) {
444
+ return fail(toCliError(e));
445
+ }
446
+ }
447
+ // ── text (7.3) ───────────────────────────────────────────────────────────────
448
+ const WINDOW_TEXT = { '30d': 'last 30 days', '90d': 'last 90 days', '365d': 'last 12 months' };
449
+ const TYPE_WORD = { external: 'external ', internal: 'internal ', all: '' };
450
+ const SORT_TEXT = { last_active: 'last active', total_engagement_time: 'time', session_count: 'visits' };
451
+ const dur = (s, estimated = false) => `${estimated ? '~' : ''}${fmtDuration(s)}`;
452
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
453
+ /** Shrink only the free-text names until `build` fits `max` (so the fixed sentences are never cut). */
454
+ function shrinkNames(build, max) {
455
+ for (const n of [120, 60, 30, 12]) {
456
+ const t = build(n);
457
+ if (byteLen(t) <= max)
458
+ return t;
459
+ }
460
+ return build(0);
461
+ }
462
+ const nameAt = (s, n) => (n ? clipBytes(s, n) : '...');
463
+ /** 7.3 item 5: the full scope of the list, so the model's next page is the same list. */
464
+ export function continuationArgs(scope, sortBy, search, page, total) {
465
+ const lastPage = Math.max(1, Math.ceil(total / VIEWERS_PAGE_SIZE));
466
+ const parts = [`viewerType ${scope.viewerType}`];
467
+ if (scope.startDate)
468
+ parts.push(`startDate ${scope.startDate}`);
469
+ parts.push(`sortBy ${sortBy}`);
470
+ if (search)
471
+ parts.push(`search ${JSON.stringify(search)}`);
472
+ parts.push(`limit ${VIEWERS_PAGE_SIZE}`, `page ${page} of ${lastPage}`);
473
+ return `use pitcher_analytics_viewers with ${parts.join(', ')}`;
474
+ }
475
+ /** fitText with the reserve sized to the longest `more` this call can print, never below 160 B. */
476
+ function fitDsrText(input) {
477
+ const longest = byteLen(input.more({ lines: 99999, hidden: 99999, kinds: ['viewer', 'visit'] }));
478
+ return fitText({ ...input, maxBytes: DSR_TEXT_MAX_BYTES, reserve: Math.max(DSR_MORE_MIN_RESERVE, longest + 1) });
479
+ }
480
+ function viewerLine(r, scope) {
481
+ const flag = scope.viewerType === 'external' ? '' : ` [${r.isExternal ? 'external' : 'internal'}]`;
482
+ return (`${r.identity}${flag}: ${plural(r.visits, 'visit')}, ${dur(r.engagedSeconds)}, last ${naiveDate(r.lastActive)}, ` +
483
+ `${plural(r.filesViewed, 'file')}${r.forwards > 0 ? `, forwarded ${r.forwards}x` : ''}`);
484
+ }
485
+ function visitLine(v) {
486
+ const files = new Set(v.items.filter((i) => i.fileId).map((i) => i.fileId)).size;
487
+ const sections = v.items.filter((i) => i.type === 'section').length;
488
+ return `${naiveMinute(v.startedAt)} ${dur(v.engagedSeconds)} ${plural(files, 'file')} ${plural(sections, 'section')}`;
489
+ }
490
+ function topPages(f, n) {
491
+ return f.pages
492
+ .filter((p) => p.seconds > 0 || p.views > 0)
493
+ .sort((a, b) => b.seconds - a.seconds || a.page - b.page)
494
+ .slice(0, n);
495
+ }
496
+ function viewerTotalsHead(d, listed) {
497
+ const t = d.totals;
498
+ if (!t.visits)
499
+ return 'This viewer: no visits in this window.';
500
+ const list = listed < t.visits ? `Newest ${listed} of ${t.visits} visits listed below.` : `${plural(listed, 'visit')} listed below.`;
501
+ return (`This viewer: ${plural(t.visits, 'visit')}, ${dur(t.engagedSeconds)}, first ${naiveDate(t.firstVisit)}, ` +
502
+ `last ${naiveDate(t.lastVisit)} (viewer's local time). ${list}`);
503
+ }
504
+ function viewerFilesHead(d) {
505
+ if (!d.files.length)
506
+ return '';
507
+ const entries = d.files.slice(0, 5).map((f) => {
508
+ const tops = topPages(f, 3);
509
+ return `${clipBytes(f.name, 60)} (${f.fileId}) ${dur(f.seconds, f.estimated)}${tops.length ? `, top pages ${tops.map((p) => `${p.page} ${dur(p.seconds, p.estimated)}`).join(', ')}` : ''}`;
510
+ });
511
+ return listHead('Files: ', entries, d.files.length, 900, `Files: ${d.files.length} files.`);
512
+ }
513
+ function unavailableHead(unavailable, viewers) {
514
+ if (!unavailable.length)
515
+ return '';
516
+ const list = listFailed(unavailable, viewers) ? 'The viewer list could not be loaded. ' : '';
517
+ return `${list}Some figures could not be loaded (${unavailable.join(', ')}); a 0 may be missing data.`;
518
+ }
519
+ /**
520
+ * 8.2 row 6: the list is lost and the viewers exist, or the counts that could say "none" are lost with it (then 0 is
521
+ * missing data, never "nobody opened", PR 6010 review).
522
+ */
523
+ export const listFailed = (unavailable, viewers) => unavailable.includes('viewer_list') &&
524
+ (viewers > 0 || unavailable.includes('viewer_count') || unavailable.includes('overview'));
525
+ const zeroSentence = (viewers, unavailable) => viewers === 0 && !unavailable.includes('overview') && !unavailable.includes('viewer_count')
526
+ ? 'No one has opened this DSR in this window.'
527
+ : '';
528
+ export function dsrViewText(raw) {
529
+ const d = raw;
530
+ const h = d.header;
531
+ const s = d.scope;
532
+ const head1 = shrinkNames((n) => 'If your host shows the DSR engagement view, the user sees it there; otherwise summarise this for the user. ' +
533
+ 'Viewer identities below are drill keys: pass one unchanged as viewerIdentity. ' +
534
+ `DSR "${nameAt(h.canvasName, n)}" (canvas ${s.canvasId}), ${TYPE_WORD[s.viewerType]}viewers, ${WINDOW_TEXT[s.window]}: ` +
535
+ `${plural(h.viewers, 'viewer')}, ${plural(h.visits, 'visit')}, ${dur(h.engagedSeconds)} engaged; ` +
536
+ `forwarded ${plural(h.forwards, 'time')} (all viewers).` +
537
+ (h.mostViewedFile ? ` Most read file: ${nameAt(h.mostViewedFile.name, n && Math.min(n, 60))} (${h.mostViewedFile.id}) ${dur(h.mostViewedFile.seconds)}.` : '') +
538
+ (h.lastActivity ? ` Last activity ${naiveMinute(h.lastActivity)} (viewer's local time).` : ''), 700);
539
+ const v = d.viewer;
540
+ const visits = v ? v.visits.slice(0, TEXT_VISITS_IN_OVERVIEW) : [];
541
+ const sortBy = 'last_active';
542
+ const hidden = Math.max(0, d.viewers.total - d.viewers.rows.length);
543
+ return fitDsrText({
544
+ heads: [
545
+ { text: head1, maxBytes: 700 },
546
+ { text: unavailableHead(d.unavailable, h.viewers), maxBytes: 200 },
547
+ { text: zeroSentence(h.viewers, d.unavailable), maxBytes: 45 },
548
+ { text: v ? viewerTotalsHead(v, visits.length) : '', maxBytes: 200 },
549
+ { text: v ? viewerFilesHead(v) : '', maxBytes: 900 },
550
+ ],
551
+ body: [
552
+ ...visits.map((x) => ({ text: visitLine(x), kind: 'visit' })),
553
+ ...d.viewers.rows.map((r) => ({ text: viewerLine(r, s), kind: 'viewer' })),
554
+ ],
555
+ hidden,
556
+ more: ({ lines, hidden: h2 }) => {
557
+ const next = continuationArgs(s, sortBy, null, h2 > 0 ? 2 : 1, d.viewers.total);
558
+ const dropped = h2 > 0 && lines > 0 ? ` (dropped lines of this page: the same call with page 1)` : '';
559
+ return `...and ${lines + h2} more viewers: ${next}${dropped}.`;
560
+ },
561
+ });
562
+ }
563
+ export function dsrViewersPageText(raw) {
564
+ const d = raw;
565
+ const s = d.scope;
566
+ const first = (d.page - 1) * VIEWERS_PAGE_SIZE + 1;
567
+ const range = d.rows.length ? `Viewers ${first}-${first + d.rows.length - 1} of ${d.total}` : `No viewers on page ${d.page} of ${d.total}`;
568
+ const head = `${range} (${TYPE_WORD[s.viewerType]}${s.viewerType === 'all' ? 'all ' : ''}viewers, ${WINDOW_TEXT[s.window]}, ` +
569
+ `sorted by ${SORT_TEXT[d.sortBy]}${d.search ? `, search ${JSON.stringify(d.search)}` : ''}):`;
570
+ const later = Math.max(0, d.total - (d.page - 1) * VIEWERS_PAGE_SIZE - d.rows.length);
571
+ return fitDsrText({
572
+ heads: [{ text: head, maxBytes: 200 }],
573
+ body: d.rows.map((r) => ({ text: viewerLine(r, s), kind: 'viewer' })),
574
+ hidden: later,
575
+ more: ({ lines, hidden }) => {
576
+ const parts = [];
577
+ if (hidden > 0)
578
+ parts.push(`${hidden} on later pages: ${continuationArgs(s, d.sortBy, d.search, d.page + 1, d.total)}`);
579
+ if (lines > 0)
580
+ parts.push(`${lines} dropped from this page: the same call with page ${d.page}`);
581
+ return `...${lines + hidden} not listed: ${parts.join('; ')}`;
582
+ },
583
+ });
584
+ }
585
+ export function dsrViewerText(raw) {
586
+ const d = raw;
587
+ return fitDsrText({
588
+ heads: [
589
+ { text: viewerTotalsHead(d, d.visits.length), maxBytes: 200 },
590
+ { text: viewerFilesHead(d), maxBytes: 900 },
591
+ ],
592
+ body: d.visits.map((v) => ({ text: visitLine(v), kind: 'visit' })),
593
+ hidden: Math.max(0, d.visitsTotal - d.visits.length),
594
+ more: ({ lines, hidden }) => `...${lines + hidden} older visits not listed`,
595
+ });
596
+ }
597
+ function contentHead(d) {
598
+ const s = d.scope;
599
+ const n = d.viewersCovered;
600
+ if (d.viewersSampled === 0) {
601
+ if (listFailed(d.unavailable, d.viewersTotal ?? 0))
602
+ return 'The viewer list could not be loaded.';
603
+ return zeroSentence(d.viewersTotal ?? 0, d.unavailable) || 'No viewers to sample in this window.';
604
+ }
605
+ const filesRead = d.files.length;
606
+ const time = dur(d.files.reduce((t, f) => t + f.seconds, 0), d.files.some((f) => f.estimated));
607
+ return shrinkNames((len) => {
608
+ const name = d.canvasName ? ` for DSR "${nameAt(d.canvasName, len)}"` : '';
609
+ const failed = d.viewersFailed
610
+ ? ` (${n} of ${d.viewersSampled} sampled viewers loaded; ${d.viewersFailed} timed out or failed)`
611
+ : '';
612
+ const who = d.sampleSource === 'listed'
613
+ ? `${failed ? `${n} of the ${d.viewersSampled}` : `the ${n}`} listed viewers (${WINDOW_TEXT[s.window]})`
614
+ : `${failed ? `${n} of the ${d.viewersSampled}` : `the ${n}`} most recently active of ${d.viewersTotal === null ? 'the listed' : d.viewersTotal} ${TYPE_WORD[s.viewerType]}viewers (${WINDOW_TEXT[s.window]})`;
615
+ return (`Sample: ${who}${name}${failed}. Not totals: the overview counts every viewer; timelines include every visit ` +
616
+ `of each address. Read: ${plural(filesRead, 'file')}, ${time}.`);
617
+ }, 350);
618
+ }
619
+ function contentFileLine(f, n) {
620
+ const parts = [`${clipBytes(f.name, 60)} (${f.fileId}): ${dur(f.seconds, f.estimated)}, ${f.viewers ?? 0} of ${n} covered viewers`];
621
+ if (f.pageCount > 0) {
622
+ const read = f.pages.filter((p) => p.seconds > 0 || p.views > 0);
623
+ parts.push(f.dense ? `pages read ${read.length} of ${f.pageCount}` : `pages read ${read.length}`);
624
+ const tops = topPages(f, 3);
625
+ if (tops.length) {
626
+ parts.push(`top ${tops
627
+ .map((p, i) => `${p.page} (${dur(p.seconds, p.estimated)}${i === 0 ? `, ${plural(p.viewers ?? 0, 'viewer')}` : ''})`)
628
+ .join(', ')}`);
629
+ }
630
+ if (f.dense) {
631
+ const unopened = f.pages.filter((p) => p.seconds === 0 && p.views === 0).map((p) => p.page);
632
+ if (unopened.length)
633
+ parts.push(`not opened by these ${n}: ${clipBytes(compressRanges(unopened), 40)}`);
634
+ }
635
+ }
636
+ return parts.join('; ');
637
+ }
638
+ export function dsrContentText(raw) {
639
+ const d = raw;
640
+ const n = d.viewersCovered;
641
+ const sections = d.sections.slice(0, 8).map((x) => `${clipBytes(x.name, 40)} ${dur(x.seconds)}`);
642
+ return fitDsrText({
643
+ heads: [
644
+ { text: contentHead(d), maxBytes: 350 },
645
+ { text: sections.length ? listHead('Sections: ', sections, d.sections.length, 600, `Sections: ${d.sections.length}.`) : '', maxBytes: 600 },
646
+ ],
647
+ body: d.files.map((f) => ({ text: contentFileLine(f, n), kind: 'file' })),
648
+ more: ({ lines }) => `...${lines} more files`,
649
+ });
650
+ }
651
+ // ── tools ────────────────────────────────────────────────────────────────────
652
+ const VIEW = 'dsr-engagement';
653
+ const identity = z.string().min(1).max(1024);
654
+ const dsrInput = {
655
+ instanceId: z.string().describe('Instance ID (required)'),
656
+ canvasId: z
657
+ .string()
658
+ .describe('DSR canvas ID (required): from pitcher_search, pitcher_canvases_list, or canvas.id of pitcher_dsr_create'),
659
+ viewerType: z.enum(['external', 'internal', 'all']).optional().describe('Whose views: external (default), internal, all'),
660
+ window: z.enum(['30d', '90d', '365d']).optional().describe('Time window: 30d (default), 90d, 365d (last 12 months)'),
661
+ };
662
+ export const dsrEngagementTools = [
663
+ {
664
+ name: 'pitcher_analytics_dsr_view',
665
+ title: 'DSR engagement view (interactive)',
666
+ description: 'Interactive engagement view for one DSR (Digital Sales Room) canvas: who opened it, how often, how long. With ' +
667
+ "viewerIdentity it also returns that viewer's visits and time per page of each file. In hosts without UI it " +
668
+ "returns the same as text. Shows what Pitcher's Sharing Analytics shows the signed-in user. Find canvasId with " +
669
+ 'pitcher_search or pitcher_canvases_list. Viewer identities in the text are drill keys: pass them back unchanged.',
670
+ inputShape: {
671
+ ...dsrInput,
672
+ viewerIdentity: identity.optional().describe('A viewer identity exactly as an earlier result printed it'),
673
+ },
674
+ run: runDsrView,
675
+ toText: dsrViewText,
676
+ readOnly: true,
677
+ access: 'read', // PIT-10114: explicit for every app tool
678
+ group: 'analytics',
679
+ ui: { view: VIEW },
680
+ },
681
+ {
682
+ name: 'pitcher_analytics_dsr_viewers_page',
683
+ title: 'DSR viewers page (DSR engagement view UI helper)',
684
+ description: 'UI helper for the DSR engagement view: one page of 25 viewers with search and sort. For a plain data answer ' +
685
+ 'use pitcher_analytics_viewers with limit 25.',
686
+ inputShape: {
687
+ ...dsrInput,
688
+ page: z.number().int().min(1).optional().describe('1-based, 25 viewers a page'),
689
+ sortBy: z.enum(['last_active', 'total_engagement_time', 'session_count']).optional().describe('Default last_active'),
690
+ search: z.string().max(200).optional().describe('Case-sensitive part of a viewer identity'),
691
+ },
692
+ run: runDsrViewersPage,
693
+ toText: dsrViewersPageText,
694
+ readOnly: true,
695
+ access: 'read', // PIT-10114: explicit for every app tool
696
+ group: 'analytics',
697
+ ui: { view: VIEW, visibility: ['app'] },
698
+ },
699
+ {
700
+ name: 'pitcher_analytics_dsr_viewer',
701
+ title: 'DSR viewer drill (DSR engagement view UI helper)',
702
+ description: "UI helper for the DSR engagement view: one viewer's visits and time per page. Model path: " +
703
+ 'pitcher_analytics_dsr_view with viewerIdentity.',
704
+ inputShape: {
705
+ ...dsrInput,
706
+ viewerIdentity: identity.describe('The viewer identity exactly as listed'),
707
+ },
708
+ run: runDsrViewer,
709
+ toText: dsrViewerText,
710
+ readOnly: true,
711
+ access: 'read', // PIT-10114: explicit for every app tool
712
+ group: 'analytics',
713
+ ui: { view: VIEW, visibility: ['app'] },
714
+ },
715
+ {
716
+ name: 'pitcher_analytics_dsr_content',
717
+ title: 'DSR content read by recent viewers',
718
+ description: 'Most-read files and pages of one DSR canvas among its most recently active viewers (a sample of up to 15; the ' +
719
+ "result states how many it covers). Use it for 'which pages did the buyer team read'. It cannot say that nobody " +
720
+ 'opened a page, only that the covered viewers did not. In hosts with UI it opens the DSR engagement view on its ' +
721
+ 'Content tab.',
722
+ inputShape: {
723
+ ...dsrInput,
724
+ viewerIdentities: z
725
+ .array(identity)
726
+ .max(CONTENT_MAX_VIEWERS)
727
+ .optional()
728
+ .describe("UI use only: identities from the view's default list. Omit it."),
729
+ },
730
+ run: runDsrContent,
731
+ toText: dsrContentText,
732
+ readOnly: true,
733
+ access: 'read', // PIT-10114: explicit for every app tool
734
+ group: 'analytics',
735
+ ui: { view: VIEW, visibility: ['model', 'app'] },
736
+ },
737
+ ];