From d4ad31089268e374a20fda41e07a912192ad3ad7 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 13:07:41 -0400 Subject: [PATCH 01/22] feat(golemcore/obsidian): scaffold obsidian plugin module --- golemcore/obsidian/plugin.yaml | 12 +++ golemcore/obsidian/pom.xml | 90 +++++++++++++++++++ .../obsidian/ObsidianPluginBootstrap.java | 22 +++++ .../obsidian/ObsidianPluginConfiguration.java | 9 ++ ...e.plugin.api.extension.spi.PluginBootstrap | 1 + .../obsidian/ObsidianPluginBootstrapTest.java | 21 +++++ pom.xml | 1 + 7 files changed, 156 insertions(+) create mode 100644 golemcore/obsidian/plugin.yaml create mode 100644 golemcore/obsidian/pom.xml create mode 100644 golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrap.java create mode 100644 golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfiguration.java create mode 100644 golemcore/obsidian/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap create mode 100644 golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrapTest.java diff --git a/golemcore/obsidian/plugin.yaml b/golemcore/obsidian/plugin.yaml new file mode 100644 index 0000000..6e0774c --- /dev/null +++ b/golemcore/obsidian/plugin.yaml @@ -0,0 +1,12 @@ +id: golemcore/obsidian +provider: golemcore +name: obsidian +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +entrypoint: me.golemcore.plugins.golemcore.obsidian.ObsidianPluginBootstrap +description: Obsidian vault plugin backed by obsidian-local-rest-api. +sourceUrl: https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/obsidian +license: Apache-2.0 +maintainers: + - alexk-dev diff --git a/golemcore/obsidian/pom.xml b/golemcore/obsidian/pom.xml new file mode 100644 index 0000000..0837c5a --- /dev/null +++ b/golemcore/obsidian/pom.xml @@ -0,0 +1,90 @@ + + + 4.0.0 + + + me.golemcore.plugins + golemcore-plugins + 1.0.0 + ../../pom.xml + + + 1.0.0 + golemcore-obsidian-plugin + golemcore/obsidian + Obsidian vault plugin for GolemCore + + + golemcore + obsidian + ../../misc/formatter_eclipse.xml + + + + + me.golemcore.plugins + golemcore-plugin-extension-api + provided + + + me.golemcore.plugins + golemcore-plugin-runtime-api + provided + + + org.springframework + spring-context + + + 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/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrap.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrap.java new file mode 100644 index 0000000..d0f502e --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrap.java @@ -0,0 +1,22 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import me.golemcore.plugin.api.extension.spi.PluginBootstrap; +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; + +public class ObsidianPluginBootstrap implements PluginBootstrap { + + @Override + public PluginDescriptor descriptor() { + return PluginDescriptor.builder() + .id("golemcore/obsidian") + .provider("golemcore") + .name("obsidian") + .entrypoint(getClass().getName()) + .build(); + } + + @Override + public Class configurationClass() { + return ObsidianPluginConfiguration.class; + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfiguration.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfiguration.java new file mode 100644 index 0000000..75bf378 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfiguration.java @@ -0,0 +1,9 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@ComponentScan(basePackageClasses = ObsidianPluginConfiguration.class) +public class ObsidianPluginConfiguration { +} diff --git a/golemcore/obsidian/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap b/golemcore/obsidian/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap new file mode 100644 index 0000000..d96479c --- /dev/null +++ b/golemcore/obsidian/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap @@ -0,0 +1 @@ +me.golemcore.plugins.golemcore.obsidian.ObsidianPluginBootstrap diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrapTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrapTest.java new file mode 100644 index 0000000..f631b40 --- /dev/null +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrapTest.java @@ -0,0 +1,21 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; +import org.junit.jupiter.api.Test; + +class ObsidianPluginBootstrapTest { + + @Test + void shouldDescribeObsidianPlugin() { + ObsidianPluginBootstrap bootstrap = new ObsidianPluginBootstrap(); + + PluginDescriptor descriptor = bootstrap.descriptor(); + + assertEquals("golemcore/obsidian", descriptor.getId()); + assertEquals("golemcore", descriptor.getProvider()); + assertEquals("obsidian", descriptor.getName()); + assertEquals(ObsidianPluginConfiguration.class, bootstrap.configurationClass()); + } +} diff --git a/pom.xml b/pom.xml index 8011988..8a49ce9 100644 --- a/pom.xml +++ b/pom.xml @@ -75,6 +75,7 @@ golemcore/pinchtab golemcore/brave-search golemcore/browserless + golemcore/obsidian golemcore/tavily-search golemcore/firecrawl golemcore/perplexity-sonar From 3c45d5f5dc4dfde031a639402e538a7f6bb427d8 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 13:11:48 -0400 Subject: [PATCH 02/22] fix(test): allow filtered reactor runs to skip upstream modules --- pom.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pom.xml b/pom.xml index 8a49ce9..69a225e 100644 --- a/pom.xml +++ b/pom.xml @@ -148,6 +148,9 @@ org.apache.maven.plugins maven-surefire-plugin ${maven.surefire.plugin.version} + + false + org.apache.maven.plugins From 8356722c745046c3f9c6c66f772e886432507dff Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 13:18:55 -0400 Subject: [PATCH 03/22] fix(test): scope no-specified-tests handling to upstream modules --- extension-api/pom.xml | 7 +++++++ pom.xml | 3 --- runtime-api/pom.xml | 7 +++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/extension-api/pom.xml b/extension-api/pom.xml index 6673f0c..d115b76 100644 --- a/extension-api/pom.xml +++ b/extension-api/pom.xml @@ -38,6 +38,13 @@ net.revelc.code.formatter formatter-maven-plugin + + org.apache.maven.plugins + maven-surefire-plugin + + false + + diff --git a/pom.xml b/pom.xml index 69a225e..8a49ce9 100644 --- a/pom.xml +++ b/pom.xml @@ -148,9 +148,6 @@ org.apache.maven.plugins maven-surefire-plugin ${maven.surefire.plugin.version} - - false - org.apache.maven.plugins diff --git a/runtime-api/pom.xml b/runtime-api/pom.xml index afc146b..c1a2643 100644 --- a/runtime-api/pom.xml +++ b/runtime-api/pom.xml @@ -38,6 +38,13 @@ net.revelc.code.formatter formatter-maven-plugin + + org.apache.maven.plugins + maven-surefire-plugin + + false + + From e79cf28cb3b833f61e9f552671b2a2e9112a43ab Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 13:25:51 -0400 Subject: [PATCH 04/22] feat(golemcore/obsidian): add obsidian plugin settings --- golemcore/obsidian/pom.xml | 9 + .../obsidian/ObsidianPluginConfig.java | 91 ++++++++ .../obsidian/ObsidianPluginConfigService.java | 33 +++ .../ObsidianPluginSettingsContributor.java | 206 ++++++++++++++++++ ...ObsidianPluginSettingsContributorTest.java | 74 +++++++ 5 files changed, 413 insertions(+) create mode 100644 golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java create mode 100644 golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfigService.java create mode 100644 golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java create mode 100644 golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java diff --git a/golemcore/obsidian/pom.xml b/golemcore/obsidian/pom.xml index 0837c5a..dc50331 100644 --- a/golemcore/obsidian/pom.xml +++ b/golemcore/obsidian/pom.xml @@ -33,10 +33,19 @@ golemcore-plugin-runtime-api provided + + org.projectlombok + lombok + provided + org.springframework spring-context + + com.fasterxml.jackson.core + jackson-databind + org.springframework.boot spring-boot-starter-test diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java new file mode 100644 index 0000000..dd30702 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java @@ -0,0 +1,91 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ObsidianPluginConfig { + + private static final String DEFAULT_BASE_URL = "http://127.0.0.1:27123"; + private static final int DEFAULT_TIMEOUT_MS = 30_000; + private static final int DEFAULT_SEARCH_CONTEXT_LENGTH = 100; + private static final int DEFAULT_MAX_READ_CHARS = 12_000; + + @Builder.Default + private Boolean enabled = false; + + private String baseUrl; + private String apiKey; + + @Builder.Default + private Integer timeoutMs = DEFAULT_TIMEOUT_MS; + + @Builder.Default + private Boolean allowInsecureTls = false; + + @Builder.Default + private Integer defaultSearchContextLength = DEFAULT_SEARCH_CONTEXT_LENGTH; + + @Builder.Default + private Integer maxReadChars = DEFAULT_MAX_READ_CHARS; + + @Builder.Default + private Boolean allowWrite = false; + + @Builder.Default + private Boolean allowDelete = false; + + @Builder.Default + private Boolean allowMove = false; + + @Builder.Default + private Boolean allowRename = false; + + public void normalize() { + if (enabled == null) { + enabled = false; + } + if (baseUrl == null || baseUrl.isBlank()) { + baseUrl = DEFAULT_BASE_URL; + } else { + baseUrl = trimTrailingSlash(baseUrl.trim()); + } + if (timeoutMs == null || timeoutMs <= 0) { + timeoutMs = DEFAULT_TIMEOUT_MS; + } + if (allowInsecureTls == null) { + allowInsecureTls = false; + } + if (defaultSearchContextLength == null || defaultSearchContextLength <= 0) { + defaultSearchContextLength = DEFAULT_SEARCH_CONTEXT_LENGTH; + } + if (maxReadChars == null || maxReadChars <= 0) { + maxReadChars = DEFAULT_MAX_READ_CHARS; + } + if (allowWrite == null) { + allowWrite = false; + } + if (allowDelete == null) { + allowDelete = false; + } + if (allowMove == null) { + allowMove = false; + } + if (allowRename == null) { + allowRename = false; + } + } + + private String trimTrailingSlash(String value) { + String trimmed = value; + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed.isBlank() ? DEFAULT_BASE_URL : trimmed; + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfigService.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfigService.java new file mode 100644 index 0000000..ea39d0c --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfigService.java @@ -0,0 +1,33 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import me.golemcore.plugin.api.runtime.PluginConfigurationService; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class ObsidianPluginConfigService { + + static final String PLUGIN_ID = "golemcore/obsidian"; + + private final PluginConfigurationService pluginConfigurationService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public ObsidianPluginConfig getConfig() { + Map raw = pluginConfigurationService.getPluginConfig(PLUGIN_ID); + ObsidianPluginConfig config = raw.isEmpty() + ? ObsidianPluginConfig.builder().build() + : objectMapper.convertValue(raw, ObsidianPluginConfig.class); + config.normalize(); + return config; + } + + @SuppressWarnings("unchecked") + public void save(ObsidianPluginConfig config) { + config.normalize(); + pluginConfigurationService.savePluginConfig(PLUGIN_ID, objectMapper.convertValue(config, Map.class)); + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java new file mode 100644 index 0000000..163ae50 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java @@ -0,0 +1,206 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import lombok.RequiredArgsConstructor; +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 +@RequiredArgsConstructor +public class ObsidianPluginSettingsContributor implements PluginSettingsContributor { + + private static final String SECTION_KEY = "main"; + + private final ObsidianPluginConfigService configService; + + @Override + public String getPluginId() { + return ObsidianPluginConfigService.PLUGIN_ID; + } + + @Override + public List getCatalogItems() { + return List.of(PluginSettingsCatalogItem.builder() + .pluginId(ObsidianPluginConfigService.PLUGIN_ID) + .pluginName("obsidian") + .provider("golemcore") + .sectionKey(SECTION_KEY) + .title("Obsidian") + .description("Obsidian vault connection, context limits, and file operation policy.") + .blockKey("tools") + .blockTitle("Tools") + .blockDescription("Tool-specific runtime behavior and integrations") + .order(37) + .build()); + } + + @Override + public PluginSettingsSection getSection(String sectionKey) { + requireSection(sectionKey); + ObsidianPluginConfig config = configService.getConfig(); + Map values = new LinkedHashMap<>(); + values.put("enabled", Boolean.TRUE.equals(config.getEnabled())); + values.put("baseUrl", config.getBaseUrl()); + values.put("apiKey", ""); + values.put("timeoutMs", config.getTimeoutMs()); + values.put("allowInsecureTls", Boolean.TRUE.equals(config.getAllowInsecureTls())); + values.put("defaultSearchContextLength", config.getDefaultSearchContextLength()); + values.put("maxReadChars", config.getMaxReadChars()); + values.put("allowWrite", false); + values.put("allowDelete", false); + values.put("allowMove", false); + values.put("allowRename", false); + + return PluginSettingsSection.builder() + .title("Obsidian") + .description("Configure the Obsidian vault connection and conservative file operation policy.") + .fields(List.of( + PluginSettingsField.builder() + .key("enabled") + .type("boolean") + .label("Enable Obsidian") + .description("Allow tools to use the Obsidian vault integration.") + .build(), + PluginSettingsField.builder() + .key("baseUrl") + .type("text") + .label("Base URL") + .description("Obsidian Local REST API endpoint.") + .placeholder("http://127.0.0.1:27123") + .build(), + PluginSettingsField.builder() + .key("apiKey") + .type("secret") + .label("API Key") + .description("Leave blank to keep the current secret.") + .placeholder("Enter API key") + .build(), + PluginSettingsField.builder() + .key("timeoutMs") + .type("number") + .label("Request Timeout (ms)") + .description("Timeout for vault API requests.") + .min(1000.0) + .max(300000.0) + .step(1000.0) + .build(), + PluginSettingsField.builder() + .key("allowInsecureTls") + .type("boolean") + .label("Allow Insecure TLS") + .description("Allow self-signed TLS certificates when connecting to Obsidian.") + .build(), + PluginSettingsField.builder() + .key("defaultSearchContextLength") + .type("number") + .label("Default Search Context Length") + .description("Context length used when searching vault content.") + .min(1.0) + .step(1.0) + .build(), + PluginSettingsField.builder() + .key("maxReadChars") + .type("number") + .label("Max Read Chars") + .description("Maximum number of characters returned when reading a note.") + .min(1.0) + .step(1.0) + .build(), + PluginSettingsField.builder() + .key("allowWrite") + .type("boolean") + .label("Allow Write") + .description("Permit tools to create or edit notes.") + .build(), + PluginSettingsField.builder() + .key("allowDelete") + .type("boolean") + .label("Allow Delete") + .description("Permit tools to delete notes.") + .build(), + PluginSettingsField.builder() + .key("allowMove") + .type("boolean") + .label("Allow Move") + .description("Permit tools to move notes or files.") + .build(), + PluginSettingsField.builder() + .key("allowRename") + .type("boolean") + .label("Allow Rename") + .description("Permit tools to rename notes or files.") + .build())) + .values(values) + .build(); + } + + @Override + public PluginSettingsSection saveSection(String sectionKey, Map values) { + requireSection(sectionKey); + ObsidianPluginConfig config = configService.getConfig(); + config.setEnabled(readBoolean(values, "enabled", false)); + config.setBaseUrl(readString(values, "baseUrl", config.getBaseUrl())); + String apiKey = readString(values, "apiKey", null); + if (apiKey != null && !apiKey.isBlank()) { + config.setApiKey(apiKey); + } + config.setTimeoutMs(readInteger(values, "timeoutMs", config.getTimeoutMs())); + config.setAllowInsecureTls(readBoolean(values, "allowInsecureTls", false)); + config.setDefaultSearchContextLength(readInteger(values, "defaultSearchContextLength", + config.getDefaultSearchContextLength())); + config.setMaxReadChars(readInteger(values, "maxReadChars", config.getMaxReadChars())); + config.setAllowWrite(readBoolean(values, "allowWrite", false)); + config.setAllowDelete(readBoolean(values, "allowDelete", false)); + config.setAllowMove(readBoolean(values, "allowMove", false)); + config.setAllowRename(readBoolean(values, "allowRename", false)); + configService.save(config); + return getSection(sectionKey); + } + + @Override + public PluginActionResult executeAction(String sectionKey, String actionId, Map payload) { + requireSection(sectionKey); + throw new IllegalArgumentException("Unknown Obsidian action: " + actionId); + } + + private void requireSection(String sectionKey) { + if (!SECTION_KEY.equals(sectionKey)) { + throw new IllegalArgumentException("Unknown Obsidian 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/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java new file mode 100644 index 0000000..03728d4 --- /dev/null +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java @@ -0,0 +1,74 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ObsidianPluginSettingsContributorTest { + + private ObsidianPluginConfigService configService; + private ObsidianPluginSettingsContributor contributor; + private ObsidianPluginConfig config; + + @BeforeEach + void setUp() { + configService = mock(ObsidianPluginConfigService.class); + contributor = new ObsidianPluginSettingsContributor(configService); + config = ObsidianPluginConfig.builder() + .enabled(true) + .baseUrl("https://127.0.0.1:27124") + .apiKey("existing-secret") + .timeoutMs(45_000) + .allowInsecureTls(true) + .defaultSearchContextLength(120) + .maxReadChars(8_000) + .allowWrite(true) + .allowDelete(false) + .allowMove(true) + .allowRename(false) + .build(); + when(configService.getConfig()).thenReturn(config); + } + + @Test + void shouldExposeSectionWithBlankSecretAndSafeDefaults() { + PluginSettingsSection section = contributor.getSection("main"); + + assertEquals("", section.getValues().get("apiKey")); + assertEquals(false, section.getValues().get("allowWrite")); + assertEquals(false, section.getValues().get("allowDelete")); + assertEquals(false, section.getValues().get("allowMove")); + assertEquals(false, section.getValues().get("allowRename")); + } + + @Test + void shouldPreserveApiKeyWhenBlankSecretIsSaved() { + Map values = new LinkedHashMap<>(); + values.put("enabled", true); + values.put("baseUrl", "https://127.0.0.1:27124"); + values.put("apiKey", ""); + values.put("timeoutMs", 45_000); + values.put("allowInsecureTls", true); + values.put("defaultSearchContextLength", 120); + values.put("maxReadChars", 8_000); + values.put("allowWrite", true); + values.put("allowDelete", false); + values.put("allowMove", true); + values.put("allowRename", false); + + contributor.saveSection("main", values); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ObsidianPluginConfig.class); + verify(configService).save(captor.capture()); + assertEquals("existing-secret", captor.getValue().getApiKey()); + } +} From 50e6035f5f4b567e62a703036c3bc055ac0df908 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 13:31:17 -0400 Subject: [PATCH 05/22] fix(golemcore/obsidian): round-trip obsidian policy flags --- .../ObsidianPluginSettingsContributor.java | 8 +-- ...ObsidianPluginSettingsContributorTest.java | 58 ++++++++++++------- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java index 163ae50..4b19461 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java @@ -53,10 +53,10 @@ public PluginSettingsSection getSection(String sectionKey) { values.put("allowInsecureTls", Boolean.TRUE.equals(config.getAllowInsecureTls())); values.put("defaultSearchContextLength", config.getDefaultSearchContextLength()); values.put("maxReadChars", config.getMaxReadChars()); - values.put("allowWrite", false); - values.put("allowDelete", false); - values.put("allowMove", false); - values.put("allowRename", false); + values.put("allowWrite", Boolean.TRUE.equals(config.getAllowWrite())); + values.put("allowDelete", Boolean.TRUE.equals(config.getAllowDelete())); + values.put("allowMove", Boolean.TRUE.equals(config.getAllowMove())); + values.put("allowRename", Boolean.TRUE.equals(config.getAllowRename())); return PluginSettingsSection.builder() .title("Obsidian") diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java index 03728d4..94d1d5a 100644 --- a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java @@ -9,6 +9,8 @@ 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.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -23,35 +25,38 @@ class ObsidianPluginSettingsContributorTest { void setUp() { configService = mock(ObsidianPluginConfigService.class); contributor = new ObsidianPluginSettingsContributor(configService); - config = ObsidianPluginConfig.builder() - .enabled(true) - .baseUrl("https://127.0.0.1:27124") - .apiKey("existing-secret") - .timeoutMs(45_000) - .allowInsecureTls(true) - .defaultSearchContextLength(120) - .maxReadChars(8_000) - .allowWrite(true) - .allowDelete(false) - .allowMove(true) - .allowRename(false) - .build(); + config = ObsidianPluginConfig.builder().build(); + config.normalize(); when(configService.getConfig()).thenReturn(config); } @Test - void shouldExposeSectionWithBlankSecretAndSafeDefaults() { + void shouldExposeSectionWithBlankSecretAndSafeDefaultsFromDefaultConfig() { PluginSettingsSection section = contributor.getSection("main"); + assertEquals(false, section.getValues().get("enabled")); + assertEquals("http://127.0.0.1:27123", section.getValues().get("baseUrl")); assertEquals("", section.getValues().get("apiKey")); - assertEquals(false, section.getValues().get("allowWrite")); - assertEquals(false, section.getValues().get("allowDelete")); - assertEquals(false, section.getValues().get("allowMove")); - assertEquals(false, section.getValues().get("allowRename")); + assertEquals(30_000, section.getValues().get("timeoutMs")); + assertEquals(false, section.getValues().get("allowInsecureTls")); + assertEquals(100, section.getValues().get("defaultSearchContextLength")); + assertEquals(12_000, section.getValues().get("maxReadChars")); + assertFalse((Boolean) section.getValues().get("allowWrite")); + assertFalse((Boolean) section.getValues().get("allowDelete")); + assertFalse((Boolean) section.getValues().get("allowMove")); + assertFalse((Boolean) section.getValues().get("allowRename")); } @Test - void shouldPreserveApiKeyWhenBlankSecretIsSaved() { + void shouldRoundTripSavedPolicyFlagsThroughGetSection() { + config.setEnabled(true); + config.setBaseUrl("https://127.0.0.1:27124"); + config.setApiKey("existing-secret"); + config.setTimeoutMs(45_000); + config.setAllowInsecureTls(true); + config.setDefaultSearchContextLength(120); + config.setMaxReadChars(8_000); + Map values = new LinkedHashMap<>(); values.put("enabled", true); values.put("baseUrl", "https://127.0.0.1:27124"); @@ -65,10 +70,21 @@ void shouldPreserveApiKeyWhenBlankSecretIsSaved() { values.put("allowMove", true); values.put("allowRename", false); - contributor.saveSection("main", values); + PluginSettingsSection section = contributor.saveSection("main", values); ArgumentCaptor captor = ArgumentCaptor.forClass(ObsidianPluginConfig.class); verify(configService).save(captor.capture()); - assertEquals("existing-secret", captor.getValue().getApiKey()); + ObsidianPluginConfig saved = captor.getValue(); + assertEquals("existing-secret", saved.getApiKey()); + assertTrue(saved.getAllowWrite()); + assertFalse(saved.getAllowDelete()); + assertTrue(saved.getAllowMove()); + assertFalse(saved.getAllowRename()); + + assertEquals("", section.getValues().get("apiKey")); + assertTrue((Boolean) section.getValues().get("allowWrite")); + assertFalse((Boolean) section.getValues().get("allowDelete")); + assertTrue((Boolean) section.getValues().get("allowMove")); + assertFalse((Boolean) section.getValues().get("allowRename")); } } From aa98f3cd39372d5f7244be8cd7db230f1663b2a1 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 13:40:53 -0400 Subject: [PATCH 06/22] fix(golemcore/obsidian): tighten obsidian settings defaults --- .../obsidian/ObsidianPluginConfig.java | 3 ++- ...ObsidianPluginSettingsContributorTest.java | 26 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java index dd30702..2fc65fe 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java @@ -19,7 +19,8 @@ public class ObsidianPluginConfig { @Builder.Default private Boolean enabled = false; - private String baseUrl; + @Builder.Default + private String baseUrl = DEFAULT_BASE_URL; private String apiKey; @Builder.Default diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java index 94d1d5a..ac24cff 100644 --- a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java @@ -49,13 +49,25 @@ void shouldExposeSectionWithBlankSecretAndSafeDefaultsFromDefaultConfig() { @Test void shouldRoundTripSavedPolicyFlagsThroughGetSection() { - config.setEnabled(true); - config.setBaseUrl("https://127.0.0.1:27124"); - config.setApiKey("existing-secret"); - config.setTimeoutMs(45_000); - config.setAllowInsecureTls(true); - config.setDefaultSearchContextLength(120); - config.setMaxReadChars(8_000); + ObsidianPluginConfig initialConfig = ObsidianPluginConfig.builder() + .apiKey("existing-secret") + .build(); + initialConfig.normalize(); + ObsidianPluginConfig persistedConfig = ObsidianPluginConfig.builder() + .enabled(true) + .baseUrl("https://127.0.0.1:27124") + .apiKey("existing-secret") + .timeoutMs(45_000) + .allowInsecureTls(true) + .defaultSearchContextLength(120) + .maxReadChars(8_000) + .allowWrite(true) + .allowDelete(false) + .allowMove(true) + .allowRename(false) + .build(); + persistedConfig.normalize(); + when(configService.getConfig()).thenReturn(initialConfig, persistedConfig); Map values = new LinkedHashMap<>(); values.put("enabled", true); From 1a2fce1d2e18df39b4ae9aa749f078c863a56434 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 13:48:56 -0400 Subject: [PATCH 07/22] feat(golemcore/obsidian): add obsidian api client --- golemcore/obsidian/pom.xml | 5 + .../obsidian/model/ObsidianSearchResult.java | 11 + .../obsidian/support/ObsidianApiClient.java | 366 ++++++++++++++++++ .../support/ObsidianApiException.java | 15 + .../support/ObsidianApiClientTest.java | 186 +++++++++ 5 files changed, 583 insertions(+) create mode 100644 golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java create mode 100644 golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java create mode 100644 golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiException.java create mode 100644 golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java diff --git a/golemcore/obsidian/pom.xml b/golemcore/obsidian/pom.xml index dc50331..9e9d960 100644 --- a/golemcore/obsidian/pom.xml +++ b/golemcore/obsidian/pom.xml @@ -46,6 +46,11 @@ com.fasterxml.jackson.core jackson-databind + + com.squareup.okhttp3 + okhttp-jvm + ${okhttp.version} + org.springframework.boot spring-boot-starter-test diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java new file mode 100644 index 0000000..2fafaf2 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java @@ -0,0 +1,11 @@ +package me.golemcore.plugins.golemcore.obsidian.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown=true)public record ObsidianSearchResult(String filename,double score,Listmatches){ + +public ObsidianSearchResult{matches=matches==null?List.of():List.copyOf(matches);} + +@JsonIgnoreProperties(ignoreUnknown=true)public record Match(String context,Integer start,Integer end){}} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java new file mode 100644 index 0000000..5efe91c --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java @@ -0,0 +1,366 @@ +package me.golemcore.plugins.golemcore.obsidian.support; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.plugins.golemcore.obsidian.ObsidianPluginConfig; +import me.golemcore.plugins.golemcore.obsidian.ObsidianPluginConfigService; +import me.golemcore.plugins.golemcore.obsidian.model.ObsidianSearchResult; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.springframework.stereotype.Component; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Component +public class ObsidianApiClient { + + private static final MediaType TEXT_MARKDOWN = MediaType.get("text/markdown; charset=utf-8"); + private static final MediaType APPLICATION_JSON = MediaType.get("application/json; charset=utf-8"); + private static final String VAULT_SEGMENT = "vault"; + private static final String SEARCH_SEGMENT = "search"; + private static final String SIMPLE_SEGMENT = "simple"; + + private final ObsidianPluginConfigService configService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public ObsidianApiClient(ObsidianPluginConfigService configService) { + this.configService = configService; + } + + public List listDirectory(String path) { + try { + Request request = new Request.Builder() + .url(buildVaultUrl(path, true)) + .header("Accept", "application/json") + .header("Authorization", authorizationHeader()) + .get() + .build(); + try (Response response = executeRequest(request)) { + JsonNode root = readJsonResponse(response); + JsonNode filesNode = root.path("files"); + if (!filesNode.isArray()) { + return List.of(); + } + List files = new ArrayList<>(filesNode.size()); + for (JsonNode fileNode : filesNode) { + files.add(fileNode.asText("")); + } + return List.copyOf(files); + } + } catch (IOException ex) { + throw new ObsidianApiException(500, "Obsidian request failed: " + ex.getMessage()); + } + } + + public String readNote(String path) { + try { + Request request = new Request.Builder() + .url(buildVaultUrl(path, false)) + .header("Accept", "application/vnd.olrapi.note+json, text/markdown") + .header("Authorization", authorizationHeader()) + .get() + .build(); + try (Response response = executeRequest(request); + ResponseBody body = response.body()) { + String responseBody = body != null ? body.string() : ""; + ensureSuccess(response, responseBody); + if (!hasText(responseBody)) { + return ""; + } + JsonNode root = tryReadJson(responseBody); + if (root != null && root.hasNonNull("content")) { + return root.path("content").asText(""); + } + return responseBody; + } + } catch (IOException ex) { + throw new ObsidianApiException(500, "Obsidian request failed: " + ex.getMessage()); + } + } + + public void writeNote(String path, String content) { + try { + Request request = new Request.Builder() + .url(buildVaultUrl(path, false)) + .header("Accept", "application/json") + .header("Authorization", authorizationHeader()) + .header("Content-Type", TEXT_MARKDOWN.toString()) + .put(RequestBody.create(content != null ? content : "", TEXT_MARKDOWN)) + .build(); + try (Response response = executeRequest(request); + ResponseBody body = response.body()) { + String responseBody = body != null ? body.string() : ""; + ensureSuccess(response, responseBody); + } + } catch (IOException ex) { + throw new ObsidianApiException(500, "Obsidian request failed: " + ex.getMessage()); + } + } + + public void deleteNote(String path) { + try { + Request request = new Request.Builder() + .url(buildVaultUrl(path, false)) + .header("Authorization", authorizationHeader()) + .delete() + .build(); + try (Response response = executeRequest(request); + ResponseBody body = response.body()) { + String responseBody = body != null ? body.string() : ""; + ensureSuccess(response, responseBody); + } + } catch (IOException ex) { + throw new ObsidianApiException(500, "Obsidian request failed: " + ex.getMessage()); + } + } + + public List simpleSearch(String query, int contextLength) { + if (!hasText(query)) { + throw new IllegalArgumentException("Search query is required"); + } + + int effectiveContextLength = contextLength > 0 ? contextLength : getConfig().getDefaultSearchContextLength(); + try { + Request request = new Request.Builder() + .url(buildSearchUrl(query, effectiveContextLength)) + .header("Accept", "application/json") + .header("Authorization", authorizationHeader()) + .post(RequestBody.create("", APPLICATION_JSON)) + .build(); + try (Response response = executeRequest(request); + ResponseBody body = response.body()) { + String responseBody = body != null ? body.string() : ""; + ensureSuccess(response, responseBody); + if (!hasText(responseBody)) { + return List.of(); + } + JsonNode root = objectMapper.readTree(responseBody); + return parseSearchResults(root); + } + } catch (IOException ex) { + throw new ObsidianApiException(500, "Obsidian request failed: " + ex.getMessage()); + } + } + + protected Response executeRequest(Request request) throws IOException { + return buildHttpClient(getConfig()).newCall(request).execute(); + } + + private ObsidianPluginConfig getConfig() { + return configService.getConfig(); + } + + private HttpUrl buildVaultUrl(String relativePath, boolean directory) { + ObsidianPluginConfig config = getConfig(); + HttpUrl baseUrl = parseBaseUrl(config.getBaseUrl()); + HttpUrl.Builder builder = Objects.requireNonNull(baseUrl).newBuilder().addPathSegment(VAULT_SEGMENT); + for (String segment : splitPath(relativePath)) { + builder.addPathSegment(segment); + } + if (directory) { + builder.addPathSegment(""); + } + return builder.build(); + } + + private HttpUrl buildSearchUrl(String query, int contextLength) { + ObsidianPluginConfig config = getConfig(); + HttpUrl baseUrl = parseBaseUrl(config.getBaseUrl()); + return Objects.requireNonNull(baseUrl) + .newBuilder() + .addPathSegment(SEARCH_SEGMENT) + .addPathSegment(SIMPLE_SEGMENT) + .addPathSegment("") + .addQueryParameter("query", query) + .addQueryParameter("contextLength", String.valueOf(contextLength)) + .build(); + } + + private HttpUrl parseBaseUrl(String baseUrl) { + HttpUrl parsed = HttpUrl.parse(baseUrl); + if (parsed == null) { + throw new IllegalStateException("Invalid Obsidian base URL: " + baseUrl); + } + return parsed; + } + + private List splitPath(String relativePath) { + if (!hasText(relativePath)) { + return List.of(); + } + String normalized = relativePath.trim(); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + if (!hasText(normalized)) { + return List.of(); + } + String[] segments = normalized.split("/"); + List values = new ArrayList<>(segments.length); + for (String segment : segments) { + if (hasText(segment)) { + values.add(segment); + } + } + return values; + } + + private OkHttpClient buildHttpClient(ObsidianPluginConfig config) { + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .callTimeout(java.time.Duration.ofMillis(config.getTimeoutMs())) + .connectTimeout(java.time.Duration.ofMillis(config.getTimeoutMs())) + .readTimeout(java.time.Duration.ofMillis(config.getTimeoutMs())) + .writeTimeout(java.time.Duration.ofMillis(config.getTimeoutMs())); + HttpUrl baseUrl = parseBaseUrl(config.getBaseUrl()); + if (Boolean.TRUE.equals(config.getAllowInsecureTls()) && "https".equalsIgnoreCase(baseUrl.scheme())) { + try { + TrustManager[] trustManagers = new TrustManager[] { new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } }; + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustManagers, new SecureRandom()); + SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + X509TrustManager trustManager = (X509TrustManager) trustManagers[0]; + HostnameVerifier hostnameVerifier = (hostname, session) -> true; + builder.sslSocketFactory(sslSocketFactory, trustManager); + builder.hostnameVerifier(hostnameVerifier); + } catch (GeneralSecurityException ex) { + throw new IllegalStateException("Unable to configure insecure TLS for Obsidian", ex); + } + } + return builder.build(); + } + + private JsonNode readJsonResponse(Response response) throws IOException { + try (ResponseBody body = response.body()) { + String responseBody = body != null ? body.string() : ""; + ensureSuccess(response, responseBody); + if (!hasText(responseBody)) { + return objectMapper.createObjectNode(); + } + return objectMapper.readTree(responseBody); + } + } + + private void ensureSuccess(Response response, String responseBody) { + if (response.isSuccessful()) { + return; + } + throw new ObsidianApiException(response.code(), extractErrorMessage(responseBody, response.code())); + } + + private String extractErrorMessage(String responseBody, int statusCode) { + if (!hasText(responseBody)) { + return "HTTP " + statusCode; + } + JsonNode root = tryReadJson(responseBody); + if (root != null) { + String message = firstText(root.path("message").asText(null), + root.path("error").asText(null), + root.path("status").asText(null)); + if (hasText(message)) { + return message; + } + } + return responseBody.trim(); + } + + private JsonNode tryReadJson(String responseBody) { + try { + return objectMapper.readTree(responseBody); + } catch (IOException ignored) { + return null; + } + } + + private List parseSearchResults(JsonNode root) { + JsonNode resultsNode = root.path("results"); + if (resultsNode.isArray()) { + return parseSearchResultsArray(resultsNode); + } + JsonNode matchesNode = root.path("matches"); + if (matchesNode.isArray()) { + return parseSearchResultsArray(matchesNode); + } + if (root.isArray()) { + return parseSearchResultsArray(root); + } + return List.of(); + } + + private List parseSearchResultsArray(JsonNode arrayNode) { + List results = new ArrayList<>(arrayNode.size()); + for (JsonNode resultNode : arrayNode) { + results.add(new ObsidianSearchResult( + firstText(resultNode.path("filename").asText(null), + resultNode.path("path").asText(null), + resultNode.path("file").asText(null), + resultNode.path("title").asText(null), + ""), + resultNode.path("score").isNumber() ? resultNode.path("score").asDouble() : 0.0d, + parseMatches(resultNode.path("matches")))); + } + return List.copyOf(results); + } + + private List parseMatches(JsonNode matchesNode) { + if (!matchesNode.isArray()) { + return List.of(); + } + List matches = new ArrayList<>(matchesNode.size()); + for (JsonNode matchNode : matchesNode) { + matches.add(new ObsidianSearchResult.Match( + matchNode.path("context").asText(""), + matchNode.path("start").isNumber() ? matchNode.path("start").asInt() : null, + matchNode.path("end").isNumber() ? matchNode.path("end").asInt() : null)); + } + return List.copyOf(matches); + } + + private String authorizationHeader() { + return "Bearer " + getConfig().getApiKey(); + } + + private String firstText(String... values) { + for (String value : values) { + if (hasText(value)) { + return value; + } + } + return ""; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiException.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiException.java new file mode 100644 index 0000000..83831fc --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiException.java @@ -0,0 +1,15 @@ +package me.golemcore.plugins.golemcore.obsidian.support; + +public class ObsidianApiException extends RuntimeException { + + private final int statusCode; + + public ObsidianApiException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java new file mode 100644 index 0000000..e76f778 --- /dev/null +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java @@ -0,0 +1,186 @@ +package me.golemcore.plugins.golemcore.obsidian.support; + +import me.golemcore.plugins.golemcore.obsidian.ObsidianPluginConfig; +import me.golemcore.plugins.golemcore.obsidian.ObsidianPluginConfigService; +import me.golemcore.plugins.golemcore.obsidian.model.ObsidianSearchResult; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Buffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ObsidianApiClientTest { + + private static final MediaType APPLICATION_JSON = MediaType.get("application/json"); + private static final MediaType TEXT_MARKDOWN = MediaType.get("text/markdown; charset=utf-8"); + + private ObsidianPluginConfigService configService; + private MockObsidianApiClient client; + + @BeforeEach + void setUp() { + configService = mock(ObsidianPluginConfigService.class); + ObsidianPluginConfig config = ObsidianPluginConfig.builder() + .enabled(true) + .baseUrl("https://127.0.0.1:27124") + .apiKey("api-key") + .timeoutMs(30_000) + .allowInsecureTls(false) + .defaultSearchContextLength(100) + .build(); + when(configService.getConfig()).thenReturn(config); + client = new MockObsidianApiClient(configService); + } + + @Test + void shouldListRootDirectoryUsingVaultRootEndpoint() throws Exception { + client.enqueueResponse(200, "{\"files\":[\"Inbox.md\",\"Projects/\"]}"); + + List files = client.listDirectory(""); + + Request request = client.getCapturedRequests().getFirst(); + assertEquals("GET", request.method()); + assertEquals("https://127.0.0.1:27124/vault/", request.url().toString()); + assertEquals("Bearer api-key", request.header("Authorization")); + assertEquals(List.of("Inbox.md", "Projects/"), files); + } + + @Test + void shouldListNestedDirectoryUsingPathSafeVaultEndpoint() throws Exception { + client.enqueueResponse(200, "{\"files\":[\"Meeting Notes.md\"]}"); + + List files = client.listDirectory("Projects/2026 Notes"); + + Request request = client.getCapturedRequests().getFirst(); + assertEquals("GET", request.method()); + assertEquals("https://127.0.0.1:27124/vault/Projects/2026%20Notes/", request.url().toString()); + assertEquals(List.of("Meeting Notes.md"), files); + } + + @Test + void shouldReadAndWriteFilesUsingPathSafeVaultEndpoints() throws Exception { + client.enqueueResponse(200, "# Inbox\nBody"); + client.enqueueResponse(204, null); + + String note = client.readNote("Inbox Notes.md"); + client.writeNote("Projects/2026 Notes.md", "Updated body"); + + assertEquals("# Inbox\nBody", note); + + Request readRequest = client.getCapturedRequests().get(0); + assertEquals("GET", readRequest.method()); + assertEquals("https://127.0.0.1:27124/vault/Inbox%20Notes.md", readRequest.url().toString()); + + Request writeRequest = client.getCapturedRequests().get(1); + assertEquals("PUT", writeRequest.method()); + assertEquals("https://127.0.0.1:27124/vault/Projects/2026%20Notes.md", writeRequest.url().toString()); + assertEquals("text/markdown; charset=utf-8", writeRequest.header("Content-Type")); + assertEquals("Updated body", readRequestBody(writeRequest)); + } + + @Test + void shouldMapUnauthorizedResponsesToAuthFailures() { + client.enqueueResponse(401, "{\"message\":\"unauthorized\"}"); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.readNote("Inbox.md")); + + assertEquals(401, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("unauthorized")); + } + + @Test + void shouldSearchSimpleUsingQueryParametersAndParseResults() throws Exception { + client.enqueueResponse(200, """ + { + "results": [ + { + "filename": "Inbox.md", + "score": 0.98, + "matches": [ + { + "context": "Daily review notes", + "start": 6, + "end": 12 + } + ] + } + ] + } + """); + + List results = client.simpleSearch("daily review", 42); + + Request request = client.getCapturedRequests().getFirst(); + assertEquals("POST", request.method()); + assertEquals("https://127.0.0.1:27124/search/simple/?query=daily%20review&contextLength=42", + request.url().toString()); + assertEquals(List.of("Inbox.md"), results.stream().map(ObsidianSearchResult::filename).toList()); + assertEquals(0.98, results.getFirst().score()); + assertEquals("Daily review notes", results.getFirst().matches().getFirst().context()); + } + + private String readRequestBody(Request request) throws IOException { + RequestBody body = request.body(); + if (body == null) { + return ""; + } + try (Buffer buffer = new Buffer()) { + body.writeTo(buffer); + return buffer.readUtf8(); + } + } + + private static final class MockObsidianApiClient extends ObsidianApiClient { + + private final Queue plannedResponses = new ArrayDeque<>(); + private final List capturedRequests = new ArrayList<>(); + + private MockObsidianApiClient(ObsidianPluginConfigService configService) { + super(configService); + } + + @Override + protected Response executeRequest(Request request) { + capturedRequests.add(request); + PlannedResponse plannedResponse = plannedResponses.remove(); + ResponseBody responseBody = ResponseBody.create( + plannedResponse.body() == null ? "" : plannedResponse.body(), + plannedResponse.mediaType()); + return new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(plannedResponse.code()) + .message("mock") + .body(responseBody) + .build(); + } + + private void enqueueResponse(int code, String body) { + plannedResponses.add(new PlannedResponse(code, body, body == null ? APPLICATION_JSON : APPLICATION_JSON)); + } + + private List getCapturedRequests() { + return capturedRequests; + } + } + + private record PlannedResponse(int code, String body, MediaType mediaType) { + } +} From d4bc03ffe9e5bfdb9501d31a0f78ec2144eddb57 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 13:58:25 -0400 Subject: [PATCH 08/22] fix(golemcore/obsidian): align obsidian api client contract --- .../obsidian/model/ObsidianSearchResult.java | 71 +++++- .../obsidian/support/ObsidianApiClient.java | 235 +++++++++--------- .../support/ObsidianTransportException.java | 8 + .../support/ObsidianApiClientTest.java | 64 +++-- 4 files changed, 233 insertions(+), 145 deletions(-) create mode 100644 golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianTransportException.java diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java index 2fafaf2..7bf34d7 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java @@ -1,11 +1,72 @@ package me.golemcore.plugins.golemcore.obsidian.model; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - import java.util.List; -@JsonIgnoreProperties(ignoreUnknown=true)public record ObsidianSearchResult(String filename,double score,Listmatches){ +public final class ObsidianSearchResult { + + private final String filename; + private final double score; + private final List matches; + + public ObsidianSearchResult(String filename, double score, List matches) { + this.filename = filename; + this.score = score; + this.matches = matches == null ? List.of() : List.copyOf(matches); + } + + public String getFilename() { + return filename; + } + + public double getScore() { + return score; + } + + public List getMatches() { + return matches; + } + + public static final class Match { + + private final String context; + private final MatchSpan match; + + public Match(String context, MatchSpan match) { + this.context = context; + this.match = match; + } + + public String getContext() { + return context; + } + + public MatchSpan getMatch() { + return match; + } + } + + public static final class MatchSpan { + + private final Integer start; + private final Integer end; + private final String source; + + public MatchSpan(Integer start, Integer end, String source) { + this.start = start; + this.end = end; + this.source = source; + } + + public Integer getStart() { + return start; + } -public ObsidianSearchResult{matches=matches==null?List.of():List.copyOf(matches);} + public Integer getEnd() { + return end; + } -@JsonIgnoreProperties(ignoreUnknown=true)public record Match(String context,Integer start,Integer end){}} + public String getSource() { + return source; + } + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java index 5efe91c..462e3b1 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java @@ -1,5 +1,6 @@ package me.golemcore.plugins.golemcore.obsidian.support; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import me.golemcore.plugins.golemcore.obsidian.ObsidianPluginConfig; @@ -44,15 +45,21 @@ public ObsidianApiClient(ObsidianPluginConfigService configService) { } public List listDirectory(String path) { - try { - Request request = new Request.Builder() - .url(buildVaultUrl(path, true)) - .header("Accept", "application/json") - .header("Authorization", authorizationHeader()) - .get() - .build(); - try (Response response = executeRequest(request)) { - JsonNode root = readJsonResponse(response); + Request request = new Request.Builder() + .url(buildVaultUrl(path, true)) + .header("Accept", "application/json") + .header("Authorization", authorizationHeader()) + .get() + .build(); + + try (Response response = openResponse(request)) { + String responseBody = readResponseBody(response); + ensureSuccessful(response, responseBody); + if (!hasText(responseBody)) { + return List.of(); + } + try { + JsonNode root = objectMapper.readTree(responseBody); JsonNode filesNode = root.path("files"); if (!filesNode.isArray()) { return List.of(); @@ -62,71 +69,64 @@ public List listDirectory(String path) { files.add(fileNode.asText("")); } return List.copyOf(files); + } catch (JsonProcessingException ex) { + throw new ObsidianApiException(response.code(), + "Invalid JSON response: " + firstText(ex.getOriginalMessage(), "Unexpected response")); } - } catch (IOException ex) { - throw new ObsidianApiException(500, "Obsidian request failed: " + ex.getMessage()); } } public String readNote(String path) { - try { - Request request = new Request.Builder() - .url(buildVaultUrl(path, false)) - .header("Accept", "application/vnd.olrapi.note+json, text/markdown") - .header("Authorization", authorizationHeader()) - .get() - .build(); - try (Response response = executeRequest(request); - ResponseBody body = response.body()) { - String responseBody = body != null ? body.string() : ""; - ensureSuccess(response, responseBody); - if (!hasText(responseBody)) { - return ""; - } - JsonNode root = tryReadJson(responseBody); - if (root != null && root.hasNonNull("content")) { + Request request = new Request.Builder() + .url(buildVaultUrl(path, false)) + .header("Accept", "application/vnd.olrapi.note+json") + .header("Authorization", authorizationHeader()) + .get() + .build(); + + try (Response response = openResponse(request)) { + String responseBody = readResponseBody(response); + ensureSuccessful(response, responseBody); + if (!hasText(responseBody)) { + return ""; + } + try { + JsonNode root = objectMapper.readTree(responseBody); + if (root.hasNonNull("content")) { return root.path("content").asText(""); } return responseBody; + } catch (JsonProcessingException ex) { + return responseBody; } - } catch (IOException ex) { - throw new ObsidianApiException(500, "Obsidian request failed: " + ex.getMessage()); } } public void writeNote(String path, String content) { - try { - Request request = new Request.Builder() - .url(buildVaultUrl(path, false)) - .header("Accept", "application/json") - .header("Authorization", authorizationHeader()) - .header("Content-Type", TEXT_MARKDOWN.toString()) - .put(RequestBody.create(content != null ? content : "", TEXT_MARKDOWN)) - .build(); - try (Response response = executeRequest(request); - ResponseBody body = response.body()) { - String responseBody = body != null ? body.string() : ""; - ensureSuccess(response, responseBody); - } - } catch (IOException ex) { - throw new ObsidianApiException(500, "Obsidian request failed: " + ex.getMessage()); + Request request = new Request.Builder() + .url(buildVaultUrl(path, false)) + .header("Accept", "application/json") + .header("Authorization", authorizationHeader()) + .header("Content-Type", TEXT_MARKDOWN.toString()) + .put(RequestBody.create(content != null ? content : "", TEXT_MARKDOWN)) + .build(); + + try (Response response = openResponse(request)) { + String responseBody = readResponseBody(response); + ensureSuccessful(response, responseBody); } } public void deleteNote(String path) { - try { - Request request = new Request.Builder() - .url(buildVaultUrl(path, false)) - .header("Authorization", authorizationHeader()) - .delete() - .build(); - try (Response response = executeRequest(request); - ResponseBody body = response.body()) { - String responseBody = body != null ? body.string() : ""; - ensureSuccess(response, responseBody); - } - } catch (IOException ex) { - throw new ObsidianApiException(500, "Obsidian request failed: " + ex.getMessage()); + Request request = new Request.Builder() + .url(buildVaultUrl(path, false)) + .header("Authorization", authorizationHeader()) + .delete() + .build(); + + try (Response response = openResponse(request)) { + String responseBody = readResponseBody(response); + ensureSuccessful(response, responseBody); } } @@ -136,25 +136,29 @@ public List simpleSearch(String query, int contextLength) } int effectiveContextLength = contextLength > 0 ? contextLength : getConfig().getDefaultSearchContextLength(); - try { - Request request = new Request.Builder() - .url(buildSearchUrl(query, effectiveContextLength)) - .header("Accept", "application/json") - .header("Authorization", authorizationHeader()) - .post(RequestBody.create("", APPLICATION_JSON)) - .build(); - try (Response response = executeRequest(request); - ResponseBody body = response.body()) { - String responseBody = body != null ? body.string() : ""; - ensureSuccess(response, responseBody); - if (!hasText(responseBody)) { + Request request = new Request.Builder() + .url(buildSearchUrl(query, effectiveContextLength)) + .header("Accept", "application/json") + .header("Authorization", authorizationHeader()) + .post(RequestBody.create("", APPLICATION_JSON)) + .build(); + + try (Response response = openResponse(request)) { + String responseBody = readResponseBody(response); + ensureSuccessful(response, responseBody); + if (!hasText(responseBody)) { + return List.of(); + } + try { + JsonNode root = objectMapper.readTree(responseBody); + if (!root.isArray()) { return List.of(); } - JsonNode root = objectMapper.readTree(responseBody); return parseSearchResults(root); + } catch (JsonProcessingException ex) { + throw new ObsidianApiException(response.code(), + "Invalid JSON response: " + firstText(ex.getOriginalMessage(), "Unexpected response")); } - } catch (IOException ex) { - throw new ObsidianApiException(500, "Obsidian request failed: " + ex.getMessage()); } } @@ -162,6 +166,22 @@ protected Response executeRequest(Request request) throws IOException { return buildHttpClient(getConfig()).newCall(request).execute(); } + private Response openResponse(Request request) { + try { + return executeRequest(request); + } catch (IOException ex) { + throw new ObsidianTransportException(transportMessage(ex), ex); + } + } + + private String readResponseBody(Response response) { + try (ResponseBody body = response.body()) { + return body != null ? body.string() : ""; + } catch (IOException ex) { + throw new ObsidianTransportException(transportMessage(ex), ex); + } + } + private ObsidianPluginConfig getConfig() { return configService.getConfig(); } @@ -261,18 +281,7 @@ public X509Certificate[] getAcceptedIssuers() { return builder.build(); } - private JsonNode readJsonResponse(Response response) throws IOException { - try (ResponseBody body = response.body()) { - String responseBody = body != null ? body.string() : ""; - ensureSuccess(response, responseBody); - if (!hasText(responseBody)) { - return objectMapper.createObjectNode(); - } - return objectMapper.readTree(responseBody); - } - } - - private void ensureSuccess(Response response, String responseBody) { + private void ensureSuccessful(Response response, String responseBody) { if (response.isSuccessful()) { return; } @@ -283,52 +292,30 @@ private String extractErrorMessage(String responseBody, int statusCode) { if (!hasText(responseBody)) { return "HTTP " + statusCode; } - JsonNode root = tryReadJson(responseBody); - if (root != null) { + try { + JsonNode root = objectMapper.readTree(responseBody); String message = firstText(root.path("message").asText(null), root.path("error").asText(null), root.path("status").asText(null)); if (hasText(message)) { return message; } + } catch (JsonProcessingException ignored) { + // Fall through to the raw response body. } return responseBody.trim(); } - private JsonNode tryReadJson(String responseBody) { - try { - return objectMapper.readTree(responseBody); - } catch (IOException ignored) { - return null; - } - } - - private List parseSearchResults(JsonNode root) { - JsonNode resultsNode = root.path("results"); - if (resultsNode.isArray()) { - return parseSearchResultsArray(resultsNode); - } - JsonNode matchesNode = root.path("matches"); - if (matchesNode.isArray()) { - return parseSearchResultsArray(matchesNode); - } - if (root.isArray()) { - return parseSearchResultsArray(root); - } - return List.of(); - } - - private List parseSearchResultsArray(JsonNode arrayNode) { + private List parseSearchResults(JsonNode arrayNode) { List results = new ArrayList<>(arrayNode.size()); for (JsonNode resultNode : arrayNode) { - results.add(new ObsidianSearchResult( - firstText(resultNode.path("filename").asText(null), - resultNode.path("path").asText(null), - resultNode.path("file").asText(null), - resultNode.path("title").asText(null), - ""), - resultNode.path("score").isNumber() ? resultNode.path("score").asDouble() : 0.0d, - parseMatches(resultNode.path("matches")))); + String filename = firstText(resultNode.path("filename").asText(null), + resultNode.path("path").asText(null), + resultNode.path("file").asText(null), + resultNode.path("title").asText(null), + ""); + double score = resultNode.path("score").isNumber() ? resultNode.path("score").asDouble() : 0.0d; + results.add(new ObsidianSearchResult(filename, score, parseMatches(resultNode.path("matches")))); } return List.copyOf(results); } @@ -339,10 +326,13 @@ private List parseMatches(JsonNode matchesNode) { } List matches = new ArrayList<>(matchesNode.size()); for (JsonNode matchNode : matchesNode) { + JsonNode spanNode = matchNode.path("match"); matches.add(new ObsidianSearchResult.Match( matchNode.path("context").asText(""), - matchNode.path("start").isNumber() ? matchNode.path("start").asInt() : null, - matchNode.path("end").isNumber() ? matchNode.path("end").asInt() : null)); + new ObsidianSearchResult.MatchSpan( + spanNode.path("start").isNumber() ? spanNode.path("start").asInt() : null, + spanNode.path("end").isNumber() ? spanNode.path("end").asInt() : null, + spanNode.path("source").asText("")))); } return List.copyOf(matches); } @@ -351,6 +341,11 @@ private String authorizationHeader() { return "Bearer " + getConfig().getApiKey(); } + private String transportMessage(IOException ex) { + String message = ex.getMessage(); + return hasText(message) ? "Obsidian transport failed: " + message : "Obsidian transport failed"; + } + private String firstText(String... values) { for (String value : values) { if (hasText(value)) { diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianTransportException.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianTransportException.java new file mode 100644 index 0000000..5d19995 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianTransportException.java @@ -0,0 +1,8 @@ +package me.golemcore.plugins.golemcore.obsidian.support; + +public class ObsidianTransportException extends RuntimeException { + + public ObsidianTransportException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java index e76f778..2dd1a88 100644 --- a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java @@ -20,6 +20,7 @@ import java.util.Queue; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -86,6 +87,7 @@ void shouldReadAndWriteFilesUsingPathSafeVaultEndpoints() throws Exception { Request readRequest = client.getCapturedRequests().get(0); assertEquals("GET", readRequest.method()); assertEquals("https://127.0.0.1:27124/vault/Inbox%20Notes.md", readRequest.url().toString()); + assertEquals("application/vnd.olrapi.note+json", readRequest.header("Accept")); Request writeRequest = client.getCapturedRequests().get(1); assertEquals("PUT", writeRequest.method()); @@ -106,23 +108,35 @@ void shouldMapUnauthorizedResponsesToAuthFailures() { } @Test - void shouldSearchSimpleUsingQueryParametersAndParseResults() throws Exception { + void shouldWrapTransportFailuresWithoutSyntheticHttpStatus() { + client.enqueueFailure(new IOException("connect timed out")); + + ObsidianTransportException exception = assertThrows(ObsidianTransportException.class, + () -> client.readNote("Inbox.md")); + + assertTrue(exception.getMessage().contains("connect timed out")); + assertInstanceOf(IOException.class, exception.getCause()); + } + + @Test + void shouldSearchSimpleUsingTopLevelArrayAndNestedMatchContract() throws Exception { client.enqueueResponse(200, """ - { - "results": [ - { - "filename": "Inbox.md", - "score": 0.98, - "matches": [ - { - "context": "Daily review notes", + [ + { + "filename": "Inbox.md", + "score": 0.98, + "matches": [ + { + "context": "Daily review notes", + "match": { "start": 6, - "end": 12 + "end": 12, + "source": "Inbox.md" } - ] - } - ] - } + } + ] + } + ] """); List results = client.simpleSearch("daily review", 42); @@ -131,9 +145,12 @@ void shouldSearchSimpleUsingQueryParametersAndParseResults() throws Exception { assertEquals("POST", request.method()); assertEquals("https://127.0.0.1:27124/search/simple/?query=daily%20review&contextLength=42", request.url().toString()); - assertEquals(List.of("Inbox.md"), results.stream().map(ObsidianSearchResult::filename).toList()); - assertEquals(0.98, results.getFirst().score()); - assertEquals("Daily review notes", results.getFirst().matches().getFirst().context()); + assertEquals(List.of("Inbox.md"), results.stream().map(ObsidianSearchResult::getFilename).toList()); + assertEquals(0.98, results.getFirst().getScore()); + assertEquals("Daily review notes", results.getFirst().getMatches().getFirst().getContext()); + assertEquals(6, results.getFirst().getMatches().getFirst().getMatch().getStart()); + assertEquals(12, results.getFirst().getMatches().getFirst().getMatch().getEnd()); + assertEquals("Inbox.md", results.getFirst().getMatches().getFirst().getMatch().getSource()); } private String readRequestBody(Request request) throws IOException { @@ -157,9 +174,12 @@ private MockObsidianApiClient(ObsidianPluginConfigService configService) { } @Override - protected Response executeRequest(Request request) { + protected Response executeRequest(Request request) throws IOException { capturedRequests.add(request); PlannedResponse plannedResponse = plannedResponses.remove(); + if (plannedResponse.failure() != null) { + throw plannedResponse.failure(); + } ResponseBody responseBody = ResponseBody.create( plannedResponse.body() == null ? "" : plannedResponse.body(), plannedResponse.mediaType()); @@ -173,7 +193,11 @@ protected Response executeRequest(Request request) { } private void enqueueResponse(int code, String body) { - plannedResponses.add(new PlannedResponse(code, body, body == null ? APPLICATION_JSON : APPLICATION_JSON)); + plannedResponses.add(new PlannedResponse(code, body, APPLICATION_JSON, null)); + } + + private void enqueueFailure(IOException failure) { + plannedResponses.add(new PlannedResponse(0, null, null, failure)); } private List getCapturedRequests() { @@ -181,6 +205,6 @@ private List getCapturedRequests() { } } - private record PlannedResponse(int code, String body, MediaType mediaType) { + private record PlannedResponse(int code, String body, MediaType mediaType, IOException failure) { } } From b68e7e635a549353a869cf7220b1edfb710e7e96 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 14:08:41 -0400 Subject: [PATCH 09/22] fix(golemcore/obsidian): tighten obsidian api contract --- .../obsidian/support/ObsidianApiClient.java | 6 ++- .../support/ObsidianApiClientTest.java | 40 +++++++++++++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java index 462e3b1..f52a299 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java @@ -62,7 +62,8 @@ public List listDirectory(String path) { JsonNode root = objectMapper.readTree(responseBody); JsonNode filesNode = root.path("files"); if (!filesNode.isArray()) { - return List.of(); + throw new ObsidianApiException(response.code(), + "Invalid vault directory response: expected {files: [...]}"); } List files = new ArrayList<>(filesNode.size()); for (JsonNode fileNode : filesNode) { @@ -152,7 +153,8 @@ public List simpleSearch(String query, int contextLength) try { JsonNode root = objectMapper.readTree(responseBody); if (!root.isArray()) { - return List.of(); + throw new ObsidianApiException(response.code(), + "Invalid search response: expected top-level array"); } return parseSearchResults(root); } catch (JsonProcessingException ex) { diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java index 2dd1a88..478fedb 100644 --- a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java @@ -76,7 +76,19 @@ void shouldListNestedDirectoryUsingPathSafeVaultEndpoint() throws Exception { @Test void shouldReadAndWriteFilesUsingPathSafeVaultEndpoints() throws Exception { - client.enqueueResponse(200, "# Inbox\nBody"); + client.enqueueResponse(200, """ + { + "content": "# Inbox\\nBody", + "frontmatter": {}, + "path": "Inbox Notes.md", + "stat": { + "ctime": 1, + "mtime": 2, + "size": 11 + }, + "tags": [] + } + """); client.enqueueResponse(204, null); String note = client.readNote("Inbox Notes.md"); @@ -131,7 +143,7 @@ void shouldSearchSimpleUsingTopLevelArrayAndNestedMatchContract() throws Excepti "match": { "start": 6, "end": 12, - "source": "Inbox.md" + "source": "content" } } ] @@ -150,7 +162,29 @@ void shouldSearchSimpleUsingTopLevelArrayAndNestedMatchContract() throws Excepti assertEquals("Daily review notes", results.getFirst().getMatches().getFirst().getContext()); assertEquals(6, results.getFirst().getMatches().getFirst().getMatch().getStart()); assertEquals(12, results.getFirst().getMatches().getFirst().getMatch().getEnd()); - assertEquals("Inbox.md", results.getFirst().getMatches().getFirst().getMatch().getSource()); + assertEquals("content", results.getFirst().getMatches().getFirst().getMatch().getSource()); + } + + @Test + void shouldFailFastWhenSuccessfulListResponseHasWrongShape() { + client.enqueueResponse(200, "{\"files\":null}"); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.listDirectory("")); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("Invalid")); + } + + @Test + void shouldFailFastWhenSuccessfulSearchResponseHasWrongShape() { + client.enqueueResponse(200, "{\"results\":[]}"); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.simpleSearch("daily review", 42)); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("array")); } private String readRequestBody(Request request) throws IOException { From 2dcf72bad4ab93585169618e00189d78a88c62d6 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 14:15:56 -0400 Subject: [PATCH 10/22] fix(golemcore/obsidian): fail fast on malformed obsidian payloads --- .../obsidian/support/ObsidianApiClient.java | 66 ++++++++++------- .../support/ObsidianApiClientTest.java | 70 +++++++++++++++++++ 2 files changed, 111 insertions(+), 25 deletions(-) diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java index f52a299..52bf242 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java @@ -93,12 +93,15 @@ public String readNote(String path) { } try { JsonNode root = objectMapper.readTree(responseBody); - if (root.hasNonNull("content")) { - return root.path("content").asText(""); + JsonNode contentNode = root.get("content"); + if (contentNode == null || contentNode.isNull() || !contentNode.isTextual()) { + throw new ObsidianApiException(response.code(), + "Invalid note response: expected textual content"); } - return responseBody; + return contentNode.asText(""); } catch (JsonProcessingException ex) { - return responseBody; + throw new ObsidianApiException(response.code(), + "Invalid note response: expected JSON with content"); } } } @@ -147,16 +150,17 @@ public List simpleSearch(String query, int contextLength) try (Response response = openResponse(request)) { String responseBody = readResponseBody(response); ensureSuccessful(response, responseBody); - if (!hasText(responseBody)) { - return List.of(); - } try { + if (!hasText(responseBody)) { + throw new ObsidianApiException(response.code(), + "Invalid search response: expected top-level array"); + } JsonNode root = objectMapper.readTree(responseBody); if (!root.isArray()) { throw new ObsidianApiException(response.code(), "Invalid search response: expected top-level array"); } - return parseSearchResults(root); + return parseSearchResults(root, response.code()); } catch (JsonProcessingException ex) { throw new ObsidianApiException(response.code(), "Invalid JSON response: " + firstText(ex.getOriginalMessage(), "Unexpected response")); @@ -308,33 +312,45 @@ private String extractErrorMessage(String responseBody, int statusCode) { return responseBody.trim(); } - private List parseSearchResults(JsonNode arrayNode) { + private List parseSearchResults(JsonNode arrayNode, int statusCode) { List results = new ArrayList<>(arrayNode.size()); for (JsonNode resultNode : arrayNode) { - String filename = firstText(resultNode.path("filename").asText(null), - resultNode.path("path").asText(null), - resultNode.path("file").asText(null), - resultNode.path("title").asText(null), - ""); + JsonNode filenameNode = resultNode.get("filename"); + if (filenameNode == null || !filenameNode.isTextual() || !hasText(filenameNode.asText())) { + throw new ObsidianApiException(statusCode, "Invalid search result: missing filename"); + } double score = resultNode.path("score").isNumber() ? resultNode.path("score").asDouble() : 0.0d; - results.add(new ObsidianSearchResult(filename, score, parseMatches(resultNode.path("matches")))); + JsonNode matchesNode = resultNode.get("matches"); + if (matchesNode == null || !matchesNode.isArray()) { + throw new ObsidianApiException(statusCode, "Invalid search result: expected matches array"); + } + results.add(new ObsidianSearchResult(filenameNode.asText(), score, parseMatches(matchesNode, statusCode))); } return List.copyOf(results); } - private List parseMatches(JsonNode matchesNode) { - if (!matchesNode.isArray()) { - return List.of(); - } + private List parseMatches(JsonNode matchesNode, int statusCode) { List matches = new ArrayList<>(matchesNode.size()); for (JsonNode matchNode : matchesNode) { - JsonNode spanNode = matchNode.path("match"); + JsonNode contextNode = matchNode.get("context"); + if (contextNode == null || !contextNode.isTextual()) { + throw new ObsidianApiException(statusCode, "Invalid search match: missing context"); + } + JsonNode spanNode = matchNode.get("match"); + if (spanNode == null || !spanNode.isObject()) { + throw new ObsidianApiException(statusCode, "Invalid search match: expected match object"); + } + JsonNode startNode = spanNode.get("start"); + JsonNode endNode = spanNode.get("end"); + JsonNode sourceNode = spanNode.get("source"); + if (startNode == null || !startNode.isNumber() + || endNode == null || !endNode.isNumber() + || sourceNode == null || !sourceNode.isTextual()) { + throw new ObsidianApiException(statusCode, "Invalid search match: incomplete span"); + } matches.add(new ObsidianSearchResult.Match( - matchNode.path("context").asText(""), - new ObsidianSearchResult.MatchSpan( - spanNode.path("start").isNumber() ? spanNode.path("start").asInt() : null, - spanNode.path("end").isNumber() ? spanNode.path("end").asInt() : null, - spanNode.path("source").asText("")))); + contextNode.asText(), + new ObsidianSearchResult.MatchSpan(startNode.asInt(), endNode.asInt(), sourceNode.asText()))); } return List.copyOf(matches); } diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java index 478fedb..48265f4 100644 --- a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java @@ -108,6 +108,37 @@ void shouldReadAndWriteFilesUsingPathSafeVaultEndpoints() throws Exception { assertEquals("Updated body", readRequestBody(writeRequest)); } + @Test + void shouldFailFastWhenSuccessfulReadNoteResponseIsMissingContent() { + client.enqueueResponse(200, """ + { + "path": "Inbox Notes.md", + "stat": { + "ctime": 1, + "mtime": 2, + "size": 11 + } + } + """); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.readNote("Inbox Notes.md")); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("content")); + } + + @Test + void shouldFailFastWhenSuccessfulReadNoteResponseIsMalformed() { + client.enqueueResponse(200, "{not-json"); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.readNote("Inbox Notes.md")); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("JSON")); + } + @Test void shouldMapUnauthorizedResponsesToAuthFailures() { client.enqueueResponse(401, "{\"message\":\"unauthorized\"}"); @@ -165,6 +196,34 @@ void shouldSearchSimpleUsingTopLevelArrayAndNestedMatchContract() throws Excepti assertEquals("content", results.getFirst().getMatches().getFirst().getMatch().getSource()); } + @Test + void shouldFailFastWhenSuccessfulSearchResponseUsesMissingFilename() { + client.enqueueResponse(200, """ + [ + { + "path": "Inbox.md", + "score": 0.98, + "matches": [ + { + "context": "Daily review notes", + "match": { + "start": 6, + "end": 12, + "source": "content" + } + } + ] + } + ] + """); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.simpleSearch("daily review", 42)); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("filename")); + } + @Test void shouldFailFastWhenSuccessfulListResponseHasWrongShape() { client.enqueueResponse(200, "{\"files\":null}"); @@ -187,6 +246,17 @@ void shouldFailFastWhenSuccessfulSearchResponseHasWrongShape() { assertTrue(exception.getMessage().contains("array")); } + @Test + void shouldFailFastWhenSuccessfulSearchResponseIsBlank() { + client.enqueueResponse(200, ""); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.simpleSearch("daily review", 42)); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("array")); + } + private String readRequestBody(Request request) throws IOException { RequestBody body = request.body(); if (body == null) { From 5e62ca0ce60e9f712e4f9f50e5aaa846e7360729 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 14:19:25 -0400 Subject: [PATCH 11/22] fix(golemcore/obsidian): reject blank note bodies --- .../plugins/golemcore/obsidian/support/ObsidianApiClient.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java index 52bf242..9ec9523 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java @@ -89,7 +89,8 @@ public String readNote(String path) { String responseBody = readResponseBody(response); ensureSuccessful(response, responseBody); if (!hasText(responseBody)) { - return ""; + throw new ObsidianApiException(response.code(), + "Invalid note response: expected JSON with content"); } try { JsonNode root = objectMapper.readTree(responseBody); From b0499877d46e70e3d3fa25dcff8cb36ec8db1be6 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 14:20:43 -0400 Subject: [PATCH 12/22] fix(golemcore/obsidian): allow optional search source --- .../obsidian/support/ObsidianApiClient.java | 8 ++-- .../support/ObsidianApiClientTest.java | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java index 9ec9523..507e201 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java @@ -345,13 +345,15 @@ private List parseMatches(JsonNode matchesNode, int JsonNode endNode = spanNode.get("end"); JsonNode sourceNode = spanNode.get("source"); if (startNode == null || !startNode.isNumber() - || endNode == null || !endNode.isNumber() - || sourceNode == null || !sourceNode.isTextual()) { + || endNode == null || !endNode.isNumber()) { throw new ObsidianApiException(statusCode, "Invalid search match: incomplete span"); } + String source = sourceNode != null && sourceNode.isTextual() && hasText(sourceNode.asText()) + ? sourceNode.asText() + : null; matches.add(new ObsidianSearchResult.Match( contextNode.asText(), - new ObsidianSearchResult.MatchSpan(startNode.asInt(), endNode.asInt(), sourceNode.asText()))); + new ObsidianSearchResult.MatchSpan(startNode.asInt(), endNode.asInt(), source))); } return List.copyOf(matches); } diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java index 48265f4..901a081 100644 --- a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java @@ -139,6 +139,17 @@ void shouldFailFastWhenSuccessfulReadNoteResponseIsMalformed() { assertTrue(exception.getMessage().contains("JSON")); } + @Test + void shouldFailFastWhenSuccessfulReadNoteResponseIsBlank() { + client.enqueueResponse(200, ""); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.readNote("Inbox Notes.md")); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("content")); + } + @Test void shouldMapUnauthorizedResponsesToAuthFailures() { client.enqueueResponse(401, "{\"message\":\"unauthorized\"}"); @@ -196,6 +207,36 @@ void shouldSearchSimpleUsingTopLevelArrayAndNestedMatchContract() throws Excepti assertEquals("content", results.getFirst().getMatches().getFirst().getMatch().getSource()); } + @Test + void shouldAllowSearchMatchesWithoutOptionalSource() throws Exception { + client.enqueueResponse(200, """ + [ + { + "filename": "Inbox.md", + "score": 0.91, + "matches": [ + { + "context": "Daily review notes", + "match": { + "start": 6, + "end": 12 + } + } + ] + } + ] + """); + + List results = client.simpleSearch("daily review", 42); + + assertEquals("Inbox.md", results.getFirst().getFilename()); + assertEquals(0.91, results.getFirst().getScore()); + assertEquals("Daily review notes", results.getFirst().getMatches().getFirst().getContext()); + assertEquals(6, results.getFirst().getMatches().getFirst().getMatch().getStart()); + assertEquals(12, results.getFirst().getMatches().getFirst().getMatch().getEnd()); + assertEquals(null, results.getFirst().getMatches().getFirst().getMatch().getSource()); + } + @Test void shouldFailFastWhenSuccessfulSearchResponseUsesMissingFilename() { client.enqueueResponse(200, """ From 0a9c910064be5c4a484192f164a443585f6d6fb6 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 14:29:32 -0400 Subject: [PATCH 13/22] feat(golemcore/obsidian): add vault operation service --- .../obsidian/ObsidianVaultService.java | 178 +++++++++++ .../support/ObsidianPathValidator.java | 76 +++++ .../obsidian/ObsidianVaultServiceTest.java | 290 ++++++++++++++++++ 3 files changed, 544 insertions(+) create mode 100644 golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java create mode 100644 golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianPathValidator.java create mode 100644 golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java new file mode 100644 index 0000000..582084a --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java @@ -0,0 +1,178 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugins.golemcore.obsidian.model.ObsidianSearchResult; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiClient; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiException; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianPathValidator; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianTransportException; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class ObsidianVaultService { + + private final ObsidianApiClient apiClient; + private final ObsidianPluginConfigService configService; + private final ObsidianPathValidator pathValidator = new ObsidianPathValidator(); + + public ObsidianVaultService(ObsidianApiClient apiClient, ObsidianPluginConfigService configService) { + this.apiClient = apiClient; + this.configService = configService; + } + + public ToolResult listDirectory(String path) { + try { + String normalizedPath = pathValidator.normalizeDirectoryPath(path); + List entries = apiClient.listDirectory(normalizedPath); + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("entries", entries); + return ToolResult.success( + "Listed " + entries.size() + " item(s) in " + displayPath(normalizedPath), + data); + } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult readNote(String path) { + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + String content = apiClient.readNote(normalizedPath); + int originalLength = content.length(); + int maxReadChars = configService.getConfig().getMaxReadChars(); + boolean truncated = originalLength > maxReadChars; + String visibleContent = truncated ? content.substring(0, maxReadChars) : content; + + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("content", visibleContent); + data.put("truncated", truncated); + if (truncated) { + data.put("originalLength", originalLength); + } + return ToolResult.success(visibleContent, data); + } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult searchNotes(String query, Integer contextLength) { + try { + if (query == null || query.isBlank()) { + throw new IllegalArgumentException("Search query is required"); + } + int requestedContextLength = contextLength != null ? contextLength : 0; + List results = apiClient.simpleSearch(query.trim(), requestedContextLength); + Map data = new LinkedHashMap<>(); + data.put("query", query.trim()); + data.put("count", results.size()); + data.put("results", results); + return ToolResult.success("Found " + results.size() + " note(s) for query: " + query.trim(), data); + } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult createNote(String path, String content) { + return writeNote(path, content, "Created"); + } + + public ToolResult updateNote(String path, String content) { + return writeNote(path, content, "Updated"); + } + + public ToolResult deleteNote(String path) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowDelete())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Obsidian delete is disabled in plugin settings"); + } + + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + apiClient.deleteNote(normalizedPath); + return ToolResult.success("Deleted note " + normalizedPath, Map.of("path", normalizedPath)); + } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult moveNote(String path, String targetPath) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowMove())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Obsidian move is disabled in plugin settings"); + } + + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + String normalizedTargetPath = pathValidator.normalizeNotePath(targetPath); + return relocateNote(normalizedPath, normalizedTargetPath, "Moved"); + } catch (IllegalArgumentException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult renameNote(String path, String newName) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowRename())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Obsidian rename is disabled in plugin settings"); + } + + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + String targetPath = pathValidator.resolveSiblingNotePath(normalizedPath, newName); + return relocateNote(normalizedPath, targetPath, "Renamed"); + } catch (IllegalArgumentException ex) { + return executionFailure(ex.getMessage()); + } + } + + private ToolResult writeNote(String path, String content, String verb) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Obsidian write is disabled in plugin settings"); + } + + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + apiClient.writeNote(normalizedPath, content != null ? content : ""); + return ToolResult.success(verb + " note " + normalizedPath, Map.of("path", normalizedPath)); + } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + private ToolResult relocateNote(String sourcePath, String targetPath, String verb) { + try { + String content = apiClient.readNote(sourcePath); + apiClient.writeNote(targetPath, content); + try { + apiClient.deleteNote(sourcePath); + } catch (ObsidianApiException | ObsidianTransportException ex) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, + "Obsidian " + verb.toLowerCase() + " partially failed after writing " + + targetPath + "; both source and target may now exist: " + ex.getMessage()); + } + + Map data = new LinkedHashMap<>(); + data.put("sourcePath", sourcePath); + data.put("targetPath", targetPath); + return ToolResult.success(verb + " note from " + sourcePath + " to " + targetPath, data); + } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + private ToolResult executionFailure(String message) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, message); + } + + private String displayPath(String normalizedPath) { + return normalizedPath.isBlank() ? "/" : normalizedPath; + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianPathValidator.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianPathValidator.java new file mode 100644 index 0000000..89680aa --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianPathValidator.java @@ -0,0 +1,76 @@ +package me.golemcore.plugins.golemcore.obsidian.support; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Locale; + +public final class ObsidianPathValidator { + + public String normalizeDirectoryPath(String path) { + return normalizeRelativePath(path, true); + } + + public String normalizeNotePath(String path) { + String normalized = normalizeRelativePath(path, false); + if (!normalized.toLowerCase(Locale.ROOT).endsWith(".md")) { + throw new IllegalArgumentException("Obsidian note paths must end with .md"); + } + return normalized; + } + + public String normalizeNewName(String newName) { + if (newName == null || newName.isBlank()) { + throw new IllegalArgumentException("newName is required"); + } + String normalized = newName.trim(); + if (normalized.contains("/") || normalized.contains("\\")) { + throw new IllegalArgumentException("newName must not contain path separators"); + } + if (!normalized.toLowerCase(Locale.ROOT).endsWith(".md")) { + throw new IllegalArgumentException("newName must end with .md"); + } + return normalized; + } + + public String resolveSiblingNotePath(String sourcePath, String newName) { + String normalizedSourcePath = normalizeNotePath(sourcePath); + String normalizedNewName = normalizeNewName(newName); + int separatorIndex = normalizedSourcePath.lastIndexOf('/'); + if (separatorIndex < 0) { + return normalizedNewName; + } + return normalizedSourcePath.substring(0, separatorIndex + 1) + normalizedNewName; + } + + private String normalizeRelativePath(String path, boolean allowBlank) { + if (path == null || path.isBlank()) { + if (allowBlank) { + return ""; + } + throw new IllegalArgumentException("Path is required"); + } + + String candidate = path.trim().replace('\\', '/'); + Deque segments = new ArrayDeque<>(); + for (String rawSegment : candidate.split("/")) { + String segment = rawSegment.trim(); + if (segment.isEmpty() || ".".equals(segment)) { + continue; + } + if ("..".equals(segment)) { + if (segments.isEmpty()) { + throw new IllegalArgumentException("Path must stay within the vault root"); + } + segments.removeLast(); + continue; + } + segments.addLast(segment); + } + + String normalized = String.join("/", segments); + if (!allowBlank && normalized.isBlank()) { + throw new IllegalArgumentException("Path is required"); + } + return normalized; + } +} diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java new file mode 100644 index 0000000..1f92603 --- /dev/null +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java @@ -0,0 +1,290 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugins.golemcore.obsidian.model.ObsidianSearchResult; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiClient; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +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.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ObsidianVaultServiceTest { + + private ObsidianPluginConfigService configService; + private StubObsidianApiClient apiClient; + private ObsidianVaultService service; + + @BeforeEach + void setUp() { + configService = mock(ObsidianPluginConfigService.class); + when(configService.getConfig()).thenReturn(config(true, true, true, true, 12_000)); + apiClient = new StubObsidianApiClient(configService); + service = new ObsidianVaultService(apiClient, configService); + } + + @Test + void shouldListVaultRootDirectory() { + apiClient.listDirectoryResults.put("", List.of("Inbox.md", "Projects/")); + + ToolResult result = service.listDirectory(""); + + assertTrue(result.isSuccess()); + assertEquals(List.of(""), apiClient.listDirectoryCalls); + assertEquals("Listed 2 item(s) in /", result.getOutput()); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("", data.get("path")); + assertEquals(List.of("Inbox.md", "Projects/"), data.get("entries")); + } + + @Test + void shouldNormalizeNotePathsBeforeUpdating() { + ToolResult result = service.updateNote("./Projects/../Inbox.md", "# Inbox"); + + assertTrue(result.isSuccess()); + assertEquals(List.of("Inbox.md"), apiClient.writePaths); + assertEquals(List.of("# Inbox"), apiClient.writeContents); + } + + @Test + void shouldRejectBlankNotePathBeforeCallingApi() { + ToolResult result = service.readNote(" "); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("Path is required")); + assertTrue(apiClient.readPaths.isEmpty()); + } + + @Test + void shouldRejectNotePathsWithoutMarkdownExtensionBeforeCallingApi() { + ToolResult result = service.createNote("Projects/Todo.txt", "# Todo"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains(".md")); + assertTrue(apiClient.writePaths.isEmpty()); + } + + @Test + void shouldDenyCreateWhenWriteIsDisabled() { + when(configService.getConfig()).thenReturn(config(false, true, true, true, 12_000)); + + ToolResult result = service.createNote("Projects/Todo.md", "# Todo"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + assertTrue(result.getError().contains("write is disabled")); + assertTrue(apiClient.writePaths.isEmpty()); + } + + @Test + void shouldDenyDeleteWhenDeleteIsDisabled() { + when(configService.getConfig()).thenReturn(config(true, false, true, true, 12_000)); + + ToolResult result = service.deleteNote("Projects/Todo.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + assertTrue(result.getError().contains("delete is disabled")); + assertTrue(apiClient.deletePaths.isEmpty()); + } + + @Test + void shouldDenyMoveWhenMoveIsDisabled() { + when(configService.getConfig()).thenReturn(config(true, true, false, true, 12_000)); + + ToolResult result = service.moveNote("Projects/Todo.md", "Archive/Todo.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + assertTrue(result.getError().contains("move is disabled")); + assertTrue(apiClient.readPaths.isEmpty()); + assertTrue(apiClient.writePaths.isEmpty()); + assertTrue(apiClient.deletePaths.isEmpty()); + } + + @Test + void shouldDenyRenameWhenRenameIsDisabled() { + when(configService.getConfig()).thenReturn(config(true, true, true, false, 12_000)); + + ToolResult result = service.renameNote("Projects/Todo.md", "Done.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + assertTrue(result.getError().contains("rename is disabled")); + assertTrue(apiClient.readPaths.isEmpty()); + assertTrue(apiClient.writePaths.isEmpty()); + assertTrue(apiClient.deletePaths.isEmpty()); + } + + @Test + void shouldRejectRenameWhenNewNameContainsPathSeparators() { + ToolResult result = service.renameNote("Projects/Todo.md", "Archive/Done.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("newName")); + assertTrue(apiClient.readPaths.isEmpty()); + } + + @Test + void shouldTruncateReadNoteContentAndExposeStructuredFlag() { + when(configService.getConfig()).thenReturn(config(true, true, true, true, 5)); + apiClient.noteContents.put("Inbox.md", "123456789"); + + ToolResult result = service.readNote("Inbox.md"); + + assertTrue(result.isSuccess()); + assertEquals("12345", result.getOutput()); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("Inbox.md", data.get("path")); + assertEquals("12345", data.get("content")); + assertEquals(true, data.get("truncated")); + assertEquals(9, data.get("originalLength")); + } + + @Test + void shouldSearchNotesUsingRequestedContextLength() { + apiClient.searchResults = List.of(new ObsidianSearchResult( + "Inbox.md", + 0.91d, + List.of(new ObsidianSearchResult.Match( + "Daily review notes", + new ObsidianSearchResult.MatchSpan(6, 12, "content"))))); + + ToolResult result = service.searchNotes("daily review", 42); + + assertTrue(result.isSuccess()); + assertEquals(List.of("daily review"), apiClient.searchQueries); + assertEquals(List.of(42), apiClient.searchContextLengths); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals(1, data.get("count")); + assertEquals(apiClient.searchResults, data.get("results")); + } + + @Test + void shouldDeleteValidatedNote() { + ToolResult result = service.deleteNote("./Projects/../Todo.md"); + + assertTrue(result.isSuccess()); + assertEquals(List.of("Todo.md"), apiClient.deletePaths); + } + + @Test + void shouldSurfacePartialFailureWhenMoveCannotDeleteSource() { + apiClient.noteContents.put("Projects/Todo.md", "# Todo"); + apiClient.deleteFailures.put("Projects/Todo.md", new ObsidianApiException(500, "delete failed")); + + ToolResult result = service.moveNote("Projects/Todo.md", "Archive/Todo.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("both source and target may now exist")); + assertEquals(List.of("Projects/Todo.md"), apiClient.readPaths); + assertEquals(List.of("Archive/Todo.md"), apiClient.writePaths); + assertEquals(List.of("Projects/Todo.md"), apiClient.deletePaths); + } + + @Test + void shouldRenameByWritingSiblingTargetAndDeletingSource() { + apiClient.noteContents.put("Projects/Todo.md", "# Todo"); + + ToolResult result = service.renameNote("Projects/Todo.md", "Done.md"); + + assertTrue(result.isSuccess()); + assertEquals(List.of("Projects/Todo.md"), apiClient.readPaths); + assertEquals(List.of("Projects/Done.md"), apiClient.writePaths); + assertEquals(List.of("Projects/Todo.md"), apiClient.deletePaths); + } + + private ObsidianPluginConfig config( + boolean allowWrite, + boolean allowDelete, + boolean allowMove, + boolean allowRename, + int maxReadChars) { + return ObsidianPluginConfig.builder() + .enabled(true) + .baseUrl("https://127.0.0.1:27124") + .apiKey("api-key") + .timeoutMs(30_000) + .allowInsecureTls(false) + .defaultSearchContextLength(100) + .maxReadChars(maxReadChars) + .allowWrite(allowWrite) + .allowDelete(allowDelete) + .allowMove(allowMove) + .allowRename(allowRename) + .build(); + } + + private static final class StubObsidianApiClient extends ObsidianApiClient { + + private final Map> listDirectoryResults = new HashMap<>(); + private final Map noteContents = new HashMap<>(); + private final Map deleteFailures = new HashMap<>(); + private final List listDirectoryCalls = new ArrayList<>(); + private final List readPaths = new ArrayList<>(); + private final List writePaths = new ArrayList<>(); + private final List writeContents = new ArrayList<>(); + private final List deletePaths = new ArrayList<>(); + private final List searchQueries = new ArrayList<>(); + private final List searchContextLengths = new ArrayList<>(); + private List searchResults = List.of(); + + private StubObsidianApiClient(ObsidianPluginConfigService configService) { + super(configService); + } + + @Override + public List listDirectory(String path) { + listDirectoryCalls.add(path); + return listDirectoryResults.getOrDefault(path, List.of()); + } + + @Override + public String readNote(String path) { + readPaths.add(path); + if (!noteContents.containsKey(path)) { + throw new ObsidianApiException(404, "missing note"); + } + return noteContents.get(path); + } + + @Override + public void writeNote(String path, String content) { + writePaths.add(path); + writeContents.add(content); + noteContents.put(path, content); + } + + @Override + public void deleteNote(String path) { + deletePaths.add(path); + RuntimeException failure = deleteFailures.get(path); + if (failure != null) { + throw failure; + } + noteContents.remove(path); + } + + @Override + public List simpleSearch(String query, int contextLength) { + searchQueries.add(query); + searchContextLengths.add(contextLength); + return searchResults; + } + } +} From 66fa0851e46ea2b06598a7819cd4d1d3fe8bce5a Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 14:34:58 -0400 Subject: [PATCH 14/22] fix(golemcore/obsidian): harden vault service semantics --- .../obsidian/ObsidianVaultService.java | 74 +++++++++++++--- .../obsidian/ObsidianVaultServiceTest.java | 86 ++++++++++++++++++- 2 files changed, 148 insertions(+), 12 deletions(-) diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java index 582084a..86ffda5 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java @@ -32,6 +32,7 @@ public ToolResult listDirectory(String path) { Map data = new LinkedHashMap<>(); data.put("path", normalizedPath); data.put("entries", entries); + data.put("files", entries); return ToolResult.success( "Listed " + entries.size() + " item(s) in " + displayPath(normalizedPath), data); @@ -80,11 +81,11 @@ public ToolResult searchNotes(String query, Integer contextLength) { } public ToolResult createNote(String path, String content) { - return writeNote(path, content, "Created"); + return writeNote(path, content, "Created", false); } public ToolResult updateNote(String path, String content) { - return writeNote(path, content, "Updated"); + return writeNote(path, content, "Updated", true); } public ToolResult deleteNote(String path) { @@ -95,6 +96,7 @@ public ToolResult deleteNote(String path) { try { String normalizedPath = pathValidator.normalizeNotePath(path); + requireExistingNote(normalizedPath); apiClient.deleteNote(normalizedPath); return ToolResult.success("Deleted note " + normalizedPath, Map.of("path", normalizedPath)); } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { @@ -132,7 +134,7 @@ public ToolResult renameNote(String path, String newName) { } } - private ToolResult writeNote(String path, String content, String verb) { + private ToolResult writeNote(String path, String content, String verb, boolean requireExisting) { if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Obsidian write is disabled in plugin settings"); @@ -140,7 +142,13 @@ private ToolResult writeNote(String path, String content, String verb) { try { String normalizedPath = pathValidator.normalizeNotePath(path); - apiClient.writeNote(normalizedPath, content != null ? content : ""); + requireContent(content); + if (requireExisting) { + requireExistingNote(normalizedPath); + } else { + ensureNoteAbsent(normalizedPath); + } + apiClient.writeNote(normalizedPath, content); return ToolResult.success(verb + " note " + normalizedPath, Map.of("path", normalizedPath)); } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { return executionFailure(ex.getMessage()); @@ -149,25 +157,71 @@ private ToolResult writeNote(String path, String content, String verb) { private ToolResult relocateNote(String sourcePath, String targetPath, String verb) { try { - String content = apiClient.readNote(sourcePath); + if (sourcePath.equals(targetPath)) { + throw new IllegalArgumentException("Source and target paths must differ"); + } + String content = requireExistingNote(sourcePath); + ensureNoteAbsent(targetPath); apiClient.writeNote(targetPath, content); try { apiClient.deleteNote(sourcePath); } catch (ObsidianApiException | ObsidianTransportException ex) { - return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, - "Obsidian " + verb.toLowerCase() + " partially failed after writing " - + targetPath + "; both source and target may now exist: " + ex.getMessage()); + Map data = new LinkedHashMap<>(); + data.put("path", sourcePath); + data.put("target_path", targetPath); + return ToolResult.builder() + .success(false) + .failureKind(ToolFailureKind.EXECUTION_FAILED) + .error("Obsidian " + verb.toLowerCase() + " partially failed after writing " + + targetPath + "; both source and target may now exist: " + ex.getMessage()) + .data(data) + .build(); } Map data = new LinkedHashMap<>(); - data.put("sourcePath", sourcePath); - data.put("targetPath", targetPath); + data.put("path", sourcePath); + data.put("target_path", targetPath); return ToolResult.success(verb + " note from " + sourcePath + " to " + targetPath, data); } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { return executionFailure(ex.getMessage()); } } + private String requireExistingNote(String path) { + try { + return apiClient.readNote(path); + } catch (ObsidianApiException ex) { + if (ex.getStatusCode() == 404) { + throw new IllegalArgumentException("Note does not exist: " + path); + } + throw ex; + } + } + + private void ensureNoteAbsent(String path) { + if (noteExists(path)) { + throw new IllegalArgumentException("Target note already exists: " + path); + } + } + + private boolean noteExists(String path) { + try { + apiClient.readNote(path); + return true; + } catch (ObsidianApiException ex) { + if (ex.getStatusCode() == 404) { + return false; + } + throw ex; + } + } + + private void requireContent(String content) { + if (content == null) { + throw new IllegalArgumentException("Content is required"); + } + } + private ToolResult executionFailure(String message) { return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, message); } diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java index 1f92603..724b8ff 100644 --- a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java @@ -50,9 +50,12 @@ void shouldListVaultRootDirectory() { @Test void shouldNormalizeNotePathsBeforeUpdating() { + apiClient.noteContents.put("Inbox.md", "# Existing"); + ToolResult result = service.updateNote("./Projects/../Inbox.md", "# Inbox"); assertTrue(result.isSuccess()); + assertEquals(List.of("Inbox.md"), apiClient.readPaths); assertEquals(List.of("Inbox.md"), apiClient.writePaths); assertEquals(List.of("# Inbox"), apiClient.writeContents); } @@ -89,6 +92,18 @@ void shouldDenyCreateWhenWriteIsDisabled() { assertTrue(apiClient.writePaths.isEmpty()); } + @Test + void shouldRejectCreateWhenDestinationAlreadyExists() { + apiClient.noteContents.put("Projects/Todo.md", "# Existing"); + + ToolResult result = service.createNote("Projects/Todo.md", "# Todo"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("already exists")); + assertTrue(apiClient.writePaths.isEmpty()); + } + @Test void shouldDenyDeleteWhenDeleteIsDisabled() { when(configService.getConfig()).thenReturn(config(true, false, true, true, 12_000)); @@ -101,6 +116,16 @@ void shouldDenyDeleteWhenDeleteIsDisabled() { assertTrue(apiClient.deletePaths.isEmpty()); } + @Test + void shouldRejectDeleteWhenSourceDoesNotExist() { + ToolResult result = service.deleteNote("Projects/Todo.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("does not exist")); + assertTrue(apiClient.deletePaths.isEmpty()); + } + @Test void shouldDenyMoveWhenMoveIsDisabled() { when(configService.getConfig()).thenReturn(config(true, true, false, true, 12_000)); @@ -129,6 +154,16 @@ void shouldDenyRenameWhenRenameIsDisabled() { assertTrue(apiClient.deletePaths.isEmpty()); } + @Test + void shouldRejectUpdateWhenSourceDoesNotExist() { + ToolResult result = service.updateNote("Projects/Todo.md", "# Todo"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("does not exist")); + assertTrue(apiClient.writePaths.isEmpty()); + } + @Test void shouldRejectRenameWhenNewNameContainsPathSeparators() { ToolResult result = service.renameNote("Projects/Todo.md", "Archive/Done.md"); @@ -176,12 +211,43 @@ void shouldSearchNotesUsingRequestedContextLength() { @Test void shouldDeleteValidatedNote() { + apiClient.noteContents.put("Todo.md", "# Todo"); + ToolResult result = service.deleteNote("./Projects/../Todo.md"); assertTrue(result.isSuccess()); + assertEquals(List.of("Todo.md"), apiClient.readPaths); assertEquals(List.of("Todo.md"), apiClient.deletePaths); } + @Test + void shouldRejectMoveWhenTargetAlreadyExists() { + apiClient.noteContents.put("Projects/Todo.md", "# Todo"); + apiClient.noteContents.put("Archive/Todo.md", "# Existing"); + + ToolResult result = service.moveNote("Projects/Todo.md", "Archive/Todo.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("already exists")); + assertEquals(List.of("Projects/Todo.md", "Archive/Todo.md"), apiClient.readPaths); + assertTrue(apiClient.writePaths.isEmpty()); + assertTrue(apiClient.deletePaths.isEmpty()); + } + + @Test + void shouldRejectMoveWhenSourceAndTargetNormalizeToSamePath() { + apiClient.noteContents.put("Inbox.md", "# Inbox"); + + ToolResult result = service.moveNote("./Inbox.md", "Inbox.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("must differ")); + assertTrue(apiClient.writePaths.isEmpty()); + assertTrue(apiClient.deletePaths.isEmpty()); + } + @Test void shouldSurfacePartialFailureWhenMoveCannotDeleteSource() { apiClient.noteContents.put("Projects/Todo.md", "# Todo"); @@ -192,9 +258,12 @@ void shouldSurfacePartialFailureWhenMoveCannotDeleteSource() { assertFalse(result.isSuccess()); assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); assertTrue(result.getError().contains("both source and target may now exist")); - assertEquals(List.of("Projects/Todo.md"), apiClient.readPaths); + assertEquals(List.of("Projects/Todo.md", "Archive/Todo.md"), apiClient.readPaths); assertEquals(List.of("Archive/Todo.md"), apiClient.writePaths); assertEquals(List.of("Projects/Todo.md"), apiClient.deletePaths); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("Projects/Todo.md", data.get("path")); + assertEquals("Archive/Todo.md", data.get("target_path")); } @Test @@ -204,11 +273,24 @@ void shouldRenameByWritingSiblingTargetAndDeletingSource() { ToolResult result = service.renameNote("Projects/Todo.md", "Done.md"); assertTrue(result.isSuccess()); - assertEquals(List.of("Projects/Todo.md"), apiClient.readPaths); + assertEquals(List.of("Projects/Todo.md", "Projects/Done.md"), apiClient.readPaths); assertEquals(List.of("Projects/Done.md"), apiClient.writePaths); assertEquals(List.of("Projects/Todo.md"), apiClient.deletePaths); } + @Test + void shouldRejectRenameWhenNewNameKeepsSamePath() { + apiClient.noteContents.put("Projects/Todo.md", "# Todo"); + + ToolResult result = service.renameNote("Projects/Todo.md", "Todo.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("must differ")); + assertTrue(apiClient.writePaths.isEmpty()); + assertTrue(apiClient.deletePaths.isEmpty()); + } + private ObsidianPluginConfig config( boolean allowWrite, boolean allowDelete, From 8ff7322d2a1946920b0a5cdaddb569ed76d7751f Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 14:37:29 -0400 Subject: [PATCH 15/22] fix(golemcore/obsidian): validate vault directory targets --- .../obsidian/support/ObsidianPathValidator.java | 6 +++++- .../golemcore/obsidian/ObsidianVaultServiceTest.java | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianPathValidator.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianPathValidator.java index 89680aa..0dbcb9f 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianPathValidator.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianPathValidator.java @@ -7,7 +7,11 @@ public final class ObsidianPathValidator { public String normalizeDirectoryPath(String path) { - return normalizeRelativePath(path, true); + String normalized = normalizeRelativePath(path, true); + if (!normalized.isBlank() && normalized.toLowerCase(Locale.ROOT).endsWith(".md")) { + throw new IllegalArgumentException("directory paths must point to directories, not notes"); + } + return normalized; } public String normalizeNotePath(String path) { diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java index 724b8ff..158f2cc 100644 --- a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java @@ -80,6 +80,16 @@ void shouldRejectNotePathsWithoutMarkdownExtensionBeforeCallingApi() { assertTrue(apiClient.writePaths.isEmpty()); } + @Test + void shouldRejectDirectoryListingPathsThatPointToNotes() { + ToolResult result = service.listDirectory("Inbox.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("directory")); + assertTrue(apiClient.listDirectoryCalls.isEmpty()); + } + @Test void shouldDenyCreateWhenWriteIsDisabled() { when(configService.getConfig()).thenReturn(config(false, true, true, true, 12_000)); From d3312e91e37f4a814357fe828eae6234e6640737 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 14:41:42 -0400 Subject: [PATCH 16/22] feat(golemcore/obsidian): expose obsidian vault tool --- .../obsidian/ObsidianVaultToolProvider.java | 132 ++++++++++++++++++ .../ObsidianVaultToolProviderTest.java | 97 +++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java create mode 100644 golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java new file mode 100644 index 0000000..7c1bd09 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java @@ -0,0 +1,132 @@ +package me.golemcore.plugins.golemcore.obsidian; + +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 org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +@Component +public class ObsidianVaultToolProvider 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 PROPERTIES = "properties"; + private static final String REQUIRED = "required"; + private static final String PARAM_OPERATION = "operation"; + private static final String PARAM_PATH = "path"; + private static final String PARAM_QUERY = "query"; + private static final String PARAM_CONTEXT_LENGTH = "context_length"; + private static final String PARAM_CONTENT = "content"; + private static final String PARAM_TARGET_PATH = "target_path"; + private static final String PARAM_NEW_NAME = "new_name"; + + private final ObsidianVaultService service; + + public ObsidianVaultToolProvider(ObsidianVaultService service) { + this.service = service; + } + + @Override + public ToolDefinition getDefinition() { + return ToolDefinition.builder() + .name("obsidian_vault") + .description("Use an Obsidian vault through obsidian-local-rest-api.") + .inputSchema(Map.of( + TYPE, TYPE_OBJECT, + PROPERTIES, Map.of( + PARAM_OPERATION, Map.of( + TYPE, TYPE_STRING, + "enum", List.of( + "list_directory", + "read_note", + "search_notes", + "create_note", + "update_note", + "delete_note", + "move_note", + "rename_note")), + PARAM_PATH, Map.of( + TYPE, TYPE_STRING, + "description", "Vault-relative note or directory path."), + PARAM_QUERY, Map.of( + TYPE, TYPE_STRING, + "description", "Search query for search_notes."), + PARAM_CONTEXT_LENGTH, Map.of( + TYPE, TYPE_INTEGER, + "description", "Optional context length for search_notes."), + PARAM_CONTENT, Map.of( + TYPE, TYPE_STRING, + "description", "Markdown content for create_note or update_note."), + PARAM_TARGET_PATH, Map.of( + TYPE, TYPE_STRING, + "description", "Target vault-relative path for move_note."), + PARAM_NEW_NAME, Map.of( + TYPE, TYPE_STRING, + "description", "New file name for rename_note.")), + REQUIRED, List.of(PARAM_OPERATION))) + .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"); + } + + return switch (operation) { + case "list_directory" -> service.listDirectory(readString(parameters.get(PARAM_PATH))); + case "read_note" -> service.readNote(readString(parameters.get(PARAM_PATH))); + case "search_notes" -> service.searchNotes( + readString(parameters.get(PARAM_QUERY)), + readInteger(parameters.get(PARAM_CONTEXT_LENGTH))); + case "create_note" -> service.createNote( + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_CONTENT))); + case "update_note" -> service.updateNote( + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_CONTENT))); + case "delete_note" -> service.deleteNote(readString(parameters.get(PARAM_PATH))); + case "move_note" -> service.moveNote( + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_TARGET_PATH))); + case "rename_note" -> service.renameNote( + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_NEW_NAME))); + default -> ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, + "Unsupported obsidian_vault operation: " + operation); + }; + } + + private String readString(Object value) { + if (value instanceof String stringValue) { + return stringValue; + } + return null; + } + + private Integer readInteger(Object value) { + if (value instanceof Number numberValue) { + return numberValue.intValue(); + } + if (value instanceof String stringValue && !stringValue.isBlank()) { + try { + return Integer.parseInt(stringValue.trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } +} diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java new file mode 100644 index 0000000..25a0982 --- /dev/null +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java @@ -0,0 +1,97 @@ +package me.golemcore.plugins.golemcore.obsidian; + +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 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; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ObsidianVaultToolProviderTest { + + private ObsidianVaultService service; + private ObsidianVaultToolProvider provider; + + @BeforeEach + void setUp() { + service = mock(ObsidianVaultService.class); + provider = new ObsidianVaultToolProvider(service); + } + + @Test + void shouldExposeAllSupportedOperations() { + ToolDefinition definition = provider.getDefinition(); + + Map schema = definition.getInputSchema(); + Map properties = (Map) schema.get("properties"); + Map operation = (Map) properties.get("operation"); + + assertEquals("obsidian_vault", definition.getName()); + assertEquals(List.of( + "list_directory", + "read_note", + "search_notes", + "create_note", + "update_note", + "delete_note", + "move_note", + "rename_note"), operation.get("enum")); + } + + @Test + void shouldRejectMissingOperation() { + ToolResult result = provider.execute(Map.of()).join(); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("operation is required")); + } + + @Test + void shouldDispatchRenameToVaultService() { + when(service.renameNote("Projects/Todo.md", "Done.md")) + .thenReturn(ToolResult.success("renamed")); + + ToolResult result = provider.execute(Map.of( + "operation", "rename_note", + "path", "Projects/Todo.md", + "new_name", "Done.md")).join(); + + assertTrue(result.isSuccess()); + verify(service).renameNote("Projects/Todo.md", "Done.md"); + } + + @Test + void shouldDispatchSearchWithOptionalContextLength() { + when(service.searchNotes("daily review", 42)) + .thenReturn(ToolResult.success("found")); + + ToolResult result = provider.execute(Map.of( + "operation", "search_notes", + "query", "daily review", + "context_length", 42)).join(); + + assertTrue(result.isSuccess()); + verify(service).searchNotes("daily review", 42); + } + + @Test + void shouldDispatchListDirectoryWithoutPath() { + when(service.listDirectory(null)) + .thenReturn(ToolResult.success("listed")); + + ToolResult result = provider.execute(Map.of("operation", "list_directory")).join(); + + assertTrue(result.isSuccess()); + verify(service).listDirectory(null); + } +} From 97b2750537a5c26d27f31c6374d2c229402d2328 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 14:44:41 -0400 Subject: [PATCH 17/22] fix(golemcore/obsidian): tighten vault tool schema --- .../obsidian/ObsidianVaultToolProvider.java | 48 +++++++- .../ObsidianVaultToolProviderTest.java | 110 ++++++++++++++++++ 2 files changed, 152 insertions(+), 6 deletions(-) diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java index 7c1bd09..a7ad63c 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java @@ -70,7 +70,15 @@ public ToolDefinition getDefinition() { PARAM_NEW_NAME, Map.of( TYPE, TYPE_STRING, "description", "New file name for rename_note.")), - REQUIRED, List.of(PARAM_OPERATION))) + REQUIRED, List.of(PARAM_OPERATION), + "allOf", List.of( + requiredWhen("read_note", List.of(PARAM_PATH)), + requiredWhen("search_notes", List.of(PARAM_QUERY)), + requiredWhen("create_note", List.of(PARAM_PATH, PARAM_CONTENT)), + requiredWhen("update_note", List.of(PARAM_PATH, PARAM_CONTENT)), + requiredWhen("delete_note", List.of(PARAM_PATH)), + requiredWhen("move_note", List.of(PARAM_PATH, PARAM_TARGET_PATH)), + requiredWhen("rename_note", List.of(PARAM_PATH, PARAM_NEW_NAME))))) .build(); } @@ -88,9 +96,7 @@ private ToolResult executeOperation(Map parameters) { return switch (operation) { case "list_directory" -> service.listDirectory(readString(parameters.get(PARAM_PATH))); case "read_note" -> service.readNote(readString(parameters.get(PARAM_PATH))); - case "search_notes" -> service.searchNotes( - readString(parameters.get(PARAM_QUERY)), - readInteger(parameters.get(PARAM_CONTEXT_LENGTH))); + case "search_notes" -> executeSearchNotes(parameters); case "create_note" -> service.createNote( readString(parameters.get(PARAM_PATH)), readString(parameters.get(PARAM_CONTENT))); @@ -109,6 +115,24 @@ private ToolResult executeOperation(Map parameters) { }; } + private ToolResult executeSearchNotes(Map parameters) { + Object rawContextLength = parameters.get(PARAM_CONTEXT_LENGTH); + Integer contextLength = readInteger(rawContextLength); + if (rawContextLength != null && contextLength == null) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, "context_length must be an integer"); + } + return service.searchNotes(readString(parameters.get(PARAM_QUERY)), contextLength); + } + + private 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 String readString(Object value) { if (value instanceof String stringValue) { return stringValue; @@ -117,8 +141,20 @@ private String readString(Object value) { } private Integer readInteger(Object value) { - if (value instanceof Number numberValue) { - return numberValue.intValue(); + if (value instanceof Integer integerValue) { + return integerValue; + } + if (value instanceof Long longValue) { + if (longValue >= Integer.MIN_VALUE && longValue <= Integer.MAX_VALUE) { + return longValue.intValue(); + } + return null; + } + if (value instanceof Short shortValue) { + return shortValue.intValue(); + } + if (value instanceof Byte byteValue) { + return byteValue.intValue(); } if (value instanceof String stringValue && !stringValue.isBlank()) { try { diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java index 25a0982..0b2854b 100644 --- a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java @@ -47,6 +47,27 @@ void shouldExposeAllSupportedOperations() { "rename_note"), operation.get("enum")); } + @Test + void shouldRequirePathForReadNoteInSchema() { + ToolDefinition definition = provider.getDefinition(); + + Map schema = definition.getInputSchema(); + List allOf = (List) schema.get("allOf"); + Map readRule = (Map) allOf.stream() + .map(Map.class::cast) + .filter(rule -> { + Map condition = (Map) rule.get("if"); + Map conditionProperties = (Map) condition.get("properties"); + Map operationProperty = (Map) conditionProperties.get("operation"); + return "read_note".equals(operationProperty.get("const")); + }) + .findFirst() + .orElseThrow(); + Map thenClause = (Map) readRule.get("then"); + + assertEquals(List.of("path"), thenClause.get("required")); + } + @Test void shouldRejectMissingOperation() { ToolResult result = provider.execute(Map.of()).join(); @@ -70,6 +91,19 @@ void shouldDispatchRenameToVaultService() { verify(service).renameNote("Projects/Todo.md", "Done.md"); } + @Test + void shouldDispatchReadNoteToVaultService() { + when(service.readNote("Projects/Todo.md")) + .thenReturn(ToolResult.success("read")); + + ToolResult result = provider.execute(Map.of( + "operation", "read_note", + "path", "Projects/Todo.md")).join(); + + assertTrue(result.isSuccess()); + verify(service).readNote("Projects/Todo.md"); + } + @Test void shouldDispatchSearchWithOptionalContextLength() { when(service.searchNotes("daily review", 42)) @@ -84,6 +118,18 @@ void shouldDispatchSearchWithOptionalContextLength() { verify(service).searchNotes("daily review", 42); } + @Test + void shouldRejectNonIntegerContextLength() { + ToolResult result = provider.execute(Map.of( + "operation", "search_notes", + "query", "daily review", + "context_length", 42.5d)).join(); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("context_length")); + } + @Test void shouldDispatchListDirectoryWithoutPath() { when(service.listDirectory(null)) @@ -94,4 +140,68 @@ void shouldDispatchListDirectoryWithoutPath() { assertTrue(result.isSuccess()); verify(service).listDirectory(null); } + + @Test + void shouldDispatchCreateToVaultService() { + when(service.createNote("Projects/Todo.md", "# Todo")) + .thenReturn(ToolResult.success("created")); + + ToolResult result = provider.execute(Map.of( + "operation", "create_note", + "path", "Projects/Todo.md", + "content", "# Todo")).join(); + + assertTrue(result.isSuccess()); + verify(service).createNote("Projects/Todo.md", "# Todo"); + } + + @Test + void shouldDispatchUpdateToVaultService() { + when(service.updateNote("Projects/Todo.md", "# Updated")) + .thenReturn(ToolResult.success("updated")); + + ToolResult result = provider.execute(Map.of( + "operation", "update_note", + "path", "Projects/Todo.md", + "content", "# Updated")).join(); + + assertTrue(result.isSuccess()); + verify(service).updateNote("Projects/Todo.md", "# Updated"); + } + + @Test + void shouldDispatchDeleteToVaultService() { + when(service.deleteNote("Projects/Todo.md")) + .thenReturn(ToolResult.success("deleted")); + + ToolResult result = provider.execute(Map.of( + "operation", "delete_note", + "path", "Projects/Todo.md")).join(); + + assertTrue(result.isSuccess()); + verify(service).deleteNote("Projects/Todo.md"); + } + + @Test + void shouldDispatchMoveToVaultService() { + when(service.moveNote("Projects/Todo.md", "Archive/Todo.md")) + .thenReturn(ToolResult.success("moved")); + + ToolResult result = provider.execute(Map.of( + "operation", "move_note", + "path", "Projects/Todo.md", + "target_path", "Archive/Todo.md")).join(); + + assertTrue(result.isSuccess()); + verify(service).moveNote("Projects/Todo.md", "Archive/Todo.md"); + } + + @Test + void shouldRejectUnsupportedOperation() { + ToolResult result = provider.execute(Map.of("operation", "unknown")).join(); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("Unsupported")); + } } From de4e0be88ca7eff56abef62aa1dd09cd2ade3aae Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 14:46:02 -0400 Subject: [PATCH 18/22] fix(golemcore/obsidian): harden tool parameter handling --- .../obsidian/ObsidianVaultToolProvider.java | 19 ------------------- .../ObsidianVaultToolProviderTest.java | 12 ++++++++++++ 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java index a7ad63c..44e7e68 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java @@ -144,25 +144,6 @@ private Integer readInteger(Object value) { if (value instanceof Integer integerValue) { return integerValue; } - if (value instanceof Long longValue) { - if (longValue >= Integer.MIN_VALUE && longValue <= Integer.MAX_VALUE) { - return longValue.intValue(); - } - return null; - } - if (value instanceof Short shortValue) { - return shortValue.intValue(); - } - if (value instanceof Byte byteValue) { - return byteValue.intValue(); - } - if (value instanceof String stringValue && !stringValue.isBlank()) { - try { - return Integer.parseInt(stringValue.trim()); - } catch (NumberFormatException ignored) { - return null; - } - } return null; } } diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java index 0b2854b..e6a1ccb 100644 --- a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java @@ -130,6 +130,18 @@ void shouldRejectNonIntegerContextLength() { assertTrue(result.getError().contains("context_length")); } + @Test + void shouldRejectStringContextLength() { + ToolResult result = provider.execute(Map.of( + "operation", "search_notes", + "query", "daily review", + "context_length", "42")).join(); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("context_length")); + } + @Test void shouldDispatchListDirectoryWithoutPath() { when(service.listDirectory(null)) From 55a1b100d34f08ea58c5ce7a25e4ea5cb121767d Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 15:08:32 -0400 Subject: [PATCH 19/22] fix(golemcore/obsidian): add registry metadata and pass strict checks --- .../golemcore/obsidian/ObsidianVaultService.java | 3 ++- .../obsidian/model/ObsidianSearchResult.java | 8 ++++---- .../obsidian/support/ObsidianApiException.java | 2 ++ .../support/ObsidianTransportException.java | 2 ++ .../obsidian/support/ObsidianApiClientTest.java | 1 - registry/golemcore/obsidian/index.yaml | 7 +++++++ registry/golemcore/obsidian/versions/1.0.0.yaml | 13 +++++++++++++ 7 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 registry/golemcore/obsidian/index.yaml create mode 100644 registry/golemcore/obsidian/versions/1.0.0.yaml diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java index 86ffda5..448ddf5 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java @@ -11,6 +11,7 @@ import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; @Service @@ -172,7 +173,7 @@ private ToolResult relocateNote(String sourcePath, String targetPath, String ver return ToolResult.builder() .success(false) .failureKind(ToolFailureKind.EXECUTION_FAILED) - .error("Obsidian " + verb.toLowerCase() + " partially failed after writing " + .error("Obsidian " + verb.toLowerCase(Locale.ROOT) + " partially failed after writing " + targetPath + "; both source and target may now exist: " + ex.getMessage()) .data(data) .build(); diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java index 7bf34d7..a337390 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java @@ -29,11 +29,11 @@ public List getMatches() { public static final class Match { private final String context; - private final MatchSpan match; + private final MatchSpan span; - public Match(String context, MatchSpan match) { + public Match(String context, MatchSpan span) { this.context = context; - this.match = match; + this.span = span; } public String getContext() { @@ -41,7 +41,7 @@ public String getContext() { } public MatchSpan getMatch() { - return match; + return span; } } diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiException.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiException.java index 83831fc..e8bbb85 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiException.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiException.java @@ -2,6 +2,8 @@ public class ObsidianApiException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final int statusCode; public ObsidianApiException(int statusCode, String message) { diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianTransportException.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianTransportException.java index 5d19995..f405826 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianTransportException.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianTransportException.java @@ -2,6 +2,8 @@ public class ObsidianTransportException extends RuntimeException { + private static final long serialVersionUID = 1L; + public ObsidianTransportException(String message, Throwable cause) { super(message, cause); } diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java index 901a081..c2c52e2 100644 --- a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java @@ -29,7 +29,6 @@ class ObsidianApiClientTest { private static final MediaType APPLICATION_JSON = MediaType.get("application/json"); - private static final MediaType TEXT_MARKDOWN = MediaType.get("text/markdown; charset=utf-8"); private ObsidianPluginConfigService configService; private MockObsidianApiClient client; diff --git a/registry/golemcore/obsidian/index.yaml b/registry/golemcore/obsidian/index.yaml new file mode 100644 index 0000000..ccc07d6 --- /dev/null +++ b/registry/golemcore/obsidian/index.yaml @@ -0,0 +1,7 @@ +id: golemcore/obsidian +owner: golemcore +name: obsidian +latest: 1.0.0 +versions: + - 1.0.0 +source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/obsidian" diff --git a/registry/golemcore/obsidian/versions/1.0.0.yaml b/registry/golemcore/obsidian/versions/1.0.0.yaml new file mode 100644 index 0000000..46b4557 --- /dev/null +++ b/registry/golemcore/obsidian/versions/1.0.0.yaml @@ -0,0 +1,13 @@ +id: golemcore/obsidian +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +artifactUrl: "dist/golemcore/obsidian/1.0.0/golemcore-obsidian-plugin-1.0.0.jar" +checksumSha256: "9b21bb01c79cab305347340c6f6aeacca2e2a98e33b017b91860a0bf6dce03c0" +publishedAt: "2026-03-29T19:04:32Z" +sourceCommit: "de4e0be88ca7eff56abef62aa1dd09cd2ade3aae" +entrypoint: me.golemcore.plugins.golemcore.obsidian.ObsidianPluginBootstrap +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/obsidian" +license: "Apache-2.0" +maintainers: + - alexk-dev From 752eaace0dd4747ff1e4ab729a8912e36adf7079 Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 18:19:25 -0400 Subject: [PATCH 20/22] feat(golemcore/obsidian): add settings connection test --- .../ObsidianPluginSettingsContributor.java | 43 +++++++++++++++++- ...ObsidianPluginSettingsContributorTest.java | 45 ++++++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java index 4b19461..0e403e1 100644 --- a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java @@ -2,10 +2,14 @@ import lombok.RequiredArgsConstructor; import me.golemcore.plugin.api.extension.spi.PluginActionResult; +import me.golemcore.plugin.api.extension.spi.PluginSettingsAction; 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 me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiClient; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiException; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianTransportException; import org.springframework.stereotype.Component; import java.util.LinkedHashMap; @@ -17,8 +21,10 @@ public class ObsidianPluginSettingsContributor implements PluginSettingsContributor { private static final String SECTION_KEY = "main"; + private static final String ACTION_TEST_CONNECTION = "test-connection"; private final ObsidianPluginConfigService configService; + private final ObsidianApiClient apiClient; @Override public String getPluginId() { @@ -138,6 +144,11 @@ public PluginSettingsSection getSection(String sectionKey) { .description("Permit tools to rename notes or files.") .build())) .values(values) + .actions(List.of(PluginSettingsAction.builder() + .actionId(ACTION_TEST_CONNECTION) + .label("Test Connection") + .variant("secondary") + .build())) .build(); } @@ -167,7 +178,33 @@ public PluginSettingsSection saveSection(String sectionKey, Map @Override public PluginActionResult executeAction(String sectionKey, String actionId, Map payload) { requireSection(sectionKey); - throw new IllegalArgumentException("Unknown Obsidian action: " + actionId); + if (!ACTION_TEST_CONNECTION.equals(actionId)) { + throw new IllegalArgumentException("Unknown Obsidian action: " + actionId); + } + return testConnection(); + } + + private PluginActionResult testConnection() { + ObsidianPluginConfig config = configService.getConfig(); + if (!hasText(config.getApiKey())) { + return PluginActionResult.builder() + .status("error") + .message("Obsidian API key is not configured.") + .build(); + } + try { + List entries = apiClient.listDirectory(""); + return PluginActionResult.builder() + .status("ok") + .message("Connected to Obsidian. Vault root returned " + entries.size() + " item(s).") + .build(); + } catch (IllegalArgumentException | IllegalStateException | ObsidianApiException + | ObsidianTransportException ex) { + return PluginActionResult.builder() + .status("error") + .message("Connection failed: " + ex.getMessage()) + .build(); + } } private void requireSection(String sectionKey) { @@ -203,4 +240,8 @@ private String readString(Map values, String key, String default } return defaultValue; } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } } diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java index ac24cff..05e2a03 100644 --- a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java @@ -1,11 +1,16 @@ package me.golemcore.plugins.golemcore.obsidian; +import me.golemcore.plugin.api.extension.spi.PluginActionResult; import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiClient; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianTransportException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import java.io.IOException; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -18,13 +23,15 @@ class ObsidianPluginSettingsContributorTest { private ObsidianPluginConfigService configService; + private ObsidianApiClient apiClient; private ObsidianPluginSettingsContributor contributor; private ObsidianPluginConfig config; @BeforeEach void setUp() { configService = mock(ObsidianPluginConfigService.class); - contributor = new ObsidianPluginSettingsContributor(configService); + apiClient = mock(ObsidianApiClient.class); + contributor = new ObsidianPluginSettingsContributor(configService, apiClient); config = ObsidianPluginConfig.builder().build(); config.normalize(); when(configService.getConfig()).thenReturn(config); @@ -45,6 +52,9 @@ void shouldExposeSectionWithBlankSecretAndSafeDefaultsFromDefaultConfig() { assertFalse((Boolean) section.getValues().get("allowDelete")); assertFalse((Boolean) section.getValues().get("allowMove")); assertFalse((Boolean) section.getValues().get("allowRename")); + assertEquals(1, section.getActions().size()); + assertEquals("test-connection", section.getActions().getFirst().getActionId()); + assertEquals("Test Connection", section.getActions().getFirst().getLabel()); } @Test @@ -99,4 +109,37 @@ void shouldRoundTripSavedPolicyFlagsThroughGetSection() { assertTrue((Boolean) section.getValues().get("allowMove")); assertFalse((Boolean) section.getValues().get("allowRename")); } + + @Test + void shouldReturnOkWhenConnectionTestSucceeds() { + config.setApiKey("secret"); + when(apiClient.listDirectory("")).thenReturn(List.of("Inbox.md", "Projects/")); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("ok", result.getStatus()); + assertEquals("Connected to Obsidian. Vault root returned 2 item(s).", result.getMessage()); + verify(apiClient).listDirectory(""); + } + + @Test + void shouldReturnErrorWhenApiKeyIsMissing() { + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Obsidian API key is not configured.", result.getMessage()); + } + + @Test + void shouldReturnErrorWhenConnectionTestFails() { + config.setApiKey("secret"); + when(apiClient.listDirectory("")).thenThrow(new ObsidianTransportException( + "Obsidian transport failed: timeout", + new IOException("timeout"))); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Connection failed: Obsidian transport failed: timeout", result.getMessage()); + } } From 630c3afac3095ffb5708539638e9d73956ae53bb Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 18:20:03 -0400 Subject: [PATCH 21/22] chore(registry): sync obsidian artifact metadata --- registry/golemcore/obsidian/versions/1.0.0.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registry/golemcore/obsidian/versions/1.0.0.yaml b/registry/golemcore/obsidian/versions/1.0.0.yaml index 46b4557..9ed5176 100644 --- a/registry/golemcore/obsidian/versions/1.0.0.yaml +++ b/registry/golemcore/obsidian/versions/1.0.0.yaml @@ -3,9 +3,9 @@ version: 1.0.0 pluginApiVersion: 1 engineVersion: ">=0.0.0 <1.0.0" artifactUrl: "dist/golemcore/obsidian/1.0.0/golemcore-obsidian-plugin-1.0.0.jar" -checksumSha256: "9b21bb01c79cab305347340c6f6aeacca2e2a98e33b017b91860a0bf6dce03c0" +checksumSha256: "93a3afe472792f5890cc1ecfaf52d8f89ba296ed28a6e40027efc8900a9951a1" publishedAt: "2026-03-29T19:04:32Z" -sourceCommit: "de4e0be88ca7eff56abef62aa1dd09cd2ade3aae" +sourceCommit: "752eaace0dd4747ff1e4ab729a8912e36adf7079" entrypoint: me.golemcore.plugins.golemcore.obsidian.ObsidianPluginBootstrap sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/obsidian" license: "Apache-2.0" From 6746551674b965ded2417cae5103d4f86ddbbf4f Mon Sep 17 00:00:00 2001 From: Alex Kuleshov Date: Sun, 29 Mar 2026 18:25:14 -0400 Subject: [PATCH 22/22] fix(registry): align obsidian checksum with CI build --- registry/golemcore/obsidian/versions/1.0.0.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/golemcore/obsidian/versions/1.0.0.yaml b/registry/golemcore/obsidian/versions/1.0.0.yaml index 9ed5176..792ab23 100644 --- a/registry/golemcore/obsidian/versions/1.0.0.yaml +++ b/registry/golemcore/obsidian/versions/1.0.0.yaml @@ -3,7 +3,7 @@ version: 1.0.0 pluginApiVersion: 1 engineVersion: ">=0.0.0 <1.0.0" artifactUrl: "dist/golemcore/obsidian/1.0.0/golemcore-obsidian-plugin-1.0.0.jar" -checksumSha256: "93a3afe472792f5890cc1ecfaf52d8f89ba296ed28a6e40027efc8900a9951a1" +checksumSha256: "312a55b766c11fe8a957b8843af58f08a64dde9051d4aba2c781bded7b84b143" publishedAt: "2026-03-29T19:04:32Z" sourceCommit: "752eaace0dd4747ff1e4ab729a8912e36adf7079" entrypoint: me.golemcore.plugins.golemcore.obsidian.ObsidianPluginBootstrap