ai-consultants 2.10.1
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/.claude/commands/ai-consultants:consult.md +94 -0
- package/.claude/commands/ai-consultants:debate.md +103 -0
- package/.claude/commands/ai-consultants:help.md +30 -0
- package/CLAUDE.md +839 -0
- package/LICENSE +21 -0
- package/README.md +758 -0
- package/SKILL.md +156 -0
- package/bin/ai-consultants +96 -0
- package/docs/COST_RATES.md +172 -0
- package/docs/JSON_SCHEMA.md +305 -0
- package/docs/SETUP.md +879 -0
- package/docs/SMART_ROUTING.md +219 -0
- package/docs/cost_rates.json +143 -0
- package/package.json +47 -0
- package/references/configuration.md +141 -0
- package/references/details.md +60 -0
- package/scripts/build_context.sh +562 -0
- package/scripts/classify_question.sh +148 -0
- package/scripts/config.sh +753 -0
- package/scripts/configure.sh +1093 -0
- package/scripts/consult_all.sh +934 -0
- package/scripts/debate_round.sh +421 -0
- package/scripts/doctor.sh +642 -0
- package/scripts/followup.sh +197 -0
- package/scripts/install.sh +319 -0
- package/scripts/lib/api.sh +640 -0
- package/scripts/lib/api_query.sh +228 -0
- package/scripts/lib/cache.sh +338 -0
- package/scripts/lib/chunking.sh +880 -0
- package/scripts/lib/code_optimizer.sh +671 -0
- package/scripts/lib/common.sh +1088 -0
- package/scripts/lib/costs.sh +674 -0
- package/scripts/lib/personas.sh +372 -0
- package/scripts/lib/progress.sh +251 -0
- package/scripts/lib/reflection.sh +447 -0
- package/scripts/lib/routing.sh +566 -0
- package/scripts/lib/schema.json +165 -0
- package/scripts/lib/session.sh +327 -0
- package/scripts/lib/symbol_map.sh +445 -0
- package/scripts/lib/voting.sh +495 -0
- package/scripts/peer_review.sh +460 -0
- package/scripts/preflight_check.sh +452 -0
- package/scripts/query_aider.sh +83 -0
- package/scripts/query_amp.sh +67 -0
- package/scripts/query_claude.sh +99 -0
- package/scripts/query_codex.sh +92 -0
- package/scripts/query_cursor.sh +72 -0
- package/scripts/query_deepseek.sh +17 -0
- package/scripts/query_gemini.sh +85 -0
- package/scripts/query_glm.sh +17 -0
- package/scripts/query_grok.sh +17 -0
- package/scripts/query_kilo.sh +140 -0
- package/scripts/query_kimi.sh +67 -0
- package/scripts/query_minimax.sh +17 -0
- package/scripts/query_mistral.sh +97 -0
- package/scripts/query_ollama.sh +328 -0
- package/scripts/query_qwen3.sh +93 -0
- package/scripts/setup_wizard.sh +367 -0
- package/scripts/skillport-install.sh +196 -0
- package/scripts/synthesize.sh +332 -0
- package/templates/consultation_report.md +323 -0
- package/templates/synthesis_prompt.md +120 -0
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# common.sh - Shared functions for consultation scripts
|
|
3
|
+
# Includes: logging, cross-platform timeout, retry logic, validation
|
|
4
|
+
|
|
5
|
+
# Guard against double-sourcing
|
|
6
|
+
if [[ -n "${_COMMON_SH_SOURCED:-}" ]]; then
|
|
7
|
+
return 0 2>/dev/null || exit 0
|
|
8
|
+
fi
|
|
9
|
+
_COMMON_SH_SOURCED=1
|
|
10
|
+
|
|
11
|
+
# Load configuration
|
|
12
|
+
SCRIPT_DIR_COMMON="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
13
|
+
source "$SCRIPT_DIR_COMMON/../config.sh"
|
|
14
|
+
|
|
15
|
+
# =============================================================================
|
|
16
|
+
# LOGGING FUNCTIONS
|
|
17
|
+
# =============================================================================
|
|
18
|
+
|
|
19
|
+
_log() {
|
|
20
|
+
local level=$1
|
|
21
|
+
local color=$2
|
|
22
|
+
local message=$3
|
|
23
|
+
|
|
24
|
+
# Map levels to numbers for comparison (without associative arrays for compatibility)
|
|
25
|
+
local current_level_num=1 # default INFO
|
|
26
|
+
local message_level_num=1
|
|
27
|
+
|
|
28
|
+
case "$LOG_LEVEL" in
|
|
29
|
+
DEBUG) current_level_num=0 ;;
|
|
30
|
+
INFO) current_level_num=1 ;;
|
|
31
|
+
WARN) current_level_num=2 ;;
|
|
32
|
+
ERROR) current_level_num=3 ;;
|
|
33
|
+
esac
|
|
34
|
+
|
|
35
|
+
case "$level" in
|
|
36
|
+
DEBUG) message_level_num=0 ;;
|
|
37
|
+
INFO) message_level_num=1 ;;
|
|
38
|
+
WARN) message_level_num=2 ;;
|
|
39
|
+
ERROR) message_level_num=3 ;;
|
|
40
|
+
esac
|
|
41
|
+
|
|
42
|
+
if [[ "$message_level_num" -ge "$current_level_num" ]]; then
|
|
43
|
+
echo -e "${color}[$(date '+%H:%M:%S')] [$level]${C_RESET} ${message}" >&2
|
|
44
|
+
fi
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
log_debug() { _log "DEBUG" "$C_DEBUG" "$1"; }
|
|
48
|
+
log_info() { _log "INFO" "$C_INFO" "$1"; }
|
|
49
|
+
log_success() { _log "INFO" "$C_SUCCESS" "$1"; }
|
|
50
|
+
log_warn() { _log "WARN" "$C_WARN" "$1"; }
|
|
51
|
+
log_error() { _log "ERROR" "$C_ERROR" "$1"; }
|
|
52
|
+
|
|
53
|
+
# =============================================================================
|
|
54
|
+
# CROSS-PLATFORM TIMEOUT
|
|
55
|
+
# =============================================================================
|
|
56
|
+
|
|
57
|
+
# Random stagger delay to avoid rate-limit bursts on parallel launch.
|
|
58
|
+
# Usage: apply_launch_stagger
|
|
59
|
+
# Sleeps for a random duration between 0 and LAUNCH_STAGGER_MAX_SECONDS.
|
|
60
|
+
# No-op if LAUNCH_STAGGER_MAX_SECONDS is 0 or unset.
|
|
61
|
+
apply_launch_stagger() {
|
|
62
|
+
local max="${LAUNCH_STAGGER_MAX_SECONDS:-0}"
|
|
63
|
+
if [[ "$max" -gt 0 ]] 2>/dev/null; then
|
|
64
|
+
local delay_ms=$(( RANDOM % (max * 1000) ))
|
|
65
|
+
local delay_s
|
|
66
|
+
delay_s=$(awk "BEGIN {printf \"%.2f\", $delay_ms / 1000}")
|
|
67
|
+
log_debug "Stagger delay: ${delay_s}s"
|
|
68
|
+
sleep "$delay_s"
|
|
69
|
+
fi
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Timeout function compatible with macOS and Linux
|
|
73
|
+
# Usage: run_with_timeout <seconds> <command> [args...]
|
|
74
|
+
run_with_timeout() {
|
|
75
|
+
local timeout_seconds=$1
|
|
76
|
+
shift
|
|
77
|
+
local cmd=("$@")
|
|
78
|
+
|
|
79
|
+
# Try first with timeout (Linux/GNU coreutils)
|
|
80
|
+
if command -v timeout &> /dev/null; then
|
|
81
|
+
timeout "$timeout_seconds" "${cmd[@]}"
|
|
82
|
+
return $?
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# Try with gtimeout (macOS with coreutils installed)
|
|
86
|
+
if command -v gtimeout &> /dev/null; then
|
|
87
|
+
gtimeout "$timeout_seconds" "${cmd[@]}"
|
|
88
|
+
return $?
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
# Fallback: implementation with background job and kill
|
|
92
|
+
# Works on any POSIX system
|
|
93
|
+
# Note: stdin must be captured before backgrounding
|
|
94
|
+
local stdin_data
|
|
95
|
+
stdin_data=$(cat)
|
|
96
|
+
echo "$stdin_data" | "${cmd[@]}" &
|
|
97
|
+
local pid=$!
|
|
98
|
+
|
|
99
|
+
# Monitor in background
|
|
100
|
+
(
|
|
101
|
+
sleep "$timeout_seconds"
|
|
102
|
+
kill -0 "$pid" 2>/dev/null && kill -TERM "$pid" 2>/dev/null
|
|
103
|
+
) &
|
|
104
|
+
local watchdog_pid=$!
|
|
105
|
+
|
|
106
|
+
# Wait for the command
|
|
107
|
+
wait "$pid" 2>/dev/null
|
|
108
|
+
local exit_code=$?
|
|
109
|
+
|
|
110
|
+
# Clean up the watchdog if the command finished first
|
|
111
|
+
kill -0 "$watchdog_pid" 2>/dev/null && kill "$watchdog_pid" 2>/dev/null
|
|
112
|
+
wait "$watchdog_pid" 2>/dev/null
|
|
113
|
+
|
|
114
|
+
# If the process was killed by timeout, return 124 (like GNU timeout)
|
|
115
|
+
if [[ $exit_code -eq 143 ]] || [[ $exit_code -eq 137 ]]; then
|
|
116
|
+
return 124
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
return $exit_code
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# =============================================================================
|
|
123
|
+
# COMMAND VERIFICATION
|
|
124
|
+
# =============================================================================
|
|
125
|
+
|
|
126
|
+
check_command() {
|
|
127
|
+
local cmd=$1
|
|
128
|
+
local name=$2
|
|
129
|
+
local install_hint=$3
|
|
130
|
+
|
|
131
|
+
if ! command -v "$cmd" &> /dev/null; then
|
|
132
|
+
log_error "$name not found (command: $cmd)"
|
|
133
|
+
if [[ -n "$install_hint" ]]; then
|
|
134
|
+
log_info "Install with: $install_hint"
|
|
135
|
+
fi
|
|
136
|
+
return 1
|
|
137
|
+
fi
|
|
138
|
+
return 0
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# =============================================================================
|
|
142
|
+
# MAIN QUERY EXECUTION FUNCTION
|
|
143
|
+
# =============================================================================
|
|
144
|
+
|
|
145
|
+
# Executes a query to an AI consultant with retry and timeout
|
|
146
|
+
#
|
|
147
|
+
# Usage: run_query <consultant_name> <output_file> <timeout_sec> <command...>
|
|
148
|
+
#
|
|
149
|
+
# The query is passed via stdin to the command.
|
|
150
|
+
# Example:
|
|
151
|
+
# echo "$QUERY" | run_query "Gemini" "/tmp/out.json" 120 gemini -p - --output-format json
|
|
152
|
+
#
|
|
153
|
+
run_query() {
|
|
154
|
+
local consultant_name="$1"
|
|
155
|
+
local output_file="$2"
|
|
156
|
+
local timeout_seconds="$3"
|
|
157
|
+
shift 3
|
|
158
|
+
local cmd=("$@")
|
|
159
|
+
|
|
160
|
+
# File for stderr
|
|
161
|
+
local error_file="${output_file}.err"
|
|
162
|
+
|
|
163
|
+
# Read stdin into a variable so it can be reused in retries
|
|
164
|
+
local stdin_content
|
|
165
|
+
stdin_content=$(cat)
|
|
166
|
+
|
|
167
|
+
log_info "Consulting $consultant_name (timeout: ${timeout_seconds}s, max retry: $MAX_RETRIES)..."
|
|
168
|
+
|
|
169
|
+
local attempt=1
|
|
170
|
+
while (( attempt <= MAX_RETRIES )); do
|
|
171
|
+
log_debug "[$consultant_name] Attempt $attempt of $MAX_RETRIES..."
|
|
172
|
+
|
|
173
|
+
# Execute the command with timeout, passing stdin
|
|
174
|
+
echo "$stdin_content" | run_with_timeout "$timeout_seconds" "${cmd[@]}" > "$output_file" 2> "$error_file"
|
|
175
|
+
local exit_code=$?
|
|
176
|
+
|
|
177
|
+
if [[ $exit_code -eq 0 ]]; then
|
|
178
|
+
# Verify that the output is not empty
|
|
179
|
+
if [[ -s "$output_file" ]]; then
|
|
180
|
+
log_success "[$consultant_name] Response received ($(wc -c < "$output_file" | tr -d ' ') bytes)"
|
|
181
|
+
rm -f "$error_file"
|
|
182
|
+
return 0
|
|
183
|
+
else
|
|
184
|
+
log_warn "[$consultant_name] Empty response"
|
|
185
|
+
exit_code=1 # Treat empty response as failure
|
|
186
|
+
fi
|
|
187
|
+
fi
|
|
188
|
+
|
|
189
|
+
# Error handling
|
|
190
|
+
local error_msg=""
|
|
191
|
+
[[ -f "$error_file" ]] && error_msg=$(head -5 "$error_file" 2>/dev/null)
|
|
192
|
+
|
|
193
|
+
if [[ $exit_code -eq 124 ]]; then
|
|
194
|
+
log_warn "[$consultant_name] Timeout after ${timeout_seconds}s"
|
|
195
|
+
else
|
|
196
|
+
log_warn "[$consultant_name] Error (code: $exit_code)"
|
|
197
|
+
[[ -n "$error_msg" ]] && log_debug "Details: $error_msg"
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
((attempt++))
|
|
201
|
+
if (( attempt <= MAX_RETRIES )); then
|
|
202
|
+
log_info "Waiting ${RETRY_DELAY_SECONDS}s before next attempt..."
|
|
203
|
+
sleep "$RETRY_DELAY_SECONDS"
|
|
204
|
+
fi
|
|
205
|
+
done
|
|
206
|
+
|
|
207
|
+
log_error "[$consultant_name] All $MAX_RETRIES attempts failed"
|
|
208
|
+
return 1
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# =============================================================================
|
|
212
|
+
# TIMESTAMP UTILITIES
|
|
213
|
+
# =============================================================================
|
|
214
|
+
|
|
215
|
+
# Detect timestamp method once at source time (avoid repeated detection)
|
|
216
|
+
if date +%s%3N 2>/dev/null | grep -qv 'N'; then
|
|
217
|
+
_TIMESTAMP_METHOD="gnu"
|
|
218
|
+
elif command -v python3 &>/dev/null; then
|
|
219
|
+
_TIMESTAMP_METHOD="python"
|
|
220
|
+
else
|
|
221
|
+
_TIMESTAMP_METHOD="posix"
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
# Get current timestamp in milliseconds (portable - works on macOS and Linux)
|
|
225
|
+
# Usage: get_timestamp_ms
|
|
226
|
+
get_timestamp_ms() {
|
|
227
|
+
case "$_TIMESTAMP_METHOD" in
|
|
228
|
+
gnu) date +%s%3N ;;
|
|
229
|
+
python) python3 -c 'import time; print(int(time.time()*1000))' ;;
|
|
230
|
+
*) echo "$(($(date +%s) * 1000))" ;;
|
|
231
|
+
esac
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# =============================================================================
|
|
235
|
+
# CASE NORMALIZATION HELPERS
|
|
236
|
+
# =============================================================================
|
|
237
|
+
|
|
238
|
+
# Convert string to uppercase (portable - works on Bash 3.2+)
|
|
239
|
+
# Usage: to_upper "string"
|
|
240
|
+
to_upper() {
|
|
241
|
+
echo "$1" | tr '[:lower:]' '[:upper:]' | tr -d ' -'
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
# Convert string to lowercase (portable - works on Bash 3.2+)
|
|
245
|
+
# Usage: to_lower "string"
|
|
246
|
+
to_lower() {
|
|
247
|
+
echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' -' '_'
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
# Convert to title case (first letter uppercase, rest lowercase)
|
|
251
|
+
# Usage: to_title "STRING" => "String"
|
|
252
|
+
to_title() {
|
|
253
|
+
echo "$1" | awk '{print toupper(substr($0,1,1)) tolower(substr($0,2))}'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
# =============================================================================
|
|
257
|
+
# KNOWN AGENTS REGISTRY
|
|
258
|
+
# =============================================================================
|
|
259
|
+
|
|
260
|
+
# Central list of known/predefined agents (to distinguish from custom ones)
|
|
261
|
+
# This list is used by discovery functions to identify custom agents
|
|
262
|
+
KNOWN_CLI_AGENTS="GEMINI CODEX MISTRAL KILO CURSOR AIDER AMP KIMI CLAUDE QWEN3"
|
|
263
|
+
KNOWN_API_AGENTS="GLM GROK DEEPSEEK MINIMAX"
|
|
264
|
+
KNOWN_FEATURE_FLAGS="PERSONA SYNTHESIS DEBATE REFLECTION CLASSIFICATION SMART_ROUTING COST_TRACKING PROGRESS_BARS EARLY_TERMINATION PREFLIGHT"
|
|
265
|
+
|
|
266
|
+
# Check if an agent name is a known predefined agent
|
|
267
|
+
# Usage: is_known_agent "AGENTNAME"
|
|
268
|
+
is_known_agent() {
|
|
269
|
+
local agent_upper="$1"
|
|
270
|
+
for known in $KNOWN_CLI_AGENTS $KNOWN_API_AGENTS $KNOWN_FEATURE_FLAGS; do
|
|
271
|
+
[[ "$agent_upper" == "$known" ]] && return 0
|
|
272
|
+
done
|
|
273
|
+
return 1
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
# =============================================================================
|
|
277
|
+
# CLI/API MODE SWITCHING (v2.6)
|
|
278
|
+
# =============================================================================
|
|
279
|
+
|
|
280
|
+
# Check if an agent is configured to use API mode instead of CLI
|
|
281
|
+
# Usage: is_api_mode <agent_name>
|
|
282
|
+
# Returns: 0 (true) if API mode, 1 (false) if CLI mode
|
|
283
|
+
is_api_mode() {
|
|
284
|
+
local agent="$1"
|
|
285
|
+
local agent_upper
|
|
286
|
+
agent_upper=$(to_upper "$agent")
|
|
287
|
+
|
|
288
|
+
local var_name="${agent_upper}_USE_API"
|
|
289
|
+
[[ "${!var_name:-false}" == "true" ]]
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# Validate API mode configuration for an agent
|
|
293
|
+
# Checks if the required API key is set
|
|
294
|
+
# Usage: validate_api_mode <agent_name>
|
|
295
|
+
# Returns: 0 if valid, 1 if API key missing
|
|
296
|
+
validate_api_mode() {
|
|
297
|
+
local agent="$1"
|
|
298
|
+
local agent_upper
|
|
299
|
+
agent_upper=$(to_upper "$agent")
|
|
300
|
+
|
|
301
|
+
local api_key_var
|
|
302
|
+
case "$agent_upper" in
|
|
303
|
+
GEMINI) api_key_var="GEMINI_API_KEY" ;;
|
|
304
|
+
CODEX) api_key_var="OPENAI_API_KEY" ;;
|
|
305
|
+
CLAUDE) api_key_var="ANTHROPIC_API_KEY" ;;
|
|
306
|
+
MISTRAL) api_key_var="MISTRAL_API_KEY" ;;
|
|
307
|
+
QWEN3) api_key_var="QWEN3_API_KEY" ;;
|
|
308
|
+
*)
|
|
309
|
+
log_error "Unknown agent for API mode: $agent"
|
|
310
|
+
return 1
|
|
311
|
+
;;
|
|
312
|
+
esac
|
|
313
|
+
|
|
314
|
+
if [[ -z "${!api_key_var:-}" ]]; then
|
|
315
|
+
log_error "[$agent] API mode enabled but $api_key_var is not set"
|
|
316
|
+
return 1
|
|
317
|
+
fi
|
|
318
|
+
|
|
319
|
+
log_debug "[$agent] API mode validated with $api_key_var"
|
|
320
|
+
return 0
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
# Get the API key variable name for an agent
|
|
324
|
+
# Usage: get_api_key_var <agent_name>
|
|
325
|
+
get_api_key_var() {
|
|
326
|
+
local agent="$1"
|
|
327
|
+
local agent_upper
|
|
328
|
+
agent_upper=$(to_upper "$agent")
|
|
329
|
+
|
|
330
|
+
case "$agent_upper" in
|
|
331
|
+
GEMINI) echo "GEMINI_API_KEY" ;;
|
|
332
|
+
CODEX) echo "OPENAI_API_KEY" ;;
|
|
333
|
+
CLAUDE) echo "ANTHROPIC_API_KEY" ;;
|
|
334
|
+
MISTRAL) echo "MISTRAL_API_KEY" ;;
|
|
335
|
+
QWEN3) echo "QWEN3_API_KEY" ;;
|
|
336
|
+
GLM) echo "GLM_API_KEY" ;;
|
|
337
|
+
GROK) echo "GROK_API_KEY" ;;
|
|
338
|
+
DEEPSEEK) echo "DEEPSEEK_API_KEY" ;;
|
|
339
|
+
MINIMAX) echo "MINIMAX_API_KEY" ;;
|
|
340
|
+
*) echo "" ;;
|
|
341
|
+
esac
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
# Get the API URL for an agent
|
|
345
|
+
# Usage: get_api_url <agent_name>
|
|
346
|
+
get_api_url() {
|
|
347
|
+
local agent="$1"
|
|
348
|
+
local agent_upper
|
|
349
|
+
agent_upper=$(to_upper "$agent")
|
|
350
|
+
|
|
351
|
+
case "$agent_upper" in
|
|
352
|
+
GEMINI) echo "${GEMINI_API_URL:-https://generativelanguage.googleapis.com/v1beta/models}" ;;
|
|
353
|
+
CODEX) echo "${CODEX_API_URL:-https://api.openai.com/v1/chat/completions}" ;;
|
|
354
|
+
CLAUDE) echo "${CLAUDE_API_URL:-https://api.anthropic.com/v1/messages}" ;;
|
|
355
|
+
MISTRAL) echo "${MISTRAL_API_URL:-https://api.mistral.ai/v1/chat/completions}" ;;
|
|
356
|
+
QWEN3) echo "${QWEN3_API_URL:-https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation}" ;;
|
|
357
|
+
GLM) echo "${GLM_API_URL:-https://open.bigmodel.cn/api/paas/v4/chat/completions}" ;;
|
|
358
|
+
GROK) echo "${GROK_API_URL:-https://api.x.ai/v1/chat/completions}" ;;
|
|
359
|
+
DEEPSEEK) echo "${DEEPSEEK_API_URL:-https://api.deepseek.com/v1/chat/completions}" ;;
|
|
360
|
+
MINIMAX) echo "${MINIMAX_API_URL:-https://api.minimax.io/v1/chat/completions}" ;;
|
|
361
|
+
*) echo "" ;;
|
|
362
|
+
esac
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
# Get the API response format for an agent
|
|
366
|
+
# Usage: get_api_format <agent_name>
|
|
367
|
+
get_api_format() {
|
|
368
|
+
local agent="$1"
|
|
369
|
+
local agent_upper
|
|
370
|
+
agent_upper=$(to_upper "$agent")
|
|
371
|
+
|
|
372
|
+
case "$agent_upper" in
|
|
373
|
+
GEMINI) echo "google_ai" ;;
|
|
374
|
+
CLAUDE) echo "anthropic" ;;
|
|
375
|
+
CODEX|MISTRAL|GLM|GROK|DEEPSEEK|MINIMAX) echo "openai" ;;
|
|
376
|
+
QWEN3) echo "qwen" ;;
|
|
377
|
+
*) echo "openai" ;;
|
|
378
|
+
esac
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
# Log API mode status for debugging
|
|
382
|
+
log_api_mode_status() {
|
|
383
|
+
local agent="$1"
|
|
384
|
+
if is_api_mode "$agent"; then
|
|
385
|
+
log_debug "[$agent] Mode: API"
|
|
386
|
+
else
|
|
387
|
+
log_debug "[$agent] Mode: CLI"
|
|
388
|
+
fi
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
# =============================================================================
|
|
392
|
+
# SELF-EXCLUSION LOGIC (v2.2)
|
|
393
|
+
# =============================================================================
|
|
394
|
+
|
|
395
|
+
# Maps invoking agent identifier to the consultant name that should be excluded.
|
|
396
|
+
# Prevents an agent from consulting itself (e.g., Claude Code shouldn't query Claude).
|
|
397
|
+
#
|
|
398
|
+
# Usage: excluded=$(get_self_consultant_name)
|
|
399
|
+
# Returns: Uppercase consultant name (e.g., "CLAUDE", "CODEX") or empty string
|
|
400
|
+
get_self_consultant_name() {
|
|
401
|
+
local invoking
|
|
402
|
+
invoking=$(to_lower "${INVOKING_AGENT:-unknown}")
|
|
403
|
+
|
|
404
|
+
# Normalize aliases to canonical names
|
|
405
|
+
case "$invoking" in
|
|
406
|
+
claude|claude_code|claudecode) echo "CLAUDE" ;;
|
|
407
|
+
codex|codex_cli|codexcli) echo "CODEX" ;;
|
|
408
|
+
gemini|gemini_cli|geminicli) echo "GEMINI" ;;
|
|
409
|
+
mistral|vibe|mistral_vibe) echo "MISTRAL" ;;
|
|
410
|
+
kilo|kilocode|kilo_code) echo "KILO" ;;
|
|
411
|
+
amp|amp_code|ampcode) echo "AMP" ;;
|
|
412
|
+
kimi|kimi_code|kimicode) echo "KIMI" ;;
|
|
413
|
+
qwen|qwen3|qwen_code|qwencode) echo "QWEN3" ;;
|
|
414
|
+
cursor|aider) echo "$(to_upper "$invoking")" ;;
|
|
415
|
+
*) echo "" ;;
|
|
416
|
+
esac
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
# Check if a consultant should be skipped due to self-exclusion
|
|
420
|
+
# Usage: should_skip_consultant "CONSULTANTNAME"
|
|
421
|
+
# Returns: 0 (true) if should skip, 1 (false) if should include
|
|
422
|
+
should_skip_consultant() {
|
|
423
|
+
local self_name
|
|
424
|
+
self_name=$(get_self_consultant_name)
|
|
425
|
+
[[ -n "$self_name" && "$(to_upper "$1")" == "$self_name" ]]
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
# Log self-exclusion status for debugging
|
|
429
|
+
log_self_exclusion_status() {
|
|
430
|
+
local self_name
|
|
431
|
+
self_name=$(get_self_consultant_name)
|
|
432
|
+
if [[ -n "$self_name" ]]; then
|
|
433
|
+
log_debug "Self-exclusion: excluding $self_name (invoking: ${INVOKING_AGENT:-unknown})"
|
|
434
|
+
fi
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
# =============================================================================
|
|
438
|
+
# SECURITY: INPUT VALIDATION
|
|
439
|
+
# =============================================================================
|
|
440
|
+
|
|
441
|
+
# Validate a file path to prevent path traversal attacks
|
|
442
|
+
# - Rejects paths containing ".."
|
|
443
|
+
# - Rejects absolute paths starting with / (unless in allowed dirs)
|
|
444
|
+
# - Returns 0 if valid, 1 if invalid
|
|
445
|
+
# Usage: validate_file_path "path" [allow_absolute]
|
|
446
|
+
validate_file_path() {
|
|
447
|
+
local path="$1"
|
|
448
|
+
local allow_absolute="${2:-false}"
|
|
449
|
+
|
|
450
|
+
# Check for empty path
|
|
451
|
+
if [[ -z "$path" ]]; then
|
|
452
|
+
log_error "Empty file path provided"
|
|
453
|
+
return 1
|
|
454
|
+
fi
|
|
455
|
+
|
|
456
|
+
# Check for path traversal attempts
|
|
457
|
+
if [[ "$path" == *".."* ]]; then
|
|
458
|
+
log_error "Path traversal detected in: $path"
|
|
459
|
+
return 1
|
|
460
|
+
fi
|
|
461
|
+
|
|
462
|
+
# Check for null bytes (common injection technique)
|
|
463
|
+
# Bash 3.2 can't reliably detect null bytes in variables, but command-line
|
|
464
|
+
# arguments with null bytes are truncated anyway, so this is a best-effort check
|
|
465
|
+
local path_len=${#path}
|
|
466
|
+
local printf_len
|
|
467
|
+
printf_len=$(printf '%s' "$path" | wc -c | tr -d ' ')
|
|
468
|
+
if [[ "$path_len" != "$printf_len" ]]; then
|
|
469
|
+
log_error "Null byte injection detected in path"
|
|
470
|
+
return 1
|
|
471
|
+
fi
|
|
472
|
+
|
|
473
|
+
# Check absolute paths
|
|
474
|
+
if [[ "$path" == /* ]] && [[ "$allow_absolute" != "true" ]]; then
|
|
475
|
+
log_warn "Absolute path not allowed: $path"
|
|
476
|
+
return 1
|
|
477
|
+
fi
|
|
478
|
+
|
|
479
|
+
# Reject paths to sensitive system directories
|
|
480
|
+
local sensitive_paths="/etc /root /var/log /proc /sys /dev"
|
|
481
|
+
for sensitive in $sensitive_paths; do
|
|
482
|
+
if [[ "$path" == "$sensitive"* ]]; then
|
|
483
|
+
log_error "Access to sensitive path denied: $path"
|
|
484
|
+
return 1
|
|
485
|
+
fi
|
|
486
|
+
done
|
|
487
|
+
|
|
488
|
+
return 0
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
# Sanitize a string for safe use in filenames
|
|
492
|
+
# Removes or replaces dangerous characters
|
|
493
|
+
# Usage: sanitize_filename "string"
|
|
494
|
+
sanitize_filename() {
|
|
495
|
+
local input="$1"
|
|
496
|
+
# Remove null bytes, newlines, and other control characters
|
|
497
|
+
# Replace spaces and special chars with underscores
|
|
498
|
+
echo "$input" | tr -d '\0\n\r' | tr -cs '[:alnum:]._-' '_' | head -c 255
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
# Validate consultant name against known valid names
|
|
502
|
+
# Usage: validate_consultant_name "name"
|
|
503
|
+
validate_consultant_name() {
|
|
504
|
+
local name="$1"
|
|
505
|
+
local upper
|
|
506
|
+
upper=$(to_upper "$name")
|
|
507
|
+
|
|
508
|
+
# Check against known agents
|
|
509
|
+
local valid_agents="GEMINI CODEX MISTRAL KILO CURSOR AIDER AMP KIMI CLAUDE QWEN3 GLM GROK DEEPSEEK MINIMAX OLLAMA"
|
|
510
|
+
for agent in $valid_agents; do
|
|
511
|
+
if [[ "$upper" == "$agent" ]]; then
|
|
512
|
+
return 0
|
|
513
|
+
fi
|
|
514
|
+
done
|
|
515
|
+
|
|
516
|
+
# Check if it looks like a custom agent (alphanumeric only)
|
|
517
|
+
if [[ ! "$upper" =~ ^[A-Z0-9_]+$ ]]; then
|
|
518
|
+
log_error "Invalid consultant name: $name"
|
|
519
|
+
return 1
|
|
520
|
+
fi
|
|
521
|
+
|
|
522
|
+
return 0
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
# =============================================================================
|
|
526
|
+
# UTILITY
|
|
527
|
+
# =============================================================================
|
|
528
|
+
|
|
529
|
+
# Builds the complete query from arguments and context file
|
|
530
|
+
# Usage: build_full_query "query" "context_file"
|
|
531
|
+
build_full_query() {
|
|
532
|
+
local query="$1"
|
|
533
|
+
local context_file="$2"
|
|
534
|
+
local full_query=""
|
|
535
|
+
|
|
536
|
+
# Add context if provided
|
|
537
|
+
if [[ -n "$context_file" && -f "$context_file" ]]; then
|
|
538
|
+
full_query=$(cat "$context_file")
|
|
539
|
+
fi
|
|
540
|
+
|
|
541
|
+
# Add query if provided
|
|
542
|
+
if [[ -n "$query" ]]; then
|
|
543
|
+
if [[ -n "$full_query" ]]; then
|
|
544
|
+
full_query="${full_query}
|
|
545
|
+
|
|
546
|
+
# Additional Question
|
|
547
|
+
${query}"
|
|
548
|
+
else
|
|
549
|
+
full_query="$query"
|
|
550
|
+
fi
|
|
551
|
+
fi
|
|
552
|
+
|
|
553
|
+
echo "$full_query"
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
# Verifies that there is something to send
|
|
557
|
+
validate_query() {
|
|
558
|
+
local full_query="$1"
|
|
559
|
+
local consultant="$2"
|
|
560
|
+
|
|
561
|
+
if [[ -z "$full_query" ]]; then
|
|
562
|
+
log_error "No query to send to $consultant. Specify a query or a context file."
|
|
563
|
+
return 1
|
|
564
|
+
fi
|
|
565
|
+
return 0
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
# =============================================================================
|
|
569
|
+
# BASH 3.2 COMPATIBLE MAP FUNCTIONS
|
|
570
|
+
# =============================================================================
|
|
571
|
+
# These functions emulate associative arrays for compatibility with macOS
|
|
572
|
+
# default bash (3.2). They use dynamic variable names with eval.
|
|
573
|
+
#
|
|
574
|
+
# IMPORTANT: Keys must be alphanumeric (letters, numbers, underscore).
|
|
575
|
+
# Special characters in keys will be stripped.
|
|
576
|
+
#
|
|
577
|
+
# Usage:
|
|
578
|
+
# map_set "MYMAP" "key" "value"
|
|
579
|
+
# value=$(map_get "MYMAP" "key")
|
|
580
|
+
# map_has "MYMAP" "key" && echo "exists"
|
|
581
|
+
# map_keys "MYMAP" # prints space-separated keys
|
|
582
|
+
# map_clear "MYMAP"
|
|
583
|
+
|
|
584
|
+
# Sanitize key for use as variable name (keep only alphanumeric and underscore)
|
|
585
|
+
_map_sanitize_key() {
|
|
586
|
+
echo "$1" | tr -cd '[:alnum:]_'
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
# Validate map name to prevent eval injection
|
|
590
|
+
# Only allows alphanumeric characters and underscores
|
|
591
|
+
_map_validate_name() {
|
|
592
|
+
local name="$1"
|
|
593
|
+
if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
|
|
594
|
+
log_error "Invalid map name: $name (must be alphanumeric with underscores)"
|
|
595
|
+
return 1
|
|
596
|
+
fi
|
|
597
|
+
echo "$name"
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
# Set a value in a map
|
|
601
|
+
# Usage: map_set "MAPNAME" "key" "value"
|
|
602
|
+
map_set() {
|
|
603
|
+
local map_name
|
|
604
|
+
map_name=$(_map_validate_name "$1") || return 1
|
|
605
|
+
local safe_key
|
|
606
|
+
safe_key=$(_map_sanitize_key "$2")
|
|
607
|
+
local value="$3"
|
|
608
|
+
|
|
609
|
+
eval "_MAP_${map_name}_${safe_key}=\"\$value\""
|
|
610
|
+
|
|
611
|
+
# Track keys for iteration (only add if not already present)
|
|
612
|
+
local keys_var="_MAP_${map_name}__KEYS__"
|
|
613
|
+
local current_keys
|
|
614
|
+
eval "current_keys=\"\${$keys_var:-}\""
|
|
615
|
+
if [[ ! " $current_keys " =~ " $safe_key " ]]; then
|
|
616
|
+
if [[ -z "$current_keys" ]]; then
|
|
617
|
+
eval "$keys_var=\"\$safe_key\""
|
|
618
|
+
else
|
|
619
|
+
eval "$keys_var=\"\$current_keys \$safe_key\""
|
|
620
|
+
fi
|
|
621
|
+
fi
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
# Get a value from a map
|
|
625
|
+
# Usage: value=$(map_get "MAPNAME" "key")
|
|
626
|
+
map_get() {
|
|
627
|
+
local map_name
|
|
628
|
+
map_name=$(_map_validate_name "$1") || return 1
|
|
629
|
+
local safe_key
|
|
630
|
+
safe_key=$(_map_sanitize_key "$2")
|
|
631
|
+
eval "echo \"\${_MAP_${map_name}_${safe_key}:-}\""
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
# Check if a key exists in a map (returns 0 if exists, 1 otherwise)
|
|
635
|
+
# Usage: map_has "MAPNAME" "key" && echo "exists"
|
|
636
|
+
map_has() {
|
|
637
|
+
local map_name
|
|
638
|
+
map_name=$(_map_validate_name "$1") || return 1
|
|
639
|
+
local safe_key
|
|
640
|
+
safe_key=$(_map_sanitize_key "$2")
|
|
641
|
+
eval "[ -n \"\${_MAP_${map_name}_${safe_key}+x}\" ]"
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
# Get all keys from a map (space-separated)
|
|
645
|
+
# Usage: for key in $(map_keys "MAPNAME"); do ...; done
|
|
646
|
+
map_keys() {
|
|
647
|
+
local map_name
|
|
648
|
+
map_name=$(_map_validate_name "$1") || return 1
|
|
649
|
+
eval "echo \"\${_MAP_${map_name}__KEYS__:-}\""
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
# Clear all values from a map
|
|
653
|
+
# Usage: map_clear "MAPNAME"
|
|
654
|
+
map_clear() {
|
|
655
|
+
local map_name
|
|
656
|
+
map_name=$(_map_validate_name "$1") || return 1
|
|
657
|
+
local keys_var="_MAP_${map_name}__KEYS__"
|
|
658
|
+
local keys key
|
|
659
|
+
eval "keys=\"\${$keys_var:-}\""
|
|
660
|
+
for key in $keys; do
|
|
661
|
+
eval "unset _MAP_${map_name}_${key}"
|
|
662
|
+
done
|
|
663
|
+
eval "unset $keys_var"
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
# =============================================================================
|
|
667
|
+
# MODULE SOURCING HELPER
|
|
668
|
+
# =============================================================================
|
|
669
|
+
|
|
670
|
+
# Source common.sh from a library module with automatic path detection
|
|
671
|
+
# This is called by other lib/*.sh files to source common.sh reliably
|
|
672
|
+
# Usage: source_common (call from within the module's directory context)
|
|
673
|
+
# Note: This function exists primarily for documentation - modules should
|
|
674
|
+
# use the pattern below directly since this function may not exist yet.
|
|
675
|
+
#
|
|
676
|
+
# Standard pattern for lib modules:
|
|
677
|
+
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
678
|
+
# source "$SCRIPT_DIR/common.sh" 2>/dev/null || source "${SCRIPT_DIR%/*}/lib/common.sh" 2>/dev/null || true
|
|
679
|
+
|
|
680
|
+
# =============================================================================
|
|
681
|
+
# PANIC MODE DETECTION (v2.2)
|
|
682
|
+
# =============================================================================
|
|
683
|
+
|
|
684
|
+
# Check if panic mode should be triggered based on responses
|
|
685
|
+
# Usage: should_trigger_panic <responses_dir>
|
|
686
|
+
# Returns: 0 if panic mode should trigger, 1 otherwise
|
|
687
|
+
should_trigger_panic() {
|
|
688
|
+
local responses_dir="$1"
|
|
689
|
+
local panic_mode="${ENABLE_PANIC_MODE:-auto}"
|
|
690
|
+
|
|
691
|
+
# Never trigger if disabled
|
|
692
|
+
[[ "$panic_mode" == "never" ]] && return 1
|
|
693
|
+
|
|
694
|
+
# Always trigger if set to always
|
|
695
|
+
[[ "$panic_mode" == "always" ]] && return 0
|
|
696
|
+
|
|
697
|
+
# Auto-detect mode
|
|
698
|
+
local total_confidence=0
|
|
699
|
+
local response_count=0
|
|
700
|
+
local has_uncertainty_keywords=false
|
|
701
|
+
|
|
702
|
+
for f in "$responses_dir"/*.json; do
|
|
703
|
+
if [[ -f "$f" && -s "$f" ]]; then
|
|
704
|
+
# Check confidence score
|
|
705
|
+
local confidence
|
|
706
|
+
confidence=$(jq -r '.confidence.score // 5' "$f" 2>/dev/null)
|
|
707
|
+
total_confidence=$((total_confidence + confidence))
|
|
708
|
+
((response_count++))
|
|
709
|
+
|
|
710
|
+
# Check for uncertainty keywords in summary/detailed response
|
|
711
|
+
local keywords="${PANIC_KEYWORDS:-uncertain|maybe|not sure|possibly}"
|
|
712
|
+
local text
|
|
713
|
+
text=$(jq -r '(.response.summary // "") + " " + (.response.detailed // "")' "$f" 2>/dev/null | tr '[:upper:]' '[:lower:]')
|
|
714
|
+
|
|
715
|
+
if echo "$text" | grep -qiE "$keywords"; then
|
|
716
|
+
has_uncertainty_keywords=true
|
|
717
|
+
fi
|
|
718
|
+
fi
|
|
719
|
+
done
|
|
720
|
+
|
|
721
|
+
# Calculate average confidence
|
|
722
|
+
if [[ $response_count -gt 0 ]]; then
|
|
723
|
+
local avg_confidence=$((total_confidence / response_count))
|
|
724
|
+
local threshold="${PANIC_CONFIDENCE_THRESHOLD:-5}"
|
|
725
|
+
|
|
726
|
+
# Trigger if average confidence is below threshold
|
|
727
|
+
if [[ $avg_confidence -lt $threshold ]]; then
|
|
728
|
+
return 0
|
|
729
|
+
fi
|
|
730
|
+
fi
|
|
731
|
+
|
|
732
|
+
# Trigger if uncertainty keywords found
|
|
733
|
+
if [[ "$has_uncertainty_keywords" == "true" ]]; then
|
|
734
|
+
return 0
|
|
735
|
+
fi
|
|
736
|
+
|
|
737
|
+
return 1
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
# Get panic mode diagnosis (for reporting)
|
|
741
|
+
# Usage: get_panic_diagnosis <responses_dir>
|
|
742
|
+
# Output: JSON with diagnosis details
|
|
743
|
+
get_panic_diagnosis() {
|
|
744
|
+
local responses_dir="$1"
|
|
745
|
+
|
|
746
|
+
local total_confidence=0
|
|
747
|
+
local response_count=0
|
|
748
|
+
local uncertainty_found=()
|
|
749
|
+
|
|
750
|
+
for f in "$responses_dir"/*.json; do
|
|
751
|
+
if [[ -f "$f" && -s "$f" ]]; then
|
|
752
|
+
local confidence consultant
|
|
753
|
+
confidence=$(jq -r '.confidence.score // 5' "$f" 2>/dev/null)
|
|
754
|
+
consultant=$(jq -r '.consultant // "unknown"' "$f" 2>/dev/null)
|
|
755
|
+
total_confidence=$((total_confidence + confidence))
|
|
756
|
+
((response_count++))
|
|
757
|
+
|
|
758
|
+
# Check for keywords
|
|
759
|
+
local keywords="${PANIC_KEYWORDS:-uncertain|maybe|not sure|possibly}"
|
|
760
|
+
local text
|
|
761
|
+
text=$(jq -r '(.response.summary // "") + " " + (.response.detailed // "")' "$f" 2>/dev/null | tr '[:upper:]' '[:lower:]')
|
|
762
|
+
|
|
763
|
+
if echo "$text" | grep -qiE "$keywords"; then
|
|
764
|
+
uncertainty_found+=("$consultant")
|
|
765
|
+
fi
|
|
766
|
+
fi
|
|
767
|
+
done
|
|
768
|
+
|
|
769
|
+
local avg_confidence=5
|
|
770
|
+
if [[ $response_count -gt 0 ]]; then
|
|
771
|
+
avg_confidence=$((total_confidence / response_count))
|
|
772
|
+
fi
|
|
773
|
+
|
|
774
|
+
local threshold="${PANIC_CONFIDENCE_THRESHOLD:-5}"
|
|
775
|
+
local triggers=()
|
|
776
|
+
|
|
777
|
+
[[ $avg_confidence -lt $threshold ]] && triggers+=("low_confidence")
|
|
778
|
+
[[ ${#uncertainty_found[@]} -gt 0 ]] && triggers+=("uncertainty_keywords")
|
|
779
|
+
|
|
780
|
+
jq -n \
|
|
781
|
+
--argjson avg_confidence "$avg_confidence" \
|
|
782
|
+
--argjson threshold "$threshold" \
|
|
783
|
+
--argjson response_count "$response_count" \
|
|
784
|
+
--arg uncertainty_consultants "$(IFS=','; echo "${uncertainty_found[*]:-}")" \
|
|
785
|
+
--arg triggers "$(IFS=','; echo "${triggers[*]:-}")" \
|
|
786
|
+
'{
|
|
787
|
+
average_confidence: $avg_confidence,
|
|
788
|
+
threshold: $threshold,
|
|
789
|
+
responses_analyzed: $response_count,
|
|
790
|
+
consultants_with_uncertainty: ($uncertainty_consultants | split(",") | map(select(. != ""))),
|
|
791
|
+
triggers: ($triggers | split(",") | map(select(. != ""))),
|
|
792
|
+
triggered: (($triggers | length) > 0)
|
|
793
|
+
}'
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
# =============================================================================
|
|
797
|
+
# TOKEN ESTIMATION (v2.1)
|
|
798
|
+
# =============================================================================
|
|
799
|
+
|
|
800
|
+
# Estimate token count from text
|
|
801
|
+
# Uses ~4 characters per token approximation for English text
|
|
802
|
+
# Usage: estimate_tokens "text" or echo "text" | estimate_tokens
|
|
803
|
+
estimate_tokens() {
|
|
804
|
+
local text="${1:-}"
|
|
805
|
+
if [[ -z "$text" ]]; then
|
|
806
|
+
# Read from stdin if no argument
|
|
807
|
+
text=$(cat)
|
|
808
|
+
fi
|
|
809
|
+
local chars
|
|
810
|
+
chars=$(echo -n "$text" | wc -c | tr -d ' ')
|
|
811
|
+
echo $((chars / 4))
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
# Estimate tokens from a file
|
|
815
|
+
# Usage: estimate_tokens_file "/path/to/file"
|
|
816
|
+
estimate_tokens_file() {
|
|
817
|
+
local file_path="$1"
|
|
818
|
+
if [[ -f "$file_path" ]]; then
|
|
819
|
+
local chars
|
|
820
|
+
chars=$(wc -c < "$file_path" | tr -d ' ')
|
|
821
|
+
echo $((chars / 4))
|
|
822
|
+
else
|
|
823
|
+
echo 0
|
|
824
|
+
fi
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
# Log token estimate with label
|
|
828
|
+
# Usage: log_token_estimate "Prompt" "$prompt_text"
|
|
829
|
+
log_token_estimate() {
|
|
830
|
+
local label="$1"
|
|
831
|
+
local text="$2"
|
|
832
|
+
local tokens
|
|
833
|
+
tokens=$(estimate_tokens "$text")
|
|
834
|
+
log_debug "$label: ~$tokens tokens (~$((tokens * 4)) chars)"
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
# =============================================================================
|
|
838
|
+
# RESPONSE BUILDERS (v2.4)
|
|
839
|
+
# =============================================================================
|
|
840
|
+
# Shared helpers for building standardized JSON responses in query scripts
|
|
841
|
+
|
|
842
|
+
# Build metadata JSON for responses
|
|
843
|
+
# Usage: build_response_metadata <latency_ms> <model> [error_msg]
|
|
844
|
+
build_response_metadata() {
|
|
845
|
+
local latency="$1"
|
|
846
|
+
local model="$2"
|
|
847
|
+
local error="${3:-}"
|
|
848
|
+
|
|
849
|
+
jq -n \
|
|
850
|
+
--argjson latency "$latency" \
|
|
851
|
+
--arg model "$model" \
|
|
852
|
+
--arg timestamp "$(date -Iseconds)" \
|
|
853
|
+
--arg error "$error" \
|
|
854
|
+
'{tokens_used: 0, latency_ms: $latency, model_version: $model, timestamp: $timestamp} + (if $error != "" then {error: $error} else {} end)'
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
# Build a complete structured response JSON
|
|
858
|
+
# Usage: build_structured_response <consultant> <model> <persona> <inner_json> <latency_ms>
|
|
859
|
+
build_structured_response() {
|
|
860
|
+
local consultant="$1"
|
|
861
|
+
local model="$2"
|
|
862
|
+
local persona="$3"
|
|
863
|
+
local inner_json="$4"
|
|
864
|
+
local latency="$5"
|
|
865
|
+
|
|
866
|
+
jq -n \
|
|
867
|
+
--arg consultant "$consultant" \
|
|
868
|
+
--arg model "$model" \
|
|
869
|
+
--arg persona "$persona" \
|
|
870
|
+
--argjson inner "$inner_json" \
|
|
871
|
+
--argjson metadata "$(build_response_metadata "$latency" "$model")" \
|
|
872
|
+
'{consultant: $consultant, model: $model, persona: $persona, response: $inner.response, confidence: $inner.confidence, metadata: $metadata}'
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
# Build a fallback response from unstructured text
|
|
876
|
+
# Usage: build_fallback_response <consultant> <model> <persona> <response_text> <latency_ms>
|
|
877
|
+
build_fallback_response() {
|
|
878
|
+
local consultant="$1"
|
|
879
|
+
local model="$2"
|
|
880
|
+
local persona="$3"
|
|
881
|
+
local response_text="$4"
|
|
882
|
+
local latency="$5"
|
|
883
|
+
|
|
884
|
+
jq -n \
|
|
885
|
+
--arg consultant "$consultant" \
|
|
886
|
+
--arg model "$model" \
|
|
887
|
+
--arg persona "$persona" \
|
|
888
|
+
--arg response "$response_text" \
|
|
889
|
+
--argjson metadata "$(build_response_metadata "$latency" "$model")" \
|
|
890
|
+
'{consultant: $consultant, model: $model, persona: $persona,
|
|
891
|
+
response: {summary: "Unstructured response - see detailed", detailed: $response, approach: "unknown", pros: [], cons: [], caveats: ["Unstructured output from consultant"]},
|
|
892
|
+
confidence: {score: 5, reasoning: "Confidence not provided by consultant", uncertainty_factors: ["Non-standard response format"]},
|
|
893
|
+
metadata: $metadata}'
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
# Build an error response
|
|
897
|
+
# Usage: build_error_response <consultant> <model> <persona> <error_msg> <latency_ms>
|
|
898
|
+
build_error_response() {
|
|
899
|
+
local consultant="$1"
|
|
900
|
+
local model="$2"
|
|
901
|
+
local persona="$3"
|
|
902
|
+
local error_msg="$4"
|
|
903
|
+
local latency="$5"
|
|
904
|
+
|
|
905
|
+
jq -n \
|
|
906
|
+
--arg consultant "$consultant" \
|
|
907
|
+
--arg model "$model" \
|
|
908
|
+
--arg persona "$persona" \
|
|
909
|
+
--arg error "$error_msg" \
|
|
910
|
+
--argjson metadata "$(build_response_metadata "$latency" "$model" "$error_msg")" \
|
|
911
|
+
'{consultant: $consultant, model: $model, persona: $persona,
|
|
912
|
+
response: {summary: "ERROR: Consultation failed", detailed: $error, approach: "error", pros: [], cons: [], caveats: []},
|
|
913
|
+
confidence: {score: 0, reasoning: "Consultation failed", uncertainty_factors: ["Execution error"]},
|
|
914
|
+
metadata: $metadata}'
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
# Process consultant response and write to output file
|
|
918
|
+
# This encapsulates the common post-processing pattern found in all query scripts
|
|
919
|
+
# Usage: process_consultant_response <consultant> <model> <persona> <temp_output> <output_file> <exit_code> <latency_ms> [native_json_field]
|
|
920
|
+
# Parameters:
|
|
921
|
+
# consultant - Consultant name (e.g., "Gemini")
|
|
922
|
+
# model - Model used (e.g., "gemini-3.1-pro-preview")
|
|
923
|
+
# persona - Persona name (e.g., "The Architect")
|
|
924
|
+
# temp_output - Path to temporary output file from CLI/API
|
|
925
|
+
# output_file - Path to final output file
|
|
926
|
+
# exit_code - Exit code from CLI/API call
|
|
927
|
+
# latency_ms - Latency in milliseconds
|
|
928
|
+
# native_json_field - Optional field name to extract from native JSON (e.g., "response" for Gemini)
|
|
929
|
+
# Returns: The same exit code passed in
|
|
930
|
+
process_consultant_response() {
|
|
931
|
+
local consultant="$1"
|
|
932
|
+
local model="$2"
|
|
933
|
+
local persona="$3"
|
|
934
|
+
local temp_output="$4"
|
|
935
|
+
local output_file="$5"
|
|
936
|
+
local exit_code="$6"
|
|
937
|
+
local latency_ms="$7"
|
|
938
|
+
local native_json_field="${8:-}"
|
|
939
|
+
|
|
940
|
+
if [[ $exit_code -eq 0 && -f "$temp_output" && -s "$temp_output" ]]; then
|
|
941
|
+
local raw_response inner_response
|
|
942
|
+
raw_response=$(cat "$temp_output")
|
|
943
|
+
|
|
944
|
+
# Try to extract from native JSON format if field specified
|
|
945
|
+
if [[ -n "$native_json_field" ]] && echo "$raw_response" | jq -e ".$native_json_field" > /dev/null 2>&1; then
|
|
946
|
+
inner_response=$(echo "$raw_response" | jq -r ".$native_json_field")
|
|
947
|
+
else
|
|
948
|
+
inner_response="$raw_response"
|
|
949
|
+
fi
|
|
950
|
+
|
|
951
|
+
rm -f "$temp_output"
|
|
952
|
+
|
|
953
|
+
# Use shared helpers for response building
|
|
954
|
+
if echo "$inner_response" | jq -e '.response.summary' > /dev/null 2>&1; then
|
|
955
|
+
build_structured_response "$consultant" "$model" "$persona" "$inner_response" "$latency_ms" > "$output_file"
|
|
956
|
+
else
|
|
957
|
+
build_fallback_response "$consultant" "$model" "$persona" "$inner_response" "$latency_ms" > "$output_file"
|
|
958
|
+
fi
|
|
959
|
+
else
|
|
960
|
+
rm -f "$temp_output"
|
|
961
|
+
build_error_response "$consultant" "$model" "$persona" "Query failed with exit code $exit_code" "$latency_ms" > "$output_file"
|
|
962
|
+
fi
|
|
963
|
+
|
|
964
|
+
return $exit_code
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
# Check if a consultant is enabled
|
|
968
|
+
# Usage: is_consultant_enabled <consultant_name>
|
|
969
|
+
# Returns: 0 if enabled, 1 if disabled
|
|
970
|
+
is_consultant_enabled() {
|
|
971
|
+
local name="$1"
|
|
972
|
+
local name_upper
|
|
973
|
+
name_upper=$(to_upper "$name")
|
|
974
|
+
local var_name="ENABLE_${name_upper}"
|
|
975
|
+
|
|
976
|
+
# Get default based on consultant type
|
|
977
|
+
local default="true"
|
|
978
|
+
case "$name_upper" in
|
|
979
|
+
AIDER|AMP|KIMI|CLAUDE|QWEN3|GLM|GROK|DEEPSEEK|MINIMAX|OLLAMA)
|
|
980
|
+
default="false"
|
|
981
|
+
;;
|
|
982
|
+
esac
|
|
983
|
+
|
|
984
|
+
[[ "${!var_name:-$default}" == "true" ]]
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
# Get list of enabled consultants
|
|
988
|
+
# Usage: get_enabled_consultants
|
|
989
|
+
# Output: Space-separated list of enabled consultant names
|
|
990
|
+
get_enabled_consultants() {
|
|
991
|
+
local enabled=()
|
|
992
|
+
for consultant in "${ALL_CONSULTANTS[@]}"; do
|
|
993
|
+
if is_consultant_enabled "$consultant"; then
|
|
994
|
+
enabled+=("$consultant")
|
|
995
|
+
fi
|
|
996
|
+
done
|
|
997
|
+
echo "${enabled[@]}"
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
# =============================================================================
|
|
1001
|
+
# SYNTHESIS CLI RESOLUTION (v2.10.1)
|
|
1002
|
+
# =============================================================================
|
|
1003
|
+
|
|
1004
|
+
# Map a canonical consultant name (from get_self_consultant_name) to its CLI command name.
|
|
1005
|
+
# Usage: cli=$(_consultant_to_cli "CLAUDE")
|
|
1006
|
+
_consultant_to_cli() {
|
|
1007
|
+
case "$1" in
|
|
1008
|
+
CLAUDE) echo "claude" ;;
|
|
1009
|
+
GEMINI) echo "gemini" ;;
|
|
1010
|
+
CODEX) echo "codex" ;;
|
|
1011
|
+
MISTRAL) echo "mistral" ;;
|
|
1012
|
+
KILO) echo "kilo" ;;
|
|
1013
|
+
AMP) echo "amp" ;;
|
|
1014
|
+
KIMI) echo "kimi" ;;
|
|
1015
|
+
QWEN3) echo "qwen" ;;
|
|
1016
|
+
CURSOR) echo "cursor" ;;
|
|
1017
|
+
AIDER) echo "aider" ;;
|
|
1018
|
+
*) echo "" ;;
|
|
1019
|
+
esac
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
# Resolve which CLI to use for synthesis.
|
|
1023
|
+
# Avoids using the invoking agent's CLI (self-consultation prevention).
|
|
1024
|
+
# Walks a fallback chain: configured SYNTHESIS_CMD → gemini → codex → claude → ollama
|
|
1025
|
+
#
|
|
1026
|
+
# Usage: SYNTH_CLI=$(resolve_synthesis_cli)
|
|
1027
|
+
# Returns: CLI type name (gemini, codex, claude, ollama) or empty string on failure
|
|
1028
|
+
resolve_synthesis_cli() {
|
|
1029
|
+
# Reuse get_self_consultant_name for complete alias coverage
|
|
1030
|
+
local avoid_cmd
|
|
1031
|
+
avoid_cmd=$(_consultant_to_cli "$(get_self_consultant_name)")
|
|
1032
|
+
|
|
1033
|
+
# 1. Check if SYNTHESIS_CMD is configured and is not the invoking agent
|
|
1034
|
+
local configured="${SYNTHESIS_CMD:-}"
|
|
1035
|
+
if [[ -n "$configured" && "$configured" != "$avoid_cmd" ]]; then
|
|
1036
|
+
if command -v "$configured" &>/dev/null; then
|
|
1037
|
+
echo "$configured"
|
|
1038
|
+
return 0
|
|
1039
|
+
fi
|
|
1040
|
+
fi
|
|
1041
|
+
|
|
1042
|
+
# 2. Walk fallback chain: pick first available CLI that isn't the invoking agent
|
|
1043
|
+
local candidate cmd
|
|
1044
|
+
for candidate in gemini codex claude ollama; do
|
|
1045
|
+
[[ "$candidate" == "$avoid_cmd" ]] && continue
|
|
1046
|
+
case "$candidate" in
|
|
1047
|
+
gemini) cmd="${GEMINI_CMD:-gemini}" ;;
|
|
1048
|
+
codex) cmd="${CODEX_CMD:-codex}" ;;
|
|
1049
|
+
claude) cmd="${CLAUDE_CMD:-claude}" ;;
|
|
1050
|
+
ollama) cmd="ollama" ;;
|
|
1051
|
+
esac
|
|
1052
|
+
if command -v "$cmd" &>/dev/null; then
|
|
1053
|
+
echo "$candidate"
|
|
1054
|
+
return 0
|
|
1055
|
+
fi
|
|
1056
|
+
done
|
|
1057
|
+
|
|
1058
|
+
echo ""
|
|
1059
|
+
return 1
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
# Build synthesis command arguments for a resolved CLI type.
|
|
1063
|
+
# Sets the global SYNTHESIS_ARGS array directly (no eval needed).
|
|
1064
|
+
# Usage: build_synthesis_args <cli_type>
|
|
1065
|
+
build_synthesis_args() {
|
|
1066
|
+
local cli_type="$1"
|
|
1067
|
+
|
|
1068
|
+
case "$cli_type" in
|
|
1069
|
+
claude)
|
|
1070
|
+
SYNTHESIS_ARGS=("${CLAUDE_CMD:-claude}" "--print")
|
|
1071
|
+
local model="${SYNTHESIS_MODEL:-${CLAUDE_MODEL:-}}"
|
|
1072
|
+
[[ -n "$model" ]] && SYNTHESIS_ARGS+=("--model" "$model")
|
|
1073
|
+
;;
|
|
1074
|
+
gemini)
|
|
1075
|
+
SYNTHESIS_ARGS=("${GEMINI_CMD:-gemini}")
|
|
1076
|
+
;;
|
|
1077
|
+
codex)
|
|
1078
|
+
SYNTHESIS_ARGS=("${CODEX_CMD:-codex}" "--quiet" "--full-auto")
|
|
1079
|
+
;;
|
|
1080
|
+
ollama)
|
|
1081
|
+
SYNTHESIS_ARGS=("ollama" "run" "${OLLAMA_MODEL:-llama3.2}")
|
|
1082
|
+
;;
|
|
1083
|
+
*)
|
|
1084
|
+
log_warn "Unknown synthesis CLI type: $cli_type"
|
|
1085
|
+
return 1
|
|
1086
|
+
;;
|
|
1087
|
+
esac
|
|
1088
|
+
}
|