Claude Code mod for exploring PD Intelligence data and building reports from it, made for non-technical users: /pd opens a panel with your datasets and the…

A Claude Code mod (a function-hooks plugin) for exploring PD Intelligence data and turning it into reports, made for people who would rather press a button than type an id. Pairs with the pd-intelligence skills plugin; works without it.
It uses the PD Intelligence connector you already have set up in claude.ai, recognised by its tools whatever the connector is named. It ships no server of its own.
/pdType /pd and the PD Intelligence panel appears right in the chat (terminal, desktop and mobile alike):
📈 Example Campaign data as of Oct 9 [ Refresh ]
Dataset: [ 📈 Example Campaign ▾ ]
POSTS · per day FOLLOWERS · change ACCOUNTS POSTING · per day
79,986 +4,667 118.3M +0.1% 112 -8
▃▅▂▄▆▄▅▇▃▂▆▆▅▅ ▁▁▂▂▃▃▃▄▄▅▅▆▆▇ ▅▆▆▅▆▇▆▅▅▆▆▅▄▄
Views 8B (+5.8%) · Accounts 163 · Creators 121
PLATFORM MIX · last 7 days
████████████████████████▓
X 98% · YouTube 2%
TOP POSTS THIS WEEK
1. @someone · X "First words of the post…" 1.2M views [ Why? ]
NEEDS ATTENTION
@quiet · Instagram · 53 posts · 2,245 views [ Look into it ]
[ Explore ] [ Write a report ] [ Today's brief ] [ Sources used (4) ] [ Save results for charts: Off ]
PD Intelligence · 📈 Example Campaign), and Claude uses it without asking./pd opens instantly; Refresh fetches it again now. A section that fails says so on its own line; the rest still shows.reports/ ending in a sources list../pd-intel-evidence/ (git-ignored automatically) so charts are built from the real numbers.Inside the panel (nothing goes to the chat): Today shows PD's daily summary and that day's top posts; Details on a post shows its numbers and, on request, a summary of the comments PD collected (this can take a minute or two); Details on an account shows its followers, activity and latest posts. ← Back returns to the overview. They keep working while Claude is busy.
Ask Claude buttons hand the work to Claude in the chat. Each one appears under Requests to Claude: Sent, Claude is on it…, then Answered with a preview and Show answer, which opens the full answer in the panel. The Ask Claude card offers ready-made questions, and the datasets you used most recently come first in the dataset list.
The panel stays live: press a dataset or a button and it updates in place. Type /pd again any time for a fresh one.
Side panel or chat — your choice. On a computer, press Move to side panel and the panel docks beside the conversation; Show it here instead brings it back into the chat. The choice is remembered, so /pd opens it where you left it.
On mobile the Claude app shows /pd as a text summary (the same numbers and dataset list, without buttons). Switch datasets by just asking — "work in Countercurrent" — or with /pd-dataset and the number.
In auto mode, Claude Code may refuse the lookups the panel makes by itself (there is no request of yours behind them to judge), and /pd then says so. Allow the read-only tools the panel uses in your permission settings, or switch out of auto mode. In ~/.claude/settings.json, with SERVER being your PD Intelligence connector's name as it appears in its tool names (mcp__SERVER__list_datasets):
"permissions": {
"allow": [
"mcp__SERVER__list_datasets",
"mcp__SERVER__get_totals",
"mcp__SERVER__run_analytics_sql",
"mcp__SERVER__get_leaderboard",
"mcp__SERVER__get_comment_summary",
"mcp__SERVER__search_accounts"
]
}
| "Work in …" | Ask Claude to switch datasets in plain words; it uses the mod's choose_dataset tool. | ||
/pd-dataset ID / /pd-dataset off | Choose or clear the dataset by id. | ||
| `/pd-evidence [on \ | off \ | clear]` | The sources list and the save-for-charts switch, as commands. |
/pd-intel-code:explore, /pd-intel-code:report | What the panel's two buttons run. | ||
| Write guard | delete_tag, delete_creators, erase_account and share_document always ask first, even if a permission rule allows all PD tools. (Under bypass-permissions mode the mode still decides.) |
/plugin install pd-intel-code --marketplace BetterNeighborsAI/pd-intelligence-plugin
Inside BetterNeighborsAI it is also in the org marketplace: /plugin install pd-intel-code@better-neighbors-ai.
Function hooks are an early-access Claude Code feature; keep Claude Code up to date.
claude plugin validate plugins/pd-intel-code # manifest + hooks module
claude plugin test plugins/pd-intel-code # hooks/register.test.tsxhooks/register.tsx 711 lines1import { atom, read, update } from 'claude-code'
2import type { ButtonProps, Elements, EngineInterface, Register, RenderElement, ResolveInput, ToolCallResult, UiPressArgument } from 'claude-code'
3
4import type {
5 PdAccountRef,
6 PdAccountView,
7 PdDataset,
8 PdPostView,
9 PdEvidence,
10 PdScreen,
11 PdSection,
12 PdSnapshot,
13 PdToday,
14 PdTopPost,
15} from '../types'
16import { drawPanel as renderPanel, summary, type PanelActions, type PanelData } from './panel'
17import {
18 DAY_MS,
19 GLANCE_DAYS,
20 MCP_TOOL,
21 PD_SIGNATURE,
22 SECTION_KEYS,
23 SERIES_DAYS,
24 startRequest,
25 type SectionKey,
26 accountPostsSql,
27 activitySql,
28 dayPostsSql,
29 postDetailSql,
30 SUMMARY_SQL,
31 blockedMessage,
32 dayKey,
33 followersSql,
34 isStale,
35 label,
36 lastDays,
37 parseJson,
38 platformKey,
39 pushRecent,
40 requestDone,
41 requestStarted,
42 rowsOf,
43 serverName,
44 shortName,
45 toAccountStats,
46 toActivity,
47 toAttention,
48 toCommentSummary,
49 toDatasets,
50 toFollowers,
51 toGlance,
52 toPostDetail,
53 toSummary,
54 toTopPosts,
55 topPostsSql,
56} from './data'
57
58const CHOOSE_TOOL = 'mcp__pd-intel-code__choose_dataset'
59// Writes that remove data or widen who can see it: always put to the person.
60const CONFIRM_TOOL = /^mcp__.+__(delete_tag|delete_creators|erase_account|share_document)$/
61// A connector can still be connecting when Claude Code has just started: how long /pd waits.
62const CONNECT_TRIES = 4
63const CONNECT_WAIT_MS = 1500
64
65const ledger = atom({ plugin: 'pd-intel-code', key: 'ledger' } as const, [])
66const datasets = atom({ plugin: 'pd-intel-code', key: 'datasets' } as const, null)
67const pinned = atom({ plugin: 'pd-intel-code', key: 'pinned' } as const, null)
68const snapshot = atom({ plugin: 'pd-intel-code', key: 'snapshot' } as const, null)
69const screen = atom({ plugin: 'pd-intel-code', key: 'screen' } as const, { kind: 'home' })
70const today = atom({ plugin: 'pd-intel-code', key: 'today' } as const, null)
71const postView = atom({ plugin: 'pd-intel-code', key: 'postView' } as const, null)
72const accountView = atom({ plugin: 'pd-intel-code', key: 'accountView' } as const, null)
73const requests = atom({ plugin: 'pd-intel-code', key: 'requests' } as const, [])
74const recent = atom({ plugin: 'pd-intel-code', key: 'recent' } as const, [])
75const capture = atom({ plugin: 'pd-intel-code', key: 'capture' } as const, false)
76const notice = atom({ plugin: 'pd-intel-code', key: 'notice' } as const, null)
77const view = atom({ plugin: 'pd-intel-code', key: 'view' } as const, 'chat')
78const pdServers = atom({ plugin: 'pd-intel-code', key: 'pdServers' } as const, {})
79
80const PANE = 'pd'
81
82const EVIDENCE_ROOT = 'pd-intel-evidence'
83
84// The MCP servers that carry every PD_SIGNATURE tool, from the tools connected now.
85async function findPdServers($: EngineInterface): Promise<string[]> {
86 const tools = new Map<string, Set<string>>()
87 for (const tool of await $.tool.list()) {
88 if (!tool.mcp || !MCP_TOOL.test(tool.name)) continue
89 const server = serverName(tool.name)
90 tools.set(server, (tools.get(server) ?? new Set()).add(shortName(tool.name)))
91 }
92 return [...tools].filter(([, names]) => PD_SIGNATURE.every(n => names.has(n))).map(([server]) => server)
93}
94
95// Whether a tool call went to PD Intelligence; the answer is kept per server.
96async function isPdServer($: EngineInterface, server: string): Promise<boolean> {
97 const known = await read($, pdServers)
98 if (server in known) return known[server] === true
99 const isPd = (await findPdServers($)).includes(server)
100 await update($, pdServers, m => ({ ...m, [server]: isPd }))
101 return isPd
102}
103
104// The PD Intelligence server, waiting a little for a connector that is still connecting.
105async function pdServer($: EngineInterface): Promise<string | undefined> {
106 for (let tries = 1; ; tries++) {
107 const [server] = await findPdServers($)
108 if (server || tries >= CONNECT_TRIES) return server
109 await $.clock.sleep(CONNECT_WAIT_MS)
110 }
111}
112
113// The rows of one run_analytics_sql query, its load narrowed by `filters` (dates, platform).
114async function pdSql($: EngineInterface, datasetId: number, sql: string, filters: Record<string, unknown> = {}) {
115 return rowsOf(await callPd($, 'run_analytics_sql', { dataset_id: datasetId, sql, ...filters }))
116}
117
118// Calls a PD Intelligence tool for the panel itself (not the model's evidence).
119async function callPd($: EngineInterface, tool: string, args?: Record<string, unknown>): Promise<unknown> {
120 const server = await pdServer($)
121 if (!server) {
122 throw new Error(
123 "Can't find the PD Intelligence connector. If Claude Code just started it may still be connecting: press Refresh in a few seconds. Otherwise check /mcp that PD Intelligence is connected.",
124 )
125 }
126 let res: Awaited<ReturnType<EngineInterface['mcp']['call']>>
127 try {
128 res = await $.mcp.call(server, tool, args)
129 } catch (err) {
130 // A permission refusal reads as engine wording; say what to do instead.
131 if (/\b(refused|denied|classifier|permission)/i.test(err instanceof Error ? err.message : String(err))) throw new Error(blockedMessage(tool))
132 throw err
133 }
134 const text = res.content.map(b => b.text ?? '').join('')
135 if (res.isError) throw new Error(`PD Intelligence said: ${text.slice(0, 200)}`)
136 return parseJson(text)
137}
138
139const LOADING = { status: 'loading' } as const
140const snapshotKey = (id: number) => `snapshot-${id}`
141
142// The newest load; an older one's answers are dropped. Not drawn from, so a
143// module variable (a reload starts it over, which only drops in-flight answers).
144let latestLoad: object | null = null
145
146// Fetches the given sections of dataset `id` at once (all by default); each lands
147// as it settles, and only while this is the newest load and `id` the snapshot's dataset.
148async function loadSnapshot($: EngineInterface, id: number, today: string, keys: readonly SectionKey[] = SECTION_KEYS): Promise<void> {
149 if ((await read($, pinned))?.id !== id) return
150 const load = {}
151 latestLoad = load
152 const isMine = (s: PdSnapshot | null): s is PdSnapshot => !!s && s.datasetId === id && latestLoad === load
153 const days = lastDays(today)
154 const from = days[0]
155 const weekFrom = days[days.length - 7]
156 const lookback = dayKey(Date.parse(`${from}T00:00:00Z`) - SERIES_DAYS * DAY_MS)
157 const loading = Object.fromEntries(keys.map(k => [k, LOADING]))
158 await update($, snapshot, s =>
159 s && s.datasetId === id && keys.length < SECTION_KEYS.length
160 ? ({ ...s, ...loading } as PdSnapshot)
161 : ({ datasetId: id, fetchedOn: today, glance: LOADING, activity: LOADING, followers: LOADING, topPosts: LOADING, attention: LOADING } as PdSnapshot),
162 )
163
164 const sql = async (text: string, dateFrom: string) =>
165 rowsOf(await callPd($, 'run_analytics_sql', { dataset_id: id, sql: text, date_from: dateFrom }))
166 const jobs: Record<SectionKey, () => Promise<unknown>> = {
167 glance: async () => {
168 // get_totals carries the period-over-period numbers under `deltas`.
169 const totals = (await callPd($, 'get_totals', { dataset_id: id, comparison_days: GLANCE_DAYS })) as { deltas?: Record<string, unknown> } | null
170 return toGlance(id, totals?.deltas ?? {})
171 },
172 activity: async () => toActivity(await sql(activitySql(from), from), days),
173 followers: async () => toFollowers(await sql(followersSql(from, today), lookback), days),
174 topPosts: async () => toTopPosts(await sql(topPostsSql(weekFrom), weekFrom)),
175 attention: async () =>
176 toAttention(await callPd($, 'get_leaderboard', { dataset_id: id, entity_type: 'accounts', mode: 'needs_attention', limit: 3 })),
177 }
178
179 await Promise.all(
180 keys.map(async key => {
181 let section: PdSection<unknown>
182 try {
183 section = { status: 'ready', data: await jobs[key]() }
184 } catch (err) {
185 section = { status: 'failed', message: err instanceof Error ? err.message : String(err) }
186 }
187 await update($, snapshot, s => (isMine(s) ? ({ ...s, [key]: section } as PdSnapshot) : s))
188 }),
189 )
190
191 const done = await read($, snapshot)
192 if (isMine(done)) await $.store.set(snapshotKey(id), done)
193}
194
195// Shows dataset `id`'s saved snapshot, unless the one held already is its own.
196async function showSaved($: EngineInterface, id: number): Promise<PdSnapshot | null> {
197 const held = await read($, snapshot)
198 if (held && held.datasetId === id) return held
199 const saved = ((await $.store.get(snapshotKey(id))) as PdSnapshot | undefined) ?? null
200 if ((await read($, pinned))?.id === id) await update($, snapshot, () => saved)
201 return saved
202}
203
204// Shows dataset `id`'s saved snapshot at once; fetches it all again when it is from
205// an earlier day or `force` is set, and only its unready sections otherwise.
206async function ensureSnapshot($: EngineInterface, id: number, force = false): Promise<void> {
207 const today = dayKey(await $.clock.now())
208 const snap = await showSaved($, id)
209 if (force || !snap || snap.fetchedOn < today) return loadSnapshot($, id, today)
210 if (isStale(snap, today)) await loadSnapshot($, id, today, SECTION_KEYS.filter(k => snap[k].status !== 'ready'))
211}
212
213// A loader's answer as a panel section: ready with its data, or failed with why.
214async function settle<T>(job: () => Promise<T>): Promise<PdSection<T>> {
215 try {
216 return { status: 'ready', data: await job() }
217 } catch (err) {
218 return { status: 'failed', message: err instanceof Error ? err.message : String(err) }
219 }
220}
221
222const todayKey = (id: number) => `today-${id}`
223
224// Today's brief for dataset `id`: PD's daily summary, then that day's top posts by views.
225// Kept for the UTC day like the snapshot; answers for another dataset are dropped.
226async function ensureToday($: EngineInterface, id: number, force = false): Promise<void> {
227 const day = dayKey(await $.clock.now())
228 let held = await read($, today)
229 if (!held || held.datasetId !== id) {
230 held = ((await $.store.get(todayKey(id))) as PdToday | undefined) ?? null
231 await update($, today, () => held)
232 }
233 if (!force && held && held.fetchedOn === day && held.summary.status === 'ready' && held.posts.status === 'ready') return
234 await update($, today, () => ({ datasetId: id, fetchedOn: day, summary: LOADING, posts: LOADING }))
235 const mine = (s: PdToday | null): s is PdToday => !!s && s.datasetId === id
236 let date = day
237 const summary = await settle(async () => {
238 const s = toSummary(await pdSql($, id, SUMMARY_SQL))
239 date = s.date || day
240 return s
241 })
242 await update($, today, s => (mine(s) ? { ...s, summary } : s))
243 const posts = await settle(async () =>
244 // date_to is exclusive: the window is that one day.
245 toTopPosts(await pdSql($, id, dayPostsSql(), { date_from: date, date_to: dayKey(Date.parse(`${date}T00:00:00Z`) + DAY_MS) })),
246 )
247 await update($, today, s => (mine(s) ? { ...s, posts } : s))
248 const done = await read($, today)
249 if (mine(done)) await $.store.set(todayKey(id), done)
250}
251
252// Moves the panel to a screen and starts what it needs.
253async function show($: EngineInterface, s: PdScreen): Promise<void> {
254 await update($, screen, () => s)
255 const current = await read($, pinned)
256 if (s.kind === 'today' && current) await ensureToday($, current.id)
257 if (s.kind === 'post') await loadPost($, s.post)
258 if (s.kind === 'account') await loadAccount($, s.account)
259}
260
261// Refresh reloads what is on screen: Today, the open post or account, or the overview.
262async function refreshScreen($: EngineInterface): Promise<void> {
263 const s = await read($, screen)
264 const current = await read($, pinned)
265 if (s.kind === 'today' && current) return ensureToday($, current.id, true)
266 if (s.kind === 'post') return loadPost($, s.post)
267 if (s.kind === 'account') return loadAccount($, s.account)
268 return refresh($, true)
269}
270
271// Hands a request to Claude as the person's own message, and tracks it in the panel.
272async function ask($: EngineInterface, text: string, label: string): Promise<void> {
273 const at = new Date(await $.clock.now()).toISOString()
274 const id = `${at}-${(await read($, requests)).length}`
275 await update($, requests, list => startRequest(list, { id, label, text, status: 'sent', at }))
276 void $.prompt.submit({ text, asUser: true })
277}
278
279// One post's numbers; the comment summary waits for loadComments (it can take minutes).
280async function loadPost($: EngineInterface, post: PdTopPost): Promise<void> {
281 const current = await read($, pinned)
282 if (!current || post.id === undefined) return
283 const postId = post.id
284 const datasetId = current.id
285 await update($, postView, () => ({ datasetId, postId, detail: LOADING, comments: null }))
286 const detail = await settle(async () => toPostDetail(await pdSql($, datasetId, postDetailSql(postId))))
287 await update($, postView, v => (v && v.postId === postId && v.datasetId === datasetId ? { ...v, detail } : v))
288}
289
290async function loadComments($: EngineInterface): Promise<void> {
291 const current = await read($, pinned)
292 const held = await read($, postView)
293 if (!current || !held) return
294 const { postId, datasetId } = held
295 if (datasetId !== current.id) return
296 const mine = (v: PdPostView | null): v is PdPostView => !!v && v.postId === postId && v.datasetId === datasetId
297 await update($, postView, v => (mine(v) ? { ...v, comments: LOADING } : v))
298 const comments = await settle(async () =>
299 toCommentSummary(await callPd($, 'get_comment_summary', { dataset_id: datasetId, post_id: postId })),
300 )
301 await update($, postView, v => (mine(v) ? { ...v, comments } : v))
302}
303
304// One account's numbers and its latest posts on that platform.
305async function loadAccount($: EngineInterface, ref: PdAccountRef): Promise<void> {
306 const current = await read($, pinned)
307 if (!current) return
308 const key = `${ref.platform}:${ref.username}`
309 const datasetId = current.id
310 // Filtered to the account's platform: a common handle's look-alikes would otherwise fill the page.
311 const filter = { dataset_id: datasetId, search: ref.username, platform: platformKey(ref.platform), page_size: 100 }
312 await update($, accountView, () => ({ datasetId, key, stats: LOADING, recent: LOADING }))
313 const mine = (v: PdAccountView | null): v is PdAccountView => !!v && v.key === key && v.datasetId === datasetId
314 const stats = await settle(async () => toAccountStats(await callPd($, 'search_accounts', filter), ref))
315 await update($, accountView, v => (mine(v) ? { ...v, stats } : v))
316 const recent = await settle(async () =>
317 toTopPosts(await pdSql($, datasetId, accountPostsSql(ref.username), { platform: filter.platform })),
318 )
319 await update($, accountView, v => (mine(v) ? { ...v, recent } : v))
320}
321
322// `withSnapshot` false only lists the datasets (pinById, about to pin another).
323async function refresh($: EngineInterface, force = false, withSnapshot = true) {
324 const pinnedNow = await read($, pinned)
325 if (pinnedNow && withSnapshot) await showSaved($, pinnedNow.id)
326 await update($, notice, () => 'Loading your datasets…')
327 try {
328 const list = toDatasets(await callPd($, 'list_datasets'))
329 if (list) await update($, datasets, () => list)
330 const current = await read($, pinned)
331 const fresh = current && list?.find(d => d.id === current.id)
332 if (fresh && (fresh.name !== current.name || fresh.icon !== current.icon)) {
333 await $.store.set('dataset', fresh)
334 await update($, pinned, () => fresh)
335 showStatus($, fresh)
336 }
337 await update($, notice, () => null)
338 if (current && withSnapshot) await ensureSnapshot($, current.id, force)
339 } catch (err) {
340 await update($, notice, () => err instanceof Error ? err.message : String(err))
341 }
342}
343
344function showStatus($: EngineInterface, d: PdDataset | null) {
345 $.ui.status(d ? `PD Intelligence · ${label(d)}` : undefined)
346}
347
348async function pin($: EngineInterface, d: PdDataset | null) {
349 if (d) await $.store.set('dataset', d)
350 else await $.store.delete('dataset')
351 await update($, pinned, () => d)
352 await update($, screen, () => ({ kind: 'home' }) as PdScreen)
353 if (d) {
354 const ids = pushRecent(await read($, recent), d.id)
355 await $.store.set('recentDatasets', ids)
356 await update($, recent, () => ids)
357 }
358 showStatus($, d)
359 if (!d) {
360 await update($, snapshot, () => null)
361 return
362 }
363 await ensureSnapshot($, d.id)
364}
365
366// Pins a dataset by id, naming it from the list (loaded first if need be).
367async function pinById($: EngineInterface, id: number): Promise<PdDataset> {
368 let list = await read($, datasets)
369 if (!list) {
370 await refresh($, false, false)
371 list = await read($, datasets)
372 }
373 const found = list?.find(d => d.id === id) ?? { id, name: `Dataset ${id}`, icon: '' }
374 await pin($, found)
375 return found
376}
377
378async function setCapture($: EngineInterface, on: boolean) {
379 await $.store.set('capture', on)
380 await update($, capture, () => on)
381}
382
383// The person's choice of where /pd shows the panel, remembered across sessions.
384// Mobile places no side panel, so there the panel always draws in the chat.
385async function setView($: EngineInterface, where: 'chat' | 'side') {
386 await $.store.set('view', where)
387 await update($, view, () => where)
388 if (where === 'side') await $.ui.open({ id: PANE, title: 'PD Intelligence' })
389 else await $.ui.close({ id: PANE })
390}
391
392async function evidenceDir($: EngineInterface): Promise<string> {
393 const id = await $.session.id()
394 return `${EVIDENCE_ROOT}/${id.slice(0, 8)}`
395}
396
397async function record($: EngineInterface, e: Record<string, unknown>, ran: ToolCallResult) {
398 const { tool, tool_use_id, agentId, requestMeta, consent, ...args } = e
399 const name = shortName(String(tool))
400 const text = ran.text ?? ''
401 const isError = ran.isError === true
402 const entries = await read($, ledger)
403 const entry: PdEvidence = {
404 n: entries.length + 1,
405 tool: name,
406 args,
407 at: new Date(await $.clock.now()).toISOString(),
408 isError,
409 chars: text.length,
410 }
411
412 if (name === 'list_datasets' && !isError) {
413 const list = toDatasets(parseJson(text))
414 if (list) await update($, datasets, () => list)
415 }
416
417 if (!isError && (await read($, capture))) {
418 const dir = await evidenceDir($)
419 if (!(await $.fs.exists(`${EVIDENCE_ROOT}/.gitignore`))) {
420 // Captured results are client data: keep them out of any repo by default.
421 await $.fs.write(`${EVIDENCE_ROOT}/.gitignore`, '*\n')
422 }
423 entry.file = `${dir}/${String(entry.n).padStart(3, '0')}-${name}.json`
424 const call = { tool: name, args, at: entry.at }
425 await $.fs.write(entry.file, JSON.stringify({ call, result: parseJson(text) ?? text }, null, 2) + '\n')
426 }
427
428 await update($, ledger, list => [...list, entry])
429}
430
431function formatLedger(entries: readonly PdEvidence[]): string {
432 if (entries.length === 0) {
433 return 'No PD Intelligence calls yet this session.'
434 }
435 const lines = entries.map(x => {
436 const args = Object.keys(x.args).length > 0 ? ` ${JSON.stringify(x.args)}` : ''
437 const status = x.isError ? ' (error)' : ''
438 const file = x.file ? ` -> ${x.file}` : ''
439 return `${x.n}. ${x.at} \`${x.tool}\`${args}${status}${file}`
440 })
441 return [`PD Intelligence calls this session (${entries.length}):`, '', ...lines].join('\n')
442}
443
444// The desktop's first click on the side panel, while another place holds the keys, only
445// moves the focus onto the Button (ui.focus) and never presses it. The panel presses it
446// itself, by the closures its latest drawing gave each key. Module variables, as only
447// the drawing that is up matters: a reload starts them over.
448let paneFocused = false
449let paneButtons = new Map<string, (e: UiPressArgument) => void>()
450let clickPressed: { key: string; at: number } | null = null
451// A press for the same key this soon after the panel pressed it is that click's own.
452const CLICK_PRESS_MS = 1000
453
454// The element table, its Buttons noting their closures in `into`.
455function noteButtons(els: Elements[keyof Elements], into: Map<string, (e: UiPressArgument) => void>): Elements[keyof Elements] {
456 const Button = els.Button as (props: ButtonProps) => RenderElement
457 const noted = (props: ButtonProps) => {
458 into.set(props.key ?? props.label ?? '', props.onPress)
459 return Button(props)
460 }
461 return { ...els, Button: noted } as Elements[keyof Elements]
462}
463
464// The panel itself, drawn in the side panel or as /pd's row in the chat;
465// `extra` is what only one of the two adds, `buttons` collects the side panel's.
466async function drawPanel($: EngineInterface, e: ResolveInput, extra?: JSX.Element, buttons?: Map<string, (e: UiPressArgument) => void>) {
467 const current = await read($, pinned)
468 const held = await read($, snapshot)
469 const data: PanelData = {
470 list: await read($, datasets),
471 current,
472 snap: held && current && held.datasetId === current.id ? held : null,
473 isCapturing: await read($, capture),
474 line: await read($, notice),
475 calls: (await read($, ledger)).length,
476 screen: await read($, screen),
477 today: await read($, today),
478 postView: await read($, postView),
479 accountView: await read($, accountView),
480 requests: await read($, requests),
481 recent: await read($, recent),
482 }
483 const actions: PanelActions = {
484 pin: d => pin($, d),
485 refresh: () => refreshScreen($),
486 setCapture: on => setCapture($, on),
487 ask: (text, label) => ask($, text, label),
488 run: command => $.command.run({ command }),
489 show: s => show($, s),
490 back: () => show($, { kind: 'home' }),
491 loadComments: () => loadComments($),
492 }
493 const els = $.ui.resolve(e)
494 return renderPanel(buttons ? noteButtons(els, buttons) : els, e.surface, data, actions, extra)
495}
496
497export const register: Register = on => {
498 on('session.start', async ($, e, next) => {
499 await $.command.register({
500 name: 'pd',
501 description: 'Open the PD Intelligence panel: your datasets at a glance',
502 })
503 await $.command.register({
504 name: 'pd-dataset',
505 description: 'Choose the PD Intelligence dataset Claude works in (no argument: show it)',
506 argumentHint: '[ID | off]',
507 })
508 await $.command.register({
509 name: 'pd-evidence',
510 description: 'List every PD Intelligence call this session, for a report\'s sources; on/off saves raw results as JSON',
511 argumentHint: '[on | off | clear]',
512 })
513
514 await $.tool.register({
515 name: 'choose_dataset',
516 description:
517 'Switch the PD Intelligence dataset the person works in: what /pd shows and what Claude uses by default. Use when the person asks to switch to, work in, or focus on a dataset. Takes the id from list_datasets.',
518 inputSchema: {
519 type: 'object',
520 properties: { dataset_id: { type: 'integer', description: 'The dataset id, from list_datasets.' } },
521 required: ['dataset_id'],
522 },
523 isDeferred: false,
524 })
525
526 const saved = (await $.store.get('dataset')) as PdDataset | undefined
527 await update($, pinned, () => saved ?? null)
528 const recentIds = ((await $.store.get('recentDatasets')) as number[] | undefined) ?? []
529 await update($, recent, () => recentIds)
530 const isCapturing = (await $.store.get('capture')) === true
531 await update($, capture, () => isCapturing)
532 showStatus($, saved ?? null)
533 const where = (await $.store.get('view')) === 'side' ? 'side' : 'chat'
534 await update($, view, () => where)
535
536 if ((await $.store.get('welcomed')) !== true) {
537 await $.store.set('welcomed', true)
538 $.ui.toast('PD Intelligence: type /pd to see your datasets at a glance')
539 }
540 return next(e)
541 })
542
543 // /pd answers in text (what the model reads), and the CommandOutput hook
544 // below draws that row as the live panel, in the chat on every surface.
545 on('command.run', { command: 'pd' }, async $ => {
546 if ((await read($, view)) === 'side') await $.ui.open({ id: PANE, title: 'PD Intelligence' })
547 await refresh($)
548 const problem = await read($, notice)
549 return { text: problem ?? summary(await read($, datasets), await read($, pinned), await read($, snapshot)) }
550 })
551
552 on('command.run', { command: 'pd-dataset' }, async ($, e) => {
553 const arg = e.args.trim()
554
555 if (arg === '') {
556 return { text: summary(await read($, datasets), await read($, pinned), null) }
557 }
558
559 if (arg === 'off') {
560 await pin($, null)
561 return { text: 'No dataset chosen now.' }
562 }
563
564 if (!/^\d+$/.test(arg)) {
565 return { text: 'Usage: /pd-dataset ID (or "off"). Type /pd to see your datasets and their ids.' }
566 }
567
568 const found = await pinById($, Number(arg))
569 return { text: `Working in: ${label(found)}` }
570 })
571
572 on('command.run', { command: 'pd-evidence' }, async ($, e) => {
573 const arg = e.args.trim()
574
575 if (arg === 'on' || arg === 'off') {
576 await setCapture($, arg === 'on')
577 return {
578 text:
579 arg === 'on'
580 ? `Evidence capture on: PD Intelligence results are saved under ${await evidenceDir($)}/ (git-ignored).`
581 : 'Evidence capture off.',
582 }
583 }
584
585 if (arg === 'clear') {
586 await update($, ledger, () => [])
587 return { text: 'Evidence ledger cleared.' }
588 }
589
590 if (arg !== '') {
591 return { text: 'Usage: /pd-evidence [on | off | clear]' }
592 }
593
594 return { text: formatLedger(await read($, ledger)) }
595 })
596
597 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
598 paneFocused = e.props.isFocused
599 const buttons = new Map<string, (e: UiPressArgument) => void>()
600 const tree = await drawPanel($, e, undefined, buttons)
601 paneButtons = buttons
602 return tree
603 })
604
605 // A person's focus landing on a Button while the panel did not hold the keys is a click (Tab
606 // moves only within a panel that holds them): press it, as the desktop did not.
607 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
608 const wasFocused = paneFocused
609 const moved = await next(e)
610 const press = e.element === undefined ? undefined : paneButtons.get(e.element)
611 if (moved.deny === undefined && !wasFocused && e.origin.kind === 'person' && e.element !== undefined && press) {
612 paneFocused = true
613 clickPressed = { key: e.element, at: await $.clock.now() }
614 press({ plugin: 'pd-intel-code', element: e.element, component: 'Pane', requestId: PANE, surface: 'desktop' })
615 }
616 return moved
617 }).catch(($, e, next) => next(e))
618
619 on('ui.press', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
620 const pressed = clickPressed
621 clickPressed = null
622 if (pressed && pressed.key === e.element && (await $.clock.now()) - pressed.at < CLICK_PRESS_MS) return { element: e.element }
623 return next(e)
624 }).catch(($, e, next) => next(e))
625
626 on('ui.render', { component: 'CommandOutput', props: { command: 'pd' } }, async ($, e) => {
627 const { Box, Text, Button } = $.ui.resolve(e)
628 if (e.surface === 'mobile') {
629 return drawPanel($, e)
630 }
631 if ((await read($, view)) === 'side') {
632 return (
633 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
634 <Text dimColor>PD Intelligence is open in the side panel.</Text>
635 <Button key="to-chat" label="Show it here instead" onPress={() => setView($, 'chat')} />
636 </Box>
637 )
638 }
639 return drawPanel($, e, <Button key="to-side" label="Move to side panel" onPress={() => setView($, 'side')} />)
640 })
641
642 on('tool.call', { tool: CHOOSE_TOOL }, async ($, e) => {
643 const id = Number((e as Record<string, unknown>).dataset_id)
644 if (!Number.isInteger(id)) {
645 return { deny: 'dataset_id must be a dataset id from list_datasets' }
646 }
647 const found = await pinById($, id)
648 return { result: `Now working in ${label(found)} (dataset ${found.id}).` }
649 })
650
651 on('tool.call', { tool: MCP_TOOL }, async ($, e, next) => {
652 const ran = await next(e)
653 if (ran.deny === undefined && (await isPdServer($, serverName(e.tool)))) {
654 await record($, e as Record<string, unknown>, ran)
655 }
656 return ran
657 }).catch(($, e, next) => next(e))
658
659 on('tool.check', { tool: CONFIRM_TOOL }, async ($, e, next) => {
660 const verdict = await next(e)
661 if (verdict.decision !== 'allow' || !(await isPdServer($, serverName(e.tool)))) {
662 return verdict
663 }
664 return {
665 decision: 'ask',
666 reason: `${shortName(e.tool)} deletes PD Intelligence data or changes who can see a document`,
667 }
668 }).catch(() => ({ decision: 'ask' }))
669
670 // Claude's turns, matched to the requests the panel sent: by text when one starts, then by turn id.
671 on('turn.start', async ($, e, next) => {
672 await update($, requests, list => requestStarted(list, e.text, e.turnId))
673 return next(e)
674 }).catch(($, e, next) => next(e))
675
676 on('turn.complete', async ($, e, next) => {
677 if (e.agentId === undefined) {
678 const stopped = e.isAborted || e.reason === 'error' || e.reason === 'refusal'
679 await update($, requests, list => requestDone(list, e.turnId, e.answer, stopped))
680 }
681 return next(e)
682 }).catch(($, e, next) => next(e))
683
684 on('prompt.compose', async ($, e, next) => {
685 const composed = await next(e)
686 const current = await read($, pinned)
687 const isCapturing = await read($, capture)
688 if (!current && !isCapturing) {
689 return composed
690 }
691
692 const lines = ['# PD Intelligence (pd-intel-code)']
693 if (current) {
694 lines.push(
695 `- The person chose PD Intelligence dataset ${current.id} (${current.name}). Pass dataset_id ${current.id} to PD Intelligence tools unless they name another dataset; do not ask which dataset to use.`,
696 )
697 }
698 if (isCapturing) {
699 lines.push(
700 `- Evidence capture is on: every PD Intelligence result is saved as JSON under ${await evidenceDir($)}/, numbered in call order. For charts, tables or reworking numbers in a report, load those files (e.g. with Python) instead of calling the tool again.`,
701 )
702 }
703 lines.push(
704 '- The person may not be technical: explain findings in plain language, lead with what changed and why it matters, and keep numbers rounded unless they ask for exact figures.',
705 )
706 return {
707 sections: [...composed.sections, { id: 'pd-intel-code:context', text: lines.join('\n'), scope: 'session' }],
708 }
709 })
710}
711hooks/panel.tsx 553 lines1// The /pd panel's drawing and its text twin. Pure: register.tsx reads the
2// state, resolves the elements and hands over the closures that act, since the
3// engine follows `$` only within register.tsx.
4
5import type { Elements, RenderSurface } from 'claude-code'
6
7import type {
8 PdAccountView,
9 PdDataset,
10 PdDay,
11 PdMetric,
12 PdPostView,
13 PdRequest,
14 PdScreen,
15 PdSection,
16 PdSnapshot,
17 PdToday,
18 PdTopPost,
19 PdWeakAccount,
20} from '../types'
21import { apportion, barSvg, lineSvg, mixSvg, oneLine, platformColor, sparkline, type ChartPoint, type Series } from './charts'
22import { QUESTIONS, compact, grouped, label, orderByRecent, plainPreview, plural, shortDay, viewsLabel } from './data'
23
24type Els = Elements[keyof Elements]
25
26/** What the panel shows, read from state by register.tsx. */
27export type PanelData = {
28 list: readonly PdDataset[] | null
29 current: PdDataset | null
30 /** The snapshot, only when it is the current dataset's. */
31 snap: PdSnapshot | null
32 isCapturing: boolean
33 /** A loading or error line shown above everything else. */
34 line: string | null
35 calls: number
36 screen: PdScreen
37 /** Today's data, only drawn when it is the current dataset's. */
38 today: PdToday | null
39 postView: PdPostView | null
40 accountView: PdAccountView | null
41 /** Requests the panel handed to Claude, newest first. */
42 requests: PdRequest[]
43 /** Recently pinned dataset ids, most recent first. */
44 recent: number[]
45}
46
47/** What the panel's controls do; register.tsx builds these around `$`. */
48export type PanelActions = {
49 pin: (d: PdDataset | null) => unknown
50 refresh: () => unknown
51 setCapture: (on: boolean) => unknown
52 /** Sends a request to Claude as the person's own message, tracked under `label`. */
53 ask: (text: string, label: string) => unknown
54 run: (command: string) => unknown
55 /** Opens one of the panel's own screens. */
56 show: (s: PdScreen) => unknown
57 back: () => unknown
58 /** Fetches the open post's comment summary (slow: can take minutes). */
59 loadComments: () => unknown
60}
61
62const named = (d: PdDataset) => `${d.name} (#${d.id})`
63export const whyPrompt = (d: PdDataset, p: PdTopPost) =>
64 `Why did this post do so well? Deep-dive post ${p.postId} (@${p.author} on ${p.platform}) in ${named(d)}.`
65export const lookPrompt = (d: PdDataset, a: PdWeakAccount) => `Look into why @${a.username} is underperforming in ${named(d)}.`
66
67const REQUEST_STATUS: Record<PdRequest['status'], string> = {
68 sent: 'Sent',
69 working: 'Claude is on it…',
70 answered: 'Answered',
71 stopped: 'Stopped',
72}
73
74// Chart frames' heights in CSS pixels: a frame left to size itself stands far taller than its chart.
75const CHART_PX = 56
76const MIX_PX = 10
77
78const ready = <T,>(s: PdSection<T> | undefined): T | undefined => (s?.status === 'ready' ? s.data : undefined)
79const signed = (n: number) => `${n > 0 ? '+' : ''}${grouped(n)}`
80
81export function drawPanel(els: Els, surface: RenderSurface, data: PanelData, actions: PanelActions, extra?: JSX.Element) {
82 const { Box, Text, Button } = els
83 // By surface, as the engine's element tables are: no Svg on the terminal, no Select on mobile.
84 const Svg = surface === 'terminal' ? undefined : (els as Elements['desktop']).Svg
85 const Select = surface === 'mobile' ? undefined : (els as Elements['desktop']).Select
86 const { list, current: pinnedDataset, snap, isCapturing, line, calls, screen: sc, today } = data
87 // With an error standing and nothing saved to show, the sections would only say "Loading…".
88 const current = pinnedDataset && (snap || !line) ? pinnedDataset : null
89
90 // What a section shows while loading or after failing; undefined once ready.
91 const pending = (key: string, s: PdSection<unknown> | undefined) =>
92 !s || s.status === 'loading' ? (
93 <Text key={key} dimColor>Loading…</Text>
94 ) : s.status === 'failed' ? (
95 <Text key={key} color="warning">{`Couldn't load this: ${s.message}`}</Text>
96 ) : undefined
97
98 // Bars for daily counts, a line for a running figure; each metric in its own color.
99 const chart = (key: string, points: ChartPoint[], alt: string, series: Series, form: 'bars' | 'line' = 'bars') =>
100 Svg ? (
101 <Svg key={key} source={form === 'line' ? lineSvg(points, series) : barSvg(points, series)} alt={alt} height={CHART_PX} isInteractive />
102 ) : (
103 <Text key={key}>{sparkline(points.map(p => p.value))}</Text>
104 )
105
106 const g = ready(snap?.glance)
107 // Threads' dashboard color is near-black: the theme's text color keeps its dot visible in dark mode.
108 const dot = (platform: string) => (platform === 'Threads' ? 'text' : platformColor(platform))
109 const platformTotal = g ? g.platforms.reduce((s, x) => s + x.posts.value, 0) || 1 : 1
110 const share = (n: number) => `${((n / platformTotal) * 100).toFixed(1)}%`
111 // One post: who and where, what it said, then what to do with it on a row of its own.
112 const postRow = (p: PdTopPost, i: number) => (
113 <Box key={`post-${p.postId}`} flexDirection="column">
114 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
115 <Text bold>{`${i + 1}. @${p.author}`}</Text>
116 <Text color={dot(p.platform)}>●</Text>
117 <Text dimColor>{`${p.platform} · ${viewsLabel(p.platform, p.views, p.contentType)}`}</Text>
118 </Box>
119 <Text dimColor italic>{oneLine(p.text, 90)}</Text>
120 {(current || (p.id !== undefined && sc.kind !== 'post')) && (
121 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
122 {p.id !== undefined && sc.kind !== 'post' && (
123 <Button key={`details-${p.postId}`} label="Details" onPress={() => actions.show({ kind: 'post', post: p })} />
124 )}
125 {current && (
126 <Button key={`why-${p.postId}`} label="Ask Claude: why?" onPress={() => actions.ask(whyPrompt(current, p), `Why @${p.author}'s post did well`)} />
127 )}
128 </Box>
129 )}
130 </Box>
131 )
132 // A home section: a titled card, as the tiles are, so each reads apart from the next.
133 const card = (key: string, title: string, body: JSX.Element) => (
134 <Box key={key} flexDirection="column" gap={1} borderStyle="round" borderDimColor paddingX={1}>
135 <Text dimColor>{title}</Text>
136 {body}
137 </Box>
138 )
139 const act = ready(snap?.activity)
140 const fol = ready(snap?.followers)
141 const top = ready(snap?.topPosts)
142 const weak = ready(snap?.attention)
143 const lastDay = act?.posts.at(-1)?.day ?? fol?.at(-1)?.day
144 const when = (day: string) => `${shortDay(day)}${day === lastDay ? ' (so far)' : ''}`
145 const points = (days: readonly PdDay[], unit: string) =>
146 days.map(d => ({ label: `${when(d.day)} · ${grouped(d.value)} ${unit}`, value: d.value }))
147
148 const firstDay = act?.posts[0]?.day
149 const span = firstDay && lastDay ? `${shortDay(firstDay)} – ${shortDay(lastDay)}` : ''
150 const tile = (key: string, title: string, value: string | undefined, delta: string | undefined, body: JSX.Element, caption?: string) => (
151 <Box key={key} flexDirection="column" flexGrow={1} minWidth={24} borderStyle="round" borderDimColor paddingX={1}>
152 <Text dimColor>{title}</Text>
153 <Box flexDirection="row" columnGap={1}>
154 <Text bold>{value ?? '—'}</Text>
155 {delta ? <Text color={delta.startsWith('-') ? 'error' : 'success'}>{delta}</Text> : null}
156 </Box>
157 {body}
158 {caption ? <Text dimColor>{caption}</Text> : null}
159 </Box>
160 )
161
162 const ordered = list ? orderByRecent(list, data.recent) : null
163 const isRecent = (d: PdDataset) => data.recent.includes(d.id)
164 const picker =
165 Select ? (
166 list && list.length > 0 ? (
167 <Select
168 key="dataset"
169 label="Dataset"
170 options={(ordered ?? []).map(d => ({ value: String(d.id), label: label(d) }))}
171 value={pinnedDataset ? String(pinnedDataset.id) : undefined}
172 onSelect={v => void actions.pin(list.find(d => String(d.id) === v) ?? null)}
173 />
174 ) : null
175 ) : (
176 <Box key="datasets" flexDirection="column">
177 <Text dimColor>{`Your datasets${list ? ` (${list.length})` : ''}: press one to work in it`}</Text>
178 {(ordered ?? []).flatMap((d, i) => [
179 ...(i === 0 && isRecent(d) ? [<Text key="recent-label" dimColor>Recent</Text>] : []),
180 ...(!isRecent(d) && (i === 0 || isRecent(ordered![i - 1])) && data.recent.length > 0
181 ? [<Text key="all-label" dimColor>All</Text>]
182 : []),
183 <Button
184 key={`ds-${d.id}`}
185 plain
186 label={`${d.id === pinnedDataset?.id ? '●' : '○'} ${label(d)} #${d.id}`}
187 onPress={() => actions.pin(d)}
188 />,
189 ])}
190 </Box>
191 )
192
193 const homeBody = (
194 <Box key="home" flexDirection="column" gap={2}>
195 {picker}
196
197 {current && (
198 <Box flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1}>
199 {tile(
200 'posts',
201 'POSTS',
202 g && grouped(g.posts.value),
203 g?.posts.deltaLabel,
204 act ? chart('chart-posts', points(act.posts, 'posts'), 'Posts per day, last 14 days', 'blue') : pending('chart-posts', snap?.activity)!,
205 act ? `posts per day · ${span}` : undefined,
206 )}
207 {tile(
208 'followers',
209 'FOLLOWERS',
210 g && compact(g.followers.value),
211 g?.followers.deltaLabel,
212 fol ? (
213 fol.length > 0 ? (
214 chart(
215 'chart-followers',
216 fol.map(d => ({ label: `${when(d.day)} · ${signed(d.value)} since ${shortDay(fol[0].day)}`, value: d.value })),
217 'Followers gained since the first day, last 14 days',
218 'aqua',
219 'line',
220 )
221 ) : (
222 <Text key="chart-followers" dimColor>No follower history yet.</Text>
223 )
224 ) : (
225 pending('chart-followers', snap?.followers)!
226 ),
227 fol && fol.length > 0 ? `followers gained since ${shortDay(fol[0].day)}` : undefined,
228 )}
229 {tile(
230 'accounts',
231 'ACCOUNTS POSTING',
232 g && grouped(g.activeAccounts.value),
233 g?.activeAccounts.deltaLabel,
234 act
235 ? chart('chart-accounts', points(act.accounts, 'accounts posting'), 'Accounts posting per day, last 14 days', 'violet')
236 : pending('chart-accounts', snap?.activity)!,
237 act ? `accounts posting per day · ${span}` : undefined,
238 )}
239 </Box>
240 )}
241 {current && g && (
242 <Text dimColor wrap="wrap">
243 {`Views ${compact(g.views.value)} (${g.views.deltaLabel}) · Accounts ${grouped(g.accounts.value)} · Creators ${grouped(g.creators.value)}`}
244 </Text>
245 )}
246
247 {current && g && g.platforms.length > 0 && (
248 <Box key="platforms" flexDirection="column" gap={1}>
249 <Text dimColor>POSTS BY PLATFORM · change vs. 7 days ago</Text>
250 {Svg ? (
251 <Svg
252 source={mixSvg(g.platforms.map(x => ({ platform: x.platform, posts: x.posts.value })))}
253 alt={`Posts by platform: ${g.platforms.map(x => `${x.platform} ${share(x.posts.value)}`).join(', ')}`}
254 height={MIX_PX}
255 isInteractive
256 />
257 ) : (
258 <Box flexDirection="row">
259 {apportion(g.platforms.map(x => x.posts.value), 32).map((n, i) => (
260 <Text key={`bar-${g.platforms[i].platform}`} color={dot(g.platforms[i].platform)}>
261 {'█'.repeat(n)}
262 </Text>
263 ))}
264 </Box>
265 )}
266 <Box flexDirection="row" flexWrap="wrap" columnGap={3} rowGap={1}>
267 {g.platforms.map(x => (
268 <Box key={`platform-${x.platform}`} flexDirection="column" minWidth={12}>
269 <Box flexDirection="row" columnGap={1}>
270 <Text color={dot(x.platform)}>●</Text>
271 <Text>{x.platform}</Text>
272 </Box>
273 <Box flexDirection="row" columnGap={1}>
274 <Text bold>{compact(x.posts.value)}</Text>
275 <Text dimColor>{share(x.posts.value)}</Text>
276 </Box>
277 {x.posts.deltaLabel ? (
278 <Text color={x.posts.deltaLabel.startsWith('-') ? 'error' : 'success'}>{x.posts.deltaLabel}</Text>
279 ) : null}
280 </Box>
281 ))}
282 </Box>
283 </Box>
284 )}
285
286 {current &&
287 card(
288 'top',
289 'TOP POSTS THIS WEEK',
290 pending('top-state', snap?.topPosts) ??
291 (top && top.length === 0 ? (
292 <Text dimColor>No posts in the last 7 days.</Text>
293 ) : (
294 <Box flexDirection="column" gap={2}>
295 {(top ?? []).map((p, i) => postRow(p, i))}
296 </Box>
297 )),
298 )}
299
300 {current &&
301 card(
302 'weak',
303 'NEEDS ATTENTION',
304 pending('weak-state', snap?.attention) ??
305 (weak && weak.length === 0 ? (
306 <Text dimColor>Nothing flagged.</Text>
307 ) : (
308 <Box flexDirection="column" gap={2}>
309 {(weak ?? []).map(a => (
310 <Box key={`weak-${a.username}`} flexDirection="column">
311 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
312 <Text bold>{`@${a.username}`}</Text>
313 <Text color={dot(a.platform)}>●</Text>
314 <Text dimColor>{a.platform}</Text>
315 </Box>
316 <Text dimColor>{`${plural(a.posts, 'post')} · ${compact(a.views)} ${a.views === 1 ? 'view' : 'views'}`}</Text>
317 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
318 <Button
319 key={`account-${a.username}`}
320 label="Details"
321 onPress={() => actions.show({ kind: 'account', account: { username: a.username, platform: a.platform } })}
322 />
323 <Button key={`look-${a.username}`} label="Ask Claude: look into it" onPress={() => actions.ask(lookPrompt(current, a), `Look into @${a.username}`)} />
324 </Box>
325 </Box>
326 ))}
327 </Box>
328 )),
329 )}
330
331 {data.requests.length > 0 &&
332 card(
333 'requests',
334 'REQUESTS TO CLAUDE',
335 <Box flexDirection="column" gap={2}>
336 {data.requests.slice(0, 3).map(r => (
337 <Box key={`request-${r.id}`} flexDirection="column">
338 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
339 <Text bold>{r.label}</Text>
340 <Text
341 color={r.status === 'answered' ? 'success' : r.status === 'stopped' ? 'warning' : undefined}
342 dimColor={r.status === 'sent'}
343 >
344 {REQUEST_STATUS[r.status]}
345 </Text>
346 </Box>
347 {r.answer ? <Text dimColor>{plainPreview(r.answer, 200)}</Text> : null}
348 {r.status === 'answered' && (
349 <Box flexDirection="row" marginTop={1}>
350 <Button key={`answer-${r.id}`} label="Show answer" onPress={() => actions.show({ kind: 'answer', requestId: r.id })} />
351 </Box>
352 )}
353 </Box>
354 ))}
355 </Box>,
356 )}
357
358 {current &&
359 card(
360 'questions',
361 'ASK CLAUDE',
362 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
363 {QUESTIONS.map((q, i) => (
364 <Button key={`question-${i}`} label={q.label} onPress={() => actions.ask(q.ask(current), q.label)} />
365 ))}
366 </Box>,
367 )}
368
369 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
370 {current && <Button key="explore" label="Explore" variant="primary" onPress={() => actions.run('pd-intel-code:explore')} />}
371 {current && <Button key="report" label="Write a report" onPress={() => actions.run('pd-intel-code:report')} />}
372 <Button key="sources" label={`Sources used (${calls})`} onPress={() => actions.run('pd-evidence')} />
373 <Button key="capture" label={`Save results for charts: ${isCapturing ? 'On' : 'Off'}`} onPress={() => actions.setCapture(!isCapturing)} />
374 </Box>
375 </Box>
376 )
377
378 const Markdown = els.Markdown
379 const back = <Button key="back" label="← Back" onPress={() => actions.back()} />
380 const tdy = today && current && today.datasetId === current.id ? today : null
381 const todayBody = (
382 <Box key="today-screen" flexDirection="column" gap={1}>
383 {back}
384 {pending('today-summary', tdy?.summary) ??
385 (tdy && tdy.summary.status === 'ready' ? (
386 <Box flexDirection="column" gap={1}>
387 <Text dimColor>{`TODAY · summary for ${shortDay(tdy.summary.data.date)}`}</Text>
388 <Markdown key="today-narrative" text={tdy.summary.data.narrative} />
389 </Box>
390 ) : null)}
391 <Text dimColor>TOP POSTS THAT DAY</Text>
392 {pending('today-posts', tdy?.posts) ??
393 (tdy && tdy.posts.status === 'ready' ? (
394 tdy.posts.data.length === 0 ? (
395 <Text dimColor>No posts that day.</Text>
396 ) : (
397 <Box flexDirection="column" gap={1}>
398 {tdy.posts.data.map((p, i) => postRow(p, i))}
399 </Box>
400 )
401 ) : null)}
402 </Box>
403 )
404
405 // Only the open post's or account's own data, for the dataset in use.
406 const pv =
407 sc.kind === 'post' && current && data.postView?.postId === sc.post.id && data.postView.datasetId === current.id ? data.postView : null
408 const postBody =
409 sc.kind === 'post' && current ? (
410 <Box key="post-screen" flexDirection="column" gap={1}>
411 {back}
412 {postRow(sc.post, 0)}
413 {pending('post-detail', pv?.detail) ??
414 (pv && pv.detail.status === 'ready' ? (
415 <Box flexDirection="column">
416 <Text>{`${plural(pv.detail.data.likes, 'like')} · ${plural(pv.detail.data.comments, 'comment')} · ${plural(pv.detail.data.shares, 'share')}`}</Text>
417 <Text dimColor>{`${pv.detail.data.engagementRate.toFixed(2)}% engagement · posted ${shortDay(pv.detail.data.postedAt.slice(0, 10))}`}</Text>
418 <Text dimColor>{pv.detail.data.url}</Text>
419 </Box>
420 ) : null)}
421 {!pv?.comments ? (
422 <Button key="load-comments" label="Load comment summary" onPress={() => actions.loadComments()} />
423 ) : pv.comments.status === 'loading' ? (
424 <Text dimColor>Loading — comment summaries can take a minute or two</Text>
425 ) : pv.comments.status === 'failed' ? (
426 <Text color="warning">{`Couldn't load this: ${pv.comments.message}`}</Text>
427 ) : (
428 <Text>
429 {`${grouped(pv.comments.data.collected)} comments collected${pv.detail.status === 'ready' ? ` (the post shows ${grouped(pv.detail.data.comments)})` : ''} · ${grouped(pv.comments.data.commenters)} ${pv.comments.data.commenters === 1 ? 'person' : 'people'}`}
430 </Text>
431 )}
432 </Box>
433 ) : null
434
435 const av =
436 sc.kind === 'account' &&
437 current &&
438 data.accountView?.key === `${sc.account.platform}:${sc.account.username}` &&
439 data.accountView.datasetId === current.id
440 ? data.accountView
441 : null
442 const accountBody =
443 sc.kind === 'account' && current ? (
444 <Box key="account-screen" flexDirection="column" gap={1}>
445 {back}
446 <Box flexDirection="row" columnGap={1}>
447 <Text bold>{`@${sc.account.username}`}</Text>
448 <Text color={dot(sc.account.platform)}>●</Text>
449 <Text dimColor>{sc.account.platform}</Text>
450 </Box>
451 {pending('account-stats', av?.stats) ??
452 (av && av.stats.status === 'ready' ? (
453 <Box flexDirection="column">
454 <Text>{`${plural(av.stats.data.followers, 'follower')} · ${plural(av.stats.data.posts, 'post')} · active ${plural(av.stats.data.activeDays, 'day')}`}</Text>
455 <Text dimColor>{`${viewsLabel(sc.account.platform, av.stats.data.views)} · ${plural(av.stats.data.likes, 'like')} · ${plural(av.stats.data.comments, 'comment')}`}</Text>
456 <Text dimColor>{`${av.stats.data.engagementRate.toFixed(2)}% engagement · last post ${shortDay(av.stats.data.lastPost.slice(0, 10))}`}</Text>
457 </Box>
458 ) : null)}
459 <Text dimColor>RECENT POSTS</Text>
460 {pending('account-recent', av?.recent) ??
461 (av && av.recent.status === 'ready' ? (
462 av.recent.data.length === 0 ? (
463 <Text dimColor>No posts found.</Text>
464 ) : (
465 <Box flexDirection="column" gap={1}>
466 {av.recent.data.map((p, i) => postRow(p, i))}
467 </Box>
468 )
469 ) : null)}
470 </Box>
471 ) : null
472
473 const asked = sc.kind === 'answer' ? data.requests.find(r => r.id === sc.requestId) : undefined
474 const answerBody =
475 sc.kind === 'answer' ? (
476 <Box key="answer-screen" flexDirection="column" gap={1}>
477 {back}
478 <Text dimColor>{asked?.text ?? 'This request is no longer in the list.'}</Text>
479 {asked?.answer ? <Markdown key="answer-text" text={asked.answer} /> : null}
480 </Box>
481 ) : null
482
483 return (
484 <Box flexDirection="column" gap={1}>
485 {line && <Text color="warning">{line}</Text>}
486
487 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
488 {pinnedDataset ? <Text bold>{label(pinnedDataset)}</Text> : <Text>No dataset chosen yet. Pick one and Claude will use it.</Text>}
489 {snap && <Text dimColor>{`data as of ${shortDay(snap.fetchedOn)}`}</Text>}
490 {current && sc.kind === 'home' && <Button key="today" label="Today" onPress={() => actions.show({ kind: 'today' })} />}
491 <Button key="refresh" label="Refresh" onPress={() => actions.refresh()} />
492 </Box>
493 {sc.kind === 'today' && current ? todayBody : (postBody ?? accountBody ?? answerBody ?? homeBody)}
494
495 {extra}
496 </Box>
497 )
498}
499
500// The panel as markdown: the /pd row the model reads, and what a surface that
501// draws no plugin panels (the mobile app) shows in its place.
502export function summary(list: readonly PdDataset[] | null, current: PdDataset | null, snap: PdSnapshot | null): string {
503 const g = snap?.glance.status === 'ready' ? snap.glance.data : null
504 const lines = [current ? `**PD Intelligence** · working in **${label(current)}**` : '**PD Intelligence** · no dataset chosen yet']
505 if (g && current && g.datasetId === current.id) {
506 const m = (x: PdMetric, value: string) => `${value} (${x.deltaLabel})`
507 lines.push(
508 '',
509 `Last ${g.days} days:`,
510 `- Posts: ${m(g.posts, grouped(g.posts.value))}`,
511 `- Views: ${m(g.views, compact(g.views.value))}`,
512 `- Followers: ${m(g.followers, compact(g.followers.value))}`,
513 `- Accounts: ${m(g.accounts, grouped(g.accounts.value))}, ${m(g.activeAccounts, grouped(g.activeAccounts.value))} posting`,
514 `- Creators: ${m(g.creators, grouped(g.creators.value))}`,
515 )
516 if (g.platforms.length > 0) {
517 lines.push(`- By platform: ${g.platforms.map(p => `${p.platform} ${compact(p.posts.value)}`).join(' · ')}`)
518 }
519 const act = ready(snap?.activity)
520 if (act) {
521 const values = act.posts.map(d => d.value)
522 lines.push(
523 `- Posts per day, last ${values.length} days: ${grouped(Math.min(...values))} to ${grouped(Math.max(...values))}, ${grouped(values.at(-1) ?? 0)} so far today`,
524 )
525 }
526 const top = ready(snap?.topPosts)
527 if (top && top.length > 0) {
528 lines.push(
529 '',
530 'Top posts this week:',
531 ...top.map((p, i) => `${i + 1}. @${p.author} on ${p.platform}: "${oneLine(p.text, 80)}" (${compact(p.views)} views, post ${p.postId})`),
532 )
533 }
534 const weak = ready(snap?.attention)
535 if (weak && weak.length > 0) {
536 lines.push('', 'Needs attention:', ...weak.map(a => `- @${a.username} (${a.platform}): ${plural(a.posts, 'post')}, ${compact(a.views)} ${a.views === 1 ? 'view' : 'views'}`))
537 }
538 }
539 if (list) {
540 lines.push(
541 '',
542 '**Your datasets**',
543 ...list.map(d => `- ${d.id === current?.id ? '**' : ''}${label(d)} · #${d.id}${d.id === current?.id ? '** (current)' : ''}`),
544 '',
545 'To switch, ask Claude ("work in ...") or type `/pd-dataset` and the number.',
546 )
547 }
548 if (current) {
549 lines.push('Next: ask Claude to explore this dataset, or type `/pd-intel-code:report` for a report.')
550 }
551 return lines.join('\n')
552}
553hooks/data.ts 362 lines1// Pure helpers: names, formatting and the shapes PD Intelligence answers in.
2// Nothing here takes `$`: the engine follows `$` only within register.tsx.
3
4import type {
5 PdAccountRef,
6 PdAccountStats,
7 PdActivity,
8 PdCommentSummary,
9 PdDataset,
10 PdDay,
11 PdGlance,
12 PdMetric,
13 PdPostDetail,
14 PdRequest,
15 PdSnapshot,
16 PdSummary,
17 PdTopPost,
18 PdWeakAccount,
19} from '../types'
20
21// The PD Intelligence server is recognised by its tools, never by its name: the
22// connector is named differently per install (PD_Intelligence, claude_ai_PD_Intelligence,
23// whatever someone typed in `claude mcp add`). A server with both of these is PD.
24export const PD_SIGNATURE = ['list_datasets', 'get_totals', 'run_analytics_sql'] as const
25// Any MCP tool but this mod's own; the hook checks the server is PD's.
26export const MCP_TOOL = /^mcp__(?!pd-intel-code__)/
27
28// The window the panel's "change" column covers.
29export const GLANCE_DAYS = 7
30
31export const PLATFORM_NAMES: Record<string, string> = {
32 facebook: 'Facebook',
33 instagram: 'Instagram',
34 threads: 'Threads',
35 bluesky: 'Bluesky',
36 tiktok: 'TikTok',
37 x_twitter: 'X',
38 youtube: 'YouTube',
39}
40
41export const platformName = (p: string) => PLATFORM_NAMES[p] ?? p
42// PD's own key for a platform's display name (`X` is `x_twitter`), for filters.
43export const platformKey = (name: string) =>
44 Object.entries(PLATFORM_NAMES).find(([, v]) => v === name)?.[0] ?? name.toLowerCase()
45
46export const shortName = (tool: string) => tool.slice(tool.lastIndexOf('__') + 2)
47export const serverName = (tool: string) => tool.slice('mcp__'.length, tool.lastIndexOf('__'))
48
49export const grouped = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
50
51export function compact(n: number): string {
52 // Exact below 10,000; rounded to one decimal of K, M or B above it.
53 for (const [from, size, unit] of [[1e9, 1e9, 'B'], [1e6, 1e6, 'M'], [1e4, 1e3, 'K']] as const) {
54 if (Math.abs(n) >= from) return `${(n / size).toFixed(1).replace(/\.0$/, '')}${unit}`
55 }
56 return grouped(n)
57}
58
59// `1 post`, `2,767 views`.
60export const plural = (n: number, word: string) => `${grouped(n)} ${word}${n === 1 ? '' : 's'}`
61
62export const label = (d: PdDataset) => `${d.icon ? `${d.icon.trim()} ` : ''}${d.name}`
63
64export function parseJson(text: string): unknown {
65 try {
66 return JSON.parse(text)
67 } catch {
68 return undefined
69 }
70}
71
72// list_datasets answers `{ result: [{ id, name, icon, ... }] }`.
73export function toDatasets(parsed: unknown): PdDataset[] | undefined {
74 const rows = (parsed as { result?: unknown } | undefined)?.result
75 if (!Array.isArray(rows)) return undefined
76 return rows
77 .filter(r => typeof r?.id === 'number' && typeof r?.name === 'string')
78 .map(r => ({ id: r.id as number, name: r.name as string, icon: typeof r.icon === 'string' ? r.icon : '' }))
79 .sort((a, b) => a.id - b.id)
80}
81
82export function toGlance(datasetId: number, raw: Record<string, unknown>): PdGlance {
83 const metric = (v: unknown): PdMetric => {
84 const m = v as { value?: unknown; delta_label?: unknown } | undefined
85 return { value: Number(m?.value ?? 0), deltaLabel: String(m?.delta_label ?? '') }
86 }
87 const platforms = Object.entries((raw.platforms ?? {}) as Record<string, unknown>)
88 .map(([platform, v]) => ({ platform: platformName(platform), posts: metric(v) }))
89 .sort((a, b) => b.posts.value - a.posts.value)
90 return {
91 datasetId,
92 days: Number(raw.comparison_days ?? GLANCE_DAYS),
93 posts: metric(raw.total_posts),
94 views: metric(raw.total_views),
95 followers: metric(raw.total_followers),
96 accounts: metric(raw.total_accounts),
97 activeAccounts: metric(raw.active_accounts),
98 creators: metric(raw.total_creators),
99 platforms,
100 }
101}
102
103export const DAY_MS = 86_400_000
104// How many days the tiles' charts cover.
105export const SERIES_DAYS = 14
106
107const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
108
109export const dayKey = (ms: number) => new Date(ms).toISOString().slice(0, 10)
110
111// The `n` days ending with `today`, oldest first, as YYYY-MM-DD (UTC).
112export function lastDays(today: string, n = SERIES_DAYS): string[] {
113 const end = Date.parse(`${today}T00:00:00Z`)
114 return Array.from({ length: n }, (_, i) => dayKey(end - (n - 1 - i) * DAY_MS))
115}
116
117export function shortDay(day: string): string {
118 const d = new Date(`${day}T00:00:00Z`)
119 return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`
120}
121
122// run_analytics_sql answers `{ columns: [...], rows: [[...], ...] }`.
123export function rowsOf(parsed: unknown): Record<string, unknown>[] {
124 const { columns, rows } = (parsed ?? {}) as { columns?: unknown; rows?: unknown }
125 if (!Array.isArray(columns) || !Array.isArray(rows)) {
126 throw new Error('PD Intelligence answered in a shape the panel does not know')
127 }
128 return rows.map(r => Object.fromEntries(columns.map((c, i) => [String(c), (r as unknown[])[i]])))
129}
130
131// The dates in these queries come from lastDays, never from input.
132export const activitySql = (from: string) =>
133 `SELECT CAST(post_timestamp AS DATE) AS day, platform, COUNT(*) AS posts, COUNT(DISTINCT account_id) AS accounts ` +
134 `FROM posts WHERE post_timestamp >= DATE '${from}' GROUP BY 1, 2`
135
136// Each account's last follower count per day, carried over days without one, summed over the
137// accounts that had a count by the first day, so one counted mid-window is not a jump.
138export const followersSql = (from: string, to: string) =>
139 `WITH days AS (SELECT CAST(d AS DATE) AS day FROM range(DATE '${from}', DATE '${to}' + INTERVAL 1 DAY, INTERVAL 1 DAY) t(d)), ` +
140 `snaps AS (SELECT account_id, CAST(created_at AS DATE) AS day, arg_max(followers_count, created_at) AS followers FROM account_metrics GROUP BY 1, 2), ` +
141 `grid AS (SELECT a.account_id, d.day FROM (SELECT DISTINCT account_id FROM snaps WHERE day <= DATE '${from}') a CROSS JOIN days d), ` +
142 `filled AS (SELECT g.day, (SELECT s.followers FROM snaps s WHERE s.account_id = g.account_id AND s.day <= g.day ORDER BY s.day DESC LIMIT 1) AS followers FROM grid g) ` +
143 `SELECT day, SUM(followers) AS followers FROM filled GROUP BY day ORDER BY day`
144
145// A post as the panel lists it; its title only when it has one (Threads' are empty, not null).
146const POST_ROW =
147 `SELECT p.id AS id, p.post_id, p.platform, p.author_username, left(regexp_replace(coalesce(nullif(p.title, ''), p.post_text, ''), '\\s+', ' ', 'g'), 120) AS text, m.views, p.content_type `
148
149export const topPostsSql = (from: string) =>
150 POST_ROW +
151 `FROM posts p JOIN post_metrics_latest m ON m.post_id = p.id ` +
152 `WHERE p.post_timestamp >= DATE '${from}' ORDER BY m.views DESC NULLS LAST LIMIT 3`
153
154// One day's top posts: the day itself is the call's date_from/date_to window.
155export const dayPostsSql = () => POST_ROW + `FROM posts p JOIN post_metrics_latest m ON m.post_id = p.id ORDER BY m.views DESC NULLS LAST LIMIT 5`
156
157// An account's latest posts: its platform is the call's platform filter.
158export const accountPostsSql = (username: string) =>
159 POST_ROW +
160 `FROM posts p LEFT JOIN post_metrics_latest m ON m.post_id = p.id ` +
161 `WHERE lower(p.author_username) = lower('${username.replace(/'/g, "''")}') ORDER BY p.post_timestamp DESC LIMIT 5`
162
163// One post's numbers, by PD's internal id (a number, never input text).
164export const postDetailSql = (id: number) =>
165 `SELECT p.id, p.post_id, p.platform, p.author_username, p.post_url, p.post_timestamp, p.content_type, m.views, m.likes, m.comments, m.shares ` +
166 `FROM posts p LEFT JOIN post_metrics_latest m ON m.post_id = p.id WHERE p.id = ${Math.trunc(id)}`
167
168// The latest stored daily brief (PD writes one a day; the table keeps them all).
169export const SUMMARY_SQL = `SELECT summary_date, narrative_text FROM daily_summaries ORDER BY summary_date DESC LIMIT 1`
170
171const sqlDay = (v: unknown) => String(v).slice(0, 10)
172
173export function toActivity(rows: readonly Record<string, unknown>[], days: readonly string[]): PdActivity {
174 const posts = new Map<string, number>()
175 const accounts = new Map<string, number>()
176 for (const r of rows) {
177 const day = sqlDay(r.day)
178 const n = Number(r.posts ?? 0)
179 posts.set(day, (posts.get(day) ?? 0) + n)
180 accounts.set(day, (accounts.get(day) ?? 0) + Number(r.accounts ?? 0))
181 }
182 return {
183 posts: days.map(day => ({ day, value: posts.get(day) ?? 0 })),
184 accounts: days.map(day => ({ day, value: accounts.get(day) ?? 0 })),
185 }
186}
187
188export function toFollowers(rows: readonly Record<string, unknown>[], days: readonly string[]): PdDay[] {
189 // A day no account has a count for yet comes back null: no value, not zero.
190 const byDay = new Map(rows.map(r => [sqlDay(r.day), r.followers == null ? NaN : Number(r.followers)]))
191 let last: number | undefined
192 const filled = days.map(day => {
193 const v = byDay.get(day)
194 if (v !== undefined && Number.isFinite(v)) last = v
195 return last
196 })
197 const base = filled.find(v => v !== undefined)
198 if (base === undefined) return []
199 return days.flatMap((day, i) => (filled[i] === undefined ? [] : [{ day, value: filled[i]! - base }]))
200}
201
202export function toTopPosts(rows: readonly Record<string, unknown>[]): PdTopPost[] {
203 return rows.map(r => ({
204 postId: String(r.post_id),
205 platform: platformName(String(r.platform ?? '')),
206 author: String(r.author_username ?? ''),
207 text: String(r.text ?? '').trim(),
208 views: Number(r.views ?? 0),
209 id: r.id == null ? undefined : Number(r.id),
210 contentType: r.content_type == null ? undefined : String(r.content_type),
211 }))
212}
213
214// get_leaderboard answers `{ result: [...] }`. Its engagement figures are left
215// out on purpose: hidden likes come back as -1 per post.
216export function toAttention(parsed: unknown): PdWeakAccount[] {
217 const rows = (parsed as { result?: unknown } | undefined)?.result
218 if (!Array.isArray(rows)) throw new Error('PD Intelligence answered in a shape the panel does not know')
219 return rows
220 .map(r => ({
221 username: String(r?.author_username ?? r?.username ?? ''),
222 platform: platformName(String(r?.platform ?? '')),
223 posts: Number(r?.total_posts ?? 0),
224 views: Number(r?.total_views ?? 0),
225 }))
226 .filter(a => a.username !== '')
227}
228
229export const SECTION_KEYS = ['glance', 'activity', 'followers', 'topPosts', 'attention'] as const
230export type SectionKey = (typeof SECTION_KEYS)[number]
231
232export const isStale = (s: PdSnapshot, today: string) =>
233 s.fetchedOn < today || SECTION_KEYS.some(k => s[k].status !== 'ready')
234
235// The read-only tools the panel itself calls; an allow rule for these lets it work in auto mode.
236export const PANEL_TOOLS = [
237 'list_datasets',
238 'get_totals',
239 'run_analytics_sql',
240 'get_leaderboard',
241 'get_comment_summary',
242 'search_accounts',
243] as const
244
245// What the panel says when Claude Code's permissions refuse one of its own lookups (in auto
246// mode a call the panel makes by itself has no request behind it to judge, so it is refused).
247export const blockedMessage = (tool: string) =>
248 `Claude Code's permissions stopped the panel from asking PD Intelligence for data (${tool}). ` +
249 `In auto mode a lookup the panel makes by itself can't be judged, so it is refused. ` +
250 `To fix it, allow PD Intelligence's read-only tools (${PANEL_TOOLS.join(', ')}) in your permission settings, ` +
251 `or switch out of auto mode, then press Refresh.`
252
253const num = (v: unknown) => Number(v ?? 0) || 0
254const str = (v: unknown) => (v == null ? '' : String(v))
255const unknownShape = () => new Error('PD Intelligence answered in a shape the panel does not know')
256
257// The daily_summaries row SUMMARY_SQL reads.
258export function toSummary(rows: readonly Record<string, unknown>[]): PdSummary {
259 const r = rows[0] ?? {}
260 if (typeof r.narrative_text !== 'string' || r.narrative_text.trim() === '') {
261 throw new Error('PD Intelligence has no daily summary for this dataset yet')
262 }
263 return { date: str(r.summary_date).slice(0, 10), narrative: r.narrative_text }
264}
265
266// The row postDetailSql reads. Engagement rate as PD defines it: likes, comments and shares per
267// view, 0 without views; a hidden like count (-1) counts as none.
268export function toPostDetail(rows: readonly Record<string, unknown>[]): PdPostDetail {
269 const r = rows[0]
270 if (!r) throw new Error('PD Intelligence has no such post in this dataset')
271 const views = num(r.views)
272 const engagements = Math.max(num(r.likes), 0) + Math.max(num(r.comments), 0) + Math.max(num(r.shares), 0)
273 return {
274 views,
275 likes: num(r.likes),
276 comments: num(r.comments),
277 shares: num(r.shares),
278 engagementRate: views > 0 ? (engagements / views) * 100 : 0,
279 postedAt: str(r.post_timestamp),
280 url: str(r.post_url),
281 contentType: str(r.content_type),
282 }
283}
284
285export function toCommentSummary(parsed: unknown): PdCommentSummary {
286 const r = (parsed ?? {}) as { summary_stats?: Record<string, unknown>; timeline?: unknown }
287 if (!r.summary_stats) throw unknownShape()
288 const timeline = Array.isArray(r.timeline) ? r.timeline : []
289 return {
290 collected: num(r.summary_stats.total_comments),
291 commenters: num(r.summary_stats.total_commenters),
292 authorReplies: num(r.summary_stats.author_reply_count),
293 byDay: timeline.map(t => ({ day: str(t?.date).slice(0, 10), value: num(t?.count) })),
294 }
295}
296
297// search_accounts matches handles loosely: keep the one on the row's platform.
298export function toAccountStats(parsed: unknown, ref: PdAccountRef): PdAccountStats {
299 const items = (parsed as { items?: unknown } | undefined)?.items
300 if (!Array.isArray(items)) throw unknownShape()
301 const r = items.find(
302 a => str(a?.author_username).toLowerCase() === ref.username.toLowerCase() && platformName(str(a?.platform)) === ref.platform,
303 )
304 if (!r) throw new Error(`No account @${ref.username} on ${ref.platform} in this dataset`)
305 return {
306 followers: num(r.followers_count),
307 posts: num(r.total_posts),
308 views: num(r.total_views),
309 likes: num(r.total_likes),
310 comments: num(r.total_comments),
311 engagementRate: num(r.engagement_rate),
312 firstPost: str(r.first_post),
313 lastPost: str(r.last_post),
314 activeDays: num(r.active_days),
315 }
316}
317
318// Threads and Bluesky never publish views; Instagram only on video (Reels).
319export function viewsLabel(platform: string, views: number, contentType?: string): string {
320 const unpublished = platform === 'Threads' || platform === 'Bluesky' || (platform === 'Instagram' && contentType !== 'video')
321 if (views === 0 && unpublished) return 'views not published'
322 return `${compact(views)} ${views === 1 ? 'view' : 'views'}`
323}
324
325export const pushRecent = (ids: readonly number[], id: number, max = 5) => [id, ...ids.filter(x => x !== id)].slice(0, max)
326
327export function orderByRecent(list: readonly PdDataset[], recent: readonly number[]): PdDataset[] {
328 const first = recent.flatMap(id => list.filter(d => d.id === id))
329 return [...first, ...list.filter(d => !recent.includes(d.id))]
330}
331
332// Tested questions the panel hands to Claude; the dataset is filled in at press time.
333export const QUESTIONS: readonly { label: string; ask: (d: PdDataset) => string }[] = [
334 { label: 'Posts per day by platform', ask: d => `Show posts per day by platform for the last 30 days in ${d.name} (#${d.id}).` },
335 { label: 'Biggest follower gains', ask: d => `Which accounts gained the most followers this month in ${d.name} (#${d.id})?` },
336 { label: 'Most-discussed posts', ask: d => `Which posts drew the most comments this week in ${d.name} (#${d.id}), and what are people saying?` },
337 { label: 'Engagement by platform', ask: d => `Compare engagement by platform this month in ${d.name} (#${d.id}), using each platform's published metrics.` },
338]
339
340export const startRequest = (list: readonly PdRequest[], req: PdRequest, max = 10) => [req, ...list].slice(0, max)
341
342// The oldest request still waiting with this exact text starts this turn (the list is
343// newest first); any other turn is not the panel's.
344export function requestStarted(list: readonly PdRequest[], text: string, turnId: string): PdRequest[] {
345 const i = list.map(r => r.status === 'sent' && r.text === text).lastIndexOf(true)
346 return i < 0 ? [...list] : list.map((r, j) => (j === i ? { ...r, status: 'working', turnId } : r))
347}
348
349export const requestDone = (list: readonly PdRequest[], turnId: string, answer: string, stopped: boolean): PdRequest[] =>
350 list.map(r => (r.turnId === turnId && r.status === 'working' ? { ...r, status: stopped ? 'stopped' : 'answered', answer } : r))
351
352// Markdown to one plain line: headings, emphasis and link targets dropped.
353export function plainPreview(markdown: string, max: number): string {
354 const flat = markdown
355 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
356 .replace(/[#*_`>]+/g, '')
357 .replace(/\s+/g, ' ')
358 .trim()
359 const chars = [...flat]
360 return chars.length <= max ? flat : `${chars.slice(0, max - 1).join('')}…`
361}
362hooks/charts.ts 167 lines1// Pure chart builders for the /pd panel: SVG for the surfaces that draw it
2// (desktop, mobile, vscode), text for the terminal. Nothing here calls PD.
3
4export type ChartPoint = { label: string; value: number }
5export type MixPart = { platform: string; posts: number }
6
7const BARS = '▁▂▃▄▅▆▇█'
8
9export function sparkline(values: readonly number[]): string {
10 if (values.length === 0) return ''
11 const min = Math.min(...values)
12 const max = Math.max(...values)
13 if (max === min) return BARS[max === 0 ? 0 : 3].repeat(values.length)
14 return values.map(v => BARS[Math.round(((v - min) / (max - min)) * (BARS.length - 1))]).join('')
15}
16
17const esc = (s: string) =>
18 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
19
20// One color per metric, from the dataviz reference palette's categorical slots, [light, dark];
21// the SVG picks by the reader's color scheme.
22export const SERIES = {
23 blue: ['#2a78d6', '#3987e5'],
24 aqua: ['#1baf7a', '#199e70'],
25 violet: ['#4a3aa7', '#9085e9'],
26} as const
27export type Series = keyof typeof SERIES
28
29// Every chart's root: it fills the frame the panel sizes, and follows the reader's theme, since a
30// frame whose color scheme differs from the page's paints an opaque (white) background.
31const THEMED = ':root{color-scheme:light dark}html,body,svg{background:transparent;margin:0;height:100%;overflow:hidden}svg{display:block}'
32const svgOpen = (width: number, height: number) =>
33 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="100%" height="100%" preserveAspectRatio="none">`
34
35const seriesStyle = (series: Series) =>
36 `<style>${THEMED}.s{fill:${SERIES[series][0]};stroke:${SERIES[series][0]}}.axis{stroke:#c9c8c3}` +
37 `@media (prefers-color-scheme:dark){.s{fill:${SERIES[series][1]};stroke:${SERIES[series][1]}}.axis{stroke:#4a4a46}}</style>`
38
39// One bar per point, a hover title on each whole column, a zero line for
40// series that dip below zero (follower change).
41export function barSvg(points: readonly ChartPoint[], series: Series = 'blue', width = 280, height = 44): string {
42 const slot = width / Math.max(points.length, 1)
43 const gap = 2
44 const lo = Math.min(0, ...points.map(p => p.value))
45 const hi = Math.max(0, ...points.map(p => p.value))
46 const span = hi - lo || 1
47 const y = (v: number) => height - ((v - lo) / span) * height
48 const zero = y(0)
49 const cols = points.map((p, i) => {
50 const x = i * slot
51 const top = Math.min(y(p.value), zero)
52 const h = p.value === 0 ? 0 : Math.max(Math.abs(y(p.value) - zero), 1)
53 return (
54 `<g><title>${esc(p.label)}</title>` +
55 `<rect x="${x.toFixed(1)}" y="0" width="${slot.toFixed(1)}" height="${height}" fill="transparent"/>` +
56 `<rect class="s" stroke-width="0" x="${(x + gap / 2).toFixed(1)}" y="${top.toFixed(1)}" width="${Math.max(slot - gap, 1).toFixed(1)}" height="${h.toFixed(1)}" rx="2"/></g>`
57 )
58 })
59 return (
60 svgOpen(width, height) +
61 seriesStyle(series) +
62 `<line class="axis" x1="0" x2="${width}" y1="${zero.toFixed(1)}" y2="${zero.toFixed(1)}" stroke-width="1"/>` +
63 cols.join('') +
64 '</svg>'
65 )
66}
67
68// A line over a shaded area, for a running figure (follower change): one point per day, a
69// hover title on each whole column, the zero line, and the last point marked.
70export function lineSvg(points: readonly ChartPoint[], series: Series = 'aqua', width = 280, height = 44): string {
71 const pad = 3
72 const slot = width / Math.max(points.length, 1)
73 const lo = Math.min(0, ...points.map(p => p.value))
74 const hi = Math.max(0, ...points.map(p => p.value))
75 const span = hi - lo || 1
76 const y = (v: number) => pad + (height - 2 * pad) * (1 - (v - lo) / span)
77 const x = (i: number) => i * slot + slot / 2
78 const zero = y(0)
79 const xy = points.map((p, i) => `${x(i).toFixed(1)} ${y(p.value).toFixed(1)}`)
80 const line =
81 xy.length > 1 ? `<path d="M ${xy.join(' L ')}" fill="none" class="s" stroke-width="2" vector-effect="non-scaling-stroke" style="fill:none"/>` : ''
82 const area =
83 xy.length > 1
84 ? `<path d="M ${x(0).toFixed(1)} ${zero.toFixed(1)} L ${xy.join(' L ')} L ${x(points.length - 1).toFixed(1)} ${zero.toFixed(1)} Z" class="s" stroke-width="0" fill-opacity="0.18"/>`
85 : ''
86 // The last point as a round dot that stays round however the frame stretches the chart.
87 const end = points.length > 0 ? `${x(points.length - 1).toFixed(1)} ${y(points[points.length - 1].value).toFixed(1)}` : ''
88 const last = end ? `<path class="s" d="M ${end} L ${end}" stroke-width="6" stroke-linecap="round" vector-effect="non-scaling-stroke"/>` : ''
89 const cols = points.map(
90 (p, i) => `<g><title>${esc(p.label)}</title><rect x="${(i * slot).toFixed(1)}" y="0" width="${slot.toFixed(1)}" height="${height}" fill="transparent"/></g>`,
91 )
92 return (
93 svgOpen(width, height) +
94 seriesStyle(series) +
95 `<line class="axis" x1="0" x2="${width}" y1="${zero.toFixed(1)}" y2="${zero.toFixed(1)}" stroke-width="1"/>` +
96 area +
97 line +
98 last +
99 cols.join('') +
100 '</svg>'
101 )
102}
103
104// Whole-number shares of `total` that add up to it (largest remainder).
105export function apportion(values: readonly number[], total: number): number[] {
106 const sum = values.reduce((s, v) => s + v, 0)
107 if (sum <= 0) return values.map(() => 0)
108 const raw = values.map(v => (v / sum) * total)
109 const out = raw.map(Math.floor)
110 let left = total - out.reduce((s, v) => s + v, 0)
111 const order = raw.map((r, i) => [r - Math.floor(r), i] as const).sort((a, b) => b[0] - a[0])
112 for (const [, i] of order) {
113 if (left <= 0) break
114 out[i]++
115 left--
116 }
117 return out
118}
119
120// The PD Intelligence dashboard's color for each platform, [light, dark]; the dark step only
121// where the dashboard's color would vanish on a dark background (Threads, X).
122const PLATFORM_COLORS: Record<string, readonly [string, string]> = {
123 Instagram: ['#E1306C', '#E1306C'],
124 Threads: ['#111111', '#E5E5E5'],
125 Facebook: ['#1877F2', '#1877F2'],
126 TikTok: ['#14A39A', '#14A39A'],
127 X: ['#64748B', '#94A3B8'],
128 YouTube: ['#FF0000', '#FF0000'],
129 Bluesky: ['#0085FF', '#0085FF'],
130}
131const OTHER_COLOR = ['#8A8984', '#8A8984'] as const
132
133export const platformColor = (platform: string, mode: 'light' | 'dark' = 'light') =>
134 (PLATFORM_COLORS[platform] ?? OTHER_COLOR)[mode === 'dark' ? 1 : 0]
135
136// A thin bar of one segment per platform, each in its dashboard color with a hover title.
137export function mixSvg(parts: readonly MixPart[], width = 280, height = 6): string {
138 const total = parts.reduce((s, p) => s + p.posts, 0) || 1
139 const light: string[] = []
140 const dark: string[] = []
141 const segs: string[] = []
142 let x = 0
143 parts.forEach((p, i) => {
144 const w = (p.posts / total) * width
145 light.push(`.m${i}{fill:${platformColor(p.platform)}}`)
146 dark.push(`.m${i}{fill:${platformColor(p.platform, 'dark')}}`)
147 segs.push(
148 `<rect class="m${i}" x="${x.toFixed(1)}" y="0" width="${Math.max(w - 2, 0.5).toFixed(1)}" height="${height}" rx="2">` +
149 `<title>${esc(`${p.platform} · ${((p.posts / total) * 100).toFixed(1)}%`)}</title></rect>`,
150 )
151 x += w
152 })
153 return (
154 svgOpen(width, height) +
155 `<style>${THEMED}${light.join('')}@media (prefers-color-scheme:dark){${dark.join('')}}</style>` +
156 segs.join('') +
157 '</svg>'
158 )
159}
160
161export function oneLine(text: string, max: number): string {
162 // Post text often arrives wrapped in its own straight quotes; drop them.
163 const flat = text.replace(/\s+/g, ' ').trim().replace(/^"+|"+$/g, '').trim()
164 const chars = [...flat]
165 return chars.length <= max ? flat : `${chars.slice(0, max - 1).join('')}…`
166}
167types/index.d.ts 159 lines1/** One PD Intelligence tool call, as /pd-evidence lists it. */
2export type PdEvidence = {
3 /** 1-based position in this session's ledger. */
4 n: number
5 /** The tool's own name, without its `mcp__<server>__` prefix. */
6 tool: string
7 /** The arguments the call was made with. */
8 args: Record<string, unknown>
9 /** When it returned, ISO 8601 UTC. */
10 at: string
11 isError: boolean
12 /** Length of the result text the model read. */
13 chars: number
14 /** Where the raw result was saved, when evidence capture was on. */
15 file?: string
16}
17
18/** A dataset as list_datasets returns it, trimmed to what the panel shows. */
19export type PdDataset = { id: number; name: string; icon: string }
20
21/** One headline number and its change over the comparison window. */
22export type PdMetric = { value: number; deltaLabel: string }
23
24/** get_dashboard_stats for the pinned dataset, as the panel draws it. */
25export type PdGlance = {
26 datasetId: number
27 days: number
28 posts: PdMetric
29 views: PdMetric
30 followers: PdMetric
31 accounts: PdMetric
32 activeAccounts: PdMetric
33 creators: PdMetric
34 platforms: { platform: string; posts: PdMetric }[]
35}
36
37/** One day's value in a series; `day` is YYYY-MM-DD (UTC). */
38export type PdDay = { day: string; value: number }
39
40/** A panel section: each loads, and fails, on its own. */
41export type PdSection<T> =
42 | { status: 'loading' }
43 | { status: 'ready'; data: T }
44 | { status: 'failed'; message: string }
45
46/** Posts and posting accounts per day. */
47export type PdActivity = {
48 posts: PdDay[]
49 accounts: PdDay[]
50}
51
52/** A post the panel lists; `id` (PD's internal id) is what drill-ins need. */
53export type PdTopPost = { id?: number; postId: string; platform: string; author: string; text: string; views: number; contentType?: string }
54
55/** An account get_leaderboard's needs_attention mode ranks low. */
56export type PdWeakAccount = { username: string; platform: string; posts: number; views: number }
57
58/** Everything the panel shows for one dataset. */
59export type PdSnapshot = {
60 datasetId: number
61 /** The UTC day it was fetched, YYYY-MM-DD. */
62 fetchedOn: string
63 glance: PdSection<PdGlance>
64 activity: PdSection<PdActivity>
65 /** Followers gained or lost since the first day of the window. */
66 followers: PdSection<PdDay[]>
67 topPosts: PdSection<PdTopPost[]>
68 attention: PdSection<PdWeakAccount[]>
69}
70
71/** PD's daily summary for a dataset: its markdown narrative and the day it covers. */
72export type PdSummary = { date: string; narrative: string }
73
74/** The Today screen's data, fetched when opened and kept for the UTC day. */
75export type PdToday = { datasetId: number; fetchedOn: string; summary: PdSection<PdSummary>; posts: PdSection<PdTopPost[]> }
76
77export type PdPostDetail = {
78 views: number
79 likes: number
80 comments: number
81 shares: number
82 engagementRate: number
83 postedAt: string
84 url: string
85 contentType: string
86}
87
88/** Comments PD collected for one post (can be fewer than the post's own count). */
89export type PdCommentSummary = { collected: number; commenters: number; authorReplies: number; byDay: PdDay[] }
90
91export type PdAccountStats = {
92 followers: number
93 posts: number
94 views: number
95 likes: number
96 comments: number
97 engagementRate: number
98 firstPost: string
99 lastPost: string
100 activeDays: number
101}
102
103/** An account as a row names it: handle and platform display name. */
104export type PdAccountRef = { username: string; platform: string }
105
106/** What the panel shows. */
107export type PdScreen =
108 | { kind: 'home' }
109 | { kind: 'today' }
110 | { kind: 'post'; post: PdTopPost }
111 | { kind: 'account'; account: PdAccountRef }
112 | { kind: 'answer'; requestId: string }
113
114/** A request the panel handed to Claude, and what came of it. */
115export type PdRequest = {
116 id: string
117 label: string
118 text: string
119 status: 'sent' | 'working' | 'answered' | 'stopped'
120 at: string
121 turnId?: string
122 answer?: string
123}
124
125export type PdPostView = { datasetId: number; postId: number; detail: PdSection<PdPostDetail>; comments: PdSection<PdCommentSummary> | null }
126export type PdAccountView = { datasetId: number; key: string; stats: PdSection<PdAccountStats>; recent: PdSection<PdTopPost[]> }
127
128declare module 'claude-code' {
129 interface PluginState {
130 'pd-intel-code': {
131 ledger: PdEvidence[]
132 /** Every dataset the person can see; null until first loaded. */
133 datasets: PdDataset[] | null
134 /** The dataset Claude works in (mirrors $.store `dataset`). */
135 pinned: PdDataset | null
136 /** The pinned dataset's control-center data (mirrors $.store `snapshot-ID`). */
137 snapshot: PdSnapshot | null
138 /** What the panel draws: home or one of its in-panel views (session only). */
139 screen: PdScreen
140 /** The Today screen's data for the pinned dataset (mirrors $.store `today-ID`). */
141 today: PdToday | null
142 postView: PdPostView | null
143 accountView: PdAccountView | null
144 /** Requests the panel handed to Claude, newest first (session only). */
145 requests: PdRequest[]
146 /** Recently pinned dataset ids, most recent first (mirrors $.store `recentDatasets`). */
147 recent: number[]
148 /** Whether raw results are saved to disk (mirrors $.store `capture`). */
149 capture: boolean
150 /** A loading or error line the panel shows above everything else. */
151 notice: string | null
152 /** Where /pd shows the panel: in the chat, or the side panel (mirrors $.store `view`). */
153 view: 'chat' | 'side'
154 /** For each MCP server a tool call went to: whether it is PD Intelligence. */
155 pdServers: Record<string, boolean>
156 }
157 }
158}
159