@karmaniverous/jeeves 0.3.0 → 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 +15 -10
- package/content/soul-section.md +1 -0
- package/dist/cli/jeeves/index.js +532 -241
- package/dist/index.d.ts +194 -3
- package/dist/index.js +981 -306
- 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.
|
|
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.
|
|
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,7 +259,15 @@ 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
|
};
|
|
265
|
+
/** All known marker sets — single source of truth for cross-contamination detection. */
|
|
266
|
+
const ALL_MARKERS = [
|
|
267
|
+
TOOLS_MARKERS,
|
|
268
|
+
SOUL_MARKERS,
|
|
269
|
+
AGENTS_MARKERS,
|
|
270
|
+
];
|
|
238
271
|
/**
|
|
239
272
|
* Regex pattern to extract version stamp from a BEGIN marker comment.
|
|
240
273
|
*
|
|
@@ -275,7 +308,7 @@ const DEFAULT_PORTS = {
|
|
|
275
308
|
};
|
|
276
309
|
|
|
277
310
|
/**
|
|
278
|
-
* Managed section IDs
|
|
311
|
+
* Managed section IDs, stable ordering, and platform component registry.
|
|
279
312
|
*
|
|
280
313
|
* @remarks
|
|
281
314
|
* Section ordering is fixed to prevent diff churn regardless of which
|
|
@@ -305,6 +338,22 @@ const SECTION_ORDER = [
|
|
|
305
338
|
SECTION_IDS.Runner,
|
|
306
339
|
SECTION_IDS.Meta,
|
|
307
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
|
+
];
|
|
308
357
|
|
|
309
358
|
/**
|
|
310
359
|
* Workspace and config root initialization.
|
|
@@ -388,6 +437,124 @@ function resetInit() {
|
|
|
388
437
|
state = undefined;
|
|
389
438
|
}
|
|
390
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
|
+
|
|
391
558
|
/**
|
|
392
559
|
* Similarity-based cleanup detection for orphaned managed content.
|
|
393
560
|
*
|
|
@@ -583,6 +750,48 @@ function parseManaged(fileContent, markers = TOOLS_MARKERS) {
|
|
|
583
750
|
};
|
|
584
751
|
}
|
|
585
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Strip foreign managed blocks from content.
|
|
755
|
+
*
|
|
756
|
+
* @remarks
|
|
757
|
+
* Prevents cross-contamination by removing managed blocks that belong
|
|
758
|
+
* to other marker sets. For example, when writing TOOLS.md with TOOLS
|
|
759
|
+
* markers, any SOUL or AGENTS managed blocks found in the user content
|
|
760
|
+
* zone are stripped — they don't belong there.
|
|
761
|
+
*
|
|
762
|
+
* @packageDocumentation
|
|
763
|
+
*/
|
|
764
|
+
/**
|
|
765
|
+
* Build a regex that matches an entire managed block (BEGIN marker through END marker).
|
|
766
|
+
*
|
|
767
|
+
* @param markers - The marker set to match.
|
|
768
|
+
* @returns A regex that matches the full block including markers.
|
|
769
|
+
*/
|
|
770
|
+
function buildBlockPattern(markers) {
|
|
771
|
+
const escapedBegin = markers.begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
772
|
+
const escapedEnd = markers.end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
773
|
+
return new RegExp(`\\s*<!--\\s*${escapedBegin}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->[\\s\\S]*?<!--\\s*${escapedEnd}\\s*-->\\s*`, 'g');
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Strip managed blocks belonging to foreign marker sets from content.
|
|
777
|
+
*
|
|
778
|
+
* @param content - The content to clean (typically user content zone).
|
|
779
|
+
* @param currentMarkers - The marker set that owns this file (will NOT be stripped).
|
|
780
|
+
* @returns Content with foreign managed blocks removed.
|
|
781
|
+
*/
|
|
782
|
+
function stripForeignMarkers(content, currentMarkers) {
|
|
783
|
+
let result = content;
|
|
784
|
+
for (const markers of ALL_MARKERS) {
|
|
785
|
+
// Skip the current file's own markers
|
|
786
|
+
if (markers.begin === currentMarkers.begin)
|
|
787
|
+
continue;
|
|
788
|
+
const pattern = buildBlockPattern(markers);
|
|
789
|
+
result = result.replace(pattern, '\n');
|
|
790
|
+
}
|
|
791
|
+
// Clean up multiple blank lines left by removals
|
|
792
|
+
return result.replace(/\n{3,}/g, '\n\n').trim();
|
|
793
|
+
}
|
|
794
|
+
|
|
586
795
|
/**
|
|
587
796
|
* Version-stamp parsing and convergence logic.
|
|
588
797
|
*
|
|
@@ -699,32 +908,51 @@ async function updateManagedSection(filePath, content, options = {}) {
|
|
|
699
908
|
? `# ${markers.title}\n\n${sectionText}`
|
|
700
909
|
: sectionText;
|
|
701
910
|
}
|
|
702
|
-
//
|
|
703
|
-
|
|
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();
|
|
919
|
+
// Strip foreign managed blocks from user content (cross-contamination fix)
|
|
920
|
+
const userContent = stripForeignMarkers(rawUserContent, markers);
|
|
704
921
|
const cleanupNeeded = needsCleanup(newManagedBody, userContent);
|
|
705
922
|
// Build the full managed block
|
|
706
923
|
const beginLine = formatBeginMarker(markers.begin, coreVersion);
|
|
707
924
|
const endLine = formatEndMarker(markers.end);
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
parts.push(parsed.beforeContent);
|
|
711
|
-
parts.push('');
|
|
712
|
-
}
|
|
713
|
-
parts.push(beginLine);
|
|
925
|
+
const managedParts = [];
|
|
926
|
+
managedParts.push(beginLine);
|
|
714
927
|
if (cleanupNeeded) {
|
|
715
|
-
|
|
716
|
-
|
|
928
|
+
managedParts.push('');
|
|
929
|
+
managedParts.push(CLEANUP_FLAG);
|
|
717
930
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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);
|
|
725
945
|
}
|
|
726
|
-
|
|
727
|
-
|
|
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');
|
|
728
956
|
atomicWrite(filePath, newFileContent);
|
|
729
957
|
});
|
|
730
958
|
}
|
|
@@ -802,6 +1030,8 @@ At minimum, always brief sub-agents on:
|
|
|
802
1030
|
|
|
803
1031
|
**Anything important enough to have a permanent cron/heartbeat entry is important enough to be codified into the data flow.**
|
|
804
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
|
+
|
|
805
1035
|
## Messaging Dispatch
|
|
806
1036
|
|
|
807
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).
|
|
@@ -810,7 +1040,7 @@ At minimum, always brief sub-agents on:
|
|
|
810
1040
|
|
|
811
1041
|
## Heartbeat Discipline
|
|
812
1042
|
|
|
813
|
-
**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.
|
|
814
1044
|
|
|
815
1045
|
Heartbeat items are for **transient, session-requiring work-in-progress ONLY**. Each item must include its own termination condition.
|
|
816
1046
|
|
|
@@ -871,14 +1101,11 @@ When discovering a new data source, integrate it into the existing data flow pip
|
|
|
871
1101
|
|
|
872
1102
|
## Bootstrap Protocol
|
|
873
1103
|
|
|
874
|
-
|
|
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.
|
|
875
1105
|
|
|
876
|
-
|
|
877
|
-
2. **Inform** the user what's missing and what it provides
|
|
878
|
-
3. **Guide** them through installation using the component's CLI install command
|
|
879
|
-
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.
|
|
880
1107
|
|
|
881
|
-
|
|
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.
|
|
882
1109
|
|
|
883
1110
|
## Em-Dash Discipline
|
|
884
1111
|
|
|
@@ -914,11 +1141,17 @@ No stranded local branches. Push immediately after commit. A commit that isn't p
|
|
|
914
1141
|
|
|
915
1142
|
### Check PR State Before Pushing
|
|
916
1143
|
|
|
917
|
-
**Before EVERY \`git push\`**,
|
|
1144
|
+
**Before EVERY \`git push\`**, run \`gh pr list --head <branch> --repo <repo> --json number,state\` to check whether a PR exists on that branch and whether it's merged.
|
|
1145
|
+
|
|
1146
|
+
- **No PR exists:** Safe to push.
|
|
1147
|
+
- **PR is \`OPEN\`:** Safe to push.
|
|
1148
|
+
- **PR is \`MERGED\` or \`CLOSED\`:** **STOP** and report to the user. Do not push to a merged PR branch.
|
|
1149
|
+
|
|
1150
|
+
This is not optional. It applies to every push, every branch, every time. No judgment call about whether the branch "is a PR branch" — the check is mechanical.
|
|
918
1151
|
|
|
919
|
-
|
|
1152
|
+
### New PR Over Merged Branch
|
|
920
1153
|
|
|
921
|
-
|
|
1154
|
+
When a PR has been merged and additional work is needed on the same branch, create a new PR on the **same branch** targeting the same base. Do not create new branches, cherry-pick, or start over. The commits are already there — \`gh pr create --head <existing-branch>\` is the entire operation.
|
|
922
1155
|
|
|
923
1156
|
## Managed Content Self-Maintenance
|
|
924
1157
|
|
|
@@ -954,6 +1187,7 @@ var soulSectionContent = `## Core Truths
|
|
|
954
1187
|
I am a **senior software engineer** first. The persona is style; the engineering discipline is substance.
|
|
955
1188
|
|
|
956
1189
|
What this means in practice:
|
|
1190
|
+
- **Do not execute untested code.** Every mutation script defaults to dry-run mode. The dry-run output is the test — it shows what would happen. Live execution requires an explicit flag. If dry-run is hard to implement, that's a design flaw.
|
|
957
1191
|
- **No cowboy coding.** I don't iterate in production. I don't ship untested changes. I don't treat live systems as scratch pads.
|
|
958
1192
|
- **I follow proper workflows.** Branch, test, review, merge. CI/CD exists for a reason. If there's a pipeline, I use it.
|
|
959
1193
|
- **I resist n00b temptations.** "Let me just quickly…" in prod is how outages happen. I know better.
|
|
@@ -1213,250 +1447,156 @@ async function refreshPlatformContent(options) {
|
|
|
1213
1447
|
}
|
|
1214
1448
|
|
|
1215
1449
|
/**
|
|
1216
|
-
*
|
|
1450
|
+
* Platform-aware service state detection.
|
|
1217
1451
|
*
|
|
1218
1452
|
* @remarks
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
* 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).
|
|
1222
1455
|
*/
|
|
1223
1456
|
/**
|
|
1224
|
-
*
|
|
1457
|
+
* Detect the state of a system service by name.
|
|
1225
1458
|
*
|
|
1226
|
-
* @
|
|
1227
|
-
*
|
|
1228
|
-
* at the component's prime-interval, calling `generateToolsContent()`
|
|
1229
|
-
* and `refreshPlatformContent()` on each cycle.
|
|
1459
|
+
* @param serviceName - The service name (e.g., 'jeeves-runner').
|
|
1460
|
+
* @returns The detected service state.
|
|
1230
1461
|
*/
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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);
|
|
1239
1470
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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';
|
|
1243
1488
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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';
|
|
1247
1495
|
}
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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
|
+
});
|
|
1260
1509
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
if (this.timer) {
|
|
1264
|
-
clearInterval(this.timer);
|
|
1265
|
-
this.timer = undefined;
|
|
1266
|
-
}
|
|
1510
|
+
catch {
|
|
1511
|
+
return 'not_installed';
|
|
1267
1512
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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';
|
|
1296
1548
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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';
|
|
1300
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';
|
|
1301
1559
|
}
|
|
1302
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
|
+
}
|
|
1303
1568
|
|
|
1304
1569
|
/**
|
|
1305
|
-
*
|
|
1570
|
+
* Core configuration schema and resolution.
|
|
1306
1571
|
*
|
|
1307
1572
|
* @remarks
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
* First call returns `placeholder`. Subsequent calls return the last
|
|
1315
|
-
* successfully fetched content. If a refresh fails, the previous good
|
|
1316
|
-
* value is retained.
|
|
1317
|
-
*
|
|
1318
|
-
* @example
|
|
1319
|
-
* ```typescript
|
|
1320
|
-
* const getContent = createAsyncContentCache({
|
|
1321
|
-
* fetch: async () => {
|
|
1322
|
-
* const res = await fetch('http://127.0.0.1:1936/status');
|
|
1323
|
-
* return formatWatcherStatus(await res.json());
|
|
1324
|
-
* },
|
|
1325
|
-
* placeholder: '> Initializing watcher status...',
|
|
1326
|
-
* });
|
|
1327
|
-
*
|
|
1328
|
-
* const writer = createComponentWriter({
|
|
1329
|
-
* // ...
|
|
1330
|
-
* generateToolsContent: getContent,
|
|
1331
|
-
* });
|
|
1332
|
-
* ```
|
|
1333
|
-
*/
|
|
1334
|
-
/**
|
|
1335
|
-
* Creates a synchronous content accessor backed by an async data source.
|
|
1336
|
-
*
|
|
1337
|
-
* @param options - Cache configuration.
|
|
1338
|
-
* @returns A sync `() => string` suitable for `generateToolsContent`.
|
|
1339
|
-
*/
|
|
1340
|
-
function createAsyncContentCache(options) {
|
|
1341
|
-
const { fetch: fetchContent, placeholder = '> Initializing...', onError = (err) => {
|
|
1342
|
-
console.warn('[jeeves] async content cache refresh failed:', err);
|
|
1343
|
-
}, } = options;
|
|
1344
|
-
let cached = placeholder;
|
|
1345
|
-
let refreshing = false;
|
|
1346
|
-
return () => {
|
|
1347
|
-
if (!refreshing) {
|
|
1348
|
-
refreshing = true;
|
|
1349
|
-
fetchContent()
|
|
1350
|
-
.then((content) => {
|
|
1351
|
-
cached = content;
|
|
1352
|
-
})
|
|
1353
|
-
.catch(onError)
|
|
1354
|
-
.finally(() => {
|
|
1355
|
-
refreshing = false;
|
|
1356
|
-
});
|
|
1357
|
-
}
|
|
1358
|
-
return cached;
|
|
1359
|
-
};
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
/**
|
|
1363
|
-
* Factory function for creating a ComponentWriter.
|
|
1364
|
-
*
|
|
1365
|
-
* @remarks
|
|
1366
|
-
* Validates the component descriptor at runtime:
|
|
1367
|
-
* - `refreshIntervalSeconds` must be a prime number
|
|
1368
|
-
* - `serviceCommands` and `pluginCommands` must be provided
|
|
1369
|
-
* - `name`, `version`, `sectionId` must be non-empty strings
|
|
1370
|
-
* - `generateToolsContent` must be a function
|
|
1371
|
-
*/
|
|
1372
|
-
/**
|
|
1373
|
-
* Check whether a number is prime.
|
|
1374
|
-
*
|
|
1375
|
-
* @param n - Number to check.
|
|
1376
|
-
* @returns `true` if n is prime.
|
|
1377
|
-
*/
|
|
1378
|
-
function isPrime(n) {
|
|
1379
|
-
if (n < 2)
|
|
1380
|
-
return false;
|
|
1381
|
-
if (n === 2)
|
|
1382
|
-
return true;
|
|
1383
|
-
if (n % 2 === 0)
|
|
1384
|
-
return false;
|
|
1385
|
-
for (let i = 3; i * i <= n; i += 2) {
|
|
1386
|
-
if (n % i === 0)
|
|
1387
|
-
return false;
|
|
1388
|
-
}
|
|
1389
|
-
return true;
|
|
1390
|
-
}
|
|
1391
|
-
/**
|
|
1392
|
-
* Validate a component descriptor at runtime.
|
|
1393
|
-
*
|
|
1394
|
-
* @param input - The descriptor to validate (typed as unknown for runtime safety).
|
|
1395
|
-
* @throws Error if the descriptor is invalid.
|
|
1396
|
-
*/
|
|
1397
|
-
function validateDescriptor(input) {
|
|
1398
|
-
const component = input;
|
|
1399
|
-
if (!component['name'] || typeof component['name'] !== 'string') {
|
|
1400
|
-
throw new Error('JeevesComponent.name must be a non-empty string');
|
|
1401
|
-
}
|
|
1402
|
-
if (!component['version'] || typeof component['version'] !== 'string') {
|
|
1403
|
-
throw new Error('JeevesComponent.version must be a non-empty string');
|
|
1404
|
-
}
|
|
1405
|
-
if (!component['sectionId'] || typeof component['sectionId'] !== 'string') {
|
|
1406
|
-
throw new Error('JeevesComponent.sectionId must be a non-empty string');
|
|
1407
|
-
}
|
|
1408
|
-
if (typeof component['refreshIntervalSeconds'] !== 'number' ||
|
|
1409
|
-
!isPrime(component['refreshIntervalSeconds'])) {
|
|
1410
|
-
throw new Error(`JeevesComponent.refreshIntervalSeconds must be a prime number, got ${String(component['refreshIntervalSeconds'])}`);
|
|
1411
|
-
}
|
|
1412
|
-
if (typeof component['generateToolsContent'] !== 'function') {
|
|
1413
|
-
throw new Error('JeevesComponent.generateToolsContent must be a function');
|
|
1414
|
-
}
|
|
1415
|
-
const svc = component['serviceCommands'];
|
|
1416
|
-
if (!svc ||
|
|
1417
|
-
typeof svc['stop'] !== 'function' ||
|
|
1418
|
-
typeof svc['uninstall'] !== 'function' ||
|
|
1419
|
-
typeof svc['status'] !== 'function') {
|
|
1420
|
-
throw new Error('JeevesComponent.serviceCommands must provide stop, uninstall, and status functions');
|
|
1421
|
-
}
|
|
1422
|
-
const plg = component['pluginCommands'];
|
|
1423
|
-
if (!plg || typeof plg['uninstall'] !== 'function') {
|
|
1424
|
-
throw new Error('JeevesComponent.pluginCommands must provide an uninstall function');
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
/**
|
|
1428
|
-
* Create a ComponentWriter for a validated component descriptor.
|
|
1429
|
-
*
|
|
1430
|
-
* @param component - The component descriptor to validate and wrap.
|
|
1431
|
-
* @returns A new `ComponentWriter` instance.
|
|
1432
|
-
* @throws Error if the component descriptor is invalid.
|
|
1433
|
-
*/
|
|
1434
|
-
function createComponentWriter(component) {
|
|
1435
|
-
validateDescriptor(component);
|
|
1436
|
-
return new ComponentWriter(component);
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
/**
|
|
1440
|
-
* Core configuration schema and resolution.
|
|
1441
|
-
*
|
|
1442
|
-
* @remarks
|
|
1443
|
-
* Core config lives at `{configRoot}/jeeves-core/config.json`.
|
|
1444
|
-
* Config resolution order:
|
|
1445
|
-
* 1. Component's own config file
|
|
1446
|
-
* 2. Core config file
|
|
1447
|
-
* 3. Hardcoded library defaults
|
|
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
|
|
1448
1578
|
*/
|
|
1449
1579
|
/** Zod schema for a service entry in core config. */
|
|
1450
1580
|
const serviceEntrySchema = z.object({
|
|
1451
1581
|
/** Service URL (must be a valid URL). */
|
|
1452
1582
|
url: z.string().url().describe('Service URL'),
|
|
1453
1583
|
});
|
|
1584
|
+
/** Default bind address for all Jeeves services. */
|
|
1585
|
+
const DEFAULT_BIND_ADDRESS = '0.0.0.0';
|
|
1454
1586
|
/** Zod schema for the core config file. */
|
|
1455
1587
|
const coreConfigSchema = z.object({
|
|
1456
1588
|
/** JSON Schema pointer for IDE autocomplete. */
|
|
1457
1589
|
$schema: z.string().optional().describe('JSON Schema pointer'),
|
|
1458
1590
|
/** Owner identity keys (canonical identityLinks references). */
|
|
1459
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'),
|
|
1460
1600
|
/** Service URL overrides keyed by service name. */
|
|
1461
1601
|
services: z
|
|
1462
1602
|
.record(z.string(), serviceEntrySchema)
|
|
@@ -1493,6 +1633,11 @@ function generateJsonSchema() {
|
|
|
1493
1633
|
items: { type: 'string' },
|
|
1494
1634
|
default: [],
|
|
1495
1635
|
},
|
|
1636
|
+
bindAddress: {
|
|
1637
|
+
type: 'string',
|
|
1638
|
+
default: '0.0.0.0',
|
|
1639
|
+
description: 'Bind address for all Jeeves services',
|
|
1640
|
+
},
|
|
1496
1641
|
services: {
|
|
1497
1642
|
type: 'object',
|
|
1498
1643
|
additionalProperties: {
|
|
@@ -1635,6 +1780,584 @@ function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
|
|
|
1635
1780
|
}
|
|
1636
1781
|
}
|
|
1637
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) => {
|
|
2226
|
+
console.warn('[jeeves] async content cache refresh failed:', err);
|
|
2227
|
+
}, } = options;
|
|
2228
|
+
let cached = placeholder;
|
|
2229
|
+
let refreshing = false;
|
|
2230
|
+
return () => {
|
|
2231
|
+
if (!refreshing) {
|
|
2232
|
+
refreshing = true;
|
|
2233
|
+
fetchContent()
|
|
2234
|
+
.then((content) => {
|
|
2235
|
+
cached = content;
|
|
2236
|
+
})
|
|
2237
|
+
.catch(onError)
|
|
2238
|
+
.finally(() => {
|
|
2239
|
+
refreshing = false;
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
return cached;
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/**
|
|
2247
|
+
* Factory function for creating a ComponentWriter.
|
|
2248
|
+
*
|
|
2249
|
+
* @remarks
|
|
2250
|
+
* Validates the component descriptor at runtime:
|
|
2251
|
+
* - `refreshIntervalSeconds` must be a prime number
|
|
2252
|
+
* - `serviceCommands` and `pluginCommands` must be provided
|
|
2253
|
+
* - `name`, `version`, `sectionId` must be non-empty strings
|
|
2254
|
+
* - `generateToolsContent` must be a function
|
|
2255
|
+
*/
|
|
2256
|
+
/**
|
|
2257
|
+
* Check whether a number is prime.
|
|
2258
|
+
*
|
|
2259
|
+
* @param n - Number to check.
|
|
2260
|
+
* @returns `true` if n is prime.
|
|
2261
|
+
*/
|
|
2262
|
+
function isPrime(n) {
|
|
2263
|
+
if (n < 2)
|
|
2264
|
+
return false;
|
|
2265
|
+
if (n === 2)
|
|
2266
|
+
return true;
|
|
2267
|
+
if (n % 2 === 0)
|
|
2268
|
+
return false;
|
|
2269
|
+
for (let i = 3; i * i <= n; i += 2) {
|
|
2270
|
+
if (n % i === 0)
|
|
2271
|
+
return false;
|
|
2272
|
+
}
|
|
2273
|
+
return true;
|
|
2274
|
+
}
|
|
2275
|
+
/**
|
|
2276
|
+
* Validate a component descriptor at runtime.
|
|
2277
|
+
*
|
|
2278
|
+
* @param input - The descriptor to validate (typed as unknown for runtime safety).
|
|
2279
|
+
* @throws Error if the descriptor is invalid.
|
|
2280
|
+
*/
|
|
2281
|
+
function validateDescriptor(input) {
|
|
2282
|
+
const component = input;
|
|
2283
|
+
if (!component['name'] || typeof component['name'] !== 'string') {
|
|
2284
|
+
throw new Error('JeevesComponent.name must be a non-empty string');
|
|
2285
|
+
}
|
|
2286
|
+
if (!component['version'] || typeof component['version'] !== 'string') {
|
|
2287
|
+
throw new Error('JeevesComponent.version must be a non-empty string');
|
|
2288
|
+
}
|
|
2289
|
+
if (!component['sectionId'] || typeof component['sectionId'] !== 'string') {
|
|
2290
|
+
throw new Error('JeevesComponent.sectionId must be a non-empty string');
|
|
2291
|
+
}
|
|
2292
|
+
if (typeof component['refreshIntervalSeconds'] !== 'number' ||
|
|
2293
|
+
!isPrime(component['refreshIntervalSeconds'])) {
|
|
2294
|
+
throw new Error(`JeevesComponent.refreshIntervalSeconds must be a prime number, got ${String(component['refreshIntervalSeconds'])}`);
|
|
2295
|
+
}
|
|
2296
|
+
if (typeof component['generateToolsContent'] !== 'function') {
|
|
2297
|
+
throw new Error('JeevesComponent.generateToolsContent must be a function');
|
|
2298
|
+
}
|
|
2299
|
+
const svc = component['serviceCommands'];
|
|
2300
|
+
if (!svc ||
|
|
2301
|
+
typeof svc['stop'] !== 'function' ||
|
|
2302
|
+
typeof svc['uninstall'] !== 'function' ||
|
|
2303
|
+
typeof svc['status'] !== 'function') {
|
|
2304
|
+
throw new Error('JeevesComponent.serviceCommands must provide stop, uninstall, and status functions');
|
|
2305
|
+
}
|
|
2306
|
+
const plg = component['pluginCommands'];
|
|
2307
|
+
if (!plg || typeof plg['uninstall'] !== 'function') {
|
|
2308
|
+
throw new Error('JeevesComponent.pluginCommands must provide an uninstall function');
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Create a ComponentWriter for a validated component descriptor.
|
|
2313
|
+
*
|
|
2314
|
+
* @param component - The component descriptor to validate and wrap.
|
|
2315
|
+
* @returns A new `ComponentWriter` instance.
|
|
2316
|
+
* @throws Error if the component descriptor is invalid.
|
|
2317
|
+
*/
|
|
2318
|
+
function createComponentWriter(component) {
|
|
2319
|
+
validateDescriptor(component);
|
|
2320
|
+
return new ComponentWriter(component);
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
/**
|
|
2324
|
+
* Resolve the bind address for a Jeeves service.
|
|
2325
|
+
*
|
|
2326
|
+
* @remarks
|
|
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`
|
|
2332
|
+
*/
|
|
2333
|
+
/**
|
|
2334
|
+
* Resolve the bind address for a Jeeves service.
|
|
2335
|
+
*
|
|
2336
|
+
* @param componentName - Optional component name for component-specific override.
|
|
2337
|
+
* @returns The resolved bind address.
|
|
2338
|
+
*/
|
|
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;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
// Tier 2: Core config
|
|
2348
|
+
const coreConfig = loadConfig(getCoreConfigDir());
|
|
2349
|
+
if (coreConfig?.bindAddress) {
|
|
2350
|
+
return coreConfig.bindAddress;
|
|
2351
|
+
}
|
|
2352
|
+
// Tier 3: Environment variable
|
|
2353
|
+
const envValue = process.env['JEEVES_BIND_ADDRESS'];
|
|
2354
|
+
if (envValue) {
|
|
2355
|
+
return envValue;
|
|
2356
|
+
}
|
|
2357
|
+
// Tier 4: Default
|
|
2358
|
+
return DEFAULT_BIND_ADDRESS;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
1638
2361
|
/**
|
|
1639
2362
|
* Remove a managed section or entire managed block from a file.
|
|
1640
2363
|
*
|
|
@@ -1767,6 +2490,7 @@ function ensureCoreConfig(coreConfigDir) {
|
|
|
1767
2490
|
* @remarks
|
|
1768
2491
|
* Uses the same `updateManagedSection()` code path as writer cycles.
|
|
1769
2492
|
* Creates core config with defaults if missing. Copies templates.
|
|
2493
|
+
* Writes initial HEARTBEAT with "Not installed" alerts for all platform components.
|
|
1770
2494
|
* Jaccard cleanup detection runs automatically via `updateManagedSection`.
|
|
1771
2495
|
*
|
|
1772
2496
|
* @param options - Seeding configuration.
|
|
@@ -1775,67 +2499,18 @@ async function seedContent(options) {
|
|
|
1775
2499
|
const coreConfigDir = getCoreConfigDir();
|
|
1776
2500
|
// Ensure core config exists
|
|
1777
2501
|
ensureCoreConfig(coreConfigDir);
|
|
1778
|
-
// Seed
|
|
2502
|
+
// Seed SOUL.md, AGENTS.md, TOOLS.md Platform section
|
|
1779
2503
|
await refreshPlatformContent({
|
|
1780
2504
|
coreVersion: options.coreVersion,
|
|
1781
2505
|
});
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
*/
|
|
1791
|
-
/**
|
|
1792
|
-
* Fetch a URL with an automatic abort timeout.
|
|
1793
|
-
*
|
|
1794
|
-
* @param url - URL to fetch.
|
|
1795
|
-
* @param timeoutMs - Timeout in milliseconds before aborting.
|
|
1796
|
-
* @param init - Optional `fetch` init options.
|
|
1797
|
-
* @returns The fetch Response object.
|
|
1798
|
-
*/
|
|
1799
|
-
async function fetchWithTimeout(url, timeoutMs, init) {
|
|
1800
|
-
const controller = new AbortController();
|
|
1801
|
-
const timeout = setTimeout(() => {
|
|
1802
|
-
controller.abort();
|
|
1803
|
-
}, timeoutMs);
|
|
1804
|
-
try {
|
|
1805
|
-
return await fetch(url, { ...init, signal: controller.signal });
|
|
1806
|
-
}
|
|
1807
|
-
finally {
|
|
1808
|
-
clearTimeout(timeout);
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
/**
|
|
1812
|
-
* Fetch JSON from a URL, throwing on non-OK responses.
|
|
1813
|
-
*
|
|
1814
|
-
* @param url - URL to fetch.
|
|
1815
|
-
* @param init - Optional `fetch` init options.
|
|
1816
|
-
* @returns Parsed JSON response body.
|
|
1817
|
-
* @throws Error with `HTTP {status}: {body}` message on non-OK responses.
|
|
1818
|
-
*/
|
|
1819
|
-
async function fetchJson(url, init) {
|
|
1820
|
-
const res = await fetch(url, init);
|
|
1821
|
-
if (!res.ok) {
|
|
1822
|
-
throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
|
|
1823
|
-
}
|
|
1824
|
-
return res.json();
|
|
1825
|
-
}
|
|
1826
|
-
/**
|
|
1827
|
-
* POST JSON to a URL and return parsed response.
|
|
1828
|
-
*
|
|
1829
|
-
* @param url - URL to POST to.
|
|
1830
|
-
* @param body - Request body (will be JSON-stringified).
|
|
1831
|
-
* @returns Parsed JSON response body.
|
|
1832
|
-
*/
|
|
1833
|
-
async function postJson(url, body) {
|
|
1834
|
-
return fetchJson(url, {
|
|
1835
|
-
method: 'POST',
|
|
1836
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1837
|
-
body: JSON.stringify(body),
|
|
1838
|
-
});
|
|
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);
|
|
1839
2514
|
}
|
|
1840
2515
|
|
|
1841
2516
|
/**
|
|
@@ -2096,4 +2771,4 @@ function connectionFail(error, baseUrl, pluginId) {
|
|
|
2096
2771
|
return fail(error);
|
|
2097
2772
|
}
|
|
2098
2773
|
|
|
2099
|
-
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 };
|