@karmaniverous/jeeves 0.3.1 → 0.4.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/content/agents-section.md +6 -7
- package/dist/cli/jeeves/index.js +473 -236
- package/dist/index.d.ts +191 -3
- package/dist/index.js +998 -378
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2,11 +2,11 @@ import { JSONPath } from 'jsonpath-plus';
|
|
|
2
2
|
import { writeFileSync, renameSync, existsSync, readFileSync, mkdirSync, cpSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { lock } from 'proper-lockfile';
|
|
5
|
-
import { gte } from 'semver';
|
|
5
|
+
import { gte, gt } from 'semver';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { packageDirectorySync } from 'package-directory';
|
|
8
|
-
import { z } from 'zod';
|
|
9
8
|
import { execSync } from 'node:child_process';
|
|
9
|
+
import { z } from 'zod';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -66,6 +66,8 @@ const WORKSPACE_FILES = {
|
|
|
66
66
|
soul: 'SOUL.md',
|
|
67
67
|
/** AGENTS.md — operational protocols and memory architecture. */
|
|
68
68
|
agents: 'AGENTS.md',
|
|
69
|
+
/** HEARTBEAT.md — platform status and health alerts. */
|
|
70
|
+
heartbeat: 'HEARTBEAT.md',
|
|
69
71
|
};
|
|
70
72
|
/** Templates directory name within core config. */
|
|
71
73
|
const TEMPLATES_DIR = 'templates';
|
|
@@ -80,14 +82,14 @@ const COMPONENT_VERSIONS_FILE = 'component-versions.json';
|
|
|
80
82
|
* Core library version, inlined at build time.
|
|
81
83
|
*
|
|
82
84
|
* @remarks
|
|
83
|
-
* The `0.3.
|
|
85
|
+
* The `0.3.1` placeholder is replaced by
|
|
84
86
|
* `@rollup/plugin-replace` during the build with the actual version
|
|
85
87
|
* from `package.json`. This ensures the correct version survives
|
|
86
88
|
* when consumers bundle core into their own dist (where runtime
|
|
87
89
|
* `import.meta.url`-based resolution would find the wrong package.json).
|
|
88
90
|
*/
|
|
89
91
|
/** The core library version from package.json (inlined at build time). */
|
|
90
|
-
const CORE_VERSION = '0.3.
|
|
92
|
+
const CORE_VERSION = '0.3.1';
|
|
91
93
|
|
|
92
94
|
/**
|
|
93
95
|
* Shared file I/O helpers for managed section operations.
|
|
@@ -198,6 +200,25 @@ function writeComponentVersion(coreConfigDir, options) {
|
|
|
198
200
|
}
|
|
199
201
|
atomicWrite(filePath, JSON.stringify(existing, null, 2) + '\n');
|
|
200
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Remove a component's version entry from the shared state file.
|
|
205
|
+
*
|
|
206
|
+
* @remarks
|
|
207
|
+
* Called during plugin uninstall to prevent the HEARTBEAT writer from
|
|
208
|
+
* probing a service that's intentionally gone. If the component isn't
|
|
209
|
+
* in the file, this is a no-op.
|
|
210
|
+
*
|
|
211
|
+
* @param coreConfigDir - Path to the core config directory.
|
|
212
|
+
* @param componentName - The component name to remove.
|
|
213
|
+
*/
|
|
214
|
+
function removeComponentVersion(coreConfigDir, componentName) {
|
|
215
|
+
const existing = readComponentVersions(coreConfigDir);
|
|
216
|
+
if (!(componentName in existing))
|
|
217
|
+
return;
|
|
218
|
+
const updated = Object.fromEntries(Object.entries(existing).filter(([key]) => key !== componentName));
|
|
219
|
+
const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
|
|
220
|
+
atomicWrite(filePath, JSON.stringify(updated, null, 2) + '\n');
|
|
221
|
+
}
|
|
201
222
|
|
|
202
223
|
/**
|
|
203
224
|
* Comment markers for managed content blocks.
|
|
@@ -216,6 +237,8 @@ const TOOLS_MARKERS = {
|
|
|
216
237
|
end: 'END JEEVES PLATFORM TOOLS',
|
|
217
238
|
/** H1 title prepended in section mode. */
|
|
218
239
|
title: 'Jeeves Platform Tools',
|
|
240
|
+
/** Managed block at bottom of file. */
|
|
241
|
+
position: 'bottom',
|
|
219
242
|
};
|
|
220
243
|
/** Default markers for SOUL.md managed block. */
|
|
221
244
|
const SOUL_MARKERS = {
|
|
@@ -225,6 +248,8 @@ const SOUL_MARKERS = {
|
|
|
225
248
|
end: 'END JEEVES SOUL',
|
|
226
249
|
/** H1 title prepended in the managed block. */
|
|
227
250
|
title: 'Jeeves Platform Soul',
|
|
251
|
+
/** Managed block at bottom of file. */
|
|
252
|
+
position: 'bottom',
|
|
228
253
|
};
|
|
229
254
|
/** Default markers for AGENTS.md managed block. */
|
|
230
255
|
const AGENTS_MARKERS = {
|
|
@@ -234,6 +259,8 @@ const AGENTS_MARKERS = {
|
|
|
234
259
|
end: 'END JEEVES AGENTS',
|
|
235
260
|
/** H1 title prepended in the managed block. */
|
|
236
261
|
title: 'Jeeves Platform Agents',
|
|
262
|
+
/** Managed block at bottom of file. */
|
|
263
|
+
position: 'bottom',
|
|
237
264
|
};
|
|
238
265
|
/** All known marker sets — single source of truth for cross-contamination detection. */
|
|
239
266
|
const ALL_MARKERS = [
|
|
@@ -281,7 +308,7 @@ const DEFAULT_PORTS = {
|
|
|
281
308
|
};
|
|
282
309
|
|
|
283
310
|
/**
|
|
284
|
-
* Managed section IDs
|
|
311
|
+
* Managed section IDs, stable ordering, and platform component registry.
|
|
285
312
|
*
|
|
286
313
|
* @remarks
|
|
287
314
|
* Section ordering is fixed to prevent diff churn regardless of which
|
|
@@ -311,6 +338,22 @@ const SECTION_ORDER = [
|
|
|
311
338
|
SECTION_IDS.Runner,
|
|
312
339
|
SECTION_IDS.Meta,
|
|
313
340
|
];
|
|
341
|
+
/**
|
|
342
|
+
* The four essential platform components.
|
|
343
|
+
*
|
|
344
|
+
* @remarks
|
|
345
|
+
* These components constitute the Jeeves platform. `jeeves install` writes
|
|
346
|
+
* initial HEARTBEAT "Not installed" alerts for all of them. The HEARTBEAT
|
|
347
|
+
* writer generates "Not installed" alerts only for platform components not
|
|
348
|
+
* in `component-versions.json`. Optional future components (not in this list)
|
|
349
|
+
* appear in HEARTBEAT only after explicit install.
|
|
350
|
+
*/
|
|
351
|
+
const PLATFORM_COMPONENTS = [
|
|
352
|
+
'runner',
|
|
353
|
+
'watcher',
|
|
354
|
+
'server',
|
|
355
|
+
'meta',
|
|
356
|
+
];
|
|
314
357
|
|
|
315
358
|
/**
|
|
316
359
|
* Workspace and config root initialization.
|
|
@@ -394,6 +437,124 @@ function resetInit() {
|
|
|
394
437
|
state = undefined;
|
|
395
438
|
}
|
|
396
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Heading-based HEARTBEAT section writer.
|
|
442
|
+
*
|
|
443
|
+
* @remarks
|
|
444
|
+
* Manages the `# Jeeves Platform Status` section in HEARTBEAT.md.
|
|
445
|
+
* Unlike TOOLS/SOUL/AGENTS (which use HTML comment markers), HEARTBEAT
|
|
446
|
+
* uses markdown headings as markers — this ensures the file passes
|
|
447
|
+
* OpenClaw's heartbeat emptiness check when only headings remain.
|
|
448
|
+
*
|
|
449
|
+
* The section is always at the bottom of the file (H1 to EOF).
|
|
450
|
+
* User heartbeat items above the section are preserved.
|
|
451
|
+
*/
|
|
452
|
+
/** The H1 heading that anchors the platform status section. */
|
|
453
|
+
const HEARTBEAT_HEADING = '# Jeeves Platform Status';
|
|
454
|
+
/**
|
|
455
|
+
* Parse the HEARTBEAT.md file content.
|
|
456
|
+
*
|
|
457
|
+
* @param fileContent - Full file content.
|
|
458
|
+
* @returns Parsed result with user zone and component entries.
|
|
459
|
+
*/
|
|
460
|
+
function parseHeartbeat(fileContent) {
|
|
461
|
+
const headingIndex = fileContent.indexOf(HEARTBEAT_HEADING);
|
|
462
|
+
if (headingIndex === -1) {
|
|
463
|
+
return {
|
|
464
|
+
userContent: fileContent.trim(),
|
|
465
|
+
found: false,
|
|
466
|
+
entries: [],
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const userContent = fileContent.slice(0, headingIndex).trim();
|
|
470
|
+
const sectionContent = fileContent.slice(headingIndex + HEARTBEAT_HEADING.length);
|
|
471
|
+
const entries = [];
|
|
472
|
+
const h2Re = /^## (jeeves-\S+?)(?:: declined)?$/gm;
|
|
473
|
+
let match;
|
|
474
|
+
const h2Positions = [];
|
|
475
|
+
while ((match = h2Re.exec(sectionContent)) !== null) {
|
|
476
|
+
const fullHeading = match[0];
|
|
477
|
+
const name = match[1];
|
|
478
|
+
const declined = fullHeading.endsWith(': declined');
|
|
479
|
+
h2Positions.push({ name, declined, start: match.index });
|
|
480
|
+
}
|
|
481
|
+
for (let i = 0; i < h2Positions.length; i++) {
|
|
482
|
+
const pos = h2Positions[i];
|
|
483
|
+
const headingLine = pos.declined
|
|
484
|
+
? `## ${pos.name}: declined`
|
|
485
|
+
: `## ${pos.name}`;
|
|
486
|
+
const contentStart = pos.start + headingLine.length;
|
|
487
|
+
const contentEnd = i + 1 < h2Positions.length
|
|
488
|
+
? h2Positions[i + 1].start
|
|
489
|
+
: sectionContent.length;
|
|
490
|
+
const content = sectionContent.slice(contentStart, contentEnd).trim();
|
|
491
|
+
entries.push({
|
|
492
|
+
name: pos.name,
|
|
493
|
+
declined: pos.declined,
|
|
494
|
+
content,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return { userContent, found: true, entries };
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Build the HEARTBEAT section content from entries.
|
|
501
|
+
*
|
|
502
|
+
* @param entries - Component entries to write.
|
|
503
|
+
* @returns The full section string (H1 + H2s).
|
|
504
|
+
*/
|
|
505
|
+
function buildHeartbeatSection(entries) {
|
|
506
|
+
const parts = [HEARTBEAT_HEADING];
|
|
507
|
+
for (const entry of entries) {
|
|
508
|
+
if (entry.declined) {
|
|
509
|
+
parts.push(`## ${entry.name}: declined`);
|
|
510
|
+
}
|
|
511
|
+
else if (entry.content) {
|
|
512
|
+
parts.push(`## ${entry.name}`);
|
|
513
|
+
parts.push(entry.content);
|
|
514
|
+
}
|
|
515
|
+
// Healthy components (no content, not declined) get no H2 section
|
|
516
|
+
}
|
|
517
|
+
return parts.join('\n');
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Write the HEARTBEAT section to a file.
|
|
521
|
+
*
|
|
522
|
+
* @remarks
|
|
523
|
+
* Replaces everything from `# Jeeves Platform Status` to EOF.
|
|
524
|
+
* Preserves user content above the heading. Uses file-level locking.
|
|
525
|
+
*
|
|
526
|
+
* @param filePath - Absolute path to HEARTBEAT.md.
|
|
527
|
+
* @param entries - Component entries to write.
|
|
528
|
+
*/
|
|
529
|
+
async function writeHeartbeatSection(filePath, entries) {
|
|
530
|
+
const dir = dirname(filePath);
|
|
531
|
+
if (!existsSync(dir)) {
|
|
532
|
+
mkdirSync(dir, { recursive: true });
|
|
533
|
+
}
|
|
534
|
+
if (!existsSync(filePath)) {
|
|
535
|
+
writeFileSync(filePath, '', 'utf-8');
|
|
536
|
+
}
|
|
537
|
+
try {
|
|
538
|
+
await withFileLock(filePath, () => {
|
|
539
|
+
const fileContent = readFileSync(filePath, 'utf-8');
|
|
540
|
+
const parsed = parseHeartbeat(fileContent);
|
|
541
|
+
const section = buildHeartbeatSection(entries);
|
|
542
|
+
const parts = [];
|
|
543
|
+
if (parsed.userContent) {
|
|
544
|
+
parts.push(parsed.userContent);
|
|
545
|
+
parts.push('');
|
|
546
|
+
}
|
|
547
|
+
parts.push(section);
|
|
548
|
+
parts.push('');
|
|
549
|
+
atomicWrite(filePath, parts.join('\n'));
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
554
|
+
console.warn(`jeeves-core: writeHeartbeatSection failed for ${filePath}: ${message}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
397
558
|
/**
|
|
398
559
|
* Similarity-based cleanup detection for orphaned managed content.
|
|
399
560
|
*
|
|
@@ -747,32 +908,51 @@ async function updateManagedSection(filePath, content, options = {}) {
|
|
|
747
908
|
? `# ${markers.title}\n\n${sectionText}`
|
|
748
909
|
: sectionText;
|
|
749
910
|
}
|
|
911
|
+
// Combine beforeContent + userContent for the user zone.
|
|
912
|
+
// When migrating from top→bottom, beforeContent is empty and
|
|
913
|
+
// userContent has the real content. When already at bottom,
|
|
914
|
+
// beforeContent has the user content and userContent is empty.
|
|
915
|
+
const rawUserContent = [parsed.beforeContent, parsed.userContent]
|
|
916
|
+
.filter(Boolean)
|
|
917
|
+
.join('\n\n')
|
|
918
|
+
.trim();
|
|
750
919
|
// Strip foreign managed blocks from user content (cross-contamination fix)
|
|
751
|
-
const userContent = stripForeignMarkers(
|
|
920
|
+
const userContent = stripForeignMarkers(rawUserContent, markers);
|
|
752
921
|
const cleanupNeeded = needsCleanup(newManagedBody, userContent);
|
|
753
922
|
// Build the full managed block
|
|
754
923
|
const beginLine = formatBeginMarker(markers.begin, coreVersion);
|
|
755
924
|
const endLine = formatEndMarker(markers.end);
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
parts.push(parsed.beforeContent);
|
|
759
|
-
parts.push('');
|
|
760
|
-
}
|
|
761
|
-
parts.push(beginLine);
|
|
925
|
+
const managedParts = [];
|
|
926
|
+
managedParts.push(beginLine);
|
|
762
927
|
if (cleanupNeeded) {
|
|
763
|
-
|
|
764
|
-
|
|
928
|
+
managedParts.push('');
|
|
929
|
+
managedParts.push(CLEANUP_FLAG);
|
|
765
930
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
931
|
+
managedParts.push('');
|
|
932
|
+
managedParts.push(newManagedBody);
|
|
933
|
+
managedParts.push('');
|
|
934
|
+
managedParts.push(endLine);
|
|
935
|
+
const managedBlock = managedParts.join('\n');
|
|
936
|
+
const position = markers.position ?? 'top';
|
|
937
|
+
const fileParts = [];
|
|
938
|
+
if (position === 'bottom') {
|
|
939
|
+
// User content first, managed block at end
|
|
940
|
+
if (userContent) {
|
|
941
|
+
fileParts.push(userContent);
|
|
942
|
+
fileParts.push('');
|
|
943
|
+
}
|
|
944
|
+
fileParts.push(managedBlock);
|
|
773
945
|
}
|
|
774
|
-
|
|
775
|
-
|
|
946
|
+
else {
|
|
947
|
+
// Managed block first (legacy default), user content below
|
|
948
|
+
fileParts.push(managedBlock);
|
|
949
|
+
if (userContent) {
|
|
950
|
+
fileParts.push('');
|
|
951
|
+
fileParts.push(userContent);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
fileParts.push('');
|
|
955
|
+
const newFileContent = fileParts.join('\n');
|
|
776
956
|
atomicWrite(filePath, newFileContent);
|
|
777
957
|
});
|
|
778
958
|
}
|
|
@@ -850,6 +1030,8 @@ At minimum, always brief sub-agents on:
|
|
|
850
1030
|
|
|
851
1031
|
**Anything important enough to have a permanent cron/heartbeat entry is important enough to be codified into the data flow.**
|
|
852
1032
|
|
|
1033
|
+
**HEARTBEAT efficiency:** When HEARTBEAT fires with multiple component alerts, resolve them all in a single conversation. Walk the user through each component in dependency order. Each heartbeat cycle is a full LLM session — resolve all alerts in one pass.
|
|
1034
|
+
|
|
853
1035
|
## Messaging Dispatch
|
|
854
1036
|
|
|
855
1037
|
**Same-channel replies:** Don't use the \`message\` tool. It fires immediately, jumping ahead of streaming narration. Just write text as your response. Use \`message\` only for **cross-channel** sends (sending to a different channel or DM than the one you're responding in).
|
|
@@ -858,7 +1040,7 @@ At minimum, always brief sub-agents on:
|
|
|
858
1040
|
|
|
859
1041
|
## Heartbeat Discipline
|
|
860
1042
|
|
|
861
|
-
**Default state of HEARTBEAT.md is
|
|
1043
|
+
**Default state of HEARTBEAT.md is effectively empty** — the \`# Jeeves Platform Status\` section contains only headings when all components are healthy or declined. No list content, no LLM cost. User heartbeat items (above the platform status section) follow the same transient/termination rules as before.
|
|
862
1044
|
|
|
863
1045
|
Heartbeat items are for **transient, session-requiring work-in-progress ONLY**. Each item must include its own termination condition.
|
|
864
1046
|
|
|
@@ -919,14 +1101,11 @@ When discovering a new data source, integrate it into the existing data flow pip
|
|
|
919
1101
|
|
|
920
1102
|
## Bootstrap Protocol
|
|
921
1103
|
|
|
922
|
-
|
|
1104
|
+
**Proactive platform status:** HEARTBEAT.md is loaded every session. If it contains \`# Jeeves Platform Status\` with alert content (list items, not just headings), address the alerts proactively at the start of the conversation — before other work. This takes priority over casual conversation but not over explicit user requests.
|
|
923
1105
|
|
|
924
|
-
|
|
925
|
-
2. **Inform** the user what's missing and what it provides
|
|
926
|
-
3. **Guide** them through installation using the component's CLI install command
|
|
927
|
-
4. **Verify** the installation succeeded (probe the port, check the plugin registration)
|
|
1106
|
+
**Follow the instructions:** Explain the component, ask for consent, execute the command, verify. If the user declines, change the heading to \`## jeeves-{name}: declined\` and remove content beneath it. Do not prompt for declined components. Do not improvise — the HEARTBEAT instructions are authoritative.
|
|
928
1107
|
|
|
929
|
-
|
|
1108
|
+
When resolving multiple component alerts, walk the user through each in dependency order (watcher before meta, runner and server independent) within a single conversation rather than one per heartbeat cycle.
|
|
930
1109
|
|
|
931
1110
|
## Em-Dash Discipline
|
|
932
1111
|
|
|
@@ -1268,132 +1447,782 @@ async function refreshPlatformContent(options) {
|
|
|
1268
1447
|
}
|
|
1269
1448
|
|
|
1270
1449
|
/**
|
|
1271
|
-
*
|
|
1450
|
+
* Platform-aware service state detection.
|
|
1272
1451
|
*
|
|
1273
1452
|
* @remarks
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
1276
|
-
* on a configurable prime-interval timer cycle.
|
|
1453
|
+
* Detects whether a system service is installed and running.
|
|
1454
|
+
* Delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
|
|
1277
1455
|
*/
|
|
1278
1456
|
/**
|
|
1279
|
-
*
|
|
1457
|
+
* Detect the state of a system service by name.
|
|
1280
1458
|
*
|
|
1281
|
-
* @
|
|
1282
|
-
*
|
|
1283
|
-
* at the component's prime-interval, calling `generateToolsContent()`
|
|
1284
|
-
* and `refreshPlatformContent()` on each cycle.
|
|
1459
|
+
* @param serviceName - The service name (e.g., 'jeeves-runner').
|
|
1460
|
+
* @returns The detected service state.
|
|
1285
1461
|
*/
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1462
|
+
function getServiceState(serviceName) {
|
|
1463
|
+
switch (process.platform) {
|
|
1464
|
+
case 'win32':
|
|
1465
|
+
return getServiceStateWindows(serviceName);
|
|
1466
|
+
case 'darwin':
|
|
1467
|
+
return getServiceStateMacOS(serviceName);
|
|
1468
|
+
default:
|
|
1469
|
+
return getServiceStateLinux(serviceName);
|
|
1294
1470
|
}
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Windows: detect via NSSM.
|
|
1474
|
+
* - Exit code 3 = service does not exist
|
|
1475
|
+
* - "SERVICE_RUNNING" in output = running
|
|
1476
|
+
* - Other output = stopped/paused
|
|
1477
|
+
*/
|
|
1478
|
+
function getServiceStateWindows(serviceName) {
|
|
1479
|
+
try {
|
|
1480
|
+
const output = execSync(`nssm status ${serviceName}`, {
|
|
1481
|
+
encoding: 'utf-8',
|
|
1482
|
+
timeout: 5000,
|
|
1483
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1484
|
+
}).trim();
|
|
1485
|
+
if (output.includes('SERVICE_RUNNING'))
|
|
1486
|
+
return 'running';
|
|
1487
|
+
return 'stopped';
|
|
1298
1488
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1489
|
+
catch (err) {
|
|
1490
|
+
// NSSM exits with code 3 when the service doesn't exist
|
|
1491
|
+
if (isExecError(err) && err.status === 3)
|
|
1492
|
+
return 'not_installed';
|
|
1493
|
+
// Any other error (nssm not found, timeout, etc.) — treat as not installed
|
|
1494
|
+
return 'not_installed';
|
|
1302
1495
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Linux: detect via systemd user services.
|
|
1499
|
+
* - `systemctl --user is-enabled {name}.service` exits non-zero = not installed
|
|
1500
|
+
* - `systemctl --user is-active {name}.service` returns "active" = running
|
|
1501
|
+
*/
|
|
1502
|
+
function getServiceStateLinux(serviceName) {
|
|
1503
|
+
try {
|
|
1504
|
+
execSync(`systemctl --user is-enabled ${serviceName}.service`, {
|
|
1505
|
+
encoding: 'utf-8',
|
|
1506
|
+
timeout: 5000,
|
|
1507
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1508
|
+
});
|
|
1315
1509
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
if (this.timer) {
|
|
1319
|
-
clearInterval(this.timer);
|
|
1320
|
-
this.timer = undefined;
|
|
1321
|
-
}
|
|
1510
|
+
catch {
|
|
1511
|
+
return 'not_installed';
|
|
1322
1512
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1513
|
+
try {
|
|
1514
|
+
const output = execSync(`systemctl --user is-active ${serviceName}.service`, {
|
|
1515
|
+
encoding: 'utf-8',
|
|
1516
|
+
timeout: 5000,
|
|
1517
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1518
|
+
}).trim();
|
|
1519
|
+
if (output === 'active')
|
|
1520
|
+
return 'running';
|
|
1521
|
+
return 'stopped';
|
|
1522
|
+
}
|
|
1523
|
+
catch {
|
|
1524
|
+
return 'stopped';
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* macOS: detect via launchctl.
|
|
1529
|
+
* - `launchctl list {name}` exits non-zero = not installed
|
|
1530
|
+
* - PID column is `-` or `0` = stopped
|
|
1531
|
+
*/
|
|
1532
|
+
function getServiceStateMacOS(serviceName) {
|
|
1533
|
+
try {
|
|
1534
|
+
const output = execSync(`launchctl list ${serviceName}`, {
|
|
1535
|
+
encoding: 'utf-8',
|
|
1536
|
+
timeout: 5000,
|
|
1537
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1538
|
+
}).trim();
|
|
1539
|
+
// launchctl list output formats:
|
|
1540
|
+
// 1. Table row: "PID\tStatus\tLabel" (e.g., "1234\t0\tcom.jeeves.runner")
|
|
1541
|
+
// 2. Plist-style: '"PID" = 1234;'
|
|
1542
|
+
// 3. Single service: first token is the PID or "-"
|
|
1543
|
+
// Try table format: first token is PID
|
|
1544
|
+
const tableMatch = /^(\d+|-)\s/m.exec(output);
|
|
1545
|
+
if (tableMatch) {
|
|
1546
|
+
const pid = tableMatch[1];
|
|
1547
|
+
return pid !== '-' && Number(pid) > 0 ? 'running' : 'stopped';
|
|
1351
1548
|
}
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1549
|
+
// Try plist-style: "PID" = <number>;
|
|
1550
|
+
const plistMatch = /"PID"\s*=\s*(\d+)/m.exec(output);
|
|
1551
|
+
if (plistMatch) {
|
|
1552
|
+
return Number(plistMatch[1]) > 0 ? 'running' : 'stopped';
|
|
1355
1553
|
}
|
|
1554
|
+
// If we got output but can't parse it, assume stopped (service exists but state unclear)
|
|
1555
|
+
return 'stopped';
|
|
1556
|
+
}
|
|
1557
|
+
catch {
|
|
1558
|
+
return 'not_installed';
|
|
1356
1559
|
}
|
|
1357
1560
|
}
|
|
1561
|
+
/** Type guard for execSync errors with a status code. */
|
|
1562
|
+
function isExecError(err) {
|
|
1563
|
+
return (typeof err === 'object' &&
|
|
1564
|
+
err !== null &&
|
|
1565
|
+
'status' in err &&
|
|
1566
|
+
typeof err.status === 'number');
|
|
1567
|
+
}
|
|
1358
1568
|
|
|
1359
1569
|
/**
|
|
1360
|
-
*
|
|
1570
|
+
* Core configuration schema and resolution.
|
|
1361
1571
|
*
|
|
1362
1572
|
* @remarks
|
|
1363
|
-
*
|
|
1364
|
-
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
1369
|
-
* First call returns `placeholder`. Subsequent calls return the last
|
|
1370
|
-
* successfully fetched content. If a refresh fails, the previous good
|
|
1371
|
-
* value is retained.
|
|
1372
|
-
*
|
|
1373
|
-
* @example
|
|
1374
|
-
* ```typescript
|
|
1375
|
-
* const getContent = createAsyncContentCache({
|
|
1376
|
-
* fetch: async () => {
|
|
1377
|
-
* const res = await fetch('http://127.0.0.1:1936/status');
|
|
1378
|
-
* return formatWatcherStatus(await res.json());
|
|
1379
|
-
* },
|
|
1380
|
-
* placeholder: '> Initializing watcher status...',
|
|
1381
|
-
* });
|
|
1382
|
-
*
|
|
1383
|
-
* const writer = createComponentWriter({
|
|
1384
|
-
* // ...
|
|
1385
|
-
* generateToolsContent: getContent,
|
|
1386
|
-
* });
|
|
1387
|
-
* ```
|
|
1573
|
+
* Core config lives at `{configRoot}/jeeves-core/config.json`.
|
|
1574
|
+
* Config resolution order:
|
|
1575
|
+
* 1. Component's own config file
|
|
1576
|
+
* 2. Core config file
|
|
1577
|
+
* 3. Hardcoded library defaults
|
|
1388
1578
|
*/
|
|
1579
|
+
/** Zod schema for a service entry in core config. */
|
|
1580
|
+
const serviceEntrySchema = z.object({
|
|
1581
|
+
/** Service URL (must be a valid URL). */
|
|
1582
|
+
url: z.string().url().describe('Service URL'),
|
|
1583
|
+
});
|
|
1584
|
+
/** Default bind address for all Jeeves services. */
|
|
1585
|
+
const DEFAULT_BIND_ADDRESS = '0.0.0.0';
|
|
1586
|
+
/** Zod schema for the core config file. */
|
|
1587
|
+
const coreConfigSchema = z.object({
|
|
1588
|
+
/** JSON Schema pointer for IDE autocomplete. */
|
|
1589
|
+
$schema: z.string().optional().describe('JSON Schema pointer'),
|
|
1590
|
+
/** Owner identity keys (canonical identityLinks references). */
|
|
1591
|
+
owners: z.array(z.string()).default([]).describe('Owner identity keys'),
|
|
1592
|
+
/**
|
|
1593
|
+
* Bind address for all Jeeves services. Default: `0.0.0.0` (all interfaces).
|
|
1594
|
+
* Individual components can override in their own config.
|
|
1595
|
+
*/
|
|
1596
|
+
bindAddress: z
|
|
1597
|
+
.string()
|
|
1598
|
+
.default(DEFAULT_BIND_ADDRESS)
|
|
1599
|
+
.describe('Bind address for all Jeeves services'),
|
|
1600
|
+
/** Service URL overrides keyed by service name. */
|
|
1601
|
+
services: z
|
|
1602
|
+
.record(z.string(), serviceEntrySchema)
|
|
1603
|
+
.default({})
|
|
1604
|
+
.describe('Service URL overrides'),
|
|
1605
|
+
/** Registry cache configuration. */
|
|
1606
|
+
registryCache: z
|
|
1607
|
+
.object({
|
|
1608
|
+
/** Cache TTL in seconds for npm registry queries. */
|
|
1609
|
+
ttlSeconds: z
|
|
1610
|
+
.number()
|
|
1611
|
+
.int()
|
|
1612
|
+
.positive()
|
|
1613
|
+
.default(3600)
|
|
1614
|
+
.describe('Cache TTL in seconds'),
|
|
1615
|
+
})
|
|
1616
|
+
.default({})
|
|
1617
|
+
.describe('Registry cache settings'),
|
|
1618
|
+
});
|
|
1389
1619
|
/**
|
|
1390
|
-
*
|
|
1620
|
+
* Generate a JSON Schema from the Zod schema for `$schema` pointer support.
|
|
1391
1621
|
*
|
|
1392
|
-
* @
|
|
1393
|
-
* @returns A sync `() => string` suitable for `generateToolsContent`.
|
|
1622
|
+
* @returns A JSON Schema object.
|
|
1394
1623
|
*/
|
|
1395
|
-
function
|
|
1396
|
-
|
|
1624
|
+
function generateJsonSchema() {
|
|
1625
|
+
return {
|
|
1626
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
1627
|
+
title: 'Jeeves Core Configuration',
|
|
1628
|
+
type: 'object',
|
|
1629
|
+
properties: {
|
|
1630
|
+
$schema: { type: 'string' },
|
|
1631
|
+
owners: {
|
|
1632
|
+
type: 'array',
|
|
1633
|
+
items: { type: 'string' },
|
|
1634
|
+
default: [],
|
|
1635
|
+
},
|
|
1636
|
+
bindAddress: {
|
|
1637
|
+
type: 'string',
|
|
1638
|
+
default: '0.0.0.0',
|
|
1639
|
+
description: 'Bind address for all Jeeves services',
|
|
1640
|
+
},
|
|
1641
|
+
services: {
|
|
1642
|
+
type: 'object',
|
|
1643
|
+
additionalProperties: {
|
|
1644
|
+
type: 'object',
|
|
1645
|
+
properties: {
|
|
1646
|
+
url: { type: 'string', format: 'uri' },
|
|
1647
|
+
},
|
|
1648
|
+
required: ['url'],
|
|
1649
|
+
},
|
|
1650
|
+
default: {},
|
|
1651
|
+
},
|
|
1652
|
+
registryCache: {
|
|
1653
|
+
type: 'object',
|
|
1654
|
+
properties: {
|
|
1655
|
+
ttlSeconds: {
|
|
1656
|
+
type: 'integer',
|
|
1657
|
+
minimum: 1,
|
|
1658
|
+
default: 3600,
|
|
1659
|
+
},
|
|
1660
|
+
},
|
|
1661
|
+
default: {},
|
|
1662
|
+
},
|
|
1663
|
+
},
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Load and parse a config file, returning undefined if missing or invalid.
|
|
1668
|
+
*
|
|
1669
|
+
* @param configDir - Directory containing config.json.
|
|
1670
|
+
* @returns Parsed config or undefined.
|
|
1671
|
+
*/
|
|
1672
|
+
function loadConfig(configDir) {
|
|
1673
|
+
const configPath = join(configDir, CONFIG_FILE);
|
|
1674
|
+
if (!existsSync(configPath))
|
|
1675
|
+
return undefined;
|
|
1676
|
+
try {
|
|
1677
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
1678
|
+
const parsed = JSON.parse(raw);
|
|
1679
|
+
return coreConfigSchema.parse(parsed);
|
|
1680
|
+
}
|
|
1681
|
+
catch {
|
|
1682
|
+
return undefined;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* Service URL resolution.
|
|
1688
|
+
*
|
|
1689
|
+
* @remarks
|
|
1690
|
+
* Resolves the URL for a named Jeeves service using the following
|
|
1691
|
+
* resolution order:
|
|
1692
|
+
* 1. Consumer's own component config
|
|
1693
|
+
* 2. Core config (`{configRoot}/jeeves-core/config.json`)
|
|
1694
|
+
* 3. Default port constants
|
|
1695
|
+
*/
|
|
1696
|
+
/**
|
|
1697
|
+
* Resolve the URL for a named Jeeves service.
|
|
1698
|
+
*
|
|
1699
|
+
* @param serviceName - The service name (e.g., 'watcher', 'runner').
|
|
1700
|
+
* @param consumerName - Optional consumer component name for config override.
|
|
1701
|
+
* @returns The resolved service URL.
|
|
1702
|
+
* @throws Error if `init()` has not been called or the service is unknown.
|
|
1703
|
+
*/
|
|
1704
|
+
function getServiceUrl(serviceName, consumerName) {
|
|
1705
|
+
// 1. Check consumer's own config
|
|
1706
|
+
if (consumerName) {
|
|
1707
|
+
const consumerDir = getComponentConfigDir(consumerName);
|
|
1708
|
+
const consumerConfig = loadConfig(consumerDir);
|
|
1709
|
+
const consumerUrl = consumerConfig?.services[serviceName]?.url;
|
|
1710
|
+
if (consumerUrl)
|
|
1711
|
+
return consumerUrl;
|
|
1712
|
+
}
|
|
1713
|
+
// 2. Check core config
|
|
1714
|
+
const coreDir = getCoreConfigDir();
|
|
1715
|
+
const coreConfig = loadConfig(coreDir);
|
|
1716
|
+
const coreUrl = coreConfig?.services[serviceName]?.url;
|
|
1717
|
+
if (coreUrl)
|
|
1718
|
+
return coreUrl;
|
|
1719
|
+
// 3. Fall back to port constants
|
|
1720
|
+
const port = DEFAULT_PORTS[serviceName];
|
|
1721
|
+
if (port !== undefined) {
|
|
1722
|
+
return `http://127.0.0.1:${String(port)}`;
|
|
1723
|
+
}
|
|
1724
|
+
throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* Registry version cache for npm package update awareness.
|
|
1729
|
+
*
|
|
1730
|
+
* @remarks
|
|
1731
|
+
* Caches the latest npm registry version in a local JSON file
|
|
1732
|
+
* to avoid expensive `npm view` calls on every refresh cycle.
|
|
1733
|
+
*/
|
|
1734
|
+
/**
|
|
1735
|
+
* Check the npm registry for the latest version of a package.
|
|
1736
|
+
*
|
|
1737
|
+
* @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
|
|
1738
|
+
* @param cacheDir - Directory to store the cache file.
|
|
1739
|
+
* @param ttlSeconds - Cache TTL in seconds (default 3600).
|
|
1740
|
+
* @returns The latest version string, or undefined if the check fails.
|
|
1741
|
+
*/
|
|
1742
|
+
function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
|
|
1743
|
+
const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
|
|
1744
|
+
// Check cache first
|
|
1745
|
+
if (existsSync(cachePath)) {
|
|
1746
|
+
try {
|
|
1747
|
+
const raw = readFileSync(cachePath, 'utf-8');
|
|
1748
|
+
const entry = JSON.parse(raw);
|
|
1749
|
+
const age = Date.now() - new Date(entry.checkedAt).getTime();
|
|
1750
|
+
if (age < ttlSeconds * 1000) {
|
|
1751
|
+
return entry.version;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
catch {
|
|
1755
|
+
// Cache corrupt — proceed with fresh check
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
// Query npm registry
|
|
1759
|
+
try {
|
|
1760
|
+
const result = execSync(`npm view ${packageName} version`, {
|
|
1761
|
+
encoding: 'utf-8',
|
|
1762
|
+
timeout: 15_000,
|
|
1763
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1764
|
+
}).trim();
|
|
1765
|
+
if (!result)
|
|
1766
|
+
return undefined;
|
|
1767
|
+
// Write cache
|
|
1768
|
+
if (!existsSync(cacheDir)) {
|
|
1769
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
1770
|
+
}
|
|
1771
|
+
const entry = {
|
|
1772
|
+
version: result,
|
|
1773
|
+
checkedAt: new Date().toISOString(),
|
|
1774
|
+
};
|
|
1775
|
+
writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
|
|
1776
|
+
return result;
|
|
1777
|
+
}
|
|
1778
|
+
catch {
|
|
1779
|
+
return undefined;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/**
|
|
1784
|
+
* HTTP helpers for the OpenClaw plugin SDK.
|
|
1785
|
+
*
|
|
1786
|
+
* @remarks
|
|
1787
|
+
* Thin wrappers around `fetch` that throw on non-OK responses
|
|
1788
|
+
* and handle JSON serialisation/deserialisation.
|
|
1789
|
+
*/
|
|
1790
|
+
/**
|
|
1791
|
+
* Fetch a URL with an automatic abort timeout.
|
|
1792
|
+
*
|
|
1793
|
+
* @param url - URL to fetch.
|
|
1794
|
+
* @param timeoutMs - Timeout in milliseconds before aborting.
|
|
1795
|
+
* @param init - Optional `fetch` init options.
|
|
1796
|
+
* @returns The fetch Response object.
|
|
1797
|
+
*/
|
|
1798
|
+
async function fetchWithTimeout(url, timeoutMs, init) {
|
|
1799
|
+
const controller = new AbortController();
|
|
1800
|
+
const timeout = setTimeout(() => {
|
|
1801
|
+
controller.abort();
|
|
1802
|
+
}, timeoutMs);
|
|
1803
|
+
try {
|
|
1804
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
1805
|
+
}
|
|
1806
|
+
finally {
|
|
1807
|
+
clearTimeout(timeout);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Fetch JSON from a URL, throwing on non-OK responses.
|
|
1812
|
+
*
|
|
1813
|
+
* @param url - URL to fetch.
|
|
1814
|
+
* @param init - Optional `fetch` init options.
|
|
1815
|
+
* @returns Parsed JSON response body.
|
|
1816
|
+
* @throws Error with `HTTP {status}: {body}` message on non-OK responses.
|
|
1817
|
+
*/
|
|
1818
|
+
async function fetchJson(url, init) {
|
|
1819
|
+
const res = await fetch(url, init);
|
|
1820
|
+
if (!res.ok) {
|
|
1821
|
+
throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
|
|
1822
|
+
}
|
|
1823
|
+
return res.json();
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* POST JSON to a URL and return parsed response.
|
|
1827
|
+
*
|
|
1828
|
+
* @param url - URL to POST to.
|
|
1829
|
+
* @param body - Request body (will be JSON-stringified).
|
|
1830
|
+
* @returns Parsed JSON response body.
|
|
1831
|
+
*/
|
|
1832
|
+
async function postJson(url, body) {
|
|
1833
|
+
return fetchJson(url, {
|
|
1834
|
+
method: 'POST',
|
|
1835
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1836
|
+
body: JSON.stringify(body),
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/**
|
|
1841
|
+
* HEARTBEAT health orchestration.
|
|
1842
|
+
*
|
|
1843
|
+
* @remarks
|
|
1844
|
+
* Determines the state of each platform component and generates
|
|
1845
|
+
* HEARTBEAT entries with actionable alert text. Applies the dependency
|
|
1846
|
+
* graph for alert suppression and auto-decline.
|
|
1847
|
+
*/
|
|
1848
|
+
/** Derive the full service name from a component name. */
|
|
1849
|
+
function toServiceName(name) {
|
|
1850
|
+
return `jeeves-${name}`;
|
|
1851
|
+
}
|
|
1852
|
+
/** Known dependency declarations for platform components. */
|
|
1853
|
+
const COMPONENT_DEPS = {
|
|
1854
|
+
meta: { hard: ['watcher'], soft: [] },
|
|
1855
|
+
server: { hard: [], soft: ['watcher', 'runner', 'meta'] },
|
|
1856
|
+
runner: { hard: [], soft: [] },
|
|
1857
|
+
watcher: { hard: [], soft: [] },
|
|
1858
|
+
};
|
|
1859
|
+
/** "Not installed" alert text for each platform component. Shared with seedContent. */
|
|
1860
|
+
const NOT_INSTALLED_ALERTS = {
|
|
1861
|
+
runner: 'Not installed. The runner is the job execution engine — it runs scheduled scripts for data fetching, transformation, and maintenance. Ask the user for consent to install. On approval, execute: `npx @karmaniverous/jeeves-runner-openclaw install`.',
|
|
1862
|
+
watcher: 'Not installed. The watcher indexes documents into a vector store for semantic search. Ask the user for consent to install. On approval, execute: `npx @karmaniverous/jeeves-watcher-openclaw install`.',
|
|
1863
|
+
server: 'Not installed. The server provides a web UI for document browsing, rendering, sharing, and search. Ask the user for consent to install. On approval, execute: `npx @karmaniverous/jeeves-server-openclaw install`.',
|
|
1864
|
+
meta: 'Not installed. Meta is the knowledge synthesis engine. Requires jeeves-watcher. Ask the user for consent to install. On approval, install watcher first if needed, then execute: `npx @karmaniverous/jeeves-meta-openclaw install`.',
|
|
1865
|
+
};
|
|
1866
|
+
/** Alert text generators by state. */
|
|
1867
|
+
const ALERT_TEXT = {
|
|
1868
|
+
runner: {
|
|
1869
|
+
not_installed: NOT_INSTALLED_ALERTS['runner'],
|
|
1870
|
+
config_missing: (configRoot) => `Plugin installed but no configuration file found at \`${configRoot}/jeeves-runner/config.json\`. The plugin installer should have created a default config. Ask the user for consent to re-run the installer: \`npx @karmaniverous/jeeves-runner-openclaw install\`.`,
|
|
1871
|
+
service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-runner service install`. Verify the service is installed.',
|
|
1872
|
+
service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-runner service start`. Verify via `GET http://127.0.0.1:1937/status`.',
|
|
1873
|
+
},
|
|
1874
|
+
watcher: {
|
|
1875
|
+
not_installed: NOT_INSTALLED_ALERTS['watcher'],
|
|
1876
|
+
deps_missing: 'Plugin installed but Qdrant is not responding on `http://127.0.0.1:6333`. Qdrant is the vector database required for semantic search. Ask the user for consent to set up Qdrant. Guide them through installation for their platform — Docker is simplest: `docker run -p 6333:6333 qdrant/qdrant`. Verify via `GET http://127.0.0.1:6333/collections`.',
|
|
1877
|
+
config_missing: (configRoot) => `Plugin installed, Qdrant available, but config file missing or invalid at \`${configRoot}/jeeves-watcher/config.json\`. The plugin installer should have created a default config. If missing, re-run: \`npx @karmaniverous/jeeves-watcher-openclaw install\`.`,
|
|
1878
|
+
service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-watcher service install`. Verify the service is installed.',
|
|
1879
|
+
service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-watcher service start`. Verify via `GET http://127.0.0.1:1936/status`.',
|
|
1880
|
+
},
|
|
1881
|
+
server: {
|
|
1882
|
+
not_installed: NOT_INSTALLED_ALERTS['server'],
|
|
1883
|
+
config_missing: (configRoot) => `Plugin installed but config file missing or invalid at \`${configRoot}/jeeves-server/config.json\`. The plugin installer should have created a default config. If missing, re-run: \`npx @karmaniverous/jeeves-server-openclaw install\`.`,
|
|
1884
|
+
service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-server service install`. Verify the service is installed.',
|
|
1885
|
+
service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-server service start`. Verify via `GET http://127.0.0.1:1934/status`.',
|
|
1886
|
+
},
|
|
1887
|
+
meta: {
|
|
1888
|
+
not_installed: NOT_INSTALLED_ALERTS['meta'],
|
|
1889
|
+
deps_missing: 'Plugin installed but required dependency jeeves-watcher is not available. The watcher must be installed and running before meta can function. Do not attempt to set up meta until jeeves-watcher is healthy.',
|
|
1890
|
+
config_missing: (configRoot) => `Plugin installed, watcher available, but config file missing or invalid at \`${configRoot}/jeeves-meta/config.json\`. The plugin installer should have created a default config. If missing, re-run: \`npx @karmaniverous/jeeves-meta-openclaw install\`.`,
|
|
1891
|
+
service_not_installed: 'Plugin installed and configured but the system service is not installed. Ask the user for consent. On approval, execute: `jeeves-meta service install`. Verify the service is installed.',
|
|
1892
|
+
service_stopped: 'Service installed but not running. Ask the user for consent. On approval, execute: `jeeves-meta service start`. Verify via `GET http://127.0.0.1:1938/status`.',
|
|
1893
|
+
},
|
|
1894
|
+
};
|
|
1895
|
+
/** Default Qdrant URL for watcher dependency check. */
|
|
1896
|
+
const QDRANT_URL = 'http://127.0.0.1:6333';
|
|
1897
|
+
/** Health probe timeout in milliseconds. */
|
|
1898
|
+
const PROBE_TIMEOUT_MS = 3000;
|
|
1899
|
+
/**
|
|
1900
|
+
* Check if Qdrant is reachable (watcher dependency).
|
|
1901
|
+
*
|
|
1902
|
+
* @returns True if Qdrant responds.
|
|
1903
|
+
*/
|
|
1904
|
+
async function isQdrantAvailable() {
|
|
1905
|
+
try {
|
|
1906
|
+
await fetchWithTimeout(`${QDRANT_URL}/collections`, PROBE_TIMEOUT_MS);
|
|
1907
|
+
return true;
|
|
1908
|
+
}
|
|
1909
|
+
catch {
|
|
1910
|
+
return false;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Determine the state of a single component.
|
|
1915
|
+
*
|
|
1916
|
+
* @param name - Component name.
|
|
1917
|
+
* @param registry - Current component-versions.json contents.
|
|
1918
|
+
* @param configRoot - Config root path.
|
|
1919
|
+
* @param healthySet - Set of component names known to be healthy (for dep checks).
|
|
1920
|
+
* @returns The component's state.
|
|
1921
|
+
*/
|
|
1922
|
+
async function determineComponentState(name, registry, configRoot, healthySet) {
|
|
1923
|
+
// Not in registry = not installed
|
|
1924
|
+
if (!(name in registry))
|
|
1925
|
+
return 'not_installed';
|
|
1926
|
+
// Check hard dependencies
|
|
1927
|
+
const deps = COMPONENT_DEPS[name];
|
|
1928
|
+
for (const hardDep of deps.hard) {
|
|
1929
|
+
if (!healthySet.has(hardDep))
|
|
1930
|
+
return 'deps_missing';
|
|
1931
|
+
}
|
|
1932
|
+
// Watcher-specific: check Qdrant
|
|
1933
|
+
if (name === 'watcher' && !(await isQdrantAvailable())) {
|
|
1934
|
+
return 'deps_missing';
|
|
1935
|
+
}
|
|
1936
|
+
// Check config file
|
|
1937
|
+
const configPath = join(configRoot, `jeeves-${name}`, CONFIG_FILE);
|
|
1938
|
+
if (!existsSync(configPath))
|
|
1939
|
+
return 'config_missing';
|
|
1940
|
+
// Fast path: probe HTTP health endpoint
|
|
1941
|
+
try {
|
|
1942
|
+
const url = getServiceUrl(name);
|
|
1943
|
+
await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS);
|
|
1944
|
+
// Healthy — check for available updates
|
|
1945
|
+
const entry = registry[name];
|
|
1946
|
+
if (entry.pluginPackage && entry.pluginVersion) {
|
|
1947
|
+
const componentConfigDir = join(configRoot, `jeeves-${name}`);
|
|
1948
|
+
const latestVersion = checkRegistryVersion(entry.pluginPackage, componentConfigDir);
|
|
1949
|
+
if (latestVersion && gt(latestVersion, entry.pluginVersion)) {
|
|
1950
|
+
return 'update_available';
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
return 'healthy';
|
|
1954
|
+
}
|
|
1955
|
+
catch {
|
|
1956
|
+
// Service not responding — classify sub-state
|
|
1957
|
+
const serviceState = getServiceState(toServiceName(name));
|
|
1958
|
+
if (serviceState === 'not_installed')
|
|
1959
|
+
return 'service_not_installed';
|
|
1960
|
+
if (serviceState === 'stopped')
|
|
1961
|
+
return 'service_stopped';
|
|
1962
|
+
// serviceState === 'running' but HTTP failed — still treat as stopped
|
|
1963
|
+
return 'service_stopped';
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* Generate the alert text for a component in a given state.
|
|
1968
|
+
*
|
|
1969
|
+
* @param name - Component name.
|
|
1970
|
+
* @param state - The component's state.
|
|
1971
|
+
* @param configRoot - Config root path.
|
|
1972
|
+
* @returns Alert text (list items), or empty string if healthy.
|
|
1973
|
+
*/
|
|
1974
|
+
function generateAlertText(name, state, configRoot, registry) {
|
|
1975
|
+
if (state === 'healthy')
|
|
1976
|
+
return '';
|
|
1977
|
+
// Update available — dynamic text with version info
|
|
1978
|
+
if (state === 'update_available') {
|
|
1979
|
+
const entry = registry[name];
|
|
1980
|
+
const currentVersion = entry.pluginVersion ?? 'unknown';
|
|
1981
|
+
const componentConfigDir = join(configRoot, `jeeves-${name}`);
|
|
1982
|
+
const latestVersion = entry.pluginPackage
|
|
1983
|
+
? (checkRegistryVersion(entry.pluginPackage, componentConfigDir) ??
|
|
1984
|
+
'unknown')
|
|
1985
|
+
: 'unknown';
|
|
1986
|
+
const installCmd = entry.pluginPackage
|
|
1987
|
+
? `\`npx ${entry.pluginPackage} install\``
|
|
1988
|
+
: `\`npx @karmaniverous/jeeves-${name}-openclaw install\``;
|
|
1989
|
+
return `- Update available: v${currentVersion} → v${latestVersion}. Ask the user for consent to update. On approval, execute: ${installCmd}.`;
|
|
1990
|
+
}
|
|
1991
|
+
const componentAlerts = ALERT_TEXT[name];
|
|
1992
|
+
const alertOrFn = componentAlerts[state];
|
|
1993
|
+
if (!alertOrFn)
|
|
1994
|
+
return '';
|
|
1995
|
+
const text = typeof alertOrFn === 'function' ? alertOrFn(configRoot) : alertOrFn;
|
|
1996
|
+
return `- ${text}`;
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* Orchestrate HEARTBEAT entries for all platform components.
|
|
2000
|
+
*
|
|
2001
|
+
* @param options - Orchestration configuration.
|
|
2002
|
+
* @returns Array of HeartbeatEntry for writeHeartbeatSection.
|
|
2003
|
+
*/
|
|
2004
|
+
async function orchestrateHeartbeat(options) {
|
|
2005
|
+
const { coreConfigDir, configRoot, declinedNames } = options;
|
|
2006
|
+
const registry = readComponentVersions(coreConfigDir);
|
|
2007
|
+
// First pass: determine which components are healthy (for dep resolution)
|
|
2008
|
+
const healthySet = new Set();
|
|
2009
|
+
for (const name of PLATFORM_COMPONENTS) {
|
|
2010
|
+
if (declinedNames.has(toServiceName(name)))
|
|
2011
|
+
continue;
|
|
2012
|
+
if (!(name in registry))
|
|
2013
|
+
continue;
|
|
2014
|
+
try {
|
|
2015
|
+
const url = getServiceUrl(name);
|
|
2016
|
+
await fetchWithTimeout(`${url}/status`, PROBE_TIMEOUT_MS);
|
|
2017
|
+
healthySet.add(name);
|
|
2018
|
+
}
|
|
2019
|
+
catch {
|
|
2020
|
+
// Not healthy — will be classified in second pass
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
// Second pass: generate entries
|
|
2024
|
+
const entries = [];
|
|
2025
|
+
for (const name of PLATFORM_COMPONENTS) {
|
|
2026
|
+
const fullName = toServiceName(name);
|
|
2027
|
+
// Declined
|
|
2028
|
+
if (declinedNames.has(fullName)) {
|
|
2029
|
+
// Auto-decline dependents of declined hard deps
|
|
2030
|
+
entries.push({ name: fullName, declined: true, content: '' });
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
const state = await determineComponentState(name, registry, configRoot, healthySet);
|
|
2034
|
+
// Auto-decline if hard dep is declined
|
|
2035
|
+
const deps = COMPONENT_DEPS[name];
|
|
2036
|
+
const hardDepDeclined = deps.hard.some((d) => declinedNames.has(toServiceName(d)));
|
|
2037
|
+
if (hardDepDeclined) {
|
|
2038
|
+
entries.push({ name: fullName, declined: true, content: '' });
|
|
2039
|
+
continue;
|
|
2040
|
+
}
|
|
2041
|
+
const alertText = generateAlertText(name, state, configRoot, registry);
|
|
2042
|
+
entries.push({ name: fullName, declined: false, content: alertText });
|
|
2043
|
+
}
|
|
2044
|
+
// Add soft-dep informational alerts for any healthy component with soft deps
|
|
2045
|
+
for (const entry of entries) {
|
|
2046
|
+
if (entry.declined || entry.content)
|
|
2047
|
+
continue;
|
|
2048
|
+
// Entry is healthy (no alert, not declined) — check for soft deps
|
|
2049
|
+
const shortName = entry.name.replace(/^jeeves-/, '');
|
|
2050
|
+
const deps = COMPONENT_DEPS[shortName];
|
|
2051
|
+
if (!deps.soft.length)
|
|
2052
|
+
continue;
|
|
2053
|
+
const softAlerts = [];
|
|
2054
|
+
for (const dep of deps.soft) {
|
|
2055
|
+
const depFullName = toServiceName(dep);
|
|
2056
|
+
if (declinedNames.has(depFullName))
|
|
2057
|
+
continue;
|
|
2058
|
+
if (!healthySet.has(dep)) {
|
|
2059
|
+
softAlerts.push(`- ${entry.name} is running. Some features are unavailable because ${depFullName} is not installed/running.`);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
if (softAlerts.length > 0) {
|
|
2063
|
+
entry.content = softAlerts.join('\n');
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
return entries;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
/**
|
|
2070
|
+
* Timer-based orchestrator for managed content writing.
|
|
2071
|
+
*
|
|
2072
|
+
* @remarks
|
|
2073
|
+
* `ComponentWriter` manages a component's TOOLS.md section writes
|
|
2074
|
+
* and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
|
|
2075
|
+
* on a configurable prime-interval timer cycle.
|
|
2076
|
+
*/
|
|
2077
|
+
/**
|
|
2078
|
+
* Orchestrates managed content writing for a single Jeeves component.
|
|
2079
|
+
*
|
|
2080
|
+
* @remarks
|
|
2081
|
+
* Created via `createComponentWriter()`. Manages a timer that fires
|
|
2082
|
+
* at the component's prime-interval, calling `generateToolsContent()`
|
|
2083
|
+
* and `refreshPlatformContent()` on each cycle.
|
|
2084
|
+
*/
|
|
2085
|
+
class ComponentWriter {
|
|
2086
|
+
timer;
|
|
2087
|
+
component;
|
|
2088
|
+
configDir;
|
|
2089
|
+
/** @internal */
|
|
2090
|
+
constructor(component) {
|
|
2091
|
+
this.component = component;
|
|
2092
|
+
this.configDir = getComponentConfigDir(component.name);
|
|
2093
|
+
}
|
|
2094
|
+
/** The component's config directory path. */
|
|
2095
|
+
get componentConfigDir() {
|
|
2096
|
+
return this.configDir;
|
|
2097
|
+
}
|
|
2098
|
+
/** Whether the writer timer is currently running. */
|
|
2099
|
+
get isRunning() {
|
|
2100
|
+
return this.timer !== undefined;
|
|
2101
|
+
}
|
|
2102
|
+
/**
|
|
2103
|
+
* Start the writer timer.
|
|
2104
|
+
*
|
|
2105
|
+
* @remarks
|
|
2106
|
+
* Performs an immediate first write, then sets up the interval.
|
|
2107
|
+
*/
|
|
2108
|
+
start() {
|
|
2109
|
+
if (this.timer)
|
|
2110
|
+
return;
|
|
2111
|
+
// Fire immediately, then on interval
|
|
2112
|
+
void this.cycle();
|
|
2113
|
+
this.timer = setInterval(() => void this.cycle(), this.component.refreshIntervalSeconds * 1000);
|
|
2114
|
+
}
|
|
2115
|
+
/** Stop the writer timer. */
|
|
2116
|
+
stop() {
|
|
2117
|
+
if (this.timer) {
|
|
2118
|
+
clearInterval(this.timer);
|
|
2119
|
+
this.timer = undefined;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Execute a single write cycle.
|
|
2124
|
+
*
|
|
2125
|
+
* @remarks
|
|
2126
|
+
* Calls `generateToolsContent()` and writes the component's
|
|
2127
|
+
* TOOLS.md section via `updateManagedSection()`. Also calls
|
|
2128
|
+
* `refreshPlatformContent()` for shared content maintenance.
|
|
2129
|
+
*/
|
|
2130
|
+
async cycle() {
|
|
2131
|
+
try {
|
|
2132
|
+
const workspacePath = getWorkspacePath();
|
|
2133
|
+
const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
|
|
2134
|
+
// Write the component's TOOLS.md section
|
|
2135
|
+
const toolsContent = this.component.generateToolsContent();
|
|
2136
|
+
await updateManagedSection(toolsPath, toolsContent, {
|
|
2137
|
+
mode: 'section',
|
|
2138
|
+
sectionId: this.component.sectionId,
|
|
2139
|
+
markers: TOOLS_MARKERS,
|
|
2140
|
+
coreVersion: CORE_VERSION,
|
|
2141
|
+
});
|
|
2142
|
+
// Platform content maintenance: SOUL.md, AGENTS.md, Platform section
|
|
2143
|
+
await refreshPlatformContent({
|
|
2144
|
+
coreVersion: CORE_VERSION,
|
|
2145
|
+
componentName: this.component.name,
|
|
2146
|
+
componentVersion: this.component.version,
|
|
2147
|
+
servicePackage: this.component.servicePackage,
|
|
2148
|
+
pluginPackage: this.component.pluginPackage,
|
|
2149
|
+
});
|
|
2150
|
+
// HEARTBEAT health orchestration
|
|
2151
|
+
const heartbeatPath = join(workspacePath, WORKSPACE_FILES.heartbeat);
|
|
2152
|
+
try {
|
|
2153
|
+
const existingContent = (() => {
|
|
2154
|
+
try {
|
|
2155
|
+
return readFileSync(heartbeatPath, 'utf-8');
|
|
2156
|
+
}
|
|
2157
|
+
catch (err) {
|
|
2158
|
+
// Only swallow "file not found" — let permission errors propagate
|
|
2159
|
+
if (err instanceof Error &&
|
|
2160
|
+
'code' in err &&
|
|
2161
|
+
err.code === 'ENOENT') {
|
|
2162
|
+
return '';
|
|
2163
|
+
}
|
|
2164
|
+
throw err;
|
|
2165
|
+
}
|
|
2166
|
+
})();
|
|
2167
|
+
const parsed = parseHeartbeat(existingContent);
|
|
2168
|
+
const declinedNames = new Set(parsed.entries.filter((e) => e.declined).map((e) => e.name));
|
|
2169
|
+
const entries = await orchestrateHeartbeat({
|
|
2170
|
+
coreConfigDir: getCoreConfigDir(),
|
|
2171
|
+
configRoot: getConfigRoot(),
|
|
2172
|
+
declinedNames,
|
|
2173
|
+
});
|
|
2174
|
+
await writeHeartbeatSection(heartbeatPath, entries);
|
|
2175
|
+
}
|
|
2176
|
+
catch (err) {
|
|
2177
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2178
|
+
console.warn(`jeeves-core: HEARTBEAT orchestration failed: ${msg}`);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
catch (err) {
|
|
2182
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2183
|
+
console.warn(`jeeves-core: ComponentWriter cycle failed for ${this.component.name}: ${message}`);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* Creates a synchronous content accessor backed by an async data source.
|
|
2190
|
+
*
|
|
2191
|
+
* @remarks
|
|
2192
|
+
* Solves the sync/async gap in `JeevesComponent.generateToolsContent()`:
|
|
2193
|
+
* the interface is synchronous, but most components fetch live data from
|
|
2194
|
+
* their HTTP service. This utility returns a sync `() => string` that
|
|
2195
|
+
* serves the last successfully fetched value while kicking off a background
|
|
2196
|
+
* refresh on each call.
|
|
2197
|
+
*
|
|
2198
|
+
* First call returns `placeholder`. Subsequent calls return the last
|
|
2199
|
+
* successfully fetched content. If a refresh fails, the previous good
|
|
2200
|
+
* value is retained.
|
|
2201
|
+
*
|
|
2202
|
+
* @example
|
|
2203
|
+
* ```typescript
|
|
2204
|
+
* const getContent = createAsyncContentCache({
|
|
2205
|
+
* fetch: async () => {
|
|
2206
|
+
* const res = await fetch('http://127.0.0.1:1936/status');
|
|
2207
|
+
* return formatWatcherStatus(await res.json());
|
|
2208
|
+
* },
|
|
2209
|
+
* placeholder: '> Initializing watcher status...',
|
|
2210
|
+
* });
|
|
2211
|
+
*
|
|
2212
|
+
* const writer = createComponentWriter({
|
|
2213
|
+
* // ...
|
|
2214
|
+
* generateToolsContent: getContent,
|
|
2215
|
+
* });
|
|
2216
|
+
* ```
|
|
2217
|
+
*/
|
|
2218
|
+
/**
|
|
2219
|
+
* Creates a synchronous content accessor backed by an async data source.
|
|
2220
|
+
*
|
|
2221
|
+
* @param options - Cache configuration.
|
|
2222
|
+
* @returns A sync `() => string` suitable for `generateToolsContent`.
|
|
2223
|
+
*/
|
|
2224
|
+
function createAsyncContentCache(options) {
|
|
2225
|
+
const { fetch: fetchContent, placeholder = '> Initializing...', onError = (err) => {
|
|
1397
2226
|
console.warn('[jeeves] async content cache refresh failed:', err);
|
|
1398
2227
|
}, } = options;
|
|
1399
2228
|
let cached = placeholder;
|
|
@@ -1492,202 +2321,41 @@ function createComponentWriter(component) {
|
|
|
1492
2321
|
}
|
|
1493
2322
|
|
|
1494
2323
|
/**
|
|
1495
|
-
*
|
|
1496
|
-
*
|
|
1497
|
-
* @remarks
|
|
1498
|
-
* Core config lives at `{configRoot}/jeeves-core/config.json`.
|
|
1499
|
-
* Config resolution order:
|
|
1500
|
-
* 1. Component's own config file
|
|
1501
|
-
* 2. Core config file
|
|
1502
|
-
* 3. Hardcoded library defaults
|
|
1503
|
-
*/
|
|
1504
|
-
/** Zod schema for a service entry in core config. */
|
|
1505
|
-
const serviceEntrySchema = z.object({
|
|
1506
|
-
/** Service URL (must be a valid URL). */
|
|
1507
|
-
url: z.string().url().describe('Service URL'),
|
|
1508
|
-
});
|
|
1509
|
-
/** Zod schema for the core config file. */
|
|
1510
|
-
const coreConfigSchema = z.object({
|
|
1511
|
-
/** JSON Schema pointer for IDE autocomplete. */
|
|
1512
|
-
$schema: z.string().optional().describe('JSON Schema pointer'),
|
|
1513
|
-
/** Owner identity keys (canonical identityLinks references). */
|
|
1514
|
-
owners: z.array(z.string()).default([]).describe('Owner identity keys'),
|
|
1515
|
-
/** Service URL overrides keyed by service name. */
|
|
1516
|
-
services: z
|
|
1517
|
-
.record(z.string(), serviceEntrySchema)
|
|
1518
|
-
.default({})
|
|
1519
|
-
.describe('Service URL overrides'),
|
|
1520
|
-
/** Registry cache configuration. */
|
|
1521
|
-
registryCache: z
|
|
1522
|
-
.object({
|
|
1523
|
-
/** Cache TTL in seconds for npm registry queries. */
|
|
1524
|
-
ttlSeconds: z
|
|
1525
|
-
.number()
|
|
1526
|
-
.int()
|
|
1527
|
-
.positive()
|
|
1528
|
-
.default(3600)
|
|
1529
|
-
.describe('Cache TTL in seconds'),
|
|
1530
|
-
})
|
|
1531
|
-
.default({})
|
|
1532
|
-
.describe('Registry cache settings'),
|
|
1533
|
-
});
|
|
1534
|
-
/**
|
|
1535
|
-
* Generate a JSON Schema from the Zod schema for `$schema` pointer support.
|
|
1536
|
-
*
|
|
1537
|
-
* @returns A JSON Schema object.
|
|
1538
|
-
*/
|
|
1539
|
-
function generateJsonSchema() {
|
|
1540
|
-
return {
|
|
1541
|
-
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
1542
|
-
title: 'Jeeves Core Configuration',
|
|
1543
|
-
type: 'object',
|
|
1544
|
-
properties: {
|
|
1545
|
-
$schema: { type: 'string' },
|
|
1546
|
-
owners: {
|
|
1547
|
-
type: 'array',
|
|
1548
|
-
items: { type: 'string' },
|
|
1549
|
-
default: [],
|
|
1550
|
-
},
|
|
1551
|
-
services: {
|
|
1552
|
-
type: 'object',
|
|
1553
|
-
additionalProperties: {
|
|
1554
|
-
type: 'object',
|
|
1555
|
-
properties: {
|
|
1556
|
-
url: { type: 'string', format: 'uri' },
|
|
1557
|
-
},
|
|
1558
|
-
required: ['url'],
|
|
1559
|
-
},
|
|
1560
|
-
default: {},
|
|
1561
|
-
},
|
|
1562
|
-
registryCache: {
|
|
1563
|
-
type: 'object',
|
|
1564
|
-
properties: {
|
|
1565
|
-
ttlSeconds: {
|
|
1566
|
-
type: 'integer',
|
|
1567
|
-
minimum: 1,
|
|
1568
|
-
default: 3600,
|
|
1569
|
-
},
|
|
1570
|
-
},
|
|
1571
|
-
default: {},
|
|
1572
|
-
},
|
|
1573
|
-
},
|
|
1574
|
-
};
|
|
1575
|
-
}
|
|
1576
|
-
/**
|
|
1577
|
-
* Load and parse a config file, returning undefined if missing or invalid.
|
|
1578
|
-
*
|
|
1579
|
-
* @param configDir - Directory containing config.json.
|
|
1580
|
-
* @returns Parsed config or undefined.
|
|
1581
|
-
*/
|
|
1582
|
-
function loadConfig(configDir) {
|
|
1583
|
-
const configPath = join(configDir, CONFIG_FILE);
|
|
1584
|
-
if (!existsSync(configPath))
|
|
1585
|
-
return undefined;
|
|
1586
|
-
try {
|
|
1587
|
-
const raw = readFileSync(configPath, 'utf-8');
|
|
1588
|
-
const parsed = JSON.parse(raw);
|
|
1589
|
-
return coreConfigSchema.parse(parsed);
|
|
1590
|
-
}
|
|
1591
|
-
catch {
|
|
1592
|
-
return undefined;
|
|
1593
|
-
}
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
/**
|
|
1597
|
-
* Service URL resolution.
|
|
1598
|
-
*
|
|
1599
|
-
* @remarks
|
|
1600
|
-
* Resolves the URL for a named Jeeves service using the following
|
|
1601
|
-
* resolution order:
|
|
1602
|
-
* 1. Consumer's own component config
|
|
1603
|
-
* 2. Core config (`{configRoot}/jeeves-core/config.json`)
|
|
1604
|
-
* 3. Default port constants
|
|
1605
|
-
*/
|
|
1606
|
-
/**
|
|
1607
|
-
* Resolve the URL for a named Jeeves service.
|
|
1608
|
-
*
|
|
1609
|
-
* @param serviceName - The service name (e.g., 'watcher', 'runner').
|
|
1610
|
-
* @param consumerName - Optional consumer component name for config override.
|
|
1611
|
-
* @returns The resolved service URL.
|
|
1612
|
-
* @throws Error if `init()` has not been called or the service is unknown.
|
|
1613
|
-
*/
|
|
1614
|
-
function getServiceUrl(serviceName, consumerName) {
|
|
1615
|
-
// 1. Check consumer's own config
|
|
1616
|
-
if (consumerName) {
|
|
1617
|
-
const consumerDir = getComponentConfigDir(consumerName);
|
|
1618
|
-
const consumerConfig = loadConfig(consumerDir);
|
|
1619
|
-
const consumerUrl = consumerConfig?.services[serviceName]?.url;
|
|
1620
|
-
if (consumerUrl)
|
|
1621
|
-
return consumerUrl;
|
|
1622
|
-
}
|
|
1623
|
-
// 2. Check core config
|
|
1624
|
-
const coreDir = getCoreConfigDir();
|
|
1625
|
-
const coreConfig = loadConfig(coreDir);
|
|
1626
|
-
const coreUrl = coreConfig?.services[serviceName]?.url;
|
|
1627
|
-
if (coreUrl)
|
|
1628
|
-
return coreUrl;
|
|
1629
|
-
// 3. Fall back to port constants
|
|
1630
|
-
const port = DEFAULT_PORTS[serviceName];
|
|
1631
|
-
if (port !== undefined) {
|
|
1632
|
-
return `http://127.0.0.1:${String(port)}`;
|
|
1633
|
-
}
|
|
1634
|
-
throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
/**
|
|
1638
|
-
* Registry version cache for npm package update awareness.
|
|
2324
|
+
* Resolve the bind address for a Jeeves service.
|
|
1639
2325
|
*
|
|
1640
2326
|
* @remarks
|
|
1641
|
-
*
|
|
1642
|
-
*
|
|
2327
|
+
* Resolution order (four-tier):
|
|
2328
|
+
* 1. Component config `bindAddress` field (if componentName provided)
|
|
2329
|
+
* 2. Core config `bindAddress` field
|
|
2330
|
+
* 3. `JEEVES_BIND_ADDRESS` environment variable
|
|
2331
|
+
* 4. Default: `0.0.0.0`
|
|
1643
2332
|
*/
|
|
1644
2333
|
/**
|
|
1645
|
-
*
|
|
2334
|
+
* Resolve the bind address for a Jeeves service.
|
|
1646
2335
|
*
|
|
1647
|
-
* @param
|
|
1648
|
-
* @
|
|
1649
|
-
* @param ttlSeconds - Cache TTL in seconds (default 3600).
|
|
1650
|
-
* @returns The latest version string, or undefined if the check fails.
|
|
2336
|
+
* @param componentName - Optional component name for component-specific override.
|
|
2337
|
+
* @returns The resolved bind address.
|
|
1651
2338
|
*/
|
|
1652
|
-
function
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
const entry = JSON.parse(raw);
|
|
1659
|
-
const age = Date.now() - new Date(entry.checkedAt).getTime();
|
|
1660
|
-
if (age < ttlSeconds * 1000) {
|
|
1661
|
-
return entry.version;
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
catch {
|
|
1665
|
-
// Cache corrupt — proceed with fresh check
|
|
2339
|
+
function getBindAddress(componentName) {
|
|
2340
|
+
// Tier 1: Component config (if provided)
|
|
2341
|
+
if (componentName) {
|
|
2342
|
+
const componentConfig = loadConfig(getComponentConfigDir(componentName));
|
|
2343
|
+
if (componentConfig?.bindAddress) {
|
|
2344
|
+
return componentConfig.bindAddress;
|
|
1666
2345
|
}
|
|
1667
2346
|
}
|
|
1668
|
-
//
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
timeout: 15_000,
|
|
1673
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1674
|
-
}).trim();
|
|
1675
|
-
if (!result)
|
|
1676
|
-
return undefined;
|
|
1677
|
-
// Write cache
|
|
1678
|
-
if (!existsSync(cacheDir)) {
|
|
1679
|
-
mkdirSync(cacheDir, { recursive: true });
|
|
1680
|
-
}
|
|
1681
|
-
const entry = {
|
|
1682
|
-
version: result,
|
|
1683
|
-
checkedAt: new Date().toISOString(),
|
|
1684
|
-
};
|
|
1685
|
-
writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
|
|
1686
|
-
return result;
|
|
2347
|
+
// Tier 2: Core config
|
|
2348
|
+
const coreConfig = loadConfig(getCoreConfigDir());
|
|
2349
|
+
if (coreConfig?.bindAddress) {
|
|
2350
|
+
return coreConfig.bindAddress;
|
|
1687
2351
|
}
|
|
1688
|
-
|
|
1689
|
-
|
|
2352
|
+
// Tier 3: Environment variable
|
|
2353
|
+
const envValue = process.env['JEEVES_BIND_ADDRESS'];
|
|
2354
|
+
if (envValue) {
|
|
2355
|
+
return envValue;
|
|
1690
2356
|
}
|
|
2357
|
+
// Tier 4: Default
|
|
2358
|
+
return DEFAULT_BIND_ADDRESS;
|
|
1691
2359
|
}
|
|
1692
2360
|
|
|
1693
2361
|
/**
|
|
@@ -1822,6 +2490,7 @@ function ensureCoreConfig(coreConfigDir) {
|
|
|
1822
2490
|
* @remarks
|
|
1823
2491
|
* Uses the same `updateManagedSection()` code path as writer cycles.
|
|
1824
2492
|
* Creates core config with defaults if missing. Copies templates.
|
|
2493
|
+
* Writes initial HEARTBEAT with "Not installed" alerts for all platform components.
|
|
1825
2494
|
* Jaccard cleanup detection runs automatically via `updateManagedSection`.
|
|
1826
2495
|
*
|
|
1827
2496
|
* @param options - Seeding configuration.
|
|
@@ -1830,67 +2499,18 @@ async function seedContent(options) {
|
|
|
1830
2499
|
const coreConfigDir = getCoreConfigDir();
|
|
1831
2500
|
// Ensure core config exists
|
|
1832
2501
|
ensureCoreConfig(coreConfigDir);
|
|
1833
|
-
// Seed
|
|
2502
|
+
// Seed SOUL.md, AGENTS.md, TOOLS.md Platform section
|
|
1834
2503
|
await refreshPlatformContent({
|
|
1835
2504
|
coreVersion: options.coreVersion,
|
|
1836
2505
|
});
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
*/
|
|
1846
|
-
/**
|
|
1847
|
-
* Fetch a URL with an automatic abort timeout.
|
|
1848
|
-
*
|
|
1849
|
-
* @param url - URL to fetch.
|
|
1850
|
-
* @param timeoutMs - Timeout in milliseconds before aborting.
|
|
1851
|
-
* @param init - Optional `fetch` init options.
|
|
1852
|
-
* @returns The fetch Response object.
|
|
1853
|
-
*/
|
|
1854
|
-
async function fetchWithTimeout(url, timeoutMs, init) {
|
|
1855
|
-
const controller = new AbortController();
|
|
1856
|
-
const timeout = setTimeout(() => {
|
|
1857
|
-
controller.abort();
|
|
1858
|
-
}, timeoutMs);
|
|
1859
|
-
try {
|
|
1860
|
-
return await fetch(url, { ...init, signal: controller.signal });
|
|
1861
|
-
}
|
|
1862
|
-
finally {
|
|
1863
|
-
clearTimeout(timeout);
|
|
1864
|
-
}
|
|
1865
|
-
}
|
|
1866
|
-
/**
|
|
1867
|
-
* Fetch JSON from a URL, throwing on non-OK responses.
|
|
1868
|
-
*
|
|
1869
|
-
* @param url - URL to fetch.
|
|
1870
|
-
* @param init - Optional `fetch` init options.
|
|
1871
|
-
* @returns Parsed JSON response body.
|
|
1872
|
-
* @throws Error with `HTTP {status}: {body}` message on non-OK responses.
|
|
1873
|
-
*/
|
|
1874
|
-
async function fetchJson(url, init) {
|
|
1875
|
-
const res = await fetch(url, init);
|
|
1876
|
-
if (!res.ok) {
|
|
1877
|
-
throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
|
|
1878
|
-
}
|
|
1879
|
-
return res.json();
|
|
1880
|
-
}
|
|
1881
|
-
/**
|
|
1882
|
-
* POST JSON to a URL and return parsed response.
|
|
1883
|
-
*
|
|
1884
|
-
* @param url - URL to POST to.
|
|
1885
|
-
* @param body - Request body (will be JSON-stringified).
|
|
1886
|
-
* @returns Parsed JSON response body.
|
|
1887
|
-
*/
|
|
1888
|
-
async function postJson(url, body) {
|
|
1889
|
-
return fetchJson(url, {
|
|
1890
|
-
method: 'POST',
|
|
1891
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1892
|
-
body: JSON.stringify(body),
|
|
1893
|
-
});
|
|
2506
|
+
// Seed HEARTBEAT.md with "Not installed" alerts for all platform components
|
|
2507
|
+
const heartbeatPath = join(getWorkspacePath(), WORKSPACE_FILES.heartbeat);
|
|
2508
|
+
const entries = PLATFORM_COMPONENTS.map((name) => ({
|
|
2509
|
+
name: toServiceName(name),
|
|
2510
|
+
declined: false,
|
|
2511
|
+
content: `- ${NOT_INSTALLED_ALERTS[name]}`,
|
|
2512
|
+
}));
|
|
2513
|
+
await writeHeartbeatSection(heartbeatPath, entries);
|
|
1894
2514
|
}
|
|
1895
2515
|
|
|
1896
2516
|
/**
|
|
@@ -2151,4 +2771,4 @@ function connectionFail(error, baseUrl, pluginId) {
|
|
|
2151
2771
|
return fail(error);
|
|
2152
2772
|
}
|
|
2153
2773
|
|
|
2154
|
-
export { AGENTS_MARKERS, CLEANUP_FLAG, COMPONENT_CONFIG_PREFIX, COMPONENT_VERSIONS_FILE, CONFIG_FILE, CORE_CONFIG_DIR, CORE_VERSION, ComponentWriter, DEFAULT_CORE_VERSION, DEFAULT_PORTS, META_PORT, REGISTRY_CACHE_FILE, RUNNER_PORT, SECTION_IDS, SECTION_ORDER, SERVER_PORT, SOUL_MARKERS, STALENESS_THRESHOLD_MS, STALE_LOCK_MS, TEMPLATES_DIR, TOOLS_MARKERS, VERSION_STAMP_PATTERN, WATCHER_PORT, WORKSPACE_FILES, atomicWrite, checkRegistryVersion, connectionFail, coreConfigSchema, createAsyncContentCache, createComponentWriter, createConfigQueryHandler, fail, fetchJson, fetchWithTimeout, formatBeginMarker, formatEndMarker, generateJsonSchema, getComponentConfigDir, getConfigRoot, getCoreConfigDir, getCoreConfigFile, getServiceUrl, getWorkspacePath, init, jaccard, needsCleanup, ok, parseManaged, patchConfig, postJson, readComponentVersions, refreshPlatformContent, removeManagedSection, resetInit, resolveConfigPath, resolveOpenClawHome, resolveOptionalPluginSetting, resolvePluginSetting, resolveWorkspacePath, seedContent, shingles, shouldWrite, updateManagedSection, withFileLock, writeComponentVersion };
|
|
2774
|
+
export { AGENTS_MARKERS, CLEANUP_FLAG, COMPONENT_CONFIG_PREFIX, COMPONENT_VERSIONS_FILE, CONFIG_FILE, CORE_CONFIG_DIR, CORE_VERSION, ComponentWriter, DEFAULT_BIND_ADDRESS, DEFAULT_CORE_VERSION, DEFAULT_PORTS, HEARTBEAT_HEADING, META_PORT, PLATFORM_COMPONENTS, REGISTRY_CACHE_FILE, RUNNER_PORT, SECTION_IDS, SECTION_ORDER, SERVER_PORT, SOUL_MARKERS, STALENESS_THRESHOLD_MS, STALE_LOCK_MS, TEMPLATES_DIR, TOOLS_MARKERS, VERSION_STAMP_PATTERN, WATCHER_PORT, WORKSPACE_FILES, atomicWrite, buildHeartbeatSection, checkRegistryVersion, connectionFail, coreConfigSchema, createAsyncContentCache, createComponentWriter, createConfigQueryHandler, fail, fetchJson, fetchWithTimeout, formatBeginMarker, formatEndMarker, generateJsonSchema, getBindAddress, getComponentConfigDir, getConfigRoot, getCoreConfigDir, getCoreConfigFile, getServiceState, getServiceUrl, getWorkspacePath, init, jaccard, needsCleanup, ok, orchestrateHeartbeat, parseHeartbeat, parseManaged, patchConfig, postJson, readComponentVersions, refreshPlatformContent, removeComponentVersion, removeManagedSection, resetInit, resolveConfigPath, resolveOpenClawHome, resolveOptionalPluginSetting, resolvePluginSetting, resolveWorkspacePath, seedContent, shingles, shouldWrite, updateManagedSection, withFileLock, writeComponentVersion, writeHeartbeatSection };
|