ticlawk 0.1.16-dev.1 → 0.1.16-dev.11

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.
@@ -11,6 +11,7 @@ import { existsSync } from 'node:fs';
11
11
  import { basename } from 'node:path';
12
12
  import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
13
13
  import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
14
+ import { ensureAgentHome } from '../../core/agent-home.mjs';
14
15
  import {
15
16
  createOpenCodeSession,
16
17
  getOpenCodeRuntimeHealth,
@@ -23,13 +24,10 @@ import {
23
24
  requireOpenCodePath,
24
25
  } from './session.mjs';
25
26
  import { buildOpenCodeInputFromInbound } from '../../core/media/inbound.mjs';
26
- import { normalizeOutboundMedia } from '../../core/media/outbound.mjs';
27
27
  import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
28
28
  import {
29
29
  shouldStreamRuntime,
30
30
  createDeltaAggregator,
31
- sendAdapterMessage,
32
- recordActivity,
33
31
  reportSubprocessFailure,
34
32
  terminalRuntimeFailure,
35
33
  updateBindingRuntimeMeta,
@@ -97,8 +95,6 @@ export const openCodeRuntime = {
97
95
  displayName: payload.name || session.title || basename(requestedCwd) || 'opencode',
98
96
  runtimeMeta: {
99
97
  sessionId: session.sessionId,
100
- workdir: requestedCwd,
101
- cwd: requestedCwd,
102
98
  runtimePath: opencodePath,
103
99
  opencodePath,
104
100
  opencodeVersion,
@@ -107,20 +103,11 @@ export const openCodeRuntime = {
107
103
  };
108
104
  }
109
105
 
110
- if (!requestedCwd) {
111
- throw new Error('cwd or sessionId is required for opencode binding');
112
- }
113
- if (!existsSync(requestedCwd)) {
114
- throw new Error(`opencode cwd not found locally: ${requestedCwd}`);
115
- }
116
-
117
106
  return {
118
107
  runtime: this.name,
119
- displayName: payload.name || basename(requestedCwd) || 'opencode',
108
+ displayName: payload.name || (requestedCwd ? basename(requestedCwd) : 'opencode'),
120
109
  runtimeMeta: {
121
110
  sessionId: null,
122
- workdir: requestedCwd,
123
- cwd: requestedCwd,
124
111
  runtimePath: opencodePath,
125
112
  opencodePath,
126
113
  opencodeVersion,
@@ -139,16 +126,9 @@ export const openCodeRuntime = {
139
126
  const adapter = ctx.adapter;
140
127
  const meta = binding.runtimeMeta || {};
141
128
  const runtimeOpenCodePath = meta.opencodePath || meta.runtimePath || null;
142
-
143
- if (!meta.cwd || !existsSync(meta.cwd)) {
144
- await sendAdapterMessage(adapter, binding, {
145
- type: 'assistant',
146
- text: `⚠️ opencode cwd not found: ${meta.cwd || '(missing)'}`,
147
- media: [],
148
- replyToMessageId: inbound.messageId || null,
149
- });
150
- return true;
151
- }
129
+ const agentHome = ensureAgentHome(binding.id, {
130
+ displayName: binding.display_name || binding.name || null,
131
+ });
152
132
 
153
133
  // For image inbound, resolve the attached media to local file paths
154
134
  // and forward them to opencode via `--file` (mirrors how Codex uses
@@ -163,24 +143,14 @@ export const openCodeRuntime = {
163
143
  const captionText = (inbound.text || '').trim();
164
144
 
165
145
  if (files.length === 0 && !captionText) {
166
- await sendAdapterMessage(adapter, binding, {
167
- type: 'assistant',
168
- 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.',
169
- media: [],
170
- replyToMessageId: inbound.messageId || null,
171
- });
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.
172
150
  return true;
173
151
  }
174
-
175
- if (files.length === 0 && captionText) {
176
- // Downloads all failed; tell the user we're proceeding with the caption alone.
177
- await sendAdapterMessage(adapter, binding, {
178
- type: 'assistant',
179
- text: '⚠️ Could not access the attached image data; acting on the caption text only.',
180
- media: [],
181
- replyToMessageId: inbound.messageId || null,
182
- });
183
- }
152
+ // If files.length === 0 && captionText, fall through with the
153
+ // caption-only message below no inline user notice.
184
154
 
185
155
  // If user sent images with no caption, give the model a minimal
186
156
  // instruction so it has something to anchor on.
@@ -195,7 +165,7 @@ export const openCodeRuntime = {
195
165
  binding,
196
166
  agent: this.name,
197
167
  sessionId: sessionId || meta.sessionId || binding.id,
198
- cwd: cwd || meta.cwd,
168
+ cwd: cwd || agentHome,
199
169
  replyToMessageId: inbound.messageId || null,
200
170
  event: {
201
171
  hook_event_name: 'worker.message.delta',
@@ -217,7 +187,7 @@ export const openCodeRuntime = {
217
187
  });
218
188
  const standingPrompt = buildStandingPrompt({ agentId: binding.id });
219
189
  const result = shouldStreamRuntime(this.name, this)
220
- ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, opencodePath, agentEnv }, message, {
190
+ ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, opencodePath, agentEnv }, message, {
221
191
  standingPrompt,
222
192
  files,
223
193
  onEvent: async (event) => {
@@ -227,7 +197,7 @@ export const openCodeRuntime = {
227
197
  binding,
228
198
  agent: this.name,
229
199
  sessionId: event.sessionId || meta.sessionId || binding.id,
230
- cwd: meta.cwd,
200
+ cwd: agentHome,
231
201
  replyToMessageId: inbound.messageId || null,
232
202
  event: {
233
203
  hook_event_name: 'worker.turn.start',
@@ -238,34 +208,29 @@ export const openCodeRuntime = {
238
208
  } else if (event?.type === 'message.delta' && event.text) {
239
209
  deltaAggregator.push(event.text, {
240
210
  sessionId: event.sessionId || meta.sessionId || binding.id,
241
- cwd: meta.cwd,
211
+ cwd: agentHome,
242
212
  });
243
213
  }
244
214
  },
245
215
  })
246
- : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, opencodePath, agentEnv }, message, { files, standingPrompt });
216
+ : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, opencodePath, agentEnv }, message, { files, standingPrompt });
247
217
 
248
218
  await deltaAggregator.flush();
249
219
 
250
220
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
251
221
  sessionId: result?.sessionId || meta.sessionId,
252
- cwd: result?.cwd || meta.cwd,
253
222
  runtimePath: opencodePath,
254
223
  opencodePath,
255
224
  opencodeVersion,
256
225
  rotatePending: false,
257
226
  lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
258
227
  }, { status: 'connected' });
259
- await recordActivity(adapter, nextBinding, inbound, {
260
- ...result,
261
- media: normalizeOutboundMedia(result),
262
- });
263
228
  await emitWorkerEvent({
264
229
  adapter,
265
230
  binding: nextBinding,
266
231
  agent: this.name,
267
232
  sessionId: result?.sessionId || meta.sessionId || binding.id,
268
- cwd: result?.cwd || meta.cwd,
233
+ cwd: result?.cwd || agentHome,
269
234
  replyToMessageId: inbound.messageId || null,
270
235
  event: {
271
236
  hook_event_name: 'Stop',
@@ -281,7 +246,7 @@ export const openCodeRuntime = {
281
246
  binding,
282
247
  agent: this.name,
283
248
  sessionId: meta.sessionId || binding.id,
284
- cwd: meta.cwd,
249
+ cwd: agentHome,
285
250
  replyToMessageId: inbound.messageId || null,
286
251
  event: {
287
252
  hook_event_name: 'worker.turn.error',
@@ -313,7 +278,7 @@ export const openCodeRuntime = {
313
278
  binding,
314
279
  agent: this.name,
315
280
  sessionId: meta.sessionId || binding.id,
316
- cwd: meta.cwd || '',
281
+ cwd: ensureAgentHome(binding.id) || '',
317
282
  event: {
318
283
  hook_event_name: 'Stop',
319
284
  worker_event_name: 'worker.turn.complete',
@@ -4,10 +4,10 @@
4
4
  * Wraps the pi CLI RPC mode and exposes the ticlawk runtime contract.
5
5
  */
6
6
 
7
- import { existsSync } from 'node:fs';
8
7
  import { basename } from 'node:path';
9
8
  import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
10
9
  import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
10
+ import { ensureAgentHome } from '../../core/agent-home.mjs';
11
11
  import {
12
12
  buildPiImagesFromInbound,
13
13
  discoverPiSessions,
@@ -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,
@@ -80,8 +78,6 @@ export const piRuntime = {
80
78
  displayName: payload.name || basename(session.cwd || requestedCwd) || 'pi',
81
79
  runtimeMeta: {
82
80
  sessionId: session.sessionId,
83
- workdir: session.cwd || requestedCwd,
84
- cwd: session.cwd || requestedCwd,
85
81
  path: session.path || null,
86
82
  runtimePath: piPath,
87
83
  piPath,
@@ -91,20 +87,11 @@ export const piRuntime = {
91
87
  };
92
88
  }
93
89
 
94
- if (!requestedCwd) {
95
- throw new Error('cwd or sessionId is required for pi binding');
96
- }
97
- if (!existsSync(requestedCwd)) {
98
- throw new Error(`pi cwd not found locally: ${requestedCwd}`);
99
- }
100
-
101
90
  return {
102
91
  runtime: this.name,
103
- displayName: payload.name || basename(requestedCwd) || 'pi',
92
+ displayName: payload.name || (requestedCwd ? basename(requestedCwd) : 'pi'),
104
93
  runtimeMeta: {
105
94
  sessionId: null,
106
- workdir: requestedCwd,
107
- cwd: requestedCwd,
108
95
  path: null,
109
96
  runtimePath: piPath,
110
97
  piPath,
@@ -123,16 +110,9 @@ export const piRuntime = {
123
110
  if (!binding) return false;
124
111
  const adapter = ctx.adapter;
125
112
  const meta = binding.runtimeMeta || {};
126
-
127
- if (!meta.cwd || !existsSync(meta.cwd)) {
128
- await sendAdapterMessage(adapter, binding, {
129
- type: 'assistant',
130
- text: `⚠️ pi cwd not found: ${meta.cwd || '(missing)'}`,
131
- media: [],
132
- replyToMessageId: inbound.messageId || null,
133
- });
134
- return true;
135
- }
113
+ const agentHome = ensureAgentHome(binding.id, {
114
+ displayName: binding.display_name || binding.name || null,
115
+ });
136
116
 
137
117
  let images = [];
138
118
  let message = inbound.text || '';
@@ -140,22 +120,11 @@ export const piRuntime = {
140
120
  images = await buildPiImagesFromInbound(inbound);
141
121
  const captionText = (inbound.text || '').trim();
142
122
  if (images.length === 0 && !captionText) {
143
- await sendAdapterMessage(adapter, binding, {
144
- type: 'assistant',
145
- 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.',
146
- media: [],
147
- replyToMessageId: inbound.messageId || null,
148
- });
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.
149
126
  return true;
150
127
  }
151
- if (images.length === 0 && captionText) {
152
- await sendAdapterMessage(adapter, binding, {
153
- type: 'assistant',
154
- text: '⚠️ Could not access the attached image data; acting on the caption text only.',
155
- media: [],
156
- replyToMessageId: inbound.messageId || null,
157
- });
158
- }
159
128
  message = captionText || 'Please analyze the attached image(s).';
160
129
  }
161
130
 
@@ -167,7 +136,7 @@ export const piRuntime = {
167
136
  binding,
168
137
  agent: this.name,
169
138
  sessionId: sessionId || meta.sessionId || binding.id,
170
- cwd: cwd || meta.cwd,
139
+ cwd: cwd || agentHome,
171
140
  replyToMessageId: inbound.messageId || null,
172
141
  event: {
173
142
  hook_event_name: 'worker.message.delta',
@@ -189,7 +158,7 @@ export const piRuntime = {
189
158
  });
190
159
  const standingPrompt = buildStandingPrompt({ agentId: binding.id });
191
160
  const result = shouldStreamRuntime(this.name, this)
192
- ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, piPath: runtimePiPath, agentEnv }, message, {
161
+ ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, piPath: runtimePiPath, agentEnv }, message, {
193
162
  standingPrompt,
194
163
  images,
195
164
  onEvent: async (event) => {
@@ -199,7 +168,7 @@ export const piRuntime = {
199
168
  binding,
200
169
  agent: this.name,
201
170
  sessionId: event.sessionId || meta.sessionId || binding.id,
202
- cwd: meta.cwd,
171
+ cwd: agentHome,
203
172
  replyToMessageId: inbound.messageId || null,
204
173
  event: {
205
174
  hook_event_name: 'worker.turn.start',
@@ -210,17 +179,16 @@ export const piRuntime = {
210
179
  } else if (event?.type === 'message.delta' && event.text) {
211
180
  deltaAggregator.push(event.text, {
212
181
  sessionId: event.sessionId || meta.sessionId || binding.id,
213
- cwd: meta.cwd,
182
+ cwd: agentHome,
214
183
  });
215
184
  }
216
185
  },
217
186
  })
218
- : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, piPath: runtimePiPath, agentEnv }, message, { images, standingPrompt });
187
+ : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, piPath: runtimePiPath, agentEnv }, message, { images, standingPrompt });
219
188
 
220
189
  await deltaAggregator.flush();
221
190
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
222
191
  sessionId: result?.sessionId || meta.sessionId,
223
- cwd: result?.cwd || meta.cwd,
224
192
  path: result?.path || meta.path || null,
225
193
  runtimePath: runtimePiPath,
226
194
  piPath: runtimePiPath,
@@ -228,13 +196,12 @@ export const piRuntime = {
228
196
  rotatePending: false,
229
197
  lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
230
198
  }, { status: 'connected' });
231
- await recordActivity(adapter, nextBinding, inbound, result);
232
199
  await emitWorkerEvent({
233
200
  adapter,
234
201
  binding: nextBinding,
235
202
  agent: this.name,
236
203
  sessionId: result?.sessionId || meta.sessionId || binding.id,
237
- cwd: result?.cwd || meta.cwd,
204
+ cwd: result?.cwd || agentHome,
238
205
  replyToMessageId: inbound.messageId || null,
239
206
  event: {
240
207
  hook_event_name: 'Stop',
@@ -250,7 +217,7 @@ export const piRuntime = {
250
217
  binding,
251
218
  agent: this.name,
252
219
  sessionId: meta.sessionId || binding.id,
253
- cwd: meta.cwd,
220
+ cwd: agentHome,
254
221
  replyToMessageId: inbound.messageId || null,
255
222
  event: {
256
223
  hook_event_name: 'worker.turn.error',
@@ -282,7 +249,7 @@ export const piRuntime = {
282
249
  binding,
283
250
  agent: this.name,
284
251
  sessionId: meta.sessionId || binding.id,
285
- cwd: meta.cwd || '',
252
+ cwd: ensureAgentHome(binding.id) || '',
286
253
  event: {
287
254
  hook_event_name: 'Stop',
288
255
  worker_event_name: 'worker.turn.complete',
package/ticlawk.mjs CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  persistConfig,
22
22
  } from './src/core/config.mjs';
23
23
  import { startLocalHttpServer } from './src/core/http.mjs';
24
+ import { startReminderTicker } from './src/core/reminder-ticker.mjs';
24
25
  import { installProcessDiagnostics } from './src/core/diagnostics.mjs';
25
26
  import * as logger from './src/core/logger.mjs';
26
27
  import { Bus } from './src/core/bus.mjs';
@@ -30,6 +31,28 @@ import { buildRuntimeContext, normalizeServiceType } from './src/core/runtime-re
30
31
  import { belongsToRuntimeHost, getBindingRuntimeHostId, getHostId } from './src/core/host-id.mjs';
31
32
  import { readPkgVersion } from './src/core/update.mjs';
32
33
  import { buildImageMessageFromInbound } from './src/core/media/inbound.mjs';
34
+ import { existsSync as _fsExists, unlinkSync as _fsUnlink } from 'node:fs';
35
+ import { join as _pathJoin } from 'node:path';
36
+ import { getActiveProfile } from './src/core/profiles.mjs';
37
+
38
+ // Pre-profile builds wrote bindings to ~/.ticlawk/bindings.json. The
39
+ // profile flow now owns binding persistence under
40
+ // ~/.ticlawk/profiles/<adapter>/<userId>/bindings.json. The root file
41
+ // hasn't had a writer for a while but lingers as stale state. Prune
42
+ // once per daemon start so it doesn't confuse `jq` audits later.
43
+ function pruneLegacyRootBindings() {
44
+ if (!getActiveProfile()) return;
45
+ const rootPath = _pathJoin(AF_HOME, 'bindings.json');
46
+ if (!_fsExists(rootPath)) return;
47
+ try {
48
+ _fsUnlink(rootPath);
49
+ logger.debugLog?.('startup', 'legacy-root-bindings.pruned', { path: rootPath });
50
+ } catch (err) {
51
+ logger.debugError?.('startup', 'legacy-root-bindings.prune-failed', {
52
+ path: rootPath, error: err?.message || String(err),
53
+ });
54
+ }
55
+ }
33
56
 
34
57
  // Re-export the config-owned paths so local tooling can inspect the
35
58
  // ticlawk home/config/log locations without reaching into src/.
@@ -80,7 +103,7 @@ function createUpsertBindingWithSync(runtimes, adapter) {
80
103
  };
81
104
  }
82
105
 
83
- function createCacheBinding(runtimes, getAdapter) {
106
+ function createPersistBinding(runtimes, getAdapter) {
84
107
  return async (binding) => {
85
108
  const nextBinding = await upsertBinding(binding);
86
109
  if (!belongsToRuntimeHost(nextBinding)) {
@@ -101,13 +124,13 @@ function createCacheBinding(runtimes, getAdapter) {
101
124
  };
102
125
  }
103
126
 
104
- function createBaseRuntimeCtx(runtimes, cacheBinding, upsertBindingWithSync) {
127
+ function createBaseRuntimeCtx(runtimes, persistBinding, upsertBindingWithSync) {
105
128
  return {
106
129
  runtimes,
107
130
  getBinding,
108
131
  listBindings,
109
132
  deleteBinding,
110
- cacheBinding,
133
+ persistBinding,
111
134
  upsertBinding: upsertBindingWithSync,
112
135
  buildImageMessageFromInbound,
113
136
  logger,
@@ -221,11 +244,12 @@ export async function startTiclawk() {
221
244
  if (started) return;
222
245
  started = true;
223
246
  installProcessDiagnostics();
247
+ pruneLegacyRootBindings();
224
248
 
225
249
  const { runtimeList, runtimes } = await buildRuntimeContext();
226
250
  const resolveRuntimeBinding = createResolveRuntimeBinding(runtimes);
227
251
  let adapter;
228
- const cacheBinding = createCacheBinding(runtimes, () => adapter);
252
+ const persistBinding = createPersistBinding(runtimes, () => adapter);
229
253
  let baseRuntimeCtx;
230
254
  let syncBinding = async (binding) => {
231
255
  if (!adapter) {
@@ -233,13 +257,13 @@ export async function startTiclawk() {
233
257
  }
234
258
  return upsertBinding(binding);
235
259
  };
236
- baseRuntimeCtx = createBaseRuntimeCtx(runtimes, cacheBinding, (binding) => syncBinding(binding));
260
+ baseRuntimeCtx = createBaseRuntimeCtx(runtimes, persistBinding, (binding) => syncBinding(binding));
237
261
  adapter = createAdapter(
238
262
  'ticlawk',
239
263
  createAdapterContext(baseRuntimeCtx, resolveRuntimeBinding)
240
264
  );
241
265
  syncBinding = createUpsertBindingWithSync(runtimes, adapter);
242
- baseRuntimeCtx = createBaseRuntimeCtx(runtimes, cacheBinding, (binding) => syncBinding(binding));
266
+ baseRuntimeCtx = createBaseRuntimeCtx(runtimes, persistBinding, (binding) => syncBinding(binding));
243
267
 
244
268
  printBanner(adapter);
245
269
  if (typeof adapter.refreshBindings === 'function') {
@@ -252,6 +276,7 @@ export async function startTiclawk() {
252
276
  adapter,
253
277
  ctx: { listBindings, getBinding },
254
278
  });
279
+ startReminderTicker();
255
280
  await recoverAllRuntimes(runtimeList, adapter);
256
281
  await reconcileBindingsAfterRestart(runtimes, adapter);
257
282
  await adapter.start();
@@ -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
- }