shithub: ai

Download patch

ref: 974a684109c1333eccd8c89d519ea869ceec1836
author: Jean-André Santoni <jean.andre.santoni@gmail.com>
date: Thu Mar 19 09:15:35 EDT 2026

initial commit

--- /dev/null
+++ b/ai
@@ -1,0 +1,650 @@
+#!/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
+	}
+}
--