@m8i-51/shoal 0.1.20 → 0.1.22
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/.env.example +11 -0
- package/README.md +3 -0
- package/framework/coverage.ts +4 -3
- package/framework/cross-run-dedup.ts +43 -0
- package/framework/diary.ts +5 -3
- package/framework/llm-client.ts +4 -1
- package/framework/types.ts +11 -0
- package/package.json +6 -4
- package/server/index.ts +30 -24
- package/server/runs.ts +4 -3
- package/web/dist/assets/index-Cx9-vLgF.js +85 -0
- package/web/dist/index.html +1 -1
- package/framework/__tests__/cost.test.ts +0 -130
- package/framework/__tests__/coverage.test.ts +0 -429
- package/framework/__tests__/diary.test.ts +0 -139
- package/framework/__tests__/page-cache.test.ts +0 -106
- package/framework/__tests__/report.test.ts +0 -253
- package/server/__tests__/api.test.ts +0 -307
- package/server/__tests__/scheduler.test.ts +0 -137
- package/web/dist/assets/index-PhasbbTM.js +0 -85
package/.env.example
CHANGED
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
# LLM_PROVIDER=codex
|
|
20
20
|
# LLM_MODEL=gpt-5.1-codex-mini # 省略可
|
|
21
21
|
|
|
22
|
+
# --- Amazon Bedrock ---
|
|
23
|
+
# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION は AWS SDK が自動で読み取ります
|
|
24
|
+
# LLM_PROVIDER=bedrock
|
|
25
|
+
# AWS_ACCESS_KEY_ID=
|
|
26
|
+
# AWS_SECRET_ACCESS_KEY=
|
|
27
|
+
# AWS_REGION=us-east-1
|
|
28
|
+
# LLM_MODEL=anthropic.claude-3-5-haiku-20241022-v1:0 # 省略可 / クロスリージョン推論プロファイルも対応
|
|
29
|
+
|
|
22
30
|
# --- Groq (無料枠あり・高速) ---
|
|
23
31
|
# https://console.groq.com でキー取得
|
|
24
32
|
# LLM_PROVIDER=groq
|
|
@@ -100,6 +108,9 @@ GITHUB_REPO=owner/repo
|
|
|
100
108
|
MAX_EXPLORERS=4
|
|
101
109
|
MAX_BROWSERS=2
|
|
102
110
|
|
|
111
|
+
# 1 に設定するとキャッシュ済みの product spec を無視し product discovery を再実行します
|
|
112
|
+
# REFRESH_SPEC=1
|
|
113
|
+
|
|
103
114
|
# Persona templates (optional)
|
|
104
115
|
# Path to a local YAML/JSON file, or an npm package name.
|
|
105
116
|
# If omitted, shoal auto-discovers personas.yaml / personas.yml / personas.json in the project root.
|
package/README.md
CHANGED
|
@@ -119,6 +119,7 @@ Opens at `http://localhost:4000`. From there you can:
|
|
|
119
119
|
- **Generate an Agent Diary** — after a run completes, one LLM call turns the raw log into a story-style narrative of the exploration, readable by anyone on the team
|
|
120
120
|
- **Hall of Issues** — browse all findings across every run with full-text search and category filter. Export as JSON to share, or paste a community findings URL to import findings from other projects.
|
|
121
121
|
- **Edit app goals** — guide the goal-gap detector by defining what the app should achieve
|
|
122
|
+
- **Schedule a weekly run** — pick a day and time directly in the dashboard for automatic recurring runs (the `shoal serve` process must stay running; for a serverless alternative see [Scheduled runs](#scheduled-runs) below)
|
|
122
123
|
|
|
123
124
|
---
|
|
124
125
|
|
|
@@ -246,6 +247,8 @@ shoal defaults to Anthropic Claude. To use a different provider, set these varia
|
|
|
246
247
|
| Amazon Bedrock | `LLM_PROVIDER=bedrock`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` |
|
|
247
248
|
| OpenAI | `LLM_PROVIDER=openai`, `LLM_API_KEY`, `LLM_MODEL` |
|
|
248
249
|
| OpenRouter | `LLM_PROVIDER=openrouter`, `LLM_API_KEY`, `LLM_MODEL` |
|
|
250
|
+
| Groq | `LLM_PROVIDER=groq`, `LLM_API_KEY`, `LLM_MODEL` |
|
|
251
|
+
| Gemini | `LLM_PROVIDER=gemini`, `LLM_API_KEY`, `LLM_MODEL` |
|
|
249
252
|
| Codex (ChatGPT subscription) | run `npm run auth:codex` once, then `LLM_PROVIDER=codex` |
|
|
250
253
|
| Ollama | `LLM_BASE_URL=http://localhost:11434/v1`, `LLM_MODEL` |
|
|
251
254
|
| LM Studio | `LLM_BASE_URL=http://localhost:1234/v1`, `LLM_MODEL` |
|
package/framework/coverage.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import type
|
|
3
|
+
import { isFinding, type Finding } from "./types";
|
|
4
4
|
import type { Scenario } from "./scenario-designer";
|
|
5
5
|
|
|
6
6
|
export interface RunCoverage {
|
|
@@ -231,9 +231,10 @@ export function getFindingHotspots(topN = 12): FindingHotspot[] {
|
|
|
231
231
|
const dir = path.join(base, runDir);
|
|
232
232
|
try {
|
|
233
233
|
for (const file of fs.readdirSync(dir)) {
|
|
234
|
-
if (!file.endsWith(".json")) continue;
|
|
234
|
+
if (!file.endsWith(".json") || file === "triage_result.json") continue;
|
|
235
235
|
try {
|
|
236
|
-
const f:
|
|
236
|
+
const f: unknown = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
237
|
+
if (!isFinding(f)) continue;
|
|
237
238
|
const p = extractPath(f);
|
|
238
239
|
const entry = counts.get(p) ?? { total: 0, categories: {} };
|
|
239
240
|
entry.total++;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Finding } from "./types";
|
|
2
|
+
|
|
3
|
+
function tokenize(text: string): Set<string> {
|
|
4
|
+
return new Set(text.toLowerCase().match(/[a-z0-9]+/g) ?? []);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function jaccard(a: Set<string>, b: Set<string>): number {
|
|
8
|
+
const intersection = [...a].filter((x) => b.has(x)).length;
|
|
9
|
+
const union = new Set([...a, ...b]).size;
|
|
10
|
+
return union === 0 ? 0 : intersection / union;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* タイトル+本文の単語集合の Jaccard 係数で finding をクラスタリングする。
|
|
15
|
+
* triage は同一 run 内の重複しか除去しないため、run をまたいで
|
|
16
|
+
* 繰り返し報告されている問題を見つけるための補助。
|
|
17
|
+
*/
|
|
18
|
+
export function groupSimilarFindings(findings: Finding[], threshold = 0.3): Finding[][] {
|
|
19
|
+
const tokensByIndex = findings.map((f) => tokenize(`${f.title} ${f.body}`));
|
|
20
|
+
const seen = new Set<number>();
|
|
21
|
+
const clusters: Finding[][] = [];
|
|
22
|
+
|
|
23
|
+
for (let i = 0; i < findings.length; i++) {
|
|
24
|
+
if (seen.has(i)) continue;
|
|
25
|
+
const clusterIndices = [i];
|
|
26
|
+
for (let j = i + 1; j < findings.length; j++) {
|
|
27
|
+
if (seen.has(j)) continue;
|
|
28
|
+
if (jaccard(tokensByIndex[i], tokensByIndex[j]) >= threshold) {
|
|
29
|
+
clusterIndices.push(j);
|
|
30
|
+
seen.add(j);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
seen.add(i);
|
|
34
|
+
clusters.push(clusterIndices.map((idx) => findings[idx]));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return clusters;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 2件以上で構成されるクラスタ(= run をまたいだ重複候補)のみ返す */
|
|
41
|
+
export function findCrossRunDuplicates(findings: Finding[], threshold = 0.3): Finding[][] {
|
|
42
|
+
return groupSimilarFindings(findings, threshold).filter((cluster) => cluster.length > 1);
|
|
43
|
+
}
|
package/framework/diary.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { createLLMClient } from "./llm-client.js";
|
|
4
|
-
import type
|
|
4
|
+
import { isFinding, type Finding } from "./types.js";
|
|
5
5
|
|
|
6
6
|
function loadFindings(runId: string): Finding[] {
|
|
7
7
|
const dir = path.join(process.cwd(), "findings", runId);
|
|
8
8
|
if (!fs.existsSync(dir)) return [];
|
|
9
9
|
const out: Finding[] = [];
|
|
10
10
|
for (const file of fs.readdirSync(dir)) {
|
|
11
|
-
if (!file.endsWith(".json")) continue;
|
|
11
|
+
if (!file.endsWith(".json") || file === "triage_result.json") continue;
|
|
12
12
|
try {
|
|
13
|
-
|
|
13
|
+
const f: unknown = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
14
|
+
if (!isFinding(f)) continue;
|
|
15
|
+
out.push(f);
|
|
14
16
|
} catch { /* skip */ }
|
|
15
17
|
}
|
|
16
18
|
return out;
|
package/framework/llm-client.ts
CHANGED
|
@@ -200,7 +200,10 @@ class OpenAICompatClient {
|
|
|
200
200
|
private defaultModel: string;
|
|
201
201
|
|
|
202
202
|
constructor(apiKey: string, baseURL?: string, defaultModel?: string) {
|
|
203
|
-
|
|
203
|
+
// ローカルプロバイダ(Ollama/LM Studio)は空のキーで使われることがある。
|
|
204
|
+
// openai SDK は空文字列のキーをコンストラクタで拒否するバージョンがあるため、
|
|
205
|
+
// ダミー値で代替する(API互換エンドポイント側はキーを検証しない)。
|
|
206
|
+
this.client = new OpenAI({ apiKey: apiKey || "not-needed", baseURL });
|
|
204
207
|
this.defaultModel = defaultModel ?? "gpt-4o-mini";
|
|
205
208
|
}
|
|
206
209
|
|
package/framework/types.ts
CHANGED
|
@@ -25,6 +25,17 @@ export interface Finding {
|
|
|
25
25
|
screenshotPath?: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* findings/run_<id>/ ディレクトリには各 finding の JSON と並んで
|
|
30
|
+
* triage_result.json(集計ファイル、Finding ではない)が置かれる。
|
|
31
|
+
* 読み込んだ JSON が実際に Finding かどうかをここで一括判定する。
|
|
32
|
+
*/
|
|
33
|
+
export function isFinding(v: unknown): v is Finding {
|
|
34
|
+
if (typeof v !== "object" || v === null) return false;
|
|
35
|
+
const o = v as Record<string, unknown>;
|
|
36
|
+
return typeof o.id === "string" && typeof o.category === "string" && typeof o.timestamp === "string";
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
export interface RegressionCheck {
|
|
29
40
|
issueNumber: number;
|
|
30
41
|
issueTitle: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m8i-51/shoal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Multi-agent web exploration framework — finds bugs, UX issues, and missing features by running AI agents against your app",
|
|
6
6
|
"repository": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"web/dist/",
|
|
19
19
|
"run.ts",
|
|
20
20
|
"triage-only.ts",
|
|
21
|
-
".env.example"
|
|
21
|
+
".env.example",
|
|
22
|
+
"!**/__tests__/**"
|
|
22
23
|
],
|
|
23
24
|
"scripts": {
|
|
24
25
|
"prepublishOnly": "npm run build:web",
|
|
@@ -31,7 +32,8 @@
|
|
|
31
32
|
"build:web": "vite build web",
|
|
32
33
|
"auth:codex": "tsx scripts/auth-codex.ts",
|
|
33
34
|
"test": "vitest run",
|
|
34
|
-
"test:watch": "vitest"
|
|
35
|
+
"test:watch": "vitest",
|
|
36
|
+
"test:coverage": "vitest run --coverage"
|
|
35
37
|
},
|
|
36
38
|
"dependencies": {
|
|
37
39
|
"@anthropic-ai/bedrock-sdk": "^0.29.1",
|
|
@@ -42,7 +44,7 @@
|
|
|
42
44
|
"express-rate-limit": "^8.5.0",
|
|
43
45
|
"openai": "^6.33.0",
|
|
44
46
|
"playwright": "^1.59.1",
|
|
45
|
-
"tsx": "^4.
|
|
47
|
+
"tsx": "^4.22.4",
|
|
46
48
|
"yaml": "^2.9.0"
|
|
47
49
|
},
|
|
48
50
|
"devDependencies": {
|
package/server/index.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { listRuns, getReportPath } from "./runs.js";
|
|
|
8
8
|
import { activeSessions, spawnRun, cancelSession } from "./runner.js";
|
|
9
9
|
import { loadSchedule, saveSchedule, startScheduler, type ScheduleConfig } from "./scheduler.js";
|
|
10
10
|
import { generateDiary, getDiaryPath } from "../framework/diary.js";
|
|
11
|
-
import
|
|
11
|
+
import { findCrossRunDuplicates } from "../framework/cross-run-dedup.js";
|
|
12
|
+
import { isFinding, type Finding } from "../framework/types.js";
|
|
12
13
|
|
|
13
14
|
function specFilePath(baseUrl: string): string {
|
|
14
15
|
try {
|
|
@@ -141,9 +142,10 @@ function loadAllFindings(): (Finding & { runId: string })[] {
|
|
|
141
142
|
const dir = join(base, runDir);
|
|
142
143
|
try {
|
|
143
144
|
for (const file of readdirSync(dir)) {
|
|
144
|
-
if (!file.endsWith(".json")) continue;
|
|
145
|
+
if (!file.endsWith(".json") || file === "triage_result.json") continue;
|
|
145
146
|
try {
|
|
146
|
-
const f:
|
|
147
|
+
const f: unknown = JSON.parse(readFileSync(join(dir, file), "utf-8"));
|
|
148
|
+
if (!isFinding(f)) continue;
|
|
147
149
|
all.push({ ...f, runId: runDir });
|
|
148
150
|
} catch { /* skip */ }
|
|
149
151
|
}
|
|
@@ -156,6 +158,10 @@ app.get("/api/findings", (_req, res) => {
|
|
|
156
158
|
res.json(loadAllFindings());
|
|
157
159
|
});
|
|
158
160
|
|
|
161
|
+
app.get("/api/findings/cross-run-duplicates", (_req, res) => {
|
|
162
|
+
res.json(findCrossRunDuplicates(loadAllFindings()));
|
|
163
|
+
});
|
|
164
|
+
|
|
159
165
|
app.get("/api/findings/export", (_req, res) => {
|
|
160
166
|
const findings = loadAllFindings().map(({ id, title, body, category, agentName, role, timestamp, runId }) => ({
|
|
161
167
|
id, title, body, category, agentName, role, timestamp, runId,
|
|
@@ -321,6 +327,27 @@ app.get("/api/runs/:runId/log", (req, res) => {
|
|
|
321
327
|
res.status(404).json({ error: "no log found" });
|
|
322
328
|
});
|
|
323
329
|
|
|
330
|
+
// ----------------------------------------------------------------
|
|
331
|
+
// API: schedule config
|
|
332
|
+
// ----------------------------------------------------------------
|
|
333
|
+
app.get("/api/schedule", (_req, res) => {
|
|
334
|
+
res.json(loadSchedule());
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
app.patch("/api/schedule", (req, res) => {
|
|
338
|
+
const current = loadSchedule();
|
|
339
|
+
const { enabled, dayOfWeek, hour, minute } = req.body as Partial<ScheduleConfig>;
|
|
340
|
+
const updated: ScheduleConfig = {
|
|
341
|
+
...current,
|
|
342
|
+
...(enabled != null ? { enabled: Boolean(enabled) } : {}),
|
|
343
|
+
...(dayOfWeek != null && Number.isInteger(dayOfWeek) && dayOfWeek >= 0 && dayOfWeek <= 6 ? { dayOfWeek } : {}),
|
|
344
|
+
...(hour != null && Number.isInteger(hour) && hour >= 0 && hour <= 23 ? { hour } : {}),
|
|
345
|
+
...(minute != null && Number.isInteger(minute) && minute >= 0 && minute <= 59 ? { minute } : {}),
|
|
346
|
+
};
|
|
347
|
+
saveSchedule(updated);
|
|
348
|
+
res.json(updated);
|
|
349
|
+
});
|
|
350
|
+
|
|
324
351
|
// ----------------------------------------------------------------
|
|
325
352
|
// Static: serve built React app
|
|
326
353
|
// ----------------------------------------------------------------
|
|
@@ -352,27 +379,6 @@ process.on("unhandledRejection", (reason) => {
|
|
|
352
379
|
console.error("[server] unhandledRejection:", reason);
|
|
353
380
|
});
|
|
354
381
|
|
|
355
|
-
// ----------------------------------------------------------------
|
|
356
|
-
// API: schedule config
|
|
357
|
-
// ----------------------------------------------------------------
|
|
358
|
-
app.get("/api/schedule", (_req, res) => {
|
|
359
|
-
res.json(loadSchedule());
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
app.patch("/api/schedule", (req, res) => {
|
|
363
|
-
const current = loadSchedule();
|
|
364
|
-
const { enabled, dayOfWeek, hour, minute } = req.body as Partial<ScheduleConfig>;
|
|
365
|
-
const updated: ScheduleConfig = {
|
|
366
|
-
...current,
|
|
367
|
-
...(enabled != null ? { enabled: Boolean(enabled) } : {}),
|
|
368
|
-
...(dayOfWeek != null && Number.isInteger(dayOfWeek) && dayOfWeek >= 0 && dayOfWeek <= 6 ? { dayOfWeek } : {}),
|
|
369
|
-
...(hour != null && Number.isInteger(hour) && hour >= 0 && hour <= 23 ? { hour } : {}),
|
|
370
|
-
...(minute != null && Number.isInteger(minute) && minute >= 0 && minute <= 59 ? { minute } : {}),
|
|
371
|
-
};
|
|
372
|
-
saveSchedule(updated);
|
|
373
|
-
res.json(updated);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
382
|
export { app };
|
|
377
383
|
|
|
378
384
|
if (process.env.NODE_ENV !== "test") {
|
package/server/runs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import
|
|
3
|
+
import { isFinding, type RunLog } from "../framework/types";
|
|
4
4
|
|
|
5
5
|
export interface RunSummary {
|
|
6
6
|
runId: string;
|
|
@@ -25,9 +25,10 @@ function countFindings(runId: string): { total: number; byCategory: Record<strin
|
|
|
25
25
|
const byCategory: Record<string, number> = {};
|
|
26
26
|
let total = 0;
|
|
27
27
|
for (const file of fs.readdirSync(dir)) {
|
|
28
|
-
if (!file.endsWith(".json")) continue;
|
|
28
|
+
if (!file.endsWith(".json") || file === "triage_result.json") continue;
|
|
29
29
|
try {
|
|
30
|
-
const f:
|
|
30
|
+
const f: unknown = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
31
|
+
if (!isFinding(f)) continue;
|
|
31
32
|
byCategory[f.category] = (byCategory[f.category] ?? 0) + 1;
|
|
32
33
|
total++;
|
|
33
34
|
} catch {
|