@dn-inc/openclaw-seahorse 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,9 +3,16 @@
3
3
  Drop-in replacement for OpenClaw's built-in memory search, powered by [Seahorse](https://seahorse.dnotitia.ai).
4
4
 
5
5
  - Uploads local memory files (`MEMORY.md`, `memory/**/*.md`) to Seahorse Storage
6
+ - Optionally syncs the entire workspace for full semantic search over all files
6
7
  - Routes `memory_search` to Seahorse's vector search API
7
8
  - Keeps `memory_get` reading from local files (same as native)
8
9
 
10
+ ## Requirements
11
+
12
+ - Node.js 20+
13
+ - OpenClaw >= 2026.1.26
14
+ - Seahorse account (Storage + Table tenants)
15
+
9
16
  ## Quick Start (Zero Config)
10
17
 
11
18
  1. Install the plugin:
@@ -16,29 +23,21 @@ openclaw plugins install @dn-inc/openclaw-seahorse
16
23
 
17
24
  2. Get your Storage URL, Table URL, and API key from the [Seahorse console](https://seahorse.dnotitia.ai), then add to `plugins.entries` in `openclaw.json`:
18
25
 
19
- ```json
26
+ ```jsonc
20
27
  "openclaw-seahorse": {
21
28
  "config": {
22
29
  "storageUrl": "https://<your-storage-id>.api.seahorse.dnotitia.ai",
23
30
  "tableUrl": "https://<your-table-id>.api.seahorse.dnotitia.ai",
24
- "apiKey": "your-key-here"
31
+ "apiKey": "your-key-here",
32
+ // Optional: search your entire workspace, not just memory files
33
+ "syncWorkspace": true
25
34
  }
26
35
  }
27
36
  ```
28
37
 
29
38
  If you prefer not to store the key in config, you can set the `SEAHORSE_API_KEY` environment variable instead. The `apiKey` field also supports `${ENV_VAR}` syntax (e.g., `"${SEAHORSE_API_KEY}"`).
30
39
 
31
- That's it. With zero additional config, the plugin behaves identically to OpenClaw's built-in memory search:
32
-
33
- - **Hybrid search** (dense 70% + sparse 30%)
34
- - **6 results** per query
35
- - **0.35 minimum score** threshold
36
-
37
- ## Requirements
38
-
39
- - Node.js 20+
40
- - OpenClaw >= 2026.1.26
41
- - Seahorse account (Storage + Table tenants)
40
+ That's it. The plugin auto-syncs your files on startup and watches for changes in real time. With zero additional config, search behaves identically to OpenClaw's built-in memory search.
42
41
 
43
42
  ## Config Reference
44
43
 
@@ -50,7 +49,7 @@ That's it. With zero additional config, the plugin behaves identically to OpenCl
50
49
  | `tableUrl` | string | Yes | Seahorse Table tenant URL |
51
50
  | `apiKey` | string | No | API key. Auto-reads from `SEAHORSE_API_KEY` env var if omitted. Also supports `${ENV_VAR}` syntax. |
52
51
 
53
- ### Layer 1: Search Behavior (optional)
52
+ ### Layer 1: Search & Sync Behavior (optional)
54
53
 
55
54
  These override the OpenClaw-compatible defaults.
56
55
 
@@ -59,6 +58,8 @@ These override the OpenClaw-compatible defaults.
59
58
  | `topK` | number | `6` | Number of results to return |
60
59
  | `minScore` | number | `0.35` | Minimum relevance score (0-1) |
61
60
  | `searchMode` | string | `"hybrid"` | `"dense"`, `"sparse"`, or `"hybrid"` |
61
+ | `syncWorkspace` | boolean | `false` | Sync entire workspace (not just memory files) |
62
+ | `syncBlacklist` | string[] | `[]` | Additional blacklist patterns for workspace sync (see [File Sync](#file-sync)) |
62
63
 
63
64
  Example — return more results with a lower score threshold:
64
65
 
@@ -146,7 +147,7 @@ The plugin ships with OpenClaw-compatible defaults so it works as a drop-in repl
146
147
 
147
148
  ### `memory_search`
148
149
 
149
- Semantic vector search over memory files via Seahorse.
150
+ Semantic vector search over synced files via Seahorse.
150
151
 
151
152
  | Parameter | Type | Required | Description |
152
153
  |-----------|------|----------|-------------|
@@ -174,7 +175,28 @@ All memory files are stored under the `openclaw/` prefix in Seahorse Storage (e.
174
175
 
175
176
  The plugin runs a background service (`seahorse-sync`) that keeps Seahorse Storage in sync:
176
177
 
177
- - **Initial sync**: scans existing memory files on startup, uploads only changed files
178
+ - **Initial sync**: scans files on startup, uploads only changed files
178
179
  - **Live watch**: detects file create/modify/delete via `fs.watch` (1.5s debounce)
179
180
  - **Dedup**: mtime-based state tracking prevents redundant uploads
180
181
  - **Delete sync**: local file deletion triggers Seahorse Storage deletion
182
+
183
+ By default, only memory files (`MEMORY.md` + `memory/**/*.md`) are synced. Set `syncWorkspace: true` to sync the entire workspace — all files become searchable via `memory_search` without any tool or API changes.
184
+
185
+ When workspace sync is enabled, dotfiles/dotdirs (`.*`) are always excluded. Add further patterns via `syncBlacklist`:
186
+
187
+ ```json
188
+ {
189
+ "storageUrl": "...", "tableUrl": "...", "apiKey": "...",
190
+ "syncWorkspace": true,
191
+ "syncBlacklist": ["node_modules/", "dist/", "*.log", "*.tmp"]
192
+ }
193
+ ```
194
+
195
+ Supported blacklist patterns:
196
+
197
+ | Pattern | Example | Matches |
198
+ |---------|---------|---------|
199
+ | `"dir/"` | `"node_modules/"` | Directory with that exact name |
200
+ | `"*.ext"` | `"*.tmp"` | Any segment ending with `.tmp` |
201
+ | `"prefix*"` | `".*"` | Any segment starting with `.` (built-in) |
202
+ | `"name"` | `"Thumbs.db"` | Exact filename in any directory |
package/index.ts CHANGED
@@ -37,6 +37,12 @@ const plugin: OpenClawPluginDefinition = {
37
37
  const rawSparse = (rawConfig.sparse ?? {}) as Record<string, unknown>;
38
38
  const rawFusion = (rawConfig.fusion ?? {}) as Record<string, unknown>;
39
39
 
40
+ const syncWorkspace = rawConfig.syncWorkspace === true;
41
+ const rawBlacklist = rawConfig.syncBlacklist;
42
+ const syncBlacklist: string[] = Array.isArray(rawBlacklist)
43
+ ? rawBlacklist.filter((item): item is string => typeof item === "string")
44
+ : [];
45
+
40
46
  config = {
41
47
  storageUrl: resolveEnvVars(String(rawConfig.storageUrl ?? "")),
42
48
  tableUrl: resolveEnvVars(String(rawConfig.tableUrl ?? "")),
@@ -59,6 +65,8 @@ const plugin: OpenClawPluginDefinition = {
59
65
  ...(rawFusion.k != null && { k: parsePositiveNumber(rawFusion.k, "fusion.k") }),
60
66
  },
61
67
  projection: String(rawConfig.projection ?? DEFAULTS.projection),
68
+ syncWorkspace,
69
+ syncBlacklist,
62
70
  };
63
71
  } catch (err: unknown) {
64
72
  const msg = err instanceof Error ? err.message : String(err);
@@ -87,7 +95,7 @@ const plugin: OpenClawPluginDefinition = {
87
95
 
88
96
  registerMemorySearchTool(api, config, client);
89
97
  registerMemoryGetTool(api);
90
- registerSyncService(api, client);
98
+ registerSyncService(api, client, config);
91
99
 
92
100
  api.logger.info("[openclaw-seahorse] Plugin registered (memory_search, memory_get, sync service).");
93
101
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dn-inc/openclaw-seahorse",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "Seahorse memory plugin for OpenClaw",
6
6
  "license": "MIT",
7
7
  "main": "./index.ts",
package/src/helpers.ts CHANGED
@@ -103,6 +103,83 @@ export function parseUnitInterval(raw: unknown, name: string): number | undefine
103
103
  return n;
104
104
  }
105
105
 
106
+ /**
107
+ * Check whether a POSIX relative path matches any blacklist pattern.
108
+ * Each pattern is tested against every segment (directory or file name) of the path:
109
+ * - `"dir/"` — directory exact match (segment equals name without trailing slash)
110
+ * - `"*.ext"` — suffix wildcard (segment ends with ext portion)
111
+ * - `"prefix*"` — prefix wildcard (segment starts with prefix portion)
112
+ * - `"name"` — exact match (segment equals pattern exactly)
113
+ */
114
+ export function isBlacklisted(relPath: string, blacklist: readonly string[]): boolean {
115
+ if (blacklist.length === 0) return false;
116
+ const segments = relPath.split("/");
117
+ for (const pattern of blacklist) {
118
+ if (pattern.endsWith("/")) {
119
+ // Directory exact match — match any directory segment
120
+ const dirName = pattern.slice(0, -1);
121
+ // All segments except the last are directories
122
+ for (let i = 0; i < segments.length - 1; i++) {
123
+ if (segments[i] === dirName) return true;
124
+ }
125
+ } else if (pattern.startsWith("*")) {
126
+ // Suffix wildcard — match any segment ending with the suffix
127
+ const suffix = pattern.slice(1);
128
+ for (const seg of segments) {
129
+ if (seg.endsWith(suffix)) return true;
130
+ }
131
+ } else if (pattern.endsWith("*")) {
132
+ // Prefix wildcard — match any segment starting with the prefix
133
+ const prefix = pattern.slice(0, -1);
134
+ for (const seg of segments) {
135
+ if (seg.startsWith(prefix)) return true;
136
+ }
137
+ } else {
138
+ // Exact name match — match any segment
139
+ for (const seg of segments) {
140
+ if (seg === pattern) return true;
141
+ }
142
+ }
143
+ }
144
+ return false;
145
+ }
146
+
147
+ /**
148
+ * Recursively find all files in a workspace directory, excluding blacklisted paths.
149
+ * Blacklisted directories are not entered (efficient pruning).
150
+ */
151
+ export async function findWorkspaceFiles(
152
+ workspaceDir: string,
153
+ blacklist: readonly string[],
154
+ ): Promise<string[]> {
155
+ const files: string[] = [];
156
+ await collectAllFiles(workspaceDir, "", blacklist, files);
157
+ return files;
158
+ }
159
+
160
+ /** Recursively collect files, skipping blacklisted entries. */
161
+ async function collectAllFiles(
162
+ absDir: string,
163
+ relDir: string,
164
+ blacklist: readonly string[],
165
+ out: string[],
166
+ ): Promise<void> {
167
+ const entries = await fs.readdir(absDir, { withFileTypes: true });
168
+ for (const entry of entries) {
169
+ const relPath = relDir ? toPosixPath(path.join(relDir, entry.name)) : entry.name;
170
+ if (entry.isDirectory()) {
171
+ // Construct a fake child path so directory patterns ("dir/") are checked
172
+ // against this directory name appearing as a non-last segment.
173
+ if (isBlacklisted(relPath + "/_", blacklist)) continue;
174
+ await collectAllFiles(path.join(absDir, entry.name), relPath, blacklist, out);
175
+ } else if (entry.isFile()) {
176
+ if (!isBlacklisted(relPath, blacklist)) {
177
+ out.push(relPath);
178
+ }
179
+ }
180
+ }
181
+ }
182
+
106
183
  /** Check whether a resolved absolute path is within the allowed memory directories. */
107
184
  export function isAllowedMemoryPath(resolvedPath: string, workspaceDir: string): boolean {
108
185
  const normalizedWorkspace = path.resolve(workspaceDir);
package/src/service.ts CHANGED
@@ -3,8 +3,10 @@ import { watch, type FSWatcher } from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { OpenClawPluginApi, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
5
5
  import type { SeahorseClient } from "./client.js";
6
+ import type { SeahorseConfig } from "./types.js";
7
+ import { DEFAULT_BLACKLIST } from "./types.js";
6
8
  import { SyncState } from "./sync-state.js";
7
- import { sanitizeError, findMemoryFiles, isAllowedMemoryPath, toPosixPath } from "./helpers.js";
9
+ import { sanitizeError, findMemoryFiles, findWorkspaceFiles, isAllowedMemoryPath, isBlacklisted, toPosixPath } from "./helpers.js";
8
10
 
9
11
  const DEBOUNCE_MS = 1500;
10
12
 
@@ -14,6 +16,7 @@ const DEBOUNCE_MS = 1500;
14
16
  export function registerSyncService(
15
17
  api: OpenClawPluginApi,
16
18
  client: SeahorseClient,
19
+ config: SeahorseConfig,
17
20
  ): void {
18
21
  let watchers: FSWatcher[] = [];
19
22
  let syncState: SyncState;
@@ -35,15 +38,19 @@ export function registerSyncService(
35
38
  await syncState.load();
36
39
 
37
40
  const memoryDir = path.join(workspaceDir, "memory");
41
+ const mergedBlacklist = [...DEFAULT_BLACKLIST, ...config.syncBlacklist];
42
+ const findFiles = config.syncWorkspace
43
+ ? () => findWorkspaceFiles(workspaceDir, mergedBlacklist)
44
+ : () => findMemoryFiles(workspaceDir);
38
45
 
39
- // Scan and upload memory files that need syncing
40
- const syncMemoryFiles = async (label: string): Promise<number> => {
46
+ // Scan and upload files that need syncing
47
+ const syncFiles = async (label: string): Promise<number> => {
41
48
  let files: string[];
42
49
  try {
43
- files = await findMemoryFiles(workspaceDir);
50
+ files = await findFiles();
44
51
  } catch (err: unknown) {
45
52
  logger.warn(
46
- `[openclaw-seahorse] Error scanning memory files (${label}): ${sanitizeError(err)}`,
53
+ `[openclaw-seahorse] Error scanning files (${label}): ${sanitizeError(err)}`,
47
54
  );
48
55
  return 0;
49
56
  }
@@ -79,7 +86,7 @@ export function registerSyncService(
79
86
  };
80
87
 
81
88
  // Initial sync
82
- const fileCount = await syncMemoryFiles("initial");
89
+ const fileCount = await syncFiles("initial");
83
90
  logger.info(
84
91
  `[openclaw-seahorse] Initial sync complete. ${fileCount} file(s) checked.`,
85
92
  );
@@ -87,9 +94,11 @@ export function registerSyncService(
87
94
  abortController = new AbortController();
88
95
 
89
96
  const processFileEvent = (relPath: string) => {
90
- const absCheck = path.resolve(workspaceDir, relPath);
91
- if (!isAllowedMemoryPath(absCheck, workspaceDir)) {
92
- return;
97
+ if (config.syncWorkspace) {
98
+ if (isBlacklisted(relPath, mergedBlacklist)) return;
99
+ } else {
100
+ const absCheck = path.resolve(workspaceDir, relPath);
101
+ if (!isAllowedMemoryPath(absCheck, workspaceDir)) return;
93
102
  }
94
103
 
95
104
  logger.debug?.(`[openclaw-seahorse] File event: ${relPath} (debounce ${DEBOUNCE_MS}ms)`);
@@ -141,89 +150,111 @@ export function registerSyncService(
141
150
  );
142
151
  };
143
152
 
144
- const setupMemoryDirWatcher = (): void => {
153
+ if (config.syncWorkspace) {
154
+ // Workspace mode: single recursive watcher on the workspace root
145
155
  try {
146
- const memWatcher = watch(
147
- memoryDir,
156
+ const wsWatcher = watch(
157
+ workspaceDir,
148
158
  { recursive: true, signal: abortController.signal },
149
159
  (_eventType, filename) => {
150
160
  if (!filename) return;
151
- const normalized = filename.replace(/\\/g, "/");
152
- if (!normalized.endsWith(".md")) return;
153
- const relPath = toPosixPath(path.join("memory", normalized));
161
+ const relPath = toPosixPath(filename);
154
162
  processFileEvent(relPath);
155
163
  },
156
164
  );
157
- watchers.push(memWatcher);
158
- logger.info("[openclaw-seahorse] Watching memory/ directory.");
165
+ watchers.push(wsWatcher);
166
+ logger.info("[openclaw-seahorse] Watching workspace directory (recursive).");
159
167
  } catch (err: unknown) {
160
168
  logger.warn(
161
- `[openclaw-seahorse] Could not watch memory/ directory: ${sanitizeError(err)}`,
169
+ `[openclaw-seahorse] Could not watch workspace directory: ${sanitizeError(err)}`,
162
170
  );
163
171
  }
164
- };
172
+ } else {
173
+ // Memory-only mode: root watcher + lazy memory/ watcher
174
+ const setupMemoryDirWatcher = (): void => {
175
+ try {
176
+ const memWatcher = watch(
177
+ memoryDir,
178
+ { recursive: true, signal: abortController.signal },
179
+ (_eventType, filename) => {
180
+ if (!filename) return;
181
+ const normalized = filename.replace(/\\/g, "/");
182
+ if (!normalized.endsWith(".md")) return;
183
+ const relPath = toPosixPath(path.join("memory", normalized));
184
+ processFileEvent(relPath);
185
+ },
186
+ );
187
+ watchers.push(memWatcher);
188
+ logger.info("[openclaw-seahorse] Watching memory/ directory.");
189
+ } catch (err: unknown) {
190
+ logger.warn(
191
+ `[openclaw-seahorse] Could not watch memory/ directory: ${sanitizeError(err)}`,
192
+ );
193
+ }
194
+ };
165
195
 
166
- // Watch workspace root for MEMORY.md changes and memory/ directory creation
167
- let memoryDirWatcherSet = false;
168
- try {
169
- const rootWatcher = watch(
170
- workspaceDir,
171
- { signal: abortController.signal },
172
- (eventType, filename) => {
173
- if (filename === "MEMORY.md") {
174
- processFileEvent("MEMORY.md");
175
- }
176
- // Detect memory/ directory creation
177
- if (!memoryDirWatcherSet && filename === "memory" && eventType === "rename") {
178
- memoryDirWatcherSet = true;
179
- const op = (async () => {
180
- try {
181
- const dirStat = await fs.stat(memoryDir);
182
- if (!dirStat.isDirectory()) {
196
+ // Watch workspace root for MEMORY.md changes and memory/ directory creation
197
+ let memoryDirWatcherSet = false;
198
+ try {
199
+ const rootWatcher = watch(
200
+ workspaceDir,
201
+ { signal: abortController.signal },
202
+ (eventType, filename) => {
203
+ if (filename === "MEMORY.md") {
204
+ processFileEvent("MEMORY.md");
205
+ }
206
+ // Detect memory/ directory creation
207
+ if (!memoryDirWatcherSet && filename === "memory" && eventType === "rename") {
208
+ memoryDirWatcherSet = true;
209
+ const op = (async () => {
210
+ try {
211
+ const dirStat = await fs.stat(memoryDir);
212
+ if (!dirStat.isDirectory()) {
213
+ memoryDirWatcherSet = false;
214
+ return;
215
+ }
216
+ setupMemoryDirWatcher();
217
+ // Catch-up sync: upload files written before/during watcher setup
218
+ const count = await syncFiles("catch-up");
219
+ if (count > 0) {
220
+ logger.info(
221
+ `[openclaw-seahorse] Catch-up sync complete. ${count} file(s) checked.`,
222
+ );
223
+ }
224
+ } catch (err: unknown) {
183
225
  memoryDirWatcherSet = false;
184
- return;
185
- }
186
- setupMemoryDirWatcher();
187
- // Catch-up sync: upload files written before/during watcher setup
188
- const count = await syncMemoryFiles("catch-up");
189
- if (count > 0) {
190
- logger.info(
191
- `[openclaw-seahorse] Catch-up sync complete. ${count} file(s) checked.`,
226
+ logger.warn(
227
+ `[openclaw-seahorse] Catch-up sync error: ${sanitizeError(err)}`,
192
228
  );
193
229
  }
194
- } catch (err: unknown) {
195
- memoryDirWatcherSet = false;
196
- logger.warn(
197
- `[openclaw-seahorse] Catch-up sync error: ${sanitizeError(err)}`,
198
- );
199
- }
200
- })();
201
- trackOp(op);
202
- }
203
- },
204
- );
205
- watchers.push(rootWatcher);
206
- } catch (err: unknown) {
207
- logger.warn(
208
- `[openclaw-seahorse] Could not watch workspace root: ${sanitizeError(err)}`,
209
- );
210
- }
211
-
212
- // Watch memory/ directory if it already exists
213
- try {
214
- await fs.access(memoryDir);
215
- memoryDirWatcherSet = true;
216
- setupMemoryDirWatcher();
217
- } catch (err: unknown) {
218
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
219
- logger.info(
220
- "[openclaw-seahorse] memory/ directory does not exist yet. Will start watching when created.",
230
+ })();
231
+ trackOp(op);
232
+ }
233
+ },
221
234
  );
222
- } else {
235
+ watchers.push(rootWatcher);
236
+ } catch (err: unknown) {
223
237
  logger.warn(
224
- `[openclaw-seahorse] Could not access memory/ directory: ${sanitizeError(err)}`,
238
+ `[openclaw-seahorse] Could not watch workspace root: ${sanitizeError(err)}`,
225
239
  );
226
240
  }
241
+
242
+ // Watch memory/ directory if it already exists
243
+ try {
244
+ await fs.access(memoryDir);
245
+ memoryDirWatcherSet = true;
246
+ setupMemoryDirWatcher();
247
+ } catch (err: unknown) {
248
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
249
+ logger.info(
250
+ "[openclaw-seahorse] memory/ directory does not exist yet. Will start watching when created.",
251
+ );
252
+ } else {
253
+ logger.warn(
254
+ `[openclaw-seahorse] Could not access memory/ directory: ${sanitizeError(err)}`,
255
+ );
256
+ }
257
+ }
227
258
  }
228
259
  },
229
260
 
package/src/types.ts CHANGED
@@ -35,8 +35,13 @@ export const DEFAULTS = {
35
35
  sparse: { column: "sparse_vector" },
36
36
  fusion: { type: "rrf", alpha: 0.7 },
37
37
  projection: "id, text, metadata, distance, score",
38
+ syncWorkspace: false,
39
+ syncBlacklist: [] as string[],
38
40
  } as const;
39
41
 
42
+ /** Default blacklist patterns applied when syncWorkspace is enabled. */
43
+ export const DEFAULT_BLACKLIST: readonly string[] = [".*"] as const;
44
+
40
45
  /** Seahorse RRF default k parameter (server default). */
41
46
  export const RRF_DEFAULT_K = 60;
42
47
 
@@ -55,6 +60,8 @@ export interface SeahorseConfig {
55
60
  sparse: SparseConfig;
56
61
  fusion: FusionConfig;
57
62
  projection: string;
63
+ syncWorkspace: boolean;
64
+ syncBlacklist: string[];
58
65
  }
59
66
 
60
67
  /** Parsed search result from Seahorse */