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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion golemcore/brain/plugin.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion golemcore/brain/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<relativePath>../../pom.xml</relativePath>
</parent>

<version>1.0.1</version>
<version>1.0.2</version>
<artifactId>golemcore-brain-plugin</artifactId>
<name>golemcore/brain</name>
<description>GolemCore Brain wiki tool plugin for GolemCore</description>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -116,13 +120,13 @@ public ToolResult createPage(String spaceSlug, String parentPath, String title,
String kind) {
requireWriteAllowed();
String space = resolveSpace(spaceSlug);
Map<String, Object> 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<String, Object> 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) {
Expand Down Expand Up @@ -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<String> exactPath = findSectionPath(space, requestedPath, true);
if (exactPath.isPresent()) {
return exactPath.get();
}
String slugifiedPath = slugifyPath(requestedPath);
if (!slugifiedPath.isBlank() && !slugifiedPath.equals(requestedPath)) {
Optional<String> slugPath = findSectionPath(space, slugifiedPath, false);
if (slugPath.isPresent()) {
return slugPath.get();
}
}
throw new IllegalStateException("Brain section not found: " + requestedPath
+ " (also tried: " + slugifiedPath + ")");
}

private Optional<String> 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<String> 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);
Expand Down Expand Up @@ -270,8 +342,7 @@ private JsonNode executeJson(String method, HttpUrl url, Map<String, Object> 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();
Expand All @@ -285,8 +356,7 @@ private void executeNoContent(String method, HttpUrl url, Map<String, Object> 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));
}
}
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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."));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
""");
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion registry/golemcore/brain/index.yaml
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 12 additions & 0 deletions registry/golemcore/brain/versions/1.0.2.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading