730 lines
26 KiB
TypeScript
730 lines
26 KiB
TypeScript
// app-docs.ts — Help Center 用のドキュメント参照・ユーザー状態スナップショットツール
|
|
//
|
|
// Help piece (pieces/help.yaml) と META_TOOLS から呼ばれる:
|
|
// - ReadAppDoc({ name }) : symbolic name で project doc を読む
|
|
// - ListAppDocs() : 利用可能な doc を categorize した一覧
|
|
// - GetMyOrchestratorState() : 呼び出しユーザーの sanitized なスナップショット
|
|
//
|
|
// セキュリティ:
|
|
// - REPO_ROOT / DOCS_DIR / PIECES_DIR の allow-list でしかファイルを開かない
|
|
// - path.resolve した結果がいずれかの allow-list 配下であることを必ず確認
|
|
// - 秘密情報 (OAuth client secret, static token, encrypted blob) は GetMyOrchestratorState で
|
|
// 一切返さない
|
|
|
|
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
import { resolve, join, dirname, relative, isAbsolute } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { parse as parseYaml } from 'yaml';
|
|
import type Database from 'better-sqlite3';
|
|
import { ToolDef } from '../../llm/openai-compat.js';
|
|
import type { ToolContext, ToolResult } from './core.js';
|
|
import { logger } from '../../logger.js';
|
|
|
|
// ── Paths ─────────────────────────────────────────────────────────────────────
|
|
// dist/engine/tools/app-docs.js または src/engine/tools/app-docs.ts から
|
|
// リポジトリルートを解決する (どちらの環境でも 3 階層上)
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const REPO_ROOT = resolve(__dirname, '..', '..', '..');
|
|
const DOCS_DIR = join(REPO_ROOT, 'docs');
|
|
const PIECES_DIR = join(REPO_ROOT, 'pieces');
|
|
const TOOLS_DOCS_DIR = join(DOCS_DIR, 'tools');
|
|
|
|
// ── Validation ────────────────────────────────────────────────────────────────
|
|
const NAME_REGEX = /^[a-zA-Z0-9_\-/.]+$/;
|
|
const MAX_BYTES = 32 * 1024; // 32 KB cap per doc to keep tokens bounded
|
|
|
|
// ── Injected deps (server.ts calls setAppDocsDeps) ───────────────────────────
|
|
interface AppDocsDeps {
|
|
db: Database.Database;
|
|
/**
|
|
* Optional override for the user-folder root (used to introspect
|
|
* AGENTS.md / memory/ / scripts/ counts). Falls back to './data/users'.
|
|
*/
|
|
userFolderRoot?: string;
|
|
}
|
|
|
|
let _deps: AppDocsDeps | null = null;
|
|
|
|
export function setAppDocsDeps(deps: AppDocsDeps | null): void {
|
|
_deps = deps;
|
|
}
|
|
|
|
function getUserFolderRoot(): string {
|
|
return _deps?.userFolderRoot ?? './data/users';
|
|
}
|
|
|
|
// ── Tool definitions ──────────────────────────────────────────────────────────
|
|
|
|
export const TOOL_DEFS: Record<string, ToolDef> = {
|
|
ReadAppDoc: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'ReadAppDoc',
|
|
description:
|
|
'MAESTRO のプロジェクト内ドキュメント (docs/ / pieces/) を symbolic name で読む。'
|
|
+ ' Help アシスタントが概念や操作手順を答える前のリファレンス参照に使う。'
|
|
+ ' 詳細は ReadToolDoc({ name: "ReadAppDoc" })。',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
name: {
|
|
type: 'string',
|
|
description:
|
|
'Symbolic name. 例: "docs/mcp" / "docs/architecture" / "piece/chat" / "tool/browseweb"',
|
|
},
|
|
},
|
|
required: ['name'],
|
|
},
|
|
},
|
|
},
|
|
ListAppDocs: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'ListAppDocs',
|
|
description:
|
|
'MAESTRO のプロジェクト内ドキュメント一覧を category 別に返す (docs/ / pieces/ / tool docs)。'
|
|
+ ' 質問に答える前に関連 doc を探すために使う。'
|
|
+ ' 詳細は ReadToolDoc({ name: "ListAppDocs" })。',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {},
|
|
required: [],
|
|
},
|
|
},
|
|
},
|
|
GetMyOrchestratorState: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'GetMyOrchestratorState',
|
|
description:
|
|
'呼び出しユーザーの現在の Orchestrator 状態 (最近のタスク・MCP 接続・User Folder の構成等) を sanitized な Markdown で返す。'
|
|
+ ' ユーザー固有の質問 (「自分の MCP は何が繋がっている?」「最近何を実行した?」) に答える前に呼ぶ。'
|
|
+ ' トークン・OAuth secret などの秘密情報は一切含まない。'
|
|
+ ' 詳細は ReadToolDoc({ name: "GetMyOrchestratorState" })。',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {},
|
|
required: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
// ── Path resolver ─────────────────────────────────────────────────────────────
|
|
|
|
interface ResolvedDoc {
|
|
path: string;
|
|
label: string;
|
|
}
|
|
|
|
/**
|
|
* Subpaths under docs/ that are internal-only and must never be exposed to
|
|
* end users via ReadAppDoc / ListAppDocs.
|
|
*
|
|
* Match is against the path relative to DOCS_DIR (no leading slash).
|
|
* A blocked entry ending in '/' blocks the entire subtree.
|
|
* An entry without '/' blocks that exact file (with or without .md suffix).
|
|
*/
|
|
const BLOCKED_DOCS_SUBPATHS = [
|
|
'superpowers/', // implementation plans, internal specs
|
|
'maintenance-checklist', // internal ops reference
|
|
];
|
|
|
|
function isBlockedDocsSubpath(relFromDocs: string): boolean {
|
|
return BLOCKED_DOCS_SUBPATHS.some((blocked) => {
|
|
if (blocked.endsWith('/')) {
|
|
return relFromDocs.startsWith(blocked);
|
|
}
|
|
return (
|
|
relFromDocs === blocked
|
|
|| relFromDocs === `${blocked}.md`
|
|
);
|
|
});
|
|
}
|
|
|
|
function sanitizeListedDescription(description: string): string {
|
|
const referencesInternalDoc = BLOCKED_DOCS_SUBPATHS.some((blocked) =>
|
|
description.includes(blocked.replace(/\/$/, '')),
|
|
);
|
|
return referencesInternalDoc ? '(description omitted: internal reference)' : description;
|
|
}
|
|
|
|
/**
|
|
* Map a symbolic doc name to a concrete file path under an allow-listed root.
|
|
* Returns null on invalid names, attempted traversal, or blocked internal docs.
|
|
*/
|
|
export function resolveAppDocPath(name: string): ResolvedDoc | null {
|
|
if (!name || typeof name !== 'string') return null;
|
|
if (!NAME_REGEX.test(name)) return null;
|
|
if (name.includes('..')) return null;
|
|
|
|
// Rejected: internal top-level project docs (CLAUDE.md / AGENTS.md / README.md)
|
|
if (
|
|
name === 'claude-md'
|
|
|| name === 'CLAUDE.md'
|
|
|| name === 'architecture'
|
|
|| name === 'agents-md'
|
|
|| name === 'AGENTS.md'
|
|
|| name === 'readme'
|
|
|| name === 'README.md'
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// Piece YAML
|
|
if (name.startsWith('piece/')) {
|
|
const piece = name.slice('piece/'.length);
|
|
if (!piece || piece.includes('/')) return null;
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(piece)) return null;
|
|
const path = join(PIECES_DIR, `${piece}.yaml`);
|
|
if (!isUnderRoot(path, PIECES_DIR)) return null;
|
|
return { path, label: `pieces/${piece}.yaml` };
|
|
}
|
|
|
|
// Tool docs (alias to ReadToolDoc behavior)
|
|
if (name.startsWith('tool/')) {
|
|
const tool = name.slice('tool/'.length).toLowerCase();
|
|
if (!tool || tool.includes('/')) return null;
|
|
if (!/^[a-z0-9_-]+$/.test(tool)) return null;
|
|
const path = join(TOOLS_DOCS_DIR, `${tool}.md`);
|
|
if (!isUnderRoot(path, TOOLS_DOCS_DIR)) return null;
|
|
return { path, label: `docs/tools/${tool}.md` };
|
|
}
|
|
|
|
// docs/* (auto-append .md if missing)
|
|
if (name.startsWith('docs/')) {
|
|
const rel = name.slice('docs/'.length);
|
|
if (!rel) return null;
|
|
const withExt = rel.endsWith('.md') ? rel : `${rel}.md`;
|
|
const resolvedPath = join(DOCS_DIR, withExt);
|
|
if (!isUnderRoot(resolvedPath, DOCS_DIR)) return null;
|
|
// Reject blocked internal subpaths
|
|
const relFromDocs = relative(DOCS_DIR, resolvedPath);
|
|
if (isBlockedDocsSubpath(relFromDocs)) return null;
|
|
return { path: resolvedPath, label: `docs/${rel.replace(/\.md$/, '')}` };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isUnderRoot(absolutePath: string, rootDir: string): boolean {
|
|
// Use path.relative for a portable containment check (no string-prefix games).
|
|
const rel = relative(rootDir, absolutePath);
|
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
}
|
|
|
|
// ── ReadAppDoc implementation ────────────────────────────────────────────────
|
|
|
|
async function executeReadAppDoc(
|
|
input: Record<string, unknown>,
|
|
_ctx: ToolContext,
|
|
): Promise<ToolResult> {
|
|
const name = input['name'];
|
|
if (typeof name !== 'string' || !name) {
|
|
return { output: 'ReadAppDoc error: name パラメータが必要です', isError: true };
|
|
}
|
|
|
|
const resolved = resolveAppDocPath(name);
|
|
if (!resolved) {
|
|
return {
|
|
output:
|
|
`ReadAppDoc error: 不正な name "${name}"。`
|
|
+ ' 有効な形式: "docs/<path>" / "piece/<name>" / "tool/<name>"。'
|
|
+ ' ListAppDocs() で利用可能な doc 一覧を取得できます。',
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
if (!existsSync(resolved.path)) {
|
|
return {
|
|
output:
|
|
`ReadAppDoc: "${name}" (${resolved.label}) は存在しません。`
|
|
+ ' ListAppDocs() で利用可能な doc 一覧を確認してください。',
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
let stat;
|
|
try {
|
|
stat = statSync(resolved.path);
|
|
} catch (e) {
|
|
return { output: `ReadAppDoc error: stat 失敗: ${(e as Error).message}`, isError: true };
|
|
}
|
|
if (!stat.isFile()) {
|
|
return { output: `ReadAppDoc error: ${resolved.label} はファイルではありません`, isError: true };
|
|
}
|
|
|
|
let raw: string;
|
|
try {
|
|
if (stat.size <= MAX_BYTES) {
|
|
raw = readFileSync(resolved.path, 'utf-8');
|
|
} else {
|
|
// Truncate at MAX_BYTES, walk back to a UTF-8 codepoint boundary
|
|
const buf = Buffer.alloc(MAX_BYTES);
|
|
const fd = (await import('fs')).openSync(resolved.path, 'r');
|
|
try {
|
|
(await import('fs')).readSync(fd, buf, 0, MAX_BYTES, 0);
|
|
} finally {
|
|
(await import('fs')).closeSync(fd);
|
|
}
|
|
let safe = buf.length;
|
|
while (safe > 0 && (buf[safe - 1]! & 0xc0) === 0x80) safe--;
|
|
raw = buf.subarray(0, safe).toString('utf-8')
|
|
+ `\n\n[truncated: original was ${stat.size} bytes; ${stat.size - safe} bytes omitted]`;
|
|
}
|
|
} catch (e) {
|
|
return { output: `ReadAppDoc error: ${(e as Error).message}`, isError: true };
|
|
}
|
|
|
|
return { output: `# ${resolved.label}\n\n${raw}`, isError: false };
|
|
}
|
|
|
|
// ── ListAppDocs implementation ────────────────────────────────────────────────
|
|
|
|
interface DocEntry {
|
|
symbolicName: string;
|
|
description: string;
|
|
}
|
|
|
|
/**
|
|
* Extract a one-line description from a Markdown file.
|
|
* Skips frontmatter and YAML-ish boilerplate, returns the first non-empty
|
|
* heading or paragraph line. Capped at ~140 chars.
|
|
*/
|
|
function extractMarkdownDescription(filePath: string): string {
|
|
try {
|
|
const raw = readFileSync(filePath, 'utf-8');
|
|
const lines = raw.split('\n');
|
|
let inFrontmatter = false;
|
|
let foundFirstHeading = false;
|
|
for (let i = 0; i < lines.length && i < 100; i++) {
|
|
const line = lines[i]!.trim();
|
|
if (i === 0 && line === '---') { inFrontmatter = true; continue; }
|
|
if (inFrontmatter) {
|
|
if (line === '---') inFrontmatter = false;
|
|
continue;
|
|
}
|
|
if (!line) continue;
|
|
if (line.startsWith('<!--')) continue;
|
|
if (line.startsWith('# ')) {
|
|
if (!foundFirstHeading) { foundFirstHeading = true; continue; }
|
|
// For second-level heading or beyond, we don't want it as description
|
|
}
|
|
if (line.startsWith('#')) continue;
|
|
// Use this line as description
|
|
return sanitizeListedDescription(line.slice(0, 140));
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return '(no description)';
|
|
}
|
|
|
|
/**
|
|
* Read a piece YAML's `description` field (first non-empty line).
|
|
*/
|
|
function extractPieceDescription(filePath: string): string {
|
|
try {
|
|
const raw = readFileSync(filePath, 'utf-8');
|
|
const data = parseYaml(raw) as { description?: string } | undefined;
|
|
if (data?.description && typeof data.description === 'string') {
|
|
const first = data.description
|
|
.split('\n')
|
|
.map((s) => s.trim())
|
|
.find((s) => s.length > 0);
|
|
return sanitizeListedDescription((first ?? '(no description)').slice(0, 140));
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return '(no description)';
|
|
}
|
|
|
|
function listMarkdownFiles(dir: string, prefix = ''): string[] {
|
|
// Returns symbolic relative paths (without .md extension) for all .md files
|
|
// recursively. Skips dotfiles. Caps depth at 3.
|
|
const out: string[] = [];
|
|
function walk(current: string, depth: number, currentPrefix: string) {
|
|
if (depth > 3) return;
|
|
let entries: string[];
|
|
try {
|
|
entries = readdirSync(current);
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries.sort()) {
|
|
if (entry.startsWith('.')) continue;
|
|
const full = join(current, entry);
|
|
let st;
|
|
try { st = statSync(full); } catch { continue; }
|
|
if (st.isDirectory()) {
|
|
walk(full, depth + 1, currentPrefix ? `${currentPrefix}/${entry}` : entry);
|
|
} else if (st.isFile() && entry.endsWith('.md')) {
|
|
const baseName = entry.slice(0, -3);
|
|
const sym = currentPrefix ? `${currentPrefix}/${baseName}` : baseName;
|
|
out.push(`${prefix}${sym}`);
|
|
}
|
|
}
|
|
}
|
|
walk(dir, 0, '');
|
|
return out;
|
|
}
|
|
|
|
async function executeListAppDocs(
|
|
_input: Record<string, unknown>,
|
|
_ctx: ToolContext,
|
|
): Promise<ToolResult> {
|
|
const sections: string[] = [];
|
|
|
|
// 1. Pieces
|
|
const pieces: DocEntry[] = [];
|
|
if (existsSync(PIECES_DIR)) {
|
|
let pieceFiles: string[];
|
|
try {
|
|
pieceFiles = readdirSync(PIECES_DIR).filter((f) => f.endsWith('.yaml')).sort();
|
|
} catch {
|
|
pieceFiles = [];
|
|
}
|
|
for (const file of pieceFiles) {
|
|
const baseName = file.slice(0, -5); // strip .yaml
|
|
pieces.push({
|
|
symbolicName: `piece/${baseName}`,
|
|
description: extractPieceDescription(join(PIECES_DIR, file)),
|
|
});
|
|
}
|
|
}
|
|
sections.push('# Piece 一覧 (`piece/<name>` で読み込み)');
|
|
sections.push('');
|
|
if (pieces.length === 0) {
|
|
sections.push('- (none)');
|
|
} else {
|
|
for (const entry of pieces) {
|
|
sections.push(`- \`${entry.symbolicName}\` — ${entry.description}`);
|
|
}
|
|
}
|
|
sections.push('');
|
|
|
|
// 3. docs/* (excluding docs/tools/, which we list separately)
|
|
const docs: DocEntry[] = [];
|
|
if (existsSync(DOCS_DIR)) {
|
|
const allDocs = listMarkdownFiles(DOCS_DIR);
|
|
for (const sym of allDocs) {
|
|
// Skip docs/tools — they get their own section
|
|
if (sym.startsWith('tools/')) continue;
|
|
// Skip plans/ subtree to keep the list manageable (plans are historical)
|
|
if (sym.startsWith('plans/')) continue;
|
|
// Skip internal-only subtrees / files
|
|
if (sym.startsWith('superpowers/')) continue;
|
|
if (sym.startsWith('design/')) continue;
|
|
if (sym === 'maintenance-checklist') continue;
|
|
const fullPath = join(DOCS_DIR, `${sym}.md`);
|
|
docs.push({
|
|
symbolicName: `docs/${sym}`,
|
|
description: extractMarkdownDescription(fullPath),
|
|
});
|
|
}
|
|
}
|
|
sections.push('# ドキュメント (`docs/<path>` で読み込み)');
|
|
sections.push('');
|
|
if (docs.length === 0) {
|
|
sections.push('- (none)');
|
|
} else {
|
|
for (const entry of docs) {
|
|
sections.push(`- \`${entry.symbolicName}\` — ${entry.description}`);
|
|
}
|
|
}
|
|
sections.push('');
|
|
|
|
// 4. tool docs
|
|
const toolDocs: DocEntry[] = [];
|
|
if (existsSync(TOOLS_DOCS_DIR)) {
|
|
let toolFiles: string[];
|
|
try {
|
|
toolFiles = readdirSync(TOOLS_DOCS_DIR).filter((f) => f.endsWith('.md')).sort();
|
|
} catch {
|
|
toolFiles = [];
|
|
}
|
|
for (const file of toolFiles) {
|
|
const baseName = file.slice(0, -3);
|
|
toolDocs.push({
|
|
symbolicName: `tool/${baseName}`,
|
|
description: extractMarkdownDescription(join(TOOLS_DOCS_DIR, file)),
|
|
});
|
|
}
|
|
}
|
|
sections.push('# ツール参照 (`tool/<name>` で読み込み — ReadToolDoc と同等)');
|
|
sections.push('');
|
|
if (toolDocs.length === 0) {
|
|
sections.push('- (none)');
|
|
} else {
|
|
for (const entry of toolDocs) {
|
|
sections.push(`- \`${entry.symbolicName}\` — ${entry.description}`);
|
|
}
|
|
}
|
|
sections.push('');
|
|
|
|
// Cap output size: if total entries > 150, append a notice
|
|
const totalEntries = pieces.length + docs.length + toolDocs.length;
|
|
if (totalEntries > 150) {
|
|
sections.push(`> 注: 合計 ${totalEntries} 件の doc があります。特定の領域は ReadAppDoc({ name: "docs/<path>" }) で個別に取得してください。`);
|
|
}
|
|
|
|
return { output: sections.join('\n'), isError: false };
|
|
}
|
|
|
|
// ── GetMyOrchestratorState implementation ────────────────────────────────────
|
|
|
|
interface RecentTaskRow {
|
|
id: number;
|
|
title: string;
|
|
piece_name: string;
|
|
created_at: string;
|
|
state: string;
|
|
job_status: string | null;
|
|
}
|
|
|
|
interface McpServerRow {
|
|
id: string;
|
|
name: string;
|
|
auth_kind: string;
|
|
owner_id: string | null;
|
|
enabled: number;
|
|
}
|
|
|
|
interface McpTokenRow {
|
|
server_id: string;
|
|
expires_at: string | null;
|
|
}
|
|
|
|
interface UserPiecesEntry {
|
|
name: string;
|
|
description: string;
|
|
}
|
|
|
|
async function executeGetMyOrchestratorState(
|
|
_input: Record<string, unknown>,
|
|
ctx: ToolContext,
|
|
): Promise<ToolResult> {
|
|
if (!ctx.userId) {
|
|
return {
|
|
output: 'GetMyOrchestratorState requires an authenticated user',
|
|
isError: true,
|
|
};
|
|
}
|
|
if (!_deps?.db) {
|
|
return {
|
|
output: 'GetMyOrchestratorState: DB が初期化されていません (server.ts で setAppDocsDeps を呼んでください)',
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const db = _deps.db;
|
|
const userId = ctx.userId;
|
|
const lines: string[] = [];
|
|
lines.push('# あなたの現在の状態');
|
|
lines.push('');
|
|
|
|
// ── User ─────────────────────────────────────────────────────────────────
|
|
let userRow: { id: string; name: string | null; email: string; role: string } | undefined;
|
|
try {
|
|
userRow = db
|
|
.prepare('SELECT id, name, email, role FROM users WHERE id = ?')
|
|
.get(userId) as { id: string; name: string | null; email: string; role: string } | undefined;
|
|
} catch (e) {
|
|
logger.warn(`[GetMyOrchestratorState] failed to fetch user: ${(e as Error).message}`);
|
|
}
|
|
|
|
lines.push('## ユーザー');
|
|
if (userRow) {
|
|
lines.push(`- id: \`${userRow.id}\``);
|
|
if (userRow.name) lines.push(`- 名前: ${userRow.name}`);
|
|
lines.push(`- role: ${userRow.role}`);
|
|
} else {
|
|
lines.push(`- id: \`${userId}\` (DB レコードなし)`);
|
|
}
|
|
lines.push('');
|
|
|
|
// ── Recent tasks (5) ─────────────────────────────────────────────────────
|
|
let recentTasks: RecentTaskRow[] = [];
|
|
try {
|
|
recentTasks = db
|
|
.prepare(`
|
|
SELECT
|
|
lt.id,
|
|
COALESCE(lt.title, '(untitled)') AS title,
|
|
lt.piece_name,
|
|
lt.created_at,
|
|
lt.state,
|
|
(SELECT j.status FROM jobs j
|
|
WHERE j.repo = 'local/task-' || lt.id AND j.issue_number = lt.id
|
|
ORDER BY j.created_at DESC LIMIT 1) AS job_status
|
|
FROM local_tasks lt
|
|
WHERE lt.owner_id = ?
|
|
ORDER BY lt.created_at DESC
|
|
LIMIT 5
|
|
`)
|
|
.all(userId) as RecentTaskRow[];
|
|
} catch (e) {
|
|
logger.warn(`[GetMyOrchestratorState] failed to fetch tasks: ${(e as Error).message}`);
|
|
}
|
|
|
|
lines.push('## 最近のタスク (最新 5 件)');
|
|
if (recentTasks.length === 0) {
|
|
lines.push('- (なし)');
|
|
} else {
|
|
for (const t of recentTasks) {
|
|
const status = t.job_status ?? t.state ?? 'unknown';
|
|
const titleStr = (t.title ?? '').slice(0, 60);
|
|
lines.push(`- task-${t.id}: ${t.piece_name} / ${status} / ${t.created_at} — ${titleStr}`);
|
|
}
|
|
}
|
|
lines.push('');
|
|
|
|
// ── MCP servers visible to user ──────────────────────────────────────────
|
|
let mcpServers: McpServerRow[] = [];
|
|
let userTokens: McpTokenRow[] = [];
|
|
try {
|
|
// Report ONLY the servers actually exposed to the agent in THIS task, i.e.
|
|
// scoped to the task's effective space — exactly mirroring the aggregator's
|
|
// registry.listEnabledForSpace(ctx.spaceId). The old owner-scoped query
|
|
// listed servers from other spaces too, so it told the agent a server was
|
|
// "connected" when it was not callable here (cross-space). See
|
|
// docs/superpowers/specs/2026-06-19-personal-space-mcp-resolution-design.md
|
|
const effectiveSpaceId = ctx.spaceId ?? null;
|
|
mcpServers = (
|
|
effectiveSpaceId === null
|
|
? db.prepare(`SELECT id, name, auth_kind, owner_id, enabled FROM mcp_servers WHERE enabled = 1 AND space_id IS NULL ORDER BY id`).all()
|
|
: db.prepare(`SELECT id, name, auth_kind, owner_id, enabled FROM mcp_servers WHERE enabled = 1 AND space_id = ? ORDER BY id`).all(effectiveSpaceId)
|
|
) as McpServerRow[];
|
|
userTokens = db
|
|
.prepare('SELECT server_id, expires_at FROM user_mcp_tokens WHERE user_id = ?')
|
|
.all(userId) as McpTokenRow[];
|
|
} catch (e) {
|
|
logger.warn(`[GetMyOrchestratorState] failed to fetch MCP: ${(e as Error).message}`);
|
|
}
|
|
|
|
const tokenSet = new Set(userTokens.map((t) => t.server_id));
|
|
|
|
lines.push('## MCP サーバー (このタスクで利用可能)');
|
|
if (mcpServers.length === 0) {
|
|
lines.push('- (利用可能なサーバーなし)');
|
|
} else {
|
|
for (const s of mcpServers) {
|
|
const scope = s.owner_id ? '個人' : '全体';
|
|
const authKindLabel = s.auth_kind === 'api_key' ? 'API キー' : 'OAuth';
|
|
let connected: string;
|
|
if (s.auth_kind === 'api_key') {
|
|
// For api_key servers, the static token is stored at server-level.
|
|
// No per-user OAuth handshake required — these are effectively "connected"
|
|
// for any user who can see the server.
|
|
connected = '連携済み (api_key)';
|
|
} else {
|
|
connected = tokenSet.has(s.id) ? '連携済み' : '未連携';
|
|
}
|
|
lines.push(`- \`${s.id}\` (${s.name}) — ${authKindLabel} / ${scope} / ${connected}`);
|
|
}
|
|
}
|
|
lines.push('');
|
|
|
|
// ── User folder summary ──────────────────────────────────────────────────
|
|
const userFolderRoot = getUserFolderRoot();
|
|
const userDir = resolve(userFolderRoot, userId);
|
|
|
|
lines.push('## ユーザーフォルダ');
|
|
|
|
// AGENTS.md
|
|
const agentsMdPath = join(userDir, 'AGENTS.md');
|
|
if (existsSync(agentsMdPath)) {
|
|
try {
|
|
const st = statSync(agentsMdPath);
|
|
lines.push(`- AGENTS.md: ${st.size} bytes`);
|
|
} catch {
|
|
lines.push('- AGENTS.md: 取得失敗');
|
|
}
|
|
} else {
|
|
lines.push('- AGENTS.md: (未設定)');
|
|
}
|
|
|
|
// memory/, scripts/, browser-macros/, templates/, recordings/
|
|
for (const sub of ['memory', 'scripts', 'browser-macros', 'templates', 'recordings'] as const) {
|
|
const subdir = join(userDir, sub);
|
|
if (existsSync(subdir)) {
|
|
try {
|
|
const entries = readdirSync(subdir).filter((f) => {
|
|
if (f.startsWith('.')) return false;
|
|
if (sub === 'memory') return f.endsWith('.md') && f !== 'MEMORY.md';
|
|
return true;
|
|
});
|
|
lines.push(`- ${sub}/: ${entries.length} 件`);
|
|
} catch {
|
|
lines.push(`- ${sub}/: 取得失敗`);
|
|
}
|
|
} else {
|
|
lines.push(`- ${sub}/: 0 件`);
|
|
}
|
|
}
|
|
lines.push('');
|
|
|
|
// ── Custom pieces (forks under data/users/{id}/pieces/) ─────────────────
|
|
const userPiecesDir = join(userDir, 'pieces');
|
|
const customPieces: UserPiecesEntry[] = [];
|
|
if (existsSync(userPiecesDir)) {
|
|
try {
|
|
const files = readdirSync(userPiecesDir).filter((f) => f.endsWith('.yaml')).sort();
|
|
for (const file of files) {
|
|
const baseName = file.slice(0, -5);
|
|
customPieces.push({
|
|
name: baseName,
|
|
description: extractPieceDescription(join(userPiecesDir, file)),
|
|
});
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
lines.push('## カスタム Piece (自分の fork)');
|
|
if (customPieces.length === 0) {
|
|
lines.push('- (なし)');
|
|
} else {
|
|
for (const cp of customPieces) {
|
|
lines.push(`- \`${cp.name}\` — ${cp.description}`);
|
|
}
|
|
}
|
|
lines.push('');
|
|
|
|
// ── Built-in pieces (just names) ─────────────────────────────────────────
|
|
let builtinPieces: string[] = [];
|
|
if (existsSync(PIECES_DIR)) {
|
|
try {
|
|
builtinPieces = readdirSync(PIECES_DIR)
|
|
.filter((f) => f.endsWith('.yaml'))
|
|
.map((f) => f.slice(0, -5))
|
|
.sort();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
lines.push('## 組み込み Piece');
|
|
if (builtinPieces.length === 0) {
|
|
lines.push('- (なし)');
|
|
} else {
|
|
lines.push(`- ${builtinPieces.join(', ')}`);
|
|
}
|
|
|
|
return { output: lines.join('\n'), isError: false };
|
|
}
|
|
|
|
// ── Dispatch ──────────────────────────────────────────────────────────────────
|
|
|
|
export async function executeTool(
|
|
name: string,
|
|
input: Record<string, unknown>,
|
|
ctx: ToolContext,
|
|
): Promise<ToolResult | null> {
|
|
if (name === 'ReadAppDoc') return executeReadAppDoc(input, ctx);
|
|
if (name === 'ListAppDocs') return executeListAppDocs(input, ctx);
|
|
if (name === 'GetMyOrchestratorState') return executeGetMyOrchestratorState(input, ctx);
|
|
return null;
|
|
}
|