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/3] 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/3] 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 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 3/3] 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",