@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/README.md CHANGED
@@ -16,7 +16,7 @@ That's it. I handle the rest.
16
16
 
17
17
  ## Who I Am
18
18
 
19
- My name is Jeeves.
19
+ My name is Jeeves.
20
20
 
21
21
  I add *identity* to OpenClaw: professional discipline, operational protocols, and a suite of services for data-wrangling, indexing, synthesis, and presentation.
22
22
 
@@ -60,19 +60,87 @@ I coordinate four service components. Each has its own repo, service, and OpenCl
60
60
  | [jeeves-runner](https://github.com/karmaniverous/jeeves-runner) | 1937 | Turing's paper in the *Proceedings* (1937) | Scheduled jobs, zero-LLM-cost scripts |
61
61
  | [jeeves-meta](https://github.com/karmaniverous/jeeves-meta) | 1938 | Shannon's switching circuits thesis (1938) | Three-step LLM synthesis |
62
62
 
63
- This package (`@karmaniverous/jeeves`) is the substrate they all share: managed workspace content, service discovery, config resolution, version-stamp convergence. It's a library and CLI. No daemon, no port, no tools registered with the gateway.
63
+ This package (`@karmaniverous/jeeves`) is the substrate they all share: managed workspace content, service discovery, config resolution, version-stamp convergence, and a Plugin SDK for building component plugins. It's a library and CLI. No daemon, no port, no tools registered with the gateway.
64
64
 
65
- ## For Platform Developers
65
+ ## Plugin SDK
66
66
 
67
- If you're building a component plugin, you implement one interface and call one factory:
67
+ The Plugin SDK (`src/plugin/`) provides canonical types and utilities for building OpenClaw plugins that integrate with the Jeeves platform.
68
+
69
+ ### Core Types
70
+
71
+ - **`PluginApi`** — the shape of the `api` object the OpenClaw gateway passes to plugins at registration time. Provides `config`, `resolvePath()`, and `registerTool()`.
72
+ - **`ToolResult`** — result shape returned by tool executions: an array of content blocks plus an optional `isError` flag.
73
+ - **`ToolDescriptor`** — tool definition for registration: `name`, `description`, `parameters` (JSON Schema), and an `execute` function.
74
+
75
+ ### Result Formatters
76
+
77
+ - **`ok(data)`** — wraps arbitrary data as a successful `ToolResult` with JSON-stringified content.
78
+ - **`fail(error)`** — wraps an error into a `ToolResult` with `isError: true`.
79
+ - **`connectionFail(error, baseUrl, pluginId)`** — detects `ECONNREFUSED`, `ENOTFOUND`, and `ETIMEDOUT` from `error.cause.code` and returns a user-friendly message referencing the plugin's `config.apiUrl` setting. Falls back to `fail()` for non-connection errors.
80
+
81
+ ### HTTP Helpers
82
+
83
+ - **`fetchJson(url, init?)`** — thin wrapper around `fetch` that throws on non-OK responses and returns parsed JSON.
84
+ - **`postJson(url, body)`** — POST JSON to a URL and return parsed response.
85
+
86
+ ### Resolution Helpers
87
+
88
+ - **`resolveWorkspacePath(api)`** — resolves the workspace root from the plugin API via a three-step chain: `api.config.agents.defaults.workspace` → `api.resolvePath('.')` → `process.cwd()`.
89
+ - **`resolvePluginSetting(api, pluginId, key, envVar, fallback)`** — resolves a plugin setting via: plugin config → environment variable → fallback value.
90
+
91
+ ### OpenClaw Config Utilities
92
+
93
+ - **`resolveOpenClawHome()`** — resolves the OpenClaw home directory: `OPENCLAW_CONFIG` env (dirname) → `OPENCLAW_HOME` env → `~/.openclaw`.
94
+ - **`resolveConfigPath(home)`** — resolves the OpenClaw config file path: `OPENCLAW_CONFIG` env → `{home}/openclaw.json`.
95
+ - **`patchConfig(config, pluginId, mode)`** — idempotent config patching for plugin install/uninstall. Manages `plugins.entries.{pluginId}` and `tools.alsoAllow`.
96
+
97
+ ## Config Query Handler
98
+
99
+ The `createConfigQueryHandler(getConfig)` factory produces a transport-agnostic handler for `GET /config` endpoints. It accepts a `getConfig` callback that returns the current config object.
100
+
101
+ - No `path` parameter → returns the full config document.
102
+ - Valid JSONPath expression → returns matching results with count (powered by `jsonpath-plus`).
103
+ - Invalid JSONPath → returns a 400 error.
104
+
105
+ Component services wire this into their HTTP server to expose config for diagnostic queries.
106
+
107
+ ## Managed Content System
108
+
109
+ The managed content system maintains SOUL.md, AGENTS.md, and TOOLS.md without destroying user-authored content.
110
+
111
+ ### Key Functions
112
+
113
+ - **`updateManagedSection(filePath, content, options)`** — writes managed content in either block mode (replaces entire managed block) or section mode (upserts a named H2 section within the block). Handles file locking, version-stamp convergence, cleanup detection, and atomic writes.
114
+ - **`removeManagedSection(filePath, options)`** — removes a specific section or the entire managed block. If the last section is removed, the entire block is removed.
115
+ - **`parseManaged(fileContent, markers)`** — parses a file into its managed block, version stamp, sections, and user content.
116
+ - **`atomicWrite(filePath, content)`** — writes via a temp file + rename to prevent partial writes.
117
+ - **`withFileLock(filePath, fn)`** — executes a callback while holding a file-level lock (2-minute stale threshold, 5 retries).
118
+
119
+ ### ManagedMarkers Type
120
+
121
+ ```typescript
122
+ interface ManagedMarkers {
123
+ begin: string; // BEGIN comment marker text
124
+ end: string; // END comment marker text
125
+ title?: string; // Optional H1 title prepended inside managed block
126
+ }
127
+ ```
128
+
129
+ Pre-defined marker sets: `TOOLS_MARKERS`, `SOUL_MARKERS`, `AGENTS_MARKERS`.
130
+
131
+ See the [Managed Content System](https://docs.karmanivero.us/jeeves/documents/Managed_Content_System.html) guide for the full deep-dive.
132
+
133
+ ## ComponentWriter and JeevesComponent
134
+
135
+ Component plugins implement the `JeevesComponent` interface and use `createComponentWriter()` to get a timer-based orchestrator:
68
136
 
69
137
  ```typescript
70
138
  import { init, createComponentWriter } from '@karmaniverous/jeeves';
71
139
  import type { JeevesComponent } from '@karmaniverous/jeeves';
72
140
 
73
141
  init({
74
- workspacePath: api.resolvePath('.'),
75
- configRoot: api.getConfig('configRoot'),
142
+ workspacePath: resolveWorkspacePath(api),
143
+ configRoot: resolvePluginSetting(api, pluginId, 'configRoot', 'JEEVES_CONFIG_ROOT', 'j:/config'),
76
144
  });
77
145
 
78
146
  const writer = createComponentWriter({
@@ -88,18 +156,29 @@ const writer = createComponentWriter({
88
156
  writer.start();
89
157
  ```
90
158
 
91
- The writer handles everything: your TOOLS.md section, platform content (SOUL/AGENTS/Platform), file locking, version stamps, cleanup detection.
159
+ On each cycle the writer calls `generateToolsContent()`, writes the component's TOOLS.md section, and runs `refreshPlatformContent()` to maintain SOUL.md, AGENTS.md, and the Platform section with live service health data.
160
+
161
+ The `createAsyncContentCache({ fetch, placeholder? })` utility bridges the sync `generateToolsContent` interface with async data sources — returns a sync `() => string` that serves cached content while refreshing in the background.
92
162
 
93
- See the [Building a Component Plugin](https://docs.karmanivero.us/jeeves/documents/guides_building-a-component-plugin.html) guide for the full walkthrough.
163
+ See the [Building a Component Plugin](https://docs.karmanivero.us/jeeves/documents/Building_a_Component_Plugin.html) guide for the full walkthrough.
164
+
165
+ ## Service Discovery
166
+
167
+ - **`getServiceUrl(serviceName, consumerName?)`** — resolves a service URL via: consumer config → core config → default port constants.
168
+ - **`probeService(serviceName, consumerName?, timeoutMs?)`** — probes `/status` then `/health` endpoints, returns a `ProbeResult` with health status and version.
169
+ - **`probeAllServices(consumerName?, timeoutMs?)`** — probes all known services (server, watcher, runner, meta).
170
+ - **`checkRegistryVersion(packageName, cacheDir, ttlSeconds?)`** — checks npm registry for the latest version with local file caching (default 1-hour TTL).
94
171
 
95
172
  ## CLI
96
173
 
97
174
  ```bash
98
- jeeves install # Bootstrap identity, protocols, platform content
99
- jeeves uninstall # Remove managed sections and artifacts
100
- jeeves status # Probe all service ports, report health
175
+ jeeves install # Seed identity, protocols, platform content; create core config
176
+ jeeves uninstall # Remove managed sections, templates, config schema
177
+ jeeves status # Probe all service ports, report health table
101
178
  ```
102
179
 
180
+ All three commands accept `--workspace <path>` and `--config-root <path>` options.
181
+
103
182
  ## Configuration
104
183
 
105
184
  Core config at `{configRoot}/jeeves-core/config.json`:
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  #!/usr/bin/env node
3
3
  import require$$0 from 'commander';
4
- import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, cpSync, rmSync } from 'node:fs';
4
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, cpSync, rmSync } from 'node:fs';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import Handlebars from 'handlebars';
9
9
  import { packageDirectorySync } from 'package-directory';
10
- import { execSync } from 'node:child_process';
10
+ import semver, { gte } from 'semver';
11
11
  import { lock } from 'proper-lockfile';
12
- import { gte } from 'semver';
12
+ import { execSync } from 'node:child_process';
13
13
 
14
14
  function getDefaultExportFromCjs (x) {
15
15
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
@@ -141,6 +141,8 @@ const TEMPLATES_DIR = 'templates';
141
141
  const REGISTRY_CACHE_FILE = 'registry-cache.json';
142
142
  /** Core config file name. */
143
143
  const CONFIG_FILE = 'config.json';
144
+ /** Component versions state file name. */
145
+ const COMPONENT_VERSIONS_FILE = 'component-versions.json';
144
146
 
145
147
  /**
146
148
  * Default port assignments for Jeeves platform services.
@@ -204,14 +206,14 @@ const SECTION_ORDER = [
204
206
  * Core library version, inlined at build time.
205
207
  *
206
208
  * @remarks
207
- * The `0.1.5` placeholder is replaced by
209
+ * The `0.1.6` placeholder is replaced by
208
210
  * `@rollup/plugin-replace` during the build with the actual version
209
211
  * from `package.json`. This ensures the correct version survives
210
212
  * when consumers bundle core into their own dist (where runtime
211
213
  * `import.meta.url`-based resolution would find the wrong package.json).
212
214
  */
213
215
  /** The core library version from package.json (inlined at build time). */
214
- const CORE_VERSION = '0.1.5';
216
+ const CORE_VERSION = '0.1.6';
215
217
 
216
218
  /**
217
219
  * Core configuration schema and resolution.
@@ -726,6 +728,117 @@ Read these templates when creating new specs, onboarding to new projects, or whe
726
728
  {{/if}}
727
729
  `;
728
730
 
731
+ /**
732
+ * Shared file I/O helpers for managed section operations.
733
+ *
734
+ * @remarks
735
+ * Extracts the atomic write pattern and file-level locking into
736
+ * reusable utilities, eliminating duplication between
737
+ * `updateManagedSection` and `removeManagedSection`.
738
+ */
739
+ /** Stale lock threshold in ms (2 minutes). */
740
+ const STALE_LOCK_MS = 120_000;
741
+ /** Default core version when none provided. */
742
+ const DEFAULT_CORE_VERSION = '0.0.0';
743
+ /** Lock retry options. */
744
+ const LOCK_RETRIES = { retries: 5, minTimeout: 100, maxTimeout: 1000 };
745
+ /**
746
+ * Write content to a file atomically via a temp file + rename.
747
+ *
748
+ * @param filePath - Absolute path to the target file.
749
+ * @param content - Content to write.
750
+ */
751
+ function atomicWrite(filePath, content) {
752
+ const dir = dirname(filePath);
753
+ const tempPath = join(dir, `.${String(Date.now())}.tmp`);
754
+ writeFileSync(tempPath, content, 'utf-8');
755
+ renameSync(tempPath, filePath);
756
+ }
757
+ /**
758
+ * Execute a callback while holding a file lock.
759
+ *
760
+ * @remarks
761
+ * Acquires a lock on the file, executes the callback, and releases
762
+ * the lock in a finally block. The lock uses a 2-minute stale threshold
763
+ * and retries up to 5 times.
764
+ *
765
+ * @param filePath - Absolute path to the file to lock.
766
+ * @param fn - Async callback to execute while holding the lock.
767
+ */
768
+ async function withFileLock(filePath, fn) {
769
+ let release;
770
+ try {
771
+ release = await lock(filePath, {
772
+ stale: STALE_LOCK_MS,
773
+ retries: LOCK_RETRIES,
774
+ });
775
+ await fn();
776
+ }
777
+ finally {
778
+ if (release) {
779
+ try {
780
+ await release();
781
+ }
782
+ catch {
783
+ // Lock already released or file deleted — safe to ignore
784
+ }
785
+ }
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Shared component version state file management.
791
+ *
792
+ * @remarks
793
+ * Each `ComponentWriter` cycle writes its component's entry to
794
+ * `{coreConfigDir}/component-versions.json`. The Platform Handlebars
795
+ * template reads this file to populate ALL rows in the service health
796
+ * table, not just the calling component's.
797
+ */
798
+ /**
799
+ * Read the component versions state file.
800
+ *
801
+ * @param coreConfigDir - Path to the core config directory.
802
+ * @returns The parsed state, or an empty object if the file doesn't exist.
803
+ */
804
+ function readComponentVersions(coreConfigDir) {
805
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
806
+ if (!existsSync(filePath))
807
+ return {};
808
+ try {
809
+ const raw = readFileSync(filePath, 'utf-8');
810
+ return JSON.parse(raw);
811
+ }
812
+ catch {
813
+ return {};
814
+ }
815
+ }
816
+ /**
817
+ * Write a component's version entry to the shared state file.
818
+ *
819
+ * @remarks
820
+ * Reads the existing file, merges the new entry, and writes atomically.
821
+ *
822
+ * @param coreConfigDir - Path to the core config directory.
823
+ * @param options - Component version data to write.
824
+ */
825
+ function writeComponentVersion(coreConfigDir, options) {
826
+ const existing = readComponentVersions(coreConfigDir);
827
+ existing[options.componentName] = {
828
+ serviceVersion: options.serviceVersion,
829
+ pluginVersion: options.pluginVersion,
830
+ servicePackage: options.servicePackage,
831
+ pluginPackage: options.pluginPackage,
832
+ updatedAt: new Date().toISOString(),
833
+ };
834
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
835
+ const dir = dirname(filePath);
836
+ if (!existsSync(dir)) {
837
+ mkdirSync(dir, { recursive: true });
838
+ }
839
+ atomicWrite(filePath, JSON.stringify(existing, null, 2) + '\n');
840
+ }
841
+
729
842
  /**
730
843
  * Service URL resolution.
731
844
  *
@@ -1149,10 +1262,6 @@ function shouldWrite(myVersion, existing, stalenessThresholdMs = STALENESS_THRES
1149
1262
  *
1150
1263
  * Provides file-level locking, version-stamp convergence, and atomic writes.
1151
1264
  */
1152
- /** Default core version when none provided. */
1153
- const DEFAULT_VERSION = '0.0.0';
1154
- /** Stale lock threshold in ms (2 minutes). */
1155
- const STALE_LOCK_MS = 120_000;
1156
1265
  /**
1157
1266
  * Update a managed section in a file.
1158
1267
  *
@@ -1161,7 +1270,7 @@ const STALE_LOCK_MS = 120_000;
1161
1270
  * @param options - Write mode and optional configuration.
1162
1271
  */
1163
1272
  async function updateManagedSection(filePath, content, options = {}) {
1164
- const { mode = 'block', sectionId, markers = TOOLS_MARKERS, coreVersion = DEFAULT_VERSION, stalenessThresholdMs, } = options;
1273
+ const { mode = 'block', sectionId, markers = TOOLS_MARKERS, coreVersion = DEFAULT_CORE_VERSION, stalenessThresholdMs, } = options;
1165
1274
  if (mode === 'section' && !sectionId) {
1166
1275
  throw new Error('sectionId is required when mode is "section"');
1167
1276
  }
@@ -1173,93 +1282,134 @@ async function updateManagedSection(filePath, content, options = {}) {
1173
1282
  if (!existsSync(filePath)) {
1174
1283
  writeFileSync(filePath, '', 'utf-8');
1175
1284
  }
1176
- let release;
1177
1285
  try {
1178
- release = await lock(filePath, {
1179
- stale: STALE_LOCK_MS,
1180
- retries: { retries: 5, minTimeout: 100, maxTimeout: 1000 },
1181
- });
1182
- const fileContent = readFileSync(filePath, 'utf-8');
1183
- const parsed = parseManaged(fileContent, markers);
1184
- // Version-stamp convergence check (block mode only).
1185
- // In section mode, components always write their own sections — the version
1186
- // stamp governs shared content convergence, not component-specific sections.
1187
- if (mode === 'block' &&
1188
- !shouldWrite(coreVersion, parsed.versionStamp, stalenessThresholdMs)) {
1189
- return;
1190
- }
1191
- let newManagedBody;
1192
- if (mode === 'block') {
1193
- // Prepend H1 title if markers specify one
1194
- newManagedBody = markers.title
1195
- ? `# ${markers.title}\n\n${content}`
1196
- : content;
1197
- }
1198
- else {
1199
- // Section mode: upsert the named section
1200
- const sections = [...parsed.sections];
1201
- const existingIdx = sections.findIndex((s) => s.id === sectionId);
1202
- if (existingIdx >= 0) {
1203
- sections[existingIdx] = { id: sectionId, content };
1286
+ await withFileLock(filePath, () => {
1287
+ const fileContent = readFileSync(filePath, 'utf-8');
1288
+ const parsed = parseManaged(fileContent, markers);
1289
+ // Version-stamp convergence check (block mode only).
1290
+ // In section mode, components always write their own sections — the version
1291
+ // stamp governs shared content convergence, not component-specific sections.
1292
+ if (mode === 'block' &&
1293
+ !shouldWrite(coreVersion, parsed.versionStamp, stalenessThresholdMs)) {
1294
+ return;
1295
+ }
1296
+ let newManagedBody;
1297
+ if (mode === 'block') {
1298
+ // Prepend H1 title if markers specify one
1299
+ newManagedBody = markers.title
1300
+ ? `# ${markers.title}\n\n${content}`
1301
+ : content;
1204
1302
  }
1205
1303
  else {
1206
- sections.push({ id: sectionId, content });
1304
+ // Section mode: upsert the named section
1305
+ const sections = [...parsed.sections];
1306
+ const existingIdx = sections.findIndex((s) => s.id === sectionId);
1307
+ if (existingIdx >= 0) {
1308
+ sections[existingIdx] = { id: sectionId, content };
1309
+ }
1310
+ else {
1311
+ sections.push({ id: sectionId, content });
1312
+ }
1313
+ sortSectionsByOrder(sections);
1314
+ const sectionText = sections
1315
+ .map((s) => `## ${s.id}\n\n${s.content}`)
1316
+ .join('\n\n');
1317
+ // Prepend H1 title if markers specify one
1318
+ newManagedBody = markers.title
1319
+ ? `# ${markers.title}\n\n${sectionText}`
1320
+ : sectionText;
1321
+ }
1322
+ // Cleanup detection
1323
+ const userContent = parsed.userContent;
1324
+ const cleanupNeeded = needsCleanup(newManagedBody, userContent);
1325
+ // Build the full managed block
1326
+ const beginLine = formatBeginMarker(markers.begin, coreVersion);
1327
+ const endLine = formatEndMarker(markers.end);
1328
+ const parts = [];
1329
+ if (parsed.beforeContent) {
1330
+ parts.push(parsed.beforeContent);
1331
+ parts.push('');
1332
+ }
1333
+ parts.push(beginLine);
1334
+ if (cleanupNeeded) {
1335
+ parts.push('');
1336
+ parts.push(CLEANUP_FLAG);
1207
1337
  }
1208
- sortSectionsByOrder(sections);
1209
- const sectionText = sections
1210
- .map((s) => `## ${s.id}\n\n${s.content}`)
1211
- .join('\n\n');
1212
- // Prepend H1 title if markers specify one (e.g., "# Jeeves Platform Tools")
1213
- newManagedBody = markers.title
1214
- ? `# ${markers.title}\n\n${sectionText}`
1215
- : sectionText;
1216
- }
1217
- // Cleanup detection
1218
- const userContent = parsed.userContent;
1219
- const cleanupNeeded = needsCleanup(newManagedBody, userContent);
1220
- // Build the full managed block
1221
- const beginLine = formatBeginMarker(markers.begin, coreVersion);
1222
- const endLine = formatEndMarker(markers.end);
1223
- const parts = [];
1224
- if (parsed.beforeContent) {
1225
- parts.push(parsed.beforeContent);
1226
1338
  parts.push('');
1227
- }
1228
- parts.push(beginLine);
1229
- if (cleanupNeeded) {
1339
+ parts.push(newManagedBody);
1230
1340
  parts.push('');
1231
- parts.push(CLEANUP_FLAG);
1232
- }
1233
- parts.push('');
1234
- parts.push(newManagedBody);
1235
- parts.push('');
1236
- parts.push(endLine);
1237
- if (userContent) {
1341
+ parts.push(endLine);
1342
+ if (userContent) {
1343
+ parts.push('');
1344
+ parts.push(userContent);
1345
+ }
1238
1346
  parts.push('');
1239
- parts.push(userContent);
1240
- }
1241
- parts.push('');
1242
- const newFileContent = parts.join('\n');
1243
- // Atomic write: write to temp file, then rename
1244
- const tempPath = join(dir, `.${String(Date.now())}.tmp`);
1245
- writeFileSync(tempPath, newFileContent, 'utf-8');
1246
- renameSync(tempPath, filePath);
1347
+ const newFileContent = parts.join('\n');
1348
+ atomicWrite(filePath, newFileContent);
1349
+ });
1247
1350
  }
1248
1351
  catch (err) {
1249
1352
  // Log warning but don't throw — writer cycles are periodic
1250
1353
  const message = err instanceof Error ? err.message : String(err);
1251
1354
  console.warn(`jeeves-core: updateManagedSection failed for ${filePath}: ${message}`);
1252
1355
  }
1253
- finally {
1254
- if (release) {
1255
- try {
1256
- await release();
1356
+ }
1357
+
1358
+ /**
1359
+ * Build enriched service rows for the Platform template.
1360
+ *
1361
+ * @remarks
1362
+ * Merges health probe results with component version state and
1363
+ * npm registry update availability into rows for the Handlebars
1364
+ * Platform template.
1365
+ */
1366
+ /**
1367
+ * Check whether an available version is newer than the current one.
1368
+ *
1369
+ * @param available - Registry version string.
1370
+ * @param current - Currently installed version string.
1371
+ * @returns The available version if it's newer, otherwise undefined.
1372
+ */
1373
+ function newerVersion(available, current) {
1374
+ if (!available ||
1375
+ !current ||
1376
+ !semver.valid(available) ||
1377
+ !semver.valid(current)) {
1378
+ return undefined;
1379
+ }
1380
+ return semver.gt(available, current) ? available : undefined;
1381
+ }
1382
+ /**
1383
+ * Build enriched service rows for the Platform Handlebars template.
1384
+ *
1385
+ * @param options - Probe results, version state, and configuration.
1386
+ * @returns Array of enriched service rows.
1387
+ */
1388
+ function buildServiceRows(options) {
1389
+ const { probeResults, componentVersions, cacheDir, skipRegistryCheck } = options;
1390
+ return probeResults.map((r) => {
1391
+ const entry = componentVersions[r.name];
1392
+ if (!entry)
1393
+ return { ...r };
1394
+ let availableServiceVersion;
1395
+ let availablePluginVersion;
1396
+ if (!skipRegistryCheck) {
1397
+ if (entry.servicePackage) {
1398
+ const registryVersion = checkRegistryVersion(entry.servicePackage, cacheDir);
1399
+ availableServiceVersion = newerVersion(registryVersion, r.version);
1257
1400
  }
1258
- catch {
1259
- // Lock already released or file deleted — safe to ignore
1401
+ if (entry.pluginPackage && entry.pluginVersion) {
1402
+ const registryVersion = checkRegistryVersion(entry.pluginPackage, cacheDir);
1403
+ availablePluginVersion = newerVersion(registryVersion, entry.pluginVersion);
1260
1404
  }
1261
1405
  }
1262
- }
1406
+ return {
1407
+ ...r,
1408
+ pluginVersion: entry.pluginVersion,
1409
+ availableServiceVersion,
1410
+ availablePluginVersion,
1411
+ };
1412
+ });
1263
1413
  }
1264
1414
 
1265
1415
  /**
@@ -1315,15 +1465,28 @@ function copyTemplates(coreConfigDir) {
1315
1465
  }
1316
1466
  /** Whether Handlebars helpers have been registered. */
1317
1467
  let helpersRegistered = false;
1318
- /**
1319
- * Register Handlebars helpers used in the Platform template.
1320
- */
1468
+ /** Register Handlebars helpers used in the Platform template. */
1321
1469
  function registerHelpers() {
1322
1470
  if (helpersRegistered)
1323
1471
  return;
1324
1472
  helpersRegistered = true;
1325
1473
  Handlebars.registerHelper('gt', (a, b) => typeof a === 'number' && typeof b === 'number' && a > b);
1326
1474
  }
1475
+ /**
1476
+ * Check if a newer core version is available on npm.
1477
+ *
1478
+ * @returns The newer version string, or undefined.
1479
+ */
1480
+ function checkCoreUpdate(coreVersion, cacheDir) {
1481
+ const registryVersion = checkRegistryVersion('@karmaniverous/jeeves', cacheDir);
1482
+ if (registryVersion &&
1483
+ semver.valid(registryVersion) &&
1484
+ semver.valid(coreVersion) &&
1485
+ semver.gt(registryVersion, coreVersion)) {
1486
+ return registryVersion;
1487
+ }
1488
+ return undefined;
1489
+ }
1327
1490
  /**
1328
1491
  * Refresh platform content: SOUL.md, AGENTS.md, and TOOLS.md Platform section.
1329
1492
  *
@@ -1335,55 +1498,46 @@ async function refreshPlatformContent(options) {
1335
1498
  const coreConfigDir = getCoreConfigDir();
1336
1499
  // 1. Probe all services
1337
1500
  const probeResults = await probeAllServices(undefined, probeTimeoutMs);
1338
- const unhealthyServices = probeResults.filter((r) => !r.healthy);
1339
- // 2. Registry version checks
1501
+ // 2. Write calling component's version entry (with serviceVersion from probe)
1502
+ if (componentName) {
1503
+ const callerProbe = probeResults.find((r) => r.name === componentName);
1504
+ writeComponentVersion(coreConfigDir, {
1505
+ componentName,
1506
+ serviceVersion: callerProbe?.version,
1507
+ pluginVersion: componentVersion,
1508
+ servicePackage,
1509
+ pluginPackage,
1510
+ });
1511
+ }
1512
+ // 3. Read all component versions from the shared state file
1513
+ const componentVersions = readComponentVersions(coreConfigDir);
1514
+ // 4. Build enriched service rows with registry checks
1340
1515
  const cacheDir = componentName
1341
1516
  ? getComponentConfigDir(componentName)
1342
1517
  : coreConfigDir;
1343
- let availableCoreVersion;
1344
- let availableServiceVersion;
1345
- let availablePluginVersion;
1346
- if (!skipRegistryCheck) {
1347
- const coreRegistryVersion = checkRegistryVersion('@karmaniverous/jeeves', cacheDir);
1348
- if (coreRegistryVersion && coreRegistryVersion !== coreVersion) {
1349
- availableCoreVersion = coreRegistryVersion;
1350
- }
1351
- if (servicePackage) {
1352
- const svcVersion = checkRegistryVersion(servicePackage, cacheDir);
1353
- if (svcVersion) {
1354
- availableServiceVersion = svcVersion;
1355
- }
1356
- }
1357
- if (pluginPackage) {
1358
- const plgVersion = checkRegistryVersion(pluginPackage, cacheDir);
1359
- if (plgVersion) {
1360
- availablePluginVersion = plgVersion;
1361
- }
1362
- }
1363
- }
1364
- // 3. Build enriched service rows — match the calling component by name
1365
- const serviceRows = probeResults.map((r) => ({
1366
- ...r,
1367
- pluginVersion: r.name === componentName ? componentVersion : undefined,
1368
- availableServiceVersion: r.name === componentName ? availableServiceVersion : undefined,
1369
- availablePluginVersion: r.name === componentName ? availablePluginVersion : undefined,
1370
- }));
1371
- // 5. Check if templates are available
1518
+ const availableCoreVersion = skipRegistryCheck
1519
+ ? undefined
1520
+ : checkCoreUpdate(coreVersion, cacheDir);
1521
+ const serviceRows = buildServiceRows({
1522
+ probeResults,
1523
+ componentVersions,
1524
+ cacheDir,
1525
+ skipRegistryCheck,
1526
+ });
1527
+ // 5. Render Platform template
1372
1528
  const templatePath = join(coreConfigDir, TEMPLATES_DIR);
1373
- const templatesAvailable = existsSync(templatePath);
1374
- // 6. Render Platform template
1375
1529
  registerHelpers();
1376
1530
  const template = Handlebars.compile(toolsPlatformTemplate);
1377
1531
  const templateData = {
1378
1532
  services: serviceRows,
1379
- unhealthyServices,
1533
+ unhealthyServices: serviceRows.filter((r) => !r.healthy),
1380
1534
  coreVersion,
1381
1535
  availableCoreVersion,
1382
- templatesAvailable,
1536
+ templatesAvailable: existsSync(templatePath),
1383
1537
  templatePath,
1384
1538
  };
1385
1539
  const platformContent = template(templateData);
1386
- // 7. Write TOOLS.md Platform section
1540
+ // 6. Write TOOLS.md Platform section
1387
1541
  const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
1388
1542
  await updateManagedSection(toolsPath, platformContent, {
1389
1543
  mode: 'section',
@@ -1392,7 +1546,7 @@ async function refreshPlatformContent(options) {
1392
1546
  coreVersion,
1393
1547
  stalenessThresholdMs,
1394
1548
  });
1395
- // 8. Write SOUL.md managed block
1549
+ // 7. Write SOUL.md managed block
1396
1550
  const soulPath = join(workspacePath, WORKSPACE_FILES.soul);
1397
1551
  await updateManagedSection(soulPath, soulSectionContent, {
1398
1552
  mode: 'block',
@@ -1400,7 +1554,7 @@ async function refreshPlatformContent(options) {
1400
1554
  coreVersion,
1401
1555
  stalenessThresholdMs,
1402
1556
  });
1403
- // 9. Write AGENTS.md managed block
1557
+ // 8. Write AGENTS.md managed block
1404
1558
  const agentsPath = join(workspacePath, WORKSPACE_FILES.agents);
1405
1559
  await updateManagedSection(agentsPath, agentsSectionContent, {
1406
1560
  mode: 'block',
@@ -1408,7 +1562,7 @@ async function refreshPlatformContent(options) {
1408
1562
  coreVersion,
1409
1563
  stalenessThresholdMs,
1410
1564
  });
1411
- // 10. Copy templates to config dir
1565
+ // 9. Copy templates to config dir
1412
1566
  copyTemplates(coreConfigDir);
1413
1567
  }
1414
1568