A footnote under Claude's reply with one idea you wouldn't have suggested yourself

A Claude Code mod that shows you the idea you wouldn't have had. A little while after Claude answers, a folded footnote appears under the reply: Another angle ›. Open it and you get one idea from outside your usual frame, anchored in something from this chat, with the blind spot it comes from.

/third-eye asks for an idea right now. /third-eye off rests it for the session, /third-eye on wakes it.It looks at most once every 15 minutes, and only after you've been quiet for 25 seconds, so it never interrupts a back-and-forth.
Claude Code 2.1.286 or later (mods). The footnote is drawn in the Claude desktop app and in the terminal.
In Claude Code:
/plugin marketplace add hacksurvivor/claude-mods
/plugin install third-eye@hacksurvivor
/reload-plugins
See PRIVACY.md.
Apache-2.0
hooks/register.tsx 215 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Idea } from '../types'
5import { IDLE_MS, anchorOf, isDue, isUnder, parseIdea, question, readTaste, remember } from './eye'
6
7// A footnote under Claude's reply with one idea the person wouldn't have
8// suggested themselves. The eye looks a little while after a reply, at most
9// once in 15 minutes, by asking the session's own model over this chat (a
10// fork, so the chat's prompt cache serves it). /third-eye asks it now.
11
12const TASTE_KEY = 'taste'
13const IDEAS_KEPT = 20
14
15const ideas = atom({ plugin: 'third-eye', key: 'ideas' } as const, [] as Idea[])
16const open = atom({ plugin: 'third-eye', key: 'open' } as const, [] as string[])
17const looking = atom({ plugin: 'third-eye', key: 'looking' } as const, null as string | null)
18const isPaused = atom({ plugin: 'third-eye', key: 'isPaused' } as const, false)
19
20let waiting: Timer | undefined
21let isBusy = false
22let lastAnchor: string | undefined
23
24// Resolves the new idea, or a sentence saying why there is none.
25async function look($: EngineInterface, anchor: string): Promise<Idea | string> {
26 if (isBusy) {
27 return 'Your third eye is already looking.'
28 }
29
30 isBusy = true
31 await update($, looking, () => anchor)
32
33 try {
34 const taste = readTaste(await $.store.get(TASTE_KEY))
35 const reply = await $.model.fork({ prompt: question(taste) })
36
37 if (!reply.isAnswered) {
38 return reply.reason === 'nothing-to-fork'
39 ? 'Nothing to look at yet. Ask Claude something first.'
40 : `Your third eye couldn't look this time (${reply.reason}).`
41 }
42
43 const found = parseIdea(reply.text)
44
45 if (found === undefined) {
46 return 'Your third eye came back without an idea. Try again in a bit.'
47 }
48
49 const at = await $.clock.now()
50 const idea: Idea = { id: `eye-${at}`, anchor, ...found, at, status: 'new' }
51 await update($, ideas, list => [...list, idea].slice(-IDEAS_KEPT))
52 await update($, open, list => [...list, idea.id])
53
54 return idea
55 } finally {
56 await update($, looking, () => null)
57 isBusy = false
58 }
59}
60
61// The reply the eye looks from: the last one this module saw end, or after a
62// reload, the chat's last assistant text.
63async function lastReply($: EngineInterface): Promise<string | undefined> {
64 if (lastAnchor !== undefined) {
65 return lastAnchor
66 }
67
68 const rows = await $.session.messages()
69 const row = [...rows].reverse().find(item => item.role === 'assistant' && item.text.trim() !== '')
70
71 return row === undefined ? undefined : anchorOf(row.text)
72}
73
74async function ask($: EngineInterface, idea: Idea): Promise<void> {
75 await update($, ideas, list => list.map(item => (item.id === idea.id ? { ...item, status: 'asked' as const } : item)))
76 await $.store.set(TASTE_KEY, remember(readTaste(await $.store.get(TASTE_KEY)), 'asked', idea.idea))
77 await $.prompt.submit({ text: idea.prompt, asUser: true })
78}
79
80async function dismiss($: EngineInterface, idea: Idea): Promise<void> {
81 await update($, ideas, list => list.map(item => (item.id === idea.id ? { ...item, status: 'dismissed' as const } : item)))
82 await $.store.set(TASTE_KEY, remember(readTaste(await $.store.get(TASTE_KEY)), 'dismissed', idea.idea))
83 $.ui.toast('Got it. Fewer ideas like that one.')
84}
85
86async function fold($: EngineInterface, id: string): Promise<void> {
87 await update($, open, list => (list.includes(id) ? list.filter(item => item !== id) : [...list, id]))
88}
89
90// The footnote's paragraph: the blind spot, then the idea, in the reply's own voice.
91function paragraph(idea: Idea): string {
92 const sentence = /[.!?]$/.test(idea.idea) ? idea.idea : `${idea.idea}.`
93
94 return `*${`${idea.why} ${sentence}`.replace(/\*/g, '\\*')}*`
95}
96
97export const register: Register = on => {
98 on('session.start', async ($, e, next) => {
99 await $.command.register({
100 name: 'third-eye',
101 description: "Ask your third eye for an idea you wouldn't suggest, or turn it off or on",
102 argumentHint: '[off|on]',
103 })
104
105 return next(e)
106 })
107
108 on('command.run', { command: 'third-eye' }, async ($, e) => {
109 const arg = e.args.trim().toLowerCase()
110
111 if (arg === 'off' || arg === 'on') {
112 await update($, isPaused, () => arg === 'off')
113 waiting?.cancel()
114
115 return {
116 text: arg === 'off' ? 'Your third eye is resting for this session. /third-eye on wakes it.' : 'Your third eye is awake.',
117 }
118 }
119
120 const anchor = await lastReply($)
121
122 if (anchor === undefined) {
123 return { text: 'Nothing to look at yet. Ask Claude something first.' }
124 }
125
126 waiting?.cancel()
127 const found = await look($, anchor)
128
129 return { text: typeof found === 'string' ? found : `Another angle: ${found.idea}` }
130 })
131
132 // A while after a main-loop reply, unless the person has written again.
133 on('turn.complete', async ($, e, next) => {
134 const done = await next(e)
135
136 if (e.agentId !== undefined || e.reason !== 'answer' || e.answer.trim() === '') {
137 return done
138 }
139
140 const anchor = anchorOf(e.answer)
141 lastAnchor = anchor
142
143 if (!(await read($, isPaused)) && isDue(await $.clock.now(), await read($, ideas))) {
144 waiting?.cancel()
145 waiting = $.clock.after(IDLE_MS, () => void look($, anchor))
146 }
147
148 return done
149 })
150
151 on('prompt.submit', ($, e, next) => {
152 waiting?.cancel()
153 waiting = undefined
154
155 return next(e)
156 })
157
158 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
159 const text = e.props.text
160 const mine = (await read($, ideas)).filter(idea => idea.status !== 'dismissed' && isUnder(idea.anchor, text))
161 const lookingAt = await read($, looking)
162 const isLookingHere = lookingAt !== null && isUnder(lookingAt, text)
163
164 if (mine.length === 0 && !isLookingHere) {
165 return next(e)
166 }
167
168 const drawn = await next(e)
169 const unfolded = await read($, open)
170 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
171
172 return (
173 <Box flexDirection="column">
174 {drawn}
175 {mine.map(idea => {
176 const isOpen = unfolded.includes(idea.id)
177
178 return (
179 <Box key={idea.id} flexDirection="column" marginTop={1} marginLeft={2}>
180 <Button
181 key={`fold-${idea.id}`}
182 label={isOpen ? 'Another angle ⌄' : 'Another angle ›'}
183 plain
184 dimColor
185 onPress={() => fold($, idea.id)}
186 />
187 {isOpen ? (
188 <Box key={`body-${idea.id}`} flexDirection="column" marginLeft={2}>
189 <Markdown key={`text-${idea.id}`} text={paragraph(idea)} dimColor />
190 {idea.status === 'new' ? (
191 <Box key={`acts-${idea.id}`} flexDirection="row" columnGap={1}>
192 <Button key={`ask-${idea.id}`} label="Ask Claude about this" plain onPress={() => ask($, idea)} />
193 <Text dimColor>·</Text>
194 <Button key={`nope-${idea.id}`} label="Not for me" plain dimColor onPress={() => dismiss($, idea)} />
195 </Box>
196 ) : (
197 <Text key={`asked-${idea.id}`} dimColor>
198 Asked
199 </Text>
200 )}
201 </Box>
202 ) : null}
203 </Box>
204 )
205 })}
206 {isLookingHere ? (
207 <Box key="looking" marginTop={1} marginLeft={2}>
208 <Text dimColor>Another angle…</Text>
209 </Box>
210 ) : null}
211 </Box>
212 )
213 })
214}
215hooks/eye.ts 83 lines1// What the third eye asks, how it reads the answer, and when it looks.
2
3import type { Idea, Taste } from '../types'
4
5// Quiet for this long after a reply before the eye looks: the person is reading.
6export const IDLE_MS = 25_000
7// At most one unasked-for idea in this window.
8export const GAP_MS = 15 * 60_000
9const ANCHOR_CHARS = 160
10const TASTE_KEPT = 30
11
12export function anchorOf(text: string): string {
13 return text.trim().slice(-ANCHOR_CHARS)
14}
15
16// The last text block of a reply ends where the turn's answer ends.
17export function isUnder(anchor: string, blockText: string): boolean {
18 const block = anchorOf(blockText)
19
20 return block.length >= 20 && anchor.endsWith(block.slice(-Math.min(block.length, 80)))
21}
22
23export function isDue(now: number, ideas: readonly Idea[]): boolean {
24 const last = ideas.at(-1)
25
26 return last === undefined || now - last.at >= GAP_MS
27}
28
29export function question(taste: Taste): string {
30 const avoid = taste.dismissed.slice(-12)
31 const liked = taste.asked.slice(-6)
32
33 return [
34 'Step outside this conversation for a moment and act as the person\'s third eye.',
35 'Look only at what the person (not you) has asked for in this session: the questions they keep asking, what they measure, what they take for granted, and what they never ask about.',
36 'Offer ONE idea they would not have suggested themselves: an angle outside their habitual frame that matters for what they are actually trying to achieve.',
37 'Not the obvious next step, not a follow-up on what you just did, and not generic advice (tests, docs, backups, monitoring) unless it is truly surprising here. It must be concrete enough to start on in this session.',
38 'Look from a side they skip: the person on the other end, the money, the time, the people outside the work, a different field that solved this already. Anchor it in one specific detail from this chat, so it could only have been said here.',
39 avoid.length > 0 ? `They turned these down before, so stay away from their kind: ${avoid.map(item => `"${item}"`).join('; ')}.` : '',
40 liked.length > 0 ? `They took these up before: ${liked.map(item => `"${item}"`).join('; ')}.` : '',
41 'Write in the language the person writes in. Plain words, no hype.',
42 'Reply with only this JSON, nothing around it:',
43 '{"idea": "<the idea as one imperative sentence, at most 14 words>", "why": "<one or two sentences in second person naming the blind spot>", "prompt": "<what they could send you to start on it, in their voice>"}',
44 ]
45 .filter(line => line !== '')
46 .join('\n')
47}
48
49export function parseIdea(reply: string): Pick<Idea, 'idea' | 'why' | 'prompt'> | undefined {
50 const start = reply.indexOf('{')
51 const end = reply.lastIndexOf('}')
52
53 if (start < 0 || end <= start) {
54 return undefined
55 }
56
57 try {
58 const data = JSON.parse(reply.slice(start, end + 1)) as Record<string, unknown>
59 const { idea, why, prompt } = data
60
61 if (typeof idea === 'string' && typeof why === 'string' && idea.trim() !== '' && why.trim() !== '') {
62 return { idea: idea.trim(), why: why.trim(), prompt: typeof prompt === 'string' && prompt.trim() !== '' ? prompt.trim() : idea.trim() }
63 }
64 } catch {
65 // not JSON
66 }
67
68 return undefined
69}
70
71export function remember(taste: Taste, kind: keyof Taste, idea: string): Taste {
72 return { ...taste, [kind]: [...taste[kind].filter(item => item !== idea), idea].slice(-TASTE_KEPT) }
73}
74
75export function readTaste(stored: unknown): Taste {
76 const value = (stored ?? {}) as Partial<Taste>
77
78 return {
79 dismissed: Array.isArray(value.dismissed) ? value.dismissed.filter(item => typeof item === 'string') : [],
80 asked: Array.isArray(value.asked) ? value.asked.filter(item => typeof item === 'string') : [],
81 }
82}
83types/index.d.ts 28 lines1// One idea the third eye offered, and the reply it sits under.
2export type Idea = {
3 id: string
4 // The end of the reply's text, to find the block it hangs under.
5 anchor: string
6 idea: string
7 why: string
8 prompt: string
9 at: number
10 status: 'new' | 'asked' | 'dismissed'
11}
12
13// What the person turned down or took, kept across sessions to steer the next ones.
14export type Taste = { dismissed: string[]; asked: string[] }
15
16declare module 'claude-code' {
17 interface PluginState {
18 'third-eye': {
19 ideas: Idea[]
20 // Ideas whose footnote is unfolded.
21 open: string[]
22 // The anchor of the reply the eye is looking at now, if any.
23 looking: string | null
24 isPaused: boolean
25 }
26 }
27}
28