feat(tags): derive skill + agent tags from conversation analysis #2

Merged
gabrielvidal merged 1 commits from conv-tags into main 2026-08-09 23:01:19 +02:00
16 changed files with 560 additions and 43 deletions

View File

@@ -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()` Tags are **derived, never authored** (`frontend/src/lib/tags.ts`): `useTags()`
aggregates the projects/services on disk, the stack + `package.json` keywords a 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 declares, the skills/agents catalogs, and every mention carried by a
project-local skill into one list of `<kind>:<slug>` tags with per-source counts. conversation, memory, plan or project-local skill into one list of
Nothing creates, renames or deletes a tag — rename a directory and the tag `<kind>:<slug>` tags with per-source counts. Nothing creates, renames or deletes
follows; a tag that is referenced but has no directory is flagged `exists: a tag — rename a directory and the tag follows; a tag that is referenced but has
false` rather than hidden, since that is usually a rename something still points no directory is flagged `exists: false` rather than hidden, since that is usually
at. 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 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 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 into `TAG_COLORS`, `lib/tagStyles.ts`), **icon** (a key into the curated
`TAG_ICONS` set, `lib/tagIcons.tsx`) and **description**. Unset fields fall back `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`, 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 stacks violet/`Blocks`, skills amber/`Wrench`, agents emerald/`Bot` — which is
did before the page existed. why an untouched homelab looks exactly as it did before the page existed.
`useTagPresentation()` is the queryless resolver every surface uses, so a tag `useTagPresentation()` is the queryless resolver every surface uses, so a tag
looks the same on a conversation card (`ConvMetaBits`), in a memory/plan list, 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 homelab location(s):` bullet, so editing it here changes what a spawned agent
actually reads — the Tags editor previews that exact line. 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 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 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 chunk, and pulling ~1500 icons into the main bundle to serve one settings page

View File

@@ -1104,6 +1104,81 @@ def _attach_subagents(ap: pathlib.Path, data: dict) -> dict:
return data 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: def _conv_card(path: str, s: dict) -> dict:
"""Compact conversation reference used when cross-linking from a memory.""" """Compact conversation reference used when cross-linking from a memory."""
return { return {
@@ -1163,25 +1238,32 @@ def _conversation_cards(full: bool) -> list[dict]:
summaries = store.all_summaries() summaries = store.all_summaries()
# Group subagent summaries under their parent so we can roll their usage up. # Group subagent summaries under their parent so we can roll their usage up.
children: dict[str, list[dict]] = {} children: dict[str, list[dict]] = {}
child_paths: dict[str, list[str]] = {}
for path, s in summaries: for path, s in summaries:
if s.get("isSidechain"): if s.get("isSidechain"):
pp = _parent_path_of(path) pp = _parent_path_of(path)
if pp: if pp:
children.setdefault(pp, []).append(s) children.setdefault(pp, []).append(s)
child_paths.setdefault(pp, []).append(path)
keys = _CARD_KEYS_FULL if full else _CARD_KEYS keys = _CARD_KEYS_FULL if full else _CARD_KEYS
# A session resumed across working dirs has one transcript file per cwd — # A session resumed across working dirs has one transcript file per cwd —
# collapse those so the conversation shows up once, not once per copy. # collapse those so the conversation shows up once, not once per copy.
out = [] out = []
agents_by_conv = _agents_by_conversation()
for path, s in _dedup_by_session( for path, s in _dedup_by_session(
[(p, s) for p, s in summaries if not s.get("isSidechain")] [(p, s) for p, s in summaries if not s.get("isSidechain")]
): ):
if not s.get("messages"): if not s.get("messages"):
continue # skip empty transcripts continue # skip empty transcripts
skills_used = _skills_used([path, *child_paths.get(path, [])])
s = _rollup_children(s, children.get(path, [])) s = _rollup_children(s, children.get(path, []))
m = _conv_meta(s) 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), "title": _conv_title(s),
"runningAgents": _running_agents(s, m.get("state")), "runningAgents": _running_agents(s, m.get("state")),
"skillsUsed": skills_used,
"agentsUsed": agents_by_conv.get(cid, []),
"meta": m}) "meta": m})
return out return out
@@ -1271,17 +1353,23 @@ def conversation_summary(id: str):
if not s: if not s:
raise HTTPException(404, "summary not indexed yet") raise HTTPException(404, "summary not indexed yet")
children: list[dict] = [] children: list[dict] = []
paths = [str(ap)]
sub_dir = ap.with_suffix("") / "subagents" sub_dir = ap.with_suffix("") / "subagents"
if sub_dir.is_dir(): if sub_dir.is_dir():
for child in sorted(sub_dir.glob("*.jsonl")): for child in sorted(sub_dir.glob("*.jsonl")):
paths.append(str(child))
cs = store.get_summary(str(child)) cs = store.get_summary(str(child))
if cs: if cs:
children.append(cs) children.append(cs)
skills_used = _skills_used(paths)
s = _rollup_children(s, children) s = _rollup_children(s, children)
m = _conv_meta(s) m = _conv_meta(s)
return {"id": id, **{k: s.get(k) for k in _CARD_KEYS}, return {"id": id, **{k: s.get(k) for k in _CARD_KEYS},
"title": _conv_title(s), "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)) @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.""" """Full parsed thread (tools collapsible client-side) + per-turn usage."""
ap = _resolve_transcript(id) ap = _resolve_transcript(id)
data = _parse_full_cached(ap) 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.pop("skills", None)
data["id"] = id data["id"] = id
data["title"] = _conv_title(data) data["title"] = _conv_title(data)

View File

@@ -398,6 +398,14 @@ class ConversationSummary(Schema):
firstContextTokens: Optional[int] = None firstContextTokens: Optional[int] = None
firstMessageTokens: Optional[int] = None firstMessageTokens: Optional[int] = None
tasks: Optional[list[Task]] = 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 meta: Optional[ConvMeta] = None

View File

@@ -5315,6 +5315,34 @@
], ],
"title": "Tasks" "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": { "meta": {
"anyOf": [ "anyOf": [
{ {
@@ -5726,6 +5754,34 @@
], ],
"title": "Tasks" "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": { "meta": {
"anyOf": [ "anyOf": [
{ {

View File

@@ -36,10 +36,12 @@ import {
richUpload, richUpload,
richToUploaded, richToUploaded,
shortcutTags, shortcutTags,
toolMentions,
toolTags,
uploadedToRich, uploadedToRich,
type MentionTag, type MentionTag,
} from "@/lib/richComposer"; } from "@/lib/richComposer";
import { useCtas } from "@/lib/queries"; import { useAgents, useCtas, useSkills } from "@/lib/queries";
import { defaultGuidelineIds } from "@/lib/guidelineDag"; import { defaultGuidelineIds } from "@/lib/guidelineDag";
import { useTagPresentation } from "@/lib/tags"; import { useTagPresentation } from "@/lib/tags";
import { useSpawn } from "@/lib/spawn"; import { useSpawn } from "@/lib/spawn";
@@ -263,13 +265,22 @@ export function SpawnComposer() {
const [guidelinesOn, setGuidelinesOn] = useState(true); const [guidelinesOn, setGuidelinesOn] = useState(true);
const tagPresentation = useTagPresentation(); 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( const composerTags = useMemo(
() => [ () => [
...shortcutTags(shortcuts, selected), ...shortcutTags(shortcuts, selected),
...locationTags(tags, tagPresentation), ...locationTags(tags, tagPresentation),
...toolTags(tools, tagPresentation),
...ctaTags(ctas ?? []), ...ctaTags(ctas ?? []),
], ],
[shortcuts, selected, tags, tagPresentation, ctas], [shortcuts, selected, tags, tools, tagPresentation, ctas],
); );
const composePrompt = useMemo( const composePrompt = useMemo(
() => richComposePrompt(shortcuts, selected, guidelinesOn, ctas ?? []), () => richComposePrompt(shortcuts, selected, guidelinesOn, ctas ?? []),

View File

@@ -18,7 +18,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { cn, fmtCost, fmtDateTime, fmtDuration, relTime } from "@/lib/utils"; 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 { import type {
AgentRunRef, AgentRunRef,
ConvMeta, ConvMeta,
@@ -337,6 +337,99 @@ export function ServiceTags({
return <LocationTags kind="service" slugs={services} linkify={linkify} />; return <LocationTags kind="service" slugs={services} linkify={linkify} />;
} }
/** 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 = (
<>
<Icon className="h-2.5 w-2.5" />
{name}
</>
);
return href ? (
<Link key={name} to={href} className={cn(base, style.pill, style.pillHover)}>
{body}
</Link>
) : (
<span key={name} className={cn(base, style.pill)}>
{body}
</span>
);
})}
{hidden > 0 && (
<button
type="button"
onClick={(e) => {
// These sit inside card-wide links on some surfaces; expanding must
// not also navigate.
e.preventDefault();
e.stopPropagation();
setExpanded(true);
}}
className={cn(base, "bg-muted text-muted-foreground hover:bg-accent")}
title={names.slice(TOOL_TAGS_SHOWN).join(", ")}
>
+{hidden}
</button>
)}
</>
);
}
/** Skill pills — see {@link ToolTags}. */
export function SkillTags({
skills,
linkify = false,
}: {
skills?: string[] | null;
linkify?: boolean;
}) {
return <ToolTags kind="skill" names={skills} linkify={linkify} />;
}
/** Agent pills — the delegation counterpart of {@link SkillTags}. */
export function AgentTags({
agents,
linkify = false,
}: {
agents?: string[] | null;
linkify?: boolean;
}) {
return <ToolTags kind="agent" names={agents} linkify={linkify} />;
}
/** One worktree's timeline row inside the {@link WorktreeTag} hover card. */ /** One worktree's timeline row inside the {@link WorktreeTag} hover card. */
function WorktreeRow({ w }: { w: WorktreeInfo }) { function WorktreeRow({ w }: { w: WorktreeInfo }) {
const lifespan = const lifespan =

View File

@@ -16,9 +16,17 @@ import {
useModels, useModels,
type Harness, type Harness,
} from "@/lib/models"; } 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 { 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"; import { SendMenuButton } from "./SendMenuButton";
/** /**
@@ -162,9 +170,22 @@ export function ResumeBox({
// CTA chips sit alongside the guideline chips here too: the same prompts the // CTA chips sit alongside the guideline chips here too: the same prompts the
// buttons under a finished thread send, foldable into a typed message. // buttons under a finished thread send, foldable into a typed message.
const { data: ctas } = useCtas(); 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( 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( const composePrompt = useMemo(
() => richComposePrompt(shortcuts, selected, guidelinesOn, ctas ?? []), () => richComposePrompt(shortcuts, selected, guidelinesOn, ctas ?? []),

View File

@@ -70,7 +70,7 @@ import { SubagentWidget, isAgentCall } from "@/business/conversations/components
import { parseAskForm, AskFormWidget, type AskFormArgs } from "@/business/forms/components/FormWidget"; import { parseAskForm, AskFormWidget, type AskFormArgs } from "@/business/forms/components/FormWidget";
import { FormModal } from "@/business/forms/components/FormModal"; import { FormModal } from "@/business/forms/components/FormModal";
import { ContextGizmo } from "@/business/conversations/components/ContextWidget"; 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 { TaskBadge, TaskPanel } from "@/business/conversations/components/TaskPanel";
import { PlanStatusBadge, fmtMinutes } from "@/business/plans/pages/Plans"; import { PlanStatusBadge, fmtMinutes } from "@/business/plans/pages/Plans";
import type { ArtefactItem, ConversationDetail, MemorySummary, PlanSummary, SubagentRef, ThreadItem } from "@/types"; import type { ArtefactItem, ConversationDetail, MemorySummary, PlanSummary, SubagentRef, ThreadItem } from "@/types";
@@ -809,6 +809,10 @@ function MetaHeader({
{showEffort && <EffortTags efforts={effortsOf(convo)} />} {showEffort && <EffortTags efforts={effortsOf(convo)} />}
<ProjectTags projects={meta.projects} linkify /> <ProjectTags projects={meta.projects} linkify />
<ServiceTags services={meta.services} linkify /> <ServiceTags services={meta.services} linkify />
{/* What it worked *with*, next to where it worked — detail only: the list
cards stay about location, and these are long tails. */}
<SkillTags skills={convo.skillsUsed} linkify />
<AgentTags agents={convo.agentsUsed} linkify />
<WorktreeTag worktrees={meta.worktrees} /> <WorktreeTag worktrees={meta.worktrees} />
{/* pi sessions don't exist on claude.ai — the deep link is claude-only. */} {/* pi sessions don't exist on claude.ai — the deep link is claude-only. */}
{convo.sessionId && meta.harness !== "pi" && ( {convo.sessionId && meta.harness !== "pi" && (

View File

@@ -1,5 +1,5 @@
import { useMemo, useState, type ReactNode } from "react"; import { useMemo, useState, type ReactNode } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { import {
RefreshCw, RefreshCw,
Wrench, Wrench,
@@ -199,6 +199,13 @@ export function Skills() {
// Source filter (repo / project / built-in). Empty = everything. // Source filter (repo / project / built-in). Empty = everything.
const [sources, setSources] = useState<string[]>([]); const [sources, setSources] = useState<string[]>([]);
// `?skill=<name>` 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 counts = useMemo(() => {
const c: Record<SourceKind, number> = { repo: 0, project: 0, builtin: 0 }; const c: Record<SourceKind, number> = { repo: 0, project: 0, builtin: 0 };
for (const s of skills ?? []) c[sourceKind(s)]++; for (const s of skills ?? []) c[sourceKind(s)]++;
@@ -208,6 +215,7 @@ export function Skills() {
const rows = useMemo(() => { const rows = useMemo(() => {
const out: Row[] = []; const out: Row[] = [];
for (const s of skills ?? []) { for (const s of skills ?? []) {
if (focused && s.name !== focused) continue;
if (sources.length && !sources.includes(sourceKind(s))) continue; if (sources.length && !sources.includes(sourceKind(s))) continue;
out.push({ out.push({
key: `${s.sourceKind}:${s.source}:${s.name}`, 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. // 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)); out.sort((a, b) => rowTime(b) - rowTime(a) || a.name.localeCompare(b.name));
return out; return out;
}, [skills, sources]); }, [skills, sources, focused]);
// The usage bars are scaled to the busiest skill *in view*, so filtering to a // 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. // quiet corner still shows relative usage rather than a row of empty tracks.
@@ -331,6 +339,17 @@ export function Skills() {
) )
) : ( ) : (
<> <>
{focused && (
<button
type="button"
onClick={() => setParams({}, { replace: true })}
className="mb-3 flex shrink-0 items-center gap-2 self-start rounded-full border border-primary/40 bg-primary/10 px-3 py-1 text-[11px] font-medium text-primary hover:bg-primary/20"
>
<Wrench className="h-3 w-3" />
{focused}
<span className="text-muted-foreground">· show all skills</span>
</button>
)}
{filterChips} {filterChips}
<FuzzyList<Row> <FuzzyList<Row>
items={rows} items={rows}

View File

@@ -7,6 +7,7 @@ import {
Brain, Brain,
ClipboardList, ClipboardList,
Wrench, Wrench,
Bot,
Folder, Folder,
AlertTriangle, AlertTriangle,
} from "lucide-react"; } from "lucide-react";
@@ -70,6 +71,7 @@ function TagUsageRow({ tag }: { tag: Tag }) {
<UsageStat icon={<Brain className="h-3 w-3" />} n={u.memories} label="memories" /> <UsageStat icon={<Brain className="h-3 w-3" />} n={u.memories} label="memories" />
<UsageStat icon={<ClipboardList className="h-3 w-3" />} n={u.plans} label="plans" /> <UsageStat icon={<ClipboardList className="h-3 w-3" />} n={u.plans} label="plans" />
<UsageStat icon={<Wrench className="h-3 w-3" />} n={u.skills} label="skills" /> <UsageStat icon={<Wrench className="h-3 w-3" />} n={u.skills} label="skills" />
<UsageStat icon={<Bot className="h-3 w-3" />} n={u.agents} label="agents" />
<UsageStat icon={<Folder className="h-3 w-3" />} n={u.projects} label="projects" /> <UsageStat icon={<Folder className="h-3 w-3" />} n={u.projects} label="projects" />
{tag.total === 0 && <span className="italic">unused</span>} {tag.total === 0 && <span className="italic">unused</span>}
</div> </div>
@@ -319,13 +321,15 @@ export function TagsPage() {
<div className="flex min-h-0 flex-1 flex-col p-4"> <div className="flex min-h-0 flex-1 flex-col p-4">
<div className="mx-auto flex min-h-0 w-full max-w-2xl flex-col"> <div className="mx-auto flex min-h-0 w-full max-w-2xl flex-col">
<p className="mb-3 text-[12px] leading-relaxed text-muted-foreground"> <p className="mb-3 text-[12px] leading-relaxed text-muted-foreground">
Every tag the app derives from your homelab the{" "} Every tag the app derives from your homelab where work happens (the{" "}
<span className="font-medium text-foreground">projects</span> and{" "} <span className="font-medium text-foreground">projects</span> and{" "}
<span className="font-medium text-foreground">services</span> on disk, the{" "} <span className="font-medium text-foreground">services</span> on disk, the{" "}
<span className="font-medium text-foreground">stacks</span> they declare, and <span className="font-medium text-foreground">stacks</span> they declare) and
everything conversations, memories, plans and skills refer to. Tags aren't what it's done with (the{" "}
created or deleted here; pick one to set its colour, glyph and the description <span className="font-medium text-foreground">skills</span> conversations
it carries into a spawned prompt. invoke and the <span className="font-medium text-foreground">agents</span>{" "}
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.
</p> </p>
<div className="mb-3 flex flex-wrap items-center gap-1.5"> <div className="mb-3 flex flex-wrap items-center gap-1.5">

View File

@@ -47,6 +47,8 @@ export interface ConversationDetail {
firstContextTokens?: number | null; firstContextTokens?: number | null;
firstMessageTokens?: number | null; firstMessageTokens?: number | null;
tasks?: Task[] | null; tasks?: Task[] | null;
skillsUsed?: string[] | null;
agentsUsed?: string[] | null;
meta?: ConvMeta | null; meta?: ConvMeta | null;
thread: ThreadItem[]; thread: ThreadItem[];
turns: TurnUsage[]; turns: TurnUsage[];

View File

@@ -40,5 +40,7 @@ export interface ConversationSummary {
firstContextTokens?: number | null; firstContextTokens?: number | null;
firstMessageTokens?: number | null; firstMessageTokens?: number | null;
tasks?: Task[] | null; tasks?: Task[] | null;
skillsUsed?: string[] | null;
agentsUsed?: string[] | null;
meta?: ConvMeta | null; meta?: ConvMeta | null;
} }

View File

@@ -18,6 +18,13 @@ export interface MentionTag {
description: string; 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 * Adapters that let the shared {@link RichInput} from `@gabvdl/ui` drive the two
* `claude -p` composers (home {@link SpawnBox}, conversation {@link ResumeBox}). * `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:<name>` / `agent:<name>` 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 = <T extends { at?: string | null }>(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: <Icon className={cn("h-2.5 w-2.5", style.text)} />,
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 * Map conversation CTAs to RichInput tags. They live in the scrollable list
* (also reachable as `#complete`), carry their id on the tag as `cta:<id>`, and * (also reachable as `#complete`), carry their id on the tag as `cta:<id>`, and
@@ -174,6 +232,13 @@ export function richComposePrompt(
const slug = t.id.slice(t.id.indexOf(":") + 1); const slug = t.id.slice(t.id.indexOf(":") + 1);
return { path: `${kind}/${slug}`, description: t.description ?? slug }; 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 attachments = input.files.map((f) => ({ repoPath: richToUploaded(f).repoPath }));
const lines = guidelinesOn ? guidelineLines(shortcuts, selected) : []; const lines = guidelinesOn ? guidelineLines(shortcuts, selected) : [];
// CTA tags contribute their whole prompt file, resolved from the live list // 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))) .map((t) => ctas.find((c) => c.id === t.id.slice(4)))
.filter((c): c is Cta => !!c?.prompt) .filter((c): c is Cta => !!c?.prompt)
.map((c) => ({ label: c.label || c.name, prompt: 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);
}; };
} }

View File

@@ -25,6 +25,7 @@ export function composePrompt(
tags: PromptTag[] = [], tags: PromptTag[] = [],
attachments: { repoPath: string }[] = [], attachments: { repoPath: string }[] = [],
ctas: { label: string; prompt: string }[] = [], ctas: { label: string; prompt: string }[] = [],
tools: { kind: "skill" | "agent"; name: string; description: string }[] = [],
): string { ): string {
const lines: string[] = [text.trim()]; const lines: string[] = [text.trim()];
@@ -43,6 +44,19 @@ export function composePrompt(
for (const t of tags) lines.push(`- \`${t.path}\`${t.description}`); 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); const clean = guidelines.map((g) => g.trim()).filter(Boolean);
if (clean.length > 0) { if (clean.length > 0) {
lines.push("", "Guidelines:"); lines.push("", "Guidelines:");

View File

@@ -1,6 +1,7 @@
import { useMemo } from "react"; 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 { import {
useAgents,
useConversationsAll, useConversationsAll,
useMemories, useMemories,
usePlans, usePlans,
@@ -18,29 +19,43 @@ import { useSettings, type TagMeta } from "@/settings";
* Tags are **derived, not authored**: nothing in the app creates, renames or * 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 * deletes one. A tag exists because something in the homelab already refers to
* it — a directory under `projects/`/`services/`, a stack/keyword a project * 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 * declares, a `SKILL.md`/agent definition under `.claude/`, or a mention mined
* why this module exposes no mutators for the tags themselves: the only thing a * out of a conversation, memory or plan. That is why this module exposes no
* human owns is a tag's *presentation* (colour, glyph, description), which lives * mutators for the tags themselves: the only thing a human owns is a tag's
* in settings under `tagMeta` and is merged in here. * *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: * Consequences worth knowing:
* - a tag can be referenced but not exist on disk (a conversation tagged with a * - 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 * project that has since been removed, or a built-in skill/agent that has no
* the page surfaces those separately rather than hiding them, because a * file in this repo) — {@link Tag.exists} says which, and the page surfaces
* dangling tag is usually a rename you forgot; * 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 * - counts are what makes a tag worth styling at all, so they're computed per
* source rather than as one total. * source rather than as one total.
*/ */
/** Where a tag comes from. */ /** 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. */ /** How many of each kind of record carry this tag. */
export interface TagUsage { export interface TagUsage {
conversations: number; conversations: number;
memories: number; memories: number;
plans: number; plans: number;
/** Skills a project ships — only ever non-zero for `project` tags. */
skills: number; skills: number;
/** Agent definitions a project ships — likewise `project`-only. */
agents: number;
/** Projects declaring it — only ever non-zero for `stack` tags. */ /** Projects declaring it — only ever non-zero for `stack` tags. */
projects: number; projects: number;
} }
@@ -56,7 +71,12 @@ export interface Tag {
* is lowercased to fold `React`/`react` onto one tag. * is lowercased to fold `React`/`react` onto one tag.
*/ */
label: string; 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; path: string | null;
/** The description the tag was derived with (before any override). */ /** The description the tag was derived with (before any override). */
derivedDescription: string; derivedDescription: string;
@@ -86,26 +106,50 @@ const KIND_DEFAULTS: Record<TagKind, { color: TagColor; Icon: LucideIcon }> = {
project: { color: "primary", Icon: TagIcon }, project: { color: "primary", Icon: TagIcon },
service: { color: "sky", Icon: Server }, service: { color: "sky", Icon: Server },
stack: { color: "violet", Icon: Blocks }, 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<TagKind, string> = { export const TAG_KIND_LABEL: Record<TagKind, string> = {
project: "Project", project: "Project",
service: "Service", service: "Service",
stack: "Stack", stack: "Stack",
skill: "Skill",
agent: "Agent",
}; };
/** `project` + `foo` → `project:foo`. */ /** `project` + `foo` → `project:foo`. */
export const tagId = (kind: TagKind, slug: string) => `${kind}:${slug}`; 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 { export function tagPath(kind: TagKind, slug: string): string | null {
if (kind === "project") return `projects/${slug}`; if (kind === "project") return `projects/${slug}`;
if (kind === "service") return `services/${slug}`; if (kind === "service") return `services/${slug}`;
return null; 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. */ /** Trim a derived description to something that reads as a single line. */
const truncate = (s: string, n = 90) => const truncate = (s: string, n = 90) =>
s.length > n ? `${s.slice(0, n - 1).trimEnd()}` : s; s.length > n ? `${s.slice(0, n - 1).trimEnd()}` : s;
@@ -165,6 +209,8 @@ interface Draft {
label: string; label: string;
derivedDescription: string; derivedDescription: string;
exists: boolean; exists: boolean;
/** Set for skill/agent tags, whose path can't be derived from the id. */
path: string | null;
usage: TagUsage; usage: TagUsage;
lastUsed: string | null; lastUsed: string | null;
} }
@@ -174,6 +220,7 @@ const emptyUsage = (): TagUsage => ({
memories: 0, memories: 0,
plans: 0, plans: 0,
skills: 0, skills: 0,
agents: 0,
projects: 0, projects: 0,
}); });
@@ -195,6 +242,7 @@ export function useTags(): { tags: Tag[]; loading: boolean } {
const memoriesQ = useMemories(); const memoriesQ = useMemories();
const plansQ = usePlans(); const plansQ = usePlans();
const skillsQ = useSkills(); const skillsQ = useSkills();
const agentsQ = useAgents();
const tagMeta = useSettings((s) => s.tagMeta); const tagMeta = useSettings((s) => s.tagMeta);
const projects = projectsQ.data; const projects = projectsQ.data;
@@ -203,6 +251,7 @@ export function useTags(): { tags: Tag[]; loading: boolean } {
const memories = memoriesQ.data; const memories = memoriesQ.data;
const plans = plansQ.data; const plans = plansQ.data;
const skills = skillsQ.data?.skills; const skills = skillsQ.data?.skills;
const agents = agentsQ.data?.agents;
const drafts = useMemo(() => { const drafts = useMemo(() => {
const byId = new Map<string, Draft>(); const byId = new Map<string, Draft>();
@@ -218,6 +267,7 @@ export function useTags(): { tags: Tag[]; loading: boolean } {
label: kind === "stack" ? rawSlug.trim() : slug, label: kind === "stack" ? rawSlug.trim() : slug,
derivedDescription: "", derivedDescription: "",
exists: false, exists: false,
path: null,
usage: emptyUsage(), usage: emptyUsage(),
lastUsed: null, lastUsed: null,
}; };
@@ -252,6 +302,26 @@ export function useTags(): { tags: Tag[]; loading: boolean } {
d.exists = true; d.exists = true;
d.derivedDescription = truncate(s.description || s.name || s.dir); 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 // 2. What refers to them. `lastUsed` tracks conversations only — it answers
// "when did I last work on this", which the other sources can't. // "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; d.usage.conversations += 1;
if (at && (!d.lastUsed || at > d.lastUsed)) d.lastUsed = at; 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 bump = (kind: TagKind, slug: string, field: keyof TagUsage) => {
const d = touch(kind, slug); 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.projects) bump("project", slug, "plans");
for (const slug of p.services) bump("service", 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 // A project-local skill/agent belongs to the project it ships in; the
// own skills have no project tag to attribute to. // homelab's own (and the built-ins) have no project tag to attribute to.
for (const s of skills ?? []) { for (const s of skills ?? []) {
if (s.sourceKind === "project" && s.source) bump("project", s.source, "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 })); return [...byId.entries()].map(([id, d]) => ({ id, ...d }));
}, [projects, services, convos, memories, plans, skills]); }, [projects, services, convos, memories, plans, skills, agents]);
const tags = useMemo( const tags = useMemo(
() => () =>
@@ -303,7 +390,7 @@ export function useTags(): { tags: Tag[]; loading: boolean } {
kind: d.kind, kind: d.kind,
slug: d.slug, slug: d.slug,
label: d.label, label: d.label,
path: tagPath(d.kind, d.slug), path: d.path ?? tagPath(d.kind, d.slug),
derivedDescription: d.derivedDescription, derivedDescription: d.derivedDescription,
description: meta?.description || d.derivedDescription, description: meta?.description || d.derivedDescription,
color, color,
@@ -318,6 +405,7 @@ export function useTags(): { tags: Tag[]; loading: boolean } {
usage.memories + usage.memories +
usage.plans + usage.plans +
usage.skills + usage.skills +
usage.agents +
usage.projects, usage.projects,
lastUsed: d.lastUsed, lastUsed: d.lastUsed,
}; };
@@ -335,6 +423,7 @@ export function useTags(): { tags: Tag[]; loading: boolean } {
convosQ.isLoading || convosQ.isLoading ||
memoriesQ.isLoading || memoriesQ.isLoading ||
plansQ.isLoading || plansQ.isLoading ||
skillsQ.isLoading, skillsQ.isLoading ||
agentsQ.isLoading,
}; };
} }

View File

@@ -390,6 +390,14 @@ const BY_TOOL: Record<string, ToolUsage> = {
Grep: tool(9_800, 0.03, 4_100), 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( function conversation(
id: string, id: string,
title: string, title: string,
@@ -439,6 +447,11 @@ function conversation(
firstContextTokens: 39_800 + idx * 400, firstContextTokens: 39_800 + idx * 400,
firstMessageTokens: 90 + idx * 15, firstMessageTokens: 90 + idx * 15,
tasks: opts.full ? TASKS : null, 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 }), meta: meta(state, harness, lifecycle, { archived: opts.archived, project, service, effort }),
thread, thread,
turns: turns(model, Math.max(1, thread.filter((t) => t.kind === "text" && t.role === "assistant").length)), turns: turns(model, Math.max(1, thread.filter((t) => t.kind === "text" && t.role === "assistant").length)),