@karmaniverous/jeeves 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,51 @@
1
1
  #!/usr/bin/env node
2
- import require$$0 from 'commander';
2
+ import * as commander from 'commander';
3
3
  import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, cpSync, rmSync } from 'node:fs';
4
4
  import { join, dirname } from 'node:path';
5
+ import { gte } from 'semver';
6
+ import 'node:child_process';
5
7
  import { z } from 'zod';
8
+ import { lock } from 'proper-lockfile';
6
9
  import { fileURLToPath } from 'node:url';
7
10
  import { packageDirectorySync } from 'package-directory';
8
- import { lock } from 'proper-lockfile';
9
- import { gte } from 'semver';
10
11
 
11
12
  function getDefaultExportFromCjs (x) {
12
13
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
13
14
  }
14
15
 
16
+ function getAugmentedNamespace(n) {
17
+ if (Object.prototype.hasOwnProperty.call(n, '__esModule')) return n;
18
+ var f = n.default;
19
+ if (typeof f == "function") {
20
+ var a = function a () {
21
+ var isInstance = false;
22
+ try {
23
+ isInstance = this instanceof a;
24
+ } catch {}
25
+ if (isInstance) {
26
+ return Reflect.construct(f, arguments, this.constructor);
27
+ }
28
+ return f.apply(this, arguments);
29
+ };
30
+ a.prototype = f.prototype;
31
+ } else a = {};
32
+ Object.defineProperty(a, '__esModule', {value: true});
33
+ Object.keys(n).forEach(function (k) {
34
+ var d = Object.getOwnPropertyDescriptor(n, k);
35
+ Object.defineProperty(a, k, d.get ? d : {
36
+ enumerable: true,
37
+ get: function () {
38
+ return n[k];
39
+ }
40
+ });
41
+ });
42
+ return a;
43
+ }
44
+
15
45
  var extraTypings = {exports: {}};
16
46
 
47
+ var require$$0 = /*@__PURE__*/getAugmentedNamespace(commander);
48
+
17
49
  var hasRequiredExtraTypings;
18
50
 
19
51
  function requireExtraTypings () {
@@ -84,6 +116,8 @@ const TOOLS_MARKERS = {
84
116
  end: 'END JEEVES PLATFORM TOOLS',
85
117
  /** H1 title prepended in section mode. */
86
118
  title: 'Jeeves Platform Tools',
119
+ /** Managed block at bottom of file. */
120
+ position: 'bottom',
87
121
  };
88
122
  /** Default markers for SOUL.md managed block. */
89
123
  const SOUL_MARKERS = {
@@ -93,6 +127,8 @@ const SOUL_MARKERS = {
93
127
  end: 'END JEEVES SOUL',
94
128
  /** H1 title prepended in the managed block. */
95
129
  title: 'Jeeves Platform Soul',
130
+ /** Managed block at bottom of file. */
131
+ position: 'bottom',
96
132
  };
97
133
  /** Default markers for AGENTS.md managed block. */
98
134
  const AGENTS_MARKERS = {
@@ -102,6 +138,8 @@ const AGENTS_MARKERS = {
102
138
  end: 'END JEEVES AGENTS',
103
139
  /** H1 title prepended in the managed block. */
104
140
  title: 'Jeeves Platform Agents',
141
+ /** Managed block at bottom of file. */
142
+ position: 'bottom',
105
143
  };
106
144
  /** All known marker sets — single source of truth for cross-contamination detection. */
107
145
  const ALL_MARKERS = [
@@ -135,6 +173,8 @@ const WORKSPACE_FILES = {
135
173
  soul: 'SOUL.md',
136
174
  /** AGENTS.md — operational protocols and memory architecture. */
137
175
  agents: 'AGENTS.md',
176
+ /** HEARTBEAT.md — platform status and health alerts. */
177
+ heartbeat: 'HEARTBEAT.md',
138
178
  };
139
179
  /** Templates directory name within core config. */
140
180
  const TEMPLATES_DIR = 'templates';
@@ -170,7 +210,7 @@ const DEFAULT_PORTS = {
170
210
  };
171
211
 
172
212
  /**
173
- * Managed section IDs and their stable ordering for TOOLS.md.
213
+ * Managed section IDs, stable ordering, and platform component registry.
174
214
  *
175
215
  * @remarks
176
216
  * Section ordering is fixed to prevent diff churn regardless of which
@@ -200,19 +240,86 @@ const SECTION_ORDER = [
200
240
  SECTION_IDS.Runner,
201
241
  SECTION_IDS.Meta,
202
242
  ];
243
+ /**
244
+ * The four essential platform components.
245
+ *
246
+ * @remarks
247
+ * These components constitute the Jeeves platform. `jeeves install` writes
248
+ * initial HEARTBEAT "Not installed" alerts for all of them. The HEARTBEAT
249
+ * writer generates "Not installed" alerts only for platform components not
250
+ * in `component-versions.json`. Optional future components (not in this list)
251
+ * appear in HEARTBEAT only after explicit install.
252
+ */
253
+ const PLATFORM_COMPONENTS = [
254
+ 'runner',
255
+ 'watcher',
256
+ 'server',
257
+ 'meta',
258
+ ];
203
259
 
204
260
  /**
205
261
  * Core library version, inlined at build time.
206
262
  *
207
263
  * @remarks
208
- * The `0.3.0` placeholder is replaced by
264
+ * The `0.3.1` placeholder is replaced by
209
265
  * `@rollup/plugin-replace` during the build with the actual version
210
266
  * from `package.json`. This ensures the correct version survives
211
267
  * when consumers bundle core into their own dist (where runtime
212
268
  * `import.meta.url`-based resolution would find the wrong package.json).
213
269
  */
214
270
  /** The core library version from package.json (inlined at build time). */
215
- const CORE_VERSION = '0.3.0';
271
+ const CORE_VERSION = '0.3.1';
272
+
273
+ /**
274
+ * Workspace and config root initialization.
275
+ *
276
+ * @remarks
277
+ * `init()` must be called once before any other core library functions.
278
+ * It caches `workspacePath` and `configRoot` at module level.
279
+ * Core derives all namespaced paths from these values:
280
+ * - `{configRoot}/jeeves-core/` for core config
281
+ * - `{configRoot}/jeeves-{name}/` for each component
282
+ */
283
+ let state;
284
+ /**
285
+ * Initialize the core library with workspace and config root paths.
286
+ *
287
+ * @param options - Workspace and config root paths.
288
+ */
289
+ function init(options) {
290
+ state = {
291
+ workspacePath: options.workspacePath,
292
+ configRoot: options.configRoot,
293
+ coreConfigDir: join(options.configRoot, CORE_CONFIG_DIR),
294
+ };
295
+ }
296
+ /**
297
+ * Get the cached workspace path.
298
+ *
299
+ * @throws Error if `init()` has not been called.
300
+ */
301
+ function getWorkspacePath() {
302
+ if (!state)
303
+ throw new Error('jeeves-core: init() must be called first');
304
+ return state.workspacePath;
305
+ }
306
+ /**
307
+ * Get the core config directory path.
308
+ *
309
+ * @throws Error if `init()` has not been called.
310
+ */
311
+ function getCoreConfigDir() {
312
+ if (!state)
313
+ throw new Error('jeeves-core: init() must be called first');
314
+ return state.coreConfigDir;
315
+ }
316
+
317
+ var init$1 = /*#__PURE__*/Object.freeze({
318
+ __proto__: null,
319
+ getCoreConfigDir: getCoreConfigDir,
320
+ getWorkspacePath: getWorkspacePath,
321
+ init: init
322
+ });
216
323
 
217
324
  /**
218
325
  * Core configuration schema and resolution.
@@ -229,12 +336,22 @@ const serviceEntrySchema = z.object({
229
336
  /** Service URL (must be a valid URL). */
230
337
  url: z.string().url().describe('Service URL'),
231
338
  });
339
+ /** Default bind address for all Jeeves services. */
340
+ const DEFAULT_BIND_ADDRESS = '0.0.0.0';
232
341
  /** Zod schema for the core config file. */
233
342
  const coreConfigSchema = z.object({
234
343
  /** JSON Schema pointer for IDE autocomplete. */
235
344
  $schema: z.string().optional().describe('JSON Schema pointer'),
236
345
  /** Owner identity keys (canonical identityLinks references). */
237
346
  owners: z.array(z.string()).default([]).describe('Owner identity keys'),
347
+ /**
348
+ * Bind address for all Jeeves services. Default: `0.0.0.0` (all interfaces).
349
+ * Individual components can override in their own config.
350
+ */
351
+ bindAddress: z
352
+ .string()
353
+ .default(DEFAULT_BIND_ADDRESS)
354
+ .describe('Bind address for all Jeeves services'),
238
355
  /** Service URL overrides keyed by service name. */
239
356
  services: z
240
357
  .record(z.string(), serviceEntrySchema)
@@ -271,6 +388,11 @@ function generateJsonSchema() {
271
388
  items: { type: 'string' },
272
389
  default: [],
273
390
  },
391
+ bindAddress: {
392
+ type: 'string',
393
+ default: '0.0.0.0',
394
+ description: 'Bind address for all Jeeves services',
395
+ },
274
396
  services: {
275
397
  type: 'object',
276
398
  additionalProperties: {
@@ -317,55 +439,313 @@ function loadConfig(configDir) {
317
439
  }
318
440
 
319
441
  /**
320
- * Workspace and config root initialization.
442
+ * Service URL resolution.
321
443
  *
322
444
  * @remarks
323
- * `init()` must be called once before any other core library functions.
324
- * It caches `workspacePath` and `configRoot` at module level.
325
- * Core derives all namespaced paths from these values:
326
- * - `{configRoot}/jeeves-core/` for core config
327
- * - `{configRoot}/jeeves-{name}/` for each component
445
+ * Resolves the URL for a named Jeeves service using the following
446
+ * resolution order:
447
+ * 1. Consumer's own component config
448
+ * 2. Core config (`{configRoot}/jeeves-core/config.json`)
449
+ * 3. Default port constants
328
450
  */
329
- let state;
330
451
  /**
331
- * Initialize the core library with workspace and config root paths.
452
+ * Resolve the URL for a named Jeeves service.
332
453
  *
333
- * @param options - Workspace and config root paths.
454
+ * @param serviceName - The service name (e.g., 'watcher', 'runner').
455
+ * @param consumerName - Optional consumer component name for config override.
456
+ * @returns The resolved service URL.
457
+ * @throws Error if `init()` has not been called or the service is unknown.
334
458
  */
335
- function init(options) {
336
- state = {
337
- workspacePath: options.workspacePath,
338
- configRoot: options.configRoot,
339
- coreConfigDir: join(options.configRoot, CORE_CONFIG_DIR),
340
- };
459
+ function getServiceUrl(serviceName, consumerName) {
460
+ // 2. Check core config
461
+ const coreDir = getCoreConfigDir();
462
+ const coreConfig = loadConfig(coreDir);
463
+ const coreUrl = coreConfig?.services[serviceName]?.url;
464
+ if (coreUrl)
465
+ return coreUrl;
466
+ // 3. Fall back to port constants
467
+ const port = DEFAULT_PORTS[serviceName];
468
+ if (port !== undefined) {
469
+ return `http://127.0.0.1:${String(port)}`;
470
+ }
471
+ throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
341
472
  }
473
+
342
474
  /**
343
- * Get the cached workspace path.
475
+ * HTTP helpers for the OpenClaw plugin SDK.
344
476
  *
345
- * @throws Error if `init()` has not been called.
477
+ * @remarks
478
+ * Thin wrappers around `fetch` that throw on non-OK responses
479
+ * and handle JSON serialisation/deserialisation.
346
480
  */
347
- function getWorkspacePath() {
348
- if (!state)
349
- throw new Error('jeeves-core: init() must be called first');
350
- return state.workspacePath;
481
+ /**
482
+ * Fetch a URL with an automatic abort timeout.
483
+ *
484
+ * @param url - URL to fetch.
485
+ * @param timeoutMs - Timeout in milliseconds before aborting.
486
+ * @param init - Optional `fetch` init options.
487
+ * @returns The fetch Response object.
488
+ */
489
+ async function fetchWithTimeout(url, timeoutMs, init) {
490
+ const controller = new AbortController();
491
+ const timeout = setTimeout(() => {
492
+ controller.abort();
493
+ }, timeoutMs);
494
+ try {
495
+ return await fetch(url, { ...init, signal: controller.signal });
496
+ }
497
+ finally {
498
+ clearTimeout(timeout);
499
+ }
351
500
  }
501
+
352
502
  /**
353
- * Get the core config directory path.
503
+ * Shared file I/O helpers for managed section operations.
354
504
  *
355
- * @throws Error if `init()` has not been called.
505
+ * @remarks
506
+ * Extracts the atomic write pattern and file-level locking into
507
+ * reusable utilities, eliminating duplication between
508
+ * `updateManagedSection` and `removeManagedSection`.
356
509
  */
357
- function getCoreConfigDir() {
358
- if (!state)
359
- throw new Error('jeeves-core: init() must be called first');
360
- return state.coreConfigDir;
510
+ /** Stale lock threshold in ms (2 minutes). */
511
+ const STALE_LOCK_MS = 120_000;
512
+ /** Default core version when none provided. */
513
+ const DEFAULT_CORE_VERSION = CORE_VERSION;
514
+ /** Lock retry options. */
515
+ const LOCK_RETRIES = { retries: 5, minTimeout: 100, maxTimeout: 1000 };
516
+ /**
517
+ * Write content to a file atomically via a temp file + rename.
518
+ *
519
+ * @param filePath - Absolute path to the target file.
520
+ * @param content - Content to write.
521
+ */
522
+ function atomicWrite(filePath, content) {
523
+ const dir = dirname(filePath);
524
+ const tempPath = join(dir, `.${String(Date.now())}.tmp`);
525
+ writeFileSync(tempPath, content, 'utf-8');
526
+ renameSync(tempPath, filePath);
527
+ }
528
+ /**
529
+ * Execute a callback while holding a file lock.
530
+ *
531
+ * @remarks
532
+ * Acquires a lock on the file, executes the callback, and releases
533
+ * the lock in a finally block. The lock uses a 2-minute stale threshold
534
+ * and retries up to 5 times.
535
+ *
536
+ * @param filePath - Absolute path to the file to lock.
537
+ * @param fn - Async callback to execute while holding the lock.
538
+ */
539
+ async function withFileLock(filePath, fn) {
540
+ let release;
541
+ try {
542
+ release = await lock(filePath, {
543
+ stale: STALE_LOCK_MS,
544
+ retries: LOCK_RETRIES,
545
+ });
546
+ await fn();
547
+ }
548
+ finally {
549
+ if (release) {
550
+ try {
551
+ await release();
552
+ }
553
+ catch {
554
+ // Lock already released or file deleted — safe to ignore
555
+ }
556
+ }
557
+ }
361
558
  }
362
559
 
363
- var init$1 = /*#__PURE__*/Object.freeze({
364
- __proto__: null,
365
- getCoreConfigDir: getCoreConfigDir,
366
- getWorkspacePath: getWorkspacePath,
367
- init: init
368
- });
560
+ /**
561
+ * Shared component version state file management.
562
+ *
563
+ * @remarks
564
+ * Each `ComponentWriter` cycle writes its component's entry to
565
+ * `{coreConfigDir}/component-versions.json`. The Platform Handlebars
566
+ * template reads this file to populate ALL rows in the service health
567
+ * table, not just the calling component's.
568
+ */
569
+ /**
570
+ * Read the component versions state file.
571
+ *
572
+ * @param coreConfigDir - Path to the core config directory.
573
+ * @returns The parsed state, or an empty object if the file doesn't exist.
574
+ */
575
+ function readComponentVersions(coreConfigDir) {
576
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
577
+ if (!existsSync(filePath))
578
+ return {};
579
+ try {
580
+ const raw = readFileSync(filePath, 'utf-8');
581
+ return JSON.parse(raw);
582
+ }
583
+ catch {
584
+ return {};
585
+ }
586
+ }
587
+ /**
588
+ * Write a component's version entry to the shared state file.
589
+ *
590
+ * @remarks
591
+ * Reads the existing file, merges the new entry, and writes atomically.
592
+ *
593
+ * @param coreConfigDir - Path to the core config directory.
594
+ * @param options - Component version data to write.
595
+ */
596
+ function writeComponentVersion(coreConfigDir, options) {
597
+ const existing = readComponentVersions(coreConfigDir);
598
+ existing[options.componentName] = {
599
+ pluginVersion: options.pluginVersion,
600
+ servicePackage: options.servicePackage,
601
+ pluginPackage: options.pluginPackage,
602
+ updatedAt: new Date().toISOString(),
603
+ };
604
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
605
+ const dir = dirname(filePath);
606
+ if (!existsSync(dir)) {
607
+ mkdirSync(dir, { recursive: true });
608
+ }
609
+ atomicWrite(filePath, JSON.stringify(existing, null, 2) + '\n');
610
+ }
611
+
612
+ /**
613
+ * HEARTBEAT health orchestration.
614
+ *
615
+ * @remarks
616
+ * Determines the state of each platform component and generates
617
+ * HEARTBEAT entries with actionable alert text. Applies the dependency
618
+ * graph for alert suppression and auto-decline.
619
+ */
620
+ /** Derive the full service name from a component name. */
621
+ function toServiceName(name) {
622
+ return `jeeves-${name}`;
623
+ }
624
+ /** "Not installed" alert text for each platform component. Shared with seedContent. */
625
+ const NOT_INSTALLED_ALERTS = {
626
+ 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`.',
627
+ 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`.',
628
+ 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`.',
629
+ 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`.',
630
+ };
631
+
632
+ /**
633
+ * Heading-based HEARTBEAT section writer.
634
+ *
635
+ * @remarks
636
+ * Manages the `# Jeeves Platform Status` section in HEARTBEAT.md.
637
+ * Unlike TOOLS/SOUL/AGENTS (which use HTML comment markers), HEARTBEAT
638
+ * uses markdown headings as markers — this ensures the file passes
639
+ * OpenClaw's heartbeat emptiness check when only headings remain.
640
+ *
641
+ * The section is always at the bottom of the file (H1 to EOF).
642
+ * User heartbeat items above the section are preserved.
643
+ */
644
+ /** The H1 heading that anchors the platform status section. */
645
+ const HEARTBEAT_HEADING = '# Jeeves Platform Status';
646
+ /**
647
+ * Parse the HEARTBEAT.md file content.
648
+ *
649
+ * @param fileContent - Full file content.
650
+ * @returns Parsed result with user zone and component entries.
651
+ */
652
+ function parseHeartbeat(fileContent) {
653
+ const headingIndex = fileContent.indexOf(HEARTBEAT_HEADING);
654
+ if (headingIndex === -1) {
655
+ return {
656
+ userContent: fileContent.trim(),
657
+ found: false,
658
+ entries: [],
659
+ };
660
+ }
661
+ const userContent = fileContent.slice(0, headingIndex).trim();
662
+ const sectionContent = fileContent.slice(headingIndex + HEARTBEAT_HEADING.length);
663
+ const entries = [];
664
+ const h2Re = /^## (jeeves-\S+?)(?:: declined)?$/gm;
665
+ let match;
666
+ const h2Positions = [];
667
+ while ((match = h2Re.exec(sectionContent)) !== null) {
668
+ const fullHeading = match[0];
669
+ const name = match[1];
670
+ const declined = fullHeading.endsWith(': declined');
671
+ h2Positions.push({ name, declined, start: match.index });
672
+ }
673
+ for (let i = 0; i < h2Positions.length; i++) {
674
+ const pos = h2Positions[i];
675
+ const headingLine = pos.declined
676
+ ? `## ${pos.name}: declined`
677
+ : `## ${pos.name}`;
678
+ const contentStart = pos.start + headingLine.length;
679
+ const contentEnd = i + 1 < h2Positions.length
680
+ ? h2Positions[i + 1].start
681
+ : sectionContent.length;
682
+ const content = sectionContent.slice(contentStart, contentEnd).trim();
683
+ entries.push({
684
+ name: pos.name,
685
+ declined: pos.declined,
686
+ content,
687
+ });
688
+ }
689
+ return { userContent, found: true, entries };
690
+ }
691
+ /**
692
+ * Build the HEARTBEAT section content from entries.
693
+ *
694
+ * @param entries - Component entries to write.
695
+ * @returns The full section string (H1 + H2s).
696
+ */
697
+ function buildHeartbeatSection(entries) {
698
+ const parts = [HEARTBEAT_HEADING];
699
+ for (const entry of entries) {
700
+ if (entry.declined) {
701
+ parts.push(`## ${entry.name}: declined`);
702
+ }
703
+ else if (entry.content) {
704
+ parts.push(`## ${entry.name}`);
705
+ parts.push(entry.content);
706
+ }
707
+ // Healthy components (no content, not declined) get no H2 section
708
+ }
709
+ return parts.join('\n');
710
+ }
711
+ /**
712
+ * Write the HEARTBEAT section to a file.
713
+ *
714
+ * @remarks
715
+ * Replaces everything from `# Jeeves Platform Status` to EOF.
716
+ * Preserves user content above the heading. Uses file-level locking.
717
+ *
718
+ * @param filePath - Absolute path to HEARTBEAT.md.
719
+ * @param entries - Component entries to write.
720
+ */
721
+ async function writeHeartbeatSection(filePath, entries) {
722
+ const dir = dirname(filePath);
723
+ if (!existsSync(dir)) {
724
+ mkdirSync(dir, { recursive: true });
725
+ }
726
+ if (!existsSync(filePath)) {
727
+ writeFileSync(filePath, '', 'utf-8');
728
+ }
729
+ try {
730
+ await withFileLock(filePath, () => {
731
+ const fileContent = readFileSync(filePath, 'utf-8');
732
+ const parsed = parseHeartbeat(fileContent);
733
+ const section = buildHeartbeatSection(entries);
734
+ const parts = [];
735
+ if (parsed.userContent) {
736
+ parts.push(parsed.userContent);
737
+ parts.push('');
738
+ }
739
+ parts.push(section);
740
+ parts.push('');
741
+ atomicWrite(filePath, parts.join('\n'));
742
+ });
743
+ }
744
+ catch (err) {
745
+ const message = err instanceof Error ? err.message : String(err);
746
+ console.warn(`jeeves-core: writeHeartbeatSection failed for ${filePath}: ${message}`);
747
+ }
748
+ }
369
749
 
370
750
  var agentsSectionContent = `## Memory Architecture
371
751
 
@@ -434,6 +814,8 @@ At minimum, always brief sub-agents on:
434
814
 
435
815
  **Anything important enough to have a permanent cron/heartbeat entry is important enough to be codified into the data flow.**
436
816
 
817
+ **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.
818
+
437
819
  ## Messaging Dispatch
438
820
 
439
821
  **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).
@@ -442,7 +824,7 @@ At minimum, always brief sub-agents on:
442
824
 
443
825
  ## Heartbeat Discipline
444
826
 
445
- **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.
827
+ **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.
446
828
 
447
829
  Heartbeat items are for **transient, session-requiring work-in-progress ONLY**. Each item must include its own termination condition.
448
830
 
@@ -503,14 +885,11 @@ When discovering a new data source, integrate it into the existing data flow pip
503
885
 
504
886
  ## Bootstrap Protocol
505
887
 
506
- When you detect that a Jeeves platform component is missing or not configured:
888
+ **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.
507
889
 
508
- 1. **Identify** the gap (service not responding on expected port, plugin not registered, config file missing)
509
- 2. **Inform** the user what's missing and what it provides
510
- 3. **Guide** them through installation using the component's CLI install command
511
- 4. **Verify** the installation succeeded (probe the port, check the plugin registration)
890
+ **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.
512
891
 
513
- Don't silently work around missing components. The user should know what's available and what isn't.
892
+ 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.
514
893
 
515
894
  ## Em-Dash Discipline
516
895
 
@@ -731,116 +1110,6 @@ Read these templates when creating new specs, onboarding to new projects, or whe
731
1110
  <!-- ENDIF_TEMPLATES -->
732
1111
  `;
733
1112
 
734
- /**
735
- * Shared file I/O helpers for managed section operations.
736
- *
737
- * @remarks
738
- * Extracts the atomic write pattern and file-level locking into
739
- * reusable utilities, eliminating duplication between
740
- * `updateManagedSection` and `removeManagedSection`.
741
- */
742
- /** Stale lock threshold in ms (2 minutes). */
743
- const STALE_LOCK_MS = 120_000;
744
- /** Default core version when none provided. */
745
- const DEFAULT_CORE_VERSION = CORE_VERSION;
746
- /** Lock retry options. */
747
- const LOCK_RETRIES = { retries: 5, minTimeout: 100, maxTimeout: 1000 };
748
- /**
749
- * Write content to a file atomically via a temp file + rename.
750
- *
751
- * @param filePath - Absolute path to the target file.
752
- * @param content - Content to write.
753
- */
754
- function atomicWrite(filePath, content) {
755
- const dir = dirname(filePath);
756
- const tempPath = join(dir, `.${String(Date.now())}.tmp`);
757
- writeFileSync(tempPath, content, 'utf-8');
758
- renameSync(tempPath, filePath);
759
- }
760
- /**
761
- * Execute a callback while holding a file lock.
762
- *
763
- * @remarks
764
- * Acquires a lock on the file, executes the callback, and releases
765
- * the lock in a finally block. The lock uses a 2-minute stale threshold
766
- * and retries up to 5 times.
767
- *
768
- * @param filePath - Absolute path to the file to lock.
769
- * @param fn - Async callback to execute while holding the lock.
770
- */
771
- async function withFileLock(filePath, fn) {
772
- let release;
773
- try {
774
- release = await lock(filePath, {
775
- stale: STALE_LOCK_MS,
776
- retries: LOCK_RETRIES,
777
- });
778
- await fn();
779
- }
780
- finally {
781
- if (release) {
782
- try {
783
- await release();
784
- }
785
- catch {
786
- // Lock already released or file deleted — safe to ignore
787
- }
788
- }
789
- }
790
- }
791
-
792
- /**
793
- * Shared component version state file management.
794
- *
795
- * @remarks
796
- * Each `ComponentWriter` cycle writes its component's entry to
797
- * `{coreConfigDir}/component-versions.json`. The Platform Handlebars
798
- * template reads this file to populate ALL rows in the service health
799
- * table, not just the calling component's.
800
- */
801
- /**
802
- * Read the component versions state file.
803
- *
804
- * @param coreConfigDir - Path to the core config directory.
805
- * @returns The parsed state, or an empty object if the file doesn't exist.
806
- */
807
- function readComponentVersions(coreConfigDir) {
808
- const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
809
- if (!existsSync(filePath))
810
- return {};
811
- try {
812
- const raw = readFileSync(filePath, 'utf-8');
813
- return JSON.parse(raw);
814
- }
815
- catch {
816
- return {};
817
- }
818
- }
819
- /**
820
- * Write a component's version entry to the shared state file.
821
- *
822
- * @remarks
823
- * Reads the existing file, merges the new entry, and writes atomically.
824
- *
825
- * @param coreConfigDir - Path to the core config directory.
826
- * @param options - Component version data to write.
827
- */
828
- function writeComponentVersion(coreConfigDir, options) {
829
- const existing = readComponentVersions(coreConfigDir);
830
- existing[options.componentName] = {
831
- pluginVersion: options.pluginVersion,
832
- servicePackage: options.servicePackage,
833
- pluginPackage: options.pluginPackage,
834
- updatedAt: new Date().toISOString(),
835
- };
836
- const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
837
- const dir = dirname(filePath);
838
- if (!existsSync(dir)) {
839
- mkdirSync(dir, { recursive: true });
840
- }
841
- atomicWrite(filePath, JSON.stringify(existing, null, 2) + '\n');
842
- }
843
-
844
1113
  /**
845
1114
  * Similarity-based cleanup detection for orphaned managed content.
846
1115
  *
@@ -1194,32 +1463,51 @@ async function updateManagedSection(filePath, content, options = {}) {
1194
1463
  ? `# ${markers.title}\n\n${sectionText}`
1195
1464
  : sectionText;
1196
1465
  }
1466
+ // Combine beforeContent + userContent for the user zone.
1467
+ // When migrating from top→bottom, beforeContent is empty and
1468
+ // userContent has the real content. When already at bottom,
1469
+ // beforeContent has the user content and userContent is empty.
1470
+ const rawUserContent = [parsed.beforeContent, parsed.userContent]
1471
+ .filter(Boolean)
1472
+ .join('\n\n')
1473
+ .trim();
1197
1474
  // Strip foreign managed blocks from user content (cross-contamination fix)
1198
- const userContent = stripForeignMarkers(parsed.userContent, markers);
1475
+ const userContent = stripForeignMarkers(rawUserContent, markers);
1199
1476
  const cleanupNeeded = needsCleanup(newManagedBody, userContent);
1200
1477
  // Build the full managed block
1201
1478
  const beginLine = formatBeginMarker(markers.begin, coreVersion);
1202
1479
  const endLine = formatEndMarker(markers.end);
1203
- const parts = [];
1204
- if (parsed.beforeContent) {
1205
- parts.push(parsed.beforeContent);
1206
- parts.push('');
1207
- }
1208
- parts.push(beginLine);
1480
+ const managedParts = [];
1481
+ managedParts.push(beginLine);
1209
1482
  if (cleanupNeeded) {
1210
- parts.push('');
1211
- parts.push(CLEANUP_FLAG);
1483
+ managedParts.push('');
1484
+ managedParts.push(CLEANUP_FLAG);
1212
1485
  }
1213
- parts.push('');
1214
- parts.push(newManagedBody);
1215
- parts.push('');
1216
- parts.push(endLine);
1217
- if (userContent) {
1218
- parts.push('');
1219
- parts.push(userContent);
1486
+ managedParts.push('');
1487
+ managedParts.push(newManagedBody);
1488
+ managedParts.push('');
1489
+ managedParts.push(endLine);
1490
+ const managedBlock = managedParts.join('\n');
1491
+ const position = markers.position ?? 'top';
1492
+ const fileParts = [];
1493
+ if (position === 'bottom') {
1494
+ // User content first, managed block at end
1495
+ if (userContent) {
1496
+ fileParts.push(userContent);
1497
+ fileParts.push('');
1498
+ }
1499
+ fileParts.push(managedBlock);
1220
1500
  }
1221
- parts.push('');
1222
- const newFileContent = parts.join('\n');
1501
+ else {
1502
+ // Managed block first (legacy default), user content below
1503
+ fileParts.push(managedBlock);
1504
+ if (userContent) {
1505
+ fileParts.push('');
1506
+ fileParts.push(userContent);
1507
+ }
1508
+ }
1509
+ fileParts.push('');
1510
+ const newFileContent = fileParts.join('\n');
1223
1511
  atomicWrite(filePath, newFileContent);
1224
1512
  });
1225
1513
  }
@@ -1387,6 +1675,7 @@ function ensureCoreConfig(coreConfigDir) {
1387
1675
  * @remarks
1388
1676
  * Uses the same `updateManagedSection()` code path as writer cycles.
1389
1677
  * Creates core config with defaults if missing. Copies templates.
1678
+ * Writes initial HEARTBEAT with "Not installed" alerts for all platform components.
1390
1679
  * Jaccard cleanup detection runs automatically via `updateManagedSection`.
1391
1680
  *
1392
1681
  * @param options - Seeding configuration.
@@ -1395,10 +1684,18 @@ async function seedContent(options) {
1395
1684
  const coreConfigDir = getCoreConfigDir();
1396
1685
  // Ensure core config exists
1397
1686
  ensureCoreConfig(coreConfigDir);
1398
- // Seed content via the same code path as writer cycles
1687
+ // Seed SOUL.md, AGENTS.md, TOOLS.md Platform section
1399
1688
  await refreshPlatformContent({
1400
1689
  coreVersion: options.coreVersion,
1401
1690
  });
1691
+ // Seed HEARTBEAT.md with "Not installed" alerts for all platform components
1692
+ const heartbeatPath = join(getWorkspacePath(), WORKSPACE_FILES.heartbeat);
1693
+ const entries = PLATFORM_COMPONENTS.map((name) => ({
1694
+ name: toServiceName(name),
1695
+ declined: false,
1696
+ content: `- ${NOT_INSTALLED_ALERTS[name]}`,
1697
+ }));
1698
+ await writeHeartbeatSection(heartbeatPath, entries);
1402
1699
  }
1403
1700
 
1404
1701
  /**
@@ -1455,72 +1752,12 @@ function registerInstallCommand(program) {
1455
1752
  console.log(' - SOUL.md managed section written');
1456
1753
  console.log(' - AGENTS.md managed section written');
1457
1754
  console.log(' - TOOLS.md Platform section written');
1755
+ console.log(' - HEARTBEAT.md platform status written');
1458
1756
  console.log(' - Templates copied to config directory');
1459
1757
  console.log(' - Core config created (if not present)');
1460
1758
  });
1461
1759
  }
1462
1760
 
1463
- /**
1464
- * Service URL resolution.
1465
- *
1466
- * @remarks
1467
- * Resolves the URL for a named Jeeves service using the following
1468
- * resolution order:
1469
- * 1. Consumer's own component config
1470
- * 2. Core config (`{configRoot}/jeeves-core/config.json`)
1471
- * 3. Default port constants
1472
- */
1473
- /**
1474
- * Resolve the URL for a named Jeeves service.
1475
- *
1476
- * @param serviceName - The service name (e.g., 'watcher', 'runner').
1477
- * @param consumerName - Optional consumer component name for config override.
1478
- * @returns The resolved service URL.
1479
- * @throws Error if `init()` has not been called or the service is unknown.
1480
- */
1481
- function getServiceUrl(serviceName, consumerName) {
1482
- // 2. Check core config
1483
- const coreDir = getCoreConfigDir();
1484
- const coreConfig = loadConfig(coreDir);
1485
- const coreUrl = coreConfig?.services[serviceName]?.url;
1486
- if (coreUrl)
1487
- return coreUrl;
1488
- // 3. Fall back to port constants
1489
- const port = DEFAULT_PORTS[serviceName];
1490
- if (port !== undefined) {
1491
- return `http://127.0.0.1:${String(port)}`;
1492
- }
1493
- throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
1494
- }
1495
-
1496
- /**
1497
- * HTTP helpers for the OpenClaw plugin SDK.
1498
- *
1499
- * @remarks
1500
- * Thin wrappers around `fetch` that throw on non-OK responses
1501
- * and handle JSON serialisation/deserialisation.
1502
- */
1503
- /**
1504
- * Fetch a URL with an automatic abort timeout.
1505
- *
1506
- * @param url - URL to fetch.
1507
- * @param timeoutMs - Timeout in milliseconds before aborting.
1508
- * @param init - Optional `fetch` init options.
1509
- * @returns The fetch Response object.
1510
- */
1511
- async function fetchWithTimeout(url, timeoutMs, init) {
1512
- const controller = new AbortController();
1513
- const timeout = setTimeout(() => {
1514
- controller.abort();
1515
- }, timeoutMs);
1516
- try {
1517
- return await fetch(url, { ...init, signal: controller.signal });
1518
- }
1519
- finally {
1520
- clearTimeout(timeout);
1521
- }
1522
- }
1523
-
1524
1761
  /**
1525
1762
  * CLI status command: discover components and probe their health.
1526
1763
  *