blun-king-cli 9.1.396 → 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
@@ -381,6 +381,31 @@ Anweisung wie "Zeig mir den letzten Telegram-Verlauf" das passende Werkzeug im
381
381
  selben Modellschritt erhalten, während Begrüßungen und fachfremde Anfragen keine
382
382
  zusätzlichen Schemata laden.
383
383
 
384
+ Abschlussbedingungen mit Laufzeitbeleg
385
+ --------------------------------------
386
+
387
+ Ab BLUN King 9.1.397 kann ein autonomes Ziel mit ausdrücklich gesetzter
388
+ Abschlussbedingung nicht mehr allein aufgrund einer Textbehauptung als fertig
389
+ markiert werden. Vor dem Abschluss muss ein aktueller Prüf-Checkpoint vorliegen,
390
+ der auf mindestens einem erfolgreichen Laufzeitwerkzeug beruht und als verifiziert
391
+ eingestuft ist. Fehlt dieser Beleg, bleibt das Ziel aktiv und King erhält eine
392
+ konkrete Nachbesserung statt einer falschen Fertigmeldung. Ziele ohne ausdrücklich
393
+ gesetzte Abschlussbedingung behalten ihr bisheriges Verhalten.
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
+
384
409
  Angehängte Bilder werden weiterhin mit ReadMediaFile gelesen. Bild-, Video- und
385
410
  Spracherzeugung laufen asynchron, sodass die Konsole während der Verarbeitung
386
411
  nutzbar bleibt. GenerateVideo kann außerdem einen abgeschlossenen Bildauftrag
package/README.md CHANGED
@@ -391,6 +391,29 @@ Anweisung wie „Zeig mir den letzten Telegram-Verlauf“ das passende Werkzeug
391
391
  selben Modellschritt erhalten, während Begrüßungen und fachfremde Anfragen keine
392
392
  zusätzlichen Schemata laden.
393
393
 
394
+ ## Abschlussbedingungen mit Laufzeitbeleg
395
+
396
+ Ab BLUN King 9.1.397 kann ein autonomes Ziel mit ausdrücklich gesetzter
397
+ Abschlussbedingung nicht mehr allein aufgrund einer Textbehauptung als fertig
398
+ markiert werden. Vor dem Abschluss muss ein aktueller Prüf-Checkpoint vorliegen,
399
+ der auf mindestens einem erfolgreichen Laufzeitwerkzeug beruht und als verifiziert
400
+ eingestuft ist. Fehlt dieser Beleg, bleibt das Ziel aktiv und King erhält eine
401
+ konkrete Nachbesserung statt einer falschen Fertigmeldung. Ziele ohne ausdrücklich
402
+ gesetzte Abschlussbedingung behalten ihr bisheriges Verhalten.
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
+
394
417
  Angehängte Bilder werden weiterhin mit `ReadMediaFile` gelesen. Bild-, Video-
395
418
  und Spracherzeugung laufen asynchron, sodass die Konsole während der Verarbeitung
396
419
  nutzbar bleibt. `GenerateVideo` kann außerdem einen abgeschlossenen Bildauftrag
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ function hasCompletionCriterion(goal) {
4
+ return typeof goal?.completionCriterion === 'string'
5
+ && goal.completionCriterion.trim().length > 0;
6
+ }
7
+
8
+ function successfulRuntimeEvidence(checkpoint) {
9
+ const successfulTools = Number(checkpoint?.evidenceReceipt?.successfulTools);
10
+ return Number.isSafeInteger(successfulTools) && successfulTools > 0;
11
+ }
12
+
13
+ function evaluateGoalCompletionEvidence(goal) {
14
+ if (!hasCompletionCriterion(goal)) {
15
+ return {
16
+ required: false,
17
+ allPassed: true,
18
+ result: 'satisfied',
19
+ gaps: [],
20
+ };
21
+ }
22
+
23
+ const checkpoint = goal?.actionCheckpoint;
24
+ if (!checkpoint || typeof checkpoint !== 'object' || Array.isArray(checkpoint)) {
25
+ return {
26
+ required: true,
27
+ allPassed: false,
28
+ result: 'needs_revision',
29
+ gaps: ['Save a verify-phase action checkpoint before completing the goal.'],
30
+ };
31
+ }
32
+
33
+ const gaps = [];
34
+ if (checkpoint.phase !== 'verify') {
35
+ gaps.push('The latest action checkpoint must be in the verify phase.');
36
+ }
37
+ if (checkpoint.evidenceBasis !== 'runtime_tool') {
38
+ gaps.push('The completion proof must come from a runtime tool.');
39
+ }
40
+ if (checkpoint.epistemicState !== 'verified') {
41
+ gaps.push('The completion proof must be classified as verified.');
42
+ }
43
+ if (!successfulRuntimeEvidence(checkpoint)) {
44
+ gaps.push('The runtime evidence receipt must contain at least one successful tool result.');
45
+ }
46
+
47
+ return {
48
+ required: true,
49
+ allPassed: gaps.length === 0,
50
+ result: gaps.length === 0 ? 'satisfied' : 'needs_revision',
51
+ gaps,
52
+ };
53
+ }
54
+
55
+ module.exports = {
56
+ evaluateGoalCompletionEvidence,
57
+ };
@@ -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
@@ -21447,6 +21447,7 @@ var { projectSoulText } = createRequire(import.meta.url)("./bin/soul-organizatio
21447
21447
  var { soulFileInstruction } = createRequire(import.meta.url)("./bin/soul-preservation-policy.cjs");
21448
21448
  var { readProfilePersona, resolveSoulFile } = createRequire(import.meta.url)("./bin/profile-identity-resolution.cjs");
21449
21449
  var { advanceActionEvidenceReceipt, assertActionCheckpointEvidenceBasis, assertActionCheckpointRevision, emptyActionEvidenceReceipt, normalizeActionCheckpoint, projectActionCheckpoint } = createRequire(import.meta.url)("./bin/cognitive-action-checkpoint.cjs");
21450
+ var { evaluateGoalCompletionEvidence } = createRequire(import.meta.url)("./bin/goal-completion-evidence-policy.cjs");
21450
21451
  async function prepareSystemPromptContext(kaos, brandHome, options) {
21451
21452
  const additionalDirs = normalizeAdditionalDirs(options?.additionalDirs ?? []);
21452
21453
  const [cwdListing, agentsMdResult, additionalDirsInfo] = await Promise.all([
@@ -230394,6 +230395,10 @@ var init_goal$1 = __esmMin((() => {
230394
230395
  async markComplete(input = {}, actor = "model") {
230395
230396
  const state = this.state;
230396
230397
  if (state === void 0 || state.status !== "active") return null;
230398
+ const completionEvidence = evaluateGoalCompletionEvidence(state);
230399
+ if (!completionEvidence.allPassed) {
230400
+ throw new BlunError(ErrorCodes.GOAL_STATUS_INVALID, `Completion criterion needs revision: ${completionEvidence.gaps.join(" ")} Run the required verification, save a runtime-backed verify checkpoint, then mark the goal complete.`);
230401
+ }
230397
230402
  const mcInjector = this.agent.injection?.getMissionContractInjector();
230398
230403
  if (mcInjector) {
230399
230404
  const evaluation = mcInjector.evaluate();
@@ -262710,7 +262715,7 @@ var init_outcome_prompts = __esmMin((() => {}));
262710
262715
  //#region ../../packages/agent-core/src/tools/builtin/goal/update-goal.md?raw
262711
262716
  var update_goal_default;
262712
262717
  var init_update_goal$1 = __esmMin((() => {
262713
- update_goal_default = "Update the current autonomous goal. Set `status` only for a lifecycle change. After a coherent work slice, save `actionCheckpoint` with a monotone revision, the last verified result, exact next action, expected evidence, and an explicit evidence basis. Use `runtime_tool` only when a successful tool in this turn measured the result; use `user_statement` for a direct user assertion, `external_report` for a report not independently measured here, and `carried_forward` only when the last verified text is unchanged. Classify knowledge as `verified`, `credible_unverified`, `hypothesis`, `uncertain_memory`, `stale`, or `unknown`; never present a weaker state as verified, and preserve the state on carry-forward. Start at revision 1 and increment the currently projected revision by exactly one; stale writers fail closed. This is durable progress state, not permission, and should change only when the facts change. A checkpoint-only call keeps the goal active.\n\n- `active` — resume a paused or blocked goal when the user explicitly asks you to work on that goal.\n- `complete` — the objective is fully satisfied, all files are written, all tests pass, and any stated validation has passed.\n- `blocked` — a genuine external condition or required user decision prevents progress.\n- `paused` — set the goal aside for now.\n\nDo not mark complete after a plan or partial result. If useful work remains, checkpoint it and continue. Do not ask for permission merely to execute an already authorized checkpoint; ask only at a real rights boundary or missing user decision.\n";
262718
+ update_goal_default = "Update the current autonomous goal. Set `status` only for a lifecycle change. After a coherent work slice, save `actionCheckpoint` with a monotone revision, the last verified result, exact next action, expected evidence, and an explicit evidence basis. Use `runtime_tool` only when a successful tool in this turn measured the result; use `user_statement` for a direct user assertion, `external_report` for a report not independently measured here, and `carried_forward` only when the last verified text is unchanged. Classify knowledge as `verified`, `credible_unverified`, `hypothesis`, `uncertain_memory`, `stale`, or `unknown`; never present a weaker state as verified, and preserve the state on carry-forward. Start at revision 1 and increment the currently projected revision by exactly one; stale writers fail closed. This is durable progress state, not permission, and should change only when the facts change. A checkpoint-only call keeps the goal active.\n\n- `active` — resume a paused or blocked goal when the user explicitly asks you to work on that goal.\n- `complete` — the objective is fully satisfied, all files are written, all tests pass, and any stated validation has passed. When the goal has a completion criterion, first save a `verify` checkpoint with `runtime_tool`, `verified`, and a successful runtime evidence receipt.\n- `blocked` — a genuine external condition or required user decision prevents progress.\n- `paused` — set the goal aside for now.\n\nDo not mark complete after a plan or partial result. If useful work remains, checkpoint it and continue. Do not ask for permission merely to execute an already authorized checkpoint; ask only at a real rights boundary or missing user decision.\n";
262714
262719
  }));
262715
262720
  //#endregion
262716
262721
  //#region ../../packages/agent-core/src/tools/builtin/goal/update-goal.ts
@@ -514724,7 +514729,7 @@ var retryTelegramMediaDelivery, retryableTelegramMediaFailure;
514724
514729
  var createMediaAutoRetrievalController, parseAcceptedMediaJob, validateCompletedMedia;
514725
514730
  ({ createMediaAutoRetrievalController, parseAcceptedMediaJob } = createRequire(import.meta.url)("./bin/media-auto-retrieval-policy.cjs"));
514726
514731
  ({ validateCompletedMedia } = createRequire(import.meta.url)("./bin/media-result-policy.cjs"));
514727
- 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");
514728
514733
  const TELEGRAM_TEXT_LIMIT = 4096;
514729
514734
  const TELEGRAM_ATTACHMENT_LIMIT = 50 * 1024 * 1024;
514730
514735
  function mediaTelegramTarget(filePath) {
@@ -514833,6 +514838,23 @@ async function sendReplyFallback(chatId, text, contextOnly = false) {
514833
514838
  } catch {}
514834
514839
  return true;
514835
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
+ }
514836
514858
  const privateSafeText = sanitizePrivateConversationReply(chatId, text);
514837
514859
  if (isGroupChat(chatId) && isGroupSuppressed(text, contextOnly)) {
514838
514860
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blun-king-cli",
3
- "version": "9.1.396",
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);