@songsid/agend 2.1.4-beta.21 → 2.1.4-beta.22

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.
@@ -58,6 +58,8 @@ export interface FleetContext {
58
58
  getDeliveryEpoch?(name: string): number;
59
59
  /** Subscription provider IDs used by running or paused fleet/Classic instances. */
60
60
  getActiveUsageProviderIds?(): ReadonlySet<string>;
61
+ /** Effective backend IDs used by running or paused fleet/Classic instances. */
62
+ getActiveBackendIds?(): ReadonlySet<string>;
61
63
  toggleFleetCollab(instanceName: string): boolean;
62
64
  /** Apply a model to an instance (runtime paste or persist + restart). Returns a status string. */
63
65
  applyModel(instanceName: string, model: string): Promise<string>;
@@ -289,6 +289,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
289
289
  * Classic channels use their effective backend merge chain, not the raw row.
290
290
  */
291
291
  getActiveUsageProviderIds(): ReadonlySet<string>;
292
+ /** Effective backends with a running or persisted-paused fleet/Classic instance. */
293
+ getActiveBackendIds(): ReadonlySet<string>;
292
294
  isClassicInstance(name: string): boolean;
293
295
  private cacheInstanceExecutionState;
294
296
  private cancelIdleButtonRetirement;
@@ -600,13 +602,14 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
600
602
  */
601
603
  private postNonceButtonPrompt;
602
604
  /**
603
- * Claim a nonce-armed callback exactly once.
605
+ * Validate a nonce-armed callback and optionally claim it exactly once.
604
606
  *
605
607
  * Returns:
606
608
  * - null — the callback is not for this prefix; try the next handler
607
609
  * - "consumed" — for this prefix but stale/denied/malformed; stop dispatch
608
- * - the entry+action — the click is authorized and now claimed (removed
609
- * from the map before any await, so double clicks cannot act twice)
610
+ * - the entry+action — the click is authorized; consuming actions are
611
+ * claimed before any await, while explicitly non-consuming actions keep
612
+ * the prompt available for a later decision
610
613
  *
611
614
  * A stale click (expired nonce, or a pre-upgrade button whose payload no
612
615
  * longer parses) collapses the clicked message so the dead button stops
@@ -56,7 +56,7 @@ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
56
56
  import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
57
57
  import { RestartProgress } from "./restart-progress.js";
58
58
  import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
59
- import { canUnlockAdvancedTips, DailyTipScheduler, selectTip } from "./tips.js";
59
+ import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
60
60
  import { getTmuxSession } from "./config.js";
61
61
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
62
62
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -835,13 +835,22 @@ export class FleetManager {
835
835
  */
836
836
  getActiveUsageProviderIds() {
837
837
  const providers = new Set();
838
+ for (const backend of this.getActiveBackendIds()) {
839
+ const provider = usageProviderIdForBackend(backend);
840
+ if (provider)
841
+ providers.add(provider);
842
+ }
843
+ return providers;
844
+ }
845
+ /** Effective backends with a running or persisted-paused fleet/Classic instance. */
846
+ getActiveBackendIds() {
847
+ const backends = new Set();
838
848
  const add = (name, backend) => {
839
849
  const status = this.getInstanceStatus(name);
840
850
  if (status !== "running" && status !== "paused")
841
851
  return;
842
- const provider = usageProviderIdForBackend(backend);
843
- if (provider)
844
- providers.add(provider);
852
+ if (backend)
853
+ backends.add(backend);
845
854
  };
846
855
  for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
847
856
  // loadFleetConfig() has already merged the fleet default into each row.
@@ -850,7 +859,7 @@ export class FleetManager {
850
859
  for (const channel of this.classicChannels?.getAll() ?? []) {
851
860
  add(channel.instanceName, this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend));
852
861
  }
853
- return providers;
862
+ return backends;
854
863
  }
855
864
  isClassicInstance(name) {
856
865
  return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
@@ -5039,19 +5048,20 @@ export class FleetManager {
5039
5048
  }
5040
5049
  }
5041
5050
  /**
5042
- * Claim a nonce-armed callback exactly once.
5051
+ * Validate a nonce-armed callback and optionally claim it exactly once.
5043
5052
  *
5044
5053
  * Returns:
5045
5054
  * - null — the callback is not for this prefix; try the next handler
5046
5055
  * - "consumed" — for this prefix but stale/denied/malformed; stop dispatch
5047
- * - the entry+action — the click is authorized and now claimed (removed
5048
- * from the map before any await, so double clicks cannot act twice)
5056
+ * - the entry+action — the click is authorized; consuming actions are
5057
+ * claimed before any await, while explicitly non-consuming actions keep
5058
+ * the prompt available for a later decision
5049
5059
  *
5050
5060
  * A stale click (expired nonce, or a pre-upgrade button whose payload no
5051
5061
  * longer parses) collapses the clicked message so the dead button stops
5052
5062
  * inviting clicks — the same courtesy the cancel button extends.
5053
5063
  */
5054
- consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter) {
5064
+ consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter, consume = true) {
5055
5065
  if (!data.callbackData.startsWith(prefix))
5056
5066
  return null;
5057
5067
  const match = data.callbackData.match(actionRe);
@@ -5085,11 +5095,13 @@ export class FleetManager {
5085
5095
  this.logger.warn({ instanceName: pending.instanceName, prefix, userId: data.userId }, "Rejected unauthorized or mismatched button callback");
5086
5096
  return "consumed";
5087
5097
  }
5088
- // Claim before any await: double clicks and duplicate callback delivery can
5089
- // never act twice.
5090
- this.pendingNonceButtons.delete(match[1]);
5091
- if (pending.timer)
5092
- clearTimeout(pending.timer);
5098
+ if (consume) {
5099
+ // Claim before any await: double clicks and duplicate callback delivery
5100
+ // can never act twice for state-changing actions.
5101
+ this.pendingNonceButtons.delete(match[1]);
5102
+ if (pending.timer)
5103
+ clearTimeout(pending.timer);
5104
+ }
5093
5105
  return { entry: pending, action: match[2] };
5094
5106
  }
5095
5107
  /** Collapse a consumed prompt's buttons into a final status line. */
@@ -5128,7 +5140,9 @@ export class FleetManager {
5128
5140
  }
5129
5141
  pickAvailableTip() {
5130
5142
  const state = this.readTipState();
5131
- return state ? selectTip(state.dismissed, Math.random, state.advancedUnlocked) : null;
5143
+ return state
5144
+ ? selectTip(state.dismissed, Math.random, state.advancedUnlocked, this.getActiveBackendIds())
5145
+ : null;
5132
5146
  }
5133
5147
  tipText(tip) {
5134
5148
  return getLocale() === "zh-TW" ? tip.text_zh : tip.text_en;
@@ -5158,12 +5172,14 @@ export class FleetManager {
5158
5172
  const state = this.readTipState();
5159
5173
  if (!state)
5160
5174
  return "unavailable";
5161
- if (!state.advancedUnlocked && canUnlockAdvancedTips(state.dismissed)) {
5175
+ if (!state.advancedUnlocked
5176
+ && visibleTipLevels(true).has("advanced")
5177
+ && canUnlockAdvancedTips(state.dismissed)) {
5162
5178
  return await this.promptAdvancedTipUnlock(generalName, adapter, chatId, threadId)
5163
5179
  ? "posted"
5164
5180
  : "unavailable";
5165
5181
  }
5166
- const tip = selectTip(state.dismissed, Math.random, state.advancedUnlocked);
5182
+ const tip = selectTip(state.dismissed, Math.random, state.advancedUnlocked, this.getActiveBackendIds());
5167
5183
  if (!tip)
5168
5184
  return this.scheduler ? "empty" : "unavailable";
5169
5185
  const nonce = await this.postNonceButtonPrompt({
@@ -5175,7 +5191,10 @@ export class FleetManager {
5175
5191
  chatId,
5176
5192
  threadId,
5177
5193
  message: this.formatTip(tip),
5178
- choices: [{ action: "dismiss", label: t("tips.dismiss") }],
5194
+ choices: [
5195
+ { action: "dismiss", label: t("tips.dismiss") },
5196
+ { action: "confused", label: t("tips.confused") },
5197
+ ],
5179
5198
  // Expiry removes only the stale button; the useful tip remains readable.
5180
5199
  expiredText: this.formatTip(tip),
5181
5200
  extra: { allowAnyUser: true, tipId: tip.id },
@@ -5217,12 +5236,40 @@ export class FleetManager {
5217
5236
  }
5218
5237
  }
5219
5238
  async handleTipDismiss(data, callbackAdapterId, receivingAdapter) {
5220
- const claimed = this.consumeNonceCallback(TIP_DISMISS_CALLBACK_PREFIX, /^tip-dismiss:([0-9a-f]+):(dismiss)$/, data, callbackAdapterId, receivingAdapter);
5239
+ const isConfused = data.callbackData.endsWith(":confused");
5240
+ const claimed = this.consumeNonceCallback(TIP_DISMISS_CALLBACK_PREFIX, /^tip-dismiss:([0-9a-f]+):(dismiss|confused)$/, data, callbackAdapterId, receivingAdapter, !isConfused);
5221
5241
  if (claimed === null)
5222
5242
  return false;
5223
5243
  if (claimed === "consumed")
5224
5244
  return true;
5225
5245
  const { entry: pending } = claimed;
5246
+ if (claimed.action === "confused") {
5247
+ if (!pending.tipId || !data.userId || !this.scheduler) {
5248
+ this.logger.error({ tipId: pending.tipId, userId: data.userId }, "Tip feedback has incomplete persistence context");
5249
+ await pending.adapter.sendText(pending.chatId, t("tips.unavailable"), { threadId: pending.threadId });
5250
+ return true;
5251
+ }
5252
+ try {
5253
+ this.scheduler.db.recordTipFeedback(data.userId, pending.tipId, "confused");
5254
+ }
5255
+ catch (err) {
5256
+ this.logger.warn({ err, tipId: pending.tipId, userId: data.userId }, "Failed to persist tip feedback");
5257
+ await pending.adapter.sendText(pending.chatId, t("tips.unavailable"), { threadId: pending.threadId }).catch(() => { });
5258
+ return true;
5259
+ }
5260
+ this.logger.info({
5261
+ tipId: pending.tipId,
5262
+ userId: data.userId,
5263
+ feedbackType: "confused",
5264
+ }, "Tip feedback recorded");
5265
+ this.eventLog?.insert(pending.instanceName, "tip_feedback", {
5266
+ tipId: pending.tipId,
5267
+ userId: data.userId,
5268
+ feedbackType: "confused",
5269
+ });
5270
+ await pending.adapter.sendText(pending.chatId, t("tips.feedback_recorded"), { threadId: pending.threadId }).catch(err => this.logger.warn({ err, tipId: pending.tipId }, "Failed to acknowledge tip feedback"));
5271
+ return true;
5272
+ }
5226
5273
  if (!pending.tipId || !data.userId || !this.scheduler) {
5227
5274
  this.logger.error({ tipId: pending.tipId, userId: data.userId }, "Tip dismissal has incomplete persistence context");
5228
5275
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));