@karmaniverous/jeeves 0.4.6 → 0.5.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 +56 -3
- package/content/skill.md +99 -0
- package/dist/cli/jeeves/index.js +660 -158
- package/dist/cli/plugin/index.js +206 -17
- package/dist/cli/service/index.js +63 -7
- package/dist/index.d.ts +393 -14
- package/dist/index.js +1175 -122
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { writeFileSync, renameSync, unlinkSync, existsSync, readFileSync, mkdirSync,
|
|
2
|
-
import { join, dirname, resolve } from 'node:path';
|
|
1
|
+
import fs, { writeFileSync, renameSync, unlinkSync, existsSync, readFileSync, mkdirSync, readdirSync, copyFileSync, rmSync, cpSync } from 'node:fs';
|
|
2
|
+
import path, { join, dirname, resolve, basename } from 'node:path';
|
|
3
3
|
import { lock } from 'proper-lockfile';
|
|
4
4
|
import { JSONPath } from 'jsonpath-plus';
|
|
5
|
+
import { major, valid, gte, gt } from 'semver';
|
|
6
|
+
import { z } from 'zod';
|
|
5
7
|
import * as commander from 'commander';
|
|
6
|
-
import {
|
|
8
|
+
import { packageDirectorySync } from 'package-directory';
|
|
7
9
|
import { homedir } from 'node:os';
|
|
8
|
-
import {
|
|
9
|
-
import { execSync } from 'node:child_process';
|
|
10
|
+
import cp, { execSync } from 'node:child_process';
|
|
10
11
|
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import
|
|
12
|
+
import crypto from 'node:crypto';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Comment markers for managed content blocks.
|
|
@@ -68,8 +69,8 @@ const ALL_MARKERS = [
|
|
|
68
69
|
const VERSION_STAMP_PATTERN = /<!--\s*(.+?)\s*\|\s*core:(\S+)\s*\|\s*(\S+)\s*-->/;
|
|
69
70
|
/** Staleness threshold for version-stamp convergence in milliseconds. */
|
|
70
71
|
const STALENESS_THRESHOLD_MS = 5 * 60 * 1000;
|
|
71
|
-
/** Warning text
|
|
72
|
-
const CLEANUP_FLAG = '> ⚠️ CLEANUP NEEDED: Orphaned Jeeves content
|
|
72
|
+
/** Warning text injected inside managed block when cleanup is needed. */
|
|
73
|
+
const CLEANUP_FLAG = '> ⚠️ CLEANUP NEEDED: Orphaned Jeeves content detected outside this managed block. Review the file and remove any content outside the BEGIN/END markers that duplicates what appears inside them.';
|
|
73
74
|
|
|
74
75
|
/**
|
|
75
76
|
* Directory and file path conventions for the Jeeves platform.
|
|
@@ -88,7 +89,13 @@ const WORKSPACE_FILES = {
|
|
|
88
89
|
agents: 'AGENTS.md',
|
|
89
90
|
/** HEARTBEAT.md — platform status and health alerts. */
|
|
90
91
|
heartbeat: 'HEARTBEAT.md',
|
|
92
|
+
/** MEMORY.md — curated long-term memory. */
|
|
93
|
+
memory: 'MEMORY.md',
|
|
91
94
|
};
|
|
95
|
+
/** Skill directory name within workspace. */
|
|
96
|
+
const SKILLS_DIR = 'skills';
|
|
97
|
+
/** Jeeves skill directory name. */
|
|
98
|
+
const JEEVES_SKILL_DIR = 'jeeves';
|
|
92
99
|
/** Templates directory name within core config. */
|
|
93
100
|
const TEMPLATES_DIR = 'templates';
|
|
94
101
|
/** Registry cache file name. */
|
|
@@ -176,14 +183,14 @@ const PLATFORM_COMPONENTS = [
|
|
|
176
183
|
* Core library version, inlined at build time.
|
|
177
184
|
*
|
|
178
185
|
* @remarks
|
|
179
|
-
* The `0.4.
|
|
186
|
+
* The `0.4.7` placeholder is replaced by
|
|
180
187
|
* `@rollup/plugin-replace` during the build with the actual version
|
|
181
188
|
* from `package.json`. This ensures the correct version survives
|
|
182
189
|
* when consumers bundle core into their own dist (where runtime
|
|
183
190
|
* `import.meta.url`-based resolution would find the wrong package.json).
|
|
184
191
|
*/
|
|
185
192
|
/** The core library version from package.json (inlined at build time). */
|
|
186
|
-
const CORE_VERSION = '0.4.
|
|
193
|
+
const CORE_VERSION = '0.4.7';
|
|
187
194
|
|
|
188
195
|
/**
|
|
189
196
|
* Workspace and config root initialization.
|
|
@@ -556,6 +563,257 @@ function createStatusHandler(options) {
|
|
|
556
563
|
};
|
|
557
564
|
}
|
|
558
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Runtime Node.js version floor check.
|
|
568
|
+
*
|
|
569
|
+
* @module
|
|
570
|
+
*/
|
|
571
|
+
/** Minimum supported Node.js major version. */
|
|
572
|
+
const MIN_NODE_MAJOR = 22;
|
|
573
|
+
/**
|
|
574
|
+
* Check that the running Node.js version meets the minimum requirement.
|
|
575
|
+
* Prints an error and exits with code 1 if the check fails.
|
|
576
|
+
*/
|
|
577
|
+
function checkNodeVersion() {
|
|
578
|
+
const nodeMajor = major(process.versions.node);
|
|
579
|
+
if (nodeMajor < MIN_NODE_MAJOR) {
|
|
580
|
+
console.error(`Error: jeeves requires Node.js >= ${String(MIN_NODE_MAJOR)}. Current: ${process.versions.node}`);
|
|
581
|
+
process.exit(1);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Workspace-level shared configuration: `jeeves.config.json`.
|
|
587
|
+
*
|
|
588
|
+
* @remarks
|
|
589
|
+
* Lives at the OpenClaw workspace root alongside TOOLS.md and SOUL.md.
|
|
590
|
+
* Provides namespaced shared defaults consumed by the root Jeeves CLI.
|
|
591
|
+
* Resolution precedence: CLI flags → env vars → jeeves.config.json → defaults.
|
|
592
|
+
*
|
|
593
|
+
* This does not replace component-owned config schemas (Decision 41).
|
|
594
|
+
*/
|
|
595
|
+
/** Workspace config file name. */
|
|
596
|
+
const WORKSPACE_CONFIG_FILE = 'jeeves.config.json';
|
|
597
|
+
/** Core shared config section. */
|
|
598
|
+
const workspaceCoreConfigSchema = z
|
|
599
|
+
.object({
|
|
600
|
+
/** Workspace root path. */
|
|
601
|
+
workspace: z.string().optional().describe('Workspace root path'),
|
|
602
|
+
/** Platform config root path. */
|
|
603
|
+
configRoot: z.string().optional().describe('Platform config root path'),
|
|
604
|
+
/** OpenClaw gateway URL. */
|
|
605
|
+
gatewayUrl: z.string().optional().describe('OpenClaw gateway URL'),
|
|
606
|
+
})
|
|
607
|
+
.partial();
|
|
608
|
+
/** Memory shared config section. */
|
|
609
|
+
const workspaceMemoryConfigSchema = z
|
|
610
|
+
.object({
|
|
611
|
+
/** MEMORY.md character budget. */
|
|
612
|
+
budget: z.number().int().positive().optional().describe('Memory budget'),
|
|
613
|
+
/** Warning threshold as a fraction of budget. */
|
|
614
|
+
warningThreshold: z
|
|
615
|
+
.number()
|
|
616
|
+
.min(0)
|
|
617
|
+
.max(1)
|
|
618
|
+
.optional()
|
|
619
|
+
.describe('Memory warning threshold'),
|
|
620
|
+
/** Staleness threshold in days. */
|
|
621
|
+
staleDays: z
|
|
622
|
+
.number()
|
|
623
|
+
.int()
|
|
624
|
+
.positive()
|
|
625
|
+
.optional()
|
|
626
|
+
.describe('Memory staleness threshold in days'),
|
|
627
|
+
})
|
|
628
|
+
.partial();
|
|
629
|
+
/** Workspace config Zod schema. */
|
|
630
|
+
const workspaceConfigSchema = z.object({
|
|
631
|
+
/** JSON Schema pointer for IDE autocomplete. */
|
|
632
|
+
$schema: z.string().optional().describe('JSON Schema pointer'),
|
|
633
|
+
/** Core shared defaults. */
|
|
634
|
+
core: workspaceCoreConfigSchema.optional(),
|
|
635
|
+
/** Memory hygiene shared defaults. */
|
|
636
|
+
memory: workspaceMemoryConfigSchema.optional(),
|
|
637
|
+
});
|
|
638
|
+
/** Built-in workspace config defaults. */
|
|
639
|
+
const WORKSPACE_CONFIG_DEFAULTS = {
|
|
640
|
+
core: {
|
|
641
|
+
workspace: '.',
|
|
642
|
+
configRoot: './config',
|
|
643
|
+
gatewayUrl: 'http://127.0.0.1:3000',
|
|
644
|
+
},
|
|
645
|
+
memory: {
|
|
646
|
+
budget: 20_000,
|
|
647
|
+
warningThreshold: 0.8,
|
|
648
|
+
staleDays: 30,
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
/**
|
|
652
|
+
* Load workspace config from `jeeves.config.json` at a given path.
|
|
653
|
+
*
|
|
654
|
+
* @param workspacePath - Workspace root directory.
|
|
655
|
+
* @returns Parsed config or undefined if missing or invalid.
|
|
656
|
+
*/
|
|
657
|
+
function loadWorkspaceConfig(workspacePath) {
|
|
658
|
+
const configPath = join(workspacePath, WORKSPACE_CONFIG_FILE);
|
|
659
|
+
if (!existsSync(configPath))
|
|
660
|
+
return undefined;
|
|
661
|
+
try {
|
|
662
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
663
|
+
const parsed = JSON.parse(raw);
|
|
664
|
+
return workspaceConfigSchema.parse(parsed);
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
668
|
+
console.warn(`jeeves-core: failed to load ${configPath}: ${msg}`);
|
|
669
|
+
return undefined;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Resolve a config value with four-tier precedence.
|
|
674
|
+
*
|
|
675
|
+
* @param flagValue - CLI flag value (highest priority).
|
|
676
|
+
* @param envValue - Environment variable value.
|
|
677
|
+
* @param fileValue - Value from jeeves.config.json.
|
|
678
|
+
* @param defaultValue - Built-in default (lowest priority).
|
|
679
|
+
* @returns The resolved value with provenance annotation.
|
|
680
|
+
*/
|
|
681
|
+
function resolveConfigValue(flagValue, envValue, fileValue, defaultValue) {
|
|
682
|
+
if (flagValue !== undefined)
|
|
683
|
+
return { value: flagValue, provenance: 'flag' };
|
|
684
|
+
if (envValue !== undefined)
|
|
685
|
+
return { value: envValue, provenance: 'env' };
|
|
686
|
+
if (fileValue !== undefined)
|
|
687
|
+
return { value: fileValue, provenance: 'file' };
|
|
688
|
+
return { value: defaultValue, provenance: 'default' };
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Generate a JSON Schema for the workspace config.
|
|
692
|
+
*
|
|
693
|
+
* @returns A JSON Schema object.
|
|
694
|
+
*/
|
|
695
|
+
function generateWorkspaceJsonSchema() {
|
|
696
|
+
return {
|
|
697
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
698
|
+
title: 'Jeeves Workspace Configuration',
|
|
699
|
+
type: 'object',
|
|
700
|
+
properties: {
|
|
701
|
+
$schema: { type: 'string' },
|
|
702
|
+
core: {
|
|
703
|
+
type: 'object',
|
|
704
|
+
properties: {
|
|
705
|
+
workspace: {
|
|
706
|
+
type: 'string',
|
|
707
|
+
default: WORKSPACE_CONFIG_DEFAULTS.core.workspace,
|
|
708
|
+
},
|
|
709
|
+
configRoot: {
|
|
710
|
+
type: 'string',
|
|
711
|
+
default: WORKSPACE_CONFIG_DEFAULTS.core.configRoot,
|
|
712
|
+
},
|
|
713
|
+
gatewayUrl: {
|
|
714
|
+
type: 'string',
|
|
715
|
+
default: WORKSPACE_CONFIG_DEFAULTS.core.gatewayUrl,
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
memory: {
|
|
720
|
+
type: 'object',
|
|
721
|
+
properties: {
|
|
722
|
+
budget: {
|
|
723
|
+
type: 'integer',
|
|
724
|
+
minimum: 1,
|
|
725
|
+
default: WORKSPACE_CONFIG_DEFAULTS.memory.budget,
|
|
726
|
+
},
|
|
727
|
+
warningThreshold: {
|
|
728
|
+
type: 'number',
|
|
729
|
+
minimum: 0,
|
|
730
|
+
maximum: 1,
|
|
731
|
+
default: WORKSPACE_CONFIG_DEFAULTS.memory.warningThreshold,
|
|
732
|
+
},
|
|
733
|
+
staleDays: {
|
|
734
|
+
type: 'integer',
|
|
735
|
+
minimum: 1,
|
|
736
|
+
default: WORKSPACE_CONFIG_DEFAULTS.memory.staleDays,
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Shared CLI defaults and resolution for Jeeves CLI commands.
|
|
746
|
+
*
|
|
747
|
+
* @remarks
|
|
748
|
+
* All root CLI commands share workspace/config-root resolution. Values follow
|
|
749
|
+
* the shared precedence model: flags → env → jeeves.config.json → defaults.
|
|
750
|
+
*/
|
|
751
|
+
/** Default workspace path. */
|
|
752
|
+
const DEFAULT_WORKSPACE = WORKSPACE_CONFIG_DEFAULTS.core.workspace;
|
|
753
|
+
/** Default config root path. */
|
|
754
|
+
const DEFAULT_CONFIG_ROOT = WORKSPACE_CONFIG_DEFAULTS.core.configRoot;
|
|
755
|
+
/** Read a numeric env var or return undefined if missing, empty, or invalid. */
|
|
756
|
+
function readNumericEnv(name) {
|
|
757
|
+
const raw = process.env[name];
|
|
758
|
+
if (raw === undefined || raw.trim() === '')
|
|
759
|
+
return undefined;
|
|
760
|
+
const value = Number(raw);
|
|
761
|
+
return Number.isFinite(value) ? value : undefined;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Resolve shared CLI config using flags, env, file, and defaults.
|
|
765
|
+
*
|
|
766
|
+
* @param opts - Parsed CLI workspace/config-root options.
|
|
767
|
+
* @returns Resolved config tree with provenance on every leaf.
|
|
768
|
+
*/
|
|
769
|
+
function resolveCliConfig(opts) {
|
|
770
|
+
const workspaceSeed = resolveConfigValue(opts.workspace, process.env['JEEVES_WORKSPACE'], undefined, DEFAULT_WORKSPACE);
|
|
771
|
+
const fileConfig = loadWorkspaceConfig(workspaceSeed.value);
|
|
772
|
+
return {
|
|
773
|
+
core: {
|
|
774
|
+
workspace: resolveConfigValue(opts.workspace, process.env['JEEVES_WORKSPACE'], fileConfig?.core?.workspace, DEFAULT_WORKSPACE),
|
|
775
|
+
configRoot: resolveConfigValue(opts.configRoot, process.env['JEEVES_CONFIG_ROOT'], fileConfig?.core?.configRoot, DEFAULT_CONFIG_ROOT),
|
|
776
|
+
gatewayUrl: resolveConfigValue(undefined, process.env['JEEVES_GATEWAY_URL'], fileConfig?.core?.gatewayUrl, WORKSPACE_CONFIG_DEFAULTS.core.gatewayUrl),
|
|
777
|
+
},
|
|
778
|
+
memory: {
|
|
779
|
+
budget: resolveConfigValue(undefined, readNumericEnv('JEEVES_MEMORY_BUDGET'), fileConfig?.memory?.budget, WORKSPACE_CONFIG_DEFAULTS.memory.budget),
|
|
780
|
+
warningThreshold: resolveConfigValue(undefined, readNumericEnv('JEEVES_MEMORY_WARNING_THRESHOLD'), fileConfig?.memory?.warningThreshold, WORKSPACE_CONFIG_DEFAULTS.memory.warningThreshold),
|
|
781
|
+
staleDays: resolveConfigValue(undefined, readNumericEnv('JEEVES_MEMORY_STALE_DAYS'), fileConfig?.memory?.staleDays, WORKSPACE_CONFIG_DEFAULTS.memory.staleDays),
|
|
782
|
+
},
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Initialize core from standard CLI options after resolving shared defaults.
|
|
787
|
+
*
|
|
788
|
+
* @param opts - Parsed Commander options with workspace and configRoot.
|
|
789
|
+
* @returns Resolved CLI config.
|
|
790
|
+
*/
|
|
791
|
+
function initFromOptions(opts) {
|
|
792
|
+
const resolved = resolveCliConfig(opts);
|
|
793
|
+
init({
|
|
794
|
+
workspacePath: resolve(resolved.core.workspace.value),
|
|
795
|
+
configRoot: resolve(resolved.core.configRoot.value),
|
|
796
|
+
});
|
|
797
|
+
return resolved;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* `jeeves config [jsonpath]` — inspect effective shared CLI configuration.
|
|
802
|
+
*
|
|
803
|
+
* @remarks
|
|
804
|
+
* Shows effective values and provenance using the shared precedence model.
|
|
805
|
+
* Optional JSONPath filters the resolved config tree.
|
|
806
|
+
*/
|
|
807
|
+
/**
|
|
808
|
+
* Build the effective shared CLI config tree.
|
|
809
|
+
*
|
|
810
|
+
* @param opts - Parsed CLI workspace/config-root options.
|
|
811
|
+
* @returns Effective config tree with provenance on each leaf.
|
|
812
|
+
*/
|
|
813
|
+
function buildEffectiveConfig(opts) {
|
|
814
|
+
return initFromOptions(opts);
|
|
815
|
+
}
|
|
816
|
+
|
|
559
817
|
function getDefaultExportFromCjs (x) {
|
|
560
818
|
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
|
|
561
819
|
}
|
|
@@ -1120,6 +1378,129 @@ function buildWithSections(beforeContent, userContent, sections, markers, coreVe
|
|
|
1120
1378
|
return parts.join('\n');
|
|
1121
1379
|
}
|
|
1122
1380
|
|
|
1381
|
+
var skillContent = `---
|
|
1382
|
+
name: jeeves
|
|
1383
|
+
description: Jeeves platform architecture, data flow, component interaction, scripts repo, and coordination knowledge. Use when making architectural decisions, coordinating across components, checking platform health, managing service lifecycle, or working with the scripts repo.
|
|
1384
|
+
---
|
|
1385
|
+
|
|
1386
|
+
# Jeeves Platform Skill
|
|
1387
|
+
|
|
1388
|
+
## Platform Architecture
|
|
1389
|
+
|
|
1390
|
+
Jeeves is a four-component platform coordinated by a shared library (\`@karmaniverous/jeeves\`):
|
|
1391
|
+
|
|
1392
|
+
| Component | Role | Port |
|
|
1393
|
+
|-----------|------|------|
|
|
1394
|
+
| **jeeves-runner** | Execute: scheduled jobs, SQLite state, HTTP API | 1937 |
|
|
1395
|
+
| **jeeves-watcher** | Index: file→Qdrant semantic indexing, inference rules | 1936 |
|
|
1396
|
+
| **jeeves-server** | Present: web UI, file browser, doc render, export | 1934 |
|
|
1397
|
+
| **jeeves-meta** | Distill: LLM synthesis, .meta/ directories, scheduling | 1938 |
|
|
1398
|
+
|
|
1399
|
+
Core (\`@karmaniverous/jeeves\`) is a **library + CLI**, not a service. No port.
|
|
1400
|
+
|
|
1401
|
+
## Data Flow
|
|
1402
|
+
|
|
1403
|
+
\`\`\`
|
|
1404
|
+
Files → Watcher (index) → Qdrant → Meta (synthesize) → .meta/ → Watcher (re-index)
|
|
1405
|
+
↓
|
|
1406
|
+
Runner (schedule) → Scripts → Services ← Server (present) ← Browser
|
|
1407
|
+
\`\`\`
|
|
1408
|
+
|
|
1409
|
+
## Component Interaction
|
|
1410
|
+
|
|
1411
|
+
- **Watcher** indexes files into Qdrant with inference rules and enrichments.
|
|
1412
|
+
- **Meta** reads from Qdrant, synthesizes \`.meta/\` directories, which watcher re-indexes.
|
|
1413
|
+
- **Runner** executes scheduled scripts that may call any service's HTTP API.
|
|
1414
|
+
- **Server** presents files, renders documents, and provides the event gateway.
|
|
1415
|
+
- **Core** provides shared content management (TOOLS.md, SOUL.md, AGENTS.md), service discovery, config resolution, and the component SDK.
|
|
1416
|
+
|
|
1417
|
+
## Service Discovery
|
|
1418
|
+
|
|
1419
|
+
Services find each other via config resolution:
|
|
1420
|
+
1. Component's own config file (\`{configRoot}/jeeves-{name}/config.json\`)
|
|
1421
|
+
2. Core config file (\`{configRoot}/jeeves-core/config.json\`)
|
|
1422
|
+
3. Default port constants
|
|
1423
|
+
|
|
1424
|
+
## Scripts Repo
|
|
1425
|
+
|
|
1426
|
+
Location: \`{configRoot}/jeeves-core/scripts/\`
|
|
1427
|
+
Template: \`@karmaniverous/jeeves-scripts-template\`
|
|
1428
|
+
|
|
1429
|
+
Scripts use utilities from \`@karmaniverous/jeeves\` (general) and \`@karmaniverous/jeeves-runner\` (runner-specific). Any script that could be useful outside runner scheduling belongs in core.
|
|
1430
|
+
|
|
1431
|
+
## Managed Content System
|
|
1432
|
+
|
|
1433
|
+
Core maintains managed sections in workspace files using comment markers:
|
|
1434
|
+
- **TOOLS.md** — Component sections (section mode) + Platform section
|
|
1435
|
+
- **SOUL.md** — Professional discipline and behavioral foundations (block mode)
|
|
1436
|
+
- **AGENTS.md** — Operational protocols and memory architecture (block mode)
|
|
1437
|
+
- **HEARTBEAT.md** — Platform health status (heading-based)
|
|
1438
|
+
|
|
1439
|
+
Managed blocks are stationary after initial insertion. Cleanup detection uses Jaccard similarity on 3-word shingles. Cleanup escalation spawns a gateway session when orphaned content is detected.
|
|
1440
|
+
|
|
1441
|
+
## Workspace Configuration
|
|
1442
|
+
|
|
1443
|
+
\`jeeves.config.json\` at workspace root provides shared defaults:
|
|
1444
|
+
- Precedence: CLI flags → env vars → file → defaults
|
|
1445
|
+
- Namespaced: \`core.*\` (workspace, configRoot, gatewayUrl) and \`memory.*\` (budget, warningThreshold, staleDays)
|
|
1446
|
+
- Inspect with \`jeeves config [jsonpath]\`
|
|
1447
|
+
|
|
1448
|
+
## HEARTBEAT Protocol
|
|
1449
|
+
|
|
1450
|
+
The HEARTBEAT system uses a state machine per component:
|
|
1451
|
+
\`not_installed → deps_missing → config_missing → service_not_installed → service_stopped → healthy\`
|
|
1452
|
+
|
|
1453
|
+
Dependency-aware: hard deps block alerts, soft deps add informational notes. Declined components are tracked via heading suffix.
|
|
1454
|
+
|
|
1455
|
+
## Plugin Lifecycle
|
|
1456
|
+
|
|
1457
|
+
\`\`\`bash
|
|
1458
|
+
# Core install (seed workspace content)
|
|
1459
|
+
npx @karmaniverous/jeeves install
|
|
1460
|
+
|
|
1461
|
+
# Component plugin install
|
|
1462
|
+
npx @karmaniverous/jeeves-{component}-openclaw install
|
|
1463
|
+
|
|
1464
|
+
# Component plugin uninstall
|
|
1465
|
+
npx @karmaniverous/jeeves-{component}-openclaw uninstall
|
|
1466
|
+
|
|
1467
|
+
# Core uninstall (remove managed sections)
|
|
1468
|
+
npx @karmaniverous/jeeves uninstall
|
|
1469
|
+
\`\`\`
|
|
1470
|
+
|
|
1471
|
+
## Memory Hygiene
|
|
1472
|
+
|
|
1473
|
+
MEMORY.md has a character budget (default 20,000). Core tracks:
|
|
1474
|
+
- Character count and usage percentage
|
|
1475
|
+
- Warning at 80% of budget
|
|
1476
|
+
- Stale section candidates (H2 sections whose most recent ISO date exceeds the staleness threshold)
|
|
1477
|
+
- Evergreen sections (no dates) are never flagged
|
|
1478
|
+
|
|
1479
|
+
Review is human/agent-mediated — core does not auto-delete.
|
|
1480
|
+
`;
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Skill seeding: write the `jeeves` workspace skill unconditionally.
|
|
1484
|
+
*
|
|
1485
|
+
* @remarks
|
|
1486
|
+
* The skill file is entirely generated — no user-authored content (Decision 48).
|
|
1487
|
+
* Every installer (core CLI and component plugins) writes it unconditionally.
|
|
1488
|
+
* Content is inlined at build time via `rollup-plugin-md.ts`.
|
|
1489
|
+
*/
|
|
1490
|
+
/**
|
|
1491
|
+
* Seed the jeeves workspace skill file.
|
|
1492
|
+
*
|
|
1493
|
+
* @param workspacePath - Workspace root directory.
|
|
1494
|
+
*/
|
|
1495
|
+
function seedSkill(workspacePath) {
|
|
1496
|
+
const skillDir = join(workspacePath, SKILLS_DIR, JEEVES_SKILL_DIR);
|
|
1497
|
+
if (!existsSync(skillDir)) {
|
|
1498
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1499
|
+
}
|
|
1500
|
+
const skillPath = join(skillDir, 'SKILL.md');
|
|
1501
|
+
writeFileSync(skillPath, skillContent, 'utf-8');
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1123
1504
|
/**
|
|
1124
1505
|
* OpenClaw configuration helpers for plugin CLI installers.
|
|
1125
1506
|
*
|
|
@@ -1239,13 +1620,9 @@ function patchConfig(config, pluginId, mode) {
|
|
|
1239
1620
|
}
|
|
1240
1621
|
|
|
1241
1622
|
/**
|
|
1242
|
-
*
|
|
1623
|
+
* Internal helpers for the plugin installer CLI.
|
|
1243
1624
|
*
|
|
1244
|
-
* @
|
|
1245
|
-
* Produces a Commander program with `install` and `uninstall` commands
|
|
1246
|
-
* that handle the full plugin lifecycle: copy dist to extensions,
|
|
1247
|
-
* patch OpenClaw config, manage HEARTBEAT entries, and clean up
|
|
1248
|
-
* managed sections on uninstall.
|
|
1625
|
+
* @module
|
|
1249
1626
|
*/
|
|
1250
1627
|
/**
|
|
1251
1628
|
* Derive a component name from a plugin ID.
|
|
@@ -1260,7 +1637,7 @@ function deriveComponentName(pluginId) {
|
|
|
1260
1637
|
return pluginId.replace(/^jeeves-/, '').replace(/-openclaw$/, '');
|
|
1261
1638
|
}
|
|
1262
1639
|
/**
|
|
1263
|
-
* Copy all files from source directory to destination.
|
|
1640
|
+
* Copy all files from source directory to destination, recursively.
|
|
1264
1641
|
*
|
|
1265
1642
|
* @param srcDir - Source directory.
|
|
1266
1643
|
* @param destDir - Destination directory.
|
|
@@ -1294,6 +1671,12 @@ function readJsonFile(filePath) {
|
|
|
1294
1671
|
return {};
|
|
1295
1672
|
}
|
|
1296
1673
|
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* Factory for the standard `-openclaw` plugin installer CLI.
|
|
1677
|
+
*
|
|
1678
|
+
* @module
|
|
1679
|
+
*/
|
|
1297
1680
|
/**
|
|
1298
1681
|
* Create a standard plugin installer CLI program.
|
|
1299
1682
|
*
|
|
@@ -1319,6 +1702,16 @@ function createPluginCli(options) {
|
|
|
1319
1702
|
const extensionsDir = join(openClawHome, 'extensions', pluginId);
|
|
1320
1703
|
console.log(`Copying dist to ${extensionsDir}...`);
|
|
1321
1704
|
copyDistFiles(distDir, extensionsDir);
|
|
1705
|
+
// Copy package.json and openclaw.plugin.json from package root
|
|
1706
|
+
const pkgRoot = packageDirectorySync({ cwd: distDir });
|
|
1707
|
+
if (pkgRoot) {
|
|
1708
|
+
for (const file of ['package.json', 'openclaw.plugin.json']) {
|
|
1709
|
+
const src = join(pkgRoot, file);
|
|
1710
|
+
if (existsSync(src)) {
|
|
1711
|
+
copyFileSync(src, join(extensionsDir, file));
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1322
1715
|
console.log(' ✓ Dist files copied');
|
|
1323
1716
|
// 2. Patch openclaw.json
|
|
1324
1717
|
console.log('Patching OpenClaw config...');
|
|
@@ -1347,7 +1740,7 @@ function createPluginCli(options) {
|
|
|
1347
1740
|
for (const msg of messages) {
|
|
1348
1741
|
console.log(` ✓ ${msg}`);
|
|
1349
1742
|
}
|
|
1350
|
-
// 4. Write initial HEARTBEAT entry
|
|
1743
|
+
// 4. Write initial HEARTBEAT entry and seed jeeves skill
|
|
1351
1744
|
try {
|
|
1352
1745
|
const cfgRoot = opts.configRoot;
|
|
1353
1746
|
const agents = config.agents;
|
|
@@ -1362,7 +1755,6 @@ function createPluginCli(options) {
|
|
|
1362
1755
|
: '';
|
|
1363
1756
|
const parsed = parseHeartbeat(existing);
|
|
1364
1757
|
const fullName = `jeeves-${componentName}`;
|
|
1365
|
-
// Only add if not already present
|
|
1366
1758
|
const hasEntry = parsed.entries.some((e) => e.name === fullName);
|
|
1367
1759
|
if (!hasEntry) {
|
|
1368
1760
|
parsed.entries.push({
|
|
@@ -1378,10 +1770,36 @@ function createPluginCli(options) {
|
|
|
1378
1770
|
catch {
|
|
1379
1771
|
console.log(' ⚠ Could not write HEARTBEAT entry');
|
|
1380
1772
|
}
|
|
1773
|
+
try {
|
|
1774
|
+
seedSkill(ws);
|
|
1775
|
+
console.log(' ✓ Jeeves skill seeded');
|
|
1776
|
+
}
|
|
1777
|
+
catch {
|
|
1778
|
+
console.log(' ⚠ Could not seed Jeeves skill');
|
|
1779
|
+
}
|
|
1381
1780
|
}
|
|
1382
1781
|
}
|
|
1383
1782
|
catch {
|
|
1384
|
-
// HEARTBEAT
|
|
1783
|
+
// HEARTBEAT + skill seeding are best-effort during install
|
|
1784
|
+
}
|
|
1785
|
+
// 5. Write component version
|
|
1786
|
+
try {
|
|
1787
|
+
init({
|
|
1788
|
+
workspacePath: opts.workspace ?? '.',
|
|
1789
|
+
configRoot: opts.configRoot,
|
|
1790
|
+
});
|
|
1791
|
+
const pkgJsonPath = join(extensionsDir, 'package.json');
|
|
1792
|
+
const pkgJson = readJsonFile(pkgJsonPath);
|
|
1793
|
+
const pluginVersion = typeof pkgJson.version === 'string' ? pkgJson.version : undefined;
|
|
1794
|
+
writeComponentVersion(getCoreConfigDir(), {
|
|
1795
|
+
componentName,
|
|
1796
|
+
pluginPackage,
|
|
1797
|
+
pluginVersion,
|
|
1798
|
+
});
|
|
1799
|
+
console.log(' ✓ Component version written');
|
|
1800
|
+
}
|
|
1801
|
+
catch {
|
|
1802
|
+
console.log(' ⚠ Could not write component version');
|
|
1385
1803
|
}
|
|
1386
1804
|
console.log();
|
|
1387
1805
|
console.log(`✅ ${pluginPackage} installed.`);
|
|
@@ -1411,10 +1829,9 @@ function createPluginCli(options) {
|
|
|
1411
1829
|
}
|
|
1412
1830
|
// 3. Remove TOOLS.md section
|
|
1413
1831
|
try {
|
|
1414
|
-
const cfgRoot = opts.configRoot;
|
|
1415
1832
|
const ws = opts.workspace;
|
|
1416
1833
|
if (ws) {
|
|
1417
|
-
init({ workspacePath: ws, configRoot:
|
|
1834
|
+
init({ workspacePath: ws, configRoot: opts.configRoot });
|
|
1418
1835
|
const sectionId = componentName.charAt(0).toUpperCase() + componentName.slice(1);
|
|
1419
1836
|
const toolsPath = join(ws, WORKSPACE_FILES.tools);
|
|
1420
1837
|
if (existsSync(toolsPath)) {
|
|
@@ -1431,10 +1848,9 @@ function createPluginCli(options) {
|
|
|
1431
1848
|
}
|
|
1432
1849
|
// 4. Remove component-versions.json entry
|
|
1433
1850
|
try {
|
|
1434
|
-
const cfgRoot = opts.configRoot;
|
|
1435
1851
|
init({
|
|
1436
1852
|
workspacePath: opts.workspace ?? '.',
|
|
1437
|
-
configRoot:
|
|
1853
|
+
configRoot: opts.configRoot,
|
|
1438
1854
|
});
|
|
1439
1855
|
removeComponentVersion(getCoreConfigDir(), componentName);
|
|
1440
1856
|
console.log(' ✓ Component version entry removed');
|
|
@@ -1770,7 +2186,7 @@ function isExecError(err) {
|
|
|
1770
2186
|
* (Windows), systemd (Linux), or launchd (macOS) based on platform.
|
|
1771
2187
|
*/
|
|
1772
2188
|
/** Exec helper that returns stdout. */
|
|
1773
|
-
function run(cmd) {
|
|
2189
|
+
function run$1(cmd) {
|
|
1774
2190
|
return execSync(cmd, {
|
|
1775
2191
|
encoding: 'utf-8',
|
|
1776
2192
|
timeout: 30_000,
|
|
@@ -1823,31 +2239,31 @@ function createWindowsManager(descriptor) {
|
|
|
1823
2239
|
const cmdArgs = descriptor.startCommand(cfgPath);
|
|
1824
2240
|
const appPath = cmdArgs[0];
|
|
1825
2241
|
const appArgs = cmdArgs.slice(1).join(' ');
|
|
1826
|
-
run(`nssm install ${svcName} ${appPath}`);
|
|
2242
|
+
run$1(`nssm install ${svcName} ${appPath}`);
|
|
1827
2243
|
if (appArgs) {
|
|
1828
|
-
run(`nssm set ${svcName} AppParameters ${appArgs}`);
|
|
2244
|
+
run$1(`nssm set ${svcName} AppParameters ${appArgs}`);
|
|
1829
2245
|
}
|
|
1830
|
-
run(`nssm set ${svcName} AppStdout ${join(homedir(), `${svcName}.log`)}`);
|
|
1831
|
-
run(`nssm set ${svcName} AppStderr ${join(homedir(), `${svcName}.log`)}`);
|
|
1832
|
-
run(`nssm set ${svcName} AppRotateFiles 1`);
|
|
1833
|
-
run(`nssm set ${svcName} AppRotateBytes 1048576`);
|
|
2246
|
+
run$1(`nssm set ${svcName} AppStdout ${join(homedir(), `${svcName}.log`)}`);
|
|
2247
|
+
run$1(`nssm set ${svcName} AppStderr ${join(homedir(), `${svcName}.log`)}`);
|
|
2248
|
+
run$1(`nssm set ${svcName} AppRotateFiles 1`);
|
|
2249
|
+
run$1(`nssm set ${svcName} AppRotateBytes 1048576`);
|
|
1834
2250
|
},
|
|
1835
2251
|
uninstall(options) {
|
|
1836
2252
|
const svcName = resolveServiceName(descriptor, options);
|
|
1837
2253
|
runQuiet(`nssm stop ${svcName}`);
|
|
1838
|
-
run(`nssm remove ${svcName} confirm`);
|
|
2254
|
+
run$1(`nssm remove ${svcName} confirm`);
|
|
1839
2255
|
},
|
|
1840
2256
|
start(options) {
|
|
1841
2257
|
const svcName = resolveServiceName(descriptor, options);
|
|
1842
|
-
run(`nssm start ${svcName}`);
|
|
2258
|
+
run$1(`nssm start ${svcName}`);
|
|
1843
2259
|
},
|
|
1844
2260
|
stop(options) {
|
|
1845
2261
|
const svcName = resolveServiceName(descriptor, options);
|
|
1846
|
-
run(`nssm stop ${svcName}`);
|
|
2262
|
+
run$1(`nssm stop ${svcName}`);
|
|
1847
2263
|
},
|
|
1848
2264
|
restart(options) {
|
|
1849
2265
|
const svcName = resolveServiceName(descriptor, options);
|
|
1850
|
-
run(`nssm restart ${svcName}`);
|
|
2266
|
+
run$1(`nssm restart ${svcName}`);
|
|
1851
2267
|
},
|
|
1852
2268
|
status(options) {
|
|
1853
2269
|
const svcName = resolveServiceName(descriptor, options);
|
|
@@ -1892,8 +2308,8 @@ function createLinuxManager(descriptor) {
|
|
|
1892
2308
|
const cmdArgs = descriptor.startCommand(cfgPath);
|
|
1893
2309
|
mkdirSync(unitDir, { recursive: true });
|
|
1894
2310
|
writeFileSync(unitPath(svcName), buildSystemdUnit(svcName, cmdArgs));
|
|
1895
|
-
run('systemctl --user daemon-reload');
|
|
1896
|
-
run(`systemctl --user enable ${svcName}.service`);
|
|
2311
|
+
run$1('systemctl --user daemon-reload');
|
|
2312
|
+
run$1(`systemctl --user enable ${svcName}.service`);
|
|
1897
2313
|
},
|
|
1898
2314
|
uninstall(options) {
|
|
1899
2315
|
const svcName = resolveServiceName(descriptor, options);
|
|
@@ -1906,15 +2322,15 @@ function createLinuxManager(descriptor) {
|
|
|
1906
2322
|
},
|
|
1907
2323
|
start(options) {
|
|
1908
2324
|
const svcName = resolveServiceName(descriptor, options);
|
|
1909
|
-
run(`systemctl --user start ${svcName}.service`);
|
|
2325
|
+
run$1(`systemctl --user start ${svcName}.service`);
|
|
1910
2326
|
},
|
|
1911
2327
|
stop(options) {
|
|
1912
2328
|
const svcName = resolveServiceName(descriptor, options);
|
|
1913
|
-
run(`systemctl --user stop ${svcName}.service`);
|
|
2329
|
+
run$1(`systemctl --user stop ${svcName}.service`);
|
|
1914
2330
|
},
|
|
1915
2331
|
restart(options) {
|
|
1916
2332
|
const svcName = resolveServiceName(descriptor, options);
|
|
1917
|
-
run(`systemctl --user restart ${svcName}.service`);
|
|
2333
|
+
run$1(`systemctl --user restart ${svcName}.service`);
|
|
1918
2334
|
},
|
|
1919
2335
|
status(options) {
|
|
1920
2336
|
const svcName = resolveServiceName(descriptor, options);
|
|
@@ -1978,16 +2394,16 @@ function createMacOSManager(descriptor) {
|
|
|
1978
2394
|
},
|
|
1979
2395
|
start(options) {
|
|
1980
2396
|
const svcName = resolveServiceName(descriptor, options);
|
|
1981
|
-
run(`launchctl load ${plistPath(svcName)}`);
|
|
2397
|
+
run$1(`launchctl load ${plistPath(svcName)}`);
|
|
1982
2398
|
},
|
|
1983
2399
|
stop(options) {
|
|
1984
2400
|
const svcName = resolveServiceName(descriptor, options);
|
|
1985
|
-
run(`launchctl unload ${plistPath(svcName)}`);
|
|
2401
|
+
run$1(`launchctl unload ${plistPath(svcName)}`);
|
|
1986
2402
|
},
|
|
1987
2403
|
restart(options) {
|
|
1988
2404
|
const svcName = resolveServiceName(descriptor, options);
|
|
1989
2405
|
runQuiet(`launchctl unload ${plistPath(svcName)}`);
|
|
1990
|
-
run(`launchctl load ${plistPath(svcName)}`);
|
|
2406
|
+
run$1(`launchctl load ${plistPath(svcName)}`);
|
|
1991
2407
|
},
|
|
1992
2408
|
status(options) {
|
|
1993
2409
|
const svcName = resolveServiceName(descriptor, options);
|
|
@@ -2016,19 +2432,6 @@ function createServiceManager(descriptor) {
|
|
|
2016
2432
|
}
|
|
2017
2433
|
}
|
|
2018
2434
|
|
|
2019
|
-
/**
|
|
2020
|
-
* Shared CLI defaults and option registration for Jeeves CLI commands.
|
|
2021
|
-
*
|
|
2022
|
-
* @remarks
|
|
2023
|
-
* All three CLI commands (install, uninstall, status) share the same
|
|
2024
|
-
* `--workspace` and `--config-root` options with the same defaults.
|
|
2025
|
-
* This module centralizes them to eliminate duplication.
|
|
2026
|
-
*/
|
|
2027
|
-
/** Default workspace path (current directory). */
|
|
2028
|
-
const DEFAULT_WORKSPACE = '.';
|
|
2029
|
-
/** Default config root path. */
|
|
2030
|
-
const DEFAULT_CONFIG_ROOT = './config';
|
|
2031
|
-
|
|
2032
2435
|
/**
|
|
2033
2436
|
* Factory for the standard Jeeves service CLI.
|
|
2034
2437
|
*
|
|
@@ -2457,10 +2860,10 @@ async function updateManagedSection(filePath, content, options = {}) {
|
|
|
2457
2860
|
? `# ${markers.title}\n\n${sectionText}`
|
|
2458
2861
|
: sectionText;
|
|
2459
2862
|
}
|
|
2460
|
-
//
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
//
|
|
2863
|
+
// Build the full managed block
|
|
2864
|
+
const beginLine = formatBeginMarker(markers.begin, coreVersion);
|
|
2865
|
+
const endLine = formatEndMarker(markers.end);
|
|
2866
|
+
// Combine all user content for cleanup detection
|
|
2464
2867
|
const rawUserContent = [parsed.beforeContent, parsed.userContent]
|
|
2465
2868
|
.filter(Boolean)
|
|
2466
2869
|
.join('\n\n')
|
|
@@ -2468,9 +2871,6 @@ async function updateManagedSection(filePath, content, options = {}) {
|
|
|
2468
2871
|
// Strip foreign managed blocks from user content (cross-contamination fix)
|
|
2469
2872
|
const userContent = stripForeignMarkers(rawUserContent, markers);
|
|
2470
2873
|
const cleanupNeeded = needsCleanup(newManagedBody, userContent);
|
|
2471
|
-
// Build the full managed block
|
|
2472
|
-
const beginLine = formatBeginMarker(markers.begin, coreVersion);
|
|
2473
|
-
const endLine = formatEndMarker(markers.end);
|
|
2474
2874
|
const managedParts = [];
|
|
2475
2875
|
managedParts.push(beginLine);
|
|
2476
2876
|
if (cleanupNeeded) {
|
|
@@ -2482,26 +2882,54 @@ async function updateManagedSection(filePath, content, options = {}) {
|
|
|
2482
2882
|
managedParts.push('');
|
|
2483
2883
|
managedParts.push(endLine);
|
|
2484
2884
|
const managedBlock = managedParts.join('\n');
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
//
|
|
2489
|
-
|
|
2490
|
-
|
|
2885
|
+
let newFileContent;
|
|
2886
|
+
if (parsed.found) {
|
|
2887
|
+
// Existing block: update in place — preserve position, don't move.
|
|
2888
|
+
// Strip foreign managed blocks from both content zones (cross-contamination fix).
|
|
2889
|
+
const cleanBefore = stripForeignMarkers(parsed.beforeContent, markers);
|
|
2890
|
+
const cleanAfter = stripForeignMarkers(parsed.userContent, markers);
|
|
2891
|
+
const fileParts = [];
|
|
2892
|
+
if (cleanBefore) {
|
|
2893
|
+
fileParts.push(cleanBefore);
|
|
2491
2894
|
fileParts.push('');
|
|
2492
2895
|
}
|
|
2493
2896
|
fileParts.push(managedBlock);
|
|
2897
|
+
if (cleanAfter) {
|
|
2898
|
+
fileParts.push('');
|
|
2899
|
+
fileParts.push(cleanAfter);
|
|
2900
|
+
}
|
|
2901
|
+
fileParts.push('');
|
|
2902
|
+
newFileContent = fileParts.join('\n');
|
|
2494
2903
|
}
|
|
2495
2904
|
else {
|
|
2496
|
-
//
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2905
|
+
// No existing block: insert new block using the configured position.
|
|
2906
|
+
// Strip orphaned same-type BEGIN markers from user content to prevent
|
|
2907
|
+
// the parser from pairing them with the new END marker on the next cycle.
|
|
2908
|
+
const escapedBegin = markers.begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2909
|
+
const orphanedBeginRe = new RegExp(`^<!--\\s*${escapedBegin}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$\\n?`, 'gm');
|
|
2910
|
+
const cleanUserContent = userContent
|
|
2911
|
+
.replace(orphanedBeginRe, '')
|
|
2912
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
2913
|
+
.trim();
|
|
2914
|
+
const position = markers.position ?? 'top';
|
|
2915
|
+
const fileParts = [];
|
|
2916
|
+
if (position === 'bottom') {
|
|
2917
|
+
if (cleanUserContent) {
|
|
2918
|
+
fileParts.push(cleanUserContent);
|
|
2919
|
+
fileParts.push('');
|
|
2920
|
+
}
|
|
2921
|
+
fileParts.push(managedBlock);
|
|
2501
2922
|
}
|
|
2923
|
+
else {
|
|
2924
|
+
fileParts.push(managedBlock);
|
|
2925
|
+
if (cleanUserContent) {
|
|
2926
|
+
fileParts.push('');
|
|
2927
|
+
fileParts.push(cleanUserContent);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
fileParts.push('');
|
|
2931
|
+
newFileContent = fileParts.join('\n');
|
|
2502
2932
|
}
|
|
2503
|
-
fileParts.push('');
|
|
2504
|
-
const newFileContent = fileParts.join('\n');
|
|
2505
2933
|
atomicWrite(filePath, newFileContent);
|
|
2506
2934
|
});
|
|
2507
2935
|
}
|
|
@@ -2995,6 +3423,103 @@ async function refreshPlatformContent(options) {
|
|
|
2995
3423
|
copyTemplates(coreConfigDir);
|
|
2996
3424
|
}
|
|
2997
3425
|
|
|
3426
|
+
/**
|
|
3427
|
+
* Cleanup-session escalation for managed files with orphaned duplicated content.
|
|
3428
|
+
*
|
|
3429
|
+
* @remarks
|
|
3430
|
+
* When a managed file contains the cleanup flag, the writer can ask the
|
|
3431
|
+
* OpenClaw gateway to spawn a background session to remove orphaned content.
|
|
3432
|
+
* The request is best-effort: accepted requests return `true`; any transport
|
|
3433
|
+
* or HTTP failure returns `false` so the file warning remains the fallback.
|
|
3434
|
+
*/
|
|
3435
|
+
/** Timeout for cleanup-session spawn requests. */
|
|
3436
|
+
const CLEANUP_REQUEST_TIMEOUT_MS = 5_000;
|
|
3437
|
+
/**
|
|
3438
|
+
* Build the cleanup task prompt sent to the gateway session API.
|
|
3439
|
+
*
|
|
3440
|
+
* @param filePath - Managed file requiring cleanup.
|
|
3441
|
+
* @param markerIdentity - Marker identity for the file.
|
|
3442
|
+
* @returns Cleanup instructions for the spawned session.
|
|
3443
|
+
*/
|
|
3444
|
+
function buildCleanupTask(filePath, markerIdentity) {
|
|
3445
|
+
return [
|
|
3446
|
+
`Clean up orphaned managed content in ${filePath}.`,
|
|
3447
|
+
`The file uses ${markerIdentity} managed comment markers.`,
|
|
3448
|
+
'Review content outside the managed block and remove only duplicated managed content.',
|
|
3449
|
+
'Preserve any unique user-authored content outside the managed block.',
|
|
3450
|
+
'Do not modify content inside the managed block unless required to preserve valid marker structure.',
|
|
3451
|
+
].join(' ');
|
|
3452
|
+
}
|
|
3453
|
+
/**
|
|
3454
|
+
* Request a cleanup session from the OpenClaw gateway.
|
|
3455
|
+
*
|
|
3456
|
+
* @remarks
|
|
3457
|
+
* Fire-and-forget. A 200-class response means the request was accepted.
|
|
3458
|
+
* Any HTTP or transport failure returns `false` so the file-level cleanup
|
|
3459
|
+
* warning remains the only signal.
|
|
3460
|
+
*
|
|
3461
|
+
* @param options - Cleanup request configuration.
|
|
3462
|
+
* @returns Whether the gateway accepted the cleanup request.
|
|
3463
|
+
*/
|
|
3464
|
+
async function requestCleanupSession(options) {
|
|
3465
|
+
const { gatewayUrl, filePath, markerIdentity } = options;
|
|
3466
|
+
const url = `${gatewayUrl.replace(/\/$/, '')}/sessions/spawn`;
|
|
3467
|
+
const label = `cleanup:${basename(filePath)}`;
|
|
3468
|
+
const body = {
|
|
3469
|
+
task: buildCleanupTask(filePath, markerIdentity),
|
|
3470
|
+
label,
|
|
3471
|
+
};
|
|
3472
|
+
try {
|
|
3473
|
+
const response = await fetchWithTimeout(url, CLEANUP_REQUEST_TIMEOUT_MS, {
|
|
3474
|
+
method: 'POST',
|
|
3475
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3476
|
+
body: JSON.stringify(body),
|
|
3477
|
+
});
|
|
3478
|
+
return response.ok;
|
|
3479
|
+
}
|
|
3480
|
+
catch {
|
|
3481
|
+
return false;
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
/**
|
|
3486
|
+
* Cleanup flag scanning extracted from ComponentWriter.cycle().
|
|
3487
|
+
*
|
|
3488
|
+
* @remarks
|
|
3489
|
+
* After writing managed files, scans each for the cleanup flag and
|
|
3490
|
+
* fires a best-effort escalation request when a gateway URL is configured.
|
|
3491
|
+
* Uses a `pendingCleanups` set to deduplicate in-flight requests.
|
|
3492
|
+
*/
|
|
3493
|
+
/**
|
|
3494
|
+
* Scan managed files for the cleanup flag and escalate when detected.
|
|
3495
|
+
*
|
|
3496
|
+
* @param targets - Managed files to scan.
|
|
3497
|
+
* @param gatewayUrl - Gateway URL for session spawn.
|
|
3498
|
+
* @param pendingCleanups - Set tracking in-flight requests (mutated).
|
|
3499
|
+
*/
|
|
3500
|
+
function scanAndEscalateCleanup(targets, gatewayUrl, pendingCleanups) {
|
|
3501
|
+
for (const target of targets) {
|
|
3502
|
+
try {
|
|
3503
|
+
if (pendingCleanups.has(target.filePath))
|
|
3504
|
+
continue;
|
|
3505
|
+
const fileContent = readFileSync(target.filePath, 'utf-8');
|
|
3506
|
+
if (fileContent.includes(CLEANUP_FLAG)) {
|
|
3507
|
+
pendingCleanups.add(target.filePath);
|
|
3508
|
+
void requestCleanupSession({
|
|
3509
|
+
gatewayUrl,
|
|
3510
|
+
filePath: target.filePath,
|
|
3511
|
+
markerIdentity: target.markerIdentity,
|
|
3512
|
+
}).finally(() => {
|
|
3513
|
+
pendingCleanups.delete(target.filePath);
|
|
3514
|
+
});
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
catch {
|
|
3518
|
+
// Best-effort: don't fail the cycle for escalation issues.
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
|
|
2998
3523
|
/**
|
|
2999
3524
|
* Core configuration schema and resolution.
|
|
3000
3525
|
*
|
|
@@ -3439,29 +3964,76 @@ async function orchestrateHeartbeat(options) {
|
|
|
3439
3964
|
}
|
|
3440
3965
|
|
|
3441
3966
|
/**
|
|
3442
|
-
*
|
|
3967
|
+
* HEARTBEAT orchestration extracted from ComponentWriter.cycle().
|
|
3443
3968
|
*
|
|
3444
3969
|
* @remarks
|
|
3445
|
-
*
|
|
3446
|
-
*
|
|
3447
|
-
*
|
|
3970
|
+
* Reads existing HEARTBEAT.md, resolves declined components, runs the
|
|
3971
|
+
* heartbeat state machine, and writes the result. Best-effort: failures
|
|
3972
|
+
* are logged but do not propagate.
|
|
3973
|
+
*/
|
|
3974
|
+
/**
|
|
3975
|
+
* Read a file's content, returning empty string if the file does not exist.
|
|
3976
|
+
*
|
|
3977
|
+
* @param filePath - Absolute file path.
|
|
3978
|
+
* @returns File content or empty string.
|
|
3448
3979
|
*/
|
|
3980
|
+
function readFileOrEmpty(filePath) {
|
|
3981
|
+
try {
|
|
3982
|
+
return readFileSync(filePath, 'utf-8');
|
|
3983
|
+
}
|
|
3984
|
+
catch (err) {
|
|
3985
|
+
if (err instanceof Error &&
|
|
3986
|
+
'code' in err &&
|
|
3987
|
+
err.code === 'ENOENT') {
|
|
3988
|
+
return '';
|
|
3989
|
+
}
|
|
3990
|
+
throw err;
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3449
3993
|
/**
|
|
3450
|
-
*
|
|
3994
|
+
* Run a single HEARTBEAT orchestration cycle.
|
|
3995
|
+
*
|
|
3996
|
+
* @param options - Heartbeat cycle configuration.
|
|
3997
|
+
*/
|
|
3998
|
+
async function runHeartbeatCycle(options) {
|
|
3999
|
+
const { workspacePath, coreConfigDir, configRoot } = options;
|
|
4000
|
+
const heartbeatPath = join(workspacePath, WORKSPACE_FILES.heartbeat);
|
|
4001
|
+
try {
|
|
4002
|
+
const existingContent = readFileOrEmpty(heartbeatPath);
|
|
4003
|
+
const parsed = parseHeartbeat(existingContent);
|
|
4004
|
+
const declinedNames = new Set(parsed.entries.filter((e) => e.declined).map((e) => e.name));
|
|
4005
|
+
const entries = await orchestrateHeartbeat({
|
|
4006
|
+
coreConfigDir,
|
|
4007
|
+
configRoot,
|
|
4008
|
+
declinedNames,
|
|
4009
|
+
});
|
|
4010
|
+
await writeHeartbeatSection(heartbeatPath, entries);
|
|
4011
|
+
}
|
|
4012
|
+
catch (err) {
|
|
4013
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4014
|
+
console.warn(`jeeves-core: HEARTBEAT orchestration failed: ${msg}`);
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
/**
|
|
4019
|
+
* Timer-based orchestrator for managed content writing.
|
|
3451
4020
|
*
|
|
3452
4021
|
* @remarks
|
|
3453
|
-
*
|
|
3454
|
-
*
|
|
3455
|
-
*
|
|
4022
|
+
* `ComponentWriter` manages a component's TOOLS.md section writes
|
|
4023
|
+
* and platform content maintenance (SOUL.md, AGENTS.md, Platform section)
|
|
4024
|
+
* on a configurable prime-interval timer cycle.
|
|
3456
4025
|
*/
|
|
3457
4026
|
class ComponentWriter {
|
|
3458
4027
|
timer;
|
|
3459
4028
|
component;
|
|
3460
4029
|
configDir;
|
|
4030
|
+
gatewayUrl;
|
|
4031
|
+
pendingCleanups = new Set();
|
|
3461
4032
|
/** @internal */
|
|
3462
|
-
constructor(component) {
|
|
4033
|
+
constructor(component, options) {
|
|
3463
4034
|
this.component = component;
|
|
3464
4035
|
this.configDir = getComponentConfigDir(component.name);
|
|
4036
|
+
this.gatewayUrl = options?.gatewayUrl;
|
|
3465
4037
|
}
|
|
3466
4038
|
/** The component's config directory path. */
|
|
3467
4039
|
get componentConfigDir() {
|
|
@@ -3495,15 +4067,16 @@ class ComponentWriter {
|
|
|
3495
4067
|
* Execute a single write cycle.
|
|
3496
4068
|
*
|
|
3497
4069
|
* @remarks
|
|
3498
|
-
*
|
|
3499
|
-
*
|
|
3500
|
-
*
|
|
4070
|
+
* 1. Write the component's TOOLS.md section.
|
|
4071
|
+
* 2. Refresh shared platform content (SOUL.md, AGENTS.md, Platform section).
|
|
4072
|
+
* 3. Scan for cleanup flags and escalate if a gateway URL is configured.
|
|
4073
|
+
* 4. Run HEARTBEAT health orchestration.
|
|
3501
4074
|
*/
|
|
3502
4075
|
async cycle() {
|
|
3503
4076
|
try {
|
|
3504
4077
|
const workspacePath = getWorkspacePath();
|
|
3505
4078
|
const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
|
|
3506
|
-
// Write the component's TOOLS.md section
|
|
4079
|
+
// 1. Write the component's TOOLS.md section
|
|
3507
4080
|
const toolsContent = this.component.generateToolsContent();
|
|
3508
4081
|
await updateManagedSection(toolsPath, toolsContent, {
|
|
3509
4082
|
mode: 'section',
|
|
@@ -3511,7 +4084,7 @@ class ComponentWriter {
|
|
|
3511
4084
|
markers: TOOLS_MARKERS,
|
|
3512
4085
|
coreVersion: CORE_VERSION,
|
|
3513
4086
|
});
|
|
3514
|
-
// Platform content maintenance
|
|
4087
|
+
// 2. Platform content maintenance
|
|
3515
4088
|
await refreshPlatformContent({
|
|
3516
4089
|
coreVersion: CORE_VERSION,
|
|
3517
4090
|
componentName: this.component.name,
|
|
@@ -3519,36 +4092,26 @@ class ComponentWriter {
|
|
|
3519
4092
|
servicePackage: this.component.servicePackage,
|
|
3520
4093
|
pluginPackage: this.component.pluginPackage,
|
|
3521
4094
|
});
|
|
3522
|
-
//
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
}
|
|
3536
|
-
throw err;
|
|
3537
|
-
}
|
|
3538
|
-
})();
|
|
3539
|
-
const parsed = parseHeartbeat(existingContent);
|
|
3540
|
-
const declinedNames = new Set(parsed.entries.filter((e) => e.declined).map((e) => e.name));
|
|
3541
|
-
const entries = await orchestrateHeartbeat({
|
|
3542
|
-
coreConfigDir: getCoreConfigDir(),
|
|
3543
|
-
configRoot: getConfigRoot(),
|
|
3544
|
-
declinedNames,
|
|
3545
|
-
});
|
|
3546
|
-
await writeHeartbeatSection(heartbeatPath, entries);
|
|
3547
|
-
}
|
|
3548
|
-
catch (err) {
|
|
3549
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
3550
|
-
console.warn(`jeeves-core: HEARTBEAT orchestration failed: ${msg}`);
|
|
4095
|
+
// 3. Cleanup escalation
|
|
4096
|
+
if (this.gatewayUrl) {
|
|
4097
|
+
scanAndEscalateCleanup([
|
|
4098
|
+
{ filePath: toolsPath, markerIdentity: 'TOOLS' },
|
|
4099
|
+
{
|
|
4100
|
+
filePath: join(workspacePath, WORKSPACE_FILES.soul),
|
|
4101
|
+
markerIdentity: 'SOUL',
|
|
4102
|
+
},
|
|
4103
|
+
{
|
|
4104
|
+
filePath: join(workspacePath, WORKSPACE_FILES.agents),
|
|
4105
|
+
markerIdentity: 'AGENTS',
|
|
4106
|
+
},
|
|
4107
|
+
], this.gatewayUrl, this.pendingCleanups);
|
|
3551
4108
|
}
|
|
4109
|
+
// 4. HEARTBEAT orchestration
|
|
4110
|
+
await runHeartbeatCycle({
|
|
4111
|
+
workspacePath,
|
|
4112
|
+
coreConfigDir: getCoreConfigDir(),
|
|
4113
|
+
configRoot: getConfigRoot(),
|
|
4114
|
+
});
|
|
3552
4115
|
}
|
|
3553
4116
|
catch (err) {
|
|
3554
4117
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -3631,13 +4194,14 @@ function createAsyncContentCache(options) {
|
|
|
3631
4194
|
* This replaces the v0.4.0 `createComponentWriter(JeevesComponent)`.
|
|
3632
4195
|
*
|
|
3633
4196
|
* @param descriptor - The component descriptor to validate and wrap.
|
|
4197
|
+
* @param options - Optional writer configuration (e.g., gatewayUrl for cleanup escalation).
|
|
3634
4198
|
* @returns A new `ComponentWriter` instance.
|
|
3635
4199
|
* @throws ZodError if the descriptor is invalid.
|
|
3636
4200
|
*/
|
|
3637
|
-
function createComponentWriter(descriptor) {
|
|
4201
|
+
function createComponentWriter(descriptor, options) {
|
|
3638
4202
|
// Validate via Zod — throws ZodError with detailed messages on failure
|
|
3639
4203
|
jeevesComponentDescriptorSchema.parse(descriptor);
|
|
3640
|
-
return new ComponentWriter(descriptor);
|
|
4204
|
+
return new ComponentWriter(descriptor, options);
|
|
3641
4205
|
}
|
|
3642
4206
|
|
|
3643
4207
|
/**
|
|
@@ -3678,6 +4242,92 @@ function getBindAddress(componentName) {
|
|
|
3678
4242
|
return DEFAULT_BIND_ADDRESS;
|
|
3679
4243
|
}
|
|
3680
4244
|
|
|
4245
|
+
/**
|
|
4246
|
+
* Memory budget accounting and staleness detection for MEMORY.md.
|
|
4247
|
+
*
|
|
4248
|
+
* @remarks
|
|
4249
|
+
* Scans MEMORY.md for ISO date patterns in H2/H3 headings and bullet items.
|
|
4250
|
+
* Reports character count against a configured budget, warning threshold state,
|
|
4251
|
+
* and stale section candidates. Does not auto-delete: review remains
|
|
4252
|
+
* human- or agent-mediated (Decision 42).
|
|
4253
|
+
*/
|
|
4254
|
+
/** ISO date pattern: YYYY-MM-DD. */
|
|
4255
|
+
const ISO_DATE_RE = /\b(\d{4}-\d{2}-\d{2})\b/g;
|
|
4256
|
+
/** H2 heading pattern used to split sections. */
|
|
4257
|
+
const H2_RE = /^## /m;
|
|
4258
|
+
/**
|
|
4259
|
+
* Extract the most recent ISO date from a string.
|
|
4260
|
+
*
|
|
4261
|
+
* @param text - Text to scan for dates.
|
|
4262
|
+
* @returns The most recent date found, or undefined.
|
|
4263
|
+
*/
|
|
4264
|
+
function extractMostRecentDate(text) {
|
|
4265
|
+
const matches = text.match(ISO_DATE_RE);
|
|
4266
|
+
if (!matches)
|
|
4267
|
+
return undefined;
|
|
4268
|
+
let latest;
|
|
4269
|
+
for (const match of matches) {
|
|
4270
|
+
const d = new Date(match + 'T00:00:00Z');
|
|
4271
|
+
if (!Number.isNaN(d.getTime())) {
|
|
4272
|
+
if (!latest || d > latest)
|
|
4273
|
+
latest = d;
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
4276
|
+
return latest;
|
|
4277
|
+
}
|
|
4278
|
+
/**
|
|
4279
|
+
* Analyze MEMORY.md for budget and staleness.
|
|
4280
|
+
*
|
|
4281
|
+
* @param options - Analysis configuration.
|
|
4282
|
+
* @returns Memory hygiene result.
|
|
4283
|
+
*/
|
|
4284
|
+
function analyzeMemory(options) {
|
|
4285
|
+
const { workspacePath, budget, warningThreshold, staleDays } = options;
|
|
4286
|
+
const memoryPath = join(workspacePath, WORKSPACE_FILES.memory);
|
|
4287
|
+
if (!existsSync(memoryPath)) {
|
|
4288
|
+
return {
|
|
4289
|
+
exists: false,
|
|
4290
|
+
charCount: 0,
|
|
4291
|
+
budget,
|
|
4292
|
+
usage: 0,
|
|
4293
|
+
warning: false,
|
|
4294
|
+
overBudget: false,
|
|
4295
|
+
staleCandidates: 0,
|
|
4296
|
+
staleSectionNames: [],
|
|
4297
|
+
};
|
|
4298
|
+
}
|
|
4299
|
+
const content = readFileSync(memoryPath, 'utf-8');
|
|
4300
|
+
const charCount = content.length;
|
|
4301
|
+
const usage = budget > 0 ? charCount / budget : charCount > 0 ? Infinity : 0;
|
|
4302
|
+
const warning = usage >= warningThreshold;
|
|
4303
|
+
const overBudget = usage > 1;
|
|
4304
|
+
// Split into H2 sections and scan for staleness
|
|
4305
|
+
const sections = content.split(H2_RE).slice(1); // skip content before first H2
|
|
4306
|
+
const now = Date.now();
|
|
4307
|
+
const thresholdMs = staleDays * 24 * 60 * 60 * 1000;
|
|
4308
|
+
const staleSectionNames = [];
|
|
4309
|
+
for (const section of sections) {
|
|
4310
|
+
const sectionName = section.split('\n')[0]?.trim() ?? '';
|
|
4311
|
+
const recentDate = extractMostRecentDate(section);
|
|
4312
|
+
// Sections without dates are evergreen — never flagged (Decision 47)
|
|
4313
|
+
if (!recentDate)
|
|
4314
|
+
continue;
|
|
4315
|
+
if (now - recentDate.getTime() > thresholdMs) {
|
|
4316
|
+
staleSectionNames.push(sectionName);
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4319
|
+
return {
|
|
4320
|
+
exists: true,
|
|
4321
|
+
charCount,
|
|
4322
|
+
budget,
|
|
4323
|
+
usage,
|
|
4324
|
+
warning,
|
|
4325
|
+
overBudget,
|
|
4326
|
+
staleCandidates: staleSectionNames.length,
|
|
4327
|
+
staleSectionNames,
|
|
4328
|
+
};
|
|
4329
|
+
}
|
|
4330
|
+
|
|
3681
4331
|
/**
|
|
3682
4332
|
* One-shot content seeding used by the CLI install command.
|
|
3683
4333
|
*
|
|
@@ -3736,6 +4386,8 @@ async function seedContent(options) {
|
|
|
3736
4386
|
content: `- ${NOT_INSTALLED_ALERTS[name]}`,
|
|
3737
4387
|
}));
|
|
3738
4388
|
await writeHeartbeatSection(heartbeatPath, entries);
|
|
4389
|
+
// Seed jeeves workspace skill (Decision 48: overwrite-on-install)
|
|
4390
|
+
seedSkill(getWorkspacePath());
|
|
3739
4391
|
}
|
|
3740
4392
|
|
|
3741
4393
|
/**
|
|
@@ -3967,6 +4619,33 @@ function createPluginToolset(descriptor) {
|
|
|
3967
4619
|
return [statusTool, configTool, configApplyTool, serviceTool];
|
|
3968
4620
|
}
|
|
3969
4621
|
|
|
4622
|
+
/**
|
|
4623
|
+
* Resolve the version of a package from its `import.meta.url`.
|
|
4624
|
+
*
|
|
4625
|
+
* @module
|
|
4626
|
+
*/
|
|
4627
|
+
/**
|
|
4628
|
+
* Get the version string from the nearest `package.json` relative to the
|
|
4629
|
+
* caller's module URL.
|
|
4630
|
+
*
|
|
4631
|
+
* @param importMetaUrl - The `import.meta.url` of the calling module.
|
|
4632
|
+
* @returns The `version` field, or `'unknown'` on any error.
|
|
4633
|
+
*/
|
|
4634
|
+
function getPackageVersion(importMetaUrl) {
|
|
4635
|
+
try {
|
|
4636
|
+
const dir = fileURLToPath(importMetaUrl);
|
|
4637
|
+
const pkgRoot = packageDirectorySync({ cwd: dir });
|
|
4638
|
+
if (!pkgRoot)
|
|
4639
|
+
return 'unknown';
|
|
4640
|
+
const raw = readFileSync(join(pkgRoot, 'package.json'), 'utf-8');
|
|
4641
|
+
const pkg = JSON.parse(raw);
|
|
4642
|
+
return typeof pkg.version === 'string' ? pkg.version : 'unknown';
|
|
4643
|
+
}
|
|
4644
|
+
catch {
|
|
4645
|
+
return 'unknown';
|
|
4646
|
+
}
|
|
4647
|
+
}
|
|
4648
|
+
|
|
3970
4649
|
/**
|
|
3971
4650
|
* Plugin resolution helpers for the OpenClaw plugin SDK.
|
|
3972
4651
|
*
|
|
@@ -4038,4 +4717,378 @@ function resolveOptionalPluginSetting(api, pluginId, key, envVar) {
|
|
|
4038
4717
|
return undefined;
|
|
4039
4718
|
}
|
|
4040
4719
|
|
|
4041
|
-
|
|
4720
|
+
/**
|
|
4721
|
+
* Shared filesystem utilities for runner job scripts.
|
|
4722
|
+
*
|
|
4723
|
+
* @module
|
|
4724
|
+
*/
|
|
4725
|
+
// ========== Time & UUID ==========
|
|
4726
|
+
/** Return current time as ISO 8601 string. */
|
|
4727
|
+
function nowIso() {
|
|
4728
|
+
return new Date().toISOString();
|
|
4729
|
+
}
|
|
4730
|
+
/** Generate a random UUID v4. */
|
|
4731
|
+
function uuid() {
|
|
4732
|
+
return crypto.randomUUID();
|
|
4733
|
+
}
|
|
4734
|
+
// ========== File System ==========
|
|
4735
|
+
/** Create a directory and any missing parents. */
|
|
4736
|
+
function ensureDir(p) {
|
|
4737
|
+
fs.mkdirSync(p, { recursive: true });
|
|
4738
|
+
}
|
|
4739
|
+
/** Read and parse a JSON file, returning `fallback` on any error. */
|
|
4740
|
+
function readJson(p, fallback) {
|
|
4741
|
+
try {
|
|
4742
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
4743
|
+
}
|
|
4744
|
+
catch {
|
|
4745
|
+
return fallback;
|
|
4746
|
+
}
|
|
4747
|
+
}
|
|
4748
|
+
/** Atomically write a JSON file (write to .tmp, then rename). */
|
|
4749
|
+
function writeJsonAtomic(p, obj) {
|
|
4750
|
+
ensureDir(path.dirname(p));
|
|
4751
|
+
const tmp = p + '.tmp';
|
|
4752
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
4753
|
+
fs.renameSync(tmp, p);
|
|
4754
|
+
}
|
|
4755
|
+
/** Append a single JSON object as a JSONL line. */
|
|
4756
|
+
function appendJsonl(p, obj) {
|
|
4757
|
+
ensureDir(path.dirname(p));
|
|
4758
|
+
fs.appendFileSync(p, JSON.stringify(obj) + '\n', 'utf8');
|
|
4759
|
+
}
|
|
4760
|
+
/** Read a JSONL file into an array of parsed objects. */
|
|
4761
|
+
function readJsonl(p) {
|
|
4762
|
+
try {
|
|
4763
|
+
const content = fs.readFileSync(p, 'utf8');
|
|
4764
|
+
return content
|
|
4765
|
+
.split('\n')
|
|
4766
|
+
.filter((line) => line.trim())
|
|
4767
|
+
.map((line) => JSON.parse(line));
|
|
4768
|
+
}
|
|
4769
|
+
catch {
|
|
4770
|
+
return [];
|
|
4771
|
+
}
|
|
4772
|
+
}
|
|
4773
|
+
/** Overwrite a file with an array of objects as JSONL. */
|
|
4774
|
+
function writeJsonl(p, entries) {
|
|
4775
|
+
ensureDir(path.dirname(p));
|
|
4776
|
+
const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
|
4777
|
+
fs.writeFileSync(p, content, 'utf8');
|
|
4778
|
+
}
|
|
4779
|
+
// ========== Process Control ==========
|
|
4780
|
+
/** Synchronous sleep using Atomics.wait. */
|
|
4781
|
+
function sleepMs(ms) {
|
|
4782
|
+
const sab = new SharedArrayBuffer(4);
|
|
4783
|
+
const ia = new Int32Array(sab);
|
|
4784
|
+
Atomics.wait(ia, 0, 0, ms);
|
|
4785
|
+
}
|
|
4786
|
+
/** Async sleep via setTimeout. */
|
|
4787
|
+
function sleepAsync(ms) {
|
|
4788
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
4789
|
+
}
|
|
4790
|
+
// ========== Env File Loader ==========
|
|
4791
|
+
/** Load a .env-style key=value file into process.env. */
|
|
4792
|
+
function loadEnvFile(envPath) {
|
|
4793
|
+
if (!fs.existsSync(envPath))
|
|
4794
|
+
throw new Error(`Missing secret file: ${envPath}`);
|
|
4795
|
+
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
|
|
4796
|
+
const trimmed = line.trim();
|
|
4797
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
4798
|
+
continue;
|
|
4799
|
+
const idx = trimmed.indexOf('=');
|
|
4800
|
+
if (idx > 0) {
|
|
4801
|
+
process.env[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim();
|
|
4802
|
+
}
|
|
4803
|
+
}
|
|
4804
|
+
}
|
|
4805
|
+
// ========== CLI Arg Parsing ==========
|
|
4806
|
+
/** Parse --key=value arguments from argv into a record. */
|
|
4807
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
4808
|
+
const args = {};
|
|
4809
|
+
for (const arg of argv) {
|
|
4810
|
+
const match = /^--([^=]+)=(.*)$/.exec(arg);
|
|
4811
|
+
if (match) {
|
|
4812
|
+
args[match[1]] = match[2];
|
|
4813
|
+
}
|
|
4814
|
+
}
|
|
4815
|
+
return args;
|
|
4816
|
+
}
|
|
4817
|
+
/** Get the value following a named flag, or a default. */
|
|
4818
|
+
function getArg(argv, name, defaultValue) {
|
|
4819
|
+
const i = argv.indexOf(name);
|
|
4820
|
+
if (i >= 0 && i + 1 < argv.length)
|
|
4821
|
+
return argv[i + 1];
|
|
4822
|
+
return defaultValue;
|
|
4823
|
+
}
|
|
4824
|
+
|
|
4825
|
+
/**
|
|
4826
|
+
* Google API auth helpers for runner job scripts.
|
|
4827
|
+
* Supports OAuth refresh tokens and service account impersonation.
|
|
4828
|
+
*
|
|
4829
|
+
* @module
|
|
4830
|
+
*/
|
|
4831
|
+
// ========== JWT / Service Account ==========
|
|
4832
|
+
function base64url(buf) {
|
|
4833
|
+
return buf
|
|
4834
|
+
.toString('base64')
|
|
4835
|
+
.replace(/=/g, '')
|
|
4836
|
+
.replace(/\+/g, '-')
|
|
4837
|
+
.replace(/\//g, '_');
|
|
4838
|
+
}
|
|
4839
|
+
function createJwt(serviceAccount, scopes, subject) {
|
|
4840
|
+
const now = Math.floor(Date.now() / 1000);
|
|
4841
|
+
const header = { alg: 'RS256', typ: 'JWT' };
|
|
4842
|
+
const payload = {
|
|
4843
|
+
iss: serviceAccount.client_email,
|
|
4844
|
+
sub: subject,
|
|
4845
|
+
scope: scopes.join(' '),
|
|
4846
|
+
aud: serviceAccount.token_uri,
|
|
4847
|
+
iat: now,
|
|
4848
|
+
exp: now + 3600,
|
|
4849
|
+
};
|
|
4850
|
+
const headerB64 = base64url(Buffer.from(JSON.stringify(header)));
|
|
4851
|
+
const payloadB64 = base64url(Buffer.from(JSON.stringify(payload)));
|
|
4852
|
+
const unsigned = `${headerB64}.${payloadB64}`;
|
|
4853
|
+
const sign = crypto.createSign('RSA-SHA256');
|
|
4854
|
+
sign.update(unsigned);
|
|
4855
|
+
const signature = base64url(sign.sign(serviceAccount.private_key));
|
|
4856
|
+
return `${unsigned}.${signature}`;
|
|
4857
|
+
}
|
|
4858
|
+
async function getServiceAccountToken(serviceAccount, scopes, subject) {
|
|
4859
|
+
const jwt = createJwt(serviceAccount, scopes, subject);
|
|
4860
|
+
const resp = await fetch(serviceAccount.token_uri, {
|
|
4861
|
+
method: 'POST',
|
|
4862
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
4863
|
+
body: new URLSearchParams({
|
|
4864
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
4865
|
+
assertion: jwt,
|
|
4866
|
+
}),
|
|
4867
|
+
});
|
|
4868
|
+
if (!resp.ok) {
|
|
4869
|
+
const body = await resp.text();
|
|
4870
|
+
throw new Error(`Service account token failed (${String(resp.status)}): ${body}`);
|
|
4871
|
+
}
|
|
4872
|
+
const data = (await resp.json());
|
|
4873
|
+
return data.access_token;
|
|
4874
|
+
}
|
|
4875
|
+
// ========== OAuth Refresh ==========
|
|
4876
|
+
async function getOAuthToken(refreshToken, client) {
|
|
4877
|
+
const resp = await fetch('https://oauth2.googleapis.com/token', {
|
|
4878
|
+
method: 'POST',
|
|
4879
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
4880
|
+
body: new URLSearchParams({
|
|
4881
|
+
client_id: client.client_id,
|
|
4882
|
+
client_secret: client.client_secret,
|
|
4883
|
+
refresh_token: refreshToken,
|
|
4884
|
+
grant_type: 'refresh_token',
|
|
4885
|
+
}),
|
|
4886
|
+
});
|
|
4887
|
+
if (!resp.ok) {
|
|
4888
|
+
const body = await resp.text();
|
|
4889
|
+
throw new Error(`OAuth token refresh failed (${String(resp.status)}): ${body}`);
|
|
4890
|
+
}
|
|
4891
|
+
const data = (await resp.json());
|
|
4892
|
+
return data.access_token;
|
|
4893
|
+
}
|
|
4894
|
+
// ========== Unified Auth ==========
|
|
4895
|
+
/**
|
|
4896
|
+
* Create a Google auth helper with the given configuration.
|
|
4897
|
+
* Returns a function that resolves an access token for a given account and scopes.
|
|
4898
|
+
*/
|
|
4899
|
+
function createGoogleAuth(options) {
|
|
4900
|
+
const { clientCredentialsPath, credentialsDir, serviceAccountDir } = options;
|
|
4901
|
+
let _oauthClient = null;
|
|
4902
|
+
function getOAuthClient() {
|
|
4903
|
+
if (!_oauthClient) {
|
|
4904
|
+
_oauthClient = JSON.parse(fs.readFileSync(clientCredentialsPath, 'utf8'));
|
|
4905
|
+
}
|
|
4906
|
+
return _oauthClient;
|
|
4907
|
+
}
|
|
4908
|
+
/**
|
|
4909
|
+
* Get an access token for the given account and scopes.
|
|
4910
|
+
*/
|
|
4911
|
+
async function getAccessToken(account, scopes) {
|
|
4912
|
+
if (account.serviceAccount) {
|
|
4913
|
+
const saDir = serviceAccountDir ?? credentialsDir;
|
|
4914
|
+
const saPath = typeof account.serviceAccount === 'string'
|
|
4915
|
+
? account.serviceAccount
|
|
4916
|
+
: path.join(saDir, account.serviceAccount.file);
|
|
4917
|
+
const sa = JSON.parse(fs.readFileSync(saPath, 'utf8'));
|
|
4918
|
+
return getServiceAccountToken(sa, scopes, account.email);
|
|
4919
|
+
}
|
|
4920
|
+
if (account.tokenFile) {
|
|
4921
|
+
const tokenPath = path.join(credentialsDir, account.tokenFile);
|
|
4922
|
+
const tokenData = JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
|
|
4923
|
+
return getOAuthToken(tokenData.refresh_token, getOAuthClient());
|
|
4924
|
+
}
|
|
4925
|
+
throw new Error(`No auth method configured for ${account.email}`);
|
|
4926
|
+
}
|
|
4927
|
+
return {
|
|
4928
|
+
/** Get an access token for the given account and scopes. */
|
|
4929
|
+
getAccessToken,
|
|
4930
|
+
};
|
|
4931
|
+
}
|
|
4932
|
+
|
|
4933
|
+
/**
|
|
4934
|
+
* Shared crash-handler wrapper for runner job scripts.
|
|
4935
|
+
* Catches uncaught errors, logs them, and exits with code 1.
|
|
4936
|
+
*
|
|
4937
|
+
* @module
|
|
4938
|
+
*/
|
|
4939
|
+
/**
|
|
4940
|
+
* Wrap a script's main function with crash handling.
|
|
4941
|
+
* On uncaught errors, appends to `_crash.log` in `crashDir` and exits.
|
|
4942
|
+
*
|
|
4943
|
+
* @param name - Script identifier for the crash log.
|
|
4944
|
+
* @param fn - Main function to execute (sync or async).
|
|
4945
|
+
* @param crashDir - Directory for crash logs (default: current working directory).
|
|
4946
|
+
*/
|
|
4947
|
+
function runScript(name, fn, crashDir = process.cwd()) {
|
|
4948
|
+
const execute = () => {
|
|
4949
|
+
const result = fn();
|
|
4950
|
+
if (result instanceof Promise) {
|
|
4951
|
+
result.catch((err) => {
|
|
4952
|
+
handleCrash(name, err, crashDir);
|
|
4953
|
+
});
|
|
4954
|
+
}
|
|
4955
|
+
};
|
|
4956
|
+
try {
|
|
4957
|
+
execute();
|
|
4958
|
+
}
|
|
4959
|
+
catch (err) {
|
|
4960
|
+
handleCrash(name, err, crashDir);
|
|
4961
|
+
}
|
|
4962
|
+
}
|
|
4963
|
+
function handleCrash(name, err, crashDir) {
|
|
4964
|
+
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
4965
|
+
const entry = `[${new Date().toISOString()}] CRASH (${name}): ${message}\n`;
|
|
4966
|
+
try {
|
|
4967
|
+
fs.mkdirSync(crashDir, { recursive: true });
|
|
4968
|
+
fs.appendFileSync(path.join(crashDir, '_crash.log'), entry);
|
|
4969
|
+
}
|
|
4970
|
+
catch {
|
|
4971
|
+
// Best effort — don't crash the crash handler.
|
|
4972
|
+
}
|
|
4973
|
+
console.error(entry);
|
|
4974
|
+
process.exit(1);
|
|
4975
|
+
}
|
|
4976
|
+
|
|
4977
|
+
/**
|
|
4978
|
+
* Shell execution utilities for runner job scripts.
|
|
4979
|
+
*
|
|
4980
|
+
* @module
|
|
4981
|
+
*/
|
|
4982
|
+
/**
|
|
4983
|
+
* Run a command synchronously and return trimmed stdout.
|
|
4984
|
+
* Throws on non-zero exit code.
|
|
4985
|
+
*/
|
|
4986
|
+
function run(cmd, args, opts = {}) {
|
|
4987
|
+
const r = cp.spawnSync(cmd, args, {
|
|
4988
|
+
encoding: 'utf8',
|
|
4989
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
4990
|
+
...opts,
|
|
4991
|
+
});
|
|
4992
|
+
if (r.error)
|
|
4993
|
+
throw r.error;
|
|
4994
|
+
const stdout = r.stdout || '';
|
|
4995
|
+
const stderr = r.stderr || '';
|
|
4996
|
+
if (r.status !== 0) {
|
|
4997
|
+
const msg = (stderr || stdout).trim();
|
|
4998
|
+
throw new Error(`${cmd} ${args.join(' ')} failed (exit ${String(r.status)}): ${msg}`);
|
|
4999
|
+
}
|
|
5000
|
+
return stdout.trim();
|
|
5001
|
+
}
|
|
5002
|
+
/**
|
|
5003
|
+
* Run a command with automatic retries on transient failures.
|
|
5004
|
+
* Uses exponential backoff between attempts.
|
|
5005
|
+
*/
|
|
5006
|
+
function runWithRetry(cmd, args, opts = {}) {
|
|
5007
|
+
const { retries = 2, backoffMs = 5000, isRetryable, ...runOpts } = opts;
|
|
5008
|
+
let lastErr;
|
|
5009
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
5010
|
+
try {
|
|
5011
|
+
return run(cmd, args, runOpts);
|
|
5012
|
+
}
|
|
5013
|
+
catch (e) {
|
|
5014
|
+
lastErr = e;
|
|
5015
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
5016
|
+
const retryable = isRetryable
|
|
5017
|
+
? isRetryable(e)
|
|
5018
|
+
: /context deadline exceeded|timed out|timeout/i.test(msg);
|
|
5019
|
+
if (!retryable || attempt === retries)
|
|
5020
|
+
throw lastErr;
|
|
5021
|
+
sleepMs(backoffMs * Math.pow(2, attempt));
|
|
5022
|
+
}
|
|
5023
|
+
}
|
|
5024
|
+
throw lastErr;
|
|
5025
|
+
}
|
|
5026
|
+
|
|
5027
|
+
/**
|
|
5028
|
+
* Slack channel → workspace mapping cache.
|
|
5029
|
+
* Resolves which Slack workspace owns a given channel.
|
|
5030
|
+
*
|
|
5031
|
+
* @module
|
|
5032
|
+
*/
|
|
5033
|
+
let _cache = null;
|
|
5034
|
+
let _dirty = false;
|
|
5035
|
+
let _cachePath = '';
|
|
5036
|
+
function loadCache(cachePath) {
|
|
5037
|
+
if (!_cache || _cachePath !== cachePath) {
|
|
5038
|
+
_cachePath = cachePath;
|
|
5039
|
+
try {
|
|
5040
|
+
_cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
5041
|
+
}
|
|
5042
|
+
catch {
|
|
5043
|
+
_cache = {};
|
|
5044
|
+
}
|
|
5045
|
+
}
|
|
5046
|
+
return _cache;
|
|
5047
|
+
}
|
|
5048
|
+
/** Flush pending cache changes to disk. */
|
|
5049
|
+
function saveCache() {
|
|
5050
|
+
if (_dirty && _cache && _cachePath) {
|
|
5051
|
+
fs.writeFileSync(_cachePath, JSON.stringify(_cache, null, 2) + '\n');
|
|
5052
|
+
_dirty = false;
|
|
5053
|
+
}
|
|
5054
|
+
}
|
|
5055
|
+
async function queryChannelWorkspace(channelId, token, defaultWorkspace) {
|
|
5056
|
+
const url = `https://slack.com/api/conversations.info?channel=${channelId}`;
|
|
5057
|
+
try {
|
|
5058
|
+
const res = await fetch(url, {
|
|
5059
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
5060
|
+
});
|
|
5061
|
+
if (!res.ok)
|
|
5062
|
+
return defaultWorkspace;
|
|
5063
|
+
const j = (await res.json());
|
|
5064
|
+
if (!j.ok)
|
|
5065
|
+
return defaultWorkspace;
|
|
5066
|
+
const shared = j.channel?.shared_team_ids ?? [];
|
|
5067
|
+
if (shared.length > 0 && !shared.includes(defaultWorkspace)) {
|
|
5068
|
+
return shared[0];
|
|
5069
|
+
}
|
|
5070
|
+
return defaultWorkspace;
|
|
5071
|
+
}
|
|
5072
|
+
catch {
|
|
5073
|
+
return defaultWorkspace;
|
|
5074
|
+
}
|
|
5075
|
+
}
|
|
5076
|
+
/**
|
|
5077
|
+
* Resolve the workspace team ID that owns a Slack channel.
|
|
5078
|
+
* Results are cached to disk.
|
|
5079
|
+
*
|
|
5080
|
+
* @param channelId - Slack channel ID.
|
|
5081
|
+
* @param token - Slack bot token for API calls.
|
|
5082
|
+
* @param options - Cache path and default workspace.
|
|
5083
|
+
*/
|
|
5084
|
+
async function getChannelWorkspace(channelId, token, options) {
|
|
5085
|
+
const cache = loadCache(options.cachePath);
|
|
5086
|
+
if (cache[channelId])
|
|
5087
|
+
return cache[channelId];
|
|
5088
|
+
const teamId = await queryChannelWorkspace(channelId, token, options.defaultWorkspace);
|
|
5089
|
+
cache[channelId] = teamId;
|
|
5090
|
+
_dirty = true;
|
|
5091
|
+
return teamId;
|
|
5092
|
+
}
|
|
5093
|
+
|
|
5094
|
+
export { AGENTS_MARKERS, CLEANUP_FLAG, COMPONENT_CONFIG_PREFIX, COMPONENT_VERSIONS_FILE, CONFIG_FILE, CORE_CONFIG_DIR, CORE_VERSION, ComponentWriter, DEFAULT_BIND_ADDRESS, DEFAULT_CORE_VERSION, DEFAULT_PORTS, HEARTBEAT_HEADING, JEEVES_SKILL_DIR, META_PORT, PLATFORM_COMPONENTS, REGISTRY_CACHE_FILE, RUNNER_PORT, SECTION_IDS, SECTION_ORDER, SERVER_PORT, SKILLS_DIR, SOUL_MARKERS, STALENESS_THRESHOLD_MS, STALE_LOCK_MS, TEMPLATES_DIR, TOOLS_MARKERS, VERSION_STAMP_PATTERN, WATCHER_PORT, WORKSPACE_CONFIG_DEFAULTS, WORKSPACE_CONFIG_FILE, WORKSPACE_FILES, analyzeMemory, appendJsonl, atomicWrite, buildEffectiveConfig, buildHeartbeatSection, checkNodeVersion, checkRegistryVersion, connectionFail, coreConfigSchema, createAsyncContentCache, createComponentWriter, createConfigApplyHandler, createConfigQueryHandler, createGoogleAuth, createPluginCli, createPluginToolset, createServiceCli, createServiceManager, createStatusHandler, ensureDir, extractMostRecentDate, fail, fetchJson, fetchWithTimeout, formatBeginMarker, formatEndMarker, generateJsonSchema, generateWorkspaceJsonSchema, getArg, getBindAddress, getChannelWorkspace, getComponentConfigDir, getConfigRoot, getCoreConfigDir, getCoreConfigFile, getEffectiveServiceName, getPackageVersion, getServiceState, getServiceUrl, getWorkspacePath, init, isPrime, jaccard, jeevesComponentDescriptorSchema, loadEnvFile, loadWorkspaceConfig, needsCleanup, nowIso, ok, orchestrateHeartbeat, parseArgs, parseHeartbeat, parseManaged, patchConfig, postJson, readComponentVersions, readJson, readJsonl, refreshPlatformContent, removeComponentVersion, removeManagedSection, resetInit, resolveConfigPath, resolveConfigValue, resolveOpenClawHome, resolveOptionalPluginSetting, resolvePluginSetting, resolveWorkspacePath, run, runScript, runWithRetry, saveCache, seedContent, seedSkill, shingles, shouldWrite, sleepAsync, sleepMs, updateManagedSection, uuid, withFileLock, workspaceConfigSchema, writeComponentVersion, writeHeartbeatSection, writeJsonAtomic, writeJsonl };
|