@nooticr/mcp 1.26.19
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/LICENSE +21 -0
- package/README.md +521 -0
- package/dist/auth.js +170 -0
- package/dist/config.js +54 -0
- package/dist/idempotency.js +32 -0
- package/dist/index.js +417 -0
- package/dist/nooticr.js +6 -0
- package/dist/oauth.js +263 -0
- package/dist/shared/brand-watch.js +297 -0
- package/dist/shared/cimd.js +231 -0
- package/dist/shared/collab.js +447 -0
- package/dist/shared/comment-review.js +171 -0
- package/dist/shared/connections.js +80 -0
- package/dist/shared/evidence.js +410 -0
- package/dist/shared/handoff.js +526 -0
- package/dist/shared/jobs.js +1785 -0
- package/dist/shared/nooticr.js +249 -0
- package/dist/shared/oauth.js +132 -0
- package/dist/shared/output-schemas.js +1046 -0
- package/dist/shared/own-account.js +559 -0
- package/dist/shared/performance.js +156 -0
- package/dist/shared/prompts.js +383 -0
- package/dist/shared/spend.js +158 -0
- package/dist/shared/tasks.js +88 -0
- package/dist/shared/tools-def.js +387 -0
- package/dist/shared/tools.js +2027 -0
- package/dist/shared/ui-template.js +2856 -0
- package/dist/shared/video.js +152 -0
- package/dist/shared/view-meta.js +34 -0
- package/dist/shared/watchlist.js +603 -0
- package/dist/video.js +6 -0
- package/package.json +62 -0
|
@@ -0,0 +1,1785 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools named after the job, not after the endpoint.
|
|
3
|
+
*
|
|
4
|
+
* ## What is wrong with a tool per endpoint
|
|
5
|
+
*
|
|
6
|
+
* Almost everything here wraps one backend call and returns what it returned.
|
|
7
|
+
* That is a fine SDK and a poor product: the user's question is "who is asking
|
|
8
|
+
* me things I have not answered", and answering it means knowing to call
|
|
9
|
+
* `get_user_posts`, then `get_post_comments` once per post, then reading the
|
|
10
|
+
* results for questions, then remembering which post each question came from.
|
|
11
|
+
* A model can be talked through that. It usually is not, because nothing in
|
|
12
|
+
* the tool list suggests the sequence exists.
|
|
13
|
+
*
|
|
14
|
+
* `search_mentions` is the one tool that already works the other way, and the
|
|
15
|
+
* five here are built to its shape:
|
|
16
|
+
*
|
|
17
|
+
* - it names a job ("monitor a brand") rather than a resource;
|
|
18
|
+
* - it fans out across the networks itself instead of being called nine
|
|
19
|
+
* times;
|
|
20
|
+
* - it returns evidence grouped by the unit of decision — the comment,
|
|
21
|
+
* under the post it was left on, because that is what you act on;
|
|
22
|
+
* - every item carries an addressable id, so a second tool can act on
|
|
23
|
+
* exactly the ones a person picked;
|
|
24
|
+
* - and it pages and windows, so a sweep is affordable.
|
|
25
|
+
*
|
|
26
|
+
* ## They are compositions, and they add no backend
|
|
27
|
+
*
|
|
28
|
+
* Every one of these is existing backend calls in a sequence, plus arithmetic
|
|
29
|
+
* over what came back. Nothing here calls Gemini or asks the server for a
|
|
30
|
+
* judgement, and no endpoint was added to serve them — where a tool wanted a
|
|
31
|
+
* capability the backend does not have (audience overlap between two accounts,
|
|
32
|
+
* a post's publication date on platforms that withhold it) it says so in the
|
|
33
|
+
* result rather than pretending.
|
|
34
|
+
*
|
|
35
|
+
* The reasoning is handed to the calling model for the reason evidence.ts
|
|
36
|
+
* gives: it is text over text, the model holding the conversation is better at
|
|
37
|
+
* it than a Flash model behind another network hop, and it costs us nothing.
|
|
38
|
+
* What we sell is the fetch.
|
|
39
|
+
*
|
|
40
|
+
* ## Billing
|
|
41
|
+
*
|
|
42
|
+
* The sum of the calls actually made, exactly as every other tool here is. That is a
|
|
43
|
+
* real number here rather than a constant, because these fan out — twelve
|
|
44
|
+
* posts is twelve comment fetches — so each tool caps its fan-out, makes the
|
|
45
|
+
* cap an argument, prints the arithmetic in its description, and routes
|
|
46
|
+
* anything above the established threshold through the same elicitation
|
|
47
|
+
* `search_mentions` uses.
|
|
48
|
+
*
|
|
49
|
+
* ## Partial results
|
|
50
|
+
*
|
|
51
|
+
* A creator with no recent posts, a platform that declines, a post with its
|
|
52
|
+
* comments switched off: all normal, none of them a reason to fail a call that
|
|
53
|
+
* has already spent credits on the parts that worked. Every fan-out here
|
|
54
|
+
* collects failures into `unavailable` and returns what it got.
|
|
55
|
+
*/
|
|
56
|
+
import { z } from "zod";
|
|
57
|
+
import { OUTPUT_SCHEMAS } from "./output-schemas.js";
|
|
58
|
+
import { platformFromUrl, postSlug } from "./comment-review.js";
|
|
59
|
+
import { ownIt } from "./evidence.js";
|
|
60
|
+
import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, } from "./spend.js";
|
|
61
|
+
import { distributionOf, excluding, numberOf, standing, } from "./performance.js";
|
|
62
|
+
import { normaliseHandle, watchEntryId, watchlistOwner } from "./watchlist.js";
|
|
63
|
+
import { viewMeta } from "./view-meta.js";
|
|
64
|
+
import { extractLinks, COLLAB_RUBRIC, vettingGuidance } from "./collab.js";
|
|
65
|
+
/** Every fan-out is capped; the cap is an argument, and the argument is clamped. */
|
|
66
|
+
function clamp(value, fallback, min, max) {
|
|
67
|
+
const n = Math.floor(numberOf(value ?? fallback));
|
|
68
|
+
if (!n)
|
|
69
|
+
return fallback;
|
|
70
|
+
return Math.min(max, Math.max(min, n));
|
|
71
|
+
}
|
|
72
|
+
const rowsOf = (value) => Array.isArray(value) ? value.filter((r) => r && typeof r === "object") : [];
|
|
73
|
+
const structuredOf = (res) => (res.structured ?? {});
|
|
74
|
+
const reason = (err) => (err instanceof Error ? err.message : String(err));
|
|
75
|
+
/**
|
|
76
|
+
* An addressable id for a post.
|
|
77
|
+
*
|
|
78
|
+
* Qualified by platform, unlike the comment ids, because these tools put a
|
|
79
|
+
* niche sweep and a creator's own feed in the same payload and the same
|
|
80
|
+
* numeric video id exists on TikTok and on Douyin. `postSlug` handles the
|
|
81
|
+
* YouTube case — the video id lives in `?v=`, so the last path segment is
|
|
82
|
+
* "watch" for every video on the platform and ids built from it collided
|
|
83
|
+
* across posts, which is the bug this scheme exists to avoid.
|
|
84
|
+
*/
|
|
85
|
+
export function postIdOf(post, index) {
|
|
86
|
+
const url = String(post.externalUrl ?? post.url ?? "");
|
|
87
|
+
const platform = String(post.platform ?? "") || platformFromUrl(url) || "unknown";
|
|
88
|
+
const slug = url ? postSlug(url) : "";
|
|
89
|
+
// postSlug falls back to the literal "post" for anything that is not a URL,
|
|
90
|
+
// which would be the same string for every undated row in a feed. The
|
|
91
|
+
// platform's own id, then the position, are unique where it is not.
|
|
92
|
+
const key = slug && slug !== "post" ? slug : String(post.id ?? "") || `at${index}`;
|
|
93
|
+
return `post:${platform}:${key}`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A comment's id, derived from the comment rather than from where it sat.
|
|
97
|
+
*
|
|
98
|
+
* The older schemes here are positional. `analyze_comments` mints
|
|
99
|
+
* `comment:<postSlug>:<index>`, and `search_mentions` mints
|
|
100
|
+
* `<platform>:<postId>:<index into the whole result>` — and an index moves.
|
|
101
|
+
* Change `commentsPerPost`, page with a different `offset`, apply a `since`
|
|
102
|
+
* window, or simply call again after three new comments have landed, and the
|
|
103
|
+
* same id now names a different comment. That is worse than having no id: a
|
|
104
|
+
* host that stored one to address a specific comment silently gets another.
|
|
105
|
+
*
|
|
106
|
+
* So this uses what the comment carries. The platform's own id where there is
|
|
107
|
+
* one — TikTok's `cid` is the case that matters — and otherwise a fingerprint
|
|
108
|
+
* of the post it is under, who wrote it and what it says, which are the three
|
|
109
|
+
* things that do not change between two fetches of the same comment.
|
|
110
|
+
*
|
|
111
|
+
* The consequence, stated rather than hidden: two byte-identical comments by
|
|
112
|
+
* the same author under the same post collapse to one id. They are
|
|
113
|
+
* indistinguishable in the payload as well, so there is nothing to tell them
|
|
114
|
+
* apart with; a positional id would merely have disguised that.
|
|
115
|
+
*/
|
|
116
|
+
export function commentIdFor(ownerPostId, comment) {
|
|
117
|
+
const key = ownerPostId.replace(/^post:/, "");
|
|
118
|
+
const upstream = String(comment.id ?? comment.cid ?? comment.commentId ?? "").trim();
|
|
119
|
+
if (upstream)
|
|
120
|
+
return `comment:${key}:${upstream}`;
|
|
121
|
+
const author = String(comment.author ?? comment.username ?? "").replace(/^@/, "");
|
|
122
|
+
const text = String(comment.text ?? "").trim();
|
|
123
|
+
return `comment:${key}:h${fingerprint(`${key}\u0000${author}\u0000${text}`)}`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* FNV-1a, 32-bit, base36. Not a hash for security — a short stable name for a
|
|
127
|
+
* string, computed the same way in Node and in a Worker with nothing imported.
|
|
128
|
+
* Thirty-two bits is ample for the few dozen comments under one post.
|
|
129
|
+
*/
|
|
130
|
+
function fingerprint(value) {
|
|
131
|
+
let hash = 0x811c9dc5;
|
|
132
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
133
|
+
hash ^= value.charCodeAt(i);
|
|
134
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
135
|
+
}
|
|
136
|
+
return hash.toString(36);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Dates, where the platform gives one.
|
|
140
|
+
*
|
|
141
|
+
* `since` is a documented argument on these tools and it cannot always be
|
|
142
|
+
* honoured: several platforms return a feed with no timestamp on it at all,
|
|
143
|
+
* and the post schema has never declared one. So this probes the spellings
|
|
144
|
+
* that do arrive and the caller reports whether the window was actually
|
|
145
|
+
* applied — a silently ignored `since` is how a monitoring answer becomes
|
|
146
|
+
* wrong without looking wrong.
|
|
147
|
+
*/
|
|
148
|
+
const DATE_KEYS = [
|
|
149
|
+
"postedAt", "publishedAt", "createdAt", "created_at", "publishedTime",
|
|
150
|
+
"takenAt", "timestamp", "createTime", "create_time", "date",
|
|
151
|
+
];
|
|
152
|
+
export function postDate(post) {
|
|
153
|
+
for (const key of DATE_KEYS) {
|
|
154
|
+
const raw = post[key];
|
|
155
|
+
if (raw === null || raw === undefined || raw === "")
|
|
156
|
+
continue;
|
|
157
|
+
if (typeof raw === "number") {
|
|
158
|
+
// Epoch seconds and epoch milliseconds both arrive, and a value below
|
|
159
|
+
// 1e12 is not a plausible millisecond timestamp for a social post.
|
|
160
|
+
const at = new Date(raw > 1e12 ? raw : raw * 1000);
|
|
161
|
+
if (!Number.isNaN(at.getTime()))
|
|
162
|
+
return at.toISOString();
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const at = new Date(String(raw));
|
|
166
|
+
if (!Number.isNaN(at.getTime()))
|
|
167
|
+
return at.toISOString();
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* A comment's date, or null when it cannot be believed.
|
|
173
|
+
*
|
|
174
|
+
* Seven of the nine comment mappers in the importer end with
|
|
175
|
+
* `unwrap_or_else(|| Utc::now())` when the upstream date will not parse, so on
|
|
176
|
+
* most networks every comment comes back stamped with the moment it was
|
|
177
|
+
* fetched. YouTube's mapper is the only one that returns null instead. A
|
|
178
|
+
* fabricated timestamp is not a cosmetic problem: it renders as "2 minutes
|
|
179
|
+
* ago" in the view and reads to a model as a fact about when someone wrote
|
|
180
|
+
* something, and `search_mentions`'s own `since` filter passes everything on
|
|
181
|
+
* those platforms because of it.
|
|
182
|
+
*
|
|
183
|
+
* Nothing in the payload distinguishes a fabricated date from a real one, so
|
|
184
|
+
* the only signal available here is that it is indistinguishable from now.
|
|
185
|
+
* Anything inside this window is dropped. It costs the true date of a genuinely
|
|
186
|
+
* fresh comment, which is a much smaller error than asserting a date for every
|
|
187
|
+
* comment on seven networks.
|
|
188
|
+
*/
|
|
189
|
+
export const FABRICATED_DATE_WINDOW_MS = 5 * 60 * 1000;
|
|
190
|
+
export function trustedCommentDate(raw, now = Date.now()) {
|
|
191
|
+
if (raw === null || raw === undefined || raw === "")
|
|
192
|
+
return null;
|
|
193
|
+
const at = new Date(typeof raw === "number" ? (raw > 1e12 ? raw : raw * 1000) : String(raw));
|
|
194
|
+
const ms = at.getTime();
|
|
195
|
+
if (Number.isNaN(ms))
|
|
196
|
+
return null;
|
|
197
|
+
if (Math.abs(now - ms) < FABRICATED_DATE_WINDOW_MS)
|
|
198
|
+
return null;
|
|
199
|
+
return at.toISOString();
|
|
200
|
+
}
|
|
201
|
+
export function applySince(posts, since) {
|
|
202
|
+
if (!since)
|
|
203
|
+
return { posts, applied: false, undated: 0 };
|
|
204
|
+
const floor = new Date(since).getTime();
|
|
205
|
+
if (Number.isNaN(floor))
|
|
206
|
+
return { posts, applied: false, undated: 0 };
|
|
207
|
+
let dated = 0;
|
|
208
|
+
let undated = 0;
|
|
209
|
+
const kept = posts.filter((p) => {
|
|
210
|
+
const at = postDate(p);
|
|
211
|
+
if (!at) {
|
|
212
|
+
undated += 1;
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
dated += 1;
|
|
216
|
+
return new Date(at).getTime() >= floor;
|
|
217
|
+
});
|
|
218
|
+
return { posts: kept, applied: dated > 0, undated };
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Which comments look like they want an answer.
|
|
222
|
+
*
|
|
223
|
+
* Deliberately a crude test over punctuation and stock phrasing, and
|
|
224
|
+
* deliberately not a judgement: the judgement belongs to the model reading the
|
|
225
|
+
* result, which can tell a rhetorical question from a real one and can read
|
|
226
|
+
* the languages this cannot. What the flag buys is ordering — a creator with
|
|
227
|
+
* two hundred comments wants the twelve that end in a question mark at the
|
|
228
|
+
* top of the page — and the guidance says plainly that it is a sort, not a
|
|
229
|
+
* verdict, so nothing downstream treats it as one.
|
|
230
|
+
*/
|
|
231
|
+
export function replySignals(text) {
|
|
232
|
+
const raw = String(text ?? "");
|
|
233
|
+
const t = raw.toLowerCase();
|
|
234
|
+
const signals = [];
|
|
235
|
+
if (raw.includes("?") || raw.includes("?"))
|
|
236
|
+
signals.push("question mark");
|
|
237
|
+
if (/^(who|what|whats|when|where|why|how|which|can|could|do|does|did|is|are|will|would|should|any)\b/.test(t)) {
|
|
238
|
+
signals.push("opens with a question word");
|
|
239
|
+
}
|
|
240
|
+
if (/\b(how do|how did|how can|how much|what is|what's|whats the|where do|where did|which one|any tips|any advice|recommend|link\b)/.test(t)) {
|
|
241
|
+
signals.push("asks for information");
|
|
242
|
+
}
|
|
243
|
+
if (/\b(please|pls|plz|can you|could you|would you|do a|make a|make more|more of|video on|part 2|part two|tutorial|follow up|followup)\b/.test(t)) {
|
|
244
|
+
signals.push("asks for something to be made");
|
|
245
|
+
}
|
|
246
|
+
if (/\b(not working|doesn'?t work|didn'?t work|broken|error|crash|refund|charged|stuck|won'?t load|failed)\b/.test(t)) {
|
|
247
|
+
signals.push("reports something broken");
|
|
248
|
+
}
|
|
249
|
+
return signals;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* What a run of calls actually cost.
|
|
253
|
+
*
|
|
254
|
+
* The backend reports its own charge per call in `mcpCredits.cost`, and that
|
|
255
|
+
* is the number to trust — a first free use, an admin bypass or a price change
|
|
256
|
+
* upstream all show up there and in no table we keep. The local table is the
|
|
257
|
+
* fallback for a response that omitted it, and the up-front estimate that the
|
|
258
|
+
* spend confirmation is built on.
|
|
259
|
+
*/
|
|
260
|
+
class Spend {
|
|
261
|
+
total = 0;
|
|
262
|
+
calls = [];
|
|
263
|
+
record(tool, structured) {
|
|
264
|
+
this.calls.push(tool);
|
|
265
|
+
const reported = (structured.mcpCredits ?? null);
|
|
266
|
+
const cost = reported ? numberOf(reported.cost) : NaN;
|
|
267
|
+
this.total += Number.isFinite(cost) && reported ? cost : costOf([tool]);
|
|
268
|
+
}
|
|
269
|
+
/** A call that threw. It may still have been billed, so it is not free. */
|
|
270
|
+
attempted(tool) {
|
|
271
|
+
this.calls.push(tool);
|
|
272
|
+
this.total += costOf([tool]);
|
|
273
|
+
}
|
|
274
|
+
get credits() {
|
|
275
|
+
return Math.round(this.total * 100) / 100;
|
|
276
|
+
}
|
|
277
|
+
get payload() {
|
|
278
|
+
return { cost: this.credits, calls: this.calls.length };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** The stats a post carries, reduced to the metrics worth ranking on. */
|
|
282
|
+
export const METRICS = ["views", "likes", "comments", "shares", "engagementRate"];
|
|
283
|
+
export function metricOf(post, metric) {
|
|
284
|
+
return numberOf(post[metric]);
|
|
285
|
+
}
|
|
286
|
+
/** A post trimmed to what a reader and a view both need, plus its standing. */
|
|
287
|
+
function scored(post, index, metric, baseline) {
|
|
288
|
+
const value = metricOf(post, metric);
|
|
289
|
+
return {
|
|
290
|
+
...post,
|
|
291
|
+
// The addressable id goes alongside the platform's own, never over it.
|
|
292
|
+
// Overwriting `id` threw away the only value that lets a caller dedupe
|
|
293
|
+
// against its own records or call the platform's API for this post, to
|
|
294
|
+
// save a field — and `postId` is already the name the flat structures in
|
|
295
|
+
// this file use for exactly this.
|
|
296
|
+
postId: postIdOf(post, index),
|
|
297
|
+
postedAt: postDate(post),
|
|
298
|
+
metric,
|
|
299
|
+
metricValue: value,
|
|
300
|
+
standing: standing(value, baseline),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
304
|
+
// Guidance
|
|
305
|
+
//
|
|
306
|
+
// In the voice evidence.ts sets, and for the reason it gives: a tool result is
|
|
307
|
+
// the only channel to the calling model — prompts are user-selected and cannot
|
|
308
|
+
// steer anything — so this text is not documentation, it is the steering, and
|
|
309
|
+
// it lands in the model's context. Each block says what the material is, what
|
|
310
|
+
// to work out from it, that every claim must be tied to a quote or a number,
|
|
311
|
+
// and ends by telling the model the reasoning is its own.
|
|
312
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
313
|
+
function audienceGuidance(a) {
|
|
314
|
+
const lines = [
|
|
315
|
+
`${a.comments} comments from ${a.posts} recent post${a.posts === 1 ? "" : "s"} by @${a.handle}, ` +
|
|
316
|
+
"grouped under the post each one was left on. Every comment carries an id.",
|
|
317
|
+
"",
|
|
318
|
+
`${a.flagged} are flagged wantsReply. That flag is a punctuation-and-phrasing test, not a ` +
|
|
319
|
+
"judgement: it misses questions asked without a question mark, misses everything not in " +
|
|
320
|
+
"English, and catches rhetorical ones. Use it as an ordering and read the rest yourself.",
|
|
321
|
+
"",
|
|
322
|
+
"Work out which comments actually want an answer from this creator, what each is really " +
|
|
323
|
+
"asking, and draft a reply in their voice — short, specific, and quoting the comment it " +
|
|
324
|
+
"answers. Where the answer already exists in one of these posts, name that post. Group the " +
|
|
325
|
+
"ones asking the same thing: a single pinned reply beats twenty individual ones, and a " +
|
|
326
|
+
"question that recurs across posts is a video rather than a reply.",
|
|
327
|
+
"",
|
|
328
|
+
"You are drafting, not sending. Nothing in nooticr can post a comment on any network — the " +
|
|
329
|
+
"connections carry upload and read permission only — so these are for the creator to paste " +
|
|
330
|
+
"in themselves. Say that rather than implying the replies will go out.",
|
|
331
|
+
"",
|
|
332
|
+
"Do not invent facts about the product, the price or the creator's plans to fill a gap. If a " +
|
|
333
|
+
"comment cannot be answered from what is here, say what you would need.",
|
|
334
|
+
"",
|
|
335
|
+
"Do not date anything. Comment timestamps are fabricated as the moment of the fetch on most " +
|
|
336
|
+
"networks, so any that could not be believed have been dropped to null — a comment with no " +
|
|
337
|
+
"`postedAt` is one whose date is unknown, not one posted long ago.",
|
|
338
|
+
];
|
|
339
|
+
if (a.since) {
|
|
340
|
+
lines.push("", a.sinceApplied
|
|
341
|
+
? `Only posts from ${a.since} onward are included.`
|
|
342
|
+
: `A window from ${a.since} was asked for, but this platform returned no dates, so ` +
|
|
343
|
+
"nothing was filtered — say so rather than implying the window held.");
|
|
344
|
+
}
|
|
345
|
+
if (a.failed) {
|
|
346
|
+
lines.push("", `${a.failed} post${a.failed === 1 ? "" : "s"} could not be read and are listed under ` +
|
|
347
|
+
"`unavailable`. Answer for the ones you have and name the ones you do not.");
|
|
348
|
+
}
|
|
349
|
+
lines.push("", "Then call show_audience_replies with your drafts so a person can read them one row at a " +
|
|
350
|
+
"time, copy the ones they want and ignore the rest. It fetches nothing, sends nothing and " +
|
|
351
|
+
"costs nothing.", ownIt);
|
|
352
|
+
return lines.join("\n");
|
|
353
|
+
}
|
|
354
|
+
function competitorGuidance(a) {
|
|
355
|
+
const lines = [
|
|
356
|
+
`${a.shipped} recent post${a.shipped === 1 ? "" : "s"} by @${a.handle}, each scored against ` +
|
|
357
|
+
`that account's own median ${a.metric} rather than against anyone else's numbers.`,
|
|
358
|
+
"",
|
|
359
|
+
a.baseline
|
|
360
|
+
? `Their median is ${Math.round(a.baseline.median).toLocaleString("en-US")} ${a.metric}, ` +
|
|
361
|
+
`with the middle half between ${Math.round(a.baseline.p25).toLocaleString("en-US")} and ` +
|
|
362
|
+
`${Math.round(a.baseline.p75).toLocaleString("en-US")}. ${a.outperformers} post` +
|
|
363
|
+
`${a.outperformers === 1 ? "" : "s"} beat it by a quarter or more.`
|
|
364
|
+
: "There are too few posts here to call anything a baseline, so no post has been scored. " +
|
|
365
|
+
"Say that rather than ranking them by raw numbers.",
|
|
366
|
+
"",
|
|
367
|
+
"A raw view count mostly measures follower count. What is worth reporting is which posts beat " +
|
|
368
|
+
"this creator's own ordinary result and what those posts have in common that the rest do " +
|
|
369
|
+
"not — format, hook, subject, length, posting time. Name the ratio for each claim " +
|
|
370
|
+
"(`standing.ratio` is the post over their median) and quote the title or caption you are " +
|
|
371
|
+
"reasoning from.",
|
|
372
|
+
"",
|
|
373
|
+
"One post above the median is noise. Say a pattern exists only when two or more of the " +
|
|
374
|
+
"outperformers share the thing you are naming, and say which posts those are.",
|
|
375
|
+
];
|
|
376
|
+
if (a.newSince !== null) {
|
|
377
|
+
lines.push("", `${a.newSince} of these are new since your last check on ${a.lastChecked}. Lead with those ` +
|
|
378
|
+
"— the rest have been reported before. New means the post was not in the list last time, " +
|
|
379
|
+
"which is an identity comparison rather than a date one; do not restate it as \"posted " +
|
|
380
|
+
"in the last N days\", because the dates these platforms return are not good enough for " +
|
|
381
|
+
"that claim.");
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
lines.push("", "This is the current window rather than a diff: this creator is not on the watchlist, so " +
|
|
385
|
+
"there is no previous check to compare against. watch_creator them and the next call " +
|
|
386
|
+
"will mark what is new.");
|
|
387
|
+
}
|
|
388
|
+
lines.push(ownIt);
|
|
389
|
+
return lines.join("\n");
|
|
390
|
+
}
|
|
391
|
+
function collabGuidance(a) {
|
|
392
|
+
return [
|
|
393
|
+
`${a.found} creators in "${a.niche}" on ${a.platform}` +
|
|
394
|
+
(a.seed ? `, from a keyword search and from the lookalikes of @${a.seed}` : "") +
|
|
395
|
+
". Each carries an id, a follower count and whichever of the two searches surfaced it.",
|
|
396
|
+
"",
|
|
397
|
+
"Shortlist the ones worth approaching and rank them. Reason from what is here: follower " +
|
|
398
|
+
"count against this creator's own, whether both searches found them (which is a stronger " +
|
|
399
|
+
"signal than either alone), what their bio actually says they do, and whether they are " +
|
|
400
|
+
"verified. Give each one a reason a person could disagree with, and name the number or the " +
|
|
401
|
+
"phrase from their bio you are reading it from.",
|
|
402
|
+
"",
|
|
403
|
+
"Say plainly what this evidence cannot settle. Audience overlap — whether the same people " +
|
|
404
|
+
"comment under both accounts — is the signal that would decide this, and it is not here: " +
|
|
405
|
+
"it needs a post list and several comment fetches per candidate, which is roughly nine " +
|
|
406
|
+
"credits each and around eighty for a shortlist this size. That is a decision for the user " +
|
|
407
|
+
"to make, not one to make silently on their behalf. For a finalist or two, " +
|
|
408
|
+
"answer_my_audience on each account returns commenters with their handles, and the " +
|
|
409
|
+
"intersection is the overlap; say so rather than guessing at it.",
|
|
410
|
+
"",
|
|
411
|
+
"Do not rank on follower count alone. A creator ten times the user's size is a different " +
|
|
412
|
+
"conversation from a peer, and both can be right depending on what they want — say which " +
|
|
413
|
+
"kind each one is.",
|
|
414
|
+
ownIt,
|
|
415
|
+
].join("\n");
|
|
416
|
+
}
|
|
417
|
+
function underperformGuidance(a) {
|
|
418
|
+
const lines = [
|
|
419
|
+
`This post, its stats, and the ${a.window} recent posts by the same creator it is being ` +
|
|
420
|
+
"measured against. The post itself is excluded from that baseline, so it is not being " +
|
|
421
|
+
"compared partly with itself.",
|
|
422
|
+
"",
|
|
423
|
+
];
|
|
424
|
+
if (!a.baseline || a.where.ratio === null) {
|
|
425
|
+
lines.push("There are too few comparable posts to build a baseline, so there is no verdict here. Say " +
|
|
426
|
+
"that plainly rather than calling the post good or bad from its raw numbers, and say " +
|
|
427
|
+
"what a bigger `window` would need to contain.");
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
lines.push(`It did ${a.where.value.toLocaleString("en-US")} ${a.metric} against a median of ` +
|
|
431
|
+
`${Math.round(a.where.median ?? 0).toLocaleString("en-US")} — ${a.where.ratio}× their ` +
|
|
432
|
+
`normal result, ahead of ${a.where.percentile}% of the window.`, "", "The question is what is different about this post, not whether the number is low. Compare " +
|
|
433
|
+
"it against the posts around and above it in `window`: hook, subject, format, length, " +
|
|
434
|
+
"caption, hashtags, posting time. Name the specific post you are contrasting it with and " +
|
|
435
|
+
"quote the difference.", "", "Be honest about the size of the gap. Within about a quarter of the median is the ordinary " +
|
|
436
|
+
"spread of an account and not an underperformance at all — if that is what this is, say " +
|
|
437
|
+
"so and stop, rather than manufacturing a cause for noise.");
|
|
438
|
+
}
|
|
439
|
+
lines.push("", "The stats here are reach, not quality. If a judgement needs the video itself, call " +
|
|
440
|
+
"analyze_post with mode 'evidence' for the frames, or get_post_transcript for the exact " +
|
|
441
|
+
"words, and look for yourself — the tools are yours to call, so nobody should be asked to " +
|
|
442
|
+
"relay one back to you.", ownIt);
|
|
443
|
+
return lines.join("\n");
|
|
444
|
+
}
|
|
445
|
+
function nextGuidance(a) {
|
|
446
|
+
const lines = [
|
|
447
|
+
`Two halves that only mean something together. Demand: ${a.demandComments} comments from ` +
|
|
448
|
+
`@${a.handle}'s own recent posts, ${a.asks} of them flagged as asking for something. ` +
|
|
449
|
+
`Supply: ${a.supplyPosts} recent posts in "${a.niche}"` +
|
|
450
|
+
(a.nicheSource === "hashtags"
|
|
451
|
+
? " — a niche nobody named, taken from the hashtag this creator uses most, so check it is the right one before trusting the sweep."
|
|
452
|
+
: "") +
|
|
453
|
+
".",
|
|
454
|
+
"",
|
|
455
|
+
"Work out what their audience keeps asking for that the niche is not already serving. A gap " +
|
|
456
|
+
"in the sweep that nobody asked for is noise; a request that the sweep shows is already " +
|
|
457
|
+
"answered ten times over is a crowded topic; the opportunity is the request with no good " +
|
|
458
|
+
"answer in the supply half.",
|
|
459
|
+
"",
|
|
460
|
+
"For each idea you propose, give: the exact comment that asked for it, quoted, with its id; " +
|
|
461
|
+
"how many separate commenters asked for something like it; what the sweep shows on that " +
|
|
462
|
+
"topic — either the closest post and its numbers, or that there is none; and the hook you " +
|
|
463
|
+
"would open with. An idea without a quote behind it is you inventing content strategy, " +
|
|
464
|
+
"which is the failure mode this tool exists to prevent.",
|
|
465
|
+
"",
|
|
466
|
+
"Rank by demand against absence of supply, not by how good the idea sounds. Say when the " +
|
|
467
|
+
"evidence is thin — one comment is one person, and a niche sweep of a dozen posts is a " +
|
|
468
|
+
"sample of a platform, not a census of it.",
|
|
469
|
+
];
|
|
470
|
+
if (a.failed) {
|
|
471
|
+
lines.push("", `${a.failed} of the fetches failed and are listed under \`unavailable\`; the demand half is ` +
|
|
472
|
+
"that much thinner than it looks.");
|
|
473
|
+
}
|
|
474
|
+
lines.push(ownIt);
|
|
475
|
+
return lines.join("\n");
|
|
476
|
+
}
|
|
477
|
+
/** Regex metacharacters in a search term, escaped so the term is matched literally. */
|
|
478
|
+
function escapeRegExp(s) {
|
|
479
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Where a term is actually said, case-insensitively and on whole words —
|
|
483
|
+
* "nike" must not fire inside "nikeisha". `\b` only means anything next to a
|
|
484
|
+
* word character, so a term that starts or ends on punctuation (an ampersand,
|
|
485
|
+
* a period) skips the boundary check on that side rather than failing to
|
|
486
|
+
* match at all.
|
|
487
|
+
*
|
|
488
|
+
* Every occurrence is counted; only the first `maxExcerpts` carry the
|
|
489
|
+
* surrounding text, because a transcript that says a name forty times does
|
|
490
|
+
* not need forty near-identical quotes to make the point.
|
|
491
|
+
*/
|
|
492
|
+
export function matchExcerpts(transcript, term, maxExcerpts = 3, contextChars = 180) {
|
|
493
|
+
const text = String(transcript ?? "");
|
|
494
|
+
const needle = String(term ?? "").trim();
|
|
495
|
+
if (!text || !needle)
|
|
496
|
+
return { excerpts: [], matchCount: 0 };
|
|
497
|
+
const startsWord = /^\w/.test(needle);
|
|
498
|
+
const endsWord = /\w$/.test(needle);
|
|
499
|
+
const pattern = new RegExp(`${startsWord ? "\\b" : ""}${escapeRegExp(needle)}${endsWord ? "\\b" : ""}`, "gi");
|
|
500
|
+
const excerpts = [];
|
|
501
|
+
let matchCount = 0;
|
|
502
|
+
let match;
|
|
503
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
504
|
+
matchCount++;
|
|
505
|
+
if (excerpts.length < maxExcerpts) {
|
|
506
|
+
const start = Math.max(0, match.index - contextChars);
|
|
507
|
+
const end = Math.min(text.length, match.index + match[0].length + contextChars);
|
|
508
|
+
let excerpt = text.slice(start, end).trim();
|
|
509
|
+
if (start > 0)
|
|
510
|
+
excerpt = `…${excerpt}`;
|
|
511
|
+
if (end < text.length)
|
|
512
|
+
excerpt = `${excerpt}…`;
|
|
513
|
+
excerpts.push({ text: excerpt, position: match.index, occurrence: matchCount });
|
|
514
|
+
}
|
|
515
|
+
// The pattern carries no empty-match risk (`needle` is checked non-empty
|
|
516
|
+
// above), but a stalled lastIndex on a zero-width match would loop
|
|
517
|
+
// forever, so this guards it regardless.
|
|
518
|
+
if (match[0].length === 0)
|
|
519
|
+
pattern.lastIndex++;
|
|
520
|
+
}
|
|
521
|
+
return { excerpts, matchCount };
|
|
522
|
+
}
|
|
523
|
+
function spokenMentionGuidance(a) {
|
|
524
|
+
const networks = a.platforms.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" and ");
|
|
525
|
+
const lines = [
|
|
526
|
+
`${a.matched} of ${a.transcribed} transcribed post${a.transcribed === 1 ? "" : "s"} on ${networks} ` +
|
|
527
|
+
`actually say "${a.term}" out loud. ${a.considered} candidate${a.considered === 1 ? "" : "s"} ` +
|
|
528
|
+
`were found before the transcript ceiling was applied; ${a.transcriptsAvailable} of the ` +
|
|
529
|
+
`${a.transcribed} checked carried a caption track at all.`,
|
|
530
|
+
"",
|
|
531
|
+
];
|
|
532
|
+
if (a.ceilingReached) {
|
|
533
|
+
lines.push(`Only the ${a.maxTranscripts} most-viewed candidates were transcribed — ` +
|
|
534
|
+
`${a.considered - a.maxTranscripts} more were found and never checked, not because they ` +
|
|
535
|
+
"don't matter but because the ceiling stopped here. Raise `maxTranscripts` to look " +
|
|
536
|
+
"further, at 1 credit each.", "");
|
|
537
|
+
}
|
|
538
|
+
lines.push("Read each excerpt for tone — praise, a complaint, a comparison, a passing mention — and " +
|
|
539
|
+
"quote the line rather than paraphrasing it: it is exactly what was said, not a summary of " +
|
|
540
|
+
"it. `matchCount` is how many times the term comes up in that transcript; only the first " +
|
|
541
|
+
"few occurrences carry an excerpt, and the rest are still counted toward it.", "", "This covers TikTok and YouTube only, and even there only the videos that carry a caption " +
|
|
542
|
+
"track. A silent or captionless video is invisible to this tool no matter how directly it " +
|
|
543
|
+
"names the term, and no other network was attempted at all, because none of them expose a " +
|
|
544
|
+
"caption track this cheaply — search_mentions is the tool for what people wrote there. Say " +
|
|
545
|
+
"plainly that coverage is partial rather than treating an empty result as proof nobody said " +
|
|
546
|
+
"it on camera.");
|
|
547
|
+
if (a.failed) {
|
|
548
|
+
lines.push("", `${a.failed} candidate${a.failed === 1 ? "" : "s"} could not be narrowed or transcribed and ` +
|
|
549
|
+
"are listed under `unavailable`, with why.");
|
|
550
|
+
}
|
|
551
|
+
lines.push("", ownIt);
|
|
552
|
+
return lines.join("\n");
|
|
553
|
+
}
|
|
554
|
+
const metricArg = z
|
|
555
|
+
.enum(METRICS)
|
|
556
|
+
.optional()
|
|
557
|
+
.describe("Which stat to rank on (default views).");
|
|
558
|
+
export function registerJobTools(server, makeClient, store) {
|
|
559
|
+
/** Guidance in the text block, evidence in the structured one — as every tool here does. */
|
|
560
|
+
const evidence = (guidance, payload) => ({
|
|
561
|
+
content: [{ type: "text", text: guidance }],
|
|
562
|
+
structuredContent: payload,
|
|
563
|
+
});
|
|
564
|
+
const failed = (prefix, err) => ({
|
|
565
|
+
content: [{ type: "text", text: `${prefix}: ${reason(err)}` }],
|
|
566
|
+
isError: true,
|
|
567
|
+
});
|
|
568
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
569
|
+
// 1. answer_my_audience — the mirror of search_mentions.
|
|
570
|
+
//
|
|
571
|
+
// The brand monitor asks what strangers are saying about you. This asks what
|
|
572
|
+
// the people already under your own posts asked you and never got an answer
|
|
573
|
+
// to, which is the same fan-out pointed the other way and the only one of
|
|
574
|
+
// these that ends in something the user does rather than something they read.
|
|
575
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
576
|
+
server.registerTool("answer_my_audience", {
|
|
577
|
+
title: "Answer My Audience",
|
|
578
|
+
_meta: viewMeta("answer_my_audience"),
|
|
579
|
+
description: "The questions waiting for you under your own posts. Fetches a creator's recent posts, " +
|
|
580
|
+
"reads the comments on each, and returns them grouped under the post they were left on — " +
|
|
581
|
+
"every comment with a stable id, and the ones that look like questions or requests " +
|
|
582
|
+
"flagged and sorted to the top. This FINDS and helps you DRAFT answers; it cannot post " +
|
|
583
|
+
"them. No nooticr connection carries comment-write permission on any network, so the " +
|
|
584
|
+
"replies are for a person to paste in themselves — never promise the user they will be " +
|
|
585
|
+
"sent. `since` filters on the POST's date, not the comments'. `limit` caps how many " +
|
|
586
|
+
"posts are opened. Pair it with show_audience_replies to lay the drafts out for triage. " +
|
|
587
|
+
"Costs 2 nooticr credits for the post list plus 2 per post opened — 14 credits at the " +
|
|
588
|
+
"default of 6 posts. Use when the job is to answer people; search_mentions is for what " +
|
|
589
|
+
"strangers say elsewhere.",
|
|
590
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
591
|
+
outputSchema: OUTPUT_SCHEMAS.answer_my_audience,
|
|
592
|
+
inputSchema: z
|
|
593
|
+
.object({
|
|
594
|
+
username: z.string().describe("Your handle, with or without @."),
|
|
595
|
+
platform: z.string().optional().describe("Platform (default tiktok)."),
|
|
596
|
+
limit: z
|
|
597
|
+
.number()
|
|
598
|
+
.int()
|
|
599
|
+
.optional()
|
|
600
|
+
.describe("Posts to open (default 6, max 12). Each one is a comment fetch, so this is the price."),
|
|
601
|
+
commentsPerPost: z.number().int().optional().describe("Comments to read per post (default 20, max 50)."),
|
|
602
|
+
since: z
|
|
603
|
+
.string()
|
|
604
|
+
.optional()
|
|
605
|
+
.describe("Only POSTS published on or after this date, as YYYY-MM-DD. It windows the posts, " +
|
|
606
|
+
"not the comments — comment dates are fabricated as 'now' on most networks and " +
|
|
607
|
+
"cannot be filtered on. Ignored where the platform returns no post dates either."),
|
|
608
|
+
})
|
|
609
|
+
.strict(),
|
|
610
|
+
}, async (args, extra) => {
|
|
611
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
612
|
+
const handle = normaliseHandle(args.username);
|
|
613
|
+
const platform = (args.platform || "tiktok").toLowerCase();
|
|
614
|
+
const cap = clamp(args.limit, 6, 1, 12);
|
|
615
|
+
const perPost = clamp(args.commentsPerPost, 20, 1, 50);
|
|
616
|
+
const spend = new Spend();
|
|
617
|
+
let feed;
|
|
618
|
+
try {
|
|
619
|
+
const res = await client.callTool("get_user_posts", { username: handle, platform, limit: cap });
|
|
620
|
+
const structured = structuredOf(res);
|
|
621
|
+
spend.record("get_user_posts", structured);
|
|
622
|
+
feed = rowsOf(structured.posts);
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
return failed("answer_my_audience could not list the posts", err);
|
|
626
|
+
}
|
|
627
|
+
const windowed = applySince(feed, args.since);
|
|
628
|
+
const posts = windowed.posts.slice(0, cap);
|
|
629
|
+
if (!posts.length) {
|
|
630
|
+
// Two credits spent and nothing to fan out over. Returning early is
|
|
631
|
+
// both the honest answer and the cheap one.
|
|
632
|
+
return evidence(`@${handle} has no recent posts on ${platform}` +
|
|
633
|
+
(args.since ? ` in the window from ${args.since}` : "") +
|
|
634
|
+
". Nothing was opened, so nothing further was charged.", {
|
|
635
|
+
mode: "evidence",
|
|
636
|
+
tool: "answer_my_audience",
|
|
637
|
+
term: `@${handle}`,
|
|
638
|
+
username: handle,
|
|
639
|
+
platform,
|
|
640
|
+
postsChecked: 0,
|
|
641
|
+
threads: [],
|
|
642
|
+
posts: [],
|
|
643
|
+
unavailable: [],
|
|
644
|
+
mcpCredits: spend.payload,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
// The fan-out is the price, and the number of posts is not knowable from
|
|
648
|
+
// the tool description — it is an argument, and it is capped by what the
|
|
649
|
+
// creator has actually posted. So the confirmation happens here, once
|
|
650
|
+
// the real number is known, rather than on the worst case. The post list
|
|
651
|
+
// that got us here cost 2, which is below the threshold this codebase
|
|
652
|
+
// already decided is worth interrupting someone for.
|
|
653
|
+
const fanOut = costOf(posts.map(() => "get_post_comments"));
|
|
654
|
+
const decision = await confirmSpend(server.server, {
|
|
655
|
+
credits: fanOut,
|
|
656
|
+
summary: `Read the comments on ${posts.length} recent post${posts.length === 1 ? "" : "s"} by @${handle}.`,
|
|
657
|
+
cheaper: 'Lower "limit" to open fewer posts.',
|
|
658
|
+
});
|
|
659
|
+
if (!decision.proceed) {
|
|
660
|
+
return declinedResult(fanOut, `Reading ${posts.length} posts' comments`, 'Lower "limit" to open fewer posts.');
|
|
661
|
+
}
|
|
662
|
+
const threads = [];
|
|
663
|
+
const unavailable = [];
|
|
664
|
+
let totalComments = 0;
|
|
665
|
+
let flagged = 0;
|
|
666
|
+
for (const [index, post] of posts.entries()) {
|
|
667
|
+
const url = String(post.externalUrl ?? post.url ?? "");
|
|
668
|
+
const id = postIdOf(post, index);
|
|
669
|
+
if (!url) {
|
|
670
|
+
// Nothing to fetch comments for, and no reason to spend a credit
|
|
671
|
+
// finding that out.
|
|
672
|
+
unavailable.push({ postId: id, url: null, reason: "the post carries no permalink to fetch comments from" });
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
const res = await client.callTool("get_post_comments", { url, limit: perPost });
|
|
677
|
+
const structured = structuredOf(res);
|
|
678
|
+
spend.record("get_post_comments", structured);
|
|
679
|
+
const mentions = rowsOf(structured.comments)
|
|
680
|
+
.map((c) => {
|
|
681
|
+
const text = String(c.text ?? "").trim();
|
|
682
|
+
if (!text)
|
|
683
|
+
return null;
|
|
684
|
+
const signals = replySignals(text);
|
|
685
|
+
return {
|
|
686
|
+
id: commentIdFor(id, c),
|
|
687
|
+
text,
|
|
688
|
+
username: String(c.author ?? c.username ?? "").replace(/^@/, ""),
|
|
689
|
+
likes: numberOf(c.likes),
|
|
690
|
+
replies: numberOf(c.replies),
|
|
691
|
+
// Null rather than a date we cannot stand behind — see
|
|
692
|
+
// trustedCommentDate. The view will simply omit the timestamp.
|
|
693
|
+
postedAt: trustedCommentDate(c.postedAt ?? c.createdAt),
|
|
694
|
+
avatarUrl: c.avatarUrl ?? null,
|
|
695
|
+
avatarProxyUrl: c.avatarProxyUrl ?? null,
|
|
696
|
+
wantsReply: signals.length > 0,
|
|
697
|
+
signals,
|
|
698
|
+
// Drives the view's filter chips. A label from a phrase test,
|
|
699
|
+
// not a classification — the guidance says so to the model,
|
|
700
|
+
// and the chip says "wants a reply" rather than "question".
|
|
701
|
+
category: signals.length ? "wants_a_reply" : "unclear",
|
|
702
|
+
};
|
|
703
|
+
})
|
|
704
|
+
.filter((c) => c !== null)
|
|
705
|
+
// Flagged first, then loudest. A creator with two hundred comments
|
|
706
|
+
// reads the top of the page and stops.
|
|
707
|
+
.sort((a, b) => Number(b.wantsReply) - Number(a.wantsReply) ||
|
|
708
|
+
b.signals.length - a.signals.length ||
|
|
709
|
+
b.likes - a.likes);
|
|
710
|
+
totalComments += mentions.length;
|
|
711
|
+
flagged += mentions.filter((m) => m.wantsReply).length;
|
|
712
|
+
threads.push({
|
|
713
|
+
post: { ...post, postId: id, postedAt: postDate(post) },
|
|
714
|
+
postIsAboutTerm: false,
|
|
715
|
+
mentionCount: mentions.length,
|
|
716
|
+
mentions,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
// Comments switched off, a post the platform has since hidden, a
|
|
721
|
+
// network that declined: normal, and no reason to lose the posts
|
|
722
|
+
// that worked.
|
|
723
|
+
spend.attempted("get_post_comments");
|
|
724
|
+
unavailable.push({ postId: id, url, reason: reason(err) });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const guidance = audienceGuidance({
|
|
728
|
+
handle,
|
|
729
|
+
posts: threads.length,
|
|
730
|
+
comments: totalComments,
|
|
731
|
+
flagged,
|
|
732
|
+
failed: unavailable.length,
|
|
733
|
+
since: args.since,
|
|
734
|
+
sinceApplied: windowed.applied,
|
|
735
|
+
});
|
|
736
|
+
return evidence(guidance, {
|
|
737
|
+
mode: "evidence",
|
|
738
|
+
tool: "answer_my_audience",
|
|
739
|
+
evidenceFrom: ["get_user_posts", "get_post_comments"],
|
|
740
|
+
// `term` + `threads` is what the monitoring view keys off, so this
|
|
741
|
+
// draws through the surface built for triaging comments rather than
|
|
742
|
+
// needing a second one.
|
|
743
|
+
term: `@${handle}`,
|
|
744
|
+
username: handle,
|
|
745
|
+
platform,
|
|
746
|
+
since: args.since ?? null,
|
|
747
|
+
sinceApplied: windowed.applied,
|
|
748
|
+
postsChecked: threads.length,
|
|
749
|
+
// Stated in the payload as well as the prose, because a model that
|
|
750
|
+
// believes it can send will promise the user something that cannot
|
|
751
|
+
// happen. No connection carries comment-write permission anywhere.
|
|
752
|
+
repliesCanBeSent: false,
|
|
753
|
+
totalMentions: totalComments,
|
|
754
|
+
totalThreads: threads.length,
|
|
755
|
+
wantsReplyCount: flagged,
|
|
756
|
+
byCategory: { wants_a_reply: flagged, unclear: totalComments - flagged },
|
|
757
|
+
threads,
|
|
758
|
+
posts: threads.map((t) => t.post),
|
|
759
|
+
unavailable,
|
|
760
|
+
creditsCharged: spend.credits,
|
|
761
|
+
mcpCredits: spend.payload,
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
765
|
+
// 2. track_competitor
|
|
766
|
+
//
|
|
767
|
+
// Writes, when the creator is on the watchlist: it moves a baseline forward
|
|
768
|
+
// so the next call can say what is new. That baseline is deliberately not
|
|
769
|
+
// the one catch_up_watchlist keeps — two tools sharing one "since I last
|
|
770
|
+
// looked" means each silently consumes the other's answer.
|
|
771
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
772
|
+
server.registerTool("track_competitor", {
|
|
773
|
+
title: "Track Competitor",
|
|
774
|
+
_meta: viewMeta("track_competitor"),
|
|
775
|
+
description: "What a creator shipped recently and which of it beat THEIR OWN baseline. Fetches their " +
|
|
776
|
+
"recent posts once and scores each against the median of that same window, because a raw " +
|
|
777
|
+
"view count mostly measures follower count — outperformance against themselves is the " +
|
|
778
|
+
"signal. If they are on your watchlist it also marks what is new since your last " +
|
|
779
|
+
"track_competitor call and moves that marker forward. " +
|
|
780
|
+
"Consumes 2 nooticr credits — one post list, whatever the window size. Use for a rival you " +
|
|
781
|
+
"follow; analyze_creator_profile is the full teardown of one you do not.",
|
|
782
|
+
annotations: {
|
|
783
|
+
readOnlyHint: false,
|
|
784
|
+
destructiveHint: false,
|
|
785
|
+
// Moves the "last checked" marker for a watched creator, so the second
|
|
786
|
+
// call in a row does not answer the same question as the first.
|
|
787
|
+
idempotentHint: false,
|
|
788
|
+
openWorldHint: true,
|
|
789
|
+
},
|
|
790
|
+
outputSchema: OUTPUT_SCHEMAS.track_competitor,
|
|
791
|
+
inputSchema: z
|
|
792
|
+
.object({
|
|
793
|
+
username: z.string().describe("Creator handle, with or without @."),
|
|
794
|
+
platform: z.string().optional().describe("Platform (default tiktok)."),
|
|
795
|
+
limit: z.number().int().optional().describe("Posts in the window (default 12, max 30). One fetch either way."),
|
|
796
|
+
metric: metricArg,
|
|
797
|
+
since: z
|
|
798
|
+
.string()
|
|
799
|
+
.optional()
|
|
800
|
+
.describe("Only posts published on or after this date, as YYYY-MM-DD. Ignored where the " +
|
|
801
|
+
"platform returns no post dates — `sinceApplied` says which happened."),
|
|
802
|
+
})
|
|
803
|
+
.strict(),
|
|
804
|
+
}, async (args, extra) => {
|
|
805
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
806
|
+
const handle = normaliseHandle(args.username);
|
|
807
|
+
const platform = (args.platform || "tiktok").toLowerCase();
|
|
808
|
+
const cap = clamp(args.limit, 12, 1, 30);
|
|
809
|
+
const metric = args.metric ?? "views";
|
|
810
|
+
const spend = new Spend();
|
|
811
|
+
let feed;
|
|
812
|
+
try {
|
|
813
|
+
const res = await client.callTool("get_user_posts", { username: handle, platform, limit: cap });
|
|
814
|
+
const structured = structuredOf(res);
|
|
815
|
+
spend.record("get_user_posts", structured);
|
|
816
|
+
feed = rowsOf(structured.posts);
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
return failed("track_competitor could not list the posts", err);
|
|
820
|
+
}
|
|
821
|
+
const windowed = applySince(feed, args.since);
|
|
822
|
+
const posts = windowed.posts;
|
|
823
|
+
const values = posts.map((p) => metricOf(p, metric));
|
|
824
|
+
const baseline = distributionOf(values);
|
|
825
|
+
// The watchlist is where "since I last checked" can live at all, and only
|
|
826
|
+
// for creators the user already chose to watch. Adding them here would be
|
|
827
|
+
// a side effect nobody asked for, so an untracked creator gets the
|
|
828
|
+
// current window and is told how to get a diff next time.
|
|
829
|
+
let previous;
|
|
830
|
+
let tracked = false;
|
|
831
|
+
let owner = "anonymous";
|
|
832
|
+
try {
|
|
833
|
+
owner = await watchlistOwner(client);
|
|
834
|
+
const entry = (await store.list(owner)).find((e) => e.id === watchEntryId(platform, handle));
|
|
835
|
+
tracked = Boolean(entry);
|
|
836
|
+
previous = entry?.competitorBaseline;
|
|
837
|
+
if (entry) {
|
|
838
|
+
await store.put(owner, {
|
|
839
|
+
...entry,
|
|
840
|
+
competitorBaseline: {
|
|
841
|
+
capturedAt: new Date().toISOString(),
|
|
842
|
+
postIds: posts.map((p, i) => String(p.id ?? p.externalUrl ?? postIdOf(p, i))),
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
// A store that will not answer costs the diff, not the tool. The
|
|
849
|
+
// outperformance half is the point and it is already computed.
|
|
850
|
+
tracked = false;
|
|
851
|
+
}
|
|
852
|
+
const seen = new Set(previous?.postIds ?? []);
|
|
853
|
+
const scoredPosts = posts.map((post, index) => {
|
|
854
|
+
const row = scored(post, index, metric, values);
|
|
855
|
+
return {
|
|
856
|
+
...row,
|
|
857
|
+
isNew: previous ? !seen.has(String(post.id ?? post.externalUrl ?? postIdOf(post, index))) : null,
|
|
858
|
+
};
|
|
859
|
+
});
|
|
860
|
+
// Loudest first against their own median, which is the ranking the
|
|
861
|
+
// question asks for — not the platform's chronological order.
|
|
862
|
+
scoredPosts.sort((a, b) => numberOf(b.standing.ratio) - numberOf(a.standing.ratio));
|
|
863
|
+
const outperformers = scoredPosts.filter((p) => {
|
|
864
|
+
const verdict = String(p.standing.verdict ?? "");
|
|
865
|
+
return verdict === "breakout" || verdict === "above_baseline";
|
|
866
|
+
});
|
|
867
|
+
const newSince = previous ? scoredPosts.filter((p) => p.isNew === true).length : null;
|
|
868
|
+
return evidence(competitorGuidance({
|
|
869
|
+
handle,
|
|
870
|
+
metric,
|
|
871
|
+
shipped: scoredPosts.length,
|
|
872
|
+
baseline,
|
|
873
|
+
outperformers: outperformers.length,
|
|
874
|
+
newSince,
|
|
875
|
+
lastChecked: previous?.capturedAt,
|
|
876
|
+
}), {
|
|
877
|
+
mode: "evidence",
|
|
878
|
+
tool: "track_competitor",
|
|
879
|
+
evidenceFrom: ["get_user_posts"],
|
|
880
|
+
username: handle,
|
|
881
|
+
platform,
|
|
882
|
+
metric,
|
|
883
|
+
window: scoredPosts.length,
|
|
884
|
+
since: args.since ?? null,
|
|
885
|
+
sinceApplied: windowed.applied,
|
|
886
|
+
tracked,
|
|
887
|
+
lastCheckedAt: previous?.capturedAt ?? null,
|
|
888
|
+
newSincePreviousCheck: newSince,
|
|
889
|
+
baseline,
|
|
890
|
+
// The addressable id, not the platform's own: this is a reference
|
|
891
|
+
// into `posts` above, and `postId` is what identifies a row there
|
|
892
|
+
// across calls.
|
|
893
|
+
outperformers: outperformers.map((p) => p.postId),
|
|
894
|
+
posts: scoredPosts,
|
|
895
|
+
unavailable: [],
|
|
896
|
+
creditsCharged: spend.credits,
|
|
897
|
+
mcpCredits: spend.payload,
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
901
|
+
// 3. who_should_i_work_with
|
|
902
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
903
|
+
server.registerTool("who_should_i_work_with", {
|
|
904
|
+
title: "Who Should I Work With",
|
|
905
|
+
_meta: viewMeta("who_should_i_work_with"),
|
|
906
|
+
description: "A collaboration shortlist for a niche. Searches creators by keyword and, when you name a " +
|
|
907
|
+
"creator who already fits, adds their lookalikes — then merges the two, marks which " +
|
|
908
|
+
"search found each one, and gives every candidate an id. It does NOT measure audience " +
|
|
909
|
+
"overlap: proving the same people comment under two accounts costs roughly nine credits " +
|
|
910
|
+
"per candidate, so the result says so and tells you how to check a finalist yourself. " +
|
|
911
|
+
"Consumes 2 nooticr credits, or 4 with a seed creator. Use to build a list to vet; " +
|
|
912
|
+
"get_similar_creators is the raw lookalike call.",
|
|
913
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
914
|
+
outputSchema: OUTPUT_SCHEMAS.who_should_i_work_with,
|
|
915
|
+
inputSchema: z
|
|
916
|
+
.object({
|
|
917
|
+
niche: z.string().describe("Niche or keyword, e.g. 'home fitness'."),
|
|
918
|
+
platform: z
|
|
919
|
+
.enum(["tiktok", "instagram", "xiaohongshu", "youtube", "douyin"])
|
|
920
|
+
.optional()
|
|
921
|
+
.describe("Which platform (default tiktok). Lookalikes exist on tiktok and instagram only."),
|
|
922
|
+
seed: z
|
|
923
|
+
.string()
|
|
924
|
+
.optional()
|
|
925
|
+
.describe("A creator who already fits — their lookalikes are added to the shortlist. Costs 2 more credits."),
|
|
926
|
+
count: z.number().int().optional().describe("Candidates from the keyword search (default 8, max 20)."),
|
|
927
|
+
})
|
|
928
|
+
.strict(),
|
|
929
|
+
}, async (args, extra) => {
|
|
930
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
931
|
+
const platform = (args.platform || "tiktok").toLowerCase();
|
|
932
|
+
const count = clamp(args.count, 8, 1, 20);
|
|
933
|
+
const spend = new Spend();
|
|
934
|
+
const unavailable = [];
|
|
935
|
+
const bySource = new Map();
|
|
936
|
+
const add = (raw, source) => {
|
|
937
|
+
const username = String(raw.username ?? raw.uniqueId ?? "").replace(/^@/, "");
|
|
938
|
+
if (!username)
|
|
939
|
+
return;
|
|
940
|
+
const key = username.toLowerCase();
|
|
941
|
+
const existing = bySource.get(key);
|
|
942
|
+
if (existing) {
|
|
943
|
+
// Found by both searches. That agreement is the strongest signal in
|
|
944
|
+
// this payload, so it is recorded rather than deduplicated away.
|
|
945
|
+
const already = String(existing.foundBy ?? "");
|
|
946
|
+
if (already && already !== source)
|
|
947
|
+
existing.foundBy = "both";
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
bySource.set(key, {
|
|
951
|
+
...raw,
|
|
952
|
+
id: `creator:${platform}:${key}`,
|
|
953
|
+
username,
|
|
954
|
+
followers: numberOf(raw.followers ?? raw.followerCount),
|
|
955
|
+
foundBy: source,
|
|
956
|
+
// What the host should go and read. Pulled out of the bio here
|
|
957
|
+
// rather than left for the model to spot inside prose — see
|
|
958
|
+
// collab.ts for why they are typed and why we do not open them.
|
|
959
|
+
links: extractLinks(String(raw.signature ?? raw.bio ?? ""), typeof raw.externalUrl === "string" ? raw.externalUrl : undefined),
|
|
960
|
+
});
|
|
961
|
+
};
|
|
962
|
+
try {
|
|
963
|
+
const res = await client.callTool("search_creators", { keyword: args.niche, platform, count });
|
|
964
|
+
const structured = structuredOf(res);
|
|
965
|
+
spend.record("search_creators", structured);
|
|
966
|
+
for (const c of rowsOf(structured.creators))
|
|
967
|
+
add(c, "search");
|
|
968
|
+
}
|
|
969
|
+
catch (err) {
|
|
970
|
+
spend.attempted("search_creators");
|
|
971
|
+
unavailable.push({ via: "search_creators", reason: reason(err) });
|
|
972
|
+
}
|
|
973
|
+
if (args.seed) {
|
|
974
|
+
try {
|
|
975
|
+
const res = await client.callTool("get_similar_creators", {
|
|
976
|
+
username: normaliseHandle(args.seed),
|
|
977
|
+
platform,
|
|
978
|
+
});
|
|
979
|
+
const structured = structuredOf(res);
|
|
980
|
+
spend.record("get_similar_creators", structured);
|
|
981
|
+
for (const c of rowsOf(structured.creators))
|
|
982
|
+
add(c, "similar");
|
|
983
|
+
}
|
|
984
|
+
catch (err) {
|
|
985
|
+
// A seed on a platform with no lookalike endpoint, or a handle that
|
|
986
|
+
// does not resolve. The keyword half still stands on its own.
|
|
987
|
+
spend.attempted("get_similar_creators");
|
|
988
|
+
unavailable.push({ via: "get_similar_creators", reason: reason(err) });
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
const creators = [...bySource.values()].sort((a, b) => (a.foundBy === "both" ? 0 : 1) - (b.foundBy === "both" ? 0 : 1) ||
|
|
992
|
+
numberOf(b.followers) - numberOf(a.followers));
|
|
993
|
+
const withLinks = creators.filter((c) => Array.isArray(c.links) && c.links.length > 0).length;
|
|
994
|
+
return evidence([
|
|
995
|
+
collabGuidance({ niche: args.niche, found: creators.length, seed: args.seed, platform }),
|
|
996
|
+
"",
|
|
997
|
+
vettingGuidance(creators.length, withLinks),
|
|
998
|
+
].join("\n"), {
|
|
999
|
+
mode: "evidence",
|
|
1000
|
+
tool: "who_should_i_work_with",
|
|
1001
|
+
rubric: COLLAB_RUBRIC,
|
|
1002
|
+
withLinks,
|
|
1003
|
+
evidenceFrom: args.seed ? ["search_creators", "get_similar_creators"] : ["search_creators"],
|
|
1004
|
+
niche: args.niche,
|
|
1005
|
+
platform,
|
|
1006
|
+
seed: args.seed ? normaliseHandle(args.seed) : null,
|
|
1007
|
+
creators,
|
|
1008
|
+
foundBoth: creators.filter((c) => c.foundBy === "both").length,
|
|
1009
|
+
audienceOverlap: {
|
|
1010
|
+
attempted: false,
|
|
1011
|
+
reason: "Overlap needs a post list plus several comment fetches per candidate — about 9 " +
|
|
1012
|
+
`credits each, roughly ${creators.length * 9} for this shortlist — so it is the ` +
|
|
1013
|
+
"user's call, not a silent charge.",
|
|
1014
|
+
howTo: "For one or two finalists, call answer_my_audience on each account and intersect " +
|
|
1015
|
+
"the commenter handles. That is the real signal, measured rather than guessed.",
|
|
1016
|
+
},
|
|
1017
|
+
unavailable,
|
|
1018
|
+
creditsCharged: spend.credits,
|
|
1019
|
+
mcpCredits: spend.payload,
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1023
|
+
// 4. why_did_this_underperform
|
|
1024
|
+
//
|
|
1025
|
+
// Not compare_posts. That answers "why did A beat B" from two URLs a person
|
|
1026
|
+
// already suspects; this answers "is this even bad, and against what", which
|
|
1027
|
+
// needs the creator's own distribution rather than a second post.
|
|
1028
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1029
|
+
server.registerTool("why_did_this_underperform", {
|
|
1030
|
+
title: "Why Did This Underperform",
|
|
1031
|
+
_meta: viewMeta("why_did_this_underperform"),
|
|
1032
|
+
description: "One post measured against the creator's own recent median rather than against another " +
|
|
1033
|
+
"post. Fetches the post, fetches that creator's recent window, excludes the post from its " +
|
|
1034
|
+
"own baseline, and returns where it actually sits in the distribution — median, " +
|
|
1035
|
+
"quartiles, ratio and percentile — so the answer can be 'this is a normal result, not a " +
|
|
1036
|
+
"failure'. Consumes 3 nooticr credits (1 for the post, 2 for the window). Use when you " +
|
|
1037
|
+
"have one post and no comparison; compare_posts is for two URLs you already picked.",
|
|
1038
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1039
|
+
outputSchema: OUTPUT_SCHEMAS.why_did_this_underperform,
|
|
1040
|
+
inputSchema: z
|
|
1041
|
+
.object({
|
|
1042
|
+
url: z.string().describe("The post to explain."),
|
|
1043
|
+
username: z
|
|
1044
|
+
.string()
|
|
1045
|
+
.optional()
|
|
1046
|
+
.describe("Whose baseline to use. Read from the post when the platform names its creator."),
|
|
1047
|
+
platform: z.string().optional().describe("Platform of the creator's feed. Read from the post when it says."),
|
|
1048
|
+
window: z.number().int().optional().describe("Posts in the comparison window (default 12, max 30)."),
|
|
1049
|
+
metric: metricArg,
|
|
1050
|
+
})
|
|
1051
|
+
.strict(),
|
|
1052
|
+
}, async (args, extra) => {
|
|
1053
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
1054
|
+
const windowSize = clamp(args.window, 12, 3, 30);
|
|
1055
|
+
const metric = args.metric ?? "views";
|
|
1056
|
+
const spend = new Spend();
|
|
1057
|
+
let post;
|
|
1058
|
+
try {
|
|
1059
|
+
const res = await client.callTool("get_social_media", { url: args.url });
|
|
1060
|
+
const structured = structuredOf(res);
|
|
1061
|
+
spend.record("get_social_media", structured);
|
|
1062
|
+
post = (structured.post ?? structured);
|
|
1063
|
+
}
|
|
1064
|
+
catch (err) {
|
|
1065
|
+
return failed("why_did_this_underperform could not fetch the post", err);
|
|
1066
|
+
}
|
|
1067
|
+
const handle = normaliseHandle(args.username || String(post.creatorHandle ?? post.author ?? post.username ?? ""));
|
|
1068
|
+
const platform = (args.platform || String(post.platform ?? "") || platformFromUrl(args.url) || "tiktok").toLowerCase();
|
|
1069
|
+
const value = metricOf(post, metric);
|
|
1070
|
+
if (!handle) {
|
|
1071
|
+
// One credit spent, and the comparison this tool exists for is
|
|
1072
|
+
// impossible without knowing whose baseline to build. Say which
|
|
1073
|
+
// argument fixes it rather than comparing against nothing.
|
|
1074
|
+
return evidence(`This post's stats, but no creator to measure them against — the platform did not name ` +
|
|
1075
|
+
`one and no \`username\` was given. Pass \`username\` to build the baseline. The post ` +
|
|
1076
|
+
`did ${value.toLocaleString("en-US")} ${metric}, which on its own says nothing about ` +
|
|
1077
|
+
`whether that is good for this account.\n${ownIt}`, {
|
|
1078
|
+
mode: "evidence",
|
|
1079
|
+
tool: "why_did_this_underperform",
|
|
1080
|
+
url: args.url,
|
|
1081
|
+
post,
|
|
1082
|
+
metric,
|
|
1083
|
+
metricValue: value,
|
|
1084
|
+
baseline: null,
|
|
1085
|
+
standing: null,
|
|
1086
|
+
window: [],
|
|
1087
|
+
unavailable: [{ via: "get_user_posts", reason: "the post names no creator and no username was given" }],
|
|
1088
|
+
creditsCharged: spend.credits,
|
|
1089
|
+
mcpCredits: spend.payload,
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
let feed = [];
|
|
1093
|
+
const unavailable = [];
|
|
1094
|
+
try {
|
|
1095
|
+
const res = await client.callTool("get_user_posts", {
|
|
1096
|
+
username: handle,
|
|
1097
|
+
platform,
|
|
1098
|
+
limit: windowSize,
|
|
1099
|
+
});
|
|
1100
|
+
const structured = structuredOf(res);
|
|
1101
|
+
spend.record("get_user_posts", structured);
|
|
1102
|
+
feed = rowsOf(structured.posts);
|
|
1103
|
+
}
|
|
1104
|
+
catch (err) {
|
|
1105
|
+
spend.attempted("get_user_posts");
|
|
1106
|
+
unavailable.push({ via: "get_user_posts", reason: reason(err) });
|
|
1107
|
+
}
|
|
1108
|
+
// Measuring a post partly against itself flatters a flop and blunts a
|
|
1109
|
+
// breakout, and get_user_posts almost always returns the post in
|
|
1110
|
+
// question, so it comes back out before anything is computed.
|
|
1111
|
+
const others = excluding(feed, { url: args.url, id: String(post.id ?? "") });
|
|
1112
|
+
const values = others.map((p) => metricOf(p, metric));
|
|
1113
|
+
const baseline = distributionOf(values);
|
|
1114
|
+
const where = standing(value, values);
|
|
1115
|
+
const window = others
|
|
1116
|
+
.map((p, i) => scored(p, i, metric, values))
|
|
1117
|
+
.sort((a, b) => numberOf(b.metricValue) - numberOf(a.metricValue));
|
|
1118
|
+
return evidence(underperformGuidance({ metric, where, baseline, window: window.length }), {
|
|
1119
|
+
mode: "evidence",
|
|
1120
|
+
tool: "why_did_this_underperform",
|
|
1121
|
+
evidenceFrom: ["get_social_media", "get_user_posts"],
|
|
1122
|
+
url: args.url,
|
|
1123
|
+
username: handle,
|
|
1124
|
+
platform,
|
|
1125
|
+
metric,
|
|
1126
|
+
metricValue: value,
|
|
1127
|
+
// standing lives on the post too, not only as a sibling field —
|
|
1128
|
+
// postCard's standingBadge reads p.standing, and nesting it here is
|
|
1129
|
+
// what makes the ratio-vs-baseline verdict this tool computes
|
|
1130
|
+
// actually show up on the card instead of only in the chat text.
|
|
1131
|
+
post: { ...post, postId: postIdOf(post, 0), standing: where },
|
|
1132
|
+
baseline,
|
|
1133
|
+
standing: where,
|
|
1134
|
+
window,
|
|
1135
|
+
unavailable,
|
|
1136
|
+
creditsCharged: spend.credits,
|
|
1137
|
+
mcpCredits: spend.payload,
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1141
|
+
// 5. what_should_i_make_next — demand against supply.
|
|
1142
|
+
//
|
|
1143
|
+
// Either half alone is the familiar failure. A niche sweep produces "gaps"
|
|
1144
|
+
// that are gaps because nobody wants them; a comment read produces requests
|
|
1145
|
+
// with no way to know the topic is already saturated. The answer is in the
|
|
1146
|
+
// intersection, and the intersection needs both fetches in one result.
|
|
1147
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1148
|
+
server.registerTool("what_should_i_make_next", {
|
|
1149
|
+
title: "What Should I Make Next",
|
|
1150
|
+
_meta: viewMeta("what_should_i_make_next"),
|
|
1151
|
+
description: "Demand and supply in one result. Reads the comments on a creator's own recent posts for " +
|
|
1152
|
+
"what their audience explicitly asks for, sweeps the niche for what is already being " +
|
|
1153
|
+
"made, and returns both with ids so an idea can be traced back to the comment that asked " +
|
|
1154
|
+
"for it. A gap nobody asked for is noise; a request nobody serves is the opportunity. " +
|
|
1155
|
+
"Names the niche from the creator's most-used hashtag when you do not give one. " +
|
|
1156
|
+
"Consumes 2 nooticr credits for the post list, 2 per post read for comments, and 2 for the " +
|
|
1157
|
+
"niche sweep — 12 at the default of 4 posts. Use to decide what to film; niche_report " +
|
|
1158
|
+
"covers the supply half alone.",
|
|
1159
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1160
|
+
outputSchema: OUTPUT_SCHEMAS.what_should_i_make_next,
|
|
1161
|
+
inputSchema: z
|
|
1162
|
+
.object({
|
|
1163
|
+
username: z.string().describe("Your handle, with or without @."),
|
|
1164
|
+
platform: z.string().optional().describe("Platform (default tiktok)."),
|
|
1165
|
+
niche: z
|
|
1166
|
+
.string()
|
|
1167
|
+
.optional()
|
|
1168
|
+
.describe("Niche for the supply sweep. Defaults to this creator's most frequent hashtag."),
|
|
1169
|
+
limit: z.number().int().optional().describe("Your posts to read comments on (default 4, max 8). This is the price."),
|
|
1170
|
+
commentsPerPost: z.number().int().optional().describe("Comments per post (default 25, max 50)."),
|
|
1171
|
+
supplyLimit: z.number().int().optional().describe("Posts in the niche sweep (default 12, max 30). One fetch either way."),
|
|
1172
|
+
})
|
|
1173
|
+
.strict(),
|
|
1174
|
+
}, async (args, extra) => {
|
|
1175
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
1176
|
+
const handle = normaliseHandle(args.username);
|
|
1177
|
+
const platform = (args.platform || "tiktok").toLowerCase();
|
|
1178
|
+
const cap = clamp(args.limit, 4, 1, 8);
|
|
1179
|
+
const perPost = clamp(args.commentsPerPost, 25, 1, 50);
|
|
1180
|
+
const supplyCap = clamp(args.supplyLimit, 12, 1, 30);
|
|
1181
|
+
const spend = new Spend();
|
|
1182
|
+
const unavailable = [];
|
|
1183
|
+
let feed;
|
|
1184
|
+
try {
|
|
1185
|
+
const res = await client.callTool("get_user_posts", { username: handle, platform, limit: cap });
|
|
1186
|
+
const structured = structuredOf(res);
|
|
1187
|
+
spend.record("get_user_posts", structured);
|
|
1188
|
+
feed = rowsOf(structured.posts);
|
|
1189
|
+
}
|
|
1190
|
+
catch (err) {
|
|
1191
|
+
return failed("what_should_i_make_next could not list your posts", err);
|
|
1192
|
+
}
|
|
1193
|
+
const mine = feed.slice(0, cap);
|
|
1194
|
+
// A niche nobody named. The creator's own most-used hashtag is a
|
|
1195
|
+
// deterministic answer drawn from material already paid for, which is
|
|
1196
|
+
// better than guessing and much better than failing the call — and the
|
|
1197
|
+
// guidance tells the model to sanity-check it before trusting the sweep.
|
|
1198
|
+
const tally = new Map();
|
|
1199
|
+
for (const post of mine) {
|
|
1200
|
+
for (const tag of Array.isArray(post.hashtags) ? post.hashtags : []) {
|
|
1201
|
+
const clean = String(tag ?? "").replace(/^#/, "").trim().toLowerCase();
|
|
1202
|
+
if (clean.length > 2)
|
|
1203
|
+
tally.set(clean, (tally.get(clean) ?? 0) + 1);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
const topTag = [...tally.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
1207
|
+
const niche = args.niche || topTag?.[0] || "";
|
|
1208
|
+
const nicheSource = args.niche ? "argument" : topTag ? "hashtags" : "none";
|
|
1209
|
+
const fanOut = costOf([
|
|
1210
|
+
...mine.map(() => "get_post_comments"),
|
|
1211
|
+
...(niche ? ["discover_social_posts"] : []),
|
|
1212
|
+
]);
|
|
1213
|
+
const decision = await confirmSpend(server.server, {
|
|
1214
|
+
credits: fanOut,
|
|
1215
|
+
summary: `Read the comments on ${mine.length} of @${handle}'s posts` +
|
|
1216
|
+
(niche ? ` and sweep the "${niche}" niche for what already exists.` : "."),
|
|
1217
|
+
cheaper: 'Lower "limit" to read fewer of your own posts.',
|
|
1218
|
+
});
|
|
1219
|
+
if (!decision.proceed) {
|
|
1220
|
+
return declinedResult(fanOut, "That demand-and-supply read", 'Lower "limit" to read fewer of your own posts.');
|
|
1221
|
+
}
|
|
1222
|
+
const demand = [];
|
|
1223
|
+
let asks = 0;
|
|
1224
|
+
let commentCount = 0;
|
|
1225
|
+
for (const [index, post] of mine.entries()) {
|
|
1226
|
+
const url = String(post.externalUrl ?? post.url ?? "");
|
|
1227
|
+
const id = postIdOf(post, index);
|
|
1228
|
+
if (!url) {
|
|
1229
|
+
unavailable.push({ postId: id, url: null, reason: "the post carries no permalink" });
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
try {
|
|
1233
|
+
const res = await client.callTool("get_post_comments", { url, limit: perPost });
|
|
1234
|
+
const structured = structuredOf(res);
|
|
1235
|
+
spend.record("get_post_comments", structured);
|
|
1236
|
+
const comments = rowsOf(structured.comments)
|
|
1237
|
+
.map((c) => {
|
|
1238
|
+
const text = String(c.text ?? "").trim();
|
|
1239
|
+
if (!text)
|
|
1240
|
+
return null;
|
|
1241
|
+
const signals = replySignals(text);
|
|
1242
|
+
return {
|
|
1243
|
+
id: commentIdFor(id, c),
|
|
1244
|
+
text,
|
|
1245
|
+
author: String(c.author ?? c.username ?? "").replace(/^@/, ""),
|
|
1246
|
+
likes: numberOf(c.likes),
|
|
1247
|
+
asking: signals.includes("asks for something to be made") || signals.includes("asks for information"),
|
|
1248
|
+
signals,
|
|
1249
|
+
};
|
|
1250
|
+
})
|
|
1251
|
+
.filter((c) => c !== null)
|
|
1252
|
+
.sort((a, b) => Number(b.asking) - Number(a.asking) || b.likes - a.likes);
|
|
1253
|
+
commentCount += comments.length;
|
|
1254
|
+
asks += comments.filter((c) => c.asking).length;
|
|
1255
|
+
demand.push({
|
|
1256
|
+
postId: id,
|
|
1257
|
+
url,
|
|
1258
|
+
title: post.title ?? post.caption ?? "",
|
|
1259
|
+
views: numberOf(post.views),
|
|
1260
|
+
// The platform's own keyword clustering, already fetched and
|
|
1261
|
+
// already paid for. It is evidence too.
|
|
1262
|
+
themes: structured.themes ?? [],
|
|
1263
|
+
comments,
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
catch (err) {
|
|
1267
|
+
spend.attempted("get_post_comments");
|
|
1268
|
+
unavailable.push({ postId: id, url, reason: reason(err) });
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
let supply = [];
|
|
1272
|
+
if (niche) {
|
|
1273
|
+
try {
|
|
1274
|
+
const res = await client.callTool("discover_social_posts", {
|
|
1275
|
+
niche,
|
|
1276
|
+
platform,
|
|
1277
|
+
limit: supplyCap,
|
|
1278
|
+
});
|
|
1279
|
+
const structured = structuredOf(res);
|
|
1280
|
+
spend.record("discover_social_posts", structured);
|
|
1281
|
+
supply = rowsOf(structured.posts).map((p, i) => ({
|
|
1282
|
+
...p,
|
|
1283
|
+
postId: postIdOf(p, i),
|
|
1284
|
+
postedAt: postDate(p),
|
|
1285
|
+
}));
|
|
1286
|
+
}
|
|
1287
|
+
catch (err) {
|
|
1288
|
+
spend.attempted("discover_social_posts");
|
|
1289
|
+
unavailable.push({ via: "discover_social_posts", reason: reason(err) });
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
else {
|
|
1293
|
+
unavailable.push({
|
|
1294
|
+
via: "discover_social_posts",
|
|
1295
|
+
reason: "no niche was given and the posts carry no hashtags to infer one from",
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
const supplyValues = supply.map((p) => metricOf(p, "views"));
|
|
1299
|
+
const myValues = mine.map((p) => metricOf(p, "views"));
|
|
1300
|
+
return evidence(nextGuidance({
|
|
1301
|
+
handle,
|
|
1302
|
+
niche: niche || "(none)",
|
|
1303
|
+
nicheSource,
|
|
1304
|
+
demandComments: commentCount,
|
|
1305
|
+
asks,
|
|
1306
|
+
supplyPosts: supply.length,
|
|
1307
|
+
failed: unavailable.length,
|
|
1308
|
+
}), {
|
|
1309
|
+
mode: "evidence",
|
|
1310
|
+
tool: "what_should_i_make_next",
|
|
1311
|
+
evidenceFrom: ["get_user_posts", "get_post_comments", "discover_social_posts"],
|
|
1312
|
+
username: handle,
|
|
1313
|
+
platform,
|
|
1314
|
+
niche: niche || null,
|
|
1315
|
+
nicheSource,
|
|
1316
|
+
demand,
|
|
1317
|
+
demandComments: commentCount,
|
|
1318
|
+
askCount: asks,
|
|
1319
|
+
supply,
|
|
1320
|
+
// Both medians, so "already saturated" and "big for me" are numbers
|
|
1321
|
+
// rather than impressions.
|
|
1322
|
+
supplyBaseline: distributionOf(supplyValues),
|
|
1323
|
+
yourBaseline: distributionOf(myValues),
|
|
1324
|
+
posts: supply,
|
|
1325
|
+
unavailable,
|
|
1326
|
+
creditsCharged: spend.credits,
|
|
1327
|
+
mcpCredits: spend.payload,
|
|
1328
|
+
});
|
|
1329
|
+
});
|
|
1330
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1331
|
+
// The other half of answer_my_audience: the model hands back what it drafted
|
|
1332
|
+
// and this draws it, in the surface built for triaging comments. Costs
|
|
1333
|
+
// nothing and fetches nothing — everything it needs is already in context.
|
|
1334
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1335
|
+
server.registerTool("show_audience_replies", {
|
|
1336
|
+
title: "Show Audience Replies",
|
|
1337
|
+
_meta: viewMeta("show_audience_replies"),
|
|
1338
|
+
description: "Lay out the replies you drafted from answer_my_audience so a person can read them. " +
|
|
1339
|
+
"Free, and makes no requests — it only draws what you pass it. It does NOT send " +
|
|
1340
|
+
"anything: no nooticr connection can post a comment, so each row is a draft for the " +
|
|
1341
|
+
"creator to copy into the app themselves. Each comment shows with what you decided to do " +
|
|
1342
|
+
"about it and your draft underneath, grouped under its post, so they can work through " +
|
|
1343
|
+
"them one at a time and skip the rest. Call this after you have drafted the replies, not " +
|
|
1344
|
+
"instead of drafting them.",
|
|
1345
|
+
annotations: {
|
|
1346
|
+
readOnlyHint: true,
|
|
1347
|
+
destructiveHint: false,
|
|
1348
|
+
idempotentHint: true,
|
|
1349
|
+
// Draws what it is given; reaches nothing.
|
|
1350
|
+
openWorldHint: false,
|
|
1351
|
+
},
|
|
1352
|
+
outputSchema: OUTPUT_SCHEMAS.show_audience_replies,
|
|
1353
|
+
inputSchema: z
|
|
1354
|
+
.object({
|
|
1355
|
+
username: z.string().describe("Whose audience this is, with or without @."),
|
|
1356
|
+
summary: z.string().optional().describe("What the comment section is asking for, in a sentence or two."),
|
|
1357
|
+
replies: z
|
|
1358
|
+
.array(z.object({
|
|
1359
|
+
id: z.string().describe("The comment id from answer_my_audience, so the row addresses the same comment."),
|
|
1360
|
+
comment: z.string().describe("What they wrote."),
|
|
1361
|
+
author: z.string().optional(),
|
|
1362
|
+
likes: z.number().optional(),
|
|
1363
|
+
postUrl: z.string().optional().describe("The post it was left on — rows are grouped by it."),
|
|
1364
|
+
postTitle: z.string().optional(),
|
|
1365
|
+
postedAt: z.string().optional(),
|
|
1366
|
+
draft: z
|
|
1367
|
+
.string()
|
|
1368
|
+
.optional()
|
|
1369
|
+
.describe("The reply, in the creator's voice, for them to paste in themselves."),
|
|
1370
|
+
kind: z
|
|
1371
|
+
.enum(["answer", "pin", "make_a_video", "escalate", "ignore"])
|
|
1372
|
+
.optional()
|
|
1373
|
+
.describe("What the creator should do about it by hand — nothing here acts on their " +
|
|
1374
|
+
"behalf. Becomes the filter chip they triage by."),
|
|
1375
|
+
why: z.string().optional().describe("Why you decided that, if it is not obvious."),
|
|
1376
|
+
}))
|
|
1377
|
+
.describe("One entry per comment you decided about."),
|
|
1378
|
+
themes: z.array(z.string()).optional().describe("What keeps coming up across them."),
|
|
1379
|
+
nextSteps: z.array(z.string()).optional().describe("What to do beyond replying."),
|
|
1380
|
+
})
|
|
1381
|
+
.strict(),
|
|
1382
|
+
}, async (args) => {
|
|
1383
|
+
const handle = normaliseHandle(args.username);
|
|
1384
|
+
const counts = {};
|
|
1385
|
+
for (const r of args.replies) {
|
|
1386
|
+
const kind = String(r.kind ?? "answer");
|
|
1387
|
+
counts[kind] = (counts[kind] ?? 0) + 1;
|
|
1388
|
+
}
|
|
1389
|
+
// Grouped by the post, because that is the unit a person acts on: you
|
|
1390
|
+
// open one post and answer everything under it, not one comment from
|
|
1391
|
+
// each of six posts in turn.
|
|
1392
|
+
const groups = new Map();
|
|
1393
|
+
for (const r of args.replies) {
|
|
1394
|
+
const url = String(r.postUrl ?? "");
|
|
1395
|
+
const key = url || "(unknown post)";
|
|
1396
|
+
let group = groups.get(key);
|
|
1397
|
+
if (!group) {
|
|
1398
|
+
groups.set(key, (group = {
|
|
1399
|
+
post: {
|
|
1400
|
+
platform: platformFromUrl(url),
|
|
1401
|
+
title: String(r.postTitle ?? ""),
|
|
1402
|
+
externalUrl: url || null,
|
|
1403
|
+
},
|
|
1404
|
+
postIsAboutTerm: false,
|
|
1405
|
+
mentionCount: 0,
|
|
1406
|
+
mentions: [],
|
|
1407
|
+
}));
|
|
1408
|
+
}
|
|
1409
|
+
group.mentions.push({
|
|
1410
|
+
id: r.id,
|
|
1411
|
+
text: r.comment,
|
|
1412
|
+
username: r.author ?? "",
|
|
1413
|
+
likes: r.likes ?? 0,
|
|
1414
|
+
postedAt: r.postedAt ?? null,
|
|
1415
|
+
category: r.kind ?? "answer",
|
|
1416
|
+
// The view draws `note` under the comment, which is where the draft
|
|
1417
|
+
// has to be for a person to triage it without opening anything.
|
|
1418
|
+
note: r.draft ?? r.why ?? null,
|
|
1419
|
+
draft: r.draft ?? null,
|
|
1420
|
+
why: r.why ?? null,
|
|
1421
|
+
hits: 1,
|
|
1422
|
+
});
|
|
1423
|
+
group.mentionCount = group.mentions.length;
|
|
1424
|
+
}
|
|
1425
|
+
const threads = [...groups.values()];
|
|
1426
|
+
const drafted = args.replies.filter((r) => String(r.draft ?? "").trim()).length;
|
|
1427
|
+
return {
|
|
1428
|
+
content: [
|
|
1429
|
+
{
|
|
1430
|
+
type: "text",
|
|
1431
|
+
text: `Showing ${args.replies.length} comment${args.replies.length === 1 ? "" : "s"} with ` +
|
|
1432
|
+
`${drafted} drafted repl${drafted === 1 ? "y" : "ies"} for @${handle} to send ` +
|
|
1433
|
+
`themselves — nothing here posts them.` +
|
|
1434
|
+
(args.summary ? ` ${args.summary}` : ""),
|
|
1435
|
+
},
|
|
1436
|
+
],
|
|
1437
|
+
// term + threads is what the monitoring view keys off, so this renders
|
|
1438
|
+
// through the surface a brand sweep and a comment review already use.
|
|
1439
|
+
structuredContent: {
|
|
1440
|
+
review: true,
|
|
1441
|
+
term: `@${handle}`,
|
|
1442
|
+
username: handle,
|
|
1443
|
+
summary: args.summary ?? null,
|
|
1444
|
+
totalMentions: args.replies.length,
|
|
1445
|
+
drafted,
|
|
1446
|
+
byCategory: counts,
|
|
1447
|
+
themes: args.themes ?? [],
|
|
1448
|
+
nextSteps: args.nextSteps ?? [],
|
|
1449
|
+
threads,
|
|
1450
|
+
// Nothing was fetched, so nothing was charged.
|
|
1451
|
+
mcpCredits: { cost: 0 },
|
|
1452
|
+
},
|
|
1453
|
+
};
|
|
1454
|
+
});
|
|
1455
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1456
|
+
// 7. search_spoken_mentions
|
|
1457
|
+
//
|
|
1458
|
+
// search_mentions reads captions, post bodies and comments — text a brand
|
|
1459
|
+
// can be named in without anyone ever saying it out loud, and it stays
|
|
1460
|
+
// silent about the rant that says the name three times on camera and never
|
|
1461
|
+
// once in writing. This closes that gap the way every tool here does:
|
|
1462
|
+
// existing calls, composed. Narrow to candidates with discover_social_posts
|
|
1463
|
+
// / get_user_posts, transcribe the survivors with get_post_transcript
|
|
1464
|
+
// (which reads a platform's own caption track, not our own speech
|
|
1465
|
+
// recognition — see its docstring in tools.ts), and search the words.
|
|
1466
|
+
// TikTok and YouTube only, because those are the only two networks that
|
|
1467
|
+
// call answers for; asking about anything else would bill for a fetch that
|
|
1468
|
+
// cannot succeed.
|
|
1469
|
+
//
|
|
1470
|
+
// The one thing this fan-out has to hold that the others do not: there is
|
|
1471
|
+
// no cheap first call that reveals how many candidates are worth
|
|
1472
|
+
// transcribing, the way a watchlist length or an already-fetched post list
|
|
1473
|
+
// does elsewhere in this file. A wide niche can return far more candidates
|
|
1474
|
+
// than are worth reading to find three hits, so `maxTranscripts` is a hard,
|
|
1475
|
+
// server-clamped ceiling (see MAX_SPOKEN_TRANSCRIPTS in spend.ts) rather
|
|
1476
|
+
// than a suggestion, and the whole worst case — narrowing plus the ceiling
|
|
1477
|
+
// — is confirmed before a single call is made, the same way search_mentions
|
|
1478
|
+
// confirms a worst case from its own arguments alone.
|
|
1479
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1480
|
+
const SPOKEN_TRANSCRIBE_BUDGET_MS = 12_000;
|
|
1481
|
+
server.registerTool("search_spoken_mentions", {
|
|
1482
|
+
title: "Search Spoken Mentions",
|
|
1483
|
+
_meta: viewMeta("search_spoken_mentions"),
|
|
1484
|
+
description: "Brand mentions people SAY but never type. search_mentions reads captions, post bodies " +
|
|
1485
|
+
"and comments — text — so a video that names a brand only out loud is invisible to it. " +
|
|
1486
|
+
"This reads the words actually spoken, from the platform's own caption track (not our own " +
|
|
1487
|
+
"speech recognition — see get_post_transcript), and searches them for a term. Narrows to " +
|
|
1488
|
+
"candidate posts first (a niche/keyword sweep, named creator handles, and/or your " +
|
|
1489
|
+
"watchlist), transcribes only the most-viewed survivors up to a hard ceiling you set, and " +
|
|
1490
|
+
"returns the matched line with surrounding context so tone can be judged. TikTok and " +
|
|
1491
|
+
"YouTube only — the only two networks whose posts carry a caption track this cheaply — " +
|
|
1492
|
+
"and even there a video with no captions is invisible to this tool; coverage is real but " +
|
|
1493
|
+
"partial, and the result says how many candidates were found, transcribed and matched so " +
|
|
1494
|
+
"the gap is never silent. Costs 2 nooticr credits per platform a niche is searched on, 2 " +
|
|
1495
|
+
"per creator handle checked (including ones added by useWatchlist), and 1 per transcript " +
|
|
1496
|
+
"actually fetched — never more than `maxTranscripts` transcripts, whatever the candidate " +
|
|
1497
|
+
"count. Above 6 credits worst-case it asks first, like search_mentions. Use when a term " +
|
|
1498
|
+
"might be spoken on camera but not written anywhere; search_mentions is for what people " +
|
|
1499
|
+
"typed.",
|
|
1500
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1501
|
+
outputSchema: OUTPUT_SCHEMAS.search_spoken_mentions,
|
|
1502
|
+
inputSchema: z
|
|
1503
|
+
.object({
|
|
1504
|
+
term: z
|
|
1505
|
+
.string()
|
|
1506
|
+
.describe("Brand, product or person to listen for, e.g. 'nooticr'. Matched case-insensitively " +
|
|
1507
|
+
"on whole words, so 'nike' will not match inside 'nikeisha'."),
|
|
1508
|
+
platforms: z
|
|
1509
|
+
.array(z.enum(["tiktok", "youtube"]))
|
|
1510
|
+
.optional()
|
|
1511
|
+
.describe("Which networks to check (default: both). These are the only two whose posts carry " +
|
|
1512
|
+
"a caption track this tool can read."),
|
|
1513
|
+
niche: z
|
|
1514
|
+
.string()
|
|
1515
|
+
.optional()
|
|
1516
|
+
.describe("Niche or keyword for a candidate sweep, e.g. 'skincare'. Provide this, usernames, " +
|
|
1517
|
+
"useWatchlist, or any combination — at least one is required."),
|
|
1518
|
+
usernames: z
|
|
1519
|
+
.array(z.string())
|
|
1520
|
+
.optional()
|
|
1521
|
+
.describe("Specific creator handles to check, with or without @. Checked on every platform in " +
|
|
1522
|
+
"`platforms`."),
|
|
1523
|
+
useWatchlist: z
|
|
1524
|
+
.boolean()
|
|
1525
|
+
.optional()
|
|
1526
|
+
.describe("Also check your watchlisted creators who are on tiktok or youtube. Looking the " +
|
|
1527
|
+
"list up is free; each creator it adds is still priced like any other handle."),
|
|
1528
|
+
candidateLimit: z
|
|
1529
|
+
.number()
|
|
1530
|
+
.int()
|
|
1531
|
+
.optional()
|
|
1532
|
+
.describe("Posts pulled per narrowing call, before the transcript ceiling is applied (default " +
|
|
1533
|
+
"15, max 25). Finds more candidates at the same narrowing cost — does not raise " +
|
|
1534
|
+
"transcript spend on its own."),
|
|
1535
|
+
maxTranscripts: z
|
|
1536
|
+
.number()
|
|
1537
|
+
.int()
|
|
1538
|
+
.optional()
|
|
1539
|
+
.describe("Hard ceiling on transcripts fetched, at 1 credit each (default 8, max " +
|
|
1540
|
+
`${MAX_SPOKEN_TRANSCRIPTS}). The most-viewed candidates are read first, so lowering ` +
|
|
1541
|
+
"this trades completeness for cost rather than dropping posts at random."),
|
|
1542
|
+
})
|
|
1543
|
+
.strict(),
|
|
1544
|
+
}, async (args, extra) => {
|
|
1545
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
1546
|
+
const term = String(args.term ?? "").trim();
|
|
1547
|
+
if (!term) {
|
|
1548
|
+
return {
|
|
1549
|
+
content: [{ type: "text", text: "search_spoken_mentions needs a term to listen for." }],
|
|
1550
|
+
isError: true,
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
const platforms = [
|
|
1554
|
+
...new Set((args.platforms && args.platforms.length ? args.platforms : ["tiktok", "youtube"]).map((p) => String(p).toLowerCase())),
|
|
1555
|
+
].filter((p) => p === "tiktok" || p === "youtube");
|
|
1556
|
+
const explicitHandles = [...new Set((args.usernames ?? []).map(normaliseHandle).filter(Boolean))];
|
|
1557
|
+
// A watchlist handle already knows its own platform, so it is checked
|
|
1558
|
+
// once there rather than once per requested platform — the same
|
|
1559
|
+
// precision track_competitor already applies when it reads this store.
|
|
1560
|
+
let watchlistUnits = [];
|
|
1561
|
+
let watchlistTotal = 0;
|
|
1562
|
+
if (args.useWatchlist) {
|
|
1563
|
+
try {
|
|
1564
|
+
const owner = await watchlistOwner(client);
|
|
1565
|
+
const entries = await store.list(owner);
|
|
1566
|
+
const inScope = entries.filter((e) => platforms.includes(e.platform));
|
|
1567
|
+
watchlistTotal = inScope.length;
|
|
1568
|
+
watchlistUnits = inScope.map((e) => ({ handle: e.handle, platform: e.platform }));
|
|
1569
|
+
}
|
|
1570
|
+
catch {
|
|
1571
|
+
// A watchlist that will not answer costs nothing added from it —
|
|
1572
|
+
// same as an empty one, not a reason to fail the whole call.
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
const handleUnits = [
|
|
1576
|
+
...explicitHandles.flatMap((handle) => platforms.map((platform) => ({ handle, platform }))),
|
|
1577
|
+
...watchlistUnits,
|
|
1578
|
+
].slice(0, MAX_SPOKEN_HANDLE_CALLS);
|
|
1579
|
+
if (!args.niche && !handleUnits.length) {
|
|
1580
|
+
return {
|
|
1581
|
+
content: [
|
|
1582
|
+
{
|
|
1583
|
+
type: "text",
|
|
1584
|
+
text: "search_spoken_mentions needs a niche/keyword, one or more creator handles, or " +
|
|
1585
|
+
"useWatchlist — nothing was fetched, so nothing was charged.",
|
|
1586
|
+
},
|
|
1587
|
+
],
|
|
1588
|
+
isError: true,
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
const candidateLimit = clamp(args.candidateLimit, 15, 1, 25);
|
|
1592
|
+
const maxTranscripts = clamp(args.maxTranscripts, 8, 1, MAX_SPOKEN_TRANSCRIPTS);
|
|
1593
|
+
// The full worst case, from arguments alone: every narrowing call runs,
|
|
1594
|
+
// and the ceiling's worth of transcripts is fetched. Confirmed before
|
|
1595
|
+
// any call is made — actual spend is very often less, and the guidance
|
|
1596
|
+
// says so, but nobody is ever asked to approve a number smaller than
|
|
1597
|
+
// what could actually happen.
|
|
1598
|
+
const worstCase = costOf([
|
|
1599
|
+
...Array(args.niche ? platforms.length : 0).fill("discover_social_posts"),
|
|
1600
|
+
...Array(handleUnits.length).fill("get_user_posts"),
|
|
1601
|
+
...Array(maxTranscripts).fill("get_post_transcript"),
|
|
1602
|
+
]);
|
|
1603
|
+
const decision = await confirmSpend(server.server, {
|
|
1604
|
+
credits: worstCase,
|
|
1605
|
+
summary: `Search for spoken mentions of "${term}" on ${platforms.join(" and ")}` +
|
|
1606
|
+
(args.niche ? `, sweeping "${args.niche}"` : "") +
|
|
1607
|
+
(handleUnits.length
|
|
1608
|
+
? ` and checking ${handleUnits.length} creator${handleUnits.length === 1 ? "" : "s"}`
|
|
1609
|
+
: "") +
|
|
1610
|
+
`, transcribing up to ${maxTranscripts} of the results.`,
|
|
1611
|
+
cheaper: 'Lower "maxTranscripts", or narrow "platforms"/"usernames", to spend less.',
|
|
1612
|
+
});
|
|
1613
|
+
if (!decision.proceed) {
|
|
1614
|
+
return declinedResult(worstCase, "That spoken-mention search", 'Lower "maxTranscripts", or narrow "platforms"/"usernames", to spend less.');
|
|
1615
|
+
}
|
|
1616
|
+
const spend = new Spend();
|
|
1617
|
+
const candidatesByKey = new Map();
|
|
1618
|
+
const narrowingFailures = [];
|
|
1619
|
+
const addCandidate = (raw, source) => {
|
|
1620
|
+
const url = String(raw.externalUrl ?? raw.url ?? "");
|
|
1621
|
+
const key = url || `${String(raw.platform ?? "")}:${String(raw.title ?? raw.caption ?? "")}:${candidatesByKey.size}`;
|
|
1622
|
+
if (candidatesByKey.has(key))
|
|
1623
|
+
return;
|
|
1624
|
+
candidatesByKey.set(key, { ...raw, foundVia: source });
|
|
1625
|
+
};
|
|
1626
|
+
if (args.niche) {
|
|
1627
|
+
for (const platform of platforms) {
|
|
1628
|
+
try {
|
|
1629
|
+
const res = await client.callTool("discover_social_posts", {
|
|
1630
|
+
niche: args.niche,
|
|
1631
|
+
platform,
|
|
1632
|
+
limit: candidateLimit,
|
|
1633
|
+
});
|
|
1634
|
+
const structured = structuredOf(res);
|
|
1635
|
+
spend.record("discover_social_posts", structured);
|
|
1636
|
+
for (const p of rowsOf(structured.posts))
|
|
1637
|
+
addCandidate(p, "niche_search");
|
|
1638
|
+
}
|
|
1639
|
+
catch (err) {
|
|
1640
|
+
spend.attempted("discover_social_posts");
|
|
1641
|
+
narrowingFailures.push({ via: "discover_social_posts", platform, reason: reason(err) });
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
for (const { handle, platform } of handleUnits) {
|
|
1646
|
+
try {
|
|
1647
|
+
const res = await client.callTool("get_user_posts", { username: handle, platform, limit: candidateLimit });
|
|
1648
|
+
const structured = structuredOf(res);
|
|
1649
|
+
spend.record("get_user_posts", structured);
|
|
1650
|
+
for (const p of rowsOf(structured.posts))
|
|
1651
|
+
addCandidate(p, `handle:${handle}`);
|
|
1652
|
+
}
|
|
1653
|
+
catch (err) {
|
|
1654
|
+
spend.attempted("get_user_posts");
|
|
1655
|
+
narrowingFailures.push({ via: "get_user_posts", handle, platform, reason: reason(err) });
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
// Most-viewed first: the ceiling should spend its transcripts on the
|
|
1659
|
+
// candidates most likely to matter, not on whichever a platform
|
|
1660
|
+
// happened to return first.
|
|
1661
|
+
const candidates = [...candidatesByKey.values()].sort((a, b) => numberOf(b.views) - numberOf(a.views));
|
|
1662
|
+
const considered = candidates.length;
|
|
1663
|
+
const toCheck = candidates.slice(0, maxTranscripts);
|
|
1664
|
+
const ceilingReached = considered > toCheck.length;
|
|
1665
|
+
const baseFields = {
|
|
1666
|
+
mode: "evidence",
|
|
1667
|
+
tool: "search_spoken_mentions",
|
|
1668
|
+
term,
|
|
1669
|
+
platforms,
|
|
1670
|
+
niche: args.niche ?? null,
|
|
1671
|
+
usernames: explicitHandles,
|
|
1672
|
+
watchlistChecked: watchlistTotal,
|
|
1673
|
+
maxTranscripts,
|
|
1674
|
+
};
|
|
1675
|
+
if (!toCheck.length) {
|
|
1676
|
+
return evidence(`No candidate posts were found on ${platforms.join(" and ")}` +
|
|
1677
|
+
(args.niche ? ` for "${args.niche}"` : "") +
|
|
1678
|
+
`. ${spend.credits} credit${spend.credits === 1 ? "" : "s"} were spent narrowing and ` +
|
|
1679
|
+
"none of it reached a transcript.", {
|
|
1680
|
+
...baseFields,
|
|
1681
|
+
candidatesConsidered: 0,
|
|
1682
|
+
transcribed: 0,
|
|
1683
|
+
transcriptsAvailable: 0,
|
|
1684
|
+
matched: 0,
|
|
1685
|
+
ceilingReached: false,
|
|
1686
|
+
hits: [],
|
|
1687
|
+
posts: [],
|
|
1688
|
+
unavailable: narrowingFailures,
|
|
1689
|
+
creditsCharged: spend.credits,
|
|
1690
|
+
mcpCredits: spend.payload,
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
const hits = [];
|
|
1694
|
+
const unavailable = [...narrowingFailures];
|
|
1695
|
+
let transcribed = 0;
|
|
1696
|
+
let transcriptsAvailable = 0;
|
|
1697
|
+
for (const [index, post] of toCheck.entries()) {
|
|
1698
|
+
const url = String(post.externalUrl ?? post.url ?? "");
|
|
1699
|
+
const id = postIdOf(post, index);
|
|
1700
|
+
if (!url) {
|
|
1701
|
+
unavailable.push({ postId: id, reason: "the post carries no permalink to fetch a transcript from" });
|
|
1702
|
+
continue;
|
|
1703
|
+
}
|
|
1704
|
+
transcribed++;
|
|
1705
|
+
try {
|
|
1706
|
+
// A speech-to-text fallback can come back "transcribing: true,
|
|
1707
|
+
// retryAfterMs" — the job is accepted and running, not a caption-track
|
|
1708
|
+
// miss — and treating it as one would silently misreport every post
|
|
1709
|
+
// whose answer just hadn't finished yet. Poll it out under a bounded
|
|
1710
|
+
// budget so one slow job can't hang the whole search; the first
|
|
1711
|
+
// callTool above already spent this post's credit, and every retry
|
|
1712
|
+
// below re-reads the same cached job rather than spending again.
|
|
1713
|
+
let structured = structuredOf(await client.callTool("get_post_transcript", { url }));
|
|
1714
|
+
spend.record("get_post_transcript", structured);
|
|
1715
|
+
const pollStartedAt = Date.now();
|
|
1716
|
+
while (structured.transcribing && Date.now() - pollStartedAt < SPOKEN_TRANSCRIBE_BUDGET_MS) {
|
|
1717
|
+
const waitMs = Math.min(Number(structured.retryAfterMs) || 1000, 5000);
|
|
1718
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
1719
|
+
structured = structuredOf(await client.callTool("get_post_transcript", { url }));
|
|
1720
|
+
}
|
|
1721
|
+
if (structured.transcribing) {
|
|
1722
|
+
unavailable.push({
|
|
1723
|
+
postId: id,
|
|
1724
|
+
url,
|
|
1725
|
+
reason: "still listening to this post's audio — try again shortly",
|
|
1726
|
+
});
|
|
1727
|
+
continue;
|
|
1728
|
+
}
|
|
1729
|
+
if (!structured.available) {
|
|
1730
|
+
unavailable.push({ postId: id, url, reason: String(structured.reason ?? "no caption track") });
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
transcriptsAvailable++;
|
|
1734
|
+
const { excerpts, matchCount } = matchExcerpts(String(structured.transcript ?? ""), term);
|
|
1735
|
+
if (matchCount > 0) {
|
|
1736
|
+
hits.push({
|
|
1737
|
+
post: { ...post, postId: id, postedAt: postDate(post) },
|
|
1738
|
+
postId: id,
|
|
1739
|
+
matchCount,
|
|
1740
|
+
excerpts,
|
|
1741
|
+
wordCount: numberOf(structured.wordCount),
|
|
1742
|
+
language: structured.language ?? null,
|
|
1743
|
+
autoGenerated: !!structured.autoGenerated,
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
catch (err) {
|
|
1748
|
+
spend.attempted("get_post_transcript");
|
|
1749
|
+
unavailable.push({ postId: id, url, reason: reason(err) });
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
// Loudest match first, then reach — the same ordering principle as the
|
|
1753
|
+
// rest of this file: what the sweep found, ranked by how much it says.
|
|
1754
|
+
hits.sort((a, b) => numberOf(b.matchCount) - numberOf(a.matchCount) ||
|
|
1755
|
+
numberOf(b.post?.views) - numberOf(a.post?.views));
|
|
1756
|
+
return evidence(spokenMentionGuidance({
|
|
1757
|
+
term,
|
|
1758
|
+
platforms,
|
|
1759
|
+
considered,
|
|
1760
|
+
transcribed,
|
|
1761
|
+
transcriptsAvailable,
|
|
1762
|
+
matched: hits.length,
|
|
1763
|
+
maxTranscripts,
|
|
1764
|
+
ceilingReached,
|
|
1765
|
+
failed: unavailable.length,
|
|
1766
|
+
}), {
|
|
1767
|
+
...baseFields,
|
|
1768
|
+
evidenceFrom: [
|
|
1769
|
+
...(args.niche ? ["discover_social_posts"] : []),
|
|
1770
|
+
...(handleUnits.length ? ["get_user_posts"] : []),
|
|
1771
|
+
"get_post_transcript",
|
|
1772
|
+
],
|
|
1773
|
+
candidatesConsidered: considered,
|
|
1774
|
+
transcribed,
|
|
1775
|
+
transcriptsAvailable,
|
|
1776
|
+
matched: hits.length,
|
|
1777
|
+
ceilingReached,
|
|
1778
|
+
hits,
|
|
1779
|
+
posts: hits.map((h) => h.post),
|
|
1780
|
+
unavailable,
|
|
1781
|
+
creditsCharged: spend.credits,
|
|
1782
|
+
mcpCredits: spend.payload,
|
|
1783
|
+
});
|
|
1784
|
+
});
|
|
1785
|
+
}
|