@karmaniverous/jeeves 0.1.6 → 0.2.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
@@ -1,12 +1,192 @@
1
- import { join, dirname } from 'node:path';
2
- import { existsSync, mkdirSync, writeFileSync, readFileSync, renameSync, cpSync } from 'node:fs';
1
+ import { JSONPath } from 'jsonpath-plus';
2
+ import { writeFileSync, renameSync, existsSync, readFileSync, mkdirSync, cpSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
3
4
  import { lock } from 'proper-lockfile';
4
- import { gte } from 'semver';
5
+ import semver, { gte } from 'semver';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import Handlebars from 'handlebars';
7
8
  import { packageDirectorySync } from 'package-directory';
8
9
  import { z } from 'zod';
9
10
  import { execSync } from 'node:child_process';
11
+ import { homedir } from 'node:os';
12
+
13
+ /**
14
+ * Generic config query handler with JSONPath support.
15
+ *
16
+ * @remarks
17
+ * Provides a transport-agnostic config query function that can be
18
+ * used by any Jeeves component's HTTP API. Returns the full config
19
+ * document or filters it via JSONPath expressions.
20
+ */
21
+ /**
22
+ * Create a config query handler.
23
+ *
24
+ * @remarks
25
+ * - No `path` parameter → returns the full config document.
26
+ * - Valid JSONPath → returns matching results with count.
27
+ * - Invalid JSONPath → returns 400 error.
28
+ *
29
+ * @param getConfig - Function that returns the current config object.
30
+ * @returns A config query handler function.
31
+ */
32
+ function createConfigQueryHandler(getConfig) {
33
+ return (query) => {
34
+ const config = getConfig();
35
+ if (!query.path) {
36
+ return Promise.resolve({ status: 200, body: config });
37
+ }
38
+ try {
39
+ const result = JSONPath({
40
+ path: query.path,
41
+ json: config,
42
+ });
43
+ return Promise.resolve({
44
+ status: 200,
45
+ body: { result, count: result.length },
46
+ });
47
+ }
48
+ catch (error) {
49
+ const message = error instanceof Error ? error.message : 'Query failed';
50
+ return Promise.resolve({ status: 400, body: { error: message } });
51
+ }
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Directory and file path conventions for the Jeeves platform.
57
+ */
58
+ /** Core config directory name within the config root. */
59
+ const CORE_CONFIG_DIR = 'jeeves-core';
60
+ /** Prefix for component config directories: `jeeves-{name}`. */
61
+ const COMPONENT_CONFIG_PREFIX = 'jeeves-';
62
+ /** Default workspace file names. */
63
+ const WORKSPACE_FILES = {
64
+ /** TOOLS.md — live platform state and component sections. */
65
+ tools: 'TOOLS.md',
66
+ /** SOUL.md — professional discipline and behavioral foundations. */
67
+ soul: 'SOUL.md',
68
+ /** AGENTS.md — operational protocols and memory architecture. */
69
+ agents: 'AGENTS.md',
70
+ };
71
+ /** Templates directory name within core config. */
72
+ const TEMPLATES_DIR = 'templates';
73
+ /** Registry cache file name. */
74
+ const REGISTRY_CACHE_FILE = 'registry-cache.json';
75
+ /** Core config file name. */
76
+ const CONFIG_FILE = 'config.json';
77
+ /** Component versions state file name. */
78
+ const COMPONENT_VERSIONS_FILE = 'component-versions.json';
79
+
80
+ /**
81
+ * Shared file I/O helpers for managed section operations.
82
+ *
83
+ * @remarks
84
+ * Extracts the atomic write pattern and file-level locking into
85
+ * reusable utilities, eliminating duplication between
86
+ * `updateManagedSection` and `removeManagedSection`.
87
+ */
88
+ /** Stale lock threshold in ms (2 minutes). */
89
+ const STALE_LOCK_MS = 120_000;
90
+ /** Default core version when none provided. */
91
+ const DEFAULT_CORE_VERSION = '0.0.0';
92
+ /** Lock retry options. */
93
+ const LOCK_RETRIES = { retries: 5, minTimeout: 100, maxTimeout: 1000 };
94
+ /**
95
+ * Write content to a file atomically via a temp file + rename.
96
+ *
97
+ * @param filePath - Absolute path to the target file.
98
+ * @param content - Content to write.
99
+ */
100
+ function atomicWrite(filePath, content) {
101
+ const dir = dirname(filePath);
102
+ const tempPath = join(dir, `.${String(Date.now())}.tmp`);
103
+ writeFileSync(tempPath, content, 'utf-8');
104
+ renameSync(tempPath, filePath);
105
+ }
106
+ /**
107
+ * Execute a callback while holding a file lock.
108
+ *
109
+ * @remarks
110
+ * Acquires a lock on the file, executes the callback, and releases
111
+ * the lock in a finally block. The lock uses a 2-minute stale threshold
112
+ * and retries up to 5 times.
113
+ *
114
+ * @param filePath - Absolute path to the file to lock.
115
+ * @param fn - Async callback to execute while holding the lock.
116
+ */
117
+ async function withFileLock(filePath, fn) {
118
+ let release;
119
+ try {
120
+ release = await lock(filePath, {
121
+ stale: STALE_LOCK_MS,
122
+ retries: LOCK_RETRIES,
123
+ });
124
+ await fn();
125
+ }
126
+ finally {
127
+ if (release) {
128
+ try {
129
+ await release();
130
+ }
131
+ catch {
132
+ // Lock already released or file deleted — safe to ignore
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Shared component version state file management.
140
+ *
141
+ * @remarks
142
+ * Each `ComponentWriter` cycle writes its component's entry to
143
+ * `{coreConfigDir}/component-versions.json`. The Platform Handlebars
144
+ * template reads this file to populate ALL rows in the service health
145
+ * table, not just the calling component's.
146
+ */
147
+ /**
148
+ * Read the component versions state file.
149
+ *
150
+ * @param coreConfigDir - Path to the core config directory.
151
+ * @returns The parsed state, or an empty object if the file doesn't exist.
152
+ */
153
+ function readComponentVersions(coreConfigDir) {
154
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
155
+ if (!existsSync(filePath))
156
+ return {};
157
+ try {
158
+ const raw = readFileSync(filePath, 'utf-8');
159
+ return JSON.parse(raw);
160
+ }
161
+ catch {
162
+ return {};
163
+ }
164
+ }
165
+ /**
166
+ * Write a component's version entry to the shared state file.
167
+ *
168
+ * @remarks
169
+ * Reads the existing file, merges the new entry, and writes atomically.
170
+ *
171
+ * @param coreConfigDir - Path to the core config directory.
172
+ * @param options - Component version data to write.
173
+ */
174
+ function writeComponentVersion(coreConfigDir, options) {
175
+ const existing = readComponentVersions(coreConfigDir);
176
+ existing[options.componentName] = {
177
+ serviceVersion: options.serviceVersion,
178
+ pluginVersion: options.pluginVersion,
179
+ servicePackage: options.servicePackage,
180
+ pluginPackage: options.pluginPackage,
181
+ updatedAt: new Date().toISOString(),
182
+ };
183
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
184
+ const dir = dirname(filePath);
185
+ if (!existsSync(dir)) {
186
+ mkdirSync(dir, { recursive: true });
187
+ }
188
+ atomicWrite(filePath, JSON.stringify(existing, null, 2) + '\n');
189
+ }
10
190
 
11
191
  /**
12
192
  * Comment markers for managed content blocks.
@@ -57,29 +237,6 @@ const STALENESS_THRESHOLD_MS = 5 * 60 * 1000;
57
237
  /** Warning text prepended inside managed block when cleanup is needed. */
58
238
  const CLEANUP_FLAG = '> ⚠️ CLEANUP NEEDED: Orphaned Jeeves content may exist below this managed section. Review everything after the END marker and remove any content that duplicates what appears above.';
59
239
 
60
- /**
61
- * Directory and file path conventions for the Jeeves platform.
62
- */
63
- /** Core config directory name within the config root. */
64
- const CORE_CONFIG_DIR = 'jeeves-core';
65
- /** Prefix for component config directories: `jeeves-{name}`. */
66
- const COMPONENT_CONFIG_PREFIX = 'jeeves-';
67
- /** Default workspace file names. */
68
- const WORKSPACE_FILES = {
69
- /** TOOLS.md — live platform state and component sections. */
70
- tools: 'TOOLS.md',
71
- /** SOUL.md — professional discipline and behavioral foundations. */
72
- soul: 'SOUL.md',
73
- /** AGENTS.md — operational protocols and memory architecture. */
74
- agents: 'AGENTS.md',
75
- };
76
- /** Templates directory name within core config. */
77
- const TEMPLATES_DIR = 'templates';
78
- /** Registry cache file name. */
79
- const REGISTRY_CACHE_FILE = 'registry-cache.json';
80
- /** Core config file name. */
81
- const CONFIG_FILE = 'config.json';
82
-
83
240
  /**
84
241
  * Default port assignments for Jeeves platform services.
85
242
  *
@@ -142,14 +299,14 @@ const SECTION_ORDER = [
142
299
  * Core library version, inlined at build time.
143
300
  *
144
301
  * @remarks
145
- * The `0.1.5` placeholder is replaced by
302
+ * The `0.1.6` placeholder is replaced by
146
303
  * `@rollup/plugin-replace` during the build with the actual version
147
304
  * from `package.json`. This ensures the correct version survives
148
305
  * when consumers bundle core into their own dist (where runtime
149
306
  * `import.meta.url`-based resolution would find the wrong package.json).
150
307
  */
151
308
  /** The core library version from package.json (inlined at build time). */
152
- const CORE_VERSION = '0.1.5';
309
+ const CORE_VERSION = '0.1.6';
153
310
 
154
311
  /**
155
312
  * Workspace and config root initialization.
@@ -487,10 +644,6 @@ function shouldWrite(myVersion, existing, stalenessThresholdMs = STALENESS_THRES
487
644
  *
488
645
  * Provides file-level locking, version-stamp convergence, and atomic writes.
489
646
  */
490
- /** Default core version when none provided. */
491
- const DEFAULT_VERSION = '0.0.0';
492
- /** Stale lock threshold in ms (2 minutes). */
493
- const STALE_LOCK_MS = 120_000;
494
647
  /**
495
648
  * Update a managed section in a file.
496
649
  *
@@ -499,7 +652,7 @@ const STALE_LOCK_MS = 120_000;
499
652
  * @param options - Write mode and optional configuration.
500
653
  */
501
654
  async function updateManagedSection(filePath, content, options = {}) {
502
- const { mode = 'block', sectionId, markers = TOOLS_MARKERS, coreVersion = DEFAULT_VERSION, stalenessThresholdMs, } = options;
655
+ const { mode = 'block', sectionId, markers = TOOLS_MARKERS, coreVersion = DEFAULT_CORE_VERSION, stalenessThresholdMs, } = options;
503
656
  if (mode === 'section' && !sectionId) {
504
657
  throw new Error('sectionId is required when mode is "section"');
505
658
  }
@@ -511,93 +664,77 @@ async function updateManagedSection(filePath, content, options = {}) {
511
664
  if (!existsSync(filePath)) {
512
665
  writeFileSync(filePath, '', 'utf-8');
513
666
  }
514
- let release;
515
667
  try {
516
- release = await lock(filePath, {
517
- stale: STALE_LOCK_MS,
518
- retries: { retries: 5, minTimeout: 100, maxTimeout: 1000 },
519
- });
520
- const fileContent = readFileSync(filePath, 'utf-8');
521
- const parsed = parseManaged(fileContent, markers);
522
- // Version-stamp convergence check (block mode only).
523
- // In section mode, components always write their own sections — the version
524
- // stamp governs shared content convergence, not component-specific sections.
525
- if (mode === 'block' &&
526
- !shouldWrite(coreVersion, parsed.versionStamp, stalenessThresholdMs)) {
527
- return;
528
- }
529
- let newManagedBody;
530
- if (mode === 'block') {
531
- // Prepend H1 title if markers specify one
532
- newManagedBody = markers.title
533
- ? `# ${markers.title}\n\n${content}`
534
- : content;
535
- }
536
- else {
537
- // Section mode: upsert the named section
538
- const sections = [...parsed.sections];
539
- const existingIdx = sections.findIndex((s) => s.id === sectionId);
540
- if (existingIdx >= 0) {
541
- sections[existingIdx] = { id: sectionId, content };
668
+ await withFileLock(filePath, () => {
669
+ const fileContent = readFileSync(filePath, 'utf-8');
670
+ const parsed = parseManaged(fileContent, markers);
671
+ // Version-stamp convergence check (block mode only).
672
+ // In section mode, components always write their own sections — the version
673
+ // stamp governs shared content convergence, not component-specific sections.
674
+ if (mode === 'block' &&
675
+ !shouldWrite(coreVersion, parsed.versionStamp, stalenessThresholdMs)) {
676
+ return;
677
+ }
678
+ let newManagedBody;
679
+ if (mode === 'block') {
680
+ // Prepend H1 title if markers specify one
681
+ newManagedBody = markers.title
682
+ ? `# ${markers.title}\n\n${content}`
683
+ : content;
542
684
  }
543
685
  else {
544
- sections.push({ id: sectionId, content });
686
+ // Section mode: upsert the named section
687
+ const sections = [...parsed.sections];
688
+ const existingIdx = sections.findIndex((s) => s.id === sectionId);
689
+ if (existingIdx >= 0) {
690
+ sections[existingIdx] = { id: sectionId, content };
691
+ }
692
+ else {
693
+ sections.push({ id: sectionId, content });
694
+ }
695
+ sortSectionsByOrder(sections);
696
+ const sectionText = sections
697
+ .map((s) => `## ${s.id}\n\n${s.content}`)
698
+ .join('\n\n');
699
+ // Prepend H1 title if markers specify one
700
+ newManagedBody = markers.title
701
+ ? `# ${markers.title}\n\n${sectionText}`
702
+ : sectionText;
703
+ }
704
+ // Cleanup detection
705
+ const userContent = parsed.userContent;
706
+ const cleanupNeeded = needsCleanup(newManagedBody, userContent);
707
+ // Build the full managed block
708
+ const beginLine = formatBeginMarker(markers.begin, coreVersion);
709
+ const endLine = formatEndMarker(markers.end);
710
+ const parts = [];
711
+ if (parsed.beforeContent) {
712
+ parts.push(parsed.beforeContent);
713
+ parts.push('');
714
+ }
715
+ parts.push(beginLine);
716
+ if (cleanupNeeded) {
717
+ parts.push('');
718
+ parts.push(CLEANUP_FLAG);
545
719
  }
546
- sortSectionsByOrder(sections);
547
- const sectionText = sections
548
- .map((s) => `## ${s.id}\n\n${s.content}`)
549
- .join('\n\n');
550
- // Prepend H1 title if markers specify one (e.g., "# Jeeves Platform Tools")
551
- newManagedBody = markers.title
552
- ? `# ${markers.title}\n\n${sectionText}`
553
- : sectionText;
554
- }
555
- // Cleanup detection
556
- const userContent = parsed.userContent;
557
- const cleanupNeeded = needsCleanup(newManagedBody, userContent);
558
- // Build the full managed block
559
- const beginLine = formatBeginMarker(markers.begin, coreVersion);
560
- const endLine = formatEndMarker(markers.end);
561
- const parts = [];
562
- if (parsed.beforeContent) {
563
- parts.push(parsed.beforeContent);
564
720
  parts.push('');
565
- }
566
- parts.push(beginLine);
567
- if (cleanupNeeded) {
721
+ parts.push(newManagedBody);
568
722
  parts.push('');
569
- parts.push(CLEANUP_FLAG);
570
- }
571
- parts.push('');
572
- parts.push(newManagedBody);
573
- parts.push('');
574
- parts.push(endLine);
575
- if (userContent) {
723
+ parts.push(endLine);
724
+ if (userContent) {
725
+ parts.push('');
726
+ parts.push(userContent);
727
+ }
576
728
  parts.push('');
577
- parts.push(userContent);
578
- }
579
- parts.push('');
580
- const newFileContent = parts.join('\n');
581
- // Atomic write: write to temp file, then rename
582
- const tempPath = join(dir, `.${String(Date.now())}.tmp`);
583
- writeFileSync(tempPath, newFileContent, 'utf-8');
584
- renameSync(tempPath, filePath);
729
+ const newFileContent = parts.join('\n');
730
+ atomicWrite(filePath, newFileContent);
731
+ });
585
732
  }
586
733
  catch (err) {
587
734
  // Log warning but don't throw — writer cycles are periodic
588
735
  const message = err instanceof Error ? err.message : String(err);
589
736
  console.warn(`jeeves-core: updateManagedSection failed for ${filePath}: ${message}`);
590
737
  }
591
- finally {
592
- if (release) {
593
- try {
594
- await release();
595
- }
596
- catch {
597
- // Lock already released or file deleted — safe to ignore
598
- }
599
- }
600
- }
601
738
  }
602
739
 
603
740
  var agentsSectionContent = `## Memory Architecture
@@ -1234,6 +1371,63 @@ function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
1234
1371
  }
1235
1372
  }
1236
1373
 
1374
+ /**
1375
+ * Build enriched service rows for the Platform template.
1376
+ *
1377
+ * @remarks
1378
+ * Merges health probe results with component version state and
1379
+ * npm registry update availability into rows for the Handlebars
1380
+ * Platform template.
1381
+ */
1382
+ /**
1383
+ * Check whether an available version is newer than the current one.
1384
+ *
1385
+ * @param available - Registry version string.
1386
+ * @param current - Currently installed version string.
1387
+ * @returns The available version if it's newer, otherwise undefined.
1388
+ */
1389
+ function newerVersion(available, current) {
1390
+ if (!available ||
1391
+ !current ||
1392
+ !semver.valid(available) ||
1393
+ !semver.valid(current)) {
1394
+ return undefined;
1395
+ }
1396
+ return semver.gt(available, current) ? available : undefined;
1397
+ }
1398
+ /**
1399
+ * Build enriched service rows for the Platform Handlebars template.
1400
+ *
1401
+ * @param options - Probe results, version state, and configuration.
1402
+ * @returns Array of enriched service rows.
1403
+ */
1404
+ function buildServiceRows(options) {
1405
+ const { probeResults, componentVersions, cacheDir, skipRegistryCheck } = options;
1406
+ return probeResults.map((r) => {
1407
+ const entry = componentVersions[r.name];
1408
+ if (!entry)
1409
+ return { ...r };
1410
+ let availableServiceVersion;
1411
+ let availablePluginVersion;
1412
+ if (!skipRegistryCheck) {
1413
+ if (entry.servicePackage) {
1414
+ const registryVersion = checkRegistryVersion(entry.servicePackage, cacheDir);
1415
+ availableServiceVersion = newerVersion(registryVersion, r.version);
1416
+ }
1417
+ if (entry.pluginPackage && entry.pluginVersion) {
1418
+ const registryVersion = checkRegistryVersion(entry.pluginPackage, cacheDir);
1419
+ availablePluginVersion = newerVersion(registryVersion, entry.pluginVersion);
1420
+ }
1421
+ }
1422
+ return {
1423
+ ...r,
1424
+ pluginVersion: entry.pluginVersion,
1425
+ availableServiceVersion,
1426
+ availablePluginVersion,
1427
+ };
1428
+ });
1429
+ }
1430
+
1237
1431
  /**
1238
1432
  * Internal function to maintain SOUL.md, AGENTS.md, and TOOLS.md Platform section.
1239
1433
  *
@@ -1287,15 +1481,28 @@ function copyTemplates(coreConfigDir) {
1287
1481
  }
1288
1482
  /** Whether Handlebars helpers have been registered. */
1289
1483
  let helpersRegistered = false;
1290
- /**
1291
- * Register Handlebars helpers used in the Platform template.
1292
- */
1484
+ /** Register Handlebars helpers used in the Platform template. */
1293
1485
  function registerHelpers() {
1294
1486
  if (helpersRegistered)
1295
1487
  return;
1296
1488
  helpersRegistered = true;
1297
1489
  Handlebars.registerHelper('gt', (a, b) => typeof a === 'number' && typeof b === 'number' && a > b);
1298
1490
  }
1491
+ /**
1492
+ * Check if a newer core version is available on npm.
1493
+ *
1494
+ * @returns The newer version string, or undefined.
1495
+ */
1496
+ function checkCoreUpdate(coreVersion, cacheDir) {
1497
+ const registryVersion = checkRegistryVersion('@karmaniverous/jeeves', cacheDir);
1498
+ if (registryVersion &&
1499
+ semver.valid(registryVersion) &&
1500
+ semver.valid(coreVersion) &&
1501
+ semver.gt(registryVersion, coreVersion)) {
1502
+ return registryVersion;
1503
+ }
1504
+ return undefined;
1505
+ }
1299
1506
  /**
1300
1507
  * Refresh platform content: SOUL.md, AGENTS.md, and TOOLS.md Platform section.
1301
1508
  *
@@ -1307,55 +1514,46 @@ async function refreshPlatformContent(options) {
1307
1514
  const coreConfigDir = getCoreConfigDir();
1308
1515
  // 1. Probe all services
1309
1516
  const probeResults = await probeAllServices(undefined, probeTimeoutMs);
1310
- const unhealthyServices = probeResults.filter((r) => !r.healthy);
1311
- // 2. Registry version checks
1517
+ // 2. Write calling component's version entry (with serviceVersion from probe)
1518
+ if (componentName) {
1519
+ const callerProbe = probeResults.find((r) => r.name === componentName);
1520
+ writeComponentVersion(coreConfigDir, {
1521
+ componentName,
1522
+ serviceVersion: callerProbe?.version,
1523
+ pluginVersion: componentVersion,
1524
+ servicePackage,
1525
+ pluginPackage,
1526
+ });
1527
+ }
1528
+ // 3. Read all component versions from the shared state file
1529
+ const componentVersions = readComponentVersions(coreConfigDir);
1530
+ // 4. Build enriched service rows with registry checks
1312
1531
  const cacheDir = componentName
1313
1532
  ? getComponentConfigDir(componentName)
1314
1533
  : coreConfigDir;
1315
- let availableCoreVersion;
1316
- let availableServiceVersion;
1317
- let availablePluginVersion;
1318
- if (!skipRegistryCheck) {
1319
- const coreRegistryVersion = checkRegistryVersion('@karmaniverous/jeeves', cacheDir);
1320
- if (coreRegistryVersion && coreRegistryVersion !== coreVersion) {
1321
- availableCoreVersion = coreRegistryVersion;
1322
- }
1323
- if (servicePackage) {
1324
- const svcVersion = checkRegistryVersion(servicePackage, cacheDir);
1325
- if (svcVersion) {
1326
- availableServiceVersion = svcVersion;
1327
- }
1328
- }
1329
- if (pluginPackage) {
1330
- const plgVersion = checkRegistryVersion(pluginPackage, cacheDir);
1331
- if (plgVersion) {
1332
- availablePluginVersion = plgVersion;
1333
- }
1334
- }
1335
- }
1336
- // 3. Build enriched service rows — match the calling component by name
1337
- const serviceRows = probeResults.map((r) => ({
1338
- ...r,
1339
- pluginVersion: r.name === componentName ? componentVersion : undefined,
1340
- availableServiceVersion: r.name === componentName ? availableServiceVersion : undefined,
1341
- availablePluginVersion: r.name === componentName ? availablePluginVersion : undefined,
1342
- }));
1343
- // 5. Check if templates are available
1534
+ const availableCoreVersion = skipRegistryCheck
1535
+ ? undefined
1536
+ : checkCoreUpdate(coreVersion, cacheDir);
1537
+ const serviceRows = buildServiceRows({
1538
+ probeResults,
1539
+ componentVersions,
1540
+ cacheDir,
1541
+ skipRegistryCheck,
1542
+ });
1543
+ // 5. Render Platform template
1344
1544
  const templatePath = join(coreConfigDir, TEMPLATES_DIR);
1345
- const templatesAvailable = existsSync(templatePath);
1346
- // 6. Render Platform template
1347
1545
  registerHelpers();
1348
1546
  const template = Handlebars.compile(toolsPlatformTemplate);
1349
1547
  const templateData = {
1350
1548
  services: serviceRows,
1351
- unhealthyServices,
1549
+ unhealthyServices: serviceRows.filter((r) => !r.healthy),
1352
1550
  coreVersion,
1353
1551
  availableCoreVersion,
1354
- templatesAvailable,
1552
+ templatesAvailable: existsSync(templatePath),
1355
1553
  templatePath,
1356
1554
  };
1357
1555
  const platformContent = template(templateData);
1358
- // 7. Write TOOLS.md Platform section
1556
+ // 6. Write TOOLS.md Platform section
1359
1557
  const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
1360
1558
  await updateManagedSection(toolsPath, platformContent, {
1361
1559
  mode: 'section',
@@ -1364,7 +1562,7 @@ async function refreshPlatformContent(options) {
1364
1562
  coreVersion,
1365
1563
  stalenessThresholdMs,
1366
1564
  });
1367
- // 8. Write SOUL.md managed block
1565
+ // 7. Write SOUL.md managed block
1368
1566
  const soulPath = join(workspacePath, WORKSPACE_FILES.soul);
1369
1567
  await updateManagedSection(soulPath, soulSectionContent, {
1370
1568
  mode: 'block',
@@ -1372,7 +1570,7 @@ async function refreshPlatformContent(options) {
1372
1570
  coreVersion,
1373
1571
  stalenessThresholdMs,
1374
1572
  });
1375
- // 9. Write AGENTS.md managed block
1573
+ // 8. Write AGENTS.md managed block
1376
1574
  const agentsPath = join(workspacePath, WORKSPACE_FILES.agents);
1377
1575
  await updateManagedSection(agentsPath, agentsSectionContent, {
1378
1576
  mode: 'block',
@@ -1380,7 +1578,7 @@ async function refreshPlatformContent(options) {
1380
1578
  coreVersion,
1381
1579
  stalenessThresholdMs,
1382
1580
  });
1383
- // 10. Copy templates to config dir
1581
+ // 9. Copy templates to config dir
1384
1582
  copyTemplates(coreConfigDir);
1385
1583
  }
1386
1584
 
@@ -1460,6 +1658,8 @@ class ComponentWriter {
1460
1658
  coreVersion: CORE_VERSION,
1461
1659
  });
1462
1660
  // Platform content maintenance: SOUL.md, AGENTS.md, Platform section
1661
+ // refreshPlatformContent also writes the component version entry
1662
+ // (with serviceVersion from probe) to the shared state file.
1463
1663
  await refreshPlatformContent({
1464
1664
  coreVersion: CORE_VERSION,
1465
1665
  componentName: this.component.name,
@@ -1614,41 +1814,150 @@ function createComponentWriter(component, options) {
1614
1814
  }
1615
1815
 
1616
1816
  /**
1617
- * Resolve the OpenClaw workspace root from the plugin API.
1817
+ * Plugin resolution helpers for the OpenClaw plugin SDK.
1618
1818
  *
1619
1819
  * @remarks
1620
- * Tries three sources in order:
1621
- * 1. `api.config.agents.defaults.workspace` — explicit config (most authoritative)
1622
- * 2. `api.resolvePath('.')` — gateway-provided path resolver
1623
- * 3. `process.cwd()` — last resort (unsafe when gateway runs from system32)
1624
- *
1625
- * The config value is checked first because `api.resolvePath('.')` delegates
1626
- * to `path.resolve('.')`, which returns `process.cwd()` — not the workspace.
1627
- * When the gateway runs as a Windows service from `C:\Windows\system32`,
1628
- * `resolvePath('.')` returns system32, not the configured workspace.
1629
- *
1630
- * Plugins should call this once at registration time and pass the result
1631
- * to `init({ workspacePath })`.
1820
+ * Provides workspace path resolution and plugin setting resolution
1821
+ * with a standard three-step fallback chain:
1822
+ * plugin config → environment variable → default value.
1632
1823
  */
1633
1824
  /**
1634
1825
  * Resolve the workspace root from the OpenClaw plugin API.
1635
1826
  *
1636
- * @param api - The plugin API object provided by the gateway at registration.
1827
+ * @remarks
1828
+ * Tries three sources in order:
1829
+ * 1. `api.config.agents.defaults.workspace` — explicit config
1830
+ * 2. `api.resolvePath('.')` — gateway-provided path resolver
1831
+ * 3. `process.cwd()` — last resort
1832
+ *
1833
+ * @param api - The plugin API object provided by the gateway.
1637
1834
  * @returns Absolute path to the workspace root.
1638
1835
  */
1639
1836
  function resolveWorkspacePath(api) {
1640
- // 1. Explicit config value (most authoritative)
1641
1837
  const configured = api.config?.agents?.defaults?.workspace;
1642
1838
  if (typeof configured === 'string' && configured.trim()) {
1643
1839
  return configured;
1644
1840
  }
1645
- // 2. Gateway-provided path resolver
1646
1841
  if (typeof api.resolvePath === 'function') {
1647
1842
  return api.resolvePath('.');
1648
1843
  }
1649
- // 3. Last resort — unsafe when gateway runs from system32
1650
1844
  return process.cwd();
1651
1845
  }
1846
+ /**
1847
+ * Resolve a plugin setting via the standard three-step fallback chain:
1848
+ * plugin config → environment variable → fallback value.
1849
+ *
1850
+ * @param api - Plugin API object.
1851
+ * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
1852
+ * @param key - Config key within the plugin's config object.
1853
+ * @param envVar - Environment variable name.
1854
+ * @param fallback - Default value if neither source provides one.
1855
+ * @returns The resolved setting value.
1856
+ */
1857
+ function resolvePluginSetting(api, pluginId, key, envVar, fallback) {
1858
+ const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
1859
+ if (typeof fromPlugin === 'string')
1860
+ return fromPlugin;
1861
+ const fromEnv = process.env[envVar];
1862
+ if (fromEnv)
1863
+ return fromEnv;
1864
+ return fallback;
1865
+ }
1866
+
1867
+ /**
1868
+ * Remove a managed section or entire managed block from a file.
1869
+ *
1870
+ * @remarks
1871
+ * Supports two modes:
1872
+ * - No `sectionId`: Remove the entire managed block (markers + content),
1873
+ * leaving user content intact.
1874
+ * - With `sectionId`: Remove a specific H2 section from within the
1875
+ * managed block. If it was the last section, remove the entire block.
1876
+ *
1877
+ * Provides file-level locking and atomic writes (temp file + rename).
1878
+ * Missing markers or nonexistent sections are no-ops (no error thrown).
1879
+ */
1880
+ /**
1881
+ * Remove a managed section or entire managed block from a file.
1882
+ *
1883
+ * @param filePath - Absolute path to the target file.
1884
+ * @param options - Optional section ID and custom markers.
1885
+ */
1886
+ async function removeManagedSection(filePath, options = {}) {
1887
+ const { sectionId, markers = TOOLS_MARKERS } = options;
1888
+ if (!existsSync(filePath))
1889
+ return;
1890
+ await withFileLock(filePath, () => {
1891
+ const fileContent = readFileSync(filePath, 'utf-8');
1892
+ const parsed = parseManaged(fileContent, markers);
1893
+ if (!parsed.found)
1894
+ return;
1895
+ let newContent;
1896
+ if (!sectionId) {
1897
+ // Remove entire managed block
1898
+ newContent = buildWithoutBlock(parsed.beforeContent, parsed.userContent);
1899
+ }
1900
+ else {
1901
+ // Remove specific section
1902
+ const remaining = parsed.sections.filter((s) => s.id !== sectionId);
1903
+ if (remaining.length === parsed.sections.length) {
1904
+ // Section not found — no-op
1905
+ return;
1906
+ }
1907
+ if (remaining.length === 0) {
1908
+ // Last section removed — remove entire block
1909
+ newContent = buildWithoutBlock(parsed.beforeContent, parsed.userContent);
1910
+ }
1911
+ else {
1912
+ // Rebuild managed block without the removed section
1913
+ newContent = buildWithSections(parsed.beforeContent, parsed.userContent, remaining, markers, parsed.versionStamp?.version);
1914
+ }
1915
+ }
1916
+ atomicWrite(filePath, newContent);
1917
+ });
1918
+ }
1919
+ /** Build file content without the managed block. */
1920
+ function buildWithoutBlock(beforeContent, userContent) {
1921
+ const parts = [];
1922
+ if (beforeContent)
1923
+ parts.push(beforeContent);
1924
+ if (userContent) {
1925
+ if (parts.length > 0)
1926
+ parts.push('');
1927
+ parts.push(userContent);
1928
+ }
1929
+ if (parts.length === 0)
1930
+ return '';
1931
+ return parts.join('\n') + '\n';
1932
+ }
1933
+ /** Rebuild file content with remaining sections. */
1934
+ function buildWithSections(beforeContent, userContent, sections, markers, coreVersion) {
1935
+ const sorted = sortSectionsByOrder([...sections]);
1936
+ const sectionText = sorted
1937
+ .map((s) => `## ${s.id}\n\n${s.content}`)
1938
+ .join('\n\n');
1939
+ const managedBody = markers.title
1940
+ ? `# ${markers.title}\n\n${sectionText}`
1941
+ : sectionText;
1942
+ const beginLine = formatBeginMarker(markers.begin, coreVersion ?? DEFAULT_CORE_VERSION);
1943
+ const endLine = formatEndMarker(markers.end);
1944
+ const parts = [];
1945
+ if (beforeContent) {
1946
+ parts.push(beforeContent);
1947
+ parts.push('');
1948
+ }
1949
+ parts.push(beginLine);
1950
+ parts.push('');
1951
+ parts.push(managedBody);
1952
+ parts.push('');
1953
+ parts.push(endLine);
1954
+ if (userContent) {
1955
+ parts.push('');
1956
+ parts.push(userContent);
1957
+ }
1958
+ parts.push('');
1959
+ return parts.join('\n');
1960
+ }
1652
1961
 
1653
1962
  /**
1654
1963
  * One-shot content seeding used by the CLI install command.
@@ -1703,4 +2012,228 @@ async function seedContent(options) {
1703
2012
  });
1704
2013
  }
1705
2014
 
1706
- export { AGENTS_MARKERS, CLEANUP_FLAG, COMPONENT_CONFIG_PREFIX, CONFIG_FILE, CORE_CONFIG_DIR, CORE_VERSION, ComponentWriter, DEFAULT_PORTS, META_PORT, REGISTRY_CACHE_FILE, RUNNER_PORT, SECTION_IDS, SECTION_ORDER, SERVER_PORT, SOUL_MARKERS, STALENESS_THRESHOLD_MS, TEMPLATES_DIR, TOOLS_MARKERS, VERSION_STAMP_PATTERN, WATCHER_PORT, WORKSPACE_FILES, checkRegistryVersion, coreConfigSchema, createAsyncContentCache, createComponentWriter, formatBeginMarker, formatEndMarker, generateJsonSchema, getComponentConfigDir, getConfigRoot, getCoreConfigDir, getCoreConfigFile, getServiceUrl, getWorkspacePath, init, jaccard, needsCleanup, parseManaged, probeAllServices, probeService, refreshPlatformContent, resetInit, resolveWorkspacePath, seedContent, shingles, shouldWrite, updateManagedSection };
2015
+ /**
2016
+ * HTTP helpers for the OpenClaw plugin SDK.
2017
+ *
2018
+ * @remarks
2019
+ * Thin wrappers around `fetch` that throw on non-OK responses
2020
+ * and handle JSON serialisation/deserialisation.
2021
+ */
2022
+ /**
2023
+ * Fetch JSON from a URL, throwing on non-OK responses.
2024
+ *
2025
+ * @param url - URL to fetch.
2026
+ * @param init - Optional `fetch` init options.
2027
+ * @returns Parsed JSON response body.
2028
+ * @throws Error with `HTTP {status}: {body}` message on non-OK responses.
2029
+ */
2030
+ async function fetchJson(url, init) {
2031
+ const res = await fetch(url, init);
2032
+ if (!res.ok) {
2033
+ throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
2034
+ }
2035
+ return res.json();
2036
+ }
2037
+ /**
2038
+ * POST JSON to a URL and return parsed response.
2039
+ *
2040
+ * @param url - URL to POST to.
2041
+ * @param body - Request body (will be JSON-stringified).
2042
+ * @returns Parsed JSON response body.
2043
+ */
2044
+ async function postJson(url, body) {
2045
+ return fetchJson(url, {
2046
+ method: 'POST',
2047
+ headers: { 'Content-Type': 'application/json' },
2048
+ body: JSON.stringify(body),
2049
+ });
2050
+ }
2051
+
2052
+ /**
2053
+ * OpenClaw configuration helpers for plugin CLI installers.
2054
+ *
2055
+ * @remarks
2056
+ * Provides resolution of OpenClaw home directory and config file path,
2057
+ * plus idempotent config patching for plugin install/uninstall.
2058
+ */
2059
+ /**
2060
+ * Resolve the OpenClaw home directory.
2061
+ *
2062
+ * @remarks
2063
+ * Resolution order:
2064
+ * 1. `OPENCLAW_CONFIG` env var → dirname of the config file path
2065
+ * 2. `OPENCLAW_HOME` env var → resolved path
2066
+ * 3. Default: `~/.openclaw`
2067
+ *
2068
+ * @returns Absolute path to the OpenClaw home directory.
2069
+ */
2070
+ function resolveOpenClawHome() {
2071
+ if (process.env.OPENCLAW_CONFIG) {
2072
+ return dirname(resolve(process.env.OPENCLAW_CONFIG));
2073
+ }
2074
+ if (process.env.OPENCLAW_HOME) {
2075
+ return resolve(process.env.OPENCLAW_HOME);
2076
+ }
2077
+ return join(homedir(), '.openclaw');
2078
+ }
2079
+ /**
2080
+ * Resolve the OpenClaw config file path.
2081
+ *
2082
+ * @remarks
2083
+ * If `OPENCLAW_CONFIG` is set, uses that directly.
2084
+ * Otherwise defaults to `{home}/openclaw.json`.
2085
+ *
2086
+ * @param home - The OpenClaw home directory.
2087
+ * @returns Absolute path to the config file.
2088
+ */
2089
+ function resolveConfigPath(home) {
2090
+ if (process.env.OPENCLAW_CONFIG) {
2091
+ return resolve(process.env.OPENCLAW_CONFIG);
2092
+ }
2093
+ return join(home, 'openclaw.json');
2094
+ }
2095
+ /**
2096
+ * Patch an allowlist array: add or remove the plugin ID.
2097
+ *
2098
+ * @returns A log message if a change was made, or undefined.
2099
+ */
2100
+ function patchAllowList(parent, key, label, pluginId, mode) {
2101
+ if (mode === 'add') {
2102
+ if (!Array.isArray(parent[key])) {
2103
+ parent[key] = [pluginId];
2104
+ return `Created ${label} with "${pluginId}"`;
2105
+ }
2106
+ const list = parent[key];
2107
+ if (!list.includes(pluginId)) {
2108
+ list.push(pluginId);
2109
+ return `Added "${pluginId}" to ${label}`;
2110
+ }
2111
+ }
2112
+ else {
2113
+ if (!Array.isArray(parent[key]))
2114
+ return undefined;
2115
+ const list = parent[key];
2116
+ const filtered = list.filter((id) => id !== pluginId);
2117
+ if (filtered.length !== list.length) {
2118
+ parent[key] = filtered;
2119
+ return `Removed "${pluginId}" from ${label}`;
2120
+ }
2121
+ }
2122
+ return undefined;
2123
+ }
2124
+ /**
2125
+ * Patch an OpenClaw config for plugin install or uninstall.
2126
+ *
2127
+ * @remarks
2128
+ * Manages `plugins.entries.{pluginId}` and `tools.alsoAllow`.
2129
+ * Idempotent: adding twice produces no duplicates; removing when absent
2130
+ * produces no errors.
2131
+ *
2132
+ * @param config - The parsed OpenClaw config object (mutated in place).
2133
+ * @param pluginId - The plugin identifier.
2134
+ * @param mode - Whether to add or remove the plugin.
2135
+ * @returns Array of log messages describing changes made.
2136
+ */
2137
+ function patchConfig(config, pluginId, mode) {
2138
+ const messages = [];
2139
+ // Ensure plugins section
2140
+ if (!config.plugins || typeof config.plugins !== 'object') {
2141
+ config.plugins = {};
2142
+ }
2143
+ const plugins = config.plugins;
2144
+ // plugins.entries
2145
+ if (!plugins.entries || typeof plugins.entries !== 'object') {
2146
+ plugins.entries = {};
2147
+ }
2148
+ const entries = plugins.entries;
2149
+ if (mode === 'add') {
2150
+ if (!entries[pluginId]) {
2151
+ entries[pluginId] = { enabled: true };
2152
+ messages.push(`Added "${pluginId}" to plugins.entries`);
2153
+ }
2154
+ }
2155
+ else if (pluginId in entries) {
2156
+ Reflect.deleteProperty(entries, pluginId);
2157
+ messages.push(`Removed "${pluginId}" from plugins.entries`);
2158
+ }
2159
+ // tools.alsoAllow
2160
+ if (!config.tools || typeof config.tools !== 'object') {
2161
+ config.tools = {};
2162
+ }
2163
+ const tools = config.tools;
2164
+ const toolAlsoAllow = patchAllowList(tools, 'alsoAllow', 'tools.alsoAllow', pluginId, mode);
2165
+ if (toolAlsoAllow)
2166
+ messages.push(toolAlsoAllow);
2167
+ return messages;
2168
+ }
2169
+
2170
+ /**
2171
+ * Tool result formatters for the OpenClaw plugin SDK.
2172
+ *
2173
+ * @remarks
2174
+ * Provides standardised helpers for building `ToolResult` objects:
2175
+ * success, error, and connection-error variants.
2176
+ */
2177
+ /**
2178
+ * Format a successful tool result.
2179
+ *
2180
+ * @param data - Arbitrary data to return as JSON.
2181
+ * @returns A `ToolResult` with JSON-stringified content.
2182
+ */
2183
+ function ok(data) {
2184
+ return {
2185
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
2186
+ };
2187
+ }
2188
+ /**
2189
+ * Format an error tool result.
2190
+ *
2191
+ * @param error - Error instance, string, or other value.
2192
+ * @returns A `ToolResult` with `isError: true`.
2193
+ */
2194
+ function fail(error) {
2195
+ const message = error instanceof Error ? error.message : String(error);
2196
+ return {
2197
+ content: [{ type: 'text', text: 'Error: ' + message }],
2198
+ isError: true,
2199
+ };
2200
+ }
2201
+ /**
2202
+ * Format a connection error with actionable guidance.
2203
+ *
2204
+ * @remarks
2205
+ * Detects `ECONNREFUSED`, `ENOTFOUND`, and `ETIMEDOUT` from
2206
+ * `error.cause.code` and returns a user-friendly message referencing
2207
+ * the plugin's `config.apiUrl` setting. Falls back to `fail()` for
2208
+ * non-connection errors.
2209
+ *
2210
+ * @param error - Error instance (typically from `fetch`).
2211
+ * @param baseUrl - The URL that was being contacted.
2212
+ * @param pluginId - The plugin identifier for config guidance.
2213
+ * @returns A `ToolResult` with `isError: true`.
2214
+ */
2215
+ function connectionFail(error, baseUrl, pluginId) {
2216
+ const cause = error instanceof Error ? error.cause : undefined;
2217
+ const code = cause && typeof cause === 'object' && 'code' in cause
2218
+ ? String(cause.code)
2219
+ : '';
2220
+ const isConnectionError = code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT';
2221
+ if (isConnectionError) {
2222
+ return {
2223
+ content: [
2224
+ {
2225
+ type: 'text',
2226
+ text: [
2227
+ `Service not reachable at ${baseUrl}.`,
2228
+ 'Either start the service, or if it runs on a different port,',
2229
+ `set plugins.entries.${pluginId}.config.apiUrl in openclaw.json.`,
2230
+ ].join('\n'),
2231
+ },
2232
+ ],
2233
+ isError: true,
2234
+ };
2235
+ }
2236
+ return fail(error);
2237
+ }
2238
+
2239
+ 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, formatBeginMarker, formatEndMarker, generateJsonSchema, getComponentConfigDir, getConfigRoot, getCoreConfigDir, getCoreConfigFile, getServiceUrl, getWorkspacePath, init, jaccard, needsCleanup, ok, parseManaged, patchConfig, postJson, probeAllServices, probeService, readComponentVersions, refreshPlatformContent, removeManagedSection, resetInit, resolveConfigPath, resolveOpenClawHome, resolvePluginSetting, resolveWorkspacePath, seedContent, shingles, shouldWrite, updateManagedSection, withFileLock, writeComponentVersion };