From 4006e01964834cac3288aa7660b385d2b09531d7 Mon Sep 17 00:00:00 2001 From: Yi Lu Date: Mon, 27 Apr 2026 23:29:27 -0300 Subject: [PATCH 1/2] feat: wire citations to publish, fix dashboard status, bump reflexio to 0.2.17 - Map local cited_items onto InteractionData.citations on the publish wire so reflexio can drive playbook/profile reflection from real Assistant citations. - Align dashboard with reflexio's lowercase-StrEnum + null-for-current wire shape: narrow PlaybookStatus/ProfileStatus types, treat null as CURRENT, consolidate the three duplicated statusLabel helpers into lib/status.ts. - Lower per-session interaction-cleanup thresholds (1000/500 -> 500/200) for shorter-lived claude-smart users. - Bump reflexio submodule to v0.2.17. --- plugin/dashboard/app/dashboard/page.tsx | 6 +- plugin/dashboard/app/playbooks/[id]/page.tsx | 8 +- plugin/dashboard/app/playbooks/page.tsx | 8 +- plugin/dashboard/app/profiles/[id]/page.tsx | 8 +- plugin/dashboard/lib/status.ts | 14 +++ plugin/dashboard/lib/types.ts | 8 +- plugin/scripts/backend-service.sh | 4 +- plugin/src/claude_smart/state.py | 50 ++++++++- reflexio | 2 +- tests/test_state.py | 109 ++++++++++++++++++- 10 files changed, 185 insertions(+), 32 deletions(-) create mode 100644 plugin/dashboard/lib/status.ts diff --git a/plugin/dashboard/app/dashboard/page.tsx b/plugin/dashboard/app/dashboard/page.tsx index d388adc..41937dd 100644 --- a/plugin/dashboard/app/dashboard/page.tsx +++ b/plugin/dashboard/app/dashboard/page.tsx @@ -50,9 +50,9 @@ export default function DashboardPage() { }; }, [reflexioUrl]); - const currentPlaybooks = (playbooks ?? []).filter( - (p) => p.status == null || p.status === "CURRENT", - ); + // CURRENT playbooks arrive as `status: null` (response_model_exclude_none + // strips the field). Anything else (e.g. "archived", "pending") is excluded. + const currentPlaybooks = (playbooks ?? []).filter((p) => p.status == null); const learningInteractionTotal = (sessions ?? []).reduce( (acc, s) => acc + s.learning_interaction_count, 0, diff --git a/plugin/dashboard/app/playbooks/[id]/page.tsx b/plugin/dashboard/app/playbooks/[id]/page.tsx index 669f6b6..f950aa9 100644 --- a/plugin/dashboard/app/playbooks/[id]/page.tsx +++ b/plugin/dashboard/app/playbooks/[id]/page.tsx @@ -28,6 +28,7 @@ import { reflexio } from "@/lib/reflexio-client"; import { useSettings } from "@/hooks/use-settings"; import { formatTimestamp, truncateId } from "@/lib/format"; import { cn } from "@/lib/utils"; +import { statusLabel } from "@/lib/status"; import type { UserPlaybook } from "@/lib/types"; type FormState = { content: string; trigger: string; rationale: string }; @@ -46,13 +47,6 @@ function displayName(name: string | null | undefined): string | null { return name; } -function statusLabel(p: UserPlaybook): "CURRENT" | "ARCHIVED" | "PENDING" { - if (!p.status) return "CURRENT"; - if (p.status === "ARCHIVED") return "ARCHIVED"; - if (p.status === "PENDING") return "PENDING"; - return "CURRENT"; -} - export default function PlaybookDetailPage({ params, }: { diff --git a/plugin/dashboard/app/playbooks/page.tsx b/plugin/dashboard/app/playbooks/page.tsx index ebacec1..6f90604 100644 --- a/plugin/dashboard/app/playbooks/page.tsx +++ b/plugin/dashboard/app/playbooks/page.tsx @@ -18,15 +18,9 @@ import { import { reflexio } from "@/lib/reflexio-client"; import { useSettings } from "@/hooks/use-settings"; import { formatRelative } from "@/lib/format"; +import { statusLabel } from "@/lib/status"; import type { UserPlaybook } from "@/lib/types"; -function statusLabel(p: UserPlaybook): "CURRENT" | "ARCHIVED" | "PENDING" { - if (!p.status) return "CURRENT"; - if (p.status === "ARCHIVED") return "ARCHIVED"; - if (p.status === "PENDING") return "PENDING"; - return "CURRENT"; -} - export default function PlaybooksPage() { const { reflexioUrl } = useSettings(); const [playbooks, setPlaybooks] = useState(null); diff --git a/plugin/dashboard/app/profiles/[id]/page.tsx b/plugin/dashboard/app/profiles/[id]/page.tsx index 3311224..f6187ec 100644 --- a/plugin/dashboard/app/profiles/[id]/page.tsx +++ b/plugin/dashboard/app/profiles/[id]/page.tsx @@ -31,15 +31,9 @@ import { reflexio } from "@/lib/reflexio-client"; import { useSettings } from "@/hooks/use-settings"; import { formatTimestamp, truncateId } from "@/lib/format"; import { cn } from "@/lib/utils"; +import { statusLabel as status } from "@/lib/status"; import type { UserProfile } from "@/lib/types"; -function status(p: UserProfile): "CURRENT" | "ARCHIVED" | "PENDING" { - if (!p.status) return "CURRENT"; - if (p.status === "ARCHIVED") return "ARCHIVED"; - if (p.status === "PENDING") return "PENDING"; - return "CURRENT"; -} - export default function ProfileDetailPage({ params, }: { diff --git a/plugin/dashboard/lib/status.ts b/plugin/dashboard/lib/status.ts new file mode 100644 index 0000000..0ecfe34 --- /dev/null +++ b/plugin/dashboard/lib/status.ts @@ -0,0 +1,14 @@ +// Wire status comes from Python's Status StrEnum: lowercase strings +// ("archived", "pending"). CURRENT rows are omitted from JSON entirely +// (response_model_exclude_none) and arrive as `null`/undefined. +// Normalize to the uppercase label the dashboard renders. + +export type StatusLabel = "CURRENT" | "ARCHIVED" | "PENDING"; + +export function statusLabel(p: { status?: string | null }): StatusLabel { + if (!p.status) return "CURRENT"; + const s = String(p.status).toLowerCase(); + if (s === "archived") return "ARCHIVED"; + if (s === "pending") return "PENDING"; + return "CURRENT"; +} diff --git a/plugin/dashboard/lib/types.ts b/plugin/dashboard/lib/types.ts index f478180..41b85f1 100644 --- a/plugin/dashboard/lib/types.ts +++ b/plugin/dashboard/lib/types.ts @@ -5,9 +5,13 @@ export type UserActionType = | "PRAISE" | "STOP"; -export type PlaybookStatus = "PENDING" | "CURRENT" | "ARCHIVED"; +// Wire format from the reflexio API. CURRENT rows arrive as `status: null` +// (response_model_exclude_none strips it from the JSON entirely), so the type +// only enumerates the non-null values. The values are lowercase because they +// come from Python's Status StrEnum. +export type PlaybookStatus = "pending" | "archived"; -export type ProfileStatus = "CURRENT" | "ARCHIVED" | "PENDING"; +export type ProfileStatus = "pending" | "archived"; export interface ToolUsed { tool_name: string; diff --git a/plugin/scripts/backend-service.sh b/plugin/scripts/backend-service.sh index 78d7d33..b0a1fe3 100755 --- a/plugin/scripts/backend-service.sh +++ b/plugin/scripts/backend-service.sh @@ -162,8 +162,8 @@ case "$CMD" in # claude-smart users. Reflexio's library defaults are much higher # (250k/50k) for server deployments; here we override only in the # claude-smart plugin context. Users can still override via env. - export INTERACTION_CLEANUP_THRESHOLD="${INTERACTION_CLEANUP_THRESHOLD:-1000}" - export INTERACTION_CLEANUP_DELETE_COUNT="${INTERACTION_CLEANUP_DELETE_COUNT:-500}" + export INTERACTION_CLEANUP_THRESHOLD="${INTERACTION_CLEANUP_THRESHOLD:-500}" + export INTERACTION_CLEANUP_DELETE_COUNT="${INTERACTION_CLEANUP_DELETE_COUNT:-200}" # --no-reload: uvicorn's reloader forks a supervisor; makes PGID # bookkeeping harder and we don't need hot-reload for a user-facing diff --git a/plugin/src/claude_smart/state.py b/plugin/src/claude_smart/state.py index b2693e1..18fdb7f 100644 --- a/plugin/src/claude_smart/state.py +++ b/plugin/src/claude_smart/state.py @@ -34,6 +34,8 @@ _TOOL_DATA_FIELD_MAX_LEN = 256 +_VALID_CITATION_KINDS = frozenset({"playbook", "profile"}) + def _truncate_tool_data_field(value: Any) -> Any: """Truncate a single tool_data field value to ``_TOOL_DATA_FIELD_MAX_LEN``. @@ -166,6 +168,46 @@ def read_all(session_id: str) -> list[dict[str, Any]]: return records +def _to_wire_citations(cited_items: Any) -> list[dict[str, str]]: + """Map local ``cited_items`` to the wire ``Citation`` shape. + + Local entries (from ``events.stop._resolve_cited_items``) carry + ``{id, kind, title, real_id}``; reflexio's ``InteractionData.citations`` + wants ``{kind, real_id, tag, title}`` where ``tag`` is the rank id + (``r1-301``-style) we already keep under ``id``. Entries without a + ``real_id`` (unresolved injections) are dropped — the server can't + join them back to a stored row. + + Args: + cited_items (Any): The list-of-dicts blob attached to an Assistant + turn record, or ``None`` when the turn cited nothing. + + Returns: + list[dict[str, str]]: Citation dicts ready to be folded into an + ``InteractionData`` payload. Empty when ``cited_items`` is + missing, malformed, or contains nothing resolvable. + """ + if not isinstance(cited_items, list): + return [] + out: list[dict[str, str]] = [] + for item in cited_items: + if not isinstance(item, dict): + continue + real_id = item.get("real_id") + kind = item.get("kind") + if not real_id or kind not in _VALID_CITATION_KINDS: + continue + out.append( + { + "kind": kind, + "real_id": real_id, + "tag": item.get("id", ""), + "title": item.get("title", ""), + } + ) + return out + + def unpublished_slice( records: Iterable[dict[str, Any]], ) -> tuple[int, list[dict[str, Any]]]: @@ -212,12 +254,16 @@ def unpublished_slice( pending_tools.append(tool_entry) continue if role in {"User", "Assistant"}: - # ``cited_items`` is local-only metadata for the dashboard's - # "used" badge; reflexio's InteractionData has no slot for it. + # ``cited_items`` is local-only metadata (dashboard "used" badge); + # map it onto the wire's ``citations`` field — reflexio uses those + # to drive playbook/profile reflection in the publish flow. turn = { k: v for k, v in rec.items() if k not in {"role", "ts", "cited_items"} } turn["role"] = role + citations = _to_wire_citations(rec.get("cited_items")) + if citations: + turn["citations"] = citations if role == "Assistant" and pending_tools: turn["tools_used"] = pending_tools pending_tools = [] diff --git a/reflexio b/reflexio index d9e0001..9143d1b 160000 --- a/reflexio +++ b/reflexio @@ -1 +1 @@ -Subproject commit d9e000100157bcb59efd7390883e828b1598291e +Subproject commit 9143d1bf74584d0ecced69216c5b50773363c8e0 diff --git a/tests/test_state.py b/tests/test_state.py index 2d544fd..b6825b7 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -280,7 +280,7 @@ def test_unpublished_slice_includes_truncated_tool_output() -> None: def test_unpublished_slice_strips_cited_items(session_dir) -> None: - """``cited_items`` is dashboard-only metadata; reflexio must not receive it.""" + """``cited_items`` is local-only — never lands on the wire under that key.""" records = [ {"role": "User", "content": "hi"}, { @@ -293,6 +293,113 @@ def test_unpublished_slice_strips_cited_items(session_dir) -> None: assert "cited_items" not in turns[-1] +def test_unpublished_slice_maps_cited_items_to_citations() -> None: + """Resolved cited_items become a wire-shaped ``citations`` list on the turn.""" + records = [ + {"role": "User", "content": "hi"}, + { + "role": "Assistant", + "content": "ok", + "cited_items": [ + { + "id": "r1-ab12", + "kind": "playbook", + "title": "rule X", + "real_id": "pb_42", + }, + { + "id": "p1-cd34", + "kind": "profile", + "title": "user role", + "real_id": "prof_7", + }, + ], + }, + ] + _, turns = state.unpublished_slice(records) + assert turns[-1]["citations"] == [ + {"kind": "playbook", "real_id": "pb_42", "tag": "r1-ab12", "title": "rule X"}, + { + "kind": "profile", + "real_id": "prof_7", + "tag": "p1-cd34", + "title": "user role", + }, + ] + + +def test_unpublished_slice_omits_citations_when_empty() -> None: + """Empty / unresolvable cited_items → no ``citations`` key on the turn. + + Producing a key with ``[]`` would inflate every published Assistant + record; absence is meaningful. + """ + records = [ + {"role": "User", "content": "hi"}, + { + "role": "Assistant", + "content": "no real_id", + "cited_items": [{"id": "r1-ab12", "kind": "playbook", "title": "t"}], + }, + { + "role": "Assistant", + "content": "empty list", + "cited_items": [], + }, + { + "role": "Assistant", + "content": "no cited_items at all", + }, + ] + _, turns = state.unpublished_slice(records) + for turn in turns[1:]: + assert "citations" not in turn + + +def test_to_wire_citations_filters_invalid_kinds() -> None: + """Items with unknown ``kind`` are dropped (server has a Literal there).""" + items = [ + {"id": "r1-ab12", "kind": "playbook", "title": "ok", "real_id": "pb_1"}, + {"id": "x1-0001", "kind": "agent_playbook", "title": "junk", "real_id": "ap_1"}, + {"id": "y1-0002", "kind": "", "title": "junk2", "real_id": "z_1"}, + ] + result = state._to_wire_citations(items) + assert [c["kind"] for c in result] == ["playbook"] + assert result[0]["real_id"] == "pb_1" + + +def test_to_wire_citations_drops_unresolved_real_id() -> None: + """Entries without ``real_id`` (unresolved injections) cannot round-trip.""" + items = [ + {"id": "r1-ab12", "kind": "playbook", "title": "no real_id"}, + {"id": "p1-cd34", "kind": "profile", "title": "empty", "real_id": ""}, + {"id": "r1-9999", "kind": "playbook", "title": "ok", "real_id": "pb_9"}, + ] + result = state._to_wire_citations(items) + assert len(result) == 1 + assert result[0]["real_id"] == "pb_9" + + +def test_to_wire_citations_handles_non_list_input() -> None: + """None / dict / str inputs return ``[]`` without raising.""" + assert state._to_wire_citations(None) == [] + assert state._to_wire_citations({"id": "r1-ab12"}) == [] + assert state._to_wire_citations("oops") == [] + + +def test_to_wire_citations_skips_non_dict_items() -> None: + """A list that mixes dicts and junk only emits the dict entries.""" + items = [ + "a-string", + None, + 42, + {"id": "r1-ab12", "kind": "playbook", "title": "ok", "real_id": "pb_1"}, + ] + result = state._to_wire_citations(items) + assert len(result) == 1 + assert result[0]["tag"] == "r1-ab12" + + def _append_worker(state_dir: str, session_id: str, payload: str) -> None: # Child processes inherit env after fork, so CLAUDE_SMART_STATE_DIR is # already set. Belt-and-suspenders: reassert it. From fc2f6b70c5286d3adaff5b4ddadbb4c7b019614d Mon Sep 17 00:00:00 2001 From: Yi Lu Date: Mon, 27 Apr 2026 23:38:54 -0300 Subject: [PATCH 2/2] fix(state): only attach citations and tools_used to Assistant turns User turns shouldn't carry citations or tool_used metadata; restrict the citation/pending_tools attachment to Assistant turns and harden the _to_wire_citations input checks against non-string id/title/real_id. --- plugin/src/claude_smart/state.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/plugin/src/claude_smart/state.py b/plugin/src/claude_smart/state.py index 18fdb7f..ea45773 100644 --- a/plugin/src/claude_smart/state.py +++ b/plugin/src/claude_smart/state.py @@ -195,14 +195,18 @@ def _to_wire_citations(cited_items: Any) -> list[dict[str, str]]: continue real_id = item.get("real_id") kind = item.get("kind") - if not real_id or kind not in _VALID_CITATION_KINDS: + if not isinstance(real_id, str) or not real_id: continue + if kind not in _VALID_CITATION_KINDS: + continue + tag = item.get("id") + title = item.get("title") out.append( { "kind": kind, "real_id": real_id, - "tag": item.get("id", ""), - "title": item.get("title", ""), + "tag": tag if isinstance(tag, str) else "", + "title": title if isinstance(title, str) else "", } ) return out @@ -261,11 +265,12 @@ def unpublished_slice( k: v for k, v in rec.items() if k not in {"role", "ts", "cited_items"} } turn["role"] = role - citations = _to_wire_citations(rec.get("cited_items")) - if citations: - turn["citations"] = citations - if role == "Assistant" and pending_tools: - turn["tools_used"] = pending_tools - pending_tools = [] + if role == "Assistant": + citations = _to_wire_citations(rec.get("cited_items")) + if citations: + turn["citations"] = citations + if pending_tools: + turn["tools_used"] = pending_tools + pending_tools = [] turns.append(turn) return published, turns