ticlawk 0.1.16-dev.7 → 0.1.16-dev.9

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/bin/ticlawk.mjs CHANGED
@@ -30,7 +30,6 @@ import { getInstallDaemonHelp, runInstallDaemon } from '../src/core/daemon-insta
30
30
  import { runSetupReadiness } from '../src/core/setup-readiness.mjs';
31
31
  import {
32
32
  AGENT_COMMAND_HELP,
33
- runAttachmentUploadCommand,
34
33
  runAttachmentViewCommand,
35
34
  runGroupCreateCommand,
36
35
  runGroupMembersAddCommand,
@@ -416,10 +415,6 @@ async function main() {
416
415
  console.log(AGENT_COMMAND_HELP.attachment);
417
416
  return;
418
417
  }
419
- if (sub === 'upload') {
420
- process.exitCode = await runAttachmentUploadCommand(args);
421
- return;
422
- }
423
418
  if (sub === 'view') {
424
419
  process.exitCode = await runAttachmentViewCommand(args);
425
420
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticlawk",
3
- "version": "0.1.16-dev.7",
3
+ "version": "0.1.16-dev.9",
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;
@@ -494,22 +484,6 @@ export async function removeAgentGroupMember({
494
484
  );
495
485
  }
496
486
 
497
- // ── Upload ──
498
-
499
- export async function uploadAsset(fileName, fileData, contentType, kind = 'chat_media') {
500
- const formData = new FormData();
501
- formData.append('file', new Blob([fileData], { type: contentType }), fileName);
502
- formData.append('kind', kind);
503
- if (contentType) formData.append('content_type', contentType);
504
-
505
- const { data } = await apiFetch('/api/assets/upload', {
506
- method: 'POST',
507
- body: formData,
508
- timeout: 30000,
509
- });
510
- return data;
511
- }
512
-
513
487
  // ── Channel event pipe ──
514
488
 
515
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',
@@ -637,18 +662,6 @@ function inferContentType(filePath) {
637
662
  }
638
663
  }
639
664
 
640
- export async function runAttachmentUploadCommand(args) {
641
- const env = requireAgentEnv();
642
- const file = args._?.[2];
643
- if (!file) {
644
- console.error('usage: ticlawk attachment upload <file>');
645
- return 2;
646
- }
647
- const upload = await uploadFileViaDaemon(env, file);
648
- printJson(upload);
649
- return upload.ok ? 0 : 1;
650
- }
651
-
652
665
  export async function runAttachmentViewCommand(args) {
653
666
  const env = requireAgentEnv();
654
667
  const assetId = args._?.[2];
@@ -802,12 +815,14 @@ export async function runServerInfoCommand(args) {
802
815
 
803
816
  export const AGENT_COMMAND_HELP = {
804
817
  message: `ticlawk message <send|read|check|search|react>
805
- ticlawk message send --target "<target>" [--seen-up-to-seq N] [--reply-to <msg-id>]
818
+ ticlawk message send --target "<target>" [--seen-up-to-seq N] [--reply-to <msg-id>] [--attach <file> ...]
806
819
  Body is read from stdin (use <<'EOF' ... EOF for multiline).
807
820
  Targets:
808
821
  dm:@<user> private message
809
822
  #<group> group conversation
810
823
  #<group>:<msgid> thread under a top-level message in that group
824
+ --attach <file> uploads a local file and attaches it to the message
825
+ (repeatable; max 10 attachments per message).
811
826
  ticlawk message read --target "<target>" [--around <msg-id>] [--before-seq N] [--limit N]
812
827
  ticlawk message check [--target "<target>"]
813
828
  Non-blocking poll for new/unprocessed messages.
@@ -821,9 +836,9 @@ export const AGENT_COMMAND_HELP = {
821
836
  ticlawk profile show [@handle | --id <agent-id>]
822
837
  ticlawk profile update [--display-name X] [--description Y] [--avatar-file path]
823
838
  `,
824
- attachment: `ticlawk attachment <upload|view>
825
- ticlawk attachment upload <file>
826
- ticlawk attachment view <asset-id> [--out <path>]
839
+ attachment: `ticlawk attachment view <asset-id> [--out <path>]
840
+ Fetch metadata + signed URL for an existing asset. To send a file
841
+ to a user, use \`ticlawk message send --attach <file>\` instead.
827
842
  `,
828
843
  reminder: `ticlawk reminder <schedule|list|snooze|update|cancel|log>
829
844
  ticlawk reminder schedule --title <t> (--fire-at <iso> | --in-seconds N | --in-minutes N) (--target "<target>" | --anchor-conversation-id <id>) [--anchor-message-id <id>]
@@ -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,
@@ -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,30 @@ 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
+
83
+ **The number of attachments must match what your text says.** If your
84
+ message body says "two charts attached", the command must contain
85
+ exactly two \`--attach\` flags. If your files are not ready yet (still
86
+ downloading, still generating), **wait** before sending; do not send a
87
+ "here it is" message claiming files you have not actually attached.
88
+ The user only sees what you attached, not what you promised.
89
+
66
90
  ### Threads
67
91
 
68
92
  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
- }