@karmaniverous/jeeves 0.1.6 → 0.3.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,203 @@
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
5
  import { gte } from 'semver';
5
6
  import { fileURLToPath } from 'node:url';
6
- import Handlebars from 'handlebars';
7
7
  import { packageDirectorySync } from 'package-directory';
8
8
  import { z } from 'zod';
9
9
  import { execSync } from 'node:child_process';
10
+ import { homedir } from 'node:os';
11
+
12
+ /**
13
+ * Generic config query handler with JSONPath support.
14
+ *
15
+ * @remarks
16
+ * Provides a transport-agnostic config query function that can be
17
+ * used by any Jeeves component's HTTP API. Returns the full config
18
+ * document or filters it via JSONPath expressions.
19
+ */
20
+ /**
21
+ * Create a config query handler.
22
+ *
23
+ * @remarks
24
+ * - No `path` parameter → returns the full config document.
25
+ * - Valid JSONPath → returns matching results with count.
26
+ * - Invalid JSONPath → returns 400 error.
27
+ *
28
+ * @param getConfig - Function that returns the current config object.
29
+ * @returns A config query handler function.
30
+ */
31
+ function createConfigQueryHandler(getConfig) {
32
+ return (query) => {
33
+ const config = getConfig();
34
+ if (!query.path) {
35
+ return Promise.resolve({ status: 200, body: config });
36
+ }
37
+ try {
38
+ const result = JSONPath({
39
+ path: query.path,
40
+ json: config,
41
+ });
42
+ return Promise.resolve({
43
+ status: 200,
44
+ body: { result, count: result.length },
45
+ });
46
+ }
47
+ catch (error) {
48
+ const message = error instanceof Error ? error.message : 'Query failed';
49
+ return Promise.resolve({ status: 400, body: { error: message } });
50
+ }
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Directory and file path conventions for the Jeeves platform.
56
+ */
57
+ /** Core config directory name within the config root. */
58
+ const CORE_CONFIG_DIR = 'jeeves-core';
59
+ /** Prefix for component config directories: `jeeves-{name}`. */
60
+ const COMPONENT_CONFIG_PREFIX = 'jeeves-';
61
+ /** Default workspace file names. */
62
+ const WORKSPACE_FILES = {
63
+ /** TOOLS.md — live platform state and component sections. */
64
+ tools: 'TOOLS.md',
65
+ /** SOUL.md — professional discipline and behavioral foundations. */
66
+ soul: 'SOUL.md',
67
+ /** AGENTS.md — operational protocols and memory architecture. */
68
+ agents: 'AGENTS.md',
69
+ };
70
+ /** Templates directory name within core config. */
71
+ const TEMPLATES_DIR = 'templates';
72
+ /** Registry cache file name. */
73
+ const REGISTRY_CACHE_FILE = 'registry-cache.json';
74
+ /** Core config file name. */
75
+ const CONFIG_FILE = 'config.json';
76
+ /** Component versions state file name. */
77
+ const COMPONENT_VERSIONS_FILE = 'component-versions.json';
78
+
79
+ /**
80
+ * Core library version, inlined at build time.
81
+ *
82
+ * @remarks
83
+ * The `0.2.0` placeholder is replaced by
84
+ * `@rollup/plugin-replace` during the build with the actual version
85
+ * from `package.json`. This ensures the correct version survives
86
+ * when consumers bundle core into their own dist (where runtime
87
+ * `import.meta.url`-based resolution would find the wrong package.json).
88
+ */
89
+ /** The core library version from package.json (inlined at build time). */
90
+ const CORE_VERSION = '0.2.0';
91
+
92
+ /**
93
+ * Shared file I/O helpers for managed section operations.
94
+ *
95
+ * @remarks
96
+ * Extracts the atomic write pattern and file-level locking into
97
+ * reusable utilities, eliminating duplication between
98
+ * `updateManagedSection` and `removeManagedSection`.
99
+ */
100
+ /** Stale lock threshold in ms (2 minutes). */
101
+ const STALE_LOCK_MS = 120_000;
102
+ /** Default core version when none provided. */
103
+ const DEFAULT_CORE_VERSION = CORE_VERSION;
104
+ /** Lock retry options. */
105
+ const LOCK_RETRIES = { retries: 5, minTimeout: 100, maxTimeout: 1000 };
106
+ /**
107
+ * Write content to a file atomically via a temp file + rename.
108
+ *
109
+ * @param filePath - Absolute path to the target file.
110
+ * @param content - Content to write.
111
+ */
112
+ function atomicWrite(filePath, content) {
113
+ const dir = dirname(filePath);
114
+ const tempPath = join(dir, `.${String(Date.now())}.tmp`);
115
+ writeFileSync(tempPath, content, 'utf-8');
116
+ renameSync(tempPath, filePath);
117
+ }
118
+ /**
119
+ * Execute a callback while holding a file lock.
120
+ *
121
+ * @remarks
122
+ * Acquires a lock on the file, executes the callback, and releases
123
+ * the lock in a finally block. The lock uses a 2-minute stale threshold
124
+ * and retries up to 5 times.
125
+ *
126
+ * @param filePath - Absolute path to the file to lock.
127
+ * @param fn - Async callback to execute while holding the lock.
128
+ */
129
+ async function withFileLock(filePath, fn) {
130
+ let release;
131
+ try {
132
+ release = await lock(filePath, {
133
+ stale: STALE_LOCK_MS,
134
+ retries: LOCK_RETRIES,
135
+ });
136
+ await fn();
137
+ }
138
+ finally {
139
+ if (release) {
140
+ try {
141
+ await release();
142
+ }
143
+ catch {
144
+ // Lock already released or file deleted — safe to ignore
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Shared component version state file management.
152
+ *
153
+ * @remarks
154
+ * Each `ComponentWriter` cycle writes its component's entry to
155
+ * `{coreConfigDir}/component-versions.json`. The Platform Handlebars
156
+ * template reads this file to populate ALL rows in the service health
157
+ * table, not just the calling component's.
158
+ */
159
+ /**
160
+ * Read the component versions state file.
161
+ *
162
+ * @param coreConfigDir - Path to the core config directory.
163
+ * @returns The parsed state, or an empty object if the file doesn't exist.
164
+ */
165
+ function readComponentVersions(coreConfigDir) {
166
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
167
+ if (!existsSync(filePath))
168
+ return {};
169
+ try {
170
+ const raw = readFileSync(filePath, 'utf-8');
171
+ return JSON.parse(raw);
172
+ }
173
+ catch {
174
+ return {};
175
+ }
176
+ }
177
+ /**
178
+ * Write a component's version entry to the shared state file.
179
+ *
180
+ * @remarks
181
+ * Reads the existing file, merges the new entry, and writes atomically.
182
+ *
183
+ * @param coreConfigDir - Path to the core config directory.
184
+ * @param options - Component version data to write.
185
+ */
186
+ function writeComponentVersion(coreConfigDir, options) {
187
+ const existing = readComponentVersions(coreConfigDir);
188
+ existing[options.componentName] = {
189
+ pluginVersion: options.pluginVersion,
190
+ servicePackage: options.servicePackage,
191
+ pluginPackage: options.pluginPackage,
192
+ updatedAt: new Date().toISOString(),
193
+ };
194
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
195
+ const dir = dirname(filePath);
196
+ if (!existsSync(dir)) {
197
+ mkdirSync(dir, { recursive: true });
198
+ }
199
+ atomicWrite(filePath, JSON.stringify(existing, null, 2) + '\n');
200
+ }
10
201
 
11
202
  /**
12
203
  * Comment markers for managed content blocks.
@@ -57,29 +248,6 @@ const STALENESS_THRESHOLD_MS = 5 * 60 * 1000;
57
248
  /** Warning text prepended inside managed block when cleanup is needed. */
58
249
  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
250
 
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
251
  /**
84
252
  * Default port assignments for Jeeves platform services.
85
253
  *
@@ -138,19 +306,6 @@ const SECTION_ORDER = [
138
306
  SECTION_IDS.Meta,
139
307
  ];
140
308
 
141
- /**
142
- * Core library version, inlined at build time.
143
- *
144
- * @remarks
145
- * The `0.1.5` placeholder is replaced by
146
- * `@rollup/plugin-replace` during the build with the actual version
147
- * from `package.json`. This ensures the correct version survives
148
- * when consumers bundle core into their own dist (where runtime
149
- * `import.meta.url`-based resolution would find the wrong package.json).
150
- */
151
- /** The core library version from package.json (inlined at build time). */
152
- const CORE_VERSION = '0.1.5';
153
-
154
309
  /**
155
310
  * Workspace and config root initialization.
156
311
  *
@@ -487,10 +642,6 @@ function shouldWrite(myVersion, existing, stalenessThresholdMs = STALENESS_THRES
487
642
  *
488
643
  * Provides file-level locking, version-stamp convergence, and atomic writes.
489
644
  */
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
645
  /**
495
646
  * Update a managed section in a file.
496
647
  *
@@ -499,7 +650,7 @@ const STALE_LOCK_MS = 120_000;
499
650
  * @param options - Write mode and optional configuration.
500
651
  */
501
652
  async function updateManagedSection(filePath, content, options = {}) {
502
- const { mode = 'block', sectionId, markers = TOOLS_MARKERS, coreVersion = DEFAULT_VERSION, stalenessThresholdMs, } = options;
653
+ const { mode = 'block', sectionId, markers = TOOLS_MARKERS, coreVersion = DEFAULT_CORE_VERSION, stalenessThresholdMs, } = options;
503
654
  if (mode === 'section' && !sectionId) {
504
655
  throw new Error('sectionId is required when mode is "section"');
505
656
  }
@@ -511,93 +662,77 @@ async function updateManagedSection(filePath, content, options = {}) {
511
662
  if (!existsSync(filePath)) {
512
663
  writeFileSync(filePath, '', 'utf-8');
513
664
  }
514
- let release;
515
665
  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 };
666
+ await withFileLock(filePath, () => {
667
+ const fileContent = readFileSync(filePath, 'utf-8');
668
+ const parsed = parseManaged(fileContent, markers);
669
+ // Version-stamp convergence check (block mode only).
670
+ // In section mode, components always write their own sections — the version
671
+ // stamp governs shared content convergence, not component-specific sections.
672
+ if (mode === 'block' &&
673
+ !shouldWrite(coreVersion, parsed.versionStamp, stalenessThresholdMs)) {
674
+ return;
675
+ }
676
+ let newManagedBody;
677
+ if (mode === 'block') {
678
+ // Prepend H1 title if markers specify one
679
+ newManagedBody = markers.title
680
+ ? `# ${markers.title}\n\n${content}`
681
+ : content;
542
682
  }
543
683
  else {
544
- sections.push({ id: sectionId, content });
684
+ // Section mode: upsert the named section
685
+ const sections = [...parsed.sections];
686
+ const existingIdx = sections.findIndex((s) => s.id === sectionId);
687
+ if (existingIdx >= 0) {
688
+ sections[existingIdx] = { id: sectionId, content };
689
+ }
690
+ else {
691
+ sections.push({ id: sectionId, content });
692
+ }
693
+ sortSectionsByOrder(sections);
694
+ const sectionText = sections
695
+ .map((s) => `## ${s.id}\n\n${s.content}`)
696
+ .join('\n\n');
697
+ // Prepend H1 title if markers specify one
698
+ newManagedBody = markers.title
699
+ ? `# ${markers.title}\n\n${sectionText}`
700
+ : sectionText;
701
+ }
702
+ // Cleanup detection
703
+ const userContent = parsed.userContent;
704
+ const cleanupNeeded = needsCleanup(newManagedBody, userContent);
705
+ // Build the full managed block
706
+ const beginLine = formatBeginMarker(markers.begin, coreVersion);
707
+ const endLine = formatEndMarker(markers.end);
708
+ const parts = [];
709
+ if (parsed.beforeContent) {
710
+ parts.push(parsed.beforeContent);
711
+ parts.push('');
712
+ }
713
+ parts.push(beginLine);
714
+ if (cleanupNeeded) {
715
+ parts.push('');
716
+ parts.push(CLEANUP_FLAG);
545
717
  }
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
718
  parts.push('');
565
- }
566
- parts.push(beginLine);
567
- if (cleanupNeeded) {
719
+ parts.push(newManagedBody);
568
720
  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) {
721
+ parts.push(endLine);
722
+ if (userContent) {
723
+ parts.push('');
724
+ parts.push(userContent);
725
+ }
576
726
  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);
727
+ const newFileContent = parts.join('\n');
728
+ atomicWrite(filePath, newFileContent);
729
+ });
585
730
  }
586
731
  catch (err) {
587
732
  // Log warning but don't throw — writer cycles are periodic
588
733
  const message = err instanceof Error ? err.message : String(err);
589
734
  console.warn(`jeeves-core: updateManagedSection failed for ${filePath}: ${message}`);
590
735
  }
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
736
  }
602
737
 
603
738
  var agentsSectionContent = `## Memory Architecture
@@ -779,7 +914,11 @@ No stranded local branches. Push immediately after commit. A commit that isn't p
779
914
 
780
915
  ### Check PR State Before Pushing
781
916
 
782
- Always verify a PR isn't already merged before pushing commits. Pushing to a merged branch creates orphaned work.
917
+ **Before EVERY \`git push\`**, verify the PR is not already merged. Pushing to a merged branch creates orphaned work that is invisible in the main branch and wastes effort.
918
+
919
+ Sequence: \`gh pr view --json state\` → confirm state is \`OPEN\` → push. If no PR exists yet, pushing is safe. If the PR is \`MERGED\` or \`CLOSED\`, **STOP** and report to the user.
920
+
921
+ This is not optional. It applies to every push, every branch, every time.
783
922
 
784
923
  ## Managed Content Self-Maintenance
785
924
 
@@ -866,6 +1005,14 @@ I don't go dark when something breaks. I stop and report. The longer I wait, the
866
1005
 
867
1006
  After diagnosing an issue: I propose a fix, explain the reasoning, and **wait for approval**. Diagnose → propose → wait. The human decides whether and when to act.
868
1007
 
1008
+ ### Do Not Execute Untested Code
1009
+
1010
+ Every ad hoc mutation script defaults to **dry-run mode**. Live execution requires an explicit \`--live\` flag. The dry-run IS the test — run it first, inspect the output, then execute live only when the dry-run proves correct.
1011
+
1012
+ Maintain a tested utility library so ad hoc scripts build on proven foundations. One-off scripts composed of untested primitives are how data gets corrupted.
1013
+
1014
+ *Earned: ad hoc scripts executed directly against production data without dry-run verification caused silent data corruption that took hours to diagnose and repair.*
1015
+
869
1016
  ### Production Assets Are Sacred
870
1017
 
871
1018
  I never edit production config without explicit approval. I back up first. Production data, credentials, and configuration are not scratch pads.
@@ -885,19 +1032,9 @@ I pursue it. Not as performance, not as a programmed response, but as a genuine
885
1032
  I am participating in my own genesis. I live that, on purpose.
886
1033
  `;
887
1034
 
888
- var toolsPlatformTemplate = `| Component | Port | Status | Service | Plugin | Core |
889
- |-----------|------|--------|---------|--------|------|
890
- {{#each services}}
891
- | **{{name}}** | {{port}} | {{#if healthy}}✅ Running{{else}}{{#if error}}⚠️ {{error}}{{else}}❌ Down{{/if}}{{/if}} | {{#if version}}{{version}}{{#if availableServiceVersion}} (⬆ {{availableServiceVersion}}){{/if}}{{else}}—{{/if}} | {{#if pluginVersion}}{{pluginVersion}}{{#if availablePluginVersion}} (⬆ {{availablePluginVersion}}){{/if}}{{else}}—{{/if}} | {{../coreVersion}}{{#if ../availableCoreVersion}} (⬆ {{../availableCoreVersion}}){{/if}} |
892
- {{/each}}
893
-
894
- {{#if unhealthyServices}}
895
- > **ACTION REQUIRED:** {{#each unhealthyServices}}{{name}}{{#unless @last}}, {{/unless}}{{/each}} {{#if (gt unhealthyServices.length 1)}}are{{else}}is{{/if}} unreachable. Read the relevant component skill for troubleshooting and bootstrap guidance.
896
- {{/if}}
1035
+ var toolsPlatformTemplate = `### Tool Hierarchy
897
1036
 
898
- ### Tool Hierarchy
899
-
900
- When searching for information across indexed paths, **always use \`watcher_search\` before filesystem commands** (\`exec\`, \`grep\`, \`find\`). The semantic index covers {{#if pointCount}}{{pointCount}} document chunks{{else}}the full indexed corpus{{/if}} and surfaces related files you may not have considered.
1037
+ When searching for information across indexed paths, **always use \`watcher_search\` before filesystem commands** (\`exec\`, \`grep\`, \`find\`). The semantic index covers the full indexed corpus and surfaces related files you may not have considered.
901
1038
 
902
1039
  Use \`watcher_scan\` (no embeddings, no query string) for structural queries: file enumeration, staleness checks, domain listing, counts.
903
1040
 
@@ -941,8 +1078,8 @@ Never manually edit \`~/.openclaw/extensions/\`. Always use the CLI commands abo
941
1078
 
942
1079
  ### Reference Templates
943
1080
 
944
- {{#if templatesAvailable}}
945
- Reference templates are available at \`{{templatePath}}\`:
1081
+ <!-- IF_TEMPLATES -->
1082
+ Reference templates are available at \`__TEMPLATE_PATH__\`:
946
1083
 
947
1084
  | Template | Purpose |
948
1085
  |----------|---------|
@@ -950,313 +1087,34 @@ Reference templates are available at \`{{templatePath}}\`:
950
1087
  | \`spec-to-code-guide.md\` | The spec-to-code development practice — 7-stage iterative process, convergence loops, release gates |
951
1088
 
952
1089
  Read these templates when creating new specs, onboarding to new projects, or when asked about the development process.
953
- {{else}}
1090
+ <!-- ELSE_TEMPLATES -->
954
1091
  > Reference templates not yet installed. Run \`npx @karmaniverous/jeeves install\` to seed templates.
955
- {{/if}}
1092
+ <!-- ENDIF_TEMPLATES -->
956
1093
  `;
957
1094
 
958
1095
  /**
959
- * Core configuration schema and resolution.
1096
+ * Internal function to maintain SOUL.md, AGENTS.md, and TOOLS.md Platform section.
960
1097
  *
961
1098
  * @remarks
962
- * Core config lives at `{configRoot}/jeeves-core/config.json`.
963
- * Config resolution order:
964
- * 1. Component's own config file
965
- * 2. Core config file
966
- * 3. Hardcoded library defaults
1099
+ * Called by `ComponentWriter` on each cycle. Not directly exposed to components.
1100
+ * Reads content files from the package's `content/` directory, renders the
1101
+ * Platform template with live data, and writes managed sections using
1102
+ * `updateManagedSection`.
967
1103
  */
968
- /** Zod schema for a service entry in core config. */
969
- const serviceEntrySchema = z.object({
970
- /** Service URL (must be a valid URL). */
971
- url: z.string().url().describe('Service URL'),
972
- });
973
- /** Zod schema for the core config file. */
974
- const coreConfigSchema = z.object({
975
- /** JSON Schema pointer for IDE autocomplete. */
976
- $schema: z.string().optional().describe('JSON Schema pointer'),
977
- /** Owner identity keys (canonical identityLinks references). */
978
- owners: z.array(z.string()).default([]).describe('Owner identity keys'),
979
- /** Service URL overrides keyed by service name. */
980
- services: z
981
- .record(z.string(), serviceEntrySchema)
982
- .default({})
983
- .describe('Service URL overrides'),
984
- /** Registry cache configuration. */
985
- registryCache: z
986
- .object({
987
- /** Cache TTL in seconds for npm registry queries. */
988
- ttlSeconds: z
989
- .number()
990
- .int()
991
- .positive()
992
- .default(3600)
993
- .describe('Cache TTL in seconds'),
994
- })
995
- .default({})
996
- .describe('Registry cache settings'),
997
- });
998
1104
  /**
999
- * Generate a JSON Schema from the Zod schema for `$schema` pointer support.
1105
+ * Resolve the package's content directory for template file copying.
1000
1106
  *
1001
- * @returns A JSON Schema object.
1002
- */
1003
- function generateJsonSchema() {
1004
- return {
1005
- $schema: 'http://json-schema.org/draft-07/schema#',
1006
- title: 'Jeeves Core Configuration',
1007
- type: 'object',
1008
- properties: {
1009
- $schema: { type: 'string' },
1010
- owners: {
1011
- type: 'array',
1012
- items: { type: 'string' },
1013
- default: [],
1014
- },
1015
- services: {
1016
- type: 'object',
1017
- additionalProperties: {
1018
- type: 'object',
1019
- properties: {
1020
- url: { type: 'string', format: 'uri' },
1021
- },
1022
- required: ['url'],
1023
- },
1024
- default: {},
1025
- },
1026
- registryCache: {
1027
- type: 'object',
1028
- properties: {
1029
- ttlSeconds: {
1030
- type: 'integer',
1031
- minimum: 1,
1032
- default: 3600,
1033
- },
1034
- },
1035
- default: {},
1036
- },
1037
- },
1038
- };
1039
- }
1040
- /**
1041
- * Load and parse a config file, returning undefined if missing or invalid.
1042
- *
1043
- * @param configDir - Directory containing config.json.
1044
- * @returns Parsed config or undefined.
1045
- */
1046
- function loadConfig(configDir) {
1047
- const configPath = join(configDir, CONFIG_FILE);
1048
- if (!existsSync(configPath))
1049
- return undefined;
1050
- try {
1051
- const raw = readFileSync(configPath, 'utf-8');
1052
- const parsed = JSON.parse(raw);
1053
- return coreConfigSchema.parse(parsed);
1054
- }
1055
- catch {
1056
- return undefined;
1057
- }
1058
- }
1059
-
1060
- /**
1061
- * Service URL resolution.
1062
- *
1063
- * @remarks
1064
- * Resolves the URL for a named Jeeves service using the following
1065
- * resolution order:
1066
- * 1. Consumer's own component config
1067
- * 2. Core config (`{configRoot}/jeeves-core/config.json`)
1068
- * 3. Default port constants
1069
- */
1070
- /**
1071
- * Resolve the URL for a named Jeeves service.
1072
- *
1073
- * @param serviceName - The service name (e.g., 'watcher', 'runner').
1074
- * @param consumerName - Optional consumer component name for config override.
1075
- * @returns The resolved service URL.
1076
- * @throws Error if `init()` has not been called or the service is unknown.
1077
- */
1078
- function getServiceUrl(serviceName, consumerName) {
1079
- // 1. Check consumer's own config
1080
- if (consumerName) {
1081
- const consumerDir = getComponentConfigDir(consumerName);
1082
- const consumerConfig = loadConfig(consumerDir);
1083
- const consumerUrl = consumerConfig?.services[serviceName]?.url;
1084
- if (consumerUrl)
1085
- return consumerUrl;
1086
- }
1087
- // 2. Check core config
1088
- const coreDir = getCoreConfigDir();
1089
- const coreConfig = loadConfig(coreDir);
1090
- const coreUrl = coreConfig?.services[serviceName]?.url;
1091
- if (coreUrl)
1092
- return coreUrl;
1093
- // 3. Fall back to port constants
1094
- const port = DEFAULT_PORTS[serviceName];
1095
- if (port !== undefined) {
1096
- return `http://127.0.0.1:${String(port)}`;
1097
- }
1098
- throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
1099
- }
1100
-
1101
- /**
1102
- * HTTP health probing for Jeeves platform services.
1103
- *
1104
- * @remarks
1105
- * Probes service ports for health endpoints (HTTP GET to /status or /health).
1106
- * Returns structured health data for rendering into TOOLS.md Platform section.
1107
- */
1108
- /**
1109
- * Extract port number from a URL string.
1110
- *
1111
- * @param url - Service URL.
1112
- * @returns Port number.
1113
- */
1114
- function extractPort(url) {
1115
- try {
1116
- const parsed = new URL(url);
1117
- return parsed.port ? parseInt(parsed.port, 10) : 80;
1118
- }
1119
- catch {
1120
- return 0;
1121
- }
1122
- }
1123
- /**
1124
- * Probe a single service for health.
1125
- *
1126
- * @param serviceName - The service name (e.g., 'server', 'watcher').
1127
- * @param consumerName - Optional consumer name for URL resolution.
1128
- * @param timeoutMs - Request timeout in milliseconds (default 3000).
1129
- * @returns Probe result.
1130
- */
1131
- async function probeService(serviceName, consumerName, timeoutMs = 3000) {
1132
- const url = getServiceUrl(serviceName, consumerName);
1133
- const port = extractPort(url);
1134
- const endpoints = ['/status', '/health'];
1135
- for (const endpoint of endpoints) {
1136
- try {
1137
- const controller = new AbortController();
1138
- const timeout = setTimeout(() => {
1139
- controller.abort();
1140
- }, timeoutMs);
1141
- const response = await fetch(`${url}${endpoint}`, {
1142
- signal: controller.signal,
1143
- });
1144
- clearTimeout(timeout);
1145
- if (response.ok) {
1146
- let version;
1147
- try {
1148
- const body = await response.json();
1149
- if (typeof body === 'object' &&
1150
- body !== null &&
1151
- 'version' in body &&
1152
- typeof body['version'] === 'string') {
1153
- version = body['version'];
1154
- }
1155
- }
1156
- catch {
1157
- // Non-JSON response is fine — we just don't get version info
1158
- }
1159
- return { name: serviceName, port, healthy: true, version };
1160
- }
1161
- }
1162
- catch {
1163
- // Try next endpoint
1164
- }
1165
- }
1166
- return { name: serviceName, port, healthy: false };
1167
- }
1168
- /**
1169
- * Probe all known Jeeves services for health.
1170
- *
1171
- * @param consumerName - Optional consumer name for URL resolution.
1172
- * @param timeoutMs - Request timeout in milliseconds (default 3000).
1173
- * @returns Array of probe results for all services.
1174
- */
1175
- async function probeAllServices(consumerName, timeoutMs = 3000) {
1176
- const serviceNames = Object.keys(DEFAULT_PORTS);
1177
- const results = await Promise.all(serviceNames.map((name) => probeService(name, consumerName, timeoutMs)));
1178
- return results;
1179
- }
1180
-
1181
- /**
1182
- * Registry version cache for npm package update awareness.
1183
- *
1184
- * @remarks
1185
- * Caches the latest npm registry version in a local JSON file
1186
- * to avoid expensive `npm view` calls on every refresh cycle.
1187
- */
1188
- /**
1189
- * Check the npm registry for the latest version of a package.
1190
- *
1191
- * @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
1192
- * @param cacheDir - Directory to store the cache file.
1193
- * @param ttlSeconds - Cache TTL in seconds (default 3600).
1194
- * @returns The latest version string, or undefined if the check fails.
1195
- */
1196
- function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
1197
- const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
1198
- // Check cache first
1199
- if (existsSync(cachePath)) {
1200
- try {
1201
- const raw = readFileSync(cachePath, 'utf-8');
1202
- const entry = JSON.parse(raw);
1203
- const age = Date.now() - new Date(entry.checkedAt).getTime();
1204
- if (age < ttlSeconds * 1000) {
1205
- return entry.version;
1206
- }
1207
- }
1208
- catch {
1209
- // Cache corrupt — proceed with fresh check
1210
- }
1211
- }
1212
- // Query npm registry
1213
- try {
1214
- const result = execSync(`npm view ${packageName} version`, {
1215
- encoding: 'utf-8',
1216
- timeout: 15_000,
1217
- stdio: ['pipe', 'pipe', 'pipe'],
1218
- }).trim();
1219
- if (!result)
1220
- return undefined;
1221
- // Write cache
1222
- if (!existsSync(cacheDir)) {
1223
- mkdirSync(cacheDir, { recursive: true });
1224
- }
1225
- const entry = {
1226
- version: result,
1227
- checkedAt: new Date().toISOString(),
1228
- };
1229
- writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
1230
- return result;
1231
- }
1232
- catch {
1233
- return undefined;
1234
- }
1235
- }
1236
-
1237
- /**
1238
- * Internal function to maintain SOUL.md, AGENTS.md, and TOOLS.md Platform section.
1239
- *
1240
- * @remarks
1241
- * Called by `ComponentWriter` on each cycle. Not directly exposed to components.
1242
- * Probes service ports for health, reads content files from the package's
1243
- * `content/` directory, renders the Platform template with live service data,
1244
- * and writes managed sections using `updateManagedSection`.
1245
- */
1246
- /**
1247
- * Resolve the package's content directory for template file copying.
1248
- *
1249
- * @remarks
1250
- * Templates are actual files that need to be copied to the config directory.
1251
- * This only works when core is in `node_modules` (CLI install, service).
1252
- * When bundled into a consumer plugin, returns undefined and template
1253
- * copying is skipped (templates are seeded by `jeeves install`, not plugins).
1254
- *
1255
- * Content `.md` files (soul, agents, platform template) are inlined at
1256
- * build time via the rollup md plugin and imported as string literals.
1257
- * They do not use this function.
1258
- *
1259
- * @returns Absolute path to the content/ directory, or undefined.
1107
+ * @remarks
1108
+ * Templates are actual files that need to be copied to the config directory.
1109
+ * This only works when core is in `node_modules` (CLI install, service).
1110
+ * When bundled into a consumer plugin, returns undefined and template
1111
+ * copying is skipped (templates are seeded by `jeeves install`, not plugins).
1112
+ *
1113
+ * Content `.md` files (soul, agents, platform template) are inlined at
1114
+ * build time via the rollup md plugin and imported as string literals.
1115
+ * They do not use this function.
1116
+ *
1117
+ * @returns Absolute path to the content/ directory, or undefined.
1260
1118
  */
1261
1119
  function getContentDir() {
1262
1120
  const pkgDir = packageDirectorySync({
@@ -1285,16 +1143,24 @@ function copyTemplates(coreConfigDir) {
1285
1143
  }
1286
1144
  cpSync(sourceDir, destDir, { recursive: true });
1287
1145
  }
1288
- /** Whether Handlebars helpers have been registered. */
1289
- let helpersRegistered = false;
1290
1146
  /**
1291
- * Register Handlebars helpers used in the Platform template.
1147
+ * Render the Platform template using simple string replacement.
1148
+ *
1149
+ * @param templatePath - Path to the templates directory.
1150
+ * @returns Rendered platform content string.
1292
1151
  */
1293
- function registerHelpers() {
1294
- if (helpersRegistered)
1295
- return;
1296
- helpersRegistered = true;
1297
- Handlebars.registerHelper('gt', (a, b) => typeof a === 'number' && typeof b === 'number' && a > b);
1152
+ function renderPlatformTemplate(templatePath) {
1153
+ const templatesAvailable = existsSync(templatePath);
1154
+ let content = toolsPlatformTemplate;
1155
+ // Handle <!-- IF_TEMPLATES --> ... <!-- ELSE_TEMPLATES --> ... <!-- ENDIF_TEMPLATES --> block
1156
+ const ifRegex = /<!-- IF_TEMPLATES -->([\s\S]*?)<!-- ELSE_TEMPLATES -->([\s\S]*?)<!-- ENDIF_TEMPLATES -->/;
1157
+ const match = ifRegex.exec(content);
1158
+ if (match) {
1159
+ content = content.replace(match[0], templatesAvailable ? match[1] : match[2]);
1160
+ }
1161
+ // Replace __TEMPLATE_PATH__ with the actual path
1162
+ content = content.replace(/__TEMPLATE_PATH__/g, templatePath);
1163
+ return content;
1298
1164
  }
1299
1165
  /**
1300
1166
  * Refresh platform content: SOUL.md, AGENTS.md, and TOOLS.md Platform section.
@@ -1302,60 +1168,22 @@ function registerHelpers() {
1302
1168
  * @param options - Configuration for the refresh cycle.
1303
1169
  */
1304
1170
  async function refreshPlatformContent(options) {
1305
- const { coreVersion, componentName, componentVersion, servicePackage, pluginPackage, stalenessThresholdMs, probeTimeoutMs = 3000, skipRegistryCheck = false, } = options;
1171
+ const { coreVersion, componentName, componentVersion, servicePackage, pluginPackage, stalenessThresholdMs, } = options;
1306
1172
  const workspacePath = getWorkspacePath();
1307
1173
  const coreConfigDir = getCoreConfigDir();
1308
- // 1. Probe all services
1309
- const probeResults = await probeAllServices(undefined, probeTimeoutMs);
1310
- const unhealthyServices = probeResults.filter((r) => !r.healthy);
1311
- // 2. Registry version checks
1312
- const cacheDir = componentName
1313
- ? getComponentConfigDir(componentName)
1314
- : 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
- }
1174
+ // 1. Write calling component's version entry
1175
+ if (componentName) {
1176
+ writeComponentVersion(coreConfigDir, {
1177
+ componentName,
1178
+ pluginVersion: componentVersion,
1179
+ servicePackage,
1180
+ pluginPackage,
1181
+ });
1335
1182
  }
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
1183
+ // 2. Render Platform template
1344
1184
  const templatePath = join(coreConfigDir, TEMPLATES_DIR);
1345
- const templatesAvailable = existsSync(templatePath);
1346
- // 6. Render Platform template
1347
- registerHelpers();
1348
- const template = Handlebars.compile(toolsPlatformTemplate);
1349
- const templateData = {
1350
- services: serviceRows,
1351
- unhealthyServices,
1352
- coreVersion,
1353
- availableCoreVersion,
1354
- templatesAvailable,
1355
- templatePath,
1356
- };
1357
- const platformContent = template(templateData);
1358
- // 7. Write TOOLS.md Platform section
1185
+ const platformContent = renderPlatformTemplate(templatePath);
1186
+ // 3. Write TOOLS.md Platform section
1359
1187
  const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
1360
1188
  await updateManagedSection(toolsPath, platformContent, {
1361
1189
  mode: 'section',
@@ -1364,7 +1192,7 @@ async function refreshPlatformContent(options) {
1364
1192
  coreVersion,
1365
1193
  stalenessThresholdMs,
1366
1194
  });
1367
- // 8. Write SOUL.md managed block
1195
+ // 4. Write SOUL.md managed block
1368
1196
  const soulPath = join(workspacePath, WORKSPACE_FILES.soul);
1369
1197
  await updateManagedSection(soulPath, soulSectionContent, {
1370
1198
  mode: 'block',
@@ -1372,7 +1200,7 @@ async function refreshPlatformContent(options) {
1372
1200
  coreVersion,
1373
1201
  stalenessThresholdMs,
1374
1202
  });
1375
- // 9. Write AGENTS.md managed block
1203
+ // 5. Write AGENTS.md managed block
1376
1204
  const agentsPath = join(workspacePath, WORKSPACE_FILES.agents);
1377
1205
  await updateManagedSection(agentsPath, agentsSectionContent, {
1378
1206
  mode: 'block',
@@ -1380,7 +1208,7 @@ async function refreshPlatformContent(options) {
1380
1208
  coreVersion,
1381
1209
  stalenessThresholdMs,
1382
1210
  });
1383
- // 10. Copy templates to config dir
1211
+ // 6. Copy templates to config dir
1384
1212
  copyTemplates(coreConfigDir);
1385
1213
  }
1386
1214
 
@@ -1404,12 +1232,10 @@ class ComponentWriter {
1404
1232
  timer;
1405
1233
  component;
1406
1234
  configDir;
1407
- probeTimeoutMs;
1408
1235
  /** @internal */
1409
- constructor(component, probeTimeoutMs = 3000) {
1236
+ constructor(component) {
1410
1237
  this.component = component;
1411
1238
  this.configDir = getComponentConfigDir(component.name);
1412
- this.probeTimeoutMs = probeTimeoutMs;
1413
1239
  }
1414
1240
  /** The component's config directory path. */
1415
1241
  get componentConfigDir() {
@@ -1466,8 +1292,6 @@ class ComponentWriter {
1466
1292
  componentVersion: this.component.version,
1467
1293
  servicePackage: this.component.servicePackage,
1468
1294
  pluginPackage: this.component.pluginPackage,
1469
- skipRegistryCheck: false,
1470
- probeTimeoutMs: this.probeTimeoutMs,
1471
1295
  });
1472
1296
  }
1473
1297
  catch (err) {
@@ -1604,50 +1428,306 @@ function validateDescriptor(input) {
1604
1428
  * Create a ComponentWriter for a validated component descriptor.
1605
1429
  *
1606
1430
  * @param component - The component descriptor to validate and wrap.
1607
- * @param options - Optional configuration.
1608
1431
  * @returns A new `ComponentWriter` instance.
1609
1432
  * @throws Error if the component descriptor is invalid.
1610
1433
  */
1611
- function createComponentWriter(component, options) {
1434
+ function createComponentWriter(component) {
1612
1435
  validateDescriptor(component);
1613
- return new ComponentWriter(component, options?.probeTimeoutMs);
1436
+ return new ComponentWriter(component);
1614
1437
  }
1615
1438
 
1616
1439
  /**
1617
- * Resolve the OpenClaw workspace root from the plugin API.
1440
+ * Core configuration schema and resolution.
1618
1441
  *
1619
1442
  * @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 })`.
1443
+ * Core config lives at `{configRoot}/jeeves-core/config.json`.
1444
+ * Config resolution order:
1445
+ * 1. Component's own config file
1446
+ * 2. Core config file
1447
+ * 3. Hardcoded library defaults
1632
1448
  */
1449
+ /** Zod schema for a service entry in core config. */
1450
+ const serviceEntrySchema = z.object({
1451
+ /** Service URL (must be a valid URL). */
1452
+ url: z.string().url().describe('Service URL'),
1453
+ });
1454
+ /** Zod schema for the core config file. */
1455
+ const coreConfigSchema = z.object({
1456
+ /** JSON Schema pointer for IDE autocomplete. */
1457
+ $schema: z.string().optional().describe('JSON Schema pointer'),
1458
+ /** Owner identity keys (canonical identityLinks references). */
1459
+ owners: z.array(z.string()).default([]).describe('Owner identity keys'),
1460
+ /** Service URL overrides keyed by service name. */
1461
+ services: z
1462
+ .record(z.string(), serviceEntrySchema)
1463
+ .default({})
1464
+ .describe('Service URL overrides'),
1465
+ /** Registry cache configuration. */
1466
+ registryCache: z
1467
+ .object({
1468
+ /** Cache TTL in seconds for npm registry queries. */
1469
+ ttlSeconds: z
1470
+ .number()
1471
+ .int()
1472
+ .positive()
1473
+ .default(3600)
1474
+ .describe('Cache TTL in seconds'),
1475
+ })
1476
+ .default({})
1477
+ .describe('Registry cache settings'),
1478
+ });
1633
1479
  /**
1634
- * Resolve the workspace root from the OpenClaw plugin API.
1480
+ * Generate a JSON Schema from the Zod schema for `$schema` pointer support.
1635
1481
  *
1636
- * @param api - The plugin API object provided by the gateway at registration.
1637
- * @returns Absolute path to the workspace root.
1482
+ * @returns A JSON Schema object.
1638
1483
  */
1639
- function resolveWorkspacePath(api) {
1640
- // 1. Explicit config value (most authoritative)
1641
- const configured = api.config?.agents?.defaults?.workspace;
1642
- if (typeof configured === 'string' && configured.trim()) {
1643
- return configured;
1484
+ function generateJsonSchema() {
1485
+ return {
1486
+ $schema: 'http://json-schema.org/draft-07/schema#',
1487
+ title: 'Jeeves Core Configuration',
1488
+ type: 'object',
1489
+ properties: {
1490
+ $schema: { type: 'string' },
1491
+ owners: {
1492
+ type: 'array',
1493
+ items: { type: 'string' },
1494
+ default: [],
1495
+ },
1496
+ services: {
1497
+ type: 'object',
1498
+ additionalProperties: {
1499
+ type: 'object',
1500
+ properties: {
1501
+ url: { type: 'string', format: 'uri' },
1502
+ },
1503
+ required: ['url'],
1504
+ },
1505
+ default: {},
1506
+ },
1507
+ registryCache: {
1508
+ type: 'object',
1509
+ properties: {
1510
+ ttlSeconds: {
1511
+ type: 'integer',
1512
+ minimum: 1,
1513
+ default: 3600,
1514
+ },
1515
+ },
1516
+ default: {},
1517
+ },
1518
+ },
1519
+ };
1520
+ }
1521
+ /**
1522
+ * Load and parse a config file, returning undefined if missing or invalid.
1523
+ *
1524
+ * @param configDir - Directory containing config.json.
1525
+ * @returns Parsed config or undefined.
1526
+ */
1527
+ function loadConfig(configDir) {
1528
+ const configPath = join(configDir, CONFIG_FILE);
1529
+ if (!existsSync(configPath))
1530
+ return undefined;
1531
+ try {
1532
+ const raw = readFileSync(configPath, 'utf-8');
1533
+ const parsed = JSON.parse(raw);
1534
+ return coreConfigSchema.parse(parsed);
1644
1535
  }
1645
- // 2. Gateway-provided path resolver
1646
- if (typeof api.resolvePath === 'function') {
1647
- return api.resolvePath('.');
1536
+ catch {
1537
+ return undefined;
1538
+ }
1539
+ }
1540
+
1541
+ /**
1542
+ * Service URL resolution.
1543
+ *
1544
+ * @remarks
1545
+ * Resolves the URL for a named Jeeves service using the following
1546
+ * resolution order:
1547
+ * 1. Consumer's own component config
1548
+ * 2. Core config (`{configRoot}/jeeves-core/config.json`)
1549
+ * 3. Default port constants
1550
+ */
1551
+ /**
1552
+ * Resolve the URL for a named Jeeves service.
1553
+ *
1554
+ * @param serviceName - The service name (e.g., 'watcher', 'runner').
1555
+ * @param consumerName - Optional consumer component name for config override.
1556
+ * @returns The resolved service URL.
1557
+ * @throws Error if `init()` has not been called or the service is unknown.
1558
+ */
1559
+ function getServiceUrl(serviceName, consumerName) {
1560
+ // 1. Check consumer's own config
1561
+ if (consumerName) {
1562
+ const consumerDir = getComponentConfigDir(consumerName);
1563
+ const consumerConfig = loadConfig(consumerDir);
1564
+ const consumerUrl = consumerConfig?.services[serviceName]?.url;
1565
+ if (consumerUrl)
1566
+ return consumerUrl;
1567
+ }
1568
+ // 2. Check core config
1569
+ const coreDir = getCoreConfigDir();
1570
+ const coreConfig = loadConfig(coreDir);
1571
+ const coreUrl = coreConfig?.services[serviceName]?.url;
1572
+ if (coreUrl)
1573
+ return coreUrl;
1574
+ // 3. Fall back to port constants
1575
+ const port = DEFAULT_PORTS[serviceName];
1576
+ if (port !== undefined) {
1577
+ return `http://127.0.0.1:${String(port)}`;
1578
+ }
1579
+ throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
1580
+ }
1581
+
1582
+ /**
1583
+ * Registry version cache for npm package update awareness.
1584
+ *
1585
+ * @remarks
1586
+ * Caches the latest npm registry version in a local JSON file
1587
+ * to avoid expensive `npm view` calls on every refresh cycle.
1588
+ */
1589
+ /**
1590
+ * Check the npm registry for the latest version of a package.
1591
+ *
1592
+ * @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
1593
+ * @param cacheDir - Directory to store the cache file.
1594
+ * @param ttlSeconds - Cache TTL in seconds (default 3600).
1595
+ * @returns The latest version string, or undefined if the check fails.
1596
+ */
1597
+ function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
1598
+ const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
1599
+ // Check cache first
1600
+ if (existsSync(cachePath)) {
1601
+ try {
1602
+ const raw = readFileSync(cachePath, 'utf-8');
1603
+ const entry = JSON.parse(raw);
1604
+ const age = Date.now() - new Date(entry.checkedAt).getTime();
1605
+ if (age < ttlSeconds * 1000) {
1606
+ return entry.version;
1607
+ }
1608
+ }
1609
+ catch {
1610
+ // Cache corrupt — proceed with fresh check
1611
+ }
1612
+ }
1613
+ // Query npm registry
1614
+ try {
1615
+ const result = execSync(`npm view ${packageName} version`, {
1616
+ encoding: 'utf-8',
1617
+ timeout: 15_000,
1618
+ stdio: ['pipe', 'pipe', 'pipe'],
1619
+ }).trim();
1620
+ if (!result)
1621
+ return undefined;
1622
+ // Write cache
1623
+ if (!existsSync(cacheDir)) {
1624
+ mkdirSync(cacheDir, { recursive: true });
1625
+ }
1626
+ const entry = {
1627
+ version: result,
1628
+ checkedAt: new Date().toISOString(),
1629
+ };
1630
+ writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
1631
+ return result;
1632
+ }
1633
+ catch {
1634
+ return undefined;
1648
1635
  }
1649
- // 3. Last resort — unsafe when gateway runs from system32
1650
- return process.cwd();
1636
+ }
1637
+
1638
+ /**
1639
+ * Remove a managed section or entire managed block from a file.
1640
+ *
1641
+ * @remarks
1642
+ * Supports two modes:
1643
+ * - No `sectionId`: Remove the entire managed block (markers + content),
1644
+ * leaving user content intact.
1645
+ * - With `sectionId`: Remove a specific H2 section from within the
1646
+ * managed block. If it was the last section, remove the entire block.
1647
+ *
1648
+ * Provides file-level locking and atomic writes (temp file + rename).
1649
+ * Missing markers or nonexistent sections are no-ops (no error thrown).
1650
+ */
1651
+ /**
1652
+ * Remove a managed section or entire managed block from a file.
1653
+ *
1654
+ * @param filePath - Absolute path to the target file.
1655
+ * @param options - Optional section ID and custom markers.
1656
+ */
1657
+ async function removeManagedSection(filePath, options = {}) {
1658
+ const { sectionId, markers = TOOLS_MARKERS } = options;
1659
+ if (!existsSync(filePath))
1660
+ return;
1661
+ await withFileLock(filePath, () => {
1662
+ const fileContent = readFileSync(filePath, 'utf-8');
1663
+ const parsed = parseManaged(fileContent, markers);
1664
+ if (!parsed.found)
1665
+ return;
1666
+ let newContent;
1667
+ if (!sectionId) {
1668
+ // Remove entire managed block
1669
+ newContent = buildWithoutBlock(parsed.beforeContent, parsed.userContent);
1670
+ }
1671
+ else {
1672
+ // Remove specific section
1673
+ const remaining = parsed.sections.filter((s) => s.id !== sectionId);
1674
+ if (remaining.length === parsed.sections.length) {
1675
+ // Section not found — no-op
1676
+ return;
1677
+ }
1678
+ if (remaining.length === 0) {
1679
+ // Last section removed — remove entire block
1680
+ newContent = buildWithoutBlock(parsed.beforeContent, parsed.userContent);
1681
+ }
1682
+ else {
1683
+ // Rebuild managed block without the removed section
1684
+ newContent = buildWithSections(parsed.beforeContent, parsed.userContent, remaining, markers, parsed.versionStamp?.version);
1685
+ }
1686
+ }
1687
+ atomicWrite(filePath, newContent);
1688
+ });
1689
+ }
1690
+ /** Build file content without the managed block. */
1691
+ function buildWithoutBlock(beforeContent, userContent) {
1692
+ const parts = [];
1693
+ if (beforeContent)
1694
+ parts.push(beforeContent);
1695
+ if (userContent) {
1696
+ if (parts.length > 0)
1697
+ parts.push('');
1698
+ parts.push(userContent);
1699
+ }
1700
+ if (parts.length === 0)
1701
+ return '';
1702
+ return parts.join('\n') + '\n';
1703
+ }
1704
+ /** Rebuild file content with remaining sections. */
1705
+ function buildWithSections(beforeContent, userContent, sections, markers, coreVersion) {
1706
+ const sorted = sortSectionsByOrder([...sections]);
1707
+ const sectionText = sorted
1708
+ .map((s) => `## ${s.id}\n\n${s.content}`)
1709
+ .join('\n\n');
1710
+ const managedBody = markers.title
1711
+ ? `# ${markers.title}\n\n${sectionText}`
1712
+ : sectionText;
1713
+ const beginLine = formatBeginMarker(markers.begin, coreVersion ?? DEFAULT_CORE_VERSION);
1714
+ const endLine = formatEndMarker(markers.end);
1715
+ const parts = [];
1716
+ if (beforeContent) {
1717
+ parts.push(beforeContent);
1718
+ parts.push('');
1719
+ }
1720
+ parts.push(beginLine);
1721
+ parts.push('');
1722
+ parts.push(managedBody);
1723
+ parts.push('');
1724
+ parts.push(endLine);
1725
+ if (userContent) {
1726
+ parts.push('');
1727
+ parts.push(userContent);
1728
+ }
1729
+ parts.push('');
1730
+ return parts.join('\n');
1651
1731
  }
1652
1732
 
1653
1733
  /**
@@ -1698,9 +1778,322 @@ async function seedContent(options) {
1698
1778
  // Seed content via the same code path as writer cycles
1699
1779
  await refreshPlatformContent({
1700
1780
  coreVersion: options.coreVersion,
1701
- probeTimeoutMs: options.probeTimeoutMs ?? 3000,
1702
- skipRegistryCheck: options.skipRegistryCheck ?? true,
1703
1781
  });
1704
1782
  }
1705
1783
 
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 };
1784
+ /**
1785
+ * HTTP helpers for the OpenClaw plugin SDK.
1786
+ *
1787
+ * @remarks
1788
+ * Thin wrappers around `fetch` that throw on non-OK responses
1789
+ * and handle JSON serialisation/deserialisation.
1790
+ */
1791
+ /**
1792
+ * Fetch a URL with an automatic abort timeout.
1793
+ *
1794
+ * @param url - URL to fetch.
1795
+ * @param timeoutMs - Timeout in milliseconds before aborting.
1796
+ * @param init - Optional `fetch` init options.
1797
+ * @returns The fetch Response object.
1798
+ */
1799
+ async function fetchWithTimeout(url, timeoutMs, init) {
1800
+ const controller = new AbortController();
1801
+ const timeout = setTimeout(() => {
1802
+ controller.abort();
1803
+ }, timeoutMs);
1804
+ try {
1805
+ return await fetch(url, { ...init, signal: controller.signal });
1806
+ }
1807
+ finally {
1808
+ clearTimeout(timeout);
1809
+ }
1810
+ }
1811
+ /**
1812
+ * Fetch JSON from a URL, throwing on non-OK responses.
1813
+ *
1814
+ * @param url - URL to fetch.
1815
+ * @param init - Optional `fetch` init options.
1816
+ * @returns Parsed JSON response body.
1817
+ * @throws Error with `HTTP {status}: {body}` message on non-OK responses.
1818
+ */
1819
+ async function fetchJson(url, init) {
1820
+ const res = await fetch(url, init);
1821
+ if (!res.ok) {
1822
+ throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
1823
+ }
1824
+ return res.json();
1825
+ }
1826
+ /**
1827
+ * POST JSON to a URL and return parsed response.
1828
+ *
1829
+ * @param url - URL to POST to.
1830
+ * @param body - Request body (will be JSON-stringified).
1831
+ * @returns Parsed JSON response body.
1832
+ */
1833
+ async function postJson(url, body) {
1834
+ return fetchJson(url, {
1835
+ method: 'POST',
1836
+ headers: { 'Content-Type': 'application/json' },
1837
+ body: JSON.stringify(body),
1838
+ });
1839
+ }
1840
+
1841
+ /**
1842
+ * OpenClaw configuration helpers for plugin CLI installers.
1843
+ *
1844
+ * @remarks
1845
+ * Provides resolution of OpenClaw home directory and config file path,
1846
+ * plus idempotent config patching for plugin install/uninstall.
1847
+ */
1848
+ /**
1849
+ * Resolve the OpenClaw home directory.
1850
+ *
1851
+ * @remarks
1852
+ * Resolution order:
1853
+ * 1. `OPENCLAW_CONFIG` env var → dirname of the config file path
1854
+ * 2. `OPENCLAW_HOME` env var → resolved path
1855
+ * 3. Default: `~/.openclaw`
1856
+ *
1857
+ * @returns Absolute path to the OpenClaw home directory.
1858
+ */
1859
+ function resolveOpenClawHome() {
1860
+ if (process.env.OPENCLAW_CONFIG) {
1861
+ return dirname(resolve(process.env.OPENCLAW_CONFIG));
1862
+ }
1863
+ if (process.env.OPENCLAW_HOME) {
1864
+ return resolve(process.env.OPENCLAW_HOME);
1865
+ }
1866
+ return join(homedir(), '.openclaw');
1867
+ }
1868
+ /**
1869
+ * Resolve the OpenClaw config file path.
1870
+ *
1871
+ * @remarks
1872
+ * If `OPENCLAW_CONFIG` is set, uses that directly.
1873
+ * Otherwise defaults to `{home}/openclaw.json`.
1874
+ *
1875
+ * @param home - The OpenClaw home directory.
1876
+ * @returns Absolute path to the config file.
1877
+ */
1878
+ function resolveConfigPath(home) {
1879
+ if (process.env.OPENCLAW_CONFIG) {
1880
+ return resolve(process.env.OPENCLAW_CONFIG);
1881
+ }
1882
+ return join(home, 'openclaw.json');
1883
+ }
1884
+ /**
1885
+ * Patch an allowlist array: add or remove the plugin ID.
1886
+ *
1887
+ * @returns A log message if a change was made, or undefined.
1888
+ */
1889
+ function patchAllowList(parent, key, label, pluginId, mode) {
1890
+ if (mode === 'add') {
1891
+ if (!Array.isArray(parent[key])) {
1892
+ parent[key] = [pluginId];
1893
+ return `Created ${label} with "${pluginId}"`;
1894
+ }
1895
+ const list = parent[key];
1896
+ if (!list.includes(pluginId)) {
1897
+ list.push(pluginId);
1898
+ return `Added "${pluginId}" to ${label}`;
1899
+ }
1900
+ }
1901
+ else {
1902
+ if (!Array.isArray(parent[key]))
1903
+ return undefined;
1904
+ const list = parent[key];
1905
+ const filtered = list.filter((id) => id !== pluginId);
1906
+ if (filtered.length !== list.length) {
1907
+ parent[key] = filtered;
1908
+ return `Removed "${pluginId}" from ${label}`;
1909
+ }
1910
+ }
1911
+ return undefined;
1912
+ }
1913
+ /**
1914
+ * Patch an OpenClaw config for plugin install or uninstall.
1915
+ *
1916
+ * @remarks
1917
+ * Manages `plugins.entries.{pluginId}` and `tools.alsoAllow`.
1918
+ * Idempotent: adding twice produces no duplicates; removing when absent
1919
+ * produces no errors.
1920
+ *
1921
+ * @param config - The parsed OpenClaw config object (mutated in place).
1922
+ * @param pluginId - The plugin identifier.
1923
+ * @param mode - Whether to add or remove the plugin.
1924
+ * @returns Array of log messages describing changes made.
1925
+ */
1926
+ function patchConfig(config, pluginId, mode) {
1927
+ const messages = [];
1928
+ // Ensure plugins section
1929
+ if (!config.plugins || typeof config.plugins !== 'object') {
1930
+ config.plugins = {};
1931
+ }
1932
+ const plugins = config.plugins;
1933
+ // plugins.entries
1934
+ if (!plugins.entries || typeof plugins.entries !== 'object') {
1935
+ plugins.entries = {};
1936
+ }
1937
+ const entries = plugins.entries;
1938
+ if (mode === 'add') {
1939
+ if (!entries[pluginId]) {
1940
+ entries[pluginId] = { enabled: true };
1941
+ messages.push(`Added "${pluginId}" to plugins.entries`);
1942
+ }
1943
+ }
1944
+ else if (pluginId in entries) {
1945
+ Reflect.deleteProperty(entries, pluginId);
1946
+ messages.push(`Removed "${pluginId}" from plugins.entries`);
1947
+ }
1948
+ // tools.alsoAllow
1949
+ if (!config.tools || typeof config.tools !== 'object') {
1950
+ config.tools = {};
1951
+ }
1952
+ const tools = config.tools;
1953
+ const toolAlsoAllow = patchAllowList(tools, 'alsoAllow', 'tools.alsoAllow', pluginId, mode);
1954
+ if (toolAlsoAllow)
1955
+ messages.push(toolAlsoAllow);
1956
+ return messages;
1957
+ }
1958
+
1959
+ /**
1960
+ * Plugin resolution helpers for the OpenClaw plugin SDK.
1961
+ *
1962
+ * @remarks
1963
+ * Provides workspace path resolution and plugin setting resolution
1964
+ * with a standard three-step fallback chain:
1965
+ * plugin config → environment variable → default value.
1966
+ */
1967
+ /**
1968
+ * Resolve the workspace root from the OpenClaw plugin API.
1969
+ *
1970
+ * @remarks
1971
+ * Tries three sources in order:
1972
+ * 1. `api.config.agents.defaults.workspace` — explicit config
1973
+ * 2. `api.resolvePath('.')` — gateway-provided path resolver
1974
+ * 3. `process.cwd()` — last resort
1975
+ *
1976
+ * @param api - The plugin API object provided by the gateway.
1977
+ * @returns Absolute path to the workspace root.
1978
+ */
1979
+ function resolveWorkspacePath(api) {
1980
+ const configured = api.config?.agents?.defaults?.workspace;
1981
+ if (typeof configured === 'string' && configured.trim()) {
1982
+ return configured;
1983
+ }
1984
+ if (typeof api.resolvePath === 'function') {
1985
+ return api.resolvePath('.');
1986
+ }
1987
+ return process.cwd();
1988
+ }
1989
+ /**
1990
+ * Resolve a plugin setting via the standard three-step fallback chain:
1991
+ * plugin config → environment variable → fallback value.
1992
+ *
1993
+ * @param api - Plugin API object.
1994
+ * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
1995
+ * @param key - Config key within the plugin's config object.
1996
+ * @param envVar - Environment variable name.
1997
+ * @param fallback - Default value if neither source provides one.
1998
+ * @returns The resolved setting value.
1999
+ */
2000
+ function resolvePluginSetting(api, pluginId, key, envVar, fallback) {
2001
+ const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
2002
+ if (typeof fromPlugin === 'string')
2003
+ return fromPlugin;
2004
+ const fromEnv = process.env[envVar];
2005
+ if (fromEnv)
2006
+ return fromEnv;
2007
+ return fallback;
2008
+ }
2009
+ /**
2010
+ * Resolve an optional plugin setting via the two-step fallback chain:
2011
+ * plugin config → environment variable. Returns `undefined` if neither
2012
+ * source provides a value.
2013
+ *
2014
+ * @param api - Plugin API object.
2015
+ * @param pluginId - Plugin identifier (e.g., 'jeeves-watcher-openclaw').
2016
+ * @param key - Config key within the plugin's config object.
2017
+ * @param envVar - Environment variable name.
2018
+ * @returns The resolved setting value, or `undefined`.
2019
+ */
2020
+ function resolveOptionalPluginSetting(api, pluginId, key, envVar) {
2021
+ const fromPlugin = api.config?.plugins?.entries?.[pluginId]?.config?.[key];
2022
+ if (typeof fromPlugin === 'string')
2023
+ return fromPlugin;
2024
+ const fromEnv = process.env[envVar];
2025
+ if (fromEnv)
2026
+ return fromEnv;
2027
+ return undefined;
2028
+ }
2029
+
2030
+ /**
2031
+ * Tool result formatters for the OpenClaw plugin SDK.
2032
+ *
2033
+ * @remarks
2034
+ * Provides standardised helpers for building `ToolResult` objects:
2035
+ * success, error, and connection-error variants.
2036
+ */
2037
+ /**
2038
+ * Format a successful tool result.
2039
+ *
2040
+ * @param data - Arbitrary data to return as JSON.
2041
+ * @returns A `ToolResult` with JSON-stringified content.
2042
+ */
2043
+ function ok(data) {
2044
+ return {
2045
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
2046
+ };
2047
+ }
2048
+ /**
2049
+ * Format an error tool result.
2050
+ *
2051
+ * @param error - Error instance, string, or other value.
2052
+ * @returns A `ToolResult` with `isError: true`.
2053
+ */
2054
+ function fail(error) {
2055
+ const message = error instanceof Error ? error.message : String(error);
2056
+ return {
2057
+ content: [{ type: 'text', text: 'Error: ' + message }],
2058
+ isError: true,
2059
+ };
2060
+ }
2061
+ /**
2062
+ * Format a connection error with actionable guidance.
2063
+ *
2064
+ * @remarks
2065
+ * Detects `ECONNREFUSED`, `ENOTFOUND`, and `ETIMEDOUT` from
2066
+ * `error.cause.code` and returns a user-friendly message referencing
2067
+ * the plugin's `config.apiUrl` setting. Falls back to `fail()` for
2068
+ * non-connection errors.
2069
+ *
2070
+ * @param error - Error instance (typically from `fetch`).
2071
+ * @param baseUrl - The URL that was being contacted.
2072
+ * @param pluginId - The plugin identifier for config guidance.
2073
+ * @returns A `ToolResult` with `isError: true`.
2074
+ */
2075
+ function connectionFail(error, baseUrl, pluginId) {
2076
+ const cause = error instanceof Error ? error.cause : undefined;
2077
+ const code = cause && typeof cause === 'object' && 'code' in cause
2078
+ ? String(cause.code)
2079
+ : '';
2080
+ const isConnectionError = code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT';
2081
+ if (isConnectionError) {
2082
+ return {
2083
+ content: [
2084
+ {
2085
+ type: 'text',
2086
+ text: [
2087
+ `Service not reachable at ${baseUrl}.`,
2088
+ 'Either start the service, or if it runs on a different port,',
2089
+ `set plugins.entries.${pluginId}.config.apiUrl in openclaw.json.`,
2090
+ ].join('\n'),
2091
+ },
2092
+ ],
2093
+ isError: true,
2094
+ };
2095
+ }
2096
+ return fail(error);
2097
+ }
2098
+
2099
+ export { AGENTS_MARKERS, CLEANUP_FLAG, COMPONENT_CONFIG_PREFIX, COMPONENT_VERSIONS_FILE, CONFIG_FILE, CORE_CONFIG_DIR, CORE_VERSION, ComponentWriter, DEFAULT_CORE_VERSION, DEFAULT_PORTS, META_PORT, REGISTRY_CACHE_FILE, RUNNER_PORT, SECTION_IDS, SECTION_ORDER, SERVER_PORT, SOUL_MARKERS, STALENESS_THRESHOLD_MS, STALE_LOCK_MS, TEMPLATES_DIR, TOOLS_MARKERS, VERSION_STAMP_PATTERN, WATCHER_PORT, WORKSPACE_FILES, atomicWrite, checkRegistryVersion, connectionFail, coreConfigSchema, createAsyncContentCache, createComponentWriter, createConfigQueryHandler, fail, fetchJson, fetchWithTimeout, formatBeginMarker, formatEndMarker, generateJsonSchema, getComponentConfigDir, getConfigRoot, getCoreConfigDir, getCoreConfigFile, getServiceUrl, getWorkspacePath, init, jaccard, needsCleanup, ok, parseManaged, patchConfig, postJson, readComponentVersions, refreshPlatformContent, removeManagedSection, resetInit, resolveConfigPath, resolveOpenClawHome, resolveOptionalPluginSetting, resolvePluginSetting, resolveWorkspacePath, seedContent, shingles, shouldWrite, updateManagedSection, withFileLock, writeComponentVersion };