okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
6c1ba64
main
1#!/usr/bin/env bun 2// Local-only prompt review: `pull` gathers the model-facing strings from the 3// gleam sources into one editable file, `push` writes edited ones back. 4// bun scripts/prompts.ts pull [--min 50] 5// bun scripts/prompts.ts push [--dry] [--force] 6import { createHash } from "node:crypto" 7import { readdirSync , readFileSync , statSync , writeFileSync } from "node:fs" 8import { join , relative , resolve } from "node:path" 9import { spawnSync } from "node:child_process" 10 11const root = resolve ( import . meta. dir , ".." ) 12const out = join ( import . meta. dir , "prompts.txt" ) 13const MARK = "@@@ " 14 15type Unit = { start :number ; end :number ; text :string ; line :number ; raw :boolean } 16 17const escapes :Record < string , string > = { n :"\n" , r :"\r" , t :"\t" , f :"\f" , '"' :'"' , "\\" :"\\" } 18 19// Every string literal in a gleam source, with `"a" <> "b"` chains of pure 20// literals joined into one unit. Comments are skipped, so quoted text inside 21// them never counts. 22const units = ( src :string ) :Unit [] => { 23const found :Unit [] = [] 24let i = 0 25let line = 1 26const skipBlank = ( from :number ) :number => { 27let j = from 28for (;;) { 29if ( / \s / . test ( src [ j ] ?? "" )) j ++ 30else if ( src . startsWith ( "//" , j )) while ( j < src . length && src [ j ] !== "\n" ) j ++ 31else return j 32} 33} 34const readString = ( from :number ) :{ end :number ; text :string } => { 35let j = from + 1 36let text = "" 37while ( src [ j ] !== '"' ) { 38if ( src [ j ] !== "\\" ) text += src [ j ++ ] 39else if ( src [ j + 1 ] === "u" ) { 40const close = src . indexOf ( "}" , j ) 41text += String . fromCodePoint ( parseInt ( src . slice ( j + 3 , close ), 16 )) 42j = close + 1 43} else { 44const mapped = escapes [ src [ j + 1 ]] 45if ( mapped === undefined ) throw new Error ( `unknown escape \\ ${ src [ j + 1 ]} at offset ${ j } ` ) 46text += mapped 47j += 2 48} 49} 50return { end :j + 1 , text} 51} 52while ( i < src . length ) { 53if ( src . startsWith ( "//" , i )) { 54while ( i < src . length && src [ i ] !== "\n" ) i ++ 55} else if ( src [ i ] === '"' ) { 56const start = i 57let { end, text} = readString ( i ) 58for (;;) { 59const op = skipBlank ( end ) 60if ( ! src . startsWith ( "<>" , op )) break 61const next = skipBlank ( op + 2 ) 62if ( src [ next ] !== '"' ) break 63const piece = readString ( next ) 64text += piece . text 65end = piece . end 66} 67found . push ({ start, end, text, line :src . slice ( 0 , start ). split ( "\n" ). length , raw :src . slice ( start , end ). includes ( "\n" ) }) 68i = end 69} else i ++ 70} 71return found 72} 73 74const encode = ( text :string , raw :boolean ) :string => 75'"' + 76[ ...text ] 77. map (( ch ) => ( ch === "\\" ?"\\\\" :ch === '"' ?'\\"' :ch === "\n" ?( raw ?"\n" :"\\n" ) :ch === "\t" ?"\\t" :ch === "\r" ?"\\r" :ch === "\f" ?"\\f" :ch )) 78. join ( "" ) + 79'"' 80 81const sources = ( dir :string ) :string [] => 82readdirSync ( dir ). flatMap (( name ) => { 83const path = join ( dir , name ) 84return statSync ( path ). isDirectory () ?sources ( path ) :name . endsWith ( ".gleam" ) ?[ path ] :[] 85}) 86 87const digest = ( text :string ) => createHash ( "sha1" ). update ( text ). digest ( "hex" ). slice ( 0 , 8 ) 88// A prompt reads as prose: several words, not an identifier or a short label. 89const sqlStart = / ^\s*(select|insert|create|update|delete|pragma|alter|drop|with|replace)\b / i 90const sqlWords = / \b(SELECT|INSERT INTO|CREATE TABLE|WHERE|FROM)\b / 91const prose = ( text :string , min :number ) => 92text . length >= min && text . trim (). split ( / \s+ / ). length >= 5 && ! sqlStart . test ( text ) && ! sqlWords . test ( text ) 93 94// The definition a string sits in, so a block reads with its context. 95const enclosing = ( src :string , offset :number ) :string => { 96let name = "top level" 97for ( const m of src . matchAll ( / ^(?:pub )?(?:fn|const) (\w+) / gm )) { 98if ( m . index > offset ) break 99name = m [ 1 ] 100} 101return name 102} 103 104const pull = ( min :number ) => { 105const blocks :string [] = [] 106for ( const file of sources ( join ( root , "src" )). sort ()) { 107const rel = relative ( root , file ) 108const text = readFileSync ( file , "utf8" ) 109const picked = units ( text ). map (( unit , ordinal ) => ({ unit, ordinal})). filter (({ unit}) => prose ( unit . text , min )) 110for ( const { unit, ordinal} of picked ) { 111if ( unit . text . split ( "\n" ). some (( l ) => l . startsWith ( MARK ))) throw new Error ( ` ${ rel } : ${ unit . line } contains a ${ MARK . trim ()} line` ) 112blocks . push ( ` ${ MARK } ${ rel } # ${ ordinal } @ ${ digest ( unit . text )} (line ${ unit . line } , in ${ enclosing ( text , unit . start )} )\n ${ unit . text } \n` ) 113} 114} 115const header = `# prompts pulled from src/**/*.gleam. Edit the text under each ${ MARK . trim ()} header, keep the header lines, then \`bun scripts/prompts.ts push\`.\n# a block is refused if its source string changed since the pull (use --force to override).\n\n` 116writeFileSync ( out , header + blocks . join ( "\n" )) 117console . log ( `wrote ${ blocks . length } prompts to ${ relative ( root , out )} ` ) 118} 119 120type Edit = { rel :string ; ordinal :number ; digest :string ; text :string } 121 122const parse = () :Edit [] => { 123const edits :Edit [] = [] 124let current :{ head :RegExpMatchArray ; body :string [] } | undefined 125const close = () => { 126if ( ! current ) return 127// The block separator adds exactly one newline after the text. 128const body = current . body . join ( "\n" ). replace ( / \n$ / , "" ) 129edits . push ({ rel :current . head [ 1 ], ordinal :Number ( current . head [ 2 ]), digest :current . head [ 3 ], text :body }) 130} 131for ( const line of readFileSync ( out , "utf8" ). split ( "\n" )) { 132const head = line . startsWith ( MARK ) ?line . match ( / ^@@@ (\S+) #(\d+) @(\w+) / ) :null 133if ( head ) { 134close () 135current = { head, body :[] } 136} else current ?. body . push ( line ) 137} 138close () 139return edits 140} 141 142const push = ( dry :boolean , force :boolean ) => { 143const byFile = Map . groupBy ( parse (), ( edit ) => edit . rel ) 144let changed = 0 145const touched :string [] = [] 146for ( const [ rel , edits ] of byFile ) { 147const path = join ( root , rel ) 148let src = readFileSync ( path , "utf8" ) 149const current = units ( src ) 150const patches = edits . flatMap (( edit ) => { 151const unit = current [ edit . ordinal ] 152if ( ! unit ) throw new Error ( ` ${ rel } # ${ edit . ordinal } : no such string any more` ) 153if ( unit . text === edit . text ) return [] 154if ( digest ( unit . text ) !== edit . digest && ! force ) { 155console . error ( `skipped ${ rel } # ${ edit . ordinal } : the source changed since the pull` ) 156return [] 157} 158return [{ unit, edit}] 159}) 160for ( const { unit, edit} of patches . sort (( a , b ) => b . unit . start - a . unit . start )) { 161src = src . slice ( 0 , unit . start ) + encode ( edit . text , unit . raw ) + src . slice ( unit . end ) 162console . log ( ` ${ dry ? "would update" : "updated" } ${ rel } : ${ unit . line } ` ) 163changed ++ 164} 165if ( patches . length && ! dry ) { 166writeFileSync ( path , src ) 167touched . push ( path ) 168} 169} 170if ( touched . length ) spawnSync ( "gleam" , [ "format" , ...touched ], { cwd :root , stdio :"inherit" }) 171console . log ( changed ?` ${ changed } prompts ${ dry ? "differ" : "written back" } ` :"no changes" ) 172} 173 174const [ command , ...flags ] = process . argv . slice ( 2 ) 175if ( command === "pull" ) { 176const at = flags . indexOf ( "--min" ) 177pull ( at >= 0 ?Number ( flags [ at + 1 ]) :50 ) 178} else if ( command === "push" ) push ( flags . includes ( "--dry" ), flags . includes ( "--force" )) 179else console . error ( "usage: prompts.ts pull [--min N] | push [--dry] [--force]" )