@nooticr/mcp 1.26.29 → 1.26.31
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/dist/shared/brand-watch.js +20 -4
- package/dist/shared/evidence-digest.js +269 -0
- package/dist/shared/evidence.js +53 -0
- package/dist/shared/jobs.js +59 -16
- package/dist/shared/own-account.js +12 -2
- package/dist/shared/tools-def.js +1 -1
- package/dist/shared/tools.js +39 -17
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* rather than a build.
|
|
14
14
|
*/
|
|
15
15
|
import { z } from "zod";
|
|
16
|
+
import { withEvidence } from "./evidence-digest.js";
|
|
16
17
|
import { OUTPUT_SCHEMAS } from "./output-schemas.js";
|
|
17
18
|
import { viewMeta } from "./view-meta.js";
|
|
18
19
|
import { BACKEND_CALL_CREDITS, confirmSpend, declinedResult, searchMentionsCost } from "./spend.js";
|
|
@@ -268,9 +269,17 @@ function trendGuidance(sc, runs) {
|
|
|
268
269
|
}
|
|
269
270
|
function toResult(proxy) {
|
|
270
271
|
const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
|
|
272
|
+
const structured = proxy.structured;
|
|
273
|
+
// The backend's text block is a one-line summary — "1 active brand watch."
|
|
274
|
+
// — and the watches themselves are in `structuredContent`, which a host
|
|
275
|
+
// rendering the view keeps for the widget. A model asked which watch to stop
|
|
276
|
+
// could see the count and not the terms (#59).
|
|
277
|
+
const text = String(textBlock?.text ?? "");
|
|
271
278
|
return {
|
|
272
|
-
content:
|
|
273
|
-
|
|
279
|
+
content: [
|
|
280
|
+
{ type: "text", text: structured ? withEvidence(text, structured) : text },
|
|
281
|
+
],
|
|
282
|
+
structuredContent: structured,
|
|
274
283
|
};
|
|
275
284
|
}
|
|
276
285
|
function failed(prefix, err) {
|
|
@@ -468,10 +477,17 @@ export function registerBrandWatch(server, makeClient) {
|
|
|
468
477
|
// model reading two points as a trend, and reading the start of the
|
|
469
478
|
// retention window as the moment a conversation began.
|
|
470
479
|
const guidance = trendGuidance(sc, runs);
|
|
480
|
+
const payload = { guidance, ...sc };
|
|
471
481
|
return {
|
|
472
482
|
...result,
|
|
473
|
-
|
|
474
|
-
|
|
483
|
+
// "8 runs of X, newest first" with the runs in the other channel is
|
|
484
|
+
// the same deictic failure as everywhere else (#59): a host that
|
|
485
|
+
// renders the view keeps `structuredContent` for the widget, and the
|
|
486
|
+
// model is left counting runs it cannot read.
|
|
487
|
+
content: [
|
|
488
|
+
{ type: "text", text: withEvidence(guidance, payload) },
|
|
489
|
+
],
|
|
490
|
+
structuredContent: payload,
|
|
475
491
|
};
|
|
476
492
|
}
|
|
477
493
|
catch (err) {
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A compact rendering of a payload's evidence, for the channel that gets the
|
|
3
|
+
* text blocks.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* There are two channels out of a tool call and no host delivers both to the
|
|
8
|
+
* model:
|
|
9
|
+
*
|
|
10
|
+
* | | `content` text blocks | `structuredContent` |
|
|
11
|
+
* |---|---|---|
|
|
12
|
+
* | Claude Code | dropped when `structuredContent` is present | delivered, serialised |
|
|
13
|
+
* | Claude.ai, tool with a UI view | delivered | routed to the widget |
|
|
14
|
+
*
|
|
15
|
+
* #44 found the first column and #51 fixed it, by writing the guidance into
|
|
16
|
+
* both channels. The evidence never got the same treatment, so it survived
|
|
17
|
+
* Claude Code and vanished on Claude.ai — where a real session received
|
|
18
|
+
* "3 posts that might be someone describing ...", instructions to quote lines
|
|
19
|
+
* from them and report their permalinks, and not one post (#59).
|
|
20
|
+
*
|
|
21
|
+
* The tools most affected were the composed ones, which replace the backend's
|
|
22
|
+
* text block with their own guidance. That guidance is deictic — "Here are 4
|
|
23
|
+
* comments", "2 creators", "8 runs" — so it counts and describes material that
|
|
24
|
+
* is somewhere else. "Here are 4 comments" is a false sentence in the channel
|
|
25
|
+
* it is written in.
|
|
26
|
+
*
|
|
27
|
+
* So: each channel has to be self-sufficient. This renders the evidence for
|
|
28
|
+
* the text one.
|
|
29
|
+
*
|
|
30
|
+
* ## What it is not
|
|
31
|
+
*
|
|
32
|
+
* Not the serialised payload. A model asked to judge posts needs the caption,
|
|
33
|
+
* the handle and the link; it does not need `engagementRate` to four decimal
|
|
34
|
+
* places, and 14 KB of JSON in a text block crowds out the guidance that says
|
|
35
|
+
* what to do with it. Each shape below renders the fields its guidance
|
|
36
|
+
* actually names.
|
|
37
|
+
*/
|
|
38
|
+
/** Per-item text budget. Long enough for a real Reddit complaint, short enough that twenty fit. */
|
|
39
|
+
const ITEM_CHARS = 600;
|
|
40
|
+
/**
|
|
41
|
+
* Whole-digest budget.
|
|
42
|
+
*
|
|
43
|
+
* `answer_my_audience` can carry 14 KB of payload. The cap is what keeps a
|
|
44
|
+
* text block readable rather than a second serialisation of it — and when it
|
|
45
|
+
* bites, the digest says so, because a model reasoning over 20 of 60 comments
|
|
46
|
+
* has to know it has 20.
|
|
47
|
+
*/
|
|
48
|
+
const DIGEST_CHARS = 6000;
|
|
49
|
+
/** How many items of one kind are worth rendering before the count matters more than the detail. */
|
|
50
|
+
const MAX_ITEMS = 25;
|
|
51
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
52
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
53
|
+
function clamp(text, max = ITEM_CHARS) {
|
|
54
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
55
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
56
|
+
}
|
|
57
|
+
/** `12.3k`, because a raw 12345 beside four other numbers is harder to read than it looks. */
|
|
58
|
+
function compactCount(n) {
|
|
59
|
+
if (n >= 1_000_000)
|
|
60
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
61
|
+
if (n >= 1_000)
|
|
62
|
+
return `${(n / 1_000).toFixed(1)}k`;
|
|
63
|
+
return String(n);
|
|
64
|
+
}
|
|
65
|
+
function stats(row) {
|
|
66
|
+
const parts = [];
|
|
67
|
+
const views = num(row.views);
|
|
68
|
+
const likes = num(row.likes);
|
|
69
|
+
const comments = num(row.comments);
|
|
70
|
+
if (views)
|
|
71
|
+
parts.push(`${compactCount(views)} views`);
|
|
72
|
+
if (likes)
|
|
73
|
+
parts.push(`${compactCount(likes)} likes`);
|
|
74
|
+
if (comments)
|
|
75
|
+
parts.push(`${compactCount(comments)} comments`);
|
|
76
|
+
return parts.join(", ");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* One post, as a line a model can cite.
|
|
80
|
+
*
|
|
81
|
+
* `externalUrl` is not decoration: `find_people_with_problem`'s guidance asks
|
|
82
|
+
* for qualifying posts to be reported "with their permalink, so they can
|
|
83
|
+
* actually be replied to". A digest without links cannot answer that.
|
|
84
|
+
*/
|
|
85
|
+
function postLine(row) {
|
|
86
|
+
const head = [
|
|
87
|
+
str(row.platform) || null,
|
|
88
|
+
str(row.creatorHandle) ? `@${str(row.creatorHandle)}` : null,
|
|
89
|
+
str(row.subreddit) || null,
|
|
90
|
+
str(row.foundBy) ? `found by: ${str(row.foundBy)}` : null,
|
|
91
|
+
stats(row) || null,
|
|
92
|
+
]
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.join(" · ");
|
|
95
|
+
const text = clamp(str(row.caption) || str(row.title) || str(row.text));
|
|
96
|
+
const url = str(row.externalUrl) || str(row.url);
|
|
97
|
+
return [head && `[${head}]`, text, url].filter(Boolean).join("\n ");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* One comment, with its id.
|
|
101
|
+
*
|
|
102
|
+
* The id is the load-bearing field: `analyze_comments` asks for a
|
|
103
|
+
* classification per comment and `show_comment_review` takes those ids back.
|
|
104
|
+
* A digest that renders the text and drops the id breaks the next call.
|
|
105
|
+
*/
|
|
106
|
+
function commentLine(row) {
|
|
107
|
+
const id = str(row.id);
|
|
108
|
+
const who = str(row.author) || str(row.username);
|
|
109
|
+
const likes = num(row.likes);
|
|
110
|
+
const head = [id || null, who ? `@${who}` : null, likes ? `${compactCount(likes)} likes` : null]
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.join(" · ");
|
|
113
|
+
return [head && `[${head}]`, clamp(str(row.text) || str(row.comment))].filter(Boolean).join("\n ");
|
|
114
|
+
}
|
|
115
|
+
function creatorLine(row) {
|
|
116
|
+
const head = [
|
|
117
|
+
str(row.username) ? `@${str(row.username)}` : str(row.handle) ? `@${str(row.handle)}` : null,
|
|
118
|
+
str(row.platform) || null,
|
|
119
|
+
num(row.followers) ? `${compactCount(num(row.followers))} followers` : null,
|
|
120
|
+
str(row.foundBy) ? `found by: ${str(row.foundBy)}` : null,
|
|
121
|
+
]
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.join(" · ");
|
|
124
|
+
const why = clamp(str(row.why) || str(row.bio) || str(row.verdict));
|
|
125
|
+
const links = Array.isArray(row.links)
|
|
126
|
+
? row.links
|
|
127
|
+
.map((l) => str(l.url))
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.slice(0, 3)
|
|
130
|
+
.join(" ")
|
|
131
|
+
: "";
|
|
132
|
+
return [head && `[${head}]`, why, links].filter(Boolean).join("\n ");
|
|
133
|
+
}
|
|
134
|
+
function runLine(row) {
|
|
135
|
+
const when = str(row.ranAt).slice(0, 10);
|
|
136
|
+
const found = num(row.found);
|
|
137
|
+
const reported = num(row.reported);
|
|
138
|
+
return `${when || "(undated)"} · found ${found ?? "?"} · reported ${reported ?? "?"}`;
|
|
139
|
+
}
|
|
140
|
+
/** Everything with no shape of its own: the short scalar fields, in order. */
|
|
141
|
+
function genericLine(row) {
|
|
142
|
+
const pairs = Object.entries(row)
|
|
143
|
+
.filter(([, v]) => typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
144
|
+
.map(([k, v]) => `${k}: ${clamp(String(v), 160)}`)
|
|
145
|
+
.slice(0, 8);
|
|
146
|
+
return pairs.join(" · ");
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The evidence keys worth rendering, and how.
|
|
150
|
+
*
|
|
151
|
+
* Ordered: when a payload carries several, the first is the one the guidance
|
|
152
|
+
* is most likely to be about.
|
|
153
|
+
*/
|
|
154
|
+
const SHAPES = [
|
|
155
|
+
{ key: "posts", label: "post", line: postLine },
|
|
156
|
+
// The baseline a post is scored against, and the asks a niche sweep found.
|
|
157
|
+
// Both are posts in all but name, and both are the material their tool's
|
|
158
|
+
// guidance reasons over — `why_did_this_underperform` cannot say "this is an
|
|
159
|
+
// ordinary result" without the distribution it is ordinary against.
|
|
160
|
+
{ key: "window", label: "post in the baseline window", line: postLine },
|
|
161
|
+
{ key: "demand", label: "thing the audience asked for", line: postLine },
|
|
162
|
+
{ key: "supply", label: "post already serving it", line: postLine },
|
|
163
|
+
{ key: "comments", label: "comment", line: commentLine },
|
|
164
|
+
{ key: "replies", label: "reply", line: commentLine },
|
|
165
|
+
{ key: "mentions", label: "mention", line: commentLine },
|
|
166
|
+
{ key: "creators", label: "creator", line: creatorLine },
|
|
167
|
+
{ key: "candidates", label: "candidate", line: creatorLine },
|
|
168
|
+
{ key: "runs", label: "run", line: runLine },
|
|
169
|
+
{ key: "cues", label: "line", line: genericLine },
|
|
170
|
+
{ key: "segments", label: "segment", line: genericLine },
|
|
171
|
+
{ key: "hashtags", label: "hashtag", line: genericLine },
|
|
172
|
+
{ key: "sounds", label: "sound", line: genericLine },
|
|
173
|
+
{ key: "watches", label: "watch", line: genericLine },
|
|
174
|
+
{ key: "apps", label: "app", line: genericLine },
|
|
175
|
+
{ key: "connections", label: "connection", line: genericLine },
|
|
176
|
+
{ key: "unavailable", label: "network that could not be searched", line: genericLine },
|
|
177
|
+
];
|
|
178
|
+
/**
|
|
179
|
+
* Render whatever evidence a payload carries, or "" when it carries none.
|
|
180
|
+
*
|
|
181
|
+
* Returning "" rather than a placeholder matters: a tool whose result is a
|
|
182
|
+
* verdict rather than material (`show_*`, a state mutation) should get its
|
|
183
|
+
* guidance and nothing appended.
|
|
184
|
+
*/
|
|
185
|
+
/** Fields that identify a row — the ones a caller would cite it by. */
|
|
186
|
+
const IDENTIFYING = [
|
|
187
|
+
"id",
|
|
188
|
+
"externalUrl",
|
|
189
|
+
"url",
|
|
190
|
+
"permalink",
|
|
191
|
+
"caption",
|
|
192
|
+
"text",
|
|
193
|
+
"title",
|
|
194
|
+
"username",
|
|
195
|
+
"handle",
|
|
196
|
+
"creatorHandle",
|
|
197
|
+
"name",
|
|
198
|
+
"term",
|
|
199
|
+
"appId",
|
|
200
|
+
"ranAt",
|
|
201
|
+
"tag",
|
|
202
|
+
];
|
|
203
|
+
/**
|
|
204
|
+
* Any array of objects the named shapes did not claim.
|
|
205
|
+
*
|
|
206
|
+
* A hand-maintained list of keys is exactly the artefact that drifts — a tool
|
|
207
|
+
* adds `window` or `demand`, nobody updates the list, and the material goes
|
|
208
|
+
* missing again with every test still green. So anything that looks like a row
|
|
209
|
+
* with an identity gets rendered, well or plainly, rather than dropped.
|
|
210
|
+
*/
|
|
211
|
+
function unclaimedShapes(payload) {
|
|
212
|
+
const named = new Set(SHAPES.map((s) => s.key));
|
|
213
|
+
return Object.entries(payload)
|
|
214
|
+
.filter(([key, rows]) => {
|
|
215
|
+
if (named.has(key) || !Array.isArray(rows) || rows.length === 0)
|
|
216
|
+
return false;
|
|
217
|
+
const first = rows.find((r) => r && typeof r === "object");
|
|
218
|
+
if (!first)
|
|
219
|
+
return false;
|
|
220
|
+
return IDENTIFYING.some((f) => {
|
|
221
|
+
const v = first[f];
|
|
222
|
+
return typeof v === "string" || typeof v === "number";
|
|
223
|
+
});
|
|
224
|
+
})
|
|
225
|
+
.map(([key]) => ({ key, label: key.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase(), line: genericLine }));
|
|
226
|
+
}
|
|
227
|
+
export function evidenceDigest(payload) {
|
|
228
|
+
const blocks = [];
|
|
229
|
+
let spent = 0;
|
|
230
|
+
for (const shape of [...SHAPES, ...unclaimedShapes(payload)]) {
|
|
231
|
+
const rows = payload[shape.key];
|
|
232
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
233
|
+
continue;
|
|
234
|
+
if (spent >= DIGEST_CHARS)
|
|
235
|
+
break;
|
|
236
|
+
const shown = [];
|
|
237
|
+
for (const row of rows.slice(0, MAX_ITEMS)) {
|
|
238
|
+
if (spent >= DIGEST_CHARS)
|
|
239
|
+
break;
|
|
240
|
+
const line = shape.line((row ?? {}));
|
|
241
|
+
if (!line.trim())
|
|
242
|
+
continue;
|
|
243
|
+
shown.push(line);
|
|
244
|
+
spent += line.length;
|
|
245
|
+
}
|
|
246
|
+
if (!shown.length)
|
|
247
|
+
continue;
|
|
248
|
+
// Say what was left out. A model reasoning over 20 of 60 has to know it
|
|
249
|
+
// has 20 — a digest that silently truncates turns "here is everything" into
|
|
250
|
+
// a claim the caller cannot check.
|
|
251
|
+
const total = rows.length;
|
|
252
|
+
const plural = total === 1 || shape.label.endsWith("s") ? shape.label : `${shape.label}s`;
|
|
253
|
+
const header = shown.length < total
|
|
254
|
+
? `${shown.length} of ${total} ${plural} (the rest are in the structured payload and in the view):`
|
|
255
|
+
: `${total} ${plural}:`;
|
|
256
|
+
blocks.push([header, ...shown.map((l, i) => `${i + 1}. ${l}`)].join("\n"));
|
|
257
|
+
}
|
|
258
|
+
return blocks.join("\n\n");
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Guidance and evidence in one text block, in the order a reader needs them.
|
|
262
|
+
*
|
|
263
|
+
* Guidance first: it is the instruction, and a model that reads the material
|
|
264
|
+
* before knowing what it is for reads it twice.
|
|
265
|
+
*/
|
|
266
|
+
export function withEvidence(guidance, payload) {
|
|
267
|
+
const digest = evidenceDigest(payload);
|
|
268
|
+
return digest ? `${guidance}\n\n---\n\n${digest}` : guidance;
|
|
269
|
+
}
|
package/dist/shared/evidence.js
CHANGED
|
@@ -563,3 +563,56 @@ export function fetchBillingNote(tool) {
|
|
|
563
563
|
"not charge again: the idempotency key is namespaced per tool, so a retry replays the " +
|
|
564
564
|
"same debits.");
|
|
565
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* The searchable core of a complaint, with the speaker stripped out.
|
|
568
|
+
*
|
|
569
|
+
* `find_people_with_problem` widens a problem into the shapes a complaint
|
|
570
|
+
* takes. Those shapes were templated onto the raw argument, and the argument
|
|
571
|
+
* the schema asks for is a full sentence with a subject ("I waste an hour a
|
|
572
|
+
* day checking what competitors posted") — so "is there a way to " + that
|
|
573
|
+
* produced a string nobody has ever written, and sent it to a keyword search
|
|
574
|
+
* (#60).
|
|
575
|
+
*
|
|
576
|
+
* Two things happen here, and neither is intent matching — the backend is a
|
|
577
|
+
* keyword search and this does not pretend otherwise:
|
|
578
|
+
*
|
|
579
|
+
* - The first-person subject and its auxiliary come off the front of each
|
|
580
|
+
* clause. "I'm out of content ideas" is a sentence about a speaker;
|
|
581
|
+
* "out of content ideas" is what the post being looked for actually says.
|
|
582
|
+
* - The result is capped, because a long sentence matches on its commonest
|
|
583
|
+
* words. That is the tool's own advice to its caller, and it was not
|
|
584
|
+
* taking it.
|
|
585
|
+
*
|
|
586
|
+
* A caller who can do better should: `queries` overrides all of this.
|
|
587
|
+
*/
|
|
588
|
+
export function complaintCore(problem) {
|
|
589
|
+
const MAX_WORDS = 12;
|
|
590
|
+
// The pronoun always goes. The verb after it goes ONLY if it is an
|
|
591
|
+
// auxiliary — "I have no idea" is about a speaker, but "I waste an hour a
|
|
592
|
+
// day" carries "waste", and stripping it leaves "an hour a day checking",
|
|
593
|
+
// which is a worse search than the one being fixed. That distinction is the
|
|
594
|
+
// whole of this function; getting it wrong trades one bad query for another.
|
|
595
|
+
const SUBJECT = /^\s*(?:i|we)\b['’]?(?:m\b)?[\s,]*/i;
|
|
596
|
+
const AUXILIARY = /^\s*(?:am|are|is|was|were|have|has|had|do|does|did|don['’]?t|can['’]?t|cannot|been)\b[\s,]*/i;
|
|
597
|
+
// Never end on a word that was pointing at the one the cap removed.
|
|
598
|
+
const DANGLING = /(?:\s+(?:for|my|our|the|a|an|of|to|and|or|with|in|on|at|by|that|this))+$/i;
|
|
599
|
+
const cleaned = problem
|
|
600
|
+
.split(/[,;]+/)
|
|
601
|
+
.map((clause) => {
|
|
602
|
+
const withoutSubject = clause.trim().replace(SUBJECT, "");
|
|
603
|
+
// Only strip an auxiliary that actually followed a subject, so a clause
|
|
604
|
+
// opening with a real verb keeps it.
|
|
605
|
+
return (withoutSubject === clause.trim()
|
|
606
|
+
? withoutSubject
|
|
607
|
+
: withoutSubject.replace(AUXILIARY, "")).trim();
|
|
608
|
+
})
|
|
609
|
+
.filter(Boolean)
|
|
610
|
+
.join(", ");
|
|
611
|
+
const words = (cleaned || problem.trim()).split(/\s+/).filter(Boolean);
|
|
612
|
+
return words
|
|
613
|
+
.slice(0, MAX_WORDS)
|
|
614
|
+
.join(" ")
|
|
615
|
+
.replace(/[,;]+$/, "")
|
|
616
|
+
.replace(DANGLING, "")
|
|
617
|
+
.trim();
|
|
618
|
+
}
|
package/dist/shared/jobs.js
CHANGED
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
import { z } from "zod";
|
|
57
57
|
import { OUTPUT_SCHEMAS } from "./output-schemas.js";
|
|
58
58
|
import { platformFromUrl, postSlug } from "./comment-review.js";
|
|
59
|
-
import { clamp, handleMissGuidance, ownIt, PLATFORM_ARG } from "./evidence.js";
|
|
59
|
+
import { clamp, complaintCore, handleMissGuidance, ownIt, PLATFORM_ARG } from "./evidence.js";
|
|
60
|
+
import { withEvidence } from "./evidence-digest.js";
|
|
60
61
|
import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, SPOKEN_PLATFORMS, CREDITS_PER_CREATOR, } from "./spend.js";
|
|
61
62
|
import { distributionOf, excluding, numberOf, standing, } from "./performance.js";
|
|
62
63
|
import { normaliseHandle, watchEntryId, watchlistOwner } from "./watchlist.js";
|
|
@@ -596,14 +597,21 @@ const metricArg = z
|
|
|
596
597
|
.optional()
|
|
597
598
|
.describe("Which stat to rank on (default views).");
|
|
598
599
|
export function registerJobTools(server, makeClient, store) {
|
|
599
|
-
/** Guidance
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
600
|
+
/** Guidance AND evidence in both channels, because no host delivers both. */
|
|
601
|
+
// Neither channel is safe on its own, and which one is dropped depends on
|
|
602
|
+
// the host: Claude Code discards every `content` text block when a result
|
|
603
|
+
// carries `structuredContent` (#44, measured at 0 guidance phrases across
|
|
604
|
+
// 175 tool results), while Claude.ai routes `structuredContent` to the
|
|
605
|
+
// widget and hands the model only the text blocks (#59).
|
|
606
|
+
//
|
|
607
|
+
// #51 made the guidance survive both. The evidence did not, so a real
|
|
608
|
+
// session got "3 posts that might be someone describing ...", instructions
|
|
609
|
+
// to quote lines from them, and no posts. The guidance these tools write is
|
|
610
|
+
// deictic — it counts and describes material that used to be somewhere
|
|
611
|
+
// else — so `withEvidence` puts a rendering of that material in the same
|
|
612
|
+
// block as the sentence describing it.
|
|
605
613
|
const evidence = (guidance, payload) => ({
|
|
606
|
-
content: [{ type: "text", text: guidance }],
|
|
614
|
+
content: [{ type: "text", text: withEvidence(guidance, payload) }],
|
|
607
615
|
structuredContent: { guidance, ...payload },
|
|
608
616
|
});
|
|
609
617
|
const failed = (prefix, err) => ({
|
|
@@ -2104,6 +2112,14 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
2104
2112
|
.optional()
|
|
2105
2113
|
.describe("Where to look (default reddit). Each one is a separate paid search."),
|
|
2106
2114
|
limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."),
|
|
2115
|
+
queries: z
|
|
2116
|
+
.array(z.string())
|
|
2117
|
+
.optional()
|
|
2118
|
+
.describe("Search these exact phrasings instead of the ones derived from `problem` (max 4). " +
|
|
2119
|
+
"You are better at this than a string template is: write how a person would " +
|
|
2120
|
+
"actually word the complaint on the network being searched — a Reddit title, " +
|
|
2121
|
+
"an X post — and keep each one short, because this is a keyword search and a " +
|
|
2122
|
+
"long sentence matches on its commonest words."),
|
|
2107
2123
|
})
|
|
2108
2124
|
.strict(),
|
|
2109
2125
|
}, async (args, extra) => {
|
|
@@ -2115,11 +2131,27 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
2115
2131
|
// The shapes a complaint takes, rather than the topic it is about. Kept
|
|
2116
2132
|
// to three: each is a paid call per platform, and past three they start
|
|
2117
2133
|
// returning the same posts as each other.
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2134
|
+
//
|
|
2135
|
+
// Built from a stripped core rather than the raw sentence, and the two
|
|
2136
|
+
// non-plain shapes no longer prefix a template onto whatever arrived.
|
|
2137
|
+
// The old `is there a way to ${problem}` assumed a bare verb phrase
|
|
2138
|
+
// while the schema's own example is a full sentence with a subject, so
|
|
2139
|
+
// the documented input produced "is there a way to I have no idea what
|
|
2140
|
+
// to post next, I'm out of content ideas for my page" — a string nobody
|
|
2141
|
+
// has ever written, sent to a keyword search, three times, having been
|
|
2142
|
+
// lengthened twice (#60).
|
|
2143
|
+
const core = complaintCore(problem);
|
|
2144
|
+
const shapes = args.queries?.length
|
|
2145
|
+
? args.queries.slice(0, 4).map((q, i) => ({ shape: `given:${i + 1}`, query: q.trim() }))
|
|
2146
|
+
: [
|
|
2147
|
+
{ shape: "plain", query: core },
|
|
2148
|
+
// Reddit's own idiom, and it reads as a sentence now that `core`
|
|
2149
|
+
// has had its leading subject removed.
|
|
2150
|
+
{ shape: "shared", query: `anyone else ${core}` },
|
|
2151
|
+
// A SUFFIX, not a prefix: appended keywords cannot be made
|
|
2152
|
+
// ungrammatical by whatever shape the core turned out to be.
|
|
2153
|
+
{ shape: "asking", query: `${core} any recommendations` },
|
|
2154
|
+
];
|
|
2123
2155
|
const seen = new Set();
|
|
2124
2156
|
const posts = [];
|
|
2125
2157
|
const unavailable = [];
|
|
@@ -2191,9 +2223,20 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
2191
2223
|
"and say how many you rejected and why — a shortlist with no reject count reads as if " +
|
|
2192
2224
|
"the search was precise.",
|
|
2193
2225
|
"",
|
|
2194
|
-
|
|
2195
|
-
"
|
|
2196
|
-
|
|
2226
|
+
args.queries?.length
|
|
2227
|
+
? "`foundBy` says which of your own phrasings surfaced each post, in the order you " +
|
|
2228
|
+
"gave them."
|
|
2229
|
+
: "`foundBy` says which phrasing surfaced each post: `plain` the problem itself, " +
|
|
2230
|
+
"`shared` the does-anyone-else form, `asking` the looking-for-a-tool form. The " +
|
|
2231
|
+
"last two skew towards genuine complaints and the first towards content about " +
|
|
2232
|
+
"the topic.",
|
|
2233
|
+
"",
|
|
2234
|
+
args.queries?.length
|
|
2235
|
+
? ""
|
|
2236
|
+
: `These were searched as: ${shapes.map((sh) => `"${sh.query}"`).join(", ")}. That ` +
|
|
2237
|
+
"is a keyword search, not intent matching. If those read wrong for where you are " +
|
|
2238
|
+
"looking — a Reddit title is worded differently from an X post — call this again " +
|
|
2239
|
+
"with `queries` set to phrasings you write yourself, and keep each one short.",
|
|
2197
2240
|
"",
|
|
2198
2241
|
unavailable.length
|
|
2199
2242
|
? `Note that ${unavailable.length} searches errored — tell the user which networks ` +
|
|
@@ -51,13 +51,23 @@
|
|
|
51
51
|
* `get_content_plan` above); this file is what makes them reachable here.
|
|
52
52
|
*/
|
|
53
53
|
import { z } from "zod";
|
|
54
|
+
import { withEvidence } from "./evidence-digest.js";
|
|
54
55
|
import { OUTPUT_SCHEMAS } from "./output-schemas.js";
|
|
55
56
|
import { viewMeta } from "./view-meta.js";
|
|
56
57
|
function toResult(proxy) {
|
|
57
58
|
const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
|
|
59
|
+
const structured = proxy.structured;
|
|
60
|
+
// The backend's summary line, plus the rows it summarises. `list_own_apps`
|
|
61
|
+
// said "You have 2 app(s) in this workspace" and kept the appIds in
|
|
62
|
+
// `structuredContent` — which a host rendering the view gives to the widget,
|
|
63
|
+
// leaving a model that needs an appId for draft_post with the count alone
|
|
64
|
+
// (#59).
|
|
65
|
+
const text = String(textBlock?.text ?? "");
|
|
58
66
|
return {
|
|
59
|
-
content:
|
|
60
|
-
|
|
67
|
+
content: [
|
|
68
|
+
{ type: "text", text: structured ? withEvidence(text, structured) : text },
|
|
69
|
+
],
|
|
70
|
+
structuredContent: structured,
|
|
61
71
|
};
|
|
62
72
|
}
|
|
63
73
|
function failed(prefix, err) {
|
package/dist/shared/tools-def.js
CHANGED
|
@@ -394,7 +394,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
394
394
|
name: "find_people_with_problem",
|
|
395
395
|
title: "Find People With A Problem",
|
|
396
396
|
description: "Find people describing a problem in their own words — prospects, early users, anyone whose complaint your product answers. Say the problem the way a person would say it (\"I spend hours checking competitors by hand\") and this searches for posts shaped like that complaint, not for the topic it is about. That difference is the point: searching the TOPIC finds marketing about it, which is what discover_social_posts is for. Widens the query into the forms a complaint takes — the plain phrasing, the does-anyone-else question, the is-there-a-tool ask — and merges what each returns. It is a wide cheap net and NOT a filter: the posts come back for you to judge, and many will be off-target. Consumes 2 nooticr credits per platform searched. Defaults to reddit, where people describe workflow pain in sentences; add twitter for volume.",
|
|
397
|
-
inputSchema: z.object({ problem: z.string().describe("The problem in a person's own words, not a topic. \"I waste an hour a day checking what competitors posted\" finds people; \"competitor analysis\" finds marketing about competitor analysis."), platforms: z.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram", "linkedin"])).optional().describe("Where to look (default reddit). Each one is a separate paid search."), limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15).") }).strict(),
|
|
397
|
+
inputSchema: z.object({ problem: z.string().describe("The problem in a person's own words, not a topic. \"I waste an hour a day checking what competitors posted\" finds people; \"competitor analysis\" finds marketing about competitor analysis."), platforms: z.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram", "linkedin"])).optional().describe("Where to look (default reddit). Each one is a separate paid search."), limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."), queries: z.array(z.string()).optional().describe("Search these exact phrasings instead of the ones derived from `problem` (max 4). You are better at this than a string template is: write how a person would actually word the complaint on the network being searched \u2014 a Reddit title, an X post \u2014 and keep each one short, because this is a keyword search and a long sentence matches on its commonest words.") }).strict(),
|
|
398
398
|
},
|
|
399
399
|
{
|
|
400
400
|
name: "search_spoken_mentions",
|
package/dist/shared/tools.js
CHANGED
|
@@ -15,6 +15,7 @@ import { deriveHashtags, derivedNote } from "./hashtags.js";
|
|
|
15
15
|
import { createTaskStore, registerSlowTool } from "./tasks.js";
|
|
16
16
|
import { COMMENT_CATEGORIES, COMMENT_SENTIMENTS, platformFromUrl, reviewGuidance, toEvidence, } from "./comment-review.js";
|
|
17
17
|
import { costSentence, EVIDENCE_PLANS, fetchBillingNote, frameIndex, framesToBlocks, planCalls, scoreDraftGuidance, KNOWN_PLATFORMS, platformFailureGuidance, } from "./evidence.js";
|
|
18
|
+
import { evidenceDigest } from "./evidence-digest.js";
|
|
18
19
|
import { confirmSpend, declinedResult, searchMentionsCost, SEARCH_PLATFORMS, DISCOVERABLE_PLATFORMS, } from "./spend.js";
|
|
19
20
|
import { BackendWatchStore, MemoryWatchStore, registerWatchlist, } from "./watchlist.js";
|
|
20
21
|
import { registerJobTools } from "./jobs.js";
|
|
@@ -25,7 +26,7 @@ import { loadingPlansJson } from "./loading-plans.js";
|
|
|
25
26
|
import { registerHandoff } from "./handoff.js";
|
|
26
27
|
import { registerCollabTools } from "./collab.js";
|
|
27
28
|
/** Current MCP server version — bumped on every deploy for traceability. */
|
|
28
|
-
export const MCP_SERVER_VERSION = "1.26.
|
|
29
|
+
export const MCP_SERVER_VERSION = "1.26.31";
|
|
29
30
|
/** MCP Apps extension identifier */
|
|
30
31
|
const UI_EXTENSION = "io.modelcontextprotocol/ui";
|
|
31
32
|
/** MIME type for MCP Apps HTML resources */
|
|
@@ -444,6 +445,19 @@ async function runEvidence(tool, args, client) {
|
|
|
444
445
|
// rather than "no fetch was made".
|
|
445
446
|
if (plan.also && !nothingToFetch)
|
|
446
447
|
out[plan.also.via] = extra;
|
|
448
|
+
// The evidence has to ride in the text block too, and it is written last
|
|
449
|
+
// because `out` is what it renders — the fetched payload, plus whatever the
|
|
450
|
+
// `also` call added, minus the frames that became image blocks.
|
|
451
|
+
//
|
|
452
|
+
// Same reason the guidance is in both channels (#44/#51), pointed the other
|
|
453
|
+
// way: Claude Code drops these text blocks and reads `structuredContent`,
|
|
454
|
+
// while a host rendering a UI view hands the model the text blocks and gives
|
|
455
|
+
// `structuredContent` to the widget. A transcript that exists only in the
|
|
456
|
+
// second one is a transcript the model was told to read and never saw (#59).
|
|
457
|
+
const digest = evidenceDigest(out);
|
|
458
|
+
if (digest) {
|
|
459
|
+
content[0] = { type: "text", text: `${guidance}\n\n---\n\n${digest}` };
|
|
460
|
+
}
|
|
447
461
|
return { content, structuredContent: out };
|
|
448
462
|
}
|
|
449
463
|
export function createMcpServer(rawMakeClient, opts) {
|
|
@@ -1202,23 +1216,31 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1202
1216
|
const structured = (res.structured ?? {});
|
|
1203
1217
|
const comments = toEvidence(args.url, structured.comments);
|
|
1204
1218
|
const guidance = reviewGuidance(args.url, comments.length);
|
|
1219
|
+
// "Here are 4 comments ... Read them and classify each one" was a false
|
|
1220
|
+
// sentence in the channel it was written in: the comments were only ever
|
|
1221
|
+
// in `structuredContent`, which a host rendering the view keeps for the
|
|
1222
|
+
// widget. The model was asked to classify comments it could not see, and
|
|
1223
|
+
// to return the `id` of each — so the digest renders ids too (#59).
|
|
1224
|
+
const payload = {
|
|
1225
|
+
// See runEvidence: a text block alone reaches no host that renders
|
|
1226
|
+
// structuredContent, and this is the tool show_comment_review's whole
|
|
1227
|
+
// chain hangs off.
|
|
1228
|
+
guidance,
|
|
1229
|
+
mode: "evidence",
|
|
1230
|
+
url: args.url,
|
|
1231
|
+
platform: structured.platform ?? null,
|
|
1232
|
+
commentCount: comments.length,
|
|
1233
|
+
comments,
|
|
1234
|
+
// Passed through rather than re-derived: the platform's own clustering
|
|
1235
|
+
// is evidence too, and it is already paid for.
|
|
1236
|
+
themes: structured.themes ?? [],
|
|
1237
|
+
mcpCredits: structured.mcpCredits ?? null,
|
|
1238
|
+
};
|
|
1205
1239
|
return {
|
|
1206
|
-
content: [
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
// chain hangs off.
|
|
1211
|
-
guidance,
|
|
1212
|
-
mode: "evidence",
|
|
1213
|
-
url: args.url,
|
|
1214
|
-
platform: structured.platform ?? null,
|
|
1215
|
-
commentCount: comments.length,
|
|
1216
|
-
comments,
|
|
1217
|
-
// Passed through rather than re-derived: the platform's own clustering
|
|
1218
|
-
// is evidence too, and it is already paid for.
|
|
1219
|
-
themes: structured.themes ?? [],
|
|
1220
|
-
mcpCredits: structured.mcpCredits ?? null,
|
|
1221
|
-
},
|
|
1240
|
+
content: [
|
|
1241
|
+
{ type: "text", text: `${guidance}\n\n---\n\n${evidenceDigest(payload)}` },
|
|
1242
|
+
],
|
|
1243
|
+
structuredContent: payload,
|
|
1222
1244
|
};
|
|
1223
1245
|
}
|
|
1224
1246
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nooticr/mcp",
|
|
3
3
|
"mcpName": "com.nooticr/mcp",
|
|
4
|
-
"version": "1.26.
|
|
4
|
+
"version": "1.26.31",
|
|
5
5
|
"description": "MCP server for nooticr - fetch, discover and understand TikTok/Instagram/YouTube/X/Reddit/LinkedIn/Weibo posts with AI (media metadata, niche discovery, brand monitoring, hook & viral analysis)",
|
|
6
6
|
"homepage": "https://mcp.nooticr.com",
|
|
7
7
|
"repository": {
|