@clien-ai/mcp 0.2.1 → 0.4.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.
@@ -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 `groundInEvidence: false` to skip retrieval. Grounding
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
- projectId: z
88
+ project_id: z
64
89
  .string()
65
90
  .uuid()
66
- .describe('UUID of the project this persona belongs to. Get it from `list_projects` ' +
67
- '(or `create_project`, whose `_meta.project.id` you can pass straight in). ' +
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
- companySize: z
120
+ company_size: z
93
121
  .string()
94
- .max(COMPANY_SIZE_MAX, `companySize too long (max ${COMPANY_SIZE_MAX} chars)`)
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; 404 means the caller does
126
- * not own the target project; 400 surfaces the server's validation detail;
127
- * everything else surfaces a generic HTTP error.
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 detail = truncate(text, 200);
207
+ let parsed = null;
142
208
  try {
143
- const parsed = JSON.parse(text);
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 { projectId, name, role, company, description, companySize, industry, location, ephemeral } = parsed.data;
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
- projectId: z
299
+ project_id: z
217
300
  .string()
218
301
  .uuid()
219
- .describe('UUID of the project this persona belongs to. Get it from `list_projects` ' +
220
- '(or `create_project`). Rejected with a 404 if you do not own the project.'),
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
- companySize: z
329
+ company_size: z
244
330
  .string()
245
- .max(COMPANY_SIZE_MAX, `companySize too long (max ${COMPANY_SIZE_MAX} chars)`)
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
- additionalContext: z
334
+ additional_context: z
249
335
  .string()
250
- .max(ADDITIONAL_CONTEXT_MAX, `additionalContext too long (max ${ADDITIONAL_CONTEXT_MAX} chars)`)
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
- groundInEvidence: z
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 projectId.');
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 { projectId, name, role, company, industry, companySize, additionalContext, ephemeral, groundInEvidence, } = parsed.data;
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)
@@ -364,12 +458,18 @@ export async function generatePersona(input, deps) {
364
458
  : sources.length > 0
365
459
  ? ` Grounded in ${sources.length} real source${sources.length === 1 ? '' : 's'} (user voice from forums + Stack Exchange).`
366
460
  : ' No matching real-world evidence was found, so it was generated from the brief.';
461
+ // FUL-243: render the GENERATED profile, not just a confirmation line. This
462
+ // tool's whole output is content the caller did not supply — the model wrote
463
+ // the background, company, industry, size and location — so a one-line receipt
464
+ // that points at `_meta.persona` means the agent cannot read what it just paid
465
+ // a credit to create. (`create_persona` / `update_persona` keep their
466
+ // confirmation lines: those only echo fields the agent itself passed in.)
367
467
  return {
368
468
  content: [
369
469
  {
370
470
  type: 'text',
371
- text: `Generated persona "${persona.name ?? name}" (${persona.role ?? role}, id: ${persona.id}) ` +
372
- `in project ${projectId} — debited 1 credit.${groundingNote}${insightsNote}`,
471
+ text: `Generated persona in project ${projectId} — debited 1 credit.` +
472
+ `${groundingNote}${insightsNote}\n\n${renderPersonaProfile(persona, persona.id)}`,
373
473
  },
374
474
  ],
375
475
  _meta: { persona, sources },
@@ -383,25 +483,40 @@ const ENRICH_PERSONA_FETCH_TIMEOUT_MS = 60_000;
383
483
  // app/lib/personas/enrich-with-credits.ts (the @clien-ai/mcp package is published
384
484
  // separately and cannot import from the Next.js app). The server re-validates.
385
485
  const ENRICHABLE_FIELDS = ['description', 'company', 'industry', 'companySize', 'location'];
486
+ /** FUL-233: legacy camelCase spellings `enrich_persona` still accepts. */
487
+ export const ENRICH_PERSONA_ALIASES = {
488
+ personaId: 'persona_id',
489
+ additionalContext: 'additional_context',
490
+ };
386
491
  export const EnrichPersonaInputSchema = z.object({
387
- personaId: z
492
+ persona_id: z
388
493
  .string()
389
494
  .uuid()
390
- .describe('UUID of the persona to enrich. Get it from a persona\'s `_meta.persona.id` ' +
391
- '(e.g. from `create_persona`) or `list_projects`. Rejected with a 404 if you do not own it.'),
495
+ .optional()
496
+ .describe('REQUIRED — pass `persona_id` (or its deprecated alias `personaId`); the call is rejected ' +
497
+ 'if neither is present. It is absent from this schema\'s `required` list only so an ' +
498
+ 'alias-only call stays valid. UUID of the persona to enrich. Get it from a ' +
499
+ '`list_personas` entry or a persona\'s `_meta.persona.id` (e.g. from `create_persona`). ' +
500
+ 'Rejected with a 404 if you do not own it.'),
392
501
  refresh: z
393
502
  .array(z.enum(ENRICHABLE_FIELDS))
394
503
  .optional()
395
504
  .describe('Optional list of already-populated fields to REGENERATE. By default enrich only fills ' +
396
505
  'fields that are currently blank and never overwrites existing content; pass e.g. ' +
397
- '["description", "industry"] to force those to be regenerated. Valid values: ' +
398
- 'description, company, industry, companySize, location.'),
399
- additionalContext: z
506
+ '["description", "industry"] to force those to be regenerated. These are persona ENTITY ' +
507
+ 'field names, not param names, so `companySize` keeps its camelCase spelling here. ' +
508
+ 'Valid values: description, company, industry, companySize, location.'),
509
+ additional_context: z
400
510
  .string()
401
- .max(ADDITIONAL_CONTEXT_MAX, `additionalContext too long (max ${ADDITIONAL_CONTEXT_MAX} chars)`)
511
+ .max(ADDITIONAL_CONTEXT_MAX, `additional_context too long (max ${ADDITIONAL_CONTEXT_MAX} chars)`)
402
512
  .optional()
403
513
  .describe('Optional free-form brief to steer the enrichment — the product/market being validated, ' +
404
514
  'constraints, or anything that should shape the filled-in background and details.'),
515
+ // --- Deprecated camelCase aliases (FUL-233) — rewritten before parse. ---
516
+ personaId: deprecatedAlias('persona_id', z.string().uuid()),
517
+ additionalContext: deprecatedAlias('additional_context', z
518
+ .string()
519
+ .max(ADDITIONAL_CONTEXT_MAX, `additionalContext too long (max ${ADDITIONAL_CONTEXT_MAX} chars)`)),
405
520
  });
406
521
  /**
407
522
  * Map a non-ok POST /api/personas/ai-enrich response to a PersonaToolError.
@@ -443,7 +558,7 @@ async function throwForEnrichResponse(response, deps) {
443
558
  }
444
559
  if (status === 404) {
445
560
  throw new PersonaToolError('Could not enrich persona — persona not found, or you don\'t own it. ' +
446
- 'Confirm the personaId (e.g. from create_persona\'s _meta.persona.id).');
561
+ 'Confirm the persona_id (e.g. from list_personas, or create_persona\'s _meta.persona.id).');
447
562
  }
448
563
  const detail = parsed?.error ? truncate(parsed.error, 200) : truncate(text, 200);
449
564
  if (status === 400) {
@@ -460,7 +575,8 @@ export async function enrichPersona(input, deps) {
460
575
  if (!parsed.success) {
461
576
  throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
462
577
  }
463
- const { personaId, refresh, additionalContext } = parsed.data;
578
+ const { persona_id: rawPersonaId, refresh, additional_context: additionalContext, } = parsed.data;
579
+ const personaId = requireId(rawPersonaId, 'persona_id', 'personaId');
464
580
  const ctx = makeCtx(deps);
465
581
  const body = { personaId };
466
582
  if (refresh !== undefined)
@@ -492,9 +608,14 @@ export async function enrichPersona(input, deps) {
492
608
  const enriched = Array.isArray(json?.enriched) ? json.enriched : [];
493
609
  const balance = typeof json?.balance === 'number' ? json.balance : null;
494
610
  const remaining = balance !== null ? ` ${balance} credit${balance === 1 ? '' : 's'} remaining.` : '';
611
+ // FUL-243: on a real enrichment, render the resulting profile — the filled
612
+ // fields are model-written content the agent has no other way to read (the
613
+ // `_meta.persona` it used to be pointed at is dropped by Claude Code). The
614
+ // no-op branch stays a one-liner: nothing changed, so there is nothing new
615
+ // to show.
495
616
  const text = enriched.length > 0
496
- ? `Enriched persona "${persona.name ?? personaId}" (id: ${persona.id}) — filled ` +
497
- `${enriched.join(', ')}. Debited 1 credit.${remaining}`
617
+ ? `Enriched persona (id: ${persona.id}) — filled ${enriched.join(', ')}. ` +
618
+ `Debited 1 credit.${remaining}\n\n${renderPersonaProfile(persona, personaId)}`
498
619
  : `Persona "${persona.name ?? personaId}" (id: ${persona.id}) was already fully populated — ` +
499
620
  'nothing enriched, no credit charged. Pass `refresh` to regenerate specific fields.';
500
621
  return {
@@ -502,4 +623,395 @@ export async function enrichPersona(input, deps) {
502
623
  _meta: { persona, enriched, balance },
503
624
  };
504
625
  }
626
+ // ---------------------------------------------------------------------------
627
+ // list_personas + get_persona (FUL-213 / §U2) — read-only entity discovery
628
+ // ---------------------------------------------------------------------------
629
+ //
630
+ // The keystone read surface: `list_personas` discovers persona IDs in a project
631
+ // (including personas created in the WEB app, previously unreachable from an
632
+ // agent), and `get_persona` fetches one entity for correction/interview. Both
633
+ // are read-only over the existing bearer endpoints — zero credit cost, zero side
634
+ // effects — mirroring `list_reports`/`get_report`.
635
+ //
636
+ // NOTE: this is the entity-discovery surface, NOT the grounding surface — with
637
+ // one exception. FUL-208: `toPersona` now maps the M5b identity columns, so both
638
+ // tools return FLAT `persona.seniority` / `persona.careerPath` strings when the
639
+ // run populated them (absent otherwise; read-only — see `update_persona`). Forum
640
+ // grounding (`sources[]`) and the report-level `identityProvenance` are still
641
+ // `report_data.personas[]` only, read via `get_report` — and the shape differs
642
+ // there: the same two leaf names are NESTED under `identityPriors`.
643
+ const LIST_LIMIT_MAX = 100;
644
+ const LIST_LIMIT_DEFAULT = 20;
645
+ export const ListPersonasInputSchema = z.object({
646
+ project_id: z
647
+ .string()
648
+ .uuid()
649
+ .describe('UUID of the project whose personas to list. Get it from `list_projects` ' +
650
+ '(or `create_project`). Rejected with a 404 if you do not own the project. ' +
651
+ 'Lists personas created via the web app too, so you can discover a persona ' +
652
+ 'id to pass to `get_persona`, `update_persona`, `enrich_persona`, or `interview_persona`.'),
653
+ limit: z
654
+ .number()
655
+ .int()
656
+ .min(1)
657
+ .max(LIST_LIMIT_MAX)
658
+ .optional()
659
+ .default(LIST_LIMIT_DEFAULT)
660
+ .describe(`Maximum number of personas to return (1-${LIST_LIMIT_MAX}, default ${LIST_LIMIT_DEFAULT}). ` +
661
+ 'Newest personas come first.'),
662
+ offset: z
663
+ .number()
664
+ .int()
665
+ .min(0)
666
+ .optional()
667
+ .default(0)
668
+ .describe('Number of personas to skip before returning results (default 0). ' +
669
+ 'Combine with `limit` to page through a large persona list; the total ' +
670
+ 'count is returned in `_meta.pagination.total`.'),
671
+ });
672
+ export const GetPersonaInputSchema = z.object({
673
+ persona_id: z
674
+ .string()
675
+ .uuid()
676
+ .describe('UUID of the persona to fetch. Get it from a `list_personas` entry (or a ' +
677
+ 'persona\'s `_meta.persona.id`). Rejected with a 404 if you do not own it. ' +
678
+ 'Returns the persona ENTITY (name, role, company, industry, etc.) for ' +
679
+ 'discovery/correction, including read-only `seniority` / `careerPath` when ' +
680
+ 'the persona has identity grounding. Forum sources and `identityProvenance` ' +
681
+ 'live in `report_data`, read them via `get_report`.'),
682
+ });
683
+ /**
684
+ * Render a persona's FULL profile into readable text (FUL-243).
685
+ *
686
+ * Before this, `get_persona` returned ONE line — name, role, company, id — and
687
+ * pointed at `_meta.persona` for everything else. `_meta` is an optional host
688
+ * channel that Claude Code drops, so an agent about to interview a persona could
689
+ * not read the persona: no background, no industry, no location, no lock state.
690
+ * The whole profile now lands in the text, which every host delivers.
691
+ *
692
+ * Written as compact labelled lines rather than a JSON dump: the reader is a
693
+ * model, and labelled prose costs fewer tokens than pretty-printed JSON while
694
+ * staying unambiguous. Empty fields are OMITTED rather than rendered as "none" —
695
+ * a blank `industry` is unknown, and printing "Industry: none" would assert
696
+ * something the API never said.
697
+ *
698
+ * ⚠️ `includeInInsights` is deliberately NOT rendered. It is a phantom field on
699
+ * `interface Persona`: a write-path input on the app (`!ephemeral` on POST) that
700
+ * no response ever maps back out, as the FUL-240 coupling test records. Rendering
701
+ * it would print an insights/ephemeral claim derived from a value that is always
702
+ * `undefined` — inventing a fact in agent-visible text, which is the exact bug
703
+ * class this change exists to fix. (FUL-242 removes the field outright.)
704
+ *
705
+ * The closing caveat mirrors `list_competitors`: it makes a reader UNDER-trust
706
+ * the record. Almost nothing here is evidence — with one exception since FUL-208.
707
+ * `seniority` / `careerPath` ARE identity grounding and are rendered above, but
708
+ * they are aggregate-calibrated rather than span-receipted, so the caveat names
709
+ * them explicitly as un-citable instead of claiming the record carries no
710
+ * grounding at all. Everything else still lives on `report_data`.
711
+ */
712
+ export function renderPersonaProfile(persona, fallbackId) {
713
+ const id = persona.id ?? fallbackId;
714
+ const role = persona.role ? `, ${persona.role}` : '';
715
+ const company = persona.company ? ` at ${persona.company}` : '';
716
+ const header = `Persona "${persona.name ?? fallbackId}"${role}${company}`;
717
+ const lines = [header, `id: ${id}`];
718
+ if (persona.projectId)
719
+ lines.push(`project_id: ${persona.projectId}`);
720
+ // Profile attributes on one line — these are what an interviewer needs to
721
+ // frame a question, and they read better grouped than as five stanzas.
722
+ const attributes = [
723
+ persona.industry && `Industry: ${persona.industry}`,
724
+ persona.companySize && `Company size: ${persona.companySize}`,
725
+ persona.location && `Location: ${persona.location}`,
726
+ ].filter(Boolean);
727
+ if (attributes.length > 0)
728
+ lines.push(attributes.join(' · '));
729
+ // Provenance and mutability. `isLocked` is rendered whenever the API states it
730
+ // (`=== true` / `=== false`, not truthiness) because "locked" and "unknown" are
731
+ // genuinely different answers to "can I edit this?" — and an agent that assumes
732
+ // editable will meet a 403 from `update_persona`.
733
+ const stateBits = [
734
+ persona.source && `Source: ${persona.source}`,
735
+ persona.enhancementStatus && `Enhancement: ${persona.enhancementStatus}`,
736
+ persona.isLocked === true
737
+ ? 'LOCKED — read-only (subscription limit); `update_persona` and `enrich_persona` will be refused'
738
+ : persona.isLocked === false
739
+ ? 'Editable'
740
+ : null,
741
+ persona.validationJobId && `From research job: ${persona.validationJobId}`,
742
+ ].filter(Boolean);
743
+ if (stateBits.length > 0)
744
+ lines.push(stateBits.join(' · '));
745
+ // FUL-208 identity priors, rendered as their OWN line rather than folded into
746
+ // the attributes above. They differ in KIND from `industry`/`location`: those
747
+ // are plain profile fields, whereas these are agent-written and calibrated
748
+ // against an aggregate people distribution. Mixing them in would flatten that
749
+ // distinction exactly where the closing caveat has to speak about it precisely.
750
+ // Rendered here — not left to `_meta` — because `_meta` is the channel Claude
751
+ // Code drops, which is the whole reason this renderer exists.
752
+ const identity = [
753
+ persona.seniority && `Seniority: ${persona.seniority}`,
754
+ persona.careerPath && `Career path: ${persona.careerPath}`,
755
+ ].filter(Boolean);
756
+ if (identity.length > 0)
757
+ lines.push(identity.join(' · '));
758
+ if (persona.description)
759
+ lines.push(`\nBackground:\n${persona.description}`);
760
+ // Receipts only when the response actually carried them. Their ABSENCE is not
761
+ // rendered as "no receipts": `GET /api/personas/{id}` is an entity read and is
762
+ // not the grounding surface, so a missing `sources` here means "not returned by
763
+ // this endpoint", never "this persona is ungrounded". The caveat below covers it.
764
+ //
765
+ // Read through `Array.isArray` rather than the declared type: `Persona` mirrors
766
+ // a backend shape we do not control, so a reshaped `sources` that is not an
767
+ // array would throw on `.map` and take the whole `get_persona` result with it.
768
+ // Everything else here is a string read that degrades to "omitted"; this is the
769
+ // one field that could crash, so it gets the same treatment as the digest's
770
+ // defensive accessors.
771
+ const sources = Array.isArray(persona.sources) ? persona.sources : [];
772
+ if (sources.length > 0) {
773
+ const receipts = sources.map((source, i) => {
774
+ const platform = source?.platform ?? 'unknown platform';
775
+ const url = source?.url ?? '(no url)';
776
+ return ` ${i + 1}. ${platform} — ${url}`;
777
+ });
778
+ lines.push(`\nSource receipts carried on this record (${sources.length}):\n${receipts.join('\n')}`);
779
+ }
780
+ lines.push('\nTRUST: this is the persona ENTITY. Seniority and career path are the ONE piece of ' +
781
+ 'grounding it carries, and they are aggregate-calibrated, never span-receipted — there is ' +
782
+ 'no source to follow for them, so do not cite them as evidence. Forum receipts ' +
783
+ '(`sources[]`) and `identityProvenance` live on `report_data.personas[]` — read them with ' +
784
+ '`get_report`, which nests seniority and career path under `identityPriors`. Treat every ' +
785
+ 'other field above as unverified profile data until checked there; an absent field here ' +
786
+ 'means "not returned", not "empty".');
787
+ return lines.join('\n');
788
+ }
789
+ export async function listPersonas(input, deps) {
790
+ const parsed = ListPersonasInputSchema.safeParse(input);
791
+ if (!parsed.success) {
792
+ throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
793
+ }
794
+ const { project_id: projectId, limit, offset } = parsed.data;
795
+ const ctx = makeCtx(deps);
796
+ const query = new URLSearchParams({
797
+ projectId,
798
+ limit: String(limit),
799
+ offset: String(offset),
800
+ });
801
+ let response;
802
+ try {
803
+ response = await apiCall(ctx, 'GET', `/api/personas?${query.toString()}`, {
804
+ timeoutMs: PERSONAS_FETCH_TIMEOUT_MS,
805
+ });
806
+ }
807
+ catch (err) {
808
+ const e = err;
809
+ if (e.name === 'AbortError' || e.name === 'TimeoutError') {
810
+ throw new PersonaToolError(`Listing personas timed out after ${PERSONAS_FETCH_TIMEOUT_MS / 1000}s. Try again in a moment.`);
811
+ }
812
+ throw err;
813
+ }
814
+ if (!response.ok)
815
+ await throwForResponse(response, deps, 'list personas');
816
+ const json = (await response.json().catch(() => null));
817
+ const personas = json?.personas ?? [];
818
+ const total = json?.pagination?.total ?? personas.length;
819
+ let text;
820
+ if (personas.length === 0) {
821
+ text =
822
+ offset > 0
823
+ ? `No personas at offset ${offset} in project ${projectId} (total: ${total}).`
824
+ : `No personas in project ${projectId}. Use create_persona or generate_persona to add one.`;
825
+ }
826
+ else {
827
+ const lines = personas.map((p) => {
828
+ const role = p.role ? ` (${p.role})` : '';
829
+ return `- ${p.name ?? '(unnamed)'}${role} — id: ${p.id ?? 'unknown'}`;
830
+ });
831
+ const shown = offset + personas.length;
832
+ const more = shown < total ? `\n\n${total - shown} more not shown — page with offset=${shown}.` : '';
833
+ text =
834
+ `Found ${total} persona${total === 1 ? '' : 's'} in project ${projectId}:\n${lines.join('\n')}${more}\n\n` +
835
+ 'Use get_persona with an id to read one, update_persona to correct it, or interview_persona to interview it.';
836
+ }
837
+ return {
838
+ content: [{ type: 'text', text }],
839
+ _meta: { personas, pagination: json?.pagination ?? { limit, offset, total } },
840
+ };
841
+ }
842
+ export async function getPersona(input, deps) {
843
+ const parsed = GetPersonaInputSchema.safeParse(input);
844
+ if (!parsed.success) {
845
+ throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
846
+ }
847
+ const { persona_id: personaId } = parsed.data;
848
+ const ctx = makeCtx(deps);
849
+ let response;
850
+ try {
851
+ response = await apiCall(ctx, 'GET', `/api/personas/${personaId}`, {
852
+ timeoutMs: PERSONAS_FETCH_TIMEOUT_MS,
853
+ });
854
+ }
855
+ catch (err) {
856
+ const e = err;
857
+ if (e.name === 'AbortError' || e.name === 'TimeoutError') {
858
+ throw new PersonaToolError(`Fetching the persona timed out after ${PERSONAS_FETCH_TIMEOUT_MS / 1000}s. Try again in a moment.`);
859
+ }
860
+ throw err;
861
+ }
862
+ if (!response.ok) {
863
+ await throwForResponse(response, deps, 'get persona', {
864
+ notFound: 'Could not get persona — persona not found, or you don\'t own it. ' +
865
+ 'Confirm the persona_id (e.g. from list_personas).',
866
+ });
867
+ }
868
+ const json = (await response.json().catch(() => null));
869
+ const persona = json?.persona;
870
+ if (!persona?.id) {
871
+ throw new PersonaToolError('Persona fetch returned no persona; the operation may not have succeeded.');
872
+ }
873
+ return {
874
+ content: [{ type: 'text', text: renderPersonaProfile(persona, personaId) }],
875
+ _meta: { persona },
876
+ };
877
+ }
878
+ // ---------------------------------------------------------------------------
879
+ // update_persona (FUL-210 / §U4) — correct explicit persona fields
880
+ // ---------------------------------------------------------------------------
881
+ //
882
+ // Wraps the shipped `PATCH /api/personas/[personaId]` (explicit patch, bearer,
883
+ // FREE/unmetered) — NOT `ai-enrich` (which is AI-filled + debits a credit). This
884
+ // completes the read → correct → interview loop for a web-created persona without
885
+ // leaving for the web UI. Identity priors (seniority/careerPath) are M5c-managed
886
+ // and NOT patchable here.
887
+ //
888
+ // The "at least one field required" rule is enforced in the handler (not via a
889
+ // Zod `.refine()`) so the schema stays a plain `ZodObject` — the registry type
890
+ // and the schema-contract test both assume `.shape`, and a `.refine()` would
891
+ // yield a `ZodEffects` (mirrors the convention documented in interview.ts).
892
+ /**
893
+ * The scalar fields `update_persona` can patch: snake_case PARAM name → the
894
+ * camelCase field name the REST API body and the persona entity both use
895
+ * (mirrors `UpdatePersonaSchema`). Only `company_size` diverges; the rest are
896
+ * single-word and identical on both sides.
897
+ */
898
+ const UPDATABLE_FIELDS = {
899
+ name: 'name',
900
+ role: 'role',
901
+ company: 'company',
902
+ description: 'description',
903
+ company_size: 'companySize',
904
+ industry: 'industry',
905
+ location: 'location',
906
+ };
907
+ /** Param names as the caller supplies them — used in caller-facing messages. */
908
+ const UPDATABLE_PARAMS = Object.keys(UPDATABLE_FIELDS);
909
+ /** FUL-233: legacy camelCase spellings `update_persona` still accepts. */
910
+ export const UPDATE_PERSONA_ALIASES = { companySize: 'company_size' };
911
+ export const UpdatePersonaInputSchema = z.object({
912
+ persona_id: z
913
+ .string()
914
+ .uuid()
915
+ .describe('UUID of the persona to update. Get it from a `list_personas` entry (or a ' +
916
+ 'persona\'s `_meta.persona.id`). Rejected with a 404 if you do not own it.'),
917
+ name: z
918
+ .string()
919
+ .trim()
920
+ .min(1, 'name must be non-empty')
921
+ .max(NAME_MAX, `name too long (max ${NAME_MAX} chars)`)
922
+ .optional()
923
+ .describe('New name for the persona.'),
924
+ role: z
925
+ .string()
926
+ .trim()
927
+ .min(1, 'role must be non-empty')
928
+ .max(ROLE_MAX, `role too long (max ${ROLE_MAX} chars)`)
929
+ .optional()
930
+ .describe('New job title / role (e.g. fix a wrong role).'),
931
+ company: z
932
+ .string()
933
+ .max(COMPANY_MAX, `company too long (max ${COMPANY_MAX} chars)`)
934
+ .optional()
935
+ .describe('New company or organisation.'),
936
+ description: z
937
+ .string()
938
+ .max(DESCRIPTION_MAX, `description too long (max ${DESCRIPTION_MAX} chars)`)
939
+ .optional()
940
+ .describe('New background/bio (goals, pain points, context).'),
941
+ company_size: z
942
+ .string()
943
+ .max(COMPANY_SIZE_MAX, `company_size too long (max ${COMPANY_SIZE_MAX} chars)`)
944
+ .optional()
945
+ .describe('New company size (e.g. "11-50", "Enterprise").'),
946
+ industry: z
947
+ .string()
948
+ .max(INDUSTRY_MAX, `industry too long (max ${INDUSTRY_MAX} chars)`)
949
+ .optional()
950
+ .describe('New industry (e.g. "Fintech", "Healthcare").'),
951
+ location: z
952
+ .string()
953
+ .max(LOCATION_MAX, `location too long (max ${LOCATION_MAX} chars)`)
954
+ .optional()
955
+ .describe('New location (e.g. "Berlin, Germany").'),
956
+ // --- Deprecated camelCase aliases (FUL-233) — rewritten before parse. ---
957
+ companySize: deprecatedAlias('company_size', z.string().max(COMPANY_SIZE_MAX, `companySize too long (max ${COMPANY_SIZE_MAX} chars)`)),
958
+ });
959
+ export async function updatePersona(input, deps) {
960
+ const parsed = UpdatePersonaInputSchema.safeParse(input);
961
+ if (!parsed.success) {
962
+ throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
963
+ }
964
+ const { persona_id: personaId, ...rest } = parsed.data;
965
+ // "At least one field" is enforced here, not via `.refine()` (see the header
966
+ // comment): build the patch body from only the fields the caller supplied,
967
+ // translating each snake_case param to the API's camelCase body field.
968
+ const body = {};
969
+ for (const param of UPDATABLE_PARAMS) {
970
+ const value = rest[param];
971
+ if (value !== undefined)
972
+ body[UPDATABLE_FIELDS[param]] = value;
973
+ }
974
+ if (Object.keys(body).length === 0) {
975
+ throw new PersonaToolError('Invalid input — provide at least one field to update ' +
976
+ `(one of: ${UPDATABLE_PARAMS.join(', ')}).`);
977
+ }
978
+ const ctx = makeCtx(deps);
979
+ let response;
980
+ try {
981
+ response = await apiCall(ctx, 'PATCH', `/api/personas/${personaId}`, {
982
+ body,
983
+ timeoutMs: PERSONAS_FETCH_TIMEOUT_MS,
984
+ });
985
+ }
986
+ catch (err) {
987
+ const e = err;
988
+ if (e.name === 'AbortError' || e.name === 'TimeoutError') {
989
+ throw new PersonaToolError(`Updating the persona timed out after ${PERSONAS_FETCH_TIMEOUT_MS / 1000}s. ` +
990
+ 'It may or may not have been applied — call get_persona to check before retrying.');
991
+ }
992
+ throw err;
993
+ }
994
+ if (!response.ok) {
995
+ await throwForResponse(response, deps, 'update persona', {
996
+ notFound: 'Could not update persona — persona not found, or you don\'t own it. ' +
997
+ 'Confirm the persona_id (e.g. from list_personas).',
998
+ });
999
+ }
1000
+ const json = (await response.json().catch(() => null));
1001
+ const persona = json?.persona;
1002
+ if (!persona?.id) {
1003
+ throw new PersonaToolError('Persona update returned no persona; the operation may not have succeeded.');
1004
+ }
1005
+ const updatedFields = Object.keys(body);
1006
+ return {
1007
+ content: [
1008
+ {
1009
+ type: 'text',
1010
+ text: `Updated persona "${persona.name ?? personaId}" (id: ${persona.id}) — ` +
1011
+ `set ${updatedFields.join(', ')}. No credit charged (explicit edit).`,
1012
+ },
1013
+ ],
1014
+ _meta: { persona, updated: updatedFields },
1015
+ };
1016
+ }
505
1017
  //# sourceMappingURL=personas.js.map