Skip to content

Commit a9d5acd

Browse files
committed
feat: group specialist session artifacts into output bundles
When a specialist produces 2+ artifacts in a single chat session, they are automatically grouped into a named bundle via ArtifactService.createBundle(). Bundle title follows "SpecialistName: Topic" format. The agents page specialist cards now display bundles as compact rows with title and count badge alongside standalone artifacts. Dashboard UI already handled bundles — no changes needed.
1 parent fb41ba6 commit a9d5acd

File tree

4 files changed

+384
-19
lines changed

4 files changed

+384
-19
lines changed

apps/desktop/src/features/agents/components/SpecialistCard.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,28 @@ import { getSpecialistColors } from "@/features/agents/specialist-meta";
77
import { formatRelativeTime } from "@/lib/utils/output-labels";
88
import { cleanArtifactTitle } from "@/features/dashboard/lib/clean-artifact-title";
99

10+
export interface SpecialistBundleGroup {
11+
bundleId: string;
12+
title: string;
13+
artifacts: ArtifactRecord[];
14+
createdAt: string;
15+
}
16+
1017
interface SpecialistCardProps {
1118
specialist: SpecialistAgent;
1219
onChat: (specialistId: string) => void;
1320
recentOutputs?: ArtifactRecord[] | undefined;
21+
recentBundles?: SpecialistBundleGroup[] | undefined;
1422
onOutputNavigate?: ((artifact: ArtifactRecord) => void) | undefined;
1523
}
1624

17-
export function SpecialistCard({ specialist, onChat, recentOutputs, onOutputNavigate }: SpecialistCardProps) {
25+
export function SpecialistCard({ specialist, onChat, recentOutputs, recentBundles, onOutputNavigate }: SpecialistCardProps) {
1826
const Icon = resolveSpecialistIcon(specialist.icon);
1927
const isManager = specialist.category === "manager";
2028
const colors = getSpecialistColors(specialist.id);
2129
const outputs = recentOutputs?.length ? recentOutputs : [];
30+
const bundles = recentBundles?.length ? recentBundles : [];
31+
const hasOutputs = outputs.length > 0 || bundles.length > 0;
2232

2333
return (
2434
<article
@@ -83,8 +93,8 @@ export function SpecialistCard({ specialist, onChat, recentOutputs, onOutputNavi
8393
))}
8494
</div>
8595

86-
{/* Recent outputs — only shown when outputs exist */}
87-
{outputs.length > 0 ? (
96+
{/* Recent outputs — bundles and standalone artifacts */}
97+
{hasOutputs ? (
8898
<div className="mt-4 rounded-lg border border-border/15 bg-muted/20 px-3 py-2.5 dark:border-white/[0.03] dark:bg-white/[0.02]">
8999
<div className="mb-1.5 flex items-center gap-1.5">
90100
<PackageIcon className="size-3 shrink-0 text-muted-foreground/50" />
@@ -93,6 +103,36 @@ export function SpecialistCard({ specialist, onChat, recentOutputs, onOutputNavi
93103
</span>
94104
</div>
95105
<div className="flex flex-col gap-0.5">
106+
{/* Bundle rows */}
107+
{bundles.map((bundle) => (
108+
<div
109+
key={bundle.bundleId}
110+
role="button"
111+
tabIndex={0}
112+
className="group/output flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-[11px] transition-colors hover:bg-card dark:hover:bg-white/[0.04]"
113+
onClick={() => {
114+
if (bundle.artifacts[0]) onOutputNavigate?.(bundle.artifacts[0]);
115+
}}
116+
onKeyDown={(e) => {
117+
if (e.key === "Enter" || e.key === " ") {
118+
e.preventDefault();
119+
if (bundle.artifacts[0]) onOutputNavigate?.(bundle.artifacts[0]);
120+
}
121+
}}
122+
>
123+
<PackageIcon className={cn("size-3 shrink-0", colors.iconText || "text-primary")} />
124+
<span className="min-w-0 flex-1 truncate font-medium text-foreground/70 group-hover/output:text-foreground">
125+
{bundle.title}
126+
</span>
127+
<span className="shrink-0 rounded-full bg-muted/50 px-1.5 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground">
128+
{bundle.artifacts.length}
129+
</span>
130+
<span className="shrink-0 text-muted-foreground/40">
131+
{formatRelativeTime(bundle.createdAt)}
132+
</span>
133+
</div>
134+
))}
135+
{/* Standalone artifact rows */}
96136
{outputs.map((artifact) => (
97137
<div
98138
key={artifact.artifactId}

apps/desktop/src/features/agents/components/SpecialistTeamBrowser.tsx

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { SidecarClient } from "@/lib/sidecar/client";
55
import { getActionMapping } from "@/lib/utils/action-map";
66
import { deduplicateSpecialistOutputs } from "../lib/deduplicate-specialist-outputs";
77
import { SpecialistCard } from "./SpecialistCard";
8+
import type { SpecialistBundleGroup } from "./SpecialistCard";
89

910
/** Max recent outputs shown per specialist card */
1011
const MAX_OUTPUTS_PER_SPECIALIST = 3;
@@ -15,11 +16,22 @@ interface SpecialistTeamBrowserProps {
1516
onSpecialistChat?: ((specialistId: string) => void) | undefined;
1617
}
1718

19+
function deriveBundleTitleFromArtifacts(artifacts: ArtifactRecord[]): string {
20+
if (artifacts.length === 0) return "Bundle";
21+
const types = new Set(artifacts.map((a) => a.type));
22+
if (types.size === 1) {
23+
const type = artifacts[0]!.type;
24+
return type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) + " Bundle";
25+
}
26+
return artifacts[0]!.title.split(" — ")[0] ?? "Bundle";
27+
}
28+
1829
export function SpecialistTeamBrowser({ client, agentId, onSpecialistChat }: SpecialistTeamBrowserProps) {
1930
const [specialists, setSpecialists] = useState<SpecialistAgent[]>([]);
2031
const [isLoading, setIsLoading] = useState(true);
2132
const [error, setError] = useState<string | null>(null);
2233
const [recentOutputsMap, setRecentOutputsMap] = useState<Record<string, ArtifactRecord[]>>({});
34+
const [recentBundlesMap, setRecentBundlesMap] = useState<Record<string, SpecialistBundleGroup[]>>({});
2335

2436
useEffect(() => {
2537
let cancelled = false;
@@ -74,18 +86,79 @@ export function SpecialistTeamBrowser({ client, agentId, onSpecialistChat }: Spe
7486
// Group all artifacts by specialist
7587
const raw: Record<string, ArtifactRecord[]> = {};
7688
for (const artifact of page.items) {
77-
const specialistId = artifact.createdBy;
78-
if (!raw[specialistId]) {
79-
raw[specialistId] = [];
89+
const sid = artifact.createdBy;
90+
if (!raw[sid]) {
91+
raw[sid] = [];
8092
}
81-
raw[specialistId].push(artifact);
93+
raw[sid].push(artifact);
8294
}
83-
// Deduplicate by case-insensitive title, then limit per specialist
84-
const map: Record<string, ArtifactRecord[]> = {};
85-
for (const [specialistId, artifacts] of Object.entries(raw)) {
86-
map[specialistId] = deduplicateSpecialistOutputs(artifacts).slice(0, MAX_OUTPUTS_PER_SPECIALIST);
95+
96+
// For each specialist, separate bundled from standalone artifacts
97+
const standaloneMap: Record<string, ArtifactRecord[]> = {};
98+
const bundlesMap: Record<string, SpecialistBundleGroup[]> = {};
99+
100+
for (const [sid, artifacts] of Object.entries(raw)) {
101+
const bundleArtifacts = new Map<string, ArtifactRecord[]>();
102+
const standalone: ArtifactRecord[] = [];
103+
104+
for (const artifact of artifacts) {
105+
if (artifact.bundleId) {
106+
const existing = bundleArtifacts.get(artifact.bundleId) ?? [];
107+
existing.push(artifact);
108+
bundleArtifacts.set(artifact.bundleId, existing);
109+
} else {
110+
standalone.push(artifact);
111+
}
112+
}
113+
114+
// Build bundle groups
115+
const groups: SpecialistBundleGroup[] = Array.from(bundleArtifacts.entries()).map(
116+
([bundleId, arts]) => {
117+
const sorted = arts.sort(
118+
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
119+
);
120+
return {
121+
bundleId,
122+
title: deriveBundleTitleFromArtifacts(sorted),
123+
artifacts: sorted,
124+
createdAt: sorted[0]!.createdAt,
125+
};
126+
},
127+
);
128+
129+
// Deduplicate standalone, then combine and limit total to MAX_OUTPUTS_PER_SPECIALIST
130+
const dedupedStandalone = deduplicateSpecialistOutputs(standalone).slice(0, MAX_OUTPUTS_PER_SPECIALIST);
131+
132+
// Merge bundles + standalone sorted by time, then limit
133+
type Entry =
134+
| { kind: "bundle"; group: SpecialistBundleGroup; ts: number }
135+
| { kind: "standalone"; artifact: ArtifactRecord; ts: number };
136+
137+
const entries: Entry[] = [
138+
...groups.map((g) => ({ kind: "bundle" as const, group: g, ts: new Date(g.createdAt).getTime() })),
139+
...dedupedStandalone.map((a) => ({ kind: "standalone" as const, artifact: a, ts: new Date(a.createdAt).getTime() })),
140+
];
141+
entries.sort((a, b) => b.ts - a.ts);
142+
143+
const limitedStandalone: ArtifactRecord[] = [];
144+
const limitedBundles: SpecialistBundleGroup[] = [];
145+
let count = 0;
146+
for (const entry of entries) {
147+
if (count >= MAX_OUTPUTS_PER_SPECIALIST) break;
148+
if (entry.kind === "bundle") {
149+
limitedBundles.push(entry.group);
150+
} else {
151+
limitedStandalone.push(entry.artifact);
152+
}
153+
count++;
154+
}
155+
156+
standaloneMap[sid] = limitedStandalone;
157+
bundlesMap[sid] = limitedBundles;
87158
}
88-
setRecentOutputsMap(map);
159+
160+
setRecentOutputsMap(standaloneMap);
161+
setRecentBundlesMap(bundlesMap);
89162
})
90163
.catch(() => {
91164
// Silently ignore — recent outputs are non-critical
@@ -180,6 +253,7 @@ export function SpecialistTeamBrowser({ client, agentId, onSpecialistChat }: Spe
180253
specialist={manager}
181254
onChat={handleChat}
182255
recentOutputs={recentOutputsMap[manager.id]}
256+
recentBundles={recentBundlesMap[manager.id]}
183257
onOutputNavigate={handleOutputNavigate}
184258
/>
185259
</div>
@@ -201,6 +275,7 @@ export function SpecialistTeamBrowser({ client, agentId, onSpecialistChat }: Spe
201275
specialist={specialist}
202276
onChat={handleChat}
203277
recentOutputs={recentOutputsMap[specialist.id]}
278+
recentBundles={recentBundlesMap[specialist.id]}
204279
onOutputNavigate={handleOutputNavigate}
205280
/>
206281
))}

packages/sidecar/src/artifact-extractor/extractor.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ArtifactRecord, ArtifactService, CreateArtifactOptions } from "@opengoat/core";
1+
import type { ArtifactRecord, ArtifactService, ArtifactType, CreateArtifactOptions } from "@opengoat/core";
22
import type { OpenGoatPaths } from "@opengoat/core";
33
import { detectSections, matchHeadingToOutputType } from "./content-detector.ts";
44
import { mapOutputTypeToArtifactType } from "./output-type-mapper.ts";
@@ -15,28 +15,55 @@ export interface ExtractionContext {
1515
export interface ExtractionResult {
1616
artifacts: ArtifactRecord[];
1717
skipped: number;
18+
bundleId?: string;
1819
}
1920

2021
interface ExtractionDeps {
21-
artifactService: Pick<ArtifactService, "createArtifact">;
22+
artifactService: Pick<ArtifactService, "createArtifact" | "createBundle">;
2223
opengoatPaths: OpenGoatPaths;
23-
specialist: { id: string; outputTypes: string[] };
24+
specialist: { id: string; name: string; outputTypes: string[] };
25+
}
26+
27+
interface MatchedSection {
28+
heading: string;
29+
content: string;
30+
artifactType: ArtifactType;
31+
}
32+
33+
/**
34+
* Derives a bundle title from the specialist name and matched sections.
35+
* Format: "SpecialistName: Topic"
36+
*/
37+
function deriveBundleTitle(specialistName: string, sections: MatchedSection[]): string {
38+
if (sections.length === 0) return specialistName;
39+
40+
const types = new Set(sections.map((s) => s.artifactType));
41+
if (types.size === 1) {
42+
const typeName = sections[0].artifactType
43+
.replace(/_/g, " ")
44+
.replace(/\b\w/g, (c) => c.toUpperCase());
45+
return `${specialistName}: ${typeName} Bundle`;
46+
}
47+
48+
return `${specialistName}: ${sections[0].heading}`;
2449
}
2550

2651
/**
2752
* Extracts artifacts from specialist chat text.
2853
*
29-
* Flow: detectSections → matchHeadingToOutputType → mapOutputTypeToArtifactType → createArtifact
54+
* Flow: detectSections → matchHeadingToOutputType → mapOutputTypeToArtifactType
55+
* → (if 2+) createBundle → createArtifact with bundleId
3056
*/
3157
export async function extractArtifacts(
3258
text: string,
3359
context: ExtractionContext,
3460
deps: ExtractionDeps,
3561
): Promise<ExtractionResult> {
3662
const sections = detectSections(text);
37-
const artifacts: ArtifactRecord[] = [];
63+
const matched: MatchedSection[] = [];
3864
let skipped = 0;
3965

66+
// Pass 1: Collect matched sections without creating artifacts
4067
for (const section of sections) {
4168
const matchedOutputType = matchHeadingToOutputType(
4269
section.heading,
@@ -54,14 +81,36 @@ export async function extractArtifacts(
5481
continue;
5582
}
5683

84+
matched.push({
85+
heading: section.heading,
86+
content: section.content,
87+
artifactType,
88+
});
89+
}
90+
91+
// Bundle decision: create bundle if 2+ matched sections
92+
let bundleId: string | undefined;
93+
if (matched.length >= 2) {
94+
const bundleTitle = deriveBundleTitle(deps.specialist.name, matched);
95+
const bundle = await deps.artifactService.createBundle(
96+
deps.opengoatPaths,
97+
{ projectId: context.agentId, title: bundleTitle },
98+
);
99+
bundleId = bundle.bundleId;
100+
}
101+
102+
// Pass 2: Create artifacts with optional bundleId
103+
const artifacts: ArtifactRecord[] = [];
104+
for (const section of matched) {
57105
const options: CreateArtifactOptions = {
58106
projectId: context.agentId,
59107
title: section.heading,
60-
type: artifactType,
108+
type: section.artifactType,
61109
format: "markdown",
62110
contentRef: `chat://${context.sessionId}/${context.messageIndex ?? 0}`,
63111
createdBy: context.specialistId,
64112
content: section.content,
113+
...(bundleId ? { bundleId } : {}),
65114
...(context.objectiveId ? { objectiveId: context.objectiveId } : {}),
66115
...(context.runId ? { runId: context.runId } : {}),
67116
};
@@ -73,5 +122,5 @@ export async function extractArtifacts(
73122
artifacts.push(record);
74123
}
75124

76-
return { artifacts, skipped };
125+
return { artifacts, skipped, bundleId };
77126
}

0 commit comments

Comments
 (0)