@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 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` |
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import type { Finding } from "./types";
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: Finding = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
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
+ }
@@ -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 { Finding } from "./types.js";
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
- out.push(JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8")));
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;
@@ -200,7 +200,10 @@ class OpenAICompatClient {
200
200
  private defaultModel: string;
201
201
 
202
202
  constructor(apiKey: string, baseURL?: string, defaultModel?: string) {
203
- this.client = new OpenAI({ apiKey, baseURL });
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
 
@@ -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.20",
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.21.0",
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 type { Finding } from "../framework/types.js";
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: Finding = JSON.parse(readFileSync(join(dir, file), "utf-8"));
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 type { RunLog, Finding } from "../framework/types";
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: Finding = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
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 {