blun-king-cli 9.1.397 → 9.1.398

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/LIESMICH.txt CHANGED
@@ -392,6 +392,20 @@ eingestuft ist. Fehlt dieser Beleg, bleibt das Ziel aktiv und King erhält eine
392
392
  konkrete Nachbesserung statt einer falschen Fertigmeldung. Ziele ohne ausdrücklich
393
393
  gesetzte Abschlussbedingung behalten ihr bisheriges Verhalten.
394
394
 
395
+ Keine doppelte Telegram-Antwort ohne neue Nachricht
396
+ ---------------------------------------------------
397
+
398
+ Ab BLUN King 9.1.398 prüft jeder Text-Ausgangspfad vor dem Senden die jüngste
399
+ erfolgreiche Telegram-Antwort und den jüngsten Eingang desselben Chats. Solange
400
+ danach keine neue Nutzernachricht eingetroffen ist, wird eine wortgleiche oder
401
+ nahezu gleiche Wiederholung nicht erneut gesendet. Das gilt gleichermaßen für
402
+ den Telegram-Werkzeugpfad und beide automatischen Rückfallpfade.
403
+
404
+ Nach einer neuen Nutzernachricht ist dieselbe Antwort wieder zulässig. Andere
405
+ Ergebnisse und Datei-Anhänge bleiben unverändert. Die Prüfung liest nur begrenzte
406
+ Endbereiche der Ein- und Ausgangsprotokolle und fällt bei fehlendem Beleg offen
407
+ zurück, damit Telegram nicht wegen einer beschädigten Protokollzeile blockiert.
408
+
395
409
  Angehängte Bilder werden weiterhin mit ReadMediaFile gelesen. Bild-, Video- und
396
410
  Spracherzeugung laufen asynchron, sodass die Konsole während der Verarbeitung
397
411
  nutzbar bleibt. GenerateVideo kann außerdem einen abgeschlossenen Bildauftrag
package/README.md CHANGED
@@ -401,6 +401,19 @@ eingestuft ist. Fehlt dieser Beleg, bleibt das Ziel aktiv und King erhält eine
401
401
  konkrete Nachbesserung statt einer falschen Fertigmeldung. Ziele ohne ausdrücklich
402
402
  gesetzte Abschlussbedingung behalten ihr bisheriges Verhalten.
403
403
 
404
+ ## Keine doppelte Telegram-Antwort ohne neue Nachricht
405
+
406
+ Ab BLUN King 9.1.398 prüft jeder Text-Ausgangspfad vor dem Senden die jüngste
407
+ erfolgreiche Telegram-Antwort und den jüngsten Eingang desselben Chats. Solange
408
+ danach keine neue Nutzernachricht eingetroffen ist, wird eine wortgleiche oder
409
+ nahezu gleiche Wiederholung nicht erneut gesendet. Das gilt gleichermaßen für
410
+ den Telegram-Werkzeugpfad und beide automatischen Rückfallpfade.
411
+
412
+ Nach einer neuen Nutzernachricht ist dieselbe Antwort wieder zulässig. Andere
413
+ Ergebnisse und Datei-Anhänge bleiben unverändert. Die Prüfung liest nur begrenzte
414
+ Endbereiche der Ein- und Ausgangsprotokolle und fällt bei fehlendem Beleg offen
415
+ zurück, damit Telegram nicht wegen einer beschädigten Protokollzeile blockiert.
416
+
404
417
  Angehängte Bilder werden weiterhin mit `ReadMediaFile` gelesen. Bild-, Video-
405
418
  und Spracherzeugung laufen asynchron, sodass die Konsole während der Verarbeitung
406
419
  nutzbar bleibt. `GenerateVideo` kann außerdem einen abgeschlossenen Bildauftrag
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { closeSync, openSync, readSync, statSync } = require('node:fs');
4
+
3
5
  const PRIVATE_CHAT_ID = /^[1-9]\d*$/u;
4
6
  const WORK_PERMISSION_QUESTION = /^(?:kann|darf|soll) ich\b.{0,100}\b(?:arbeit(?:en)?|weiterarbeiten|weitermachen|fortfahren|weiterbauen|umbau|bau)\b/u;
5
7
  const TRAILING_WORK_PERMISSION_QUESTION = /(?:^|\r?\n\s*\r?\n)(?:kann|darf|soll)\s+ich\b[^\r\n]{0,160}\b(?:arbeit(?:en)?|weiterarbeiten|weitermachen|fortfahren|weiterbauen|umbau|bau)\b[^\r\n]*$/iu;
@@ -10,6 +12,12 @@ const MEDIA_PROGRESS_MARKER = /\b(?:bildgenerierung|bildjob|generateimage|getmed
10
12
  const MEDIA_PENDING_STATE = /\b(?:auftrag wurde angenommen|job wurde angenommen|job angenommen|aufruf wurde abgesetzt|angestossen|angestoßen|processing|verarbeitung)\b/u;
11
13
  const MEDIA_NO_RESULT = /\b(?:kein neues bild|noch kein bild|kein bild liefern|nichts angekommen|nichts neues gelandet|nicht verfuegbar|nicht verfügbar|getmedia fehlt)\b/u;
12
14
 
15
+ const DUPLICATE_LOG_TAIL_BYTES = 512 * 1024;
16
+ const DUPLICATE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
17
+ const NEAR_DUPLICATE_MIN_CHARS = 120;
18
+ const NEAR_DUPLICATE_SIMILARITY = 0.88;
19
+ const SUCCESSFUL_REPLY_KINDS = new Set(['reply', 'reply-fallback']);
20
+
13
21
  function normalize(text) {
14
22
  return String(text ?? '')
15
23
  .normalize('NFKC')
@@ -19,6 +27,133 @@ function normalize(text) {
19
27
  .trim();
20
28
  }
21
29
 
30
+ function readJsonlTail(file, maxBytes = DUPLICATE_LOG_TAIL_BYTES) {
31
+ try {
32
+ const size = statSync(file).size;
33
+ if (size <= 0) return [];
34
+ const start = Math.max(0, size - maxBytes);
35
+ const length = size - start;
36
+ const buffer = Buffer.allocUnsafe(length);
37
+ const descriptor = openSync(file, 'r');
38
+ let bytesRead = 0;
39
+ try {
40
+ while (bytesRead < length) {
41
+ const count = readSync(descriptor, buffer, bytesRead, length - bytesRead, start + bytesRead);
42
+ if (count === 0) break;
43
+ bytesRead += count;
44
+ }
45
+ } finally {
46
+ closeSync(descriptor);
47
+ }
48
+ let raw = buffer.subarray(0, bytesRead).toString('utf8');
49
+ if (start > 0) {
50
+ const firstNewline = raw.indexOf('\n');
51
+ raw = firstNewline < 0 ? '' : raw.slice(firstNewline + 1);
52
+ }
53
+ return raw.split(/\r?\n/u).flatMap((line) => {
54
+ if (line.trim().length === 0) return [];
55
+ try {
56
+ const entry = JSON.parse(line);
57
+ return typeof entry === 'object' && entry !== null ? [entry] : [];
58
+ } catch {
59
+ return [];
60
+ }
61
+ });
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ function normalizedTokens(text) {
68
+ return normalize(text).match(/[\p{L}\p{N}_]+/gu) ?? [];
69
+ }
70
+
71
+ function tokenBigrams(tokens) {
72
+ const bigrams = [];
73
+ for (let index = 0; index + 1 < tokens.length; index += 1) {
74
+ bigrams.push(`${tokens[index]}\u0000${tokens[index + 1]}`);
75
+ }
76
+ return bigrams;
77
+ }
78
+
79
+ function diceSimilarity(leftItems, rightItems) {
80
+ const left = new Set(leftItems);
81
+ const right = new Set(rightItems);
82
+ if (left.size === 0 || right.size === 0) return 0;
83
+ let intersection = 0;
84
+ for (const item of left) {
85
+ if (right.has(item)) intersection += 1;
86
+ }
87
+ return (2 * intersection) / (left.size + right.size);
88
+ }
89
+
90
+ function outboundReplySimilarity(left, right) {
91
+ const leftNormalized = normalize(left);
92
+ const rightNormalized = normalize(right);
93
+ if (leftNormalized.length === 0 || rightNormalized.length === 0) return 0;
94
+ if (leftNormalized === rightNormalized) return 1;
95
+ if (leftNormalized.length < NEAR_DUPLICATE_MIN_CHARS || rightNormalized.length < NEAR_DUPLICATE_MIN_CHARS) return 0;
96
+ const leftTokens = normalizedTokens(leftNormalized);
97
+ const rightTokens = normalizedTokens(rightNormalized);
98
+ if (leftTokens.length < 12 || rightTokens.length < 12) return 0;
99
+ const tokenScore = diceSimilarity(leftTokens, rightTokens);
100
+ const bigramScore = diceSimilarity(tokenBigrams(leftTokens), tokenBigrams(rightTokens));
101
+ return (tokenScore + bigramScore) / 2;
102
+ }
103
+
104
+ function entryTime(entry) {
105
+ const raw = entry?.ts ?? entry?.meta?.timestamp;
106
+ const value = Date.parse(String(raw ?? ''));
107
+ return Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY;
108
+ }
109
+
110
+ function entryChatId(entry) {
111
+ return String(entry?.chat_id ?? entry?.meta?.chat_id ?? '');
112
+ }
113
+
114
+ function findDuplicateReplyWithoutNewInbound({
115
+ chatId,
116
+ inboundFile,
117
+ now = Date.now(),
118
+ outboxFile,
119
+ text,
120
+ }) {
121
+ const wantedChat = String(chatId ?? '').trim();
122
+ const candidate = String(text ?? '').trim();
123
+ if (wantedChat.length === 0 || candidate.length === 0) return null;
124
+
125
+ const outbound = readJsonlTail(outboxFile).filter((entry) => (
126
+ SUCCESSFUL_REPLY_KINDS.has(entry.kind)
127
+ && entryChatId(entry) === wantedChat
128
+ && typeof entry.text === 'string'
129
+ ));
130
+ if (outbound.length === 0) return null;
131
+ const latestOutboundAt = Math.max(...outbound.map(entryTime));
132
+ if (!Number.isFinite(latestOutboundAt)) return null;
133
+
134
+ const inboundTimes = readJsonlTail(inboundFile)
135
+ .filter((entry) => entryChatId(entry) === wantedChat)
136
+ .map(entryTime)
137
+ .filter(Number.isFinite);
138
+ if (inboundTimes.length === 0) return null;
139
+ if (Math.max(...inboundTimes) > latestOutboundAt) return null;
140
+
141
+ const cutoff = Number(now) - DUPLICATE_MAX_AGE_MS;
142
+ for (let index = outbound.length - 1; index >= 0; index -= 1) {
143
+ const previous = outbound[index];
144
+ const previousAt = entryTime(previous);
145
+ if (previousAt < cutoff || previousAt > Number(now)) continue;
146
+ const similarity = outboundReplySimilarity(previous.text, candidate);
147
+ if (similarity < NEAR_DUPLICATE_SIMILARITY) continue;
148
+ return {
149
+ kind: previous.kind,
150
+ similarity,
151
+ ts: previous.ts,
152
+ };
153
+ }
154
+ return null;
155
+ }
156
+
22
157
  function sanitizePrivateConversationReply(chatId, text) {
23
158
  const value = String(text ?? '');
24
159
  if (!PRIVATE_CHAT_ID.test(String(chatId ?? '').trim())) return value;
@@ -38,6 +173,12 @@ function isPrivateInternalStatusReply(chatId, text) {
38
173
  }
39
174
 
40
175
  module.exports = {
176
+ DUPLICATE_LOG_TAIL_BYTES,
177
+ DUPLICATE_MAX_AGE_MS,
178
+ NEAR_DUPLICATE_MIN_CHARS,
179
+ NEAR_DUPLICATE_SIMILARITY,
180
+ findDuplicateReplyWithoutNewInbound,
41
181
  isPrivateInternalStatusReply,
182
+ outboundReplySimilarity,
42
183
  sanitizePrivateConversationReply,
43
184
  };
package/blun.mjs CHANGED
@@ -514729,7 +514729,7 @@ var retryTelegramMediaDelivery, retryableTelegramMediaFailure;
514729
514729
  var createMediaAutoRetrievalController, parseAcceptedMediaJob, validateCompletedMedia;
514730
514730
  ({ createMediaAutoRetrievalController, parseAcceptedMediaJob } = createRequire(import.meta.url)("./bin/media-auto-retrieval-policy.cjs"));
514731
514731
  ({ validateCompletedMedia } = createRequire(import.meta.url)("./bin/media-result-policy.cjs"));
514732
- var { isPrivateInternalStatusReply, sanitizePrivateConversationReply } = createRequire(import.meta.url)("./bin/telegram-private-conversation-policy.cjs");
514732
+ var { findDuplicateReplyWithoutNewInbound, isPrivateInternalStatusReply, sanitizePrivateConversationReply } = createRequire(import.meta.url)("./bin/telegram-private-conversation-policy.cjs");
514733
514733
  const TELEGRAM_TEXT_LIMIT = 4096;
514734
514734
  const TELEGRAM_ATTACHMENT_LIMIT = 50 * 1024 * 1024;
514735
514735
  function mediaTelegramTarget(filePath) {
@@ -514838,6 +514838,23 @@ async function sendReplyFallback(chatId, text, contextOnly = false) {
514838
514838
  } catch {}
514839
514839
  return true;
514840
514840
  }
514841
+ if (findDuplicateReplyWithoutNewInbound({
514842
+ chatId,
514843
+ inboundFile: join$4(channelDir(), "inbox.jsonl"),
514844
+ outboxFile: outboxPath(),
514845
+ text
514846
+ }) !== null) {
514847
+ try {
514848
+ appendFileSync(outboxPath(), `${JSON.stringify({
514849
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
514850
+ direction: "out",
514851
+ kind: "reply-fallback-suppressed-duplicate-without-new-inbound",
514852
+ chat_id: String(chatId),
514853
+ text
514854
+ })}\n`);
514855
+ } catch {}
514856
+ return true;
514857
+ }
514841
514858
  const privateSafeText = sanitizePrivateConversationReply(chatId, text);
514842
514859
  if (isGroupChat(chatId) && isGroupSuppressed(text, contextOnly)) {
514843
514860
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blun-king-cli",
3
- "version": "9.1.397",
3
+ "version": "9.1.398",
4
4
  "description": "BLUN CLI - your own AI agent with a Telegram channel. Get it done. With BLUN.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { closeSync, openSync, readSync, statSync } = require('node:fs');
4
+
3
5
  const PRIVATE_CHAT_ID = /^[1-9]\d*$/u;
4
6
  const WORK_PERMISSION_QUESTION = /^(?:kann|darf|soll) ich\b.{0,100}\b(?:arbeit(?:en)?|weiterarbeiten|weitermachen|fortfahren|weiterbauen|umbau|bau)\b/u;
5
7
  const TRAILING_WORK_PERMISSION_QUESTION = /(?:^|\r?\n\s*\r?\n)(?:kann|darf|soll)\s+ich\b[^\r\n]{0,160}\b(?:arbeit(?:en)?|weiterarbeiten|weitermachen|fortfahren|weiterbauen|umbau|bau)\b[^\r\n]*$/iu;
@@ -10,6 +12,12 @@ const MEDIA_PROGRESS_MARKER = /\b(?:bildgenerierung|bildjob|generateimage|getmed
10
12
  const MEDIA_PENDING_STATE = /\b(?:auftrag wurde angenommen|job wurde angenommen|job angenommen|aufruf wurde abgesetzt|angestossen|angestoßen|processing|verarbeitung)\b/u;
11
13
  const MEDIA_NO_RESULT = /\b(?:kein neues bild|noch kein bild|kein bild liefern|nichts angekommen|nichts neues gelandet|nicht verfuegbar|nicht verfügbar|getmedia fehlt)\b/u;
12
14
 
15
+ const DUPLICATE_LOG_TAIL_BYTES = 512 * 1024;
16
+ const DUPLICATE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
17
+ const NEAR_DUPLICATE_MIN_CHARS = 120;
18
+ const NEAR_DUPLICATE_SIMILARITY = 0.88;
19
+ const SUCCESSFUL_REPLY_KINDS = new Set(['reply', 'reply-fallback']);
20
+
13
21
  function normalize(text) {
14
22
  return String(text ?? '')
15
23
  .normalize('NFKC')
@@ -19,6 +27,133 @@ function normalize(text) {
19
27
  .trim();
20
28
  }
21
29
 
30
+ function readJsonlTail(file, maxBytes = DUPLICATE_LOG_TAIL_BYTES) {
31
+ try {
32
+ const size = statSync(file).size;
33
+ if (size <= 0) return [];
34
+ const start = Math.max(0, size - maxBytes);
35
+ const length = size - start;
36
+ const buffer = Buffer.allocUnsafe(length);
37
+ const descriptor = openSync(file, 'r');
38
+ let bytesRead = 0;
39
+ try {
40
+ while (bytesRead < length) {
41
+ const count = readSync(descriptor, buffer, bytesRead, length - bytesRead, start + bytesRead);
42
+ if (count === 0) break;
43
+ bytesRead += count;
44
+ }
45
+ } finally {
46
+ closeSync(descriptor);
47
+ }
48
+ let raw = buffer.subarray(0, bytesRead).toString('utf8');
49
+ if (start > 0) {
50
+ const firstNewline = raw.indexOf('\n');
51
+ raw = firstNewline < 0 ? '' : raw.slice(firstNewline + 1);
52
+ }
53
+ return raw.split(/\r?\n/u).flatMap((line) => {
54
+ if (line.trim().length === 0) return [];
55
+ try {
56
+ const entry = JSON.parse(line);
57
+ return typeof entry === 'object' && entry !== null ? [entry] : [];
58
+ } catch {
59
+ return [];
60
+ }
61
+ });
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ function normalizedTokens(text) {
68
+ return normalize(text).match(/[\p{L}\p{N}_]+/gu) ?? [];
69
+ }
70
+
71
+ function tokenBigrams(tokens) {
72
+ const bigrams = [];
73
+ for (let index = 0; index + 1 < tokens.length; index += 1) {
74
+ bigrams.push(`${tokens[index]}\u0000${tokens[index + 1]}`);
75
+ }
76
+ return bigrams;
77
+ }
78
+
79
+ function diceSimilarity(leftItems, rightItems) {
80
+ const left = new Set(leftItems);
81
+ const right = new Set(rightItems);
82
+ if (left.size === 0 || right.size === 0) return 0;
83
+ let intersection = 0;
84
+ for (const item of left) {
85
+ if (right.has(item)) intersection += 1;
86
+ }
87
+ return (2 * intersection) / (left.size + right.size);
88
+ }
89
+
90
+ function outboundReplySimilarity(left, right) {
91
+ const leftNormalized = normalize(left);
92
+ const rightNormalized = normalize(right);
93
+ if (leftNormalized.length === 0 || rightNormalized.length === 0) return 0;
94
+ if (leftNormalized === rightNormalized) return 1;
95
+ if (leftNormalized.length < NEAR_DUPLICATE_MIN_CHARS || rightNormalized.length < NEAR_DUPLICATE_MIN_CHARS) return 0;
96
+ const leftTokens = normalizedTokens(leftNormalized);
97
+ const rightTokens = normalizedTokens(rightNormalized);
98
+ if (leftTokens.length < 12 || rightTokens.length < 12) return 0;
99
+ const tokenScore = diceSimilarity(leftTokens, rightTokens);
100
+ const bigramScore = diceSimilarity(tokenBigrams(leftTokens), tokenBigrams(rightTokens));
101
+ return (tokenScore + bigramScore) / 2;
102
+ }
103
+
104
+ function entryTime(entry) {
105
+ const raw = entry?.ts ?? entry?.meta?.timestamp;
106
+ const value = Date.parse(String(raw ?? ''));
107
+ return Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY;
108
+ }
109
+
110
+ function entryChatId(entry) {
111
+ return String(entry?.chat_id ?? entry?.meta?.chat_id ?? '');
112
+ }
113
+
114
+ function findDuplicateReplyWithoutNewInbound({
115
+ chatId,
116
+ inboundFile,
117
+ now = Date.now(),
118
+ outboxFile,
119
+ text,
120
+ }) {
121
+ const wantedChat = String(chatId ?? '').trim();
122
+ const candidate = String(text ?? '').trim();
123
+ if (wantedChat.length === 0 || candidate.length === 0) return null;
124
+
125
+ const outbound = readJsonlTail(outboxFile).filter((entry) => (
126
+ SUCCESSFUL_REPLY_KINDS.has(entry.kind)
127
+ && entryChatId(entry) === wantedChat
128
+ && typeof entry.text === 'string'
129
+ ));
130
+ if (outbound.length === 0) return null;
131
+ const latestOutboundAt = Math.max(...outbound.map(entryTime));
132
+ if (!Number.isFinite(latestOutboundAt)) return null;
133
+
134
+ const inboundTimes = readJsonlTail(inboundFile)
135
+ .filter((entry) => entryChatId(entry) === wantedChat)
136
+ .map(entryTime)
137
+ .filter(Number.isFinite);
138
+ if (inboundTimes.length === 0) return null;
139
+ if (Math.max(...inboundTimes) > latestOutboundAt) return null;
140
+
141
+ const cutoff = Number(now) - DUPLICATE_MAX_AGE_MS;
142
+ for (let index = outbound.length - 1; index >= 0; index -= 1) {
143
+ const previous = outbound[index];
144
+ const previousAt = entryTime(previous);
145
+ if (previousAt < cutoff || previousAt > Number(now)) continue;
146
+ const similarity = outboundReplySimilarity(previous.text, candidate);
147
+ if (similarity < NEAR_DUPLICATE_SIMILARITY) continue;
148
+ return {
149
+ kind: previous.kind,
150
+ similarity,
151
+ ts: previous.ts,
152
+ };
153
+ }
154
+ return null;
155
+ }
156
+
22
157
  function sanitizePrivateConversationReply(chatId, text) {
23
158
  const value = String(text ?? '');
24
159
  if (!PRIVATE_CHAT_ID.test(String(chatId ?? '').trim())) return value;
@@ -38,6 +173,12 @@ function isPrivateInternalStatusReply(chatId, text) {
38
173
  }
39
174
 
40
175
  module.exports = {
176
+ DUPLICATE_LOG_TAIL_BYTES,
177
+ DUPLICATE_MAX_AGE_MS,
178
+ NEAR_DUPLICATE_MIN_CHARS,
179
+ NEAR_DUPLICATE_SIMILARITY,
180
+ findDuplicateReplyWithoutNewInbound,
41
181
  isPrivateInternalStatusReply,
182
+ outboundReplySimilarity,
42
183
  sanitizePrivateConversationReply,
43
184
  };
@@ -11,7 +11,7 @@ import telegramApprovalRelay from "../../bin/telegram-approval-relay.cjs";
11
11
  import privateConversationPolicy from "../bin/telegram-private-conversation-policy.cjs";
12
12
  const { buildTelegramRemoteStatus, resolveTelegramRemoteVersion } = remoteStatusPolicy;
13
13
  const { buildTelegramApprovalCard, isAuthorizedTelegramApprovalCallback, listPendingTelegramApprovals, markTelegramApprovalSent, parseTelegramApprovalCallback, resolveTelegramApprovalTarget, wasTelegramApprovalSent, writeTelegramApprovalResponse } = telegramApprovalRelay;
14
- const { isPrivateInternalStatusReply, sanitizePrivateConversationReply } = privateConversationPolicy;
14
+ const { findDuplicateReplyWithoutNewInbound, isPrivateInternalStatusReply, sanitizePrivateConversationReply } = privateConversationPolicy;
15
15
  //#region ../../node_modules/pathe/dist/shared/pathe.M-eThtNZ.mjs
16
16
  const _DRIVE_LETTER_START_RE = /^[A-Za-z]:\//;
17
17
  function normalizeWindowsPath(input = "") {
@@ -4199,6 +4199,22 @@ async function sendReplyFallback(chatId, rawText) {
4199
4199
  logLine(`reply-fallback suppressed (private internal): ${rawText.slice(0, 60)}`);
4200
4200
  return true;
4201
4201
  }
4202
+ if (findDuplicateReplyWithoutNewInbound({
4203
+ chatId,
4204
+ inboundFile: inboxLog(),
4205
+ outboxFile: outboxLog(),
4206
+ text: rawText
4207
+ }) !== null) {
4208
+ appendFileSync(outboxLog(), `${JSON.stringify({
4209
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
4210
+ direction: "out",
4211
+ kind: "reply-fallback-suppressed-duplicate-without-new-inbound",
4212
+ chat_id: String(chatId),
4213
+ text: rawText
4214
+ })}\n`);
4215
+ logLine(`reply-fallback suppressed (duplicate without new inbound): ${rawText.slice(0, 60)}`);
4216
+ return true;
4217
+ }
4202
4218
  const privateSafeText = sanitizePrivateConversationReply(chatId, rawText);
4203
4219
  if (isGroupChat(chatId) && isGroupNoiseReply(rawText)) {
4204
4220
  logLine(`reply-fallback suppressed (group meta-noise): ${rawText.slice(0, 60)}`);
@@ -9667,7 +9667,7 @@ var StdioServerTransport = class {
9667
9667
  * chats the inbound gate would deliver from.
9668
9668
  */
9669
9669
  var import_out = require_out();
9670
- const { isPrivateInternalStatusReply, sanitizePrivateConversationReply } = privateConversationPolicy;
9670
+ const { findDuplicateReplyWithoutNewInbound, isPrivateInternalStatusReply, sanitizePrivateConversationReply } = privateConversationPolicy;
9671
9671
  const TOOL_DEFINITIONS = [
9672
9672
  {
9673
9673
  name: "reply",
@@ -9787,34 +9787,17 @@ async function runTool(api, token, name, args) {
9787
9787
  }
9788
9788
  }
9789
9789
  /**
9790
- * Idempotency guard: models occasionally call reply multiple times with the
9791
- * same text. A message that
9792
- * innerhalb des Fensters bereits wortgleich an denselben Chat ging, wird nicht
9793
- * erneut gesendet — das Tool meldet dem Modell "bereits zugestellt", damit es
9794
- * nicht weiter retryt. Datei-Anhänge sind nie betroffen.
9790
+ * Idempotency guard for text-only replies. If no newer inbound message exists,
9791
+ * an exact or near-identical successful reply is not sent again. Attachments
9792
+ * are never affected.
9795
9793
  */
9796
- const DUPLICATE_WINDOW_MS = 3e5;
9797
- const OUTBOX_TAIL_BYTES = 32768;
9798
9794
  function isRecentDuplicate(chatId, text) {
9799
- try {
9800
- const file = outboxLog();
9801
- const size = statSync(file).size;
9802
- const raw = readFileSync(file, "utf8");
9803
- const tail = size > OUTBOX_TAIL_BYTES ? raw.slice(raw.length - OUTBOX_TAIL_BYTES) : raw;
9804
- const wanted = text.trim();
9805
- const cutoff = Date.now() - DUPLICATE_WINDOW_MS;
9806
- for (const line of tail.split("\n")) {
9807
- if (line.trim().length === 0) continue;
9808
- try {
9809
- const entry = JSON.parse(line);
9810
- if (entry.kind !== "reply" && entry.kind !== "reply-fallback") continue;
9811
- if (String(entry.chat_id) !== String(chatId)) continue;
9812
- if (entry.ts === void 0 || Date.parse(entry.ts) < cutoff) continue;
9813
- if ((entry.text ?? "").trim() === wanted) return true;
9814
- } catch {}
9815
- }
9816
- } catch {}
9817
- return false;
9795
+ return findDuplicateReplyWithoutNewInbound({
9796
+ chatId,
9797
+ inboundFile: inboxLog(),
9798
+ outboxFile: outboxLog(),
9799
+ text
9800
+ }) !== null;
9818
9801
  }
9819
9802
  function telegramErrorMessage(error) {
9820
9803
  return error instanceof Error ? error.message : String(error);