@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.
- package/README.md +67 -49
- package/package.json +40 -38
- package/start-codex-agent.ps1 +179 -71
- package/telegram-doctor.ps1 +34 -20
- package/telegram-plugin/.env.example +1 -0
- package/telegram-plugin/README.md +3 -0
- package/telegram-plugin/dispatcher.js +4 -0
- package/telegram-plugin/lib/bridge.js +1550 -86
- package/telegram-plugin/lib/codex.js +142 -21
- package/telegram-plugin/lib/env.js +29 -1
- package/telegram-plugin/lib/paths.js +7 -1
- package/telegram-plugin/lib/sidecars.js +12 -1
- package/telegram-plugin/lib/singleton.js +66 -0
- package/telegram-plugin/lib/storage.js +66 -25
- package/telegram-plugin/lib/telegram.js +8 -0
- package/telegram-plugin/poller.js +4 -0
- package/telegram-plugin/responder.js +4 -0
- package/telegram-setup.ps1 +217 -58
- package/telegram-status.ps1 +292 -182
- package/telegram-title-embed.ps1 +442 -0
- package/telegram-title-watcher.ps1 +454 -0
|
@@ -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
|
-
|
|
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 =
|
|
36
|
-
|
|
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
|
|
357
|
-
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
return true;
|
|
1233
|
+
function getPendingReplyActivityAt(entry) {
|
|
1234
|
+
if (!entry || typeof entry !== "object") {
|
|
1235
|
+
return "";
|
|
364
1236
|
}
|
|
365
|
-
return
|
|
1237
|
+
return String(
|
|
1238
|
+
entry.lastSignalAt
|
|
1239
|
+
|| entry.progressSentAt
|
|
1240
|
+
|| entry.sentAt
|
|
1241
|
+
|| entry.createdAt
|
|
1242
|
+
|| ""
|
|
1243
|
+
).trim();
|
|
366
1244
|
}
|
|
367
1245
|
|
|
368
|
-
function
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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 <
|
|
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
|
|
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).
|
|
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) =>
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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(
|
|
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 (
|
|
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) =>
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
2503
|
+
pending: (state.pendingReplies || []).filter((entry) => isNonTerminalPendingReply(entry)).length
|
|
1040
2504
|
};
|
|
1041
2505
|
}
|
|
1042
2506
|
|