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
2 changes: 2 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,7 @@ class SessionsConfig:
memorize_interval_minutes: int = 30 # Background memorization sweep interval
sticky_period_minutes: int = 120 # Reuse session if active within this window
client_idle_timeout_minutes: int = 60 # Auto-disconnect clients idle longer than this (0 = disabled)
star_project_hook: bool = False # opt-in; fire an internal agent turn on star/unstar transition

@classmethod
def from_dict(cls, d: dict) -> SessionsConfig:
Expand All @@ -718,6 +719,7 @@ def from_dict(cls, d: dict) -> SessionsConfig:
memorize_interval_minutes=d.get("memorize_interval_minutes", 30),
sticky_period_minutes=d.get("sticky_period_minutes", 120),
client_idle_timeout_minutes=d.get("client_idle_timeout_minutes", 60),
star_project_hook=bool(d.get("star_project_hook", False)),
)


Expand Down
32 changes: 31 additions & 1 deletion nerve/gateway/routes/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,12 @@ async def get_messages(session_id: str, limit: int = 500, user: dict = Depends(r

@router.patch("/api/sessions/{session_id}")
async def update_session(session_id: str, req: dict, user: dict = Depends(require_auth)):
"""Update session fields (title, starred)."""
"""Update session fields (title, starred).

When ``sessions.star_project_hook`` is enabled (default off), a ``starred``
0<->1 transition fires a one-shot internal turn in that same session so the
agent can promote it to a project (create its Task card) or archive it.
"""
deps = get_deps()
session = await deps.db.get_session(session_id)
if not session:
Expand All @@ -174,8 +179,33 @@ async def update_session(session_id: str, req: dict, user: dict = Depends(requir
fields["starred"] = 1 if req["starred"] else 0
if not fields:
raise HTTPException(status_code=400, detail="No valid fields to update")
old_starred = int(session.get("starred") or 0)
await deps.db.update_session_fields(session_id, fields)
updated = await deps.db.get_session(session_id)
# Star = opt-in project registration (sessions.star_project_hook, default
# off). On a starred transition fire a one-shot internal turn.
if (get_config().sessions.star_project_hook and "starred" in fields
and deps.engine and fields["starred"] != old_starred):
promoted = fields["starred"] == 1
trigger = (
"[system:star-promote] This session was just STARRED, which registers it as a "
"PROJECT. Per the multi-session protocol: create or refresh this project's Task card "
"(the umbrella for this session and its children), register it in the task-heartbeat "
"registry, then stop. Keep that card updated at the end of every future turn."
if promoted else
"[system:star-unpromote] This session was just UNSTARRED, so it is no longer a "
"project. Archive its project Task (keep the ## Updates audit trail) and deregister it "
"from the task-heartbeat registry, then stop."
)
try:
asyncio.create_task(
deps.engine.run(
session_id=session_id, user_message=trigger,
source="web", internal=True,
)
)
except Exception as e: # a hook failure must never break the PATCH
logger.warning("star-project hook failed for %s: %s", session_id, e)
return updated


Expand Down
Loading