@nooticr/mcp 1.26.20 → 1.26.22

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 CHANGED
@@ -90,7 +90,7 @@ them caps that fan-out with an argument.
90
90
  |------|---------|----------------|
91
91
  | `discover_social_posts` | 2 | Recent posts for a niche across nine networks, with inline thumbnails and `limit`/`offset` pagination. Use to find posts to look at. |
92
92
  | `get_user_posts` | 2 | One creator's recent posts with stats. Use to scan an account. |
93
- | `search_creators` | 2 | Creators by niche or keyword. Use when you know the niche but not the names. |
93
+ | `search_creators` | 2 | People by what they make — designers, developers, photographers, anyone with an audience — found by craft, niche or keyword. Use when you know the kind of person but not their names. Searches TikTok, Instagram and Xiaohongshu; **not** YouTube, Douyin, X, Reddit or LinkedIn, which cannot be creator-searched upstream. |
94
94
  | `get_similar_creators` | 2 | Lookalikes for a creator that already works. |
95
95
  | `discover_sounds` | 2 | Trending audio with playable previews. Sound is a major ranking signal on TikTok. |
96
96
  | `discover_hashtags` | 2 | Trending hashtags with volumes and whether each is rising, cooling or steady. |
@@ -138,6 +138,10 @@ them caps that fan-out with an argument.
138
138
  | `check_nooticr_credits` | free | Balance and billing URL. |
139
139
  | `buy_nooticr_credits` | free | A Stripe Checkout URL for a credit pack. Credits land automatically after payment. |
140
140
  | `nooticr_login` | free | Re-link the account when a call fails with an authentication error. |
141
+ | `list_social_connections` | free | What you have connected and what each connection is actually allowed to do — read the account, publish a post, manage comments — as yes, no or **unknown**. Unknown means the grant predates scope recording: treat it as "try it", not as a refusal. Also says which platforms can be linked at all, which is a smaller set than the networks nooticr reads. |
142
+ | `connect_social_account` | free | A link the user opens to connect one account. They approve at the provider in their own browser; nothing is connected until they do, and no credential passes through the tool or the model's context. Each call mints a fresh link — an old one does not work twice. |
143
+ | `create_product` | free | Create a product in your own workspace — the row every other own-account tool needs before it has anything to work with. Always creates in the calling session's workspace, never one you could name. `name` and `slug` are required; every other field is snake_case and read by exact key, so a camelCase `websiteUrl` lands nowhere. Writes no brand playbook. |
144
+ | `update_product` | free | Patch a product's fields. An omitted argument leaves its column unchanged, so this cannot blank a field by not mentioning it, and the result lists what was actually written — a misspelled field shows up as one that did not change rather than as a silent no-op. |
141
145
 
142
146
  ### Your own product
143
147
 
@@ -193,7 +193,10 @@ export function registerBrandWatch(server, makeClient) {
193
193
  platforms: z
194
194
  .array(z.string())
195
195
  .optional()
196
- .describe("kind: \"mentions\" only — networks to sweep. Omit for every searchable network."),
196
+ .describe("kind: \"mentions\" only — networks to sweep. Omit for every searchable network. "
197
+ + "Xiaohongshu is swept on post text only (its comments cannot be fetched upstream) and "
198
+ + "costs 5 a run rather than 2, so name the networks rather than omitting this if that "
199
+ + "trade is not worth it."),
197
200
  handle: z
198
201
  .string()
199
202
  .optional()
@@ -16,6 +16,12 @@
16
16
  */
17
17
  import { z } from "zod";
18
18
  import { OUTPUT_SCHEMAS } from "./output-schemas.js";
19
+ // Both of these shipped view-less. The grant a connection actually carries —
20
+ // read, publish, manage comments, each yes/no/unknown — is a table, and a
21
+ // table read out as prose is the thing a view is for; the third value
22
+ // especially, since "unknown" means nobody recorded the scope and must never
23
+ // be drawn as the refusal it is not.
24
+ import { viewMeta } from "./view-meta.js";
19
25
  function toResult(proxy) {
20
26
  const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
21
27
  return {
@@ -32,6 +38,7 @@ function failed(prefix, err) {
32
38
  }
33
39
  export function registerConnectionTools(server, makeClient) {
34
40
  server.registerTool("list_social_connections", {
41
+ _meta: viewMeta("list_social_connections"),
35
42
  title: "List Social Connections",
36
43
  description: "List the social accounts your workspace has connected and what each connection is " +
37
44
  "allowed to do — read the account, publish a post, manage comments. Each answer is yes, " +
@@ -52,6 +59,7 @@ export function registerConnectionTools(server, makeClient) {
52
59
  }
53
60
  });
54
61
  server.registerTool("connect_social_account", {
62
+ _meta: viewMeta("connect_social_account"),
55
63
  title: "Connect Social Account",
56
64
  description: "Get a link to open so you can connect one social account. Takes the platform, and " +
57
65
  "optionally influencerId or appId when your workspace has more than one creator profile " +
@@ -57,7 +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 { handleMissGuidance, ownIt, PLATFORM_ARG } from "./evidence.js";
60
- import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, } from "./spend.js";
60
+ import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, SPOKEN_PLATFORMS, } from "./spend.js";
61
61
  import { distributionOf, excluding, numberOf, standing, } from "./performance.js";
62
62
  import { normaliseHandle, watchEntryId, watchlistOwner } from "./watchlist.js";
63
63
  import { viewMeta } from "./view-meta.js";
@@ -482,6 +482,48 @@ function nextGuidance(a) {
482
482
  function escapeRegExp(s) {
483
483
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
484
484
  }
485
+ /** "4:12", or "1:04:12" once there is an hour to show. */
486
+ export function formatTimecode(ms) {
487
+ const total = Math.max(0, Math.floor(ms / 1000));
488
+ const s = total % 60;
489
+ const m = Math.floor(total / 60) % 60;
490
+ const h = Math.floor(total / 3600);
491
+ const pad = (n) => String(n).padStart(2, "0");
492
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
493
+ }
494
+ /**
495
+ * The cue a character offset falls in — the line the term was said in.
496
+ *
497
+ * The cues are sorted by offset, so this keeps the last one that starts at or
498
+ * before the match and stops at the first that starts after it. A match
499
+ * landing in the gap before the first cue has no answer, which is the honest
500
+ * one.
501
+ */
502
+ function cueAt(cues, position) {
503
+ let found = null;
504
+ for (const cue of cues) {
505
+ if (cue.offset > position)
506
+ break;
507
+ found = cue;
508
+ }
509
+ return found;
510
+ }
511
+ /** Attach the moment each excerpt was said, where the track carried one. */
512
+ export function timeExcerpts(excerpts, rawCues) {
513
+ const cues = (Array.isArray(rawCues) ? rawCues : [])
514
+ .filter((c) => !!c && typeof c === "object"
515
+ && typeof c.offset === "number"
516
+ && typeof c.startMs === "number")
517
+ .sort((a, b) => a.offset - b.offset);
518
+ if (!cues.length)
519
+ return excerpts;
520
+ return excerpts.map((ex) => {
521
+ const cue = cueAt(cues, ex.position);
522
+ if (!cue)
523
+ return ex;
524
+ return { ...ex, startMs: cue.startMs, timecode: formatTimecode(cue.startMs) };
525
+ });
526
+ }
485
527
  /**
486
528
  * Where a term is actually said, case-insensitively and on whole words —
487
529
  * "nike" must not fire inside "nikeisha". `\b` only means anything next to a
@@ -934,9 +976,17 @@ export function registerJobTools(server, makeClient, store) {
934
976
  server.registerTool("who_should_i_work_with", {
935
977
  title: "Who Should I Work With",
936
978
  _meta: viewMeta("who_should_i_work_with"),
937
- description: "A collaboration shortlist for a niche. Searches creators by keyword and, when you name a " +
938
- "creator who already fits, adds their lookalikes — then merges the two, marks which " +
939
- "search found each one, and gives every candidate an id. It does NOT measure audience " +
979
+ description: "A shortlist of people to work with — collaborators, or anyone you are looking to hire or " +
980
+ "commission: designers, developers, photographers, editors. Searches creators by craft or " +
981
+ "keyword and, when you name someone who already fits, adds their lookalikes — then merges the " +
982
+ "two, marks which search found each one, and gives every candidate an id. Every candidate also " +
983
+ "carries the links pulled out of their bio, typed and sorted, so vetting is reading the work " +
984
+ "rather than re-reading a follower count; those links are never fetched here, because they come " +
985
+ "from a field the person being evaluated controls. " +
986
+ "Searches tiktok, instagram, xiaohongshu. " +
987
+ "Not searchable here: youtube, douyin, twitter, reddit, linkedin — if the ask names one of those, " +
988
+ "say it cannot be searched rather than quietly substituting a network that can. " +
989
+ "It does NOT measure audience " +
940
990
  "overlap: proving the same people comment under two accounts costs roughly nine credits " +
941
991
  "per candidate, so the result says so and tells you how to check a finalist yourself. " +
942
992
  "Consumes 2 nooticr credits, or 4 with a seed creator. Use to build a list to vet; " +
@@ -947,7 +997,11 @@ export function registerJobTools(server, makeClient, store) {
947
997
  .object({
948
998
  niche: z.string().describe("Niche or keyword, e.g. 'home fitness'."),
949
999
  platform: z
950
- .enum(["tiktok", "instagram", "xiaohongshu", "youtube", "douyin"])
1000
+ // youtube 400s upstream and douyin returns user objects with every
1001
+ // field null (nooticr-server's CREATOR_SEARCH_PLATFORMS says so next
1002
+ // to the implementation). This tool runs the same keyword search, so
1003
+ // advertising them here only spends a paid call to fail.
1004
+ .enum(["tiktok", "instagram", "xiaohongshu"])
951
1005
  .optional()
952
1006
  .describe("Which platform (default tiktok). Lookalikes exist on tiktok and instagram only."),
953
1007
  seed: z
@@ -1537,7 +1591,7 @@ export function registerJobTools(server, makeClient, store) {
1537
1591
  .describe("Brand, product or person to listen for, e.g. 'nooticr'. Matched case-insensitively " +
1538
1592
  "on whole words, so 'nike' will not match inside 'nikeisha'."),
1539
1593
  platforms: z
1540
- .array(z.enum(["tiktok", "youtube"]))
1594
+ .array(z.enum(SPOKEN_PLATFORMS))
1541
1595
  .optional()
1542
1596
  .describe("Which networks to check (default: both). These are the only two whose posts carry " +
1543
1597
  "a caption track this tool can read."),
@@ -1582,8 +1636,8 @@ export function registerJobTools(server, makeClient, store) {
1582
1636
  };
1583
1637
  }
1584
1638
  const platforms = [
1585
- ...new Set((args.platforms && args.platforms.length ? args.platforms : ["tiktok", "youtube"]).map((p) => String(p).toLowerCase())),
1586
- ].filter((p) => p === "tiktok" || p === "youtube");
1639
+ ...new Set((args.platforms && args.platforms.length ? args.platforms : SPOKEN_PLATFORMS).map((p) => String(p).toLowerCase())),
1640
+ ].filter((p) => SPOKEN_PLATFORMS.includes(p));
1587
1641
  const explicitHandles = [...new Set((args.usernames ?? []).map(normaliseHandle).filter(Boolean))];
1588
1642
  // A watchlist handle already knows its own platform, so it is checked
1589
1643
  // once there rather than once per requested platform — the same
@@ -1768,7 +1822,12 @@ export function registerJobTools(server, makeClient, store) {
1768
1822
  post: { ...post, postId: id, postedAt: postDate(post) },
1769
1823
  postId: id,
1770
1824
  matchCount,
1771
- excerpts,
1825
+ // TikTok and YouTube both publish a timed caption track, and
1826
+ // those are the only two platforms this tool narrows to — so a
1827
+ // hit here almost always knows the moment it was said. The
1828
+ // speech-to-text fallback carries no clock, and an excerpt from
1829
+ // one simply arrives without a timecode.
1830
+ excerpts: timeExcerpts(excerpts, structured.cues),
1772
1831
  wordCount: numberOf(structured.wordCount),
1773
1832
  language: structured.language ?? null,
1774
1833
  autoGenerated: !!structured.autoGenerated,
@@ -0,0 +1,365 @@
1
+ /**
2
+ * What each tool is about to do, and what it will cost — for the wait.
3
+ *
4
+ * ## Why this exists rather than a table in the view
5
+ *
6
+ * The view is a static HTML string with no imports, so its first version of
7
+ * this was a hand-copied price list. It was wrong within the hour: it took its
8
+ * numbers from `mcp_tool_cost` in nooticr-server, which prices a *direct*
9
+ * `/mcp` call, while every evidence tool in this server fans out to cheaper
10
+ * calls instead. `analyze_post` is billed 6 there and 3 here (frames 2 plus
11
+ * transcript 1); `analyze_creator_profile` 15 there and 2 here. A price on the
12
+ * face of a loading screen is a promise, and one copied from the wrong table
13
+ * is worse than none at all.
14
+ *
15
+ * So the plans are built here, from the same constants the tools bill against
16
+ * — `EVIDENCE_PLANS` and `BACKEND_CALL_CREDITS` — and substituted into the
17
+ * template as JSON. A tool that changes what it fetches changes what the wait
18
+ * says, with no second place to remember.
19
+ *
20
+ * ## What the view may and may not claim
21
+ *
22
+ * MCP Apps gives a view four notifications: tool-input, tool-input-partial,
23
+ * tool-result and tool-cancelled (`@modelcontextprotocol/ext-apps`). There is
24
+ * no progress notification, so nothing here can honestly say a step has
25
+ * *finished* — only what the call is going to do and what each line costs.
26
+ * The design board's ledger ticks green checks through its rows; that is a
27
+ * canvas loop, and reproducing it would assert completions this server has no
28
+ * way to know. The rows are drawn, priced, and left un-ticked.
29
+ */
30
+ import { planCalls } from "./evidence.js";
31
+ import { BACKEND_CALL_CREDITS, CREDITS_PER_CREATOR, CREDITS_PER_NETWORK, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, SEARCH_PLATFORMS, SPOKEN_PLATFORMS, XIAOHONGSHU_CREDITS, } from "./spend.js";
32
+ /** What each backend call is doing, in words a person reads rather than a tool name. */
33
+ const CALL_LABELS = {
34
+ get_post_frames: { label: "Sampling frames", detail: "one per shot, by scene change" },
35
+ get_post_transcript: { label: "Reading the caption track", detail: "exact wording, not inferred" },
36
+ get_social_media: { label: "Fetching the post", detail: "stats, caption and media" },
37
+ get_user_posts: { label: "Loading their posts", detail: "recent window, with stats" },
38
+ get_post_comments: { label: "Reading the comments", detail: "top replies and their themes" },
39
+ discover_social_posts: { label: "Searching posts", detail: "recent, for the niche" },
40
+ search_creators: { label: "Finding creators", detail: "by keyword" },
41
+ get_similar_creators: { label: "Finding lookalikes", detail: "from the seed creator" },
42
+ discover_sounds: { label: "Finding sounds", detail: "trending audio" },
43
+ discover_hashtags: { label: "Reading the trend board", detail: "with volumes and direction" },
44
+ };
45
+ function step(via) {
46
+ const said = CALL_LABELS[via] ?? { label: via };
47
+ return { via, label: said.label, detail: said.detail, credits: BACKEND_CALL_CREDITS[via] ?? 0 };
48
+ }
49
+ /** The fan-out an evidence tool declares, as priced lines. */
50
+ function evidenceSteps(tool) {
51
+ return planCalls(tool).map(step);
52
+ }
53
+ const CREATOR_UNIT = {
54
+ via: "get_user_posts",
55
+ label: "Checking a creator",
56
+ detail: "since your last catch-up",
57
+ credits: CREDITS_PER_CREATOR,
58
+ defaultCount: 1,
59
+ };
60
+ /**
61
+ * One plan per registered tool.
62
+ *
63
+ * Pinned by tests/loading-plans.test.ts against the server's own tool list, so
64
+ * a tool cannot ship without a wait that says what it is doing — which is how
65
+ * search_mentions, the slowest and dearest call here, ended up drawing a
66
+ * generic grey box that said "Working".
67
+ */
68
+ export const LOADING_PLANS = {
69
+ // ─── Read a post ───
70
+ get_social_media: { label: "Fetching the post", kind: "post", n: 1, steps: [step("get_social_media")] },
71
+ get_post_transcript: {
72
+ label: "Reading the caption track",
73
+ kind: "text",
74
+ n: 1,
75
+ steps: [step("get_post_transcript")],
76
+ },
77
+ get_post_frames: { label: "Sampling frames", kind: "post", n: 1, steps: [step("get_post_frames")] },
78
+ get_post_comments: { label: "Loading comments", kind: "list", n: 6, steps: [step("get_post_comments")] },
79
+ // ─── Understand a post ───
80
+ analyze_post: { label: "Analysing the post", kind: "post", n: 1, steps: evidenceSteps("analyze_post") },
81
+ understand_social_post: {
82
+ label: "Watching the video",
83
+ kind: "post",
84
+ n: 1,
85
+ steps: evidenceSteps("understand_social_post"),
86
+ },
87
+ analyze_post_fast: {
88
+ label: "Reading the post",
89
+ kind: "text",
90
+ n: 1,
91
+ steps: evidenceSteps("analyze_post_fast"),
92
+ },
93
+ analyze_comments: {
94
+ label: "Reading the comment section",
95
+ kind: "text",
96
+ n: 1,
97
+ // Not an evidence plan: it proxies one get_post_comments and synthesises.
98
+ steps: [step("get_post_comments")],
99
+ },
100
+ compare_posts: {
101
+ label: "Fetching the first post",
102
+ kind: "strip",
103
+ n: 2,
104
+ steps: evidenceSteps("compare_posts"),
105
+ note: "1 credit more for every further post you fetch yourself.",
106
+ },
107
+ // ─── Research ───
108
+ discover_social_posts: {
109
+ label: "Searching posts",
110
+ kind: "strip",
111
+ n: 3,
112
+ steps: [step("discover_social_posts")],
113
+ },
114
+ get_user_posts: { label: "Loading their posts", kind: "strip", n: 3, steps: [step("get_user_posts")] },
115
+ search_creators: { label: "Finding creators", kind: "list", n: 5, steps: [step("search_creators")] },
116
+ get_similar_creators: {
117
+ label: "Finding similar creators",
118
+ kind: "list",
119
+ n: 5,
120
+ steps: [step("get_similar_creators")],
121
+ },
122
+ discover_sounds: { label: "Finding trending sounds", kind: "list", n: 4, steps: [step("discover_sounds")] },
123
+ discover_hashtags: {
124
+ label: "Reading the trend board",
125
+ kind: "list",
126
+ n: 6,
127
+ steps: [step("discover_hashtags")],
128
+ },
129
+ analyze_creator_profile: {
130
+ label: "Reading the profile",
131
+ kind: "list",
132
+ n: 4,
133
+ steps: evidenceSteps("analyze_creator_profile"),
134
+ },
135
+ find_hook_pattern: {
136
+ label: "Reading their openings",
137
+ kind: "list",
138
+ n: 5,
139
+ steps: evidenceSteps("find_hook_pattern"),
140
+ },
141
+ niche_report: { label: "Reading the niche", kind: "text", n: 1, steps: evidenceSteps("niche_report") },
142
+ // ─── Make something ───
143
+ write_hooks: { label: "Reading the source post", kind: "text", n: 1, steps: evidenceSteps("write_hooks") },
144
+ create_variants: {
145
+ label: "Reading the post that worked",
146
+ kind: "text",
147
+ n: 1,
148
+ steps: evidenceSteps("create_variants"),
149
+ },
150
+ repurpose_post: {
151
+ label: "Reading the source post",
152
+ kind: "text",
153
+ n: 1,
154
+ steps: evidenceSteps("repurpose_post"),
155
+ },
156
+ // Fetches nothing: the draft is already the caller's.
157
+ score_draft: { label: "Scoring the draft", kind: "text", n: 1, steps: [], free: true },
158
+ // ─── Brand monitoring ───
159
+ search_mentions: {
160
+ label: "Sweeping for mentions",
161
+ kind: "list",
162
+ n: 6,
163
+ steps: [],
164
+ perUnit: [
165
+ {
166
+ via: "search_mentions",
167
+ label: "Sweeping a network",
168
+ detail: "every comment that names the term",
169
+ credits: CREDITS_PER_NETWORK,
170
+ arg: "platforms",
171
+ defaultCount: SEARCH_PLATFORMS.length,
172
+ perPlatform: true,
173
+ },
174
+ ],
175
+ note: "Priced per network. Omitting platforms means all of them.",
176
+ },
177
+ // The one plan whose arithmetic is not a single argument read straight off:
178
+ // it mirrors the worst case jobs.ts computes before it asks to spend, line
179
+ // for line, because the two numbers are shown to the same person minutes
180
+ // apart and any gap between them reads as one of them lying.
181
+ search_spoken_mentions: {
182
+ label: "Listening for mentions",
183
+ kind: "list",
184
+ n: 4,
185
+ steps: [],
186
+ perUnit: [
187
+ {
188
+ via: "discover_social_posts",
189
+ label: "Sweeping for candidates",
190
+ detail: "per network, when a niche is given",
191
+ credits: BACKEND_CALL_CREDITS.discover_social_posts,
192
+ arg: "platforms",
193
+ only: SPOKEN_PLATFORMS,
194
+ defaultCount: SPOKEN_PLATFORMS.length,
195
+ onlyWith: "niche",
196
+ },
197
+ {
198
+ via: "get_user_posts",
199
+ label: "Checking a creator",
200
+ detail: "once per network asked for",
201
+ credits: BACKEND_CALL_CREDITS.get_user_posts,
202
+ arg: "usernames",
203
+ per: { arg: "platforms", only: SPOKEN_PLATFORMS, defaultCount: SPOKEN_PLATFORMS.length },
204
+ defaultCount: 0,
205
+ max: MAX_SPOKEN_HANDLE_CALLS,
206
+ ceilingWith: "useWatchlist",
207
+ },
208
+ {
209
+ via: "get_post_transcript",
210
+ label: "Transcribing a candidate",
211
+ detail: "most-viewed first, up to the ceiling",
212
+ credits: BACKEND_CALL_CREDITS.get_post_transcript,
213
+ arg: "maxTranscripts",
214
+ defaultCount: 8,
215
+ min: 1,
216
+ max: MAX_SPOKEN_TRANSCRIPTS,
217
+ },
218
+ ],
219
+ note: "The ceiling is the price. Only the survivors are transcribed.",
220
+ },
221
+ catch_up_watchlist: {
222
+ label: "Checking your watchlist",
223
+ kind: "list",
224
+ n: 5,
225
+ steps: [],
226
+ perUnit: [CREATOR_UNIT],
227
+ note: "Priced per creator on the watchlist.",
228
+ },
229
+ // ─── The goal tools (jobs.ts) ───
230
+ answer_my_audience: {
231
+ label: "Reading your own replies",
232
+ kind: "list",
233
+ n: 6,
234
+ steps: [step("get_user_posts")],
235
+ perUnit: [
236
+ {
237
+ via: "get_post_comments",
238
+ label: "Opening a post",
239
+ detail: "reading what was asked under it",
240
+ credits: BACKEND_CALL_CREDITS.get_post_comments,
241
+ arg: "limit",
242
+ defaultCount: 6,
243
+ },
244
+ ],
245
+ },
246
+ track_competitor: {
247
+ label: "Reading what they shipped",
248
+ kind: "strip",
249
+ n: 3,
250
+ steps: [step("get_user_posts")],
251
+ },
252
+ who_should_i_work_with: {
253
+ label: "Building a shortlist",
254
+ kind: "list",
255
+ n: 5,
256
+ steps: [step("search_creators")],
257
+ perUnit: [
258
+ {
259
+ via: "get_similar_creators",
260
+ label: "Adding their lookalikes",
261
+ credits: BACKEND_CALL_CREDITS.get_similar_creators,
262
+ defaultCount: 1,
263
+ onlyWith: "seed",
264
+ },
265
+ ],
266
+ },
267
+ why_did_this_underperform: {
268
+ label: "Measuring it against their own median",
269
+ kind: "text",
270
+ n: 1,
271
+ steps: [step("get_social_media"), step("get_user_posts")],
272
+ },
273
+ what_should_i_make_next: {
274
+ label: "Reading demand against supply",
275
+ kind: "list",
276
+ n: 6,
277
+ steps: [step("get_user_posts"), step("discover_social_posts")],
278
+ perUnit: [
279
+ {
280
+ via: "get_post_comments",
281
+ label: "Reading a post's comments",
282
+ detail: "what the audience asked for",
283
+ credits: BACKEND_CALL_CREDITS.get_post_comments,
284
+ arg: "limit",
285
+ defaultCount: 4,
286
+ },
287
+ ],
288
+ },
289
+ // ─── Draws what the caller already has: no fetch, no price ───
290
+ show_comment_review: { label: "Drawing the labels", kind: "list", n: 6, steps: [], free: true },
291
+ show_audience_replies: { label: "Laying out the drafts", kind: "list", n: 5, steps: [], free: true },
292
+ // ─── Draws what the caller already produced: no fetch, no price ───
293
+ // The show_* family renders the model's own output. A wait here is brief
294
+ // and free, but it still says which, because "nothing is happening" and
295
+ // "this is free and instant" look identical on a blank screen.
296
+ show_analysis: { label: "Drawing the reading", kind: "text", n: 1, steps: [], free: true },
297
+ show_comparison: { label: "Drawing the comparison", kind: "strip", n: 2, steps: [], free: true },
298
+ show_hooks: { label: "Laying out the hooks", kind: "list", n: 5, steps: [], free: true },
299
+ show_variants: { label: "Laying out the variants", kind: "list", n: 4, steps: [], free: true },
300
+ show_repurposed_post: { label: "Laying out the rewrites", kind: "text", n: 1, steps: [], free: true },
301
+ show_collab_shortlist: { label: "Drawing the shortlist", kind: "list", n: 5, steps: [], free: true },
302
+ prepare_handoff: { label: "Packaging the handoff", kind: "list", n: 4, steps: [], free: true },
303
+ // ─── Own-account reads: nooticr's own stored rows ───
304
+ get_scheduled_posts: { label: "Reading your pipeline", kind: "list", n: 5, steps: [], free: true },
305
+ get_post_performance: { label: "Reading your numbers", kind: "list", n: 5, steps: [], free: true },
306
+ get_video_stats: { label: "Reading the last sync", kind: "strip", n: 3, steps: [], free: true },
307
+ get_brand_playbook: { label: "Reading the playbook", kind: "text", n: 1, steps: [], free: true },
308
+ // ─── The product analysis: the one own-account tool that spends ───
309
+ // The only outbound fetch in this family — it reads an excerpt of the
310
+ // product's own site — and it is billed against the workspace's plan AI
311
+ // credits, not the personal MCP balance.
312
+ analyze_product: { label: "Starting the analysis", kind: "text", n: 1, steps: [], planAi: true },
313
+ // Free to poll: the spend already happened when the job was started.
314
+ analyze_product_status: {
315
+ label: "Checking the analysis",
316
+ kind: "text",
317
+ n: 1,
318
+ steps: [],
319
+ free: true,
320
+ },
321
+ // ─── Scheduled monitoring (brand-watch.ts) ───
322
+ // Managing a schedule is not using it: the sweeps a watch makes are billed
323
+ // as search_mentions when the worker runs them, later and elsewhere.
324
+ create_brand_watch: { label: "Setting up the watch", kind: "text", n: 1, steps: [], free: true },
325
+ list_brand_watches: { label: "Reading your watches", kind: "list", n: 3, steps: [], free: true },
326
+ stop_brand_watch: { label: "Stopping the watch", kind: "text", n: 1, steps: [], free: true },
327
+ // ─── Own-account reads: nooticr's own stored rows, never an upstream call ───
328
+ list_own_apps: { label: "Reading your products", kind: "list", n: 3, steps: [], free: true },
329
+ get_content_plan: { label: "Reading the saved plan", kind: "text", n: 1, steps: [], free: true },
330
+ // Calls AI, but the dashboard's own pre-publish review has never billed for
331
+ // it, so neither does this.
332
+ review_post: { label: "Scoring the post", kind: "text", n: 1, steps: [], free: true },
333
+ // ─── Own-account generation: a different balance ───
334
+ draft_post: { label: "Drafting the post", kind: "text", n: 1, steps: [], planAi: true },
335
+ growth_brief: { label: "Writing the brief", kind: "text", n: 1, steps: [], planAi: true },
336
+ generate_content_plan: {
337
+ label: "Planning the week",
338
+ kind: "list",
339
+ n: 5,
340
+ steps: [],
341
+ planAi: true,
342
+ },
343
+ generate_captions: { label: "Writing the captions", kind: "text", n: 1, steps: [], planAi: true },
344
+ // ─── Account and own-account: free, and free at a zero balance ───
345
+ check_nooticr_credits: { label: "Checking your balance", kind: "text", n: 1, steps: [], free: true },
346
+ buy_nooticr_credits: { label: "Opening checkout", kind: "text", n: 1, steps: [], free: true },
347
+ nooticr_login: { label: "Getting a sign-in link", kind: "text", n: 1, steps: [], free: true },
348
+ watch_creator: { label: "Adding to your watchlist", kind: "text", n: 1, steps: [], free: true },
349
+ unwatch_creator: { label: "Removing from your watchlist", kind: "text", n: 1, steps: [], free: true },
350
+ list_social_connections: { label: "Reading your connections", kind: "list", n: 3, steps: [], free: true },
351
+ connect_social_account: { label: "Minting a fresh link", kind: "text", n: 1, steps: [], free: true },
352
+ create_product: { label: "Writing the row", kind: "text", n: 1, steps: [], free: true },
353
+ update_product: { label: "Patching the row", kind: "text", n: 1, steps: [], free: true },
354
+ };
355
+ /** Everything the view needs, as one JSON blob substituted into the template. */
356
+ export function loadingPlansJson() {
357
+ return JSON.stringify({
358
+ plans: LOADING_PLANS,
359
+ platforms: SEARCH_PLATFORMS,
360
+ perNetwork: CREDITS_PER_NETWORK,
361
+ xiaohongshu: XIAOHONGSHU_CREDITS,
362
+ });
363
+ }
364
+ /** Tools that fan out over an argument, for the tests that pin the wait. */
365
+ export const ARGUMENT_PRICED = Object.keys(LOADING_PLANS).filter((t) => !!LOADING_PLANS[t].perUnit);
@@ -587,6 +587,9 @@ export const OUTPUT_SCHEMAS = {
587
587
  text: scalar().describe("The spoken line, with surrounding context to judge tone from."),
588
588
  position: scalar().describe("Character offset of the match inside the full transcript."),
589
589
  occurrence: scalar().describe("Which match this is, 1-based. Only the first few carry an excerpt."),
590
+ startMs: scalar().describe("When it was said, from the caption track's own cue timings. Absent, never " +
591
+ "estimated, when the platform published no timings."),
592
+ timecode: scalar().describe('The same instant as "4:12".'),
590
593
  })),
591
594
  wordCount: scalar(),
592
595
  language: scalar(),
@@ -599,6 +602,16 @@ export const OUTPUT_SCHEMAS = {
599
602
  }),
600
603
  get_post_transcript: open({
601
604
  available: scalar().describe("false when the post carries no caption track."),
605
+ // Deliberately no text: it is already in `transcript`, and a caption
606
+ // track is long enough that sending it twice would be a real cost in a
607
+ // payload that ends up in a model's context. `offset` is what makes that
608
+ // safe — it indexes into the transcript that is there.
609
+ cues: listOf(open({
610
+ startMs: scalar().describe("When this line starts, in milliseconds."),
611
+ endMs: scalar(),
612
+ offset: scalar().describe("Where this line's words begin in `transcript`, in UTF-16 code units — so a match " +
613
+ "found at a string index maps back to the moment it was said."),
614
+ })).describe("Cue timings, where the platform published a timed track. Absent for speech-to-text."),
602
615
  transcript: scalar(),
603
616
  wordCount: scalar(),
604
617
  language: scalar(),
@@ -87,6 +87,7 @@ export function registerOwnAccountTools(server, makeClient) {
87
87
  }
88
88
  });
89
89
  server.registerTool("create_product", {
90
+ _meta: viewMeta("create_product"),
90
91
  title: "Create Product",
91
92
  description: "Create a new product (\"app\") in your own workspace — the row every other own-account " +
92
93
  "tool needs before it has anything to work with; a fresh workspace has none. Takes no " +
@@ -138,6 +139,7 @@ export function registerOwnAccountTools(server, makeClient) {
138
139
  }
139
140
  });
140
141
  server.registerTool("update_product", {
142
+ _meta: viewMeta("update_product"),
141
143
  title: "Update Product",
142
144
  description: "Patch your own product's fields — omitted arguments leave their column unchanged. Takes " +
143
145
  "appId (optional when your workspace has only one product); every other field is " +
@@ -10,6 +10,18 @@ export const SEARCH_PLATFORMS = [
10
10
  "reddit",
11
11
  "weibo",
12
12
  ];
13
+ /**
14
+ * The only two networks `search_spoken_mentions` can reach.
15
+ *
16
+ * Both publish a caption track their post detail already carries; everywhere
17
+ * else the words would have to be inferred from the audio, which is a
18
+ * different tool at a different price. Named here rather than inline because
19
+ * three places have to agree on it — the tool's own enum, the filter it puts
20
+ * every requested platform through, and the wait that prices the sweep. A
21
+ * platform the filter drops but the price counts is a credit quoted for a
22
+ * call that never happens.
23
+ */
24
+ export const SPOKEN_PLATFORMS = ["tiktok", "youtube"];
13
25
  /** Two per network, except the one that costs an order of magnitude more upstream. */
14
26
  export const CREDITS_PER_NETWORK = 2;
15
27
  export const XIAOHONGSHU_CREDITS = 5;