@senpi/runtime 1.0.95-dev.runtime-phase-2.20260508073606 → 1.1.0-dev.main.20260527104621

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 (128) hide show
  1. package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
  2. package/dist/actions/close-position/close-position.action.js +4 -1
  3. package/dist/actions/close-position/close-position.action.js.map +1 -1
  4. package/dist/actions/llm-decision/openclaw-llm-decision.d.ts +30 -0
  5. package/dist/actions/llm-decision/openclaw-llm-decision.d.ts.map +1 -1
  6. package/dist/actions/llm-decision/openclaw-llm-decision.js +87 -15
  7. package/dist/actions/llm-decision/openclaw-llm-decision.js.map +1 -1
  8. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  9. package/dist/actions/open-position/open-position.action.js +19 -2
  10. package/dist/actions/open-position/open-position.action.js.map +1 -1
  11. package/dist/actions/open-position/resolve-margin.d.ts +9 -1
  12. package/dist/actions/open-position/resolve-margin.d.ts.map +1 -1
  13. package/dist/actions/open-position/resolve-margin.js +12 -1
  14. package/dist/actions/open-position/resolve-margin.js.map +1 -1
  15. package/dist/cli/guide-commands.d.ts.map +1 -1
  16. package/dist/cli/guide-commands.js +8 -4
  17. package/dist/cli/guide-commands.js.map +1 -1
  18. package/dist/cli/senpi-cli-config.d.ts +15 -1
  19. package/dist/cli/senpi-cli-config.d.ts.map +1 -1
  20. package/dist/cli/senpi-cli-config.js +20 -4
  21. package/dist/cli/senpi-cli-config.js.map +1 -1
  22. package/dist/cli/senpi-commands.d.ts.map +1 -1
  23. package/dist/cli/senpi-commands.js +163 -0
  24. package/dist/cli/senpi-commands.js.map +1 -1
  25. package/dist/dsl/engine-client/config-mapping.d.ts +5 -0
  26. package/dist/dsl/engine-client/config-mapping.d.ts.map +1 -1
  27. package/dist/dsl/engine-client/config-mapping.js +14 -2
  28. package/dist/dsl/engine-client/config-mapping.js.map +1 -1
  29. package/dist/dsl/engine-client/event-mapping.d.ts +5 -1
  30. package/dist/dsl/engine-client/event-mapping.d.ts.map +1 -1
  31. package/dist/dsl/engine-client/event-mapping.js +6 -0
  32. package/dist/dsl/engine-client/event-mapping.js.map +1 -1
  33. package/dist/dsl/events/build-closed-event.d.ts.map +1 -1
  34. package/dist/dsl/events/build-closed-event.js +2 -0
  35. package/dist/dsl/events/build-closed-event.js.map +1 -1
  36. package/dist/dsl/events/handlers.d.ts.map +1 -1
  37. package/dist/dsl/events/handlers.js +9 -5
  38. package/dist/dsl/events/handlers.js.map +1 -1
  39. package/dist/dsl/events/initial-state.d.ts +2 -2
  40. package/dist/dsl/events/initial-state.d.ts.map +1 -1
  41. package/dist/dsl/events/initial-state.js +4 -1
  42. package/dist/dsl/events/initial-state.js.map +1 -1
  43. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  44. package/dist/dsl/monitor/process-one-position.js +38 -1
  45. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  46. package/dist/dsl/notifications/formatter.js +1 -1
  47. package/dist/dsl/notifications/formatter.js.map +1 -1
  48. package/dist/index.d.ts +21 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +248 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/runtime/auto-update/coordinator.d.ts +13 -0
  53. package/dist/runtime/auto-update/coordinator.d.ts.map +1 -1
  54. package/dist/runtime/auto-update/coordinator.js +22 -8
  55. package/dist/runtime/auto-update/coordinator.js.map +1 -1
  56. package/dist/runtime/decision-errors.d.ts +166 -0
  57. package/dist/runtime/decision-errors.d.ts.map +1 -0
  58. package/dist/runtime/decision-errors.js +328 -0
  59. package/dist/runtime/decision-errors.js.map +1 -0
  60. package/dist/runtime/notifications.d.ts +15 -1
  61. package/dist/runtime/notifications.d.ts.map +1 -1
  62. package/dist/runtime/notifications.js +16 -11
  63. package/dist/runtime/notifications.js.map +1 -1
  64. package/dist/runtime/run.d.ts +36 -0
  65. package/dist/runtime/run.d.ts.map +1 -1
  66. package/dist/runtime/run.js +9 -0
  67. package/dist/runtime/run.js.map +1 -1
  68. package/dist/runtime/runtime-schema.d.ts +1019 -1
  69. package/dist/runtime/runtime-schema.d.ts.map +1 -1
  70. package/dist/runtime/runtime-schema.js +34 -0
  71. package/dist/runtime/runtime-schema.js.map +1 -1
  72. package/dist/runtime/runtime.d.ts.map +1 -1
  73. package/dist/runtime/runtime.js +7 -4
  74. package/dist/runtime/runtime.js.map +1 -1
  75. package/dist/senpi/types.d.ts +2 -1
  76. package/dist/senpi/types.d.ts.map +1 -1
  77. package/dist/skills-manager/bootstrap.d.ts +26 -0
  78. package/dist/skills-manager/bootstrap.d.ts.map +1 -0
  79. package/dist/skills-manager/bootstrap.js +86 -0
  80. package/dist/skills-manager/bootstrap.js.map +1 -0
  81. package/dist/skills-manager/constants.d.ts +65 -0
  82. package/dist/skills-manager/constants.d.ts.map +1 -0
  83. package/dist/skills-manager/constants.js +91 -0
  84. package/dist/skills-manager/constants.js.map +1 -0
  85. package/dist/skills-manager/coordinator.d.ts +346 -0
  86. package/dist/skills-manager/coordinator.d.ts.map +1 -0
  87. package/dist/skills-manager/coordinator.js +1037 -0
  88. package/dist/skills-manager/coordinator.js.map +1 -0
  89. package/dist/skills-manager/fetcher.d.ts +28 -0
  90. package/dist/skills-manager/fetcher.d.ts.map +1 -0
  91. package/dist/skills-manager/fetcher.js +92 -0
  92. package/dist/skills-manager/fetcher.js.map +1 -0
  93. package/dist/skills-manager/index.d.ts +5 -0
  94. package/dist/skills-manager/index.d.ts.map +1 -0
  95. package/dist/skills-manager/index.js +4 -0
  96. package/dist/skills-manager/index.js.map +1 -0
  97. package/dist/skills-manager/state-store.d.ts +57 -0
  98. package/dist/skills-manager/state-store.d.ts.map +1 -0
  99. package/dist/skills-manager/state-store.js +129 -0
  100. package/dist/skills-manager/state-store.js.map +1 -0
  101. package/dist/skills-manager/types.d.ts +177 -0
  102. package/dist/skills-manager/types.d.ts.map +1 -0
  103. package/dist/skills-manager/types.js +2 -0
  104. package/dist/skills-manager/types.js.map +1 -0
  105. package/dist/skills-manager/utils.d.ts +81 -0
  106. package/dist/skills-manager/utils.d.ts.map +1 -0
  107. package/dist/skills-manager/utils.js +143 -0
  108. package/dist/skills-manager/utils.js.map +1 -0
  109. package/dist/skills-manager/version-manager.d.ts +146 -0
  110. package/dist/skills-manager/version-manager.d.ts.map +1 -0
  111. package/dist/skills-manager/version-manager.js +299 -0
  112. package/dist/skills-manager/version-manager.js.map +1 -0
  113. package/dist/skills-manager/writer.d.ts +28 -0
  114. package/dist/skills-manager/writer.d.ts.map +1 -0
  115. package/dist/skills-manager/writer.js +101 -0
  116. package/dist/skills-manager/writer.js.map +1 -0
  117. package/dist/state/state-manager.d.ts +3 -0
  118. package/dist/state/state-manager.d.ts.map +1 -1
  119. package/dist/state/state-manager.js +9 -0
  120. package/dist/state/state-manager.js.map +1 -1
  121. package/dist/types/dsl/index.d.ts +22 -3
  122. package/dist/types/dsl/index.d.ts.map +1 -1
  123. package/dist/utils/retry.d.ts +7 -0
  124. package/dist/utils/retry.d.ts.map +1 -1
  125. package/dist/utils/retry.js +5 -1
  126. package/dist/utils/retry.js.map +1 -1
  127. package/openclaw.plugin.json +28 -1
  128. package/package.json +3 -1
@@ -0,0 +1,1037 @@
1
+ /**
2
+ * Central lifecycle manager for Senpi skills auto-update.
3
+ *
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.
7
+ *
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.
22
+ */
23
+ import { readdir } from "node:fs/promises";
24
+ import { bootstrapSkillsIfNeeded } from "./bootstrap.js";
25
+ import { cloneSkillsRepo } from "./fetcher.js";
26
+ import { SkillsManagerStateStore } from "./state-store.js";
27
+ import { checkSkillVersion } from "./version-manager.js";
28
+ 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
+ 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
+ }
37
+ export class SkillsUpdateCoordinator {
38
+ options;
39
+ store;
40
+ operationChain = Promise.resolve();
41
+ stopped = true;
42
+ runToken = 0;
43
+ state = createDefaultSkillsManagerState();
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
+ /**
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();
77
+ constructor(options) {
78
+ this.options = options;
79
+ this.store = new SkillsManagerStateStore(options.stateDir, options.config.skillsDir, options.logger);
80
+ this.snapshot = this.buildSnapshot("disabled", "not-started");
81
+ }
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
+ */
92
+ async start() {
93
+ this.runToken += 1;
94
+ this.stopped = false;
95
+ const { logger, config } = this.options;
96
+ if (!config.enabled) {
97
+ this.stopped = true;
98
+ this.snapshot = this.buildSnapshot("disabled", "skills-manager-disabled");
99
+ logger.info("[senpi-skills-manager] disabled by config");
100
+ return;
101
+ }
102
+ logger.info(`[senpi-skills-manager] starting | skillsDir=${config.skillsDir.join(",")} skills=${config.skills.join(",") || "(discover)"} cadence=external (driven by auto-update)`);
103
+ this.state = await this.store.load();
104
+ const bootstrapFn = this.options.deps?.bootstrapSkillsIfNeeded ?? bootstrapSkillsIfNeeded;
105
+ try {
106
+ await bootstrapFn(config, logger);
107
+ }
108
+ catch (err) {
109
+ logger.warn(`[senpi-skills-manager] bootstrap error (non-fatal): ${formatErr(err)}`);
110
+ }
111
+ this.snapshot = this.buildSnapshot("healthy", "started");
112
+ }
113
+ /**
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.
123
+ */
124
+ async stop() {
125
+ this.runToken += 1;
126
+ this.stopped = true;
127
+ const inFlight = this.operationChain;
128
+ this.options.logger.info("[senpi-skills-manager] stop requested — draining in-flight operations");
129
+ // operationChain never rejects (runSerialized catches via try/finally before
130
+ // releasing the queue), so this await always resolves.
131
+ await inFlight;
132
+ this.options.logger.info("[senpi-skills-manager] stopped");
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
+ }
175
+ /** Return the latest point-in-time snapshot for gateway RPCs. */
176
+ getSnapshot() {
177
+ return this.snapshot;
178
+ }
179
+ // ── Manual trigger methods (called by gateway RPCs) ────────────────────────
180
+ /**
181
+ * Force an immediate version check and apply for one named skill.
182
+ *
183
+ * Skips installation when the update kind is `"none"` (already current) or
184
+ * `"unknown"` (remote version could not be classified — e.g. the fetch
185
+ * failed or the remote `version` field is missing). Falling through on
186
+ * `"unknown"` would write `installedVersion: null` and lose tracking, so we
187
+ * treat it the same as `"none"` and return `updated: false`.
188
+ *
189
+ * @throws On network or install error so the gateway RPC can return ok=false.
190
+ */
191
+ async updateSkill(skillName) {
192
+ const token = this.runToken;
193
+ this.assertManualOperationAllowed("updateSkill");
194
+ return this.runSerialized("updateSkill", async () => {
195
+ this.assertManualOperationAllowed("updateSkill", token);
196
+ const { logger } = this.options;
197
+ logger.info(`[senpi-skills-manager] updateSkill invoked | skill=${skillName}`);
198
+ const checkVersionFn = this.resolveCheckFn();
199
+ let versionInfo;
200
+ try {
201
+ versionInfo = await checkVersionFn(this.options.config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
202
+ }
203
+ catch (err) {
204
+ // Version check threw (5xx, network error, timeout, body-parse failure
205
+ // — all now propagate from checkSkillVersion after the version-manager
206
+ // re-throw fix). Mirror the tick/updateAll paths and record the
207
+ // failure on this skill's state before rethrowing so the gateway RPC
208
+ // still returns ok=false to the caller.
209
+ const errorMessage = formatErr(err);
210
+ logger.warn(`[senpi-skills-manager] updateSkill — version check failed | skill=${skillName} err=${errorMessage}`);
211
+ this.recordCheckFailure(skillName, errorMessage);
212
+ await this.persistState();
213
+ this.snapshot = this.buildSnapshot(this.snapshot.health, "manual-update-failed", errorMessage);
214
+ throw err;
215
+ }
216
+ // Record the remote version we just learned so the snapshot's latestVersion
217
+ // 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.
220
+ this.latestVersions.set(skillName, versionInfo.remoteVersion);
221
+ this.previousVersions.set(skillName, versionInfo.localVersion);
222
+ this.skillDescriptions.set(skillName, versionInfo.remoteDescription ?? null);
223
+ // Mirror runTick: every skill whose version we just checked gets its
224
+ // `lastCheckedAt` updated (and `installedVersion` refreshed from the local
225
+ // read) regardless of whether it proceeds to install. Without this, skills
226
+ // updated only via manual RPCs would show `lastCheckedAt: null` in the
227
+ // status snapshot until the next automatic tick fires (up to 60 min away).
228
+ const checkedState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
229
+ checkedState.lastCheckedAt = new Date().toISOString();
230
+ if (versionInfo.localVersion !== null) {
231
+ checkedState.installedVersion = versionInfo.localVersion;
232
+ }
233
+ // Persist the remote version we just learned so the snapshot reflects
234
+ // it across restarts — without this, `senpi.skills.status` returns
235
+ // `latestVersion: null` for every skill for the first
236
+ // (startupDelaySeconds + pollIntervalMinutes) after a gateway restart.
237
+ checkedState.latestVersion = versionInfo.remoteVersion;
238
+ // NOTE: do NOT clear failure state here. See runTick for rationale —
239
+ // a successful version check is not proof of operational health. Failure
240
+ // counters are only reset by `recordSuccess` after a successful install.
241
+ this.state.skills[skillName] = checkedState;
242
+ logger.info(`[senpi-skills-manager] updateSkill check result | skill=${skillName} local=${versionInfo.localVersion ?? "none"} remote=${versionInfo.remoteVersion ?? "unknown"} kind=${versionInfo.updateKind}`);
243
+ if (versionInfo.updateKind === "none" || versionInfo.updateKind === "unknown") {
244
+ logger.info(`[senpi-skills-manager] updateSkill skipped | skill=${skillName} reason=${versionInfo.updateKind}`);
245
+ // Persist so the `lastCheckedAt` timestamp survives a gateway restart
246
+ // even on skip paths (otherwise the next load() would see a stale null).
247
+ await this.persistState();
248
+ // Refresh snapshot so the status RPC doesn't return pre-check data.
249
+ this.snapshot = this.buildSnapshot(this.snapshot.health, "manual-update-skipped");
250
+ return { updated: false, version: versionInfo.localVersion };
251
+ }
252
+ try {
253
+ const installedVersion = await this.applyUpdate(skillName, versionInfo.remoteVersion);
254
+ logger.info(`[senpi-skills-manager] updateSkill complete | skill=${skillName} version=${installedVersion ?? "unknown"}`);
255
+ // Refresh snapshot so the status RPC reflects the freshly-installed version
256
+ // immediately, rather than waiting for the next poll tick (up to 60 min).
257
+ this.snapshot = this.buildSnapshot("healthy", "manual-update-complete");
258
+ return { updated: true, version: installedVersion };
259
+ }
260
+ catch (err) {
261
+ const errorMessage = formatErr(err);
262
+ // applyUpdate has already recorded per-skill failure state (consecutiveFailures,
263
+ // lastFailureAt, lastFailureReason) and persisted before throwing. We repeat
264
+ // persistState() here as a defensive backstop in case applyUpdate's internal
265
+ // persist silently failed, and also so the updated lastCheckedAt we set
266
+ // earlier in this method is guaranteed to reach disk on the error path.
267
+ await this.persistState();
268
+ this.snapshot = this.buildSnapshot(this.snapshot.health, "manual-update-failed", errorMessage);
269
+ throw err;
270
+ }
271
+ });
272
+ }
273
+ /**
274
+ * Force an immediate version check and apply for all discovered/configured skills.
275
+ * Individual failures do not abort the remaining skills.
276
+ *
277
+ * All skills share a single `git clone` — the repo is cloned once, every
278
+ * skill that needs an update is installed from that same checkout, then the
279
+ * checkout is cleaned up. A previous version called `updateSkill` in a loop,
280
+ * which caused one full clone per skill (N redundant clones for N skills);
281
+ * this mirrors the tick path's batching instead.
282
+ */
283
+ async updateAll() {
284
+ const token = this.runToken;
285
+ this.assertManualOperationAllowed("updateAll");
286
+ return this.runSerialized("updateAll", async () => {
287
+ this.assertManualOperationAllowed("updateAll", token);
288
+ const { logger, config } = this.options;
289
+ logger.info("[senpi-skills-manager] updateAll invoked");
290
+ const skillNames = await this.resolveSkillNames();
291
+ if (skillNames.length === 0) {
292
+ logger.info("[senpi-skills-manager] updateAll — no skills to check");
293
+ return [];
294
+ }
295
+ logger.info(`[senpi-skills-manager] updateAll — checking versions | skills=${skillNames.join(",")}`);
296
+ const checkVersionFn = this.resolveCheckFn();
297
+ // Check every skill's version in parallel — one rejection does not abort others.
298
+ const versionChecks = await Promise.allSettled(skillNames.map(async (skillName) => {
299
+ const versionInfo = await checkVersionFn(config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
300
+ this.latestVersions.set(skillName, versionInfo.remoteVersion);
301
+ this.previousVersions.set(skillName, versionInfo.localVersion);
302
+ this.skillDescriptions.set(skillName, versionInfo.remoteDescription ?? null);
303
+ return { name: skillName, ...versionInfo };
304
+ }));
305
+ const results = [];
306
+ const skillsToInstall = [];
307
+ for (let index = 0; index < versionChecks.length; index++) {
308
+ const skillName = skillNames[index];
309
+ const checkResult = versionChecks[index];
310
+ if (checkResult.status === "rejected") {
311
+ const errorMessage = formatErr(checkResult.reason);
312
+ logger.warn(`[senpi-skills-manager] updateAll — version check failed | skill=${skillName} err=${errorMessage}`);
313
+ // Mirror runTick: rejected checks update `lastCheckedAt`, bump the
314
+ // failure counter, and capture the reason. Otherwise a manual
315
+ // updateAll run could swallow a per-skill check failure entirely —
316
+ // the caller sees `error` in the returned array, but the snapshot
317
+ // and persisted state never reflect it.
318
+ this.recordCheckFailure(skillName, errorMessage);
319
+ results.push({ name: skillName, updated: false, version: null, error: errorMessage });
320
+ continue;
321
+ }
322
+ const { localVersion, remoteVersion, updateKind } = checkResult.value;
323
+ // Mirror runTick: every successfully-checked skill gets `lastCheckedAt`
324
+ // updated and `installedVersion` refreshed from the local read, whether
325
+ // or not it proceeds to install. Otherwise skills touched only by
326
+ // manual RPCs would show `lastCheckedAt: null` in the status snapshot
327
+ // until the next automatic tick.
328
+ const skillState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
329
+ skillState.lastCheckedAt = new Date().toISOString();
330
+ if (localVersion !== null)
331
+ skillState.installedVersion = localVersion;
332
+ skillState.latestVersion = remoteVersion;
333
+ // NOTE: do NOT clear failure state here. See runTick for rationale —
334
+ // a successful version check is not proof of operational health.
335
+ // Failure counters are only reset by `recordSuccess` after a
336
+ // successful install.
337
+ this.state.skills[skillName] = skillState;
338
+ // Same skip policy as updateSkill: "none" and "unknown" leave the skill alone.
339
+ if (updateKind === "none" || updateKind === "unknown") {
340
+ logger.info(`[senpi-skills-manager] updateAll — skipping | skill=${skillName} reason=${updateKind}`);
341
+ results.push({ name: skillName, updated: false, version: localVersion });
342
+ continue;
343
+ }
344
+ skillsToInstall.push(skillName);
345
+ }
346
+ if (skillsToInstall.length === 0) {
347
+ logger.info(`[senpi-skills-manager] updateAll complete — no installs needed | checked=${skillNames.length}`);
348
+ // Persist so the `lastCheckedAt` timestamps we just set on every
349
+ // successfully-checked skill survive a gateway restart.
350
+ await this.persistState();
351
+ // No installs, but we may have learned new remote versions from the
352
+ // parallel checks above — refresh the snapshot so getSnapshot() doesn't
353
+ // return pre-check data.
354
+ this.snapshot = this.buildSnapshot(this.snapshot.health, "manual-update-skipped");
355
+ return results;
356
+ }
357
+ logger.info(`[senpi-skills-manager] updateAll — installing | count=${skillsToInstall.length} skills=${skillsToInstall.join(",")}`);
358
+ // Single clone, install many.
359
+ let installOutcomes;
360
+ try {
361
+ installOutcomes = await this.cloneAndInstallMany(skillsToInstall);
362
+ }
363
+ catch (err) {
364
+ const errorMessage = `clone failed: ${formatErr(err)}`;
365
+ logger.warn(`[senpi-skills-manager] updateAll — ${errorMessage}`);
366
+ for (const skillName of skillsToInstall) {
367
+ results.push({ name: skillName, updated: false, version: null, error: errorMessage });
368
+ }
369
+ // Clone failure doesn't produce install outcomes per-skill, but every
370
+ // checked skill already had `lastCheckedAt` updated in-memory above.
371
+ // Persist so those timestamps survive a gateway restart.
372
+ await this.persistState();
373
+ this.snapshot = this.buildSnapshot(this.snapshot.health, "manual-update-clone-failed");
374
+ return results;
375
+ }
376
+ let successCount = 0;
377
+ let failureCount = 0;
378
+ for (const skillName of skillsToInstall) {
379
+ const outcome = this.processInstallOutcome(skillName, installOutcomes);
380
+ if (outcome.success) {
381
+ results.push({ name: skillName, updated: true, version: outcome.version });
382
+ successCount += 1;
383
+ }
384
+ else {
385
+ results.push({ name: skillName, updated: false, version: null, error: outcome.error });
386
+ failureCount += 1;
387
+ }
388
+ }
389
+ await this.persistState();
390
+ logger.info(`[senpi-skills-manager] updateAll complete | installed=${successCount} failed=${failureCount} checked=${skillNames.length}`);
391
+ // Refresh snapshot so the status RPC reflects the freshly-installed
392
+ // versions immediately instead of waiting for the next poll tick.
393
+ this.snapshot = this.buildSnapshot("healthy", "manual-update-complete");
394
+ return results;
395
+ });
396
+ }
397
+ /**
398
+ * Run coordinator work in a global critical section so periodic ticks and
399
+ * manual RPC updates never interleave at await points.
400
+ */
401
+ async runSerialized(operationName, operation) {
402
+ const queued = this.operationChain;
403
+ let releaseQueue;
404
+ this.operationChain = new Promise((resolve) => {
405
+ releaseQueue = resolve;
406
+ });
407
+ await queued;
408
+ this.options.logger.info(`[senpi-skills-manager] runSerialized — entering critical section | op=${operationName}`);
409
+ try {
410
+ return await operation();
411
+ }
412
+ finally {
413
+ this.options.logger.info(`[senpi-skills-manager] runSerialized — leaving critical section | op=${operationName}`);
414
+ // Release the queue so the next caller can enter. We always release even
415
+ // if `operation` threw — otherwise a thrown error would deadlock every
416
+ // subsequent call forever.
417
+ releaseQueue?.();
418
+ }
419
+ }
420
+ /**
421
+ * Manual gateway RPCs (`updateSkill`, `updateAll`) are only valid while the
422
+ * coordinator is actively running and enabled. This prevents pre-start calls
423
+ * (e.g. when SENPI_RUNTIME_API_DISABLED=true skipped start()) from persisting
424
+ * the constructor-default empty state over a valid on-disk state file.
425
+ */
426
+ assertManualOperationAllowed(operationName, token = null) {
427
+ if (!this.options.config.enabled) {
428
+ throw new SkillsCoordinatorUnavailableError(`[senpi-skills-manager] ${operationName} rejected: skills manager is disabled`);
429
+ }
430
+ if (this.stopped) {
431
+ throw new SkillsCoordinatorUnavailableError(`[senpi-skills-manager] ${operationName} rejected: coordinator is not running`);
432
+ }
433
+ if (token !== null && token !== this.runToken) {
434
+ throw new SkillsCoordinatorUnavailableError(`[senpi-skills-manager] ${operationName} rejected: coordinator lifecycle changed`);
435
+ }
436
+ }
437
+ /**
438
+ * Core tick logic: resolve skill names, check versions in parallel, apply
439
+ * policy per skill, then run a single clone for all skills needing update.
440
+ */
441
+ async runTick() {
442
+ const { logger, config } = this.options;
443
+ const skillNames = await this.resolveSkillNames();
444
+ if (skillNames.length === 0) {
445
+ logger.info("[senpi-skills-manager] tick — no skills to check");
446
+ return;
447
+ }
448
+ logger.info(`[senpi-skills-manager] tick — checking versions | count=${skillNames.length} skills=${skillNames.join(",")}`);
449
+ const checkVersionFn = this.resolveCheckFn();
450
+ // Check all versions in parallel — per-skill errors are captured, not thrown.
451
+ const versionChecks = await Promise.allSettled(skillNames.map(async (skillName) => {
452
+ const versionInfo = await checkVersionFn(config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
453
+ this.latestVersions.set(skillName, versionInfo.remoteVersion);
454
+ this.previousVersions.set(skillName, versionInfo.localVersion);
455
+ this.skillDescriptions.set(skillName, versionInfo.remoteDescription ?? null);
456
+ return { name: skillName, ...versionInfo };
457
+ }));
458
+ const skillsToInstall = [];
459
+ let majorPendingCount = 0;
460
+ let upToDateCount = 0;
461
+ for (let index = 0; index < versionChecks.length; index++) {
462
+ const skillName = skillNames[index];
463
+ const checkResult = versionChecks[index];
464
+ if (checkResult.status === "rejected") {
465
+ const errorMessage = formatErr(checkResult.reason);
466
+ this.recordCheckFailure(skillName, errorMessage);
467
+ logger.warn(`[senpi-skills-manager] tick — version check failed | skill=${skillName} err=${errorMessage}`);
468
+ continue;
469
+ }
470
+ const { localVersion, remoteVersion, updateKind } = checkResult.value;
471
+ const skillState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
472
+ skillState.lastCheckedAt = new Date().toISOString();
473
+ if (localVersion !== null)
474
+ skillState.installedVersion = localVersion;
475
+ skillState.latestVersion = remoteVersion;
476
+ // NOTE: do NOT clear failure state here. A successful version check does
477
+ // not prove the skill is operationally healthy — it only proves the raw
478
+ // SKILL.md is reachable. Install failures (clone, copy, swap) reflect
479
+ // real operational issues; clearing them on a check-success would wipe
480
+ // that history mid-tick and prevent consecutiveFailures from accumulating
481
+ // across multiple failed install attempts. Failure state is cleared only
482
+ // by `recordSuccess` (called after a successful install).
483
+ logger.info(`[senpi-skills-manager] tick — skill=${skillName} local=${localVersion ?? "none"} remote=${remoteVersion ?? "unknown"} kind=${updateKind}`);
484
+ if (updateKind === "none" || updateKind === "unknown") {
485
+ this.state.skills[skillName] = skillState;
486
+ upToDateCount += 1;
487
+ continue;
488
+ }
489
+ if (updateKind === "major") {
490
+ // Major versions are never auto-applied — notify the user and let them decide.
491
+ await this.handleMajorAvailable(skillState, skillName, remoteVersion);
492
+ this.state.skills[skillName] = skillState;
493
+ majorPendingCount += 1;
494
+ continue;
495
+ }
496
+ // patch / minor — queue for install
497
+ skillsToInstall.push(skillName);
498
+ this.state.skills[skillName] = skillState;
499
+ }
500
+ logger.info(`[senpi-skills-manager] tick — triage | upToDate=${upToDateCount} majorPending=${majorPendingCount} toInstall=${skillsToInstall.length}`);
501
+ if (skillsToInstall.length === 0)
502
+ return;
503
+ await this.applyUpdates(skillsToInstall);
504
+ }
505
+ /**
506
+ * 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.
510
+ *
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.
515
+ */
516
+ async applyUpdates(skillNames) {
517
+ const { logger } = this.options;
518
+ logger.info(`[senpi-skills-manager] applyUpdates — batch install starting | count=${skillNames.length} skills=${skillNames.join(",")}`);
519
+ let installOutcomes;
520
+ try {
521
+ installOutcomes = await this.cloneAndInstallMany(skillNames);
522
+ }
523
+ catch (err) {
524
+ logger.warn(`[senpi-skills-manager] applyUpdates — git clone failed, skipping batch: ${formatErr(err)}`);
525
+ return;
526
+ }
527
+ const succeededSkillNames = [];
528
+ const failedSkills = [];
529
+ for (const skillName of skillNames) {
530
+ const outcome = this.processInstallOutcome(skillName, installOutcomes);
531
+ if (outcome.success) {
532
+ succeededSkillNames.push(skillName);
533
+ logger.info(`[senpi-skills-manager] applyUpdates — installed | skill=${skillName} version=${outcome.version ?? "unknown"}`);
534
+ }
535
+ else {
536
+ failedSkills.push({ name: skillName, reason: outcome.error });
537
+ }
538
+ }
539
+ logger.info(`[senpi-skills-manager] applyUpdates complete | installed=${succeededSkillNames.length} failed=${failedSkills.length}`);
540
+ await this.sendUpdateSummary(succeededSkillNames, failedSkills);
541
+ }
542
+ /**
543
+ * Clone the remote skills repo exactly once and install every skill in
544
+ * `skillNames` from that single checkout. Returns a per-skill outcome map:
545
+ * `{ version }` on success or `{ version: null, error }` on install failure.
546
+ *
547
+ * Throws only when the clone itself fails — per-skill install failures are
548
+ * captured in the map so callers can continue processing the rest.
549
+ *
550
+ * When `deps.cloneAndInstall` is supplied (unit tests), that injector is
551
+ * called in place of the real clone+install loop; any names missing from
552
+ * its returned map are treated as install failures.
553
+ */
554
+ async cloneAndInstallMany(skillNames) {
555
+ const { logger, config } = this.options;
556
+ const outcomes = new Map();
557
+ if (skillNames.length === 0)
558
+ return outcomes;
559
+ if (this.options.deps?.cloneAndInstall) {
560
+ // Injected dep for tests — receives skill names + config, returns name→version map.
561
+ // Names absent from the returned map are treated as failures.
562
+ logger.info(`[senpi-skills-manager] cloneAndInstallMany — using injected dep | skills=${skillNames.join(",")}`);
563
+ const versionsByName = await this.options.deps.cloneAndInstall(skillNames, config);
564
+ for (const skillName of skillNames) {
565
+ if (versionsByName.has(skillName)) {
566
+ outcomes.set(skillName, {
567
+ version: versionsByName.get(skillName) ?? this.latestVersions.get(skillName) ?? null,
568
+ });
569
+ }
570
+ else {
571
+ outcomes.set(skillName, { version: null, error: "install failed" });
572
+ }
573
+ }
574
+ return outcomes;
575
+ }
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}`);
594
+ }
595
+ }
596
+ }
597
+ finally {
598
+ await cleanup();
599
+ logger.info("[senpi-skills-manager] cloneAndInstallMany — clone cleanup done");
600
+ }
601
+ return outcomes;
602
+ }
603
+ /**
604
+ * Record a successful install on in-memory skill state.
605
+ *
606
+ * Resets failure tracking — a successful end-to-end install is the only
607
+ * signal we treat as "operationally healthy." A successful version check
608
+ * alone is NOT sufficient (and intentionally does not call this), because:
609
+ *
610
+ * - The version check only proves the raw SKILL.md is reachable. It does
611
+ * not exercise the clone/copy/swap pipeline that actually keeps the skill
612
+ * working.
613
+ * - Clearing failure state on every check-success would wipe install-failure
614
+ * history mid-tick: a skill failing to install on every tick would still
615
+ * report `consecutiveFailures: 1` (instead of N) because each tick's
616
+ * check-success reset would precede the install-failure increment.
617
+ *
618
+ * Consequence: a skill that's perpetually up-to-date but had a one-off
619
+ * failure in the past will retain a non-zero `consecutiveFailures` until
620
+ * the next install. We accept that trade-off — surfacing a stale failure
621
+ * counter is preferable to silently wiping real install-failure history.
622
+ */
623
+ recordSuccess(skillState, newVersion) {
624
+ skillState.installedVersion = newVersion;
625
+ skillState.lastAppliedVersion = newVersion;
626
+ skillState.consecutiveFailures = 0;
627
+ skillState.lastFailureAt = null;
628
+ skillState.lastFailureReason = null;
629
+ }
630
+ /**
631
+ * Record a version-check failure on in-memory skill state.
632
+ *
633
+ * Shared by `runTick` and `updateAll` — both need to bump
634
+ * `consecutiveFailures` and capture the reason when `Promise.allSettled`
635
+ * surfaces a rejected check. `lastCheckedAt` is updated too so the snapshot
636
+ * doesn't look like the skill was never polled (it was — the poll failed).
637
+ */
638
+ recordCheckFailure(skillName, errorMessage) {
639
+ const skillState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
640
+ const now = new Date().toISOString();
641
+ skillState.lastCheckedAt = now;
642
+ skillState.consecutiveFailures += 1;
643
+ skillState.lastFailureAt = now;
644
+ skillState.lastFailureReason = errorMessage;
645
+ this.state.skills[skillName] = skillState;
646
+ }
647
+ /**
648
+ * Apply one install outcome to in-memory skill state and return a compact
649
+ * success/failure result for the caller's bookkeeping.
650
+ */
651
+ processInstallOutcome(skillName, installOutcomes) {
652
+ const outcome = installOutcomes.get(skillName) ?? { version: null, error: "install result missing" };
653
+ const skillState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
654
+ if (outcome.error) {
655
+ skillState.consecutiveFailures += 1;
656
+ skillState.lastFailureAt = new Date().toISOString();
657
+ skillState.lastFailureReason = outcome.error;
658
+ this.state.skills[skillName] = skillState;
659
+ return { success: false, error: outcome.error };
660
+ }
661
+ this.recordSuccess(skillState, outcome.version);
662
+ this.state.skills[skillName] = skillState;
663
+ return { success: true, version: outcome.version };
664
+ }
665
+ /**
666
+ * Apply a single skill update (used by the manual `updateSkill` RPC path).
667
+ * Returns the installed version.
668
+ *
669
+ * Delegates to {@link cloneAndInstallMany} so the single-skill and batch
670
+ * paths share one implementation of clone+install. The per-skill install
671
+ * error is re-thrown here so `updateSkill` can surface it to the RPC
672
+ * caller via a rejected promise (batch path captures it instead).
673
+ */
674
+ async applyUpdate(skillName, remoteVersion) {
675
+ const { logger } = this.options;
676
+ logger.info(`[senpi-skills-manager] applyUpdate — starting | skill=${skillName} targetVersion=${remoteVersion ?? "unknown"}`);
677
+ // Clone can throw (full repo fetch failed). Unlike the batch path — which
678
+ // intentionally doesn't attribute a shared clone failure to individual
679
+ // skills — the single-skill path has an unambiguous owner, so we record
680
+ // the clone failure against this skill's failure counters before
681
+ // re-throwing to the RPC caller.
682
+ let installOutcomes;
683
+ try {
684
+ installOutcomes = await this.cloneAndInstallMany([skillName]);
685
+ }
686
+ catch (err) {
687
+ const errorMessage = `clone failed: ${formatErr(err)}`;
688
+ const skillState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
689
+ skillState.consecutiveFailures += 1;
690
+ const now = new Date().toISOString();
691
+ skillState.lastFailureAt = now;
692
+ skillState.lastFailureReason = errorMessage;
693
+ this.state.skills[skillName] = skillState;
694
+ await this.persistState();
695
+ logger.warn(`[senpi-skills-manager] applyUpdate — ${errorMessage} | skill=${skillName}`);
696
+ throw err;
697
+ }
698
+ // Delegate outcome bookkeeping (success or install-failure) to the shared
699
+ // helper so the single-skill path records failure state identically to
700
+ // the batch path — previously this path threw raw without ever touching
701
+ // consecutiveFailures / lastFailureAt / lastFailureReason, so manual
702
+ // updateSkill install failures were silently invisible to the snapshot.
703
+ const processed = this.processInstallOutcome(skillName, installOutcomes);
704
+ await this.persistState();
705
+ if (!processed.success) {
706
+ logger.warn(`[senpi-skills-manager] applyUpdate — install failed | skill=${skillName} err=${processed.error}`);
707
+ throw new Error(processed.error);
708
+ }
709
+ const installedVersion = processed.version ?? remoteVersion ?? null;
710
+ logger.info(`[senpi-skills-manager] applyUpdate complete | skill=${skillName} version=${installedVersion ?? "unknown"}`);
711
+ return installedVersion;
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
+ /**
916
+ * Return the version-check function for this coordinator: either the
917
+ * injected `deps.checkSkillVersion` (unit tests) or the real
918
+ * `checkSkillVersion` with the default `fetchImpl`.
919
+ *
920
+ * Extracted so the three call sites (`updateSkill`, `updateAll`, `runTick`)
921
+ * don't repeat the same resolve-and-wrap lambda — a change to the real
922
+ * `checkSkillVersion` signature now only has to land here.
923
+ */
924
+ resolveCheckFn() {
925
+ return (this.options.deps?.checkSkillVersion ??
926
+ ((skillsDirs, skillName, rawBaseUrl, logger) => checkSkillVersion(skillsDirs, skillName, rawBaseUrl, undefined, logger)));
927
+ }
928
+ /**
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.
937
+ */
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();
970
+ }
971
+ /**
972
+ * Build the point-in-time snapshot returned by {@link getSnapshot}.
973
+ *
974
+ * The snapshot is the single serialisable view of the coordinator's state
975
+ * consumed by gateway RPCs (`senpi.skills.status`) and any health UI. It
976
+ * fuses three data sources:
977
+ * 1. Per-skill persisted state (`this.state.skills`) — installed version,
978
+ * last check time, consecutive failures, last failure reason.
979
+ * 2. In-memory `this.latestVersions` map — the most recent remote version
980
+ * observed during a version check (may not yet be installed).
981
+ * 3. Caller-supplied lifecycle metadata — `health`, `lastAction`,
982
+ * `lastError` describing the most recent tick / manual RPC outcome.
983
+ *
984
+ * This is called at every state-mutating boundary (start, tick complete,
985
+ * tick error, manual update complete, manual update skipped, manual update
986
+ * clone failed, stop/disable) so the next RPC response reflects the latest
987
+ * state rather than stale pre-action data.
988
+ */
989
+ buildSnapshot(health, lastAction, lastError = null) {
990
+ const skillSnapshots = Object.values(this.state.skills).map((skillState) => ({
991
+ name: skillState.name,
992
+ installedVersion: skillState.installedVersion,
993
+ // Prefer the in-memory map (freshest — populated synchronously inside
994
+ // the current tick before `persistState` runs) and fall back to the
995
+ // persisted field. The persisted fallback covers the post-restart
996
+ // window where the map is empty until the first external tick fires.
997
+ latestVersion: this.latestVersions.get(skillState.name) ?? skillState.latestVersion ?? null,
998
+ lastCheckedAt: skillState.lastCheckedAt,
999
+ consecutiveFailures: skillState.consecutiveFailures,
1000
+ lastFailureReason: skillState.lastFailureReason,
1001
+ }));
1002
+ return {
1003
+ enabled: this.options.config.enabled,
1004
+ // Snapshot is a serializable view consumed by gateway RPCs and the CLI;
1005
+ // hand callers a defensive copy so their downstream mutations (e.g.
1006
+ // sorting, push for display) can't poison the live config.
1007
+ skillsDir: [...this.options.config.skillsDir],
1008
+ health,
1009
+ lastAction,
1010
+ lastError,
1011
+ skills: skillSnapshots,
1012
+ };
1013
+ }
1014
+ async persistState() {
1015
+ try {
1016
+ await this.store.save(this.state);
1017
+ }
1018
+ catch (err) {
1019
+ this.options.logger.warn(`[senpi-skills-manager] failed to persist state: ${formatErr(err)}`);
1020
+ }
1021
+ }
1022
+ }
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
+ //# sourceMappingURL=coordinator.js.map