@pitcher/cli-scripts 3.2.0 → 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 (60) hide show
  1. package/dist/src/commands/dsrLanding.js +1 -1
  2. package/dist/src/commands/roleplay.d.ts +154 -3
  3. package/dist/src/commands/roleplay.js +276 -23
  4. package/dist/src/core/dsr-engagement.d.ts +152 -0
  5. package/dist/src/core/dsr-engagement.js +301 -0
  6. package/dist/src/core/envelope.d.ts +1 -0
  7. package/dist/src/core/envelope.js +6 -1
  8. package/dist/src/core/errors.d.ts +3 -1
  9. package/dist/src/core/errors.js +2 -2
  10. package/dist/src/core/http.js +7 -5
  11. package/dist/src/core/ld.js +2 -1
  12. package/dist/src/core/me.d.ts +3 -0
  13. package/dist/src/core/me.js +10 -0
  14. package/dist/src/core/roleplay-stats.d.ts +48 -7
  15. package/dist/src/core/roleplay-stats.js +68 -11
  16. package/dist/src/mcp/apps/confirm/define.d.ts +66 -0
  17. package/dist/src/mcp/apps/confirm/define.js +208 -0
  18. package/dist/src/mcp/apps/confirm/hash.d.ts +4 -0
  19. package/dist/src/mcp/apps/confirm/hash.js +23 -0
  20. package/dist/src/mcp/apps/confirm/types.d.ts +96 -0
  21. package/dist/src/mcp/apps/confirm/types.js +15 -0
  22. package/dist/src/mcp/apps/contentPicker.d.ts +104 -0
  23. package/dist/src/mcp/apps/contentPicker.js +268 -0
  24. package/dist/src/mcp/apps/dsrEngagement.d.ts +194 -0
  25. package/dist/src/mcp/apps/dsrEngagement.js +737 -0
  26. package/dist/src/mcp/apps/registry.d.ts +14 -0
  27. package/dist/src/mcp/apps/registry.js +25 -0
  28. package/dist/src/mcp/apps/roleplayManager.d.ts +228 -0
  29. package/dist/src/mcp/apps/roleplayManager.js +606 -0
  30. package/dist/src/mcp/apps/roleplayReps.d.ts +79 -0
  31. package/dist/src/mcp/apps/roleplayReps.js +328 -0
  32. package/dist/src/mcp/apps/roleplayWrites.d.ts +6 -0
  33. package/dist/src/mcp/apps/roleplayWrites.js +509 -0
  34. package/dist/src/mcp/apps/text.d.ts +41 -0
  35. package/dist/src/mcp/apps/text.js +58 -0
  36. package/dist/src/mcp/apps/types.d.ts +23 -0
  37. package/dist/src/mcp/apps/types.js +12 -0
  38. package/dist/src/mcp/apps/writeConfirm.d.ts +3 -0
  39. package/dist/src/mcp/apps/writeConfirm.js +8 -0
  40. package/dist/src/mcp/groups.d.ts +42 -0
  41. package/dist/src/mcp/groups.js +89 -0
  42. package/dist/src/mcp/hosted.d.ts +36 -0
  43. package/dist/src/mcp/hosted.js +47 -1
  44. package/dist/src/mcp/index.d.ts +9 -3
  45. package/dist/src/mcp/index.js +9 -2
  46. package/dist/src/mcp/prompts/registry.js +8 -7
  47. package/dist/src/mcp/prompts/types.d.ts +3 -0
  48. package/dist/src/mcp/registry.d.ts +4 -0
  49. package/dist/src/mcp/registry.js +29 -24
  50. package/dist/src/mcp/renderPolicy.d.ts +1 -0
  51. package/dist/src/mcp/renderPolicy.js +8 -2
  52. package/dist/src/mcp/resources/registry.js +2 -2
  53. package/dist/src/mcp/resources/types.d.ts +3 -0
  54. package/dist/src/mcp/result.d.ts +2 -1
  55. package/dist/src/mcp/result.js +8 -6
  56. package/dist/src/mcp/server.d.ts +62 -0
  57. package/dist/src/mcp/server.js +161 -10
  58. package/dist/src/mcp/tools/roleplay.js +34 -5
  59. package/dist/src/mcp/tools/types.d.ts +35 -0
  60. package/package.json +2 -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
+ }
@@ -6,6 +6,7 @@ export interface Envelope<T> {
6
6
  code: ErrorCodeName;
7
7
  exitCode: number;
8
8
  message: string;
9
+ backendStatus?: number;
9
10
  };
10
11
  warnings?: string[];
11
12
  }
@@ -4,7 +4,12 @@ export function ok(data, warnings) {
4
4
  export function fail(error, warnings) {
5
5
  return {
6
6
  ok: false,
7
- error: { code: error.code, exitCode: error.exitCode, message: error.message },
7
+ error: {
8
+ code: error.code,
9
+ exitCode: error.exitCode,
10
+ message: error.message,
11
+ ...(error.backendStatus ? { backendStatus: error.backendStatus } : {}),
12
+ },
8
13
  ...(warnings?.length ? { warnings } : {}),
9
14
  };
10
15
  }
@@ -13,6 +13,8 @@ export interface CliError {
13
13
  code: ErrorCodeName;
14
14
  exitCode: number;
15
15
  message: string;
16
+ /** HTTP status of the backend's error answer, when the failure came from a backend response (PIT-10114 audit). */
17
+ backendStatus?: number;
16
18
  }
17
- export declare function createCliError(code: ErrorCodeName, message: string): CliError;
19
+ export declare function createCliError(code: ErrorCodeName, message: string, backendStatus?: number): CliError;
18
20
  export declare function isCliError(e: unknown): e is CliError;
@@ -7,8 +7,8 @@ export const ERROR_CODES = {
7
7
  NOT_FOUND: 5,
8
8
  CONFLICT: 6,
9
9
  };
10
- export function createCliError(code, message) {
11
- return { __cliError: true, code, exitCode: ERROR_CODES[code], message };
10
+ export function createCliError(code, message, backendStatus) {
11
+ return { __cliError: true, code, exitCode: ERROR_CODES[code], message, ...(backendStatus ? { backendStatus } : {}) };
12
12
  }
13
13
  export function isCliError(e) {
14
14
  if (typeof e !== 'object' || e === null)
@@ -242,15 +242,17 @@ export function toCliError(e) {
242
242
  const apiMessage = extractApiMessage(data, err.message || 'Request failed');
243
243
  const status = err.response?.status;
244
244
  if (typeof status === 'number') {
245
+ // backendStatus: the backend answered (PIT-10114). A backend 4xx also maps to VALIDATION, so the code alone
246
+ // cannot tell a local check from a backend answer.
245
247
  if (status === 401 || status === 403)
246
- return createCliError('AUTH', apiMessage);
248
+ return createCliError('AUTH', apiMessage, status);
247
249
  if (status === 404)
248
- return createCliError('NOT_FOUND', apiMessage);
250
+ return createCliError('NOT_FOUND', apiMessage, status);
249
251
  if (status === 409)
250
- return createCliError('CONFLICT', apiMessage);
252
+ return createCliError('CONFLICT', apiMessage, status);
251
253
  if (status >= 400 && status < 500)
252
- return createCliError('VALIDATION', apiMessage);
253
- return createCliError('NETWORK', apiMessage);
254
+ return createCliError('VALIDATION', apiMessage, status);
255
+ return createCliError('NETWORK', apiMessage, status);
254
256
  }
255
257
  if (err.isAxiosError)
256
258
  return createCliError('NETWORK', apiMessage);
@@ -423,7 +423,8 @@ export function toLdCliError(e) {
423
423
  : s && s >= 400 && s < 500
424
424
  ? 'VALIDATION'
425
425
  : 'NETWORK';
426
- return createCliError(code, msg);
426
+ // the L&D backend answered: keep its status for the PIT-10114 audit (`backendStatus`)
427
+ return createCliError(code, msg, typeof s === 'number' ? s : undefined);
427
428
  }
428
429
  return toCliError(e);
429
430
  }
@@ -0,0 +1,3 @@
1
+ import type { AxiosInstance } from 'axios';
2
+ /** The caller's Pitcher user id (Django `GET /api/v1/users/me/`), as a string. PIT-10113: the actor of a confirmed write. */
3
+ export declare function getMeId(client: AxiosInstance): Promise<string>;
@@ -0,0 +1,10 @@
1
+ import { createCliError } from './errors.js';
2
+ /** The caller's Pitcher user id (Django `GET /api/v1/users/me/`), as a string. PIT-10113: the actor of a confirmed write. */
3
+ export async function getMeId(client) {
4
+ const res = await client.get('/api/v1/users/me/');
5
+ const id = res.data?.id;
6
+ const s = typeof id === 'number' ? String(id) : typeof id === 'string' ? id : '';
7
+ if (!/^[0-9]{1,9}$/.test(s))
8
+ throw createCliError('AUTH', 'could not read your Pitcher user id');
9
+ return s;
10
+ }
@@ -2,8 +2,11 @@
2
2
  * The AI Roleplay manager-app's per-rep rules, ported 1:1 so the MCP team tool and the manager
3
3
  * app give the same answer on the same data (PIT-9200). Sources, in ng-production-apps
4
4
  * `pitcher-coach/manager-app/src/`: `lib/session-score.ts` (reading a score), `lib/stats.ts`
5
- * (best, mean, trend), `lib/status.ts` (`deriveStatus`) and `hooks/useManagerData.ts`
6
- * (`getAssignmentSessions`, `getRepStats`). Change them together.
5
+ * (best, mean, trend, `normalizeScore`), `lib/status.ts` (`deriveStatus`),
6
+ * `hooks/useManagerData.ts` (`getAssignmentSessions`, `getRepStats`),
7
+ * `components/SkillBreakdown.tsx` (`latestSkills`) and `lib/api.ts` (`loadAssignmentProgress`,
8
+ * ported as `loadTeamModel` in `commands/roleplay.ts`). Since PIT-9971 status, session count, best
9
+ * score and last session come from the server's pass progress when it is known. Change them together.
7
10
  *
8
11
  * The one rule every aggregate here obeys: a session is unscored iff `overallScore` is not a
9
12
  * finite number. An unscored session is never read as 0.
@@ -15,11 +18,13 @@ export interface RoleplaySessionLike {
15
18
  overallScore?: number | null;
16
19
  durationMs?: number | null;
17
20
  evaluation?: {
21
+ grading_scale?: number | string | null;
18
22
  category_scores?: Array<{
19
23
  id?: string;
20
24
  skillId?: string;
21
25
  title?: string;
22
26
  score?: number;
27
+ max_score?: number | string | null;
23
28
  }>;
24
29
  } | null;
25
30
  }
@@ -32,6 +37,14 @@ export interface RoleplayAssignmentLike {
32
37
  resolvedUserIds: string[];
33
38
  }
34
39
  export type RepStatus = 'passed' | 'in_progress' | 'not_started' | 'overdue' | 'needs_coaching';
40
+ /** next-core's pass progress for one (assignment, rep) pair over ALL its sessions (PIT-9971). */
41
+ export interface AssignmentProgressLike {
42
+ passed: boolean;
43
+ passSource: 'stored' | 'live' | null;
44
+ attempts: number;
45
+ bestScore: number | null;
46
+ latestAt: string | null;
47
+ }
35
48
  /** The session's score, or null when it has none (`toFiniteOrNull`). */
36
49
  export declare function sessionScore(session: RoleplaySessionLike): number | null;
37
50
  /** Mean of the finite values, rounded, or null when none are finite (`meanOfFinite`). */
@@ -46,10 +59,13 @@ export declare const scoredCount: (sessions: RoleplaySessionLike[]) => number;
46
59
  /** Up to 3 sessions per end, capped at half the scored series (`calcTrend`). */
47
60
  export declare function calcTrend(sessions: RoleplaySessionLike[]): number | null;
48
61
  /**
49
- * `deriveStatus`: the raw best score is compared with `passingScore` as stored (legacy
50
- * evaluations may grade on a 5-point scale; the manager app does not normalise either).
62
+ * `deriveStatus`: the raw best score is compared with `passingScore` as stored, as the manager app
63
+ * does. `overallScore` is 0..100 on every evaluation, legacy ones included (their `grading_scale` of 5
64
+ * applies to the category scores only), so never normalise it here.
65
+ * With `progress`, passed and started come from the server; only `needs_coaching` (a judgement
66
+ * on the recent trend) reads the capped list. Without it, everything comes from the list.
51
67
  */
52
- export declare function deriveStatus(sessions: RoleplaySessionLike[], assignment: RoleplayAssignmentLike, now?: Date): RepStatus;
68
+ export declare function deriveStatus(sessions: RoleplaySessionLike[], assignment: RoleplayAssignmentLike, now?: Date, progress?: AssignmentProgressLike | null): RepStatus;
53
69
  /** `getAssignmentSessions`: same scenario, a rep of the assignment, on or after it was created. */
54
70
  export declare function assignmentSessions<T extends RoleplaySessionLike>(sessions: T[], assignment: RoleplayAssignmentLike): T[];
55
71
  export interface RepStats {
@@ -62,9 +78,34 @@ export interface RepStats {
62
78
  status: RepStatus;
63
79
  last_session_at: string | null;
64
80
  total_duration_ms: number;
81
+ /** Progress counts more attempts than the list holds: avg_score, scored_sessions and trend cover recent sessions only. */
82
+ recent_only: boolean;
83
+ pass_source: 'stored' | 'live' | null;
65
84
  }
66
- /** `getRepStats` for one rep of one assignment, without the raw sessions. */
67
- export declare function repStats(userId: string, sessions: RoleplaySessionLike[], assignment: RoleplayAssignmentLike, now?: Date): RepStats;
85
+ /**
86
+ * `getRepStats` for one rep of one assignment, without the raw sessions. With `progress`, sessions,
87
+ * best score and last session come from the server; average, scored count and trend stay on the list.
88
+ */
89
+ export declare function repStats(userId: string, sessions: RoleplaySessionLike[], assignment: RoleplayAssignmentLike, now?: Date, progress?: AssignmentProgressLike | null): RepStats;
90
+ /** A grading scale: a finite positive number, or a string `Number()` turns into one; else null (D28). */
91
+ export declare function scaleOrNull(v: unknown): number | null;
92
+ /** `normalizeScore` (manager-app `lib/stats.ts`): a score on `max` as 0..100; no usable max = as is. */
93
+ export declare function normalizeScore(score: number, max?: number | null): number;
94
+ export interface LatestSkill {
95
+ skillId: string;
96
+ title: string;
97
+ score: number;
98
+ }
99
+ /**
100
+ * The Rep panel's skills (`SkillBreakdown`): the NEWEST session with at least one category score,
101
+ * scored or not, its categories normalised on `max_score ?? grading_scale ?? 100`, weakest first.
102
+ * Categories without a finite score are skipped; null when none remain. Never averaged across
103
+ * sessions, so 5-point and 100-point evaluations are never mixed.
104
+ */
105
+ export declare function latestSkills<T extends RoleplaySessionLike>(sessions: T[]): {
106
+ session: T;
107
+ skills: LatestSkill[];
108
+ } | null;
68
109
  export interface SkillAverage {
69
110
  skill_id: string;
70
111
  title: string;