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

hooks/register.tsx 123 lines1import { 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}
123hooks/lens.ts 162 lines1export 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}
162types/index.d.ts 8 lines1export type Pinned = { readonly asks: readonly string[] }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'jev-report': { pinned: Pinned | null }
6 }
7}
8