@blunking/codexlink 0.1.2 → 0.1.10

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.
@@ -1,23 +1,71 @@
1
- import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs";
1
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { listLoadedThreadsOverWs, readThreadOverWs } from "./app-server-client.js";
4
4
  import { loadConfig } from "./env.js";
5
- import { injectIntoThread } from "./codex.js";
6
- import { getUpdates, sendMessage } from "./telegram.js";
5
+ import { injectIntoThread, isAddressOnlyPing } from "./codex.js";
6
+ import { getUpdates, sendChatAction, sendMessage } from "./telegram.js";
7
7
  import { appendJsonl, appendLog, defaultState, loadJson, nowIso, readTail, saveJson } from "./storage.js";
8
8
 
9
9
  function loadState(config) {
10
- return loadJson(config.paths.stateFile, defaultState());
10
+ const state = loadJson(config.paths.stateFile, defaultState());
11
+ return scrubIdleBriefArtifactsInPlace(state);
11
12
  }
12
13
 
13
14
  function saveStateForConfig(config, state) {
14
- saveJson(config.paths.stateFile, state);
15
+ saveJson(config.paths.stateFile, scrubIdleBriefArtifactsInPlace(state));
16
+ }
17
+
18
+ function persistActiveThreadBinding(config, threadId) {
19
+ const value = String(threadId || "").trim();
20
+ if (!value) {
21
+ return;
22
+ }
23
+
24
+ try {
25
+ const envPath = config.paths.envFile;
26
+ const existing = existsSync(envPath) ? readFileSync(envPath, "utf8").split(/\r?\n/) : [];
27
+ let wroteThread = false;
28
+ const lines = existing
29
+ .filter((line, index, all) => index < all.length - 1 || line.trim() !== "")
30
+ .map((line) => {
31
+ if (/^\s*BLUN_TELEGRAM_THREAD_ID\s*=/.test(line)) {
32
+ wroteThread = true;
33
+ return `BLUN_TELEGRAM_THREAD_ID=${value}`;
34
+ }
35
+ return line;
36
+ });
37
+ if (!wroteThread) {
38
+ lines.push(`BLUN_TELEGRAM_THREAD_ID=${value}`);
39
+ }
40
+ writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8");
41
+ } catch {
42
+ // Runtime binding is a self-heal path; dispatch can continue with state.
43
+ }
44
+
45
+ try {
46
+ const runtimePath = config.paths.currentRuntimeFile;
47
+ const runtime = loadJson(runtimePath, null);
48
+ if (runtime && (!config.appServerWsUrl || !runtime.ws_url || String(runtime.ws_url).trim() === String(config.appServerWsUrl).trim())) {
49
+ runtime.thread_id = value;
50
+ saveJson(runtimePath, runtime);
51
+ }
52
+ } catch {
53
+ // Best effort only.
54
+ }
15
55
  }
16
56
 
17
57
  function queueKey(entry) {
18
58
  return `${entry.chatId}:${entry.messageId}`;
19
59
  }
20
60
 
61
+ function hasKnownInboundMessage(state, inbound) {
62
+ const key = queueKey(inbound);
63
+ return [
64
+ ...(state.queue || []),
65
+ ...(state.pendingReplies || [])
66
+ ].some((entry) => queueKey(entry) === key);
67
+ }
68
+
21
69
  function pendingReplyKey(entry) {
22
70
  return entry.turnId || `${entry.threadId || ""}:${entry.chatId}:${entry.messageId}`;
23
71
  }
@@ -32,17 +80,390 @@ function containsToken(text, token) {
32
80
  }
33
81
 
34
82
  function looksLikeEscalation(text) {
35
- const value = String(text || "");
36
- return [
83
+ const value = foldTriggerText(text);
84
+ if (!value) {
85
+ return false;
86
+ }
87
+ if ([
37
88
  "eskalation",
38
89
  "escalation",
39
90
  "urgent",
40
91
  "emergency",
41
- "sofort",
42
92
  "prio 0",
43
93
  "p0",
44
94
  "blocker"
45
- ].some((token) => containsToken(value, token));
95
+ ].some((token) => containsToken(value, token))) {
96
+ return true;
97
+ }
98
+ return containsToken(value, "sofort") && !/\bab sofort\b/u.test(value);
99
+ }
100
+
101
+ const CONTINUE_NEGATIVE_ONLY = new Set([
102
+ "ok",
103
+ "okay",
104
+ "ja",
105
+ "yes",
106
+ "si",
107
+ "oui",
108
+ "passt",
109
+ "gut",
110
+ "nice",
111
+ "cool",
112
+ "danke",
113
+ "thanks",
114
+ "merci",
115
+ "gracias",
116
+ "verstanden",
117
+ "perfekt",
118
+ "super",
119
+ "top",
120
+ "alles klar",
121
+ "sieht gut aus",
122
+ "hort sich gut an",
123
+ "hoert sich gut an"
124
+ ]);
125
+
126
+ const CONTINUE_BLOCK_PATTERNS = [
127
+ /\bweiter so\b/u,
128
+ /^status\b/u,
129
+ /\bexplizites go\b/u,
130
+ /\bohne\b[\s\S]{0,24}\bgo\b/u
131
+ ];
132
+
133
+ const CONTINUE_PATTERN_GROUPS = [
134
+ { weight: 4, patterns: [/\bmach weiter\b/u, /\bleg los\b/u, /\blos geht'?s\b/u, /\bfeuer frei\b/u, /\bsetz(?:e)? es um\b/u, /\bfu(?:eh|h)re es aus\b/u, /\bimplementiere es\b/u, /\bfix das\b/u, /\bfix den fehler\b/u, /\bbeheb(?:e)? das\b/u, /\breparier das\b/u, /\bteste es\b/u, /\bdebugge es\b/u] },
135
+ { weight: 4, patterns: [/\bgo ahead\b/u, /\bcontinue\b/u, /\bkeep going\b/u, /\bexecute it\b/u, /\bimplement it\b/u, /\bfix it\b/u, /\bpatch it\b/u, /\bdebug it\b/u, /\btest it\b/u, /\brun it\b/u, /\bsend it\b/u, /\bship it\b/u, /\bkeep cooking\b/u, /\bfinish the implementation\b/u] },
136
+ { weight: 4, patterns: [/\bdale\b/u, /\bvas y\b/u, /\bfais le\b/u, /\bcontinua\b/u, /\bvai avanti\b/u, /\bvamos\b/u, /\bga door\b/u, /\bkontynuuj\b/u, /\bdevam et\b/u] },
137
+ { weight: 3, patterns: [/\bund weiter\b/u, /\barbeite weiter\b/u, /\bsetz(?:e)? fort\b/u, /\bn(?:ae|a)chster schritt\b/u, /\bmach den n(?:ae|a)chsten schritt\b/u, /\bweiter trotz fehler\b/u, /\bnicht abbrechen\b/u, /\bnochmal versuchen\b/u, /\bbrief f(?:ue|u)r dich\b/u, /\bbrief\b[\s\S]{0,30}\babruf(?:en)?\b/u, /\bbrief\b[\s\S]{0,30}\bpull\b/u] },
138
+ { weight: 3, patterns: [/\bgib gas\b/u, /\bhau rein\b/u, /\bzieh durch\b/u, /\bzieh komplett durch\b/u, /\bnicht quatschen machen\b/u, /\bballer weiter\b/u, /\bmach den rest\b/u, /\bmach alleine weiter\b/u, /\bfull send\b/u, /\bfuck it we ball\b/u, /\byolo\b/u] }
139
+ ];
140
+
141
+ const CONTINUE_ACTION_WORDS = [
142
+ "mach",
143
+ "start",
144
+ "weiter",
145
+ "los",
146
+ "arbeite",
147
+ "setz",
148
+ "setze",
149
+ "fuhre",
150
+ "fuehre",
151
+ "implementiere",
152
+ "fix",
153
+ "teste",
154
+ "debugge",
155
+ "patch",
156
+ "bau",
157
+ "ander",
158
+ "aender",
159
+ "schreib",
160
+ "go",
161
+ "continue",
162
+ "deploy",
163
+ "ship",
164
+ "baller",
165
+ "vollgas",
166
+ "cook",
167
+ "run"
168
+ ];
169
+
170
+ const WORK_CONTEXT_HINTS = [
171
+ "auth",
172
+ "middleware",
173
+ "datei",
174
+ "file",
175
+ "code",
176
+ "anderung",
177
+ "aenderung",
178
+ "brief",
179
+ "abruf",
180
+ "pull",
181
+ "commit",
182
+ "test",
183
+ "debug",
184
+ "fehler",
185
+ "bug",
186
+ "fix",
187
+ "patch",
188
+ "implement",
189
+ "umsetzen",
190
+ "refactor",
191
+ "deploy",
192
+ "ui",
193
+ "portal",
194
+ "gruppe",
195
+ "konsole",
196
+ "plugin"
197
+ ];
198
+
199
+ function normalizeTriggerText(text) {
200
+ return String(text || "")
201
+ .normalize("NFKC")
202
+ .toLowerCase()
203
+ .replace(/[’']/g, "'")
204
+ .replace(/[“”„"]/g, "\"")
205
+ .replace(/[–—]/g, "-")
206
+ .replace(/[^\p{L}\p{N}@_"'-]+/gu, " ")
207
+ .replace(/\s+/g, " ")
208
+ .trim();
209
+ }
210
+
211
+ function foldTriggerText(text) {
212
+ return normalizeTriggerText(text)
213
+ .normalize("NFD")
214
+ .replace(/\p{Diacritic}+/gu, "");
215
+ }
216
+
217
+ function tokenCount(text) {
218
+ if (!text) {
219
+ return 0;
220
+ }
221
+ return text.split(/\s+/).filter(Boolean).length;
222
+ }
223
+
224
+ function looksLikeWorkContextText(text) {
225
+ const normalized = foldTriggerText(text);
226
+ if (!normalized || CONTINUE_NEGATIVE_ONLY.has(normalized)) {
227
+ return false;
228
+ }
229
+ return WORK_CONTEXT_HINTS.some((token) => containsToken(normalized, token));
230
+ }
231
+
232
+ function hasRecentWorkContext(context = {}) {
233
+ const currentConversationKey = String(context.conversationKey || "").trim();
234
+ const recentEntries = Array.isArray(context.recentEntries) ? context.recentEntries : [];
235
+ if (context.hasRecentWorkContext === true) {
236
+ return true;
237
+ }
238
+ if (context.hasPendingReplies) {
239
+ return true;
240
+ }
241
+ if (recentEntries.some((entry) => String(entry.intent || "").trim().toLowerCase() !== "continue_nudge")) {
242
+ return true;
243
+ }
244
+
245
+ if (currentConversationKey) {
246
+ const recentConversationEntries = recentEntries.filter((entry) => String(entry.conversationKey || "").trim() === currentConversationKey);
247
+ if (recentConversationEntries.some((entry) => looksLikeWorkContextText(entry.text || entry.sourceText || ""))) {
248
+ return true;
249
+ }
250
+ }
251
+
252
+ if (looksLikeWorkContextText(context.lastUserWorkText || "")) {
253
+ return true;
254
+ }
255
+
256
+ return false;
257
+ }
258
+
259
+ function getContinueTriggerScore(text, context = {}) {
260
+ const normalized = foldTriggerText(text);
261
+ if (!normalized) {
262
+ return 0;
263
+ }
264
+ if (CONTINUE_NEGATIVE_ONLY.has(normalized)) {
265
+ return 0;
266
+ }
267
+ if (looksLikeStatusBroadcast(normalized)) {
268
+ return 0;
269
+ }
270
+
271
+ const words = tokenCount(normalized);
272
+ let score = 0;
273
+
274
+ for (const pattern of CONTINUE_BLOCK_PATTERNS) {
275
+ if (pattern.test(normalized)) {
276
+ score -= 2;
277
+ }
278
+ }
279
+
280
+ for (const group of CONTINUE_PATTERN_GROUPS) {
281
+ for (const pattern of group.patterns) {
282
+ if (pattern.test(normalized)) {
283
+ score += group.weight;
284
+ }
285
+ }
286
+ }
287
+
288
+ for (const actionWord of CONTINUE_ACTION_WORDS) {
289
+ if (containsToken(normalized, actionWord)) {
290
+ score += 1;
291
+ }
292
+ }
293
+
294
+ if (words > 0 && words <= 4 && score > 0) {
295
+ score += 1;
296
+ }
297
+
298
+ if (hasRecentWorkContext(context) && score > 0) {
299
+ score += 1;
300
+ }
301
+
302
+ if (/[!?]/.test(String(text || "")) && score < 3) {
303
+ score -= 1;
304
+ }
305
+
306
+ if (words > 12 && score < 3) {
307
+ score = Math.min(score, 1);
308
+ }
309
+
310
+ return Math.max(0, score);
311
+ }
312
+
313
+ function looksLikeContinueNudge(text, context = {}) {
314
+ const score = getContinueTriggerScore(text, context);
315
+ if (score >= 3) {
316
+ return true;
317
+ }
318
+ return score >= 1 && hasRecentWorkContext(context);
319
+ }
320
+
321
+ function looksLikeAckOnly(text) {
322
+ const value = String(text || "").trim().toLowerCase();
323
+ if (!value) {
324
+ return true;
325
+ }
326
+ if (value.length > 220) {
327
+ return false;
328
+ }
329
+ return [
330
+ "ok",
331
+ "okay",
332
+ "ja",
333
+ "verstanden",
334
+ "alles klar",
335
+ "mache ich",
336
+ "ich arbeite weiter",
337
+ "ich mache weiter",
338
+ "ich bin dran",
339
+ "ich bin da",
340
+ "weiter",
341
+ "alles klar, ich mache weiter",
342
+ "verstanden. ich arbeite weiter."
343
+ ].includes(value);
344
+ }
345
+
346
+ function looksLikeContextRequestOnly(text) {
347
+ const value = String(text || "").trim().toLowerCase();
348
+ if (!value || value.length > 280) {
349
+ return false;
350
+ }
351
+ return [
352
+ "mir fehlt in diesem chat gerade der konkrete arbeitskontext. schick mir bitte den letzten stand oder die aufgabe kurz hier rein, dann setze ich direkt fort.",
353
+ "schick mir kurz den letzten stand oder den punkt, ab dem ich anknüpfen soll.",
354
+ "schick mir bitte den letzten stand.",
355
+ "mir fehlt gerade der konkrete arbeitskontext.",
356
+ "welcher punkt genau?",
357
+ "womit genau soll ich weitermachen?"
358
+ ].includes(value);
359
+ }
360
+
361
+ function uniqueAgentMentionNames(config) {
362
+ const values = [
363
+ ...(Array.isArray(config.mentionNames) ? config.mentionNames : []),
364
+ config.agentName
365
+ ];
366
+ return Array.from(new Set(
367
+ values
368
+ .map((value) => foldTriggerText(value))
369
+ .filter((value) => value && value !== "default")
370
+ ));
371
+ }
372
+
373
+ function uniqueOtherAgentMentionNames(config) {
374
+ const ownNames = new Set(uniqueAgentMentionNames(config));
375
+ const values = Array.isArray(config.otherAgentNames) ? config.otherAgentNames : [];
376
+ return Array.from(new Set(
377
+ values
378
+ .map((value) => foldTriggerText(value))
379
+ .filter((value) => value && value !== "default" && !ownNames.has(value))
380
+ ));
381
+ }
382
+
383
+ function escapeRegExp(value) {
384
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
385
+ }
386
+
387
+ function looksLikeStatusBroadcast(text) {
388
+ const normalized = foldTriggerText(text);
389
+ if (/^status(?:\s|$|[~:.-])/u.test(normalized)) {
390
+ return true;
391
+ }
392
+ return /^[a-z][a-z0-9_-]{1,24}\s+\d{1,2}:\d{2}\b/u.test(normalized);
393
+ }
394
+
395
+ function isAgentAddressed(config, text) {
396
+ const normalized = foldTriggerText(text);
397
+ if (!normalized) {
398
+ return false;
399
+ }
400
+
401
+ const mentionNames = uniqueAgentMentionNames(config);
402
+ if (mentionNames.length === 0) {
403
+ return false;
404
+ }
405
+
406
+ for (const name of mentionNames) {
407
+ const mention = escapeRegExp(name);
408
+ const startsAddressed = new RegExp(`^@?${mention}\\b(?:\\s|\\s*[-:,]|$)`, "u").test(normalized);
409
+ if (startsAddressed) {
410
+ return true;
411
+ }
412
+
413
+ const routedToAgent = new RegExp(`\\b(?:fuer|fur|for|an|to)\\s+@?${mention}\\b`, "u").test(normalized);
414
+ if (routedToAgent) {
415
+ return true;
416
+ }
417
+
418
+ const briefDirective = new RegExp(`\\bbrief\\b[\\s\\S]{0,60}\\b@?${mention}\\b|\\b@?${mention}\\b[\\s\\S]{0,60}\\bbrief\\b`, "u").test(normalized);
419
+ if (briefDirective) {
420
+ return true;
421
+ }
422
+
423
+ const imperativeAfterMention = new RegExp(`\\b@?${mention}\\b\\s+(?:bitte|please|pull|abruf|abrufen|zieh|hol|hole|pruef|pruf|teste|debugge|fix|patch|mach|setz|starte|antwort|melde)\\b`, "u").test(normalized);
424
+ if (imperativeAfterMention) {
425
+ return true;
426
+ }
427
+ }
428
+
429
+ return false;
430
+ }
431
+
432
+ function isOtherAgentAddressed(config, text) {
433
+ const normalized = foldTriggerText(text);
434
+ if (!normalized) {
435
+ return false;
436
+ }
437
+
438
+ const mentionNames = uniqueOtherAgentMentionNames(config);
439
+ if (mentionNames.length === 0) {
440
+ return false;
441
+ }
442
+
443
+ for (const name of mentionNames) {
444
+ const mention = escapeRegExp(name);
445
+ const startsAddressed = new RegExp(`^@?${mention}\\b(?:\\s|\\s*[-:,]|$)`, "u").test(normalized);
446
+ if (startsAddressed) {
447
+ return true;
448
+ }
449
+
450
+ const routedToAgent = new RegExp(`\\b(?:fuer|fur|for|an|to)\\s+@?${mention}\\b`, "u").test(normalized);
451
+ if (routedToAgent) {
452
+ return true;
453
+ }
454
+
455
+ const briefDirective = new RegExp(`\\bbrief\\b[\\s\\S]{0,60}\\b@?${mention}\\b|\\b@?${mention}\\b[\\s\\S]{0,60}\\bbrief\\b`, "u").test(normalized);
456
+ if (briefDirective) {
457
+ return true;
458
+ }
459
+
460
+ const workDirective = new RegExp(`\\b@?${mention}\\b[\\s\\S]{0,80}\\b(?:bitte|please|weiter|continue|mach|pull|abruf|abrufen|zieh|hol|hole|pruef|pruf|teste|debugge|fix|patch|setz|starte|antwort|melde|uebersetz|ubersetz|translate)\\b|\\b(?:weiter|continue|mach|pull|abruf|abrufen|zieh|hol|hole|pruef|pruf|teste|debugge|fix|patch|setz|starte|antwort|melde|uebersetz|ubersetz|translate)\\b[\\s\\S]{0,80}\\b@?${mention}\\b`, "u").test(normalized);
461
+ if (workDirective) {
462
+ return true;
463
+ }
464
+ }
465
+
466
+ return false;
46
467
  }
47
468
 
48
469
  function classifyInboundRelevance(config, inbound) {
@@ -55,8 +476,21 @@ function classifyInboundRelevance(config, inbound) {
55
476
  }
56
477
 
57
478
  const text = String(inbound.text || "");
479
+ if (!looksLikeStatusBroadcast(text) && isAgentAddressed(config, text)) {
480
+ return "direct";
481
+ }
482
+
483
+ if (!looksLikeStatusBroadcast(text) && isOtherAgentAddressed(config, text)) {
484
+ return "ambient";
485
+ }
486
+
487
+ const allowedUserIds = Array.isArray(config.allowedChatIds) ? config.allowedChatIds : [];
488
+ if (!inbound.senderIsBot && allowedUserIds.includes(String(inbound.userId || ""))) {
489
+ return "direct";
490
+ }
491
+
58
492
  const agentName = String(config.agentName || "").trim();
59
- if (agentName && agentName.toLowerCase() !== "default" && containsToken(text, agentName)) {
493
+ if (agentName && agentName.toLowerCase() !== "default" && !looksLikeStatusBroadcast(text) && containsToken(text, agentName)) {
60
494
  return "direct";
61
495
  }
62
496
 
@@ -75,6 +509,8 @@ function statusWeight(status) {
75
509
  return 4;
76
510
  case "submitted":
77
511
  return 2;
512
+ case "parked":
513
+ return 0;
78
514
  case "queued":
79
515
  default:
80
516
  return 1;
@@ -114,6 +550,215 @@ function pickLatestRecord(current, incoming) {
114
550
  return incomingId >= currentId ? { ...incoming } : { ...current };
115
551
  }
116
552
 
553
+ function isoAgeMs(isoString) {
554
+ const millis = Date.parse(isoString || "");
555
+ if (Number.isNaN(millis)) {
556
+ return Number.POSITIVE_INFINITY;
557
+ }
558
+ return Math.max(0, Date.now() - millis);
559
+ }
560
+
561
+ function isNonTerminalPendingReply(entry) {
562
+ return Boolean(entry)
563
+ && !entry.sentAt
564
+ && !["error", "ignored_bot", "superseded", "expired", "stale_thread"].includes(String(entry.status || ""));
565
+ }
566
+
567
+ function hasResponseMessageIds(entry) {
568
+ return Array.isArray(entry?.responseMessageIds)
569
+ && entry.responseMessageIds.filter(Boolean).length > 0;
570
+ }
571
+
572
+ function isReplyAwaitingOutcome(entry) {
573
+ if (!entry || typeof entry !== "object") {
574
+ return false;
575
+ }
576
+ const status = String(entry.status || "").trim().toLowerCase();
577
+ if (["sent", "suppressed_ack", "error", "ignored_bot", "superseded", "expired", "stale_thread"].includes(status)) {
578
+ return false;
579
+ }
580
+ return !hasResponseMessageIds(entry);
581
+ }
582
+
583
+ function closeStaleThreadPendingRepliesInPlace(pendingReplies, activeThreadId) {
584
+ const threadId = String(activeThreadId || "").trim();
585
+ if (!threadId) {
586
+ return 0;
587
+ }
588
+ const replies = Array.isArray(pendingReplies) ? pendingReplies : [];
589
+ let closed = 0;
590
+ for (const entry of replies) {
591
+ if (!isNonTerminalPendingReply(entry)) {
592
+ continue;
593
+ }
594
+ const entryThreadId = String(entry.threadId || "").trim();
595
+ if (!entryThreadId || entryThreadId === threadId) {
596
+ continue;
597
+ }
598
+ entry.status = "stale_thread";
599
+ entry.sentAt = nowIso();
600
+ entry.responsePreview = entry.responsePreview || `[stale pending reply from previous thread ${entryThreadId}]`;
601
+ closed += 1;
602
+ }
603
+ return closed;
604
+ }
605
+
606
+ function supersedeOlderPendingRepliesInPlace(pendingReplies) {
607
+ const replies = Array.isArray(pendingReplies) ? pendingReplies : [];
608
+ const newestByConversation = new Map();
609
+
610
+ for (const entry of replies) {
611
+ if (!isReplyAwaitingOutcome(entry)) {
612
+ continue;
613
+ }
614
+ const key = [
615
+ String(entry.chatId || "").trim(),
616
+ String(entry.conversationKey || "").trim(),
617
+ String(entry.telegramThreadId || "").trim()
618
+ ].join("|");
619
+ const current = newestByConversation.get(key);
620
+ const stamp = String(entry.createdAt || "");
621
+ if (!current || stamp > current.stamp) {
622
+ newestByConversation.set(key, { entry, stamp });
623
+ }
624
+ }
625
+
626
+ let superseded = 0;
627
+ for (const entry of replies) {
628
+ if (!isReplyAwaitingOutcome(entry)) {
629
+ continue;
630
+ }
631
+ const key = [
632
+ String(entry.chatId || "").trim(),
633
+ String(entry.conversationKey || "").trim(),
634
+ String(entry.telegramThreadId || "").trim()
635
+ ].join("|");
636
+ const newest = newestByConversation.get(key)?.entry || null;
637
+ if (!newest || newest === entry) {
638
+ continue;
639
+ }
640
+ entry.status = "superseded";
641
+ entry.sentAt = nowIso();
642
+ entry.responsePreview = entry.responsePreview || "[superseded by newer message]";
643
+ superseded += 1;
644
+ }
645
+
646
+ return superseded;
647
+ }
648
+
649
+ function isPrivateIdleBriefArtifact(entry) {
650
+ if (!entry) {
651
+ return false;
652
+ }
653
+ if (String(entry.chatType || "").toLowerCase() !== "private") {
654
+ return false;
655
+ }
656
+ return looksLikeMnemoIdleLoopBrief(entry.text || entry.sourceText || "");
657
+ }
658
+
659
+ function scrubIdleBriefArtifactsInPlace(state) {
660
+ if (!state || typeof state !== "object") {
661
+ return state;
662
+ }
663
+
664
+ const queue = Array.isArray(state.queue) ? state.queue : [];
665
+ state.queue = mergeQueueLists([], queue.filter((entry) => !isPrivateIdleBriefArtifact(entry)));
666
+
667
+ const pendingReplies = Array.isArray(state.pendingReplies) ? state.pendingReplies : [];
668
+ state.pendingReplies = mergePendingReplyLists([], pendingReplies.filter((entry) => !isPrivateIdleBriefArtifact(entry)));
669
+
670
+ if (isPrivateIdleBriefArtifact(state.lastInbound)) {
671
+ state.lastInbound = [...state.queue]
672
+ .filter((entry) => !isPrivateIdleBriefArtifact(entry))
673
+ .sort((left, right) => {
674
+ const leftStamp = String(left?.ts || left?.deliveredAt || left?.lastAttemptAt || "");
675
+ const rightStamp = String(right?.ts || right?.deliveredAt || right?.lastAttemptAt || "");
676
+ if (leftStamp !== rightStamp) {
677
+ return rightStamp.localeCompare(leftStamp);
678
+ }
679
+ return Number(right?.messageId || 0) - Number(left?.messageId || 0);
680
+ })[0] || null;
681
+ }
682
+
683
+ return state;
684
+ }
685
+
686
+ function closeExpiredPendingRepliesInPlace(config, pendingReplies) {
687
+ const replies = Array.isArray(pendingReplies) ? pendingReplies : [];
688
+ const timeoutMs = getEffectivePendingReplyTimeoutMs(config);
689
+ if (timeoutMs <= 0) {
690
+ return 0;
691
+ }
692
+
693
+ let expired = 0;
694
+ for (const entry of replies) {
695
+ if (hasResponseMessageIds(entry)) {
696
+ entry.status = String(entry.status || "").trim().toLowerCase() === "suppressed_ack" ? "suppressed_ack" : "sent";
697
+ entry.sentAt = entry.sentAt || getPendingReplyActivityAt(entry) || nowIso();
698
+ continue;
699
+ }
700
+ if (!isNonTerminalPendingReply(entry)) {
701
+ continue;
702
+ }
703
+ if (isoAgeMs(getPendingReplyActivityAt(entry)) < timeoutMs) {
704
+ continue;
705
+ }
706
+ entry.status = "expired";
707
+ entry.sentAt = nowIso();
708
+ entry.responsePreview = entry.responsePreview || `[pending reply expired after ${timeoutMs}ms]`;
709
+ expired += 1;
710
+ }
711
+ return expired;
712
+ }
713
+
714
+ function getEffectivePendingReplyTimeoutMs(config) {
715
+ const configuredMs = Math.max(Number(config.pendingReplyTimeoutMs || 0), 0);
716
+ if (configuredMs <= 0) {
717
+ return 0;
718
+ }
719
+ return configuredMs;
720
+ }
721
+
722
+ function getEffectiveIdleCooldownMs(config, entry = null) {
723
+ const configuredMs = Math.max(Number(config.idleCooldownMs || 0), 0);
724
+ if (configuredMs <= 0) {
725
+ return 0;
726
+ }
727
+ const relevance = String(entry?.relevance || "").trim().toLowerCase();
728
+ const chatType = String(entry?.chatType || "").trim().toLowerCase();
729
+ const directLike = chatType === "private" || relevance === "direct" || relevance === "lane";
730
+ const capMs = directLike ? 3000 : 5000;
731
+ return Math.min(configuredMs, capMs);
732
+ }
733
+
734
+ function parkExpiredAmbientQueueEntriesInPlace(config, queue) {
735
+ const entries = Array.isArray(queue) ? queue : [];
736
+ const ttlMs = Math.max(Number(config.ambientQueueTtlMs || 0), 0);
737
+ if (ttlMs <= 0) {
738
+ return 0;
739
+ }
740
+
741
+ let parked = 0;
742
+ for (const entry of entries) {
743
+ if (!entry || entry.status !== "queued") {
744
+ continue;
745
+ }
746
+ if (String(entry.relevance || "") !== "ambient") {
747
+ continue;
748
+ }
749
+ if (isoAgeMs(entry.ts) < ttlMs) {
750
+ continue;
751
+ }
752
+ entry.status = "parked";
753
+ entry.parkedAt = nowIso();
754
+ if (!entry.responsePreview) {
755
+ entry.responsePreview = `[ambient parked after ${ttlMs}ms]`;
756
+ }
757
+ parked += 1;
758
+ }
759
+ return parked;
760
+ }
761
+
117
762
  function mergeQueueEntry(current, incoming) {
118
763
  if (!current && !incoming) {
119
764
  return null;
@@ -142,7 +787,7 @@ function mergeQueueEntry(current, incoming) {
142
787
  merged.deliveredAt = pickIsoLater(current.deliveredAt, incoming.deliveredAt);
143
788
  merged.ts = pickIsoLater(current.ts, incoming.ts);
144
789
 
145
- for (const field of ["threadId", "responsePreview", "stderr", "stdout", "chatType", "conversationKey", "groupTitle", "telegramThreadId", "senderIsBot", "relevance"]) {
790
+ for (const field of ["threadId", "responsePreview", "stderr", "stdout", "chatType", "conversationKey", "groupTitle", "telegramThreadId", "senderIsBot", "relevance", "intent"]) {
146
791
  if (!merged[field]) {
147
792
  merged[field] = current[field] || incoming[field] || null;
148
793
  }
@@ -298,6 +943,22 @@ function syncRecordFromQueue(record, queue) {
298
943
  return match ? mergeQueueEntry(record, match) : record;
299
944
  }
300
945
 
946
+ function markMatchingQueueEntriesInPlace(state, source, updates) {
947
+ const key = queueKey(source || {});
948
+ if (!key || key === ":") {
949
+ return 0;
950
+ }
951
+ let changed = 0;
952
+ for (const entry of state.queue || []) {
953
+ if (queueKey(entry) !== key) {
954
+ continue;
955
+ }
956
+ Object.assign(entry, updates);
957
+ changed += 1;
958
+ }
959
+ return changed;
960
+ }
961
+
301
962
  function mergeStateSnapshots(currentState, incomingState) {
302
963
  const merged = {
303
964
  ...currentState,
@@ -320,6 +981,7 @@ function mergeStateSnapshots(currentState, incomingState) {
320
981
  merged.queue
321
982
  );
322
983
  merged.lastOutbound = pickLatestRecord(currentState.lastOutbound || null, incomingState.lastOutbound || null);
984
+ merged.lastUiNotice = pickLatestRecord(currentState.lastUiNotice || null, incomingState.lastUiNotice || null);
323
985
  merged.lastPollAt = pickIsoLater(currentState.lastPollAt, incomingState.lastPollAt);
324
986
  merged.lastInjectAt = pickIsoLater(currentState.lastInjectAt, incomingState.lastInjectAt);
325
987
  merged.currentThreadId = incomingState.currentThreadId || currentState.currentThreadId || "";
@@ -346,6 +1008,7 @@ function normalizeInbound(message) {
346
1008
  userId: message.from?.id ? String(message.from.id) : "",
347
1009
  text,
348
1010
  ts: nowIso(),
1011
+ intent: "message",
349
1012
  relevance: "ambient",
350
1013
  status: "queued",
351
1014
  attempts: 0,
@@ -353,47 +1016,377 @@ function normalizeInbound(message) {
353
1016
  };
354
1017
  }
355
1018
 
356
- function normalizeTelegramThreadId(value) {
357
- return String(value || "").trim();
1019
+ function buildContinueContext(state, inbound) {
1020
+ const sameConversation = (entry) => String(entry?.conversationKey || "").trim() === String(inbound?.conversationKey || "").trim();
1021
+ const recentEntries = [
1022
+ ...(state.queue || []),
1023
+ ...(state.pendingReplies || [])
1024
+ ].filter((entry) => {
1025
+ if (!entry || entry.senderIsBot) {
1026
+ return false;
1027
+ }
1028
+ if (!sameConversation(entry)) {
1029
+ return false;
1030
+ }
1031
+ const ageMs = isoAgeMs(entry.ts || entry.createdAt || entry.deliveredAt || entry.lastAttemptAt || "");
1032
+ return ageMs <= 1000 * 60 * 60 * 6;
1033
+ });
1034
+
1035
+ const lastUserWorkEntry = [...recentEntries]
1036
+ .reverse()
1037
+ .find((entry) => String(entry.intent || "").trim().toLowerCase() !== "continue_nudge");
1038
+
1039
+ return {
1040
+ conversationKey: inbound?.conversationKey || "",
1041
+ recentEntries,
1042
+ lastUserWorkText: lastUserWorkEntry?.text || lastUserWorkEntry?.sourceText || "",
1043
+ hasPendingReplies: (state.pendingReplies || []).some((entry) => {
1044
+ if (!entry || entry.senderIsBot || entry.sentAt) {
1045
+ return false;
1046
+ }
1047
+ return sameConversation(entry);
1048
+ })
1049
+ };
1050
+ }
1051
+
1052
+ function looksLikeMnemoIdleLoopBrief(text) {
1053
+ const value = String(text || "").trim();
1054
+ if (!value) {
1055
+ return false;
1056
+ }
1057
+ if (/^Mnemo Idle #\d+:/i.test(value)) {
1058
+ return true;
1059
+ }
1060
+ return /^---\s*BRIEF\b[\s\S]*\bfrom=mnemo-idle-loop\b[\s\S]*\[IDLE-CYCLE\]/i.test(value);
1061
+ }
1062
+
1063
+ function normalizeTelegramThreadId(value) {
1064
+ return String(value || "").trim();
1065
+ }
1066
+
1067
+ function isAllowedChat(config, chatId) {
1068
+ const allowed = Array.isArray(config.allowedChatIds) ? config.allowedChatIds : [];
1069
+ if (allowed.length === 0) {
1070
+ return true;
1071
+ }
1072
+ return allowed.includes(String(chatId || "").trim());
1073
+ }
1074
+
1075
+ function splitTelegramText(text, maxLength = 3500) {
1076
+ const value = String(text || "").trim();
1077
+ if (!value) {
1078
+ return [];
1079
+ }
1080
+ if (value.length <= maxLength) {
1081
+ return [value];
1082
+ }
1083
+
1084
+ const chunks = [];
1085
+ let remaining = value;
1086
+ while (remaining.length > maxLength) {
1087
+ let cut = remaining.lastIndexOf("\n\n", maxLength);
1088
+ if (cut < 0 || cut < maxLength * 0.5) {
1089
+ cut = remaining.lastIndexOf("\n", maxLength);
1090
+ }
1091
+ if (cut < 0 || cut < maxLength * 0.5) {
1092
+ cut = remaining.lastIndexOf(" ", maxLength);
1093
+ }
1094
+ if (cut < 0 || cut < maxLength * 0.5) {
1095
+ cut = maxLength;
1096
+ }
1097
+ chunks.push(remaining.slice(0, cut).trim());
1098
+ remaining = remaining.slice(cut).trim();
1099
+ }
1100
+ if (remaining) {
1101
+ chunks.push(remaining);
1102
+ }
1103
+ return chunks.filter(Boolean);
1104
+ }
1105
+
1106
+ function shouldSendDeferredReceipt(config, entry, reason) {
1107
+ if (!config?.queueNoticeEnabled) {
1108
+ return false;
1109
+ }
1110
+ if (!entry) {
1111
+ return false;
1112
+ }
1113
+ if (entry.senderIsBot) {
1114
+ return false;
1115
+ }
1116
+ if (entry.intent === "continue_nudge") {
1117
+ return false;
1118
+ }
1119
+ if (entry.queueNoticeSentAt) {
1120
+ return false;
1121
+ }
1122
+ if (!["pending_reply", "session_active"].includes(String(reason || ""))) {
1123
+ return false;
1124
+ }
1125
+ const relevance = String(entry.relevance || "").toLowerCase();
1126
+ const chatType = String(entry.chatType || "").toLowerCase();
1127
+ return chatType === "private" || relevance === "direct" || relevance === "lane";
1128
+ }
1129
+
1130
+ function shouldSendTypingIndicator(entry) {
1131
+ if (!entry || entry.senderIsBot) {
1132
+ return false;
1133
+ }
1134
+ const relevance = String(entry.relevance || "").toLowerCase();
1135
+ const chatType = String(entry.chatType || "").toLowerCase();
1136
+ return chatType === "private" || relevance === "direct" || relevance === "lane";
1137
+ }
1138
+
1139
+ function buildDeferredReceiptText(entry) {
1140
+ const chatType = String(entry?.chatType || "").toLowerCase();
1141
+ const user = String(entry?.user || "").trim();
1142
+ if (chatType === "private") {
1143
+ return "Ich habe deine Nachricht. Ich ziehe sie nach dem aktuellen Lauf.";
1144
+ }
1145
+ if (user) {
1146
+ return `Alles klar ${user}, ich habe deine Nachricht. Ich ziehe sie nach dem aktuellen Lauf.`;
1147
+ }
1148
+ return "Ich habe die Nachricht. Ich ziehe sie nach dem aktuellen Lauf.";
1149
+ }
1150
+
1151
+ function buildProgressFallbackText(entry) {
1152
+ const chatType = String(entry?.chatType || "").toLowerCase();
1153
+ const user = String(entry?.user || "").trim();
1154
+ if (chatType === "private") {
1155
+ return "Ich arbeite noch daran und melde den naechsten konkreten Stand hier.";
1156
+ }
1157
+ if (user) {
1158
+ return `${user}, ich arbeite noch daran und melde den naechsten konkreten Stand hier.`;
1159
+ }
1160
+ return "Ich arbeite noch daran und melde den naechsten konkreten Stand hier.";
1161
+ }
1162
+
1163
+ function hasRecentSessionWrite(entry, sessionPath, activeWindowMs = 15000) {
1164
+ if (!entry?.createdAt || !sessionPath || !existsSync(sessionPath)) {
1165
+ return false;
1166
+ }
1167
+ try {
1168
+ const modifiedAt = statSync(sessionPath).mtimeMs;
1169
+ const createdAt = Date.parse(entry.createdAt);
1170
+ if (!Number.isFinite(createdAt) || modifiedAt < createdAt) {
1171
+ return false;
1172
+ }
1173
+ return Date.now() - modifiedAt <= activeWindowMs;
1174
+ } catch {
1175
+ return false;
1176
+ }
1177
+ }
1178
+
1179
+ function shouldSendFallbackProgress(config, entry, sessionPath) {
1180
+ if (!entry || entry.senderIsBot) {
1181
+ return false;
1182
+ }
1183
+ const fallbackMs = Math.max(Number(config.progressFallbackMs || 0), 0);
1184
+ if (fallbackMs <= 0 || isoAgeMs(entry.createdAt) < fallbackMs) {
1185
+ return false;
1186
+ }
1187
+
1188
+ const intent = String(entry.intent || "").trim().toLowerCase();
1189
+ const relevance = String(entry.relevance || "").trim().toLowerCase();
1190
+ const sourceText = entry.sourceText || entry.text || "";
1191
+ const looksLikeWork = intent === "continue_nudge"
1192
+ || relevance === "escalation"
1193
+ || looksLikeWorkContextText(sourceText);
1194
+ if (!looksLikeWork) {
1195
+ return false;
1196
+ }
1197
+
1198
+ return hasRecentSessionWrite(entry, sessionPath);
1199
+ }
1200
+
1201
+ function shouldSendProgressUpgrade(entry, progress) {
1202
+ if (!entry || !progress) {
1203
+ return false;
1204
+ }
1205
+ if (String(entry.progressMode || "").trim().toLowerCase() !== "fallback") {
1206
+ return false;
1207
+ }
1208
+ if (entry.progressUpgradeSentAt) {
1209
+ return false;
1210
+ }
1211
+ const progressText = normalizeWhitespace(repairMojibake(progress.message || ""));
1212
+ if (!progressText) {
1213
+ return false;
1214
+ }
1215
+ const fallbackText = normalizeWhitespace(repairMojibake(entry.progressPreview || ""));
1216
+ if (!fallbackText || progressText === fallbackText) {
1217
+ return false;
1218
+ }
1219
+ if (looksLikeAckOnly(progressText) || looksLikeContextRequestOnly(progressText)) {
1220
+ return false;
1221
+ }
1222
+ return true;
1223
+ }
1224
+
1225
+ function getProgressRelayMode(config) {
1226
+ const mode = String(config?.progressRelayMode || "status").trim().toLowerCase();
1227
+ if (["off", "status", "commentary"].includes(mode)) {
1228
+ return mode;
1229
+ }
1230
+ return "status";
358
1231
  }
359
1232
 
360
- function isAllowedChat(config, chatId) {
361
- const allowed = Array.isArray(config.allowedChatIds) ? config.allowedChatIds : [];
362
- if (allowed.length === 0) {
363
- return true;
1233
+ function getPendingReplyActivityAt(entry) {
1234
+ if (!entry || typeof entry !== "object") {
1235
+ return "";
364
1236
  }
365
- return allowed.includes(String(chatId || "").trim());
1237
+ return String(
1238
+ entry.lastSignalAt
1239
+ || entry.progressSentAt
1240
+ || entry.sentAt
1241
+ || entry.createdAt
1242
+ || ""
1243
+ ).trim();
366
1244
  }
367
1245
 
368
- function splitTelegramText(text, maxLength = 3500) {
369
- const value = String(text || "").trim();
370
- if (!value) {
371
- return [];
372
- }
373
- if (value.length <= maxLength) {
374
- return [value];
1246
+ function normalizeWhitespace(text) {
1247
+ return String(text || "")
1248
+ .replace(/\r/g, " ")
1249
+ .replace(/\n/g, " ")
1250
+ .replace(/\s+/g, " ")
1251
+ .trim();
1252
+ }
1253
+
1254
+ function repairMojibake(text) {
1255
+ const value = String(text || "");
1256
+ if (!/[Ãâ�]/.test(value)) {
1257
+ return value;
375
1258
  }
376
1259
 
377
- const chunks = [];
378
- let remaining = value;
379
- while (remaining.length > maxLength) {
380
- let cut = remaining.lastIndexOf("\n\n", maxLength);
381
- if (cut < 0 || cut < maxLength * 0.5) {
382
- cut = remaining.lastIndexOf("\n", maxLength);
383
- }
384
- if (cut < 0 || cut < maxLength * 0.5) {
385
- cut = remaining.lastIndexOf(" ", maxLength);
1260
+ try {
1261
+ const repaired = Buffer.from(value, "latin1").toString("utf8");
1262
+ if (repaired && !/\u0000/.test(repaired)) {
1263
+ return repaired;
386
1264
  }
387
- if (cut < 0 || cut < maxLength * 0.5) {
388
- cut = maxLength;
1265
+ } catch {
1266
+ // Fall through to targeted replacements.
1267
+ }
1268
+
1269
+ return value
1270
+ .replace(/—/g, "-")
1271
+ .replace(/–/g, "-")
1272
+ .replace(/„|“|”/g, "\"")
1273
+ .replace(/’|‘/g, "'")
1274
+ .replace(/…/g, "...")
1275
+ .replace(/€/g, "EUR")
1276
+ .replace(/Ä/g, "Ä")
1277
+ .replace(/Ö/g, "Ö")
1278
+ .replace(/Ü/g, "Ü")
1279
+ .replace(/ä/g, "ä")
1280
+ .replace(/ö/g, "ö")
1281
+ .replace(/ü/g, "ü")
1282
+ .replace(/ß/g, "ß");
1283
+ }
1284
+
1285
+ function shouldPublishInboundUiNotice(entry) {
1286
+ if (!entry) {
1287
+ return false;
1288
+ }
1289
+ if (entry.senderIsBot) {
1290
+ return false;
1291
+ }
1292
+ const chatType = String(entry.chatType || "").toLowerCase();
1293
+ const relevance = String(entry.relevance || "").toLowerCase();
1294
+ return chatType === "private" || ["direct", "lane", "escalation"].includes(relevance);
1295
+ }
1296
+
1297
+ function formatCompactInboundUiNotice(entry) {
1298
+ const text = normalizeWhitespace(repairMojibake(entry?.text || "")).slice(0, 180);
1299
+ if (!text) {
1300
+ return "";
1301
+ }
1302
+ if (/^(brief von|mnemo idle #)/i.test(text)) {
1303
+ return text;
1304
+ }
1305
+ const user = String(entry?.user || "unknown").trim();
1306
+ const groupTitle = String(entry?.groupTitle || "").trim();
1307
+ const chatType = String(entry?.chatType || "").trim().toLowerCase();
1308
+ if (chatType === "private" || !groupTitle) {
1309
+ return `${user}: ${text}`;
1310
+ }
1311
+ return `${user} @ ${groupTitle}: ${text}`;
1312
+ }
1313
+
1314
+ function findRecentOutboundForTurn(config, chatId, source, sourceTurnId, text = "") {
1315
+ if (!config?.paths?.outboxFile || !existsSync(config.paths.outboxFile)) {
1316
+ return null;
1317
+ }
1318
+ const normalizedChatId = String(chatId || "").trim();
1319
+ const normalizedSource = String(source || "").trim();
1320
+ const normalizedTurnId = String(sourceTurnId || "").trim();
1321
+ const normalizedText = normalizeWhitespace(repairMojibake(text || ""));
1322
+ if (!normalizedChatId || !normalizedSource || !normalizedTurnId) {
1323
+ return null;
1324
+ }
1325
+
1326
+ const matches = [];
1327
+ for (const line of readTail(config.paths.outboxFile, 400).reverse()) {
1328
+ try {
1329
+ const item = JSON.parse(line);
1330
+ if (String(item?.chatId || "").trim() !== normalizedChatId) {
1331
+ continue;
1332
+ }
1333
+ if (String(item?.source || "").trim() !== normalizedSource) {
1334
+ continue;
1335
+ }
1336
+ if (String(item?.sourceTurnId || "").trim() !== normalizedTurnId) {
1337
+ continue;
1338
+ }
1339
+ if (normalizedText) {
1340
+ const itemText = normalizeWhitespace(repairMojibake(item?.text || ""));
1341
+ if (itemText !== normalizedText) {
1342
+ continue;
1343
+ }
1344
+ }
1345
+ matches.push(item);
1346
+ } catch {
1347
+ // Ignore malformed tail lines.
389
1348
  }
390
- chunks.push(remaining.slice(0, cut).trim());
391
- remaining = remaining.slice(cut).trim();
392
1349
  }
393
- if (remaining) {
394
- chunks.push(remaining);
1350
+
1351
+ if (matches.length === 0) {
1352
+ return null;
1353
+ }
1354
+
1355
+ matches.sort((left, right) => String(left?.ts || "").localeCompare(String(right?.ts || "")));
1356
+ return {
1357
+ outbound: matches[matches.length - 1],
1358
+ messageIds: matches.map((item) => String(item.messageId || "").trim()).filter(Boolean)
1359
+ };
1360
+ }
1361
+
1362
+ async function maybeSendDeferredReceipt(config, state, entry, reason) {
1363
+ if (!shouldSendDeferredReceipt(config, entry, reason)) {
1364
+ return false;
1365
+ }
1366
+
1367
+ try {
1368
+ await sendOutboundChunks(config, state, {
1369
+ chatId: entry.chatId,
1370
+ text: buildDeferredReceiptText(entry),
1371
+ replyToMessageId: entry.messageId,
1372
+ telegramThreadId: entry.telegramThreadId,
1373
+ source: "queue_notice"
1374
+ });
1375
+ entry.queueNoticeSentAt = nowIso();
1376
+ entry.queueNoticeReason = String(reason || "");
1377
+ state.lastQueueNoticeAt = entry.queueNoticeSentAt;
1378
+ appendLog(
1379
+ config.paths.activityFile,
1380
+ `QUEUE_NOTICE chat=${entry.chatId} message=${entry.messageId} reason=${entry.queueNoticeReason || "-"}`
1381
+ );
1382
+ return true;
1383
+ } catch (error) {
1384
+ appendLog(
1385
+ config.paths.activityFile,
1386
+ `QUEUE_NOTICE_ERROR chat=${entry.chatId} message=${entry.messageId} reason=${String(reason || "-")}: ${error}`
1387
+ );
1388
+ return false;
395
1389
  }
396
- return chunks.filter(Boolean);
397
1390
  }
398
1391
 
399
1392
  function readJsonlDelta(path, offset, carry = "") {
@@ -456,17 +1449,28 @@ async function resolveThreadSessionPath(config, threadId) {
456
1449
  return findRolloutFile(config.paths.sessionsDir, threadId) || "";
457
1450
  }
458
1451
 
459
- function countOpenPendingReplies(state) {
460
- return (state.pendingReplies || []).filter((entry) => !entry.sentAt && !["error", "ignored_bot", "superseded"].includes(String(entry.status || ""))).length;
1452
+ function countOpenPendingReplies(state, config) {
1453
+ const timeoutMs = getEffectivePendingReplyTimeoutMs(config);
1454
+ return (state.pendingReplies || []).filter((entry) => {
1455
+ if (!isNonTerminalPendingReply(entry)) {
1456
+ return false;
1457
+ }
1458
+ if (timeoutMs > 0 && isoAgeMs(entry.createdAt) >= timeoutMs) {
1459
+ return false;
1460
+ }
1461
+ return true;
1462
+ }).length;
461
1463
  }
462
1464
 
463
- async function resolveSessionActivity(config, threadId) {
1465
+ async function resolveSessionActivity(config, threadId, entry = null) {
464
1466
  const sessionPath = await resolveThreadSessionPath(config, threadId);
1467
+ const cooldownMs = getEffectiveIdleCooldownMs(config, entry);
465
1468
  if (!sessionPath || !existsSync(sessionPath)) {
466
1469
  return {
467
1470
  sessionPath,
468
1471
  quietMs: Number.POSITIVE_INFINITY,
469
- active: false
1472
+ active: false,
1473
+ cooldownMs
470
1474
  };
471
1475
  }
472
1476
 
@@ -474,7 +1478,8 @@ async function resolveSessionActivity(config, threadId) {
474
1478
  return {
475
1479
  sessionPath,
476
1480
  quietMs,
477
- active: quietMs < Number(config.idleCooldownMs || 0)
1481
+ active: quietMs < cooldownMs,
1482
+ cooldownMs
478
1483
  };
479
1484
  }
480
1485
 
@@ -494,6 +1499,7 @@ function buildPendingReplyEntry(message, threadId, turnId, sessionPath, sessionO
494
1499
  groupTitle: String(message.groupTitle || "").trim(),
495
1500
  user: String(message.user || "").trim(),
496
1501
  sourceText: String(message.text || ""),
1502
+ intent: String(message.intent || "message").trim(),
497
1503
  createdAt: nowIso(),
498
1504
  status: "pending",
499
1505
  sentAt: null,
@@ -502,6 +1508,16 @@ function buildPendingReplyEntry(message, threadId, turnId, sessionPath, sessionO
502
1508
  };
503
1509
  }
504
1510
 
1511
+ function shouldTrackPendingReply(message) {
1512
+ if (!message) {
1513
+ return false;
1514
+ }
1515
+ if (looksLikeBotSender(message)) {
1516
+ return false;
1517
+ }
1518
+ return String(message.intent || "message").trim().toLowerCase() !== "continue_nudge";
1519
+ }
1520
+
505
1521
  function parseUnixSeconds(isoString) {
506
1522
  const millis = Date.parse(isoString || "");
507
1523
  if (Number.isNaN(millis)) {
@@ -510,6 +1526,46 @@ function parseUnixSeconds(isoString) {
510
1526
  return Math.floor(millis / 1000);
511
1527
  }
512
1528
 
1529
+ function isPidAlive(pid) {
1530
+ const parsed = Number.parseInt(String(pid || "0"), 10);
1531
+ if (!parsed || parsed <= 0) {
1532
+ return false;
1533
+ }
1534
+ try {
1535
+ process.kill(parsed, 0);
1536
+ return true;
1537
+ } catch {
1538
+ return false;
1539
+ }
1540
+ }
1541
+
1542
+ function getRuntimeOwner(config) {
1543
+ if (!config.paths.currentRuntimeFile || !existsSync(config.paths.currentRuntimeFile)) {
1544
+ return null;
1545
+ }
1546
+ const runtime = loadJson(config.paths.currentRuntimeFile, null);
1547
+ if (!runtime) {
1548
+ return null;
1549
+ }
1550
+ if (config.appServerWsUrl && runtime.ws_url && String(runtime.ws_url).trim() !== String(config.appServerWsUrl).trim()) {
1551
+ return null;
1552
+ }
1553
+ const frontendHostPid = Number.parseInt(String(runtime.frontend_host_pid || "0"), 10) || 0;
1554
+ return {
1555
+ runtime,
1556
+ frontendHostPid,
1557
+ frontendAlive: isPidAlive(frontendHostPid)
1558
+ };
1559
+ }
1560
+
1561
+ function normalizeThreadTimestampMs(value) {
1562
+ const numeric = Number(value);
1563
+ if (!Number.isFinite(numeric) || numeric <= 0) {
1564
+ return 0;
1565
+ }
1566
+ return numeric > 100000000000 ? numeric : numeric * 1000;
1567
+ }
1568
+
513
1569
  function buildHistoryText(message) {
514
1570
  const text = String(message.text || "").trim();
515
1571
  const chatType = String(message.chatType || "");
@@ -602,7 +1658,7 @@ function promoteVisibleQueuedEntry(config, state, threadId, message) {
602
1658
  return true;
603
1659
  }
604
1660
 
605
- async function resolveActiveThreadId(config, state, preferredThreadId) {
1661
+ async function resolveActiveThreadId(config, state, preferredThreadId, options = {}) {
606
1662
  const fallbackThreadId = String(preferredThreadId || config.currentThreadId || state.currentThreadId || "").trim();
607
1663
  if (!config.appServerWsUrl) {
608
1664
  return fallbackThreadId;
@@ -618,6 +1674,19 @@ async function resolveActiveThreadId(config, state, preferredThreadId) {
618
1674
  return fallbackThreadId;
619
1675
  }
620
1676
 
1677
+ const runtimeOwner = getRuntimeOwner(config);
1678
+ const runtimeThreadId = String(runtimeOwner?.runtime?.thread_id || "").trim();
1679
+ const pinnedThreadId = String(preferredThreadId || config.currentThreadId || runtimeThreadId || "").trim();
1680
+ if (options.forcePreferred && pinnedThreadId && loadedIds.includes(pinnedThreadId)) {
1681
+ if (state.currentThreadId !== pinnedThreadId) {
1682
+ state.currentThreadId = pinnedThreadId;
1683
+ saveStateForConfig(config, state);
1684
+ appendLog(config.paths.activityFile, `REMOTE_ACTIVE_THREAD_PINNED thread=${pinnedThreadId}`);
1685
+ }
1686
+ persistActiveThreadBinding(config, pinnedThreadId);
1687
+ return pinnedThreadId;
1688
+ }
1689
+
621
1690
  let bestThreadId = loadedIds[loadedIds.length - 1];
622
1691
  let bestScore = Number.NEGATIVE_INFINITY;
623
1692
 
@@ -629,9 +1698,23 @@ async function resolveActiveThreadId(config, state, preferredThreadId) {
629
1698
  threadId: candidateThreadId,
630
1699
  timeoutMs: 5000
631
1700
  });
632
- const sessionPath = String(readResult?.response?.result?.thread?.path || "").trim();
1701
+ const thread = readResult?.response?.result?.thread || {};
1702
+ const createdAtMs = normalizeThreadTimestampMs(thread.createdAt);
1703
+ if (createdAtMs > 0) {
1704
+ score = createdAtMs;
1705
+ }
1706
+ const source = String(thread.source || "").toLowerCase();
1707
+ const statusType = String(thread.status?.type || "").toLowerCase();
1708
+ if (source === "cli" && statusType === "active") {
1709
+ score += 1000000000000000;
1710
+ } else if (statusType === "active") {
1711
+ score += 900000000000000;
1712
+ } else if (source === "cli") {
1713
+ score += 800000000000000;
1714
+ }
1715
+ const sessionPath = String(thread.path || "").trim();
633
1716
  if (sessionPath && existsSync(sessionPath)) {
634
- score = statSync(sessionPath).mtimeMs;
1717
+ score = Math.max(score, statSync(sessionPath).birthtimeMs || 0);
635
1718
  }
636
1719
  } catch {
637
1720
  score = 0;
@@ -648,6 +1731,7 @@ async function resolveActiveThreadId(config, state, preferredThreadId) {
648
1731
  saveStateForConfig(config, state);
649
1732
  appendLog(config.paths.activityFile, `REMOTE_ACTIVE_THREAD thread=${bestThreadId}`);
650
1733
  }
1734
+ persistActiveThreadBinding(config, bestThreadId);
651
1735
  return bestThreadId;
652
1736
  } catch (error) {
653
1737
  const message = String(error?.message || error).replace(/\s+/g, " ").slice(0, 180);
@@ -659,20 +1743,38 @@ async function resolveActiveThreadId(config, state, preferredThreadId) {
659
1743
  export function bridgeStatus() {
660
1744
  const config = loadConfig();
661
1745
  const state = loadState(config);
1746
+ const runtimeOwner = getRuntimeOwner(config);
1747
+ const parkedAmbient = parkExpiredAmbientQueueEntriesInPlace(config, state.queue || []);
1748
+ state.pendingReplies = reconcilePendingRepliesInPlace(state.pendingReplies || []);
1749
+ const expiredPendingReplies = closeExpiredPendingRepliesInPlace(config, state.pendingReplies || []);
1750
+ if (expiredPendingReplies > 0 || parkedAmbient > 0) {
1751
+ if (parkedAmbient > 0) {
1752
+ appendLog(config.paths.activityFile, `AMBIENT_PARKED count=${parkedAmbient}`);
1753
+ }
1754
+ saveStateForConfig(config, state);
1755
+ }
662
1756
  const queued = state.queue.filter((item) => item.status === "queued");
663
1757
  const submitted = state.queue.filter((item) => item.status === "submitted");
1758
+ const parked = state.queue.filter((item) => item.status === "parked");
664
1759
  const ambient = queued.filter((item) => item.relevance === "ambient");
665
- const pendingReplies = (state.pendingReplies || []).filter((item) => !item.sentAt && item.status !== "error");
1760
+ const pendingReplies = (state.pendingReplies || []).filter((item) => isNonTerminalPendingReply(item));
1761
+ const expiredReplies = (state.pendingReplies || []).filter((item) => String(item.status || "") === "expired");
666
1762
  return {
667
1763
  agent: config.agentName,
668
1764
  allowedChatId: config.allowedChatId || null,
669
1765
  boundThreadId: config.currentThreadId || state.currentThreadId || null,
1766
+ frontendOwnerPid: runtimeOwner?.frontendHostPid || null,
1767
+ frontendOwnerAlive: runtimeOwner?.frontendAlive ?? null,
670
1768
  dispatchMode: config.dispatchMode,
671
1769
  idleCooldownMs: config.idleCooldownMs,
1770
+ pendingReplyTimeoutMs: config.pendingReplyTimeoutMs,
672
1771
  queueDepth: queued.length,
673
1772
  ambientQueueDepth: ambient.length,
1773
+ parkedQueueDepth: parked.length,
674
1774
  submittedDepth: submitted.length,
675
1775
  pendingReplyDepth: pendingReplies.length,
1776
+ expiredPendingReplyDepth: expiredReplies.length,
1777
+ progressRelayMode: getProgressRelayMode(config),
676
1778
  lastInbound: state.lastInbound,
677
1779
  lastOutbound: state.lastOutbound,
678
1780
  lastPollAt: state.lastPollAt,
@@ -696,10 +1798,43 @@ async function sendOutboundChunks(config, state, options) {
696
1798
  throw new Error("Outbound Telegram text is empty.");
697
1799
  }
698
1800
 
1801
+ if ((source === "auto" || source === "auto_progress") && sourceTurnId) {
1802
+ const existing = findRecentOutboundForTurn(config, chatId, source, sourceTurnId, text);
1803
+ if (existing?.outbound) {
1804
+ state.lastOutbound = existing.outbound;
1805
+ appendLog(
1806
+ config.paths.activityFile,
1807
+ `OUT_AUTO_SKIP_DUP chat=${chatId} reply_to=${replyToMessageId || "-"} turn=${sourceTurnId} outbound=${existing.messageIds.join(",")}`
1808
+ );
1809
+ return {
1810
+ ok: true,
1811
+ outbound: existing.outbound,
1812
+ messageIds: existing.messageIds,
1813
+ skippedDuplicate: true
1814
+ };
1815
+ }
1816
+ }
1817
+
699
1818
  const chunks = splitTelegramText(text);
700
1819
  const messageIds = [];
701
1820
  let lastOutbound = null;
702
1821
 
1822
+ const contextEntry = [
1823
+ ...(state.queue || []),
1824
+ ...(state.pendingReplies || [])
1825
+ ].find((entry) => {
1826
+ if (String(entry.chatId || "").trim() !== chatId) {
1827
+ return false;
1828
+ }
1829
+ if (replyToMessageId && String(entry.messageId || entry.replyToMessageId || "").trim() === replyToMessageId) {
1830
+ return true;
1831
+ }
1832
+ if (telegramThreadId && String(entry.telegramThreadId || "").trim() === telegramThreadId) {
1833
+ return true;
1834
+ }
1835
+ return false;
1836
+ }) || null;
1837
+
703
1838
  for (let index = 0; index < chunks.length; index += 1) {
704
1839
  const chunk = chunks[index];
705
1840
  const result = await sendMessage(config, {
@@ -725,6 +1860,36 @@ async function sendOutboundChunks(config, state, options) {
725
1860
  lastOutbound = outbound;
726
1861
  }
727
1862
 
1863
+ if (lastOutbound) {
1864
+ try {
1865
+ const preview = (
1866
+ typeof normalizeWhitespace === "function"
1867
+ ? normalizeWhitespace(repairMojibake(lastOutbound.text))
1868
+ : String(lastOutbound.text || "")
1869
+ .replace(/\r/g, " ")
1870
+ .replace(/\n/g, " ")
1871
+ .replace(/\s+/g, " ")
1872
+ .trim()
1873
+ ).slice(0, 140);
1874
+ const groupTitle = String(contextEntry?.groupTitle || "").trim();
1875
+ const user = String(contextEntry?.user || "").trim();
1876
+ const chatType = String(contextEntry?.chatType || "").trim().toLowerCase();
1877
+ let label = "Antwort";
1878
+ if (groupTitle) {
1879
+ label = `Antwort @ ${groupTitle}`;
1880
+ } else if (chatType === "private" && user) {
1881
+ label = `Antwort an ${user}`;
1882
+ }
1883
+ state.lastUiNotice = {
1884
+ ts: nowIso(),
1885
+ kind: "outbound",
1886
+ text: `${label}: ${preview}`
1887
+ };
1888
+ } catch (error) {
1889
+ appendLog(config.paths.activityFile, `UI_NOTICE_ERROR chat=${chatId} reply_to=${replyToMessageId || "-"}: ${error}`);
1890
+ }
1891
+ }
1892
+
728
1893
  return {
729
1894
  ok: true,
730
1895
  outbound: lastOutbound,
@@ -751,6 +1916,10 @@ export function bindCurrentThread(threadId) {
751
1916
  export async function pollOnce() {
752
1917
  const config = loadConfig();
753
1918
  const state = loadState(config);
1919
+ const parkedAmbientAtStart = parkExpiredAmbientQueueEntriesInPlace(config, state.queue || []);
1920
+ if (parkedAmbientAtStart > 0) {
1921
+ appendLog(config.paths.activityFile, `AMBIENT_PARKED count=${parkedAmbientAtStart}`);
1922
+ }
754
1923
  const startOffset = Number(state.offset || 0);
755
1924
  const updates = await getUpdates(config, startOffset);
756
1925
  let captured = 0;
@@ -768,14 +1937,45 @@ export async function pollOnce() {
768
1937
  appendLog(config.paths.activityFile, `IGNORED chat=${inbound.chatId} message=${inbound.messageId}`);
769
1938
  continue;
770
1939
  }
1940
+ if (String(inbound.chatType || "") === "private" && looksLikeMnemoIdleLoopBrief(inbound.text)) {
1941
+ ignored += 1;
1942
+ appendLog(config.paths.activityFile, `IGNORED_IDLE_BRIEF chat=${inbound.chatId} message=${inbound.messageId}`);
1943
+ continue;
1944
+ }
771
1945
  if (!inbound.text.trim()) {
772
1946
  ignored += 1;
773
1947
  appendLog(config.paths.activityFile, `IGNORED_EMPTY chat=${inbound.chatId} message=${inbound.messageId}`);
774
1948
  continue;
775
1949
  }
1950
+ const continueContext = buildContinueContext(state, inbound);
1951
+ inbound.intent = looksLikeContinueNudge(inbound.text, continueContext) ? "continue_nudge" : "message";
776
1952
  inbound.relevance = classifyInboundRelevance(config, inbound);
1953
+ if (hasKnownInboundMessage(state, inbound)) {
1954
+ ignored += 1;
1955
+ appendLog(config.paths.activityFile, `IGNORED_DUPLICATE chat=${inbound.chatId} message=${inbound.messageId}`);
1956
+ continue;
1957
+ }
1958
+ if (String(inbound.chatType || "") !== "private" && String(inbound.relevance || "") === "ambient") {
1959
+ ignored += 1;
1960
+ appendJsonl(config.paths.inboxFile, { ...inbound, status: "ignored_ambient" });
1961
+ appendLog(config.paths.activityFile, `IGNORED_AMBIENT chat=${inbound.chatId} message=${inbound.messageId} user=${inbound.user}: ${inbound.text.replace(/\s+/g, " ").slice(0, 180)}`);
1962
+ continue;
1963
+ }
1964
+ if (shouldSendTypingIndicator(inbound)) {
1965
+ void sendChatAction(config, {
1966
+ chatId: inbound.chatId,
1967
+ telegramThreadId: inbound.telegramThreadId
1968
+ }).catch(() => {});
1969
+ }
777
1970
  state.queue.push(inbound);
778
1971
  state.lastInbound = inbound;
1972
+ if (shouldPublishInboundUiNotice(inbound)) {
1973
+ state.lastUiNotice = {
1974
+ ts: nowIso(),
1975
+ kind: "inbound",
1976
+ text: formatCompactInboundUiNotice(inbound)
1977
+ };
1978
+ }
779
1979
  appendJsonl(config.paths.inboxFile, inbound);
780
1980
  appendLog(config.paths.activityFile, `IN chat=${inbound.chatId} message=${inbound.messageId} relevance=${inbound.relevance} user=${inbound.user}: ${inbound.text.replace(/\s+/g, " ").slice(0, 180)}`);
781
1981
  captured += 1;
@@ -796,52 +1996,177 @@ export async function pollOnce() {
796
1996
  export function listQueue(limit = 10) {
797
1997
  const config = loadConfig();
798
1998
  const state = loadState(config);
1999
+ const parkedAmbient = parkExpiredAmbientQueueEntriesInPlace(config, state.queue || []);
2000
+ if (parkedAmbient > 0) {
2001
+ appendLog(config.paths.activityFile, `AMBIENT_PARKED count=${parkedAmbient}`);
2002
+ saveStateForConfig(config, state);
2003
+ }
799
2004
  return state.queue.slice(-Math.max(1, limit));
800
2005
  }
801
2006
 
2007
+ function getQueuedDispatchPriority(item) {
2008
+ if (!item || item.status !== "queued") {
2009
+ return Number.MAX_SAFE_INTEGER;
2010
+ }
2011
+ const relevance = String(item.relevance || "").toLowerCase();
2012
+ const chatType = String(item.chatType || "").toLowerCase();
2013
+ if (relevance === "escalation") {
2014
+ return 0;
2015
+ }
2016
+ if (isFastDispatchEntry(item)) {
2017
+ return 1;
2018
+ }
2019
+ if (chatType === "private" || relevance === "direct" || relevance === "lane") {
2020
+ return 2;
2021
+ }
2022
+ return 3;
2023
+ }
2024
+
2025
+ function compareQueuedDispatchOrder(left, right) {
2026
+ const priorityDiff = getQueuedDispatchPriority(left) - getQueuedDispatchPriority(right);
2027
+ if (priorityDiff !== 0) {
2028
+ return priorityDiff;
2029
+ }
2030
+ const leftTs = String(left?.ts || "");
2031
+ const rightTs = String(right?.ts || "");
2032
+ if (leftTs !== rightTs) {
2033
+ return leftTs.localeCompare(rightTs);
2034
+ }
2035
+ const leftMessage = Number.parseInt(String(left?.messageId || "0"), 10);
2036
+ const rightMessage = Number.parseInt(String(right?.messageId || "0"), 10);
2037
+ if (Number.isFinite(leftMessage) && Number.isFinite(rightMessage) && leftMessage !== rightMessage) {
2038
+ return leftMessage - rightMessage;
2039
+ }
2040
+ return String(left?.messageId || "").localeCompare(String(right?.messageId || ""));
2041
+ }
2042
+
2043
+ function selectNextQueuedEntry(queue, options = {}) {
2044
+ const auto = Boolean(options.auto);
2045
+ const deferredMode = String(options.dispatchMode || "deferred").toLowerCase() !== "legacy";
2046
+ const queued = Array.isArray(queue) ? queue.filter((item) => item?.status === "queued") : [];
2047
+ if (!auto || !deferredMode) {
2048
+ return queued.sort(compareQueuedDispatchOrder)[0] || null;
2049
+ }
2050
+ const eligible = queued.filter((item) => {
2051
+ const relevance = String(item.relevance || "").toLowerCase();
2052
+ const chatType = String(item.chatType || "").toLowerCase();
2053
+ return relevance === "escalation" || chatType === "private" || relevance === "direct" || relevance === "lane";
2054
+ });
2055
+ return eligible.sort(compareQueuedDispatchOrder)[0] || null;
2056
+ }
2057
+
2058
+ function isFastDispatchEntry(entry) {
2059
+ if (!entry) {
2060
+ return false;
2061
+ }
2062
+ if (String(entry.intent || "").trim().toLowerCase() === "continue_nudge") {
2063
+ return true;
2064
+ }
2065
+ return false;
2066
+ }
2067
+
802
2068
  export async function injectNext(threadId, options = {}) {
803
2069
  const config = loadConfig();
804
2070
  const state = loadState(config);
2071
+ const parkedAmbient = parkExpiredAmbientQueueEntriesInPlace(config, state.queue || []);
2072
+ const runtimeOwner = getRuntimeOwner(config);
2073
+ state.pendingReplies = reconcilePendingRepliesInPlace(state.pendingReplies || []);
2074
+ const expiredPendingReplies = closeExpiredPendingRepliesInPlace(config, state.pendingReplies || []);
2075
+ if (expiredPendingReplies > 0 || parkedAmbient > 0) {
2076
+ if (parkedAmbient > 0) {
2077
+ appendLog(config.paths.activityFile, `AMBIENT_PARKED count=${parkedAmbient}`);
2078
+ }
2079
+ appendLog(config.paths.activityFile, `PENDING_REPLY_EXPIRED count=${expiredPendingReplies}`);
2080
+ saveStateForConfig(config, state);
2081
+ }
805
2082
  const auto = Boolean(options.auto);
806
2083
  const useAppServer = Boolean(config.appServerWsUrl);
2084
+ if (auto && useAppServer && runtimeOwner && !runtimeOwner.frontendAlive) {
2085
+ appendLog(config.paths.activityFile, `OWNER_OFFLINE frontend_pid=${runtimeOwner.frontendHostPid || 0}`);
2086
+ return {
2087
+ ok: false,
2088
+ status: "deferred",
2089
+ reason: "owner_offline",
2090
+ frontendHostPid: runtimeOwner.frontendHostPid || 0
2091
+ };
2092
+ }
2093
+
2094
+ const next = selectNextQueuedEntry(state.queue || [], {
2095
+ auto,
2096
+ dispatchMode: config.dispatchMode
2097
+ });
2098
+
2099
+ if (!next) {
2100
+ return {
2101
+ ok: true,
2102
+ status: auto ? "deferred" : "empty",
2103
+ reason: auto ? "no_eligible_message" : undefined
2104
+ };
2105
+ }
2106
+
2107
+ if (isAddressOnlyPing(config, next.text) && !looksLikeBotSender(next)) {
2108
+ next.status = "delivered";
2109
+ next.deliveredAt = nowIso();
2110
+ next.threadId = null;
2111
+ next.turnId = null;
2112
+ next.responsePreview = "plugin_ping_ack";
2113
+ next.stderr = null;
2114
+ next.stdout = null;
2115
+ markMatchingQueueEntriesInPlace(state, next, {
2116
+ status: next.status,
2117
+ deliveredAt: next.deliveredAt,
2118
+ threadId: next.threadId,
2119
+ turnId: next.turnId,
2120
+ responsePreview: next.responsePreview,
2121
+ stderr: next.stderr,
2122
+ stdout: next.stdout
2123
+ });
2124
+ appendLog(config.paths.activityFile, `PING_ACK chat=${next.chatId} message=${next.messageId}`);
2125
+ await sendOutboundChunks(config, state, {
2126
+ chatId: next.chatId,
2127
+ text: "Ja, ich bin da.",
2128
+ replyToMessageId: next.messageId,
2129
+ telegramThreadId: next.telegramThreadId,
2130
+ source: "ping_ack"
2131
+ });
2132
+ const latestState = loadState(config);
2133
+ saveStateForConfig(config, mergeStateSnapshots(latestState, state));
2134
+ return {
2135
+ ok: true,
2136
+ status: "delivered",
2137
+ reason: "ping_ack",
2138
+ message: next
2139
+ };
2140
+ }
2141
+
2142
+ const explicitThreadId = String(threadId || "").trim();
807
2143
  const preferredThreadId = (
808
2144
  threadId
809
2145
  || (useAppServer ? config.currentThreadId : state.currentThreadId)
810
2146
  || (useAppServer ? state.currentThreadId : config.currentThreadId)
811
2147
  || ""
812
2148
  ).trim();
813
- const resolvedThreadId = await resolveActiveThreadId(config, state, preferredThreadId);
2149
+ const resolvedThreadId = await resolveActiveThreadId(config, state, preferredThreadId, {
2150
+ forcePreferred: Boolean(explicitThreadId)
2151
+ });
814
2152
  if (!resolvedThreadId) {
815
2153
  throw new Error("No bound thread id. Use bridge_bind_current_thread first.");
816
2154
  }
817
-
818
- let next = null;
819
- if (auto && String(config.dispatchMode || "deferred").toLowerCase() !== "legacy") {
820
- next = state.queue.find((item) => {
821
- if (item.status !== "queued") {
822
- return false;
823
- }
824
- if (String(item.chatType || "") === "private") {
825
- return true;
826
- }
827
- return ["direct", "lane", "escalation"].includes(String(item.relevance || ""));
828
- });
829
- } else {
830
- next = state.queue.find((item) => item.status === "queued");
2155
+ const staleThreadPendingReplies = closeStaleThreadPendingRepliesInPlace(state.pendingReplies || [], resolvedThreadId);
2156
+ if (staleThreadPendingReplies > 0) {
2157
+ appendLog(config.paths.activityFile, `PENDING_REPLY_STALE_THREAD count=${staleThreadPendingReplies} active_thread=${resolvedThreadId}`);
2158
+ saveStateForConfig(config, state);
831
2159
  }
832
2160
 
833
- if (!next) {
834
- return {
835
- ok: true,
836
- status: auto ? "deferred" : "empty",
837
- reason: auto ? "no_eligible_message" : undefined
838
- };
2161
+ const bypassDeferredGate = auto && (next.relevance === "escalation" || isFastDispatchEntry(next));
2162
+ if (bypassDeferredGate && auto && isFastDispatchEntry(next)) {
2163
+ appendLog(config.paths.activityFile, `FAST_TRIGGER_BYPASS chat=${next.chatId} message=${next.messageId} intent=${next.intent}`);
839
2164
  }
840
-
841
- const bypassDeferredGate = auto && next.relevance === "escalation";
842
2165
  if (auto && !bypassDeferredGate && String(config.dispatchMode || "deferred").toLowerCase() !== "legacy") {
843
- const openPendingReplies = countOpenPendingReplies(state);
2166
+ const openPendingReplies = countOpenPendingReplies(state, config);
844
2167
  if (openPendingReplies > 0) {
2168
+ await maybeSendDeferredReceipt(config, state, next, "pending_reply");
2169
+ saveStateForConfig(config, state);
845
2170
  return {
846
2171
  ok: false,
847
2172
  status: "deferred",
@@ -850,14 +2175,16 @@ export async function injectNext(threadId, options = {}) {
850
2175
  };
851
2176
  }
852
2177
 
853
- const sessionActivity = await resolveSessionActivity(config, resolvedThreadId);
2178
+ const sessionActivity = await resolveSessionActivity(config, resolvedThreadId, next);
854
2179
  if (sessionActivity.active) {
2180
+ await maybeSendDeferredReceipt(config, state, next, "session_active");
2181
+ saveStateForConfig(config, state);
855
2182
  return {
856
2183
  ok: false,
857
2184
  status: "deferred",
858
2185
  reason: "session_active",
859
2186
  quietMs: sessionActivity.quietMs,
860
- readyInMs: Math.max(0, Number(config.idleCooldownMs || 0) - Number(sessionActivity.quietMs || 0))
2187
+ readyInMs: Math.max(0, Number(sessionActivity.cooldownMs || 0) - Number(sessionActivity.quietMs || 0))
861
2188
  };
862
2189
  }
863
2190
  }
@@ -915,8 +2242,19 @@ export async function injectNext(threadId, options = {}) {
915
2242
  next.responsePreview = result.responseText.slice(0, 400);
916
2243
  next.stderr = result.stderr.slice(0, 400);
917
2244
  next.stdout = result.stdout.slice(0, 400);
2245
+ markMatchingQueueEntriesInPlace(state, next, {
2246
+ status: next.status,
2247
+ deliveredAt: next.deliveredAt,
2248
+ threadId: next.threadId,
2249
+ turnId: next.turnId,
2250
+ responsePreview: next.responsePreview,
2251
+ stderr: next.stderr,
2252
+ stdout: next.stdout
2253
+ });
918
2254
  if (useAppServer && result.ok) {
919
- if (looksLikeBotSender(next)) {
2255
+ if (!shouldTrackPendingReply(next)) {
2256
+ appendLog(config.paths.activityFile, `REPLY_SKIP_CONTINUE thread=${resolvedThreadId} turn=${next.turnId || "-"} message=${next.messageId} chat=${next.chatId}`);
2257
+ } else if (looksLikeBotSender(next)) {
920
2258
  appendLog(config.paths.activityFile, `REPLY_SKIP_BOT thread=${resolvedThreadId} turn=${next.turnId || "-"} message=${next.messageId} chat=${next.chatId}`);
921
2259
  } else {
922
2260
  const pendingReply = buildPendingReplyEntry(next, resolvedThreadId, next.turnId, sessionPath, sessionOffset);
@@ -942,7 +2280,16 @@ export async function injectNext(threadId, options = {}) {
942
2280
  export async function relayRepliesOnce() {
943
2281
  const config = loadConfig();
944
2282
  const state = loadState(config);
2283
+ const parkedAmbient = parkExpiredAmbientQueueEntriesInPlace(config, state.queue || []);
945
2284
  state.pendingReplies = reconcilePendingRepliesInPlace(state.pendingReplies || []);
2285
+ const supersededPendingReplies = supersedeOlderPendingRepliesInPlace(state.pendingReplies || []);
2286
+ closeExpiredPendingRepliesInPlace(config, state.pendingReplies || []);
2287
+ if (parkedAmbient > 0 || supersededPendingReplies > 0) {
2288
+ appendLog(config.paths.activityFile, `AMBIENT_PARKED count=${parkedAmbient}`);
2289
+ if (supersededPendingReplies > 0) {
2290
+ appendLog(config.paths.activityFile, `PENDING_REPLY_SUPERSEDED count=${supersededPendingReplies}`);
2291
+ }
2292
+ }
946
2293
 
947
2294
  for (const entry of state.pendingReplies || []) {
948
2295
  if (entry.sentAt || entry.status === "error") {
@@ -956,7 +2303,7 @@ export async function relayRepliesOnce() {
956
2303
  entry.responsePreview = entry.responsePreview || "[ignored bot message]";
957
2304
  }
958
2305
 
959
- const pendingReplies = (state.pendingReplies || []).filter((entry) => !entry.sentAt && entry.status !== "error");
2306
+ const pendingReplies = (state.pendingReplies || []).filter((entry) => isReplyAwaitingOutcome(entry));
960
2307
  if (pendingReplies.length === 0) {
961
2308
  saveStateForConfig(config, state);
962
2309
  return { ok: true, status: "empty", delivered: 0 };
@@ -964,7 +2311,8 @@ export async function relayRepliesOnce() {
964
2311
 
965
2312
  let delivered = 0;
966
2313
  const usedTurnIds = new Set();
967
- const sessionCompletions = new Map();
2314
+ const usedProgressKeys = new Set();
2315
+ const sessionSignals = new Map();
968
2316
 
969
2317
  for (const entry of pendingReplies) {
970
2318
  if (!entry.sessionPath) {
@@ -974,11 +2322,12 @@ export async function relayRepliesOnce() {
974
2322
  continue;
975
2323
  }
976
2324
 
977
- if (!sessionCompletions.has(entry.sessionPath)) {
2325
+ if (!sessionSignals.has(entry.sessionPath)) {
978
2326
  const fallbackOffset = pendingReplies
979
2327
  .filter((candidate) => candidate.sessionPath === entry.sessionPath)
980
2328
  .reduce((lowest, candidate) => Math.min(lowest, Number(candidate.sessionOffset || 0)), Number(entry.sessionOffset || 0));
981
- const currentOffset = Number((state.replyOffsets || {})[entry.sessionPath] ?? fallbackOffset);
2329
+ const savedOffset = Number((state.replyOffsets || {})[entry.sessionPath] ?? fallbackOffset);
2330
+ const currentOffset = Math.min(savedOffset, fallbackOffset);
982
2331
  const currentCarry = String((state.replyBuffers || {})[entry.sessionPath] || "");
983
2332
  const delta = readJsonlDelta(entry.sessionPath, currentOffset, currentCarry);
984
2333
  state.replyOffsets = {
@@ -997,21 +2346,128 @@ export async function relayRepliesOnce() {
997
2346
  timestamp: String(item?.timestamp || "").trim()
998
2347
  }))
999
2348
  .filter((item) => item.message);
1000
- sessionCompletions.set(entry.sessionPath, completions);
2349
+ const finalAnswers = delta.items
2350
+ .filter((item) => item?.type === "event_msg" && item?.payload?.type === "agent_message" && String(item?.payload?.phase || "").trim().toLowerCase() === "final_answer")
2351
+ .map((item) => ({
2352
+ message: String(item?.payload?.message || "").trim(),
2353
+ timestamp: String(item?.timestamp || "").trim()
2354
+ }))
2355
+ .filter((item) => item.message);
2356
+ const commentaries = delta.items
2357
+ .filter((item) => item?.type === "event_msg" && item?.payload?.type === "agent_message" && String(item?.payload?.phase || "").trim().toLowerCase() === "commentary")
2358
+ .map((item) => ({
2359
+ message: String(item?.payload?.message || "").trim(),
2360
+ timestamp: String(item?.timestamp || "").trim()
2361
+ }))
2362
+ .filter((item) => item.message);
2363
+ sessionSignals.set(entry.sessionPath, { completions, finalAnswers, commentaries });
2364
+ }
2365
+
2366
+ const signals = sessionSignals.get(entry.sessionPath) || { completions: [], finalAnswers: [], commentaries: [] };
2367
+ const completions = signals.completions || [];
2368
+ const finalAnswers = signals.finalAnswers || [];
2369
+ const commentaries = signals.commentaries || [];
2370
+ const progressRelayMode = getProgressRelayMode(config);
2371
+
2372
+ if (progressRelayMode === "commentary" && !entry.progressSentAt) {
2373
+ const progress = commentaries.find((item) => {
2374
+ const key = `${item.timestamp}|${item.message}`;
2375
+ return item.timestamp >= entry.createdAt && !usedProgressKeys.has(key);
2376
+ });
2377
+ if (progress) {
2378
+ const outboundProgress = await sendOutboundChunks(config, state, {
2379
+ chatId: entry.chatId,
2380
+ text: progress.message,
2381
+ replyToMessageId: entry.replyToMessageId,
2382
+ telegramThreadId: entry.telegramThreadId,
2383
+ source: "auto_progress",
2384
+ sourceTurnId: entry.turnId || null
2385
+ });
2386
+ entry.progressSentAt = nowIso();
2387
+ entry.lastSignalAt = entry.progressSentAt;
2388
+ entry.progressMode = "commentary";
2389
+ entry.progressPreview = progress.message.slice(0, 400);
2390
+ entry.progressMessageIds = outboundProgress.messageIds;
2391
+ entry.progressKey = `${progress.timestamp}|${progress.message}`;
2392
+ usedProgressKeys.add(`${progress.timestamp}|${progress.message}`);
2393
+ appendLog(config.paths.activityFile, `REPLY_PROGRESS_SENT thread=${entry.threadId} turn=${entry.turnId || "-"} chat=${entry.chatId} source_message=${entry.messageId} outbound=${outboundProgress.messageIds.join(",")}`);
2394
+ }
1001
2395
  }
1002
2396
 
1003
- const completions = sessionCompletions.get(entry.sessionPath) || [];
2397
+ if (progressRelayMode !== "off" && !entry.progressSentAt && shouldSendFallbackProgress(config, entry, entry.sessionPath)) {
2398
+ const fallbackText = buildProgressFallbackText(entry);
2399
+ const outboundProgress = await sendOutboundChunks(config, state, {
2400
+ chatId: entry.chatId,
2401
+ text: fallbackText,
2402
+ replyToMessageId: entry.replyToMessageId,
2403
+ telegramThreadId: entry.telegramThreadId,
2404
+ source: "auto_progress",
2405
+ sourceTurnId: entry.turnId || null
2406
+ });
2407
+ entry.progressSentAt = nowIso();
2408
+ entry.lastSignalAt = entry.progressSentAt;
2409
+ entry.progressMode = "fallback";
2410
+ entry.progressPreview = fallbackText.slice(0, 400);
2411
+ entry.progressMessageIds = outboundProgress.messageIds;
2412
+ appendLog(config.paths.activityFile, `REPLY_PROGRESS_FALLBACK thread=${entry.threadId} turn=${entry.turnId || "-"} chat=${entry.chatId} source_message=${entry.messageId} outbound=${outboundProgress.messageIds.join(",")}`);
2413
+ }
2414
+
2415
+ if (progressRelayMode === "commentary" && entry.progressSentAt) {
2416
+ const upgrade = commentaries.find((item) => {
2417
+ const key = `${item.timestamp}|${item.message}`;
2418
+ return item.timestamp >= entry.createdAt
2419
+ && key !== entry.progressKey
2420
+ && !usedProgressKeys.has(key)
2421
+ && shouldSendProgressUpgrade(entry, item);
2422
+ });
2423
+ if (upgrade) {
2424
+ const outboundUpgrade = await sendOutboundChunks(config, state, {
2425
+ chatId: entry.chatId,
2426
+ text: upgrade.message,
2427
+ replyToMessageId: entry.replyToMessageId,
2428
+ telegramThreadId: entry.telegramThreadId,
2429
+ source: "auto_progress",
2430
+ sourceTurnId: entry.turnId || null
2431
+ });
2432
+ entry.progressUpgradeSentAt = nowIso();
2433
+ entry.lastSignalAt = entry.progressUpgradeSentAt;
2434
+ entry.progressUpgradePreview = upgrade.message.slice(0, 400);
2435
+ entry.progressUpgradeMessageIds = outboundUpgrade.messageIds;
2436
+ entry.progressUpgradeKey = `${upgrade.timestamp}|${upgrade.message}`;
2437
+ usedProgressKeys.add(`${upgrade.timestamp}|${upgrade.message}`);
2438
+ appendLog(config.paths.activityFile, `REPLY_PROGRESS_UPGRADE thread=${entry.threadId} turn=${entry.turnId || "-"} chat=${entry.chatId} source_message=${entry.messageId} outbound=${outboundUpgrade.messageIds.join(",")}`);
2439
+ }
2440
+ }
1004
2441
 
1005
2442
  let match = null;
2443
+ const finalAnswer = finalAnswers.find((item) => item.timestamp >= entry.createdAt && !usedProgressKeys.has(`final|${item.timestamp}|${item.message}`));
2444
+ if (finalAnswer) {
2445
+ match = {
2446
+ turnId: entry.turnId || "",
2447
+ message: finalAnswer.message,
2448
+ timestamp: finalAnswer.timestamp,
2449
+ source: "final_answer"
2450
+ };
2451
+ usedProgressKeys.add(`final|${finalAnswer.timestamp}|${finalAnswer.message}`);
2452
+ }
1006
2453
  if (entry.turnId) {
1007
- match = completions.find((item) => item.turnId === entry.turnId);
2454
+ match = match || completions.find((item) => item.turnId === entry.turnId);
1008
2455
  } else {
1009
- match = completions.find((item) => item.timestamp >= entry.createdAt && !usedTurnIds.has(item.turnId));
2456
+ match = match || completions.find((item) => item.timestamp >= entry.createdAt && !usedTurnIds.has(item.turnId));
1010
2457
  }
1011
2458
  if (!match) {
1012
2459
  continue;
1013
2460
  }
1014
2461
 
2462
+ if (entry.intent === "continue_nudge" && (looksLikeAckOnly(match.message) || looksLikeContextRequestOnly(match.message))) {
2463
+ entry.sentAt = nowIso();
2464
+ entry.status = "suppressed_ack";
2465
+ entry.turnId = entry.turnId || match.turnId;
2466
+ entry.responsePreview = match.message.slice(0, 400);
2467
+ usedTurnIds.add(match.turnId);
2468
+ appendLog(config.paths.activityFile, `REPLY_SUPPRESSED_CONTINUE_ACK thread=${entry.threadId} turn=${entry.turnId || "-"} chat=${entry.chatId} source_message=${entry.messageId}`);
2469
+ continue;
2470
+ }
1015
2471
  const outboundResult = await sendOutboundChunks(config, state, {
1016
2472
  chatId: entry.chatId,
1017
2473
  text: match.message,
@@ -1023,8 +2479,16 @@ export async function relayRepliesOnce() {
1023
2479
  entry.sentAt = nowIso();
1024
2480
  entry.status = "sent";
1025
2481
  entry.turnId = entry.turnId || match.turnId;
2482
+ entry.lastSignalAt = entry.sentAt;
1026
2483
  entry.responsePreview = match.message.slice(0, 400);
1027
2484
  entry.responseMessageIds = outboundResult.messageIds;
2485
+ markMatchingQueueEntriesInPlace(state, entry, {
2486
+ status: "delivered",
2487
+ deliveredAt: entry.sentAt,
2488
+ threadId: entry.threadId,
2489
+ turnId: entry.turnId,
2490
+ responsePreview: entry.responsePreview
2491
+ });
1028
2492
  usedTurnIds.add(match.turnId);
1029
2493
  appendLog(config.paths.activityFile, `REPLY_SENT thread=${entry.threadId} turn=${entry.turnId || "-"} chat=${entry.chatId} source_message=${entry.messageId} outbound=${outboundResult.messageIds.join(",")}`);
1030
2494
  delivered += 1;
@@ -1036,7 +2500,7 @@ export async function relayRepliesOnce() {
1036
2500
  ok: true,
1037
2501
  status: delivered > 0 ? "sent" : "pending",
1038
2502
  delivered,
1039
- pending: (state.pendingReplies || []).filter((entry) => !entry.sentAt && entry.status !== "error").length
2503
+ pending: (state.pendingReplies || []).filter((entry) => isNonTerminalPendingReply(entry)).length
1040
2504
  };
1041
2505
  }
1042
2506