@senpi/runtime 1.1.0-dev.runtime-phase-2.20260513122539 → 1.1.0-dev.runtime-phase-2.20260514144638
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/senpi-cli-config.d.ts +15 -1
- package/dist/cli/senpi-cli-config.d.ts.map +1 -1
- package/dist/cli/senpi-cli-config.js +19 -2
- package/dist/cli/senpi-cli-config.js.map +1 -1
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +4 -1
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/index.d.ts +17 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -11
- package/dist/index.js.map +1 -1
- package/dist/runtime/auto-update/coordinator.d.ts +13 -0
- package/dist/runtime/auto-update/coordinator.d.ts.map +1 -1
- package/dist/runtime/auto-update/coordinator.js +22 -8
- package/dist/runtime/auto-update/coordinator.js.map +1 -1
- package/dist/skills-manager/bootstrap.d.ts.map +1 -1
- package/dist/skills-manager/bootstrap.js +34 -16
- package/dist/skills-manager/bootstrap.js.map +1 -1
- package/dist/skills-manager/constants.d.ts +20 -1
- package/dist/skills-manager/constants.d.ts.map +1 -1
- package/dist/skills-manager/constants.js +26 -1
- package/dist/skills-manager/constants.js.map +1 -1
- package/dist/skills-manager/coordinator.d.ts +157 -20
- package/dist/skills-manager/coordinator.d.ts.map +1 -1
- package/dist/skills-manager/coordinator.js +289 -86
- package/dist/skills-manager/coordinator.js.map +1 -1
- package/dist/skills-manager/index.d.ts +1 -1
- package/dist/skills-manager/index.d.ts.map +1 -1
- package/dist/skills-manager/index.js +1 -1
- package/dist/skills-manager/index.js.map +1 -1
- package/dist/skills-manager/state-store.d.ts +27 -4
- package/dist/skills-manager/state-store.d.ts.map +1 -1
- package/dist/skills-manager/state-store.js +42 -16
- package/dist/skills-manager/state-store.js.map +1 -1
- package/dist/skills-manager/types.d.ts +46 -8
- package/dist/skills-manager/types.d.ts.map +1 -1
- package/dist/skills-manager/utils.d.ts +17 -0
- package/dist/skills-manager/utils.d.ts.map +1 -1
- package/dist/skills-manager/utils.js +43 -11
- package/dist/skills-manager/utils.js.map +1 -1
- package/dist/skills-manager/version-manager.d.ts +93 -8
- package/dist/skills-manager/version-manager.d.ts.map +1 -1
- package/dist/skills-manager/version-manager.js +185 -28
- package/dist/skills-manager/version-manager.js.map +1 -1
- package/openclaw.plugin.json +7 -9
- package/package.json +1 -1
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Central lifecycle manager for Senpi skills auto-update.
|
|
3
3
|
*
|
|
4
|
-
* Mirrors AutoUpdateCoordinator from src/runtime/auto-update/coordinator.ts
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* testability.
|
|
4
|
+
* Mirrors AutoUpdateCoordinator from src/runtime/auto-update/coordinator.ts
|
|
5
|
+
* for thread-safety (runToken + stopped + running triple) and uses the same
|
|
6
|
+
* injectable-dependency pattern for testability.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Scheduling: this coordinator does not own a timer. Ticks fire from
|
|
9
|
+
* outside — currently from auto-update's `onTickComplete` hook in
|
|
10
|
+
* `src/index.ts`, which calls {@link SkillsUpdateCoordinator.runTickOnce}
|
|
11
|
+
* after every auto-update cycle. Skills-manager therefore inherits the
|
|
12
|
+
* `autoUpdate.pollIntervalMinutes` cadence and the same failure-backoff
|
|
13
|
+
* cadence.
|
|
14
|
+
*
|
|
15
|
+
* On start(): load persisted state, bootstrap any missing skills, mark
|
|
16
|
+
* health as healthy. The first tick fires on the next external invocation
|
|
17
|
+
* of `runTickOnce()`.
|
|
18
|
+
*
|
|
19
|
+
* Each tick: resolve skill names (configured list or discovery), check
|
|
20
|
+
* remote versions in parallel, auto-apply patch/minor, notify on major
|
|
21
|
+
* (deduped by version), skip on none/unknown.
|
|
12
22
|
*/
|
|
13
23
|
import { readdir } from "node:fs/promises";
|
|
14
24
|
import { bootstrapSkillsIfNeeded } from "./bootstrap.js";
|
|
@@ -18,10 +28,15 @@ import { checkSkillVersion } from "./version-manager.js";
|
|
|
18
28
|
import { installSkill } from "./writer.js";
|
|
19
29
|
import { SKILLS_RAW_BASE_URL, SKILLS_REPO_URL } from "./constants.js";
|
|
20
30
|
import { createDefaultSkillState, createDefaultSkillsManagerState, formatErr } from "./utils.js";
|
|
31
|
+
export class SkillsCoordinatorUnavailableError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "SkillsCoordinatorUnavailableError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
21
37
|
export class SkillsUpdateCoordinator {
|
|
22
38
|
options;
|
|
23
39
|
store;
|
|
24
|
-
timer = null;
|
|
25
40
|
operationChain = Promise.resolve();
|
|
26
41
|
stopped = true;
|
|
27
42
|
runToken = 0;
|
|
@@ -29,12 +44,51 @@ export class SkillsUpdateCoordinator {
|
|
|
29
44
|
snapshot;
|
|
30
45
|
/** Latest remote version per skill name — populated during ticks. */
|
|
31
46
|
latestVersions = new Map();
|
|
47
|
+
/**
|
|
48
|
+
* Local (pre-install) version per skill name — populated during ticks
|
|
49
|
+
* and manual RPC paths from `SkillVersionInfo.localVersion`. Used by the
|
|
50
|
+
* update-summary and major-available notifications to render the
|
|
51
|
+
* `prev → new` transition. Captured *before* the install runs so the
|
|
52
|
+
* value still reflects the on-disk version at the time the user chose to
|
|
53
|
+
* update; reading `state.skills[name].installedVersion` instead would
|
|
54
|
+
* surface the *post-install* version (recorded by `recordSuccess`) and
|
|
55
|
+
* collapse the transition to `vX -> vX`.
|
|
56
|
+
*/
|
|
57
|
+
previousVersions = new Map();
|
|
58
|
+
/**
|
|
59
|
+
* Cached short descriptions parsed from each skill's **remote** SKILL.md
|
|
60
|
+
* frontmatter — i.e. the description for the upcoming/current remote
|
|
61
|
+
* version, not whatever's installed locally. Used to enrich
|
|
62
|
+
* update-summary and major-available notifications with a one-line
|
|
63
|
+
* "About:" so the user gets context for the version the message is
|
|
64
|
+
* actually announcing.
|
|
65
|
+
*
|
|
66
|
+
* Populated during tick / updateSkill / updateAll alongside
|
|
67
|
+
* `latestVersions`; absent / null entries simply omit the line from the
|
|
68
|
+
* notification.
|
|
69
|
+
*
|
|
70
|
+
* Using the remote description (rather than the local one) keeps the
|
|
71
|
+
* notification description in sync with the version number it sits next
|
|
72
|
+
* to — e.g. when notifying about a major bump v1 → v2, the "About:" line
|
|
73
|
+
* reflects the v2 description, even if the v1 local copy on disk is
|
|
74
|
+
* stale or worded differently.
|
|
75
|
+
*/
|
|
76
|
+
skillDescriptions = new Map();
|
|
32
77
|
constructor(options) {
|
|
33
78
|
this.options = options;
|
|
34
79
|
this.store = new SkillsManagerStateStore(options.stateDir, options.config.skillsDir, options.logger);
|
|
35
80
|
this.snapshot = this.buildSnapshot("disabled", "not-started");
|
|
36
81
|
}
|
|
37
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Load persisted state and bootstrap missing skills. Does **not** schedule
|
|
84
|
+
* a poll loop — skills-manager runs off the auto-update coordinator's
|
|
85
|
+
* `onTickComplete` hook (see `src/index.ts`), which invokes
|
|
86
|
+
* {@link runTickOnce} on whatever cadence auto-update is configured with.
|
|
87
|
+
*
|
|
88
|
+
* Calling `start()` is still required even in the externally-scheduled
|
|
89
|
+
* mode so bootstrap, state load, and the "ready" snapshot all happen
|
|
90
|
+
* before the first external tick fires.
|
|
91
|
+
*/
|
|
38
92
|
async start() {
|
|
39
93
|
this.runToken += 1;
|
|
40
94
|
this.stopped = false;
|
|
@@ -45,7 +99,7 @@ export class SkillsUpdateCoordinator {
|
|
|
45
99
|
logger.info("[senpi-skills-manager] disabled by config");
|
|
46
100
|
return;
|
|
47
101
|
}
|
|
48
|
-
logger.info(`[senpi-skills-manager] starting | skillsDir=${config.skillsDir
|
|
102
|
+
logger.info(`[senpi-skills-manager] starting | skillsDir=${config.skillsDir.join(",")} skills=${config.skills.join(",") || "(discover)"} cadence=external (driven by auto-update)`);
|
|
49
103
|
this.state = await this.store.load();
|
|
50
104
|
const bootstrapFn = this.options.deps?.bootstrapSkillsIfNeeded ?? bootstrapSkillsIfNeeded;
|
|
51
105
|
try {
|
|
@@ -55,23 +109,21 @@ export class SkillsUpdateCoordinator {
|
|
|
55
109
|
logger.warn(`[senpi-skills-manager] bootstrap error (non-fatal): ${formatErr(err)}`);
|
|
56
110
|
}
|
|
57
111
|
this.snapshot = this.buildSnapshot("healthy", "started");
|
|
58
|
-
// First tick fires after 1 s (Math.max(1_000, 0) floor in scheduleNext)
|
|
59
|
-
this.scheduleNext(0, this.runToken);
|
|
60
112
|
}
|
|
61
113
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
114
|
+
* Stop the coordinator: prevent any new ticks from being processed, and
|
|
115
|
+
* wait for any in-flight tick or manual RPC to finish so the caller can
|
|
116
|
+
* rely on no async work still touching state after `stop()` resolves.
|
|
117
|
+
*
|
|
118
|
+
* Since scheduling is now external (auto-update's `onTickComplete` hook),
|
|
119
|
+
* there's no internal timer to cancel — `stop()` just flips the `stopped`
|
|
120
|
+
* flag (which `runTickOnce` checks) and drains the serialised operation
|
|
121
|
+
* queue. The auto-update hook itself becomes a no-op for stopped
|
|
122
|
+
* coordinators since `runTickOnce` returns early.
|
|
67
123
|
*/
|
|
68
124
|
async stop() {
|
|
69
125
|
this.runToken += 1;
|
|
70
126
|
this.stopped = true;
|
|
71
|
-
if (this.timer) {
|
|
72
|
-
clearTimeout(this.timer);
|
|
73
|
-
this.timer = null;
|
|
74
|
-
}
|
|
75
127
|
const inFlight = this.operationChain;
|
|
76
128
|
this.options.logger.info("[senpi-skills-manager] stop requested — draining in-flight operations");
|
|
77
129
|
// operationChain never rejects (runSerialized catches via try/finally before
|
|
@@ -79,6 +131,47 @@ export class SkillsUpdateCoordinator {
|
|
|
79
131
|
await inFlight;
|
|
80
132
|
this.options.logger.info("[senpi-skills-manager] stopped");
|
|
81
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Public one-shot tick entry point. Called externally on whatever cadence
|
|
136
|
+
* the host wires up — currently from the auto-update coordinator's
|
|
137
|
+
* `onTickComplete` hook in `src/index.ts`, which fires after each
|
|
138
|
+
* auto-update version check completes (success or failure).
|
|
139
|
+
*
|
|
140
|
+
* Replaces the previous internal `tick()` + `scheduleNext()` pair: the
|
|
141
|
+
* coordinator no longer owns a timer of its own. Manual RPCs
|
|
142
|
+
* (`senpi.skills.update`, `senpi.skills.updateAll`) remain independent of
|
|
143
|
+
* this path.
|
|
144
|
+
*
|
|
145
|
+
* Returns early as a no-op when:
|
|
146
|
+
* - The coordinator has been stopped (e.g. plugin teardown).
|
|
147
|
+
* - The runToken has rolled (e.g. a stop/start cycle invalidated this call).
|
|
148
|
+
* - The coordinator was disabled at start time (config.enabled === false).
|
|
149
|
+
*
|
|
150
|
+
* Tick errors are logged + recorded on the snapshot but never thrown —
|
|
151
|
+
* the caller (auto-update) must not have its own scheduling disturbed by
|
|
152
|
+
* a transient skills-manager failure.
|
|
153
|
+
*/
|
|
154
|
+
async runTickOnce() {
|
|
155
|
+
const token = this.runToken;
|
|
156
|
+
if (this.stopped || !this.options.config.enabled)
|
|
157
|
+
return;
|
|
158
|
+
await this.runSerialized("tick", async () => {
|
|
159
|
+
if (this.stopped || token !== this.runToken)
|
|
160
|
+
return;
|
|
161
|
+
this.options.logger.info("[senpi-skills-manager] tick started (external trigger)");
|
|
162
|
+
try {
|
|
163
|
+
await this.runTick();
|
|
164
|
+
await this.persistState();
|
|
165
|
+
this.snapshot = this.buildSnapshot("healthy", "tick-complete");
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
this.options.logger.warn(`[senpi-skills-manager] tick error: ${formatErr(err)}`);
|
|
169
|
+
this.snapshot = this.buildSnapshot("degraded", "tick-error", formatErr(err));
|
|
170
|
+
// No internal backoff — the next external tick (from auto-update's
|
|
171
|
+
// own poll loop / backoff) will retry on its own cadence.
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
82
175
|
/** Return the latest point-in-time snapshot for gateway RPCs. */
|
|
83
176
|
getSnapshot() {
|
|
84
177
|
return this.snapshot;
|
|
@@ -96,7 +189,10 @@ export class SkillsUpdateCoordinator {
|
|
|
96
189
|
* @throws On network or install error so the gateway RPC can return ok=false.
|
|
97
190
|
*/
|
|
98
191
|
async updateSkill(skillName) {
|
|
192
|
+
const token = this.runToken;
|
|
193
|
+
this.assertManualOperationAllowed("updateSkill");
|
|
99
194
|
return this.runSerialized("updateSkill", async () => {
|
|
195
|
+
this.assertManualOperationAllowed("updateSkill", token);
|
|
100
196
|
const { logger } = this.options;
|
|
101
197
|
logger.info(`[senpi-skills-manager] updateSkill invoked | skill=${skillName}`);
|
|
102
198
|
const checkVersionFn = this.resolveCheckFn();
|
|
@@ -119,8 +215,11 @@ export class SkillsUpdateCoordinator {
|
|
|
119
215
|
}
|
|
120
216
|
// Record the remote version we just learned so the snapshot's latestVersion
|
|
121
217
|
// field reflects it on the next getSnapshot() call — even when no install
|
|
122
|
-
// happens (skip paths below).
|
|
218
|
+
// happens (skip paths below). Local description is cached alongside so
|
|
219
|
+
// notifications (sendUpdateSummary / handleMajorAvailable) can include it.
|
|
123
220
|
this.latestVersions.set(skillName, versionInfo.remoteVersion);
|
|
221
|
+
this.previousVersions.set(skillName, versionInfo.localVersion);
|
|
222
|
+
this.skillDescriptions.set(skillName, versionInfo.remoteDescription ?? null);
|
|
124
223
|
// Mirror runTick: every skill whose version we just checked gets its
|
|
125
224
|
// `lastCheckedAt` updated (and `installedVersion` refreshed from the local
|
|
126
225
|
// read) regardless of whether it proceeds to install. Without this, skills
|
|
@@ -177,7 +276,10 @@ export class SkillsUpdateCoordinator {
|
|
|
177
276
|
* this mirrors the tick path's batching instead.
|
|
178
277
|
*/
|
|
179
278
|
async updateAll() {
|
|
279
|
+
const token = this.runToken;
|
|
280
|
+
this.assertManualOperationAllowed("updateAll");
|
|
180
281
|
return this.runSerialized("updateAll", async () => {
|
|
282
|
+
this.assertManualOperationAllowed("updateAll", token);
|
|
181
283
|
const { logger, config } = this.options;
|
|
182
284
|
logger.info("[senpi-skills-manager] updateAll invoked");
|
|
183
285
|
const skillNames = await this.resolveSkillNames();
|
|
@@ -191,6 +293,8 @@ export class SkillsUpdateCoordinator {
|
|
|
191
293
|
const versionChecks = await Promise.allSettled(skillNames.map(async (skillName) => {
|
|
192
294
|
const versionInfo = await checkVersionFn(config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
|
|
193
295
|
this.latestVersions.set(skillName, versionInfo.remoteVersion);
|
|
296
|
+
this.previousVersions.set(skillName, versionInfo.localVersion);
|
|
297
|
+
this.skillDescriptions.set(skillName, versionInfo.remoteDescription ?? null);
|
|
194
298
|
return { name: skillName, ...versionInfo };
|
|
195
299
|
}));
|
|
196
300
|
const results = [];
|
|
@@ -284,41 +388,6 @@ export class SkillsUpdateCoordinator {
|
|
|
284
388
|
return results;
|
|
285
389
|
});
|
|
286
390
|
}
|
|
287
|
-
// ── Private scheduling ─────────────────────────────────────────────────────
|
|
288
|
-
scheduleNext(delayMs, token) {
|
|
289
|
-
if (this.stopped || token !== this.runToken)
|
|
290
|
-
return;
|
|
291
|
-
if (this.timer)
|
|
292
|
-
clearTimeout(this.timer);
|
|
293
|
-
const waitMs = Math.max(1_000, delayMs);
|
|
294
|
-
const nextCheckAt = new Date(Date.now() + waitMs).toISOString();
|
|
295
|
-
this.snapshot = { ...this.snapshot, nextCheckAt };
|
|
296
|
-
this.timer = setTimeout(() => {
|
|
297
|
-
void this.tick(token);
|
|
298
|
-
}, waitMs);
|
|
299
|
-
}
|
|
300
|
-
async tick(token) {
|
|
301
|
-
if (this.stopped || token !== this.runToken)
|
|
302
|
-
return;
|
|
303
|
-
await this.runSerialized("tick", async () => {
|
|
304
|
-
if (this.stopped || token !== this.runToken)
|
|
305
|
-
return;
|
|
306
|
-
this.options.logger.info("[senpi-skills-manager] tick started");
|
|
307
|
-
try {
|
|
308
|
-
await this.runTick();
|
|
309
|
-
await this.persistState();
|
|
310
|
-
this.snapshot = this.buildSnapshot("healthy", "tick-complete");
|
|
311
|
-
const nextDelayMs = this.options.config.pollIntervalMinutes * 60_000;
|
|
312
|
-
this.scheduleNext(nextDelayMs, token);
|
|
313
|
-
}
|
|
314
|
-
catch (err) {
|
|
315
|
-
this.options.logger.warn(`[senpi-skills-manager] tick error: ${formatErr(err)}`);
|
|
316
|
-
this.snapshot = this.buildSnapshot("degraded", "tick-error", formatErr(err));
|
|
317
|
-
// 5-minute backoff on full-tick failure (e.g. network completely down)
|
|
318
|
-
this.scheduleNext(5 * 60_000, token);
|
|
319
|
-
}
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
391
|
/**
|
|
323
392
|
* Run coordinator work in a global critical section so periodic ticks and
|
|
324
393
|
* manual RPC updates never interleave at await points.
|
|
@@ -342,6 +411,23 @@ export class SkillsUpdateCoordinator {
|
|
|
342
411
|
releaseQueue?.();
|
|
343
412
|
}
|
|
344
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* Manual gateway RPCs (`updateSkill`, `updateAll`) are only valid while the
|
|
416
|
+
* coordinator is actively running and enabled. This prevents pre-start calls
|
|
417
|
+
* (e.g. when SENPI_RUNTIME_API_DISABLED=true skipped start()) from persisting
|
|
418
|
+
* the constructor-default empty state over a valid on-disk state file.
|
|
419
|
+
*/
|
|
420
|
+
assertManualOperationAllowed(operationName, token = null) {
|
|
421
|
+
if (!this.options.config.enabled) {
|
|
422
|
+
throw new SkillsCoordinatorUnavailableError(`[senpi-skills-manager] ${operationName} rejected: skills manager is disabled`);
|
|
423
|
+
}
|
|
424
|
+
if (this.stopped) {
|
|
425
|
+
throw new SkillsCoordinatorUnavailableError(`[senpi-skills-manager] ${operationName} rejected: coordinator is not running`);
|
|
426
|
+
}
|
|
427
|
+
if (token !== null && token !== this.runToken) {
|
|
428
|
+
throw new SkillsCoordinatorUnavailableError(`[senpi-skills-manager] ${operationName} rejected: coordinator lifecycle changed`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
345
431
|
/**
|
|
346
432
|
* Core tick logic: resolve skill names, check versions in parallel, apply
|
|
347
433
|
* policy per skill, then run a single clone for all skills needing update.
|
|
@@ -359,6 +445,8 @@ export class SkillsUpdateCoordinator {
|
|
|
359
445
|
const versionChecks = await Promise.allSettled(skillNames.map(async (skillName) => {
|
|
360
446
|
const versionInfo = await checkVersionFn(config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
|
|
361
447
|
this.latestVersions.set(skillName, versionInfo.remoteVersion);
|
|
448
|
+
this.previousVersions.set(skillName, versionInfo.localVersion);
|
|
449
|
+
this.skillDescriptions.set(skillName, versionInfo.remoteDescription ?? null);
|
|
362
450
|
return { name: skillName, ...versionInfo };
|
|
363
451
|
}));
|
|
364
452
|
const skillsToInstall = [];
|
|
@@ -484,7 +572,10 @@ export class SkillsUpdateCoordinator {
|
|
|
484
572
|
try {
|
|
485
573
|
for (const skillName of skillNames) {
|
|
486
574
|
try {
|
|
487
|
-
|
|
575
|
+
// Writes always target the *primary* dir (first entry in the
|
|
576
|
+
// search path). Secondary dirs are read-only mirrors — we'd never
|
|
577
|
+
// overwrite them on a tick or manual update.
|
|
578
|
+
await installSkill(cloneDir, skillName, config.skillsDir[0], logger);
|
|
488
579
|
const newVersion = this.latestVersions.get(skillName) ?? null;
|
|
489
580
|
outcomes.set(skillName, { version: newVersion });
|
|
490
581
|
logger.info(`[senpi-skills-manager] cloneAndInstallMany — installed | skill=${skillName} version=${newVersion ?? "unknown"}`);
|
|
@@ -612,6 +703,58 @@ export class SkillsUpdateCoordinator {
|
|
|
612
703
|
logger.info(`[senpi-skills-manager] applyUpdate complete | skill=${skillName} version=${installedVersion ?? "unknown"}`);
|
|
613
704
|
return installedVersion;
|
|
614
705
|
}
|
|
706
|
+
/**
|
|
707
|
+
* Format the per-skill nested-bullet block used in both notification
|
|
708
|
+
* shapes. Renders the structure suggested in PR #78 review feedback:
|
|
709
|
+
*
|
|
710
|
+
* **skill-name**
|
|
711
|
+
* • v1.0.0 → v2.0.0 (or "v1.0.0 (install failed)" when failed)
|
|
712
|
+
* • description: … (omitted when no remote description)
|
|
713
|
+
* • error: … (only on failure, replaces the description)
|
|
714
|
+
*
|
|
715
|
+
* **Why markdown, not HTML?**
|
|
716
|
+
* Openclaw's Telegram channel (`src/telegram/send.ts:sendMessageTelegram`)
|
|
717
|
+
* pipes outbound text through `renderTelegramHtmlText`, which defaults
|
|
718
|
+
* `textMode` to `"markdown"` — i.e. it expects the source to be markdown
|
|
719
|
+
* and runs `markdownToTelegramHtml` over it before handing the converted
|
|
720
|
+
* HTML to the Bot API with `parse_mode: "HTML"`. The `message` tool
|
|
721
|
+
* (which is what this coordinator's `notify` callback ultimately hits via
|
|
722
|
+
* `/tools/invoke`) does not expose `textMode`, so HTML written by us is
|
|
723
|
+
* treated as literal characters by the markdown step and rendered as
|
|
724
|
+
* visible `<b>` text in the chat (the actual production bug this docstring
|
|
725
|
+
* fixes — confirmed against a live notification screenshot).
|
|
726
|
+
*
|
|
727
|
+
* Using markdown source (`**bold**`, backticks) instead means the channel's
|
|
728
|
+
* markdown→HTML converter produces the correct `<b>` / `<code>` HTML
|
|
729
|
+
* payload and Telegram renders the formatting properly.
|
|
730
|
+
*
|
|
731
|
+
* @param skillName Skill identifier (rendered bold via `**name**`).
|
|
732
|
+
* @param toVersion Resolved upgraded-to version. `null` for failure rows
|
|
733
|
+
* where no install completed; renders as
|
|
734
|
+
* "vPREV (install failed)".
|
|
735
|
+
* @param errorReason Optional failure reason. When provided, replaces
|
|
736
|
+
* the description line with `error: …`.
|
|
737
|
+
*/
|
|
738
|
+
formatSkillBlock(skillName, toVersion, errorReason = null) {
|
|
739
|
+
const prev = this.previousVersions.get(skillName) ?? null;
|
|
740
|
+
const versionLine = errorReason !== null
|
|
741
|
+
? ` • ${prev !== null ? `v${prev} ` : ""}(install failed)`
|
|
742
|
+
: ` • ${prev !== null ? `v${prev} → ` : ""}${toVersion !== null ? `v${toVersion}` : "(unknown)"}`;
|
|
743
|
+
const description = this.skillDescriptions.get(skillName) ?? null;
|
|
744
|
+
const trailingLine = errorReason !== null
|
|
745
|
+
? ` • error: ${errorReason}`
|
|
746
|
+
: description
|
|
747
|
+
? ` • description: ${description}`
|
|
748
|
+
: null;
|
|
749
|
+
// Skill name line: bold only, no leading bullet — the bold acts as the
|
|
750
|
+
// visual anchor for the block, and the nested bullets below already
|
|
751
|
+
// convey "these belong to this skill". A leading `•` on the heading
|
|
752
|
+
// looked redundant in production.
|
|
753
|
+
const lines = [`**${skillName}**`, versionLine];
|
|
754
|
+
if (trailingLine)
|
|
755
|
+
lines.push(trailingLine);
|
|
756
|
+
return lines.join("\n");
|
|
757
|
+
}
|
|
615
758
|
/**
|
|
616
759
|
* Send a one-way Telegram notification for a pending major update, deduped
|
|
617
760
|
* by version.
|
|
@@ -627,9 +770,19 @@ export class SkillsUpdateCoordinator {
|
|
|
627
770
|
return;
|
|
628
771
|
}
|
|
629
772
|
try {
|
|
630
|
-
|
|
773
|
+
const block = this.formatSkillBlock(skillName, remoteVersion);
|
|
774
|
+
await this.options.notify(`Senpi skills update — major available:\n\n` +
|
|
775
|
+
`${block}\n\n` +
|
|
631
776
|
`Major updates are not applied automatically.\n` +
|
|
632
|
-
|
|
777
|
+
// Inline code via markdown backticks — the channel's markdown→HTML
|
|
778
|
+
// step converts these to `<code>` so the install command renders
|
|
779
|
+
// in monospace. Skill names in the senpi-skills repo are
|
|
780
|
+
// kebab-case identifiers (no backtick chars), so no escaping is
|
|
781
|
+
// needed here in practice.
|
|
782
|
+
`Run: \`openclaw senpi skills update ${skillName}\`\n\n` +
|
|
783
|
+
// Yes/no CTA — operator-friendly framing, intentionally short.
|
|
784
|
+
// Replies are routed to the agent layer, not to this coordinator.
|
|
785
|
+
`Want help with the upgrade? Reply yes or no.`);
|
|
633
786
|
skillState.lastNotifiedMajorVersion = remoteVersion;
|
|
634
787
|
this.options.logger.info(`[senpi-skills-manager] handleMajorAvailable — major-update notification sent | skill=${skillName} version=${remoteVersion}`);
|
|
635
788
|
}
|
|
@@ -644,21 +797,39 @@ export class SkillsUpdateCoordinator {
|
|
|
644
797
|
* so the user can tell which skills still need attention. Sections with no
|
|
645
798
|
* entries are omitted. If neither section has entries, no notification is
|
|
646
799
|
* sent.
|
|
800
|
+
*
|
|
801
|
+
* Format (markdown source — see `formatSkillBlock` for why markdown
|
|
802
|
+
* rather than HTML):
|
|
803
|
+
*
|
|
804
|
+
* Senpi skills update:
|
|
805
|
+
*
|
|
806
|
+
* Updated:
|
|
807
|
+
*
|
|
808
|
+
* **skill-a**
|
|
809
|
+
* • v1.0.0 → v1.0.1
|
|
810
|
+
* • description: …
|
|
811
|
+
*
|
|
812
|
+
* **skill-b**
|
|
813
|
+
* • v2.0.0 → v2.0.1
|
|
814
|
+
* • description: …
|
|
815
|
+
*
|
|
816
|
+
* Failed to update:
|
|
817
|
+
*
|
|
818
|
+
* **skill-c**
|
|
819
|
+
* • v1.0.0 (install failed)
|
|
820
|
+
* • error: clone failed: …
|
|
647
821
|
*/
|
|
648
822
|
async sendUpdateSummary(succeededSkillNames, failedSkills) {
|
|
649
823
|
if (succeededSkillNames.length === 0 && failedSkills.length === 0)
|
|
650
824
|
return;
|
|
651
825
|
const sections = [];
|
|
652
826
|
if (succeededSkillNames.length > 0) {
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
return ` • ${name}${latestVersion ? ` -> v${latestVersion}` : ""}`;
|
|
656
|
-
});
|
|
657
|
-
sections.push(`Updated:\n${successLines.join("\n")}`);
|
|
827
|
+
const blocks = succeededSkillNames.map((name) => this.formatSkillBlock(name, this.latestVersions.get(name) ?? null));
|
|
828
|
+
sections.push(`Updated:\n\n${blocks.join("\n\n")}`);
|
|
658
829
|
}
|
|
659
830
|
if (failedSkills.length > 0) {
|
|
660
|
-
const
|
|
661
|
-
sections.push(`Failed to update:\n${
|
|
831
|
+
const blocks = failedSkills.map(({ name, reason }) => this.formatSkillBlock(name, null, reason));
|
|
832
|
+
sections.push(`Failed to update:\n\n${blocks.join("\n\n")}`);
|
|
662
833
|
}
|
|
663
834
|
try {
|
|
664
835
|
await this.options.notify(`Senpi skills update:\n\n${sections.join("\n\n")}`);
|
|
@@ -679,31 +850,50 @@ export class SkillsUpdateCoordinator {
|
|
|
679
850
|
*/
|
|
680
851
|
resolveCheckFn() {
|
|
681
852
|
return (this.options.deps?.checkSkillVersion ??
|
|
682
|
-
((
|
|
853
|
+
((skillsDirs, skillName, rawBaseUrl, logger) => checkSkillVersion(skillsDirs, skillName, rawBaseUrl, undefined, logger)));
|
|
683
854
|
}
|
|
684
855
|
/**
|
|
685
856
|
* Resolve the list of skill names to check this tick.
|
|
686
857
|
* Uses `config.skills` when non-empty; otherwise discovers all immediate
|
|
687
|
-
* subdirectories
|
|
858
|
+
* subdirectories across every dir in the `skillsDir` search path,
|
|
859
|
+
* deduped by name and sorted.
|
|
860
|
+
*
|
|
861
|
+
* Per-dir read errors (other than ENOENT) are logged but do not abort the
|
|
862
|
+
* scan — a misconfigured secondary dir cannot mask skills present in the
|
|
863
|
+
* primary.
|
|
688
864
|
*/
|
|
689
865
|
async resolveSkillNames() {
|
|
690
866
|
if (this.options.config.skills.length > 0) {
|
|
691
867
|
return this.options.config.skills;
|
|
692
868
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
869
|
+
const seen = new Set();
|
|
870
|
+
let anySucceeded = false;
|
|
871
|
+
for (const dir of this.options.config.skillsDir) {
|
|
872
|
+
try {
|
|
873
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
874
|
+
anySucceeded = true;
|
|
875
|
+
for (const entry of entries) {
|
|
876
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
877
|
+
seen.add(entry.name);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
catch (err) {
|
|
882
|
+
// ENOENT for a dir that hasn't been created yet is benign — only
|
|
883
|
+
// log the noisier failure modes so operators can diagnose them.
|
|
884
|
+
if (err.code !== "ENOENT") {
|
|
885
|
+
this.options.logger.warn(`[senpi-skills-manager] resolveSkillNames — failed to read skillsDir, continuing | dir=${dir} err=${formatErr(err)}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
699
888
|
}
|
|
700
|
-
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
|
|
889
|
+
if (!anySucceeded && seen.size === 0) {
|
|
890
|
+
// Every configured dir failed to read (or none exist). Previously the
|
|
891
|
+
// single-dir version warned in this case; preserve that signal so
|
|
892
|
+
// "no skills checked" is still distinguishable from "skillsDir
|
|
893
|
+
// configured wrong" in the logs.
|
|
894
|
+
this.options.logger.warn(`[senpi-skills-manager] resolveSkillNames — no readable skills dirs | dirs=${this.options.config.skillsDir.join(",")}`);
|
|
706
895
|
}
|
|
896
|
+
return Array.from(seen).sort();
|
|
707
897
|
}
|
|
708
898
|
/**
|
|
709
899
|
* Build the point-in-time snapshot returned by {@link getSnapshot}.
|
|
@@ -718,9 +908,6 @@ export class SkillsUpdateCoordinator {
|
|
|
718
908
|
* 3. Caller-supplied lifecycle metadata — `health`, `lastAction`,
|
|
719
909
|
* `lastError` describing the most recent tick / manual RPC outcome.
|
|
720
910
|
*
|
|
721
|
-
* `nextCheckAt` is preserved from the previous snapshot so callers always
|
|
722
|
-
* see a valid ETA even on transient rebuilds (e.g. mid-tick error paths).
|
|
723
|
-
*
|
|
724
911
|
* This is called at every state-mutating boundary (start, tick complete,
|
|
725
912
|
* tick error, manual update complete, manual update skipped, manual update
|
|
726
913
|
* clone failed, stop/disable) so the next RPC response reflects the latest
|
|
@@ -737,12 +924,14 @@ export class SkillsUpdateCoordinator {
|
|
|
737
924
|
}));
|
|
738
925
|
return {
|
|
739
926
|
enabled: this.options.config.enabled,
|
|
740
|
-
|
|
927
|
+
// Snapshot is a serializable view consumed by gateway RPCs and the CLI;
|
|
928
|
+
// hand callers a defensive copy so their downstream mutations (e.g.
|
|
929
|
+
// sorting, push for display) can't poison the live config.
|
|
930
|
+
skillsDir: [...this.options.config.skillsDir],
|
|
741
931
|
health,
|
|
742
932
|
lastAction,
|
|
743
933
|
lastError,
|
|
744
934
|
skills: skillSnapshots,
|
|
745
|
-
nextCheckAt: this.snapshot?.nextCheckAt ?? null,
|
|
746
935
|
};
|
|
747
936
|
}
|
|
748
937
|
async persistState() {
|
|
@@ -754,4 +943,18 @@ export class SkillsUpdateCoordinator {
|
|
|
754
943
|
}
|
|
755
944
|
}
|
|
756
945
|
}
|
|
946
|
+
// `escapeHtml` was removed alongside the switch from HTML to markdown
|
|
947
|
+
// notification source — see `formatSkillBlock` for why. The openclaw
|
|
948
|
+
// Telegram channel runs `markdownToTelegramHtml` over our text and
|
|
949
|
+
// handles HTML escaping itself on the converter's output. Pre-escaping
|
|
950
|
+
// at this layer would double-escape (e.g. `&` becomes `&amp;`)
|
|
951
|
+
// and corrupt the rendered output.
|
|
952
|
+
//
|
|
953
|
+
// Markdown-special characters (`*`, `_`, `[`, `]`, backtick) in dynamic
|
|
954
|
+
// values aren't escaped either — descriptions that legitimately contain
|
|
955
|
+
// these would author them as markdown intentionally; skill names in the
|
|
956
|
+
// senpi-skills repo are kebab-case identifiers; error messages from
|
|
957
|
+
// `formatErr` are typically prose. If a future skill description ever
|
|
958
|
+
// breaks the rendering, the fix is to author the SKILL.md frontmatter
|
|
959
|
+
// using proper markdown.
|
|
757
960
|
//# sourceMappingURL=coordinator.js.map
|