@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.
Files changed (46) hide show
  1. package/dist/cli/senpi-cli-config.d.ts +15 -1
  2. package/dist/cli/senpi-cli-config.d.ts.map +1 -1
  3. package/dist/cli/senpi-cli-config.js +19 -2
  4. package/dist/cli/senpi-cli-config.js.map +1 -1
  5. package/dist/cli/senpi-commands.d.ts.map +1 -1
  6. package/dist/cli/senpi-commands.js +4 -1
  7. package/dist/cli/senpi-commands.js.map +1 -1
  8. package/dist/index.d.ts +17 -3
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +47 -11
  11. package/dist/index.js.map +1 -1
  12. package/dist/runtime/auto-update/coordinator.d.ts +13 -0
  13. package/dist/runtime/auto-update/coordinator.d.ts.map +1 -1
  14. package/dist/runtime/auto-update/coordinator.js +22 -8
  15. package/dist/runtime/auto-update/coordinator.js.map +1 -1
  16. package/dist/skills-manager/bootstrap.d.ts.map +1 -1
  17. package/dist/skills-manager/bootstrap.js +34 -16
  18. package/dist/skills-manager/bootstrap.js.map +1 -1
  19. package/dist/skills-manager/constants.d.ts +20 -1
  20. package/dist/skills-manager/constants.d.ts.map +1 -1
  21. package/dist/skills-manager/constants.js +26 -1
  22. package/dist/skills-manager/constants.js.map +1 -1
  23. package/dist/skills-manager/coordinator.d.ts +157 -20
  24. package/dist/skills-manager/coordinator.d.ts.map +1 -1
  25. package/dist/skills-manager/coordinator.js +289 -86
  26. package/dist/skills-manager/coordinator.js.map +1 -1
  27. package/dist/skills-manager/index.d.ts +1 -1
  28. package/dist/skills-manager/index.d.ts.map +1 -1
  29. package/dist/skills-manager/index.js +1 -1
  30. package/dist/skills-manager/index.js.map +1 -1
  31. package/dist/skills-manager/state-store.d.ts +27 -4
  32. package/dist/skills-manager/state-store.d.ts.map +1 -1
  33. package/dist/skills-manager/state-store.js +42 -16
  34. package/dist/skills-manager/state-store.js.map +1 -1
  35. package/dist/skills-manager/types.d.ts +46 -8
  36. package/dist/skills-manager/types.d.ts.map +1 -1
  37. package/dist/skills-manager/utils.d.ts +17 -0
  38. package/dist/skills-manager/utils.d.ts.map +1 -1
  39. package/dist/skills-manager/utils.js +43 -11
  40. package/dist/skills-manager/utils.js.map +1 -1
  41. package/dist/skills-manager/version-manager.d.ts +93 -8
  42. package/dist/skills-manager/version-manager.d.ts.map +1 -1
  43. package/dist/skills-manager/version-manager.js +185 -28
  44. package/dist/skills-manager/version-manager.js.map +1 -1
  45. package/openclaw.plugin.json +7 -9
  46. 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
- * same runToken + stopped + running triple for thread-safety, same
6
- * self-managed setTimeout loop, same injectable-dependency pattern for
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
- * On start(): bootstrap missing skills then schedule the first poll.
10
- * Each tick(): resolve skill names, check remote versions in parallel,
11
- * auto-apply patch/minor, notify on major (deduped), skip on none/unknown.
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
- /** Load persisted state, bootstrap missing skills, and schedule the first poll. */
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} pollInterval=${config.pollIntervalMinutes}m skills=${config.skills.join(",") || "(discover)"}`);
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
- * Cancel the next scheduled poll, prevent in-flight ticks from rescheduling,
63
- * and wait for any currently-executing tick or manual RPC to finish so the
64
- * caller can rely on no async work still touching state after `stop()`
65
- * resolves. Without this drain, a caller `await coordinator.stop()` could
66
- * proceed to e.g. delete the state dir while a tick mid-write fires.
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
- await installSkill(cloneDir, skillName, config.skillsDir, logger);
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
- await this.options.notify(`Senpi skill "${skillName}" has a major update available: v${remoteVersion}.\n` +
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
- `Run: openclaw senpi skills update ${skillName}`);
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 successLines = succeededSkillNames.map((name) => {
654
- const latestVersion = this.latestVersions.get(name);
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 failureLines = failedSkills.map(({ name, reason }) => ` • ${name}: ${reason}`);
661
- sections.push(`Failed to update:\n${failureLines.join("\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
- ((skillsDir, skillName, rawBaseUrl, logger) => checkSkillVersion(skillsDir, skillName, rawBaseUrl, undefined, logger)));
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 of `skillsDir`.
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
- try {
694
- const entries = await readdir(this.options.config.skillsDir, { withFileTypes: true });
695
- return entries
696
- .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
697
- .map((entry) => entry.name)
698
- .sort();
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
- catch (err) {
701
- // Missing / unreadable skillsDir — log so the operator can diagnose
702
- // (previously failed silently, which made "no skills checked" indistinguishable
703
- // from "skillsDir configured wrong" in the logs).
704
- this.options.logger.warn(`[senpi-skills-manager] resolveSkillNames — failed to read skillsDir | dir=${this.options.config.skillsDir} err=${formatErr(err)}`);
705
- return [];
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
- skillsDir: this.options.config.skillsDir,
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. `&amp;` becomes `&amp;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