diff --git a/packages/plugins/plugin-briefs/migrations/001_briefs_cards.sql b/packages/plugins/plugin-briefs/migrations/001_briefs_cards.sql new file mode 100644 index 00000000000..e0adf6016e2 --- /dev/null +++ b/packages/plugins/plugin-briefs/migrations/001_briefs_cards.sql @@ -0,0 +1,114 @@ +CREATE TABLE plugin_briefs_37182b0291.briefs_cards ( + id uuid PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id text NOT NULL, + slug text NOT NULL, + title text NOT NULL, + grouping_description text NOT NULL, + root_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL, + state text NOT NULL CHECK (state IN ('error', 'blocked', 'waiting-user', 'waiting-reviewer', 'live', 'done', 'stale')), + summary_status text NOT NULL CHECK (summary_status IN ('ok', 'pending', 'fallback')), + pinned boolean NOT NULL DEFAULT false, + hidden boolean NOT NULL DEFAULT false, + stale_at timestamptz NOT NULL, + expires_at timestamptz, + latest_snapshot_id uuid, + last_meaningful_event_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (company_id, user_id, slug) +); + +CREATE TABLE plugin_briefs_37182b0291.briefs_card_sources ( + id uuid PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id text NOT NULL, + card_id uuid NOT NULL REFERENCES plugin_briefs_37182b0291.briefs_cards(id) ON DELETE CASCADE, + source_kind text NOT NULL CHECK (source_kind IN ('issue_tree', 'issue', 'comment', 'run', 'document', 'work_product', 'interaction', 'activity_event', 'approval')), + source_id text NOT NULL, + issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL, + identifier text, + title_line text NOT NULL, + right_tag text NOT NULL, + link_path text NOT NULL, + is_intra_tree_blocked boolean, + event_at timestamptz NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (card_id, source_kind, source_id) +); + +CREATE TABLE plugin_briefs_37182b0291.briefs_card_snapshots ( + id uuid PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id text NOT NULL, + card_id uuid NOT NULL REFERENCES plugin_briefs_37182b0291.briefs_cards(id) ON DELETE CASCADE, + summary_paragraph text, + summary_status text NOT NULL CHECK (summary_status IN ('ok', 'pending', 'fallback')), + summary_model text, + summary_tokens_in integer, + summary_tokens_out integer, + summary_failure_reason text CHECK (summary_failure_reason IS NULL OR summary_failure_reason IN ('model_error', 'truncation_failed', 'budget_capped', 'safety_block')), + task_rows jsonb NOT NULL DEFAULT '[]'::jsonb, + evidence_source_ids jsonb NOT NULL DEFAULT '[]'::jsonb, + generated_by_agent_id uuid, + generated_by_run_id uuid, + deterministic_state_inputs jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE plugin_briefs_37182b0291.briefs_cards + ADD CONSTRAINT briefs_cards_latest_snapshot_fk + FOREIGN KEY (latest_snapshot_id) + REFERENCES plugin_briefs_37182b0291.briefs_card_snapshots(id) + ON DELETE SET NULL; + +CREATE TABLE plugin_briefs_37182b0291.briefs_user_preferences ( + id uuid PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id text NOT NULL, + cadence text NOT NULL DEFAULT 'hourly' CHECK (cadence IN ('manual', 'hourly', 'daily')), + retention_days integer NOT NULL DEFAULT 7 CHECK (retention_days > 0), + done_retention_hours integer NOT NULL DEFAULT 72 CHECK (done_retention_hours > 0), + stale_after_days integer NOT NULL DEFAULT 7 CHECK (stale_after_days > 0), + max_unpinned_cards integer NOT NULL DEFAULT 30 CHECK (max_unpinned_cards > 0), + scope text NOT NULL DEFAULT 'user' CHECK (scope IN ('user')), + settings jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (company_id, user_id) +); + +CREATE TABLE plugin_briefs_37182b0291.briefs_cursors ( + id uuid PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id text NOT NULL, + cursor_key text NOT NULL, + cursor_kind text NOT NULL, + last_seen_at timestamptz, + overlap_window_start_at timestamptz, + dedupe_state jsonb NOT NULL DEFAULT '[]'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (company_id, user_id, cursor_key) +); + +CREATE INDEX briefs_cards_company_user_state_idx + ON plugin_briefs_37182b0291.briefs_cards (company_id, user_id, hidden, state, last_meaningful_event_at DESC); + +CREATE INDEX briefs_cards_root_issue_idx + ON plugin_briefs_37182b0291.briefs_cards (company_id, root_issue_id); + +CREATE INDEX briefs_card_sources_card_event_idx + ON plugin_briefs_37182b0291.briefs_card_sources (card_id, event_at DESC); + +CREATE INDEX briefs_card_sources_company_source_idx + ON plugin_briefs_37182b0291.briefs_card_sources (company_id, source_kind, source_id); + +CREATE INDEX briefs_card_snapshots_card_created_idx + ON plugin_briefs_37182b0291.briefs_card_snapshots (card_id, created_at DESC); + +CREATE INDEX briefs_cursors_company_user_kind_idx + ON plugin_briefs_37182b0291.briefs_cursors (company_id, user_id, cursor_kind); diff --git a/packages/plugins/plugin-briefs/package.json b/packages/plugins/plugin-briefs/package.json new file mode 100644 index 00000000000..02bcfc6a6b0 --- /dev/null +++ b/packages/plugins/plugin-briefs/package.json @@ -0,0 +1,62 @@ +{ + "name": "@paperclipai/plugin-briefs", + "version": "0.1.0", + "description": "First-party Briefs plugin data model and deterministic briefing card service", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/plugins/plugin-briefs" + }, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist", + "migrations" + ], + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js" + }, + "keywords": [ + "paperclip", + "plugin", + "briefs", + "briefing" + ], + "scripts": { + "postinstall": "node ../../../scripts/link-plugin-dev-sdk.mjs", + "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit", + "test": "vitest run", + "prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../scripts/generate-plugin-package-json.mjs", + "postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi" + }, + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/packages/plugins/plugin-briefs/src/contracts.ts b/packages/plugins/plugin-briefs/src/contracts.ts new file mode 100644 index 00000000000..b8401194b4c --- /dev/null +++ b/packages/plugins/plugin-briefs/src/contracts.ts @@ -0,0 +1,158 @@ +import { z } from "@paperclipai/plugin-sdk"; + +export const briefCardStateSchema = z.enum([ + "error", + "blocked", + "waiting-user", + "waiting-reviewer", + "live", + "done", + "stale", +]); + +export const briefSummaryStatusSchema = z.enum(["ok", "pending", "fallback"]); + +export const briefSummaryFailureReasonSchema = z.enum([ + "model_error", + "truncation_failed", + "budget_capped", + "safety_block", +]); + +export const briefSourceKindSchema = z.enum([ + "issue_tree", + "issue", + "comment", + "run", + "document", + "work_product", + "interaction", + "activity_event", + "approval", +]); + +export const briefTaskRowSchema = z.object({ + kind: briefSourceKindSchema.exclude(["issue_tree", "activity_event", "work_product"]), + sourceId: z.string().min(1), + issueId: z.string().min(1).nullable(), + identifier: z.string().min(1).nullable(), + titleLine: z.string().min(1).max(120), + rightTag: z.string().min(1).max(40), + linkPath: z.string().min(1), + isIntraTreeBlocked: z.boolean().nullable(), + eventAt: z.string().min(1), +}).strict(); + +export const briefCardSourceSchema = z.object({ + id: z.string().min(1), + companyId: z.string().min(1), + userId: z.string().min(1), + cardId: z.string().min(1), + sourceKind: briefSourceKindSchema, + sourceId: z.string().min(1), + issueId: z.string().min(1).nullable(), + identifier: z.string().min(1).nullable(), + titleLine: z.string().min(1).max(160), + rightTag: z.string().min(1).max(60), + linkPath: z.string().min(1), + isIntraTreeBlocked: z.boolean().nullable(), + eventAt: z.string().min(1), + metadata: z.record(z.string(), z.unknown()).default({}), +}).strict(); + +export const briefSnapshotSchema = z.object({ + id: z.string().min(1), + companyId: z.string().min(1), + userId: z.string().min(1), + cardId: z.string().min(1), + summaryParagraph: z.string().max(260).nullable(), + summaryStatus: briefSummaryStatusSchema, + summaryModel: z.string().min(1).nullable(), + summaryTokensIn: z.number().int().nonnegative().nullable(), + summaryTokensOut: z.number().int().nonnegative().nullable(), + summaryFailureReason: briefSummaryFailureReasonSchema.nullable(), + taskRows: z.array(briefTaskRowSchema).max(3), + evidenceSourceIds: z.array(z.string().min(1)), + generatedByAgentId: z.string().min(1).nullable(), + generatedByRunId: z.string().min(1).nullable(), + deterministicStateInputs: z.record(z.string(), z.unknown()).default({}), + createdAt: z.string().min(1), +}).strict(); + +export const briefCardSchema = z.object({ + id: z.string().min(1), + companyId: z.string().min(1), + userId: z.string().min(1), + slug: z.string().min(1).max(120), + title: z.string().min(1).max(90), + groupingDescription: z.string().min(1).max(500), + rootIssueId: z.string().min(1).nullable(), + state: briefCardStateSchema, + summaryStatus: briefSummaryStatusSchema, + pinned: z.boolean(), + hidden: z.boolean(), + staleAt: z.string().min(1), + expiresAt: z.string().min(1).nullable(), + latestSnapshotId: z.string().min(1).nullable(), + lastMeaningfulEventAt: z.string().min(1), + snapshot: briefSnapshotSchema, + sources: z.array(briefCardSourceSchema), + moreSourceCount: z.number().int().nonnegative(), +}).strict(); + +export const briefPreferencesSchema = z.object({ + companyId: z.string().min(1), + userId: z.string().min(1), + cadence: z.enum(["manual", "hourly", "daily"]).default("hourly"), + retentionDays: z.number().int().positive().default(7), + doneRetentionHours: z.number().int().positive().default(72), + staleAfterDays: z.number().int().positive().default(7), + maxUnpinnedCards: z.number().int().positive().default(30), + scope: z.literal("user").default("user"), +}).strict(); + +export const listBriefCardsInputSchema = z.object({ + companyId: z.string().min(1), + userId: z.string().min(1), + includeHidden: z.boolean().optional().default(false), + limit: z.number().int().positive().max(100).optional().default(50), +}).strict(); + +export const pinBriefCardInputSchema = z.object({ + companyId: z.string().min(1), + userId: z.string().min(1), + cardId: z.string().min(1), + pinned: z.boolean(), +}).strict(); + +export const updateBriefPreferencesInputSchema = briefPreferencesSchema.partial({ + cadence: true, + retentionDays: true, + doneRetentionHours: true, + staleAfterDays: true, + maxUnpinnedCards: true, + scope: true, +}).required({ + companyId: true, + userId: true, +}); + +export const briefCursorEventSchema = z.object({ + id: z.string().min(1), + eventAt: z.string().min(1), + fingerprint: z.string().min(1).optional(), +}).strict(); + +export type BriefCardState = z.infer; +export type BriefSummaryStatus = z.infer; +export type BriefSummaryFailureReason = z.infer; +export type BriefSourceKind = z.infer; +export type BriefTaskRow = z.infer; +export type BriefCardSource = z.infer; +export type BriefSnapshot = z.infer; +export type BriefCard = z.infer; +export type BriefPreferences = z.infer; +export type ListBriefCardsInput = z.infer; +export type PinBriefCardInput = z.infer; +export type UpdateBriefPreferencesInput = z.infer; +export type BriefCursorEvent = z.infer; diff --git a/packages/plugins/plugin-briefs/src/deterministic-card-service.ts b/packages/plugins/plugin-briefs/src/deterministic-card-service.ts new file mode 100644 index 00000000000..17c7dde6a00 --- /dev/null +++ b/packages/plugins/plugin-briefs/src/deterministic-card-service.ts @@ -0,0 +1,766 @@ +import { randomUUID } from "node:crypto"; +import type { + BriefCard, + BriefCardSource, + BriefCardState, + BriefCursorEvent, + BriefPreferences, + BriefSnapshot, + BriefSummaryFailureReason, + BriefSummaryStatus, + BriefTaskRow, +} from "./contracts.js"; + +export type BriefIssueStatus = + | "backlog" + | "todo" + | "in_progress" + | "in_review" + | "done" + | "blocked" + | "cancelled"; + +export type BriefsIssueInput = { + id: string; + companyId: string; + parentId: string | null; + title: string; + identifier: string | null; + status: BriefIssueStatus | string; + priority?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + createdByUserId?: string | null; + updatedByUserId?: string | null; + activeRecoveryAction?: unknown | null; + blockerAttention?: { reason?: string | null } | null; + executionState?: { + currentParticipant?: { type?: string; userId?: string | null; agentId?: string | null } | null; + returnAssignee?: { type?: string; userId?: string | null; agentId?: string | null } | null; + } | null; + createdAt?: Date | string | null; + updatedAt?: Date | string | null; + completedAt?: Date | string | null; +}; + +export type BriefsRelationInput = { + blockedBy?: Array<{ + id: string; + companyId?: string | null; + identifier?: string | null; + title?: string | null; + status?: BriefIssueStatus | string | null; + }>; +}; + +export type BriefsRunInput = { + id: string; + companyId: string; + issueId: string | null; + status: string; + error?: string | null; + startedAt?: Date | string | null; + finishedAt?: Date | string | null; + createdAt?: Date | string | null; +}; + +export type BriefsCommentInput = { + id: string; + companyId: string; + issueId: string; + authorUserId?: string | null; + body?: string | null; + createdAt?: Date | string | null; +}; + +export type BriefsDocumentInput = { + id: string; + companyId: string; + issueId: string; + key: string; + title?: string | null; + createdByUserId?: string | null; + updatedByUserId?: string | null; + updatedAt?: Date | string | null; +}; + +export type BriefsInteractionInput = { + id: string; + companyId: string; + issueId: string; + kind: "request_confirmation" | "ask_user_questions" | "suggest_tasks" | string; + status: "pending" | "answered" | "accepted" | "rejected" | "cancelled" | string; + targetUserId?: string | null; + createdAt?: Date | string | null; + updatedAt?: Date | string | null; +}; + +export type BriefsApprovalInput = { + id: string; + companyId: string; + issueId: string; + status: "pending_approval" | "approved" | "rejected" | string; + reviewerUserId?: string | null; + decidedByUserId?: string | null; + createdAt?: Date | string | null; + decidedAt?: Date | string | null; +}; + +export type BriefsWorkProductInput = { + id: string; + companyId: string; + issueId: string; + title: string; + status?: string | null; + createdAt?: Date | string | null; + updatedAt?: Date | string | null; +}; + +export type BriefsActivityEventInput = { + id: string; + companyId: string; + issueId?: string | null; + action: string; + createdAt?: Date | string | null; +}; + +export type BriefsSourceBundle = { + companyId: string; + userId: string; + rootIssueId: string; + issues: BriefsIssueInput[]; + relations?: Record; + activeRuns?: Record; + runs?: BriefsRunInput[]; + comments?: BriefsCommentInput[]; + documents?: BriefsDocumentInput[]; + interactions?: BriefsInteractionInput[]; + approvals?: BriefsApprovalInput[]; + workProducts?: BriefsWorkProductInput[]; + activityEvents?: BriefsActivityEventInput[]; + relevantAgentIds?: string[]; + groupingDescription?: string | null; + title?: string | null; +}; + +export type DeterministicBriefOptions = { + now?: Date | string; + pinned?: boolean; + hidden?: boolean; + summaryStatus?: BriefSummaryStatus; + summaryParagraph?: string | null; + summaryFailureReason?: BriefSummaryFailureReason | null; + summaryModel?: string | null; + summaryTokensIn?: number | null; + summaryTokensOut?: number | null; + generatedByAgentId?: string | null; + generatedByRunId?: string | null; + preferences?: Partial; + idFactory?: () => string; +}; + +export type BriefCursorDedupeResult = { + freshEvents: BriefCursorEvent[]; + dedupeState: string[]; + lastSeenAt: string | null; +}; + +const DEFAULT_RETENTION_DAYS = 7; +const DEFAULT_DONE_RETENTION_HOURS = 72; +const DEFAULT_STALE_AFTER_DAYS = 7; +const DEFAULT_DISCOVERY_WINDOW_DAYS = 14; +const LIVE_WINDOW_HOURS = 6; +const FAILED_RUN_WINDOW_HOURS = 24; +const MAX_DEDUPE_STATE = 500; + +const BLOCKER_ATTENTION_REASONS = new Set([ + "owner_paused", + "recovery_required", + "external_wait", + "needs_attention", + "stalled", +]); + +function asTime(value: Date | string | null | undefined): number | null { + if (!value) return null; + const time = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(time) ? time : null; +} + +function toIso(value: number | Date | string): string { + const date = typeof value === "number" ? new Date(value) : value instanceof Date ? value : new Date(value); + return date.toISOString(); +} + +function addMs(time: number, amount: number): number { + return time + amount; +} + +function days(value: number): number { + return value * 24 * 60 * 60 * 1000; +} + +function hours(value: number): number { + return value * 60 * 60 * 1000; +} + +function truncate(value: string, max: number): string { + const compact = value.replace(/\s+/g, " ").trim(); + if (compact.length <= max) return compact; + return `${compact.slice(0, Math.max(0, max - 1)).trimEnd()}…`; +} + +export function slugifyBriefGrouping(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-+/g, "-") + .slice(0, 96) || "brief"; +} + +function issueHref(issue: Pick): string { + return `/PAP/issues/${issue.identifier ?? issue.id}`; +} + +function sourceIssue(bundle: BriefsSourceBundle, issueId: string | null | undefined): BriefsIssueInput | null { + if (!issueId) return null; + return bundle.issues.find((issue) => issue.id === issueId) ?? null; +} + +function sourceLink(bundle: BriefsSourceBundle, kind: string, sourceId: string, issueId: string | null, key?: string): string { + const issue = sourceIssue(bundle, issueId); + if (kind === "run") return `/PAP/runs/${sourceId}`; + if (!issue) return `/PAP/issues/${issueId ?? sourceId}`; + if (kind === "comment") return `${issueHref(issue)}#comment-${sourceId}`; + if (kind === "document") return `${issueHref(issue)}#document-${key ?? sourceId}`; + if (kind === "interaction") return `${issueHref(issue)}#interaction-${sourceId}`; + if (kind === "approval") return `/PAP/approvals/${sourceId}`; + if (kind === "work_product") return `${issueHref(issue)}#work-product-${sourceId}`; + return issueHref(issue); +} + +function eventTime(...values: Array): number { + return Math.max(0, ...values.map(asTime).filter((time): time is number => time != null)); +} + +function issueEventTime(issue: BriefsIssueInput): number { + return eventTime(issue.completedAt, issue.updatedAt, issue.createdAt); +} + +function runEventTime(run: BriefsRunInput): number { + return eventTime(run.finishedAt, run.startedAt, run.createdAt); +} + +function assertCompanyScope(bundle: BriefsSourceBundle): void { + const issueIds = new Set(bundle.issues.map((issue) => issue.id)); + const check = (label: string, companyId: string, sourceId: string) => { + if (companyId !== bundle.companyId) { + throw new Error(`Briefs source ${label}:${sourceId} belongs to another company`); + } + }; + + for (const issue of bundle.issues) check("issue", issue.companyId, issue.id); + for (const run of [...Object.values(bundle.activeRuns ?? {}).flat(), ...(bundle.runs ?? [])]) check("run", run.companyId, run.id); + for (const comment of bundle.comments ?? []) check("comment", comment.companyId, comment.id); + for (const document of bundle.documents ?? []) check("document", document.companyId, document.id); + for (const interaction of bundle.interactions ?? []) check("interaction", interaction.companyId, interaction.id); + for (const approval of bundle.approvals ?? []) check("approval", approval.companyId, approval.id); + for (const workProduct of bundle.workProducts ?? []) check("work_product", workProduct.companyId, workProduct.id); + for (const event of bundle.activityEvents ?? []) check("activity_event", event.companyId, event.id); + + for (const [issueId, relation] of Object.entries(bundle.relations ?? {})) { + if (!issueIds.has(issueId)) { + throw new Error(`Briefs relation references issue outside the source bundle: ${issueId}`); + } + for (const blocker of relation.blockedBy ?? []) { + if (blocker.companyId && blocker.companyId !== bundle.companyId) { + throw new Error(`Briefs blocker ${blocker.id} belongs to another company`); + } + } + } +} + +function getRootIssue(bundle: BriefsSourceBundle): BriefsIssueInput { + const root = bundle.issues.find((issue) => issue.id === bundle.rootIssueId); + if (!root) throw new Error(`Root issue not found in Briefs source bundle: ${bundle.rootIssueId}`); + return root; +} + +function getIssueIds(bundle: BriefsSourceBundle): Set { + return new Set(bundle.issues.map((issue) => issue.id)); +} + +function activeRuns(bundle: BriefsSourceBundle): BriefsRunInput[] { + return [ + ...Object.values(bundle.activeRuns ?? {}).flat(), + ...(bundle.runs ?? []).filter((run) => ["queued", "running", "in_progress"].includes(run.status)), + ]; +} + +function failedRunsWithinWindow(bundle: BriefsSourceBundle, nowMs: number): Map { + const counts = new Map(); + for (const run of bundle.runs ?? []) { + if (!run.issueId || !["failed", "error"].includes(run.status)) continue; + const at = runEventTime(run); + if (!at || nowMs - at > hours(FAILED_RUN_WINDOW_HOURS)) continue; + counts.set(run.issueId, (counts.get(run.issueId) ?? 0) + 1); + } + return counts; +} + +function hasOutOfTreeBlocker(issue: BriefsIssueInput, relation: BriefsRelationInput | undefined, treeIssueIds: Set): boolean { + if (issue.status !== "blocked") return false; + for (const blocker of relation?.blockedBy ?? []) { + if (["done", "cancelled"].includes(String(blocker.status))) continue; + if (!treeIssueIds.has(blocker.id)) return true; + } + return false; +} + +function issueHasBlockingAttention(issue: BriefsIssueInput): boolean { + const reason = issue.blockerAttention?.reason; + return Boolean(reason && BLOCKER_ATTENTION_REASONS.has(reason)); +} + +function participantIsUser( + participant: BriefsIssueInput["executionState"] extends infer T + ? T extends { currentParticipant?: infer P } ? P : never + : never, + userId: string, +): boolean { + return Boolean(participant && typeof participant === "object" && "type" in participant && participant.type === "user" && participant.userId === userId); +} + +function issueWaitingOnUser(issue: BriefsIssueInput, userId: string): boolean { + return issue.assigneeUserId === userId + || participantIsUser(issue.executionState?.currentParticipant ?? null, userId) + || participantIsUser(issue.executionState?.returnAssignee ?? null, userId); +} + +function issueHasTypedReviewer(issue: BriefsIssueInput): boolean { + const participant = issue.executionState?.currentParticipant; + return issue.status === "in_review" && Boolean(participant?.type === "user" || participant?.type === "agent"); +} + +function pendingInteractionForUser(interaction: BriefsInteractionInput, userId: string): boolean { + return interaction.status === "pending" && (!interaction.targetUserId || interaction.targetUserId === userId); +} + +function pendingApprovalForUser(approval: BriefsApprovalInput, userId: string): boolean { + return approval.status === "pending_approval" && approval.reviewerUserId === userId; +} + +function pendingApprovalForOtherReviewer(approval: BriefsApprovalInput, userId: string): boolean { + return approval.status === "pending_approval" && approval.reviewerUserId !== userId; +} + +function lastMeaningfulEventMs(bundle: BriefsSourceBundle): number { + return Math.max( + ...bundle.issues.map(issueEventTime), + ...Object.values(bundle.activeRuns ?? {}).flat().map(runEventTime), + ...(bundle.runs ?? []).map(runEventTime), + ...(bundle.comments ?? []).map((comment) => eventTime(comment.createdAt)), + ...(bundle.documents ?? []).map((document) => eventTime(document.updatedAt)), + ...(bundle.interactions ?? []).map((interaction) => eventTime(interaction.updatedAt, interaction.createdAt)), + ...(bundle.approvals ?? []).map((approval) => eventTime(approval.decidedAt, approval.createdAt)), + ...(bundle.workProducts ?? []).map((workProduct) => eventTime(workProduct.updatedAt, workProduct.createdAt)), + ...(bundle.activityEvents ?? []).map((activity) => eventTime(activity.createdAt)), + ); +} + +export function resolveBriefCardState(bundle: BriefsSourceBundle, options: { now?: Date | string; preferences?: Partial } = {}): { + state: BriefCardState; + inputs: Record; +} { + assertCompanyScope(bundle); + const nowMs = asTime(options.now ?? new Date()) ?? Date.now(); + const root = getRootIssue(bundle); + const treeIssueIds = getIssueIds(bundle); + const failedRunCounts = failedRunsWithinWindow(bundle, nowMs); + const lastEventMs = lastMeaningfulEventMs(bundle); + const doneRetentionHours = options.preferences?.doneRetentionHours ?? DEFAULT_DONE_RETENTION_HOURS; + + const hasRecoveryAction = bundle.issues.some((issue) => Boolean(issue.activeRecoveryAction)); + const hasFailedRunLoop = [...failedRunCounts.values()].some((count) => count >= 3); + const hasBlocked = bundle.issues.some((issue) => hasOutOfTreeBlocker(issue, bundle.relations?.[issue.id], treeIssueIds) || issueHasBlockingAttention(issue)); + const hasWaitingUser = bundle.issues.some((issue) => issueWaitingOnUser(issue, bundle.userId)) + || (bundle.interactions ?? []).some((interaction) => pendingInteractionForUser(interaction, bundle.userId)) + || (bundle.approvals ?? []).some((approval) => pendingApprovalForUser(approval, bundle.userId)); + const hasWaitingReviewer = bundle.issues.some((issue) => issueHasTypedReviewer(issue) && !issueWaitingOnUser(issue, bundle.userId)) + || (bundle.approvals ?? []).some((approval) => pendingApprovalForOtherReviewer(approval, bundle.userId)); + const hasLive = (activeRuns(bundle).length > 0 || bundle.issues.some((issue) => issue.status === "in_progress")) + && lastEventMs > 0 + && nowMs - lastEventMs <= hours(LIVE_WINDOW_HOURS); + const doneAt = asTime(root.completedAt) ?? (root.status === "done" ? issueEventTime(root) : null); + const isRecentlyDone = root.status === "done" && doneAt != null && nowMs - doneAt <= hours(doneRetentionHours); + + const inputs = { + hasRecoveryAction, + hasFailedRunLoop, + hasBlocked, + hasWaitingUser, + hasWaitingReviewer, + hasLive, + isRecentlyDone, + lastMeaningfulEventAt: lastEventMs ? toIso(lastEventMs) : null, + }; + + if (hasRecoveryAction || hasFailedRunLoop) return { state: "error", inputs }; + if (hasBlocked) return { state: "blocked", inputs }; + if (hasWaitingUser) return { state: "waiting-user", inputs }; + if (hasWaitingReviewer) return { state: "waiting-reviewer", inputs }; + if (hasLive) return { state: "live", inputs }; + if (isRecentlyDone) return { state: "done", inputs }; + return { state: "stale", inputs }; +} + +function sourcePriority(source: BriefCardSource): number { + if (source.rightTag === "blocked") return 0; + if (source.rightTag === "asked you") return 1; + if (source.rightTag === "in_review" || source.rightTag === "approval") return 2; + if (source.rightTag === "running" || source.rightTag === "in_progress") return 3; + if (source.rightTag === "failed") return 4; + return 5; +} + +function toTaskRow(source: BriefCardSource): BriefTaskRow | null { + if (!["issue", "run", "comment", "document", "interaction", "approval"].includes(source.sourceKind)) return null; + return { + kind: source.sourceKind as BriefTaskRow["kind"], + sourceId: source.sourceId, + issueId: source.issueId, + identifier: source.identifier, + titleLine: truncate(source.titleLine, 120), + rightTag: source.rightTag, + linkPath: source.linkPath, + isIntraTreeBlocked: source.isIntraTreeBlocked, + eventAt: source.eventAt, + }; +} + +function issueRightTag(issue: BriefsIssueInput, bundle: BriefsSourceBundle, treeIssueIds: Set): { tag: string; intraTreeBlocked: boolean | null } { + if (issue.status === "blocked") { + const relation = bundle.relations?.[issue.id]; + const blockers = relation?.blockedBy ?? []; + const hasOnlyInTreeBlockers = blockers.length > 0 && blockers.every((blocker) => treeIssueIds.has(blocker.id)); + return { tag: "blocked", intraTreeBlocked: hasOnlyInTreeBlockers }; + } + if (issueWaitingOnUser(issue, bundle.userId)) return { tag: "asked you", intraTreeBlocked: null }; + return { tag: String(issue.status), intraTreeBlocked: null }; +} + +function buildSources(bundle: BriefsSourceBundle, cardId: string, idFactory: () => string): BriefCardSource[] { + const treeIssueIds = getIssueIds(bundle); + const issueSources: BriefCardSource[] = bundle.issues.map((issue) => { + const right = issueRightTag(issue, bundle, treeIssueIds); + return { + id: idFactory(), + companyId: bundle.companyId, + userId: bundle.userId, + cardId, + sourceKind: "issue", + sourceId: issue.id, + issueId: issue.id, + identifier: issue.identifier, + titleLine: truncate(issue.title, 160), + rightTag: right.tag, + linkPath: issueHref(issue), + isIntraTreeBlocked: right.intraTreeBlocked, + eventAt: toIso(issueEventTime(issue) || Date.now()), + metadata: { priority: issue.priority ?? null }, + }; + }); + + const runSources = [ + ...Object.values(bundle.activeRuns ?? {}).flat(), + ...(bundle.runs ?? []), + ].map((run): BriefCardSource => { + const issue = sourceIssue(bundle, run.issueId); + return { + id: idFactory(), + companyId: bundle.companyId, + userId: bundle.userId, + cardId, + sourceKind: "run", + sourceId: run.id, + issueId: run.issueId, + identifier: issue?.identifier ?? null, + titleLine: truncate(issue ? `Run for ${issue.title}` : "Heartbeat run", 160), + rightTag: ["failed", "error"].includes(run.status) ? "failed" : run.status, + linkPath: sourceLink(bundle, "run", run.id, run.issueId), + isIntraTreeBlocked: null, + eventAt: toIso(runEventTime(run) || Date.now()), + metadata: { error: run.error ?? null }, + }; + }); + + const commentSources = (bundle.comments ?? []).map((comment): BriefCardSource => { + const issue = sourceIssue(bundle, comment.issueId); + return { + id: idFactory(), + companyId: bundle.companyId, + userId: bundle.userId, + cardId, + sourceKind: "comment", + sourceId: comment.id, + issueId: comment.issueId, + identifier: issue?.identifier ?? null, + titleLine: truncate(comment.body || `Comment on ${issue?.title ?? "issue"}`, 160), + rightTag: "comment", + linkPath: sourceLink(bundle, "comment", comment.id, comment.issueId), + isIntraTreeBlocked: null, + eventAt: toIso(eventTime(comment.createdAt) || Date.now()), + metadata: { authorUserId: comment.authorUserId ?? null }, + }; + }); + + const documentSources = (bundle.documents ?? []).map((document): BriefCardSource => { + const issue = sourceIssue(bundle, document.issueId); + return { + id: idFactory(), + companyId: bundle.companyId, + userId: bundle.userId, + cardId, + sourceKind: "document", + sourceId: document.id, + issueId: document.issueId, + identifier: issue?.identifier ?? null, + titleLine: truncate(document.title ?? document.key, 160), + rightTag: "document", + linkPath: sourceLink(bundle, "document", document.id, document.issueId, document.key), + isIntraTreeBlocked: null, + eventAt: toIso(eventTime(document.updatedAt) || Date.now()), + metadata: { key: document.key }, + }; + }); + + const interactionSources = (bundle.interactions ?? []).map((interaction): BriefCardSource => { + const issue = sourceIssue(bundle, interaction.issueId); + return { + id: idFactory(), + companyId: bundle.companyId, + userId: bundle.userId, + cardId, + sourceKind: "interaction", + sourceId: interaction.id, + issueId: interaction.issueId, + identifier: issue?.identifier ?? null, + titleLine: truncate(interaction.kind.replaceAll("_", " "), 160), + rightTag: pendingInteractionForUser(interaction, bundle.userId) ? "asked you" : interaction.status, + linkPath: sourceLink(bundle, "interaction", interaction.id, interaction.issueId), + isIntraTreeBlocked: null, + eventAt: toIso(eventTime(interaction.updatedAt, interaction.createdAt) || Date.now()), + metadata: { kind: interaction.kind, targetUserId: interaction.targetUserId ?? null }, + }; + }); + + const approvalSources = (bundle.approvals ?? []).map((approval): BriefCardSource => { + const issue = sourceIssue(bundle, approval.issueId); + return { + id: idFactory(), + companyId: bundle.companyId, + userId: bundle.userId, + cardId, + sourceKind: "approval", + sourceId: approval.id, + issueId: approval.issueId, + identifier: issue?.identifier ?? null, + titleLine: truncate(`Approval for ${issue?.title ?? "issue"}`, 160), + rightTag: approval.status === "pending_approval" ? "approval" : approval.status, + linkPath: sourceLink(bundle, "approval", approval.id, approval.issueId), + isIntraTreeBlocked: null, + eventAt: toIso(eventTime(approval.decidedAt, approval.createdAt) || Date.now()), + metadata: { reviewerUserId: approval.reviewerUserId ?? null }, + }; + }); + + const workProductSources = (bundle.workProducts ?? []).map((workProduct): BriefCardSource => { + const issue = sourceIssue(bundle, workProduct.issueId); + return { + id: idFactory(), + companyId: bundle.companyId, + userId: bundle.userId, + cardId, + sourceKind: "work_product", + sourceId: workProduct.id, + issueId: workProduct.issueId, + identifier: issue?.identifier ?? null, + titleLine: truncate(workProduct.title, 160), + rightTag: workProduct.status ?? "work product", + linkPath: sourceLink(bundle, "work_product", workProduct.id, workProduct.issueId), + isIntraTreeBlocked: null, + eventAt: toIso(eventTime(workProduct.updatedAt, workProduct.createdAt) || Date.now()), + metadata: {}, + }; + }); + + const activitySources = (bundle.activityEvents ?? []).map((activity): BriefCardSource => ({ + id: idFactory(), + companyId: bundle.companyId, + userId: bundle.userId, + cardId, + sourceKind: "activity_event", + sourceId: activity.id, + issueId: activity.issueId ?? null, + identifier: sourceIssue(bundle, activity.issueId)?.identifier ?? null, + titleLine: truncate(activity.action, 160), + rightTag: "activity", + linkPath: sourceLink(bundle, "activity_event", activity.id, activity.issueId ?? null), + isIntraTreeBlocked: null, + eventAt: toIso(eventTime(activity.createdAt) || Date.now()), + metadata: { action: activity.action }, + })); + + return [ + ...issueSources, + ...runSources, + ...commentSources, + ...documentSources, + ...interactionSources, + ...approvalSources, + ...workProductSources, + ...activitySources, + ].sort((a, b) => sourcePriority(a) - sourcePriority(b) || new Date(b.eventAt).getTime() - new Date(a.eventAt).getTime()); +} + +function fallbackSummary(state: BriefCardState, rows: BriefTaskRow[]): string { + const row = rows[0]; + if (!row) return "No current source rows are available for this brief."; + if (state === "blocked") return `${row.identifier ?? "A source issue"} is blocked and needs out-of-tree progress.`; + if (state === "waiting-user") return `${row.identifier ?? "A source item"} is waiting on your response.`; + if (state === "waiting-reviewer") return `${row.identifier ?? "A source item"} is in review with a typed reviewer.`; + if (state === "live") return `${row.identifier ?? "A source issue"} is active with recent work in progress.`; + if (state === "done") return `${row.identifier ?? "The root issue"} was completed recently.`; + if (state === "error") return `${row.identifier ?? "A source issue"} has a recovery or repeated run failure signal.`; + return `${row.identifier ?? "This area"} has no recent meaningful activity.`; +} + +export function buildDeterministicBriefCard(bundle: BriefsSourceBundle, options: DeterministicBriefOptions = {}): BriefCard { + assertCompanyScope(bundle); + const root = getRootIssue(bundle); + const idFactory = options.idFactory ?? randomUUID; + const cardId = idFactory(); + const snapshotId = idFactory(); + const nowMs = asTime(options.now ?? new Date()) ?? Date.now(); + const lastEventMs = lastMeaningfulEventMs(bundle) || nowMs; + const stateResult = resolveBriefCardState(bundle, options); + const summaryStatus = options.summaryStatus ?? "fallback"; + const groupingDescription = truncate( + bundle.groupingDescription ?? `Issue tree rooted at ${root.identifier ?? root.id}: ${root.title}`, + 500, + ); + const slug = slugifyBriefGrouping(groupingDescription); + const sources = buildSources(bundle, cardId, idFactory); + const taskRows = sources.map(toTaskRow).filter((row): row is BriefTaskRow => Boolean(row)).slice(0, 3); + const staleAt = addMs(lastEventMs, days(options.preferences?.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS)); + const retentionMs = stateResult.state === "done" + ? hours(options.preferences?.doneRetentionHours ?? DEFAULT_DONE_RETENTION_HOURS) + : days(options.preferences?.retentionDays ?? DEFAULT_RETENTION_DAYS); + const pinned = options.pinned ?? false; + const summaryParagraph = summaryStatus === "ok" + ? truncate(options.summaryParagraph ?? fallbackSummary(stateResult.state, taskRows), 260) + : null; + + const snapshot: BriefSnapshot = { + id: snapshotId, + companyId: bundle.companyId, + userId: bundle.userId, + cardId, + summaryParagraph, + summaryStatus, + summaryModel: options.summaryModel ?? null, + summaryTokensIn: options.summaryTokensIn ?? null, + summaryTokensOut: options.summaryTokensOut ?? null, + summaryFailureReason: summaryStatus === "fallback" ? options.summaryFailureReason ?? "model_error" : null, + taskRows, + evidenceSourceIds: summaryStatus === "ok" ? taskRows.map((row) => row.sourceId) : [], + generatedByAgentId: options.generatedByAgentId ?? null, + generatedByRunId: options.generatedByRunId ?? null, + deterministicStateInputs: stateResult.inputs, + createdAt: toIso(nowMs), + }; + + return { + id: cardId, + companyId: bundle.companyId, + userId: bundle.userId, + slug, + title: truncate(bundle.title ?? root.title, 90), + groupingDescription, + rootIssueId: root.id, + state: stateResult.state, + summaryStatus, + pinned, + hidden: options.hidden ?? false, + staleAt: toIso(staleAt), + expiresAt: pinned ? null : toIso(addMs(lastEventMs, retentionMs)), + latestSnapshotId: snapshotId, + lastMeaningfulEventAt: toIso(lastEventMs), + snapshot, + sources, + moreSourceCount: Math.max(0, sources.length - taskRows.length), + }; +} + +export function isBriefTreeRelevantToUser(bundle: BriefsSourceBundle): boolean { + assertCompanyScope(bundle); + return bundle.issues.some((issue) => issue.createdByUserId === bundle.userId || issue.updatedByUserId === bundle.userId || issueWaitingOnUser(issue, bundle.userId)) + || (bundle.comments ?? []).some((comment) => comment.authorUserId === bundle.userId) + || (bundle.documents ?? []).some((document) => document.createdByUserId === bundle.userId || document.updatedByUserId === bundle.userId) + || (bundle.interactions ?? []).some((interaction) => interaction.targetUserId === bundle.userId) + || (bundle.approvals ?? []).some((approval) => approval.reviewerUserId === bundle.userId || approval.decidedByUserId === bundle.userId) + || bundle.issues.some((issue) => Boolean(issue.assigneeAgentId && bundle.relevantAgentIds?.includes(issue.assigneeAgentId))); +} + +export function selectRelevantBriefTrees(input: { + companyId: string; + userId: string; + candidateTrees: BriefsSourceBundle[]; + now?: Date | string; + discoveryWindowDays?: number; +}): BriefsSourceBundle[] { + const nowMs = asTime(input.now ?? new Date()) ?? Date.now(); + const windowMs = days(input.discoveryWindowDays ?? DEFAULT_DISCOVERY_WINDOW_DAYS); + return input.candidateTrees.filter((tree) => { + if (tree.companyId !== input.companyId || tree.userId !== input.userId) return false; + assertCompanyScope(tree); + return isBriefTreeRelevantToUser(tree) && nowMs - lastMeaningfulEventMs(tree) <= windowMs; + }); +} + +export function sortBriefCards(cards: BriefCard[]): BriefCard[] { + return [...cards].sort((a, b) => { + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; + return new Date(b.lastMeaningfulEventAt).getTime() - new Date(a.lastMeaningfulEventAt).getTime(); + }); +} + +export function filterExpiredBriefCards(cards: BriefCard[], now: Date | string = new Date()): BriefCard[] { + const nowMs = asTime(now) ?? Date.now(); + return cards.filter((card) => card.pinned || !card.expiresAt || new Date(card.expiresAt).getTime() > nowMs); +} + +export function dedupeBriefCursorEvents( + events: BriefCursorEvent[], + previousDedupeState: readonly string[] = [], +): BriefCursorDedupeResult { + const seen = new Set(previousDedupeState); + const freshEvents: BriefCursorEvent[] = []; + for (const event of events) { + const key = event.fingerprint ?? event.id; + if (seen.has(key)) continue; + seen.add(key); + freshEvents.push(event); + } + const sortedState = [...seen].slice(-MAX_DEDUPE_STATE); + const lastSeen = freshEvents.reduce((max, event) => { + const time = asTime(event.eventAt); + return time == null ? max : Math.max(max ?? 0, time); + }, null); + return { + freshEvents, + dedupeState: sortedState, + lastSeenAt: lastSeen == null ? null : toIso(lastSeen), + }; +} diff --git a/packages/plugins/plugin-briefs/src/index.ts b/packages/plugins/plugin-briefs/src/index.ts new file mode 100644 index 00000000000..8d1263de20b --- /dev/null +++ b/packages/plugins/plugin-briefs/src/index.ts @@ -0,0 +1,4 @@ +export { default } from "./worker.js"; +export { default as manifest } from "./manifest.js"; +export * from "./contracts.js"; +export * from "./deterministic-card-service.js"; diff --git a/packages/plugins/plugin-briefs/src/manifest.ts b/packages/plugins/plugin-briefs/src/manifest.ts new file mode 100644 index 00000000000..e81d0cd00b7 --- /dev/null +++ b/packages/plugins/plugin-briefs/src/manifest.ts @@ -0,0 +1,41 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +export const PLUGIN_ID = "paperclipai.plugin-briefs"; + +const manifest: PaperclipPluginManifestV1 = { + id: PLUGIN_ID, + apiVersion: 1, + version: "0.1.0", + displayName: "Briefs", + description: "Company-scoped briefing cards backed by deterministic Paperclip work-state analysis.", + author: "Paperclip", + categories: ["automation", "ui"], + capabilities: [ + "database.namespace.migrate", + "database.namespace.read", + "database.namespace.write", + "companies.read", + "projects.read", + "goals.read", + "agents.read", + "issues.read", + "issue.subtree.read", + "issue.relations.read", + "issue.comments.read", + "issue.documents.read", + "issues.orchestration.read", + "activity.log.write", + "plugin.state.read", + "plugin.state.write" + ], + entrypoints: { + worker: "./dist/worker.js" + }, + database: { + namespaceSlug: "briefs", + migrationsDir: "migrations", + coreReadTables: ["companies", "issues"] + } +}; + +export default manifest; diff --git a/packages/plugins/plugin-briefs/src/store.ts b/packages/plugins/plugin-briefs/src/store.ts new file mode 100644 index 00000000000..8c0ab7f2053 --- /dev/null +++ b/packages/plugins/plugin-briefs/src/store.ts @@ -0,0 +1,332 @@ +import { randomUUID } from "node:crypto"; +import type { PluginDatabaseClient } from "@paperclipai/plugin-sdk"; +import type { + BriefCard, + BriefCardSource, + BriefPreferences, + BriefSnapshot, +} from "./contracts.js"; + +type BriefCardRow = { + id: string; + company_id: string; + user_id: string; + slug: string; + title: string; + grouping_description: string; + root_issue_id: string | null; + state: BriefCard["state"]; + summary_status: BriefCard["summaryStatus"]; + pinned: boolean; + hidden: boolean; + stale_at: string; + expires_at: string | null; + latest_snapshot_id: string | null; + last_meaningful_event_at: string; +}; + +type BriefSnapshotRow = { + id: string; + company_id: string; + user_id: string; + card_id: string; + summary_paragraph: string | null; + summary_status: BriefSnapshot["summaryStatus"]; + summary_model: string | null; + summary_tokens_in: number | null; + summary_tokens_out: number | null; + summary_failure_reason: BriefSnapshot["summaryFailureReason"]; + task_rows: unknown; + evidence_source_ids: unknown; + generated_by_agent_id: string | null; + generated_by_run_id: string | null; + deterministic_state_inputs: unknown; + created_at: string; +}; + +type BriefSourceRow = { + id: string; + company_id: string; + user_id: string; + card_id: string; + source_kind: BriefCardSource["sourceKind"]; + source_id: string; + issue_id: string | null; + identifier: string | null; + title_line: string; + right_tag: string; + link_path: string; + is_intra_tree_blocked: boolean | null; + event_at: string; + metadata: unknown; +}; + +function table(namespace: string, name: string): string { + return `${namespace}.${name}`; +} + +function toSnapshot(row: BriefSnapshotRow): BriefSnapshot { + return { + id: row.id, + companyId: row.company_id, + userId: row.user_id, + cardId: row.card_id, + summaryParagraph: row.summary_paragraph, + summaryStatus: row.summary_status, + summaryModel: row.summary_model, + summaryTokensIn: row.summary_tokens_in, + summaryTokensOut: row.summary_tokens_out, + summaryFailureReason: row.summary_failure_reason, + taskRows: Array.isArray(row.task_rows) ? row.task_rows as BriefSnapshot["taskRows"] : [], + evidenceSourceIds: Array.isArray(row.evidence_source_ids) ? row.evidence_source_ids as string[] : [], + generatedByAgentId: row.generated_by_agent_id, + generatedByRunId: row.generated_by_run_id, + deterministicStateInputs: row.deterministic_state_inputs && typeof row.deterministic_state_inputs === "object" + ? row.deterministic_state_inputs as Record + : {}, + createdAt: row.created_at, + }; +} + +function toSource(row: BriefSourceRow): BriefCardSource { + return { + id: row.id, + companyId: row.company_id, + userId: row.user_id, + cardId: row.card_id, + sourceKind: row.source_kind, + sourceId: row.source_id, + issueId: row.issue_id, + identifier: row.identifier, + titleLine: row.title_line, + rightTag: row.right_tag, + linkPath: row.link_path, + isIntraTreeBlocked: row.is_intra_tree_blocked, + eventAt: row.event_at, + metadata: row.metadata && typeof row.metadata === "object" ? row.metadata as Record : {}, + }; +} + +function toCard(row: BriefCardRow, snapshot: BriefSnapshot, sources: BriefCardSource[]): BriefCard { + return { + id: row.id, + companyId: row.company_id, + userId: row.user_id, + slug: row.slug, + title: row.title, + groupingDescription: row.grouping_description, + rootIssueId: row.root_issue_id, + state: row.state, + summaryStatus: row.summary_status, + pinned: row.pinned, + hidden: row.hidden, + staleAt: row.stale_at, + expiresAt: row.expires_at, + latestSnapshotId: row.latest_snapshot_id, + lastMeaningfulEventAt: row.last_meaningful_event_at, + snapshot, + sources, + moreSourceCount: Math.max(0, sources.length - snapshot.taskRows.length), + }; +} + +export function createBriefsStore(db: PluginDatabaseClient) { + const cardsTable = table(db.namespace, "briefs_cards"); + const sourcesTable = table(db.namespace, "briefs_card_sources"); + const snapshotsTable = table(db.namespace, "briefs_card_snapshots"); + const preferencesTable = table(db.namespace, "briefs_user_preferences"); + + return { + async listCards(input: { companyId: string; userId: string; includeHidden?: boolean; limit?: number }): Promise { + const cardRows = await db.query( + `SELECT id, company_id, user_id, slug, title, grouping_description, root_issue_id, state, summary_status, + pinned, hidden, stale_at, expires_at, latest_snapshot_id, last_meaningful_event_at + FROM ${cardsTable} + WHERE company_id = $1 AND user_id = $2 AND ($3::boolean OR hidden = false) + ORDER BY pinned DESC, last_meaningful_event_at DESC, id DESC + LIMIT $4`, + [input.companyId, input.userId, Boolean(input.includeHidden), input.limit ?? 50], + ); + + const cards: BriefCard[] = []; + for (const card of cardRows) { + if (!card.latest_snapshot_id) continue; + const snapshots = await db.query( + `SELECT id, company_id, user_id, card_id, summary_paragraph, summary_status, summary_model, + summary_tokens_in, summary_tokens_out, summary_failure_reason, task_rows, evidence_source_ids, + generated_by_agent_id, generated_by_run_id, deterministic_state_inputs, created_at + FROM ${snapshotsTable} + WHERE company_id = $1 AND user_id = $2 AND card_id = $3 AND id = $4 + LIMIT 1`, + [input.companyId, input.userId, card.id, card.latest_snapshot_id], + ); + const snapshot = snapshots[0]; + if (!snapshot) continue; + const sources = await db.query( + `SELECT id, company_id, user_id, card_id, source_kind, source_id, issue_id, identifier, title_line, + right_tag, link_path, is_intra_tree_blocked, event_at, metadata + FROM ${sourcesTable} + WHERE company_id = $1 AND user_id = $2 AND card_id = $3 + ORDER BY event_at DESC, id DESC`, + [input.companyId, input.userId, card.id], + ); + cards.push(toCard(card, toSnapshot(snapshot), sources.map(toSource))); + } + return cards; + }, + + async saveCard(card: BriefCard): Promise { + await db.execute( + `INSERT INTO ${cardsTable} ( + id, company_id, user_id, slug, title, grouping_description, root_issue_id, state, summary_status, + pinned, hidden, stale_at, expires_at, latest_snapshot_id, last_meaningful_event_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::timestamptz, $13::timestamptz, NULL, $14::timestamptz) + ON CONFLICT (company_id, user_id, slug) DO UPDATE SET + title = EXCLUDED.title, + grouping_description = EXCLUDED.grouping_description, + root_issue_id = EXCLUDED.root_issue_id, + state = EXCLUDED.state, + summary_status = EXCLUDED.summary_status, + pinned = ${cardsTable}.pinned OR EXCLUDED.pinned, + hidden = EXCLUDED.hidden, + stale_at = EXCLUDED.stale_at, + expires_at = CASE WHEN ${cardsTable}.pinned OR EXCLUDED.pinned THEN NULL ELSE EXCLUDED.expires_at END, + last_meaningful_event_at = EXCLUDED.last_meaningful_event_at, + updated_at = now()`, + [ + card.id, + card.companyId, + card.userId, + card.slug, + card.title, + card.groupingDescription, + card.rootIssueId, + card.state, + card.summaryStatus, + card.pinned, + card.hidden, + card.staleAt, + card.expiresAt, + card.lastMeaningfulEventAt, + ], + ); + + const existingRows = await db.query<{ id: string; pinned: boolean }>( + `SELECT id, pinned FROM ${cardsTable} WHERE company_id = $1 AND user_id = $2 AND slug = $3 LIMIT 1`, + [card.companyId, card.userId, card.slug], + ); + const persistedCardId = existingRows[0]?.id ?? card.id; + + await db.execute( + `DELETE FROM ${sourcesTable} WHERE company_id = $1 AND user_id = $2 AND card_id = $3`, + [card.companyId, card.userId, persistedCardId], + ); + for (const source of card.sources) { + await db.execute( + `INSERT INTO ${sourcesTable} ( + id, company_id, user_id, card_id, source_kind, source_id, issue_id, identifier, title_line, + right_tag, link_path, is_intra_tree_blocked, event_at, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::timestamptz, $14::jsonb)`, + [ + source.id, + source.companyId, + source.userId, + persistedCardId, + source.sourceKind, + source.sourceId, + source.issueId, + source.identifier, + source.titleLine, + source.rightTag, + source.linkPath, + source.isIntraTreeBlocked, + source.eventAt, + JSON.stringify(source.metadata ?? {}), + ], + ); + } + + await db.execute( + `INSERT INTO ${snapshotsTable} ( + id, company_id, user_id, card_id, summary_paragraph, summary_status, summary_model, + summary_tokens_in, summary_tokens_out, summary_failure_reason, task_rows, evidence_source_ids, + generated_by_agent_id, generated_by_run_id, deterministic_state_inputs + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12::jsonb, $13, $14, $15::jsonb)`, + [ + card.snapshot.id, + card.companyId, + card.userId, + persistedCardId, + card.snapshot.summaryParagraph, + card.snapshot.summaryStatus, + card.snapshot.summaryModel, + card.snapshot.summaryTokensIn, + card.snapshot.summaryTokensOut, + card.snapshot.summaryFailureReason, + JSON.stringify(card.snapshot.taskRows), + JSON.stringify(card.snapshot.evidenceSourceIds), + card.snapshot.generatedByAgentId, + card.snapshot.generatedByRunId, + JSON.stringify(card.snapshot.deterministicStateInputs), + ], + ); + + await db.execute( + `UPDATE ${cardsTable} + SET latest_snapshot_id = $1, updated_at = now() + WHERE company_id = $2 AND user_id = $3 AND id = $4`, + [card.snapshot.id, card.companyId, card.userId, persistedCardId], + ); + + return { + ...card, + id: persistedCardId, + pinned: existingRows[0]?.pinned ?? card.pinned, + latestSnapshotId: card.snapshot.id, + snapshot: { ...card.snapshot, cardId: persistedCardId }, + sources: card.sources.map((source) => ({ ...source, cardId: persistedCardId })), + }; + }, + + async setPinned(input: { companyId: string; userId: string; cardId: string; pinned: boolean }): Promise { + await db.execute( + `UPDATE ${cardsTable} + SET pinned = $1, expires_at = CASE WHEN $1 THEN NULL ELSE expires_at END, updated_at = now() + WHERE company_id = $2 AND user_id = $3 AND id = $4`, + [input.pinned, input.companyId, input.userId, input.cardId], + ); + }, + + async upsertPreferences(preferences: BriefPreferences): Promise { + await db.execute( + `INSERT INTO ${preferencesTable} ( + id, company_id, user_id, cadence, retention_days, done_retention_hours, stale_after_days, + max_unpinned_cards, scope + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (company_id, user_id) DO UPDATE SET + cadence = EXCLUDED.cadence, + retention_days = EXCLUDED.retention_days, + done_retention_hours = EXCLUDED.done_retention_hours, + stale_after_days = EXCLUDED.stale_after_days, + max_unpinned_cards = EXCLUDED.max_unpinned_cards, + scope = EXCLUDED.scope, + updated_at = now()`, + [ + randomUUID(), + preferences.companyId, + preferences.userId, + preferences.cadence, + preferences.retentionDays, + preferences.doneRetentionHours, + preferences.staleAfterDays, + preferences.maxUnpinnedCards, + preferences.scope, + ], + ); + }, + }; +} diff --git a/packages/plugins/plugin-briefs/src/worker.ts b/packages/plugins/plugin-briefs/src/worker.ts new file mode 100644 index 00000000000..595189de9f1 --- /dev/null +++ b/packages/plugins/plugin-briefs/src/worker.ts @@ -0,0 +1,87 @@ +import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; +import { + briefPreferencesSchema, + listBriefCardsInputSchema, + pinBriefCardInputSchema, + updateBriefPreferencesInputSchema, +} from "./contracts.js"; +import { + buildDeterministicBriefCard, + type BriefsSourceBundle, + type DeterministicBriefOptions, +} from "./deterministic-card-service.js"; +import { createBriefsStore } from "./store.js"; + +function objectParam(value: unknown, name: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${name} must be an object`); + } + return value as Record; +} + +const plugin = definePlugin({ + async setup(ctx) { + const store = createBriefsStore(ctx.db); + + ctx.data.register("cards", async (params) => { + const input = listBriefCardsInputSchema.parse(params); + return { + cards: await store.listCards(input), + }; + }); + + ctx.actions.register("save-deterministic-card", async (params) => { + const input = objectParam(params, "params"); + const bundle = objectParam(input.bundle, "bundle") as BriefsSourceBundle; + const options = (input.options && typeof input.options === "object" ? input.options : {}) as DeterministicBriefOptions; + const card = await store.saveCard(buildDeterministicBriefCard(bundle, options)); + await ctx.activity.log({ + companyId: card.companyId, + message: `Updated briefing card "${card.title}"`, + entityType: "plugin:briefs:card", + entityId: card.id, + metadata: { + userId: card.userId, + state: card.state, + rootIssueId: card.rootIssueId, + summaryStatus: card.summaryStatus, + }, + }); + return { card }; + }); + + ctx.actions.register("pin-card", async (params) => { + const input = pinBriefCardInputSchema.parse(params); + await store.setPinned(input); + await ctx.activity.log({ + companyId: input.companyId, + message: `${input.pinned ? "Pinned" : "Unpinned"} briefing card`, + entityType: "plugin:briefs:card", + entityId: input.cardId, + metadata: { userId: input.userId, pinned: input.pinned }, + }); + return { ok: true }; + }); + + ctx.actions.register("update-preferences", async (params) => { + const input = updateBriefPreferencesInputSchema.parse(params); + const preferences = briefPreferencesSchema.parse(input); + await store.upsertPreferences(preferences); + await ctx.activity.log({ + companyId: preferences.companyId, + message: "Updated briefing preferences", + entityType: "plugin:briefs:preferences", + entityId: preferences.userId, + metadata: preferences, + }); + return { preferences }; + }); + }, + + async onHealth() { + return { status: "ok", message: "Briefs deterministic card service ready" }; + }, +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/plugin-briefs/tests/deterministic-card-service.spec.ts b/packages/plugins/plugin-briefs/tests/deterministic-card-service.spec.ts new file mode 100644 index 00000000000..ae77d56c146 --- /dev/null +++ b/packages/plugins/plugin-briefs/tests/deterministic-card-service.spec.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import { + buildDeterministicBriefCard, + dedupeBriefCursorEvents, + filterExpiredBriefCards, + isBriefTreeRelevantToUser, + resolveBriefCardState, + selectRelevantBriefTrees, + sortBriefCards, + type BriefsIssueInput, + type BriefsSourceBundle, +} from "../src/deterministic-card-service.js"; + +const companyId = "company-1"; +const userId = "user-1"; +const now = "2026-05-22T12:00:00.000Z"; + +function ids() { + let counter = 0; + return () => `00000000-0000-4000-8000-${String(++counter).padStart(12, "0")}`; +} + +function issue(overrides: Partial = {}): BriefsIssueInput { + return { + id: "issue-root", + companyId, + parentId: null, + title: "Briefs plugin planning", + identifier: "PAP-2381", + status: "in_progress", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + createdByUserId: userId, + updatedAt: "2026-05-22T11:30:00.000Z", + createdAt: "2026-05-20T11:30:00.000Z", + ...overrides, + }; +} + +function bundle(overrides: Partial = {}): BriefsSourceBundle { + return { + companyId, + userId, + rootIssueId: "issue-root", + issues: [issue()], + relations: {}, + activeRuns: {}, + comments: [], + documents: [], + interactions: [], + approvals: [], + ...overrides, + }; +} + +describe("deterministic Briefs cards", () => { + it("rejects cross-company source data before producing a card", () => { + expect(() => buildDeterministicBriefCard(bundle({ + issues: [issue({ companyId: "company-2" })], + }), { now, idFactory: ids() })).toThrow(/another company/); + }); + + it("uses user involvement and the discovery window for source selection", () => { + const relevant = bundle({ + comments: [{ + id: "comment-1", + companyId, + issueId: "issue-root", + authorUserId: userId, + body: "Please continue.", + createdAt: "2026-05-22T10:00:00.000Z", + }], + }); + const irrelevant = bundle({ + rootIssueId: "other-root", + issues: [issue({ + id: "other-root", + identifier: "PAP-10", + createdByUserId: "someone-else", + updatedAt: "2026-05-22T10:00:00.000Z", + })], + }); + const stale = bundle({ + rootIssueId: "stale-root", + issues: [issue({ + id: "stale-root", + createdByUserId: userId, + createdAt: "2026-04-01T09:00:00.000Z", + updatedAt: "2026-04-01T10:00:00.000Z", + })], + }); + + expect(isBriefTreeRelevantToUser(relevant)).toBe(true); + expect(selectRelevantBriefTrees({ + companyId, + userId, + candidateTrees: [relevant, irrelevant, stale], + now, + })).toEqual([relevant]); + }); + + it("does not promote intra-tree sequencing blockers to card-level blocked", () => { + const child = issue({ + id: "issue-child", + parentId: "issue-root", + identifier: "PAP-9961", + title: "Data model", + status: "blocked", + updatedAt: "2026-05-22T11:45:00.000Z", + }); + + const card = buildDeterministicBriefCard(bundle({ + issues: [issue(), child], + relations: { + "issue-child": { + blockedBy: [{ id: "issue-root", companyId, status: "in_progress", identifier: "PAP-2381" }], + }, + }, + }), { now, idFactory: ids() }); + + expect(card.state).toBe("live"); + expect(card.sources.find((source) => source.sourceId === "issue-child")).toMatchObject({ + rightTag: "blocked", + isIntraTreeBlocked: true, + }); + }); + + it("gives out-of-tree blockers precedence over waiting and live signals", () => { + const blocked = issue({ + id: "issue-child", + parentId: "issue-root", + status: "blocked", + assigneeUserId: userId, + updatedAt: "2026-05-22T11:45:00.000Z", + }); + + expect(resolveBriefCardState(bundle({ + issues: [issue(), blocked], + relations: { + "issue-child": { + blockedBy: [{ id: "external-blocker", companyId, status: "todo", identifier: "PAP-9999" }], + }, + }, + activeRuns: { + "issue-root": [{ + id: "run-1", + companyId, + issueId: "issue-root", + status: "running", + startedAt: "2026-05-22T11:50:00.000Z", + }], + }, + }), { now }).state).toBe("blocked"); + }); + + it("keeps summary failure orthogonal to deterministic state", () => { + const card = buildDeterministicBriefCard(bundle(), { + now, + summaryStatus: "fallback", + summaryFailureReason: "budget_capped", + idFactory: ids(), + }); + + expect(card.state).toBe("live"); + expect(card.summaryStatus).toBe("fallback"); + expect(card.snapshot.summaryFailureReason).toBe("budget_capped"); + }); + + it("keeps pinned cards stable while unpinned cards expire predictably", () => { + const unpinned = buildDeterministicBriefCard(bundle(), { + now, + idFactory: ids(), + preferences: { retentionDays: 1 }, + }); + const pinned = buildDeterministicBriefCard(bundle(), { + now, + pinned: true, + idFactory: ids(), + preferences: { retentionDays: 1 }, + }); + + expect(unpinned.expiresAt).toBe("2026-05-23T11:30:00.000Z"); + expect(pinned.expiresAt).toBeNull(); + expect(filterExpiredBriefCards([unpinned, pinned], "2026-05-24T00:00:00.000Z")).toEqual([pinned]); + }); + + it("sorts pinned cards first and then by recent meaningful activity", () => { + const olderPinned = buildDeterministicBriefCard(bundle({ + issues: [issue({ id: "old", updatedAt: "2026-05-22T09:00:00.000Z" })], + rootIssueId: "old", + }), { now, pinned: true, idFactory: ids() }); + const newerUnpinned = buildDeterministicBriefCard(bundle({ + issues: [issue({ id: "new", updatedAt: "2026-05-22T11:00:00.000Z" })], + rootIssueId: "new", + }), { now, idFactory: ids() }); + + expect(sortBriefCards([newerUnpinned, olderPinned]).map((card) => card.id)).toEqual([ + olderPinned.id, + newerUnpinned.id, + ]); + }); + + it("deduplicates cursor overlap events by fingerprint", () => { + expect(dedupeBriefCursorEvents([ + { id: "a", fingerprint: "issue-1:update", eventAt: "2026-05-22T10:00:00.000Z" }, + { id: "b", fingerprint: "issue-2:update", eventAt: "2026-05-22T11:00:00.000Z" }, + { id: "c", fingerprint: "issue-2:update", eventAt: "2026-05-22T11:05:00.000Z" }, + ], ["issue-1:update"])).toEqual({ + freshEvents: [ + { id: "b", fingerprint: "issue-2:update", eventAt: "2026-05-22T11:00:00.000Z" }, + ], + dedupeState: ["issue-1:update", "issue-2:update"], + lastSeenAt: "2026-05-22T11:00:00.000Z", + }); + }); +}); diff --git a/packages/plugins/plugin-briefs/tsconfig.json b/packages/plugins/plugin-briefs/tsconfig.json new file mode 100644 index 00000000000..63913161d36 --- /dev/null +++ b/packages/plugins/plugin-briefs/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/plugins/plugin-briefs/vitest.config.ts b/packages/plugins/plugin-briefs/vitest.config.ts new file mode 100644 index 00000000000..649a293ef95 --- /dev/null +++ b/packages/plugins/plugin-briefs/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +});