@geekbeer/minion 3.51.2 → 3.53.1

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,143 @@
1
+ /**
2
+ * Page Recipe Store (SQLite, experimental — v3.53.0)
3
+ *
4
+ * Backs POST /api/web/extract. See core/lib/web-extract/extractor.js for the
5
+ * orchestrator that decides hot vs cold paths.
6
+ */
7
+
8
+ const { getDb } = require('../db')
9
+
10
+ const MAX_FAIL_COUNT = 3
11
+
12
+ function find({ urlTemplate, domFingerprint }) {
13
+ const db = getDb()
14
+ const row = db.prepare(`
15
+ SELECT url_template, dom_fingerprint, selectors_json, page_type,
16
+ hit_count, fail_count, last_verified_at, created_at
17
+ FROM page_recipes
18
+ WHERE url_template = ? AND dom_fingerprint = ?
19
+ `).get(urlTemplate, domFingerprint)
20
+ if (!row) return null
21
+ return parseRow(row)
22
+ }
23
+
24
+ function findByTemplate(urlTemplate) {
25
+ const db = getDb()
26
+ const rows = db.prepare(`
27
+ SELECT url_template, dom_fingerprint, selectors_json, page_type,
28
+ hit_count, fail_count, last_verified_at, created_at
29
+ FROM page_recipes
30
+ WHERE url_template = ?
31
+ ORDER BY last_verified_at DESC NULLS LAST, created_at DESC
32
+ `).all(urlTemplate)
33
+ return rows.map(parseRow)
34
+ }
35
+
36
+ function upsert({ urlTemplate, domFingerprint, selectors, pageType }) {
37
+ const db = getDb()
38
+ const json = JSON.stringify(selectors || {})
39
+ const now = new Date().toISOString()
40
+ db.prepare(`
41
+ INSERT INTO page_recipes (url_template, dom_fingerprint, selectors_json, page_type, hit_count, fail_count, last_verified_at, created_at)
42
+ VALUES (?, ?, ?, ?, 0, 0, ?, ?)
43
+ ON CONFLICT(url_template, dom_fingerprint) DO UPDATE SET
44
+ selectors_json = excluded.selectors_json,
45
+ page_type = excluded.page_type,
46
+ fail_count = 0,
47
+ last_verified_at = excluded.last_verified_at
48
+ `).run(urlTemplate, domFingerprint, json, pageType || null, now, now)
49
+ return find({ urlTemplate, domFingerprint })
50
+ }
51
+
52
+ function incrementHit({ urlTemplate, domFingerprint }) {
53
+ const db = getDb()
54
+ db.prepare(`
55
+ UPDATE page_recipes
56
+ SET hit_count = hit_count + 1
57
+ WHERE url_template = ? AND dom_fingerprint = ?
58
+ `).run(urlTemplate, domFingerprint)
59
+ }
60
+
61
+ function setLastVerified({ urlTemplate, domFingerprint }) {
62
+ const db = getDb()
63
+ db.prepare(`
64
+ UPDATE page_recipes
65
+ SET last_verified_at = ?, fail_count = 0
66
+ WHERE url_template = ? AND dom_fingerprint = ?
67
+ `).run(new Date().toISOString(), urlTemplate, domFingerprint)
68
+ }
69
+
70
+ /**
71
+ * Increment fail count. Returns true if the recipe was deleted (>= MAX_FAIL_COUNT).
72
+ */
73
+ function incrementFail({ urlTemplate, domFingerprint }) {
74
+ const db = getDb()
75
+ const row = db.prepare(`
76
+ SELECT fail_count FROM page_recipes
77
+ WHERE url_template = ? AND dom_fingerprint = ?
78
+ `).get(urlTemplate, domFingerprint)
79
+ if (!row) return false
80
+ const next = (row.fail_count || 0) + 1
81
+ if (next >= MAX_FAIL_COUNT) {
82
+ remove({ urlTemplate, domFingerprint })
83
+ return true
84
+ }
85
+ db.prepare(`
86
+ UPDATE page_recipes
87
+ SET fail_count = ?
88
+ WHERE url_template = ? AND dom_fingerprint = ?
89
+ `).run(next, urlTemplate, domFingerprint)
90
+ return false
91
+ }
92
+
93
+ function remove({ urlTemplate, domFingerprint }) {
94
+ const db = getDb()
95
+ const result = db.prepare(`
96
+ DELETE FROM page_recipes
97
+ WHERE url_template = ? AND dom_fingerprint = ?
98
+ `).run(urlTemplate, domFingerprint)
99
+ return result.changes > 0
100
+ }
101
+
102
+ function listAll({ limit = 100 } = {}) {
103
+ const db = getDb()
104
+ const rows = db.prepare(`
105
+ SELECT url_template, dom_fingerprint, selectors_json, page_type,
106
+ hit_count, fail_count, last_verified_at, created_at
107
+ FROM page_recipes
108
+ ORDER BY last_verified_at DESC NULLS LAST, created_at DESC
109
+ LIMIT ?
110
+ `).all(limit)
111
+ return rows.map(parseRow)
112
+ }
113
+
114
+ function parseRow(row) {
115
+ let selectors = {}
116
+ try {
117
+ selectors = JSON.parse(row.selectors_json || '{}')
118
+ } catch {
119
+ selectors = {}
120
+ }
121
+ return {
122
+ url_template: row.url_template,
123
+ dom_fingerprint: row.dom_fingerprint,
124
+ selectors,
125
+ page_type: row.page_type,
126
+ hit_count: row.hit_count,
127
+ fail_count: row.fail_count,
128
+ last_verified_at: row.last_verified_at,
129
+ created_at: row.created_at,
130
+ }
131
+ }
132
+
133
+ module.exports = {
134
+ find,
135
+ findByTemplate,
136
+ upsert,
137
+ incrementHit,
138
+ incrementFail,
139
+ setLastVerified,
140
+ remove,
141
+ listAll,
142
+ MAX_FAIL_COUNT,
143
+ }
@@ -696,6 +696,72 @@ PUT `/api/email/inbox/:id` body:
696
696
 
697
697
  Note: 既読メールは受信後90日で自動削除される。未読メールは保持される。
698
698
 
699
+ ### Web Page Extraction 🧪 (experimental, v3.53.0〜)
700
+
701
+ Web ページの読み取り・要約・情報抽出をミニオン内のサブプロセスで完結させ、メインの Claude Code セッションには結果 JSON だけを返すための実験的 API。Playwright MCP で DOM 全体がチャットに流れ込みトークン肥大化を起こす問題への対処として導入。
702
+
703
+ | Method | Endpoint | Description |
704
+ |--------|----------|-------------|
705
+ | POST | `/api/web/extract` | URL を抽出済み JSON に変換 (タイトル・本文・主要構造化データ) |
706
+ | GET | `/api/web/recipes` | キャッシュされたレシピ一覧 (debug) |
707
+ | DELETE | `/api/web/recipes?template=...&fingerprint=...` | レシピを削除 |
708
+
709
+ **前提条件 (LLM の解決順):**
710
+ 1. **primary LLM プラグインが設定されていればそれを使用** (推奨)。`PUT /api/llm/config` で `claude` 等を primary に指定すると、CLI の認証情報 (`~/.claude/.credentials.json`) がそのまま使われる。API キーの別途設定は不要。
711
+ 2. primary 未設定で `ANTHROPIC_API_KEY` シークレットが設定されていれば、そちらを fallback として使用 (Anthropic Messages API の tool_use で JSON Schema を強制)。
712
+ 3. どちらもなければ 503 (`LLM_UNAVAILABLE`) を返す。
713
+
714
+ その他の前提:
715
+ - ホスト上で `npx playwright install chromium` を実行済みであること (未実行の場合 503 が返る)
716
+
717
+ **`POST /api/web/extract` リクエスト:**
718
+ ```json
719
+ {
720
+ "url": "https://example.com/article/123",
721
+ "hint": "本文と著者を抽出してほしい (任意, 抽出フィールドのヒント)"
722
+ }
723
+ ```
724
+
725
+ **レスポンス (success):**
726
+ ```json
727
+ {
728
+ "success": true,
729
+ "experimental": true,
730
+ "url": "https://example.com/article/123",
731
+ "finalUrl": "https://example.com/article/123",
732
+ "statusCode": 200,
733
+ "recipeMode": "cold",
734
+ "recipeId": "example.com/article/:id#abc123def456",
735
+ "pageType": "article",
736
+ "title": "...",
737
+ "content": "Markdown 本文...",
738
+ "structured": { "title": "...", "author": "...", "publishedAt": "..." },
739
+ "selectors": { "title": { "selector": "h1" }, "author": { "selector": "a[rel=author]" } }
740
+ }
741
+ ```
742
+
743
+ **動作:**
744
+ - 初回アクセス (cold): Playwright でレンダリング → Readability で本文抽出 → Anthropic Haiku でセレクタ生成 → SQLite (`page_recipes`) に保存 → セレクタで再抽出して返却
745
+ - 2回目以降 (hot): URL 正規化・テンプレート化 → DOM フィンガープリントで保存済みレシピを照合 → セレクタで抽出のみ (LLM 呼び出しなし)
746
+ - セルフヒール: hot 実行で空結果が返ったら `fail_count++`、3回失敗で破棄して次回 cold 再生成
747
+
748
+ **URL 正規化ルール:**
749
+ - `utm_*` `fbclid` `gclid` `ref` 等のトラッキングクエリは除去
750
+ - `page` `p` `offset` 等のページネーション値は `:n` プレースホルダ化
751
+ - パスセグメントは数値→`:id`、UUID→`:uuid`、20文字以上の英数字→`:slug` に置換
752
+ - 例: `https://www.lancers.jp/work/proposal/123456?utm_source=foo&page=2` → `lancers.jp/work/proposal/:id?page=:n`
753
+
754
+ **エラー:**
755
+ - 401: APIトークン不正
756
+ - 400: URL 欠落・不正
757
+ - 502: primary LLM 呼び出し失敗 (`PRIMARY_LLM_FAILED`) / 返却 JSON が parse 不能 (`PRIMARY_LLM_BAD_JSON`)
758
+ - 503: Playwright 未インストール (`PLAYWRIGHT_UNAVAILABLE`) / LLM 未設定 (`LLM_UNAVAILABLE`)
759
+ - 500: 抽出失敗 (タイムアウト等)
760
+
761
+ **注意 (experimental):**
762
+ - レスポンス形状・URL 正規化ルール・キャッシュスキーマは予告なく変更される可能性がある
763
+ - 認証付きページ (ログイン必須) は対応外。Cookie や対話的操作が必要な場合は Playwright MCP を使用すること
764
+
699
765
  ### Commands
700
766
 
701
767
  | Method | Endpoint | Description |
@@ -705,6 +771,23 @@ Note: 既読メールは受信後90日で自動削除される。未読メール
705
771
 
706
772
  Available commands: `restart-agent`, `update-agent`, `restart-display`, `restart-all`, `status-services`
707
773
 
774
+ ### Admin (HQ → Minion only)
775
+
776
+ HQ が課金状態の変化に応じてミニオンに直接プッシュするエンドポイント。
777
+
778
+ | Method | Endpoint | Description |
779
+ |--------|----------|-------------|
780
+ | POST | `/api/admin/freeze` | ポーラーを停止しHQへの発信を抑止する。Body: `{reason}` |
781
+ | GET | `/api/admin/state` | 現在のfrozen状態を取得 |
782
+
783
+ **Freeze の挙動 (v3.52.0〜):**
784
+ - ミニオン内のグローバル `frozen=true` (in-memory only) を立てる。
785
+ - 各ポーラー (step, dag-step, board-task, thread-watcher, dag-cron, revision-watcher, heartbeat) が `pollOnce()` 直前で `isFrozen()` を確認しスキップ。
786
+ - 共通HTTPクライアント (`core/api.js`, 各pollerの `*Request`) は HQ から `402 billing_frozen` を受信した時点で自動的に `frozen=true` をセット (HQからのpushが届かなくても自己防衛)。
787
+ - 状態は**ディスク永続化されない**。プロセス再起動で自動的にクリアされ、課金復活後の `restart-agent` コマンドで自然に解除される。
788
+
789
+ 復旧は HQ が既存の `POST /api/command { command: "restart-agent" }` をプッシュすることで行う (専用 unfreeze エンドポイントは無い)。
790
+
708
791
  ---
709
792
 
710
793
  ## HQ API Endpoints (https://<HQ_URL>)
@@ -4,6 +4,64 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## Webページの読み取り・要約 🧪 (experimental, v3.53.0〜)
8
+
9
+ ユーザーから「このURLを要約して」「このページの情報を抽出して」「このページの一覧を取ってきて」と依頼された場合の手順。
10
+
11
+ ### まずローカルAPIを試す
12
+
13
+ ```bash
14
+ curl -X POST http://localhost:8080/api/web/extract \
15
+ -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
16
+ -d '{"url": "対象URL", "hint": "何を抽出したいか短く (任意)"}' | jq
17
+ ```
18
+
19
+ 返ってきた JSON の `title` `content` `structured` を使って要約・回答を作成する。`structured` には初回アクセス時に LLM が選んだフィールドが入る。
20
+
21
+ このAPIは内部で Playwright + Readability を回して **メインセッションには結果 JSON だけ返す** ため、Playwright MCP を使うときに起きていたチャットコンテキストのトークン肥大化が回避できる。
22
+
23
+ ### Playwright MCP を使うべき場面
24
+
25
+ `/api/web/extract` で対応できないのは以下のケース。このときだけ `mcp__playwright__*` を使う:
26
+
27
+ - ログイン必須ページ (Cookie/2FA 等の認証必要)
28
+ - フォーム入力・複数ページ遷移を伴う操作
29
+ - ボタンクリック→動的に追加されるコンテンツの取得
30
+ - Lancers コンペ応募など、明らかに対話的操作が必要なフロー
31
+
32
+ **単純な閲覧・抽出用途では MCP を使わない。**
33
+
34
+ ### よくあるパターン
35
+
36
+ | ユーザー依頼 | 推奨手段 |
37
+ |--------------|----------|
38
+ | 「このQiita記事を要約」 | `/api/web/extract` |
39
+ | 「Lancersコンペの一覧を取得」 | `/api/web/extract` |
40
+ | 「このプロダクトページから価格を抽出」 | `/api/web/extract` |
41
+ | 「ログインしてダッシュボード操作」 | Playwright MCP |
42
+ | 「フォームを送信」 | Playwright MCP |
43
+ | 「複数ページ巡回して全件取得」 | `/api/web/extract` をループ呼び出し (各ページに対して) |
44
+
45
+ ### キャッシュの確認・破棄 (debug)
46
+
47
+ ```bash
48
+ # キャッシュ済みレシピ一覧
49
+ curl -H "Authorization: Bearer $API_TOKEN" http://localhost:8080/api/web/recipes | jq
50
+
51
+ # 特定のレシピを削除 (壊れたセレクタを強制再生成させたい場合)
52
+ curl -X DELETE -H "Authorization: Bearer $API_TOKEN" \
53
+ "http://localhost:8080/api/web/recipes?template=lancers.jp/work/proposal/:id&fingerprint=abc123def456"
54
+ ```
55
+
56
+ ### 失敗時の対処
57
+
58
+ - `503 PLAYWRIGHT_UNAVAILABLE` → ホスト側で `npx playwright install chromium` を実行 (sudo 不要)
59
+ - `503 LLM_UNAVAILABLE` → primary LLM 未設定。`PUT /api/llm/config -d '{"primary":"claude"}'` で primary を指定するか、fallback として `PUT /api/secrets/ANTHROPIC_API_KEY` で API キーを投入
60
+ - `502 PRIMARY_LLM_BAD_JSON` → primary LLM が JSON 形式で返さなかった。プラグインの認証状態を `GET /api/llm/plugins` で確認し、必要なら別 plugin を primary にする
61
+ - `500 extract timeout` → ページが重すぎる/JSレンダリング待ちが長すぎる。Playwright MCP に切り替えて手動操作
62
+
63
+ ---
64
+
7
65
  ## スキルの修正
8
66
 
9
67
  ### 1. ローカルのスキルを編集する
@@ -362,6 +362,29 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
362
362
  )
363
363
  }
364
364
 
365
+ // Web page extraction guidance (experimental, v3.53.0)
366
+ if (!sessionId) {
367
+ const port = require('../../core/config').config.AGENT_PORT
368
+ parts.push(
369
+ '[Webページ読み取りについて 🧪 experimental]',
370
+ 'Webページの読み取り・要約・情報抽出が必要なときは、まず以下のローカルAPIを試すこと:',
371
+ '',
372
+ '```bash',
373
+ `curl -X POST http://localhost:${port}/api/web/extract \\`,
374
+ ' -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \\',
375
+ ' -d \'{"url": "対象URL", "hint": "抽出したい内容を短く"}\'',
376
+ '```',
377
+ '',
378
+ 'このAPIは内部で Playwright + Readability を回し、抽出済みJSONだけを返すため、',
379
+ 'DOM全体がチャットに流れ込んでトークン肥大化することを防げる。',
380
+ '初回アクセスで学習したセレクタはSQLiteにキャッシュされ、2回目以降はLLM呼び出しなしで抽出される。',
381
+ '',
382
+ 'Playwright MCP (`mcp__playwright__*`) は **ログイン・フォーム入力・複数画面の対話操作**が必要な場合のみ使用する。',
383
+ '単純な閲覧・要約・一覧取得用途ではMCPを使わない。',
384
+ ''
385
+ )
386
+ }
387
+
365
388
  // File output guidance — always inject on new sessions
366
389
  if (!sessionId) {
367
390
  parts.push(
@@ -770,6 +793,11 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
770
793
  child.on('close', async (code) => {
771
794
  activeChatChild = null
772
795
 
796
+ console.log(`[Chat] child closed: code=${code}, response=${fullResponse.length}chars, turns=${turnCount}, stderr=${stderrBuffer.length}bytes, session=${resolvedSessionId}`)
797
+ if (stderrBuffer.trim()) {
798
+ console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
799
+ }
800
+
773
801
  // Store messages in chat-store
774
802
  if (resolvedSessionId) {
775
803
  // If this was a new session, also store the user message now
@@ -782,11 +810,15 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
782
810
  }
783
811
  }
784
812
 
785
- // If exit code is non-zero and no response was generated, send error
786
- if (code !== 0 && !fullResponse) {
813
+ if (code !== 0) {
787
814
  const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
788
- console.error(`[Chat] CLI failed (exit ${code}): ${errorMsg}`)
789
- const errorEvent = JSON.stringify({ type: 'error', error: errorMsg })
815
+ console.error(`[Chat] CLI failed (exit ${code}, partial=${!!fullResponse}): ${errorMsg}`)
816
+ const errorEvent = JSON.stringify({
817
+ type: 'error',
818
+ error: errorMsg,
819
+ partial: !!fullResponse,
820
+ exit_code: code,
821
+ })
790
822
  res.write(`data: ${errorEvent}\n\n`)
791
823
  }
792
824
 
package/linux/server.js CHANGED
@@ -74,6 +74,7 @@ const { skillRoutes } = require('../core/routes/skills')
74
74
  const { workflowRoutes } = require('../core/routes/workflows')
75
75
  const { routineRoutes } = require('../core/routes/routines')
76
76
  const { authRoutes } = require('../core/routes/auth')
77
+ const { adminRoutes } = require('../core/routes/admin')
77
78
  const { variableRoutes } = require('../core/routes/variables')
78
79
  const { memoryRoutes } = require('../core/routes/memory')
79
80
  const { dailyLogRoutes } = require('../core/routes/daily-logs')
@@ -84,6 +85,7 @@ const { todoRoutes } = require('../core/routes/todos')
84
85
  const { emailRoutes } = require('../core/routes/emails')
85
86
  const { daemonRoutes } = require('../core/routes/daemons')
86
87
  const { llmRoutes } = require('../core/routes/llm')
88
+ const { webRoutes } = require('../core/routes/web')
87
89
 
88
90
  // Linux-specific routes
89
91
  const { commandRoutes, getProcessManager, getAllowedCommands } = require('./routes/commands')
@@ -286,6 +288,7 @@ async function registerAllRoutes(app) {
286
288
  await app.register(workflowRoutes, { workflowRunner })
287
289
  await app.register(routineRoutes, { routineRunner })
288
290
  await app.register(authRoutes)
291
+ await app.register(adminRoutes)
289
292
  await app.register(variableRoutes)
290
293
  await app.register(memoryRoutes)
291
294
  await app.register(dailyLogRoutes)
@@ -296,6 +299,7 @@ async function registerAllRoutes(app) {
296
299
  await app.register(emailRoutes)
297
300
  await app.register(daemonRoutes, { heartbeatStatus: () => ({ running: !!heartbeatTimer, last_beat_at: lastBeatAt }) })
298
301
  await app.register(llmRoutes)
302
+ await app.register(webRoutes)
299
303
 
300
304
  // Linux-specific routes
301
305
  await app.register(commandRoutes)
package/mac/server.js CHANGED
@@ -72,6 +72,7 @@ const { todoRoutes } = require('../core/routes/todos')
72
72
  const { emailRoutes } = require('../core/routes/emails')
73
73
  const { daemonRoutes } = require('../core/routes/daemons')
74
74
  const { llmRoutes } = require('../core/routes/llm')
75
+ const { webRoutes } = require('../core/routes/web')
75
76
 
76
77
  // macOS-specific routes
77
78
  const { commandRoutes, getProcessManager, getAllowedCommands } = require('./routes/commands')
@@ -284,6 +285,7 @@ async function registerAllRoutes(app) {
284
285
  await app.register(emailRoutes)
285
286
  await app.register(daemonRoutes, { heartbeatStatus: () => ({ running: !!heartbeatTimer, last_beat_at: lastBeatAt }) })
286
287
  await app.register(llmRoutes)
288
+ await app.register(webRoutes)
287
289
 
288
290
  // macOS-specific routes
289
291
  await app.register(commandRoutes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.51.2",
3
+ "version": "3.53.1",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
@@ -33,13 +33,17 @@
33
33
  "db:migration:new": "node scripts/new-migration.js"
34
34
  },
35
35
  "dependencies": {
36
+ "@mozilla/readability": "^0.5.0",
36
37
  "croner": "^9.0.0",
37
38
  "fastify": "^5.2.2",
39
+ "linkedom": "^0.18.0",
40
+ "turndown": "^7.2.0",
38
41
  "ws": "^8.0.0"
39
42
  },
40
43
  "optionalDependencies": {
41
44
  "better-sqlite3": "^11.0.0",
42
- "node-pty": "^1.0.0"
45
+ "node-pty": "^1.0.0",
46
+ "playwright": "^1.48.0"
43
47
  },
44
48
  "engines": {
45
49
  "node": ">=22.0.0"
package/rules/core.md CHANGED
@@ -103,7 +103,35 @@ minion-cli --version # バージョン確認
103
103
 
104
104
  `http://localhost:8080` — 認証: `Authorization: Bearer $API_TOKEN`
105
105
 
106
- 主なカテゴリ: Health, Skills, Workflows, Executions, Terminal, Files, Commands, Permissions
106
+ 主なカテゴリ: Health, Skills, Workflows, Executions, Terminal, Files, Commands, Permissions, Admin (HQ-pushed freeze), Web Extraction (experimental)
107
+
108
+ #### Web Page Extraction 🧪 (experimental, v3.53.0〜)
109
+
110
+ Webページの読み取り・要約・情報抽出には、**Playwright MCP より先に** ローカルAPI `POST /api/web/extract` を試すこと。
111
+
112
+ このAPIは内部でヘッドレスブラウザ→本文クリーン (Readability) →セレクタ抽出 (LLMサブプロセスで自動学習) →JSON 返却までを完結させる。**メインセッションに巨大な DOM が流れ込まないため、トークン肥大化やセッション終了を防げる。**
113
+
114
+ ```bash
115
+ curl -X POST http://localhost:8080/api/web/extract \
116
+ -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
117
+ -d '{"url": "https://example.com/article/123", "hint": "本文と著者を抽出"}'
118
+ ```
119
+
120
+ レシピは初回アクセス時に LLM (Haiku) で生成・SQLite (`page_recipes` テーブル) に保存され、2回目以降の構造的に同じページでは LLM 呼び出しなしで抽出される。
121
+
122
+ Playwright MCP (`mcp__playwright__*`) は **フォーム入力・クリック・複数画面遷移など対話的な操作**が必要な場合のみ使用すること。単に「ページを読む」目的では MCP を使わない。
123
+
124
+ **実験的機能**: レスポンス形状は予告なく変わる可能性がある。要件: (1) primary LLM 設定済み (`PUT /api/llm/config` で `claude` 等を選択、`hq llm primary <name>` でも可) または `ANTHROPIC_API_KEY` シークレット設定済み、(2) ホスト上で `npx playwright install chromium` 実行済み。primary LLM が設定されていれば API キー不要 (Claude Code CLI の認証情報を再利用)。
125
+
126
+ 詳細仕様は `~/.minion/docs/api-reference.md` の「Web Page Extraction」セクション、ユースケースは `~/.minion/docs/task-guides.md` の「Webページの読み取り・要約」を参照。
127
+
128
+ #### Billing-Driven Freeze (v3.52.0〜)
129
+
130
+ ユーザーの決済が `past_due` に陥ると、HQ から `POST /api/admin/freeze` がプッシュされ、ミニオン全ポーラーが停止する。
131
+ - 状態は in-memory のみ (frozen.json などのディスクファイルは存在しない)。
132
+ - HQ への発信中に `402 billing_frozen` を受けた場合も自動的にfrozen化される (HQ pushが届かなくても自己防衛)。
133
+ - 復旧は HQ が `restart-agent` コマンドをプッシュ → プロセス再起動 → in-memory フラグが消えて自動再開。
134
+ - API仕様は `~/.minion/docs/api-reference.md` の「Admin」セクション参照。
107
135
 
108
136
  #### Permission Management
109
137
 
@@ -423,6 +423,29 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
423
423
  )
424
424
  }
425
425
 
426
+ // Web page extraction guidance (experimental, v3.53.0)
427
+ if (!sessionId) {
428
+ const port = require('../../core/config').config.AGENT_PORT
429
+ parts.push(
430
+ '[Webページ読み取りについて 🧪 experimental]',
431
+ 'Webページの読み取り・要約・情報抽出が必要なときは、まず以下のローカルAPIを試すこと:',
432
+ '',
433
+ '```bash',
434
+ `curl -X POST http://localhost:${port}/api/web/extract \\`,
435
+ ' -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \\',
436
+ ' -d \'{"url": "対象URL", "hint": "抽出したい内容を短く"}\'',
437
+ '```',
438
+ '',
439
+ 'このAPIは内部で Playwright + Readability を回し、抽出済みJSONだけを返すため、',
440
+ 'DOM全体がチャットに流れ込んでトークン肥大化することを防げる。',
441
+ '初回アクセスで学習したセレクタはSQLiteにキャッシュされ、2回目以降はLLM呼び出しなしで抽出される。',
442
+ '',
443
+ 'Playwright MCP (`mcp__playwright__*`) は **ログイン・フォーム入力・複数画面の対話操作**が必要な場合のみ使用する。',
444
+ '単純な閲覧・要約・一覧取得用途ではMCPを使わない。',
445
+ ''
446
+ )
447
+ }
448
+
426
449
  // File output guidance — always inject on new sessions
427
450
  if (!sessionId) {
428
451
  parts.push(
@@ -790,6 +813,12 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
790
813
 
791
814
  child.on('close', async (code) => {
792
815
  activeChatChild = null
816
+
817
+ console.log(`[Chat] child closed: code=${code}, response=${fullResponse.length}chars, turns=${turnCount}, stderr=${stderrBuffer.length}bytes, session=${resolvedSessionId}`)
818
+ if (stderrBuffer.trim()) {
819
+ console.log(`[Chat] final stderr (tail 500): ${stderrBuffer.slice(-500)}`)
820
+ }
821
+
793
822
  if (resolvedSessionId) {
794
823
  if (!sessionId) {
795
824
  await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
@@ -798,9 +827,15 @@ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, original
798
827
  await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
799
828
  }
800
829
  }
801
- if (code !== 0 && !fullResponse) {
830
+ if (code !== 0) {
802
831
  const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
803
- res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
832
+ console.error(`[Chat] CLI failed (exit ${code}, partial=${!!fullResponse}): ${errorMsg}`)
833
+ res.write(`data: ${JSON.stringify({
834
+ type: 'error',
835
+ error: errorMsg,
836
+ partial: !!fullResponse,
837
+ exit_code: code,
838
+ })}\n\n`)
804
839
  }
805
840
 
806
841
  const session = await chatStore.load(workspaceId)
package/win/server.js CHANGED
@@ -57,6 +57,7 @@ const { skillRoutes } = require('../core/routes/skills')
57
57
  const { workflowRoutes } = require('../core/routes/workflows')
58
58
  const { routineRoutes } = require('../core/routes/routines')
59
59
  const { authRoutes } = require('../core/routes/auth')
60
+ const { adminRoutes } = require('../core/routes/admin')
60
61
  const { variableRoutes } = require('../core/routes/variables')
61
62
  const { memoryRoutes } = require('../core/routes/memory')
62
63
  const { dailyLogRoutes } = require('../core/routes/daily-logs')
@@ -66,6 +67,7 @@ const { todoRoutes } = require('../core/routes/todos')
66
67
  const { emailRoutes } = require('../core/routes/emails')
67
68
  const { daemonRoutes } = require('../core/routes/daemons')
68
69
  const { llmRoutes } = require('../core/routes/llm')
70
+ const { webRoutes } = require('../core/routes/web')
69
71
 
70
72
  // Validate configuration
71
73
  validate()
@@ -221,6 +223,7 @@ async function registerRoutes(app) {
221
223
  await app.register(workflowRoutes, { workflowRunner })
222
224
  await app.register(routineRoutes, { routineRunner })
223
225
  await app.register(authRoutes)
226
+ await app.register(adminRoutes)
224
227
  await app.register(variableRoutes)
225
228
  await app.register(memoryRoutes)
226
229
  await app.register(dailyLogRoutes)
@@ -230,6 +233,7 @@ async function registerRoutes(app) {
230
233
  await app.register(emailRoutes)
231
234
  await app.register(daemonRoutes, { heartbeatStatus: () => ({ running: !!heartbeatTimer, last_beat_at: lastBeatAt }) })
232
235
  await app.register(llmRoutes)
236
+ await app.register(webRoutes)
233
237
 
234
238
  // Shutdown endpoint — allows detached restart/update scripts to trigger
235
239
  // graceful shutdown (offline heartbeat) before force-killing the process.