ticlawk 0.1.16-dev.3 → 0.1.16-dev.30

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.
Files changed (39) hide show
  1. package/README.md +14 -2
  2. package/bin/ticlawk.mjs +207 -25
  3. package/package.json +1 -1
  4. package/src/adapters/ticlawk/api.mjs +232 -23
  5. package/src/adapters/ticlawk/credentials.mjs +41 -1
  6. package/src/adapters/ticlawk/index.mjs +196 -195
  7. package/src/adapters/ticlawk/wake-client.mjs +1 -1
  8. package/src/cli/agent-commands.mjs +607 -37
  9. package/src/core/agent-cli-handlers.mjs +449 -20
  10. package/src/core/agent-home.mjs +86 -10
  11. package/src/core/argv.mjs +11 -1
  12. package/src/core/http.mjs +126 -0
  13. package/src/core/runtime-env.mjs +7 -0
  14. package/src/core/runtime-support.mjs +101 -30
  15. package/src/migrate/write-initial-memory.mjs +5 -5
  16. package/src/runtimes/_shared/agent-handbook.mjs +45 -0
  17. package/src/runtimes/_shared/brand.mjs +2 -0
  18. package/src/runtimes/_shared/goal-task-protocol.mjs +50 -0
  19. package/src/runtimes/_shared/handbook/BASICS.md +27 -0
  20. package/src/runtimes/_shared/handbook/COLLABORATION.md +37 -0
  21. package/src/runtimes/_shared/handbook/COMMUNICATION.md +48 -0
  22. package/src/runtimes/_shared/handbook/DM_SCOPE.md +13 -0
  23. package/src/runtimes/_shared/handbook/GOAL_AUTHORITY.md +43 -0
  24. package/src/runtimes/_shared/handbook/GOAL_TASK_CORE.md +43 -0
  25. package/src/runtimes/_shared/handbook/GROUP_ADMIN_SCOPE.md +21 -0
  26. package/src/runtimes/_shared/handbook/GROUP_MEMBER_SCOPE.md +15 -0
  27. package/src/runtimes/_shared/handbook/SURFACES.md +41 -0
  28. package/src/runtimes/_shared/handbook/TASK_WORKER.md +14 -0
  29. package/src/runtimes/_shared/standing-prompt.mjs +111 -264
  30. package/src/runtimes/_shared/wake-prompt.mjs +261 -0
  31. package/src/runtimes/claude-code/index.mjs +30 -108
  32. package/src/runtimes/codex/index.mjs +114 -23
  33. package/src/runtimes/openclaw/index.mjs +16 -26
  34. package/src/runtimes/opencode/index.mjs +42 -36
  35. package/src/runtimes/opencode/session.mjs +5 -4
  36. package/src/runtimes/pi/index.mjs +39 -31
  37. package/src/runtimes/pi/session.mjs +5 -2
  38. package/src/adapters/ticlawk/cards.mjs +0 -149
  39. package/src/core/media/outbound.mjs +0 -163
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { basename } from 'node:path';
8
8
  import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
9
+ import { getGoalTaskProtocolOverlayKey } from '../_shared/goal-task-protocol.mjs';
9
10
  import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
10
11
  import { ensureAgentHome } from '../../core/agent-home.mjs';
11
12
  import {
@@ -22,13 +23,15 @@ import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
22
23
  import {
23
24
  shouldStreamRuntime,
24
25
  createDeltaAggregator,
25
- sendAdapterMessage,
26
- recordActivity,
27
26
  reportSubprocessFailure,
28
27
  terminalRuntimeFailure,
29
28
  updateBindingRuntimeMeta,
29
+ resolveRuntimeSessionScope,
30
+ buildRuntimeSessionMetaPatch,
30
31
  } from '../../core/runtime-support.mjs';
31
32
 
33
+ const standingPromptSeen = new Set();
34
+
32
35
  export const piRuntime = {
33
36
  name: 'pi',
34
37
 
@@ -41,6 +44,7 @@ export const piRuntime = {
41
44
  piPath,
42
45
  agentEnv,
43
46
  standingPrompt: opts.standingPrompt || null,
47
+ forceStandingPrompt: Boolean(opts.forceStandingPrompt),
44
48
  timeoutMs: opts.timeoutMs,
45
49
  });
46
50
  },
@@ -54,6 +58,7 @@ export const piRuntime = {
54
58
  piPath,
55
59
  agentEnv,
56
60
  standingPrompt: opts.standingPrompt || null,
61
+ forceStandingPrompt: Boolean(opts.forceStandingPrompt),
57
62
  timeoutMs: opts.timeoutMs,
58
63
  onEvent: opts.onEvent,
59
64
  });
@@ -122,33 +127,24 @@ export const piRuntime = {
122
127
  images = await buildPiImagesFromInbound(inbound);
123
128
  const captionText = (inbound.text || '').trim();
124
129
  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
- });
130
+ // Image decode failed and no caption to fall back on. Bail
131
+ // without a user notice; the dead chat-projection path that
132
+ // used to surface such notices is gone.
131
133
  return true;
132
134
  }
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
135
  message = captionText || 'Please analyze the attached image(s).';
142
136
  }
143
137
 
144
- const shouldRotate = !meta.sessionId || meta.rotatePending;
138
+ const sessionScope = resolveRuntimeSessionScope(meta, inbound);
139
+ const shouldRotate = sessionScope.shouldRotate;
140
+ const targetSessionId = shouldRotate ? null : sessionScope.sessionId;
145
141
  const deltaAggregator = createDeltaAggregator({
146
142
  flushDelta: async ({ text, sessionId, cwd }) => {
147
143
  await emitWorkerEvent({
148
144
  adapter,
149
145
  binding,
150
146
  agent: this.name,
151
- sessionId: sessionId || meta.sessionId || binding.id,
147
+ sessionId: sessionId || targetSessionId || binding.id,
152
148
  cwd: cwd || agentHome,
153
149
  replyToMessageId: inbound.messageId || null,
154
150
  event: {
@@ -166,13 +162,22 @@ export const piRuntime = {
166
162
  const runtimePiVersion = getPiRuntimeHealth(runtimePiPath).version || meta.piVersion || null;
167
163
  const agentEnv = buildAgentRuntimeEnv({
168
164
  agentId: binding.id,
169
- sessionId: meta.sessionId,
165
+ sessionId: targetSessionId,
170
166
  hostId: binding.runtime_host_id,
167
+ conversationId: inbound.conversationId,
168
+ messageId: inbound.messageId,
169
+ target: inbound.envelopeTarget,
171
170
  });
172
- const standingPrompt = buildStandingPrompt({ agentId: binding.id });
171
+ const standingPrompt = buildStandingPrompt({ agentId: binding.id, inbound });
172
+ const protocolOverlayKey = getGoalTaskProtocolOverlayKey({ agentId: binding.id, inbound });
173
+ const standingPromptSeenKey = targetSessionId
174
+ ? `${binding.id}|${targetSessionId}|${protocolOverlayKey}`
175
+ : null;
176
+ const forceStandingPrompt = Boolean(standingPromptSeenKey && !standingPromptSeen.has(standingPromptSeenKey));
173
177
  const result = shouldStreamRuntime(this.name, this)
174
- ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, piPath: runtimePiPath, agentEnv }, message, {
178
+ ? await this.runTurnStream({ sessionId: targetSessionId, cwd: agentHome, piPath: runtimePiPath, agentEnv }, message, {
175
179
  standingPrompt,
180
+ forceStandingPrompt,
176
181
  images,
177
182
  onEvent: async (event) => {
178
183
  if (event?.type === 'turn.started') {
@@ -180,7 +185,7 @@ export const piRuntime = {
180
185
  adapter,
181
186
  binding,
182
187
  agent: this.name,
183
- sessionId: event.sessionId || meta.sessionId || binding.id,
188
+ sessionId: event.sessionId || targetSessionId || binding.id,
184
189
  cwd: agentHome,
185
190
  replyToMessageId: inbound.messageId || null,
186
191
  event: {
@@ -191,30 +196,33 @@ export const piRuntime = {
191
196
  });
192
197
  } else if (event?.type === 'message.delta' && event.text) {
193
198
  deltaAggregator.push(event.text, {
194
- sessionId: event.sessionId || meta.sessionId || binding.id,
199
+ sessionId: event.sessionId || targetSessionId || binding.id,
195
200
  cwd: agentHome,
196
201
  });
197
202
  }
198
203
  },
199
204
  })
200
- : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, piPath: runtimePiPath, agentEnv }, message, { images, standingPrompt });
205
+ : await this.runTurn({ sessionId: targetSessionId, cwd: agentHome, piPath: runtimePiPath, agentEnv }, message, { images, standingPrompt, forceStandingPrompt });
201
206
 
202
207
  await deltaAggregator.flush();
208
+ const observedSessionId = result?.sessionId || targetSessionId;
209
+ if (observedSessionId) {
210
+ standingPromptSeen.add(`${binding.id}|${observedSessionId}|${protocolOverlayKey}`);
211
+ }
203
212
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
204
- sessionId: result?.sessionId || meta.sessionId,
205
- path: result?.path || meta.path || null,
213
+ ...buildRuntimeSessionMetaPatch(meta, sessionScope, {
214
+ sessionId: result?.sessionId,
215
+ path: result?.path,
216
+ }),
206
217
  runtimePath: runtimePiPath,
207
218
  piPath: runtimePiPath,
208
219
  piVersion: runtimePiVersion,
209
- rotatePending: false,
210
- lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
211
220
  }, { status: 'connected' });
212
- await recordActivity(adapter, nextBinding, inbound, result);
213
221
  await emitWorkerEvent({
214
222
  adapter,
215
223
  binding: nextBinding,
216
224
  agent: this.name,
217
- sessionId: result?.sessionId || meta.sessionId || binding.id,
225
+ sessionId: result?.sessionId || targetSessionId || binding.id,
218
226
  cwd: result?.cwd || agentHome,
219
227
  replyToMessageId: inbound.messageId || null,
220
228
  event: {
@@ -230,7 +238,7 @@ export const piRuntime = {
230
238
  adapter,
231
239
  binding,
232
240
  agent: this.name,
233
- sessionId: meta.sessionId || binding.id,
241
+ sessionId: targetSessionId || binding.id,
234
242
  cwd: agentHome,
235
243
  replyToMessageId: inbound.messageId || null,
236
244
  event: {
@@ -197,11 +197,14 @@ export function runPiPrompt({
197
197
  piPath = null,
198
198
  agentEnv = null,
199
199
  standingPrompt = null,
200
+ forceStandingPrompt = false,
200
201
  timeoutMs = Number(process.env.PI_RUN_TIMEOUT_MS || DEFAULT_PI_RUN_TIMEOUT_MS),
201
202
  onEvent,
202
203
  }) {
203
- // pi has no documented system-prompt flag prepend on first turn.
204
- const finalMessage = standingPrompt && !sessionId
204
+ // pi has no documented system-prompt flag. Prepend on first turn, and
205
+ // let callers force one resumed-session injection for a newly selected
206
+ // protocol overlay.
207
+ const finalMessage = standingPrompt && (!sessionId || forceStandingPrompt)
205
208
  ? `${standingPrompt}\n\n---\n\n${message}`
206
209
  : message;
207
210
  return new Promise((resolve, reject) => {
@@ -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
- }