Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions plugin/src/claude_smart/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,59 @@

from __future__ import annotations

from typing import Literal
import uuid
from typing import Any, Literal

from claude_smart import state
from claude_smart.reflexio_adapter import Adapter

PublishStatus = Literal["nothing", "ok", "failed"]


def _publish_request_id(session_id: str, start: int, end: int) -> str:
"""Return a stable request ID for one buffered publish range."""

name = f"claude-smart:{session_id}:{start}:{end}"
return str(uuid.uuid5(uuid.NAMESPACE_URL, name))


def _prepare_publish_batch(
session_id: str,
) -> tuple[int, int, list[dict[str, Any]]] | None:
"""Freeze and return one canonical unpublished record range."""

while True:
records = state.read_all(session_id)
published, available = state.unpublished_slice(records)
if not available:
return None

publish_end = state.pending_publish_end(records, published)
if publish_end is None:
publish_end = state.safe_publish_end(records, published)
_, proposed = state.unpublished_slice(
records[:publish_end], published_override=published
)
if not proposed:
return None
state.append(
session_id,
{"publish_attempt": {"start": published, "end": publish_end}},
)

canonical = state.read_all(session_id)
if state.published_record_offset(canonical) != published:
continue
canonical_end = state.pending_publish_end(canonical, published)
if canonical_end is None:
continue
_, interactions = state.unpublished_slice(
canonical[:canonical_end], published_override=published
)
if interactions:
return published, canonical_end, interactions


def publish_unpublished(
*,
session_id: str,
Expand Down Expand Up @@ -56,20 +101,24 @@ def publish_unpublished(
was unreachable. On ``"failed"`` the watermark is not advanced,
so the next hook retries the same batch.
"""
records = state.read_all(session_id)
_, interactions = state.unpublished_slice(records)
if not interactions:
batch = _prepare_publish_batch(session_id)
if batch is None:
return ("nothing", 0)
published_record_offset, publish_end, interactions = batch

client = adapter if adapter is not None else Adapter()
ok = client.publish(
session_id=session_id,
project_id=project_id,
request_id=_publish_request_id(
session_id, published_record_offset, publish_end
),
interactions=interactions,
force_extraction=force_extraction,
override_learning_stall=override_learning_stall,
skip_aggregation=skip_aggregation,
)
if ok:
state.append(session_id, {"published_up_to": len(records)})
state.append(session_id, {"published_up_to": publish_end})
return ("ok", len(interactions))
return ("failed", len(interactions))
77 changes: 76 additions & 1 deletion plugin/src/claude_smart/reflexio_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def publish(
*,
session_id: str,
project_id: str,
request_id: str | None = None,
interactions: Sequence[dict[str, Any]],
force_extraction: bool = False,
override_learning_stall: bool = False,
Expand All @@ -121,9 +122,57 @@ def publish(
if client is None:
return False
try:
interaction_list = list(interactions)
if _needs_raw_retrieved_learning_publish(interaction_list):
raw_request = getattr(client, "_make_request", None)
if request_id is not None and callable(raw_request):
payload = {
"request_id": request_id,
"user_id": project_id,
"interaction_data_list": interaction_list,
"agent_version": runtime.agent_version(),
"session_id": session_id,
"skip_aggregation": skip_aggregation,
"force_extraction": force_extraction,
"evaluation_only": False,
"override_learning_stall": override_learning_stall,
}
try:
raw_request(
"POST",
"/api/publish_interaction",
json=payload,
params=None,
)
return True
except Exception as exc: # noqa: BLE001
_LOGGER.warning(
"Could not confirm retrieved-learning links; retrying "
"the base interaction with the same request ID: %s",
exc,
)
fallback_payload = {
**payload,
"interaction_data_list": _without_retrieved_learnings(
interaction_list
),
}
raw_request(
"POST",
"/api/publish_interaction",
json=fallback_payload,
params=None,
)
return True
else:
_LOGGER.warning(
"Stable raw publishing is unavailable; publishing "
"without optional retrieved-learning links"
)
interaction_list = _without_retrieved_learnings(interaction_list)
kwargs = {
"user_id": project_id,
"interactions": list(interactions),
"interactions": interaction_list,
"agent_version": runtime.agent_version(),
"session_id": session_id,
"wait_for_response": False,
Expand Down Expand Up @@ -476,6 +525,32 @@ def _supports_keyword(callable_obj: Any, keyword: str) -> bool:
return keyword in signature.parameters


def _needs_raw_retrieved_learning_publish(
interactions: Sequence[dict[str, Any]],
) -> bool:
"""Return whether links require a caller-stable raw request.

The public client does not accept a caller-supplied request ID, and older
clients also strip this field. The authenticated helper preserves both.
"""
return any(item.get("retrieved_learnings") for item in interactions)


def _without_retrieved_learnings(
interactions: Sequence[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Copy interactions without the optional compatibility-only field."""

return [
{
key: value
for key, value in interaction.items()
if key != "retrieved_learnings"
}
for interaction in interactions
]


def _filter_rejected_agent_playbooks(items: list[Any]) -> list[Any]:
"""Drop rejected shared skills defensively, even if an older backend ignores filters."""
return [
Expand Down
Loading
Loading