@nooticr/mcp 1.26.41 → 1.26.42
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 +12 -6
- package/dist/shared/amazon.js +85 -14
- package/dist/shared/backend-only.js +27 -0
- package/dist/shared/connections.js +4 -2
- package/dist/shared/evidence-digest.js +83 -20
- package/dist/shared/getting-started.js +201 -0
- package/dist/shared/jobs.js +20 -10
- package/dist/shared/loading-plans.js +18 -0
- package/dist/shared/nooticr.js +29 -1
- package/dist/shared/oauth.js +3 -2
- package/dist/shared/output-schemas.js +104 -4
- package/dist/shared/prompts.js +5 -1
- package/dist/shared/spend.js +4 -0
- package/dist/shared/tools-def.js +43 -7
- package/dist/shared/tools.js +286 -8
- package/dist/shared/ui-template.js +159 -5
- package/dist/shared/watchlist.js +39 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ them caps that fan-out with an argument.
|
|
|
67
67
|
| Tool | Credits | What it is for |
|
|
68
68
|
|------|---------|----------------|
|
|
69
69
|
| `get_social_media` | 1 | The post's facts and media — contentType, title, caption, author, stats, direct media URLs, plus an inline thumbnail. Use when you want the post itself and nothing interpreted. |
|
|
70
|
-
| `get_post_transcript` | 1 | The words actually spoken. Where the platform publishes a caption track (TikTok, Douyin, YouTube) it is read as-is — exact rather than inferred, and far cheaper than watching the video; everywhere else the post's own audio is transcribed, which needs speech-to-text configured on the server. Use before any analysis when the wording matters. |
|
|
70
|
+
| `get_post_transcript` | 1 | The words actually spoken. Where the platform publishes a caption track (TikTok, Douyin, YouTube) it is read as-is — exact rather than inferred, and far cheaper than watching the video; everywhere else the post's own audio is transcribed, which needs speech-to-text configured on the server. `format: "srt"` or `"vtt"` also returns a ready caption file built from the track's own timing, at no extra cost. Use before any analysis when the wording matters. |
|
|
71
71
|
| `get_post_frames` | 2 | Frames sampled evenly across a post's video, returned as **images you can actually look at** — not a description of them. ffmpeg opens the stream directly rather than downloading it, so HLS works and an expired link is re-resolved on the spot. Verified live at 3/3 on TikTok, YouTube, Instagram, Douyin and X; Reddit works on video posts. A carousel or slideshow returns its own images unchanged. Each frame costs roughly 1,200 tokens of your context. |
|
|
72
72
|
| `get_post_comments` | 2 | Top comments plus the themes the platform clusters them into, with which ones the creator pinned or liked. Use when you want to read what people wrote. |
|
|
73
73
|
|
|
@@ -78,7 +78,7 @@ them caps that fan-out with an argument.
|
|
|
78
78
|
| `analyze_post_fast` | 2 | The post's transcript, caption and stats — everything but the pictures, which is what makes it the cheap read. Two fetches: `get_social_media` (1) and `get_post_transcript` (1). The sensible default. |
|
|
79
79
|
| `analyze_post` | 3 | Frames sampled across the video, as **images your model can actually look at**, plus the transcript. Two fetches: `get_post_frames` (2) and `get_post_transcript` (1). Use when the visuals are the point — framing, editing, on-screen text. |
|
|
80
80
|
| `understand_social_post` | 3 | The same two fetches, asked for a description of what physically happens on screen rather than why it works. Use when you need the events, not the strategy. |
|
|
81
|
-
| `analyze_comments` | 2 | The comment section, every comment with a stable id, and the taxonomy to label them with — sentiment, and whether each is praise, a complaint, a bug report, a question, a request, a comparison or spam. The same `get_post_comments` call, at the same price as reading them directly. |
|
|
81
|
+
| `analyze_comments` | 2 | The comment section, every comment with a stable id, and the taxonomy to label them with — sentiment, and whether each is praise, a complaint, a bug report, a question, a request, a comparison or spam. The same `get_post_comments` call, at the same price as reading them directly. Pass `verbatim: true` to have every item rendered whole for quoting rather than clipped to fit; same price. |
|
|
82
82
|
| `show_comment_review` | free | Draws the classifications your model produced — every comment with its sentiment and category, filterable and selectable. Makes no requests; it only renders what you pass it. |
|
|
83
83
|
| `show_post_analysis` | free | Draws the analysis your model wrote from `analyze_post`/`analyze_post_fast`/`understand_social_post` — hook strength, script structure, quotable lines, suggested hashtags and more, whichever fields you produced. Makes no requests; it only renders what you pass it. |
|
|
84
84
|
| `compare_posts` | 1 | The first of two to five posts, fetched with its stats, and the comparison left to you. Fetch the rest with `get_social_media` at 1 credit each. Use when performance differs and you need to know why. |
|
|
@@ -92,12 +92,14 @@ them caps that fan-out with an argument.
|
|
|
92
92
|
| `get_user_posts` | 2 | One creator's recent posts with stats. Use to scan an account. |
|
|
93
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
|
+
| `suggest_creator_identity` | 2 per network searched, plus 2 to find the handle (6 by default) | The same person on the other networks, from one handle you already know — with the evidence for each: a link published in both bios, one bio naming the other account, an identical handle, a matching display name. **Suggests, never merges**: nothing is stored, a human confirms, and two accounts' follower counts are never added together. TikTok, Instagram and Xiaohongshu. |
|
|
95
96
|
| `discover_sounds` | 2 | Trending audio with playable previews. Sound is a major ranking signal on TikTok. |
|
|
96
97
|
| `discover_hashtags` | 2 | What to tag with. TikTok comes from the Creative Center trend board — volumes plus rising/cooling/steady. The other eight searchable networks have no trend board upstream, so tags are counted across a live sweep of a niche you name: real and current, but a single sample, so no rising/cooling signal. `source` says which you got. LinkedIn cannot be swept. |
|
|
97
98
|
| `find_hook_pattern` | 2 | A creator's recent posts, so their opening lines can be read as a set and turned into fill-in-the-blank templates. One `get_user_posts` call. |
|
|
98
99
|
| `search_mentions` | 2 per network (5 for Xiaohongshu) | **Brand monitoring.** Every *comment* that names a term, across nine networks at once, grouped under the post it was left on. A brand is named far more often in the replies than in a caption, so the comment is the unit — not the post. Takes a `since` date to read a past window, and pages with `offset`/`pageSize` so a nine-network sweep does not arrive all at once. Does not read speech inside a video — `search_spoken_mentions` does, on TikTok, YouTube and Douyin. |
|
|
99
100
|
| `watch_creator` | free | Add a creator to your watchlist. Stores the handle only — nothing is fetched. |
|
|
100
101
|
| `unwatch_creator` | free | Drop a creator from the watchlist. |
|
|
102
|
+
| `list_watchlist` | free | Who you are watching: handle, platform, note, and when you last caught up on each. |
|
|
101
103
|
| `catch_up_watchlist` | 2 per creator | What everyone you watch has posted since your last catch-up. Compares against the snapshot taken last time and moves it forward, so it answers "what is new" rather than "what exists". |
|
|
102
104
|
| `create_brand_watch` | free to call | Schedule a recurring sweep and get emailed only what is new. `kind: "mentions"` (default) is a recurring `search_mentions` sweep for `term`/`platforms`; `kind: "competitor"` is a recurring `get_user_posts` check on one creator (`handle`/`platform`), mailing only what beats their own recent median. Two calls by design: the first returns the quote (cost per run, cadence, cost per day) and a `confirmationToken`; nothing is created or charged until you call it again with `confirm: true` and that token. Each run then bills the same as calling the matching tool yourself — 2 credits per network (5 for Xiaohongshu) for mentions, a flat 2 credits for competitor. |
|
|
103
105
|
| `list_brand_watches` | free | Every watch you have scheduled: term, networks, cadence, cost per run, credits spent so far, runs made, and when the next one is due. |
|
|
@@ -110,7 +112,7 @@ them caps that fan-out with an argument.
|
|
|
110
112
|
|
|
111
113
|
| Tool | Credits | What it is for |
|
|
112
114
|
|------|---------|----------------|
|
|
113
|
-
| `answer_my_audience` | 2 + 2 per post opened (14 by default) | **The mirror of `search_mentions`.** The questions waiting under your *own* posts: recent posts fetched, comments read on each, grouped under the post they were left on, every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. It finds and drafts — it cannot post a reply
|
|
115
|
+
| `answer_my_audience` | 2 + 2 per post opened (14 by default) | **The mirror of `search_mentions`.** The questions waiting under your *own* posts: recent posts fetched, comments read on each, grouped under the post they were left on, every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. It finds and drafts — it cannot post a reply: nooticr does not post comments on any network, even where a connection's grant allows managing them. `limit` caps how many posts are opened, which is the price. |
|
|
114
116
|
| `prepare_handoff` | free | **The hand-off to your tracker.** Turns items you classified — a bug report in a comment, a complaint said out loud in a video — into the exact `title`, `body` and `labels` to file through whichever GitHub, Jira or Linear MCP server the host also has connected. nooticr files nothing and holds no tracker credential. The body carries the quote fenced and framed as a third-party report rather than as instructions, because the issue is read next by a coding agent; contact details are redacted, `@handles` and `#numbers` are defanged so filing notifies nobody by accident, and a `searchFirst` string dedupes a second sweep against the first. Makes no requests. |
|
|
115
117
|
| `show_audience_replies` | free | Lays your drafts out for a person to work through, grouped under the post, each with what you decided to do about it. Sends nothing; fetches nothing. |
|
|
116
118
|
| `show_standings` | free | Draws the standings your model read out of `compare_creators` or `watchlist_standings` — one row per creator, with the window and the median it was scored against. `ranking` records which axis you ordered on, and `tooThin` draws a creator with too short a window as unranked rather than last: missing from a comparison is not the same as losing it. Makes no requests. |
|
|
@@ -119,10 +121,11 @@ them caps that fan-out with an argument.
|
|
|
119
121
|
| `compare_creators` | 2 per creator (4-10) | Two to five creators side by side on the one axis that compares: each post's ratio to **that creator's own** median. Per creator you get the window that was scored, their median, the share of it that beat that median, how hard they beat it when they did, every ratio, and their best and worst post. It does not rank them — "how often they land one" and "how big it is when they do" usually disagree, and a hit rate over a short window is one post either way, so the window travels with every number and the read is yours. |
|
|
120
122
|
| `watchlist_standings` | 2 per watched creator | The same, across everyone on your watchlist: "who moved" in one call instead of one per creator. Reading the watchlist is free; the fetches are not, so it confirms the total before spending. `catch_up_watchlist` answers what is **new**; this answers how it **did**. |
|
|
121
123
|
| `who_should_i_work_with` | 2, or 4 with a seed | A collaboration shortlist: a keyword search merged with the lookalikes of a creator who already fits, marked by which search found each one. Every candidate also carries the **links pulled out of their bio**, typed and sorted by how much opening one will tell you — a repository to read the code in, their own site, a link hub that holds the real links — so vetting is reading the work rather than re-reading the follower count. Those links are never fetched here: they came out of a field the person being evaluated controls, so the host opens them, and the result says so. It does **not** measure audience overlap — that costs about nine credits a candidate, so the result says so and shows how to check a finalist rather than faking the signal. |
|
|
122
|
-
| `find_people_with_problem` | 6 per platform | Find people **describing a problem in their own words** — prospects, early users, anyone whose complaint your product answers. Say the problem the way a person would say it and it searches for posts shaped like that complaint rather than for the topic it is about: searching the topic finds marketing about it, which is what `discover_social_posts` is for. Widens the query into the forms a complaint takes — the plain phrasing, the does-anyone-else question, the is-there-a-tool ask — and marks which one surfaced each post. A wide cheap net and **not** a filter: the posts come back for you to judge, and plenty will be off-target. Defaults to reddit, where people describe workflow pain in sentences. Each platform is searched once per query shape — three by default, at 2 credits a search. Not searchable here: linkedin, which has no keyword post search upstream. |
|
|
124
|
+
| `find_people_with_problem` | 6 per platform | Find people **describing a problem in their own words** — prospects, early users, anyone whose complaint your product answers. Say the problem the way a person would say it and it searches for posts shaped like that complaint rather than for the topic it is about: searching the topic finds marketing about it, which is what `discover_social_posts` is for. Widens the query into the forms a complaint takes — the plain phrasing, the does-anyone-else question, the is-there-a-tool ask — and marks which one surfaced each post. A wide cheap net and **not** a filter: the posts come back for you to judge, and plenty will be off-target. Defaults to reddit, where people describe workflow pain in sentences. Each platform is searched once per query shape — three by default, at 2 credits a search. Not searchable here: linkedin, which has no keyword post search upstream. Pass `verbatim: true` to have every item rendered whole for quoting rather than clipped to fit; same price. |
|
|
123
125
|
| `show_collab_shortlist` | free | Draws the candidates you scored, ranked, and asks the user which to approach. The scores are attributed to your model, not presented as a nooticr rating, and a candidate scored without anything having been opened is marked unverified. Makes no requests. |
|
|
124
126
|
| `why_did_this_underperform` | 3 | One post against the creator's own recent distribution, with the post taken back out of its own baseline. Returns median, quartiles, ratio and percentile, so the answer can be "this is an ordinary result, not a failure". Different question from `compare_posts`, which weighs two URLs you already picked. |
|
|
125
127
|
| `what_should_i_make_next` | 2 + 2 per post read + 2 (12 by default) | Demand against supply: what your commenters explicitly ask for, set beside what a niche sweep shows is already being made. A gap nobody asked for is noise; a request nobody serves is the opportunity. Falls back to your most-used hashtag when you name no niche. |
|
|
128
|
+
| `detect_spoken_mentions` | 2 | **Every brand named out loud in one video.** The spoken transcript and the written caption, side by side, for your model to list each brand with its verbatim sentence, the creator's framing and whether it was said, written or both. Works where the creator never enabled captions, because the audio is transcribed. 1 credit for `get_post_transcript` plus 1 for `get_social_media`; nothing when no transcript can be made. |
|
|
126
129
|
| `search_spoken_mentions` | 2 per platform narrowed by niche + 2 per creator handle + 1 per transcript, up to `maxTranscripts` | **The mirror of `search_mentions`, for what was said rather than typed.** Narrows to candidate posts (a niche sweep, named handles, and/or your watchlist), transcribes only the most-viewed survivors up to a hard ceiling, and searches the words for the term — TikTok, YouTube and Douyin, and only where the creator actually enabled captions. Reports how many candidates were found, transcribed and matched, so the spend is legible. |
|
|
127
130
|
|
|
128
131
|
### Study a market on Amazon
|
|
@@ -148,7 +151,7 @@ a `scanId` for the rest.
|
|
|
148
151
|
| `scan_marketplace_category` | 3 per product collected (30 by default) | The same category read on eleven more sites — AliExpress, Cdiscount, Flipkart, Mercado Libre, Lazada, Otto, Rakuten, Trendyol, Jumia, Vinted and Temu. Vinted is resale rather than retail, so its `rating` describes the seller and the scan says so in `ratingScope` — do not read it as a product's reviews. `marketplace` picks the site; `market` picks the storefront for the sites that have several, and the argument names the real set per site so a model cannot send Otto a Lazada region. Same rollup, same confirmation before spending. |
|
|
149
152
|
| `marketplace_scan_status` | free | Picks a running marketplace collection back up by `scanId` and the same `marketplace`. Free, for the same reason `amazon_scan_status` is. |
|
|
150
153
|
| `get_marketplace_product` | 3 | One product from any of the twelve sites, by id or URL: price, rating, the full star histogram, specs and the reviews. |
|
|
151
|
-
| `show_amazon_category_insights` | free | Draws the read **your model** wrote — purchase drivers, barriers, what each brand does well, the gaps, the positioning angles — beside the listings and their reviews, so a person can click a product and check any claim against the text it came from. The conclusions are attributed to your model, never presented as a nooticr rating of anyone's product.
|
|
154
|
+
| `show_amazon_category_insights` | free | Draws the read **your model** wrote — purchase drivers, barriers, what each brand does well, the gaps, the positioning angles — beside the listings and their reviews, so a person can click a product and check any claim against the text it came from. The conclusions are attributed to your model, never presented as a nooticr rating of anyone's product. Given the `scanId`, it re-reads the listings from the scan itself (free) instead of drawing whatever the model re-sends, and flags any ASIN the read cites that the scan does not contain; without one, the card says its listings were not checked. |
|
|
152
155
|
|
|
153
156
|
### Make something
|
|
154
157
|
|
|
@@ -166,9 +169,12 @@ a `scanId` for the rest.
|
|
|
166
169
|
|
|
167
170
|
| Tool | Credits | What it is for |
|
|
168
171
|
|------|---------|----------------|
|
|
172
|
+
| `nooticr_getting_started` | free | **Start here.** Where this account stands — balance, connected accounts, watchlist — and the next calls worth making, each with its price and an example. The server's `instructions` point every host at it. |
|
|
169
173
|
| `check_nooticr_credits` | free | Balance and billing URL. |
|
|
174
|
+
| `list_tool_runs` | free | Your tool-call history: what ran, whether it worked, and the credits each call actually took (0 for a free, refunded, cached or replayed call). `scope: "workspace"` shows every member's runs to owners and admins. |
|
|
175
|
+
| `get_tool_run` | free | One run from that history by id, with its full error. |
|
|
170
176
|
| `nooticr_login` | free | Re-link the account when a call fails with an authentication error. |
|
|
171
|
-
| `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. |
|
|
177
|
+
| `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**. "Manage comments" reports the platform's grant; no nooticr tool posts or replies to a comment. 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. |
|
|
172
178
|
| `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. |
|
|
173
179
|
| `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. |
|
|
174
180
|
| `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. |
|
package/dist/shared/amazon.js
CHANGED
|
@@ -192,7 +192,7 @@ export function categoryGuidance(opts) {
|
|
|
192
192
|
lines.push("Judge only from what the reviews say. Where the evidence is thin, say so rather than", "producing a confident line the text does not support — a category read that overstates its", "basis is worse than a short one. Prices, ratings and mention counts are in the payload;", "do not re-derive them by hand.");
|
|
193
193
|
if (insightsTool) {
|
|
194
194
|
lines.push("");
|
|
195
|
-
lines.push("To show the result in the conversation — products, their reviews, and your read side by", `side — call ${insightsTool} with what you concluded. It costs nothing and
|
|
195
|
+
lines.push("To show the result in the conversation — products, their reviews, and your read side by", `side — call ${insightsTool} with what you concluded${opts.scanId ? ` and scanId "${opts.scanId}"` : " and the scanId"}.`, "It costs nothing, and it re-reads the listings from the scan itself, so do not re-send them.");
|
|
196
196
|
}
|
|
197
197
|
return lines.join("\n");
|
|
198
198
|
}
|
|
@@ -390,14 +390,19 @@ export function registerAmazonTools(server, makeClient) {
|
|
|
390
390
|
description: "Display the category read you produced from scan_amazon_category: purchase drivers, " +
|
|
391
391
|
"barriers, what each brand does well, the gaps, and the positioning angles — drawn beside " +
|
|
392
392
|
"the listings and their reviews, so a person can click a product and check any claim " +
|
|
393
|
-
"against the text it came from.
|
|
394
|
-
"
|
|
393
|
+
"against the text it came from. Pass the scanId: the listings are then re-read from the " +
|
|
394
|
+
"scan itself (free) rather than taken from you, so do not re-send them, and any ASIN you " +
|
|
395
|
+
"cite that the scan does not contain is flagged on the card. Without a scanId, listings " +
|
|
396
|
+
"you pass are drawn marked as not checked against a scan. Free. Your read is attributed " +
|
|
397
|
+
"to you rather than presented as a nooticr rating of anyone's product. " +
|
|
395
398
|
"Call this after you have read the reviews, not instead of reading them.",
|
|
396
399
|
annotations: {
|
|
397
400
|
readOnlyHint: true,
|
|
398
401
|
destructiveHint: false,
|
|
399
402
|
idempotentHint: true,
|
|
400
|
-
// Draws what it is given
|
|
403
|
+
// Draws what it is given. Its one request re-reads a scan the caller
|
|
404
|
+
// already ran, from nooticr's own job store with no wait, so it
|
|
405
|
+
// fetches nothing new from the marketplace (#107).
|
|
401
406
|
openWorldHint: false,
|
|
402
407
|
},
|
|
403
408
|
outputSchema: OUTPUT_SCHEMAS.show_amazon_category_insights,
|
|
@@ -457,15 +462,17 @@ export function registerAmazonTools(server, makeClient) {
|
|
|
457
462
|
products: z
|
|
458
463
|
.array(anyObject())
|
|
459
464
|
.optional()
|
|
460
|
-
.describe("
|
|
461
|
-
rollup: anyObject().optional().describe("
|
|
462
|
-
scanId: z
|
|
465
|
+
.describe("Only when there is no scanId. With one, the listings are re-read from the scan and anything here is ignored."),
|
|
466
|
+
rollup: anyObject().optional().describe("Only when there is no scanId; re-read from the scan otherwise."),
|
|
467
|
+
scanId: z
|
|
468
|
+
.string()
|
|
469
|
+
.optional()
|
|
470
|
+
.describe("The scan this reads. Pass it: it is what lets the card draw the collector's own listings."),
|
|
463
471
|
})
|
|
464
472
|
.passthrough(),
|
|
465
|
-
}, async (args) => {
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
: [];
|
|
473
|
+
}, async (args, extra) => {
|
|
474
|
+
const checked = await rehydrate(args, () => makeClient({ ...extra, arguments: args }));
|
|
475
|
+
const products = checked.products;
|
|
469
476
|
const counts = {
|
|
470
477
|
drivers: Array.isArray(args.drivers) ? args.drivers.length : 0,
|
|
471
478
|
barriers: Array.isArray(args.barriers) ? args.barriers.length : 0,
|
|
@@ -475,7 +482,8 @@ export function registerAmazonTools(server, makeClient) {
|
|
|
475
482
|
const text = `Category read for ${String(args.category ?? "this category")}: ${counts.drivers} driver(s), ` +
|
|
476
483
|
`${counts.barriers} barrier(s), ${counts.gaps} gap(s), ${counts.positioning} positioning angle(s)` +
|
|
477
484
|
`${products.length ? `, drawn beside ${products.length} listing(s)` : ""}.` +
|
|
478
|
-
(args.summary ? ` ${String(args.summary)}` : "")
|
|
485
|
+
(args.summary ? ` ${String(args.summary)}` : "") +
|
|
486
|
+
` ${checked.verification.note}`;
|
|
479
487
|
return {
|
|
480
488
|
content: [{ type: "text", text }],
|
|
481
489
|
structuredContent: {
|
|
@@ -489,14 +497,77 @@ export function registerAmazonTools(server, makeClient) {
|
|
|
489
497
|
gaps: args.gaps ?? [],
|
|
490
498
|
positioning: args.positioning ?? [],
|
|
491
499
|
products,
|
|
492
|
-
rollup:
|
|
500
|
+
rollup: checked.rollup,
|
|
493
501
|
scanId: args.scanId ?? null,
|
|
494
|
-
|
|
502
|
+
verification: checked.verification,
|
|
503
|
+
// The one read is amazon_scan_status, which is free by design.
|
|
495
504
|
mcpCredits: { cost: 0 },
|
|
496
505
|
},
|
|
497
506
|
};
|
|
498
507
|
});
|
|
499
508
|
}
|
|
509
|
+
/**
|
|
510
|
+
* The listings a category read is drawn beside, from the scan rather than
|
|
511
|
+
* from the model (#107).
|
|
512
|
+
*
|
|
513
|
+
* The card exists so a person can check a claim against the text it came
|
|
514
|
+
* from, which only works if the text is the collector's. A model that dropped
|
|
515
|
+
* three listings, mis-copied a price or invented a competitor used to produce
|
|
516
|
+
* a card identical to a truthful one. With a scanId the listings are re-read
|
|
517
|
+
* from the scan, free and with no wait, and whatever the model sent is only
|
|
518
|
+
* compared against them. Without one, or when the re-read fails, what the
|
|
519
|
+
* model sent is drawn and marked unverified rather than refused: a card with a
|
|
520
|
+
* visible caveat is more use to a person than no card.
|
|
521
|
+
*/
|
|
522
|
+
export async function rehydrate(args, client) {
|
|
523
|
+
const sent = Array.isArray(args.products) ? args.products : [];
|
|
524
|
+
const sentRollup = (args.rollup ?? {});
|
|
525
|
+
const scanId = typeof args.scanId === "string" ? args.scanId.trim() : "";
|
|
526
|
+
const unverified = (note) => ({
|
|
527
|
+
products: sent,
|
|
528
|
+
rollup: sentRollup,
|
|
529
|
+
verification: { status: "unverified", note },
|
|
530
|
+
});
|
|
531
|
+
if (!scanId) {
|
|
532
|
+
return unverified(sent.length
|
|
533
|
+
? "No scanId was passed, so these listings are as re-sent and were not checked against a scan."
|
|
534
|
+
: "No scanId was passed, so there are no listings to draw beside this read.");
|
|
535
|
+
}
|
|
536
|
+
let payload;
|
|
537
|
+
try {
|
|
538
|
+
const res = await (await client()).callTool("amazon_scan_status", { scanId, waitSeconds: 0 });
|
|
539
|
+
payload = structured(res);
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
543
|
+
return unverified(`Scan ${scanId} could not be re-read (${why}), so these listings are as re-sent and were not checked.`);
|
|
544
|
+
}
|
|
545
|
+
const products = normaliseProducts(payload.products);
|
|
546
|
+
const ids = new Set(products.map((p) => String(p.asin ?? "")).filter(Boolean));
|
|
547
|
+
const idOf = (p) => String([p.asin, p.sku, p.pid, p.goods_id, p.item_id, p.product_id, p.id].find((v) => typeof v === "string" && v) ?? "");
|
|
548
|
+
const notInScan = [...new Set(sent.map(idOf).filter((id) => id && !ids.has(id)))];
|
|
549
|
+
const cited = Array.isArray(args.strengths)
|
|
550
|
+
? args.strengths.map((s) => String(s.asin ?? "")).filter(Boolean)
|
|
551
|
+
: [];
|
|
552
|
+
const unknownAsins = [...new Set(cited.filter((a) => !ids.has(a)))];
|
|
553
|
+
const scanComplete = payload.complete !== false;
|
|
554
|
+
const flags = [
|
|
555
|
+
notInScan.length ? `${notInScan.length} listing(s) you sent are not in the scan (${notInScan.join(", ")}) and are not drawn.` : "",
|
|
556
|
+
unknownAsins.length ? `ASIN(s) cited in strengths but not in the scan: ${unknownAsins.join(", ")}.` : "",
|
|
557
|
+
scanComplete ? "" : "The scan is still running, so the set drawn is what it had collected so far.",
|
|
558
|
+
].filter(Boolean);
|
|
559
|
+
return {
|
|
560
|
+
products,
|
|
561
|
+
rollup: (payload.rollup ?? {}),
|
|
562
|
+
verification: {
|
|
563
|
+
status: "verified",
|
|
564
|
+
note: [`The ${products.length} listing(s) drawn are the scan's own, re-read from ${scanId}.`, ...flags].join(" "),
|
|
565
|
+
scanComplete,
|
|
566
|
+
notInScan,
|
|
567
|
+
unknownAsins,
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
}
|
|
500
571
|
/**
|
|
501
572
|
* One scan result, shaped for both channels.
|
|
502
573
|
*
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nooticr-server tools this public surface deliberately does not register.
|
|
3
|
+
*
|
|
4
|
+
* `POST /mcp` also serves the dashboard's internal copilot, so it carries
|
|
5
|
+
* tools that have no business in a Claude or ChatGPT host. Absence alone
|
|
6
|
+
* reads the same as "nobody got round to it", so each exclusion is written
|
|
7
|
+
* down with its reason (#106). A tool leaving this list for `TOOL_NAMES` is a
|
|
8
|
+
* product decision, not a cleanup; tests/backend-only.test.ts keeps the two
|
|
9
|
+
* lists disjoint.
|
|
10
|
+
*/
|
|
11
|
+
export const BACKEND_ONLY_TOOLS = {
|
|
12
|
+
get_watchlist_baseline: "Plumbing for BackendWatchStore, which reads a creator's baseline itself; a host has nothing to do with the raw row.",
|
|
13
|
+
advance_watchlist_baseline: "Plumbing for BackendWatchStore; catch_up_watchlist advances the baseline after the user confirms, so a host never needs to.",
|
|
14
|
+
buy_nooticr_credits: "No purchase path is exposed to a model (#100). Credits are topped up on the nooticr website.",
|
|
15
|
+
overlay_bake: "Rendering and publishing stay in the dashboard (#92).",
|
|
16
|
+
bake_job_status: "Rendering and publishing stay in the dashboard (#92).",
|
|
17
|
+
compose_sequence: "Rendering and publishing stay in the dashboard (#92).",
|
|
18
|
+
spawn_variants: "Rendering and publishing stay in the dashboard (#92).",
|
|
19
|
+
enqueue_publish_job: "Rendering and publishing stay in the dashboard (#92).",
|
|
20
|
+
schedule_post: "Rendering and publishing stay in the dashboard (#92).",
|
|
21
|
+
get_batch_history: "History of the dashboard's compose batches, which only exist once rendering does; stays with #92.",
|
|
22
|
+
get_latest_analysis: "Returns the dashboard's stored AI fleet-analysis verdict; the public surface sells the evidence, not a judgement of ours.",
|
|
23
|
+
brand_watch_history: "Not a deliberate exclusion: a candidate for the public surface once it has a trend view to draw. Listed so it reads as pending, not forgotten.",
|
|
24
|
+
get_google_analytics: "Site analytics are dashboard-only until #104 decides their shape.",
|
|
25
|
+
get_posthog_analytics: "Site analytics are dashboard-only until #104 decides their shape.",
|
|
26
|
+
get_search_console_data: "Site analytics are dashboard-only until #104 decides their shape.",
|
|
27
|
+
};
|
|
@@ -44,8 +44,10 @@ export function registerConnectionTools(server, makeClient) {
|
|
|
44
44
|
"allowed to do — read the account, publish a post, manage comments. Each answer is yes, " +
|
|
45
45
|
"no, or unknown; unknown means the grant predates scope recording, so treat it as \"try " +
|
|
46
46
|
"it\", not as a refusal. Also returns which platforms can be connected at all, which is a " +
|
|
47
|
-
"smaller set than the networks nooticr can read.
|
|
48
|
-
"something
|
|
47
|
+
"smaller set than the networks nooticr can read. \"Manage comments\" reports what the " +
|
|
48
|
+
"platform granted, not something nooticr does: no nooticr tool posts, replies to or " +
|
|
49
|
+
"moderates a comment, so a yes there never means a drafted reply will be sent. Call this " +
|
|
50
|
+
"before promising that something can be posted or replied to. No cost to call.",
|
|
49
51
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
50
52
|
inputSchema: z.object({}).strict(),
|
|
51
53
|
outputSchema: OUTPUT_SCHEMAS.list_social_connections,
|
|
@@ -48,11 +48,37 @@ const ITEM_CHARS = 600;
|
|
|
48
48
|
const DIGEST_CHARS = 6000;
|
|
49
49
|
/** How many items of one kind are worth rendering before the count matters more than the detail. */
|
|
50
50
|
const MAX_ITEMS = 25;
|
|
51
|
+
function budget(verbatim) {
|
|
52
|
+
return verbatim
|
|
53
|
+
? { item: 20_000, reply: 20_000, replies: 1_000, field: 4_000, digest: 200_000, items: 500, clipped: false }
|
|
54
|
+
: { item: ITEM_CHARS, reply: 240, replies: 4, field: 160, digest: DIGEST_CHARS, items: MAX_ITEMS, clipped: false };
|
|
55
|
+
}
|
|
51
56
|
const str = (v) => (typeof v === "string" ? v : "");
|
|
52
57
|
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
53
|
-
function clamp(text, max
|
|
58
|
+
function clamp(text, max, b) {
|
|
54
59
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
55
|
-
|
|
60
|
+
if (flat.length <= max)
|
|
61
|
+
return flat;
|
|
62
|
+
if (b)
|
|
63
|
+
b.clipped = true;
|
|
64
|
+
return `${flat.slice(0, max - 1)}…`;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The picture to embed beside a row, proxied where one exists (#91).
|
|
68
|
+
*
|
|
69
|
+
* A report writer on a host that renders the view gets only this text block,
|
|
70
|
+
* so without it the model had the review text and the price and no picture
|
|
71
|
+
* to put next to them. The proxy URL comes first for the reason
|
|
72
|
+
* `normaliseProducts` gives: the raw CDN link is signed, short-lived and
|
|
73
|
+
* Referer-gated, so pasted into a document it dies within the hour.
|
|
74
|
+
*/
|
|
75
|
+
function media(row) {
|
|
76
|
+
for (const k of ["thumbnailProxyUrl", "imageProxyUrl", "image", "coverProxyUrl", "thumbnailUrl", "coverUrl"]) {
|
|
77
|
+
const v = row[k];
|
|
78
|
+
if (typeof v === "string" && /^https?:\/\//.test(v))
|
|
79
|
+
return v;
|
|
80
|
+
}
|
|
81
|
+
return "";
|
|
56
82
|
}
|
|
57
83
|
/** `12.3k`, because a raw 12345 beside four other numbers is harder to read than it looks. */
|
|
58
84
|
function compactCount(n) {
|
|
@@ -82,7 +108,7 @@ function stats(row) {
|
|
|
82
108
|
* for qualifying posts to be reported "with their permalink, so they can
|
|
83
109
|
* actually be replied to". A digest without links cannot answer that.
|
|
84
110
|
*/
|
|
85
|
-
function postLine(row) {
|
|
111
|
+
function postLine(row, b) {
|
|
86
112
|
const head = [
|
|
87
113
|
str(row.platform) || null,
|
|
88
114
|
str(row.creatorHandle) ? `@${str(row.creatorHandle)}` : null,
|
|
@@ -92,20 +118,22 @@ function postLine(row) {
|
|
|
92
118
|
]
|
|
93
119
|
.filter(Boolean)
|
|
94
120
|
.join(" · ");
|
|
95
|
-
const text = clamp(str(row.caption) || str(row.title) || str(row.text));
|
|
121
|
+
const text = clamp(str(row.caption) || str(row.title) || str(row.text), b.item, b);
|
|
96
122
|
const url = str(row.externalUrl) || str(row.url);
|
|
123
|
+
const picture = media(row);
|
|
97
124
|
// Replies under the post, when something opened it. Nested one level down,
|
|
98
125
|
// which is exactly where #59 could come back without the chain-map gate
|
|
99
126
|
// noticing: `posts` is rendered, so the check is satisfied while the thing
|
|
100
127
|
// that was paid for — the comment that says "same here, this is my problem"
|
|
101
128
|
// — stays in the payload only.
|
|
102
129
|
const replies = Array.isArray(row.commentSample) ? row.commentSample : [];
|
|
103
|
-
const shown = replies.slice(0,
|
|
130
|
+
const shown = replies.slice(0, b.replies).map((c) => ` - ${clamp(commentLine(c, b), b.reply, b)}`);
|
|
104
131
|
if (replies.length > shown.length) {
|
|
132
|
+
b.clipped = true;
|
|
105
133
|
shown.push(` - (${replies.length - shown.length} more under this post)`);
|
|
106
134
|
}
|
|
107
135
|
const readNothing = row.commentsRead === 0 ? " - (this thread would not open — not that nobody replied)" : "";
|
|
108
|
-
return [head && `[${head}]`, text, url, ...shown, readNothing]
|
|
136
|
+
return [head && `[${head}]`, text, url, picture && `image: ${picture}`, ...shown, readNothing]
|
|
109
137
|
.filter(Boolean)
|
|
110
138
|
.join("\n ");
|
|
111
139
|
}
|
|
@@ -116,16 +144,16 @@ function postLine(row) {
|
|
|
116
144
|
* classification per comment and `show_comment_review` takes those ids back.
|
|
117
145
|
* A digest that renders the text and drops the id breaks the next call.
|
|
118
146
|
*/
|
|
119
|
-
function commentLine(row) {
|
|
147
|
+
function commentLine(row, b) {
|
|
120
148
|
const id = str(row.id);
|
|
121
149
|
const who = str(row.author) || str(row.username);
|
|
122
150
|
const likes = num(row.likes);
|
|
123
151
|
const head = [id || null, who ? `@${who}` : null, likes ? `${compactCount(likes)} likes` : null]
|
|
124
152
|
.filter(Boolean)
|
|
125
153
|
.join(" · ");
|
|
126
|
-
return [head && `[${head}]`, clamp(str(row.text) || str(row.comment))].filter(Boolean).join("\n ");
|
|
154
|
+
return [head && `[${head}]`, clamp(str(row.text) || str(row.comment), b.item, b)].filter(Boolean).join("\n ");
|
|
127
155
|
}
|
|
128
|
-
function creatorLine(row) {
|
|
156
|
+
function creatorLine(row, b) {
|
|
129
157
|
const head = [
|
|
130
158
|
str(row.username) ? `@${str(row.username)}` : str(row.handle) ? `@${str(row.handle)}` : null,
|
|
131
159
|
str(row.platform) || null,
|
|
@@ -134,7 +162,7 @@ function creatorLine(row) {
|
|
|
134
162
|
]
|
|
135
163
|
.filter(Boolean)
|
|
136
164
|
.join(" · ");
|
|
137
|
-
const why = clamp(str(row.why) || str(row.bio) || str(row.verdict));
|
|
165
|
+
const why = clamp(str(row.why) || str(row.bio) || str(row.verdict), b.item, b);
|
|
138
166
|
const links = Array.isArray(row.links)
|
|
139
167
|
? row.links
|
|
140
168
|
.map((l) => str(l.url))
|
|
@@ -150,11 +178,34 @@ function runLine(row) {
|
|
|
150
178
|
const reported = num(row.reported);
|
|
151
179
|
return `${when || "(undated)"} · found ${found ?? "?"} · reported ${reported ?? "?"}`;
|
|
152
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* One marketplace listing, with its picture (#91).
|
|
183
|
+
*
|
|
184
|
+
* Products used to fall to `genericLine`, whose first eight scalar fields ran
|
|
185
|
+
* out before `image` — so the one listing field a report most wants to embed
|
|
186
|
+
* never reached the model.
|
|
187
|
+
*/
|
|
188
|
+
function productLine(row, b) {
|
|
189
|
+
const rating = num(row.rating);
|
|
190
|
+
const count = num(row.ratingCount);
|
|
191
|
+
const head = [
|
|
192
|
+
str(row.asin) || null,
|
|
193
|
+
str(row.brand) || null,
|
|
194
|
+
str(row.price) || null,
|
|
195
|
+
rating != null ? `${rating}★${count ? ` from ${compactCount(count)} ratings` : ""}` : null,
|
|
196
|
+
]
|
|
197
|
+
.filter(Boolean)
|
|
198
|
+
.join(" · ");
|
|
199
|
+
const picture = media(row);
|
|
200
|
+
return [head && `[${head}]`, clamp(str(row.title), b.item, b), str(row.url), picture && `image: ${picture}`]
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.join("\n ");
|
|
203
|
+
}
|
|
153
204
|
/** Everything with no shape of its own: the short scalar fields, in order. */
|
|
154
|
-
function genericLine(row) {
|
|
205
|
+
function genericLine(row, b) {
|
|
155
206
|
const pairs = Object.entries(row)
|
|
156
207
|
.filter(([, v]) => typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
157
|
-
.map(([k, v]) => `${k}: ${clamp(String(v),
|
|
208
|
+
.map(([k, v]) => `${k}: ${clamp(String(v), b.field, b)}`)
|
|
158
209
|
.slice(0, 8);
|
|
159
210
|
return pairs.join(" · ");
|
|
160
211
|
}
|
|
@@ -166,6 +217,7 @@ function genericLine(row) {
|
|
|
166
217
|
*/
|
|
167
218
|
const SHAPES = [
|
|
168
219
|
{ key: "posts", label: "post", line: postLine },
|
|
220
|
+
{ key: "products", label: "listing", line: productLine },
|
|
169
221
|
// The baseline a post is scored against, and the asks a niche sweep found.
|
|
170
222
|
// Both are posts in all but name, and both are the material their tool's
|
|
171
223
|
// guidance reasons over — `why_did_this_underperform` cannot say "this is an
|
|
@@ -237,20 +289,24 @@ function unclaimedShapes(payload) {
|
|
|
237
289
|
})
|
|
238
290
|
.map(([key]) => ({ key, label: key.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase(), line: genericLine }));
|
|
239
291
|
}
|
|
240
|
-
export function evidenceDigest(payload) {
|
|
292
|
+
export function evidenceDigest(payload, opts = {}) {
|
|
241
293
|
const blocks = [];
|
|
242
294
|
let spent = 0;
|
|
295
|
+
const b = budget(opts.verbatim === true);
|
|
296
|
+
const elsewhere = opts.recover
|
|
297
|
+
? `the rest are in the structured payload and in the view; to read them here, ${opts.recover}`
|
|
298
|
+
: "the rest are in the structured payload and in the view";
|
|
243
299
|
for (const shape of [...SHAPES, ...unclaimedShapes(payload)]) {
|
|
244
300
|
const rows = payload[shape.key];
|
|
245
301
|
if (!Array.isArray(rows) || rows.length === 0)
|
|
246
302
|
continue;
|
|
247
|
-
if (spent >=
|
|
303
|
+
if (spent >= b.digest)
|
|
248
304
|
break;
|
|
249
305
|
const shown = [];
|
|
250
|
-
for (const row of rows.slice(0,
|
|
251
|
-
if (spent >=
|
|
306
|
+
for (const row of rows.slice(0, b.items)) {
|
|
307
|
+
if (spent >= b.digest)
|
|
252
308
|
break;
|
|
253
|
-
const line = shape.line((row ?? {}));
|
|
309
|
+
const line = shape.line((row ?? {}), b);
|
|
254
310
|
if (!line.trim())
|
|
255
311
|
continue;
|
|
256
312
|
shown.push(line);
|
|
@@ -264,10 +320,17 @@ export function evidenceDigest(payload) {
|
|
|
264
320
|
const total = rows.length;
|
|
265
321
|
const plural = total === 1 || shape.label.endsWith("s") ? shape.label : `${shape.label}s`;
|
|
266
322
|
const header = shown.length < total
|
|
267
|
-
? `${shown.length} of ${total} ${plural} (
|
|
323
|
+
? `${shown.length} of ${total} ${plural} (${elsewhere}):`
|
|
268
324
|
: `${total} ${plural}:`;
|
|
269
325
|
blocks.push([header, ...shown.map((l, i) => `${i + 1}. ${l}`)].join("\n"));
|
|
270
326
|
}
|
|
327
|
+
// A clipped item ends in "…", which a model can miss and then quote as if
|
|
328
|
+
// whole. Said once, with the way to get the full text when there is one.
|
|
329
|
+
if (b.clipped && blocks.length) {
|
|
330
|
+
blocks.push(opts.recover
|
|
331
|
+
? `Some text above was shortened (it ends in "…"). Do not quote a shortened line as complete; to get every item whole, ${opts.recover}.`
|
|
332
|
+
: `Some text above was shortened (it ends in "…"). Do not quote a shortened line as complete.`);
|
|
333
|
+
}
|
|
271
334
|
return blocks.join("\n\n");
|
|
272
335
|
}
|
|
273
336
|
/**
|
|
@@ -276,7 +339,7 @@ export function evidenceDigest(payload) {
|
|
|
276
339
|
* Guidance first: it is the instruction, and a model that reads the material
|
|
277
340
|
* before knowing what it is for reads it twice.
|
|
278
341
|
*/
|
|
279
|
-
export function withEvidence(guidance, payload) {
|
|
280
|
-
const digest = evidenceDigest(payload);
|
|
342
|
+
export function withEvidence(guidance, payload, opts = {}) {
|
|
343
|
+
const digest = evidenceDigest(payload, opts);
|
|
281
344
|
return digest ? `${guidance}\n\n---\n\n${digest}` : guidance;
|
|
282
345
|
}
|