@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.
- package/dist/api/types.d.ts +4 -3
- package/dist/index.js +123 -17
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/web-dist/assets/{addon-fit-CE-3eCvv.js → addon-fit-BXTX0Xqu.js} +1 -1
- package/web-dist/assets/{addon-web-links-DeODgawX.js → addon-web-links-KebkunbU.js} +1 -1
- package/web-dist/assets/{highlighter-DpnM5FfD.js → highlighter-B3HctNDB.js} +1 -1
- package/web-dist/assets/index-BbLm3DrY.css +1 -0
- package/web-dist/assets/{index-gNzir_kH.js → index-DwgTjTHO.js} +5 -5
- package/web-dist/assets/{xterm-BjU-ZsL5.js → xterm-fEUfDV0F.js} +1 -1
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-aRsvImZt.css +0 -1
package/dist/api/types.d.ts
CHANGED
|
@@ -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
|
|
1401
|
-
*
|
|
1402
|
-
* already
|
|
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
|
|
2275
|
-
images: f.recall
|
|
2276
|
-
files: f.recall
|
|
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
|
|
2591
|
-
|
|
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).
|
|
2869
|
-
*
|
|
2870
|
-
*
|
|
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
|
|
2896
|
+
if (!entry || !queued) {
|
|
2877
2897
|
throw new HttpError(
|
|
2878
2898
|
409,
|
|
2879
|
-
"
|
|
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
|
-
|
|
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
|
|
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),
|