@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/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.2.0` placeholder is replaced by
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.2.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 and their stable ordering for TOOLS.md.
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
- // Cleanup detection
703
- const userContent = parsed.userContent;
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 parts = [];
709
- if (parsed.beforeContent) {
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
- parts.push('');
716
- parts.push(CLEANUP_FLAG);
928
+ managedParts.push('');
929
+ managedParts.push(CLEANUP_FLAG);
717
930
  }
718
- parts.push('');
719
- parts.push(newManagedBody);
720
- parts.push('');
721
- parts.push(endLine);
722
- if (userContent) {
723
- parts.push('');
724
- parts.push(userContent);
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
- parts.push('');
727
- const newFileContent = parts.join('\n');
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 EMPTY** (comments only). Every heartbeat loads the full system prompt. An empty heartbeat that returns HEARTBEAT_OK still costs a full LLM session.
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
- When you detect that a Jeeves platform component is missing or not configured:
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
- 1. **Identify** the gap (service not responding on expected port, plugin not registered, config file missing)
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
- Don't silently work around missing components. The user should know what's available and what isn't.
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\`**, verify the PR is not already merged. Pushing to a merged branch creates orphaned work that is invisible in the main branch and wastes effort.
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
- Sequence: \`gh pr view --json state\` → confirm state is \`OPEN\` → push. If no PR exists yet, pushing is safe. If the PR is \`MERGED\` or \`CLOSED\`, **STOP** and report to the user.
1152
+ ### New PR Over Merged Branch
920
1153
 
921
- This is not optional. It applies to every push, every branch, every time.
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
- * Timer-based orchestrator for managed content writing.
1450
+ * Platform-aware service state detection.
1217
1451
  *
1218
1452
  * @remarks
1219
- * `ComponentWriter` manages a component's TOOLS.md section writes
1220
- * and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
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
- * Orchestrates managed content writing for a single Jeeves component.
1457
+ * Detect the state of a system service by name.
1225
1458
  *
1226
- * @remarks
1227
- * Created via `createComponentWriter()`. Manages a timer that fires
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
- class ComponentWriter {
1232
- timer;
1233
- component;
1234
- configDir;
1235
- /** @internal */
1236
- constructor(component) {
1237
- this.component = component;
1238
- this.configDir = getComponentConfigDir(component.name);
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
- /** The component's config directory path. */
1241
- get componentConfigDir() {
1242
- return this.configDir;
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
- /** Whether the writer timer is currently running. */
1245
- get isRunning() {
1246
- return this.timer !== undefined;
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
- * Start the writer timer.
1250
- *
1251
- * @remarks
1252
- * Performs an immediate first write, then sets up the interval.
1253
- */
1254
- start() {
1255
- if (this.timer)
1256
- return;
1257
- // Fire immediately, then on interval
1258
- void this.cycle();
1259
- this.timer = setInterval(() => void this.cycle(), this.component.refreshIntervalSeconds * 1000);
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
- /** Stop the writer timer. */
1262
- stop() {
1263
- if (this.timer) {
1264
- clearInterval(this.timer);
1265
- this.timer = undefined;
1266
- }
1510
+ catch {
1511
+ return 'not_installed';
1267
1512
  }
1268
- /**
1269
- * Execute a single write cycle.
1270
- *
1271
- * @remarks
1272
- * Calls `generateToolsContent()` and writes the component's
1273
- * TOOLS.md section via `updateManagedSection()`. Also calls
1274
- * `refreshPlatformContent()` for shared content maintenance.
1275
- */
1276
- async cycle() {
1277
- try {
1278
- const workspacePath = getWorkspacePath();
1279
- const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
1280
- // Write the component's TOOLS.md section
1281
- const toolsContent = this.component.generateToolsContent();
1282
- await updateManagedSection(toolsPath, toolsContent, {
1283
- mode: 'section',
1284
- sectionId: this.component.sectionId,
1285
- markers: TOOLS_MARKERS,
1286
- coreVersion: CORE_VERSION,
1287
- });
1288
- // Platform content maintenance: SOUL.md, AGENTS.md, Platform section
1289
- await refreshPlatformContent({
1290
- coreVersion: CORE_VERSION,
1291
- componentName: this.component.name,
1292
- componentVersion: this.component.version,
1293
- servicePackage: this.component.servicePackage,
1294
- pluginPackage: this.component.pluginPackage,
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
- catch (err) {
1298
- const message = err instanceof Error ? err.message : String(err);
1299
- console.warn(`jeeves-core: ComponentWriter cycle failed for ${this.component.name}: ${message}`);
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
- * Creates a synchronous content accessor backed by an async data source.
1570
+ * Core configuration schema and resolution.
1306
1571
  *
1307
1572
  * @remarks
1308
- * Solves the sync/async gap in `JeevesComponent.generateToolsContent()`:
1309
- * the interface is synchronous, but most components fetch live data from
1310
- * their HTTP service. This utility returns a sync `() => string` that
1311
- * serves the last successfully fetched value while kicking off a background
1312
- * refresh on each call.
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 content via the same code path as writer cycles
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
- * HTTP helpers for the OpenClaw plugin SDK.
1786
- *
1787
- * @remarks
1788
- * Thin wrappers around `fetch` that throw on non-OK responses
1789
- * and handle JSON serialisation/deserialisation.
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 };