@clien-ai/mcp 0.2.1 → 0.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/README.md +96 -11
- package/dist/server.js +10 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/collections.js +433 -0
- package/dist/tools/collections.js.map +1 -0
- package/dist/tools/interview.js +190 -25
- package/dist/tools/interview.js.map +1 -1
- package/dist/tools/param-aliases.js +144 -0
- package/dist/tools/param-aliases.js.map +1 -0
- package/dist/tools/personas.js +442 -40
- package/dist/tools/personas.js.map +1 -1
- package/dist/tools/projects.js +545 -6
- package/dist/tools/projects.js.map +1 -1
- package/dist/tools/registry.js +234 -18
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/scoped-research.js +19 -3
- package/dist/tools/scoped-research.js.map +1 -1
- package/dist/types/report.js +180 -2
- package/dist/types/report.js.map +1 -1
- package/package.json +1 -1
package/dist/tools/personas.js
CHANGED
|
@@ -8,10 +8,23 @@
|
|
|
8
8
|
* per-endpoint timeouts) exactly as `projects.ts` and `status.ts` do. No new
|
|
9
9
|
* HTTP machinery.
|
|
10
10
|
*
|
|
11
|
+
* Params are snake_case (`project_id`, `company_size`) — matching every other
|
|
12
|
+
* tool — and translated to the camelCase the REST API expects at the call site.
|
|
13
|
+
* FUL-233: the pre-migration camelCase spellings stay accepted as deprecated
|
|
14
|
+
* aliases, declared in the schema and rewritten before parse; see
|
|
15
|
+
* `param-aliases.ts` for the mechanism and the `*_ALIASES` maps below for which
|
|
16
|
+
* spellings each tool honours.
|
|
17
|
+
* FUL-238: because an alias is a separate OPTIONAL property, it cannot satisfy
|
|
18
|
+
* the advert's `required` list — so the three ids that have an alias
|
|
19
|
+
* (`create_persona`/`generate_persona` `project_id`, `enrich_persona`
|
|
20
|
+
* `persona_id`) are declared `.optional()` and their presence is enforced in the
|
|
21
|
+
* handler by `requireId`, keeping an alias-only payload valid against the
|
|
22
|
+
* advertised schema. See `requireId` for the full rationale.
|
|
23
|
+
*
|
|
11
24
|
* Contract:
|
|
12
25
|
* - create_persona → POST /api/personas
|
|
13
26
|
* { projectId, name, role, company?, description?, companySize?,
|
|
14
|
-
* industry?, location?, ephemeral? } → 201 { persona }.
|
|
27
|
+
* industry?, location?, ephemeral? } → 201 { persona } (API body names).
|
|
15
28
|
* Returns a confirmation line + the persona in `_meta.persona`.
|
|
16
29
|
* The backend stamps `source='mcp'` automatically for bearer callers
|
|
17
30
|
* (FUL-21). `ephemeral: true` sets `include_in_insights=false` so a
|
|
@@ -24,7 +37,7 @@
|
|
|
24
37
|
* so `source='mcp'` + `ephemeral` behave identically to the explicit create.
|
|
25
38
|
* FUL-96: by default it also grounds the persona in REAL user-voice evidence
|
|
26
39
|
* (Exa forums + Stack Exchange) and returns the source receipts in
|
|
27
|
-
* `_meta.sources`; pass `
|
|
40
|
+
* `_meta.sources`; pass `ground_in_evidence: false` to skip retrieval. Grounding
|
|
28
41
|
* adds no extra credit cost (the Exa call is COGS).
|
|
29
42
|
* FUL-101 (M3a): the generated persona also carries a decision endowment
|
|
30
43
|
* (current tool, typical spend, switching cost, who-pays-vs-who-uses) folded
|
|
@@ -33,6 +46,12 @@
|
|
|
33
46
|
* FUL-102 (M3b): it also carries a credibility domain ("credible on X — not
|
|
34
47
|
* beyond") folded into the background, an honest bound on what the persona can
|
|
35
48
|
* speak to (Chuang transfer rule).
|
|
49
|
+
* FUL-150 (M5b): it also generates identity priors — seniority rung + career
|
|
50
|
+
* trajectory — persisted to the structured `personas.seniority` /
|
|
51
|
+
* `personas.career_path` columns (the demographic shape of the role). Unlike
|
|
52
|
+
* endowment/scope (folded into `background`), these are NOT echoed in the
|
|
53
|
+
* returned persona object — they are a write-only persistence target that
|
|
54
|
+
* M5c later reads back to calibrate against aggregate people distributions.
|
|
36
55
|
* - `enrich_persona` (FUL-77) — AI-fills the blank (or explicitly refreshed)
|
|
37
56
|
* scalar fields of an EXISTING persona over `POST /api/personas/ai-enrich`.
|
|
38
57
|
* Debits one `enrich_persona` credit, refunded if the enrichment fails; a
|
|
@@ -45,6 +64,7 @@
|
|
|
45
64
|
*/
|
|
46
65
|
import { z } from 'zod';
|
|
47
66
|
import { ToolError } from './errors.js';
|
|
67
|
+
import { deprecatedAlias } from './param-aliases.js';
|
|
48
68
|
import { apiCall, categorizeFetchError, truncate, } from './research.js';
|
|
49
69
|
const PERSONAS_FETCH_TIMEOUT_MS = 30_000;
|
|
50
70
|
// Caps re-declared locally: the @clien-ai/mcp package is published separately
|
|
@@ -59,12 +79,20 @@ const DESCRIPTION_MAX = 5000;
|
|
|
59
79
|
const COMPANY_SIZE_MAX = 100;
|
|
60
80
|
const INDUSTRY_MAX = 100;
|
|
61
81
|
const LOCATION_MAX = 200;
|
|
82
|
+
/** FUL-233: legacy camelCase spellings `create_persona` still accepts. */
|
|
83
|
+
export const CREATE_PERSONA_ALIASES = {
|
|
84
|
+
projectId: 'project_id',
|
|
85
|
+
companySize: 'company_size',
|
|
86
|
+
};
|
|
62
87
|
export const CreatePersonaInputSchema = z.object({
|
|
63
|
-
|
|
88
|
+
project_id: z
|
|
64
89
|
.string()
|
|
65
90
|
.uuid()
|
|
66
|
-
.
|
|
67
|
-
'(or
|
|
91
|
+
.optional()
|
|
92
|
+
.describe('REQUIRED — pass `project_id` (or its deprecated alias `projectId`); the call is rejected ' +
|
|
93
|
+
'if neither is present. It is absent from this schema\'s `required` list only so an ' +
|
|
94
|
+
'alias-only call stays valid. UUID of the project this persona belongs to. Get it from ' +
|
|
95
|
+
'`list_projects` (or `create_project`, whose `_meta.project.id` you can pass straight in). ' +
|
|
68
96
|
'The persona is rejected with a 404 if you do not own the project.'),
|
|
69
97
|
name: z
|
|
70
98
|
.string()
|
|
@@ -89,9 +117,9 @@ export const CreatePersonaInputSchema = z.object({
|
|
|
89
117
|
.optional()
|
|
90
118
|
.describe('Optional background/bio for the persona: goals, pain points, context — ' +
|
|
91
119
|
'whatever helps an interview feel grounded. Stored as the persona\'s background.'),
|
|
92
|
-
|
|
120
|
+
company_size: z
|
|
93
121
|
.string()
|
|
94
|
-
.max(COMPANY_SIZE_MAX, `
|
|
122
|
+
.max(COMPANY_SIZE_MAX, `company_size too long (max ${COMPANY_SIZE_MAX} chars)`)
|
|
95
123
|
.optional()
|
|
96
124
|
.describe('Optional company size (e.g. "11-50", "Enterprise").'),
|
|
97
125
|
industry: z
|
|
@@ -111,6 +139,9 @@ export const CreatePersonaInputSchema = z.object({
|
|
|
111
139
|
.describe('Set true for a scratch/throwaway persona that should NOT count toward the ' +
|
|
112
140
|
'project\'s aggregate insights (sets include_in_insights=false). Leave false ' +
|
|
113
141
|
'(the default) for a real persona you want reflected in project-level analysis.'),
|
|
142
|
+
// --- Deprecated camelCase aliases (FUL-233) — rewritten before parse. ---
|
|
143
|
+
projectId: deprecatedAlias('project_id', z.string().uuid()),
|
|
144
|
+
companySize: deprecatedAlias('company_size', z.string().max(COMPANY_SIZE_MAX, `companySize too long (max ${COMPANY_SIZE_MAX} chars)`)),
|
|
114
145
|
});
|
|
115
146
|
export class PersonaToolError extends ToolError {
|
|
116
147
|
}
|
|
@@ -120,33 +151,77 @@ function formatZodError(error) {
|
|
|
120
151
|
function makeCtx(deps) {
|
|
121
152
|
return { config: deps.config, session: deps.session, fetch: deps.fetch ?? fetch };
|
|
122
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Presence check for an id that is `.optional()` in the Zod schema but mandatory
|
|
156
|
+
* in practice (FUL-238).
|
|
157
|
+
*
|
|
158
|
+
* Why optional-then-checked rather than plainly required: a required field lands
|
|
159
|
+
* in the advert's `required` list, and its deprecated camelCase alias — which is
|
|
160
|
+
* a SEPARATE optional property — cannot satisfy it. So an alias-only payload was
|
|
161
|
+
* invalid against the advertised schema even though the server honoured it, and
|
|
162
|
+
* a client that pre-validates OUTGOING arguments would refuse to send the call.
|
|
163
|
+
* Dropping the id from `required` and enforcing it here makes the advert accept
|
|
164
|
+
* every payload the runtime accepts. The cost is the advert's "this field is
|
|
165
|
+
* mandatory" signal, so each id's `.describe()` says REQUIRED in prose instead.
|
|
166
|
+
*
|
|
167
|
+
* Deliberately NOT a Zod `.refine()`/`.superRefine()`: those yield a `ZodEffects`
|
|
168
|
+
* with no `.shape`, which both `zodToInputSchema` and
|
|
169
|
+
* `__tests__/schema-contract.test.ts` depend on — the same convention
|
|
170
|
+
* `interview_persona` (per-action ids) and `update_persona` (at-least-one-field)
|
|
171
|
+
* already follow. Cross-field and presence rules live in plain code.
|
|
172
|
+
*
|
|
173
|
+
* Runs after `normalizeParamAliases` (applied by `defineTool` in `registry.ts`),
|
|
174
|
+
* so by the time it sees the input an alias-only call has already been rewritten
|
|
175
|
+
* onto the canonical name — and before any HTTP call, so a missing id never
|
|
176
|
+
* writes anything.
|
|
177
|
+
*
|
|
178
|
+
* `undefined` is the only "absent": Zod rejects an explicit `null` for a
|
|
179
|
+
* `.string().uuid()` before this runs.
|
|
180
|
+
*/
|
|
181
|
+
function requireId(value, canonical, alias) {
|
|
182
|
+
if (value === undefined) {
|
|
183
|
+
throw new PersonaToolError(`Invalid input — \`${canonical}\` is required. Pass \`${canonical}\` ` +
|
|
184
|
+
`(or its deprecated alias \`${alias}\`).`);
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
123
188
|
/**
|
|
124
189
|
* Shared non-ok handling: 401 drops the cached session (so the next tool call
|
|
125
|
-
* rebuilds from disk) and surfaces a re-auth message;
|
|
126
|
-
*
|
|
127
|
-
*
|
|
190
|
+
* rebuilds from disk) and surfaces a re-auth message; 403 `PERSONA_LOCKED`
|
|
191
|
+
* surfaces an upgrade message (a locked persona/project is read-only); 404 means
|
|
192
|
+
* the caller does not own the target (the default message points at the project;
|
|
193
|
+
* pass `notFound` for a persona-scoped 404); 400 surfaces the server's
|
|
194
|
+
* validation detail; everything else surfaces a generic HTTP error.
|
|
195
|
+
*
|
|
196
|
+
* The body is parsed BEFORE the 404 branch so the 403 lock case can key on the
|
|
197
|
+
* `PERSONA_LOCKED` code the PATCH route returns.
|
|
128
198
|
*/
|
|
129
|
-
async function throwForResponse(response, deps, action) {
|
|
199
|
+
async function throwForResponse(response, deps, action, options) {
|
|
130
200
|
const category = categorizeFetchError(response.status);
|
|
131
201
|
if (category === 'auth') {
|
|
132
202
|
if (response.refreshError?.kind !== 'transient')
|
|
133
203
|
deps.invalidateSession?.();
|
|
134
204
|
throw new PersonaToolError('Session expired. Re-run any Clien.ai tool to re-authenticate, then try again.');
|
|
135
205
|
}
|
|
136
|
-
if (response.status === 404) {
|
|
137
|
-
throw new PersonaToolError(`Could not ${action} — project not found, or you don't own it. ` +
|
|
138
|
-
'Call list_projects to confirm the projectId.');
|
|
139
|
-
}
|
|
140
206
|
const text = await response.text().catch(() => '');
|
|
141
|
-
let
|
|
207
|
+
let parsed = null;
|
|
142
208
|
try {
|
|
143
|
-
|
|
144
|
-
if (parsed?.error)
|
|
145
|
-
detail = `${parsed.error}${parsed.code ? ` (${parsed.code})` : ''}`;
|
|
209
|
+
parsed = JSON.parse(text);
|
|
146
210
|
}
|
|
147
211
|
catch {
|
|
148
|
-
// non-JSON body — fall back to the truncated raw text
|
|
212
|
+
// non-JSON body — fall back to the truncated raw text below.
|
|
213
|
+
}
|
|
214
|
+
if (response.status === 403 && parsed?.code === 'PERSONA_LOCKED') {
|
|
215
|
+
throw new PersonaToolError(`Could not ${action} — it is locked due to subscription limits. Upgrade to Pro to edit it.`);
|
|
149
216
|
}
|
|
217
|
+
if (response.status === 404) {
|
|
218
|
+
throw new PersonaToolError(options?.notFound ??
|
|
219
|
+
`Could not ${action} — project not found, or you don't own it. ` +
|
|
220
|
+
'Call list_projects to confirm the project_id.');
|
|
221
|
+
}
|
|
222
|
+
let detail = truncate(text, 200);
|
|
223
|
+
if (parsed?.error)
|
|
224
|
+
detail = `${parsed.error}${parsed.code ? ` (${parsed.code})` : ''}`;
|
|
150
225
|
if (response.status === 400) {
|
|
151
226
|
throw new PersonaToolError(`Could not ${action} — ${detail}`);
|
|
152
227
|
}
|
|
@@ -157,7 +232,8 @@ export async function createPersona(input, deps) {
|
|
|
157
232
|
if (!parsed.success) {
|
|
158
233
|
throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
|
|
159
234
|
}
|
|
160
|
-
const {
|
|
235
|
+
const { project_id: rawProjectId, name, role, company, description, company_size: companySize, industry, location, ephemeral, } = parsed.data;
|
|
236
|
+
const projectId = requireId(rawProjectId, 'project_id', 'projectId');
|
|
161
237
|
const ctx = makeCtx(deps);
|
|
162
238
|
const body = { projectId, name, role, ephemeral };
|
|
163
239
|
if (company !== undefined)
|
|
@@ -212,12 +288,22 @@ const GENERATE_PERSONA_FETCH_TIMEOUT_MS = 60_000;
|
|
|
212
288
|
// Local cap, kept in sync with GeneratePersonaSchema in
|
|
213
289
|
// app/api/personas/ai-generate/route.ts. The server re-validates regardless.
|
|
214
290
|
const ADDITIONAL_CONTEXT_MAX = 2000;
|
|
291
|
+
/** FUL-233: legacy camelCase spellings `generate_persona` still accepts. */
|
|
292
|
+
export const GENERATE_PERSONA_ALIASES = {
|
|
293
|
+
projectId: 'project_id',
|
|
294
|
+
companySize: 'company_size',
|
|
295
|
+
additionalContext: 'additional_context',
|
|
296
|
+
groundInEvidence: 'ground_in_evidence',
|
|
297
|
+
};
|
|
215
298
|
export const GeneratePersonaInputSchema = z.object({
|
|
216
|
-
|
|
299
|
+
project_id: z
|
|
217
300
|
.string()
|
|
218
301
|
.uuid()
|
|
219
|
-
.
|
|
220
|
-
'
|
|
302
|
+
.optional()
|
|
303
|
+
.describe('REQUIRED — pass `project_id` (or its deprecated alias `projectId`); the call is rejected ' +
|
|
304
|
+
'if neither is present. It is absent from this schema\'s `required` list only so an ' +
|
|
305
|
+
'alias-only call stays valid. UUID of the project this persona belongs to. Get it from ' +
|
|
306
|
+
'`list_projects` (or `create_project`). Rejected with a 404 if you do not own the project.'),
|
|
221
307
|
name: z
|
|
222
308
|
.string()
|
|
223
309
|
.trim()
|
|
@@ -240,14 +326,14 @@ export const GeneratePersonaInputSchema = z.object({
|
|
|
240
326
|
.max(INDUSTRY_MAX, `industry too long (max ${INDUSTRY_MAX} chars)`)
|
|
241
327
|
.optional()
|
|
242
328
|
.describe('Optional industry (e.g. "Fintech"). When omitted the AI infers one.'),
|
|
243
|
-
|
|
329
|
+
company_size: z
|
|
244
330
|
.string()
|
|
245
|
-
.max(COMPANY_SIZE_MAX, `
|
|
331
|
+
.max(COMPANY_SIZE_MAX, `company_size too long (max ${COMPANY_SIZE_MAX} chars)`)
|
|
246
332
|
.optional()
|
|
247
333
|
.describe('Optional company size (e.g. "11-50"). When omitted the AI infers one.'),
|
|
248
|
-
|
|
334
|
+
additional_context: z
|
|
249
335
|
.string()
|
|
250
|
-
.max(ADDITIONAL_CONTEXT_MAX, `
|
|
336
|
+
.max(ADDITIONAL_CONTEXT_MAX, `additional_context too long (max ${ADDITIONAL_CONTEXT_MAX} chars)`)
|
|
251
337
|
.optional()
|
|
252
338
|
.describe('Optional free-form brief to steer generation — goals, constraints, the product being ' +
|
|
253
339
|
'validated, or anything that should shape the persona\'s background and pain points.'),
|
|
@@ -257,7 +343,7 @@ export const GeneratePersonaInputSchema = z.object({
|
|
|
257
343
|
.default(false)
|
|
258
344
|
.describe('Set true for a scratch/throwaway persona that should NOT count toward the project\'s ' +
|
|
259
345
|
'aggregate insights (sets include_in_insights=false).'),
|
|
260
|
-
|
|
346
|
+
ground_in_evidence: z
|
|
261
347
|
.boolean()
|
|
262
348
|
.optional()
|
|
263
349
|
.default(true)
|
|
@@ -265,6 +351,13 @@ export const GeneratePersonaInputSchema = z.object({
|
|
|
265
351
|
'from community forums + Stack Exchange for this role/industry, and returned with its ' +
|
|
266
352
|
'source receipts. Set false to skip retrieval for a faster, invented-from-brief persona. ' +
|
|
267
353
|
'Either way this debits one generate_persona credit; grounding adds no extra credit cost.'),
|
|
354
|
+
// --- Deprecated camelCase aliases (FUL-233) — rewritten before parse. ---
|
|
355
|
+
projectId: deprecatedAlias('project_id', z.string().uuid()),
|
|
356
|
+
companySize: deprecatedAlias('company_size', z.string().max(COMPANY_SIZE_MAX, `companySize too long (max ${COMPANY_SIZE_MAX} chars)`)),
|
|
357
|
+
additionalContext: deprecatedAlias('additional_context', z
|
|
358
|
+
.string()
|
|
359
|
+
.max(ADDITIONAL_CONTEXT_MAX, `additionalContext too long (max ${ADDITIONAL_CONTEXT_MAX} chars)`)),
|
|
360
|
+
groundInEvidence: deprecatedAlias('ground_in_evidence', z.boolean()),
|
|
268
361
|
});
|
|
269
362
|
/**
|
|
270
363
|
* Map a non-ok POST /api/personas/ai-generate response to a PersonaToolError.
|
|
@@ -302,7 +395,7 @@ async function throwForGenerateResponse(response, deps) {
|
|
|
302
395
|
}
|
|
303
396
|
if (status === 404) {
|
|
304
397
|
throw new PersonaToolError('Could not generate persona — project not found, or you don\'t own it. ' +
|
|
305
|
-
'Call list_projects to confirm the
|
|
398
|
+
'Call list_projects to confirm the project_id.');
|
|
306
399
|
}
|
|
307
400
|
const detail = parsed?.error ? truncate(parsed.error, 200) : truncate(text, 200);
|
|
308
401
|
if (status === 400) {
|
|
@@ -319,7 +412,8 @@ export async function generatePersona(input, deps) {
|
|
|
319
412
|
if (!parsed.success) {
|
|
320
413
|
throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
|
|
321
414
|
}
|
|
322
|
-
const {
|
|
415
|
+
const { project_id: rawProjectId, name, role, company, industry, company_size: companySize, additional_context: additionalContext, ephemeral, ground_in_evidence: groundInEvidence, } = parsed.data;
|
|
416
|
+
const projectId = requireId(rawProjectId, 'project_id', 'projectId');
|
|
323
417
|
const ctx = makeCtx(deps);
|
|
324
418
|
const body = { projectId, name, role, ephemeral, groundInEvidence };
|
|
325
419
|
if (company !== undefined)
|
|
@@ -383,25 +477,40 @@ const ENRICH_PERSONA_FETCH_TIMEOUT_MS = 60_000;
|
|
|
383
477
|
// app/lib/personas/enrich-with-credits.ts (the @clien-ai/mcp package is published
|
|
384
478
|
// separately and cannot import from the Next.js app). The server re-validates.
|
|
385
479
|
const ENRICHABLE_FIELDS = ['description', 'company', 'industry', 'companySize', 'location'];
|
|
480
|
+
/** FUL-233: legacy camelCase spellings `enrich_persona` still accepts. */
|
|
481
|
+
export const ENRICH_PERSONA_ALIASES = {
|
|
482
|
+
personaId: 'persona_id',
|
|
483
|
+
additionalContext: 'additional_context',
|
|
484
|
+
};
|
|
386
485
|
export const EnrichPersonaInputSchema = z.object({
|
|
387
|
-
|
|
486
|
+
persona_id: z
|
|
388
487
|
.string()
|
|
389
488
|
.uuid()
|
|
390
|
-
.
|
|
391
|
-
'
|
|
489
|
+
.optional()
|
|
490
|
+
.describe('REQUIRED — pass `persona_id` (or its deprecated alias `personaId`); the call is rejected ' +
|
|
491
|
+
'if neither is present. It is absent from this schema\'s `required` list only so an ' +
|
|
492
|
+
'alias-only call stays valid. UUID of the persona to enrich. Get it from a ' +
|
|
493
|
+
'`list_personas` entry or a persona\'s `_meta.persona.id` (e.g. from `create_persona`). ' +
|
|
494
|
+
'Rejected with a 404 if you do not own it.'),
|
|
392
495
|
refresh: z
|
|
393
496
|
.array(z.enum(ENRICHABLE_FIELDS))
|
|
394
497
|
.optional()
|
|
395
498
|
.describe('Optional list of already-populated fields to REGENERATE. By default enrich only fills ' +
|
|
396
499
|
'fields that are currently blank and never overwrites existing content; pass e.g. ' +
|
|
397
|
-
'["description", "industry"] to force those to be regenerated.
|
|
398
|
-
'
|
|
399
|
-
|
|
500
|
+
'["description", "industry"] to force those to be regenerated. These are persona ENTITY ' +
|
|
501
|
+
'field names, not param names, so `companySize` keeps its camelCase spelling here. ' +
|
|
502
|
+
'Valid values: description, company, industry, companySize, location.'),
|
|
503
|
+
additional_context: z
|
|
400
504
|
.string()
|
|
401
|
-
.max(ADDITIONAL_CONTEXT_MAX, `
|
|
505
|
+
.max(ADDITIONAL_CONTEXT_MAX, `additional_context too long (max ${ADDITIONAL_CONTEXT_MAX} chars)`)
|
|
402
506
|
.optional()
|
|
403
507
|
.describe('Optional free-form brief to steer the enrichment — the product/market being validated, ' +
|
|
404
508
|
'constraints, or anything that should shape the filled-in background and details.'),
|
|
509
|
+
// --- Deprecated camelCase aliases (FUL-233) — rewritten before parse. ---
|
|
510
|
+
personaId: deprecatedAlias('persona_id', z.string().uuid()),
|
|
511
|
+
additionalContext: deprecatedAlias('additional_context', z
|
|
512
|
+
.string()
|
|
513
|
+
.max(ADDITIONAL_CONTEXT_MAX, `additionalContext too long (max ${ADDITIONAL_CONTEXT_MAX} chars)`)),
|
|
405
514
|
});
|
|
406
515
|
/**
|
|
407
516
|
* Map a non-ok POST /api/personas/ai-enrich response to a PersonaToolError.
|
|
@@ -443,7 +552,7 @@ async function throwForEnrichResponse(response, deps) {
|
|
|
443
552
|
}
|
|
444
553
|
if (status === 404) {
|
|
445
554
|
throw new PersonaToolError('Could not enrich persona — persona not found, or you don\'t own it. ' +
|
|
446
|
-
'Confirm the
|
|
555
|
+
'Confirm the persona_id (e.g. from list_personas, or create_persona\'s _meta.persona.id).');
|
|
447
556
|
}
|
|
448
557
|
const detail = parsed?.error ? truncate(parsed.error, 200) : truncate(text, 200);
|
|
449
558
|
if (status === 400) {
|
|
@@ -460,7 +569,8 @@ export async function enrichPersona(input, deps) {
|
|
|
460
569
|
if (!parsed.success) {
|
|
461
570
|
throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
|
|
462
571
|
}
|
|
463
|
-
const {
|
|
572
|
+
const { persona_id: rawPersonaId, refresh, additional_context: additionalContext, } = parsed.data;
|
|
573
|
+
const personaId = requireId(rawPersonaId, 'persona_id', 'personaId');
|
|
464
574
|
const ctx = makeCtx(deps);
|
|
465
575
|
const body = { personaId };
|
|
466
576
|
if (refresh !== undefined)
|
|
@@ -502,4 +612,296 @@ export async function enrichPersona(input, deps) {
|
|
|
502
612
|
_meta: { persona, enriched, balance },
|
|
503
613
|
};
|
|
504
614
|
}
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// list_personas + get_persona (FUL-213 / §U2) — read-only entity discovery
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
//
|
|
619
|
+
// The keystone read surface: `list_personas` discovers persona IDs in a project
|
|
620
|
+
// (including personas created in the WEB app, previously unreachable from an
|
|
621
|
+
// agent), and `get_persona` fetches one entity for correction/interview. Both
|
|
622
|
+
// are read-only over the existing bearer endpoints — zero credit cost, zero side
|
|
623
|
+
// effects — mirroring `list_reports`/`get_report`.
|
|
624
|
+
//
|
|
625
|
+
// NOTE: this is the entity-discovery surface, NOT the grounding surface. Persona
|
|
626
|
+
// forum grounding (`sources[]`) and identity grounding (`identityPriors` /
|
|
627
|
+
// `identityProvenance`) live on `report_data.personas[]`, surfaced via
|
|
628
|
+
// `get_report` — `get_persona` returns whatever `GET /api/personas/[id]` gives
|
|
629
|
+
// (`toPersona`, which omits `sources[]` and the identity columns), so it does not
|
|
630
|
+
// promise grounding fields.
|
|
631
|
+
const LIST_LIMIT_MAX = 100;
|
|
632
|
+
const LIST_LIMIT_DEFAULT = 20;
|
|
633
|
+
export const ListPersonasInputSchema = z.object({
|
|
634
|
+
project_id: z
|
|
635
|
+
.string()
|
|
636
|
+
.uuid()
|
|
637
|
+
.describe('UUID of the project whose personas to list. Get it from `list_projects` ' +
|
|
638
|
+
'(or `create_project`). Rejected with a 404 if you do not own the project. ' +
|
|
639
|
+
'Lists personas created via the web app too, so you can discover a persona ' +
|
|
640
|
+
'id to pass to `get_persona`, `update_persona`, `enrich_persona`, or `interview_persona`.'),
|
|
641
|
+
limit: z
|
|
642
|
+
.number()
|
|
643
|
+
.int()
|
|
644
|
+
.min(1)
|
|
645
|
+
.max(LIST_LIMIT_MAX)
|
|
646
|
+
.optional()
|
|
647
|
+
.default(LIST_LIMIT_DEFAULT)
|
|
648
|
+
.describe(`Maximum number of personas to return (1-${LIST_LIMIT_MAX}, default ${LIST_LIMIT_DEFAULT}). ` +
|
|
649
|
+
'Newest personas come first.'),
|
|
650
|
+
offset: z
|
|
651
|
+
.number()
|
|
652
|
+
.int()
|
|
653
|
+
.min(0)
|
|
654
|
+
.optional()
|
|
655
|
+
.default(0)
|
|
656
|
+
.describe('Number of personas to skip before returning results (default 0). ' +
|
|
657
|
+
'Combine with `limit` to page through a large persona list; the total ' +
|
|
658
|
+
'count is returned in `_meta.pagination.total`.'),
|
|
659
|
+
});
|
|
660
|
+
export const GetPersonaInputSchema = z.object({
|
|
661
|
+
persona_id: z
|
|
662
|
+
.string()
|
|
663
|
+
.uuid()
|
|
664
|
+
.describe('UUID of the persona to fetch. Get it from a `list_personas` entry (or a ' +
|
|
665
|
+
'persona\'s `_meta.persona.id`). Rejected with a 404 if you do not own it. ' +
|
|
666
|
+
'Returns the persona ENTITY (name, role, company, industry, etc.) for ' +
|
|
667
|
+
'discovery/correction — persona grounding (forum sources + identity) lives ' +
|
|
668
|
+
'in `report_data`, read it via `get_report`.'),
|
|
669
|
+
});
|
|
670
|
+
export async function listPersonas(input, deps) {
|
|
671
|
+
const parsed = ListPersonasInputSchema.safeParse(input);
|
|
672
|
+
if (!parsed.success) {
|
|
673
|
+
throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
|
|
674
|
+
}
|
|
675
|
+
const { project_id: projectId, limit, offset } = parsed.data;
|
|
676
|
+
const ctx = makeCtx(deps);
|
|
677
|
+
const query = new URLSearchParams({
|
|
678
|
+
projectId,
|
|
679
|
+
limit: String(limit),
|
|
680
|
+
offset: String(offset),
|
|
681
|
+
});
|
|
682
|
+
let response;
|
|
683
|
+
try {
|
|
684
|
+
response = await apiCall(ctx, 'GET', `/api/personas?${query.toString()}`, {
|
|
685
|
+
timeoutMs: PERSONAS_FETCH_TIMEOUT_MS,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
const e = err;
|
|
690
|
+
if (e.name === 'AbortError' || e.name === 'TimeoutError') {
|
|
691
|
+
throw new PersonaToolError(`Listing personas timed out after ${PERSONAS_FETCH_TIMEOUT_MS / 1000}s. Try again in a moment.`);
|
|
692
|
+
}
|
|
693
|
+
throw err;
|
|
694
|
+
}
|
|
695
|
+
if (!response.ok)
|
|
696
|
+
await throwForResponse(response, deps, 'list personas');
|
|
697
|
+
const json = (await response.json().catch(() => null));
|
|
698
|
+
const personas = json?.personas ?? [];
|
|
699
|
+
const total = json?.pagination?.total ?? personas.length;
|
|
700
|
+
let text;
|
|
701
|
+
if (personas.length === 0) {
|
|
702
|
+
text =
|
|
703
|
+
offset > 0
|
|
704
|
+
? `No personas at offset ${offset} in project ${projectId} (total: ${total}).`
|
|
705
|
+
: `No personas in project ${projectId}. Use create_persona or generate_persona to add one.`;
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
const lines = personas.map((p) => {
|
|
709
|
+
const role = p.role ? ` (${p.role})` : '';
|
|
710
|
+
return `- ${p.name ?? '(unnamed)'}${role} — id: ${p.id ?? 'unknown'}`;
|
|
711
|
+
});
|
|
712
|
+
const shown = offset + personas.length;
|
|
713
|
+
const more = shown < total ? `\n\n${total - shown} more not shown — page with offset=${shown}.` : '';
|
|
714
|
+
text =
|
|
715
|
+
`Found ${total} persona${total === 1 ? '' : 's'} in project ${projectId}:\n${lines.join('\n')}${more}\n\n` +
|
|
716
|
+
'Use get_persona with an id to read one, update_persona to correct it, or interview_persona to interview it.';
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
content: [{ type: 'text', text }],
|
|
720
|
+
_meta: { personas, pagination: json?.pagination ?? { limit, offset, total } },
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
export async function getPersona(input, deps) {
|
|
724
|
+
const parsed = GetPersonaInputSchema.safeParse(input);
|
|
725
|
+
if (!parsed.success) {
|
|
726
|
+
throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
|
|
727
|
+
}
|
|
728
|
+
const { persona_id: personaId } = parsed.data;
|
|
729
|
+
const ctx = makeCtx(deps);
|
|
730
|
+
let response;
|
|
731
|
+
try {
|
|
732
|
+
response = await apiCall(ctx, 'GET', `/api/personas/${personaId}`, {
|
|
733
|
+
timeoutMs: PERSONAS_FETCH_TIMEOUT_MS,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
const e = err;
|
|
738
|
+
if (e.name === 'AbortError' || e.name === 'TimeoutError') {
|
|
739
|
+
throw new PersonaToolError(`Fetching the persona timed out after ${PERSONAS_FETCH_TIMEOUT_MS / 1000}s. Try again in a moment.`);
|
|
740
|
+
}
|
|
741
|
+
throw err;
|
|
742
|
+
}
|
|
743
|
+
if (!response.ok) {
|
|
744
|
+
await throwForResponse(response, deps, 'get persona', {
|
|
745
|
+
notFound: 'Could not get persona — persona not found, or you don\'t own it. ' +
|
|
746
|
+
'Confirm the persona_id (e.g. from list_personas).',
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
const json = (await response.json().catch(() => null));
|
|
750
|
+
const persona = json?.persona;
|
|
751
|
+
if (!persona?.id) {
|
|
752
|
+
throw new PersonaToolError('Persona fetch returned no persona; the operation may not have succeeded.');
|
|
753
|
+
}
|
|
754
|
+
const role = persona.role ? `, ${persona.role}` : '';
|
|
755
|
+
const company = persona.company ? ` at ${persona.company}` : '';
|
|
756
|
+
return {
|
|
757
|
+
content: [
|
|
758
|
+
{
|
|
759
|
+
type: 'text',
|
|
760
|
+
text: `Persona "${persona.name ?? personaId}"${role}${company} (id: ${persona.id})` +
|
|
761
|
+
`${persona.projectId ? ` in project ${persona.projectId}` : ''}. ` +
|
|
762
|
+
'Full fields are in `_meta.persona`.',
|
|
763
|
+
},
|
|
764
|
+
],
|
|
765
|
+
_meta: { persona },
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
// update_persona (FUL-210 / §U4) — correct explicit persona fields
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
//
|
|
772
|
+
// Wraps the shipped `PATCH /api/personas/[personaId]` (explicit patch, bearer,
|
|
773
|
+
// FREE/unmetered) — NOT `ai-enrich` (which is AI-filled + debits a credit). This
|
|
774
|
+
// completes the read → correct → interview loop for a web-created persona without
|
|
775
|
+
// leaving for the web UI. Identity priors (seniority/careerPath) are M5c-managed
|
|
776
|
+
// and NOT patchable here.
|
|
777
|
+
//
|
|
778
|
+
// The "at least one field required" rule is enforced in the handler (not via a
|
|
779
|
+
// Zod `.refine()`) so the schema stays a plain `ZodObject` — the registry type
|
|
780
|
+
// and the schema-contract test both assume `.shape`, and a `.refine()` would
|
|
781
|
+
// yield a `ZodEffects` (mirrors the convention documented in interview.ts).
|
|
782
|
+
/**
|
|
783
|
+
* The scalar fields `update_persona` can patch: snake_case PARAM name → the
|
|
784
|
+
* camelCase field name the REST API body and the persona entity both use
|
|
785
|
+
* (mirrors `UpdatePersonaSchema`). Only `company_size` diverges; the rest are
|
|
786
|
+
* single-word and identical on both sides.
|
|
787
|
+
*/
|
|
788
|
+
const UPDATABLE_FIELDS = {
|
|
789
|
+
name: 'name',
|
|
790
|
+
role: 'role',
|
|
791
|
+
company: 'company',
|
|
792
|
+
description: 'description',
|
|
793
|
+
company_size: 'companySize',
|
|
794
|
+
industry: 'industry',
|
|
795
|
+
location: 'location',
|
|
796
|
+
};
|
|
797
|
+
/** Param names as the caller supplies them — used in caller-facing messages. */
|
|
798
|
+
const UPDATABLE_PARAMS = Object.keys(UPDATABLE_FIELDS);
|
|
799
|
+
/** FUL-233: legacy camelCase spellings `update_persona` still accepts. */
|
|
800
|
+
export const UPDATE_PERSONA_ALIASES = { companySize: 'company_size' };
|
|
801
|
+
export const UpdatePersonaInputSchema = z.object({
|
|
802
|
+
persona_id: z
|
|
803
|
+
.string()
|
|
804
|
+
.uuid()
|
|
805
|
+
.describe('UUID of the persona to update. Get it from a `list_personas` entry (or a ' +
|
|
806
|
+
'persona\'s `_meta.persona.id`). Rejected with a 404 if you do not own it.'),
|
|
807
|
+
name: z
|
|
808
|
+
.string()
|
|
809
|
+
.trim()
|
|
810
|
+
.min(1, 'name must be non-empty')
|
|
811
|
+
.max(NAME_MAX, `name too long (max ${NAME_MAX} chars)`)
|
|
812
|
+
.optional()
|
|
813
|
+
.describe('New name for the persona.'),
|
|
814
|
+
role: z
|
|
815
|
+
.string()
|
|
816
|
+
.trim()
|
|
817
|
+
.min(1, 'role must be non-empty')
|
|
818
|
+
.max(ROLE_MAX, `role too long (max ${ROLE_MAX} chars)`)
|
|
819
|
+
.optional()
|
|
820
|
+
.describe('New job title / role (e.g. fix a wrong role).'),
|
|
821
|
+
company: z
|
|
822
|
+
.string()
|
|
823
|
+
.max(COMPANY_MAX, `company too long (max ${COMPANY_MAX} chars)`)
|
|
824
|
+
.optional()
|
|
825
|
+
.describe('New company or organisation.'),
|
|
826
|
+
description: z
|
|
827
|
+
.string()
|
|
828
|
+
.max(DESCRIPTION_MAX, `description too long (max ${DESCRIPTION_MAX} chars)`)
|
|
829
|
+
.optional()
|
|
830
|
+
.describe('New background/bio (goals, pain points, context).'),
|
|
831
|
+
company_size: z
|
|
832
|
+
.string()
|
|
833
|
+
.max(COMPANY_SIZE_MAX, `company_size too long (max ${COMPANY_SIZE_MAX} chars)`)
|
|
834
|
+
.optional()
|
|
835
|
+
.describe('New company size (e.g. "11-50", "Enterprise").'),
|
|
836
|
+
industry: z
|
|
837
|
+
.string()
|
|
838
|
+
.max(INDUSTRY_MAX, `industry too long (max ${INDUSTRY_MAX} chars)`)
|
|
839
|
+
.optional()
|
|
840
|
+
.describe('New industry (e.g. "Fintech", "Healthcare").'),
|
|
841
|
+
location: z
|
|
842
|
+
.string()
|
|
843
|
+
.max(LOCATION_MAX, `location too long (max ${LOCATION_MAX} chars)`)
|
|
844
|
+
.optional()
|
|
845
|
+
.describe('New location (e.g. "Berlin, Germany").'),
|
|
846
|
+
// --- Deprecated camelCase aliases (FUL-233) — rewritten before parse. ---
|
|
847
|
+
companySize: deprecatedAlias('company_size', z.string().max(COMPANY_SIZE_MAX, `companySize too long (max ${COMPANY_SIZE_MAX} chars)`)),
|
|
848
|
+
});
|
|
849
|
+
export async function updatePersona(input, deps) {
|
|
850
|
+
const parsed = UpdatePersonaInputSchema.safeParse(input);
|
|
851
|
+
if (!parsed.success) {
|
|
852
|
+
throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
|
|
853
|
+
}
|
|
854
|
+
const { persona_id: personaId, ...rest } = parsed.data;
|
|
855
|
+
// "At least one field" is enforced here, not via `.refine()` (see the header
|
|
856
|
+
// comment): build the patch body from only the fields the caller supplied,
|
|
857
|
+
// translating each snake_case param to the API's camelCase body field.
|
|
858
|
+
const body = {};
|
|
859
|
+
for (const param of UPDATABLE_PARAMS) {
|
|
860
|
+
const value = rest[param];
|
|
861
|
+
if (value !== undefined)
|
|
862
|
+
body[UPDATABLE_FIELDS[param]] = value;
|
|
863
|
+
}
|
|
864
|
+
if (Object.keys(body).length === 0) {
|
|
865
|
+
throw new PersonaToolError('Invalid input — provide at least one field to update ' +
|
|
866
|
+
`(one of: ${UPDATABLE_PARAMS.join(', ')}).`);
|
|
867
|
+
}
|
|
868
|
+
const ctx = makeCtx(deps);
|
|
869
|
+
let response;
|
|
870
|
+
try {
|
|
871
|
+
response = await apiCall(ctx, 'PATCH', `/api/personas/${personaId}`, {
|
|
872
|
+
body,
|
|
873
|
+
timeoutMs: PERSONAS_FETCH_TIMEOUT_MS,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
catch (err) {
|
|
877
|
+
const e = err;
|
|
878
|
+
if (e.name === 'AbortError' || e.name === 'TimeoutError') {
|
|
879
|
+
throw new PersonaToolError(`Updating the persona timed out after ${PERSONAS_FETCH_TIMEOUT_MS / 1000}s. ` +
|
|
880
|
+
'It may or may not have been applied — call get_persona to check before retrying.');
|
|
881
|
+
}
|
|
882
|
+
throw err;
|
|
883
|
+
}
|
|
884
|
+
if (!response.ok) {
|
|
885
|
+
await throwForResponse(response, deps, 'update persona', {
|
|
886
|
+
notFound: 'Could not update persona — persona not found, or you don\'t own it. ' +
|
|
887
|
+
'Confirm the persona_id (e.g. from list_personas).',
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
const json = (await response.json().catch(() => null));
|
|
891
|
+
const persona = json?.persona;
|
|
892
|
+
if (!persona?.id) {
|
|
893
|
+
throw new PersonaToolError('Persona update returned no persona; the operation may not have succeeded.');
|
|
894
|
+
}
|
|
895
|
+
const updatedFields = Object.keys(body);
|
|
896
|
+
return {
|
|
897
|
+
content: [
|
|
898
|
+
{
|
|
899
|
+
type: 'text',
|
|
900
|
+
text: `Updated persona "${persona.name ?? personaId}" (id: ${persona.id}) — ` +
|
|
901
|
+
`set ${updatedFields.join(', ')}. No credit charged (explicit edit).`,
|
|
902
|
+
},
|
|
903
|
+
],
|
|
904
|
+
_meta: { persona, updated: updatedFields },
|
|
905
|
+
};
|
|
906
|
+
}
|
|
505
907
|
//# sourceMappingURL=personas.js.map
|