feat(tags): derive skill + agent tags from conversation analysis #2
35
CLAUDE.md
35
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()`
|
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
|
||||||
|
|||||||
101
backend/main.py
101
backend/main.py
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 ?? []),
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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 ?? []),
|
||||||
|
|||||||
@@ -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" && (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:");
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
Reference in New Issue
Block a user