@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.
@@ -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)
@@ -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
- personaId: z
486
+ persona_id: z
388
487
  .string()
389
488
  .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.'),
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. Valid values: ' +
398
- 'description, company, industry, companySize, location.'),
399
- 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
400
504
  .string()
401
- .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)`)
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 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).');
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 { personaId, refresh, additionalContext } = parsed.data;
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