@prismshadow/penguin-server 0.2.5 → 0.2.7

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.
@@ -1397,9 +1397,10 @@ interface SubagentMessageResponse {
1397
1397
  * DELETE /api/sessions/:id/follow-ups/:followUpId: the withdrawn message's original content,
1398
1398
  * for the composer to restore into the input box for editing and resending (#287). File
1399
1399
  * attachments are read back from the Session scratchpad (then deleted from it); one that
1400
- * disappeared meanwhile is omitted rather than failing the recall. 409 `not_pending` when
1401
- * the entry is no longer queued — steering already delivered to the model, or a follow-up
1402
- * already auto-started (or unknown id either way).
1400
+ * disappeared meanwhile is omitted rather than failing the recall. 409 when the entry is no
1401
+ * longer queued, with a code per endpoint because the reasons read differently to the user:
1402
+ * `not_pending` — the steering message already reached the model; `follow_up_started` — the
1403
+ * follow-up already auto-started as a task of its own (unknown ids land on the same code).
1403
1404
  */
1404
1405
  interface RecalledMessageResponse {
1405
1406
  text: string;
package/dist/index.js CHANGED
@@ -2271,11 +2271,22 @@ function agentKey(projectId, agentId) {
2271
2271
  function followUpInfo(f) {
2272
2272
  return {
2273
2273
  id: f.id,
2274
- text: f.recall?.text ?? "",
2275
- images: f.recall?.images.length ?? 0,
2276
- files: f.recall?.files.length ?? 0
2274
+ text: f.recall.text,
2275
+ images: f.recall.images.length,
2276
+ files: f.recall.files.length
2277
2277
  };
2278
2278
  }
2279
+ function recallStoreOf(input) {
2280
+ const text = input.filter(isPlainText("user")).map((m) => m.payload.text).join("\n");
2281
+ const images = [];
2282
+ for (const msg of input) {
2283
+ const payload = msg.payload;
2284
+ if (msg.type === "model_msg" && payload.type === "image_url" && typeof payload.image_url === "string") {
2285
+ images.push(payload.image_url);
2286
+ }
2287
+ }
2288
+ return { text, images, files: [] };
2289
+ }
2279
2290
  function isDeliveredSteering(msg) {
2280
2291
  const p = msg.payload;
2281
2292
  if (msg.type !== "model_msg" || p.type !== "text" || p.role !== "user") return false;
@@ -2574,7 +2585,11 @@ var SessionManager = class {
2574
2585
  * With `queueIfBusy`, a busy session (running/compacting) enqueues the input as a
2575
2586
  * follow-up instead of 409: it auto-starts as an ordinary next task once the session
2576
2587
  * returns to idle (`queued: true` in the result; see startQueuedFollowUp), keeping its
2577
- * thinkingLevel for that auto-start.
2588
+ * thinkingLevel for that auto-start. `opts.recall` is that queued message's original
2589
+ * content — optional, because only the HTTP route knows the parts of it the input does
2590
+ * not carry (the pre-attachment text and where each file landed on disk); every other
2591
+ * caller's store is read off the input, so a queued follow-up is recallable and shows
2592
+ * its content whichever path queued it.
2578
2593
  */
2579
2594
  async startTask(sessionId, input, opts) {
2580
2595
  return this.withLock(sessionId, async () => {
@@ -2587,8 +2602,10 @@ var SessionManager = class {
2587
2602
  id: randomUUID2(),
2588
2603
  input,
2589
2604
  ...opts.thinkingLevel !== void 0 ? { thinkingLevel: opts.thinkingLevel } : {},
2590
- // The original content rides the queue so a recall can hand it back (see recallFollowUp).
2591
- ...opts.recall !== void 0 ? { recall: opts.recall } : {}
2605
+ // The original content rides the queue so a recall can hand it back, and so the
2606
+ // queued line shows what is waiting (see recallFollowUp). Callers that know more
2607
+ // than the input carries pass their own; the rest get it read off the input.
2608
+ recall: opts.recall ?? recallStoreOf(input)
2592
2609
  });
2593
2610
  entry.lastActivityMs = Date.now();
2594
2611
  this.publishState(entry, entry.status);
@@ -2865,18 +2882,21 @@ var SessionManager = class {
2865
2882
  /**
2866
2883
  * Recall a queued follow-up task (#287): remove it from the queue before it auto-starts,
2867
2884
  * re-broadcast state, and hand back its original content (plus the thinking level it was
2868
- * queued with). 409 `not_pending` when the id is not in the queue — it already
2869
- * auto-started (or the runtime was evicted/restarted, which empties the queue) — or the
2870
- * entry has no recall store to give back.
2885
+ * queued with). Being in the queue is the whole condition — every queued entry carries a
2886
+ * recall store (see QueuedFollowUp.recall), so no queued message is ever refused. 409
2887
+ * `follow_up_started` when the id is not in the queue: it already auto-started, or the
2888
+ * runtime was released and took the queue with it. Its own code, distinct from steering's
2889
+ * `not_pending`, because the two mean different things to the user — a follow-up became a
2890
+ * task of its own, a steering message reached the model mid-run.
2871
2891
  */
2872
2892
  recallFollowUp(sessionId, followUpId) {
2873
2893
  const entry = this.entries.get(sessionId);
2874
2894
  const i = entry?.followUps.findIndex((f) => f.id === followUpId) ?? -1;
2875
2895
  const queued = i >= 0 ? entry.followUps[i] : void 0;
2876
- if (!entry || !queued?.recall) {
2896
+ if (!entry || !queued) {
2877
2897
  throw new HttpError(
2878
2898
  409,
2879
- "not_pending",
2899
+ "follow_up_started",
2880
2900
  "This follow-up message already started and can no longer be recalled."
2881
2901
  );
2882
2902
  }
@@ -4085,6 +4105,26 @@ var MESSAGING_TEXT_CHUNK_CHARS = 4e3;
4085
4105
  var MESSAGING_TEXT_ONLY_NOTICE = "Only text messages are supported for now. \u76EE\u524D\u4EC5\u652F\u6301\u6587\u672C\u6D88\u606F\u3002";
4086
4106
  var MESSAGING_APPROVAL_NOTICE = "A tool call is waiting for your approval in the PenguinHarness web UI. \u6709\u5DE5\u5177\u8C03\u7528\u6B63\u5728\u7B49\u5F85\u4F60\u5728\u7F51\u9875\u7AEF\u5BA1\u6279\u3002";
4087
4107
  var MESSAGING_TEST_MESSAGE = "PenguinHarness test message: this Session's messaging binding works. \u6D4B\u8BD5\u6D88\u606F\uFF1A\u8BE5\u4F1A\u8BDD\u7684\u6D88\u606F\u7ED1\u5B9A\u5DE5\u4F5C\u6B63\u5E38\u3002";
4108
+ var MESSAGING_INBOUND_DEDUPE_SIZE = 64;
4109
+ var MESSAGING_INBOUND_DEDUPE_WINDOW_MS = 10 * 6e4;
4110
+ var RecentInboundIds = class {
4111
+ seen = /* @__PURE__ */ new Map();
4112
+ /** True when this id was already processed inside the window; records it otherwise. */
4113
+ check(messageId, now) {
4114
+ const firstSeen = this.seen.get(messageId);
4115
+ if (firstSeen !== void 0 && now - firstSeen <= MESSAGING_INBOUND_DEDUPE_WINDOW_MS) {
4116
+ return true;
4117
+ }
4118
+ this.seen.delete(messageId);
4119
+ this.seen.set(messageId, now);
4120
+ while (this.seen.size > MESSAGING_INBOUND_DEDUPE_SIZE) {
4121
+ const oldest = this.seen.keys().next();
4122
+ if (oldest.done === true) break;
4123
+ this.seen.delete(oldest.value);
4124
+ }
4125
+ return false;
4126
+ }
4127
+ };
4088
4128
  function chunkMessagingText(text, max = MESSAGING_TEXT_CHUNK_CHARS) {
4089
4129
  const out = [];
4090
4130
  let rest = text;
@@ -4098,6 +4138,9 @@ function chunkMessagingText(text, max = MESSAGING_TEXT_CHUNK_CHARS) {
4098
4138
  if (rest.length > 0) out.push(rest);
4099
4139
  return out;
4100
4140
  }
4141
+ function inboundKey(sessionId, channel) {
4142
+ return `${sessionId}:${channel}`;
4143
+ }
4101
4144
  var MessagingBridge = class {
4102
4145
  constructor(deps) {
4103
4146
  this.deps = deps;
@@ -4108,6 +4151,16 @@ var MessagingBridge = class {
4108
4151
  }
4109
4152
  deps;
4110
4153
  entries = /* @__PURE__ */ new Map();
4154
+ /**
4155
+ * Processed inbound ids per BINDING (`sessionId:channel`), deliberately not per
4156
+ * connection: re-saving an enabled binding restarts its connector, and a message
4157
+ * already turned into a Task must not run again because the connector is new. In memory
4158
+ * only — a server restart forgets, so a redelivery that survives a restart still
4159
+ * duplicates. Accepted: a channel replays within seconds of its own reconnect, and
4160
+ * persisting this would put a write on every inbound message to close a window the
4161
+ * restart itself has almost always already closed.
4162
+ */
4163
+ recentInbound = /* @__PURE__ */ new Map();
4111
4164
  connectors;
4112
4165
  now;
4113
4166
  log;
@@ -4147,11 +4200,15 @@ var MessagingBridge = class {
4147
4200
  /** Route DELETE: disconnect (when this channel holds the connection) and drop the row. No-op when unbound. */
4148
4201
  unbind(sessionId, channel) {
4149
4202
  if (this.entries.get(sessionId)?.channel === channel) this.disconnect(sessionId);
4203
+ this.recentInbound.delete(inboundKey(sessionId, channel));
4150
4204
  this.deps.repo.delete(sessionId, channel);
4151
4205
  }
4152
4206
  /** Session-delete cascade: disconnect and drop every channel's config. */
4153
4207
  unbindSession(sessionId) {
4154
4208
  this.disconnect(sessionId);
4209
+ for (const key of [...this.recentInbound.keys()]) {
4210
+ if (key.startsWith(`${sessionId}:`)) this.recentInbound.delete(key);
4211
+ }
4155
4212
  this.deps.repo.deleteSession(sessionId);
4156
4213
  }
4157
4214
  /** One channel's runtime status (only the connected channel is ever anything but disconnected). */
@@ -4272,8 +4329,32 @@ var MessagingBridge = class {
4272
4329
  return client;
4273
4330
  }
4274
4331
  // —— Inbound ——————————————————————————————————————————————————————————————
4332
+ /**
4333
+ * Has this binding already processed this message? A duplicate is a complete no-op —
4334
+ * ahead of the chat record and the text-only reply, not just the Task start, because a
4335
+ * replayed sticker would otherwise answer with the notice twice.
4336
+ *
4337
+ * Nothing downstream is idempotent: `startTask` with `queueIfBusy` appends to the
4338
+ * follow-up queue unconditionally, and a queued input is published to the Session
4339
+ * channel, so one redelivery becomes two runs in the chat AND two messages in the Web
4340
+ * App. A connector that mints no id opts out rather than having every message after the
4341
+ * first read as a duplicate.
4342
+ */
4343
+ isRedelivery(entry, msg) {
4344
+ if (msg.messageId === "") return false;
4345
+ const key = inboundKey(entry.sessionId, entry.channel);
4346
+ let recent = this.recentInbound.get(key);
4347
+ if (recent === void 0) {
4348
+ recent = new RecentInboundIds();
4349
+ this.recentInbound.set(key, recent);
4350
+ }
4351
+ if (!recent.check(msg.messageId, this.now())) return false;
4352
+ this.log(`[messaging] ${entry.channel} redelivered message ${msg.messageId}, ignored`);
4353
+ return true;
4354
+ }
4275
4355
  async onInbound(entry, msg) {
4276
4356
  if (this.entries.get(entry.sessionId) !== entry) return;
4357
+ if (this.isRedelivery(entry, msg)) return;
4277
4358
  try {
4278
4359
  const isDirect = msg.chatKind === "direct";
4279
4360
  this.deps.repo.recordChat(entry.sessionId, entry.channel, msg.chatId, isDirect);
@@ -4632,6 +4713,7 @@ var TelegramConnector = class {
4632
4713
  }
4633
4714
  async poll(bot, handlers, signal, isClosed) {
4634
4715
  let ready = false;
4716
+ let webhookChecked = false;
4635
4717
  let drained = false;
4636
4718
  let failures = 0;
4637
4719
  let offset;
@@ -4640,6 +4722,16 @@ var TelegramConnector = class {
4640
4722
  if (!ready) {
4641
4723
  await bot.getMe();
4642
4724
  if (isClosed()) return;
4725
+ if (!webhookChecked) {
4726
+ const { url } = await bot.getWebhookInfo();
4727
+ if (isClosed()) return;
4728
+ if (url !== "") {
4729
+ throw new Error(
4730
+ `a webhook is registered on this bot at ${url}, which blocks polling \u2014 remove it there and the connection recovers on its own`
4731
+ );
4732
+ }
4733
+ webhookChecked = true;
4734
+ }
4643
4735
  if (!drained) {
4644
4736
  const backlog = await bot.getUpdates({ offset: -1, timeoutSec: 0, signal });
4645
4737
  if (isClosed()) return;
@@ -4647,18 +4739,21 @@ var TelegramConnector = class {
4647
4739
  if (newest !== void 0) offset = newest.update_id + 1;
4648
4740
  drained = true;
4649
4741
  }
4650
- ready = true;
4651
- failures = 0;
4652
- handlers.onReady?.();
4653
- continue;
4654
4742
  }
4655
4743
  const updates = await bot.getUpdates({
4656
4744
  ...offset !== void 0 ? { offset } : {},
4657
- timeoutSec: POLL_TIMEOUT_SEC,
4745
+ // Recovery polls must answer immediately — the outage is only over once a
4746
+ // `getUpdates` comes back, and parking for the long-poll window would hide that
4747
+ // for another 30s. Steady state parks as usual.
4748
+ timeoutSec: ready ? POLL_TIMEOUT_SEC : 0,
4658
4749
  signal
4659
4750
  });
4660
4751
  if (isClosed()) return;
4661
4752
  failures = 0;
4753
+ if (!ready) {
4754
+ ready = true;
4755
+ handlers.onReady?.();
4756
+ }
4662
4757
  for (const update of updates) {
4663
4758
  offset = update.update_id + 1;
4664
4759
  const msg = normalizeUpdate(update);
@@ -4678,6 +4773,15 @@ var TelegramConnector = class {
4678
4773
  // src/runtime/messaging/telegram-api.ts
4679
4774
  var TELEGRAM_API_BASE = "https://api.telegram.org";
4680
4775
  var CALL_TIMEOUT_MS = 15e3;
4776
+ function conflictText(description) {
4777
+ if (/terminated by other getUpdates/i.test(description)) {
4778
+ return "another program is already polling this bot \u2014 one bot token can serve only one PenguinHarness server at a time";
4779
+ }
4780
+ if (/webhook is active/i.test(description)) {
4781
+ return "a webhook is set on this bot, which blocks polling \u2014 remove it and the connection recovers on its own";
4782
+ }
4783
+ return null;
4784
+ }
4681
4785
  function fetchErrorText(err) {
4682
4786
  if (err instanceof Error) {
4683
4787
  if (err.name === "TimeoutError" || err.name === "AbortError") return "request timed out";
@@ -4706,7 +4810,8 @@ function createTelegramTransport() {
4706
4810
  }
4707
4811
  const body = await res.json().catch(() => null);
4708
4812
  if (body === null || body.ok !== true) {
4709
- const detail = body?.description ?? `HTTP ${res.status}`;
4813
+ const described = body?.description;
4814
+ const detail = (described !== void 0 ? conflictText(described) : null) ?? described ?? `HTTP ${res.status}`;
4710
4815
  const code = body?.error_code !== void 0 ? ` (code ${body.error_code})` : "";
4711
4816
  throw new Error(`${method} failed: ${detail}${code}`);
4712
4817
  }
@@ -4718,6 +4823,7 @@ function createTelegramTransport() {
4718
4823
  };
4719
4824
  return {
4720
4825
  getMe: () => call("getMe", {}),
4826
+ getWebhookInfo: () => call("getWebhookInfo", {}),
4721
4827
  async sendMessage({ chatId, text, replyToMessageId }) {
4722
4828
  await call("sendMessage", {
4723
4829
  chat_id: wireChatId(chatId),