@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/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 };