f0173440e4
Piggy was a hand-rolled OpenAI tool loop. It is now a Prime Agent session — Prime Intellect's own harness, embedded as a Node library — answering from PIG's tools and, for the first time, able to put information into the CRM rather than only read it out. The harness is a coding agent, so the first job was taking the coding agent away from it. `noTools: 'all'` plus an explicit allowlist leaves the model with PIG's ten `pig_*` tools and no bash, no filesystem, no IPython. That holds under attack: a hostile extension, a skill and a settings file planted in the agent's own directory, then `setActiveToolsByName` called with every built-in, still leaves ten tools, all ours. Both lines are load-bearing — `noTools` alone registers nothing, and the allowlist is what admits our own. Writing is gated rather than assumed. A change is proposed, not made: the tool returns a description, the transcript renders a diff card, and nothing reaches the database until someone presses Apply. Contracts, commitments, allocations and compliance always stop for a human whatever the mode. Every write runs through `executeMutation` as the calling user, so their capabilities and the audit trail apply exactly as they would to a human's. Four things about the SDK are wrong in its own documentation and cost a debugging cycle each: models.json does not resolve an env var name for `apiKey`, it sends the literal string; there is no built-in prime-inference provider in 0.84.1; a ResourceLoader you pass in is never reloaded for you; and the stock system prompt is a coding-assistant prompt that must be replaced — but replacing it also silently removes the tool list, because the harness only renders that section when it owns the prompt. AGENTS.md records all four. The expensive one was thinking level. The harness defaults to `medium`, and nemotron spent an entire 4,096-token budget reasoning and returned an empty answer. `low` was worse; `off` omits the parameter so the endpoint's default wins. An explicit `reasoning_effort: none` via `thinkingLevelMap` took a turn from 6,195 output tokens to 149. And a turn is now bounded. The harness loop is `while (true)` with no iteration cap; a runaway on a frontier model would have eaten the credit it is supposed to report on. Ceilings on model calls and tokens, enforced both through the harness hook and independently from the event stream, plus a per-user daily spend limit — and the ledger now records spend on turns that fail, which it previously discarded. Signing in lands on /piggy, which is a workspace: conversations down one side, the agent in the middle, what it did and what it cost beside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
132 lines
4.8 KiB
TypeScript
132 lines
4.8 KiB
TypeScript
/**
|
|
* The left navigation pane.
|
|
*
|
|
* One component for both treatments the sidebar primitive provides: the
|
|
* collapsible desktop rail and the phone Sheet. Deliberately not two, because
|
|
* the previous shell had the desktop list and the tab bar as separate JSX and
|
|
* they had already drifted — the tab bar's active pill and the sidebar's
|
|
* active row used different tokens.
|
|
*/
|
|
import { Fragment } from 'react';
|
|
import { X } from 'lucide-react';
|
|
import { Link, useMatch, useResolvedPath } from 'react-router-dom';
|
|
import { useIdentity } from '@/lib/identity';
|
|
import { NAV_GROUP_HEADING, NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
|
|
import { AccountSwitcher } from './AccountSwitcher';
|
|
import { Button } from './ui';
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarRail,
|
|
SidebarSeparator,
|
|
useSidebar,
|
|
} from './ui/sidebar';
|
|
|
|
export function AppSidebar() {
|
|
const identity = useIdentity();
|
|
const items = visibleNav(identity);
|
|
const { isMobile, setOpenMobile } = useSidebar();
|
|
|
|
return (
|
|
<Sidebar collapsible="icon">
|
|
<SidebarHeader>
|
|
{/* The Sheet's own close button is suppressed because it lands on top
|
|
of the account switcher. This one replaces it — Escape and the
|
|
overlay work, but a visible close is not optional on a touch
|
|
device where neither is discoverable. */}
|
|
{isMobile ? (
|
|
<div className="flex items-center justify-between pl-2">
|
|
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
|
|
Navigate
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-muted"
|
|
aria-label="Close navigation"
|
|
onClick={() => setOpenMobile(false)}
|
|
>
|
|
<X className="size-5" aria-hidden />
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
<AccountSwitcher />
|
|
</SidebarHeader>
|
|
<SidebarSeparator />
|
|
<SidebarContent>
|
|
{NAV_GROUPS.map((group) => {
|
|
const groupItems = items.filter((item) => item.group === group);
|
|
// A heading over nothing is worse than a missing section: it reads
|
|
// as a section that failed to load rather than one you cannot use.
|
|
if (!groupItems.length) return null;
|
|
const heading = NAV_GROUP_HEADING[group];
|
|
return (
|
|
<Fragment key={group}>
|
|
<SidebarGroup>
|
|
{heading ? <SidebarGroupLabel>{heading}</SidebarGroupLabel> : null}
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{groupItems.map((item) => (
|
|
<NavItemRow key={item.to} item={item} />
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
{/*
|
|
An unlabelled group has no heading to separate it from the next
|
|
one, so it gets a rule instead. This is also the only separation
|
|
that survives collapse: at icon width every heading is pulled up
|
|
and faded out, so without the rule the front door would be just
|
|
one more glyph in an undifferentiated stack of them.
|
|
*/}
|
|
{heading === null ? <SidebarSeparator /> : null}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</SidebarContent>
|
|
{/*
|
|
No footer. The old shell ended with "Prime Intellect Growth / Compute
|
|
revenue system", which the account switcher at the top now says
|
|
better — and at 900px the fourteen nav rows do not all fit, so a
|
|
restatement of the workspace name was costing two of them.
|
|
*/}
|
|
<SidebarRail />
|
|
</Sidebar>
|
|
);
|
|
}
|
|
|
|
function NavItemRow({ item }: { item: NavItem }) {
|
|
const { setOpenMobile, isMobile } = useSidebar();
|
|
// `asChild` renders the row *as* the link rather than wrapping one, so there
|
|
// is a single focusable element per row. Active state is asked of the router
|
|
// instead of compared against a pathname, so `/demand/abc` still lights
|
|
// Demand and `/` does not light everything.
|
|
const resolved = useResolvedPath(item.to);
|
|
const isActive = useMatch({ path: resolved.pathname, end: item.to === '/' }) !== null;
|
|
|
|
return (
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton asChild isActive={isActive} tooltip={item.label}>
|
|
<Link
|
|
to={item.to}
|
|
aria-current={isActive ? 'page' : undefined}
|
|
onClick={() => {
|
|
if (isMobile) setOpenMobile(false);
|
|
}}
|
|
>
|
|
<item.icon aria-hidden />
|
|
<span>{item.label}</span>
|
|
</Link>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
}
|