{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-09",
  "title": "Knowledge sources",
  "description": "The sources an agent searches, where a source lights in the colour of the agent when its answer uses it, with indexing progress, a Re-sync that turns into a spinner then a check, and Retry on the one that failed.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/agent-run.json",
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/dropdown-menu.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/live-duration.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/mesh-tile.json",
    "https://ui.ndekocode.dev/r/morph-button.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/motion-presets.json",
    "https://ui.ndekocode.dev/r/motion-word-roll.json",
    "https://ui.ndekocode.dev/r/progress.json",
    "https://ui.ndekocode.dev/r/run-session.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-kept-height.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/use-session.json",
    "https://ui.ndekocode.dev/r/use-timeouts.json",
    "https://ui.ndekocode.dev/r/utils.json",
    "https://ui.ndekocode.dev/r/words.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/agent/agent-09.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion } from 'motion/react';\nimport { useEffect, useState } from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport { WordRoll } from '@/components/core/word-roll';\nimport { useKeptHeight } from '@/registry/ndeko/hooks/use-kept-height';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  useSession,\n  type SessionStep,\n} from '@/registry/ndeko/hooks/use-session';\nimport { useTimeouts } from '@/registry/ndeko/hooks/use-timeouts';\nimport {\n  emptyRun,\n  reduceRun,\n  seriesColorOf,\n  type RunAgent,\n  type RunEvent,\n  type RunSnapshot,\n  type ToolCall,\n} from '@/registry/ndeko/lib/agent-run';\nimport {\n  pressedQuietOf,\n  spokenUnless,\n} from '@/registry/ndeko/lib/demonstration';\nimport {\n  FileTextIcon,\n  FolderIcon,\n  GlobeIcon,\n  LifeBuoyIcon,\n  PlusIcon,\n  RefreshCwIcon,\n  RotateCcwIcon,\n  UploadIcon,\n} from '@/registry/ndeko/lib/icons';\nimport {\n  fadePresence,\n  rowPresenceOf,\n} from '@/registry/ndeko/lib/motion-presets';\nimport { applyBatchAt, restingOf } from '@/registry/ndeko/lib/run-session';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { countOf } from '@/registry/ndeko/lib/words';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/registry/ndeko/ui/dropdown-menu';\nimport { ClockGate } from '@/registry/ndeko/ui/live-duration';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\nimport { MorphButton, type MorphState } from '@/registry/ndeko/ui/morph-button';\nimport { Progress, ProgressLabel } from '@/registry/ndeko/ui/progress';\n\nimport { AgentTile } from './_parts/agent-tile';\nimport { RESEARCHER } from './_parts/crew';\n\nimport { RelativeTime } from './_parts/relative-time';\n\nexport type SourceKind = 'file' | 'site' | 'drive' | 'helpdesk';\n\nexport type SourceState =\n  'queued' | 'indexing' | 'ready' | 'syncing' | 'failed';\n\nexport type KnowledgeSource = {\n  id: string;\n  kind: SourceKind;\n  name: string;\n  /** What it is, in a few words: \"PDF, 2.4 MB\", \"312 pages\". */\n  detail: string;\n  /** Chunks indexed at the last finished sync. */\n  chunks: number;\n  lastSyncAt?: number;\n  /** The latest sync job. Its output carries `{ chunks, expected }` while it indexes. */\n  job?: ToolCall;\n};\n\n/** An answer being written, and the sources it has read so far. */\nexport type SourceAnswer = {\n  /** Who answers: its name is shown and its colour is the light of the sources it uses. */\n  agent: Pick<RunAgent, 'name' | 'series'>;\n  question: string;\n  /** The sources the answer draws on, in the order it reached them. */\n  usedIds: string[];\n  isDone: boolean;\n};\n\nconst KIND_GLYPH: Record<SourceKind, React.ReactNode> = {\n  file: <FileTextIcon />,\n  site: <GlobeIcon />,\n  drive: <FolderIcon />,\n  helpdesk: <LifeBuoyIcon />,\n};\n\nconst KIND_WORD: Record<SourceKind, string> = {\n  file: 'Uploaded file',\n  site: 'Docs site',\n  drive: 'Shared drive folder',\n  helpdesk: 'Help center',\n};\n\nconst RESYNC_LABELS: Record<MorphState, string> = {\n  idle: 'Re-sync',\n  busy: 'Syncing',\n  done: 'Synced',\n};\n\nconst RETRY_LABELS: Record<MorphState, string> = {\n  idle: 'Retry',\n  busy: 'Retrying',\n  done: 'Synced',\n};\n\nconst CONFIRM_MS = 1600;\n\nconst COUNT = new Intl.NumberFormat('en-US');\n\ntype JobProgress = {\n  chunks: number;\n  expected?: number;\n};\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  const isObject = typeof value === 'object';\n  return isObject && value !== null;\n}\n\nfunction numberAt(record: Record<string, unknown>, key: string) {\n  const value = record[key];\n  if (typeof value === 'number') {\n    return value;\n  }\n  return undefined;\n}\n\nfunction progressOf(job: ToolCall | undefined): JobProgress | null {\n  const output = job?.output;\n  if (!isRecord(output)) {\n    return null;\n  }\n  const chunks = numberAt(output, 'chunks');\n  if (chunks === undefined) {\n    return null;\n  }\n  return { chunks, expected: numberAt(output, 'expected') };\n}\n\nexport function sourceStateOf(source: KnowledgeSource): SourceState {\n  const job = source.job;\n  const hasSynced = source.lastSyncAt !== undefined;\n  if (!job) {\n    if (hasSynced) {\n      return 'ready';\n    }\n    return 'queued';\n  }\n  if (job.status === 'failed') {\n    return 'failed';\n  }\n  if (job.status === 'running') {\n    if (hasSynced) {\n      return 'syncing';\n    }\n    return 'indexing';\n  }\n  if (job.status === 'done') {\n    return 'ready';\n  }\n  return 'queued';\n}\n\nfunction chunksOf(source: KnowledgeSource) {\n  const progress = progressOf(source.job);\n  return progress?.chunks ?? source.chunks;\n}\n\n/**\n * What a source holds for good: the count of its last finished sync. A sync under way does not\n * count yet, so the total of the header moves once, when the sync ends.\n */\nfunction settledChunksOf(source: KnowledgeSource) {\n  const isDone = source.job?.status === 'done';\n  if (isDone) {\n    return chunksOf(source);\n  }\n  return source.chunks;\n}\n\nfunction shareOf(part: number, total: number | undefined) {\n  if (!total) {\n    return 0;\n  }\n  const ratio = part / total;\n  const share = ratio * 100;\n  return Math.min(100, share);\n}\n\nfunction lastSyncOf(source: KnowledgeSource) {\n  const job = source.job;\n  const isDone = job?.status === 'done';\n  if (isDone) {\n    return job?.endedAt;\n  }\n  return source.lastSyncAt;\n}\n\nfunction morphStateOf(isSyncing: boolean, isConfirming: boolean): MorphState {\n  if (isSyncing) {\n    return 'busy';\n  }\n  if (isConfirming) {\n    return 'done';\n  }\n  return 'idle';\n}\n\nfunction SyncButton({\n  source,\n  state,\n  isPressed,\n  onSync,\n}: {\n  source: KnowledgeSource;\n  state: SourceState;\n  isPressed: boolean;\n  onSync?: (sourceId: string) => void;\n}) {\n  const isSyncing = state === 'syncing';\n  const isFailed = state === 'failed';\n  const [shownSyncing, setShownSyncing] = useState(isSyncing);\n  const [isConfirming, setIsConfirming] = useState(false);\n\n  const hasSyncChanged = shownSyncing !== isSyncing;\n  if (hasSyncChanged) {\n    setShownSyncing(isSyncing);\n    const hasFinished = !isSyncing && !isFailed;\n    if (hasFinished) {\n      setIsConfirming(true);\n    }\n  }\n\n  useEffect(() => {\n    if (!isConfirming) {\n      return;\n    }\n    const timer = setTimeout(() => setIsConfirming(false), CONFIRM_MS);\n    return () => clearTimeout(timer);\n  }, [isConfirming]);\n\n  const morphState = morphStateOf(isSyncing, isConfirming);\n  const labels = labelsOf(isFailed);\n  const idleIcon = idleIconOf(isFailed);\n  const accessibleLabel = accessibleLabelOf(morphState, isFailed, source.name);\n\n  function sync() {\n    onSync?.(source.id);\n  }\n\n  return (\n    <MorphButton\n      type='button'\n      variant='outline'\n      size='sm'\n      state={morphState}\n      labels={labels}\n      idleIcon={idleIcon}\n      onClick={sync}\n      aria-label={accessibleLabel}\n      className={pressedQuietOf(isPressed)}\n    />\n  );\n}\n\nfunction labelsOf(isFailed: boolean) {\n  if (isFailed) {\n    return RETRY_LABELS;\n  }\n  return RESYNC_LABELS;\n}\n\nfunction idleIconOf(isFailed: boolean) {\n  if (isFailed) {\n    return <RotateCcwIcon />;\n  }\n  return <RefreshCwIcon />;\n}\n\nfunction accessibleLabelOf(\n  morphState: MorphState,\n  isFailed: boolean,\n  name: string\n) {\n  const labels = labelsOf(isFailed);\n  const word = labels[morphState];\n  return `${word} ${name}`;\n}\n\n/**\n * A count that climbs while a source is indexed is plain text in a box of fixed width: it changes\n * too often for digits that roll, and nothing beside it moves when it gains a digit.\n */\nfunction IndexingSignal({ source }: { source: KnowledgeSource }) {\n  const progress = progressOf(source.job);\n  const chunks = progress?.chunks ?? 0;\n  const expected = progress?.expected;\n  const value = shareOf(chunks, expected);\n  const hasExpected = expected !== undefined;\n\n  return (\n    <Progress value={value} className='gap-1.5 pb-0.5'>\n      <ProgressLabel className='text-muted-foreground text-xs leading-5 font-normal'>\n        Indexing{' '}\n        <span className='inline-block min-w-[5ch] text-end tabular-nums'>\n          {COUNT.format(chunks)}\n        </span>\n        {hasExpected && (\n          <span className='tabular-nums'> of {COUNT.format(expected)}</span>\n        )}{' '}\n        chunks\n      </ProgressLabel>\n    </Progress>\n  );\n}\n\nfunction SourceSignal({\n  source,\n  state,\n}: {\n  source: KnowledgeSource;\n  state: SourceState;\n}) {\n  const chunks = chunksOf(source);\n  const lastSyncAt = lastSyncOf(source);\n  const hasSynced = lastSyncAt !== undefined;\n\n  if (state === 'indexing') {\n    return <IndexingSignal source={source} />;\n  }\n  if (state === 'failed') {\n    return (\n      <p className='text-destructive-ink text-xs leading-5'>\n        {source.job?.error}\n      </p>\n    );\n  }\n  if (state === 'queued') {\n    return (\n      <p className='text-muted-foreground text-xs leading-5'>\n        Waits its turn to be indexed\n      </p>\n    );\n  }\n  return (\n    <p className='text-muted-foreground text-xs leading-5'>\n      <MotionNumber value={chunks} /> chunks\n      {hasSynced && (\n        <>\n          , synced <RelativeTime at={lastSyncAt ?? 0} />\n        </>\n      )}\n    </p>\n  );\n}\n\nfunction signalKeyOf(state: SourceState) {\n  if (state === 'syncing') {\n    return 'ready';\n  }\n  return state;\n}\n\nfunction showsButtonIn(state: SourceState) {\n  const isReady = state === 'ready';\n  const isSyncing = state === 'syncing';\n  const isFailed = state === 'failed';\n  return isReady || isSyncing || isFailed;\n}\n\ntype LitStyle = React.CSSProperties & { '--lit': string };\n\nconst UNLIT: LitStyle = { '--lit': 'transparent' };\n\nfunction litStyleOf(color: string | null): LitStyle {\n  if (color === null) {\n    return UNLIT;\n  }\n  return { '--lit': color };\n}\n\nfunction rowTintOf(isLit: boolean) {\n  if (isLit) {\n    return 'bg-(--lit)/8';\n  }\n  return undefined;\n}\n\n/** The ring settles around the tile from a little larger, once, when the source lights. */\nfunction haloOf(isLit: boolean) {\n  if (isLit) {\n    return 'scale-100 opacity-100';\n  }\n  return 'scale-110 opacity-0';\n}\n\nfunction SourceRow({\n  source,\n  litColor,\n  isSyncPressed,\n  onSync,\n}: {\n  source: KnowledgeSource;\n  /** The colour of the agent whose answer uses the source now, or null while nothing reads it. */\n  litColor: string | null;\n  isSyncPressed: boolean;\n  onSync?: (sourceId: string) => void;\n}) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const rowPresence = rowPresenceOf(shouldReduceMotion);\n  const fade = fadePresence();\n  const state = sourceStateOf(source);\n  const showsButton = showsButtonIn(state);\n  const isLit = litColor !== null;\n\n  return (\n    <div\n      data-lit={isLit}\n      className={cn(\n        'flex items-start gap-3 rounded-lg px-2 py-2.5 transition-colors duration-300 ease-(--ease-out-strong)',\n        rowTintOf(isLit)\n      )}\n      style={litStyleOf(litColor)}\n    >\n      <span className='relative shrink-0'>\n        <span\n          aria-hidden='true'\n          className={cn(\n            'absolute -inset-1 rounded-[10px] shadow-[0_0_0_2px_color-mix(in_oklab,var(--lit)_55%,transparent)] transition-[opacity,scale] duration-300 ease-(--ease-out-strong)',\n            haloOf(isLit)\n          )}\n        />\n        <span\n          aria-hidden='true'\n          className='keycap-3d text-muted-foreground relative flex size-8 items-center justify-center rounded-lg [&_svg]:size-4'\n        >\n          {KIND_GLYPH[source.kind]}\n        </span>\n      </span>\n      <div className='min-w-0 flex-1'>\n        <p className='truncate text-[13px] leading-5 font-medium'>\n          {source.name}\n        </p>\n        <p className='text-muted-foreground truncate text-xs leading-5'>\n          <span className='sr-only'>{KIND_WORD[source.kind]}, </span>\n          {source.detail}\n          {isLit && <span className='sr-only'>, used by the answer</span>}\n        </p>\n        <AnimatePresence initial={false}>\n          <motion.div\n            key={signalKeyOf(state)}\n            className='overflow-hidden'\n            {...rowPresence}\n          >\n            <SourceSignal source={source} state={state} />\n          </motion.div>\n        </AnimatePresence>\n      </div>\n      <AnimatePresence initial={false}>\n        {showsButton && (\n          <motion.span key='sync' className='shrink-0' {...fade}>\n            <SyncButton\n              source={source}\n              state={state}\n              isPressed={isSyncPressed}\n              onSync={onSync}\n            />\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction answerLineOf(answer: SourceAnswer) {\n  const name = answer.agent.name;\n  const usedCount = answer.usedIds.length;\n  if (!answer.isDone) {\n    return `${name} is answering`;\n  }\n  if (usedCount === 0) {\n    return `${name} answered without a source`;\n  }\n  return `${name} answered from ${countOf(usedCount, 'source')}`;\n}\n\nfunction AnswerTrace({ answer }: { answer: SourceAnswer }) {\n  const { agent, question } = answer;\n\n  return (\n    <div className='bg-foreground/[0.04] mt-2 flex flex-col gap-1 rounded-lg p-3'>\n      <p className='text-muted-foreground flex items-center gap-2 text-xs leading-5'>\n        <AgentTile name={agent.name} series={agent.series} size='sm' />\n        <WordRoll text={answerLineOf(answer)} />\n      </p>\n      <p className='text-[13px] leading-5 text-pretty'>\n        &ldquo;{question}&rdquo;\n      </p>\n    </div>\n  );\n}\n\nexport type SourceKindChoice = {\n  kind: SourceKind;\n  label: string;\n  isAvailable: boolean;\n};\n\nexport type KnowledgeSourcesProps = {\n  sources: KnowledgeSource[];\n  /** Re-sync for a ready source, Retry for a failed one. */\n  onSync?: (sourceId: string) => void;\n  /** The Add source menu: one entry per way in. */\n  addChoices?: SourceKindChoice[];\n  onAdd?: (kind: SourceKind) => void;\n  /** The answer being written from these sources: each source it uses lights in the colour of its agent. */\n  answer?: SourceAnswer | null;\n  /** The source whose sync button a demonstration holds down. */\n  pressedSyncId?: string | null;\n  title?: string;\n  description?: string;\n  className?: string;\n};\n\nconst ADD_GLYPH: Partial<Record<SourceKind, React.ReactNode>> = {\n  file: <UploadIcon />,\n  site: <GlobeIcon />,\n};\n\nconst NO_CHOICES: SourceKindChoice[] = [];\n\nfunction totalChunksOf(sources: KnowledgeSource[]) {\n  let total = 0;\n  for (const source of sources) {\n    total += settledChunksOf(source);\n  }\n  return total;\n}\n\nfunction litColorOf(answer: SourceAnswer | null, sourceId: string) {\n  if (!answer) {\n    return null;\n  }\n  const isUsed = answer.usedIds.includes(sourceId);\n  if (!isUsed) {\n    return null;\n  }\n  return seriesColorOf(answer.agent.series);\n}\n\nexport function KnowledgeSources({\n  sources,\n  onSync,\n  addChoices = NO_CHOICES,\n  onAdd,\n  answer = null,\n  pressedSyncId = null,\n  title = 'Knowledge',\n  description,\n  className,\n}: KnowledgeSourcesProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const rowPresence = rowPresenceOf(shouldReduceMotion);\n  const totalChunks = totalChunksOf(sources);\n  const hasChoices = addChoices.length > 0;\n  const isEmpty = sources.length === 0;\n\n  return (\n    <section\n      aria-label={title}\n      className={cn(\n        'bg-card hairline-ring ring-border flex flex-col gap-1 rounded-xl p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)]',\n        className\n      )}\n    >\n      <header className='flex items-start justify-between gap-3 pb-1'>\n        <div className='min-w-0'>\n          <h3 className='text-sm font-medium'>{title}</h3>\n          <p className='text-muted-foreground text-xs leading-5'>\n            {countOf(sources.length, 'source')},{' '}\n            <MotionNumber value={totalChunks} /> chunks\n            {description && <>. {description}</>}\n          </p>\n        </div>\n        {hasChoices && (\n          <DropdownMenu>\n            <DropdownMenuTrigger\n              render={<Button type='button' variant='outline' size='sm' />}\n            >\n              <PlusIcon />\n              Add source\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align='end'>\n              {addChoices.map((choice) => (\n                <DropdownMenuItem\n                  key={choice.kind}\n                  disabled={!choice.isAvailable}\n                  onClick={() => onAdd?.(choice.kind)}\n                >\n                  {ADD_GLYPH[choice.kind]}\n                  {choice.label}\n                </DropdownMenuItem>\n              ))}\n            </DropdownMenuContent>\n          </DropdownMenu>\n        )}\n      </header>\n      {isEmpty && (\n        <p className='text-muted-foreground py-6 text-center text-[13px]'>\n          No sources yet. Add a file or a site to start.\n        </p>\n      )}\n      <ul aria-label='Sources' className='-mx-2 flex flex-col'>\n        <AnimatePresence initial={false}>\n          {sources.map((source) => (\n            <motion.li\n              key={source.id}\n              className='overflow-hidden'\n              {...rowPresence}\n            >\n              <SourceRow\n                source={source}\n                litColor={litColorOf(answer, source.id)}\n                isSyncPressed={pressedSyncId === source.id}\n                onSync={onSync}\n              />\n            </motion.li>\n          ))}\n        </AnimatePresence>\n      </ul>\n      <AnimatePresence initial={false}>\n        {answer && (\n          <motion.div key='answer' className='overflow-hidden' {...rowPresence}>\n            <AnswerTrace answer={answer} />\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </section>\n  );\n}\n\ntype SourceDefinition = Omit<KnowledgeSource, 'job'>;\n\ntype AddableKind = 'file' | 'site';\n\nconst MINUTE_MS = 60_000;\n\nconst LOADED_AT = Date.now();\n\nfunction minutesAgo(minutes: number) {\n  return LOADED_AT - minutes * MINUTE_MS;\n}\n\nconst SOURCES: SourceDefinition[] = [\n  {\n    id: 'contract',\n    kind: 'file',\n    name: 'Brightmoor supply contract.pdf',\n    detail: 'PDF, 2.4 MB, 38 pages',\n    chunks: 412,\n    lastSyncAt: minutesAgo(2 * 24 * 60),\n  },\n  {\n    id: 'docs',\n    kind: 'site',\n    name: 'docs.fernhill.example',\n    detail: 'Docs site, 312 pages',\n    chunks: 0,\n  },\n  {\n    id: 'drive',\n    kind: 'drive',\n    name: 'Finance drive / Vendors',\n    detail: 'Shared folder, 86 files',\n    chunks: 2340,\n    lastSyncAt: minutesAgo(64),\n  },\n  {\n    id: 'help',\n    kind: 'helpdesk',\n    name: 'Brightmoor help center',\n    detail: 'Help center, 140 articles',\n    chunks: 968,\n    lastSyncAt: minutesAgo(12),\n  },\n];\n\nconst ADDABLE: Record<AddableKind, SourceDefinition> = {\n  file: {\n    id: 'price-list',\n    kind: 'file',\n    name: 'Larkfield price list 2026.pdf',\n    detail: 'PDF, 860 KB, 9 pages',\n    chunks: 0,\n  },\n  site: {\n    id: 'larkfield-site',\n    kind: 'site',\n    name: 'help.larkfield.example',\n    detail: 'Docs site, 74 pages',\n    chunks: 0,\n  },\n};\n\nconst DRIVE_ERROR =\n  'Access to the drive expired. Retry reconnects with your account.';\n\nfunction jobStarted(id: string, sourceId: string, expected: number): RunEvent {\n  return {\n    type: 'call.started',\n    call: {\n      id,\n      tool: 'index',\n      label: `Index ${sourceId}`,\n      status: 'running',\n      input: { sourceId },\n      output: { chunks: 0, expected },\n    },\n  };\n}\n\nfunction syncStarted(id: string, sourceId: string): RunEvent {\n  return {\n    type: 'call.started',\n    call: {\n      id,\n      tool: 'index',\n      label: `Sync ${sourceId}`,\n      status: 'running',\n      input: { sourceId },\n    },\n  };\n}\n\nfunction jobProgress(id: string, chunks: number, expected: number): RunEvent {\n  return { type: 'call.updated', id, patch: { output: { chunks, expected } } };\n}\n\nfunction jobDone(id: string, chunks: number): RunEvent {\n  return {\n    type: 'call.updated',\n    id,\n    patch: { status: 'done', output: { chunks } },\n  };\n}\n\nconst DOCS_CHUNKS = 1820;\n\nconst ADDED_CHUNKS = 640;\n\n/** What a re-sync of the drive finds: sixteen chunks more than before. */\nconst DRIVE_GAIN = 16;\n\nconst QUESTION = 'Which discount applies when we order more than 500 units?';\n\ntype KnowledgeState = {\n  run: RunSnapshot;\n  added: SourceDefinition[];\n  answer: SourceAnswer | null;\n};\n\ntype KnowledgeAction =\n  | { type: 'events'; events: RunEvent[] }\n  | { type: 'sync'; sourceId: string; jobId: string }\n  | { type: 'synced'; jobId: string; chunks: number }\n  | { type: 'add'; kind: AddableKind; jobId: string }\n  | { type: 'ask' }\n  | { type: 'use'; sourceId: string }\n  | { type: 'answered' };\n\ntype KnowledgeStep = SessionStep<KnowledgeState, KnowledgeAction>;\n\nconst INITIAL: KnowledgeState = {\n  run: emptyRun({ id: 'knowledge' }),\n  added: [],\n  answer: null,\n};\n\nfunction sourceIdOf(call: ToolCall) {\n  const input = call.input ?? {};\n  const sourceId = input.sourceId;\n  if (typeof sourceId === 'string') {\n    return sourceId;\n  }\n  return '';\n}\n\nfunction latestJobOf(calls: ToolCall[], sourceId: string) {\n  const jobs = calls.filter((call) => sourceIdOf(call) === sourceId);\n  return jobs.at(-1);\n}\n\nfunction withJobs(definitions: SourceDefinition[], calls: ToolCall[]) {\n  return definitions.map((definition) => {\n    const job = latestJobOf(calls, definition.id);\n    return { ...definition, job };\n  });\n}\n\nfunction sourcesOf(state: KnowledgeState) {\n  const definitions = [...SOURCES, ...state.added];\n  return withJobs(definitions, state.run.calls);\n}\n\nfunction withSync(\n  state: KnowledgeState,\n  sourceId: string,\n  jobId: string,\n  now: number\n) {\n  const job = latestJobOf(state.run.calls, sourceId);\n  const isBusy = job?.status === 'running';\n  if (isBusy) {\n    return state;\n  }\n  const started = syncStarted(jobId, sourceId);\n  const run = reduceRun(state.run, started, now);\n  return { ...state, run };\n}\n\n/** The script and the timer a person's click starts may both land the same sync: only the first one counts. */\nfunction withSyncLanded(\n  state: KnowledgeState,\n  jobId: string,\n  chunks: number,\n  now: number\n) {\n  const job = state.run.calls.find((call) => call.id === jobId);\n  const isRunning = job?.status === 'running';\n  if (!isRunning) {\n    return state;\n  }\n  const landed = jobDone(jobId, chunks);\n  const run = reduceRun(state.run, landed, now);\n  return { ...state, run };\n}\n\nfunction withAdded(\n  state: KnowledgeState,\n  kind: AddableKind,\n  jobId: string,\n  now: number\n) {\n  const definition = ADDABLE[kind];\n  const isKnown = state.added.some((source) => source.id === definition.id);\n  if (isKnown) {\n    return state;\n  }\n  const added = [...state.added, definition];\n  const started = jobStarted(jobId, definition.id, ADDED_CHUNKS);\n  const run = reduceRun(state.run, started, now);\n  return { ...state, added, run };\n}\n\nfunction withUse(state: KnowledgeState, sourceId: string) {\n  const answer = state.answer;\n  if (!answer) {\n    return state;\n  }\n  const isUsed = answer.usedIds.includes(sourceId);\n  if (isUsed) {\n    return state;\n  }\n  const usedIds = [...answer.usedIds, sourceId];\n  return { ...state, answer: { ...answer, usedIds } };\n}\n\nfunction withAnswered(state: KnowledgeState) {\n  const answer = state.answer;\n  if (!answer) {\n    return state;\n  }\n  return { ...state, answer: { ...answer, isDone: true } };\n}\n\nfunction applyAt(\n  state: KnowledgeState,\n  action: KnowledgeAction,\n  now: number\n): KnowledgeState {\n  if (action.type === 'sync') {\n    return withSync(state, action.sourceId, action.jobId, now);\n  }\n  if (action.type === 'synced') {\n    return withSyncLanded(state, action.jobId, action.chunks, now);\n  }\n  if (action.type === 'add') {\n    return withAdded(state, action.kind, action.jobId, now);\n  }\n  if (action.type === 'ask') {\n    const answer: SourceAnswer = {\n      agent: RESEARCHER,\n      question: QUESTION,\n      usedIds: [],\n      isDone: false,\n    };\n    return { ...state, answer };\n  }\n  if (action.type === 'use') {\n    return withUse(state, action.sourceId);\n  }\n  if (action.type === 'answered') {\n    return withAnswered(state);\n  }\n  const run = applyBatchAt(state.run, action, now);\n  return { ...state, run };\n}\n\n/** Times are stamped when an action lands, as a backend would stream it. */\nfunction reducer(state: KnowledgeState, action: KnowledgeAction) {\n  return applyAt(state, action, Date.now());\n}\n\n/** The sources were synced minutes or days ago, so the resting state is stamped on the same clock. */\nfunction applyAfterLoading(\n  state: KnowledgeState,\n  action: KnowledgeAction,\n  sinceStart: number\n) {\n  return applyAt(state, action, LOADED_AT + sinceStart);\n}\n\nfunction landing(after: number, ...events: RunEvent[]): KnowledgeStep {\n  return { after, action: { type: 'events', events } };\n}\n\nconst SYNC_MS = 1500;\n\nconst INDEX_STEPS = 4;\n\nconst INDEX_STEP_MS = 650;\n\nconst DRIVE_RETRY_ID = 'drive-2';\n\nconst DRIVE_SYNCED_CHUNKS = 2340 + DRIVE_GAIN;\n\n/** The failed drive is retried by the demonstration after this long; a person who took over finds it waiting. */\nconst RETRY_AFTER_MS = 2200;\n\nconst SCRIPT: KnowledgeStep[] = [\n  landing(700, jobStarted('docs-1', 'docs', DOCS_CHUNKS)),\n  landing(700, jobProgress('docs-1', 240, DOCS_CHUNKS)),\n  landing(700, jobProgress('docs-1', 610, DOCS_CHUNKS)),\n  landing(300, syncStarted('drive-1', 'drive')),\n  landing(400, jobProgress('docs-1', 980, DOCS_CHUNKS)),\n  landing(700, jobProgress('docs-1', 1340, DOCS_CHUNKS)),\n  landing(500, {\n    type: 'call.updated',\n    id: 'drive-1',\n    patch: { status: 'failed', error: DRIVE_ERROR },\n  }),\n  landing(300, jobProgress('docs-1', 1620, DOCS_CHUNKS)),\n  landing(700, jobDone('docs-1', DOCS_CHUNKS)),\n  {\n    after: RETRY_AFTER_MS,\n    by: 'demo',\n    press: 'drive',\n    action: { type: 'sync', sourceId: 'drive', jobId: DRIVE_RETRY_ID },\n  },\n  {\n    after: SYNC_MS,\n    action: {\n      type: 'synced',\n      jobId: DRIVE_RETRY_ID,\n      chunks: DRIVE_SYNCED_CHUNKS,\n    },\n  },\n  { after: 1400, action: { type: 'ask' } },\n  { after: 1100, action: { type: 'use', sourceId: 'contract' } },\n  { after: 1000, action: { type: 'use', sourceId: 'help' } },\n  { after: 1200, action: { type: 'answered' } },\n];\n\nconst SESSION = {\n  reducer,\n  initial: INITIAL,\n  resting: restingOf(INITIAL, SCRIPT, applyAfterLoading),\n  script: SCRIPT,\n  loopAfter: 3400,\n};\n\nfunction lastChangeOf(job: ToolCall | undefined) {\n  if (job?.endedAt !== undefined) {\n    return job.endedAt;\n  }\n  if (job?.startedAt !== undefined) {\n    return job.startedAt;\n  }\n  return -Infinity;\n}\n\nfunction announcementOf(sources: KnowledgeSource[]) {\n  let latest: KnowledgeSource | undefined;\n  let latestTime = -Infinity;\n  for (const source of sources) {\n    const time = lastChangeOf(source.job);\n    if (time >= latestTime) {\n      latest = source;\n      latestTime = time;\n    }\n  }\n  if (!latest?.job) {\n    return '';\n  }\n  const state = sourceStateOf(latest);\n  const chunks = chunksOf(latest);\n  if (state === 'failed') {\n    return `${latest.name} failed to sync: ${latest.job.error ?? ''}`;\n  }\n  if (state === 'ready') {\n    return `${latest.name} is up to date, ${chunks} chunks`;\n  }\n  return `${latest.name} is syncing`;\n}\n\nfunction answerAnnouncementOf(\n  answer: SourceAnswer | null,\n  sources: KnowledgeSource[]\n) {\n  if (!answer) {\n    return '';\n  }\n  const lastId = answer.usedIds.at(-1);\n  const last = sources.find((source) => source.id === lastId);\n  if (answer.isDone) {\n    return answerLineOf(answer);\n  }\n  if (!last) {\n    return `${answer.agent.name} is answering: ${answer.question}`;\n  }\n  return `${answer.agent.name} reads ${last.name}`;\n}\n\nfunction syncedChunksOf(source: KnowledgeSource) {\n  const chunks = chunksOf(source);\n  if (source.id === 'drive') {\n    return chunks + DRIVE_GAIN;\n  }\n  return chunks;\n}\n\nfunction isAddable(kind: SourceKind): kind is AddableKind {\n  return kind === 'file' || kind === 'site';\n}\n\n/** A job started by a click is named after the moment of the click, so two syncs of one source never share an id. */\nfunction nextJobIdOf(sourceId: string) {\n  return `${sourceId}-job-${Date.now()}`;\n}\n\nexport default function Agent09() {\n  const { schedule, clearAll } = useTimeouts();\n  const { ref, isInView, isAutoplaying, state, act, pressed, restart } =\n    useSession(SESSION);\n  const sources = sourcesOf(state);\n  const announcement = announcementOf(sources);\n  const answerAnnouncement = answerAnnouncementOf(state.answer, sources);\n  const hasAddedFile = state.added.some((source) => source.kind === 'file');\n  const hasAddedSite = state.added.some((source) => source.kind === 'site');\n  const addChoices: SourceKindChoice[] = [\n    { kind: 'file', label: 'Upload files', isAvailable: !hasAddedFile },\n    { kind: 'site', label: 'Connect a site', isAvailable: !hasAddedSite },\n  ];\n\n  function sync(sourceId: string) {\n    const source = sources.find((candidate) => candidate.id === sourceId);\n    if (!source) {\n      return;\n    }\n    const jobId = nextJobIdOf(sourceId);\n    const chunks = syncedChunksOf(source);\n    act({ type: 'sync', sourceId, jobId });\n    schedule(() => act({ type: 'synced', jobId, chunks }), SYNC_MS);\n  }\n\n  function add(kind: SourceKind) {\n    if (!isAddable(kind)) {\n      return;\n    }\n    const definition = ADDABLE[kind];\n    const jobId = nextJobIdOf(definition.id);\n    act({ type: 'add', kind, jobId });\n    for (let step = 1; step <= INDEX_STEPS; step += 1) {\n      const reached = ADDED_CHUNKS * step;\n      const chunks = Math.round(reached / INDEX_STEPS);\n      const isLast = step === INDEX_STEPS;\n      schedule(() => {\n        if (isLast) {\n          act({ type: 'events', events: [jobDone(jobId, chunks)] });\n          return;\n        }\n        const progress = jobProgress(jobId, chunks, ADDED_CHUNKS);\n        act({ type: 'events', events: [progress] });\n      }, step * INDEX_STEP_MS);\n    }\n  }\n\n  function startOver() {\n    clearAll();\n    restart();\n  }\n\n  useKeptHeight(ref);\n\n  return (\n    <div\n      ref={ref}\n      className='flex min-h-[33.5rem] w-full max-w-[30rem] flex-col gap-2'\n    >\n      <ClockGate isActive={isInView}>\n        <KnowledgeSources\n          sources={sources}\n          onSync={sync}\n          addChoices={addChoices}\n          onAdd={add}\n          answer={state.answer}\n          pressedSyncId={pressed}\n          description='Researcher searches these before it answers'\n        />\n      </ClockGate>\n      <div className='flex h-7 items-center justify-end'>\n        <Button\n          type='button'\n          variant='ghost'\n          size='sm'\n          onClick={startOver}\n          className='text-muted-foreground'\n        >\n          <RotateCcwIcon />\n          Start over\n        </Button>\n      </div>\n      <LiveRegion message={spokenUnless(isAutoplaying, announcement)} />\n      <LiveRegion message={spokenUnless(isAutoplaying, answerAnnouncement)} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/agent/agent-09.tsx"
    },
    {
      "path": "registry/ndeko/components/agent/_parts/agent-tile.tsx",
      "content": "import { seriesColorOf, type SeriesSlot } from '@/registry/ndeko/lib/agent-run';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { MeshTile } from '@/registry/ndeko/ui/mesh-tile';\n\nconst SIZE_CLASS = {\n  sm: 'size-5 rounded-[5px]',\n  md: 'size-6 rounded-md',\n  lg: 'size-8 rounded-lg',\n} as const;\n\nexport type AgentTileSize = keyof typeof SIZE_CLASS;\n\ntype AgentTileProps = {\n  name: string;\n  series: SeriesSlot;\n  size?: AgentTileSize;\n  className?: string;\n};\n\n/** The picture of an agent: a gradient drawn from its name, in its series colour. Its name always sits beside it. */\nexport function AgentTile({\n  name,\n  series,\n  size = 'md',\n  className,\n}: AgentTileProps) {\n  const color = seriesColorOf(series);\n  const sizeClass = SIZE_CLASS[size];\n\n  return (\n    <MeshTile seed={name} color={color} className={cn(sizeClass, className)} />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/agent/_parts/agent-tile.tsx"
    },
    {
      "path": "registry/ndeko/components/agent/_parts/crew.ts",
      "content": "import type { RunAgent } from '@/registry/ndeko/lib/agent-run';\n\nexport const RUN_TITLE = 'Q3 vendor review for Brightmoor Supply';\n\nexport const PLANNER: RunAgent = {\n  id: 'planner',\n  name: 'Planner',\n  series: 1,\n  status: 'idle',\n  role: 'Lead',\n  model: 'Claude Opus',\n};\n\nexport const RESEARCHER: RunAgent = {\n  id: 'researcher',\n  name: 'Researcher',\n  series: 2,\n  status: 'idle',\n  model: 'Claude Sonnet',\n  parentId: 'planner',\n};\n\nexport const WRITER: RunAgent = {\n  id: 'writer',\n  name: 'Writer',\n  series: 3,\n  status: 'idle',\n  model: 'Claude Haiku',\n  parentId: 'planner',\n};\n\nexport const REVIEWER: RunAgent = {\n  id: 'reviewer',\n  name: 'Reviewer',\n  series: 4,\n  status: 'idle',\n  model: 'Claude Sonnet',\n  parentId: 'planner',\n};\n\nexport const CODER: RunAgent = {\n  id: 'coder',\n  name: 'Coder',\n  series: 5,\n  status: 'idle',\n  model: 'Claude Opus',\n  parentId: 'planner',\n};\n\nexport const CREW: RunAgent[] = [PLANNER, RESEARCHER, WRITER, REVIEWER, CODER];\n\nexport const PAGE_ERROR = 'The pricing page refused the reader (403)';\n",
      "type": "registry:component",
      "target": "components/agent/_parts/crew.ts"
    },
    {
      "path": "registry/ndeko/components/agent/_parts/relative-time.tsx",
      "content": "'use client';\n\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { useLiveNow } from '@/registry/ndeko/ui/live-duration';\n\nconst MINUTE_MS = 60_000;\n\nconst HOUR_MS = 60 * MINUTE_MS;\n\nconst DAY_MS = 24 * HOUR_MS;\n\nexport function relativeTimeOf(at: number, now: number) {\n  const age = now - at;\n  const elapsed = Math.max(0, age);\n  if (elapsed < MINUTE_MS) {\n    return 'just now';\n  }\n  if (elapsed < HOUR_MS) {\n    const minutes = Math.floor(elapsed / MINUTE_MS);\n    return `${minutes} min ago`;\n  }\n  if (elapsed < DAY_MS) {\n    const hours = Math.floor(elapsed / HOUR_MS);\n    return `${hours} h ago`;\n  }\n  const days = Math.floor(elapsed / DAY_MS);\n  if (days === 1) {\n    return 'yesterday';\n  }\n  return `${days} days ago`;\n}\n\ntype RelativeTimeProps = {\n  at: number;\n  className?: string;\n};\n\nexport function RelativeTime({ at, className }: RelativeTimeProps) {\n  const now = useLiveNow(1000, true);\n  const label = relativeTimeOf(at, now);\n\n  return <span className={cn('tabular-nums', className)}>{label}</span>;\n}\n",
      "type": "registry:component",
      "target": "components/agent/_parts/relative-time.tsx"
    }
  ],
  "categories": [
    "components",
    "agent"
  ],
  "type": "registry:component"
}