Make Piggy part of the product rather than a guest in it
CI / verify (push) Successful in 7m10s
CI / publish (push) Has been skipped

Piggy arrived as a chat panel bolted onto a CRM and then grew a workspace
around it. The layout was already right — the audit found the approval card
to be the best-designed object in the repo, and the account page's empty
panels less finished than anything in the workspace. What was wrong was
vocabulary: nobody had written the small things down, so both halves kept
inventing them.

Piggy was drawn with five different marks — a pig in the dock, a sparkle in
the sidebar and again on the model picker, a speech bubble on the Ask
buttons, and a stock robot glyph on every assistant message, which is the
one people look at most. There is now one mark. The composer, which is the
first control in the product since sign-in lands on /piggy, was the only
un-adapted shadcn field left: 6px radius against a 12px Send button it sat
8px from. A stat tile had been reinvented six times at three numeral scales,
and the same uppercase micro-label existed in five variants, two of them one
tab apart in the same rail. There were 63 hand-written font sizes: not a
scale, sixty-three opinions.

Underneath that, the focus ring was invisible. The global rule used
ring-accent, which Tailwind deliberately aliases onto the hover tint, so the
ring measured 1.01:1 against the light canvas — no visible focus indicator
anywhere in the product, for any accent, in either theme. It is ring-brand
now and measures 17:1. The warning, positive and info tones were darkened
until each clears 4.5:1 on a card, on inset and on its own chip, and the
light canvas moved to 98% so a card lifts without leaning on its shadow.

The mobile work is the part worth reading. A landscape phone gave the
transcript 28% of the viewport and a keyboard-up phone 16%, against a 45%
floor — and the fixed tab bar painted over the composer, covering the safety
sentence and half the Send button, because two source comments asserted the
bar stood down on short viewports and it never had. Both fixed and measured
by hit-testing rather than by screenshot. The composer itself was 64px tall
for a blank second line nobody typed, because the auto-resize effect sizes
to scrollHeight and scrollHeight counts rows — a CSS height could not win
against an inline style, so the attribute was the honest lever.

Verified across both themes driven through the app's own control: no
horizontal overflow on 15 routes at four viewports, 672 stat values that fit,
297 labels at exactly 11px/500, Escape returning focus to its opener rather
than the body on every overlay, and a rejected write no longer reporting
"Succeeded" with a green check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude
2026-08-14 18:22:15 -07:00
parent f0173440e4
commit 18d5f5bfc0
89 changed files with 8523 additions and 2447 deletions
+11 -5
View File
@@ -49,6 +49,7 @@ import {
createConfiguredAuthProvider,
type AuthProvider,
} from './lib/auth-provider';
import { activityPayloadColumns, toActivityPayload } from './lib/activity-payload';
import { apiError } from './lib/mutation';
import { createMediaRoutes } from './lib/media';
import { CapacityService } from './services/capacity';
@@ -388,7 +389,7 @@ export function createApp(
db.select().from(supplyDeals).where(eq(supplyDeals.accountId, id)),
db.select().from(contracts).where(eq(contracts.accountId, id)),
db
.select()
.select(activityPayloadColumns)
.from(activities)
.where(eq(activities.accountId, id))
.orderBy(desc(activities.occurredAt))
@@ -418,7 +419,7 @@ export function createApp(
demandDeals: demand,
supplyDeals: supply,
contracts: paperwork,
activities: recentActivity,
activities: recentActivity.map(toActivityPayload),
dealContacts: buyingGroup,
});
});
@@ -573,7 +574,7 @@ export function createApp(
*/
.where(inArray(supplyDeals.stage, [...SUPPLY_OPEN_STAGES])),
db
.select({ activity: activities, accountName: accounts.name })
.select({ ...activityPayloadColumns, accountName: accounts.name })
.from(activities)
.leftJoin(accounts, eq(accounts.id, activities.accountId))
.orderBy(desc(activities.occurredAt))
@@ -592,8 +593,13 @@ export function createApp(
compliance,
// The subject alone reads as an anonymous feed — "Chased the firm quote"
// says nothing until you know whose. The name comes from the join rather
// than a second request per row.
recentActivity: recent.map(({ activity, accountName }) => ({ ...activity, accountName })),
// than a second request per row, and the row carries the same attribution
// the account timeline reads, so the same entry cannot be Piggy's on one
// surface and anonymous on the other.
recentActivity: recent.map(({ accountName, ...activity }) => ({
...toActivityPayload(activity),
accountName,
})),
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* What an activity looks like on the wire, and who is recorded as having done it.
*
* Two endpoints render a timeline — the account record and the dashboard feed —
* and both selected the whole row, so the payload was whatever the table
* happened to hold that week. That is how `meta` came to ship every stage
* transition blob and Slack permalink to every browser, and it is why nothing
* on the client could safely depend on `external_id` being there at all: it
* arrived by accident, and the next hand-written column list would have removed
* it without anyone noticing. The projection is declared here instead, once,
* and shaped around the two questions a reader of a timeline actually asks:
* what happened, and who did it.
*
* **Who did it is the part that was broken.** Piggy signs a row in one of two
* places, because it makes two shapes of write:
*
* - `pig_log_activity`'s row IS its own audit event, so there is no separate
* audit row to stamp. It carries an `external_id` of `piggy:<uuid>`, which
* doubles as the idempotency key that stops a retried tool call logging the
* same conversation twice (`write-tools.ts`).
* - Every other write tool leaves `meta.actorAgent = 'piggy'` on the audit row
* the mutation convention inserts beside the change (`attributedToPiggy`).
*
* `activities.actor_agent` itself stays exactly what it has always been — set
* only when the request authenticated as an agent — because a browser session
* authorised by a person really was authorised by that person, and stamping it
* otherwise would be a lie told inside the audit trail. So the derivation lives
* on the read, not on the row: the wire says "an agent produced this entry, and
* it was Piggy", which is precisely what the column means, while the stored row
* goes on recording how the request was authorised. Nothing here writes.
*/
import type { ActivityType } from '@pig/core';
import { activities } from '@pig/db';
/**
* The mark on an `external_id` that Piggy logged the row.
*
* Exported because the client draws the distinction too: a row Piggy logged
* gets attribution in the timeline, and a row synced from Slack or Buzz — which
* also carries an external id — does not.
*/
export const PIGGY_EXTERNAL_ID_PREFIX = 'piggy:';
/** What `actorAgent` reads when the row is Piggy's. One spelling, one source. */
export const PIGGY_AGENT_NAME = 'piggy';
/**
* The columns a timeline needs.
*
* `meta`, `source`, `created_at` and `actor_user_id` are deliberately absent.
* `meta` is an internal payload with no reader in the browser, and its one
* client-relevant fact is folded into `actorAgent` below; the other three say
* nothing a timeline shows. Ship them and they become a contract by default.
*/
export const activityPayloadColumns = {
id: activities.id,
type: activities.type,
subject: activities.subject,
body: activities.body,
accountId: activities.accountId,
contactId: activities.contactId,
demandDealId: activities.demandDealId,
supplyDealId: activities.supplyDealId,
actorAgent: activities.actorAgent,
externalId: activities.externalId,
meta: activities.meta,
occurredAt: activities.occurredAt,
};
/** The shape `activityPayloadColumns` selects, before serialisation. */
export interface ActivityRow {
id: string;
type: ActivityType;
subject: string | null;
body: string | null;
accountId: string | null;
contactId: string | null;
demandDealId: string | null;
supplyDealId: string | null;
actorAgent: string | null;
externalId: string | null;
meta: Record<string, unknown> | null;
occurredAt: Date;
}
export interface ActivityPayload {
id: string;
type: ActivityType;
subject: string | null;
body: string | null;
accountId: string | null;
contactId: string | null;
demandDealId: string | null;
supplyDealId: string | null;
/**
* Which agent produced the entry, or null when a person typed it. `'piggy'`
* covers both of the ways Piggy signs a write; see the note at the top.
*/
actorAgent: string | null;
/**
* External identity, for idempotent sync. A `piggy:` prefix — see
* `PIGGY_EXTERNAL_ID_PREFIX` — means Piggy logged this activity itself, as
* opposed to Piggy having changed a record and this being the audit of it.
*/
externalId: string | null;
occurredAt: string;
}
/**
* Whether Piggy produced this entry, asked of both places it can have said so.
*
* The `meta` arm is read defensively rather than cast: `meta` is free-form JSON
* written by every mutation in the product, and a row where it holds a string
* or an array must answer "no", not throw on the account page.
*/
function producedByPiggy(row: Pick<ActivityRow, 'externalId' | 'meta'>): boolean {
if (row.externalId?.startsWith(PIGGY_EXTERNAL_ID_PREFIX)) return true;
const stamped = row.meta && typeof row.meta === 'object' ? row.meta.actorAgent : null;
return stamped === PIGGY_AGENT_NAME;
}
export function toActivityPayload(row: ActivityRow): ActivityPayload {
return {
id: row.id,
type: row.type,
subject: row.subject,
body: row.body,
accountId: row.accountId,
contactId: row.contactId,
demandDealId: row.demandDealId,
supplyDealId: row.supplyDealId,
actorAgent: row.actorAgent ?? (producedByPiggy(row) ? PIGGY_AGENT_NAME : null),
externalId: row.externalId,
occurredAt: row.occurredAt.toISOString(),
};
}
+4 -1
View File
@@ -539,7 +539,10 @@ export function createPiggyChatRoutes(options: PiggyChatProxyOptions) {
{
...apiError(
'piggy_rate_limited',
'You have reached the hourly limit for Piggy. Try again shortly.',
// No "try again shortly": the client turns `retryAfterSeconds` into
// a wall-clock time and says exactly when Retry comes back, so a
// vaguer version of the same promise here would only contradict it.
"You have used this hour's Piggy questions.",
),
retryAfterSeconds: decision.retryAfterSeconds,
},
+34 -1
View File
@@ -29,7 +29,7 @@
* link is resolved only against conversations the caller owns.
*/
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
import type { AgentTaskKind, AgentTaskOutcome } from '@pig/core';
import { PIGGY_MODES, type AgentTaskKind, type AgentTaskOutcome, type PiggyMode } from '@pig/core';
import type { Database } from '@pig/db';
import { agentRuns, agentTasks, piggyConversations, users } from '@pig/db';
import type { Principal } from '../lib/auth';
@@ -63,6 +63,21 @@ export interface PiggyRunSummary {
*/
status: string;
model: string | null;
/**
* What this turn was allowed to do — the whole safety argument, per row.
*
* PIG's claim is that nothing lands until a person presses Apply, and that
* claim is only auditable if the ledger records which turns were even offered
* write tools. Without it a run that quietly applied five changes under `auto`
* is indistinguishable from one that could not have changed a thing.
*
* Null means the mode was not recorded, which is two real cases and not a
* failure: a queued task run, which has no mode because nobody chose one, and
* a chat turn from before the relay started stamping it. Reported as null
* rather than defaulted to `read_only`, because guessing the safe answer on an
* audit surface is the one direction a wrong guess must never go.
*/
mode: PiggyMode | null;
/** The question, for a chat turn; the queued work, for a task run. */
label: string;
/** The first line of what Piggy answered. Null on a turn that said nothing. */
@@ -158,6 +173,23 @@ function readString(bag: Record<string, unknown> | null, key: string): string |
return typeof value === 'string' && value.trim() ? value : null;
}
/**
* The mode a chat turn ran in, from the run's `input` blob.
*
* `agent_runs` has no `mode` column; the chat relay writes it into `input`
* alongside the message and the conversation id (`chat-server.ts`,
* `startChatRun`). That is a free-text bag, so the value is checked against the
* ontology rather than cast — a run whose blob says `"mode": "yolo"` must report
* no mode at all, not put an invented one in the ledger.
*
* Exported for the test, which is the only way to exercise a blob the relay
* would never write without standing up a database to hold it.
*/
export function runMode(input: Record<string, unknown> | null): PiggyMode | null {
const claimed = readString(input, 'mode');
return PIGGY_MODES.find((mode) => mode === claimed) ?? null;
}
/**
* The conversation a run answered.
*
@@ -251,6 +283,7 @@ export class PiggyActivityService {
agent: row.agent,
status: row.status,
model: row.model,
mode: runMode(row.input),
// A run with neither a question nor a task kind is a row written before
// the turn got anywhere; naming it after its status beats an empty cell.
label:
+103
View File
@@ -0,0 +1,103 @@
/**
* That a write Piggy made can be told from one a person typed.
*
* The product's safety argument is that nothing lands until a human presses
* Apply. That argument is only checkable after the fact if the record surfaces
* can say which rows came from the agent — and until this landed they could
* not: an approved write read as hand-typed in the account timeline while the
* seeded row beneath it said "· by piggy".
*
* Piggy signs a row in two places and the payload has to answer for both, so
* both are asserted here, along with the two rows that must NOT be claimed:
* a person's own entry, and a Slack sync that also carries an external id.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PIGGY_EXTERNAL_ID_PREFIX,
toActivityPayload,
type ActivityRow,
} from '../src/lib/activity-payload';
import { runMode } from '../src/services/piggy-activity';
function activity(overrides: Partial<ActivityRow> = {}): ActivityRow {
return {
id: '60000000-0000-4000-8000-000000000001',
type: 'call',
subject: 'Call with DEMO — Northwind Robotics about extending the H200 block',
body: null,
accountId: '10000000-0000-4000-8000-00000000000a',
contactId: null,
demandDealId: null,
supplyDealId: null,
actorAgent: null,
externalId: null,
meta: null,
occurredAt: new Date('2026-08-13T09:00:00.000Z'),
...overrides,
};
}
test('an activity Piggy logged is attributed to Piggy', () => {
// `pig_log_activity`'s row is its own audit event, so the provenance rides on
// the external id — which is also what stops a retried tool call logging the
// same conversation twice.
const payload = toActivityPayload(
activity({ externalId: `${PIGGY_EXTERNAL_ID_PREFIX}d016db18-a6eb-4857-9cb5-cff3d58c78d0` }),
);
assert.equal(payload.actorAgent, 'piggy');
// Still on the wire, because a record surface may want to draw the row Piggy
// logged differently from the audit of a record Piggy changed.
assert.ok(payload.externalId?.startsWith(PIGGY_EXTERNAL_ID_PREFIX));
});
test('the audit of a record Piggy changed is attributed to Piggy', () => {
// Every write tool other than `pig_log_activity` stamps the audit row's meta
// instead, because the mutation convention writes that row, not the tool.
const payload = toActivityPayload(activity({ meta: { actorAgent: 'piggy', piggyTool: 'pig_update_deal_stage' } }));
assert.equal(payload.actorAgent, 'piggy');
});
test('a persons own entry claims no agent', () => {
assert.equal(toActivityPayload(activity()).actorAgent, null);
});
test('a synced entry is not mistaken for Piggys', () => {
// Slack and Buzz carry external ids too. Attributing their rows to the agent
// would put words in Piggy's mouth on the surface people audit it from.
const payload = toActivityPayload(activity({ externalId: 'slack:C09QT/1755082800.123' }));
assert.equal(payload.actorAgent, null);
});
test('a stored agent stamp still wins', () => {
// An API key really did authenticate as an agent; the derivation must not
// overwrite what the column already recorded.
assert.equal(toActivityPayload(activity({ actorAgent: 'agent' })).actorAgent, 'agent');
});
test('meta that is not an object cannot break the timeline', () => {
// `meta` is free-form JSON written by every mutation in the product.
const hostile = { meta: ['piggy'] as unknown as Record<string, unknown> };
assert.equal(toActivityPayload(activity(hostile)).actorAgent, null);
});
test('the payload carries no internal blob', () => {
const payload = toActivityPayload(activity({ meta: { slackPermalink: 'https://…' } }));
assert.equal('meta' in payload, false);
assert.equal(payload.occurredAt, '2026-08-13T09:00:00.000Z');
});
test('a run reports the mode it was allowed to run in', () => {
assert.equal(runMode({ surface: 'chat', mode: 'auto' }), 'auto');
assert.equal(runMode({ surface: 'chat', mode: 'read_only' }), 'read_only');
});
test('a run that recorded no mode reports none, rather than the safe one', () => {
// A queued task has no mode, and neither do the chat turns written before the
// relay stamped it. Defaulting those to `read_only` would put a claim in the
// ledger that nobody made.
assert.equal(runMode(null), null);
assert.equal(runMode({ surface: 'chat' }), null);
assert.equal(runMode({ mode: 'yolo' }), null);
assert.equal(runMode({ mode: 42 }), null);
});
+4
View File
@@ -33,6 +33,7 @@ function run(overrides: Partial<PiggyRunSummary> = {}): PiggyRunSummary {
agent: 'piggy',
status: 'succeeded',
model: 'nvidia/nemotron-3-nano-30b-a3b',
mode: 'confirm',
label: 'Are we under water on the Northwind renewal?',
summary: 'Yes — the block is 38 per cent idle at the current rate.',
error: null,
@@ -72,6 +73,9 @@ test('an administrator reads a colleagues spend and not their question', () =
// Everything an audit is actually for, which is the half that belongs to PIG.
assert.equal(redacted.status, 'succeeded');
assert.equal(redacted.model, 'nvidia/nemotron-3-nano-30b-a3b');
// What a turn was allowed to do is the company's record, not the person's
// words: an audit that cannot say which turns could write is not an audit.
assert.equal(redacted.mode, 'confirm');
assert.equal(redacted.costMicroCents, 4_200);
assert.equal(redacted.inputTokens, 2_100);
assert.equal(redacted.durationMs, 4_000);