src / toolsProvider.ts
/**
* Ideas & Pain Points Plugin — toolsProvider (11 tools)
*
* Tools:
* Ideas · manage_idea(action)
* Pain Points · manage_pain_point(action)
* Analysis · evaluate_idea, generate_problem_statement, link_pain_to_idea
* Generation · generate(type)
* Research · research(topic)
* Validation · validate(action), define_mvp
* Dashboard · validation_dashboard
* Export · export_report
*/
import { text, tool, type Tool, type ToolCallContext, type ToolsProvider } from "@lmstudio/sdk";
import { readFile, writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { homedir } from "os";
import { webSearch as _webSearch, type TimeRange } from "./search";
import { detectWebPeer } from "./peers";
import { z } from "zod";
import { pluginConfigSchematics } from "./config";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function json(obj: unknown): string {
return JSON.stringify(obj, null, 2);
}
function safe_impl<T extends Record<string, unknown>>(
name: string,
fn: (params: T, ctx: ToolCallContext) => Promise<string>
): (params: T, ctx: ToolCallContext) => Promise<string> {
return async (params: T, ctx: ToolCallContext) => {
if (ctx.signal.aborted) {
return JSON.stringify({ tool_error: true, tool: name, error: "cancelled" });
}
try {
return await fn(params, ctx);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return JSON.stringify({
tool_error: true,
tool: name,
error: msg,
hint: "Read the error above, fix the parameter causing the issue, and retry the tool call.",
}, null, 2);
}
};
}
// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------
type ImpactLevel = "low" | "medium" | "high" | "critical";
type StatusType = "active" | "archived" | "validated" | "rejected" | "in_progress";
type ExperimentType =
| "customer_interview"
| "landing_page"
| "fake_door"
| "smoke_test"
| "concierge_mvp"
| "wizard_of_oz"
| "survey"
| "prototype"
| "a_b_test"
| "cold_outreach"
| "other";
type ExperimentResult = "pending" | "validated" | "invalidated" | "inconclusive";
interface Assumption {
id: string;
statement: string; // "Users will pay $X/month for Y"
type: "desirability" | "feasibility" | "viability" | "usability";
riskLevel: "low" | "medium" | "high" | "critical";
confidence: number; // 0–100 current confidence
validatedBy: string[]; // experiment IDs that tested this
}
interface ValidationExperiment {
id: string;
ideaId: string;
title: string;
type: ExperimentType;
hypothesis: string; // "We believe that..."
method: string; // How to run the experiment
successCriteria: string; // What a "pass" looks like (measurable)
effort: "hours" | "days" | "weeks";
cost: string; // Estimated cost
result: ExperimentResult;
evidence: string; // What was observed
learnings: string; // What was learned
testedAssumptionIds: string[];
createdAt: string;
updatedAt: string;
}
interface Idea {
id: string;
title: string;
description: string;
category: string;
tags: string[];
status: StatusType;
impactScore: number; // 1–10
feasibilityScore: number; // 1–10
noveltyScore: number; // 1–10
effortScore: number; // 1–10 (higher = more effort)
linkedPainPointIds: string[];
assumptions: Assumption[]; // Key assumptions to validate
mvpDefinition: string; // Smallest testable version
validationStatus: "not_started" | "in_progress" | "validated" | "invalidated";
notes: string;
createdAt: string;
updatedAt: string;
}
interface PainPoint {
id: string;
title: string;
description: string;
context: string;
affectedUsers: string;
frequency: "rare" | "occasional" | "frequent" | "constant";
impact: ImpactLevel;
category: string;
tags: string[];
status: StatusType;
problemStatement: string;
linkedIdeaIds: string[];
notes: string;
createdAt: string;
updatedAt: string;
}
interface IdeasDB {
ideas: Idea[];
painPoints: PainPoint[];
experiments: ValidationExperiment[];
}
function getDataDir(configPath: string): string {
const p = configPath.trim() || join(homedir(), "ideas-data");
return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
}
function dbPath(dataDir: string): string {
return join(dataDir, "ideas.json");
}
async function loadDB(dataDir: string): Promise<IdeasDB> {
let raw: string;
try {
raw = await readFile(dbPath(dataDir), "utf8");
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return { ideas: [], painPoints: [], experiments: [] };
}
throw err;
}
const db = JSON.parse(raw) as IdeasDB;
if (!db.ideas) db.ideas = [];
if (!db.painPoints) db.painPoints = [];
if (!db.experiments) db.experiments = [];
// Hydrate fields added after initial release so old records don't crash
for (const idea of db.ideas) {
idea.assumptions ??= [];
idea.mvpDefinition ??= "";
idea.validationStatus ??= "not_started";
idea.linkedPainPointIds ??= [];
idea.tags ??= [];
}
for (const pp of db.painPoints) {
pp.linkedIdeaIds ??= [];
pp.tags ??= [];
}
return db;
}
async function saveDB(dataDir: string, db: IdeasDB): Promise<void> {
await mkdir(dataDir, { recursive: true });
await writeFile(dbPath(dataDir), JSON.stringify(db, null, 2), "utf8");
}
function makeId(): string {
return crypto.randomUUID();
}
function calcPriority(idea: Pick<Idea, "impactScore" | "feasibilityScore" | "noveltyScore" | "effortScore">): number {
return Math.round(
((idea.impactScore + idea.feasibilityScore + idea.noveltyScore - idea.effortScore) / 3) * 10
) / 10;
}
// ---------------------------------------------------------------------------
// Tools Provider
// ---------------------------------------------------------------------------
export const toolsProvider: ToolsProvider = async (ctl) => {
const cfg = ctl.getPluginConfig(pluginConfigSchematics);
const dataDir = () => getDataDir(cfg.get("dataPath"));
const maxResults = () => cfg.get("maxSearchResults");
const searxng = () => cfg.get("searxngUrl").trim() || undefined;
const searchWindow = (): TimeRange | undefined => {
const v = cfg.get("searchRecencyWindow").trim().toLowerCase();
return (["day", "week", "month", "year"].includes(v) ? v : undefined) as TimeRange | undefined;
};
const webSearch = (query: string, max?: number, timeRange?: TimeRange) =>
_webSearch(query, max, 10_000, searxng(), timeRange ?? searchWindow());
const webPeerLoaded = await detectWebPeer(ctl as unknown as { client: any });
const tools: Tool[] = [
// =========================================================================
// IDEAS
// =========================================================================
tool({
name: "manage_idea",
description: text`
Manage ideas in the database.
action: "capture" — save a new idea with scores
action: "list" — list ideas with optional filters and sort
action: "get" — get full details of a single idea by ID
action: "update" — update fields on an existing idea
action: "delete" — permanently delete an idea (cascades experiments and pain point links)
`,
parameters: {
action: z.enum(["capture","list","get","update","delete"]).describe("Operation to perform"),
id: z.string().default("").describe("get/update/delete: idea ID"),
title: z.string().default("").describe("capture: required. update: optional"),
description: z.string().default("").describe("capture: required. update: optional"),
category: z.string().default("general").describe("capture/update: product, feature, process, research, business, etc."),
tags: z.array(z.string()).default([]).describe("capture/update: tags for filtering"),
impactScore: z.coerce.number().int().min(1).max(10).optional().describe("1=low, 10=transformative"),
feasibilityScore: z.coerce.number().int().min(1).max(10).optional().describe("1=nearly impossible, 10=trivial"),
noveltyScore: z.coerce.number().int().min(1).max(10).optional().describe("1=common, 10=very original"),
effortScore: z.coerce.number().int().min(1).max(10).optional().describe("1=hours, 10=years"),
status: z.enum(["active","archived","validated","rejected","in_progress"]).optional(),
validationStatus: z.enum(["not_started","in_progress","validated","invalidated"]).optional(),
mvpDefinition: z.string().optional(),
notes: z.string().default(""),
filterStatus: z.enum(["active","archived","validated","rejected","in_progress","all"]).default("active"),
filterCategory: z.string().default(""),
filterTag: z.string().default(""),
search: z.string().default(""),
sortBy: z.enum(["priority","impact","feasibility","novelty","effort","createdAt"]).default("priority"),
},
implementation: safe_impl("manage_idea", async (params) => {
const { action } = params;
const db = await loadDB(dataDir());
if (action === "capture") {
if (!params.title) throw new Error("title is required for action=capture");
if (!params.description) throw new Error("description is required for action=capture");
const idea: Idea = {
id: makeId(),
title: params.title,
description: params.description,
category: params.category,
tags: params.tags,
status: "active",
impactScore: params.impactScore ?? 5,
feasibilityScore: params.feasibilityScore ?? 5,
noveltyScore: params.noveltyScore ?? 5,
effortScore: params.effortScore ?? 5,
linkedPainPointIds: [],
assumptions: [],
mvpDefinition: "",
validationStatus: "not_started",
notes: params.notes,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
db.ideas.push(idea);
await saveDB(dataDir(), db);
return json({ success: true, idea, priorityScore: calcPriority(idea) });
}
if (action === "list") {
let ideas = db.ideas;
if (params.filterStatus !== "all") ideas = ideas.filter((i) => i.status === params.filterStatus);
if (params.filterCategory) ideas = ideas.filter((i) => i.category.toLowerCase().includes(params.filterCategory.toLowerCase()));
if (params.filterTag) ideas = ideas.filter((i) => i.tags.some((t) => t.toLowerCase().includes(params.filterTag.toLowerCase())));
if (params.search) {
const kw = params.search.toLowerCase();
ideas = ideas.filter((i) => i.title.toLowerCase().includes(kw) || i.description.toLowerCase().includes(kw));
}
const withScore = ideas.map((i) => ({ ...i, priorityScore: calcPriority(i) }));
withScore.sort((a, b) => {
switch (params.sortBy) {
case "priority": return b.priorityScore - a.priorityScore;
case "impact": return b.impactScore - a.impactScore;
case "feasibility": return b.feasibilityScore - a.feasibilityScore;
case "novelty": return b.noveltyScore - a.noveltyScore;
case "effort": return a.effortScore - b.effortScore;
case "createdAt": return b.createdAt.localeCompare(a.createdAt);
}
});
const summary = withScore.map((i) => ({
id: i.id, title: i.title, category: i.category, tags: i.tags, status: i.status,
impact: i.impactScore, feasibility: i.feasibilityScore, novelty: i.noveltyScore,
effort: i.effortScore, priorityScore: i.priorityScore,
linkedPainPoints: i.linkedPainPointIds.length, createdAt: i.createdAt.slice(0, 10),
}));
return json({ total: summary.length, sortBy: params.sortBy, ideas: summary });
}
if (action === "get") {
const idea = db.ideas.find((i) => i.id === params.id);
if (!idea) throw new Error(`Idea ID '${params.id}' not found.`);
const linkedPainPoints = db.painPoints.filter((p) => idea.linkedPainPointIds.includes(p.id));
return json({ ...idea, linkedPainPoints });
}
if (action === "update") {
const idx = db.ideas.findIndex((i) => i.id === params.id);
if (idx === -1) throw new Error(`Idea ID '${params.id}' not found.`);
const updateFields = {
title: params.title || undefined,
description: params.description || undefined,
category: params.category !== "general" ? params.category : undefined,
tags: params.tags.length ? params.tags : undefined,
status: params.status,
impactScore: params.impactScore,
feasibilityScore: params.feasibilityScore,
noveltyScore: params.noveltyScore,
effortScore: params.effortScore,
validationStatus: params.validationStatus,
mvpDefinition: params.mvpDefinition,
notes: params.notes || undefined,
};
const patch = Object.fromEntries(Object.entries(updateFields).filter(([, v]) => v !== undefined));
db.ideas[idx] = { ...db.ideas[idx], ...patch, updatedAt: new Date().toISOString() } as Idea;
await saveDB(dataDir(), db);
return json({ success: true, idea: db.ideas[idx] });
}
if (action === "delete") {
const before = db.ideas.length;
db.ideas = db.ideas.filter((i) => i.id !== params.id);
if (db.ideas.length === before) throw new Error(`Idea ID '${params.id}' not found.`);
const removedExperiments = (db.experiments ?? []).filter((e) => e.ideaId === params.id).length;
db.experiments = (db.experiments ?? []).filter((e) => e.ideaId !== params.id);
for (const pp of db.painPoints) {
pp.linkedIdeaIds = pp.linkedIdeaIds.filter((lid) => lid !== params.id);
}
await saveDB(dataDir(), db);
return json({ success: true, deleted: params.id, removedExperiments });
}
throw new Error(`Unknown action: ${action}`);
}),
}),
// =========================================================================
// PAIN POINTS
// =========================================================================
tool({
name: "manage_pain_point",
description: text`
Manage pain points in the database.
action: "capture" — log a new pain point
action: "list" — list pain points with optional filters
action: "get" — get full details of a single pain point by ID
action: "update" — update fields on an existing pain point
action: "delete" — permanently delete a pain point (cleans up idea links)
`,
parameters: {
action: z.enum(["capture","list","get","update","delete"]).describe("Operation to perform"),
id: z.string().default("").describe("get/update/delete: pain point ID"),
title: z.string().default("").describe("capture: required. update: optional"),
description: z.string().default("").describe("capture: required. update: optional"),
context: z.string().default("").describe("Where/when this was observed"),
affectedUsers: z.string().default("").describe("Who experiences this pain"),
frequency: z.enum(["rare","occasional","frequent","constant"]).optional(),
impact: z.enum(["low","medium","high","critical"]).optional(),
category: z.string().default("general"),
tags: z.array(z.string()).default([]),
status: z.enum(["active","archived","validated","rejected","in_progress"]).optional(),
problemStatement: z.string().optional(),
notes: z.string().default(""),
filterStatus: z.enum(["active","archived","validated","rejected","in_progress","all"]).default("active"),
filterImpact: z.enum(["low","medium","high","critical","all"]).default("all"),
filterCategory: z.string().default(""),
filterTag: z.string().default(""),
search: z.string().default(""),
},
implementation: safe_impl("manage_pain_point", async (params) => {
const { action } = params;
const db = await loadDB(dataDir());
if (action === "capture") {
if (!params.title) throw new Error("title is required for action=capture");
if (!params.description) throw new Error("description is required for action=capture");
const pp: PainPoint = {
id: makeId(),
title: params.title,
description: params.description,
context: params.context,
affectedUsers: params.affectedUsers,
frequency: params.frequency ?? "occasional",
impact: params.impact ?? "medium",
category: params.category,
tags: params.tags,
status: "active",
problemStatement: "",
linkedIdeaIds: [],
notes: params.notes,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
db.painPoints.push(pp);
await saveDB(dataDir(), db);
return json({ success: true, painPoint: pp });
}
if (action === "list") {
let pps = db.painPoints;
if (params.filterStatus !== "all") pps = pps.filter((p) => p.status === params.filterStatus);
if (params.filterCategory) pps = pps.filter((p) => p.category.toLowerCase().includes(params.filterCategory.toLowerCase()));
if (params.filterImpact !== "all") pps = pps.filter((p) => p.impact === params.filterImpact);
if (params.filterTag) pps = pps.filter((p) => p.tags.some((t) => t.toLowerCase().includes(params.filterTag.toLowerCase())));
if (params.search) {
const kw = params.search.toLowerCase();
pps = pps.filter((p) => p.title.toLowerCase().includes(kw) || p.description.toLowerCase().includes(kw));
}
const impactOrder: Record<ImpactLevel, number> = { critical: 4, high: 3, medium: 2, low: 1 };
pps = pps.slice().sort((a, b) => (impactOrder[b.impact] ?? 0) - (impactOrder[a.impact] ?? 0));
const summary = pps.map((p) => ({
id: p.id, title: p.title, category: p.category, impact: p.impact, frequency: p.frequency,
affectedUsers: p.affectedUsers, tags: p.tags, status: p.status,
hasProblemStatement: !!p.problemStatement, linkedIdeas: p.linkedIdeaIds.length,
createdAt: p.createdAt.slice(0, 10),
}));
return json({ total: summary.length, painPoints: summary });
}
if (action === "get") {
const pp = db.painPoints.find((p) => p.id === params.id);
if (!pp) throw new Error(`Pain point ID '${params.id}' not found.`);
const linkedIdeas = db.ideas.filter((i) => pp.linkedIdeaIds.includes(i.id));
return json({ ...pp, linkedIdeas });
}
if (action === "update") {
const idx = db.painPoints.findIndex((p) => p.id === params.id);
if (idx === -1) throw new Error(`Pain point ID '${params.id}' not found.`);
const updateFields = {
title: params.title || undefined,
description: params.description || undefined,
context: params.context || undefined,
affectedUsers: params.affectedUsers || undefined,
frequency: params.frequency,
impact: params.impact,
category: params.category !== "general" ? params.category : undefined,
tags: params.tags.length ? params.tags : undefined,
status: params.status,
problemStatement: params.problemStatement,
notes: params.notes || undefined,
};
const patch = Object.fromEntries(Object.entries(updateFields).filter(([, v]) => v !== undefined));
db.painPoints[idx] = { ...db.painPoints[idx], ...patch, updatedAt: new Date().toISOString() } as PainPoint;
await saveDB(dataDir(), db);
return json({ success: true, painPoint: db.painPoints[idx] });
}
if (action === "delete") {
const before = db.painPoints.length;
db.painPoints = db.painPoints.filter((p) => p.id !== params.id);
if (db.painPoints.length === before) throw new Error(`Pain point ID '${params.id}' not found.`);
for (const idea of db.ideas) {
idea.linkedPainPointIds = idea.linkedPainPointIds.filter((lid) => lid !== params.id);
}
await saveDB(dataDir(), db);
return json({ success: true, deleted: params.id });
}
throw new Error(`Unknown action: ${action}`);
}),
}),
// =========================================================================
// ANALYSIS & GENERATION
// =========================================================================
tool({
name: "generate_problem_statement",
description: text`
Generate a structured, formal problem statement from a pain point description.
Uses the "Who / What / Why / Impact" framework and the Jobs-to-be-Done lens.
After generating, call update_pain_point with the problemStatement field to save it.
`,
parameters: {
painPointId: z.string().default("")
.describe("Pain point ID to load from database (leave blank to use inline text)"),
title: z.string().default("").describe("Pain point title (if not using ID)"),
description: z.string().default("").describe("Pain point description (if not using ID)"),
affectedUsers: z.string().default("").describe("Who is affected"),
context: z.string().default("").describe("Context where this pain occurs"),
impact: z.string().default("").describe("Impact severity or business cost"),
},
implementation: safe_impl("generate_problem_statement", async (params) => {
let title = params.title;
let description = params.description;
let affectedUsers = params.affectedUsers;
let context = params.context;
let impact = params.impact;
if (params.painPointId) {
const db = await loadDB(dataDir());
const pp = db.painPoints.find((p) => p.id === params.painPointId);
if (!pp) throw new Error(`Pain point ID '${params.painPointId}' not found.`);
title = title || pp.title;
description = description || pp.description;
affectedUsers = affectedUsers || pp.affectedUsers;
context = context || pp.context;
impact = impact || pp.impact;
}
if (!title && !description) {
throw new Error("Provide either a painPointId or at minimum title and description.");
}
const payload = {
title,
description,
affectedUsers,
context,
impact,
painPointId: params.painPointId || null,
instructions:
"Generate a crisp, structured problem statement using this framework:\n" +
"**Who**: [specific user segment]\n" +
"**What**: [the core problem they face — not the symptom, the root cause]\n" +
"**When/Where**: [the context/trigger for the problem]\n" +
"**Why it matters**: [quantified or qualified impact]\n" +
"**Current workarounds**: [how they cope today and why it's insufficient]\n" +
"**Success criterion**: [what 'solved' looks like]\n\n" +
"Then write a single-sentence problem statement suitable for a product brief. " +
"Keep the whole output under 200 words.",
};
return json(payload);
}),
}),
tool({
name: "evaluate_idea",
description: text`
Deeply evaluate an idea across multiple dimensions:
market potential, technical feasibility, differentiation, risks, and time-to-value.
Produces a scorecard and go/no-go recommendation.
`,
parameters: {
ideaId: z.string().default("")
.describe("Idea ID to load from database (leave blank to use inline text)"),
title: z.string().default("").describe("Idea title (if not using ID)"),
description: z.string().default("").describe("Idea description (if not using ID)"),
targetMarket: z.string().default("").describe("Who would use/buy this"),
competitorContext: z.string().default("")
.describe("Known competitors or existing solutions"),
},
implementation: safe_impl("evaluate_idea", async (params) => {
let title = params.title;
let description = params.description;
if (params.ideaId) {
const db = await loadDB(dataDir());
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (!idea) throw new Error(`Idea ID '${params.ideaId}' not found.`);
title = title || idea.title;
description = description || idea.description;
}
return json({
title,
description,
targetMarket: params.targetMarket,
competitorContext: params.competitorContext,
instructions:
"Evaluate this idea across these dimensions (score each 1–10 with explicit rationale — do not assign scores without reasoning):\n" +
"1. **Market Size**: What evidence supports the market size claim? Note if unknown.\n" +
"2. **Problem Severity**: How painful is the problem — and for whom specifically? Do not generalize.\n" +
"3. **Differentiation**: What actually exists today that competes? Be specific — do not assume the space is open.\n" +
"4. **Technical Feasibility**: What are the real technical risks, not just the optimistic case?\n" +
"5. **Time to Value**: How quickly can users see value — and what must go right for that to happen?\n" +
"6. **Moat/Defensibility**: What would stop a well-funded competitor from copying this in 6 months?\n\n" +
"Then list:\n" +
"- Top 3 risks — include risks that could kill the idea entirely, not just manageable ones\n" +
"- Top 3 assumptions that must be validated before spending significant time or money\n" +
"- Recommended next step — base this on the weakest dimension above, not the strongest\n" +
"- Assessment: present arguments FOR and AGAINST proceeding; let the user form their own conclusion. " +
" Do not issue a single go/no-go verdict as if it were objective — it is not.",
});
}),
}),
tool({
name: "link_pain_to_idea",
description: text`
Create a bidirectional link between a pain point and an idea.
Useful for mapping which ideas address which pain points.
`,
parameters: {
painPointId: z.string().describe("Pain point ID"),
ideaId: z.string().describe("Idea ID"),
},
implementation: safe_impl("link_pain_to_idea", async ({ painPointId, ideaId }) => {
const db = await loadDB(dataDir());
const ppIdx = db.painPoints.findIndex((p) => p.id === painPointId);
const ideaIdx = db.ideas.findIndex((i) => i.id === ideaId);
if (ppIdx === -1) throw new Error(`Pain point ID '${painPointId}' not found.`);
if (ideaIdx === -1) throw new Error(`Idea ID '${ideaId}' not found.`);
const pp = db.painPoints[ppIdx];
const idea = db.ideas[ideaIdx];
if (!pp.linkedIdeaIds.includes(ideaId)) {
pp.linkedIdeaIds.push(ideaId);
pp.updatedAt = new Date().toISOString();
}
if (!idea.linkedPainPointIds.includes(painPointId)) {
idea.linkedPainPointIds.push(painPointId);
idea.updatedAt = new Date().toISOString();
}
await saveDB(dataDir(), db);
return json({
success: true,
painPoint: { id: pp.id, title: pp.title, linkedIdeas: pp.linkedIdeaIds },
idea: { id: idea.id, title: idea.title, linkedPainPoints: idea.linkedPainPointIds },
});
}),
}),
tool({
name: "generate",
description: text`
Content generation for idea development.
type: "solution_brief" — generate a concise mini product spec from a pain point + idea pair
type: "brainstorm" — generate diverse solution ideas for a problem
type: "landing_page" — generate ready-to-use landing page copy
`,
parameters: {
type: z.enum(["solution_brief","brainstorm","landing_page"]).describe("What to generate"),
painPointId: z.string().default(""),
ideaId: z.string().default(""),
problemDescription: z.string().default(""),
solutionDescription: z.string().default(""),
targetUsers: z.string().default(""),
constraints: z.string().default("").describe("brainstorm: budget, tech stack, timeline, team size constraints"),
approachCount: z.coerce.number().int().min(3).max(15).default(6).describe("brainstorm: number of solution ideas"),
productName: z.string().default(""),
tagline: z.string().default(""),
coreBenefit: z.string().default(""),
topPainPoint: z.string().default(""),
ctaGoal: z.enum(["email_signup","waitlist","book_demo","early_access","buy_now"]).default("waitlist"),
tone: z.enum(["professional","conversational","bold","empathetic"]).default("conversational"),
},
implementation: safe_impl("generate", async (params) => {
const db = await loadDB(dataDir());
if (params.type === "solution_brief") {
let problem = params.problemDescription;
let solution = params.solutionDescription;
let target = params.targetUsers;
if (params.painPointId) {
const pp = db.painPoints.find((p) => p.id === params.painPointId);
if (!pp) throw new Error(`Pain point ID '${params.painPointId}' not found.`);
problem = problem || `${pp.title}: ${pp.description}`; target = target || pp.affectedUsers;
}
if (params.ideaId) {
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (!idea) throw new Error(`Idea ID '${params.ideaId}' not found.`);
solution = solution || `${idea.title}: ${idea.description}`;
}
return json({ problem, solution, targetUsers: target, instructions: "Write a solution brief with sections: ## Problem, ## Proposed Solution, ## Target Users, ## Core Features (MVP) (3–5 bullets, must-haves only), ## Success Metrics (2–3 measurable KPIs), ## What We're NOT Building, ## Open Questions. Under 400 words. Be specific and opinionated." });
}
if (params.type === "brainstorm") {
let problem = params.problemDescription;
if (params.painPointId) {
const pp = db.painPoints.find((p) => p.id === params.painPointId);
if (!pp) throw new Error(`Pain point ID '${params.painPointId}' not found.`);
problem = problem || `${pp.title}: ${pp.description}`;
}
if (!problem) throw new Error("Provide either a painPointId or problemDescription.");
return json({ problem, constraints: params.constraints, approachCount: params.approachCount, instructions: `Generate ${params.approachCount} distinct solution ideas for the problem above. For each: - **Idea**: One-line description, - **Approach type**: (SaaS / automation / platform / community / hardware / process), - **Core insight**: The key insight that makes this work, - **Pros**: 2-3 bullets, - **Cons**: 1-2 bullets, - **Effort estimate**: hours / days / weeks / months. Include at least one unconventional or contrarian idea.` });
}
if (params.type === "landing_page") {
let description = "";
let title = params.productName;
if (params.ideaId) {
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (idea) { description = idea.description; title = title || idea.title; }
}
const ctaCopy: Record<string, { primary: string; secondary: string }> = {
email_signup: { primary: "Get Early Access", secondary: "Notify me when it launches" },
waitlist: { primary: "Join the Waitlist", secondary: "Be first to know" },
book_demo: { primary: "Book a Demo", secondary: "See it in action" },
early_access: { primary: "Request Early Access", secondary: "Join 100+ early users" },
buy_now: { primary: "Get Started", secondary: "Start your free trial" },
};
return json({ productName: title || "Your Product", targetUser: params.targetUsers, coreBenefit: params.coreBenefit, topPainPoint: params.topPainPoint, ideaDescription: description, ctaGoal: params.ctaGoal, ctaSuggestions: ctaCopy[params.ctaGoal], tone: params.tone, instructions: `Write complete landing page copy for "${title || "this product"}" targeting ${params.targetUsers}. Core benefit: ${params.coreBenefit}. Tone: ${params.tone}. CTA: ${params.ctaGoal.replace(/_/g, " ")}.\n\nProduce exactly:\n**HEADLINE** (3 options A/B/C — 8 words max each)\n**SUBHEADLINE** (1–2 sentences)\n**HERO SECTION** (2–3 sentences)\n**3 FEATURE BULLETS** (benefit-first, 10 words max)\n**SOCIAL PROOF PLACEHOLDER**\n**CTA COPY** (button text + micro-copy)\n**FAQ** (3 most common objections)\n**META DESCRIPTION** (155 chars)\n\nNo jargon. Start with the user's pain.` });
}
throw new Error(`Unknown type: ${params.type}`);
}),
}),
// =========================================================================
// RESEARCH — omitted when altra/web-search peer is loaded
// =========================================================================
...(webPeerLoaded ? [] : [tool({
name: "research",
description: text`
Web research for idea validation.
topic: "similar" — search for similar problems, existing solutions, competitors, and discussions
topic: "market_size" — search for TAM/SAM/SOM data, growth rates, and industry reports
topic: "competitors" — find existing products and startups solving the same problem
NOTE: if altra/web-search plugin is installed use its "search" tool instead.
`,
parameters: {
topic: z.enum(["similar","market_size","competitors"]).describe("What to research"),
query: z.string().describe("Problem, idea, or market to research"),
focus: z.enum(["competitors","solutions","research","discussions","general"]).default("general")
.describe("similar: what angle to focus on"),
region: z.string().default("global").describe("market_size: geographic region (global, US, India, etc.)"),
category: z.string().default("").describe("competitors: optional category (e.g. 'B2B SaaS', 'dev tool')"),
max: z.coerce.number().int().min(3).max(20).optional().describe("Max results"),
},
implementation: safe_impl("research", async ({ topic, query, focus, region, category, max }, ctx) => {
const limit = max ?? maxResults();
if (topic === "similar") {
ctx.status(`Searching similar problems: ${query}`);
const suffixMap: Record<string, string> = {
competitors: "competitor product solution", solutions: "how to solve solution tool",
research: "research study statistics", discussions: "reddit hackernews discussion forum", general: "",
};
const fullQuery = `${query} ${suffixMap[focus] ?? ""}`.trim();
const hits = await webSearch(fullQuery, limit, searchWindow());
return json({ query: fullQuery, focus, results: hits });
}
if (topic === "market_size") {
ctx.status(`Researching market size: ${query}`);
const yr = new Date().getFullYear();
const queries = [
`${query} market size TAM ${region} ${yr} billion`,
`${query} industry report growth rate CAGR ${yr} ${yr + 1}`,
`site:statista.com OR site:grandviewresearch.com OR site:mordorintelligence.com ${query} market ${yr}`,
];
const settled = await Promise.allSettled(queries.map((q) => webSearch(q, 4, searchWindow())));
const allResults: Array<{ title: string; url: string; snippet: string }> = [];
for (const r of settled) { if (r.status === "fulfilled") allResults.push(...r.value); }
const seen = new Set<string>();
const deduped = allResults.filter((r) => { if (seen.has(r.url)) return false; seen.add(r.url); return true; });
return json({
market: query, region,
searchResults: deduped.slice(0, limit),
tamFramework: {
topDown: "Find a published industry report (Statista, Grand View Research, IBISWorld) and filter to your specific segment.",
bottomUp: "Count your target customers × annual spend per customer = TAM.",
valueTheory: "What % of value you create could you reasonably capture as revenue? Work backwards from that.",
},
instructions:
"Using the search results above, present market size data as found — do not fabricate figures. " +
"Include: (1) TAM — cite the source for each figure, (2) SAM — explain segmentation logic, " +
"(3) SOM — range of realistic estimates, (4) Market dynamics — both tailwinds AND headwinds, " +
"(5) Data quality — flag if figures are old or from a single source. Do not conclude 'should' build.",
});
}
if (topic === "competitors") {
ctx.status(`Scanning competitors for: ${query}`);
const cat = category ? ` ${category}` : "";
const searches = [
{ angle: "direct_competitors", query: `${query}${cat} alternatives competitors` },
{ angle: "existing_products", query: `${query}${cat} best tools software app` },
{ angle: "startup_landscape", query: `${query}${cat} startup crunchbase wellfound product hunt` },
{ angle: "open_source", query: `${query}${cat} open source github solution` },
];
const searchHits = await Promise.all(searches.map((s) => webSearch(s.query, limit, searchWindow()).catch(() => [])));
const results: Record<string, unknown> = Object.fromEntries(searches.map((s, i) => [s.angle, searchHits[i]]));
return json({
idea: query, category: category || "general", competitorSearch: results,
instructions:
"Produce a neutral competitive landscape — do not assume gaps exist before looking. " +
"(1) List significant competitors: name, what they do, pricing, strengths AND weaknesses. " +
"(2) List open-source alternatives found. " +
"(3) Gaps — only report genuinely unaddressed ones; say so if none. " +
"(4) Differentiation — only if a credible angle exists from the data. " +
"(5) Market saturation: cite the evidence (funding, product count, review volume).",
});
}
throw new Error(`Unknown topic: ${topic}`);
}),
})]),
// =========================================================================
// EXPORT
// =========================================================================
tool({
name: "export_report",
description: text`
Export a formatted Markdown report of all ideas and pain points.
Includes summaries, scores, links, and statistics.
Returns the file path where the report was saved.
`,
parameters: {
outputPath: z.string().default("")
.describe("File path to save the report. Defaults to <dataPath>/report-<date>.md"),
includeArchived: z.coerce.boolean().default(false)
.describe("Include archived/rejected items"),
},
implementation: safe_impl("export_report", async ({ outputPath, includeArchived }) => {
const db = await loadDB(dataDir());
const date = new Date().toISOString().slice(0, 10);
const outPath = outputPath.trim() || join(dataDir(), `ideas-report-${date}.md`);
const filterStatus = (status: string) =>
includeArchived || (status !== "archived" && status !== "rejected");
const ideas = db.ideas.filter((i) => filterStatus(i.status));
const pps = db.painPoints.filter((p) => filterStatus(p.status));
let md = `# Ideas & Pain Points Report — ${date}\n\n`;
// Stats
md += `## Summary\n\n`;
md += `| Metric | Count |\n|--------|-------|\n`;
md += `| Total Ideas | ${db.ideas.length} |\n`;
md += `| Total Pain Points | ${db.painPoints.length} |\n`;
md += `| Active Ideas | ${db.ideas.filter((i) => i.status === "active").length} |\n`;
md += `| Validated Ideas | ${db.ideas.filter((i) => i.status === "validated").length} |\n`;
md += `| Critical Pain Points | ${db.painPoints.filter((p) => p.impact === "critical").length} |\n`;
md += `\n---\n\n`;
// Top ideas by priority
const topIdeas = ideas
.map((i) => ({ ...i, priority: calcPriority(i) }))
.sort((a, b) => b.priority - a.priority)
.slice(0, 10);
md += `## Top Ideas by Priority\n\n`;
for (const i of topIdeas) {
md += `### ${i.title} *(${i.status})*\n`;
md += `**Category**: ${i.category} | **Priority Score**: ${i.priority.toFixed(1)} \n`;
md += `**Scores**: Impact ${i.impactScore}/10 · Feasibility ${i.feasibilityScore}/10 · Novelty ${i.noveltyScore}/10 · Effort ${i.effortScore}/10 \n`;
if (i.tags.length > 0) md += `**Tags**: ${i.tags.join(", ")} \n`;
md += `\n${i.description}\n\n`;
if (i.notes) md += `> **Notes**: ${i.notes}\n\n`;
if (i.linkedPainPointIds.length > 0) {
const linked = db.painPoints.filter((p) => i.linkedPainPointIds.includes(p.id));
md += `**Addresses pain points**: ${linked.map((p) => p.title).join(", ")} \n`;
}
md += `\n`;
}
// Pain points by impact
const sortedPPs = pps.slice().sort((a, b) => {
const order: Record<ImpactLevel, number> = { critical: 4, high: 3, medium: 2, low: 1 };
return (order[b.impact] ?? 0) - (order[a.impact] ?? 0);
});
md += `---\n\n## Pain Points\n\n`;
for (const p of sortedPPs) {
md += `### ${p.title} *(${p.impact} impact · ${p.frequency})*\n`;
md += `**Category**: ${p.category} | **Status**: ${p.status} \n`;
if (p.affectedUsers) md += `**Affected users**: ${p.affectedUsers} \n`;
if (p.context) md += `**Context**: ${p.context} \n`;
if (p.tags.length > 0) md += `**Tags**: ${p.tags.join(", ")} \n`;
md += `\n${p.description}\n\n`;
if (p.problemStatement) md += `**Problem Statement**: ${p.problemStatement}\n\n`;
if (p.linkedIdeaIds.length > 0) {
const linked = db.ideas.filter((i) => p.linkedIdeaIds.includes(i.id));
md += `**Potential solutions**: ${linked.map((i) => i.title).join(", ")} \n`;
}
md += `\n`;
}
await mkdir(dataDir(), { recursive: true });
await writeFile(outPath, md, "utf8");
return json({
success: true,
path: outPath,
ideasIncluded: topIdeas.length,
painPointsIncluded: sortedPPs.length,
});
}),
}),
// =========================================================================
// RESEARCH — MARKET SIZE
// =========================================================================
// =========================================================================
// VALIDATION
// =========================================================================
tool({
name: "validate",
description: text`
Lean validation workflows for ideas.
action: "map_assumptions" — identify and prioritize critical assumptions
action: "design_experiment" — design a specific lean validation experiment
action: "log_result" — record the outcome of a completed experiment
action: "questions" — generate customer discovery interview/survey questions
action: "scorecard" — build a validation scorecard from all experiments so far
`,
parameters: {
action: z.enum(["map_assumptions","design_experiment","log_result","questions","scorecard"])
.describe("Validation operation to perform"),
ideaId: z.string().default("").describe("Idea ID (required for design_experiment, log_result, scorecard)"),
title: z.string().default("").describe("Idea title (if not using ideaId)"),
description: z.string().default("").describe("Idea description (if not using ideaId)"),
targetUsers: z.string().default("").describe("Who the idea is for"),
revenueModel: z.string().default("").describe("map_assumptions: how the idea makes money"),
assumption: z.string().default("").describe("design_experiment: the specific assumption being tested"),
experimentType: z.enum([
"customer_interview","landing_page","fake_door","smoke_test",
"concierge_mvp","wizard_of_oz","survey","prototype","a_b_test","cold_outreach","other",
]).optional().describe("design_experiment: type of experiment"),
budget: z.string().default("$0").describe("design_experiment: available budget"),
timeAvailable: z.enum(["hours","days","weeks"]).default("days"),
experimentId: z.string().default("").describe("log_result: experiment ID to update"),
result: z.enum(["validated","invalidated","inconclusive"]).optional().describe("log_result: outcome verdict"),
evidence: z.string().default("").describe("log_result: what you actually observed"),
learnings: z.string().default("").describe("log_result: what you learned and next steps"),
confidenceChange: z.coerce.number().int().min(-100).max(100).default(0),
validationGoal: z.enum(["problem","solution","willingness_to_pay","full_discovery"]).default("full_discovery"),
format: z.enum(["interview","survey"]).default("interview"),
},
implementation: safe_impl("validate", async (params, ctx) => {
const { action } = params;
const db = await loadDB(dataDir());
if (action === "map_assumptions") {
let title = params.title;
let description = params.description;
if (params.ideaId) {
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (!idea) throw new Error(`Idea ID '${params.ideaId}' not found.`);
title = title || idea.title; description = description || idea.description;
}
return json({
title, description, targetUsers: params.targetUsers, revenueModel: params.revenueModel,
assumptionCategories: {
desirability: ["Users have the problem we think they have","The problem is painful enough that they actively seek solutions","Users prefer our approach over existing workarounds","There are enough users with this problem to build a business"],
feasibility: ["The core technical challenge is solvable with our current skills","We can build a usable version within the available time/budget","Key dependencies (APIs, data, infrastructure) are accessible","We can hire the talent needed to build and maintain this"],
viability: ["Users will pay the price we need to charge for the business to work","Customer acquisition cost (CAC) will be lower than lifetime value (LTV)","We can reach customers through affordable channels","The business can survive until it reaches profitability"],
usability: ["Users can understand what the product does within the first 60 seconds","The core workflow requires no support or documentation","Users can achieve their goal successfully on their first attempt"],
},
instructions: `For the idea "${title}", generate a prioritized list of 8–12 critical assumptions. For each: Statement ('We believe that...'), Category (desirability/feasibility/viability/usability), Risk (critical/high/medium/low), Current confidence (0–100%), Cheapest test. Sort by (risk × inverse_confidence). Be brutally honest.`,
});
}
if (action === "design_experiment") {
if (!params.ideaId) throw new Error("ideaId is required for action=design_experiment");
if (!params.experimentType) throw new Error("experimentType is required for action=design_experiment");
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (!idea) throw new Error(`Idea ID '${params.ideaId}' not found.`);
const templates: Record<string, { successCriteria: string; method: string }> = {
customer_interview: { method: "Recruit 5–10 people matching your target user via LinkedIn/Reddit. Conduct 20–30 min structured interviews. Do NOT pitch — only listen.", successCriteria: "≥7/10 interviewees confirm the problem is real (rate it ≥7/10). At least 3 describe a current workaround." },
landing_page: { method: "Build a single-page site in Carrd/Webflow. Capture emails. Drive 100–200 visitors. Measure sign-up rate.", successCriteria: "≥5% of visitors submit email. At least 20 total sign-ups." },
fake_door: { method: "Add a button/link for the feature. When clicked, show 'Coming soon — sign up'. Track click-through vs. page views.", successCriteria: "≥3–5% click-through indicates genuine interest." },
smoke_test: { method: "Write a cold email/post describing the product. Send to 50–100 targeted people. Track reply/click rate.", successCriteria: "≥10% positive reply rate. At least 5 people ask 'how do I get access?'" },
concierge_mvp: { method: "Manually deliver the core value to 3–5 early users. Don't build anything yet. Charge if possible.", successCriteria: "Users complete at least one full cycle. At least 2/3 would pay." },
wizard_of_oz: { method: "Build a front-end UI but do the work manually behind the scenes. Run 5–10 users through the full flow.", successCriteria: "Users complete their goal without help. Satisfaction ≥8/10." },
survey: { method: "Write 5–10 questions in Typeform/Google Forms. Aim for 50+ responses.", successCriteria: "≥60% rate problem as high/critical. ≥30% express intent to use." },
prototype: { method: "Build a clickable prototype in Figma/Framer. Run 5 usability sessions.", successCriteria: "≥4/5 users complete the core task without getting stuck." },
a_b_test: { method: "Split audience 50/50 between two variants. Run until statistical significance.", successCriteria: "One variant shows ≥20% higher conversion at ≥95% confidence." },
cold_outreach: { method: "Send 50–100 personalized cold emails/DMs. Track open, reply, and meeting rates.", successCriteria: "≥30% open, ≥10% reply, ≥5% meeting booked." },
other: { method: "Design a custom experiment based on the assumption being tested.", successCriteria: "Define a measurable metric that indicates the assumption is valid." },
};
const template = templates[params.experimentType] ?? templates.other;
const exp: ValidationExperiment = {
id: makeId(), ideaId: params.ideaId,
title: `${params.experimentType.replace(/_/g, " ")} — ${params.assumption.slice(0, 50)}`,
type: params.experimentType, hypothesis: `We believe that ${params.assumption}.`,
method: template.method, successCriteria: template.successCriteria,
effort: params.timeAvailable, cost: params.budget,
result: "pending", evidence: "", learnings: "", testedAssumptionIds: [],
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
};
if (!db.experiments) db.experiments = [];
db.experiments.push(exp);
const ideaIdx = db.ideas.findIndex((i) => i.id === params.ideaId);
if (ideaIdx !== -1 && db.ideas[ideaIdx].validationStatus === "not_started") {
db.ideas[ideaIdx].validationStatus = "in_progress";
db.ideas[ideaIdx].updatedAt = new Date().toISOString();
}
await saveDB(dataDir(), db);
return json({ success: true, experiment: exp, ideaTitle: idea.title, targetUsers: params.targetUsers, instructions: `Design a complete ${params.experimentType.replace(/_/g, " ")} experiment to test: "${params.assumption}". Produce: (1) Hypothesis in 'We believe X, we'll know it's true when Y' format, (2) Step-by-step execution plan, (3) Specific success/failure criteria with numbers, (4) What to do if experiment succeeds vs. fails, (5) Biggest risk that could make this experiment misleading.` });
}
if (action === "log_result") {
if (!params.experimentId) throw new Error("experimentId is required for action=log_result");
if (!params.result) throw new Error("result is required for action=log_result");
if (!db.experiments) db.experiments = [];
const expIdx = db.experiments.findIndex((e) => e.id === params.experimentId);
if (expIdx === -1) throw new Error(`Experiment ID '${params.experimentId}' not found.`);
db.experiments[expIdx].result = params.result;
db.experiments[expIdx].evidence = params.evidence;
db.experiments[expIdx].learnings = params.learnings;
db.experiments[expIdx].updatedAt = new Date().toISOString();
const ideaId = db.experiments[expIdx].ideaId;
const ideaExps = db.experiments.filter((e) => e.ideaId === ideaId);
const validated = ideaExps.filter((e) => e.result === "validated").length;
const invalidated = ideaExps.filter((e) => e.result === "invalidated").length;
const total = ideaExps.filter((e) => e.result !== "pending").length;
const ideaIdx = db.ideas.findIndex((i) => i.id === ideaId);
if (ideaIdx !== -1 && total > 0) {
if (invalidated > 0 && invalidated >= validated) db.ideas[ideaIdx].validationStatus = "invalidated";
else if (validated >= 2 && validated > invalidated) db.ideas[ideaIdx].validationStatus = "validated";
else db.ideas[ideaIdx].validationStatus = "in_progress";
db.ideas[ideaIdx].updatedAt = new Date().toISOString();
}
await saveDB(dataDir(), db);
return json({ success: true, experiment: db.experiments[expIdx], confidenceChange: params.confidenceChange, validationSummary: { totalExperiments: ideaExps.length, validated, invalidated, pending: ideaExps.filter((e) => e.result === "pending").length, ideaValidationStatus: ideaIdx !== -1 ? db.ideas[ideaIdx].validationStatus : "unknown" }, nextStepSuggestion: params.result === "validated" ? "Assumption confirmed. Move to next highest-risk assumption." : params.result === "invalidated" ? "Assumption failed. Pivot idea, reframe the problem, or abandon." : "Inconclusive. Redesign with clearer success criteria or larger sample." });
}
if (action === "questions") {
let description = params.description;
if (params.ideaId) {
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (!idea) throw new Error(`Idea ID '${params.ideaId}' not found.`);
description = description || idea.description;
}
return json({
ideaDescription: description, targetUser: params.targetUsers || "target user",
validationGoal: params.validationGoal, format: params.format,
momTestPrinciples: ["Ask about the PAST, not the future","Ask about SPECIFICS, not generalities","Never pitch or lead the witness","Dig into workarounds — what do they do TODAY?","Silence is data — let them fill the gap"],
instructions: `Generate 12–15 ${params.format} questions to validate: "${description}". Target user: ${params.targetUsers || "your target user"}. Goal: ${params.validationGoal.replace(/_/g, " ")}. Organize into: Warm-up (2–3), Problem exploration (4–5), Current behavior (3–4)${params.validationGoal !== "problem" ? ", Solution reaction (2–3)" : ""}${params.validationGoal === "willingness_to_pay" || params.validationGoal === "full_discovery" ? ", Economics (1–2)" : ""}. For each: note what you're trying to learn and what a 'good answer' looks like.`,
});
}
if (action === "scorecard") {
if (!params.ideaId) throw new Error("ideaId is required for action=scorecard");
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (!idea) throw new Error(`Idea ID '${params.ideaId}' not found.`);
const experiments = (db.experiments ?? []).filter((e) => e.ideaId === params.ideaId);
const completed = experiments.filter((e) => e.result !== "pending");
const validated = completed.filter((e) => e.result === "validated");
const invalidated = completed.filter((e) => e.result === "invalidated");
const inconclusive = completed.filter((e) => e.result === "inconclusive");
const validationRate = completed.length > 0 ? Math.round((validated.length / completed.length) * 100) : 0;
const linkedPainPoints = db.painPoints.filter((p) => idea.linkedPainPointIds.includes(p.id));
const avgImpact = linkedPainPoints.length > 0 ? linkedPainPoints.filter((p) => p.impact === "critical" || p.impact === "high").length / linkedPainPoints.length : 0;
let recommendation: string;
if (completed.length === 0) recommendation = "NO DATA — Run at least 2–3 experiments before deciding.";
else if (invalidated.length > validated.length && completed.length >= 2) recommendation = "KILL OR PIVOT — More assumptions failed than passed.";
else if (validated.length >= 2 && validationRate >= 60) recommendation = "GO — Evidence supports proceeding. Define MVP and start building.";
else if (completed.length >= 2 && validationRate >= 40) recommendation = "CONTINUE VALIDATING — Promising signals but not enough confidence yet.";
else recommendation = "UNCERTAIN — Mixed results. Focus next experiments on highest-risk assumptions.";
return json({ ideaTitle: idea.title, ideaStatus: idea.validationStatus, priorityScore: calcPriority(idea), scorecard: { experimentsRun: experiments.length, completed: completed.length, validated: validated.length, invalidated: invalidated.length, inconclusive: inconclusive.length, validationRate: `${validationRate}%`, linkedPainPoints: linkedPainPoints.length, highImpactPainPointCoverage: `${Math.round(avgImpact * 100)}%` }, ideaScores: { impact: idea.impactScore, feasibility: idea.feasibilityScore, novelty: idea.noveltyScore, effort: idea.effortScore }, experiments: completed.map((e) => ({ type: e.type, result: e.result, evidence: e.evidence.slice(0, 200), learnings: e.learnings.slice(0, 200) })), recommendation, instructions: `Based on all validation data for "${idea.title}", produce: (1) Confidence score 0–100 for each category (desirability, feasibility, viability, usability), (2) 3 strongest pieces of evidence FOR, (3) 3 strongest pieces of evidence AGAINST, (4) Top 2 remaining unknowns, (5) Balanced assessment. Do not issue a single GO/KILL verdict.` });
}
throw new Error(`Unknown action: ${action}`);
}),
}),
// =========================================================================
// MVP DEFINITION & TESTING
// =========================================================================
tool({
name: "define_mvp",
description: text`
Define the Minimum Viable Product (MVP) for an idea.
An MVP is NOT a minimal product — it is the SMALLEST experiment that tests the core value hypothesis.
Returns a focused MVP scope with must-have features, explicit cut list,
build/measure/learn loop, and launch checklist.
Saves the MVP definition to the idea record.
`,
parameters: {
ideaId: z.string().describe("Idea ID to define MVP for"),
targetUser: z.string().default("").describe("Primary user persona for the MVP"),
coreProblem: z.string().default("").describe("The single problem the MVP solves"),
constraints: z.string().default("").describe("Constraints: team size, budget, timeline, tech stack"),
mvpType: z.enum([
"concierge", // Manual service delivery
"wizard_of_oz", // Fake automation
"landing_page", // Pre-launch page
"single_feature",// One core feature only
"prototype", // Clickable non-functional
"full_build", // Real lightweight product
]).default("single_feature").describe("Type of MVP to define"),
},
implementation: safe_impl("define_mvp", async (params) => {
const db = await loadDB(dataDir());
const ideaIdx = db.ideas.findIndex((i) => i.id === params.ideaId);
if (ideaIdx === -1) throw new Error(`Idea ID '${params.ideaId}' not found.`);
const idea = db.ideas[ideaIdx];
const mvpTypeDescriptions: Record<string, string> = {
concierge: "Manually deliver the service to 3–5 paying users. No code. Validate willingness to pay and core value before building anything.",
wizard_of_oz: "Build the front-end UI. Do the back-end work manually. Users think it's automated — you're the wizard behind the curtain.",
landing_page: "A single page that communicates the value prop and captures emails. Measures demand before building.",
single_feature: "Build only the one feature that delivers the core value. Everything else is cut. No onboarding, no settings, no edge cases.",
prototype: "Clickable Figma/Framer prototype. Zero code. Tests UX and value prop with real users before writing a line.",
full_build: "A simple but real product. Focus on the happy path only — one user type, one core workflow, no edge cases.",
};
const payload = {
ideaTitle: idea.title,
ideaDescription: idea.description,
mvpType: params.mvpType,
mvpTypeApproach: mvpTypeDescriptions[params.mvpType],
targetUser: params.targetUser,
coreProblem: params.coreProblem,
constraints: params.constraints,
experiments: (db.experiments ?? [])
.filter((e) => e.ideaId === params.ideaId && e.result === "validated")
.map((e) => ({ type: e.type, evidence: e.evidence.slice(0, 150) })),
instructions:
`Define the MVP for "${idea.title}" as a ${params.mvpType.replace(/_/g, " ")} MVP. ` +
`Target user: ${params.targetUser || "primary user"}. ` +
`Core problem: ${params.coreProblem || "the main pain point"}. ` +
`Constraints: ${params.constraints || "none specified"}.\n\n` +
"Produce:\n" +
"## Core Value Hypothesis\n[Single sentence: 'We believe [user] will [do action] because [value]']\n\n" +
"## MVP Scope — MUST HAVE (3–5 items max)\n[Only features that directly test the hypothesis]\n\n" +
"## Explicitly NOT in MVP\n[List 5+ things you're cutting and why]\n\n" +
"## Build Plan\n[Key steps, tools to use, estimated time]\n\n" +
"## Success Metrics\n[2–3 specific measurable outcomes that mean 'it worked']\n\n" +
"## Failure Criteria\n[What would tell you to stop and pivot]\n\n" +
"## Launch Checklist\n[5–10 items to check before showing to first users]\n\n" +
"Be ruthlessly minimal. Every feature you add is a hypothesis that needs its own validation.",
};
// Save MVP definition to idea
db.ideas[ideaIdx].mvpDefinition = `${params.mvpType} MVP for: ${params.coreProblem || idea.description.slice(0, 100)}`;
db.ideas[ideaIdx].updatedAt = new Date().toISOString();
await saveDB(dataDir(), db);
return json(payload);
}),
}),
tool({
name: "validation_dashboard",
description: text`
Cross-idea validation overview — your "where am I?" briefing.
Shows active ideas by validation status, pending experiments,
highest-risk untested assumptions, and overall validation progress.
Use this when you want a quick pulse on your entire idea portfolio.
`,
parameters: {
includeArchived: z.boolean().default(false).describe("Include archived/rejected ideas in the dashboard"),
},
implementation: safe_impl("validation_dashboard", async (params) => {
const db = await loadDB(dataDir());
const ideas = params.includeArchived
? db.ideas
: db.ideas.filter((i) => i.status !== "archived" && i.status !== "rejected");
const experiments = db.experiments ?? [];
// --- Validation status breakdown ---
const byValidation: Record<string, Idea[]> = {
not_started: [], in_progress: [], validated: [], invalidated: [],
};
for (const idea of ideas) {
const vs = idea.validationStatus ?? "not_started";
(byValidation[vs] ??= []).push(idea);
}
// --- Experiment stats ---
const activeIdeaIds = new Set(ideas.map((i) => i.id));
const relevantExperiments = experiments.filter((e) => activeIdeaIds.has(e.ideaId));
const pending = relevantExperiments.filter((e) => e.result === "pending");
const validated = relevantExperiments.filter((e) => e.result === "validated");
const invalidated = relevantExperiments.filter((e) => e.result === "invalidated");
const inconclusive = relevantExperiments.filter((e) => e.result === "inconclusive");
// --- Highest-risk untested assumptions ---
const untestedAssumptions: Array<{
ideaId: string; ideaTitle: string; assumption: Assumption;
}> = [];
for (const idea of ideas) {
for (const a of idea.assumptions ?? []) {
if (a.validatedBy.length === 0) {
untestedAssumptions.push({ ideaId: idea.id, ideaTitle: idea.title, assumption: a });
}
}
}
// Sort: critical/high risk first, then lowest confidence
const riskOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
untestedAssumptions.sort((a, b) => {
const rd = (riskOrder[a.assumption.riskLevel] ?? 3) - (riskOrder[b.assumption.riskLevel] ?? 3);
if (rd !== 0) return rd;
return a.assumption.confidence - b.assumption.confidence;
});
// --- Per-idea progress ---
const ideaProgress = ideas.map((idea) => {
const ideaExps = relevantExperiments.filter((e) => e.ideaId === idea.id);
const totalAssumptions = (idea.assumptions ?? []).length;
const testedAssumptions = (idea.assumptions ?? []).filter((a) => a.validatedBy.length > 0).length;
return {
id: idea.id,
title: idea.title,
status: idea.status,
validationStatus: idea.validationStatus ?? "not_started",
scores: { impact: idea.impactScore, feasibility: idea.feasibilityScore, novelty: idea.noveltyScore },
assumptions: { total: totalAssumptions, tested: testedAssumptions },
experiments: {
total: ideaExps.length,
pending: ideaExps.filter((e) => e.result === "pending").length,
validated: ideaExps.filter((e) => e.result === "validated").length,
invalidated: ideaExps.filter((e) => e.result === "invalidated").length,
},
};
});
return json({
summary: {
totalActiveIdeas: ideas.length,
byValidationStatus: {
not_started: byValidation.not_started.length,
in_progress: byValidation.in_progress.length,
validated: byValidation.validated.length,
invalidated: byValidation.invalidated.length,
},
experiments: {
total: relevantExperiments.length,
pending: pending.length,
validated: validated.length,
invalidated: invalidated.length,
inconclusive: inconclusive.length,
},
untestedAssumptions: untestedAssumptions.length,
},
needsAttention: {
pendingExperiments: pending.slice(0, 10).map((e) => ({
id: e.id, ideaId: e.ideaId, title: e.title, type: e.type,
hypothesis: e.hypothesis, effort: e.effort,
})),
highRiskUntested: untestedAssumptions.slice(0, 10).map((a) => ({
ideaId: a.ideaId, ideaTitle: a.ideaTitle,
assumptionId: a.assumption.id,
statement: a.assumption.statement,
type: a.assumption.type,
riskLevel: a.assumption.riskLevel,
confidence: a.assumption.confidence,
})),
ideasWithNoExperiments: ideas
.filter((i) => i.validationStatus !== "validated" && i.validationStatus !== "invalidated")
.filter((i) => !relevantExperiments.some((e) => e.ideaId === i.id))
.map((i) => ({ id: i.id, title: i.title, status: i.validationStatus ?? "not_started" })),
},
ideaProgress,
});
}),
}),
];
return tools;
};
src / toolsProvider.ts
/**
* Ideas & Pain Points Plugin — toolsProvider (11 tools)
*
* Tools:
* Ideas · manage_idea(action)
* Pain Points · manage_pain_point(action)
* Analysis · evaluate_idea, generate_problem_statement, link_pain_to_idea
* Generation · generate(type)
* Research · research(topic)
* Validation · validate(action), define_mvp
* Dashboard · validation_dashboard
* Export · export_report
*/
import { text, tool, type Tool, type ToolCallContext, type ToolsProvider } from "@lmstudio/sdk";
import { readFile, writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { homedir } from "os";
import { webSearch as _webSearch, type TimeRange } from "./search";
import { detectWebPeer } from "./peers";
import { z } from "zod";
import { pluginConfigSchematics } from "./config";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function json(obj: unknown): string {
return JSON.stringify(obj, null, 2);
}
function safe_impl<T extends Record<string, unknown>>(
name: string,
fn: (params: T, ctx: ToolCallContext) => Promise<string>
): (params: T, ctx: ToolCallContext) => Promise<string> {
return async (params: T, ctx: ToolCallContext) => {
if (ctx.signal.aborted) {
return JSON.stringify({ tool_error: true, tool: name, error: "cancelled" });
}
try {
return await fn(params, ctx);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return JSON.stringify({
tool_error: true,
tool: name,
error: msg,
hint: "Read the error above, fix the parameter causing the issue, and retry the tool call.",
}, null, 2);
}
};
}
// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------
type ImpactLevel = "low" | "medium" | "high" | "critical";
type StatusType = "active" | "archived" | "validated" | "rejected" | "in_progress";
type ExperimentType =
| "customer_interview"
| "landing_page"
| "fake_door"
| "smoke_test"
| "concierge_mvp"
| "wizard_of_oz"
| "survey"
| "prototype"
| "a_b_test"
| "cold_outreach"
| "other";
type ExperimentResult = "pending" | "validated" | "invalidated" | "inconclusive";
interface Assumption {
id: string;
statement: string; // "Users will pay $X/month for Y"
type: "desirability" | "feasibility" | "viability" | "usability";
riskLevel: "low" | "medium" | "high" | "critical";
confidence: number; // 0–100 current confidence
validatedBy: string[]; // experiment IDs that tested this
}
interface ValidationExperiment {
id: string;
ideaId: string;
title: string;
type: ExperimentType;
hypothesis: string; // "We believe that..."
method: string; // How to run the experiment
successCriteria: string; // What a "pass" looks like (measurable)
effort: "hours" | "days" | "weeks";
cost: string; // Estimated cost
result: ExperimentResult;
evidence: string; // What was observed
learnings: string; // What was learned
testedAssumptionIds: string[];
createdAt: string;
updatedAt: string;
}
interface Idea {
id: string;
title: string;
description: string;
category: string;
tags: string[];
status: StatusType;
impactScore: number; // 1–10
feasibilityScore: number; // 1–10
noveltyScore: number; // 1–10
effortScore: number; // 1–10 (higher = more effort)
linkedPainPointIds: string[];
assumptions: Assumption[]; // Key assumptions to validate
mvpDefinition: string; // Smallest testable version
validationStatus: "not_started" | "in_progress" | "validated" | "invalidated";
notes: string;
createdAt: string;
updatedAt: string;
}
interface PainPoint {
id: string;
title: string;
description: string;
context: string;
affectedUsers: string;
frequency: "rare" | "occasional" | "frequent" | "constant";
impact: ImpactLevel;
category: string;
tags: string[];
status: StatusType;
problemStatement: string;
linkedIdeaIds: string[];
notes: string;
createdAt: string;
updatedAt: string;
}
interface IdeasDB {
ideas: Idea[];
painPoints: PainPoint[];
experiments: ValidationExperiment[];
}
function getDataDir(configPath: string): string {
const p = configPath.trim() || join(homedir(), "ideas-data");
return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
}
function dbPath(dataDir: string): string {
return join(dataDir, "ideas.json");
}
async function loadDB(dataDir: string): Promise<IdeasDB> {
let raw: string;
try {
raw = await readFile(dbPath(dataDir), "utf8");
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return { ideas: [], painPoints: [], experiments: [] };
}
throw err;
}
const db = JSON.parse(raw) as IdeasDB;
if (!db.ideas) db.ideas = [];
if (!db.painPoints) db.painPoints = [];
if (!db.experiments) db.experiments = [];
// Hydrate fields added after initial release so old records don't crash
for (const idea of db.ideas) {
idea.assumptions ??= [];
idea.mvpDefinition ??= "";
idea.validationStatus ??= "not_started";
idea.linkedPainPointIds ??= [];
idea.tags ??= [];
}
for (const pp of db.painPoints) {
pp.linkedIdeaIds ??= [];
pp.tags ??= [];
}
return db;
}
async function saveDB(dataDir: string, db: IdeasDB): Promise<void> {
await mkdir(dataDir, { recursive: true });
await writeFile(dbPath(dataDir), JSON.stringify(db, null, 2), "utf8");
}
function makeId(): string {
return crypto.randomUUID();
}
function calcPriority(idea: Pick<Idea, "impactScore" | "feasibilityScore" | "noveltyScore" | "effortScore">): number {
return Math.round(
((idea.impactScore + idea.feasibilityScore + idea.noveltyScore - idea.effortScore) / 3) * 10
) / 10;
}
// ---------------------------------------------------------------------------
// Tools Provider
// ---------------------------------------------------------------------------
export const toolsProvider: ToolsProvider = async (ctl) => {
const cfg = ctl.getPluginConfig(pluginConfigSchematics);
const dataDir = () => getDataDir(cfg.get("dataPath"));
const maxResults = () => cfg.get("maxSearchResults");
const searxng = () => cfg.get("searxngUrl").trim() || undefined;
const searchWindow = (): TimeRange | undefined => {
const v = cfg.get("searchRecencyWindow").trim().toLowerCase();
return (["day", "week", "month", "year"].includes(v) ? v : undefined) as TimeRange | undefined;
};
const webSearch = (query: string, max?: number, timeRange?: TimeRange) =>
_webSearch(query, max, 10_000, searxng(), timeRange ?? searchWindow());
const webPeerLoaded = await detectWebPeer(ctl as unknown as { client: any });
const tools: Tool[] = [
// =========================================================================
// IDEAS
// =========================================================================
tool({
name: "manage_idea",
description: text`
Manage ideas in the database.
action: "capture" — save a new idea with scores
action: "list" — list ideas with optional filters and sort
action: "get" — get full details of a single idea by ID
action: "update" — update fields on an existing idea
action: "delete" — permanently delete an idea (cascades experiments and pain point links)
`,
parameters: {
action: z.enum(["capture","list","get","update","delete"]).describe("Operation to perform"),
id: z.string().default("").describe("get/update/delete: idea ID"),
title: z.string().default("").describe("capture: required. update: optional"),
description: z.string().default("").describe("capture: required. update: optional"),
category: z.string().default("general").describe("capture/update: product, feature, process, research, business, etc."),
tags: z.array(z.string()).default([]).describe("capture/update: tags for filtering"),
impactScore: z.coerce.number().int().min(1).max(10).optional().describe("1=low, 10=transformative"),
feasibilityScore: z.coerce.number().int().min(1).max(10).optional().describe("1=nearly impossible, 10=trivial"),
noveltyScore: z.coerce.number().int().min(1).max(10).optional().describe("1=common, 10=very original"),
effortScore: z.coerce.number().int().min(1).max(10).optional().describe("1=hours, 10=years"),
status: z.enum(["active","archived","validated","rejected","in_progress"]).optional(),
validationStatus: z.enum(["not_started","in_progress","validated","invalidated"]).optional(),
mvpDefinition: z.string().optional(),
notes: z.string().default(""),
filterStatus: z.enum(["active","archived","validated","rejected","in_progress","all"]).default("active"),
filterCategory: z.string().default(""),
filterTag: z.string().default(""),
search: z.string().default(""),
sortBy: z.enum(["priority","impact","feasibility","novelty","effort","createdAt"]).default("priority"),
},
implementation: safe_impl("manage_idea", async (params) => {
const { action } = params;
const db = await loadDB(dataDir());
if (action === "capture") {
if (!params.title) throw new Error("title is required for action=capture");
if (!params.description) throw new Error("description is required for action=capture");
const idea: Idea = {
id: makeId(),
title: params.title,
description: params.description,
category: params.category,
tags: params.tags,
status: "active",
impactScore: params.impactScore ?? 5,
feasibilityScore: params.feasibilityScore ?? 5,
noveltyScore: params.noveltyScore ?? 5,
effortScore: params.effortScore ?? 5,
linkedPainPointIds: [],
assumptions: [],
mvpDefinition: "",
validationStatus: "not_started",
notes: params.notes,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
db.ideas.push(idea);
await saveDB(dataDir(), db);
return json({ success: true, idea, priorityScore: calcPriority(idea) });
}
if (action === "list") {
let ideas = db.ideas;
if (params.filterStatus !== "all") ideas = ideas.filter((i) => i.status === params.filterStatus);
if (params.filterCategory) ideas = ideas.filter((i) => i.category.toLowerCase().includes(params.filterCategory.toLowerCase()));
if (params.filterTag) ideas = ideas.filter((i) => i.tags.some((t) => t.toLowerCase().includes(params.filterTag.toLowerCase())));
if (params.search) {
const kw = params.search.toLowerCase();
ideas = ideas.filter((i) => i.title.toLowerCase().includes(kw) || i.description.toLowerCase().includes(kw));
}
const withScore = ideas.map((i) => ({ ...i, priorityScore: calcPriority(i) }));
withScore.sort((a, b) => {
switch (params.sortBy) {
case "priority": return b.priorityScore - a.priorityScore;
case "impact": return b.impactScore - a.impactScore;
case "feasibility": return b.feasibilityScore - a.feasibilityScore;
case "novelty": return b.noveltyScore - a.noveltyScore;
case "effort": return a.effortScore - b.effortScore;
case "createdAt": return b.createdAt.localeCompare(a.createdAt);
}
});
const summary = withScore.map((i) => ({
id: i.id, title: i.title, category: i.category, tags: i.tags, status: i.status,
impact: i.impactScore, feasibility: i.feasibilityScore, novelty: i.noveltyScore,
effort: i.effortScore, priorityScore: i.priorityScore,
linkedPainPoints: i.linkedPainPointIds.length, createdAt: i.createdAt.slice(0, 10),
}));
return json({ total: summary.length, sortBy: params.sortBy, ideas: summary });
}
if (action === "get") {
const idea = db.ideas.find((i) => i.id === params.id);
if (!idea) throw new Error(`Idea ID '${params.id}' not found.`);
const linkedPainPoints = db.painPoints.filter((p) => idea.linkedPainPointIds.includes(p.id));
return json({ ...idea, linkedPainPoints });
}
if (action === "update") {
const idx = db.ideas.findIndex((i) => i.id === params.id);
if (idx === -1) throw new Error(`Idea ID '${params.id}' not found.`);
const updateFields = {
title: params.title || undefined,
description: params.description || undefined,
category: params.category !== "general" ? params.category : undefined,
tags: params.tags.length ? params.tags : undefined,
status: params.status,
impactScore: params.impactScore,
feasibilityScore: params.feasibilityScore,
noveltyScore: params.noveltyScore,
effortScore: params.effortScore,
validationStatus: params.validationStatus,
mvpDefinition: params.mvpDefinition,
notes: params.notes || undefined,
};
const patch = Object.fromEntries(Object.entries(updateFields).filter(([, v]) => v !== undefined));
db.ideas[idx] = { ...db.ideas[idx], ...patch, updatedAt: new Date().toISOString() } as Idea;
await saveDB(dataDir(), db);
return json({ success: true, idea: db.ideas[idx] });
}
if (action === "delete") {
const before = db.ideas.length;
db.ideas = db.ideas.filter((i) => i.id !== params.id);
if (db.ideas.length === before) throw new Error(`Idea ID '${params.id}' not found.`);
const removedExperiments = (db.experiments ?? []).filter((e) => e.ideaId === params.id).length;
db.experiments = (db.experiments ?? []).filter((e) => e.ideaId !== params.id);
for (const pp of db.painPoints) {
pp.linkedIdeaIds = pp.linkedIdeaIds.filter((lid) => lid !== params.id);
}
await saveDB(dataDir(), db);
return json({ success: true, deleted: params.id, removedExperiments });
}
throw new Error(`Unknown action: ${action}`);
}),
}),
// =========================================================================
// PAIN POINTS
// =========================================================================
tool({
name: "manage_pain_point",
description: text`
Manage pain points in the database.
action: "capture" — log a new pain point
action: "list" — list pain points with optional filters
action: "get" — get full details of a single pain point by ID
action: "update" — update fields on an existing pain point
action: "delete" — permanently delete a pain point (cleans up idea links)
`,
parameters: {
action: z.enum(["capture","list","get","update","delete"]).describe("Operation to perform"),
id: z.string().default("").describe("get/update/delete: pain point ID"),
title: z.string().default("").describe("capture: required. update: optional"),
description: z.string().default("").describe("capture: required. update: optional"),
context: z.string().default("").describe("Where/when this was observed"),
affectedUsers: z.string().default("").describe("Who experiences this pain"),
frequency: z.enum(["rare","occasional","frequent","constant"]).optional(),
impact: z.enum(["low","medium","high","critical"]).optional(),
category: z.string().default("general"),
tags: z.array(z.string()).default([]),
status: z.enum(["active","archived","validated","rejected","in_progress"]).optional(),
problemStatement: z.string().optional(),
notes: z.string().default(""),
filterStatus: z.enum(["active","archived","validated","rejected","in_progress","all"]).default("active"),
filterImpact: z.enum(["low","medium","high","critical","all"]).default("all"),
filterCategory: z.string().default(""),
filterTag: z.string().default(""),
search: z.string().default(""),
},
implementation: safe_impl("manage_pain_point", async (params) => {
const { action } = params;
const db = await loadDB(dataDir());
if (action === "capture") {
if (!params.title) throw new Error("title is required for action=capture");
if (!params.description) throw new Error("description is required for action=capture");
const pp: PainPoint = {
id: makeId(),
title: params.title,
description: params.description,
context: params.context,
affectedUsers: params.affectedUsers,
frequency: params.frequency ?? "occasional",
impact: params.impact ?? "medium",
category: params.category,
tags: params.tags,
status: "active",
problemStatement: "",
linkedIdeaIds: [],
notes: params.notes,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
db.painPoints.push(pp);
await saveDB(dataDir(), db);
return json({ success: true, painPoint: pp });
}
if (action === "list") {
let pps = db.painPoints;
if (params.filterStatus !== "all") pps = pps.filter((p) => p.status === params.filterStatus);
if (params.filterCategory) pps = pps.filter((p) => p.category.toLowerCase().includes(params.filterCategory.toLowerCase()));
if (params.filterImpact !== "all") pps = pps.filter((p) => p.impact === params.filterImpact);
if (params.filterTag) pps = pps.filter((p) => p.tags.some((t) => t.toLowerCase().includes(params.filterTag.toLowerCase())));
if (params.search) {
const kw = params.search.toLowerCase();
pps = pps.filter((p) => p.title.toLowerCase().includes(kw) || p.description.toLowerCase().includes(kw));
}
const impactOrder: Record<ImpactLevel, number> = { critical: 4, high: 3, medium: 2, low: 1 };
pps = pps.slice().sort((a, b) => (impactOrder[b.impact] ?? 0) - (impactOrder[a.impact] ?? 0));
const summary = pps.map((p) => ({
id: p.id, title: p.title, category: p.category, impact: p.impact, frequency: p.frequency,
affectedUsers: p.affectedUsers, tags: p.tags, status: p.status,
hasProblemStatement: !!p.problemStatement, linkedIdeas: p.linkedIdeaIds.length,
createdAt: p.createdAt.slice(0, 10),
}));
return json({ total: summary.length, painPoints: summary });
}
if (action === "get") {
const pp = db.painPoints.find((p) => p.id === params.id);
if (!pp) throw new Error(`Pain point ID '${params.id}' not found.`);
const linkedIdeas = db.ideas.filter((i) => pp.linkedIdeaIds.includes(i.id));
return json({ ...pp, linkedIdeas });
}
if (action === "update") {
const idx = db.painPoints.findIndex((p) => p.id === params.id);
if (idx === -1) throw new Error(`Pain point ID '${params.id}' not found.`);
const updateFields = {
title: params.title || undefined,
description: params.description || undefined,
context: params.context || undefined,
affectedUsers: params.affectedUsers || undefined,
frequency: params.frequency,
impact: params.impact,
category: params.category !== "general" ? params.category : undefined,
tags: params.tags.length ? params.tags : undefined,
status: params.status,
problemStatement: params.problemStatement,
notes: params.notes || undefined,
};
const patch = Object.fromEntries(Object.entries(updateFields).filter(([, v]) => v !== undefined));
db.painPoints[idx] = { ...db.painPoints[idx], ...patch, updatedAt: new Date().toISOString() } as PainPoint;
await saveDB(dataDir(), db);
return json({ success: true, painPoint: db.painPoints[idx] });
}
if (action === "delete") {
const before = db.painPoints.length;
db.painPoints = db.painPoints.filter((p) => p.id !== params.id);
if (db.painPoints.length === before) throw new Error(`Pain point ID '${params.id}' not found.`);
for (const idea of db.ideas) {
idea.linkedPainPointIds = idea.linkedPainPointIds.filter((lid) => lid !== params.id);
}
await saveDB(dataDir(), db);
return json({ success: true, deleted: params.id });
}
throw new Error(`Unknown action: ${action}`);
}),
}),
// =========================================================================
// ANALYSIS & GENERATION
// =========================================================================
tool({
name: "generate_problem_statement",
description: text`
Generate a structured, formal problem statement from a pain point description.
Uses the "Who / What / Why / Impact" framework and the Jobs-to-be-Done lens.
After generating, call update_pain_point with the problemStatement field to save it.
`,
parameters: {
painPointId: z.string().default("")
.describe("Pain point ID to load from database (leave blank to use inline text)"),
title: z.string().default("").describe("Pain point title (if not using ID)"),
description: z.string().default("").describe("Pain point description (if not using ID)"),
affectedUsers: z.string().default("").describe("Who is affected"),
context: z.string().default("").describe("Context where this pain occurs"),
impact: z.string().default("").describe("Impact severity or business cost"),
},
implementation: safe_impl("generate_problem_statement", async (params) => {
let title = params.title;
let description = params.description;
let affectedUsers = params.affectedUsers;
let context = params.context;
let impact = params.impact;
if (params.painPointId) {
const db = await loadDB(dataDir());
const pp = db.painPoints.find((p) => p.id === params.painPointId);
if (!pp) throw new Error(`Pain point ID '${params.painPointId}' not found.`);
title = title || pp.title;
description = description || pp.description;
affectedUsers = affectedUsers || pp.affectedUsers;
context = context || pp.context;
impact = impact || pp.impact;
}
if (!title && !description) {
throw new Error("Provide either a painPointId or at minimum title and description.");
}
const payload = {
title,
description,
affectedUsers,
context,
impact,
painPointId: params.painPointId || null,
instructions:
"Generate a crisp, structured problem statement using this framework:\n" +
"**Who**: [specific user segment]\n" +
"**What**: [the core problem they face — not the symptom, the root cause]\n" +
"**When/Where**: [the context/trigger for the problem]\n" +
"**Why it matters**: [quantified or qualified impact]\n" +
"**Current workarounds**: [how they cope today and why it's insufficient]\n" +
"**Success criterion**: [what 'solved' looks like]\n\n" +
"Then write a single-sentence problem statement suitable for a product brief. " +
"Keep the whole output under 200 words.",
};
return json(payload);
}),
}),
tool({
name: "evaluate_idea",
description: text`
Deeply evaluate an idea across multiple dimensions:
market potential, technical feasibility, differentiation, risks, and time-to-value.
Produces a scorecard and go/no-go recommendation.
`,
parameters: {
ideaId: z.string().default("")
.describe("Idea ID to load from database (leave blank to use inline text)"),
title: z.string().default("").describe("Idea title (if not using ID)"),
description: z.string().default("").describe("Idea description (if not using ID)"),
targetMarket: z.string().default("").describe("Who would use/buy this"),
competitorContext: z.string().default("")
.describe("Known competitors or existing solutions"),
},
implementation: safe_impl("evaluate_idea", async (params) => {
let title = params.title;
let description = params.description;
if (params.ideaId) {
const db = await loadDB(dataDir());
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (!idea) throw new Error(`Idea ID '${params.ideaId}' not found.`);
title = title || idea.title;
description = description || idea.description;
}
return json({
title,
description,
targetMarket: params.targetMarket,
competitorContext: params.competitorContext,
instructions:
"Evaluate this idea across these dimensions (score each 1–10 with explicit rationale — do not assign scores without reasoning):\n" +
"1. **Market Size**: What evidence supports the market size claim? Note if unknown.\n" +
"2. **Problem Severity**: How painful is the problem — and for whom specifically? Do not generalize.\n" +
"3. **Differentiation**: What actually exists today that competes? Be specific — do not assume the space is open.\n" +
"4. **Technical Feasibility**: What are the real technical risks, not just the optimistic case?\n" +
"5. **Time to Value**: How quickly can users see value — and what must go right for that to happen?\n" +
"6. **Moat/Defensibility**: What would stop a well-funded competitor from copying this in 6 months?\n\n" +
"Then list:\n" +
"- Top 3 risks — include risks that could kill the idea entirely, not just manageable ones\n" +
"- Top 3 assumptions that must be validated before spending significant time or money\n" +
"- Recommended next step — base this on the weakest dimension above, not the strongest\n" +
"- Assessment: present arguments FOR and AGAINST proceeding; let the user form their own conclusion. " +
" Do not issue a single go/no-go verdict as if it were objective — it is not.",
});
}),
}),
tool({
name: "link_pain_to_idea",
description: text`
Create a bidirectional link between a pain point and an idea.
Useful for mapping which ideas address which pain points.
`,
parameters: {
painPointId: z.string().describe("Pain point ID"),
ideaId: z.string().describe("Idea ID"),
},
implementation: safe_impl("link_pain_to_idea", async ({ painPointId, ideaId }) => {
const db = await loadDB(dataDir());
const ppIdx = db.painPoints.findIndex((p) => p.id === painPointId);
const ideaIdx = db.ideas.findIndex((i) => i.id === ideaId);
if (ppIdx === -1) throw new Error(`Pain point ID '${painPointId}' not found.`);
if (ideaIdx === -1) throw new Error(`Idea ID '${ideaId}' not found.`);
const pp = db.painPoints[ppIdx];
const idea = db.ideas[ideaIdx];
if (!pp.linkedIdeaIds.includes(ideaId)) {
pp.linkedIdeaIds.push(ideaId);
pp.updatedAt = new Date().toISOString();
}
if (!idea.linkedPainPointIds.includes(painPointId)) {
idea.linkedPainPointIds.push(painPointId);
idea.updatedAt = new Date().toISOString();
}
await saveDB(dataDir(), db);
return json({
success: true,
painPoint: { id: pp.id, title: pp.title, linkedIdeas: pp.linkedIdeaIds },
idea: { id: idea.id, title: idea.title, linkedPainPoints: idea.linkedPainPointIds },
});
}),
}),
tool({
name: "generate",
description: text`
Content generation for idea development.
type: "solution_brief" — generate a concise mini product spec from a pain point + idea pair
type: "brainstorm" — generate diverse solution ideas for a problem
type: "landing_page" — generate ready-to-use landing page copy
`,
parameters: {
type: z.enum(["solution_brief","brainstorm","landing_page"]).describe("What to generate"),
painPointId: z.string().default(""),
ideaId: z.string().default(""),
problemDescription: z.string().default(""),
solutionDescription: z.string().default(""),
targetUsers: z.string().default(""),
constraints: z.string().default("").describe("brainstorm: budget, tech stack, timeline, team size constraints"),
approachCount: z.coerce.number().int().min(3).max(15).default(6).describe("brainstorm: number of solution ideas"),
productName: z.string().default(""),
tagline: z.string().default(""),
coreBenefit: z.string().default(""),
topPainPoint: z.string().default(""),
ctaGoal: z.enum(["email_signup","waitlist","book_demo","early_access","buy_now"]).default("waitlist"),
tone: z.enum(["professional","conversational","bold","empathetic"]).default("conversational"),
},
implementation: safe_impl("generate", async (params) => {
const db = await loadDB(dataDir());
if (params.type === "solution_brief") {
let problem = params.problemDescription;
let solution = params.solutionDescription;
let target = params.targetUsers;
if (params.painPointId) {
const pp = db.painPoints.find((p) => p.id === params.painPointId);
if (!pp) throw new Error(`Pain point ID '${params.painPointId}' not found.`);
problem = problem || `${pp.title}: ${pp.description}`; target = target || pp.affectedUsers;
}
if (params.ideaId) {
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (!idea) throw new Error(`Idea ID '${params.ideaId}' not found.`);
solution = solution || `${idea.title}: ${idea.description}`;
}
return json({ problem, solution, targetUsers: target, instructions: "Write a solution brief with sections: ## Problem, ## Proposed Solution, ## Target Users, ## Core Features (MVP) (3–5 bullets, must-haves only), ## Success Metrics (2–3 measurable KPIs), ## What We're NOT Building, ## Open Questions. Under 400 words. Be specific and opinionated." });
}
if (params.type === "brainstorm") {
let problem = params.problemDescription;
if (params.painPointId) {
const pp = db.painPoints.find((p) => p.id === params.painPointId);
if (!pp) throw new Error(`Pain point ID '${params.painPointId}' not found.`);
problem = problem || `${pp.title}: ${pp.description}`;
}
if (!problem) throw new Error("Provide either a painPointId or problemDescription.");
return json({ problem, constraints: params.constraints, approachCount: params.approachCount, instructions: `Generate ${params.approachCount} distinct solution ideas for the problem above. For each: - **Idea**: One-line description, - **Approach type**: (SaaS / automation / platform / community / hardware / process), - **Core insight**: The key insight that makes this work, - **Pros**: 2-3 bullets, - **Cons**: 1-2 bullets, - **Effort estimate**: hours / days / weeks / months. Include at least one unconventional or contrarian idea.` });
}
if (params.type === "landing_page") {
let description = "";
let title = params.productName;
if (params.ideaId) {
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (idea) { description = idea.description; title = title || idea.title; }
}
const ctaCopy: Record<string, { primary: string; secondary: string }> = {
email_signup: { primary: "Get Early Access", secondary: "Notify me when it launches" },
waitlist: { primary: "Join the Waitlist", secondary: "Be first to know" },
book_demo: { primary: "Book a Demo", secondary: "See it in action" },
early_access: { primary: "Request Early Access", secondary: "Join 100+ early users" },
buy_now: { primary: "Get Started", secondary: "Start your free trial" },
};
return json({ productName: title || "Your Product", targetUser: params.targetUsers, coreBenefit: params.coreBenefit, topPainPoint: params.topPainPoint, ideaDescription: description, ctaGoal: params.ctaGoal, ctaSuggestions: ctaCopy[params.ctaGoal], tone: params.tone, instructions: `Write complete landing page copy for "${title || "this product"}" targeting ${params.targetUsers}. Core benefit: ${params.coreBenefit}. Tone: ${params.tone}. CTA: ${params.ctaGoal.replace(/_/g, " ")}.\n\nProduce exactly:\n**HEADLINE** (3 options A/B/C — 8 words max each)\n**SUBHEADLINE** (1–2 sentences)\n**HERO SECTION** (2–3 sentences)\n**3 FEATURE BULLETS** (benefit-first, 10 words max)\n**SOCIAL PROOF PLACEHOLDER**\n**CTA COPY** (button text + micro-copy)\n**FAQ** (3 most common objections)\n**META DESCRIPTION** (155 chars)\n\nNo jargon. Start with the user's pain.` });
}
throw new Error(`Unknown type: ${params.type}`);
}),
}),
// =========================================================================
// RESEARCH — omitted when altra/web-search peer is loaded
// =========================================================================
...(webPeerLoaded ? [] : [tool({
name: "research",
description: text`
Web research for idea validation.
topic: "similar" — search for similar problems, existing solutions, competitors, and discussions
topic: "market_size" — search for TAM/SAM/SOM data, growth rates, and industry reports
topic: "competitors" — find existing products and startups solving the same problem
NOTE: if altra/web-search plugin is installed use its "search" tool instead.
`,
parameters: {
topic: z.enum(["similar","market_size","competitors"]).describe("What to research"),
query: z.string().describe("Problem, idea, or market to research"),
focus: z.enum(["competitors","solutions","research","discussions","general"]).default("general")
.describe("similar: what angle to focus on"),
region: z.string().default("global").describe("market_size: geographic region (global, US, India, etc.)"),
category: z.string().default("").describe("competitors: optional category (e.g. 'B2B SaaS', 'dev tool')"),
max: z.coerce.number().int().min(3).max(20).optional().describe("Max results"),
},
implementation: safe_impl("research", async ({ topic, query, focus, region, category, max }, ctx) => {
const limit = max ?? maxResults();
if (topic === "similar") {
ctx.status(`Searching similar problems: ${query}`);
const suffixMap: Record<string, string> = {
competitors: "competitor product solution", solutions: "how to solve solution tool",
research: "research study statistics", discussions: "reddit hackernews discussion forum", general: "",
};
const fullQuery = `${query} ${suffixMap[focus] ?? ""}`.trim();
const hits = await webSearch(fullQuery, limit, searchWindow());
return json({ query: fullQuery, focus, results: hits });
}
if (topic === "market_size") {
ctx.status(`Researching market size: ${query}`);
const yr = new Date().getFullYear();
const queries = [
`${query} market size TAM ${region} ${yr} billion`,
`${query} industry report growth rate CAGR ${yr} ${yr + 1}`,
`site:statista.com OR site:grandviewresearch.com OR site:mordorintelligence.com ${query} market ${yr}`,
];
const settled = await Promise.allSettled(queries.map((q) => webSearch(q, 4, searchWindow())));
const allResults: Array<{ title: string; url: string; snippet: string }> = [];
for (const r of settled) { if (r.status === "fulfilled") allResults.push(...r.value); }
const seen = new Set<string>();
const deduped = allResults.filter((r) => { if (seen.has(r.url)) return false; seen.add(r.url); return true; });
return json({
market: query, region,
searchResults: deduped.slice(0, limit),
tamFramework: {
topDown: "Find a published industry report (Statista, Grand View Research, IBISWorld) and filter to your specific segment.",
bottomUp: "Count your target customers × annual spend per customer = TAM.",
valueTheory: "What % of value you create could you reasonably capture as revenue? Work backwards from that.",
},
instructions:
"Using the search results above, present market size data as found — do not fabricate figures. " +
"Include: (1) TAM — cite the source for each figure, (2) SAM — explain segmentation logic, " +
"(3) SOM — range of realistic estimates, (4) Market dynamics — both tailwinds AND headwinds, " +
"(5) Data quality — flag if figures are old or from a single source. Do not conclude 'should' build.",
});
}
if (topic === "competitors") {
ctx.status(`Scanning competitors for: ${query}`);
const cat = category ? ` ${category}` : "";
const searches = [
{ angle: "direct_competitors", query: `${query}${cat} alternatives competitors` },
{ angle: "existing_products", query: `${query}${cat} best tools software app` },
{ angle: "startup_landscape", query: `${query}${cat} startup crunchbase wellfound product hunt` },
{ angle: "open_source", query: `${query}${cat} open source github solution` },
];
const searchHits = await Promise.all(searches.map((s) => webSearch(s.query, limit, searchWindow()).catch(() => [])));
const results: Record<string, unknown> = Object.fromEntries(searches.map((s, i) => [s.angle, searchHits[i]]));
return json({
idea: query, category: category || "general", competitorSearch: results,
instructions:
"Produce a neutral competitive landscape — do not assume gaps exist before looking. " +
"(1) List significant competitors: name, what they do, pricing, strengths AND weaknesses. " +
"(2) List open-source alternatives found. " +
"(3) Gaps — only report genuinely unaddressed ones; say so if none. " +
"(4) Differentiation — only if a credible angle exists from the data. " +
"(5) Market saturation: cite the evidence (funding, product count, review volume).",
});
}
throw new Error(`Unknown topic: ${topic}`);
}),
})]),
// =========================================================================
// EXPORT
// =========================================================================
tool({
name: "export_report",
description: text`
Export a formatted Markdown report of all ideas and pain points.
Includes summaries, scores, links, and statistics.
Returns the file path where the report was saved.
`,
parameters: {
outputPath: z.string().default("")
.describe("File path to save the report. Defaults to <dataPath>/report-<date>.md"),
includeArchived: z.coerce.boolean().default(false)
.describe("Include archived/rejected items"),
},
implementation: safe_impl("export_report", async ({ outputPath, includeArchived }) => {
const db = await loadDB(dataDir());
const date = new Date().toISOString().slice(0, 10);
const outPath = outputPath.trim() || join(dataDir(), `ideas-report-${date}.md`);
const filterStatus = (status: string) =>
includeArchived || (status !== "archived" && status !== "rejected");
const ideas = db.ideas.filter((i) => filterStatus(i.status));
const pps = db.painPoints.filter((p) => filterStatus(p.status));
let md = `# Ideas & Pain Points Report — ${date}\n\n`;
// Stats
md += `## Summary\n\n`;
md += `| Metric | Count |\n|--------|-------|\n`;
md += `| Total Ideas | ${db.ideas.length} |\n`;
md += `| Total Pain Points | ${db.painPoints.length} |\n`;
md += `| Active Ideas | ${db.ideas.filter((i) => i.status === "active").length} |\n`;
md += `| Validated Ideas | ${db.ideas.filter((i) => i.status === "validated").length} |\n`;
md += `| Critical Pain Points | ${db.painPoints.filter((p) => p.impact === "critical").length} |\n`;
md += `\n---\n\n`;
// Top ideas by priority
const topIdeas = ideas
.map((i) => ({ ...i, priority: calcPriority(i) }))
.sort((a, b) => b.priority - a.priority)
.slice(0, 10);
md += `## Top Ideas by Priority\n\n`;
for (const i of topIdeas) {
md += `### ${i.title} *(${i.status})*\n`;
md += `**Category**: ${i.category} | **Priority Score**: ${i.priority.toFixed(1)} \n`;
md += `**Scores**: Impact ${i.impactScore}/10 · Feasibility ${i.feasibilityScore}/10 · Novelty ${i.noveltyScore}/10 · Effort ${i.effortScore}/10 \n`;
if (i.tags.length > 0) md += `**Tags**: ${i.tags.join(", ")} \n`;
md += `\n${i.description}\n\n`;
if (i.notes) md += `> **Notes**: ${i.notes}\n\n`;
if (i.linkedPainPointIds.length > 0) {
const linked = db.painPoints.filter((p) => i.linkedPainPointIds.includes(p.id));
md += `**Addresses pain points**: ${linked.map((p) => p.title).join(", ")} \n`;
}
md += `\n`;
}
// Pain points by impact
const sortedPPs = pps.slice().sort((a, b) => {
const order: Record<ImpactLevel, number> = { critical: 4, high: 3, medium: 2, low: 1 };
return (order[b.impact] ?? 0) - (order[a.impact] ?? 0);
});
md += `---\n\n## Pain Points\n\n`;
for (const p of sortedPPs) {
md += `### ${p.title} *(${p.impact} impact · ${p.frequency})*\n`;
md += `**Category**: ${p.category} | **Status**: ${p.status} \n`;
if (p.affectedUsers) md += `**Affected users**: ${p.affectedUsers} \n`;
if (p.context) md += `**Context**: ${p.context} \n`;
if (p.tags.length > 0) md += `**Tags**: ${p.tags.join(", ")} \n`;
md += `\n${p.description}\n\n`;
if (p.problemStatement) md += `**Problem Statement**: ${p.problemStatement}\n\n`;
if (p.linkedIdeaIds.length > 0) {
const linked = db.ideas.filter((i) => p.linkedIdeaIds.includes(i.id));
md += `**Potential solutions**: ${linked.map((i) => i.title).join(", ")} \n`;
}
md += `\n`;
}
await mkdir(dataDir(), { recursive: true });
await writeFile(outPath, md, "utf8");
return json({
success: true,
path: outPath,
ideasIncluded: topIdeas.length,
painPointsIncluded: sortedPPs.length,
});
}),
}),
// =========================================================================
// RESEARCH — MARKET SIZE
// =========================================================================
// =========================================================================
// VALIDATION
// =========================================================================
tool({
name: "validate",
description: text`
Lean validation workflows for ideas.
action: "map_assumptions" — identify and prioritize critical assumptions
action: "design_experiment" — design a specific lean validation experiment
action: "log_result" — record the outcome of a completed experiment
action: "questions" — generate customer discovery interview/survey questions
action: "scorecard" — build a validation scorecard from all experiments so far
`,
parameters: {
action: z.enum(["map_assumptions","design_experiment","log_result","questions","scorecard"])
.describe("Validation operation to perform"),
ideaId: z.string().default("").describe("Idea ID (required for design_experiment, log_result, scorecard)"),
title: z.string().default("").describe("Idea title (if not using ideaId)"),
description: z.string().default("").describe("Idea description (if not using ideaId)"),
targetUsers: z.string().default("").describe("Who the idea is for"),
revenueModel: z.string().default("").describe("map_assumptions: how the idea makes money"),
assumption: z.string().default("").describe("design_experiment: the specific assumption being tested"),
experimentType: z.enum([
"customer_interview","landing_page","fake_door","smoke_test",
"concierge_mvp","wizard_of_oz","survey","prototype","a_b_test","cold_outreach","other",
]).optional().describe("design_experiment: type of experiment"),
budget: z.string().default("$0").describe("design_experiment: available budget"),
timeAvailable: z.enum(["hours","days","weeks"]).default("days"),
experimentId: z.string().default("").describe("log_result: experiment ID to update"),
result: z.enum(["validated","invalidated","inconclusive"]).optional().describe("log_result: outcome verdict"),
evidence: z.string().default("").describe("log_result: what you actually observed"),
learnings: z.string().default("").describe("log_result: what you learned and next steps"),
confidenceChange: z.coerce.number().int().min(-100).max(100).default(0),
validationGoal: z.enum(["problem","solution","willingness_to_pay","full_discovery"]).default("full_discovery"),
format: z.enum(["interview","survey"]).default("interview"),
},
implementation: safe_impl("validate", async (params, ctx) => {
const { action } = params;
const db = await loadDB(dataDir());
if (action === "map_assumptions") {
let title = params.title;
let description = params.description;
if (params.ideaId) {
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (!idea) throw new Error(`Idea ID '${params.ideaId}' not found.`);
title = title || idea.title; description = description || idea.description;
}
return json({
title, description, targetUsers: params.targetUsers, revenueModel: params.revenueModel,
assumptionCategories: {
desirability: ["Users have the problem we think they have","The problem is painful enough that they actively seek solutions","Users prefer our approach over existing workarounds","There are enough users with this problem to build a business"],
feasibility: ["The core technical challenge is solvable with our current skills","We can build a usable version within the available time/budget","Key dependencies (APIs, data, infrastructure) are accessible","We can hire the talent needed to build and maintain this"],
viability: ["Users will pay the price we need to charge for the business to work","Customer acquisition cost (CAC) will be lower than lifetime value (LTV)","We can reach customers through affordable channels","The business can survive until it reaches profitability"],
usability: ["Users can understand what the product does within the first 60 seconds","The core workflow requires no support or documentation","Users can achieve their goal successfully on their first attempt"],
},
instructions: `For the idea "${title}", generate a prioritized list of 8–12 critical assumptions. For each: Statement ('We believe that...'), Category (desirability/feasibility/viability/usability), Risk (critical/high/medium/low), Current confidence (0–100%), Cheapest test. Sort by (risk × inverse_confidence). Be brutally honest.`,
});
}
if (action === "design_experiment") {
if (!params.ideaId) throw new Error("ideaId is required for action=design_experiment");
if (!params.experimentType) throw new Error("experimentType is required for action=design_experiment");
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (!idea) throw new Error(`Idea ID '${params.ideaId}' not found.`);
const templates: Record<string, { successCriteria: string; method: string }> = {
customer_interview: { method: "Recruit 5–10 people matching your target user via LinkedIn/Reddit. Conduct 20–30 min structured interviews. Do NOT pitch — only listen.", successCriteria: "≥7/10 interviewees confirm the problem is real (rate it ≥7/10). At least 3 describe a current workaround." },
landing_page: { method: "Build a single-page site in Carrd/Webflow. Capture emails. Drive 100–200 visitors. Measure sign-up rate.", successCriteria: "≥5% of visitors submit email. At least 20 total sign-ups." },
fake_door: { method: "Add a button/link for the feature. When clicked, show 'Coming soon — sign up'. Track click-through vs. page views.", successCriteria: "≥3–5% click-through indicates genuine interest." },
smoke_test: { method: "Write a cold email/post describing the product. Send to 50–100 targeted people. Track reply/click rate.", successCriteria: "≥10% positive reply rate. At least 5 people ask 'how do I get access?'" },
concierge_mvp: { method: "Manually deliver the core value to 3–5 early users. Don't build anything yet. Charge if possible.", successCriteria: "Users complete at least one full cycle. At least 2/3 would pay." },
wizard_of_oz: { method: "Build a front-end UI but do the work manually behind the scenes. Run 5–10 users through the full flow.", successCriteria: "Users complete their goal without help. Satisfaction ≥8/10." },
survey: { method: "Write 5–10 questions in Typeform/Google Forms. Aim for 50+ responses.", successCriteria: "≥60% rate problem as high/critical. ≥30% express intent to use." },
prototype: { method: "Build a clickable prototype in Figma/Framer. Run 5 usability sessions.", successCriteria: "≥4/5 users complete the core task without getting stuck." },
a_b_test: { method: "Split audience 50/50 between two variants. Run until statistical significance.", successCriteria: "One variant shows ≥20% higher conversion at ≥95% confidence." },
cold_outreach: { method: "Send 50–100 personalized cold emails/DMs. Track open, reply, and meeting rates.", successCriteria: "≥30% open, ≥10% reply, ≥5% meeting booked." },
other: { method: "Design a custom experiment based on the assumption being tested.", successCriteria: "Define a measurable metric that indicates the assumption is valid." },
};
const template = templates[params.experimentType] ?? templates.other;
const exp: ValidationExperiment = {
id: makeId(), ideaId: params.ideaId,
title: `${params.experimentType.replace(/_/g, " ")} — ${params.assumption.slice(0, 50)}`,
type: params.experimentType, hypothesis: `We believe that ${params.assumption}.`,
method: template.method, successCriteria: template.successCriteria,
effort: params.timeAvailable, cost: params.budget,
result: "pending", evidence: "", learnings: "", testedAssumptionIds: [],
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
};
if (!db.experiments) db.experiments = [];
db.experiments.push(exp);
const ideaIdx = db.ideas.findIndex((i) => i.id === params.ideaId);
if (ideaIdx !== -1 && db.ideas[ideaIdx].validationStatus === "not_started") {
db.ideas[ideaIdx].validationStatus = "in_progress";
db.ideas[ideaIdx].updatedAt = new Date().toISOString();
}
await saveDB(dataDir(), db);
return json({ success: true, experiment: exp, ideaTitle: idea.title, targetUsers: params.targetUsers, instructions: `Design a complete ${params.experimentType.replace(/_/g, " ")} experiment to test: "${params.assumption}". Produce: (1) Hypothesis in 'We believe X, we'll know it's true when Y' format, (2) Step-by-step execution plan, (3) Specific success/failure criteria with numbers, (4) What to do if experiment succeeds vs. fails, (5) Biggest risk that could make this experiment misleading.` });
}
if (action === "log_result") {
if (!params.experimentId) throw new Error("experimentId is required for action=log_result");
if (!params.result) throw new Error("result is required for action=log_result");
if (!db.experiments) db.experiments = [];
const expIdx = db.experiments.findIndex((e) => e.id === params.experimentId);
if (expIdx === -1) throw new Error(`Experiment ID '${params.experimentId}' not found.`);
db.experiments[expIdx].result = params.result;
db.experiments[expIdx].evidence = params.evidence;
db.experiments[expIdx].learnings = params.learnings;
db.experiments[expIdx].updatedAt = new Date().toISOString();
const ideaId = db.experiments[expIdx].ideaId;
const ideaExps = db.experiments.filter((e) => e.ideaId === ideaId);
const validated = ideaExps.filter((e) => e.result === "validated").length;
const invalidated = ideaExps.filter((e) => e.result === "invalidated").length;
const total = ideaExps.filter((e) => e.result !== "pending").length;
const ideaIdx = db.ideas.findIndex((i) => i.id === ideaId);
if (ideaIdx !== -1 && total > 0) {
if (invalidated > 0 && invalidated >= validated) db.ideas[ideaIdx].validationStatus = "invalidated";
else if (validated >= 2 && validated > invalidated) db.ideas[ideaIdx].validationStatus = "validated";
else db.ideas[ideaIdx].validationStatus = "in_progress";
db.ideas[ideaIdx].updatedAt = new Date().toISOString();
}
await saveDB(dataDir(), db);
return json({ success: true, experiment: db.experiments[expIdx], confidenceChange: params.confidenceChange, validationSummary: { totalExperiments: ideaExps.length, validated, invalidated, pending: ideaExps.filter((e) => e.result === "pending").length, ideaValidationStatus: ideaIdx !== -1 ? db.ideas[ideaIdx].validationStatus : "unknown" }, nextStepSuggestion: params.result === "validated" ? "Assumption confirmed. Move to next highest-risk assumption." : params.result === "invalidated" ? "Assumption failed. Pivot idea, reframe the problem, or abandon." : "Inconclusive. Redesign with clearer success criteria or larger sample." });
}
if (action === "questions") {
let description = params.description;
if (params.ideaId) {
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (!idea) throw new Error(`Idea ID '${params.ideaId}' not found.`);
description = description || idea.description;
}
return json({
ideaDescription: description, targetUser: params.targetUsers || "target user",
validationGoal: params.validationGoal, format: params.format,
momTestPrinciples: ["Ask about the PAST, not the future","Ask about SPECIFICS, not generalities","Never pitch or lead the witness","Dig into workarounds — what do they do TODAY?","Silence is data — let them fill the gap"],
instructions: `Generate 12–15 ${params.format} questions to validate: "${description}". Target user: ${params.targetUsers || "your target user"}. Goal: ${params.validationGoal.replace(/_/g, " ")}. Organize into: Warm-up (2–3), Problem exploration (4–5), Current behavior (3–4)${params.validationGoal !== "problem" ? ", Solution reaction (2–3)" : ""}${params.validationGoal === "willingness_to_pay" || params.validationGoal === "full_discovery" ? ", Economics (1–2)" : ""}. For each: note what you're trying to learn and what a 'good answer' looks like.`,
});
}
if (action === "scorecard") {
if (!params.ideaId) throw new Error("ideaId is required for action=scorecard");
const idea = db.ideas.find((i) => i.id === params.ideaId);
if (!idea) throw new Error(`Idea ID '${params.ideaId}' not found.`);
const experiments = (db.experiments ?? []).filter((e) => e.ideaId === params.ideaId);
const completed = experiments.filter((e) => e.result !== "pending");
const validated = completed.filter((e) => e.result === "validated");
const invalidated = completed.filter((e) => e.result === "invalidated");
const inconclusive = completed.filter((e) => e.result === "inconclusive");
const validationRate = completed.length > 0 ? Math.round((validated.length / completed.length) * 100) : 0;
const linkedPainPoints = db.painPoints.filter((p) => idea.linkedPainPointIds.includes(p.id));
const avgImpact = linkedPainPoints.length > 0 ? linkedPainPoints.filter((p) => p.impact === "critical" || p.impact === "high").length / linkedPainPoints.length : 0;
let recommendation: string;
if (completed.length === 0) recommendation = "NO DATA — Run at least 2–3 experiments before deciding.";
else if (invalidated.length > validated.length && completed.length >= 2) recommendation = "KILL OR PIVOT — More assumptions failed than passed.";
else if (validated.length >= 2 && validationRate >= 60) recommendation = "GO — Evidence supports proceeding. Define MVP and start building.";
else if (completed.length >= 2 && validationRate >= 40) recommendation = "CONTINUE VALIDATING — Promising signals but not enough confidence yet.";
else recommendation = "UNCERTAIN — Mixed results. Focus next experiments on highest-risk assumptions.";
return json({ ideaTitle: idea.title, ideaStatus: idea.validationStatus, priorityScore: calcPriority(idea), scorecard: { experimentsRun: experiments.length, completed: completed.length, validated: validated.length, invalidated: invalidated.length, inconclusive: inconclusive.length, validationRate: `${validationRate}%`, linkedPainPoints: linkedPainPoints.length, highImpactPainPointCoverage: `${Math.round(avgImpact * 100)}%` }, ideaScores: { impact: idea.impactScore, feasibility: idea.feasibilityScore, novelty: idea.noveltyScore, effort: idea.effortScore }, experiments: completed.map((e) => ({ type: e.type, result: e.result, evidence: e.evidence.slice(0, 200), learnings: e.learnings.slice(0, 200) })), recommendation, instructions: `Based on all validation data for "${idea.title}", produce: (1) Confidence score 0–100 for each category (desirability, feasibility, viability, usability), (2) 3 strongest pieces of evidence FOR, (3) 3 strongest pieces of evidence AGAINST, (4) Top 2 remaining unknowns, (5) Balanced assessment. Do not issue a single GO/KILL verdict.` });
}
throw new Error(`Unknown action: ${action}`);
}),
}),
// =========================================================================
// MVP DEFINITION & TESTING
// =========================================================================
tool({
name: "define_mvp",
description: text`
Define the Minimum Viable Product (MVP) for an idea.
An MVP is NOT a minimal product — it is the SMALLEST experiment that tests the core value hypothesis.
Returns a focused MVP scope with must-have features, explicit cut list,
build/measure/learn loop, and launch checklist.
Saves the MVP definition to the idea record.
`,
parameters: {
ideaId: z.string().describe("Idea ID to define MVP for"),
targetUser: z.string().default("").describe("Primary user persona for the MVP"),
coreProblem: z.string().default("").describe("The single problem the MVP solves"),
constraints: z.string().default("").describe("Constraints: team size, budget, timeline, tech stack"),
mvpType: z.enum([
"concierge", // Manual service delivery
"wizard_of_oz", // Fake automation
"landing_page", // Pre-launch page
"single_feature",// One core feature only
"prototype", // Clickable non-functional
"full_build", // Real lightweight product
]).default("single_feature").describe("Type of MVP to define"),
},
implementation: safe_impl("define_mvp", async (params) => {
const db = await loadDB(dataDir());
const ideaIdx = db.ideas.findIndex((i) => i.id === params.ideaId);
if (ideaIdx === -1) throw new Error(`Idea ID '${params.ideaId}' not found.`);
const idea = db.ideas[ideaIdx];
const mvpTypeDescriptions: Record<string, string> = {
concierge: "Manually deliver the service to 3–5 paying users. No code. Validate willingness to pay and core value before building anything.",
wizard_of_oz: "Build the front-end UI. Do the back-end work manually. Users think it's automated — you're the wizard behind the curtain.",
landing_page: "A single page that communicates the value prop and captures emails. Measures demand before building.",
single_feature: "Build only the one feature that delivers the core value. Everything else is cut. No onboarding, no settings, no edge cases.",
prototype: "Clickable Figma/Framer prototype. Zero code. Tests UX and value prop with real users before writing a line.",
full_build: "A simple but real product. Focus on the happy path only — one user type, one core workflow, no edge cases.",
};
const payload = {
ideaTitle: idea.title,
ideaDescription: idea.description,
mvpType: params.mvpType,
mvpTypeApproach: mvpTypeDescriptions[params.mvpType],
targetUser: params.targetUser,
coreProblem: params.coreProblem,
constraints: params.constraints,
experiments: (db.experiments ?? [])
.filter((e) => e.ideaId === params.ideaId && e.result === "validated")
.map((e) => ({ type: e.type, evidence: e.evidence.slice(0, 150) })),
instructions:
`Define the MVP for "${idea.title}" as a ${params.mvpType.replace(/_/g, " ")} MVP. ` +
`Target user: ${params.targetUser || "primary user"}. ` +
`Core problem: ${params.coreProblem || "the main pain point"}. ` +
`Constraints: ${params.constraints || "none specified"}.\n\n` +
"Produce:\n" +
"## Core Value Hypothesis\n[Single sentence: 'We believe [user] will [do action] because [value]']\n\n" +
"## MVP Scope — MUST HAVE (3–5 items max)\n[Only features that directly test the hypothesis]\n\n" +
"## Explicitly NOT in MVP\n[List 5+ things you're cutting and why]\n\n" +
"## Build Plan\n[Key steps, tools to use, estimated time]\n\n" +
"## Success Metrics\n[2–3 specific measurable outcomes that mean 'it worked']\n\n" +
"## Failure Criteria\n[What would tell you to stop and pivot]\n\n" +
"## Launch Checklist\n[5–10 items to check before showing to first users]\n\n" +
"Be ruthlessly minimal. Every feature you add is a hypothesis that needs its own validation.",
};
// Save MVP definition to idea
db.ideas[ideaIdx].mvpDefinition = `${params.mvpType} MVP for: ${params.coreProblem || idea.description.slice(0, 100)}`;
db.ideas[ideaIdx].updatedAt = new Date().toISOString();
await saveDB(dataDir(), db);
return json(payload);
}),
}),
tool({
name: "validation_dashboard",
description: text`
Cross-idea validation overview — your "where am I?" briefing.
Shows active ideas by validation status, pending experiments,
highest-risk untested assumptions, and overall validation progress.
Use this when you want a quick pulse on your entire idea portfolio.
`,
parameters: {
includeArchived: z.boolean().default(false).describe("Include archived/rejected ideas in the dashboard"),
},
implementation: safe_impl("validation_dashboard", async (params) => {
const db = await loadDB(dataDir());
const ideas = params.includeArchived
? db.ideas
: db.ideas.filter((i) => i.status !== "archived" && i.status !== "rejected");
const experiments = db.experiments ?? [];
// --- Validation status breakdown ---
const byValidation: Record<string, Idea[]> = {
not_started: [], in_progress: [], validated: [], invalidated: [],
};
for (const idea of ideas) {
const vs = idea.validationStatus ?? "not_started";
(byValidation[vs] ??= []).push(idea);
}
// --- Experiment stats ---
const activeIdeaIds = new Set(ideas.map((i) => i.id));
const relevantExperiments = experiments.filter((e) => activeIdeaIds.has(e.ideaId));
const pending = relevantExperiments.filter((e) => e.result === "pending");
const validated = relevantExperiments.filter((e) => e.result === "validated");
const invalidated = relevantExperiments.filter((e) => e.result === "invalidated");
const inconclusive = relevantExperiments.filter((e) => e.result === "inconclusive");
// --- Highest-risk untested assumptions ---
const untestedAssumptions: Array<{
ideaId: string; ideaTitle: string; assumption: Assumption;
}> = [];
for (const idea of ideas) {
for (const a of idea.assumptions ?? []) {
if (a.validatedBy.length === 0) {
untestedAssumptions.push({ ideaId: idea.id, ideaTitle: idea.title, assumption: a });
}
}
}
// Sort: critical/high risk first, then lowest confidence
const riskOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
untestedAssumptions.sort((a, b) => {
const rd = (riskOrder[a.assumption.riskLevel] ?? 3) - (riskOrder[b.assumption.riskLevel] ?? 3);
if (rd !== 0) return rd;
return a.assumption.confidence - b.assumption.confidence;
});
// --- Per-idea progress ---
const ideaProgress = ideas.map((idea) => {
const ideaExps = relevantExperiments.filter((e) => e.ideaId === idea.id);
const totalAssumptions = (idea.assumptions ?? []).length;
const testedAssumptions = (idea.assumptions ?? []).filter((a) => a.validatedBy.length > 0).length;
return {
id: idea.id,
title: idea.title,
status: idea.status,
validationStatus: idea.validationStatus ?? "not_started",
scores: { impact: idea.impactScore, feasibility: idea.feasibilityScore, novelty: idea.noveltyScore },
assumptions: { total: totalAssumptions, tested: testedAssumptions },
experiments: {
total: ideaExps.length,
pending: ideaExps.filter((e) => e.result === "pending").length,
validated: ideaExps.filter((e) => e.result === "validated").length,
invalidated: ideaExps.filter((e) => e.result === "invalidated").length,
},
};
});
return json({
summary: {
totalActiveIdeas: ideas.length,
byValidationStatus: {
not_started: byValidation.not_started.length,
in_progress: byValidation.in_progress.length,
validated: byValidation.validated.length,
invalidated: byValidation.invalidated.length,
},
experiments: {
total: relevantExperiments.length,
pending: pending.length,
validated: validated.length,
invalidated: invalidated.length,
inconclusive: inconclusive.length,
},
untestedAssumptions: untestedAssumptions.length,
},
needsAttention: {
pendingExperiments: pending.slice(0, 10).map((e) => ({
id: e.id, ideaId: e.ideaId, title: e.title, type: e.type,
hypothesis: e.hypothesis, effort: e.effort,
})),
highRiskUntested: untestedAssumptions.slice(0, 10).map((a) => ({
ideaId: a.ideaId, ideaTitle: a.ideaTitle,
assumptionId: a.assumption.id,
statement: a.assumption.statement,
type: a.assumption.type,
riskLevel: a.assumption.riskLevel,
confidence: a.assumption.confidence,
})),
ideasWithNoExperiments: ideas
.filter((i) => i.validationStatus !== "validated" && i.validationStatus !== "invalidated")
.filter((i) => !relevantExperiments.some((e) => e.ideaId === i.id))
.map((i) => ({ id: i.id, title: i.title, status: i.validationStatus ?? "not_started" })),
},
ideaProgress,
});
}),
}),
];
return tools;
};