@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.
Files changed (41) hide show
  1. package/dist/index.d.ts +18 -6
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +0 -3
  4. package/dist/index.js.map +1 -1
  5. package/dist/skills-manager/bootstrap.d.ts +12 -7
  6. package/dist/skills-manager/bootstrap.d.ts.map +1 -1
  7. package/dist/skills-manager/bootstrap.js +44 -22
  8. package/dist/skills-manager/bootstrap.js.map +1 -1
  9. package/dist/skills-manager/constants.d.ts +5 -33
  10. package/dist/skills-manager/constants.d.ts.map +1 -1
  11. package/dist/skills-manager/constants.js +11 -50
  12. package/dist/skills-manager/constants.js.map +1 -1
  13. package/dist/skills-manager/coordinator.d.ts +26 -119
  14. package/dist/skills-manager/coordinator.d.ts.map +1 -1
  15. package/dist/skills-manager/coordinator.js +107 -344
  16. package/dist/skills-manager/coordinator.js.map +1 -1
  17. package/dist/skills-manager/fetcher.d.ts +23 -2
  18. package/dist/skills-manager/fetcher.d.ts.map +1 -1
  19. package/dist/skills-manager/fetcher.js +36 -6
  20. package/dist/skills-manager/fetcher.js.map +1 -1
  21. package/dist/skills-manager/manifest.d.ts +136 -0
  22. package/dist/skills-manager/manifest.d.ts.map +1 -0
  23. package/dist/skills-manager/manifest.js +177 -0
  24. package/dist/skills-manager/manifest.js.map +1 -0
  25. package/dist/skills-manager/skills-manifest.json +16 -0
  26. package/dist/skills-manager/types.d.ts +49 -30
  27. package/dist/skills-manager/types.d.ts.map +1 -1
  28. package/dist/skills-manager/utils.d.ts +10 -26
  29. package/dist/skills-manager/utils.d.ts.map +1 -1
  30. package/dist/skills-manager/utils.js +18 -34
  31. package/dist/skills-manager/utils.js.map +1 -1
  32. package/dist/skills-manager/version-manager.d.ts +21 -91
  33. package/dist/skills-manager/version-manager.d.ts.map +1 -1
  34. package/dist/skills-manager/version-manager.js +48 -178
  35. package/dist/skills-manager/version-manager.js.map +1 -1
  36. package/dist/skills-manager/writer.d.ts +12 -1
  37. package/dist/skills-manager/writer.d.ts.map +1 -1
  38. package/dist/skills-manager/writer.js +50 -4
  39. package/dist/skills-manager/writer.js.map +1 -1
  40. package/openclaw.plugin.json +17 -6
  41. 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: 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.
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
- * 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.
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
- skillDescriptions = new Map();
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=${config.skills.join(",") || "(discover)"} cadence=external (driven by auto-update)`);
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, SKILLS_RAW_BASE_URL, logger);
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). Local description is cached alongside so
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 = await this.resolveSkillNames();
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 versionInfo = await checkVersionFn(config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
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 = await this.resolveSkillNames();
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 versionInfo = await checkVersionFn(config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
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 — notify the user and let them decide.
491
- await this.handleMajorAvailable(skillState, skillName, remoteVersion);
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
- * The update summary notification distinguishes successful installs from
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 or
512
- * sending a summary — a full-clone failure is almost always a transient
513
- * network/git issue that affects every skill equally, so attributing it
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
- const succeededSkillNames = [];
528
- const failedSkills = [];
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
- succeededSkillNames.push(skillName);
501
+ succeeded += 1;
533
502
  logger.info(`[senpi-skills-manager] applyUpdates — installed | skill=${skillName} version=${outcome.version ?? "unknown"}`);
534
503
  }
535
504
  else {
536
- failedSkills.push({ name: skillName, reason: outcome.error });
505
+ failed += 1;
537
506
  }
538
507
  }
539
- logger.info(`[senpi-skills-manager] applyUpdates complete | installed=${succeededSkillNames.length} failed=${failedSkills.length}`);
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
- logger.info(`[senpi-skills-manager] cloneAndInstallMany — cloning repo | url=${SKILLS_REPO_URL} timeoutMs=120000`);
577
- const { cloneDir, cleanup } = await cloneSkillsRepo(SKILLS_REPO_URL, 120_000, logger);
578
- logger.info(`[senpi-skills-manager] cloneAndInstallMany — clone ready, installing | count=${skillNames.length} cloneDir=${cloneDir}`);
579
- try {
580
- for (const skillName of skillNames) {
581
- try {
582
- // Writes always target the *primary* dir (first entry in the
583
- // search path). Secondary dirs are read-only mirrors — we'd never
584
- // overwrite them on a tick or manual update.
585
- await installSkill(cloneDir, skillName, config.skillsDir[0], logger);
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}`);
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
- finally {
598
- await cleanup();
599
- logger.info("[senpi-skills-manager] cloneAndInstallMany — clone cleanup done");
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 the list of skill names to check this tick.
930
- * Uses `config.skills` when non-empty; otherwise discovers all immediate
931
- * subdirectories across every dir in the `skillsDir` search path,
932
- * deduped by name and sorted.
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
- async resolveSkillNames() {
939
- if (this.options.config.skills.length > 0) {
940
- return this.options.config.skills;
941
- }
942
- const seen = new Set();
943
- let anySucceeded = false;
944
- for (const dir of this.options.config.skillsDir) {
945
- try {
946
- const entries = await readdir(dir, { withFileTypes: true });
947
- anySucceeded = true;
948
- for (const entry of entries) {
949
- if (entry.isDirectory() && !entry.name.startsWith(".")) {
950
- seen.add(entry.name);
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. `&amp;` becomes `&amp;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