From a670133f28259f9afb0022bc13910c40d8e4edb4 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 19 Apr 2026 11:45:29 -0400 Subject: [PATCH 1/3] feat(golemcore/brain): support brain 1.0.3 api --- README.md | 2 +- golemcore/brain/plugin.yaml | 4 +- golemcore/brain/pom.xml | 2 +- .../brain/BrainPluginSettingsContributor.java | 6 +- .../plugins/golemcore/brain/BrainService.java | 177 ++++++++++++++++-- .../golemcore/brain/BrainToolProvider.java | 114 ++++++++++- .../brain/BrainToolProviderTest.java | 90 +++++++-- registry/golemcore/brain/index.yaml | 3 +- registry/golemcore/brain/versions/1.0.3.yaml | 12 ++ 9 files changed, 371 insertions(+), 39 deletions(-) create mode 100644 registry/golemcore/brain/versions/1.0.3.yaml diff --git a/README.md b/README.md index d30aaea..bedafbf 100644 --- a/README.md +++ b/README.md @@ -40,7 +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/brain` | GolemCore Brain wiki tool plugin for CRUD operations, hybrid search, intellisearch, and reindexing. | | `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 index d438778..fd281f6 100644 --- a/golemcore/brain/plugin.yaml +++ b/golemcore/brain/plugin.yaml @@ -1,11 +1,11 @@ id: golemcore/brain provider: golemcore name: brain -version: 1.0.2 +version: 1.0.3 pluginApiVersion: 1 engineVersion: ">=0.0.0 <2.0.0" entrypoint: me.golemcore.plugins.golemcore.brain.BrainPluginBootstrap -description: "GolemCore Brain wiki plugin for CRUD operations and optional dynamic relevance search." +description: "GolemCore Brain wiki plugin for CRUD, section-level patching, atomic transactions, link-graph insights, top-read tracking, hybrid search, intellisearch, and reindexing." sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/brain" license: "Apache-2.0" maintainers: diff --git a/golemcore/brain/pom.xml b/golemcore/brain/pom.xml index f2ccbfb..96fd4bf 100644 --- a/golemcore/brain/pom.xml +++ b/golemcore/brain/pom.xml @@ -11,7 +11,7 @@ ../../pom.xml - 1.0.2 + 1.0.3 golemcore-brain-plugin golemcore/brain GolemCore Brain wiki tool plugin for GolemCore 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 index 597ebbd..5a8e22a 100644 --- 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 @@ -59,7 +59,7 @@ public PluginSettingsSection getSection(String sectionKey) { 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.") + .description("Configure the Brain wiki API tool and optional dynamic intellisearch endpoint.") .fields(List.of( PluginSettingsField.builder() .key("enabled") @@ -108,8 +108,8 @@ public PluginSettingsSection getSection(String sectionKey) { PluginSettingsField.builder() .key("allowWrite") .type("boolean") - .label("Allow Write Operations") - .description("Allow create, update, delete, ensure, move, and copy operations.") + .label("Allow Mutating Operations") + .description("Allow page mutations and search reindex requests.") .build())) .values(values) .build(); 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 index 8ade7a5..e173b7d 100644 --- 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 @@ -27,6 +27,9 @@ public class BrainService { private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); private static final int DEFAULT_SEARCH_LIMIT = 5; + private static final String SEARCH_MODE_AUTO = "auto"; + private static final String SEARCH_MODE_FTS = "fts"; + private static final String SEARCH_MODE_HYBRID = "hybrid"; private static final Pattern SLUG_SEPARATOR_PATTERN = Pattern.compile("[^a-z0-9]+"); private final BrainPluginConfigService configService; @@ -69,12 +72,14 @@ public ToolResult listTree(String spaceSlug) { } } - public ToolResult searchPages(String spaceSlug, String query, Integer limit) { + public ToolResult searchPages(String spaceSlug, String query, String searchMode, Integer limit) { String resolvedQuery = requireText(query, "query"); String space = resolveSpace(spaceSlug); + String resolvedMode = resolveSearchMode(searchMode, SEARCH_MODE_FTS); int resolvedLimit = resolveLimit(limit, DEFAULT_SEARCH_LIMIT); try { - JsonNode node = executeJson("GET", searchUrl(space, resolvedQuery, resolvedLimit), null); + JsonNode node = executeJson("POST", searchUrl(space), + searchPayload(resolvedQuery, resolvedMode, resolvedLimit)); return ToolResult.success(formatSearchResults(resolvedQuery, node), nodeToData(node)); } catch (IOException | IllegalStateException exception) { return executionFailure(exception.getMessage()); @@ -117,7 +122,7 @@ public ToolResult readPage(String spaceSlug, String path) { } public ToolResult createPage(String spaceSlug, String parentPath, String title, String slug, String content, - String kind) { + String kind, List tags, String summary) { requireWriteAllowed(); String space = resolveSpace(spaceSlug); try { @@ -127,6 +132,12 @@ public ToolResult createPage(String spaceSlug, String parentPath, String title, payload.put("slug", trimToNull(slug)); payload.put("content", valueOrEmpty(content)); payload.put("kind", trimToNull(kind) != null ? kind : "PAGE"); + if (tags != null && !tags.isEmpty()) { + payload.put("tags", tags); + } + if (trimToNull(summary) != null) { + payload.put("summary", summary); + } JsonNode node = executeJson("POST", spaceUrl(space, "/pages"), payload); return ToolResult.success("Created Brain page " + node.path("path").asText(""), nodeToData(node)); } catch (IOException | IllegalStateException exception) { @@ -135,7 +146,7 @@ public ToolResult createPage(String spaceSlug, String parentPath, String title, } public ToolResult updatePage(String spaceSlug, String path, String title, String slug, String content, - String revision) { + String revision, List tags, String summary) { requireWriteAllowed(); String pagePath = requireText(path, "path"); String space = resolveSpace(spaceSlug); @@ -144,6 +155,12 @@ public ToolResult updatePage(String spaceSlug, String path, String title, String payload.put("slug", trimToNull(slug)); payload.put("content", valueOrEmpty(content)); payload.put("revision", trimToNull(revision)); + if (tags != null && !tags.isEmpty()) { + payload.put("tags", tags); + } + if (trimToNull(summary) != null) { + payload.put("summary", summary); + } try { JsonNode node = executeJson("PUT", pageUrl(space, pagePath), payload); return ToolResult.success("Updated Brain page " + node.path("path").asText(pagePath), nodeToData(node)); @@ -152,6 +169,73 @@ public ToolResult updatePage(String spaceSlug, String path, String title, String } } + public ToolResult patchPage(String spaceSlug, String path, String operation, String heading, String content, + String expectedRevision) { + requireWriteAllowed(); + String pagePath = requireText(path, "path"); + String resolvedOperation = requireText(operation, "patch_operation"); + String resolvedRevision = requireText(expectedRevision, "expected_revision"); + String space = resolveSpace(spaceSlug); + Map payload = new LinkedHashMap<>(); + payload.put("operation", resolvedOperation.toUpperCase(Locale.ROOT)); + payload.put("expectedRevision", resolvedRevision); + payload.put("content", valueOrEmpty(content)); + payload.put("heading", trimToNull(heading)); + try { + JsonNode node = executeJson("PATCH", pageUrl(space, pagePath), payload); + return ToolResult.success("Patched Brain page " + pagePath + " (" + resolvedOperation + ")", + nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult getWikiGraph(String spaceSlug) { + String space = resolveSpace(spaceSlug); + try { + JsonNode node = executeJson("GET", spaceUrl(space, "/wiki/graph"), null); + int orphanCount = node.path("orphans").isArray() ? node.path("orphans").size() : 0; + int danglingCount = node.path("dangling").isArray() ? node.path("dangling").size() : 0; + return ToolResult.success( + "Brain graph for " + space + ": " + orphanCount + " orphan(s), " + danglingCount + " dangling", + nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult listTopAccessed(String spaceSlug, Integer limit) { + String space = resolveSpace(spaceSlug); + int resolvedLimit = resolveLimit(limit, DEFAULT_SEARCH_LIMIT); + try { + JsonNode node = executeJson("GET", + spaceUrl(space, "/wiki/access/top").newBuilder() + .addQueryParameter("limit", Integer.toString(resolvedLimit)).build(), + null); + int count = node.path("items").isArray() ? node.path("items").size() : 0; + return ToolResult.success("Listed " + count + " top Brain page(s) by access count", nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult applyTransaction(String spaceSlug, List> operations) { + requireWriteAllowed(); + if (operations == null || operations.isEmpty()) { + return executionFailure("operations is required and must be non-empty"); + } + String space = resolveSpace(spaceSlug); + Map payload = new LinkedHashMap<>(); + payload.put("operations", operations); + try { + JsonNode node = executeJson("POST", spaceUrl(space, "/wiki/tx"), payload); + int applied = node.path("operations").isArray() ? node.path("operations").size() : 0; + return ToolResult.success("Applied Brain transaction with " + applied + " operation(s)", nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + public ToolResult deletePage(String spaceSlug, String path) { requireWriteAllowed(); String pagePath = requireText(path, "path"); @@ -224,6 +308,28 @@ public ToolResult listAssets(String spaceSlug, String path) { } } + public ToolResult reindexSpace(String spaceSlug) { + requireWriteAllowed(); + String space = resolveSpace(spaceSlug); + try { + JsonNode node = executeJson("POST", adminSpaceReindexUrl(space), null); + return ToolResult.success("Queued Brain reindex for space " + space, nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + + public ToolResult reindexAllSpaces() { + requireWriteAllowed(); + try { + JsonNode node = executeJson("POST", adminSpacesReindexUrl(), null); + int spacesQueued = node.path("spacesQueued").asInt(0); + return ToolResult.success("Queued Brain reindex for " + spacesQueued + " space(s)", 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<>(); @@ -310,7 +416,9 @@ private String slugifySegment(String segment) { private ToolResult fallbackIntellisearch(String space, String context, String query, int limit) { try { - JsonNode hits = executeJson("GET", searchUrl(space, query, limit), null); + JsonNode searchResult = executeJson("POST", searchUrl(space), + searchPayload(query, SEARCH_MODE_HYBRID, limit)); + JsonNode hits = searchHits(searchResult); List> documents = new ArrayList<>(); if (hits.isArray()) { for (JsonNode hit : hits) { @@ -370,10 +478,18 @@ private Request buildRequest(String method, HttpUrl url, Map pay if (apiToken != null && !apiToken.isBlank()) { builder.header("Authorization", "Bearer " + apiToken); } - RequestBody body = payload == null ? null : RequestBody.create(objectMapper.writeValueAsString(payload), JSON); + RequestBody body = payload == null + ? emptyRequestBody(method) + : RequestBody.create(objectMapper.writeValueAsString(payload), JSON); return builder.method(method, body).build(); } + private RequestBody emptyRequestBody(String method) { + return "POST".equals(method) || "PUT".equals(method) || "PATCH".equals(method) + ? RequestBody.create("", JSON) + : null; + } + private HttpUrl apiUrl(String path) { HttpUrl base = parseBaseUrl(); return base.newBuilder() @@ -397,11 +513,16 @@ private HttpUrl pageActionUrl(String space, String path, String actionPath) { .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 searchUrl(String space) { + return spaceUrl(space, "/search"); + } + + private HttpUrl adminSpaceReindexUrl(String space) { + return apiUrl("/api/admin/spaces/" + encodePathSegment(space) + "/reindex"); + } + + private HttpUrl adminSpacesReindexUrl() { + return apiUrl("/api/admin/spaces/reindex"); } private HttpUrl dynamicApiUrl(String space, String dynamicApiSlug) { @@ -428,26 +549,52 @@ private int resolveLimit(Integer value, int defaultValue) { private void requireWriteAllowed() { if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { - throw new IllegalStateException("Brain write operations are disabled in plugin settings"); + throw new IllegalStateException("Brain mutating operations are disabled in plugin settings"); } } + private Map searchPayload(String query, String mode, int limit) { + Map payload = new LinkedHashMap<>(); + payload.put("query", query); + payload.put("mode", mode); + payload.put("limit", limit); + return payload; + } + 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()) { + JsonNode hits = searchHits(node); + if (!hits.isArray() || hits.isEmpty()) { return "No Brain pages found for: " + query; } + String mode = node.path("mode").asText(""); + String modeSuffix = mode.isBlank() ? "" : " (" + mode + ")"; List rows = new ArrayList<>(); - for (JsonNode hit : node) { + for (JsonNode hit : hits) { 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); + return "Brain search results for \"" + query + "\"" + modeSuffix + ":\n" + String.join("\n", rows); + } + + private JsonNode searchHits(JsonNode node) { + return node.isArray() ? node : node.path("hits"); + } + + private String resolveSearchMode(String value, String defaultMode) { + String resolved = firstNonBlank(value, defaultMode); + String normalized = resolved.toLowerCase(Locale.ROOT).replace('_', '-'); + if (SEARCH_MODE_AUTO.equals(normalized) || SEARCH_MODE_FTS.equals(normalized) + || SEARCH_MODE_HYBRID.equals(normalized)) { + return normalized; + } + throw new IllegalArgumentException("Unsupported Brain search_mode: " + value + + " (expected auto, fts, or hybrid)"); } private String formatIntellisearchDocuments(List> documents) { 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 index aa6e3b2..23d35c9 100644 --- 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 @@ -26,6 +26,7 @@ public class BrainToolProvider implements ToolProvider { 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_SEARCH_MODE = "search_mode"; private static final String PARAM_CONTEXT = "context"; private static final String PARAM_LIMIT = "limit"; private static final String PARAM_PATH = "path"; @@ -41,6 +42,12 @@ public class BrainToolProvider implements ToolProvider { 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 static final String PARAM_TAGS = "tags"; + private static final String PARAM_SUMMARY = "summary"; + private static final String PARAM_PATCH_OPERATION = "patch_operation"; + private static final String PARAM_HEADING = "heading"; + private static final String PARAM_EXPECTED_REVISION = "expected_revision"; + private static final String PARAM_OPERATIONS = "operations"; private final BrainPluginConfigService configService; private final BrainService service; @@ -77,9 +84,16 @@ public ToolDefinition getDefinition() { "ensure_page", "move_page", "copy_page", - "list_assets"))); + "list_assets", + "reindex_space", + "reindex_all_spaces", + "patch_page", + "get_wiki_graph", + "wiki_top_accessed", + "wiki_tx"))); properties.put(PARAM_SPACE_SLUG, stringProperty("Brain space slug. Defaults to plugin settings.")); properties.put(PARAM_QUERY, stringProperty("Search query.")); + properties.put(PARAM_SEARCH_MODE, searchModeProperty()); 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.")); @@ -97,6 +111,23 @@ public ToolDefinition getDefinition() { 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.")); + properties.put(PARAM_TAGS, stringListProperty( + "Optional list of tags written into the page frontmatter by create_page or update_page.")); + properties.put(PARAM_SUMMARY, + stringProperty("Optional page summary stored in the frontmatter by create_page or update_page.")); + properties.put(PARAM_PATCH_OPERATION, Map.of( + TYPE, TYPE_STRING, + "enum", List.of("APPEND", "PREPEND", "REPLACE_SECTION"), + "description", "patch_page operation. REPLACE_SECTION requires heading.")); + properties.put(PARAM_HEADING, stringProperty( + "Markdown heading text (without leading #) to target for patch_page REPLACE_SECTION.")); + properties.put(PARAM_EXPECTED_REVISION, + stringProperty("Required revision string for patch_page optimistic concurrency.")); + properties.put(PARAM_OPERATIONS, Map.of( + TYPE, "array", + "description", + "Ordered transaction operations for wiki_tx. Each item: {op: CREATE|UPDATE|DELETE, path, parentPath, slug, title, content, kind, expectedRevision}.", + "items", Map.of(TYPE, TYPE_OBJECT))); Map schema = new LinkedHashMap<>(); schema.put(TYPE, TYPE_OBJECT); @@ -112,11 +143,15 @@ public ToolDefinition getDefinition() { 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)))); + requiredWhen("list_assets", List.of(PARAM_PATH)), + requiredWhen("patch_page", + List.of(PARAM_PATH, PARAM_PATCH_OPERATION, PARAM_EXPECTED_REVISION)), + requiredWhen("wiki_tx", List.of(PARAM_OPERATIONS)))); return ToolDefinition.builder() .name("golemcore_brain") - .description("Use GolemCore Brain wiki pages, spaces, search, and optional dynamic intellisearch.") + .description( + "Use GolemCore Brain wiki pages, spaces, hybrid search, intellisearch, section-level patch, atomic transactions, link graph, top-read tracking, and reindexing.") .inputSchema(schema) .build(); } @@ -142,6 +177,7 @@ private ToolResult executeOperation(Map parameters) { case "search_pages" -> service.searchPages( readString(parameters.get(PARAM_SPACE_SLUG)), readString(parameters.get(PARAM_QUERY)), + readString(parameters.get(PARAM_SEARCH_MODE)), readInteger(parameters.get(PARAM_LIMIT))); case "intellisearch" -> service.intellisearch( readString(parameters.get(PARAM_SPACE_SLUG)), @@ -159,14 +195,32 @@ private ToolResult executeOperation(Map parameters) { readString(parameters.get(PARAM_TITLE)), readString(parameters.get(PARAM_SLUG)), readString(parameters.get(PARAM_CONTENT)), - readString(parameters.get(PARAM_KIND))); + readString(parameters.get(PARAM_KIND)), + readStringList(parameters.get(PARAM_TAGS)), + readString(parameters.get(PARAM_SUMMARY))); 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))); + readString(parameters.get(PARAM_REVISION)), + readStringList(parameters.get(PARAM_TAGS)), + readString(parameters.get(PARAM_SUMMARY))); + case "patch_page" -> service.patchPage( + readString(parameters.get(PARAM_SPACE_SLUG)), + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_PATCH_OPERATION)), + readString(parameters.get(PARAM_HEADING)), + readString(parameters.get(PARAM_CONTENT)), + readString(parameters.get(PARAM_EXPECTED_REVISION))); + case "get_wiki_graph" -> service.getWikiGraph(readString(parameters.get(PARAM_SPACE_SLUG))); + case "wiki_top_accessed" -> service.listTopAccessed( + readString(parameters.get(PARAM_SPACE_SLUG)), + readInteger(parameters.get(PARAM_LIMIT))); + case "wiki_tx" -> service.applyTransaction( + readString(parameters.get(PARAM_SPACE_SLUG)), + readMapList(parameters.get(PARAM_OPERATIONS))); case "delete_page" -> service.deletePage( readString(parameters.get(PARAM_SPACE_SLUG)), readString(parameters.get(PARAM_PATH))); @@ -189,6 +243,8 @@ private ToolResult executeOperation(Map parameters) { case "list_assets" -> service.listAssets( readString(parameters.get(PARAM_SPACE_SLUG)), readString(parameters.get(PARAM_PATH))); + case "reindex_space" -> service.reindexSpace(readString(parameters.get(PARAM_SPACE_SLUG))); + case "reindex_all_spaces" -> service.reindexAllSpaces(); default -> ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, "Unknown Brain operation: " + operation); }; @@ -223,6 +279,20 @@ private static Map booleanProperty(String description) { return Map.of(TYPE, TYPE_BOOLEAN, "description", description); } + private static Map searchModeProperty() { + return Map.of( + TYPE, TYPE_STRING, + "enum", List.of("auto", "fts", "hybrid"), + "description", "Brain search mode for search_pages. Defaults to fts."); + } + + private static Map stringListProperty(String description) { + return Map.of( + TYPE, "array", + "description", description, + "items", Map.of(TYPE, TYPE_STRING)); + } + private String readString(Object value) { return value instanceof String text ? text : null; } @@ -244,4 +314,38 @@ private Integer readInteger(Object value) { private boolean readBoolean(Object value) { return !(value instanceof Boolean bool) || bool; } + + @SuppressWarnings("unchecked") + private List readStringList(Object value) { + if (value instanceof List list) { + List result = new java.util.ArrayList<>(); + for (Object item : list) { + if (item instanceof String text && !text.isBlank()) { + result.add(text); + } + } + return result; + } + return List.of(); + } + + @SuppressWarnings("unchecked") + private List> readMapList(Object value) { + if (value instanceof List list) { + List> result = new java.util.ArrayList<>(); + for (Object item : list) { + if (item instanceof Map map) { + Map typed = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() instanceof String key) { + typed.put(key, entry.getValue()); + } + } + result.add(typed); + } + } + return result; + } + return List.of(); + } } 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 index 3901e87..fa1cd0b 100644 --- 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 @@ -59,15 +59,25 @@ void shouldExposeBrainOperationsIncludingIntellisearch() { "ensure_page", "move_page", "copy_page", - "list_assets"), operation.get("enum")); + "list_assets", + "reindex_space", + "reindex_all_spaces", + "patch_page", + "get_wiki_graph", + "wiki_top_accessed", + "wiki_tx"), operation.get("enum")); } @Test void shouldSearchPagesThroughBrainCrudApi() { httpEngine.enqueueJson(200, """ - [ - {"path":"ops/runbook","title":"Runbook","excerpt":"deploy safely","kind":"PAGE"} - ] + { + "mode":"fts", + "semanticReady":false, + "hits":[ + {"path":"ops/runbook","title":"Runbook","excerpt":"deploy safely","kind":"PAGE"} + ] + } """); ToolResult result = provider.execute(Map.of( @@ -78,9 +88,33 @@ void shouldSearchPagesThroughBrainCrudApi() { 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("POST", request.method()); + assertEquals("/api/spaces/docs/search", request.target()); assertEquals("Bearer brain-token", request.header("Authorization")); + assertTrue(request.body().contains("\"query\":\"deploy\"")); + assertTrue(request.body().contains("\"mode\":\"fts\"")); + assertTrue(request.body().contains("\"limit\":2")); + } + + @Test + void shouldPassRequestedSearchModeToBrainSearchApi() { + httpEngine.enqueueJson(200, """ + { + "mode":"hybrid", + "semanticReady":true, + "hits":[] + } + """); + + ToolResult result = provider.execute(Map.of( + "operation", "search_pages", + "query", "incident response", + "search_mode", "hybrid")).join(); + + assertTrue(result.isSuccess()); + OkHttpMockEngine.CapturedRequest request = httpEngine.takeRequest(); + assertEquals("/api/spaces/docs/search", request.target()); + assertTrue(request.body().contains("\"mode\":\"hybrid\"")); } @Test @@ -224,7 +258,7 @@ void shouldDenyWriteOperationsWhenWritesDisabled() { "path", "ops/runbook")).join(); assertFalse(result.isSuccess()); - assertTrue(result.getError().contains("write operations are disabled")); + assertTrue(result.getError().contains("mutating operations are disabled")); assertEquals(0, httpEngine.getRequestCount()); } @@ -259,9 +293,14 @@ void shouldUseConfiguredDynamicEndpointForIntellisearch() { @Test void shouldFallbackToSearchAndReadPagesForIntellisearchWhenDynamicEndpointIsAbsent() { httpEngine.enqueueJson(200, """ - [ - {"path":"ops/runbook","title":"Runbook","excerpt":"rollback steps","kind":"PAGE"} - ] + { + "mode":"fts-fallback", + "semanticReady":false, + "fallbackReason":"embedding-model-not-configured", + "hits":[ + {"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"} @@ -276,10 +315,39 @@ void shouldFallbackToSearchAndReadPagesForIntellisearchWhenDynamicEndpointIsAbse 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("POST", searchRequest.method()); + assertEquals("/api/spaces/docs/search", searchRequest.target()); + assertTrue(searchRequest.body().contains("\"query\":\"Need deployment rollback docs\"")); + assertTrue(searchRequest.body().contains("\"mode\":\"hybrid\"")); + assertTrue(searchRequest.body().contains("\"limit\":1")); assertEquals("/api/spaces/docs/page?path=ops%2Frunbook", readRequest.target()); } + @Test + void shouldQueueReindexRequestsWhenMutatingOperationsAreAllowed() { + config.setAllowWrite(true); + httpEngine.enqueueJson(202, """ + {"status":"queued","spacesQueued":1} + """); + httpEngine.enqueueJson(202, """ + {"status":"queued","spacesQueued":3} + """); + + ToolResult spaceResult = provider.execute(Map.of("operation", "reindex_space")).join(); + ToolResult allResult = provider.execute(Map.of("operation", "reindex_all_spaces")).join(); + + assertTrue(spaceResult.isSuccess()); + assertTrue(allResult.isSuccess()); + assertTrue(spaceResult.getOutput().contains("space docs")); + assertTrue(allResult.getOutput().contains("3 space")); + OkHttpMockEngine.CapturedRequest spaceRequest = httpEngine.takeRequest(); + OkHttpMockEngine.CapturedRequest allRequest = httpEngine.takeRequest(); + assertEquals("POST", spaceRequest.method()); + assertEquals("/api/admin/spaces/docs/reindex", spaceRequest.target()); + assertEquals("POST", allRequest.method()); + assertEquals("/api/admin/spaces/reindex", allRequest.target()); + } + @Test void shouldRequireConfiguredBrainBaseUrl() { config.setBaseUrl(" "); diff --git a/registry/golemcore/brain/index.yaml b/registry/golemcore/brain/index.yaml index 71c6845..1e3e1a1 100644 --- a/registry/golemcore/brain/index.yaml +++ b/registry/golemcore/brain/index.yaml @@ -1,9 +1,10 @@ id: golemcore/brain owner: golemcore name: brain -latest: 1.0.2 +latest: 1.0.3 versions: - 1.0.0 - 1.0.1 - 1.0.2 + - 1.0.3 source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/brain" diff --git a/registry/golemcore/brain/versions/1.0.3.yaml b/registry/golemcore/brain/versions/1.0.3.yaml new file mode 100644 index 0000000..2ef7f1c --- /dev/null +++ b/registry/golemcore/brain/versions/1.0.3.yaml @@ -0,0 +1,12 @@ +id: golemcore/brain +version: 1.0.3 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <2.0.0" +artifactUrl: "dist/golemcore/brain/1.0.3/golemcore-brain-plugin-1.0.3.jar" +publishedAt: "2026-04-18T00:00:00Z" +sourceCommit: "TBD" +entrypoint: me.golemcore.plugins.golemcore.brain.BrainPluginBootstrap +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/brain" +license: "Apache-2.0" +maintainers: + - alexk-dev From f610cc405584e1e3ab2181dd1575610382ba7f38 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 19 Apr 2026 11:55:31 -0400 Subject: [PATCH 2/3] fix(golemcore/brain): align search defaults with brain main --- .../plugins/golemcore/brain/BrainService.java | 20 ++++++++++++- .../golemcore/brain/BrainToolProvider.java | 4 ++- .../brain/BrainToolProviderTest.java | 30 ++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) 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 index e173b7d..b72e0e4 100644 --- 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 @@ -75,7 +75,7 @@ public ToolResult listTree(String spaceSlug) { public ToolResult searchPages(String spaceSlug, String query, String searchMode, Integer limit) { String resolvedQuery = requireText(query, "query"); String space = resolveSpace(spaceSlug); - String resolvedMode = resolveSearchMode(searchMode, SEARCH_MODE_FTS); + String resolvedMode = resolveSearchMode(searchMode, SEARCH_MODE_AUTO); int resolvedLimit = resolveLimit(limit, DEFAULT_SEARCH_LIMIT); try { JsonNode node = executeJson("POST", searchUrl(space), @@ -86,6 +86,20 @@ public ToolResult searchPages(String spaceSlug, String query, String searchMode, } } + public ToolResult getSearchStatus(String spaceSlug) { + String space = resolveSpace(spaceSlug); + try { + JsonNode node = executeJson("GET", searchStatusUrl(space), null); + String output = "Brain search status for " + space + + ": ready=" + node.path("ready").asBoolean(false) + + ", indexedDocuments=" + node.path("indexedDocuments").asInt(0) + + ", embeddingsReady=" + node.path("embeddingsReady").asBoolean(false); + return ToolResult.success(output, nodeToData(node)); + } catch (IOException | IllegalStateException exception) { + return executionFailure(exception.getMessage()); + } + } + public ToolResult intellisearch( String spaceSlug, String context, @@ -517,6 +531,10 @@ private HttpUrl searchUrl(String space) { return spaceUrl(space, "/search"); } + private HttpUrl searchStatusUrl(String space) { + return spaceUrl(space, "/search/status"); + } + private HttpUrl adminSpaceReindexUrl(String space) { return apiUrl("/api/admin/spaces/" + encodePathSegment(space) + "/reindex"); } 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 index 23d35c9..b6ffa30 100644 --- 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 @@ -76,6 +76,7 @@ public ToolDefinition getDefinition() { "list_spaces", "list_tree", "search_pages", + "get_search_status", "intellisearch", "read_page", "create_page", @@ -179,6 +180,7 @@ private ToolResult executeOperation(Map parameters) { readString(parameters.get(PARAM_QUERY)), readString(parameters.get(PARAM_SEARCH_MODE)), readInteger(parameters.get(PARAM_LIMIT))); + case "get_search_status" -> service.getSearchStatus(readString(parameters.get(PARAM_SPACE_SLUG))); case "intellisearch" -> service.intellisearch( readString(parameters.get(PARAM_SPACE_SLUG)), readString(parameters.get(PARAM_CONTEXT)), @@ -283,7 +285,7 @@ private static Map searchModeProperty() { return Map.of( TYPE, TYPE_STRING, "enum", List.of("auto", "fts", "hybrid"), - "description", "Brain search mode for search_pages. Defaults to fts."); + "description", "Brain search mode for search_pages. Defaults to auto."); } private static Map stringListProperty(String description) { 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 index fa1cd0b..2888cf5 100644 --- 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 @@ -51,6 +51,7 @@ void shouldExposeBrainOperationsIncludingIntellisearch() { "list_spaces", "list_tree", "search_pages", + "get_search_status", "intellisearch", "read_page", "create_page", @@ -92,7 +93,7 @@ void shouldSearchPagesThroughBrainCrudApi() { assertEquals("/api/spaces/docs/search", request.target()); assertEquals("Bearer brain-token", request.header("Authorization")); assertTrue(request.body().contains("\"query\":\"deploy\"")); - assertTrue(request.body().contains("\"mode\":\"fts\"")); + assertTrue(request.body().contains("\"mode\":\"auto\"")); assertTrue(request.body().contains("\"limit\":2")); } @@ -117,6 +118,33 @@ void shouldPassRequestedSearchModeToBrainSearchApi() { assertTrue(request.body().contains("\"mode\":\"hybrid\"")); } + @Test + void shouldReadSearchStatusThroughBrainCrudApi() { + httpEngine.enqueueJson(200, """ + { + "mode":"hybrid", + "ready":true, + "indexedDocuments":12, + "fullTextIndexedDocuments":12, + "embeddingDocuments":12, + "embeddingIndexedDocuments":10, + "staleDocuments":2, + "embeddingsReady":false, + "embeddingModelId":"text-embedding-3-small" + } + """); + + ToolResult result = provider.execute(Map.of("operation", "get_search_status")).join(); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("ready=true")); + assertTrue(result.getOutput().contains("indexedDocuments=12")); + assertTrue(result.getOutput().contains("embeddingsReady=false")); + OkHttpMockEngine.CapturedRequest request = httpEngine.takeRequest(); + assertEquals("GET", request.method()); + assertEquals("/api/spaces/docs/search/status", request.target()); + } + @Test void shouldReadPageThroughBrainCrudApi() { httpEngine.enqueueJson(200, """ From 7306b7b4c784f17c913b412300776e50c7757f90 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 19 Apr 2026 12:08:05 -0400 Subject: [PATCH 3/3] test(golemcore/brain): cover wiki tool endpoints --- .../brain/BrainToolProviderTest.java | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) 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 index 2888cf5..538d333 100644 --- 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 @@ -145,6 +145,50 @@ void shouldReadSearchStatusThroughBrainCrudApi() { assertEquals("/api/spaces/docs/search/status", request.target()); } + @Test + void shouldReadWikiGraphThroughBrainCrudApi() { + httpEngine.enqueueJson(200, """ + { + "orphans":[{"path":"ops/orphan","title":"Orphan"}], + "dangling":[ + {"fromPath":"ops/runbook","toPath":"ops/missing"}, + {"fromPath":"ops/guide","toPath":"ops/todo"} + ] + } + """); + + ToolResult result = provider.execute(Map.of("operation", "get_wiki_graph")).join(); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("1 orphan")); + assertTrue(result.getOutput().contains("2 dangling")); + OkHttpMockEngine.CapturedRequest request = httpEngine.takeRequest(); + assertEquals("GET", request.method()); + assertEquals("/api/spaces/docs/wiki/graph", request.target()); + } + + @Test + void shouldListTopAccessedPagesThroughBrainCrudApi() { + httpEngine.enqueueJson(200, """ + { + "items":[ + {"path":"ops/runbook","title":"Runbook","accessCount":7}, + {"path":"ops/guide","title":"Guide","accessCount":4} + ] + } + """); + + ToolResult result = provider.execute(Map.of( + "operation", "wiki_top_accessed", + "limit", 2)).join(); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("2 top Brain page")); + OkHttpMockEngine.CapturedRequest request = httpEngine.takeRequest(); + assertEquals("GET", request.method()); + assertEquals("/api/spaces/docs/wiki/access/top?limit=2", request.target()); + } + @Test void shouldReadPageThroughBrainCrudApi() { httpEngine.enqueueJson(200, """ @@ -162,6 +206,32 @@ void shouldReadPageThroughBrainCrudApi() { assertEquals("/api/spaces/docs/page?path=ops%2Frunbook", request.target()); } + @Test + void shouldPatchPageThroughBrainCrudApiWhenWritesAllowed() { + config.setAllowWrite(true); + httpEngine.enqueueJson(200, """ + {"path":"ops/runbook","title":"Runbook","content":"Updated","kind":"PAGE","revision":"rev-8"} + """); + + ToolResult result = provider.execute(Map.of( + "operation", "patch_page", + "path", "ops/runbook", + "patch_operation", "replace_section", + "heading", "Rollback", + "content", "Use the previous image.", + "expected_revision", "rev-7")).join(); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("Patched Brain page ops/runbook")); + OkHttpMockEngine.CapturedRequest request = httpEngine.takeRequest(); + assertEquals("PATCH", request.method()); + assertEquals("/api/spaces/docs/page?path=ops%2Frunbook", request.target()); + assertTrue(request.body().contains("\"operation\":\"REPLACE_SECTION\"")); + assertTrue(request.body().contains("\"expectedRevision\":\"rev-7\"")); + assertTrue(request.body().contains("\"heading\":\"Rollback\"")); + assertTrue(request.body().contains("\"content\":\"Use the previous image.\"")); + } + @Test void shouldCreatePageThroughBrainCrudApiWhenWritesAllowed() { config.setAllowWrite(true); @@ -376,6 +446,39 @@ void shouldQueueReindexRequestsWhenMutatingOperationsAreAllowed() { assertEquals("/api/admin/spaces/reindex", allRequest.target()); } + @Test + void shouldApplyWikiTransactionThroughBrainCrudApiWhenWritesAllowed() { + config.setAllowWrite(true); + httpEngine.enqueueJson(200, """ + { + "operations":[ + {"op":"UPDATE","path":"ops/runbook","revision":"rev-8"} + ] + } + """); + + ToolResult result = provider.execute(Map.of( + "operation", "wiki_tx", + "operations", List.of(Map.of( + "op", "UPDATE", + "path", "ops/runbook", + "title", "Runbook", + "content", "Updated runbook", + "kind", "PAGE", + "expectedRevision", "rev-7")))) + .join(); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("1 operation")); + OkHttpMockEngine.CapturedRequest request = httpEngine.takeRequest(); + assertEquals("POST", request.method()); + assertEquals("/api/spaces/docs/wiki/tx", request.target()); + assertTrue(request.body().contains("\"operations\"")); + assertTrue(request.body().contains("\"op\":\"UPDATE\"")); + assertTrue(request.body().contains("\"path\":\"ops/runbook\"")); + assertTrue(request.body().contains("\"expectedRevision\":\"rev-7\"")); + } + @Test void shouldRequireConfiguredBrainBaseUrl() { config.setBaseUrl(" ");