@nooticr/mcp 1.26.21 → 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 +4 -0
- package/dist/shared/connections.js +8 -0
- package/dist/shared/jobs.js +52 -5
- package/dist/shared/loading-plans.js +365 -0
- package/dist/shared/output-schemas.js +13 -0
- package/dist/shared/own-account.js +2 -0
- package/dist/shared/spend.js +12 -0
- package/dist/shared/tools-def.js +12 -12
- package/dist/shared/tools.js +34 -4
- package/dist/shared/ui-template.js +1126 -79
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
|
|
@@ -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 " +
|
package/dist/shared/jobs.js
CHANGED
|
@@ -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
|
|
@@ -1549,7 +1591,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1549
1591
|
.describe("Brand, product or person to listen for, e.g. 'nooticr'. Matched case-insensitively " +
|
|
1550
1592
|
"on whole words, so 'nike' will not match inside 'nikeisha'."),
|
|
1551
1593
|
platforms: z
|
|
1552
|
-
.array(z.enum(
|
|
1594
|
+
.array(z.enum(SPOKEN_PLATFORMS))
|
|
1553
1595
|
.optional()
|
|
1554
1596
|
.describe("Which networks to check (default: both). These are the only two whose posts carry " +
|
|
1555
1597
|
"a caption track this tool can read."),
|
|
@@ -1594,8 +1636,8 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1594
1636
|
};
|
|
1595
1637
|
}
|
|
1596
1638
|
const platforms = [
|
|
1597
|
-
...new Set((args.platforms && args.platforms.length ? args.platforms :
|
|
1598
|
-
].filter((p) => p
|
|
1639
|
+
...new Set((args.platforms && args.platforms.length ? args.platforms : SPOKEN_PLATFORMS).map((p) => String(p).toLowerCase())),
|
|
1640
|
+
].filter((p) => SPOKEN_PLATFORMS.includes(p));
|
|
1599
1641
|
const explicitHandles = [...new Set((args.usernames ?? []).map(normaliseHandle).filter(Boolean))];
|
|
1600
1642
|
// A watchlist handle already knows its own platform, so it is checked
|
|
1601
1643
|
// once there rather than once per requested platform — the same
|
|
@@ -1780,7 +1822,12 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1780
1822
|
post: { ...post, postId: id, postedAt: postDate(post) },
|
|
1781
1823
|
postId: id,
|
|
1782
1824
|
matchCount,
|
|
1783
|
-
|
|
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),
|
|
1784
1831
|
wordCount: numberOf(structured.wordCount),
|
|
1785
1832
|
language: structured.language ?? null,
|
|
1786
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 " +
|
package/dist/shared/spend.js
CHANGED
|
@@ -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;
|
package/dist/shared/tools-def.js
CHANGED
|
@@ -246,18 +246,6 @@ export const TOOL_DEFINITIONS = [
|
|
|
246
246
|
description: "List every product (\"app\") in your own workspace — id, name, niche and product type. Call this first when your workspace has more than one product and another own-account tool asks for appId. Reads only your own workspace. No cost to call.",
|
|
247
247
|
inputSchema: z.object({}).strict(),
|
|
248
248
|
},
|
|
249
|
-
{
|
|
250
|
-
name: "create_product",
|
|
251
|
-
title: "Create Product",
|
|
252
|
-
description: "Create a new product (\"app\") in your own workspace — the row every other own-account tool needs before it has anything to work with. Takes no workspace argument: it always creates in the calling session's own workspace. Subject to your plan's product limit. Does not generate a brand playbook by itself; call analyze_product afterwards for that. name and slug are required; description, website_url, niche, product_type and the store-listing fields are optional and snake_case. Free — no AI call, just a row.",
|
|
253
|
-
inputSchema: z.object({ name: z.string().describe("Product name."), slug: z.string().describe("URL-safe slug, unique within your workspace."), description: z.string().optional(), website_url: z.string().optional().describe("The product's own site; analyze_product later fetches an excerpt of it."), niche: z.string().optional(), product_type: z.string().optional(), icon_url: z.string().optional(), primary_cta_label: z.string().optional(), primary_cta_url: z.string().optional(), external_listing_id: z.string().optional(), ios_bundle_id: z.string().optional(), android_package: z.string().optional() }).strict(),
|
|
254
|
-
},
|
|
255
|
-
{
|
|
256
|
-
name: "update_product",
|
|
257
|
-
title: "Update Product",
|
|
258
|
-
description: "Patch your own product's fields — omitted arguments leave their column unchanged. Takes appId (optional with a single-app workspace); every other field is snake_case, same as create_product. Free — no AI call, just a row.",
|
|
259
|
-
inputSchema: z.object({ appId: z.number().int().optional().describe("Your product's id. Omit only with a single-app workspace."), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), website_url: z.string().optional(), niche: z.string().optional(), product_type: z.string().optional(), icon_url: z.string().optional(), primary_cta_label: z.string().optional(), primary_cta_url: z.string().optional(), external_listing_id: z.string().optional(), ios_bundle_id: z.string().optional(), android_package: z.string().optional() }).strict(),
|
|
260
|
-
},
|
|
261
249
|
{
|
|
262
250
|
name: "get_brand_playbook",
|
|
263
251
|
title: "Get Brand Playbook",
|
|
@@ -384,4 +372,16 @@ export const TOOL_DEFINITIONS = [
|
|
|
384
372
|
description: "Brand mentions people SAY but never type. search_mentions reads captions, post bodies and comments — text — so a video that names a brand only out loud is invisible to it. This reads the words actually spoken, from the platform's own caption track, and searches them for a term. Narrows to candidate posts first (a niche sweep, named creator handles, and/or your watchlist), transcribes only the most-viewed survivors up to a hard ceiling you set, and returns the matched line with surrounding context. TikTok and YouTube only — the only two networks whose posts carry a caption track this cheaply — and even there a video with no captions is invisible to this tool; the result says how many candidates were found, transcribed and matched. Use when a term might be spoken on camera but not written anywhere. Consumes 2 nooticr credits per platform a niche is searched on, 2 per creator handle checked, and 1 per transcript actually fetched, capped by maxTranscripts.",
|
|
385
373
|
inputSchema: z.object({ term: z.string().describe("Brand, product or person to listen for, e.g. 'nooticr'."), platforms: z.array(z.enum(["tiktok", "youtube"])).optional().describe("Which networks to check (default: both)."), niche: z.string().optional().describe("Niche or keyword for a candidate sweep. Provide this, usernames, useWatchlist, or any combination."), usernames: z.array(z.string()).optional().describe("Specific creator handles to check."), useWatchlist: z.boolean().optional().describe("Also check your watchlisted creators on tiktok or youtube."), candidateLimit: z.number().int().optional().describe("Posts pulled per narrowing call (default 15, max 25)."), maxTranscripts: z.number().int().optional().describe("Hard ceiling on transcripts fetched (default 8, max 20).") }).strict(),
|
|
386
374
|
},
|
|
375
|
+
{
|
|
376
|
+
name: "create_product",
|
|
377
|
+
title: "Create Product",
|
|
378
|
+
description: "Create a product in your own workspace \u2014 the row every other own-account tool needs before it has anything to work with; a fresh workspace has none. Takes no workspace argument: it always creates in the workspace of the session calling it, never one you could name. name and slug are required; every other field is optional and all of them are snake_case, read by exact key \u2014 a camelCase websiteUrl is not an alias, it is a field that lands nowhere. Subject to your plan's product limit; the error names the limit if you hit it. Does not generate a brand playbook. Free \u2014 no AI call, just a row.",
|
|
379
|
+
inputSchema: z.object({ name: z.string().describe("Product name."), slug: z.string().describe("URL-safe slug, unique within your workspace."), description: z.string().optional(), website_url: z.string().optional().describe("The product's own site; analyze_product later fetches an excerpt of it."), niche: z.string().optional(), product_type: z.string().optional(), icon_url: z.string().optional(), primary_cta_label: z.string().optional(), primary_cta_url: z.string().optional(), external_listing_id: z.string().optional(), ios_bundle_id: z.string().optional(), android_package: z.string().optional() }).strict(),
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: "update_product",
|
|
383
|
+
title: "Update Product",
|
|
384
|
+
description: "Patch your own product's fields \u2014 omitted arguments leave their column unchanged, so this cannot blank a field by not mentioning it. Takes appId, optional when your workspace has exactly one product; every other field is snake_case, the same names create_product takes and read the same way. The result lists which fields were actually written, so a name you spelled wrong shows up as a field that did not change rather than as a silent no-op. Free \u2014 no AI call, just a row.",
|
|
385
|
+
inputSchema: z.object({ appId: z.number().int().optional().describe("Your product's id. Omit only with a single-product workspace."), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), website_url: z.string().optional(), niche: z.string().optional(), product_type: z.string().optional(), icon_url: z.string().optional(), primary_cta_label: z.string().optional(), primary_cta_url: z.string().optional(), external_listing_id: z.string().optional(), ios_bundle_id: z.string().optional(), android_package: z.string().optional() }).strict(),
|
|
386
|
+
},
|
|
387
387
|
];
|