From 7d9e21de11276dd89e4abed80684f415e39e46aa Mon Sep 17 00:00:00 2001 From: ruttydm Date: Wed, 1 Apr 2026 20:18:17 +0200 Subject: [PATCH] Extract shared Lua core --- core/src/Contracts/LuaToolInvoker.php | 29 +++ core/src/Lua/LuaBridge.php | 144 +++++++++++ core/src/Lua/LuaCatalogBuilder.php | 183 +++++++++++++ core/src/Lua/LuaDocRenderer.php | 356 ++++++++++++++++++++++++++ 4 files changed, 712 insertions(+) create mode 100644 core/src/Contracts/LuaToolInvoker.php create mode 100644 core/src/Lua/LuaBridge.php create mode 100644 core/src/Lua/LuaCatalogBuilder.php create mode 100644 core/src/Lua/LuaDocRenderer.php diff --git a/core/src/Contracts/LuaToolInvoker.php b/core/src/Contracts/LuaToolInvoker.php new file mode 100644 index 0000000..f20053a --- /dev/null +++ b/core/src/Contracts/LuaToolInvoker.php @@ -0,0 +1,29 @@ + $args + */ + public function invoke(string $toolSlug, array $args): mixed; + + /** + * Tool metadata for bridge call logging and UI decoration. + * + * @return array{icon?: string, name?: string} + */ + public function getToolMeta(string $toolSlug): array; +} diff --git a/core/src/Lua/LuaBridge.php b/core/src/Lua/LuaBridge.php new file mode 100644 index 0000000..b9ee926 --- /dev/null +++ b/core/src/Lua/LuaBridge.php @@ -0,0 +1,144 @@ + */ + private array $functionMap; + + /** @var array> */ + private array $parameterMap; + + /** @var list */ + private array $callLog = []; + + /** + * @param array $functionMap + * @param array> $parameterMap + */ + public function __construct( + array $functionMap, + array $parameterMap, + private LuaToolInvoker $invoker, + ) { + $this->functionMap = $functionMap; + $this->parameterMap = $parameterMap; + } + + public function call(string $path, mixed ...$args): mixed + { + if (! isset($this->functionMap[$path])) { + $message = "Unknown function: app.{$path}"; + + $parts = explode('.', $path); + if (count($parts) > 1) { + $namespacePrefix = implode('.', array_slice($parts, 0, -1)); + $available = []; + + foreach ($this->functionMap as $functionPath => $_toolSlug) { + if (str_starts_with($functionPath, $namespacePrefix . '.')) { + $functionParts = explode('.', $functionPath); + $available[] = end($functionParts); + } + } + + if ($available !== []) { + $message .= '. Did you mean: ' . implode(', ', $available); + } + } + + $this->callLog[] = [ + 'path' => $path, + 'durationMs' => 0, + 'status' => 'error', + 'error' => $message, + 'group' => self::extractGroup($path), + ]; + + throw new \RuntimeException($message); + } + + $toolSlug = $this->functionMap[$path]; + $toolMeta = $this->invoker->getToolMeta($toolSlug); + $group = self::extractGroup($path); + + $params = []; + if ($args !== [] && is_array($args[0])) { + $params = $args[0]; + } elseif ($args !== []) { + $params = $this->mapPositionalArgs($path, $args); + } + + $start = microtime(true); + + try { + $result = $this->invoker->invoke($toolSlug, $params); + + $this->callLog[] = [ + 'path' => $path, + 'durationMs' => round((microtime(true) - $start) * 1000, 1), + 'status' => 'ok', + 'icon' => $toolMeta['icon'] ?? 'ph:wrench', + 'name' => $toolMeta['name'] ?? $toolSlug, + 'group' => $group, + ]; + + return $result; + } catch (\Throwable $e) { + $this->callLog[] = [ + 'path' => $path, + 'durationMs' => round((microtime(true) - $start) * 1000, 1), + 'status' => 'error', + 'error' => $e->getMessage(), + 'icon' => $toolMeta['icon'] ?? 'ph:wrench', + 'name' => $toolMeta['name'] ?? $toolSlug, + 'group' => $group, + ]; + + throw $e; + } + } + + /** + * @return list + */ + public function getCallLog(): array + { + return $this->callLog; + } + + private static function extractGroup(string $path): string + { + $parts = explode('.', $path); + + if (($parts[0] ?? null) === 'integrations' && isset($parts[1])) { + return $parts[1]; + } + + return $parts[0] ?? ''; + } + + /** + * @param array $args + * @return array + */ + private function mapPositionalArgs(string $path, array $args): array + { + $paramNames = $this->parameterMap[$path] ?? []; + if ($paramNames === []) { + return []; + } + + $mapped = []; + foreach ($args as $index => $value) { + if (isset($paramNames[$index])) { + $mapped[$paramNames[$index]] = $value; + } + } + + return $mapped; + } +} diff --git a/core/src/Lua/LuaCatalogBuilder.php b/core/src/Lua/LuaCatalogBuilder.php new file mode 100644 index 0000000..bbd2ecd --- /dev/null +++ b/core/src/Lua/LuaCatalogBuilder.php @@ -0,0 +1,183 @@ +> $catalog + * @param array $skipApps + * @return array>, sourceToolSlug: string}>}> + */ + public function buildNamespaces(array $catalog, array $skipApps = ['tasks', 'system', 'lua']): array + { + $namespaces = []; + + foreach ($catalog as $app) { + $appName = (string) ($app['name'] ?? ''); + + if ($appName === '' || in_array($appName, $skipApps, true)) { + continue; + } + + $baseNamespace = ! empty($app['isIntegration']) + ? "integrations.{$appName}" + : $appName; + + foreach ($app['tools'] ?? [] as $tool) { + $slug = (string) ($tool['slug'] ?? ''); + if ($slug === '') { + continue; + } + + $namespaceName = $baseNamespace; + if (str_starts_with($slug, 'mcp_')) { + $namespaceName = $this->mcpNamespace($slug); + $functionName = $this->mcpFunctionName($slug); + } else { + $functionName = $this->deriveFunctionName( + (string) ($tool['name'] ?? $slug), + $appName, + ); + } + + if (! isset($namespaces[$namespaceName])) { + $namespaces[$namespaceName] = [ + 'description' => (string) ($app['description'] ?? ''), + 'functions' => [], + ]; + } + + $namespaces[$namespaceName]['functions'][] = $this->buildFunction($functionName, $tool, $slug); + } + } + + uksort($namespaces, function (string $a, string $b): int { + $aWeight = str_starts_with($a, 'mcp.') ? 2 : (str_starts_with($a, 'integrations.') ? 1 : 0); + $bWeight = str_starts_with($b, 'mcp.') ? 2 : (str_starts_with($b, 'integrations.') ? 1 : 0); + + return $aWeight <=> $bWeight ?: strcmp($a, $b); + }); + + return $namespaces; + } + + /** + * @param array>, sourceToolSlug: string}>}> $namespaces + * @return array + */ + public function buildFunctionMap(array $namespaces): array + { + $map = []; + + foreach ($namespaces as $namespaceName => $namespace) { + foreach ($namespace['functions'] as $function) { + $map[$namespaceName . '.' . $function['name']] = $function['sourceToolSlug']; + } + } + + return $map; + } + + /** + * @param array>, sourceToolSlug: string}>}> $namespaces + * @return array> + */ + public function buildParameterMap(array $namespaces): array + { + $map = []; + + foreach ($namespaces as $namespaceName => $namespace) { + foreach ($namespace['functions'] as $function) { + $map[$namespaceName . '.' . $function['name']] = array_map( + fn (array $param) => (string) ($param['name'] ?? ''), + $function['parameters'], + ); + } + } + + return $map; + } + + public function deriveFunctionName(string $toolName, string $appName): string + { + $snake = strtolower(trim($toolName)); + $snake = preg_replace('/[^a-z0-9]+/', '_', $snake) ?? ''; + $snake = trim($snake, '_'); + + if ($snake === '') { + return 'tool'; + } + + $words = explode('_', $snake); + $appBase = rtrim(strtolower($appName), 's'); + + $filtered = array_values(array_filter($words, function (string $word) use ($appBase): bool { + if (in_array($word, ['on', 'of', 'for', 'in', 'to', 'the', 'a', 'an'], true)) { + return false; + } + + $wordBase = rtrim($word, 's'); + + return ! str_contains($wordBase, $appBase) && ! str_contains($appBase, $wordBase); + })); + + return implode('_', $filtered) ?: $snake; + } + + /** + * @param array $tool + * @return array{name: string, description: string, fullDescription: string, parameters: array>, sourceToolSlug: string} + */ + private function buildFunction(string $functionName, array $tool, string $slug): array + { + $parameters = []; + + foreach ($tool['parameters'] ?? [] as $parameter) { + if (! is_array($parameter)) { + continue; + } + + $name = (string) ($parameter['name'] ?? ''); + if ($name === '') { + continue; + } + + $parameter['name'] = $this->toSnakeCase($name); + $parameters[] = $parameter; + } + + return [ + 'name' => $functionName, + 'description' => (string) ($tool['fullDescription'] ?? $tool['description'] ?? ''), + 'fullDescription' => (string) ($tool['fullDescription'] ?? ''), + 'parameters' => $parameters, + 'sourceToolSlug' => $slug, + ]; + } + + private function mcpNamespace(string $slug): string + { + if (preg_match('/^mcp_(.+?)__/', $slug, $matches) === 1) { + return 'mcp.' . $matches[1]; + } + + return 'mcp'; + } + + private function mcpFunctionName(string $slug): string + { + if (preg_match('/^mcp_.+?__(.+)$/', $slug, $matches) === 1) { + return preg_replace('/[^a-z0-9]+/', '_', strtolower($matches[1])) ?? 'tool'; + } + + return preg_replace('/[^a-z0-9]+/', '_', strtolower($slug)) ?? 'tool'; + } + + private function toSnakeCase(string $name): string + { + return strtolower((string) preg_replace('/[A-Z]/', '_$0', $name)); + } +} diff --git a/core/src/Lua/LuaDocRenderer.php b/core/src/Lua/LuaDocRenderer.php new file mode 100644 index 0000000..789cca6 --- /dev/null +++ b/core/src/Lua/LuaDocRenderer.php @@ -0,0 +1,356 @@ +>, sourceToolSlug: string}>}> $namespaces + * @param array $staticPages + */ + public function generateNamespaceIndex(array $namespaces, array $staticPages = [], ?string $filterNamespace = null): string + { + $allNamespaces = $namespaces; + + if ($filterNamespace !== null) { + $namespaces = array_filter( + $namespaces, + fn (mixed $_value, string $key) => $key === $filterNamespace || str_starts_with($key, $filterNamespace . '.'), + ARRAY_FILTER_USE_BOTH, + ); + + if ($namespaces === []) { + return "Namespace '{$filterNamespace}' not found. Available: " . implode(', ', array_keys($allNamespaces)); + } + } + + $lines = ['Available Lua API namespaces:', '']; + + foreach ($namespaces as $namespaceName => $namespace) { + $lines[] = "**app.{$namespaceName}** — {$namespace['description']}"; + + foreach ($namespace['functions'] as $function) { + $lines[] = ' app.' . $namespaceName . '.' . $this->buildSignature($function); + } + + $lines[] = ''; + } + + if ($staticPages !== []) { + $lines[] = 'Supplementary docs: ' . implode(', ', array_keys($staticPages)); + $lines[] = 'Use lua_read_doc to read any page or namespace in detail.'; + } + + return implode("\n", $lines); + } + + /** + * @param array>, sourceToolSlug: string}>}> $namespaces + * @param null|callable(string): ?string $supplementaryDocsResolver + */ + public function generateNamespaceDocs( + string $namespace, + array $namespaces, + ?callable $supplementaryDocsResolver = null, + ): string { + if (! isset($namespaces[$namespace])) { + return "Namespace '{$namespace}' not found. Available: " . implode(', ', array_keys($namespaces)); + } + + $namespaceData = $namespaces[$namespace]; + $lines = ["# app.{$namespace} — {$namespaceData['description']}", '']; + + foreach ($namespaceData['functions'] as $function) { + $lines[] = '## app.' . $namespace . '.' . $this->buildSignature($function); + $lines[] = ''; + + if ($function['description'] !== '') { + $lines[] = $function['description']; + $lines[] = ''; + } + + $lines[] = $this->formatParameterTable($function['parameters']); + $lines[] = ''; + } + + if ($supplementaryDocsResolver !== null) { + $supplementary = $supplementaryDocsResolver($namespace); + if ($supplementary !== null && $supplementary !== '') { + $lines[] = '---'; + $lines[] = ''; + $lines[] = $supplementary; + } + } + + return implode("\n", $lines); + } + + /** + * @param array>, sourceToolSlug: string}>}> $namespaces + */ + public function generateFunctionDocs(string $namespace, string $function, array $namespaces): string + { + if (! isset($namespaces[$namespace])) { + return "Namespace '{$namespace}' not found."; + } + + $match = null; + foreach ($namespaces[$namespace]['functions'] as $entry) { + if ($entry['name'] === $function) { + $match = $entry; + break; + } + } + + if ($match === null) { + $available = implode(', ', array_map( + fn (array $entry) => $entry['name'], + $namespaces[$namespace]['functions'], + )); + + return "Function '{$function}' not found in app.{$namespace}. Available: {$available}"; + } + + $lines = [ + '# app.' . $namespace . '.' . $this->buildSignature($match), + '', + ]; + + if ($match['description'] !== '') { + $lines[] = $match['description']; + $lines[] = ''; + } + + if ($match['fullDescription'] !== '' && $match['fullDescription'] !== $match['description']) { + $lines[] = $match['fullDescription']; + $lines[] = ''; + } + + $lines[] = $this->formatParameterTable($match['parameters']); + $lines[] = ''; + $lines[] = "*(Maps to tool: `{$match['sourceToolSlug']}`)*"; + + return implode("\n", $lines); + } + + /** + * @param array>, sourceToolSlug: string}>}> $namespaces + * @param array $staticPages + */ + public function search(string $query, array $namespaces, array $staticPages = [], int $limit = 10): string + { + $queryLower = strtolower($query); + $results = []; + + foreach ($namespaces as $namespaceName => $namespace) { + foreach ($namespace['functions'] as $function) { + $score = $this->scoreMatch($function, $namespaceName, $queryLower); + + if ($score > 0) { + $results[] = [ + 'score' => $score, + 'text' => '**app.' . $namespaceName . '.' . $this->buildSignature($function) . '** — ' . $function['description'], + ]; + } + } + } + + foreach ($staticPages as $slug => $content) { + if (stripos($content, $query) === false) { + continue; + } + + $results[] = [ + 'score' => 1, + 'text' => "**[{$slug}]** (supplementary doc)\n" . $this->extractSearchContext($content, $query), + ]; + } + + usort($results, fn (array $a, array $b) => $b['score'] <=> $a['score']); + $results = array_slice($results, 0, $limit); + + if ($results === []) { + return "No results found for '{$query}'."; + } + + $lines = ["Found " . count($results) . " result(s) for '{$query}':", '']; + foreach ($results as $result) { + $lines[] = $result['text']; + $lines[] = ''; + } + + return implode("\n", $lines); + } + + /** + * @param array>, sourceToolSlug: string}>}> $namespaces + * @param array $staticPages + * @return list + */ + public function getAvailablePages(array $namespaces, array $staticPages = []): array + { + $pages = array_keys($staticPages); + $pages = array_merge($pages, array_keys($namespaces)); + sort($pages); + + return $pages; + } + + /** + * @param array>, sourceToolSlug: string}>}> $namespaces + */ + public function getNamespaceSummary(array $namespaces): string + { + $internal = []; + $integrations = []; + $mcp = []; + + foreach (array_keys($namespaces) as $namespaceName) { + if (str_starts_with($namespaceName, 'mcp.')) { + $mcp[] = "app.{$namespaceName}"; + } elseif (str_starts_with($namespaceName, 'integrations.')) { + $integrations[] = "app.{$namespaceName}"; + } else { + $internal[] = "app.{$namespaceName}"; + } + } + + $lines = []; + + if ($internal !== []) { + $lines[] = ' Internal: ' . implode(', ', $internal); + } + + if ($integrations !== []) { + $lines[] = ' Integrations: ' . implode(', ', $integrations); + } + + if ($mcp !== []) { + $lines[] = ' MCP: ' . implode(', ', $mcp); + } + + $lines[] = ' Use lua_read_doc(page) for function signatures and parameters.'; + + return implode("\n", $lines); + } + + /** + * @param array{name: string, parameters: array>} $function + */ + private function buildSignature(array $function): string + { + $params = []; + + foreach ($function['parameters'] as $parameter) { + $required = (bool) ($parameter['required'] ?? false); + $name = (string) ($parameter['name'] ?? 'arg'); + $params[] = $required ? $name : $name . '?'; + } + + return $function['name'] . '({' . implode(', ', $params) . '})'; + } + + /** + * @param array> $parameters + */ + private function formatParameterTable(array $parameters): string + { + if ($parameters === []) { + return '*No parameters.*'; + } + + $lines = [ + '| Parameter | Type | Required | Description |', + '|-----------|------|----------|-------------|', + ]; + + foreach ($parameters as $parameter) { + $type = $parameter['type'] ?? 'string'; + if (is_array($type)) { + $type = implode(' | ', $type); + } + + $description = (string) ($parameter['description'] ?? ''); + if (! empty($parameter['enum']) && is_array($parameter['enum'])) { + $enumValues = implode(', ', array_map( + fn (mixed $value) => "`{$value}`", + $parameter['enum'], + )); + $description .= ($description !== '' ? ' ' : '') . "Values: {$enumValues}"; + } + + $lines[] = sprintf( + '| %s | %s | %s | %s |', + (string) ($parameter['name'] ?? 'arg'), + (string) $type, + ! empty($parameter['required']) ? 'yes' : 'no', + $description, + ); + } + + return implode("\n", $lines); + } + + /** + * @param array{name: string, description: string, parameters: array>} $function + */ + private function scoreMatch(array $function, string $namespaceName, string $queryLower): int + { + $score = 0; + $words = array_filter(explode(' ', $queryLower)); + + foreach ($words as $word) { + if (strtolower($function['name']) === $word) { + $score += 10; + } elseif (str_contains(strtolower($function['name']), $word)) { + $score += 5; + } + + if (str_contains(strtolower($namespaceName), $word)) { + $score += 3; + } + + if (str_contains(strtolower($function['description']), $word)) { + $score += 2; + } + + foreach ($function['parameters'] as $parameter) { + if (str_contains(strtolower((string) ($parameter['name'] ?? '')), $word)) { + $score += 1; + } + + if (str_contains(strtolower((string) ($parameter['description'] ?? '')), $word)) { + $score += 1; + } + } + } + + if (str_contains(strtolower($function['description']), $queryLower)) { + $score += 5; + } + + return $score; + } + + private function extractSearchContext(string $content, string $query): string + { + $lines = explode("\n", $content); + $snippets = []; + + foreach ($lines as $index => $line) { + if (stripos($line, $query) === false) { + continue; + } + + $start = max(0, $index - 2); + $end = min(count($lines) - 1, $index + 2); + $snippets[] = implode("\n", array_slice($lines, $start, $end - $start + 1)); + + if (count($snippets) >= 2) { + break; + } + } + + return implode("\n...\n", $snippets); + } +}