@blunking/codexlink 0.1.2 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,442 @@
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [string]$StateFile,
4
+
5
+ [Parameter(Mandatory = $true)]
6
+ [string]$BaseTitle,
7
+
8
+ [string]$LogFile = ""
9
+ )
10
+
11
+ $ErrorActionPreference = "SilentlyContinue"
12
+
13
+ if (-not ("BlunEmbeddedQueueTitleWatcher" -as [type])) {
14
+ Add-Type -ReferencedAssemblies "System.Web.Extensions" -TypeDefinition @"
15
+ using System;
16
+ using System.Collections;
17
+ using System.Collections.Generic;
18
+ using System.IO;
19
+ using System.Threading;
20
+ using System.Web.Script.Serialization;
21
+
22
+ public static class BlunEmbeddedQueueTitleWatcher
23
+ {
24
+ private static Timer _timer;
25
+ private static string _stateFile;
26
+ private static string _baseTitle;
27
+ private static string _lastTitle = "";
28
+ private static long _ambientTtlMs = 600000;
29
+ private static string _lastNotice = "";
30
+ private static string _lastUiNotice = "";
31
+ private static string _lastUiKind = "";
32
+ private static string _logFile = "";
33
+ private static readonly object Gate = new object();
34
+
35
+ public static void Start(string stateFile, string baseTitle, long ambientTtlMs, string logFile)
36
+ {
37
+ lock (Gate)
38
+ {
39
+ _stateFile = stateFile ?? "";
40
+ _baseTitle = baseTitle ?? "";
41
+ _lastTitle = "";
42
+ _ambientTtlMs = ambientTtlMs > 0 ? ambientTtlMs : 600000;
43
+ _logFile = logFile ?? "";
44
+ _lastNotice = "";
45
+ _lastUiNotice = "";
46
+ _lastUiKind = "";
47
+ WriteLog("START");
48
+ TrySetTitle(_baseTitle);
49
+ if (_timer != null)
50
+ {
51
+ _timer.Dispose();
52
+ }
53
+ _timer = new Timer(_ => Tick(), null, 0, 900);
54
+ }
55
+ }
56
+
57
+ private static void Tick()
58
+ {
59
+ try
60
+ {
61
+ string title;
62
+ string notice;
63
+ string uiNotice;
64
+ string uiKind;
65
+ BuildSnapshot(out title, out notice, out uiNotice, out uiKind);
66
+ TrySetTitle(title);
67
+ TryWriteNotice(notice);
68
+ TryWriteUiNotice(uiKind, uiNotice);
69
+ }
70
+ catch
71
+ {
72
+ WriteLog("TICK_ERROR");
73
+ }
74
+ }
75
+
76
+ private static void TrySetTitle(string value)
77
+ {
78
+ var normalizedTitle = value ?? "";
79
+ if (string.Equals(normalizedTitle, _lastTitle, StringComparison.Ordinal))
80
+ {
81
+ return;
82
+ }
83
+ try
84
+ {
85
+ Console.Title = normalizedTitle;
86
+ _lastTitle = normalizedTitle;
87
+ WriteLog("TITLE " + Normalize(normalizedTitle, 180));
88
+ }
89
+ catch
90
+ {
91
+ WriteLog("TITLE_ERROR");
92
+ }
93
+ }
94
+
95
+ private static void TryWriteNotice(string notice)
96
+ {
97
+ var normalized = notice ?? "";
98
+ if (string.Equals(normalized, _lastNotice, StringComparison.Ordinal))
99
+ {
100
+ return;
101
+ }
102
+
103
+ try
104
+ {
105
+ // Do not write background queue notices into the interactive Codex
106
+ // terminal. It corrupts the prompt/input area on Windows terminals.
107
+ }
108
+ catch
109
+ {
110
+ WriteLog("NOTICE_ERROR");
111
+ }
112
+
113
+ _lastNotice = normalized;
114
+ WriteLog("NOTICE " + Normalize(normalized, 220));
115
+ }
116
+
117
+ private static void TryWriteUiNotice(string kind, string notice)
118
+ {
119
+ var normalizedKind = string.IsNullOrWhiteSpace(kind) ? "generic" : kind.Trim().ToLowerInvariant();
120
+ var normalized = notice ?? "";
121
+ if (string.IsNullOrWhiteSpace(normalized))
122
+ {
123
+ return;
124
+ }
125
+
126
+ if (string.Equals(normalized, _lastUiNotice, StringComparison.Ordinal)
127
+ && string.Equals(normalizedKind, _lastUiKind, StringComparison.Ordinal))
128
+ {
129
+ return;
130
+ }
131
+
132
+ try
133
+ {
134
+ Console.WriteLine("");
135
+ Console.WriteLine("[Telegram] " + normalized);
136
+ }
137
+ catch
138
+ {
139
+ WriteLog("UI_ERROR " + normalizedKind);
140
+ }
141
+
142
+ _lastUiNotice = normalized;
143
+ _lastUiKind = normalizedKind;
144
+ WriteLog("UI " + normalizedKind + " " + Normalize(normalized, 220));
145
+ }
146
+
147
+ private static void BuildSnapshot(out string title, out string notice, out string uiNotice, out string uiKind)
148
+ {
149
+ title = _baseTitle;
150
+ notice = "";
151
+ uiNotice = "";
152
+ uiKind = "";
153
+ if (string.IsNullOrWhiteSpace(_stateFile) || !File.Exists(_stateFile))
154
+ {
155
+ return;
156
+ }
157
+
158
+ var raw = File.ReadAllText(_stateFile);
159
+ if (string.IsNullOrWhiteSpace(raw))
160
+ {
161
+ return;
162
+ }
163
+
164
+ var serializer = new JavaScriptSerializer();
165
+ var root = serializer.DeserializeObject(raw) as Dictionary<string, object>;
166
+ if (root == null || !root.ContainsKey("queue"))
167
+ {
168
+ return;
169
+ }
170
+
171
+ if (root.ContainsKey("lastUiNotice"))
172
+ {
173
+ var ui = root["lastUiNotice"] as Dictionary<string, object>;
174
+ if (ui != null)
175
+ {
176
+ uiNotice = Normalize(GetString(ui, "text"), 220);
177
+ uiKind = Normalize(GetString(ui, "kind"), 32);
178
+ }
179
+ }
180
+
181
+ int total = 0;
182
+ int pending = 0;
183
+ int direct = 0;
184
+ int ambient = 0;
185
+ int escalation = 0;
186
+ string preview = "";
187
+
188
+ var pendingReplies = root.ContainsKey("pendingReplies") ? AsObjects(root["pendingReplies"]) : new object[0];
189
+ foreach (var item in pendingReplies)
190
+ {
191
+ var entry = item as Dictionary<string, object>;
192
+ if (entry == null || !IsOpenPendingReply(entry))
193
+ {
194
+ continue;
195
+ }
196
+
197
+ total += 1;
198
+ pending += 1;
199
+ CountRelevance(entry, ref direct, ref ambient, ref escalation);
200
+
201
+ if (string.IsNullOrWhiteSpace(preview))
202
+ {
203
+ preview = FormatPreview(entry, 44, true);
204
+ }
205
+ }
206
+
207
+ var queue = AsObjects(root["queue"]);
208
+ foreach (var item in queue)
209
+ {
210
+ var entry = item as Dictionary<string, object>;
211
+ if (entry == null)
212
+ {
213
+ continue;
214
+ }
215
+
216
+ var status = GetString(entry, "status");
217
+ if (!string.Equals(status, "queued", StringComparison.OrdinalIgnoreCase))
218
+ {
219
+ continue;
220
+ }
221
+
222
+ var relevance = GetString(entry, "relevance");
223
+ if (string.Equals(relevance, "ambient", StringComparison.OrdinalIgnoreCase) && IsOlderThan(entry, _ambientTtlMs))
224
+ {
225
+ continue;
226
+ }
227
+
228
+ total += 1;
229
+ CountRelevance(entry, ref direct, ref ambient, ref escalation);
230
+
231
+ if (string.IsNullOrWhiteSpace(preview))
232
+ {
233
+ preview = FormatPreview(entry, 44, false);
234
+ }
235
+ }
236
+
237
+ if (total == 0)
238
+ {
239
+ return;
240
+ }
241
+
242
+ var parts = new List<string> { "Q:" + total.ToString() };
243
+ if (pending > 0) parts.Add("P:" + pending.ToString());
244
+ if (direct > 0) parts.Add("D:" + direct.ToString());
245
+ if (ambient > 0) parts.Add("G:" + ambient.ToString());
246
+ if (escalation > 0) parts.Add("E:" + escalation.ToString());
247
+
248
+ var suffix = string.Join(" ", parts.ToArray());
249
+ title = _baseTitle + " | " + suffix;
250
+ var noticeParts = new List<string> { total.ToString() + " waiting" };
251
+ if (pending > 0) noticeParts.Add("pending " + pending.ToString());
252
+ if (direct > 0) noticeParts.Add("direct " + direct.ToString());
253
+ if (ambient > 0) noticeParts.Add("group " + ambient.ToString());
254
+ if (escalation > 0) noticeParts.Add("escalation " + escalation.ToString());
255
+ notice = string.Join(" | ", noticeParts.ToArray());
256
+ if (string.IsNullOrWhiteSpace(preview))
257
+ {
258
+ return;
259
+ }
260
+ title = title + " | " + preview;
261
+ notice = notice + " | " + preview;
262
+ }
263
+
264
+ private static object[] AsObjects(object value)
265
+ {
266
+ var arr = value as object[];
267
+ if (arr != null)
268
+ {
269
+ return arr;
270
+ }
271
+ var list = value as ArrayList;
272
+ if (list != null)
273
+ {
274
+ return list.ToArray();
275
+ }
276
+ return new object[0];
277
+ }
278
+
279
+ private static string GetString(Dictionary<string, object> entry, string key)
280
+ {
281
+ if (!entry.ContainsKey(key) || entry[key] == null)
282
+ {
283
+ return "";
284
+ }
285
+ return Convert.ToString(entry[key]) ?? "";
286
+ }
287
+
288
+ private static void CountRelevance(Dictionary<string, object> entry, ref int direct, ref int ambient, ref int escalation)
289
+ {
290
+ var relevance = GetString(entry, "relevance");
291
+ if (string.Equals(relevance, "ambient", StringComparison.OrdinalIgnoreCase))
292
+ {
293
+ ambient += 1;
294
+ }
295
+ else if (string.Equals(relevance, "escalation", StringComparison.OrdinalIgnoreCase))
296
+ {
297
+ escalation += 1;
298
+ }
299
+ else
300
+ {
301
+ direct += 1;
302
+ }
303
+ }
304
+
305
+ private static bool IsOpenPendingReply(Dictionary<string, object> entry)
306
+ {
307
+ var status = GetString(entry, "status").Trim().ToLowerInvariant();
308
+ if (entry.ContainsKey("sentAt") && entry["sentAt"] != null && !string.IsNullOrWhiteSpace(Convert.ToString(entry["sentAt"])))
309
+ {
310
+ return false;
311
+ }
312
+ if (status == "sent" || status == "suppressed_ack" || status == "error" || status == "ignored_bot" || status == "superseded" || status == "expired" || status == "stale_thread")
313
+ {
314
+ return false;
315
+ }
316
+ return true;
317
+ }
318
+
319
+ private static string FormatPreview(Dictionary<string, object> entry, int maxLength, bool pending)
320
+ {
321
+ var user = GetString(entry, "user");
322
+ var group = GetString(entry, "groupTitle");
323
+ var rawText = pending ? GetString(entry, "sourceText") : GetString(entry, "text");
324
+ var text = Normalize(rawText, maxLength);
325
+ if (string.IsNullOrWhiteSpace(text))
326
+ {
327
+ return "";
328
+ }
329
+ var state = pending ? "pending: " : "";
330
+ if (!string.IsNullOrWhiteSpace(group))
331
+ {
332
+ return Normalize(state + user + "@" + group + ": " + text, maxLength);
333
+ }
334
+ if (!string.IsNullOrWhiteSpace(user))
335
+ {
336
+ return Normalize(state + user + ": " + text, maxLength);
337
+ }
338
+ return state + text;
339
+ }
340
+
341
+ private static string Normalize(string value, int maxLength)
342
+ {
343
+ if (string.IsNullOrWhiteSpace(value))
344
+ {
345
+ return "";
346
+ }
347
+
348
+ var compact = RepairMojibake(value).Replace("\r", " ").Replace("\n", " ").Trim();
349
+ while (compact.Contains(" "))
350
+ {
351
+ compact = compact.Replace(" ", " ");
352
+ }
353
+
354
+ if (compact.Length <= maxLength)
355
+ {
356
+ return compact;
357
+ }
358
+
359
+ return compact.Substring(0, Math.Max(0, maxLength - 3)).TrimEnd() + "...";
360
+ }
361
+
362
+ private static string RepairMojibake(string value)
363
+ {
364
+ if (string.IsNullOrWhiteSpace(value))
365
+ {
366
+ return "";
367
+ }
368
+
369
+ return value
370
+ .Replace("—", "-")
371
+ .Replace("–", "-")
372
+ .Replace("„", "\"")
373
+ .Replace("“", "\"")
374
+ .Replace("â€\u009d", "\"")
375
+ .Replace("’", "'")
376
+ .Replace("‘", "'")
377
+ .Replace("…", "...")
378
+ .Replace("€", "EUR")
379
+ .Replace("Ä", "Ä")
380
+ .Replace("Ö", "Ö")
381
+ .Replace("Ü", "Ü")
382
+ .Replace("ä", "ä")
383
+ .Replace("ö", "ö")
384
+ .Replace("ü", "ü")
385
+ .Replace("ß", "ß");
386
+ }
387
+
388
+ private static bool IsOlderThan(Dictionary<string, object> entry, long ttlMs)
389
+ {
390
+ if (!entry.ContainsKey("ts") || entry["ts"] == null)
391
+ {
392
+ return true;
393
+ }
394
+ try
395
+ {
396
+ var parsed = DateTimeOffset.Parse(Convert.ToString(entry["ts"]) ?? "");
397
+ return (DateTimeOffset.UtcNow - parsed.ToUniversalTime()).TotalMilliseconds >= ttlMs;
398
+ }
399
+ catch
400
+ {
401
+ return true;
402
+ }
403
+ }
404
+
405
+ private static void WriteLog(string message)
406
+ {
407
+ if (string.IsNullOrWhiteSpace(_logFile))
408
+ {
409
+ return;
410
+ }
411
+ try
412
+ {
413
+ File.AppendAllText(_logFile, DateTimeOffset.UtcNow.ToString("o") + " " + message + Environment.NewLine);
414
+ }
415
+ catch
416
+ {
417
+ }
418
+ }
419
+ }
420
+ "@
421
+ }
422
+
423
+ $ambientTtlMs = 600000
424
+ try {
425
+ $stateDir = Split-Path -Parent $StateFile
426
+ $envPath = Join-Path $stateDir ".env"
427
+ if (Test-Path $envPath) {
428
+ foreach ($line in (Get-Content -Path $envPath)) {
429
+ if (-not $line) { continue }
430
+ if ($line.Trim().StartsWith("#")) { continue }
431
+ $parts = $line -split "=", 2
432
+ if ($parts.Count -ne 2) { continue }
433
+ if ($parts[0].Trim() -eq "BLUN_TELEGRAM_AMBIENT_QUEUE_TTL_MS") {
434
+ $ambientTtlMs = [int64]$parts[1].Trim()
435
+ }
436
+ }
437
+ }
438
+ } catch {
439
+ $ambientTtlMs = 600000
440
+ }
441
+
442
+ [BlunEmbeddedQueueTitleWatcher]::Start($StateFile, $BaseTitle, $ambientTtlMs, $LogFile)