@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,152 @@
1
+ import type { DsrSessionEvent, DsrVisit, DsrVisitItem } from './dsr-visits.js';
2
+ /**
3
+ * DSR engagement view (PIT-10067) aggregation: pure functions over `summarizeVisits` output and the native
4
+ * `canvas-details/viewers` response. The visit rule stays in `dsr-visits.ts` (D8); this module only classifies
5
+ * items, sums them per file and page, ports the Sharing Analytics page-count rule and maps the viewers route.
6
+ */
7
+ export type DsrUnavailable = 'overview' | 'engagement' | 'total_shared' | 'viewer_share_counts' | 'most_viewed' | 'viewer_count' | 'viewer_list';
8
+ export interface DsrHeader {
9
+ canvasName: string;
10
+ totalSections: number;
11
+ totalFiles: number;
12
+ viewers: number;
13
+ visits: number;
14
+ engagedSeconds: number;
15
+ /** overview.total_shared: ALL viewers, the viewer filter does not apply to it (spec 3.1 fact 7). */
16
+ forwards: number;
17
+ mostViewedFile: {
18
+ id: string;
19
+ name: string;
20
+ seconds: number;
21
+ } | null;
22
+ mostViewedSection: {
23
+ name: string;
24
+ enters: number;
25
+ } | null;
26
+ /** viewers.items[0].last_active of a page 1 sorted by last_active desc (the only read toHeader serves). */
27
+ lastActivity: string | null;
28
+ }
29
+ export interface DsrViewerRow {
30
+ /** normalized_email ?? email, VERBATIM: the drill key. Never trimmed or lowercased. */
31
+ identity: string;
32
+ isExternal: boolean;
33
+ visits: number;
34
+ engagedSeconds: number;
35
+ /** The viewer's local wall-clock time, no offset (D12). */
36
+ lastActive: string | null;
37
+ sectionsViewed: number;
38
+ filesViewed: number;
39
+ /** share_count: forwards by this address, counted over all viewers. */
40
+ forwards: number;
41
+ }
42
+ export interface DsrPageStat {
43
+ page: number;
44
+ seconds: number;
45
+ views: number;
46
+ viewers?: number;
47
+ estimated: boolean;
48
+ }
49
+ export interface DsrFileAgg {
50
+ fileId: string;
51
+ name: string;
52
+ seconds: number;
53
+ views: number;
54
+ viewers?: number;
55
+ estimated: boolean;
56
+ observed: DsrPageStat[];
57
+ }
58
+ export interface DsrSectionAgg {
59
+ id: string;
60
+ name: string;
61
+ seconds: number;
62
+ views: number;
63
+ viewers?: number;
64
+ }
65
+ /** One viewer's timeline, keyed by the identity it was read with. `names` holds only real entity names. */
66
+ export interface DsrTimeline {
67
+ key: string;
68
+ visits: DsrVisit[];
69
+ names?: Record<string, string>;
70
+ }
71
+ export interface DsrFileMeta {
72
+ thumbnailUrl: string | null;
73
+ contentType: string | null;
74
+ contentLength: number | null;
75
+ thumbCount: number;
76
+ }
77
+ export declare const PAGES_MAX = 300;
78
+ /** A Pitcher file id. */
79
+ export declare const ULID: RegExp;
80
+ /**
81
+ * D9: a page of a ULID file, `<fileId>/page/<n>` or the older `<fileId>/<n>` (0-based). Stricter than the
82
+ * route's unanchored PAGE_INDEX_SUFFIX: it requires a ULID file id, so `CollectionPlayer-<uuid>/3` is no page.
83
+ */
84
+ export declare const PAGE_ID: RegExp;
85
+ export type ItemClass = {
86
+ kind: 'section';
87
+ } | {
88
+ kind: 'file';
89
+ fileId: string;
90
+ } | {
91
+ kind: 'page';
92
+ fileId: string;
93
+ page: number;
94
+ };
95
+ /**
96
+ * By id shape, never `type` alone: the sessions route types an old-shape page (`<fileId>/<n>`) as `File`
97
+ * (spec 3.1 fact 2), and `dsr-visits` then gives it no page number.
98
+ */
99
+ export declare function classifyItem(it: Pick<DsrVisitItem, 'type' | 'id'>): ItemClass;
100
+ /**
101
+ * Entity names worth showing. The sessions route names an entity `nameMap.get(id) || entity_type || entity_id`;
102
+ * only the id, or a type word coming from `entity_type`, counts as no name.
103
+ */
104
+ export declare function realNames(events: DsrSessionEvent[]): Record<string, string>;
105
+ export declare const fileFallbackName: (fileId: string) => string;
106
+ /**
107
+ * Per file and page over every visit of every timeline (spec 7.2 rules 1 to 3). Page items add to their file's
108
+ * page; file items to the file's own seconds and views. A file's seconds are the sum of its page seconds when it
109
+ * has any page item, else its own (D10, the viewers route's rule). Its views are its own Entered count, or, when
110
+ * only its pages were tracked, the number of visits that opened one of them. `viewers` counts distinct timeline
111
+ * keys and is set only when `countViewers`.
112
+ */
113
+ export declare function aggregateFiles(timelines: DsrTimeline[], opts?: {
114
+ countViewers?: boolean;
115
+ }): {
116
+ files: DsrFileAgg[];
117
+ sections: DsrSectionAgg[];
118
+ };
119
+ /**
120
+ * D11, the Sharing Analytics `SessionTimeline` page-count rule (spec 3.1 fact 12): video and audio have no pages;
121
+ * `meta = contentLength ?? thumbCount`; no observed pages: `meta >= 2 ? meta : 0`; `meta >= 2`: the larger of meta
122
+ * and the highest observed page; else the observed pages only (`dense: false`, no "of N").
123
+ */
124
+ export declare function pageCountFor(meta: DsrFileMeta | undefined, observed: DsrPageStat[]): {
125
+ pageCount: number;
126
+ dense: boolean;
127
+ };
128
+ /** Dense: pages 1..pageCount with zeros; else the observed pages ascending. Capped at PAGES_MAX. */
129
+ export declare function densePages(observed: DsrPageStat[], pageCount: number, dense: boolean, max?: number): {
130
+ pages: DsrPageStat[];
131
+ pagesTruncated?: number;
132
+ };
133
+ /** `stripExternalMarker` of `@pitcher/analytics-shared` (case kept; a bare marker keeps the raw value). */
134
+ export declare function stripExternalMarker(identity: string): string;
135
+ /**
136
+ * Content sample keys (R1-09): the sessions route matches `{verbatim, stripped, stripped + marker}`, so a stripped
137
+ * key covers both forms of an address. First occurrence wins; at most `max` keys.
138
+ */
139
+ export declare function dedupeIdentities(ids: string[], max: number): string[];
140
+ export declare function toViewerRow(row: Record<string, unknown>): DsrViewerRow;
141
+ /** The header of a native viewers response read as page 1 sorted by last_active desc. */
142
+ export declare function toHeader(native: Record<string, unknown>): DsrHeader;
143
+ export declare const DSR_UNAVAILABLE: readonly DsrUnavailable[];
144
+ export declare function unavailableOf(native: Record<string, unknown>): DsrUnavailable[];
145
+ /** "2026-11-08 14:05" from the viewer's naive wall-clock; "unknown-date" for null; anything else verbatim. */
146
+ export declare function naiveMinute(ts: string | null | undefined): string;
147
+ /** "2026-11-08", as naiveMinute. */
148
+ export declare function naiveDate(ts: string | null | undefined): string;
149
+ /** D20, Sharing Analytics `formatDuration`: "45s", "22m", "2h", "1h 12m" (floored). */
150
+ export declare function fmtDuration(seconds: number): string;
151
+ /** "3-6, 9, 12-14" from ascending or unsorted page numbers. */
152
+ export declare function compressRanges(nums: number[]): string;
@@ -0,0 +1,301 @@
1
+ export const PAGES_MAX = 300;
2
+ /** A Pitcher file id. */
3
+ export const ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
4
+ /**
5
+ * D9: a page of a ULID file, `<fileId>/page/<n>` or the older `<fileId>/<n>` (0-based). Stricter than the
6
+ * route's unanchored PAGE_INDEX_SUFFIX: it requires a ULID file id, so `CollectionPlayer-<uuid>/3` is no page.
7
+ */
8
+ export const PAGE_ID = /^([0-9A-HJKMNP-TV-Z]{26})\/(?:page\/)?(\d+)$/i;
9
+ /**
10
+ * By id shape, never `type` alone: the sessions route types an old-shape page (`<fileId>/<n>`) as `File`
11
+ * (spec 3.1 fact 2), and `dsr-visits` then gives it no page number.
12
+ */
13
+ export function classifyItem(it) {
14
+ if (it.type === 'section')
15
+ return { kind: 'section' };
16
+ const m = PAGE_ID.exec(it.id);
17
+ return m ? { kind: 'page', fileId: m[1], page: Number(m[2]) + 1 } : { kind: 'file', fileId: it.id };
18
+ }
19
+ /**
20
+ * Event and component type words: the only `entity_type` values that are a fallback rather than a name. The
21
+ * sessions route selects `whatType AS entity_type`, and for DSR File, Page and Section events `whatType` is the
22
+ * item's tracked TITLE, so a name equal to `entity_type` is usually the real one (PR 6010 review). The component
23
+ * names are the route's `COMPONENT_TYPES_WITH_FILE_TRACKING` (`@pitcher/analytics-shared`'s
24
+ * COLLECTION_COMPONENT_EVENT_NAMES plus ContentPlayer); cli-scripts does not depend on that package. SectionList,
25
+ * Multimedia, Grid and Text are canvas component types also seen in `whatType` on EU DSR traffic.
26
+ */
27
+ const TYPE_WORDS = new Set([
28
+ 'File',
29
+ 'Page',
30
+ 'Section',
31
+ 'Canvas',
32
+ 'CollectionPlayer',
33
+ 'Collection',
34
+ 'ContentPlayer',
35
+ 'SectionList',
36
+ 'Multimedia',
37
+ 'Grid',
38
+ 'Text',
39
+ ]);
40
+ /**
41
+ * Entity names worth showing. The sessions route names an entity `nameMap.get(id) || entity_type || entity_id`;
42
+ * only the id, or a type word coming from `entity_type`, counts as no name.
43
+ */
44
+ export function realNames(events) {
45
+ const out = {};
46
+ for (const e of events) {
47
+ const n = e.entity_name;
48
+ if (!n || n === e.entity_id || (n === e.entity_type && TYPE_WORDS.has(n)) || e.entity_id in out)
49
+ continue;
50
+ out[e.entity_id] = n;
51
+ }
52
+ return out;
53
+ }
54
+ export const fileFallbackName = (fileId) => `File ${fileId.slice(0, 8)}`;
55
+ /**
56
+ * Per file and page over every visit of every timeline (spec 7.2 rules 1 to 3). Page items add to their file's
57
+ * page; file items to the file's own seconds and views. A file's seconds are the sum of its page seconds when it
58
+ * has any page item, else its own (D10, the viewers route's rule). Its views are its own Entered count, or, when
59
+ * only its pages were tracked, the number of visits that opened one of them. `viewers` counts distinct timeline
60
+ * keys and is set only when `countViewers`.
61
+ */
62
+ export function aggregateFiles(timelines, opts = {}) {
63
+ const files = new Map();
64
+ const sections = new Map();
65
+ const fileAcc = (fileId, names) => {
66
+ let f = files.get(fileId);
67
+ if (!f) {
68
+ f = {
69
+ fileId,
70
+ name: names[fileId] ?? null,
71
+ ownSeconds: 0,
72
+ ownViews: 0,
73
+ ownEstimated: false,
74
+ hasFileItem: false,
75
+ pages: new Map(),
76
+ keys: new Set(),
77
+ visitsTouching: 0,
78
+ };
79
+ files.set(fileId, f);
80
+ }
81
+ else if (!f.name && names[fileId]) {
82
+ f.name = names[fileId];
83
+ }
84
+ return f;
85
+ };
86
+ for (const t of timelines) {
87
+ const names = t.names ?? {};
88
+ for (const v of t.visits) {
89
+ const touched = new Set();
90
+ for (const it of v.items) {
91
+ const c = classifyItem(it);
92
+ if (c.kind === 'section') {
93
+ let s = sections.get(it.id);
94
+ if (!s) {
95
+ s = { id: it.id, name: names[it.id] ?? it.name, seconds: 0, views: 0, keys: new Set() };
96
+ sections.set(it.id, s);
97
+ }
98
+ s.seconds += it.seconds;
99
+ s.views += it.views;
100
+ s.keys.add(t.key);
101
+ continue;
102
+ }
103
+ const f = fileAcc(c.fileId, names);
104
+ f.keys.add(t.key);
105
+ touched.add(c.fileId);
106
+ if (c.kind === 'file') {
107
+ f.hasFileItem = true;
108
+ f.ownSeconds += it.seconds;
109
+ f.ownViews += it.views;
110
+ f.ownEstimated || (f.ownEstimated = it.estimated);
111
+ }
112
+ else {
113
+ let p = f.pages.get(c.page);
114
+ if (!p) {
115
+ p = { seconds: 0, views: 0, estimated: false, keys: new Set() };
116
+ f.pages.set(c.page, p);
117
+ }
118
+ p.seconds += it.seconds;
119
+ p.views += it.views;
120
+ p.estimated || (p.estimated = it.estimated);
121
+ p.keys.add(t.key);
122
+ }
123
+ }
124
+ for (const id of touched)
125
+ files.get(id).visitsTouching += 1;
126
+ }
127
+ }
128
+ const count = opts.countViewers === true;
129
+ const outFiles = Array.from(files.values()).map((f) => {
130
+ const observed = Array.from(f.pages.entries())
131
+ .sort(([a], [b]) => a - b)
132
+ .map(([page, p]) => ({
133
+ page,
134
+ seconds: p.seconds,
135
+ views: p.views,
136
+ ...(count ? { viewers: p.keys.size } : {}),
137
+ estimated: p.estimated,
138
+ }));
139
+ const paged = observed.length > 0;
140
+ return {
141
+ fileId: f.fileId,
142
+ name: f.name ?? fileFallbackName(f.fileId),
143
+ seconds: paged ? observed.reduce((n, p) => n + p.seconds, 0) : f.ownSeconds,
144
+ views: f.hasFileItem && f.ownViews > 0 ? f.ownViews : f.visitsTouching,
145
+ ...(count ? { viewers: f.keys.size } : {}),
146
+ estimated: paged ? observed.some((p) => p.estimated) : f.ownEstimated,
147
+ observed,
148
+ };
149
+ });
150
+ outFiles.sort((a, b) => b.seconds - a.seconds || a.name.localeCompare(b.name, 'en') || a.fileId.localeCompare(b.fileId));
151
+ const outSections = Array.from(sections.values())
152
+ .map(({ keys, ...s }) => (count ? { ...s, viewers: keys.size } : s))
153
+ .sort((a, b) => b.seconds - a.seconds || a.name.localeCompare(b.name, 'en'));
154
+ return { files: outFiles, sections: outSections };
155
+ }
156
+ /**
157
+ * D11, the Sharing Analytics `SessionTimeline` page-count rule (spec 3.1 fact 12): video and audio have no pages;
158
+ * `meta = contentLength ?? thumbCount`; no observed pages: `meta >= 2 ? meta : 0`; `meta >= 2`: the larger of meta
159
+ * and the highest observed page; else the observed pages only (`dense: false`, no "of N").
160
+ */
161
+ export function pageCountFor(meta, observed) {
162
+ const type = (meta?.contentType ?? '').toLowerCase();
163
+ if (type.startsWith('video') || type.startsWith('audio'))
164
+ return { pageCount: 0, dense: false };
165
+ const m = meta ? (meta.contentLength ?? meta.thumbCount) : 0;
166
+ if (!observed.length)
167
+ return m >= 2 ? { pageCount: m, dense: true } : { pageCount: 0, dense: false };
168
+ if (m >= 2)
169
+ return { pageCount: Math.max(m, ...observed.map((p) => p.page)), dense: true };
170
+ return { pageCount: new Set(observed.map((p) => p.page)).size, dense: false };
171
+ }
172
+ /** Dense: pages 1..pageCount with zeros; else the observed pages ascending. Capped at PAGES_MAX. */
173
+ export function densePages(observed, pageCount, dense, max = PAGES_MAX) {
174
+ if (pageCount === 0)
175
+ return { pages: [] };
176
+ let pages;
177
+ if (dense) {
178
+ const byPage = new Map(observed.map((p) => [p.page, p]));
179
+ const withViewers = observed.some((p) => p.viewers !== undefined);
180
+ // build only what is kept: pageCount comes from the file's metadata, unbounded
181
+ pages = Array.from({ length: Math.min(pageCount, max) }, (_, i) => byPage.get(i + 1) ?? { page: i + 1, seconds: 0, views: 0, ...(withViewers ? { viewers: 0 } : {}), estimated: false });
182
+ return pageCount > max ? { pages, pagesTruncated: pageCount - max } : { pages };
183
+ }
184
+ pages = [...observed].sort((a, b) => a.page - b.page);
185
+ return pages.length > max ? { pages: pages.slice(0, max), pagesTruncated: pages.length - max } : { pages };
186
+ }
187
+ const EXTERNAL_MARKER = ' (External)';
188
+ /** `stripExternalMarker` of `@pitcher/analytics-shared` (case kept; a bare marker keeps the raw value). */
189
+ export function stripExternalMarker(identity) {
190
+ if (!identity.endsWith(EXTERNAL_MARKER))
191
+ return identity;
192
+ return identity.slice(0, -EXTERNAL_MARKER.length) || identity;
193
+ }
194
+ /**
195
+ * Content sample keys (R1-09): the sessions route matches `{verbatim, stripped, stripped + marker}`, so a stripped
196
+ * key covers both forms of an address. First occurrence wins; at most `max` keys.
197
+ */
198
+ export function dedupeIdentities(ids, max) {
199
+ const out = [];
200
+ const seen = new Set();
201
+ for (const id of ids) {
202
+ const key = stripExternalMarker(id);
203
+ if (!key || seen.has(key))
204
+ continue;
205
+ seen.add(key);
206
+ out.push(key);
207
+ if (out.length >= max)
208
+ break;
209
+ }
210
+ return out;
211
+ }
212
+ const num = (x) => Number(x) || 0;
213
+ const str = (x) => (typeof x === 'string' && x !== '' ? x : null);
214
+ const obj = (x) => x && typeof x === 'object' ? x : {};
215
+ export function toViewerRow(row) {
216
+ const identity = typeof row.normalized_email === 'string' ? row.normalized_email : typeof row.email === 'string' ? row.email : '';
217
+ return {
218
+ identity,
219
+ isExternal: row.is_external === true,
220
+ visits: num(row.session_count),
221
+ engagedSeconds: num(row.total_engagement_time),
222
+ lastActive: str(row.last_active),
223
+ sectionsViewed: num(row.sections_viewed),
224
+ filesViewed: num(row.files_viewed),
225
+ forwards: num(row.share_count),
226
+ };
227
+ }
228
+ /** The header of a native viewers response read as page 1 sorted by last_active desc. */
229
+ export function toHeader(native) {
230
+ const o = obj(native.overview);
231
+ const val = (k) => num(obj(o[k]).value);
232
+ const file = o.most_viewed_file ? obj(o.most_viewed_file) : null;
233
+ const section = o.most_viewed_section ? obj(o.most_viewed_section) : null;
234
+ const items = obj(native.viewers).items;
235
+ const first = Array.isArray(items) && items.length ? obj(items[0]) : null;
236
+ return {
237
+ canvasName: typeof native.canvas_name === 'string' ? native.canvas_name : '',
238
+ totalSections: num(native.total_sections),
239
+ totalFiles: num(native.total_files),
240
+ viewers: val('total_viewers'),
241
+ visits: val('total_sessions'),
242
+ engagedSeconds: val('total_engagement_time'),
243
+ forwards: val('total_shared'),
244
+ mostViewedFile: file ? { id: String(file.id ?? ''), name: String(file.name ?? ''), seconds: num(file.value) } : null,
245
+ mostViewedSection: section ? { name: String(section.name ?? ''), enters: num(section.value) } : null,
246
+ lastActivity: first ? str(first.last_active) : null,
247
+ };
248
+ }
249
+ export const DSR_UNAVAILABLE = [
250
+ 'overview',
251
+ 'engagement',
252
+ 'total_shared',
253
+ 'viewer_share_counts',
254
+ 'most_viewed',
255
+ 'viewer_count',
256
+ 'viewer_list',
257
+ ];
258
+ export function unavailableOf(native) {
259
+ const u = obj(native._meta).unavailable;
260
+ return Array.isArray(u) ? u.filter((x) => DSR_UNAVAILABLE.includes(x)) : [];
261
+ }
262
+ // ── naive timestamps (D12) and durations (D20) ───────────────────────────────
263
+ const NAIVE = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::\d{2})?$/;
264
+ /** "2026-11-08 14:05" from the viewer's naive wall-clock; "unknown-date" for null; anything else verbatim. */
265
+ export function naiveMinute(ts) {
266
+ if (!ts)
267
+ return 'unknown-date';
268
+ const m = NAIVE.exec(ts);
269
+ return m ? `${m[1]} ${m[2]}` : ts;
270
+ }
271
+ /** "2026-11-08", as naiveMinute. */
272
+ export function naiveDate(ts) {
273
+ if (!ts)
274
+ return 'unknown-date';
275
+ const m = NAIVE.exec(ts);
276
+ return m ? m[1] : ts;
277
+ }
278
+ /** D20, Sharing Analytics `formatDuration`: "45s", "22m", "2h", "1h 12m" (floored). */
279
+ export function fmtDuration(seconds) {
280
+ const s = Math.max(0, Math.floor(seconds));
281
+ if (s < 60)
282
+ return `${s}s`;
283
+ if (s < 3600)
284
+ return `${Math.floor(s / 60)}m`;
285
+ const h = Math.floor(s / 3600);
286
+ const m = Math.floor((s % 3600) / 60);
287
+ return m ? `${h}h ${m}m` : `${h}h`;
288
+ }
289
+ /** "3-6, 9, 12-14" from ascending or unsorted page numbers. */
290
+ export function compressRanges(nums) {
291
+ const xs = Array.from(new Set(nums)).sort((a, b) => a - b);
292
+ const parts = [];
293
+ for (let i = 0; i < xs.length;) {
294
+ let j = i;
295
+ while (j + 1 < xs.length && xs[j + 1] === xs[j] + 1)
296
+ j += 1;
297
+ parts.push(i === j ? String(xs[i]) : `${xs[i]}-${xs[j]}`);
298
+ i = j + 1;
299
+ }
300
+ return parts.join(', ');
301
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * One DSR viewer's event timeline (`/analytics/canvas-details/sessions`) → visits, and per visit the
3
+ * sections, files and pages viewed with their time (PIT-9589). The visit rule is the Sharing
4
+ * Analytics app's (`packages/apps/canvas-analytics/src/lib/reconstructSessions.ts`): a visit opens
5
+ * on an `Entered` of the canvas itself and is keyed by its `event_pair_id`; its engaged time is the
6
+ * sum of its focus segments (canvas Entered → Exited). Change them together.
7
+ *
8
+ * Item time: the sum of the item's `Exited` durations (`forhowlong`, seconds) in the visit. An
9
+ * `Entered` with no `Exited` (the unload beacon was dropped, PIT-5833) or an `Exited` reporting 0 is
10
+ * estimated from the gap to the next event of the visit, as the app's timeline does, and flagged
11
+ * `estimated`.
12
+ */
13
+ export interface DsrSessionEvent {
14
+ timestamp: string;
15
+ action: string;
16
+ entity_id: string;
17
+ entity_type?: string;
18
+ entity_name?: string;
19
+ original_event_name: string;
20
+ duration?: number;
21
+ event_pair_id?: string;
22
+ }
23
+ export interface DsrVisitItem {
24
+ type: 'section' | 'file' | 'page';
25
+ id: string;
26
+ name: string;
27
+ page_number?: number;
28
+ views: number;
29
+ seconds: number;
30
+ estimated: boolean;
31
+ }
32
+ export interface DsrVisit {
33
+ visit: number;
34
+ started_at: string;
35
+ ended_at: string;
36
+ engaged_seconds: number;
37
+ items: DsrVisitItem[];
38
+ }
39
+ /** `reconstructSessions`' grouping: events from an anchor `Entered` until the next one, by pair key. */
40
+ export declare function groupVisits(events: DsrSessionEvent[], anchorId: string): DsrSessionEvent[][];
41
+ /** `computeEngagementDuration`: the wall-clock of each canvas focus segment, summed. */
42
+ export declare function engagedSeconds(events: DsrSessionEvent[], anchorId: string): number;
43
+ export declare function summarizeVisits(events: DsrSessionEvent[], canvasId: string): DsrVisit[];
44
+ /** RFC 4180 cell; a leading = + - @ is prefixed with ' so a spreadsheet never runs it as a formula. */
45
+ export declare function csvCell(v: unknown): string;
46
+ export declare const VISITS_CSV_HEADER: string[];
47
+ export declare function visitsToCsv(viewers: Array<{
48
+ viewer: string;
49
+ visits: DsrVisit[];
50
+ }>): string;