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.
- package/README.md +14 -2
- package/bin/ticlawk.mjs +207 -25
- package/package.json +1 -1
- package/src/adapters/ticlawk/api.mjs +232 -23
- package/src/adapters/ticlawk/credentials.mjs +41 -1
- package/src/adapters/ticlawk/index.mjs +196 -195
- package/src/adapters/ticlawk/wake-client.mjs +1 -1
- package/src/cli/agent-commands.mjs +607 -37
- package/src/core/agent-cli-handlers.mjs +449 -20
- package/src/core/agent-home.mjs +86 -10
- package/src/core/argv.mjs +11 -1
- package/src/core/http.mjs +126 -0
- package/src/core/runtime-env.mjs +7 -0
- package/src/core/runtime-support.mjs +101 -30
- package/src/migrate/write-initial-memory.mjs +5 -5
- package/src/runtimes/_shared/agent-handbook.mjs +45 -0
- package/src/runtimes/_shared/brand.mjs +2 -0
- package/src/runtimes/_shared/goal-task-protocol.mjs +50 -0
- package/src/runtimes/_shared/handbook/BASICS.md +27 -0
- package/src/runtimes/_shared/handbook/COLLABORATION.md +37 -0
- package/src/runtimes/_shared/handbook/COMMUNICATION.md +48 -0
- package/src/runtimes/_shared/handbook/DM_SCOPE.md +13 -0
- package/src/runtimes/_shared/handbook/GOAL_AUTHORITY.md +43 -0
- package/src/runtimes/_shared/handbook/GOAL_TASK_CORE.md +43 -0
- package/src/runtimes/_shared/handbook/GROUP_ADMIN_SCOPE.md +21 -0
- package/src/runtimes/_shared/handbook/GROUP_MEMBER_SCOPE.md +15 -0
- package/src/runtimes/_shared/handbook/SURFACES.md +41 -0
- package/src/runtimes/_shared/handbook/TASK_WORKER.md +14 -0
- package/src/runtimes/_shared/standing-prompt.mjs +111 -264
- package/src/runtimes/_shared/wake-prompt.mjs +261 -0
- package/src/runtimes/claude-code/index.mjs +30 -108
- package/src/runtimes/codex/index.mjs +114 -23
- package/src/runtimes/openclaw/index.mjs +16 -26
- package/src/runtimes/opencode/index.mjs +42 -36
- package/src/runtimes/opencode/session.mjs +5 -4
- package/src/runtimes/pi/index.mjs +39 -31
- package/src/runtimes/pi/session.mjs +5 -2
- package/src/adapters/ticlawk/cards.mjs +0 -149
- 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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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 ||
|
|
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:
|
|
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:
|
|
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 ||
|
|
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 ||
|
|
199
|
+
sessionId: event.sessionId || targetSessionId || binding.id,
|
|
195
200
|
cwd: agentHome,
|
|
196
201
|
});
|
|
197
202
|
}
|
|
198
203
|
},
|
|
199
204
|
})
|
|
200
|
-
: await this.runTurn({ sessionId:
|
|
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
|
-
|
|
205
|
-
|
|
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 ||
|
|
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:
|
|
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
|
|
204
|
-
|
|
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
|
-
}
|