@karmaniverous/jeeves-watcher-openclaw 0.2.0 → 0.3.1

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
@@ -38,7 +38,7 @@ Set the `apiUrl` in the plugin configuration to point at your jeeves-watcher ser
38
38
 
39
39
  ```json
40
40
  {
41
- "apiUrl": "http://localhost:3000"
41
+ "apiUrl": "http://127.0.0.1:3458"
42
42
  }
43
43
  ```
44
44
 
@@ -54,6 +54,8 @@ Set the `apiUrl` in the plugin configuration to point at your jeeves-watcher ser
54
54
  | `watcher_config_apply` | Apply a new configuration |
55
55
  | `watcher_reindex` | Trigger a full reindex |
56
56
  | `watcher_issues` | List indexing issues and errors |
57
+ | `memory_search` | Semantically search memory files (MEMORY.md and memory/*.md) |
58
+ | `memory_get` | Read content from memory files with optional line range |
57
59
 
58
60
  ## Documentation
59
61
 
package/dist/cli.js CHANGED
@@ -22,15 +22,12 @@ import { fileURLToPath } from 'url';
22
22
  const PLUGIN_ID = 'jeeves-watcher-openclaw';
23
23
  /** Resolve the OpenClaw home directory. */
24
24
  function resolveOpenClawHome() {
25
- // 1. OPENCLAW_CONFIG points directly to the config file
26
25
  if (process.env.OPENCLAW_CONFIG) {
27
26
  return dirname(resolve(process.env.OPENCLAW_CONFIG));
28
27
  }
29
- // 2. OPENCLAW_HOME points to the .openclaw directory
30
28
  if (process.env.OPENCLAW_HOME) {
31
29
  return resolve(process.env.OPENCLAW_HOME);
32
30
  }
33
- // 3. Default location
34
31
  return join(homedir(), '.openclaw');
35
32
  }
36
33
  /** Resolve the config file path. */
@@ -43,8 +40,6 @@ function resolveConfigPath(home) {
43
40
  /** Get the package root (where this CLI lives). */
44
41
  function getPackageRoot() {
45
42
  const thisFile = fileURLToPath(import.meta.url);
46
- // In dist: dist/cli.js → package root is ..
47
- // In src: src/cli.ts → package root is ..
48
43
  return resolve(dirname(thisFile), '..');
49
44
  }
50
45
  /** Read and parse JSON, returning null on failure. */
@@ -60,6 +55,79 @@ function readJson(path) {
60
55
  function writeJson(path, data) {
61
56
  writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
62
57
  }
58
+ /**
59
+ * Patch an allowlist array: add or remove the plugin ID.
60
+ * Returns a log message if a change was made, or undefined.
61
+ */
62
+ function patchAllowList(parent, key, label, mode) {
63
+ if (!Array.isArray(parent[key]) || parent[key].length === 0)
64
+ return undefined;
65
+ const list = parent[key];
66
+ if (mode === 'add') {
67
+ if (!list.includes(PLUGIN_ID)) {
68
+ list.push(PLUGIN_ID);
69
+ return `Added "${PLUGIN_ID}" to ${label}`;
70
+ }
71
+ }
72
+ else {
73
+ const filtered = list.filter((id) => id !== PLUGIN_ID);
74
+ if (filtered.length !== list.length) {
75
+ parent[key] = filtered;
76
+ return `Removed "${PLUGIN_ID}" from ${label}`;
77
+ }
78
+ }
79
+ return undefined;
80
+ }
81
+ /** Patch OpenClaw config for install or uninstall. Returns log messages. */
82
+ function patchConfig(config, mode) {
83
+ const messages = [];
84
+ // Ensure plugins section
85
+ if (!config.plugins || typeof config.plugins !== 'object') {
86
+ config.plugins = {};
87
+ }
88
+ const plugins = config.plugins;
89
+ // plugins.allow
90
+ const pluginAllow = patchAllowList(plugins, 'allow', 'plugins.allow', mode);
91
+ if (pluginAllow)
92
+ messages.push(pluginAllow);
93
+ // plugins.entries
94
+ if (!plugins.entries || typeof plugins.entries !== 'object') {
95
+ plugins.entries = {};
96
+ }
97
+ const entries = plugins.entries;
98
+ if (mode === 'add') {
99
+ if (!entries[PLUGIN_ID]) {
100
+ entries[PLUGIN_ID] = { enabled: true };
101
+ messages.push(`Added "${PLUGIN_ID}" to plugins.entries`);
102
+ }
103
+ }
104
+ else if (PLUGIN_ID in entries) {
105
+ Reflect.deleteProperty(entries, PLUGIN_ID);
106
+ messages.push(`Removed "${PLUGIN_ID}" from plugins.entries`);
107
+ }
108
+ // plugins.slots — claim or release the memory slot
109
+ if (!plugins.slots || typeof plugins.slots !== 'object') {
110
+ plugins.slots = {};
111
+ }
112
+ const slots = plugins.slots;
113
+ if (mode === 'add') {
114
+ const prev = slots.memory;
115
+ slots.memory = PLUGIN_ID;
116
+ if (prev !== PLUGIN_ID) {
117
+ messages.push(`Set plugins.slots.memory to "${PLUGIN_ID}"${prev ? ` (was "${prev}")` : ''}`);
118
+ }
119
+ }
120
+ else if (slots.memory === PLUGIN_ID) {
121
+ slots.memory = 'memory-core';
122
+ messages.push(`Reverted plugins.slots.memory to "memory-core"`);
123
+ }
124
+ // tools.allow
125
+ const tools = (config.tools ?? {});
126
+ const toolAllow = patchAllowList(tools, 'allow', 'tools.allow', mode);
127
+ if (toolAllow)
128
+ messages.push(toolAllow);
129
+ return messages;
130
+ }
63
131
  /** Install the plugin into OpenClaw's extensions directory. */
64
132
  function install() {
65
133
  const home = resolveOpenClawHome();
@@ -71,19 +139,16 @@ function install() {
71
139
  console.log(`Extensions dir: ${extDir}`);
72
140
  console.log(`Package root: ${pkgRoot}`);
73
141
  console.log();
74
- // Validate OpenClaw home exists
75
142
  if (!existsSync(home)) {
76
143
  console.error(`Error: OpenClaw home directory not found at ${home}`);
77
144
  console.error('Set OPENCLAW_HOME or OPENCLAW_CONFIG if using a non-default installation.');
78
145
  process.exit(1);
79
146
  }
80
- // Validate config exists
81
147
  if (!existsSync(configPath)) {
82
148
  console.error(`Error: OpenClaw config not found at ${configPath}`);
83
149
  console.error('Set OPENCLAW_CONFIG if using a non-default config location.');
84
150
  process.exit(1);
85
151
  }
86
- // Validate package root has openclaw.plugin.json
87
152
  const pluginManifestPath = join(pkgRoot, 'openclaw.plugin.json');
88
153
  if (!existsSync(pluginManifestPath)) {
89
154
  console.error(`Error: openclaw.plugin.json not found at ${pluginManifestPath}`);
@@ -95,9 +160,7 @@ function install() {
95
160
  rmSync(extDir, { recursive: true, force: true });
96
161
  }
97
162
  mkdirSync(extDir, { recursive: true });
98
- // Copy dist/, openclaw.plugin.json, package.json
99
- const filesToCopy = ['dist', 'openclaw.plugin.json', 'package.json'];
100
- for (const file of filesToCopy) {
163
+ for (const file of ['dist', 'openclaw.plugin.json', 'package.json']) {
101
164
  const src = join(pkgRoot, file);
102
165
  const dest = join(extDir, file);
103
166
  if (existsSync(src)) {
@@ -105,13 +168,12 @@ function install() {
105
168
  console.log(` ✓ ${file}`);
106
169
  }
107
170
  }
108
- // Copy node_modules if present (for runtime dependencies)
109
171
  const nodeModulesSrc = join(pkgRoot, 'node_modules');
110
172
  if (existsSync(nodeModulesSrc)) {
111
173
  cpSync(nodeModulesSrc, join(extDir, 'node_modules'), { recursive: true });
112
174
  console.log(' ✓ node_modules');
113
175
  }
114
- // Patch OpenClaw config
176
+ // Patch config
115
177
  console.log();
116
178
  console.log('Patching OpenClaw config...');
117
179
  const config = readJson(configPath);
@@ -119,36 +181,8 @@ function install() {
119
181
  console.error(`Error: Could not parse ${configPath}`);
120
182
  process.exit(1);
121
183
  }
122
- // Ensure plugins section exists
123
- if (!config.plugins || typeof config.plugins !== 'object') {
124
- config.plugins = {};
125
- }
126
- const plugins = config.plugins;
127
- // If plugins.allow exists and is populated, add ourselves to it
128
- if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
129
- const allow = plugins.allow;
130
- if (!allow.includes(PLUGIN_ID)) {
131
- allow.push(PLUGIN_ID);
132
- console.log(` ✓ Added "${PLUGIN_ID}" to plugins.allow`);
133
- }
134
- }
135
- // Add to plugins.entries
136
- if (!plugins.entries || typeof plugins.entries !== 'object') {
137
- plugins.entries = {};
138
- }
139
- const entries = plugins.entries;
140
- if (!entries[PLUGIN_ID]) {
141
- entries[PLUGIN_ID] = { enabled: true };
142
- console.log(` ✓ Added "${PLUGIN_ID}" to plugins.entries`);
143
- }
144
- // If tools.allow exists and is populated, add ourselves to it
145
- const tools = (config.tools ?? {});
146
- if (Array.isArray(tools.allow) && tools.allow.length > 0) {
147
- const toolsAllow = tools.allow;
148
- if (!toolsAllow.includes(PLUGIN_ID)) {
149
- toolsAllow.push(PLUGIN_ID);
150
- console.log(` ✓ Added "${PLUGIN_ID}" to tools.allow`);
151
- }
184
+ for (const msg of patchConfig(config, 'add')) {
185
+ console.log(` ✓ ${msg}`);
152
186
  }
153
187
  writeJson(configPath, config);
154
188
  console.log();
@@ -164,7 +198,6 @@ function uninstall() {
164
198
  console.log(`Config: ${configPath}`);
165
199
  console.log(`Extensions dir: ${extDir}`);
166
200
  console.log();
167
- // Remove extensions directory
168
201
  if (existsSync(extDir)) {
169
202
  rmSync(extDir, { recursive: true, force: true });
170
203
  console.log(`✓ Removed ${extDir}`);
@@ -172,30 +205,12 @@ function uninstall() {
172
205
  else {
173
206
  console.log(` (extensions directory not found, skipping)`);
174
207
  }
175
- // Patch OpenClaw config
176
208
  if (existsSync(configPath)) {
177
209
  console.log('Patching OpenClaw config...');
178
210
  const config = readJson(configPath);
179
211
  if (config) {
180
- const plugins = (config.plugins ?? {});
181
- // Remove from plugins.allow if it exists and is populated
182
- if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
183
- plugins.allow = plugins.allow.filter((id) => id !== PLUGIN_ID);
184
- console.log(` ✓ Removed "${PLUGIN_ID}" from plugins.allow`);
185
- }
186
- // Remove from plugins.entries
187
- if (plugins.entries && typeof plugins.entries === 'object') {
188
- const entries = plugins.entries;
189
- if (PLUGIN_ID in entries) {
190
- Reflect.deleteProperty(entries, PLUGIN_ID);
191
- console.log(` ✓ Removed "${PLUGIN_ID}" from plugins.entries`);
192
- }
193
- }
194
- // Remove from tools.allow if it exists and is populated
195
- const tools = (config.tools ?? {});
196
- if (Array.isArray(tools.allow) && tools.allow.length > 0) {
197
- tools.allow = tools.allow.filter((id) => id !== PLUGIN_ID);
198
- console.log(` ✓ Removed "${PLUGIN_ID}" from tools.allow`);
212
+ for (const msg of patchConfig(config, 'remove')) {
213
+ console.log(` ✓ ${msg}`);
199
214
  }
200
215
  writeJson(configPath, config);
201
216
  }
@@ -234,3 +249,5 @@ switch (command) {
234
249
  }
235
250
  break;
236
251
  }
252
+
253
+ export { patchConfig };
package/dist/index.js CHANGED
@@ -1,8 +1,31 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { readFile } from 'node:fs/promises';
4
+
1
5
  /**
2
6
  * @module plugin/helpers
3
7
  * Shared types and utility functions for the OpenClaw plugin tool registrations.
4
8
  */
5
9
  const DEFAULT_API_URL = 'http://127.0.0.1:3458';
10
+ /** Source identifier for virtual rule registration. */
11
+ const PLUGIN_SOURCE = 'jeeves-watcher-openclaw';
12
+ /** Normalize a path to forward slashes and lowercase drive letter on Windows. */
13
+ function normalizePath(p) {
14
+ let result = p.replace(/\\/g, '/');
15
+ if (/^[A-Z]:/.test(result)) {
16
+ result = result[0].toLowerCase() + result.slice(1);
17
+ }
18
+ return result;
19
+ }
20
+ /**
21
+ * Resolve the workspace path from gateway config.
22
+ * Priority: agent-specific > defaults > fallback (~/.openclaw/workspace).
23
+ */
24
+ function getWorkspacePath(api) {
25
+ const agentWorkspace = api.config?.agents?.entries?.['main']?.workspace ??
26
+ api.config?.agents?.defaults?.workspace;
27
+ return agentWorkspace ?? join(homedir(), '.openclaw', 'workspace');
28
+ }
6
29
  /** Resolve the watcher API base URL from plugin config. */
7
30
  function getApiUrl(api) {
8
31
  const url = api.config?.plugins?.entries?.['jeeves-watcher']?.config?.apiUrl;
@@ -54,238 +77,414 @@ async function fetchJson(url, init) {
54
77
  }
55
78
  return res.json();
56
79
  }
80
+ /** POST JSON to a URL and return parsed response. */
81
+ async function postJson(url, body) {
82
+ return fetchJson(url, {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json' },
85
+ body: JSON.stringify(body),
86
+ });
87
+ }
57
88
 
58
89
  /**
59
- * @module plugin
60
- * OpenClaw plugin entry point. Registers all jeeves-watcher tools.
90
+ * @module plugin/memoryTools
91
+ * memory_search and memory_get tool implementations with lazy init.
92
+ *
93
+ * Lazy init registers virtual inference rules with the watcher on first
94
+ * memory_search call. Re-attempts on failure. memory_get reads files
95
+ * directly from the filesystem with path validation.
61
96
  */
62
- /** Register all jeeves-watcher tools with the OpenClaw plugin API. */
63
- function register(api) {
64
- const baseUrl = getApiUrl(api);
65
- api.registerTool({
66
- name: 'watcher_status',
67
- description: 'Get jeeves-watcher service health, uptime, and collection statistics.',
68
- parameters: { type: 'object', properties: {} },
69
- execute: async () => {
70
- try {
71
- return ok(await fetchJson(`${baseUrl}/status`));
72
- }
73
- catch (error) {
74
- return connectionFail(error, baseUrl);
75
- }
76
- },
77
- }, { optional: true });
78
- api.registerTool({
79
- name: 'watcher_search',
80
- description: 'Semantic search over indexed documents. Supports Qdrant filters.',
81
- parameters: {
82
- type: 'object',
83
- required: ['query'],
84
- properties: {
85
- query: { type: 'string', description: 'Search query text.' },
86
- limit: {
87
- type: 'number',
88
- description: 'Max results (default 10).',
97
+ /** Build virtual inference rules for a workspace path. */
98
+ function buildVirtualRules(workspace) {
99
+ const ws = normalizePath(workspace);
100
+ return [
101
+ {
102
+ name: 'openclaw-memory-longterm',
103
+ description: 'OpenClaw long-term memory file',
104
+ match: {
105
+ type: 'object',
106
+ properties: {
107
+ file: {
108
+ type: 'object',
109
+ properties: {
110
+ path: { type: 'string', pattern: `^${ws}/MEMORY\\.md$` },
111
+ },
112
+ },
89
113
  },
90
- offset: {
91
- type: 'number',
92
- description: 'Number of results to skip for pagination.',
93
- },
94
- filter: {
114
+ },
115
+ schema: [
116
+ {
95
117
  type: 'object',
96
- description: 'Qdrant filter object.',
118
+ properties: {
119
+ domain: { type: 'string', set: 'memory' },
120
+ kind: { type: 'string', set: 'long-term' },
121
+ },
97
122
  },
98
- },
99
- },
100
- execute: async (_id, params) => {
101
- try {
102
- const body = { query: params.query };
103
- if (params.limit !== undefined)
104
- body.limit = params.limit;
105
- if (params.offset !== undefined)
106
- body.offset = params.offset;
107
- if (params.filter !== undefined)
108
- body.filter = params.filter;
109
- return ok(await fetchJson(`${baseUrl}/search`, {
110
- method: 'POST',
111
- headers: { 'Content-Type': 'application/json' },
112
- body: JSON.stringify(body),
113
- }));
114
- }
115
- catch (error) {
116
- return connectionFail(error, baseUrl);
117
- }
123
+ ],
118
124
  },
119
- }, { optional: true });
120
- api.registerTool({
121
- name: 'watcher_enrich',
122
- description: 'Set or update metadata on a document by file path.',
123
- parameters: {
124
- type: 'object',
125
- required: ['path', 'metadata'],
126
- properties: {
127
- path: {
128
- type: 'string',
129
- description: 'Relative file path of the document.',
125
+ {
126
+ name: 'openclaw-memory-daily',
127
+ description: 'OpenClaw daily memory logs',
128
+ match: {
129
+ type: 'object',
130
+ properties: {
131
+ file: {
132
+ type: 'object',
133
+ properties: {
134
+ path: { type: 'string', pattern: `^${ws}/memory/.*\\.md$` },
135
+ },
136
+ },
130
137
  },
131
- metadata: {
138
+ },
139
+ schema: [
140
+ {
132
141
  type: 'object',
133
- description: 'Key-value metadata to set on the document.',
142
+ properties: {
143
+ domain: { type: 'string', set: 'memory' },
144
+ kind: { type: 'string', set: 'daily-log' },
145
+ },
134
146
  },
135
- },
147
+ ],
136
148
  },
149
+ ];
150
+ }
151
+ /** Validate a path is within the memory scope. */
152
+ function isAllowedMemoryPath(filePath, workspace) {
153
+ const norm = normalizePath(filePath).toLowerCase();
154
+ const ws = normalizePath(workspace).toLowerCase();
155
+ // Exact match: {workspace}/MEMORY.md
156
+ if (norm === `${ws}/memory.md`)
157
+ return true;
158
+ // Prefix match: {workspace}/memory/**/*.md
159
+ if (norm.startsWith(`${ws}/memory/`) && norm.endsWith('.md'))
160
+ return true;
161
+ return false;
162
+ }
163
+ /**
164
+ * Create memory tool registrations for the plugin.
165
+ * Returns register functions for memory_search and memory_get.
166
+ */
167
+ function createMemoryTools(api, baseUrl) {
168
+ const workspace = getWorkspacePath(api);
169
+ const state = {
170
+ initialized: false,
171
+ workspace,
172
+ baseUrl,
173
+ };
174
+ /** Lazy init: register virtual rules with watcher. */
175
+ async function ensureInit() {
176
+ if (state.initialized)
177
+ return;
178
+ // Check watcher is reachable
179
+ await fetchJson(`${state.baseUrl}/status`);
180
+ // Clear any stale rules
181
+ await fetchJson(`${state.baseUrl}/rules/unregister`, {
182
+ method: 'DELETE',
183
+ headers: { 'Content-Type': 'application/json' },
184
+ body: JSON.stringify({ source: PLUGIN_SOURCE }),
185
+ });
186
+ // Register virtual rules
187
+ await postJson(`${state.baseUrl}/rules/register`, {
188
+ source: PLUGIN_SOURCE,
189
+ rules: buildVirtualRules(state.workspace),
190
+ });
191
+ state.initialized = true;
192
+ }
193
+ const memorySearch = async (_id, params) => {
194
+ try {
195
+ await ensureInit();
196
+ const body = {
197
+ query: params.query,
198
+ filter: {
199
+ must: [{ key: 'domain', match: { value: 'memory' } }],
200
+ },
201
+ };
202
+ if (params.maxResults !== undefined)
203
+ body.limit = params.maxResults;
204
+ const raw = await postJson(`${state.baseUrl}/search`, body);
205
+ // Map results to system-prompt-compatible format
206
+ const results = raw.map((r) => {
207
+ const payload = r.payload;
208
+ const mapped = {
209
+ path: payload.file_path,
210
+ snippet: payload.chunk_text,
211
+ score: r.score,
212
+ };
213
+ if (payload.line_start != null)
214
+ mapped.from = payload.line_start;
215
+ if (payload.line_end != null)
216
+ mapped.to = payload.line_end;
217
+ return mapped;
218
+ });
219
+ const minScore = typeof params.minScore === 'number' ? params.minScore : 0;
220
+ const filtered = results.filter((r) => typeof r.score === 'number' && r.score >= minScore);
221
+ return ok(filtered);
222
+ }
223
+ catch (error) {
224
+ state.initialized = false;
225
+ return connectionFail(error, state.baseUrl);
226
+ }
227
+ };
228
+ const memoryGet = async (_id, params) => {
229
+ try {
230
+ const filePath = String(params.path);
231
+ // Re-derive workspace on every call for immediate pickup of moves
232
+ const currentWorkspace = getWorkspacePath(api);
233
+ if (!isAllowedMemoryPath(filePath, currentWorkspace)) {
234
+ return fail(`Path not within memory scope. Allowed: ${currentWorkspace}/MEMORY.md and ${currentWorkspace}/memory/**/*.md`);
235
+ }
236
+ const content = await readFile(filePath, 'utf-8');
237
+ if (params.from !== undefined) {
238
+ const from = Number(params.from);
239
+ const lines = content.split('\n');
240
+ const startIdx = Math.max(0, from - 1); // 1-indexed to 0-indexed
241
+ const count = params.lines !== undefined
242
+ ? Number(params.lines)
243
+ : lines.length - startIdx;
244
+ const sliced = lines.slice(startIdx, startIdx + count);
245
+ return ok(sliced.join('\n'));
246
+ }
247
+ return ok(content);
248
+ }
249
+ catch (error) {
250
+ return fail(error);
251
+ }
252
+ };
253
+ return { memorySearch, memoryGet };
254
+ }
255
+
256
+ /**
257
+ * @module plugin/watcherTools
258
+ * Watcher tool registrations (watcher_* tools) for the OpenClaw plugin.
259
+ */
260
+ /** Register a single API tool with standard try/catch + ok/connectionFail. */
261
+ function registerApiTool(api, baseUrl, config) {
262
+ api.registerTool({
263
+ name: config.name,
264
+ description: config.description,
265
+ parameters: config.parameters,
137
266
  execute: async (_id, params) => {
138
267
  try {
139
- return ok(await fetchJson(`${baseUrl}/metadata`, {
140
- method: 'POST',
141
- headers: { 'Content-Type': 'application/json' },
142
- body: JSON.stringify({
143
- path: params.path,
144
- metadata: params.metadata,
145
- }),
146
- }));
268
+ const [endpoint, body] = config.buildRequest(params);
269
+ const url = `${baseUrl}${endpoint}`;
270
+ const data = body !== undefined
271
+ ? await postJson(url, body)
272
+ : await fetchJson(url);
273
+ return ok(data);
147
274
  }
148
275
  catch (error) {
149
276
  return connectionFail(error, baseUrl);
150
277
  }
151
278
  },
152
279
  }, { optional: true });
153
- api.registerTool({
154
- name: 'watcher_query',
155
- description: 'Query the merged virtual document via JSONPath.',
156
- parameters: {
157
- type: 'object',
158
- required: ['path'],
159
- properties: {
160
- path: {
161
- type: 'string',
162
- description: 'JSONPath expression.',
280
+ }
281
+ /** Pick defined keys from params into a body object. */
282
+ function pickDefined(params, keys) {
283
+ const body = {};
284
+ for (const key of keys) {
285
+ if (params[key] !== undefined)
286
+ body[key] = params[key];
287
+ }
288
+ return body;
289
+ }
290
+ /** Register all 8 watcher_* tools with the OpenClaw plugin API. */
291
+ function registerWatcherTools(api, baseUrl) {
292
+ const tools = [
293
+ {
294
+ name: 'watcher_status',
295
+ description: 'Get jeeves-watcher service health, uptime, and collection statistics.',
296
+ parameters: { type: 'object', properties: {} },
297
+ buildRequest: () => ['/status'],
298
+ },
299
+ {
300
+ name: 'watcher_search',
301
+ description: 'Semantic search over indexed documents. Supports Qdrant filters.',
302
+ parameters: {
303
+ type: 'object',
304
+ required: ['query'],
305
+ properties: {
306
+ query: { type: 'string', description: 'Search query text.' },
307
+ limit: { type: 'number', description: 'Max results (default 10).' },
308
+ offset: {
309
+ type: 'number',
310
+ description: 'Number of results to skip for pagination.',
311
+ },
312
+ filter: { type: 'object', description: 'Qdrant filter object.' },
163
313
  },
164
- resolve: {
165
- type: 'array',
166
- items: { type: 'string', enum: ['files', 'globals'] },
167
- description: 'Resolution scopes to include (e.g., ["files"], ["globals"], or both).',
314
+ },
315
+ buildRequest: (params) => {
316
+ const body = pickDefined(params, [
317
+ 'query',
318
+ 'limit',
319
+ 'offset',
320
+ 'filter',
321
+ ]);
322
+ return ['/search', body];
323
+ },
324
+ },
325
+ {
326
+ name: 'watcher_enrich',
327
+ description: 'Set or update metadata on a document by file path.',
328
+ parameters: {
329
+ type: 'object',
330
+ required: ['path', 'metadata'],
331
+ properties: {
332
+ path: {
333
+ type: 'string',
334
+ description: 'Relative file path of the document.',
335
+ },
336
+ metadata: {
337
+ type: 'object',
338
+ description: 'Key-value metadata to set on the document.',
339
+ },
168
340
  },
169
341
  },
342
+ buildRequest: (params) => [
343
+ '/metadata',
344
+ { path: params.path, metadata: params.metadata },
345
+ ],
170
346
  },
171
- execute: async (_id, params) => {
172
- try {
173
- const body = { path: params.path };
174
- if (params.resolve !== undefined)
175
- body.resolve = params.resolve;
176
- return ok(await fetchJson(`${baseUrl}/config/query`, {
177
- method: 'POST',
178
- headers: { 'Content-Type': 'application/json' },
179
- body: JSON.stringify(body),
180
- }));
181
- }
182
- catch (error) {
183
- return connectionFail(error, baseUrl);
184
- }
347
+ {
348
+ name: 'watcher_query',
349
+ description: 'Query the merged virtual document via JSONPath.',
350
+ parameters: {
351
+ type: 'object',
352
+ required: ['path'],
353
+ properties: {
354
+ path: { type: 'string', description: 'JSONPath expression.' },
355
+ resolve: {
356
+ type: 'array',
357
+ items: { type: 'string', enum: ['files', 'globals'] },
358
+ description: 'Resolution scopes to include (e.g., ["files"], ["globals"], or both).',
359
+ },
360
+ },
361
+ },
362
+ buildRequest: (params) => {
363
+ const body = pickDefined(params, ['path', 'resolve']);
364
+ return ['/config/query', body];
365
+ },
185
366
  },
186
- }, { optional: true });
187
- api.registerTool({
188
- name: 'watcher_validate',
189
- description: 'Validate a candidate config (or current config if omitted). Optionally test file paths against the config to preview rule matching and metadata output.',
190
- parameters: {
191
- type: 'object',
192
- properties: {
193
- config: {
194
- type: 'object',
195
- description: 'Candidate config (partial or full). Omit to validate current config.',
367
+ {
368
+ name: 'watcher_validate',
369
+ description: 'Validate a candidate config (or current config if omitted). Optionally test file paths against the config to preview rule matching and metadata output.',
370
+ parameters: {
371
+ type: 'object',
372
+ properties: {
373
+ config: {
374
+ type: 'object',
375
+ description: 'Candidate config (partial or full). Omit to validate current config.',
376
+ },
377
+ testPaths: {
378
+ type: 'array',
379
+ items: { type: 'string' },
380
+ description: 'File paths to test against the config for dry-run preview.',
381
+ },
382
+ },
383
+ },
384
+ buildRequest: (params) => {
385
+ const body = pickDefined(params, ['config', 'testPaths']);
386
+ return ['/config/validate', body];
387
+ },
388
+ },
389
+ {
390
+ name: 'watcher_config_apply',
391
+ description: 'Apply a full or partial config. Validates, writes to disk, and triggers configured reindex behavior.',
392
+ parameters: {
393
+ type: 'object',
394
+ required: ['config'],
395
+ properties: {
396
+ config: {
397
+ type: 'object',
398
+ description: 'Full or partial config to apply.',
399
+ },
196
400
  },
197
- testPaths: {
198
- type: 'array',
199
- items: { type: 'string' },
200
- description: 'File paths to test against the config for dry-run preview.',
401
+ },
402
+ buildRequest: (params) => ['/config/apply', { config: params.config }],
403
+ },
404
+ {
405
+ name: 'watcher_reindex',
406
+ description: 'Trigger a reindex of the watched files.',
407
+ parameters: {
408
+ type: 'object',
409
+ properties: {
410
+ scope: {
411
+ type: 'string',
412
+ enum: ['rules', 'full'],
413
+ description: 'Reindex scope: "rules" (default) re-applies inference rules; "full" re-embeds everything.',
414
+ },
201
415
  },
202
416
  },
417
+ buildRequest: (params) => [
418
+ '/config-reindex',
419
+ { scope: params.scope ?? 'rules' },
420
+ ],
203
421
  },
204
- execute: async (_id, params) => {
205
- try {
206
- const body = {};
207
- if (params.config !== undefined)
208
- body.config = params.config;
209
- if (params.testPaths !== undefined)
210
- body.testPaths = params.testPaths;
211
- return ok(await fetchJson(`${baseUrl}/config/validate`, {
212
- method: 'POST',
213
- headers: { 'Content-Type': 'application/json' },
214
- body: JSON.stringify(body),
215
- }));
216
- }
217
- catch (error) {
218
- return connectionFail(error, baseUrl);
219
- }
422
+ {
423
+ name: 'watcher_issues',
424
+ description: 'Get runtime embedding failures. Shows files that failed processing and why.',
425
+ parameters: { type: 'object', properties: {} },
426
+ buildRequest: () => ['/issues'],
220
427
  },
221
- }, { optional: true });
428
+ ];
429
+ for (const tool of tools) {
430
+ registerApiTool(api, baseUrl, tool);
431
+ }
432
+ }
433
+
434
+ /**
435
+ * @module plugin
436
+ * OpenClaw plugin entry point. Registers all jeeves-watcher tools.
437
+ */
438
+ /** Register all jeeves-watcher tools with the OpenClaw plugin API. */
439
+ function register(api) {
440
+ const baseUrl = getApiUrl(api);
441
+ // Register 8 watcher_* tools
442
+ registerWatcherTools(api, baseUrl);
443
+ // Register memory slot tools (memory_search + memory_get)
444
+ const { memorySearch, memoryGet } = createMemoryTools(api, baseUrl);
222
445
  api.registerTool({
223
- name: 'watcher_config_apply',
224
- description: 'Apply a full or partial config. Validates, writes to disk, and triggers configured reindex behavior.',
446
+ name: 'memory_search',
447
+ description: 'Semantically search MEMORY.md and memory/*.md files. Returns top snippets with path and line numbers.',
225
448
  parameters: {
226
449
  type: 'object',
227
- required: ['config'],
450
+ required: ['query'],
228
451
  properties: {
229
- config: {
230
- type: 'object',
231
- description: 'Full or partial config to apply.',
452
+ query: { type: 'string', description: 'Search query text.' },
453
+ maxResults: {
454
+ type: 'number',
455
+ description: 'Maximum results to return.',
456
+ },
457
+ minScore: {
458
+ type: 'number',
459
+ description: 'Minimum similarity score threshold.',
232
460
  },
233
461
  },
234
462
  },
235
- execute: async (_id, params) => {
236
- try {
237
- return ok(await fetchJson(`${baseUrl}/config/apply`, {
238
- method: 'POST',
239
- headers: { 'Content-Type': 'application/json' },
240
- body: JSON.stringify({ config: params.config }),
241
- }));
242
- }
243
- catch (error) {
244
- return connectionFail(error, baseUrl);
245
- }
246
- },
247
- }, { optional: true });
463
+ execute: memorySearch,
464
+ });
248
465
  api.registerTool({
249
- name: 'watcher_reindex',
250
- description: 'Trigger a reindex of the watched files.',
466
+ name: 'memory_get',
467
+ description: 'Read content from MEMORY.md or memory/*.md files with optional line range.',
251
468
  parameters: {
252
469
  type: 'object',
470
+ required: ['path'],
253
471
  properties: {
254
- scope: {
472
+ path: {
255
473
  type: 'string',
256
- enum: ['rules', 'full'],
257
- description: 'Reindex scope: "rules" (default) re-applies inference rules; "full" re-embeds everything.',
474
+ description: 'Path to the memory file to read.',
475
+ },
476
+ from: {
477
+ type: 'number',
478
+ description: 'Line number to start reading from (1-indexed).',
479
+ },
480
+ lines: {
481
+ type: 'number',
482
+ description: 'Number of lines to read.',
258
483
  },
259
484
  },
260
485
  },
261
- execute: async (_id, params) => {
262
- try {
263
- return ok(await fetchJson(`${baseUrl}/config-reindex`, {
264
- method: 'POST',
265
- headers: { 'Content-Type': 'application/json' },
266
- body: JSON.stringify({
267
- scope: params.scope ?? 'rules',
268
- }),
269
- }));
270
- }
271
- catch (error) {
272
- return connectionFail(error, baseUrl);
273
- }
274
- },
275
- }, { optional: true });
276
- api.registerTool({
277
- name: 'watcher_issues',
278
- description: 'Get runtime embedding failures. Shows files that failed processing and why.',
279
- parameters: { type: 'object', properties: {} },
280
- execute: async () => {
281
- try {
282
- return ok(await fetchJson(`${baseUrl}/issues`));
283
- }
284
- catch (error) {
285
- return connectionFail(error, baseUrl);
286
- }
287
- },
288
- }, { optional: true });
486
+ execute: memoryGet,
487
+ });
289
488
  }
290
489
 
291
490
  export { register as default };
@@ -46,6 +46,9 @@ curl -X POST http://127.0.0.1:<PORT>/config/query \
46
46
  | `/config-reindex` | POST | Trigger reindex |
47
47
  | `/metadata` | POST | Enrich document metadata |
48
48
  | `/issues` | GET | Runtime embedding failures |
49
+ | `/rules/register` | POST | Register virtual inference rules |
50
+ | `/rules/unregister` | DELETE | Remove virtual rules by source |
51
+ | `/points/delete` | POST | Delete points matching a Qdrant filter |
49
52
 
50
53
  **If the watcher is unreachable:** Check the service status (`nssm status jeeves-watcher` on Windows), check the configured port in the watcher config file, and check logs for startup errors.
51
54
 
@@ -83,6 +86,22 @@ You don't need to know the rules in advance. The config is introspectable at run
83
86
 
84
87
  ## Tools
85
88
 
89
+ ### `memory_search`
90
+ Semantically search MEMORY.md and memory/*.md files. Powered by the watcher's vector store with Gemini 3072-dim embeddings.
91
+ - `query` (string, required) — search query text
92
+ - `maxResults` (number, optional) — maximum results to return
93
+ - `minScore` (number, optional) — minimum similarity score threshold
94
+
95
+ Returns: `[{ path, from, to, snippet, score }]` where `from`/`to` are 1-indexed line numbers.
96
+
97
+ ### `memory_get`
98
+ Read content from MEMORY.md or memory/*.md files with optional line range.
99
+ - `path` (string, required) — path to the memory file
100
+ - `from` (number, optional) — line number to start reading from (1-indexed)
101
+ - `lines` (number, optional) — number of lines to read
102
+
103
+ Path validation: only files within the workspace's MEMORY.md and memory/**/*.md are accessible.
104
+
86
105
  ### `watcher_search`
87
106
  Semantic search over indexed documents.
88
107
  - `query` (string, required) — natural language search query
@@ -411,9 +430,9 @@ Or check if a specific path would match:
411
430
  ### Rule Structure
412
431
  Each inference rule has:
413
432
  - `name` (required) — unique identifier
414
- - `description` (optional) — human-readable purpose
433
+ - `description` (required) — human-readable purpose
415
434
  - `match` — JSON Schema with picomatch glob for path matching
416
- - `set` — metadata fields to set on match
435
+ - `schema` — array of named schema references and/or inline schema objects with `set` templates
417
436
  - `map` (optional) — named JsonMap transform
418
437
  - `template` (optional) — named Handlebars template
419
438
 
package/dist/src/cli.d.ts CHANGED
@@ -13,4 +13,5 @@
13
13
  * - OPENCLAW_HOME env var (path to .openclaw directory)
14
14
  * - Default: ~/.openclaw/openclaw.json
15
15
  */
16
- export {};
16
+ /** Patch OpenClaw config for install or uninstall. Returns log messages. */
17
+ export declare function patchConfig(config: Record<string, unknown>, mode: 'add' | 'remove'): string[];
@@ -0,0 +1 @@
1
+ export {};
@@ -10,6 +10,14 @@ export interface PluginApi {
10
10
  config?: Record<string, unknown>;
11
11
  }>;
12
12
  };
13
+ agents?: {
14
+ entries?: Record<string, {
15
+ workspace?: string;
16
+ }>;
17
+ defaults?: {
18
+ workspace?: string;
19
+ };
20
+ };
13
21
  };
14
22
  registerTool(tool: {
15
23
  name: string;
@@ -28,6 +36,15 @@ export interface ToolResult {
28
36
  }>;
29
37
  isError?: boolean;
30
38
  }
39
+ /** Source identifier for virtual rule registration. */
40
+ export declare const PLUGIN_SOURCE = "jeeves-watcher-openclaw";
41
+ /** Normalize a path to forward slashes and lowercase drive letter on Windows. */
42
+ export declare function normalizePath(p: string): string;
43
+ /**
44
+ * Resolve the workspace path from gateway config.
45
+ * Priority: agent-specific > defaults > fallback (~/.openclaw/workspace).
46
+ */
47
+ export declare function getWorkspacePath(api: PluginApi): string;
31
48
  /** Resolve the watcher API base URL from plugin config. */
32
49
  export declare function getApiUrl(api: PluginApi): string;
33
50
  /** Format a successful tool result. */
@@ -38,3 +55,5 @@ export declare function fail(error: unknown): ToolResult;
38
55
  export declare function connectionFail(error: unknown, baseUrl: string): ToolResult;
39
56
  /** Fetch JSON from a URL, throwing on non-OK responses. */
40
57
  export declare function fetchJson(url: string, init?: RequestInit): Promise<unknown>;
58
+ /** POST JSON to a URL and return parsed response. */
59
+ export declare function postJson(url: string, body: unknown): Promise<unknown>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @module plugin/memoryTools
3
+ * memory_search and memory_get tool implementations with lazy init.
4
+ *
5
+ * Lazy init registers virtual inference rules with the watcher on first
6
+ * memory_search call. Re-attempts on failure. memory_get reads files
7
+ * directly from the filesystem with path validation.
8
+ */
9
+ import { type PluginApi, type ToolResult } from './helpers.js';
10
+ /**
11
+ * Create memory tool registrations for the plugin.
12
+ * Returns register functions for memory_search and memory_get.
13
+ */
14
+ export declare function createMemoryTools(api: PluginApi, baseUrl: string): {
15
+ memorySearch: (_id: string, params: Record<string, unknown>) => Promise<ToolResult>;
16
+ memoryGet: (_id: string, params: Record<string, unknown>) => Promise<ToolResult>;
17
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @module plugin/watcherTools
3
+ * Watcher tool registrations (watcher_* tools) for the OpenClaw plugin.
4
+ */
5
+ import { type PluginApi } from './helpers.js';
6
+ /** Register all 8 watcher_* tools with the OpenClaw plugin API. */
7
+ export declare function registerWatcherTools(api: PluginApi, baseUrl: string): void;
@@ -0,0 +1 @@
1
+ export {};
@@ -2,7 +2,8 @@
2
2
  "id": "jeeves-watcher-openclaw",
3
3
  "name": "Jeeves Watcher",
4
4
  "description": "Semantic search, metadata enrichment, and instance administration for a jeeves-watcher deployment.",
5
- "version": "0.2.0",
5
+ "version": "0.3.1",
6
+ "kind": "memory",
6
7
  "skills": [
7
8
  "dist/skills/jeeves-watcher"
8
9
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karmaniverous/jeeves-watcher-openclaw",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "author": "Jason Williscroft",
5
5
  "description": "OpenClaw plugin for jeeves-watcher — semantic search and metadata enrichment tools",
6
6
  "license": "BSD-3-Clause",
@@ -59,9 +59,11 @@
59
59
  "@rollup/plugin-typescript": "^12.3.0",
60
60
  "auto-changelog": "^2.5.0",
61
61
  "cross-env": "^10.1.0",
62
+ "knip": "^5.85.0",
62
63
  "release-it": "^19.2.4",
63
64
  "rollup": "^4.59.0",
64
- "tslib": "^2.8.1"
65
+ "tslib": "^2.8.1",
66
+ "vitest": "^4.0.18"
65
67
  },
66
68
  "scripts": {
67
69
  "build:plugin": "rimraf dist && cross-env NO_COLOR=1 rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript",
@@ -72,6 +74,8 @@
72
74
  "lint:fix": "eslint --fix .",
73
75
  "release": "dotenvx run -f .env.local -- release-it",
74
76
  "release:pre": "dotenvx run -f .env.local -- release-it --no-git.requireBranch --github.prerelease --preRelease",
77
+ "knip": "knip",
78
+ "test": "vitest run",
75
79
  "typecheck": "tsc"
76
80
  },
77
81
  "release-it": {
@@ -91,6 +95,7 @@
91
95
  "after:init": [
92
96
  "npm run lint",
93
97
  "npm run typecheck",
98
+ "npm run test",
94
99
  "npm run build"
95
100
  ],
96
101
  "before:npm:release": [