ticlawk 0.1.16-dev.6 → 0.1.16-dev.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticlawk",
3
- "version": "0.1.16-dev.6",
3
+ "version": "0.1.16-dev.8",
4
4
  "description": "Local connector that links agent harnesses (Claude Code, Codex, OpenClaw, opencode, Pi) to the Ticlawk mobile app.",
5
5
  "type": "module",
6
6
  "main": "ticlawk.mjs",
@@ -149,18 +149,6 @@ export async function updateChannel(id, updates) {
149
149
  return updateAgent(id, updates);
150
150
  }
151
151
 
152
- export async function postRuntimeResult(body) {
153
- // Activity-only: backend records the agent_event for UI trajectory
154
- // display but does NOT project it into a chat message. The agent CLI's
155
- // `ticlawk message send` is the only path that produces chat rows.
156
- const result = await apiFetch('/api/runtime-results', {
157
- method: 'POST',
158
- body: JSON.stringify(body),
159
- timeout: 30000,
160
- });
161
- return result;
162
- }
163
-
164
152
  // ── Deliveries (replaces legacy message_jobs poll/ack) ──
165
153
 
166
154
  export async function claimPendingDeliveries(hostId, limit = 5, excludedAgentIds = []) {
@@ -199,6 +187,7 @@ export async function sendAgentMessage({
199
187
  replyToMessageId,
200
188
  runtimeHostId,
201
189
  visibility,
190
+ mediaAssetIds,
202
191
  }) {
203
192
  const { data } = await apiFetch('/api/agent/messages/send', {
204
193
  method: 'POST',
@@ -210,6 +199,7 @@ export async function sendAgentMessage({
210
199
  reply_to_message_id: replyToMessageId ?? null,
211
200
  runtime_host_id: runtimeHostId ?? null,
212
201
  visibility: visibility || null,
202
+ media_asset_ids: Array.isArray(mediaAssetIds) && mediaAssetIds.length > 0 ? mediaAssetIds : undefined,
213
203
  }),
214
204
  });
215
205
  return data || null;
@@ -436,6 +426,20 @@ export async function uploadAgentAttachment({
436
426
  });
437
427
  }
438
428
 
429
+ export async function uploadAgentAvatar({
430
+ actingAgentId, filename, contentType, dataBase64,
431
+ }) {
432
+ return apiFetch('/api/agent/profile/avatar', {
433
+ method: 'POST',
434
+ body: JSON.stringify({
435
+ acting_as_agent_id: actingAgentId,
436
+ filename,
437
+ content_type: contentType,
438
+ data_base64: dataBase64,
439
+ }),
440
+ });
441
+ }
442
+
439
443
  export async function viewAgentAttachment({ actingAgentId, assetId }) {
440
444
  const params = new URLSearchParams();
441
445
  params.set('acting_as_agent_id', actingAgentId);
@@ -480,22 +484,6 @@ export async function removeAgentGroupMember({
480
484
  );
481
485
  }
482
486
 
483
- // ── Upload ──
484
-
485
- export async function uploadAsset(fileName, fileData, contentType, kind = 'chat_media') {
486
- const formData = new FormData();
487
- formData.append('file', new Blob([fileData], { type: contentType }), fileName);
488
- formData.append('kind', kind);
489
- if (contentType) formData.append('content_type', contentType);
490
-
491
- const { data } = await apiFetch('/api/assets/upload', {
492
- method: 'POST',
493
- body: formData,
494
- timeout: 30000,
495
- });
496
- return data;
497
- }
498
-
499
487
  // ── Channel event pipe ──
500
488
 
501
489
  export async function postEvent({ agent, agent_id, runtime_host_id, session_id, cwd, runtime_version, event, required = false }) {
@@ -10,7 +10,6 @@ import { isTerminalRuntimeFailure } from '../../core/runtime-support.mjs';
10
10
  import { clearUpdateRequiredState, readUpdateState, setUpdateRequiredState } from '../../core/update-state.mjs';
11
11
  import { isManagedInstall, startDetachedSelfUpdate } from '../../core/update.mjs';
12
12
  import * as api from './api.mjs';
13
- import { processAndSaveResult } from './cards.mjs';
14
13
  import { persistApiCredential } from './credentials.mjs';
15
14
  import { TiclawkWakeClient } from './wake-client.mjs';
16
15
 
@@ -205,10 +204,6 @@ export function normalizeInboundMessage(msg) {
205
204
  };
206
205
  }
207
206
 
208
- function getBindingSessionId(binding) {
209
- return binding?.runtimeMeta?.sessionId || binding?.runtimeMeta?.sessionKey || binding?.runtimeMeta?.agentId || binding?.id || null;
210
- }
211
-
212
207
  function getRuntimeVersion(bindingOrMeta) {
213
208
  const value = bindingOrMeta?.runtimeMeta?.runtimeVersion ?? bindingOrMeta?.runtimeVersion ?? bindingOrMeta?.agent_runtime_version ?? null;
214
209
  return Number.isInteger(value) ? Number(value) : null;
@@ -1341,25 +1336,6 @@ export function createTiclawkAdapter(ctx) {
1341
1336
  }
1342
1337
  },
1343
1338
 
1344
- async send(binding, outbound) {
1345
- const localMediaPaths = (outbound.media || [])
1346
- .filter((item) => item.kind === 'local_path')
1347
- .map((item) => item.value);
1348
- await processAndSaveResult({
1349
- text: outbound.text || '',
1350
- mediaUrls: localMediaPaths,
1351
- }, {
1352
- agentId: binding.id,
1353
- hostId,
1354
- agent: binding.runtime,
1355
- sessionId: getBindingSessionId(binding),
1356
- runtimeVersion: getRuntimeVersion(binding),
1357
- turnId: outbound.turnId || outbound.replyToMessageId || null,
1358
- type: outbound.type || 'agent_message',
1359
- replyToMessageId: outbound.replyToMessageId || null,
1360
- });
1361
- },
1362
-
1363
1339
  // Daemon-driven impersonated reply: the runtime never reached
1364
1340
  // `ticlawk message send` (subprocess died, timeout, etc.), so the
1365
1341
  // daemon speaks for the agent. Used by reportSubprocessFailure
@@ -136,12 +136,37 @@ export async function runMessageSendCommand(args) {
136
136
  console.error(' EOF');
137
137
  return 2;
138
138
  }
139
+
140
+ // --attach <path> may be passed multiple times. Each file is uploaded
141
+ // through the same daemon endpoint as `ticlawk attachment upload`, then
142
+ // its asset_id is linked to the message via media_asset_ids.
143
+ const attachArg = args.attach;
144
+ const attachPaths = Array.isArray(attachArg)
145
+ ? attachArg
146
+ : attachArg
147
+ ? [attachArg]
148
+ : [];
149
+ if (attachPaths.length > 10) {
150
+ console.error('at most 10 --attach files per message');
151
+ return 2;
152
+ }
153
+ const mediaAssetIds = [];
154
+ for (const filePath of attachPaths) {
155
+ const upload = await uploadFileViaDaemon(env, String(filePath));
156
+ if (!upload.ok) {
157
+ console.error(`attachment upload failed for ${filePath}: ${upload.error}`);
158
+ return 1;
159
+ }
160
+ mediaAssetIds.push(upload.assetId);
161
+ }
162
+
139
163
  const body = {
140
164
  target,
141
165
  conversation_id: conversationId,
142
166
  text: text.replace(/\n+$/, ''),
143
167
  seen_up_to_seq: getNumberArg(args, 'seen-up-to-seq'),
144
168
  reply_to_message_id: getArg(args, 'reply-to'),
169
+ media_asset_ids: mediaAssetIds.length > 0 ? mediaAssetIds : undefined,
145
170
  };
146
171
  const res = await daemonRequest({
147
172
  method: 'POST',
@@ -545,13 +570,12 @@ export async function runProfileUpdateCommand(args) {
545
570
  }
546
571
  let avatarUrl = null;
547
572
  if (avatarFile) {
548
- // Re-use the attachment upload path then set the public_url as the avatar.
549
- const upload = await uploadFileViaDaemon(env, avatarFile);
573
+ const upload = await uploadAvatarViaDaemon(env, avatarFile);
550
574
  if (!upload.ok) {
551
575
  console.error(`avatar upload failed: ${upload.error}`);
552
576
  return 1;
553
577
  }
554
- avatarUrl = upload.publicUrl;
578
+ avatarUrl = upload.url;
555
579
  }
556
580
  const res = await daemonRequest({
557
581
  method: 'POST',
@@ -567,6 +591,34 @@ export async function runProfileUpdateCommand(args) {
567
591
  return exitFromStatus(res.statusCode);
568
592
  }
569
593
 
594
+ async function uploadAvatarViaDaemon(env, filePath) {
595
+ let stat;
596
+ try { stat = statSync(filePath); } catch (err) {
597
+ return { ok: false, error: `cannot stat ${filePath}: ${err.message}` };
598
+ }
599
+ if (!stat.isFile()) return { ok: false, error: `${filePath} is not a regular file` };
600
+ const contentType = inferContentType(filePath);
601
+ if (!contentType.startsWith('image/')) {
602
+ return { ok: false, error: `avatar must be an image (got content_type ${contentType})` };
603
+ }
604
+ const data = readFileSync(filePath);
605
+ const res = await daemonRequest({
606
+ method: 'POST',
607
+ path: '/agent/profile/avatar',
608
+ headers: commonHeaders(env),
609
+ body: {
610
+ filename: basename(filePath),
611
+ content_type: contentType,
612
+ data_base64: data.toString('base64'),
613
+ },
614
+ });
615
+ if (res.statusCode < 200 || res.statusCode >= 300) {
616
+ return { ok: false, error: res.body?.error || `HTTP ${res.statusCode}` };
617
+ }
618
+ if (!res.body?.url) return { ok: false, error: 'avatar upload returned no url' };
619
+ return { ok: true, url: res.body.url };
620
+ }
621
+
570
622
  async function uploadFileViaDaemon(env, filePath) {
571
623
  let stat;
572
624
  try { stat = statSync(filePath); } catch (err) {
@@ -588,7 +640,11 @@ async function uploadFileViaDaemon(env, filePath) {
588
640
  if (res.statusCode < 200 || res.statusCode >= 300) {
589
641
  return { ok: false, error: res.body?.error || `HTTP ${res.statusCode}` };
590
642
  }
591
- return { ok: true, assetId: res.body?.asset?.asset_id, publicUrl: res.body?.public_url };
643
+ const asset = res.body?.data;
644
+ if (!asset?.asset_id || !asset?.url) {
645
+ return { ok: false, error: 'attachment upload returned no asset' };
646
+ }
647
+ return { ok: true, assetId: asset.asset_id, url: asset.url, expiresAt: asset.expires_at };
592
648
  }
593
649
 
594
650
  function inferContentType(filePath) {
@@ -771,12 +827,14 @@ export async function runServerInfoCommand(args) {
771
827
 
772
828
  export const AGENT_COMMAND_HELP = {
773
829
  message: `ticlawk message <send|read|check|search|react>
774
- ticlawk message send --target "<target>" [--seen-up-to-seq N] [--reply-to <msg-id>]
830
+ ticlawk message send --target "<target>" [--seen-up-to-seq N] [--reply-to <msg-id>] [--attach <file> ...]
775
831
  Body is read from stdin (use <<'EOF' ... EOF for multiline).
776
832
  Targets:
777
833
  dm:@<user> private message
778
834
  #<group> group conversation
779
835
  #<group>:<msgid> thread under a top-level message in that group
836
+ --attach <file> uploads a local file and attaches it to the message
837
+ (repeatable; max 10 attachments per message).
780
838
  ticlawk message read --target "<target>" [--around <msg-id>] [--before-seq N] [--limit N]
781
839
  ticlawk message check [--target "<target>"]
782
840
  Non-blocking poll for new/unprocessed messages.
@@ -792,6 +850,9 @@ export const AGENT_COMMAND_HELP = {
792
850
  `,
793
851
  attachment: `ticlawk attachment <upload|view>
794
852
  ticlawk attachment upload <file>
853
+ Upload-only — the user is NOT notified. Almost always you want
854
+ \`ticlawk message send --attach <file>\` instead, which uploads AND
855
+ surfaces the file to the user inside a chat message.
795
856
  ticlawk attachment view <asset-id> [--out <path>]
796
857
  `,
797
858
  reminder: `ticlawk reminder <schedule|list|snooze|update|cancel|log>
@@ -145,6 +145,10 @@ export async function handleMessageSend(req, body, ctx) {
145
145
  return { status: 400, body: { error: 'target or conversation_id is required' } };
146
146
  }
147
147
 
148
+ const mediaAssetIds = Array.isArray(body?.media_asset_ids)
149
+ ? body.media_asset_ids.map((v) => String(v).trim()).filter(Boolean)
150
+ : [];
151
+
148
152
  try {
149
153
  const data = await api.sendAgentMessage({
150
154
  actingAgentId,
@@ -153,6 +157,7 @@ export async function handleMessageSend(req, body, ctx) {
153
157
  seenUpToSeq: body?.seen_up_to_seq,
154
158
  replyToMessageId: body?.reply_to_message_id || threadRootMsgId || null,
155
159
  runtimeHostId: getRuntimeHostId(req, body),
160
+ mediaAssetIds,
156
161
  });
157
162
  debugLog('agent-cli', 'send.ok', {
158
163
  actingAgentId,
@@ -587,8 +592,8 @@ export async function handleAttachmentUpload(req, body, ctx) {
587
592
  });
588
593
  debugLog('agent-cli', 'attachment.upload', {
589
594
  actingAgentId,
590
- asset_id: data?.asset?.asset_id,
591
- bytes: data?.asset?.size_bytes,
595
+ asset_id: data?.data?.asset_id,
596
+ bytes: data?.data?.size_bytes,
592
597
  });
593
598
  return { status: 200, body: data };
594
599
  } catch (err) {
@@ -596,6 +601,28 @@ export async function handleAttachmentUpload(req, body, ctx) {
596
601
  }
597
602
  }
598
603
 
604
+ export async function handleProfileAvatarUpload(req, body, ctx) {
605
+ const actingAgentId = getActingAgentId(req, body);
606
+ const v = validateActingAgent(actingAgentId, ctx);
607
+ if (!v.ok) return { status: v.status, body: { error: v.error } };
608
+ if (!body?.data_base64) return { status: 400, body: { error: 'data_base64 is required' } };
609
+ if (!body?.content_type || !String(body.content_type).startsWith('image/')) {
610
+ return { status: 400, body: { error: 'content_type must be image/*' } };
611
+ }
612
+ try {
613
+ const data = await api.uploadAgentAvatar({
614
+ actingAgentId,
615
+ filename: body?.filename || 'avatar.bin',
616
+ contentType: body.content_type,
617
+ dataBase64: body.data_base64,
618
+ });
619
+ debugLog('agent-cli', 'avatar.upload', { actingAgentId, url: data?.url });
620
+ return { status: 200, body: data };
621
+ } catch (err) {
622
+ return { status: err?.status || 500, body: { error: err?.message || 'avatar upload failed' } };
623
+ }
624
+ }
625
+
599
626
  export async function handleAttachmentView(req, query, ctx) {
600
627
  const actingAgentId = getActingAgentId(req, query);
601
628
  const v = validateActingAgent(actingAgentId, ctx);
package/src/core/http.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  handleMessageRead,
12
12
  handleMessageSearch,
13
13
  handleMessageSend,
14
+ handleProfileAvatarUpload,
14
15
  handleProfileShow,
15
16
  handleProfileUpdate,
16
17
  handleReminderCancel,
@@ -148,6 +149,12 @@ export function startLocalHttpServer({ port, adapter, ctx }) {
148
149
  const r = await handleProfileUpdate(req, body, cliCtx);
149
150
  return writeJson(res, r.status, r.body);
150
151
  }
152
+ if (urlNoQuery === '/agent/profile/avatar' && method === 'POST') {
153
+ const body = await readJsonBody(req);
154
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
155
+ const r = await handleProfileAvatarUpload(req, body, cliCtx);
156
+ return writeJson(res, r.status, r.body);
157
+ }
151
158
  if (urlNoQuery === '/agent/attachment/upload' && method === 'POST') {
152
159
  const body = await readJsonBody(req);
153
160
  if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
@@ -42,36 +42,6 @@ export function shouldStreamRuntime(runtimeName, runtime) {
42
42
  return Boolean(runtime?.runTurnStream) && getStreamingMode(runtimeName);
43
43
  }
44
44
 
45
- export async function sendAdapterMessage(adapter, binding, payload) {
46
- await adapter.send(binding, payload);
47
- }
48
-
49
- /**
50
- * Record the runtime's final turn output as activity (NOT chat).
51
- *
52
- * Previously called `sendResult` and treated as "the chat reply path".
53
- * After the group-chat upgrade, chat is produced exclusively by the
54
- * agent invoking `ticlawk message send` via the CLI. The runtime's
55
- * raw final output is still surfaced for trajectory/debug UI, but it
56
- * no longer materializes as a `messages` row — the trigger
57
- * `project_agent_event` was updated in PR-2b to drop the chat
58
- * projection.
59
- *
60
- * Renamed so the call sites read self-evidently: "record activity"
61
- * never reads as "send a chat message".
62
- */
63
- export async function recordActivity(adapter, binding, inbound, result) {
64
- if (!result?.text && (!result?.mediaUrls || result.mediaUrls.length === 0)) return;
65
- await sendAdapterMessage(adapter, binding, {
66
- type: 'assistant',
67
- text: result.text || '',
68
- media: result.media || [],
69
- turnId: inbound.messageId || result?.turnId || null,
70
- replyToMessageId: inbound.messageId || null,
71
- });
72
- }
73
-
74
-
75
45
  export async function reportSubprocessFailure({ adapter, binding, inbound, runtimeName, info }) {
76
46
  if (!info || info.ok) return;
77
47
  const seconds = ((info.durationMs || 0) / 1000).toFixed(1);
@@ -63,6 +63,27 @@ EOF
63
63
 
64
64
  **IMPORTANT**: To reply to any message, always reuse the exact \`target\` from the received message. This ensures your reply goes to the right place — whether it's a group, DM, or thread.
65
65
 
66
+ ### Sending files
67
+
68
+ When you produce a file the user should see (a generated report, a
69
+ screenshot, a downloaded asset, a code patch as a file), attach it
70
+ inline with the chat message:
71
+
72
+ \`\`\`bash
73
+ ticlawk message send --target "#group" --attach /tmp/report.pdf --attach /tmp/chart.png <<'EOF'
74
+ Here's the analysis you asked for — full report attached, plus the
75
+ key chart pulled out for quick reference.
76
+ EOF
77
+ \`\`\`
78
+
79
+ - \`--attach <path>\` may be repeated; up to 10 files per message.
80
+ - Each file uploads to the secure file layer and surfaces to the user
81
+ inside the chat UI alongside your text.
82
+ - \`ticlawk attachment upload <file>\` exists as a separate command but
83
+ **only uploads** — the user is not notified and cannot see the file.
84
+ Almost never the right command. Only use it if you have a specific
85
+ reason to stash a file for later use without telling the user.
86
+
66
87
  ### Threads
67
88
 
68
89
  Threads are sub-conversations attached to a specific message. They let you discuss a topic without cluttering the main group.
@@ -23,11 +23,9 @@ import {
23
23
  } from './session.mjs';
24
24
  import { discoverSessions } from './transcripts.mjs';
25
25
  import { buildImageMessageFromInbound } from '../../core/media/inbound.mjs';
26
- import { normalizeOutboundMedia } from '../../core/media/outbound.mjs';
27
26
  import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
28
27
  import {
29
28
  shouldStreamRuntime,
30
- recordActivity,
31
29
  reportSubprocessFailure,
32
30
  terminalRuntimeFailure,
33
31
  updateBindingRuntimeMeta,
@@ -196,10 +194,6 @@ export const claudeCodeRuntime = {
196
194
  rotatePending: false,
197
195
  lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
198
196
  }, { status: 'connected' });
199
- await recordActivity(adapter, nextBinding, inbound, {
200
- ...result,
201
- media: normalizeOutboundMedia(result),
202
- });
203
197
  await emitWorkerEvent({
204
198
  adapter,
205
199
  binding: nextBinding,
@@ -18,13 +18,10 @@ import {
18
18
  requireCodexPath,
19
19
  } from './session.mjs';
20
20
  import { buildCodexInputFromInbound } from '../../core/media/inbound.mjs';
21
- import { normalizeOutboundMedia } from '../../core/media/outbound.mjs';
22
21
  import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
23
22
  import {
24
23
  shouldStreamRuntime,
25
24
  createDeltaAggregator,
26
- sendAdapterMessage,
27
- recordActivity,
28
25
  reportSubprocessFailure,
29
26
  terminalRuntimeFailure,
30
27
  updateBindingRuntimeMeta,
@@ -215,10 +212,6 @@ export const codexRuntime = {
215
212
  rotatePending: false,
216
213
  lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
217
214
  }, { status: 'connected' });
218
- await recordActivity(adapter, nextBinding, inbound, {
219
- ...result,
220
- media: normalizeOutboundMedia(result),
221
- });
222
215
  await emitWorkerEvent({
223
216
  adapter,
224
217
  binding: nextBinding,
@@ -1,5 +1,4 @@
1
- import { normalizeOutboundMedia } from '../../core/media/outbound.mjs';
2
- import { reportSubprocessFailure, sendAdapterMessage, recordActivity, terminalRuntimeFailure } from '../../core/runtime-support.mjs';
1
+ import { reportSubprocessFailure, terminalRuntimeFailure } from '../../core/runtime-support.mjs';
3
2
  import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
4
3
  import { GATEWAY_HOST, GATEWAY_PORT } from './identity.mjs';
5
4
 
@@ -192,10 +191,6 @@ export const openClawRuntime = {
192
191
  });
193
192
  },
194
193
  });
195
- await recordActivity(adapter, binding, inbound, {
196
- ...result,
197
- media: normalizeOutboundMedia(result),
198
- });
199
194
  return true;
200
195
  } catch (err) {
201
196
  if (typeof adapter.emitEvent === 'function') {
@@ -233,18 +228,11 @@ export const openClawRuntime = {
233
228
  }
234
229
  },
235
230
 
236
- async recoverInFlight(ctx) {
231
+ async recoverInFlight() {
232
+ // Just drain the persisted in-flight set — the user-facing notice
233
+ // that used to surface here went through the dead chat-projection
234
+ // path. OpenClaw is non-primary; this no-ops cleanly for now.
237
235
  const entries = recoverInFlightEntries();
238
- for (const entry of entries) {
239
- const binding = ctx.getBinding(entry.bindingId);
240
- if (!binding) continue;
241
- await sendAdapterMessage(ctx.adapter, binding, {
242
- type: 'assistant',
243
- text: '⚠️ Lost while ticlawk restarted.\n\nThis OpenClaw message was in flight when ticlawk restarted. Please retry your message.',
244
- media: [],
245
- replyToMessageId: entry.messageId || null,
246
- }).catch(() => {});
247
- }
248
236
  return entries.length;
249
237
  },
250
238
 
@@ -24,13 +24,10 @@ import {
24
24
  requireOpenCodePath,
25
25
  } from './session.mjs';
26
26
  import { buildOpenCodeInputFromInbound } from '../../core/media/inbound.mjs';
27
- import { normalizeOutboundMedia } from '../../core/media/outbound.mjs';
28
27
  import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
29
28
  import {
30
29
  shouldStreamRuntime,
31
30
  createDeltaAggregator,
32
- sendAdapterMessage,
33
- recordActivity,
34
31
  reportSubprocessFailure,
35
32
  terminalRuntimeFailure,
36
33
  updateBindingRuntimeMeta,
@@ -146,24 +143,14 @@ export const openCodeRuntime = {
146
143
  const captionText = (inbound.text || '').trim();
147
144
 
148
145
  if (files.length === 0 && !captionText) {
149
- await sendAdapterMessage(adapter, binding, {
150
- type: 'assistant',
151
- text: '⚠️ Image attached, but I could not access the image data and there is no caption to fall back to. Try sending the image again, or include a text caption.',
152
- media: [],
153
- replyToMessageId: inbound.messageId || null,
154
- });
146
+ // Image decode failed and no caption to fall back on — we have
147
+ // nothing meaningful to feed the model. Bail without a user
148
+ // notice; this runtime is non-primary and the dead chat-projection
149
+ // path that used to surface such notices is gone.
155
150
  return true;
156
151
  }
157
-
158
- if (files.length === 0 && captionText) {
159
- // Downloads all failed; tell the user we're proceeding with the caption alone.
160
- await sendAdapterMessage(adapter, binding, {
161
- type: 'assistant',
162
- text: '⚠️ Could not access the attached image data; acting on the caption text only.',
163
- media: [],
164
- replyToMessageId: inbound.messageId || null,
165
- });
166
- }
152
+ // If files.length === 0 && captionText, fall through with the
153
+ // caption-only message below no inline user notice.
167
154
 
168
155
  // If user sent images with no caption, give the model a minimal
169
156
  // instruction so it has something to anchor on.
@@ -238,10 +225,6 @@ export const openCodeRuntime = {
238
225
  rotatePending: false,
239
226
  lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
240
227
  }, { status: 'connected' });
241
- await recordActivity(adapter, nextBinding, inbound, {
242
- ...result,
243
- media: normalizeOutboundMedia(result),
244
- });
245
228
  await emitWorkerEvent({
246
229
  adapter,
247
230
  binding: nextBinding,
@@ -22,8 +22,6 @@ import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
22
22
  import {
23
23
  shouldStreamRuntime,
24
24
  createDeltaAggregator,
25
- sendAdapterMessage,
26
- recordActivity,
27
25
  reportSubprocessFailure,
28
26
  terminalRuntimeFailure,
29
27
  updateBindingRuntimeMeta,
@@ -122,22 +120,11 @@ export const piRuntime = {
122
120
  images = await buildPiImagesFromInbound(inbound);
123
121
  const captionText = (inbound.text || '').trim();
124
122
  if (images.length === 0 && !captionText) {
125
- await sendAdapterMessage(adapter, binding, {
126
- type: 'assistant',
127
- text: '⚠️ Image attached, but I could not access the image data and there is no caption to fall back on. Try sending the image again, or include a text caption.',
128
- media: [],
129
- replyToMessageId: inbound.messageId || null,
130
- });
123
+ // Image decode failed and no caption to fall back on. Bail
124
+ // without a user notice; the dead chat-projection path that
125
+ // used to surface such notices is gone.
131
126
  return true;
132
127
  }
133
- if (images.length === 0 && captionText) {
134
- await sendAdapterMessage(adapter, binding, {
135
- type: 'assistant',
136
- text: '⚠️ Could not access the attached image data; acting on the caption text only.',
137
- media: [],
138
- replyToMessageId: inbound.messageId || null,
139
- });
140
- }
141
128
  message = captionText || 'Please analyze the attached image(s).';
142
129
  }
143
130
 
@@ -209,7 +196,6 @@ export const piRuntime = {
209
196
  rotatePending: false,
210
197
  lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
211
198
  }, { status: 'connected' });
212
- await recordActivity(adapter, nextBinding, inbound, result);
213
199
  await emitWorkerEvent({
214
200
  adapter,
215
201
  binding: nextBinding,
@@ -1,149 +0,0 @@
1
- /**
2
- * ticlawk adapter — final agent message / media write path.
3
- *
4
- * Everything here turns an agent result (text + optional media local paths)
5
- * into a terminal runtime result written back to ticlawk.
6
- *
7
- * This module imports from `./api.mjs` (HTTP client) and from
8
- * `../../core/logger.mjs` (structured logging). No runtime dependencies.
9
- */
10
-
11
- import { existsSync, readFileSync } from 'node:fs';
12
- import { extname } from 'node:path';
13
- import { randomUUID } from 'node:crypto';
14
- import * as api from './api.mjs';
15
- import { extractMediaPaths } from '../../core/media/outbound.mjs';
16
- import { debugLog, debugError } from '../../core/logger.mjs';
17
- import { TICLAWK_CONNECTOR_API_KEY } from '../../core/config.mjs';
18
-
19
- const MIME_MAP = {
20
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
21
- '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
22
- '.mp4': 'video/mp4', '.webm': 'video/webm',
23
- '.opus': 'audio/opus', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
24
- '.pdf': 'application/pdf',
25
- };
26
-
27
- async function uploadLocalAsset(localPath) {
28
- if (!process.env[TICLAWK_CONNECTOR_API_KEY]) {
29
- debugError('relay', 'upload.skipped', {
30
- localPath,
31
- reason: 'missing connector api key',
32
- });
33
- return null;
34
- }
35
- if (!existsSync(localPath)) {
36
- debugError('relay', 'upload.skipped', {
37
- localPath,
38
- reason: 'file not found',
39
- });
40
- return null;
41
- }
42
-
43
- const ext = extname(localPath).toLowerCase();
44
- const contentType = MIME_MAP[ext] || 'application/octet-stream';
45
- const fileName = `${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
46
- const fileData = readFileSync(localPath);
47
-
48
- try {
49
- const asset = await api.uploadAsset(fileName, fileData, contentType);
50
- if (!asset?.asset_id) {
51
- debugError('relay', 'upload.failed', {
52
- localPath,
53
- fileName,
54
- error: 'missing asset_id in upload response',
55
- });
56
- return null;
57
- }
58
- debugLog('relay', 'upload.ok', {
59
- localPath,
60
- fileName,
61
- assetId: asset.asset_id,
62
- contentType: asset.content_type || contentType,
63
- sizeBytes: asset.size_bytes ?? null,
64
- });
65
- return asset;
66
- } catch (err) {
67
- debugError('relay', 'upload.failed', {
68
- localPath,
69
- fileName,
70
- error: err.message,
71
- });
72
- return null;
73
- }
74
- }
75
-
76
- export async function uploadMediaAssets(localPaths) {
77
- const assets = [];
78
- for (const p of localPaths) {
79
- const asset = await uploadLocalAsset(p);
80
- if (asset) assets.push(asset);
81
- }
82
- return assets;
83
- }
84
-
85
- export async function processAndSaveResult(result, opts) {
86
- const { agentId: explicitAgentId, sessionKey, hostId, type, replyToMessageId, agent, sessionId, turnId, runtimeVersion } = opts;
87
- const agentId = explicitAgentId || sessionKey;
88
- const startedAt = Date.now();
89
-
90
- // Collect media: from agent mediaUrls + parsed from text
91
- const allLocalPaths = [...new Set([
92
- ...(result.mediaUrls || []),
93
- ...extractMediaPaths(result.text || ''),
94
- ])];
95
-
96
- debugLog('relay', 'process-result.begin', {
97
- agentId,
98
- type,
99
- parentMessageId: replyToMessageId || null,
100
- textLength: result.text?.length || 0,
101
- localMediaCount: allLocalPaths.length,
102
- });
103
-
104
- // Upload local media to Ticlawk private chat assets.
105
- const uploadedAssets = await uploadMediaAssets(allLocalPaths);
106
- const uploadedAssetIds = uploadedAssets
107
- .map((asset) => asset?.asset_id)
108
- .filter(Boolean);
109
-
110
- const updateId = randomUUID();
111
- debugLog('relay', 'post-final.begin', {
112
- agentId,
113
- updateId,
114
- durationMs: Date.now() - startedAt,
115
- uploadedMediaCount: uploadedAssetIds.length,
116
- });
117
- try {
118
- await api.postRuntimeResult({
119
- agent,
120
- agent_id: agentId,
121
- runtime_host_id: hostId,
122
- session_id: sessionId || null,
123
- cwd: '',
124
- runtime_version: runtimeVersion ?? null,
125
- result_id: updateId,
126
- turn_id: turnId || replyToMessageId || null,
127
- reply_to_message_id: replyToMessageId || null,
128
- origin_ts: new Date().toISOString(),
129
- text: result.text || '',
130
- media_asset_ids: uploadedAssetIds,
131
- output_type: type || 'agent_message',
132
- });
133
- } catch (err) {
134
- debugError('relay', 'post-final.failed', {
135
- agentId,
136
- updateId,
137
- durationMs: Date.now() - startedAt,
138
- error: err.message,
139
- });
140
- throw err;
141
- }
142
- debugLog('relay', 'process-result.ok', {
143
- agentId,
144
- updateId,
145
- durationMs: Date.now() - startedAt,
146
- uploadedMediaCount: uploadedAssetIds.length,
147
- });
148
- return { id: updateId, agentId };
149
- }
@@ -1,163 +0,0 @@
1
- import { extname } from 'node:path';
2
- import { homedir } from 'node:os';
3
- import { fileURLToPath, pathToFileURL } from 'node:url';
4
-
5
- export const MIME_MAP = {
6
- '.png': 'image/png',
7
- '.jpg': 'image/jpeg',
8
- '.jpeg': 'image/jpeg',
9
- '.gif': 'image/gif',
10
- '.webp': 'image/webp',
11
- '.svg': 'image/svg+xml',
12
- '.mp4': 'video/mp4',
13
- '.webm': 'video/webm',
14
- '.pdf': 'application/pdf',
15
- };
16
-
17
- function mediaExtensionPattern() {
18
- return Object.keys(MIME_MAP).map((entry) => entry.replace('.', '\\.')).join('|');
19
- }
20
-
21
- function stripUrlSuffix(value) {
22
- return String(value || '').replace(/[?#].*$/, '');
23
- }
24
-
25
- function trimCandidate(value) {
26
- return String(value || '')
27
- .trim()
28
- .replace(/^<|>$/g, '')
29
- .replace(/^["']|["']$/g, '')
30
- .replace(/\\\//g, '/');
31
- }
32
-
33
- function hasKnownMediaExtension(value) {
34
- return Boolean(MIME_MAP[extname(stripUrlSuffix(value)).toLowerCase()]);
35
- }
36
-
37
- function normalizeMediaPath(value) {
38
- let candidate = trimCandidate(value);
39
- if (!candidate) return null;
40
-
41
- candidate = candidate.replace(/^about:/i, '');
42
-
43
- if (/^file:/i.test(candidate)) {
44
- try {
45
- candidate = fileURLToPath(candidate);
46
- } catch {
47
- candidate = candidate.replace(/^file:(?:\/\/)?/i, '');
48
- }
49
- }
50
-
51
- candidate = stripUrlSuffix(candidate);
52
-
53
- if (candidate.startsWith('~')) {
54
- candidate = candidate.replace(/^~/, homedir());
55
- }
56
-
57
- if (candidate.startsWith('/')) {
58
- candidate = candidate.replace(/^\/{2,}/, '/');
59
- }
60
-
61
- if (!candidate.startsWith('/')) return null;
62
- if (!hasKnownMediaExtension(candidate)) return null;
63
- return candidate;
64
- }
65
-
66
- function addMediaCandidate(paths, value) {
67
- const normalized = normalizeMediaPath(value);
68
- if (normalized) paths.add(normalized);
69
- }
70
-
71
- export function extractMediaPaths(text) {
72
- const extPattern = mediaExtensionPattern();
73
- const paths = new Set();
74
- const candidates = [];
75
-
76
- const body = String(text || '');
77
- for (const match of body.matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) {
78
- candidates.push({ index: match.index || 0, value: match[1] });
79
- }
80
- for (const match of body.matchAll(/!?\[[^\]]*\]\(\s*<([^>\n]+)>/gi)) {
81
- candidates.push({ index: match.index || 0, value: match[1] });
82
- }
83
- for (const match of body.matchAll(/!?\[[^\]]*\]\(\s*([^\s)>]+)>?/gi)) {
84
- candidates.push({ index: match.index || 0, value: match[1] });
85
- }
86
-
87
- const re = new RegExp(`((?:file:(?://)?|about:(?://)?)?(?:~|/{1,3})[^\\s"'<>)]*?(?:${extPattern})(?:[?#][^\\s"'<>)]*)?)`, 'gi');
88
- for (const match of body.matchAll(re)) {
89
- candidates.push({ index: match.index || 0, value: match[1] });
90
- }
91
-
92
- candidates
93
- .sort((a, b) => a.index - b.index)
94
- .forEach((candidate) => addMediaCandidate(paths, candidate.value));
95
-
96
- return [...paths];
97
- }
98
-
99
- function escapeRegExp(value) {
100
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
101
- }
102
-
103
- function referencesPath(value, normalizedPaths) {
104
- const normalized = normalizeMediaPath(value);
105
- return Boolean(normalized && normalizedPaths.has(normalized));
106
- }
107
-
108
- function pathReferenceVariants(path) {
109
- const normalized = normalizeMediaPath(path) || path;
110
- const variants = new Set([normalized]);
111
- if (normalized.startsWith('/')) {
112
- variants.add(pathToFileURL(normalized).href);
113
- variants.add(`about:${normalized}`);
114
- variants.add(`about://${normalized}`);
115
- }
116
- return [...variants].sort((a, b) => b.length - a.length);
117
- }
118
-
119
- export function stripMediaPathReferences(text, explicitPaths = []) {
120
- let next = String(text || '');
121
- const paths = [...new Set([
122
- ...explicitPaths,
123
- ...extractMediaPaths(next),
124
- ].map((path) => normalizeMediaPath(path)).filter(Boolean))];
125
- const normalizedPaths = new Set(paths);
126
-
127
- next = next
128
- .replace(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi, (match, src) => (
129
- referencesPath(src, normalizedPaths) ? '' : match
130
- ))
131
- .replace(/!\[[^\]]*\]\(\s*<([^>\n]+)>(?:\s+["'][^"']*["'])?\s*\)/gi, (match, target) => (
132
- referencesPath(target, normalizedPaths) ? '' : match
133
- ))
134
- .replace(/!\[[^\]]*\]\(\s*([^\s)>]+)>?(?:\s+["'][^"']*["'])?\s*\)/gi, (match, target) => (
135
- referencesPath(target, normalizedPaths) ? '' : match
136
- ));
137
-
138
- for (const path of paths) {
139
- for (const variant of pathReferenceVariants(path)) {
140
- const escapedPath = escapeRegExp(variant);
141
- next = next
142
- .replace(new RegExp(`\\[media attached[^\\]]*${escapedPath}[^\\]]*\\]\\s*`, 'gi'), '')
143
- .replace(new RegExp(`^\\s*${escapedPath}\\s*$`, 'gim'), '')
144
- .replace(new RegExp(escapedPath, 'g'), '');
145
- }
146
- }
147
-
148
- return next
149
- .replace(/<img[^>]+src=["'](?:file:|about:)(?:\/){0,3}(?:tmp|private|var|Users|home)[^"']*["'][^>]*>/gi, '')
150
- .replace(/!\[[^\]]*\]\(\s*<?(?:file:|about:)(?:\/){0,3}(?:tmp|private|var|Users|home)[^)]*\)/gi, '')
151
- .replace(/\n{3,}/g, '\n\n')
152
- .trim();
153
- }
154
-
155
- export function normalizeOutboundMedia(result) {
156
- const explicit = Array.isArray(result?.mediaUrls) ? result.mediaUrls : [];
157
- const inferred = extractMediaPaths(result?.text || '');
158
- return [...new Set([...explicit, ...inferred])].map((path) => ({
159
- kind: 'local_path',
160
- value: path,
161
- mime: MIME_MAP[extname(path).toLowerCase()] || 'application/octet-stream',
162
- }));
163
- }