@zachjxyz/moxie 0.5.0 → 0.6.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/bin/moxie +5 -1
- package/lib/agents.sh +140 -201
- package/lib/gateway-agent.mjs +58 -6
- package/lib/phases.sh +111 -348
- package/lib/select-agents.mjs +417 -0
- package/lib/select-gateway.mjs +243 -0
- package/package.json +1 -1
package/bin/moxie
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
set -euo pipefail
|
|
19
19
|
|
|
20
|
-
MOXIE_VERSION="0.
|
|
20
|
+
MOXIE_VERSION="0.6.1"
|
|
21
21
|
# Resolve symlinks (npm installs bin as a symlink)
|
|
22
22
|
_self="$0"
|
|
23
23
|
while [ -L "$_self" ]; do
|
|
@@ -50,6 +50,7 @@ case "$COMMAND" in
|
|
|
50
50
|
cost) cmd_cost "$@" ;;
|
|
51
51
|
logs) cmd_logs "$@" ;;
|
|
52
52
|
models) cmd_models "$@" ;;
|
|
53
|
+
key) cmd_key "$@" ;;
|
|
53
54
|
agents) cmd_agents "$@" ;;
|
|
54
55
|
doctor) cmd_doctor "$@" ;;
|
|
55
56
|
version) echo "moxie $MOXIE_VERSION" ;;
|
|
@@ -66,6 +67,7 @@ Commands:
|
|
|
66
67
|
cost Token usage breakdown by phase and agent
|
|
67
68
|
logs Tail or view logs for a phase
|
|
68
69
|
models List available AI Gateway models
|
|
70
|
+
key Manage AI Gateway API key (set, status, remove)
|
|
69
71
|
agents List configured agents (agents swap to change models)
|
|
70
72
|
doctor Check agent CLIs, auth, and project health
|
|
71
73
|
version Print version
|
|
@@ -86,6 +88,8 @@ Usage:
|
|
|
86
88
|
moxie models List all AI Gateway models
|
|
87
89
|
moxie models -f anthropic Filter models by provider/name
|
|
88
90
|
moxie agents swap Add/remove/swap gateway models
|
|
91
|
+
moxie key set Set or update AI Gateway API key
|
|
92
|
+
moxie key Check key status
|
|
89
93
|
moxie doctor Check agents and dependencies
|
|
90
94
|
|
|
91
95
|
Pipeline: rfc → audit → fix → plan → build
|
package/lib/agents.sh
CHANGED
|
@@ -368,21 +368,23 @@ print('1' if d.get('$name', False) else '0')
|
|
|
368
368
|
# Persist degradation state to disk (called after degrading an agent)
|
|
369
369
|
_save_degraded() {
|
|
370
370
|
local deg_file="$MOXIE_DIR/degraded.json"
|
|
371
|
+
local deg_tmp="${deg_file}.tmp"
|
|
371
372
|
python3 -c "
|
|
372
|
-
import json
|
|
373
|
+
import json, sys
|
|
373
374
|
existing = {}
|
|
374
375
|
try:
|
|
375
376
|
with open('$deg_file') as f:
|
|
376
377
|
existing = json.load(f)
|
|
377
378
|
except: pass
|
|
378
379
|
names = '${AGENT_NAMES[*]}'.split()
|
|
380
|
+
degraded_flags = '${AGENT_DEGRADED[*]}'.split()
|
|
379
381
|
for i, name in enumerate(names):
|
|
380
|
-
degraded_flags = '${AGENT_DEGRADED[*]}'.split()
|
|
381
382
|
if i < len(degraded_flags) and degraded_flags[i] == '1':
|
|
382
383
|
existing[name] = True
|
|
383
|
-
with open('$
|
|
384
|
+
with open('$deg_tmp', 'w') as f:
|
|
384
385
|
json.dump(existing, f, indent=2)
|
|
385
|
-
" 2
|
|
386
|
+
" || { echo "WARNING: Failed to save degradation state" >&2; return; }
|
|
387
|
+
mv "$deg_tmp" "$deg_file"
|
|
386
388
|
}
|
|
387
389
|
|
|
388
390
|
# Returns 0 if the agent is degraded, 1 otherwise.
|
|
@@ -398,10 +400,11 @@ is_agent_degraded() {
|
|
|
398
400
|
}
|
|
399
401
|
|
|
400
402
|
# Record a productive or non-productive turn for an agent.
|
|
401
|
-
# Usage: _record_turn_health <agent_name> <logfile>
|
|
403
|
+
# Usage: _record_turn_health <agent_name> <logfile> [exit_code]
|
|
402
404
|
_record_turn_health() {
|
|
403
405
|
local name="$1"
|
|
404
406
|
local logfile="$2"
|
|
407
|
+
local exit_code="${3:-0}"
|
|
405
408
|
|
|
406
409
|
local idx=-1
|
|
407
410
|
for i in "${!AGENT_NAMES[@]}"; do
|
|
@@ -419,9 +422,13 @@ _record_turn_health() {
|
|
|
419
422
|
log_size=$(wc -c < "$logfile" | tr -d ' ')
|
|
420
423
|
fi
|
|
421
424
|
|
|
422
|
-
# Check for known failure signatures
|
|
425
|
+
# Check for known failure signatures
|
|
423
426
|
local is_failure=0
|
|
424
|
-
if [ "$
|
|
427
|
+
if [ "$exit_code" = "124" ]; then
|
|
428
|
+
is_failure=1 # timeout
|
|
429
|
+
elif [ "$exit_code" != "0" ] && [ "$log_size" -lt 1024 ]; then
|
|
430
|
+
is_failure=1 # non-zero exit with minimal output
|
|
431
|
+
elif [ "$log_size" -lt 1024 ]; then
|
|
425
432
|
is_failure=1
|
|
426
433
|
elif grep -qE '^\[TIMEOUT\]' "$logfile" 2>/dev/null; then
|
|
427
434
|
is_failure=1
|
|
@@ -526,15 +533,20 @@ dispatch_logged() {
|
|
|
526
533
|
return 0
|
|
527
534
|
fi
|
|
528
535
|
|
|
529
|
-
|
|
536
|
+
# Capture exit code through the pipe (tee masks it)
|
|
537
|
+
local rc_file
|
|
538
|
+
rc_file=$(mktemp "${TMPDIR:-/tmp}/moxie-rc.XXXXXX")
|
|
539
|
+
( dispatch_agent "$agent" "$prompt_file" "$timeout_secs" "$phase" 2>&1; echo $? > "$rc_file" ) | tee "$logfile"
|
|
540
|
+
local dispatch_rc=0
|
|
541
|
+
[ -f "$rc_file" ] && dispatch_rc=$(cat "$rc_file") && rm -f "$rc_file"
|
|
530
542
|
|
|
531
543
|
local tokens
|
|
532
544
|
tokens=$(extract_tokens "$logfile" "$agent")
|
|
533
545
|
record_tokens "$csv_file" "$turn" "$agent" "$ts" "$tokens" "$phase"
|
|
534
546
|
echo " Tokens: $tokens"
|
|
535
547
|
|
|
536
|
-
# Track agent health based on output
|
|
537
|
-
_record_turn_health "$agent" "$logfile" || return $?
|
|
548
|
+
# Track agent health based on output and exit code
|
|
549
|
+
_record_turn_health "$agent" "$logfile" "$dispatch_rc" || return $?
|
|
538
550
|
}
|
|
539
551
|
|
|
540
552
|
# ---- Rewrite agents section of config.toml ----
|
|
@@ -612,8 +624,11 @@ if '[settings]' in cleaned:
|
|
|
612
624
|
else:
|
|
613
625
|
cleaned = cleaned.rstrip() + '\n\n' + agents_block
|
|
614
626
|
|
|
615
|
-
|
|
627
|
+
tmp_path = config_path + '.tmp'
|
|
628
|
+
with open(tmp_path, 'w') as f:
|
|
616
629
|
f.write(cleaned)
|
|
630
|
+
import os
|
|
631
|
+
os.rename(tmp_path, config_path)
|
|
617
632
|
" "$MOXIE_CONFIG" \
|
|
618
633
|
"$(IFS='|'; echo "${cli_agents[*]}")" \
|
|
619
634
|
"$(IFS='|'; echo "${cli_cmds[*]}")" \
|
|
@@ -621,6 +636,76 @@ with open(config_path, 'w') as f:
|
|
|
621
636
|
"$(IFS='|'; echo "${new_gw_models[*]}")"
|
|
622
637
|
}
|
|
623
638
|
|
|
639
|
+
# ---- Manage AI Gateway API key ----
|
|
640
|
+
|
|
641
|
+
cmd_key() {
|
|
642
|
+
local subcmd="${1:-status}"
|
|
643
|
+
shift 2>/dev/null || true
|
|
644
|
+
|
|
645
|
+
case "$subcmd" in
|
|
646
|
+
set|update|add)
|
|
647
|
+
gateway_store_key "vercel-ai-gateway"
|
|
648
|
+
;;
|
|
649
|
+
status|check)
|
|
650
|
+
if gateway_has_key "vercel-ai-gateway"; then
|
|
651
|
+
echo "AI Gateway key: configured"
|
|
652
|
+
if [ "$MOXIE_PLATFORM" = "darwin" ]; then
|
|
653
|
+
echo " Storage: macOS Keychain"
|
|
654
|
+
elif _is_wsl; then
|
|
655
|
+
echo " Storage: Windows User environment"
|
|
656
|
+
else
|
|
657
|
+
echo " Storage: ~/.moxie/vercel-ai-gateway.key.enc"
|
|
658
|
+
fi
|
|
659
|
+
else
|
|
660
|
+
echo "AI Gateway key: not configured"
|
|
661
|
+
echo ""
|
|
662
|
+
echo "Set one with: moxie key set"
|
|
663
|
+
fi
|
|
664
|
+
;;
|
|
665
|
+
remove|delete)
|
|
666
|
+
if [ "$MOXIE_PLATFORM" = "darwin" ]; then
|
|
667
|
+
security delete-generic-password -a moxie -s "vercel-ai-gateway" 2>/dev/null && \
|
|
668
|
+
echo "Key removed from macOS Keychain." || \
|
|
669
|
+
echo "No key found to remove."
|
|
670
|
+
elif _is_wsl; then
|
|
671
|
+
powershell.exe -Command "[System.Environment]::SetEnvironmentVariable('MOXIE_GATEWAY_KEY_vercel-ai-gateway', \$null, 'User')" 2>/dev/null && \
|
|
672
|
+
echo "Key removed from Windows environment." || \
|
|
673
|
+
echo "No key found to remove."
|
|
674
|
+
elif [ "$MOXIE_PLATFORM" = "linux" ]; then
|
|
675
|
+
local enc_file="$HOME/.moxie/vercel-ai-gateway.key.enc"
|
|
676
|
+
if [ -f "$enc_file" ]; then
|
|
677
|
+
rm -f "$enc_file"
|
|
678
|
+
echo "Key removed: $enc_file"
|
|
679
|
+
else
|
|
680
|
+
echo "No key found to remove."
|
|
681
|
+
fi
|
|
682
|
+
fi
|
|
683
|
+
;;
|
|
684
|
+
help|--help|-h)
|
|
685
|
+
cat <<'EOF'
|
|
686
|
+
Usage: moxie key [subcommand]
|
|
687
|
+
|
|
688
|
+
Manage your Vercel AI Gateway API key.
|
|
689
|
+
|
|
690
|
+
Subcommands:
|
|
691
|
+
set Set or update the API key
|
|
692
|
+
status Check if a key is configured (default)
|
|
693
|
+
remove Delete the stored key
|
|
694
|
+
|
|
695
|
+
Examples:
|
|
696
|
+
moxie key set Enter a new API key
|
|
697
|
+
moxie key Check current key status
|
|
698
|
+
moxie key remove Delete stored key
|
|
699
|
+
EOF
|
|
700
|
+
;;
|
|
701
|
+
*)
|
|
702
|
+
echo "Unknown subcommand: $subcmd" >&2
|
|
703
|
+
echo "Run 'moxie key help' for usage." >&2
|
|
704
|
+
return 1
|
|
705
|
+
;;
|
|
706
|
+
esac
|
|
707
|
+
}
|
|
708
|
+
|
|
624
709
|
# ---- List agents ----
|
|
625
710
|
|
|
626
711
|
cmd_agents() {
|
|
@@ -733,205 +818,59 @@ _cmd_agents_swap() {
|
|
|
733
818
|
[ -n "$cfg_ep" ] && endpoint="$cfg_ep"
|
|
734
819
|
fi
|
|
735
820
|
|
|
736
|
-
#
|
|
737
|
-
|
|
738
|
-
local
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
821
|
+
# Build current models JSON
|
|
822
|
+
local current_json="["
|
|
823
|
+
local first=1
|
|
824
|
+
if [ ${#current_gw_models[@]} -gt 0 ]; then
|
|
825
|
+
for cm in "${current_gw_models[@]}"; do
|
|
826
|
+
[ "$first" = "0" ] && current_json="${current_json},"
|
|
827
|
+
first=0
|
|
828
|
+
current_json="${current_json}\"$cm\""
|
|
829
|
+
done
|
|
745
830
|
fi
|
|
831
|
+
current_json="${current_json}]"
|
|
832
|
+
|
|
833
|
+
# Run the Node.js swap picker
|
|
834
|
+
local swap_output=""
|
|
835
|
+
swap_output=$(
|
|
836
|
+
GATEWAY_API_KEY="$api_key" \
|
|
837
|
+
GATEWAY_ENDPOINT="$endpoint" \
|
|
838
|
+
MOXIE_CURRENT_MODELS="$current_json" \
|
|
839
|
+
node "$MOXIE_LIB/select-gateway.mjs" < /dev/tty 2>/dev/tty
|
|
840
|
+
) || {
|
|
841
|
+
echo "ERROR: Model selection failed." >&2
|
|
842
|
+
return 1
|
|
843
|
+
}
|
|
746
844
|
|
|
747
|
-
# Parse
|
|
748
|
-
local
|
|
749
|
-
local
|
|
845
|
+
# Parse result
|
|
846
|
+
local cancelled=""
|
|
847
|
+
local new_gw_names=()
|
|
848
|
+
local new_gw_models=()
|
|
750
849
|
eval "$(python3 -c "
|
|
751
850
|
import json, sys, shlex
|
|
752
851
|
data = json.loads(sys.stdin.read())
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
print('
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
_matched=1
|
|
772
|
-
break
|
|
773
|
-
fi
|
|
774
|
-
done
|
|
775
|
-
sselected+=("$_matched")
|
|
776
|
-
done
|
|
777
|
-
|
|
778
|
-
# Interactive search TUI (same as init picker)
|
|
779
|
-
local search=""
|
|
780
|
-
local scursor=0
|
|
781
|
-
local max_visible=15
|
|
782
|
-
|
|
783
|
-
_swap_render() {
|
|
784
|
-
local filtered=()
|
|
785
|
-
local lc_search
|
|
786
|
-
lc_search=$(echo "$search" | tr '[:upper:]' '[:lower:]')
|
|
787
|
-
for (( i = 0; i < ${#all_model_ids[@]}; i++ )); do
|
|
788
|
-
if [ -z "$search" ]; then
|
|
789
|
-
filtered+=("$i")
|
|
790
|
-
else
|
|
791
|
-
local lc_id
|
|
792
|
-
lc_id=$(echo "${all_model_ids[$i]}" | tr '[:upper:]' '[:lower:]')
|
|
793
|
-
[[ "$lc_id" == *"$lc_search"* ]] && filtered+=("$i")
|
|
794
|
-
fi
|
|
795
|
-
done
|
|
796
|
-
|
|
797
|
-
local fcount=${#filtered[@]}
|
|
798
|
-
[ "$scursor" -ge "$fcount" ] && scursor=$(( fcount > 0 ? fcount - 1 : 0 ))
|
|
799
|
-
[ "$scursor" -lt 0 ] && scursor=0
|
|
800
|
-
|
|
801
|
-
local vstart=0
|
|
802
|
-
if [ "$scursor" -ge "$max_visible" ]; then
|
|
803
|
-
vstart=$(( scursor - max_visible + 1 ))
|
|
804
|
-
fi
|
|
805
|
-
local vend=$(( vstart + max_visible ))
|
|
806
|
-
[ "$vend" -gt "$fcount" ] && vend=$fcount
|
|
807
|
-
|
|
808
|
-
local sel_count=0
|
|
809
|
-
for (( i = 0; i < ${#sselected[@]}; i++ )); do
|
|
810
|
-
[ "${sselected[$i]}" = "1" ] && sel_count=$(( sel_count + 1 ))
|
|
811
|
-
done
|
|
812
|
-
|
|
813
|
-
printf "\\r\\033[K \\033[1mSwap gateway models\\033[0m (%d available, %d selected)\\n" "$fcount" "$sel_count" >&2
|
|
814
|
-
printf "\\r\\033[K Search: \\033[36m%s\\033[0m\\033[2m|\\033[0m\\n\\n" "$search" >&2
|
|
815
|
-
|
|
816
|
-
local rendered=0
|
|
817
|
-
for (( vi = vstart; vi < vend; vi++ )); do
|
|
818
|
-
local ri=${filtered[$vi]}
|
|
819
|
-
local mid="${all_model_ids[$ri]}"
|
|
820
|
-
local marker=" "
|
|
821
|
-
[ "$vi" -eq "$scursor" ] && marker="> "
|
|
822
|
-
local check="[ ]"
|
|
823
|
-
[ "${sselected[$ri]}" = "1" ] && check="[x]"
|
|
824
|
-
printf "\\r\\033[K %s%s %s\\n" "$marker" "$check" "$mid" >&2
|
|
825
|
-
rendered=$(( rendered + 1 ))
|
|
826
|
-
done
|
|
827
|
-
|
|
828
|
-
while [ "$rendered" -lt "$max_visible" ]; do
|
|
829
|
-
printf "\\r\\033[K\\n" >&2
|
|
830
|
-
rendered=$(( rendered + 1 ))
|
|
831
|
-
done
|
|
832
|
-
|
|
833
|
-
if [ "$vstart" -gt 0 ] || [ "$vend" -lt "$fcount" ]; then
|
|
834
|
-
printf "\\r\\033[K \\033[2m(%d-%d of %d · scroll for more)\\033[0m\\n" "$(( vstart + 1 ))" "$vend" "$fcount" >&2
|
|
835
|
-
else
|
|
836
|
-
printf "\\r\\033[K\\n" >&2
|
|
837
|
-
fi
|
|
838
|
-
|
|
839
|
-
printf "\\r\\033[K \\033[2m↑↓ navigate · space select · enter confirm · esc cancel · type to filter\\033[0m" >&2
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
local swap_lines=$(( max_visible + 5 ))
|
|
843
|
-
|
|
844
|
-
printf "\\033[?25l" >&2
|
|
845
|
-
local old_stty
|
|
846
|
-
old_stty=$(stty -g < /dev/tty 2>/dev/null)
|
|
847
|
-
stty -echo -icanon min 1 < /dev/tty 2>/dev/null
|
|
848
|
-
|
|
849
|
-
_swap_cleanup() {
|
|
850
|
-
printf "\\033[?25h" >&2
|
|
851
|
-
stty "$old_stty" < /dev/tty 2>/dev/null
|
|
852
|
-
}
|
|
853
|
-
trap '_swap_cleanup' INT TERM
|
|
854
|
-
|
|
855
|
-
_swap_render
|
|
856
|
-
|
|
857
|
-
local swap_done=0
|
|
858
|
-
local swap_cancelled=0
|
|
859
|
-
while [ "$swap_done" = "0" ]; do
|
|
860
|
-
local skey
|
|
861
|
-
skey=$(dd bs=1 count=1 2>/dev/null < /dev/tty) || break
|
|
862
|
-
|
|
863
|
-
if [ "$skey" = $'\033' ]; then
|
|
864
|
-
local sbracket sdir
|
|
865
|
-
sbracket=$(dd bs=1 count=1 2>/dev/null < /dev/tty) || true
|
|
866
|
-
sdir=$(dd bs=1 count=1 2>/dev/null < /dev/tty) || true
|
|
867
|
-
if [ "$sbracket" = "[" ]; then
|
|
868
|
-
case "$sdir" in
|
|
869
|
-
A) scursor=$(( scursor - 1 )); [ "$scursor" -lt 0 ] && scursor=0 ;;
|
|
870
|
-
B) scursor=$(( scursor + 1 )) ;;
|
|
871
|
-
esac
|
|
872
|
-
else
|
|
873
|
-
swap_cancelled=1
|
|
874
|
-
swap_done=1
|
|
875
|
-
fi
|
|
876
|
-
elif [ "$skey" = " " ]; then
|
|
877
|
-
local ft=()
|
|
878
|
-
local lcs
|
|
879
|
-
lcs=$(echo "$search" | tr '[:upper:]' '[:lower:]')
|
|
880
|
-
for (( i = 0; i < ${#all_model_ids[@]}; i++ )); do
|
|
881
|
-
if [ -z "$search" ]; then
|
|
882
|
-
ft+=("$i")
|
|
883
|
-
else
|
|
884
|
-
local lci
|
|
885
|
-
lci=$(echo "${all_model_ids[$i]}" | tr '[:upper:]' '[:lower:]')
|
|
886
|
-
[[ "$lci" == *"$lcs"* ]] && ft+=("$i")
|
|
887
|
-
fi
|
|
888
|
-
done
|
|
889
|
-
if [ "$scursor" -lt "${#ft[@]}" ]; then
|
|
890
|
-
local tidx=${ft[$scursor]}
|
|
891
|
-
[ "${sselected[$tidx]}" = "0" ] && sselected[$tidx]=1 || sselected[$tidx]=0
|
|
892
|
-
fi
|
|
893
|
-
elif [ "$skey" = "" ]; then
|
|
894
|
-
swap_done=1
|
|
895
|
-
elif [ "$skey" = $'\177' ] || [ "$skey" = $'\010' ]; then
|
|
896
|
-
if [ -n "$search" ]; then
|
|
897
|
-
search="${search%?}"
|
|
898
|
-
scursor=0
|
|
899
|
-
fi
|
|
900
|
-
else
|
|
901
|
-
if [[ "$skey" =~ [[:print:]] ]]; then
|
|
902
|
-
search="${search}${skey}"
|
|
903
|
-
scursor=0
|
|
904
|
-
fi
|
|
905
|
-
fi
|
|
906
|
-
|
|
907
|
-
printf "\\033[%dA" "$swap_lines" >&2
|
|
908
|
-
_swap_render
|
|
909
|
-
done
|
|
910
|
-
|
|
911
|
-
_swap_cleanup
|
|
912
|
-
trap - INT TERM
|
|
913
|
-
printf "\\n\\n" >&2
|
|
914
|
-
|
|
915
|
-
if [ "$swap_cancelled" = "1" ]; then
|
|
852
|
+
if data.get('cancelled', False):
|
|
853
|
+
print('cancelled=1')
|
|
854
|
+
else:
|
|
855
|
+
print('cancelled=0')
|
|
856
|
+
names = []
|
|
857
|
+
models = []
|
|
858
|
+
for mid in data.get('gateway_models', []):
|
|
859
|
+
prov = mid.split('/')[0] if '/' in mid else 'unknown'
|
|
860
|
+
mname = mid.split('/', 1)[1] if '/' in mid else mid
|
|
861
|
+
slug = (prov + '-' + mname).lower().replace(' ', '-').replace('.', '-')
|
|
862
|
+
slug = ''.join(c for c in slug if c.isalnum() or c == '-') + '-gw'
|
|
863
|
+
names.append(slug)
|
|
864
|
+
models.append(mid)
|
|
865
|
+
print('new_gw_names=(' + ' '.join(shlex.quote(n) for n in names) + ')')
|
|
866
|
+
print('new_gw_models=(' + ' '.join(shlex.quote(m) for m in models) + ')')
|
|
867
|
+
" <<< "$swap_output")"
|
|
868
|
+
|
|
869
|
+
if [ "$cancelled" = "1" ]; then
|
|
916
870
|
echo "Cancelled — no changes made."
|
|
917
871
|
return 0
|
|
918
872
|
fi
|
|
919
873
|
|
|
920
|
-
# Collect selected models
|
|
921
|
-
local new_gw_names=()
|
|
922
|
-
local new_gw_models=()
|
|
923
|
-
for (( i = 0; i < ${#sselected[@]}; i++ )); do
|
|
924
|
-
[ "${sselected[$i]}" != "1" ] && continue
|
|
925
|
-
local mid="${all_model_ids[$i]}"
|
|
926
|
-
local prov="${mid%%/*}"
|
|
927
|
-
local mname="${mid#*/}"
|
|
928
|
-
local slug
|
|
929
|
-
slug=$(echo "${prov}-${mname}" | tr '[:upper:]' '[:lower:]' | tr ' .' '-' | tr -cd 'a-z0-9-')
|
|
930
|
-
slug="${slug}-gw"
|
|
931
|
-
new_gw_names+=("$slug")
|
|
932
|
-
new_gw_models+=("$mid")
|
|
933
|
-
done
|
|
934
|
-
|
|
935
874
|
local total_agents=$(( ${#cli_agents[@]} + ${#new_gw_names[@]} ))
|
|
936
875
|
if [ "$total_agents" -lt 2 ]; then
|
|
937
876
|
echo "ERROR: Need at least 2 agents total (${#cli_agents[@]} CLI + ${#new_gw_names[@]} gateway = $total_agents)." >&2
|
package/lib/gateway-agent.mjs
CHANGED
|
@@ -27,10 +27,21 @@ if (!MODEL) { process.stderr.write('ERROR: GATEWAY_MODEL not set\n'); process.ex
|
|
|
27
27
|
const prompt = process.argv[2];
|
|
28
28
|
if (!prompt) { process.stderr.write('ERROR: No prompt provided\n'); process.exit(1); }
|
|
29
29
|
|
|
30
|
+
// ---- Path safety ----
|
|
31
|
+
|
|
32
|
+
function safePath(p) {
|
|
33
|
+
const resolved = resolve(CWD, p);
|
|
34
|
+
if (resolved !== CWD && !resolved.startsWith(CWD + '/')) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return resolved;
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
// ---- Tool implementations ----
|
|
31
41
|
|
|
32
42
|
function toolReadFile(args) {
|
|
33
|
-
const filepath =
|
|
43
|
+
const filepath = safePath(args.path);
|
|
44
|
+
if (!filepath) return `Error: Path escapes project root: ${args.path}`;
|
|
34
45
|
if (!existsSync(filepath)) return `Error: File not found: ${args.path}`;
|
|
35
46
|
const content = readFileSync(filepath, 'utf8');
|
|
36
47
|
const lines = content.split('\n');
|
|
@@ -41,14 +52,16 @@ function toolReadFile(args) {
|
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
function toolWriteFile(args) {
|
|
44
|
-
const filepath =
|
|
55
|
+
const filepath = safePath(args.path);
|
|
56
|
+
if (!filepath) return `Error: Path escapes project root: ${args.path}`;
|
|
45
57
|
mkdirSync(dirname(filepath), { recursive: true });
|
|
46
58
|
writeFileSync(filepath, args.content, 'utf8');
|
|
47
59
|
return `Written ${args.content.length} bytes to ${args.path}`;
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
function toolAppendFile(args) {
|
|
51
|
-
const filepath =
|
|
63
|
+
const filepath = safePath(args.path);
|
|
64
|
+
if (!filepath) return `Error: Path escapes project root: ${args.path}`;
|
|
52
65
|
mkdirSync(dirname(filepath), { recursive: true });
|
|
53
66
|
const existing = existsSync(filepath) ? readFileSync(filepath, 'utf8') : '';
|
|
54
67
|
writeFileSync(filepath, existing + args.content, 'utf8');
|
|
@@ -56,7 +69,8 @@ function toolAppendFile(args) {
|
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
function toolEditFile(args) {
|
|
59
|
-
const filepath =
|
|
72
|
+
const filepath = safePath(args.path);
|
|
73
|
+
if (!filepath) return `Error: Path escapes project root: ${args.path}`;
|
|
60
74
|
if (!existsSync(filepath)) return `Error: File not found: ${args.path}`;
|
|
61
75
|
const content = readFileSync(filepath, 'utf8');
|
|
62
76
|
const count = content.split(args.old_string).length - 1;
|
|
@@ -90,7 +104,8 @@ function toolRunCommand(args) {
|
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
function toolListDirectory(args) {
|
|
93
|
-
const dirpath =
|
|
107
|
+
const dirpath = safePath(args.path || '.');
|
|
108
|
+
if (!dirpath) return `Error: Path escapes project root: ${args.path}`;
|
|
94
109
|
if (!existsSync(dirpath)) return `Error: Directory not found: ${args.path}`;
|
|
95
110
|
const entries = readdirSync(dirpath, { withFileTypes: true });
|
|
96
111
|
return entries.map(e => {
|
|
@@ -100,7 +115,8 @@ function toolListDirectory(args) {
|
|
|
100
115
|
}
|
|
101
116
|
|
|
102
117
|
function toolGlob(args) {
|
|
103
|
-
const base =
|
|
118
|
+
const base = safePath(args.path || '.');
|
|
119
|
+
if (!base) return `Error: Path escapes project root: ${args.path}`;
|
|
104
120
|
const regex = globToRegex(args.pattern);
|
|
105
121
|
const matches = [];
|
|
106
122
|
walkDir(base, base, regex, matches, 0);
|
|
@@ -445,6 +461,42 @@ function chatCompletion(messages) {
|
|
|
445
461
|
res.on('end', () => {
|
|
446
462
|
if (idleTimer) clearTimeout(idleTimer);
|
|
447
463
|
|
|
464
|
+
// Process any remaining data in buffer (API may not send trailing newline)
|
|
465
|
+
if (buffer.trim()) {
|
|
466
|
+
const trimmed = buffer.trim();
|
|
467
|
+
if (trimmed.startsWith('data: ')) {
|
|
468
|
+
const data = trimmed.slice(6);
|
|
469
|
+
if (data !== '[DONE]') {
|
|
470
|
+
try {
|
|
471
|
+
const parsed = JSON.parse(data);
|
|
472
|
+
if (parsed.usage) usage = parsed.usage;
|
|
473
|
+
const choice = parsed.choices && parsed.choices[0];
|
|
474
|
+
if (choice && choice.delta) {
|
|
475
|
+
if (choice.delta.content) {
|
|
476
|
+
content += choice.delta.content;
|
|
477
|
+
process.stdout.write(choice.delta.content);
|
|
478
|
+
}
|
|
479
|
+
if (choice.delta.tool_calls) {
|
|
480
|
+
for (const tc of choice.delta.tool_calls) {
|
|
481
|
+
const idx = tc.index;
|
|
482
|
+
if (!toolCalls.has(idx)) {
|
|
483
|
+
toolCalls.set(idx, { id: tc.id || '', name: '', arguments: '' });
|
|
484
|
+
}
|
|
485
|
+
const entry = toolCalls.get(idx);
|
|
486
|
+
if (tc.id) entry.id = tc.id;
|
|
487
|
+
if (tc.function) {
|
|
488
|
+
if (tc.function.name) entry.name = tc.function.name;
|
|
489
|
+
if (tc.function.arguments) entry.arguments += tc.function.arguments;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
} catch { /* malformed final chunk — skip */ }
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
buffer = '';
|
|
498
|
+
}
|
|
499
|
+
|
|
448
500
|
// Convert Map to sorted array
|
|
449
501
|
const sortedTools = [];
|
|
450
502
|
const indices = Array.from(toolCalls.keys()).sort((a, b) => a - b);
|