From d29ef5b45b2dc0ef1903abc5e42b8dd7f03efde8 Mon Sep 17 00:00:00 2001 From: golemcore1 Date: Sat, 11 Apr 2026 20:02:30 +0000 Subject: [PATCH 1/2] feat(golemcore/brain): add Brain wiki tool plugin --- README.md | 1 + golemcore/brain/plugin.yaml | 12 + golemcore/brain/pom.xml | 104 +++++ .../golemcore/brain/BrainPluginBootstrap.java | 22 + .../golemcore/brain/BrainPluginConfig.java | 76 +++ .../brain/BrainPluginConfigService.java | 42 ++ .../brain/BrainPluginConfiguration.java | 12 + .../brain/BrainPluginSettingsContributor.java | 176 +++++++ .../plugins/golemcore/brain/BrainService.java | 442 ++++++++++++++++++ .../golemcore/brain/BrainToolProvider.java | 246 ++++++++++ ...e.plugin.api.extension.spi.PluginBootstrap | 1 + .../testsupport/http/OkHttpMockEngine.java | 113 +++++ .../brain/BrainPluginBootstrapTest.java | 21 + .../brain/BrainPluginConfigServiceTest.java | 75 +++ .../brain/BrainPluginConfigurationTest.java | 35 ++ .../brain/BrainToolProviderTest.java | 223 +++++++++ pom.xml | 1 + registry/golemcore/brain/index.yaml | 7 + registry/golemcore/brain/versions/1.0.0.yaml | 9 + 19 files changed, 1618 insertions(+) create mode 100644 golemcore/brain/plugin.yaml create mode 100644 golemcore/brain/pom.xml create mode 100644 golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginBootstrap.java create mode 100644 golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfig.java create mode 100644 golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfigService.java create mode 100644 golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfiguration.java create mode 100644 golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginSettingsContributor.java create mode 100644 golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainService.java create mode 100644 golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainToolProvider.java create mode 100644 golemcore/brain/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap create mode 100644 golemcore/brain/src/test/java/me/golemcore/bot/testsupport/http/OkHttpMockEngine.java create mode 100644 golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainPluginBootstrapTest.java create mode 100644 golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfigServiceTest.java create mode 100644 golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfigurationTest.java create mode 100644 golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainToolProviderTest.java create mode 100644 registry/golemcore/brain/index.yaml create mode 100644 registry/golemcore/brain/versions/1.0.0.yaml diff --git a/README.md b/README.md index fa2bebf..d30aaea 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ The current `golemcore/*` modules in this repository are: | Plugin | Purpose | | --- | --- | | `golemcore/brave-search` | Brave Search web search tool plugin with API-key backed queries. | +| `golemcore/brain` | GolemCore Brain wiki tool plugin for CRUD operations and optional dynamic relevance search. | | `golemcore/browser` | Playwright-backed browser automation tool with screenshot support. | | `golemcore/browserless` | Browserless smart scrape plugin for rendered HTML, markdown, and link extraction. | | `golemcore/elevenlabs` | ElevenLabs-backed STT/TTS provider plugin. | diff --git a/golemcore/brain/plugin.yaml b/golemcore/brain/plugin.yaml new file mode 100644 index 0000000..3c6ee52 --- /dev/null +++ b/golemcore/brain/plugin.yaml @@ -0,0 +1,12 @@ +id: golemcore/brain +provider: golemcore +name: brain +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +entrypoint: me.golemcore.plugins.golemcore.brain.BrainPluginBootstrap +description: "GolemCore Brain wiki plugin for CRUD operations and optional dynamic relevance search." +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/brain" +license: "Apache-2.0" +maintainers: + - alexk-dev diff --git a/golemcore/brain/pom.xml b/golemcore/brain/pom.xml new file mode 100644 index 0000000..61f23e6 --- /dev/null +++ b/golemcore/brain/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + + me.golemcore.plugins + golemcore-plugins + 1.0.0 + ../../pom.xml + + + 1.0.0 + golemcore-brain-plugin + golemcore/brain + GolemCore Brain wiki tool plugin for GolemCore + + + golemcore + brain + ../../misc/formatter_eclipse.xml + + + + + me.golemcore.plugins + golemcore-plugin-extension-api + provided + + + me.golemcore.plugins + golemcore-plugin-runtime-api + provided + + + org.projectlombok + lombok + provided + + + org.springframework + spring-context + + + com.fasterxml.jackson.core + jackson-databind + + + com.squareup.okhttp3 + okhttp-jvm + ${okhttp.version} + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + src/main/resources + + + ${project.basedir} + + plugin.yaml + + META-INF/golemcore + + + + + net.revelc.code.formatter + formatter-maven-plugin + + + maven-shade-plugin + + + maven-antrun-plugin + + + copy-plugin-artifact + package + + + + + + + + run + + + + + + + diff --git a/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginBootstrap.java b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginBootstrap.java new file mode 100644 index 0000000..16b7ad2 --- /dev/null +++ b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginBootstrap.java @@ -0,0 +1,22 @@ +package me.golemcore.plugins.golemcore.brain; + +import me.golemcore.plugin.api.extension.spi.PluginBootstrap; +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; + +public class BrainPluginBootstrap implements PluginBootstrap { + + @Override + public PluginDescriptor descriptor() { + return PluginDescriptor.builder() + .id("golemcore/brain") + .provider("golemcore") + .name("brain") + .entrypoint(getClass().getName()) + .build(); + } + + @Override + public Class configurationClass() { + return BrainPluginConfiguration.class; + } +} diff --git a/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfig.java b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfig.java new file mode 100644 index 0000000..c210ace --- /dev/null +++ b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfig.java @@ -0,0 +1,76 @@ +package me.golemcore.plugins.golemcore.brain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BrainPluginConfig { + + private static final int DEFAULT_INTELLISEARCH_LIMIT = 5; + private static final int MAX_INTELLISEARCH_LIMIT = 20; + + @Builder.Default + private Boolean enabled = false; + + private String baseUrl; + private String apiToken; + + @Builder.Default + private String defaultSpaceSlug = "default"; + + private String dynamicApiSlug; + + @Builder.Default + private Integer defaultIntellisearchLimit = DEFAULT_INTELLISEARCH_LIMIT; + + @Builder.Default + private Boolean allowWrite = false; + + public void normalize() { + if (enabled == null) { + enabled = false; + } + baseUrl = trimToNull(baseUrl); + if (baseUrl != null) { + baseUrl = stripTrailingSlash(baseUrl); + } + apiToken = trimToNull(apiToken); + defaultSpaceSlug = normalizeDefaultSpace(defaultSpaceSlug); + dynamicApiSlug = trimToNull(dynamicApiSlug); + if (defaultIntellisearchLimit == null || defaultIntellisearchLimit <= 0) { + defaultIntellisearchLimit = DEFAULT_INTELLISEARCH_LIMIT; + } + if (defaultIntellisearchLimit > MAX_INTELLISEARCH_LIMIT) { + defaultIntellisearchLimit = MAX_INTELLISEARCH_LIMIT; + } + if (allowWrite == null) { + allowWrite = false; + } + } + + private String normalizeDefaultSpace(String value) { + String normalized = trimToNull(value); + return normalized != null ? normalized : "default"; + } + + private String stripTrailingSlash(String value) { + String stripped = value; + while (stripped.endsWith("/")) { + stripped = stripped.substring(0, stripped.length() - 1); + } + return stripped; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfigService.java b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfigService.java new file mode 100644 index 0000000..b620e47 --- /dev/null +++ b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfigService.java @@ -0,0 +1,42 @@ +package me.golemcore.plugins.golemcore.brain; + +import org.springframework.beans.factory.annotation.Autowired; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.plugin.api.runtime.PluginConfigurationService; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +public class BrainPluginConfigService { + + static final String PLUGIN_ID = "golemcore/brain"; + + private final PluginConfigurationService pluginConfigurationService; + private final ObjectMapper objectMapper; + + @Autowired + public BrainPluginConfigService(PluginConfigurationService pluginConfigurationService) { + this.pluginConfigurationService = pluginConfigurationService; + this.objectMapper = new ObjectMapper(); + } + + public BrainPluginConfig getConfig() { + Map raw = pluginConfigurationService == null + ? Map.of() + : pluginConfigurationService.getPluginConfig(PLUGIN_ID); + BrainPluginConfig config = raw.isEmpty() + ? BrainPluginConfig.builder().build() + : objectMapper.convertValue(raw, BrainPluginConfig.class); + config.normalize(); + return config; + } + + public void save(BrainPluginConfig config) { + config.normalize(); + Map saved = objectMapper.convertValue(config, new TypeReference<>() { + }); + pluginConfigurationService.savePluginConfig(PLUGIN_ID, saved); + } +} diff --git a/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfiguration.java b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfiguration.java new file mode 100644 index 0000000..d37dc51 --- /dev/null +++ b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfiguration.java @@ -0,0 +1,12 @@ +package me.golemcore.plugins.golemcore.brain; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@ComponentScan(basePackageClasses = { + BrainToolProvider.class, + BrainPluginSettingsContributor.class +}) +public class BrainPluginConfiguration { +} diff --git a/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginSettingsContributor.java b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginSettingsContributor.java new file mode 100644 index 0000000..597ebbd --- /dev/null +++ b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainPluginSettingsContributor.java @@ -0,0 +1,176 @@ +package me.golemcore.plugins.golemcore.brain; + +import org.springframework.beans.factory.annotation.Autowired; +import me.golemcore.plugin.api.extension.spi.PluginActionResult; +import me.golemcore.plugin.api.extension.spi.PluginSettingsCatalogItem; +import me.golemcore.plugin.api.extension.spi.PluginSettingsContributor; +import me.golemcore.plugin.api.extension.spi.PluginSettingsField; +import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class BrainPluginSettingsContributor implements PluginSettingsContributor { + + private static final String SECTION_KEY = "main"; + + private final BrainPluginConfigService configService; + + @Autowired + public BrainPluginSettingsContributor(BrainPluginConfigService configService) { + this.configService = configService; + } + + @Override + public String getPluginId() { + return BrainPluginConfigService.PLUGIN_ID; + } + + @Override + public List getCatalogItems() { + return List.of(PluginSettingsCatalogItem.builder() + .pluginId(BrainPluginConfigService.PLUGIN_ID) + .pluginName("brain") + .provider("golemcore") + .sectionKey(SECTION_KEY) + .title("GolemCore Brain") + .description("GolemCore Brain API credentials and wiki tool behavior.") + .blockKey("tools") + .blockTitle("Tools") + .blockDescription("Tool-specific runtime behavior and integrations") + .order(62) + .build()); + } + + @Override + public PluginSettingsSection getSection(String sectionKey) { + requireSection(sectionKey); + BrainPluginConfig config = configService.getConfig(); + Map values = new LinkedHashMap<>(); + values.put("enabled", Boolean.TRUE.equals(config.getEnabled())); + values.put("baseUrl", config.getBaseUrl()); + values.put("apiToken", ""); + values.put("defaultSpaceSlug", config.getDefaultSpaceSlug()); + values.put("dynamicApiSlug", config.getDynamicApiSlug()); + values.put("defaultIntellisearchLimit", config.getDefaultIntellisearchLimit()); + values.put("allowWrite", Boolean.TRUE.equals(config.getAllowWrite())); + return PluginSettingsSection.builder() + .title("GolemCore Brain") + .description("Configure the Brain wiki API tool and optional dynamic relevance endpoint.") + .fields(List.of( + PluginSettingsField.builder() + .key("enabled") + .type("boolean") + .label("Enable Brain tool") + .description("Allow the model to use the GolemCore Brain wiki tool.") + .build(), + PluginSettingsField.builder() + .key("baseUrl") + .type("text") + .label("Brain Base URL") + .description( + "Base URL of the GolemCore Brain instance, for example https://brain.example.") + .placeholder("https://brain.example") + .build(), + PluginSettingsField.builder() + .key("apiToken") + .type("secret") + .label("Brain API Token") + .description("Optional bearer token generated by Brain API key settings.") + .placeholder("Enter API token") + .build(), + PluginSettingsField.builder() + .key("defaultSpaceSlug") + .type("text") + .label("Default Space Slug") + .description("Brain space used when the tool call does not provide space_slug.") + .placeholder("default") + .build(), + PluginSettingsField.builder() + .key("dynamicApiSlug") + .type("text") + .label("Dynamic Relevance Endpoint") + .description("Optional Brain dynamic API slug used by intellisearch.") + .placeholder("brain-relevance") + .build(), + PluginSettingsField.builder() + .key("defaultIntellisearchLimit") + .type("number") + .label("Default Intellisearch Limit") + .description("Maximum fallback pages to fetch when dynamic endpoint is not configured.") + .min(1.0) + .max(20.0) + .step(1.0) + .build(), + PluginSettingsField.builder() + .key("allowWrite") + .type("boolean") + .label("Allow Write Operations") + .description("Allow create, update, delete, ensure, move, and copy operations.") + .build())) + .values(values) + .build(); + } + + @Override + public PluginSettingsSection saveSection(String sectionKey, Map values) { + requireSection(sectionKey); + BrainPluginConfig config = configService.getConfig(); + config.setEnabled(readBoolean(values, "enabled", false)); + config.setBaseUrl(readString(values, "baseUrl", config.getBaseUrl())); + String apiToken = readString(values, "apiToken", null); + if (apiToken != null && !apiToken.isBlank()) { + config.setApiToken(apiToken); + } + config.setDefaultSpaceSlug(readString(values, "defaultSpaceSlug", config.getDefaultSpaceSlug())); + config.setDynamicApiSlug(readString(values, "dynamicApiSlug", config.getDynamicApiSlug())); + config.setDefaultIntellisearchLimit(readInteger(values, "defaultIntellisearchLimit", + config.getDefaultIntellisearchLimit())); + config.setAllowWrite(readBoolean(values, "allowWrite", false)); + configService.save(config); + return getSection(sectionKey); + } + + @Override + public PluginActionResult executeAction(String sectionKey, String actionId, Map payload) { + requireSection(sectionKey); + throw new IllegalArgumentException("Unknown Brain action: " + actionId); + } + + private void requireSection(String sectionKey) { + if (!SECTION_KEY.equals(sectionKey)) { + throw new IllegalArgumentException("Unknown Brain settings section: " + sectionKey); + } + } + + private boolean readBoolean(Map values, String key, boolean defaultValue) { + Object value = values.get(key); + return value instanceof Boolean bool ? bool : defaultValue; + } + + private int readInteger(Map values, String key, int defaultValue) { + Object value = values.get(key); + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Integer.parseInt(text); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + return defaultValue; + } + + private String readString(Map values, String key, String defaultValue) { + Object value = values.get(key); + if (value instanceof String text) { + return text; + } + return defaultValue; + } +} diff --git a/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainService.java b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainService.java new file mode 100644 index 0000000..c9a4e5b --- /dev/null +++ b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainService.java @@ -0,0 +1,442 @@ +package me.golemcore.plugins.golemcore.brain; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class BrainService { + + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + private static final int DEFAULT_SEARCH_LIMIT = 5; + + private final BrainPluginConfigService configService; + private final OkHttpClient httpClient; + private final ObjectMapper objectMapper; + + public BrainService(BrainPluginConfigService configService) { + this(configService, new OkHttpClient()); + } + + public BrainService(BrainPluginConfigService configService, OkHttpClient httpClient) { + this.configService = configService; + this.httpClient = httpClient; + this.objectMapper = new ObjectMapper(); + } + + public boolean isAvailable() { + BrainPluginConfig config = configService.getConfig(); + return Boolean.TRUE.equals(config.getEnabled()) && config.getBaseUrl() != null + && !config.getBaseUrl().isBlank(); + } + + public ToolResult listSpaces() { + try { + JsonNode node = executeJson("GET", apiUrl("/api/spaces"), null); + int count = node.isArray() ? node.size() : 0; + return ToolResult.success("Listed " + count + " Brain space(s)", nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult listTree(String spaceSlug) { + String space = resolveSpace(spaceSlug); + try { + JsonNode node = executeJson("GET", spaceUrl(space, "/tree"), null); + return ToolResult.success("Loaded Brain tree for space " + space, nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult searchPages(String spaceSlug, String query, Integer limit) { + String resolvedQuery = requireText(query, "query"); + String space = resolveSpace(spaceSlug); + int resolvedLimit = resolveLimit(limit, DEFAULT_SEARCH_LIMIT); + try { + JsonNode node = executeJson("GET", searchUrl(space, resolvedQuery, resolvedLimit), null); + return ToolResult.success(formatSearchResults(resolvedQuery, node), nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult intellisearch( + String spaceSlug, + String context, + String query, + Integer limit, + String dynamicApiSlug, + Boolean useDynamicEndpoint) { + String resolvedContext = requireText(firstNonBlank(context, query), "context or query"); + String resolvedQuery = firstNonBlank(query, context); + String space = resolveSpace(spaceSlug); + int resolvedLimit = resolveLimit(limit, configService.getConfig().getDefaultIntellisearchLimit()); + String resolvedDynamicApiSlug = trimToNull(dynamicApiSlug); + if (resolvedDynamicApiSlug == null) { + resolvedDynamicApiSlug = trimToNull(configService.getConfig().getDynamicApiSlug()); + } + boolean shouldUseDynamicEndpoint = !Boolean.FALSE.equals(useDynamicEndpoint) && resolvedDynamicApiSlug != null; + if (shouldUseDynamicEndpoint) { + return runDynamicIntellisearch(space, resolvedDynamicApiSlug, resolvedContext, resolvedQuery, + resolvedLimit); + } + return fallbackIntellisearch(space, resolvedContext, resolvedQuery, resolvedLimit); + } + + public ToolResult readPage(String spaceSlug, String path) { + String pagePath = requireText(path, "path"); + String space = resolveSpace(spaceSlug); + try { + JsonNode node = executeJson("GET", pageUrl(space, pagePath), null); + String content = node.path("content").asText(node.toPrettyString()); + return ToolResult.success(content, nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult createPage(String spaceSlug, String parentPath, String title, String slug, String content, + String kind) { + requireWriteAllowed(); + String space = resolveSpace(spaceSlug); + Map payload = new LinkedHashMap<>(); + payload.put("parentPath", valueOrEmpty(parentPath)); + payload.put("title", requireText(title, "title")); + payload.put("slug", trimToNull(slug)); + payload.put("content", valueOrEmpty(content)); + payload.put("kind", trimToNull(kind) != null ? kind : "PAGE"); + try { + JsonNode node = executeJson("POST", spaceUrl(space, "/pages"), payload); + return ToolResult.success("Created Brain page " + node.path("path").asText(""), nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult updatePage(String spaceSlug, String path, String title, String slug, String content, + String revision) { + requireWriteAllowed(); + String pagePath = requireText(path, "path"); + String space = resolveSpace(spaceSlug); + Map payload = new LinkedHashMap<>(); + payload.put("title", trimToNull(title)); + payload.put("slug", trimToNull(slug)); + payload.put("content", valueOrEmpty(content)); + payload.put("revision", trimToNull(revision)); + try { + JsonNode node = executeJson("PUT", pageUrl(space, pagePath), payload); + return ToolResult.success("Updated Brain page " + node.path("path").asText(pagePath), nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult deletePage(String spaceSlug, String path) { + requireWriteAllowed(); + String pagePath = requireText(path, "path"); + String space = resolveSpace(spaceSlug); + try { + executeNoContent("DELETE", pageUrl(space, pagePath), null); + return ToolResult.success("Deleted Brain page " + pagePath, Map.of("path", pagePath)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult ensurePage(String spaceSlug, String path, String targetTitle) { + requireWriteAllowed(); + String pagePath = requireText(path, "path"); + String space = resolveSpace(spaceSlug); + Map payload = new LinkedHashMap<>(); + payload.put("path", pagePath); + payload.put("targetTitle", trimToNull(targetTitle)); + try { + JsonNode node = executeJson("POST", spaceUrl(space, "/pages/ensure"), payload); + return ToolResult.success("Ensured Brain page " + node.path("path").asText(pagePath), nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult movePage(String spaceSlug, String path, String targetParentPath, String targetSlug, + String beforeSlug) { + requireWriteAllowed(); + String pagePath = requireText(path, "path"); + String space = resolveSpace(spaceSlug); + Map payload = new LinkedHashMap<>(); + payload.put("targetParentPath", valueOrEmpty(targetParentPath)); + payload.put("targetSlug", trimToNull(targetSlug)); + payload.put("beforeSlug", trimToNull(beforeSlug)); + try { + JsonNode node = executeJson("POST", pageActionUrl(space, pagePath, "/page/move"), payload); + return ToolResult.success("Moved Brain page " + pagePath, nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult copyPage(String spaceSlug, String path, String targetParentPath, String targetSlug, + String beforeSlug) { + requireWriteAllowed(); + String pagePath = requireText(path, "path"); + String space = resolveSpace(spaceSlug); + Map payload = new LinkedHashMap<>(); + payload.put("targetParentPath", valueOrEmpty(targetParentPath)); + payload.put("targetSlug", trimToNull(targetSlug)); + payload.put("beforeSlug", trimToNull(beforeSlug)); + try { + JsonNode node = executeJson("POST", pageActionUrl(space, pagePath, "/page/copy"), payload); + return ToolResult.success("Copied Brain page " + pagePath, nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult listAssets(String spaceSlug, String path) { + String pagePath = requireText(path, "path"); + String space = resolveSpace(spaceSlug); + try { + JsonNode node = executeJson("GET", pageActionUrl(space, pagePath, "/pages/assets"), null); + return ToolResult.success("Listed Brain assets for " + pagePath, nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + private ToolResult runDynamicIntellisearch(String space, String dynamicApiSlug, String context, String query, + int limit) { + Map payload = new LinkedHashMap<>(); + payload.put("context", context); + payload.put("query", query); + payload.put("limit", limit); + payload.put("task", "Find the most relevant Brain documents for the provided context."); + try { + JsonNode node = executeJson("POST", dynamicApiUrl(space, dynamicApiSlug), payload); + return ToolResult.success("Brain dynamic intellisearch via " + dynamicApiSlug + ": " + + compactJson(node), nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + private ToolResult fallbackIntellisearch(String space, String context, String query, int limit) { + try { + JsonNode hits = executeJson("GET", searchUrl(space, query, limit), null); + List> documents = new ArrayList<>(); + if (hits.isArray()) { + for (JsonNode hit : hits) { + String path = hit.path("path").asText(null); + if (path == null || path.isBlank()) { + continue; + } + JsonNode page = executeJson("GET", pageUrl(space, path), null); + Map document = new LinkedHashMap<>(); + document.put("path", path); + document.put("title", page.path("title").asText(hit.path("title").asText(""))); + document.put("excerpt", hit.path("excerpt").asText("")); + document.put("content", page.path("content").asText("")); + documents.add(document); + } + } + Map data = new LinkedHashMap<>(); + data.put("context", context); + data.put("query", query); + data.put("documents", documents); + return ToolResult.success(formatIntellisearchDocuments(documents), data); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + private JsonNode executeJson(String method, HttpUrl url, Map payload) throws IOException { + try (Response response = httpClient.newCall(buildRequest(method, url, payload)).execute(); + ResponseBody body = response.body()) { + String responseBody = body != null ? body.string() : ""; + if (!response.isSuccessful()) { + throw new IllegalStateException("Brain API request failed with HTTP " + response.code() + + responseMessage(responseBody)); + } + if (responseBody.isBlank()) { + return objectMapper.nullNode(); + } + return objectMapper.readTree(responseBody); + } + } + + private void executeNoContent(String method, HttpUrl url, Map payload) throws IOException { + try (Response response = httpClient.newCall(buildRequest(method, url, payload)).execute(); + ResponseBody body = response.body()) { + String responseBody = body != null ? body.string() : ""; + if (!response.isSuccessful()) { + throw new IllegalStateException("Brain API request failed with HTTP " + response.code() + + responseMessage(responseBody)); + } + } + } + + private Request buildRequest(String method, HttpUrl url, Map payload) + throws JsonProcessingException { + Request.Builder builder = new Request.Builder() + .url(url) + .header("Accept", "application/json"); + String apiToken = configService.getConfig().getApiToken(); + if (apiToken != null && !apiToken.isBlank()) { + builder.header("Authorization", "Bearer " + apiToken); + } + RequestBody body = payload == null ? null : RequestBody.create(objectMapper.writeValueAsString(payload), JSON); + return builder.method(method, body).build(); + } + + private HttpUrl apiUrl(String path) { + HttpUrl base = parseBaseUrl(); + return base.newBuilder() + .encodedPath(path) + .build(); + } + + private HttpUrl spaceUrl(String space, String path) { + return apiUrl("/api/spaces/" + encodePathSegment(space) + path); + } + + private HttpUrl pageUrl(String space, String path) { + return spaceUrl(space, "/page").newBuilder() + .addQueryParameter("path", path) + .build(); + } + + private HttpUrl pageActionUrl(String space, String path, String actionPath) { + return spaceUrl(space, actionPath).newBuilder() + .addQueryParameter("path", path) + .build(); + } + + private HttpUrl searchUrl(String space, String query, int limit) { + return spaceUrl(space, "/search").newBuilder() + .addQueryParameter("q", query) + .addQueryParameter("limit", Integer.toString(limit)) + .build(); + } + + private HttpUrl dynamicApiUrl(String space, String dynamicApiSlug) { + return spaceUrl(space, "/dynamic-apis/" + encodePathSegment(dynamicApiSlug) + "/run"); + } + + private HttpUrl parseBaseUrl() { + String baseUrl = requireText(configService.getConfig().getBaseUrl(), "baseUrl"); + HttpUrl parsed = HttpUrl.parse(baseUrl); + if (parsed == null) { + throw new IllegalStateException("Invalid Brain base URL: " + baseUrl); + } + return parsed; + } + + private String resolveSpace(String spaceSlug) { + return requireText(firstNonBlank(spaceSlug, configService.getConfig().getDefaultSpaceSlug()), "space_slug"); + } + + private int resolveLimit(Integer value, int defaultValue) { + int limit = value != null && value > 0 ? value : defaultValue; + return Math.max(1, Math.min(20, limit)); + } + + private void requireWriteAllowed() { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + throw new IllegalStateException("Brain write operations are disabled in plugin settings"); + } + } + + private Object nodeToData(JsonNode node) throws JsonProcessingException { + return objectMapper.convertValue(node, new TypeReference<>() { + }); + } + + private String formatSearchResults(String query, JsonNode node) { + if (!node.isArray() || node.isEmpty()) { + return "No Brain pages found for: " + query; + } + List rows = new ArrayList<>(); + for (JsonNode hit : node) { + rows.add("- " + hit.path("title").asText(hit.path("path").asText("")) + + " (`" + hit.path("path").asText("") + "`): " + + hit.path("excerpt").asText("")); + } + return "Brain search results for \"" + query + "\":\n" + String.join("\n", rows); + } + + private String formatIntellisearchDocuments(List> documents) { + if (documents.isEmpty()) { + return "Brain fallback intellisearch returned 0 document(s)"; + } + List rows = new ArrayList<>(); + for (Map document : documents) { + rows.add("- " + document.getOrDefault("title", "") + + " (`" + document.getOrDefault("path", "") + "`): " + + document.getOrDefault("content", "")); + } + return "Brain fallback intellisearch returned " + documents.size() + " document(s):\n" + + String.join("\n", rows); + } + + private String compactJson(JsonNode node) throws JsonProcessingException { + return objectMapper.writeValueAsString(nodeToData(node)); + } + + private ToolResult executionFailure(String message) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, + message != null ? message : "Brain API request failed"); + } + + private String responseMessage(String body) { + if (body == null || body.isBlank()) { + return ""; + } + return ": " + body; + } + + private String requireText(String value, String fieldName) { + String text = trimToNull(value); + if (text == null) { + throw new IllegalArgumentException(fieldName + " is required"); + } + return text; + } + + private String firstNonBlank(String first, String second) { + String firstText = trimToNull(first); + return firstText != null ? firstText : trimToNull(second); + } + + private String valueOrEmpty(String value) { + String text = trimToNull(value); + return text != null ? text : ""; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String encodePathSegment(String value) { + return value.replace("/", "%2F"); + } +} diff --git a/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainToolProvider.java b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainToolProvider.java new file mode 100644 index 0000000..7067a53 --- /dev/null +++ b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainToolProvider.java @@ -0,0 +1,246 @@ +package me.golemcore.plugins.golemcore.brain; + +import org.springframework.beans.factory.annotation.Autowired; +import me.golemcore.plugin.api.extension.model.ToolDefinition; +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugin.api.extension.spi.ToolProvider; +import okhttp3.OkHttpClient; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +@Component +public class BrainToolProvider implements ToolProvider { + + private static final String TYPE = "type"; + private static final String TYPE_OBJECT = "object"; + private static final String TYPE_STRING = "string"; + private static final String TYPE_INTEGER = "integer"; + private static final String TYPE_BOOLEAN = "boolean"; + private static final String PROPERTIES = "properties"; + private static final String REQUIRED = "required"; + private static final String PARAM_OPERATION = "operation"; + private static final String PARAM_SPACE_SLUG = "space_slug"; + private static final String PARAM_QUERY = "query"; + private static final String PARAM_CONTEXT = "context"; + private static final String PARAM_LIMIT = "limit"; + private static final String PARAM_PATH = "path"; + private static final String PARAM_PARENT_PATH = "parent_path"; + private static final String PARAM_TITLE = "title"; + private static final String PARAM_SLUG = "slug"; + private static final String PARAM_CONTENT = "content"; + private static final String PARAM_KIND = "kind"; + private static final String PARAM_REVISION = "revision"; + private static final String PARAM_TARGET_TITLE = "target_title"; + private static final String PARAM_TARGET_PARENT_PATH = "target_parent_path"; + private static final String PARAM_TARGET_SLUG = "target_slug"; + private static final String PARAM_BEFORE_SLUG = "before_slug"; + private static final String PARAM_DYNAMIC_API_SLUG = "dynamic_api_slug"; + private static final String PARAM_USE_DYNAMIC_ENDPOINT = "use_dynamic_endpoint"; + + private final BrainPluginConfigService configService; + private final BrainService service; + + @Autowired + public BrainToolProvider(BrainPluginConfigService configService) { + this(configService, new OkHttpClient()); + } + + BrainToolProvider(BrainPluginConfigService configService, OkHttpClient httpClient) { + this.configService = configService; + this.service = new BrainService(configService, httpClient); + } + + @Override + public boolean isEnabled() { + return service.isAvailable(); + } + + @Override + public ToolDefinition getDefinition() { + Map properties = new LinkedHashMap<>(); + properties.put(PARAM_OPERATION, Map.of( + TYPE, TYPE_STRING, + "enum", List.of( + "list_spaces", + "list_tree", + "search_pages", + "intellisearch", + "read_page", + "create_page", + "update_page", + "delete_page", + "ensure_page", + "move_page", + "copy_page", + "list_assets"))); + properties.put(PARAM_SPACE_SLUG, stringProperty("Brain space slug. Defaults to plugin settings.")); + properties.put(PARAM_QUERY, stringProperty("Search query.")); + properties.put(PARAM_CONTEXT, stringProperty("Natural language context for intellisearch.")); + properties.put(PARAM_LIMIT, integerProperty("Maximum number of results or pages.")); + properties.put(PARAM_PATH, stringProperty("Brain page path.")); + properties.put(PARAM_PARENT_PATH, stringProperty("Parent section path for create_page.")); + properties.put(PARAM_TITLE, stringProperty("Page title.")); + properties.put(PARAM_SLUG, stringProperty("Optional page slug.")); + properties.put(PARAM_CONTENT, stringProperty("Markdown page content.")); + properties.put(PARAM_KIND, stringProperty("Brain node kind: PAGE or SECTION.")); + properties.put(PARAM_REVISION, stringProperty("Optional optimistic-lock revision for update_page.")); + properties.put(PARAM_TARGET_TITLE, stringProperty("Target title for ensure_page.")); + properties.put(PARAM_TARGET_PARENT_PATH, stringProperty("Target parent path for move_page or copy_page.")); + properties.put(PARAM_TARGET_SLUG, stringProperty("Target slug for move_page or copy_page.")); + properties.put(PARAM_BEFORE_SLUG, stringProperty("Sibling slug to insert before when moving or copying.")); + properties.put(PARAM_DYNAMIC_API_SLUG, stringProperty("Optional Brain dynamic API slug for intellisearch.")); + properties.put(PARAM_USE_DYNAMIC_ENDPOINT, + booleanProperty("Set false to force fallback search even when a dynamic endpoint is configured.")); + + Map schema = new LinkedHashMap<>(); + schema.put(TYPE, TYPE_OBJECT); + schema.put(PROPERTIES, properties); + schema.put(REQUIRED, List.of(PARAM_OPERATION)); + schema.put("allOf", List.of( + requiredWhen("search_pages", List.of(PARAM_QUERY)), + requiredWhenAny("intellisearch", List.of(List.of(PARAM_CONTEXT), List.of(PARAM_QUERY))), + requiredWhen("read_page", List.of(PARAM_PATH)), + requiredWhen("create_page", List.of(PARAM_TITLE)), + requiredWhen("update_page", List.of(PARAM_PATH, PARAM_CONTENT)), + requiredWhen("delete_page", List.of(PARAM_PATH)), + requiredWhen("ensure_page", List.of(PARAM_PATH)), + requiredWhen("move_page", List.of(PARAM_PATH, PARAM_TARGET_PARENT_PATH)), + requiredWhen("copy_page", List.of(PARAM_PATH, PARAM_TARGET_PARENT_PATH)), + requiredWhen("list_assets", List.of(PARAM_PATH)))); + + return ToolDefinition.builder() + .name("golemcore_brain") + .description("Use GolemCore Brain wiki pages, spaces, search, and optional dynamic intellisearch.") + .inputSchema(schema) + .build(); + } + + @Override + public CompletableFuture execute(Map parameters) { + return CompletableFuture.supplyAsync(() -> executeOperation(parameters)); + } + + private ToolResult executeOperation(Map parameters) { + String operation = readString(parameters.get(PARAM_OPERATION)); + if (operation == null || operation.isBlank()) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, "operation is required"); + } + if (!isEnabled()) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "GolemCore Brain tool is disabled or base URL is missing"); + } + try { + return switch (operation) { + case "list_spaces" -> service.listSpaces(); + case "list_tree" -> service.listTree(readString(parameters.get(PARAM_SPACE_SLUG))); + case "search_pages" -> service.searchPages( + readString(parameters.get(PARAM_SPACE_SLUG)), + readString(parameters.get(PARAM_QUERY)), + readInteger(parameters.get(PARAM_LIMIT))); + case "intellisearch" -> service.intellisearch( + readString(parameters.get(PARAM_SPACE_SLUG)), + readString(parameters.get(PARAM_CONTEXT)), + readString(parameters.get(PARAM_QUERY)), + readInteger(parameters.get(PARAM_LIMIT)), + readString(parameters.get(PARAM_DYNAMIC_API_SLUG)), + readBoolean(parameters.get(PARAM_USE_DYNAMIC_ENDPOINT))); + case "read_page" -> service.readPage( + readString(parameters.get(PARAM_SPACE_SLUG)), + readString(parameters.get(PARAM_PATH))); + case "create_page" -> service.createPage( + readString(parameters.get(PARAM_SPACE_SLUG)), + readString(parameters.get(PARAM_PARENT_PATH)), + readString(parameters.get(PARAM_TITLE)), + readString(parameters.get(PARAM_SLUG)), + readString(parameters.get(PARAM_CONTENT)), + readString(parameters.get(PARAM_KIND))); + case "update_page" -> service.updatePage( + readString(parameters.get(PARAM_SPACE_SLUG)), + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_TITLE)), + readString(parameters.get(PARAM_SLUG)), + readString(parameters.get(PARAM_CONTENT)), + readString(parameters.get(PARAM_REVISION))); + case "delete_page" -> service.deletePage( + readString(parameters.get(PARAM_SPACE_SLUG)), + readString(parameters.get(PARAM_PATH))); + case "ensure_page" -> service.ensurePage( + readString(parameters.get(PARAM_SPACE_SLUG)), + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_TARGET_TITLE))); + case "move_page" -> service.movePage( + readString(parameters.get(PARAM_SPACE_SLUG)), + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_TARGET_PARENT_PATH)), + readString(parameters.get(PARAM_TARGET_SLUG)), + readString(parameters.get(PARAM_BEFORE_SLUG))); + case "copy_page" -> service.copyPage( + readString(parameters.get(PARAM_SPACE_SLUG)), + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_TARGET_PARENT_PATH)), + readString(parameters.get(PARAM_TARGET_SLUG)), + readString(parameters.get(PARAM_BEFORE_SLUG))); + case "list_assets" -> service.listAssets( + readString(parameters.get(PARAM_SPACE_SLUG)), + readString(parameters.get(PARAM_PATH))); + default -> ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, + "Unknown Brain operation: " + operation); + }; + } catch (IllegalArgumentException | IllegalStateException exception) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, exception.getMessage()); + } + } + + private static Map requiredWhen(String operation, List requiredFields) { + return Map.of( + "if", Map.of(PROPERTIES, Map.of(PARAM_OPERATION, Map.of("const", operation))), + "then", Map.of(REQUIRED, requiredFields)); + } + + private static Map requiredWhenAny(String operation, List> alternatives) { + return Map.of( + "if", Map.of(PROPERTIES, Map.of(PARAM_OPERATION, Map.of("const", operation))), + "then", Map.of("anyOf", alternatives.stream() + .map(required -> Map.of(REQUIRED, required)) + .toList())); + } + + private static Map stringProperty(String description) { + return Map.of(TYPE, TYPE_STRING, "description", description); + } + + private static Map integerProperty(String description) { + return Map.of(TYPE, TYPE_INTEGER, "description", description); + } + + private static Map booleanProperty(String description) { + return Map.of(TYPE, TYPE_BOOLEAN, "description", description); + } + + private String readString(Object value) { + return value instanceof String text ? text : null; + } + + private Integer readInteger(Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Integer.parseInt(text); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + private boolean readBoolean(Object value) { + return !(value instanceof Boolean bool) || bool; + } +} diff --git a/golemcore/brain/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap b/golemcore/brain/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap new file mode 100644 index 0000000..d05d252 --- /dev/null +++ b/golemcore/brain/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap @@ -0,0 +1 @@ +me.golemcore.plugins.golemcore.brain.BrainPluginBootstrap diff --git a/golemcore/brain/src/test/java/me/golemcore/bot/testsupport/http/OkHttpMockEngine.java b/golemcore/brain/src/test/java/me/golemcore/bot/testsupport/http/OkHttpMockEngine.java new file mode 100644 index 0000000..320bb52 --- /dev/null +++ b/golemcore/brain/src/test/java/me/golemcore/bot/testsupport/http/OkHttpMockEngine.java @@ -0,0 +1,113 @@ +package me.golemcore.bot.testsupport.http; + +import okhttp3.Headers; +import okhttp3.Interceptor; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Buffer; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; + +public final class OkHttpMockEngine implements Interceptor { + + private static final String DEFAULT_CONTENT_TYPE = "application/json"; + + private final Queue plannedResults = new ConcurrentLinkedQueue<>(); + private final Queue capturedRequests = new ConcurrentLinkedQueue<>(); + private final AtomicInteger requestCount = new AtomicInteger(); + + public void enqueueJson(int code, String body) { + enqueue(code, body != null ? body.getBytes(StandardCharsets.UTF_8) : new byte[0], DEFAULT_CONTENT_TYPE); + } + + public CapturedRequest takeRequest() { + return capturedRequests.poll(); + } + + public int getRequestCount() { + return requestCount.get(); + } + + @Override + public Response intercept(Chain chain) throws IOException { + Request request = chain.request(); + String requestBody = readRequestBody(request); + capturedRequests.add(new CapturedRequest(request, requestBody)); + requestCount.incrementAndGet(); + + PlannedResult plannedResult = plannedResults.poll(); + if (plannedResult == null) { + throw new IOException("No planned response for request: " + request.method() + " " + request.url()); + } + + MediaType mediaType = plannedResult.contentType() != null + ? MediaType.parse(plannedResult.contentType()) + : null; + ResponseBody responseBody = ResponseBody.create(plannedResult.body(), mediaType); + return new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(plannedResult.code()) + .message("mock") + .headers(Headers.of()) + .body(responseBody) + .build(); + } + + private void enqueue(int code, byte[] body, String contentType) { + plannedResults.add(new PlannedResult(code, body, contentType)); + } + + private String readRequestBody(Request request) throws IOException { + RequestBody requestBody = request.body(); + if (requestBody == null) { + return ""; + } + try (Buffer buffer = new Buffer()) { + requestBody.writeTo(buffer); + return buffer.readString(StandardCharsets.UTF_8); + } + } + + private record PlannedResult(int code, byte[] body, String contentType) { + } + + public static final class CapturedRequest { + private final Request request; + private final String requestBody; + + private CapturedRequest(Request request, String requestBody) { + this.request = request; + this.requestBody = requestBody; + } + + public String method() { + return request.method(); + } + + public String target() { + String encodedPath = request.url().encodedPath(); + String encodedQuery = request.url().encodedQuery(); + if (encodedQuery == null || encodedQuery.isBlank()) { + return encodedPath; + } + return encodedPath + "?" + encodedQuery; + } + + public String header(String name) { + return request.header(name); + } + + public String body() { + return requestBody; + } + } +} diff --git a/golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainPluginBootstrapTest.java b/golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainPluginBootstrapTest.java new file mode 100644 index 0000000..4008461 --- /dev/null +++ b/golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainPluginBootstrapTest.java @@ -0,0 +1,21 @@ +package me.golemcore.plugins.golemcore.brain; + +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BrainPluginBootstrapTest { + + @Test + void shouldExposeBrainPluginDescriptor() { + BrainPluginBootstrap bootstrap = new BrainPluginBootstrap(); + + PluginDescriptor descriptor = bootstrap.descriptor(); + + assertEquals("golemcore/brain", descriptor.getId()); + assertEquals("golemcore", descriptor.getProvider()); + assertEquals("brain", descriptor.getName()); + assertEquals(BrainPluginConfiguration.class, bootstrap.configurationClass()); + } +} diff --git a/golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfigServiceTest.java b/golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfigServiceTest.java new file mode 100644 index 0000000..91bda65 --- /dev/null +++ b/golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfigServiceTest.java @@ -0,0 +1,75 @@ +package me.golemcore.plugins.golemcore.brain; + +import me.golemcore.plugin.api.runtime.PluginConfigurationService; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BrainPluginConfigServiceTest { + + @Test + void shouldLoadAndNormalizeConfig() { + PluginConfigurationService pluginConfigurationService = mock(PluginConfigurationService.class); + when(pluginConfigurationService.getPluginConfig(BrainPluginConfigService.PLUGIN_ID)).thenReturn(Map.of( + "enabled", true, + "baseUrl", "https://brain.example/", + "apiToken", "token", + "defaultSpaceSlug", "docs", + "allowWrite", true, + "defaultIntellisearchLimit", 500)); + + BrainPluginConfig config = new BrainPluginConfigService(pluginConfigurationService).getConfig(); + + assertTrue(config.getEnabled()); + assertEquals("https://brain.example", config.getBaseUrl()); + assertEquals("docs", config.getDefaultSpaceSlug()); + assertEquals(20, config.getDefaultIntellisearchLimit()); + assertTrue(config.getAllowWrite()); + } + + @Test + void shouldPersistNormalizedConfig() { + PluginConfigurationService pluginConfigurationService = mock(PluginConfigurationService.class); + BrainPluginConfigService service = new BrainPluginConfigService(pluginConfigurationService); + BrainPluginConfig config = BrainPluginConfig.builder() + .enabled(true) + .baseUrl("https://brain.example/") + .apiToken("token") + .defaultSpaceSlug("docs") + .allowWrite(false) + .defaultIntellisearchLimit(0) + .build(); + + service.save(config); + + verify(pluginConfigurationService).savePluginConfig(eq(BrainPluginConfigService.PLUGIN_ID), + org.mockito.Mockito.argThat(saved -> { + Object baseUrl = saved.get("baseUrl"); + Object defaultLimit = saved.get("defaultIntellisearchLimit"); + Object allowWrite = saved.get("allowWrite"); + return "https://brain.example".equals(baseUrl) + && Integer.valueOf(5).equals(defaultLimit) + && Boolean.FALSE.equals(allowWrite); + })); + } + + @Test + void shouldDefaultDisabledConfig() { + PluginConfigurationService pluginConfigurationService = mock(PluginConfigurationService.class); + when(pluginConfigurationService.getPluginConfig(BrainPluginConfigService.PLUGIN_ID)).thenReturn(Map.of()); + + BrainPluginConfig config = new BrainPluginConfigService(pluginConfigurationService).getConfig(); + + assertFalse(config.getEnabled()); + assertEquals("default", config.getDefaultSpaceSlug()); + assertEquals(5, config.getDefaultIntellisearchLimit()); + } +} diff --git a/golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfigurationTest.java b/golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfigurationTest.java new file mode 100644 index 0000000..a409481 --- /dev/null +++ b/golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainPluginConfigurationTest.java @@ -0,0 +1,35 @@ +package me.golemcore.plugins.golemcore.brain; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import me.golemcore.plugin.api.runtime.PluginConfigurationService; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; + +class BrainPluginConfigurationTest { + + @Test + void shouldCreateBrainPluginBeans() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.register(BrainPluginConfiguration.class, TestConfig.class); + context.refresh(); + + assertNotNull(context.getBean(BrainToolProvider.class)); + assertNotNull(context.getBean(BrainPluginSettingsContributor.class)); + } + } + + @Configuration(proxyBeanMethods = false) + @SuppressWarnings("PMD.TestClassWithoutTestCases") + static class TestConfig { + + @Bean + PluginConfigurationService pluginConfigurationService() { + return mock(PluginConfigurationService.class); + } + } +} diff --git a/golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainToolProviderTest.java b/golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainToolProviderTest.java new file mode 100644 index 0000000..44327ac --- /dev/null +++ b/golemcore/brain/src/test/java/me/golemcore/plugins/golemcore/brain/BrainToolProviderTest.java @@ -0,0 +1,223 @@ +package me.golemcore.plugins.golemcore.brain; + +import me.golemcore.bot.testsupport.http.OkHttpMockEngine; +import me.golemcore.plugin.api.extension.model.ToolDefinition; +import me.golemcore.plugin.api.extension.model.ToolResult; +import okhttp3.OkHttpClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BrainToolProviderTest { + + private OkHttpMockEngine httpEngine; + private BrainPluginConfig config; + private BrainToolProvider provider; + + @BeforeEach + void setUp() { + httpEngine = new OkHttpMockEngine(); + config = BrainPluginConfig.builder() + .enabled(true) + .baseUrl("https://brain.example") + .apiToken("brain-token") + .defaultSpaceSlug("docs") + .defaultIntellisearchLimit(3) + .build(); + config.normalize(); + BrainPluginConfigService configService = new FixedBrainPluginConfigService(config); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(httpEngine) + .build(); + provider = new BrainToolProvider(configService, client); + } + + @Test + void shouldExposeBrainOperationsIncludingIntellisearch() { + ToolDefinition definition = provider.getDefinition(); + + Map schema = definition.getInputSchema(); + Map properties = (Map) schema.get("properties"); + Map operation = (Map) properties.get("operation"); + + assertEquals("golemcore_brain", definition.getName()); + assertEquals(List.of( + "list_spaces", + "list_tree", + "search_pages", + "intellisearch", + "read_page", + "create_page", + "update_page", + "delete_page", + "ensure_page", + "move_page", + "copy_page", + "list_assets"), operation.get("enum")); + } + + @Test + void shouldSearchPagesThroughBrainCrudApi() { + httpEngine.enqueueJson(200, """ + [ + {"path":"ops/runbook","title":"Runbook","excerpt":"deploy safely","kind":"PAGE"} + ] + """); + + ToolResult result = provider.execute(Map.of( + "operation", "search_pages", + "query", "deploy", + "limit", 2)).join(); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("Runbook")); + OkHttpMockEngine.CapturedRequest request = httpEngine.takeRequest(); + assertEquals("GET", request.method()); + assertEquals("/api/spaces/docs/search?q=deploy&limit=2", request.target()); + assertEquals("Bearer brain-token", request.header("Authorization")); + } + + @Test + void shouldReadPageThroughBrainCrudApi() { + httpEngine.enqueueJson(200, """ + {"path":"ops/runbook","title":"Runbook","content":"# Runbook","kind":"PAGE"} + """); + + ToolResult result = provider.execute(Map.of( + "operation", "read_page", + "path", "ops/runbook")).join(); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("# Runbook")); + OkHttpMockEngine.CapturedRequest request = httpEngine.takeRequest(); + assertEquals("GET", request.method()); + assertEquals("/api/spaces/docs/page?path=ops%2Frunbook", request.target()); + } + + @Test + void shouldCreatePageThroughBrainCrudApiWhenWritesAllowed() { + config.setAllowWrite(true); + httpEngine.enqueueJson(200, """ + {"path":"ops/runbook","title":"Runbook","content":"# Runbook","kind":"PAGE"} + """); + + ToolResult result = provider.execute(Map.of( + "operation", "create_page", + "parent_path", "ops", + "title", "Runbook", + "slug", "runbook", + "content", "# Runbook", + "kind", "PAGE")).join(); + + assertTrue(result.isSuccess()); + OkHttpMockEngine.CapturedRequest request = httpEngine.takeRequest(); + assertEquals("POST", request.method()); + assertEquals("/api/spaces/docs/pages", request.target()); + assertTrue(request.body().contains("\"parentPath\":\"ops\"")); + assertTrue(request.body().contains("\"title\":\"Runbook\"")); + } + + @Test + void shouldDenyWriteOperationsWhenWritesDisabled() { + ToolResult result = provider.execute(Map.of( + "operation", "delete_page", + "path", "ops/runbook")).join(); + + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("write operations are disabled")); + assertEquals(0, httpEngine.getRequestCount()); + } + + @Test + void shouldUseConfiguredDynamicEndpointForIntellisearch() { + config.setDynamicApiSlug("brain-relevance"); + httpEngine.enqueueJson(200, """ + { + "apiSlug":"brain-relevance", + "result":{"documents":[{"path":"ops/runbook","title":"Runbook","score":0.91}]}, + "rawResponse":"{\\"documents\\":[]}", + "iterations":2, + "toolCallCount":1 + } + """); + + ToolResult result = provider.execute(Map.of( + "operation", "intellisearch", + "context", "Need deployment rollback docs", + "query", "rollback", + "limit", 5)).join(); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("brain-relevance")); + OkHttpMockEngine.CapturedRequest request = httpEngine.takeRequest(); + assertEquals("POST", request.method()); + assertEquals("/api/spaces/docs/dynamic-apis/brain-relevance/run", request.target()); + assertTrue(request.body().contains("Need deployment rollback docs")); + assertTrue(request.body().contains("rollback")); + } + + @Test + void shouldFallbackToSearchAndReadPagesForIntellisearchWhenDynamicEndpointIsAbsent() { + httpEngine.enqueueJson(200, """ + [ + {"path":"ops/runbook","title":"Runbook","excerpt":"rollback steps","kind":"PAGE"} + ] + """); + httpEngine.enqueueJson(200, """ + {"path":"ops/runbook","title":"Runbook","content":"Rollback by reverting the release.","kind":"PAGE"} + """); + + ToolResult result = provider.execute(Map.of( + "operation", "intellisearch", + "context", "Need deployment rollback docs", + "limit", 1)).join(); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("Rollback by reverting")); + OkHttpMockEngine.CapturedRequest searchRequest = httpEngine.takeRequest(); + OkHttpMockEngine.CapturedRequest readRequest = httpEngine.takeRequest(); + assertEquals("/api/spaces/docs/search?q=Need%20deployment%20rollback%20docs&limit=1", searchRequest.target()); + assertEquals("/api/spaces/docs/page?path=ops%2Frunbook", readRequest.target()); + } + + @Test + void shouldRequireConfiguredBrainBaseUrl() { + config.setBaseUrl(" "); + config.normalize(); + + assertFalse(provider.isEnabled()); + ToolResult result = provider.execute(Map.of("operation", "list_spaces")).join(); + + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("disabled or base URL is missing")); + } + + @Test + void shouldRejectMissingOperation() { + ToolResult result = provider.execute(Map.of()).join(); + + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("operation is required")); + } + + private static class FixedBrainPluginConfigService extends BrainPluginConfigService { + private final BrainPluginConfig config; + + FixedBrainPluginConfigService(BrainPluginConfig config) { + super(null); + this.config = config; + } + + @Override + public BrainPluginConfig getConfig() { + config.normalize(); + return config; + } + } +} diff --git a/pom.xml b/pom.xml index 272ad83..a4658fd 100644 --- a/pom.xml +++ b/pom.xml @@ -83,6 +83,7 @@ golemcore/weather golemcore/mail golemcore/lightrag + golemcore/brain diff --git a/registry/golemcore/brain/index.yaml b/registry/golemcore/brain/index.yaml new file mode 100644 index 0000000..88afa9d --- /dev/null +++ b/registry/golemcore/brain/index.yaml @@ -0,0 +1,7 @@ +id: golemcore/brain +owner: golemcore +name: brain +latest: 1.0.0 +versions: + - 1.0.0 +source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/brain" diff --git a/registry/golemcore/brain/versions/1.0.0.yaml b/registry/golemcore/brain/versions/1.0.0.yaml new file mode 100644 index 0000000..bcaab07 --- /dev/null +++ b/registry/golemcore/brain/versions/1.0.0.yaml @@ -0,0 +1,9 @@ +id: golemcore/brain +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +entrypoint: me.golemcore.plugins.golemcore.brain.BrainPluginBootstrap +artifactUrl: dist/golemcore/brain/1.0.0/golemcore-brain-plugin-1.0.0.jar +sha256: pending-release +publishedAt: pending-release +sourceCommit: pending-release From 79febd1437ab208a48c6731e783ed22430a5009f Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 12 Apr 2026 15:37:57 -0400 Subject: [PATCH 2/2] fix(golemcore/brain): align registry metadata --- registry/golemcore/brain/versions/1.0.0.yaml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/registry/golemcore/brain/versions/1.0.0.yaml b/registry/golemcore/brain/versions/1.0.0.yaml index bcaab07..d1565c8 100644 --- a/registry/golemcore/brain/versions/1.0.0.yaml +++ b/registry/golemcore/brain/versions/1.0.0.yaml @@ -2,8 +2,11 @@ id: golemcore/brain version: 1.0.0 pluginApiVersion: 1 engineVersion: ">=0.0.0 <1.0.0" +artifactUrl: "dist/golemcore/brain/1.0.0/golemcore-brain-plugin-1.0.0.jar" +publishedAt: "pending-release" +sourceCommit: "pending-release" entrypoint: me.golemcore.plugins.golemcore.brain.BrainPluginBootstrap -artifactUrl: dist/golemcore/brain/1.0.0/golemcore-brain-plugin-1.0.0.jar -sha256: pending-release -publishedAt: pending-release -sourceCommit: pending-release +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/brain" +license: "Apache-2.0" +maintainers: + - alexk-dev