@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.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +68 -0
  2. package/LICENSE +95 -0
  3. package/mods/agents-panel/.claude-plugin/plugin.json +9 -0
  4. package/mods/agents-panel/hooks/hooks.json +1 -0
  5. package/mods/agents-panel/hooks/register.tsx +277 -0
  6. package/mods/agents-panel/types/index.d.ts +24 -0
  7. package/mods/context-bar/.claude-plugin/plugin.json +9 -0
  8. package/mods/context-bar/hooks/hooks.json +1 -0
  9. package/mods/context-bar/hooks/register.tsx +215 -0
  10. package/mods/context-bar/types/index.d.ts +25 -0
  11. package/mods/custom-crab-agents/.claude-plugin/plugin.json +9 -0
  12. package/mods/custom-crab-agents/hooks/hooks.json +1 -0
  13. package/mods/custom-crab-agents/hooks/register.tsx +508 -0
  14. package/mods/custom-crab-agents/types/index.d.ts +45 -0
  15. package/mods/delete-guard/.claude-plugin/plugin.json +9 -0
  16. package/mods/delete-guard/hooks/hooks.json +1 -0
  17. package/mods/delete-guard/hooks/register.ts +151 -0
  18. package/mods/delete-guard/types/index.d.ts +14 -0
  19. package/mods/focus-mode/.claude-plugin/plugin.json +17 -0
  20. package/mods/focus-mode/hooks/hooks.json +1 -0
  21. package/mods/focus-mode/hooks/register.tsx +184 -0
  22. package/mods/focus-mode/types/index.d.ts +14 -0
  23. package/mods/handoff/.claude-plugin/plugin.json +9 -0
  24. package/mods/handoff/hooks/hooks.json +1 -0
  25. package/mods/handoff/hooks/register.tsx +156 -0
  26. package/mods/handoff/types/index.d.ts +15 -0
  27. package/mods/hide-personal-data/.claude-plugin/plugin.json +17 -0
  28. package/mods/hide-personal-data/hooks/hooks.json +1 -0
  29. package/mods/hide-personal-data/hooks/register.tsx +196 -0
  30. package/mods/hide-personal-data/types/index.d.ts +17 -0
  31. package/mods/mini-game/.claude-plugin/plugin.json +9 -0
  32. package/mods/mini-game/hooks/hooks.json +1 -0
  33. package/mods/mini-game/hooks/register.tsx +273 -0
  34. package/mods/mini-game/types/index.d.ts +21 -0
  35. package/mods/tool-timeline/.claude-plugin/plugin.json +9 -0
  36. package/mods/tool-timeline/hooks/hooks.json +1 -0
  37. package/mods/tool-timeline/hooks/register.tsx +311 -0
  38. package/mods/tool-timeline/types/index.d.ts +25 -0
  39. package/mods/tsconfig.json +17 -0
  40. package/mods/you-should-know/.claude-plugin/plugin.json +23 -0
  41. package/mods/you-should-know/hooks/hooks.json +1 -0
  42. package/mods/you-should-know/hooks/register.tsx +116 -0
  43. package/mods/you-should-know/types/index.d.ts +15 -0
  44. package/package.json +25 -4
  45. 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.