@nooticr/mcp 1.26.30 → 1.26.32

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.
@@ -94,7 +94,20 @@ function postLine(row) {
94
94
  .join(" · ");
95
95
  const text = clamp(str(row.caption) || str(row.title) || str(row.text));
96
96
  const url = str(row.externalUrl) || str(row.url);
97
- return [head && `[${head}]`, text, url].filter(Boolean).join("\n ");
97
+ // Replies under the post, when something opened it. Nested one level down,
98
+ // which is exactly where #59 could come back without the chain-map gate
99
+ // noticing: `posts` is rendered, so the check is satisfied while the thing
100
+ // that was paid for — the comment that says "same here, this is my problem"
101
+ // — stays in the payload only.
102
+ const replies = Array.isArray(row.commentSample) ? row.commentSample : [];
103
+ const shown = replies.slice(0, 4).map((c) => ` - ${clamp(commentLine(c), 240)}`);
104
+ if (replies.length > shown.length) {
105
+ shown.push(` - (${replies.length - shown.length} more under this post)`);
106
+ }
107
+ const readNothing = row.commentsRead === 0 ? " - (this thread would not open — not that nobody replied)" : "";
108
+ return [head && `[${head}]`, text, url, ...shown, readNothing]
109
+ .filter(Boolean)
110
+ .join("\n ");
98
111
  }
99
112
  /**
100
113
  * One comment, with its id.
@@ -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,7 @@
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
60
  import { withEvidence } from "./evidence-digest.js";
61
61
  import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, SPOKEN_PLATFORMS, CREDITS_PER_CREATOR, } from "./spend.js";
62
62
  import { distributionOf, excluding, numberOf, standing, } from "./performance.js";
@@ -2112,6 +2112,23 @@ export function registerJobTools(server, makeClient, store) {
2112
2112
  .optional()
2113
2113
  .describe("Where to look (default reddit). Each one is a separate paid search."),
2114
2114
  limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."),
2115
+ readComments: z
2116
+ .boolean()
2117
+ .optional()
2118
+ .describe("Open the most promising results and read their comments too (default false). " +
2119
+ "On Reddit especially, the person with the problem is at least as often replying " +
2120
+ "under someone else's thread as posting their own, and a post-only search cannot " +
2121
+ "see any of them. Costs 2 nooticr credits per post opened, up to 5 — and they are " +
2122
+ "opened on the MERGED, deduped results, so it is five extra calls in total rather " +
2123
+ "than five per query shape per platform."),
2124
+ queries: z
2125
+ .array(z.string())
2126
+ .optional()
2127
+ .describe("Search these exact phrasings instead of the ones derived from `problem` (max 4). " +
2128
+ "You are better at this than a string template is: write how a person would " +
2129
+ "actually word the complaint on the network being searched — a Reddit title, " +
2130
+ "an X post — and keep each one short, because this is a keyword search and a " +
2131
+ "long sentence matches on its commonest words."),
2115
2132
  })
2116
2133
  .strict(),
2117
2134
  }, async (args, extra) => {
@@ -2123,11 +2140,27 @@ export function registerJobTools(server, makeClient, store) {
2123
2140
  // The shapes a complaint takes, rather than the topic it is about. Kept
2124
2141
  // to three: each is a paid call per platform, and past three they start
2125
2142
  // returning the same posts as each other.
2126
- const shapes = [
2127
- { shape: "plain", query: problem },
2128
- { shape: "shared", query: `anyone else ${problem}` },
2129
- { shape: "asking", query: `is there a way to ${problem}` },
2130
- ];
2143
+ //
2144
+ // Built from a stripped core rather than the raw sentence, and the two
2145
+ // non-plain shapes no longer prefix a template onto whatever arrived.
2146
+ // The old `is there a way to ${problem}` assumed a bare verb phrase
2147
+ // while the schema's own example is a full sentence with a subject, so
2148
+ // the documented input produced "is there a way to I have no idea what
2149
+ // to post next, I'm out of content ideas for my page" — a string nobody
2150
+ // has ever written, sent to a keyword search, three times, having been
2151
+ // lengthened twice (#60).
2152
+ const core = complaintCore(problem);
2153
+ const shapes = args.queries?.length
2154
+ ? args.queries.slice(0, 4).map((q, i) => ({ shape: `given:${i + 1}`, query: q.trim() }))
2155
+ : [
2156
+ { shape: "plain", query: core },
2157
+ // Reddit's own idiom, and it reads as a sentence now that `core`
2158
+ // has had its leading subject removed.
2159
+ { shape: "shared", query: `anyone else ${core}` },
2160
+ // A SUFFIX, not a prefix: appended keywords cannot be made
2161
+ // ungrammatical by whatever shape the core turned out to be.
2162
+ { shape: "asking", query: `${core} any recommendations` },
2163
+ ];
2131
2164
  const seen = new Set();
2132
2165
  const posts = [];
2133
2166
  const unavailable = [];
@@ -2157,6 +2190,41 @@ export function registerJobTools(server, makeClient, store) {
2157
2190
  }
2158
2191
  }
2159
2192
  }
2193
+ // Read the comments under the most promising results, on the MERGED,
2194
+ // deduped list rather than per search.
2195
+ //
2196
+ // That distinction is the whole reason this lives here and not in the
2197
+ // backend's own `include_comments`: three query shapes across two
2198
+ // platforms is six searches, and opening five posts under each would be
2199
+ // thirty paid calls for a tool whose description promises a wide CHEAP
2200
+ // net. On the merged list it is five, once.
2201
+ //
2202
+ // Why at all: on Reddit the person with the problem is at least as often
2203
+ // replying under someone else's thread as posting their own, and a
2204
+ // post-only search cannot see any of them (Nooticr/nooticr-server#63).
2205
+ if (args.readComments && posts.length) {
2206
+ const OPEN = 5;
2207
+ for (const post of posts.slice(0, OPEN)) {
2208
+ const url = String(post.externalUrl ?? post.url ?? "");
2209
+ if (!url)
2210
+ continue;
2211
+ try {
2212
+ const res = await client.callTool("get_post_comments", { url, limit: 8 });
2213
+ const structured = (res.structured ?? {});
2214
+ spend.record("get_post_comments", structured);
2215
+ const comments = Array.isArray(structured.comments) ? structured.comments : [];
2216
+ post.commentsRead = comments.length;
2217
+ post.commentSample = comments;
2218
+ }
2219
+ catch {
2220
+ // A thread that will not open is not a failed sweep — the post's
2221
+ // own text may carry the complaint, and this is the cheap net.
2222
+ // Stated rather than left absent, so a reader can tell "no
2223
+ // comments" from "not looked at".
2224
+ post.commentsRead = 0;
2225
+ }
2226
+ }
2227
+ }
2160
2228
  if (!posts.length) {
2161
2229
  return evidence([
2162
2230
  `Nothing came back for "${problem}" on ${platforms.join(", ")}.`,
@@ -2199,9 +2267,31 @@ export function registerJobTools(server, makeClient, store) {
2199
2267
  "and say how many you rejected and why — a shortlist with no reject count reads as if " +
2200
2268
  "the search was precise.",
2201
2269
  "",
2202
- "`foundBy` says which phrasing surfaced each post: `plain` the problem itself, `shared` " +
2203
- "the does-anyone-else form, `asking` the is-there-a-tool form. The last two skew " +
2204
- "towards genuine complaints and the first towards content about the topic.",
2270
+ args.readComments
2271
+ ? "The first few posts carry `commentSample`: the replies underneath them. Judge those " +
2272
+ "the same way — a person saying \"same here, this is exactly my problem\" under " +
2273
+ "someone else's thread is a better find than the thread itself, and the permalink " +
2274
+ "to quote is the post's. `commentsRead: 0` means the thread would not open, not " +
2275
+ "that nobody replied."
2276
+ : "Only the posts themselves were searched. On Reddit the person with the problem is " +
2277
+ "at least as often replying under someone else's thread as posting their own — if " +
2278
+ "these results look thin, call again with `readComments: true` to open the top few " +
2279
+ "and read what is underneath.",
2280
+ "",
2281
+ args.queries?.length
2282
+ ? "`foundBy` says which of your own phrasings surfaced each post, in the order you " +
2283
+ "gave them."
2284
+ : "`foundBy` says which phrasing surfaced each post: `plain` the problem itself, " +
2285
+ "`shared` the does-anyone-else form, `asking` the looking-for-a-tool form. The " +
2286
+ "last two skew towards genuine complaints and the first towards content about " +
2287
+ "the topic.",
2288
+ "",
2289
+ args.queries?.length
2290
+ ? ""
2291
+ : `These were searched as: ${shapes.map((sh) => `"${sh.query}"`).join(", ")}. That ` +
2292
+ "is a keyword search, not intent matching. If those read wrong for where you are " +
2293
+ "looking — a Reddit title is worded differently from an X post — call this again " +
2294
+ "with `queries` set to phrasings you write yourself, and keep each one short.",
2205
2295
  "",
2206
2296
  unavailable.length
2207
2297
  ? `Note that ${unavailable.length} searches errored — tell the user which networks ` +
@@ -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)."), readComments: z.boolean().optional().describe("Open the most promising results and read their comments too (default false). On Reddit especially, the person with the problem is at least as often replying under someone else's thread as posting their own, and a post-only search cannot see any of them. Costs 2 nooticr credits per post opened, up to 5 \u2014 and they are opened on the MERGED, deduped results, so it is five extra calls in total rather than five per query shape per platform."), 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",
@@ -26,7 +26,7 @@ import { loadingPlansJson } from "./loading-plans.js";
26
26
  import { registerHandoff } from "./handoff.js";
27
27
  import { registerCollabTools } from "./collab.js";
28
28
  /** Current MCP server version — bumped on every deploy for traceability. */
29
- export const MCP_SERVER_VERSION = "1.26.30";
29
+ export const MCP_SERVER_VERSION = "1.26.32";
30
30
  /** MCP Apps extension identifier */
31
31
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
32
32
  /** MIME type for MCP Apps HTML resources */
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.30",
4
+ "version": "1.26.32",
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": {