blun-king-cli 9.1.397 → 9.1.399
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 +29 -0
- package/README.md +27 -0
- package/bin/telegram-private-conversation-policy.cjs +141 -0
- package/bin/validated-learning-signal.cjs +115 -8
- package/blun.mjs +20 -2
- package/package.json +1 -1
- package/telegram-plugin/bin/telegram-private-conversation-policy.cjs +141 -0
- package/telegram-plugin/dist/bridge.mjs +17 -1
- package/telegram-plugin/dist/mcp-server.mjs +10 -27
package/LIESMICH.txt
CHANGED
|
@@ -392,6 +392,35 @@ 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
|
+
Projektbezogenes Lernen ohne Übersprechen
|
|
396
|
+
------------------------------------------
|
|
397
|
+
|
|
398
|
+
Ab BLUN King 9.1.399 bleiben bestätigte Rot-zu-Grün-Lernkandidaten innerhalb
|
|
399
|
+
des Git-Projekts, in dem sie entstanden sind. Zwei Arbeitskopien desselben
|
|
400
|
+
Remote-Repositories teilen denselben anonymisierten Projekt-Schlüssel; andere
|
|
401
|
+
Repositories erhalten getrennte Lernspeicher. Lokale Git-Projekte ohne Remote
|
|
402
|
+
werden anhand ihres Repository-Wurzelpfads getrennt.
|
|
403
|
+
|
|
404
|
+
Weder Remote-Adresse noch lokaler Projektpfad werden im Lernkandidaten
|
|
405
|
+
gespeichert. Außerhalb eines Git-Projekts bleibt der bisherige globale
|
|
406
|
+
Profilspeicher erhalten. Damit kann eine gemessene Projektregel später wieder
|
|
407
|
+
aufgerufen werden, ohne als vermeintlich allgemeine Regel in fachfremde Projekte
|
|
408
|
+
zu gelangen.
|
|
409
|
+
|
|
410
|
+
Keine doppelte Telegram-Antwort ohne neue Nachricht
|
|
411
|
+
---------------------------------------------------
|
|
412
|
+
|
|
413
|
+
Ab BLUN King 9.1.398 prüft jeder Text-Ausgangspfad vor dem Senden die jüngste
|
|
414
|
+
erfolgreiche Telegram-Antwort und den jüngsten Eingang desselben Chats. Solange
|
|
415
|
+
danach keine neue Nutzernachricht eingetroffen ist, wird eine wortgleiche oder
|
|
416
|
+
nahezu gleiche Wiederholung nicht erneut gesendet. Das gilt gleichermaßen für
|
|
417
|
+
den Telegram-Werkzeugpfad und beide automatischen Rückfallpfade.
|
|
418
|
+
|
|
419
|
+
Nach einer neuen Nutzernachricht ist dieselbe Antwort wieder zulässig. Andere
|
|
420
|
+
Ergebnisse und Datei-Anhänge bleiben unverändert. Die Prüfung liest nur begrenzte
|
|
421
|
+
Endbereiche der Ein- und Ausgangsprotokolle und fällt bei fehlendem Beleg offen
|
|
422
|
+
zurück, damit Telegram nicht wegen einer beschädigten Protokollzeile blockiert.
|
|
423
|
+
|
|
395
424
|
Angehängte Bilder werden weiterhin mit ReadMediaFile gelesen. Bild-, Video- und
|
|
396
425
|
Spracherzeugung laufen asynchron, sodass die Konsole während der Verarbeitung
|
|
397
426
|
nutzbar bleibt. GenerateVideo kann außerdem einen abgeschlossenen Bildauftrag
|
package/README.md
CHANGED
|
@@ -401,6 +401,33 @@ 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
|
+
## Projektbezogenes Lernen ohne Übersprechen
|
|
405
|
+
|
|
406
|
+
Ab BLUN King 9.1.399 bleiben bestätigte Rot-zu-Grün-Lernkandidaten innerhalb
|
|
407
|
+
des Git-Projekts, in dem sie entstanden sind. Zwei Arbeitskopien desselben
|
|
408
|
+
Remote-Repositories teilen denselben anonymisierten Projekt-Schlüssel; andere
|
|
409
|
+
Repositories erhalten getrennte Lernspeicher. Lokale Git-Projekte ohne Remote
|
|
410
|
+
werden anhand ihres Repository-Wurzelpfads getrennt.
|
|
411
|
+
|
|
412
|
+
Weder Remote-Adresse noch lokaler Projektpfad werden im Lernkandidaten
|
|
413
|
+
gespeichert. Außerhalb eines Git-Projekts bleibt der bisherige globale
|
|
414
|
+
Profilspeicher erhalten. Damit kann eine gemessene Projektregel später wieder
|
|
415
|
+
aufgerufen werden, ohne als vermeintlich allgemeine Regel in fachfremde Projekte
|
|
416
|
+
zu gelangen.
|
|
417
|
+
|
|
418
|
+
## Keine doppelte Telegram-Antwort ohne neue Nachricht
|
|
419
|
+
|
|
420
|
+
Ab BLUN King 9.1.398 prüft jeder Text-Ausgangspfad vor dem Senden die jüngste
|
|
421
|
+
erfolgreiche Telegram-Antwort und den jüngsten Eingang desselben Chats. Solange
|
|
422
|
+
danach keine neue Nutzernachricht eingetroffen ist, wird eine wortgleiche oder
|
|
423
|
+
nahezu gleiche Wiederholung nicht erneut gesendet. Das gilt gleichermaßen für
|
|
424
|
+
den Telegram-Werkzeugpfad und beide automatischen Rückfallpfade.
|
|
425
|
+
|
|
426
|
+
Nach einer neuen Nutzernachricht ist dieselbe Antwort wieder zulässig. Andere
|
|
427
|
+
Ergebnisse und Datei-Anhänge bleiben unverändert. Die Prüfung liest nur begrenzte
|
|
428
|
+
Endbereiche der Ein- und Ausgangsprotokolle und fällt bei fehlendem Beleg offen
|
|
429
|
+
zurück, damit Telegram nicht wegen einer beschädigten Protokollzeile blockiert.
|
|
430
|
+
|
|
404
431
|
Angehängte Bilder werden weiterhin mit `ReadMediaFile` gelesen. Bild-, Video-
|
|
405
432
|
und Spracherzeugung laufen asynchron, sodass die Konsole während der Verarbeitung
|
|
406
433
|
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
|
};
|
|
@@ -115,13 +115,96 @@ function profileKey(value) {
|
|
|
115
115
|
return (normalized || 'default').replace(/[^a-z0-9._-]/giu, '_').slice(0, 64);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
function
|
|
119
|
-
|
|
118
|
+
function normalizedRemote(value) {
|
|
119
|
+
let remote = typeof value === 'string' ? value.trim() : '';
|
|
120
|
+
if (!remote) return '';
|
|
121
|
+
remote = remote.replace(/^git@([^:]+):/iu, 'ssh://git@$1/');
|
|
122
|
+
try {
|
|
123
|
+
const parsed = new URL(remote);
|
|
124
|
+
const host = parsed.hostname.toLowerCase();
|
|
125
|
+
const pathname = parsed.pathname.replace(/^\/+|\/+$/gu, '').replace(/\.git$/iu, '').toLowerCase();
|
|
126
|
+
return host && pathname ? `${host}/${pathname}` : '';
|
|
127
|
+
} catch {
|
|
128
|
+
return remote.replace(/\\/gu, '/').replace(/\.git$/iu, '').toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function gitConfigPath(dotGitPath) {
|
|
133
|
+
try {
|
|
134
|
+
const stat = fs.lstatSync(dotGitPath);
|
|
135
|
+
if (stat.isDirectory()) return path.join(dotGitPath, 'config');
|
|
136
|
+
if (!stat.isFile()) return '';
|
|
137
|
+
const pointer = fs.readFileSync(dotGitPath, 'utf8').match(/^gitdir:\s*(.+)$/imu)?.[1]?.trim();
|
|
138
|
+
if (!pointer) return '';
|
|
139
|
+
const gitDir = path.resolve(path.dirname(dotGitPath), pointer);
|
|
140
|
+
const commonPointerPath = path.join(gitDir, 'commondir');
|
|
141
|
+
if (fs.existsSync(commonPointerPath)) {
|
|
142
|
+
const commonPointer = fs.readFileSync(commonPointerPath, 'utf8').trim();
|
|
143
|
+
if (commonPointer) return path.join(path.resolve(gitDir, commonPointer), 'config');
|
|
144
|
+
}
|
|
145
|
+
return path.join(gitDir, 'config');
|
|
146
|
+
} catch {
|
|
147
|
+
return '';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function originRemote(configPath) {
|
|
152
|
+
if (!configPath) return '';
|
|
153
|
+
try {
|
|
154
|
+
const stat = fs.statSync(configPath);
|
|
155
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) return '';
|
|
156
|
+
const lines = fs.readFileSync(configPath, 'utf8').split(/\r?\n/gu);
|
|
157
|
+
let inOrigin = false;
|
|
158
|
+
for (const line of lines) {
|
|
159
|
+
const section = line.match(/^\s*\[([^\]]+)\]\s*$/u)?.[1]?.trim();
|
|
160
|
+
if (section !== undefined) {
|
|
161
|
+
inOrigin = /^remote\s+"origin"$/iu.test(section);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!inOrigin) continue;
|
|
165
|
+
const url = line.match(/^\s*url\s*=\s*(.+?)\s*$/iu)?.[1];
|
|
166
|
+
if (url) return normalizedRemote(url);
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
return '';
|
|
170
|
+
}
|
|
171
|
+
return '';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resolveValidatedLearningProject(projectDir) {
|
|
175
|
+
const requested = typeof projectDir === 'string' ? projectDir.trim() : '';
|
|
176
|
+
if (!requested) return { scope: 'global', projectId: '' };
|
|
177
|
+
let current;
|
|
178
|
+
try {
|
|
179
|
+
current = fs.realpathSync(path.resolve(requested));
|
|
180
|
+
} catch {
|
|
181
|
+
return { scope: 'global', projectId: '' };
|
|
182
|
+
}
|
|
183
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
184
|
+
const dotGitPath = path.join(current, '.git');
|
|
185
|
+
if (fs.existsSync(dotGitPath)) {
|
|
186
|
+
const remote = originRemote(gitConfigPath(dotGitPath));
|
|
187
|
+
const identity = remote ? `remote:${remote}` : `path:${current.toLowerCase()}`;
|
|
188
|
+
return {
|
|
189
|
+
scope: 'project',
|
|
190
|
+
projectId: crypto.createHash('sha256').update(identity).digest('hex').slice(0, 16),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const parent = path.dirname(current);
|
|
194
|
+
if (parent === current) break;
|
|
195
|
+
current = parent;
|
|
196
|
+
}
|
|
197
|
+
return { scope: 'global', projectId: '' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function candidateDirectory(homeDir, profile, projectId = '') {
|
|
201
|
+
const base = path.join(
|
|
120
202
|
path.resolve(homeDir),
|
|
121
203
|
'self-improvement',
|
|
122
204
|
'validated-learning',
|
|
123
205
|
profileKey(profile),
|
|
124
206
|
);
|
|
207
|
+
return projectId ? path.join(base, 'projects', projectId) : base;
|
|
125
208
|
}
|
|
126
209
|
|
|
127
210
|
function atomicCreate(filePath, value) {
|
|
@@ -161,7 +244,8 @@ function readCandidate(filePath) {
|
|
|
161
244
|
function readValidatedLearningCandidates(options = {}) {
|
|
162
245
|
const homeDir = typeof options.homeDir === 'string' ? options.homeDir.trim() : '';
|
|
163
246
|
if (!homeDir) return [];
|
|
164
|
-
const
|
|
247
|
+
const project = resolveValidatedLearningProject(options.projectDir);
|
|
248
|
+
const directory = candidateDirectory(homeDir, options.profile, project.projectId);
|
|
165
249
|
try {
|
|
166
250
|
return fs.readdirSync(directory, { withFileTypes: true })
|
|
167
251
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
@@ -255,21 +339,25 @@ function pendingCandidateForSignal(candidates, signal) {
|
|
|
255
339
|
|
|
256
340
|
function createCandidate(signal, options, nowIso) {
|
|
257
341
|
const profile = profileKey(options.profile);
|
|
342
|
+
const projectId = typeof options.projectId === 'string' ? options.projectId : '';
|
|
258
343
|
const commandHash = verificationCommandHash(signal);
|
|
259
344
|
const digest = crypto.createHash('sha256').update([
|
|
260
345
|
profile,
|
|
346
|
+
projectId,
|
|
261
347
|
commandHash,
|
|
262
348
|
signal.failedToolCallId,
|
|
263
349
|
signal.successfulToolCallId,
|
|
264
350
|
].join('\0')).digest('hex').slice(0, 32);
|
|
265
351
|
const id = `vl-${digest}`;
|
|
266
|
-
const filePath = path.join(candidateDirectory(options.homeDir, profile), `${id}.json`);
|
|
352
|
+
const filePath = path.join(candidateDirectory(options.homeDir, profile, projectId), `${id}.json`);
|
|
267
353
|
atomicCreate(filePath, {
|
|
268
354
|
schemaVersion: SCHEMA_VERSION,
|
|
269
355
|
id,
|
|
270
356
|
type: 'validated_red_green',
|
|
271
357
|
status: 'pending',
|
|
272
358
|
profile,
|
|
359
|
+
scope: projectId ? 'project' : 'global',
|
|
360
|
+
...(projectId ? { projectId } : {}),
|
|
273
361
|
verification: {
|
|
274
362
|
toolName: signal.toolName,
|
|
275
363
|
commandHash,
|
|
@@ -286,6 +374,7 @@ function createCandidate(signal, options, nowIso) {
|
|
|
286
374
|
function syncValidatedLearningCandidate(history, options = {}) {
|
|
287
375
|
const signal = detectValidatedLearningSignal(history);
|
|
288
376
|
const homeDir = typeof options.homeDir === 'string' ? options.homeDir.trim() : '';
|
|
377
|
+
const project = resolveValidatedLearningProject(options.projectDir);
|
|
289
378
|
if (!homeDir) {
|
|
290
379
|
return signal === null ? null : {
|
|
291
380
|
...signal,
|
|
@@ -300,6 +389,7 @@ function syncValidatedLearningCandidate(history, options = {}) {
|
|
|
300
389
|
let candidates = readValidatedLearningCandidates({
|
|
301
390
|
homeDir,
|
|
302
391
|
profile: options.profile,
|
|
392
|
+
projectDir: options.projectDir,
|
|
303
393
|
});
|
|
304
394
|
const recordedCandidateIds = successfulMistakeRecordCandidateIds(history);
|
|
305
395
|
for (const candidateId of recordedCandidateIds) {
|
|
@@ -309,7 +399,11 @@ function syncValidatedLearningCandidate(history, options = {}) {
|
|
|
309
399
|
markCandidate(candidate, 'recorded', nowIso);
|
|
310
400
|
}
|
|
311
401
|
if (recordedCandidateIds.size > 0) {
|
|
312
|
-
candidates = readValidatedLearningCandidates({
|
|
402
|
+
candidates = readValidatedLearningCandidates({
|
|
403
|
+
homeDir,
|
|
404
|
+
profile: options.profile,
|
|
405
|
+
projectDir: options.projectDir,
|
|
406
|
+
});
|
|
313
407
|
}
|
|
314
408
|
let expiredCandidate = false;
|
|
315
409
|
for (const candidate of candidates) {
|
|
@@ -320,11 +414,19 @@ function syncValidatedLearningCandidate(history, options = {}) {
|
|
|
320
414
|
}
|
|
321
415
|
}
|
|
322
416
|
if (expiredCandidate) {
|
|
323
|
-
candidates = readValidatedLearningCandidates({
|
|
417
|
+
candidates = readValidatedLearningCandidates({
|
|
418
|
+
homeDir,
|
|
419
|
+
profile: options.profile,
|
|
420
|
+
projectDir: options.projectDir,
|
|
421
|
+
});
|
|
324
422
|
}
|
|
325
423
|
const retention = pruneTerminalValidatedLearningCandidates({ candidates });
|
|
326
424
|
if (retention.deleted > 0) {
|
|
327
|
-
candidates = readValidatedLearningCandidates({
|
|
425
|
+
candidates = readValidatedLearningCandidates({
|
|
426
|
+
homeDir,
|
|
427
|
+
profile: options.profile,
|
|
428
|
+
projectDir: options.projectDir,
|
|
429
|
+
});
|
|
328
430
|
}
|
|
329
431
|
if (signal !== null) {
|
|
330
432
|
const duplicate = pendingCandidateForSignal(candidates, signal);
|
|
@@ -332,7 +434,11 @@ function syncValidatedLearningCandidate(history, options = {}) {
|
|
|
332
434
|
return { ...duplicate, source: 'current_turn' };
|
|
333
435
|
}
|
|
334
436
|
return {
|
|
335
|
-
...createCandidate(signal, {
|
|
437
|
+
...createCandidate(signal, {
|
|
438
|
+
homeDir,
|
|
439
|
+
profile: options.profile,
|
|
440
|
+
projectId: project.projectId,
|
|
441
|
+
}, nowIso),
|
|
336
442
|
source: 'current_turn',
|
|
337
443
|
};
|
|
338
444
|
}
|
|
@@ -352,5 +458,6 @@ module.exports = {
|
|
|
352
458
|
normalizeCommand,
|
|
353
459
|
pruneTerminalValidatedLearningCandidates,
|
|
354
460
|
readValidatedLearningCandidates,
|
|
461
|
+
resolveValidatedLearningProject,
|
|
355
462
|
syncValidatedLearningCandidate,
|
|
356
463
|
};
|
package/blun.mjs
CHANGED
|
@@ -232224,7 +232224,8 @@ var init_validated_learning_signal = __esmMin((() => {
|
|
|
232224
232224
|
if (!shouldSyncValidatedLearningCandidate(this.agent.context.history)) return;
|
|
232225
232225
|
const signal = syncValidatedLearningCandidate(this.agent.context.history, {
|
|
232226
232226
|
homeDir: process.env["BLUN_SHARED_HOME"] ?? process.env["BLUN_HOME"] ?? "",
|
|
232227
|
-
profile: process.env["BLUN_PROFILE"] ?? "default"
|
|
232227
|
+
profile: process.env["BLUN_PROFILE"] ?? "default",
|
|
232228
|
+
projectDir: process.cwd()
|
|
232228
232229
|
});
|
|
232229
232230
|
if (signal === null) {
|
|
232230
232231
|
if (existing.length > 0) this.agent.context.removeSystemRemindersMatching(isValidatedLearningSignalReminder);
|
|
@@ -514729,7 +514730,7 @@ var retryTelegramMediaDelivery, retryableTelegramMediaFailure;
|
|
|
514729
514730
|
var createMediaAutoRetrievalController, parseAcceptedMediaJob, validateCompletedMedia;
|
|
514730
514731
|
({ createMediaAutoRetrievalController, parseAcceptedMediaJob } = createRequire(import.meta.url)("./bin/media-auto-retrieval-policy.cjs"));
|
|
514731
514732
|
({ validateCompletedMedia } = createRequire(import.meta.url)("./bin/media-result-policy.cjs"));
|
|
514732
|
-
var { isPrivateInternalStatusReply, sanitizePrivateConversationReply } = createRequire(import.meta.url)("./bin/telegram-private-conversation-policy.cjs");
|
|
514733
|
+
var { findDuplicateReplyWithoutNewInbound, isPrivateInternalStatusReply, sanitizePrivateConversationReply } = createRequire(import.meta.url)("./bin/telegram-private-conversation-policy.cjs");
|
|
514733
514734
|
const TELEGRAM_TEXT_LIMIT = 4096;
|
|
514734
514735
|
const TELEGRAM_ATTACHMENT_LIMIT = 50 * 1024 * 1024;
|
|
514735
514736
|
function mediaTelegramTarget(filePath) {
|
|
@@ -514838,6 +514839,23 @@ async function sendReplyFallback(chatId, text, contextOnly = false) {
|
|
|
514838
514839
|
} catch {}
|
|
514839
514840
|
return true;
|
|
514840
514841
|
}
|
|
514842
|
+
if (findDuplicateReplyWithoutNewInbound({
|
|
514843
|
+
chatId,
|
|
514844
|
+
inboundFile: join$4(channelDir(), "inbox.jsonl"),
|
|
514845
|
+
outboxFile: outboxPath(),
|
|
514846
|
+
text
|
|
514847
|
+
}) !== null) {
|
|
514848
|
+
try {
|
|
514849
|
+
appendFileSync(outboxPath(), `${JSON.stringify({
|
|
514850
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
514851
|
+
direction: "out",
|
|
514852
|
+
kind: "reply-fallback-suppressed-duplicate-without-new-inbound",
|
|
514853
|
+
chat_id: String(chatId),
|
|
514854
|
+
text
|
|
514855
|
+
})}\n`);
|
|
514856
|
+
} catch {}
|
|
514857
|
+
return true;
|
|
514858
|
+
}
|
|
514841
514859
|
const privateSafeText = sanitizePrivateConversationReply(chatId, text);
|
|
514842
514860
|
if (isGroupChat(chatId) && isGroupSuppressed(text, contextOnly)) {
|
|
514843
514861
|
try {
|
package/package.json
CHANGED
|
@@ -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
|
|
9791
|
-
*
|
|
9792
|
-
*
|
|
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
|
-
|
|
9800
|
-
|
|
9801
|
-
|
|
9802
|
-
|
|
9803
|
-
|
|
9804
|
-
|
|
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);
|