@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.
@@ -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: textBlock ? [{ type: "text", text: String(textBlock.text ?? "") }] : [],
273
- structuredContent: proxy.structured,
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
- content: [{ type: "text", text: guidance }],
474
- structuredContent: { guidance, ...sc },
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
+ }
@@ -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
+ }
@@ -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 in the text block, evidence in the structured one — as every tool here does. */
600
- // Both channels, because only one of them arrives. A host that renders
601
- // `structuredContent` replaces the content text blocks with the serialised
602
- // JSON, so guidance kept only in a text block reaches no model — measured at
603
- // 0 phrases across 175 tool results. See tools.ts's runEvidence for the full
604
- // note, and docs/testing/tool-chaining-quests.md for the measurement.
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
- const shapes = [
2119
- { shape: "plain", query: problem },
2120
- { shape: "shared", query: `anyone else ${problem}` },
2121
- { shape: "asking", query: `is there a way to ${problem}` },
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
- "`foundBy` says which phrasing surfaced each post: `plain` the problem itself, `shared` " +
2195
- "the does-anyone-else form, `asking` the is-there-a-tool form. The last two skew " +
2196
- "towards genuine complaints and the first towards content about the topic.",
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: textBlock ? [{ type: "text", text: String(textBlock.text ?? "") }] : [],
60
- structuredContent: proxy.structured,
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) {
@@ -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",
@@ -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";
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: [{ type: "text", text: guidance }],
1207
- structuredContent: {
1208
- // See runEvidence: a text block alone reaches no host that renders
1209
- // structuredContent, and this is the tool show_comment_review's whole
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.29",
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": {