@senpi/runtime 3.0.6-dev.runtime-phase-2.20260627082846 → 3.0.6-dev.skill-manager-v2.20260629194505
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +18 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -3
- package/dist/index.js.map +1 -1
- package/dist/skills-manager/bootstrap.d.ts +12 -7
- package/dist/skills-manager/bootstrap.d.ts.map +1 -1
- package/dist/skills-manager/bootstrap.js +44 -22
- package/dist/skills-manager/bootstrap.js.map +1 -1
- package/dist/skills-manager/constants.d.ts +5 -33
- package/dist/skills-manager/constants.d.ts.map +1 -1
- package/dist/skills-manager/constants.js +11 -50
- package/dist/skills-manager/constants.js.map +1 -1
- package/dist/skills-manager/coordinator.d.ts +26 -119
- package/dist/skills-manager/coordinator.d.ts.map +1 -1
- package/dist/skills-manager/coordinator.js +107 -344
- package/dist/skills-manager/coordinator.js.map +1 -1
- package/dist/skills-manager/fetcher.d.ts +23 -2
- package/dist/skills-manager/fetcher.d.ts.map +1 -1
- package/dist/skills-manager/fetcher.js +36 -6
- package/dist/skills-manager/fetcher.js.map +1 -1
- package/dist/skills-manager/manifest.d.ts +136 -0
- package/dist/skills-manager/manifest.d.ts.map +1 -0
- package/dist/skills-manager/manifest.js +177 -0
- package/dist/skills-manager/manifest.js.map +1 -0
- package/dist/skills-manager/skills-manifest.json +16 -0
- package/dist/skills-manager/types.d.ts +49 -30
- package/dist/skills-manager/types.d.ts.map +1 -1
- package/dist/skills-manager/utils.d.ts +10 -26
- package/dist/skills-manager/utils.d.ts.map +1 -1
- package/dist/skills-manager/utils.js +18 -34
- package/dist/skills-manager/utils.js.map +1 -1
- package/dist/skills-manager/version-manager.d.ts +21 -91
- package/dist/skills-manager/version-manager.d.ts.map +1 -1
- package/dist/skills-manager/version-manager.js +48 -178
- package/dist/skills-manager/version-manager.js.map +1 -1
- package/dist/skills-manager/writer.d.ts +12 -1
- package/dist/skills-manager/writer.d.ts.map +1 -1
- package/dist/skills-manager/writer.js +50 -4
- package/dist/skills-manager/writer.js.map +1 -1
- package/openclaw.plugin.json +17 -6
- package/package.json +3 -2
|
@@ -16,17 +16,17 @@
|
|
|
16
16
|
* health as healthy. The first tick fires on the next external invocation
|
|
17
17
|
* of `runTickOnce()`.
|
|
18
18
|
*
|
|
19
|
-
* Each tick:
|
|
20
|
-
*
|
|
21
|
-
* (deduped by version),
|
|
19
|
+
* Each tick: take the managed skill set from the manifest
|
|
20
|
+
* (`skills-manifest.json`, the single source of truth), check remote versions
|
|
21
|
+
* in parallel, auto-apply patch/minor, notify on major (deduped by version),
|
|
22
|
+
* skip on none/unknown.
|
|
22
23
|
*/
|
|
23
|
-
import { readdir } from "node:fs/promises";
|
|
24
24
|
import { bootstrapSkillsIfNeeded } from "./bootstrap.js";
|
|
25
25
|
import { cloneSkillsRepo } from "./fetcher.js";
|
|
26
|
+
import { getManifestSkillNames, resolveSkillSource } from "./manifest.js";
|
|
26
27
|
import { SkillsManagerStateStore } from "./state-store.js";
|
|
27
28
|
import { checkSkillVersion } from "./version-manager.js";
|
|
28
29
|
import { installSkill } from "./writer.js";
|
|
29
|
-
import { NOTIFICATION_SKILL_LIST_TOP_N, SKILLS_RAW_BASE_URL, SKILLS_REPO_URL, TELEGRAM_NOTIFICATION_BUDGET_CHARS, } from "./constants.js";
|
|
30
30
|
import { createDefaultSkillState, createDefaultSkillsManagerState, formatErr } from "./utils.js";
|
|
31
31
|
export class SkillsCoordinatorUnavailableError extends Error {
|
|
32
32
|
constructor(message) {
|
|
@@ -42,38 +42,11 @@ export class SkillsUpdateCoordinator {
|
|
|
42
42
|
runToken = 0;
|
|
43
43
|
state = createDefaultSkillsManagerState();
|
|
44
44
|
snapshot;
|
|
45
|
-
/** Latest remote version per skill name — populated during ticks. */
|
|
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
45
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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.
|
|
46
|
+
* Latest remote version per skill name — populated during ticks. Read by
|
|
47
|
+
* `buildSnapshot` (status RPC) and used as the install `expectedVersion`.
|
|
75
48
|
*/
|
|
76
|
-
|
|
49
|
+
latestVersions = new Map();
|
|
77
50
|
constructor(options) {
|
|
78
51
|
this.options = options;
|
|
79
52
|
this.store = new SkillsManagerStateStore(options.stateDir, options.config.skillsDir, options.logger);
|
|
@@ -99,7 +72,7 @@ export class SkillsUpdateCoordinator {
|
|
|
99
72
|
logger.info("[senpi-skills-manager] disabled by config");
|
|
100
73
|
return;
|
|
101
74
|
}
|
|
102
|
-
logger.info(`[senpi-skills-manager] starting | skillsDir=${config.skillsDir.join(",")} skills=${
|
|
75
|
+
logger.info(`[senpi-skills-manager] starting | skillsDir=${config.skillsDir.join(",")} skills=${this.resolveSkillNames().join(",")} (manifest) cadence=external (driven by auto-update)`);
|
|
103
76
|
this.state = await this.store.load();
|
|
104
77
|
const bootstrapFn = this.options.deps?.bootstrapSkillsIfNeeded ?? bootstrapSkillsIfNeeded;
|
|
105
78
|
try {
|
|
@@ -196,9 +169,10 @@ export class SkillsUpdateCoordinator {
|
|
|
196
169
|
const { logger } = this.options;
|
|
197
170
|
logger.info(`[senpi-skills-manager] updateSkill invoked | skill=${skillName}`);
|
|
198
171
|
const checkVersionFn = this.resolveCheckFn();
|
|
172
|
+
const source = this.resolveSource(skillName);
|
|
199
173
|
let versionInfo;
|
|
200
174
|
try {
|
|
201
|
-
versionInfo = await checkVersionFn(this.options.config.skillsDir, skillName,
|
|
175
|
+
versionInfo = await checkVersionFn(this.options.config.skillsDir, skillName, source.rawBaseUrl, logger, source.path);
|
|
202
176
|
}
|
|
203
177
|
catch (err) {
|
|
204
178
|
// Version check threw (5xx, network error, timeout, body-parse failure
|
|
@@ -215,11 +189,8 @@ export class SkillsUpdateCoordinator {
|
|
|
215
189
|
}
|
|
216
190
|
// Record the remote version we just learned so the snapshot's latestVersion
|
|
217
191
|
// field reflects it on the next getSnapshot() call — even when no install
|
|
218
|
-
// happens (skip paths below).
|
|
219
|
-
// notifications (sendUpdateSummary / handleMajorAvailable) can include it.
|
|
192
|
+
// happens (skip paths below).
|
|
220
193
|
this.latestVersions.set(skillName, versionInfo.remoteVersion);
|
|
221
|
-
this.previousVersions.set(skillName, versionInfo.localVersion);
|
|
222
|
-
this.skillDescriptions.set(skillName, versionInfo.remoteDescription ?? null);
|
|
223
194
|
// Mirror runTick: every skill whose version we just checked gets its
|
|
224
195
|
// `lastCheckedAt` updated (and `installedVersion` refreshed from the local
|
|
225
196
|
// read) regardless of whether it proceeds to install. Without this, skills
|
|
@@ -287,7 +258,7 @@ export class SkillsUpdateCoordinator {
|
|
|
287
258
|
this.assertManualOperationAllowed("updateAll", token);
|
|
288
259
|
const { logger, config } = this.options;
|
|
289
260
|
logger.info("[senpi-skills-manager] updateAll invoked");
|
|
290
|
-
const skillNames =
|
|
261
|
+
const skillNames = this.resolveSkillNames();
|
|
291
262
|
if (skillNames.length === 0) {
|
|
292
263
|
logger.info("[senpi-skills-manager] updateAll — no skills to check");
|
|
293
264
|
return [];
|
|
@@ -296,10 +267,9 @@ export class SkillsUpdateCoordinator {
|
|
|
296
267
|
const checkVersionFn = this.resolveCheckFn();
|
|
297
268
|
// Check every skill's version in parallel — one rejection does not abort others.
|
|
298
269
|
const versionChecks = await Promise.allSettled(skillNames.map(async (skillName) => {
|
|
299
|
-
const
|
|
270
|
+
const source = this.resolveSource(skillName);
|
|
271
|
+
const versionInfo = await checkVersionFn(config.skillsDir, skillName, source.rawBaseUrl, logger, source.path);
|
|
300
272
|
this.latestVersions.set(skillName, versionInfo.remoteVersion);
|
|
301
|
-
this.previousVersions.set(skillName, versionInfo.localVersion);
|
|
302
|
-
this.skillDescriptions.set(skillName, versionInfo.remoteDescription ?? null);
|
|
303
273
|
return { name: skillName, ...versionInfo };
|
|
304
274
|
}));
|
|
305
275
|
const results = [];
|
|
@@ -440,7 +410,7 @@ export class SkillsUpdateCoordinator {
|
|
|
440
410
|
*/
|
|
441
411
|
async runTick() {
|
|
442
412
|
const { logger, config } = this.options;
|
|
443
|
-
const skillNames =
|
|
413
|
+
const skillNames = this.resolveSkillNames();
|
|
444
414
|
if (skillNames.length === 0) {
|
|
445
415
|
logger.info("[senpi-skills-manager] tick — no skills to check");
|
|
446
416
|
return;
|
|
@@ -449,10 +419,9 @@ export class SkillsUpdateCoordinator {
|
|
|
449
419
|
const checkVersionFn = this.resolveCheckFn();
|
|
450
420
|
// Check all versions in parallel — per-skill errors are captured, not thrown.
|
|
451
421
|
const versionChecks = await Promise.allSettled(skillNames.map(async (skillName) => {
|
|
452
|
-
const
|
|
422
|
+
const source = this.resolveSource(skillName);
|
|
423
|
+
const versionInfo = await checkVersionFn(config.skillsDir, skillName, source.rawBaseUrl, logger, source.path);
|
|
453
424
|
this.latestVersions.set(skillName, versionInfo.remoteVersion);
|
|
454
|
-
this.previousVersions.set(skillName, versionInfo.localVersion);
|
|
455
|
-
this.skillDescriptions.set(skillName, versionInfo.remoteDescription ?? null);
|
|
456
425
|
return { name: skillName, ...versionInfo };
|
|
457
426
|
}));
|
|
458
427
|
const skillsToInstall = [];
|
|
@@ -487,8 +456,10 @@ export class SkillsUpdateCoordinator {
|
|
|
487
456
|
continue;
|
|
488
457
|
}
|
|
489
458
|
if (updateKind === "major") {
|
|
490
|
-
// Major versions are never auto-applied
|
|
491
|
-
|
|
459
|
+
// Major versions are never auto-applied. The newer `latestVersion` is
|
|
460
|
+
// recorded in state and surfaced via `senpi skills status`; the operator
|
|
461
|
+
// updates manually with `senpi skills update <name>`. No notification.
|
|
462
|
+
logger.info(`[senpi-skills-manager] tick — major available, not auto-applied | skill=${skillName} local=${localVersion ?? "none"} remote=${remoteVersion ?? "unknown"}`);
|
|
492
463
|
this.state.skills[skillName] = skillState;
|
|
493
464
|
majorPendingCount += 1;
|
|
494
465
|
continue;
|
|
@@ -504,14 +475,12 @@ export class SkillsUpdateCoordinator {
|
|
|
504
475
|
}
|
|
505
476
|
/**
|
|
506
477
|
* Clone the repo once and install every skill in `skillNames`.
|
|
507
|
-
* Per-skill errors are recorded in state without aborting the others
|
|
508
|
-
*
|
|
509
|
-
* failures so the user can see exactly which skills need attention.
|
|
478
|
+
* Per-skill errors are recorded in state without aborting the others;
|
|
479
|
+
* outcomes are logged (no notification).
|
|
510
480
|
*
|
|
511
|
-
* If the clone itself fails we log and return without touching state
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
* per-skill would be misleading. The next tick retries.
|
|
481
|
+
* If the clone itself fails we log and return without touching state — a
|
|
482
|
+
* full-clone failure is almost always a transient network/git issue that
|
|
483
|
+
* affects every skill equally. The next tick retries.
|
|
515
484
|
*/
|
|
516
485
|
async applyUpdates(skillNames) {
|
|
517
486
|
const { logger } = this.options;
|
|
@@ -524,20 +493,19 @@ export class SkillsUpdateCoordinator {
|
|
|
524
493
|
logger.warn(`[senpi-skills-manager] applyUpdates — git clone failed, skipping batch: ${formatErr(err)}`);
|
|
525
494
|
return;
|
|
526
495
|
}
|
|
527
|
-
|
|
528
|
-
|
|
496
|
+
let succeeded = 0;
|
|
497
|
+
let failed = 0;
|
|
529
498
|
for (const skillName of skillNames) {
|
|
530
499
|
const outcome = this.processInstallOutcome(skillName, installOutcomes);
|
|
531
500
|
if (outcome.success) {
|
|
532
|
-
|
|
501
|
+
succeeded += 1;
|
|
533
502
|
logger.info(`[senpi-skills-manager] applyUpdates — installed | skill=${skillName} version=${outcome.version ?? "unknown"}`);
|
|
534
503
|
}
|
|
535
504
|
else {
|
|
536
|
-
|
|
505
|
+
failed += 1;
|
|
537
506
|
}
|
|
538
507
|
}
|
|
539
|
-
logger.info(`[senpi-skills-manager] applyUpdates complete | installed=${
|
|
540
|
-
await this.sendUpdateSummary(succeededSkillNames, failedSkills);
|
|
508
|
+
logger.info(`[senpi-skills-manager] applyUpdates complete | installed=${succeeded} failed=${failed}`);
|
|
541
509
|
}
|
|
542
510
|
/**
|
|
543
511
|
* Clone the remote skills repo exactly once and install every skill in
|
|
@@ -573,30 +541,63 @@ export class SkillsUpdateCoordinator {
|
|
|
573
541
|
}
|
|
574
542
|
return outcomes;
|
|
575
543
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
544
|
+
// Group skills by their resolved (repo, branch, commit) so each distinct
|
|
545
|
+
// source is cloned exactly once. For the common monorepo-on-one-branch case
|
|
546
|
+
// this is a single clone; a skill pinned to a different repo/branch/commit
|
|
547
|
+
// (manifest entry, override, or env knob) gets its own checkout.
|
|
548
|
+
const groups = new Map();
|
|
549
|
+
for (const skillName of skillNames) {
|
|
550
|
+
const source = this.resolveSource(skillName);
|
|
551
|
+
const key = `${source.repo}\n${source.branch}\n${source.commit ?? ""}`;
|
|
552
|
+
const bucket = groups.get(key);
|
|
553
|
+
if (bucket)
|
|
554
|
+
bucket.push(source);
|
|
555
|
+
else
|
|
556
|
+
groups.set(key, [source]);
|
|
557
|
+
}
|
|
558
|
+
// Allowlist for this batch = exactly the skills we resolved, so an install
|
|
559
|
+
// can never write a directory outside the managed set.
|
|
560
|
+
const allowedSkills = skillNames;
|
|
561
|
+
for (const sources of groups.values()) {
|
|
562
|
+
const { repo, branch, commit } = sources[0];
|
|
563
|
+
logger.info(`[senpi-skills-manager] cloneAndInstallMany — cloning repo | url=${repo} branch=${branch}${commit ? ` commit=${commit}` : ""} skills=${sources.map((s) => s.name).join(",")} timeoutMs=120000`);
|
|
564
|
+
const { cloneDir, cleanup } = await cloneSkillsRepo({
|
|
565
|
+
repoUrl: repo,
|
|
566
|
+
branch,
|
|
567
|
+
commit: commit ?? undefined,
|
|
568
|
+
timeoutMs: 120_000,
|
|
569
|
+
logger,
|
|
570
|
+
});
|
|
571
|
+
logger.info(`[senpi-skills-manager] cloneAndInstallMany — clone ready, installing | count=${sources.length} cloneDir=${cloneDir}`);
|
|
572
|
+
try {
|
|
573
|
+
for (const source of sources) {
|
|
574
|
+
const skillName = source.name;
|
|
575
|
+
try {
|
|
576
|
+
// Writes always target the *primary* dir (first entry in the
|
|
577
|
+
// search path). Secondary dirs are read-only mirrors — we'd never
|
|
578
|
+
// overwrite them on a tick or manual update.
|
|
579
|
+
await installSkill(cloneDir, skillName, config.skillsDir[0], logger, {
|
|
580
|
+
sourcePath: source.path,
|
|
581
|
+
allowedSkills,
|
|
582
|
+
// Verify the freshly-installed SKILL.md matches the version the
|
|
583
|
+
// check decided to install — rolls back on mismatch.
|
|
584
|
+
expectedVersion: this.latestVersions.get(skillName) ?? null,
|
|
585
|
+
});
|
|
586
|
+
const newVersion = this.latestVersions.get(skillName) ?? null;
|
|
587
|
+
outcomes.set(skillName, { version: newVersion });
|
|
588
|
+
logger.info(`[senpi-skills-manager] cloneAndInstallMany — installed | skill=${skillName} version=${newVersion ?? "unknown"}`);
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
const errorMessage = formatErr(err);
|
|
592
|
+
outcomes.set(skillName, { version: null, error: errorMessage });
|
|
593
|
+
logger.warn(`[senpi-skills-manager] cloneAndInstallMany — install failed | skill=${skillName} err=${errorMessage}`);
|
|
594
|
+
}
|
|
594
595
|
}
|
|
595
596
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
597
|
+
finally {
|
|
598
|
+
await cleanup();
|
|
599
|
+
logger.info("[senpi-skills-manager] cloneAndInstallMany — clone cleanup done");
|
|
600
|
+
}
|
|
600
601
|
}
|
|
601
602
|
return outcomes;
|
|
602
603
|
}
|
|
@@ -622,7 +623,6 @@ export class SkillsUpdateCoordinator {
|
|
|
622
623
|
*/
|
|
623
624
|
recordSuccess(skillState, newVersion) {
|
|
624
625
|
skillState.installedVersion = newVersion;
|
|
625
|
-
skillState.lastAppliedVersion = newVersion;
|
|
626
626
|
skillState.consecutiveFailures = 0;
|
|
627
627
|
skillState.lastFailureAt = null;
|
|
628
628
|
skillState.lastFailureReason = null;
|
|
@@ -710,208 +710,6 @@ export class SkillsUpdateCoordinator {
|
|
|
710
710
|
logger.info(`[senpi-skills-manager] applyUpdate complete | skill=${skillName} version=${installedVersion ?? "unknown"}`);
|
|
711
711
|
return installedVersion;
|
|
712
712
|
}
|
|
713
|
-
/**
|
|
714
|
-
* Format the per-skill nested-bullet block used in both notification
|
|
715
|
-
* shapes. Renders the structure suggested in PR #78 review feedback:
|
|
716
|
-
*
|
|
717
|
-
* **skill-name**
|
|
718
|
-
* • v1.0.0 → v2.0.0 (or "v1.0.0 (install failed)" when failed)
|
|
719
|
-
* • description: … (omitted when no remote description)
|
|
720
|
-
* • error: … (only on failure, replaces the description)
|
|
721
|
-
*
|
|
722
|
-
* **Why markdown, not HTML?**
|
|
723
|
-
* Openclaw's Telegram channel (`src/telegram/send.ts:sendMessageTelegram`)
|
|
724
|
-
* pipes outbound text through `renderTelegramHtmlText`, which defaults
|
|
725
|
-
* `textMode` to `"markdown"` — i.e. it expects the source to be markdown
|
|
726
|
-
* and runs `markdownToTelegramHtml` over it before handing the converted
|
|
727
|
-
* HTML to the Bot API with `parse_mode: "HTML"`. The `message` tool
|
|
728
|
-
* (which is what this coordinator's `notify` callback ultimately hits via
|
|
729
|
-
* `/tools/invoke`) does not expose `textMode`, so HTML written by us is
|
|
730
|
-
* treated as literal characters by the markdown step and rendered as
|
|
731
|
-
* visible `<b>` text in the chat (the actual production bug this docstring
|
|
732
|
-
* fixes — confirmed against a live notification screenshot).
|
|
733
|
-
*
|
|
734
|
-
* Using markdown source (`**bold**`, backticks) instead means the channel's
|
|
735
|
-
* markdown→HTML converter produces the correct `<b>` / `<code>` HTML
|
|
736
|
-
* payload and Telegram renders the formatting properly.
|
|
737
|
-
*
|
|
738
|
-
* @param skillName Skill identifier (rendered bold via `**name**`).
|
|
739
|
-
* @param toVersion Resolved upgraded-to version. `null` for failure rows
|
|
740
|
-
* where no install completed; renders as
|
|
741
|
-
* "vPREV (install failed)".
|
|
742
|
-
* @param errorReason Optional failure reason. When provided, replaces
|
|
743
|
-
* the description line with `error: …`.
|
|
744
|
-
*/
|
|
745
|
-
formatSkillBlock(skillName, toVersion, errorReason = null, options = {}) {
|
|
746
|
-
const prev = this.previousVersions.get(skillName) ?? null;
|
|
747
|
-
const versionLine = errorReason !== null
|
|
748
|
-
? ` • ${prev !== null ? `v${prev} ` : ""}(install failed)`
|
|
749
|
-
: ` • ${prev !== null ? `v${prev} → ` : ""}${toVersion !== null ? `v${toVersion}` : "(unknown)"}`;
|
|
750
|
-
// `omitDescription` is set when the caller is rebuilding a notification
|
|
751
|
-
// in compact mode after the verbose form exceeded Telegram's per-message
|
|
752
|
-
// byte budget (see `sendUpdateSummary`'s three-tier sizing). The error
|
|
753
|
-
// line for failures is NEVER omitted — it's the actionable bit and a
|
|
754
|
-
// single line, not the source of the overflow.
|
|
755
|
-
const description = options.omitDescription
|
|
756
|
-
? null
|
|
757
|
-
: (this.skillDescriptions.get(skillName) ?? null);
|
|
758
|
-
const trailingLine = errorReason !== null
|
|
759
|
-
? ` • error: ${errorReason}`
|
|
760
|
-
: description
|
|
761
|
-
? ` • description: ${description}`
|
|
762
|
-
: null;
|
|
763
|
-
// Skill name line: bold only, no leading bullet — the bold acts as the
|
|
764
|
-
// visual anchor for the block, and the nested bullets below already
|
|
765
|
-
// convey "these belong to this skill". A leading `•` on the heading
|
|
766
|
-
// looked redundant in production.
|
|
767
|
-
const lines = [`**${skillName}**`, versionLine];
|
|
768
|
-
if (trailingLine)
|
|
769
|
-
lines.push(trailingLine);
|
|
770
|
-
return lines.join("\n");
|
|
771
|
-
}
|
|
772
|
-
/**
|
|
773
|
-
* Send a one-way Telegram notification for a pending major update, deduped
|
|
774
|
-
* by version.
|
|
775
|
-
*
|
|
776
|
-
* The dedup key (`lastNotifiedMajorVersion`) is only set after the notify
|
|
777
|
-
* call resolves successfully. If the notification fails (Telegram API down,
|
|
778
|
-
* transient network error, etc.) the key is left unchanged so the next tick
|
|
779
|
-
* retries delivery instead of silently suppressing it forever.
|
|
780
|
-
*/
|
|
781
|
-
async handleMajorAvailable(skillState, skillName, remoteVersion) {
|
|
782
|
-
if (skillState.lastNotifiedMajorVersion === remoteVersion) {
|
|
783
|
-
this.options.logger.info(`[senpi-skills-manager] handleMajorAvailable — notification already sent, skipping | skill=${skillName} version=${remoteVersion}`);
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
try {
|
|
787
|
-
const block = this.formatSkillBlock(skillName, remoteVersion);
|
|
788
|
-
await this.options.notify(`Senpi skills update — major available:\n\n` +
|
|
789
|
-
`${block}\n\n` +
|
|
790
|
-
`Major updates are not applied automatically.\n` +
|
|
791
|
-
// Inline code via markdown backticks — the channel's markdown→HTML
|
|
792
|
-
// step converts these to `<code>` so the install command renders
|
|
793
|
-
// in monospace. Skill names in the senpi-skills repo are
|
|
794
|
-
// kebab-case identifiers (no backtick chars), so no escaping is
|
|
795
|
-
// needed here in practice.
|
|
796
|
-
`Run: \`openclaw senpi skills update ${skillName}\`\n\n` +
|
|
797
|
-
// Yes/no CTA — operator-friendly framing, intentionally short.
|
|
798
|
-
// Replies are routed to the agent layer, not to this coordinator.
|
|
799
|
-
`Want help with the upgrade? Reply yes or no.`);
|
|
800
|
-
skillState.lastNotifiedMajorVersion = remoteVersion;
|
|
801
|
-
this.options.logger.info(`[senpi-skills-manager] handleMajorAvailable — major-update notification sent | skill=${skillName} version=${remoteVersion}`);
|
|
802
|
-
}
|
|
803
|
-
catch (err) {
|
|
804
|
-
this.options.logger.warn(`[senpi-skills-manager] failed to send major-update notification for "${skillName}": ${formatErr(err)}`);
|
|
805
|
-
// Leave dedup key unchanged — retry on next tick.
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
/**
|
|
809
|
-
* Send an aggregated notification summarising the outcome of this tick's
|
|
810
|
-
* updates. Successful installs and failures are shown in separate sections
|
|
811
|
-
* so the user can tell which skills still need attention. Sections with no
|
|
812
|
-
* entries are omitted. If neither section has entries, no notification is
|
|
813
|
-
* sent.
|
|
814
|
-
*
|
|
815
|
-
* Format (markdown source — see `formatSkillBlock` for why markdown
|
|
816
|
-
* rather than HTML):
|
|
817
|
-
*
|
|
818
|
-
* Senpi skills update:
|
|
819
|
-
*
|
|
820
|
-
* Updated:
|
|
821
|
-
*
|
|
822
|
-
* **skill-a**
|
|
823
|
-
* • v1.0.0 → v1.0.1
|
|
824
|
-
* • description: …
|
|
825
|
-
*
|
|
826
|
-
* **skill-b**
|
|
827
|
-
* • v2.0.0 → v2.0.1
|
|
828
|
-
* • description: …
|
|
829
|
-
*
|
|
830
|
-
* Failed to update:
|
|
831
|
-
*
|
|
832
|
-
* **skill-c**
|
|
833
|
-
* • v1.0.0 (install failed)
|
|
834
|
-
* • error: clone failed: …
|
|
835
|
-
*/
|
|
836
|
-
async sendUpdateSummary(succeededSkillNames, failedSkills) {
|
|
837
|
-
if (succeededSkillNames.length === 0 && failedSkills.length === 0)
|
|
838
|
-
return;
|
|
839
|
-
// ── Three-tier size budget ────────────────────────────────────────────
|
|
840
|
-
//
|
|
841
|
-
// Telegram caps a single message at 4096 chars; if we send more, the
|
|
842
|
-
// Bot API rejects with "MESSAGE_TOO_LONG" and the user sees nothing.
|
|
843
|
-
// Discovery-mode deployments with many skills + long descriptions can
|
|
844
|
-
// realistically blow past that. Lead's review comment
|
|
845
|
-
// (PR #78 #discussion_r3247822298): always render *something* useful,
|
|
846
|
-
// never silently drop the notification.
|
|
847
|
-
//
|
|
848
|
-
// Tier 1 (default — verbose): full per-skill blocks with descriptions.
|
|
849
|
-
// Tier 2 (compact): same blocks but with description lines omitted —
|
|
850
|
-
// keeps version transitions + error reasons, which are the
|
|
851
|
-
// actionable bits.
|
|
852
|
-
// Tier 3 (top-N cap): if even compact mode exceeds the budget, render
|
|
853
|
-
// only the first NOTIFICATION_SKILL_LIST_TOP_N of each section with a
|
|
854
|
-
// "… and N more" tail.
|
|
855
|
-
//
|
|
856
|
-
// The 4000-char default budget (vs Telegram's hard 4096) leaves headroom
|
|
857
|
-
// for the gateway's markdown→HTML conversion to add wrapping tags
|
|
858
|
-
// without pushing the payload over. Both the budget and the per-section
|
|
859
|
-
// cap are env-overridable (see `constants.ts`) so operators with
|
|
860
|
-
// alternative transports or different verbosity preferences can tune
|
|
861
|
-
// them without a code change.
|
|
862
|
-
const BUDGET_CHARS = TELEGRAM_NOTIFICATION_BUDGET_CHARS;
|
|
863
|
-
const TOP_N = NOTIFICATION_SKILL_LIST_TOP_N;
|
|
864
|
-
const build = (opts) => {
|
|
865
|
-
const succeededRendered = opts.cap !== undefined
|
|
866
|
-
? succeededSkillNames.slice(0, opts.cap)
|
|
867
|
-
: succeededSkillNames;
|
|
868
|
-
const failedRendered = opts.cap !== undefined
|
|
869
|
-
? failedSkills.slice(0, opts.cap)
|
|
870
|
-
: failedSkills;
|
|
871
|
-
const sections = [];
|
|
872
|
-
if (succeededSkillNames.length > 0) {
|
|
873
|
-
const blocks = succeededRendered.map((name) => this.formatSkillBlock(name, this.latestVersions.get(name) ?? null, null, {
|
|
874
|
-
omitDescription: opts.omitDescriptions,
|
|
875
|
-
}));
|
|
876
|
-
// Tail when capped: shows the omitted count so operators know the
|
|
877
|
-
// list isn't the complete picture (and can look at logs for full).
|
|
878
|
-
const tail = opts.cap !== undefined && succeededSkillNames.length > opts.cap
|
|
879
|
-
? `\n\n… and ${succeededSkillNames.length - opts.cap} more updated skill${succeededSkillNames.length - opts.cap === 1 ? "" : "s"} not shown`
|
|
880
|
-
: "";
|
|
881
|
-
sections.push(`Updated:\n\n${blocks.join("\n\n")}${tail}`);
|
|
882
|
-
}
|
|
883
|
-
if (failedSkills.length > 0) {
|
|
884
|
-
const blocks = failedRendered.map(({ name, reason }) => this.formatSkillBlock(name, null, reason, {
|
|
885
|
-
omitDescription: opts.omitDescriptions,
|
|
886
|
-
}));
|
|
887
|
-
const tail = opts.cap !== undefined && failedSkills.length > opts.cap
|
|
888
|
-
? `\n\n… and ${failedSkills.length - opts.cap} more failed skill${failedSkills.length - opts.cap === 1 ? "" : "s"} not shown`
|
|
889
|
-
: "";
|
|
890
|
-
sections.push(`Failed to update:\n\n${blocks.join("\n\n")}${tail}`);
|
|
891
|
-
}
|
|
892
|
-
return `Senpi skills update:\n\n${sections.join("\n\n")}`;
|
|
893
|
-
};
|
|
894
|
-
// Tier 1: verbose
|
|
895
|
-
let message = build({ omitDescriptions: false });
|
|
896
|
-
let tier = "verbose";
|
|
897
|
-
if (message.length > BUDGET_CHARS) {
|
|
898
|
-
// Tier 2: drop descriptions
|
|
899
|
-
message = build({ omitDescriptions: true });
|
|
900
|
-
tier = "compact (descriptions omitted)";
|
|
901
|
-
if (message.length > BUDGET_CHARS) {
|
|
902
|
-
// Tier 3: cap each section to top-N
|
|
903
|
-
message = build({ omitDescriptions: true, cap: TOP_N });
|
|
904
|
-
tier = `capped (top ${TOP_N} per section, descriptions omitted)`;
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
try {
|
|
908
|
-
await this.options.notify(message);
|
|
909
|
-
this.options.logger.info(`[senpi-skills-manager] sendUpdateSummary — notification sent | succeeded=${succeededSkillNames.length} failed=${failedSkills.length} tier=${tier} messageChars=${message.length}`);
|
|
910
|
-
}
|
|
911
|
-
catch (err) {
|
|
912
|
-
this.options.logger.warn(`[senpi-skills-manager] failed to send update summary notification: ${formatErr(err)}`);
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
713
|
/**
|
|
916
714
|
* Return the version-check function for this coordinator: either the
|
|
917
715
|
* injected `deps.checkSkillVersion` (unit tests) or the real
|
|
@@ -923,50 +721,29 @@ export class SkillsUpdateCoordinator {
|
|
|
923
721
|
*/
|
|
924
722
|
resolveCheckFn() {
|
|
925
723
|
return (this.options.deps?.checkSkillVersion ??
|
|
926
|
-
((skillsDirs, skillName, rawBaseUrl, logger) => checkSkillVersion(skillsDirs, skillName, rawBaseUrl, undefined, logger)));
|
|
724
|
+
((skillsDirs, skillName, rawBaseUrl, logger, remotePath) => checkSkillVersion(skillsDirs, skillName, rawBaseUrl, undefined, logger, remotePath)));
|
|
927
725
|
}
|
|
928
726
|
/**
|
|
929
|
-
* Resolve
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
* Per-dir read errors (other than ENOENT) are logged but do not abort the
|
|
935
|
-
* scan — a misconfigured secondary dir cannot mask skills present in the
|
|
936
|
-
* primary.
|
|
727
|
+
* Resolve a skill name to its concrete source (repo/branch/path/rawBaseUrl)
|
|
728
|
+
* from the manifest, applying any plugin-config per-skill overrides. The
|
|
729
|
+
* `SENPI_SKILLS_BRANCH` / `SENPI_SKILLS_REPO_URL` env testing knobs are
|
|
730
|
+
* applied inside {@link resolveSkillSource}.
|
|
937
731
|
*/
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
}
|
|
954
|
-
catch (err) {
|
|
955
|
-
// ENOENT for a dir that hasn't been created yet is benign — only
|
|
956
|
-
// log the noisier failure modes so operators can diagnose them.
|
|
957
|
-
if (err.code !== "ENOENT") {
|
|
958
|
-
this.options.logger.warn(`[senpi-skills-manager] resolveSkillNames — failed to read skillsDir, continuing | dir=${dir} err=${formatErr(err)}`);
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
if (!anySucceeded && seen.size === 0) {
|
|
963
|
-
// Every configured dir failed to read (or none exist). Previously the
|
|
964
|
-
// single-dir version warned in this case; preserve that signal so
|
|
965
|
-
// "no skills checked" is still distinguishable from "skillsDir
|
|
966
|
-
// configured wrong" in the logs.
|
|
967
|
-
this.options.logger.warn(`[senpi-skills-manager] resolveSkillNames — no readable skills dirs | dirs=${this.options.config.skillsDir.join(",")}`);
|
|
968
|
-
}
|
|
969
|
-
return Array.from(seen).sort();
|
|
732
|
+
resolveSource(skillName) {
|
|
733
|
+
return resolveSkillSource(skillName, { overrides: this.options.config.overrides });
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* The managed skill set for this tick — **always the manifest** set
|
|
737
|
+
* (`skills-manifest.json`), the single source of truth. `config.skills` and
|
|
738
|
+
* directory discovery no longer define the set: the manifest does, full stop
|
|
739
|
+
* (only `enabled: false` disables the manager entirely).
|
|
740
|
+
*
|
|
741
|
+
* `deps.listManagedSkills` is a test-only seam to substitute the set without
|
|
742
|
+
* a fixture manifest on disk; production always falls through to the
|
|
743
|
+
* manifest.
|
|
744
|
+
*/
|
|
745
|
+
resolveSkillNames() {
|
|
746
|
+
return this.options.deps?.listManagedSkills?.() ?? getManifestSkillNames();
|
|
970
747
|
}
|
|
971
748
|
/**
|
|
972
749
|
* Build the point-in-time snapshot returned by {@link getSnapshot}.
|
|
@@ -1020,18 +797,4 @@ export class SkillsUpdateCoordinator {
|
|
|
1020
797
|
}
|
|
1021
798
|
}
|
|
1022
799
|
}
|
|
1023
|
-
// `escapeHtml` was removed alongside the switch from HTML to markdown
|
|
1024
|
-
// notification source — see `formatSkillBlock` for why. The openclaw
|
|
1025
|
-
// Telegram channel runs `markdownToTelegramHtml` over our text and
|
|
1026
|
-
// handles HTML escaping itself on the converter's output. Pre-escaping
|
|
1027
|
-
// at this layer would double-escape (e.g. `&` becomes `&amp;`)
|
|
1028
|
-
// and corrupt the rendered output.
|
|
1029
|
-
//
|
|
1030
|
-
// Markdown-special characters (`*`, `_`, `[`, `]`, backtick) in dynamic
|
|
1031
|
-
// values aren't escaped either — descriptions that legitimately contain
|
|
1032
|
-
// these would author them as markdown intentionally; skill names in the
|
|
1033
|
-
// senpi-skills repo are kebab-case identifiers; error messages from
|
|
1034
|
-
// `formatErr` are typically prose. If a future skill description ever
|
|
1035
|
-
// breaks the rendering, the fix is to author the SKILL.md frontmatter
|
|
1036
|
-
// using proper markdown.
|
|
1037
800
|
//# sourceMappingURL=coordinator.js.map
|