diff --git a/CLAUDE.md b/CLAUDE.md index 246c7d8..a9acbdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -467,20 +467,33 @@ first; a session can switch mid-thread), so past conversations are tagged too Tags are **derived, never authored** (`frontend/src/lib/tags.ts`): `useTags()` aggregates the projects/services on disk, the stack + `package.json` keywords a -project declares, and every mention carried by a conversation, memory, plan or -project-local skill into one list of `:` tags with per-source counts. -Nothing creates, renames or deletes a tag — rename a directory and the tag -follows; a tag that is referenced but has no directory is flagged `exists: -false` rather than hidden, since that is usually a rename something still points -at. +project declares, the skills/agents catalogs, and every mention carried by a +conversation, memory, plan or project-local skill into one list of +`:` tags with per-source counts. Nothing creates, renames or deletes +a tag — rename a directory and the tag follows; a tag that is referenced but has +no directory is flagged `exists: false` rather than hidden, since that is usually +a rename something still points at. + +Two axes: **where** work happened (`project`/`service`/`stack`) and **what it was +done with** (`skill`/`agent`). The tool axis is mined from the transcripts, not +declared — a conversation card carries `skillsUsed` (the `Skill(…)` calls, its +subagents' included, since a child transcript is never listed on its own) and +`agentsUsed` (built in `main._agents_by_conversation` from the same run list the +agents catalog counts, so a card can't disagree with the agent page: a subagent +run keys to its *parent* conversation, a cron/manual agent session to the +conversation it is). Population is "everything on disk + the built-ins that +actually ran", straight off the two catalogs, so a built-in skill/agent is a tag +with `exists: false` and no file to open. Those pills render on the conversation +**detail** only (`SkillTags`/`AgentTags` in `ConvMetaBits`, capped at 5 with an +expanding `+N`) — the list cards stay about location. What a human *does* own is a tag's presentation, stored per id in the settings store's `tagMeta` bag (server-side, like the rest of Settings): **colour** (a key into `TAG_COLORS`, `lib/tagStyles.ts`), **icon** (a key into the curated `TAG_ICONS` set, `lib/tagIcons.tsx`) and **description**. Unset fields fall back to the tag kind's shipped look — projects primary/`Tag`, services sky/`Server`, -stacks violet/`Blocks` — which is why an untouched homelab looks exactly as it -did before the page existed. +stacks violet/`Blocks`, skills amber/`Wrench`, agents emerald/`Bot` — which is +why an untouched homelab looks exactly as it did before the page existed. `useTagPresentation()` is the queryless resolver every surface uses, so a tag looks the same on a conversation card (`ConvMetaBits`), in a memory/plan list, @@ -489,6 +502,12 @@ the composer: it's the text that goes out in the prompt's `Work in the following homelab location(s):` bullet, so editing it here changes what a spawned agent actually reads — the Tags editor previews that exact line. +Skill/agent tags are composer chips too (`toolMentions`/`toolTags`, offered in +both the spawn and resume composers, recently-used first). They deliberately do +*not* join the location block: selecting one adds a `Use the following in this +task:` bullet naming the skill/subagent and its description — the difference +between a session invoking `open-pr` and one hand-rolling a PR body. + The icon set is curated rather than lucide's full `icons` barrel on purpose: tag glyphs render synchronously all over the app, so they can't come from a lazy chunk, and pulling ~1500 icons into the main bundle to serve one settings page diff --git a/backend/main.py b/backend/main.py index 9cad17f..bc19bc0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1104,6 +1104,81 @@ def _attach_subagents(ap: pathlib.Path, data: dict) -> dict: return data +_skills_by_path_cache: tuple[int, dict[str, dict[str, int]]] | None = None + + +def _skills_by_path() -> dict[str, dict[str, int]]: + """transcript path → ``{skill: calls}``, decoded once per store version. + + The indexer keeps a transcript's skill map in its **own column** (it feeds + the catalog rollup), not inside the summary blob — so a stored summary has + no `skills` key at all, and this is the join that puts it back. + """ + global _skills_by_path_cache + hit = _skills_by_path_cache + if hit and hit[0] == store.summaries_version: + return hit[1] + out: dict[str, dict[str, int]] = {} + for path, raw in store.all_transcript_skill_rows(): + try: + contrib = json.loads(raw or "{}") + except ValueError: + continue + counts = {name: d.get("count") or 0 for name, d in contrib.items()} + if counts: + out[path] = counts + _skills_by_path_cache = (store.summaries_version, out) + return out + + +def _skills_used(paths: list[str], extra: dict | None = None) -> list[str]: + """Skill names used across `paths`, busiest first. + + Callers pass a conversation *and its subagent transcripts*: a `Skill(...)` + run by a spawned Explore is work this conversation caused, and the child is + never listed on its own — so hiding its skills would lose them entirely. + `extra` folds in a freshly parsed ``{skill: {count, …}}`` map for a + transcript the store may not have re-read yet (the detail view parses live). + """ + by_path = _skills_by_path() + counts: dict[str, int] = {} + for p in paths: + for name, n in by_path.get(p, {}).items(): + counts[name] = counts.get(name, 0) + n + for name, d in (extra or {}).items(): + counts[name] = counts.get(name, 0) + (d.get("count") or 0) + return sorted(counts, key=lambda n: (-counts[n], n)) + + +_agents_by_conv_cache: tuple[tuple[int, int], dict[str, list[str]]] | None = None + + +def _agents_by_conversation() -> dict[str, list[str]]: + """conversation id → the agent types that ran in it, busiest first. + + Built from the same run list the agents catalog counts (`_agent_runs`) — + not from a second pass over the `Task` calls — so a conversation card can + never disagree with the agent page. That list already resolves both origins: + a subagent run is keyed to its *parent* conversation (where its Task card + lives), and a cron/manual agent session to the conversation it *is*. + """ + global _agents_by_conv_cache + key = (store.summaries_version, meta_store.version) + hit = _agents_by_conv_cache + if hit and hit[0] == key: + return hit[1] + counts: dict[str, dict[str, int]] = {} + for r in _agent_runs(): + cid, name = r.get("conversationId"), r.get("agent") + if not cid or not name: + continue + c = counts.setdefault(cid, {}) + c[name] = c.get(name, 0) + 1 + out = {cid: sorted(c, key=lambda n: (-c[n], n)) for cid, c in counts.items()} + _agents_by_conv_cache = (key, out) + return out + + def _conv_card(path: str, s: dict) -> dict: """Compact conversation reference used when cross-linking from a memory.""" return { @@ -1163,25 +1238,32 @@ def _conversation_cards(full: bool) -> list[dict]: summaries = store.all_summaries() # Group subagent summaries under their parent so we can roll their usage up. children: dict[str, list[dict]] = {} + child_paths: dict[str, list[str]] = {} for path, s in summaries: if s.get("isSidechain"): pp = _parent_path_of(path) if pp: children.setdefault(pp, []).append(s) + child_paths.setdefault(pp, []).append(path) keys = _CARD_KEYS_FULL if full else _CARD_KEYS # A session resumed across working dirs has one transcript file per cwd — # collapse those so the conversation shows up once, not once per copy. out = [] + agents_by_conv = _agents_by_conversation() for path, s in _dedup_by_session( [(p, s) for p, s in summaries if not s.get("isSidechain")] ): if not s.get("messages"): continue # skip empty transcripts + skills_used = _skills_used([path, *child_paths.get(path, [])]) s = _rollup_children(s, children.get(path, [])) m = _conv_meta(s) - out.append({"id": _conv_id(path), **{k: s.get(k) for k in keys}, + cid = _conv_id(path) + out.append({"id": cid, **{k: s.get(k) for k in keys}, "title": _conv_title(s), "runningAgents": _running_agents(s, m.get("state")), + "skillsUsed": skills_used, + "agentsUsed": agents_by_conv.get(cid, []), "meta": m}) return out @@ -1271,17 +1353,23 @@ def conversation_summary(id: str): if not s: raise HTTPException(404, "summary not indexed yet") children: list[dict] = [] + paths = [str(ap)] sub_dir = ap.with_suffix("") / "subagents" if sub_dir.is_dir(): for child in sorted(sub_dir.glob("*.jsonl")): + paths.append(str(child)) cs = store.get_summary(str(child)) if cs: children.append(cs) + skills_used = _skills_used(paths) s = _rollup_children(s, children) m = _conv_meta(s) return {"id": id, **{k: s.get(k) for k in _CARD_KEYS}, "title": _conv_title(s), - "runningAgents": _running_agents(s, m.get("state")), "meta": m} + "runningAgents": _running_agents(s, m.get("state")), + "skillsUsed": skills_used, + "agentsUsed": _agents_by_conversation().get(id, []), + "meta": m} @app.get("/api/notifications", responses=_r(schemas.NotificationsResponse)) @@ -1526,6 +1614,15 @@ def conversation_detail(id: str): """Full parsed thread (tools collapsible client-side) + per-turn usage.""" ap = _resolve_transcript(id) data = _parse_full_cached(ap) + # The raw `{skill: {count, last}}` map is indexer-internal; the thread only + # needs the names it used, and those roll the subagents' calls in. This + # parse is fresher than the store (a live turn re-parses here first), so the + # conversation's own map comes off `data` and only the children are joined. + sub_dir = ap.with_suffix("") / "subagents" + kid_paths = ([str(c) for c in sorted(sub_dir.glob("*.jsonl"))] + if sub_dir.is_dir() else []) + data["skillsUsed"] = _skills_used(kid_paths, extra=data.get("skills")) + data["agentsUsed"] = _agents_by_conversation().get(id, []) data.pop("skills", None) data["id"] = id data["title"] = _conv_title(data) diff --git a/backend/schemas.py b/backend/schemas.py index 9618c44..2b3c4f4 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -398,6 +398,14 @@ class ConversationSummary(Schema): firstContextTokens: Optional[int] = None firstMessageTokens: Optional[int] = None tasks: Optional[list[Task]] = None + # What the conversation *used*, busiest first — the tool-side counterpart of + # `meta.projects`/`meta.services`, and what the tag registry derives its + # `skill:`/`agent:` tags from. Skills are the `Skill(...)` calls mined from + # the transcript (a spawned subagent's calls count as the parent's, since + # the child is never listed on its own); agents are the run list the agents + # catalog counts, so a card can't disagree with the agent page. + skillsUsed: Optional[list[str]] = None + agentsUsed: Optional[list[str]] = None meta: Optional[ConvMeta] = None diff --git a/frontend/openapi.json b/frontend/openapi.json index 68135ae..80133f0 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -5315,6 +5315,34 @@ ], "title": "Tasks" }, + "skillsUsed": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Skillsused" + }, + "agentsUsed": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Agentsused" + }, "meta": { "anyOf": [ { @@ -5726,6 +5754,34 @@ ], "title": "Tasks" }, + "skillsUsed": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Skillsused" + }, + "agentsUsed": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Agentsused" + }, "meta": { "anyOf": [ { diff --git a/frontend/src/business/composer/components/SpawnComposer.tsx b/frontend/src/business/composer/components/SpawnComposer.tsx index 296a74f..c9660f3 100644 --- a/frontend/src/business/composer/components/SpawnComposer.tsx +++ b/frontend/src/business/composer/components/SpawnComposer.tsx @@ -36,10 +36,12 @@ import { richUpload, richToUploaded, shortcutTags, + toolMentions, + toolTags, uploadedToRich, type MentionTag, } from "@/lib/richComposer"; -import { useCtas } from "@/lib/queries"; +import { useAgents, useCtas, useSkills } from "@/lib/queries"; import { defaultGuidelineIds } from "@/lib/guidelineDag"; import { useTagPresentation } from "@/lib/tags"; import { useSpawn } from "@/lib/spawn"; @@ -263,13 +265,22 @@ export function SpawnComposer() { const [guidelinesOn, setGuidelinesOn] = useState(true); const tagPresentation = useTagPresentation(); + // The tool axis of the mention list, from the same catalogs the Skills/Agents + // pages show: picking one tells the spawned session to reach for it. + const { data: skillsData } = useSkills(); + const { data: agentsData } = useAgents(); + const tools = useMemo( + () => toolMentions(skillsData?.skills, agentsData?.agents), + [skillsData, agentsData], + ); const composerTags = useMemo( () => [ ...shortcutTags(shortcuts, selected), ...locationTags(tags, tagPresentation), + ...toolTags(tools, tagPresentation), ...ctaTags(ctas ?? []), ], - [shortcuts, selected, tags, tagPresentation, ctas], + [shortcuts, selected, tags, tools, tagPresentation, ctas], ); const composePrompt = useMemo( () => richComposePrompt(shortcuts, selected, guidelinesOn, ctas ?? []), diff --git a/frontend/src/business/conversations/components/ConvMetaBits.tsx b/frontend/src/business/conversations/components/ConvMetaBits.tsx index 0c48e37..6905db7 100644 --- a/frontend/src/business/conversations/components/ConvMetaBits.tsx +++ b/frontend/src/business/conversations/components/ConvMetaBits.tsx @@ -18,7 +18,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Link, useNavigate } from "react-router-dom"; import { cn, fmtCost, fmtDateTime, fmtDuration, relTime } from "@/lib/utils"; -import { useTagPresentation, type TagKind } from "@/lib/tags"; +import { tagHref, useTagPresentation, type TagKind } from "@/lib/tags"; import type { AgentRunRef, ConvMeta, @@ -337,6 +337,99 @@ export function ServiceTags({ return ; } +/** How many tool pills a conversation shows before collapsing the rest. */ +const TOOL_TAGS_SHOWN = 5; + +/** + * The pills for what a conversation *worked with*: the skills it invoked and the + * agent types it spawned (or was launched as), mined from the transcript by the + * backend — `skillsUsed`/`agentsUsed` — and dressed by the tag registry, so + * `skill:`/`agent:` tags recoloured in Settings → Tags change here too. + * + * Unlike the location pills these are *derived from behaviour*, not declared: + * nothing writes them into the metadata sidecar, and a busy session easily + * touches a dozen skills — hence the cap, with the tail behind a "+N" that + * expands in place rather than a scroll or a tooltip nobody finds on a phone. + */ +function ToolTags({ + kind, + names, + linkify, +}: { + kind: "skill" | "agent"; + names?: string[] | null; + linkify: boolean; +}) { + const { look } = useTagPresentation(); + const [expanded, setExpanded] = useState(false); + if (!names || names.length === 0) return null; + const shown = expanded ? names : names.slice(0, TOOL_TAGS_SHOWN); + const hidden = names.length - shown.length; + const base = + "inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium"; + return ( + <> + {shown.map((name) => { + const { style, Icon } = look(kind, name); + const href = linkify ? tagHref(kind, name) : null; + const body = ( + <> + + {name} + + ); + return href ? ( + + {body} + + ) : ( + + {body} + + ); + })} + {hidden > 0 && ( + + )} + + ); +} + +/** Skill pills — see {@link ToolTags}. */ +export function SkillTags({ + skills, + linkify = false, +}: { + skills?: string[] | null; + linkify?: boolean; +}) { + return ; +} + +/** Agent pills — the delegation counterpart of {@link SkillTags}. */ +export function AgentTags({ + agents, + linkify = false, +}: { + agents?: string[] | null; + linkify?: boolean; +}) { + return ; +} + /** One worktree's timeline row inside the {@link WorktreeTag} hover card. */ function WorktreeRow({ w }: { w: WorktreeInfo }) { const lifespan = diff --git a/frontend/src/business/conversations/components/ResumeBox.tsx b/frontend/src/business/conversations/components/ResumeBox.tsx index 42844af..7db0e09 100644 --- a/frontend/src/business/conversations/components/ResumeBox.tsx +++ b/frontend/src/business/conversations/components/ResumeBox.tsx @@ -16,9 +16,17 @@ import { useModels, type Harness, } from "@/lib/models"; -import { ctaTags, richComposePrompt, richUpload, shortcutTags } from "@/lib/richComposer"; +import { + ctaTags, + richComposePrompt, + richUpload, + shortcutTags, + toolMentions, + toolTags, +} from "@/lib/richComposer"; import { defaultGuidelineIds } from "@/lib/guidelineDag"; -import { useCtas } from "@/lib/queries"; +import { useTagPresentation } from "@/lib/tags"; +import { useAgents, useCtas, useSkills } from "@/lib/queries"; import { SendMenuButton } from "./SendMenuButton"; /** @@ -162,9 +170,22 @@ export function ResumeBox({ // CTA chips sit alongside the guideline chips here too: the same prompts the // buttons under a finished thread send, foldable into a typed message. const { data: ctas } = useCtas(); + // Skill/agent chips, same as the spawn composer: a resumed session can be + // pointed at a skill it hasn't thought to use. + const tagPresentation = useTagPresentation(); + const { data: skillsData } = useSkills(); + const { data: agentsData } = useAgents(); + const tools = useMemo( + () => toolMentions(skillsData?.skills, agentsData?.agents), + [skillsData, agentsData], + ); const tags = useMemo( - () => [...shortcutTags(shortcuts, selected), ...ctaTags(ctas ?? [])], - [shortcuts, selected, ctas], + () => [ + ...shortcutTags(shortcuts, selected), + ...toolTags(tools, tagPresentation), + ...ctaTags(ctas ?? []), + ], + [shortcuts, selected, tools, tagPresentation, ctas], ); const composePrompt = useMemo( () => richComposePrompt(shortcuts, selected, guidelinesOn, ctas ?? []), diff --git a/frontend/src/business/conversations/pages/Conversation.tsx b/frontend/src/business/conversations/pages/Conversation.tsx index 0600eeb..72c485a 100644 --- a/frontend/src/business/conversations/pages/Conversation.tsx +++ b/frontend/src/business/conversations/pages/Conversation.tsx @@ -70,7 +70,7 @@ import { SubagentWidget, isAgentCall } from "@/business/conversations/components import { parseAskForm, AskFormWidget, type AskFormArgs } from "@/business/forms/components/FormWidget"; import { FormModal } from "@/business/forms/components/FormModal"; import { ContextGizmo } from "@/business/conversations/components/ContextWidget"; -import { BUCKET_COLORS, AgentBadge, ByToolBar, bucketForTool, CronBadge, CtaBadge, HarnessBadge, ThinkingBadge, ProjectTags, ServiceTags, StateBadge, StatusChecks, WorktreeTag } from "@/business/conversations/components/ConvMetaBits"; +import { BUCKET_COLORS, AgentBadge, AgentTags, ByToolBar, bucketForTool, CronBadge, CtaBadge, HarnessBadge, ThinkingBadge, ProjectTags, ServiceTags, SkillTags, StateBadge, StatusChecks, WorktreeTag } from "@/business/conversations/components/ConvMetaBits"; import { TaskBadge, TaskPanel } from "@/business/conversations/components/TaskPanel"; import { PlanStatusBadge, fmtMinutes } from "@/business/plans/pages/Plans"; import type { ArtefactItem, ConversationDetail, MemorySummary, PlanSummary, SubagentRef, ThreadItem } from "@/types"; @@ -809,6 +809,10 @@ function MetaHeader({ {showEffort && } + {/* What it worked *with*, next to where it worked — detail only: the list + cards stay about location, and these are long tails. */} + + {/* pi sessions don't exist on claude.ai — the deep link is claude-only. */} {convo.sessionId && meta.harness !== "pi" && ( diff --git a/frontend/src/business/skills/pages/Skills.tsx b/frontend/src/business/skills/pages/Skills.tsx index c64810f..bf83077 100644 --- a/frontend/src/business/skills/pages/Skills.tsx +++ b/frontend/src/business/skills/pages/Skills.tsx @@ -1,5 +1,5 @@ import { useMemo, useState, type ReactNode } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { RefreshCw, Wrench, @@ -199,6 +199,13 @@ export function Skills() { // Source filter (repo / project / built-in). Empty = everything. const [sources, setSources] = useState([]); + // `?skill=` narrows the catalog to one card — the deep link a `skill:` + // tag pill points at. The page has no per-skill route (a skill's "detail" is + // its SKILL.md in the editor), so this is what makes such a pill land + // somewhere specific instead of on a 40-row list. + const [params, setParams] = useSearchParams(); + const focused = params.get("skill"); + const counts = useMemo(() => { const c: Record = { repo: 0, project: 0, builtin: 0 }; for (const s of skills ?? []) c[sourceKind(s)]++; @@ -208,6 +215,7 @@ export function Skills() { const rows = useMemo(() => { const out: Row[] = []; for (const s of skills ?? []) { + if (focused && s.name !== focused) continue; if (sources.length && !sources.includes(sourceKind(s))) continue; out.push({ key: `${s.sourceKind}:${s.source}:${s.name}`, @@ -222,7 +230,7 @@ export function Skills() { // FuzzyList keeps this order for an empty query and re-ranks once you type. out.sort((a, b) => rowTime(b) - rowTime(a) || a.name.localeCompare(b.name)); return out; - }, [skills, sources]); + }, [skills, sources, focused]); // The usage bars are scaled to the busiest skill *in view*, so filtering to a // quiet corner still shows relative usage rather than a row of empty tracks. @@ -331,6 +339,17 @@ export function Skills() { ) ) : ( <> + {focused && ( + + )} {filterChips} items={rows} diff --git a/frontend/src/business/tags/pages/Tags.tsx b/frontend/src/business/tags/pages/Tags.tsx index b537771..1637160 100644 --- a/frontend/src/business/tags/pages/Tags.tsx +++ b/frontend/src/business/tags/pages/Tags.tsx @@ -7,6 +7,7 @@ import { Brain, ClipboardList, Wrench, + Bot, Folder, AlertTriangle, } from "lucide-react"; @@ -70,6 +71,7 @@ function TagUsageRow({ tag }: { tag: Tag }) { } n={u.memories} label="memories" /> } n={u.plans} label="plans" /> } n={u.skills} label="skills" /> + } n={u.agents} label="agents" /> } n={u.projects} label="projects" /> {tag.total === 0 && unused} @@ -319,13 +321,15 @@ export function TagsPage() {

- Every tag the app derives from your homelab — the{" "} + Every tag the app derives from your homelab — where work happens (the{" "} projects and{" "} services on disk, the{" "} - stacks they declare, and - everything conversations, memories, plans and skills refer to. Tags aren't - created or deleted here; pick one to set its colour, glyph and the description - it carries into a spawned prompt. + stacks they declare) and + what it's done with (the{" "} + skills conversations + invoke and the agents{" "} + they spawn). Tags aren't created or deleted here; pick one to set its colour, + glyph and the description it carries into a spawned prompt.

diff --git a/frontend/src/generated/model/conversationDetail.ts b/frontend/src/generated/model/conversationDetail.ts index 5e0b555..c14812a 100644 --- a/frontend/src/generated/model/conversationDetail.ts +++ b/frontend/src/generated/model/conversationDetail.ts @@ -47,6 +47,8 @@ export interface ConversationDetail { firstContextTokens?: number | null; firstMessageTokens?: number | null; tasks?: Task[] | null; + skillsUsed?: string[] | null; + agentsUsed?: string[] | null; meta?: ConvMeta | null; thread: ThreadItem[]; turns: TurnUsage[]; diff --git a/frontend/src/generated/model/conversationSummary.ts b/frontend/src/generated/model/conversationSummary.ts index 7ac2e56..575a34a 100644 --- a/frontend/src/generated/model/conversationSummary.ts +++ b/frontend/src/generated/model/conversationSummary.ts @@ -40,5 +40,7 @@ export interface ConversationSummary { firstContextTokens?: number | null; firstMessageTokens?: number | null; tasks?: Task[] | null; + skillsUsed?: string[] | null; + agentsUsed?: string[] | null; meta?: ConvMeta | null; } diff --git a/frontend/src/lib/richComposer.tsx b/frontend/src/lib/richComposer.tsx index 3659c13..a043ca9 100644 --- a/frontend/src/lib/richComposer.tsx +++ b/frontend/src/lib/richComposer.tsx @@ -18,6 +18,13 @@ export interface MentionTag { description: string; } +/** A skill or agent the composer can tell a session to reach for. */ +export interface ToolMention { + kind: "skill" | "agent"; + name: string; + description: string; +} + /** * Adapters that let the shared {@link RichInput} from `@gabvdl/ui` drive the two * `claude -p` composers (home {@link SpawnBox}, conversation {@link ResumeBox}). @@ -95,6 +102,57 @@ export function locationTags( }); } +/** + * The tool axis of the mention list: a chip per skill and per agent, carrying + * `skill:` / `agent:` on the tag id — the same ids the tag registry + * derives from what conversations actually used, so a skill recoloured in + * Settings → Tags looks the same here, on the conversation it ran in, and in the + * registry. + * + * Selecting one doesn't run anything: it adds a line to the prompt naming the + * skill/subagent and what it's for (see {@link richComposePrompt}), which is the + * difference between a session using `open-pr` and one hand-rolling a PR body. + * + * Recently-used first — the head of the list is what the `#` menu shows before + * you type, and "what I reached for last" beats alphabetical there. + */ +export function toolMentions( + skills: { name: string; description: string; lastUsed?: string | null }[] = [], + agents: { name: string; description: string; lastRun?: string | null }[] = [], +): ToolMention[] { + const byRecency = (a: T, b: T) => + (b.at ?? "").localeCompare(a.at ?? ""); + return [ + ...skills.map((s) => ({ + kind: "skill" as const, name: s.name, + description: s.description, at: s.lastUsed, + })).sort(byRecency), + ...agents.map((a) => ({ + kind: "agent" as const, name: a.name, + description: a.description, at: a.lastRun, + })).sort(byRecency), + ].map(({ kind, name, description }) => ({ kind, name, description })); +} + +/** Map {@link ToolMention}s to RichInput toggle tags — see {@link toolMentions}. */ +export function toolTags( + tools: ToolMention[], + { look, describe }: TagPresentation, +): RichTag[] { + return tools.map((t) => { + const { style, Icon } = look(t.kind, t.name); + return { + id: `${t.kind}:${t.name}`, + label: t.name, + icon: , + kind: "toggle", + group: "list", + slug: t.name, + description: describe(t.kind, t.name, t.description), + } satisfies RichTag; + }); +} + /** * Map conversation CTAs to RichInput tags. They live in the scrollable list * (also reachable as `#complete`), carry their id on the tag as `cta:`, and @@ -174,6 +232,13 @@ export function richComposePrompt( const slug = t.id.slice(t.id.indexOf(":") + 1); return { path: `${kind}/${slug}`, description: t.description ?? slug }; }); + const tools = input.tags + .filter((t) => t.id.startsWith("skill:") || t.id.startsWith("agent:")) + .map((t) => ({ + kind: t.id.startsWith("skill:") ? ("skill" as const) : ("agent" as const), + name: t.id.slice(t.id.indexOf(":") + 1), + description: t.description ?? "", + })); const attachments = input.files.map((f) => ({ repoPath: richToUploaded(f).repoPath })); const lines = guidelinesOn ? guidelineLines(shortcuts, selected) : []; // CTA tags contribute their whole prompt file, resolved from the live list @@ -183,6 +248,6 @@ export function richComposePrompt( .map((t) => ctas.find((c) => c.id === t.id.slice(4))) .filter((c): c is Cta => !!c?.prompt) .map((c) => ({ label: c.label || c.name, prompt: c.prompt ?? "" })); - return composePrompt(input.text, lines, locations, attachments, picked); + return composePrompt(input.text, lines, locations, attachments, picked, tools); }; } diff --git a/frontend/src/lib/shortcuts.tsx b/frontend/src/lib/shortcuts.tsx index 2004dc1..dfa0ad0 100644 --- a/frontend/src/lib/shortcuts.tsx +++ b/frontend/src/lib/shortcuts.tsx @@ -25,6 +25,7 @@ export function composePrompt( tags: PromptTag[] = [], attachments: { repoPath: string }[] = [], ctas: { label: string; prompt: string }[] = [], + tools: { kind: "skill" | "agent"; name: string; description: string }[] = [], ): string { const lines: string[] = [text.trim()]; @@ -43,6 +44,19 @@ export function composePrompt( for (const t of tags) lines.push(`- \`${t.path}\` — ${t.description}`); } + // Skill/agent mentions: the "what to reach for" counterpart of the location + // block. Named explicitly because a session that doesn't know a skill exists + // will happily reimplement it by hand. + if (tools.length > 0) { + lines.push("", "Use the following in this task:"); + for (const t of tools) { + const what = t.kind === "skill" ? "skill" : "subagent"; + lines.push( + `- the \`${t.name}\` ${what}${t.description ? ` — ${t.description}` : ""}`, + ); + } + } + const clean = guidelines.map((g) => g.trim()).filter(Boolean); if (clean.length > 0) { lines.push("", "Guidelines:"); diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index 8766639..549ae61 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -1,6 +1,7 @@ import { useMemo } from "react"; -import { Blocks, Server, Tag as TagIcon, type LucideIcon } from "lucide-react"; +import { Blocks, Bot, Server, Tag as TagIcon, Wrench, type LucideIcon } from "lucide-react"; import { + useAgents, useConversationsAll, useMemories, usePlans, @@ -18,29 +19,43 @@ import { useSettings, type TagMeta } from "@/settings"; * Tags are **derived, not authored**: nothing in the app creates, renames or * deletes one. A tag exists because something in the homelab already refers to * it — a directory under `projects/`/`services/`, a stack/keyword a project - * declares, or a mention mined out of a conversation, memory or plan. That is - * why this module exposes no mutators for the tags themselves: the only thing a - * human owns is a tag's *presentation* (colour, glyph, description), which lives - * in settings under `tagMeta` and is merged in here. + * declares, a `SKILL.md`/agent definition under `.claude/`, or a mention mined + * out of a conversation, memory or plan. That is why this module exposes no + * mutators for the tags themselves: the only thing a human owns is a tag's + * *presentation* (colour, glyph, description), which lives in settings under + * `tagMeta` and is merged in here. + * + * Two axes, and they answer different questions: + * - **where** work happened — `project`/`service`, plus the `stack` a project + * declares; + * - **what it was done with** — `skill`/`agent`, mined from the conversations + * themselves (the `Skill(…)` calls a session made, the agent types it + * spawned or was launched as). Those two come off the conversation cards' + * `skillsUsed`/`agentsUsed`, so a tag's conversation count is measured, not + * declared. * * Consequences worth knowing: * - a tag can be referenced but not exist on disk (a conversation tagged with a - * project that has since been removed) — {@link Tag.exists} says which, and - * the page surfaces those separately rather than hiding them, because a - * dangling tag is usually a rename you forgot; + * project that has since been removed, or a built-in skill/agent that has no + * file in this repo) — {@link Tag.exists} says which, and the page surfaces + * those separately rather than hiding them, because a dangling tag is + * usually a rename you forgot; * - counts are what makes a tag worth styling at all, so they're computed per * source rather than as one total. */ /** Where a tag comes from. */ -export type TagKind = "project" | "service" | "stack"; +export type TagKind = "project" | "service" | "stack" | "skill" | "agent"; /** How many of each kind of record carry this tag. */ export interface TagUsage { conversations: number; memories: number; plans: number; + /** Skills a project ships — only ever non-zero for `project` tags. */ skills: number; + /** Agent definitions a project ships — likewise `project`-only. */ + agents: number; /** Projects declaring it — only ever non-zero for `stack` tags. */ projects: number; } @@ -56,7 +71,12 @@ export interface Tag { * is lowercased to fold `React`/`react` onto one tag. */ label: string; - /** Repo path (`projects/foo`), for the tags that name a directory. */ + /** + * Repo path, for the tags that name something on disk — `projects/foo` for a + * location, the skill's own directory for a `skill` tag, the agent's `.md` + * for an `agent` one. Null for a stack tag and for a built-in skill/agent, + * which have no file here. + */ path: string | null; /** The description the tag was derived with (before any override). */ derivedDescription: string; @@ -86,26 +106,50 @@ const KIND_DEFAULTS: Record = { project: { color: "primary", Icon: TagIcon }, service: { color: "sky", Icon: Server }, stack: { color: "violet", Icon: Blocks }, + // The "what it was done with" axis, kept visually apart from the location + // pills so a card doesn't read as if it worked on a project called `open-pr`. + skill: { color: "amber", Icon: Wrench }, + agent: { color: "emerald", Icon: Bot }, }; -export const TAG_KINDS: TagKind[] = ["project", "service", "stack"]; +export const TAG_KINDS: TagKind[] = ["project", "service", "stack", "skill", "agent"]; export const TAG_KIND_LABEL: Record = { project: "Project", service: "Service", stack: "Stack", + skill: "Skill", + agent: "Agent", }; /** `project` + `foo` → `project:foo`. */ export const tagId = (kind: TagKind, slug: string) => `${kind}:${slug}`; -/** The repo path a location tag points at (stack tags have none). */ +/** + * The repo path a location tag points at, purely from its id. Stack tags have + * none, and skill/agent paths can't be guessed (a skill lives in the repo's + * `.claude/skills/` *or* a project's, and a built-in lives nowhere) — those come + * off the catalogs instead, in {@link useTags}. + */ export function tagPath(kind: TagKind, slug: string): string | null { if (kind === "project") return `projects/${slug}`; if (kind === "service") return `services/${slug}`; return null; } +/** + * Where a tag's own page is, when it has one. The location tags own a route + * each; an agent has its catalog page; a skill has no detail route, so it + * deep-links into the catalog pre-filtered to itself. + */ +export function tagHref(kind: TagKind, slug: string): string | null { + if (kind === "project") return `/projects/${encodeURIComponent(slug)}`; + if (kind === "service") return `/services/${encodeURIComponent(slug)}`; + if (kind === "agent") return `/agents/${encodeURIComponent(slug)}`; + if (kind === "skill") return `/skills?skill=${encodeURIComponent(slug)}`; + return null; +} + /** Trim a derived description to something that reads as a single line. */ const truncate = (s: string, n = 90) => s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s; @@ -165,6 +209,8 @@ interface Draft { label: string; derivedDescription: string; exists: boolean; + /** Set for skill/agent tags, whose path can't be derived from the id. */ + path: string | null; usage: TagUsage; lastUsed: string | null; } @@ -174,6 +220,7 @@ const emptyUsage = (): TagUsage => ({ memories: 0, plans: 0, skills: 0, + agents: 0, projects: 0, }); @@ -195,6 +242,7 @@ export function useTags(): { tags: Tag[]; loading: boolean } { const memoriesQ = useMemories(); const plansQ = usePlans(); const skillsQ = useSkills(); + const agentsQ = useAgents(); const tagMeta = useSettings((s) => s.tagMeta); const projects = projectsQ.data; @@ -203,6 +251,7 @@ export function useTags(): { tags: Tag[]; loading: boolean } { const memories = memoriesQ.data; const plans = plansQ.data; const skills = skillsQ.data?.skills; + const agents = agentsQ.data?.agents; const drafts = useMemo(() => { const byId = new Map(); @@ -218,6 +267,7 @@ export function useTags(): { tags: Tag[]; loading: boolean } { label: kind === "stack" ? rawSlug.trim() : slug, derivedDescription: "", exists: false, + path: null, usage: emptyUsage(), lastUsed: null, }; @@ -252,6 +302,26 @@ export function useTags(): { tags: Tag[]; loading: boolean } { d.exists = true; d.derivedDescription = truncate(s.description || s.name || s.dir); } + // The tool axis. Both catalogs already include the built-ins a conversation + // used (`sourceKind: "builtin"`, no file here) alongside the definitions on + // disk, which is exactly the population a tag list wants: everything that + // can be invoked, marked by whether it's ours to edit. + for (const s of skills ?? []) { + const d = touch("skill", s.name); + if (!d) continue; + d.exists = s.sourceKind !== "builtin"; + d.path = s.dir ?? null; + // No fallback to the name: a card that says "screenshot — screenshot" is + // noise, and a built-in stub carries no description of its own. + d.derivedDescription = truncate(s.description || ""); + } + for (const a of agents ?? []) { + const d = touch("agent", a.name); + if (!d) continue; + d.exists = a.sourceKind !== "builtin"; + d.path = a.path ?? null; + d.derivedDescription = truncate(a.description || ""); + } // 2. What refers to them. `lastUsed` tracks conversations only — it answers // "when did I last work on this", which the other sources can't. @@ -269,6 +339,20 @@ export function useTags(): { tags: Tag[]; loading: boolean } { d.usage.conversations += 1; if (at && (!d.lastUsed || at > d.lastUsed)) d.lastUsed = at; } + // What the session reached for. One count per conversation, not per call: + // a tag list answers "how many sessions used this", and the Skills/Agents + // pages already own the per-invocation arithmetic. + for (const [kind, names] of [ + ["skill", c.skillsUsed], + ["agent", c.agentsUsed], + ] as const) { + for (const name of names ?? []) { + const d = touch(kind, name); + if (!d) continue; + d.usage.conversations += 1; + if (at && (!d.lastUsed || at > d.lastUsed)) d.lastUsed = at; + } + } } const bump = (kind: TagKind, slug: string, field: keyof TagUsage) => { const d = touch(kind, slug); @@ -282,14 +366,17 @@ export function useTags(): { tags: Tag[]; loading: boolean } { for (const slug of p.projects) bump("project", slug, "plans"); for (const slug of p.services) bump("service", slug, "plans"); } - // A project-local skill belongs to the project it ships in; the homelab's - // own skills have no project tag to attribute to. + // A project-local skill/agent belongs to the project it ships in; the + // homelab's own (and the built-ins) have no project tag to attribute to. for (const s of skills ?? []) { if (s.sourceKind === "project" && s.source) bump("project", s.source, "skills"); } + for (const a of agents ?? []) { + if (a.sourceKind === "project" && a.source) bump("project", a.source, "agents"); + } return [...byId.entries()].map(([id, d]) => ({ id, ...d })); - }, [projects, services, convos, memories, plans, skills]); + }, [projects, services, convos, memories, plans, skills, agents]); const tags = useMemo( () => @@ -303,7 +390,7 @@ export function useTags(): { tags: Tag[]; loading: boolean } { kind: d.kind, slug: d.slug, label: d.label, - path: tagPath(d.kind, d.slug), + path: d.path ?? tagPath(d.kind, d.slug), derivedDescription: d.derivedDescription, description: meta?.description || d.derivedDescription, color, @@ -318,6 +405,7 @@ export function useTags(): { tags: Tag[]; loading: boolean } { usage.memories + usage.plans + usage.skills + + usage.agents + usage.projects, lastUsed: d.lastUsed, }; @@ -335,6 +423,7 @@ export function useTags(): { tags: Tag[]; loading: boolean } { convosQ.isLoading || memoriesQ.isLoading || plansQ.isLoading || - skillsQ.isLoading, + skillsQ.isLoading || + agentsQ.isLoading, }; } diff --git a/frontend/src/mock/seed.ts b/frontend/src/mock/seed.ts index 894c25a..574436c 100644 --- a/frontend/src/mock/seed.ts +++ b/frontend/src/mock/seed.ts @@ -390,6 +390,14 @@ const BY_TOOL: Record = { Grep: tool(9_800, 0.03, 4_100), }; +// Skills invoked / agent types spawned, busiest first — the shape the backend +// mines from a transcript (`skillsUsed`/`agentsUsed`). +const SKILLS_USED = [ + "commit-project", "screenshot", "open-pr", "notify-done", "plan", + "conv-meta", "ask-form", +]; +const AGENTS_USED = ["Explore", "general-purpose", "Plan", "complete"]; + function conversation( id: string, title: string, @@ -439,6 +447,11 @@ function conversation( firstContextTokens: 39_800 + idx * 400, firstMessageTokens: 90 + idx * 15, tasks: opts.full ? TASKS : null, + // The tool axis the `skill:`/`agent:` tags are derived from. Cycled by + // index so a mock load shows both the short case and one long enough to + // hit the detail header's "+N" overflow. + skillsUsed: SKILLS_USED.slice(0, 1 + (idx % SKILLS_USED.length)), + agentsUsed: AGENTS_USED.slice(0, 1 + (idx % AGENTS_USED.length)), meta: meta(state, harness, lifecycle, { archived: opts.archived, project, service, effort }), thread, turns: turns(model, Math.max(1, thread.filter((t) => t.kind === "text" && t.role === "assistant").length)),