@g3nomaly/g3n-mods 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +68 -0
- package/LICENSE +95 -0
- package/mods/agents-panel/.claude-plugin/plugin.json +9 -0
- package/mods/agents-panel/hooks/hooks.json +1 -0
- package/mods/agents-panel/hooks/register.tsx +277 -0
- package/mods/agents-panel/types/index.d.ts +24 -0
- package/mods/context-bar/.claude-plugin/plugin.json +9 -0
- package/mods/context-bar/hooks/hooks.json +1 -0
- package/mods/context-bar/hooks/register.tsx +215 -0
- package/mods/context-bar/types/index.d.ts +25 -0
- package/mods/custom-crab-agents/.claude-plugin/plugin.json +9 -0
- package/mods/custom-crab-agents/hooks/hooks.json +1 -0
- package/mods/custom-crab-agents/hooks/register.tsx +508 -0
- package/mods/custom-crab-agents/types/index.d.ts +45 -0
- package/mods/delete-guard/.claude-plugin/plugin.json +9 -0
- package/mods/delete-guard/hooks/hooks.json +1 -0
- package/mods/delete-guard/hooks/register.ts +151 -0
- package/mods/delete-guard/types/index.d.ts +14 -0
- package/mods/focus-mode/.claude-plugin/plugin.json +17 -0
- package/mods/focus-mode/hooks/hooks.json +1 -0
- package/mods/focus-mode/hooks/register.tsx +184 -0
- package/mods/focus-mode/types/index.d.ts +14 -0
- package/mods/handoff/.claude-plugin/plugin.json +9 -0
- package/mods/handoff/hooks/hooks.json +1 -0
- package/mods/handoff/hooks/register.tsx +156 -0
- package/mods/handoff/types/index.d.ts +15 -0
- package/mods/hide-personal-data/.claude-plugin/plugin.json +17 -0
- package/mods/hide-personal-data/hooks/hooks.json +1 -0
- package/mods/hide-personal-data/hooks/register.tsx +196 -0
- package/mods/hide-personal-data/types/index.d.ts +17 -0
- package/mods/mini-game/.claude-plugin/plugin.json +9 -0
- package/mods/mini-game/hooks/hooks.json +1 -0
- package/mods/mini-game/hooks/register.tsx +273 -0
- package/mods/mini-game/types/index.d.ts +21 -0
- package/mods/tool-timeline/.claude-plugin/plugin.json +9 -0
- package/mods/tool-timeline/hooks/hooks.json +1 -0
- package/mods/tool-timeline/hooks/register.tsx +311 -0
- package/mods/tool-timeline/types/index.d.ts +25 -0
- package/mods/tsconfig.json +17 -0
- package/mods/you-should-know/.claude-plugin/plugin.json +23 -0
- package/mods/you-should-know/hooks/hooks.json +1 -0
- package/mods/you-should-know/hooks/register.tsx +116 -0
- package/mods/you-should-know/types/index.d.ts +15 -0
- package/package.json +25 -4
- package/README.md +0 -3
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/tool-timeline/hooks/register.tsx
|
|
2
|
+
import { atom, read, update } from 'claude-code'
|
|
3
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
4
|
+
|
|
5
|
+
import type { CallStatus, TimelineCall } from '../types'
|
|
6
|
+
|
|
7
|
+
const PANE = 'tool-timeline'
|
|
8
|
+
const TITLE = 'Tool timeline'
|
|
9
|
+
const calls = atom({ plugin: 'tool-timeline', key: 'calls' } as const, [])
|
|
10
|
+
|
|
11
|
+
/** Calls kept in state; older ones fall off. */
|
|
12
|
+
const KEEP = 100
|
|
13
|
+
|
|
14
|
+
/** Approximate pixel width of one cell of the editor's code font. */
|
|
15
|
+
const CELL_PX = 7
|
|
16
|
+
|
|
17
|
+
const TRACK = '#2b3240'
|
|
18
|
+
const ERROR = '#e06c6c'
|
|
19
|
+
const DIM = '#8a8f98'
|
|
20
|
+
|
|
21
|
+
/** Badge color per tool family. */
|
|
22
|
+
const TOOL_COLORS: ReadonlyArray<[RegExp, string]> = [
|
|
23
|
+
[/^Bash$|^PowerShell$/, '#e5b567'],
|
|
24
|
+
[/^Read$/, '#5b8def'],
|
|
25
|
+
[/^(Edit|Write|NotebookEdit)$/, '#7cc47f'],
|
|
26
|
+
[/^(Grep|Glob|LS)$/, '#9b87f5'],
|
|
27
|
+
[/^Web/, '#5fb3c4'],
|
|
28
|
+
[/^(Agent|Task)$/, '#f09bb5'],
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
/** Input fields that say most about a call, tried in order. */
|
|
32
|
+
const LABEL_FIELDS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'skill', 'prompt'] as const
|
|
33
|
+
|
|
34
|
+
/** Finds the badge color of a tool, or the dim color for a tool outside every family. */
|
|
35
|
+
function colorOf(tool: string): string {
|
|
36
|
+
return TOOL_COLORS.find(([match]) => match.test(tool))?.[1] ?? DIM
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Picks the one-character badge a tool shows: `$` for shell tools, else its first letter. */
|
|
40
|
+
function badgeOf(tool: string): string {
|
|
41
|
+
if (tool === 'Bash' || tool === 'PowerShell') return '$'
|
|
42
|
+
return tool.replace(/^mcp__[^_]+__/, '').charAt(0).toUpperCase()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @brief Shortens a call's input to the text the pane shows: a command, a file's name, a pattern, or the first text field.
|
|
47
|
+
*
|
|
48
|
+
* @param input - Arguments of the tool call.
|
|
49
|
+
* @returns The label, with whitespace collapsed to single spaces.
|
|
50
|
+
*/
|
|
51
|
+
export function labelOf(input: Record<string, unknown>): string {
|
|
52
|
+
for (const field of LABEL_FIELDS) {
|
|
53
|
+
const value = input[field]
|
|
54
|
+
if (typeof value !== 'string' || value.length === 0) continue
|
|
55
|
+
const text = field === 'file_path' || field === 'notebook_path' ? value.split('/').pop() ?? value : value
|
|
56
|
+
return text.replace(/\s+/g, ' ').trim()
|
|
57
|
+
}
|
|
58
|
+
const first = Object.values(input).find((value): value is string => typeof value === 'string')
|
|
59
|
+
return first?.replace(/\s+/g, ' ').trim() ?? ''
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @brief Joins the text of a tool result's content, which is a string or a list of text blocks.
|
|
64
|
+
*
|
|
65
|
+
* @param content - Result content: a string, or a list of blocks with a `text` field.
|
|
66
|
+
* @returns The text, one block per line.
|
|
67
|
+
*/
|
|
68
|
+
function resultText(content: unknown): string {
|
|
69
|
+
if (typeof content === 'string') return content
|
|
70
|
+
if (!Array.isArray(content)) return ''
|
|
71
|
+
return content
|
|
72
|
+
.map(block => (typeof block === 'object' && block !== null && 'text' in block && typeof block.text === 'string' ? block.text : ''))
|
|
73
|
+
.join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Checks whether an error text reads as a refusal: a hook's deny, the permission dialog's no, a blocked call. */
|
|
77
|
+
function isRefusal(text: string): boolean {
|
|
78
|
+
return /\bdenied\b|\bblocked\b|doesn't want to proceed|was rejected|permission/i.test(text)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Formats a duration in milliseconds as seconds, with one decimal below ten seconds. */
|
|
82
|
+
function seconds(ms: number): string {
|
|
83
|
+
return ms >= 10_000 ? `${Math.round(ms / 1000)}s` : `${(ms / 1000).toFixed(1)}s`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Shortens a call's note for the callout, e.g. `use the trash` for delete-guard's deny. */
|
|
87
|
+
function shortNote(note: string | undefined): string {
|
|
88
|
+
if (note === undefined) return ''
|
|
89
|
+
if (/trash/i.test(note)) return 'use the trash'
|
|
90
|
+
const text = note.replace(/^[\w-]+:\s*/, '')
|
|
91
|
+
return text.length > 40 ? `${text.slice(0, 39)}…` : text
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @brief Adds a call or changes the one with its id; the list stays in start order and capped.
|
|
96
|
+
*
|
|
97
|
+
* @param $ - Engine handle the calls state is updated through.
|
|
98
|
+
* @param id - Tool use id of the call.
|
|
99
|
+
* @param patch - Builds the call's next value from its stored value, or from `undefined` when the call is new.
|
|
100
|
+
*/
|
|
101
|
+
async function patchCall($: EngineInterface, id: string, patch: (call: TimelineCall | undefined) => TimelineCall): Promise<void> {
|
|
102
|
+
await update($, calls, list => {
|
|
103
|
+
const index = list.findIndex(call => call.id === id)
|
|
104
|
+
if (index === -1) return [...list, patch(undefined)].slice(-KEEP)
|
|
105
|
+
return list.map((call, i) => (i === index ? patch(call) : call))
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @brief Ends a call with its status, keeping the first ending it got.
|
|
111
|
+
*
|
|
112
|
+
* @param $ - Engine handle the calls state is updated through.
|
|
113
|
+
* @param id - Tool use id of the call.
|
|
114
|
+
* @param status - Status the call ends with.
|
|
115
|
+
* @param note - Text shown with the status, such as a refusal reason.
|
|
116
|
+
*/
|
|
117
|
+
async function settleCall($: EngineInterface, id: string, status: CallStatus, note?: string): Promise<void> {
|
|
118
|
+
const endedAt = await $.clock.now()
|
|
119
|
+
await patchCall($, id, call =>
|
|
120
|
+
call === undefined
|
|
121
|
+
? { id, tool: 'tool', label: '', status, startedAt: endedAt, endedAt, note }
|
|
122
|
+
: call.endedAt !== undefined ? call : { ...call, status, endedAt, note },
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @brief Redraws the pane while a call runs, so its bar and time move.
|
|
128
|
+
*
|
|
129
|
+
* @param $ - Engine handle the pane is redrawn through.
|
|
130
|
+
*/
|
|
131
|
+
async function tick($: EngineInterface): Promise<void> {
|
|
132
|
+
const list = await read($, calls)
|
|
133
|
+
if (list.some(call => call.status === 'running')) $.ui.invalidate('ui.render')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @brief Finds the call that replaced a denied call: the next call of the same tool.
|
|
138
|
+
*
|
|
139
|
+
* @param list - Calls in start order.
|
|
140
|
+
* @param index - Position of the denied call in `list`.
|
|
141
|
+
* @returns The replacing call, or `undefined` when none follows.
|
|
142
|
+
*/
|
|
143
|
+
function replacementOf(list: readonly TimelineCall[], index: number): TimelineCall | undefined {
|
|
144
|
+
const denied = list[index]
|
|
145
|
+
return denied === undefined ? undefined : list.slice(index + 1).find(call => call.tool === denied.tool)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @brief Builds the SVG of a track with a bar filled to `fill` pixels.
|
|
150
|
+
*
|
|
151
|
+
* @param width - Width of the track in pixels.
|
|
152
|
+
* @param fill - Filled length in pixels, clamped to the width.
|
|
153
|
+
* @param color - Fill color.
|
|
154
|
+
* @returns The SVG markup.
|
|
155
|
+
*/
|
|
156
|
+
function barSvg(width: number, fill: number, color: string): string {
|
|
157
|
+
const w = Math.max(0, Math.min(width, fill))
|
|
158
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8"><rect width="${width}" height="8" rx="2" fill="${TRACK}"/><rect width="${w.toFixed(1)}" height="8" rx="2" fill="${color}"/></svg>`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @brief Subscribes the timeline's handlers to the engine's events, its command and its pane.
|
|
163
|
+
*
|
|
164
|
+
* @param on - Subscribes a handler to one engine event, optionally filtered.
|
|
165
|
+
*/
|
|
166
|
+
export const register: Register = on => {
|
|
167
|
+
on('session.start', async ($, e, next) => {
|
|
168
|
+
await $.command.register({ name: 'tool-timeline', description: 'Show or hide the tool call timeline' })
|
|
169
|
+
void $.ui.open({ id: PANE, title: TITLE })
|
|
170
|
+
$.clock.every(1000, () => void tick($))
|
|
171
|
+
return next(e)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
on('command.run', { command: 'tool-timeline' }, async $ => {
|
|
175
|
+
if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
|
|
176
|
+
await $.ui.close({ id: PANE })
|
|
177
|
+
return { text: 'Tool timeline hidden.' }
|
|
178
|
+
}
|
|
179
|
+
await $.ui.open({ id: PANE, title: TITLE })
|
|
180
|
+
return { text: 'Tool timeline shown.' }
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
on('tool.call', async ($, e, next) => {
|
|
184
|
+
const startedAt = await $.clock.now()
|
|
185
|
+
const label = labelOf(e as unknown as Record<string, unknown>)
|
|
186
|
+
await patchCall($, e.tool_use_id, call => ({
|
|
187
|
+
...call,
|
|
188
|
+
id: e.tool_use_id,
|
|
189
|
+
tool: e.tool,
|
|
190
|
+
label,
|
|
191
|
+
status: 'running',
|
|
192
|
+
startedAt,
|
|
193
|
+
agentId: e.agentId,
|
|
194
|
+
}))
|
|
195
|
+
|
|
196
|
+
const ran = await next(e)
|
|
197
|
+
if ('deny' in ran) await settleCall($, e.tool_use_id, 'denied', ran.deny)
|
|
198
|
+
else if (ran.isError === true) await settleCall($, e.tool_use_id, isRefusal(ran.text ?? '') ? 'denied' : 'error', ran.text)
|
|
199
|
+
else await settleCall($, e.tool_use_id, 'ok')
|
|
200
|
+
return ran
|
|
201
|
+
}).catch(($, e, next) => next(e))
|
|
202
|
+
|
|
203
|
+
on('session.append', async ($, e, next) => {
|
|
204
|
+
const stored = await next(e)
|
|
205
|
+
if (e.door === 'response') {
|
|
206
|
+
const now = await $.clock.now()
|
|
207
|
+
for (const block of e.message.content) {
|
|
208
|
+
if (block.type !== 'tool_use' || typeof block.id !== 'string' || typeof block.name !== 'string') continue
|
|
209
|
+
const input = typeof block.input === 'object' && block.input !== null ? (block.input as Record<string, unknown>) : {}
|
|
210
|
+
const id = block.id
|
|
211
|
+
const tool = block.name
|
|
212
|
+
await patchCall($, id, call => call ?? { id, tool, label: labelOf(input), status: 'queued', startedAt: now, agentId: e.agentId })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (e.door === 'tool-result') {
|
|
216
|
+
for (const block of e.message.content) {
|
|
217
|
+
if (block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue
|
|
218
|
+
const list = await read($, calls)
|
|
219
|
+
const call = list.find(one => one.id === block.tool_use_id)
|
|
220
|
+
if (call?.status === 'queued') {
|
|
221
|
+
const text = resultText(block.content)
|
|
222
|
+
await settleCall($, block.tool_use_id, block.is_error === true ? 'denied' : 'ok', text)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return stored
|
|
227
|
+
}).catch(($, e, next) => next(e))
|
|
228
|
+
|
|
229
|
+
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
230
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
231
|
+
const list = await read($, calls)
|
|
232
|
+
if (list.length === 0) return <Text dimColor>No tool calls yet.</Text>
|
|
233
|
+
|
|
234
|
+
const now = await $.clock.now()
|
|
235
|
+
const room = Math.max(4, (e.viewport?.rows ?? 30) - 6)
|
|
236
|
+
const shown = list.slice(-room)
|
|
237
|
+
/** Length of a call so far, or up to its end when it has one. */
|
|
238
|
+
const durationOf = (call: TimelineCall): number => (call.endedAt ?? now) - call.startedAt
|
|
239
|
+
const longest = Math.max(1, ...shown.map(durationOf))
|
|
240
|
+
const total = list.reduce((sum, call) => sum + (call.status === 'denied' ? 0 : durationOf(call)), 0)
|
|
241
|
+
const barCells = Math.max(8, Math.floor(e.props.bodyColumns * 0.3))
|
|
242
|
+
const labelCells = Math.max(12, e.props.bodyColumns - barCells - 12)
|
|
243
|
+
|
|
244
|
+
/** Draws the bar showing a call's share of the longest call's time. */
|
|
245
|
+
const bar = (call: TimelineCall) => {
|
|
246
|
+
const color = call.status === 'error' ? ERROR : call.status === 'ok' && /^Bash$/.test(call.tool) ? '#5fae6e' : colorOf(call.tool)
|
|
247
|
+
const share = call.status === 'denied' ? 0.04 : Math.max(0.03, durationOf(call) / longest)
|
|
248
|
+
if (e.surface === 'terminal') {
|
|
249
|
+
const filled = Math.max(1, Math.round(share * barCells))
|
|
250
|
+
return (
|
|
251
|
+
<Text>
|
|
252
|
+
<Text color={color}>{'▬'.repeat(filled)}</Text>
|
|
253
|
+
<Text color={TRACK}>{'▬'.repeat(barCells - filled)}</Text>
|
|
254
|
+
</Text>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
const { Svg } = $.ui.resolve(e)
|
|
258
|
+
const width = barCells * CELL_PX
|
|
259
|
+
return <Svg source={barSvg(width, share * width, color)} alt={`${call.tool} ${seconds(durationOf(call))}`} width={width} height={8} />
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Renders a call's status at the end of its row: its time, `denied`, `queued` or a mark for an error. */
|
|
263
|
+
const outcome = (call: TimelineCall) => {
|
|
264
|
+
switch (call.status) {
|
|
265
|
+
case 'denied': return <Text color={ERROR}>denied</Text>
|
|
266
|
+
case 'error': return <Text color={ERROR}>{seconds(durationOf(call))} ✗</Text>
|
|
267
|
+
case 'ok': return <Text dimColor>{seconds(durationOf(call))}</Text>
|
|
268
|
+
case 'running': return <Text color="#e5b567">{seconds(durationOf(call))}…</Text>
|
|
269
|
+
case 'queued': return <Text dimColor>queued</Text>
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const rows = shown.map((call, index) => {
|
|
274
|
+
const replacement = call.status === 'denied' ? replacementOf(shown, index) : undefined
|
|
275
|
+
return (
|
|
276
|
+
<Box flexDirection="column">
|
|
277
|
+
<Box flexDirection="row" columnGap={1}>
|
|
278
|
+
<Text bold color="#1b1f27" backgroundColor={colorOf(call.tool)}> {badgeOf(call.tool)} </Text>
|
|
279
|
+
<Box width={labelCells}>
|
|
280
|
+
<Text wrap="truncate-end" strikethrough={call.status === 'denied'} color={call.status === 'denied' ? ERROR : undefined}>
|
|
281
|
+
{call.agentId === undefined ? '' : '↳ '}{call.tool} <Text dimColor>{call.label}</Text>
|
|
282
|
+
</Text>
|
|
283
|
+
</Box>
|
|
284
|
+
{bar(call)}
|
|
285
|
+
<Box width={8} justifyContent="flex-end">{outcome(call)}</Box>
|
|
286
|
+
</Box>
|
|
287
|
+
{call.status === 'denied' && (
|
|
288
|
+
<Box borderStyle="round" borderColor="#7a3b3b" paddingX={1} flexDirection="row" justifyContent="space-between">
|
|
289
|
+
<Text wrap="truncate-end">
|
|
290
|
+
<Text color={ERROR}>⊘ </Text>
|
|
291
|
+
<Text strikethrough dimColor>{call.label}</Text>
|
|
292
|
+
{replacement === undefined ? '' : <Text> → <Text color="#7cc47f">{replacement.label}</Text></Text>}
|
|
293
|
+
</Text>
|
|
294
|
+
<Text color={ERROR}>denied{call.note === undefined ? '' : ` · ${shortNote(call.note)}`}</Text>
|
|
295
|
+
</Box>
|
|
296
|
+
)}
|
|
297
|
+
</Box>
|
|
298
|
+
)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<Box flexDirection="column" gap={0}>
|
|
303
|
+
<Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
|
|
304
|
+
<Text bold>Tool timeline</Text>
|
|
305
|
+
<Text dimColor>{list.length} call{list.length === 1 ? '' : 's'} · {seconds(total)}</Text>
|
|
306
|
+
</Box>
|
|
307
|
+
{rows}
|
|
308
|
+
</Box>
|
|
309
|
+
)
|
|
310
|
+
})
|
|
311
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/tool-timeline/types/index.d.ts
|
|
2
|
+
|
|
3
|
+
/** How a call ended: still waiting or running, done, failed, or refused before it ran. */
|
|
4
|
+
export type CallStatus = 'queued' | 'running' | 'ok' | 'error' | 'denied'
|
|
5
|
+
|
|
6
|
+
/** One tool call on the timeline; times are milliseconds since the epoch. */
|
|
7
|
+
export type TimelineCall = {
|
|
8
|
+
id: string
|
|
9
|
+
tool: string
|
|
10
|
+
/** Short form of the call's input, e.g. `rm -rf dist` or `package.json` */
|
|
11
|
+
label: string
|
|
12
|
+
status: CallStatus
|
|
13
|
+
startedAt: number
|
|
14
|
+
endedAt?: number
|
|
15
|
+
/** Why it was denied or failed, as the deny or error text said */
|
|
16
|
+
note?: string
|
|
17
|
+
/** Set on calls a subagent made */
|
|
18
|
+
agentId?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare module 'claude-code' {
|
|
22
|
+
interface PluginState {
|
|
23
|
+
'tool-timeline': { calls: TimelineCall[] }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2023",
|
|
4
|
+
"lib": ["es2023"],
|
|
5
|
+
"types": [],
|
|
6
|
+
"module": "esnext",
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"jsx": "react",
|
|
13
|
+
"jsxFactory": "h",
|
|
14
|
+
"jsxFragmentFactory": "Fragment"
|
|
15
|
+
},
|
|
16
|
+
"include": ["../types/claude-code.d.ts", "*/hooks", "*/types"]
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "you-should-know",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A card under long replies that flags their key point: a headline and one supporting line.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "g3ntrovert"
|
|
7
|
+
},
|
|
8
|
+
"types": "./types/index.d.ts",
|
|
9
|
+
"userConfig": {
|
|
10
|
+
"minChars": {
|
|
11
|
+
"type": "number",
|
|
12
|
+
"title": "Minimum reply length",
|
|
13
|
+
"default": 1500,
|
|
14
|
+
"description": "Replies shorter than this many characters get no card"
|
|
15
|
+
},
|
|
16
|
+
"model": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"title": "Model",
|
|
19
|
+
"default": "haiku",
|
|
20
|
+
"description": "Model that picks the key point"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.tsx"] }
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/you-should-know/hooks/register.tsx
|
|
2
|
+
import { atom, read, update } from 'claude-code'
|
|
3
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
4
|
+
|
|
5
|
+
import type { Card } from '../types'
|
|
6
|
+
|
|
7
|
+
const cards = atom({ plugin: 'you-should-know', key: 'cards' } as const, [])
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MIN_CHARS = 1500
|
|
10
|
+
const DEFAULT_MODEL = 'haiku'
|
|
11
|
+
const TAIL = 200
|
|
12
|
+
const KEEP = 20
|
|
13
|
+
/** Most of a reply sent to the model; the end of a reply carries its conclusion. */
|
|
14
|
+
const MAX_PROMPT_CHARS = 40_000
|
|
15
|
+
|
|
16
|
+
const SYSTEM = [
|
|
17
|
+
'You read one reply that an AI coding assistant wrote to its user, and pick the single point the user must not miss:',
|
|
18
|
+
'a cost, a risk, a decision they have to make, a breaking change, or a surprising fact.',
|
|
19
|
+
'Answer with JSON only, no prose and no code fence: {"headline": "...", "detail": "..."}.',
|
|
20
|
+
'The headline is a plain statement of at most 70 characters. The detail is one supporting line of at most 120 characters.',
|
|
21
|
+
'Use the reply\'s own facts and numbers. If nothing in the reply stands out, answer {"skip": true}.',
|
|
22
|
+
].join(' ')
|
|
23
|
+
|
|
24
|
+
const BULB_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><circle cx="20" cy="20" r="20" fill="#3d3424"/><path d="M20 11a6 6 0 0 0-3.6 10.8c.8.6 1.1 1.3 1.1 2.2v1h5v-1c0-.9.3-1.6 1.1-2.2A6 6 0 0 0 20 11z" fill="none" stroke="#e5b567" stroke-width="1.6"/><path d="M17.5 28h5M18.3 30.5h3.4" stroke="#e5b567" stroke-width="1.6" stroke-linecap="round"/></svg>'
|
|
25
|
+
|
|
26
|
+
/** Collapses every run of whitespace to one space and trims both ends. */
|
|
27
|
+
function normalize(text: string): string {
|
|
28
|
+
return text.replace(/\s+/g, ' ').trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @brief Reads the model's answer into a card's headline and detail.
|
|
33
|
+
*
|
|
34
|
+
* @param text - Raw answer from the model.
|
|
35
|
+
* @returns The headline and detail, or `undefined` for a skip or anything malformed.
|
|
36
|
+
*/
|
|
37
|
+
export function parseCard(text: string): Omit<Card, 'tail'> | undefined {
|
|
38
|
+
const json = /\{[\s\S]*\}/.exec(text)?.[0]
|
|
39
|
+
if (json === undefined) return undefined
|
|
40
|
+
let value: unknown
|
|
41
|
+
try {
|
|
42
|
+
value = JSON.parse(json)
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
if (typeof value !== 'object' || value === null || !('headline' in value) || !('detail' in value)) return undefined
|
|
47
|
+
const { headline, detail } = value
|
|
48
|
+
if (typeof headline !== 'string' || typeof detail !== 'string' || headline.trim().length === 0) return undefined
|
|
49
|
+
return { headline: headline.trim().slice(0, 90), detail: detail.trim().slice(0, 160) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @brief Asks the model for the reply's key point and keeps the card when there is one.
|
|
54
|
+
*
|
|
55
|
+
* @param $ - Engine handle the model call and the cards state go through.
|
|
56
|
+
* @param answer - Full text of the assistant's reply.
|
|
57
|
+
* @param model - Model that reads the reply.
|
|
58
|
+
*/
|
|
59
|
+
async function extract($: EngineInterface, answer: string, model: string): Promise<void> {
|
|
60
|
+
const result = await $.model.complete({ model, system: SYSTEM, prompt: answer.slice(-MAX_PROMPT_CHARS), maxTokens: 300 })
|
|
61
|
+
if (!result.isAnswered) return
|
|
62
|
+
const card = parseCard(result.text)
|
|
63
|
+
if (card === undefined) return
|
|
64
|
+
const tail = normalize(answer).slice(-TAIL)
|
|
65
|
+
await update($, cards, list => [...list.filter(one => one.tail !== tail), { tail, ...card }].slice(-KEEP))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @brief Subscribes the card handlers to the engine's events and renders the card.
|
|
70
|
+
*
|
|
71
|
+
* @param on - Subscribes a handler to one engine event.
|
|
72
|
+
* @param options - Plugin options: `minChars` sets the shortest reply read, `model` the model that reads it.
|
|
73
|
+
*/
|
|
74
|
+
export const register: Register = (on, options) => {
|
|
75
|
+
const minChars = Number(options.minChars ?? DEFAULT_MIN_CHARS)
|
|
76
|
+
const model = typeof options.model === 'string' && options.model.length > 0 ? options.model : DEFAULT_MODEL
|
|
77
|
+
|
|
78
|
+
on('turn.complete', async ($, e, next) => {
|
|
79
|
+
const result = await next(e)
|
|
80
|
+
if (e.agentId === undefined && e.reason === 'answer' && e.answer.length >= minChars) {
|
|
81
|
+
const answer = e.answer
|
|
82
|
+
$.clock.after(0, () => void extract($, answer, model))
|
|
83
|
+
}
|
|
84
|
+
return result
|
|
85
|
+
}).catch(($, e, next) => next(e))
|
|
86
|
+
|
|
87
|
+
on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
|
|
88
|
+
if (e.props.isSummary === true) return next(e)
|
|
89
|
+
const text = normalize(e.props.text)
|
|
90
|
+
if (text.length < 20) return next(e)
|
|
91
|
+
const card = (await read($, cards)).find(one => one.tail.endsWith(text.slice(-TAIL)))
|
|
92
|
+
if (card === undefined) return next(e)
|
|
93
|
+
|
|
94
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
95
|
+
const icon = e.surface === 'terminal'
|
|
96
|
+
? <Text color="#e5b567">💡</Text>
|
|
97
|
+
: (() => {
|
|
98
|
+
const { Svg } = $.ui.resolve(e)
|
|
99
|
+
return <Svg source={BULB_SVG} alt="key point" width={40} height={40} />
|
|
100
|
+
})()
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<Box flexDirection="column" gap={1}>
|
|
104
|
+
{await next(e)}
|
|
105
|
+
<Box borderStyle="round" borderColor="#3a3f4b" paddingX={2} paddingY={1} flexDirection="row" columnGap={2} alignItems="center">
|
|
106
|
+
{icon}
|
|
107
|
+
<Box flexDirection="column">
|
|
108
|
+
<Text bold color="#e5b567">YOU SHOULD KNOW</Text>
|
|
109
|
+
<Text bold>{card.headline}</Text>
|
|
110
|
+
<Text dimColor>{card.detail}</Text>
|
|
111
|
+
</Box>
|
|
112
|
+
</Box>
|
|
113
|
+
</Box>
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/you-should-know/types/index.d.ts
|
|
2
|
+
|
|
3
|
+
/** The key point of one long reply, keyed by the reply's last characters so its message can find it. */
|
|
4
|
+
export type Card = {
|
|
5
|
+
/** The reply's last 200 characters, whitespace collapsed */
|
|
6
|
+
tail: string
|
|
7
|
+
headline: string
|
|
8
|
+
detail: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare module 'claude-code' {
|
|
12
|
+
interface PluginState {
|
|
13
|
+
'you-should-know': { cards: Card[] }
|
|
14
|
+
}
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@g3nomaly/g3n-mods",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The g3n Claude Code mods: panes, status lines, commands and hooks, installed with @g3nomaly/mods.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
".claude-plugin",
|
|
8
|
+
"mods"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "bun run build:marketplace",
|
|
12
|
+
"typecheck": "tsc --noEmit && tsc --noEmit --project mods/tsconfig.json",
|
|
13
|
+
"validate": "bun scripts/validate.ts",
|
|
14
|
+
"build:marketplace": "bun scripts/build-marketplace.ts",
|
|
15
|
+
"types": "bun scripts/sync-types.ts"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@g3nomaly/core": "^0.3.1",
|
|
19
|
+
"@types/bun": "^1.4.0",
|
|
20
|
+
"typescript": "~7.0.2",
|
|
21
|
+
"zod": "^4.4.3"
|
|
22
|
+
},
|
|
23
|
+
"license": "Elastic-2.0",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/README.md
DELETED