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