SLOPSHOPPER

PD Intelligence for Claude Code

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…

newpanerowsguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pd-intel-code
│ ┃ pd ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Can't find the PD Intelligence connector. If │ pd-intel-code │ │ ┃ Claude Code just started it may still be ⏺ Read(src/auth.ts) │ PD Intelligence: type /pd to see your │ │ ┃ connecting: press Refresh in a few seconds. ⎿ Read 6 lines │ datasets at a glance │ │ ┃ Otherwise check /mcp that PD Intelligence is ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ connected. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No dataset chosen yet. Pick one and Claude ⎿ 3 pass, 1 fail │ ┃ will use it. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Sources used (0) ] [ Save results for char │ ✻ Worked for 42s · done 4:20 PM │ │ › /pd │ Can't find the PD Intelligence connector. If Claude Code just start │ it may still be connecting: press Refresh in a few seconds. Otherwi │ check /mcp that PD Intelligence is connected. │ │ No dataset chosen yet. Pick one and Claude will use it. [ Refresh ] │ │ [ Sources used (0) ] [ Save results for charts: Off ] │ │ [ Move to side panel ] │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pd
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. No dataset chosen yet. Pick one and Claude will use it. [ Re [ Sources used (0) ] [ Save results for charts: Off ]
Command output
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. No dataset chosen yet. Pick one and Claude will use it. [ Refresh ] [ Sources used (0) ] [ Save results for charts: Off ] [ Move to side panel ]
README

pd-intel-code — PD Intelligence for Claude Code

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.

Start here: /pd

Type /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 ]
  • Pick a dataset from the dropdown (on the phone: press one in the list). It's remembered across sessions, shown in the status line (PD Intelligence · 📈 Example Campaign), and Claude uses it without asking.
  • Tiles show each number, its change over 7 days and a 14-day chart. On the desktop and phone, hover a bar for its day and value; the terminal draws the same trend in bar characters. The followers chart shows followers gained or lost since the first day.
  • Why?, Look into it and Today's brief send Claude a ready-written request as your own message, so you see exactly what was asked.
  • Data is fetched once a day per dataset and saved, so /pd opens instantly; Refresh fetches it again now. A section that fails says so on its own line; the rest still shows.
  • Explore — a plain-language tour: what's tracked, what changed, what's worth a look.
  • Write a report — Claude asks topic, period and audience, then writes an evidence-backed report to reports/ ending in a sources list.
  • Save results for charts — saves each PD Intelligence result as JSON under ./pd-intel-evidence/ (git-ignored automatically) so charts are built from the real numbers.
  • Sources used — every PD Intelligence call Claude made this session (tool, arguments, time), ready for a report's sources section. The panel's own fetches are not listed.

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"
  ]
}

Also

"Work in …"Ask Claude to switch datasets in plain words; it uses the mod's choose_dataset tool.
/pd-dataset ID / /pd-dataset offChoose 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:reportWhat the panel's two buttons run.
Write guarddelete_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.)

Install

/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.

Develop

claude plugin validate plugins/pd-intel-code   # manifest + hooks module
claude plugin test plugins/pd-intel-code       # hooks/register.test.tsx
Source 5 files
hooks/register.tsx 711 lines
1import { 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}
711
hooks/panel.tsx 553 lines
1// 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}
553
hooks/data.ts 362 lines
1// 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}
362
hooks/charts.ts 167 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
167
types/index.d.ts 159 lines
1/** 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