@clien-ai/mcp 0.2.0 → 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.
@@ -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,8 +37,21 @@
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).
42
+ * FUL-101 (M3a): the generated persona also carries a decision endowment
43
+ * (current tool, typical spend, switching cost, who-pays-vs-who-uses) folded
44
+ * into its background — the traits that predict a switch decision — so a later
45
+ * `interview_persona` reasons from the persona's real status quo.
46
+ * FUL-102 (M3b): it also carries a credibility domain ("credible on X — not
47
+ * beyond") folded into the background, an honest bound on what the persona can
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.
29
55
  * - `enrich_persona` (FUL-77) — AI-fills the blank (or explicitly refreshed)
30
56
  * scalar fields of an EXISTING persona over `POST /api/personas/ai-enrich`.
31
57
  * Debits one `enrich_persona` credit, refunded if the enrichment fails; a
@@ -38,6 +64,7 @@
38
64
  */
39
65
  import { z } from 'zod';
40
66
  import { ToolError } from './errors.js';
67
+ import { deprecatedAlias } from './param-aliases.js';
41
68
  import { apiCall, categorizeFetchError, truncate, } from './research.js';
42
69
  const PERSONAS_FETCH_TIMEOUT_MS = 30_000;
43
70
  // Caps re-declared locally: the @clien-ai/mcp package is published separately
@@ -52,12 +79,20 @@ const DESCRIPTION_MAX = 5000;
52
79
  const COMPANY_SIZE_MAX = 100;
53
80
  const INDUSTRY_MAX = 100;
54
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
+ };
55
87
  export const CreatePersonaInputSchema = z.object({
56
- projectId: z
88
+ project_id: z
57
89
  .string()
58
90
  .uuid()
59
- .describe('UUID of the project this persona belongs to. Get it from `list_projects` ' +
60
- '(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). ' +
61
96
  'The persona is rejected with a 404 if you do not own the project.'),
62
97
  name: z
63
98
  .string()
@@ -82,9 +117,9 @@ export const CreatePersonaInputSchema = z.object({
82
117
  .optional()
83
118
  .describe('Optional background/bio for the persona: goals, pain points, context — ' +
84
119
  'whatever helps an interview feel grounded. Stored as the persona\'s background.'),
85
- companySize: z
120
+ company_size: z
86
121
  .string()
87
- .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)`)
88
123
  .optional()
89
124
  .describe('Optional company size (e.g. "11-50", "Enterprise").'),
90
125
  industry: z
@@ -104,6 +139,9 @@ export const CreatePersonaInputSchema = z.object({
104
139
  .describe('Set true for a scratch/throwaway persona that should NOT count toward the ' +
105
140
  'project\'s aggregate insights (sets include_in_insights=false). Leave false ' +
106
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)`)),
107
145
  });
108
146
  export class PersonaToolError extends ToolError {
109
147
  }
@@ -113,33 +151,77 @@ function formatZodError(error) {
113
151
  function makeCtx(deps) {
114
152
  return { config: deps.config, session: deps.session, fetch: deps.fetch ?? fetch };
115
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
+ }
116
188
  /**
117
189
  * Shared non-ok handling: 401 drops the cached session (so the next tool call
118
- * rebuilds from disk) and surfaces a re-auth message; 404 means the caller does
119
- * not own the target project; 400 surfaces the server's validation detail;
120
- * 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.
121
198
  */
122
- async function throwForResponse(response, deps, action) {
199
+ async function throwForResponse(response, deps, action, options) {
123
200
  const category = categorizeFetchError(response.status);
124
201
  if (category === 'auth') {
125
202
  if (response.refreshError?.kind !== 'transient')
126
203
  deps.invalidateSession?.();
127
204
  throw new PersonaToolError('Session expired. Re-run any Clien.ai tool to re-authenticate, then try again.');
128
205
  }
129
- if (response.status === 404) {
130
- throw new PersonaToolError(`Could not ${action} — project not found, or you don't own it. ` +
131
- 'Call list_projects to confirm the projectId.');
132
- }
133
206
  const text = await response.text().catch(() => '');
134
- let detail = truncate(text, 200);
207
+ let parsed = null;
135
208
  try {
136
- const parsed = JSON.parse(text);
137
- if (parsed?.error)
138
- detail = `${parsed.error}${parsed.code ? ` (${parsed.code})` : ''}`;
209
+ parsed = JSON.parse(text);
139
210
  }
140
211
  catch {
141
- // 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.`);
142
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})` : ''}`;
143
225
  if (response.status === 400) {
144
226
  throw new PersonaToolError(`Could not ${action} — ${detail}`);
145
227
  }
@@ -150,7 +232,8 @@ export async function createPersona(input, deps) {
150
232
  if (!parsed.success) {
151
233
  throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
152
234
  }
153
- 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');
154
237
  const ctx = makeCtx(deps);
155
238
  const body = { projectId, name, role, ephemeral };
156
239
  if (company !== undefined)
@@ -205,12 +288,22 @@ const GENERATE_PERSONA_FETCH_TIMEOUT_MS = 60_000;
205
288
  // Local cap, kept in sync with GeneratePersonaSchema in
206
289
  // app/api/personas/ai-generate/route.ts. The server re-validates regardless.
207
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
+ };
208
298
  export const GeneratePersonaInputSchema = z.object({
209
- projectId: z
299
+ project_id: z
210
300
  .string()
211
301
  .uuid()
212
- .describe('UUID of the project this persona belongs to. Get it from `list_projects` ' +
213
- '(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.'),
214
307
  name: z
215
308
  .string()
216
309
  .trim()
@@ -233,14 +326,14 @@ export const GeneratePersonaInputSchema = z.object({
233
326
  .max(INDUSTRY_MAX, `industry too long (max ${INDUSTRY_MAX} chars)`)
234
327
  .optional()
235
328
  .describe('Optional industry (e.g. "Fintech"). When omitted the AI infers one.'),
236
- companySize: z
329
+ company_size: z
237
330
  .string()
238
- .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)`)
239
332
  .optional()
240
333
  .describe('Optional company size (e.g. "11-50"). When omitted the AI infers one.'),
241
- additionalContext: z
334
+ additional_context: z
242
335
  .string()
243
- .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)`)
244
337
  .optional()
245
338
  .describe('Optional free-form brief to steer generation — goals, constraints, the product being ' +
246
339
  'validated, or anything that should shape the persona\'s background and pain points.'),
@@ -250,7 +343,7 @@ export const GeneratePersonaInputSchema = z.object({
250
343
  .default(false)
251
344
  .describe('Set true for a scratch/throwaway persona that should NOT count toward the project\'s ' +
252
345
  'aggregate insights (sets include_in_insights=false).'),
253
- groundInEvidence: z
346
+ ground_in_evidence: z
254
347
  .boolean()
255
348
  .optional()
256
349
  .default(true)
@@ -258,6 +351,13 @@ export const GeneratePersonaInputSchema = z.object({
258
351
  'from community forums + Stack Exchange for this role/industry, and returned with its ' +
259
352
  'source receipts. Set false to skip retrieval for a faster, invented-from-brief persona. ' +
260
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()),
261
361
  });
262
362
  /**
263
363
  * Map a non-ok POST /api/personas/ai-generate response to a PersonaToolError.
@@ -295,7 +395,7 @@ async function throwForGenerateResponse(response, deps) {
295
395
  }
296
396
  if (status === 404) {
297
397
  throw new PersonaToolError('Could not generate persona — project not found, or you don\'t own it. ' +
298
- 'Call list_projects to confirm the projectId.');
398
+ 'Call list_projects to confirm the project_id.');
299
399
  }
300
400
  const detail = parsed?.error ? truncate(parsed.error, 200) : truncate(text, 200);
301
401
  if (status === 400) {
@@ -312,7 +412,8 @@ export async function generatePersona(input, deps) {
312
412
  if (!parsed.success) {
313
413
  throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
314
414
  }
315
- 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');
316
417
  const ctx = makeCtx(deps);
317
418
  const body = { projectId, name, role, ephemeral, groundInEvidence };
318
419
  if (company !== undefined)
@@ -376,25 +477,40 @@ const ENRICH_PERSONA_FETCH_TIMEOUT_MS = 60_000;
376
477
  // app/lib/personas/enrich-with-credits.ts (the @clien-ai/mcp package is published
377
478
  // separately and cannot import from the Next.js app). The server re-validates.
378
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
+ };
379
485
  export const EnrichPersonaInputSchema = z.object({
380
- personaId: z
486
+ persona_id: z
381
487
  .string()
382
488
  .uuid()
383
- .describe('UUID of the persona to enrich. Get it from a persona\'s `_meta.persona.id` ' +
384
- '(e.g. from `create_persona`) or `list_projects`. Rejected with a 404 if you do not own it.'),
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.'),
385
495
  refresh: z
386
496
  .array(z.enum(ENRICHABLE_FIELDS))
387
497
  .optional()
388
498
  .describe('Optional list of already-populated fields to REGENERATE. By default enrich only fills ' +
389
499
  'fields that are currently blank and never overwrites existing content; pass e.g. ' +
390
- '["description", "industry"] to force those to be regenerated. Valid values: ' +
391
- 'description, company, industry, companySize, location.'),
392
- additionalContext: z
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
393
504
  .string()
394
- .max(ADDITIONAL_CONTEXT_MAX, `additionalContext too long (max ${ADDITIONAL_CONTEXT_MAX} chars)`)
505
+ .max(ADDITIONAL_CONTEXT_MAX, `additional_context too long (max ${ADDITIONAL_CONTEXT_MAX} chars)`)
395
506
  .optional()
396
507
  .describe('Optional free-form brief to steer the enrichment — the product/market being validated, ' +
397
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)`)),
398
514
  });
399
515
  /**
400
516
  * Map a non-ok POST /api/personas/ai-enrich response to a PersonaToolError.
@@ -436,7 +552,7 @@ async function throwForEnrichResponse(response, deps) {
436
552
  }
437
553
  if (status === 404) {
438
554
  throw new PersonaToolError('Could not enrich persona — persona not found, or you don\'t own it. ' +
439
- 'Confirm the personaId (e.g. from create_persona\'s _meta.persona.id).');
555
+ 'Confirm the persona_id (e.g. from list_personas, or create_persona\'s _meta.persona.id).');
440
556
  }
441
557
  const detail = parsed?.error ? truncate(parsed.error, 200) : truncate(text, 200);
442
558
  if (status === 400) {
@@ -453,7 +569,8 @@ export async function enrichPersona(input, deps) {
453
569
  if (!parsed.success) {
454
570
  throw new PersonaToolError(`Invalid input — ${formatZodError(parsed.error)}`);
455
571
  }
456
- const { personaId, refresh, additionalContext } = parsed.data;
572
+ const { persona_id: rawPersonaId, refresh, additional_context: additionalContext, } = parsed.data;
573
+ const personaId = requireId(rawPersonaId, 'persona_id', 'personaId');
457
574
  const ctx = makeCtx(deps);
458
575
  const body = { personaId };
459
576
  if (refresh !== undefined)
@@ -495,4 +612,296 @@ export async function enrichPersona(input, deps) {
495
612
  _meta: { persona, enriched, balance },
496
613
  };
497
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
+ }
498
907
  //# sourceMappingURL=personas.js.map