@nooticr/mcp 1.26.27 → 1.26.29
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 +11 -5
- package/dist/shared/brand-watch.js +19 -7
- package/dist/shared/evidence.js +67 -0
- package/dist/shared/jobs.js +366 -17
- package/dist/shared/loading-plans.js +29 -1
- package/dist/shared/output-schemas.js +110 -2
- package/dist/shared/prompts.js +1 -1
- package/dist/shared/standings.js +146 -0
- package/dist/shared/tools-def.js +26 -2
- package/dist/shared/tools.js +137 -22
- package/dist/shared/ui-template.js +71 -3
- package/dist/shared/watchlist.js +13 -5
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ npx @nooticr/mcp login # one-time sign-in (Google)
|
|
|
52
52
|
|
|
53
53
|
## Tools
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
68 tools, grouped by what you are trying to do. Prices are in nooticr credits and
|
|
56
56
|
match what the server actually charges.
|
|
57
57
|
|
|
58
58
|
Seven of them — the ones under **Answer a question you actually have** — are not
|
|
@@ -113,9 +113,13 @@ 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_creator` | 2 | What a creator shipped, and which of it beat **their own** median rather than a raw view count that mostly measures follower count. One post list, whatever the window. If they are on your watchlist it also marks what is new since your last check and moves that marker forward — its own marker, not the one `catch_up_watchlist` keeps. |
|
|
119
|
+
| `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. |
|
|
122
|
+
| `find_people_with_problem` | 2 per platform | Find people **describing a problem in their own words** — prospects, early users, anyone whose complaint your product answers. Say the problem the way a person would say it and it searches for posts shaped like that complaint rather than for the topic it is about: searching the topic finds marketing about it, which is what `discover_social_posts` is for. Widens the query into the forms a complaint takes — the plain phrasing, the does-anyone-else question, the is-there-a-tool ask — and marks which one surfaced each post. A wide cheap net and **not** a filter: the posts come back for you to judge, and plenty will be off-target. Defaults to reddit, where people describe workflow pain in sentences. |
|
|
119
123
|
| `show_collab_shortlist` | free | Draws the candidates you scored, ranked, and asks the user which to approach. The scores are attributed to your model, not presented as a nooticr rating, and a candidate scored without anything having been opened is marked unverified. Makes no requests. |
|
|
120
124
|
| `why_did_this_underperform` | 3 | One post against the creator's own recent distribution, with the post taken back out of its own baseline. Returns median, quartiles, ratio and percentile, so the answer can be "this is an ordinary result, not a failure". Different question from `compare_posts`, which weighs two URLs you already picked. |
|
|
121
125
|
| `what_should_i_make_next` | 2 + 2 per post read + 2 (12 by default) | Demand against supply: what your commenters explicitly ask for, set beside what a niche sweep shows is already being made. A gap nobody asked for is noise; a request nobody serves is the opportunity. Falls back to your most-used hashtag when you name no niche. |
|
|
@@ -526,9 +530,11 @@ Those are unit tests against a stub backend. For real MCP-protocol
|
|
|
526
530
|
end-to-end tests — no Rust, no Postgres, no API key —
|
|
527
531
|
`npm run test:e2e-smoke:fixture` spawns this repo's real built CLI and
|
|
528
532
|
drives it over stdio against a pure-Node stand-in for nooticr-server. See
|
|
529
|
-
`docs/testing/agentic-e2e-testing.md` for that plus the
|
|
530
|
-
(`npm run test:e2e-smoke` against a real nooticr-server
|
|
531
|
-
|
|
533
|
+
`docs/testing/agentic-e2e-testing.md` for that plus the tier above it
|
|
534
|
+
(`npm run test:e2e-smoke` against a real nooticr-server). For a real model
|
|
535
|
+
driving the server through whole journeys — asserting on the chain of tools
|
|
536
|
+
it walks, not on one call — see `npm run test:quests` and
|
|
537
|
+
`docs/testing/tool-chaining-quests.md`. And for
|
|
532
538
|
`tests/e2e/agentic-visual*.e2e.ts` — every reachable widget view, driven by
|
|
533
539
|
a real tool-call result, rendered in a real browser with every host-facing
|
|
534
540
|
button actually clicked (`npx playwright test tests/e2e/agentic-visual.e2e.ts
|
|
@@ -259,9 +259,11 @@ function trendGuidance(sc, runs) {
|
|
|
259
259
|
"high `timesSeen` is a post that keeps resurfacing — worth naming as one thing rather " +
|
|
260
260
|
"than as repeated new interest.");
|
|
261
261
|
}
|
|
262
|
-
lines.push("", "This is free because the sweeps were already billed.
|
|
263
|
-
"
|
|
264
|
-
"
|
|
262
|
+
lines.push("", "This is free because the sweeps were already billed. Read the numbers and say what changed, " +
|
|
263
|
+
"including that nothing did, if that is what they show. Then call show_trend with the " +
|
|
264
|
+
"series and your read of it — free, and it draws the chart with the two things a chart " +
|
|
265
|
+
"here can lie about marked: a series too short to call a direction, and a left edge that " +
|
|
266
|
+
"is where the record starts rather than where the conversation did.");
|
|
265
267
|
return lines.join("\n");
|
|
266
268
|
}
|
|
267
269
|
function toResult(proxy) {
|
|
@@ -445,7 +447,13 @@ export function registerBrandWatch(server, makeClient) {
|
|
|
445
447
|
"`term` a watch was created for. Nothing was fetched and nothing was charged.",
|
|
446
448
|
},
|
|
447
449
|
],
|
|
448
|
-
structuredContent: {
|
|
450
|
+
structuredContent: {
|
|
451
|
+
guidance: "This needs a watch to read: pass `watchId` (from list_brand_watches) or the " +
|
|
452
|
+
"`term` a watch was created for. Nothing was fetched and nothing was charged.",
|
|
453
|
+
available: false,
|
|
454
|
+
billable: false,
|
|
455
|
+
mcpCredits: { cost: 0 },
|
|
456
|
+
},
|
|
449
457
|
};
|
|
450
458
|
}
|
|
451
459
|
try {
|
|
@@ -453,13 +461,17 @@ export function registerBrandWatch(server, makeClient) {
|
|
|
453
461
|
const result = toResult(proxy);
|
|
454
462
|
const sc = (result.structuredContent ?? {});
|
|
455
463
|
const runs = Array.isArray(sc.runs) ? sc.runs : [];
|
|
456
|
-
// Guidance in
|
|
457
|
-
//
|
|
464
|
+
// Guidance in BOTH places, not just the text block. A host that
|
|
465
|
+
// renders structuredContent drops every content text block and shows
|
|
466
|
+
// the model the serialised JSON instead, so a text-only copy reaches
|
|
467
|
+
// nobody — measured across 59 real runs. What it has to prevent is a
|
|
458
468
|
// model reading two points as a trend, and reading the start of the
|
|
459
469
|
// retention window as the moment a conversation began.
|
|
470
|
+
const guidance = trendGuidance(sc, runs);
|
|
460
471
|
return {
|
|
461
472
|
...result,
|
|
462
|
-
content: [{ type: "text", text:
|
|
473
|
+
content: [{ type: "text", text: guidance }],
|
|
474
|
+
structuredContent: { guidance, ...sc },
|
|
463
475
|
};
|
|
464
476
|
}
|
|
465
477
|
catch (err) {
|
package/dist/shared/evidence.js
CHANGED
|
@@ -115,6 +115,58 @@ export function handleMissGuidance(a) {
|
|
|
115
115
|
];
|
|
116
116
|
return lines.filter((l, i, all) => !(l === "" && all[i - 1] === "")).join("\n");
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Every network the surface can be asked for, for recognising one in an error.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately a flat list rather than a per-capability one: this is used to
|
|
122
|
+
* spot a platform NAME in a failure message, and a platform that failed is a
|
|
123
|
+
* platform whatever it was capable of.
|
|
124
|
+
*/
|
|
125
|
+
export const KNOWN_PLATFORMS = [
|
|
126
|
+
"tiktok",
|
|
127
|
+
"instagram",
|
|
128
|
+
"youtube",
|
|
129
|
+
"douyin",
|
|
130
|
+
"xiaohongshu",
|
|
131
|
+
"twitter",
|
|
132
|
+
"bilibili",
|
|
133
|
+
"linkedin",
|
|
134
|
+
"reddit",
|
|
135
|
+
"weibo",
|
|
136
|
+
];
|
|
137
|
+
/**
|
|
138
|
+
* What to say when a network ERRORED, as opposed to came back empty.
|
|
139
|
+
*
|
|
140
|
+
* Those want opposite responses and nothing distinguished them. An empty
|
|
141
|
+
* result means try a different query; an error means try a different network,
|
|
142
|
+
* or tell the user. Without that split a host does the one thing that cannot
|
|
143
|
+
* work: re-runs the same call with the words changed.
|
|
144
|
+
*
|
|
145
|
+
* From a real session. Reddit's discovery endpoint started failing, and the
|
|
146
|
+
* model read "The social data service could not complete this request" as a
|
|
147
|
+
* bad query — three more discover_social_posts calls with reworded niches,
|
|
148
|
+
* then a search_mentions, every one a paid upstream call against a network
|
|
149
|
+
* that was never going to answer. The user had asked for "X and Reddit"; they
|
|
150
|
+
* were told about X and never told Reddit had failed at all.
|
|
151
|
+
*
|
|
152
|
+
* So: name the network, say the failure is not about the query, forbid the
|
|
153
|
+
* reword loop explicitly, and say what to do instead. `handleMissGuidance`'s
|
|
154
|
+
* shape, for the other half of the same problem.
|
|
155
|
+
*/
|
|
156
|
+
export function platformFailureGuidance(a) {
|
|
157
|
+
return [
|
|
158
|
+
`${a.platform} could not be searched: ${a.message}`,
|
|
159
|
+
"",
|
|
160
|
+
"This is the network failing, not your query. Do NOT call this again with different " +
|
|
161
|
+
"wording, a different niche phrasing, or a different tool against the same network — " +
|
|
162
|
+
"each attempt is another paid upstream call that fails the same way.",
|
|
163
|
+
"",
|
|
164
|
+
`Carry on with any other networks the user asked for, and tell them plainly that ` +
|
|
165
|
+
`${a.platform} could not be searched this time. An answer that silently covers half of ` +
|
|
166
|
+
"what was asked for reads as a complete answer, which is worse than a short one that " +
|
|
167
|
+
"says what is missing.",
|
|
168
|
+
].join("\n");
|
|
169
|
+
}
|
|
118
170
|
/** Closing line every guidance block shares. */
|
|
119
171
|
export const ownIt = "Reason over this yourself rather than asking for an interpretation of it — " +
|
|
120
172
|
"you can see everything the analysis would have been built from. Everything fetched here — " +
|
|
@@ -251,6 +303,21 @@ export const EVIDENCE_PLANS = {
|
|
|
251
303
|
`Focus for this pass, as asked: ${focus}. Still cover the profile as a whole, but weight what you cover toward this.`,
|
|
252
304
|
]
|
|
253
305
|
: []),
|
|
306
|
+
"",
|
|
307
|
+
// The redirect has to be here rather than only in the description,
|
|
308
|
+
// because by the time a description could help this tool has already
|
|
309
|
+
// been chosen. Measured: the phrase "track what X has been doing"
|
|
310
|
+
// retrieves analyze_creator_profile and never track_creator —
|
|
311
|
+
// track_creator's name only surfaces when the user says the word
|
|
312
|
+
// "competitor", which is not how most people phrase it. A result
|
|
313
|
+
// naming the other tool is the one channel that reaches a model which
|
|
314
|
+
// has already committed to this one.
|
|
315
|
+
"If what was actually asked for was to TRACK this creator — how they are",
|
|
316
|
+
"doing lately, what they have shipped since last time, keeping an eye on",
|
|
317
|
+
"them — this is the wrong tool and you should call track_creator",
|
|
318
|
+
"instead. It scores each post against this creator's OWN median rather",
|
|
319
|
+
"than handing you raw counts, and it remembers where you left off, so",
|
|
320
|
+
"the next call can say what is new. This one does neither.",
|
|
254
321
|
ownIt,
|
|
255
322
|
].join("\n");
|
|
256
323
|
},
|
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") : [];
|
|
@@ -596,9 +597,14 @@ const metricArg = z
|
|
|
596
597
|
.describe("Which stat to rank on (default views).");
|
|
597
598
|
export function registerJobTools(server, makeClient, store) {
|
|
598
599
|
/** Guidance in the text block, evidence in the structured one — as every tool here does. */
|
|
600
|
+
// Both channels, because only one of them arrives. A host that renders
|
|
601
|
+
// `structuredContent` replaces the content text blocks with the serialised
|
|
602
|
+
// JSON, so guidance kept only in a text block reaches no model — measured at
|
|
603
|
+
// 0 phrases across 175 tool results. See tools.ts's runEvidence for the full
|
|
604
|
+
// note, and docs/testing/tool-chaining-quests.md for the measurement.
|
|
599
605
|
const evidence = (guidance, payload) => ({
|
|
600
606
|
content: [{ type: "text", text: guidance }],
|
|
601
|
-
structuredContent: payload,
|
|
607
|
+
structuredContent: { guidance, ...payload },
|
|
602
608
|
});
|
|
603
609
|
const failed = (prefix, err) => ({
|
|
604
610
|
content: [{ type: "text", text: `${prefix}: ${reason(err)}` }],
|
|
@@ -801,23 +807,25 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
801
807
|
});
|
|
802
808
|
});
|
|
803
809
|
// ───────────────────────────────────────────────────────────────────────────
|
|
804
|
-
// 2.
|
|
810
|
+
// 2. track_creator
|
|
805
811
|
//
|
|
806
812
|
// Writes, when the creator is on the watchlist: it moves a baseline forward
|
|
807
813
|
// so the next call can say what is new. That baseline is deliberately not
|
|
808
814
|
// the one catch_up_watchlist keeps — two tools sharing one "since I last
|
|
809
815
|
// looked" means each silently consumes the other's answer.
|
|
810
816
|
// ───────────────────────────────────────────────────────────────────────────
|
|
811
|
-
server.registerTool("
|
|
817
|
+
server.registerTool("track_creator", {
|
|
812
818
|
title: "Track Competitor",
|
|
813
|
-
_meta: viewMeta("
|
|
814
|
-
description: "
|
|
815
|
-
"
|
|
816
|
-
"
|
|
817
|
-
"
|
|
818
|
-
"
|
|
819
|
-
"
|
|
820
|
-
"
|
|
819
|
+
_meta: viewMeta("track_creator"),
|
|
820
|
+
description: "Track a competitor, keep an eye on a rival, see how someone is doing lately, catch what " +
|
|
821
|
+
"they shipped since you last looked — this is the tool for all of those. What a creator " +
|
|
822
|
+
"shipped recently and which of it beat THEIR OWN baseline: fetches their recent posts " +
|
|
823
|
+
"once and scores each against the median of that same window, because a raw view count " +
|
|
824
|
+
"mostly measures follower count — outperformance against themselves is the signal. If " +
|
|
825
|
+
"they are on your watchlist it also marks what is new since your last track_creator " +
|
|
826
|
+
"call and moves that marker forward. " +
|
|
827
|
+
"Consumes 2 nooticr credits — one post list, whatever the window size. Use for a rival " +
|
|
828
|
+
"you follow; analyze_creator_profile is the full teardown of one you do not.",
|
|
821
829
|
annotations: {
|
|
822
830
|
readOnlyHint: false,
|
|
823
831
|
destructiveHint: false,
|
|
@@ -826,7 +834,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
826
834
|
idempotentHint: false,
|
|
827
835
|
openWorldHint: true,
|
|
828
836
|
},
|
|
829
|
-
outputSchema: OUTPUT_SCHEMAS.
|
|
837
|
+
outputSchema: OUTPUT_SCHEMAS.track_creator,
|
|
830
838
|
inputSchema: z
|
|
831
839
|
.object({
|
|
832
840
|
username: z
|
|
@@ -863,7 +871,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
863
871
|
feed = rowsOf(structured.posts);
|
|
864
872
|
}
|
|
865
873
|
catch (err) {
|
|
866
|
-
return failed("
|
|
874
|
+
return failed("track_creator could not list the posts", err);
|
|
867
875
|
}
|
|
868
876
|
// Nothing came back. Previously this fell through to the scoring path and
|
|
869
877
|
// produced a baseline-less report about zero posts, which reads as "this
|
|
@@ -871,7 +879,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
871
879
|
if (feed.length === 0) {
|
|
872
880
|
return evidence(handleMissGuidance({ handle, platform, defaulted: platformDefaulted }), {
|
|
873
881
|
mode: "evidence",
|
|
874
|
-
tool: "
|
|
882
|
+
tool: "track_creator",
|
|
875
883
|
evidenceFrom: ["get_user_posts"],
|
|
876
884
|
username: handle,
|
|
877
885
|
platform,
|
|
@@ -941,7 +949,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
941
949
|
lastChecked: previous?.capturedAt,
|
|
942
950
|
}), {
|
|
943
951
|
mode: "evidence",
|
|
944
|
-
tool: "
|
|
952
|
+
tool: "track_creator",
|
|
945
953
|
evidenceFrom: ["get_user_posts"],
|
|
946
954
|
username: handle,
|
|
947
955
|
platform,
|
|
@@ -964,6 +972,185 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
964
972
|
});
|
|
965
973
|
});
|
|
966
974
|
// ───────────────────────────────────────────────────────────────────────────
|
|
975
|
+
// 2b. compare_creators / watchlist_standings
|
|
976
|
+
//
|
|
977
|
+
// The comparison track_creator computes and discards. Both are pure
|
|
978
|
+
// compositions of get_user_posts — one fetch per creator, priced as the sum
|
|
979
|
+
// — and both leave the reading to the caller, because a ranking hides the
|
|
980
|
+
// window size that decides whether the ranking means anything.
|
|
981
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
982
|
+
/** One creator's window, fetched and scored. Shared by both tools below. */
|
|
983
|
+
const standingFor = async (client, spend, handle, platform, metric, cap) => {
|
|
984
|
+
try {
|
|
985
|
+
const res = await client.callTool("get_user_posts", { username: handle, platform, limit: cap });
|
|
986
|
+
const structured = structuredOf(res);
|
|
987
|
+
spend.record("get_user_posts", structured);
|
|
988
|
+
const posts = rowsOf(structured.posts).map((p, i) => ({
|
|
989
|
+
...p,
|
|
990
|
+
postId: postIdOf(p, i),
|
|
991
|
+
postedAt: postDate(p),
|
|
992
|
+
}));
|
|
993
|
+
return creatorStanding(handle, platform, posts, metric, (post) => metricOf(post, metric));
|
|
994
|
+
}
|
|
995
|
+
catch (err) {
|
|
996
|
+
// One creator failing must not lose the others: a comparison of three is
|
|
997
|
+
// still worth reading, and saying which one is missing is the honest
|
|
998
|
+
// version of an answer with a hole in it.
|
|
999
|
+
spend.attempted("get_user_posts");
|
|
1000
|
+
return {
|
|
1001
|
+
handle,
|
|
1002
|
+
platform,
|
|
1003
|
+
window: 0,
|
|
1004
|
+
baseline: null,
|
|
1005
|
+
hitRate: null,
|
|
1006
|
+
medianWinRatio: null,
|
|
1007
|
+
ratios: [],
|
|
1008
|
+
best: null,
|
|
1009
|
+
worst: null,
|
|
1010
|
+
unavailable: reason(err),
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
server.registerTool("compare_creators", {
|
|
1015
|
+
title: "Compare Creators",
|
|
1016
|
+
_meta: viewMeta("compare_creators"),
|
|
1017
|
+
description: "Two to five creators side by side, each scored against THEIR OWN recent median rather " +
|
|
1018
|
+
"than against each other's raw numbers — which is the only way the comparison means " +
|
|
1019
|
+
"anything, because a raw view count mostly measures follower count. Returns per creator: " +
|
|
1020
|
+
"how many posts were scored, their own median, the share of the window that beat it, how " +
|
|
1021
|
+
"hard they beat it when they did, every post's ratio, and their best and worst post. The " +
|
|
1022
|
+
"ranking is yours to make: 'how often' and 'how big' usually disagree, and a hit rate " +
|
|
1023
|
+
"over a short window is one post either way, so the window size travels with every " +
|
|
1024
|
+
"number. Fetches one post list per creator: 2 nooticr credits each, 4-10 in total. " +
|
|
1025
|
+
"Use for 'is their hit rate better than mine'; track_creator is one creator alone.",
|
|
1026
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1027
|
+
outputSchema: OUTPUT_SCHEMAS.compare_creators,
|
|
1028
|
+
inputSchema: z
|
|
1029
|
+
.object({
|
|
1030
|
+
usernames: z
|
|
1031
|
+
.array(z.string())
|
|
1032
|
+
.min(2)
|
|
1033
|
+
.max(5)
|
|
1034
|
+
.describe("2-5 creator handles, with or without @. Include your own to benchmark against " +
|
|
1035
|
+
"them. These are handles, not brand names — resolve a company name first."),
|
|
1036
|
+
platform: z.string().optional().describe(PLATFORM_ARG),
|
|
1037
|
+
limit: z
|
|
1038
|
+
.number()
|
|
1039
|
+
.int()
|
|
1040
|
+
.optional()
|
|
1041
|
+
.describe("Posts per creator (default 12, max 30). One fetch each either way."),
|
|
1042
|
+
metric: metricArg,
|
|
1043
|
+
})
|
|
1044
|
+
.strict(),
|
|
1045
|
+
}, async (args, extra) => {
|
|
1046
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
1047
|
+
const platform = (args.platform || "tiktok").toLowerCase();
|
|
1048
|
+
const cap = clamp(args.limit, 12, 1, 30);
|
|
1049
|
+
const metric = args.metric ?? "views";
|
|
1050
|
+
// Deduped, because comparing a creator against themselves is two paid
|
|
1051
|
+
// fetches for one column.
|
|
1052
|
+
const handles = [...new Set(args.usernames.map(normaliseHandle))].filter(Boolean);
|
|
1053
|
+
if (handles.length < 2) {
|
|
1054
|
+
return evidence("A comparison needs two different creators. After removing duplicates only " +
|
|
1055
|
+
`${handles.length} handle${handles.length === 1 ? "" : "s"} remained, so nothing was ` +
|
|
1056
|
+
"fetched and nothing was charged.", { tool: "compare_creators", creators: [], billable: false, mcpCredits: { cost: 0 } });
|
|
1057
|
+
}
|
|
1058
|
+
const credits = costOf(handles.map(() => "get_user_posts"));
|
|
1059
|
+
const decision = await confirmSpend(server.server, {
|
|
1060
|
+
credits,
|
|
1061
|
+
summary: `Read the recent posts of ${handles.map((h) => "@" + h).join(", ")} on ${platform}.`,
|
|
1062
|
+
cheaper: "Compare fewer creators — each one is a separate fetch.",
|
|
1063
|
+
});
|
|
1064
|
+
if (!decision.proceed) {
|
|
1065
|
+
return declinedResult(credits, "That comparison", "Compare fewer creators.");
|
|
1066
|
+
}
|
|
1067
|
+
const spend = new Spend();
|
|
1068
|
+
const rows = [];
|
|
1069
|
+
for (const handle of handles) {
|
|
1070
|
+
rows.push(await standingFor(client, spend, handle, platform, metric, cap));
|
|
1071
|
+
}
|
|
1072
|
+
return evidence(standingsGuidance({ rows, metric, source: "handles" }) + "\n\n" + ownIt, {
|
|
1073
|
+
mode: "evidence",
|
|
1074
|
+
tool: "compare_creators",
|
|
1075
|
+
evidenceFrom: ["get_user_posts"],
|
|
1076
|
+
platform,
|
|
1077
|
+
platformDefaulted: !args.platform,
|
|
1078
|
+
metric,
|
|
1079
|
+
thinWindow: THIN_WINDOW,
|
|
1080
|
+
aboveRatio: ABOVE_RATIO,
|
|
1081
|
+
creators: rows,
|
|
1082
|
+
creditsCharged: spend.credits,
|
|
1083
|
+
mcpCredits: spend.payload,
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
server.registerTool("watchlist_standings", {
|
|
1087
|
+
title: "Watchlist Standings",
|
|
1088
|
+
_meta: viewMeta("watchlist_standings"),
|
|
1089
|
+
description: "The same comparison across everyone on your watchlist, answering 'who moved' in one " +
|
|
1090
|
+
"call instead of one per creator. Each is scored against their own recent median, so the " +
|
|
1091
|
+
"numbers are comparable between accounts of different sizes; the window size travels " +
|
|
1092
|
+
"with each so a short one is not ranked as if it were a season. Reads the watchlist " +
|
|
1093
|
+
"itself for free and fetches one post list per creator on it: 2 nooticr credits each, so " +
|
|
1094
|
+
"the price is the size of your watchlist — it confirms before spending. Use for the " +
|
|
1095
|
+
"weekly 'who is accelerating'; catch_up_watchlist is what is NEW rather than how it did.",
|
|
1096
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1097
|
+
outputSchema: OUTPUT_SCHEMAS.watchlist_standings,
|
|
1098
|
+
inputSchema: z
|
|
1099
|
+
.object({
|
|
1100
|
+
limit: z
|
|
1101
|
+
.number()
|
|
1102
|
+
.int()
|
|
1103
|
+
.optional()
|
|
1104
|
+
.describe("Posts per creator (default 12, max 30). One fetch each either way."),
|
|
1105
|
+
metric: metricArg,
|
|
1106
|
+
})
|
|
1107
|
+
.strict(),
|
|
1108
|
+
}, async (args, extra) => {
|
|
1109
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
1110
|
+
const cap = clamp(args.limit, 12, 1, 30);
|
|
1111
|
+
const metric = args.metric ?? "views";
|
|
1112
|
+
let entries = [];
|
|
1113
|
+
try {
|
|
1114
|
+
entries = await store.list(await watchlistOwner(client));
|
|
1115
|
+
}
|
|
1116
|
+
catch (err) {
|
|
1117
|
+
return failed("watchlist_standings could not read your watchlist", err);
|
|
1118
|
+
}
|
|
1119
|
+
if (!entries.length) {
|
|
1120
|
+
// Not an error and not billed: an empty watchlist is a state with a
|
|
1121
|
+
// next step, and saying so beats a comparison of nobody.
|
|
1122
|
+
return evidence("Your watchlist is empty, so there is nobody to compare. Add creators with " +
|
|
1123
|
+
"watch_creator first — the standings are then one call. Nothing was charged.", { tool: "watchlist_standings", watching: 0, creators: [], billable: false, mcpCredits: { cost: 0 } });
|
|
1124
|
+
}
|
|
1125
|
+
const credits = entries.length * CREDITS_PER_CREATOR;
|
|
1126
|
+
const decision = await confirmSpend(server.server, {
|
|
1127
|
+
credits,
|
|
1128
|
+
summary: `Read the recent posts of all ${entries.length} creator${entries.length === 1 ? "" : "s"} ` +
|
|
1129
|
+
"on your watchlist and score each against their own median.",
|
|
1130
|
+
cheaper: "Unwatch creators you no longer follow, or use track_creator for just one.",
|
|
1131
|
+
});
|
|
1132
|
+
if (!decision.proceed) {
|
|
1133
|
+
return declinedResult(credits, "Those standings", "Use track_creator for a single creator.");
|
|
1134
|
+
}
|
|
1135
|
+
const spend = new Spend();
|
|
1136
|
+
const rows = [];
|
|
1137
|
+
for (const entry of entries) {
|
|
1138
|
+
rows.push(await standingFor(client, spend, entry.handle, entry.platform, metric, cap));
|
|
1139
|
+
}
|
|
1140
|
+
return evidence(standingsGuidance({ rows, metric, source: "watchlist" }) + "\n\n" + ownIt, {
|
|
1141
|
+
mode: "evidence",
|
|
1142
|
+
tool: "watchlist_standings",
|
|
1143
|
+
evidenceFrom: ["get_user_posts"],
|
|
1144
|
+
metric,
|
|
1145
|
+
watching: entries.length,
|
|
1146
|
+
thinWindow: THIN_WINDOW,
|
|
1147
|
+
aboveRatio: ABOVE_RATIO,
|
|
1148
|
+
creators: rows,
|
|
1149
|
+
creditsCharged: spend.credits,
|
|
1150
|
+
mcpCredits: spend.payload,
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
1153
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
967
1154
|
// 3. who_should_i_work_with
|
|
968
1155
|
// ───────────────────────────────────────────────────────────────────────────
|
|
969
1156
|
server.registerTool("who_should_i_work_with", {
|
|
@@ -1637,7 +1824,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1637
1824
|
const explicitHandles = [...new Set((args.usernames ?? []).map(normaliseHandle).filter(Boolean))];
|
|
1638
1825
|
// A watchlist handle already knows its own platform, so it is checked
|
|
1639
1826
|
// once there rather than once per requested platform — the same
|
|
1640
|
-
// precision
|
|
1827
|
+
// precision track_creator already applies when it reads this store.
|
|
1641
1828
|
let watchlistUnits = [];
|
|
1642
1829
|
let watchlistTotal = 0;
|
|
1643
1830
|
if (args.useWatchlist) {
|
|
@@ -1868,4 +2055,166 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1868
2055
|
mcpCredits: spend.payload,
|
|
1869
2056
|
});
|
|
1870
2057
|
});
|
|
2058
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
2059
|
+
// find_people_with_problem — the prospect search this surface did not have.
|
|
2060
|
+
//
|
|
2061
|
+
// Every other discovery tool here is built around a TOPIC or a TERM:
|
|
2062
|
+
// discover_social_posts takes a niche, search_mentions takes a brand,
|
|
2063
|
+
// search_creators takes a keyword. None is built around a PROBLEM SOMEONE
|
|
2064
|
+
// DESCRIBED, which is the shape of "find people who would want this".
|
|
2065
|
+
//
|
|
2066
|
+
// A user asked exactly that — "search X and Reddit for people that might see
|
|
2067
|
+
// our product as a great use for their daily struggle" — and the model did
|
|
2068
|
+
// the only thing available: fed the pain point into discover_social_posts as
|
|
2069
|
+
// a `niche`. That argument is documented as "Niche/topic, e.g. 'fitness'",
|
|
2070
|
+
// the backend runs it as a keyword search, and a seven-word sentence matched
|
|
2071
|
+
// on its commonest words. Seven of eight results were marketing content; the
|
|
2072
|
+
// one on-target result was a competitor's ad.
|
|
2073
|
+
//
|
|
2074
|
+
// What this does differently is not smarter matching — we run the same
|
|
2075
|
+
// keyword search, because that is what the backend has. It is that the query
|
|
2076
|
+
// is EXPANDED into the shapes a complaint actually takes, the platforms
|
|
2077
|
+
// default to where people complain rather than where people market, and the
|
|
2078
|
+
// result says plainly that it is a wide net the caller still has to sort.
|
|
2079
|
+
// The judging is the host's, per this repo's whole design.
|
|
2080
|
+
server.registerTool("find_people_with_problem", {
|
|
2081
|
+
title: "Find People With A Problem",
|
|
2082
|
+
_meta: viewMeta("find_people_with_problem"),
|
|
2083
|
+
description: "Find people describing a problem in their own words — prospects, early users, anyone " +
|
|
2084
|
+
"whose complaint your product answers. Say the problem the way a person would say it " +
|
|
2085
|
+
"(\"I spend hours checking competitors by hand\") and this searches for posts shaped like " +
|
|
2086
|
+
"that complaint, not for the topic it is about. That difference is the point: searching " +
|
|
2087
|
+
"the TOPIC finds marketing about it, which is what discover_social_posts is for. " +
|
|
2088
|
+
"Widens the query into the forms a complaint takes — the plain phrasing, the " +
|
|
2089
|
+
"does-anyone-else question, the is-there-a-tool ask — and merges what each returns. " +
|
|
2090
|
+
"It is a wide cheap net and NOT a filter: the posts come back for you to judge, and " +
|
|
2091
|
+
"many will be off-target. Consumes 2 nooticr credits per platform searched. Defaults to " +
|
|
2092
|
+
"reddit, where people describe workflow pain in sentences; add twitter for volume.",
|
|
2093
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2094
|
+
outputSchema: OUTPUT_SCHEMAS.find_people_with_problem,
|
|
2095
|
+
inputSchema: z
|
|
2096
|
+
.object({
|
|
2097
|
+
problem: z
|
|
2098
|
+
.string()
|
|
2099
|
+
.describe("The problem in a person's own words, not a topic. \"I waste an hour a day " +
|
|
2100
|
+
"checking what competitors posted\" finds people; \"competitor analysis\" finds " +
|
|
2101
|
+
"marketing about competitor analysis."),
|
|
2102
|
+
platforms: z
|
|
2103
|
+
.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram", "linkedin"]))
|
|
2104
|
+
.optional()
|
|
2105
|
+
.describe("Where to look (default reddit). Each one is a separate paid search."),
|
|
2106
|
+
limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."),
|
|
2107
|
+
})
|
|
2108
|
+
.strict(),
|
|
2109
|
+
}, async (args, extra) => {
|
|
2110
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
2111
|
+
const spend = new Spend();
|
|
2112
|
+
const problem = args.problem.trim();
|
|
2113
|
+
const platforms = args.platforms?.length ? args.platforms : ["reddit"];
|
|
2114
|
+
const cap = clamp(args.limit, 6, 1, 15);
|
|
2115
|
+
// The shapes a complaint takes, rather than the topic it is about. Kept
|
|
2116
|
+
// to three: each is a paid call per platform, and past three they start
|
|
2117
|
+
// returning the same posts as each other.
|
|
2118
|
+
const shapes = [
|
|
2119
|
+
{ shape: "plain", query: problem },
|
|
2120
|
+
{ shape: "shared", query: `anyone else ${problem}` },
|
|
2121
|
+
{ shape: "asking", query: `is there a way to ${problem}` },
|
|
2122
|
+
];
|
|
2123
|
+
const seen = new Set();
|
|
2124
|
+
const posts = [];
|
|
2125
|
+
const unavailable = [];
|
|
2126
|
+
for (const platform of platforms) {
|
|
2127
|
+
for (const { shape, query } of shapes) {
|
|
2128
|
+
try {
|
|
2129
|
+
const res = await client.callTool("discover_social_posts", { niche: query, platform, limit: cap });
|
|
2130
|
+
const structured = (res.structured ?? {});
|
|
2131
|
+
spend.record("discover_social_posts", structured);
|
|
2132
|
+
for (const raw of structured.posts ?? []) {
|
|
2133
|
+
const key = String(raw.externalUrl ?? raw.url ?? JSON.stringify(raw));
|
|
2134
|
+
if (seen.has(key))
|
|
2135
|
+
continue;
|
|
2136
|
+
seen.add(key);
|
|
2137
|
+
// `foundBy` is what makes the net inspectable: a post only the
|
|
2138
|
+
// does-anyone-else phrasing found is a different kind of hit
|
|
2139
|
+
// from one the plain phrasing found, and the caller judging
|
|
2140
|
+
// these should be able to see which.
|
|
2141
|
+
posts.push({ ...raw, foundBy: shape, platform: raw.platform ?? platform });
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
catch (err) {
|
|
2145
|
+
spend.attempted("discover_social_posts");
|
|
2146
|
+
// A platform that failed is reported, not retried with different
|
|
2147
|
+
// words — the failure is the network, not the phrasing. See #49.
|
|
2148
|
+
unavailable.push({ platform, shape, reason: reason(err) });
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
if (!posts.length) {
|
|
2153
|
+
return evidence([
|
|
2154
|
+
`Nothing came back for "${problem}" on ${platforms.join(", ")}.`,
|
|
2155
|
+
"",
|
|
2156
|
+
unavailable.length
|
|
2157
|
+
? `${unavailable.length} of ${platforms.length * shapes.length} searches errored, so ` +
|
|
2158
|
+
"this may be the network rather than the problem statement — say which networks " +
|
|
2159
|
+
"could not be searched rather than reporting an empty result as a finding."
|
|
2160
|
+
: "The searches worked and found nothing shaped like this complaint. Try the " +
|
|
2161
|
+
"problem in fewer, plainer words — this is a keyword search underneath, so a " +
|
|
2162
|
+
"long sentence matches on its commonest words.",
|
|
2163
|
+
"",
|
|
2164
|
+
ownIt,
|
|
2165
|
+
].join("\n"), {
|
|
2166
|
+
mode: "evidence",
|
|
2167
|
+
tool: "find_people_with_problem",
|
|
2168
|
+
problem,
|
|
2169
|
+
platforms,
|
|
2170
|
+
posts: [],
|
|
2171
|
+
unavailable,
|
|
2172
|
+
creditsCharged: spend.credits,
|
|
2173
|
+
mcpCredits: spend.payload,
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
return evidence([
|
|
2177
|
+
`${posts.length} posts that might be someone describing "${problem}", across ` +
|
|
2178
|
+
`${platforms.join(", ")}.`,
|
|
2179
|
+
"",
|
|
2180
|
+
"This is a wide net, not a filter. It is a keyword search underneath, so a good number " +
|
|
2181
|
+
"of these will be marketing ABOUT the problem rather than someone having it — that " +
|
|
2182
|
+
"distinction is the whole job here and it is yours to make, not something the search " +
|
|
2183
|
+
"could do.",
|
|
2184
|
+
"",
|
|
2185
|
+
"For each post decide: is this person describing this problem as THEIRS, right now? A " +
|
|
2186
|
+
"complaint, a question, a workaround they are tired of, all count. An ad for a tool " +
|
|
2187
|
+
"that solves it does not, and neither does a listicle. Quote the line that made you " +
|
|
2188
|
+
"decide, and name the post you are reasoning from.",
|
|
2189
|
+
"",
|
|
2190
|
+
"Report the ones that qualify with their permalink, so they can actually be replied to, " +
|
|
2191
|
+
"and say how many you rejected and why — a shortlist with no reject count reads as if " +
|
|
2192
|
+
"the search was precise.",
|
|
2193
|
+
"",
|
|
2194
|
+
"`foundBy` says which phrasing surfaced each post: `plain` the problem itself, `shared` " +
|
|
2195
|
+
"the does-anyone-else form, `asking` the is-there-a-tool form. The last two skew " +
|
|
2196
|
+
"towards genuine complaints and the first towards content about the topic.",
|
|
2197
|
+
"",
|
|
2198
|
+
unavailable.length
|
|
2199
|
+
? `Note that ${unavailable.length} searches errored — tell the user which networks ` +
|
|
2200
|
+
"could not be searched rather than presenting this as the whole picture.\n"
|
|
2201
|
+
: "",
|
|
2202
|
+
ownIt,
|
|
2203
|
+
]
|
|
2204
|
+
.filter((l, i, all) => !(l === "" && all[i - 1] === ""))
|
|
2205
|
+
.join("\n"), {
|
|
2206
|
+
mode: "evidence",
|
|
2207
|
+
tool: "find_people_with_problem",
|
|
2208
|
+
evidenceFrom: ["discover_social_posts"],
|
|
2209
|
+
problem,
|
|
2210
|
+
platforms,
|
|
2211
|
+
// `posts` is the key the gallery view already draws, so this needs no
|
|
2212
|
+
// view of its own.
|
|
2213
|
+
posts,
|
|
2214
|
+
searchedShapes: shapes.map((s) => s.shape),
|
|
2215
|
+
unavailable,
|
|
2216
|
+
creditsCharged: spend.credits,
|
|
2217
|
+
mcpCredits: spend.payload,
|
|
2218
|
+
});
|
|
2219
|
+
});
|
|
1871
2220
|
}
|