Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ IMPORTANT RULES:
await withDb(async (db) => {
const now = Math.floor(Date.now() / 1000);
const result = await db.prepare(
"UPDATE feedback SET status = 'dismissed', updated_at = ? WHERE id = ? AND status = 'open'"
"UPDATE feedback SET status = 'dismissed', updated_at = ? WHERE id = ? AND status IN ('open', 'pending_review')"
).run(now, feedbackId);
if (result.changes > 0) {
console.log(`Feedback ${feedbackId} dismissed.`);
Expand Down
119 changes: 116 additions & 3 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import {
dismissFeedbackSchema,
publishToGithubSchema,
triageSchema,
preTriageSchema,
} from "./schemas.js";
import { getCategories, getWebhooks } from "./categories.js";
import { checkGhAuth, createGithubIssue } from "./github.js";
import { execFileSync } from "child_process";
import { checkGhAuth, createGithubIssue, extractKeywords, keywordSimilarity, isSuggestionBoxIssueTitle } from "./github.js";

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isSuggestionBoxIssueTitle isn't exported from github.ts (it doesn't even exist there as a named function — the bracket-prefix check is inlined inside searchExistingIssues). This import will fail at compile time and at runtime.

Either export it from github.ts or inline the check here.

import { assertValidConfig } from "./config.js";
import { RateLimiter, RateLimitError } from "./rate-limiter.js";

Expand Down Expand Up @@ -225,8 +227,8 @@ If similar feedback already exists, your submission becomes a vote on it instead
}

const item = await store.getFeedbackById(feedback_id);
if (!item || item.status !== "open") {
return { content: [{ type: "text" as const, text: `Feedback ${feedback_id} not found or not open.` }], isError: true };
if (!item || (item.status !== "open" && item.status !== "pending_review")) {
return { content: [{ type: "text" as const, text: `Feedback ${feedback_id} not found or not in a publishable state (must be open or pending_review).` }], isError: true };
}

const repo = github_repo ?? item.githubRepo;
Expand Down Expand Up @@ -422,6 +424,117 @@ Start now: present **Item 1** and ask the user what to do.`;
},
);

// -------------------------------------------------------------------------
// Tool: suggestion_box_pre_triage
// -------------------------------------------------------------------------
server.tool(
"suggestion_box_pre_triage",
`Pre-triage open feedback: groups similar entries by topic, checks GitHub for existing issues, computes combined impact per group, and moves items to a pending_review queue for human approval.

Use this before a review session to:
- Collapse noisy duplicates into coherent clusters
- Surface which groups already have a GitHub issue
- Prioritize by combined votes and estimated impact
- Prepare a clean queue for the TUI review flow

Returns a structured report of groups with representative items, vote totals, impact estimates, and GitHub deduplication status.`,
preTriageSchema.shape,
async ({ target_type, target_name, github_repo, mark_as_pending_review, limit }) => {
try {
const result = await store.preTriage({
targetType: target_type,
targetName: target_name,
githubRepo: github_repo,
markAsPendingReview: mark_as_pending_review,
limit,
});

if (result.totalItems === 0) {
return { content: [{ type: "text" as const, text: "No open feedback items found matching the filters." }] };
}

// For groups that have a github_repo, check for existing issues
const ghAvailable = github_repo ? checkGhAuth() : false;
if (ghAvailable && github_repo) {
for (const group of result.groups) {
const keywords = extractKeywords(group.representative);
if (!keywords) continue;
try {
const raw = execFileSync(
"gh",
["issue", "list", "--repo", github_repo, "--search", `${keywords} in:title`, "--state", "open", "--json", "number,title,url", "--limit", "5"],
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
);
const issues: Array<{ number: number; title: string; url: string }> = JSON.parse(raw.trim() || "[]");
const SIMILARITY_THRESHOLD = 0.3;
for (const issue of issues) {
if (!isSuggestionBoxIssueTitle(issue.title) && keywordSimilarity(keywords, issue.title) >= SIMILARITY_THRESHOLD) {
group.existingGithubIssueUrl = issue.url;
group.existingGithubIssueNumber = issue.number;
break;
}
}
} catch {
// GitHub search failed — continue without dedup info
}
}
}

// Build report
const categoryLabel: Record<string, string> = {
friction: "Friction Report",
feature_request: "Feature Request",
observation: "Observation",
};

let text = `Pre-triage complete: ${result.totalItems} items grouped into ${result.groups.length} cluster(s)`;
if (result.markedAsPendingReview > 0) {
text += ` — ${result.markedAsPendingReview} marked as pending_review`;
}
text += "\n\n";

for (let i = 0; i < result.groups.length; i++) {
const group = result.groups[i];
const rep = group.representative;
const label = categoryLabel[rep.category] ?? rep.category;

text += `━━━ Group ${i + 1}/${result.groups.length}: [${label}] ${group.items.length} item(s), ${group.totalVotes} total vote(s) ━━━\n`;

if (group.totalEstimatedTokensSaved > 0 || group.totalEstimatedTimeSavedMinutes > 0) {
const parts: string[] = [];
if (group.totalEstimatedTokensSaved > 0) parts.push(`~${group.totalEstimatedTokensSaved} tokens`);
if (group.totalEstimatedTimeSavedMinutes > 0) parts.push(`~${group.totalEstimatedTimeSavedMinutes}min`);
text += `Impact: ${parts.join(", ")}\n`;
}

if (group.existingGithubIssueUrl) {
text += `GitHub duplicate: #${group.existingGithubIssueNumber} ${group.existingGithubIssueUrl}\n`;
}

text += `Target: ${rep.targetType}/${rep.targetName}\n`;
text += `Representative (ID: ${rep.id}, ${rep.votes} vote(s)):\n`;
const preview = rep.content.length > 200 ? rep.content.slice(0, 197) + "..." : rep.content;
text += ` ${preview}\n`;

if (group.items.length > 1) {
text += `Similar items (${group.items.length - 1}):\n`;
for (const item of group.items) {
if (item.id === rep.id) continue;
const itemPreview = item.content.length > 100 ? item.content.slice(0, 97) + "..." : item.content;
text += ` - [${item.votes}v] ${item.id.slice(0, 8)}: ${itemPreview}\n`;
}
}

text += "\n";
}

return { content: [{ type: "text" as const, text }] };
} catch (e: any) {
return { content: [{ type: "text" as const, text: `Error: ${e.message}` }], isError: true };
}
},
);

const transport = new StdioServerTransport();
await server.connect(transport);

Expand Down
10 changes: 9 additions & 1 deletion src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function createListFeedbackSchema(categories?: string[]) {
category: categorySchema,
target_type: z.enum(["mcp_server", "tool", "codebase", "workflow", "general"]).optional().describe("Filter by target type"),
target_name: z.string().optional().describe("Filter by target name"),
status: z.enum(["open", "published", "dismissed"]).optional().describe("Filter by status (default: open)"),
status: z.enum(["open", "pending_review", "published", "dismissed"]).optional().describe("Filter by status (default: open)"),
session_id: z.string().optional().describe("Filter by session ID"),
sort_by: z.enum(["votes", "recent", "impact"]).optional().describe("Sort order (default: votes)"),
limit: z.coerce.number().optional().describe("Max results (default: 20)"),
Expand Down Expand Up @@ -77,3 +77,11 @@ export const triageSchema = z.object({
threshold: z.coerce.number().int().min(1).optional().describe("Minimum vote count to include (default: 3)"),
limit: z.coerce.number().int().min(1).optional().describe("Max results (default: 20)"),
});

export const preTriageSchema = z.object({
target_type: z.enum(["mcp_server", "tool", "codebase", "workflow", "general"]).optional().describe("Filter by target type"),
target_name: z.string().optional().describe("Filter by target name"),
github_repo: z.string().optional().describe("GitHub repo to check for existing issues (format: owner/repo)"),
mark_as_pending_review: z.boolean().optional().describe("Mark triaged items as pending_review (default: true)"),
limit: z.coerce.number().optional().describe("Max feedback items to consider (default: 100)"),
});
3 changes: 3 additions & 0 deletions src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export type {
FeedbackStats,
TriageInput,
TriageResult,
TriageGroup,
PreTriageInput,
PreTriageResult,
} from "./types.js";

import { FeedbackStore } from "./store.js";
Expand Down
90 changes: 89 additions & 1 deletion src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import type {
SortBy,
TriageInput,
TriageResult,
PreTriageInput,
PreTriageResult,
TriageGroup,
} from "./types.js";

type Database = Awaited<ReturnType<typeof connect>>;
Expand Down Expand Up @@ -397,7 +400,7 @@ export class FeedbackStore {
const now = Math.floor(Date.now() / 1000);
return this.withDb(async (db) => {
const result = await db.prepare(
"UPDATE feedback SET status = 'dismissed', updated_at = ? WHERE id = ? AND status = 'open'"
"UPDATE feedback SET status = 'dismissed', updated_at = ? WHERE id = ? AND status IN ('open', 'pending_review')"
).run(now, feedbackId);
return result.changes > 0;
});
Expand Down Expand Up @@ -542,6 +545,91 @@ export class FeedbackStore {
return rows.length;
}

async markPendingReview(feedbackId: string): Promise<boolean> {
await this.init();
const now = Math.floor(Date.now() / 1000);
return this.withDb(async (db) => {
const result = await db.prepare(
"UPDATE feedback SET status = 'pending_review', updated_at = ? WHERE id = ? AND status = 'open'"
).run(now, feedbackId);
return result.changes > 0;
});
}

/**
* Group open feedback by similarity, check GitHub for existing issues,
* and optionally mark items as pending_review.
*/
async preTriage(input: PreTriageInput = {}): Promise<PreTriageResult> {
await this.init();

const items = await this.listFeedback({
targetType: input.targetType,
targetName: input.targetName,
status: "open",
sortBy: "votes",
limit: input.limit ?? 100,
});

if (items.length === 0) {
return { groups: [], totalItems: 0, markedAsPendingReview: 0 };
}

// Greedy clustering using trigram similarity
const CLUSTER_THRESHOLD = 0.25;
const assigned = new Set<string>();
const groups: TriageGroup[] = [];

for (const item of items) {
if (assigned.has(item.id)) continue;

const cluster: Feedback[] = [item];
assigned.add(item.id);

for (const candidate of items) {
if (assigned.has(candidate.id)) continue;
const sim = trigramSimilarity(item.content, candidate.content);
if (sim >= CLUSTER_THRESHOLD) {
cluster.push(candidate);
assigned.add(candidate.id);
}
}

// Representative is the highest-voted item (items are already sorted by votes desc)
const representative = cluster.reduce((best, c) => c.votes > best.votes ? c : best, cluster[0]);

const totalVotes = cluster.reduce((sum, c) => sum + c.votes, 0);
const totalTokens = cluster.reduce((sum, c) => sum + (c.estimatedTokensSaved ?? 0), 0);
const totalMinutes = cluster.reduce((sum, c) => sum + (c.estimatedTimeSavedMinutes ?? 0), 0);

groups.push({
representative,
items: cluster,
totalVotes,
totalEstimatedTokensSaved: totalTokens,
totalEstimatedTimeSavedMinutes: totalMinutes,
existingGithubIssueUrl: null,
existingGithubIssueNumber: null,
});
}

// Optionally mark items as pending_review
const shouldMark = input.markAsPendingReview !== false;
let markedCount = 0;
if (shouldMark) {
for (const item of items) {
const marked = await this.markPendingReview(item.id);
if (marked) markedCount++;
}
}

return {
groups,
totalItems: items.length,
markedAsPendingReview: markedCount,
};
}

async purge(): Promise<number> {
await this.init();
return this.withDb(async (db) => {
Expand Down
35 changes: 34 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type FeedbackCategory = "friction" | "feature_request" | "observation" |

export type TargetType = "mcp_server" | "tool" | "codebase" | "workflow" | "general";

export type FeedbackStatus = "open" | "published" | "dismissed";
export type FeedbackStatus = "open" | "pending_review" | "published" | "dismissed";

export type SortBy = "votes" | "recent" | "impact";

Expand Down Expand Up @@ -137,3 +137,36 @@ export interface TriageResult {
/** The threshold used */
threshold: number;
}

/** A cluster of similar feedback items produced by pre-triage. */
export interface TriageGroup {
/** The representative (highest-voted) item in this cluster. */
representative: Feedback;
/** All items in the cluster (includes the representative). */
items: Feedback[];
/** Combined vote count across all items in the cluster. */
totalVotes: number;
/** Combined estimated tokens saved across all items. */
totalEstimatedTokensSaved: number;
/** Combined estimated time saved across all items. */
totalEstimatedTimeSavedMinutes: number;
/** URL of an existing GitHub issue that matches this cluster, if any. */
existingGithubIssueUrl: string | null;
/** Number of an existing GitHub issue, if any. */
existingGithubIssueNumber: number | null;
}

export interface PreTriageInput {
targetType?: TargetType;
targetName?: string;
githubRepo?: string;
/** If true, mark triaged items as pending_review. Default: true. */
markAsPendingReview?: boolean;
limit?: number;
}

export interface PreTriageResult {
groups: TriageGroup[];
totalItems: number;
markedAsPendingReview: number;
}
Loading
Loading