Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions core/src/Contracts/LuaToolInvoker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace OpenCompany\IntegrationCore\Contracts;

/**
* Host-application adapter used by the shared Lua bridge.
*
* The bridge resolves app.* paths to tool slugs, then delegates actual
* instantiation and execution to the host via this interface.
*/
interface LuaToolInvoker
{
/**
* Execute a tool by slug with Lua-facing named parameters.
*
* Implementations may normalize parameter names for legacy tool systems
* before dispatching.
*
* @param array<string, mixed> $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;
}
144 changes: 144 additions & 0 deletions core/src/Lua/LuaBridge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

namespace OpenCompany\IntegrationCore\Lua;

use OpenCompany\IntegrationCore\Contracts\LuaToolInvoker;

class LuaBridge
{
/** @var array<string, string> */
private array $functionMap;

/** @var array<string, list<string>> */
private array $parameterMap;

/** @var list<array{path: string, durationMs: float, status: string, error?: string, icon?: string, name?: string, group?: string}> */
private array $callLog = [];

/**
* @param array<string, string> $functionMap
* @param array<string, list<string>> $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<array{path: string, durationMs: float, status: string, error?: string, icon?: string, name?: string, group?: string}>
*/
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<int, mixed> $args
* @return array<string, mixed>
*/
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;
}
}
183 changes: 183 additions & 0 deletions core/src/Lua/LuaCatalogBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

namespace OpenCompany\IntegrationCore\Lua;

class LuaCatalogBuilder
{
/**
* Build normalized Lua namespaces from a host tool catalog.
*
* @param array<int, array<string, mixed>> $catalog
* @param array<int, string> $skipApps
* @return array<string, array{description: string, functions: array<int, array{name: string, description: string, fullDescription: string, parameters: array<int, array<string, mixed>>, 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<string, array{description: string, functions: array<int, array{name: string, description: string, fullDescription: string, parameters: array<int, array<string, mixed>>, sourceToolSlug: string}>}> $namespaces
* @return array<string, string>
*/
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<string, array{description: string, functions: array<int, array{name: string, description: string, fullDescription: string, parameters: array<int, array<string, mixed>>, sourceToolSlug: string}>}> $namespaces
* @return array<string, list<string>>
*/
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<string, mixed> $tool
* @return array{name: string, description: string, fullDescription: string, parameters: array<int, array<string, mixed>>, 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));
}
}
Loading