@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/README.md +90 -11
- package/content/agents-section.md +5 -1
- package/content/soul-section.md +8 -0
- package/content/templates/spec.md +6 -0
- package/content/tools-platform.md +5 -15
- package/dist/cli/jeeves/index.js +324 -345
- package/dist/index.d.ts +412 -138
- package/dist/index.js +922 -529
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,12 +1,203 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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(
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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.
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
945
|
-
Reference templates are available at \`
|
|
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
|
-
|
|
1090
|
+
<!-- ELSE_TEMPLATES -->
|
|
954
1091
|
> Reference templates not yet installed. Run \`npx @karmaniverous/jeeves install\` to seed templates.
|
|
955
|
-
|
|
1092
|
+
<!-- ENDIF_TEMPLATES -->
|
|
956
1093
|
`;
|
|
957
1094
|
|
|
958
1095
|
/**
|
|
959
|
-
*
|
|
1096
|
+
* Internal function to maintain SOUL.md, AGENTS.md, and TOOLS.md Platform section.
|
|
960
1097
|
*
|
|
961
1098
|
* @remarks
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
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
|
-
*
|
|
1105
|
+
* Resolve the package's content directory for template file copying.
|
|
1000
1106
|
*
|
|
1001
|
-
* @
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
*
|
|
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
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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,
|
|
1171
|
+
const { coreVersion, componentName, componentVersion, servicePackage, pluginPackage, stalenessThresholdMs, } = options;
|
|
1306
1172
|
const workspacePath = getWorkspacePath();
|
|
1307
1173
|
const coreConfigDir = getCoreConfigDir();
|
|
1308
|
-
// 1.
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
//
|
|
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
|
|
1346
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
1434
|
+
function createComponentWriter(component) {
|
|
1612
1435
|
validateDescriptor(component);
|
|
1613
|
-
return new ComponentWriter(component
|
|
1436
|
+
return new ComponentWriter(component);
|
|
1614
1437
|
}
|
|
1615
1438
|
|
|
1616
1439
|
/**
|
|
1617
|
-
*
|
|
1440
|
+
* Core configuration schema and resolution.
|
|
1618
1441
|
*
|
|
1619
1442
|
* @remarks
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
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
|
-
*
|
|
1480
|
+
* Generate a JSON Schema from the Zod schema for `$schema` pointer support.
|
|
1635
1481
|
*
|
|
1636
|
-
* @
|
|
1637
|
-
* @returns Absolute path to the workspace root.
|
|
1482
|
+
* @returns A JSON Schema object.
|
|
1638
1483
|
*/
|
|
1639
|
-
function
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
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 };
|