sync: update from private repo (d31b280)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-11 11:28:40 +00:00
parent d061ad08d8
commit 3b1645cc91
96 changed files with 4627 additions and 287 deletions

View File

@ -64,7 +64,11 @@ llm:
# max_concurrency: 2 # max_concurrency: 2
# enabled: true # enabled: true
# 例: タイトル生成専用ワーカー (chat ジョブは受け付けない) # 例: タイトル生成用ワーカー (chat ジョブは受け付けない)。
# 注意: タスク作成時にタイトル用 LLM はもう呼ばれない (実行中にエージェントが
# Mission Brief の goal からタイトルを派生する)。このワーカーは
# 「AIでタイトルを再生成」ボタン (POST /api/local/tasks/:id/regenerate-title)
# のオンデマンド生成にのみ使われる。未設定なら先頭ワーカーが代替する。
# - id: title-worker # - id: title-worker
# connection_type: direct # connection_type: direct
# endpoint: http://localhost:11434/v1 # endpoint: http://localhost:11434/v1
@ -265,6 +269,7 @@ tools:
# office_pdf_max_size_mb: 10 # ReadPdf 上限 # office_pdf_max_size_mb: 10 # ReadPdf 上限
# office_pptx_max_size_mb: 50 # ReadPPTX 上限 # office_pptx_max_size_mb: 50 # ReadPPTX 上限
# office_pptx_max_uncompressed_mb: 200 # PPTX ZIP 展開後上限 (zip-bomb 検知) # office_pptx_max_uncompressed_mb: 200 # PPTX ZIP 展開後上限 (zip-bomb 検知)
# office_msg_max_size_mb: 25 # ReadMsg 上限 (default 25)
# speech_server_url: http://localhost:8000/v1 # speech_server_url: http://localhost:8000/v1
# speech_timeout: 300 # speech_timeout: 300
# speech_language: ja # speech_language: ja
@ -307,6 +312,29 @@ tools:
# total_max_kb: 32 # total_max_kb: 32
# over_budget_strategy: skip_remaining # truncate_last / skip_remaining (default) / degrade_to_search # over_budget_strategy: skip_remaining # truncate_last / skip_remaining (default) / degrade_to_search
# ─── サーバー TLS (オプション) ───────────────────────────────
# 【フレッシュインストール】setup.sh が server.tls.enabled: true を自動書き込む。
# 【アップグレード】server ブロック未記載の場合は false のまま(既存デプロイを壊さない)。
# 【リバースプロキシ構成】このブロックを省略するか enabled: false のままにすること。
# プロキシが TLS を終端しているので、ここで有効にすると二重終端になり接続が壊れる。
#
# server:
# tls:
# enabled: true # フレッシュインストールのデフォルト; ブロック未記載=アップグレード時 false
# cert_file: null # PEM 証明書パス (任意); cert_file と key_file は両方設定するか両方省略
# key_file: null
# min_version: TLSv1.2
# self_signed_dir: ./data/tls
# self_signed_hosts: [] # localhost / 127.0.0.1 / ::1 / hostname は常に含まれる
# http_redirect: true
# http_redirect_port: 9080 # HTTPS ポートと異なる値にすること
# redirect_host: null # リダイレクト先のホスト; null = バインドホストを使用
# # リバースプロキシ構成: このブロックを省略するか enabled: false のままにすること。
# # プロキシが TLS を終端している場合に native TLS を有効にすると二重終端になり壊れる。
# # noVNC / SSH コンソールを wss で使う場合は「信頼済み証明書」が必要
# # (自己署名の wss はブラウザに click-through がない) —
# # cert_file / key_file で実証明書を指定するか、OS の信頼ストアに自己署名 CA を登録すること。
# ─── 認証 (オプション) ──────────────────────────────────────── # ─── 認証 (オプション) ────────────────────────────────────────
# 未設定なら認証なしで動作 (従来互換)。 # 未設定なら認証なしで動作 (従来互換)。
# auth: # auth:

View File

@ -1,4 +1,4 @@
# Office ファイル系ツールReadPdf / ReadExcel / ReadDocx / ReadPPTX / PdfToImages / SplitExcelSheets / SplitDocxSections # Office ファイル系ツールReadPdf / ReadExcel / ReadDocx / ReadPPTX / ReadMsg / PdfToImages / SplitExcelSheets / SplitDocxSections
ローカル workspace の Office 文書・PDF を読み込むツール群。 ローカル workspace の Office 文書・PDF を読み込むツール群。
@ -126,6 +126,38 @@ ReadPPTX({ file_path: "input/slides.pptx" })
// → 各スライドのテキスト・表・スピーカーノートを返す // → 各スライドのテキスト・表・スピーカーノートを返す
``` ```
### ReadMsg
Outlook の `.msg`OLE2 / CFBF 複合バイナリ)メールを読む。
```js
ReadMsg({ file_path: "input/inquiry.msg" })
// → 件名・差出人・宛先・CC・日時・本文テキストと添付一覧を返す
```
返却テキストの構成:
```
Subject: 見積もりのご依頼
From: 山田太郎 <taro@example.com>
To: sales@example.com
Date: ...
(本文テキスト)
Attachments (2):
- 見積書.pdf (20480 bytes) -> input/見積書.pdf
- data.xlsx (8192 bytes) -> input/data.xlsx
```
ポイント:
- 本文は plain text を優先。HTML メールはタグを除去して整形。どちらも取れない場合は `(no text body)`
- **添付は `input/` に自動保存される**。保存後は種別ごとのツールで開くPDF → ReadPdf、Excel → ReadExcel、画像 → ReadImage など)
- ファイル名は basename に正規化し、パス区切りや制御文字を除去(ディレクトリトラバーサル防止)。同名衝突時は連番を付与
- 添付に埋め込まれた `.msg`(メール in メール)は保存せず一覧に注記のみ。必要なら個別に扱う
- 不正な `.msg`CFBF シグネチャ不一致)は中身を読まずエラーを返す。バイナリがそのまま出力に混ざることはない
## 変換・分割系 ## 変換・分割系
### PdfToImages ### PdfToImages

64
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@kenjiuno/msgreader": "^1.28.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@novnc/novnc": "^1.6.0", "@novnc/novnc": "^1.6.0",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
@ -33,6 +34,7 @@
"pptxgenjs": "^4.0.1", "pptxgenjs": "^4.0.1",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"selfsigned": "^2.4.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"ssh2": "^1.17.0", "ssh2": "^1.17.0",
"undici": "^7.25.0", "undici": "^7.25.0",
@ -675,6 +677,37 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@kenjiuno/decompressrtf": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@kenjiuno/decompressrtf/-/decompressrtf-0.1.4.tgz",
"integrity": "sha512-v9c/iFz17jRWyd2cRnrvJg4VOg/4I/VCk+bG8JnoX2gJ9sAesPzo3uTqcmlVXdpasTI8hChpBVw00pghKe3qTQ==",
"license": "BSD-2-Clause"
},
"node_modules/@kenjiuno/msgreader": {
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/@kenjiuno/msgreader/-/msgreader-1.28.0.tgz",
"integrity": "sha512-+iv2rWCGRHmX/3sBwXZzkThEuuywGJjnYsvxj6Kp1L/FDMICQcFrtqN+6MFrnh2d+umtfGtX904wxaYEDZ52MQ==",
"license": "Apache-2.0",
"dependencies": {
"@kenjiuno/decompressrtf": "^0.1.3",
"iconv-lite": "^0.6.3"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/@kenjiuno/msgreader/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@ -1709,6 +1742,15 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/oauth": { "node_modules/@types/oauth": {
"version": "0.9.6", "version": "0.9.6",
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz",
@ -4518,6 +4560,15 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -5314,6 +5365,19 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/selfsigned": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz",
"integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==",
"license": "MIT",
"dependencies": {
"@types/node-forge": "^1.3.0",
"node-forge": "^1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",

View File

@ -27,6 +27,7 @@
"vapid-rotate": "tsx scripts/vapid-rotate.ts" "vapid-rotate": "tsx scripts/vapid-rotate.ts"
}, },
"dependencies": { "dependencies": {
"@kenjiuno/msgreader": "^1.28.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@novnc/novnc": "^1.6.0", "@novnc/novnc": "^1.6.0",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
@ -51,6 +52,7 @@
"pptxgenjs": "^4.0.1", "pptxgenjs": "^4.0.1",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"selfsigned": "^2.4.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"ssh2": "^1.17.0", "ssh2": "^1.17.0",
"undici": "^7.25.0", "undici": "^7.25.0",

View File

@ -66,7 +66,7 @@ movements:
- `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない - `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない
- **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})` - **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})`
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, UpdateDashboardWidget, 'mcp__*'] allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, UpdateDashboardWidget, 'mcp__*']
# default_next is the engine-internal fallback for context overflow / ASK # default_next is the engine-internal fallback for context overflow / ASK
# limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM. # limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM.
default_next: COMPLETE default_next: COMPLETE

View File

@ -62,7 +62,7 @@ movements:
- **次の report へ**: `transition({next_step: "report"})` - **次の report へ**: `transition({next_step: "report"})`
- **処理対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **処理対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **データが壊れている / 読み取れない / エラー発生で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - **データが壊れている / 読み取れない / エラー発生で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, SQLite, WebSearch, WebFetch, DownloadFile, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, SplitExcelSheets, PdfToImages, ReadImage, AnnotateImage, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, ReadToolDoc, 'mcp__*'] allowed_tools: [Read, Write, Bash, Glob, Grep, SQLite, WebSearch, WebFetch, DownloadFile, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, ReadMsg, SplitExcelSheets, PdfToImages, ReadImage, AnnotateImage, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, ReadToolDoc, 'mcp__*']
default_next: report default_next: report
rules: rules:
- condition: output/ に結果を書き出した - condition: output/ に結果を書き出した

View File

@ -121,7 +121,7 @@ movements:
- **並列分解が効率的 → decompose へ**: `transition({next_step: "decompose"})` - **並列分解が効率的 → decompose へ**: `transition({next_step: "decompose"})`
- **必須情報が不足し確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **必須情報が不足し確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, IngestDocument, IngestStatus, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadMsg, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, IngestDocument, IngestStatus, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
default_next: verify default_next: verify
rules: rules:
- condition: 2つ以上の独立したテーマがあり、並列分解が効率的と判断した - condition: 2つ以上の独立したテーマがあり、並列分解が効率的と判断した
@ -177,7 +177,7 @@ movements:
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})` - 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})` (上記形式で) - 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})` - 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, SearchNotes, ReadNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache] allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, SearchNotes, ReadNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache]
default_next: COMPLETE default_next: COMPLETE
rules: rules:
- condition: 成果物がない、または内容に不足・誤りがある(追加質問への回答に検索根拠が不足している場合も含む) - condition: 成果物がない、または内容に不足・誤りがある(追加質問への回答に検索根拠が不足している場合も含む)

View File

@ -38,6 +38,10 @@ movements:
- テキストが抽出できた場合 → そのまま加工に進む - テキストが抽出できた場合 → そのまま加工に進む
- 全ページが空テキスト(スキャン PDFの場合 → PdfToImages でページ画像化し、ReadImage で内容を確認するReadImage は VLM 対応 worker でのみ利用可能) - 全ページが空テキスト(スキャン PDFの場合 → PdfToImages でページ画像化し、ReadImage で内容を確認するReadImage は VLM 対応 worker でのみ利用可能)
**Outlook メール (.msg)**:
- ReadMsg で件名・差出人・宛先・本文を取得。添付は input/ に保存される
- 保存された添付は ReadPdf / ReadExcel / ReadImage など種別ごとのツールで開く
## Office ファイルの加工方針 ## Office ファイルの加工方針
Excel (.xlsx) の編集: Excel (.xlsx) の編集:
@ -63,7 +67,7 @@ movements:
- **追加情報が必要で同じ process を続行**: `transition({next_step: "process", summary: "..."})` - **追加情報が必要で同じ process を続行**: `transition({next_step: "process", summary: "..."})`
- **対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **読み取り不能・対応外フォーマット等の技術的失敗**: `complete({status: "aborted", abort_reason: "..."})` - **読み取り不能・対応外フォーマット等の技術的失敗**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, SplitExcelSheets, SplitDocxSections, PdfToImages, ReadImage, WebSearch, WebFetch, DownloadFile, SQLite, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, ReadToolDoc, 'mcp__*'] allowed_tools: [Read, Write, Bash, Glob, Grep, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, ReadMsg, SplitExcelSheets, SplitDocxSections, PdfToImages, ReadImage, WebSearch, WebFetch, DownloadFile, SQLite, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, ReadToolDoc, 'mcp__*']
default_next: verify default_next: verify
rules: rules:
- condition: output/ に成果物を書き出した(または既存ファイルを編集した) - condition: output/ に成果物を書き出した(または既存ファイルを編集した)
@ -112,7 +116,7 @@ movements:
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})` - 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で) - 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})` - 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, ReadPdf, ReadImage, ReadExcel, ReadDocx, ReadPPTX, ReadToolDoc] allowed_tools: [Read, Glob, Grep, ReadPdf, ReadImage, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, ReadToolDoc]
default_next: COMPLETE default_next: COMPLETE
rules: rules:
- condition: 成果物がない、または内容に不足・誤りがある - condition: 成果物がない、または内容に不足・誤りがある

View File

@ -5,6 +5,53 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")" PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
PID_FILE="$PROJECT_DIR/.server.pid" PID_FILE="$PROJECT_DIR/.server.pid"
LOG_FILE="$PROJECT_DIR/logs/server.log" LOG_FILE="$PROJECT_DIR/logs/server.log"
# Optional project-root .env (gitignored; setup.sh writes credentials here,
# operators can add e.g. HOST=0.0.0.0 for reverse-proxy deployments so a bare
# `server.sh restart` keeps the bind address). Lines are `KEY=value` or
# `export KEY='value'`. Precedence: explicit environment > .env > defaults,
# so `HOST=127.0.0.1 scripts/server.sh restart` still overrides the file.
if [[ -f "$PROJECT_DIR/.env" ]]; then
# Values are parsed LITERALLY (no eval/source): a value is either bare, or
# single-quoted in setup.sh's format ('\'' encodes a literal quote), or
# double-quoted (quotes stripped, contents kept literal — no $ expansion).
# One KEY=value per line; full-line comments only.
#
# Keys this loader itself set from .env are tracked space-delimited (keys are
# validated identifiers so this is unambiguous — no Bash-4 associative
# arrays, macOS ships bash 3.2). Only EXTERNAL pre-set environment is
# protected; duplicate keys within .env keep normal source semantics (last
# line wins) because earlier lines are recorded here and may be overridden.
_envfile_set=" "
_q=\'
_esc=$'\x01'
while IFS= read -r _line || [[ -n "$_line" ]]; do
_line="${_line#"${_line%%[![:space:]]*}"}" # ltrim
[[ -z "$_line" || "$_line" == \#* ]] && continue
_kv="${_line#export }"
_key="${_kv%%=*}"
[[ "$_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
[[ "$_kv" == *=* ]] || continue
_val="${_kv#*=}"
case "$_val" in
"$_q"*)
_val="${_val//${_q}\\${_q}${_q}/${_esc}}" # '\'' -> literal-quote marker
_val="${_val//${_q}/}" # drop quoting quotes
_val="${_val//${_esc}/${_q}}" # restore literal quotes
;;
\"*\")
_val="${_val#\"}"
_val="${_val%\"}"
;;
esac
if [[ "$_envfile_set" == *" $_key "* || -z "${!_key+x}" ]]; then
export "$_key=$_val"
_envfile_set="${_envfile_set}${_key} "
fi
done < "$PROJECT_DIR/.env"
unset _line _kv _key _val _envfile_set _q _esc
fi
PORT="${PORT:-9876}" PORT="${PORT:-9876}"
cd "$PROJECT_DIR" cd "$PROJECT_DIR"

View File

@ -130,6 +130,15 @@ path.write_text(text.replace(needle, replacement))
PY PY
fi fi
python3 - <<'PY'
from pathlib import Path
p = Path("config.yaml")
text = p.read_text()
if "\nserver:" not in text:
text += "\nserver:\n tls:\n enabled: true\n"
p.write_text(text)
PY
echo " config.yaml を生成/更新しました" echo " config.yaml を生成/更新しました"
echo "" echo ""
@ -191,7 +200,8 @@ chmod 600 "${ENV_FILE}"
echo "" echo ""
echo " 認証情報を ${ENV_FILE} (権限 0600) に書き出しました。" echo " 認証情報を ${ENV_FILE} (権限 0600) に書き出しました。"
echo " 起動前に以下で読み込んでください:" echo " scripts/server.sh は起動時に ${ENV_FILE} を自動で読み込みます。"
echo " 手動起動 (npm start 等) の場合のみ、事前に読み込んでください:"
echo "" echo ""
echo " source ${ENV_FILE}" echo " source ${ENV_FILE}"
echo "" echo ""

View File

@ -1,4 +1,5 @@
import type { IncomingMessage, Server as HttpServer } from 'node:http'; import type { IncomingMessage, Server as HttpServer } from 'node:http';
import type { Server as HttpsServer } from 'node:https';
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
import { WebSocketServer, type WebSocket } from 'ws'; import { WebSocketServer, type WebSocket } from 'ws';
import { Router, json, type Request, type Response } from 'express'; import { Router, json, type Request, type Response } from 'express';
@ -81,7 +82,9 @@ const PATH_RE = /^\/+api\/local\/tasks\/([^/]+)\/console\/ws$/;
* (the client gets a 1006 abnormal close) so we don't leak failure * (the client gets a 1006 abnormal close) so we don't leak failure
* reasons over the upgrade channel. The reason is always logged. * reasons over the upgrade channel. The reason is always logged.
*/ */
export function attachConsoleWs(server: HttpServer, deps: ConsoleWsDeps): void { // Both http.Server and https.Server emit the 'upgrade' event used for WSS,
// so either type is a valid host for the console WebSocket upgrade handler.
export function attachConsoleWs(server: HttpServer | HttpsServer, deps: ConsoleWsDeps): void {
const wss = new WebSocketServer({ noServer: true }); const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', async (req, socket, head) => { server.on('upgrade', async (req, socket, head) => {

View File

@ -37,7 +37,11 @@ function makeUser(overrides: Partial<Express.User> = {}): Express.User {
}; };
} }
function makeApp(repo: Repository, user?: Express.User): express.Application { function makeApp(
repo: Repository,
user?: Express.User,
opts: { authActive?: boolean } = {},
): express.Application {
const app = express(); const app = express();
if (user) { if (user) {
app.use((req, _res, next) => { app.use((req, _res, next) => {
@ -45,7 +49,7 @@ function makeApp(repo: Repository, user?: Express.User): express.Application {
next(); next();
}); });
} }
mountLocalFilesApi(app, repo); mountLocalFilesApi(app, repo, { authActive: opts.authActive ?? true });
return app; return app;
} }
@ -55,6 +59,7 @@ beforeEach(() => {
mkdirSync(join(ws, 'output', 'sub'), { recursive: true }); mkdirSync(join(ws, 'output', 'sub'), { recursive: true });
writeFileSync(join(ws, 'input', 'data.csv'), 'a,b\n1,2\n'); writeFileSync(join(ws, 'input', 'data.csv'), 'a,b\n1,2\n');
writeFileSync(join(ws, 'output', 'report.md'), '# report'); writeFileSync(join(ws, 'output', 'report.md'), '# report');
writeFileSync(join(ws, 'output', 'report.html'), '<!doctype html><script>window.__ran = true</script><h1>report</h1>');
writeFileSync(join(ws, 'output', 'sub', 'nested.txt'), 'nested'); writeFileSync(join(ws, 'output', 'sub', 'nested.txt'), 'nested');
// A file just outside the workspace that traversal must never reach. // A file just outside the workspace that traversal must never reach.
writeFileSync(join(ws, '..', `outside-${process.pid}.txt`), 'secret'); writeFileSync(join(ws, '..', `outside-${process.pid}.txt`), 'secret');
@ -153,6 +158,80 @@ describe('GET /api/local/tasks/:taskId/files/raw', () => {
expect(res.headers['content-type']).toContain('markdown'); expect(res.headers['content-type']).toContain('markdown');
}); });
it('sandboxes raw HTML by default', async () => {
const res = await request(makeApp(makeRepo(), makeUser()))
.get('/api/local/tasks/1/files/raw?section=output&path=report.html');
expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBe('sandbox');
});
it('allows the task OWNER to open trusted raw HTML without the sandbox header', async () => {
const res = await request(makeApp(makeRepo(), makeUser()))
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBeUndefined();
expect(res.headers['content-type']).toContain('html');
});
it("keeps trusted raw HTML sandboxed even for an admin on another user's task (user→admin lure)", async () => {
const res = await request(makeApp(makeRepo(), makeUser({ id: 'admin-9', role: 'admin' })))
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBe('sandbox');
});
it('keeps trusted raw HTML sandboxed for a NON-owner viewer of a shared task', async () => {
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({
id: 1,
ownerId: 'user-1',
visibility: 'public',
workspacePath: ws,
}),
} as Partial<Repository>);
const res = await request(makeApp(repo, makeUser({ id: 'user-2' })))
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBe('sandbox');
});
it('keeps trusted raw HTML sandboxed for an ownerless task even when authenticated', async () => {
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({
id: 1,
ownerId: null,
visibility: 'public',
workspacePath: ws,
}),
} as Partial<Repository>);
const res = await request(makeApp(repo, makeUser()))
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBe('sandbox');
});
it('keeps trusted raw HTML sandboxed when auth is on but no user is present', async () => {
const res = await request(makeApp(makeRepo()))
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBe('sandbox');
});
it('allows trusted raw HTML in no-auth mode (sole operator owns every task)', async () => {
const res = await request(makeApp(makeRepo(), undefined, { authActive: false }))
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBeUndefined();
expect(res.headers['content-type']).toContain('html');
});
it('still sandboxes non-HTML in no-auth mode even with trusted=1', async () => {
const res = await request(makeApp(makeRepo(), undefined, { authActive: false }))
.get('/api/local/tasks/1/files/raw?section=output&path=report.md&trusted=1');
expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBe('sandbox');
});
it('rejects traversal reads with 400 and never serves outside files', async () => { it('rejects traversal reads with 400 and never serves outside files', async () => {
const res = await request(makeApp(makeRepo(), makeUser())) const res = await request(makeApp(makeRepo(), makeUser()))
.get(`/api/local/tasks/1/files/raw?section=input&path=..%2F..%2Foutside-${process.pid}.txt`); .get(`/api/local/tasks/1/files/raw?section=input&path=..%2F..%2Foutside-${process.pid}.txt`);

View File

@ -6,7 +6,20 @@ import { logger } from '../logger.js';
import { parseTaskId } from './validation.js'; import { parseTaskId } from './validation.js';
import { ensurePathWithin, isPathEscapeError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, setUntrustedFileResponseHeaders } from './local-api-helpers.js'; import { ensurePathWithin, isPathEscapeError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, setUntrustedFileResponseHeaders } from './local-api-helpers.js';
export function mountLocalFilesApi(app: Application, repo: Repository): void { export interface LocalFilesApiOptions {
/** Whether the auth subsystem is wired. When false (no-auth single-user
* deployment) there is no req.user, so the sole local operator owns every
* task and is allowed to open their own generated HTML with trusted=1.
* Defaults to true (owner identity comes from req.user). */
authActive?: boolean;
}
export function mountLocalFilesApi(
app: Application,
repo: Repository,
opts: LocalFilesApiOptions = {},
): void {
const authActive = opts.authActive ?? true;
app.get('/api/local/tasks/:taskId/files', async (req: Request, res: Response) => { app.get('/api/local/tasks/:taskId/files', async (req: Request, res: Response) => {
try { try {
@ -125,7 +138,23 @@ export function mountLocalFilesApi(app: Application, repo: Repository): void {
res.status(400).json({ error: 'path must point to a file' }); res.status(400).json({ error: 'path must point to a file' });
return; return;
} }
// trusted=1 drops the CSP sandbox so the owner's own generated HTML can
// run on the app origin. STRICTLY owner-only — self-XSS at worst:
// - org/public visibility lets other users VIEW the task, but serving
// someone else's HTML unsandboxed here would be stored XSS against
// the viewer;
// - admins are excluded too: another user's HTML running in an ADMIN
// session would be a user→admin privilege-escalation lure.
// No-auth single-user mode has no req.user; the sole operator owns every
// task, so they are the owner for this purpose (self-XSS only — there is
// no second principal to attack).
const trustedAllowed = authActive
? !!viewer && task.ownerId != null && viewer.id === task.ownerId
: true;
const trustedHtml = req.query.trusted === '1' && /\.html?$/i.test(filePath) && trustedAllowed;
if (!trustedHtml) {
setUntrustedFileResponseHeaders(res); setUntrustedFileResponseHeaders(res);
}
res.type(extname(filePath) || 'application/octet-stream'); res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath)); res.send(readFileSync(filePath));
} catch (err) { } catch (err) {

View File

@ -7,6 +7,7 @@ import { tmpdir } from 'os';
import { Repository, localTaskRepoName } from '../db/repository.js'; import { Repository, localTaskRepoName } from '../db/repository.js';
import { BrowserSessionRepo } from '../db/browser-session-repo.js'; import { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { mountLocalTasksApi } from './local-tasks-api.js'; import { mountLocalTasksApi } from './local-tasks-api.js';
import { buildLocalConversationContext } from '../engine/local-context.js';
describe('POST /api/local/tasks with visibility', () => { describe('POST /api/local/tasks with visibility', () => {
let tempDir = ''; let tempDir = '';
@ -888,6 +889,41 @@ describe('POST /api/local/tasks/:id/continue', () => {
expect(handoff?.body).toContain('ssh-ops'); expect(handoff?.body).toContain('ssh-ops');
}); });
it('persists the switch instruction as the latest user request so the agent follows it', async () => {
const { task } = await setupTaskWithTerminalJob();
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: 'use output/manual.md to set up foo' });
expect(res.status).toBe(201);
const comments = await repo.listLocalTaskComments(task.id);
// The switch text must exist as a user 'request' comment...
const userRequests = comments.filter((c) => c.author === 'user' && c.kind === 'request');
const switchComment = userRequests.find((c) => c.body === 'use output/manual.md to set up foo');
expect(switchComment).toBeTruthy();
// ...and be the LATEST user instruction (newer than the original 'b' body and
// the prior agent result), which is what buildLocalConversationContext keys on.
const userInstructionKinds = ['comment', 'request', 'interjection'];
const latestUserInstruction = [...comments]
.reverse()
.find((c) => c.author === 'user' && userInstructionKinds.includes(c.kind));
expect(latestUserInstruction?.body).toBe('use output/manual.md to set up foo');
// End-to-end: feeding the resulting comments + the continued job's
// instruction into the worker's context builder must put the switch text
// under the active "## タスク" heading, NOT the demoted
// "## オリジナルタスク (参考、対応済みの可能性あり)" slot that caused the
// agent to re-follow earlier instructions.
const ctx = buildLocalConversationContext({
comments,
jobInstruction: 'use output/manual.md to set up foo',
inputFiles: [],
outputFiles: [],
});
expect(ctx).toContain('## タスク');
expect(ctx).toContain('use output/manual.md to set up foo');
expect(ctx).not.toContain('## オリジナルタスク');
});
it('returns 409 job_in_progress when prev job is running', async () => { it('returns 409 job_in_progress when prev job is running', async () => {
const { task } = await setupTaskWithTerminalJob({ status: 'running' }); const { task } = await setupTaskWithTerminalJob({ status: 'running' });
const res = await request(app) const res = await request(app)

View File

@ -8,11 +8,12 @@ import { resolveJobScheduling } from '../scheduling.js';
import { parseTaskId, validateCreateTaskBody, validateCommentBody, validateFeedbackBody } from './validation.js'; import { parseTaskId, validateCreateTaskBody, validateCommentBody, validateFeedbackBody } from './validation.js';
import { getLocalWorkspacePath, checkTaskOwnership, canViewTask } from './local-api-helpers.js'; import { getLocalWorkspacePath, checkTaskOwnership, canViewTask } from './local-api-helpers.js';
import { jobEventBus, type JobStreamEvent } from './job-events.js'; import { jobEventBus, type JobStreamEvent } from './job-events.js';
import { buildTitleFallback } from '../title-generation.js';
export interface LocalTasksApiOptions { export interface LocalTasksApiOptions {
repo: Repository; repo: Repository;
worktreeDir?: string; worktreeDir?: string;
generateTitle?: (body: string) => Promise<string>; generateTitle?: (body: string, ownerId?: string) => Promise<string>;
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>; selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
/** /**
* Server-side validator for piece names accepted by the * Server-side validator for piece names accepted by the
@ -126,28 +127,23 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
browserSessionProfileId = n; browserSessionProfileId = n;
} }
let taskTitle = (body.title ?? '').trim(); const userTitle = (body.title ?? '').trim();
const rawPiece = (body.piece ?? 'auto').trim(); const rawPiece = (body.piece ?? 'auto').trim();
const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[]; const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];
// タイトル生成と piece 分類を並列実行 // Title is NOT generated by an LLM at creation time anymore — that fired a
const [generatedTitle, autoSelectedPiece] = await Promise.all([ // second concurrent LLM request per task and churned gateway backend
// タイトル生成 // slots. Instead we set a cheap synchronous fallback now, and the agent
(!taskTitle && opts.generateTitle) // upgrades it during the run by deriving from the Mission Brief goal
? Promise.race([ // (see Repository.updateMissionBriefSync). On-demand AI regeneration is
opts.generateTitle(body.body.trim()), // available via POST /api/local/tasks/:id/regenerate-title.
new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)), const autoSelectedPiece = (rawPiece === 'auto' && opts.selectPiece)
]).catch((e: unknown) => { logger.warn(`Title generation failed: ${e}`); return ''; }) ? await opts.selectPiece(body.body.trim(), attachmentNames, (req.user as Express.User | undefined)?.id)
: Promise.resolve(''), .catch((e: unknown) => { logger.warn(`Piece classification failed: ${e}`); return 'chat'; })
// piece 分類('auto' の場合のみ); userId を渡し per-user カタログを使用 : rawPiece;
(rawPiece === 'auto' && opts.selectPiece)
? opts.selectPiece(body.body.trim(), attachmentNames, (req.user as Express.User | undefined)?.id).catch((e: unknown) => { logger.warn(`Piece classification failed: ${e}`); return 'chat'; })
: Promise.resolve(rawPiece),
]);
if (!taskTitle) { const taskTitle = userTitle || buildTitleFallback(body.body.trim());
taskTitle = generatedTitle || body.body.trim().slice(0, 40).replace(/\n/g, ' '); const titleSource: 'auto' | 'user' = userTitle ? 'user' : 'auto';
}
const piece = autoSelectedPiece; const piece = autoSelectedPiece;
const profile = body.profile ?? 'auto'; const profile = body.profile ?? 'auto';
const outputFormat = body.outputFormat ?? 'markdown'; const outputFormat = body.outputFormat ?? 'markdown';
@ -168,6 +164,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
const task = await repo.createLocalTask({ const task = await repo.createLocalTask({
title: taskTitle, title: taskTitle,
titleSource,
body: body.body.trim(), body: body.body.trim(),
pieceName: piece, pieceName: piece,
profile, profile,
@ -406,7 +403,18 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined }); const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return; if (!checkTaskOwnership(req, res, task)) return;
const updates: { visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {}; const updates: { title?: string; titleSource?: 'user'; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
if (req.body.title !== undefined) {
if (typeof req.body.title !== 'string') {
res.status(400).json({ error: 'title must be a string' }); return;
}
const trimmed = req.body.title.trim();
if (!trimmed) { res.status(400).json({ error: 'title must not be empty' }); return; }
if (trimmed.length > 200) { res.status(400).json({ error: 'title must be 200 characters or less' }); return; }
// Manual edit pins the title: the agent never auto-overwrites a user title.
updates.title = trimmed;
updates.titleSource = 'user';
}
if (req.body.visibility !== undefined) { if (req.body.visibility !== undefined) {
const v = req.body.visibility; const v = req.body.visibility;
if (!['private', 'org', 'public'].includes(v)) { if (!['private', 'org', 'public'].includes(v)) {
@ -443,6 +451,41 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
} }
}); });
// On-demand AI title regeneration. Unlike the old creation-time path this
// only fires when the user explicitly asks (a button), so it never adds a
// concurrent LLM request to the task-creation hot path. Owner/admin only.
app.post('/api/local/tasks/:taskId/regenerate-title', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return;
if (!opts.generateTitle) { res.status(503).json({ error: 'Title generation is not configured' }); return; }
let title = '';
try {
title = await Promise.race([
// Ownerless (no-auth) tasks attribute to 'local', matching the
// worker/piece-runner convention (ownerId ?? 'local').
opts.generateTitle(task!.body, task!.ownerId ?? 'local'),
new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
]);
} catch (e) {
logger.warn(`Title regeneration failed (task=${taskId}): ${e}`);
res.status(502).json({ error: 'Title generation failed' }); return;
}
// Empty model output is not an error: fall back to the cheap synchronous
// title so the button always yields something (matching the old creation
// path's behaviour).
title = (title ?? '').trim() || buildTitleFallback(task!.body);
await repo.updateLocalTask(taskId, { title, titleSource: 'agent' });
res.json({ title });
} catch (err) {
logger.error(`Regenerate title API error: ${err}`);
res.status(500).json({ error: 'Failed to regenerate title' });
}
});
app.delete('/api/local/tasks/:taskId', async (req: Request, res: Response) => { app.delete('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
try { try {
const taskId = parseTaskId(req.params.taskId); const taskId = parseTaskId(req.params.taskId);
@ -556,6 +599,15 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
await repo.updateLocalTask(taskId, { pieceName: piece }); await repo.updateLocalTask(taskId, { pieceName: piece });
// Persist the switch-time instruction as a user request. Without this it
// lives only in job.instruction, and buildLocalConversationContext picks
// the *latest user comment* as the current instruction — so a stale older
// comment would win and the switch text would be demoted to the "original
// task (possibly already handled)" slot, making the agent re-follow prior
// instructions instead of the new one. Mirrors the create path, which
// also persists the body as a 'request' comment.
await repo.addLocalTaskComment(taskId, 'user', instruction.trim(), 'request');
// Surface the handoff in the timeline so the user (and the LLM, when // Surface the handoff in the timeline so the user (and the LLM, when
// it later inspects task comments) can see when piece switches happened. // it later inspects task comments) can see when piece switches happened.
await repo.addLocalTaskComment( await repo.addLocalTaskComment(

View File

@ -6,6 +6,7 @@ import { fileURLToPath } from 'url';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import express from 'express'; import express from 'express';
import type { Server } from 'http'; import type { Server } from 'http';
import type { Server as HttpsServer } from 'https';
import type { SessionManager, BrowserSession } from '../engine/browser-session.js'; import type { SessionManager, BrowserSession } from '../engine/browser-session.js';
import type { UpgradeAuthChecker } from './auth.js'; import type { UpgradeAuthChecker } from './auth.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
@ -78,7 +79,9 @@ export function createNovncRouter(): Router {
* - authenticateUpgrade (dev ) session * - authenticateUpgrade (dev ) session
*/ */
export function setupNovncWebSocketProxy( export function setupNovncWebSocketProxy(
server: Server, // Both http.Server and https.Server emit the 'upgrade' event used for WSS,
// so either type works here as a WebSocket proxy host.
server: Server | HttpsServer,
getSessionManager: () => SessionManager | null, getSessionManager: () => SessionManager | null,
authenticateUpgrade?: UpgradeAuthChecker, authenticateUpgrade?: UpgradeAuthChecker,
authorizeSession?: NovncSessionAuthorizer, authorizeSession?: NovncSessionAuthorizer,

View File

@ -0,0 +1,81 @@
import { describe, it, expect, afterEach } from 'vitest';
import { createServer as createHttpsServer, type Server } from 'https';
import { request as httpsRequest } from 'https';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { resolveTlsOptions } from '../net/tls-options.js';
import { SERVER_TLS_DEFAULTS } from '../server/config.js';
describe('native HTTPS listener (self-signed)', () => {
let server: Server | undefined;
let dir: string | undefined;
afterEach(async () => {
if (server) await new Promise<void>((r) => server!.close(() => r()));
if (dir) rmSync(dir, { recursive: true, force: true });
server = undefined;
dir = undefined;
});
it('completes a TLS>=1.2 handshake and serves the app over https', { timeout: 15000 }, async () => {
dir = mkdtempSync(join(tmpdir(), 'tls-listener-'));
const resolved = resolveTlsOptions({ ...SERVER_TLS_DEFAULTS, enabled: true, selfSignedDir: dir });
server = createHttpsServer(
{ cert: resolved.cert, key: resolved.key, minVersion: resolved.minVersion },
(_req, res) => {
res.writeHead(200);
res.end('ok');
},
);
await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r));
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
const result = await new Promise<{ code: number; body: string; proto: string | null }>(
(resolve, reject) => {
const req = httpsRequest(
{ host: '127.0.0.1', port, path: '/', rejectUnauthorized: false },
(res) => {
// Capture protocol before the socket is torn down (socket may be
// null by the time 'end' fires so we snapshot it on 'response').
const proto = (res.socket as import('tls').TLSSocket | null)?.getProtocol?.() ?? null;
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () =>
resolve({ code: res.statusCode ?? 0, body: d, proto }),
);
},
);
req.on('error', reject);
req.end();
},
);
expect(result.code).toBe(200);
expect(result.body).toBe('ok');
expect(['TLSv1.2', 'TLSv1.3']).toContain(result.proto);
});
it('a strict client rejects the self-signed cert', async () => {
dir = mkdtempSync(join(tmpdir(), 'tls-listener-strict-'));
const resolved = resolveTlsOptions({ ...SERVER_TLS_DEFAULTS, enabled: true, selfSignedDir: dir });
server = createHttpsServer(
{ cert: resolved.cert, key: resolved.key, minVersion: resolved.minVersion },
(_req, res) => {
res.writeHead(200);
res.end('ok');
},
);
await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r));
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
const outcome = await new Promise<string>((resolve) => {
const req = httpsRequest(
{ host: '127.0.0.1', port, path: '/', rejectUnauthorized: true },
() => resolve('UNEXPECTED_OK'),
);
req.on('error', (e) => resolve('rejected:' + (e as NodeJS.ErrnoException).code));
req.end();
});
expect(outcome).toMatch(/^rejected:/);
});
});

View File

@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { computeEffectiveSecureCookie, shouldWarnDoubleTls } from './server.js';
describe('computeEffectiveSecureCookie', () => {
it('is true when secure_cookie is on (proxy mode)', () => {
expect(computeEffectiveSecureCookie(true, false)).toBe(true);
});
it('is true when native TLS is on even if secure_cookie is off', () => {
expect(computeEffectiveSecureCookie(false, true)).toBe(true);
});
it('is false when neither', () => {
expect(computeEffectiveSecureCookie(false, false)).toBe(false);
});
});
describe('shouldWarnDoubleTls', () => {
it('warns when native TLS and secure_cookie (proxy signal) are both on', () => {
expect(shouldWarnDoubleTls(true, true)).toBe(true);
});
it('does not warn for a plain native-TLS install (secure_cookie off)', () => {
expect(shouldWarnDoubleTls(true, false)).toBe(false);
});
it('does not warn when TLS is disabled', () => {
expect(shouldWarnDoubleTls(false, true)).toBe(false);
});
});

View File

@ -16,6 +16,7 @@ import { mountBrandingApi, resolveBranding } from './branding-api.js';
import { createBrowserApi } from './browser-api.js'; import { createBrowserApi } from './browser-api.js';
import { createBrowserSessionApi } from './browser-session-api.js'; import { createBrowserSessionApi } from './browser-session-api.js';
import { createSubtaskActivityRouter } from './subtask-activity-api.js'; import { createSubtaskActivityRouter } from './subtask-activity-api.js';
import { createUsageRouter } from './usage-api.js';
import { SessionManager } from '../engine/browser-session.js'; import { SessionManager } from '../engine/browser-session.js';
import { createNovncRouter, setupNovncWebSocketProxy } from './novnc-proxy.js'; import { createNovncRouter, setupNovncWebSocketProxy } from './novnc-proxy.js';
import { setSessionManager } from '../engine/tools/browser.js'; import { setSessionManager } from '../engine/tools/browser.js';
@ -104,6 +105,11 @@ import { createNotesApi } from './notes-api.js';
import { mountGateway, type GatewayMountHandle } from './gateway-mount.js'; import { mountGateway, type GatewayMountHandle } from './gateway-mount.js';
import { readGatewayConfig } from '../gateway/config.js'; import { readGatewayConfig } from '../gateway/config.js';
import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js'; import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js';
import { createServer as createHttpsServer } from 'https';
import { X509Certificate } from 'crypto';
import { mergeServerConfig } from '../server/config.js';
import { resolveTlsOptions } from '../net/tls-options.js';
import { createHttpRedirectServer } from '../net/http-redirect.js';
const __filenameServer = fileURLToPath(import.meta.url); const __filenameServer = fileURLToPath(import.meta.url);
const __dirnameServer = dirname(__filenameServer); const __dirnameServer = dirname(__filenameServer);
@ -112,7 +118,7 @@ export interface CoreServerOptions {
repo: Repository; repo: Repository;
worktreeDir?: string; worktreeDir?: string;
configuredRepos?: string[]; configuredRepos?: string[];
generateTitle?: (body: string) => Promise<string>; generateTitle?: (body: string, ownerId?: string) => Promise<string>;
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>; selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
configManager?: ConfigManager; configManager?: ConfigManager;
piecesDir?: string; piecesDir?: string;
@ -170,6 +176,14 @@ export function createCoreServer(opts: CoreServerOptions): {
gatewayMount: GatewayMountHandle | null; gatewayMount: GatewayMountHandle | null;
/** True when an OAuth provider or local auth is active. False = no-auth mode. */ /** True when an OAuth provider or local auth is active. False = no-auth mode. */
authActive: boolean; authActive: boolean;
/**
* Resolved server config snapshot computed once with the real listen port
* so both the cookie-secure decision (inside createCoreServer) and the
* listener branch (inside startCoreServer) share the SAME object.
* A config hot-reload between the two would otherwise cause tls.enabled to
* disagree between the cookie flag and the actual listener type.
*/
serverConfig: ReturnType<typeof mergeServerConfig>;
} { } {
const { repo, worktreeDir } = opts; const { repo, worktreeDir } = opts;
const app = express(); const app = express();
@ -276,6 +290,16 @@ export function createCoreServer(opts: CoreServerOptions): {
} }
let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined; let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined;
// Resolve server config ONCE here with the real listen port (threaded in via
// opts.listenPort by startCoreServer). Both the cookie-secure decision below
// and the listener branch in startCoreServer consume this same snapshot so a
// config hot-reload between the two calls cannot produce a mismatch between
// tls.enabled and the cookie secure flag.
const serverCfg = mergeServerConfig(loadConfig().server, {
freshInstall: false,
httpsPort: opts.listenPort ?? Number(process.env['PORT'] ?? 9876),
});
if (authActive) { if (authActive) {
// Idempotently seed the shared `local` system admin (id='local', the same // Idempotently seed the shared `local` system admin (id='local', the same
// owner the no-auth path uses) so an existing single-user / no-auth // owner the no-auth path uses) so an existing single-user / no-auth
@ -286,9 +310,21 @@ export function createCoreServer(opts: CoreServerOptions): {
logger.info(`[auth] seeded local system admin id=local email=${bootstrap.email}`); logger.info(`[auth] seeded local system admin id=local email=${bootstrap.email}`);
} }
// Compose effective secureCookie: native TLS termination also requires
// the secure flag on session cookies, even when no upstream proxy is
// present. IMPORTANT: trust-proxy (line ~191) stays keyed on the
// ORIGINAL opts.authConfig.secureCookie — native TLS must NOT enable it.
const effectiveSecureCookie = computeEffectiveSecureCookie(
!!opts.authConfig?.secureCookie,
serverCfg.tls.enabled,
);
const authConfigForSetup = opts.authConfig
? { ...opts.authConfig, secureCookie: effectiveSecureCookie }
: opts.authConfig;
const auth = setupAuth( const auth = setupAuth(
repo, repo,
opts.authConfig!, authConfigForSetup!,
() => { () => {
const b = resolveBranding(opts.configManager); const b = resolveBranding(opts.configManager);
return { appName: b.appName, loginPageTitle: b.loginPageTitle }; return { appName: b.appName, loginPageTitle: b.loginPageTitle };
@ -330,6 +366,7 @@ export function createCoreServer(opts: CoreServerOptions): {
// per-piece write authz (built-in/global-custom → admin, user-custom → owner) // per-piece write authz (built-in/global-custom → admin, user-custom → owner)
// is enforced inside pieces-api.ts handlers. // is enforced inside pieces-api.ts handlers.
app.use('/api/pieces', requireAuth); app.use('/api/pieces', requireAuth);
app.use('/api/usage', requireAuth);
// Scheduled tasks: any authenticated user can create/list (visibility-filtered). // Scheduled tasks: any authenticated user can create/list (visibility-filtered).
// PATCH/DELETE owner-or-admin enforcement lives in the handlers (Task 14). // PATCH/DELETE owner-or-admin enforcement lives in the handlers (Task 14).
app.use('/api/scheduled-tasks', requireAuth); app.use('/api/scheduled-tasks', requireAuth);
@ -893,10 +930,11 @@ export function createCoreServer(opts: CoreServerOptions): {
}); });
// --- Local files API --- // --- Local files API ---
mountLocalFilesApi(app, repo); mountLocalFilesApi(app, repo, { authActive });
// --- Subtask activity API --- // --- Subtask activity API ---
app.use('/api/local/tasks', createSubtaskActivityRouter(repo)); app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
app.use('/api/usage', createUsageRouter(repo, { authActive }));
// --- Subtask files API (listing MUST come before wildcard) --- // --- Subtask files API (listing MUST come before wildcard) ---
mountSubtaskFilesApi(app, repo); mountSubtaskFilesApi(app, repo);
@ -1176,7 +1214,19 @@ export function createCoreServer(opts: CoreServerOptions): {
return isOwner || user.role === 'admin'; return isOwner || user.role === 'admin';
}; };
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive }; return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive, serverConfig: serverCfg };
}
/** Cookie `secure` must be set whenever the user-facing scheme is https
* via an upstream TLS proxy (secureCookie) OR native TLS termination. */
export function computeEffectiveSecureCookie(secureCookie: boolean, tlsEnabled: boolean): boolean {
return secureCookie || tlsEnabled;
}
/** Heuristic: native TLS + the proxy signal (secure_cookie) likely means a
* reverse proxy is also terminating TLS double-TLS misconfiguration. */
export function shouldWarnDoubleTls(tlsEnabled: boolean, secureCookie: boolean): boolean {
return tlsEnabled && secureCookie;
} }
export function finalizeServer(app: express.Application): express.Application { export function finalizeServer(app: express.Application): express.Application {
@ -1231,6 +1281,7 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
workerMetrics, workerMetrics,
gatewayMount, gatewayMount,
authActive, authActive,
serverConfig,
// Forward the actual port to createCoreServer so the admin gateway // Forward the actual port to createCoreServer so the admin gateway
// status endpoint reports the real bind port (not the PORT env // status endpoint reports the real bind port (not the PORT env
// guess). See `listenPort` doc on CoreServerOptions. // guess). See `listenPort` doc on CoreServerOptions.
@ -1255,7 +1306,53 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
// 127.0.0.1:9876 port mapping instead. // 127.0.0.1:9876 port mapping instead.
const host = process.env['HOST'] ?? '127.0.0.1'; const host = process.env['HOST'] ?? '127.0.0.1';
const isLoopbackBind = host === '127.0.0.1' || host === '::1' || host === 'localhost'; const isLoopbackBind = host === '127.0.0.1' || host === '::1' || host === 'localhost';
const server = finalApp.listen(port, host, () => { // Use the config snapshot already resolved in createCoreServer (same port,
// same loadConfig() call) — no second read so a hot-reload between the two
// cannot make the cookie-secure flag disagree with the listener type.
const tls = serverConfig.tls;
let server: import('http').Server | import('https').Server;
if (tls.enabled) {
// Augment the self-signed SAN list with the redirect target host and the
// (non-wildcard) bind host so that browsers following the HTTP→HTTPS redirect
// always land on a hostname that is covered by the certificate. Provided-cert
// deployments are unaffected because resolveTlsOptions ignores selfSignedHosts
// when cert_file/key_file are set.
const extraSan = [
tls.redirectHost,
host && host !== '0.0.0.0' && host !== '::' ? host : null,
].filter((h): h is string => !!h);
const tlsForResolve = extraSan.length
? { ...tls, selfSignedHosts: [...tls.selfSignedHosts, ...extraSan] }
: tls;
const resolved = resolveTlsOptions(tlsForResolve); // fatal throw on bad operator cert
server = createHttpsServer({ cert: resolved.cert, key: resolved.key, minVersion: resolved.minVersion }, finalApp);
server.listen(port, host, () => {
const source = tls.certFile ? `provided(${tls.certFile})` : 'self-signed';
const fp = new X509Certificate(resolved.cert).fingerprint256;
logger.info(`Core server listening on https://${host}:${port} cert=${source} sha256=${fp}`);
if (shouldWarnDoubleTls(true, !!opts.authConfig?.secureCookie)) {
logger.warn(
`[security] server.tls.enabled is ON while auth.secure_cookie is also ON (reverse-proxy signal). ` +
`If a TLS-terminating proxy is in front of this app, set server.tls.enabled: false to avoid double TLS.`,
);
}
});
if (tls.httpRedirect) {
if (tls.redirectHost == null && (host === '0.0.0.0' || host === '::')) {
logger.warn(
`[server] HTTP->HTTPS redirect host falls back to the wildcard bind address (${host}); browsers cannot follow it. ` +
`Set server.tls.redirect_host to the externally reachable hostname.`,
);
}
const pinnedHost = tls.redirectHost ?? (isLoopbackBind ? 'localhost' : host);
const redirector = createHttpRedirectServer({ httpsPort: port, pinnedHost });
redirector.on('error', (e) => logger.warn(`[server] HTTP redirect listener error: ${(e as Error).message}`));
redirector.listen(tls.httpRedirectPort, host, () =>
logger.info(`HTTP->HTTPS redirect listening on http://${host}:${tls.httpRedirectPort}`),
);
}
} else {
server = finalApp.listen(port, host, () => {
logger.info(`Core server listening on ${host}:${port}`); logger.info(`Core server listening on ${host}:${port}`);
if (!isLoopbackBind && !authActive) { if (!isLoopbackBind && !authActive) {
logger.warn( logger.warn(
@ -1267,6 +1364,7 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
); );
} }
}); });
}
// 起動と同時に CAPTCHA Pool の idle GC を回す (task session を 5 分アイドルで GC) // 起動と同時に CAPTCHA Pool の idle GC を回す (task session を 5 分アイドルで GC)
if (browserSessionManager) browserSessionManager.startIdleGc(); if (browserSessionManager) browserSessionManager.startIdleGc();

View File

@ -0,0 +1,135 @@
/**
* Usage dashboard API (GET /api/usage/daily) tests.
*
* Coverage:
* - admin sees all users + byUser breakdown; non-admin scoped to own rows
* - no-auth (authActive=false) sees everyone (scope 'all')
* - day / week / month bucketing collapses model/route correctly
* - inclusive range, default range, from>to 400, range-too-large 400
* - invalid dates fall back to defaults (not 500)
*
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
*/
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { createUsageRouter } from './usage-api.js';
function makeApp(repo: Repository, opts: { authActive: boolean; user?: { id: string; role?: string } }) {
const app = express();
app.use((req, _res, next) => {
if (opts.user) (req as unknown as { user: unknown }).user = opts.user;
next();
});
app.use('/api/usage', createUsageRouter(repo, { authActive: opts.authActive }));
return app;
}
function seed(repo: Repository, rows: Array<{ day: string; userId: string; source: 'gateway' | 'direct'; model?: string; route?: string; tin: number; tout: number; req?: number }>) {
for (const r of rows) {
repo.incrementLlmUsage({
day: r.day, userId: r.userId, source: r.source,
model: r.model ?? 'm', route: r.route ?? 'r',
tokensIn: r.tin, tokensOut: r.tout, requests: r.req ?? 1,
});
}
}
describe('GET /api/usage/daily', () => {
let repo: Repository;
beforeEach(() => {
repo = new Repository(':memory:');
seed(repo, [
{ day: '2026-06-10', userId: 'u1', source: 'gateway', tin: 100, tout: 40 },
{ day: '2026-06-10', userId: 'u1', source: 'direct', model: 'x', route: 'h', tin: 10, tout: 5 },
{ day: '2026-06-11', userId: 'u1', source: 'gateway', tin: 7, tout: 3 },
{ day: '2026-06-11', userId: 'u2', source: 'direct', tin: 1000, tout: 500 },
]);
});
it('non-admin sees only their own rows (scope=self, no byUser)', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
expect(res.status).toBe(200);
expect(res.body.scope).toBe('self');
expect(res.body.byUser).toBeUndefined();
// u1 only: gateway 100+40+7+3=150, direct 10+5=15
expect(res.body.totals.gateway).toMatchObject({ tokensIn: 107, tokensOut: 43, requests: 2 });
expect(res.body.totals.direct).toMatchObject({ tokensIn: 10, tokensOut: 5, requests: 1 });
});
it('admin sees all users with a byUser breakdown', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
expect(res.status).toBe(200);
expect(res.body.scope).toBe('all');
expect(res.body.totals.direct.tokensIn).toBe(1010); // u1 10 + u2 1000
const users = (res.body.byUser as Array<{ userId: string }>).map((u) => u.userId).sort();
expect(users).toEqual(['u1', 'u2']);
// sorted by total tokens desc → u2 (1500) first
expect(res.body.byUser[0].userId).toBe('u2');
});
it('resolves byUser display names (real users → name, sentinels verbatim)', async () => {
const u = repo.createUser({ email: 'alice@example.com', name: 'Alice', role: 'user', status: 'active' });
seed(repo, [
{ day: '2026-06-11', userId: u.id, source: 'direct', tin: 5, tout: 5 },
{ day: '2026-06-11', userId: 'local', source: 'direct', tin: 1, tout: 1 },
]);
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
const byId = Object.fromEntries((res.body.byUser as Array<{ userId: string; displayName: string }>).map((r) => [r.userId, r.displayName]));
expect(byId[u.id]).toBe('Alice');
expect(byId['local']).toBe('local'); // sentinel returned verbatim for UI localization
});
it('no-auth mode (authActive=false) sees everyone', async () => {
const app = makeApp(repo, { authActive: false }); // no req.user
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
expect(res.status).toBe(200);
expect(res.body.scope).toBe('all');
expect(res.body.byUser.length).toBe(2);
});
it('day granularity yields one bucket per active day', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-11&granularity=day');
expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10', '2026-06-11']);
});
it('month granularity collapses days into a YYYY-MM bucket', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30&granularity=month');
expect(res.body.series).toHaveLength(1);
expect(res.body.series[0].bucket).toBe('2026-06');
});
it('week granularity uses an ISO YYYY-Www bucket', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-08&to=2026-06-14&granularity=week');
// 2026-06-10 / -11 fall in ISO week 24 of 2026
expect(res.body.series).toHaveLength(1);
expect(res.body.series[0].bucket).toBe('2026-W24');
});
it('rejects from > to with 400', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
const res = await request(app).get('/api/usage/daily?from=2026-06-30&to=2026-06-01');
expect(res.status).toBe(400);
});
it('rejects an absurdly large range with 400', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
const res = await request(app).get('/api/usage/daily?from=2000-01-01&to=2026-06-30');
expect(res.status).toBe(400);
});
it('falls back to defaults for invalid dates (no 500)', async () => {
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
const res = await request(app).get('/api/usage/daily?from=2026-99-99');
expect(res.status).toBe(200);
// default window is the last 30 days, ending today
expect(res.body.to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});

171
src/bridge/usage-api.ts Normal file
View File

@ -0,0 +1,171 @@
import { Router, Request, Response } from 'express';
import type { Repository, LlmUsageDailyAgg } from '../db/repository.js';
import { logger } from '../logger.js';
/**
* Per-user LLM usage dashboard API. Reads the llm_usage_daily ledger
* (gateway + direct, recorded at the OpenAICompatClient completion
* boundary) and shapes a time series for the Usage tab.
*
* Visibility: admin (and the no-auth single-user local mode) see every
* user's usage; a non-admin authenticated user sees only their own rows.
* This is a separate lens from the gateway per-key billing view the two
* are never summed.
*
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
*/
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
/** True only for a real calendar day in 'YYYY-MM-DD' (rejects 2026-99-99). */
function isValidDay(s: unknown): s is string {
if (typeof s !== 'string' || !DAY_RE.test(s)) return false;
const d = new Date(`${s}T00:00:00.000Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
}
const MAX_RANGE_DAYS = 800; // ~2y guard so a hand-crafted range can't scan unbounded
type Granularity = 'day' | 'week' | 'month';
interface Counters {
tokensIn: number;
tokensOut: number;
requests: number;
}
function emptyCounters(): Counters {
return { tokensIn: 0, tokensOut: 0, requests: 0 };
}
function addInto(target: Counters, row: LlmUsageDailyAgg): void {
target.tokensIn += row.tokensIn;
target.tokensOut += row.tokensOut;
target.requests += row.requests;
}
function utcToday(): string {
return new Date().toISOString().slice(0, 10);
}
/** day - n days, as 'YYYY-MM-DD' (UTC). */
function shiftDay(day: string, deltaDays: number): string {
const d = new Date(`${day}T00:00:00.000Z`);
d.setUTCDate(d.getUTCDate() + deltaDays);
return d.toISOString().slice(0, 10);
}
/** Inclusive day count between two 'YYYY-MM-DD' (UTC). */
function dayDiff(from: string, to: string): number {
const a = Date.parse(`${from}T00:00:00.000Z`);
const b = Date.parse(`${to}T00:00:00.000Z`);
return Math.round((b - a) / 86_400_000);
}
/** ISO-8601 week key 'YYYY-Www' for a 'YYYY-MM-DD' day (UTC). */
function isoWeekKey(day: string): string {
const d = new Date(`${day}T00:00:00.000Z`);
// ISO week: Thursday of the current week decides the year.
const dayNum = (d.getUTCDay() + 6) % 7; // Mon=0 .. Sun=6
d.setUTCDate(d.getUTCDate() - dayNum + 3);
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
const week = 1 + Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86_400_000));
return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
}
function bucketKey(day: string, granularity: Granularity): string {
if (granularity === 'month') return day.slice(0, 7);
if (granularity === 'week') return isoWeekKey(day);
return day;
}
/**
* Human-friendly label for a usage owner id. Real users resolve to their
* name (or email) so the admin breakdown isn't a wall of opaque ids; the
* 'local' / 'system' sentinels are returned verbatim so the UI can localize
* them. Falls back to the raw id when no user row exists.
*/
function resolveDisplayName(repo: Repository, userId: string): string {
if (userId === 'local' || userId === 'system') return userId;
const u = repo.getUserById(userId);
return u?.name || u?.email || userId;
}
export function createUsageRouter(repo: Repository, opts: { authActive: boolean }): Router {
const router = Router();
// GET /daily?from=YYYY-MM-DD&to=YYYY-MM-DD&granularity=day|week|month
router.get('/daily', (req: Request, res: Response) => {
try {
const to = isValidDay(req.query['to']) ? req.query['to'] : utcToday();
const from = isValidDay(req.query['from']) ? req.query['from'] : shiftDay(to, -29);
if (from > to) {
res.status(400).json({ error: 'from must be on or before to' });
return;
}
if (dayDiff(from, to) > MAX_RANGE_DAYS) {
res.status(400).json({ error: `range too large (max ${MAX_RANGE_DAYS} days)` });
return;
}
const gq = req.query['granularity'];
const granularity: Granularity =
gq === 'week' || gq === 'month' ? gq : 'day';
// Visibility: a non-admin authenticated user is scoped to their own
// rows. Admin and the no-auth local mode see everyone.
const user = req.user as Express.User | undefined;
const isAdmin = !opts.authActive || user?.role === 'admin';
const scopeUserId = isAdmin ? undefined : (user?.id ?? 'local');
const rows = repo.queryLlmUsageDaily({ from, to, userId: scopeUserId });
// Bucket by (bucketKey, source). Buckets are sparse — only days with
// usage appear; the client fills gaps for the chart.
const buckets = new Map<string, { gateway: Counters; direct: Counters }>();
const totals = { gateway: emptyCounters(), direct: emptyCounters() };
const byUser = new Map<string, Counters>();
for (const row of rows) {
const key = bucketKey(row.day, granularity);
let b = buckets.get(key);
if (!b) {
b = { gateway: emptyCounters(), direct: emptyCounters() };
buckets.set(key, b);
}
const sourceKey = row.source === 'gateway' ? 'gateway' : 'direct';
addInto(b[sourceKey], row);
addInto(totals[sourceKey], row);
if (isAdmin) {
let u = byUser.get(row.userId);
if (!u) { u = emptyCounters(); byUser.set(row.userId, u); }
addInto(u, row);
}
}
const series = Array.from(buckets.entries())
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map(([bucket, c]) => ({ bucket, gateway: c.gateway, direct: c.direct }));
res.json({
from,
to,
granularity,
scope: isAdmin ? 'all' : 'self',
series,
totals,
...(isAdmin
? {
byUser: Array.from(byUser.entries())
.map(([userId, c]) => ({ userId, displayName: resolveDisplayName(repo, userId), ...c }))
.sort((a, b) => (b.tokensIn + b.tokensOut) - (a.tokensIn + a.tokensOut)),
}
: {}),
});
} catch (e) {
logger.error(`[usage-api] /daily failed: ${String(e)}`);
res.status(500).json({ error: 'Failed to load usage' });
}
});
return router;
}

View File

@ -4,6 +4,7 @@ import { logger } from './logger.js';
import { normalizeConfig } from './config-normalize.js'; import { normalizeConfig } from './config-normalize.js';
import type { McpRuntimeConfig } from './mcp/config.js'; import type { McpRuntimeConfig } from './mcp/config.js';
import type { SshRuntimeConfig } from './ssh/config.js'; import type { SshRuntimeConfig } from './ssh/config.js';
import type { ServerConfig } from './server/config.js';
export interface AskConfig { export interface AskConfig {
maxPerJob: number; // default: 2 maxPerJob: number; // default: 2
@ -60,6 +61,7 @@ export interface ToolsConfig {
officePdfMaxSizeMb?: number; // ReadPdf の最大ファイルサイズ (default: 10) officePdfMaxSizeMb?: number; // ReadPdf の最大ファイルサイズ (default: 10)
officePptxMaxSizeMb?: number; // ReadPPTX の最大ファイルサイズ (default: 50) officePptxMaxSizeMb?: number; // ReadPPTX の最大ファイルサイズ (default: 50)
officePptxMaxUncompressedMb?: number; // ReadPPTX の ZIP 展開後サイズ上限 (default: 200) officePptxMaxUncompressedMb?: number; // ReadPPTX の ZIP 展開後サイズ上限 (default: 200)
officeMsgMaxSizeMb?: number; // ReadMsg の最大ファイルサイズ (default: 25)
/** /**
* Max request body size (MB) for the POST /api/local/tasks and * Max request body size (MB) for the POST /api/local/tasks and
* /api/local/tasks/:id/comments endpoints (includes base64-encoded * /api/local/tasks/:id/comments endpoints (includes base64-encoded
@ -502,6 +504,7 @@ export interface AppConfig {
ssh?: Partial<SshRuntimeConfig>; ssh?: Partial<SshRuntimeConfig>;
notes?: NotesConfig; notes?: NotesConfig;
notifications?: NotificationsConfig; notifications?: NotificationsConfig;
server?: Partial<ServerConfig>;
} }
const DEFAULT_REFLECTION: ReflectionConfig = { const DEFAULT_REFLECTION: ReflectionConfig = {

View File

@ -338,27 +338,38 @@ describe('runReflectionJob integration', () => {
}), }),
} as any); } as any);
// Mock fetch so callReflectionLlm returns a valid abstain result // Mock fetch so callReflectionLlm (now streaming via OpenAICompatClient)
const mockFetch = vi.fn().mockResolvedValue({ // returns a valid abstain result as an SSE submit_reflection tool_call.
ok: true, const reflectionArgs = JSON.stringify({
json: async () => ({
choices: [{
message: {
tool_calls: [{
function: {
arguments: JSON.stringify({
memory_changes: [], memory_changes: [],
piece_changes: { should_edit: false }, piece_changes: { should_edit: false },
reasoning: 'nothing to learn', reasoning: 'nothing to learn',
abstain_reason: 'task completed successfully without issues', abstain_reason: 'task completed successfully without issues',
});
const sseLines = [
`data: ${JSON.stringify({ model: 'reflect-model', choices: [{ delta: { tool_calls: [{ index: 0, id: 'r1', function: { name: 'submit_reflection', arguments: reflectionArgs } }] }, finish_reason: null }] })}\n\n`,
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] })}\n\n`,
`data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 123, completion_tokens: 45 } })}\n\n`,
'data: [DONE]\n\n',
];
const mockFetch = vi.fn().mockImplementation(async () => {
const encoder = new TextEncoder();
let i = 0;
return {
ok: true,
status: 200,
headers: { get: () => null },
body: {
getReader: () => ({
read: async () =>
i < sseLines.length
? { done: false, value: encoder.encode(sseLines[i++]) }
: { done: true, value: undefined },
releaseLock: () => {},
}), }),
}, },
}], } as any;
}, });
}],
usage: { prompt_tokens: 123, completion_tokens: 45 },
}),
} as any);
vi.stubGlobal('fetch', mockFetch); vi.stubGlobal('fetch', mockFetch);
const { runReflectionJob } = await import('../engine/reflection/reflection-runner.js'); const { runReflectionJob } = await import('../engine/reflection/reflection-runner.js');

View File

@ -90,12 +90,44 @@ export function runMigrations(db: Database.Database): void {
db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'"); db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'");
}); });
// Title provenance: 'auto' (creation fallback) / 'agent' (derived from
// Mission Brief goal) / 'user' (manual edit, never auto-overwritten).
addColumnIfMissing(db, 'local_tasks', 'title_source', () => {
db.exec("ALTER TABLE local_tasks ADD COLUMN title_source TEXT NOT NULL DEFAULT 'auto'");
});
migrateMcpTables(db); migrateMcpTables(db);
migrateSshTables(db); migrateSshTables(db);
migrateNotesTables(db); migrateNotesTables(db);
migrateDashboardWidgets(db); migrateDashboardWidgets(db);
migrateGatewayVirtualKeys(db); migrateGatewayVirtualKeys(db);
migratePushNotificationsTables(db); migratePushNotificationsTables(db);
migrateLlmUsageDaily(db);
}
/**
* Per-user daily LLM usage aggregation (gateway + direct). Idempotent.
* Mirrors schema.sql + Repository.initSchema (dual-path rule:
* project_db_migration_dual_path). Additive table, no mixed-version risk.
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md.
*/
function migrateLlmUsageDaily(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS llm_usage_daily (
day TEXT NOT NULL,
user_id TEXT NOT NULL,
source TEXT NOT NULL,
model TEXT NOT NULL,
route TEXT NOT NULL,
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
requests INTEGER NOT NULL DEFAULT 0,
last_updated_at TEXT NOT NULL,
PRIMARY KEY (day, user_id, source, model, route)
);
CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day
ON llm_usage_daily (user_id, day);
`);
} }
/** /**

View File

@ -0,0 +1,105 @@
/**
* Per-user daily LLM usage ledger (llm_usage_daily) repository tests.
*
* Coverage:
* - incrementLlmUsage UPSERTs on first call, accumulates on second
* - requests defaults to +1; a usage-less call still bumps requests
* - negative deltas clamp to zero
* - distinct (model) and (route) produce distinct rows on the same day
* - 'system' / 'local' sentinels aggregate as single rows (NULL trap avoided)
* - queryLlmUsageDaily collapses model/route, groups by (day, user, source)
* - day filter is an inclusive range; userId filter scopes a single user
* - UTC day boundary splits into separate buckets
*
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { Repository } from './repository.js';
function makeRepo(): Repository {
return new Repository(':memory:');
}
describe('llm_usage_daily repository', () => {
let repo: Repository;
beforeEach(() => {
repo = makeRepo();
});
it('UPSERTs first call and accumulates on the same grain', () => {
const grain = { day: '2026-06-11', userId: 'u1', source: 'direct' as const, model: 'm', route: 'r' };
repo.incrementLlmUsage({ ...grain, tokensIn: 100, tokensOut: 40 });
repo.incrementLlmUsage({ ...grain, tokensIn: 10, tokensOut: 5 });
const rows = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11' });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
day: '2026-06-11', userId: 'u1', source: 'direct',
tokensIn: 110, tokensOut: 45, requests: 2,
});
});
it('a usage-less call still bumps requests (0-token request != failure)', () => {
repo.incrementLlmUsage({ day: '2026-06-11', userId: 'u1', source: 'gateway', model: 'm', route: 'r' });
const rows = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11' });
expect(rows[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, requests: 1 });
});
it('clamps negative deltas to zero', () => {
repo.incrementLlmUsage({ day: '2026-06-11', userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: -5, tokensOut: -9, requests: -3 });
const rows = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11' });
expect(rows[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, requests: 0 });
});
it('distinct model and route are separate rows but collapse in the query', () => {
const base = { day: '2026-06-11', userId: 'u1', source: 'direct' as const, tokensIn: 10, tokensOut: 5 };
repo.incrementLlmUsage({ ...base, model: 'big', route: 'host-a' });
repo.incrementLlmUsage({ ...base, model: 'small', route: 'host-a' });
repo.incrementLlmUsage({ ...base, model: 'big', route: 'host-b' });
// 3 distinct (model,route) rows underneath, collapsed to one (day,user,source).
const rows = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11' });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ tokensIn: 30, tokensOut: 15, requests: 3 });
});
it("'system' and 'local' sentinels each aggregate as a single row", () => {
const g = { day: '2026-06-11', source: 'direct' as const, model: 'm', route: 'r', tokensIn: 1, tokensOut: 1 };
repo.incrementLlmUsage({ ...g, userId: 'system' });
repo.incrementLlmUsage({ ...g, userId: 'system' });
repo.incrementLlmUsage({ ...g, userId: 'local' });
const rows = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11' });
const byUser = Object.fromEntries(rows.map((r) => [r.userId, r.requests]));
expect(byUser).toEqual({ system: 2, local: 1 });
});
it('gateway and direct are distinct rows for the same user/day', () => {
repo.incrementLlmUsage({ day: '2026-06-11', userId: 'u1', source: 'gateway', model: 'm', route: 'r', tokensIn: 7, tokensOut: 3 });
repo.incrementLlmUsage({ day: '2026-06-11', userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: 2, tokensOut: 1 });
const rows = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11' });
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.source).sort()).toEqual(['direct', 'gateway']);
});
it('queryLlmUsageDaily honours an inclusive day range', () => {
for (const day of ['2026-06-09', '2026-06-10', '2026-06-11', '2026-06-12']) {
repo.incrementLlmUsage({ day, userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 });
}
const rows = repo.queryLlmUsageDaily({ from: '2026-06-10', to: '2026-06-11' });
expect(rows.map((r) => r.day)).toEqual(['2026-06-10', '2026-06-11']);
});
it('userId filter scopes the query to one user', () => {
repo.incrementLlmUsage({ day: '2026-06-11', userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 });
repo.incrementLlmUsage({ day: '2026-06-11', userId: 'u2', source: 'direct', model: 'm', route: 'r', tokensIn: 9, tokensOut: 0 });
const mine = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11', userId: 'u1' });
expect(mine).toHaveLength(1);
expect(mine[0]).toMatchObject({ userId: 'u1', tokensIn: 1 });
});
it('derives the UTC day from `at` when day is omitted; boundary splits buckets', () => {
const grain = { userId: 'u1', source: 'direct' as const, model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 };
repo.incrementLlmUsage({ ...grain, at: '2026-06-11T23:59:59.000Z' });
repo.incrementLlmUsage({ ...grain, at: '2026-06-12T00:00:01.000Z' });
const rows = repo.queryLlmUsageDaily({ from: '2026-06-01', to: '2026-06-30' });
expect(rows.map((r) => r.day)).toEqual(['2026-06-11', '2026-06-12']);
});
});

View File

@ -1653,3 +1653,74 @@ describe('Repository browser notifications V2', () => {
}); });
}); });
}); });
describe('Repository title derivation from Mission Brief goal', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
});
function makeRepo(): Repository {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-title-'));
return new Repository(join(tempDir, 'orchestrator.db'));
}
it('derives the title from the goal when title is auto, and marks it agent', async () => {
const repo = makeRepo();
try {
const task = await repo.createLocalTask({ title: '仮タイトル', titleSource: 'auto', body: 'b' });
repo.makeMissionBriefIO(task.id).update({ goal: '議事録を作成する\n背景...' });
const after = await repo.getLocalTask(task.id);
expect(after?.title).toBe('議事録を作成する');
expect(after?.titleSource).toBe('agent');
} finally {
repo.close();
}
});
it('re-derives on a later goal update while still agent-owned', async () => {
const repo = makeRepo();
try {
const task = await repo.createLocalTask({ title: 'x', titleSource: 'auto', body: 'b' });
const io = repo.makeMissionBriefIO(task.id);
io.update({ goal: '最初の目標' });
io.update({ goal: '更新された目標' });
const after = await repo.getLocalTask(task.id);
expect(after?.title).toBe('更新された目標');
expect(after?.titleSource).toBe('agent');
} finally {
repo.close();
}
});
it('never overwrites a user-edited title', async () => {
const repo = makeRepo();
try {
const task = await repo.createLocalTask({ title: 'x', titleSource: 'auto', body: 'b' });
await repo.updateLocalTask(task.id, { title: '手動タイトル', titleSource: 'user' });
repo.makeMissionBriefIO(task.id).update({ goal: 'エージェントの目標' });
const after = await repo.getLocalTask(task.id);
expect(after?.title).toBe('手動タイトル');
expect(after?.titleSource).toBe('user');
} finally {
repo.close();
}
});
it('does not touch the title when the patch has no goal', async () => {
const repo = makeRepo();
try {
const task = await repo.createLocalTask({ title: '仮', titleSource: 'auto', body: 'b' });
repo.makeMissionBriefIO(task.id).update({ done: '- step 1' });
const after = await repo.getLocalTask(task.id);
expect(after?.title).toBe('仮');
expect(after?.titleSource).toBe('auto');
} finally {
repo.close();
}
});
});

View File

@ -6,6 +6,7 @@ import { randomUUID, scryptSync, randomBytes, timingSafeEqual } from 'crypto';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { buildVisibilityWhere } from '../bridge/visibility.js'; import { buildVisibilityWhere } from '../bridge/visibility.js';
import { buildTitleFromGoal } from '../title-generation.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@ -146,9 +147,13 @@ export interface SubtaskInfo {
childCompleted?: number; childCompleted?: number;
} }
export type TitleSource = 'auto' | 'agent' | 'user';
export interface LocalTask { export interface LocalTask {
id: number; id: number;
title: string; title: string;
/** Provenance of `title`. 'user' is never auto-overwritten by the agent. */
titleSource: TitleSource;
body: string; body: string;
pieceName: string; pieceName: string;
profile: 'auto' | 'fast' | 'quality' | string; profile: 'auto' | 'fast' | 'quality' | string;
@ -335,6 +340,33 @@ function rowToGatewayKeyUsage(row: GatewayKeyUsageRow): GatewayKeyUsage {
}; };
} }
/** Per-call delta for the per-user daily LLM usage ledger. */
export interface LlmUsageIncrement {
/** UTC day bucket 'YYYY-MM-DD'. Defaults to today (UTC) when omitted. */
day?: string;
/** Owner id, or 'local' (no-auth) / 'system' (ownerless) sentinel. */
userId: string;
source: 'gateway' | 'direct';
/** Real model name (chunk.model), routing-key fallback, or 'unknown'. */
model: string;
/** Backend server name (gateway backendId / direct host), or 'unknown'. */
route: string;
tokensIn?: number;
tokensOut?: number;
requests?: number;
at?: string;
}
/** Daily-grouped aggregate row (model/route collapsed) for the usage API. */
export interface LlmUsageDailyAgg {
day: string;
userId: string;
source: string;
tokensIn: number;
tokensOut: number;
requests: number;
}
/** /**
* Coerce an optional limit (tokens_budget / rate_limit_rpm) to either * Coerce an optional limit (tokens_budget / rate_limit_rpm) to either
* a positive integer or null. Anything else (undefined, null, 0, * a positive integer or null. Anything else (undefined, null, 0,
@ -432,6 +464,8 @@ export interface UpsertWorkerNodeParams {
export interface CreateLocalTaskParams { export interface CreateLocalTaskParams {
title: string; title: string;
/** Defaults to 'auto'. Pass 'user' when the caller supplied an explicit title. */
titleSource?: TitleSource;
body: string; body: string;
pieceName?: string; pieceName?: string;
profile?: 'auto' | 'fast' | 'quality'; profile?: 'auto' | 'fast' | 'quality';
@ -650,6 +684,7 @@ interface JobRow {
interface LocalTaskRow { interface LocalTaskRow {
id: number; id: number;
title: string; title: string;
title_source: string | null;
body: string; body: string;
piece_name: string; piece_name: string;
profile: string; profile: string;
@ -802,6 +837,7 @@ function rowToLocalTask(row: LocalTaskRow): LocalTask {
return { return {
id: row.id, id: row.id,
title: row.title, title: row.title,
titleSource: (row.title_source as TitleSource | null) ?? 'auto',
body: row.body, body: row.body,
pieceName: row.piece_name, pieceName: row.piece_name,
profile: row.profile, profile: row.profile,
@ -1133,6 +1169,26 @@ export class Repository {
CREATE INDEX IF NOT EXISTS idx_gateway_usage_key CREATE INDEX IF NOT EXISTS idx_gateway_usage_key
ON gateway_key_usage (key_id); ON gateway_key_usage (key_id);
`); `);
// Per-user daily LLM usage (gateway + direct). Mirrors schema.sql +
// migrate.ts (dual-path rule). Separate lens from gateway_key_usage.
// Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
this.db.exec(`
CREATE TABLE IF NOT EXISTS llm_usage_daily (
day TEXT NOT NULL,
user_id TEXT NOT NULL,
source TEXT NOT NULL,
model TEXT NOT NULL,
route TEXT NOT NULL,
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
requests INTEGER NOT NULL DEFAULT 0,
last_updated_at TEXT NOT NULL,
PRIMARY KEY (day, user_id, source, model, route)
);
CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day
ON llm_usage_daily (user_id, day);
`);
} }
private ensureColumn(tableName: string, columnName: string, definition: string): void { private ensureColumn(tableName: string, columnName: string, definition: string): void {
@ -1247,11 +1303,12 @@ export class Repository {
async createLocalTask(params: CreateLocalTaskParams): Promise<LocalTask> { async createLocalTask(params: CreateLocalTaskParams): Promise<LocalTask> {
const result = this.db const result = this.db
.prepare( .prepare(
`INSERT INTO local_tasks (title, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, options) `INSERT INTO local_tasks (title, title_source, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, options)
VALUES (@title, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @options)` VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @options)`
) )
.run({ .run({
title: params.title, title: params.title,
titleSource: params.titleSource ?? 'auto',
body: params.body, body: params.body,
pieceName: params.pieceName ?? 'chat', pieceName: params.pieceName ?? 'chat',
profile: params.profile ?? 'auto', profile: params.profile ?? 'auto',
@ -1365,9 +1422,10 @@ export class Repository {
* from sync paths (e.g. buildSystemPrompt). better-sqlite3 is sync * from sync paths (e.g. buildSystemPrompt). better-sqlite3 is sync
* underneath anyway. */ * underneath anyway. */
updateMissionBriefSync(taskId: number, patch: Partial<MissionBrief>): MissionBrief | null { updateMissionBriefSync(taskId: number, patch: Partial<MissionBrief>): MissionBrief | null {
const existing = parseMissionBrief( const row = this.db
(this.db.prepare(`SELECT mission_brief FROM local_tasks WHERE id = ?`).get(taskId) as { mission_brief: string | null } | undefined)?.mission_brief ?? null, .prepare(`SELECT mission_brief, title_source FROM local_tasks WHERE id = ?`)
); .get(taskId) as { mission_brief: string | null; title_source: string | null } | undefined;
const existing = parseMissionBrief(row?.mission_brief ?? null);
const next: MissionBrief = { const next: MissionBrief = {
goal: patch.goal !== undefined ? patch.goal : existing?.goal ?? '', goal: patch.goal !== undefined ? patch.goal : existing?.goal ?? '',
done: patch.done !== undefined ? patch.done : existing?.done ?? '', done: patch.done !== undefined ? patch.done : existing?.done ?? '',
@ -1376,9 +1434,29 @@ export class Repository {
}; };
const allEmpty = !next.goal && !next.done && !next.open && !next.clarifications; const allEmpty = !next.goal && !next.done && !next.open && !next.clarifications;
const stored = allEmpty ? null : JSON.stringify(next); const stored = allEmpty ? null : JSON.stringify(next);
// Derive the task title from the agent's goal (no LLM call). Only when the
// goal value actually changed (agents re-send an unchanged brief across
// iterations — re-deriving every time would churn updated_at and flicker
// the title) and the user hasn't taken manual control (a user edit pins
// title_source='user' and is never overwritten).
const goalChanged = patch.goal !== undefined && patch.goal !== (existing?.goal ?? '');
const derivedTitle = (goalChanged && (row?.title_source ?? 'auto') !== 'user')
? buildTitleFromGoal(next.goal)
: '';
// Atomic: persist the brief and the derived title as one unit so a crash
// between them can't leave the title out of sync with the goal.
this.db.transaction(() => {
this.db.prepare( this.db.prepare(
`UPDATE local_tasks SET mission_brief = ?, updated_at = datetime('now') WHERE id = ?` `UPDATE local_tasks SET mission_brief = ?, updated_at = datetime('now') WHERE id = ?`
).run(stored, taskId); ).run(stored, taskId);
if (derivedTitle) {
this.db.prepare(
`UPDATE local_tasks SET title = ?, title_source = 'agent' WHERE id = ?`
).run(derivedTitle, taskId);
}
})();
return allEmpty ? null : next; return allEmpty ? null : next;
} }
@ -1636,6 +1714,7 @@ export class Repository {
const params: Record<string, unknown> = { taskId }; const params: Record<string, unknown> = { taskId };
const fieldMap: Record<string, string> = { const fieldMap: Record<string, string> = {
title: 'title', title: 'title',
titleSource: 'title_source',
body: 'body', body: 'body',
pieceName: 'piece_name', pieceName: 'piece_name',
profile: 'profile', profile: 'profile',
@ -3490,6 +3569,83 @@ export class Repository {
return rows.map(rowToGatewayKeyUsage); return rows.map(rowToGatewayKeyUsage);
} }
// ── Per-user daily LLM usage (gateway + direct) ──────────────────────
//
// Recorded at the OpenAICompatClient completion boundary for every
// successful chat completion. UPSERT on the (day, user_id, source,
// model, route) grain. Separate lens from gateway_key_usage — never
// summed across the two tables. Spec:
// docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
/**
* UPSERT: bump per-(day, user, source, model, route) counters. Deltas
* are clamped at zero. `day` defaults to the UTC day of `at` (or now).
* Called once per successful stream completion; `usage`-less completions
* still bump `requests` (tokens 0) so a 0-token request is distinct from
* a failed/aborted one (which is never recorded).
*/
incrementLlmUsage(params: LlmUsageIncrement): void {
const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0));
const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0));
const reqs = Math.max(0, Math.floor(params.requests ?? 1));
const ts = params.at ?? new Date().toISOString();
const day = params.day ?? ts.slice(0, 10);
this.db
.prepare(
`INSERT INTO llm_usage_daily
(day, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (day, user_id, source, model, route) DO UPDATE SET
tokens_in = tokens_in + excluded.tokens_in,
tokens_out = tokens_out + excluded.tokens_out,
requests = requests + excluded.requests,
last_updated_at = excluded.last_updated_at`,
)
.run(day, params.userId, params.source, params.model, params.route, tIn, tOut, reqs, ts);
}
/**
* Daily time series for the usage dashboard, grouped by (day, user_id,
* source) with model/route collapsed. `userId` filter scopes a non-admin
* to their own rows; omit it for the admin all-users view (callers can
* collapse user_id afterwards). Inclusive `from`/`to` are 'YYYY-MM-DD'.
*/
queryLlmUsageDaily(opts: { from: string; to: string; userId?: string }): LlmUsageDailyAgg[] {
const where = ['day >= ?', 'day <= ?'];
const args: unknown[] = [opts.from, opts.to];
if (opts.userId !== undefined) {
where.push('user_id = ?');
args.push(opts.userId);
}
const rows = this.db
.prepare(
`SELECT day, user_id, source,
SUM(tokens_in) AS tokens_in,
SUM(tokens_out) AS tokens_out,
SUM(requests) AS requests
FROM llm_usage_daily
WHERE ${where.join(' AND ')}
GROUP BY day, user_id, source
ORDER BY day ASC`,
)
.all(...args) as Array<{
day: string;
user_id: string;
source: string;
tokens_in: number;
tokens_out: number;
requests: number;
}>;
return rows.map((r) => ({
day: r.day,
userId: r.user_id,
source: r.source,
tokensIn: r.tokens_in,
tokensOut: r.tokens_out,
requests: r.requests,
}));
}
/** Return the underlying Database instance (needed by migrate.ts and session store) */ /** Return the underlying Database instance (needed by migrate.ts and session store) */
getDb(): Database.Database { getDb(): Database.Database {
return this.db; return this.db;

View File

@ -65,7 +65,11 @@ CREATE TABLE IF NOT EXISTS local_tasks (
mission_brief TEXT, mission_brief TEXT,
-- Per-task options (JSON blob). Controls runtime behaviour toggles such as -- Per-task options (JSON blob). Controls runtime behaviour toggles such as
-- { mcpDisabled: true, skillsDisabled: true }. Default '{}' = all enabled. -- { mcpDisabled: true, skillsDisabled: true }. Default '{}' = all enabled.
options TEXT DEFAULT '{}' options TEXT DEFAULT '{}',
-- Provenance of `title`: 'auto' = cheap fallback set at creation,
-- 'agent' = derived from the Mission Brief goal during the run,
-- 'user' = manually edited (never overwritten by agent/regeneration).
title_source TEXT NOT NULL DEFAULT 'auto'
); );
CREATE INDEX IF NOT EXISTS idx_local_tasks_updated_at ON local_tasks (updated_at DESC); CREATE INDEX IF NOT EXISTS idx_local_tasks_updated_at ON local_tasks (updated_at DESC);
@ -586,6 +590,29 @@ CREATE TABLE IF NOT EXISTS gateway_key_usage (
CREATE INDEX IF NOT EXISTS idx_gateway_usage_key CREATE INDEX IF NOT EXISTS idx_gateway_usage_key
ON gateway_key_usage (key_id); ON gateway_key_usage (key_id);
-- ── LLM usage: per-user daily aggregation (gateway + direct) ────────────
-- Daily UPSERT buckets recorded at the OpenAICompatClient completion
-- boundary, covering BOTH gateway-routed and direct LLM calls. This is a
-- separate lens from gateway_key_usage (which is per-virtual-key / billing).
-- grain = (day, user_id, source, model, route); user_id is NOT NULL with a
-- 'system' / 'local' sentinel so ON CONFLICT keys never hit the SQLite
-- NULL != NULL trap. Spec:
-- docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
CREATE TABLE IF NOT EXISTS llm_usage_daily (
day TEXT NOT NULL, -- 'YYYY-MM-DD' (UTC)
user_id TEXT NOT NULL, -- owner id / 'local' / 'system'
source TEXT NOT NULL, -- 'gateway' | 'direct'
model TEXT NOT NULL, -- real model name (chunk.model), routing key fallback
route TEXT NOT NULL, -- backend server name (gateway backendId / direct host)
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
requests INTEGER NOT NULL DEFAULT 0,
last_updated_at TEXT NOT NULL,
PRIMARY KEY (day, user_id, source, model, route)
);
CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day
ON llm_usage_daily (user_id, day);
-- ── Browser Notifications V2: Web Push subscriptions + per-user prefs ─── -- ── Browser Notifications V2: Web Push subscriptions + per-user prefs ───
-- Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md -- Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md
-- endpoint is globally UNIQUE so logging into a different user in the same -- endpoint is globally UNIQUE so logging into a different user in the same

View File

@ -1963,7 +1963,7 @@ export async function executeMovement(
{ role: 'user', content: taskInstruction }, { role: 'user', content: taskInstruction },
]; ];
const runIsolatedLlm = (isolatedMessages: Message[]): Promise<string> => const runIsolatedLlm = (isolatedMessages: Message[]): Promise<string> =>
runIsolatedLlmHelper(client, isolatedMessages, cancelSignal); runIsolatedLlmHelper(client, isolatedMessages, cancelSignal, { userId: ctx.userId });
// Traceability T-1: ensure eventLogger is non-undefined for the // Traceability T-1: ensure eventLogger is non-undefined for the
// duration of the movement. Production callers (piece-runner) always // duration of the movement. Production callers (piece-runner) always
@ -2166,6 +2166,7 @@ export async function executeMovement(
}, },
}, },
`movement=${movement.name} `, `movement=${movement.name} `,
{ userId: ctx.userId },
); );
const llmDurationMs = Date.now() - llmStartedAt; const llmDurationMs = Date.now() - llmStartedAt;
let { accumulatedText } = consumed; let { accumulatedText } = consumed;

View File

@ -4,6 +4,7 @@ import type {
ToolCall, ToolCall,
OpenAICompatClient, OpenAICompatClient,
LLMEvent, LLMEvent,
LlmCallContext,
} from '../llm/openai-compat.js'; } from '../llm/openai-compat.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { stripThinkingTokens } from './strip-thinking.js'; import { stripThinkingTokens } from './strip-thinking.js';
@ -22,9 +23,10 @@ export async function runIsolatedLlm(
client: OpenAICompatClient, client: OpenAICompatClient,
messages: Message[], messages: Message[],
cancelSignal?: AbortSignal, cancelSignal?: AbortSignal,
context?: LlmCallContext,
): Promise<string> { ): Promise<string> {
let output = ''; let output = '';
for await (const event of client.chat(messages, undefined, cancelSignal)) { for await (const event of client.chat(messages, undefined, cancelSignal, context)) {
if (event.type === 'text') { if (event.type === 'text') {
output += event.text; output += event.text;
continue; continue;
@ -107,8 +109,9 @@ export async function consumeLlmStream(
idleTimeoutMs: number, idleTimeoutMs: number,
callbacks: ConsumeStreamCallbacks = {}, callbacks: ConsumeStreamCallbacks = {},
contextLabel: string = '', contextLabel: string = '',
context?: LlmCallContext,
): Promise<ConsumedLLMResponse> { ): Promise<ConsumedLLMResponse> {
const stream = client.chat(messages, tools, cancelSignal); const stream = client.chat(messages, tools, cancelSignal, context);
const accumulator: ConsumedLLMResponse = { const accumulator: ConsumedLLMResponse = {
accumulatedText: '', accumulatedText: '',
pendingToolCalls: [], pendingToolCalls: [],

View File

@ -79,6 +79,7 @@ export async function classifyPiece(
pieces: PieceDescription[], pieces: PieceDescription[],
fileNames: string[], fileNames: string[],
timeoutMs: number = 8000, timeoutMs: number = 8000,
userId?: string,
): Promise<string | null> { ): Promise<string | null> {
const prompt = buildClassificationPrompt(taskText, pieces, fileNames); const prompt = buildClassificationPrompt(taskText, pieces, fileNames);
logger.debug(`[piece-classifier] candidates=[${pieces.map(p => p.name).join(', ')}] textLen=${taskText.length}`); logger.debug(`[piece-classifier] candidates=[${pieces.map(p => p.name).join(', ')}] textLen=${taskText.length}`);
@ -87,7 +88,7 @@ export async function classifyPiece(
const llmCall = async (): Promise<string | null> => { const llmCall = async (): Promise<string | null> => {
let result = ''; let result = '';
try { try {
for await (const event of client.chat(messages)) { for await (const event of client.chat(messages, undefined, undefined, { userId })) {
if (event.type === 'text') result += event.text; if (event.type === 'text') result += event.text;
else if (event.type === 'error') return null; else if (event.type === 'error') return null;
else if (event.type === 'done') break; else if (event.type === 'done') break;

View File

@ -13,26 +13,57 @@ const validResult = {
reasoning: 'x', reasoning: 'x',
}; };
const okResponse = { /**
* The reflection client now routes through OpenAICompatClient, which speaks
* streaming SSE. Build a fake streaming `Response` that emits the given SSE
* `data:` payloads, then `[DONE]`.
*/
function sseResponse(chunks: unknown[]): Response {
const lines = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`);
lines.push('data: [DONE]\n\n');
const encoder = new TextEncoder();
let i = 0;
return {
ok: true, ok: true,
json: () => Promise.resolve({ status: 200,
headers: { get: () => null },
body: {
getReader: () => ({
read: async () =>
i < lines.length
? { done: false, value: encoder.encode(lines[i++]) }
: { done: true, value: undefined },
releaseLock: () => {},
}),
},
} as unknown as Response;
}
/** A complete, valid submit_reflection tool-call stream with usage. */
function okStream(args: unknown = validResult): Response {
return sseResponse([
{
model: 'test-model',
choices: [ choices: [
{ {
message: { delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'submit_reflection', arguments: JSON.stringify(args) } }] },
tool_calls: [ finish_reason: null,
{
function: {
name: 'submit_reflection',
arguments: JSON.stringify(validResult),
},
}, },
], ],
}, },
}, { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
], { choices: [], usage: { prompt_tokens: 42, completion_tokens: 17 } },
usage: { prompt_tokens: 42, completion_tokens: 17 }, ]);
}), }
};
function httpError(status: number, bodyText: string): Response {
return {
ok: false,
status,
headers: { get: () => null },
text: () => Promise.resolve(bodyText),
} as unknown as Response;
}
beforeEach(() => { beforeEach(() => {
// No real backoff sleeps in tests. // No real backoff sleeps in tests.
@ -45,7 +76,7 @@ afterEach(() => {
describe('callReflectionLlm', () => { describe('callReflectionLlm', () => {
it('happy path: parses tool_call arguments and extracts token usage', async () => { it('happy path: parses tool_call arguments and extracts token usage', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okResponse)); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okStream()));
const result = await callReflectionLlm(cfg, 'system prompt', 'user prompt'); const result = await callReflectionLlm(cfg, 'system prompt', 'user prompt');
@ -59,12 +90,8 @@ describe('callReflectionLlm', () => {
it('retries a 5xx (backend tool-call parse failure) and succeeds on resample', async () => { it('retries a 5xx (backend tool-call parse failure) and succeeds on resample', async () => {
const fetchMock = vi.fn() const fetchMock = vi.fn()
.mockResolvedValueOnce({ .mockResolvedValueOnce(httpError(500, '{"error":{"message":"Failed to parse input at pos 41"}}'))
ok: false, .mockResolvedValueOnce(okStream());
status: 500,
text: () => Promise.resolve('{"error":{"message":"Failed to parse input at pos 41: <tool_call>..."}}'),
})
.mockResolvedValueOnce(okResponse);
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
const result = await callReflectionLlm(cfg, 's', 'u'); const result = await callReflectionLlm(cfg, 's', 'u');
@ -73,11 +100,7 @@ describe('callReflectionLlm', () => {
}); });
it('gives up after 3 attempts of persistent 5xx', async () => { it('gives up after 3 attempts of persistent 5xx', async () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue(httpError(500, 'parse error'));
ok: false,
status: 500,
text: () => Promise.resolve('parse error'),
});
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('HTTP 500'); await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('HTTP 500');
@ -85,38 +108,62 @@ describe('callReflectionLlm', () => {
}); });
it('does NOT retry a 4xx (deterministic config error, e.g. invalid api key)', async () => { it('does NOT retry a 4xx (deterministic config error, e.g. invalid api key)', async () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue(httpError(401, 'invalid api key'));
ok: false,
status: 401,
text: () => Promise.resolve('invalid api key'),
});
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('HTTP 401'); await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('HTTP 401');
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
}); });
it('retries when no tool_calls present, then throws after exhaustion', async () => { it('retries when no tool_call present, then throws after exhaustion', async () => {
const fetchMock = vi.fn().mockResolvedValue({ // A stream that yields only text and finishes — no submit_reflection call.
ok: true, const noToolStream = () => sseResponse([
json: () => Promise.resolve({ choices: [{ message: {} }] }), { choices: [{ delta: { content: 'just text' }, finish_reason: 'stop' }] },
}); ]);
const fetchMock = vi.fn().mockImplementation(async () => noToolStream());
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
await expect(callReflectionLlm(cfg, 'system prompt', 'user prompt')) await expect(callReflectionLlm(cfg, 'system prompt', 'user prompt'))
.rejects.toThrow('no tool_call'); .rejects.toThrow('no submit_reflection tool_call');
expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenCalledTimes(3);
}); });
it('retries malformed tool_call arguments JSON', async () => { it('does NOT retry a budget_exhausted gateway sentinel (fail fast)', async () => {
// SSE sentinel error → client yields gatewayErrorType=budget_exhausted.
const fetchMock = vi.fn().mockResolvedValue(
sseResponse([{ error: { type: 'budget_exhausted', message: 'over quota' } }]),
);
vi.stubGlobal('fetch', fetchMock);
await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('budget_exhausted');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('does NOT retry a prompt-size preflight block (fail fast)', async () => {
// Tiny context window forces the client preflight guard to block before
// any fetch; resampling the identical prompt cannot help.
const fetchMock = vi.fn().mockResolvedValue(okStream());
vi.stubGlobal('fetch', fetchMock);
await expect(callReflectionLlm({ ...cfg, contextLimitTokens: 1 }, 'system', 'user'))
.rejects.toThrow('blocked before send');
expect(fetchMock).not.toHaveBeenCalled();
});
it('retries malformed tool_call arguments (client yields empty input)', async () => {
// First stream carries broken JSON args → client parses to {} → structural
// guard treats it as malformed → resample. Second stream is valid.
const brokenStream = () => sseResponse([
{
choices: [
{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'submit_reflection', arguments: '{broken' } }] }, finish_reason: null },
],
},
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
]);
const fetchMock = vi.fn() const fetchMock = vi.fn()
.mockResolvedValueOnce({ .mockImplementationOnce(async () => brokenStream())
ok: true, .mockImplementationOnce(async () => okStream());
json: () => Promise.resolve({
choices: [{ message: { tool_calls: [{ function: { name: 'submit_reflection', arguments: '{broken' } }] } }],
}),
})
.mockResolvedValueOnce(okResponse);
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
const result = await callReflectionLlm(cfg, 's', 'u'); const result = await callReflectionLlm(cfg, 's', 'u');

View File

@ -1,4 +1,6 @@
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { getDefaultProviderRetryConfig } from '../../config.js';
import { OpenAICompatClient, type LLMEvent, type Message, type ToolDef } from '../../llm/openai-compat.js';
import type { ReflectionResult } from './types.js'; import type { ReflectionResult } from './types.js';
import { REFLECTION_TOOL_SCHEMA } from './reflection-schema.js'; import { REFLECTION_TOOL_SCHEMA } from './reflection-schema.js';
@ -6,6 +8,17 @@ export interface ReflectionLlmConfig {
endpoint: string; endpoint: string;
model: string | undefined; model: string | undefined;
apiKey?: string; apiKey?: string;
/** True when the reflection worker routes through the AAO Gateway (proxy). */
proxy?: boolean;
/** Reflection target user — recorded as the usage owner. */
userId?: string;
/**
* Model context window in tokens. Passed to the shared client's
* prompt-size preflight guard. Reflection prompts can be large (uncapped
* memory snapshot), so use the worker's real limit rather than the
* client's conservative 32k default, which would block valid prompts.
*/
contextLimitTokens?: number;
} }
export interface ReflectionLlmResult { export interface ReflectionLlmResult {
@ -62,54 +75,104 @@ export async function callReflectionLlm(
throw lastErr ?? new Error('reflection LLM failed'); throw lastErr ?? new Error('reflection LLM failed');
} }
/**
* Classify an OpenAICompatClient error for the reflection resample loop.
* - HTTP 5xx (incl. tool-call parse errors on malformed model output) and
* gateway_shutdown / gateway_timeout: transient resample.
* - HTTP 4xx (bad key / request shape), budget_exhausted / rate_limited
* (won't pass until the period resets), and the client-side
* "blocked before send" prompt-size guard: deterministic fail fast.
* - Everything else (transport / parse / idle timeout): stochastic resample.
*/
function classifyClientError(message: string, gatewayErrorType?: string): Error {
if (gatewayErrorType === 'budget_exhausted' || gatewayErrorType === 'rate_limited') {
return new Error(message);
}
if (gatewayErrorType === 'gateway_shutdown' || gatewayErrorType === 'gateway_timeout') {
return new RetryableLlmError(message);
}
// Client-side preflight rejection — the prompt is too large; resampling the
// identical prompt cannot help.
if (message.includes('blocked before send')) {
return new Error(message);
}
const m = /HTTP (\d{3})/.exec(message);
if (m) {
const status = Number(m[1]);
if (status >= 500) return new RetryableLlmError(message);
return new Error(message);
}
return new RetryableLlmError(message);
}
async function callOnce( async function callOnce(
cfg: ReflectionLlmConfig, cfg: ReflectionLlmConfig,
systemPrompt: string, systemPrompt: string,
userPrompt: string, userPrompt: string,
start: number, start: number,
): Promise<ReflectionLlmResult> { ): Promise<ReflectionLlmResult> {
const body: Record<string, unknown> = { // Route through the shared client so usage lands in the single
messages: [ // per-user ledger (gateway + direct) like every other LLM call.
// maxAttempts=1: the outer callReflectionLlm loop owns resampling.
const client = new OpenAICompatClient(
cfg.endpoint,
cfg.model,
cfg.apiKey,
{ ...getDefaultProviderRetryConfig(), maxAttempts: 1 },
undefined,
cfg.contextLimitTokens, // real model window; avoid the 32k default blocking large reflection prompts
undefined,
undefined,
{ proxy: cfg.proxy === true },
);
const messages: Message[] = [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }, { role: 'user', content: userPrompt },
], ];
tools: [REFLECTION_TOOL_SCHEMA],
tool_choice: { type: 'function', function: { name: 'submit_reflection' } }, let parsed: ReflectionResult | null = null;
temperature: 0.2, let usage: { prompt_tokens: number; completion_tokens: number } | undefined;
}; let errorMsg: string | null = null;
if (cfg.model) { let errorGatewayType: string | undefined;
body['model'] = cfg.model;
for await (const event of client.chat(
messages,
[REFLECTION_TOOL_SCHEMA as unknown as ToolDef],
undefined,
{ userId: cfg.userId },
{ temperature: 0.2, toolChoice: { type: 'function', function: { name: 'submit_reflection' } } },
) as AsyncGenerator<LLMEvent>) {
if (event.type === 'tool_use') {
if (event.name === 'submit_reflection' && parsed === null) {
parsed = event.input as unknown as ReflectionResult;
} }
const resp = await fetch(`${cfg.endpoint}/chat/completions`, { } else if (event.type === 'done') {
method: 'POST', usage = event.usage;
headers: { } else if (event.type === 'error') {
'content-type': 'application/json', errorMsg = event.error;
...(cfg.apiKey ? { authorization: `Bearer ${cfg.apiKey}` } : {}), errorGatewayType = event.gatewayErrorType;
},
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text();
const msg = `reflection LLM HTTP ${resp.status}: ${text}`;
// 5xx: backend-side failure (incl. tool-call parse errors on malformed
// model output) — resample. 4xx: deterministic config error — fail fast.
if (resp.status >= 500) throw new RetryableLlmError(msg);
throw new Error(msg);
} }
const data = await resp.json() as any; }
const toolCall = data.choices?.[0]?.message?.tool_calls?.[0];
if (!toolCall) throw new RetryableLlmError('reflection LLM returned no tool_call'); if (errorMsg !== null) {
let parsed: ReflectionResult; throw classifyClientError(`reflection LLM ${errorMsg}`, errorGatewayType);
try { }
parsed = JSON.parse(toolCall.function.arguments) as ReflectionResult; if (parsed === null) {
} catch { throw new RetryableLlmError('reflection LLM returned no submit_reflection tool_call');
throw new RetryableLlmError('reflection LLM tool_call arguments were not valid JSON'); }
// The shared client swallows tool-argument JSON parse errors and yields an
// empty `{}` input. Preserve the old resample-on-malformed behaviour with a
// shallow structural check against the tool schema's required fields — a
// genuinely-empty object means the model emitted broken tool markup.
const p = parsed as unknown as Record<string, unknown>;
if (p['piece_changes'] === undefined || p['reasoning'] === undefined) {
throw new RetryableLlmError('reflection LLM tool_call arguments were malformed or incomplete');
} }
return { return {
parsed, parsed,
tokensIn: data.usage?.prompt_tokens ?? 0, tokensIn: usage?.prompt_tokens ?? 0,
tokensOut: data.usage?.completion_tokens ?? 0, tokensOut: usage?.completion_tokens ?? 0,
durationMs: Date.now() - start, durationMs: Date.now() - start,
raw: data, raw: { usage },
}; };
} }

View File

@ -24,6 +24,10 @@ export interface RunReflectionDeps {
* (normal task calls always send the worker's key reflection must too). * (normal task calls always send the worker's key reflection must too).
*/ */
llmApiKey?: string; llmApiKey?: string;
/** True when the reflection worker routes through the AAO Gateway (proxy). */
llmProxy?: boolean;
/** Reflection worker's model context window (tokens) for the prompt guard. */
llmContextLimitTokens?: number;
} }
export async function runReflectionJob( export async function runReflectionJob(
@ -86,6 +90,9 @@ export async function runReflectionJob(
endpoint: deps.llmEndpoint, endpoint: deps.llmEndpoint,
model: deps.llmModel, model: deps.llmModel,
apiKey: deps.llmApiKey, apiKey: deps.llmApiKey,
proxy: deps.llmProxy === true,
userId: meta.userId,
contextLimitTokens: deps.llmContextLimitTokens,
}; };
let llmResult; let llmResult;

Binary file not shown.

View File

@ -51,6 +51,7 @@ export interface ToolsConfig {
officePdfMaxSizeMb?: number; // ReadPdf の最大ファイルサイズ (default: 10) officePdfMaxSizeMb?: number; // ReadPdf の最大ファイルサイズ (default: 10)
officePptxMaxSizeMb?: number; // ReadPPTX の最大ファイルサイズ (default: 50) officePptxMaxSizeMb?: number; // ReadPPTX の最大ファイルサイズ (default: 50)
officePptxMaxUncompressedMb?: number; // ReadPPTX の ZIP 展開後サイズ上限 (default: 200) officePptxMaxUncompressedMb?: number; // ReadPPTX の ZIP 展開後サイズ上限 (default: 200)
officeMsgMaxSizeMb?: number; // ReadMsg の最大ファイルサイズ (default: 25)
webfetchScreenshot?: boolean; // WebFetch で vlmEnabled 時にスクショを添付するか (default: true) webfetchScreenshot?: boolean; // WebFetch で vlmEnabled 時にスクショを添付するか (default: true)
webfetchScreenshotTimeoutMs?: number; // スクショ取得のタイムアウト (default: 15000) webfetchScreenshotTimeoutMs?: number; // スクショ取得のタイムアウト (default: 15000)
} }

View File

@ -52,6 +52,7 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
readexcel: 'office', readexcel: 'office',
readdocx: 'office', readdocx: 'office',
readpptx: 'office', readpptx: 'office',
readmsg: 'office',
pdftoimages: 'office', pdftoimages: 'office',
splitexcelsheets: 'office', splitexcelsheets: 'office',
splitdocxsections: 'office', splitdocxsections: 'office',

View File

@ -0,0 +1,324 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { fileURLToPath } from 'url';
import {
formatAddress,
stripHtml,
selectMsgBody,
sanitizeAttachmentName,
formatMsgOutput,
assembleMsgOutput,
pickEmail,
isParsedMsgValid,
executeReadMsg,
type MsgView,
} from './msg.js';
import type { ToolContext } from './core.js';
import { executeTool as officeExecuteTool, TOOL_DEFS as OFFICE_TOOL_DEFS } from './office.js';
const FIXTURE = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'__fixtures__',
'attachmentFiles.msg',
);
describe('formatAddress', () => {
it('renders name and email together', () => {
expect(formatAddress({ name: 'Alice', email: 'alice@example.com' })).toBe(
'Alice <alice@example.com>',
);
});
it('renders name only when email is missing', () => {
expect(formatAddress({ name: 'Alice' })).toBe('Alice');
});
it('renders email only when name is missing', () => {
expect(formatAddress({ email: 'alice@example.com' })).toBe('alice@example.com');
});
it('falls back to a placeholder when both are missing', () => {
expect(formatAddress({})).toBe('(unknown)');
});
});
describe('stripHtml', () => {
it('removes tags and decodes entities', () => {
expect(stripHtml('<p>Hello&nbsp;<b>world</b> &amp; co</p>')).toBe('Hello world & co');
});
it('drops script and style content', () => {
const html = '<style>.x{color:red}</style><p>Keep</p><script>alert(1)</script>';
expect(stripHtml(html)).toBe('Keep');
});
it('turns block boundaries into newlines', () => {
expect(stripHtml('<div>line1</div><div>line2</div>')).toBe('line1\nline2');
});
it('decodes valid numeric entities', () => {
expect(stripHtml('<p>&#65;&#x42;</p>')).toBe('AB');
});
it('does not throw on out-of-range numeric entities', () => {
expect(() => stripHtml('<p>&#999999999;</p>')).not.toThrow();
expect(stripHtml('A&#x110000;B')).toBe('A&#x110000;B');
});
});
describe('selectMsgBody', () => {
it('prefers the plain-text body', () => {
expect(selectMsgBody({ body: 'plain text', bodyHtml: '<p>html</p>' })).toEqual({
text: 'plain text',
format: 'plain',
});
});
it('falls back to stripped HTML when no plain body exists', () => {
expect(selectMsgBody({ bodyHtml: '<p>html body</p>' })).toEqual({
text: 'html body',
format: 'html',
});
});
it('reports none when no body is present', () => {
expect(selectMsgBody({})).toEqual({ text: '', format: 'none' });
});
it('decodes PidTagHtml (html) when body and bodyHtml are absent', () => {
const html = new TextEncoder().encode('<p>from pidtag</p>');
expect(selectMsgBody({ html })).toEqual({ text: 'from pidtag', format: 'html' });
});
it('prefers plain body over the PidTagHtml field', () => {
const html = new TextEncoder().encode('<p>html</p>');
expect(selectMsgBody({ body: 'plain', html })).toEqual({ text: 'plain', format: 'plain' });
});
it('falls back to PidTagHtml when bodyHtml is empty/whitespace', () => {
const html = new TextEncoder().encode('<p>pidtag body</p>');
expect(selectMsgBody({ bodyHtml: ' ', html })).toEqual({
text: 'pidtag body',
format: 'html',
});
});
});
describe('pickEmail', () => {
it('prefers a real SMTP address over a legacy EX DN', () => {
expect(pickEmail('/O=EX/OU=x/CN=alice', 'alice@example.com')).toBe('alice@example.com');
expect(pickEmail('alice@example.com', '/O=EX/OU=x/CN=alice')).toBe('alice@example.com');
});
it('falls back to the EX DN when no SMTP-looking address exists', () => {
expect(pickEmail(undefined, '/O=EX/OU=x/CN=alice')).toBe('/O=EX/OU=x/CN=alice');
});
it('returns undefined when nothing usable is provided', () => {
expect(pickEmail(undefined, undefined)).toBeUndefined();
expect(pickEmail('', ' ')).toBeUndefined();
});
});
describe('isParsedMsgValid', () => {
it('accepts a parsed Outlook message', () => {
expect(isParsedMsgValid({ dataType: 'msg' })).toBe(true);
});
it('rejects an unsupported CFBF result (old .doc/.xls, corrupted compound file)', () => {
expect(isParsedMsgValid({ error: 'Unsupported file type!', dataType: null })).toBe(false);
expect(isParsedMsgValid({ dataType: null })).toBe(false);
expect(isParsedMsgValid({ dataType: 'attachment' })).toBe(false);
});
it('treats a whitespace-only plain body as empty and uses HTML', () => {
expect(selectMsgBody({ body: ' \n ', bodyHtml: '<p>real</p>' })).toEqual({
text: 'real',
format: 'html',
});
});
});
describe('sanitizeAttachmentName', () => {
it('keeps a normal filename unchanged', () => {
expect(sanitizeAttachmentName('report.pdf', 0)).toBe('report.pdf');
});
it('strips directory components to prevent path traversal', () => {
expect(sanitizeAttachmentName('../../etc/passwd', 0)).toBe('passwd');
expect(sanitizeAttachmentName('foo/bar/baz.txt', 0)).toBe('baz.txt');
expect(sanitizeAttachmentName('a\\b\\c.doc', 0)).toBe('c.doc');
});
it('removes control characters and null bytes', () => {
expect(sanitizeAttachmentName('na\x00me.txt', 0)).toBe('name.txt');
expect(sanitizeAttachmentName('tab\tname.txt', 0)).toBe('tabname.txt');
});
it('preserves spaces inside the filename', () => {
expect(sanitizeAttachmentName('my report.pdf', 0)).toBe('my report.pdf');
});
it('falls back to an indexed name when the result is empty', () => {
expect(sanitizeAttachmentName('', 2)).toBe('attachment-3');
expect(sanitizeAttachmentName('...', 0)).toBe('attachment-1');
});
});
describe('formatMsgOutput', () => {
const baseView: MsgView = {
subject: 'Quarterly report',
from: { name: 'Alice', email: 'alice@example.com' },
to: [{ name: 'Bob', email: 'bob@example.com' }],
cc: [],
date: 'Mon, 1 Jun 2026 10:00:00 +0900',
body: { text: 'See attached.', format: 'plain' },
attachments: [],
};
it('renders the header block and body', () => {
const out = formatMsgOutput(baseView);
expect(out).toContain('Subject: Quarterly report');
expect(out).toContain('From: Alice <alice@example.com>');
expect(out).toContain('To: Bob <bob@example.com>');
expect(out).toContain('Date: Mon, 1 Jun 2026 10:00:00 +0900');
expect(out).toContain('See attached.');
});
it('lists saved attachments with their paths and sizes', () => {
const out = formatMsgOutput({
...baseView,
attachments: [{ fileName: 'report.pdf', contentLength: 2048, savedPath: 'input/report.pdf' }],
});
expect(out).toContain('Attachments (1)');
expect(out).toContain('report.pdf');
expect(out).toContain('input/report.pdf');
expect(out).toContain('2048');
});
it('shows a skip reason for attachments that were not saved', () => {
const out = formatMsgOutput({
...baseView,
attachments: [{ fileName: 'huge.bin', skipped: 'exceeds size limit' }],
});
expect(out).toContain('huge.bin');
expect(out).toContain('exceeds size limit');
});
it('notes when the body could not be extracted', () => {
const out = formatMsgOutput({ ...baseView, body: { text: '', format: 'none' } });
expect(out).toContain('(no text body)');
});
it('omits the CC line when there are no CC recipients', () => {
expect(formatMsgOutput(baseView)).not.toContain('Cc:');
});
it('keeps the attachment list when the body is truncated to budget', () => {
const longBody = 'word '.repeat(20000);
const out = assembleMsgOutput(
{
...baseView,
body: { text: longBody, format: 'plain' },
attachments: [{ fileName: 'a.pdf', contentLength: 10, savedPath: 'input/a.pdf' }],
},
100,
'mail.msg',
);
expect(out).toContain('input/a.pdf');
expect(out).toContain('Subject: Quarterly report');
expect(out.length).toBeLessThan(longBody.length);
});
it('includes the CC line when CC recipients exist', () => {
const out = formatMsgOutput({ ...baseView, cc: [{ email: 'carol@example.com' }] });
expect(out).toContain('Cc: carol@example.com');
});
});
describe('executeReadMsg (integration)', () => {
let workspace: string;
const ctx = (): ToolContext => ({ workspacePath: workspace, editAllowed: true });
beforeEach(() => {
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'readmsg-'));
fs.copyFileSync(FIXTURE, path.join(workspace, 'mail.msg'));
});
afterEach(() => {
fs.rmSync(workspace, { recursive: true, force: true });
});
it('extracts headers and body from a real .msg file', async () => {
const result = await executeReadMsg({ file_path: 'mail.msg' }, ctx());
expect(result.isError).toBeFalsy();
expect(result.output).toContain('Subject: attachmentFiles');
expect(result.output).toContain('From: hmailuser <hmailuser@hmailserver.test>');
expect(result.output).toContain('To: hmailuser@hmailserver.test');
expect(result.output).toContain('attachmentFiles');
});
it('saves attachments to input/ and lists them', async () => {
const result = await executeReadMsg({ file_path: 'mail.msg' }, ctx());
expect(result.output).toContain('Attachments (3)');
for (const [name, size] of [
['jpg.jpg', 726],
['png.png', 134],
['tif.tif', 664],
] as const) {
const saved = path.join(workspace, 'input', name);
expect(fs.existsSync(saved)).toBe(true);
expect(fs.statSync(saved).size).toBe(size);
expect(result.output).toContain(path.join('input', name));
}
});
it('rejects paths outside the workspace', async () => {
const result = await executeReadMsg({ file_path: '../../etc/passwd' }, ctx());
expect(result.isError).toBe(true);
});
it('reports a clear error for a non-.msg file', async () => {
fs.writeFileSync(path.join(workspace, 'junk.msg'), 'not a real msg file');
const result = await executeReadMsg({ file_path: 'junk.msg' }, ctx());
expect(result.isError).toBe(true);
expect(result.output).toContain('ReadMsg');
});
it('does not write attachments in a read-only phase', async () => {
const result = await executeReadMsg(
{ file_path: 'mail.msg' },
{ workspacePath: workspace, editAllowed: false },
);
expect(result.isError).toBeFalsy();
expect(fs.existsSync(path.join(workspace, 'input', 'jpg.jpg'))).toBe(false);
expect(result.output).toContain('read-only');
});
it('does not overwrite an existing file in input/', async () => {
fs.mkdirSync(path.join(workspace, 'input'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'input', 'jpg.jpg'), 'pre-existing');
const result = await executeReadMsg({ file_path: 'mail.msg' }, ctx());
expect(fs.readFileSync(path.join(workspace, 'input', 'jpg.jpg'), 'utf8')).toBe('pre-existing');
expect(fs.existsSync(path.join(workspace, 'input', 'jpg-1.jpg'))).toBe(true);
expect(result.output).toContain('jpg-1.jpg');
});
it('rejects files exceeding the configured size limit', async () => {
const result = await executeReadMsg(
{ file_path: 'mail.msg' },
{ workspacePath: workspace, editAllowed: true, toolsConfig: { officeMsgMaxSizeMb: 0.001 } },
);
expect(result.isError).toBe(true);
expect(result.output).toMatch(/size|limit|too large/i);
});
it('is registered and routed through the office module dispatch', async () => {
expect(OFFICE_TOOL_DEFS.ReadMsg).toBeDefined();
const result = await officeExecuteTool('ReadMsg', { file_path: 'mail.msg' }, ctx());
expect(result?.isError).toBeFalsy();
expect(result?.output).toContain('Subject: attachmentFiles');
});
});

416
src/engine/tools/msg.ts Normal file
View File

@ -0,0 +1,416 @@
import * as fs from 'fs';
import * as path from 'path';
import MsgReaderImport from '@kenjiuno/msgreader';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { resolveAndGuard, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
import { logger } from '../../logger.js';
// CJS/ESM interop: under native Node ESM (the built dist), a default import of
// this CommonJS package resolves to the module.exports namespace object, not the
// class — so `new MsgReaderImport()` throws "is not a constructor". Vitest/tsx
// hide this via __esModule interop. Pick the real constructor for both worlds.
const MsgReader = (
typeof MsgReaderImport === 'function'
? MsgReaderImport
: (MsgReaderImport as unknown as { default: typeof MsgReaderImport }).default
) as typeof MsgReaderImport;
type MsgReaderInstance = InstanceType<typeof MsgReader>;
const DEFAULT_MSG_MAX_SIZE_MB = 25;
export interface MsgAddress {
name?: string;
email?: string;
}
export interface MsgAttachmentMeta {
fileName: string;
contentLength?: number;
/** Relative path the attachment was written to (when saved). */
savedPath?: string;
/** Reason the attachment was not saved (mutually exclusive with savedPath). */
skipped?: string;
}
export interface MsgView {
subject?: string;
from?: MsgAddress;
to: MsgAddress[];
cc: MsgAddress[];
date?: string;
body: { text: string; format: 'plain' | 'html' | 'none' };
attachments: MsgAttachmentMeta[];
}
/** Render a single address as `Name <email>`, falling back gracefully. */
export function formatAddress(a: MsgAddress): string {
const name = a.name?.trim();
const email = a.email?.trim();
if (name && email) return `${name} <${email}>`;
if (name) return name;
if (email) return email;
return '(unknown)';
}
const NAMED_ENTITIES: Record<string, string> = {
'&nbsp;': ' ',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&apos;': "'",
};
// Decode a numeric character reference, preserving the original entity if the
// code point is out of range (broken email HTML must not crash the whole read).
function safeFromCodePoint(code: number, original: string): string {
if (!Number.isFinite(code) || code < 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
return original;
}
try {
return String.fromCodePoint(code);
} catch {
return original;
}
}
function decodeEntities(s: string): string {
let out = s.replace(/&nbsp;|&amp;|&lt;|&gt;|&quot;|&#39;|&apos;/g, (m) => NAMED_ENTITIES[m]);
out = out.replace(/&#(\d+);/g, (m, code) => safeFromCodePoint(Number(code), m));
out = out.replace(/&#x([0-9a-fA-F]+);/g, (m, code) => safeFromCodePoint(parseInt(code, 16), m));
return out;
}
/** Convert an HTML fragment into readable plain text. */
export function stripHtml(html: string): string {
let s = html;
// Drop script/style blocks including their contents.
s = s.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
// Treat <br> and block-level boundaries as newlines.
s = s.replace(/<br\s*\/?>/gi, '\n');
s = s.replace(/<\/(p|div|li|tr|h[1-6]|ul|ol|table|blockquote|section|article)\s*>/gi, '\n');
// Remove all remaining tags.
s = s.replace(/<[^>]+>/g, '');
s = decodeEntities(s);
// Normalize whitespace: collapse intra-line runs, trim each line, collapse blank runs.
s = s
.split('\n')
.map((line) => line.replace(/[^\S\n]+/g, ' ').trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
return s;
}
/** Choose the best available body text, preferring plain over HTML. */
export function selectMsgBody(fields: {
body?: string;
bodyHtml?: string;
// PidTagHtml: some HTML-only messages carry the body here as raw bytes.
html?: Uint8Array | string;
}): {
text: string;
format: 'plain' | 'html' | 'none';
} {
const plain = fields.body?.trim();
if (plain) return { text: plain, format: 'plain' };
// Try each HTML source in order; an empty/whitespace bodyHtml must not block
// the PidTagHtml fallback, so we check the stripped result of each.
const htmlSources = [
fields.bodyHtml,
fields.html != null
? typeof fields.html === 'string'
? fields.html
: Buffer.from(fields.html).toString('utf8')
: undefined,
];
for (const source of htmlSources) {
if (!source) continue;
const stripped = stripHtml(source);
if (stripped) return { text: stripped, format: 'html' };
}
return { text: '', format: 'none' };
}
/**
* Pick the most usable email address from candidates, preferring a real SMTP
* address (contains '@') over an Exchange legacy EX DN (`/O=.../CN=...`).
*/
export function pickEmail(...candidates: (string | undefined)[]): string | undefined {
const valid = candidates.map((c) => c?.trim()).filter((c): c is string => !!c);
return valid.find((c) => c.includes('@')) ?? valid[0];
}
/** Reduce an attachment name to a safe basename, never escaping the target dir. */
export function sanitizeAttachmentName(name: string, index: number): string {
// Take the last path segment across both separators (defends path traversal).
const base = name.split(/[/\\]/).pop() ?? '';
// Strip control characters and null bytes.
// eslint-disable-next-line no-control-regex
const cleaned = base.replace(/[\x00-\x1f\x7f]/g, '').trim();
// Reject names that are empty or consist only of dots/spaces.
if (!cleaned || /^[.\s]*$/.test(cleaned)) {
return `attachment-${index + 1}`;
}
return cleaned;
}
/** Find a filename that collides with neither this run nor existing files on disk. */
function resolveFreeName(dir: string, name: string, used: Set<string>): string {
const ext = path.extname(name);
const stem = name.slice(0, name.length - ext.length);
let candidate = name;
let n = 1;
while (used.has(candidate) || fs.existsSync(path.join(dir, candidate))) {
candidate = `${stem}-${n}${ext}`;
n += 1;
}
return candidate;
}
/** Build the human-readable text output for a parsed message. */
export function formatMsgOutput(view: MsgView): string {
const lines: string[] = [];
if (view.subject) lines.push(`Subject: ${view.subject}`);
if (view.from) lines.push(`From: ${formatAddress(view.from)}`);
if (view.to.length) lines.push(`To: ${view.to.map(formatAddress).join(', ')}`);
if (view.cc.length) lines.push(`Cc: ${view.cc.map(formatAddress).join(', ')}`);
if (view.date) lines.push(`Date: ${view.date}`);
const parts: string[] = [lines.join('\n')];
parts.push(view.body.format === 'none' ? '(no text body)' : view.body.text);
if (view.attachments.length) {
const attLines = [`Attachments (${view.attachments.length}):`];
for (const att of view.attachments) {
if (att.savedPath) {
const size = att.contentLength != null ? ` (${att.contentLength} bytes)` : '';
attLines.push(`- ${att.fileName}${size} -> ${att.savedPath}`);
} else {
attLines.push(`- ${att.fileName} - skipped: ${att.skipped ?? 'not saved'}`);
}
}
parts.push(attLines.join('\n'));
}
return parts.join('\n\n');
}
/**
* Assemble the final output, truncating ONLY the body to the token budget.
* Headers and the attachment list (with saved input/ paths) always survive
* attachments are already written to disk and the caller needs their paths.
*/
export function assembleMsgOutput(view: MsgView, budgetTokens: number, sourceLabel: string): string {
const shell = formatMsgOutput({ ...view, body: { text: '', format: 'plain' } });
const reserveTokens = Math.ceil(shell.length / 4) + 64;
const bodyBudget = Math.max(500, budgetTokens - reserveTokens);
const bodyText = view.body.format === 'none' ? '' : view.body.text;
const truncatedBody = truncateToBudget(bodyText, bodyBudget, { sourceLabel }).text;
return formatMsgOutput({ ...view, body: { text: truncatedBody, format: view.body.format } });
}
/**
* msgreader's getFileData() returns `{ error: 'Unsupported file type!' }` (not a
* throw) for CFBF files that aren't Outlook messages (legacy .doc/.xls, broken
* compound files). Treat anything whose root isn't a 'msg' as a read failure.
*/
export function isParsedMsgValid(fields: { error?: string; dataType?: string | null }): boolean {
return !fields.error && fields.dataType === 'msg';
}
export const READ_MSG_DEF: ToolDef = {
type: 'function',
function: {
name: 'ReadMsg',
description:
'Read an Outlook .msg email file, extracting subject/sender/recipients/body and saving attachments to input/. 詳細は ReadToolDoc({ name: "ReadMsg" }) で取得可能。',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the .msg file' },
},
required: ['file_path'],
},
},
};
interface FieldsLike {
error?: string;
dataType?: string | null;
subject?: string;
senderName?: string;
senderEmail?: string;
senderSmtpAddress?: string;
body?: string;
bodyHtml?: string;
html?: Uint8Array;
messageDeliveryTime?: string;
clientSubmitTime?: string;
recipients?: { name?: string; email?: string; smtpAddress?: string; recipType?: string }[];
attachments?: {
fileName?: string;
fileNameShort?: string;
contentLength?: number;
innerMsgContent?: boolean;
dataType?: string | null;
}[];
}
export async function executeReadMsg(
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult> {
const filePath = String(input.file_path ?? '');
if (!filePath) {
return { output: 'ReadMsg: file_path is required', isError: true };
}
let resolved: string;
try {
resolved = resolveAndGuard(ctx.workspacePath, filePath);
} catch (e) {
return { output: `ReadMsg: ${(e as Error).message}`, isError: true };
}
// Enforce a size cap before loading the whole file into memory (matches the
// other office tools, which each guard against oversized inputs).
const maxMb =
typeof ctx.toolsConfig?.officeMsgMaxSizeMb === 'number' &&
Number.isFinite(ctx.toolsConfig.officeMsgMaxSizeMb) &&
ctx.toolsConfig.officeMsgMaxSizeMb > 0
? ctx.toolsConfig.officeMsgMaxSizeMb
: DEFAULT_MSG_MAX_SIZE_MB;
try {
const sizeMb = fs.statSync(resolved).size / 1024 / 1024;
if (sizeMb > maxMb) {
return {
output: `ReadMsg: file size ${sizeMb.toFixed(1)}MB exceeds limit of ${maxMb}MB`,
isError: true,
};
}
} catch (e) {
return { output: `ReadMsg: cannot stat file: ${(e as Error).message}`, isError: true };
}
let buffer: Buffer;
try {
buffer = fs.readFileSync(resolved);
} catch (e) {
return { output: `ReadMsg: cannot read file: ${(e as Error).message}`, isError: true };
}
// .msg is an OLE2 / CFBF compound file. Validate the magic header up front:
// MsgReader silently returns an empty result for non-CFBF data instead of throwing.
const CFBF_MAGIC = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
if (buffer.length < 8 || !buffer.subarray(0, 8).equals(CFBF_MAGIC)) {
return {
output: `ReadMsg: not a valid Outlook .msg file (bad signature): ${path.basename(resolved)}`,
isError: true,
};
}
let reader: MsgReaderInstance;
let fields: FieldsLike;
try {
// Copy into a standalone ArrayBuffer (MsgReader rejects Node Buffers).
const arrayBuffer = new Uint8Array(buffer).buffer;
reader = new MsgReader(arrayBuffer);
fields = reader.getFileData() as unknown as FieldsLike;
} catch (e) {
return {
output: `ReadMsg: failed to parse .msg (is this a valid Outlook message?): ${(e as Error).message}`,
isError: true,
};
}
if (!isParsedMsgValid(fields)) {
return {
output: `ReadMsg: not a parseable Outlook message${fields.error ? ` (${fields.error})` : ''}: ${path.basename(resolved)}`,
isError: true,
};
}
const recipients = fields.recipients ?? [];
const to = recipients
.filter((r) => (r.recipType ?? 'to') === 'to')
.map((r) => ({ name: r.name, email: pickEmail(r.smtpAddress, r.email) }));
const cc = recipients
.filter((r) => r.recipType === 'cc')
.map((r) => ({ name: r.name, email: pickEmail(r.smtpAddress, r.email) }));
const inputDir = path.join(ctx.workspacePath, 'input');
const attachments: MsgAttachmentMeta[] = [];
const rawAttachments = fields.attachments ?? [];
const usedNames = new Set<string>();
rawAttachments.forEach((att, i) => {
const rawName = att.fileName || att.fileNameShort || '';
const baseName = sanitizeAttachmentName(rawName, i);
// Read-only movements (verify etc.) must not mutate the workspace.
if (!ctx.editAllowed) {
attachments.push({
fileName: baseName,
contentLength: att.contentLength,
skipped: 'read-only phase (attachment not saved)',
});
return;
}
if (att.innerMsgContent) {
attachments.push({
fileName: baseName,
contentLength: att.contentLength,
skipped: 'embedded message (open separately)',
});
return;
}
// Resolve a name that collides with neither an earlier attachment this run
// nor a file already present in input/ (user uploads, prior extractions).
const name = resolveFreeName(inputDir, baseName, usedNames);
usedNames.add(name);
try {
const data = reader.getAttachment(att as never);
fs.mkdirSync(inputDir, { recursive: true });
const dest = path.join(inputDir, name);
fs.writeFileSync(dest, Buffer.from(data.content));
attachments.push({
fileName: name,
contentLength: data.content.length,
savedPath: path.join('input', name),
});
} catch (e) {
logger.warn(`[ReadMsg] failed to save attachment ${name}: ${(e as Error).message}`);
attachments.push({
fileName: name,
contentLength: att.contentLength,
skipped: `extraction failed: ${(e as Error).message}`,
});
}
});
const view: MsgView = {
subject: fields.subject,
from: (() => {
const email = pickEmail(fields.senderSmtpAddress, fields.senderEmail);
return fields.senderName || email ? { name: fields.senderName, email } : undefined;
})(),
to,
cc,
date: fields.messageDeliveryTime || fields.clientSubmitTime,
body: selectMsgBody(fields),
attachments,
};
const output = assembleMsgOutput(view, getToolOutputBudgetTokens(ctx), path.basename(resolved));
logger.info(
`[ReadMsg] ${path.basename(resolved)}: attachments=${attachments.length} bodyFormat=${view.body.format}`,
);
return { output, isError: false };
}

View File

@ -10,6 +10,7 @@ import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js'; import type { ToolContext, ToolResult } from './core.js';
import { resolveAndGuard, resolveOutputPathWithin, truncateToBudget, getToolOutputBudgetTokens } from './core.js'; import { resolveAndGuard, resolveOutputPathWithin, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
import { resolveThemePalette, extractSheetStyles } from './excel-styles.js'; import { resolveThemePalette, extractSheetStyles } from './excel-styles.js';
import { READ_MSG_DEF, executeReadMsg } from './msg.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { callVisionModel, resolveImagePath } from './image.js'; import { callVisionModel, resolveImagePath } from './image.js';
import type { import type {
@ -351,6 +352,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
ReadDocx: READ_DOCX_DEF, ReadDocx: READ_DOCX_DEF,
ReadPdf: READ_PDF_DEF, ReadPdf: READ_PDF_DEF,
ReadPPTX: READ_PPTX_DEF, ReadPPTX: READ_PPTX_DEF,
ReadMsg: READ_MSG_DEF,
SplitExcelSheets: SPLIT_EXCEL_SHEETS_DEF, SplitExcelSheets: SPLIT_EXCEL_SHEETS_DEF,
SplitDocxSections: SPLIT_DOCX_SECTIONS_DEF, SplitDocxSections: SPLIT_DOCX_SECTIONS_DEF,
PdfToImages: PDF_TO_IMAGES_DEF, PdfToImages: PDF_TO_IMAGES_DEF,
@ -2284,6 +2286,8 @@ export async function executeTool(
return executeReadPdf(input, ctx); return executeReadPdf(input, ctx);
case 'ReadPPTX': case 'ReadPPTX':
return executeReadPptx(input, ctx); return executeReadPptx(input, ctx);
case 'ReadMsg':
return executeReadMsg(input, ctx);
case 'SplitExcelSheets': case 'SplitExcelSheets':
return executeSplitExcelSheets(input, ctx); return executeSplitExcelSheets(input, ctx);
case 'SplitDocxSections': case 'SplitDocxSections':

View File

@ -758,3 +758,43 @@ describe('OpenAICompatClient gateway sentinel error events', () => {
expect(events.at(-1)?.type).toBe('done'); expect(events.at(-1)?.type).toBe('done');
}); });
}); });
describe('OpenAICompatClient tool-call flush at stream end', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
// Some OpenAI-compat backends finish a forced/named tool call with
// finish_reason 'stop' (or omit it) instead of 'tool_calls'. The client must
// still surface the accumulated tool_use at the done boundary, not drop it.
it('emits tool_use when the stream ends on finish_reason "stop" (via [DONE])', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeSseResponse([
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'do_it', arguments: '{"a":1}' } }] }, finish_reason: null }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
'[DONE]',
])));
const events = await collectEvents(new OpenAICompatClient('http://h:1/v1', 'm'), [{ role: 'user', content: 'q' }]);
const toolUse = events.find((e) => e.type === 'tool_use');
expect(toolUse).toMatchObject({ type: 'tool_use', name: 'do_it', input: { a: 1 } });
// done still follows the tool_use
expect(events[events.length - 1].type).toBe('done');
});
it('emits tool_use when the stream ends at EOF without [DONE]', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeSseResponse([
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c2', function: { name: 'fn', arguments: '{}' } }] }, finish_reason: null }] },
])));
const events = await collectEvents(new OpenAICompatClient('http://h:1/v1', 'm'), [{ role: 'user', content: 'q' }]);
expect(events.filter((e) => e.type === 'tool_use')).toHaveLength(1);
});
it('does not double-emit when finish_reason "tool_calls" already flushed', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeSseResponse([
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c3', function: { name: 'fn', arguments: '{}' } }] }, finish_reason: 'tool_calls' }] },
'[DONE]',
])));
const events = await collectEvents(new OpenAICompatClient('http://h:1/v1', 'm'), [{ role: 'user', content: 'q' }]);
expect(events.filter((e) => e.type === 'tool_use')).toHaveLength(1);
});
});

View File

@ -1,5 +1,6 @@
import { getDefaultProviderRetryConfig, type ProviderRetryConfig } from '../config.js'; import { getDefaultProviderRetryConfig, type ProviderRetryConfig } from '../config.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { recordLlmUsage } from './usage-recorder.js';
import { import {
IMAGE_CONTENT_TOKENS, IMAGE_CONTENT_TOKENS,
estimateMessageTokens, estimateMessageTokens,
@ -238,6 +239,33 @@ interface ToolCallAccumulator {
}; };
} }
/**
* Emit accumulated tool calls as `tool_use` events (sorted by index) and
* clear the accumulator. Called both on `finish_reason === 'tool_calls'` and
* at stream end some OpenAI-compat backends finish a forced/named tool call
* with finish_reason 'stop', so draining at the done boundary keeps the call
* from being silently dropped. Returns an empty array when nothing is pending,
* so the done-site flush is a no-op for the normal 'tool_calls' path (the map
* is already cleared).
*/
function drainToolCalls(accumulators: Map<number, ToolCallAccumulator>): LLMEvent[] {
if (accumulators.size === 0) return [];
const events: LLMEvent[] = [];
const sortedIndices = Array.from(accumulators.keys()).sort((a, b) => a - b);
for (const idx of sortedIndices) {
const acc = accumulators.get(idx)!;
let input: Record<string, unknown> = {};
try {
input = JSON.parse(acc.function.arguments) as Record<string, unknown>;
} catch {
logger.warn(`OpenAICompatClient: failed to parse tool arguments: ${acc.function.arguments}`);
}
events.push({ type: 'tool_use', id: acc.id, name: acc.function.name, input });
}
accumulators.clear();
return events;
}
export interface OpenAICompatClientOptions { export interface OpenAICompatClientOptions {
/** /**
* When true, this client treats its endpoint as an LLM gateway / proxy * When true, this client treats its endpoint as an LLM gateway / proxy
@ -252,6 +280,26 @@ export interface OpenAICompatClientOptions {
proxy?: boolean; proxy?: boolean;
} }
/**
* Per-call attribution context. Threaded from each call site so the
* usage recorder can attribute the completion to a MAESTRO user. Absent
* userId falls back to the 'system' sentinel (never NULL).
*/
export interface LlmCallContext {
userId?: string;
}
/**
* Per-call request-shaping overrides. Used by callers that need to force a
* tool (reflection's forced submit_reflection) or pin sampling temperature.
* Kept off the hot agent path (which leaves these unset).
*/
export interface LlmRequestOptions {
temperature?: number;
/** OpenAI tool_choice (e.g. `{ type: 'function', function: { name } }`). */
toolChoice?: unknown;
}
export class OpenAICompatClient { export class OpenAICompatClient {
private retryConfig: ProviderRetryConfig; private retryConfig: ProviderRetryConfig;
readonly timeoutMs: number; readonly timeoutMs: number;
@ -292,7 +340,43 @@ export class OpenAICompatClient {
this.preferredBackendId = backendId; this.preferredBackendId = backendId;
} }
async *chat(messages: Message[], tools?: ToolDef[], externalSignal?: AbortSignal): AsyncGenerator<LLMEvent> { /**
* Record one successful completion to the per-user daily usage ledger.
* Called from the single done funnel (both the `[DONE]` and EOF exits)
* so the two terminal paths can never double-count. `source` is the
* client's proxy flag, `model` is the first observed chunk.model (routing
* key fallback), `route` is the gateway backendId (proxy) or endpoint host
* (direct). Never records on abort / timeout / error (those don't `done`).
*/
private finalizeDone(
usage: { prompt_tokens: number; completion_tokens: number } | undefined,
observedModel: string,
observedBackendId: string,
context?: LlmCallContext,
): void {
const source: 'gateway' | 'direct' = this.proxy ? 'gateway' : 'direct';
const model = observedModel || this.model || 'unknown';
let route = 'unknown';
if (this.proxy) {
route = observedBackendId || 'unknown';
} else {
try {
route = new URL(this.baseUrl).host || 'unknown';
} catch {
route = 'unknown';
}
}
recordLlmUsage({
userId: context?.userId || 'system',
source,
model,
route,
tokensIn: usage?.prompt_tokens ?? 0,
tokensOut: usage?.completion_tokens ?? 0,
});
}
async *chat(messages: Message[], tools?: ToolDef[], externalSignal?: AbortSignal, context?: LlmCallContext, requestOptions?: LlmRequestOptions): AsyncGenerator<LLMEvent> {
const controller = new AbortController(); const controller = new AbortController();
// アイドルタイムアウト: チャンク受信のたびにリセットされる // アイドルタイムアウト: チャンク受信のたびにリセットされる
let timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); let timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
@ -338,6 +422,12 @@ export class OpenAICompatClient {
if (tools && tools.length > 0) { if (tools && tools.length > 0) {
body['tools'] = tools; body['tools'] = tools;
} }
if (requestOptions?.temperature != null) {
body['temperature'] = requestOptions.temperature;
}
if (requestOptions?.toolChoice != null) {
body['tool_choice'] = requestOptions.toolChoice;
}
// Block oversized prompts before the HTTP request so callers see a // Block oversized prompts before the HTTP request so callers see a
// structured error instead of an opaque HTTP 400. The runtime context // structured error instead of an opaque HTTP 400. The runtime context
// limit is fetched per-model (see fetchOllamaContextLimit) and passed // limit is fetched per-model (see fetchOllamaContextLimit) and passed
@ -358,6 +448,10 @@ export class OpenAICompatClient {
for (let attempt = 1; attempt <= maxAttempts; attempt++) { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
let response: Response | null = null; let response: Response | null = null;
// Usage attribution captured during this attempt's stream. Reset
// per attempt; only the attempt that reaches `done` records.
let observedModel = '';
let observedBackendId = '';
try { try {
response = await fetch(`${this.baseUrl}/chat/completions`, { response = await fetch(`${this.baseUrl}/chat/completions`, {
@ -440,6 +534,7 @@ export class OpenAICompatClient {
const rawBackendId = response.headers.get('x-litellm-model-id'); const rawBackendId = response.headers.get('x-litellm-model-id');
const backendId = rawBackendId ? rawBackendId.trim() : ''; const backendId = rawBackendId ? rawBackendId.trim() : '';
if (backendId.length > 0) { if (backendId.length > 0) {
observedBackendId = backendId;
const rawCacheKey = response.headers.get('x-litellm-cache-key'); const rawCacheKey = response.headers.get('x-litellm-cache-key');
const cacheKey = rawCacheKey ? rawCacheKey.trim() : ''; const cacheKey = rawCacheKey ? rawCacheKey.trim() : '';
yield { type: 'backend', backendId, cacheKey: cacheKey.length > 0 ? cacheKey : null }; yield { type: 'backend', backendId, cacheKey: cacheKey.length > 0 ? cacheKey : null };
@ -475,7 +570,13 @@ export class OpenAICompatClient {
const data = trimmed.slice('data: '.length); const data = trimmed.slice('data: '.length);
if (data === '[DONE]') { if (data === '[DONE]') {
// Flush any tool calls the backend left un-finished (some
// OpenAI-compat servers end a forced/named tool call with
// finish_reason 'stop' instead of 'tool_calls'). Without this
// the accumulated call would be silently dropped.
yield* drainToolCalls(toolCallAccumulators);
// usage 付きで done を emit // usage 付きで done を emit
this.finalizeDone(usage, observedModel, observedBackendId, context);
yield { type: 'done', usage }; yield { type: 'done', usage };
return; return;
} }
@ -488,6 +589,15 @@ export class OpenAICompatClient {
continue; continue;
} }
// Real model name for usage attribution. The gateway passes
// chunks through byte-for-byte, so chunk.model is the actual
// backend model for both direct and gateway paths. First
// non-empty value wins.
if (!observedModel) {
const m = chunk['model'];
if (typeof m === 'string' && m.length > 0) observedModel = m;
}
// AAO Gateway / LiteLLM sentinel error event: // AAO Gateway / LiteLLM sentinel error event:
// data: {"error":{"type":"gateway_shutdown","message":"..."}} // data: {"error":{"type":"gateway_shutdown","message":"..."}}
// gateway_shutdown / gateway_timeout は他 worker に retry すれば // gateway_shutdown / gateway_timeout は他 worker に retry すれば
@ -596,23 +706,7 @@ export class OpenAICompatClient {
// tool_calls が完了したら emit // tool_calls が完了したら emit
if (finishReason === 'tool_calls') { if (finishReason === 'tool_calls') {
const sortedIndices = Array.from(toolCallAccumulators.keys()).sort((a, b) => a - b); yield* drainToolCalls(toolCallAccumulators);
for (const idx of sortedIndices) {
const acc = toolCallAccumulators.get(idx)!;
let input: Record<string, unknown> = {};
try {
input = JSON.parse(acc.function.arguments) as Record<string, unknown>;
} catch {
logger.warn(`OpenAICompatClient: failed to parse tool arguments: ${acc.function.arguments}`);
}
yield {
type: 'tool_use',
id: acc.id,
name: acc.function.name,
input,
};
}
toolCallAccumulators.clear();
} }
} }
} }
@ -644,6 +738,8 @@ export class OpenAICompatClient {
} }
// [DONE] なしにストリームが終了した場合 // [DONE] なしにストリームが終了した場合
yield* drainToolCalls(toolCallAccumulators);
this.finalizeDone(usage, observedModel, observedBackendId, context);
yield { type: 'done', usage }; yield { type: 'done', usage };
return; return;
} }

52
src/llm/usage-recorder.ts Normal file
View File

@ -0,0 +1,52 @@
import { logger } from '../logger.js';
/**
* Per-user LLM usage event, emitted by OpenAICompatClient once per
* successful chat completion (gateway-routed and direct alike). The
* recorder is a process-global sink so every call site agent-loop,
* title generation, piece classification, reflection is covered
* without threading a Repository handle through every client
* construction. This is the single chokepoint the design relies on to
* avoid the propagation leaks this codebase has repeatedly hit.
*
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
*/
export interface LlmUsageEvent {
/** Owner id, or 'local' (no-auth) / 'system' (ownerless) sentinel. NOT NULL. */
userId: string;
source: 'gateway' | 'direct';
/** Real model name (chunk.model) with routing-key / 'unknown' fallback. */
model: string;
/** Backend server name (gateway backendId / direct host) or 'unknown'. */
route: string;
tokensIn: number;
tokensOut: number;
}
export type LlmUsageRecorder = (event: LlmUsageEvent) => void;
let recorder: LlmUsageRecorder | null = null;
/**
* Install (or clear with null) the process-global usage recorder. Called
* once during bootstrap with a thin wrapper over Repository.incrementLlmUsage.
*/
export function setLlmUsageRecorder(fn: LlmUsageRecorder | null): void {
recorder = fn;
}
/**
* Record one successful completion. No-op when no recorder is installed
* (e.g. unit tests, gateway server process). Writes are best-effort: a DB
* hiccup must never kill the agent's stream, so failures are swallowed
* with a warn.
*/
export function recordLlmUsage(event: LlmUsageEvent): void {
if (!recorder) return;
try {
recorder(event);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`[usage-recorder] record failed (non-fatal): ${msg}`);
}
}

View File

@ -0,0 +1,125 @@
/**
* Usage recording at the OpenAICompatClient completion boundary.
*
* Verifies the single finalizeDone funnel:
* - a successful stream records exactly one event with model (chunk.model),
* tokens from usage, source=direct, route=endpoint host
* - a usage-less but successful stream still records (tokens 0)
* - an error stream records nothing (abort/timeout/error never `done`)
* - proxy mode records source=gateway, route=backendId (x-litellm-model-id)
* - no recorder installed = no-op (never throws)
*
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { OpenAICompatClient } from './openai-compat.js';
import { getDefaultProviderRetryConfig } from '../config.js';
import { setLlmUsageRecorder, type LlmUsageEvent } from './usage-recorder.js';
const NO_RETRY = { ...getDefaultProviderRetryConfig(), maxAttempts: 1 };
function sseResponse(chunks: unknown[], headers: Record<string, string> = {}): Response {
const lines = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`);
lines.push('data: [DONE]\n\n');
const encoder = new TextEncoder();
let i = 0;
return {
ok: true,
status: 200,
headers: { get: (k: string) => headers[k.toLowerCase()] ?? null },
body: {
getReader: () => ({
read: async () =>
i < lines.length
? { done: false, value: encoder.encode(lines[i++]) }
: { done: true, value: undefined },
releaseLock: () => {},
}),
},
} as unknown as Response;
}
const textChunk = (model: string) => ({ model, choices: [{ delta: { content: 'hi' }, finish_reason: null }] });
const usageChunk = (pin: number, pout: number) => ({ choices: [], usage: { prompt_tokens: pin, completion_tokens: pout } });
async function drain(client: OpenAICompatClient, ctx?: { userId?: string }): Promise<void> {
for await (const _ of client.chat([{ role: 'user', content: 'q' }], undefined, undefined, ctx)) {
// discard
}
}
afterEach(() => {
setLlmUsageRecorder(null);
vi.unstubAllGlobals();
});
describe('LLM usage recording', () => {
it('records one direct event with model, tokens, and endpoint-host route', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([textChunk('llama-3.1-8b'), usageChunk(120, 30)])));
const client = new OpenAICompatClient('http://gpu-1.local:11434/v1', 'role-auto');
await drain(client, { userId: 'u1' });
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
userId: 'u1', source: 'direct', model: 'llama-3.1-8b', route: 'gpu-1.local:11434',
tokensIn: 120, tokensOut: 30,
});
});
it('records a usage-less success with zero tokens', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([textChunk('m')])));
await drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' });
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ tokensIn: 0, tokensOut: 0 });
});
it('falls back userId to system and model to routing key', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
// No `model` field in the chunk → fall back to the client's routing key.
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([{ choices: [{ delta: { content: 'x' }, finish_reason: null }] }, usageChunk(1, 1)])));
await drain(new OpenAICompatClient('http://h:1/v1', 'routing-key')); // no context
expect(events[0]).toMatchObject({ userId: 'system', model: 'routing-key' });
});
it('records nothing on an error stream', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false, status: 500, headers: { get: () => null }, text: () => Promise.resolve('boom'),
} as unknown as Response));
await drain(new OpenAICompatClient('http://h:1/v1', 'm', undefined, NO_RETRY), { userId: 'u1' });
expect(events).toHaveLength(0);
});
it('records source=gateway with backendId route in proxy mode', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
sseResponse([textChunk('qwen-72b'), usageChunk(5, 2)], { 'x-litellm-model-id': 'pool-a-node-3' }),
));
const client = new OpenAICompatClient(
'http://gateway:4000/v1', 'role-quality', undefined, undefined, undefined, undefined, undefined, undefined,
{ proxy: true },
);
await drain(client, { userId: 'u9' });
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ source: 'gateway', model: 'qwen-72b', route: 'pool-a-node-3' });
});
it('is a no-op when no recorder is installed', async () => {
setLlmUsageRecorder(null);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([textChunk('m'), usageChunk(1, 1)])));
await expect(drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' })).resolves.toBeUndefined();
});
});

View File

@ -7,6 +7,7 @@ import { normalizeToolNameForMetric, BUILTIN_TOOL_NAMES } from './tool-name-allo
import { createWorkerMetrics } from './worker-metrics.js'; import { createWorkerMetrics } from './worker-metrics.js';
import { TOOL_DEFS as SLIDE_DEFS } from '../engine/tools/slide.js'; import { TOOL_DEFS as SLIDE_DEFS } from '../engine/tools/slide.js';
import { TOOL_DEFS as MSLEARN_DEFS } from '../engine/tools/ms-learn.js'; import { TOOL_DEFS as MSLEARN_DEFS } from '../engine/tools/ms-learn.js';
import { TOOL_DEFS as OFFICE_DEFS } from '../engine/tools/office.js';
describe('normalizeToolNameForMetric', () => { describe('normalizeToolNameForMetric', () => {
it('passes built-in tool names through verbatim', () => { it('passes built-in tool names through verbatim', () => {
@ -69,15 +70,19 @@ describe('normalizeToolNameForMetric', () => {
// so every real call collapsed to 'unknown' in metrics. Pin the allowlist to // so every real call collapsed to 'unknown' in metrics. Pin the allowlist to
// the actual TOOL_DEFS so it can't drift again. // the actual TOOL_DEFS so it can't drift again.
describe('metrics allowlist ↔ real tool definitions (audit regression)', () => { describe('metrics allowlist ↔ real tool definitions (audit regression)', () => {
const realNames = [...Object.keys(SLIDE_DEFS), ...Object.keys(MSLEARN_DEFS)]; const realNames = [
...Object.keys(SLIDE_DEFS),
...Object.keys(MSLEARN_DEFS),
...Object.keys(OFFICE_DEFS),
];
it('every real slide/ms-learn tool normalizes to itself (not "unknown")', () => { it('every real slide/ms-learn/office tool normalizes to itself (not "unknown")', () => {
for (const name of realNames) { for (const name of realNames) {
expect(normalizeToolNameForMetric(name)).toBe(name); expect(normalizeToolNameForMetric(name)).toBe(name);
} }
}); });
it('every real slide/ms-learn tool is present in BUILTIN_TOOL_NAMES', () => { it('every real slide/ms-learn/office tool is present in BUILTIN_TOOL_NAMES', () => {
for (const name of realNames) { for (const name of realNames) {
expect(BUILTIN_TOOL_NAMES.has(name)).toBe(true); expect(BUILTIN_TOOL_NAMES.has(name)).toBe(true);
} }

View File

@ -43,7 +43,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
// image.ts // image.ts
'AnnotateImage', 'ReadImage', 'AnnotateImage', 'ReadImage',
// office.ts // office.ts
'PdfToImages', 'ReadDocx', 'ReadExcel', 'ReadPdf', 'ReadPPTX', 'PdfToImages', 'ReadDocx', 'ReadExcel', 'ReadMsg', 'ReadPdf', 'ReadPPTX',
'SplitDocxSections', 'SplitExcelSheets', 'SplitDocxSections', 'SplitExcelSheets',
// data.ts // data.ts
'SQLite', 'SQLite',

View File

@ -0,0 +1,70 @@
import { describe, it, expect, afterEach } from 'vitest';
import type { Server } from 'http';
import * as http from 'http';
import { buildRedirectLocation, createHttpRedirectServer } from './http-redirect.js';
describe('buildRedirectLocation', () => {
it('pins the host and preserves path + query', () => {
expect(buildRedirectLocation('app.lan', 9876, '/tasks?id=5')).toBe('https://app.lan:9876/tasks?id=5');
});
it('buildRedirectLocation never echoes a caller-supplied host string', () => {
// The function takes pinnedHost as a parameter — it cannot echo an attacker host
// because the host is always taken from the first argument, not from reqUrl.
// Pass a reqUrl that looks like it contains a host to confirm it is ignored.
const loc = buildRedirectLocation('app.lan', 9876, '/');
expect(loc).toBe('https://app.lan:9876/');
expect(loc).not.toContain('evil.attacker.com');
});
it('strips CR/LF/space from the path to prevent header injection', () => {
const loc = buildRedirectLocation('app.lan', 9876, '/a\r\nSet-Cookie: x=1 b');
expect(loc).not.toMatch(/[\r\n ]/);
});
it('omits the port for 443', () => {
expect(buildRedirectLocation('app.lan', 443, '/')).toBe('https://app.lan/');
});
it('prefixes a leading slash when the url lacks one', () => {
expect(buildRedirectLocation('app.lan', 9876, 'x')).toBe('https://app.lan:9876/x');
});
});
describe('createHttpRedirectServer', () => {
let server: Server;
afterEach(() => new Promise<void>((r) => (server ? server.close(() => r()) : r())));
it('responds 301 with the pinned https Location', async () => {
server = createHttpRedirectServer({ httpsPort: 9876, pinnedHost: 'app.lan' });
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
const res = await fetch(`http://127.0.0.1:${port}/x?y=1`, { redirect: 'manual' });
expect(res.status).toBe(301);
expect(res.headers.get('location')).toBe('https://app.lan:9876/x?y=1');
});
it('ignores an attacker-controlled Host header (open-redirect guard)', async () => {
const srv = createHttpRedirectServer({ httpsPort: 9876, pinnedHost: 'app.lan' });
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', r));
try {
const addr = srv.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
// Use node's http.request so we can set the Host header explicitly.
// (Some fetch implementations silently drop Host overrides.)
const loc = await new Promise<string>((resolve, reject) => {
const req = http.request(
{ hostname: '127.0.0.1', port, path: '/x', method: 'GET', headers: { Host: 'evil.attacker.com' } },
(res) => resolve(res.headers['location'] ?? ''),
);
req.on('error', reject);
req.end();
});
expect(loc).toBe('https://app.lan:9876/x');
expect(loc).not.toContain('evil.attacker.com');
} finally {
await new Promise<void>((r) => srv.close(() => r()));
}
});
});

21
src/net/http-redirect.ts Normal file
View File

@ -0,0 +1,21 @@
import { createServer, type Server } from 'http';
/** Build the redirect target from a PINNED host — never the request Host header. */
export function buildRedirectLocation(pinnedHost: string, httpsPort: number, reqUrl: string): string {
const safePath = reqUrl.replace(/[\r\n ]/g, '');
const hostPort = httpsPort === 443 ? pinnedHost : `${pinnedHost}:${httpsPort}`;
return `https://${hostPort}${safePath.startsWith('/') ? safePath : `/${safePath}`}`;
}
export interface RedirectServerOpts {
httpsPort: number;
pinnedHost: string;
}
export function createHttpRedirectServer(opts: RedirectServerOpts): Server {
return createServer((req, res) => {
const location = buildRedirectLocation(opts.pinnedHost, opts.httpsPort, req.url ?? '/');
res.writeHead(301, { Location: location });
res.end();
});
}

View File

@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync, writeFileSync, statSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { X509Certificate } from 'crypto';
import { ensureSelfSignedCert } from './self-signed.js';
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'selfsigned-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
describe('ensureSelfSignedCert', () => {
it('generates a cert+key pair with localhost/127.0.0.1 in the SAN', () => {
const { cert, key } = ensureSelfSignedCert(dir, []);
expect(cert).toContain('BEGIN CERTIFICATE');
expect(key).toContain('PRIVATE KEY');
const san = new X509Certificate(cert).subjectAltName ?? '';
expect(san).toContain('localhost');
expect(san).toContain('127.0.0.1');
});
it('includes extra SAN hosts', () => {
const { cert } = ensureSelfSignedCert(dir, ['maestro.lan']);
expect(new X509Certificate(cert).subjectAltName ?? '').toContain('maestro.lan');
});
it('includes a redirect-style hostname in the SAN (guards server.ts augmentation)', () => {
// When an operator sets redirect_host: app.lan the server folds that name
// into selfSignedHosts before calling resolveTlsOptions — this test guards
// that the augmented host is actually present in the emitted certificate.
const { cert } = ensureSelfSignedCert(dir, ['redirect.example']);
expect(new X509Certificate(cert).subjectAltName ?? '').toContain('redirect.example');
});
it('reuses an existing valid pair (stable across calls)', () => {
const a = ensureSelfSignedCert(dir, []);
const b = ensureSelfSignedCert(dir, []);
expect(b.cert).toBe(a.cert);
expect(b.key).toBe(a.key);
});
it('regenerates when the existing pair is mismatched/corrupt', () => {
const a = ensureSelfSignedCert(dir, []);
writeFileSync(join(dir, 'cert.pem'), 'not a cert');
const b = ensureSelfSignedCert(dir, []);
expect(b.cert).toContain('BEGIN CERTIFICATE');
expect(b.cert).not.toBe(a.cert);
});
it('writes the key file with 0600 permissions', () => {
ensureSelfSignedCert(dir, []);
expect(statSync(join(dir, 'key.pem')).mode & 0o777).toBe(0o600);
});
it('writes the cert file with 0600 permissions', () => {
ensureSelfSignedCert(dir, []);
expect(statSync(join(dir, 'cert.pem')).mode & 0o777).toBe(0o600);
});
it('treats host:port as a DNS name, not an IP address (regression for isIP fix)', () => {
const { cert } = ensureSelfSignedCert(dir, ['myhost:8443']);
const san = new X509Certificate(cert).subjectAltName ?? '';
// Must appear as a DNS-typed SAN entry, not an IP Address entry
expect(san).toContain('DNS:myhost:8443');
expect(san).not.toContain('IP Address:myhost:8443');
});
it('always includes localhost even when hostname detection is junk', () => {
const { cert } = ensureSelfSignedCert(dir, [], { hostname: '' });
expect(new X509Certificate(cert).subjectAltName ?? '').toContain('localhost');
});
});

72
src/net/self-signed.ts Normal file
View File

@ -0,0 +1,72 @@
import { readFileSync, writeFileSync, existsSync, renameSync, mkdirSync } from 'fs';
import { join } from 'path';
import { hostname as osHostname } from 'os';
import { isIP } from 'net';
import { X509Certificate, createPrivateKey } from 'crypto';
import { logger } from '../logger.js';
import selfsigned from 'selfsigned';
export interface EnsureCertOpts {
/** Override host detection (tests). */
hostname?: string;
}
const CERT_FILE = 'cert.pem';
const KEY_FILE = 'key.pem';
function sanHosts(extra: string[], hostname: string): string[] {
const base = ['localhost', '127.0.0.1', '::1'];
const host = hostname.trim();
if (host && host !== 'localhost') base.push(host);
return [...new Set([...base, ...extra.filter((h) => h.trim().length > 0)])];
}
function isIp(value: string): boolean {
return isIP(value) !== 0;
}
// Validates that the cert and key match each other; does NOT check expiry by design
// (the caller regenerates on restart; the Settings UI surfaces expiry).
function readValidPair(dir: string): { cert: string; key: string } | null {
const certPath = join(dir, CERT_FILE);
const keyPath = join(dir, KEY_FILE);
if (!existsSync(certPath) || !existsSync(keyPath)) return null;
try {
const cert = readFileSync(certPath, 'utf-8');
const key = readFileSync(keyPath, 'utf-8');
const x509 = new X509Certificate(cert);
const priv = createPrivateKey(key);
if (!x509.checkPrivateKey(priv)) return null;
return { cert, key };
} catch (err) {
logger.warn(`[self-signed] existing cert/key unreadable, will regenerate: ${(err as Error).message}`);
return null;
}
}
export function ensureSelfSignedCert(
dir: string,
extraHosts: string[],
opts: EnsureCertOpts = {},
): { cert: string; key: string } {
const existing = readValidPair(dir);
if (existing) return existing;
mkdirSync(dir, { recursive: true });
const hosts = sanHosts(extraHosts, opts.hostname ?? osHostname());
const altNames = hosts.map((h) => (isIp(h) ? { type: 7, ip: h } : { type: 2, value: h }));
const pems = selfsigned.generate([{ name: 'commonName', value: hosts[0] }], {
keySize: 2048,
days: 825,
algorithm: 'sha256',
extensions: [{ name: 'subjectAltName', altNames }],
});
const certPath = join(dir, CERT_FILE);
const keyPath = join(dir, KEY_FILE);
writeFileSync(`${certPath}.tmp`, pems.cert, { mode: 0o600 });
writeFileSync(`${keyPath}.tmp`, pems.private, { mode: 0o600 });
renameSync(`${keyPath}.tmp`, keyPath);
renameSync(`${certPath}.tmp`, certPath);
return { cert: pems.cert, key: pems.private };
}

View File

@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { resolveTlsOptions } from './tls-options.js';
import { SERVER_TLS_DEFAULTS } from '../server/config.js';
import { ensureSelfSignedCert } from './self-signed.js';
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tlsopt-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
describe('resolveTlsOptions', () => {
it('returns a self-signed pair when no cert_file is configured', () => {
const opts = resolveTlsOptions({ ...SERVER_TLS_DEFAULTS, enabled: true, selfSignedDir: dir });
expect(opts.cert).toContain('BEGIN CERTIFICATE');
expect(opts.key).toContain('PRIVATE KEY');
expect(opts.minVersion).toBe('TLSv1.2');
});
it('uses provided PEM files when cert_file and key_file are set', () => {
const { cert, key } = ensureSelfSignedCert(dir, []);
writeFileSync(join(dir, 'my.crt'), cert);
writeFileSync(join(dir, 'my.key'), key);
const opts = resolveTlsOptions({
...SERVER_TLS_DEFAULTS, enabled: true,
certFile: join(dir, 'my.crt'), keyFile: join(dir, 'my.key'),
});
expect(opts.cert).toBe(cert);
expect(opts.key).toBe(key);
});
it('fails closed (throws) when cert_file is unreadable — never self-signs', () => {
expect(() =>
resolveTlsOptions({
...SERVER_TLS_DEFAULTS, enabled: true,
certFile: join(dir, 'missing.crt'), keyFile: join(dir, 'missing.key'),
}),
).toThrow();
});
it('fails closed when the provided cert is malformed PEM', () => {
writeFileSync(join(dir, 'bad.crt'), 'garbage');
writeFileSync(join(dir, 'bad.key'), 'garbage');
expect(() =>
resolveTlsOptions({
...SERVER_TLS_DEFAULTS, enabled: true,
certFile: join(dir, 'bad.crt'), keyFile: join(dir, 'bad.key'),
}),
).toThrow();
});
it('fails closed when provided cert and key do not match', () => {
const a = ensureSelfSignedCert(dir, []);
const dir2 = mkdtempSync(join(tmpdir(), 'tlsopt2-'));
const b = ensureSelfSignedCert(dir2, ['other']);
writeFileSync(join(dir, 'a.crt'), a.cert);
writeFileSync(join(dir, 'b.key'), b.key);
expect(() =>
resolveTlsOptions({
...SERVER_TLS_DEFAULTS, enabled: true,
certFile: join(dir, 'a.crt'), keyFile: join(dir, 'b.key'),
}),
).toThrow();
rmSync(dir2, { recursive: true, force: true });
});
});

30
src/net/tls-options.ts Normal file
View File

@ -0,0 +1,30 @@
import { readFileSync } from 'fs';
import { X509Certificate, createPrivateKey } from 'crypto';
import type { ServerTlsConfig } from '../server/config.js';
import { ensureSelfSignedCert } from './self-signed.js';
export interface ResolvedTlsOptions {
cert: string;
key: string;
minVersion: 'TLSv1.2' | 'TLSv1.3';
}
/**
* Resolve TLS material. Any failure on an operator-provided cert is FATAL
* (throws) never silently fall back to self-signed or HTTP, which would
* downgrade an operator who deliberately configured a real certificate.
*/
export function resolveTlsOptions(tls: ServerTlsConfig): ResolvedTlsOptions {
if (tls.certFile && tls.keyFile) {
const cert = readFileSync(tls.certFile, 'utf-8');
const key = readFileSync(tls.keyFile, 'utf-8');
const x509 = new X509Certificate(cert);
const priv = createPrivateKey(key);
if (!x509.checkPrivateKey(priv)) {
throw new Error('server.tls: cert_file and key_file do not match');
}
return { cert, key, minVersion: tls.minVersion };
}
const { cert, key } = ensureSelfSignedCert(tls.selfSignedDir, tls.selfSignedHosts);
return { cert, key, minVersion: tls.minVersion };
}

61
src/server/config.test.ts Normal file
View File

@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { mergeServerConfig, SERVER_TLS_DEFAULTS } from './config.js';
describe('mergeServerConfig', () => {
it('upgrade-safe: an absent server block disables TLS', () => {
const cfg = mergeServerConfig(undefined, { freshInstall: false });
expect(cfg.tls.enabled).toBe(false);
});
it('fresh install with absent block enables TLS', () => {
const cfg = mergeServerConfig(undefined, { freshInstall: true });
expect(cfg.tls.enabled).toBe(true);
});
it('an explicit enabled value always wins over the freshInstall default', () => {
expect(mergeServerConfig({ tls: { enabled: false } }, { freshInstall: true }).tls.enabled).toBe(false);
expect(mergeServerConfig({ tls: { enabled: true } }, { freshInstall: false }).tls.enabled).toBe(true);
});
it('fills defaults for unspecified tls fields', () => {
const cfg = mergeServerConfig({ tls: { enabled: true } }, { freshInstall: false });
expect(cfg.tls.minVersion).toBe(SERVER_TLS_DEFAULTS.minVersion);
expect(cfg.tls.selfSignedDir).toBe(SERVER_TLS_DEFAULTS.selfSignedDir);
expect(cfg.tls.httpRedirect).toBe(true);
expect(cfg.tls.httpRedirectPort).toBe(9080);
expect(cfg.tls.selfSignedHosts).toEqual([]);
});
it('throws when http_redirect_port equals the https port', () => {
expect(() =>
mergeServerConfig({ tls: { enabled: true, httpRedirectPort: 9876 } }, { freshInstall: false, httpsPort: 9876 }),
).toThrow(/redirect.*port/i);
});
it('throws when only one of cert_file/key_file is set', () => {
expect(() =>
mergeServerConfig({ tls: { enabled: true, certFile: '/x/cert.pem' } }, { freshInstall: false }),
).toThrow(/cert_file.*key_file|both/i);
});
it('throws when key_file is set without cert_file', () => {
expect(() =>
mergeServerConfig({ tls: { enabled: true, keyFile: '/x/key.pem' } }, { freshInstall: false }),
).toThrow(/cert_file.*key_file|both/i);
});
it('does not throw on port collision when httpRedirect is false', () => {
expect(() =>
mergeServerConfig(
{ tls: { enabled: true, httpRedirect: false, httpRedirectPort: 9876 } },
{ freshInstall: false, httpsPort: 9876 },
),
).not.toThrow();
});
it('disabled TLS with a lone certFile does not throw', () => {
expect(() =>
mergeServerConfig({ tls: { enabled: false, certFile: '/x/c.pem' } }, { freshInstall: false }),
).not.toThrow();
});
});

63
src/server/config.ts Normal file
View File

@ -0,0 +1,63 @@
export interface ServerTlsConfig {
enabled: boolean;
certFile: string | null;
keyFile: string | null;
minVersion: 'TLSv1.2' | 'TLSv1.3';
selfSignedDir: string;
selfSignedHosts: string[];
httpRedirect: boolean;
httpRedirectPort: number;
/**
* Host used to build the HTTPHTTPS redirect `Location` header.
* When null the bind host is used instead.
* Intentionally NOT taken from the request Host header to prevent open-redirect attacks.
*/
redirectHost: string | null;
}
export interface ServerConfig {
tls: ServerTlsConfig;
}
export const SERVER_TLS_DEFAULTS: ServerTlsConfig = {
enabled: false,
certFile: null,
keyFile: null,
minVersion: 'TLSv1.2',
selfSignedDir: './data/tls',
selfSignedHosts: [],
httpRedirect: true,
httpRedirectPort: 9080,
redirectHost: null,
};
export interface MergeServerOpts {
freshInstall: boolean;
httpsPort?: number;
}
export function mergeServerConfig(
partial: Partial<ServerConfig> | undefined,
opts: MergeServerOpts,
): ServerConfig {
const tlsPartial = (partial?.tls ?? {}) as Partial<ServerTlsConfig>;
const enabledDefault = opts.freshInstall;
const tls: ServerTlsConfig = {
...SERVER_TLS_DEFAULTS,
...tlsPartial,
enabled: tlsPartial.enabled ?? enabledDefault,
};
tls.selfSignedHosts = [...(tlsPartial.selfSignedHosts ?? SERVER_TLS_DEFAULTS.selfSignedHosts)];
if (tls.enabled) {
const hasCert = !!tls.certFile;
const hasKey = !!tls.keyFile;
if (hasCert !== hasKey) {
throw new Error('server.tls: set both cert_file and key_file, or neither');
}
if (opts.httpsPort != null && tls.httpRedirect && tls.httpRedirectPort === opts.httpsPort) {
throw new Error(`server.tls: http_redirect_port (${tls.httpRedirectPort}) must differ from the HTTPS port`);
}
}
return { tls };
}

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
buildTitleFallback, buildTitleFallback,
buildTitleFromGoal,
buildTitlePrompt, buildTitlePrompt,
isUrlOnlyTitleInput, isUrlOnlyTitleInput,
stripUrlsForTitle, stripUrlsForTitle,
@ -27,3 +28,29 @@ describe('title generation helpers', () => {
expect(buildTitlePrompt(input)).not.toContain('example.com'); expect(buildTitlePrompt(input)).not.toContain('example.com');
}); });
}); });
describe('buildTitleFromGoal', () => {
it('uses the first non-empty line of the goal', () => {
expect(buildTitleFromGoal('議事録を作成する\n\n詳細な背景説明...')).toBe('議事録を作成する');
});
it('strips markdown heading and list markers', () => {
expect(buildTitleFromGoal('## 売上レポートをまとめる')).toBe('売上レポートをまとめる');
expect(buildTitleFromGoal('- 顧客リストを整理する')).toBe('顧客リストを整理する');
});
it('caps length at 40 chars', () => {
const long = 'あ'.repeat(80);
expect(buildTitleFromGoal(long).length).toBe(40);
});
it('masks URLs and returns empty for URL-only goals', () => {
expect(buildTitleFromGoal('https://example.com/x')).toBe('');
expect(buildTitleFromGoal('要約して https://example.com/x')).toBe('要約して [URL]');
});
it('returns empty string for blank goal so callers skip the update', () => {
expect(buildTitleFromGoal('')).toBe('');
expect(buildTitleFromGoal(' \n ')).toBe('');
});
});

View File

@ -15,6 +15,23 @@ export function buildTitleFallback(input: string): string {
return stripUrlsForTitle(input).slice(0, 40).trim() || '新しい依頼'; return stripUrlsForTitle(input).slice(0, 40).trim() || '新しい依頼';
} }
/**
* Derive a concise task title from the Mission Brief `goal` set by the agent.
* Used at runtime (no LLM call): the agent already restates the user's
* requirement as `goal` via MissionUpdate, so we take its first non-empty
* line, strip markdown list/heading markers and URLs, and cap the length.
* Returns '' when nothing usable remains so callers can skip the update.
*/
export function buildTitleFromGoal(goal: string): string {
const firstLine = goal
.split('\n')
.map(l => l.trim())
.find(l => l.length > 0) ?? '';
const cleaned = firstLine.replace(/^#{1,6}\s*/, '').replace(/^[-*+]\s+/, '');
if (isUrlOnlyTitleInput(cleaned)) return '';
return stripUrlsForTitle(cleaned).slice(0, 40).trim();
}
export function buildTitlePrompt(input: string): string | null { export function buildTitlePrompt(input: string): string | null {
if (isUrlOnlyTitleInput(input)) return null; if (isUrlOnlyTitleInput(input)) return null;
const sanitized = stripUrlsForTitle(input).slice(0, 500); const sanitized = stripUrlsForTitle(input).slice(0, 500);

View File

@ -19,6 +19,7 @@ import { logger } from './logger.js';
import { accessSync, existsSync, mkdirSync, constants } from 'fs'; import { accessSync, existsSync, mkdirSync, constants } from 'fs';
import { dirname, resolve, join } from 'path'; import { dirname, resolve, join } from 'path';
import { OpenAICompatClient } from './llm/openai-compat.js'; import { OpenAICompatClient } from './llm/openai-compat.js';
import { setLlmUsageRecorder } from './llm/usage-recorder.js';
import { llmRoutingKey } from './llm/routing-key.js'; import { llmRoutingKey } from './llm/routing-key.js';
import { ConfigManager } from './config-manager.js'; import { ConfigManager } from './config-manager.js';
import { WorkerManager } from './worker-manager.js'; import { WorkerManager } from './worker-manager.js';
@ -121,6 +122,23 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
const repo = new Repository(dbPath); const repo = new Repository(dbPath);
runMigrations(repo.getDb()); runMigrations(repo.getDb());
// Install the process-global LLM usage recorder so every OpenAICompatClient
// completion (agent loop, title, classify, reflection — gateway + direct)
// lands in the per-user daily ledger. Best-effort: the recorder helper
// swallows write errors so a DB hiccup never kills an agent stream.
// Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
setLlmUsageRecorder((event) => {
repo.incrementLlmUsage({
userId: event.userId,
source: event.source,
model: event.model,
route: event.route,
tokensIn: event.tokensIn,
tokensOut: event.tokensOut,
requests: 1,
});
});
// 起動時に孤立ジョブを回復 // 起動時に孤立ジョブを回復
await repo.recoverOrphanedJobs(); await repo.recoverOrphanedJobs();
@ -135,7 +153,7 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
config.provider.workers.find(w => w.enabled !== false && w.roles?.includes('title')) ?? config.provider.workers.find(w => w.enabled !== false && w.roles?.includes('title')) ??
config.provider.workers[0]; config.provider.workers[0];
let titleClient: OpenAICompatClient | null = null; let titleClient: OpenAICompatClient | null = null;
let generateTitle: ((body: string) => Promise<string>) | undefined; let generateTitle: ((body: string, ownerId?: string) => Promise<string>) | undefined;
if (titleWorker) { if (titleWorker) {
const titleModel = titleWorker.model ?? config.provider.model; const titleModel = titleWorker.model ?? config.provider.model;
@ -156,18 +174,24 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
titleClient = new OpenAICompatClient( titleClient = new OpenAICompatClient(
titleWorker.endpoint, titleWorker.endpoint,
titleRoutingKey, titleRoutingKey,
undefined, titleWorker.apiKey,
config.provider.retry, config.provider.retry,
(config.provider.timeoutMinutes ?? 10) * 60 * 1000, (config.provider.timeoutMinutes ?? 10) * 60 * 1000,
undefined,
undefined,
undefined,
// proxy mode so title/classification usage is recorded as
// source='gateway' with the backendId route (not mislabeled 'direct').
{ proxy: titleWorker.proxy === true },
); );
generateTitle = async (body: string): Promise<string> => { generateTitle = async (body: string, ownerId?: string): Promise<string> => {
const fallback = buildTitleFallback(body); const fallback = buildTitleFallback(body);
const prompt = buildTitlePrompt(body); const prompt = buildTitlePrompt(body);
if (!prompt) return fallback; if (!prompt) return fallback;
let title = ''; let title = '';
try { try {
for await (const event of titleClient!.chat([{ role: 'user', content: prompt }])) { for await (const event of titleClient!.chat([{ role: 'user', content: prompt }], undefined, undefined, { userId: ownerId })) {
if (event.type === 'text') title += event.text; if (event.type === 'text') title += event.text;
if (event.type === 'error') return fallback; if (event.type === 'error') return fallback;
if (event.type === 'done') break; if (event.type === 'done') break;
@ -232,7 +256,7 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
const selectPiece = titleClient const selectPiece = titleClient
? async (body: string, fileNames: string[], userId?: string): Promise<string> => { ? async (body: string, fileNames: string[], userId?: string): Promise<string> => {
const pieces = pieceCatalog.getForUser(userId ?? 'local'); const pieces = pieceCatalog.getForUser(userId ?? 'local');
const result = await classifyPiece(titleClient!, body, pieces, fileNames); const result = await classifyPiece(titleClient!, body, pieces, fileNames, undefined, userId ?? 'local');
return result ?? 'chat'; return result ?? 'chat';
} }
: undefined; : undefined;

View File

@ -818,7 +818,7 @@ export class Worker {
]; ];
let answer = ''; let answer = '';
for await (const event of llmClient.chat(messages)) { for await (const event of llmClient.chat(messages, undefined, undefined, { userId: parentJob?.ownerId ?? 'local' })) {
if (event.type === 'text') { if (event.type === 'text') {
answer += event.text; answer += event.text;
} else if (event.type === 'error') { } else if (event.type === 'error') {
@ -1779,6 +1779,8 @@ export class Worker {
// Same credential as normal task LLM calls — a key-enforcing // Same credential as normal task LLM calls — a key-enforcing
// gateway 401s reflection without it. // gateway 401s reflection without it.
llmApiKey: this.getWorkerDef().apiKey, llmApiKey: this.getWorkerDef().apiKey,
llmProxy: this.getWorkerDef().proxy === true,
llmContextLimitTokens: this.contextLimitTokens,
}, },
job job
); );

View File

@ -21,7 +21,7 @@ import { useBackdropClose } from './lib/useBackdropClose';
import { TopBar } from './components/layout/TopBar'; import { TopBar } from './components/layout/TopBar';
import { NavDrawer } from './components/layout/NavDrawer'; import { NavDrawer } from './components/layout/NavDrawer';
import { useEdgeSwipe } from './hooks/useEdgeSwipe'; import { useEdgeSwipe } from './hooks/useEdgeSwipe';
import { visibleNavItemsFor, useCompactNav } from './components/layout/TopBar'; import { visibleNavItemsFor } from './components/layout/TopBar';
import { ResizeHandle } from './components/layout/ResizeHandle'; import { ResizeHandle } from './components/layout/ResizeHandle';
import { TaskListPanel } from './components/list/TaskListPanel'; import { TaskListPanel } from './components/list/TaskListPanel';
import { ChatPane } from './components/chat/ChatPane'; import { ChatPane } from './components/chat/ChatPane';
@ -40,6 +40,7 @@ import { UsersPage } from './pages/UsersPage';
import { AdminCaptchaPage } from './pages/AdminCaptchaPage'; import { AdminCaptchaPage } from './pages/AdminCaptchaPage';
import { SharedView } from './pages/SharedView'; import { SharedView } from './pages/SharedView';
import { UserFolderTab } from './components/userfolder/UserFolderTab'; import { UserFolderTab } from './components/userfolder/UserFolderTab';
import { UsagePage } from './components/usage/UsagePage';
import { HelpPage } from './pages/HelpPage'; import { HelpPage } from './pages/HelpPage';
import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel'; import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel';
import type { ConsoleStatus } from './lib/ssh-console-types'; import type { ConsoleStatus } from './lib/ssh-console-types';
@ -163,7 +164,9 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
const tabletDetailBackdrop = useBackdropClose(() => setTabletDetailOpen(false)); const tabletDetailBackdrop = useBackdropClose(() => setTabletDetailOpen(false));
const [navDrawerOpen, setNavDrawerOpen] = useState(false); const [navDrawerOpen, setNavDrawerOpen] = useState(false);
const hamburgerRef = useRef<HTMLButtonElement>(null); const hamburgerRef = useRef<HTMLButtonElement>(null);
const compactMode = useCompactNav(isAdmin, authEnabled); // compactMode is measured by TopBar (actual fit) and reported up here so the
// nav drawer / edge-swipe stay in sync with whether the hamburger is shown.
const [compactMode, setCompactMode] = useState(false);
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled); const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
const openNavDrawer = () => { const openNavDrawer = () => {
@ -355,10 +358,10 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
sortMode: sort, sortMode: sort,
searchQuery: search, searchQuery: search,
activeTaskId: localTaskId, activeTaskId: localTaskId,
// Owner scope (自分/すべて). Only active when auth is on — in no-auth mode // Owner scope (自分/他のユーザ). Only active when auth is on — in no-auth mode
// every task is owned by 'local' and the toggle would be meaningless. // every task is owned by 'local' and the toggle would be meaningless.
scope: urlState.scope, scope: urlState.scope,
onScopeChange: (scope: 'mine' | 'all') => setUrlState(prev => ({ ...prev, scope })), onScopeChange: (scope: 'mine' | 'others') => setUrlState(prev => ({ ...prev, scope })),
currentUserId: user?.id ?? null, currentUserId: user?.id ?? null,
scopeEnabled: authEnabled && !!user, scopeEnabled: authEnabled && !!user,
onStatusChange: (s: string) => setUrlState(prev => ({ ...prev, status: s as typeof status })), onStatusChange: (s: string) => setUrlState(prev => ({ ...prev, status: s as typeof status })),
@ -451,6 +454,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
hamburgerButtonRef={hamburgerRef} hamburgerButtonRef={hamburgerRef}
navDrawerOpen={navDrawerOpen} navDrawerOpen={navDrawerOpen}
onOpenCommandK={() => setCmdkOpen(true)} onOpenCommandK={() => setCmdkOpen(true)}
onCompactChange={setCompactMode}
/> />
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0"> <div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
@ -471,6 +475,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>} {page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>} {page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
{page === 'userfolder' && <div className="flex-1 min-h-0 overflow-hidden"><UserFolderTab showToast={showToast} /></div>} {page === 'userfolder' && <div className="flex-1 min-h-0 overflow-hidden"><UserFolderTab showToast={showToast} /></div>}
{page === 'usage' && <div className="flex-1 min-h-0 overflow-hidden flex flex-col"><UsagePage /></div>}
{page === 'help' && <div className="flex-1 min-h-0 overflow-hidden"><HelpPage isAdmin={isAdmin} onAskAi={() => { setCreateInitialPiece('help'); setShowCreateDialog(true); }} selectedId={urlState.help} onSelect={(id) => setUrlState(prev => ({ ...prev, help: id }))} /></div>} {page === 'help' && <div className="flex-1 min-h-0 overflow-hidden"><HelpPage isAdmin={isAdmin} onAskAi={() => { setCreateInitialPiece('help'); setShowCreateDialog(true); }} selectedId={urlState.help} onSelect={(id) => setUrlState(prev => ({ ...prev, help: id }))} /></div>}
{page === 'tasks' && <OutputPreviewProvider openOutputPath={handleOutputPathLinkClick}><div className="flex-1 min-h-0 overflow-hidden"> {page === 'tasks' && <OutputPreviewProvider openOutputPath={handleOutputPathLinkClick}><div className="flex-1 min-h-0 overflow-hidden">
@ -563,8 +568,10 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
)} )}
</div> </div>
{/* タブレット: 2カラム (sm 〜 lg) */} {/* : 2 (sm xl)3
<div className="hidden sm:grid lg:hidden gap-2 p-2 h-full" style={{ gridTemplateColumns: 'clamp(220px, 30vw, 280px) minmax(0, 1fr)' }}> xl(1280px)
2+ */}
<div className="hidden sm:grid xl:hidden gap-2 p-2 h-full" style={{ gridTemplateColumns: 'clamp(220px, 30vw, 280px) minmax(0, 1fr)' }}>
<div className="bg-canvas border border-hairline rounded-md overflow-hidden"> <div className="bg-canvas border border-hairline rounded-md overflow-hidden">
<TaskListWithSidePanel <TaskListWithSidePanel
upper={<div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} /></div>} upper={<div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} /></div>}
@ -583,9 +590,9 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
</div> </div>
</div> </div>
{/* デスクトップ: >= lg (1024px). normal=3 列、focused=rail/chat/handle/ws=4 列 */} {/* デスクトップ: >= xl (1280px). normal=3 列、focused=rail/chat/handle/ws=4 列 */}
<div <div
className="hidden lg:grid gap-2 p-2 h-full" className="hidden xl:grid gap-2 p-2 h-full"
data-focused-grid={isFocused ? '1' : undefined} data-focused-grid={isFocused ? '1' : undefined}
style={gridStyle} style={gridStyle}
> >
@ -642,7 +649,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
{/* Tablet: detail overlay */} {/* Tablet: detail overlay */}
{tabletDetailOpen && panelOpen && ( {tabletDetailOpen && panelOpen && (
<div className="hidden sm:block lg:hidden fixed inset-0 bg-black/40 z-40" {...tabletDetailBackdrop}> <div className="hidden sm:block xl:hidden fixed inset-0 bg-black/40 z-40" {...tabletDetailBackdrop}>
<div className="absolute right-0 top-0 bottom-0 bg-canvas shadow-2xl flex flex-col overflow-hidden" style={{ width: 'min(480px, 90vw)' }} onClick={e => e.stopPropagation()}> <div className="absolute right-0 top-0 bottom-0 bg-canvas shadow-2xl flex flex-col overflow-hidden" style={{ width: 'min(480px, 90vw)' }} onClick={e => e.stopPropagation()}>
{localTaskId && ( {localTaskId && (
<LocalDetailPanel <LocalDetailPanel
@ -675,6 +682,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
section={previewState.section} section={previewState.section}
filePath={previewState.filePath} filePath={previewState.filePath}
editable={previewState.editable} editable={previewState.editable}
trustedHtmlUrl={user || !authEnabled ? previewState.trustedHtmlUrl : undefined}
/> />
)} )}
{branding.footerText && ( {branding.footerText && (

View File

@ -99,9 +99,13 @@ export interface SubtaskActivity {
activityLog: string; activityLog: string;
} }
export type TitleSource = 'auto' | 'agent' | 'user';
export interface LocalTask { export interface LocalTask {
id: number; id: number;
title: string; title: string;
/** Provenance of the title: 'auto' (creation fallback), 'agent' (derived from goal), 'user' (manual edit). */
titleSource?: TitleSource;
body: string; body: string;
pieceName: string; pieceName: string;
profile: string; profile: string;
@ -255,7 +259,7 @@ export async function postLocalTaskComment(taskId: number, body: string, author:
export async function updateLocalTask( export async function updateLocalTask(
taskId: number, taskId: number,
updates: { visibility?: Visibility; visibilityScopeOrgId?: string | null }, updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null },
): Promise<LocalTask> { ): Promise<LocalTask> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`, { const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
method: 'PATCH', method: 'PATCH',
@ -267,6 +271,14 @@ export async function updateLocalTask(
return data.task; return data.task;
} }
/** Trigger on-demand AI title regeneration. Owner/admin only. Returns the new title. */
export async function regenerateTaskTitle(taskId: number): Promise<string> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/regenerate-title`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to regenerate title');
return data.title as string;
}
export async function continueTaskWithPiece( export async function continueTaskWithPiece(
taskId: number, taskId: number,
body: { piece: string; instruction: string }, body: { piece: string; instruction: string },
@ -319,6 +331,11 @@ export function getLocalFileRawUrl(taskId: number, section: 'workspace' | 'input
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`; return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
} }
export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
const params = new URLSearchParams({ section, path, trusted: '1' });
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}
export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise<void> { export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, { const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, {
method: 'PUT', method: 'PUT',
@ -1266,3 +1283,46 @@ export async function postTestNotification(): Promise<{ ok: boolean }> {
const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' }); const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' });
return notificationsJsonOrThrow(res, 'failed to send test notification'); return notificationsJsonOrThrow(res, 'failed to send test notification');
} }
// ============================================================
// LLM usage dashboard (per-user, gateway + direct).
// Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
export interface UsageCounters {
tokensIn: number;
tokensOut: number;
requests: number;
}
export interface UsageBucket {
bucket: string; // 'YYYY-MM-DD' | 'YYYY-Www' | 'YYYY-MM'
gateway: UsageCounters;
direct: UsageCounters;
}
export interface UsageByUser extends UsageCounters {
userId: string;
/** Resolved display name (real users); 'local' / 'system' for sentinels. */
displayName: string;
}
export interface UsageDailyResponse {
from: string;
to: string;
granularity: 'day' | 'week' | 'month';
scope: 'all' | 'self';
series: UsageBucket[];
totals: { gateway: UsageCounters; direct: UsageCounters };
byUser?: UsageByUser[]; // admin / local mode only
}
export async function getUsageDaily(params: {
from?: string;
to?: string;
granularity?: 'day' | 'week' | 'month';
}): Promise<UsageDailyResponse> {
const qs = new URLSearchParams();
if (params.from) qs.set('from', params.from);
if (params.to) qs.set('to', params.to);
if (params.granularity) qs.set('granularity', params.granularity);
const q = qs.toString();
const res = await fetch(`${BASE}/usage/daily${q ? `?${q}` : ''}`);
if (!res.ok) throw new Error(`Failed to load usage (${res.status})`);
return res.json();
}

View File

@ -227,7 +227,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
</button> </button>
</div> </div>
</div> </div>
<div role="tablist" aria-label={t('panel.tabsLabel')} className="hidden sm:flex gap-4 -mb-px"> <div role="tablist" aria-label={t('panel.tabsLabel')} className="hidden sm:flex flex-wrap gap-x-4 gap-y-1 -mb-px">
{tabs.map(tab => { {tabs.map(tab => {
const active = activeTab === tab.id; const active = activeTab === tab.id;
const pending = active && tabTransitionPending; const pending = active && tabTransitionPending;
@ -237,7 +237,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
role="tab" role="tab"
aria-selected={active} aria-selected={active}
onClick={() => onTabChange(tab.id)} onClick={() => onTabChange(tab.id)}
className={`pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${ className={`whitespace-nowrap pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${
active active
? 'border-accent text-slate-900 font-semibold' ? 'border-accent text-slate-900 font-semibold'
: 'border-transparent text-slate-500 font-medium hover:text-slate-800' : 'border-transparent text-slate-500 font-medium hover:text-slate-800'

View File

@ -104,7 +104,27 @@ export function LocalDetailPanel({
refetchInterval: 5000, refetchInterval: 5000,
}); });
const showSshTab = consoleStatus?.active === true; const showSshTab = consoleStatus?.active === true;
const visibleTabs = LOCAL_TABS.filter((t) => t.id !== 'ssh' || showSshTab);
// Browser tab visibility: mirror SSH — show only once a viewable browser
// session is live for this task. `available: true` means a noVNC session
// exists (the agent has actually used the browser); every other case
// (no_session / headless_mode / display_unavailable / novnc_not_installed)
// is non-viewable, so the tab would show nothing useful and stays hidden.
// Shares the ['task-session', id] query with BrowserTab (deduped by key).
const { data: browserSession } = useQuery<{ available: boolean }>({
queryKey: ['task-session', task?.id],
queryFn: async () => {
const r = await fetch(`/api/local/browser/sessions/task-session/${task!.id}`);
return r.ok ? r.json() : { available: false };
},
enabled: !!task,
refetchInterval: 5000,
});
const showBrowserTab = browserSession?.available === true;
const visibleTabs = LOCAL_TABS.filter(
(t) => (t.id !== 'ssh' || showSshTab) && (t.id !== 'browser' || showBrowserTab),
);
const handleStartEdit = () => { const handleStartEdit = () => {
if (!task) return; if (!task) return;

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief } from '../../../api'; import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief, updateLocalTask, regenerateTaskTitle } from '../../../api';
import { StatusBadge } from '../../shared/StatusBadge'; import { StatusBadge } from '../../shared/StatusBadge';
import { SubtasksPanel, type SubtaskFilePreviewHandler } from './SubtasksPanel'; import { SubtasksPanel, type SubtaskFilePreviewHandler } from './SubtasksPanel';
import { ContextUsageGauge } from '../ContextUsageGauge'; import { ContextUsageGauge } from '../ContextUsageGauge';
@ -272,6 +272,116 @@ function MissionCard({ task }: { task: LocalTask }) {
); );
} }
/**
* Editable task title with on-demand AI regeneration. The title is set to a
* cheap fallback at creation and upgraded by the agent (derived from the
* Mission Brief goal) during the run. A manual edit pins it (title_source =
* 'user') so the agent never overwrites it afterwards.
*/
function TaskTitleRow({ task }: { task: LocalTask }) {
const { t } = useTranslation('detail');
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(task.title);
const [error, setError] = useState<string | null>(null);
// Sync the displayed title with server-side updates (the agent rewrites it
// mid-run) unless the user is actively editing.
useEffect(() => {
if (!editing) setDraft(task.title);
}, [task.title, editing]);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] });
qc.invalidateQueries({ queryKey: ['localTasks'] });
};
const saveMutation = useMutation({
mutationFn: () => updateLocalTask(task.id, { title: draft.trim() }),
onSuccess: () => { setEditing(false); setError(null); invalidate(); },
onError: (err: unknown) => setError(err instanceof Error ? err.message : 'Failed to save title'),
});
const regenMutation = useMutation({
mutationFn: () => regenerateTaskTitle(task.id),
onSuccess: () => { setError(null); invalidate(); },
onError: (err: unknown) => setError(err instanceof Error ? err.message : 'Failed to regenerate title'),
});
const save = () => { if (draft.trim()) saveMutation.mutate(); };
if (editing) {
return (
<div className="flex flex-col gap-1.5">
<input
value={draft}
autoFocus
maxLength={200}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); save(); }
if (e.key === 'Escape') { setEditing(false); setError(null); setDraft(task.title); }
}}
className="w-full px-2.5 py-1.5 text-lg font-extrabold text-slate-900 border border-hairline rounded-md focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-accent"
/>
{error && <div className="text-2xs text-red-600">{error}</div>}
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={() => { setEditing(false); setError(null); setDraft(task.title); }}
disabled={saveMutation.isPending}
className="px-3 h-7 text-xs rounded-md border border-hairline bg-canvas text-slate-700 hover:bg-surface transition-colors disabled:opacity-50"
>
{t('title.cancel')}
</button>
<button
type="button"
onClick={save}
disabled={saveMutation.isPending || !draft.trim()}
className="px-3 h-7 text-xs font-semibold rounded-md bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
>
{saveMutation.isPending ? t('title.saving') : t('title.save')}
</button>
</div>
</div>
);
}
return (
<div>
<div className="group flex items-start justify-between gap-2">
<div className="text-lg font-extrabold text-slate-900 break-words leading-tight min-w-0">{task.title}</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={() => regenMutation.mutate()}
disabled={regenMutation.isPending}
title={t('title.regenerate')}
aria-label={t('title.regenerate')}
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-accent hover:bg-surface transition-colors disabled:opacity-50"
>
<svg className={`w-3.5 h-3.5 ${regenMutation.isPending ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2v3h-3" />
</svg>
</button>
<button
type="button"
onClick={() => { setDraft(task.title); setEditing(true); setError(null); }}
title={t('title.edit')}
aria-label={t('title.edit')}
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface transition-colors"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11.5 2.5l2 2L6 12l-2.5.5L4 10l7.5-7.5z" />
</svg>
</button>
</div>
</div>
{error && <div className="text-2xs text-red-600 mt-1">{error}</div>}
</div>
);
}
interface OverviewTabProps { interface OverviewTabProps {
task: LocalTask; task: LocalTask;
subtaskActivities?: SubtaskActivity[]; subtaskActivities?: SubtaskActivity[];
@ -284,7 +394,7 @@ export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: O
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm"> <div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="text-lg font-extrabold text-slate-900">{task.title}</div> <TaskTitleRow task={task} />
<div className="flex flex-wrap gap-2 mt-2"> <div className="flex flex-wrap gap-2 mt-2">
<StatusBadge status={status} /> <StatusBadge status={status} />
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{task.pieceName}</span> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{task.pieceName}</span>

View File

@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LocalFileEntry, getLocalFileRawUrl } from '../../api'; import { LocalFileEntry, getLocalFileRawUrl } from '../../api';
import { isPreviewable, formatFileDate } from '../../lib/utils'; import { isPreviewable, formatFileDate } from '../../lib/utils';
import { splitFileName } from '../../lib/fileType';
import { FileTypeIcon } from './FileTypeIcon';
interface FileBrowserProps { interface FileBrowserProps {
section: 'workspace' | 'input' | 'output' | 'logs'; section: 'workspace' | 'input' | 'output' | 'logs';
@ -211,20 +213,30 @@ export function FileBrowser({
key={`${entry.kind}:${entry.path}`} key={`${entry.kind}:${entry.path}`}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-canvas border border-hairline hover:bg-surface transition-colors" className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-canvas border border-hairline hover:bg-surface transition-colors"
> >
<span className="text-slate-400 flex-shrink-0" aria-hidden="true"> <span className="flex-shrink-0" aria-hidden="true">
{entry.kind === 'directory' ? ( {entry.kind === 'directory' ? (
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg className="w-4 h-4 text-slate-400" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 4.5A1.5 1.5 0 013.5 3h3l1.5 2h4.5A1.5 1.5 0 0114 6.5v5A1.5 1.5 0 0112.5 13h-9A1.5 1.5 0 012 11.5v-7z" /> <path d="M2 4.5A1.5 1.5 0 013.5 3h3l1.5 2h4.5A1.5 1.5 0 0114 6.5v5A1.5 1.5 0 0112.5 13h-9A1.5 1.5 0 012 11.5v-7z" />
</svg> </svg>
) : ( ) : (
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <FileTypeIcon name={entry.name} />
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
<path d="M9 2v4.5h4.5" />
</svg>
)} )}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[13px] text-slate-800 truncate" title={entry.name}>{entry.name}</div> {entry.kind === 'file'
? (() => {
// Truncate the stem but never the extension, so the file
// type stays readable on long names: `long-na…me.xlsx`.
const { stem, ext } = splitFileName(entry.name);
return (
<div className="flex items-baseline text-[13px] text-slate-800 min-w-0" title={entry.name}>
<span className="truncate min-w-0">{stem}</span>
{ext && <span className="flex-shrink-0">{ext}</span>}
</div>
);
})()
: <div className="text-[13px] text-slate-800 truncate" title={entry.name}>{entry.name}</div>
}
{entry.kind === 'file' && entry.modifiedAt && ( {entry.kind === 'file' && entry.modifiedAt && (
<div className="text-[10px] text-slate-400 font-mono leading-tight">{formatFileDate(entry.modifiedAt)}</div> <div className="text-[10px] text-slate-400 font-mono leading-tight">{formatFileDate(entry.modifiedAt)}</div>
)} )}

View File

@ -65,6 +65,7 @@ interface FilePreviewProps {
section?: string; section?: string;
filePath?: string; filePath?: string;
editable?: boolean; editable?: boolean;
trustedHtmlUrl?: string;
} }
// --- CSV --- // --- CSV ---
@ -622,7 +623,7 @@ function renderJsonl(content: string): JSX.Element {
} }
// --- FilePreview --- // --- FilePreview ---
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable }: FilePreviewProps) { export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable, trustedHtmlUrl }: FilePreviewProps) {
const { t } = useTranslation('files'); const { t } = useTranslation('files');
const [mode, setMode] = useState<'view' | 'edit'>('view'); const [mode, setMode] = useState<'view' | 'edit'>('view');
const [editContent, setEditContent] = useState(content); const [editContent, setEditContent] = useState(content);
@ -634,6 +635,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
const canEdit = editable && taskId != null && section && filePath; const canEdit = editable && taskId != null && section && filePath;
const isMarkdownFile = /\.(md|markdown)$/i.test(name); const isMarkdownFile = /\.(md|markdown)$/i.test(name);
const canOpenTrustedHtml = mode === 'view' && !!trustedHtmlUrl && /\.html?$/i.test(name);
const handlePrint = async () => { const handlePrint = async () => {
if (printing) return; if (printing) return;
@ -756,6 +758,21 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
<div className="flex justify-between items-center px-4 py-2.5 border-b border-hairline flex-shrink-0 sticky top-0 bg-surface z-10 gap-2"> <div className="flex justify-between items-center px-4 py-2.5 border-b border-hairline flex-shrink-0 sticky top-0 bg-surface z-10 gap-2">
<div className="font-mono text-xs text-slate-700 truncate" title={name}>{name}</div> <div className="font-mono text-xs text-slate-700 truncate" title={name}>{name}</div>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
{canOpenTrustedHtml && (
<a
href={trustedHtmlUrl}
target="_blank"
rel="noopener noreferrer"
title={t('trustedHtml.tooltip')}
className="inline-flex items-center gap-1 px-2.5 h-7 text-2xs font-medium rounded-md border border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100 transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3H3.5A1.5 1.5 0 0 0 2 4.5v8A1.5 1.5 0 0 0 3.5 14h8a1.5 1.5 0 0 0 1.5-1.5V10" />
<path d="M9 2h5v5M8 8l6-6" />
</svg>
{t('trustedHtml.button')}
</a>
)}
{isMarkdownFile && mode === 'view' && ( {isMarkdownFile && mode === 'view' && (
<button <button
onClick={handlePrint} onClick={handlePrint}

View File

@ -0,0 +1,89 @@
import { fileCategory, categoryColorClass, type FileCategory } from '../../lib/fileType';
/** カテゴリごとの 16x16 ストロークグリフ (既存ファイル行のアイコンと同じスタイル)。 */
function glyph(cat: FileCategory) {
switch (cat) {
case 'image':
return (
<>
<rect x="2" y="3" width="12" height="10" rx="1.5" />
<circle cx="5.5" cy="6.5" r="1" />
<path d="M3 12l3-3 2.5 2.5L11 7l2 2" />
</>
);
case 'spreadsheet':
return (
<>
<rect x="2.5" y="3" width="11" height="10" rx="1" />
<path d="M2.5 6.5h11M2.5 10h11M6 3v10M10 3v10" />
</>
);
case 'document':
return (
<>
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
<path d="M9 2v4.5h4.5" />
<path d="M5.5 9h5M5.5 11h3" />
</>
);
case 'presentation':
return (
<>
<rect x="2" y="3" width="12" height="8" rx="1" />
<path d="M8 11v2M6 14h4" />
</>
);
case 'code':
return <path d="M6 5L3 8l3 3M10 5l3 3-3 3" />;
case 'archive':
return (
<>
<rect x="3" y="3" width="10" height="11" rx="1" />
<path d="M8 3v11" strokeDasharray="1.5 1.5" />
</>
);
case 'audio':
return (
<>
<circle cx="5.3" cy="11.5" r="1.7" />
<circle cx="10.7" cy="9.7" r="1.7" />
<path d="M7 11.5V4l5.4-1.5v7.2" />
</>
);
case 'video':
return (
<>
<rect x="2" y="3.5" width="12" height="9" rx="1.5" />
<path d="M7 6.3l3 1.7-3 1.7z" />
</>
);
case 'pdf':
case 'other':
default:
// 折れ角のあるページ。pdf は色 (赤) で識別、other は灰色。
return (
<>
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
<path d="M9 2v4.5h4.5" />
</>
);
}
}
export function FileTypeIcon({ name, className }: { name: string; className?: string }) {
const cat = fileCategory(name);
return (
<svg
className={`${className ?? 'w-4 h-4'} ${categoryColorClass(cat)}`}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{glyph(cat)}
</svg>
);
}

View File

@ -5,7 +5,8 @@ import { useBackdropClose } from '../../lib/useBackdropClose';
export interface NavItem { export interface NavItem {
id: PageId; id: PageId;
label: string; /** i18n key resolved against the `layout` namespace at render time. */
labelKey: string;
} }
interface NavDrawerProps { interface NavDrawerProps {
@ -85,6 +86,14 @@ const NAV_ICONS: Record<PageId, ReactNode> = {
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /> <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</svg> </svg>
), ),
usage: (
<svg {...ICON_PROPS}>
<line x1="4" y1="20" x2="20" y2="20" />
<rect x="6" y="11" width="3" height="6" />
<rect x="11" y="7" width="3" height="10" />
<rect x="16" y="13" width="3" height="4" />
</svg>
),
}; };
export function NavDrawer({ export function NavDrawer({
@ -211,7 +220,7 @@ export function NavDrawer({
}`} }`}
> >
<span className="flex-shrink-0 text-slate-500">{NAV_ICONS[item.id]}</span> <span className="flex-shrink-0 text-slate-500">{NAV_ICONS[item.id]}</span>
<span className="flex-1 text-left">{item.label}</span> <span className="flex-1 text-left">{t(item.labelKey)}</span>
</button> </button>
); );
})} })}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { PageId } from '../../lib/urlState'; import type { PageId } from '../../lib/urlState';
import type { AuthUser } from '../../App'; import type { AuthUser } from '../../App';
@ -17,6 +17,9 @@ interface TopBarProps {
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>; hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
navDrawerOpen?: boolean; navDrawerOpen?: boolean;
onOpenCommandK?: () => void; onOpenCommandK?: () => void;
/** Reports the measured compact state up so App can drive the nav drawer /
* edge-swipe. Called whenever the collapse decision flips. */
onCompactChange?: (compact: boolean) => void;
} }
// labelKey resolves against the `layout` i18n namespace at render time (module // labelKey resolves against the `layout` i18n namespace at render time (module
@ -26,14 +29,29 @@ export const NAV_ITEMS: Array<{ id: PageId; labelKey: string; adminOnly: boolean
{ id: 'schedules', labelKey: 'nav.schedules', adminOnly: false, requiresAuth: false }, { id: 'schedules', labelKey: 'nav.schedules', adminOnly: false, requiresAuth: false },
{ id: 'pieces', labelKey: 'nav.pieces', adminOnly: false, requiresAuth: false }, { id: 'pieces', labelKey: 'nav.pieces', adminOnly: false, requiresAuth: false },
{ id: 'captcha', labelKey: 'nav.captcha', adminOnly: true, requiresAuth: false }, { id: 'captcha', labelKey: 'nav.captcha', adminOnly: true, requiresAuth: false },
{ id: 'usage', labelKey: 'nav.usage', adminOnly: false, requiresAuth: false },
{ id: 'settings', labelKey: 'nav.settings', adminOnly: false, requiresAuth: false }, { id: 'settings', labelKey: 'nav.settings', adminOnly: false, requiresAuth: false },
{ id: 'users', labelKey: 'nav.users', adminOnly: true, requiresAuth: true }, { id: 'users', labelKey: 'nav.users', adminOnly: true, requiresAuth: true },
{ id: 'help', labelKey: 'nav.help', adminOnly: false, requiresAuth: false }, { id: 'help', labelKey: 'nav.help', adminOnly: false, requiresAuth: false },
{ id: 'userfolder', labelKey: 'nav.userfolder', adminOnly: false, requiresAuth: false }, { id: 'userfolder', labelKey: 'nav.userfolder', adminOnly: false, requiresAuth: false },
]; ];
export function estimateCollapseThreshold(navCount: number): number { /**
return 430 + navCount * 78 + 60; * Decide whether the inline nav must collapse into the hamburger, from MEASURED
* widths (not a width estimate). Collapse exactly when the nav's natural width
* no longer fits the space left after the fixed left content (logo/name/version)
* and the right-side controls. The buffer keeps a small gap so tabs never touch
* the controls. Before anything is measured (zero widths) we assume it fits, so
* the bar paints with tabs rather than flashing the hamburger.
*/
export function shouldCollapseNav(
rowWidth: number,
fixedWidth: number,
navWidth: number,
buffer = 40,
): boolean {
if (rowWidth <= 0 || navWidth <= 0) return false;
return navWidth > rowWidth - fixedWidth - buffer;
} }
export function useViewportNarrow(threshold: number): boolean { export function useViewportNarrow(threshold: number): boolean {
@ -58,11 +76,6 @@ export function visibleNavItemsFor(isAdmin: boolean, authEnabled: boolean) {
}); });
} }
export function useCompactNav(isAdmin: boolean, authEnabled: boolean): boolean {
const visible = visibleNavItemsFor(isAdmin, authEnabled);
return useViewportNarrow(estimateCollapseThreshold(visible.length));
}
export function TopBar({ export function TopBar({
currentPage, currentPage,
onNavigate, onNavigate,
@ -75,12 +88,43 @@ export function TopBar({
hamburgerButtonRef, hamburgerButtonRef,
navDrawerOpen = false, navDrawerOpen = false,
onOpenCommandK, onOpenCommandK,
onCompactChange,
}: TopBarProps) { }: TopBarProps) {
const { t } = useTranslation('layout'); const { t } = useTranslation('layout');
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled); const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
const [showPwChange, setShowPwChange] = useState(false); const [showPwChange, setShowPwChange] = useState(false);
// Collapse-to-hamburger decision from MEASURED widths, so it flips exactly
// when the tabs stop fitting — no width estimate, no 2-line state.
const rowRef = useRef<HTMLDivElement>(null);
const leftFixedRef = useRef<HTMLDivElement>(null); // logo + appName + version (NOT the nav)
const rightRef = useRef<HTMLDivElement>(null); // right-side controls
const navRulerRef = useRef<HTMLDivElement>(null); // off-screen full-width nav, always present
const [compactMode, setCompactMode] = useState(false);
useLayoutEffect(() => {
const row = rowRef.current;
if (!row || typeof ResizeObserver === 'undefined') return;
const measure = () => {
const leftFixed = leftFixedRef.current?.offsetWidth ?? 0;
const right = rightRef.current?.offsetWidth ?? 0;
const navWidth = navRulerRef.current?.offsetWidth ?? 0;
setCompactMode(shouldCollapseNav(row.clientWidth, leftFixed + right, navWidth));
};
const ro = new ResizeObserver(measure);
ro.observe(row);
// Observe the ruler + right group too: their widths change with language
// (label lengths) and login state, which also moves the collapse point.
if (navRulerRef.current) ro.observe(navRulerRef.current);
if (rightRef.current) ro.observe(rightRef.current);
measure();
return () => ro.disconnect();
}, [visibleNav.length, t]);
useEffect(() => {
onCompactChange?.(compactMode);
}, [compactMode, onCompactChange]);
return ( return (
<div <div
className="flex-shrink-0 bg-canvas border-b border-hairline px-4 flex items-center" className="flex-shrink-0 bg-canvas border-b border-hairline px-4 flex items-center"
@ -89,7 +133,26 @@ export function TopBar({
minHeight: 'calc(48px + env(safe-area-inset-top, 0px))', minHeight: 'calc(48px + env(safe-area-inset-top, 0px))',
}} }}
> >
<div className="flex items-center justify-between gap-3 flex-wrap w-full py-1.5"> <div ref={rowRef} className="relative flex items-center justify-between gap-3 flex-nowrap w-full py-1.5 min-w-0">
{/* Off-screen full-width nav ruler. Always rendered (absolute, out of
flow) so its width is measurable even while the visible nav is
collapsed that constant width is what keeps the collapse decision
from oscillating. INVARIANT: this ruler must stay a width-SUPERSET
of the real nav (same px/gap, every label font-semibold = the widest
state). If it ever underestimates the real nav, the 2-line bug
returns. Do NOT add per-item active/inactive weight here. */}
<nav
ref={navRulerRef}
aria-hidden="true"
className="absolute left-0 top-0 invisible pointer-events-none flex gap-5 ml-2"
>
{visibleNav.map(item => (
<span key={item.id} className="px-0.5 text-xs font-semibold whitespace-nowrap">
{t(item.labelKey)}
</span>
))}
</nav>
<div className="flex items-center gap-4 min-w-0 self-stretch"> <div className="flex items-center gap-4 min-w-0 self-stretch">
{compactMode && ( {compactMode && (
<button <button
@ -109,6 +172,10 @@ export function TopBar({
</svg> </svg>
</button> </button>
)} )}
{/* leftFixedRef must wrap ONLY logo/name/version the hamburger is a
sibling above, kept out of this measurement so toggling it doesn't
change leftFixed width (which would oscillate the collapse). */}
<div ref={leftFixedRef} className="flex items-center gap-4 flex-shrink-0">
<img <img
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`} src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
alt="" alt=""
@ -120,9 +187,10 @@ export function TopBar({
<span className="text-[10px] font-mono text-slate-400 hidden sm:inline"> <span className="text-[10px] font-mono text-slate-400 hidden sm:inline">
v{__APP_VERSION__} v{__APP_VERSION__}
</span> </span>
</div>
{!compactMode && ( {!compactMode && (
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2" aria-label={t('nav.mainNav')}> <nav className="flex gap-5 items-stretch -mb-[13px] ml-2 flex-shrink-0" aria-label={t('nav.mainNav')}>
{visibleNav.map(item => { {visibleNav.map(item => {
const active = currentPage === item.id; const active = currentPage === item.id;
return ( return (
@ -131,7 +199,7 @@ export function TopBar({
type="button" type="button"
onClick={() => onNavigate(item.id)} onClick={() => onNavigate(item.id)}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
className={`relative px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${ className={`relative whitespace-nowrap px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
active active
? 'font-semibold text-slate-900 border-accent' ? 'font-semibold text-slate-900 border-accent'
: 'font-medium text-slate-500 border-transparent hover:text-slate-800' : 'font-medium text-slate-500 border-transparent hover:text-slate-800'
@ -145,7 +213,7 @@ export function TopBar({
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div ref={rightRef} className="flex items-center gap-2 flex-shrink-0">
{onOpenCommandK && ( {onOpenCommandK && (
<button <button
type="button" type="button"

View File

@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { shouldCollapseNav } from './TopBar';
describe('shouldCollapseNav', () => {
it('does not collapse while widths are unmeasured (avoids hamburger flash)', () => {
expect(shouldCollapseNav(0, 0, 0)).toBe(false);
expect(shouldCollapseNav(1000, 300, 0)).toBe(false); // navWidth 0 = not measured yet
});
it('keeps tabs when the nav fits the leftover space (minus buffer)', () => {
// row 1000, fixed 300 → 700 available, minus 40 buffer = 660. nav 600 fits.
expect(shouldCollapseNav(1000, 300, 600)).toBe(false);
});
it('collapses when the nav no longer fits', () => {
// 700 available - 40 buffer = 660. nav 700 does not fit.
expect(shouldCollapseNav(1000, 300, 700)).toBe(true);
});
it('respects the buffer at the boundary', () => {
// available 660 after buffer; nav exactly 660 fits, 661 collapses.
expect(shouldCollapseNav(1000, 300, 660)).toBe(false);
expect(shouldCollapseNav(1000, 300, 661)).toBe(true);
});
it('honors a custom buffer', () => {
expect(shouldCollapseNav(1000, 300, 690, 10)).toBe(false); // 690 <= 690
expect(shouldCollapseNav(1000, 300, 691, 10)).toBe(true);
});
});

View File

@ -19,7 +19,7 @@ interface TaskListPanelProps {
onSelectTask: (id: number) => void; onSelectTask: (id: number) => void;
onOpenCreate: () => void; onOpenCreate: () => void;
/** /**
* Owner scope (mine/all). Only meaningful when scopeEnabled auth must be * Owner scope (mine/others). Only meaningful when scopeEnabled auth must be
* on and the viewer known; otherwise everything is owner 'local' and the * on and the viewer known; otherwise everything is owner 'local' and the
* control is hidden. * control is hidden.
*/ */
@ -36,12 +36,12 @@ interface TaskListPanelProps {
function ScopeToggle({ function ScopeToggle({
scope, scope,
mineCount, mineCount,
allCount, othersCount,
onScopeChange, onScopeChange,
}: { }: {
scope: TaskScope; scope: TaskScope;
mineCount: number; mineCount: number;
allCount: number; othersCount: number;
onScopeChange: (scope: TaskScope) => void; onScopeChange: (scope: TaskScope) => void;
}) { }) {
const { t } = useTranslation('list'); const { t } = useTranslation('list');
@ -62,7 +62,7 @@ function ScopeToggle({
return ( return (
<div className="flex gap-0.5 p-0.5 mb-2 rounded-md bg-canvas border border-hairline" role="group" aria-label={t('scope.aria')}> <div className="flex gap-0.5 p-0.5 mb-2 rounded-md bg-canvas border border-hairline" role="group" aria-label={t('scope.aria')}>
{seg('mine', t('scope.mine'), mineCount)} {seg('mine', t('scope.mine'), mineCount)}
{seg('all', t('scope.all'), allCount)} {seg('others', t('scope.others'), othersCount)}
</div> </div>
); );
} }
@ -88,8 +88,11 @@ export function TaskListPanel({
const { t } = useTranslation('list'); const { t } = useTranslation('list');
// Owner scope is the outermost filter: status counts / search / sort all // Owner scope is the outermost filter: status counts / search / sort all
// operate on the scoped list so "自分" mode never counts others' tasks. // operate on the scoped list so "自分" mode never counts others' tasks.
const effectiveScope: TaskScope = scopeEnabled ? scope : 'all'; // When the toggle is disabled (no-auth: every task is owner 'local'), the
const localTasks = filterTasksByScope(allTasks, effectiveScope, currentUserId); // scope partition is meaningless — show the full list unfiltered.
const localTasks = scopeEnabled
? filterTasksByScope(allTasks, scope, currentUserId)
: allTasks;
if (mode === 'rail') { if (mode === 'rail') {
const localColumnsRail: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => { const localColumnsRail: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s); acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
@ -169,7 +172,7 @@ export function TaskListPanel({
<ScopeToggle <ScopeToggle
scope={scope} scope={scope}
mineCount={filterTasksByScope(allTasks, 'mine', currentUserId).length} mineCount={filterTasksByScope(allTasks, 'mine', currentUserId).length}
allCount={allTasks.length} othersCount={filterTasksByScope(allTasks, 'others', currentUserId).length}
onScopeChange={onScopeChange} onScopeChange={onScopeChange}
/> />
)} )}

View File

@ -23,6 +23,7 @@ import { NotificationsForm } from './NotificationsForm';
import { BrandingForm } from './BrandingForm'; import { BrandingForm } from './BrandingForm';
import { MemoryLearningForm } from './MemoryLearningForm'; import { MemoryLearningForm } from './MemoryLearningForm';
import { MetricsForm } from './MetricsForm'; import { MetricsForm } from './MetricsForm';
import { ServerTlsForm } from './ServerTlsForm';
import { ReflectionForm } from './ReflectionForm'; import { ReflectionForm } from './ReflectionForm';
import { McpForm } from './McpForm'; import { McpForm } from './McpForm';
import { SshForm } from './SshForm'; import { SshForm } from './SshForm';
@ -195,6 +196,9 @@ function ConfigFormInner({ section }: ConfigFormProps) {
case 'gateway-server': return <GatewayServerForm {...formProps} />; case 'gateway-server': return <GatewayServerForm {...formProps} />;
case 'llm-metrics': return <MetricsForm {...formProps} />; case 'llm-metrics': return <MetricsForm {...formProps} />;
// ── Server / Network
case 'server-tls': return <ServerTlsForm {...formProps} />;
// ── Agent Runtime // ── Agent Runtime
case 'ask-subtasks': return <AskSubtasksForm {...formProps} />; case 'ask-subtasks': return <AskSubtasksForm {...formProps} />;
case 'context': return <ContextForm {...formProps} />; case 'context': return <ContextForm {...formProps} />;

View File

@ -0,0 +1,119 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import { StringArrayEditor } from './StringArrayEditor';
import type { SectionFormProps } from './types';
/**
* Server TLS settings binds to the `server.tls` config object.
*
* Editable fields: enabled, certFile, keyFile, httpRedirect,
* httpRedirectPort, redirectHost, selfSignedHosts.
*
* Fields intentionally omitted from the UI (left to config.yaml):
* minVersion, selfSignedDir. The onChange path mechanism in ConfigFormInner
* uses setNestedValue which does a shallow-merge, so unedited fields are
* preserved on save automatically.
*
* TODO(server-tls-info): cert source/expiry/fingerprint panel + regenerate
* button need a GET /api/server/tls-info endpoint (future).
*/
export function ServerTlsForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
// Navigate to the server.tls sub-object; fall back to empty object if absent.
const tls = (config?.server?.tls) ?? {};
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">
{t('serverTls.title')}
</h2>
{/* Restart-required banner — always visible */}
<div className="px-3 py-2.5 rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-500/10 dark:border-amber-500/30 text-xs text-amber-800 dark:text-amber-300">
{t('serverTls.restartBanner')}
</div>
{/* Enable HTTPS */}
<div>
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700 dark:text-slate-200">
<input
type="checkbox"
checked={tls.enabled === true}
onChange={e => onChange('server.tls.enabled', e.target.checked)}
/>
<span>{t('serverTls.enabled')}</span>
</label>
<HelpText>{t('serverTls.enabledHelp')}</HelpText>
</div>
{/* Certificate file */}
<div>
<FieldLabel>{t('serverTls.certFile')}</FieldLabel>
<FieldInput
value={tls.certFile ?? ''}
onChange={v => onChange('server.tls.certFile', v || undefined)}
placeholder="/etc/ssl/certs/server.pem"
/>
</div>
{/* Private key file */}
<div>
<FieldLabel>{t('serverTls.keyFile')}</FieldLabel>
<FieldInput
value={tls.keyFile ?? ''}
onChange={v => onChange('server.tls.keyFile', v || undefined)}
placeholder="/etc/ssl/private/server.key"
/>
<HelpText>{t('serverTls.certHelp')}</HelpText>
</div>
{/* HTTP → HTTPS redirect */}
<div>
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700 dark:text-slate-200">
<input
type="checkbox"
checked={tls.httpRedirect === true}
onChange={e => onChange('server.tls.httpRedirect', e.target.checked)}
/>
<span>{t('serverTls.httpRedirect')}</span>
</label>
</div>
{/* HTTP redirect port */}
<div>
<FieldLabel>{t('serverTls.httpRedirectPort')}</FieldLabel>
<FieldInput
type="number"
value={tls.httpRedirectPort != null ? String(tls.httpRedirectPort) : ''}
onChange={v => {
const n = parseInt(v, 10);
onChange('server.tls.httpRedirectPort', isNaN(n) ? undefined : n);
}}
placeholder="9080"
/>
</div>
{/* Redirect host (optional) */}
<div>
<FieldLabel>{t('serverTls.redirectHost')}</FieldLabel>
<FieldInput
value={tls.redirectHost ?? ''}
onChange={v => onChange('server.tls.redirectHost', v || undefined)}
placeholder="example.com"
/>
</div>
{/* Additional SAN hostnames for self-signed cert */}
<div>
<FieldLabel>{t('serverTls.selfSignedHosts')}</FieldLabel>
<StringArrayEditor
value={tls.selfSignedHosts ?? []}
onChange={v => onChange('server.tls.selfSignedHosts', v)}
placeholder="example.com / 10.0.0.10"
/>
</div>
</div>
);
}

View File

@ -90,6 +90,13 @@ const CONFIG_GROUPS = [
{ id: 'ssh', label: 'Admin SSH' }, { id: 'ssh', label: 'Admin SSH' },
], ],
}, },
{
label: 'Network',
adminOnly: true,
sections: [
{ id: 'server-tls', label: 'HTTPS / TLS' },
],
},
] as const; ] as const;
/** /**

View File

@ -0,0 +1,367 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { getUsageDaily, type UsageBucket, type UsageCounters } from '../../api';
type Preset = 'last7' | 'last30' | 'last90' | 'ytd' | 'custom';
type Gran = 'day' | 'week' | 'month';
function utcToday(): string {
return new Date().toISOString().slice(0, 10);
}
function shiftDay(day: string, delta: number): string {
const x = new Date(`${day}T00:00:00.000Z`);
x.setUTCDate(x.getUTCDate() + delta);
return x.toISOString().slice(0, 10);
}
function yearStart(): string {
return `${new Date().getUTCFullYear()}-01-01`;
}
function fmtTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function total(c: UsageCounters): number {
return c.tokensIn + c.tokensOut;
}
function zeroCounters(): UsageCounters {
return { tokensIn: 0, tokensOut: 0, requests: 0 };
}
// Mirror the server's bucket keys (usage-api.ts) so we can fill empty buckets
// and keep the chart's time axis linear instead of index-based.
function isoWeekKey(day: string): string {
const d = new Date(`${day}T00:00:00.000Z`);
const dayNum = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - dayNum + 3);
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
const week = 1 + Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86_400_000));
return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
}
function bucketKey(day: string, g: Gran): string {
if (g === 'month') return day.slice(0, 7);
if (g === 'week') return isoWeekKey(day);
return day;
}
/** Ordered, gap-free bucket list across [from, to] at the chosen granularity. */
function denseBuckets(series: UsageBucket[], from: string, to: string, g: Gran): UsageBucket[] {
const byKey = new Map(series.map((b) => [b.bucket, b]));
const out: UsageBucket[] = [];
const seen = new Set<string>();
let day = from;
let guard = 0;
while (day <= to && guard++ < 2000) {
const key = bucketKey(day, g);
if (!seen.has(key)) {
seen.add(key);
out.push(byKey.get(key) ?? { bucket: key, gateway: zeroCounters(), direct: zeroCounters() });
}
day = shiftDay(day, 1);
}
return out;
}
function rangeFor(preset: Preset, customFrom: string, customTo: string): { from: string; to: string } {
const to = utcToday();
switch (preset) {
case 'last7': return { from: shiftDay(to, -6), to };
case 'last90': return { from: shiftDay(to, -89), to };
case 'ytd': return { from: yearStart(), to };
case 'custom': return { from: customFrom || shiftDay(to, -29), to: customTo || to };
case 'last30':
default: return { from: shiftDay(to, -29), to };
}
}
const GW = '#6366f1'; // indigo-500 — gateway
const DR = '#22c55e'; // green-500 — direct
/** i18next t with interpolation support (widened from the bare key signature). */
type TFn = (key: string, opts?: Record<string, unknown>) => string;
export function UsagePage() {
const { t } = useTranslation('usage');
const [preset, setPreset] = useState<Preset>('last30');
const [granularity, setGranularity] = useState<Gran>('day');
const [customFrom, setCustomFrom] = useState('');
const [customTo, setCustomTo] = useState('');
const { from, to } = rangeFor(preset, customFrom, customTo);
// Client-side guard: don't fire a request the server would 400 on; show an
// inline message instead of a generic error.
const customInvalid = preset === 'custom' && !!customFrom && !!customTo && customFrom > customTo;
const { data, isLoading, error } = useQuery({
queryKey: ['usage-daily', from, to, granularity],
queryFn: () => getUsageDaily({ from, to, granularity }),
enabled: !customInvalid,
});
const presets: Preset[] = ['last7', 'last30', 'last90', 'ytd', 'custom'];
const grans: Gran[] = ['day', 'week', 'month'];
const series: UsageBucket[] = data?.series ?? [];
// Gap-free buckets so the bar/line x-axis is time-linear, not index-based.
const dense = useMemo(
() => (data ? denseBuckets(series, data.from, data.to, granularity) : []),
[series, data, granularity],
);
const maxBucket = useMemo(
() => dense.reduce((m, b) => Math.max(m, total(b.gateway) + total(b.direct)), 0),
[dense],
);
const cumulative = useMemo(() => {
let run = 0;
return dense.map((b) => {
run += total(b.gateway) + total(b.direct);
return run;
});
}, [dense]);
const maxCumulative = cumulative.length ? cumulative[cumulative.length - 1] : 0;
return (
<div className="flex-1 min-h-0 overflow-auto">
<div className="max-w-5xl mx-auto p-6 space-y-5">
<header>
<h1 className="text-lg font-semibold text-slate-800 dark:text-slate-100">{t('title')}</h1>
<p className="text-[13px] text-slate-500 dark:text-slate-400 mt-1">{t('subtitle')}</p>
</header>
{/* Controls */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex gap-1">
{presets.map((p) => (
<button
key={p}
onClick={() => setPreset(p)}
className={`px-2.5 py-1 text-xs rounded-md border transition-colors ${
preset === p
? 'bg-accent text-white border-accent'
: 'border-hairline text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
}`}
>
{t(`range.${p}`)}
</button>
))}
</div>
{preset === 'custom' && (
<div className="flex items-center gap-1.5 text-xs">
<input type="date" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)}
aria-invalid={customInvalid}
className="border border-hairline rounded px-1.5 py-1 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 [color-scheme:light] dark:[color-scheme:dark]" />
<span className="text-slate-400"></span>
<input type="date" value={customTo} onChange={(e) => setCustomTo(e.target.value)}
aria-invalid={customInvalid}
className="border border-hairline rounded px-1.5 py-1 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 [color-scheme:light] dark:[color-scheme:dark]" />
</div>
)}
<div className="flex items-center gap-1 ml-auto">
<span className="text-xs text-slate-400 mr-1">{t('granularity.label')}</span>
{grans.map((g) => (
<button
key={g}
onClick={() => setGranularity(g)}
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
granularity === g
? 'bg-accent text-white border-accent'
: 'border-hairline text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
}`}
>
{t(`granularity.${g}`)}
</button>
))}
</div>
</div>
{customInvalid && <div className="text-sm text-amber-600 dark:text-amber-400">{t('range.invalid')}</div>}
{!customInvalid && isLoading && <div className="text-sm text-slate-400 italic">{t('loading')}</div>}
{!customInvalid && error && <div className="text-sm text-red-600">{t('error')}</div>}
{!customInvalid && data && (
<>
<TotalsCards totals={data.totals} t={t} />
{series.length === 0 ? (
<div className="border border-hairline rounded-lg p-8 text-center text-sm text-slate-400 italic">
{t('empty')}
</div>
) : (
<>
<StackedBars series={dense} maxBucket={maxBucket} t={t} />
<CumulativeLine series={dense} cumulative={cumulative} max={maxCumulative} t={t} />
</>
)}
{data.byUser && data.byUser.length > 0 && <ByUserTable rows={data.byUser} t={t} />}
</>
)}
</div>
</div>
);
}
function TotalsCards({ totals, t }: { totals: { gateway: UsageCounters; direct: UsageCounters }; t: TFn }) {
const combined: UsageCounters = {
tokensIn: totals.gateway.tokensIn + totals.direct.tokensIn,
tokensOut: totals.gateway.tokensOut + totals.direct.tokensOut,
requests: totals.gateway.requests + totals.direct.requests,
};
const card = (label: string, c: UsageCounters, dot?: string) => (
<div className="border border-hairline rounded-lg p-3">
<div className="flex items-center gap-1.5 mb-2">
{dot && <span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: dot }} />}
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{label}</span>
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<div>
<div className="text-[11px] text-slate-400">{t('totals.input')}</div>
<div className="font-mono">{fmtTokens(c.tokensIn)}</div>
</div>
<div>
<div className="text-[11px] text-slate-400">{t('totals.output')}</div>
<div className="font-mono">{fmtTokens(c.tokensOut)}</div>
</div>
<div>
<div className="text-[11px] text-slate-400">{t('totals.requests')}</div>
<div className="font-mono">{c.requests.toLocaleString()}</div>
</div>
</div>
</div>
);
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{card(t('totals.combined'), combined)}
{card(t('totals.gateway'), totals.gateway, GW)}
{card(t('totals.direct'), totals.direct, DR)}
</div>
);
}
function StackedBars({ series, maxBucket, t }: { series: UsageBucket[]; maxBucket: number; t: TFn }) {
const grand = series.reduce((s, b) => s + total(b.gateway) + total(b.direct), 0);
const ariaLabel = t('chart.barsAria', { title: t('chart.tokensTitle'), total: fmtTokens(grand), count: series.length });
return (
<div className="border border-hairline rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{t('chart.tokensTitle')}</span>
<Legend t={t} />
</div>
<div className="flex items-end gap-1 h-44" role="img" aria-label={ariaLabel}>
{series.map((b) => {
const g = total(b.gateway);
const d = total(b.direct);
const sum = g + d;
const hPct = maxBucket > 0 ? (sum / maxBucket) * 100 : 0;
const gPct = sum > 0 ? (g / sum) * 100 : 0;
return (
<div key={b.bucket} className="flex-1 min-w-0 flex flex-col items-center group relative">
<div className="w-full flex flex-col justify-end" style={{ height: '100%' }}>
<div className="w-full rounded-t-sm overflow-hidden flex flex-col" style={{ height: `${Math.max(hPct, sum > 0 ? 2 : 0)}%` }}>
<div style={{ height: `${gPct}%`, background: GW }} />
<div style={{ height: `${100 - gPct}%`, background: DR }} />
</div>
</div>
{/* Tooltip (decorative — chart summary is exposed via aria-label) */}
<div aria-hidden="true" className="pointer-events-none absolute bottom-full mb-1 hidden group-hover:block z-10 whitespace-nowrap bg-slate-800 text-white text-[10px] rounded px-1.5 py-1 shadow">
<div className="font-mono">{b.bucket}</div>
<div><span style={{ color: GW }}></span> {fmtTokens(g)}</div>
<div><span style={{ color: DR }}></span> {fmtTokens(d)}</div>
</div>
</div>
);
})}
</div>
<div className="flex justify-between mt-2 text-[10px] text-slate-400 font-mono">
<span>{series[0]?.bucket}</span>
<span>{series[series.length - 1]?.bucket}</span>
</div>
</div>
);
}
function CumulativeLine({ series, cumulative, max, t }: { series: UsageBucket[]; cumulative: number[]; max: number; t: TFn }) {
const W = 600;
const H = 140;
const pad = 4;
const n = cumulative.length;
const coords = cumulative.map((v, i) => {
const x = n <= 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad);
const y = max > 0 ? H - pad - (v / max) * (H - 2 * pad) : H - pad;
return { x, y };
});
const points = coords.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(' ');
const ariaLabel = t('chart.lineAria', { total: fmtTokens(max) });
return (
<div className="border border-hairline rounded-lg p-4">
<div className="flex items-baseline justify-between mb-3">
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{t('chart.cumulativeTitle')}</span>
<span className="text-[11px] text-slate-500 font-mono">{t('chart.total', { value: fmtTokens(max) })}</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-36" role="img" aria-label={ariaLabel}>
{/* A single bucket can't form a line — draw the point so it's visible. */}
{n === 1 ? (
<circle cx={coords[0].x} cy={coords[0].y} r={3} fill="var(--accent, #6366f1)" vectorEffect="non-scaling-stroke" />
) : (
<polyline points={points} fill="none" stroke="var(--accent, #6366f1)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
)}
</svg>
<div className="flex justify-between mt-1 text-[10px] text-slate-400 font-mono">
<span>{series[0]?.bucket}</span>
<span>{series[series.length - 1]?.bucket}</span>
</div>
</div>
);
}
function Legend({ t }: { t: TFn }) {
return (
<div className="flex items-center gap-3 text-[11px] text-slate-500">
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: GW }} />{t('chart.legendGateway')}</span>
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: DR }} />{t('chart.legendDirect')}</span>
</div>
);
}
function ByUserTable({ rows, t }: { rows: Array<{ userId: string; displayName: string } & UsageCounters>; t: TFn }) {
// Localize the sentinels; real users show their resolved name with the id as
// a secondary line.
const label = (r: { userId: string; displayName: string }): { primary: string; secondary?: string } => {
if (r.userId === 'local') return { primary: t('byUser.local') };
if (r.userId === 'system') return { primary: t('byUser.system') };
return { primary: r.displayName, secondary: r.displayName === r.userId ? undefined : r.userId };
};
return (
<div className="border border-hairline rounded-lg p-4">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-3">{t('byUser.title')}</div>
<table className="w-full text-sm">
<thead>
<tr className="text-[11px] text-slate-400 text-left">
<th className="font-medium pb-1">{t('byUser.user')}</th>
<th className="font-medium pb-1 text-right">{t('byUser.input')}</th>
<th className="font-medium pb-1 text-right">{t('byUser.output')}</th>
<th className="font-medium pb-1 text-right">{t('byUser.requests')}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const l = label(r);
return (
<tr key={r.userId} className="border-t border-hairline">
<td className="py-1.5 text-slate-600 dark:text-slate-300">
<span>{l.primary}</span>
{l.secondary && <span className="ml-1.5 text-[11px] text-slate-400 font-mono">{l.secondary}</span>}
</td>
<td className="py-1.5 text-right font-mono">{fmtTokens(r.tokensIn)}</td>
<td className="py-1.5 text-right font-mono">{fmtTokens(r.tokensOut)}</td>
<td className="py-1.5 text-right font-mono">{r.requests.toLocaleString()}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@ -4,6 +4,7 @@ import {
fetchLocalFileContent, fetchLocalFileContent,
fetchSubtaskFileContent, fetchSubtaskFileContent,
getLocalFileRawUrl, getLocalFileRawUrl,
getTrustedLocalHtmlUrl,
subtaskFileRawUrl, subtaskFileRawUrl,
} from '../api'; } from '../api';
import { isImagePreviewable, isPdfPreviewable, isTextPreviewable, isHtmlPreviewable } from '../lib/utils'; import { isImagePreviewable, isPdfPreviewable, isTextPreviewable, isHtmlPreviewable } from '../lib/utils';
@ -17,6 +18,7 @@ export interface PreviewState {
section?: string; section?: string;
filePath?: string; filePath?: string;
editable?: boolean; editable?: boolean;
trustedHtmlUrl?: string;
} }
export function useFilePreview(onError: (msg: string) => void) { export function useFilePreview(onError: (msg: string) => void) {
@ -32,7 +34,11 @@ export function useFilePreview(onError: (msg: string) => void) {
try { try {
const canEdit = section === 'output' && isTextPreviewable(name); const canEdit = section === 'output' && isTextPreviewable(name);
if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) { if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) {
setPreviewState({ name, content: '', imageSrc: getLocalFileRawUrl(taskId, section, filePath), taskId, section, filePath, editable: false }); const imageSrc = getLocalFileRawUrl(taskId, section, filePath);
const trustedHtmlUrl = isHtmlPreviewable(name)
? getTrustedLocalHtmlUrl(taskId, section, filePath)
: undefined;
setPreviewState({ name, content: '', imageSrc, trustedHtmlUrl, taskId, section, filePath, editable: false });
return; return;
} }
const content = await fetchLocalFileContent(taskId, section, filePath); const content = await fetchLocalFileContent(taskId, section, filePath);

View File

@ -21,6 +21,7 @@
}, },
"focus": { "toStandard": "Back to standard view", "toFocused": "Focused mode (TASK column to a thin rail / split chat and workspace)", "toFocusedShort": "Switch to focused mode" }, "focus": { "toStandard": "Back to standard view", "toFocused": "Focused mode (TASK column to a thin rail / split chat and workspace)", "toFocusedShort": "Switch to focused mode" },
"feedback": { "title": "Feedback", "change": "Change", "good": "Good", "bad": "Needs improvement", "commentPlaceholder": "Comment (optional)", "cancel": "Cancel", "submitting": "Submitting...", "submit": "Submit" }, "feedback": { "title": "Feedback", "change": "Change", "good": "Good", "bad": "Needs improvement", "commentPlaceholder": "Comment (optional)", "cancel": "Cancel", "submitting": "Submitting...", "submit": "Submit" },
"title": { "edit": "Edit title", "regenerate": "Regenerate title with AI", "save": "Save", "saving": "Saving...", "cancel": "Cancel" },
"mission": { "mission": {
"pinnedMemo": "pinned memo", "edit": "Edit", "cancel": "Cancel", "saving": "Saving...", "save": "Save", "pinnedMemo": "pinned memo", "edit": "Edit", "cancel": "Cancel", "saving": "Saving...", "save": "Save",
"emptyHelp": "No Mission Brief yet. The agent writes here automatically as needed, but pinning the goal / progress / remaining work manually keeps the essence in view even through long conversations.", "emptyHelp": "No Mission Brief yet. The agent writes here automatically as needed, but pinning the goal / progress / remaining work manually keeps the essence in view even through long conversations.",

View File

@ -11,6 +11,7 @@
"preparing": "Preparing...", "preparing": "Preparing...",
"button": "PDF / Print" "button": "PDF / Print"
}, },
"trustedHtml": { "button": "Open as trusted", "tooltip": "Open in a new tab with scripts allowed" },
"cancel": "Cancel", "cancel": "Cancel",
"saving": "Saving...", "saving": "Saving...",
"save": "Save", "save": "Save",

View File

@ -9,7 +9,8 @@
"settings": "Settings", "settings": "Settings",
"users": "Users", "users": "Users",
"help": "Help", "help": "Help",
"userfolder": "User Folder" "userfolder": "User Folder",
"usage": "Usage"
}, },
"commandPalette": { "commandPalette": {
"open": "Open command palette", "open": "Open command palette",

View File

@ -41,6 +41,6 @@
"scope": { "scope": {
"aria": "Task display scope", "aria": "Task display scope",
"mine": "Mine", "mine": "Mine",
"all": "All" "others": "Others"
} }
} }

View File

@ -779,5 +779,18 @@
}, },
"settingsPage": { "settingsPage": {
"sectionList": "Section list" "sectionList": "Section list"
},
"serverTls": {
"title": "HTTPS / TLS",
"enabled": "Serve over HTTPS",
"enabledHelp": "Terminate TLS in the app. Self-signed by default — browsers (and the noVNC / SSH console over wss) will warn until you install a real certificate. Requires a restart to apply.",
"certFile": "Certificate file (PEM)",
"keyFile": "Private key file (PEM)",
"certHelp": "Leave both empty to use an auto-generated self-signed certificate.",
"httpRedirect": "Redirect HTTP to HTTPS",
"httpRedirectPort": "HTTP redirect port",
"redirectHost": "Redirect host (optional)",
"selfSignedHosts": "Additional certificate hostnames",
"restartBanner": "Changes to HTTPS settings require a server restart to take effect."
} }
} }

View File

@ -0,0 +1,47 @@
{
"title": "LLM Usage",
"subtitle": "Combined token usage across AAO Gateway and direct calls, aggregated per UTC day. This is a separate view from the gateway per-key billing panel.",
"range": {
"last7": "Last 7 days",
"last30": "Last 30 days",
"last90": "Last 90 days",
"ytd": "Year to date",
"custom": "Custom",
"invalid": "Start date must be on or before the end date."
},
"granularity": {
"label": "Granularity",
"day": "Day",
"week": "Week",
"month": "Month"
},
"totals": {
"input": "Input tokens",
"output": "Output tokens",
"requests": "Requests",
"gateway": "Gateway",
"direct": "Direct",
"combined": "Combined"
},
"chart": {
"tokensTitle": "Tokens by period (gateway vs direct)",
"cumulativeTitle": "Cumulative tokens",
"legendGateway": "Gateway",
"legendDirect": "Direct",
"total": "Total {{value}}",
"barsAria": "{{title}}: {{total}} tokens total across {{count}} periods",
"lineAria": "Cumulative tokens over time, reaching {{total}} total"
},
"byUser": {
"title": "By user",
"user": "User",
"input": "Input",
"output": "Output",
"requests": "Requests",
"local": "Local",
"system": "System"
},
"empty": "No usage recorded in this range yet. Aggregation starts from the day this feature went live; past usage cannot be backfilled.",
"loading": "Loading usage…",
"error": "Failed to load usage."
}

View File

@ -21,6 +21,7 @@
}, },
"focus": { "toStandard": "標準表示に戻る", "toFocused": "集中モード (TASK 列を細い rail に / Chat と Workspace を可変分割)", "toFocusedShort": "集中モードに切替" }, "focus": { "toStandard": "標準表示に戻る", "toFocused": "集中モード (TASK 列を細い rail に / Chat と Workspace を可変分割)", "toFocusedShort": "集中モードに切替" },
"feedback": { "title": "フィードバック", "change": "変更", "good": "良かった", "bad": "改善が必要", "commentPlaceholder": "コメント(任意)", "cancel": "キャンセル", "submitting": "送信中...", "submit": "送信" }, "feedback": { "title": "フィードバック", "change": "変更", "good": "良かった", "bad": "改善が必要", "commentPlaceholder": "コメント(任意)", "cancel": "キャンセル", "submitting": "送信中...", "submit": "送信" },
"title": { "edit": "タイトルを編集", "regenerate": "AIでタイトルを再生成", "save": "保存", "saving": "保存中...", "cancel": "キャンセル" },
"mission": { "mission": {
"pinnedMemo": "固定メモ", "edit": "編集", "cancel": "キャンセル", "saving": "保存中...", "save": "保存", "pinnedMemo": "固定メモ", "edit": "編集", "cancel": "キャンセル", "saving": "保存中...", "save": "保存",
"emptyHelp": "まだ Mission Brief は設定されていません。エージェントが必要に応じて自動で書き込みますが、手動で目標 / 進捗 / 残タスクをここに固定しておくことで、長い会話の途中でも本質を見失わないように誘導できます。", "emptyHelp": "まだ Mission Brief は設定されていません。エージェントが必要に応じて自動で書き込みますが、手動で目標 / 進捗 / 残タスクをここに固定しておくことで、長い会話の途中でも本質を見失わないように誘導できます。",

View File

@ -11,6 +11,7 @@
"preparing": "準備中...", "preparing": "準備中...",
"button": "PDF / 印刷" "button": "PDF / 印刷"
}, },
"trustedHtml": { "button": "信頼済みとして開く", "tooltip": "スクリプトを許可して別タブで開く" },
"cancel": "キャンセル", "cancel": "キャンセル",
"saving": "保存中...", "saving": "保存中...",
"save": "保存", "save": "保存",

View File

@ -9,7 +9,8 @@
"settings": "設定", "settings": "設定",
"users": "ユーザー", "users": "ユーザー",
"help": "ヘルプ", "help": "ヘルプ",
"userfolder": "ユーザーフォルダ" "userfolder": "ユーザーフォルダ",
"usage": "使用量"
}, },
"commandPalette": { "commandPalette": {
"open": "コマンドパレットを開く", "open": "コマンドパレットを開く",

View File

@ -41,6 +41,6 @@
"scope": { "scope": {
"aria": "タスクの表示範囲", "aria": "タスクの表示範囲",
"mine": "自分", "mine": "自分",
"all": "すべて" "others": "他のユーザ"
} }
} }

View File

@ -779,5 +779,18 @@
}, },
"settingsPage": { "settingsPage": {
"sectionList": "セクション一覧" "sectionList": "セクション一覧"
},
"serverTls": {
"title": "HTTPS / TLS",
"enabled": "HTTPS で配信",
"enabledHelp": "アプリ内で TLS を終端します。デフォルトは自己署名証明書のため、正式な証明書を導入するまでブラウザ(および wss 経由の noVNC・SSH コンソール)に警告が表示されます。反映にはサーバーの再起動が必要です。",
"certFile": "証明書ファイルPEM",
"keyFile": "秘密鍵ファイルPEM",
"certHelp": "両方を空欄にすると、自動生成の自己署名証明書を使用します。",
"httpRedirect": "HTTP を HTTPS へリダイレクト",
"httpRedirectPort": "HTTP リダイレクトポート",
"redirectHost": "リダイレクト先ホスト(省略可)",
"selfSignedHosts": "証明書の追加ホスト名",
"restartBanner": "HTTPS 設定の変更を反映するには、サーバーの再起動が必要です。"
} }
} }

View File

@ -0,0 +1,47 @@
{
"title": "LLM 使用量",
"subtitle": "AAO Gateway 経由と Direct を合算したトークン使用量を UTC 日次で集計します。ゲートウェイのキー別課金パネルとは別集計です。",
"range": {
"last7": "直近7日",
"last30": "直近30日",
"last90": "直近90日",
"ytd": "年初来",
"custom": "カスタム",
"invalid": "開始日は終了日以前にしてください。"
},
"granularity": {
"label": "粒度",
"day": "日",
"week": "週",
"month": "月"
},
"totals": {
"input": "入力トークン",
"output": "出力トークン",
"requests": "リクエスト",
"gateway": "Gateway",
"direct": "Direct",
"combined": "合計"
},
"chart": {
"tokensTitle": "期間別トークンGateway / Direct",
"cumulativeTitle": "累積トークン",
"legendGateway": "Gateway",
"legendDirect": "Direct",
"total": "合計 {{value}}",
"barsAria": "{{title}}:合計 {{total}} トークン、{{count}} 期間",
"lineAria": "累積トークンの推移、合計 {{total}} に到達"
},
"byUser": {
"title": "ユーザー別",
"user": "ユーザー",
"input": "入力",
"output": "出力",
"requests": "リクエスト",
"local": "ローカル",
"system": "システム"
},
"empty": "この期間の使用量はまだありません。集計はこの機能の稼働開始日から始まり、過去分は遡って集計できません。",
"loading": "使用量を読み込み中…",
"error": "使用量の読み込みに失敗しました。"
}

View File

@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { cronToFormState } from './cronForm';
/**
* The backend's convertToCron (src/scheduler.ts) mirrored here so the test
* asserts a true round-trip: editing a saved schedule and saving it back must
* not change the cron expression.
*/
function convertToCron(f: ReturnType<typeof cronToFormState>): string {
const m = f.minute ?? 0;
const h = f.hour ?? 0;
switch (f.scheduleType) {
case 'daily': return `${m} ${h} * * *`;
case 'weekly': return `${m} ${h} * * ${f.dayOfWeek ?? 0}`;
case 'monthly': return `${m} ${h} ${f.dayOfMonth ?? 1} * *`;
case 'cron': return f.cronExpression;
case 'once': return 'once';
}
}
describe('cronToFormState', () => {
it('classifies simple presets (daily/weekly/monthly)', () => {
expect(cronToFormState('0 9 * * *')).toMatchObject({ scheduleType: 'daily', hour: 9, minute: 0 });
expect(cronToFormState('30 14 * * *')).toMatchObject({ scheduleType: 'daily', hour: 14, minute: 30 });
expect(cronToFormState('0 9 * * 3')).toMatchObject({ scheduleType: 'weekly', hour: 9, minute: 0, dayOfWeek: 3 });
expect(cronToFormState('0 9 15 * *')).toMatchObject({ scheduleType: 'monthly', hour: 9, minute: 0, dayOfMonth: 15 });
});
it("keeps custom expressions as 'cron' and preserves the raw expression", () => {
for (const expr of [
'0 9 * * 1-5', '*/30 * * * *', '0 */2 * * *', '0 9,17 * * *', '15 9 1,15 * *', '0 9 1 6 *',
'0 9 * * 7', // Sunday-as-7 is out of the editor's 0-6 select range
'0 9 * * *', // double space → 6 tokens, not a clean preset
]) {
const f = cronToFormState(expr);
expect(f.scheduleType).toBe('cron');
expect(f.cronExpression).toBe(expr);
}
});
it('classifies Sunday-as-0 weekly', () => {
expect(cronToFormState('0 9 * * 0')).toMatchObject({ scheduleType: 'weekly', dayOfWeek: 0 });
});
it('never produces NaN fields for custom expressions', () => {
const f = cronToFormState('0 9 * * 1-5');
expect(Number.isNaN(f.hour)).toBe(false);
expect(Number.isNaN(f.minute)).toBe(false);
expect(Number.isNaN(f.dayOfWeek)).toBe(false);
expect(Number.isNaN(f.dayOfMonth)).toBe(false);
});
it("maps 'once' to the once type", () => {
expect(cronToFormState('once')).toMatchObject({ scheduleType: 'once' });
});
it('treats a malformed (non 5-field) string as a raw cron expression', () => {
expect(cronToFormState('garbage')).toMatchObject({ scheduleType: 'cron', cronExpression: 'garbage' });
});
it('round-trips losslessly through the backend convertToCron', () => {
const cases = [
'0 9 * * *', '30 14 * * *', '0 9 * * 3', '0 9 15 * *',
'0 9 * * 1-5', '*/30 * * * *', '0 */2 * * *', '0 9,17 * * *',
'15 9 1,15 * *', '0 9 1 6 *', 'once',
];
for (const expr of cases) {
expect(convertToCron(cronToFormState(expr))).toBe(expr);
}
});
});

73
ui/src/lib/cronForm.ts Normal file
View File

@ -0,0 +1,73 @@
/**
* cronForm.ts cron
*
*
* DB cron
* (daily / weekly / monthly / cron / once)
*
* 重要: daily/weekly/monthly
* **** (: `0 9 * * *`) (`1-5`) / (`*/30`) /
* (`9,17`) / `*`
* `Number()` NaN
* cron `cron`
*/
export type ScheduleType = 'daily' | 'weekly' | 'monthly' | 'cron' | 'once';
export interface ScheduleFields {
scheduleType: ScheduleType;
hour: number;
minute: number;
dayOfWeek: number;
dayOfMonth: number;
cronExpression: string;
}
const isInt = (s: string): boolean => /^\d+$/.test(s);
const inRange = (s: string, lo: number, hi: number): boolean => {
if (!isInt(s)) return false;
const n = Number(s);
return n >= lo && n <= hi;
};
export function cronToFormState(cron: string): ScheduleFields {
// 任意の cron 文字列をそのまま保持するフォールバック (生の式を捨てない)。
const asCron: ScheduleFields = {
scheduleType: 'cron',
hour: 9,
minute: 0,
dayOfWeek: 1,
dayOfMonth: 1,
cronExpression: cron,
};
if (cron === 'once') {
return { scheduleType: 'once', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
}
const parts = cron.split(' ');
if (parts.length !== 5) return asCron;
const [min, hour, dom, mon, dow] = parts;
// プリセットは「分・時が整数」かつ「月は毎月 (*)」が前提。満たさなければ cron 扱い。
if (mon !== '*' || !isInt(min) || !isInt(hour)) return asCron;
const h = Number(hour);
const m = Number(min);
if (dom === '*' && dow === '*') {
return { scheduleType: 'daily', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
}
// weekly/monthly only when the day value is within the editor's select range
// (dow 0-6, dom 1-31). Out-of-range forms like `* * * * 7` (Sunday-as-7) stay
// as raw cron so re-saving never rewrites them to a different value.
if (dom === '*' && inRange(dow, 0, 6)) {
return { scheduleType: 'weekly', hour: h, minute: m, dayOfWeek: Number(dow), dayOfMonth: 1, cronExpression: '' };
}
if (inRange(dom, 1, 31) && dow === '*') {
return { scheduleType: 'monthly', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: Number(dom), cronExpression: '' };
}
// dom と dow が両方指定など、プリセットに当てはまらない組み合わせは cron 扱い。
return asCron;
}

View File

@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { splitFileName, fileCategory, categoryColorClass } from './fileType';
describe('splitFileName', () => {
it('splits a normal name into stem + ext (ext keeps the dot)', () => {
expect(splitFileName('report.xlsx')).toEqual({ stem: 'report', ext: '.xlsx' });
});
it('uses the LAST dot for multi-dot names', () => {
expect(splitFileName('archive.tar.gz')).toEqual({ stem: 'archive.tar', ext: '.gz' });
});
it('treats a leading-dot dotfile as having no extension', () => {
expect(splitFileName('.gitignore')).toEqual({ stem: '.gitignore', ext: '' });
});
it('returns empty ext when there is no dot', () => {
expect(splitFileName('README')).toEqual({ stem: 'README', ext: '' });
});
});
describe('fileCategory', () => {
it('maps known extensions (case-insensitive)', () => {
expect(fileCategory('a.PNG')).toBe('image');
expect(fileCategory('a.pdf')).toBe('pdf');
expect(fileCategory('a.xlsx')).toBe('spreadsheet');
expect(fileCategory('a.md')).toBe('document');
expect(fileCategory('a.pptx')).toBe('presentation');
expect(fileCategory('main.ts')).toBe('code');
expect(fileCategory('a.zip')).toBe('archive');
expect(fileCategory('a.mp3')).toBe('audio');
expect(fileCategory('a.mp4')).toBe('video');
});
it('falls back to other for unknown or missing extensions', () => {
expect(fileCategory('a.unknownext')).toBe('other');
expect(fileCategory('README')).toBe('other');
expect(fileCategory('.gitignore')).toBe('other');
});
});
describe('categoryColorClass', () => {
it('returns a literal tailwind text-color class for every category', () => {
for (const cat of ['image', 'pdf', 'spreadsheet', 'document', 'presentation', 'code', 'archive', 'audio', 'video', 'other'] as const) {
expect(categoryColorClass(cat)).toMatch(/^text-\w+-\d{3}$/);
}
});
});

75
ui/src/lib/fileType.ts Normal file
View File

@ -0,0 +1,75 @@
/**
* fileType.ts
*
* (a) (b)
* 使
*/
export type FileCategory =
| 'image'
| 'pdf'
| 'spreadsheet'
| 'document'
| 'presentation'
| 'code'
| 'archive'
| 'audio'
| 'video'
| 'other';
/**
* `.`
* - `report.xlsx` { stem: 'report', ext: '.xlsx' }
* - `archive.tar.gz` { stem: 'archive.tar', ext: '.gz' }
* - `.gitignore` { stem: '.gitignore', ext: '' }
* - `README` { stem: 'README', ext: '' }
*/
export function splitFileName(name: string): { stem: string; ext: string } {
const dot = name.lastIndexOf('.');
if (dot <= 0) return { stem: name, ext: '' }; // 先頭ドット(ドットファイル) or ドット無し
return { stem: name.slice(0, dot), ext: name.slice(dot) };
}
// 拡張子(小文字・ドット無し) → カテゴリ
const EXT_CATEGORY: Record<string, FileCategory> = {};
const register = (cat: FileCategory, exts: string[]) => {
for (const e of exts) EXT_CATEGORY[e] = cat;
};
register('image', ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'tif', 'tiff', 'heic']);
register('pdf', ['pdf']);
register('spreadsheet', ['xlsx', 'xls', 'xlsm', 'csv', 'tsv', 'ods']);
register('document', ['doc', 'docx', 'odt', 'rtf', 'txt', 'md', 'markdown', 'log']);
register('presentation', ['ppt', 'pptx', 'odp', 'key']);
register('code', [
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'py', 'rb', 'go', 'rs', 'java', 'kt',
'c', 'cc', 'cpp', 'h', 'hpp', 'cs', 'php', 'swift', 'sh', 'bash', 'zsh', 'sql',
'json', 'yaml', 'yml', 'toml', 'xml', 'html', 'htm', 'css', 'scss', 'less', 'ipynb',
]);
register('archive', ['zip', 'tar', 'gz', 'tgz', 'rar', '7z', 'bz2', 'xz', 'zst']);
register('audio', ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus']);
register('video', ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v']);
export function fileCategory(name: string): FileCategory {
const { ext } = splitFileName(name);
if (!ext) return 'other';
return EXT_CATEGORY[ext.slice(1).toLowerCase()] ?? 'other';
}
// Tailwind の content スキャンに拾わせるため、色クラスはリテラル文字列で持つ
// (動的に `text-${x}-500` を組み立てると JIT が purge する)。
const CATEGORY_COLOR: Record<FileCategory, string> = {
image: 'text-cyan-500',
pdf: 'text-red-500',
spreadsheet: 'text-green-600',
document: 'text-blue-500',
presentation: 'text-orange-500',
code: 'text-indigo-500',
archive: 'text-amber-500',
audio: 'text-purple-500',
video: 'text-pink-500',
other: 'text-slate-400',
};
export function categoryColorClass(cat: FileCategory): string {
return CATEGORY_COLOR[cat];
}

View File

@ -14,12 +14,20 @@ describe('filterTasksByScope', () => {
expect(filterTasksByScope(tasks, 'mine', 'alice').map(t => t.id)).toEqual([1, 3]); expect(filterTasksByScope(tasks, 'mine', 'alice').map(t => t.id)).toEqual([1, 3]);
}); });
it("scope='all' returns everything", () => { it("scope='others' keeps everyone else's tasks (incl. legacy null owners)", () => {
expect(filterTasksByScope(tasks, 'all', 'alice')).toHaveLength(5); expect(filterTasksByScope(tasks, 'others', 'alice').map(t => t.id)).toEqual([2, 4, 5]);
}); });
it('no current user (auth disabled) returns everything even for mine', () => { it('mine + others partition the full list with no overlap', () => {
const mine = filterTasksByScope(tasks, 'mine', 'alice').map(t => t.id);
const others = filterTasksByScope(tasks, 'others', 'alice').map(t => t.id);
expect([...mine, ...others].sort()).toEqual([1, 2, 3, 4, 5]);
expect(mine.filter(id => others.includes(id))).toEqual([]);
});
it('no current user (auth disabled) returns everything for either scope', () => {
expect(filterTasksByScope(tasks, 'mine', null)).toHaveLength(5); expect(filterTasksByScope(tasks, 'mine', null)).toHaveLength(5);
expect(filterTasksByScope(tasks, 'others', null)).toHaveLength(5);
}); });
it('legacy null/undefined owners never match mine', () => { it('legacy null/undefined owners never match mine', () => {

View File

@ -2,20 +2,23 @@
* taskScope.ts * taskScope.ts
* *
* visibility (public / org) * visibility (public / org)
* 'mine' ownerId * 2:
* (currentUserId ) * 'mine' ownerId
* * 'others' ( + legacy null owner)
* (currentUserId )
*/ */
export type TaskScope = 'mine' | 'all'; export type TaskScope = 'mine' | 'others';
export const TASK_SCOPES: TaskScope[] = ['mine', 'all']; export const TASK_SCOPES: TaskScope[] = ['mine', 'others'];
export function filterTasksByScope<T extends { ownerId?: string | null }>( export function filterTasksByScope<T extends { ownerId?: string | null }>(
tasks: T[], tasks: T[],
scope: TaskScope, scope: TaskScope,
currentUserId: string | null, currentUserId: string | null,
): T[] { ): T[] {
if (scope !== 'mine' || !currentUserId) return tasks; if (!currentUserId) return tasks;
return tasks.filter(t => t.ownerId === currentUserId); return scope === 'mine'
? tasks.filter(t => t.ownerId === currentUserId)
: tasks.filter(t => t.ownerId !== currentUserId);
} }

View File

@ -139,7 +139,7 @@ describe('buildUiUrlStateSearch', () => {
status: 'waiting_human', status: 'waiting_human',
search: 'foo bar', search: 'foo bar',
sort: 'status', sort: 'status',
scope: 'all', scope: 'others',
detailTab: 'trace', detailTab: 'trace',
mobileTab: 'files', mobileTab: 'files',
taskId: 99, taskId: 99,

View File

@ -2,7 +2,7 @@ const COLUMNS = ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retr
const DETAIL_TABS = ['overview', 'activity', 'files', 'trace', 'browser', 'ssh'] as const; const DETAIL_TABS = ['overview', 'activity', 'files', 'trace', 'browser', 'ssh'] as const;
const MOBILE_TABS = ['chat', 'overview', 'activity', 'files', 'trace', 'browser', 'ssh'] as const; const MOBILE_TABS = ['chat', 'overview', 'activity', 'files', 'trace', 'browser', 'ssh'] as const;
const SORT_MODES = ['updated', 'status', 'title'] as const; const SORT_MODES = ['updated', 'status', 'title'] as const;
const PAGES = ['tasks', 'pieces', 'settings', 'schedules', 'users', 'captcha', 'userfolder', 'help'] as const; const PAGES = ['tasks', 'pieces', 'settings', 'schedules', 'users', 'captcha', 'userfolder', 'usage', 'help'] as const;
const SETTINGS_SECTIONS = [ const SETTINGS_SECTIONS = [
// User group // User group
'preferences', 'preferences',
@ -76,8 +76,9 @@ export interface UiUrlState {
status: 'all' | StatusColumn; status: 'all' | StatusColumn;
search: string; search: string;
sort: SortMode; sort: SortMode;
/** Owner scope for the task list. 'mine' = own tasks only (default when auth is on). */ /** Owner scope for the task list. 'mine' = own tasks only (default when auth is on),
scope: 'mine' | 'all'; * 'others' = everyone else's tasks. */
scope: 'mine' | 'others';
detailTab: DetailTabId; detailTab: DetailTabId;
mobileTab: MobileTabId; mobileTab: MobileTabId;
taskId: number | null; taskId: number | null;
@ -131,7 +132,7 @@ export function readUiUrlState(): UiUrlState {
: 'all', : 'all',
search: params.get('q') ?? '', search: params.get('q') ?? '',
sort: sort && SORT_MODES.includes(sort as SortMode) ? sort as SortMode : 'updated', sort: sort && SORT_MODES.includes(sort as SortMode) ? sort as SortMode : 'updated',
scope: params.get('scope') === 'all' ? 'all' : 'mine', scope: params.get('scope') === 'others' ? 'others' : 'mine',
detailTab: detailTab && DETAIL_TABS.includes(detailTab as DetailTabId) detailTab: detailTab && DETAIL_TABS.includes(detailTab as DetailTabId)
? detailTab as DetailTabId ? detailTab as DetailTabId
: 'overview', : 'overview',
@ -154,7 +155,7 @@ export function buildUiUrlStateSearch(state: UiUrlState): string {
if (state.status !== 'all') params.set('status', state.status); if (state.status !== 'all') params.set('status', state.status);
if (state.search) params.set('q', state.search); if (state.search) params.set('q', state.search);
if (state.sort !== 'updated') params.set('sort', state.sort); if (state.sort !== 'updated') params.set('sort', state.sort);
if (state.scope === 'all') params.set('scope', 'all'); if (state.scope === 'others') params.set('scope', 'others');
if (state.detailTab !== 'overview') params.set('tab', state.detailTab); if (state.detailTab !== 'overview') params.set('tab', state.detailTab);
if (state.mobileTab !== 'chat') params.set('mobileTab', state.mobileTab); if (state.mobileTab !== 'chat') params.set('mobileTab', state.mobileTab);
if (state.taskId) params.set('task', String(state.taskId)); if (state.taskId) params.set('task', String(state.taskId));

View File

@ -8,6 +8,7 @@ import { StatChip } from '../components/shared/StatChip';
import { usePieceList } from '../hooks/usePieces'; import { usePieceList } from '../hooks/usePieces';
import { resolvePieceOptions } from '../lib/splitPieces'; import { resolvePieceOptions } from '../lib/splitPieces';
import { ownerDisplayName } from '../lib/owner'; import { ownerDisplayName } from '../lib/owner';
import { cronToFormState } from '../lib/cronForm';
import { fetchMyOrgs, listBrowserSessionProfiles, type Visibility } from '../api'; import { fetchMyOrgs, listBrowserSessionProfiles, type Visibility } from '../api';
import { useAuthState } from '../App'; import { useAuthState } from '../App';
@ -95,18 +96,10 @@ function parseCronToDisplay(cron: string, t: TFunction): string {
return cron; return cron;
} }
function cronToFormState(cron: string): Pick<ScheduleFormState, 'scheduleType' | 'hour' | 'minute' | 'dayOfWeek' | 'dayOfMonth' | 'cronExpression'> { // cronToFormState lives in ../lib/cronForm so it can be unit-tested in isolation.
if (cron === 'once') return { scheduleType: 'once', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' }; // It only maps to daily/weekly/monthly presets when every field is a simple
const parts = cron.split(' '); // integer; custom expressions (ranges/steps/lists) stay as raw 'cron' so editing
if (parts.length !== 5) return { scheduleType: 'cron', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: cron }; // a saved schedule never mangles it to NaN or drops the expression.
const [min, hour, dom, , dow] = parts;
const h = Number(hour), m = Number(min);
if (dom !== '*' && dow === '*') return { scheduleType: 'monthly', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: Number(dom), cronExpression: '' };
if (dow !== '*' && dom === '*') return { scheduleType: 'weekly', hour: h, minute: m, dayOfWeek: Number(dow), dayOfMonth: 1, cronExpression: '' };
if (dom === '*' && dow === '*') return { scheduleType: 'daily', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
return { scheduleType: 'cron', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: 1, cronExpression: cron };
}
function taskToFormState(task: ScheduledTask): ScheduleFormState { function taskToFormState(task: ScheduledTask): ScheduleFormState {
const cronState = cronToFormState(task.cronExpression); const cronState = cronToFormState(task.cronExpression);