@nooticr/mcp 1.26.28 → 1.26.30

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/README.md CHANGED
@@ -52,7 +52,7 @@ npx @nooticr/mcp login # one-time sign-in (Google)
52
52
 
53
53
  ## Tools
54
54
 
55
- 64 tools, grouped by what you are trying to do. Prices are in nooticr credits and
55
+ 68 tools, grouped by what you are trying to do. Prices are in nooticr credits and
56
56
  match what the server actually charges.
57
57
 
58
58
  Seven of them — the ones under **Answer a question you actually have** — are not
@@ -115,10 +115,11 @@ them caps that fan-out with an argument.
115
115
  | `show_audience_replies` | free | Lays your drafts out for a person to work through, grouped under the post, each with what you decided to do about it. Sends nothing; fetches nothing. |
116
116
  | `show_standings` | free | Draws the standings your model read out of `compare_creators` or `watchlist_standings` — one row per creator, with the window and the median it was scored against. `ranking` records which axis you ordered on, and `tooThin` draws a creator with too short a window as unranked rather than last: missing from a comparison is not the same as losing it. Makes no requests. |
117
117
  | `show_trend` | free | Draws the trend your model read out of `mention_trend`. `tooShort` marks a series with too few points to call a direction, and `edgeIsRecordStart` marks a left edge that is where the record begins rather than where the conversation did — a chart hiding either is the mistake this view could otherwise make on your behalf. Makes no requests. |
118
- | `track_competitor` | 2 | What a creator shipped, and which of it beat **their own** median rather than a raw view count that mostly measures follower count. One post list, whatever the window. If they are on your watchlist it also marks what is new since your last check and moves that marker forward — its own marker, not the one `catch_up_watchlist` keeps. |
118
+ | `track_creator` | 2 | What a creator shipped, and which of it beat **their own** median rather than a raw view count that mostly measures follower count. One post list, whatever the window. If they are on your watchlist it also marks what is new since your last check and moves that marker forward — its own marker, not the one `catch_up_watchlist` keeps. |
119
119
  | `compare_creators` | 2 per creator (4-10) | Two to five creators side by side on the one axis that compares: each post's ratio to **that creator's own** median. Per creator you get the window that was scored, their median, the share of it that beat that median, how hard they beat it when they did, every ratio, and their best and worst post. It does not rank them — "how often they land one" and "how big it is when they do" usually disagree, and a hit rate over a short window is one post either way, so the window travels with every number and the read is yours. |
120
120
  | `watchlist_standings` | 2 per watched creator | The same, across everyone on your watchlist: "who moved" in one call instead of one per creator. Reading the watchlist is free; the fetches are not, so it confirms the total before spending. `catch_up_watchlist` answers what is **new**; this answers how it **did**. |
121
121
  | `who_should_i_work_with` | 2, or 4 with a seed | A collaboration shortlist: a keyword search merged with the lookalikes of a creator who already fits, marked by which search found each one. Every candidate also carries the **links pulled out of their bio**, typed and sorted by how much opening one will tell you — a repository to read the code in, their own site, a link hub that holds the real links — so vetting is reading the work rather than re-reading the follower count. Those links are never fetched here: they came out of a field the person being evaluated controls, so the host opens them, and the result says so. It does **not** measure audience overlap — that costs about nine credits a candidate, so the result says so and shows how to check a finalist rather than faking the signal. |
122
+ | `find_people_with_problem` | 2 per platform | Find people **describing a problem in their own words** — prospects, early users, anyone whose complaint your product answers. Say the problem the way a person would say it and it searches for posts shaped like that complaint rather than for the topic it is about: searching the topic finds marketing about it, which is what `discover_social_posts` is for. Widens the query into the forms a complaint takes — the plain phrasing, the does-anyone-else question, the is-there-a-tool ask — and marks which one surfaced each post. A wide cheap net and **not** a filter: the posts come back for you to judge, and plenty will be off-target. Defaults to reddit, where people describe workflow pain in sentences. |
122
123
  | `show_collab_shortlist` | free | Draws the candidates you scored, ranked, and asks the user which to approach. The scores are attributed to your model, not presented as a nooticr rating, and a candidate scored without anything having been opened is marked unverified. Makes no requests. |
123
124
  | `why_did_this_underperform` | 3 | One post against the creator's own recent distribution, with the post taken back out of its own baseline. Returns median, quartiles, ratio and percentile, so the answer can be "this is an ordinary result, not a failure". Different question from `compare_posts`, which weighs two URLs you already picked. |
124
125
  | `what_should_i_make_next` | 2 + 2 per post read + 2 (12 by default) | Demand against supply: what your commenters explicitly ask for, set beside what a niche sweep shows is already being made. A gap nobody asked for is noise; a request nobody serves is the opportunity. Falls back to your most-used hashtag when you name no niche. |
@@ -529,9 +530,11 @@ Those are unit tests against a stub backend. For real MCP-protocol
529
530
  end-to-end tests — no Rust, no Postgres, no API key —
530
531
  `npm run test:e2e-smoke:fixture` spawns this repo's real built CLI and
531
532
  drives it over stdio against a pure-Node stand-in for nooticr-server. See
532
- `docs/testing/agentic-e2e-testing.md` for that plus the two tiers above it
533
- (`npm run test:e2e-smoke` against a real nooticr-server, and
534
- `npm run test:agentic-e2e`, a real model driving it), and for
533
+ `docs/testing/agentic-e2e-testing.md` for that plus the tier above it
534
+ (`npm run test:e2e-smoke` against a real nooticr-server). For a real model
535
+ driving the server through whole journeys — asserting on the chain of tools
536
+ it walks, not on one call — see `npm run test:quests` and
537
+ `docs/testing/tool-chaining-quests.md`. And for
535
538
  `tests/e2e/agentic-visual*.e2e.ts` — every reachable widget view, driven by
536
539
  a real tool-call result, rendered in a real browser with every host-facing
537
540
  button actually clicked (`npx playwright test tests/e2e/agentic-visual.e2e.ts
@@ -13,6 +13,7 @@
13
13
  * rather than a build.
14
14
  */
15
15
  import { z } from "zod";
16
+ import { withEvidence } from "./evidence-digest.js";
16
17
  import { OUTPUT_SCHEMAS } from "./output-schemas.js";
17
18
  import { viewMeta } from "./view-meta.js";
18
19
  import { BACKEND_CALL_CREDITS, confirmSpend, declinedResult, searchMentionsCost } from "./spend.js";
@@ -259,16 +260,26 @@ function trendGuidance(sc, runs) {
259
260
  "high `timesSeen` is a post that keeps resurfacing — worth naming as one thing rather " +
260
261
  "than as repeated new interest.");
261
262
  }
262
- lines.push("", "This is free because the sweeps were already billed. Nothing here needs a further call to " +
263
- "interpret it — read the numbers and say what changed, including that nothing did, if that " +
264
- "is what they show.");
263
+ lines.push("", "This is free because the sweeps were already billed. Read the numbers and say what changed, " +
264
+ "including that nothing did, if that is what they show. Then call show_trend with the " +
265
+ "series and your read of it — free, and it draws the chart with the two things a chart " +
266
+ "here can lie about marked: a series too short to call a direction, and a left edge that " +
267
+ "is where the record starts rather than where the conversation did.");
265
268
  return lines.join("\n");
266
269
  }
267
270
  function toResult(proxy) {
268
271
  const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
272
+ const structured = proxy.structured;
273
+ // The backend's text block is a one-line summary — "1 active brand watch."
274
+ // — and the watches themselves are in `structuredContent`, which a host
275
+ // rendering the view keeps for the widget. A model asked which watch to stop
276
+ // could see the count and not the terms (#59).
277
+ const text = String(textBlock?.text ?? "");
269
278
  return {
270
- content: textBlock ? [{ type: "text", text: String(textBlock.text ?? "") }] : [],
271
- structuredContent: proxy.structured,
279
+ content: [
280
+ { type: "text", text: structured ? withEvidence(text, structured) : text },
281
+ ],
282
+ structuredContent: structured,
272
283
  };
273
284
  }
274
285
  function failed(prefix, err) {
@@ -445,7 +456,13 @@ export function registerBrandWatch(server, makeClient) {
445
456
  "`term` a watch was created for. Nothing was fetched and nothing was charged.",
446
457
  },
447
458
  ],
448
- structuredContent: { available: false, billable: false, mcpCredits: { cost: 0 } },
459
+ structuredContent: {
460
+ guidance: "This needs a watch to read: pass `watchId` (from list_brand_watches) or the " +
461
+ "`term` a watch was created for. Nothing was fetched and nothing was charged.",
462
+ available: false,
463
+ billable: false,
464
+ mcpCredits: { cost: 0 },
465
+ },
449
466
  };
450
467
  }
451
468
  try {
@@ -453,13 +470,24 @@ export function registerBrandWatch(server, makeClient) {
453
470
  const result = toResult(proxy);
454
471
  const sc = (result.structuredContent ?? {});
455
472
  const runs = Array.isArray(sc.runs) ? sc.runs : [];
456
- // Guidance in the text block, evidence in the structured one — the
457
- // pattern every reading tool here uses. What it has to prevent is a
473
+ // Guidance in BOTH places, not just the text block. A host that
474
+ // renders structuredContent drops every content text block and shows
475
+ // the model the serialised JSON instead, so a text-only copy reaches
476
+ // nobody — measured across 59 real runs. What it has to prevent is a
458
477
  // model reading two points as a trend, and reading the start of the
459
478
  // retention window as the moment a conversation began.
479
+ const guidance = trendGuidance(sc, runs);
480
+ const payload = { guidance, ...sc };
460
481
  return {
461
482
  ...result,
462
- content: [{ type: "text", text: trendGuidance(sc, runs) }],
483
+ // "8 runs of X, newest first" with the runs in the other channel is
484
+ // the same deictic failure as everywhere else (#59): a host that
485
+ // renders the view keeps `structuredContent` for the widget, and the
486
+ // model is left counting runs it cannot read.
487
+ content: [
488
+ { type: "text", text: withEvidence(guidance, payload) },
489
+ ],
490
+ structuredContent: payload,
463
491
  };
464
492
  }
465
493
  catch (err) {
@@ -0,0 +1,269 @@
1
+ /**
2
+ * A compact rendering of a payload's evidence, for the channel that gets the
3
+ * text blocks.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * There are two channels out of a tool call and no host delivers both to the
8
+ * model:
9
+ *
10
+ * | | `content` text blocks | `structuredContent` |
11
+ * |---|---|---|
12
+ * | Claude Code | dropped when `structuredContent` is present | delivered, serialised |
13
+ * | Claude.ai, tool with a UI view | delivered | routed to the widget |
14
+ *
15
+ * #44 found the first column and #51 fixed it, by writing the guidance into
16
+ * both channels. The evidence never got the same treatment, so it survived
17
+ * Claude Code and vanished on Claude.ai — where a real session received
18
+ * "3 posts that might be someone describing ...", instructions to quote lines
19
+ * from them and report their permalinks, and not one post (#59).
20
+ *
21
+ * The tools most affected were the composed ones, which replace the backend's
22
+ * text block with their own guidance. That guidance is deictic — "Here are 4
23
+ * comments", "2 creators", "8 runs" — so it counts and describes material that
24
+ * is somewhere else. "Here are 4 comments" is a false sentence in the channel
25
+ * it is written in.
26
+ *
27
+ * So: each channel has to be self-sufficient. This renders the evidence for
28
+ * the text one.
29
+ *
30
+ * ## What it is not
31
+ *
32
+ * Not the serialised payload. A model asked to judge posts needs the caption,
33
+ * the handle and the link; it does not need `engagementRate` to four decimal
34
+ * places, and 14 KB of JSON in a text block crowds out the guidance that says
35
+ * what to do with it. Each shape below renders the fields its guidance
36
+ * actually names.
37
+ */
38
+ /** Per-item text budget. Long enough for a real Reddit complaint, short enough that twenty fit. */
39
+ const ITEM_CHARS = 600;
40
+ /**
41
+ * Whole-digest budget.
42
+ *
43
+ * `answer_my_audience` can carry 14 KB of payload. The cap is what keeps a
44
+ * text block readable rather than a second serialisation of it — and when it
45
+ * bites, the digest says so, because a model reasoning over 20 of 60 comments
46
+ * has to know it has 20.
47
+ */
48
+ const DIGEST_CHARS = 6000;
49
+ /** How many items of one kind are worth rendering before the count matters more than the detail. */
50
+ const MAX_ITEMS = 25;
51
+ const str = (v) => (typeof v === "string" ? v : "");
52
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
53
+ function clamp(text, max = ITEM_CHARS) {
54
+ const flat = text.replace(/\s+/g, " ").trim();
55
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
56
+ }
57
+ /** `12.3k`, because a raw 12345 beside four other numbers is harder to read than it looks. */
58
+ function compactCount(n) {
59
+ if (n >= 1_000_000)
60
+ return `${(n / 1_000_000).toFixed(1)}M`;
61
+ if (n >= 1_000)
62
+ return `${(n / 1_000).toFixed(1)}k`;
63
+ return String(n);
64
+ }
65
+ function stats(row) {
66
+ const parts = [];
67
+ const views = num(row.views);
68
+ const likes = num(row.likes);
69
+ const comments = num(row.comments);
70
+ if (views)
71
+ parts.push(`${compactCount(views)} views`);
72
+ if (likes)
73
+ parts.push(`${compactCount(likes)} likes`);
74
+ if (comments)
75
+ parts.push(`${compactCount(comments)} comments`);
76
+ return parts.join(", ");
77
+ }
78
+ /**
79
+ * One post, as a line a model can cite.
80
+ *
81
+ * `externalUrl` is not decoration: `find_people_with_problem`'s guidance asks
82
+ * for qualifying posts to be reported "with their permalink, so they can
83
+ * actually be replied to". A digest without links cannot answer that.
84
+ */
85
+ function postLine(row) {
86
+ const head = [
87
+ str(row.platform) || null,
88
+ str(row.creatorHandle) ? `@${str(row.creatorHandle)}` : null,
89
+ str(row.subreddit) || null,
90
+ str(row.foundBy) ? `found by: ${str(row.foundBy)}` : null,
91
+ stats(row) || null,
92
+ ]
93
+ .filter(Boolean)
94
+ .join(" · ");
95
+ const text = clamp(str(row.caption) || str(row.title) || str(row.text));
96
+ const url = str(row.externalUrl) || str(row.url);
97
+ return [head && `[${head}]`, text, url].filter(Boolean).join("\n ");
98
+ }
99
+ /**
100
+ * One comment, with its id.
101
+ *
102
+ * The id is the load-bearing field: `analyze_comments` asks for a
103
+ * classification per comment and `show_comment_review` takes those ids back.
104
+ * A digest that renders the text and drops the id breaks the next call.
105
+ */
106
+ function commentLine(row) {
107
+ const id = str(row.id);
108
+ const who = str(row.author) || str(row.username);
109
+ const likes = num(row.likes);
110
+ const head = [id || null, who ? `@${who}` : null, likes ? `${compactCount(likes)} likes` : null]
111
+ .filter(Boolean)
112
+ .join(" · ");
113
+ return [head && `[${head}]`, clamp(str(row.text) || str(row.comment))].filter(Boolean).join("\n ");
114
+ }
115
+ function creatorLine(row) {
116
+ const head = [
117
+ str(row.username) ? `@${str(row.username)}` : str(row.handle) ? `@${str(row.handle)}` : null,
118
+ str(row.platform) || null,
119
+ num(row.followers) ? `${compactCount(num(row.followers))} followers` : null,
120
+ str(row.foundBy) ? `found by: ${str(row.foundBy)}` : null,
121
+ ]
122
+ .filter(Boolean)
123
+ .join(" · ");
124
+ const why = clamp(str(row.why) || str(row.bio) || str(row.verdict));
125
+ const links = Array.isArray(row.links)
126
+ ? row.links
127
+ .map((l) => str(l.url))
128
+ .filter(Boolean)
129
+ .slice(0, 3)
130
+ .join(" ")
131
+ : "";
132
+ return [head && `[${head}]`, why, links].filter(Boolean).join("\n ");
133
+ }
134
+ function runLine(row) {
135
+ const when = str(row.ranAt).slice(0, 10);
136
+ const found = num(row.found);
137
+ const reported = num(row.reported);
138
+ return `${when || "(undated)"} · found ${found ?? "?"} · reported ${reported ?? "?"}`;
139
+ }
140
+ /** Everything with no shape of its own: the short scalar fields, in order. */
141
+ function genericLine(row) {
142
+ const pairs = Object.entries(row)
143
+ .filter(([, v]) => typeof v === "string" || typeof v === "number" || typeof v === "boolean")
144
+ .map(([k, v]) => `${k}: ${clamp(String(v), 160)}`)
145
+ .slice(0, 8);
146
+ return pairs.join(" · ");
147
+ }
148
+ /**
149
+ * The evidence keys worth rendering, and how.
150
+ *
151
+ * Ordered: when a payload carries several, the first is the one the guidance
152
+ * is most likely to be about.
153
+ */
154
+ const SHAPES = [
155
+ { key: "posts", label: "post", line: postLine },
156
+ // The baseline a post is scored against, and the asks a niche sweep found.
157
+ // Both are posts in all but name, and both are the material their tool's
158
+ // guidance reasons over — `why_did_this_underperform` cannot say "this is an
159
+ // ordinary result" without the distribution it is ordinary against.
160
+ { key: "window", label: "post in the baseline window", line: postLine },
161
+ { key: "demand", label: "thing the audience asked for", line: postLine },
162
+ { key: "supply", label: "post already serving it", line: postLine },
163
+ { key: "comments", label: "comment", line: commentLine },
164
+ { key: "replies", label: "reply", line: commentLine },
165
+ { key: "mentions", label: "mention", line: commentLine },
166
+ { key: "creators", label: "creator", line: creatorLine },
167
+ { key: "candidates", label: "candidate", line: creatorLine },
168
+ { key: "runs", label: "run", line: runLine },
169
+ { key: "cues", label: "line", line: genericLine },
170
+ { key: "segments", label: "segment", line: genericLine },
171
+ { key: "hashtags", label: "hashtag", line: genericLine },
172
+ { key: "sounds", label: "sound", line: genericLine },
173
+ { key: "watches", label: "watch", line: genericLine },
174
+ { key: "apps", label: "app", line: genericLine },
175
+ { key: "connections", label: "connection", line: genericLine },
176
+ { key: "unavailable", label: "network that could not be searched", line: genericLine },
177
+ ];
178
+ /**
179
+ * Render whatever evidence a payload carries, or "" when it carries none.
180
+ *
181
+ * Returning "" rather than a placeholder matters: a tool whose result is a
182
+ * verdict rather than material (`show_*`, a state mutation) should get its
183
+ * guidance and nothing appended.
184
+ */
185
+ /** Fields that identify a row — the ones a caller would cite it by. */
186
+ const IDENTIFYING = [
187
+ "id",
188
+ "externalUrl",
189
+ "url",
190
+ "permalink",
191
+ "caption",
192
+ "text",
193
+ "title",
194
+ "username",
195
+ "handle",
196
+ "creatorHandle",
197
+ "name",
198
+ "term",
199
+ "appId",
200
+ "ranAt",
201
+ "tag",
202
+ ];
203
+ /**
204
+ * Any array of objects the named shapes did not claim.
205
+ *
206
+ * A hand-maintained list of keys is exactly the artefact that drifts — a tool
207
+ * adds `window` or `demand`, nobody updates the list, and the material goes
208
+ * missing again with every test still green. So anything that looks like a row
209
+ * with an identity gets rendered, well or plainly, rather than dropped.
210
+ */
211
+ function unclaimedShapes(payload) {
212
+ const named = new Set(SHAPES.map((s) => s.key));
213
+ return Object.entries(payload)
214
+ .filter(([key, rows]) => {
215
+ if (named.has(key) || !Array.isArray(rows) || rows.length === 0)
216
+ return false;
217
+ const first = rows.find((r) => r && typeof r === "object");
218
+ if (!first)
219
+ return false;
220
+ return IDENTIFYING.some((f) => {
221
+ const v = first[f];
222
+ return typeof v === "string" || typeof v === "number";
223
+ });
224
+ })
225
+ .map(([key]) => ({ key, label: key.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase(), line: genericLine }));
226
+ }
227
+ export function evidenceDigest(payload) {
228
+ const blocks = [];
229
+ let spent = 0;
230
+ for (const shape of [...SHAPES, ...unclaimedShapes(payload)]) {
231
+ const rows = payload[shape.key];
232
+ if (!Array.isArray(rows) || rows.length === 0)
233
+ continue;
234
+ if (spent >= DIGEST_CHARS)
235
+ break;
236
+ const shown = [];
237
+ for (const row of rows.slice(0, MAX_ITEMS)) {
238
+ if (spent >= DIGEST_CHARS)
239
+ break;
240
+ const line = shape.line((row ?? {}));
241
+ if (!line.trim())
242
+ continue;
243
+ shown.push(line);
244
+ spent += line.length;
245
+ }
246
+ if (!shown.length)
247
+ continue;
248
+ // Say what was left out. A model reasoning over 20 of 60 has to know it
249
+ // has 20 — a digest that silently truncates turns "here is everything" into
250
+ // a claim the caller cannot check.
251
+ const total = rows.length;
252
+ const plural = total === 1 || shape.label.endsWith("s") ? shape.label : `${shape.label}s`;
253
+ const header = shown.length < total
254
+ ? `${shown.length} of ${total} ${plural} (the rest are in the structured payload and in the view):`
255
+ : `${total} ${plural}:`;
256
+ blocks.push([header, ...shown.map((l, i) => `${i + 1}. ${l}`)].join("\n"));
257
+ }
258
+ return blocks.join("\n\n");
259
+ }
260
+ /**
261
+ * Guidance and evidence in one text block, in the order a reader needs them.
262
+ *
263
+ * Guidance first: it is the instruction, and a model that reads the material
264
+ * before knowing what it is for reads it twice.
265
+ */
266
+ export function withEvidence(guidance, payload) {
267
+ const digest = evidenceDigest(payload);
268
+ return digest ? `${guidance}\n\n---\n\n${digest}` : guidance;
269
+ }
@@ -115,6 +115,58 @@ export function handleMissGuidance(a) {
115
115
  ];
116
116
  return lines.filter((l, i, all) => !(l === "" && all[i - 1] === "")).join("\n");
117
117
  }
118
+ /**
119
+ * Every network the surface can be asked for, for recognising one in an error.
120
+ *
121
+ * Deliberately a flat list rather than a per-capability one: this is used to
122
+ * spot a platform NAME in a failure message, and a platform that failed is a
123
+ * platform whatever it was capable of.
124
+ */
125
+ export const KNOWN_PLATFORMS = [
126
+ "tiktok",
127
+ "instagram",
128
+ "youtube",
129
+ "douyin",
130
+ "xiaohongshu",
131
+ "twitter",
132
+ "bilibili",
133
+ "linkedin",
134
+ "reddit",
135
+ "weibo",
136
+ ];
137
+ /**
138
+ * What to say when a network ERRORED, as opposed to came back empty.
139
+ *
140
+ * Those want opposite responses and nothing distinguished them. An empty
141
+ * result means try a different query; an error means try a different network,
142
+ * or tell the user. Without that split a host does the one thing that cannot
143
+ * work: re-runs the same call with the words changed.
144
+ *
145
+ * From a real session. Reddit's discovery endpoint started failing, and the
146
+ * model read "The social data service could not complete this request" as a
147
+ * bad query — three more discover_social_posts calls with reworded niches,
148
+ * then a search_mentions, every one a paid upstream call against a network
149
+ * that was never going to answer. The user had asked for "X and Reddit"; they
150
+ * were told about X and never told Reddit had failed at all.
151
+ *
152
+ * So: name the network, say the failure is not about the query, forbid the
153
+ * reword loop explicitly, and say what to do instead. `handleMissGuidance`'s
154
+ * shape, for the other half of the same problem.
155
+ */
156
+ export function platformFailureGuidance(a) {
157
+ return [
158
+ `${a.platform} could not be searched: ${a.message}`,
159
+ "",
160
+ "This is the network failing, not your query. Do NOT call this again with different " +
161
+ "wording, a different niche phrasing, or a different tool against the same network — " +
162
+ "each attempt is another paid upstream call that fails the same way.",
163
+ "",
164
+ `Carry on with any other networks the user asked for, and tell them plainly that ` +
165
+ `${a.platform} could not be searched this time. An answer that silently covers half of ` +
166
+ "what was asked for reads as a complete answer, which is worse than a short one that " +
167
+ "says what is missing.",
168
+ ].join("\n");
169
+ }
118
170
  /** Closing line every guidance block shares. */
119
171
  export const ownIt = "Reason over this yourself rather than asking for an interpretation of it — " +
120
172
  "you can see everything the analysis would have been built from. Everything fetched here — " +
@@ -251,6 +303,21 @@ export const EVIDENCE_PLANS = {
251
303
  `Focus for this pass, as asked: ${focus}. Still cover the profile as a whole, but weight what you cover toward this.`,
252
304
  ]
253
305
  : []),
306
+ "",
307
+ // The redirect has to be here rather than only in the description,
308
+ // because by the time a description could help this tool has already
309
+ // been chosen. Measured: the phrase "track what X has been doing"
310
+ // retrieves analyze_creator_profile and never track_creator —
311
+ // track_creator's name only surfaces when the user says the word
312
+ // "competitor", which is not how most people phrase it. A result
313
+ // naming the other tool is the one channel that reaches a model which
314
+ // has already committed to this one.
315
+ "If what was actually asked for was to TRACK this creator — how they are",
316
+ "doing lately, what they have shipped since last time, keeping an eye on",
317
+ "them — this is the wrong tool and you should call track_creator",
318
+ "instead. It scores each post against this creator's OWN median rather",
319
+ "than handing you raw counts, and it remembers where you left off, so",
320
+ "the next call can say what is new. This one does neither.",
254
321
  ownIt,
255
322
  ].join("\n");
256
323
  },