@karmaniverous/jeeves-meta-openclaw 0.1.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 +50 -0
- package/dist/cli.js +239 -0
- package/dist/index.js +1001 -0
- package/dist/rollup.config.d.ts +9 -0
- package/dist/skills/jeeves-meta/SKILL.md +207 -0
- package/dist/src/cli.d.ts +19 -0
- package/dist/src/cli.test.d.ts +1 -0
- package/dist/src/configLoader.d.ts +6 -0
- package/dist/src/helpers.d.ts +44 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/promptInjection.d.ts +20 -0
- package/dist/src/rules.d.ts +19 -0
- package/dist/src/tools.d.ts +8 -0
- package/dist/src/toolsWriter.d.ts +26 -0
- package/dist/src/toolsWriter.test.d.ts +6 -0
- package/openclaw.plugin.json +18 -0
- package/package.json +119 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
import { HttpWatcherClient, paginatedScan, isLocked, normalizePath, globMetas, buildOwnershipTree, findNode, ensureMetaJson, actualStaleness, computeEffectiveStaleness, selectCandidate, filterInScope, computeStructureHash, readLatestArchive, hasSteerChanged, isArchitectTriggered, loadSynthConfig } from '@karmaniverous/jeeves-meta';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared types and utilities for the OpenClaw plugin.
|
|
7
|
+
*
|
|
8
|
+
* @module helpers
|
|
9
|
+
*/
|
|
10
|
+
const PLUGIN_NAME = 'jeeves-meta-openclaw';
|
|
11
|
+
/** Get plugin config. */
|
|
12
|
+
function getPluginConfig(api) {
|
|
13
|
+
return api.config?.plugins?.entries?.[PLUGIN_NAME]?.config;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the config file path.
|
|
17
|
+
*
|
|
18
|
+
* Resolution order:
|
|
19
|
+
* 1. Plugin config `configPath` setting
|
|
20
|
+
* 2. `JEEVES_META_CONFIG` environment variable
|
|
21
|
+
* 3. Error — no default path
|
|
22
|
+
*/
|
|
23
|
+
function getConfigPath(api) {
|
|
24
|
+
const fromPlugin = getPluginConfig(api)?.configPath;
|
|
25
|
+
if (typeof fromPlugin === 'string')
|
|
26
|
+
return fromPlugin;
|
|
27
|
+
const fromEnv = process.env['JEEVES_META_CONFIG'];
|
|
28
|
+
if (fromEnv)
|
|
29
|
+
return fromEnv;
|
|
30
|
+
throw new Error('jeeves-meta config path not found. Set configPath in plugin config or JEEVES_META_CONFIG env var.');
|
|
31
|
+
}
|
|
32
|
+
/** Format a successful tool result. */
|
|
33
|
+
function ok(data) {
|
|
34
|
+
return {
|
|
35
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Format an error tool result. */
|
|
39
|
+
function fail(error) {
|
|
40
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: 'text', text: 'Error: ' + message }],
|
|
43
|
+
isError: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Virtual rule definitions and registration for jeeves-meta.
|
|
49
|
+
*
|
|
50
|
+
* Registers three inference rules with the watcher at plugin startup:
|
|
51
|
+
* 1. synth-meta-live — indexes live .meta/meta.json files
|
|
52
|
+
* 2. synth-meta-archive — indexes archived snapshots
|
|
53
|
+
* 3. synth-config — indexes the synth config file
|
|
54
|
+
*
|
|
55
|
+
* @module rules
|
|
56
|
+
*/
|
|
57
|
+
const SOURCE = 'jeeves-meta';
|
|
58
|
+
/** Virtual rule definitions per spec Section 15. */
|
|
59
|
+
const SYNTH_RULES = [
|
|
60
|
+
{
|
|
61
|
+
name: 'synth-meta-live',
|
|
62
|
+
description: 'Live jeeves-meta .meta/meta.json files',
|
|
63
|
+
match: {
|
|
64
|
+
properties: {
|
|
65
|
+
file: {
|
|
66
|
+
properties: {
|
|
67
|
+
path: { type: 'string', glob: '**/.meta/meta.json' },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
schema: [
|
|
73
|
+
'base',
|
|
74
|
+
{
|
|
75
|
+
properties: {
|
|
76
|
+
domains: { set: ['synth-meta'] },
|
|
77
|
+
synth_id: { type: 'string', set: '{{json._id}}' },
|
|
78
|
+
synth_steer: { type: 'string', set: '{{json._steer}}' },
|
|
79
|
+
synth_depth: { type: 'number', set: '{{json._depth}}' },
|
|
80
|
+
synth_emphasis: { type: 'number', set: '{{json._emphasis}}' },
|
|
81
|
+
synth_synthesis_count: {
|
|
82
|
+
type: 'integer',
|
|
83
|
+
set: '{{json._synthesisCount}}',
|
|
84
|
+
},
|
|
85
|
+
synth_structure_hash: {
|
|
86
|
+
type: 'string',
|
|
87
|
+
set: '{{json._structureHash}}',
|
|
88
|
+
},
|
|
89
|
+
synth_architect_tokens: {
|
|
90
|
+
type: 'integer',
|
|
91
|
+
set: '{{json._architectTokens}}',
|
|
92
|
+
},
|
|
93
|
+
synth_builder_tokens: {
|
|
94
|
+
type: 'integer',
|
|
95
|
+
set: '{{json._builderTokens}}',
|
|
96
|
+
},
|
|
97
|
+
synth_critic_tokens: {
|
|
98
|
+
type: 'integer',
|
|
99
|
+
set: '{{json._criticTokens}}',
|
|
100
|
+
},
|
|
101
|
+
synth_error_step: {
|
|
102
|
+
type: 'string',
|
|
103
|
+
set: '{{json._error.step}}',
|
|
104
|
+
},
|
|
105
|
+
generated_at_unix: {
|
|
106
|
+
type: 'integer',
|
|
107
|
+
set: '{{toUnix json._generatedAt}}',
|
|
108
|
+
description: 'Synthesis timestamp as Unix seconds for range queries',
|
|
109
|
+
},
|
|
110
|
+
has_error: {
|
|
111
|
+
type: 'boolean',
|
|
112
|
+
set: '{{#if json._error}}true{{else}}false{{/if}}',
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
render: {
|
|
118
|
+
frontmatter: [
|
|
119
|
+
'synth_id',
|
|
120
|
+
'synth_steer',
|
|
121
|
+
'generated_at_unix',
|
|
122
|
+
'synth_depth',
|
|
123
|
+
'synth_emphasis',
|
|
124
|
+
'synth_architect_tokens',
|
|
125
|
+
'synth_builder_tokens',
|
|
126
|
+
'synth_critic_tokens',
|
|
127
|
+
],
|
|
128
|
+
body: [
|
|
129
|
+
{
|
|
130
|
+
path: 'json._content',
|
|
131
|
+
heading: 1,
|
|
132
|
+
label: 'Synthesis',
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
renderAs: 'md',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'synth-meta-archive',
|
|
140
|
+
description: 'Archived jeeves-meta .meta/archive snapshots',
|
|
141
|
+
match: {
|
|
142
|
+
properties: {
|
|
143
|
+
file: {
|
|
144
|
+
properties: {
|
|
145
|
+
path: { type: 'string', glob: '**/.meta/archive/*.json' },
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
schema: [
|
|
151
|
+
'base',
|
|
152
|
+
{
|
|
153
|
+
properties: {
|
|
154
|
+
domains: { set: ['synth-archive'] },
|
|
155
|
+
synth_id: { type: 'string', set: '{{json._id}}' },
|
|
156
|
+
archived: { type: 'boolean', set: 'true' },
|
|
157
|
+
archived_at: { type: 'string', set: '{{json._archivedAt}}' },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
render: {
|
|
162
|
+
frontmatter: ['synth_id', 'archived', 'archived_at'],
|
|
163
|
+
body: [
|
|
164
|
+
{
|
|
165
|
+
path: 'json._content',
|
|
166
|
+
heading: 1,
|
|
167
|
+
label: 'Synthesis (archived)',
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
renderAs: 'md',
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'synth-config',
|
|
175
|
+
description: 'jeeves-meta configuration file',
|
|
176
|
+
match: {
|
|
177
|
+
properties: {
|
|
178
|
+
file: {
|
|
179
|
+
properties: {
|
|
180
|
+
path: { type: 'string', glob: '**/jeeves-meta.config.json' },
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
schema: [
|
|
186
|
+
'base',
|
|
187
|
+
{
|
|
188
|
+
properties: {
|
|
189
|
+
domains: { set: ['synth-config'] },
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
render: {
|
|
194
|
+
frontmatter: [
|
|
195
|
+
'watchPaths',
|
|
196
|
+
'watcherUrl',
|
|
197
|
+
'gatewayUrl',
|
|
198
|
+
'architectEvery',
|
|
199
|
+
'depthWeight',
|
|
200
|
+
'maxArchive',
|
|
201
|
+
'maxLines',
|
|
202
|
+
'batchSize',
|
|
203
|
+
],
|
|
204
|
+
body: [
|
|
205
|
+
{
|
|
206
|
+
path: 'json.defaultArchitect',
|
|
207
|
+
heading: 2,
|
|
208
|
+
label: 'Default Architect Prompt',
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
path: 'json.defaultCritic',
|
|
212
|
+
heading: 2,
|
|
213
|
+
label: 'Default Critic Prompt',
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
renderAs: 'md',
|
|
218
|
+
},
|
|
219
|
+
];
|
|
220
|
+
/**
|
|
221
|
+
* Register jeeves-meta virtual rules with the watcher.
|
|
222
|
+
*
|
|
223
|
+
* Called at plugin startup. Rules are additive — the watcher appends
|
|
224
|
+
* them after config-file rules (last-match-wins).
|
|
225
|
+
*
|
|
226
|
+
* @param watcherUrl - Base URL for the watcher service.
|
|
227
|
+
*/
|
|
228
|
+
async function registerSynthRules(watcherUrl) {
|
|
229
|
+
const client = new HttpWatcherClient({ baseUrl: watcherUrl });
|
|
230
|
+
await client.registerRules(SOURCE, SYNTH_RULES);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Synth tool registrations for OpenClaw.
|
|
235
|
+
*
|
|
236
|
+
* @module tools
|
|
237
|
+
*/
|
|
238
|
+
/** Register all synth_* tools. */
|
|
239
|
+
function registerSynthTools(api) {
|
|
240
|
+
const configPath = getConfigPath(api);
|
|
241
|
+
// Lazy-load config (resolved once on first use)
|
|
242
|
+
let _config = null;
|
|
243
|
+
const getConfig = () => {
|
|
244
|
+
if (!_config) {
|
|
245
|
+
_config = loadSynthConfig(configPath);
|
|
246
|
+
}
|
|
247
|
+
return _config;
|
|
248
|
+
};
|
|
249
|
+
/** Derive watcherUrl from loaded config. */
|
|
250
|
+
const getWatcherUrl = () => getConfig().watcherUrl;
|
|
251
|
+
/** Derive watchPaths from loaded config. */
|
|
252
|
+
const getWatchPaths = () => getConfig().watchPaths;
|
|
253
|
+
// ─── synth_list ──────────────────────────────────────────────
|
|
254
|
+
api.registerTool({
|
|
255
|
+
name: 'synth_list',
|
|
256
|
+
description: 'List metas with summary stats and per-meta projection. Replaces synth_status + synth_entities.',
|
|
257
|
+
parameters: {
|
|
258
|
+
type: 'object',
|
|
259
|
+
properties: {
|
|
260
|
+
pathPrefix: {
|
|
261
|
+
type: 'string',
|
|
262
|
+
description: 'Filter metas by path prefix (e.g. "github/").',
|
|
263
|
+
},
|
|
264
|
+
filter: {
|
|
265
|
+
type: 'object',
|
|
266
|
+
description: 'Structured filter. Supported keys: hasError (boolean), staleHours (number, min hours stale), neverSynthesized (boolean), locked (boolean).',
|
|
267
|
+
properties: {
|
|
268
|
+
hasError: { type: 'boolean' },
|
|
269
|
+
staleHours: { type: 'number' },
|
|
270
|
+
neverSynthesized: { type: 'boolean' },
|
|
271
|
+
locked: { type: 'boolean' },
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
fields: {
|
|
275
|
+
type: 'array',
|
|
276
|
+
items: { type: 'string' },
|
|
277
|
+
description: 'Fields to include per meta. Default: path, depth, emphasis, stalenessSeconds, lastSynthesized, hasError, locked, architectTokens, builderTokens, criticTokens.',
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
execute: async (_id, params) => {
|
|
282
|
+
try {
|
|
283
|
+
const pathPrefix = params.pathPrefix;
|
|
284
|
+
const watcher = new HttpWatcherClient({ baseUrl: getWatcherUrl() });
|
|
285
|
+
// Query watcher for synth-meta domain points
|
|
286
|
+
const scanFiles = await paginatedScan(watcher, {
|
|
287
|
+
...(pathPrefix ? { pathPrefix } : {}),
|
|
288
|
+
filter: {
|
|
289
|
+
must: [{ key: 'domains', match: { value: 'synth-meta' } }],
|
|
290
|
+
},
|
|
291
|
+
fields: [
|
|
292
|
+
'file_path',
|
|
293
|
+
'synth_depth',
|
|
294
|
+
'synth_emphasis',
|
|
295
|
+
'synth_architect_tokens',
|
|
296
|
+
'synth_builder_tokens',
|
|
297
|
+
'synth_critic_tokens',
|
|
298
|
+
'has_error',
|
|
299
|
+
'generated_at_unix',
|
|
300
|
+
'synth_error_step',
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
const entities = [];
|
|
304
|
+
let staleCount = 0;
|
|
305
|
+
let errorCount = 0;
|
|
306
|
+
let lockedCount = 0;
|
|
307
|
+
let neverSynthesizedCount = 0;
|
|
308
|
+
let totalArchTokens = 0;
|
|
309
|
+
let totalBuilderTokens = 0;
|
|
310
|
+
let totalCriticTokens = 0;
|
|
311
|
+
let lastSynthPath = null;
|
|
312
|
+
let lastSynthAt = null;
|
|
313
|
+
let stalestPath = null;
|
|
314
|
+
let stalestEffective = -1;
|
|
315
|
+
const config = getConfig();
|
|
316
|
+
for (const sf of scanFiles) {
|
|
317
|
+
const filePath = sf.file_path;
|
|
318
|
+
const depth = typeof sf['synth_depth'] === 'number' ? sf['synth_depth'] : 0;
|
|
319
|
+
const emphasis = typeof sf['synth_emphasis'] === 'number' ? sf['synth_emphasis'] : 1;
|
|
320
|
+
const hasError = sf['has_error'] === true || sf['has_error'] === 'true';
|
|
321
|
+
const archTokens = typeof sf['synth_architect_tokens'] === 'number'
|
|
322
|
+
? sf['synth_architect_tokens']
|
|
323
|
+
: 0;
|
|
324
|
+
const buildTokens = typeof sf['synth_builder_tokens'] === 'number'
|
|
325
|
+
? sf['synth_builder_tokens']
|
|
326
|
+
: 0;
|
|
327
|
+
const critTokens = typeof sf['synth_critic_tokens'] === 'number'
|
|
328
|
+
? sf['synth_critic_tokens']
|
|
329
|
+
: 0;
|
|
330
|
+
const genAtUnix = typeof sf['generated_at_unix'] === 'number'
|
|
331
|
+
? sf['generated_at_unix']
|
|
332
|
+
: 0;
|
|
333
|
+
const locked = isLocked(normalizePath(filePath));
|
|
334
|
+
const neverSynth = genAtUnix === 0;
|
|
335
|
+
// Compute staleness from generated_at_unix
|
|
336
|
+
let stalenessSeconds;
|
|
337
|
+
if (neverSynth) {
|
|
338
|
+
stalenessSeconds = Infinity;
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
stalenessSeconds = Math.floor(Date.now() / 1000) - genAtUnix;
|
|
342
|
+
if (stalenessSeconds < 0)
|
|
343
|
+
stalenessSeconds = 0;
|
|
344
|
+
}
|
|
345
|
+
// Apply structured filter
|
|
346
|
+
const filter = params.filter;
|
|
347
|
+
if (filter) {
|
|
348
|
+
if (filter.hasError !== undefined && hasError !== filter.hasError)
|
|
349
|
+
continue;
|
|
350
|
+
if (filter.neverSynthesized !== undefined &&
|
|
351
|
+
neverSynth !== filter.neverSynthesized)
|
|
352
|
+
continue;
|
|
353
|
+
if (filter.locked !== undefined && locked !== filter.locked)
|
|
354
|
+
continue;
|
|
355
|
+
if (typeof filter.staleHours === 'number' &&
|
|
356
|
+
stalenessSeconds < filter.staleHours * 3600)
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (stalenessSeconds > 0)
|
|
360
|
+
staleCount++;
|
|
361
|
+
if (hasError)
|
|
362
|
+
errorCount++;
|
|
363
|
+
if (locked)
|
|
364
|
+
lockedCount++;
|
|
365
|
+
if (neverSynth)
|
|
366
|
+
neverSynthesizedCount++;
|
|
367
|
+
if (archTokens > 0)
|
|
368
|
+
totalArchTokens += archTokens;
|
|
369
|
+
if (buildTokens > 0)
|
|
370
|
+
totalBuilderTokens += buildTokens;
|
|
371
|
+
if (critTokens > 0)
|
|
372
|
+
totalCriticTokens += critTokens;
|
|
373
|
+
const genAtIso = genAtUnix > 0 ? new Date(genAtUnix * 1000).toISOString() : null;
|
|
374
|
+
if (genAtIso && (!lastSynthAt || genAtIso > lastSynthAt)) {
|
|
375
|
+
lastSynthAt = genAtIso;
|
|
376
|
+
lastSynthPath = filePath;
|
|
377
|
+
}
|
|
378
|
+
// Effective staleness for stalest computation
|
|
379
|
+
const depthFactor = Math.pow(1 + config.depthWeight, depth);
|
|
380
|
+
const effectiveStaleness = (stalenessSeconds === Infinity
|
|
381
|
+
? Number.MAX_SAFE_INTEGER
|
|
382
|
+
: stalenessSeconds) *
|
|
383
|
+
depthFactor *
|
|
384
|
+
emphasis;
|
|
385
|
+
if (effectiveStaleness > stalestEffective) {
|
|
386
|
+
stalestEffective = effectiveStaleness;
|
|
387
|
+
stalestPath = filePath;
|
|
388
|
+
}
|
|
389
|
+
// Derive meta path from file_path (strip /meta.json)
|
|
390
|
+
const metaPath = filePath.replace(/\/meta\.json$/, '');
|
|
391
|
+
const fields = params.fields;
|
|
392
|
+
const raw = {
|
|
393
|
+
path: metaPath,
|
|
394
|
+
depth,
|
|
395
|
+
emphasis,
|
|
396
|
+
stalenessSeconds: stalenessSeconds === Infinity
|
|
397
|
+
? 'never-synthesized'
|
|
398
|
+
: Math.round(stalenessSeconds),
|
|
399
|
+
lastSynthesized: genAtIso,
|
|
400
|
+
hasError,
|
|
401
|
+
locked,
|
|
402
|
+
architectTokens: archTokens > 0 ? archTokens : null,
|
|
403
|
+
builderTokens: buildTokens > 0 ? buildTokens : null,
|
|
404
|
+
criticTokens: critTokens > 0 ? critTokens : null,
|
|
405
|
+
children: 0,
|
|
406
|
+
};
|
|
407
|
+
if (fields) {
|
|
408
|
+
const projected = {};
|
|
409
|
+
for (const f of fields) {
|
|
410
|
+
if (f in raw)
|
|
411
|
+
projected[f] = raw[f];
|
|
412
|
+
}
|
|
413
|
+
entities.push(projected);
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
entities.push(raw);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return ok({
|
|
420
|
+
summary: {
|
|
421
|
+
total: entities.length,
|
|
422
|
+
stale: staleCount,
|
|
423
|
+
errors: errorCount,
|
|
424
|
+
locked: lockedCount,
|
|
425
|
+
neverSynthesized: neverSynthesizedCount,
|
|
426
|
+
tokens: {
|
|
427
|
+
architect: totalArchTokens,
|
|
428
|
+
builder: totalBuilderTokens,
|
|
429
|
+
critic: totalCriticTokens,
|
|
430
|
+
},
|
|
431
|
+
stalestPath,
|
|
432
|
+
lastSynthesizedPath: lastSynthPath,
|
|
433
|
+
lastSynthesizedAt: lastSynthAt,
|
|
434
|
+
},
|
|
435
|
+
items: entities.sort((a, b) => String(a.path ?? '').localeCompare(String(b.path ?? ''))),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
return fail(error);
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
// ─── synth_detail ────────────────────────────────────────────
|
|
444
|
+
api.registerTool({
|
|
445
|
+
name: 'synth_detail',
|
|
446
|
+
description: 'Full detail for a single meta, with optional archive history.',
|
|
447
|
+
parameters: {
|
|
448
|
+
type: 'object',
|
|
449
|
+
properties: {
|
|
450
|
+
path: {
|
|
451
|
+
type: 'string',
|
|
452
|
+
description: 'Path to .meta/ directory or owner directory (required).',
|
|
453
|
+
},
|
|
454
|
+
fields: {
|
|
455
|
+
type: 'array',
|
|
456
|
+
items: { type: 'string' },
|
|
457
|
+
description: 'Fields to include. Default: all except _architect, _builder, _critic, _content, _feedback.',
|
|
458
|
+
},
|
|
459
|
+
includeArchive: {
|
|
460
|
+
oneOf: [{ type: 'boolean' }, { type: 'number' }],
|
|
461
|
+
description: 'false (default), true (all snapshots), or number (N most recent).',
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
required: ['path'],
|
|
465
|
+
},
|
|
466
|
+
execute: async (_id, params) => {
|
|
467
|
+
try {
|
|
468
|
+
const targetPath = normalizePath(params.path);
|
|
469
|
+
const includeArchive = params.includeArchive;
|
|
470
|
+
const defaultExclude = new Set([
|
|
471
|
+
'_architect',
|
|
472
|
+
'_builder',
|
|
473
|
+
'_critic',
|
|
474
|
+
'_content',
|
|
475
|
+
'_feedback',
|
|
476
|
+
]);
|
|
477
|
+
const fields = params.fields;
|
|
478
|
+
const metaPaths = globMetas(getWatchPaths());
|
|
479
|
+
const tree = buildOwnershipTree(metaPaths);
|
|
480
|
+
const targetNode = findNode(tree, targetPath);
|
|
481
|
+
if (!targetNode) {
|
|
482
|
+
return fail('Meta path not found: ' + targetPath);
|
|
483
|
+
}
|
|
484
|
+
const meta = ensureMetaJson(targetNode.metaPath);
|
|
485
|
+
// Apply field projection
|
|
486
|
+
const projectMeta = (m) => {
|
|
487
|
+
if (fields) {
|
|
488
|
+
const result = {};
|
|
489
|
+
for (const f of fields)
|
|
490
|
+
result[f] = m[f];
|
|
491
|
+
return result;
|
|
492
|
+
}
|
|
493
|
+
// Default: exclude big text blobs
|
|
494
|
+
const result = {};
|
|
495
|
+
for (const [k, v] of Object.entries(m)) {
|
|
496
|
+
if (!defaultExclude.has(k))
|
|
497
|
+
result[k] = v;
|
|
498
|
+
}
|
|
499
|
+
return result;
|
|
500
|
+
};
|
|
501
|
+
const response = {
|
|
502
|
+
meta: projectMeta(meta),
|
|
503
|
+
};
|
|
504
|
+
// Archive history
|
|
505
|
+
if (includeArchive) {
|
|
506
|
+
const { readFileSync } = await import('node:fs');
|
|
507
|
+
const { join } = await import('node:path');
|
|
508
|
+
const { listArchiveFiles } = await import('@karmaniverous/jeeves-meta');
|
|
509
|
+
const archiveFiles = listArchiveFiles(targetNode.metaPath);
|
|
510
|
+
const limit = typeof includeArchive === 'number'
|
|
511
|
+
? includeArchive
|
|
512
|
+
: archiveFiles.length;
|
|
513
|
+
// Most recent first (files are sorted by timestamp)
|
|
514
|
+
const selected = archiveFiles.slice(-limit).reverse();
|
|
515
|
+
const archives = selected.map((af) => {
|
|
516
|
+
const raw = readFileSync(join(targetNode.metaPath, 'archive', af), 'utf8');
|
|
517
|
+
const parsed = JSON.parse(raw);
|
|
518
|
+
return projectMeta(parsed);
|
|
519
|
+
});
|
|
520
|
+
response.archive = archives;
|
|
521
|
+
}
|
|
522
|
+
return ok(response);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
return fail(error);
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
// ─── synth_preview ────────────────────────────────────────────
|
|
530
|
+
api.registerTool({
|
|
531
|
+
name: 'synth_preview',
|
|
532
|
+
description: 'Dry-run: show what inputs would be gathered for the next synthesis cycle without running LLM.',
|
|
533
|
+
parameters: {
|
|
534
|
+
type: 'object',
|
|
535
|
+
properties: {
|
|
536
|
+
path: {
|
|
537
|
+
type: 'string',
|
|
538
|
+
description: 'Optional: specific .meta/ path to preview. If omitted, previews the stalest candidate.',
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
execute: async (_id, params) => {
|
|
543
|
+
try {
|
|
544
|
+
const targetPath = params.path;
|
|
545
|
+
const metaPaths = globMetas(getWatchPaths());
|
|
546
|
+
const tree = buildOwnershipTree(metaPaths);
|
|
547
|
+
let targetNode;
|
|
548
|
+
if (targetPath) {
|
|
549
|
+
const normalized = normalizePath(targetPath);
|
|
550
|
+
targetNode = findNode(tree, normalized);
|
|
551
|
+
if (!targetNode) {
|
|
552
|
+
return fail('Meta path not found: ' + targetPath);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
// Select stalest
|
|
557
|
+
const candidates = [];
|
|
558
|
+
for (const node of tree.nodes.values()) {
|
|
559
|
+
const meta = ensureMetaJson(node.metaPath);
|
|
560
|
+
const staleness = actualStaleness(meta);
|
|
561
|
+
if (staleness > 0) {
|
|
562
|
+
candidates.push({ node, meta, actualStaleness: staleness });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const weighted = computeEffectiveStaleness(candidates, getConfig().depthWeight);
|
|
566
|
+
const winner = selectCandidate(weighted);
|
|
567
|
+
if (!winner) {
|
|
568
|
+
return ok({
|
|
569
|
+
message: 'No stale metas found. Nothing to synthesize.',
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
targetNode = winner.node;
|
|
573
|
+
}
|
|
574
|
+
const meta = ensureMetaJson(targetNode.metaPath);
|
|
575
|
+
const watcher = new HttpWatcherClient({ baseUrl: getWatcherUrl() });
|
|
576
|
+
// Scope files (paginated for completeness)
|
|
577
|
+
const allScanFiles = await paginatedScan(watcher, {
|
|
578
|
+
pathPrefix: targetNode.ownerPath,
|
|
579
|
+
});
|
|
580
|
+
const allFiles = allScanFiles.map((f) => f.file_path);
|
|
581
|
+
const scopeFiles = filterInScope(targetNode, allFiles);
|
|
582
|
+
// Structure hash on scope-filtered files (matches orchestrator)
|
|
583
|
+
const structureHash = computeStructureHash(scopeFiles);
|
|
584
|
+
const structureChanged = structureHash !== meta._structureHash;
|
|
585
|
+
// Steer change
|
|
586
|
+
const latestArchive = readLatestArchive(targetNode.metaPath);
|
|
587
|
+
const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
588
|
+
// Architect trigger check
|
|
589
|
+
const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, getConfig().architectEvery);
|
|
590
|
+
// Delta files
|
|
591
|
+
let deltaFiles = [];
|
|
592
|
+
if (meta._generatedAt) {
|
|
593
|
+
const modifiedAfter = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
|
|
594
|
+
const deltaScanFiles = await paginatedScan(watcher, {
|
|
595
|
+
pathPrefix: targetNode.ownerPath,
|
|
596
|
+
modifiedAfter,
|
|
597
|
+
});
|
|
598
|
+
deltaFiles = filterInScope(targetNode, deltaScanFiles.map((f) => f.file_path));
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
deltaFiles = scopeFiles;
|
|
602
|
+
}
|
|
603
|
+
return ok({
|
|
604
|
+
target: targetNode.metaPath,
|
|
605
|
+
ownerPath: targetNode.ownerPath,
|
|
606
|
+
depth: meta._depth ?? targetNode.treeDepth,
|
|
607
|
+
staleness: actualStaleness(meta) === Infinity
|
|
608
|
+
? 'never-synthesized'
|
|
609
|
+
: Math.round(actualStaleness(meta)).toString() + 's',
|
|
610
|
+
scopeFiles: {
|
|
611
|
+
count: scopeFiles.length,
|
|
612
|
+
sample: scopeFiles.slice(0, 20),
|
|
613
|
+
},
|
|
614
|
+
deltaFiles: {
|
|
615
|
+
count: deltaFiles.length,
|
|
616
|
+
sample: deltaFiles.slice(0, 20),
|
|
617
|
+
},
|
|
618
|
+
structureChanged,
|
|
619
|
+
steerChanged,
|
|
620
|
+
architectTriggered,
|
|
621
|
+
architectTriggerReasons: [
|
|
622
|
+
...(!meta._builder ? ['no cached builder (first run)'] : []),
|
|
623
|
+
...(structureChanged ? ['structure changed'] : []),
|
|
624
|
+
...(steerChanged ? ['steer changed'] : []),
|
|
625
|
+
...((meta._synthesisCount ?? 0) >= getConfig().architectEvery
|
|
626
|
+
? ['periodic refresh (architectEvery)']
|
|
627
|
+
: []),
|
|
628
|
+
],
|
|
629
|
+
currentSteer: meta._steer ?? null,
|
|
630
|
+
hasExistingContent: Boolean(meta._content),
|
|
631
|
+
hasExistingFeedback: Boolean(meta._feedback),
|
|
632
|
+
children: targetNode.children.map((c) => c.metaPath),
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
return fail(error);
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
// ─── synth_trigger ────────────────────────────────────────────
|
|
641
|
+
api.registerTool({
|
|
642
|
+
name: 'synth_trigger',
|
|
643
|
+
description: 'Manually trigger synthesis for a specific meta or the next-stalest candidate. Runs the full 3-step cycle (architect, builder, critic).',
|
|
644
|
+
parameters: {
|
|
645
|
+
type: 'object',
|
|
646
|
+
properties: {
|
|
647
|
+
path: {
|
|
648
|
+
type: 'string',
|
|
649
|
+
description: 'Optional: specific .meta/ or owner path to synthesize. If omitted, synthesizes the stalest candidate.',
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
execute: async (_id, params) => {
|
|
654
|
+
try {
|
|
655
|
+
const { orchestrate } = await import('@karmaniverous/jeeves-meta');
|
|
656
|
+
const { GatewayExecutor } = await import('@karmaniverous/jeeves-meta');
|
|
657
|
+
// Load config from canonical config file
|
|
658
|
+
const config = getConfig();
|
|
659
|
+
const executor = new GatewayExecutor({
|
|
660
|
+
gatewayUrl: config.gatewayUrl,
|
|
661
|
+
apiKey: config.gatewayApiKey,
|
|
662
|
+
});
|
|
663
|
+
const watcher = new HttpWatcherClient({ baseUrl: getWatcherUrl() });
|
|
664
|
+
// If path specified, temporarily override watchPaths to target it
|
|
665
|
+
const targetPath = params.path;
|
|
666
|
+
const effectiveConfig = targetPath
|
|
667
|
+
? {
|
|
668
|
+
...config,
|
|
669
|
+
watchPaths: [targetPath.replace(/[/\\]\.meta[/\\]?$/, '')],
|
|
670
|
+
}
|
|
671
|
+
: config;
|
|
672
|
+
const results = await orchestrate(effectiveConfig, executor, watcher);
|
|
673
|
+
const synthesized = results.filter((r) => r.synthesized);
|
|
674
|
+
if (synthesized.length === 0) {
|
|
675
|
+
return ok({
|
|
676
|
+
message: 'No synthesis performed — no stale metas found or all locked.',
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return ok({
|
|
680
|
+
synthesizedCount: synthesized.length,
|
|
681
|
+
results: synthesized.map((r) => ({
|
|
682
|
+
metaPath: r.metaPath,
|
|
683
|
+
error: r.error ?? null,
|
|
684
|
+
})),
|
|
685
|
+
message: synthesized.length.toString() +
|
|
686
|
+
' meta(s) synthesized.' +
|
|
687
|
+
(synthesized.some((r) => r.error)
|
|
688
|
+
? ' Some completed with errors.'
|
|
689
|
+
: ''),
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
return fail(error);
|
|
694
|
+
}
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Generate the Meta menu content for TOOLS.md injection.
|
|
701
|
+
*
|
|
702
|
+
* Queries the watcher API for synthesis entity stats and produces
|
|
703
|
+
* a Markdown section suitable for agent system prompt injection.
|
|
704
|
+
*
|
|
705
|
+
* @module promptInjection
|
|
706
|
+
*/
|
|
707
|
+
/**
|
|
708
|
+
* Generate the Meta menu Markdown for TOOLS.md.
|
|
709
|
+
*
|
|
710
|
+
* Three output modes:
|
|
711
|
+
* 1. Watcher unreachable - ACTION REQUIRED with diagnostic
|
|
712
|
+
* 2. No entities found - ACTION REQUIRED with setup guidance
|
|
713
|
+
* 3. Healthy - entity stats + tool listing + skill reference
|
|
714
|
+
*
|
|
715
|
+
* @param watcherUrl - Watcher API base URL.
|
|
716
|
+
* @returns Markdown string for the Meta section.
|
|
717
|
+
*/
|
|
718
|
+
async function generateMetaMenu(watcherUrl) {
|
|
719
|
+
let entities = [];
|
|
720
|
+
try {
|
|
721
|
+
const watcher = new HttpWatcherClient({ baseUrl: watcherUrl });
|
|
722
|
+
entities = await paginatedScan(watcher, {
|
|
723
|
+
filter: {
|
|
724
|
+
must: [
|
|
725
|
+
{
|
|
726
|
+
key: 'domains',
|
|
727
|
+
match: { value: 'synth-meta' },
|
|
728
|
+
},
|
|
729
|
+
],
|
|
730
|
+
},
|
|
731
|
+
fields: [
|
|
732
|
+
'synth_depth',
|
|
733
|
+
'synth_emphasis',
|
|
734
|
+
'synth_synthesis_count',
|
|
735
|
+
'synth_architect_tokens',
|
|
736
|
+
'synth_builder_tokens',
|
|
737
|
+
'synth_critic_tokens',
|
|
738
|
+
'synth_error_step',
|
|
739
|
+
'generated_at_unix',
|
|
740
|
+
'has_error',
|
|
741
|
+
],
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
return [
|
|
746
|
+
'> **ACTION REQUIRED: jeeves-watcher is unreachable.**',
|
|
747
|
+
'> The jeeves-meta synthesis engine requires a running jeeves-watcher service.',
|
|
748
|
+
`> The watcher API at ${watcherUrl} is down or not configured.`,
|
|
749
|
+
'>',
|
|
750
|
+
"> **Read the `jeeves-meta` skill's Bootstrap section immediately**",
|
|
751
|
+
'> for setup instructions. Do not attempt synthesis until watcher is available.',
|
|
752
|
+
].join('\n');
|
|
753
|
+
}
|
|
754
|
+
if (entities.length === 0) {
|
|
755
|
+
return [
|
|
756
|
+
'> **ACTION REQUIRED: No synthesis entities found.**',
|
|
757
|
+
'> The watcher is running but no `.meta/` directories were discovered',
|
|
758
|
+
'> in the configured watch paths.',
|
|
759
|
+
'>',
|
|
760
|
+
"> **Read the `jeeves-meta` skill's Bootstrap section** for guidance",
|
|
761
|
+
'> on creating `.meta/` directories and configuring watch paths.',
|
|
762
|
+
].join('\n');
|
|
763
|
+
}
|
|
764
|
+
// Compute stats
|
|
765
|
+
const now = Math.floor(Date.now() / 1000);
|
|
766
|
+
let staleCount = 0;
|
|
767
|
+
let errorCount = 0;
|
|
768
|
+
let neverSynthesized = 0;
|
|
769
|
+
let totalArchTokens = 0;
|
|
770
|
+
let totalBuilderTokens = 0;
|
|
771
|
+
let totalCriticTokens = 0;
|
|
772
|
+
let stalestPath = '';
|
|
773
|
+
let stalestAge = 0;
|
|
774
|
+
let lastSynthPath = '';
|
|
775
|
+
let lastSynthUnix = 0;
|
|
776
|
+
for (const e of entities) {
|
|
777
|
+
const generatedAt = e['generated_at_unix'];
|
|
778
|
+
const hasError = e['has_error'];
|
|
779
|
+
const archTokens = e['synth_architect_tokens'];
|
|
780
|
+
const builderTokens = e['synth_builder_tokens'];
|
|
781
|
+
const criticTokens = e['synth_critic_tokens'];
|
|
782
|
+
if (!generatedAt) {
|
|
783
|
+
neverSynthesized++;
|
|
784
|
+
staleCount++;
|
|
785
|
+
if (!isFinite(stalestAge)) ;
|
|
786
|
+
else {
|
|
787
|
+
stalestAge = Infinity;
|
|
788
|
+
stalestPath = e.file_path;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
const age = now - generatedAt;
|
|
793
|
+
if (age > 0)
|
|
794
|
+
staleCount++;
|
|
795
|
+
if (age > stalestAge && isFinite(age)) {
|
|
796
|
+
stalestAge = age;
|
|
797
|
+
stalestPath = e.file_path;
|
|
798
|
+
}
|
|
799
|
+
if (generatedAt > lastSynthUnix) {
|
|
800
|
+
lastSynthUnix = generatedAt;
|
|
801
|
+
lastSynthPath = e.file_path;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (hasError)
|
|
805
|
+
errorCount++;
|
|
806
|
+
if (archTokens)
|
|
807
|
+
totalArchTokens += archTokens;
|
|
808
|
+
if (builderTokens)
|
|
809
|
+
totalBuilderTokens += builderTokens;
|
|
810
|
+
if (criticTokens)
|
|
811
|
+
totalCriticTokens += criticTokens;
|
|
812
|
+
}
|
|
813
|
+
const formatAge = (seconds) => {
|
|
814
|
+
if (!isFinite(seconds))
|
|
815
|
+
return 'never synthesized';
|
|
816
|
+
if (seconds < 3600)
|
|
817
|
+
return Math.round(seconds / 60).toString() + 'm';
|
|
818
|
+
if (seconds < 86400)
|
|
819
|
+
return Math.round(seconds / 3600).toString() + 'h';
|
|
820
|
+
return Math.round(seconds / 86400).toString() + 'd';
|
|
821
|
+
};
|
|
822
|
+
const lines = [
|
|
823
|
+
`The jeeves-meta synthesis engine manages ${entities.length.toString()} meta entities.`,
|
|
824
|
+
'',
|
|
825
|
+
'### Entity Summary',
|
|
826
|
+
'| Metric | Value |',
|
|
827
|
+
'|--------|-------|',
|
|
828
|
+
`| Total | ${entities.length.toString()} |`,
|
|
829
|
+
`| Stale | ${staleCount.toString()} |`,
|
|
830
|
+
`| Errors | ${errorCount.toString()} |`,
|
|
831
|
+
`| Never synthesized | ${neverSynthesized.toString()} |`,
|
|
832
|
+
`| Stalest | ${stalestPath ? stalestPath + ' (' + formatAge(stalestAge) + ')' : 'n/a'} |`,
|
|
833
|
+
`| Last synthesized | ${lastSynthPath ? lastSynthPath + ' (' + new Date(lastSynthUnix * 1000).toISOString() + ')' : 'n/a'} |`,
|
|
834
|
+
'',
|
|
835
|
+
'### Token Usage (cumulative)',
|
|
836
|
+
'| Step | Tokens |',
|
|
837
|
+
'|------|--------|',
|
|
838
|
+
`| Architect | ${totalArchTokens.toLocaleString()} |`,
|
|
839
|
+
`| Builder | ${totalBuilderTokens.toLocaleString()} |`,
|
|
840
|
+
`| Critic | ${totalCriticTokens.toLocaleString()} |`,
|
|
841
|
+
'',
|
|
842
|
+
'### Tools',
|
|
843
|
+
'| Tool | Description |',
|
|
844
|
+
'|------|-------------|',
|
|
845
|
+
'| `synth_list` | List metas with summary stats and per-meta projection |',
|
|
846
|
+
'| `synth_detail` | Full detail for a single meta with optional archive history |',
|
|
847
|
+
'| `synth_trigger` | Manually trigger synthesis for a specific meta or next-stalest |',
|
|
848
|
+
'| `synth_preview` | Dry-run: show what inputs would be gathered without running LLM |',
|
|
849
|
+
'',
|
|
850
|
+
'Read the `jeeves-meta` skill for usage guidance, configuration, and troubleshooting.',
|
|
851
|
+
];
|
|
852
|
+
return lines.join('\n');
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Periodic TOOLS.md disk writer for the Meta section.
|
|
857
|
+
*
|
|
858
|
+
* Upserts a `## Meta` section under the shared `# Jeeves Platform Tools`
|
|
859
|
+
* header. The gateway reads TOOLS.md fresh from disk on each new session.
|
|
860
|
+
*
|
|
861
|
+
* @module toolsWriter
|
|
862
|
+
*/
|
|
863
|
+
const REFRESH_INTERVAL_MS = 60_000;
|
|
864
|
+
const INITIAL_DELAY_MS = 5_000;
|
|
865
|
+
let intervalHandle = null;
|
|
866
|
+
let lastWrittenMenu = '';
|
|
867
|
+
/**
|
|
868
|
+
* Resolve the workspace TOOLS.md path.
|
|
869
|
+
* Uses api.resolvePath if available, otherwise falls back to CWD.
|
|
870
|
+
*/
|
|
871
|
+
function resolveToolsPath(api) {
|
|
872
|
+
const resolvePath = api
|
|
873
|
+
.resolvePath;
|
|
874
|
+
if (typeof resolvePath === 'function') {
|
|
875
|
+
return resolvePath('TOOLS.md');
|
|
876
|
+
}
|
|
877
|
+
return resolve(process.cwd(), 'TOOLS.md');
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Upsert the Meta section in TOOLS.md content.
|
|
881
|
+
*
|
|
882
|
+
* Ordering convention: Watcher, Server, Meta.
|
|
883
|
+
* - If `## Meta` exists, replace in place.
|
|
884
|
+
* - Otherwise insert after `## Server` if present, after `## Watcher` if
|
|
885
|
+
* Server is absent, or after the H1.
|
|
886
|
+
*/
|
|
887
|
+
function upsertMetaSection(existing, metaMenu) {
|
|
888
|
+
const section = '## Meta\n\n' + metaMenu;
|
|
889
|
+
// Replace existing Meta section (match from ## Meta to next ## or # or EOF)
|
|
890
|
+
const re = /^## Meta\n[\s\S]*?(?=\n## |\n# |$(?![\s\S]))/m;
|
|
891
|
+
if (re.test(existing)) {
|
|
892
|
+
return existing.replace(re, section);
|
|
893
|
+
}
|
|
894
|
+
// No existing section. Insert in correct order.
|
|
895
|
+
const platformH1 = '# Jeeves Platform Tools';
|
|
896
|
+
// After ## Server if present
|
|
897
|
+
const serverRe = /^## Server\n[\s\S]*?(?=\n## |\n# |$(?![\s\S]))/m;
|
|
898
|
+
const serverMatch = serverRe.exec(existing);
|
|
899
|
+
if (serverMatch) {
|
|
900
|
+
const insertAt = serverMatch.index + serverMatch[0].length;
|
|
901
|
+
return (existing.slice(0, insertAt) + '\n\n' + section + existing.slice(insertAt));
|
|
902
|
+
}
|
|
903
|
+
// After ## Watcher if present
|
|
904
|
+
const watcherRe = /^## Watcher\n[\s\S]*?(?=\n## |\n# |$(?![\s\S]))/m;
|
|
905
|
+
const watcherMatch = watcherRe.exec(existing);
|
|
906
|
+
if (watcherMatch) {
|
|
907
|
+
const insertAt = watcherMatch.index + watcherMatch[0].length;
|
|
908
|
+
return (existing.slice(0, insertAt) + '\n\n' + section + existing.slice(insertAt));
|
|
909
|
+
}
|
|
910
|
+
// After H1 if present
|
|
911
|
+
if (existing.includes(platformH1)) {
|
|
912
|
+
const idx = existing.indexOf(platformH1) + platformH1.length;
|
|
913
|
+
return (existing.slice(0, idx) + '\n\n' + section + '\n' + existing.slice(idx));
|
|
914
|
+
}
|
|
915
|
+
// Prepend platform header + meta section
|
|
916
|
+
const trimmed = existing.trim();
|
|
917
|
+
if (trimmed.length === 0) {
|
|
918
|
+
return platformH1 + '\n\n' + section + '\n';
|
|
919
|
+
}
|
|
920
|
+
return platformH1 + '\n\n' + section + '\n\n' + trimmed + '\n';
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Fetch the current meta menu and write it to TOOLS.md if changed.
|
|
924
|
+
*
|
|
925
|
+
* @param api - Plugin API.
|
|
926
|
+
* @param watcherUrl - Watcher API base URL.
|
|
927
|
+
* @returns True if the file was updated.
|
|
928
|
+
*/
|
|
929
|
+
async function refreshToolsMd(api, watcherUrl) {
|
|
930
|
+
const menu = await generateMetaMenu(watcherUrl);
|
|
931
|
+
if (menu === lastWrittenMenu) {
|
|
932
|
+
return false;
|
|
933
|
+
}
|
|
934
|
+
const toolsPath = resolveToolsPath(api);
|
|
935
|
+
let current = '';
|
|
936
|
+
try {
|
|
937
|
+
current = await readFile(toolsPath, 'utf8');
|
|
938
|
+
}
|
|
939
|
+
catch {
|
|
940
|
+
// File doesn't exist yet
|
|
941
|
+
}
|
|
942
|
+
const updated = upsertMetaSection(current, menu);
|
|
943
|
+
if (updated !== current) {
|
|
944
|
+
await writeFile(toolsPath, updated, 'utf8');
|
|
945
|
+
lastWrittenMenu = menu;
|
|
946
|
+
return true;
|
|
947
|
+
}
|
|
948
|
+
lastWrittenMenu = menu;
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Start the periodic TOOLS.md writer.
|
|
953
|
+
* Defers first write by 5s, then refreshes every 60s.
|
|
954
|
+
*
|
|
955
|
+
* @param api - Plugin API.
|
|
956
|
+
* @param watcherUrl - Watcher API base URL.
|
|
957
|
+
*/
|
|
958
|
+
function startToolsWriter(api, watcherUrl) {
|
|
959
|
+
// Deferred initial write
|
|
960
|
+
setTimeout(() => {
|
|
961
|
+
refreshToolsMd(api, watcherUrl).catch((err) => {
|
|
962
|
+
console.error('[jeeves-meta] Failed to write TOOLS.md:', err);
|
|
963
|
+
});
|
|
964
|
+
}, INITIAL_DELAY_MS);
|
|
965
|
+
// Periodic refresh
|
|
966
|
+
if (intervalHandle) {
|
|
967
|
+
clearInterval(intervalHandle);
|
|
968
|
+
}
|
|
969
|
+
intervalHandle = setInterval(() => {
|
|
970
|
+
refreshToolsMd(api, watcherUrl).catch((err) => {
|
|
971
|
+
console.error('[jeeves-meta] Failed to refresh TOOLS.md:', err);
|
|
972
|
+
});
|
|
973
|
+
}, REFRESH_INTERVAL_MS);
|
|
974
|
+
if (typeof intervalHandle === 'object' && 'unref' in intervalHandle) {
|
|
975
|
+
intervalHandle.unref();
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* OpenClaw plugin for jeeves-meta.
|
|
981
|
+
*
|
|
982
|
+
* Registers synthesis tools, virtual inference rules, and starts
|
|
983
|
+
* the periodic TOOLS.md writer at gateway startup.
|
|
984
|
+
*
|
|
985
|
+
* @packageDocumentation
|
|
986
|
+
*/
|
|
987
|
+
/** Register all jeeves-meta tools and rules with the OpenClaw plugin API. */
|
|
988
|
+
function register(api) {
|
|
989
|
+
registerSynthTools(api);
|
|
990
|
+
// Load config for rule registration and tools writer
|
|
991
|
+
const config = loadSynthConfig(getConfigPath(api));
|
|
992
|
+
// Register virtual rules with watcher (fire-and-forget at startup)
|
|
993
|
+
registerSynthRules(config.watcherUrl).catch((err) => {
|
|
994
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
995
|
+
console.error('[jeeves-meta] Failed to register virtual rules:', message);
|
|
996
|
+
});
|
|
997
|
+
// Start periodic TOOLS.md writer
|
|
998
|
+
startToolsWriter(api, config.watcherUrl);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
export { register as default };
|