@gakim-digital/dexter-bridge 0.5.0
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/LICENSE +16 -0
- package/README.md +154 -0
- package/bin/dexter-bridge.js +10 -0
- package/package.json +33 -0
- package/src/agent.js +998 -0
- package/src/agentOutput.js +232 -0
- package/src/api.js +165 -0
- package/src/cli.js +337 -0
- package/src/config.js +207 -0
- package/src/logger.js +183 -0
- package/src/protocol.js +190 -0
- package/src/providers/claudeAgentSdk.js +508 -0
- package/src/providers/codexAppServer.js +457 -0
- package/src/providers/index.js +55 -0
- package/src/providers/jsonRpcClient.js +172 -0
package/src/logger.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { defaultConfigDir } from './config.js';
|
|
5
|
+
|
|
6
|
+
const SECRET_PATTERNS = [
|
|
7
|
+
/\b(dcpp|dcpd)_[A-Za-z0-9_-]+/g,
|
|
8
|
+
/(Authorization:\s*Bearer\s+)[A-Za-z0-9._-]+/gi,
|
|
9
|
+
/("?(?:apiKey|deviceToken|pairingToken|pairingCode|authorization|secret|password|token)"?\s*[:=]\s*")([^"]+)/gi,
|
|
10
|
+
/((?:api[-_]?key|token|secret|password)=)[^&\s]+/gi,
|
|
11
|
+
];
|
|
12
|
+
const MAX_LOG_BYTES = Math.max(256_000, Number(process.env.DEXTER_BRIDGE_MAX_LOG_BYTES) || 5_000_000);
|
|
13
|
+
const MAX_ROTATED_LOGS = Math.max(1, Number(process.env.DEXTER_BRIDGE_MAX_ROTATED_LOGS) || 4);
|
|
14
|
+
const DUPLICATE_WINDOW_MS = Math.max(1_000, Number(process.env.DEXTER_BRIDGE_LOG_DEDUPE_MS) || 30_000);
|
|
15
|
+
|
|
16
|
+
function safeRunId(value) {
|
|
17
|
+
return String(value || 'unknown')
|
|
18
|
+
.replace(/[^a-z0-9._-]+/gi, '-')
|
|
19
|
+
.slice(0, 120) || 'unknown';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function redact(value) {
|
|
23
|
+
let text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
24
|
+
if (text === undefined || text === null) text = '';
|
|
25
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
26
|
+
text = text.replace(pattern, (match, prefix) => `${prefix || ''}[redacted]`);
|
|
27
|
+
}
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clip(value, max = 2000) {
|
|
32
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
33
|
+
if (text.length <= max) return text;
|
|
34
|
+
return `${text.slice(0, max)}...[truncated ${text.length - max} chars]`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ensureLogDir(logDir) {
|
|
38
|
+
const dir = logDir || process.env.DEXTER_BRIDGE_LOG_DIR || path.join(defaultConfigDir(), 'logs');
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
return dir;
|
|
42
|
+
} catch {
|
|
43
|
+
const fallback = path.join(os.tmpdir(), 'dexter-bridge', 'logs');
|
|
44
|
+
fs.mkdirSync(fallback, { recursive: true, mode: 0o700 });
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function safeMeta(meta) {
|
|
50
|
+
if (!meta || typeof meta !== 'object') return meta;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(redact(JSON.stringify(meta)));
|
|
53
|
+
} catch {
|
|
54
|
+
return { value: redact(String(meta)) };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function callMirror(mirror, level, event, meta) {
|
|
59
|
+
if (typeof mirror !== 'function') return;
|
|
60
|
+
try {
|
|
61
|
+
if (mirror.length >= 3) {
|
|
62
|
+
mirror(level, event, meta);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
mirror(`${level.toUpperCase()} ${event}${meta ? ` ${clip(safeMeta(meta), 1200)}` : ''}`);
|
|
66
|
+
} catch {
|
|
67
|
+
// Logging should never affect a companion run.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function rotateLogFile(filePath, maxLogBytes, maxRotatedLogs) {
|
|
72
|
+
try {
|
|
73
|
+
const size = fs.statSync(filePath).size;
|
|
74
|
+
if (size < maxLogBytes) return;
|
|
75
|
+
for (let index = maxRotatedLogs; index >= 1; index -= 1) {
|
|
76
|
+
const source = index === 1 ? filePath : `${filePath}.${index - 1}`;
|
|
77
|
+
const target = `${filePath}.${index}`;
|
|
78
|
+
if (!fs.existsSync(source)) continue;
|
|
79
|
+
if (index === maxRotatedLogs && fs.existsSync(target)) fs.unlinkSync(target);
|
|
80
|
+
fs.renameSync(source, target);
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Rotation is best-effort and must never affect a run.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createRunLogger({
|
|
88
|
+
runId,
|
|
89
|
+
logDir,
|
|
90
|
+
mirror,
|
|
91
|
+
maxLogBytes = MAX_LOG_BYTES,
|
|
92
|
+
maxRotatedLogs = MAX_ROTATED_LOGS,
|
|
93
|
+
duplicateWindowMs = DUPLICATE_WINDOW_MS,
|
|
94
|
+
} = {}) {
|
|
95
|
+
const dir = ensureLogDir(logDir);
|
|
96
|
+
const filePath = path.join(dir, `run-${safeRunId(runId)}.jsonl`);
|
|
97
|
+
const recent = new Map();
|
|
98
|
+
const rotationBytes = Math.max(1, Number(maxLogBytes) || MAX_LOG_BYTES);
|
|
99
|
+
const rotationCount = Math.max(1, Number(maxRotatedLogs) || MAX_ROTATED_LOGS);
|
|
100
|
+
const dedupeWindow = Math.max(0, Number(duplicateWindowMs) || 0);
|
|
101
|
+
|
|
102
|
+
function write(level, event, meta = {}) {
|
|
103
|
+
const safe = safeMeta(meta);
|
|
104
|
+
const fingerprint = `${level}:${event}:${JSON.stringify(safe)}`;
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const prior = recent.get(fingerprint);
|
|
107
|
+
if (prior && now - prior < dedupeWindow) return;
|
|
108
|
+
recent.set(fingerprint, now);
|
|
109
|
+
if (recent.size > 200) {
|
|
110
|
+
for (const [key, at] of recent) {
|
|
111
|
+
if (now - at > dedupeWindow) recent.delete(key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const entry = {
|
|
115
|
+
ts: new Date().toISOString(),
|
|
116
|
+
level,
|
|
117
|
+
event,
|
|
118
|
+
pid: process.pid,
|
|
119
|
+
runId: runId || null,
|
|
120
|
+
...safe,
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
rotateLogFile(filePath, rotationBytes, rotationCount);
|
|
124
|
+
fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
125
|
+
fs.chmodSync(filePath, 0o600);
|
|
126
|
+
} catch {
|
|
127
|
+
// Logging should never affect a companion run.
|
|
128
|
+
}
|
|
129
|
+
callMirror(mirror, level, event, entry);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
filePath,
|
|
134
|
+
info: (event, meta) => write('info', event, meta),
|
|
135
|
+
warn: (event, meta) => write('warn', event, meta),
|
|
136
|
+
error: (event, meta) => write('error', event, meta),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function errorMeta(error) {
|
|
141
|
+
return {
|
|
142
|
+
name: error?.name,
|
|
143
|
+
message: error?.message || String(error || ''),
|
|
144
|
+
code: error?.code,
|
|
145
|
+
status: error?.status,
|
|
146
|
+
stack: error?.stack ? clip(error.stack, 5000) : undefined,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function summarizeToolResult(toolResult) {
|
|
151
|
+
if (!toolResult || typeof toolResult !== 'object') return null;
|
|
152
|
+
const result = toolResult.result && typeof toolResult.result === 'object' ? toolResult.result : {};
|
|
153
|
+
const error = toolResult.error && typeof toolResult.error === 'object' ? toolResult.error : null;
|
|
154
|
+
return {
|
|
155
|
+
ok: toolResult.ok === true,
|
|
156
|
+
errorCode: error?.code,
|
|
157
|
+
errorMessage: error?.message ? clip(error.message, 500) : undefined,
|
|
158
|
+
editedCount: typeof result.editedCount === 'number' ? result.editedCount : undefined,
|
|
159
|
+
targetNodeId: typeof result.targetNodeId === 'string' ? result.targetNodeId : undefined,
|
|
160
|
+
selectedNodeId: typeof result.selectedNodeId === 'string' ? result.selectedNodeId : undefined,
|
|
161
|
+
resultKeys: result && typeof result === 'object' ? Object.keys(result).slice(0, 20) : [],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function summarizePayload(payload) {
|
|
166
|
+
if (!payload || typeof payload !== 'object') return payload;
|
|
167
|
+
const value = payload;
|
|
168
|
+
return {
|
|
169
|
+
keys: Object.keys(value).slice(0, 30),
|
|
170
|
+
message: typeof value.message === 'string' ? clip(value.message, 500) : undefined,
|
|
171
|
+
type: typeof value.type === 'string' ? value.type : undefined,
|
|
172
|
+
tool: typeof value.tool === 'string' ? value.tool : typeof value.name === 'string' ? value.name : undefined,
|
|
173
|
+
callId: typeof value.callId === 'string' ? value.callId : undefined,
|
|
174
|
+
hasArgs: Boolean(value.args && typeof value.args === 'object'),
|
|
175
|
+
tokenUsage: value.tokenUsage && typeof value.tokenUsage === 'object' ? value.tokenUsage : undefined,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function logLocationHint(logDir) {
|
|
180
|
+
const dir = logDir || process.env.DEXTER_BRIDGE_LOG_DIR || path.join(defaultConfigDir(), 'logs');
|
|
181
|
+
const home = os.homedir();
|
|
182
|
+
return dir.startsWith(home) ? dir.replace(home, '~') : dir;
|
|
183
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function shortText(value, max = 360) {
|
|
6
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value ?? '');
|
|
7
|
+
return text.length > max ? `${text.slice(0, max)}...[truncated ${text.length - max} chars]` : text;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function compactSchema(schema, depth = 0) {
|
|
11
|
+
if (!isRecord(schema) || depth > 4) return schema;
|
|
12
|
+
const result = {};
|
|
13
|
+
for (const key of ['type', 'enum', 'required', 'additionalProperties']) {
|
|
14
|
+
if (schema[key] !== undefined) result[key] = schema[key];
|
|
15
|
+
}
|
|
16
|
+
if (isRecord(schema.properties)) {
|
|
17
|
+
result.properties = Object.fromEntries(
|
|
18
|
+
Object.entries(schema.properties)
|
|
19
|
+
.slice(0, 40)
|
|
20
|
+
.map(([key, value]) => [key, compactSchema(value, depth + 1)]),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
if (schema.items !== undefined) result.items = compactSchema(schema.items, depth + 1);
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function compactModelTurnContent(content) {
|
|
28
|
+
if (!Array.isArray(content)) return content;
|
|
29
|
+
return content.map((part) => {
|
|
30
|
+
if (!isRecord(part) || part.type !== 'image') return part;
|
|
31
|
+
return {
|
|
32
|
+
type: 'image',
|
|
33
|
+
mimeType: part.mimeType,
|
|
34
|
+
url: part.url,
|
|
35
|
+
omittedBase64Bytes: typeof part.base64 === 'string' ? part.base64.length : undefined,
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function clipText(value, max = 12000) {
|
|
41
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
42
|
+
if (text.length <= max) return text;
|
|
43
|
+
return `${text.slice(0, max)}\n...[truncated ${text.length - max} chars]`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function extractJsonObject(text) {
|
|
47
|
+
const input = String(text || '').trim();
|
|
48
|
+
if (!input) throw new Error('Agent returned an empty response.');
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(input);
|
|
51
|
+
} catch {
|
|
52
|
+
// Some local CLIs still wrap structured output in a JSON code fence.
|
|
53
|
+
}
|
|
54
|
+
const fenced = input.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
|
55
|
+
if (fenced) return JSON.parse(fenced[1]);
|
|
56
|
+
const start = input.indexOf('{');
|
|
57
|
+
const end = input.lastIndexOf('}');
|
|
58
|
+
if (start >= 0 && end > start) return JSON.parse(input.slice(start, end + 1));
|
|
59
|
+
throw new Error('Agent response did not contain a JSON object.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function compactToolCatalog(tools = []) {
|
|
63
|
+
return tools
|
|
64
|
+
.filter(isRecord)
|
|
65
|
+
.slice(0, 40)
|
|
66
|
+
.map((tool) => ({
|
|
67
|
+
name: typeof tool.name === 'string' ? tool.name : '',
|
|
68
|
+
description: shortText(tool.description || '', 360),
|
|
69
|
+
parameters: compactSchema(tool.parameters),
|
|
70
|
+
}))
|
|
71
|
+
.filter((tool) => tool.name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildModelTurnPrompt(modelTurn = {}) {
|
|
75
|
+
const messages = Array.isArray(modelTurn.messages)
|
|
76
|
+
? modelTurn.messages.slice(-64).map((message) => ({
|
|
77
|
+
role: message?.role,
|
|
78
|
+
content: compactModelTurnContent(message?.content),
|
|
79
|
+
toolCalls: message?.toolCalls,
|
|
80
|
+
toolCallId: message?.toolCallId,
|
|
81
|
+
}))
|
|
82
|
+
: [];
|
|
83
|
+
const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
|
|
84
|
+
return [
|
|
85
|
+
'You are the model engine for Dexter. The server owns the agent loop and executes all tools.',
|
|
86
|
+
'Return exactly one JSON object and no markdown.',
|
|
87
|
+
'Allowed response:',
|
|
88
|
+
'{"text":"optional assistant text","toolCalls":[{"id":"stable-id","name":"toolName","arguments":"{\\"key\\":\\"value\\"}"}],"finishReason":"tool_calls|stop|length"}',
|
|
89
|
+
'Each toolCalls[].arguments value must be a JSON-encoded string whose decoded value is an object.',
|
|
90
|
+
'Use only tools listed below. Do not claim a tool executed; only request it.',
|
|
91
|
+
'When the task is complete, return toolCalls:[] and finishReason:"stop".',
|
|
92
|
+
'',
|
|
93
|
+
`Messages:\n${JSON.stringify(messages)}`,
|
|
94
|
+
'',
|
|
95
|
+
`Tools:\n${JSON.stringify(tools)}`,
|
|
96
|
+
].join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function modelTurnOutputSchema(modelTurn = {}) {
|
|
100
|
+
const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
|
|
101
|
+
const toolNames = tools.map((tool) => tool.name);
|
|
102
|
+
return {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
text: { type: 'string' },
|
|
106
|
+
toolCalls: {
|
|
107
|
+
type: 'array',
|
|
108
|
+
maxItems: toolNames.length ? 12 : 0,
|
|
109
|
+
items: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
id: { type: 'string' },
|
|
113
|
+
name: toolNames.length
|
|
114
|
+
? { type: 'string', enum: toolNames }
|
|
115
|
+
: { type: 'string' },
|
|
116
|
+
// Structured Outputs requires every object schema to declare
|
|
117
|
+
// additionalProperties:false. Tool argument shapes differ per
|
|
118
|
+
// selected tool, so encode them at this boundary and validate the
|
|
119
|
+
// decoded object before returning the completion to Dexter.
|
|
120
|
+
arguments: { type: 'string' },
|
|
121
|
+
},
|
|
122
|
+
required: ['id', 'name', 'arguments'],
|
|
123
|
+
additionalProperties: false,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
finishReason: {
|
|
127
|
+
type: 'string',
|
|
128
|
+
enum: ['tool_calls', 'stop', 'length'],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
required: ['text', 'toolCalls', 'finishReason'],
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function normalizeModelTurnCompletion(raw, fallbackModel = 'local-companion') {
|
|
137
|
+
if (!isRecord(raw)) throw new Error('Model turn completion must be a JSON object.');
|
|
138
|
+
if (raw.type || raw.tool || raw.name || raw.calls) {
|
|
139
|
+
throw new Error('Legacy local-agent decisions are not supported by Dexter Companion v4.');
|
|
140
|
+
}
|
|
141
|
+
const toolCalls = Array.isArray(raw.toolCalls)
|
|
142
|
+
? raw.toolCalls.slice(0, 12).map((call, index) => {
|
|
143
|
+
if (!isRecord(call) || typeof call.name !== 'string' || !call.name.trim()) {
|
|
144
|
+
throw new Error(`Model turn tool call ${index + 1} is missing a name.`);
|
|
145
|
+
}
|
|
146
|
+
let toolArguments = call.arguments;
|
|
147
|
+
if (typeof toolArguments === 'string') {
|
|
148
|
+
try {
|
|
149
|
+
toolArguments = JSON.parse(toolArguments);
|
|
150
|
+
} catch {
|
|
151
|
+
throw new Error(`Model turn tool call ${index + 1} arguments must contain valid JSON.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (toolArguments !== undefined && !isRecord(toolArguments)) {
|
|
155
|
+
throw new Error(`Model turn tool call ${index + 1} arguments must decode to an object.`);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
id: typeof call.id === 'string' && call.id.trim() ? call.id.slice(0, 160) : `local_tool_${index + 1}`,
|
|
159
|
+
name: call.name.trim().slice(0, 120),
|
|
160
|
+
arguments: toolArguments || {},
|
|
161
|
+
};
|
|
162
|
+
})
|
|
163
|
+
: [];
|
|
164
|
+
const finishReason = raw.finishReason === 'tool_calls'
|
|
165
|
+
|| raw.finishReason === 'stop'
|
|
166
|
+
|| raw.finishReason === 'length'
|
|
167
|
+
? raw.finishReason
|
|
168
|
+
: toolCalls.length
|
|
169
|
+
? 'tool_calls'
|
|
170
|
+
: 'stop';
|
|
171
|
+
return {
|
|
172
|
+
text: typeof raw.text === 'string' ? raw.text : '',
|
|
173
|
+
toolCalls,
|
|
174
|
+
finishReason,
|
|
175
|
+
model: typeof raw.model === 'string' && raw.model.trim()
|
|
176
|
+
? raw.model.trim().slice(0, 160)
|
|
177
|
+
: fallbackModel,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function runSummary(run) {
|
|
182
|
+
return {
|
|
183
|
+
runId: run?.runId,
|
|
184
|
+
kind: run?.kind,
|
|
185
|
+
model: run?.model,
|
|
186
|
+
protocol: run?.protocol?.version,
|
|
187
|
+
step: run?.modelTurn?.step,
|
|
188
|
+
toolCount: Array.isArray(run?.modelTurn?.tools) ? run.modelTurn.tools.length : 0,
|
|
189
|
+
};
|
|
190
|
+
}
|