@inetafrica/open-claudia 3.4.17 → 3.4.18
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/CHANGELOG.md +6 -0
- package/channels/kazee/adapter.js +107 -35
- package/channels/types.js +2 -1
- package/core/context-media.js +43 -0
- package/core/router.js +32 -16
- package/package.json +3 -2
- package/test-kazee-context-media.js +98 -0
- package/test-provider-gateway.js +25 -1
- package/test-provider-language.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.4.18
|
|
4
|
+
|
|
5
|
+
- Pass images and files from recent Kazee group messages and quoted replies into the model, retaining sender and message attribution. Follow-up questions can reuse attachments after the text catch-up watermark advances.
|
|
6
|
+
- Keep group participation and capability checks ahead of referenced downloads. Exclude deleted and cross-room attachments; prioritize quoted media, deduplicate files, and bound downloads by count, size, time and free disk space.
|
|
7
|
+
- Cache completed downloads under generated filenames and explicitly report unavailable or omitted attachments. Cover real image bytes, repeated follow-ups, reply resolution, runner attachment handoff, held turns, room isolation and failed downloads.
|
|
8
|
+
|
|
3
9
|
## 3.4.17
|
|
4
10
|
|
|
5
11
|
- Include resolved caller, channel and room identifiers in external guard judgments so caller-specific permissions can be evaluated for contacts without a saved profile. Identity never grants permission by itself; existing policy and refusal behavior remain in force.
|
|
@@ -7,6 +7,9 @@ const fs = require("fs");
|
|
|
7
7
|
const path = require("path");
|
|
8
8
|
const https = require("https");
|
|
9
9
|
const http = require("http");
|
|
10
|
+
const crypto = require("crypto");
|
|
11
|
+
const { pipeline } = require("stream/promises");
|
|
12
|
+
const { Transform } = require("stream");
|
|
10
13
|
const { URL } = require("url");
|
|
11
14
|
const { TEMP_DIR, FILES_DIR } = require("../../core/config");
|
|
12
15
|
const { canonicalForChannel } = require("../../core/identity");
|
|
@@ -35,7 +38,7 @@ const INBOUND_DEDUP_MEMORY = 500;
|
|
|
35
38
|
|
|
36
39
|
const CONTEXT_MAX_MESSAGES = 40;
|
|
37
40
|
const CONTEXT_BODY_LIMIT = 280;
|
|
38
|
-
const COLD_START_WINDOW =
|
|
41
|
+
const COLD_START_WINDOW = 40;
|
|
39
42
|
|
|
40
43
|
// Kazee Media docs from mobile uploads often carry no fileName, so the saved
|
|
41
44
|
// extension must come from the mimeType (or the blob URL) — otherwise every
|
|
@@ -256,33 +259,11 @@ class KazeeAdapter {
|
|
|
256
259
|
// chat-central V2 emits attached media on msg.medias (array) or
|
|
257
260
|
// msg.media (single). Older shapes used msg.attachments — keep that
|
|
258
261
|
// as a fallback for safety.
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
: (msg.media ? [msg.media] : (msg.attachments || []));
|
|
262
|
-
if (rawMedia.length) {
|
|
263
|
-
// Kazee carries media captions in the same `content` field used for
|
|
264
|
-
// plain text. The image router reads the transport-neutral `caption`
|
|
265
|
-
// field (matching Telegram), so preserve it explicitly on media
|
|
266
|
-
// envelopes instead of silently falling back to "Describe this image".
|
|
262
|
+
const media = this._mediaRefs(msg);
|
|
263
|
+
if (media.length) {
|
|
267
264
|
envelope.caption = text;
|
|
268
|
-
envelope.media =
|
|
269
|
-
|
|
270
|
-
if (!kind) {
|
|
271
|
-
const mime = (a.mimeType || "").toLowerCase();
|
|
272
|
-
if (mime.startsWith("image/")) kind = "photo";
|
|
273
|
-
else if (mime.startsWith("audio/")) kind = "audio";
|
|
274
|
-
else if (mime.startsWith("video/")) kind = "video";
|
|
275
|
-
else kind = "document";
|
|
276
|
-
} else if (kind === "image") kind = "photo";
|
|
277
|
-
else if (kind === "file") kind = "document";
|
|
278
|
-
return {
|
|
279
|
-
type: kind,
|
|
280
|
-
fileId: a.url || a._id,
|
|
281
|
-
fileName: a.name || a.filename,
|
|
282
|
-
mimeType: a.mimeType,
|
|
283
|
-
size: a.size,
|
|
284
|
-
};
|
|
285
|
-
});
|
|
265
|
+
envelope.media = media;
|
|
266
|
+
|
|
286
267
|
if (envelope.type === "text") {
|
|
287
268
|
const k = envelope.media[0].type;
|
|
288
269
|
envelope.type = k === "video" ? "document" : k;
|
|
@@ -309,10 +290,10 @@ class KazeeAdapter {
|
|
|
309
290
|
if (typeof msg.replyTo === "object") {
|
|
310
291
|
m = msg.replyTo;
|
|
311
292
|
} else {
|
|
312
|
-
|
|
313
|
-
m = data?.message || data;
|
|
293
|
+
m = await this._messageInChat(channelId, String(msg.replyTo));
|
|
314
294
|
}
|
|
315
|
-
|
|
295
|
+
const repliedChat = m?.chatId || m?.chat?._id || m?.chat?.id || (typeof m?.chat === "string" ? m.chat : "");
|
|
296
|
+
if (m && (m._id || m.id) && !m.isDeleted && (!repliedChat || String(repliedChat) === channelId)) {
|
|
316
297
|
envelope.reply = {
|
|
317
298
|
text: m.content || "",
|
|
318
299
|
from: {
|
|
@@ -321,8 +302,10 @@ class KazeeAdapter {
|
|
|
321
302
|
username: m.sender?.username || "",
|
|
322
303
|
},
|
|
323
304
|
messageId: m._id || m.id,
|
|
305
|
+
media: this._mediaRefs(m, channelId, "Quoted message"),
|
|
324
306
|
};
|
|
325
307
|
}
|
|
308
|
+
|
|
326
309
|
} catch (e) { /* best-effort — envelope is still valid without reply context */ }
|
|
327
310
|
}
|
|
328
311
|
|
|
@@ -360,6 +343,36 @@ class KazeeAdapter {
|
|
|
360
343
|
this._emit("message", envelope);
|
|
361
344
|
}
|
|
362
345
|
|
|
346
|
+
_mediaRefs(msg, contextChatId, source) {
|
|
347
|
+
const raw = Array.isArray(msg.medias) && msg.medias.length ? msg.medias
|
|
348
|
+
: msg.media ? [msg.media] : Array.isArray(msg.attachments) ? msg.attachments : [];
|
|
349
|
+
return raw.filter((a) => a && typeof a === "object").filter((a) => {
|
|
350
|
+
const chat = a.chat?._id || a.chat?.id || a.chat;
|
|
351
|
+
return !contextChatId || !chat || String(chat) === String(contextChatId);
|
|
352
|
+
}).map((a) => {
|
|
353
|
+
const mime = String(a.mimeType || "").toLowerCase();
|
|
354
|
+
let type = a.type || (mime.startsWith("image/") ? "photo" : mime.startsWith("audio/") ? "audio" : mime.startsWith("video/") ? "video" : "document");
|
|
355
|
+
if (type === "image") type = "photo";
|
|
356
|
+
if (type === "file") type = "document";
|
|
357
|
+
const ref = { type, fileId: a.url || a._id, fileName: a.fileName || a.name || a.filename, mimeType: a.mimeType, size: a.size };
|
|
358
|
+
if (contextChatId) {
|
|
359
|
+
ref.messageId = String(msg._id || msg.id || "");
|
|
360
|
+
ref.contextKey = JSON.stringify([String(contextChatId), ref.messageId, String(a._id || a.url || "")]);
|
|
361
|
+
ref.source = source;
|
|
362
|
+
}
|
|
363
|
+
return ref;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async _messageInChat(chatId, messageId) {
|
|
368
|
+
if (!this.botUserId) return null;
|
|
369
|
+
const route = `/messages/${encodeURIComponent(chatId)}/${encodeURIComponent(String(this.botUserId))}/around/${encodeURIComponent(messageId)}?before=1&after=1`;
|
|
370
|
+
const response = await this._request("GET", route);
|
|
371
|
+
const data = response?.data || response || {};
|
|
372
|
+
if (response?.error || data.error || response?.success === false) return null;
|
|
373
|
+
return (data.messages || []).find((m) => String(m._id || m.id || "") === messageId) || null;
|
|
374
|
+
}
|
|
375
|
+
|
|
363
376
|
// Channel-agnostic thread-context provider (ChannelAdapter contract, same
|
|
364
377
|
// hook Spaces implements). The router calls this at the start of an engaged
|
|
365
378
|
// group turn and prepends the returned contextBlock to the prompt, so the
|
|
@@ -377,7 +390,25 @@ class KazeeAdapter {
|
|
|
377
390
|
try {
|
|
378
391
|
const since = kazeeState.getContextWatermark(chatId);
|
|
379
392
|
const msgs = await this._messagesSince(chatId, since);
|
|
380
|
-
|
|
393
|
+
let recent = msgs;
|
|
394
|
+
if (since) {
|
|
395
|
+
try { recent = await this._messagesSince(chatId, null, CONTEXT_MAX_MESSAGES); }
|
|
396
|
+
catch (_) { /* Delta attachments remain available when recent-page fetch fails. */ }
|
|
397
|
+
}
|
|
398
|
+
const mediaMessages = new Map();
|
|
399
|
+
for (const m of [...recent, ...msgs]) {
|
|
400
|
+
const id = String(m._id || m.id || "");
|
|
401
|
+
const room = m.chatId || m.chat?._id || m.chat?.id || (typeof m.chat === "string" ? m.chat : "");
|
|
402
|
+
if (id && (!room || String(room) === chatId)) mediaMessages.set(id, m);
|
|
403
|
+
}
|
|
404
|
+
const media = [...mediaMessages.values()]
|
|
405
|
+
.filter((m) => !m.isDeleted && String(m._id || m.id) !== String(envelope.messageId || "")
|
|
406
|
+
&& String(m.sender?._id || m.sender?.id || "") !== String(this.botUserId))
|
|
407
|
+
.sort((a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || "")))
|
|
408
|
+
.slice(-CONTEXT_MAX_MESSAGES)
|
|
409
|
+
.flatMap((m) => this._mediaRefs(m, chatId, `${m.sender?.name || "Someone"} (${m.createdAt || "unknown time"})`))
|
|
410
|
+
.reverse();
|
|
411
|
+
if (!msgs.length && !media.length) return null;
|
|
381
412
|
const newest = msgs[msgs.length - 1]?.createdAt;
|
|
382
413
|
if (newest) kazeeState.setContextWatermark(chatId, newest);
|
|
383
414
|
const triggerId = String(envelope.messageId || "");
|
|
@@ -395,13 +426,13 @@ class KazeeAdapter {
|
|
|
395
426
|
return { who, body, ago: this._ago(m.createdAt) };
|
|
396
427
|
})
|
|
397
428
|
.filter((m) => m.body);
|
|
398
|
-
if (!rendered.length) return null;
|
|
429
|
+
if (!rendered.length && !media.length) return null;
|
|
399
430
|
const lines = ["[Kazee group chat context]"];
|
|
400
431
|
lines.push(since
|
|
401
432
|
? "Messages in this group since your last engaged turn (oldest→newest):"
|
|
402
433
|
: "Recent messages in this group (oldest→newest):");
|
|
403
434
|
for (const m of rendered.slice(-CONTEXT_MAX_MESSAGES)) lines.push(` [${m.ago}] ${m.who}: ${m.body}`);
|
|
404
|
-
return { contextBlock: lines.join("\n") };
|
|
435
|
+
return { contextBlock: lines.join("\n"), media };
|
|
405
436
|
} catch (e) {
|
|
406
437
|
console.error("kazee buildThreadContext:", e.message);
|
|
407
438
|
return null;
|
|
@@ -412,11 +443,11 @@ class KazeeAdapter {
|
|
|
412
443
|
// (oldest-first, server-capped at 500). Without one — the chat's first-ever
|
|
413
444
|
// engage — one recent page, which the server already returns in
|
|
414
445
|
// chronological order.
|
|
415
|
-
async _messagesSince(chatId, sinceIso) {
|
|
446
|
+
async _messagesSince(chatId, sinceIso, count = COLD_START_WINDOW) {
|
|
416
447
|
const base = `/messages/${encodeURIComponent(chatId)}/${encodeURIComponent(String(this.botUserId))}`;
|
|
417
448
|
const query = sinceIso
|
|
418
449
|
? `?since=${encodeURIComponent(sinceIso)}`
|
|
419
|
-
: `?page=1&messageCount=${
|
|
450
|
+
: `?page=1&messageCount=${count}`;
|
|
420
451
|
const res = await this._request("GET", base + query);
|
|
421
452
|
const data = res?.data || res || {};
|
|
422
453
|
if (data.error) throw new Error(String(data.error));
|
|
@@ -726,6 +757,7 @@ class KazeeAdapter {
|
|
|
726
757
|
|
|
727
758
|
async downloadMedia(media) {
|
|
728
759
|
if (!media || !media.fileId) return null;
|
|
760
|
+
if (media.contextKey) return this._downloadContextMedia(media);
|
|
729
761
|
const url = media.fileId; // Kazee passes the cloud URL through fileId.
|
|
730
762
|
let urlExt = "";
|
|
731
763
|
try { urlExt = path.extname(new URL(url).pathname); } catch (e) {}
|
|
@@ -748,6 +780,46 @@ class KazeeAdapter {
|
|
|
748
780
|
return localPath;
|
|
749
781
|
}
|
|
750
782
|
|
|
783
|
+
async _downloadContextMedia(media) {
|
|
784
|
+
const url = new URL(media.fileId);
|
|
785
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) throw new Error("Invalid media URL");
|
|
786
|
+
const candidateExt = path.extname(media.fileName || url.pathname).toLowerCase();
|
|
787
|
+
const ext = /^\.[a-z0-9]{1,8}$/.test(candidateExt) ? candidateExt : MIME_EXT[media.mimeType] || ".bin";
|
|
788
|
+
const key = crypto.createHash("sha256").update(media.contextKey).digest("hex");
|
|
789
|
+
const localPath = path.join(media.type === "photo" ? TEMP_DIR : FILES_DIR, `kz-context-${key}${ext}`);
|
|
790
|
+
const maxBytes = Math.min(Number(media.maxBytes) || 10 * 1024 * 1024, 10 * 1024 * 1024);
|
|
791
|
+
try {
|
|
792
|
+
const st = fs.statSync(localPath);
|
|
793
|
+
if (st.isFile() && st.size > 0 && st.size <= maxBytes) return localPath;
|
|
794
|
+
} catch (_) {}
|
|
795
|
+
const disk = fs.statfsSync(path.dirname(localPath));
|
|
796
|
+
if (disk.bavail * disk.bsize < maxBytes * 2 + 16 * 1024 * 1024) throw new Error("Insufficient space for media download");
|
|
797
|
+
const partial = `${localPath}.${crypto.randomUUID()}.part`;
|
|
798
|
+
let req;
|
|
799
|
+
const timer = setTimeout(() => req?.destroy(new Error("Media download timed out")), Math.min(media.timeoutMs || 8000, 8000));
|
|
800
|
+
try {
|
|
801
|
+
const res = await new Promise((resolve, reject) => {
|
|
802
|
+
req = (url.protocol === "https:" ? https : http).get(url, resolve);
|
|
803
|
+
req.on("error", reject);
|
|
804
|
+
});
|
|
805
|
+
if (res.statusCode !== 200 || Number(res.headers["content-length"]) > maxBytes) {
|
|
806
|
+
res.destroy(); throw new Error("Media unavailable or exceeds size limit");
|
|
807
|
+
}
|
|
808
|
+
let bytes = 0;
|
|
809
|
+
await pipeline(res, new Transform({ transform(chunk, enc, done) {
|
|
810
|
+
bytes += chunk.length;
|
|
811
|
+
done(bytes > maxBytes ? new Error("Media exceeds size limit") : null, chunk);
|
|
812
|
+
} }), fs.createWriteStream(partial, { flags: "wx" }));
|
|
813
|
+
if (!bytes) throw new Error("Empty media");
|
|
814
|
+
fs.renameSync(partial, localPath);
|
|
815
|
+
return localPath;
|
|
816
|
+
} catch (e) {
|
|
817
|
+
req?.destroy();
|
|
818
|
+
try { fs.unlinkSync(partial); } catch (_) {}
|
|
819
|
+
throw e;
|
|
820
|
+
} finally { clearTimeout(timer); }
|
|
821
|
+
}
|
|
822
|
+
|
|
751
823
|
async registerCommands(commands) {
|
|
752
824
|
if (!this.botUserId) {
|
|
753
825
|
console.error("Kazee registerCommands: no botUserId yet, skipping");
|
package/channels/types.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* @property {string} [text] // command text or message body
|
|
21
21
|
* @property {string} [messageId] // outer-channel message id
|
|
22
22
|
* @property {string} [replyToId] // id of message being replied to
|
|
23
|
-
* @property {Object} [reply] // {text, document, photo} for reply context
|
|
23
|
+
* @property {Object} [reply] // {text, media?: MediaRef[], document, photo} for reply context
|
|
24
24
|
* @property {Array<MediaRef>} [media]
|
|
25
25
|
* @property {Object} [from] // {id, name, username}
|
|
26
26
|
* @property {Object} [action] // for type:"action" — {buttonId, payload, sourceMessageId}
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
* typing(channelId, state?): Promise<void> // state: "thinking" | "typing" (default). Non-socket channels ignore state.
|
|
59
59
|
* typingStop?(channelId): Promise<void> // optional — socket channels only
|
|
60
60
|
* downloadMedia(mediaRef): Promise<localPath|null>
|
|
61
|
+
* buildThreadContext?(envelope): Promise<{contextBlock: string, media?: MediaRef[]}|null>
|
|
61
62
|
* registerCommands(commands: CommandSpec[]): Promise<void>
|
|
62
63
|
*/
|
|
63
64
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Hydrate adapter-attested reply/history media only after the turn's gates.
|
|
2
|
+
// Never put remote URLs into the model prompt; keep source-message attribution.
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const MAX_CONTEXT_MEDIA = 6;
|
|
5
|
+
const MAX_CONTEXT_BYTES = 30 * 1024 * 1024;
|
|
6
|
+
const MAX_ITEM_BYTES = 10 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
async function hydrateContextMedia(adapter, refs = []) {
|
|
9
|
+
const attachments = [], imagePaths = [], lines = [], seen = new Set();
|
|
10
|
+
let remaining = MAX_CONTEXT_BYTES, attempted = 0, omitted = 0;
|
|
11
|
+
for (const media of refs) {
|
|
12
|
+
if (!media || !media.fileId) continue;
|
|
13
|
+
const key = media.contextKey || media.fileId;
|
|
14
|
+
if (seen.has(key)) continue;
|
|
15
|
+
seen.add(key);
|
|
16
|
+
if (attempted >= MAX_CONTEXT_MEDIA || remaining <= 0) { omitted++; continue; }
|
|
17
|
+
attempted++;
|
|
18
|
+
const label = [media.source || "Referenced attachment", media.messageId && `message ${media.messageId}`].filter(Boolean).join(" · ");
|
|
19
|
+
const maxBytes = Math.min(MAX_ITEM_BYTES, remaining);
|
|
20
|
+
try {
|
|
21
|
+
if (Number(media.size) > maxBytes) throw new Error("too large");
|
|
22
|
+
const localPath = await adapter.downloadMedia({ ...media, maxBytes, timeoutMs: 8000 });
|
|
23
|
+
if (!localPath) throw new Error("missing file");
|
|
24
|
+
// The adapter enforces the streamed size; stat prevents over-budget cache hits.
|
|
25
|
+
const size = require("fs").statSync(localPath).size;
|
|
26
|
+
if (!size || size > maxBytes) throw new Error("invalid size");
|
|
27
|
+
remaining -= size;
|
|
28
|
+
const isImage = media.type === "photo" || media.mimeType?.startsWith("image/");
|
|
29
|
+
attachments.push({ type: isImage ? "image" : "document", path: localPath,
|
|
30
|
+
fileName: media.fileName || path.basename(localPath), mimeType: media.mimeType || null,
|
|
31
|
+
sourceMessageId: media.messageId || null });
|
|
32
|
+
if (isImage) imagePaths.push(localPath);
|
|
33
|
+
lines.push(`${label}: ${isImage ? "image" : media.type || "file"} at ${localPath}`);
|
|
34
|
+
} catch (_) {
|
|
35
|
+
lines.push(`${label}: attachment unavailable (download failed or size limit); do not infer its contents.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (omitted) lines.push(`${omitted} further attachment(s) omitted by this turn's limit; request a specific message if needed.`);
|
|
39
|
+
return { attachments, imagePaths, contextBlock: lines.length
|
|
40
|
+
? `[Referenced media — inspect the attached files before answering]\n${lines.join("\n")}` : "" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { hydrateContextMedia, MAX_CONTEXT_MEDIA };
|
package/core/router.js
CHANGED
|
@@ -14,6 +14,7 @@ const { send, deleteMessage } = require("./io");
|
|
|
14
14
|
const { currentState, saveState, autoLinkOwnerChannel, getActiveProvider, getProjectKey } = require("./state");
|
|
15
15
|
const { admitRunContext, runAgent, startTypingHeartbeat } = require("./runner");
|
|
16
16
|
const { transcribeAudio, cloneVoiceFromClip } = require("./media");
|
|
17
|
+
const { hydrateContextMedia } = require("./context-media");
|
|
17
18
|
const { vault } = require("./vault-store");
|
|
18
19
|
const { isOnboarded, handleOnboarding } = require("./onboarding");
|
|
19
20
|
const {
|
|
@@ -258,7 +259,7 @@ async function handleVoice(envelope) {
|
|
|
258
259
|
state.lastInputWasVoice = true;
|
|
259
260
|
// Per-stage latency for the voice pipeline; the runner fills in the rest.
|
|
260
261
|
state.voiceLat = { startAt: tStart, downloadMs: tAfterDownload - tStart, sttMs: tAfterStt - tAfterDownload };
|
|
261
|
-
return
|
|
262
|
+
return runWithContext(transcript, runContext, envelope, {
|
|
262
263
|
runContext,
|
|
263
264
|
attachments: [{ type: "voice", path: oggPath, mimeType: media.mimeType || "audio/ogg", fileName: media.fileName || null }],
|
|
264
265
|
lastInputWasVoice: true,
|
|
@@ -291,7 +292,7 @@ async function handleAudio(envelope) {
|
|
|
291
292
|
if (!t) return send("Couldn't transcribe.");
|
|
292
293
|
await send(`Heard: "${t}"`, { replyTo: envelope.messageId });
|
|
293
294
|
if (!(await admitGuardedTurn(t, envelope, state))) return;
|
|
294
|
-
return
|
|
295
|
+
return runWithContext(t, runContext, envelope, {
|
|
295
296
|
runContext,
|
|
296
297
|
attachments: [{ type: "audio", path: p, mimeType: media.mimeType || null, fileName: media.fileName || null }],
|
|
297
298
|
});
|
|
@@ -340,10 +341,10 @@ async function handlePhoto(envelope) {
|
|
|
340
341
|
? `${envelope.caption} [${paths.length === 1 ? "photo" : `${paths.length} photos`} at: ${paths.join(", ")}]`
|
|
341
342
|
: `[sent ${paths.length === 1 ? "a photo" : `${paths.length} photos`} with no caption at: ${paths.join(", ")}]`;
|
|
342
343
|
if (!(await admitGuardedTurn(ask, envelope, state))) return;
|
|
343
|
-
|
|
344
|
+
let prompt = paths.length === 1
|
|
344
345
|
? `Image at ${paths[0]}\n\nView it, then: ${caption}`
|
|
345
346
|
: `Images:\n${paths.map((p, i) => `${i + 1}. ${p}`).join("\n")}\n\nView all ${paths.length}, then: ${caption}`;
|
|
346
|
-
return
|
|
347
|
+
return runWithContext(prompt, runContext, envelope, {
|
|
347
348
|
runContext,
|
|
348
349
|
imagePaths: paths,
|
|
349
350
|
attachments: paths.map((imagePath, index) => ({
|
|
@@ -357,6 +358,31 @@ async function handlePhoto(envelope) {
|
|
|
357
358
|
finally { stopPreTurnTyping(); }
|
|
358
359
|
}
|
|
359
360
|
|
|
361
|
+
async function runWithContext(prompt, runContext, envelope, options) {
|
|
362
|
+
const referenced = await referencedContext(envelope);
|
|
363
|
+
if (envelope.type !== "text" && envelope.reply?.text) {
|
|
364
|
+
prompt = `Quoted message:\n${envelope.reply.text.slice(0, 1000)}\n\n${prompt}`;
|
|
365
|
+
}
|
|
366
|
+
if (referenced.contextBlock) prompt = `${referenced.contextBlock}\n\n${prompt}`;
|
|
367
|
+
return runAgent(prompt, runContext.cwd, envelope.messageId, {
|
|
368
|
+
...options,
|
|
369
|
+
imagePaths: [...(options.imagePaths || []), ...referenced.imagePaths],
|
|
370
|
+
attachments: [...(options.attachments || []), ...referenced.attachments],
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function referencedContext(envelope) {
|
|
375
|
+
let threadCtx = null;
|
|
376
|
+
if (typeof envelope.adapter?.buildThreadContext === "function") {
|
|
377
|
+
try { threadCtx = await envelope.adapter.buildThreadContext(envelope); }
|
|
378
|
+
catch (e) { console.error("router: buildThreadContext:", e.message); }
|
|
379
|
+
}
|
|
380
|
+
const hydrated = await hydrateContextMedia(envelope.adapter, [
|
|
381
|
+
...(envelope.reply?.media || []), ...(threadCtx?.media || []),
|
|
382
|
+
]);
|
|
383
|
+
return { ...hydrated, contextBlock: [threadCtx?.contextBlock, hydrated.contextBlock].filter(Boolean).join("\n\n") };
|
|
384
|
+
}
|
|
385
|
+
|
|
360
386
|
async function handleDocument(envelope) {
|
|
361
387
|
if (isDuplicate(envelope)) return;
|
|
362
388
|
const state = currentState();
|
|
@@ -385,7 +411,7 @@ async function handleDocument(envelope) {
|
|
|
385
411
|
? `${caption} [file: ${fileName} at ${savePath}]`
|
|
386
412
|
: `[sent a file: ${fileName} at ${savePath} — no caption]`;
|
|
387
413
|
if (!(await admitGuardedTurn(ask, envelope, state))) return;
|
|
388
|
-
return
|
|
414
|
+
return runWithContext(prompt, runContext, envelope, {
|
|
389
415
|
runContext,
|
|
390
416
|
imagePaths: isImage ? [savePath] : [],
|
|
391
417
|
attachments: [{ type: isImage ? "image" : "document", path: savePath, mimeType: mime, fileName }],
|
|
@@ -699,18 +725,8 @@ async function handleText(envelope) {
|
|
|
699
725
|
// message. The adapter owns WHAT to fetch; the router owns WHEN and HOW to
|
|
700
726
|
// render it. Channels without the method (Telegram, Kazee DM, voice, web) are
|
|
701
727
|
// untouched. Best-effort: a failure never blocks the turn.
|
|
702
|
-
if (typeof envelope.adapter?.buildThreadContext === "function") {
|
|
703
|
-
try {
|
|
704
|
-
const threadCtx = await envelope.adapter.buildThreadContext(envelope);
|
|
705
|
-
if (threadCtx && threadCtx.contextBlock) {
|
|
706
|
-
prompt = `${threadCtx.contextBlock}\n\n${prompt}`;
|
|
707
|
-
}
|
|
708
|
-
} catch (e) {
|
|
709
|
-
console.error("router: buildThreadContext:", e.message);
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
728
|
|
|
713
|
-
return
|
|
729
|
+
return runWithContext(prompt, runContext, envelope, {
|
|
714
730
|
runContext,
|
|
715
731
|
imagePaths,
|
|
716
732
|
attachments,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.18",
|
|
4
4
|
"description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"test:kazee-call": "node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-call-speech.js && node test-kazee-call-session.js",
|
|
11
11
|
"setup": "node setup.js",
|
|
12
12
|
"start": "node bot.js",
|
|
13
|
-
"pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-kazee-typing-join.js && node test-kazee-inbound-dedup.js && node test-typing-phase-push.js && node test-socket-reconnect.js && node test-speech-loopback.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-call-speech.js && node test-kazee-call-session.js",
|
|
13
|
+
"pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-kazee-context-media.js && node test-kazee-typing-join.js && node test-kazee-inbound-dedup.js && node test-typing-phase-push.js && node test-socket-reconnect.js && node test-speech-loopback.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-call-speech.js && node test-kazee-call-session.js",
|
|
14
14
|
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && node test-codex-rollout.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-mandate-hygiene.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-cards.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-typing-heartbeat.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-union.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-subst-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-section-budgets.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-dedup-adjudicator.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-prospective.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-regressions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cost-report.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-mining.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-certification.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-apprenticeship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-attest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-verify-contact.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-loopback-owner-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-intro-adoption.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-grants.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-room-provider-settings.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-store-durability.js"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
@@ -127,6 +127,7 @@
|
|
|
127
127
|
"test-provider-coupling-audit.js",
|
|
128
128
|
"test-provider-parity-e2e.js",
|
|
129
129
|
"test-telegram-poll-recovery.js",
|
|
130
|
+
"test-kazee-context-media.js",
|
|
130
131
|
"test-fixtures/"
|
|
131
132
|
],
|
|
132
133
|
"keywords": [
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const assert = require("assert");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const http = require("http");
|
|
6
|
+
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "oc-context-media-"));
|
|
7
|
+
process.env.OPEN_CLAUDIA_CONFIG_DIR = testRoot;
|
|
8
|
+
process.env.WORKSPACE = path.join(testRoot, "workspace");
|
|
9
|
+
process.env.OPEN_CLAUDIA_TEST = "1";
|
|
10
|
+
const { ensureRuntimeDirectories, TEMP_DIR } = require("./core/config");
|
|
11
|
+
ensureRuntimeDirectories();
|
|
12
|
+
const { KazeeAdapter } = require("./channels/kazee/adapter");
|
|
13
|
+
const { hydrateContextMedia } = require("./core/context-media");
|
|
14
|
+
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=", "base64");
|
|
15
|
+
let requests = 0;
|
|
16
|
+
const server = http.createServer((req, res) => {
|
|
17
|
+
requests++;
|
|
18
|
+
if (req.url === "/failure") { res.writeHead(503); res.end("unavailable"); }
|
|
19
|
+
else if (req.url === "/large") { res.writeHead(200, { "content-length": 20 * 1024 * 1024 }); res.end(); }
|
|
20
|
+
else if (req.url === "/stream") { res.writeHead(200); res.end(Buffer.alloc(1000)); }
|
|
21
|
+
else if (req.url === "/slow") { /* bounded client timeout must terminate this socket */ }
|
|
22
|
+
else { res.writeHead(200, { "content-type": "image/png" }); res.end(png); }
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
(async () => {
|
|
26
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
27
|
+
const origin = `http://127.0.0.1:${server.address().port}`;
|
|
28
|
+
const adapter = new KazeeAdapter({ url: "https://chat.invalid", token: "dummy", botUserId: "bot" });
|
|
29
|
+
adapter._chatMeta = async () => ({ isGroupChat: true, assistantAction: "assistant_tagged_reply" });
|
|
30
|
+
const screenshot = { _id: "screen", chat: "group", type: "image", content: "Trip concern", createdAt: "2026-09-07T13:56:04Z",
|
|
31
|
+
sender: { _id: "samson", name: "Samson" }, medias: [{ _id: "photo", chat: "group", type: "image", url: `${origin}/image.png`, fileName: "image.png" }] };
|
|
32
|
+
const followup = { _id: "ask", chat: "group", type: "text", content: "@bot check the screenshot above", createdAt: "2026-09-07T14:59:02Z", sender: { _id: "owner" }, mentions: ["bot"] };
|
|
33
|
+
let emitted = [];
|
|
34
|
+
adapter.on("message", (e) => emitted.push(e));
|
|
35
|
+
await adapter._handleInboundMessage(screenshot);
|
|
36
|
+
assert.equal(emitted.length, 0, "unaddressed group image stays silent");
|
|
37
|
+
assert.equal(requests, 0, "silent messages trigger no download");
|
|
38
|
+
await adapter._handleInboundMessage(followup);
|
|
39
|
+
adapter._request = async (_, url) => {
|
|
40
|
+
if (url.includes("/around/")) return { data: { messages: [screenshot] } };
|
|
41
|
+
return { data: { messages: [screenshot, followup] } };
|
|
42
|
+
};
|
|
43
|
+
const context = await adapter.buildThreadContext(emitted[0]);
|
|
44
|
+
assert.equal(context.media.length, 1, "group catch-up retains image reference");
|
|
45
|
+
assert.equal(context.media[0].fileName, "image.png", "actual Kazee fileName preserved");
|
|
46
|
+
const hydrated = await hydrateContextMedia(adapter, context.media);
|
|
47
|
+
assert.equal(hydrated.imagePaths.length, 1, "image reaches provider attachment preparation");
|
|
48
|
+
assert(fs.readFileSync(hydrated.imagePaths[0]).equals(png), "downloaded image bytes preserved");
|
|
49
|
+
assert(hydrated.contextBlock.includes("Samson"));
|
|
50
|
+
assert(hydrated.contextBlock.includes("message screen"));
|
|
51
|
+
assert(!hydrated.contextBlock.includes(origin), "no remote URLs exposed to model");
|
|
52
|
+
const before = requests;
|
|
53
|
+
adapter._request = async (_, url) => ({ data: { messages: url.includes("since=") ? [followup] : [screenshot, followup] } });
|
|
54
|
+
const again = await adapter.buildThreadContext(emitted[0]);
|
|
55
|
+
assert.equal(again.media.length, 1, "follow-up recovers image older than text watermark");
|
|
56
|
+
const cached = await hydrateContextMedia(adapter, again.media);
|
|
57
|
+
assert.deepEqual(cached.imagePaths, hydrated.imagePaths);
|
|
58
|
+
assert.equal(requests, before, "verified cached file avoids repeat download");
|
|
59
|
+
|
|
60
|
+
await adapter._handleInboundMessage({ ...followup, _id: "quote", replyTo: screenshot });
|
|
61
|
+
assert.equal(emitted.at(-1).reply.media.length, 1, "populated quote keeps media");
|
|
62
|
+
adapter._request = async (_, url) => {
|
|
63
|
+
assert.equal(url, "/messages/group/bot/around/screen?before=1&after=1", "ID lookup uses supported room-scoped route");
|
|
64
|
+
return { data: { messages: [{ ...screenshot, _id: "neighbor" }, screenshot] } };
|
|
65
|
+
};
|
|
66
|
+
await adapter._handleInboundMessage({ ...followup, _id: "quote-id", replyTo: "screen" });
|
|
67
|
+
assert.equal(emitted.at(-1).reply.media.length, 1, "id-only quote resolves media");
|
|
68
|
+
const combined = await hydrateContextMedia(adapter, [...emitted.at(-1).reply.media, ...again.media]);
|
|
69
|
+
assert.equal(combined.attachments.length, 1, "quote and history deduplicate same image");
|
|
70
|
+
await adapter._handleInboundMessage({ ...followup, _id: "foreign-quote", replyTo: { ...screenshot, chat: "other-room" } });
|
|
71
|
+
assert.equal(emitted.at(-1).reply, undefined, "cross-room quote refused");
|
|
72
|
+
await adapter._handleInboundMessage({ ...followup, _id: "deleted-quote", replyTo: { ...screenshot, isDeleted: true } });
|
|
73
|
+
assert.equal(emitted.at(-1).reply, undefined, "deleted quote refused");
|
|
74
|
+
adapter._request = async () => ({ data: { messages: [
|
|
75
|
+
{ ...screenshot, _id: "other", chat: "other-room" },
|
|
76
|
+
{ ...screenshot, _id: "deleted", isDeleted: true },
|
|
77
|
+
{ ...screenshot, _id: "foreign-media", medias: [{ ...screenshot.medias[0], chat: "other-room" }] }, followup,
|
|
78
|
+
] } });
|
|
79
|
+
const denied = await adapter.buildThreadContext(emitted[0]);
|
|
80
|
+
assert.equal(denied?.media?.length || 0, 0, "history excludes deleted and foreign-room attachments");
|
|
81
|
+
|
|
82
|
+
const ref = context.media[0];
|
|
83
|
+
const limited = await hydrateContextMedia(adapter, Array.from({ length: 9 }, (_, i) => ({ ...ref, contextKey: `limit-${i}` })));
|
|
84
|
+
assert.equal(limited.attachments.length, 6);
|
|
85
|
+
assert(limited.contextBlock.includes("3 further attachment(s) omitted"));
|
|
86
|
+
const failed = await hydrateContextMedia(adapter, [{ ...ref, contextKey: "failed", fileId: `${origin}/failure` }]);
|
|
87
|
+
assert.equal(failed.attachments.length, 0);
|
|
88
|
+
assert(failed.contextBlock.includes("unavailable"));
|
|
89
|
+
for (const [route, maxBytes, timeoutMs] of [["large", 500, 8000], ["stream", 100, 8000], ["slow", 500, 30]]) {
|
|
90
|
+
await assert.rejects(adapter.downloadMedia({ ...ref, contextKey: route, fileId: `${origin}/${route}`, maxBytes, timeoutMs }));
|
|
91
|
+
}
|
|
92
|
+
assert(!fs.readdirSync(TEMP_DIR).some((f) => f.endsWith(".part")), "failed/oversize/timed-out downloads leave no partial files");
|
|
93
|
+
const safe = await adapter.downloadMedia({ ...ref, contextKey: "filename", fileName: "../../outside.png" });
|
|
94
|
+
assert.equal(path.dirname(safe), TEMP_DIR, "context filenames cannot escape media directory");
|
|
95
|
+
console.log("kazee-context-media OK: catch-up, follow-up, quotes, isolation, bytes, cache, caps and failures");
|
|
96
|
+
})().catch((e) => { console.error(e); process.exitCode = 1; }).finally(() => {
|
|
97
|
+
server.closeAllConnections(); server.close(); fs.rmSync(testRoot, { recursive: true, force: true });
|
|
98
|
+
});
|
package/test-provider-gateway.js
CHANGED
|
@@ -60,8 +60,9 @@ async function routerProbe() {
|
|
|
60
60
|
// be CONSULTED with the routed text + provider/project pinning, then let the
|
|
61
61
|
// turn through — the probe's subject is admission/dispatch mechanics.
|
|
62
62
|
const triaged = [];
|
|
63
|
+
let triageHeld = false;
|
|
63
64
|
installStub("./core/enforcer", {
|
|
64
|
-
guardInboundTriage: async (text, pinning) => { triaged.push({ text, pinning }); return { held:
|
|
65
|
+
guardInboundTriage: async (text, pinning) => { triaged.push({ text, pinning }); return { held: triageHeld, guarded: triageHeld }; },
|
|
65
66
|
});
|
|
66
67
|
installStub("./core/runner", {
|
|
67
68
|
admitRunContext(prompt, cwd, replyToMsgId, opts = {}) {
|
|
@@ -252,6 +253,29 @@ async function routerProbe() {
|
|
|
252
253
|
assert.strictEqual(lastTriage.pinning.projectDir, projectA, "triage pinning carries the project dir");
|
|
253
254
|
}
|
|
254
255
|
|
|
256
|
+
{
|
|
257
|
+
resetRouting();
|
|
258
|
+
const image = path.join(process.env.OPEN_CLAUDIA_CONFIG_DIR, "group-screen.png");
|
|
259
|
+
fs.writeFileSync(image, "image-fixture");
|
|
260
|
+
const ref = { type: "photo", fileId: "remote-image", contextKey: "room:original:image", messageId: "original", source: "Samson" };
|
|
261
|
+
let fetches = 0, downloads = 0;
|
|
262
|
+
const groupAdapter = adapter("kazee", async () => { downloads++; return image; });
|
|
263
|
+
groupAdapter.buildThreadContext = async () => { fetches++; return { contextBlock: "[Group context]", media: [ref] }; };
|
|
264
|
+
await router.onMessage(envelope("text", { text: "check the screenshot above", adapter: groupAdapter, surface: "group", reply: { text: "Original caption", media: [ref] } }));
|
|
265
|
+
const run = runs.at(-1);
|
|
266
|
+
assert.deepStrictEqual(run.opts.imagePaths, [image], "group and quoted media reach actual runner options once");
|
|
267
|
+
assert.strictEqual(run.opts.attachments[0].sourceMessageId, "original");
|
|
268
|
+
assert.match(run.prompt, /Samson/);
|
|
269
|
+
assert.match(run.prompt, /Original caption/);
|
|
270
|
+
assert.match(run.prompt, /group-screen.png/);
|
|
271
|
+
assert.strictEqual(downloads, 1);
|
|
272
|
+
const before = { fetches, downloads, runs: runs.length };
|
|
273
|
+
triageHeld = true;
|
|
274
|
+
await router.onMessage(envelope("text", { text: "ungranted ask", adapter: groupAdapter, surface: "group", reply: { media: [ref] } }));
|
|
275
|
+
triageHeld = false;
|
|
276
|
+
assert.deepStrictEqual({ fetches, downloads, runs: runs.length }, before, "held turns cannot fetch context or download attachments");
|
|
277
|
+
}
|
|
278
|
+
|
|
255
279
|
assert.ok(sends.length > 0, "media acknowledgement delivery remains active");
|
|
256
280
|
console.log("provider gateway router OK");
|
|
257
281
|
}
|
|
@@ -469,7 +469,8 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
469
469
|
// prepared 2026-09-06 for his release go. Rollout-based Codex context, per-turn ledger, sub-agent + quota accounting.
|
|
470
470
|
// 3.4.15: conversation flow and checked sentence speech; authorized calling rollout, 2026-09-05/06.
|
|
471
471
|
// 3.4.17: bind guard judgments and cached verdicts to runtime caller/room identity; approved calling rollout, 2026-09-07.
|
|
472
|
-
|
|
472
|
+
// 3.4.18: group-history/quoted attachments; owner approved implement and release, Sept7 18:32 EAT.
|
|
473
|
+
assert.strictEqual(pkg.version, "3.4.18", "release version must remain unchanged without explicit approval");
|
|
473
474
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
474
475
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
475
476
|
}
|