okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petadd a script to pull prompts into one file for review and push edits back6c1ba64

main
7.1 KiB179 linesraw
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[] => {
23  const found: Unit[] = []
24  let i = 0
25  let line = 1
26  const skipBlank = (from: number): number => {
27    let j = from
28    for (;;) {
29      if (/\s/.test(src[j] ?? "")) j++
30      else if (src.startsWith("//", j)) while (j < src.length && src[j] !== "\n") j++
31      else return j
32    }
33  }
34  const readString = (from: number): { end: number; text: string } => {
35    let j = from + 1
36    let text = ""
37    while (src[j] !== '"') {
38      if (src[j] !== "\\") text += src[j++]
39      else if (src[j + 1] === "u") {
40        const close = src.indexOf("}", j)
41        text += String.fromCodePoint(parseInt(src.slice(j + 3, close), 16))
42        j = close + 1
43      } else {
44        const mapped = escapes[src[j + 1]]
45        if (mapped === undefined) throw new Error(`unknown escape \\${src[j + 1]} at offset ${j}`)
46        text += mapped
47        j += 2
48      }
49    }
50    return { end: j + 1, text }
51  }
52  while (i < src.length) {
53    if (src.startsWith("//", i)) {
54      while (i < src.length && src[i] !== "\n") i++
55    } else if (src[i] === '"') {
56      const start = i
57      let { end, text } = readString(i)
58      for (;;) {
59        const op = skipBlank(end)
60        if (!src.startsWith("<>", op)) break
61        const next = skipBlank(op + 2)
62        if (src[next] !== '"') break
63        const piece = readString(next)
64        text += piece.text
65        end = piece.end
66      }
67      found.push({ start, end, text, line: src.slice(0, start).split("\n").length, raw: src.slice(start, end).includes("\n") })
68      i = end
69    } else i++
70  }
71  return 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[] =>
82  readdirSync(dir).flatMap((name) => {
83    const path = join(dir, name)
84    return 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) =>
92  text.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 => {
96  let name = "top level"
97  for (const m of src.matchAll(/^(?:pub )?(?:fn|const) (\w+)/gm)) {
98    if (m.index > offset) break
99    name = m[1]
100  }
101  return name
102}
103
104const pull = (min: number) => {
105  const blocks: string[] = []
106  for (const file of sources(join(root, "src")).sort()) {
107    const rel = relative(root, file)
108    const text = readFileSync(file, "utf8")
109    const picked = units(text).map((unit, ordinal) => ({ unit, ordinal })).filter(({ unit }) => prose(unit.text, min))
110    for (const { unit, ordinal } of picked) {
111      if (unit.text.split("\n").some((l) => l.startsWith(MARK))) throw new Error(`${rel}:${unit.line} contains a ${MARK.trim()} line`)
112      blocks.push(`${MARK}${rel} #${ordinal} @${digest(unit.text)} (line ${unit.line}, in ${enclosing(text, unit.start)})\n${unit.text}\n`)
113    }
114  }
115  const 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`
116  writeFileSync(out, header + blocks.join("\n"))
117  console.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[] => {
123  const edits: Edit[] = []
124  let current: { head: RegExpMatchArray; body: string[] } | undefined
125  const close = () => {
126    if (!current) return
127    // The block separator adds exactly one newline after the text.
128    const body = current.body.join("\n").replace(/\n$/, "")
129    edits.push({ rel: current.head[1], ordinal: Number(current.head[2]), digest: current.head[3], text: body })
130  }
131  for (const line of readFileSync(out, "utf8").split("\n")) {
132    const head = line.startsWith(MARK) ? line.match(/^@@@ (\S+) #(\d+) @(\w+)/) : null
133    if (head) {
134      close()
135      current = { head, body: [] }
136    } else current?.body.push(line)
137  }
138  close()
139  return edits
140}
141
142const push = (dry: boolean, force: boolean) => {
143  const byFile = Map.groupBy(parse(), (edit) => edit.rel)
144  let changed = 0
145  const touched: string[] = []
146  for (const [rel, edits] of byFile) {
147    const path = join(root, rel)
148    let src = readFileSync(path, "utf8")
149    const current = units(src)
150    const patches = edits.flatMap((edit) => {
151      const unit = current[edit.ordinal]
152      if (!unit) throw new Error(`${rel} #${edit.ordinal}: no such string any more`)
153      if (unit.text === edit.text) return []
154      if (digest(unit.text) !== edit.digest && !force) {
155        console.error(`skipped ${rel} #${edit.ordinal}: the source changed since the pull`)
156        return []
157      }
158      return [{ unit, edit }]
159    })
160    for (const { unit, edit } of patches.sort((a, b) => b.unit.start - a.unit.start)) {
161      src = src.slice(0, unit.start) + encode(edit.text, unit.raw) + src.slice(unit.end)
162      console.log(`${dry ? "would update" : "updated"} ${rel}:${unit.line}`)
163      changed++
164    }
165    if (patches.length && !dry) {
166      writeFileSync(path, src)
167      touched.push(path)
168    }
169  }
170  if (touched.length) spawnSync("gleam", ["format", ...touched], { cwd: root, stdio: "inherit" })
171  console.log(changed ? `${changed} prompts ${dry ? "differ" : "written back"}` : "no changes")
172}
173
174const [command, ...flags] = process.argv.slice(2)
175if (command === "pull") {
176  const at = flags.indexOf("--min")
177  pull(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]")