@m8i-51/shoal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -0
- package/bin/shoal.js +56 -0
- package/framework/__tests__/coverage.test.ts +232 -0
- package/framework/__tests__/report.test.ts +154 -0
- package/framework/account-manager.ts +414 -0
- package/framework/agent-loop.ts +103 -0
- package/framework/agent-store.ts +47 -0
- package/framework/cost.ts +91 -0
- package/framework/coverage.ts +157 -0
- package/framework/findings.ts +53 -0
- package/framework/github.ts +64 -0
- package/framework/llm-client.ts +507 -0
- package/framework/observation.ts +182 -0
- package/framework/org-designer.ts +85 -0
- package/framework/product-discovery.ts +327 -0
- package/framework/report.ts +276 -0
- package/framework/scenario-designer.ts +141 -0
- package/framework/triage.ts +208 -0
- package/framework/types.ts +80 -0
- package/package.json +55 -0
- package/run.ts +1213 -0
- package/server/index.ts +227 -0
- package/server/runner.ts +125 -0
- package/server/runs.ts +103 -0
- package/targets/example.ts +55 -0
- package/targets/index.ts +17 -0
- package/targets/noop.ts +6 -0
- package/targets/types.ts +19 -0
- package/triage-only.ts +57 -0
- package/web/dist/assets/index-CD6EJ_1O.js +68 -0
- package/web/dist/assets/index-DPLuVm2n.css +1 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import type { LLMClient } from "./llm-client";
|
|
3
|
+
import { createMessageWithRetry } from "./agent-loop";
|
|
4
|
+
import type { ProductSpec } from "./product-discovery";
|
|
5
|
+
|
|
6
|
+
export interface OrgDesign {
|
|
7
|
+
hrGuidance: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Evaluation lenses always included regardless of app type / アプリ種別に関わらず常に含める観点
|
|
11
|
+
export const UNIVERSAL_LENSES = [
|
|
12
|
+
"Accessibility: keyboard navigation, screen reader compatibility, error message clarity, non-color-dependent information, sufficient contrast, focus indicators / アクセシビリティ観点",
|
|
13
|
+
"Security: missing auth checks, input validation gaps, excessive error detail exposure, CSRF exposure, sensitive data in URLs / セキュリティ観点",
|
|
14
|
+
"Business logic: calculation accuracy, status transitions, approval flow correctness, edge case handling in forms / ビジネスロジック観点",
|
|
15
|
+
"Data integrity: UI reflects actual state after actions, silent save failures, optimistic update inconsistencies / データ整合性観点",
|
|
16
|
+
"New user: first-time usability, onboarding clarity, instruction completeness, error recovery, empty state messaging / 新規ユーザー観点",
|
|
17
|
+
"UX design: interaction feedback (loading states, success/error messages), form usability, modal and dialog behavior, navigation consistency, micro-interactions — evaluate against established patterns from Apple HIG and Material Design (clear affordances, immediate feedback, forgiving interactions) / UXデザイン観点",
|
|
18
|
+
"Visual design: spacing and alignment consistency, typography hierarchy, color usage and contrast, component coherence across screens, mobile responsiveness — flag anything that looks broken, cramped, or visually inconsistent / ビジュアルデザイン観点",
|
|
19
|
+
"Product/PM: feature discoverability, user journey clarity, obvious next actions, drop-off risk points, call-to-action prominence, whether the app communicates its value clearly, missing features that users of this type would expect / プロダクト・PM観点",
|
|
20
|
+
"Power user: keyboard shortcuts availability, bulk operations, filtering/sorting depth, export options, API access, customization options / パワーユーザー観点",
|
|
21
|
+
"Mobile/touch: touch target sizes, gesture support, viewport adaptation, thumb-reachable key actions / モバイル・タッチ観点",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export async function designOrg(spec: ProductSpec, client: LLMClient, model: string, coverageSummary?: string): Promise<OrgDesign> {
|
|
25
|
+
console.log("\n[org-design] starting...");
|
|
26
|
+
|
|
27
|
+
const response = await createMessageWithRetry(client, {
|
|
28
|
+
model,
|
|
29
|
+
max_tokens: 1024,
|
|
30
|
+
system: `You are a software QA expert.
|
|
31
|
+
Given an app specification, infer the organization and user base,
|
|
32
|
+
then define an agent recruitment policy for testing.`,
|
|
33
|
+
tools: [],
|
|
34
|
+
messages: [
|
|
35
|
+
{
|
|
36
|
+
role: "user",
|
|
37
|
+
content: `Design a test agent recruitment policy for the following app.
|
|
38
|
+
|
|
39
|
+
[App Overview]
|
|
40
|
+
${spec.appDescription}
|
|
41
|
+
|
|
42
|
+
[Target Users]
|
|
43
|
+
${spec.targetUsers}
|
|
44
|
+
|
|
45
|
+
[Implemented Features]
|
|
46
|
+
${spec.features}
|
|
47
|
+
${spec.designContext ? `\n[Design Context]\n${spec.designContext}\n` : ""}${coverageSummary ? `\n[Coverage History]\n${coverageSummary}\nUse this to identify underrepresented perspectives and adjust the recruitment policy accordingly.\n` : ""}
|
|
48
|
+
Please output the following:
|
|
49
|
+
|
|
50
|
+
## User types for this app
|
|
51
|
+
(What kinds of users exist — roles, skill levels, usage scenarios)
|
|
52
|
+
|
|
53
|
+
## Agent types to recruit (5–8 types)
|
|
54
|
+
By job function, role, and technical literacy. Always include:
|
|
55
|
+
- At least one UX/product designer persona (evaluates visual consistency, interaction patterns, HIG/Material compliance)
|
|
56
|
+
- At least one product manager or business analyst persona (evaluates feature completeness, user journey clarity)
|
|
57
|
+
- At least one target end-user with low technical literacy (first-time or reluctant user)
|
|
58
|
+
- Domain-specific roles relevant to this app type
|
|
59
|
+
|
|
60
|
+
## Recruitment instructions for the HR agent
|
|
61
|
+
(Concrete hiring/retirement guidelines based on the above — emphasize persona diversity across technical skill levels, job functions, and design sensitivity)`,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const text = response.content
|
|
67
|
+
.filter((b): b is Anthropic.TextBlock => b.type === "text")
|
|
68
|
+
.map((b) => b.text)
|
|
69
|
+
.join("");
|
|
70
|
+
|
|
71
|
+
const hrGuidance = `${text}
|
|
72
|
+
|
|
73
|
+
[Universal Evaluation Lenses]
|
|
74
|
+
Include one of the following perspectives in each agent's persona to ensure diverse findings:
|
|
75
|
+
${UNIVERSAL_LENSES.map((l) => `- ${l}`).join("\n")}
|
|
76
|
+
|
|
77
|
+
[Design Standards Reference]
|
|
78
|
+
When recruiting UX/design-oriented agents, give them awareness of these standards:
|
|
79
|
+
- Apple HIG: clear visual hierarchy, immediate feedback, forgiveness (undo/cancel), consistent navigation, minimal cognitive load
|
|
80
|
+
- Material Design: meaningful motion, bold clear typography, responsive layout, accessible color contrast (WCAG AA minimum)
|
|
81
|
+
- General web conventions: F-pattern reading, above-the-fold CTAs, error prevention over error recovery, progressive disclosure for complex forms`;
|
|
82
|
+
|
|
83
|
+
console.log("[org-design] done");
|
|
84
|
+
return { hrGuidance };
|
|
85
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { Page } from "playwright";
|
|
4
|
+
import type { LLMClient } from "./llm-client";
|
|
5
|
+
import { createMessageWithRetry } from "./agent-loop";
|
|
6
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
7
|
+
|
|
8
|
+
// ================================================================
|
|
9
|
+
// Documentation gathering (local or GitHub)
|
|
10
|
+
// ================================================================
|
|
11
|
+
|
|
12
|
+
const DOC_CANDIDATES = [
|
|
13
|
+
"README.md", "README_JA.md", "README.ja.md", "README.txt",
|
|
14
|
+
"docs/index.md", "docs/overview.md", "docs/README.md",
|
|
15
|
+
"openapi.json", "openapi.yaml", "swagger.json", "swagger.yaml",
|
|
16
|
+
"package.json",
|
|
17
|
+
];
|
|
18
|
+
const MAX_DOC_CHARS = 6000;
|
|
19
|
+
|
|
20
|
+
function readLocalDocs(projectPath: string): string {
|
|
21
|
+
const sections: string[] = [];
|
|
22
|
+
let totalChars = 0;
|
|
23
|
+
|
|
24
|
+
for (const candidate of DOC_CANDIDATES) {
|
|
25
|
+
if (totalChars >= MAX_DOC_CHARS) break;
|
|
26
|
+
const filePath = path.join(projectPath, candidate);
|
|
27
|
+
if (!fs.existsSync(filePath)) continue;
|
|
28
|
+
try {
|
|
29
|
+
let content = fs.readFileSync(filePath, "utf-8");
|
|
30
|
+
if (candidate === "package.json") {
|
|
31
|
+
// package.json は name / description / scripts だけ抜く
|
|
32
|
+
const pkg = JSON.parse(content) as Record<string, unknown>;
|
|
33
|
+
content = JSON.stringify({ name: pkg.name, description: pkg.description, scripts: pkg.scripts }, null, 2);
|
|
34
|
+
}
|
|
35
|
+
const remaining = MAX_DOC_CHARS - totalChars;
|
|
36
|
+
const chunk = content.slice(0, remaining);
|
|
37
|
+
sections.push(`### ${candidate}\n${chunk}`);
|
|
38
|
+
totalChars += chunk.length;
|
|
39
|
+
console.log(` [product-discovery] local doc: ${candidate} (${chunk.length} chars)`);
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore unreadable files
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return sections.join("\n\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function fetchGitHubReadme(githubRepo: string): Promise<string> {
|
|
49
|
+
for (const branch of ["main", "master"]) {
|
|
50
|
+
const url = `https://raw.githubusercontent.com/${githubRepo}/${branch}/README.md`;
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
53
|
+
if (!res.ok) continue;
|
|
54
|
+
const text = await res.text();
|
|
55
|
+
const content = text.slice(0, MAX_DOC_CHARS);
|
|
56
|
+
console.log(` [product-discovery] GitHub README fetched (${content.length} chars, branch: ${branch})`);
|
|
57
|
+
return `### README.md (GitHub: ${githubRepo})\n${content}`;
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function gatherDocumentation(projectPath?: string): Promise<string> {
|
|
66
|
+
if (projectPath) {
|
|
67
|
+
const docs = readLocalDocs(projectPath);
|
|
68
|
+
if (docs) return docs;
|
|
69
|
+
console.log(" [product-discovery] local docs: nothing found, falling back to GitHub");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const githubRepo = process.env.GITHUB_REPO ?? "";
|
|
73
|
+
if (githubRepo && githubRepo !== "owner/repo") {
|
|
74
|
+
return fetchGitHubReadme(githubRepo);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ProductSpec {
|
|
81
|
+
appName: string;
|
|
82
|
+
appDescription: string;
|
|
83
|
+
targetUsers: string;
|
|
84
|
+
features: string;
|
|
85
|
+
designContext: string;
|
|
86
|
+
uiFeatures: string;
|
|
87
|
+
appGoals: string[];
|
|
88
|
+
confidence: "high" | "medium" | "low";
|
|
89
|
+
sources: string[];
|
|
90
|
+
discoveredAt?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function specCachePath(baseUrl: string): string {
|
|
94
|
+
const host = new URL(baseUrl).host.replace(/[^a-zA-Z0-9]/g, "-");
|
|
95
|
+
return path.join(process.cwd(), "product-specs", `${host}.json`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function loadCachedSpec(baseUrl: string): ProductSpec | null {
|
|
99
|
+
const filePath = specCachePath(baseUrl);
|
|
100
|
+
if (!fs.existsSync(filePath)) return null;
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as ProductSpec;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function saveSpec(baseUrl: string, spec: ProductSpec): void {
|
|
109
|
+
const filePath = specCachePath(baseUrl);
|
|
110
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
111
|
+
fs.writeFileSync(filePath, JSON.stringify(spec, null, 2), "utf-8");
|
|
112
|
+
console.log(` [product-discovery] spec saved: ${filePath}`);
|
|
113
|
+
|
|
114
|
+
if (spec.uiFeatures) {
|
|
115
|
+
const mdPath = filePath.replace(/\.json$/, "_UI_FEATURES.md");
|
|
116
|
+
const md = `# UI Features — ${spec.appName}\n\n> Auto-generated by product-discovery on ${new Date().toISOString().slice(0, 10)}\n> UI-only interactions invisible from API responses.\n\n${spec.uiFeatures}\n`;
|
|
117
|
+
fs.writeFileSync(mdPath, md, "utf-8");
|
|
118
|
+
console.log(` [product-discovery] UI_FEATURES saved: ${mdPath}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printSpec(spec: ProductSpec): void {
|
|
123
|
+
console.log(`\n${"─".repeat(60)}`);
|
|
124
|
+
console.log(` app: ${spec.appName}`);
|
|
125
|
+
console.log(` description: ${spec.appDescription}`);
|
|
126
|
+
console.log(` users: ${spec.targetUsers}`);
|
|
127
|
+
console.log(` features:\n${spec.features.split("\n").map((l) => ` ${l}`).join("\n")}`);
|
|
128
|
+
console.log(` confidence: ${spec.confidence} / sources: ${spec.sources.join(", ")}`);
|
|
129
|
+
console.log(`${"─".repeat(60)}\n`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const DISCOVERY_TOOLS: Anthropic.Tool[] = [
|
|
133
|
+
{
|
|
134
|
+
name: "navigate_and_read",
|
|
135
|
+
description: "Navigate to a path and read page text + ARIA tree / アプリの指定パスに移動しテキストとARIAツリーを取得する",
|
|
136
|
+
input_schema: {
|
|
137
|
+
type: "object",
|
|
138
|
+
properties: {
|
|
139
|
+
path: { type: "string", description: "Path to observe (e.g. /, /tasks, /purchases)" },
|
|
140
|
+
},
|
|
141
|
+
required: ["path"],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "fetch_url",
|
|
146
|
+
description: "Fetch text content from an external URL (README, About page, etc.) / 外部URLのテキストを取得する",
|
|
147
|
+
input_schema: {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
url: { type: "string", description: "URL to fetch" },
|
|
151
|
+
},
|
|
152
|
+
required: ["url"],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "output_spec",
|
|
157
|
+
description: "Finalize and output the product spec once enough information has been gathered / 十分な情報が集まったらプロダクト仕様を確定して出力する",
|
|
158
|
+
input_schema: {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: {
|
|
161
|
+
appName: { type: "string", description: "App name" },
|
|
162
|
+
appDescription: { type: "string", description: "What the app does, who it's for, and its main value (2-3 sentences)" },
|
|
163
|
+
targetUsers: { type: "string", description: "Target users: roles, technical level, usage scenarios" },
|
|
164
|
+
features: { type: "string", description: "Implemented features, listed per screen as bullet points" },
|
|
165
|
+
designContext: {
|
|
166
|
+
type: "string",
|
|
167
|
+
description: "Detected UI framework, visual style, and applicable design standards. Include: (1) UI framework/library if detectable (Tailwind CSS, Material UI, Bootstrap, etc.), (2) visual style (minimalist, corporate, playful, dense, etc.), (3) design conventions relevant to this app type (e.g. enterprise UX patterns, consumer mobile conventions, dashboard best practices). Example: 'Tailwind CSS, minimalist corporate style — enterprise conventions: clear status indicators, inline validation, progressive disclosure for complex forms'",
|
|
168
|
+
},
|
|
169
|
+
uiFeatures: {
|
|
170
|
+
type: "string",
|
|
171
|
+
description: "UI-only interactions and features that are NOT visible from API responses alone — things only discoverable by looking at the actual screen. List per screen. Examples: client-side filters, view mode toggles (card/compact), warning modals, inline validation messages, hover states, keyboard shortcuts, drag-and-drop, collapsible panels, tabs that switch without navigation, tooltips, empty-state messages, loading skeletons. Format: 'Screen: feature 1 · feature 2 · feature 3'",
|
|
172
|
+
},
|
|
173
|
+
appGoals: {
|
|
174
|
+
type: "array",
|
|
175
|
+
items: { type: "string" },
|
|
176
|
+
description: "3–6 concrete, measurable goals this app is designed to achieve — from the perspective of its users and the business. Each goal should be a complete sentence describing a success condition. Examples: 'New employees can submit a purchase request without any training', 'Approvers can review and act on a request within 60 seconds', 'Managers can see the status of all open requests at a glance'. Infer from the app's purpose, target users, and key workflows.",
|
|
177
|
+
},
|
|
178
|
+
confidence: {
|
|
179
|
+
type: "string",
|
|
180
|
+
enum: ["high", "medium", "low"],
|
|
181
|
+
description: "Inference confidence: high if README/docs obtained, low if UI observation only",
|
|
182
|
+
},
|
|
183
|
+
sources: {
|
|
184
|
+
type: "array",
|
|
185
|
+
items: { type: "string" },
|
|
186
|
+
description: "Sources used (e.g. ['/ (top page)', '/tasks (UI)', 'README'])",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
required: ["appName", "appDescription", "targetUsers", "features", "designContext", "uiFeatures", "appGoals", "confidence", "sources"],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
export async function discoverProduct(
|
|
195
|
+
baseUrl: string,
|
|
196
|
+
page: Page,
|
|
197
|
+
client: LLMClient,
|
|
198
|
+
model: string,
|
|
199
|
+
projectPath?: string,
|
|
200
|
+
): Promise<ProductSpec> {
|
|
201
|
+
console.log("\n[product-discovery] starting...");
|
|
202
|
+
|
|
203
|
+
const systemPrompt = `You are a product discovery agent.
|
|
204
|
+
Observe the given web app and infer what it is.
|
|
205
|
+
|
|
206
|
+
Steps:
|
|
207
|
+
1. Use navigate_and_read to observe the top page
|
|
208
|
+
2. Observe 2-3 key screens (follow tabs or navigation)
|
|
209
|
+
3. If a README or About page is available, fetch it with fetch_url
|
|
210
|
+
4. Once you have enough information, call output_spec (finish within 6 observations)
|
|
211
|
+
|
|
212
|
+
Guidelines for output_spec:
|
|
213
|
+
- appDescription: 2-3 sentences covering who uses it, why, and the main value
|
|
214
|
+
- targetUsers: roles, technical level, and usage scenarios (be specific)
|
|
215
|
+
- features: list per screen as "Screen name: feature 1 · feature 2 · feature 3"
|
|
216
|
+
- designContext: note the UI framework (look for class names like "tw-", "MuiButton", "btn btn-"), visual style, and what design conventions apply for this app type
|
|
217
|
+
- uiFeatures: list UI-only features per screen that are invisible from API responses (filters, toggles, modals, validation messages, empty states, etc.)
|
|
218
|
+
- confidence: high if README/official docs obtained, low if UI observation only
|
|
219
|
+
- appGoals: 3–6 concrete goals this app is designed to achieve (user + business perspective)`;
|
|
220
|
+
|
|
221
|
+
const docs = await gatherDocumentation(projectPath);
|
|
222
|
+
const initialContent = docs
|
|
223
|
+
? `App URL: ${baseUrl}\n\nInvestigate what this app is.\n\n[Available Documentation]\n${docs}`
|
|
224
|
+
: `App URL: ${baseUrl}\n\nInvestigate what this app is.`;
|
|
225
|
+
|
|
226
|
+
const messages: Anthropic.MessageParam[] = [
|
|
227
|
+
{ role: "user", content: initialContent },
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
let spec: ProductSpec | null = null;
|
|
231
|
+
let iterations = 0;
|
|
232
|
+
|
|
233
|
+
while (iterations < 8 && !spec) {
|
|
234
|
+
iterations++;
|
|
235
|
+
|
|
236
|
+
const response = await createMessageWithRetry(client, {
|
|
237
|
+
model,
|
|
238
|
+
max_tokens: 2048,
|
|
239
|
+
system: systemPrompt,
|
|
240
|
+
tools: DISCOVERY_TOOLS,
|
|
241
|
+
messages,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
messages.push({ role: "assistant", content: response.content });
|
|
245
|
+
|
|
246
|
+
const toolUses = response.content.filter(
|
|
247
|
+
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
|
|
248
|
+
);
|
|
249
|
+
if (toolUses.length === 0 || response.stop_reason === "end_turn") break;
|
|
250
|
+
|
|
251
|
+
const toolResults: Anthropic.ToolResultBlockParam[] = [];
|
|
252
|
+
for (const toolUse of toolUses) {
|
|
253
|
+
let result: string;
|
|
254
|
+
|
|
255
|
+
if (toolUse.name === "navigate_and_read") {
|
|
256
|
+
const { path } = toolUse.input as { path: string };
|
|
257
|
+
try {
|
|
258
|
+
await page.goto(`${baseUrl}${path}`, { waitUntil: "networkidle", timeout: 10000 });
|
|
259
|
+
await page.waitForTimeout(500);
|
|
260
|
+
const [text, aria] = await Promise.all([
|
|
261
|
+
page.evaluate(() => document.body.innerText.slice(0, 1500)),
|
|
262
|
+
page.ariaSnapshot({ mode: "ai", depth: 5 }).then((s) => s.slice(0, 1500)),
|
|
263
|
+
]);
|
|
264
|
+
result = `[${path} text]\n${text}\n\n[ARIA tree]\n${aria}`;
|
|
265
|
+
console.log(` [product-discovery] observed: ${path}`);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
result = `fetch failed: ${String(e)}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
} else if (toolUse.name === "fetch_url") {
|
|
271
|
+
const { url } = toolUse.input as { url: string };
|
|
272
|
+
try {
|
|
273
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
274
|
+
const text = await res.text();
|
|
275
|
+
result = text.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 2000);
|
|
276
|
+
console.log(` [product-discovery] fetched: ${url}`);
|
|
277
|
+
} catch (e) {
|
|
278
|
+
result = `fetch failed: ${String(e)}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
} else if (toolUse.name === "output_spec") {
|
|
282
|
+
const input = toolUse.input as ProductSpec;
|
|
283
|
+
spec = {
|
|
284
|
+
appName: String(input.appName),
|
|
285
|
+
appDescription: String(input.appDescription),
|
|
286
|
+
targetUsers: String(input.targetUsers),
|
|
287
|
+
features: String(input.features),
|
|
288
|
+
designContext: String(input.designContext ?? ""),
|
|
289
|
+
uiFeatures: String(input.uiFeatures ?? ""),
|
|
290
|
+
appGoals: Array.isArray(input.appGoals) ? input.appGoals.map(String) : [],
|
|
291
|
+
confidence: input.confidence,
|
|
292
|
+
sources: Array.isArray(input.sources) ? input.sources.map(String) : [],
|
|
293
|
+
};
|
|
294
|
+
result = "product spec finalized";
|
|
295
|
+
console.log(` [product-discovery] spec confirmed (confidence: ${spec.confidence})`);
|
|
296
|
+
|
|
297
|
+
} else {
|
|
298
|
+
result = "unknown tool";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
toolResults.push({ type: "tool_result", tool_use_id: toolUse.id, content: result });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
messages.push({ role: "user", content: toolResults });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!spec) {
|
|
308
|
+
console.log(" [product-discovery] spec not confirmed, using fallback");
|
|
309
|
+
spec = {
|
|
310
|
+
appName: new URL(baseUrl).hostname,
|
|
311
|
+
appDescription: "(auto-discovery failed)",
|
|
312
|
+
targetUsers: "(unknown)",
|
|
313
|
+
features: "(auto-discovery failed)",
|
|
314
|
+
designContext: "(unknown)",
|
|
315
|
+
uiFeatures: "(unknown)",
|
|
316
|
+
appGoals: [],
|
|
317
|
+
confidence: "low",
|
|
318
|
+
sources: [],
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
spec.discoveredAt = new Date().toISOString();
|
|
323
|
+
saveSpec(baseUrl, spec);
|
|
324
|
+
console.log(`[product-discovery] done: "${spec.appName}" (confidence: ${spec.confidence})`);
|
|
325
|
+
printSpec(spec);
|
|
326
|
+
return spec;
|
|
327
|
+
}
|