diff --git a/golemcore/brain/plugin.yaml b/golemcore/brain/plugin.yaml index a973482..d438778 100644 --- a/golemcore/brain/plugin.yaml +++ b/golemcore/brain/plugin.yaml @@ -1,7 +1,7 @@ id: golemcore/brain provider: golemcore name: brain -version: 1.0.1 +version: 1.0.2 pluginApiVersion: 1 engineVersion: ">=0.0.0 <2.0.0" entrypoint: me.golemcore.plugins.golemcore.brain.BrainPluginBootstrap diff --git a/golemcore/brain/pom.xml b/golemcore/brain/pom.xml index cf74248..f2ccbfb 100644 --- a/golemcore/brain/pom.xml +++ b/golemcore/brain/pom.xml @@ -11,7 +11,7 @@ ../../pom.xml - 1.0.1 + 1.0.2 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/BrainService.java b/golemcore/brain/src/main/java/me/golemcore/plugins/golemcore/brain/BrainService.java index c9a4e5b..8ade7a5 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 @@ -18,12 +18,16 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; 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 Pattern SLUG_SEPARATOR_PATTERN = Pattern.compile("[^a-z0-9]+"); private final BrainPluginConfigService configService; private final OkHttpClient httpClient; @@ -116,13 +120,13 @@ public ToolResult createPage(String spaceSlug, String parentPath, String title, 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 { + Map payload = new LinkedHashMap<>(); + payload.put("parentPath", resolveSectionPath(space, 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"); JsonNode node = executeJson("POST", spaceUrl(space, "/pages"), payload); return ToolResult.success("Created Brain page " + node.path("path").asText(""), nodeToData(node)); } catch (IOException | IllegalStateException exception) { @@ -236,6 +240,74 @@ private ToolResult runDynamicIntellisearch(String space, String dynamicApiSlug, } } + private String resolveSectionPath(String space, String parentPath) throws IOException { + String requestedPath = valueOrEmpty(parentPath); + if (requestedPath.isBlank()) { + return ""; + } + Optional exactPath = findSectionPath(space, requestedPath, true); + if (exactPath.isPresent()) { + return exactPath.get(); + } + String slugifiedPath = slugifyPath(requestedPath); + if (!slugifiedPath.isBlank() && !slugifiedPath.equals(requestedPath)) { + Optional slugPath = findSectionPath(space, slugifiedPath, false); + if (slugPath.isPresent()) { + return slugPath.get(); + } + } + throw new IllegalStateException("Brain section not found: " + requestedPath + + " (also tried: " + slugifiedPath + ")"); + } + + private Optional findSectionPath(String space, String path, boolean failWhenPage) throws IOException { + try { + JsonNode node = executeJson("GET", pageUrl(space, path), null); + if (isSectionNode(node)) { + String resolvedPath = node.path("path").asText(path); + return Optional.of(resolvedPath); + } + if (failWhenPage && "PAGE".equals(node.path("kind").asText(""))) { + throw new IllegalStateException("Brain path is not a section: " + path); + } + return Optional.empty(); + } catch (BrainApiException exception) { + if (exception.getHttpStatusCode() == 404) { + return Optional.empty(); + } + throw exception; + } + } + + private boolean isSectionNode(JsonNode node) { + String kind = node.path("kind").asText(""); + return "SECTION".equals(kind) || "ROOT".equals(kind); + } + + private String slugifyPath(String path) { + String normalized = path.replace('\\', '/').trim() + .replaceAll("^/+", "") + .replaceAll("/+$", ""); + if (normalized.isBlank()) { + return ""; + } + List segments = new ArrayList<>(); + for (String segment : normalized.split("/")) { + String slug = slugifySegment(segment); + if (!slug.isBlank()) { + segments.add(slug); + } + } + return String.join("/", segments); + } + + private String slugifySegment(String segment) { + String slug = SLUG_SEPARATOR_PATTERN.matcher(segment.trim().toLowerCase(Locale.ROOT)).replaceAll("-") + .replaceAll("^-+", "") + .replaceAll("-+$", ""); + return slug; + } + private ToolResult fallbackIntellisearch(String space, String context, String query, int limit) { try { JsonNode hits = executeJson("GET", searchUrl(space, query, limit), null); @@ -270,8 +342,7 @@ private JsonNode executeJson(String method, HttpUrl url, Map pay 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)); + throw new BrainApiException(response.code(), responseMessage(responseBody)); } if (responseBody.isBlank()) { return objectMapper.nullNode(); @@ -285,8 +356,7 @@ private void executeNoContent(String method, HttpUrl url, Map pa 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)); + throw new BrainApiException(response.code(), responseMessage(responseBody)); } } } @@ -407,7 +477,22 @@ private String responseMessage(String body) { if (body == null || body.isBlank()) { return ""; } - return ": " + body; + String extracted = extractErrorMessage(body); + return extracted.isBlank() ? ": " + body.trim() : ": " + extracted; + } + + private String extractErrorMessage(String body) { + try { + JsonNode node = objectMapper.readTree(body); + String error = node.path("error").asText(""); + if (!error.isBlank()) { + return error; + } + String message = node.path("message").asText(""); + return message.isBlank() ? "" : message; + } catch (JsonProcessingException exception) { + return ""; + } } private String requireText(String value, String fieldName) { @@ -439,4 +524,20 @@ private String trimToNull(String value) { private String encodePathSegment(String value) { return value.replace("/", "%2F"); } + + private static class BrainApiException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + private final int httpStatusCode; + + BrainApiException(int httpStatusCode, String responseMessage) { + super("Brain API request failed with HTTP " + httpStatusCode + responseMessage); + this.httpStatusCode = httpStatusCode; + } + + int getHttpStatusCode() { + return httpStatusCode; + } + } + } 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 7067a53..aa6e3b2 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 @@ -83,7 +83,8 @@ public ToolDefinition getDefinition() { 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_PARENT_PATH, stringProperty( + "Parent section path for create_page. If the exact path is not found, the Brain plugin also tries a slugified title form.")); properties.put(PARAM_TITLE, stringProperty("Page title.")); properties.put(PARAM_SLUG, stringProperty("Optional page slug.")); properties.put(PARAM_CONTENT, stringProperty("Markdown page content.")); 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 44327ac..3901e87 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 @@ -103,6 +103,9 @@ void shouldReadPageThroughBrainCrudApi() { @Test void shouldCreatePageThroughBrainCrudApiWhenWritesAllowed() { config.setAllowWrite(true); + httpEngine.enqueueJson(200, """ + {"path":"ops","title":"Ops","content":"","kind":"SECTION"} + """); httpEngine.enqueueJson(200, """ {"path":"ops/runbook","title":"Runbook","content":"# Runbook","kind":"PAGE"} """); @@ -116,11 +119,102 @@ void shouldCreatePageThroughBrainCrudApiWhenWritesAllowed() { "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\"")); + OkHttpMockEngine.CapturedRequest lookupRequest = httpEngine.takeRequest(); + OkHttpMockEngine.CapturedRequest createRequest = httpEngine.takeRequest(); + assertEquals("GET", lookupRequest.method()); + assertEquals("/api/spaces/docs/page?path=ops", lookupRequest.target()); + assertEquals("POST", createRequest.method()); + assertEquals("/api/spaces/docs/pages", createRequest.target()); + assertTrue(createRequest.body().contains("\"parentPath\":\"ops\"")); + assertTrue(createRequest.body().contains("\"title\":\"Runbook\"")); + } + + @Test + void shouldResolveCreateParentPathBySlugifyingTitleWhenExactPathIsMissing() { + config.setAllowWrite(true); + httpEngine.enqueueJson(404, """ + {"error":"Page not found: golem notes"} + """); + httpEngine.enqueueJson(200, """ + {"path":"golem-notes","title":"Golem Notes","content":"","kind":"SECTION"} + """); + httpEngine.enqueueJson(200, """ + {"path":"golem-notes/test-note","title":"Test Note","content":"Body","kind":"PAGE"} + """); + + ToolResult result = provider.execute(Map.of( + "operation", "create_page", + "parent_path", "golem notes", + "title", "Test Note", + "slug", "test-note", + "content", "Body", + "kind", "PAGE")).join(); + + assertTrue(result.isSuccess()); + OkHttpMockEngine.CapturedRequest exactLookup = httpEngine.takeRequest(); + OkHttpMockEngine.CapturedRequest slugLookup = httpEngine.takeRequest(); + OkHttpMockEngine.CapturedRequest createRequest = httpEngine.takeRequest(); + assertEquals("/api/spaces/docs/page?path=golem%20notes", exactLookup.target()); + assertEquals("/api/spaces/docs/page?path=golem-notes", slugLookup.target()); + assertEquals("POST", createRequest.method()); + assertTrue(createRequest.body().contains("\"parentPath\":\"golem-notes\"")); + } + + @Test + void shouldRejectCreateParentPathThatResolvesToPage() { + config.setAllowWrite(true); + httpEngine.enqueueJson(200, """ + {"path":"ops/runbook","title":"Runbook","content":"Body","kind":"PAGE"} + """); + + ToolResult result = provider.execute(Map.of( + "operation", "create_page", + "parent_path", "ops/runbook", + "title", "Child", + "slug", "child", + "content", "Body", + "kind", "PAGE")).join(); + + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("Brain path is not a section: ops/runbook")); + assertEquals(1, httpEngine.getRequestCount()); + } + + @Test + void shouldExposeBrainApiErrorMessageWithoutRawJson() { + httpEngine.enqueueJson(404, """ + {"error":"Section not found: golem notes"} + """); + + ToolResult result = provider.execute(Map.of( + "operation", "read_page", + "path", "golem notes")).join(); + + assertFalse(result.isSuccess()); + assertEquals("Brain API request failed with HTTP 404: Section not found: golem notes", result.getError()); + } + + @Test + void shouldReturnClearErrorWhenCreateParentSectionCannotBeResolved() { + config.setAllowWrite(true); + httpEngine.enqueueJson(404, """ + {"error":"Page not found: Missing Section"} + """); + httpEngine.enqueueJson(404, """ + {"error":"Page not found: missing-section"} + """); + + ToolResult result = provider.execute(Map.of( + "operation", "create_page", + "parent_path", "Missing Section", + "title", "Test Note", + "slug", "test-note", + "content", "Body", + "kind", "PAGE")).join(); + + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("Brain section not found: Missing Section")); + assertEquals(2, httpEngine.getRequestCount()); } @Test diff --git a/registry/golemcore/brain/index.yaml b/registry/golemcore/brain/index.yaml index d068508..71c6845 100644 --- a/registry/golemcore/brain/index.yaml +++ b/registry/golemcore/brain/index.yaml @@ -1,8 +1,9 @@ id: golemcore/brain owner: golemcore name: brain -latest: 1.0.1 +latest: 1.0.2 versions: - 1.0.0 - 1.0.1 + - 1.0.2 source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/brain" diff --git a/registry/golemcore/brain/versions/1.0.2.yaml b/registry/golemcore/brain/versions/1.0.2.yaml new file mode 100644 index 0000000..1f4878d --- /dev/null +++ b/registry/golemcore/brain/versions/1.0.2.yaml @@ -0,0 +1,12 @@ +id: golemcore/brain +version: 1.0.2 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <2.0.0" +artifactUrl: "dist/golemcore/brain/1.0.2/golemcore-brain-plugin-1.0.2.jar" +publishedAt: "2026-04-14T19:33:00Z" +sourceCommit: "d205c101ccaab17d8950c52c9f1aa767df5a30d0" +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