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/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..9e9d960
--- /dev/null
+++ b/golemcore/obsidian/pom.xml
@@ -0,0 +1,104 @@
+
+
+ 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.projectlombok
+ lombok
+ provided
+
+
+ org.springframework
+ spring-context
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ com.squareup.okhttp3
+ okhttp-jvm
+ ${okhttp.version}
+
+
+ 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/ObsidianPluginConfig.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java
new file mode 100644
index 0000000..2fc65fe
--- /dev/null
+++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java
@@ -0,0 +1,92 @@
+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;
+
+ @Builder.Default
+ private String baseUrl = DEFAULT_BASE_URL;
+ 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/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/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..0e403e1
--- /dev/null
+++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java
@@ -0,0 +1,247 @@
+package me.golemcore.plugins.golemcore.obsidian;
+
+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;
+import java.util.List;
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+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() {
+ 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", 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")
+ .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)
+ .actions(List.of(PluginSettingsAction.builder()
+ .actionId(ACTION_TEST_CONNECTION)
+ .label("Test Connection")
+ .variant("secondary")
+ .build()))
+ .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);
+ 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) {
+ 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;
+ }
+
+ private boolean hasText(String value) {
+ return value != null && !value.isBlank();
+ }
+}
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..448ddf5
--- /dev/null
+++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java
@@ -0,0 +1,233 @@
+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.Locale;
+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);
+ data.put("files", 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", false);
+ }
+
+ public ToolResult updateNote(String path, String content) {
+ return writeNote(path, content, "Updated", true);
+ }
+
+ 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);
+ requireExistingNote(normalizedPath);
+ 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, boolean requireExisting) {
+ 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);
+ 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());
+ }
+ }
+
+ private ToolResult relocateNote(String sourcePath, String targetPath, String verb) {
+ try {
+ 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) {
+ 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(Locale.ROOT) + " partially failed after writing "
+ + targetPath + "; both source and target may now exist: " + ex.getMessage())
+ .data(data)
+ .build();
+ }
+
+ Map data = new LinkedHashMap<>();
+ 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);
+ }
+
+ private String displayPath(String normalizedPath) {
+ return normalizedPath.isBlank() ? "/" : normalizedPath;
+ }
+}
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..44e7e68
--- /dev/null
+++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java
@@ -0,0 +1,149 @@
+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),
+ "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();
+ }
+
+ @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" -> executeSearchNotes(parameters);
+ 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 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;
+ }
+ return null;
+ }
+
+ private Integer readInteger(Object value) {
+ if (value instanceof Integer integerValue) {
+ return integerValue;
+ }
+ return null;
+ }
+}
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..a337390
--- /dev/null
+++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java
@@ -0,0 +1,72 @@
+package me.golemcore.plugins.golemcore.obsidian.model;
+
+import java.util.List;
+
+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 span;
+
+ public Match(String context, MatchSpan span) {
+ this.context = context;
+ this.span = span;
+ }
+
+ public String getContext() {
+ return context;
+ }
+
+ public MatchSpan getMatch() {
+ return span;
+ }
+ }
+
+ 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 Integer getEnd() {
+ return 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
new file mode 100644
index 0000000..507e201
--- /dev/null
+++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java
@@ -0,0 +1,382 @@
+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;
+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) {
+ 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()) {
+ throw new ObsidianApiException(response.code(),
+ "Invalid vault directory response: expected {files: [...]}");
+ }
+ List files = new ArrayList<>(filesNode.size());
+ for (JsonNode fileNode : filesNode) {
+ files.add(fileNode.asText(""));
+ }
+ return List.copyOf(files);
+ } catch (JsonProcessingException ex) {
+ throw new ObsidianApiException(response.code(),
+ "Invalid JSON response: " + firstText(ex.getOriginalMessage(), "Unexpected response"));
+ }
+ }
+ }
+
+ public String readNote(String path) {
+ 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)) {
+ throw new ObsidianApiException(response.code(),
+ "Invalid note response: expected JSON with content");
+ }
+ try {
+ JsonNode root = objectMapper.readTree(responseBody);
+ JsonNode contentNode = root.get("content");
+ if (contentNode == null || contentNode.isNull() || !contentNode.isTextual()) {
+ throw new ObsidianApiException(response.code(),
+ "Invalid note response: expected textual content");
+ }
+ return contentNode.asText("");
+ } catch (JsonProcessingException ex) {
+ throw new ObsidianApiException(response.code(),
+ "Invalid note response: expected JSON with content");
+ }
+ }
+ }
+
+ public void writeNote(String path, String content) {
+ 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) {
+ 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);
+ }
+ }
+
+ public List simpleSearch(String query, int contextLength) {
+ if (!hasText(query)) {
+ throw new IllegalArgumentException("Search query is required");
+ }
+
+ int effectiveContextLength = contextLength > 0 ? contextLength : getConfig().getDefaultSearchContextLength();
+ 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);
+ 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, response.code());
+ } catch (JsonProcessingException ex) {
+ throw new ObsidianApiException(response.code(),
+ "Invalid JSON response: " + firstText(ex.getOriginalMessage(), "Unexpected response"));
+ }
+ }
+ }
+
+ 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();
+ }
+
+ 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 void ensureSuccessful(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;
+ }
+ 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 List parseSearchResults(JsonNode arrayNode, int statusCode) {
+ List results = new ArrayList<>(arrayNode.size());
+ for (JsonNode resultNode : arrayNode) {
+ 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;
+ 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, int statusCode) {
+ List matches = new ArrayList<>(matchesNode.size());
+ for (JsonNode matchNode : matchesNode) {
+ 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()) {
+ 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(), source)));
+ }
+ return List.copyOf(matches);
+ }
+
+ 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)) {
+ 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..e8bbb85
--- /dev/null
+++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiException.java
@@ -0,0 +1,17 @@
+package me.golemcore.plugins.golemcore.obsidian.support;
+
+public class ObsidianApiException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ 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/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..0dbcb9f
--- /dev/null
+++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianPathValidator.java
@@ -0,0 +1,80 @@
+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) {
+ 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) {
+ 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/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..f405826
--- /dev/null
+++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianTransportException.java
@@ -0,0 +1,10 @@
+package me.golemcore.plugins.golemcore.obsidian.support;
+
+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/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/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..05e2a03
--- /dev/null
+++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java
@@ -0,0 +1,145 @@
+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;
+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 ObsidianPluginSettingsContributorTest {
+
+ private ObsidianPluginConfigService configService;
+ private ObsidianApiClient apiClient;
+ private ObsidianPluginSettingsContributor contributor;
+ private ObsidianPluginConfig config;
+
+ @BeforeEach
+ void setUp() {
+ configService = mock(ObsidianPluginConfigService.class);
+ apiClient = mock(ObsidianApiClient.class);
+ contributor = new ObsidianPluginSettingsContributor(configService, apiClient);
+ config = ObsidianPluginConfig.builder().build();
+ config.normalize();
+ when(configService.getConfig()).thenReturn(config);
+ }
+
+ @Test
+ 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(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"));
+ assertEquals(1, section.getActions().size());
+ assertEquals("test-connection", section.getActions().getFirst().getActionId());
+ assertEquals("Test Connection", section.getActions().getFirst().getLabel());
+ }
+
+ @Test
+ void shouldRoundTripSavedPolicyFlagsThroughGetSection() {
+ 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);
+ 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);
+
+ PluginSettingsSection section = contributor.saveSection("main", values);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ObsidianPluginConfig.class);
+ verify(configService).save(captor.capture());
+ 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"));
+ }
+
+ @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());
+ }
+}
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..158f2cc
--- /dev/null
+++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java
@@ -0,0 +1,382 @@
+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() {
+ 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);
+ }
+
+ @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 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));
+
+ 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 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));
+
+ 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 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));
+
+ 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 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");
+
+ 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() {
+ 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");
+ 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", "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
+ 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", "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,
+ 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;
+ }
+ }
+}
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..e6a1ccb
--- /dev/null
+++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java
@@ -0,0 +1,219 @@
+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 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();
+
+ 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 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))
+ .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 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 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))
+ .thenReturn(ToolResult.success("listed"));
+
+ ToolResult result = provider.execute(Map.of("operation", "list_directory")).join();
+
+ 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"));
+ }
+}
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..c2c52e2
--- /dev/null
+++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java
@@ -0,0 +1,354 @@
+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.assertInstanceOf;
+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 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, """
+ {
+ "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");
+ 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());
+ assertEquals("application/vnd.olrapi.note+json", readRequest.header("Accept"));
+
+ 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 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 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\"}");
+
+ ObsidianApiException exception = assertThrows(ObsidianApiException.class,
+ () -> client.readNote("Inbox.md"));
+
+ assertEquals(401, exception.getStatusCode());
+ assertTrue(exception.getMessage().contains("unauthorized"));
+ }
+
+ @Test
+ 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, """
+ [
+ {
+ "filename": "Inbox.md",
+ "score": 0.98,
+ "matches": [
+ {
+ "context": "Daily review notes",
+ "match": {
+ "start": 6,
+ "end": 12,
+ "source": "content"
+ }
+ }
+ ]
+ }
+ ]
+ """);
+
+ 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::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("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, """
+ [
+ {
+ "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}");
+
+ 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"));
+ }
+
+ @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) {
+ 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) 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());
+ 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, APPLICATION_JSON, null));
+ }
+
+ private void enqueueFailure(IOException failure) {
+ plannedResponses.add(new PlannedResponse(0, null, null, failure));
+ }
+
+ private List getCapturedRequests() {
+ return capturedRequests;
+ }
+ }
+
+ private record PlannedResponse(int code, String body, MediaType mediaType, IOException failure) {
+ }
+}
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
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..792ab23
--- /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: "312a55b766c11fe8a957b8843af58f08a64dde9051d4aba2c781bded7b84b143"
+publishedAt: "2026-03-29T19:04:32Z"
+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"
+maintainers:
+ - alexk-dev
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
+
+