idiolect-mcp 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.mjs CHANGED
@@ -24,13 +24,54 @@
24
24
  // }
25
25
  // }
26
26
 
27
- import { readFileSync } from "node:fs";
27
+ import { readFileSync, realpathSync } from "node:fs";
28
+ import { fileURLToPath } from "node:url";
28
29
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
29
30
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
30
31
  import {
31
32
  CallToolRequestSchema,
32
33
  ListToolsRequestSchema,
33
34
  } from "@modelcontextprotocol/sdk/types.js";
35
+ import {
36
+ publicMcpState,
37
+ } from "../lib/mcp/public-contract.js";
38
+
39
+ const progressiveCatalog = JSON.parse(
40
+ readFileSync(new URL("./progressive-catalog.json", import.meta.url), "utf8"),
41
+ );
42
+ const PROGRESSIVE_ACCOUNT_TOOL_NAMES = new Set(
43
+ progressiveCatalog.tools.map((tool) => tool.name),
44
+ );
45
+ /** Cached tool names stay callable through the remote compatibility adapter. */
46
+ export const accountRuntimeCompatibilityToolNames = Object.freeze([
47
+ "feedback",
48
+ "refine",
49
+ "profile",
50
+ "manage_profile",
51
+ "learn_writing_style",
52
+ "get_my_voice",
53
+ "create_my_voice",
54
+ "draft_in_voice",
55
+ "rewrite_in_voice",
56
+ "score_voice",
57
+ "set_up_my_voice",
58
+ "record_voice_feedback",
59
+ "write_as_me",
60
+ "rewrite_as_me",
61
+ "list_writing_profiles",
62
+ "list_writing_profile_samples",
63
+ "add_samples_to_writing_profile",
64
+ "replace_samples_in_writing_profile",
65
+ "remove_sample_from_writing_profile",
66
+ "apply_writing_template",
67
+ "clear_default_writing_template",
68
+ "add_writing_preference",
69
+ "remove_writing_preference",
70
+ "remove_learned_writing_preference",
71
+ "list_style_contributions",
72
+ "remove_style_contribution",
73
+ ]);
74
+ const REMOTE_COMPATIBILITY_TOOL_NAMES = new Set(accountRuntimeCompatibilityToolNames);
34
75
 
35
76
  const BASE = process.env.IDIOLECT_BASE || "https://idiolect.app";
36
77
  const ACCOUNT_BASE = BASE.replace(/\/+$/, "");
@@ -75,27 +116,247 @@ async function scoreVoice(candidates, region) {
75
116
  return res.json();
76
117
  }
77
118
 
78
- async function readErrorBody(res) {
119
+ export function formatLegacyScoreVoiceResult(result) {
120
+ const scores = Array.isArray(result?.scores) ? result.scores : [];
121
+ const lines = scores
122
+ .map((score, index) => {
123
+ const text = typeof score?.text === "string" ? score.text : "";
124
+ const excerpt = `${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`;
125
+ const measured = score?.scored !== false && Number.isFinite(score?.voiceMatch);
126
+ const reportable = measured && score?.absoluteScoreReportable !== false;
127
+ const withheld = score?.scored === true && score?.absoluteScoreReportable === false;
128
+ const match = reportable
129
+ ? `Voice Match ${score.voiceMatch}/100`
130
+ : withheld
131
+ ? `Voice Match not calibrated${score?.reason ? ` — ${score.reason}` : ""}`
132
+ : `Voice Match unavailable${score?.reason ? ` — ${score.reason}` : ""}`;
133
+ return `#${index + 1} · ${match} — ${excerpt}`;
134
+ })
135
+ .join("\n");
136
+ const provenance = result?.metric?.validated === true
137
+ ? "Relative Voice Match on the validated instrument"
138
+ : result?.metric?.validated === false
139
+ ? "Relative Voice Match from a quick estimate"
140
+ : "Relative Voice Match; metric provenance unavailable";
141
+
142
+ return `${lines}\n\n(${provenance} — higher = reads more like you; not a forensic identity claim.)`;
143
+ }
144
+
145
+ const CREDIT_STATES = new Set([
146
+ "free_beta",
147
+ "balance_available",
148
+ "low_balance",
149
+ "zero_balance",
150
+ "legacy_unlimited",
151
+ "accounting_unavailable",
152
+ ]);
153
+ const CREDIT_MODES = new Set(["free", "credits"]);
154
+
155
+ function plainObject(value) {
156
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
157
+ }
158
+
159
+ function safeManageUrl(value) {
160
+ if (value === null) return null;
161
+ if (typeof value !== "string") return undefined;
79
162
  try {
80
- const contentType = res.headers.get("content-type") || "";
81
- const body = contentType.includes("application/json")
82
- ? JSON.stringify(await res.json())
83
- : await res.text();
84
- return body.trim().slice(0, 500);
163
+ const parsed = new URL(value);
164
+ const accountOrigin = new URL(ACCOUNT_BASE).origin;
165
+ const firstParty = parsed.hostname === "idiolect.app" || parsed.hostname.endsWith(".idiolect.app");
166
+ if (
167
+ !["https:", "http:"].includes(parsed.protocol)
168
+ || (parsed.origin !== accountOrigin && !firstParty)
169
+ || parsed.pathname !== "/settings"
170
+ || parsed.username
171
+ || parsed.password
172
+ || parsed.search !== "?section=credits"
173
+ || parsed.hash
174
+ ) return undefined;
175
+ return `${parsed.origin}/settings?section=credits`;
85
176
  } catch {
86
- return "";
177
+ return undefined;
178
+ }
179
+ }
180
+
181
+ function creditMessage(credits) {
182
+ const { state, balance, charged, manageUrl, mode } = credits;
183
+ if (state === "free_beta") {
184
+ return charged === 0
185
+ ? "Free during beta. No credits are being used."
186
+ : "Completed free during beta. No credit was used.";
187
+ }
188
+ if (state === "legacy_unlimited") return "Unlimited generation access is active. Credits are not used.";
189
+ if (state === "accounting_unavailable") {
190
+ return mode === "free"
191
+ ? "Free during beta. The credit balance is temporarily unavailable; generation remains available."
192
+ : "The credit balance is temporarily unavailable. Generation is paused; no credit was used.";
193
+ }
194
+ if (state === "zero_balance") {
195
+ return charged === 1
196
+ ? "This request completed with the last credit."
197
+ : `The credit balance is 0. A draft or rewrite needs 1 credit. Credit management: ${manageUrl}.`;
198
+ }
199
+ if (charged === 1) return `1 credit used. ${balance} ${balance === 1 ? "remains" : "remain"}.`;
200
+ if (state === "low_balance") return `${balance} credit${balance === 1 ? "" : "s"} remain. Credit management: ${manageUrl}.`;
201
+ return `${balance} credits available. Each draft or rewrite uses 1 credit.`;
202
+ }
203
+
204
+ function validCreditInvariant(credits) {
205
+ const {
206
+ state,
207
+ mode,
208
+ balance,
209
+ balancePreview,
210
+ unitCost,
211
+ charged,
212
+ unlimited,
213
+ accountingAvailable,
214
+ generationAvailable,
215
+ purchaseRequired,
216
+ manageUrl,
217
+ } = credits;
218
+ if (state === "free_beta") {
219
+ return mode === "free" && balance !== null && balancePreview && unitCost === 0 && charged === 0
220
+ && !unlimited && accountingAvailable && generationAvailable && !purchaseRequired && manageUrl === null;
221
+ }
222
+ if (state === "legacy_unlimited") {
223
+ return mode === "credits" && balance === null && !balancePreview && unitCost === 0 && charged === 0
224
+ && unlimited && accountingAvailable && generationAvailable && !purchaseRequired && manageUrl === null;
225
+ }
226
+ if (state === "accounting_unavailable") {
227
+ return balance === null && !balancePreview && charged === 0 && !unlimited && !accountingAvailable
228
+ && !purchaseRequired && manageUrl === null && unitCost === (mode === "free" ? 0 : 1)
229
+ && generationAvailable === (mode === "free");
230
+ }
231
+ if (mode !== "credits" || balance === null || balancePreview || unitCost !== 1 || unlimited || !accountingAvailable) {
232
+ return false;
233
+ }
234
+ if (state === "balance_available") {
235
+ return balance > 5 && generationAvailable && !purchaseRequired && manageUrl === null;
236
+ }
237
+ if (state === "low_balance") {
238
+ return balance >= 1 && balance <= 5 && generationAvailable && !purchaseRequired && manageUrl !== null;
87
239
  }
240
+ return state === "zero_balance" && balance === 0 && !generationAvailable && purchaseRequired && manageUrl !== null;
88
241
  }
89
242
 
90
- async function accountErrorMessage(res, toolName) {
91
- if (res.status === 401) {
92
- return "Idiolect AI API key is invalid or expired (401). Check IDIOLECT_API_KEY.";
243
+ /**
244
+ * Rebuild the public credit envelope from known primitives only. Never copy an
245
+ * upstream message or unknown field into an MCP response.
246
+ */
247
+ export function safeCreditPresentation(value) {
248
+ if (!plainObject(value)) return null;
249
+ const balance = value.balance;
250
+ const manageUrl = safeManageUrl(value.manageUrl);
251
+ if (
252
+ !CREDIT_STATES.has(value.state)
253
+ || !CREDIT_MODES.has(value.mode)
254
+ || !(balance === null || (Number.isSafeInteger(balance) && balance >= 0))
255
+ || typeof value.balancePreview !== "boolean"
256
+ || ![0, 1].includes(value.unitCost)
257
+ || ![0, 1].includes(value.charged)
258
+ || typeof value.unlimited !== "boolean"
259
+ || typeof value.accountingAvailable !== "boolean"
260
+ || typeof value.generationAvailable !== "boolean"
261
+ || typeof value.purchaseRequired !== "boolean"
262
+ || manageUrl === undefined
263
+ || typeof value.replayed !== "boolean"
264
+ ) return null;
265
+
266
+ const credits = {
267
+ state: value.state,
268
+ mode: value.mode,
269
+ balance,
270
+ balancePreview: value.balancePreview,
271
+ unitCost: value.unitCost,
272
+ charged: value.charged,
273
+ unlimited: value.unlimited,
274
+ accountingAvailable: value.accountingAvailable,
275
+ generationAvailable: value.generationAvailable,
276
+ purchaseRequired: value.purchaseRequired,
277
+ manageUrl,
278
+ replayed: value.replayed,
279
+ };
280
+ if (!validCreditInvariant(credits)) return null;
281
+ return { ...credits, message: creditMessage(credits) };
282
+ }
283
+
284
+ function safeCreditToolErrorPayload(value) {
285
+ if (!plainObject(value) || !plainObject(value.error) || !plainObject(value.retry)) return null;
286
+ const credits = safeCreditPresentation(value.credits);
287
+ if (!credits) return null;
288
+ const purchaseRequired = value.status === "purchase_required"
289
+ && value.error.code === "insufficient_credits"
290
+ && credits.state === "zero_balance"
291
+ && credits.mode === "credits"
292
+ && credits.balance === 0
293
+ && credits.charged === 0
294
+ && value.retry.after === "balance_updated";
295
+ const accountingUnavailable = value.status === "accounting_unavailable"
296
+ && value.error.code === "credit_accounting_unavailable"
297
+ && credits.state === "accounting_unavailable"
298
+ && credits.mode === "credits"
299
+ && credits.charged === 0
300
+ && value.retry.after === "accounting_available";
301
+ if (
302
+ (!purchaseRequired && !accountingUnavailable)
303
+ || value.error.retryable !== true
304
+ || value.retry.safe !== true
305
+ || value.retry.idempotent !== true
306
+ || value.retry.scope !== "pre_generation"
307
+ ) return null;
308
+
309
+ const message = purchaseRequired
310
+ ? `This request needs 1 credit; the balance is 0. Nothing was charged. Credit management: ${credits.manageUrl}. The same request remains safe to retry after the balance changes.`
311
+ : "The credit balance is temporarily unavailable. No generation started and no credit was used. The same request remains safe to retry after accounting recovers.";
312
+ return {
313
+ status: value.status,
314
+ message,
315
+ error: { code: value.error.code, retryable: true },
316
+ credits: { ...credits, message },
317
+ retry: {
318
+ safe: true,
319
+ idempotent: true,
320
+ scope: "pre_generation",
321
+ after: value.retry.after,
322
+ },
323
+ };
324
+ }
325
+
326
+ async function accountRequestError(res, toolName) {
327
+ if ((res.headers.get("content-type") || "").includes("application/json")) {
328
+ try {
329
+ const payload = await res.json();
330
+ const creditContent = safeCreditToolErrorPayload(payload);
331
+ if (creditContent) {
332
+ return new AccountRequestError(creditContent.message, res.status, creditContent);
333
+ }
334
+ const creationContent = toolName === "create_my_voice" ? safeCreationFailurePayload(payload) : null;
335
+ if (creationContent) {
336
+ return new AccountRequestError(creationContent.message, res.status, creationContent);
337
+ }
338
+ } catch {
339
+ // The bounded status fallback below deliberately ignores malformed bodies.
340
+ }
93
341
  }
94
- if (res.status === 402) {
95
- return `${toolName}: Idiolect AI Pro is required for this account API call (402).`;
342
+
343
+ const message = res.status === 401
344
+ ? "Idiolect AI API key is invalid or expired (401). Check IDIOLECT_API_KEY."
345
+ : res.status === 402
346
+ ? `${toolName}: generation access is unavailable for this Idiolect AI account (402).`
347
+ : res.status === 429
348
+ ? `${toolName}: Idiolect AI is temporarily busy (429). Try again shortly.`
349
+ : `${toolName}: Idiolect AI account API failed (${res.status}).`;
350
+ return new AccountRequestError(message, res.status);
351
+ }
352
+
353
+ class AccountRequestError extends Error {
354
+ constructor(message, status, structuredContent = null) {
355
+ super(message);
356
+ this.name = "AccountRequestError";
357
+ this.status = status;
358
+ this.structuredContent = structuredContent;
96
359
  }
97
- const body = await readErrorBody(res);
98
- return `${toolName}: Idiolect AI account API failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}`;
99
360
  }
100
361
 
101
362
  async function accountRequest(path, { method = "GET", body, toolName }) {
@@ -107,10 +368,26 @@ async function accountRequest(path, { method = "GET", body, toolName }) {
107
368
  },
108
369
  body: body ? JSON.stringify(body) : undefined,
109
370
  });
110
- if (!res.ok) throw new Error(await accountErrorMessage(res, toolName));
371
+ if (!res.ok) throw await accountRequestError(res, toolName);
111
372
  return res.json();
112
373
  }
113
374
 
375
+ async function accountMcpRequest(name, args) {
376
+ const res = await fetch(`${ACCOUNT_BASE}/api/mcp`, {
377
+ method: "POST",
378
+ headers: {
379
+ Authorization: `Bearer ${API_KEY}`,
380
+ accept: "application/json, text/event-stream",
381
+ "content-type": "application/json",
382
+ },
383
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
384
+ });
385
+ if (!res.ok) throw await accountRequestError(res, name);
386
+ const body = await res.json();
387
+ if (body.error || !body.result) throw new Error(`${name}: Idiolect AI returned an invalid MCP response.`);
388
+ return body.result;
389
+ }
390
+
114
391
  function formatValue(value) {
115
392
  if (value === undefined || value === null) return "";
116
393
  if (typeof value === "string") return value;
@@ -118,17 +395,18 @@ function formatValue(value) {
118
395
  return JSON.stringify(value, null, 2);
119
396
  }
120
397
 
121
- function formatAlternatives(alternatives) {
122
- if (!Array.isArray(alternatives) || alternatives.length === 0) return "";
123
- return `\n\nAlternatives:\n${alternatives
124
- .map((alternative, i) => `${i + 1}. ${formatValue(alternative)}`)
125
- .join("\n")}`;
126
- }
127
-
128
- function scoreSummary(r) {
398
+ export function scoreSummary(r) {
129
399
  const lines = [];
130
400
  if (r.voiceMatch !== undefined) {
131
- lines.push(r.voiceMatch === null ? "Voice Match: unavailable" : `Voice Match: ${formatValue(r.voiceMatch)}`);
401
+ const withheld = r.measured === true
402
+ && r.absoluteScoreReportable === false;
403
+ lines.push(
404
+ r.voiceMatch !== null && !withheld
405
+ ? `Voice Match: ${formatValue(r.voiceMatch)}`
406
+ : withheld
407
+ ? `Voice Match: not calibrated — ${r.reason || "insufficient evidence"}.`
408
+ : "Voice Match: unavailable",
409
+ );
132
410
  }
133
411
  if (r.delta !== undefined) lines.push(r.delta === null ? "Delta: unavailable" : `Delta: ${formatValue(r.delta)}`);
134
412
  if (r.candidateDelta !== undefined && r.candidateDelta !== r.delta) {
@@ -136,6 +414,10 @@ function scoreSummary(r) {
136
414
  }
137
415
  if (r.validated !== undefined) lines.push(`Validated: ${formatValue(r.validated)}`);
138
416
  if (r.measured !== undefined) lines.push(`Measured: ${formatValue(r.measured)}`);
417
+ if (r.scoreConfidence) lines.push(`Score confidence: ${formatValue(r.scoreConfidence)}`);
418
+ if (r.absoluteScoreReportable !== undefined) {
419
+ lines.push(`Absolute score reportable: ${formatValue(r.absoluteScoreReportable)}`);
420
+ }
139
421
  if (r.reason) lines.push(`Reason: ${formatValue(r.reason)}`);
140
422
  if (r.instrument) lines.push(`Instrument: ${formatValue(r.instrument)}`);
141
423
  if (r.register) lines.push(`Calibration register: ${formatValue(r.register)}`);
@@ -145,14 +427,110 @@ function scoreSummary(r) {
145
427
 
146
428
  // Mirrors REWRITE_REGISTERS in lib/connectors.ts. This file ships as a standalone npm
147
429
  // package with no build step, so it cannot import from the app — keep the two in sync.
148
- const REWRITE_REGISTERS = ["email", "slack", "essay"];
430
+ const REWRITE_REGISTERS = ["email", "slack", "social", "essay"];
149
431
  const REGISTER_SET = new Set(REWRITE_REGISTERS);
432
+ const CHANGE_SCOPES = ["light_edit", "rewrite", "transform"];
433
+ const CHANGE_SCOPE_SET = new Set([...CHANGE_SCOPES, "polish"]);
434
+ const EFFECT_KINDS = ["register", "tone", "humour", "candour", "energy", "voice", "other"];
435
+ const EFFECT_KIND_SET = new Set(EFFECT_KINDS);
436
+ const EFFECT_STRENGTHS = ["light", "full"];
437
+ const EFFECT_STRENGTH_SET = new Set(EFFECT_STRENGTHS);
438
+ const EFFECT_PROPERTY_SET = new Set(["kind", "ask", "strength"]);
439
+ // Who wrote the source or brief; mirrors lib/generation/change-request.ts's SOURCE_AUTHORS.
440
+ const SOURCE_AUTHORS = ["user", "host", "other", "unknown"];
441
+ const SOURCE_AUTHOR_SET = new Set(SOURCE_AUTHORS);
442
+
443
+ function backtickedValues(values) {
444
+ return values.map((value) => `\`${value}\``).join(", ");
445
+ }
446
+
447
+ const CHANGE_SCOPE_SCHEMA = {
448
+ type: "string",
449
+ enum: CHANGE_SCOPES,
450
+ description: "`changeScope` carries how much the person wants changed, from their own words.",
451
+ };
452
+ const EFFECTS_SCHEMA = {
453
+ type: "array",
454
+ maxItems: 20,
455
+ items: {
456
+ type: "object",
457
+ properties: {
458
+ kind: {
459
+ type: "string",
460
+ enum: EFFECT_KINDS,
461
+ description: "`kind` names the tone, register or effect the requester identified.",
462
+ },
463
+ ask: {
464
+ type: "string",
465
+ minLength: 1,
466
+ maxLength: 2000,
467
+ description: "`ask` preserves the requester's own words for this effect.",
468
+ },
469
+ strength: {
470
+ type: "string",
471
+ enum: EFFECT_STRENGTHS,
472
+ description: "`strength` records whether the requester asked for the effect to be `light` or `full`.",
473
+ },
474
+ },
475
+ required: ["kind", "ask"],
476
+ additionalProperties: false,
477
+ },
478
+ description: "`effects` carries each named tone, register or effect.",
479
+ };
480
+ const SOURCE_AUTHOR_SCHEMA = {
481
+ type: "string",
482
+ enum: SOURCE_AUTHORS,
483
+ description:
484
+ "Who wrote this text. `user` only when the person wrote it themselves; `host` when you (the assistant) drafted it; `other` for a third party's text. Leave it out if unsure.",
485
+ };
486
+ const BRIEF_AUTHOR_SCHEMA = {
487
+ type: "string",
488
+ enum: SOURCE_AUTHORS,
489
+ description:
490
+ "Who wrote the brief. `user` only when the person wrote it themselves; `host` when you (the assistant) drafted it; `other` for a third party's text. Leave it out if unsure.",
491
+ };
492
+
493
+ /** Validates `sourceAuthor`/`briefAuthor` and defaults an absent one to `"unknown"` on the
494
+ * returned copy of `args`. Returns a tool error, never throws. */
495
+ export function authorProvenanceError(args, toolName, key) {
496
+ if (args[key] === undefined) return null;
497
+ if (typeof args[key] !== "string" || !SOURCE_AUTHOR_SET.has(args[key])) {
498
+ return toolError(`${toolName}: \`${key}\` must be one of ${backtickedValues(SOURCE_AUTHORS)}.`);
499
+ }
500
+ return null;
501
+ }
502
+
503
+ export function changeRequestError(args, toolName) {
504
+ if (args.changeScope !== undefined
505
+ && (typeof args.changeScope !== "string" || !CHANGE_SCOPE_SET.has(args.changeScope))) {
506
+ return toolError(`${toolName}: \`changeScope\` must be one of ${backtickedValues(CHANGE_SCOPES)}.`);
507
+ }
508
+ if (args.effects === undefined) return null;
509
+ if (!Array.isArray(args.effects)) return toolError(`${toolName}: \`effects\` must be a list.`);
510
+ if (args.effects.length > 20) return toolError(`${toolName}: \`effects\` accepts at most 20 entries.`);
511
+ for (const effect of args.effects) {
512
+ if (!plainObject(effect) || Object.keys(effect).some((key) => !EFFECT_PROPERTY_SET.has(key))) {
513
+ return toolError(`${toolName}: each \`effects\` entry must contain only \`kind\`, \`ask\`, and \`strength\`.`);
514
+ }
515
+ if (typeof effect.kind !== "string" || !EFFECT_KIND_SET.has(effect.kind)) {
516
+ return toolError(`${toolName}: \`effects[].kind\` must be one of ${backtickedValues(EFFECT_KINDS)}.`);
517
+ }
518
+ if (typeof effect.ask !== "string" || effect.ask.length < 1 || effect.ask.length > 2000) {
519
+ return toolError(`${toolName}: \`effects[].ask\` must be 1 to 2,000 characters.`);
520
+ }
521
+ if (effect.strength !== undefined
522
+ && (typeof effect.strength !== "string" || !EFFECT_STRENGTH_SET.has(effect.strength))) {
523
+ return toolError(`${toolName}: \`effects[].strength\` must be one of ${backtickedValues(EFFECT_STRENGTHS)}.`);
524
+ }
525
+ }
526
+ return null;
527
+ }
150
528
 
151
529
  const legacyTools = [
152
530
  {
153
531
  name: "get_my_voice",
154
532
  description:
155
- "Load the user's Idiolect AI Voice Card. Call this BEFORE writing anything on the user's behalf (emails, posts, messages, docs) and then write strictly in the returned voice. Returns a style guide describing how the user actually writes.",
533
+ "Load the user's Idiolect AI Voice Card when they ask for personal outward-facing prose, such as an email, message, post, or reply, to match their established voice. Do not use it for ordinary factual writing or repository artifacts. Returns a style guide describing how the user actually writes.",
156
534
  inputSchema: {
157
535
  type: "object",
158
536
  properties: {
@@ -215,14 +593,95 @@ const PROVENANCE_OUTPUT_PROPERTIES = {
215
593
  validated: { type: "boolean", description: "True only when this result was measured by the validated LUAR instrument." },
216
594
  instrument: { type: ["string", "null"] },
217
595
  reason: { type: ["string", "null"] },
218
- register: { type: "string" },
219
- approximate: { type: "boolean" },
220
596
  voice: SELECTED_VOICE_SCHEMA,
221
597
  evidence: EVIDENCE_OUTPUT_SCHEMA,
222
598
  };
599
+ const CREDIT_OUTPUT_SCHEMA = {
600
+ type: "object",
601
+ additionalProperties: false,
602
+ properties: {
603
+ state: {
604
+ type: "string",
605
+ enum: ["free_beta", "balance_available", "low_balance", "zero_balance", "legacy_unlimited", "accounting_unavailable"],
606
+ },
607
+ mode: { type: "string", enum: ["free", "credits"] },
608
+ balance: { type: ["integer", "null"], minimum: 0 },
609
+ balancePreview: { type: "boolean" },
610
+ unitCost: { type: "number", enum: [0, 1] },
611
+ charged: { type: "number", enum: [0, 1] },
612
+ unlimited: { type: "boolean" },
613
+ accountingAvailable: { type: "boolean" },
614
+ generationAvailable: { type: "boolean" },
615
+ purchaseRequired: { type: "boolean" },
616
+ manageUrl: { type: ["string", "null"] },
617
+ replayed: { type: "boolean" },
618
+ message: { type: "string" },
619
+ },
620
+ required: [
621
+ "state",
622
+ "mode",
623
+ "balance",
624
+ "balancePreview",
625
+ "unitCost",
626
+ "charged",
627
+ "unlimited",
628
+ "accountingAvailable",
629
+ "generationAvailable",
630
+ "purchaseRequired",
631
+ "manageUrl",
632
+ "replayed",
633
+ "message",
634
+ ],
635
+ };
636
+ const CREDIT_ERROR_SCHEMA = {
637
+ type: "object",
638
+ additionalProperties: false,
639
+ properties: {
640
+ code: { type: "string", enum: ["insufficient_credits", "credit_accounting_unavailable"] },
641
+ retryable: { type: "boolean", enum: [true] },
642
+ },
643
+ required: ["code", "retryable"],
644
+ };
645
+ const CREDIT_RETRY_SCHEMA = {
646
+ type: "object",
647
+ additionalProperties: false,
648
+ properties: {
649
+ safe: { type: "boolean", enum: [true] },
650
+ idempotent: { type: "boolean", enum: [true] },
651
+ scope: { type: "string", enum: ["pre_generation"] },
652
+ after: { type: "string", enum: ["balance_updated", "accounting_available"] },
653
+ },
654
+ required: ["safe", "idempotent", "scope", "after"],
655
+ };
656
+ // Carried by a credit-gate refusal only; a completed draft or rewrite no longer reports
657
+ // credits (13 constants in free mode) or a status beside `state` (IDI-381).
658
+ const CREDIT_GENERATION_OUTPUT_PROPERTIES = {
659
+ status: { type: "string", enum: ["purchase_required", "accounting_unavailable"] },
660
+ message: { type: "string" },
661
+ credits: CREDIT_OUTPUT_SCHEMA,
662
+ error: CREDIT_ERROR_SCHEMA,
663
+ retry: CREDIT_RETRY_SCHEMA,
664
+ };
665
+ const NEXT_ACTION_SCHEMA = {
666
+ type: "object",
667
+ additionalProperties: false,
668
+ properties: {
669
+ action: { type: "string", enum: ["collect_evidence", "reload_voice", "continue_writing", "ask_feedback", "retry", "done"] },
670
+ tool: { type: ["string", "null"], enum: ["create_my_voice", "get_my_voice", "record_voice_feedback", null] },
671
+ },
672
+ required: ["action", "tool"],
673
+ };
674
+ const PUBLIC_STATE_PROPERTIES = {
675
+ state: { type: "string", enum: ["ready", "missing", "thin", "stale", "complete", "failed"] },
676
+ message: { type: "string" },
677
+ next: NEXT_ACTION_SCHEMA,
678
+ };
223
679
  const VOICE_OUTPUT_SCHEMA = {
224
680
  type: "object",
225
681
  properties: {
682
+ ...PUBLIC_STATE_PROPERTIES,
683
+ status: { type: "string", enum: ["ready", "setup_required"] },
684
+ message: { type: "string" },
226
685
  id: { type: "string" },
227
686
  name: { type: ["string", "null"] },
228
687
  isDefault: { type: "boolean" },
@@ -235,58 +694,330 @@ const VOICE_OUTPUT_SCHEMA = {
235
694
  taboos: { type: "array", items: { type: "string" } },
236
695
  positions: { type: "array", items: { type: "string" }, description: "Real takes the user has expressed; never invent new ones." },
237
696
  evidence: EVIDENCE_OUTPUT_SCHEMA,
238
- register: { type: "string" },
239
- approximate: { type: "boolean" },
240
697
  },
241
698
  };
242
699
  const SCORE_OUTPUT_SCHEMA = {
243
700
  type: "object",
244
701
  properties: {
702
+ ...PUBLIC_STATE_PROPERTIES,
245
703
  voiceMatch: { type: ["number", "null"], description: "0–100; higher = closer to the user's own writing. Relative, not a forensic identity claim." },
246
704
  measured: { type: "boolean", description: "False when the candidate or corpus is in the unscored band, or the scorer is unavailable." },
247
705
  ...PROVENANCE_OUTPUT_PROPERTIES,
706
+ register: { type: "string", description: "Calibration register the anchors come from; the absolute is approximate outside it." },
707
+ approximate: { type: "boolean" },
248
708
  },
249
709
  };
710
+ // A draft or rewrite is the writing, the profile that wrote it and its evidence state.
711
+ // The score cluster (voiceMatch, measured, deltas, candidate fields, crossFit, the
712
+ // calibration constants) was constant on every call and is gone from the account API
713
+ // and from here (IDI-381). Voice Match is measured on score_voice.
714
+ // IDI-522 step 2: present only for a soft language-policy pass through the profile-less
715
+ // shared engine — mirrors app/api/mcp/schemas.ts's `caveatsOutputShape` so this stdio
716
+ // schema stays in step with every other writing-tool surface.
717
+ const CAVEATS_OUTPUT_PROPERTY = { caveats: { type: "array", items: { type: "string", enum: ["language"] } } };
250
718
  const REWRITE_OUTPUT_SCHEMA = {
251
719
  type: "object",
252
720
  properties: {
253
- rewritten: { type: "string", description: "The recommended text. When the cross-fit-selected candidate regresses on reporting evidence, this is the original." },
254
- voiceMatch: { type: ["number", "null"] },
255
- delta: { type: ["number", "null"], description: "Before→after Voice Match change." },
256
- measured: { type: "boolean" },
257
- crossFit: { type: "boolean" },
258
- candidateText: { type: "string", description: "Best generated alternative, retained even when the original is recommended." },
259
- candidateVoiceMatch: { type: ["number", "null"] },
260
- candidateDelta: { type: ["number", "null"] },
261
- improved: { type: ["boolean", "null"] },
262
- regressed: { type: ["boolean", "null"] },
263
- ...PROVENANCE_OUTPUT_PROPERTIES,
721
+ ...PUBLIC_STATE_PROPERTIES,
722
+ rewritten: { type: "string", description: "The rewritten text." },
723
+ reason: { type: ["string", "null"], description: "Present only when scoring is enabled and the engine did not measure; says why." },
724
+ voice: SELECTED_VOICE_SCHEMA,
725
+ evidence: EVIDENCE_OUTPUT_SCHEMA,
726
+ ...CREDIT_GENERATION_OUTPUT_PROPERTIES,
727
+ ...CAVEATS_OUTPUT_PROPERTY,
264
728
  },
265
729
  };
266
730
  const DRAFT_OUTPUT_SCHEMA = {
267
731
  type: "object",
268
732
  properties: {
733
+ ...PUBLIC_STATE_PROPERTIES,
269
734
  draft: { type: "string", description: "New prose drafted in the user's voice." },
270
- voiceMatch: { type: ["number", "null"] },
271
- measured: { type: "boolean" },
272
- crossFit: { type: "boolean" },
273
- ...PROVENANCE_OUTPUT_PROPERTIES,
735
+ reason: { type: ["string", "null"], description: "Present only when scoring is enabled and the engine did not measure; says why." },
736
+ voice: SELECTED_VOICE_SCHEMA,
737
+ evidence: EVIDENCE_OUTPUT_SCHEMA,
738
+ ...CREDIT_GENERATION_OUTPUT_PROPERTIES,
739
+ ...CAVEATS_OUTPUT_PROPERTY,
740
+ },
741
+ };
742
+ const CREATE_VOICE_OUTPUT_SCHEMA = {
743
+ type: "object",
744
+ properties: {
745
+ ...PUBLIC_STATE_PROPERTIES,
746
+ status: { type: "string", enum: ["ready", "more_evidence_required", "build_in_progress", "failed"] },
747
+ message: { type: "string" },
748
+ reason: { type: ["string", "null"] },
749
+ voiceId: { type: ["string", "null"] },
750
+ neededChars: { type: "number", description: "Present only when more writing is needed." },
751
+ neededBlocks: { type: "number", description: "Present only when more writing is needed." },
752
+ replay: { type: "boolean" },
753
+ },
754
+ };
755
+ const FEEDBACK_OUTPUT_SCHEMA = {
756
+ type: "object",
757
+ properties: {
758
+ ...PUBLIC_STATE_PROPERTIES,
759
+ needsFinalText: { type: "boolean" },
760
+ },
761
+ };
762
+ const PROFILE_REF_OPTIONAL_SCHEMA = {
763
+ type: "string", minLength: 1,
764
+ description: "The Writing Profile's id, or its exact name. Omit to use the current profile.",
765
+ };
766
+ const PROFILE_REF_REQUIRED_SCHEMA = {
767
+ type: "string", minLength: 1,
768
+ description: "The Writing Profile's id, or its exact name.",
769
+ };
770
+ const PROFILE_ID_SCHEMA = { type: "string", format: "uuid" };
771
+ const PROFILE_SAMPLE_SCHEMA = {
772
+ type: "array",
773
+ minItems: 1,
774
+ maxItems: 500,
775
+ items: { type: "string", maxLength: 20000 },
776
+ };
777
+ const OPTIONAL_PROFILE_SAMPLE_SCHEMA = {
778
+ type: "array",
779
+ maxItems: 500,
780
+ items: { type: "string", maxLength: 20000 },
781
+ };
782
+ const PROFILE_SAMPLE_STATE_SCHEMA = {
783
+ type: "object",
784
+ properties: {
785
+ profileId: PROFILE_ID_SCHEMA,
786
+ generation: { type: "integer", minimum: 0 },
787
+ samples: {
788
+ type: "array",
789
+ items: {
790
+ type: "object",
791
+ properties: {
792
+ id: PROFILE_ID_SCHEMA,
793
+ text: { type: "string" },
794
+ addedAt: { type: "string" },
795
+ },
796
+ required: ["id", "text", "addedAt"],
797
+ additionalProperties: false,
798
+ },
799
+ },
800
+ needsWriting: { type: "boolean" },
801
+ heldNotUsable: { type: "integer", minimum: 0 },
802
+ },
803
+ required: ["profileId", "generation", "samples", "needsWriting", "heldNotUsable"],
804
+ additionalProperties: false,
805
+ };
806
+ /** What the caller must change to succeed; present only on evidence failures. */
807
+ const PROFILE_SHORTFALL_SCHEMA = {
808
+ type: "object",
809
+ properties: {
810
+ reason: { type: "string", enum: ["insufficient_input", "unsupported_language"] },
811
+ message: { type: "string" },
812
+ neededChars: { type: "integer", minimum: 0 },
813
+ neededBlocks: { type: "integer", minimum: 0 },
814
+ droppedBlocks: { type: "integer", minimum: 0 },
815
+ minimumChars: { type: "integer", minimum: 0 },
816
+ minimumBlocks: { type: "integer", minimum: 0 },
274
817
  },
818
+ required: ["message"],
819
+ additionalProperties: false,
275
820
  };
276
821
 
277
- const accountTools = [
822
+ function profileActionOutput(value, codes) {
823
+ return {
824
+ type: "object",
825
+ properties: {
826
+ ok: { type: "boolean" },
827
+ value,
828
+ error: {
829
+ type: "object",
830
+ properties: {
831
+ code: { type: "string", enum: [...codes, "unexpected_error"] },
832
+ detail: PROFILE_SHORTFALL_SCHEMA,
833
+ },
834
+ required: ["code"],
835
+ additionalProperties: false,
836
+ },
837
+ },
838
+ required: ["ok"],
839
+ additionalProperties: false,
840
+ };
841
+ }
842
+
843
+ const accountToolDefinitions = [
278
844
  {
279
845
  name: "get_my_voice",
280
846
  description:
281
- "Use this when you are about to write anything on the user's behalf — emails, posts, messages, commit messages, PR descriptions, docs. It loads the user's Idiolect AI voice so you can write strictly as them.",
847
+ "Use this when the user asks for personal outward-facing prose, such as an email, message, post, or reply, to match their established voice. Do not use it for ordinary factual writing or repository artifacts. Reports whether setup is complete, and what is still needed when it is not.",
282
848
  inputSchema: { type: "object", properties: {} },
283
849
  outputSchema: VOICE_OUTPUT_SCHEMA,
284
850
  annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: "Load Idiolect AI Voice" },
285
851
  },
852
+ {
853
+ name: "create_my_voice",
854
+ description:
855
+ "Compatibility setup action for creating a first Writing Profile, from writing samples the user authored or from short answers about how they write. Use create_writing_profile for explicit multi-profile creation.",
856
+ inputSchema: {
857
+ type: "object",
858
+ properties: {
859
+ profileId: {
860
+ ...PROFILE_ID_SCHEMA,
861
+ description:
862
+ "Exact owned Writing Profile to receive this evidence. Omit only when creating the account's first profile or adding to the current profile.",
863
+ },
864
+ samples: {
865
+ type: "array",
866
+ maxItems: 500,
867
+ items: { type: "string", maxLength: 20000 },
868
+ description: "Distinct blocks of writing supplied or explicitly approved by the user.",
869
+ },
870
+ answers: {
871
+ type: "array",
872
+ maxItems: 20,
873
+ items: {
874
+ type: "object",
875
+ properties: {
876
+ question: { type: "string", maxLength: 500 },
877
+ answer: { type: "string", maxLength: 5000 },
878
+ },
879
+ required: ["question", "answer"],
880
+ additionalProperties: false,
881
+ },
882
+ description: "Optional guided questions and the user's own answers.",
883
+ },
884
+ name: { type: "string", maxLength: 60, description: "Optional private name for the Writing Profile." },
885
+ },
886
+ additionalProperties: false,
887
+ },
888
+ outputSchema: CREATE_VOICE_OUTPUT_SCHEMA,
889
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: "Create My Writing Profile" },
890
+ },
891
+ {
892
+ name: "list_writing_profiles",
893
+ description: "List the authenticated user's Writing Profiles and identify the current profile.",
894
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
895
+ outputSchema: profileActionOutput({ type: "array" }, []),
896
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false, destructiveHint: false, title: "List Writing Profiles" },
897
+ },
898
+ {
899
+ name: "list_writing_profile_samples",
900
+ description: "List the writing samples and stable sample IDs held by the exact Writing Profile identified by profileId. This does not change the profile.",
901
+ inputSchema: {
902
+ type: "object",
903
+ properties: { profileId: PROFILE_REF_REQUIRED_SCHEMA },
904
+ required: ["profileId"],
905
+ additionalProperties: false,
906
+ },
907
+ outputSchema: profileActionOutput(PROFILE_SAMPLE_STATE_SCHEMA, ["profile_not_found"]),
908
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false, destructiveHint: false, title: "List Writing Profile Samples" },
909
+ },
910
+ {
911
+ name: "create_writing_profile",
912
+ description: "Create a new Writing Profile from approved writing. This never changes an existing profile.",
913
+ inputSchema: {
914
+ type: "object",
915
+ properties: {
916
+ name: { type: "string", maxLength: 60 },
917
+ samples: OPTIONAL_PROFILE_SAMPLE_SCHEMA,
918
+ answers: {
919
+ type: "array",
920
+ maxItems: 20,
921
+ items: {
922
+ type: "object",
923
+ properties: {
924
+ question: { type: "string", maxLength: 500 },
925
+ answer: { type: "string", maxLength: 5000 },
926
+ },
927
+ required: ["question", "answer"],
928
+ additionalProperties: false,
929
+ },
930
+ },
931
+ },
932
+ additionalProperties: false,
933
+ },
934
+ outputSchema: profileActionOutput({ type: "object" }, [
935
+ "insufficient_evidence", "rate_limited", "request_conflict", "build_in_progress",
936
+ ]),
937
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false, destructiveHint: false, title: "Create Writing Profile" },
938
+ },
939
+ {
940
+ name: "add_samples_to_writing_profile",
941
+ description: "Add approved writing samples to the exact Writing Profile identified by profileId.",
942
+ inputSchema: {
943
+ type: "object",
944
+ properties: { profileId: PROFILE_REF_OPTIONAL_SCHEMA, samples: PROFILE_SAMPLE_SCHEMA },
945
+ required: ["samples"],
946
+ additionalProperties: false,
947
+ },
948
+ outputSchema: profileActionOutput({ type: "object" }, [
949
+ "insufficient_evidence", "request_conflict", "build_in_progress", "profile_not_found",
950
+ ]),
951
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false, destructiveHint: false, title: "Add Samples to Writing Profile" },
952
+ },
953
+ {
954
+ name: "replace_samples_in_writing_profile",
955
+ description: "Replace the writing sample set for the exact Writing Profile identified by profileId. Displaced writing is revoked, not deleted. Refuses when the profile holds writing this tool cannot list.",
956
+ inputSchema: {
957
+ type: "object",
958
+ properties: { profileId: PROFILE_REF_REQUIRED_SCHEMA, samples: OPTIONAL_PROFILE_SAMPLE_SCHEMA },
959
+ required: ["profileId", "samples"],
960
+ additionalProperties: false,
961
+ },
962
+ outputSchema: profileActionOutput(PROFILE_SAMPLE_STATE_SCHEMA, ["profile_not_found"]),
963
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: true, title: "Replace Samples in Writing Profile" },
964
+ },
965
+ {
966
+ name: "remove_sample_from_writing_profile",
967
+ description: "Remove the exact writing sample identified by sampleId from the Writing Profile identified by profileId. The sample stops being used; it is revoked, not erased.",
968
+ inputSchema: {
969
+ type: "object",
970
+ properties: { profileId: PROFILE_REF_REQUIRED_SCHEMA, sampleId: PROFILE_ID_SCHEMA },
971
+ required: ["profileId", "sampleId"],
972
+ additionalProperties: false,
973
+ },
974
+ outputSchema: profileActionOutput(PROFILE_SAMPLE_STATE_SCHEMA, ["profile_not_found", "sample_not_found"]),
975
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: true, title: "Remove Sample from Writing Profile" },
976
+ },
977
+ {
978
+ name: "set_current_writing_profile",
979
+ description: "Make the exact Writing Profile identified by profileId current for future writing.",
980
+ inputSchema: {
981
+ type: "object",
982
+ properties: { profileId: PROFILE_REF_REQUIRED_SCHEMA },
983
+ required: ["profileId"],
984
+ additionalProperties: false,
985
+ },
986
+ outputSchema: profileActionOutput({ type: "object" }, ["profile_not_found"]),
987
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false, destructiveHint: false, title: "Set Current Writing Profile" },
988
+ },
989
+ {
990
+ name: "delete_writing_profile",
991
+ description: "Delete the exact Writing Profile identified by profileId. A current profile may require a replacementProfileId.",
992
+ inputSchema: {
993
+ type: "object",
994
+ properties: { profileId: PROFILE_REF_REQUIRED_SCHEMA, replacementProfileId: PROFILE_REF_REQUIRED_SCHEMA },
995
+ required: ["profileId"],
996
+ additionalProperties: false,
997
+ },
998
+ outputSchema: profileActionOutput({ type: "object" }, [
999
+ "profile_not_found", "replacement_required", "invalid_replacement",
1000
+ ]),
1001
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: true, title: "Delete Writing Profile" },
1002
+ },
1003
+ {
1004
+ name: "get_credit_status",
1005
+ description:
1006
+ "Check the account's current generation-credit state. During the free beta, this confirms that no credits are being used.",
1007
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1008
+ outputSchema: CREDIT_OUTPUT_SCHEMA,
1009
+ annotations: {
1010
+ readOnlyHint: true,
1011
+ idempotentHint: true,
1012
+ openWorldHint: false,
1013
+ destructiveHint: false,
1014
+ title: "Check Credit Status",
1015
+ },
1016
+ },
286
1017
  {
287
1018
  name: "score_voice",
288
1019
  description:
289
- "Use this when you want to check how close a piece of text is to the user's own writing. Returns a Voice Match (0–100, relative — higher means closer to the user's voice; not a forensic identity claim).",
1020
+ "Use this only when the user explicitly asks to measure or check how close a piece of text is to their own writing. Returns a relative Voice Match with visible provenance, or an honest reason measurement is unavailable; never a forensic identity claim.",
290
1021
  inputSchema: {
291
1022
  type: "object",
292
1023
  properties: { text: { type: "string", description: "Text to score against the user's voice." } },
@@ -298,7 +1029,7 @@ const accountTools = [
298
1029
  {
299
1030
  name: "rewrite_in_voice",
300
1031
  description:
301
- "Rewrite supplied prose in the user's voice. Returns an in-voice alternative with a Voice Match and before→after delta; use it only when it preserves the user's meaning and fits their request.",
1032
+ "Rewrite supplied prose in the user's voice. Returns an in-voice alternative with measurement details and a before→after delta when measured; absolute Voice Match stays withheld unless independent held-out evidence and the target register support it. Use it only when it preserves the user's meaning and fits their request.",
302
1033
  inputSchema: {
303
1034
  type: "object",
304
1035
  properties: {
@@ -311,6 +1042,17 @@ const accountTools = [
311
1042
  description:
312
1043
  "Optional target register. Adapts the output format while keeping the user's voice; omitted, the input's own format is kept.",
313
1044
  },
1045
+ // The ask, kept out of `text`. /api/v1/rewrite has accepted this all along;
1046
+ // only the tool schema was missing it.
1047
+ instructions: {
1048
+ type: "string",
1049
+ description:
1050
+ "The user's actual change request — length, tone, audience, what to cut or keep, make it sound less AI-written, roughen it up, or make it sound like I am talking. Kept separate from `text`: put the request here, never inside the prose to rewrite. `changeScope` carries magnitude; `instructions` carries the requested change.",
1051
+ },
1052
+ changeScope: CHANGE_SCOPE_SCHEMA,
1053
+ effects: EFFECTS_SCHEMA,
1054
+ sourceAuthor: SOURCE_AUTHOR_SCHEMA,
1055
+ profileId: { type: "string", format: "uuid", description: "Use this owned Writing Profile for this piece only. Select an exact profileId; this does not change the account default." },
314
1056
  },
315
1057
  required: ["text"],
316
1058
  },
@@ -320,31 +1062,495 @@ const accountTools = [
320
1062
  {
321
1063
  name: "draft_in_voice",
322
1064
  description:
323
- "Draft new prose in the user's voice from a supplied brief. Returns a draft with a Voice Match score for the user or agent to review.",
1065
+ "Draft new prose in the user's voice from a supplied `brief`, with the user's direction in the optional `instructions`. Returns a draft with measurement details for review; absolute Voice Match stays withheld unless independent held-out evidence and the target register support it.",
324
1066
  inputSchema: {
325
1067
  type: "object",
326
1068
  properties: {
327
- brief: { type: "string", description: "What to write: audience, goal, constraints, and source facts." },
1069
+ brief: {
1070
+ type: "string",
1071
+ description:
1072
+ "`brief` is the material: what the piece is about and what it must contain — its audience, its goal, and the source facts and specifics it has to carry. Never paste directions into `brief`; how to write it goes in `instructions`.",
1073
+ },
328
1074
  format: {
329
1075
  type: "string",
330
1076
  enum: ["linkedin", "x_post", "x_thread", "newsletter", "generic"],
331
- description: "Optional output format (default: generic).",
1077
+ description: "Optional output format (default: `generic`).",
332
1078
  },
1079
+ // The direction, kept out of `brief`. /api/v1/draft accepts it; this schema is the
1080
+ // stdio package's own copy because it cannot import lib/connectors.ts. The bounds
1081
+ // mirror GENERATION_INPUT_LIMITS.draftInstructions, including the nonempty minimum
1082
+ // the remote catalog advertises — an optional field that accepts "" is a third
1083
+ // state neither side means.
1084
+ instructions: {
1085
+ type: "string",
1086
+ minLength: 1,
1087
+ maxLength: 8000,
1088
+ description:
1089
+ "`instructions` is the user's actual direction: how to write the piece — tone, length, format, structure, and what to leave out. Optional; omit it when the user gave none. Never paste directions into `brief`, which carries the material, and never put material here. A named output shape goes in `format`.",
1090
+ },
1091
+ effects: EFFECTS_SCHEMA,
1092
+ briefAuthor: BRIEF_AUTHOR_SCHEMA,
1093
+ profileId: { type: "string", format: "uuid", description: "Use this owned Writing Profile for this piece only. Select an exact profileId; this does not change the account default." },
333
1094
  },
334
1095
  required: ["brief"],
335
1096
  },
336
1097
  outputSchema: DRAFT_OUTPUT_SCHEMA,
337
1098
  annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: "Draft In Voice" },
338
1099
  },
1100
+ {
1101
+ name: "learn_writing_style",
1102
+ description:
1103
+ "Teach Idiolect AI one lasting thing about how the user writes. Send authored writing only with the user's consent, preferences in their words, or observations grounded in their own writing. This stores learning and never writes or revises prose.",
1104
+ inputSchema: {
1105
+ type: "object",
1106
+ additionalProperties: false,
1107
+ properties: {
1108
+ kind: {
1109
+ type: "string",
1110
+ enum: ["authored_example", "preference", "observation"],
1111
+ description: "Whether this is the user's authored writing, a preference they stated, or an observation grounded in their writing.",
1112
+ },
1113
+ text: { type: "string", minLength: 1, maxLength: 12000 },
1114
+ consent: {
1115
+ type: "boolean",
1116
+ const: true,
1117
+ description: "Required only for authored_example: the user wrote it and agreed it may be stored.",
1118
+ },
1119
+ basis: { type: "string", minLength: 1, maxLength: 2000 },
1120
+ scope: { type: "string", enum: ["general", "occasion", "piece"] },
1121
+ occasion: { type: "string", minLength: 1, maxLength: 500 },
1122
+ profileId: { type: "string", format: "uuid" },
1123
+ writingJobVersionId: { type: "string", format: "uuid" },
1124
+ },
1125
+ required: ["kind", "text"],
1126
+ },
1127
+ outputSchema: {
1128
+ type: "object",
1129
+ additionalProperties: false,
1130
+ properties: {
1131
+ status: { type: "string", enum: ["saved", "pending", "ready", "failed"] },
1132
+ message: { type: "string" },
1133
+ next: {
1134
+ type: "object",
1135
+ additionalProperties: false,
1136
+ properties: {
1137
+ action: { type: "string", enum: ["done", "retry", "continue_writing"] },
1138
+ tool: { type: ["string", "null"], enum: ["refine", null] },
1139
+ },
1140
+ required: ["action", "tool"],
1141
+ },
1142
+ contribution: {
1143
+ type: "object",
1144
+ additionalProperties: false,
1145
+ properties: {
1146
+ id: { type: "string", format: "uuid" },
1147
+ kind: { type: "string", enum: ["authored_example", "preference", "observation"] },
1148
+ status: { type: "string", enum: ["pending", "waiting", "applied", "not_used", "piece_only"] },
1149
+ },
1150
+ required: ["id", "kind", "status"],
1151
+ },
1152
+ profileId: { type: "string", format: "uuid" },
1153
+ writingJobVersionId: { type: "string", format: "uuid" },
1154
+ neededWriting: {
1155
+ type: "object",
1156
+ additionalProperties: false,
1157
+ properties: {
1158
+ pieces: { type: "integer", minimum: 0 },
1159
+ characters: { type: "integer", minimum: 0 },
1160
+ },
1161
+ required: ["pieces", "characters"],
1162
+ },
1163
+ },
1164
+ required: ["status", "message", "next"],
1165
+ },
1166
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: "Learn Writing Style" },
1167
+ },
1168
+ {
1169
+ name: "record_voice_feedback",
1170
+ description:
1171
+ "Record bounded feedback for an Idiolect AI output. Send a verdict every time the user responds to one; a verdict on its own is worth having. Put what you observed in critique. Send correctionText only when you have the user's final text verbatim.",
1172
+ inputSchema: {
1173
+ type: "object",
1174
+ additionalProperties: false,
1175
+ properties: {
1176
+ outputId: { type: "string", format: "uuid" },
1177
+ // Hand-written mirror of the HTTP route's vocabulary — this file cannot import
1178
+ // `lib/feedback/outcomes.ts`. `superseded` means the person changed the brief or
1179
+ // direction rather than judging the text (IDI-446); keep it in step by hand.
1180
+ status: { type: "string", enum: ["accepted", "corrected_and_accepted", "rejected", "superseded"] },
1181
+ correctionText: {
1182
+ type: "string",
1183
+ minLength: 1,
1184
+ maxLength: 20000,
1185
+ description: "The user's final text, verbatim and complete. Never an excerpt, a diff, or your own description of what changed — if you do not have their exact words, omit this field and describe the change in critique instead.",
1186
+ },
1187
+ critique: {
1188
+ type: "string",
1189
+ minLength: 1,
1190
+ maxLength: 2000,
1191
+ description: "What you observed about how the output landed, in the user's words or your own: what was wrong, what they changed, or why they kept it. Free text, and the right home for any description of an edit you cannot quote verbatim.",
1192
+ },
1193
+ },
1194
+ required: ["outputId", "status"],
1195
+ },
1196
+ outputSchema: FEEDBACK_OUTPUT_SCHEMA,
1197
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: "Record Voice Feedback" },
1198
+ },
339
1199
  ];
340
1200
 
1201
+ export const accountTools = progressiveCatalog.tools;
1202
+ export const accountInstructions = progressiveCatalog.instructions;
1203
+ /** Hidden schemas retained for cached stdio callers and compatibility tests. */
1204
+ export const accountCompatibilityTools = accountToolDefinitions;
1205
+
1206
+ function toolError(message) {
1207
+ return { content: [{ type: "text", text: message }], isError: true };
1208
+ }
1209
+
1210
+ function accountToolError(error) {
1211
+ if (error instanceof AccountRequestError && error.structuredContent) {
1212
+ return {
1213
+ content: [{ type: "text", text: error.structuredContent.message }],
1214
+ structuredContent: error.structuredContent,
1215
+ isError: true,
1216
+ };
1217
+ }
1218
+ if (error instanceof AccountRequestError) return toolError(error.message);
1219
+ return toolError("Idiolect AI account API failed before a safe response was available.");
1220
+ }
1221
+
1222
+ function createVoiceBody(args) {
1223
+ const samples = args.samples ?? [];
1224
+ const answers = args.answers ?? [];
1225
+ if (!Array.isArray(samples) || samples.length > 500 || samples.some((sample) => typeof sample !== "string" || sample.length > 20000)) {
1226
+ return { error: "create_my_voice: `samples` must contain at most 500 text blocks of at most 20,000 characters each." };
1227
+ }
1228
+ if (
1229
+ !Array.isArray(answers)
1230
+ || answers.length > 20
1231
+ || answers.some((answer) => (
1232
+ !answer
1233
+ || typeof answer !== "object"
1234
+ || Array.isArray(answer)
1235
+ || typeof answer.question !== "string"
1236
+ || answer.question.length > 500
1237
+ || typeof answer.answer !== "string"
1238
+ || answer.answer.length > 5000
1239
+ ))
1240
+ ) {
1241
+ return { error: "create_my_voice: `answers` must contain at most 20 question/answer pairs within the documented limits." };
1242
+ }
1243
+ if (args.name !== undefined && (typeof args.name !== "string" || args.name.length > 60)) {
1244
+ return { error: "create_my_voice: `name` must be text of at most 60 characters." };
1245
+ }
1246
+ if (args.profileId !== undefined && typeof args.profileId !== "string") {
1247
+ return { error: "create_my_voice: `profileId` must be a Writing Profile UUID." };
1248
+ }
1249
+ return {
1250
+ body: {
1251
+ ...(args.profileId !== undefined ? { profileId: args.profileId } : {}),
1252
+ samples,
1253
+ answers: answers.map(({ question, answer }) => ({ question, answer })),
1254
+ ...(args.name !== undefined ? { name: args.name } : {}),
1255
+ },
1256
+ };
1257
+ }
1258
+
1259
+ function safeCreationResult(r) {
1260
+ const status = r.status ?? "more_evidence_required";
1261
+ const state = status === "ready" ? "complete" : status === "build_in_progress" ? "stale" : status === "failed" ? "failed" : "thin";
1262
+ const next = status === "ready"
1263
+ ? { action: "reload_voice", tool: "get_my_voice" }
1264
+ : status === "build_in_progress"
1265
+ ? { action: "retry", tool: "create_my_voice" }
1266
+ : status === "failed" ? { action: "done", tool: null } : { action: "collect_evidence", tool: "create_my_voice" };
1267
+ const neededChars = Number.isFinite(r.neededChars) ? r.neededChars : 0;
1268
+ const neededBlocks = Number.isFinite(r.neededBlocks) ? r.neededBlocks : 0;
1269
+ const message = state === "thin"
1270
+ ? `More of the user's own writing is needed: add ${neededBlocks} distinct block${neededBlocks === 1 ? "" : "s"} and ${neededChars} characters.`
1271
+ : r.message ?? (state === "complete" ? "Writing Profile ready." : "Writing Profile creation is still in progress.");
1272
+ return publicMcpState(state, message, next, {
1273
+ status,
1274
+ reason: r.reason ?? null,
1275
+ voiceId: r.voiceId ?? null,
1276
+ // The shortfall appears only when there is one; the fixed minimums are gone (IDI-381).
1277
+ ...(neededChars > 0 || neededBlocks > 0 ? { neededChars, neededBlocks } : {}),
1278
+ replay: r.replay === true,
1279
+ });
1280
+ }
1281
+
1282
+ /** Preserve a server-proven refinement conflict without copying unknown fields. */
1283
+ function safeCreationFailurePayload(value) {
1284
+ if (
1285
+ !plainObject(value)
1286
+ || value.status !== "failed"
1287
+ || typeof value.message !== "string"
1288
+ || !(value.reason === null || typeof value.reason === "string")
1289
+ || !(value.voiceId === null || typeof value.voiceId === "string")
1290
+ || typeof value.replay !== "boolean"
1291
+ ) return null;
1292
+ return safeCreationResult(value);
1293
+ }
1294
+
1295
+ const WRITING_PROFILE_ERROR_CODES = {
1296
+ list_writing_profiles: new Set(["unexpected_error"]),
1297
+ list_writing_profile_samples: new Set(["profile_not_found", "unexpected_error"]),
1298
+ create_writing_profile: new Set([
1299
+ "insufficient_evidence", "rate_limited", "request_conflict", "build_in_progress", "unexpected_error",
1300
+ ]),
1301
+ add_samples_to_writing_profile: new Set([
1302
+ "insufficient_evidence", "request_conflict", "build_in_progress", "profile_not_found", "unexpected_error",
1303
+ ]),
1304
+ replace_samples_in_writing_profile: new Set([
1305
+ "insufficient_evidence", "profile_not_found", "samples_not_fully_visible", "unexpected_error",
1306
+ ]),
1307
+ remove_sample_from_writing_profile: new Set([
1308
+ "profile_not_found", "sample_not_found", "unexpected_error",
1309
+ ]),
1310
+ set_current_writing_profile: new Set(["profile_not_found", "unexpected_error"]),
1311
+ delete_writing_profile: new Set([
1312
+ "profile_not_found", "replacement_required", "invalid_replacement", "unexpected_error",
1313
+ ]),
1314
+ };
1315
+
1316
+ function writingProfileRequest(name, args) {
1317
+ if (name === "list_writing_profiles") return { path: "/api/writing-profiles", method: "GET" };
1318
+ if (name === "create_writing_profile") {
1319
+ return {
1320
+ path: "/api/writing-profiles",
1321
+ method: "POST",
1322
+ body: {
1323
+ samples: args.samples ?? [],
1324
+ answers: args.answers ?? [],
1325
+ ...(args.name === undefined ? {} : { name: args.name }),
1326
+ },
1327
+ };
1328
+ }
1329
+ const id = encodeURIComponent(args.profileId ?? '');
1330
+ if (name === "list_writing_profile_samples") {
1331
+ return { path: `/api/writing-profiles/${id}/samples`, method: "GET" };
1332
+ }
1333
+ if (name === "add_samples_to_writing_profile") {
1334
+ // Absent means the current profile; the account API resolves it, this only forwards.
1335
+ const path = args.profileId === undefined
1336
+ ? "/api/writing-profiles/current/samples"
1337
+ : `/api/writing-profiles/${id}/samples`;
1338
+ return { path, method: "POST", body: { samples: args.samples } };
1339
+ }
1340
+ if (name === "replace_samples_in_writing_profile") {
1341
+ return { path: `/api/writing-profiles/${id}/samples`, method: "PUT", body: { samples: args.samples } };
1342
+ }
1343
+ if (name === "remove_sample_from_writing_profile") {
1344
+ const sampleId = encodeURIComponent(args.sampleId);
1345
+ return { path: `/api/writing-profiles/${id}/samples/${sampleId}`, method: "DELETE" };
1346
+ }
1347
+ if (name === "set_current_writing_profile") {
1348
+ return { path: `/api/writing-profiles/${id}/current`, method: "PUT" };
1349
+ }
1350
+ const replacement = args.replacementProfileId === undefined
1351
+ ? ""
1352
+ : `?replacementProfileId=${encodeURIComponent(args.replacementProfileId)}`;
1353
+ return { path: `/api/writing-profiles/${id}${replacement}`, method: "DELETE" };
1354
+ }
1355
+
1356
+ /** The shortfall is re-validated field by field: an unchecked passthrough would let the
1357
+ * account API widen this tool's declared output schema without the stdio server knowing. */
1358
+ function safeProfileShortfall(value) {
1359
+ if (!plainObject(value) || typeof value.message !== "string") return null;
1360
+ const detail = { message: value.message.slice(0, 2000) };
1361
+ if (value.reason === "insufficient_input" || value.reason === "unsupported_language") {
1362
+ detail.reason = value.reason;
1363
+ }
1364
+ // Counts travel only when the evidence gate produced them; a profile that could not be
1365
+ // found has no shortfall to count.
1366
+ for (const key of ["neededChars", "neededBlocks", "droppedBlocks", "minimumChars", "minimumBlocks"]) {
1367
+ if (Number.isInteger(value[key]) && value[key] >= 0) detail[key] = value[key];
1368
+ }
1369
+ return detail;
1370
+ }
1371
+
1372
+ function safeWritingProfileResult(name, value) {
1373
+ if (!plainObject(value) || typeof value.ok !== "boolean") return null;
1374
+ if (value.ok === true && Object.hasOwn(value, "value")) return { ok: true, value: value.value };
1375
+ const code = plainObject(value.error) ? value.error.code : null;
1376
+ if (typeof code !== "string" || !WRITING_PROFILE_ERROR_CODES[name]?.has(code)) return null;
1377
+ const detail = safeProfileShortfall(plainObject(value.error) ? value.error.detail : null);
1378
+ return { ok: false, error: detail ? { code, detail } : { code } };
1379
+ }
1380
+
1381
+ function writingProfileSuccessMessage(name, value, title) {
1382
+ if (name === "list_writing_profiles" && Array.isArray(value)) {
1383
+ if (value.length === 0) return "No Writing Profiles found.";
1384
+ return ["Writing Profiles:", ...value.map((profile) => {
1385
+ const label = typeof profile?.name === "string" && profile.name.length > 0
1386
+ ? profile.name
1387
+ : "Unnamed profile";
1388
+ const current = profile?.isCurrent === true ? " (current)" : "";
1389
+ return `- ${label}: ${String(profile?.id ?? "unknown")}${current}`;
1390
+ })].join("\n");
1391
+ }
1392
+ if (
1393
+ ["list_writing_profile_samples", "replace_samples_in_writing_profile", "remove_sample_from_writing_profile"].includes(name)
1394
+ && plainObject(value)
1395
+ && Array.isArray(value.samples)
1396
+ ) {
1397
+ const samples = value.samples.map((sample) => {
1398
+ const id = plainObject(sample) && typeof sample.id === "string" ? sample.id : "unknown";
1399
+ const writing = plainObject(sample) && typeof sample.text === "string" ? sample.text : "";
1400
+ return `- sampleId ${id}: ${JSON.stringify(writing)}`;
1401
+ });
1402
+ return [
1403
+ `${title} complete for profile ${String(value.profileId ?? "unknown")} (generation ${String(value.generation ?? "unknown")}).`,
1404
+ `Usable samples: ${value.samples.length}.`,
1405
+ ...(samples.length > 0 ? samples : ["- No usable writing samples remain."]),
1406
+ ...(Number.isInteger(value.heldNotUsable) && value.heldNotUsable > 0
1407
+ ? [`Held but not usable for rebuild: ${value.heldNotUsable}.`]
1408
+ : []),
1409
+ ].join("\n");
1410
+ }
1411
+ return `${title} complete.`;
1412
+ }
1413
+
1414
+ async function callWritingProfileApi(name, args) {
1415
+ const request = writingProfileRequest(name, args);
1416
+ const res = await fetch(`${ACCOUNT_BASE}${request.path}`, {
1417
+ method: request.method,
1418
+ headers: {
1419
+ Authorization: `Bearer ${API_KEY}`,
1420
+ ...(request.body ? { "content-type": "application/json" } : {}),
1421
+ },
1422
+ body: request.body ? JSON.stringify(request.body) : undefined,
1423
+ });
1424
+ let parsed = null;
1425
+ try {
1426
+ parsed = safeWritingProfileResult(name, await res.json());
1427
+ } catch {
1428
+ // The bounded failure below handles non-JSON and malformed responses.
1429
+ }
1430
+ if (!parsed) throw new AccountRequestError(`${name}: Idiolect AI account API failed (${res.status}).`, res.status);
1431
+ const title = accountToolDefinitions.find((tool) => tool.name === name)?.annotations.title ?? name;
1432
+ const building = name === "create_writing_profile"
1433
+ && parsed.ok === false
1434
+ && parsed.error.code === "build_in_progress";
1435
+ const message = parsed.ok
1436
+ ? writingProfileSuccessMessage(name, parsed.value, title)
1437
+ : building
1438
+ ? "Writing Profile accepted and building. Retry this same call shortly to get the completed profile."
1439
+ : parsed.error.detail?.message ?? `${title} failed: ${parsed.error.code}.`;
1440
+ return {
1441
+ content: [{ type: "text", text: message }],
1442
+ structuredContent: parsed,
1443
+ ...(!parsed.ok && !building ? { isError: true } : {}),
1444
+ };
1445
+ }
1446
+
1447
+ /** Import-safe account handler used by the stdio server and its parity tests. */
1448
+ export async function handleAccountTool(name, args = {}) {
1449
+ if (Object.hasOwn(WRITING_PROFILE_ERROR_CODES, name)) {
1450
+ try {
1451
+ return await callWritingProfileApi(name, args);
1452
+ } catch (error) {
1453
+ return accountToolError(error);
1454
+ }
1455
+ }
1456
+ if (name === "get_credit_status") {
1457
+ try {
1458
+ const credits = safeCreditPresentation(await accountRequest("/api/v1/credits", {
1459
+ toolName: "get_credit_status",
1460
+ }));
1461
+ if (!credits) return toolError("get_credit_status: Idiolect AI returned an invalid credit status.");
1462
+ return {
1463
+ content: [{ type: "text", text: credits.message }],
1464
+ structuredContent: credits,
1465
+ };
1466
+ } catch (error) {
1467
+ return accountToolError(error);
1468
+ }
1469
+ }
1470
+
1471
+ if (name === "get_my_voice") {
1472
+ try {
1473
+ const r = await accountRequest("/api/v1/voice", { toolName: "get_my_voice" });
1474
+ const card = r.card || formatValue(r.voice);
1475
+ return {
1476
+ content: [
1477
+ {
1478
+ type: "text",
1479
+ text: `${card}\n\n---\n(Idiolect AI account voice loaded. Write everything as this person from now on.)`,
1480
+ },
1481
+ ],
1482
+ structuredContent: publicMcpState("ready", "Writing Profile ready.", { action: "continue_writing", tool: null }, {
1483
+ status: "ready",
1484
+ ...(r.voice || {}),
1485
+ }),
1486
+ };
1487
+ } catch (error) {
1488
+ if (error instanceof AccountRequestError && error.status === 404) {
1489
+ const message = "No Writing Profile yet. Offer the user both ways to create one: sharing writing they authored, or answering a few short questions about how they write. Use only natural writing the user actually composed; approval alone does not establish authorship. Nothing is created until the user agrees.";
1490
+ return {
1491
+ content: [{ type: "text", text: message }],
1492
+ structuredContent: publicMcpState("missing", message, { action: "collect_evidence", tool: "create_my_voice" }, {
1493
+ status: "setup_required",
1494
+ }),
1495
+ };
1496
+ }
1497
+ return accountToolError(error);
1498
+ }
1499
+ }
1500
+
1501
+ if (name === "create_my_voice") {
1502
+ const prepared = createVoiceBody(args);
1503
+ if (prepared.error) return {
1504
+ content: [{ type: "text", text: prepared.error }],
1505
+ structuredContent: publicMcpState("failed", prepared.error, { action: "retry", tool: "create_my_voice" }),
1506
+ isError: true,
1507
+ };
1508
+ try {
1509
+ const r = await accountRequest("/api/v1/voice/create", {
1510
+ method: "POST",
1511
+ body: prepared.body,
1512
+ toolName: "create_my_voice",
1513
+ });
1514
+ const structuredContent = safeCreationResult(r);
1515
+ return {
1516
+ content: [{ type: "text", text: structuredContent.message }],
1517
+ structuredContent,
1518
+ };
1519
+ } catch (error) {
1520
+ return accountToolError(error);
1521
+ }
1522
+ }
1523
+
1524
+ if (name === "record_voice_feedback" || name === "learn_writing_style") {
1525
+ try {
1526
+ return await accountMcpRequest(name, args);
1527
+ } catch {
1528
+ const learning = name === "learn_writing_style";
1529
+ const message = learning
1530
+ ? "Writing style could not be saved. Retry once or finish."
1531
+ : "Feedback could not be recorded. Retry once or finish.";
1532
+ return {
1533
+ content: [{ type: "text", text: message }],
1534
+ ...(learning
1535
+ ? { structuredContent: { status: "failed", message, next: { action: "retry", tool: null } } }
1536
+ : { structuredContent: publicMcpState("failed", message, { action: "retry", tool: "record_voice_feedback" }) }),
1537
+ isError: true,
1538
+ };
1539
+ }
1540
+ }
1541
+
1542
+ return undefined;
1543
+ }
1544
+
1545
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
341
1546
  const server = new Server(
342
- { name: "idiolect", version: "0.3.0" },
1547
+ { name: "idiolect", version: PACKAGE_VERSION },
343
1548
  {
344
1549
  capabilities: { tools: {} },
345
- // Hint folded into the client's context (MCP spec): use Idiolect AI proactively.
346
- instructions:
347
- "Idiolect AI makes you write in THIS user's own voice. Whenever you are about to write any prose on the user's behalf — emails, replies, messages, posts, commit messages, PR descriptions, docs — FIRST call get_my_voice (once per session) and write in that voice. After drafting, you may call rewrite_in_voice to generate an in-voice alternative with a Voice Match; use it only when it preserves the user's meaning and fits their request. Do not use for code. Never invent the user's opinions — only match their style.",
1550
+ // Hint folded into the client's context (MCP spec).
1551
+ instructions: ACCOUNT_MODE
1552
+ ? accountInstructions
1553
+ : "Use `rewrite_in_voice` or `draft_in_voice` only when the user asks for writing in the configured voice. Use `score_voice` only when the user explicitly asks for measurement. Never infer another person's writing or invent the user's opinions.",
348
1554
  },
349
1555
  );
350
1556
 
@@ -352,27 +1558,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
352
1558
  tools: ACCOUNT_MODE ? accountTools : legacyTools,
353
1559
  }));
354
1560
 
355
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
1561
+ export async function handleToolRequest(req) {
356
1562
  if (ACCOUNT_MODE) {
357
1563
  const args = req.params.arguments || {};
358
-
359
- if (req.params.name === "get_my_voice") {
1564
+ if (
1565
+ PROGRESSIVE_ACCOUNT_TOOL_NAMES.has(req.params.name) ||
1566
+ REMOTE_COMPATIBILITY_TOOL_NAMES.has(req.params.name)
1567
+ ) {
360
1568
  try {
361
- const r = await accountRequest("/api/v1/voice", { toolName: "get_my_voice" });
362
- const card = r.card || formatValue(r.voice);
363
- return {
364
- content: [
365
- {
366
- type: "text",
367
- text: `${card}\n\n---\n(Idiolect AI account voice loaded. Write everything as this person from now on.)`,
368
- },
369
- ],
370
- structuredContent: r.voice || {},
371
- };
372
- } catch (e) {
373
- return { content: [{ type: "text", text: String(e) }], isError: true };
1569
+ return await accountMcpRequest(req.params.name, args);
1570
+ } catch (error) {
1571
+ return accountToolError(error);
374
1572
  }
375
1573
  }
1574
+ const nativeAccountResult = await handleAccountTool(req.params.name, args);
1575
+ if (nativeAccountResult) return nativeAccountResult;
376
1576
 
377
1577
  if (req.params.name === "score_voice") {
378
1578
  const text = typeof args.text === "string" ? args.text.trim() : "";
@@ -387,26 +1587,37 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
387
1587
  });
388
1588
  return {
389
1589
  content: [{ type: "text", text: scoreSummary(r) }],
390
- structuredContent: {
391
- voiceMatch: r.voiceMatch ?? null,
1590
+ structuredContent: publicMcpState("complete", "Voice Match check complete.", { action: "continue_writing", tool: null }, {
1591
+ voiceMatch: r.absoluteScoreReportable === false ? null : r.voiceMatch ?? null,
392
1592
  measured: !!r.measured,
393
1593
  validated: !!r.validated,
394
1594
  instrument: r.instrument ?? null,
395
1595
  reason: r.reason ?? null,
396
- register: r.register ?? "",
397
- approximate: !!r.approximate,
1596
+ ...(typeof r.register === "string" ? { register: r.register } : {}),
1597
+ ...(typeof r.approximate === "boolean" ? { approximate: r.approximate } : {}),
398
1598
  ...(r.voice ? { voice: r.voice } : {}),
399
1599
  ...(r.evidence ? { evidence: r.evidence } : {}),
400
- },
1600
+ }),
401
1601
  };
402
1602
  } catch (e) {
403
- return { content: [{ type: "text", text: String(e) }], isError: true };
1603
+ return accountToolError(e);
404
1604
  }
405
1605
  }
406
1606
 
407
1607
  if (req.params.name === "rewrite_in_voice") {
408
1608
  const text = typeof args.text === "string" ? args.text.trim() : "";
1609
+ const profileId = typeof args.profileId === "string" ? args.profileId : undefined;
409
1610
  const targetRegister = typeof args.targetRegister === "string" ? args.targetRegister : undefined;
1611
+ const instructions = typeof args.instructions === "string" && args.instructions.trim()
1612
+ ? args.instructions.trim()
1613
+ : undefined;
1614
+ const invalidChangeRequest = changeRequestError(args, "rewrite_in_voice");
1615
+ if (invalidChangeRequest) return invalidChangeRequest;
1616
+ const invalidSourceAuthor = authorProvenanceError(args, "rewrite_in_voice", "sourceAuthor");
1617
+ if (invalidSourceAuthor) return invalidSourceAuthor;
1618
+ const changeScope = args.changeScope === "polish" ? "light_edit" : args.changeScope;
1619
+ const effects = args.effects;
1620
+ const sourceAuthor = args.sourceAuthor;
410
1621
  if (!text) {
411
1622
  return { content: [{ type: "text", text: "rewrite_in_voice: provide `text` to rewrite." }], isError: true };
412
1623
  }
@@ -415,7 +1626,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
415
1626
  content: [
416
1627
  {
417
1628
  type: "text",
418
- text: `rewrite_in_voice: \`targetRegister\` must be one of ${REWRITE_REGISTERS.join(", ")}.`,
1629
+ text: `rewrite_in_voice: \`targetRegister\` must be one of ${backtickedValues(REWRITE_REGISTERS)}.`,
419
1630
  },
420
1631
  ],
421
1632
  isError: true,
@@ -424,45 +1635,49 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
424
1635
  try {
425
1636
  const r = await accountRequest("/api/v1/rewrite", {
426
1637
  method: "POST",
427
- body: { text, ...(targetRegister ? { targetRegister } : {}) },
1638
+ body: {
1639
+ text,
1640
+ ...(profileId !== undefined ? { voice: profileId } : {}),
1641
+ ...(targetRegister ? { targetRegister } : {}),
1642
+ ...(instructions ? { instructions } : {}),
1643
+ ...(changeScope !== undefined ? { changeScope } : {}),
1644
+ ...(effects !== undefined ? { effects } : {}),
1645
+ ...(sourceAuthor !== undefined ? { sourceAuthor } : {}),
1646
+ },
428
1647
  toolName: "rewrite_in_voice",
429
1648
  });
1649
+ // The account API returns the rewrite, the profile and its evidence state. The
1650
+ // score block this used to print and the constants it used to coerce into the
1651
+ // structured result are gone (IDI-381).
430
1652
  return {
431
- content: [
432
- {
433
- type: "text",
434
- text: `Rewritten:\n${r.rewritten || ""}\n\n${scoreSummary(r)}${formatAlternatives(r.alternatives)}`,
435
- },
436
- ],
437
- structuredContent: {
1653
+ content: [{ type: "text", text: `Rewritten:\n${r.rewritten || ""}` }],
1654
+ structuredContent: publicMcpState("complete", "Rewrite complete.", { action: "continue_writing", tool: null }, {
438
1655
  rewritten: r.rewritten ?? "",
439
- voiceMatch: r.voiceMatch ?? null,
440
- delta: r.delta ?? null,
441
- measured: !!r.measured,
442
- crossFit: !!r.crossFit,
443
- candidateText: r.candidateText ?? r.rewritten ?? "",
444
- candidateVoiceMatch: r.candidateVoiceMatch ?? null,
445
- candidateDelta: r.candidateDelta ?? null,
446
- improved: r.improved ?? null,
447
- regressed: r.regressed ?? null,
448
- validated: !!r.validated,
449
- instrument: r.instrument ?? null,
450
- reason: r.reason ?? null,
451
- register: r.register ?? "",
452
- approximate: !!r.approximate,
1656
+ ...(r.reason !== undefined ? { reason: r.reason ?? null } : {}),
453
1657
  ...(r.voice ? { voice: r.voice } : {}),
454
1658
  ...(r.evidence ? { evidence: r.evidence } : {}),
455
- },
1659
+ }),
456
1660
  };
457
1661
  } catch (e) {
458
- return { content: [{ type: "text", text: String(e) }], isError: true };
1662
+ return accountToolError(e);
459
1663
  }
460
1664
  }
461
1665
 
462
1666
  if (req.params.name === "draft_in_voice") {
463
1667
  const brief = typeof args.brief === "string" ? args.brief.trim() : "";
1668
+ const profileId = typeof args.profileId === "string" ? args.profileId : undefined;
464
1669
  const format = typeof args.format === "string" ? args.format : undefined;
1670
+ const instructions = typeof args.instructions === "string" && args.instructions.trim()
1671
+ ? args.instructions.trim()
1672
+ : undefined;
465
1673
  const formats = new Set(["linkedin", "x_post", "x_thread", "newsletter", "generic"]);
1674
+ const invalidChangeRequest = changeRequestError(args, "draft_in_voice");
1675
+ if (invalidChangeRequest) return invalidChangeRequest;
1676
+ const invalidBriefAuthor = authorProvenanceError(args, "draft_in_voice", "briefAuthor");
1677
+ if (invalidBriefAuthor) return invalidBriefAuthor;
1678
+ const changeScope = args.changeScope === "polish" ? "light_edit" : args.changeScope;
1679
+ const effects = args.effects;
1680
+ const briefAuthor = args.briefAuthor;
466
1681
  if (!brief) {
467
1682
  return { content: [{ type: "text", text: "draft_in_voice: provide `brief` to draft from." }], isError: true };
468
1683
  }
@@ -471,7 +1686,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
471
1686
  content: [
472
1687
  {
473
1688
  type: "text",
474
- text: "draft_in_voice: `format` must be one of linkedin, x_post, x_thread, newsletter, generic.",
1689
+ text: `draft_in_voice: \`format\` must be one of ${backtickedValues([...formats])}.`,
475
1690
  },
476
1691
  ],
477
1692
  isError: true,
@@ -480,32 +1695,28 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
480
1695
  try {
481
1696
  const r = await accountRequest("/api/v1/draft", {
482
1697
  method: "POST",
483
- body: { brief, ...(format ? { format } : {}) },
1698
+ body: {
1699
+ brief,
1700
+ ...(profileId !== undefined ? { voice: profileId } : {}),
1701
+ ...(format ? { format } : {}),
1702
+ ...(instructions ? { instructions } : {}),
1703
+ ...(changeScope !== undefined ? { changeScope } : {}),
1704
+ ...(effects !== undefined ? { effects } : {}),
1705
+ ...(briefAuthor !== undefined ? { briefAuthor } : {}),
1706
+ },
484
1707
  toolName: "draft_in_voice",
485
1708
  });
486
1709
  return {
487
- content: [
488
- {
489
- type: "text",
490
- text: `Draft:\n${r.draft || ""}\n\n${scoreSummary(r)}${formatAlternatives(r.alternatives)}`,
491
- },
492
- ],
493
- structuredContent: {
1710
+ content: [{ type: "text", text: `Draft:\n${r.draft || ""}` }],
1711
+ structuredContent: publicMcpState("complete", "Draft complete.", { action: "continue_writing", tool: null }, {
494
1712
  draft: r.draft ?? "",
495
- voiceMatch: r.voiceMatch ?? null,
496
- measured: !!r.measured,
497
- crossFit: !!r.crossFit,
498
- validated: !!r.validated,
499
- instrument: r.instrument ?? null,
500
- reason: r.reason ?? null,
501
- register: r.register ?? "",
502
- approximate: !!r.approximate,
1713
+ ...(r.reason !== undefined ? { reason: r.reason ?? null } : {}),
503
1714
  ...(r.voice ? { voice: r.voice } : {}),
504
1715
  ...(r.evidence ? { evidence: r.evidence } : {}),
505
- },
1716
+ }),
506
1717
  };
507
1718
  } catch (e) {
508
- return { content: [{ type: "text", text: String(e) }], isError: true };
1719
+ return accountToolError(e);
509
1720
  }
510
1721
  }
511
1722
 
@@ -533,14 +1744,11 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
533
1744
  }
534
1745
  try {
535
1746
  const r = await scoreVoice(texts, region);
536
- const lines = r.scores
537
- .map((s, i) => `#${i + 1} · Voice Match ${s.voiceMatch}/100 — ${s.text.slice(0, 60)}…`)
538
- .join("\n");
539
1747
  return {
540
1748
  content: [
541
1749
  {
542
1750
  type: "text",
543
- text: `${lines}\n\n(Relative voice-fidelity on the validated LUAR metric — higher = reads more like you. Revise low-scoring drafts before sending.)`,
1751
+ text: formatLegacyScoreVoiceResult(r),
544
1752
  },
545
1753
  ],
546
1754
  };
@@ -577,7 +1785,20 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
577
1785
  } catch (e) {
578
1786
  return { content: [{ type: "text", text: String(e) }], isError: true };
579
1787
  }
580
- });
1788
+ }
1789
+
1790
+ server.setRequestHandler(CallToolRequestSchema, handleToolRequest);
581
1791
 
582
- const transport = new StdioServerTransport();
583
- await server.connect(transport);
1792
+ function isMainModule() {
1793
+ if (!process.argv[1]) return false;
1794
+ try {
1795
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
1796
+ } catch {
1797
+ return false;
1798
+ }
1799
+ }
1800
+
1801
+ if (isMainModule()) {
1802
+ const transport = new StdioServerTransport();
1803
+ await server.connect(transport);
1804
+ }