@inetafrica/open-claudia 3.4.17 → 3.4.19

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.4.19
4
+
5
+ - Offer `claude-opus-5-5` in the Claude model picker. Verified against the live API rather than assumed: on Claude Code 2.1.280 the API bills the request to `claude-opus-5-5` (1M context, 128k max output), while 2.1.258 refuses it with "does not support this model; version 2.1.280 or newer is required". An older CLI returns that API 400 instead of degrading, so a pod on a stale image errors on the model rather than falling back — the same constraint already recorded for `claude-fable-5-1` (>= 2.1.251).
6
+
7
+ ## 3.4.18
8
+
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+
3
13
  ## 3.4.17
4
14
 
5
15
  - 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 = 15;
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 rawMedia = (Array.isArray(msg.medias) && msg.medias.length)
260
- ? msg.medias
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 = rawMedia.map((a) => {
269
- let kind = a.type;
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
- const data = await this._request("GET", `/message/${encodeURIComponent(String(msg.replyTo))}`);
313
- m = data?.message || data;
293
+ m = await this._messageInChat(channelId, String(msg.replyTo));
314
294
  }
315
- if (m && (m._id || m.id)) {
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
- if (!msgs.length) return null;
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=${COLD_START_WINDOW}`;
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 };
@@ -6,11 +6,16 @@ const { createJsonlDecoder } = require("./events");
6
6
  const { createClaudeParserState, normalizeClaudeEvent } = require("./claude-events");
7
7
  const { createClaudeHookTransport } = require("./claude-hook");
8
8
 
9
- // claude-fable-5-1 requires Claude Code >= 2.1.251; older CLIs reject it with an
10
- // API 400 rather than falling back, so a pod on a stale image will error on it.
9
+ // New models are gated on the CLI build: claude-fable-5-1 requires Claude Code
10
+ // >= 2.1.251, claude-opus-5-5 requires >= 2.1.280. An older CLI rejects them with
11
+ // an API 400 rather than falling back, so a pod on a stale image will error on
12
+ // them. Verified 2026-09-23 on 2.1.280: the API reported claude-opus-5-5 as the
13
+ // serving model (canonicalModel claude-opus-5-5, 1M context), while 2.1.258
14
+ // returned "does not support this model; version 2.1.280 or newer is required".
11
15
  const CLAUDE_MODELS = Object.freeze([
12
16
  "claude-fable-5-1",
13
17
  "claude-fable-5",
18
+ "claude-opus-5-5",
14
19
  "claude-opus-5",
15
20
  "claude-opus-4-8",
16
21
  "claude-opus-4-7",
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 runAgent(transcript, runContext.cwd, envelope.messageId, {
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 runAgent(t, runContext.cwd, envelope.messageId, {
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
- const prompt = paths.length === 1
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 runAgent(prompt, runContext.cwd, envelope.messageId, {
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 runAgent(prompt, runContext.cwd, envelope.messageId, {
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 runAgent(prompt, runContext.cwd, envelope.messageId, {
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.17",
3
+ "version": "3.4.19",
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
+ });
@@ -46,6 +46,7 @@ assert.strictEqual(provider.isAvailable(), true);
46
46
  assert.strictEqual(provider.executable(), "/fixture/claude");
47
47
  assert.strictEqual(provider.defaultModel(), "claude-default-fixture");
48
48
  assert.ok(provider.modelChoices().includes("claude-fable-5-1"));
49
+ assert.ok(provider.modelChoices().includes("claude-opus-5-5"), "opus 5.5 is offered in the model picker");
49
50
  assert.ok(provider.modelChoices().includes("claude-fable-5"));
50
51
  assert.ok(provider.modelChoices().includes("claude-sonnet-4-6"));
51
52
  assert.deepStrictEqual(provider.compatibilityStatus(), {
@@ -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: false, guarded: false }; },
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,15 @@ 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
- assert.strictEqual(pkg.version, "3.4.17", "release version must remain unchanged without explicit approval");
472
+ // 3.4.18: group-history/quoted attachments; owner approved implement and release, Sept7 18:32 EAT.
473
+ // 3.4.19: offer claude-opus-5-5 in the Claude model picker. Sumeet asked
474
+ // 2026-09-23 ("check if opus 5.5 available on Claude code CLI and if yes please
475
+ // add it"), approved the CLI upgrade ("Yes upgrade the CLI please") and left the
476
+ // release shape to me ("Proceed as you see fit"). Shipped alone, ahead of the
477
+ // unreleased Kazee fail-closed room gate, so a runtime behaviour change and an
478
+ // additive picker entry can be rolled back independently. Local Claude Code went
479
+ // 2.1.258 -> 2.1.280 first; the model is refused outright below that.
480
+ assert.strictEqual(pkg.version, "3.4.19", "release version must remain unchanged without explicit approval");
473
481
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
474
482
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
475
483
  }