@nooticr/mcp 1.26.28 → 1.26.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/dist/shared/brand-watch.js +37 -9
- package/dist/shared/evidence-digest.js +269 -0
- package/dist/shared/evidence.js +67 -0
- package/dist/shared/jobs.js +199 -22
- package/dist/shared/loading-plans.js +10 -1
- package/dist/shared/output-schemas.js +32 -2
- package/dist/shared/own-account.js +12 -2
- package/dist/shared/prompts.js +1 -1
- package/dist/shared/standings.js +5 -2
- package/dist/shared/tools-def.js +9 -3
- package/dist/shared/tools.js +110 -34
- package/dist/shared/ui-template.js +13 -3
- package/dist/shared/watchlist.js +13 -5
- package/package.json +4 -3
package/dist/shared/jobs.js
CHANGED
|
@@ -57,6 +57,7 @@ import { z } from "zod";
|
|
|
57
57
|
import { OUTPUT_SCHEMAS } from "./output-schemas.js";
|
|
58
58
|
import { platformFromUrl, postSlug } from "./comment-review.js";
|
|
59
59
|
import { clamp, handleMissGuidance, ownIt, PLATFORM_ARG } from "./evidence.js";
|
|
60
|
+
import { withEvidence } from "./evidence-digest.js";
|
|
60
61
|
import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, SPOKEN_PLATFORMS, CREDITS_PER_CREATOR, } from "./spend.js";
|
|
61
62
|
import { distributionOf, excluding, numberOf, standing, } from "./performance.js";
|
|
62
63
|
import { normaliseHandle, watchEntryId, watchlistOwner } from "./watchlist.js";
|
|
@@ -596,10 +597,22 @@ const metricArg = z
|
|
|
596
597
|
.optional()
|
|
597
598
|
.describe("Which stat to rank on (default views).");
|
|
598
599
|
export function registerJobTools(server, makeClient, store) {
|
|
599
|
-
/** Guidance
|
|
600
|
+
/** Guidance AND evidence in both channels, because no host delivers both. */
|
|
601
|
+
// Neither channel is safe on its own, and which one is dropped depends on
|
|
602
|
+
// the host: Claude Code discards every `content` text block when a result
|
|
603
|
+
// carries `structuredContent` (#44, measured at 0 guidance phrases across
|
|
604
|
+
// 175 tool results), while Claude.ai routes `structuredContent` to the
|
|
605
|
+
// widget and hands the model only the text blocks (#59).
|
|
606
|
+
//
|
|
607
|
+
// #51 made the guidance survive both. The evidence did not, so a real
|
|
608
|
+
// session got "3 posts that might be someone describing ...", instructions
|
|
609
|
+
// to quote lines from them, and no posts. The guidance these tools write is
|
|
610
|
+
// deictic — it counts and describes material that used to be somewhere
|
|
611
|
+
// else — so `withEvidence` puts a rendering of that material in the same
|
|
612
|
+
// block as the sentence describing it.
|
|
600
613
|
const evidence = (guidance, payload) => ({
|
|
601
|
-
content: [{ type: "text", text: guidance }],
|
|
602
|
-
structuredContent: payload,
|
|
614
|
+
content: [{ type: "text", text: withEvidence(guidance, payload) }],
|
|
615
|
+
structuredContent: { guidance, ...payload },
|
|
603
616
|
});
|
|
604
617
|
const failed = (prefix, err) => ({
|
|
605
618
|
content: [{ type: "text", text: `${prefix}: ${reason(err)}` }],
|
|
@@ -802,23 +815,25 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
802
815
|
});
|
|
803
816
|
});
|
|
804
817
|
// ───────────────────────────────────────────────────────────────────────────
|
|
805
|
-
// 2.
|
|
818
|
+
// 2. track_creator
|
|
806
819
|
//
|
|
807
820
|
// Writes, when the creator is on the watchlist: it moves a baseline forward
|
|
808
821
|
// so the next call can say what is new. That baseline is deliberately not
|
|
809
822
|
// the one catch_up_watchlist keeps — two tools sharing one "since I last
|
|
810
823
|
// looked" means each silently consumes the other's answer.
|
|
811
824
|
// ───────────────────────────────────────────────────────────────────────────
|
|
812
|
-
server.registerTool("
|
|
825
|
+
server.registerTool("track_creator", {
|
|
813
826
|
title: "Track Competitor",
|
|
814
|
-
_meta: viewMeta("
|
|
815
|
-
description: "
|
|
816
|
-
"
|
|
817
|
-
"
|
|
818
|
-
"
|
|
819
|
-
"
|
|
820
|
-
"
|
|
821
|
-
"
|
|
827
|
+
_meta: viewMeta("track_creator"),
|
|
828
|
+
description: "Track a competitor, keep an eye on a rival, see how someone is doing lately, catch what " +
|
|
829
|
+
"they shipped since you last looked — this is the tool for all of those. What a creator " +
|
|
830
|
+
"shipped recently and which of it beat THEIR OWN baseline: fetches their recent posts " +
|
|
831
|
+
"once and scores each against the median of that same window, because a raw view count " +
|
|
832
|
+
"mostly measures follower count — outperformance against themselves is the signal. If " +
|
|
833
|
+
"they are on your watchlist it also marks what is new since your last track_creator " +
|
|
834
|
+
"call and moves that marker forward. " +
|
|
835
|
+
"Consumes 2 nooticr credits — one post list, whatever the window size. Use for a rival " +
|
|
836
|
+
"you follow; analyze_creator_profile is the full teardown of one you do not.",
|
|
822
837
|
annotations: {
|
|
823
838
|
readOnlyHint: false,
|
|
824
839
|
destructiveHint: false,
|
|
@@ -827,7 +842,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
827
842
|
idempotentHint: false,
|
|
828
843
|
openWorldHint: true,
|
|
829
844
|
},
|
|
830
|
-
outputSchema: OUTPUT_SCHEMAS.
|
|
845
|
+
outputSchema: OUTPUT_SCHEMAS.track_creator,
|
|
831
846
|
inputSchema: z
|
|
832
847
|
.object({
|
|
833
848
|
username: z
|
|
@@ -864,7 +879,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
864
879
|
feed = rowsOf(structured.posts);
|
|
865
880
|
}
|
|
866
881
|
catch (err) {
|
|
867
|
-
return failed("
|
|
882
|
+
return failed("track_creator could not list the posts", err);
|
|
868
883
|
}
|
|
869
884
|
// Nothing came back. Previously this fell through to the scoring path and
|
|
870
885
|
// produced a baseline-less report about zero posts, which reads as "this
|
|
@@ -872,7 +887,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
872
887
|
if (feed.length === 0) {
|
|
873
888
|
return evidence(handleMissGuidance({ handle, platform, defaulted: platformDefaulted }), {
|
|
874
889
|
mode: "evidence",
|
|
875
|
-
tool: "
|
|
890
|
+
tool: "track_creator",
|
|
876
891
|
evidenceFrom: ["get_user_posts"],
|
|
877
892
|
username: handle,
|
|
878
893
|
platform,
|
|
@@ -942,7 +957,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
942
957
|
lastChecked: previous?.capturedAt,
|
|
943
958
|
}), {
|
|
944
959
|
mode: "evidence",
|
|
945
|
-
tool: "
|
|
960
|
+
tool: "track_creator",
|
|
946
961
|
evidenceFrom: ["get_user_posts"],
|
|
947
962
|
username: handle,
|
|
948
963
|
platform,
|
|
@@ -967,7 +982,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
967
982
|
// ───────────────────────────────────────────────────────────────────────────
|
|
968
983
|
// 2b. compare_creators / watchlist_standings
|
|
969
984
|
//
|
|
970
|
-
// The comparison
|
|
985
|
+
// The comparison track_creator computes and discards. Both are pure
|
|
971
986
|
// compositions of get_user_posts — one fetch per creator, priced as the sum
|
|
972
987
|
// — and both leave the reading to the caller, because a ranking hides the
|
|
973
988
|
// window size that decides whether the ranking means anything.
|
|
@@ -1015,7 +1030,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1015
1030
|
"ranking is yours to make: 'how often' and 'how big' usually disagree, and a hit rate " +
|
|
1016
1031
|
"over a short window is one post either way, so the window size travels with every " +
|
|
1017
1032
|
"number. Fetches one post list per creator: 2 nooticr credits each, 4-10 in total. " +
|
|
1018
|
-
"Use for 'is their hit rate better than mine';
|
|
1033
|
+
"Use for 'is their hit rate better than mine'; track_creator is one creator alone.",
|
|
1019
1034
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1020
1035
|
outputSchema: OUTPUT_SCHEMAS.compare_creators,
|
|
1021
1036
|
inputSchema: z
|
|
@@ -1120,10 +1135,10 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1120
1135
|
credits,
|
|
1121
1136
|
summary: `Read the recent posts of all ${entries.length} creator${entries.length === 1 ? "" : "s"} ` +
|
|
1122
1137
|
"on your watchlist and score each against their own median.",
|
|
1123
|
-
cheaper: "Unwatch creators you no longer follow, or use
|
|
1138
|
+
cheaper: "Unwatch creators you no longer follow, or use track_creator for just one.",
|
|
1124
1139
|
});
|
|
1125
1140
|
if (!decision.proceed) {
|
|
1126
|
-
return declinedResult(credits, "Those standings", "Use
|
|
1141
|
+
return declinedResult(credits, "Those standings", "Use track_creator for a single creator.");
|
|
1127
1142
|
}
|
|
1128
1143
|
const spend = new Spend();
|
|
1129
1144
|
const rows = [];
|
|
@@ -1817,7 +1832,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1817
1832
|
const explicitHandles = [...new Set((args.usernames ?? []).map(normaliseHandle).filter(Boolean))];
|
|
1818
1833
|
// A watchlist handle already knows its own platform, so it is checked
|
|
1819
1834
|
// once there rather than once per requested platform — the same
|
|
1820
|
-
// precision
|
|
1835
|
+
// precision track_creator already applies when it reads this store.
|
|
1821
1836
|
let watchlistUnits = [];
|
|
1822
1837
|
let watchlistTotal = 0;
|
|
1823
1838
|
if (args.useWatchlist) {
|
|
@@ -2048,4 +2063,166 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
2048
2063
|
mcpCredits: spend.payload,
|
|
2049
2064
|
});
|
|
2050
2065
|
});
|
|
2066
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
2067
|
+
// find_people_with_problem — the prospect search this surface did not have.
|
|
2068
|
+
//
|
|
2069
|
+
// Every other discovery tool here is built around a TOPIC or a TERM:
|
|
2070
|
+
// discover_social_posts takes a niche, search_mentions takes a brand,
|
|
2071
|
+
// search_creators takes a keyword. None is built around a PROBLEM SOMEONE
|
|
2072
|
+
// DESCRIBED, which is the shape of "find people who would want this".
|
|
2073
|
+
//
|
|
2074
|
+
// A user asked exactly that — "search X and Reddit for people that might see
|
|
2075
|
+
// our product as a great use for their daily struggle" — and the model did
|
|
2076
|
+
// the only thing available: fed the pain point into discover_social_posts as
|
|
2077
|
+
// a `niche`. That argument is documented as "Niche/topic, e.g. 'fitness'",
|
|
2078
|
+
// the backend runs it as a keyword search, and a seven-word sentence matched
|
|
2079
|
+
// on its commonest words. Seven of eight results were marketing content; the
|
|
2080
|
+
// one on-target result was a competitor's ad.
|
|
2081
|
+
//
|
|
2082
|
+
// What this does differently is not smarter matching — we run the same
|
|
2083
|
+
// keyword search, because that is what the backend has. It is that the query
|
|
2084
|
+
// is EXPANDED into the shapes a complaint actually takes, the platforms
|
|
2085
|
+
// default to where people complain rather than where people market, and the
|
|
2086
|
+
// result says plainly that it is a wide net the caller still has to sort.
|
|
2087
|
+
// The judging is the host's, per this repo's whole design.
|
|
2088
|
+
server.registerTool("find_people_with_problem", {
|
|
2089
|
+
title: "Find People With A Problem",
|
|
2090
|
+
_meta: viewMeta("find_people_with_problem"),
|
|
2091
|
+
description: "Find people describing a problem in their own words — prospects, early users, anyone " +
|
|
2092
|
+
"whose complaint your product answers. Say the problem the way a person would say it " +
|
|
2093
|
+
"(\"I spend hours checking competitors by hand\") and this searches for posts shaped like " +
|
|
2094
|
+
"that complaint, not for the topic it is about. That difference is the point: searching " +
|
|
2095
|
+
"the TOPIC finds marketing about it, which is what discover_social_posts is for. " +
|
|
2096
|
+
"Widens the query into the forms a complaint takes — the plain phrasing, the " +
|
|
2097
|
+
"does-anyone-else question, the is-there-a-tool ask — and merges what each returns. " +
|
|
2098
|
+
"It is a wide cheap net and NOT a filter: the posts come back for you to judge, and " +
|
|
2099
|
+
"many will be off-target. Consumes 2 nooticr credits per platform searched. Defaults to " +
|
|
2100
|
+
"reddit, where people describe workflow pain in sentences; add twitter for volume.",
|
|
2101
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2102
|
+
outputSchema: OUTPUT_SCHEMAS.find_people_with_problem,
|
|
2103
|
+
inputSchema: z
|
|
2104
|
+
.object({
|
|
2105
|
+
problem: z
|
|
2106
|
+
.string()
|
|
2107
|
+
.describe("The problem in a person's own words, not a topic. \"I waste an hour a day " +
|
|
2108
|
+
"checking what competitors posted\" finds people; \"competitor analysis\" finds " +
|
|
2109
|
+
"marketing about competitor analysis."),
|
|
2110
|
+
platforms: z
|
|
2111
|
+
.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram", "linkedin"]))
|
|
2112
|
+
.optional()
|
|
2113
|
+
.describe("Where to look (default reddit). Each one is a separate paid search."),
|
|
2114
|
+
limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."),
|
|
2115
|
+
})
|
|
2116
|
+
.strict(),
|
|
2117
|
+
}, async (args, extra) => {
|
|
2118
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
2119
|
+
const spend = new Spend();
|
|
2120
|
+
const problem = args.problem.trim();
|
|
2121
|
+
const platforms = args.platforms?.length ? args.platforms : ["reddit"];
|
|
2122
|
+
const cap = clamp(args.limit, 6, 1, 15);
|
|
2123
|
+
// The shapes a complaint takes, rather than the topic it is about. Kept
|
|
2124
|
+
// to three: each is a paid call per platform, and past three they start
|
|
2125
|
+
// returning the same posts as each other.
|
|
2126
|
+
const shapes = [
|
|
2127
|
+
{ shape: "plain", query: problem },
|
|
2128
|
+
{ shape: "shared", query: `anyone else ${problem}` },
|
|
2129
|
+
{ shape: "asking", query: `is there a way to ${problem}` },
|
|
2130
|
+
];
|
|
2131
|
+
const seen = new Set();
|
|
2132
|
+
const posts = [];
|
|
2133
|
+
const unavailable = [];
|
|
2134
|
+
for (const platform of platforms) {
|
|
2135
|
+
for (const { shape, query } of shapes) {
|
|
2136
|
+
try {
|
|
2137
|
+
const res = await client.callTool("discover_social_posts", { niche: query, platform, limit: cap });
|
|
2138
|
+
const structured = (res.structured ?? {});
|
|
2139
|
+
spend.record("discover_social_posts", structured);
|
|
2140
|
+
for (const raw of structured.posts ?? []) {
|
|
2141
|
+
const key = String(raw.externalUrl ?? raw.url ?? JSON.stringify(raw));
|
|
2142
|
+
if (seen.has(key))
|
|
2143
|
+
continue;
|
|
2144
|
+
seen.add(key);
|
|
2145
|
+
// `foundBy` is what makes the net inspectable: a post only the
|
|
2146
|
+
// does-anyone-else phrasing found is a different kind of hit
|
|
2147
|
+
// from one the plain phrasing found, and the caller judging
|
|
2148
|
+
// these should be able to see which.
|
|
2149
|
+
posts.push({ ...raw, foundBy: shape, platform: raw.platform ?? platform });
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
catch (err) {
|
|
2153
|
+
spend.attempted("discover_social_posts");
|
|
2154
|
+
// A platform that failed is reported, not retried with different
|
|
2155
|
+
// words — the failure is the network, not the phrasing. See #49.
|
|
2156
|
+
unavailable.push({ platform, shape, reason: reason(err) });
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
if (!posts.length) {
|
|
2161
|
+
return evidence([
|
|
2162
|
+
`Nothing came back for "${problem}" on ${platforms.join(", ")}.`,
|
|
2163
|
+
"",
|
|
2164
|
+
unavailable.length
|
|
2165
|
+
? `${unavailable.length} of ${platforms.length * shapes.length} searches errored, so ` +
|
|
2166
|
+
"this may be the network rather than the problem statement — say which networks " +
|
|
2167
|
+
"could not be searched rather than reporting an empty result as a finding."
|
|
2168
|
+
: "The searches worked and found nothing shaped like this complaint. Try the " +
|
|
2169
|
+
"problem in fewer, plainer words — this is a keyword search underneath, so a " +
|
|
2170
|
+
"long sentence matches on its commonest words.",
|
|
2171
|
+
"",
|
|
2172
|
+
ownIt,
|
|
2173
|
+
].join("\n"), {
|
|
2174
|
+
mode: "evidence",
|
|
2175
|
+
tool: "find_people_with_problem",
|
|
2176
|
+
problem,
|
|
2177
|
+
platforms,
|
|
2178
|
+
posts: [],
|
|
2179
|
+
unavailable,
|
|
2180
|
+
creditsCharged: spend.credits,
|
|
2181
|
+
mcpCredits: spend.payload,
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
return evidence([
|
|
2185
|
+
`${posts.length} posts that might be someone describing "${problem}", across ` +
|
|
2186
|
+
`${platforms.join(", ")}.`,
|
|
2187
|
+
"",
|
|
2188
|
+
"This is a wide net, not a filter. It is a keyword search underneath, so a good number " +
|
|
2189
|
+
"of these will be marketing ABOUT the problem rather than someone having it — that " +
|
|
2190
|
+
"distinction is the whole job here and it is yours to make, not something the search " +
|
|
2191
|
+
"could do.",
|
|
2192
|
+
"",
|
|
2193
|
+
"For each post decide: is this person describing this problem as THEIRS, right now? A " +
|
|
2194
|
+
"complaint, a question, a workaround they are tired of, all count. An ad for a tool " +
|
|
2195
|
+
"that solves it does not, and neither does a listicle. Quote the line that made you " +
|
|
2196
|
+
"decide, and name the post you are reasoning from.",
|
|
2197
|
+
"",
|
|
2198
|
+
"Report the ones that qualify with their permalink, so they can actually be replied to, " +
|
|
2199
|
+
"and say how many you rejected and why — a shortlist with no reject count reads as if " +
|
|
2200
|
+
"the search was precise.",
|
|
2201
|
+
"",
|
|
2202
|
+
"`foundBy` says which phrasing surfaced each post: `plain` the problem itself, `shared` " +
|
|
2203
|
+
"the does-anyone-else form, `asking` the is-there-a-tool form. The last two skew " +
|
|
2204
|
+
"towards genuine complaints and the first towards content about the topic.",
|
|
2205
|
+
"",
|
|
2206
|
+
unavailable.length
|
|
2207
|
+
? `Note that ${unavailable.length} searches errored — tell the user which networks ` +
|
|
2208
|
+
"could not be searched rather than presenting this as the whole picture.\n"
|
|
2209
|
+
: "",
|
|
2210
|
+
ownIt,
|
|
2211
|
+
]
|
|
2212
|
+
.filter((l, i, all) => !(l === "" && all[i - 1] === ""))
|
|
2213
|
+
.join("\n"), {
|
|
2214
|
+
mode: "evidence",
|
|
2215
|
+
tool: "find_people_with_problem",
|
|
2216
|
+
evidenceFrom: ["discover_social_posts"],
|
|
2217
|
+
problem,
|
|
2218
|
+
platforms,
|
|
2219
|
+
// `posts` is the key the gallery view already draws, so this needs no
|
|
2220
|
+
// view of its own.
|
|
2221
|
+
posts,
|
|
2222
|
+
searchedShapes: shapes.map((s) => s.shape),
|
|
2223
|
+
unavailable,
|
|
2224
|
+
creditsCharged: spend.credits,
|
|
2225
|
+
mcpCredits: spend.payload,
|
|
2226
|
+
});
|
|
2227
|
+
});
|
|
2051
2228
|
}
|
|
@@ -243,12 +243,21 @@ export const LOADING_PLANS = {
|
|
|
243
243
|
},
|
|
244
244
|
],
|
|
245
245
|
},
|
|
246
|
-
|
|
246
|
+
track_creator: {
|
|
247
247
|
label: "Reading what they shipped",
|
|
248
248
|
kind: "strip",
|
|
249
249
|
n: 3,
|
|
250
250
|
steps: [step("get_user_posts")],
|
|
251
251
|
},
|
|
252
|
+
find_people_with_problem: {
|
|
253
|
+
label: "Looking for people with this problem",
|
|
254
|
+
kind: "list",
|
|
255
|
+
n: 6,
|
|
256
|
+
// Three query shapes per platform, so the step repeats rather than
|
|
257
|
+
// representing one search — the wait is proportional to platforms asked
|
|
258
|
+
// for, and a single-step plan would under-report it badly.
|
|
259
|
+
steps: [step("discover_social_posts")],
|
|
260
|
+
},
|
|
252
261
|
// Both fan out one post-list fetch per creator, so the price is the size of
|
|
253
262
|
// the list and the wait is drawn per unit — same shape as catch_up_watchlist.
|
|
254
263
|
compare_creators: {
|
|
@@ -198,7 +198,19 @@ const analysis = open({
|
|
|
198
198
|
* marker, what it fetched, and where it came from. Optional throughout,
|
|
199
199
|
* because the shape of the material itself differs per tool.
|
|
200
200
|
*/
|
|
201
|
+
/**
|
|
202
|
+
* What to do with the material, carried where a host will actually deliver it.
|
|
203
|
+
*
|
|
204
|
+
* Every tool that returns guidance puts the same string in a `content` text
|
|
205
|
+
* block too. That block is the one the MCP spec points at and the one hosts
|
|
206
|
+
* drop: a host rendering `structuredContent` replaces the text blocks with the
|
|
207
|
+
* serialised JSON, so the text copy reaches no model. Declared here rather than
|
|
208
|
+
* left to `passthrough()` because a host reading the schema should be able to
|
|
209
|
+
* see that the field exists.
|
|
210
|
+
*/
|
|
211
|
+
const guidance = scalar().describe("What to do with this payload, in prose. Read it: it is the instruction, not a summary.");
|
|
201
212
|
const evidence = {
|
|
213
|
+
guidance,
|
|
202
214
|
mode: scalar().describe('Always "evidence": this payload is material you have still to read.'),
|
|
203
215
|
tool: scalar(),
|
|
204
216
|
evidenceFrom: listOf(z.string()).describe("The cheap calls this was assembled from."),
|
|
@@ -311,6 +323,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
311
323
|
* one should not find the key gone.
|
|
312
324
|
*/
|
|
313
325
|
analyze_comments: open({
|
|
326
|
+
guidance,
|
|
314
327
|
summary: scalar(),
|
|
315
328
|
themes: anyList(),
|
|
316
329
|
commentsAnalyzed: scalar(),
|
|
@@ -516,7 +529,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
516
529
|
threads: anyList().describe("Shaped like search_mentions so one view renders both."),
|
|
517
530
|
mcpCredits,
|
|
518
531
|
}),
|
|
519
|
-
|
|
532
|
+
track_creator: open({
|
|
520
533
|
...evidence,
|
|
521
534
|
username: scalar(),
|
|
522
535
|
platform: scalar(),
|
|
@@ -525,7 +538,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
525
538
|
since: scalar(),
|
|
526
539
|
sinceApplied: scalar(),
|
|
527
540
|
tracked: scalar().describe("True when this creator is on the watchlist, which is what makes a diff possible."),
|
|
528
|
-
lastCheckedAt: scalar().describe("When
|
|
541
|
+
lastCheckedAt: scalar().describe("When track_creator last looked, or null on a first look."),
|
|
529
542
|
newSincePreviousCheck: scalar().describe("Posts not seen at the last check; null when there was none."),
|
|
530
543
|
baseline: open({
|
|
531
544
|
count: scalar(),
|
|
@@ -549,6 +562,21 @@ export const OUTPUT_SCHEMAS = {
|
|
|
549
562
|
creditsCharged: scalar(),
|
|
550
563
|
mcpCredits,
|
|
551
564
|
}),
|
|
565
|
+
/**
|
|
566
|
+
* Prospect discovery. The unit is a POST that might be someone describing a
|
|
567
|
+
* problem — `posts` rather than a bespoke key, because the gallery view
|
|
568
|
+
* already draws that shape and a wide net does not need a view of its own.
|
|
569
|
+
*/
|
|
570
|
+
find_people_with_problem: open({
|
|
571
|
+
...evidence,
|
|
572
|
+
problem: scalar(),
|
|
573
|
+
platforms: listOf(z.string()),
|
|
574
|
+
posts: listOf(post),
|
|
575
|
+
searchedShapes: listOf(z.string()).describe("Which complaint phrasings were searched: plain, shared, asking."),
|
|
576
|
+
unavailable: anyList().describe("Searches that errored, by platform and shape."),
|
|
577
|
+
creditsCharged: scalar(),
|
|
578
|
+
mcpCredits,
|
|
579
|
+
}),
|
|
552
580
|
who_should_i_work_with: open({
|
|
553
581
|
...evidence,
|
|
554
582
|
niche: scalar(),
|
|
@@ -844,6 +872,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
844
872
|
* documenting a field that never arrives is worse than omitting it.
|
|
845
873
|
*/
|
|
846
874
|
score_draft: open({
|
|
875
|
+
guidance,
|
|
847
876
|
draft: scalar(),
|
|
848
877
|
platform: scalar(),
|
|
849
878
|
mcpCredits,
|
|
@@ -936,6 +965,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
936
965
|
trend: scalar().describe("The discriminator the view keys on."),
|
|
937
966
|
}),
|
|
938
967
|
mention_trend: open({
|
|
968
|
+
guidance,
|
|
939
969
|
runs: listOf(open({
|
|
940
970
|
ranAt: scalar(),
|
|
941
971
|
found: scalar().describe("Everything that sweep saw."),
|
|
@@ -51,13 +51,23 @@
|
|
|
51
51
|
* `get_content_plan` above); this file is what makes them reachable here.
|
|
52
52
|
*/
|
|
53
53
|
import { z } from "zod";
|
|
54
|
+
import { withEvidence } from "./evidence-digest.js";
|
|
54
55
|
import { OUTPUT_SCHEMAS } from "./output-schemas.js";
|
|
55
56
|
import { viewMeta } from "./view-meta.js";
|
|
56
57
|
function toResult(proxy) {
|
|
57
58
|
const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
|
|
59
|
+
const structured = proxy.structured;
|
|
60
|
+
// The backend's summary line, plus the rows it summarises. `list_own_apps`
|
|
61
|
+
// said "You have 2 app(s) in this workspace" and kept the appIds in
|
|
62
|
+
// `structuredContent` — which a host rendering the view gives to the widget,
|
|
63
|
+
// leaving a model that needs an appId for draft_post with the count alone
|
|
64
|
+
// (#59).
|
|
65
|
+
const text = String(textBlock?.text ?? "");
|
|
58
66
|
return {
|
|
59
|
-
content:
|
|
60
|
-
|
|
67
|
+
content: [
|
|
68
|
+
{ type: "text", text: structured ? withEvidence(text, structured) : text },
|
|
69
|
+
],
|
|
70
|
+
structuredContent: structured,
|
|
61
71
|
};
|
|
62
72
|
}
|
|
63
73
|
function failed(prefix, err) {
|
package/dist/shared/prompts.js
CHANGED
|
@@ -298,7 +298,7 @@ export function registerPrompts(server) {
|
|
|
298
298
|
`over- and under-performs, who the audience is. 2 credits.\n` +
|
|
299
299
|
`4. watch_creator to add them to your watchlist — free, stores the handle only, fetches ` +
|
|
300
300
|
`nothing.\n` +
|
|
301
|
-
`5.
|
|
301
|
+
`5. track_creator on the same handle — what they have shipped recently and which of it ` +
|
|
302
302
|
`beat THEIR OWN median, not a raw view count that mostly measures follower count. 2 credits, ` +
|
|
303
303
|
`whatever the window. Because they are now on your watchlist, this also sets its own "since I ` +
|
|
304
304
|
`last checked" marker — a different one from catch_up_watchlist's.\n` +
|
package/dist/shared/standings.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Putting two creators side by side (issue #30).
|
|
3
3
|
*
|
|
4
4
|
* The surface had a strong and correct stance on baselines, applied in one
|
|
5
|
-
* direction only. `
|
|
5
|
+
* direction only. `track_creator` scores each post against the median of
|
|
6
6
|
* that creator's own window, "because a raw view count mostly measures
|
|
7
7
|
* follower count — outperformance against themselves is the signal".
|
|
8
8
|
* `why_did_this_underperform` does the same for a single post.
|
|
@@ -136,7 +136,10 @@ export function standingsGuidance(a) {
|
|
|
136
136
|
lines.push("", `Every ratio here is against a window measured now. It cannot tell you whether anyone's ` +
|
|
137
137
|
"baseline is itself moving — that needs a second point in time, which nothing stores yet " +
|
|
138
138
|
"(issue #28). So do not read a high hit rate as 'improving'; it is 'consistent within this " +
|
|
139
|
-
"window' and nothing more."
|
|
139
|
+
"window' and nothing more.", "", "When you have decided what the numbers say, call show_standings with the rows and the axis " +
|
|
140
|
+
"you ranked on. Free, and it is the one place the ranking is attributed to you rather than " +
|
|
141
|
+
"presented as a nooticr score — it also draws a creator with too thin a window as unranked " +
|
|
142
|
+
"rather than last, which is the mistake a plain table makes on your behalf.");
|
|
140
143
|
return lines.join("\n");
|
|
141
144
|
}
|
|
142
145
|
/** Threshold re-exported so the tools and the view agree on the badge. */
|
package/dist/shared/tools-def.js
CHANGED
|
@@ -349,15 +349,15 @@ export const TOOL_DEFINITIONS = [
|
|
|
349
349
|
inputSchema: z.object({ username: z.string().describe("Whose audience this is, with or without @."), summary: z.string().optional(), replies: z.array(z.object({ id: z.string(), comment: z.string(), author: z.string().optional(), likes: z.number().optional(), postUrl: z.string().optional(), postTitle: z.string().optional(), postedAt: z.string().optional(), draft: z.string().optional(), kind: z.string().optional(), why: z.string().optional() })), themes: z.array(z.string()).optional(), nextSteps: z.array(z.string()).optional() }).strict(),
|
|
350
350
|
},
|
|
351
351
|
{
|
|
352
|
-
name: "
|
|
352
|
+
name: "track_creator",
|
|
353
353
|
title: "Track Competitor",
|
|
354
|
-
description: "What a creator shipped recently, and which of it beat their own baseline. Scores every post in the window against the median of that same window, because a raw view count mostly measures follower count \u2014 outperformance against themselves is the signal worth reporting. If they are on your watchlist it also marks which posts are new since your last
|
|
354
|
+
description: "What a creator shipped recently, and which of it beat their own baseline. Scores every post in the window against the median of that same window, because a raw view count mostly measures follower count \u2014 outperformance against themselves is the signal worth reporting. If they are on your watchlist it also marks which posts are new since your last track_creator call and moves that marker forward. Use for a rival you follow over time. Consumes 2 nooticr credits, whatever the window size.",
|
|
355
355
|
inputSchema: z.object({ username: z.string().describe("Creator handle, with or without @."), platform: z.string().optional().describe("Platform (default tiktok)."), limit: z.number().int().optional().describe("Posts in the window (default 12, max 30)."), metric: z.enum(["views", "likes", "comments", "shares", "engagementRate"]).optional().describe("Which stat to rank on (default views)."), since: z.string().optional().describe("Only posts published on or after this date, as YYYY-MM-DD.") }).strict(),
|
|
356
356
|
},
|
|
357
357
|
{
|
|
358
358
|
name: "compare_creators",
|
|
359
359
|
title: "Compare Creators",
|
|
360
|
-
description: "Two to five creators side by side, each scored against THEIR OWN recent median rather than against each other's raw numbers — which is the only way the comparison means anything, because a raw view count mostly measures follower count. Returns per creator: how many posts were scored, their own median, the share of the window that beat it, how hard they beat it when they did, every post's ratio, and their best and worst post. The ranking is yours to make: 'how often' and 'how big' usually disagree, and a hit rate over a short window is one post either way, so the window size travels with every number. Fetches one post list per creator: 2 nooticr credits each, 4-10 in total. Use for 'is their hit rate better than mine';
|
|
360
|
+
description: "Two to five creators side by side, each scored against THEIR OWN recent median rather than against each other's raw numbers — which is the only way the comparison means anything, because a raw view count mostly measures follower count. Returns per creator: how many posts were scored, their own median, the share of the window that beat it, how hard they beat it when they did, every post's ratio, and their best and worst post. The ranking is yours to make: 'how often' and 'how big' usually disagree, and a hit rate over a short window is one post either way, so the window size travels with every number. Fetches one post list per creator: 2 nooticr credits each, 4-10 in total. Use for 'is their hit rate better than mine'; track_creator is one creator alone.",
|
|
361
361
|
inputSchema: z.object({ usernames: z.array(z.string()).min(2).max(5), platform: z.string().optional(), limit: z.number().int().optional(), metric: z.string().optional() }).strict(),
|
|
362
362
|
},
|
|
363
363
|
{
|
|
@@ -390,6 +390,12 @@ export const TOOL_DEFINITIONS = [
|
|
|
390
390
|
description: "Demand and supply in one result. Reads the comments on a creator's own recent posts for what their audience explicitly asks for, sweeps the niche for what is already being made, and returns both with ids so an idea can be traced back to the comment that asked for it \u2014 a gap nobody asked for is noise, a request nobody serves is the opportunity. Names the niche from the creator's most-used hashtag when you do not give one. Use to decide what to film; niche_report covers the supply half alone. Consumes 2 nooticr credits for the post list, 2 per post read, and 2 for the sweep \u2014 12 at the default of 4 posts.",
|
|
391
391
|
inputSchema: z.object({ username: z.string().describe("Your handle, with or without @."), platform: z.string().optional().describe("Platform (default tiktok)."), niche: z.string().optional().describe("Niche for the supply sweep. Defaults to your most frequent hashtag."), limit: z.number().int().optional().describe("Your posts to read comments on (default 4, max 8). This is the price."), commentsPerPost: z.number().int().optional().describe("Comments per post (default 25, max 50)."), supplyLimit: z.number().int().optional().describe("Posts in the niche sweep (default 12, max 30).") }).strict(),
|
|
392
392
|
},
|
|
393
|
+
{
|
|
394
|
+
name: "find_people_with_problem",
|
|
395
|
+
title: "Find People With A Problem",
|
|
396
|
+
description: "Find people describing a problem in their own words — prospects, early users, anyone whose complaint your product answers. Say the problem the way a person would say it (\"I spend hours checking competitors by hand\") and this searches for posts shaped like that complaint, not for the topic it is about. That difference is the point: searching the TOPIC finds marketing about it, which is what discover_social_posts is for. Widens the query into the forms a complaint takes — the plain phrasing, the does-anyone-else question, the is-there-a-tool ask — and merges what each returns. It is a wide cheap net and NOT a filter: the posts come back for you to judge, and many will be off-target. Consumes 2 nooticr credits per platform searched. Defaults to reddit, where people describe workflow pain in sentences; add twitter for volume.",
|
|
397
|
+
inputSchema: z.object({ problem: z.string().describe("The problem in a person's own words, not a topic. \"I waste an hour a day checking what competitors posted\" finds people; \"competitor analysis\" finds marketing about competitor analysis."), platforms: z.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram", "linkedin"])).optional().describe("Where to look (default reddit). Each one is a separate paid search."), limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15).") }).strict(),
|
|
398
|
+
},
|
|
393
399
|
{
|
|
394
400
|
name: "search_spoken_mentions",
|
|
395
401
|
title: "Search Spoken Mentions",
|