blun-king-cli 9.1.393 → 9.1.395

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
@@ -326,6 +326,25 @@ Nachweisbare Arbeitsabläufe
326
326
 
327
327
  Alle Befehle funktionieren identisch mit `king`.
328
328
 
329
+ Gemeinsamer kognitiver Speicherkern (optional)
330
+ ------------------------------------------------
331
+
332
+ Ohne zusätzliche Konfiguration bleibt der bisherige lokale SQLite-Speicher
333
+ aktiv. Betreiber können einen gemeinsamen, portalneutralen Speicherkern
334
+ ausdrücklich über BLUN_COGNITIVE_MEMORY_ADAPTER_MODULE wählen. Der Wert muss
335
+ auf eine absolute, kanonische CommonJS-Datei zeigen.
336
+
337
+ Das Modul exportiert synchron createCognitiveMemoryAdapter(context) und liefert
338
+ einen Adapter mit Vertragsversion 6. Relative Pfade, symbolische Verknüpfungen,
339
+ asynchrone Fabriken und abweichende Vertragsversionen werden geschlossen
340
+ abgewiesen. Der Adapter erhält nur die Vertragsversion, das Profilverzeichnis
341
+ sowie Mandanten-, Agenten- und Anzeigenamen. Zugangsdaten werden nicht
342
+ übergeben.
343
+
344
+ Damit kann derselbe geprüfte Ereignisstrom über CLI und Portale hinweg gelesen,
345
+ korrigiert und zurückgezogen werden. Ohne den optionalen Anbieter entstehen
346
+ keine neuen Netzwerkzugriffe und keine Verhaltensänderung.
347
+
329
348
  Medienerzeugung
330
349
  ---------------
331
350
  Für Medienaufträge bleiben GenerateImage, GenerateVideo, GenerateSpeech und
package/README.md CHANGED
@@ -338,6 +338,24 @@ Version 9.1.0 enthält sieben zusätzliche, getrennt nutzbare Befehlsgruppen:
338
338
 
339
339
  Sie stehen unter `blun` und `king` identisch zur Verfügung.
340
340
 
341
+ ## Gemeinsamer kognitiver Speicherkern (optional)
342
+
343
+ Ohne zusätzliche Konfiguration bleibt der bisherige lokale SQLite-Speicher
344
+ aktiv. Betreiber können einen gemeinsamen, portalneutralen Speicherkern
345
+ ausdrücklich über `BLUN_COGNITIVE_MEMORY_ADAPTER_MODULE` wählen. Der Wert muss
346
+ auf eine absolute, kanonische CommonJS-Datei zeigen.
347
+
348
+ Das Modul exportiert synchron `createCognitiveMemoryAdapter(context)` und
349
+ liefert einen Adapter mit Vertragsversion 6. Relative Pfade, symbolische
350
+ Verknüpfungen, asynchrone Fabriken und abweichende Vertragsversionen werden
351
+ geschlossen abgewiesen. Der Adapter erhält nur die Vertragsversion, das
352
+ Profilverzeichnis sowie Mandanten-, Agenten- und Anzeigenamen. Zugangsdaten
353
+ werden nicht übergeben.
354
+
355
+ Damit kann derselbe geprüfte Ereignisstrom über CLI und Portale hinweg gelesen,
356
+ korrigiert und zurückgezogen werden. Ohne den optionalen Anbieter entstehen
357
+ keine neuen Netzwerkzugriffe und keine Verhaltensänderung.
358
+
341
359
  ## Medienerzeugung
342
360
 
343
361
  Für Medienaufträge bleiben `GenerateImage`, `GenerateVideo`, `GenerateSpeech` und
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const {
6
+ COGNITIVE_MEMORY_ADAPTER_VERSION,
7
+ assertCognitiveMemoryAdapter,
8
+ } = require('./cognitive-memory-adapter.cjs');
9
+
10
+ const PROVIDER_ENV = 'BLUN_COGNITIVE_MEMORY_ADAPTER_MODULE';
11
+ const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
12
+
13
+ function fail(code) {
14
+ const error = new Error(code);
15
+ error.code = code;
16
+ throw error;
17
+ }
18
+
19
+ function canonicalFile(input) {
20
+ const configured = String(input ?? '').trim();
21
+ if (!configured || !path.isAbsolute(configured)) fail('COGNITIVE_MEMORY_PROVIDER_PATH_INVALID');
22
+ const resolved = path.resolve(configured);
23
+ let stat;
24
+ let canonical;
25
+ try {
26
+ stat = fs.lstatSync(resolved);
27
+ canonical = fs.realpathSync(resolved);
28
+ } catch {
29
+ fail('COGNITIVE_MEMORY_PROVIDER_PATH_INVALID');
30
+ }
31
+ if (!stat.isFile() || stat.isSymbolicLink()
32
+ || path.normalize(canonical).toLowerCase() !== path.normalize(resolved).toLowerCase()) {
33
+ fail('COGNITIVE_MEMORY_PROVIDER_PATH_INVALID');
34
+ }
35
+ return canonical;
36
+ }
37
+
38
+ function boundedContext({ home, tenantId, agentId, agentName }) {
39
+ const tenant = String(tenantId ?? '').trim();
40
+ const agent = String(agentId ?? '').trim();
41
+ const name = String(agentName ?? '').trim();
42
+ let canonicalHome;
43
+ try {
44
+ const stat = fs.lstatSync(path.resolve(String(home ?? '')));
45
+ canonicalHome = fs.realpathSync(path.resolve(String(home ?? '')));
46
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail('COGNITIVE_MEMORY_PROVIDER_CONTEXT_INVALID');
47
+ } catch (error) {
48
+ if (error?.code === 'COGNITIVE_MEMORY_PROVIDER_CONTEXT_INVALID') throw error;
49
+ fail('COGNITIVE_MEMORY_PROVIDER_CONTEXT_INVALID');
50
+ }
51
+ if (!SAFE_ID_RE.test(tenant) || !SAFE_ID_RE.test(agent) || !name || name.length > 128
52
+ || /[\u0000-\u001f\u007f]/u.test(name)) {
53
+ fail('COGNITIVE_MEMORY_PROVIDER_CONTEXT_INVALID');
54
+ }
55
+ return Object.freeze({
56
+ contractVersion: COGNITIVE_MEMORY_ADAPTER_VERSION,
57
+ home: canonicalHome,
58
+ tenantId: tenant,
59
+ agentId: agent,
60
+ agentName: name,
61
+ });
62
+ }
63
+
64
+ function loadConfiguredCognitiveMemoryAdapter({
65
+ modulePath,
66
+ env = process.env,
67
+ home,
68
+ tenantId,
69
+ agentId,
70
+ agentName,
71
+ } = {}) {
72
+ const configured = modulePath === undefined ? env?.[PROVIDER_ENV] : modulePath;
73
+ if (!String(configured ?? '').trim()) return undefined;
74
+ const providerPath = canonicalFile(configured);
75
+ const provider = require(providerPath);
76
+ if (!provider || typeof provider.createCognitiveMemoryAdapter !== 'function') {
77
+ fail('COGNITIVE_MEMORY_PROVIDER_FACTORY_REQUIRED');
78
+ }
79
+ const adapter = provider.createCognitiveMemoryAdapter(boundedContext({
80
+ home,
81
+ tenantId,
82
+ agentId,
83
+ agentName,
84
+ }));
85
+ if (adapter && typeof adapter.then === 'function') fail('COGNITIVE_MEMORY_PROVIDER_ASYNC_UNSUPPORTED');
86
+ return assertCognitiveMemoryAdapter(adapter);
87
+ }
88
+
89
+ module.exports = {
90
+ PROVIDER_ENV,
91
+ loadConfiguredCognitiveMemoryAdapter,
92
+ };
@@ -4,6 +4,7 @@ const crypto = require('node:crypto');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const { openCognitiveMemoryAdapter } = require('./cognitive-memory-adapter.cjs');
7
+ const { loadConfiguredCognitiveMemoryAdapter } = require('./cognitive-memory-provider.cjs');
7
8
  const { buildCognitiveContextProjection } = require('./cognitive-context-projection.cjs');
8
9
  const { buildCognitiveFocusProjection } = require('./cognitive-focus-projection.cjs');
9
10
  const { authorizeAttentionCandidate } = require('./cognitive-attention-runtime.cjs');
@@ -521,6 +522,7 @@ function createRuntimeCognitiveTurnLifecycle({
521
522
  agentId,
522
523
  runtimeId,
523
524
  memoryAdapter,
525
+ memoryAdapterModule,
524
526
  now,
525
527
  } = {}) {
526
528
  const resolvedHome = String(home ?? '').trim();
@@ -537,12 +539,25 @@ function createRuntimeCognitiveTurnLifecycle({
537
539
  agentId: explicitAgent || manifestIdentity?.agentId || '',
538
540
  };
539
541
  const hasSharedIdentity = Boolean(identity.tenantId && identity.agentId);
542
+ const resolvedTenantId = hasSharedIdentity
543
+ ? identity.tenantId : digestId('home', [resolvedHome.toLowerCase()]);
544
+ const resolvedAgentId = hasSharedIdentity
545
+ ? identity.agentId : digestId('agent', [resolvedAgent.toLowerCase()]);
546
+ const resolvedMemoryAdapter = memoryAdapter === undefined
547
+ ? loadConfiguredCognitiveMemoryAdapter({
548
+ modulePath: memoryAdapterModule,
549
+ home: resolvedHome,
550
+ tenantId: resolvedTenantId,
551
+ agentId: resolvedAgentId,
552
+ agentName: resolvedAgent,
553
+ })
554
+ : memoryAdapter;
540
555
  return createCognitiveTurnLifecycle({
541
556
  home: resolvedHome,
542
- tenantId: hasSharedIdentity ? identity.tenantId : digestId('home', [resolvedHome.toLowerCase()]),
543
- agentId: hasSharedIdentity ? identity.agentId : digestId('agent', [resolvedAgent.toLowerCase()]),
557
+ tenantId: resolvedTenantId,
558
+ agentId: resolvedAgentId,
544
559
  runtimeId,
545
- memoryAdapter,
560
+ memoryAdapter: resolvedMemoryAdapter,
546
561
  now,
547
562
  });
548
563
  }
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ const TERMINAL_FAILURES = new Set(['blocked', 'cancelled', 'canceled', 'expired', 'failed']);
4
+
5
+ function parseAcceptedMediaJob(output) {
6
+ if (typeof output !== 'string') return undefined;
7
+ return /\bMedia job ([A-Za-z0-9_-]{1,200}) accepted with status\b/u.exec(output)?.[1];
8
+ }
9
+
10
+ function createMediaAutoRetrievalController(options) {
11
+ const active = new Map();
12
+ const completed = new Set();
13
+ const inflight = new Set();
14
+ const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? 5000);
15
+ const schedule = options.schedule ?? ((fn, delay) => setTimeout(fn, delay));
16
+ const cancel = options.cancel ?? clearTimeout;
17
+
18
+ function later(id, chatId) {
19
+ const timer = schedule(() => {
20
+ active.delete(id);
21
+ watch(id, chatId);
22
+ }, pollIntervalMs);
23
+ active.set(id, timer);
24
+ }
25
+
26
+ async function poll(id, chatId) {
27
+ inflight.add(id);
28
+ try {
29
+ const result = await options.getMedia(id);
30
+ options.tracker.observeLookup(result);
31
+ if (result?.kind === 'status') {
32
+ const status = String(result.status ?? '').trim().toLowerCase();
33
+ if (TERMINAL_FAILURES.has(status)) {
34
+ options.tracker.noteDeliveryFailure(id, `media_${status}`);
35
+ completed.add(id);
36
+ return;
37
+ }
38
+ later(id, chatId);
39
+ return;
40
+ }
41
+ if (result?.kind !== 'file') {
42
+ options.tracker.noteDeliveryFailure(id, 'media_result_not_file');
43
+ completed.add(id);
44
+ return;
45
+ }
46
+ const localPath = await options.saveMedia(result);
47
+ options.tracker.noteSaved(id, localPath);
48
+ const sent = await options.deliver(chatId, localPath);
49
+ if (!sent) {
50
+ options.tracker.noteDeliveryFailure(id, 'channel_delivery_failed');
51
+ later(id, chatId);
52
+ return;
53
+ }
54
+ options.tracker.noteDelivered(id);
55
+ completed.add(id);
56
+ } catch (error) {
57
+ if (error?.code === 'MEDIA_QUALITY_REJECTED') {
58
+ options.tracker.noteRejected?.(id, error.message);
59
+ completed.add(id);
60
+ return;
61
+ }
62
+ options.tracker.noteDeliveryFailure(id, error?.message ?? error);
63
+ later(id, chatId);
64
+ } finally {
65
+ inflight.delete(id);
66
+ }
67
+ }
68
+
69
+ function watch(id, chatId) {
70
+ if (completed.has(id) || active.has(id) || inflight.has(id)) return;
71
+ options.tracker.associateDelivery(id, chatId);
72
+ void poll(id, chatId);
73
+ }
74
+
75
+ return {
76
+ watch,
77
+ resume() {
78
+ for (const job of options.tracker.pendingDeliveries()) watch(job.id, job.chatId);
79
+ },
80
+ stop() {
81
+ for (const timer of active.values()) cancel(timer);
82
+ active.clear();
83
+ },
84
+ async flushForTest() {
85
+ while (inflight.size > 0) await new Promise((resolve) => setImmediate(resolve));
86
+ },
87
+ };
88
+ }
89
+
90
+ module.exports = { createMediaAutoRetrievalController, parseAcceptedMediaJob };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const MIN_IMAGE_BYTES = 100000;
4
+ const MIN_IMAGE_EDGE = 1024;
5
+
6
+ function pngDimensions(data) {
7
+ const bytes = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
8
+ if (bytes.length < 24 || !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
9
+ return undefined;
10
+ }
11
+ if (bytes.toString('ascii', 12, 16) !== 'IHDR') return undefined;
12
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
13
+ }
14
+
15
+ function jpegDimensions(data) {
16
+ const bytes = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
17
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return undefined;
18
+ let offset = 2;
19
+ while (offset + 9 < bytes.length) {
20
+ if (bytes[offset] !== 0xff) {
21
+ offset += 1;
22
+ continue;
23
+ }
24
+ const marker = bytes[offset + 1];
25
+ if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
26
+ return { height: bytes.readUInt16BE(offset + 5), width: bytes.readUInt16BE(offset + 7) };
27
+ }
28
+ if (marker === 0xd8 || marker === 0xd9) {
29
+ offset += 2;
30
+ continue;
31
+ }
32
+ const length = bytes.readUInt16BE(offset + 2);
33
+ if (length < 2) return undefined;
34
+ offset += 2 + length;
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ function validateCompletedMedia(result) {
40
+ if (result?.kind !== 'file' || !(result.data instanceof Uint8Array) || result.data.byteLength === 0) {
41
+ return { ok: false, reason: 'empty_media_payload' };
42
+ }
43
+ if (!String(result.mimeType).startsWith('image/')) return { ok: true };
44
+ const dimensions = result.mimeType === 'image/png'
45
+ ? pngDimensions(result.data)
46
+ : result.mimeType === 'image/jpeg'
47
+ ? jpegDimensions(result.data)
48
+ : undefined;
49
+ if (!dimensions) return { ok: false, reason: 'image_dimensions_unreadable' };
50
+ if (dimensions.width < MIN_IMAGE_EDGE || dimensions.height < MIN_IMAGE_EDGE) {
51
+ return { ok: false, reason: `image_dimensions_below_${MIN_IMAGE_EDGE}`, ...dimensions };
52
+ }
53
+ if (result.data.byteLength < MIN_IMAGE_BYTES) {
54
+ return { ok: false, reason: `image_payload_below_${MIN_IMAGE_BYTES}`, ...dimensions };
55
+ }
56
+ return { ok: true, ...dimensions };
57
+ }
58
+
59
+ module.exports = { validateCompletedMedia };
@@ -1,5 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
3
7
  const TERMINAL_STATUSES = new Set([
4
8
  'blocked',
5
9
  'cancelled',
@@ -10,34 +14,169 @@ const TERMINAL_STATUSES = new Set([
10
14
  'failed',
11
15
  'succeeded',
12
16
  ]);
17
+ const STATE_VERSION = 1;
18
+ const MAX_RECORDS = 100;
13
19
 
14
20
  function validMediaId(value) {
15
21
  return typeof value === 'string' && value.length > 0 && value.length <= 200
16
22
  && /^[A-Za-z0-9_-]+$/.test(value);
17
23
  }
18
24
 
25
+ function normalizedStatus(value, fallback = 'processing') {
26
+ const status = typeof value === 'string' ? value.trim().toLowerCase() : '';
27
+ return status || fallback;
28
+ }
29
+
19
30
  function terminalStatus(value) {
20
- return typeof value === 'string' && TERMINAL_STATUSES.has(value.trim().toLowerCase());
31
+ return TERMINAL_STATUSES.has(normalizedStatus(value, ''));
32
+ }
33
+
34
+ function mediaRequestKey(requestPath, body) {
35
+ const stableBody = Object.fromEntries(Object.entries(body ?? {}).sort(([a], [b]) => a.localeCompare(b)));
36
+ return crypto.createHash('sha256')
37
+ .update(String(requestPath))
38
+ .update('\0')
39
+ .update(JSON.stringify(stableBody))
40
+ .digest('hex');
21
41
  }
22
42
 
23
- function createPendingMediaTracker() {
24
- const pending = new Set();
43
+ function safeRecord(value) {
44
+ if (!value || typeof value !== 'object' || !validMediaId(value.id)) return undefined;
45
+ return {
46
+ id: value.id,
47
+ status: normalizedStatus(value.status),
48
+ ...(typeof value.requestKey === 'string' && /^[a-f0-9]{64}$/.test(value.requestKey)
49
+ ? { requestKey: value.requestKey }
50
+ : {}),
51
+ ...(typeof value.chatId === 'string' && value.chatId.length > 0 && value.chatId.length <= 100
52
+ ? { chatId: value.chatId }
53
+ : {}),
54
+ ...(typeof value.localPath === 'string' && value.localPath.length > 0 && value.localPath.length <= 4096
55
+ ? { localPath: value.localPath }
56
+ : {}),
57
+ createdAt: Number.isFinite(value.createdAt) ? value.createdAt : Date.now(),
58
+ updatedAt: Number.isFinite(value.updatedAt) ? value.updatedAt : Date.now(),
59
+ ...(Number.isFinite(value.deliveredAt) ? { deliveredAt: value.deliveredAt } : {}),
60
+ ...(typeof value.lastError === 'string' && value.lastError.length > 0
61
+ ? { lastError: value.lastError.slice(0, 500) }
62
+ : {}),
63
+ };
64
+ }
65
+
66
+ function createPendingMediaTracker(options = {}) {
67
+ const statePath = typeof options.statePath === 'string' && options.statePath.length > 0
68
+ ? options.statePath
69
+ : undefined;
70
+ const now = typeof options.now === 'function' ? options.now : Date.now;
71
+ const records = new Map();
72
+
73
+ function load() {
74
+ if (!statePath) return;
75
+ try {
76
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
77
+ if (parsed?.version !== STATE_VERSION || !Array.isArray(parsed.jobs)) return;
78
+ for (const value of parsed.jobs) {
79
+ const record = safeRecord(value);
80
+ if (record) records.set(record.id, record);
81
+ }
82
+ } catch {}
83
+ }
84
+
85
+ function persist() {
86
+ if (!statePath) return;
87
+ const jobs = [...records.values()]
88
+ .sort((a, b) => b.updatedAt - a.updatedAt)
89
+ .slice(0, MAX_RECORDS);
90
+ records.clear();
91
+ for (const job of jobs) records.set(job.id, job);
92
+ fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
93
+ const temporary = `${statePath}.${process.pid}.tmp`;
94
+ fs.writeFileSync(temporary, `${JSON.stringify({ version: STATE_VERSION, jobs })}\n`, { mode: 0o600 });
95
+ fs.renameSync(temporary, statePath);
96
+ }
97
+
98
+ function change(id, updates) {
99
+ if (!validMediaId(id)) return undefined;
100
+ const existing = records.get(id) ?? {
101
+ id,
102
+ status: 'processing',
103
+ createdAt: now(),
104
+ updatedAt: now(),
105
+ };
106
+ const record = safeRecord({ ...existing, ...updates, id, updatedAt: now() });
107
+ if (!record) return undefined;
108
+ records.set(id, record);
109
+ persist();
110
+ return record;
111
+ }
112
+
113
+ load();
25
114
 
26
115
  return {
27
- observeSubmission(job) {
116
+ observeSubmission(job, requestKey) {
28
117
  if (!validMediaId(job?.id)) return;
29
- if (terminalStatus(job?.status)) pending.delete(job.id);
30
- else pending.add(job.id);
118
+ change(job.id, {
119
+ status: normalizedStatus(job.status),
120
+ ...(typeof requestKey === 'string' ? { requestKey } : {}),
121
+ });
31
122
  },
32
123
  observeLookup(result) {
33
124
  if (!validMediaId(result?.id)) return;
34
- if (result.kind !== 'status' || terminalStatus(result.status)) pending.delete(result.id);
35
- else pending.add(result.id);
125
+ if (result.kind === 'file' || result.kind === 'text') {
126
+ change(result.id, { status: 'complete' });
127
+ return;
128
+ }
129
+ change(result.id, { status: normalizedStatus(result.status) });
130
+ },
131
+ findReusableRequest(requestKey) {
132
+ if (typeof requestKey !== 'string') return undefined;
133
+ const record = [...records.values()]
134
+ .filter((job) => job.requestKey === requestKey && job.deliveredAt === undefined)
135
+ .sort((a, b) => b.updatedAt - a.updatedAt)[0];
136
+ if (!record || (terminalStatus(record.status) && record.status !== 'complete' && record.status !== 'completed')) {
137
+ return undefined;
138
+ }
139
+ return { id: record.id, status: record.status };
140
+ },
141
+ associateDelivery(id, chatId) {
142
+ if (typeof chatId !== 'string' || chatId.length === 0) return;
143
+ change(id, { chatId });
144
+ },
145
+ noteSaved(id, localPath) {
146
+ if (typeof localPath !== 'string' || localPath.length === 0) return;
147
+ change(id, { localPath, status: 'complete', lastError: undefined });
148
+ },
149
+ noteDelivered(id) {
150
+ change(id, { deliveredAt: now(), status: 'complete', lastError: undefined });
151
+ },
152
+ noteDeliveryFailure(id, error) {
153
+ change(id, { lastError: String(error ?? 'delivery_failed').slice(0, 500) });
154
+ },
155
+ noteRejected(id, error) {
156
+ change(id, {
157
+ status: 'failed',
158
+ lastError: String(error ?? 'media_quality_rejected').slice(0, 500),
159
+ });
160
+ },
161
+ pendingDeliveries() {
162
+ return [...records.values()]
163
+ .filter((job) => job.chatId !== undefined && job.deliveredAt === undefined
164
+ && (!terminalStatus(job.status) || job.status === 'complete' || job.status === 'completed'))
165
+ .sort((a, b) => a.createdAt - b.createdAt)
166
+ .map((job) => ({
167
+ id: job.id,
168
+ chatId: job.chatId,
169
+ status: job.status,
170
+ ...(job.localPath === undefined ? {} : { localPath: job.localPath }),
171
+ }));
36
172
  },
37
173
  ids() {
38
- return [...pending];
174
+ return [...records.values()]
175
+ .filter((job) => !terminalStatus(job.status))
176
+ .sort((a, b) => a.createdAt - b.createdAt)
177
+ .map((job) => job.id);
39
178
  },
40
179
  };
41
180
  }
42
181
 
43
- module.exports = { createPendingMediaTracker };
182
+ module.exports = { createPendingMediaTracker, mediaRequestKey };
package/blun.mjs CHANGED
@@ -312949,9 +312949,9 @@ async function assertSuccess(response, operation) {
312949
312949
  } catch {}
312950
312950
  throw new Error(`${operation} failed: HTTP ${String(response.status)}${detail ? `: ${detail}` : ""}`);
312951
312951
  }
312952
- var createPendingMediaTracker, BlunMediaService;
312952
+ var createPendingMediaTracker, mediaRequestKey, activeBlunMediaService, BlunMediaService;
312953
312953
  var init_blun_media = __esmMin((() => {
312954
- ({ createPendingMediaTracker } = createRequire(import.meta.url)("./bin/pending-media-policy.cjs"));
312954
+ ({ createPendingMediaTracker, mediaRequestKey } = createRequire(import.meta.url)("./bin/pending-media-policy.cjs"));
312955
312955
  BlunMediaService = class {
312956
312956
  tokenProvider;
312957
312957
  apiKey;
@@ -312959,7 +312959,7 @@ var init_blun_media = __esmMin((() => {
312959
312959
  defaultHeaders;
312960
312960
  customHeaders;
312961
312961
  fetchImpl;
312962
- pendingMedia = createPendingMediaTracker();
312962
+ pendingMedia;
312963
312963
  constructor(options) {
312964
312964
  this.tokenProvider = options.tokenProvider;
312965
312965
  this.apiKey = options.apiKey;
@@ -312967,6 +312967,8 @@ var init_blun_media = __esmMin((() => {
312967
312967
  this.defaultHeaders = options.defaultHeaders ?? {};
312968
312968
  this.customHeaders = options.customHeaders ?? {};
312969
312969
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
312970
+ this.pendingMedia = createPendingMediaTracker({ statePath: join(resolveBlunHome$1(), "media", "pending-jobs.json") });
312971
+ activeBlunMediaService = this;
312970
312972
  }
312971
312973
  generateImage(prompt, options) {
312972
312974
  return this.submit("/images/generations", { prompt }, options);
@@ -313036,6 +313038,9 @@ var init_blun_media = __esmMin((() => {
313036
313038
  });
313037
313039
  }
313038
313040
  async submit(path, body, options) {
313041
+ const requestKey = mediaRequestKey(path, body);
313042
+ const reusable = this.pendingMedia.findReusableRequest(requestKey);
313043
+ if (reusable !== void 0) return reusable;
313039
313044
  const response = await this.request(path, {
313040
313045
  method: "POST",
313041
313046
  body: JSON.stringify(body)
@@ -313043,7 +313048,7 @@ var init_blun_media = __esmMin((() => {
313043
313048
  if (response.status === 409) throw await blockedSubmissionError(response);
313044
313049
  await assertSuccess(response, "Media request");
313045
313050
  const job = parseJob(await response.json());
313046
- this.pendingMedia.observeSubmission(job);
313051
+ this.pendingMedia.observeSubmission(job, requestKey);
313047
313052
  return job;
313048
313053
  }
313049
313054
  trackMediaLookup(result) {
@@ -313053,6 +313058,9 @@ var init_blun_media = __esmMin((() => {
313053
313058
  pendingMediaJobIds() {
313054
313059
  return this.pendingMedia.ids();
313055
313060
  }
313061
+ pendingMediaTracker() {
313062
+ return this.pendingMedia;
313063
+ }
313056
313064
  async request(path, init, options) {
313057
313065
  const firstToken = await this.resolveToken(false);
313058
313066
  const first = await this.fetchWithToken(path, init, options, firstToken);
@@ -508124,6 +508132,7 @@ var SessionEventHandler = class {
508124
508132
  mediaKind: mediaKindForToolName(matchedCall.name),
508125
508133
  phase: "failed"
508126
508134
  });
508135
+ if (matchedCall !== void 0 && matchedCall.name !== "GetMedia" && isMediaToolName(matchedCall.name) && event.isError !== true) this.host.trackChannelMediaJob(event.output);
508127
508136
  if (matchedCall?.name === "GetMedia" && event.isError !== true) this.host.runChannelMediaFallback(event.output);
508128
508137
  this.subAgentEventHandler.handleAgentSwarmToolResult(event.toolCallId, resultData, event.isError === true);
508129
508138
  if (matchedCall !== void 0 && matchedCall.name === "TodoList" && !event.isError) {
@@ -514706,6 +514715,9 @@ function outboxDeliveredFile(marker, chatId, filePath) {
514706
514715
  }
514707
514716
  var retryTelegramMediaDelivery, retryableTelegramMediaFailure;
514708
514717
  ({ retryTelegramMediaDelivery, retryableTelegramMediaFailure } = createRequire(import.meta.url)("./bin/telegram-media-delivery-policy.cjs"));
514718
+ var createMediaAutoRetrievalController, parseAcceptedMediaJob, validateCompletedMedia;
514719
+ ({ createMediaAutoRetrievalController, parseAcceptedMediaJob } = createRequire(import.meta.url)("./bin/media-auto-retrieval-policy.cjs"));
514720
+ ({ validateCompletedMedia } = createRequire(import.meta.url)("./bin/media-result-policy.cjs"));
514709
514721
  var { isPrivateInternalStatusReply, sanitizePrivateConversationReply } = createRequire(import.meta.url)("./bin/telegram-private-conversation-policy.cjs");
514710
514722
  const TELEGRAM_TEXT_LIMIT = 4096;
514711
514723
  const TELEGRAM_ATTACHMENT_LIMIT = 50 * 1024 * 1024;
@@ -514751,6 +514763,26 @@ function completedMediaLocalPath(output) {
514751
514763
  const candidate = /Media job [^\r\n]+ is complete\. Local file: (.+?)\. The BLUN host/u.exec(text)?.[1]?.trim();
514752
514764
  return candidate === void 0 || candidate.length === 0 ? void 0 : safeCompletedMediaPath(candidate);
514753
514765
  }
514766
+ function completedMediaJobId(output) {
514767
+ const text = Array.isArray(output) ? output.filter((part) => typeof part === "object" && part !== null && part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n") : typeof output === "string" ? output : "";
514768
+ return /Media job ([A-Za-z0-9_-]{1,200}) is complete\./u.exec(text)?.[1];
514769
+ }
514770
+ async function saveAutoRetrievedMedia(result) {
514771
+ const quality = validateCompletedMedia(result);
514772
+ if (!quality.ok) {
514773
+ const error = new Error(`Media quality gate rejected ${result.id}: ${quality.reason}`);
514774
+ error.code = "MEDIA_QUALITY_REJECTED";
514775
+ throw error;
514776
+ }
514777
+ const outputDir = mediaDir();
514778
+ await mkdir(outputDir, {
514779
+ recursive: true,
514780
+ mode: 448
514781
+ });
514782
+ const localPath = join(outputDir, `${safeMediaId(result.id)}.${extensionForMediaType(result.mimeType)}`);
514783
+ await writeFile(localPath, result.data, { mode: 384 });
514784
+ return localPath;
514785
+ }
514754
514786
  /** Telegram group/supergroup ids are negative; DMs are the positive user id. */
514755
514787
  function isGroupChat(chatId) {
514756
514788
  return chatId.startsWith("-");
@@ -515956,6 +515988,7 @@ var BlunTUI = class {
515956
515988
  mediaActivityStore = new MediaActivityStore();
515957
515989
  mediaActivityTickTimer;
515958
515990
  mediaActivityExpanded = false;
515991
+ mediaAutoRetrieval;
515959
515992
  lastHistoryContent;
515960
515993
  inputDraftTimer;
515961
515994
  pendingInputDraft;
@@ -517431,16 +517464,36 @@ var BlunTUI = class {
517431
517464
  /** See SessionEventHost.runChannelReplyFallback — called at turn end. */
517432
517465
  channelMediaDeliveries = /* @__PURE__ */ new Set();
517433
517466
  channelMediaDeliveryFailures = /* @__PURE__ */ new Set();
517467
+ ensureMediaAutoRetrieval() {
517468
+ if (this.mediaAutoRetrieval !== void 0) return this.mediaAutoRetrieval;
517469
+ const service = activeBlunMediaService;
517470
+ if (service === void 0) return void 0;
517471
+ this.mediaAutoRetrieval = createMediaAutoRetrievalController({
517472
+ tracker: service.pendingMediaTracker(),
517473
+ getMedia: (id) => service.getMedia(id),
517474
+ saveMedia: saveAutoRetrievedMedia,
517475
+ deliver: sendMediaReplyFallback
517476
+ });
517477
+ return this.mediaAutoRetrieval;
517478
+ }
517479
+ trackChannelMediaJob(output) {
517480
+ const guard = this.pendingChannelReplyGuard;
517481
+ const id = parseAcceptedMediaJob(typeof output === "string" ? output : "");
517482
+ if (guard === void 0 || id === void 0) return;
517483
+ this.ensureMediaAutoRetrieval()?.watch(id, guard.chatId);
517484
+ }
517434
517485
  /** Deliver completed media at tool-result time so later queued work cannot hide it. */
517435
517486
  runChannelMediaFallback(output) {
517436
517487
  const guard = this.pendingChannelReplyGuard;
517437
517488
  const filePath = completedMediaLocalPath(output);
517489
+ const jobId = completedMediaJobId(output);
517438
517490
  if (guard === void 0 || filePath === void 0) return;
517439
517491
  const deliveryKey = `${guard.chatId}\0${filePath}`;
517440
517492
  if (this.channelMediaDeliveries.has(deliveryKey)) return;
517441
517493
  this.channelMediaDeliveries.add(deliveryKey);
517442
517494
  sendMediaReplyFallback(guard.chatId, filePath).then((sent) => {
517443
517495
  if (sent) {
517496
+ if (jobId !== void 0) activeBlunMediaService?.pendingMediaTracker().noteDelivered(jobId);
517444
517497
  this.channelMediaDeliveryFailures.delete(deliveryKey);
517445
517498
  return;
517446
517499
  }
@@ -517496,6 +517549,7 @@ var BlunTUI = class {
517496
517549
  connectTelegramChannel() {
517497
517550
  process.env["BLUN_TELEGRAM_ATTACH"] = "on";
517498
517551
  this.startTelegramChannel();
517552
+ this.ensureMediaAutoRetrieval()?.resume();
517499
517553
  return this.telegramChannel !== void 0;
517500
517554
  }
517501
517555
  startTelegramChannel() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blun-king-cli",
3
- "version": "9.1.393",
3
+ "version": "9.1.395",
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": {