SLOPSHOPPER

jev-report

Reads each final report with TypeSafe Jev: pins what waits on you above the prompt, and proposes the figure that would make a dense report clear

newbandstatuspromptprocessnetwork
A shopper browsing a rack in a slop shop
Source 3 files
hooks/register.tsx 123 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Pinned } from '../types'
5import {
6  answersFrom,
7  API_URL,
8  decide,
9  DEFAULT_MODEL,
10  firstLine,
11  KEYCHAIN_SERVICE,
12  parseAnswer,
13  requestBody,
14  shouldJudge,
15} from './lens'
16
17const JEV_TIMEOUT_MS = 8_000
18
19const pinned = atom({ plugin: 'jev-report', key: 'pinned' } as const, null)
20
21let cachedKey: string | undefined
22let lastKeyMissAt = -Infinity
23let pendingNudge: string | undefined
24
25async function apiKey($: EngineInterface, options: PluginOptions): Promise<string | undefined> {
26  const option = typeof options.typesafe_api_key === 'string' ? options.typesafe_api_key.trim() : ''
27  if (option) return option
28  if (cachedKey) return cachedKey
29  if (Date.now() - lastKeyMissAt < 30_000) return undefined
30  const keychain = await $.process
31    .run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], { timeoutMs: 3000 })
32    .catch(() => undefined)
33  const fromKeychain = keychain?.exitCode === 0 ? keychain.stdout.trim() : ''
34  // The Keychain wins so a key loaded from a plaintext .env cannot override it.
35  const key = fromKeychain || (await $.env.get('TYPESAFE_API_KEY'))?.trim()
36  if (key) return (cachedKey = key)
37  lastKeyMissAt = Date.now()
38  return undefined
39}
40
41async function askJev($: EngineInterface, key: string, model: string, answer: string) {
42  const call = $.http.fetch(API_URL, {
43    method: 'POST',
44    headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
45    body: JSON.stringify(requestBody(model, answer)),
46  })
47  const timeout = $.clock.sleep(JEV_TIMEOUT_MS).then(() => undefined)
48  const response = await Promise.race([call, timeout]).catch(() => undefined)
49  if (!response) return { failure: `no answer within ${JEV_TIMEOUT_MS / 1000} s` }
50  if (response.status === 401) cachedKey = undefined
51  if (!response.ok) return { failure: `HTTP ${response.status}` }
52  try {
53    const answers = answersFrom(JSON.parse(response.text))
54    return answers ? { answers } : { failure: 'an answer without the expected fields' }
55  } catch {
56    return { failure: 'a reply that was not JSON' }
57  }
58}
59
60async function pin($: EngineInterface, asks: readonly string[]) {
61  const next: Pinned | null = asks.length > 0 ? { asks } : null
62  await update($, pinned, () => next)
63  $.ui.status(asks.length > 0 ? `◆ ${asks.length} for you · ${firstLine(asks[0] ?? '', 40)}` : undefined)
64}
65
66async function readReport($: EngineInterface, options: PluginOptions, model: string, answer: string) {
67  const parsed = parseAnswer(answer)
68  if (!shouldJudge(answer, parsed)) return pin($, [])
69  const key = await apiKey($, options)
70  if (!key) return pin($, parsed.asks)
71  const judged = await askJev($, key, model, answer)
72  if ('failure' in judged) {
73    $.ui.status(`jev-report: ${judged.failure}; report not read`)
74    return pin($, parsed.asks)
75  }
76  const lens = decide(parsed, judged.answers)
77  pendingNudge = lens.nudge
78  await pin($, lens.asks)
79  if (lens.suggestion) await $.prompt.suggest({ text: lens.suggestion })
80}
81
82export const register: Register = (on, options: PluginOptions) => {
83  const model = typeof options.model === 'string' && options.model.trim() ? options.model.trim() : DEFAULT_MODEL
84
85  // Jev answers in about a second; a timer reads the report after the turn
86  // has ended, so that second never holds up the prompt.
87  on('turn.complete', async ($, e, next) => {
88    const result = await next(e)
89    if (e.agentId === undefined && e.reason === 'answer')
90      $.clock.after(0, () => {
91        readReport($, options, model, e.answer).catch(() => $.ui.status('jev-report: report not read'))
92      })
93    return result
94  })
95
96  on('prompt.submit', async ($, e, next) => {
97    const nudge = pendingNudge
98    pendingNudge = undefined
99    if (e.command || !nudge) return next(e)
100    return next({ ...e, context: [...(e.context ?? []), nudge] })
101  })
102
103  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
104    const shown = await read($, pinned)
105    if (e.props.hasSurvey || shown === null) return next(e)
106    const { Box, Button, Text } = $.ui.resolve(e)
107    return (
108      <Box flexDirection="column">
109        {shown.asks.map((ask, index) => (
110          <Text key={`ask-${index}`}>
111            <Text color="yellow">◆ </Text>
112            {shown.asks.length > 1 ? `${index + 1}. ` : ''}
113            {firstLine(ask, 96)}
114          </Text>
115        ))}
116        <Box>
117          <Button key="done" label="Done" onPress={() => pin($, [])} />
118        </Box>
119      </Box>
120    )
121  })
122}
123
hooks/lens.ts 162 lines
1export const KEYCHAIN_SERVICE = 'typesafe-api-key'
2export const DEFAULT_MODEL = 'jev-latest'
3export const API_URL = 'https://api.typesafe.ai/v1/systemone'
4
5// A short reply with no ask section has nothing for Jev to read.
6export const MIN_JUDGED_CHARS = 280
7export const MAX_JUDGED_CHARS = 24_000
8
9const ASK_HEADING = /^\s*(?:#{1,6}\s*|\*\*)?What I need from you\b/i
10const HEADING = /^\s*(?:#{1,6}\s|\*\*[^*]+\*\*\s*$)/
11const NUMBERED = /^\s*\d+[.)]\s+(.+)$/
12const FIGURE = /^\s*```(?:mermaid|diff)\b|^\s*\|?\s*:?-{3,}:?\s*\|/m
13
14export const FIGURES = {
15  none: {
16    criterion: 'No figure: the message is short, or its points stand apart and read well as text',
17    view: '',
18  },
19  flowchart: {
20    criterion: 'A flowchart: steps, decisions, or parts that feed one another',
21    view: 'a Mermaid flowchart',
22  },
23  sequence: {
24    criterion: 'A sequence diagram: calls or messages passed between components in order',
25    view: 'a Mermaid sequence diagram',
26  },
27  state: {
28    criterion: 'A state diagram: something that moves between named states',
29    view: 'a Mermaid state diagram',
30  },
31  file_tree: {
32    criterion: 'A file tree: files or folders that were added, moved, or removed',
33    view: 'a diff file tree',
34  },
35  table: {
36    criterion: 'A table: several items compared on the same fields, such as before and after',
37    view: 'a table',
38  },
39  page: {
40    criterion: 'A visual page: a layout, UI, or dense concept that no text diagram can hold',
41    view: 'one focused HTML page',
42  },
43} as const
44export type Figure = keyof typeof FIGURES
45const FIGURE_NAMES = Object.keys(FIGURES) as Figure[]
46
47export type Parsed = {
48  readonly hasFigure: boolean
49  readonly hasAskSection: boolean
50  readonly asks: readonly string[]
51}
52
53export function parseAnswer(answer: string): Parsed {
54  const lines = answer.split('\n')
55  const start = lines.findIndex(line => ASK_HEADING.test(line))
56  const asks: string[] = []
57  if (start >= 0)
58    for (const line of lines.slice(start + 1)) {
59      if (HEADING.test(line) && !NUMBERED.test(line)) break
60      const item = NUMBERED.exec(line)?.[1]?.trim()
61      if (item) asks.push(item)
62    }
63  return { hasFigure: FIGURE.test(answer), hasAskSection: start >= 0, asks }
64}
65
66export function shouldJudge(answer: string, parsed: Parsed): boolean {
67  return answer.length <= MAX_JUDGED_CHARS && (parsed.hasAskSection || answer.length >= MIN_JUDGED_CHARS)
68}
69
70export function requestBody(model: string, answer: string) {
71  return {
72    model,
73    state: { message: answer },
74    questions: {
75      waits_on_user: {
76        type: 'noul',
77        instructions:
78          'Does `message` ask its reader to do something or decide something before the work can continue? ' +
79          'An offer of optional extra work does not count, and neither does a remark that nothing is needed.',
80      },
81      figure: {
82        type: 'choice',
83        instructions: 'Which single figure would make `message` easiest to follow for a reader who scans it first?',
84        criteria: Object.fromEntries(FIGURE_NAMES.map(name => [name, FIGURES[name].criterion])),
85      },
86      prose_strain: {
87        type: 'score',
88        instructions: 'How hard is `message` to follow as plain prose, without any figure?',
89        criteria: [
90          'Easy: a short answer, or a few facts that stand apart',
91          'Moderate: several connected steps or parts that the reader must hold in mind',
92          'Hard: a flow, structure, or comparison that the prose spreads across many sentences',
93        ],
94      },
95    },
96  }
97}
98
99export type Answers = {
100  readonly waits: number
101  readonly figure: Figure
102  readonly figureP: number
103  readonly strain: number
104}
105
106const isRecord = (value: unknown): value is Record<string, unknown> =>
107  typeof value === 'object' && value !== null && !Array.isArray(value)
108const isFigure = (value: unknown): value is Figure => typeof value === 'string' && value in FIGURES
109
110export function answersFrom(body: unknown): Answers | undefined {
111  if (!isRecord(body) || !isRecord(body.answers)) return undefined
112  const { waits_on_user: waits, figure, prose_strain: strain } = body.answers
113  if (!isRecord(waits) || typeof waits.noul !== 'number') return undefined
114  if (!isRecord(figure) || !isFigure(figure.choice) || !isRecord(figure.probabilities)) return undefined
115  if (!isRecord(strain) || typeof strain.score !== 'number') return undefined
116  const figureP = figure.probabilities[figure.choice]
117  return {
118    waits: waits.noul,
119    figure: figure.choice,
120    figureP: typeof figureP === 'number' ? figureP : 0,
121    strain: strain.score,
122  }
123}
124
125export const THRESHOLDS = {
126  pin: 0.4,
127  unfiled: 0.75,
128  empty: 0.2,
129  figure: 0.6,
130  strain: 1.0,
131} as const
132
133export type Lens = {
134  readonly asks: readonly string[]
135  readonly nudge?: string
136  readonly suggestion?: string
137}
138
139export function decide(parsed: Parsed, answers: Answers): Lens {
140  const asks = parsed.asks.length > 0 && answers.waits >= THRESHOLDS.pin ? parsed.asks : []
141  const nudge =
142    parsed.hasAskSection && answers.waits < THRESHOLDS.empty
143      ? 'jev-report: your last message had a What I need from you section, but nothing in it waited on the user. ' +
144        'When nothing waits, leave the section out and end on the report.'
145      : !parsed.hasAskSection && answers.waits >= THRESHOLDS.unfiled
146        ? 'jev-report: your last message asked the user to act outside a What I need from you section. ' +
147          'Put each request there as a numbered item that names the action, where to do it, and what to reply.'
148        : undefined
149  const wantsFigure =
150    !parsed.hasFigure &&
151    answers.figure !== 'none' &&
152    answers.figureP >= THRESHOLDS.figure &&
153    answers.strain >= THRESHOLDS.strain
154  const suggestion = wantsFigure ? `/show-me that as ${FIGURES[answers.figure].view}` : undefined
155  return { asks, ...(nudge ? { nudge } : {}), ...(suggestion ? { suggestion } : {}) }
156}
157
158export const firstLine = (text: string, max = 72) => {
159  const line = text.replace(/[`*_]/g, '').trim()
160  return line.length > max ? `${line.slice(0, max - 1)}…` : line
161}
162
types/index.d.ts 8 lines
1export type Pinned = { readonly asks: readonly string[] }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'jev-report': { pinned: Pinned | null }
6  }
7}
8