@nooticr/mcp 1.26.27 → 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 +3 -0
- package/dist/shared/jobs.js +181 -1
- package/dist/shared/loading-plans.js +19 -0
- package/dist/shared/output-schemas.js +78 -0
- package/dist/shared/standings.js +143 -0
- package/dist/shared/tools-def.js +18 -0
- package/dist/shared/tools.js +62 -1
- package/dist/shared/ui-template.js +58 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -113,8 +113,11 @@ them caps that fan-out with an argument.
|
|
|
113
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. |
|
|
114
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. |
|
|
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
|
+
| `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. |
|
|
116
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. |
|
|
117
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**. |
|
|
118
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. |
|
|
119
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. |
|
|
120
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. |
|
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,7 @@ 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 },
|
|
302
321
|
show_trend: { label: "Drawing the trend", kind: "text", n: 1, steps: [], free: true },
|
|
303
322
|
// Reads stored aggregates the sweeps already paid for. Free, and no
|
|
304
323
|
// upstream step to wait on — the wait is a database read.
|
|
@@ -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(),
|
|
@@ -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
|
@@ -354,6 +354,24 @@ export const TOOL_DEFINITIONS = [
|
|
|
354
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.",
|
|
355
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(),
|
|
356
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
|
+
},
|
|
357
375
|
{
|
|
358
376
|
name: "who_should_i_work_with",
|
|
359
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 */
|
|
@@ -526,6 +526,11 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
526
526
|
"answer_my_audience",
|
|
527
527
|
"show_audience_replies",
|
|
528
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",
|
|
529
534
|
"who_should_i_work_with",
|
|
530
535
|
"why_did_this_underperform",
|
|
531
536
|
"what_should_i_make_next",
|
|
@@ -1418,6 +1423,62 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1418
1423
|
},
|
|
1419
1424
|
};
|
|
1420
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
|
+
});
|
|
1421
1482
|
server.registerTool("show_comparison", {
|
|
1422
1483
|
title: "Show Comparison",
|
|
1423
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;}
|
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": {
|