@nooticr/mcp 1.26.26 → 1.26.28
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 +5 -0
- package/dist/shared/brand-watch.js +192 -0
- package/dist/shared/jobs.js +181 -1
- package/dist/shared/loading-plans.js +23 -0
- package/dist/shared/output-schemas.js +127 -0
- package/dist/shared/standings.js +143 -0
- package/dist/shared/tools-def.js +30 -0
- package/dist/shared/tools.js +127 -1
- package/dist/shared/ui-template.js +98 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,6 +101,7 @@ them caps that fan-out with an argument.
|
|
|
101
101
|
| `catch_up_watchlist` | 2 per creator | What everyone you watch has posted since your last catch-up. Compares against the snapshot taken last time and moves it forward, so it answers "what is new" rather than "what exists". |
|
|
102
102
|
| `create_brand_watch` | free to call | Schedule a recurring sweep and get emailed only what is new. `kind: "mentions"` (default) is a recurring `search_mentions` sweep for `term`/`platforms`; `kind: "competitor"` is a recurring `get_user_posts` check on one creator (`handle`/`platform`), mailing only what beats their own recent median. Two calls by design: the first returns the quote (cost per run, cadence, cost per day) and a `confirmationToken`; nothing is created or charged until you call it again with `confirm: true` and that token. Each run then bills the same as calling the matching tool yourself — 2 credits per network (5 for Xiaohongshu) for mentions, a flat 2 credits for competitor. |
|
|
103
103
|
| `list_brand_watches` | free | Every watch you have scheduled: term, networks, cadence, cost per run, credits spent so far, runs made, and when the next one is due. |
|
|
104
|
+
| `mention_trend` | free | **What a watch has seen over time**, which nothing else here answers — every other read is about now. One point per run: when it ran, how many mentions it found, how many were new, and both counts per network, so "which network is growing" and "did it go quiet or did we stop looking" come apart. On a competitor watch each point carries the median that run measured, which turns "they beat their own median" into "and that median has doubled". Plus the mentions more than one run has seen. Free because the sweeps were billed when they ran; charging again to read what was already paid for is the thing it fixes. |
|
|
104
105
|
| `stop_brand_watch` | free | Stop a watch by `watchId` or `term`. Immediate — the next run does not happen and nothing more is charged. |
|
|
105
106
|
| `niche_report` | 2 | Recent posts in a niche with their stats, so the dominant formats, hook patterns and the gaps nobody fills can be read off them. One `discover_social_posts` call. Use when deciding what to make. |
|
|
106
107
|
| `analyze_creator_profile` | 2 | A creator's recent posts with their stats — the material of a teardown: niche, themes, hook formula, what over- and underperforms, who the audience is. One `get_user_posts` call. |
|
|
@@ -112,7 +113,11 @@ them caps that fan-out with an argument.
|
|
|
112
113
|
| `answer_my_audience` | 2 + 2 per post opened (14 by default) | **The mirror of `search_mentions`.** The questions waiting under your *own* posts: recent posts fetched, comments read on each, grouped under the post they were left on, every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. It finds and drafts — it cannot post a reply, because no nooticr connection carries comment-write permission on any network. `limit` caps how many posts are opened, which is the price. |
|
|
113
114
|
| `prepare_handoff` | free | **The hand-off to your tracker.** Turns items you classified — a bug report in a comment, a complaint said out loud in a video — into the exact `title`, `body` and `labels` to file through whichever GitHub, Jira or Linear MCP server the host also has connected. nooticr files nothing and holds no tracker credential. The body carries the quote fenced and framed as a third-party report rather than as instructions, because the issue is read next by a coding agent; contact details are redacted, `@handles` and `#numbers` are defanged so filing notifies nobody by accident, and a `searchFirst` string dedupes a second sweep against the first. Makes no requests. |
|
|
114
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
|
+
| `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
|
+
| `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. |
|
|
115
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. |
|
|
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
|
+
| `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**. |
|
|
116
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. |
|
|
117
122
|
| `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. |
|
|
118
123
|
| `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. |
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { OUTPUT_SCHEMAS } from "./output-schemas.js";
|
|
17
|
+
import { viewMeta } from "./view-meta.js";
|
|
17
18
|
import { BACKEND_CALL_CREDITS, confirmSpend, declinedResult, searchMentionsCost } from "./spend.js";
|
|
18
19
|
/** Exported so prompts.ts's completion offers exactly what create_brand_watch accepts. */
|
|
19
20
|
export const CADENCES = ["hourly", "every_6_hours", "every_12_hours", "daily", "weekly"];
|
|
@@ -136,6 +137,133 @@ async function gateRecurringCharge(server, args) {
|
|
|
136
137
|
return null;
|
|
137
138
|
}
|
|
138
139
|
/** Same shaping every other proxied tool uses: text block plus structured payload. */
|
|
140
|
+
/**
|
|
141
|
+
* What to do with a run series.
|
|
142
|
+
*
|
|
143
|
+
* Three ways a reader gets this wrong, and all three produce a confident
|
|
144
|
+
* sentence about a change that did not happen:
|
|
145
|
+
*
|
|
146
|
+
* - Two points are not a trend. A watch that has run twice can only say what
|
|
147
|
+
* the second run found, and the difference between two sweeps of scraped
|
|
148
|
+
* data is mostly the scrape.
|
|
149
|
+
* - The start of the window is not the start of the conversation. It is the
|
|
150
|
+
* edge of what is kept, or the day the watch was created — a flat left
|
|
151
|
+
* edge reads as silence and is neither.
|
|
152
|
+
* - Volume found and volume mailed answer different questions. A run that
|
|
153
|
+
* found forty and mailed none is a conversation that has stopped moving,
|
|
154
|
+
* not one that has stopped.
|
|
155
|
+
*/
|
|
156
|
+
/**
|
|
157
|
+
* Which networks moved, and which way — computed, not left to the reader.
|
|
158
|
+
*
|
|
159
|
+
* `runs` comes back newest-first, which is the order it is read in and the
|
|
160
|
+
* wrong order to subtract in. Naming the direction here removes the one step
|
|
161
|
+
* where an off-by-one-end error produces a confident sentence about a change
|
|
162
|
+
* that went the other way.
|
|
163
|
+
*/
|
|
164
|
+
function perPlatformDirection(runs) {
|
|
165
|
+
const at = (r) => String(r.ranAt ?? "");
|
|
166
|
+
const chronological = [...runs].sort((a, b) => (at(a) < at(b) ? -1 : 1));
|
|
167
|
+
const foundIn = (r, platform) => {
|
|
168
|
+
const per = (r?.perPlatform ?? {});
|
|
169
|
+
const v = per[platform]?.found;
|
|
170
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
171
|
+
};
|
|
172
|
+
const first = chronological[0];
|
|
173
|
+
const last = chronological[chronological.length - 1];
|
|
174
|
+
const platforms = new Set();
|
|
175
|
+
for (const r of chronological) {
|
|
176
|
+
for (const k of Object.keys((r.perPlatform ?? {})))
|
|
177
|
+
platforms.add(k);
|
|
178
|
+
}
|
|
179
|
+
const moves = [];
|
|
180
|
+
for (const platform of platforms) {
|
|
181
|
+
const from = foundIn(first, platform);
|
|
182
|
+
const to = foundIn(last, platform);
|
|
183
|
+
if (from === null || to === null || from === to)
|
|
184
|
+
continue;
|
|
185
|
+
moves.push({ platform, from, to, delta: to - from });
|
|
186
|
+
}
|
|
187
|
+
if (!moves.length) {
|
|
188
|
+
return ("No network's count differs between the earliest and latest sweep in this window, so " +
|
|
189
|
+
"whatever moved, moved inside it rather than across it — look at the points themselves.");
|
|
190
|
+
}
|
|
191
|
+
moves.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
|
|
192
|
+
const said = moves
|
|
193
|
+
.slice(0, 4)
|
|
194
|
+
.map((m) => `${m.platform} ${m.delta > 0 ? "rose" : "fell"} from ${m.from} to ${m.to} ` +
|
|
195
|
+
`(${m.delta > 0 ? "+" : ""}${m.delta})`)
|
|
196
|
+
.join(", ");
|
|
197
|
+
return (`Earliest to latest sweep in this window: ${said}. Those directions are computed here on ` +
|
|
198
|
+
"purpose — `runs` is newest-first, so subtracting the ends by hand is where a real reading " +
|
|
199
|
+
"of this went backwards. Use these rather than re-deriving them.");
|
|
200
|
+
}
|
|
201
|
+
function trendGuidance(sc, runs) {
|
|
202
|
+
const n = runs.length;
|
|
203
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
204
|
+
const term = typeof sc.term === "string" && sc.term ? `"${sc.term}"` : "this watch";
|
|
205
|
+
const kind = sc.kind === "competitor" ? "competitor" : "mentions";
|
|
206
|
+
const lines = [];
|
|
207
|
+
if (!n) {
|
|
208
|
+
lines.push(`No runs recorded for ${term} in that window. A watch records a point every time it ` +
|
|
209
|
+
"sweeps, so this means it has not run yet rather than that nobody has said anything — " +
|
|
210
|
+
"say which, and check `nextRun` on list_brand_watches. Nothing was charged.");
|
|
211
|
+
return lines.join("\n");
|
|
212
|
+
}
|
|
213
|
+
const windowDays = num(sc.windowDays);
|
|
214
|
+
lines.push(`${n} run${n === 1 ? "" : "s"} of ${term}` +
|
|
215
|
+
(windowDays ? ` in the last ${windowDays} days` : "") +
|
|
216
|
+
`, newest first. Each point is one sweep: \`found\` is everything it saw, \`reported\` is ` +
|
|
217
|
+
"the subset that was new, and `perPlatform` splits both by network.");
|
|
218
|
+
if (n < 3) {
|
|
219
|
+
lines.push("", `Two points are not a trend, and ${n} ${n === 1 ? "is" : "are"} what you have. Report what ` +
|
|
220
|
+
"the runs found and say the series is too short to call a direction — the difference " +
|
|
221
|
+
"between two sweeps of scraped data is mostly the scrape.");
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
lines.push("", "Read it yourself: the shape of `found` over time is the volume question, and `perPlatform` " +
|
|
225
|
+
"is the one underneath it — a total that holds steady while one network doubles and " +
|
|
226
|
+
"another empties is the finding, and the total hides it.", "",
|
|
227
|
+
// Stated rather than left to be derived, because deriving it means
|
|
228
|
+
// knowing which end of the array is which — and `runs` is newest-first.
|
|
229
|
+
// Reading it by hand got the direction exactly backwards on the first
|
|
230
|
+
// attempt ("TikTok fell from 5 to 12"), which is a mistake a model makes
|
|
231
|
+
// just as easily and reports with the same confidence.
|
|
232
|
+
perPlatformDirection(runs), "", "`found` and `reported` are different questions and usually move differently. Flat `found` " +
|
|
233
|
+
"with falling `reported` is a conversation that has stopped moving rather than stopped. A " +
|
|
234
|
+
"spike in `found` that is almost all already-seen is one post being re-surfaced by a " +
|
|
235
|
+
"platform, not new interest.");
|
|
236
|
+
}
|
|
237
|
+
if (kind === "competitor") {
|
|
238
|
+
const newest = num(sc.medianViews?.newest);
|
|
239
|
+
const oldest = num(sc.medianViews?.oldest);
|
|
240
|
+
lines.push("", "This is a competitor watch, so each point carries `medianViews` — the baseline that run " +
|
|
241
|
+
"measured. That is the second axis: a creator can beat their own median every week while " +
|
|
242
|
+
"the median itself is falling, which is a shrinking account having good weeks." +
|
|
243
|
+
(newest !== null && oldest !== null
|
|
244
|
+
? ` Here it went from ${oldest.toLocaleString("en-US")} to ${newest.toLocaleString("en-US")}.`
|
|
245
|
+
: ""));
|
|
246
|
+
}
|
|
247
|
+
const retained = num(sc.retainedDays);
|
|
248
|
+
const created = typeof sc.watchCreatedAt === "string" ? sc.watchCreatedAt : null;
|
|
249
|
+
lines.push("", "Do not read the left edge as the start of anything." +
|
|
250
|
+
(retained ? ` Runs are kept for ${retained} days.` : "") +
|
|
251
|
+
(created ? ` This watch was created ${created.slice(0, 10)}.` : "") +
|
|
252
|
+
" A series that starts flat starts where the record does, which is not the same as a " +
|
|
253
|
+
"period when nobody was talking.");
|
|
254
|
+
const recurring = Array.isArray(sc.recurring) ? sc.recurring : [];
|
|
255
|
+
if (recurring.length) {
|
|
256
|
+
lines.push("", `\`recurring\` lists the ${recurring.length} mention${recurring.length === 1 ? "" : "s"} more ` +
|
|
257
|
+
"than one run has seen, with how many runs saw each. These are fingerprints, not text: " +
|
|
258
|
+
"the mentions themselves are other people's writing and are deliberately not stored. A " +
|
|
259
|
+
"high `timesSeen` is a post that keeps resurfacing — worth naming as one thing rather " +
|
|
260
|
+
"than as repeated new interest.");
|
|
261
|
+
}
|
|
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.");
|
|
265
|
+
return lines.join("\n");
|
|
266
|
+
}
|
|
139
267
|
function toResult(proxy) {
|
|
140
268
|
const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
|
|
141
269
|
return {
|
|
@@ -274,6 +402,70 @@ export function registerBrandWatch(server, makeClient) {
|
|
|
274
402
|
return failed("list_brand_watches failed", err);
|
|
275
403
|
}
|
|
276
404
|
});
|
|
405
|
+
server.registerTool("mention_trend", {
|
|
406
|
+
title: "Mention Trend",
|
|
407
|
+
_meta: viewMeta("mention_trend"),
|
|
408
|
+
description: "What a watch has SEEN OVER TIME, which no other tool here can answer — every read on " +
|
|
409
|
+
"this surface answers about now, and until the backend started keeping run history each " +
|
|
410
|
+
"sweep threw its numbers away. Takes `watchId` (or `term`) and an optional `days`. " +
|
|
411
|
+
"Returns one point per run: when it ran, how many mentions it found, how many were new, " +
|
|
412
|
+
"and both counts per network — so \"which network is growing\" and \"did it go quiet, or " +
|
|
413
|
+
"did we stop looking\" are separable. On a competitor watch each point also carries the " +
|
|
414
|
+
"median that run measured, which is what turns \"they beat their own median\" into \"and " +
|
|
415
|
+
"that median has doubled\". Also returns the mentions more than one run has seen. " +
|
|
416
|
+
"FREE: the sweeps were billed when they ran, and charging again to read what was already " +
|
|
417
|
+
"paid for is the thing this fixes. " +
|
|
418
|
+
"Read the series yourself and say what changed — this returns numbers, not a verdict.",
|
|
419
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
420
|
+
inputSchema: z
|
|
421
|
+
.object({
|
|
422
|
+
watchId: z.string().optional().describe("The watch id from list_brand_watches."),
|
|
423
|
+
term: z
|
|
424
|
+
.string()
|
|
425
|
+
.optional()
|
|
426
|
+
.describe("The watched term, if you do not have the id. One of the two is required."),
|
|
427
|
+
days: z
|
|
428
|
+
.number()
|
|
429
|
+
.int()
|
|
430
|
+
.optional()
|
|
431
|
+
.describe("Window in days (default 90). Clamped to how long the backend keeps runs, which " +
|
|
432
|
+
"comes back as `retainedDays` — asking for more silently returns less, and a " +
|
|
433
|
+
"flat start would read as silence rather than as the edge of the record."),
|
|
434
|
+
})
|
|
435
|
+
.strict(),
|
|
436
|
+
outputSchema: OUTPUT_SCHEMAS.mention_trend,
|
|
437
|
+
}, async (args, extra) => {
|
|
438
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
439
|
+
if (!args.watchId && !args.term?.trim()) {
|
|
440
|
+
return {
|
|
441
|
+
content: [
|
|
442
|
+
{
|
|
443
|
+
type: "text",
|
|
444
|
+
text: "This needs a watch to read: pass `watchId` (from list_brand_watches) or the " +
|
|
445
|
+
"`term` a watch was created for. Nothing was fetched and nothing was charged.",
|
|
446
|
+
},
|
|
447
|
+
],
|
|
448
|
+
structuredContent: { available: false, billable: false, mcpCredits: { cost: 0 } },
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
const proxy = await client.callTool("brand_watch_history", { ...args });
|
|
453
|
+
const result = toResult(proxy);
|
|
454
|
+
const sc = (result.structuredContent ?? {});
|
|
455
|
+
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
|
|
458
|
+
// model reading two points as a trend, and reading the start of the
|
|
459
|
+
// retention window as the moment a conversation began.
|
|
460
|
+
return {
|
|
461
|
+
...result,
|
|
462
|
+
content: [{ type: "text", text: trendGuidance(sc, runs) }],
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
return failed("mention_trend failed", err);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
277
469
|
server.registerTool("stop_brand_watch", {
|
|
278
470
|
title: "Stop Brand Watch",
|
|
279
471
|
description: "Stop a scheduled brand-monitoring watch, by watchId or by term. Takes effect immediately: " +
|
package/dist/shared/jobs.js
CHANGED
|
@@ -57,9 +57,10 @@ import { z } from "zod";
|
|
|
57
57
|
import { OUTPUT_SCHEMAS } from "./output-schemas.js";
|
|
58
58
|
import { platformFromUrl, postSlug } from "./comment-review.js";
|
|
59
59
|
import { clamp, handleMissGuidance, ownIt, PLATFORM_ARG } from "./evidence.js";
|
|
60
|
-
import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, SPOKEN_PLATFORMS, } from "./spend.js";
|
|
60
|
+
import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, SPOKEN_PLATFORMS, CREDITS_PER_CREATOR, } from "./spend.js";
|
|
61
61
|
import { distributionOf, excluding, numberOf, standing, } from "./performance.js";
|
|
62
62
|
import { normaliseHandle, watchEntryId, watchlistOwner } from "./watchlist.js";
|
|
63
|
+
import { creatorStanding, standingsGuidance, THIN_WINDOW, ABOVE_RATIO, } from "./standings.js";
|
|
63
64
|
import { viewMeta } from "./view-meta.js";
|
|
64
65
|
import { extractLinks, COLLAB_RUBRIC, vettingGuidance } from "./collab.js";
|
|
65
66
|
const rowsOf = (value) => Array.isArray(value) ? value.filter((r) => r && typeof r === "object") : [];
|
|
@@ -964,6 +965,185 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
964
965
|
});
|
|
965
966
|
});
|
|
966
967
|
// ───────────────────────────────────────────────────────────────────────────
|
|
968
|
+
// 2b. compare_creators / watchlist_standings
|
|
969
|
+
//
|
|
970
|
+
// The comparison track_competitor computes and discards. Both are pure
|
|
971
|
+
// compositions of get_user_posts — one fetch per creator, priced as the sum
|
|
972
|
+
// — and both leave the reading to the caller, because a ranking hides the
|
|
973
|
+
// window size that decides whether the ranking means anything.
|
|
974
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
975
|
+
/** One creator's window, fetched and scored. Shared by both tools below. */
|
|
976
|
+
const standingFor = async (client, spend, handle, platform, metric, cap) => {
|
|
977
|
+
try {
|
|
978
|
+
const res = await client.callTool("get_user_posts", { username: handle, platform, limit: cap });
|
|
979
|
+
const structured = structuredOf(res);
|
|
980
|
+
spend.record("get_user_posts", structured);
|
|
981
|
+
const posts = rowsOf(structured.posts).map((p, i) => ({
|
|
982
|
+
...p,
|
|
983
|
+
postId: postIdOf(p, i),
|
|
984
|
+
postedAt: postDate(p),
|
|
985
|
+
}));
|
|
986
|
+
return creatorStanding(handle, platform, posts, metric, (post) => metricOf(post, metric));
|
|
987
|
+
}
|
|
988
|
+
catch (err) {
|
|
989
|
+
// One creator failing must not lose the others: a comparison of three is
|
|
990
|
+
// still worth reading, and saying which one is missing is the honest
|
|
991
|
+
// version of an answer with a hole in it.
|
|
992
|
+
spend.attempted("get_user_posts");
|
|
993
|
+
return {
|
|
994
|
+
handle,
|
|
995
|
+
platform,
|
|
996
|
+
window: 0,
|
|
997
|
+
baseline: null,
|
|
998
|
+
hitRate: null,
|
|
999
|
+
medianWinRatio: null,
|
|
1000
|
+
ratios: [],
|
|
1001
|
+
best: null,
|
|
1002
|
+
worst: null,
|
|
1003
|
+
unavailable: reason(err),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
server.registerTool("compare_creators", {
|
|
1008
|
+
title: "Compare Creators",
|
|
1009
|
+
_meta: viewMeta("compare_creators"),
|
|
1010
|
+
description: "Two to five creators side by side, each scored against THEIR OWN recent median rather " +
|
|
1011
|
+
"than against each other's raw numbers — which is the only way the comparison means " +
|
|
1012
|
+
"anything, because a raw view count mostly measures follower count. Returns per creator: " +
|
|
1013
|
+
"how many posts were scored, their own median, the share of the window that beat it, how " +
|
|
1014
|
+
"hard they beat it when they did, every post's ratio, and their best and worst post. The " +
|
|
1015
|
+
"ranking is yours to make: 'how often' and 'how big' usually disagree, and a hit rate " +
|
|
1016
|
+
"over a short window is one post either way, so the window size travels with every " +
|
|
1017
|
+
"number. Fetches one post list per creator: 2 nooticr credits each, 4-10 in total. " +
|
|
1018
|
+
"Use for 'is their hit rate better than mine'; track_competitor is one creator alone.",
|
|
1019
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1020
|
+
outputSchema: OUTPUT_SCHEMAS.compare_creators,
|
|
1021
|
+
inputSchema: z
|
|
1022
|
+
.object({
|
|
1023
|
+
usernames: z
|
|
1024
|
+
.array(z.string())
|
|
1025
|
+
.min(2)
|
|
1026
|
+
.max(5)
|
|
1027
|
+
.describe("2-5 creator handles, with or without @. Include your own to benchmark against " +
|
|
1028
|
+
"them. These are handles, not brand names — resolve a company name first."),
|
|
1029
|
+
platform: z.string().optional().describe(PLATFORM_ARG),
|
|
1030
|
+
limit: z
|
|
1031
|
+
.number()
|
|
1032
|
+
.int()
|
|
1033
|
+
.optional()
|
|
1034
|
+
.describe("Posts per creator (default 12, max 30). One fetch each either way."),
|
|
1035
|
+
metric: metricArg,
|
|
1036
|
+
})
|
|
1037
|
+
.strict(),
|
|
1038
|
+
}, async (args, extra) => {
|
|
1039
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
1040
|
+
const platform = (args.platform || "tiktok").toLowerCase();
|
|
1041
|
+
const cap = clamp(args.limit, 12, 1, 30);
|
|
1042
|
+
const metric = args.metric ?? "views";
|
|
1043
|
+
// Deduped, because comparing a creator against themselves is two paid
|
|
1044
|
+
// fetches for one column.
|
|
1045
|
+
const handles = [...new Set(args.usernames.map(normaliseHandle))].filter(Boolean);
|
|
1046
|
+
if (handles.length < 2) {
|
|
1047
|
+
return evidence("A comparison needs two different creators. After removing duplicates only " +
|
|
1048
|
+
`${handles.length} handle${handles.length === 1 ? "" : "s"} remained, so nothing was ` +
|
|
1049
|
+
"fetched and nothing was charged.", { tool: "compare_creators", creators: [], billable: false, mcpCredits: { cost: 0 } });
|
|
1050
|
+
}
|
|
1051
|
+
const credits = costOf(handles.map(() => "get_user_posts"));
|
|
1052
|
+
const decision = await confirmSpend(server.server, {
|
|
1053
|
+
credits,
|
|
1054
|
+
summary: `Read the recent posts of ${handles.map((h) => "@" + h).join(", ")} on ${platform}.`,
|
|
1055
|
+
cheaper: "Compare fewer creators — each one is a separate fetch.",
|
|
1056
|
+
});
|
|
1057
|
+
if (!decision.proceed) {
|
|
1058
|
+
return declinedResult(credits, "That comparison", "Compare fewer creators.");
|
|
1059
|
+
}
|
|
1060
|
+
const spend = new Spend();
|
|
1061
|
+
const rows = [];
|
|
1062
|
+
for (const handle of handles) {
|
|
1063
|
+
rows.push(await standingFor(client, spend, handle, platform, metric, cap));
|
|
1064
|
+
}
|
|
1065
|
+
return evidence(standingsGuidance({ rows, metric, source: "handles" }) + "\n\n" + ownIt, {
|
|
1066
|
+
mode: "evidence",
|
|
1067
|
+
tool: "compare_creators",
|
|
1068
|
+
evidenceFrom: ["get_user_posts"],
|
|
1069
|
+
platform,
|
|
1070
|
+
platformDefaulted: !args.platform,
|
|
1071
|
+
metric,
|
|
1072
|
+
thinWindow: THIN_WINDOW,
|
|
1073
|
+
aboveRatio: ABOVE_RATIO,
|
|
1074
|
+
creators: rows,
|
|
1075
|
+
creditsCharged: spend.credits,
|
|
1076
|
+
mcpCredits: spend.payload,
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
server.registerTool("watchlist_standings", {
|
|
1080
|
+
title: "Watchlist Standings",
|
|
1081
|
+
_meta: viewMeta("watchlist_standings"),
|
|
1082
|
+
description: "The same comparison across everyone on your watchlist, answering 'who moved' in one " +
|
|
1083
|
+
"call instead of one per creator. Each is scored against their own recent median, so the " +
|
|
1084
|
+
"numbers are comparable between accounts of different sizes; the window size travels " +
|
|
1085
|
+
"with each so a short one is not ranked as if it were a season. Reads the watchlist " +
|
|
1086
|
+
"itself for free and fetches one post list per creator on it: 2 nooticr credits each, so " +
|
|
1087
|
+
"the price is the size of your watchlist — it confirms before spending. Use for the " +
|
|
1088
|
+
"weekly 'who is accelerating'; catch_up_watchlist is what is NEW rather than how it did.",
|
|
1089
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1090
|
+
outputSchema: OUTPUT_SCHEMAS.watchlist_standings,
|
|
1091
|
+
inputSchema: z
|
|
1092
|
+
.object({
|
|
1093
|
+
limit: z
|
|
1094
|
+
.number()
|
|
1095
|
+
.int()
|
|
1096
|
+
.optional()
|
|
1097
|
+
.describe("Posts per creator (default 12, max 30). One fetch each either way."),
|
|
1098
|
+
metric: metricArg,
|
|
1099
|
+
})
|
|
1100
|
+
.strict(),
|
|
1101
|
+
}, async (args, extra) => {
|
|
1102
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
1103
|
+
const cap = clamp(args.limit, 12, 1, 30);
|
|
1104
|
+
const metric = args.metric ?? "views";
|
|
1105
|
+
let entries = [];
|
|
1106
|
+
try {
|
|
1107
|
+
entries = await store.list(await watchlistOwner(client));
|
|
1108
|
+
}
|
|
1109
|
+
catch (err) {
|
|
1110
|
+
return failed("watchlist_standings could not read your watchlist", err);
|
|
1111
|
+
}
|
|
1112
|
+
if (!entries.length) {
|
|
1113
|
+
// Not an error and not billed: an empty watchlist is a state with a
|
|
1114
|
+
// next step, and saying so beats a comparison of nobody.
|
|
1115
|
+
return evidence("Your watchlist is empty, so there is nobody to compare. Add creators with " +
|
|
1116
|
+
"watch_creator first — the standings are then one call. Nothing was charged.", { tool: "watchlist_standings", watching: 0, creators: [], billable: false, mcpCredits: { cost: 0 } });
|
|
1117
|
+
}
|
|
1118
|
+
const credits = entries.length * CREDITS_PER_CREATOR;
|
|
1119
|
+
const decision = await confirmSpend(server.server, {
|
|
1120
|
+
credits,
|
|
1121
|
+
summary: `Read the recent posts of all ${entries.length} creator${entries.length === 1 ? "" : "s"} ` +
|
|
1122
|
+
"on your watchlist and score each against their own median.",
|
|
1123
|
+
cheaper: "Unwatch creators you no longer follow, or use track_competitor for just one.",
|
|
1124
|
+
});
|
|
1125
|
+
if (!decision.proceed) {
|
|
1126
|
+
return declinedResult(credits, "Those standings", "Use track_competitor for a single creator.");
|
|
1127
|
+
}
|
|
1128
|
+
const spend = new Spend();
|
|
1129
|
+
const rows = [];
|
|
1130
|
+
for (const entry of entries) {
|
|
1131
|
+
rows.push(await standingFor(client, spend, entry.handle, entry.platform, metric, cap));
|
|
1132
|
+
}
|
|
1133
|
+
return evidence(standingsGuidance({ rows, metric, source: "watchlist" }) + "\n\n" + ownIt, {
|
|
1134
|
+
mode: "evidence",
|
|
1135
|
+
tool: "watchlist_standings",
|
|
1136
|
+
evidenceFrom: ["get_user_posts"],
|
|
1137
|
+
metric,
|
|
1138
|
+
watching: entries.length,
|
|
1139
|
+
thinWindow: THIN_WINDOW,
|
|
1140
|
+
aboveRatio: ABOVE_RATIO,
|
|
1141
|
+
creators: rows,
|
|
1142
|
+
creditsCharged: spend.credits,
|
|
1143
|
+
mcpCredits: spend.payload,
|
|
1144
|
+
});
|
|
1145
|
+
});
|
|
1146
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
967
1147
|
// 3. who_should_i_work_with
|
|
968
1148
|
// ───────────────────────────────────────────────────────────────────────────
|
|
969
1149
|
server.registerTool("who_should_i_work_with", {
|
|
@@ -249,6 +249,24 @@ export const LOADING_PLANS = {
|
|
|
249
249
|
n: 3,
|
|
250
250
|
steps: [step("get_user_posts")],
|
|
251
251
|
},
|
|
252
|
+
// Both fan out one post-list fetch per creator, so the price is the size of
|
|
253
|
+
// the list and the wait is drawn per unit — same shape as catch_up_watchlist.
|
|
254
|
+
compare_creators: {
|
|
255
|
+
label: "Scoring each against their own median",
|
|
256
|
+
kind: "list",
|
|
257
|
+
n: 3,
|
|
258
|
+
steps: [],
|
|
259
|
+
perUnit: [CREATOR_UNIT],
|
|
260
|
+
note: "Priced per creator compared.",
|
|
261
|
+
},
|
|
262
|
+
watchlist_standings: {
|
|
263
|
+
label: "Reading the whole watchlist",
|
|
264
|
+
kind: "list",
|
|
265
|
+
n: 5,
|
|
266
|
+
steps: [],
|
|
267
|
+
perUnit: [CREATOR_UNIT],
|
|
268
|
+
note: "Priced per creator on the watchlist.",
|
|
269
|
+
},
|
|
252
270
|
who_should_i_work_with: {
|
|
253
271
|
label: "Building a shortlist",
|
|
254
272
|
kind: "list",
|
|
@@ -299,6 +317,11 @@ export const LOADING_PLANS = {
|
|
|
299
317
|
show_variants: { label: "Laying out the variants", kind: "list", n: 4, steps: [], free: true },
|
|
300
318
|
show_repurposed_post: { label: "Laying out the rewrites", kind: "text", n: 1, steps: [], free: true },
|
|
301
319
|
show_collab_shortlist: { label: "Drawing the shortlist", kind: "list", n: 5, steps: [], free: true },
|
|
320
|
+
show_standings: { label: "Drawing the standings", kind: "list", n: 4, steps: [], free: true },
|
|
321
|
+
show_trend: { label: "Drawing the trend", kind: "text", n: 1, steps: [], free: true },
|
|
322
|
+
// Reads stored aggregates the sweeps already paid for. Free, and no
|
|
323
|
+
// upstream step to wait on — the wait is a database read.
|
|
324
|
+
mention_trend: { label: "Reading the run history", kind: "text", n: 1, steps: [], free: true },
|
|
302
325
|
prepare_handoff: { label: "Packaging the handoff", kind: "list", n: 4, steps: [], free: true },
|
|
303
326
|
// ─── Own-account reads: nooticr's own stored rows ───
|
|
304
327
|
get_scheduled_posts: { label: "Reading your pipeline", kind: "list", n: 5, steps: [], free: true },
|
|
@@ -726,6 +726,84 @@ export const OUTPUT_SCHEMAS = {
|
|
|
726
726
|
days: scalar(),
|
|
727
727
|
mcpCredits,
|
|
728
728
|
}),
|
|
729
|
+
/**
|
|
730
|
+
* One row per creator, on the axis every creator shares. `window` sits
|
|
731
|
+
* beside every derived number on purpose — a hit rate whose denominator is
|
|
732
|
+
* invisible cannot be told apart from a hit rate that means something.
|
|
733
|
+
*/
|
|
734
|
+
compare_creators: open({
|
|
735
|
+
creators: listOf(open({
|
|
736
|
+
handle: scalar(),
|
|
737
|
+
platform: scalar(),
|
|
738
|
+
window: scalar().describe("Posts scored — the denominator of hitRate and the baseline."),
|
|
739
|
+
baseline: open({
|
|
740
|
+
count: scalar(),
|
|
741
|
+
median: scalar(),
|
|
742
|
+
min: scalar(),
|
|
743
|
+
max: scalar(),
|
|
744
|
+
p25: scalar(),
|
|
745
|
+
p75: scalar(),
|
|
746
|
+
}).nullish(),
|
|
747
|
+
hitRate: scalar().describe("Share of the window at or above aboveRatio x their own median."),
|
|
748
|
+
medianWinRatio: scalar().describe("How hard they beat their median when they did."),
|
|
749
|
+
ratios: listOf(z.number()).describe("Every post's ratio to their own median, ascending."),
|
|
750
|
+
best: post.nullish(),
|
|
751
|
+
worst: post.nullish(),
|
|
752
|
+
unavailable: scalar().describe("Set when this creator could not be scored, and why."),
|
|
753
|
+
})).optional(),
|
|
754
|
+
platform: scalar(),
|
|
755
|
+
platformDefaulted: scalar(),
|
|
756
|
+
metric: scalar(),
|
|
757
|
+
thinWindow: scalar().describe("Below this many posts, a hit rate is one post either way."),
|
|
758
|
+
aboveRatio: scalar().describe("The ratio hitRate counts from."),
|
|
759
|
+
billable: scalar(),
|
|
760
|
+
mode: scalar(),
|
|
761
|
+
tool: scalar(),
|
|
762
|
+
evidenceFrom: listOf(z.string()),
|
|
763
|
+
creditsCharged: scalar(),
|
|
764
|
+
mcpCredits,
|
|
765
|
+
}),
|
|
766
|
+
show_standings: open({
|
|
767
|
+
creators: listOf(anyObject()),
|
|
768
|
+
metric: scalar(),
|
|
769
|
+
ranking: scalar().describe("Which axis the order is on — 'how often' and 'how big' disagree."),
|
|
770
|
+
verdict: scalar(),
|
|
771
|
+
tooThin: listOf(z.string()).describe("Handles drawn unranked rather than bottom."),
|
|
772
|
+
// The discriminator the view keys on, since `creators` alone is ambiguous
|
|
773
|
+
// with the two fetching tools' payloads.
|
|
774
|
+
standings: scalar(),
|
|
775
|
+
}),
|
|
776
|
+
watchlist_standings: open({
|
|
777
|
+
creators: listOf(open({
|
|
778
|
+
handle: scalar(),
|
|
779
|
+
platform: scalar(),
|
|
780
|
+
window: scalar(),
|
|
781
|
+
baseline: open({
|
|
782
|
+
count: scalar(),
|
|
783
|
+
median: scalar(),
|
|
784
|
+
min: scalar(),
|
|
785
|
+
max: scalar(),
|
|
786
|
+
p25: scalar(),
|
|
787
|
+
p75: scalar(),
|
|
788
|
+
}).nullish(),
|
|
789
|
+
hitRate: scalar(),
|
|
790
|
+
medianWinRatio: scalar(),
|
|
791
|
+
ratios: listOf(z.number()),
|
|
792
|
+
best: post.nullish(),
|
|
793
|
+
worst: post.nullish(),
|
|
794
|
+
unavailable: scalar(),
|
|
795
|
+
})).optional(),
|
|
796
|
+
watching: scalar().describe("How many creators are on the watchlist."),
|
|
797
|
+
metric: scalar(),
|
|
798
|
+
thinWindow: scalar(),
|
|
799
|
+
aboveRatio: scalar(),
|
|
800
|
+
billable: scalar(),
|
|
801
|
+
mode: scalar(),
|
|
802
|
+
tool: scalar(),
|
|
803
|
+
evidenceFrom: listOf(z.string()),
|
|
804
|
+
creditsCharged: scalar(),
|
|
805
|
+
mcpCredits,
|
|
806
|
+
}),
|
|
729
807
|
discover_sounds: open({
|
|
730
808
|
sounds: listOf(open({
|
|
731
809
|
title: scalar(),
|
|
@@ -842,6 +920,55 @@ export const OUTPUT_SCHEMAS = {
|
|
|
842
920
|
firstRun: scalar(),
|
|
843
921
|
message: scalar(),
|
|
844
922
|
}),
|
|
923
|
+
/**
|
|
924
|
+
* A run series. `runs` is newest first, and every derived number the caller
|
|
925
|
+
* might want is left underived on purpose — the model reads the series.
|
|
926
|
+
*/
|
|
927
|
+
show_trend: open({
|
|
928
|
+
points: listOf(anyObject()),
|
|
929
|
+
term: scalar(),
|
|
930
|
+
metric: scalar(),
|
|
931
|
+
verdict: scalar(),
|
|
932
|
+
// Both caveats are fields rather than prose, so the view can draw them
|
|
933
|
+
// and cannot quietly omit them.
|
|
934
|
+
tooShort: scalar().describe("Too few points to call a direction."),
|
|
935
|
+
edgeIsRecordStart: scalar().describe("The left edge is the record's start, not the conversation's."),
|
|
936
|
+
trend: scalar().describe("The discriminator the view keys on."),
|
|
937
|
+
}),
|
|
938
|
+
mention_trend: open({
|
|
939
|
+
runs: listOf(open({
|
|
940
|
+
ranAt: scalar(),
|
|
941
|
+
found: scalar().describe("Everything that sweep saw."),
|
|
942
|
+
reported: scalar().describe("The subset that was new — a different question from found."),
|
|
943
|
+
perPlatform: anyObject().nullish().describe('{"tiktok":{"found":12,"reported":3},...}'),
|
|
944
|
+
medianViews: scalar().describe("Competitor watches only: the baseline that run measured."),
|
|
945
|
+
postsScored: scalar(),
|
|
946
|
+
costCredits: scalar(),
|
|
947
|
+
})).optional(),
|
|
948
|
+
watchId: scalar(),
|
|
949
|
+
kind: scalar(),
|
|
950
|
+
term: scalar(),
|
|
951
|
+
competitorHandle: scalar(),
|
|
952
|
+
platforms: listOf(z.string()),
|
|
953
|
+
windowDays: scalar(),
|
|
954
|
+
// Two numbers a caller cannot derive and will otherwise assume: how long
|
|
955
|
+
// anything is kept, and how long the watch has existed. A flat left edge
|
|
956
|
+
// is one of those, not a quiet period.
|
|
957
|
+
retainedDays: scalar(),
|
|
958
|
+
watchCreatedAt: scalar(),
|
|
959
|
+
runCount: scalar(),
|
|
960
|
+
found: anyObject().nullish().describe("newest and oldest in the window, so direction needs no array maths."),
|
|
961
|
+
medianViews: anyObject().nullish(),
|
|
962
|
+
recurring: listOf(open({
|
|
963
|
+
mentionKey: scalar().describe("A fingerprint. The text is deliberately not stored."),
|
|
964
|
+
timesSeen: scalar(),
|
|
965
|
+
firstReportedAt: scalar(),
|
|
966
|
+
lastSeenAt: scalar(),
|
|
967
|
+
})),
|
|
968
|
+
available: scalar(),
|
|
969
|
+
billable: scalar(),
|
|
970
|
+
mcpCredits,
|
|
971
|
+
}),
|
|
845
972
|
list_brand_watches: open({
|
|
846
973
|
watches: listOf(open({
|
|
847
974
|
watchId: scalar(),
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Putting two creators side by side (issue #30).
|
|
3
|
+
*
|
|
4
|
+
* The surface had a strong and correct stance on baselines, applied in one
|
|
5
|
+
* direction only. `track_competitor` scores each post against the median of
|
|
6
|
+
* that creator's own window, "because a raw view count mostly measures
|
|
7
|
+
* follower count — outperformance against themselves is the signal".
|
|
8
|
+
* `why_did_this_underperform` does the same for a single post.
|
|
9
|
+
*
|
|
10
|
+
* Both were right, and both then discarded the comparison. A ratio to one's
|
|
11
|
+
* own median is *exactly* the quantity that IS comparable across creators of
|
|
12
|
+
* different sizes — a 300k-follower account beating its median by 2.1× and a
|
|
13
|
+
* 4k-follower account doing the same are the same event — and no tool ever
|
|
14
|
+
* placed two of them next to each other. So "is their hit rate better than
|
|
15
|
+
* mine?" was unanswerable at any price, while every ingredient was computed on
|
|
16
|
+
* every call and thrown away.
|
|
17
|
+
*
|
|
18
|
+
* The comparison itself stays the model's job, as everywhere else here. This
|
|
19
|
+
* module normalises each creator to the same axis and reports how much window
|
|
20
|
+
* it had to do it with; ranking them server-side would hide exactly the thing
|
|
21
|
+
* that decides whether a ranking means anything.
|
|
22
|
+
*/
|
|
23
|
+
import { ABOVE_RATIO, BREAKOUT_RATIO, distributionOf, MIN_BASELINE_POSTS, numberOf, standing, } from "./performance.js";
|
|
24
|
+
/** Median of an already-sorted list. */
|
|
25
|
+
function mid(sorted) {
|
|
26
|
+
if (!sorted.length)
|
|
27
|
+
return null;
|
|
28
|
+
const i = Math.floor(sorted.length / 2);
|
|
29
|
+
return sorted.length % 2 ? sorted[i] : (sorted[i - 1] + sorted[i]) / 2;
|
|
30
|
+
}
|
|
31
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
32
|
+
/**
|
|
33
|
+
* One creator, reduced to the axis every creator shares.
|
|
34
|
+
*
|
|
35
|
+
* `hitRate` is a share of a window and not a rate — over six posts it is one
|
|
36
|
+
* post either way — which is why `window` travels beside it everywhere rather
|
|
37
|
+
* than being folded into a score. A caller that cannot see the denominator
|
|
38
|
+
* cannot know the number is noise, and the guidance below leans on it.
|
|
39
|
+
*/
|
|
40
|
+
export function creatorStanding(handle, platform, posts, metric, pick) {
|
|
41
|
+
const values = posts.map(pick);
|
|
42
|
+
const baseline = distributionOf(values);
|
|
43
|
+
const empty = {
|
|
44
|
+
handle,
|
|
45
|
+
platform,
|
|
46
|
+
window: posts.length,
|
|
47
|
+
baseline,
|
|
48
|
+
hitRate: null,
|
|
49
|
+
medianWinRatio: null,
|
|
50
|
+
ratios: [],
|
|
51
|
+
best: null,
|
|
52
|
+
worst: null,
|
|
53
|
+
};
|
|
54
|
+
if (!posts.length) {
|
|
55
|
+
return { ...empty, unavailable: "no posts came back for this handle" };
|
|
56
|
+
}
|
|
57
|
+
if (!baseline) {
|
|
58
|
+
// Fewer than MIN_BASELINE_POSTS is not a baseline, and inventing one would
|
|
59
|
+
// put a confident ratio on an account nobody has enough of. The same
|
|
60
|
+
// refusal `distributionOf` already makes for a single post.
|
|
61
|
+
return {
|
|
62
|
+
...empty,
|
|
63
|
+
unavailable: `only ${posts.length} post${posts.length === 1 ? "" : "s"} in the window — ` +
|
|
64
|
+
`${MIN_BASELINE_POSTS} is the fewest that make a baseline`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const scored = posts.map((post) => ({ post, standing: standing(pick(post), values) }));
|
|
68
|
+
const ratios = scored.map((s) => numberOf(s.standing.ratio)).filter((r) => Number.isFinite(r));
|
|
69
|
+
const winners = ratios.filter((r) => r >= ABOVE_RATIO);
|
|
70
|
+
const byRatio = [...scored].sort((a, b) => numberOf(b.standing.ratio) - numberOf(a.standing.ratio));
|
|
71
|
+
return {
|
|
72
|
+
handle,
|
|
73
|
+
platform,
|
|
74
|
+
window: posts.length,
|
|
75
|
+
baseline,
|
|
76
|
+
hitRate: round2(winners.length / posts.length),
|
|
77
|
+
medianWinRatio: winners.length ? round2(mid([...winners].sort((a, b) => a - b)) ?? 0) : null,
|
|
78
|
+
ratios: [...ratios].sort((a, b) => a - b).map(round2),
|
|
79
|
+
best: { ...byRatio[0].post, standing: byRatio[0].standing, metric },
|
|
80
|
+
worst: { ...byRatio[byRatio.length - 1].post, standing: byRatio[byRatio.length - 1].standing, metric },
|
|
81
|
+
unavailable: null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The window below which a hit rate should not be quoted as one.
|
|
86
|
+
*
|
|
87
|
+
* Not a hard floor — the row is still returned, because "we only have 5 posts"
|
|
88
|
+
* is itself the answer to "who is accelerating?" and hiding it would leave the
|
|
89
|
+
* model guessing. It is the number the guidance names so the model says "too
|
|
90
|
+
* few posts to call this" instead of ranking confidently, the same discipline
|
|
91
|
+
* `find_hook_pattern` applies with "a template that fits one post is not a
|
|
92
|
+
* pattern".
|
|
93
|
+
*/
|
|
94
|
+
export const THIN_WINDOW = 8;
|
|
95
|
+
/**
|
|
96
|
+
* What to do with a set of standings.
|
|
97
|
+
*
|
|
98
|
+
* Written to be read by the model that just received them, in the voice
|
|
99
|
+
* `evidence.ts` sets: say what the material is, what is comparable about it,
|
|
100
|
+
* and what would be a mistake to conclude.
|
|
101
|
+
*/
|
|
102
|
+
export function standingsGuidance(a) {
|
|
103
|
+
const scored = a.rows.filter((r) => !r.unavailable);
|
|
104
|
+
const thin = scored.filter((r) => r.window < THIN_WINDOW);
|
|
105
|
+
const lines = [];
|
|
106
|
+
lines.push(`${a.rows.length} creator${a.rows.length === 1 ? "" : "s"}` +
|
|
107
|
+
(a.source === "watchlist" ? " from your watchlist" : "") +
|
|
108
|
+
`, each scored against their OWN recent median ${a.metric} — never against each other's raw ` +
|
|
109
|
+
"numbers, because a raw count mostly measures follower count. The ratio is what compares: " +
|
|
110
|
+
"a large account beating its median by 2× and a small one doing the same have done the same " +
|
|
111
|
+
"thing.");
|
|
112
|
+
if (!scored.length) {
|
|
113
|
+
lines.push("", "None of them could be scored — see `unavailable` on each row. That is a gap in what came " +
|
|
114
|
+
"back, not a finding about the creators, so say which ones and why rather than reporting " +
|
|
115
|
+
"an empty comparison.");
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
lines.push("", "Per creator: `window` is how many posts were scored, `baseline.median` is the middle of " +
|
|
119
|
+
`their own window, \`hitRate\` is the share of it at or above ${ABOVE_RATIO}× that median, ` +
|
|
120
|
+
"`medianWinRatio` is how hard they beat it when they did, and `ratios` is every post's " +
|
|
121
|
+
"ratio ascending so the spread is visible. `best` and `worst` carry the posts themselves.", "", "Two different questions live in those numbers and they usually disagree: how OFTEN someone " +
|
|
122
|
+
"lands one (`hitRate`) and how BIG it is when they do (`medianWinRatio`). A creator with a " +
|
|
123
|
+
"low hit rate and a high win ratio is swinging; the reverse is consistent and capped. Say " +
|
|
124
|
+
"which of the two you are ranking on, because 'who is doing better' has no single answer.");
|
|
125
|
+
if (thin.length) {
|
|
126
|
+
lines.push("", `Do not rank ${thin.map((r) => `@${r.handle} (${r.window} posts)`).join(", ")} confidently. ` +
|
|
127
|
+
`A hit rate over fewer than ${THIN_WINDOW} posts is one post either way, and a median over ` +
|
|
128
|
+
"a window that small moves with the window. Say the sample is too thin to call rather " +
|
|
129
|
+
"than putting them in an order.");
|
|
130
|
+
}
|
|
131
|
+
const unavailable = a.rows.filter((r) => r.unavailable);
|
|
132
|
+
if (unavailable.length) {
|
|
133
|
+
lines.push("", `Not scored: ${unavailable.map((r) => `@${r.handle} — ${r.unavailable}`).join("; ")}. ` +
|
|
134
|
+
"Missing from the comparison is not the same as bottom of it.");
|
|
135
|
+
}
|
|
136
|
+
lines.push("", `Every ratio here is against a window measured now. It cannot tell you whether anyone's ` +
|
|
137
|
+
"baseline is itself moving — that needs a second point in time, which nothing stores yet " +
|
|
138
|
+
"(issue #28). So do not read a high hit rate as 'improving'; it is 'consistent within this " +
|
|
139
|
+
"window' and nothing more.");
|
|
140
|
+
return lines.join("\n");
|
|
141
|
+
}
|
|
142
|
+
/** Threshold re-exported so the tools and the view agree on the badge. */
|
|
143
|
+
export { ABOVE_RATIO, BREAKOUT_RATIO };
|
package/dist/shared/tools-def.js
CHANGED
|
@@ -228,6 +228,18 @@ export const TOOL_DEFINITIONS = [
|
|
|
228
228
|
description: "Every scheduled brand-monitoring watch this user has: term, networks, cadence, cost per run, credits spent so far, runs made, next run due, and whether it is stopped and why. Read before creating a watch — a second watch on the same term is a second recurring charge for the same answer. No cost to call.",
|
|
229
229
|
inputSchema: z.object({}).strict(),
|
|
230
230
|
},
|
|
231
|
+
{
|
|
232
|
+
name: "mention_trend",
|
|
233
|
+
title: "Mention Trend",
|
|
234
|
+
description: "What a watch has SEEN OVER TIME, which no other tool here can answer — every read on this surface answers about now, and until the backend started keeping run history each sweep threw its numbers away. Takes `watchId` (or `term`) and an optional `days`. Returns one point per run: when it ran, how many mentions it found, how many were new, and both counts per network. On a competitor watch each point also carries the median that run measured. Also returns the mentions more than one run has seen. FREE: the sweeps were billed when they ran. Read the series yourself and say what changed.",
|
|
235
|
+
inputSchema: z.object({ watchId: z.string().optional(), term: z.string().optional(), days: z.number().int().optional() }).strict(),
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "show_trend",
|
|
239
|
+
title: "Show Trend",
|
|
240
|
+
description: "Draw the trend you read out of mention_trend. Free, and makes no requests — it renders what you pass it: the series as a chart, per-network lines where you supply them, and your own read of what changed. Pass `tooShort` when the series has too few points to call a direction, and `edgeIsRecordStart` when the left edge is where the record begins rather than where the conversation did. Call this after you have decided what the numbers mean.",
|
|
241
|
+
inputSchema: z.object({ points: z.array(z.object({}).passthrough()).min(1).max(400), term: z.string().optional(), metric: z.string().optional(), verdict: z.string().optional(), tooShort: z.boolean().optional(), edgeIsRecordStart: z.boolean().optional() }).strict(),
|
|
242
|
+
},
|
|
231
243
|
{
|
|
232
244
|
name: "stop_brand_watch",
|
|
233
245
|
title: "Stop Brand Watch",
|
|
@@ -342,6 +354,24 @@ export const TOOL_DEFINITIONS = [
|
|
|
342
354
|
description: "What a creator shipped recently, and which of it beat their own baseline. Scores every post in the window against the median of that same window, because a raw view count mostly measures follower count \u2014 outperformance against themselves is the signal worth reporting. If they are on your watchlist it also marks which posts are new since your last track_competitor call and moves that marker forward. Use for a rival you follow over time. Consumes 2 nooticr credits, whatever the window size.",
|
|
343
355
|
inputSchema: z.object({ username: z.string().describe("Creator handle, with or without @."), platform: z.string().optional().describe("Platform (default tiktok)."), limit: z.number().int().optional().describe("Posts in the window (default 12, max 30)."), metric: z.enum(["views", "likes", "comments", "shares", "engagementRate"]).optional().describe("Which stat to rank on (default views)."), since: z.string().optional().describe("Only posts published on or after this date, as YYYY-MM-DD.") }).strict(),
|
|
344
356
|
},
|
|
357
|
+
{
|
|
358
|
+
name: "compare_creators",
|
|
359
|
+
title: "Compare Creators",
|
|
360
|
+
description: "Two to five creators side by side, each scored against THEIR OWN recent median rather than against each other's raw numbers — which is the only way the comparison means anything, because a raw view count mostly measures follower count. Returns per creator: how many posts were scored, their own median, the share of the window that beat it, how hard they beat it when they did, every post's ratio, and their best and worst post. The ranking is yours to make: 'how often' and 'how big' usually disagree, and a hit rate over a short window is one post either way, so the window size travels with every number. Fetches one post list per creator: 2 nooticr credits each, 4-10 in total. Use for 'is their hit rate better than mine'; track_competitor is one creator alone.",
|
|
361
|
+
inputSchema: z.object({ usernames: z.array(z.string()).min(2).max(5), platform: z.string().optional(), limit: z.number().int().optional(), metric: z.string().optional() }).strict(),
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
name: "watchlist_standings",
|
|
365
|
+
title: "Watchlist Standings",
|
|
366
|
+
description: "The same comparison across everyone on your watchlist, answering 'who moved' in one call instead of one per creator. Each is scored against their own recent median, so the numbers are comparable between accounts of different sizes; the window size travels with each so a short one is not ranked as if it were a season. Reads the watchlist itself for free and fetches one post list per creator on it: 2 nooticr credits each, so the price is the size of your watchlist — it confirms before spending. Use for the weekly 'who is accelerating'; catch_up_watchlist is what is NEW rather than how it did.",
|
|
367
|
+
inputSchema: z.object({ limit: z.number().int().optional(), metric: z.string().optional() }).strict(),
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: "show_standings",
|
|
371
|
+
title: "Show Standings",
|
|
372
|
+
description: "Draw the standings you read out of compare_creators or watchlist_standings. Free, and makes no requests — it only renders what you pass it: one row per creator with their window, their own median, how often they beat it and how hard, and the post that did best. Pass `ranking` to say which axis you ordered on, and `tooThin` for the creators whose window was too short to rank — a table that hides that is the one mistake this view can make on your behalf. Call this after you have decided what the numbers mean, not instead of deciding.",
|
|
373
|
+
inputSchema: z.object({ creators: z.array(z.object({}).passthrough()).min(1).max(25), metric: z.string().optional(), ranking: z.string().optional(), verdict: z.string().optional(), tooThin: z.array(z.string()).optional() }).strict(),
|
|
374
|
+
},
|
|
345
375
|
{
|
|
346
376
|
name: "who_should_i_work_with",
|
|
347
377
|
title: "Who Should I Work With",
|
package/dist/shared/tools.js
CHANGED
|
@@ -25,7 +25,7 @@ import { loadingPlansJson } from "./loading-plans.js";
|
|
|
25
25
|
import { registerHandoff } from "./handoff.js";
|
|
26
26
|
import { registerCollabTools } from "./collab.js";
|
|
27
27
|
/** Current MCP server version — bumped on every deploy for traceability. */
|
|
28
|
-
export const MCP_SERVER_VERSION = "1.26.
|
|
28
|
+
export const MCP_SERVER_VERSION = "1.26.28";
|
|
29
29
|
/** MCP Apps extension identifier */
|
|
30
30
|
const UI_EXTENSION = "io.modelcontextprotocol/ui";
|
|
31
31
|
/** MIME type for MCP Apps HTML resources */
|
|
@@ -507,6 +507,9 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
507
507
|
// state tools have nothing to show and stay view-less, like nooticr_login.
|
|
508
508
|
"catch_up_watchlist",
|
|
509
509
|
"search_mentions",
|
|
510
|
+
// The series a watch keeps, and the free view for the read of it.
|
|
511
|
+
"mention_trend",
|
|
512
|
+
"show_trend",
|
|
510
513
|
"show_comment_review",
|
|
511
514
|
// Close the loop the evidence-only tools open: your own analysis/hooks/
|
|
512
515
|
// variants/repurposing/comparison, drawn — same shape as
|
|
@@ -523,6 +526,11 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
523
526
|
"answer_my_audience",
|
|
524
527
|
"show_audience_replies",
|
|
525
528
|
"track_competitor",
|
|
529
|
+
// The comparison track_competitor computes per creator and never puts side
|
|
530
|
+
// by side, plus the free view that draws the table once a read is written.
|
|
531
|
+
"compare_creators",
|
|
532
|
+
"watchlist_standings",
|
|
533
|
+
"show_standings",
|
|
526
534
|
"who_should_i_work_with",
|
|
527
535
|
"why_did_this_underperform",
|
|
528
536
|
"what_should_i_make_next",
|
|
@@ -1353,6 +1361,124 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1353
1361
|
// ui-template.ts already renders (show_comparison → the comparison
|
|
1354
1362
|
// scoreboard, show_analysis → analysisCard) or a new one added alongside
|
|
1355
1363
|
// it (show_hooks, show_variants, show_repurposed_post).
|
|
1364
|
+
server.registerTool("show_trend", {
|
|
1365
|
+
title: "Show Trend",
|
|
1366
|
+
description: "Draw the trend you read out of mention_trend. Free, and makes no requests — it renders what " +
|
|
1367
|
+
"you pass it: the series as a chart, per-network lines where you supply them, and your own " +
|
|
1368
|
+
"read of what changed. Pass `tooShort` when the series has too few points to call a " +
|
|
1369
|
+
"direction, and `edgeIsRecordStart` when the left edge is where the record begins rather " +
|
|
1370
|
+
"than where the conversation did — a chart that hides either is the one mistake this view " +
|
|
1371
|
+
"can make on your behalf. Call this after you have decided what the numbers mean.",
|
|
1372
|
+
_meta: {
|
|
1373
|
+
ui: { resourceUri: uiResource("show_trend") },
|
|
1374
|
+
"ui/resourceUri": uiResource("show_trend"),
|
|
1375
|
+
"openai/outputTemplate": appsSdkResource("show_trend"),
|
|
1376
|
+
},
|
|
1377
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1378
|
+
outputSchema: OUTPUT_SCHEMAS.show_trend,
|
|
1379
|
+
inputSchema: z
|
|
1380
|
+
.object({
|
|
1381
|
+
points: z
|
|
1382
|
+
.array(anyObject())
|
|
1383
|
+
.min(1)
|
|
1384
|
+
.max(400)
|
|
1385
|
+
.describe("The series, oldest first or newest first as you like — the same shape mention_trend " +
|
|
1386
|
+
"returned (ranAt, found, reported, perPlatform, medianViews)."),
|
|
1387
|
+
term: z.string().optional().describe("What is being watched."),
|
|
1388
|
+
metric: z
|
|
1389
|
+
.string()
|
|
1390
|
+
.optional()
|
|
1391
|
+
.describe('Which number the chart plots: "found" (default), "reported" or "medianViews".'),
|
|
1392
|
+
verdict: z.string().optional().describe("Your read of what changed, in a sentence or two."),
|
|
1393
|
+
tooShort: z
|
|
1394
|
+
.boolean()
|
|
1395
|
+
.optional()
|
|
1396
|
+
.describe("True when there are too few points to call a direction. Drawn as a stated caveat rather " +
|
|
1397
|
+
"than a trend line, because two points of scraped data are mostly the scrape."),
|
|
1398
|
+
edgeIsRecordStart: z
|
|
1399
|
+
.boolean()
|
|
1400
|
+
.optional()
|
|
1401
|
+
.describe("True when the earliest point is the start of the kept record or the watch's own age, " +
|
|
1402
|
+
"not the start of the conversation. Marks the left edge so a flat start is not read as " +
|
|
1403
|
+
"silence."),
|
|
1404
|
+
})
|
|
1405
|
+
.strict(),
|
|
1406
|
+
}, async (args) => {
|
|
1407
|
+
return {
|
|
1408
|
+
content: [
|
|
1409
|
+
{
|
|
1410
|
+
type: "text",
|
|
1411
|
+
text: `Showing a ${args.points.length}-point trend${args.term ? ` for "${args.term}"` : ""}.` +
|
|
1412
|
+
(args.verdict ? ` ${args.verdict}` : ""),
|
|
1413
|
+
},
|
|
1414
|
+
],
|
|
1415
|
+
structuredContent: {
|
|
1416
|
+
points: args.points,
|
|
1417
|
+
term: args.term ?? null,
|
|
1418
|
+
metric: args.metric ?? "found",
|
|
1419
|
+
verdict: args.verdict ?? null,
|
|
1420
|
+
tooShort: args.tooShort ?? false,
|
|
1421
|
+
edgeIsRecordStart: args.edgeIsRecordStart ?? false,
|
|
1422
|
+
trend: true,
|
|
1423
|
+
},
|
|
1424
|
+
};
|
|
1425
|
+
});
|
|
1426
|
+
server.registerTool("show_standings", {
|
|
1427
|
+
title: "Show Standings",
|
|
1428
|
+
description: "Draw the standings you read out of compare_creators or watchlist_standings. Free, and makes " +
|
|
1429
|
+
"no requests — it only renders what you pass it: one row per creator with their window, their " +
|
|
1430
|
+
"own median, how often they beat it and how hard, and the post that did best. Pass `ranking` " +
|
|
1431
|
+
"to say which axis you ordered on, and `tooThin` for the creators whose window was too short " +
|
|
1432
|
+
"to rank — a table that hides that is the one mistake this view can make on your behalf. " +
|
|
1433
|
+
"Call this after you have decided what the numbers mean, not instead of deciding.",
|
|
1434
|
+
_meta: {
|
|
1435
|
+
ui: { resourceUri: uiResource("show_standings") },
|
|
1436
|
+
"ui/resourceUri": uiResource("show_standings"),
|
|
1437
|
+
"openai/outputTemplate": appsSdkResource("show_standings"),
|
|
1438
|
+
},
|
|
1439
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1440
|
+
outputSchema: OUTPUT_SCHEMAS.show_standings,
|
|
1441
|
+
inputSchema: z
|
|
1442
|
+
.object({
|
|
1443
|
+
creators: z
|
|
1444
|
+
.array(anyObject())
|
|
1445
|
+
.min(1)
|
|
1446
|
+
.max(25)
|
|
1447
|
+
.describe("The creator rows, in the order you decided on — the same shape compare_creators returned " +
|
|
1448
|
+
"(handle, platform, window, baseline, hitRate, medianWinRatio, best, ...)."),
|
|
1449
|
+
metric: z.string().optional().describe("Which stat the standings are on (views, likes, ...)."),
|
|
1450
|
+
ranking: z
|
|
1451
|
+
.string()
|
|
1452
|
+
.optional()
|
|
1453
|
+
.describe('What you ordered on and why — "how often they land one" and "how big it is when they do" ' +
|
|
1454
|
+
"usually disagree, so the reader needs to know which they are looking at."),
|
|
1455
|
+
verdict: z.string().optional().describe("Your read, in a sentence or two."),
|
|
1456
|
+
tooThin: z
|
|
1457
|
+
.array(z.string())
|
|
1458
|
+
.optional()
|
|
1459
|
+
.describe("Handles whose window was too short to rank. Drawn as unranked rather than bottom, " +
|
|
1460
|
+
"because missing from a comparison is not the same as losing it."),
|
|
1461
|
+
})
|
|
1462
|
+
.strict(),
|
|
1463
|
+
}, async (args) => {
|
|
1464
|
+
return {
|
|
1465
|
+
content: [
|
|
1466
|
+
{
|
|
1467
|
+
type: "text",
|
|
1468
|
+
text: `Showing standings for ${args.creators.length} creator${args.creators.length === 1 ? "" : "s"}.` +
|
|
1469
|
+
(args.verdict ? ` ${args.verdict}` : ""),
|
|
1470
|
+
},
|
|
1471
|
+
],
|
|
1472
|
+
structuredContent: {
|
|
1473
|
+
creators: args.creators,
|
|
1474
|
+
metric: args.metric ?? "views",
|
|
1475
|
+
ranking: args.ranking ?? null,
|
|
1476
|
+
verdict: args.verdict ?? null,
|
|
1477
|
+
tooThin: args.tooThin ?? [],
|
|
1478
|
+
standings: true,
|
|
1479
|
+
},
|
|
1480
|
+
};
|
|
1481
|
+
});
|
|
1356
1482
|
server.registerTool("show_comparison", {
|
|
1357
1483
|
title: "Show Comparison",
|
|
1358
1484
|
description: "Display a comparison you wrote after compare_posts fetched the first post and you fetched " +
|
|
@@ -3661,6 +3661,64 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
|
|
|
3661
3661
|
if(d.posts&&Array.isArray(d.posts)){
|
|
3662
3662
|
if(!d.posts.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">🔍</div><div class="text">No posts found</div></div>';return;}
|
|
3663
3663
|
app.innerHTML=galleryWrap(d.posts.map(function(p){return postCard(p,false,true);}).join(""),d.posts.length)+pickBarHtml();initPicks();return;}
|
|
3664
|
+
// Standings: creators on one normalised axis. A table, because the question
|
|
3665
|
+
// is comparative — and every row carries the window it was scored over. A
|
|
3666
|
+
// hit rate whose denominator is invisible cannot be told apart from one
|
|
3667
|
+
// that means something, which is why these numbers were computed per
|
|
3668
|
+
// creator and never put side by side before (issue #30).
|
|
3669
|
+
if(Array.isArray(d.creators)&&(d.standings||d.tool==="compare_creators"||d.tool==="watchlist_standings")){
|
|
3670
|
+
var thin=Array.isArray(d.tooThin)?d.tooThin:[];
|
|
3671
|
+
var floor=typeof d.thinWindow==="number"?d.thinWindow:8;
|
|
3672
|
+
var above=typeof d.aboveRatio==="number"?d.aboveRatio:1.25;
|
|
3673
|
+
var met=esc(d.metric||"views");
|
|
3674
|
+
var rows=d.creators.map(function(c){
|
|
3675
|
+
var un=c.unavailable;
|
|
3676
|
+
// A window shorter than the floor, or one the model flagged, is drawn
|
|
3677
|
+
// unranked. Bottom of a table is a claim; unranked is the truth.
|
|
3678
|
+
var short=!un&&(thin.indexOf(String(c.handle))>=0||(typeof c.window==="number"&&c.window<floor));
|
|
3679
|
+
var med=c.baseline&&c.baseline.median!=null?fmtNum(c.baseline.median):"-";
|
|
3680
|
+
var hit=c.hitRate!=null?Math.round(c.hitRate*100)+"%":"-";
|
|
3681
|
+
var win=c.medianWinRatio!=null?(Math.round(c.medianWinRatio*100)/100)+"x":"-";
|
|
3682
|
+
var best=c.best&&(c.best.title||c.best.caption)?String(c.best.title||c.best.caption):"";
|
|
3683
|
+
return '<tr style="border-top:1px solid var(--border-soft)'+(un?";opacity:.6":"")+'">'
|
|
3684
|
+
+'<td style="padding:9px 10px;font-weight:600;white-space:nowrap">@'+esc(c.handle)
|
|
3685
|
+
+(c.platform?'<span style="font-weight:400;color:var(--muted)"> · '+esc(c.platform)+"</span>":"")
|
|
3686
|
+
+(short?'<span style="margin-left:6px;font-size:10px;font-weight:700;color:var(--warn)">too thin to rank</span>':"")
|
|
3687
|
+
+"</td>"
|
|
3688
|
+
+(un
|
|
3689
|
+
?'<td colspan="4" style="padding:9px 10px;color:var(--muted);font-size:12.5px">'+esc(String(un))+"</td>"
|
|
3690
|
+
:'<td style="padding:9px 10px;text-align:right;font-variant-numeric:tabular-nums">'+fmtNum(c.window||0)+"</td>"
|
|
3691
|
+
+'<td style="padding:9px 10px;text-align:right;font-variant-numeric:tabular-nums">'+med+"</td>"
|
|
3692
|
+
+'<td style="padding:9px 10px;text-align:right;font-variant-numeric:tabular-nums">'+hit+"</td>"
|
|
3693
|
+
+'<td style="padding:9px 10px;text-align:right;font-variant-numeric:tabular-nums">'+win+"</td>")
|
|
3694
|
+
+"</tr>"
|
|
3695
|
+
+(best&&!un
|
|
3696
|
+
?'<tr><td colspan="5" style="padding:0 10px 9px 10px;color:var(--muted);font-size:12px">best: '+esc(best.slice(0,120))+"</td></tr>"
|
|
3697
|
+
:"");
|
|
3698
|
+
}).join("");
|
|
3699
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
3700
|
+
+'<div style="font-size:16px;font-weight:700">Standings</div>'
|
|
3701
|
+
+'<div class="faint" style="font-size:11.5px;color:var(--muted);margin:2px 0 10px">'
|
|
3702
|
+
+d.creators.length+" creator"+(d.creators.length===1?"":"s")+" · each against their OWN median "+met
|
|
3703
|
+
+(d.watching!=null?" · watchlist of "+fmtNum(d.watching):"")+"</div>"
|
|
3704
|
+
+(d.ranking?'<div style="font-size:12.5px;margin:0 0 8px">Ordered on: '+esc(String(d.ranking))+"</div>":"")
|
|
3705
|
+
+'<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">'
|
|
3706
|
+
+'<thead><tr style="text-align:left;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.05em">'
|
|
3707
|
+
+'<th style="padding:0 10px 6px">Creator</th>'
|
|
3708
|
+
+'<th style="padding:0 10px 6px;text-align:right">Window</th>'
|
|
3709
|
+
+'<th style="padding:0 10px 6px;text-align:right">Their median</th>'
|
|
3710
|
+
+'<th style="padding:0 10px 6px;text-align:right">Beat it</th>'
|
|
3711
|
+
+'<th style="padding:0 10px 6px;text-align:right">By</th>'
|
|
3712
|
+
+"</tr></thead><tbody>"+rows+"</tbody></table></div>"
|
|
3713
|
+
+(d.verdict?'<div style="font-size:13.5px;line-height:1.6;margin-top:12px">'+esc(String(d.verdict))+"</div>":"")
|
|
3714
|
+
+'<div class="faint" style="font-size:11.5px;color:var(--muted);margin-top:10px;line-height:1.6">'
|
|
3715
|
+
+'"Beat it" is the share of the window at or above '+above+"x that creator's own median. "
|
|
3716
|
+
+"A raw view count mostly measures follower count, so nothing here compares raw numbers. "
|
|
3717
|
+
+"A window under "+floor+" posts is one post either way, and no ratio here says whether a "
|
|
3718
|
+
+"baseline is itself moving — that needs a second point in time."
|
|
3719
|
+
+"</div></div></div>";
|
|
3720
|
+
return;}
|
|
3721
|
+
|
|
3664
3722
|
// Creators
|
|
3665
3723
|
if(d.creators&&Array.isArray(d.creators)){
|
|
3666
3724
|
if(!d.creators.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">👤</div><div class="text">No creators found</div></div>';return;}
|
|
@@ -3689,6 +3747,46 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
|
|
|
3689
3747
|
+"</div></div>";
|
|
3690
3748
|
return;}
|
|
3691
3749
|
|
|
3750
|
+
// A run series. Drawn as bars rather than a line: the points are sweeps on
|
|
3751
|
+
// a cadence, not a continuous signal, and a line between two sweeps
|
|
3752
|
+
// asserts the shape of something nobody measured. Both caveats the tool
|
|
3753
|
+
// computes are drawn — a chart that hides "too few points" or "this is
|
|
3754
|
+
// where the record starts, not where the conversation did" is the view
|
|
3755
|
+
// making a claim the tool refused to (issue #28).
|
|
3756
|
+
if(Array.isArray(d.points)&&(d.trend||d.tool==="mention_trend")){
|
|
3757
|
+
var metric=String(d.metric||"found");
|
|
3758
|
+
var pts=d.points.slice().sort(function(a,b){return String(a.ranAt||"")<String(b.ranAt||"")?-1:1;});
|
|
3759
|
+
var vals=pts.map(function(p){var v=p[metric];return typeof v==="number"?v:0;});
|
|
3760
|
+
var top=Math.max.apply(null,vals.concat([1]));
|
|
3761
|
+
var bars=pts.map(function(p,i){
|
|
3762
|
+
var v=vals[i];
|
|
3763
|
+
var h=Math.max(2,Math.round((v/top)*90));
|
|
3764
|
+
var when=String(p.ranAt||"").slice(0,10);
|
|
3765
|
+
var label=when+" — "+fmtNum(v)+" "+metric+(p.reported!=null?" ("+fmtNum(p.reported)+" new)":"");
|
|
3766
|
+
return '<div style="flex:1;min-width:3px;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:3px" title="'+esc(label)+'">'
|
|
3767
|
+
+'<div style="width:100%;max-width:26px;height:'+h+'px;background:var(--brand);border-radius:3px 3px 0 0;opacity:'+(i===pts.length-1?"1":".62")+'"></div>'
|
|
3768
|
+
+"</div>";
|
|
3769
|
+
}).join("");
|
|
3770
|
+
var first=pts.length?String(pts[0].ranAt||"").slice(0,10):"";
|
|
3771
|
+
var last=pts.length?String(pts[pts.length-1].ranAt||"").slice(0,10):"";
|
|
3772
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
3773
|
+
+'<div style="font-size:16px;font-weight:700">'+(d.term?esc(String(d.term)):"Trend")+"</div>"
|
|
3774
|
+
+'<div class="faint" style="font-size:11.5px;color:var(--muted);margin:2px 0 12px">'
|
|
3775
|
+
+pts.length+" run"+(pts.length===1?"":"s")+" · "+esc(metric)+" per sweep</div>"
|
|
3776
|
+
+(d.tooShort
|
|
3777
|
+
?'<div style="font-size:12.5px;color:var(--warn);font-weight:600;margin-bottom:8px">Too few points to call a direction</div>'
|
|
3778
|
+
:"")
|
|
3779
|
+
+'<div style="display:flex;align-items:flex-end;gap:2px;height:100px;border-bottom:1px solid var(--border)">'+bars+"</div>"
|
|
3780
|
+
+'<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-top:5px">'
|
|
3781
|
+
+"<span>"+esc(first)+(d.edgeIsRecordStart?" · record starts here":"")+"</span><span>"+esc(last)+"</span></div>"
|
|
3782
|
+
+(d.verdict?'<div style="font-size:13.5px;line-height:1.6;margin-top:12px">'+esc(String(d.verdict))+"</div>":"")
|
|
3783
|
+
+'<div class="faint" style="font-size:11.5px;color:var(--muted);margin-top:10px;line-height:1.6">'
|
|
3784
|
+
+"Each bar is one sweep, not a day"
|
|
3785
|
+
+(d.edgeIsRecordStart?" — and the earliest bar is where the kept record begins, not where the conversation did":"")
|
|
3786
|
+
+". A flat run of bars is a conversation that has stopped moving, which is not the same as one that has stopped."
|
|
3787
|
+
+"</div></div></div>";
|
|
3788
|
+
return;}
|
|
3789
|
+
|
|
3692
3790
|
// Trending hashtags. Two sources with different evidence behind them: the
|
|
3693
3791
|
// TikTok trend board, which measures direction over time, and a count
|
|
3694
3792
|
// across one niche sweep, which cannot. The header names which, and a
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nooticr/mcp",
|
|
3
3
|
"mcpName": "com.nooticr/mcp",
|
|
4
|
-
"version": "1.26.
|
|
4
|
+
"version": "1.26.28",
|
|
5
5
|
"description": "MCP server for nooticr - fetch, discover and understand TikTok/Instagram/YouTube/X/Reddit/LinkedIn/Weibo posts with AI (media metadata, niche discovery, brand monitoring, hook & viral analysis)",
|
|
6
6
|
"homepage": "https://mcp.nooticr.com",
|
|
7
7
|
"repository": {
|