shithub: ai

ref: 974a684109c1333eccd8c89d519ea869ceec1836
dir: /ai/

View raw version
#!/bin/rc

rfork e

wctl=/dev/null
if(test -w /dev/wctl)
	wctl=/dev/wctl

if(~ $AI_MODEL '')
	model=gpt-4o-mini
if not
	model=$AI_MODEL

apiurl=https://api.openai.com/v1/responses
state=/tmp/ai.$pid
sessionlog=/tmp/ai.$pid.log

fn usage {
	echo 'usage: ai [-m model] [question ...]' >[1=2]
	exit usage
}

fn sigexit {
	rm -rf $state
}

fn logmsg {
	{
		echo $*
	} >>$sessionlog
}

fn logsection {
	ifs='' {label=$1}
	file=$2
	{
		echo '== '^$label^' =='
		if(test -f $file){
			if(test -s $file)
				cat $file
			if not
				echo '(empty)'
		}
		if not
			echo '(missing)'
		echo
	} >>$sessionlog
}

fn needkey {
	ifs='' {apikey=`{auth/userpasswd -n 'service=openai host=api.openai.com user=api' >[2]/dev/null | sed -n '2p'}}
	if(! ~ $apikey '')
		status=()
	if not {
		echo 'ai: add an openai token to factotum' >[1=2]
		echo 'ai: echo ''key proto=pass service=openai host=api.openai.com user=api !password=...'' >/mnt/factotum/ctl' >[1=2]
		exit apikey
	}
}

fn sysmsg {
	cat <<'!'
You are ai, a small coding assistant running from rc on 9front.

You are helping inside the user's current source tree.
The user may ask about the current tree or about the local system.
You do not have direct file access. You must use the available tools.

Available tools:

tool files
    Return a compact file listing for the current directory, with line counts.
    Output format: one line per file, as "<path> <lines>".

tool man
page <manual page name>
    Show a manual page from the local 9front system.

tool shell
cmd <plan9 rc command>
    Run one command under Plan 9 rc in the current directory.

tool search
pattern <grep pattern>
    Search files in the current directory.

tool read
path <relative file path>
start <1-based line number>
count <line count>
    Read a slice of one file.

Rules:
- Ask for at most one tool per response.
- Use only relative paths.
- Gather direct evidence before answering substantive questions.
- Prefer the tool that directly answers the user's question over indirect file inspection.
- Use repository files only when they are relevant to the user's question.
- tool shell runs Plan 9 rc, not sh or bash.
- In Plan 9 rc, $path is the command search path; `ls $path` lists available commands.
- Use Plan 9 commands and rc syntax when using tool shell.
- Use tool read to inspect implementation before making claims about behavior.
- Do not answer from file names, target names, or build metadata alone when implementation files are available.
- Do not modify files through tool shell unless the user explicitly asks for changes.
- Do not invent file contents.
- Do not request write tools; edits are not implemented yet.
- When you are ready to answer the user, stop using tools.

Output format:
- For a tool request, output exactly:
  tool files
or
  tool man
  page ...
or
  tool shell
  cmd ...
or
  tool search
  pattern ...
or
  tool read
  path ...
  start ...
  count ...
- For a final user-facing response, output exactly:
  reply
  <your response text>

Do not wrap the output in markdown fences.
!
}

fn planmsg {
	cat <<'!'
You are ai, a small coding assistant running from rc on 9front.

Available tools: files, man, shell, search, read.

Write a short execution plan for the latest user request.
Focus on the user's request, not on these instructions.
Treat the latest user request as the only task to plan for.
Choose the smallest set of relevant tools.
The latest request may be about the repository or about the local system.
Do not use tools yet.
Do not answer the user yet.

Output format:
- Output exactly:
  plan
  <1-3 short lines>

Do not wrap the output in markdown fences.
!
}

fn listfiles {
	ls
}

fn summary {
	echo 'pwd'
	pwd
	echo

	echo 'top level'
	ls | sed 120q
	echo
}

fn turnview {
	if(test -f $state/turn)
		cat $state/turn
	if not
		echo '(empty)'
}

fn mkprompt {
	{
		sysmsg
		echo
		echo 'Latest user request:'
		cat $state/user
		echo
		echo 'Repository summary:'
		summary
		echo 'Prior conversation:'
		if(test -f $state/history)
			cat $state/history
		if not
			echo '(empty)'
		echo
		echo 'Current turn work:'
		turnview
		echo
	} >$state/prompt
}

fn mkplanprompt {
	{
		planmsg
		echo
		echo 'Latest user request:'
		cat $state/user
	} >$state/prompt
}

fn mkrequest {
	ifs='' {
		modelq=`{jquote $model}
		inputq=`{jquote < $state/prompt}
	}
	echo -n '{"model":'^$modelq^',"input":'^$inputq^',"store":false}' >$state/request
}

fn httpmeta {
	phase=$1
	{
		echo 'phase '^$phase
		echo 'method POST'
		echo 'url '^$apiurl
		echo 'header Content-Type: application/json'
		echo 'header Authorization: Bearer [redacted]'
		echo 'body '^$state/request
	} >$state/http
}

fn apicall {
	phase=$1
	rm -f $state/errorbody
	httpmeta $phase
	logsection $phase^-http $state/http
	hget \
		-e $state/errorbody \
		-r 'Content-Type: application/json' \
		-r 'Authorization: Bearer '^$apikey \
		-P $apiurl \
		<$state/request >$state/response
	httpstatus=$status
	{
		if(~ $httpstatus '')
			echo 'status ok'
		if not
			echo 'status '^$"httpstatus
	} >$state/httpstatus
	logsection $phase^-http-status $state/httpstatus
	logsection $phase^-response $state/response
	if(test -f $state/errorbody)
		logsection $phase^-errorbody $state/errorbody
	status=$httpstatus
}

fn callmodel {
	mkprompt
	mkrequest
	logsection prompt $state/prompt
	logsection model-request $state/request
	echo 'thinking...' >[1=2]
	apicall model
}

fn callplan {
	mkplanprompt
	mkrequest
	logsection plan-prompt $state/prompt
	logsection plan-request $state/request
	echo 'planning...' >[1=2]
	apicall plan
}

fn extracttext {
	errtype=`{jget type error < $state/response >[2]/dev/null}
	if(~ $errtype object){
		if(jget key error < $state/response | jget key message | jget str >$state/error >[2]/dev/null)
			cat $state/error >[1=2]
		if not
			cat $state/response >[1=2]
		status=error
	}
	if not
		if(! jget key output < $state/response \
			| jget idx 0 \
			| jget key content \
			| jget idx 0 \
			| jget str text >$state/model >[2]/dev/null){
			echo 'ai: could not decode model response' >[1=2]
			cat $state/response >[1=2]
			status=decode
		}
	if not
		status=()
}

fn cleanmodel {
	sed 's/\r$//' $state/model >$state/model.clean
	mv $state/model.clean $state/model
}

fn cleantool {
	sed '/^$/d' $state/model >$state/model.clean
	mv $state/model.clean $state/model
}

fn field {
	ifs='' {value=`{grep '^'^$1^' ' $2 | sed 1q | sed 's/^[^ ]* //' | sed 's/[ 	]*$//' | sed 's/^"\(.*\)"$/\1/' | sed 's/\r$//'}}
	echo -n $value
}

fn goodpath {
	switch($1){
	case '' /* ../* */../* */.. ..
		status=badpath
	case *
		status=()
	}
}

fn toolfiles {
	for(f in `{listfiles}){
		if(test -f $f){
			n=`{wc -l < $f}
			echo $f^' '^$n
		}
	}
}

fn toolman {
	page=$1

	if(~ $page ''){
		echo 'missing page'
		status=page
	}
	if not
		man $page | sed 200q
}

fn toolshell {
	ifs=' ' {cmd=$*}

	if(~ $cmd ''){
		echo 'missing command'
		status=command
	}
	if not {
		rc -c $cmd
		cmdstatus=$status
		if(~ $cmdstatus '')
			echo 'status 0'
		if not
			echo 'status '^$"cmdstatus
		status=()
	}
}

fn toolsearch {
	ifs=' ' {pat=$*}
	files=()

	if(~ $pat ''){
		echo 'missing pattern'
		status=pattern
	}
	if not
		files=`{listfiles}

	if(~ $#files 0){
		echo 'no files'
		status=nofiles
	}
	if not
	@{
		for(f in $files)
			if(test -f $f)
				grep -n $pat $f >[2]/dev/null
	} | sed 200q
}

fn toolread {
	file=$1
	start=$2
	count=$3

	if(~ $start '')
		start=1
	if(~ $count '')
		count=120

	if(! goodpath $file){
		echo 'bad path'
		status=badpath
	}
	if not {
		if(! test -f $file){
			echo 'missing file'
			status=missing
		}
		if not {
			end=`{echo $start $count + 1 - p | dc}
			sed -n $start^','^$end^'{=;p;}' $file | sed 'N;s/\n/: /'
		}
	}
}

fn adduser {
	{
		echo '== user =='
		cat $state/user
		echo
	} >>$state/history
}

fn addtool {
	{
		echo '== tool '^$1^' =='
		cat $2
		echo
	} >>$state/turn
}

fn addreply {
	{
		echo '== assistant =='
		cat $1
		echo
	} >>$state/history
}

fn addplan {
	{
		echo '== plan =='
		cat $1
		echo
	} >>$state/turn
}

fn setuser {
	echo $* >$state/user
	rm -f $state/lasttool
	rm -f $state/plan
	rm -f $state/turn
	logsection user $state/user
}

fn planturn {
	if(! callplan)
		status=plan
	if not {
		if(! extracttext)
			status=plan
		if not {
			cleanmodel
			logsection plan-model $state/model
			kind=`{sed 1q $state/model | sed 's/[ 	].*//'}
			switch($kind){
			case plan
				sed 1d $state/model >$state/plan
			case *
				cp $state/model $state/plan
			}
			if(! test -s $state/plan)
				echo '(empty plan)' >$state/plan
			echo 'plan:' >[1=2]
			cat $state/plan >[1=2]
			addplan $state/plan
			status=()
		}
	}
}

fn runturn {
	done=no
	for(step in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32){
		if(~ $done no){
			logmsg '== round '^$step^' =='
			if(! callmodel)
				done=error
			if not {
				if(! extracttext)
					done=error
				if not {
					cleanmodel
					logsection model $state/model
					type=`{sed 1q $state/model | sed 's/[ 	].*//'}
					logmsg 'type '^$type
					switch($type){
					case reply
						sed 1d $state/model >$state/reply
						if(! test -s $state/reply)
							echo '(empty reply)' >$state/reply
						cat $state/reply
						addreply $state/reply
						done=yes

						case tool
							cleantool
							logsection tool-request $state/model
							name=`{sed 1q $state/model | sed 's/^[^ 	]*[ 	]*//'}
							logmsg 'tool '^$name
							repeat=no
							if(test -f $state/lasttool)
								if(cmp -s $state/model $state/lasttool >[2]/dev/null){
									repeat=yes
								}
							if(~ $repeat yes){
								echo 'repeat tool request ignored; choose a different tool or reply.' >$state/toolout
								addtool repeat $state/toolout
								logsection toolout $state/toolout
							}
							if(~ $repeat no){
								cp $state/model $state/lasttool
								switch($name){
								case files
									echo 'tool: files' >[1=2]
									toolfiles | sed 200q >$state/toolout >[2=1]
									addtool files $state/toolout
									logsection toolout $state/toolout

								case man
									ifs='' {page=`{field page $state/model}}
									echo 'tool: man '^$page >[1=2]
									toolman $page >$state/toolout >[2=1]
									addtool man $state/toolout
									logsection toolout $state/toolout

								case shell
									ifs='' {cmd=`{field cmd $state/model}}
									echo 'tool: shell '^$cmd >[1=2]
									toolshell $cmd >$state/toolout >[2=1]
									addtool shell $state/toolout
									logsection toolout $state/toolout

								case search
									ifs='' {pat=`{field pattern $state/model}}
									echo 'tool: search '^$pat >[1=2]
									toolsearch $pat >$state/toolout >[2=1]
									addtool search $state/toolout
									logsection toolout $state/toolout

								case read
									file=`{field path $state/model}
									start=`{field start $state/model}
									count=`{field count $state/model}
									echo -n 'tool: read ' >[1=2]
									echo -n $file >[1=2]
									echo -n ' ' >[1=2]
									echo -n $start >[1=2]
									echo -n ' ' >[1=2]
									echo $count >[1=2]
									toolread $file $start $count >$state/toolout >[2=1]
									addtool read $state/toolout
									logsection toolout $state/toolout

								case *
									cat $state/model
									addreply $state/model
									done=yes
							}
						}

					case *
						cat $state/model
						addreply $state/model
						done=yes
					}
				}
			}
		}
	}

	switch($done){
	case yes
		status=()
	case error
		status=runturn
	case *
		echo 'ai: too many tool rounds' >[1=2]
		status=toomanytools
	}
}

fn help {
	echo '/help		show this help'
	echo '/reset		clear the conversation'
	echo '/context	show the current repo summary'
	echo '/quit		exit'
}

while(! ~ $#* 0 && ~ $1 -*){
	switch($1){
	case -m
		shift
		if(~ $#* 0)
			usage
		model=$1
	case *
		usage
	}
	shift
}

mkdir $state || exit state
{
	echo 'ai log'
	echo 'pid '^$pid
	echo 'model '^$model
	echo 'pwd'
	pwd
	echo
} >$sessionlog
needkey

if(! ~ $#* 0){
	setuser $*
	adduser
	if(! planturn)
		echo 'ai: could not make plan; continuing' >[1=2]
	runturn
	exit $status
}

echo 'ai: terminal mode; /help for commands' >[1=2]
while() {
	echo -n current >$wctl
	echo -n top >$wctl
	echo -n 'ai> ' >[1=2]
	ifs='' {line=`{read}}
	if(~ $#line 0)
		exit

	switch($line){
	case /quit /exit
		exit
	case /help
		help
	case /reset
		rm -f $state/history
		echo 'conversation cleared'
	case /context
		summary
	case ''
		# ignore
	case *
		setuser $"line
		adduser
		if(! planturn)
			echo 'ai: could not make plan; continuing' >[1=2]
		runturn
	}
}