@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.
- package/dist/src/commands/dsrLanding.js +1 -1
- package/dist/src/commands/roleplay.d.ts +154 -3
- package/dist/src/commands/roleplay.js +276 -23
- package/dist/src/core/dsr-engagement.d.ts +152 -0
- package/dist/src/core/dsr-engagement.js +301 -0
- package/dist/src/core/envelope.d.ts +1 -0
- package/dist/src/core/envelope.js +6 -1
- package/dist/src/core/errors.d.ts +3 -1
- package/dist/src/core/errors.js +2 -2
- package/dist/src/core/http.js +7 -5
- package/dist/src/core/ld.js +2 -1
- package/dist/src/core/me.d.ts +3 -0
- package/dist/src/core/me.js +10 -0
- package/dist/src/core/roleplay-stats.d.ts +48 -7
- package/dist/src/core/roleplay-stats.js +68 -11
- package/dist/src/mcp/apps/confirm/define.d.ts +66 -0
- package/dist/src/mcp/apps/confirm/define.js +208 -0
- package/dist/src/mcp/apps/confirm/hash.d.ts +4 -0
- package/dist/src/mcp/apps/confirm/hash.js +23 -0
- package/dist/src/mcp/apps/confirm/types.d.ts +96 -0
- package/dist/src/mcp/apps/confirm/types.js +15 -0
- package/dist/src/mcp/apps/contentPicker.d.ts +104 -0
- package/dist/src/mcp/apps/contentPicker.js +268 -0
- package/dist/src/mcp/apps/dsrEngagement.d.ts +194 -0
- package/dist/src/mcp/apps/dsrEngagement.js +737 -0
- package/dist/src/mcp/apps/registry.d.ts +14 -0
- package/dist/src/mcp/apps/registry.js +25 -0
- package/dist/src/mcp/apps/roleplayManager.d.ts +228 -0
- package/dist/src/mcp/apps/roleplayManager.js +606 -0
- package/dist/src/mcp/apps/roleplayReps.d.ts +79 -0
- package/dist/src/mcp/apps/roleplayReps.js +328 -0
- package/dist/src/mcp/apps/roleplayWrites.d.ts +6 -0
- package/dist/src/mcp/apps/roleplayWrites.js +509 -0
- package/dist/src/mcp/apps/text.d.ts +41 -0
- package/dist/src/mcp/apps/text.js +58 -0
- package/dist/src/mcp/apps/types.d.ts +23 -0
- package/dist/src/mcp/apps/types.js +12 -0
- package/dist/src/mcp/apps/writeConfirm.d.ts +3 -0
- package/dist/src/mcp/apps/writeConfirm.js +8 -0
- package/dist/src/mcp/groups.d.ts +42 -0
- package/dist/src/mcp/groups.js +89 -0
- package/dist/src/mcp/hosted.d.ts +36 -0
- package/dist/src/mcp/hosted.js +47 -1
- package/dist/src/mcp/index.d.ts +9 -3
- package/dist/src/mcp/index.js +9 -2
- package/dist/src/mcp/prompts/registry.js +8 -7
- package/dist/src/mcp/prompts/types.d.ts +3 -0
- package/dist/src/mcp/registry.d.ts +4 -0
- package/dist/src/mcp/registry.js +29 -24
- package/dist/src/mcp/renderPolicy.d.ts +1 -0
- package/dist/src/mcp/renderPolicy.js +8 -2
- package/dist/src/mcp/resources/registry.js +2 -2
- package/dist/src/mcp/resources/types.d.ts +3 -0
- package/dist/src/mcp/result.d.ts +2 -1
- package/dist/src/mcp/result.js +8 -6
- package/dist/src/mcp/server.d.ts +62 -0
- package/dist/src/mcp/server.js +161 -10
- package/dist/src/mcp/tools/roleplay.js +34 -5
- package/dist/src/mcp/tools/types.d.ts +35 -0
- package/package.json +2 -1
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { deleteCoachComment, fetchPathRecords, fetchPaths, getOwnCoachBundle, postCoachComment, postPathAssignment, } from '../../commands/roleplay.js';
|
|
3
|
+
import { runGroupsList } from '../../commands/groups.js';
|
|
4
|
+
import { runUsersGet, runUsersList } from '../../commands/users.js';
|
|
5
|
+
import { createCliError, isCliError } from '../../core/errors.js';
|
|
6
|
+
import { clientFor, toCliError } from '../../core/http.js';
|
|
7
|
+
import { getMeId } from '../../core/me.js';
|
|
8
|
+
import { confirmTextShape, defineConfirmedWrite, nameList, previewName, stripControls } from './confirm/define.js';
|
|
9
|
+
import { CONFIRM_TEXT_MAX } from './confirm/types.js';
|
|
10
|
+
const instanceId = z.string().describe('Instance ID (required)');
|
|
11
|
+
const COMMENT_MAX = 5000; // ROLEPLAY_MAX_COMMENT_LEN, next-core coach-product.const.ts
|
|
12
|
+
const USER_ID = /^[0-9]{1,9}$/; // path-schema.ts userId
|
|
13
|
+
const NAMES_SHOWN = 25;
|
|
14
|
+
const GROUPS_SHOWN = 20;
|
|
15
|
+
const GROUP_PAGES_MAX = 10;
|
|
16
|
+
const USER_PAGES_MAX = 20;
|
|
17
|
+
const LOOKUP_CONCURRENCY = 5;
|
|
18
|
+
const COMMIT_WORDING = 'ONLY after the user explicitly confirmed it. Pass exactly the proposal arguments and proposalToken; any change is ' +
|
|
19
|
+
'refused. A repeat returns the first result and never saves twice.';
|
|
20
|
+
const client = (auth, deps) => clientFor(auth, (deps ?? {}));
|
|
21
|
+
const asError = (e) => (isCliError(e) ? e : toCliError(e));
|
|
22
|
+
const str = (v) => (typeof v === 'string' ? v : v === undefined || v === null ? '' : String(v));
|
|
23
|
+
/** `items` mapped with at most `limit` calls in flight, in order. */
|
|
24
|
+
async function mapLimit(items, limit, fn) {
|
|
25
|
+
const out = new Array(items.length);
|
|
26
|
+
let next = 0;
|
|
27
|
+
const worker = async () => {
|
|
28
|
+
while (next < items.length) {
|
|
29
|
+
const i = next++;
|
|
30
|
+
out[i] = await fn(items[i]);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/** A display name for a user id: Django's `name`, else the email, else "user <id>". */
|
|
37
|
+
async function userName(auth, deps, id) {
|
|
38
|
+
const env = await runUsersGet({ ...auth, id }, (deps ?? {}));
|
|
39
|
+
const u = (env.ok ? env.data : undefined);
|
|
40
|
+
return previewName(str(u?.name).trim() || str(u?.email).trim() || `user ${id}`);
|
|
41
|
+
}
|
|
42
|
+
function dayOf(iso) {
|
|
43
|
+
const s = str(iso);
|
|
44
|
+
return /^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : 'unknown date';
|
|
45
|
+
}
|
|
46
|
+
/** Cut each list's shown names until the text fits CONFIRM_TEXT_MAX; never mid-name (spec 6.4a rule 5). */
|
|
47
|
+
function fitConfirm(build, start) {
|
|
48
|
+
for (let shown = start; shown >= 0; shown -= 1) {
|
|
49
|
+
const t = build(shown);
|
|
50
|
+
if (t.length <= CONFIRM_TEXT_MAX)
|
|
51
|
+
return t;
|
|
52
|
+
}
|
|
53
|
+
return build(0).slice(0, CONFIRM_TEXT_MAX);
|
|
54
|
+
}
|
|
55
|
+
/** The caller's coached roleplay assignment and rep, or AUTH (spec 10.1 step 3). */
|
|
56
|
+
function coachedAssignment(assignments, assignmentId, repUserId) {
|
|
57
|
+
// the coach bundle is requested with the default roleplay product, so practice rarely occurs; same filter as team
|
|
58
|
+
const a = (assignments ?? []).filter((x) => x.contentType !== 'practice').find((x) => x.id === assignmentId);
|
|
59
|
+
if (!a)
|
|
60
|
+
throw createCliError('AUTH', `You do not coach anyone on assignment ${assignmentId}`);
|
|
61
|
+
if (!(a.resolvedUserIds ?? []).map(String).includes(repUserId)) {
|
|
62
|
+
throw createCliError('AUTH', 'You do not coach this rep on this assignment');
|
|
63
|
+
}
|
|
64
|
+
return a;
|
|
65
|
+
}
|
|
66
|
+
const repNameOf = (a, repUserId) => previewName(a.resolvedUserNames?.[repUserId] || `rep ${repUserId}`);
|
|
67
|
+
const scenarioOf = (a) => previewName(a.scenarioName || a.scenarioId || a.id);
|
|
68
|
+
export const [commentPropose, commentCommit] = defineConfirmedWrite({
|
|
69
|
+
action: 'roleplay.comment.create',
|
|
70
|
+
group: 'roleplay',
|
|
71
|
+
propose: {
|
|
72
|
+
name: 'pitcher_roleplay_comment_propose',
|
|
73
|
+
title: 'Propose a coaching comment',
|
|
74
|
+
description: 'Prepare a coaching comment from you (a coaching manager) to one rep on one of their AI Roleplay assignments, ' +
|
|
75
|
+
'optionally about one session. Saves nothing: the user reviews the exact text in a confirm view before ' +
|
|
76
|
+
'pitcher_roleplay_comment_commit saves it. Take assignmentId and repUserId (user_id) from pitcher_roleplay_team ' +
|
|
77
|
+
'and sessionEntryId from pitcher_roleplay_sessions. Only a coach of that rep can comment, and only on an ' +
|
|
78
|
+
"assignment they coach the rep on; instance admins who are not the rep's coach cannot. The rep sees the comment " +
|
|
79
|
+
"under the user's own name.",
|
|
80
|
+
inputShape: {
|
|
81
|
+
instanceId,
|
|
82
|
+
assignmentId: z.string().describe('Roleplay assignment id (id from pitcher_roleplay_team)'),
|
|
83
|
+
repUserId: z.string().describe("The rep's user id (user_id from pitcher_roleplay_team)"),
|
|
84
|
+
sessionEntryId: z.string().optional().describe('One session of that rep (entry_id); omit for a general comment'),
|
|
85
|
+
comment: z.string().min(1).max(COMMENT_MAX).describe('The comment text, as the rep will read it (plain text, max 5000)'),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
commit: {
|
|
89
|
+
name: 'pitcher_roleplay_comment_commit',
|
|
90
|
+
title: 'Post a coaching comment',
|
|
91
|
+
description: `Save a comment proposed by pitcher_roleplay_comment_propose, ${COMMIT_WORDING}`,
|
|
92
|
+
argsShape: {
|
|
93
|
+
confirmText: confirmTextShape,
|
|
94
|
+
instanceId,
|
|
95
|
+
assignmentId: z.string(),
|
|
96
|
+
repUserId: z.string(),
|
|
97
|
+
sessionEntryId: z.string().nullable(),
|
|
98
|
+
comment: z.string().min(1).max(COMMENT_MAX),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
editableArg: 'comment',
|
|
102
|
+
normalize: (a) => ({
|
|
103
|
+
confirmText: str(a.confirmText).trim(),
|
|
104
|
+
instanceId: str(a.instanceId),
|
|
105
|
+
assignmentId: str(a.assignmentId),
|
|
106
|
+
repUserId: str(a.repUserId),
|
|
107
|
+
sessionEntryId: a.sessionEntryId === undefined || a.sessionEntryId === null || a.sessionEntryId === '' ? null : str(a.sessionEntryId),
|
|
108
|
+
comment: str(a.comment).trim(),
|
|
109
|
+
}),
|
|
110
|
+
async prepare(input, auth, deps) {
|
|
111
|
+
const inst = str(input.instanceId);
|
|
112
|
+
const assignmentId = str(input.assignmentId);
|
|
113
|
+
const repUserId = str(input.repUserId);
|
|
114
|
+
const sessionEntryId = str(input.sessionEntryId) || null;
|
|
115
|
+
const comment = str(input.comment).trim();
|
|
116
|
+
if (!comment)
|
|
117
|
+
throw createCliError('VALIDATION', 'comment is empty');
|
|
118
|
+
if (comment.length > COMMENT_MAX)
|
|
119
|
+
throw createCliError('VALIDATION', `at most ${COMMENT_MAX} characters`);
|
|
120
|
+
const c = client(auth, deps);
|
|
121
|
+
const [actorUserId, bundle, session] = await Promise.all([
|
|
122
|
+
getMeId(c),
|
|
123
|
+
getOwnCoachBundle(c, inst),
|
|
124
|
+
sessionEntryId
|
|
125
|
+
? c
|
|
126
|
+
.get(`/core/api/protected/roleplay/coach/sessions/${encodeURIComponent(sessionEntryId)}`, {
|
|
127
|
+
params: { instance_id: inst },
|
|
128
|
+
headers: { 'x-instance-id': inst },
|
|
129
|
+
})
|
|
130
|
+
.then((r) => (r.data ?? {}).data ?? {})
|
|
131
|
+
: Promise.resolve(null),
|
|
132
|
+
]);
|
|
133
|
+
const assignment = coachedAssignment(bundle.assignments, assignmentId, repUserId);
|
|
134
|
+
const rep = repNameOf(assignment, repUserId);
|
|
135
|
+
const scenario = scenarioOf(assignment);
|
|
136
|
+
const warnings = [];
|
|
137
|
+
const fields = [
|
|
138
|
+
{ label: 'Rep', value: rep },
|
|
139
|
+
{ label: 'Assignment', value: assignment.dueDate ? `${scenario} (due ${dayOf(assignment.dueDate)})` : scenario },
|
|
140
|
+
];
|
|
141
|
+
let sessionDay = '';
|
|
142
|
+
if (session) {
|
|
143
|
+
// a consistency check on rep-written data; the route's authority is the coach check and the args hash
|
|
144
|
+
if (str(session.userId) !== repUserId)
|
|
145
|
+
throw createCliError('VALIDATION', `session ${sessionEntryId} belongs to another rep`);
|
|
146
|
+
if (session.scenarioId && session.scenarioId !== assignment.scenarioId) {
|
|
147
|
+
warnings.push('This session is from another scenario than the assignment.');
|
|
148
|
+
}
|
|
149
|
+
sessionDay = dayOf(session.timestamp);
|
|
150
|
+
const score = typeof session.overallScore === 'number' && Number.isFinite(session.overallScore) ? `${session.overallScore}` : 'unscored';
|
|
151
|
+
fields.push({ label: 'Session', value: `${previewName(session.scenarioName || scenario)}, ${sessionDay}, score ${score}` });
|
|
152
|
+
}
|
|
153
|
+
const confirmText = `Post a coaching comment from you to ${rep} on '${scenario}'${sessionDay ? ` about the session of ${sessionDay}` : ''}. ` +
|
|
154
|
+
`${rep} sees it under your name.`;
|
|
155
|
+
const preview = {
|
|
156
|
+
title: 'Post a coaching comment',
|
|
157
|
+
summary: `A coaching comment to ${rep} on '${scenario}'.`,
|
|
158
|
+
fields,
|
|
159
|
+
body: { label: 'Comment', text: comment, editable: true, maxLength: COMMENT_MAX, argKey: 'comment' },
|
|
160
|
+
notices: [`${rep} sees this comment under your name and is asked to acknowledge it.`],
|
|
161
|
+
warnings,
|
|
162
|
+
confirmLabel: 'Post comment',
|
|
163
|
+
};
|
|
164
|
+
return {
|
|
165
|
+
actorUserId,
|
|
166
|
+
preview,
|
|
167
|
+
args: { confirmText, instanceId: inst, assignmentId, repUserId, sessionEntryId, comment },
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
async execute(args, key, auth, deps) {
|
|
171
|
+
const { body, replayed } = await postCoachComment(client(auth, deps), args, key);
|
|
172
|
+
return {
|
|
173
|
+
replayed,
|
|
174
|
+
result: {
|
|
175
|
+
id: str(body.id),
|
|
176
|
+
repUserId: args.repUserId,
|
|
177
|
+
assignmentId: args.assignmentId,
|
|
178
|
+
sessionEntryId: args.sessionEntryId,
|
|
179
|
+
timestamp: typeof body.data?.timestamp === 'string' ? body.data.timestamp : null,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
resultText: (r) => `comment ${r.id} posted for rep ${r.repUserId} on assignment ${r.assignmentId}.`,
|
|
184
|
+
});
|
|
185
|
+
export const [commentDeletePropose, commentDeleteCommit] = defineConfirmedWrite({
|
|
186
|
+
action: 'roleplay.comment.delete',
|
|
187
|
+
group: 'roleplay',
|
|
188
|
+
propose: {
|
|
189
|
+
name: 'pitcher_roleplay_comment_delete_propose',
|
|
190
|
+
title: 'Propose deleting a coaching comment',
|
|
191
|
+
description: 'Prepare deleting one of YOUR OWN AI Roleplay coaching comments (the undo for a mistaken post). Saves nothing: the ' +
|
|
192
|
+
'user confirms in a confirm view before pitcher_roleplay_comment_delete_commit deletes it. You can delete only ' +
|
|
193
|
+
'comments you wrote; the rep no longer sees a deleted comment.',
|
|
194
|
+
inputShape: { instanceId, commentId: z.string().describe('The comment id (the id a comment commit returned)') },
|
|
195
|
+
},
|
|
196
|
+
commit: {
|
|
197
|
+
name: 'pitcher_roleplay_comment_delete_commit',
|
|
198
|
+
title: 'Delete a coaching comment',
|
|
199
|
+
description: `Delete a comment proposed by pitcher_roleplay_comment_delete_propose, ${COMMIT_WORDING}`,
|
|
200
|
+
argsShape: { confirmText: confirmTextShape, instanceId, commentId: z.string() },
|
|
201
|
+
},
|
|
202
|
+
normalize: (a) => ({ confirmText: str(a.confirmText).trim(), instanceId: str(a.instanceId), commentId: str(a.commentId) }),
|
|
203
|
+
async prepare(input, auth, deps) {
|
|
204
|
+
const inst = str(input.instanceId);
|
|
205
|
+
const commentId = str(input.commentId);
|
|
206
|
+
const c = client(auth, deps);
|
|
207
|
+
const [actorUserId, bundle] = await Promise.all([getMeId(c), getOwnCoachBundle(c, inst)]);
|
|
208
|
+
const row = (bundle.comments ?? []).find((x) => x.id === commentId);
|
|
209
|
+
if (!row)
|
|
210
|
+
throw createCliError('NOT_FOUND', `comment ${commentId} not found among the comments you can see (or already deleted)`);
|
|
211
|
+
if (str(row.managerId) !== actorUserId)
|
|
212
|
+
throw createCliError('AUTH', 'You can delete only your own comments');
|
|
213
|
+
const repUserId = str(row.repUserId);
|
|
214
|
+
const assignment = (bundle.assignments ?? []).find((a) => a.id === str(row.assignmentId));
|
|
215
|
+
const rep = assignment ? repNameOf(assignment, repUserId) : previewName(`rep ${repUserId}`);
|
|
216
|
+
const scenario = assignment ? scenarioOf(assignment) : previewName(str(row.assignmentId));
|
|
217
|
+
const text = stripControls(str(row.comment));
|
|
218
|
+
const confirmText = `Delete your comment to ${rep} on '${scenario}': '${previewName(text, 120)}'.`;
|
|
219
|
+
return {
|
|
220
|
+
actorUserId,
|
|
221
|
+
args: { confirmText, instanceId: inst, commentId },
|
|
222
|
+
preview: {
|
|
223
|
+
title: 'Delete a coaching comment',
|
|
224
|
+
summary: `Delete your comment to ${rep} on '${scenario}'.`,
|
|
225
|
+
fields: [
|
|
226
|
+
{ label: 'Rep', value: rep },
|
|
227
|
+
{ label: 'Assignment', value: scenario },
|
|
228
|
+
{ label: 'Comment', value: previewName(text) },
|
|
229
|
+
],
|
|
230
|
+
notices: ['The rep no longer sees this comment.'],
|
|
231
|
+
warnings: [],
|
|
232
|
+
confirmLabel: 'Delete comment',
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
async execute(args, key, auth, deps) {
|
|
237
|
+
const { replayed } = await deleteCoachComment(client(auth, deps), args, key);
|
|
238
|
+
return { replayed, result: { id: args.commentId, deleted: true } };
|
|
239
|
+
},
|
|
240
|
+
resultText: (r) => `comment ${r.id} deleted.`,
|
|
241
|
+
});
|
|
242
|
+
const sortedUnique = (v) => (Array.isArray(v) ? [...new Set(v.map(str))].sort() : []);
|
|
243
|
+
/** next-core's `isoDate` (path-schema.ts): YYYY-MM-DD that prints back as itself. */
|
|
244
|
+
function isRealDate(v) {
|
|
245
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(v))
|
|
246
|
+
return false;
|
|
247
|
+
const d = new Date(`${v}T00:00:00Z`);
|
|
248
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
|
|
249
|
+
}
|
|
250
|
+
/** The paths-route refusals, worded for the user (spec 10.2 step 3). */
|
|
251
|
+
function pathsError(e) {
|
|
252
|
+
const err = asError(e);
|
|
253
|
+
if (err.code === 'NOT_FOUND') {
|
|
254
|
+
return createCliError('NOT_FOUND', 'Learning paths are not enabled on this instance, or you are not a member of it', err.backendStatus);
|
|
255
|
+
}
|
|
256
|
+
if (err.backendStatus === 503)
|
|
257
|
+
return createCliError('NETWORK', 'Learning paths are temporarily unavailable', 503);
|
|
258
|
+
if (err.code === 'AUTH') {
|
|
259
|
+
return createCliError('AUTH', 'Only instance admins and editors, and org admins, can assign paths', err.backendStatus);
|
|
260
|
+
}
|
|
261
|
+
return err;
|
|
262
|
+
}
|
|
263
|
+
/** Group names by id, paging the instance's groups until every id is found (W9: unknown ids are refused). */
|
|
264
|
+
async function groupNames(auth, deps, inst, ids) {
|
|
265
|
+
const found = new Map();
|
|
266
|
+
if (ids.length === 0)
|
|
267
|
+
return found;
|
|
268
|
+
const wanted = new Set(ids);
|
|
269
|
+
// bounded (spec W9: at most 10 pages of 500); past the bound an id is "not verified", never "not a group"
|
|
270
|
+
let more = false;
|
|
271
|
+
for (let page = 1; page <= GROUP_PAGES_MAX && found.size < wanted.size; page += 1) {
|
|
272
|
+
const env = await runGroupsList({ ...auth, instanceId: inst, pageSize: 500, page }, (deps ?? {}));
|
|
273
|
+
if (!env.ok)
|
|
274
|
+
throw createCliError(env.error.code, env.error.message, env.error.backendStatus);
|
|
275
|
+
for (const g of env.data.results) {
|
|
276
|
+
const id = str(g.id);
|
|
277
|
+
if (wanted.has(id))
|
|
278
|
+
found.set(id, previewName(str(g.name) || id));
|
|
279
|
+
}
|
|
280
|
+
more = !!env.data.pagination.next;
|
|
281
|
+
if (!more)
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
const unknown = ids.filter((id) => !found.has(id));
|
|
285
|
+
if (unknown.length && more) {
|
|
286
|
+
throw createCliError('VALIDATION', `could not find these groups in the first ${GROUP_PAGES_MAX * 500} groups of this instance: ${unknown.join(', ')}`);
|
|
287
|
+
}
|
|
288
|
+
if (unknown.length)
|
|
289
|
+
throw createCliError('VALIDATION', `not groups of this instance: ${unknown.join(', ')}`);
|
|
290
|
+
return found;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Managers who are reps (spec 10.2 step 6a, K11 as amended 2026-11-11): Django has no `rep` instance role (`admin`,
|
|
294
|
+
* `editor`, `member`, and an unknown role is a 400), so a rep is an instance `member` who is not an org admin, as
|
|
295
|
+
* next-core's `sqlIsPathRep` decides. Advisory: any failure returns null and the warning is skipped.
|
|
296
|
+
*/
|
|
297
|
+
async function membersAmong(auth, deps, inst, managerIds) {
|
|
298
|
+
try {
|
|
299
|
+
const wanted = new Set(managerIds);
|
|
300
|
+
const out = new Map();
|
|
301
|
+
for (let page = 1; page <= USER_PAGES_MAX; page += 1) {
|
|
302
|
+
const env = await runUsersList({ ...auth, instanceId: inst, instanceRole: 'member', pageSize: 500, page }, (deps ?? {}));
|
|
303
|
+
if (!env.ok)
|
|
304
|
+
return null;
|
|
305
|
+
for (const u of env.data.results) {
|
|
306
|
+
const id = str(u.id);
|
|
307
|
+
if (wanted.has(id) && u.role !== 'admin')
|
|
308
|
+
out.set(id, previewName(str(u.name) || str(u.email) || `user ${id}`));
|
|
309
|
+
}
|
|
310
|
+
if (!env.data.pagination.next)
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
export const [pathPropose, pathCommit] = defineConfirmedWrite({
|
|
320
|
+
action: 'roleplay.path_assignment.create',
|
|
321
|
+
group: 'roleplay',
|
|
322
|
+
propose: {
|
|
323
|
+
name: 'pitcher_roleplay_path_assignment_propose',
|
|
324
|
+
title: 'Propose a learning path assignment',
|
|
325
|
+
description: 'Prepare assigning a published AI Roleplay learning path to reps and/or content-distribution groups, with the ' +
|
|
326
|
+
'managers who coach it and an optional due date. Saves nothing until the user confirms in a confirm view and ' +
|
|
327
|
+
'pitcher_roleplay_path_assignment_commit runs. Instance admins and editors and org admins only; learning paths ' +
|
|
328
|
+
'must be enabled on the instance. Find pathId with pitcher_roleplay_paths_list, groupIds with pitcher_groups_list ' +
|
|
329
|
+
'and user ids with pitcher_users_list. Every manager named here can see, comment on and evaluate the roleplay ' +
|
|
330
|
+
'sessions and recordings of every rep on the assignment, including reps who join its groups later, until the ' +
|
|
331
|
+
'assignment is deleted. Only an admin may name themselves.',
|
|
332
|
+
inputShape: {
|
|
333
|
+
instanceId,
|
|
334
|
+
pathId: z.string().describe('Published path id (from pitcher_roleplay_paths_list)'),
|
|
335
|
+
groupIds: z.array(z.string()).max(200).optional().describe('Content-distribution group ids; their reps get the path'),
|
|
336
|
+
recipientUserIds: z.array(z.string()).max(500).optional().describe('Rep user ids (reps only)'),
|
|
337
|
+
managerIds: z.array(z.string()).min(1).max(200).describe('User ids of the managers who coach this assignment (at least one)'),
|
|
338
|
+
dueDate: z.string().optional().describe('Due date YYYY-MM-DD; omit for none'),
|
|
339
|
+
mandatory: z.boolean().optional().describe('Mark the path mandatory (default false)'),
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
commit: {
|
|
343
|
+
name: 'pitcher_roleplay_path_assignment_commit',
|
|
344
|
+
title: 'Assign a learning path',
|
|
345
|
+
description: `Save a path assignment proposed by pitcher_roleplay_path_assignment_propose, ${COMMIT_WORDING}`,
|
|
346
|
+
argsShape: {
|
|
347
|
+
confirmText: confirmTextShape,
|
|
348
|
+
instanceId,
|
|
349
|
+
pathId: z.string(),
|
|
350
|
+
groupIds: z.array(z.string()),
|
|
351
|
+
recipientUserIds: z.array(z.string()),
|
|
352
|
+
managerIds: z.array(z.string()),
|
|
353
|
+
mandatory: z.boolean(),
|
|
354
|
+
dueDate: z.string().nullable(),
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
normalize: (a) => ({
|
|
358
|
+
confirmText: str(a.confirmText).trim(),
|
|
359
|
+
instanceId: str(a.instanceId),
|
|
360
|
+
pathId: str(a.pathId),
|
|
361
|
+
groupIds: sortedUnique(a.groupIds),
|
|
362
|
+
recipientUserIds: sortedUnique(a.recipientUserIds),
|
|
363
|
+
managerIds: sortedUnique(a.managerIds),
|
|
364
|
+
mandatory: a.mandatory === true,
|
|
365
|
+
dueDate: a.dueDate === undefined || a.dueDate === null || a.dueDate === '' ? null : str(a.dueDate),
|
|
366
|
+
}),
|
|
367
|
+
async prepare(input, auth, deps) {
|
|
368
|
+
const inst = str(input.instanceId);
|
|
369
|
+
const pathId = str(input.pathId);
|
|
370
|
+
const groupIds = sortedUnique(input.groupIds);
|
|
371
|
+
const recipientUserIds = sortedUnique(input.recipientUserIds);
|
|
372
|
+
const managerIds = sortedUnique(input.managerIds);
|
|
373
|
+
const dueDate = str(input.dueDate) || null;
|
|
374
|
+
const mandatory = input.mandatory === true;
|
|
375
|
+
// local checks first: no call for a request the route would refuse anyway
|
|
376
|
+
if (groupIds.length === 0 && recipientUserIds.length === 0)
|
|
377
|
+
throw createCliError('VALIDATION', 'name at least one group or rep');
|
|
378
|
+
const badIds = [...recipientUserIds, ...managerIds].filter((id) => !USER_ID.test(id));
|
|
379
|
+
if (badIds.length)
|
|
380
|
+
throw createCliError('VALIDATION', `not user ids: ${badIds.join(', ')}`);
|
|
381
|
+
if (dueDate !== null && !isRealDate(dueDate))
|
|
382
|
+
throw createCliError('VALIDATION', 'dueDate must be a real date, YYYY-MM-DD');
|
|
383
|
+
const c = client(auth, deps);
|
|
384
|
+
const [actorUserId, paths, groups, managerNames, repNames, records, members] = await Promise.all([
|
|
385
|
+
getMeId(c),
|
|
386
|
+
fetchPaths(c, inst, 'published').catch((e) => {
|
|
387
|
+
throw pathsError(e);
|
|
388
|
+
}),
|
|
389
|
+
groupNames(auth, deps, inst, groupIds),
|
|
390
|
+
mapLimit(managerIds.slice(0, NAMES_SHOWN), LOOKUP_CONCURRENCY, (id) => userName(auth, deps, id)),
|
|
391
|
+
mapLimit(recipientUserIds.slice(0, NAMES_SHOWN), LOOKUP_CONCURRENCY, (id) => userName(auth, deps, id)),
|
|
392
|
+
fetchPathRecords(c, inst).catch((e) => {
|
|
393
|
+
throw pathsError(e);
|
|
394
|
+
}),
|
|
395
|
+
membersAmong(auth, deps, inst, managerIds),
|
|
396
|
+
]);
|
|
397
|
+
const path = paths.find((p) => str(p.id) === pathId);
|
|
398
|
+
if (!path)
|
|
399
|
+
throw createCliError('VALIDATION', `path ${pathId} is not a published path on this instance`);
|
|
400
|
+
const warnings = [];
|
|
401
|
+
const notices = [];
|
|
402
|
+
if (dueDate !== null && dueDate < new Date().toISOString().slice(0, 10))
|
|
403
|
+
warnings.push(`The due date ${dueDate} is in the past.`);
|
|
404
|
+
const overlap = records.filter((r) => str(r.pathId) === pathId && r.active !== false).length;
|
|
405
|
+
if (overlap > 0)
|
|
406
|
+
warnings.push(`This path already has ${overlap} active assignments on this instance; reps on both keep one path.`);
|
|
407
|
+
if (managerIds.includes(actorUserId)) {
|
|
408
|
+
// an admin's view carries the membership keys (path-views.ts); an editor's does not
|
|
409
|
+
if (records.length === 0) {
|
|
410
|
+
notices.push('You are named as a manager. Only an admin may do this; if you are an editor, saving fails.');
|
|
411
|
+
}
|
|
412
|
+
else if (!records.some((r) => 'resolvedUserIds' in r)) {
|
|
413
|
+
throw createCliError('AUTH', 'Only an admin may name themselves as a manager');
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const managerName = (id) => {
|
|
417
|
+
const i = managerIds.indexOf(id);
|
|
418
|
+
return i >= 0 && i < managerNames.length ? managerNames[i] : previewName(`user ${id}`);
|
|
419
|
+
};
|
|
420
|
+
for (const id of managerIds) {
|
|
421
|
+
if (recipientUserIds.includes(id) || members?.has(id)) {
|
|
422
|
+
warnings.push(`${members?.get(id) ?? managerName(id)} is a rep; naming them as manager gives them access to the other reps' sessions and recordings.`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const pathName = previewName(str(path.name) || pathId);
|
|
426
|
+
const version = typeof path.version === 'number' ? path.version : 0;
|
|
427
|
+
const stages = Array.isArray(path.stages) ? path.stages.length : 0;
|
|
428
|
+
const steps = Array.isArray(path.steps) ? path.steps.length : 0;
|
|
429
|
+
const groupList = groupIds.map((id) => groups.get(id));
|
|
430
|
+
const allManagers = [...managerNames, ...Array(Math.max(0, managerIds.length - managerNames.length)).fill('')];
|
|
431
|
+
const managersShown = (n) => nameList(allManagers.slice(0, managerNames.length), n).concat(managerIds.length > managerNames.length && n >= managerNames.length ? ` and ${managerIds.length - managerNames.length} more` : '');
|
|
432
|
+
const repsShown = (n) => {
|
|
433
|
+
if (recipientUserIds.length === 0)
|
|
434
|
+
return '';
|
|
435
|
+
const shown = repNames.slice(0, Math.min(n, repNames.length));
|
|
436
|
+
const more = recipientUserIds.length - shown.length;
|
|
437
|
+
return shown.length ? `: ${shown.join(', ')}${more ? `, and ${more} more` : ''}` : '';
|
|
438
|
+
};
|
|
439
|
+
const due = dueDate ? `Due ${dueDate}` : "Due 'no due date'";
|
|
440
|
+
const confirmText = fitConfirm((n) => {
|
|
441
|
+
const audience = [
|
|
442
|
+
groupList.length ? `groups ${nameList(groupList, n)}` : '',
|
|
443
|
+
recipientUserIds.length ? `${recipientUserIds.length} reps${repsShown(Math.min(n, 10))}` : '',
|
|
444
|
+
]
|
|
445
|
+
.filter(Boolean)
|
|
446
|
+
.join(' and ');
|
|
447
|
+
return (`Assign '${pathName}' (v${version}) to ${audience}. Managers ${managersShown(n)} get access to see, comment on and ` +
|
|
448
|
+
'evaluate the roleplay sessions and recordings of every rep on it, including reps who join these groups later, ' +
|
|
449
|
+
`until it is deleted. ${due}${mandatory ? ', mandatory' : ''}.`);
|
|
450
|
+
}, Math.max(groupList.length, managerNames.length, 10));
|
|
451
|
+
const fields = [
|
|
452
|
+
{ label: 'Path', value: `${pathName} (v${version}), ${stages} stages, ${steps} steps` },
|
|
453
|
+
{ label: 'Groups', value: groupList.length ? nameList(groupList, GROUPS_SHOWN) : 'None' },
|
|
454
|
+
{
|
|
455
|
+
label: 'Reps',
|
|
456
|
+
value: recipientUserIds.length
|
|
457
|
+
? `${repNames.join(', ')}${recipientUserIds.length > repNames.length ? ` and ${recipientUserIds.length - repNames.length} more` : ''}`
|
|
458
|
+
: 'None',
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
label: 'Managers',
|
|
462
|
+
value: `${managerNames.join(', ')}${managerIds.length > managerNames.length ? ` and ${managerIds.length - managerNames.length} more` : ''}`,
|
|
463
|
+
},
|
|
464
|
+
{ label: 'Due date', value: dueDate ?? 'None' },
|
|
465
|
+
{ label: 'Mandatory', value: mandatory ? 'Yes' : 'No' },
|
|
466
|
+
];
|
|
467
|
+
notices.unshift('The managers listed above will be able to see, comment on and evaluate the roleplay sessions and recordings of ' +
|
|
468
|
+
'every rep on this assignment, including reps added to these groups later, until the assignment is deleted (or ' +
|
|
469
|
+
'the rep leaves these groups, or paths are turned off). Deactivating it does not remove this access.', 'Reps in these groups get the path while they are members, including reps who join later. The current rep count is shown after saving.');
|
|
470
|
+
return {
|
|
471
|
+
actorUserId,
|
|
472
|
+
args: { confirmText, instanceId: inst, pathId, groupIds, recipientUserIds, managerIds, mandatory, dueDate },
|
|
473
|
+
preview: { title: 'Assign a learning path', summary: `Assign '${pathName}' to the groups and reps below.`, fields, notices, warnings, confirmLabel: 'Assign path' },
|
|
474
|
+
};
|
|
475
|
+
},
|
|
476
|
+
async execute(args, key, auth, deps) {
|
|
477
|
+
const { body, replayed } = await postPathAssignment(client(auth, deps), args.instanceId, args, key);
|
|
478
|
+
const record = body.record ?? {};
|
|
479
|
+
const snapshot = (record.pathSnapshot ?? {});
|
|
480
|
+
const resolved = record.resolvedUserIds;
|
|
481
|
+
const warnings = (Array.isArray(body.warnings) ? body.warnings : []).map((w) => {
|
|
482
|
+
const x = w;
|
|
483
|
+
return x.code === 'overlapping_record' ? `${str(x.count)} reps already had this path from another assignment` : str(x.code);
|
|
484
|
+
});
|
|
485
|
+
return {
|
|
486
|
+
replayed,
|
|
487
|
+
result: {
|
|
488
|
+
id: str(record.id),
|
|
489
|
+
pathName: str(snapshot.name),
|
|
490
|
+
groupNames: Array.isArray(record.groupNames) ? record.groupNames.map(str) : [],
|
|
491
|
+
resolvedReps: Array.isArray(resolved) ? resolved.length : null,
|
|
492
|
+
dueDate: args.dueDate,
|
|
493
|
+
mandatory: args.mandatory,
|
|
494
|
+
warnings,
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
},
|
|
498
|
+
resultText: (r) => `path '${r.pathName}' assigned (assignment ${r.id})${r.resolvedReps !== null ? ` to ${r.resolvedReps} reps` : ''}` +
|
|
499
|
+
`${r.warnings.length ? `; ${r.warnings.join('; ')}` : ''}.`,
|
|
500
|
+
});
|
|
501
|
+
/** The six confirm tools (PIT-10113), registered in `apps/registry.ts` with the write-confirm view. */
|
|
502
|
+
export const roleplayWriteTools = [
|
|
503
|
+
commentPropose,
|
|
504
|
+
commentCommit,
|
|
505
|
+
commentDeletePropose,
|
|
506
|
+
commentDeleteCommit,
|
|
507
|
+
pathPropose,
|
|
508
|
+
pathCommit,
|
|
509
|
+
];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing text helpers shared by the MCP Apps views (PIT-10068 7.3, reused by PIT-10067 7.3). Every size is
|
|
3
|
+
* in UTF-8 BYTES, never characters: a CJK name is 3 bytes a character, and the hosts' text limits are bytes.
|
|
4
|
+
*/
|
|
5
|
+
/** Default budget of one tool's model text, in bytes. */
|
|
6
|
+
export declare const APP_TEXT_MAX_BYTES = 4096;
|
|
7
|
+
/** Default bytes kept free for the "more" line. */
|
|
8
|
+
export declare const MORE_RESERVED_BYTES = 120;
|
|
9
|
+
export declare const byteLen: (s: string) => number;
|
|
10
|
+
export declare const jsonBytes: (v: unknown) => number;
|
|
11
|
+
/** Clip to at most `maxBytes` UTF-8 bytes, "..." included, never splitting a code point. */
|
|
12
|
+
export declare function clipBytes(s: string, maxBytes: number): string;
|
|
13
|
+
export interface FitTextInput {
|
|
14
|
+
heads: {
|
|
15
|
+
text: string;
|
|
16
|
+
maxBytes: number;
|
|
17
|
+
}[];
|
|
18
|
+
body: {
|
|
19
|
+
text: string;
|
|
20
|
+
kind?: string;
|
|
21
|
+
}[];
|
|
22
|
+
/** Items that never entered the body (sessions beyond 50, viewers on later pages). */
|
|
23
|
+
hidden?: number;
|
|
24
|
+
more: (d: {
|
|
25
|
+
lines: number;
|
|
26
|
+
hidden: number;
|
|
27
|
+
kinds: string[];
|
|
28
|
+
}) => string;
|
|
29
|
+
/** Whole-text budget. Default APP_TEXT_MAX_BYTES. */
|
|
30
|
+
maxBytes?: number;
|
|
31
|
+
/** Bytes kept free for the `more` line, which is clipped to fit them. Default MORE_RESERVED_BYTES. */
|
|
32
|
+
reserve?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Heads (each clipped to its own cap), then body lines in order while the running size plus the bytes reserved
|
|
36
|
+
* for the `more` line fits `maxBytes`; `more` when anything was left out. A body line enters whole or not at all.
|
|
37
|
+
* Every head has a cap, so the total never exceeds the budget and the 8 KB clamp in result.ts never triggers.
|
|
38
|
+
*/
|
|
39
|
+
export declare function fitText({ heads, body, hidden, more, maxBytes, reserve, }: FitTextInput): string;
|
|
40
|
+
/** A list head: entries dropped from the end until it fits, with "and N more"; `fallback` when none fits. */
|
|
41
|
+
export declare function listHead(prefix: string, entries: string[], total: number, maxBytes: number, fallback: string): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing text helpers shared by the MCP Apps views (PIT-10068 7.3, reused by PIT-10067 7.3). Every size is
|
|
3
|
+
* in UTF-8 BYTES, never characters: a CJK name is 3 bytes a character, and the hosts' text limits are bytes.
|
|
4
|
+
*/
|
|
5
|
+
/** Default budget of one tool's model text, in bytes. */
|
|
6
|
+
export const APP_TEXT_MAX_BYTES = 4096;
|
|
7
|
+
/** Default bytes kept free for the "more" line. */
|
|
8
|
+
export const MORE_RESERVED_BYTES = 120;
|
|
9
|
+
export const byteLen = (s) => Buffer.byteLength(s, 'utf8');
|
|
10
|
+
export const jsonBytes = (v) => byteLen(JSON.stringify(v));
|
|
11
|
+
/** Clip to at most `maxBytes` UTF-8 bytes, "..." included, never splitting a code point. */
|
|
12
|
+
export function clipBytes(s, maxBytes) {
|
|
13
|
+
if (byteLen(s) <= maxBytes)
|
|
14
|
+
return s;
|
|
15
|
+
let out = '';
|
|
16
|
+
let used = 0;
|
|
17
|
+
for (const cp of Array.from(s)) {
|
|
18
|
+
const b = byteLen(cp);
|
|
19
|
+
if (used + b + 3 > maxBytes)
|
|
20
|
+
break;
|
|
21
|
+
out += cp;
|
|
22
|
+
used += b;
|
|
23
|
+
}
|
|
24
|
+
return `${out}...`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Heads (each clipped to its own cap), then body lines in order while the running size plus the bytes reserved
|
|
28
|
+
* for the `more` line fits `maxBytes`; `more` when anything was left out. A body line enters whole or not at all.
|
|
29
|
+
* Every head has a cap, so the total never exceeds the budget and the 8 KB clamp in result.ts never triggers.
|
|
30
|
+
*/
|
|
31
|
+
export function fitText({ heads, body, hidden = 0, more, maxBytes = APP_TEXT_MAX_BYTES, reserve = MORE_RESERVED_BYTES, }) {
|
|
32
|
+
const lines = heads.filter((h) => h.text).map((h) => clipBytes(h.text, h.maxBytes));
|
|
33
|
+
let used = lines.reduce((n, l, i) => n + byteLen(l) + (i ? 1 : 0), 0);
|
|
34
|
+
let i = 0;
|
|
35
|
+
for (; i < body.length; i += 1) {
|
|
36
|
+
const add = byteLen(body[i].text) + (lines.length ? 1 : 0);
|
|
37
|
+
if (used + add + reserve > maxBytes)
|
|
38
|
+
break;
|
|
39
|
+
lines.push(body[i].text);
|
|
40
|
+
used += add;
|
|
41
|
+
}
|
|
42
|
+
const left = body.slice(i);
|
|
43
|
+
if (left.length + hidden > 0) {
|
|
44
|
+
const kinds = Array.from(new Set(left.map((l) => l.kind).filter((k) => !!k)));
|
|
45
|
+
lines.push(clipBytes(more({ lines: left.length, hidden, kinds }), reserve - 1));
|
|
46
|
+
}
|
|
47
|
+
return lines.join('\n');
|
|
48
|
+
}
|
|
49
|
+
/** A list head: entries dropped from the end until it fits, with "and N more"; `fallback` when none fits. */
|
|
50
|
+
export function listHead(prefix, entries, total, maxBytes, fallback) {
|
|
51
|
+
for (let n = entries.length; n >= 0; n -= 1) {
|
|
52
|
+
const rest = total - n;
|
|
53
|
+
const text = `${prefix}${entries.slice(0, n).join('; ')}${rest > 0 ? `${n ? '; ' : ''}and ${rest} more` : ''}.`;
|
|
54
|
+
if (byteLen(text) <= maxBytes)
|
|
55
|
+
return text;
|
|
56
|
+
}
|
|
57
|
+
return clipBytes(fallback, maxBytes);
|
|
58
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { McpRunContext } from '../server.js';
|
|
2
|
+
/** Content-Security-Policy origins a view may load from (ext-apps `_meta.ui.csp`). */
|
|
3
|
+
export interface AppCsp {
|
|
4
|
+
resourceDomains?: string[];
|
|
5
|
+
connectDomains?: string[];
|
|
6
|
+
}
|
|
7
|
+
/** One MCP Apps view, served as the `ui://pitcher/<name>.html` resource (PIT-10066). */
|
|
8
|
+
export interface AppViewDescriptor {
|
|
9
|
+
name: string;
|
|
10
|
+
title: string;
|
|
11
|
+
description: string;
|
|
12
|
+
/** CSP for this view, from the per-request context. Default: block all external origins. */
|
|
13
|
+
csp?: (ctx: McpRunContext) => AppCsp;
|
|
14
|
+
/** Sandbox permissions the view asks for (host may refuse; the view feature-detects). */
|
|
15
|
+
permissions?: {
|
|
16
|
+
clipboardWrite?: Record<string, never>;
|
|
17
|
+
};
|
|
18
|
+
/** Ask the host to draw a border around the view. Default true. */
|
|
19
|
+
prefersBorder?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare const appViewUri: (name: string) => string;
|
|
22
|
+
/** Origin of the org domain the request runs against, for img-src. [] when the context has no url (local). */
|
|
23
|
+
export declare function orgOrigin(ctx: McpRunContext): string[];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const appViewUri = (name) => `ui://pitcher/${name}.html`;
|
|
2
|
+
/** Origin of the org domain the request runs against, for img-src. [] when the context has no url (local). */
|
|
3
|
+
export function orgOrigin(ctx) {
|
|
4
|
+
if (!ctx.url)
|
|
5
|
+
return [];
|
|
6
|
+
try {
|
|
7
|
+
return [new URL(ctx.url).origin];
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { WRITE_CONFIRM_VIEW } from './confirm/define.js';
|
|
2
|
+
/** PIT-10113: the Pitcher-owned confirm view every confirmed write renders in MCP Apps hosts. */
|
|
3
|
+
export const writeConfirmView = {
|
|
4
|
+
name: WRITE_CONFIRM_VIEW,
|
|
5
|
+
title: 'Pitcher confirm',
|
|
6
|
+
description: 'Review and confirm a Pitcher change before it is saved.',
|
|
7
|
+
// csp omitted: block all external origins (text only)
|
|
8
|
+
};
|