@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.
@@ -0,0 +1,507 @@
1
+ /**
2
+ * llm-client.ts
3
+ * LLM プロバイダ抽象化レイヤー
4
+ *
5
+ * 環境変数:
6
+ * LLM_PROVIDER=anthropic (デフォルト) | openai
7
+ * LLM_BASE_URL=http://localhost:11434/v1 ← Ollama/LM Studio の場合
8
+ * LLM_MODEL=llama3.2 ← ローカルモデル名(未指定時はデフォルトモデルを使用)
9
+ * LLM_API_KEY=ollama ← ローカルLLMはダミーキーでよい
10
+ */
11
+
12
+ import Anthropic from "@anthropic-ai/sdk";
13
+ import OpenAI from "openai";
14
+ import * as fs from "fs";
15
+ import * as os from "os";
16
+ import * as path from "path";
17
+
18
+ // ---- 型定義(Anthropic 互換) ----
19
+
20
+ export type MessageParam = Anthropic.MessageParam;
21
+ export type Tool = Anthropic.Tool;
22
+ export type Message = Anthropic.Message;
23
+ export type ContentBlock = Anthropic.ContentBlock;
24
+ export type ToolUseBlock = Anthropic.ToolUseBlock;
25
+ export type ToolResultBlockParam = Anthropic.ToolResultBlockParam;
26
+
27
+ export interface CreateMessageParams {
28
+ model: string;
29
+ max_tokens: number;
30
+ system: string;
31
+ tools: Tool[];
32
+ messages: MessageParam[];
33
+ }
34
+
35
+ // ---- Anthropic クライアント ----
36
+
37
+ class AnthropicClient {
38
+ private client: Anthropic;
39
+
40
+ constructor(apiKey: string) {
41
+ this.client = new Anthropic({ apiKey });
42
+ }
43
+
44
+ async createMessage(params: CreateMessageParams): Promise<Message> {
45
+ return this.client.messages.create(params) as Promise<Message>;
46
+ }
47
+ }
48
+
49
+ // ---- OpenAI 互換クライアント ----
50
+
51
+ function toOpenAITools(tools: Tool[]): OpenAI.ChatCompletionTool[] {
52
+ return tools.map((t) => ({
53
+ type: "function" as const,
54
+ function: {
55
+ name: t.name,
56
+ description: t.description,
57
+ parameters: t.input_schema as Record<string, unknown>,
58
+ },
59
+ }));
60
+ }
61
+
62
+ function toOpenAIMessages(
63
+ system: string,
64
+ messages: MessageParam[]
65
+ ): OpenAI.ChatCompletionMessageParam[] {
66
+ const result: OpenAI.ChatCompletionMessageParam[] = [
67
+ { role: "system", content: system },
68
+ ];
69
+
70
+ for (const msg of messages) {
71
+ if (msg.role === "user") {
72
+ if (typeof msg.content === "string") {
73
+ result.push({ role: "user", content: msg.content });
74
+ } else if (Array.isArray(msg.content)) {
75
+ // tool_result ブロックを OpenAI の tool メッセージに変換
76
+ const toolResults = msg.content.filter(
77
+ (b): b is Anthropic.ToolResultBlockParam => b.type === "tool_result"
78
+ );
79
+ const textBlocks = msg.content.filter(
80
+ (b): b is Anthropic.TextBlockParam => b.type === "text"
81
+ );
82
+
83
+ for (const tr of toolResults) {
84
+ const content =
85
+ typeof tr.content === "string"
86
+ ? tr.content
87
+ : Array.isArray(tr.content)
88
+ ? tr.content
89
+ .filter((b): b is Anthropic.TextBlockParam => b.type === "text")
90
+ .map((b) => b.text)
91
+ .join("\n")
92
+ : "";
93
+ result.push({
94
+ role: "tool",
95
+ tool_call_id: tr.tool_use_id,
96
+ content,
97
+ });
98
+ }
99
+ if (textBlocks.length > 0) {
100
+ result.push({
101
+ role: "user",
102
+ content: textBlocks.map((b) => b.text).join("\n"),
103
+ });
104
+ }
105
+ }
106
+ } else if (msg.role === "assistant") {
107
+ if (typeof msg.content === "string") {
108
+ result.push({ role: "assistant", content: msg.content });
109
+ } else if (Array.isArray(msg.content)) {
110
+ const textBlocks = msg.content.filter(
111
+ (b): b is Anthropic.TextBlockParam => b.type === "text"
112
+ );
113
+ const toolUses = msg.content.filter(
114
+ (b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
115
+ );
116
+
117
+ result.push({
118
+ role: "assistant",
119
+ content: textBlocks.map((b) => b.text).join("\n") || null,
120
+ tool_calls:
121
+ toolUses.length > 0
122
+ ? toolUses.map((tu) => ({
123
+ id: tu.id,
124
+ type: "function" as const,
125
+ function: {
126
+ name: tu.name,
127
+ arguments: JSON.stringify(tu.input),
128
+ },
129
+ }))
130
+ : undefined,
131
+ });
132
+ }
133
+ }
134
+ }
135
+
136
+ return result;
137
+ }
138
+
139
+ function fromOpenAIResponse(response: OpenAI.ChatCompletion): Message {
140
+ const choice = response.choices[0];
141
+ const content: ContentBlock[] = [];
142
+
143
+ if (choice.message.content) {
144
+ content.push({ type: "text", text: choice.message.content });
145
+ }
146
+
147
+ if (choice.message.tool_calls) {
148
+ for (const tc of choice.message.tool_calls) {
149
+ let input: Record<string, unknown> = {};
150
+ try {
151
+ input = JSON.parse(tc.function.arguments);
152
+ } catch { /* ignore parse error */ }
153
+
154
+ content.push({
155
+ type: "tool_use",
156
+ id: tc.id,
157
+ name: tc.function.name,
158
+ input,
159
+ });
160
+ }
161
+ }
162
+
163
+ const stopReason = choice.finish_reason === "tool_calls" ? "tool_use" : "end_turn";
164
+
165
+ return {
166
+ id: response.id,
167
+ type: "message",
168
+ role: "assistant",
169
+ content,
170
+ model: response.model,
171
+ stop_reason: stopReason,
172
+ stop_sequence: null,
173
+ usage: {
174
+ input_tokens: response.usage?.prompt_tokens ?? 0,
175
+ output_tokens: response.usage?.completion_tokens ?? 0,
176
+ cache_creation_input_tokens: 0,
177
+ cache_read_input_tokens: 0,
178
+ },
179
+ } as unknown as Message;
180
+ }
181
+
182
+ class OpenAICompatClient {
183
+ private client: OpenAI;
184
+ private defaultModel: string;
185
+
186
+ constructor(apiKey: string, baseURL?: string, defaultModel?: string) {
187
+ this.client = new OpenAI({ apiKey, baseURL });
188
+ this.defaultModel = defaultModel ?? "gpt-4o-mini";
189
+ }
190
+
191
+ async createMessage(params: CreateMessageParams): Promise<Message> {
192
+ const response = await this.client.chat.completions.create({
193
+ model: params.model || this.defaultModel,
194
+ max_tokens: params.max_tokens,
195
+ tools: toOpenAITools(params.tools),
196
+ tool_choice: "auto",
197
+ messages: toOpenAIMessages(params.system, params.messages),
198
+ });
199
+ return fromOpenAIResponse(response);
200
+ }
201
+ }
202
+
203
+ // ---- Codex (ChatGPT subscription OAuth) ----
204
+
205
+ const CODEX_AUTH_PATH = path.join(os.homedir(), ".codex", "auth.json");
206
+ const CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex";
207
+ const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
208
+ const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
209
+
210
+ interface CodexAuthFile {
211
+ tokens: {
212
+ access_token: string;
213
+ id_token: string;
214
+ refresh_token: string;
215
+ account_id?: string;
216
+ };
217
+ last_refresh: string;
218
+ }
219
+
220
+ function parseJwtAccountId(idToken: string): string {
221
+ try {
222
+ const payload = JSON.parse(Buffer.from(idToken.split(".")[1], "base64url").toString("utf-8"));
223
+ return (payload["https://api.openai.com/auth"] as Record<string, string>)?.account_id ?? "";
224
+ } catch {
225
+ return "";
226
+ }
227
+ }
228
+
229
+ async function loadCodexToken(): Promise<{ accessToken: string; accountId: string }> {
230
+ if (!fs.existsSync(CODEX_AUTH_PATH)) {
231
+ throw new Error(`${CODEX_AUTH_PATH} not found. Run: npm run auth:codex`);
232
+ }
233
+
234
+ const auth: CodexAuthFile = JSON.parse(fs.readFileSync(CODEX_AUTH_PATH, "utf-8"));
235
+ const ageMinutes = (Date.now() - new Date(auth.last_refresh).getTime()) / 60_000;
236
+
237
+ if (ageMinutes > 55) {
238
+ const refreshed = await refreshCodexToken(auth.tokens.refresh_token);
239
+ auth.tokens.access_token = refreshed.access_token;
240
+ auth.tokens.id_token = refreshed.id_token;
241
+ if (refreshed.refresh_token) auth.tokens.refresh_token = refreshed.refresh_token;
242
+ auth.last_refresh = new Date().toISOString();
243
+ fs.writeFileSync(CODEX_AUTH_PATH, JSON.stringify(auth, null, 2));
244
+ }
245
+
246
+ const accountId = auth.tokens.account_id ?? parseJwtAccountId(auth.tokens.id_token);
247
+ return { accessToken: auth.tokens.access_token, accountId };
248
+ }
249
+
250
+ async function refreshCodexToken(refreshToken: string): Promise<{ access_token: string; id_token: string; refresh_token?: string }> {
251
+ const res = await fetch(CODEX_TOKEN_URL, {
252
+ method: "POST",
253
+ headers: { "Content-Type": "application/json" },
254
+ body: JSON.stringify({
255
+ grant_type: "refresh_token",
256
+ refresh_token: refreshToken,
257
+ client_id: CODEX_CLIENT_ID,
258
+ }),
259
+ });
260
+ if (!res.ok) throw new Error(`Codex token refresh failed: ${res.status} ${await res.text()}`);
261
+ return res.json() as Promise<{ access_token: string; id_token: string; refresh_token?: string }>;
262
+ }
263
+
264
+ async function collectCodexSseResponse(res: Response): Promise<Record<string, unknown>> {
265
+ const text = await res.text();
266
+ const eventTypes: string[] = [];
267
+ // Track completed output items manually — response.completed may have output: []
268
+ const outputItems: Record<string, unknown>[] = [];
269
+ let completedResponse: Record<string, unknown> | null = null;
270
+
271
+ for (const line of text.split("\n")) {
272
+ if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
273
+ try {
274
+ const event = JSON.parse(line.slice(6)) as Record<string, unknown>;
275
+ eventTypes.push(event.type as string);
276
+
277
+ // Collect each completed output item (message, function_call, etc.)
278
+ if (event.type === "response.output_item.done") {
279
+ const item = event.item as Record<string, unknown>;
280
+ if (item?.type !== "reasoning") outputItems.push(item);
281
+ }
282
+
283
+ if (event.type === "response.completed" || event.type === "response.done") {
284
+ completedResponse = event.response as Record<string, unknown>;
285
+ }
286
+ } catch { /* ignore malformed lines */ }
287
+ }
288
+
289
+ if (completedResponse) {
290
+ // Prefer manually collected items over response.output (which can be empty)
291
+ const output = (completedResponse.output as unknown[])?.length
292
+ ? completedResponse.output
293
+ : outputItems;
294
+ return { ...completedResponse, output };
295
+ }
296
+
297
+ throw new Error(`No completed response found in Codex SSE stream. Events seen: ${eventTypes.join(", ")}`);
298
+ }
299
+
300
+ // Replace lone surrogates that cause JSON serialization errors
301
+ function sanitizeStr(s: string): string {
302
+ return s.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
303
+ }
304
+
305
+ function toCodexInput(messages: MessageParam[]): unknown[] {
306
+ const result: unknown[] = [];
307
+
308
+ for (const msg of messages) {
309
+ if (msg.role === "user") {
310
+ if (typeof msg.content === "string") {
311
+ result.push({ role: "user", content: msg.content });
312
+ } else if (Array.isArray(msg.content)) {
313
+ const toolResults = msg.content.filter(
314
+ (b): b is Anthropic.ToolResultBlockParam => b.type === "tool_result"
315
+ );
316
+ const textBlocks = msg.content.filter(
317
+ (b): b is Anthropic.TextBlockParam => b.type === "text"
318
+ );
319
+ for (const tr of toolResults) {
320
+ const output =
321
+ typeof tr.content === "string"
322
+ ? tr.content
323
+ : Array.isArray(tr.content)
324
+ ? tr.content.filter((b): b is Anthropic.TextBlockParam => b.type === "text").map(b => b.text).join("\n")
325
+ : "";
326
+ result.push({ type: "function_call_output", call_id: tr.tool_use_id, output: sanitizeStr(output) });
327
+ }
328
+ if (textBlocks.length > 0) {
329
+ result.push({ role: "user", content: textBlocks.map(b => b.text).join("\n") });
330
+ }
331
+ }
332
+ } else if (msg.role === "assistant") {
333
+ if (typeof msg.content === "string") {
334
+ result.push({ role: "assistant", content: msg.content });
335
+ } else if (Array.isArray(msg.content)) {
336
+ const textBlocks = msg.content.filter(
337
+ (b): b is Anthropic.TextBlockParam => b.type === "text"
338
+ );
339
+ const toolUses = msg.content.filter(
340
+ (b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
341
+ );
342
+ if (textBlocks.length > 0) {
343
+ result.push({ role: "assistant", content: textBlocks.map(b => b.text).join("\n") });
344
+ }
345
+ for (const tu of toolUses) {
346
+ result.push({
347
+ type: "function_call",
348
+ call_id: tu.id,
349
+ name: tu.name,
350
+ arguments: JSON.stringify(tu.input),
351
+ });
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ return result;
358
+ }
359
+
360
+ function toCodexTools(tools: Tool[]): unknown[] {
361
+ return tools.map(t => ({
362
+ type: "function",
363
+ name: t.name,
364
+ description: t.description ?? "",
365
+ parameters: t.input_schema,
366
+ }));
367
+ }
368
+
369
+ function fromCodexResponse(response: Record<string, unknown>): Message {
370
+ const output = (response.output as unknown[]) ?? [];
371
+ const content: ContentBlock[] = [];
372
+ let hasToolUse = false;
373
+
374
+ for (const item of output) {
375
+ const i = item as Record<string, unknown>;
376
+ if (i.type === "message") {
377
+ for (const block of (i.content as unknown[]) ?? []) {
378
+ const b = block as Record<string, unknown>;
379
+ if (b.type === "output_text") content.push({ type: "text", text: b.text as string });
380
+ }
381
+ } else if (i.type === "function_call") {
382
+ hasToolUse = true;
383
+ let input: Record<string, unknown> = {};
384
+ try { input = JSON.parse(i.arguments as string); } catch { /* ignore */ }
385
+ content.push({
386
+ type: "tool_use",
387
+ id: (i.call_id ?? i.id) as string,
388
+ name: i.name as string,
389
+ input,
390
+ });
391
+ }
392
+ }
393
+
394
+ const usage = (response.usage as Record<string, number> | undefined) ?? {};
395
+
396
+ return {
397
+ id: response.id as string,
398
+ type: "message",
399
+ role: "assistant",
400
+ content,
401
+ model: response.model as string,
402
+ stop_reason: hasToolUse ? "tool_use" : "end_turn",
403
+ stop_sequence: null,
404
+ usage: {
405
+ input_tokens: usage.input_tokens ?? 0,
406
+ output_tokens: usage.output_tokens ?? 0,
407
+ cache_creation_input_tokens: 0,
408
+ cache_read_input_tokens: 0,
409
+ },
410
+ } as unknown as Message;
411
+ }
412
+
413
+ class CodexClient {
414
+ private defaultModel: string;
415
+
416
+ constructor(defaultModel: string) {
417
+ this.defaultModel = defaultModel;
418
+ }
419
+
420
+ async createMessage(params: CreateMessageParams): Promise<Message> {
421
+ const { accessToken, accountId } = await loadCodexToken();
422
+
423
+ const res = await fetch(`${CODEX_BASE_URL}/responses`, {
424
+ method: "POST",
425
+ headers: {
426
+ "Content-Type": "application/json",
427
+ "Authorization": `Bearer ${accessToken}`,
428
+ "chatgpt-account-id": accountId,
429
+ "OpenAI-Beta": "responses=experimental",
430
+ },
431
+ body: (() => {
432
+ const b = JSON.stringify({
433
+ model: params.model || this.defaultModel,
434
+ instructions: params.system || "",
435
+ input: toCodexInput(params.messages),
436
+ tools: toCodexTools(params.tools),
437
+ store: false,
438
+ stream: true,
439
+ });
440
+ return b;
441
+ })(),
442
+ });
443
+
444
+ if (!res.ok) {
445
+ throw new Error(`Codex API error: ${res.status} ${await res.text()}`);
446
+ }
447
+
448
+ const response = await collectCodexSseResponse(res);
449
+ return fromCodexResponse(response);
450
+ }
451
+ }
452
+
453
+ // ---- Factory ----
454
+
455
+ export type LLMClient = AnthropicClient | OpenAICompatClient | CodexClient;
456
+
457
+ // OpenAI-compat プロバイダのデフォルト設定
458
+ // LLM_BASE_URL / LLM_MODEL で個別上書き可能
459
+ const COMPAT_PROVIDERS: Record<string, { baseURL: string; defaultModel: string }> = {
460
+ ollama: { baseURL: "http://localhost:11434/v1", defaultModel: "llama3.2" },
461
+ "lm-studio": { baseURL: "http://localhost:1234/v1", defaultModel: "" },
462
+ groq: { baseURL: "https://api.groq.com/openai/v1", defaultModel: "llama-3.3-70b-versatile" },
463
+ gemini: { baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", defaultModel: "gemini-2.0-flash" },
464
+ openai: { baseURL: "https://api.openai.com/v1", defaultModel: "gpt-4o-mini" },
465
+ openrouter: { baseURL: "https://openrouter.ai/api/v1", defaultModel: "google/gemini-flash-1.5" },
466
+ };
467
+
468
+ export function createLLMClient(): { client: LLMClient; defaultModel: string; provider: string } {
469
+ const provider = process.env.LLM_PROVIDER ?? "anthropic";
470
+ const baseURL = process.env.LLM_BASE_URL;
471
+ const model = process.env.LLM_MODEL;
472
+
473
+ // Codex は独自クライアント
474
+ if (provider === "codex") {
475
+ const effectiveModel = model ?? "gpt-5.1-codex-mini";
476
+ console.log(`[LLM] provider: Codex (ChatGPT subscription), model: ${effectiveModel}`);
477
+ return {
478
+ client: new CodexClient(effectiveModel),
479
+ defaultModel: effectiveModel,
480
+ provider: "codex",
481
+ };
482
+ }
483
+
484
+ // OpenAI-compat: 既知プロバイダ名 または LLM_BASE_URL が設定されている場合
485
+ const compatDefaults = COMPAT_PROVIDERS[provider];
486
+ if (compatDefaults || baseURL) {
487
+ const apiKey = process.env.LLM_API_KEY ?? process.env.OPENAI_API_KEY ?? "";
488
+ const effectiveBaseURL = baseURL ?? compatDefaults?.baseURL ?? "https://api.openai.com/v1";
489
+ const effectiveModel = model ?? compatDefaults?.defaultModel ?? "gpt-4o-mini";
490
+ console.log(`[LLM] provider: ${provider} (${effectiveBaseURL}), model: ${effectiveModel}`);
491
+ return {
492
+ client: new OpenAICompatClient(apiKey, effectiveBaseURL, effectiveModel),
493
+ defaultModel: effectiveModel,
494
+ provider,
495
+ };
496
+ }
497
+
498
+ // Anthropic (default)
499
+ const apiKey = process.env.ANTHROPIC_API_KEY ?? "";
500
+ const effectiveModel = model ?? "claude-haiku-4-5-20251001";
501
+ console.log(`[LLM] provider: Anthropic, model: ${effectiveModel}`);
502
+ return {
503
+ client: new AnthropicClient(apiKey),
504
+ defaultModel: effectiveModel,
505
+ provider: "anthropic",
506
+ };
507
+ }
@@ -0,0 +1,182 @@
1
+ import type { Page } from "playwright";
2
+
3
+ export interface ConsoleEntry {
4
+ type: "error" | "warning" | "info" | "log";
5
+ text: string;
6
+ timestamp: string;
7
+ }
8
+
9
+ export interface NetworkError {
10
+ url: string;
11
+ method: string;
12
+ status: number | null;
13
+ errorText: string;
14
+ timestamp: string;
15
+ }
16
+
17
+ export interface ObservationState {
18
+ consoleLogs: ConsoleEntry[];
19
+ networkErrors: NetworkError[];
20
+ previousSnapshot: string | null;
21
+ }
22
+
23
+ export function setupObservation(page: Page): ObservationState {
24
+ const state: ObservationState = { consoleLogs: [], networkErrors: [], previousSnapshot: null };
25
+
26
+ page.on("console", (msg) => {
27
+ const type = msg.type() as ConsoleEntry["type"];
28
+ if (type === "error" || type === "warning") {
29
+ state.consoleLogs.push({ type, text: msg.text(), timestamp: new Date().toISOString() });
30
+ }
31
+ });
32
+
33
+ page.on("requestfailed", (request) => {
34
+ state.networkErrors.push({
35
+ url: request.url(),
36
+ method: request.method(),
37
+ status: null,
38
+ errorText: request.failure()?.errorText ?? "unknown",
39
+ timestamp: new Date().toISOString(),
40
+ });
41
+ });
42
+
43
+ page.on("response", (response) => {
44
+ if (response.status() >= 400 && !response.url().includes("/_next/")) {
45
+ state.networkErrors.push({
46
+ url: response.url(),
47
+ method: response.request().method(),
48
+ status: response.status(),
49
+ errorText: `HTTP ${response.status()}`,
50
+ timestamp: new Date().toISOString(),
51
+ });
52
+ }
53
+ });
54
+
55
+ return state;
56
+ }
57
+
58
+ export function getRecentConsoleLogs(state: ObservationState, limit = 10): ConsoleEntry[] {
59
+ return state.consoleLogs.slice(-limit);
60
+ }
61
+
62
+ export function getRecentNetworkErrors(state: ObservationState, limit = 10): NetworkError[] {
63
+ return state.networkErrors.slice(-limit);
64
+ }
65
+
66
+ export async function readPageText(page: Page, maxLength = 2000): Promise<string> {
67
+ const text = await page.evaluate(() => {
68
+ const walker = document.createTreeWalker(
69
+ document.body,
70
+ NodeFilter.SHOW_TEXT,
71
+ {
72
+ acceptNode(node) {
73
+ const el = node.parentElement;
74
+ if (!el) return NodeFilter.FILTER_REJECT;
75
+ const style = window.getComputedStyle(el);
76
+ if (
77
+ style.display === "none" ||
78
+ style.visibility === "hidden" ||
79
+ parseFloat(style.opacity) === 0
80
+ ) {
81
+ return NodeFilter.FILTER_REJECT;
82
+ }
83
+ return node.textContent?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
84
+ },
85
+ }
86
+ );
87
+ const texts: string[] = [];
88
+ let node: Node | null;
89
+ while ((node = walker.nextNode())) {
90
+ const t = node.textContent?.trim();
91
+ if (t) texts.push(t);
92
+ }
93
+ return texts.join("\n");
94
+ });
95
+ return text.length > maxLength ? text.slice(0, maxLength) + "\n...(truncated)" : text;
96
+ }
97
+
98
+ export async function saveSnapshotBeforeAction(page: Page, state: ObservationState): Promise<void> {
99
+ try {
100
+ state.previousSnapshot = await page.ariaSnapshot({ mode: "ai", depth: 6 });
101
+ } catch {
102
+ // ignore errors during navigation
103
+ }
104
+ }
105
+
106
+ function normalizeAriaLine(line: string): string {
107
+ return line.trim().replace(/\[ref=\w+\]/g, "").trim();
108
+ }
109
+
110
+ function lcsDiff(oldLines: string[], newLines: string[]): { added: string[]; removed: string[] } {
111
+ const m = oldLines.length;
112
+ const n = newLines.length;
113
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
114
+ for (let i = 1; i <= m; i++) {
115
+ for (let j = 1; j <= n; j++) {
116
+ dp[i][j] = oldLines[i - 1] === newLines[j - 1]
117
+ ? dp[i - 1][j - 1] + 1
118
+ : Math.max(dp[i - 1][j], dp[i][j - 1]);
119
+ }
120
+ }
121
+ const added: string[] = [];
122
+ const removed: string[] = [];
123
+ let i = m, j = n;
124
+ while (i > 0 || j > 0) {
125
+ if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
126
+ i--; j--;
127
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
128
+ added.unshift(newLines[j - 1]);
129
+ j--;
130
+ } else {
131
+ removed.unshift(oldLines[i - 1]);
132
+ i--;
133
+ }
134
+ }
135
+ return { added, removed };
136
+ }
137
+
138
+ export async function getDiffFromSnapshot(page: Page, state: ObservationState, maxLength = 2000): Promise<string> {
139
+ if (!state.previousSnapshot) return "(no previous snapshot)";
140
+
141
+ let currentSnapshot: string;
142
+ try {
143
+ currentSnapshot = await page.ariaSnapshot({ mode: "ai", depth: 6 });
144
+ } catch {
145
+ return "(failed to get snapshot)";
146
+ }
147
+
148
+ const oldLines = state.previousSnapshot.split("\n").map(normalizeAriaLine).filter(Boolean);
149
+ const newLines = currentSnapshot.split("\n").map(normalizeAriaLine).filter(Boolean);
150
+
151
+ const { added, removed } = lcsDiff(oldLines, newLines);
152
+
153
+ if (added.length === 0 && removed.length === 0) return "no changes";
154
+
155
+ const parts: string[] = [];
156
+ if (added.length > 0) parts.push(`added:\n${added.map((l) => `+ ${l}`).join("\n")}`);
157
+ if (removed.length > 0) parts.push(`removed:\n${removed.map((l) => `- ${l}`).join("\n")}`);
158
+
159
+ const result = parts.join("\n\n");
160
+ return result.length > maxLength ? result.slice(0, maxLength) + "\n...(省略)" : result;
161
+ }
162
+
163
+ export async function readAccessibilityTree(page: Page, maxLength = 3000): Promise<string> {
164
+ const snapshot = await page.ariaSnapshot({ mode: "ai", depth: 6 });
165
+ return snapshot.length > maxLength ? snapshot.slice(0, maxLength) + "\n...(省略)" : snapshot;
166
+ }
167
+
168
+ export function buildObservationWarning(state: ObservationState): string | null {
169
+ const errors = state.consoleLogs.filter((m) => m.type === "error");
170
+ const fatalNetErrors = state.networkErrors.filter((e) => e.status === null || e.status >= 500);
171
+
172
+ if (errors.length === 0 && fatalNetErrors.length === 0) return null;
173
+
174
+ const parts: string[] = ["[observation] issues detected:"];
175
+ if (errors.length > 0) {
176
+ parts.push(`JS errors: ${errors.slice(-3).map((e) => e.text).join(" | ")}`);
177
+ }
178
+ if (fatalNetErrors.length > 0) {
179
+ parts.push(`network errors: ${fatalNetErrors.slice(-3).map((e) => `${e.method} ${new URL(e.url).pathname} (${e.errorText})`).join(" | ")}`);
180
+ }
181
+ return parts.join("\n");
182
+ }