@clien-ai/mcp 0.8.1 → 0.9.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.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * FUL-341 / FUL-536 / FUL-560 — the one rule for reading and RENDERING a source's content type to
3
+ * an agent.
4
+ *
5
+ * ## Why this is a module and not two copies
6
+ *
7
+ * `list_community_signals` (`collections.ts`) and `search_forums` (`scoped-research.ts`) render the
8
+ * SAME underlying rows — a forum thread a run discovered, before and after it is promoted into
9
+ * `community_signals`. FUL-247's rule is that two surfaces over one dataset must not disagree about
10
+ * what is worth an agent's attention; FUL-536 is what happens when they do, and it took two green
11
+ * guards and a production run to notice. So the vocabulary, the fail-closed normalizer and the
12
+ * rendered line live in ONE place that both import, the same way `engagement-display.ts` owns the
13
+ * engagement line for the same two surfaces.
14
+ *
15
+ * ## The rule
16
+ *
17
+ * State the provenance in BOTH directions, always. Rendering a type only when one is present makes
18
+ * "unclassified" indistinguishable from "this surface was not told", and silence reads as
19
+ * reassurance — which is the fail-open FUL-341 exists to close. An agent deciding whether to trust
20
+ * a quote needs the negative case spelled out, and it needs the CONSEQUENCE, not just a null field.
21
+ *
22
+ * ⚠️ THE TWO EXCLUSIONS ARE THE SAME BUT ARE WORDED SEPARATELY ON PURPOSE. Since FUL-531 both kinds
23
+ * carry the identical exclusion — `GROUNDING_ELIGIBLE_CONTENT_TYPES` (`app/lib/db/community-db.ts`)
24
+ * is `CONTENT_TYPES` minus `FLAGGED_CONTENT_TYPES`, so neither reaches the automatic synthesis
25
+ * prompt, and `buildCommunityThemes` grounds no theme on either. They stay worded SEPARATELY
26
+ * because they do not MEAN the same thing: `marketing_seo` is a source we classified and judged
27
+ * vendor copy, `unclassified` is one nobody has looked at. Collapsing them would tell an agent that
28
+ * an unexamined source had been examined.
29
+ */
30
+ /**
31
+ * FUL-341 — the canonical content-type vocabulary, hand-copied.
32
+ *
33
+ * `mcp/` publishes to npm on its own cadence and cannot import from the Next.js app, so this is a
34
+ * forced duplicate of `CONTENT_TYPES` in `app/lib/validation/claim-states.ts` (itself already
35
+ * pinned to the agent's copy). This repo's answer to exactly that situation is a coupling test:
36
+ * `__tests__/integration/mcp/community-content-type-coupling.test.ts` pins this set equal to the
37
+ * app's, so widening the vocabulary in one package fails the suite rather than silently teaching
38
+ * two surfaces to disagree about the same row.
39
+ *
40
+ * ⚠️ That test parses this declaration out of THIS FILE as text. Moving or renaming it means
41
+ * updating the test's path and regex, not just the import sites.
42
+ */
43
+ export const CANONICAL_CONTENT_TYPES = ['user_voice', 'report_evidence', 'marketing_seo'];
44
+ /**
45
+ * The four states, enumerated for a tool DESCRIPTION or a schema `.describe()`.
46
+ *
47
+ * A description is an executable interface: an agent reads it BEFORE its first call and is primed
48
+ * by it, so a trust qualifier that exists only in the rendered output is discoverable one call too
49
+ * late (FUL-341). Carries no leading clause — each surface supplies its own ("every thread carries
50
+ * a `source type:` line", "every receipt carries…") — and no trailing space.
51
+ */
52
+ export const CONTENT_TYPE_STATES = '`user_voice` (a real person\'s first-hand account), `report_evidence` ' +
53
+ '(authoritative/third-person reporting), `marketing_seo` (vendor marketing or SEO content), or ' +
54
+ '`unclassified`.';
55
+ /**
56
+ * The two exclusions, in the ONE wording every surface states them in.
57
+ *
58
+ * ⚠️ Both are real and both are enforced: `GROUNDING_ELIGIBLE_CONTENT_TYPES` (`community-db.ts`)
59
+ * is `CONTENT_TYPES` minus `FLAGGED_CONTENT_TYPES` since FUL-531, so neither kind reaches the
60
+ * automatic synthesis prompt, and `buildCommunityThemes` grounds no theme on either. They are
61
+ * still stated SEPARATELY because they do not mean the same thing — one source was classified and
62
+ * judged vendor copy, the other has not been looked at — and an agent that conflated them would
63
+ * treat an unexamined source as an examined one.
64
+ *
65
+ * ⚠️ FUL-560 extracted this from the two `registry.ts` adverts that already held it word-for-word
66
+ * by hand. The `get_report` receipt pools were the surface that had NO wording at all, and adding
67
+ * a fifth hand-copy to fix that is the drift FUL-247 exists to prevent — so the copies became one
68
+ * constant, pinned across every surface by `content-type-display.test.ts`. No leading or trailing
69
+ * space; each caller joins it.
70
+ */
71
+ export const CONTENT_TYPE_EXCLUSIONS = '`marketing_seo` is shown for awareness but is EXCLUDED from grounding AND from automatic ' +
72
+ 'community synthesis — it is vendor marketing or SEO content, never cite it as user ' +
73
+ 'evidence. `unclassified` is EXCLUDED from both as well, and means the classification is ' +
74
+ 'genuinely unknown, NOT that the source was checked and found benign.';
75
+ /**
76
+ * FUL-560 — the receipt-pool paragraph, in the ONE wording every tool that RENDERS the trust digest
77
+ * advertises it in.
78
+ *
79
+ * `composeReportText` puts the same two receipt pools in the result text of `get_report`,
80
+ * `clien_research` and `clien_research_status`, so all three emit these `source type:` lines and all
81
+ * three must explain them: an agent that ran the hero tool and never calls `get_report` would
82
+ * otherwise meet the vocabulary for the first time in the output, which is the discoverable-one-call-
83
+ * too-late failure {@link CONTENT_TYPE_STATES} exists to avoid. Three hand-copies of a paragraph is
84
+ * also exactly the drift that made this module necessary — hence one constant, and
85
+ * `content-type-display.test.ts` DERIVES the tools that must carry it from the source rather than
86
+ * listing them, so a fourth digest surface fails the suite instead of shipping unadverted.
87
+ *
88
+ * Opens as a noun phrase so it can be a numbered item in `get_report`'s list or the object of a
89
+ * lead-in sentence elsewhere. No trailing space; each caller joins it.
90
+ */
91
+ export const CONTENT_TYPE_DIGEST_ADVERT = 'the SOURCE TYPE on every receipt — ' +
92
+ CONTENT_TYPE_STATES +
93
+ ' Every "RRCP-" market/competitor receipt carries a `source type:` line; a "RCP-" persona receipt ' +
94
+ 'is a first-hand `user_voice` post unless its row says otherwise, and any that is not carries the ' +
95
+ 'same line marked \u26a0. ' +
96
+ CONTENT_TYPE_EXCLUSIONS;
97
+ /**
98
+ * Normalize a persisted classification, failing CLOSED.
99
+ *
100
+ * Anything outside the canonical set — a typo'd literal, a value from a newer app version this
101
+ * client predates — becomes `null`, exactly like a missing one. A `!== null` check here instead
102
+ * would let an unrecognized string read as CLASSIFIED and present unvetted evidence to an agent as
103
+ * grounding-eligible. Version skew makes that a real case for a published client, not a
104
+ * hypothetical one.
105
+ *
106
+ * ⚠️ FUL-536 adds a second reason on the `search_forums` side. There the value is read out of a
107
+ * stored `report_data` document rather than a CHECK-constrained column, and `ForumThreadSchema` is
108
+ * `.passthrough()` — so a hallucinated `contentType: 'user_voice'` from an older run is a shape the
109
+ * parser accepts. `tagFastPathContentTypes` (FUL-535) deletes model-emitted types at the agent's
110
+ * read chokepoint for NEW data; this is what stops an old one being rendered as a classification.
111
+ */
112
+ export function canonicalContentType(value) {
113
+ return typeof value === 'string' && CANONICAL_CONTENT_TYPES.includes(value)
114
+ ? value
115
+ : null;
116
+ }
117
+ /**
118
+ * The continuation-line indent every surface renders this on. One constant, so the two
119
+ * compositions below cannot drift apart by a space.
120
+ */
121
+ const CONTINUATION = '\n ';
122
+ /**
123
+ * The sentence itself, without the indent — what a reader actually reads.
124
+ *
125
+ * Split out by FUL-560 purely so {@link contentTypeDeviationLine} can prefix a marker without
126
+ * restating any of the three branches. Nothing calls this directly; the exported line builders
127
+ * are the API, and holding the wording in ONE expression is the whole point of this module.
128
+ */
129
+ function contentTypeSentence(value) {
130
+ const contentType = canonicalContentType(value);
131
+ return !contentType
132
+ ? 'source type: unclassified - excluded from grounding and community synthesis'
133
+ : contentType === 'marketing_seo'
134
+ ? 'source type: marketing_seo - vendor marketing, excluded from grounding and community synthesis'
135
+ : `source type: ${contentType}`;
136
+ }
137
+ /**
138
+ * The `source type:` continuation line, for a row rendered in the `\n ` continuation shape both
139
+ * surfaces use. Never returns an empty string: see the both-directions rule above.
140
+ *
141
+ * Accepts the absent value in either spelling — `null` for a `community_signals` column, `undefined`
142
+ * for a `report_data` thread key FUL-535 deliberately left unstamped — because the two surfaces
143
+ * store "we don't know" differently and neither should have to widen its own types to say so.
144
+ */
145
+ export function contentTypeProvenanceLine(value) {
146
+ return `${CONTINUATION}${contentTypeSentence(value)}`;
147
+ }
148
+ /**
149
+ * FUL-560 — the same sentence, marked as a DEVIATION from a pool's stated invariant.
150
+ *
151
+ * `get_report`'s PERSONA receipt pool is the one surface where the type is a near-constant rather
152
+ * than a per-row fact: `stampEvidenceMetadata` prunes every non-`user_voice` source out of
153
+ * `persona.sources` upstream, so a per-row line there would repeat one word down the whole pool
154
+ * and bury the row that broke the rule. The pool states the invariant once in its header and each
155
+ * row that does NOT satisfy it carries this line — which is the both-directions rule kept, not
156
+ * traded away: a reader is told the rule AND told, loudly, wherever it does not hold.
157
+ *
158
+ * ⚠️ The marker is emphasis, NOT vocabulary. It prefixes the shared sentence rather than replacing
159
+ * any of it, so the wording an agent matches on stays byte-identical to every other surface — the
160
+ * FUL-247 drift this module exists to prevent. A row whose type is absent or non-canonical is a
161
+ * deviation too: `user_voice` is a positive claim, and anything else is the caller failing closed.
162
+ */
163
+ export function contentTypeDeviationLine(value) {
164
+ return `${CONTINUATION}\u26a0 ${contentTypeSentence(value)}`;
165
+ }
166
+ //# sourceMappingURL=content-type-display.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-type-display.js","sourceRoot":"","sources":["../../src/tools/content-type-display.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,YAAY,EAAE,iBAAiB,EAAE,eAAe,CAAU,CAAA;AAElG;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAC9B,wEAAwE;IACxE,gGAAgG;IAChG,iBAAiB,CAAA;AAEnB;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAClC,2FAA2F;IAC3F,qFAAqF;IACrF,0FAA0F;IAC1F,sEAAsE,CAAA;AAExE;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,0BAA0B,GACrC,qCAAqC;IACrC,mBAAmB;IACnB,mGAAmG;IACnG,mGAAmG;IACnG,2BAA2B;IAC3B,uBAAuB,CAAA;AAEzB;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAc;IACjD,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAK,uBAA6C,CAAC,QAAQ,CAAC,KAAK,CAAC;QAChG,CAAC,CAAC,KAAK;QACP,CAAC,CAAC,IAAI,CAAA;AACV,CAAC;AAED;;;GAGG;AACH,MAAM,YAAY,GAAG,OAAO,CAAA;AAE5B;;;;;;GAMG;AACH,SAAS,mBAAmB,CAAC,KAAc;IACzC,MAAM,WAAW,GAAG,oBAAoB,CAAC,KAAK,CAAC,CAAA;IAC/C,OAAO,CAAC,WAAW;QACjB,CAAC,CAAC,6EAA6E;QAC/E,CAAC,CAAC,WAAW,KAAK,eAAe;YAC/B,CAAC,CAAC,gGAAgG;YAClG,CAAC,CAAC,gBAAgB,WAAW,EAAE,CAAA;AACrC,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,yBAAyB,CAAC,KAAc;IACtD,OAAO,GAAG,YAAY,GAAG,mBAAmB,CAAC,KAAK,CAAC,EAAE,CAAA;AACvD,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,wBAAwB,CAAC,KAAc;IACrD,OAAO,GAAG,YAAY,UAAU,mBAAmB,CAAC,KAAK,CAAC,EAAE,CAAA;AAC9D,CAAC"}
@@ -0,0 +1,360 @@
1
+ /**
2
+ * `list_interview_scripts` (FUL-514) — the questions a run says are worth asking.
3
+ *
4
+ * Before this tool, MCP had the ACTION without the PLAN. An agent could call
5
+ * `interview_persona` but could not first read what the research decided was worth
6
+ * asking, so it either invented questions unanchored to the run's open hypotheses
7
+ * — in which case the answers cannot feed back into the claim spine — or
8
+ * re-derived them from `get_report`'s hypothesis list, which is the FUL-510
9
+ * failure again: an agent recomputing something the run already produced and
10
+ * stored. The web app has handed a human this screen for free since FUL-327.
11
+ *
12
+ * ⚠️ THIS TOOL NEVER GENERATES A SCRIPT. Scripts are the deterministic output of a
13
+ * research run (`agent/src/interview-script.ts`), written into the run's report
14
+ * and immutable thereafter. Deriving them on a miss would make a free read do
15
+ * model work and spend money on a GET, so a project with none gets the honest
16
+ * empty state and nothing else. The wording of that empty state matters as much as
17
+ * the guarantee: "no run has written scripts" is not "there is nothing worth
18
+ * asking".
19
+ *
20
+ * Contract:
21
+ * - list_interview_scripts → GET /api/interview-scripts?projectId →
22
+ * { interviewScripts: <script>[] | null, sourceRun: { id, createdAt } | null }
23
+ *
24
+ * ⚠️ VOCABULARY, and why this differs from `collections.ts`. The two collections
25
+ * and `get_market` read PROMOTED TABLES, so their endpoints return raw snake_case
26
+ * rows and those modules map at the tool boundary. Interview scripts are not a
27
+ * table — they live inside `validation_jobs.report_data.interviewScripts` — so
28
+ * `/api/interview-scripts` returns a shape the app has already mapped, ranked and
29
+ * joined to its hypotheses. The mapper below therefore NORMALISES (type-checks
30
+ * every field, degrades a bad one to null) rather than renames. It is still
31
+ * written out field by field, for the same reason the collection mappers are: the
32
+ * field list is part of the public MCP contract and must be readable in review.
33
+ *
34
+ * ⚠️ RANKING AND SELECTION ARE THE SERVER'S. Which run owns a project's scripts
35
+ * (`latestScriptedRunQuery` — newest non-scoped run that actually wrote an array)
36
+ * and what order they come in (`buildRankedScripts` — least-settled verdict first)
37
+ * are decided once, app-side, by the same functions the web screen renders from.
38
+ * Re-sorting here is what would let the two surfaces disagree about which
39
+ * hypothesis is most worth a real answer.
40
+ *
41
+ * Everything else follows its siblings: the shared `apiCall` client, the shared
42
+ * 401/404/400 mapping, and the rule that "I could not read the answer" and "the
43
+ * answer is none" must never look the same. Read-only, so a timeout is always safe
44
+ * to retry — no work lost, no credit spent.
45
+ */
46
+ import { z } from 'zod';
47
+ import { CollectionToolError, formatZodError, makeCtx, throwForResponse, } from './collections.js';
48
+ import { apiCall } from './research.js';
49
+ import { safeId, safeInline, truncate, collapseWhitespace } from './render-safety.js';
50
+ const SCRIPTS_FETCH_TIMEOUT_MS = 30_000;
51
+ export const ListInterviewScriptsInputSchema = z.object({
52
+ project_id: z
53
+ .string()
54
+ .uuid()
55
+ .describe('UUID of the project whose interview scripts to read. Get it from ' +
56
+ '`list_projects` (or `create_project`). Rejected with a 404 if you do not own the ' +
57
+ 'project. Returns the STORED OUTPUT OF ONE SPECIFIC RESEARCH RUN — the scripts that ' +
58
+ 'run wrote when it ran, one per hypothesis, ranked least-settled first — not a live ' +
59
+ 'suggestion and not something generated when you call this. The run is named in the ' +
60
+ 'result; pass its id to `get_report` for the hypotheses behind the questions. A ' +
61
+ 'project can legitimately have NO scripts; that is reported as "no run has written ' +
62
+ 'any", never as "there is nothing worth asking".'),
63
+ });
64
+ function asText(value) {
65
+ return typeof value === 'string' && value.trim().length > 0 ? value : null;
66
+ }
67
+ /**
68
+ * A 0–1 confidence, or null.
69
+ *
70
+ * Out-of-range and non-finite numbers become null rather than being printed. The
71
+ * app drops them for the same reason at its own render seam: a percentage derived
72
+ * from 999 reads as a measurement, and this tool has no way to caption it as
73
+ * nonsense in the one line it gets.
74
+ */
75
+ function asFraction(value) {
76
+ if (typeof value !== 'number' || !Number.isFinite(value))
77
+ return null;
78
+ return value >= 0 && value <= 1 ? value : null;
79
+ }
80
+ export function toInterviewScriptEntry(row) {
81
+ return {
82
+ hypothesisId: asText(row.hypothesisId),
83
+ statement: asText(row.statement),
84
+ status: asText(row.status),
85
+ verdictLabel: asText(row.verdictLabel),
86
+ confidence: asFraction(row.confidence),
87
+ whoToAsk: asText(row.whoToAsk),
88
+ // A drifted null (or a string where an array belongs) costs the questions,
89
+ // never the script: the hypothesis it names is still worth reporting.
90
+ questions: Array.isArray(row.questions)
91
+ ? row.questions.filter((q) => typeof q === 'string')
92
+ : [],
93
+ };
94
+ }
95
+ export function toInterviewScriptsSourceRun(row) {
96
+ return { id: asText(row.id), createdAt: asText(row.createdAt) };
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Render caps
100
+ // ---------------------------------------------------------------------------
101
+ /**
102
+ * Upper bound on scripts RENDERED, matching `report-digest.ts`'s `LIST_RENDER_CAP`
103
+ * for personas and hypotheses. A run writes one script per hypothesis and the
104
+ * producer's hypothesis cap is far below this, so it should never fire — but an
105
+ * unfired cap that would truncate silently is the thing this package refuses.
106
+ * When it fires it says so, and says where the rest are.
107
+ */
108
+ const SCRIPT_RENDER_CAP = 25;
109
+ /**
110
+ * Questions rendered per script. The generator writes exactly 3; the cap exists
111
+ * for a report written by some other build, and announces itself the same way.
112
+ */
113
+ const QUESTION_RENDER_CAP = 12;
114
+ const STATEMENT_MAX = 240;
115
+ const WHO_TO_ASK_MAX = 240;
116
+ const QUESTION_MAX = 300;
117
+ const VERDICT_MAX = 40;
118
+ const TIMESTAMP_MAX = 40;
119
+ /**
120
+ * GET /api/interview-scripts for one project.
121
+ *
122
+ * ⚠️ THE NULL AND THE THROW ARE DIFFERENT ANSWERS, and the difference is decided by
123
+ * KEY PRESENCE, not truthiness. `{ "interviewScripts": null }` is the endpoint
124
+ * saying "no run has written scripts for this project" — a real, quotable fact. A
125
+ * 200 whose body has no `interviewScripts` key at all (an HTML error page from a
126
+ * proxy, a truncated response, a reshaped envelope) is the endpoint saying nothing,
127
+ * and must not arrive at an agent wearing the empty state's words: that empty state
128
+ * ends with "run `clien_research`", which DEBITS CREDITS. A transport failure read
129
+ * as "this project has no plan" can cost the user real money.
130
+ */
131
+ async function fetchInterviewScripts(deps, projectId) {
132
+ const action = 'read the interview scripts';
133
+ const ctx = makeCtx(deps);
134
+ const query = new URLSearchParams({ projectId });
135
+ let response;
136
+ try {
137
+ response = await apiCall(ctx, 'GET', `/api/interview-scripts?${query.toString()}`, {
138
+ timeoutMs: SCRIPTS_FETCH_TIMEOUT_MS,
139
+ });
140
+ }
141
+ catch (err) {
142
+ const e = err;
143
+ if (e.name === 'AbortError' || e.name === 'TimeoutError') {
144
+ throw new CollectionToolError(`Timed out after ${SCRIPTS_FETCH_TIMEOUT_MS / 1000}s trying to ${action}. ` +
145
+ 'Try again in a moment — this is a read, so nothing was spent and nothing was lost.');
146
+ }
147
+ throw err;
148
+ }
149
+ if (!response.ok)
150
+ await throwForResponse(response, deps, action, projectId);
151
+ const json = (await response.json().catch(() => null));
152
+ const unreadable = new CollectionToolError(`Could not ${action} — the server returned a 200 with an unreadable body ` +
153
+ '(no `interviewScripts` key). This is NOT "the project has no interview scripts": the ' +
154
+ 'request failed and scripts may well exist. Retry — this is a read, so nothing was spent ' +
155
+ 'and nothing was lost. Do not tell anyone this project has no interview plan on the ' +
156
+ 'strength of this, and do not start a paid research run because of it.');
157
+ if (json === null ||
158
+ typeof json !== 'object' ||
159
+ Array.isArray(json) ||
160
+ !('interviewScripts' in json)) {
161
+ throw unreadable;
162
+ }
163
+ const rawRun = json.sourceRun;
164
+ const sourceRun = rawRun !== null && typeof rawRun === 'object' && !Array.isArray(rawRun)
165
+ ? toInterviewScriptsSourceRun(rawRun)
166
+ : null;
167
+ const raw = json.interviewScripts;
168
+ if (raw === null)
169
+ return { scripts: null, sourceRun };
170
+ if (!Array.isArray(raw))
171
+ throw unreadable;
172
+ const scripts = raw
173
+ .filter((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry))
174
+ .map(toInterviewScriptEntry);
175
+ return { scripts, sourceRun };
176
+ }
177
+ // ---------------------------------------------------------------------------
178
+ // Render
179
+ // ---------------------------------------------------------------------------
180
+ /**
181
+ * The provenance sentence, in the TEXT channel because that is the one an agent
182
+ * always receives (FUL-243: `_meta` is a mirror, Claude Code drops it).
183
+ *
184
+ * A run id this client cannot state EXACTLY gets its own sentence rather than
185
+ * silence. Silence would read as "there is nothing to say about where these came
186
+ * from", when the truth is that the provenance is unknown — and a question set
187
+ * with unknown provenance is exactly the one an agent must not attribute to
188
+ * "the research".
189
+ */
190
+ function provenanceLine(runId, createdAt) {
191
+ const safe = safeId(runId);
192
+ const when = safeInline(createdAt, TIMESTAMP_MAX);
193
+ if (!safe) {
194
+ return ('PROVENANCE: these scripts name NO source run — either the response omitted it or the ' +
195
+ 'run behind them has since been deleted. Report them as recorded, never as "run X ' +
196
+ 'recommends asking".');
197
+ }
198
+ return (`PROVENANCE: every script above was written by research run ${safe}` +
199
+ (when ? ` (recorded ${when})` : '') +
200
+ '. A report is an immutable snapshot, so the verdicts beside each question are that run\'s, ' +
201
+ `not today's — call \`get_report\` with job_id=${safe} for the hypotheses behind them.`);
202
+ }
203
+ /**
204
+ * The linkage instruction.
205
+ *
206
+ * ⚠️ Stated because nothing downstream enforces it: `interview_persona` takes no
207
+ * hypothesis id, so the tie between an answer and the hypothesis it tests exists
208
+ * only for as long as the CALLER carries `hypothesisId` forward. An agent that
209
+ * runs these questions and reports the answers without it has produced exactly the
210
+ * unanchored interview this tool was built to prevent.
211
+ */
212
+ const LINKAGE_LINE = 'LINKAGE: each script names the `hypothesisId` it was written to test. Carry that id alongside ' +
213
+ 'whatever the interview returns — `interview_persona` does not record it for you — or the ' +
214
+ 'answers cannot be tied back to the hypothesis, which is the whole point of asking them.';
215
+ /**
216
+ * The trust caveat.
217
+ *
218
+ * A verdict printed beside a question reads as settled unless something says
219
+ * otherwise (FUL-247). These verdicts are the run's reading of its own hypothesis,
220
+ * not the grading of a claim: the per-claim GROUNDED / SPECULATION / NO_RECEIPT
221
+ * states live in the source run's claim spine, which this separately released
222
+ * client cannot reconstruct.
223
+ */
224
+ const SCRIPTS_TRUST_LINE = 'TRUST: the verdict and confidence on each script are the source run\'s reading of that ' +
225
+ 'hypothesis, not a graded claim. Nothing here carries a GROUNDED / SPECULATION / NO_RECEIPT ' +
226
+ 'state — read the source run with `get_report` for those. A high confidence is a reason to ask ' +
227
+ 'a different question, never a reason to skip asking.';
228
+ const CHANNEL_NOTE = 'The full record is in `structuredContent.interviewScripts` (also mirrored in `_meta`).';
229
+ /** `Weak signal (inconclusive), confidence 52%` — or the honest partial forms. */
230
+ function verdictClause(script) {
231
+ const label = safeInline(script.verdictLabel, VERDICT_MAX);
232
+ const status = safeInline(script.status, VERDICT_MAX);
233
+ const confidence = script.confidence === null ? null : `confidence ${Math.round(script.confidence * 100)}%`;
234
+ const verdict = label && status ? `${label} (${status})` : label ?? status ?? 'verdict not recorded';
235
+ return confidence ? `${verdict}, ${confidence}` : `${verdict}, confidence not recorded`;
236
+ }
237
+ /** The question list for one script, capped and — when capped — saying so. */
238
+ function questionBlock(questions) {
239
+ const flat = questions
240
+ .map((q) => collapseWhitespace(typeof q === 'string' ? q : ''))
241
+ .filter((q) => q.length > 0);
242
+ // Branch on the RENDERED questions, not on `questions.length`: `['']` has
243
+ // length 1 and collapses to nothing, and a "Questions:" header over no
244
+ // questions reads as a rendering bug rather than as a script that lost them.
245
+ if (flat.length === 0) {
246
+ return ' Questions: none readable in the stored script.';
247
+ }
248
+ const shown = flat.slice(0, QUESTION_RENDER_CAP);
249
+ const lines = shown.map((q, i) => ` ${i + 1}. ${truncate(q, QUESTION_MAX)}`);
250
+ const dropped = flat.length - shown.length;
251
+ const more = dropped > 0
252
+ ? `\n (${dropped} more in \`structuredContent.interviewScripts\`)`
253
+ : '';
254
+ return ` Questions:\n${lines.join('\n')}${more}`;
255
+ }
256
+ /** One ranked script as its own block. */
257
+ function renderScript(script, rank) {
258
+ const id = safeId(script.hypothesisId);
259
+ const statement = safeInline(script.statement, STATEMENT_MAX);
260
+ const whoToAsk = safeInline(script.whoToAsk, WHO_TO_ASK_MAX);
261
+ // The heading names the hypothesis, because a question set detached from the
262
+ // hypothesis it serves is the thing this tool exists to stop being handed out.
263
+ const heading = statement
264
+ ? `${rank}. ${statement}`
265
+ : `${rank}. (this run recorded no statement for the hypothesis this script serves)`;
266
+ const lines = [
267
+ heading,
268
+ id
269
+ ? ` Hypothesis: ${id} — ${verdictClause(script)}`
270
+ : ` Hypothesis: NOT IDENTIFIED — ${verdictClause(script)}. Without an id these answers ` +
271
+ 'cannot be tied back to a hypothesis.',
272
+ ];
273
+ if (whoToAsk)
274
+ lines.push(` Who to ask: ${whoToAsk}`);
275
+ lines.push(questionBlock(script.questions));
276
+ return lines.join('\n');
277
+ }
278
+ export async function listInterviewScripts(input, deps) {
279
+ const parsed = ListInterviewScriptsInputSchema.safeParse(input);
280
+ if (!parsed.success) {
281
+ throw new CollectionToolError(`Invalid input — ${formatZodError(parsed.error)}`);
282
+ }
283
+ const { project_id: projectId } = parsed.data;
284
+ const { scripts, sourceRun } = await fetchInterviewScripts(deps, projectId);
285
+ let text;
286
+ if (scripts === null) {
287
+ // ⚠️ THE HONEST EMPTY STATE. "Absent" is not "settled" and it is not "we looked
288
+ // and there was nothing worth asking". A scoped scan or a failed run leaves a
289
+ // project with no scripts while its hypotheses are wide open, so this sentence
290
+ // has to say that nothing is on record rather than imply a finding.
291
+ text =
292
+ `No interview scripts recorded for project ${projectId}.\n\n` +
293
+ 'This means NO completed research run has written interview scripts for this project. It ' +
294
+ 'is NOT a finding about the hypotheses: not "they are settled", not "there is nothing ' +
295
+ 'worth asking", not zero. There is simply nothing on record.\n\n' +
296
+ 'This tool never generates scripts — it reads what a run stored, and it will keep ' +
297
+ 'returning this until one exists. Run `clien_research` (a full run) to produce them. ' +
298
+ '`scan_competitors` and `search_forums` are scoped runs and never write scripts, so ' +
299
+ 'running those will leave this empty.';
300
+ }
301
+ else {
302
+ const runId = safeId(sourceRun?.id ?? null);
303
+ const when = safeInline(sourceRun?.createdAt ?? null, TIMESTAMP_MAX);
304
+ if (scripts.length === 0) {
305
+ // ⚠️ A DIFFERENT FACT FROM THE `null` BRANCH. A run IS on record and it did
306
+ // write a scripts array — the endpoint only names a run that did. Nothing in
307
+ // it could be read, which is a defect in the stored report, not a statement
308
+ // about the hypotheses. Collapsing the two would tell a caller no research
309
+ // has been done when some has.
310
+ text =
311
+ `Project ${projectId} has a research run that wrote interview scripts, and none of ` +
312
+ 'them could be read.\n\n' +
313
+ (runId
314
+ ? `Run ${runId}${when ? ` (recorded ${when})` : ''} stored a scripts array whose ` +
315
+ 'entries are unusable — a malformed report, not an answer about the hypotheses. '
316
+ : 'The run that wrote them stored a scripts array whose entries are unusable — a ' +
317
+ 'malformed report, not an answer about the hypotheses. ') +
318
+ 'Do not treat this as "nothing worth asking". Read the run itself with `get_report` for ' +
319
+ 'its hypotheses, or run `clien_research` again to write a fresh set.\n\n' +
320
+ provenanceLine(sourceRun?.id ?? null, sourceRun?.createdAt ?? null) +
321
+ '\n\n' +
322
+ CHANNEL_NOTE;
323
+ }
324
+ else {
325
+ const shown = scripts.slice(0, SCRIPT_RENDER_CAP);
326
+ const dropped = scripts.length - shown.length;
327
+ const capNote = dropped > 0
328
+ ? `\n\n(${dropped} more script${dropped === 1 ? '' : 's'} in ` +
329
+ '`structuredContent.interviewScripts`.)'
330
+ : '';
331
+ text =
332
+ `Interview scripts for project ${projectId} — ${scripts.length} script` +
333
+ `${scripts.length === 1 ? '' : 's'}` +
334
+ `${runId ? ` from research run ${runId}` : ''}${when ? ` (recorded ${when})` : ''}.\n\n` +
335
+ 'These are the STORED output of that run, written when it ran. Nothing was generated to ' +
336
+ 'answer this call.\n\n' +
337
+ 'RANKED least-settled first: the hypothesis nearest a coin flip goes first, because a ' +
338
+ 'real answer there moves the verdict most. That order is the run\'s, not this ' +
339
+ 'client\'s.\n\n' +
340
+ shown.map((script, i) => renderScript(script, i + 1)).join('\n\n') +
341
+ capNote +
342
+ '\n\n' +
343
+ LINKAGE_LINE +
344
+ '\n\n' +
345
+ provenanceLine(sourceRun?.id ?? null, sourceRun?.createdAt ?? null) +
346
+ '\n\n' +
347
+ SCRIPTS_TRUST_LINE +
348
+ '\n\n' +
349
+ CHANNEL_NOTE;
350
+ }
351
+ }
352
+ return {
353
+ content: [{ type: 'text', text }],
354
+ // Both keys are present even when null — the PRESENCE is what tells a
355
+ // structured consumer "no run has written scripts" apart from "this tool did
356
+ // not answer".
357
+ _meta: { interviewScripts: scripts, sourceRun },
358
+ };
359
+ }
360
+ //# sourceMappingURL=interview-scripts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interview-scripts.js","sourceRoot":"","sources":["../../src/tools/interview-scripts.ts"],"names":[],"mappings":"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"}
@@ -112,8 +112,8 @@ function makeCtx(deps) {
112
112
  * insufficient-credit stop with balance/required/buy-url guidance; 401 drops the
113
113
  * cached session (so the next tool call rebuilds from disk) and surfaces a
114
114
  * re-auth message; 404 means the interview/project isn't owned by the caller;
115
- * 400 surfaces the server's validation detail; everything else surfaces a
116
- * generic HTTP error.
115
+ * 403 with a lock code surfaces the upgrade path; 400 surfaces the server's
116
+ * validation detail; everything else surfaces a generic HTTP error.
117
117
  *
118
118
  * FUL-161: the body is read and parsed ONCE up front rather than after the 404
119
119
  * branch, because the 402 branch needs its `balance`/`required`/`buy_url` and a
@@ -159,6 +159,22 @@ async function throwForResponse(response, deps, action, options) {
159
159
  deps.invalidateSession?.();
160
160
  throw new InterviewToolError('Session expired. Re-run any Clien.ai tool to re-authenticate, then try again.');
161
161
  }
162
+ // FUL-503. Without this branch a subscription lock reads as
163
+ // `Failed to start interview (HTTP 403)` — indistinguishable from a bug, so the
164
+ // agent retries something no retry can fix. Mirrors `personas.ts`, including
165
+ // naming the resource that is actually locked.
166
+ //
167
+ // FUL-553 widened where these codes come from: `start` was the only action that
168
+ // could produce them, and now `turn` and `complete`/`reopen` do too, because a
169
+ // locked project is read-only IN FULL rather than merely uncreatable-in. The
170
+ // copy is already action-parameterised, so it needed no change — but note that
171
+ // a PROJECT_LOCKED on a `turn` refers to an interview that DOES exist and is
172
+ // simply frozen, unlike the same code on a `start`.
173
+ if (status === 403 && (parsed?.code === 'PROJECT_LOCKED' || parsed?.code === 'PERSONA_LOCKED')) {
174
+ throw new InterviewToolError(parsed.code === 'PROJECT_LOCKED'
175
+ ? `Could not ${action} — the project is locked due to subscription limits and is read-only. Upgrade to Pro to change it.`
176
+ : `Could not ${action} — the persona is locked due to subscription limits. Upgrade to Pro to interview it.`);
177
+ }
162
178
  if (status === 404) {
163
179
  throw new InterviewToolError(options?.notFound ??
164
180
  `Could not ${action} — interview or project not found, or you don't own it. ` +