From d90b50508d5ff608aee5b1cd9919be286a7cd488 Mon Sep 17 00:00:00 2001 From: David Kelly <46355358+dkships@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:59:08 -0700 Subject: [PATCH 1/4] fix: restore thread-count severity signal and guard date parsing The severity formula's thread-depth term was dead in production: thread bodies were never fetched, so thread_count was always 0 and min(0*10, 50) contributed nothing. Use the list API's threads count field instead (no extra API calls) and treat 0/missing as the baseline 1. Also: - Clamp recency so an unparseable created_at can't produce NaN scores and future-dated tickets can't pin severity to 100 - Strip PII-redaction placeholders before n-gram tokenization so 'email redacted' can't surface as an emerging theme - Delete the unreachable thread-body fetch path (includeThreads was never set by any caller; last present at d5f985f) Co-Authored-By: Claude Fable 5 --- src/feedback-analyzer.test.ts | 45 +++++++++++++++++++++ src/feedback-analyzer.ts | 16 +++++--- src/helpscout.ts | 75 ++++------------------------------- src/index.ts | 9 ++--- src/productlift.ts | 1 + 5 files changed, 68 insertions(+), 78 deletions(-) diff --git a/src/feedback-analyzer.test.ts b/src/feedback-analyzer.test.ts index ffddca4..df0d486 100644 --- a/src/feedback-analyzer.test.ts +++ b/src/feedback-analyzer.test.ts @@ -167,6 +167,38 @@ describe("analyzeFeedback", () => { expect(r.unmatched_count).toBe(1); expect(r.themes).toEqual([]); }); + + it("gives threadCount 0 the same baseline severity as threadCount 1", () => { + const zero = analyzeFeedback([conv(1, "billing invoice", { threadCount: 0 })], [], config); + const one = analyzeFeedback([conv(1, "billing invoice", { threadCount: 1 })], [], config); + const zeroBilling = zero.themes.find((t) => t.theme_id === "billing")!; + const oneBilling = one.themes.find((t) => t.theme_id === "billing")!; + expect(zeroBilling.severity_score).toBe(oneBilling.severity_score); + }); + + it("scores deeper threads as more severe (thread-count term is live)", () => { + const shallow = analyzeFeedback([conv(1, "billing invoice", { threadCount: 1 })], [], config); + const deep = analyzeFeedback([conv(1, "billing invoice", { threadCount: 3 })], [], config); + const shallowBilling = shallow.themes.find((t) => t.theme_id === "billing")!; + const deepBilling = deep.themes.find((t) => t.theme_id === "billing")!; + expect(deepBilling.severity_score).toBeGreaterThan(shallowBilling.severity_score); + }); + + it("keeps scores finite when created_at is unparseable", () => { + const r = analyzeFeedback([conv(1, "billing invoice", { createdAt: "not-a-date" })], [], config); + const billing = r.themes.find((t) => t.theme_id === "billing")!; + expect(Number.isFinite(billing.severity_score)).toBe(true); + expect(Number.isFinite(billing.priority_score)).toBe(true); + }); + + it("clamps future-dated signals to at most the current-day recency boost", () => { + const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + const fut = analyzeFeedback([conv(1, "billing invoice", { createdAt: future })], [], config); + const current = analyzeFeedback([conv(1, "billing invoice", { createdAt: NOW })], [], config); + const futBilling = fut.themes.find((t) => t.theme_id === "billing")!; + const nowBilling = current.themes.find((t) => t.theme_id === "billing")!; + expect(futBilling.severity_score).toBeLessThanOrEqual(nowBilling.severity_score + 0.01); + }); }); // ── Emerging theme detection ── @@ -189,4 +221,17 @@ describe("emerging themes", () => { const r = analyzeFeedback([conv(1, "singular unmatched phrase zxqw")], [], config); expect(r.emerging_themes).toEqual([]); }); + + it("does not surface PII-redaction placeholders as emerging themes", () => { + const conversations = [ + conv(1, "zxqw problem", { preview: "reach me at [EMAIL REDACTED] about zxqw" }), + conv(2, "zxqw again", { preview: "call [PHONE REDACTED] regarding zxqw" }), + conv(3, "zxqw third", { preview: "my email [EMAIL REDACTED] bounced on zxqw" }), + ]; + const r = analyzeFeedback(conversations, [], config); + expect(r.unmatched_count).toBe(3); + for (const e of r.emerging_themes) { + expect(e.ngram).not.toMatch(/redacted/); + } + }); }); diff --git a/src/feedback-analyzer.ts b/src/feedback-analyzer.ts index 94d953c..20d6b2f 100644 --- a/src/feedback-analyzer.ts +++ b/src/feedback-analyzer.ts @@ -189,13 +189,17 @@ function computeSeverityScore(points: DataPoint[]): number { for (const p of reactive) { let pointScore = 0; - // Thread count contributes (more back-and-forth = more severe) - const threads = p.metadata.thread_count ?? 1; + // Thread count contributes (more back-and-forth = more severe). + // A zero/missing count gets the baseline of 1 so the term never zeroes out. + const threads = p.metadata.thread_count || 1; pointScore += Math.min(threads * 10, 50); // cap at 50 - // Recency: exponential decay (half-life of 7 days) - const ageMs = now - new Date(p.created_at).getTime(); - const ageDays = ageMs / (1000 * 60 * 60 * 24); + // Recency: exponential decay (half-life of 7 days). Unparseable dates get + // no boost (age treated as Infinity); future dates clamp to age 0. + const createdMs = new Date(p.created_at).getTime(); + const ageDays = Number.isFinite(createdMs) + ? Math.max(0, now - createdMs) / (1000 * 60 * 60 * 24) + : Infinity; const recencyBoost = 30 * Math.exp(-ageDays / 7); pointScore += recencyBoost; @@ -235,6 +239,8 @@ function computeVoteMomentum(points: DataPoint[]): number { function tokenize(text: string, stopWords: Set): string[] { return text + // Drop PII-redaction placeholders so "email redacted" never surfaces as an n-gram + .replace(/\[(?:ssn|cc|email|phone) redacted\]/gi, " ") .replace(/[^a-z0-9\s]/g, " ") .split(/\s+/) .filter((w) => w.length > 2 && !stopWords.has(w)); diff --git a/src/helpscout.ts b/src/helpscout.ts index 024b1c9..c827c6b 100644 --- a/src/helpscout.ts +++ b/src/helpscout.ts @@ -35,22 +35,12 @@ interface ConversationSummary { tags: Array<{ id: number; tag: string }>; primaryCustomer: { email: string }; preview: string; + // Total thread count from the list API — all thread types (customer + // messages, agent replies, notes), not just customer back-and-forth. + threads?: number; } -interface Thread { - id: number; - type: string; - body: string; - createdAt: string; - createdBy: { - type: "customer" | "user"; - email?: string; - }; -} - -export interface Conversation extends ConversationSummary { - threads: Thread[]; -} +export type Conversation = ConversationSummary; export interface Mailbox { id: number; @@ -60,7 +50,6 @@ export interface Mailbox { export interface FetchOptions { timeframeDays: number; mailboxId?: string; - includeThreads?: boolean; // default false — set true for full thread content } export class HelpScoutClient { @@ -245,14 +234,6 @@ export class HelpScoutClient { return mailboxes; } - private async fetchThreads(conversationId: number): Promise { - const data = await this.apiGet<{ - _embedded?: { threads: Thread[] }; - }>(`/conversations/${conversationId}/threads`); - - return data._embedded?.threads ?? []; - } - async fetchConversations(options: FetchOptions): Promise { const since = new Date(); since.setDate(since.getDate() - options.timeframeDays); @@ -282,49 +263,9 @@ export class HelpScoutClient { allConversations.push(...result.conversations); } - // Skip thread fetching unless explicitly requested — subject + preview - // is sufficient for theme analysis and avoids N+1 API calls - if (!options.includeThreads) { - return allConversations.map((conv) => ({ ...conv, threads: [] })); - } - - // Fetch threads with concurrency limit to stay under rate limit - const CONCURRENCY = 5; - const conversations: Conversation[] = []; - for (let i = 0; i < allConversations.length; i += CONCURRENCY) { - const batch = allConversations.slice(i, i + CONCURRENCY); - const results = await Promise.all( - batch.map(async (conv) => { - const threads = await this.fetchThreads(conv.id); - return { ...conv, threads } as Conversation; - }) - ); - conversations.push(...results); - } - - return conversations; + // Thread bodies are never fetched — excluded by design (avoids N+1 API + // calls and keeps raw message content out of the pipeline). The list + // API's `threads` count field is all downstream scoring needs. + return allConversations; } } - -/** Strip HTML tags from thread body text. */ -export function stripHtml(html: string): string { - return html - .replace(//gi, "\n") - .replace(/<\/p>/gi, "\n") - .replace(/<[^>]+>/g, "") - .replace(/ /g, " ") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/'/g, "'") - .replace(/"/g, '"') - .trim(); -} - -/** Extract customer messages from a conversation's threads */ -export function extractCustomerMessages(conv: Conversation): string[] { - return conv.threads - .filter((t) => t.createdBy.type === "customer" && t.body) - .map((t) => stripHtml(t.body)) - .filter((text) => text.length > 0); -} diff --git a/src/index.ts b/src/index.ts index 956deae..24bd83b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,6 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { z } from "zod"; import { HelpScoutClient, - extractCustomerMessages, type Conversation, type Mailbox, } from "./helpscout.js"; @@ -80,10 +79,8 @@ server.registerResource( let piiCategoriesLog: Set = new Set(); function formatConversation(conv: Conversation): FormattedConversation { - // Use thread content when available, fall back to subject + preview - const rawMessages = conv.threads.length > 0 - ? extractCustomerMessages(conv) - : [conv.preview].filter(Boolean); + // Thread bodies are excluded by design — subject + preview carry the customer voice + const rawMessages = [conv.preview].filter(Boolean); const { texts: customerMessages, piiCategoriesFound } = scrubPiiArray(rawMessages); const subjectScrub = scrubPii(conv.subject ?? ""); const previewScrub = scrubPii(conv.preview ?? ""); @@ -101,7 +98,7 @@ function formatConversation(conv: Conversation): FormattedConversation { tags: conv.tags?.map((t) => t.tag) ?? [], preview: previewScrub.text, customerMessages, - threadCount: conv.threads.length, + threadCount: conv.threads ?? 0, }; } diff --git a/src/productlift.ts b/src/productlift.ts index 37dca11..e3f8050 100644 --- a/src/productlift.ts +++ b/src/productlift.ts @@ -65,6 +65,7 @@ export class ProductLiftClient { static filterRecent(posts: PostSummary[], sinceDaysAgo: number): PostSummary[] { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - sinceDaysAgo); + // An unparseable created_at fails the >= comparison and is excluded — fails safe. return posts.filter((p) => new Date(p.created_at) >= cutoff); } From 39504d7a5281927ad0e58ba2034b19a9dabc59c2 Mon Sep 17 00:00:00 2001 From: David Kelly <46355358+dkships@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:07:02 -0700 Subject: [PATCH 2/4] fix: per-portal resilience, explicit PII sink, honest preview audit - One failing ProductLift portal no longer drops every portal's data; failures surface as named-portal warnings in the response - Total fetch failure (all sources) returns isError instead of an empty analysis that reads as success, and is never cached - Replace the module-global PII category log with an explicit per-request sink; concurrent tool calls no longer interleave audit metadata - get_feature_requests reports pii_scrubbing_applied and pii_categories_redacted, and stops sending commenter names (role only) - A malformed PRODUCTLIFT_PORTALS degrades to zero portals with the error surfaced in tool descriptions instead of killing the server - Label feature requests with their actual source portal - Guard themes.config.json loading with an actionable error - Scrub upstream error text in warnings; collapse whitespace in quotes - preview_only now describes exactly what is fetched and sent Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 37 +++++++ src/feedback-analyzer.ts | 21 +++- src/index.ts | 204 ++++++++++++++++++++++++++++----------- src/productlift.ts | 10 +- 4 files changed, 211 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68bc71a..1159947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,52 @@ All notable changes to this project are documented here. Format follows [Keep a ### Fixed +- The thread-depth severity signal is live again. Thread bodies were never fetched, so every + conversation scored `thread_count: 0` and the `min(threads * 10, 50)` severity term always + contributed nothing. The count now comes from the HelpScout list API's `threads` field (no + extra API calls). Note: this is the total thread count (including agent replies and notes), + and a zero/missing count scores as the baseline 1. +- One failing ProductLift portal no longer drops every portal's data from + `synthesize_feedback` / `generate_product_plan` / `get_feature_requests`. Failures surface + as per-portal warnings in the response. +- A total fetch failure (all sources down) now returns `isError` instead of an empty analysis + that reads as a successful result, and failures are never cached. +- Concurrent tool calls no longer interleave PII audit metadata: the module-global category + log was replaced with an explicit per-request sink. Scrubbing itself was never affected. +- Feature requests are labeled with their actual source portal; previously multi-portal + fetches stamped every request with the filter value (`"all"`). +- `generate_product_plan` `preview_only` now describes exactly what is fetched and sent. + It previously claimed comment text and customer message bodies were sent; the analysis + path fetches neither. +- Unparseable `created_at` dates no longer produce NaN priority scores, and future-dated + tickets no longer pin severity to the cap. +- PII-redaction placeholders (`[EMAIL REDACTED]` etc.) are stripped before n-gram detection, + so "email redacted" can no longer surface as an emerging theme. +- A malformed `PRODUCTLIFT_PORTALS` no longer kills the server at startup (taking the + HelpScout tools with it). The server starts with zero portals and surfaces the config + error in tool descriptions and `list_sources`. Portal fields are also trimmed now. +- `themes.config.json` load failures return an actionable error naming the path and problem. - `.env` is now authoritative: load it with `dotenv` `override: true` so a stale variable already exported in the shell/parent environment (e.g. an old `PRODUCTLIFT_PORTALS`) no longer shadows edits to `.env`. Previously, a pre-set var made `.env` changes appear to have no effect. ### Changed +- `get_feature_requests` responses now include `pii_scrubbing_applied` and + `pii_categories_redacted`, matching the other tools. +- Commenter names are no longer sent in `get_feature_requests` output — only the commenter + role (e.g. `admin`). Names were the one identity field that bypassed the scrubbing + guarantee; this aligns with the existing voter-identity exclusion. +- Upstream API error text is PII-scrubbed before it enters response warnings. - `.env.example` uses generic portal names (`acme` / `beta`) instead of specific product names, matching the `roadmap.example.com` convention used elsewhere in the repo. +### Removed + +- The unreachable thread-body fetch path (`includeThreads`, `fetchThreads`, `stripHtml`, + `extractCustomerMessages`) in `src/helpscout.ts`. No caller ever set `includeThreads`; + raw thread bodies remain excluded by design pending a privacy review. + ## [1.2.0] — 2026-05-31 ### Added diff --git a/src/feedback-analyzer.ts b/src/feedback-analyzer.ts index 20d6b2f..708cda0 100644 --- a/src/feedback-analyzer.ts +++ b/src/feedback-analyzer.ts @@ -95,8 +95,8 @@ export interface FormattedFeatureRequest { url?: string; created_at: string; updated_at: string; + // Commenter names are deliberately excluded — only the role leaves the server comments: Array<{ - author: string; role: string; comment: string; created_at: string | null; @@ -108,8 +108,23 @@ export interface FormattedFeatureRequest { export function loadThemesConfig(): ThemesConfig { const __dirname = dirname(fileURLToPath(import.meta.url)); const configPath = resolve(__dirname, "..", "themes.config.json"); - const raw = readFileSync(configPath, "utf-8"); - return JSON.parse(raw) as ThemesConfig; + let config: ThemesConfig; + try { + const raw = readFileSync(configPath, "utf-8"); + config = JSON.parse(raw) as ThemesConfig; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to load themes.config.json (${configPath}): ${msg}. ` + + "The file must exist at the project root and contain valid JSON." + ); + } + if (!Array.isArray(config.themes)) { + throw new Error( + 'Invalid themes.config.json: "themes" must be an array of theme definitions.' + ); + } + return config; } // ── Data normalization ── diff --git a/src/index.ts b/src/index.ts index 24bd83b..09d5125 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ import { ProductLiftClient, parsePortalConfigs, } from "./productlift.js"; -import type { FeatureRequest, Comment } from "./productlift.js"; +import type { FeatureRequest, Comment, PortalConfig } from "./productlift.js"; import { analyzeFeedback, loadThemesConfig, @@ -43,10 +43,27 @@ if (!HELPSCOUT_APP_ID || !HELPSCOUT_APP_SECRET) { const helpscout = new HelpScoutClient(HELPSCOUT_APP_ID, HELPSCOUT_APP_SECRET); -// ProductLift setup -const portalConfigs = parsePortalConfigs(); +// ProductLift setup — a bad portal config degrades to zero portals instead of +// killing the HelpScout tools with it. The error is surfaced in tool +// descriptions and list_sources so it's discoverable from the client. +let portalConfigs: PortalConfig[] = []; +let portalConfigError: string | undefined; +try { + portalConfigs = parsePortalConfigs(); +} catch (error) { + portalConfigError = error instanceof Error ? error.message : String(error); + console.error(`[pm-copilot] ProductLift config error: ${portalConfigError}`); +} const productliftClients = portalConfigs.map((c) => new ProductLiftClient(c)); +function describePortals(): string { + if (portalConfigError) return `none (config error: ${portalConfigError})`; + if (portalConfigs.length === 0) { + return "none (set PRODUCTLIFT_PORTALS or PRODUCTLIFT_PORTAL_URL in .env)"; + } + return portalConfigs.map((c) => c.name).join(", "); +} + const server = new McpServer({ name: "pm-copilot", version: "1.2.0", @@ -75,17 +92,16 @@ server.registerResource( }) ); -// Track PII categories found across all data in a request -let piiCategoriesLog: Set = new Set(); - -function formatConversation(conv: Conversation): FormattedConversation { +// PII categories found while formatting are collected into an explicit +// per-request sink (never a module global — concurrent tool calls interleave). +function formatConversation(conv: Conversation, piiSink: Set): FormattedConversation { // Thread bodies are excluded by design — subject + preview carry the customer voice const rawMessages = [conv.preview].filter(Boolean); const { texts: customerMessages, piiCategoriesFound } = scrubPiiArray(rawMessages); const subjectScrub = scrubPii(conv.subject ?? ""); const previewScrub = scrubPii(conv.preview ?? ""); for (const cat of [...piiCategoriesFound, ...subjectScrub.piiCategoriesFound, ...previewScrub.piiCategoriesFound]) { - piiCategoriesLog.add(cat); + piiSink.add(cat); } return { id: conv.id, @@ -118,6 +134,9 @@ interface FetchedData { piiCategoriesRedacted: string[]; dataSources: string[]; warnings: string[]; + // True when every configured source failed — callers should return an error + // instead of presenting an empty analysis as a successful result. + fetchFailed: boolean; } function filterClientsByPortal(portalName: string | undefined): ProductLiftClient[] { @@ -155,15 +174,17 @@ async function resolveMailboxId( async function fetchProductLift( params: FetchParams, + piiSink: Set, includeComments = false -): Promise { - if (portalConfigs.length === 0) return []; +): Promise<{ requests: FormattedFeatureRequest[]; warnings: string[] }> { + if (portalConfigs.length === 0) return { requests: [], warnings: [] }; const clients = filterClientsByPortal(params.portal_name); - if (clients.length === 0) return []; + if (clients.length === 0) return { requests: [], warnings: [] }; - // Fetch all portals in parallel - const results = await Promise.all( + // Fetch portals in parallel, each isolated — one failing portal becomes a + // warning instead of dropping every portal's data + const results = await Promise.allSettled( clients.map(async (client) => { const posts = await client.fetchPosts(); @@ -189,18 +210,35 @@ async function fetchProductLift( } formatted.push( - formatFeatureRequest({ - ...post, - comments, - portal: params.portal_name ?? "all", - }) + formatFeatureRequest( + { + ...post, + comments, + portal: client.portalName, + }, + piiSink + ) ); } return formatted; }) ); - return results.flat(); + const requests: FormattedFeatureRequest[] = []; + const warnings: string[] = []; + results.forEach((result, i) => { + if (result.status === "fulfilled") { + requests.push(...result.value); + } else { + const msg = + result.reason instanceof Error ? result.reason.message : String(result.reason); + warnings.push( + scrubPii(`ProductLift portal "${clients[i]?.portalName}" fetch failed: ${msg}`).text + ); + } + }); + + return { requests, warnings }; } // ── Response cache ── @@ -230,7 +268,10 @@ async function cachedFetchAndAnalyze(params: FetchParams): Promise console.error(`[pm-copilot] Cache miss (key=${key}), fetching fresh data...`); const data = await fetchAndAnalyze(params); - fetchCache.set(key, { data, timestamp: Date.now() }); + // Never cache a total fetch failure — a transient outage shouldn't stick for 5 minutes + if (!data.fetchFailed) { + fetchCache.set(key, { data, timestamp: Date.now() }); + } // Evict expired entries for (const [k, entry] of fetchCache) { @@ -242,9 +283,6 @@ async function cachedFetchAndAnalyze(params: FetchParams): Promise async function fetchAndAnalyze(params: FetchParams): Promise { const piiCategories = new Set(); - const prevLog = piiCategoriesLog; - piiCategoriesLog = piiCategories; - const warnings: string[] = []; // Fetch both sources independently — one failing doesn't block the other @@ -254,8 +292,8 @@ async function fetchAndAnalyze(params: FetchParams): Promise { timeframeDays: params.timeframe_days, mailboxId: params.mailbox_id, }) - .then((convs) => convs.map(formatConversation)), - fetchProductLift(params), + .then((convs) => convs.map((c) => formatConversation(c, piiCategories))), + fetchProductLift(params, piiCategories), ]); let conversations: FormattedConversation[] = []; @@ -265,23 +303,31 @@ async function fetchAndAnalyze(params: FetchParams): Promise { const msg = hsResult.reason instanceof Error ? hsResult.reason.message : String(hsResult.reason); - warnings.push(`HelpScout fetch failed: ${msg}`); + warnings.push(scrubPii(`HelpScout fetch failed: ${msg}`).text); console.error(`[pm-copilot] HelpScout error: ${msg}`); } let featureRequests: FormattedFeatureRequest[] = []; + let productliftFailed = false; if (plResult.status === "fulfilled") { - featureRequests = plResult.value; + featureRequests = plResult.value.requests; + warnings.push(...plResult.value.warnings); + productliftFailed = + featureRequests.length === 0 && plResult.value.warnings.length > 0; } else { + productliftFailed = true; const msg = plResult.reason instanceof Error ? plResult.reason.message : String(plResult.reason); - warnings.push(`ProductLift fetch failed: ${msg}`); + warnings.push(scrubPii(`ProductLift fetch failed: ${msg}`).text); console.error(`[pm-copilot] ProductLift error: ${msg}`); } - // Restore previous PII log reference (in case of concurrent calls) - piiCategoriesLog = prevLog; + // Total failure = HelpScout failed AND ProductLift failed or isn't configured. + // An unconfigured ProductLift alone is a legitimate HelpScout-only setup. + const fetchFailed = + hsResult.status === "rejected" && + (portalConfigs.length === 0 || productliftFailed); const config = loadThemesConfig(); const analysis = analyzeFeedback(conversations, featureRequests, config); @@ -306,6 +352,7 @@ async function fetchAndAnalyze(params: FetchParams): Promise { piiCategoriesRedacted: [...piiCategories], dataSources, warnings, + fetchFailed, }; } @@ -317,7 +364,7 @@ server.registerTool("synthesize_feedback", { "Cross-reference support tickets (HelpScout) and feature requests (ProductLift) " + "to find convergent signals. Returns theme-matched analysis with priority scores. " + "Convergent themes (appearing in both sources) get a 2x priority boost. " + - `Configured portals: ${portalConfigs.length > 0 ? portalConfigs.map((c) => c.name).join(", ") : "none"}`, + `Configured portals: ${describePortals()}`, inputSchema: { timeframe_days: z .number() @@ -369,6 +416,18 @@ server.registerTool("synthesize_feedback", { portal_name, }); + if (data.fetchFailed) { + return { + content: [ + { + type: "text" as const, + text: `Error: all data sources failed to fetch.\n${data.warnings.join("\n")}`, + }, + ], + isError: true, + }; + } + const trimmedAnalysis = trimAnalysisForDetail( data.analysis, detail_level, @@ -410,11 +469,11 @@ server.registerTool("synthesize_feedback", { } }); -function formatFeatureRequest(req: FeatureRequest): FormattedFeatureRequest { +function formatFeatureRequest(req: FeatureRequest, piiSink: Set): FormattedFeatureRequest { const titleScrub = scrubPii(req.title); const descScrub = scrubPii(req.description); for (const cat of [...titleScrub.piiCategoriesFound, ...descScrub.piiCategoriesFound]) { - piiCategoriesLog.add(cat); + piiSink.add(cat); } return { id: req.id, @@ -430,9 +489,10 @@ function formatFeatureRequest(req: FeatureRequest): FormattedFeatureRequest { updated_at: req.updated_at, comments: req.comments.map((c) => { const commentScrub = scrubPii(c.comment); - for (const cat of commentScrub.piiCategoriesFound) piiCategoriesLog.add(cat); + for (const cat of commentScrub.piiCategoriesFound) piiSink.add(cat); + // Commenter names are deliberately dropped — only the role leaves the + // server, consistent with the voter-identity exclusion. return { - author: c.author.name, role: c.author.role, comment: commentScrub.text, created_at: c.created_at, @@ -504,8 +564,9 @@ function extractQuotesForTheme( const conv = conversationMap.get(dp.id); if (!conv) continue; - // Prefer customer message that isn't agent text, fall back to subject - const msg = conv.customerMessages[0] ?? ""; + // Prefer customer message that isn't agent text, fall back to subject. + // Collapse whitespace so embedded newlines can't break markdown output. + const msg = (conv.customerMessages[0] ?? "").replace(/\s+/g, " ").trim(); if (msg && !isLikelyAgentResponse(msg)) { const truncated = msg.length > 200 ? msg.slice(0, 197) + "..." : msg; quotes.push(`[Support ticket] "${truncated}"`); @@ -518,7 +579,7 @@ function extractQuotesForTheme( if (req) { const customerComment = req.comments.find((c) => c.role !== "admin"); if (customerComment && !isLikelyAgentResponse(customerComment.comment)) { - const msg = customerComment.comment; + const msg = customerComment.comment.replace(/\s+/g, " ").trim(); const truncated = msg.length > 200 ? msg.slice(0, 197) + "..." : msg; quotes.push(`[Feature request, ${req.votes_count} votes] "${truncated}"`); } else { @@ -742,7 +803,7 @@ server.registerTool("generate_product_plan", { "MCP servers (Metabase, GA, etc.) via kpi_context to inform prioritization. " + "References the pm-copilot://methodology resource for planning framework. " + "Returns top priorities with evidence, customer quotes, and recommended actions. " + - `Configured portals: ${portalConfigs.length > 0 ? portalConfigs.map((c) => c.name).join(", ") : "none"}`, + `Configured portals: ${describePortals()}`, inputSchema: { timeframe_days: z .number() @@ -832,18 +893,18 @@ server.registerTool("generate_product_plan", { description: "This is a preview of what data would be fetched and sent to Claude.", data_sources: previewSources, helpscout: { - will_fetch: "support conversations", + will_fetch: "support conversation summaries (subject + preview, not full message bodies)", timeframe_days, mailbox_filter: mailbox_name ?? mailbox_id ?? "all", - fields_sent: ["subject (PII-scrubbed)", "customer messages (PII-scrubbed)", "tags", "thread count", "status"], - fields_NOT_sent: ["customer email (always redacted)", "agent responses", "internal notes", "attachments"], + fields_sent: ["subject (PII-scrubbed)", "preview snippet (PII-scrubbed)", "tags", "status", "created/closed timestamps", "thread count"], + fields_NOT_sent: ["customer email (always redacted)", "full thread/message bodies (never fetched)", "attachments"], }, productlift: { - will_fetch: portalConfigs.length > 0 ? "feature request posts + comments" : "SKIPPED (not configured)", + will_fetch: portalConfigs.length > 0 ? "feature request posts (comment text is NOT fetched by this tool)" : "SKIPPED (not configured)", portals: filteredPortals, top_voted_limit, - fields_sent: ["title (PII-scrubbed)", "description (PII-scrubbed)", "vote count", "comment text (PII-scrubbed)", "status"], - fields_NOT_sent: ["voter identities", "commenter emails", "internal admin notes"], + fields_sent: ["title (PII-scrubbed)", "description (PII-scrubbed)", "vote count", "comment count", "status", "timestamps"], + fields_NOT_sent: ["comment text (not fetched by this tool)", "voter identities", "commenter names and emails"], }, pii_scrubbing: { enabled: true, @@ -873,6 +934,18 @@ server.registerTool("generate_product_plan", { portal_name, }); + if (data.fetchFailed) { + return { + content: [ + { + type: "text" as const, + text: `Error: all data sources failed to fetch.\n${data.warnings.join("\n")}`, + }, + ], + isError: true, + }; + } + // Build lookup maps for quote extraction const conversationMap = new Map(); for (const conv of data.conversations) { @@ -1024,7 +1097,7 @@ server.registerTool("get_feature_requests", { "Pull feature requests from ProductLift portals. " + "Returns posts with vote counts, statuses, categories, and comments. " + "Use this to understand what customers are asking for and prioritize the roadmap. " + - `Configured portals: ${portalConfigs.length > 0 ? portalConfigs.map((c) => c.name).join(", ") : "none (set PRODUCTLIFT_PORTALS or PRODUCTLIFT_PORTAL_URL in .env)"}`, + `Configured portals: ${describePortals()}`, inputSchema: { portal_name: z .string() @@ -1046,13 +1119,11 @@ server.registerTool("get_feature_requests", { }, }, async ({ portal_name, include_comments, status }) => { if (portalConfigs.length === 0) { + const detail = portalConfigError + ? `ProductLift config error: ${portalConfigError}` + : "No ProductLift portals configured. Set PRODUCTLIFT_PORTALS or PRODUCTLIFT_PORTAL_URL + PRODUCTLIFT_API_KEY in .env"; return { - content: [ - { - type: "text" as const, - text: "Error: No ProductLift portals configured. Set PRODUCTLIFT_PORTALS or PRODUCTLIFT_PORTAL_URL + PRODUCTLIFT_API_KEY in .env", - }, - ], + content: [{ type: "text" as const, text: `Error: ${detail}` }], isError: true, }; } @@ -1077,13 +1148,30 @@ server.registerTool("get_feature_requests", { }; } + // Fetch each portal independently — one failing portal becomes a warning const allRequests: FeatureRequest[] = []; + const warnings: string[] = []; for (const client of clients) { - const requests = await client.fetchFeatureRequests(include_comments); - allRequests.push(...requests); + try { + const requests = await client.fetchFeatureRequests(include_comments); + allRequests.push(...requests); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + warnings.push( + scrubPii(`ProductLift portal "${client.portalName}" fetch failed: ${msg}`).text + ); + } } - const allFormatted = allRequests.map(formatFeatureRequest); + if (allRequests.length === 0 && warnings.length > 0) { + return { + content: [{ type: "text" as const, text: `Error: ${warnings.join("; ")}` }], + isError: true, + }; + } + + const piiCategories = new Set(); + const allFormatted = allRequests.map((r) => formatFeatureRequest(r, piiCategories)); const formatted = status ? allFormatted.filter( (r) => r.status?.toLowerCase() === status.toLowerCase() @@ -1100,6 +1188,9 @@ server.registerTool("get_feature_requests", { status_filter: status ?? "all", total_feature_requests: formatted.length, fetched_at: new Date().toISOString(), + pii_scrubbing_applied: true, + pii_categories_redacted: [...piiCategories], + ...(warnings.length > 0 && { warnings }), feature_requests: formatted, }, null, @@ -1134,6 +1225,9 @@ server.registerTool("list_sources", { let helpscout_mailboxes: Mailbox[] = []; const warnings: string[] = []; + if (portalConfigError) { + warnings.push(`ProductLift config error: ${portalConfigError}`); + } try { helpscout_mailboxes = await helpscout.fetchMailboxes(); } catch (error) { diff --git a/src/productlift.ts b/src/productlift.ts index e3f8050..16e3cd8 100644 --- a/src/productlift.ts +++ b/src/productlift.ts @@ -62,6 +62,10 @@ export class ProductLiftClient { this.portal = portal; } + get portalName(): string { + return this.portal.name; + } + static filterRecent(posts: PostSummary[], sinceDaysAgo: number): PostSummary[] { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - sinceDaysAgo); @@ -176,9 +180,9 @@ export function parsePortalConfigs(): PortalConfig[] { if (portals) { return portals.split(",").map((entry) => { const parts = entry.trim().split("|"); - const name = parts[0]; - const baseUrl = parts[1]; - const apiKey = parts.slice(2).join("|"); // rejoin — tokens may contain | + const name = parts[0]?.trim(); + const baseUrl = parts[1]?.trim(); + const apiKey = parts.slice(2).join("|").trim(); // rejoin — tokens may contain | if (!name || !baseUrl || !apiKey) { throw new Error( `Invalid PRODUCTLIFT_PORTALS format. Expected "name|url|key" per entry, got: ${entry}` From c72f57f5d4cb841207047522230281ede6c5aa88 Mon Sep 17 00:00:00 2001 From: David Kelly <46355358+dkships@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:16:02 -0700 Subject: [PATCH 3/4] refactor: extract format layer, dedup helpers, precompile keyword regexes Pure move plus dedup; no behavior change. analyzeFeedback output verified byte-for-byte identical on a fixed dataset across this change. - New src/format.ts holds the PII-scrubbing formatters, quote extraction, agent-response detection, and detail trimming (index.ts is untestable by import: it exits without env and connects a transport at load) - New helpers replace duplicated blocks in index.ts: toErrorResult (4 identical catch bodies), buildLookupMaps, signalTypeOf, capTitles; get_feature_requests reuses filterClientsByPortal - Theme keyword regexes compile once per analyzeFeedback run instead of per data point x theme x keyword; matchesTheme keeps its signature and semantics (multi-word substring, word-boundary singles, escaping, no g flag) - New guardrail tests for parsePortalConfigs env parsing and the format layer (24 new tests; 64 total) Co-Authored-By: Claude Fable 5 --- src/feedback-analyzer.ts | 46 ++++-- src/format.test.ts | 256 ++++++++++++++++++++++++++++++ src/format.ts | 312 ++++++++++++++++++++++++++++++++++++ src/index.ts | 332 +++------------------------------------ src/productlift.test.ts | 88 +++++++++++ 5 files changed, 715 insertions(+), 319 deletions(-) create mode 100644 src/format.test.ts create mode 100644 src/format.ts create mode 100644 src/productlift.test.ts diff --git a/src/feedback-analyzer.ts b/src/feedback-analyzer.ts index 708cda0..d4914b5 100644 --- a/src/feedback-analyzer.ts +++ b/src/feedback-analyzer.ts @@ -166,20 +166,42 @@ function featureRequestToDataPoint(req: FormattedFeatureRequest): DataPoint { // ── Theme matching ── -export function matchesTheme(text: string, keywords: string[]): boolean { +// Keywords compiled once per analysis run instead of per data point. +// Semantics are identical to matching the raw keyword list: multi-word +// keywords match as substrings, single-word keywords on a word boundary +// (escaped, case-insensitive, no `g` flag — a shared `g` regex is stateful). +interface CompiledKeywords { + substrings: string[]; + wordRegexes: RegExp[]; +} + +function compileKeywords(keywords: string[]): CompiledKeywords { + const substrings: string[] = []; + const wordRegexes: RegExp[] = []; for (const kw of keywords) { if (kw.includes(" ")) { - // Multi-word: substring match - if (text.includes(kw.toLowerCase())) return true; + substrings.push(kw.toLowerCase()); } else { - // Single-word: word boundary regex - const regex = new RegExp(`\\b${escapeRegex(kw)}\\b`, "i"); - if (regex.test(text)) return true; + wordRegexes.push(new RegExp(`\\b${escapeRegex(kw)}\\b`, "i")); } } + return { substrings, wordRegexes }; +} + +function matchesCompiled(text: string, compiled: CompiledKeywords): boolean { + for (const s of compiled.substrings) { + if (text.includes(s)) return true; + } + for (const r of compiled.wordRegexes) { + if (r.test(text)) return true; + } return false; } +export function matchesTheme(text: string, keywords: string[]): boolean { + return matchesCompiled(text, compileKeywords(keywords)); +} + function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -333,17 +355,23 @@ export function analyzeFeedback( ...featureRequests.map(featureRequestToDataPoint), ]; - // Match data points to themes + // Match data points to themes. Compile keywords once per run — the config + // is re-read per request (hot-editable), so this must not be module-cached. const themeMatches = new Map(); const matchedIds = new Set(); + const compiledThemes = config.themes.map((theme) => ({ + theme, + compiled: compileKeywords(theme.keywords), + })); + for (const theme of config.themes) { themeMatches.set(theme.id, []); } for (const dp of dataPoints) { - for (const theme of config.themes) { - if (matchesTheme(dp.text, theme.keywords)) { + for (const { theme, compiled } of compiledThemes) { + if (matchesCompiled(dp.text, compiled)) { themeMatches.get(theme.id)?.push(dp); matchedIds.add(dp.id); } diff --git a/src/format.test.ts b/src/format.test.ts new file mode 100644 index 0000000..9f345cc --- /dev/null +++ b/src/format.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect } from "vitest"; +import { + formatConversation, + formatFeatureRequest, + trimAnalysisForDetail, + capTitles, + signalTypeOf, + toErrorResult, +} from "./format.js"; +import type { Conversation } from "./helpscout.js"; +import type { FeatureRequest } from "./productlift.js"; +import type { AnalysisResult, ThemeMatch } from "./feedback-analyzer.js"; + +// ── Fixtures ── + +function conv(overrides: Partial = {}): Conversation { + return { + id: 1, + number: 101, + subject: "Billing question", + status: "active", + state: "published", + mailboxId: 10, + createdAt: "2026-06-01T00:00:00Z", + closedAt: null, + tags: [{ id: 1, tag: "bug" }], + primaryCustomer: { email: "jane@example.com" }, + preview: "Please check my invoice", + threads: 4, + ...overrides, + }; +} + +function featureRequest(overrides: Partial = {}): FeatureRequest { + return { + id: "a", + title: "CSV export", + description: "Let me export my data", + status: { id: 1, name: "open", color: "#fff" }, + category: null, + votes_count: 12, + comments_count: 1, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-02T00:00:00Z", + url: "https://roadmap.acme.com/p/csv-export", + portal: "acme", + comments: [ + { + id: 1, + comment: "Need this, reach me at jane@example.com", + author: { id: "u1", name: "Jane Doe", role: "user" }, + pinned_to_top: false, + tagged_for_changelog: false, + parent_id: null, + created_at: "2026-06-01T12:00:00Z", + updated_at: null, + url: "https://roadmap.acme.com/p/csv-export#c1", + }, + ], + ...overrides, + }; +} + +function theme(overrides: Partial = {}): ThemeMatch { + return { + theme_id: "billing", + label: "Billing", + category: "billing", + reactive_count: 1, + proactive_count: 1, + convergent: true, + frequency_score: 100, + severity_score: 50, + vote_momentum_score: 80, + priority_score: 150.5, + data_points: [ + { id: "hs-1", source: "REACTIVE", title: "Billing question" }, + { id: "pl-a", source: "PROACTIVE", title: "CSV export" }, + ], + ...overrides, + }; +} + +function analysis(overrides: Partial = {}): AnalysisResult { + return { + config_version: 2, + known_themes_count: 16, + total_data_points: 2, + reactive_count: 1, + proactive_count: 1, + themes: [theme()], + emerging_themes: [ + { + ngram: "dark mode", + frequency: 3, + data_points: [{ id: "hs-9", source: "REACTIVE", title: "dark mode please" }], + }, + ], + unmatched_count: 3, + ...overrides, + }; +} + +// ── formatConversation ── + +describe("formatConversation", () => { + it("maps the list API thread count to threadCount, defaulting to 0", () => { + const sink = new Set(); + expect(formatConversation(conv({ threads: 4 }), sink).threadCount).toBe(4); + expect(formatConversation(conv({ threads: undefined }), sink).threadCount).toBe(0); + }); + + it("always redacts the customer email field", () => { + const sink = new Set(); + const formatted = formatConversation(conv(), sink); + expect(formatted.customerEmail).toBe("[REDACTED]"); + }); + + it("scrubs PII from subject and preview and reports categories to the sink", () => { + const sink = new Set(); + const formatted = formatConversation( + conv({ preview: "reach me at jane@example.com or 555-123-4567" }), + sink + ); + expect(formatted.preview).not.toContain("jane@example.com"); + expect(formatted.customerMessages[0]).not.toContain("jane@example.com"); + expect([...sink].sort()).toEqual(["email", "phone"]); + }); +}); + +// ── formatFeatureRequest ── + +describe("formatFeatureRequest", () => { + it("drops commenter names, keeping only role, comment, and timestamp", () => { + const sink = new Set(); + const formatted = formatFeatureRequest(featureRequest(), sink); + const comment = formatted.comments[0]!; + expect(Object.keys(comment).sort()).toEqual(["comment", "created_at", "role"]); + expect(JSON.stringify(formatted)).not.toContain("Jane Doe"); + }); + + it("scrubs comment text and reports categories to the sink", () => { + const sink = new Set(); + const formatted = formatFeatureRequest(featureRequest(), sink); + expect(formatted.comments[0]!.comment).not.toContain("jane@example.com"); + expect(sink.has("email")).toBe(true); + }); + + it("keeps the source portal label", () => { + const sink = new Set(); + expect(formatFeatureRequest(featureRequest({ portal: "beta" }), sink).portal).toBe("beta"); + }); +}); + +// ── trimAnalysisForDetail ── + +describe("trimAnalysisForDetail", () => { + const sink = new Set(); + const conversations = [formatConversation(conv(), sink)]; + const featureRequests = [formatFeatureRequest(featureRequest(), sink)]; + + it("returns the analysis unchanged at full detail", () => { + const a = analysis(); + expect(trimAnalysisForDetail(a, "full", conversations, featureRequests)).toBe(a); + }); + + it("omits sub-scores and titles at summary detail", () => { + const result = trimAnalysisForDetail( + analysis(), + "summary", + conversations, + featureRequests + ) as { themes: Array>; emerging_themes: Array> }; + + const t = result.themes[0]!; + expect(t.signal_type).toBe("convergent"); + expect(t.evidence_summary).toContain("2 signals"); + expect(t.representative_quotes).toBeDefined(); + expect(t.frequency_score).toBeUndefined(); + expect(t.data_point_titles).toBeUndefined(); + expect(result.emerging_themes[0]!.sample_titles).toBeUndefined(); + }); + + it("adds sub-scores and capped titles at standard detail", () => { + const result = trimAnalysisForDetail( + analysis(), + "standard", + conversations, + featureRequests + ) as { themes: Array> }; + + const t = result.themes[0]!; + expect(t.frequency_score).toBe(100); + expect(t.data_points_total).toBe(2); + expect(t.data_point_titles).toEqual(["Billing question", "CSV export"]); + expect(t.data_point_titles_truncated).toBeUndefined(); + }); + + it("produces single-line quotes even when messages contain newlines", () => { + const sink2 = new Set(); + const multiline = [ + formatConversation(conv({ preview: "line one\nline two about billing" }), sink2), + ]; + const result = trimAnalysisForDetail( + analysis(), + "summary", + multiline, + featureRequests + ) as { themes: Array<{ representative_quotes: string[] }> }; + for (const q of result.themes[0]!.representative_quotes) { + expect(q).not.toContain("\n"); + } + }); +}); + +// ── helpers ── + +describe("capTitles", () => { + it("caps titles at 50 and sets the truncation flag", () => { + const dataPoints = Array.from({ length: 60 }, (_, i) => ({ + id: `hs-${i}`, + source: "REACTIVE" as const, + title: `title ${i}`, + })); + const capped = capTitles(dataPoints); + expect(capped.data_points_total).toBe(60); + expect(capped.data_point_titles).toHaveLength(50); + expect(capped.data_point_titles_truncated).toBe(true); + }); + + it("omits the truncation flag when under the cap", () => { + const capped = capTitles([{ id: "hs-1", source: "REACTIVE", title: "one" }]); + expect(capped.data_points_total).toBe(1); + expect("data_point_titles_truncated" in capped).toBe(false); + }); +}); + +describe("signalTypeOf", () => { + it("classifies convergent, reactive, and proactive themes", () => { + expect(signalTypeOf(theme({ convergent: true }))).toBe("convergent"); + expect(signalTypeOf(theme({ convergent: false, reactive_count: 2, proactive_count: 0 }))).toBe("reactive"); + expect(signalTypeOf(theme({ convergent: false, reactive_count: 0, proactive_count: 2 }))).toBe("proactive"); + }); +}); + +describe("toErrorResult", () => { + it("wraps an Error message and flags isError", () => { + const result = toErrorResult(new Error("boom")); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toBe("Error: boom"); + }); + + it("falls back to a generic message for non-Error values", () => { + expect(toErrorResult("weird").content[0]!.text).toBe("Error: Unknown error"); + }); +}); diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..8726bee --- /dev/null +++ b/src/format.ts @@ -0,0 +1,312 @@ +/** + * Format layer — shapes raw API objects into the scrubbed, analysis-ready + * structures that leave the server. PII scrubbing happens here, before any + * text enters analysis or a tool response. + * + * Everything in this module is pure: no clients, no env, no module state. + */ + +import type { Conversation } from "./helpscout.js"; +import type { FeatureRequest } from "./productlift.js"; +import type { + AnalysisResult, + DetailLevel, + SignalType, + ThemeMatch, + FormattedConversation, + FormattedFeatureRequest, +} from "./feedback-analyzer.js"; +import { scrubPii, scrubPiiArray } from "./pii-scrubber.js"; + +// ── Formatting (PII scrubbing happens here) ── + +// PII categories found while formatting are collected into an explicit +// per-request sink (never a module global — concurrent tool calls interleave). +export function formatConversation( + conv: Conversation, + piiSink: Set +): FormattedConversation { + // Thread bodies are excluded by design — subject + preview carry the customer voice + const rawMessages = [conv.preview].filter(Boolean); + const { texts: customerMessages, piiCategoriesFound } = scrubPiiArray(rawMessages); + const subjectScrub = scrubPii(conv.subject ?? ""); + const previewScrub = scrubPii(conv.preview ?? ""); + for (const cat of [...piiCategoriesFound, ...subjectScrub.piiCategoriesFound, ...previewScrub.piiCategoriesFound]) { + piiSink.add(cat); + } + return { + id: conv.id, + number: conv.number, + subject: subjectScrub.text, + status: conv.status, + createdAt: conv.createdAt, + closedAt: conv.closedAt, + customerEmail: "[REDACTED]", + tags: conv.tags?.map((t) => t.tag) ?? [], + preview: previewScrub.text, + customerMessages, + threadCount: conv.threads ?? 0, + }; +} + +export function formatFeatureRequest( + req: FeatureRequest, + piiSink: Set +): FormattedFeatureRequest { + const titleScrub = scrubPii(req.title); + const descScrub = scrubPii(req.description); + for (const cat of [...titleScrub.piiCategoriesFound, ...descScrub.piiCategoriesFound]) { + piiSink.add(cat); + } + return { + id: req.id, + title: titleScrub.text, + description: descScrub.text, + status: req.status?.name ?? null, + category: req.category?.name ?? null, + votes_count: req.votes_count ?? 0, + comments_count: req.comments_count ?? 0, + portal: req.portal, + url: req.url, + created_at: req.created_at, + updated_at: req.updated_at, + comments: req.comments.map((c) => { + const commentScrub = scrubPii(c.comment); + for (const cat of commentScrub.piiCategoriesFound) piiSink.add(cat); + // Commenter names are deliberately dropped — only the role leaves the + // server, consistent with the voter-identity exclusion. + return { + role: c.author.role, + comment: commentScrub.text, + created_at: c.created_at, + }; + }), + }; +} + +// ── Agent response detection ── + +const AGENT_RESPONSE_PATTERNS: RegExp[] = [ + // Common agent closings & pleasantries + /happy to help/i, + /hope this helps/i, + /let me know if/i, + /don't hesitate/i, + /do not hesitate/i, + /feel free to/i, + /is there anything else/i, + /glad (to |you |we )/i, + /pleasure (to |helping)/i, + /(best|warm|kind) regards/i, + // Apologies & acknowledgments + /i apologize for/i, + /we apologize for/i, + /sorry for the inconvenience/i, + /thank you for (reaching|contacting|writing|your patience)/i, + /thanks for (reaching|contacting|writing|your patience)/i, + /thanks (so much )?for checking in/i, + /bringing this to our attention/i, + // Agent action language + /i've (checked|looked into|forwarded|sent|updated|resolved|fixed|escalated)/i, + /we've (identified|checked|looked|resolved|fixed|addressed|updated|escalated)/i, + /has been (resolved|fixed|addressed|updated|escalated)/i, + /this has been flagged/i, + /our (team|support|engineering|developers)/i, + /i can help with that/i, + // System / automated text + /ai-generated draft/i, + /your request .* could(n't| not) be created/i, + /powered by/i, + // Generic support-agent phrasing + /we're aiming to .* get back to you/i, + /before i proceed/i, +]; + +export function isLikelyAgentResponse(text: string): boolean { + if (!text || text.length === 0) return true; + for (const pattern of AGENT_RESPONSE_PATTERNS) { + if (pattern.test(text)) return true; + } + return false; +} + +// ── Shared response-shaping helpers ── + +export function toErrorResult(error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + isError: true as const, + }; +} + +export function buildLookupMaps( + conversations: FormattedConversation[], + featureRequests: FormattedFeatureRequest[] +): { + convMap: Map; + reqMap: Map; +} { + const convMap = new Map(); + for (const conv of conversations) convMap.set(`hs-${conv.id}`, conv); + const reqMap = new Map(); + for (const req of featureRequests) reqMap.set(`pl-${req.id}`, req); + return { convMap, reqMap }; +} + +export function signalTypeOf( + theme: ThemeMatch +): "convergent" | "reactive" | "proactive" { + if (theme.convergent) return "convergent"; + return theme.reactive_count > 0 ? "reactive" : "proactive"; +} + +const MAX_TITLES = 50; + +export function capTitles(dataPoints: ThemeMatch["data_points"]): { + data_points_total: number; + data_point_titles: string[]; + data_point_titles_truncated?: true; +} { + const allTitles = dataPoints.map((dp) => dp.title); + return { + data_points_total: allTitles.length, + data_point_titles: allTitles.slice(0, MAX_TITLES), + ...(allTitles.length > MAX_TITLES && { + data_point_titles_truncated: true as const, + }), + }; +} + +// ── Quote extraction for product plans ── + +export function extractQuotesForTheme( + themeDataPoints: Array<{ id: string; source: SignalType; title: string }>, + conversationMap: Map, + featureRequestMap: Map, + maxQuotes: number = 3 +): string[] { + const quotes: string[] = []; + + for (const dp of themeDataPoints) { + if (quotes.length >= maxQuotes) break; + + if (dp.source === "REACTIVE") { + const conv = conversationMap.get(dp.id); + if (!conv) continue; + + // Prefer customer message that isn't agent text, fall back to subject. + // Collapse whitespace so embedded newlines can't break markdown output. + const msg = (conv.customerMessages[0] ?? "").replace(/\s+/g, " ").trim(); + if (msg && !isLikelyAgentResponse(msg)) { + const truncated = msg.length > 200 ? msg.slice(0, 197) + "..." : msg; + quotes.push(`[Support ticket] "${truncated}"`); + } else if (conv.subject && !isLikelyAgentResponse(conv.subject)) { + quotes.push(`[Support ticket] "${conv.subject}"`); + } + // Skip this data point if both are agent text — don't add a bad quote + } else { + const req = featureRequestMap.get(dp.id); + if (req) { + const customerComment = req.comments.find((c) => c.role !== "admin"); + if (customerComment && !isLikelyAgentResponse(customerComment.comment)) { + const msg = customerComment.comment.replace(/\s+/g, " ").trim(); + const truncated = msg.length > 200 ? msg.slice(0, 197) + "..." : msg; + quotes.push(`[Feature request, ${req.votes_count} votes] "${truncated}"`); + } else { + quotes.push(`[Feature request, ${req.votes_count} votes] "${req.title}"`); + } + } + } + } + + // If we couldn't find enough clean quotes, note it + if (quotes.length === 0) { + quotes.push("No direct customer quote available"); + } + + return quotes; +} + +// ── Detail level trimming ── + +export function buildEvidenceSummary(theme: ThemeMatch): string { + const total = theme.reactive_count + theme.proactive_count; + const parts: string[] = []; + if (theme.reactive_count > 0) parts.push(`${theme.reactive_count} support tickets`); + if (theme.proactive_count > 0) parts.push(`${theme.proactive_count} feature requests`); + let summary = `${total} signals (${parts.join(", ")}).`; + if (theme.convergent) { + summary += " Convergent — appears in both support and feature requests (2x priority boost)."; + } + return summary; +} + +export function trimAnalysisForDetail( + analysis: AnalysisResult, + level: DetailLevel, + conversations: FormattedConversation[], + featureRequests: FormattedFeatureRequest[] +): unknown { + if (level === "full") return analysis; + + const { convMap, reqMap } = buildLookupMaps(conversations, featureRequests); + + const themes = analysis.themes.map((theme) => { + const quotes = extractQuotesForTheme( + theme.data_points, + convMap, + reqMap, + 3 + ); + + const base = { + theme_id: theme.theme_id, + label: theme.label, + category: theme.category, + priority_score: theme.priority_score, + convergent: theme.convergent, + signal_type: signalTypeOf(theme), + reactive_count: theme.reactive_count, + proactive_count: theme.proactive_count, + evidence_summary: buildEvidenceSummary(theme), + representative_quotes: quotes, + }; + + if (level === "summary") return base; + + // standard: add sub-scores + data point titles (capped) + return { + ...base, + frequency_score: theme.frequency_score, + severity_score: theme.severity_score, + vote_momentum_score: theme.vote_momentum_score, + ...capTitles(theme.data_points), + }; + }); + + const emergingLimit = level === "summary" ? 5 : 10; + const emerging_themes = analysis.emerging_themes + .slice(0, emergingLimit) + .map((e) => { + if (level === "summary") { + return { pattern: e.ngram, frequency: e.frequency }; + } + return { + pattern: e.ngram, + frequency: e.frequency, + sample_titles: e.data_points.slice(0, 3).map((dp) => dp.title), + }; + }); + + return { + config_version: analysis.config_version, + known_themes_count: analysis.known_themes_count, + total_data_points: analysis.total_data_points, + reactive_count: analysis.reactive_count, + proactive_count: analysis.proactive_count, + themes, + emerging_themes, + unmatched_count: analysis.unmatched_count, + }; +} diff --git a/src/index.ts b/src/index.ts index 09d5125..be75322 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,6 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { z } from "zod"; import { HelpScoutClient, - type Conversation, type Mailbox, } from "./helpscout.js"; import { @@ -21,13 +20,21 @@ import { analyzeFeedback, loadThemesConfig, type AnalysisResult, - type SignalType, - type DetailLevel, - type ThemeMatch, type FormattedConversation, type FormattedFeatureRequest, } from "./feedback-analyzer.js"; -import { scrubPii, scrubPiiArray } from "./pii-scrubber.js"; +import { scrubPii } from "./pii-scrubber.js"; +import { + formatConversation, + formatFeatureRequest, + extractQuotesForTheme, + buildEvidenceSummary, + trimAnalysisForDetail, + toErrorResult, + buildLookupMaps, + signalTypeOf, + capTitles, +} from "./format.js"; import { METHODOLOGY_CONTENT, METHODOLOGY_VERSION } from "./methodology.js"; // HelpScout setup @@ -92,32 +99,6 @@ server.registerResource( }) ); -// PII categories found while formatting are collected into an explicit -// per-request sink (never a module global — concurrent tool calls interleave). -function formatConversation(conv: Conversation, piiSink: Set): FormattedConversation { - // Thread bodies are excluded by design — subject + preview carry the customer voice - const rawMessages = [conv.preview].filter(Boolean); - const { texts: customerMessages, piiCategoriesFound } = scrubPiiArray(rawMessages); - const subjectScrub = scrubPii(conv.subject ?? ""); - const previewScrub = scrubPii(conv.preview ?? ""); - for (const cat of [...piiCategoriesFound, ...subjectScrub.piiCategoriesFound, ...previewScrub.piiCategoriesFound]) { - piiSink.add(cat); - } - return { - id: conv.id, - number: conv.number, - subject: subjectScrub.text, - status: conv.status, - createdAt: conv.createdAt, - closedAt: conv.closedAt, - customerEmail: "[REDACTED]", - tags: conv.tags?.map((t) => t.tag) ?? [], - preview: previewScrub.text, - customerMessages, - threadCount: conv.threads ?? 0, - }; -} - // ── Shared data fetching ── interface FetchParams { @@ -460,143 +441,10 @@ server.registerTool("synthesize_feedback", { ], }; } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error"; - return { - content: [{ type: "text" as const, text: `Error: ${message}` }], - isError: true, - }; + return toErrorResult(error); } }); -function formatFeatureRequest(req: FeatureRequest, piiSink: Set): FormattedFeatureRequest { - const titleScrub = scrubPii(req.title); - const descScrub = scrubPii(req.description); - for (const cat of [...titleScrub.piiCategoriesFound, ...descScrub.piiCategoriesFound]) { - piiSink.add(cat); - } - return { - id: req.id, - title: titleScrub.text, - description: descScrub.text, - status: req.status?.name ?? null, - category: req.category?.name ?? null, - votes_count: req.votes_count ?? 0, - comments_count: req.comments_count ?? 0, - portal: req.portal, - url: req.url, - created_at: req.created_at, - updated_at: req.updated_at, - comments: req.comments.map((c) => { - const commentScrub = scrubPii(c.comment); - for (const cat of commentScrub.piiCategoriesFound) piiSink.add(cat); - // Commenter names are deliberately dropped — only the role leaves the - // server, consistent with the voter-identity exclusion. - return { - role: c.author.role, - comment: commentScrub.text, - created_at: c.created_at, - }; - }), - }; -} - -// ── Agent response detection ── - -const AGENT_RESPONSE_PATTERNS: RegExp[] = [ - // Common agent closings & pleasantries - /happy to help/i, - /hope this helps/i, - /let me know if/i, - /don't hesitate/i, - /do not hesitate/i, - /feel free to/i, - /is there anything else/i, - /glad (to |you |we )/i, - /pleasure (to |helping)/i, - /(best|warm|kind) regards/i, - // Apologies & acknowledgments - /i apologize for/i, - /we apologize for/i, - /sorry for the inconvenience/i, - /thank you for (reaching|contacting|writing|your patience)/i, - /thanks for (reaching|contacting|writing|your patience)/i, - /thanks (so much )?for checking in/i, - /bringing this to our attention/i, - // Agent action language - /i've (checked|looked into|forwarded|sent|updated|resolved|fixed|escalated)/i, - /we've (identified|checked|looked|resolved|fixed|addressed|updated|escalated)/i, - /has been (resolved|fixed|addressed|updated|escalated)/i, - /this has been flagged/i, - /our (team|support|engineering|developers)/i, - /i can help with that/i, - // System / automated text - /ai-generated draft/i, - /your request .* could(n't| not) be created/i, - /powered by/i, - // Generic support-agent phrasing - /we're aiming to .* get back to you/i, - /before i proceed/i, -]; - -function isLikelyAgentResponse(text: string): boolean { - if (!text || text.length === 0) return true; - for (const pattern of AGENT_RESPONSE_PATTERNS) { - if (pattern.test(text)) return true; - } - return false; -} - -// ── Quote extraction for product plans ── - -function extractQuotesForTheme( - themeDataPoints: Array<{ id: string; source: SignalType; title: string }>, - conversationMap: Map, - featureRequestMap: Map, - maxQuotes: number = 3 -): string[] { - const quotes: string[] = []; - - for (const dp of themeDataPoints) { - if (quotes.length >= maxQuotes) break; - - if (dp.source === "REACTIVE") { - const conv = conversationMap.get(dp.id); - if (!conv) continue; - - // Prefer customer message that isn't agent text, fall back to subject. - // Collapse whitespace so embedded newlines can't break markdown output. - const msg = (conv.customerMessages[0] ?? "").replace(/\s+/g, " ").trim(); - if (msg && !isLikelyAgentResponse(msg)) { - const truncated = msg.length > 200 ? msg.slice(0, 197) + "..." : msg; - quotes.push(`[Support ticket] "${truncated}"`); - } else if (conv.subject && !isLikelyAgentResponse(conv.subject)) { - quotes.push(`[Support ticket] "${conv.subject}"`); - } - // Skip this data point if both are agent text — don't add a bad quote - } else { - const req = featureRequestMap.get(dp.id); - if (req) { - const customerComment = req.comments.find((c) => c.role !== "admin"); - if (customerComment && !isLikelyAgentResponse(customerComment.comment)) { - const msg = customerComment.comment.replace(/\s+/g, " ").trim(); - const truncated = msg.length > 200 ? msg.slice(0, 197) + "..." : msg; - quotes.push(`[Feature request, ${req.votes_count} votes] "${truncated}"`); - } else { - quotes.push(`[Feature request, ${req.votes_count} votes] "${req.title}"`); - } - } - } - } - - // If we couldn't find enough clean quotes, note it - if (quotes.length === 0) { - quotes.push("No direct customer quote available"); - } - - return quotes; -} - // ── Markdown product brief ── interface PlanPriorityForRender { @@ -698,103 +546,6 @@ function renderPlanMarkdown(args: { return lines.join("\n").trimEnd() + "\n"; } -// ── Detail level trimming ── - -function buildEvidenceSummary(theme: ThemeMatch): string { - const total = theme.reactive_count + theme.proactive_count; - const parts: string[] = []; - if (theme.reactive_count > 0) parts.push(`${theme.reactive_count} support tickets`); - if (theme.proactive_count > 0) parts.push(`${theme.proactive_count} feature requests`); - let summary = `${total} signals (${parts.join(", ")}).`; - if (theme.convergent) { - summary += " Convergent — appears in both support and feature requests (2x priority boost)."; - } - return summary; -} - -function trimAnalysisForDetail( - analysis: AnalysisResult, - level: DetailLevel, - conversations: FormattedConversation[], - featureRequests: FormattedFeatureRequest[] -): unknown { - if (level === "full") return analysis; - - const convMap = new Map(); - for (const conv of conversations) convMap.set(`hs-${conv.id}`, conv); - const reqMap = new Map(); - for (const req of featureRequests) reqMap.set(`pl-${req.id}`, req); - - const themes = analysis.themes.map((theme) => { - const signalType = theme.convergent - ? "convergent" - : theme.reactive_count > 0 - ? "reactive" - : "proactive"; - const quotes = extractQuotesForTheme( - theme.data_points, - convMap, - reqMap, - 3 - ); - - const base = { - theme_id: theme.theme_id, - label: theme.label, - category: theme.category, - priority_score: theme.priority_score, - convergent: theme.convergent, - signal_type: signalType, - reactive_count: theme.reactive_count, - proactive_count: theme.proactive_count, - evidence_summary: buildEvidenceSummary(theme), - representative_quotes: quotes, - }; - - if (level === "summary") return base; - - // standard: add sub-scores + data point titles (capped at 50) - const MAX_TITLES = 50; - const allTitles = theme.data_points.map((dp) => dp.title); - return { - ...base, - frequency_score: theme.frequency_score, - severity_score: theme.severity_score, - vote_momentum_score: theme.vote_momentum_score, - data_points_total: allTitles.length, - data_point_titles: allTitles.slice(0, MAX_TITLES), - ...(allTitles.length > MAX_TITLES && { - data_point_titles_truncated: true, - }), - }; - }); - - const emergingLimit = level === "summary" ? 5 : 10; - const emerging_themes = analysis.emerging_themes - .slice(0, emergingLimit) - .map((e) => { - if (level === "summary") { - return { pattern: e.ngram, frequency: e.frequency }; - } - return { - pattern: e.ngram, - frequency: e.frequency, - sample_titles: e.data_points.slice(0, 3).map((dp) => dp.title), - }; - }); - - return { - config_version: analysis.config_version, - known_themes_count: analysis.known_themes_count, - total_data_points: analysis.total_data_points, - reactive_count: analysis.reactive_count, - proactive_count: analysis.proactive_count, - themes, - emerging_themes, - unmatched_count: analysis.unmatched_count, - }; -} - server.registerTool("generate_product_plan", { title: "Generate Product Plan", description: @@ -947,25 +698,13 @@ server.registerTool("generate_product_plan", { } // Build lookup maps for quote extraction - const conversationMap = new Map(); - for (const conv of data.conversations) { - conversationMap.set(`hs-${conv.id}`, conv); - } - const featureRequestMap = new Map(); - for (const req of data.featureRequests) { - featureRequestMap.set(`pl-${req.id}`, req); - } + const { convMap: conversationMap, reqMap: featureRequestMap } = + buildLookupMaps(data.conversations, data.featureRequests); // Build priorities from top themes const topThemes = data.analysis.themes.slice(0, max_priorities); const priorities = topThemes.map((theme, index) => { - const signalType = theme.convergent - ? "convergent" - : theme.reactive_count > 0 - ? "reactive" - : "proactive"; - const quotes = extractQuotesForTheme( theme.data_points, conversationMap, @@ -977,7 +716,7 @@ server.registerTool("generate_product_plan", { theme: theme.label, theme_id: theme.theme_id, category: theme.category, - signal_type: signalType, + signal_type: signalTypeOf(theme), priority_score: theme.priority_score, convergent: theme.convergent, evidence: { @@ -996,17 +735,8 @@ server.registerTool("generate_product_plan", { if (detail_level === "summary") return base; - // standard + full: add data point titles (capped at 50) - const MAX_TITLES = 50; - const allTitles = theme.data_points.map((dp) => dp.title); - return { - ...base, - data_points_total: allTitles.length, - data_point_titles: allTitles.slice(0, MAX_TITLES), - ...(allTitles.length > MAX_TITLES && { - data_point_titles_truncated: true, - }), - }; + // standard + full: add data point titles (capped) + return { ...base, ...capTitles(theme.data_points) }; }); // Build emerging themes summary @@ -1082,12 +812,7 @@ server.registerTool("generate_product_plan", { ], }; } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error"; - return { - content: [{ type: "text" as const, text: `Error: ${message}` }], - isError: true, - }; + return toErrorResult(error); } }); @@ -1129,12 +854,7 @@ server.registerTool("get_feature_requests", { } try { - const clients = portal_name - ? productliftClients.filter( - (_, i) => - portalConfigs[i]?.name.toLowerCase() === portal_name.toLowerCase() - ) - : productliftClients; + const clients = filterClientsByPortal(portal_name); if (clients.length === 0) { return { @@ -1200,11 +920,7 @@ server.registerTool("get_feature_requests", { ], }; } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - return { - content: [{ type: "text" as const, text: `Error: ${message}` }], - isError: true, - }; + return toErrorResult(error); } }); @@ -1254,11 +970,7 @@ server.registerTool("list_sources", { ], }; } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - return { - content: [{ type: "text" as const, text: `Error: ${message}` }], - isError: true, - }; + return toErrorResult(error); } }); diff --git a/src/productlift.test.ts b/src/productlift.test.ts new file mode 100644 index 0000000..103eaa4 --- /dev/null +++ b/src/productlift.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { parsePortalConfigs, ProductLiftClient } from "./productlift.js"; + +function stubNoPortalEnv() { + // undefined deletes the var — "" would not be nullish for the ?? fallbacks + vi.stubEnv("PRODUCTLIFT_PORTALS", undefined); + vi.stubEnv("PRODUCTLIFT_PORTAL_URL", undefined); + vi.stubEnv("PRODUCTLIFT_API_KEY", undefined); + vi.stubEnv("PRODUCTLIFT_PORTAL_NAME", undefined); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("parsePortalConfigs", () => { + it("parses multiple portals from PRODUCTLIFT_PORTALS", () => { + vi.stubEnv( + "PRODUCTLIFT_PORTALS", + "acme|https://roadmap.acme.com|key-a,beta|https://roadmap.beta.com|key-b" + ); + const configs = parsePortalConfigs(); + expect(configs).toEqual([ + { name: "acme", baseUrl: "https://roadmap.acme.com", apiKey: "key-a" }, + { name: "beta", baseUrl: "https://roadmap.beta.com", apiKey: "key-b" }, + ]); + }); + + it("rejoins pipes inside the API key", () => { + vi.stubEnv("PRODUCTLIFT_PORTALS", "acme|https://roadmap.acme.com|key|with|pipes"); + const configs = parsePortalConfigs(); + expect(configs[0]?.apiKey).toBe("key|with|pipes"); + }); + + it("trims whitespace around fields", () => { + vi.stubEnv("PRODUCTLIFT_PORTALS", " acme | https://roadmap.acme.com | key-a "); + const configs = parsePortalConfigs(); + expect(configs[0]).toEqual({ + name: "acme", + baseUrl: "https://roadmap.acme.com", + apiKey: "key-a", + }); + }); + + it("strips a trailing slash from the base URL", () => { + vi.stubEnv("PRODUCTLIFT_PORTALS", "acme|https://roadmap.acme.com/|key-a"); + expect(parsePortalConfigs()[0]?.baseUrl).toBe("https://roadmap.acme.com"); + }); + + it("throws an actionable error on a malformed entry", () => { + vi.stubEnv("PRODUCTLIFT_PORTALS", "acme|https://roadmap.acme.com"); + expect(() => parsePortalConfigs()).toThrow(/Invalid PRODUCTLIFT_PORTALS format/); + }); + + it("falls back to single-portal env vars with a default name", () => { + stubNoPortalEnv(); + vi.stubEnv("PRODUCTLIFT_PORTAL_URL", "https://roadmap.example.com/"); + vi.stubEnv("PRODUCTLIFT_API_KEY", "single-key"); + const configs = parsePortalConfigs(); + expect(configs).toEqual([ + { name: "default", baseUrl: "https://roadmap.example.com", apiKey: "single-key" }, + ]); + }); + + it("uses PRODUCTLIFT_PORTAL_NAME for the single-portal name when set", () => { + stubNoPortalEnv(); + vi.stubEnv("PRODUCTLIFT_PORTAL_URL", "https://roadmap.example.com"); + vi.stubEnv("PRODUCTLIFT_API_KEY", "single-key"); + vi.stubEnv("PRODUCTLIFT_PORTAL_NAME", "acme"); + expect(parsePortalConfigs()[0]?.name).toBe("acme"); + }); + + it("returns an empty list when nothing is configured", () => { + stubNoPortalEnv(); + expect(parsePortalConfigs()).toEqual([]); + }); +}); + +describe("ProductLiftClient", () => { + it("exposes the portal name without exposing the config", () => { + const client = new ProductLiftClient({ + name: "acme", + baseUrl: "https://roadmap.acme.com", + apiKey: "secret", + }); + expect(client.portalName).toBe("acme"); + }); +}); From f8de76ff0c06e881934a004b42b18313c11240f4 Mon Sep 17 00:00:00 2001 From: David Kelly <46355358+dkships@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:20:34 -0700 Subject: [PATCH 4/4] chore: patch hono advisories via npm audit fix Transitive dep of @modelcontextprotocol/sdk; npm audit flagged the 4.12.x line high (GHSA-xrhx-7g5j-rcj5 and related). Lockfile-only. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 ++ package-lock.json | 209 +++++++++++++++++++++++----------------------- 2 files changed, 108 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1159947..3ef7cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project are documented here. Format follows [Keep a ## [Unreleased] +### Security + +- Bumped transitive `hono` (a `@modelcontextprotocol/sdk` dependency) past the 4.12.x advisories + flagged high by `npm audit` (GHSA-xrhx-7g5j-rcj5 and related). Lockfile-only change; audit clean. + ### Fixed - The thread-depth severity signal is live again. Thread bodies were never fetched, so every diff --git a/package-lock.json b/package-lock.json index d955928..1b74cfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,6 @@ "dotenv": "^17.4.2", "zod": "^4.4.3" }, - "bin": { - "pm-copilot": "dist/index.js" - }, "devDependencies": { "@types/node": "^25.9.1", "typescript": "^6.0.3", @@ -26,21 +23,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -49,9 +46,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -119,14 +116,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -138,9 +135,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "dev": true, "license": "MIT", "funding": { @@ -148,9 +145,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], @@ -165,9 +162,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], @@ -182,9 +179,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ "x64" ], @@ -199,9 +196,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], @@ -216,9 +213,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", "cpu": [ "arm" ], @@ -233,9 +230,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], @@ -250,9 +247,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ "arm64" ], @@ -267,9 +264,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", "cpu": [ "ppc64" ], @@ -284,9 +281,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", "cpu": [ "s390x" ], @@ -301,9 +298,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", "cpu": [ "x64" ], @@ -318,9 +315,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", "cpu": [ "x64" ], @@ -335,9 +332,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", "cpu": [ "arm64" ], @@ -352,9 +349,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", "cpu": [ "wasm32" ], @@ -362,18 +359,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", "cpu": [ "arm64" ], @@ -388,9 +385,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", "cpu": [ "x64" ], @@ -419,9 +416,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -1191,9 +1188,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1625,9 +1622,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -1770,9 +1767,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -1860,13 +1857,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1876,21 +1873,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, "node_modules/router": { @@ -2214,17 +2211,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -2240,7 +2237,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0",