@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,606 @@
1
+ import { z } from 'zod';
2
+ import { compareReps, finiteOrNull, loadTeamModel, progressKey, REP_STATUSES, runRoleplaySessionGet, STATUS_RANK, } from '../../commands/roleplay.js';
3
+ import { ok, fail } from '../../core/envelope.js';
4
+ import { createCliError } from '../../core/errors.js';
5
+ import { clientFor, toCliError } from '../../core/http.js';
6
+ import { assignmentSessions, latestSkills, meanOfFinite, normalizeScore, repStats, scoredCount, sessionScore, } from '../../core/roleplay-stats.js';
7
+ import { byteLen, clipBytes, fitText as fitAppText, jsonBytes, listHead } from './text.js';
8
+ /**
9
+ * MCP Apps AI Roleplay manager view (PIT-10068): a KPI header and the assignment grid, then drills into one
10
+ * assignment's reps, one rep's sessions and one session's scorecard. Read-only. Every tool derives from
11
+ * `loadTeamModel`, the loader `pitcher_roleplay_team` uses, so the view and the text tool never disagree.
12
+ * The runners live here, not in commands/roleplay.ts: registry.test.ts requires every `run*` of a command
13
+ * module to back exactly one `allTools` tool, and app tools are not in `allTools` (D25).
14
+ */
15
+ export const roleplayManagerView = {
16
+ name: 'roleplay-manager',
17
+ title: 'Pitcher AI Roleplay team view',
18
+ description: 'Team assignment completion, rep drill and session scorecard for AI Roleplay managers.',
19
+ // csp omitted: block all external origins (no thumbnails; avatars are initials)
20
+ };
21
+ /** structuredContent budget per result (the picker's, PIT-10066 D14). */
22
+ export const ROLEPLAY_VIEW_MAX_BYTES = 120000;
23
+ /** Model-facing text budget per result, in UTF-8 bytes. */
24
+ export const ROLEPLAY_TEXT_MAX_BYTES = 4096;
25
+ export const REPS_PAGE_SIZE = 25;
26
+ export const REP_SESSIONS_MAX = 50;
27
+ export const VIEW_MAX_ASSIGNMENTS = 50;
28
+ const NEEDS_ATTENTION_MAX = 10;
29
+ const MORE_RESERVED_BYTES = 120;
30
+ // ── text in bytes (7.3; helpers in text.ts) ───────────────────────────────────
31
+ /** The roleplay text budget over the shared helper (also used by roleplayReps.ts). */
32
+ export const roleplayFitText = (input) => fitAppText({ ...input, maxBytes: ROLEPLAY_TEXT_MAX_BYTES, reserve: MORE_RESERVED_BYTES });
33
+ /** One decimal, no trailing ".0" (66.66666 -> "66.7"). */
34
+ export const fmtNum = (x) => String(Math.round(x * 10) / 10);
35
+ export const inRange = (x) => x !== null && Number.isFinite(x) && x >= 0 && x <= 9999;
36
+ /** "62/100", "unscored", or the whole token "invalid" when the score or the scale is outside 0..9999. */
37
+ export function scoreToken(score, scale) {
38
+ if (score === null)
39
+ return 'unscored';
40
+ const s = scale ?? 100;
41
+ return inRange(score) && inRange(s) ? `${fmtNum(score)}/${fmtNum(s)}` : 'invalid';
42
+ }
43
+ /** "2026-11-02 14:05" in UTC (ASCII), or the fixed "unknown-date --:--". */
44
+ export function fmtUtc(ts) {
45
+ const d = ts ? new Date(ts) : null;
46
+ if (!d || Number.isNaN(d.getTime()))
47
+ return 'unknown-date --:--';
48
+ return d.toISOString().slice(0, 16).replace('T', ' ');
49
+ }
50
+ export const name60 = (s, fallback) => clipBytes(s || fallback, 60);
51
+ export const STATUS_TEXT = {
52
+ passed: 'passed',
53
+ in_progress: 'in progress',
54
+ not_started: 'not started',
55
+ overdue: 'overdue',
56
+ needs_coaching: 'needs coaching',
57
+ };
58
+ const fmtMean = (m) => (m === null ? 'no scored sessions' : `mean ${fmtNum(m)}`);
59
+ /** 8.2 row 9 (also the capped head of the overview text). */
60
+ export function cappedSentence(coverage) {
61
+ const exact = coverage.progress === 'full' && coverage.pairsWithoutProgress === 0;
62
+ return `Newest 1000 sessions only. ${exact
63
+ ? 'Passed, not started and best scores are exact; trends and averages may be understated.'
64
+ : 'Some statuses come from these sessions only and may be understated.'}`;
65
+ }
66
+ // ── derivation ───────────────────────────────────────────────────────────────
67
+ export const emptyCounts = () => Object.fromEntries(REP_STATUSES.map((s) => [s, 0]));
68
+ const repOrder = (a, b) => compareReps({ status: a.status, name: a.name, user_id: a.userId }, { status: b.status, name: b.name, user_id: b.userId });
69
+ export function deriveAssignment(model, a, now) {
70
+ const own = assignmentSessions(model.sessions, a);
71
+ const reps = (a.resolvedUserIds ?? [])
72
+ .map((id) => {
73
+ const r = repStats(id, own, a, now, model.progress?.get(progressKey(a.id, id)) ?? null);
74
+ return {
75
+ userId: id,
76
+ name: a.resolvedUserNames?.[id] ?? null,
77
+ status: r.status,
78
+ sessions: r.sessions,
79
+ scoredSessions: r.scored_sessions,
80
+ bestScore: r.best_score,
81
+ avgScore: r.avg_score,
82
+ trend: r.trend,
83
+ lastSessionAt: r.last_session_at,
84
+ recentOnly: r.recent_only,
85
+ passSource: r.pass_source,
86
+ };
87
+ })
88
+ .sort(repOrder);
89
+ const statusCounts = emptyCounts();
90
+ for (const r of reps)
91
+ statusCounts[r.status] += 1;
92
+ return {
93
+ assignment: a,
94
+ own,
95
+ reps,
96
+ summary: {
97
+ id: a.id,
98
+ scenarioName: a.scenarioName ?? null,
99
+ type: a.type,
100
+ passingScore: a.passingScore,
101
+ dueDate: a.dueDate ?? null,
102
+ active: a.active !== false,
103
+ repCount: reps.length,
104
+ sessions: reps.reduce((n, r) => n + r.sessions, 0),
105
+ meanScore: meanOfFinite(own.map(sessionScore)),
106
+ statusCounts,
107
+ },
108
+ };
109
+ }
110
+ function headerOf(models) {
111
+ const statusCounts = emptyCounts();
112
+ const reps = new Set();
113
+ let sessions = 0;
114
+ let scoredSessions = 0;
115
+ const scores = [];
116
+ for (const m of models) {
117
+ for (const r of m.reps) {
118
+ reps.add(r.userId);
119
+ statusCounts[r.status] += 1;
120
+ sessions += r.sessions;
121
+ }
122
+ scoredSessions += scoredCount(m.own);
123
+ scores.push(...m.own.map(sessionScore));
124
+ }
125
+ const pairs = models.reduce((n, m) => n + m.reps.length, 0);
126
+ return {
127
+ assignments: models.length,
128
+ reps: reps.size,
129
+ sessions,
130
+ scoredSessions,
131
+ meanScore: meanOfFinite(scores),
132
+ passedPairs: statusCounts.passed,
133
+ repAssignmentPairs: pairs,
134
+ statusCounts,
135
+ };
136
+ }
137
+ export function scoped(kind, instanceId, model) {
138
+ return { kind, instanceId, scope: model.scope, coverage: model.coverage, complete: !model.coverage.sessionsCapped };
139
+ }
140
+ function repsPage(m, base, status, page) {
141
+ const filtered = status ? m.reps.filter((r) => r.status === status) : m.reps;
142
+ const start = (page - 1) * REPS_PAGE_SIZE;
143
+ const reps = filtered.slice(start, start + REPS_PAGE_SIZE);
144
+ return {
145
+ ...base,
146
+ kind: 'assignment-reps',
147
+ assignmentId: m.assignment.id,
148
+ scenarioName: m.assignment.scenarioName ?? null,
149
+ status,
150
+ page,
151
+ pageSize: REPS_PAGE_SIZE,
152
+ totalReps: filtered.length,
153
+ hasMore: start + reps.length < filtered.length,
154
+ reps,
155
+ };
156
+ }
157
+ const SHORTENED = 'Some feedback was shortened to fit';
158
+ /** When whole skills were dropped, say so: the remaining skills are not the complete scorecard. */
159
+ const SKILLS_OMITTED = 'Some skills were left out to fit; this is not the complete scorecard';
160
+ export const NAME_CHARS = 200;
161
+ /** Clip to `n` code points, "..." included; never leaves a lone surrogate (an emoji renders whole or not at all). */
162
+ export function clipChars(s, n) {
163
+ const cps = Array.from(s);
164
+ return cps.length <= n ? s : `${cps.slice(0, n - 3).join('')}...`;
165
+ }
166
+ /**
167
+ * Overview: drop `selected` whole first (never a shortened page, whose next page would skip reps; the view then
168
+ * loads that assignment's first page itself), then assignments from the end. Measured with `truncated` included.
169
+ */
170
+ export function fitRoleplayView(data, max = ROLEPLAY_VIEW_MAX_BYTES) {
171
+ if (jsonBytes(data) <= max)
172
+ return data;
173
+ const out = { ...data, selected: null };
174
+ const noted = () => {
175
+ const { truncated: _old, ...rest } = out;
176
+ return out.assignments.length < data.totalAssignments
177
+ ? { ...rest, truncated: `Showing ${out.assignments.length} of ${data.totalAssignments} assignments` }
178
+ : rest;
179
+ };
180
+ while (out.assignments.length && jsonBytes(noted()) > max)
181
+ out.assignments = out.assignments.slice(0, -1);
182
+ return noted();
183
+ }
184
+ /**
185
+ * Drill results keep every row (paging continues after the rows sent), so an oversized one clips its free-text
186
+ * names instead: rep, scenario and skill names to 200 characters, then to 60; then the rep's skills from the end.
187
+ */
188
+ export function fitAssignmentReps(data, max = ROLEPLAY_VIEW_MAX_BYTES) {
189
+ let out = data;
190
+ for (const n of [NAME_CHARS, 60]) {
191
+ if (jsonBytes(out) <= max)
192
+ return out;
193
+ out = {
194
+ ...out,
195
+ scenarioName: out.scenarioName === null ? null : clipChars(out.scenarioName, n),
196
+ reps: out.reps.map((r) => ({ ...r, name: r.name === null ? null : clipChars(r.name, n) })),
197
+ };
198
+ }
199
+ return out;
200
+ }
201
+ export function fitRepSessions(data, max = ROLEPLAY_VIEW_MAX_BYTES) {
202
+ let out = data;
203
+ for (const n of [NAME_CHARS, 60]) {
204
+ if (jsonBytes(out) <= max)
205
+ return out;
206
+ out = {
207
+ ...out,
208
+ scenarioName: out.scenarioName === null ? null : clipChars(out.scenarioName, n),
209
+ rep: { ...out.rep, name: out.rep.name === null ? null : clipChars(out.rep.name, n) },
210
+ latestSkills: out.latestSkills && {
211
+ ...out.latestSkills,
212
+ skills: out.latestSkills.skills.map((k) => ({ ...k, title: clipChars(k.title, n), skillId: clipChars(k.skillId, n) })),
213
+ },
214
+ };
215
+ }
216
+ while (out.latestSkills?.skills.length && jsonBytes(out) > max) {
217
+ out = { ...out, latestSkills: { ...out.latestSkills, skills: out.latestSkills.skills.slice(0, -1) } };
218
+ }
219
+ return out;
220
+ }
221
+ const SCORECARD_ITEM_CHARS = 500;
222
+ const SCORECARD_SUMMARY_CHARS = 2000;
223
+ /**
224
+ * Scorecard: clip every strength and improvement to 500 characters; then, while the FINAL payload (`truncated`
225
+ * included) is over budget: drop category feedback from the end, strengths and improvements from the end, clip the
226
+ * summary to 2000 characters and the names to 200, then drop categories from the end.
227
+ */
228
+ export function fitScorecard(data, max = ROLEPLAY_VIEW_MAX_BYTES) {
229
+ let changed = false;
230
+ const clip = (s, n) => {
231
+ const c = clipChars(s, n);
232
+ if (c !== s)
233
+ changed = true;
234
+ return c;
235
+ };
236
+ const ev = data.evaluation;
237
+ const e = ev && {
238
+ ...ev,
239
+ strengths: ev.strengths.map((s) => clip(String(s), SCORECARD_ITEM_CHARS)),
240
+ improvements: ev.improvements.map((s) => clip(String(s), SCORECARD_ITEM_CHARS)),
241
+ category_scores: ev.category_scores.map((c) => ({ ...c })),
242
+ };
243
+ const out = { ...data, evaluation: e };
244
+ let omitted = false;
245
+ const final = () => (changed ? { ...out, truncated: omitted ? SKILLS_OMITTED : SHORTENED } : out);
246
+ const over = () => jsonBytes(final()) > max;
247
+ if (e) {
248
+ for (let i = e.category_scores.length - 1; i >= 0 && over(); i -= 1) {
249
+ if (e.category_scores[i].feedback !== null) {
250
+ e.category_scores[i].feedback = null;
251
+ changed = true;
252
+ }
253
+ }
254
+ while ((e.strengths.length || e.improvements.length) && over()) {
255
+ if (e.improvements.length >= e.strengths.length)
256
+ e.improvements = e.improvements.slice(0, -1);
257
+ else
258
+ e.strengths = e.strengths.slice(0, -1);
259
+ changed = true;
260
+ }
261
+ if (over() && e.summary)
262
+ e.summary = clip(e.summary, SCORECARD_SUMMARY_CHARS);
263
+ if (over()) {
264
+ e.category_scores = e.category_scores.map((c) => ({
265
+ ...c,
266
+ title: c.title === null ? null : clip(c.title, NAME_CHARS),
267
+ skill_id: c.skill_id === null ? null : clip(c.skill_id, NAME_CHARS),
268
+ }));
269
+ }
270
+ while (e.category_scores.length && over()) {
271
+ e.category_scores = e.category_scores.slice(0, -1);
272
+ changed = true;
273
+ omitted = true;
274
+ }
275
+ }
276
+ if (over()) {
277
+ out.scenario_name = out.scenario_name === null ? null : clip(out.scenario_name, NAME_CHARS);
278
+ out.difficulty = out.difficulty === null ? null : clip(out.difficulty, NAME_CHARS);
279
+ if (e?.summary && over())
280
+ e.summary = clip(e.summary, NAME_CHARS);
281
+ }
282
+ return final();
283
+ }
284
+ // ── runners (D25) ────────────────────────────────────────────────────────────
285
+ const SCOPES = ['instance', 'coach'];
286
+ export function checkCommon(opts) {
287
+ if (!opts.instanceId)
288
+ throw createCliError('VALIDATION', 'instanceId is required');
289
+ if (opts.scope !== undefined && !SCOPES.includes(opts.scope)) {
290
+ throw createCliError('VALIDATION', `scope must be one of: ${SCOPES.join(', ')}`);
291
+ }
292
+ if (opts.status !== undefined && !REP_STATUSES.includes(opts.status)) {
293
+ throw createCliError('VALIDATION', `status must be one of: ${REP_STATUSES.join(', ')}`);
294
+ }
295
+ return { instanceId: opts.instanceId, scope: opts.scope, status: opts.status ?? null };
296
+ }
297
+ function findAssignment(model, assignmentId) {
298
+ const a = model.assignments.find((x) => x.id === assignmentId);
299
+ if (!a) {
300
+ throw createCliError('NOT_FOUND', `No roleplay assignment ${assignmentId} in this ${model.scope === 'instance' ? 'instance' : 'coaching scope'}`);
301
+ }
302
+ return a;
303
+ }
304
+ export async function runRoleplayManagerView(opts, deps = {}) {
305
+ try {
306
+ const { instanceId, scope, status } = checkCommon(opts);
307
+ const model = await loadTeamModel(clientFor(opts, deps), instanceId, scope);
308
+ if (opts.assignmentId)
309
+ findAssignment(model, opts.assignmentId);
310
+ const now = new Date();
311
+ const models = model.assignments.map((a) => deriveAssignment(model, a, now));
312
+ const base = scoped('overview', instanceId, model);
313
+ const pick = opts.assignmentId ? models.find((m) => m.assignment.id === opts.assignmentId) : models.length === 1 ? models[0] : undefined;
314
+ const data = {
315
+ ...base,
316
+ generatedAt: now.toISOString(),
317
+ header: headerOf(models),
318
+ assignments: models.slice(0, VIEW_MAX_ASSIGNMENTS).map((m) => m.summary),
319
+ totalAssignments: models.length,
320
+ selected: pick ? repsPage(pick, base, status, 1) : null,
321
+ canViewInstance: scope === 'coach' ? null : model.scope === 'instance',
322
+ ...(models.length > VIEW_MAX_ASSIGNMENTS ? { truncated: `Showing ${VIEW_MAX_ASSIGNMENTS} of ${models.length} assignments` } : {}),
323
+ };
324
+ const fitted = fitRoleplayView(data);
325
+ // The text head names the reps that need the manager across ALL scoped assignments (D16, D18), more than the
326
+ // 50 shown, so it is computed here and handed to managerViewText beside the data (keyed on the returned object).
327
+ attention.set(fitted, needsAttention(models));
328
+ return ok(fitted, model.warnings);
329
+ }
330
+ catch (e) {
331
+ return fail(toCliError(e));
332
+ }
333
+ }
334
+ /**
335
+ * Text-only context for managerViewText, never sent in structuredContent (7.1 keeps names of reps outside the
336
+ * shown page out of the view data). buildServer hands toText the same `env.data` object the runner returned.
337
+ */
338
+ const attention = new WeakMap();
339
+ /** Overdue and needs_coaching reps: status rank, then the assignment's D18 position, then name (max 10). */
340
+ function needsAttention(models) {
341
+ const all = [];
342
+ models.forEach((m, pos) => {
343
+ for (const r of m.reps) {
344
+ if (r.status !== 'overdue' && r.status !== 'needs_coaching')
345
+ continue;
346
+ all.push({ name: r.name ?? `rep ${r.userId}`, status: r.status, scenario: m.assignment.scenarioName ?? m.assignment.id, pos, userId: r.userId });
347
+ }
348
+ });
349
+ all.sort((a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status] || a.pos - b.pos || a.name.localeCompare(b.name, 'en') || a.userId.localeCompare(b.userId, 'en'));
350
+ return { reps: all.slice(0, NEEDS_ATTENTION_MAX), total: all.length };
351
+ }
352
+ export function managerViewText(raw) {
353
+ const d = raw;
354
+ const h = d.header;
355
+ const head1 = `AI Roleplay team (${d.scope} scope): ${h.assignments} assignments, ${h.reps} reps, ${h.sessions} sessions, ` +
356
+ `${h.meanScore === null ? 'no scored sessions' : `mean score ${fmtNum(h.meanScore)} (scored only)`}. ` +
357
+ 'If your host shows the roleplay team view, the user sees it there; otherwise summarise this for the user.';
358
+ const att = attention.get(d);
359
+ const head2 = att?.total
360
+ ? listHead('Needs attention: ', att.reps.map((r) => `${name60(r.name, 'rep')} (${STATUS_TEXT[r.status]}, ${name60(r.scenario, 'assignment')})`), att.total, 800, `Needs attention: ${att.total} reps.`)
361
+ : '';
362
+ const body = d.assignments.map((a) => {
363
+ const c = a.statusCounts;
364
+ const meta = [a.type, a.dueDate ? `due ${a.dueDate.slice(0, 10)}` : null, `pass ${fmtNum(a.passingScore)}`, a.active ? null : 'paused']
365
+ .filter(Boolean)
366
+ .join(', ');
367
+ return {
368
+ text: `${a.id} ${name60(a.scenarioName, a.id)} (${meta}): ${a.repCount} reps; ${c.passed} passed, ${c.overdue} overdue, ` +
369
+ `${c.needs_coaching} needs coaching, ${c.in_progress} in progress, ${c.not_started} not started; ${fmtMean(a.meanScore)}`,
370
+ };
371
+ });
372
+ return roleplayFitText({
373
+ heads: [
374
+ { text: head1, maxBytes: 400 },
375
+ { text: head2, maxBytes: 800 },
376
+ { text: d.complete ? '' : cappedSentence(d.coverage), maxBytes: 200 },
377
+ ],
378
+ body,
379
+ hidden: d.totalAssignments - d.assignments.length,
380
+ more: ({ lines, hidden }) => `...and ${lines + hidden} more assignments: ask for one by name or id.`,
381
+ });
382
+ }
383
+ export async function runRoleplayAssignmentReps(opts, deps = {}) {
384
+ try {
385
+ const { instanceId, scope, status } = checkCommon(opts);
386
+ if (!opts.assignmentId)
387
+ throw createCliError('VALIDATION', 'assignmentId is required');
388
+ const page = opts.page ?? 1;
389
+ if (!Number.isInteger(page) || page < 1)
390
+ throw createCliError('VALIDATION', 'page must be an integer from 1');
391
+ const model = await loadTeamModel(clientFor(opts, deps), instanceId, scope);
392
+ const m = deriveAssignment(model, findAssignment(model, opts.assignmentId), new Date());
393
+ return ok(fitAssignmentReps(repsPage(m, scoped('assignment-reps', instanceId, model), status, page)), model.warnings);
394
+ }
395
+ catch (e) {
396
+ return fail(toCliError(e));
397
+ }
398
+ }
399
+ export function assignmentRepsText(raw) {
400
+ const d = raw;
401
+ const first = (d.page - 1) * d.pageSize + 1;
402
+ const range = d.reps.length ? `reps ${first}-${first + d.reps.length - 1} of ${d.totalReps}` : `no reps of ${d.totalReps}`;
403
+ const head = `${name60(d.scenarioName, d.assignmentId)} (${d.assignmentId}), ${range} (status: ${d.status ? STATUS_TEXT[d.status] : 'all'}):`;
404
+ const body = d.reps.map((r) => ({
405
+ text: `${name60(r.name, 'rep')} (${r.userId}): ${STATUS_TEXT[r.status]}, ` +
406
+ `best ${r.bestScore === null ? 'none' : fmtNum(r.bestScore)}, ${r.sessions} sessions, last ${r.lastSessionAt ? fmtUtc(r.lastSessionAt).slice(0, 10) : 'none'}`,
407
+ }));
408
+ const after = Math.max(0, d.totalReps - (first - 1) - d.reps.length);
409
+ return roleplayFitText({
410
+ heads: [{ text: head, maxBytes: 300 }],
411
+ body,
412
+ hidden: after,
413
+ more: ({ lines, hidden }) => hidden > 0 ? `...${lines + hidden} more reps: pass page=${d.page + 1}` : `...${lines} more reps not shown`,
414
+ });
415
+ }
416
+ export async function runRoleplayRepSessions(opts, deps = {}) {
417
+ try {
418
+ const { instanceId, scope } = checkCommon(opts);
419
+ if (!opts.assignmentId)
420
+ throw createCliError('VALIDATION', 'assignmentId is required');
421
+ if (!opts.userId)
422
+ throw createCliError('VALIDATION', 'userId is required');
423
+ const model = await loadTeamModel(clientFor(opts, deps), instanceId, scope);
424
+ const a = model.assignments.find((x) => x.id === opts.assignmentId);
425
+ if (!a || !(a.resolvedUserIds ?? []).includes(opts.userId)) {
426
+ throw createCliError('NOT_FOUND', `No rep ${opts.userId} on assignment ${opts.assignmentId} in this scope`);
427
+ }
428
+ const m = deriveAssignment(model, a, new Date());
429
+ const rep = m.reps.find((r) => r.userId === opts.userId);
430
+ const own = m.own
431
+ .filter((s) => s.userId === opts.userId)
432
+ .sort((x, y) => new Date(y.timestamp ?? 0).getTime() - new Date(x.timestamp ?? 0).getTime());
433
+ const sessions = own.slice(0, REP_SESSIONS_MAX).map((s) => {
434
+ const score = sessionScore(s);
435
+ return {
436
+ id: String(s.id),
437
+ timestamp: s.timestamp ?? null,
438
+ score,
439
+ durationMs: finiteOrNull(s.durationMs),
440
+ evaluationStatus: typeof s.evaluationStatus === 'string' ? s.evaluationStatus : null,
441
+ meetsPassingScore: score === null ? null : score >= a.passingScore,
442
+ };
443
+ });
444
+ const latest = latestSkills(own);
445
+ return ok(fitRepSessions({
446
+ ...scoped('rep-sessions', instanceId, model),
447
+ assignmentId: a.id,
448
+ scenarioName: a.scenarioName ?? null,
449
+ passingScore: a.passingScore,
450
+ rep,
451
+ latestSkills: latest
452
+ ? { sessionId: String(latest.session.id), timestamp: latest.session.timestamp ?? null, skills: latest.skills }
453
+ : null,
454
+ sessions,
455
+ totalSessions: own.length,
456
+ }), model.warnings);
457
+ }
458
+ catch (e) {
459
+ return fail(toCliError(e));
460
+ }
461
+ }
462
+ export function repSessionsText(raw) {
463
+ const d = raw;
464
+ const r = d.rep;
465
+ const head1 = `${name60(r.name, 'Rep')} on ${name60(d.scenarioName, d.assignmentId)}: ${STATUS_TEXT[r.status]}, ` +
466
+ `${r.bestScore === null ? 'no best score' : `best ${fmtNum(r.bestScore)}`} of pass ${fmtNum(d.passingScore)}, ${r.sessions} sessions. ` +
467
+ 'Lines: date time (UTC) score entryId; open one with pitcher_roleplay_session_get.';
468
+ const skills = (d.latestSkills?.skills ?? []).slice(0, 8).map((k) => `${clipBytes(k.title, 30)} ${fmtNum(k.score)}`);
469
+ let head2 = '';
470
+ for (let n = skills.length; n > 0; n -= 1) {
471
+ const t = `Latest skills: ${skills.slice(0, n).join(', ')}.`;
472
+ if (byteLen(t) <= 450) {
473
+ head2 = t;
474
+ break;
475
+ }
476
+ }
477
+ return roleplayFitText({
478
+ heads: [
479
+ { text: head1, maxBytes: 450 },
480
+ { text: head2, maxBytes: 450 },
481
+ ],
482
+ body: d.sessions.map((s) => ({ text: `${fmtUtc(s.timestamp)} ${scoreToken(s.score, null)} ${s.id}` })),
483
+ hidden: Math.max(0, d.totalSessions - d.sessions.length),
484
+ more: ({ lines, hidden }) => `...${lines + hidden} older sessions not listed`,
485
+ });
486
+ }
487
+ /** session_get, fitted and tagged for the view (D4). */
488
+ export function fitScorecardEnvelope(env) {
489
+ if (!env.ok)
490
+ return env;
491
+ return { ...env, data: fitScorecard({ ...env.data, kind: 'scorecard' }) };
492
+ }
493
+ export const runRoleplaySessionScorecard = (opts, deps = {}) => runRoleplaySessionGet(opts, deps).then(fitScorecardEnvelope);
494
+ export function scorecardText(raw) {
495
+ const d = raw;
496
+ const ev = d.evaluation;
497
+ // The overall score is out of 100 on every evaluation; grading_scale is the category scale (legacy: 5).
498
+ const score = d.overall_score === null ? 'unscored' : `score ${scoreToken(d.overall_score, null).replace('/', ' / ')}`;
499
+ const head = `Session ${d.id}, ${name60(d.scenario_name, 'roleplay')}, ${d.timestamp ? fmtUtc(d.timestamp).slice(0, 10) : 'unknown date'}: ${score}.`;
500
+ const body = [];
501
+ if (ev?.summary)
502
+ body.push({ text: clipBytes(`Summary: ${ev.summary}`, 900), kind: 'summary' });
503
+ const list = (label, items) => clipBytes(`${label}: ${items.map((s) => clipBytes(String(s), 200)).join('; ')}`, 1000);
504
+ if (ev?.strengths.length)
505
+ body.push({ text: list('Strengths', ev.strengths), kind: 'strengths' });
506
+ if (ev?.improvements.length)
507
+ body.push({ text: list('Improvements', ev.improvements), kind: 'improvements' });
508
+ for (const c of ev?.category_scores ?? []) {
509
+ const title = clipBytes(c.title || c.skill_id || 'Skill', 30);
510
+ const s = c.score === null ? 'unscored' : `${fmtNum(normalizeScore(c.score, c.max_score ?? ev?.grading_scale ?? 100))}/100`;
511
+ body.push({ text: `${title} ${s}${c.feedback ? `: ${clipBytes(c.feedback, 200)}` : ''}`, kind: 'skill' });
512
+ }
513
+ return roleplayFitText({
514
+ // fitScorecard's notice rides as a head: skills it dropped never reach `body`, so `more` cannot count them
515
+ heads: [
516
+ { text: head, maxBytes: 300 },
517
+ { text: d.truncated ? `${d.truncated}.` : '', maxBytes: 120 },
518
+ ],
519
+ body,
520
+ more: ({ lines, kinds }) => kinds.length === 1 && kinds[0] === 'skill' ? `...${lines} skills not shown` : `...${lines} lines not shown (${kinds.join(', ')})`,
521
+ });
522
+ }
523
+ // ── tools ────────────────────────────────────────────────────────────────────
524
+ export const instanceId = z.string().describe('Instance ID (required)');
525
+ export const statusEnum = z.enum(['passed', 'in_progress', 'not_started', 'overdue', 'needs_coaching']);
526
+ export const scopeEnum = z.enum(['instance', 'coach']);
527
+ const VIEW = 'roleplay-manager';
528
+ export const roleplayManagerTools = [
529
+ {
530
+ name: 'pitcher_roleplay_manager_view',
531
+ title: 'AI Roleplay team view (interactive)',
532
+ description: 'Interactive AI Roleplay team view for a coaching manager or admin: KPI header, every roleplay assignment with how ' +
533
+ 'many reps passed, are in progress, not started, overdue or need coaching, drill-downs into reps and session ' +
534
+ 'scorecards, and a Reps tab listing each rep once across their assignments (pitcher_roleplay_reps). ' +
535
+ 'In hosts without UI it returns a text summary. Admins see every assignment on the instance, a manager ' +
536
+ 'the assignments they coach. For a plain data answer use pitcher_roleplay_team.',
537
+ inputShape: {
538
+ instanceId,
539
+ assignmentId: z.string().optional().describe('Open this assignment directly (its id from an earlier call)'),
540
+ status: statusEnum.optional().describe('Preselect the rep status filter of that assignment'),
541
+ scope: scopeEnum.optional().describe('Omit unless the user asks only for the assignments they coach (then "coach")'),
542
+ },
543
+ run: runRoleplayManagerView,
544
+ toText: managerViewText,
545
+ readOnly: true,
546
+ access: 'read', // PIT-10114: explicit for every app tool
547
+ group: 'roleplay',
548
+ ui: { view: VIEW },
549
+ },
550
+ {
551
+ name: 'pitcher_roleplay_assignment_reps',
552
+ title: 'Assignment reps (roleplay view UI helper)',
553
+ description: 'UI helper for the roleplay team view: one page of reps for one assignment, with status, best score and sessions. ' +
554
+ 'For a plain data answer prefer pitcher_roleplay_team.',
555
+ inputShape: {
556
+ instanceId,
557
+ assignmentId: z.string().describe('Assignment ID'),
558
+ status: statusEnum.optional().describe('Only reps with this status'),
559
+ page: z.number().int().min(1).optional().describe('1-based, 25 reps a page'),
560
+ scope: scopeEnum.optional().describe('The scope of the view result this drill comes from'),
561
+ },
562
+ run: runRoleplayAssignmentReps,
563
+ toText: assignmentRepsText,
564
+ readOnly: true,
565
+ access: 'read', // PIT-10114: explicit for every app tool
566
+ group: 'roleplay',
567
+ ui: { view: VIEW, visibility: ['app'] },
568
+ },
569
+ {
570
+ name: 'pitcher_roleplay_rep_sessions',
571
+ title: 'AI Roleplay sessions of one rep',
572
+ description: "One rep's AI Roleplay sessions on one assignment, newest first, with each session's entry id for " +
573
+ "pitcher_roleplay_session_get, plus the rep's latest skill scores. Works for the rep's coach and for instance or " +
574
+ 'org admins. Take userId and assignmentId from pitcher_roleplay_team. In hosts with UI it opens the roleplay team ' +
575
+ 'view on that rep.',
576
+ inputShape: {
577
+ instanceId,
578
+ assignmentId: z.string().describe('Assignment ID (from pitcher_roleplay_team)'),
579
+ userId: z.string().describe('Rep user ID (user_id from pitcher_roleplay_team)'),
580
+ scope: scopeEnum.optional().describe('Omit unless the user asks only for the assignments they coach (then "coach")'),
581
+ },
582
+ run: runRoleplayRepSessions,
583
+ toText: repSessionsText,
584
+ readOnly: true,
585
+ access: 'read', // PIT-10114: explicit for every app tool
586
+ group: 'roleplay',
587
+ ui: { view: VIEW, visibility: ['model', 'app'] },
588
+ },
589
+ {
590
+ name: 'pitcher_roleplay_session_scorecard',
591
+ title: 'Session scorecard (roleplay view UI helper)',
592
+ description: "UI helper for the roleplay team view: one session's scorecard (score, summary, strengths, improvements, per-skill " +
593
+ 'scores and feedback). Never the transcript. Same access as pitcher_roleplay_session_get.',
594
+ inputShape: {
595
+ instanceId,
596
+ entryId: z.string().describe('Session entry ID'),
597
+ },
598
+ run: runRoleplaySessionScorecard,
599
+ wraps: runRoleplaySessionGet,
600
+ toText: scorecardText,
601
+ readOnly: true,
602
+ access: 'read', // PIT-10114: explicit for every app tool
603
+ group: 'roleplay',
604
+ ui: { view: VIEW, visibility: ['app'] },
605
+ },
606
+ ];