From 8f8b7b120d3b877b21b766dde86d434386d02d1c Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Sat, 20 Jun 2026 20:13:35 +0000
Subject: [PATCH 01/15] feat: permanent direction notes/persistent directions
---
backend/api/routes/conversations.py | 51 +++
backend/api/schemas.py | 9 +-
backend/database/__init__.py | 12 +
.../migrations/0035_direction_notes.py | 51 +++
backend/database/models.py | 15 +
backend/database/queries/direction_notes.py | 69 +++
backend/database/queries/settings.py | 3 +
backend/database/schema.py | 16 +
backend/database/seeds.py | 17 +
backend/inference/__init__.py | 8 +
backend/inference/prompt_builder.py | 61 +++
backend/inference/tool_registry.py | 55 ++-
backend/pipeline/config.py | 39 +-
backend/pipeline/context.py | 7 +-
backend/pipeline/entrypoints.py | 25 ++
backend/pipeline/orchestrator.py | 73 ++-
backend/pipeline/passes/director/__init__.py | 8 +
.../passes/director/direction_note.py | 149 +++++++
backend/pipeline/passes/director/director.py | 28 +-
backend/pipeline/persistence.py | 7 +
backend/pipeline/predicates.py | 45 +-
backend/pipeline/state.py | 5 +
frontend/app.js | 16 +
frontend/chat_conversations.js | 5 +
frontend/chat_inspector.js | 54 +--
frontend/chat_messages.js | 3 +
frontend/chat_stream.js | 19 +
frontend/css/base.css | 19 +-
frontend/css/inspector.css | 48 ++
frontend/css/tools.css | 28 ++
frontend/direction_notes_panel.js | 122 +++++
frontend/index.html | 6 +
frontend/library_fragments.js | 28 +-
frontend/mobile.css | 11 +-
frontend/mobile.js | 8 +
frontend/panels.js | 44 ++
frontend/settings.js | 83 ++--
frontend/state.js | 4 +
frontend/validate.js | 2 +-
tests/integration/_llm_mock.py | 22 +
tests/integration/test_direction_notes.py | 418 ++++++++++++++++++
.../test_preset_schema_coverage.py | 1 +
tests/unit/test_interactive_fragments.py | 3 +-
tests/unit/test_kv_cache_invariants.py | 88 +++-
tests/unit/test_tool_registry.py | 2 +
45 files changed, 1688 insertions(+), 99 deletions(-)
create mode 100644 backend/database/migrations/0035_direction_notes.py
create mode 100644 backend/database/queries/direction_notes.py
create mode 100644 backend/pipeline/passes/director/direction_note.py
create mode 100644 frontend/direction_notes_panel.js
create mode 100644 frontend/panels.js
create mode 100644 tests/integration/test_direction_notes.py
diff --git a/backend/api/routes/conversations.py b/backend/api/routes/conversations.py
index fcd104b2..d0d243cb 100644
--- a/backend/api/routes/conversations.py
+++ b/backend/api/routes/conversations.py
@@ -15,11 +15,14 @@
add_message,
create_conversation,
delete_conversation,
+ delete_direction_note,
fork_conversation,
get_active_lorebook_entries,
get_character_card,
get_conversation,
get_conversation_logs,
+ get_direction_notes_for_message,
+ get_direction_notes_for_path,
get_director_log_for_message,
get_director_state,
get_interactive_fragments,
@@ -35,6 +38,7 @@
set_active_leaf,
touch_conversation,
update_conversation,
+ update_direction_note,
update_director_state,
user_attachment_payloads,
)
@@ -49,6 +53,7 @@
CompressRequest,
ConversationCreate,
ConversationUpdate,
+ DirectionNoteUpdate,
SummarizeRequest,
)
@@ -457,6 +462,14 @@ async def api_get_message_director_log(cid: str, msg_id: int):
msg = await get_message_by_id(msg_id)
if not msg or msg.get("conversation_id") != cid:
raise HTTPException(status_code=404, detail="Message not found")
+ direction_notes = [
+ {
+ "interactive_fragment_id": r["interactive_fragment_id"],
+ "interactive_fragment_label": r["interactive_fragment_label"],
+ "content": r["content"],
+ }
+ for r in await get_direction_notes_for_message(msg_id)
+ ]
log = await get_director_log_for_message(msg_id)
if not log:
return {
@@ -468,6 +481,7 @@ async def api_get_message_director_log(cid: str, msg_id: int):
"reasoning_writer": "",
"reasoning_editor": "",
"feedback": {},
+ "direction_notes": direction_notes,
}
return {
"active_moods": log.get("active_moods_after", []),
@@ -478,4 +492,41 @@ async def api_get_message_director_log(cid: str, msg_id: int):
"reasoning_writer": log.get("reasoning_writer") or "",
"reasoning_editor": log.get("reasoning_editor") or "",
"feedback": log.get("feedback", {}) or {},
+ "direction_notes": direction_notes,
}
+
+
+@router.get("/api/conversations/{cid}/direction-notes")
+async def api_list_direction_notes(cid: str):
+ conv = await get_conversation(cid)
+ if not conv:
+ raise HTTPException(status_code=404, detail="Conversation not found")
+ messages = await get_messages(cid)
+ by_id = {m["id"]: m for m in messages}
+ rows = await get_direction_notes_for_path(cid, list(by_id))
+ return [
+ {
+ "id": r["id"],
+ "interactive_fragment_id": r["interactive_fragment_id"],
+ "interactive_fragment_label": r["interactive_fragment_label"],
+ "content": r["content"],
+ "message_id": r["message_id"],
+ "turn_index": by_id[r["message_id"]]["turn_index"],
+ }
+ for r in rows
+ ]
+
+
+@router.put("/api/conversations/{cid}/direction-notes/{fid}")
+async def api_update_direction_note(cid: str, fid: int, data: DirectionNoteUpdate):
+ updated = await update_direction_note(fid, data.content)
+ if not updated:
+ raise HTTPException(status_code=404, detail="Note not found")
+ return updated
+
+
+@router.delete("/api/conversations/{cid}/direction-notes/{fid}")
+async def api_delete_direction_note(cid: str, fid: int):
+ if not await delete_direction_note(fid):
+ raise HTTPException(status_code=404, detail="Note not found")
+ return {"ok": True}
diff --git a/backend/api/schemas.py b/backend/api/schemas.py
index 4d4371b7..d84bf6ed 100644
--- a/backend/api/schemas.py
+++ b/backend/api/schemas.py
@@ -7,7 +7,7 @@
from __future__ import annotations
-from typing import Any, List, Optional
+from typing import Any, List, Literal, Optional
from pydantic import BaseModel, field_validator
@@ -49,10 +49,17 @@ class SettingsUpdate(BaseModel):
agent_shared_system_prompt: Optional[str] = None
feedback_enabled: Optional[bool] = None
director_individual_fragments: Optional[bool] = None
+ direction_notes_mode: Optional[Literal["off", "pre_writer", "post_turn"]] = None
+ direction_notes_inject: Optional[bool] = None
+ direction_notes_recipient: Optional[Literal["director", "writer", "both"]] = None
inspector_open_states: Optional[dict] = None
workflows_globally_enabled: Optional[bool] = None
+class DirectionNoteUpdate(BaseModel):
+ content: str
+
+
class WorkflowConfigUpdate(BaseModel):
# Required (no default): a body lacking "config" is a 422, not a silent
# clear; an explicit {"config": {}} is the intentional reset-to-defaults.
diff --git a/backend/database/__init__.py b/backend/database/__init__.py
index 47c1415e..c0eef52b 100644
--- a/backend/database/__init__.py
+++ b/backend/database/__init__.py
@@ -38,6 +38,13 @@
touch_conversation,
update_conversation,
)
+from .queries.direction_notes import (
+ create_direction_notes,
+ delete_direction_note,
+ get_direction_notes_for_message,
+ get_direction_notes_for_path,
+ update_direction_note,
+)
from .queries.director_state import get_director_state, update_director_state
from .queries.endpoints import (
create_endpoint,
@@ -148,6 +155,7 @@
"create_lorebook_entry",
"create_model_config",
"create_mood_fragment",
+ "create_direction_notes",
"create_user_persona",
"create_world",
"delete_character_card",
@@ -158,6 +166,7 @@
"delete_message_with_descendants",
"delete_model_config",
"delete_mood_fragment",
+ "delete_direction_note",
"delete_phrase_group",
"delete_user_persona",
"delete_world",
@@ -186,6 +195,8 @@
"get_mood_fragments",
"get_moods_before_turn",
"get_path_to_leaf",
+ "get_direction_notes_for_message",
+ "get_direction_notes_for_path",
"get_phrase_bank",
"get_phrase_bank_rows",
"get_settings",
@@ -228,6 +239,7 @@
"update_message_content",
"update_model_config",
"update_mood_fragment",
+ "update_direction_note",
"update_phrase_group",
"update_settings",
"update_user_persona",
diff --git a/backend/database/migrations/0035_direction_notes.py b/backend/database/migrations/0035_direction_notes.py
new file mode 100644
index 00000000..f7940621
--- /dev/null
+++ b/backend/database/migrations/0035_direction_notes.py
@@ -0,0 +1,51 @@
+"""Add the ``direction_notes`` table, its settings, and the default fragment.
+
+Fresh databases get the table/settings from ``schema.py`` and the default fragment
+from ``SEED_INTERACTIVE_FRAGMENTS``; this backfills existing ones. The table DDL is
+sourced from ``schema.py`` so the backfilled shape cannot drift from the fresh-install
+shape. ``direction_notes_mode`` default ``'off'`` keeps recording disabled until opted
+in; ``direction_notes_inject`` default ``1`` injects stored notes by default once
+recording produces any; ``direction_notes_recipient`` default ``'both'`` feeds them to
+the director and writer.
+"""
+
+from __future__ import annotations
+
+import sqlite3
+
+from ..schema import table_create_sql
+
+
+def migrate(conn: sqlite3.Connection) -> None:
+ conn.execute(table_create_sql("direction_notes"))
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_dirnote_message ON direction_notes(message_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_dirnote_conversation ON direction_notes(conversation_id)")
+
+ cols = {row[1] for row in conn.execute("PRAGMA table_info(settings)").fetchall()}
+ if "direction_notes_mode" not in cols:
+ conn.execute("ALTER TABLE settings ADD COLUMN direction_notes_mode TEXT NOT NULL DEFAULT 'off'")
+ print("[migrations] 0035: added direction_notes_mode column to settings")
+ if "direction_notes_inject" not in cols:
+ conn.execute("ALTER TABLE settings ADD COLUMN direction_notes_inject INTEGER NOT NULL DEFAULT 1")
+ print("[migrations] 0035: added direction_notes_inject column to settings")
+ if "direction_notes_recipient" not in cols:
+ conn.execute("ALTER TABLE settings ADD COLUMN direction_notes_recipient TEXT NOT NULL DEFAULT 'both'")
+ print("[migrations] 0035: added direction_notes_recipient column to settings")
+
+ # Ship the default direction_note fragment to existing installs. Fresh installs get
+ # it from SEED_INTERACTIVE_FRAGMENTS, which runs before migrations, so the guard makes
+ # this a no-op there. Frozen copy of that seed entry (migrations must not drift with
+ # later seed edits); keep the two in sync when changing the default.
+ frag_ids = {row[0] for row in conn.execute("SELECT id FROM interactive_fragments").fetchall()}
+ if "story_direction" not in frag_ids:
+ conn.execute(
+ "INSERT INTO interactive_fragments "
+ "(id, label, description, field_type, required, enabled, injection_label, sort_order) "
+ "VALUES ('story_direction', 'Story Direction', ?, 'direction_note', 0, 0, 'Story direction', 6)",
+ (
+ "Record a lasting development worth keeping for the rest of this branch: the direction of "
+ "travel, an established fact, or a change to a character and the reason for it. Leave empty "
+ "unless something genuinely new constrains future replies.",
+ ),
+ )
+ print("[migrations] 0035: seeded the default 'story_direction' direction_note fragment")
diff --git a/backend/database/models.py b/backend/database/models.py
index 2be7c1f4..90414fa7 100644
--- a/backend/database/models.py
+++ b/backend/database/models.py
@@ -98,6 +98,9 @@ class _SettingsBase(TypedDict):
agent_shared_system_prompt: str
feedback_enabled: int
director_individual_fragments: int
+ direction_notes_mode: str
+ direction_notes_inject: int
+ direction_notes_recipient: str
workflows_globally_enabled: int
@@ -365,6 +368,18 @@ class MoodFragmentRow(TypedDict):
enabled: int
+class DirectionNoteRow(TypedDict):
+ """A row from ``direction_notes`` (``SELECT *``)."""
+
+ id: int
+ conversation_id: str
+ message_id: int
+ interactive_fragment_id: str
+ interactive_fragment_label: str
+ content: str
+ created_at: str
+
+
class DirectorStateRow(TypedDict):
"""The director-state dict returned by ``get_director_state()``.
diff --git a/backend/database/queries/direction_notes.py b/backend/database/queries/direction_notes.py
new file mode 100644
index 00000000..693b5b78
--- /dev/null
+++ b/backend/database/queries/direction_notes.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any, Mapping, Sequence, cast
+
+from ..connection import get_db
+from ..models import DirectionNoteRow
+
+
+async def create_direction_notes(conversation_id: str, message_id: int, notes: Sequence[Mapping[str, Any]]) -> list[int]:
+ """Persist labelled notes (``interactive_fragment_id``/``interactive_fragment_label``/``content``) for one
+ assistant message; returns the new row ids."""
+ ids: list[int] = []
+ now = datetime.now(timezone.utc).isoformat()
+ async with get_db() as db:
+ for n in notes:
+ cur = await db.execute(
+ "INSERT INTO direction_notes "
+ "(conversation_id, message_id, interactive_fragment_id, interactive_fragment_label, content, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?)",
+ (conversation_id, message_id, n["interactive_fragment_id"], n["interactive_fragment_label"], n["content"], now),
+ )
+ row_id = cur.lastrowid
+ assert row_id is not None
+ ids.append(row_id)
+ await db.commit()
+ return ids
+
+
+async def get_direction_notes_for_path(conversation_id: str, path_message_ids: Sequence[int]) -> list[DirectionNoteRow]:
+ """Notes whose authoring message lies on the given active path, oldest first."""
+ # An empty IN list is a SQL syntax error; the caller's path is empty only before the first reply.
+ if not path_message_ids:
+ return []
+ placeholders = ",".join("?" for _ in path_message_ids)
+ async with get_db() as db:
+ rows = list(
+ await db.execute_fetchall(
+ f"SELECT * FROM direction_notes WHERE conversation_id = ? AND message_id IN ({placeholders}) ORDER BY id ASC", # nosec B608 -- placeholders are a fixed-count '?' list, values parameterised
+ (conversation_id, *path_message_ids),
+ )
+ )
+ return [cast(DirectionNoteRow, dict(r)) for r in rows]
+
+
+async def get_direction_notes_for_message(message_id: int) -> list[DirectionNoteRow]:
+ async with get_db() as db:
+ rows = list(
+ await db.execute_fetchall(
+ "SELECT * FROM direction_notes WHERE message_id = ? ORDER BY id ASC",
+ (message_id,),
+ )
+ )
+ return [cast(DirectionNoteRow, dict(r)) for r in rows]
+
+
+async def update_direction_note(fid: int, content: str) -> DirectionNoteRow | None:
+ async with get_db() as db:
+ await db.execute("UPDATE direction_notes SET content = ? WHERE id = ?", (content, fid))
+ await db.commit()
+ rows = list(await db.execute_fetchall("SELECT * FROM direction_notes WHERE id = ?", (fid,)))
+ return cast(DirectionNoteRow, dict(rows[0])) if rows else None
+
+
+async def delete_direction_note(fid: int) -> bool:
+ async with get_db() as db:
+ cur = await db.execute("DELETE FROM direction_notes WHERE id = ?", (fid,))
+ await db.commit()
+ return cur.rowcount > 0
diff --git a/backend/database/queries/settings.py b/backend/database/queries/settings.py
index 706ef99b..1aa76636 100644
--- a/backend/database/queries/settings.py
+++ b/backend/database/queries/settings.py
@@ -226,6 +226,9 @@ async def update_settings(data: dict) -> SettingsRow:
"agent_shared_system_prompt",
"feedback_enabled",
"director_individual_fragments",
+ "direction_notes_mode",
+ "direction_notes_inject",
+ "direction_notes_recipient",
"inspector_open_states",
"workflows_globally_enabled",
]
diff --git a/backend/database/schema.py b/backend/database/schema.py
index 86cbd6d4..7bd8dce2 100644
--- a/backend/database/schema.py
+++ b/backend/database/schema.py
@@ -39,6 +39,9 @@
agent_shared_system_prompt TEXT NOT NULL DEFAULT '',
feedback_enabled INTEGER NOT NULL DEFAULT 0,
director_individual_fragments INTEGER NOT NULL DEFAULT 0,
+ direction_notes_mode TEXT NOT NULL DEFAULT 'off',
+ direction_notes_inject INTEGER NOT NULL DEFAULT 1,
+ direction_notes_recipient TEXT NOT NULL DEFAULT 'both',
inspector_open_states TEXT NOT NULL DEFAULT '{"reasoning":true,"tool_calls":false,"injection_block":false,"context_size":true}',
workflow_config TEXT NOT NULL DEFAULT '{}',
workflows_globally_enabled INTEGER NOT NULL DEFAULT 1,
@@ -248,6 +251,19 @@
updated_at TEXT NOT NULL
);
+CREATE TABLE IF NOT EXISTS direction_notes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
+ interactive_fragment_id TEXT NOT NULL DEFAULT '',
+ interactive_fragment_label TEXT NOT NULL DEFAULT '',
+ content TEXT NOT NULL,
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_dirnote_message ON direction_notes(message_id);
+CREATE INDEX IF NOT EXISTS idx_dirnote_conversation ON direction_notes(conversation_id);
+
"""
diff --git a/backend/database/seeds.py b/backend/database/seeds.py
index 0c41034e..ffd105b3 100644
--- a/backend/database/seeds.py
+++ b/backend/database/seeds.py
@@ -154,6 +154,20 @@
"sort_order": 5,
"enabled": False,
},
+ {
+ "id": "story_direction",
+ "label": "Story Direction",
+ "description": (
+ "Record a lasting development worth keeping for the rest of this branch: the direction of "
+ "travel, an established fact, or a change to a character and the reason for it. Leave empty "
+ "unless something genuinely new constrains future replies."
+ ),
+ "field_type": "direction_note",
+ "required": False,
+ "injection_label": "Story direction",
+ "sort_order": 6,
+ "enabled": False,
+ },
]
DEFAULT_ENABLED_TOOLS = {
@@ -201,6 +215,9 @@
"agent_shared_system_prompt": "",
"feedback_enabled": 0,
"director_individual_fragments": 0,
+ "direction_notes_mode": "off",
+ "direction_notes_inject": 1,
+ "direction_notes_recipient": "both",
"workflows_globally_enabled": 1,
}
diff --git a/backend/inference/__init__.py b/backend/inference/__init__.py
index 0d72f062..4f82957b 100644
--- a/backend/inference/__init__.py
+++ b/backend/inference/__init__.py
@@ -12,6 +12,7 @@
from .endpoint_profiles import ModelProfile, is_forced_tool_choice, profile_for
from .kv_tracker import _KVCacheTracker
from .prompt_builder import (
+ build_direction_note_prompt,
build_director_scene_step_prompt,
build_director_tool_prompt,
build_editor_prompt,
@@ -20,15 +21,18 @@
build_style_injection,
compute_style_injection_block,
format_message_with_attachments,
+ render_direction_notes_block,
)
from .tool_registry import (
BUILTIN_TOOL_NAMES,
GIVE_FEEDBACK_CHOICE,
POST_WRITER_TOOLS,
PRE_WRITER_TOOLS,
+ RECORD_DIRECTION_NOTE_CHOICE,
STANDALONE_TOOLS,
TOOLS,
build_direct_scene_tool,
+ build_direction_note_tool,
build_feedback_tool,
enabled_schemas,
register_tool,
@@ -52,19 +56,23 @@
"build_director_tool_prompt",
"build_editor_prompt",
"build_feedback_prompt",
+ "build_direction_note_prompt",
"build_prefix",
"build_style_injection",
"compute_style_injection_block",
"format_message_with_attachments",
+ "render_direction_notes_block",
# tool_registry
"BUILTIN_TOOL_NAMES",
"GIVE_FEEDBACK_CHOICE",
"POST_WRITER_TOOLS",
"PRE_WRITER_TOOLS",
+ "RECORD_DIRECTION_NOTE_CHOICE",
"STANDALONE_TOOLS",
"TOOLS",
"build_direct_scene_tool",
"build_feedback_tool",
+ "build_direction_note_tool",
"enabled_schemas",
"register_tool",
]
diff --git a/backend/inference/prompt_builder.py b/backend/inference/prompt_builder.py
index 661e9707..3fbf4845 100644
--- a/backend/inference/prompt_builder.py
+++ b/backend/inference/prompt_builder.py
@@ -338,6 +338,67 @@ def build_feedback_prompt(
return "\n\n".join(parts)
+DIRECTION_NOTE_PREAMBLE = (
+ "[OOC: Pause the roleplay and step out of character. For each category below, decide whether "
+ "anything from what just happened should be remembered for the rest of this story branch: a "
+ "lasting change to a character, an established fact, or the direction of travel -- and the "
+ "reason behind it. Keep the story unpredictable; record only what genuinely constrains or "
+ "steers future replies. Fill a category's parameter only when it has something new worth "
+ "recording this turn, and leave the rest empty."
+)
+
+
+def _direction_notes_lines(notes: Sequence[Mapping[str, Any]]) -> str:
+ """One line per note in the given order (oldest-first, i.e. turn order), each tagged
+ with its authoring fragment's label and the turn it was recorded on."""
+ lines = []
+ for n in notes:
+ turn = n.get("turn_index")
+ tag = f"{n['interactive_fragment_label']}, turn {turn}" if turn is not None else n["interactive_fragment_label"]
+ lines.append(f"- ({tag}) {n['content']}")
+ return "\n".join(lines)
+
+
+def render_direction_notes_block(notes: Sequence[Mapping[str, Any]]) -> str:
+ """Render the active direction notes as a Scene Direction sub-block, or '' when empty.
+
+ Notes are listed in turn order, each prefixed with the label of the fragment that
+ authored it so the writer can tell which directive a note belongs to.
+ """
+ if not notes:
+ return ""
+ return f"**Direction Notes**\n{_direction_notes_lines(notes)}"
+
+
+def build_direction_note_prompt(
+ active_notes: Sequence[Mapping[str, Any]],
+ direction_note_fragments: Sequence[Mapping[str, Any]],
+ *,
+ inj_block: str | None = None,
+ reasoning_on: bool = False,
+ tool_schema: dict | None = None,
+) -> str:
+ """Build the request message for the direction-note step.
+
+ *active_notes* are already in effect on this branch; they are listed in turn order
+ (each labelled with its fragment) so the model evolves them rather than restating
+ them. *inj_block* is this turn's scene direction, passed for the pre-writer placement;
+ the post-turn placement omits it because the finished reply is already replayed in the
+ message history. Each parameter id is paired with its fragment's label so the model
+ knows what each opaque category id means.
+ """
+ preamble = DIRECTION_NOTE_PREAMBLE + (REASONING_GUIDANCE if reasoning_on else "")
+ parts = [preamble]
+ if active_notes:
+ parts.append("Already recorded (do not repeat these):\n" + _direction_notes_lines(active_notes))
+ if inj_block:
+ parts.append(inj_block)
+ if tool_schema is not None:
+ labels = {df["id"]: (df.get("injection_label") or df.get("label") or "").strip() for df in direction_note_fragments}
+ parts.append(_tool_call_instruction("record_direction_note", tool_schema, labels=labels))
+ return "\n\n".join(parts)
+
+
def build_editor_prompt(
has_audit_issues: bool,
report_text: str,
diff --git a/backend/inference/tool_registry.py b/backend/inference/tool_registry.py
index 8416b137..5ec1260f 100644
--- a/backend/inference/tool_registry.py
+++ b/backend/inference/tool_registry.py
@@ -130,6 +130,50 @@ def build_feedback_tool(feedback_fragments: Sequence[Mapping[str, Any]]) -> dict
GIVE_FEEDBACK_CHOICE = {"type": "function", "function": {"name": "give_feedback"}}
+_RECORD_DIRECTION_NOTE_DESCRIPTION = (
+ "Record lasting director notes that should persist for the rest of this story branch. "
+ "Each parameter is one category of note; fill only the categories that have something "
+ "new worth remembering this turn, and leave the rest empty."
+)
+
+
+def build_direction_note_tool(direction_note_fragments: Sequence[Mapping[str, Any]]) -> dict:
+ """Build the ``record_direction_note`` tool schema from the enabled direction-note fragments.
+
+ Each ``field_type="direction_note"`` fragment contributes one string parameter
+ (keyed by fragment id); there are no fixed parameters. Returns an OpenAI
+ function-calling format dict.
+
+ The schema rides the shared per-turn tools blob (via ``schema_overrides``) so
+ the direction-note step can force ``tool_choice=record_direction_note`` without
+ a cache miss.
+ """
+ properties: dict = {}
+ required: list[str] = []
+
+ for df in direction_note_fragments:
+ fid = df["id"]
+ properties[fid] = {"type": "string", "description": df["description"]}
+ if df.get("required"):
+ required.append(fid)
+
+ return {
+ "type": "function",
+ "function": {
+ "name": "record_direction_note",
+ "description": _RECORD_DIRECTION_NOTE_DESCRIPTION,
+ "parameters": {
+ "type": "object",
+ "properties": properties,
+ "required": required,
+ },
+ },
+ }
+
+
+RECORD_DIRECTION_NOTE_CHOICE = {"type": "function", "function": {"name": "record_direction_note"}}
+
+
REWRITE_PROMPT_TOOL = {
"type": "function",
"function": {
@@ -227,6 +271,14 @@ def build_feedback_tool(feedback_fragments: Sequence[Mapping[str, Any]]) -> dict
"choice": GIVE_FEEDBACK_CHOICE,
"schema": build_feedback_tool([]),
},
+ # Internal, mode-gated (never user-toggleable). The empty-properties placeholder
+ # is overridden per-turn via schema_overrides with build_direction_note_tool(direction_note_
+ # fragments); registering it here emits its bytes into the shared blob so the
+ # direction-note step reuses the cached base.
+ "record_direction_note": {
+ "choice": RECORD_DIRECTION_NOTE_CHOICE,
+ "schema": build_direction_note_tool([]),
+ },
}
# Built-in tool names declared as a literal and asserted equal to TOOLS keys at
@@ -239,6 +291,7 @@ def build_feedback_tool(feedback_fragments: Sequence[Mapping[str, Any]]) -> dict
"editor_apply_patch",
"editor_rewrite",
"give_feedback",
+ "record_direction_note",
}
)
assert BUILTIN_TOOL_NAMES == frozenset(TOOLS.keys()), "BUILTIN_TOOL_NAMES drift vs TOOLS literal keys"
@@ -248,7 +301,7 @@ def build_feedback_tool(feedback_fragments: Sequence[Mapping[str, Any]]) -> dict
# feedback-step tool (pipeline/passes/editor/feedback.py): it rides the shared per-turn tools
# blob (Invariant 3) but must NOT be offered to or triggered by the director.
PRE_WRITER_TOOLS = {"direct_scene", "rewrite_user_prompt"}
-POST_WRITER_TOOLS = {"editor_apply_patch", "editor_rewrite", "give_feedback"}
+POST_WRITER_TOOLS = {"editor_apply_patch", "editor_rewrite", "give_feedback", "record_direction_note"}
assert PRE_WRITER_TOOLS.isdisjoint(POST_WRITER_TOOLS), "phase sets overlap"
assert PRE_WRITER_TOOLS | POST_WRITER_TOOLS == BUILTIN_TOOL_NAMES, "phase sets must partition built-ins"
diff --git a/backend/pipeline/config.py b/backend/pipeline/config.py
index 32502afa..edf57335 100644
--- a/backend/pipeline/config.py
+++ b/backend/pipeline/config.py
@@ -17,7 +17,12 @@
from ..core import ChatMessage, Macros
from ..database.models import PhraseGroup
-from ..inference import CachedBase, LLMClient, enabled_schemas
+from ..inference import (
+ CachedBase,
+ LLMClient,
+ build_direction_note_tool,
+ enabled_schemas,
+)
from ..workflows.enablement import disabled_workflow_tool_names
from .passes.director import build_direct_scene_override
from .passes.editor import _feedback_active, build_feedback_override
@@ -26,7 +31,7 @@
apply_length_guard_tools,
resolve_length_guard,
)
-from .predicates import agent_enabled, is_dual_model
+from .predicates import agent_enabled, direction_note_recording_active, is_dual_model
from .state import ModelLane, _PipelineConfig
@@ -107,17 +112,17 @@ def _resolve_pipeline_config(
def _split_interactive_fragments(
fragments: Sequence[Mapping[str, Any]],
-) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]]]:
- """Split interactive fragments into writer vs. feedback groups.
+) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]], list[Mapping[str, Any]]]:
+ """Split interactive fragments into writer, feedback, and direction-note groups.
- Returns ``(writer_fragments, feedback_fragments)``. Feedback-type fragments
- are surfaced to the user via the post-writer feedback step and never reach
- the writer prompt; all others shape the ``direct_scene`` tool and Scene
- Direction block.
+ Feedback-type fragments surface to the user via the post-writer feedback step;
+ direction-note-type fragments feed the direction-note step; all others shape the
+ ``direct_scene`` tool and Scene Direction block. The three groups are disjoint.
"""
- writer = [df for df in fragments if df.get("field_type") != "feedback"]
+ writer = [df for df in fragments if df.get("field_type") not in ("feedback", "direction_note")]
feedback = [df for df in fragments if df.get("field_type") == "feedback"]
- return writer, feedback
+ direction_note_fragments = [df for df in fragments if df.get("field_type") == "direction_note"]
+ return writer, feedback, direction_note_fragments
def _build_writer_tools_blob(
@@ -129,16 +134,20 @@ def _build_writer_tools_blob(
) -> dict:
"""Build the dynamic tool-schema overrides shared across all cached calls.
- Mutates *enabled_tools* in place to add ``give_feedback`` when the feedback
- step is active. Returns a ``schema_overrides`` dict (``direct_scene`` and
- optionally ``give_feedback``) held byte-stable across every cached call in a
- turn so the LLM's KV cache is not busted.
+ Mutates *enabled_tools* in place to enable ``give_feedback`` when the feedback
+ step is active and ``record_direction_note`` when the direction-note step is.
+ Returns a ``schema_overrides`` dict (``direct_scene`` and optionally
+ ``give_feedback``) held byte-stable across every cached call in a turn so the
+ LLM's KV cache is not busted.
Called by ``_prepare_turn``.
"""
- writer_fragments, feedback_fragments = _split_interactive_fragments(interactive_fragments)
+ writer_fragments, feedback_fragments, direction_note_fragments = _split_interactive_fragments(interactive_fragments)
overrides: dict = {"direct_scene": build_direct_scene_override(writer_fragments, agentic_lorebook=agentic_lorebook)}
if _feedback_active(settings, feedback_fragments, agent_on=agent_enabled(settings)):
overrides["give_feedback"] = build_feedback_override(feedback_fragments)
enabled_tools["give_feedback"] = True
+ if direction_note_recording_active(settings, direction_note_fragments, agent_on=agent_enabled(settings)):
+ overrides["record_direction_note"] = build_direction_note_tool(direction_note_fragments)
+ enabled_tools["record_direction_note"] = True
return overrides
diff --git a/backend/pipeline/context.py b/backend/pipeline/context.py
index 7f65d0db..40818ec7 100644
--- a/backend/pipeline/context.py
+++ b/backend/pipeline/context.py
@@ -27,7 +27,6 @@
ActiveLorebookEntryRow,
CharacterCardRow,
ConversationRow,
- DirectorStateRow,
InteractiveFragmentRow,
MoodFragmentRow,
PhraseGroup,
@@ -66,7 +65,9 @@ class PipelineContext:
settings: SettingsRow
conv: ConversationRow
card: Optional[CharacterCardRow]
- director: DirectorStateRow
+ # Seeded from director_state, then carried as mutable per-turn director state
+ # (active moods, progressive fields, direction notes); not all keys are columns.
+ director: dict[str, Any]
mood_fragments: list[MoodFragmentRow]
interactive_fragments: list[InteractiveFragmentRow]
phrase_bank: list[PhraseGroup]
@@ -96,7 +97,7 @@ async def _load_pipeline_context(conversation_id: str, *, abort_token: AbortToke
if not conv:
return None
- director = await db.get_director_state(conversation_id)
+ director: dict[str, Any] = dict(await db.get_director_state(conversation_id))
mood_fragments = await db.get_mood_fragments()
mood_fragments = [f for f in mood_fragments if f.get("enabled", True)]
# Prune active moods that reference disabled fragments.
diff --git a/backend/pipeline/entrypoints.py b/backend/pipeline/entrypoints.py
index c30c1596..7c171288 100644
--- a/backend/pipeline/entrypoints.py
+++ b/backend/pipeline/entrypoints.py
@@ -42,6 +42,28 @@
# ═══════════════════════════════════════════════════════════════════════════════
+async def _load_direction_notes(ctx: PipelineContext, conversation_id: str, path: Sequence[Mapping[str, Any]]) -> None:
+ """Seed ``ctx.director['direction_notes']`` with the active-branch notes.
+
+ Reconstructed from the messages on *path*, so the set is branch-correct; each note
+ carries its authoring fragment's label and the turn it was recorded on (mapped from
+ the path). Always loaded (cheap, empty when no notes exist) -- whether the notes are
+ injected into the prompt or shown to the recording step is decided by their own gates
+ downstream, independent of one another.
+ """
+ rows = await db.get_direction_notes_for_path(conversation_id, [m["id"] for m in path])
+ turn_by_message = {m["id"]: m.get("turn_index") for m in path}
+ ctx.director["direction_notes"] = [
+ {
+ "interactive_fragment_id": r["interactive_fragment_id"],
+ "interactive_fragment_label": r["interactive_fragment_label"],
+ "content": r["content"],
+ "turn_index": turn_by_message.get(r["message_id"]),
+ }
+ for r in rows
+ ]
+
+
async def _resolve_target_and_parent(
conversation_id: str, assistant_msg_id: int
) -> tuple[Mapping[str, Any], Mapping[str, Any]] | str:
@@ -78,6 +100,7 @@ async def _prepare_regen_context(
moods_before = await db.get_moods_before_turn(conversation_id, target["turn_index"] - 1)
ctx.director["active_moods"] = moods_before
ctx.director["progressive_fields"] = progressive.branch_baseline(history)
+ await _load_direction_notes(ctx, conversation_id, history)
user_msg_id = target["parent_id"]
attachments = await db.get_user_attachments_for_message(user_msg_id) if user_msg_id else []
return history, attachments
@@ -207,6 +230,7 @@ async def handle_turn(
# Read progressive_fields from the grandparent node (branch-aware, unlike conversation_logs).
ctx.director["progressive_fields"] = progressive.branch_baseline(messages)
+ await _load_direction_notes(ctx, conversation_id, messages)
if not skip_user_persist:
# Normalize frontend attachment format to DB format before persisting.
@@ -290,6 +314,7 @@ async def handle_fork_edit(
# Reset director to branch-point baseline (branch-aware progressive_fields).
ctx.director["active_moods"] = await db.get_moods_before_turn(conversation_id, turn_index)
ctx.director["progressive_fields"] = progressive.branch_baseline(history)
+ await _load_direction_notes(ctx, conversation_id, history)
# Carry original attachments onto the new sibling.
carried_atts = await db.get_user_attachments_for_message(user_msg_id)
diff --git a/backend/pipeline/orchestrator.py b/backend/pipeline/orchestrator.py
index 235f8dc8..3d78bc20 100644
--- a/backend/pipeline/orchestrator.py
+++ b/backend/pipeline/orchestrator.py
@@ -19,7 +19,7 @@
from ..database.models import PhraseGroup
from ..inference import LLMClient, _KVCacheTracker
from .config import _resolve_pipeline_config, _split_interactive_fragments
-from .passes.director import director_stage
+from .passes.director import direction_note_step, director_stage
from .passes.editor import editor_stage
from .passes.writer import writer_stage
from .state import LorebookTurn, TurnState
@@ -43,6 +43,17 @@ def _make_result(state: TurnState, staged: list[dict] | None = None, staged_stat
return {"event": "_result", "data": state.as_result_event_data()}
+async def _consume_direction_note_step(gen: AsyncIterator[dict], state: TurnState, pass_label: str) -> AsyncIterator[dict]:
+ """Drain a direction-note step: stream its reasoning under *pass_label*, keep the notes."""
+ async for ev in gen:
+ if ev["type"] == "reasoning":
+ yield {"event": "reasoning", "data": {"pass": pass_label, "delta": ev["delta"]}}
+ elif ev["type"] == "done":
+ state.direction_notes = ev["result"].notes
+ if state.direction_notes:
+ yield {"event": "direction_notes", "data": {"notes": state.direction_notes}}
+
+
async def _run_pipeline(
client: LLMClient,
settings: Mapping[str, Any],
@@ -100,8 +111,9 @@ async def _run_pipeline(
schema_overrides=schema_overrides,
)
- # feedback fragments are handled post-writer; the rest shape the writer prompt.
- writer_fragments, feedback_fragments = _split_interactive_fragments(interactive_fragments)
+ # feedback fragments are handled post-writer and direction-note fragments by the
+ # direction-note step; the rest shape the writer prompt.
+ writer_fragments, feedback_fragments, direction_note_fragments = _split_interactive_fragments(interactive_fragments)
# Mutable state threaded through the three passes; seeded from director + user message.
state = TurnState(
@@ -129,6 +141,32 @@ async def _run_pipeline(
if client.is_aborted:
return
+ # --- Direction-note step (pre-writer placement) ---
+ # Reflects on the scene direction the director just set, so it requires
+ # direct_scene (which is what produces that direction).
+ if (
+ settings.get("direction_notes_mode") == "pre_writer"
+ and cfg.agent_on
+ and direction_note_fragments
+ and cfg.enabled_tools.get("direct_scene")
+ ):
+ async for ev in _consume_direction_note_step(
+ direction_note_step(
+ cfg.agent_lane.client,
+ cfg.agent_lane.base,
+ settings=settings,
+ direction_note_fragments=direction_note_fragments,
+ active_notes=director.get("direction_notes") or [],
+ placement="pre_writer",
+ inj_block=state.scene_direction,
+ kv_tracker=kv_tracker,
+ reasoning_on=cfg.director_reasoning_on,
+ ),
+ state,
+ "director",
+ ):
+ yield ev
+
# --- Writer pass ---
async for ev in writer_stage(
cfg,
@@ -185,5 +223,34 @@ async def _run_pipeline(
# Fold any hook-rewritten draft back into state before emitting _result.
state.resp_text = post.draft
+
+ # --- Direction-note step (post-turn placement) ---
+ # Sees the finished reply. Skipped on an empty draft (no message to anchor notes
+ # to) and on a stop arriving after the last pre-editor abort check.
+ if (
+ cfg.agent_on
+ and settings.get("direction_notes_mode") == "post_turn"
+ and direction_note_fragments
+ and state.resp_text.strip()
+ and not client.is_aborted
+ ):
+ async for ev in _consume_direction_note_step(
+ direction_note_step(
+ cfg.agent_lane.client,
+ cfg.agent_lane.base,
+ settings=settings,
+ direction_note_fragments=direction_note_fragments,
+ active_notes=director.get("direction_notes") or [],
+ placement="post_turn",
+ reply_text=state.resp_text,
+ writer_user_msg=state.writer_content,
+ kv_tracker=kv_tracker,
+ reasoning_on=cfg.editor_reasoning_on,
+ ),
+ state,
+ "editor",
+ ):
+ yield ev
+
yield _make_result(state, post.staged_attachments, post.staged_message_state)
kv_tracker.log_summary()
diff --git a/backend/pipeline/passes/director/__init__.py b/backend/pipeline/passes/director/__init__.py
index 9591ced5..454f0547 100644
--- a/backend/pipeline/passes/director/__init__.py
+++ b/backend/pipeline/passes/director/__init__.py
@@ -1,4 +1,9 @@
from . import progressive
+from .direction_note import (
+ DirectionNoteResult,
+ direction_note_step,
+ extract_direction_notes,
+)
from .director import (
DirectorResult,
apply_tool_calls,
@@ -14,4 +19,7 @@
"director_stage",
"build_direct_scene_override",
"progressive",
+ "DirectionNoteResult",
+ "extract_direction_notes",
+ "direction_note_step",
]
diff --git a/backend/pipeline/passes/director/direction_note.py b/backend/pipeline/passes/director/direction_note.py
new file mode 100644
index 00000000..aa93a36d
--- /dev/null
+++ b/backend/pipeline/passes/director/direction_note.py
@@ -0,0 +1,149 @@
+"""
+passes/director/direction_note.py -- Direction-note step.
+
+Asks the model, via a forced ``record_direction_note`` call, whether anything from
+this turn should persist for the rest of the branch. Runs as a standalone sub-call
+gated by ``direction_notes_mode`` and the enabled ``field_type='direction_note'``
+fragments; each filled parameter becomes one labelled note (empty when nothing is
+worth recording).
+
+The schema rides the shared per-turn tool blob, so this step reuses the unchanged
+base and only forces the tool choice. The trailing depends on placement: the
+post-turn placement replays the writer's user message and reply to extend the warm
+writer/editor prefix; the pre-writer placement appends only the request, carrying
+this turn's scene direction inside it.
+
+Errors and aborts are swallowed into an empty result. The post-turn placement runs
+immediately before the turn's ``_result`` is emitted, so a propagating exception
+would skip persistence of the finished reply -- recording a note must never do that.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass, field
+from typing import Any, AsyncIterator, Mapping, Sequence
+
+from ....core import ChatMessage, ContentPart, extract_hyperparams
+from ....inference import (
+ RECORD_DIRECTION_NOTE_CHOICE,
+ CachedBase,
+ LLMClient,
+ build_direction_note_prompt,
+ build_direction_note_tool,
+ parse_tool_calls,
+ reasoning_cfg,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class DirectionNoteResult:
+ """Typed result of the direction-note step, yielded as the ``done`` payload.
+
+ ``notes`` holds one ``{interactive_fragment_id, interactive_fragment_label, content}`` row per filled
+ category parameter; empty when nothing is worth recording.
+ """
+
+ notes: list[dict] = field(default_factory=list)
+ agent_raw: str = ""
+
+
+def extract_direction_notes(
+ tool_calls: list[dict],
+ direction_note_fragments: Sequence[Mapping[str, Any]],
+) -> list[dict]:
+ """Turn parsed ``record_direction_note`` calls into labelled note rows.
+
+ Each filled parameter is keyed by a direction-note fragment's id and becomes one note
+ carrying that fragment's label, denormalised so a later rename or deletion of the
+ fragment cannot orphan the note's heading. Parameters for unknown ids and blank or
+ non-string values are dropped (a malformed model reply records nothing); a later
+ call wins on key collisions.
+ """
+ labels = {df["id"]: (df.get("injection_label") or df.get("label") or df["id"]) for df in direction_note_fragments}
+ values: dict[str, str] = {}
+ for tc in tool_calls:
+ if tc.get("name") == "record_direction_note":
+ for k, v in (tc.get("arguments", {}) or {}).items():
+ if k in labels and isinstance(v, str) and v.strip():
+ values[k] = v.strip()
+ return [
+ {"interactive_fragment_id": fid, "interactive_fragment_label": labels[fid], "content": c} for fid, c in values.items()
+ ]
+
+
+async def direction_note_step(
+ client: LLMClient,
+ base: CachedBase,
+ *,
+ settings: Mapping[str, Any],
+ direction_note_fragments: Sequence[Mapping[str, Any]],
+ active_notes: Sequence[Mapping[str, Any]],
+ placement: str,
+ inj_block: str | None = None,
+ reply_text: str | None = None,
+ writer_user_msg: "str | list[ContentPart] | None" = None,
+ kv_tracker=None,
+ reasoning_on: bool = False,
+) -> AsyncIterator[dict]:
+ """Yield reasoning chunks during the call, then a single done dict.
+
+ Yields:
+ ``{"type": "reasoning", "delta": str}``
+ ``{"type": "done", "result": DirectionNoteResult}``
+ """
+ if not direction_note_fragments:
+ yield {"type": "done", "result": DirectionNoteResult()}
+ return
+
+ # Byte-identical to the override already in the shared base; built here only to
+ # echo the parameter order into the request.
+ tool_schema = build_direction_note_tool(direction_note_fragments)
+
+ request = build_direction_note_prompt(
+ active_notes,
+ direction_note_fragments,
+ inj_block=inj_block if placement == "pre_writer" else None,
+ reasoning_on=reasoning_on,
+ tool_schema=tool_schema,
+ )
+
+ if placement == "post_turn":
+ # Replay the writer exchange so the call extends the warm writer/editor prefix.
+ trailing: list[ChatMessage] = [
+ {"role": "user", "content": writer_user_msg or ""},
+ {"role": "assistant", "content": reply_text or ""},
+ {"role": "user", "content": request},
+ ]
+ else:
+ trailing = [{"role": "user", "content": request}]
+
+ hyperparams = extract_hyperparams(settings, defaults={"temperature": 0.4, "max_tokens": 2048})
+
+ resp: dict = {}
+ try:
+ async for event in base.complete(
+ client,
+ label="direction_note",
+ trailing=trailing,
+ tool_choice=RECORD_DIRECTION_NOTE_CHOICE,
+ kv_tracker=kv_tracker,
+ **hyperparams,
+ **reasoning_cfg(reasoning_on),
+ ):
+ if event["type"] == "reasoning":
+ yield {"type": "reasoning", "delta": event["delta"]}
+ elif event["type"] == "done":
+ resp = event["message"]
+ except Exception:
+ logger.exception("Direction-note step failed; recording nothing this turn")
+ yield {"type": "done", "result": DirectionNoteResult()}
+ return
+
+ agent_raw = json.dumps(resp, default=str)
+ notes = extract_direction_notes(parse_tool_calls(resp), direction_note_fragments)
+
+ yield {"type": "done", "result": DirectionNoteResult(notes=notes, agent_raw=agent_raw)}
diff --git a/backend/pipeline/passes/director/director.py b/backend/pipeline/passes/director/director.py
index 8f3b3275..d6848298 100644
--- a/backend/pipeline/passes/director/director.py
+++ b/backend/pipeline/passes/director/director.py
@@ -24,7 +24,9 @@
compute_style_injection_block,
parse_tool_calls,
reasoning_cfg,
+ render_direction_notes_block,
)
+from ...predicates import direction_note_to_director, direction_note_to_writer
from . import progressive
from .prompt_rewrite import (
apply_rewrite,
@@ -129,6 +131,7 @@ async def director_pass(
lorebook_block: str = "",
lorebook_catalog: str = "",
progressive_state: dict | None = None,
+ direction_notes_block: str = "",
) -> AsyncIterator[dict]:
"""Yield reasoning chunks during each tool call, then a single done dict.
@@ -171,6 +174,12 @@ async def director_pass(
per_fragment_on = bool(settings.get("director_individual_fragments", 0))
+ # Prepended to direct_scene prompts as "___"-fenced sections (like the lorebook),
+ # so the director decides the scene with the world facts and the established
+ # direction notes in view. Notes go only into direct_scene, never rewrite_user_prompt.
+ lorebook_prefix = ("___\n\n" + lorebook_block + "\n\n") if lorebook_block else ""
+ notes_prefix = ("___\n\n" + direction_notes_block + "\n\n") if direction_notes_block else ""
+
t0 = time.monotonic()
for name in tool_names:
if client.is_aborted:
@@ -199,7 +208,7 @@ async def director_pass(
progressive_prior=(progressive_state or {}).get(stage["id"]) if stage else None,
lorebook_catalog=lorebook_catalog if stage is None else "",
)
- step_tail = ("___\n\n" + lorebook_block + "\n\n" if lorebook_block else "") + step_tail
+ step_tail = lorebook_prefix + notes_prefix + step_tail
content = build_multimodal_content(step_tail, attachments)
trailing = [{"role": "user", "content": content}]
logger.info(
@@ -253,7 +262,7 @@ async def director_pass(
tool_schema=tool_schema,
lorebook_catalog=lorebook_catalog,
)
- tail = ("___\n\n" + lorebook_block + "\n\n" if lorebook_block else "") + tool_tail
+ tail = lorebook_prefix + (notes_prefix if name == "direct_scene" else "") + tool_tail
content = build_multimodal_content(tail, attachments)
trailing: list[ChatMessage] = [{"role": "user", "content": content}]
logger.info(
@@ -335,6 +344,12 @@ async def director_stage(
# filter below.
prior_progressive = progressive.select(director.get("progressive_fields", {}), writer_fragments)
+ # Render the stored direction notes once; the director receives them in its
+ # direct_scene prompt when it is a chosen recipient (so it steers consistent with
+ # the direction it set earlier), the writer in its Scene Direction when it is (below).
+ direction_notes = director.get("direction_notes") or []
+ notes_block = macros.resolve_message(render_direction_notes_block(direction_notes)) if direction_notes else ""
+
# --- Director pass ---
has_pre_writer_tools = any(cfg.enabled_tools.get(n, False) for n in PRE_WRITER_TOOLS)
if cfg.agent_on and has_pre_writer_tools:
@@ -354,6 +369,7 @@ async def director_stage(
lorebook_block=lorebook.block,
lorebook_catalog=lorebook.catalog,
progressive_state=prior_progressive,
+ direction_notes_block=notes_block if direction_note_to_director(settings) else "",
):
if event["type"] == "reasoning":
state.reasoning_director += event["delta"]
@@ -396,6 +412,14 @@ async def director_stage(
prior_progressive,
)
)
+ # Direction notes ride the writer's Scene Direction when the writer is a chosen
+ # recipient -- independent of direct_scene and of whether recording is on -- so they
+ # are appended here rather than routed through compute_style_injection_block (which
+ # clears its inputs when direct_scene is off). scene_direction keeps the pre-append
+ # text for the pre-writer notes step.
+ state.scene_direction = state.inj_block
+ if notes_block and direction_note_to_writer(settings):
+ state.inj_block = (state.inj_block + "\n\n" + notes_block).strip()
yield {
"event": "director_done",
diff --git a/backend/pipeline/persistence.py b/backend/pipeline/persistence.py
index 1120954c..77e7b761 100644
--- a/backend/pipeline/persistence.py
+++ b/backend/pipeline/persistence.py
@@ -125,9 +125,16 @@ async def _persist_result(
await db.add_generated_chars(len(resp_text))
except Exception:
logger.exception("Failed to update generated-chars counter; row already committed")
+ if res.direction_notes:
+ try:
+ await db.create_direction_notes(conversation_id, asst_id, res.direction_notes)
+ except Exception:
+ logger.exception("Failed to persist direction notes for assistant message %s; row already committed", asst_id)
return asst_id, rejected
else:
logger.info("Skipping assistant message persistence: resp_text is empty (reasoning‑only output)")
+ if res.direction_notes:
+ logger.info("Dropping %d direction note(s): turn produced no assistant message", len(res.direction_notes))
return None, []
diff --git a/backend/pipeline/predicates.py b/backend/pipeline/predicates.py
index 8b1b9b46..9e8aeae6 100644
--- a/backend/pipeline/predicates.py
+++ b/backend/pipeline/predicates.py
@@ -11,7 +11,7 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Any, Mapping
+from typing import TYPE_CHECKING, Any, Mapping, Sequence
if TYPE_CHECKING:
from ..inference import LLMClient
@@ -35,6 +35,49 @@ def agent_enabled(settings: Mapping[str, Any]) -> bool:
return bool(settings.get("enable_agent", 1))
+def direction_note_recording_active(
+ settings: Mapping[str, Any],
+ direction_note_fragments: Sequence[Mapping[str, Any]],
+ *,
+ agent_on: bool,
+) -> bool:
+ """Return True when the direction-note sub-call should record this turn.
+
+ Gated by the global Agent toggle, a ``direction_notes_mode`` of ``pre_writer``
+ or ``post_turn``, and the presence of at least one enabled direction-note fragment to
+ fill. This is the write side; injection of already-stored notes is independent
+ (see :func:`direction_note_injection_active`).
+ """
+ return (
+ agent_on
+ and settings.get("direction_notes_mode", "off") in ("pre_writer", "post_turn")
+ and bool(direction_note_fragments)
+ )
+
+
+def direction_note_injection_active(settings: Mapping[str, Any]) -> bool:
+ """Return True when stored direction notes should be injected at all.
+
+ The read side, decoupled from recording: notes keep injecting even while recording
+ is off or their authoring fragment is disabled. Defaults on. Who receives them is a
+ further choice (see :func:`direction_note_to_director` / :func:`direction_note_to_writer`).
+ """
+ return bool(settings.get("direction_notes_inject", 1))
+
+
+def direction_note_to_director(settings: Mapping[str, Any]) -> bool:
+ """True when the director's ``direct_scene`` pass should see the stored notes, so it
+ decides the scene consistent with the direction it established earlier."""
+ recipient = settings.get("direction_notes_recipient", "both")
+ return direction_note_injection_active(settings) and recipient in ("director", "both")
+
+
+def direction_note_to_writer(settings: Mapping[str, Any]) -> bool:
+ """True when the stored notes should ride the writer's Scene Direction block."""
+ recipient = settings.get("direction_notes_recipient", "both")
+ return direction_note_injection_active(settings) and recipient in ("writer", "both")
+
+
def resolve_persona_id(
conv: Mapping[str, Any],
card: Mapping[str, Any] | None,
diff --git a/backend/pipeline/state.py b/backend/pipeline/state.py
index 5b907f36..f748e65a 100644
--- a/backend/pipeline/state.py
+++ b/backend/pipeline/state.py
@@ -86,6 +86,7 @@ class _PipelineConfig:
"reasoning_writer",
"reasoning_editor",
"feedback_values",
+ "direction_notes",
"staged_attachments",
"staged_message_state",
)
@@ -137,6 +138,9 @@ class TurnState:
progressive_fields: dict = field(default_factory=dict)
selected_lorebook_entries: list[str] = field(default_factory=list)
inj_block: str = ""
+ # Scene Direction before the direction-notes block is appended; read by the
+ # pre-writer notes step so the notes are not listed to it a second time.
+ scene_direction: str = ""
writer_lorebook_block: str = ""
# --- writer / editor outputs ---
@@ -146,6 +150,7 @@ class TurnState:
reasoning_writer: str = ""
reasoning_editor: str = ""
feedback_values: dict = field(default_factory=dict)
+ direction_notes: list[dict] = field(default_factory=list)
# --- post-pipeline workflow staging (set by the orchestrator) ---
staged_attachments: list[dict] = field(default_factory=list)
diff --git a/frontend/app.js b/frontend/app.js
index ab1ecc9a..b08d4538 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -158,6 +158,9 @@ import {
saveSetting,
saveUserProfile,
setAgentEnabled,
+ setDirectionNotesInject,
+ setDirectionNotesMode,
+ setDirectionNotesRecipient,
setPersonaCharacterLock,
setPersonaConversationLock,
showAddPhraseGroupModal,
@@ -178,6 +181,12 @@ import {
toggleWorkflowEnabled,
toggleWorkflowsGlobal,
} from "./settings.js";
+import {
+ deleteDirectionNote,
+ editDirectionNote,
+ saveDirectionNote,
+ toggleDirectionNotesPanel,
+} from "./direction_notes_panel.js";
import { S } from "./state.js";
import { initTabLock, setLockStateChangeCallback } from "./tabLock.js";
import { $ } from "./utils.js";
@@ -236,6 +245,13 @@ Object.assign(window, {
toggleAgenticLorebook,
toggleFeedbackEnabled,
toggleDirectorIndividualFragments,
+ setDirectionNotesMode,
+ setDirectionNotesInject,
+ setDirectionNotesRecipient,
+ toggleDirectionNotesPanel,
+ editDirectionNote,
+ saveDirectionNote,
+ deleteDirectionNote,
toggleShowEditorDiff,
toggleAuditType,
toggleHideUntilBaked,
diff --git a/frontend/chat_conversations.js b/frontend/chat_conversations.js
index fe591d70..6a12ba17 100644
--- a/frontend/chat_conversations.js
+++ b/frontend/chat_conversations.js
@@ -8,9 +8,11 @@ import { renderMessages, resetRenderWindow, setMessages } from "./chat_core.js";
import { renderInspector } from "./chat_inspector.js";
import { clearInspectedMessage, inspectMessage } from "./chat_messages.js";
import { resetWorkflowViewportState } from "./chat_workflow.js";
+import { renderDirectionNotesPanel } from "./direction_notes_panel.js";
import { loadCharacters, refreshCharacters, renderCharacters } from "./library.js";
import { activateAndPrioritizeWorld, deactivateWorld } from "./lorebooks.js";
import { closeModal, showConfirmModal, showModal } from "./modal.js";
+import { isUtilityPanelOpen } from "./panels.js";
// Imported from settings_personas.js directly: going through settings.js would
// close an import cycle (settings.js → chat.js → this module).
import { updateUserBtn } from "./settings_personas.js";
@@ -194,6 +196,9 @@ export async function selectConversation(id) {
} else {
clearInspectedMessage();
}
+ // The notes panel shows the conversation's accumulated notes, so refresh it on a
+ // switch (it only otherwise refreshes on open, after a stream, and on revisit).
+ if (isUtilityPanelOpen("direction-notes-panel")) renderDirectionNotesPanel();
}
function confirmDeleteConversation(id, msgCount, afterDelete) {
diff --git a/frontend/chat_inspector.js b/frontend/chat_inspector.js
index 7e477be2..3c25485e 100644
--- a/frontend/chat_inspector.js
+++ b/frontend/chat_inspector.js
@@ -4,6 +4,7 @@
// keep working.
import { api } from "./api.js";
import { renderContextSize, renderMessages } from "./chat_core.js";
+import { closeUtilityPanel, isUtilityPanelOpen, openUtilityPanel } from "./panels.js";
import { S, effectiveWorkflowEnabled } from "./state.js";
import { $, esc } from "./utils.js";
@@ -376,6 +377,25 @@ export function buildFeedbackHtml(values) {
`;
}
+// One labelled row per note, in the order recorded this turn (fragment order), reusing
+// the feedback block's styling so the look matches the rest of the Inspector. Notes
+// arrive as {interactive_fragment_label, content}.
+export function buildDirectionNotesHtml(notes) {
+ if (!Array.isArray(notes) || !notes.length) return "";
+ const body = notes
+ .map(
+ (n) => `
+ ${esc(n.interactive_fragment_label || "")}
+
${esc(String(n.content))}
+
`,
+ )
+ .join("");
+ return `
+
Direction Notes (this turn)
+
${body}
+
`;
+}
+
function _buildInjectionBlockHtml(inj) {
const openAttr = S.injectionBlockOpen ? " open" : "";
return `
@@ -407,32 +427,10 @@ export function clearRefineDiff() {
}
export function toggleInspector() {
- const inspector = $("inspector");
- const toolsPanel = $("tools-panel");
- const btn = $("inspector-toggle");
- const toolsBtn = $("tools-panel-btn");
- const wasOpen = inspector.classList.contains("open");
- const switching = !wasOpen && toolsPanel.classList.contains("open");
-
- if (wasOpen) {
- inspector.classList.remove("open");
- btn.classList.remove("btn-active");
- } else if (switching) {
- // Both panels are the same width: swap instantly with no slide animation.
- inspector.classList.add("no-anim");
- toolsPanel.classList.add("no-anim");
- toolsPanel.classList.remove("open");
- toolsBtn.classList.remove("btn-active");
- inspector.classList.add("open");
- btn.classList.add("btn-active");
- // Force a synchronous reflow so the swapped state is committed with
- // transitions disabled before we re-enable them.
- void inspector.offsetWidth;
- inspector.classList.remove("no-anim");
- toolsPanel.classList.remove("no-anim");
+ if (isUtilityPanelOpen("inspector")) {
+ closeUtilityPanel("inspector", "inspector-toggle");
} else {
- inspector.classList.add("open");
- btn.classList.add("btn-active");
+ openUtilityPanel("inspector", "inspector-toggle", renderInspector);
}
}
@@ -481,6 +479,7 @@ function _renderInspectorMain() {
${_buildReasoningHtml()}
${buildFeedbackHtml(insp.feedback)}
+ ${buildDirectionNotesHtml(insp.direction_notes)}
${tc.length ? _buildToolCallsHtml(tc) : ""}
${inj ? _buildInjectionBlockHtml(inj) : ""}
${
@@ -502,13 +501,15 @@ function _renderInspectorMain() {
if (!hasDirectorData) {
const fbHtml = buildFeedbackHtml(S.lastFeedback && S.lastFeedback.values);
+ const pnHtml = buildDirectionNotesHtml(S.lastDirectionNotes && S.lastDirectionNotes.notes);
// Canonical order: context-size, reasoning, feedback (matches the settled
// director-data branch so nothing shifts once director output arrives).
$("inspector-content").innerHTML = `
${_buildReasoningHtml()}
${fbHtml}
- ${fbHtml ? "" : `
Send a message to see director output
`}`;
+ ${pnHtml}
+ ${fbHtml || pnHtml ? "" : `
Send a message to see director output
`}`;
renderContextSize();
return;
}
@@ -529,6 +530,7 @@ function _renderInspectorMain() {
${_buildReasoningHtml()}
${buildFeedbackHtml(S.lastFeedback && S.lastFeedback.values)}
+ ${buildDirectionNotesHtml(S.lastDirectionNotes && S.lastDirectionNotes.notes)}
${tc.length ? _buildToolCallsHtml(tc) : ""}
${inj ? _buildInjectionBlockHtml(inj) : ""}
${
diff --git a/frontend/chat_messages.js b/frontend/chat_messages.js
index a56faa51..b210dcdc 100644
--- a/frontend/chat_messages.js
+++ b/frontend/chat_messages.js
@@ -11,6 +11,8 @@ import {
setMessages,
} from "./chat_core.js";
import { renderInspector } from "./chat_inspector.js";
+import { renderDirectionNotesPanel } from "./direction_notes_panel.js";
+import { isUtilityPanelOpen } from "./panels.js";
import {
afterStream,
agentPayload,
@@ -163,6 +165,7 @@ export async function switchBranch(msgId) {
S.directorState = await api.get(convUrl(S.activeConvId, "director"));
renderMessages();
await inspectMessage(msgId);
+ if (isUtilityPanelOpen("direction-notes-panel")) await renderDirectionNotesPanel();
if (anchorMsgId && anchorOffset !== null) {
const newAnchorEl = ct.querySelector(`[data-msg-id="${anchorMsgId}"]`);
diff --git a/frontend/chat_stream.js b/frontend/chat_stream.js
index 545e5216..befa5d33 100644
--- a/frontend/chat_stream.js
+++ b/frontend/chat_stream.js
@@ -29,6 +29,8 @@ import {
} from "./chat_inspector.js";
import { clearInspectedMessage } from "./chat_messages.js";
import { _mergeWorkflowRejections } from "./chat_workflow.js";
+import { optimisticDropDirectionNotesFrom, renderDirectionNotesPanel } from "./direction_notes_panel.js";
+import { isUtilityPanelOpen } from "./panels.js";
import { refreshCharacters } from "./library.js";
// Imported directly rather than via settings.js to avoid an import cycle
// (settings.js → chat.js → this module), as chat_conversations.js does.
@@ -333,6 +335,9 @@ export async function afterStream() {
renderMessages();
}
clearInspectedMessage();
+ // The active branch moved (new reply or a regenerated sibling), so the notes
+ // panel's path-scoped set is stale; refetch it if the user has it open.
+ if (isUtilityPanelOpen("direction-notes-panel")) renderDirectionNotesPanel();
scrollToBottom(true);
refreshCharacters();
}
@@ -354,6 +359,7 @@ export async function processSSEStream(resp, container, msgDiv, signal) {
S.reasoningWriter = "";
S.reasoningEditor = "";
S.lastFeedback = null;
+ S.lastDirectionNotes = null;
S.reasoningByPass = {};
S.reasoningPassActive = 0; // tracks streaming progress (for dot lighting)
S.reasoningPassSelected = 0; // tracks what the user is viewing
@@ -528,6 +534,16 @@ function handleSSEEvent(event, data, container, msgDiv, onToken, onRewrite) {
} catch (_) {}
break;
}
+ case "direction_notes": {
+ // Director-authored notes recorded this turn; display-only, surfaced in the
+ // inspector's Direction Notes block (live here, and from the director-log on revisit).
+ try {
+ const d = JSON.parse(data);
+ S.lastDirectionNotes = { notes: d.notes || [] };
+ renderInspector();
+ } catch (_) {}
+ break;
+ }
case "phase_status": {
try {
const d = JSON.parse(data);
@@ -747,12 +763,14 @@ export async function sendMessage() {
// ── Regenerate
export async function regenerate(msgId) {
if (!S.activeConvId || !canStartGeneration()) return;
+ optimisticDropDirectionNotesFrom(msgId);
await runStreamRequest(convUrl(S.activeConvId, "messages", msgId, "regenerate"), agentPayload(), msgId);
}
// ── Super Regenerate
export async function superRegenerate(msgId) {
if (!S.activeConvId || !canStartGeneration()) return;
+ optimisticDropDirectionNotesFrom(msgId);
await runStreamRequest(convUrl(S.activeConvId, "messages", msgId, "super_regenerate"), agentPayload(), msgId);
}
@@ -801,5 +819,6 @@ export async function submitMagicRewrite(msgId) {
if (!direction) return;
if (!S.activeConvId || !canStartGeneration()) return;
S.magicInputMsgId = null;
+ optimisticDropDirectionNotesFrom(msgId);
await runStreamRequest(convUrl(S.activeConvId, "messages", msgId, "magic_rewrite"), { direction }, msgId);
}
diff --git a/frontend/css/base.css b/frontend/css/base.css
index fbf37a50..ac618bda 100644
--- a/frontend/css/base.css
+++ b/frontend/css/base.css
@@ -66,10 +66,25 @@ body {
border-left: 1px solid var(--border);
}
-/* Suppress the slide animation when swapping between the two panels;
+#direction-notes-panel {
+ width: 0;
+ min-width: 0;
+ background: var(--bg-primary);
+ overflow: hidden;
+ transition: width .25s, min-width .25s;
+}
+
+#direction-notes-panel.open {
+ width: var(--inspector-width);
+ min-width: var(--inspector-width);
+ border-left: 1px solid var(--border);
+}
+
+/* Suppress the slide animation when swapping between panels;
closing a panel entirely still animates. */
#inspector.no-anim,
-#tools-panel.no-anim {
+#tools-panel.no-anim,
+#direction-notes-panel.no-anim {
transition: none;
}
diff --git a/frontend/css/inspector.css b/frontend/css/inspector.css
index 562eec24..a813b1cc 100644
--- a/frontend/css/inspector.css
+++ b/frontend/css/inspector.css
@@ -221,3 +221,51 @@ details.inspector-block[open] .reasoning-summary-arrow {
.reasoning-box::-webkit-scrollbar { width: 3px; }
.reasoning-box::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
+.notes-empty {
+ color: var(--text-muted);
+ font-size: 12px;
+ padding: 8px 0;
+}
+
+.notes-row {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 8px 10px;
+ margin-bottom: 8px;
+}
+
+.notes-row-meta {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+.notes-row-frag {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.notes-row-turn {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: .5px;
+ color: var(--text-secondary);
+ flex: none;
+}
+
+.notes-row-content {
+ font-size: 13px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin-bottom: 8px;
+}
+
+.notes-row-actions {
+ display: flex;
+ gap: 6px;
+ justify-content: flex-end;
+}
+
diff --git a/frontend/css/tools.css b/frontend/css/tools.css
index 71d77512..e4c902a9 100644
--- a/frontend/css/tools.css
+++ b/frontend/css/tools.css
@@ -67,6 +67,34 @@ input[type="range"]::-moz-range-track {
line-height: 1.45;
}
+.tool-card-select {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 12px;
+ padding: 2px 6px;
+}
+
+/* Two-control grid (recording + injection): labels share a sized first column so
+ the selects start at the same x and fill the rest, keeping both rows aligned. */
+.dn-config {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ gap: 6px 10px;
+ margin: 4px 0 6px;
+}
+
+.dn-config label {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.dn-config select {
+ width: 100%;
+}
+
.lg-config {
display: flex;
flex-direction: column;
diff --git a/frontend/direction_notes_panel.js b/frontend/direction_notes_panel.js
new file mode 100644
index 00000000..6ad15a19
--- /dev/null
+++ b/frontend/direction_notes_panel.js
@@ -0,0 +1,122 @@
+// Direction Notes panel: the conversation's accumulated direction notes on the
+// active branch, grouped by the fragment that authored them, with edit/delete.
+// Mirrors the Inspector's right-rail panel -- the model authors notes during a
+// turn; the user curates them here.
+import { api } from "./api.js";
+import { closeModal, showConfirmModal, showModal } from "./modal.js";
+import { closeUtilityPanel, isUtilityPanelOpen, openUtilityPanel } from "./panels.js";
+import { S } from "./state.js";
+import { $, convUrl, esc, toast } from "./utils.js";
+
+// Last fetched notes, so the edit modal can seed its textarea by id without escaping round-trips.
+let notes = [];
+
+export function toggleDirectionNotesPanel() {
+ if (isUtilityPanelOpen("direction-notes-panel")) {
+ closeUtilityPanel("direction-notes-panel", "direction-notes-panel-btn");
+ } else {
+ openUtilityPanel("direction-notes-panel", "direction-notes-panel-btn", renderDirectionNotesPanel);
+ }
+}
+
+function renderRows() {
+ const el = $("direction-notes-panel-content");
+ if (!el) return;
+ if (!notes.length) {
+ el.innerHTML = `
No direction notes on this branch yet. The director records them when Direction Notes is on.
`;
+ return;
+ }
+ // Turn order (the route returns oldest-first by id, which equals turn order on a
+ // branch); within a turn, fragment order. Each note carries its fragment's label so
+ // the source is obvious without bucketing notes away from their chronology.
+ el.innerHTML = notes
+ .map(
+ (n) => `
`;
+ return;
+ }
+ renderRows();
+}
+
+// Regenerating message msgId replaces it with a new sibling, so msgId (and any of
+// its descendants) leaves the active branch along with the notes recorded on it.
+// The new branch only commits when the stream ends, so reflect the drop right away
+// from the cached set -- keep only notes whose authoring message precedes msgId on
+// the active path. afterStream refetches the committed state once the reply lands.
+export function optimisticDropDirectionNotesFrom(msgId) {
+ if (!isUtilityPanelOpen("direction-notes-panel")) return;
+ const path = S.messages.map((m) => m.id);
+ const cut = path.indexOf(msgId);
+ if (cut < 0) return;
+ const surviving = new Set(path.slice(0, cut));
+ notes = notes.filter((n) => surviving.has(n.message_id));
+ renderRows();
+}
+
+export function editDirectionNote(fid) {
+ const note = notes.find((n) => n.id === fid);
+ showModal(`
+
diff --git a/frontend/library_fragments.js b/frontend/library_fragments.js
index bb3f6aa5..fd10c185 100644
--- a/frontend/library_fragments.js
+++ b/frontend/library_fragments.js
@@ -166,13 +166,21 @@ export function renderInteractiveFragments() {
const enabled = f.enabled === true || f.enabled === 1;
const toggleId = `interactive-frag-toggle-${f.id}`;
const userBadge =
- f.field_type === "feedback" ? ` F` : "";
- // Feedback fragments are gated by the "Editor Feedback" feature flag; grey
- // them out (and explain why on hover) when that feature is disabled.
- const featureDisabled = f.field_type === "feedback" && !S.feedbackEnabled;
- const itemTitle = featureDisabled
+ f.field_type === "feedback"
+ ? ` F`
+ : f.field_type === "direction_note"
+ ? ` D`
+ : "";
+ // Feedback and direction-note fragments are gated by their own feature switch;
+ // grey them out (and explain why on hover) when that switch is off.
+ const feedbackDisabled = f.field_type === "feedback" && !S.feedbackEnabled;
+ const directionNoteDisabled = f.field_type === "direction_note" && (S.directionNotesMode || "off") === "off";
+ const featureDisabled = feedbackDisabled || directionNoteDisabled;
+ const itemTitle = feedbackDisabled
? "Editor Feedback feature is disabled — enable it in Agents panel to use this fragment"
- : esc(f.description);
+ : directionNoteDisabled
+ ? "Direction Notes recording is off -- set it to record in the Agents panel to use this fragment"
+ : esc(f.description);
return `
⋮⋮
@@ -296,6 +304,13 @@ const INTERACTIVE_FRAGMENT_EXAMPLES = {
injection_label: "e.g. Tension",
description: "Track a value that evolves each turn, e.g. 'calm' -> 'uneasy' -> 'breaking point'",
},
+ direction_note: {
+ id: "e.g. trajectory",
+ label: "e.g. Trajectory",
+ injection_label: "e.g. Direction of travel",
+ description:
+ "A lasting note the director records and keeps on this branch, e.g. 'where the story is heading and the established facts that pin it'",
+ },
feedback: {
id: "e.g. next_actions",
label: "e.g. Next Actions",
@@ -348,6 +363,7 @@ export function showInteractiveFragmentModal(fragId = null) {
+
diff --git a/frontend/mobile.css b/frontend/mobile.css
index b0f0a649..611e447c 100644
--- a/frontend/mobile.css
+++ b/frontend/mobile.css
@@ -129,13 +129,14 @@ body {
z-index: var(--mobile-overlay-z);
}
- #app:is(.mobile-sidebar-open, .mobile-tools-open, .mobile-inspector-open)::before {
+ #app:is(.mobile-sidebar-open, .mobile-tools-open, .mobile-inspector-open, .mobile-notes-open)::before {
opacity: 1;
pointer-events: auto;
}
#tools-panel,
- #inspector {
+ #inspector,
+ #direction-notes-panel {
position: fixed;
right: 0;
top: 0;
@@ -150,7 +151,8 @@ body {
}
#tools-panel.open,
- #inspector.open {
+ #inspector.open,
+ #direction-notes-panel.open {
width: var(--mobile-panel-width);
min-width: 0;
transform: translateX(0);
@@ -164,7 +166,8 @@ body {
#user-profile-btn,
#tools-panel-btn,
- #inspector-toggle {
+ #inspector-toggle,
+ #direction-notes-panel-btn {
display: none;
}
diff --git a/frontend/mobile.js b/frontend/mobile.js
index f5fc1f8f..1c8541c1 100644
--- a/frontend/mobile.js
+++ b/frontend/mobile.js
@@ -17,6 +17,8 @@ const IDS = Object.freeze({
toolsPanelToggle: "tools-panel-btn",
inspector: "inspector",
inspectorToggle: "inspector-toggle",
+ directionNotesPanel: "direction-notes-panel",
+ directionNotesPanelToggle: "direction-notes-panel-btn",
modalRoot: "modal-root",
cropModalRoot: "modal-crop-root",
});
@@ -25,6 +27,7 @@ const APP_STATE = Object.freeze({
sidebarOpen: "mobile-sidebar-open",
toolsOpen: "mobile-tools-open",
inspectorOpen: "mobile-inspector-open",
+ notesOpen: "mobile-notes-open",
});
const MOBILE_SIDE_PANELS = Object.freeze([
@@ -38,6 +41,11 @@ const MOBILE_SIDE_PANELS = Object.freeze([
toggleId: IDS.inspectorToggle,
appStateClass: APP_STATE.inspectorOpen,
},
+ {
+ elementId: IDS.directionNotesPanel,
+ toggleId: IDS.directionNotesPanelToggle,
+ appStateClass: APP_STATE.notesOpen,
+ },
]);
let _mobileBackArmed = false;
diff --git a/frontend/panels.js b/frontend/panels.js
new file mode 100644
index 00000000..d4411ce8
--- /dev/null
+++ b/frontend/panels.js
@@ -0,0 +1,44 @@
+import { $ } from "./utils.js";
+
+// The right rail hosts three mutually-exclusive utility panels sharing one slot.
+const UTILITY_PANELS = [
+ ["tools-panel", "tools-panel-btn"],
+ ["inspector", "inspector-toggle"],
+ ["direction-notes-panel", "direction-notes-panel-btn"],
+];
+
+function clearActive(btnId) {
+ const btn = $(btnId);
+ if (btn) btn.classList.remove("btn-active");
+}
+
+// Open one panel and close the others. When another panel was already open the
+// two swap in place with no slide -- they share width and position in the slot.
+export function openUtilityPanel(panelId, btnId, render) {
+ const target = $(panelId);
+ const others = UTILITY_PANELS.filter(([p]) => p !== panelId);
+ const swapping = others.some(([p]) => $(p).classList.contains("open"));
+ const animated = swapping ? [target, ...others.map(([p]) => $(p))] : [];
+ animated.forEach((el) => el.classList.add("no-anim"));
+ for (const [p, b] of others) {
+ $(p).classList.remove("open");
+ clearActive(b);
+ }
+ target.classList.add("open");
+ const btn = $(btnId);
+ if (btn) btn.classList.add("btn-active");
+ if (render) render();
+ if (swapping) {
+ void target.offsetWidth; // commit the swapped state before re-enabling transitions
+ animated.forEach((el) => el.classList.remove("no-anim"));
+ }
+}
+
+export function closeUtilityPanel(panelId, btnId) {
+ $(panelId).classList.remove("open");
+ clearActive(btnId);
+}
+
+export function isUtilityPanelOpen(panelId) {
+ return $(panelId).classList.contains("open");
+}
diff --git a/frontend/settings.js b/frontend/settings.js
index 30adc618..c87a2072 100644
--- a/frontend/settings.js
+++ b/frontend/settings.js
@@ -7,6 +7,7 @@ import { api } from "./api.js";
import { renderInspectorSecondary, renderMessages } from "./chat.js";
import { renderInteractiveFragments } from "./library_fragments.js";
import { closeModal, showConfirmModal, showModal } from "./modal.js";
+import { closeUtilityPanel, isUtilityPanelOpen, openUtilityPanel } from "./panels.js";
import { initComboboxes, loadAgentModelConfigs, loadEndpoints, renderEndpoints } from "./settings_models.js";
import { loadPersonas, updateUserBtn } from "./settings_personas.js";
import { S, effectiveWorkflowEnabled } from "./state.js";
@@ -88,6 +89,9 @@ export async function loadSettings() {
// and again by at least one enabled feedback-type interactive fragment server-side.
S.feedbackEnabled = Boolean(S.settings.feedback_enabled);
S.directorIndividualFragments = Boolean(S.settings.director_individual_fragments);
+ S.directionNotesMode = S.settings.direction_notes_mode || "off";
+ S.directionNotesInject = Boolean(S.settings.direction_notes_inject ?? 1);
+ S.directionNotesRecipient = S.settings.direction_notes_recipient || "both";
if (S.settings.length_guard_max_words) S.lengthGuardMaxWords = S.settings.length_guard_max_words;
if (S.settings.length_guard_max_paragraphs) S.lengthGuardMaxParagraphs = S.settings.length_guard_max_paragraphs;
@@ -231,34 +235,10 @@ async function persistSettings(payload) {
}
export function toggleToolsPanel() {
- const panel = $("tools-panel");
- const inspector = $("inspector");
- const btn = $("tools-panel-btn");
- const inspectorBtn = $("inspector-toggle");
- const wasOpen = panel.classList.contains("open");
- const switching = !wasOpen && inspector.classList.contains("open");
-
- if (wasOpen) {
- panel.classList.remove("open");
- btn.classList.remove("btn-active");
- } else if (switching) {
- // Both panels are the same width: swap instantly with no slide animation.
- panel.classList.add("no-anim");
- inspector.classList.add("no-anim");
- inspector.classList.remove("open");
- inspectorBtn.classList.remove("btn-active");
- panel.classList.add("open");
- btn.classList.add("btn-active");
- renderToolsPanel();
- // Force a synchronous reflow so the swapped state is committed with
- // transitions disabled before we re-enable them.
- void panel.offsetWidth;
- panel.classList.remove("no-anim");
- inspector.classList.remove("no-anim");
+ if (isUtilityPanelOpen("tools-panel")) {
+ closeUtilityPanel("tools-panel", "tools-panel-btn");
} else {
- panel.classList.add("open");
- btn.classList.add("btn-active");
- renderToolsPanel();
+ openUtilityPanel("tools-panel", "tools-panel-btn", renderToolsPanel);
}
}
@@ -306,6 +286,24 @@ export async function toggleDirectorIndividualFragments(on) {
await persistSettings({ director_individual_fragments: on });
}
+export async function setDirectionNotesMode(mode) {
+ S.directionNotesMode = mode;
+ renderToolsPanel();
+ await persistSettings({ direction_notes_mode: mode });
+}
+
+export async function setDirectionNotesInject(val) {
+ S.directionNotesInject = val === "on";
+ renderToolsPanel();
+ await persistSettings({ direction_notes_inject: val === "on" });
+}
+
+export async function setDirectionNotesRecipient(val) {
+ S.directionNotesRecipient = val;
+ renderToolsPanel();
+ await persistSettings({ direction_notes_recipient: val });
+}
+
export async function toggleShowEditorDiff(on) {
S.showEditorDiff = on;
renderMessages();
@@ -552,7 +550,36 @@ export function renderToolsPanel() {
Director fills each interactive fragment in its own LLM call. More focused output; higher latency.
Recording adds a note per enabled "direction_note" fragment, kept on this branch ("before writer" adds latency before the reply streams; "end of turn" records after the final reply). Injection is separate from recording. "Who receives" picks whether the director sees the notes while planning the scene, the writer while generating prose, or both.
+
`;
+
+ $("tools-list").innerHTML = toolCards + lengthGuardCard + feedbackCard + individualFragmentsCard + directionNotesCard;
const secEl = $("tools-list-secondary");
if (secEl) {
diff --git a/frontend/state.js b/frontend/state.js
index c7564132..abece61b 100644
--- a/frontend/state.js
+++ b/frontend/state.js
@@ -45,8 +45,12 @@ export const S = {
reasoningWriter: "",
reasoningEditor: "", // also carries the feedback sub-step's reasoning (folded into the editor channel)
lastFeedback: null, // {values: {...}} from the editor feedback sub-step for the current/streamed turn (null when none)
+ lastDirectionNotes: null, // {notes: [...]} recorded by the director-notes sub-step this turn (null when none)
feedbackEnabled: false,
directorIndividualFragments: false,
+ directionNotesMode: "off",
+ directionNotesInject: true, // inject stored direction notes into context (read side, independent of recording)
+ directionNotesRecipient: "both", // who sees injected notes: director / writer / both
reasoningPassActive: 0,
reasoningPassSelected: 0,
reasoningUserOverride: false,
diff --git a/frontend/validate.js b/frontend/validate.js
index 6e309d20..d763ed43 100644
--- a/frontend/validate.js
+++ b/frontend/validate.js
@@ -347,7 +347,7 @@ export function validateMoodFragment(data) {
return { valid: true };
}
-const FRAGMENT_FIELD_TYPES = ["string", "array", "progressive", "feedback"];
+const FRAGMENT_FIELD_TYPES = ["string", "array", "progressive", "feedback", "direction_note"];
/**
* Validate an interactive fragment.
diff --git a/tests/integration/_llm_mock.py b/tests/integration/_llm_mock.py
index 3a554dce..5d3f2da6 100644
--- a/tests/integration/_llm_mock.py
+++ b/tests/integration/_llm_mock.py
@@ -25,6 +25,7 @@
_EDITOR_FUNCTION_NAMES = {"editor_apply_patch", "editor_rewrite"}
_DIRECTOR_FUNCTION_NAMES = {"direct_scene", "rewrite_user_prompt"}
_FEEDBACK_FUNCTION_NAMES = {"give_feedback"}
+_DIRECTION_NOTE_FUNCTION_NAMES = {"record_direction_note"}
def _validate_tool_calls(tool_calls: Any) -> None:
@@ -85,6 +86,8 @@ def _pass_from_tool_choice(tool_choice: Any) -> str:
return "director"
if name in _FEEDBACK_FUNCTION_NAMES:
return "feedback"
+ if name in _DIRECTION_NOTE_FUNCTION_NAMES:
+ return "direction_note"
# Any other forced function name belongs to a workflow tool: the
# toolkit's forced_tool_call helper passes the same dict shape via
# TOOLS[]["choice"], but the name is not one of
@@ -121,6 +124,7 @@ def __init__(self) -> None:
"writer": [],
"editor": [],
"feedback": [],
+ "direction_note": [],
"workflow": [],
}
self._gates: dict[str, list[PassGate]] = {
@@ -128,6 +132,7 @@ def __init__(self) -> None:
"writer": [],
"editor": [],
"feedback": [],
+ "direction_note": [],
"workflow": [],
}
# Mirror LLMClient: the turn's clients share one abort token, so an
@@ -176,6 +181,11 @@ def enqueue_feedback(self, tool_calls: list[dict]) -> None:
_validate_tool_calls(tool_calls)
self._queues["feedback"].append({"tool_calls": tool_calls})
+ def enqueue_direction_note(self, tool_calls: list[dict]) -> None:
+ """Queue a director-notes response (the ``record_direction_note`` forced call)."""
+ _validate_tool_calls(tool_calls)
+ self._queues["direction_note"].append({"tool_calls": tool_calls})
+
def enqueue_workflow(self, message: dict) -> None:
self._queues["workflow"].append({"message": message})
@@ -259,6 +269,18 @@ async def complete(
}
return
+ if pass_name == "direction_note":
+ payload = self._queues["direction_note"].pop(0) if self._queues["direction_note"] else {"tool_calls": []}
+ yield {
+ "type": "done",
+ "message": {
+ "role": "assistant",
+ "content": "",
+ "tool_calls": payload.get("tool_calls", []),
+ },
+ }
+ return
+
payload = self._queues["director"].pop(0) if self._queues["director"] else {"tool_calls": []}
yield {
"type": "done",
diff --git a/tests/integration/test_direction_notes.py b/tests/integration/test_direction_notes.py
new file mode 100644
index 00000000..97682a59
--- /dev/null
+++ b/tests/integration/test_direction_notes.py
@@ -0,0 +1,418 @@
+"""Turn-level integration tests for the direction-note step.
+
+A direction note is authored by an interactive fragment of ``field_type="direction_note"``:
+the forced ``record_direction_note`` sub-call exposes one parameter per enabled such
+fragment, and each filled parameter persists one note (keyed to the turn's assistant
+message, carrying the fragment's id and label). Covers both placements, the suppressing
+gates (off / global agent toggle / no enabled fragment / empty draft), branch-dependence,
+the steered regenerates, and the read/write separation: recording (``direction_notes_mode``
++ per-fragment ``enabled``) is independent of injection (``direction_notes_inject``).
+"""
+
+from __future__ import annotations
+
+import backend.database as dbmod
+from backend.pipeline import (
+ handle_magic_rewrite,
+ handle_regenerate,
+ handle_super_regenerate,
+ handle_turn,
+)
+
+_NOTE = "Alice now distrusts the user, after he lied about the key."
+_HEADING = "Direction of travel"
+
+
+async def _make_fragment(fid: str = "trajectory", injection_label: str = _HEADING, enabled: bool = True) -> None:
+ """Create one enabled ``field_type="direction_note"`` interactive fragment."""
+ await dbmod.create_interactive_fragment(
+ {
+ "id": fid,
+ "label": fid.title(),
+ "description": f"Record the {injection_label}.",
+ "field_type": "direction_note",
+ "injection_label": injection_label,
+ "enabled": enabled,
+ }
+ )
+
+
+def _record_call(**fields: str) -> list[dict]:
+ """A ``record_direction_note`` tool call filling one parameter per fragment id."""
+ return [{"type": "function", "function": {"name": "record_direction_note", "arguments": dict(fields)}}]
+
+
+async def _drain(agen) -> list[dict]:
+ return [ev async for ev in agen]
+
+
+async def _notes_on_active_path(cid: str) -> list[str]:
+ path = await dbmod.get_messages(cid)
+ rows = await dbmod.get_direction_notes_for_path(cid, [m["id"] for m in path])
+ return [r["content"] for r in rows]
+
+
+async def _last_assistant(cid: str):
+ msgs = await dbmod.get_messages(cid)
+ return [m for m in msgs if m["role"] == "assistant"][-1]
+
+
+async def _injection_block(events: list[dict]) -> str:
+ blocks = [e["data"]["injection_block"] for e in events if e.get("event") == "director_done"]
+ return blocks[-1] if blocks else ""
+
+
+async def test_post_turn_fires_and_persists(client, db, llm_mock):
+ cid = "conv-dn-post"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment()
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+
+ llm_mock.enqueue_writer("She nods slowly.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
+
+ events = await _drain(handle_turn(cid, "hello"))
+
+ pevents = [e for e in events if e.get("event") == "direction_notes"]
+ assert len(pevents) == 1
+ assert [n["content"] for n in pevents[0]["data"]["notes"]] == [_NOTE]
+
+ rows = await dbmod.get_direction_notes_for_message((await _last_assistant(cid))["id"])
+ assert len(rows) == 1
+ assert rows[0]["content"] == _NOTE
+ assert rows[0]["interactive_fragment_id"] == "trajectory"
+ assert rows[0]["interactive_fragment_label"] == _HEADING
+
+
+async def test_pre_writer_runs_before_writer(client, db, llm_mock):
+ cid = "conv-dn-pre"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment()
+ await client.put(
+ "/api/settings",
+ json={"enable_agent": True, "direction_notes_mode": "pre_writer", "enabled_tools": {"direct_scene": True}},
+ )
+
+ llm_mock.enqueue_director([{"type": "function", "function": {"name": "direct_scene", "arguments": {"moods": []}}}])
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
+ llm_mock.enqueue_writer("He turns away without a word.")
+
+ await _drain(handle_turn(cid, "hello"))
+
+ order = [p for p, _ in llm_mock.calls]
+ assert "direction_note" in order
+ assert order.index("direction_note") < order.index("writer")
+ assert await _notes_on_active_path(cid) == [_NOTE]
+
+
+async def test_pre_writer_skipped_without_direct_scene(client, db, llm_mock):
+ cid = "conv-dn-pre-skip"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment()
+ # pre_writer reflects on the director's scene direction, so without direct_scene
+ # the sub-call must not run.
+ await client.put(
+ "/api/settings",
+ json={"enable_agent": True, "direction_notes_mode": "pre_writer", "enabled_tools": {"direct_scene": False}},
+ )
+
+ llm_mock.enqueue_writer("A reply.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
+
+ await _drain(handle_turn(cid, "hello"))
+
+ assert not any(p == "direction_note" for p, _ in llm_mock.calls)
+ assert await _notes_on_active_path(cid) == []
+
+
+async def test_off_does_not_run(client, db, llm_mock):
+ cid = "conv-dn-off"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment()
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "off"})
+
+ llm_mock.enqueue_writer("A reply.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE)) # must stay unconsumed
+
+ events = await _drain(handle_turn(cid, "hello"))
+
+ assert not [e for e in events if e.get("event") == "direction_notes"]
+ assert not any(p == "direction_note" for p, _ in llm_mock.calls)
+ assert await _notes_on_active_path(cid) == []
+
+
+async def test_no_enabled_fragment_does_not_run(client, db, llm_mock):
+ cid = "conv-dn-nofrag"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ # Recording on, but no direction_note fragment exists: nothing to fill, so the
+ # sub-call is skipped entirely (mirrors the feedback step with no feedback fragment).
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+
+ llm_mock.enqueue_writer("A reply.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
+
+ await _drain(handle_turn(cid, "hello"))
+
+ assert not any(p == "direction_note" for p, _ in llm_mock.calls)
+ assert await _notes_on_active_path(cid) == []
+
+
+async def test_obeys_global_agent_toggle(client, db, llm_mock):
+ cid = "conv-dn-agent-off"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment()
+ await client.put("/api/settings", json={"enable_agent": False, "direction_notes_mode": "post_turn"})
+
+ llm_mock.enqueue_writer("A reply.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
+
+ await _drain(handle_turn(cid, "hello"))
+
+ assert not any(p == "direction_note" for p, _ in llm_mock.calls)
+ assert await _notes_on_active_path(cid) == []
+
+
+async def test_empty_draft_persists_nothing(client, db, llm_mock):
+ cid = "conv-dn-empty"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment()
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+
+ llm_mock.enqueue_writer("") # reasoning-only turn: no assistant message persisted
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
+
+ await _drain(handle_turn(cid, "hello"))
+
+ # post_turn is gated on a non-empty draft, so the sub-call never fires and no row lands.
+ assert not any(p == "direction_note" for p, _ in llm_mock.calls)
+ assert await _notes_on_active_path(cid) == []
+
+
+async def test_notes_are_branch_dependent(client, db, llm_mock):
+ cid = "conv-dn-branch"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment()
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+
+ llm_mock.enqueue_writer("The door creaks open.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
+ await _drain(handle_turn(cid, "hello"))
+
+ asst1 = await _last_assistant(cid)
+ assert await _notes_on_active_path(cid) == [_NOTE]
+
+ # Regenerate that reply: a new sibling whose path excludes asst1, so asst1's note is
+ # not active. The sub-call records nothing this time (nothing enqueued).
+ llm_mock.enqueue_writer("The door stays shut.")
+ await _drain(handle_regenerate(cid, asst1["id"]))
+ assert await _notes_on_active_path(cid) == []
+
+ # Switching back to the original branch restores the note.
+ await dbmod.switch_to_branch(cid, asst1["id"])
+ assert await _notes_on_active_path(cid) == [_NOTE]
+
+
+async def test_magic_rewrite_records_note_on_new_branch(client, db, llm_mock):
+ cid = "conv-dn-magic"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment()
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+
+ llm_mock.enqueue_writer("The hall is silent.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
+ await _drain(handle_turn(cid, "hello"))
+ asst1 = await _last_assistant(cid)
+ assert await _notes_on_active_path(cid) == [_NOTE]
+
+ # Magic-rewrite runs the full pipeline as a new sibling whose path excludes asst1,
+ # so the sub-call fires again and keys its note to the new reply, not the old one.
+ note_b = "The user now carries the iron key openly."
+ llm_mock.enqueue_writer("The hall echoes with footsteps.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=note_b))
+ events = await _drain(handle_magic_rewrite(cid, asst1["id"], "make it louder"))
+
+ emitted = [e["data"]["notes"] for e in events if e.get("event") == "direction_notes"]
+ assert [[n["content"] for n in notes] for notes in emitted] == [[note_b]]
+
+ asst2 = await _last_assistant(cid)
+ assert asst2["id"] != asst1["id"]
+ assert await _notes_on_active_path(cid) == [note_b]
+ assert [r["content"] for r in await dbmod.get_direction_notes_for_message(asst2["id"])] == [note_b]
+
+
+async def test_super_regenerate_records_note_on_new_branch(client, db, llm_mock):
+ cid = "conv-dn-super"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment()
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+
+ llm_mock.enqueue_writer("She waits by the gate.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
+ await _drain(handle_turn(cid, "hello"))
+ asst1 = await _last_assistant(cid)
+
+ note_b = "She has decided to leave at dawn."
+ llm_mock.enqueue_writer("She paces by the gate.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=note_b))
+ await _drain(handle_super_regenerate(cid, asst1["id"]))
+
+ asst2 = await _last_assistant(cid)
+ assert asst2["id"] != asst1["id"]
+ assert await _notes_on_active_path(cid) == [note_b]
+
+
+async def test_injection_is_independent_of_recording(client, db, llm_mock):
+ cid = "conv-dn-rw"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment()
+ # direct_scene on so a director_done injection block is produced each turn.
+ await client.put(
+ "/api/settings",
+ json={"enable_agent": True, "direction_notes_mode": "post_turn", "enabled_tools": {"direct_scene": True}},
+ )
+
+ # Turn 1 records a note.
+ llm_mock.enqueue_writer("The lamp flickers.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
+ await _drain(handle_turn(cid, "hello"))
+
+ # Turn 2, inject on (default): the stored note appears in the injection block.
+ llm_mock.enqueue_writer("Shadows lengthen.")
+ events2 = await _drain(handle_turn(cid, "again"))
+ block2 = await _injection_block(events2)
+ assert _HEADING in block2 and _NOTE in block2
+
+ # Turn 3, inject off: the note is withheld from the prompt, yet recording still runs.
+ await client.put("/api/settings", json={"direction_notes_inject": False})
+ llm_mock.enqueue_writer("A new arrival.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory="A stranger entered."))
+ events3 = await _drain(handle_turn(cid, "more"))
+ block3 = await _injection_block(events3)
+ assert _NOTE not in block3
+ assert "A stranger entered." in await _notes_on_active_path(cid)
+
+
+async def test_disabling_fragment_stops_new_notes_keeps_old(client, db, llm_mock):
+ cid = "conv-dn-disable"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment("alpha", "Alpha heading")
+ await _make_fragment("beta", "Beta heading")
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+
+ # Turn 1: both fragments record.
+ llm_mock.enqueue_writer("Opening.")
+ llm_mock.enqueue_direction_note(_record_call(alpha="a-note", beta="b-note"))
+ await _drain(handle_turn(cid, "hello"))
+ assert set(await _notes_on_active_path(cid)) == {"a-note", "b-note"}
+
+ # Disable beta: it no longer contributes a tool parameter, so a returned beta value
+ # is dropped -- but beta's already-recorded note is untouched (enable gates writing only).
+ await client.put("/api/interactive-fragments/beta", json={"enabled": False})
+ llm_mock.enqueue_writer("Continuing.")
+ llm_mock.enqueue_direction_note(_record_call(alpha="a-note-2", beta="b-note-2"))
+ await _drain(handle_turn(cid, "again"))
+
+ notes = await _notes_on_active_path(cid)
+ assert "a-note-2" in notes # alpha still records
+ assert "b-note-2" not in notes # beta disabled -> its value is dropped
+ assert "b-note" in notes # beta's prior note survives
+
+
+def _director_scene_prompt(llm_mock) -> str:
+ """Text of the most recent direct_scene director call's user message."""
+ for cap in reversed(llm_mock.captured):
+ tc = cap.get("tool_choice")
+ if cap["pass"] == "director" and isinstance(tc, dict) and tc.get("function", {}).get("name") == "direct_scene":
+ content = cap["messages"][-1]["content"]
+ if isinstance(content, str):
+ return content
+ return "".join(p.get("text", "") for p in content if isinstance(p, dict))
+ return ""
+
+
+async def _record_then_next_turn(client, llm_mock, cid: str, recipient: str) -> list[dict]:
+ """Record _NOTE on turn 1 (post_turn), then run a second turn so the note is loaded and
+ injected; returns the second turn's events. direct_scene is on so the director runs."""
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment()
+ await client.put(
+ "/api/settings",
+ json={
+ "enable_agent": True,
+ "direction_notes_mode": "post_turn",
+ "enabled_tools": {"direct_scene": True},
+ "direction_notes_recipient": recipient,
+ },
+ )
+ direct_scene = [{"type": "function", "function": {"name": "direct_scene", "arguments": {"moods": []}}}]
+ llm_mock.enqueue_director(direct_scene)
+ llm_mock.enqueue_writer("She steps inside.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
+ await _drain(handle_turn(cid, "hello"))
+
+ llm_mock.enqueue_director(direct_scene)
+ llm_mock.enqueue_writer("She looks around.")
+ return await _drain(handle_turn(cid, "again"))
+
+
+async def test_recipient_director_only(client, db, llm_mock):
+ events = await _record_then_next_turn(client, llm_mock, "conv-dn-rec-dir", "director")
+ prompt = _director_scene_prompt(llm_mock)
+ assert _NOTE in prompt # the director's direct_scene prompt sees the note...
+ assert "turn 1" in prompt # ...tagged with the turn it was recorded on
+ assert _NOTE not in await _injection_block(events) # ...but the writer's Scene Direction does not
+
+
+async def test_recipient_writer_only(client, db, llm_mock):
+ events = await _record_then_next_turn(client, llm_mock, "conv-dn-rec-wri", "writer")
+ assert _NOTE not in _director_scene_prompt(llm_mock) # the director does not see it
+ block = await _injection_block(events)
+ assert _NOTE in block and "turn 1" in block # the writer does, tagged with the turn
+
+
+async def test_recipient_both_reaches_director_and_writer(client, db, llm_mock):
+ events = await _record_then_next_turn(client, llm_mock, "conv-dn-rec-both", "both")
+ assert _NOTE in _director_scene_prompt(llm_mock)
+ assert _NOTE in await _injection_block(events)
+
+
+async def test_fragment_routes_list_edit_delete(client, db, llm_mock):
+ cid = "conv-dn-routes"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment()
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+ llm_mock.enqueue_writer("A reply.")
+ llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
+ await _drain(handle_turn(cid, "hello"))
+
+ listing = (await client.get(f"/api/conversations/{cid}/direction-notes")).json()
+ assert len(listing) == 1
+ assert listing[0]["content"] == _NOTE
+ assert listing[0]["interactive_fragment_label"] == _HEADING
+ assert "turn_index" in listing[0]
+ fid = listing[0]["id"]
+
+ edited = await client.put(f"/api/conversations/{cid}/direction-notes/{fid}", json={"content": "edited"})
+ assert edited.status_code == 200
+ assert edited.json()["content"] == "edited"
+
+ deleted = await client.delete(f"/api/conversations/{cid}/direction-notes/{fid}")
+ assert deleted.status_code == 200
+ assert (await client.get(f"/api/conversations/{cid}/direction-notes")).json() == []
+
+ assert (await client.delete(f"/api/conversations/{cid}/direction-notes/99999")).status_code == 404
+
+
+async def test_get_for_path_empty_and_membership(client, db, llm_mock):
+ cid = "conv-dn-recon"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ uid, _ = await dbmod.add_message(cid, "user", "hi", 0)
+ on_path, _ = await dbmod.add_message(cid, "assistant", "yo", 1, parent_id=uid)
+ off_path, _ = await dbmod.add_message(cid, "assistant", "sibling", 1, parent_id=uid)
+ note = {"interactive_fragment_id": "f", "interactive_fragment_label": "F", "content": "on path"}
+ await dbmod.create_direction_notes(cid, on_path, [note])
+ await dbmod.create_direction_notes(cid, off_path, [{**note, "content": "off path"}])
+
+ assert await dbmod.get_direction_notes_for_path(cid, []) == []
+ rows = await dbmod.get_direction_notes_for_path(cid, [uid, on_path])
+ assert [r["content"] for r in rows] == ["on path"]
diff --git a/tests/integration/test_preset_schema_coverage.py b/tests/integration/test_preset_schema_coverage.py
index e300784f..7962345d 100644
--- a/tests/integration/test_preset_schema_coverage.py
+++ b/tests/integration/test_preset_schema_coverage.py
@@ -457,6 +457,7 @@ def _insert_conv_tree(path: str, cid: str, persona_id: int | None) -> None:
"model_configs",
# pure log / attachment tables: not part of any domain's user-facing identity.
"conversation_logs",
+ "direction_notes",
"user_attachments",
"workflow_attachments",
}
diff --git a/tests/unit/test_interactive_fragments.py b/tests/unit/test_interactive_fragments.py
index 6d1254f7..8ab333c3 100644
--- a/tests/unit/test_interactive_fragments.py
+++ b/tests/unit/test_interactive_fragments.py
@@ -515,7 +515,7 @@ def test_required_is_bool(self, frag):
@pytest.mark.parametrize("frag", SEED_INTERACTIVE_FRAGMENTS, ids=lambda f: f.get("id", "?"))
def test_field_type_is_valid(self, frag):
- assert frag["field_type"] in ("string", "array", "progressive", "feedback")
+ assert frag["field_type"] in ("string", "array", "progressive", "feedback", "direction_note")
def test_seed_ids_match_original_hardcoded_params(self):
ids = {f["id"] for f in SEED_INTERACTIVE_FRAGMENTS}
@@ -525,5 +525,6 @@ def test_seed_ids_match_original_hardcoded_params(self):
"next_event",
"detected_repetitions",
"suggested_actions",
+ "story_direction",
}
assert ids == expected
diff --git a/tests/unit/test_kv_cache_invariants.py b/tests/unit/test_kv_cache_invariants.py
index 7c0c1239..38745940 100644
--- a/tests/unit/test_kv_cache_invariants.py
+++ b/tests/unit/test_kv_cache_invariants.py
@@ -47,6 +47,7 @@
AbortToken,
CachedBase,
build_direct_scene_tool,
+ build_direction_note_tool,
build_feedback_tool,
enabled_schemas,
)
@@ -181,6 +182,8 @@ def _label(self, tool_choice: Any) -> str:
return "editor"
if name == "give_feedback":
return "feedback"
+ if name == "record_direction_note":
+ return "direction_note"
if name in ("direct_scene", "rewrite_user_prompt"):
return f"director:{name}"
return name or "editor"
@@ -234,6 +237,23 @@ async def complete(self, messages, model, tools=None, tool_choice=None, **params
}
return
+ if label == "direction_note":
+ yield {
+ "type": "done",
+ "message": {
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [
+ {
+ "id": "p1",
+ "type": "function",
+ "function": {"name": "record_direction_note", "arguments": '{"notes": []}'},
+ }
+ ],
+ },
+ }
+ return
+
# director:* — return a well-formed forced call so the parse path runs.
name = label.split(":", 1)[1]
args = '{"moods": ["tense"], "pacing": "urgent"}' if name == "direct_scene" else "{}"
@@ -284,20 +304,25 @@ async def _run_turn(
agent_client: CapturingClient | None = None,
agent_prefix: list[dict] | None = None,
feedback_fragments: list[dict] | None = None,
+ direction_note_fragments: list[dict] | None = None,
) -> tuple[_KVCacheTracker, CapturingClient, CapturingClient | None]:
tracker = _KVCacheTracker(conversation_id=conversation_id)
director = {"active_moods": [], "progressive_fields": {}}
enabled_tools = dict(settings["enabled_tools"])
- # Writer-only fragments shape direct_scene; feedback fragments are passed in
- # alongside them so _run_pipeline's split sees both. The caller mirrors what
- # _prepare_turn does in production: when feedback is enabled the give_feedback
- # schema rides the shared blob (schema_overrides) and its enable bit is set.
+ # Writer-only fragments shape direct_scene; feedback and direction-note fragments
+ # are passed in alongside them so _run_pipeline's split sees all three. The caller
+ # mirrors _prepare_turn: when a post-writer tool is active its schema rides the
+ # shared blob (schema_overrides) and its enable bit is set.
feedback_fragments = feedback_fragments or []
- interactive_fragments = [*_INTERACTIVE_FRAGMENTS, *feedback_fragments]
+ direction_note_fragments = direction_note_fragments or []
+ interactive_fragments = [*_INTERACTIVE_FRAGMENTS, *feedback_fragments, *direction_note_fragments]
schema_overrides = {"direct_scene": build_direct_scene_tool(_INTERACTIVE_FRAGMENTS)}
if bool(settings.get("feedback_enabled", 0)) and feedback_fragments:
schema_overrides["give_feedback"] = build_feedback_tool(feedback_fragments)
enabled_tools["give_feedback"] = True
+ if settings.get("direction_notes_mode", "off") in ("pre_writer", "post_turn") and direction_note_fragments:
+ schema_overrides["record_direction_note"] = build_direction_note_tool(direction_note_fragments)
+ enabled_tools["record_direction_note"] = True
gen = _run_pipeline(
client,
@@ -505,6 +530,59 @@ async def test_feedback_step_reuses_shared_blob_no_cache_bust():
)
+async def test_direction_note_step_reuses_shared_blob_no_cache_bust():
+ """The post-turn direction-note step must not diverge the tools blob: with the
+ feature on, ``record_direction_note`` rides the shared per-turn blob and the
+ ponder reuses the same cached base as director/writer/editor, replaying the
+ writer exchange rather than forking off ``base.prefix``."""
+ prefix = _make_prefix("You are a vivid roleplay narrator.", n_pairs=4)
+ tracker, client, _ = await _run_turn(
+ prefix=prefix,
+ settings=_base_settings(direction_notes_mode="post_turn"),
+ conversation_id="conv-dirnote-kv",
+ client=CapturingClient("writer-model"),
+ direction_note_fragments=[
+ {
+ "id": "trajectory",
+ "field_type": "direction_note",
+ "description": "Where the story is heading.",
+ "injection_label": "Direction of travel",
+ "sort_order": 0,
+ "required": False,
+ "enabled": True,
+ }
+ ],
+ )
+
+ _reconcile_tracker_with_client(tracker, client)
+
+ entries = {e["label"]: e for e in tracker._entries}
+ assert "direction_note" in entries, "direction-note step did not fire (mode=post_turn, agent on)"
+
+ wire = _wire_tools_by_label(client)
+ all_blobs = {b for blobs in wire.values() for b in blobs}
+ assert len(all_blobs) == 1, (
+ "CACHE BUST: the direction-note step diverged the tools blob. Distinct blob sizes: "
+ + json.dumps(sorted(len(b) for b in all_blobs))
+ )
+
+ the_blob = next(iter(all_blobs))
+ assert '"record_direction_note"' in the_blob, "record_direction_note schema is missing from the shared tools blob"
+
+ assert wire["direction_note"] == wire["writer"] == wire["editor"], (
+ "direction_note/writer/editor tools blobs differ -- the notes step is not reusing the frozen shared base."
+ )
+
+ prefix_bytes = _serialize_messages(prefix)
+ perm_msgs = entries["direction_note"]["msgs_serialized"]
+ writer_msgs = entries["writer"]["msgs_serialized"]
+ assert len(writer_msgs) > len(prefix_bytes), "writer stack should include the current-turn user message"
+ assert perm_msgs.startswith(writer_msgs), (
+ "CACHE BUST: the post_turn notes step forked the message stack instead of extending "
+ "the writer's -- it must replay writer_user_msg + reply."
+ )
+
+
async def test_dual_model_feedback_rides_agent_lane_writer_stays_empty():
"""Dual-model with feedback on: Invariant 5 must hold — the writer drops all
tools — while give_feedback rides only the agent lane, where the feedback
diff --git a/tests/unit/test_tool_registry.py b/tests/unit/test_tool_registry.py
index 7cd592b2..0148cb19 100644
--- a/tests/unit/test_tool_registry.py
+++ b/tests/unit/test_tool_registry.py
@@ -101,6 +101,7 @@ def test_none_returns_tools_insertion_order(self):
"editor_apply_patch",
"editor_rewrite",
"give_feedback",
+ "record_direction_note",
]
def test_dict_filter_returns_insertion_order_subset(self):
@@ -157,4 +158,5 @@ def test_registered_tool_lands_at_end_under_insertion_order(self, _restore_regis
"editor_apply_patch",
"editor_rewrite",
"give_feedback",
+ "record_direction_note",
]
From dd098a33fd2a15def9fa52b73ac68d38c3e1f868 Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Sat, 20 Jun 2026 21:23:17 +0000
Subject: [PATCH 02/15] Stale name fixes + proper directional note logging
---
.../passes/director/direction_note.py | 1 +
frontend/settings.js | 26 +++++++++----------
frontend/state.js | 2 +-
3 files changed, 15 insertions(+), 14 deletions(-)
diff --git a/backend/pipeline/passes/director/direction_note.py b/backend/pipeline/passes/director/direction_note.py
index aa93a36d..a409e3d7 100644
--- a/backend/pipeline/passes/director/direction_note.py
+++ b/backend/pipeline/passes/director/direction_note.py
@@ -144,6 +144,7 @@ async def direction_note_step(
return
agent_raw = json.dumps(resp, default=str)
+ logger.info("Direction-note step output:\n%s", agent_raw)
notes = extract_direction_notes(parse_tool_calls(resp), direction_note_fragments)
yield {"type": "done", "result": DirectionNoteResult(notes=notes, agent_raw=agent_raw)}
diff --git a/frontend/settings.js b/frontend/settings.js
index c87a2072..c5c62f5e 100644
--- a/frontend/settings.js
+++ b/frontend/settings.js
@@ -550,30 +550,30 @@ export function renderToolsPanel() {
Director fills each interactive fragment in its own LLM call. More focused output; higher latency.
Recording adds a note per enabled "direction_note" fragment, kept on this branch ("before writer" adds latency before the reply streams; "end of turn" records after the final reply). Injection is separate from recording. "Who receives" picks whether the director sees the notes while planning the scene, the writer while generating prose, or both.
diff --git a/frontend/state.js b/frontend/state.js
index abece61b..86944e36 100644
--- a/frontend/state.js
+++ b/frontend/state.js
@@ -45,7 +45,7 @@ export const S = {
reasoningWriter: "",
reasoningEditor: "", // also carries the feedback sub-step's reasoning (folded into the editor channel)
lastFeedback: null, // {values: {...}} from the editor feedback sub-step for the current/streamed turn (null when none)
- lastDirectionNotes: null, // {notes: [...]} recorded by the director-notes sub-step this turn (null when none)
+ lastDirectionNotes: null, // {notes: [...]} recorded by the direction-note sub-step this turn (null when none)
feedbackEnabled: false,
directorIndividualFragments: false,
directionNotesMode: "off",
From 8818de6fb0c5a239642fb42447376490c0575657 Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Sat, 20 Jun 2026 22:19:01 +0000
Subject: [PATCH 03/15] Fix stale notes appearing if the note tab is re-entered
mid-regen
---
frontend/chat_stream.js | 10 +++++--
frontend/direction_notes_panel.js | 45 ++++++++++++++++++++++---------
2 files changed, 41 insertions(+), 14 deletions(-)
diff --git a/frontend/chat_stream.js b/frontend/chat_stream.js
index befa5d33..2c494a72 100644
--- a/frontend/chat_stream.js
+++ b/frontend/chat_stream.js
@@ -29,7 +29,11 @@ import {
} from "./chat_inspector.js";
import { clearInspectedMessage } from "./chat_messages.js";
import { _mergeWorkflowRejections } from "./chat_workflow.js";
-import { optimisticDropDirectionNotesFrom, renderDirectionNotesPanel } from "./direction_notes_panel.js";
+import {
+ clearDirectionNotesRegenCut,
+ optimisticDropDirectionNotesFrom,
+ renderDirectionNotesPanel,
+} from "./direction_notes_panel.js";
import { isUtilityPanelOpen } from "./panels.js";
import { refreshCharacters } from "./library.js";
// Imported directly rather than via settings.js to avoid an import cycle
@@ -336,7 +340,9 @@ export async function afterStream() {
}
clearInspectedMessage();
// The active branch moved (new reply or a regenerated sibling), so the notes
- // panel's path-scoped set is stale; refetch it if the user has it open.
+ // panel's path-scoped set is stale; refetch it if the user has it open. Clear the
+ // regen cut first so the refetch reflects the now-committed server state unfiltered.
+ clearDirectionNotesRegenCut();
if (isUtilityPanelOpen("direction-notes-panel")) renderDirectionNotesPanel();
scrollToBottom(true);
refreshCharacters();
diff --git a/frontend/direction_notes_panel.js b/frontend/direction_notes_panel.js
index 6ad15a19..33b6acf5 100644
--- a/frontend/direction_notes_panel.js
+++ b/frontend/direction_notes_panel.js
@@ -11,6 +11,12 @@ import { $, convUrl, esc, toast } from "./utils.js";
// Last fetched notes, so the edit modal can seed its textarea by id without escaping round-trips.
let notes = [];
+// Message id whose turn is being regenerated, set while a regen / super-regen / magic-rewrite
+// stream is in flight and cleared by afterStream once the new branch commits. Until then the
+// server's active path still includes this message, so a refetch would resurrect the notes it
+// recorded; applyRegenCut filters them out of every render during the window.
+let regenCutMsgId = null;
+
export function toggleDirectionNotesPanel() {
if (isUtilityPanelOpen("direction-notes-panel")) {
closeUtilityPanel("direction-notes-panel", "direction-notes-panel-btn");
@@ -54,7 +60,7 @@ export async function renderDirectionNotesPanel() {
return;
}
try {
- notes = await api.get(convUrl(S.activeConvId, "direction-notes"));
+ notes = applyRegenCut(await api.get(convUrl(S.activeConvId, "direction-notes")));
} catch (e) {
el.innerHTML = `
${esc(e.message)}
`;
return;
@@ -62,19 +68,34 @@ export async function renderDirectionNotesPanel() {
renderRows();
}
-// Regenerating message msgId replaces it with a new sibling, so msgId (and any of
-// its descendants) leaves the active branch along with the notes recorded on it.
-// The new branch only commits when the stream ends, so reflect the drop right away
-// from the cached set -- keep only notes whose authoring message precedes msgId on
-// the active path. afterStream refetches the committed state once the reply lands.
-export function optimisticDropDirectionNotesFrom(msgId) {
- if (!isUtilityPanelOpen("direction-notes-panel")) return;
+// Keep only notes whose authoring message precedes the regen cut on the current active
+// path; a no-op when no regen is in flight. Recomputed from S.messages each call (rather
+// than from a snapshot) so a mid-stream conversation switch is safe: the cut id is absent
+// from the other conversation's path, so indexOf returns -1 and the list passes through.
+function applyRegenCut(list) {
+ if (regenCutMsgId == null) return list;
const path = S.messages.map((m) => m.id);
- const cut = path.indexOf(msgId);
- if (cut < 0) return;
+ const cut = path.indexOf(regenCutMsgId);
+ if (cut < 0) return list;
const surviving = new Set(path.slice(0, cut));
- notes = notes.filter((n) => surviving.has(n.message_id));
- renderRows();
+ return list.filter((n) => surviving.has(n.message_id));
+}
+
+// Called by afterStream once the regenerated reply commits (or the stream aborts and the old
+// branch stays active); the next refetch then reflects the authoritative server state.
+export function clearDirectionNotesRegenCut() {
+ regenCutMsgId = null;
+}
+
+// Regenerating message msgId replaces it with a new sibling, so msgId (and any of its
+// descendants) leaves the active branch along with the notes recorded on it. Record the cut
+// so reopening the panel mid-stream re-applies it after the refetch (the server's active path
+// only switches when the stream ends), and drop the notes from the cached set right away for
+// immediate feedback when the panel is open.
+export function optimisticDropDirectionNotesFrom(msgId) {
+ regenCutMsgId = msgId;
+ notes = applyRegenCut(notes);
+ if (isUtilityPanelOpen("direction-notes-panel")) renderRows();
}
export function editDirectionNote(fid) {
From b9afea3f8a0ada768b73c8b0ba79008b47302a1f Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Sun, 21 Jun 2026 16:21:46 +0000
Subject: [PATCH 04/15] Fix notes UI bug + make notes menu invisible by default
+ make recording location based on per-fragment choice
---
backend/api/schemas.py | 7 +-
.../migrations/0035_direction_notes.py | 46 +++++------
backend/database/models.py | 7 +-
.../database/queries/interactive_fragments.py | 4 +-
backend/database/queries/settings.py | 3 +-
backend/database/schema.py | 8 +-
backend/database/seeds.py | 6 +-
backend/pipeline/orchestrator.py | 32 +++++---
.../passes/director/direction_note.py | 24 +++---
backend/pipeline/predicates.py | 30 +++----
frontend/app.js | 6 +-
frontend/index.html | 4 +-
frontend/library_fragments.js | 16 +++-
frontend/settings.js | 67 ++++++++-------
frontend/state.js | 5 +-
tests/integration/test_direction_notes.py | 82 ++++++++++++++-----
tests/unit/test_kv_cache_invariants.py | 4 +-
17 files changed, 205 insertions(+), 146 deletions(-)
diff --git a/backend/api/schemas.py b/backend/api/schemas.py
index d84bf6ed..f132e60a 100644
--- a/backend/api/schemas.py
+++ b/backend/api/schemas.py
@@ -49,9 +49,8 @@ class SettingsUpdate(BaseModel):
agent_shared_system_prompt: Optional[str] = None
feedback_enabled: Optional[bool] = None
director_individual_fragments: Optional[bool] = None
- direction_notes_mode: Optional[Literal["off", "pre_writer", "post_turn"]] = None
- direction_notes_inject: Optional[bool] = None
- direction_notes_recipient: Optional[Literal["director", "writer", "both"]] = None
+ direction_notes_record: Optional[bool] = None
+ direction_notes_inject: Optional[Literal["off", "director", "writer", "both"]] = None
inspector_open_states: Optional[dict] = None
workflows_globally_enabled: Optional[bool] = None
@@ -137,6 +136,7 @@ class InteractiveFragmentCreate(BaseModel):
enabled: bool = True
injection_label: str
sort_order: int = 0
+ direction_note_timing: Literal["pre_writer", "post_turn"] = "post_turn"
class InteractiveFragmentUpdate(BaseModel):
@@ -147,6 +147,7 @@ class InteractiveFragmentUpdate(BaseModel):
enabled: Optional[bool] = None
injection_label: Optional[str] = None
sort_order: Optional[int] = None
+ direction_note_timing: Optional[Literal["pre_writer", "post_turn"]] = None
class WorldCreate(BaseModel):
diff --git a/backend/database/migrations/0035_direction_notes.py b/backend/database/migrations/0035_direction_notes.py
index f7940621..09852b1b 100644
--- a/backend/database/migrations/0035_direction_notes.py
+++ b/backend/database/migrations/0035_direction_notes.py
@@ -1,12 +1,11 @@
-"""Add the ``direction_notes`` table, its settings, and the default fragment.
-
-Fresh databases get the table/settings from ``schema.py`` and the default fragment
-from ``SEED_INTERACTIVE_FRAGMENTS``; this backfills existing ones. The table DDL is
-sourced from ``schema.py`` so the backfilled shape cannot drift from the fresh-install
-shape. ``direction_notes_mode`` default ``'off'`` keeps recording disabled until opted
-in; ``direction_notes_inject`` default ``1`` injects stored notes by default once
-recording produces any; ``direction_notes_recipient`` default ``'both'`` feeds them to
-the director and writer.
+"""Add the ``direction_notes`` table, its settings, and per-fragment recording timing.
+
+Fresh installs get these from ``schema.py`` and ``SEED_INTERACTIVE_FRAGMENTS``; this
+backfills existing ones, sourcing the table DDL from ``schema.py`` so the two shapes
+cannot diverge. ``direction_notes_record`` defaults off, keeping recording opt-in;
+``direction_notes_inject`` (``off``/``director``/``writer``/``both``) defaults to ``off``;
+each direction-note fragment's ``direction_note_timing`` defaults to ``post_turn``, so it
+records after the reply unless set to record before the writer.
"""
from __future__ import annotations
@@ -22,26 +21,27 @@ def migrate(conn: sqlite3.Connection) -> None:
conn.execute("CREATE INDEX IF NOT EXISTS idx_dirnote_conversation ON direction_notes(conversation_id)")
cols = {row[1] for row in conn.execute("PRAGMA table_info(settings)").fetchall()}
- if "direction_notes_mode" not in cols:
- conn.execute("ALTER TABLE settings ADD COLUMN direction_notes_mode TEXT NOT NULL DEFAULT 'off'")
- print("[migrations] 0035: added direction_notes_mode column to settings")
+ if "direction_notes_record" not in cols:
+ conn.execute("ALTER TABLE settings ADD COLUMN direction_notes_record INTEGER NOT NULL DEFAULT 0")
+ print("[migrations] 0035: added direction_notes_record column to settings")
if "direction_notes_inject" not in cols:
- conn.execute("ALTER TABLE settings ADD COLUMN direction_notes_inject INTEGER NOT NULL DEFAULT 1")
+ conn.execute("ALTER TABLE settings ADD COLUMN direction_notes_inject TEXT NOT NULL DEFAULT 'off'")
print("[migrations] 0035: added direction_notes_inject column to settings")
- if "direction_notes_recipient" not in cols:
- conn.execute("ALTER TABLE settings ADD COLUMN direction_notes_recipient TEXT NOT NULL DEFAULT 'both'")
- print("[migrations] 0035: added direction_notes_recipient column to settings")
-
- # Ship the default direction_note fragment to existing installs. Fresh installs get
- # it from SEED_INTERACTIVE_FRAGMENTS, which runs before migrations, so the guard makes
- # this a no-op there. Frozen copy of that seed entry (migrations must not drift with
- # later seed edits); keep the two in sync when changing the default.
+
+ frag_cols = {row[1] for row in conn.execute("PRAGMA table_info(interactive_fragments)").fetchall()}
+ if "direction_note_timing" not in frag_cols:
+ conn.execute("ALTER TABLE interactive_fragments ADD COLUMN direction_note_timing TEXT NOT NULL DEFAULT 'post_turn'")
+ print("[migrations] 0035: added direction_note_timing column to interactive_fragments")
+
+ # Ship the default direction_note fragment to existing installs; the guard makes this a
+ # no-op on fresh ones, which seeded it before migrations ran. The row is pinned here so a
+ # later edit to the seed cannot change what an existing install received.
frag_ids = {row[0] for row in conn.execute("SELECT id FROM interactive_fragments").fetchall()}
if "story_direction" not in frag_ids:
conn.execute(
"INSERT INTO interactive_fragments "
- "(id, label, description, field_type, required, enabled, injection_label, sort_order) "
- "VALUES ('story_direction', 'Story Direction', ?, 'direction_note', 0, 0, 'Story direction', 6)",
+ "(id, label, description, field_type, required, enabled, injection_label, sort_order, direction_note_timing) "
+ "VALUES ('story_direction', 'Story Direction', ?, 'direction_note', 0, 0, 'Story direction', 6, 'post_turn')",
(
"Record a lasting development worth keeping for the rest of this branch: the direction of "
"travel, an established fact, or a change to a character and the reason for it. Leave empty "
diff --git a/backend/database/models.py b/backend/database/models.py
index 90414fa7..2222c3c3 100644
--- a/backend/database/models.py
+++ b/backend/database/models.py
@@ -98,9 +98,8 @@ class _SettingsBase(TypedDict):
agent_shared_system_prompt: str
feedback_enabled: int
director_individual_fragments: int
- direction_notes_mode: str
- direction_notes_inject: int
- direction_notes_recipient: str
+ direction_notes_record: int
+ direction_notes_inject: str
workflows_globally_enabled: int
@@ -355,6 +354,8 @@ class InteractiveFragmentRow(TypedDict):
enabled: int
injection_label: str
sort_order: int
+ # 'pre_writer' | 'post_turn'; which recording step fills the note. Read only for direction_note fragments.
+ direction_note_timing: str
class MoodFragmentRow(TypedDict):
diff --git a/backend/database/queries/interactive_fragments.py b/backend/database/queries/interactive_fragments.py
index 68553a15..71087a7d 100644
--- a/backend/database/queries/interactive_fragments.py
+++ b/backend/database/queries/interactive_fragments.py
@@ -21,7 +21,7 @@ async def get_interactive_fragment(fid: str) -> InteractiveFragmentRow | None:
async def create_interactive_fragment(data: dict) -> InteractiveFragmentRow | None:
async with get_db() as db:
await db.execute(
- "INSERT INTO interactive_fragments (id, label, description, field_type, required, enabled, injection_label, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO interactive_fragments (id, label, description, field_type, required, enabled, injection_label, sort_order, direction_note_timing) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
data["id"],
data["label"],
@@ -31,6 +31,7 @@ async def create_interactive_fragment(data: dict) -> InteractiveFragmentRow | No
1 if data.get("enabled", True) else 0,
data["injection_label"],
data.get("sort_order", 0),
+ data.get("direction_note_timing", "post_turn"),
),
)
await db.commit()
@@ -47,6 +48,7 @@ async def update_interactive_fragment(fid: str, data: dict) -> InteractiveFragme
"enabled",
"injection_label",
"sort_order",
+ "direction_note_timing",
]
sets, vals = _build_set_clause(allowed, data)
if sets:
diff --git a/backend/database/queries/settings.py b/backend/database/queries/settings.py
index 1aa76636..a4b3549d 100644
--- a/backend/database/queries/settings.py
+++ b/backend/database/queries/settings.py
@@ -226,9 +226,8 @@ async def update_settings(data: dict) -> SettingsRow:
"agent_shared_system_prompt",
"feedback_enabled",
"director_individual_fragments",
- "direction_notes_mode",
+ "direction_notes_record",
"direction_notes_inject",
- "direction_notes_recipient",
"inspector_open_states",
"workflows_globally_enabled",
]
diff --git a/backend/database/schema.py b/backend/database/schema.py
index 7bd8dce2..5e37df7c 100644
--- a/backend/database/schema.py
+++ b/backend/database/schema.py
@@ -39,9 +39,8 @@
agent_shared_system_prompt TEXT NOT NULL DEFAULT '',
feedback_enabled INTEGER NOT NULL DEFAULT 0,
director_individual_fragments INTEGER NOT NULL DEFAULT 0,
- direction_notes_mode TEXT NOT NULL DEFAULT 'off',
- direction_notes_inject INTEGER NOT NULL DEFAULT 1,
- direction_notes_recipient TEXT NOT NULL DEFAULT 'both',
+ direction_notes_record INTEGER NOT NULL DEFAULT 0,
+ direction_notes_inject TEXT NOT NULL DEFAULT 'off',
inspector_open_states TEXT NOT NULL DEFAULT '{"reasoning":true,"tool_calls":false,"injection_block":false,"context_size":true}',
workflow_config TEXT NOT NULL DEFAULT '{}',
workflows_globally_enabled INTEGER NOT NULL DEFAULT 1,
@@ -127,7 +126,8 @@
required BOOLEAN NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT 1,
injection_label TEXT NOT NULL,
- sort_order INTEGER NOT NULL DEFAULT 0
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ direction_note_timing TEXT NOT NULL DEFAULT 'post_turn'
);
CREATE TABLE IF NOT EXISTS conversation_logs (
diff --git a/backend/database/seeds.py b/backend/database/seeds.py
index ffd105b3..3d313159 100644
--- a/backend/database/seeds.py
+++ b/backend/database/seeds.py
@@ -167,6 +167,7 @@
"injection_label": "Story direction",
"sort_order": 6,
"enabled": False,
+ "direction_note_timing": "post_turn",
},
]
@@ -215,9 +216,8 @@
"agent_shared_system_prompt": "",
"feedback_enabled": 0,
"director_individual_fragments": 0,
- "direction_notes_mode": "off",
- "direction_notes_inject": 1,
- "direction_notes_recipient": "both",
+ "direction_notes_record": 0,
+ "direction_notes_inject": "off",
"workflows_globally_enabled": 1,
}
diff --git a/backend/pipeline/orchestrator.py b/backend/pipeline/orchestrator.py
index 3d78bc20..36a496d1 100644
--- a/backend/pipeline/orchestrator.py
+++ b/backend/pipeline/orchestrator.py
@@ -22,6 +22,7 @@
from .passes.director import direction_note_step, director_stage
from .passes.editor import editor_stage
from .passes.writer import writer_stage
+from .predicates import direction_note_recording_active
from .state import LorebookTurn, TurnState
from .workflow_bridge import _PostPipelineResult, _run_post_pipeline
@@ -44,13 +45,17 @@ def _make_result(state: TurnState, staged: list[dict] | None = None, staged_stat
async def _consume_direction_note_step(gen: AsyncIterator[dict], state: TurnState, pass_label: str) -> AsyncIterator[dict]:
- """Drain a direction-note step: stream its reasoning under *pass_label*, keep the notes."""
+ """Drain a direction-note step: stream its reasoning under *pass_label*, keep the notes.
+
+ Notes accumulate across the turn's two placements; the event carries the running total so
+ the inspector shows every note recorded this turn regardless of which step produced it.
+ """
async for ev in gen:
if ev["type"] == "reasoning":
yield {"event": "reasoning", "data": {"pass": pass_label, "delta": ev["delta"]}}
elif ev["type"] == "done":
- state.direction_notes = ev["result"].notes
- if state.direction_notes:
+ if ev["result"].notes:
+ state.direction_notes.extend(ev["result"].notes)
yield {"event": "direction_notes", "data": {"notes": state.direction_notes}}
@@ -115,6 +120,12 @@ async def _run_pipeline(
# direction-note step; the rest shape the writer prompt.
writer_fragments, feedback_fragments, direction_note_fragments = _split_interactive_fragments(interactive_fragments)
+ # Each direction-note fragment chooses its own recording placement, so a turn may run a
+ # pre-writer step, a post-turn step, or both. The shared tool blob still carries the union
+ # of all of them, keeping the cached prefix byte-stable across both steps.
+ pre_writer_notes = [df for df in direction_note_fragments if df.get("direction_note_timing") == "pre_writer"]
+ post_turn_notes = [df for df in direction_note_fragments if df.get("direction_note_timing") != "pre_writer"]
+
# Mutable state threaded through the three passes; seeded from director + user message.
state = TurnState(
user_message=user_message,
@@ -144,18 +155,15 @@ async def _run_pipeline(
# --- Direction-note step (pre-writer placement) ---
# Reflects on the scene direction the director just set, so it requires
# direct_scene (which is what produces that direction).
- if (
- settings.get("direction_notes_mode") == "pre_writer"
- and cfg.agent_on
- and direction_note_fragments
- and cfg.enabled_tools.get("direct_scene")
+ if direction_note_recording_active(settings, pre_writer_notes, agent_on=cfg.agent_on) and cfg.enabled_tools.get(
+ "direct_scene"
):
async for ev in _consume_direction_note_step(
direction_note_step(
cfg.agent_lane.client,
cfg.agent_lane.base,
settings=settings,
- direction_note_fragments=direction_note_fragments,
+ direction_note_fragments=pre_writer_notes,
active_notes=director.get("direction_notes") or [],
placement="pre_writer",
inj_block=state.scene_direction,
@@ -228,9 +236,7 @@ async def _run_pipeline(
# Sees the finished reply. Skipped on an empty draft (no message to anchor notes
# to) and on a stop arriving after the last pre-editor abort check.
if (
- cfg.agent_on
- and settings.get("direction_notes_mode") == "post_turn"
- and direction_note_fragments
+ direction_note_recording_active(settings, post_turn_notes, agent_on=cfg.agent_on)
and state.resp_text.strip()
and not client.is_aborted
):
@@ -239,7 +245,7 @@ async def _run_pipeline(
cfg.agent_lane.client,
cfg.agent_lane.base,
settings=settings,
- direction_note_fragments=direction_note_fragments,
+ direction_note_fragments=post_turn_notes,
active_notes=director.get("direction_notes") or [],
placement="post_turn",
reply_text=state.resp_text,
diff --git a/backend/pipeline/passes/director/direction_note.py b/backend/pipeline/passes/director/direction_note.py
index a409e3d7..d7beb049 100644
--- a/backend/pipeline/passes/director/direction_note.py
+++ b/backend/pipeline/passes/director/direction_note.py
@@ -3,15 +3,17 @@
Asks the model, via a forced ``record_direction_note`` call, whether anything from
this turn should persist for the rest of the branch. Runs as a standalone sub-call
-gated by ``direction_notes_mode`` and the enabled ``field_type='direction_note'``
-fragments; each filled parameter becomes one labelled note (empty when nothing is
-worth recording).
-
-The schema rides the shared per-turn tool blob, so this step reuses the unchanged
-base and only forces the tool choice. The trailing depends on placement: the
-post-turn placement replays the writer's user message and reply to extend the warm
-writer/editor prefix; the pre-writer placement appends only the request, carrying
-this turn's scene direction inside it.
+gated by the master Writing switch and the enabled ``field_type='direction_note'``
+fragments whose timing matches this placement; each filled parameter becomes one
+labelled note (empty when nothing is worth recording).
+
+The wire schema in the shared per-turn tool blob is the union of every direction-note
+fragment, held byte-stable so both placements reuse the cached base and only force the
+tool choice. Each call is handed just its timing group, which shapes the request text
+and the extraction. The trailing depends on placement: the post-turn placement replays
+the writer's user message and reply to extend the warm writer/editor prefix; the
+pre-writer placement appends only the request, carrying this turn's scene direction
+inside it.
Errors and aborts are swallowed into an empty result. The post-turn placement runs
immediately before the turn's ``_result`` is emitted, so a propagating exception
@@ -99,8 +101,8 @@ async def direction_note_step(
yield {"type": "done", "result": DirectionNoteResult()}
return
- # Byte-identical to the override already in the shared base; built here only to
- # echo the parameter order into the request.
+ # This placement's timing group only -- echoed into the request so the model is asked
+ # to fill just these categories. The wire schema in the shared base is the wider union.
tool_schema = build_direction_note_tool(direction_note_fragments)
request = build_direction_note_prompt(
diff --git a/backend/pipeline/predicates.py b/backend/pipeline/predicates.py
index 9e8aeae6..3f930535 100644
--- a/backend/pipeline/predicates.py
+++ b/backend/pipeline/predicates.py
@@ -41,41 +41,37 @@ def direction_note_recording_active(
*,
agent_on: bool,
) -> bool:
- """Return True when the direction-note sub-call should record this turn.
+ """Return True when the direction-note step should record for the given fragment group.
- Gated by the global Agent toggle, a ``direction_notes_mode`` of ``pre_writer``
- or ``post_turn``, and the presence of at least one enabled direction-note fragment to
- fill. This is the write side; injection of already-stored notes is independent
- (see :func:`direction_note_injection_active`).
+ Gated by the global Agent toggle, the master Writing switch (``direction_notes_record``),
+ and at least one enabled direction-note fragment in the group. Callers pass the fragments
+ of a single timing (pre-writer or post-turn), so this answers per placement. The write
+ side; injection of already-stored notes is independent (see
+ :func:`direction_note_injection_active`).
"""
- return (
- agent_on
- and settings.get("direction_notes_mode", "off") in ("pre_writer", "post_turn")
- and bool(direction_note_fragments)
- )
+ return agent_on and bool(settings.get("direction_notes_record", 0)) and bool(direction_note_fragments)
def direction_note_injection_active(settings: Mapping[str, Any]) -> bool:
"""Return True when stored direction notes should be injected at all.
The read side, decoupled from recording: notes keep injecting even while recording
- is off or their authoring fragment is disabled. Defaults on. Who receives them is a
- further choice (see :func:`direction_note_to_director` / :func:`direction_note_to_writer`).
+ is off or their authoring fragment is disabled. Off only when the injection target is
+ ``off``; who receives them is a further choice (see :func:`direction_note_to_director` /
+ :func:`direction_note_to_writer`).
"""
- return bool(settings.get("direction_notes_inject", 1))
+ return (settings.get("direction_notes_inject", "off") or "off") != "off"
def direction_note_to_director(settings: Mapping[str, Any]) -> bool:
"""True when the director's ``direct_scene`` pass should see the stored notes, so it
decides the scene consistent with the direction it established earlier."""
- recipient = settings.get("direction_notes_recipient", "both")
- return direction_note_injection_active(settings) and recipient in ("director", "both")
+ return (settings.get("direction_notes_inject", "off") or "off") in ("director", "both")
def direction_note_to_writer(settings: Mapping[str, Any]) -> bool:
"""True when the stored notes should ride the writer's Scene Direction block."""
- recipient = settings.get("direction_notes_recipient", "both")
- return direction_note_injection_active(settings) and recipient in ("writer", "both")
+ return (settings.get("direction_notes_inject", "off") or "off") in ("writer", "both")
def resolve_persona_id(
diff --git a/frontend/app.js b/frontend/app.js
index b08d4538..4d7cadea 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -159,8 +159,7 @@ import {
saveUserProfile,
setAgentEnabled,
setDirectionNotesInject,
- setDirectionNotesMode,
- setDirectionNotesRecipient,
+ setDirectionNotesRecord,
setPersonaCharacterLock,
setPersonaConversationLock,
showAddPhraseGroupModal,
@@ -245,9 +244,8 @@ Object.assign(window, {
toggleAgenticLorebook,
toggleFeedbackEnabled,
toggleDirectorIndividualFragments,
- setDirectionNotesMode,
+ setDirectionNotesRecord,
setDirectionNotesInject,
- setDirectionNotesRecipient,
toggleDirectionNotesPanel,
editDirectionNote,
saveDirectionNote,
diff --git a/frontend/index.html b/frontend/index.html
index 4089a3f2..b928029d 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -91,13 +91,13 @@
-
+
👤 User
✨ Workflow
🔍 Inspector
-
Notes
+
Notes
diff --git a/frontend/library_fragments.js b/frontend/library_fragments.js
index fd10c185..2dd5e2f1 100644
--- a/frontend/library_fragments.js
+++ b/frontend/library_fragments.js
@@ -174,12 +174,12 @@ export function renderInteractiveFragments() {
// Feedback and direction-note fragments are gated by their own feature switch;
// grey them out (and explain why on hover) when that switch is off.
const feedbackDisabled = f.field_type === "feedback" && !S.feedbackEnabled;
- const directionNoteDisabled = f.field_type === "direction_note" && (S.directionNotesMode || "off") === "off";
+ const directionNoteDisabled = f.field_type === "direction_note" && !S.directionNotesRecord;
const featureDisabled = feedbackDisabled || directionNoteDisabled;
const itemTitle = feedbackDisabled
? "Editor Feedback feature is disabled — enable it in Agents panel to use this fragment"
: directionNoteDisabled
- ? "Direction Notes recording is off -- set it to record in the Agents panel to use this fragment"
+ ? "Direction Notes recording is off -- turn on Writing in the Agents panel to use this fragment"
: esc(f.description);
return `
@@ -330,6 +330,9 @@ export function updateInteractiveFragmentExample(fieldType) {
set("interactive-frag-label", ex.label);
set("interactive-frag-inj-label", ex.injection_label);
set("interactive-frag-desc", ex.description);
+ // The recording-timing selector applies only to direction-note fragments.
+ const timingRow = document.getElementById("interactive-frag-timing-row");
+ if (timingRow) timingRow.style.display = fieldType === "direction_note" ? "" : "none";
}
export function showInteractiveFragmentModal(fragId = null) {
@@ -343,6 +346,7 @@ export function showInteractiveFragmentModal(fragId = null) {
required: false,
injection_label: "",
sort_order: 0,
+ direction_note_timing: "post_turn",
};
const ex = INTERACTIVE_FRAGMENT_EXAMPLES[d.field_type] || INTERACTIVE_FRAGMENT_EXAMPLES.string;
@@ -367,6 +371,13 @@ export function showInteractiveFragmentModal(fragId = null) {
+
+
+
+
+
+
+
@@ -392,6 +403,7 @@ export async function saveInteractiveFragment(isEdit) {
field_type: document.getElementById("interactive-frag-type").value,
required: document.getElementById("interactive-frag-required").checked,
injection_label: document.getElementById("interactive-frag-inj-label").value.trim(),
+ direction_note_timing: document.getElementById("interactive-frag-timing-select").value,
};
const validation = validate.validateInteractiveFragment(d);
if (!validation.valid) {
diff --git a/frontend/settings.js b/frontend/settings.js
index c5c62f5e..05247f86 100644
--- a/frontend/settings.js
+++ b/frontend/settings.js
@@ -89,9 +89,9 @@ export async function loadSettings() {
// and again by at least one enabled feedback-type interactive fragment server-side.
S.feedbackEnabled = Boolean(S.settings.feedback_enabled);
S.directorIndividualFragments = Boolean(S.settings.director_individual_fragments);
- S.directionNotesMode = S.settings.direction_notes_mode || "off";
- S.directionNotesInject = Boolean(S.settings.direction_notes_inject ?? 1);
- S.directionNotesRecipient = S.settings.direction_notes_recipient || "both";
+ S.directionNotesRecord = Boolean(S.settings.direction_notes_record);
+ S.directionNotesInject = S.settings.direction_notes_inject || "off";
+ updateDirectionNotesButton();
if (S.settings.length_guard_max_words) S.lengthGuardMaxWords = S.settings.length_guard_max_words;
if (S.settings.length_guard_max_paragraphs) S.lengthGuardMaxParagraphs = S.settings.length_guard_max_paragraphs;
@@ -286,22 +286,33 @@ export async function toggleDirectorIndividualFragments(on) {
await persistSettings({ director_individual_fragments: on });
}
-export async function setDirectionNotesMode(mode) {
- S.directionNotesMode = mode;
+export async function setDirectionNotesRecord(on) {
+ S.directionNotesRecord = on;
renderToolsPanel();
- await persistSettings({ direction_notes_mode: mode });
+ // Direction-note fragments in the sidebar are greyed out when recording is off.
+ renderInteractiveFragments();
+ updateDirectionNotesButton();
+ await persistSettings({ direction_notes_record: on });
}
export async function setDirectionNotesInject(val) {
- S.directionNotesInject = val === "on";
+ S.directionNotesInject = val;
renderToolsPanel();
- await persistSettings({ direction_notes_inject: val === "on" });
+ updateDirectionNotesButton();
+ await persistSettings({ direction_notes_inject: val });
}
-export async function setDirectionNotesRecipient(val) {
- S.directionNotesRecipient = val;
- renderToolsPanel();
- await persistSettings({ direction_notes_recipient: val });
+// The Notes button and its panel only matter while notes are being recorded or injected;
+// with both off the feature is dormant, so hide the entry points and close the panel.
+function updateDirectionNotesButton() {
+ const on = S.directionNotesRecord || S.directionNotesInject !== "off";
+ for (const id of ["direction-notes-panel-btn", "mobile-direction-notes-btn"]) {
+ const el = $(id);
+ if (el) el.classList.toggle("hidden", !on);
+ }
+ if (!on && isUtilityPanelOpen("direction-notes-panel")) {
+ closeUtilityPanel("direction-notes-panel", "direction-notes-panel-btn");
+ }
}
export async function toggleShowEditorDiff(on) {
@@ -550,33 +561,27 @@ export function renderToolsPanel() {
Director fills each interactive fragment in its own LLM call. More focused output; higher latency.
Recording adds a note per enabled "direction_note" fragment, kept on this branch ("before writer" adds latency before the reply streams; "end of turn" records after the final reply). Injection is separate from recording. "Who receives" picks whether the director sees the notes while planning the scene, the writer while generating prose, or both.
+
Recording writes a lasting note per enabled "direction_note" fragment, kept on this branch; each fragment sets when it records (before the writer, or end of turn). Injection feeds stored notes back to the director, the writer, or both, and is independent of recording.
`;
$("tools-list").innerHTML = toolCards + lengthGuardCard + feedbackCard + individualFragmentsCard + directionNotesCard;
diff --git a/frontend/state.js b/frontend/state.js
index 86944e36..602e7c74 100644
--- a/frontend/state.js
+++ b/frontend/state.js
@@ -48,9 +48,8 @@ export const S = {
lastDirectionNotes: null, // {notes: [...]} recorded by the direction-note sub-step this turn (null when none)
feedbackEnabled: false,
directorIndividualFragments: false,
- directionNotesMode: "off",
- directionNotesInject: true, // inject stored direction notes into context (read side, independent of recording)
- directionNotesRecipient: "both", // who sees injected notes: director / writer / both
+ directionNotesRecord: false, // master Writing switch; a fragment also needs its own enabled + timing
+ directionNotesInject: "off", // injection target: off | director | writer | both (read side, independent of recording)
reasoningPassActive: 0,
reasoningPassSelected: 0,
reasoningUserOverride: false,
diff --git a/tests/integration/test_direction_notes.py b/tests/integration/test_direction_notes.py
index 97682a59..e2ea52ac 100644
--- a/tests/integration/test_direction_notes.py
+++ b/tests/integration/test_direction_notes.py
@@ -5,8 +5,9 @@
fragment, and each filled parameter persists one note (keyed to the turn's assistant
message, carrying the fragment's id and label). Covers both placements, the suppressing
gates (off / global agent toggle / no enabled fragment / empty draft), branch-dependence,
-the steered regenerates, and the read/write separation: recording (``direction_notes_mode``
-+ per-fragment ``enabled``) is independent of injection (``direction_notes_inject``).
+the steered regenerates, and the read/write separation: recording (the ``direction_notes_record``
+switch + per-fragment ``enabled`` and ``direction_note_timing``) is independent of injection
+(``direction_notes_inject``).
"""
from __future__ import annotations
@@ -23,7 +24,9 @@
_HEADING = "Direction of travel"
-async def _make_fragment(fid: str = "trajectory", injection_label: str = _HEADING, enabled: bool = True) -> None:
+async def _make_fragment(
+ fid: str = "trajectory", injection_label: str = _HEADING, enabled: bool = True, timing: str = "post_turn"
+) -> None:
"""Create one enabled ``field_type="direction_note"`` interactive fragment."""
await dbmod.create_interactive_fragment(
{
@@ -33,6 +36,7 @@ async def _make_fragment(fid: str = "trajectory", injection_label: str = _HEADIN
"field_type": "direction_note",
"injection_label": injection_label,
"enabled": enabled,
+ "direction_note_timing": timing,
}
)
@@ -66,7 +70,7 @@ async def test_post_turn_fires_and_persists(client, db, llm_mock):
cid = "conv-dn-post"
await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
await _make_fragment()
- await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_record": True})
llm_mock.enqueue_writer("She nods slowly.")
llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
@@ -87,10 +91,10 @@ async def test_post_turn_fires_and_persists(client, db, llm_mock):
async def test_pre_writer_runs_before_writer(client, db, llm_mock):
cid = "conv-dn-pre"
await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
- await _make_fragment()
+ await _make_fragment(timing="pre_writer")
await client.put(
"/api/settings",
- json={"enable_agent": True, "direction_notes_mode": "pre_writer", "enabled_tools": {"direct_scene": True}},
+ json={"enable_agent": True, "direction_notes_record": True, "enabled_tools": {"direct_scene": True}},
)
llm_mock.enqueue_director([{"type": "function", "function": {"name": "direct_scene", "arguments": {"moods": []}}}])
@@ -108,12 +112,12 @@ async def test_pre_writer_runs_before_writer(client, db, llm_mock):
async def test_pre_writer_skipped_without_direct_scene(client, db, llm_mock):
cid = "conv-dn-pre-skip"
await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
- await _make_fragment()
+ await _make_fragment(timing="pre_writer")
# pre_writer reflects on the director's scene direction, so without direct_scene
# the sub-call must not run.
await client.put(
"/api/settings",
- json={"enable_agent": True, "direction_notes_mode": "pre_writer", "enabled_tools": {"direct_scene": False}},
+ json={"enable_agent": True, "direction_notes_record": True, "enabled_tools": {"direct_scene": False}},
)
llm_mock.enqueue_writer("A reply.")
@@ -129,7 +133,7 @@ async def test_off_does_not_run(client, db, llm_mock):
cid = "conv-dn-off"
await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
await _make_fragment()
- await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "off"})
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_record": False})
llm_mock.enqueue_writer("A reply.")
llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE)) # must stay unconsumed
@@ -146,7 +150,7 @@ async def test_no_enabled_fragment_does_not_run(client, db, llm_mock):
await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
# Recording on, but no direction_note fragment exists: nothing to fill, so the
# sub-call is skipped entirely (mirrors the feedback step with no feedback fragment).
- await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_record": True})
llm_mock.enqueue_writer("A reply.")
llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
@@ -161,7 +165,7 @@ async def test_obeys_global_agent_toggle(client, db, llm_mock):
cid = "conv-dn-agent-off"
await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
await _make_fragment()
- await client.put("/api/settings", json={"enable_agent": False, "direction_notes_mode": "post_turn"})
+ await client.put("/api/settings", json={"enable_agent": False, "direction_notes_record": True})
llm_mock.enqueue_writer("A reply.")
llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
@@ -176,7 +180,7 @@ async def test_empty_draft_persists_nothing(client, db, llm_mock):
cid = "conv-dn-empty"
await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
await _make_fragment()
- await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_record": True})
llm_mock.enqueue_writer("") # reasoning-only turn: no assistant message persisted
llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
@@ -192,7 +196,7 @@ async def test_notes_are_branch_dependent(client, db, llm_mock):
cid = "conv-dn-branch"
await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
await _make_fragment()
- await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_record": True})
llm_mock.enqueue_writer("The door creaks open.")
llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
@@ -216,7 +220,7 @@ async def test_magic_rewrite_records_note_on_new_branch(client, db, llm_mock):
cid = "conv-dn-magic"
await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
await _make_fragment()
- await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_record": True})
llm_mock.enqueue_writer("The hall is silent.")
llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
@@ -244,7 +248,7 @@ async def test_super_regenerate_records_note_on_new_branch(client, db, llm_mock)
cid = "conv-dn-super"
await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
await _make_fragment()
- await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_record": True})
llm_mock.enqueue_writer("She waits by the gate.")
llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
@@ -268,7 +272,12 @@ async def test_injection_is_independent_of_recording(client, db, llm_mock):
# direct_scene on so a director_done injection block is produced each turn.
await client.put(
"/api/settings",
- json={"enable_agent": True, "direction_notes_mode": "post_turn", "enabled_tools": {"direct_scene": True}},
+ json={
+ "enable_agent": True,
+ "direction_notes_record": True,
+ "direction_notes_inject": "both",
+ "enabled_tools": {"direct_scene": True},
+ },
)
# Turn 1 records a note.
@@ -276,14 +285,14 @@ async def test_injection_is_independent_of_recording(client, db, llm_mock):
llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
await _drain(handle_turn(cid, "hello"))
- # Turn 2, inject on (default): the stored note appears in the injection block.
+ # Turn 2, inject on: the stored note appears in the injection block.
llm_mock.enqueue_writer("Shadows lengthen.")
events2 = await _drain(handle_turn(cid, "again"))
block2 = await _injection_block(events2)
assert _HEADING in block2 and _NOTE in block2
# Turn 3, inject off: the note is withheld from the prompt, yet recording still runs.
- await client.put("/api/settings", json={"direction_notes_inject": False})
+ await client.put("/api/settings", json={"direction_notes_inject": "off"})
llm_mock.enqueue_writer("A new arrival.")
llm_mock.enqueue_direction_note(_record_call(trajectory="A stranger entered."))
events3 = await _drain(handle_turn(cid, "more"))
@@ -297,7 +306,7 @@ async def test_disabling_fragment_stops_new_notes_keeps_old(client, db, llm_mock
await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
await _make_fragment("alpha", "Alpha heading")
await _make_fragment("beta", "Beta heading")
- await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_record": True})
# Turn 1: both fragments record.
llm_mock.enqueue_writer("Opening.")
@@ -339,9 +348,9 @@ async def _record_then_next_turn(client, llm_mock, cid: str, recipient: str) ->
"/api/settings",
json={
"enable_agent": True,
- "direction_notes_mode": "post_turn",
+ "direction_notes_record": True,
"enabled_tools": {"direct_scene": True},
- "direction_notes_recipient": recipient,
+ "direction_notes_inject": recipient,
},
)
direct_scene = [{"type": "function", "function": {"name": "direct_scene", "arguments": {"moods": []}}}]
@@ -380,7 +389,7 @@ async def test_fragment_routes_list_edit_delete(client, db, llm_mock):
cid = "conv-dn-routes"
await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
await _make_fragment()
- await client.put("/api/settings", json={"enable_agent": True, "direction_notes_mode": "post_turn"})
+ await client.put("/api/settings", json={"enable_agent": True, "direction_notes_record": True})
llm_mock.enqueue_writer("A reply.")
llm_mock.enqueue_direction_note(_record_call(trajectory=_NOTE))
await _drain(handle_turn(cid, "hello"))
@@ -416,3 +425,32 @@ async def test_get_for_path_empty_and_membership(client, db, llm_mock):
assert await dbmod.get_direction_notes_for_path(cid, []) == []
rows = await dbmod.get_direction_notes_for_path(cid, [uid, on_path])
assert [r["content"] for r in rows] == ["on path"]
+
+
+async def test_per_fragment_timing_runs_both_steps(client, db, llm_mock):
+ cid = "conv-dn-timing"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ await _make_fragment("early", "Early heading", timing="pre_writer")
+ await _make_fragment("late", "Late heading", timing="post_turn")
+ await client.put(
+ "/api/settings",
+ json={"enable_agent": True, "direction_notes_record": True, "enabled_tools": {"direct_scene": True}},
+ )
+
+ llm_mock.enqueue_director([{"type": "function", "function": {"name": "direct_scene", "arguments": {"moods": []}}}])
+ llm_mock.enqueue_direction_note(_record_call(early="recorded early"))
+ llm_mock.enqueue_writer("A reply lands.")
+ llm_mock.enqueue_direction_note(_record_call(late="recorded late"))
+
+ events = await _drain(handle_turn(cid, "hello"))
+
+ # A pre-writer step (early) and a post-turn step (late) both fire, on either side of the writer.
+ order = [p for p, _ in llm_mock.calls]
+ dn_positions = [i for i, p in enumerate(order) if p == "direction_note"]
+ assert len(dn_positions) == 2
+ assert dn_positions[0] < order.index("writer") < dn_positions[1]
+
+ # Both notes persist, and the final event carries the running total across both steps.
+ assert set(await _notes_on_active_path(cid)) == {"recorded early", "recorded late"}
+ final = [e["data"]["notes"] for e in events if e.get("event") == "direction_notes"][-1]
+ assert {n["content"] for n in final} == {"recorded early", "recorded late"}
diff --git a/tests/unit/test_kv_cache_invariants.py b/tests/unit/test_kv_cache_invariants.py
index 38745940..b70f69de 100644
--- a/tests/unit/test_kv_cache_invariants.py
+++ b/tests/unit/test_kv_cache_invariants.py
@@ -320,7 +320,7 @@ async def _run_turn(
if bool(settings.get("feedback_enabled", 0)) and feedback_fragments:
schema_overrides["give_feedback"] = build_feedback_tool(feedback_fragments)
enabled_tools["give_feedback"] = True
- if settings.get("direction_notes_mode", "off") in ("pre_writer", "post_turn") and direction_note_fragments:
+ if settings.get("direction_notes_record") and direction_note_fragments:
schema_overrides["record_direction_note"] = build_direction_note_tool(direction_note_fragments)
enabled_tools["record_direction_note"] = True
@@ -538,7 +538,7 @@ async def test_direction_note_step_reuses_shared_blob_no_cache_bust():
prefix = _make_prefix("You are a vivid roleplay narrator.", n_pairs=4)
tracker, client, _ = await _run_turn(
prefix=prefix,
- settings=_base_settings(direction_notes_mode="post_turn"),
+ settings=_base_settings(direction_notes_record=True),
conversation_id="conv-dirnote-kv",
client=CapturingClient("writer-model"),
direction_note_fragments=[
From 1b8f4b2c2a479b01fe92f83e3ae92bcdbc0c1cb2 Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Mon, 22 Jun 2026 12:32:26 +0000
Subject: [PATCH 05/15] Add user directional note authoring
---
backend/api/routes/conversations.py | 29 +++++++++++++
backend/api/schemas.py | 8 ++++
frontend/app.js | 4 ++
frontend/chat_core.js | 12 +++++-
frontend/chat_inspector.js | 16 ++++---
frontend/css/inspector.css | 27 ++++++++++++
frontend/direction_notes_panel.js | 52 ++++++++++++++++++++---
frontend/settings.js | 2 +
tests/integration/test_direction_notes.py | 40 +++++++++++++++++
9 files changed, 178 insertions(+), 12 deletions(-)
diff --git a/backend/api/routes/conversations.py b/backend/api/routes/conversations.py
index d0d243cb..5015771b 100644
--- a/backend/api/routes/conversations.py
+++ b/backend/api/routes/conversations.py
@@ -14,6 +14,7 @@
add_conversation_log,
add_message,
create_conversation,
+ create_direction_notes,
delete_conversation,
delete_direction_note,
fork_conversation,
@@ -53,12 +54,18 @@
CompressRequest,
ConversationCreate,
ConversationUpdate,
+ DirectionNoteCreate,
DirectionNoteUpdate,
SummarizeRequest,
)
logger = logging.getLogger(__name__)
+# Sentinel interactive_fragment_id stamped on user-authored direction notes; the model's
+# record_direction_note step only ever emits real fragment ids, so this never collides with
+# one. The frontend keys its distinct styling on the same value -- keep the two in sync.
+_USER_NOTE_FRAGMENT_ID = "human"
+
router = APIRouter()
@@ -517,6 +524,28 @@ async def api_list_direction_notes(cid: str):
]
+@router.post("/api/conversations/{cid}/direction-notes")
+async def api_create_direction_note(cid: str, data: DirectionNoteCreate):
+ content = data.content.strip()
+ if not content:
+ raise HTTPException(status_code=400, detail="Note content is empty")
+ msg = await get_message_by_id(data.message_id)
+ if not msg or msg.get("conversation_id") != cid:
+ raise HTTPException(status_code=404, detail="Message not found")
+ ids = await create_direction_notes(
+ cid,
+ data.message_id,
+ [
+ {
+ "interactive_fragment_id": _USER_NOTE_FRAGMENT_ID,
+ "interactive_fragment_label": data.label.strip() or "Note",
+ "content": content,
+ }
+ ],
+ )
+ return {"id": ids[0]}
+
+
@router.put("/api/conversations/{cid}/direction-notes/{fid}")
async def api_update_direction_note(cid: str, fid: int, data: DirectionNoteUpdate):
updated = await update_direction_note(fid, data.content)
diff --git a/backend/api/schemas.py b/backend/api/schemas.py
index f132e60a..975f0223 100644
--- a/backend/api/schemas.py
+++ b/backend/api/schemas.py
@@ -59,6 +59,14 @@ class DirectionNoteUpdate(BaseModel):
content: str
+class DirectionNoteCreate(BaseModel):
+ # message_id anchors the note to a turn (its turn_index is derived at read time);
+ # the route rejects an id that is not an assistant message in this conversation.
+ message_id: int
+ label: str
+ content: str
+
+
class WorkflowConfigUpdate(BaseModel):
# Required (no default): a body lacking "config" is a 422, not a silent
# clear; an explicit {"config": {}} is the intentional reset-to-defaults.
diff --git a/frontend/app.js b/frontend/app.js
index 4d7cadea..bcee637f 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -181,9 +181,11 @@ import {
toggleWorkflowsGlobal,
} from "./settings.js";
import {
+ addUserDirectionNote,
deleteDirectionNote,
editDirectionNote,
saveDirectionNote,
+ saveUserDirectionNote,
toggleDirectionNotesPanel,
} from "./direction_notes_panel.js";
import { S } from "./state.js";
@@ -247,8 +249,10 @@ Object.assign(window, {
setDirectionNotesRecord,
setDirectionNotesInject,
toggleDirectionNotesPanel,
+ addUserDirectionNote,
editDirectionNote,
saveDirectionNote,
+ saveUserDirectionNote,
deleteDirectionNote,
toggleShowEditorDiff,
toggleAuditType,
diff --git a/frontend/chat_core.js b/frontend/chat_core.js
index d4ab88ca..e4a8caa3 100644
--- a/frontend/chat_core.js
+++ b/frontend/chat_core.js
@@ -81,6 +81,7 @@ export const ICON_MAGIC = ``;
export const ICON_CHEVRON = ``;
export const ICON_FORK = ``;
+export const ICON_NOTE = ``;
export function buildMsgToolbar(m, childByParent = null) {
const isAssistant = m.role === "assistant";
@@ -137,6 +138,15 @@ export function buildMsgToolbar(m, childByParent = null) {
? ``
: "";
+ // Gated on the master recording switch, the same switch that surfaces the Notes panel,
+ // so the panel the button opens is always reachable when the button shows.
+ const noteBtn =
+ isAssistant && m.id && !isGreeting && S.directionNotesRecord
+ ? S.hasMultipleTabs
+ ? ``
+ : ``
+ : "";
+
const delBtn = !m.id
? ``
: isGreeting
@@ -148,7 +158,7 @@ export function buildMsgToolbar(m, childByParent = null) {
? ``
: "";
- return `${editBtn}${forkBtn}${regenBtn}${superRegenBtn}${magicBtn}${magicInput}${_renderExtraButtons(m)}${delBtn}${diffBtn}`;
+ return `${editBtn}${forkBtn}${regenBtn}${superRegenBtn}${magicBtn}${magicInput}${noteBtn}${_renderExtraButtons(m)}${delBtn}${diffBtn}`;
}
function _renderExtraButtons(msg) {
diff --git a/frontend/chat_inspector.js b/frontend/chat_inspector.js
index 3c25485e..5dd35eb5 100644
--- a/frontend/chat_inspector.js
+++ b/frontend/chat_inspector.js
@@ -4,6 +4,7 @@
// keep working.
import { api } from "./api.js";
import { renderContextSize, renderMessages } from "./chat_core.js";
+import { USER_NOTE_ID } from "./direction_notes_panel.js";
import { closeUtilityPanel, isUtilityPanelOpen, openUtilityPanel } from "./panels.js";
import { S, effectiveWorkflowEnabled } from "./state.js";
import { $, esc } from "./utils.js";
@@ -379,16 +380,19 @@ export function buildFeedbackHtml(values) {
// One labelled row per note, in the order recorded this turn (fragment order), reusing
// the feedback block's styling so the look matches the rest of the Inspector. Notes
-// arrive as {interactive_fragment_label, content}.
+// arrive as {interactive_fragment_id, interactive_fragment_label, content}; user-authored
+// ones are tagged so they read the same here as in the Notes panel.
export function buildDirectionNotesHtml(notes) {
if (!Array.isArray(notes) || !notes.length) return "";
const body = notes
- .map(
- (n) => `
diff --git a/frontend/css/inspector.css b/frontend/css/inspector.css
index a813b1cc..de905cba 100644
--- a/frontend/css/inspector.css
+++ b/frontend/css/inspector.css
@@ -269,3 +269,30 @@ details.inspector-block[open] .reasoning-summary-arrow {
justify-content: flex-end;
}
+/* User-authored notes carry the accent so the player tells their own notes apart from the
+ director's at a glance; the badge names the author for readers who don't parse by colour. */
+.notes-row.user-note {
+ border-color: var(--accent-dim);
+ border-left: 3px solid var(--accent);
+ background: var(--accent-glow);
+}
+
+/* Same author tag in the Inspector's per-turn block, which reuses .feedback-row. */
+.feedback-row.user-note {
+ border-left: 3px solid var(--accent);
+ padding-left: 6px;
+}
+
+.notes-row-user-badge {
+ display: inline-block;
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .5px;
+ color: var(--accent);
+ border: 1px solid var(--accent-dim);
+ border-radius: 999px;
+ padding: 0 5px;
+ vertical-align: middle;
+}
+
diff --git a/frontend/direction_notes_panel.js b/frontend/direction_notes_panel.js
index 33b6acf5..3df111ad 100644
--- a/frontend/direction_notes_panel.js
+++ b/frontend/direction_notes_panel.js
@@ -8,6 +8,10 @@ import { closeUtilityPanel, isUtilityPanelOpen, openUtilityPanel } from "./panel
import { S } from "./state.js";
import { $, convUrl, esc, toast } from "./utils.js";
+// interactive_fragment_id stamped on user-authored notes (vs the model's real fragment ids).
+// The backend create route writes the same sentinel; keep the two in sync.
+export const USER_NOTE_ID = "human";
+
// Last fetched notes, so the edit modal can seed its textarea by id without escaping round-trips.
let notes = [];
@@ -36,10 +40,12 @@ function renderRows() {
// branch); within a turn, fragment order. Each note carries its fragment's label so
// the source is obvious without bucketing notes away from their chronology.
el.innerHTML = notes
- .map(
- (n) => `
`;
+ })
.join("");
}
@@ -98,6 +104,42 @@ export function optimisticDropDirectionNotesFrom(msgId) {
if (isUtilityPanelOpen("direction-notes-panel")) renderRows();
}
+// Per-message entry point (the message toolbar's note button). Opens the panel so the new
+// note is in view after saving; the note is stamped to msgId's turn.
+export function addUserDirectionNote(msgId) {
+ if (!isUtilityPanelOpen("direction-notes-panel")) {
+ openUtilityPanel("direction-notes-panel", "direction-notes-panel-btn", renderDirectionNotesPanel);
+ }
+ showModal(`
+
Add Direction Note
+
+
+
+
+
+
+
+
+
`);
+}
+
+export async function saveUserDirectionNote(msgId) {
+ const label = document.getElementById("user-note-label").value.trim() || "Note";
+ const content = document.getElementById("user-note-content").value.trim();
+ if (!content) {
+ toast("Note cannot be empty", true);
+ return;
+ }
+ try {
+ await api.post(convUrl(S.activeConvId, "direction-notes"), { message_id: msgId, label, content });
+ closeModal();
+ await renderDirectionNotesPanel();
+ toast("Note added");
+ } catch (e) {
+ toast(e.message, true);
+ }
+}
+
export function editDirectionNote(fid) {
const note = notes.find((n) => n.id === fid);
showModal(`
diff --git a/frontend/settings.js b/frontend/settings.js
index 05247f86..af940f40 100644
--- a/frontend/settings.js
+++ b/frontend/settings.js
@@ -291,6 +291,8 @@ export async function setDirectionNotesRecord(on) {
renderToolsPanel();
// Direction-note fragments in the sidebar are greyed out when recording is off.
renderInteractiveFragments();
+ // The per-message add-note button is gated on this switch, so repaint the messages too.
+ renderMessages();
updateDirectionNotesButton();
await persistSettings({ direction_notes_record: on });
}
diff --git a/tests/integration/test_direction_notes.py b/tests/integration/test_direction_notes.py
index e2ea52ac..2ed5cdc7 100644
--- a/tests/integration/test_direction_notes.py
+++ b/tests/integration/test_direction_notes.py
@@ -454,3 +454,43 @@ async def test_per_fragment_timing_runs_both_steps(client, db, llm_mock):
assert set(await _notes_on_active_path(cid)) == {"recorded early", "recorded late"}
final = [e["data"]["notes"] for e in events if e.get("event") == "direction_notes"][-1]
assert {n["content"] for n in final} == {"recorded early", "recorded late"}
+
+
+async def test_user_note_route_creates_and_lists(client, db, llm_mock):
+ cid = "conv-dn-user"
+ await dbmod.create_conversation(cid, "dn", "Bot", "a scenario")
+ # A user note is authored through the route, not the model's step: no fragment and no
+ # recording needed. A plain turn just gives it an assistant message to anchor to.
+ await client.put("/api/settings", json={"enable_agent": True})
+ llm_mock.enqueue_writer("She waits.")
+ await _drain(handle_turn(cid, "hello"))
+ asst = await _last_assistant(cid)
+
+ created = await client.post(
+ f"/api/conversations/{cid}/direction-notes",
+ json={"message_id": asst["id"], "label": "My label", "content": "The user owns the iron key."},
+ )
+ assert created.status_code == 200
+
+ rows = await dbmod.get_direction_notes_for_message(asst["id"])
+ assert len(rows) == 1
+ assert rows[0]["interactive_fragment_id"] == "human"
+ assert rows[0]["interactive_fragment_label"] == "My label"
+ assert rows[0]["content"] == "The user owns the iron key."
+
+ # It lands on the active path and the list route stamps it with the turn, like a model note.
+ listing = (await client.get(f"/api/conversations/{cid}/direction-notes")).json()
+ assert [(n["interactive_fragment_id"], n["content"]) for n in listing] == [("human", "The user owns the iron key.")]
+ assert "turn_index" in listing[0]
+
+ # A message from no conversation (or another) is rejected; empty content is rejected.
+ assert (
+ await client.post(
+ f"/api/conversations/{cid}/direction-notes", json={"message_id": 999999, "label": "x", "content": "y"}
+ )
+ ).status_code == 404
+ assert (
+ await client.post(
+ f"/api/conversations/{cid}/direction-notes", json={"message_id": asst["id"], "label": "x", "content": " "}
+ )
+ ).status_code == 400
From f3aac655d0cf15fdf51523b13a7bd1c050d3023f Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Tue, 23 Jun 2026 18:31:32 +0000
Subject: [PATCH 06/15] Add Prose Format Workflow
---
backend/workflows/__init__.py | 20 ++
.../workflows/prose_format_llm/__init__.py | 128 ++++++++++
backend/workflows/prose_format_llm/hooks.py | 145 +++++++++++
backend/workflows/prose_format_llm/loop.py | 220 ++++++++++++++++
.../workflows/prose_format_llm/patching.py | 46 ++++
backend/workflows/prose_format_llm/prompts.py | 99 ++++++++
.../workflows/prose_format_llm/statedoc.py | 36 +++
.../workflows/prose_format_llm/violations.py | 70 ++++++
.../prose_format_llm/config_panel.js | 227 +++++++++++++++++
frontend/workflows/prose_format_llm/index.js | 37 +++
.../prose_format_llm/prose_format.css | 45 ++++
tests/unit/test_prose_format_llm.py | 236 ++++++++++++++++++
tests/unit/test_tool_registry.py | 15 +-
13 files changed, 1320 insertions(+), 4 deletions(-)
create mode 100644 backend/workflows/prose_format_llm/__init__.py
create mode 100644 backend/workflows/prose_format_llm/hooks.py
create mode 100644 backend/workflows/prose_format_llm/loop.py
create mode 100644 backend/workflows/prose_format_llm/patching.py
create mode 100644 backend/workflows/prose_format_llm/prompts.py
create mode 100644 backend/workflows/prose_format_llm/statedoc.py
create mode 100644 backend/workflows/prose_format_llm/violations.py
create mode 100644 frontend/workflows/prose_format_llm/config_panel.js
create mode 100644 frontend/workflows/prose_format_llm/index.js
create mode 100644 frontend/workflows/prose_format_llm/prose_format.css
create mode 100644 tests/unit/test_prose_format_llm.py
diff --git a/backend/workflows/__init__.py b/backend/workflows/__init__.py
index e6f1942b..6adceca3 100644
--- a/backend/workflows/__init__.py
+++ b/backend/workflows/__init__.py
@@ -49,6 +49,16 @@
from .format_consistency.hooks import (
post_pipeline as _fc_post_pipeline,
)
+from .prose_format_llm import prose_format_llm_workflow
+from .prose_format_llm.hooks import (
+ on_demand as _pf_on_demand,
+)
+from .prose_format_llm.hooks import (
+ post_pipeline as _pf_post_pipeline,
+)
+from .prose_format_llm.hooks import (
+ pre_pipeline as _pf_pre_pipeline,
+)
from .registry import (
Subscription,
ToolNameCollision,
@@ -136,5 +146,15 @@
register_workflow(format_consistency_workflow)
subscribe(format_consistency_workflow.id, HookType.POST_PIPELINE, _fc_post_pipeline, priority=-10)
+# Priority -5 runs the LLM enforcer after the deterministic normalizer (-10) and
+# before any artifact consumer like TTS (0), so an artifact is built from the
+# corrected draft. Ships enabled-but-dormant: nothing runs until a conversation's
+# prose format is analyzed. Replaces format_consistency operationally (disable
+# that one), but the two coexist safely if both stay on.
+register_workflow(prose_format_llm_workflow)
+subscribe(prose_format_llm_workflow.id, HookType.PRE_PIPELINE, _pf_pre_pipeline)
+subscribe(prose_format_llm_workflow.id, HookType.POST_PIPELINE, _pf_post_pipeline, priority=-5)
+subscribe(prose_format_llm_workflow.id, HookType.ON_DEMAND, _pf_on_demand)
+
finalize_registry()
diff --git a/backend/workflows/prose_format_llm/__init__.py b/backend/workflows/prose_format_llm/__init__.py
new file mode 100644
index 00000000..81116758
--- /dev/null
+++ b/backend/workflows/prose_format_llm/__init__.py
@@ -0,0 +1,128 @@
+"""LLM prose-format workflow.
+
+Enforces a conversation's prose-markup convention (how narration, speech, etc.
+are delimited) on the writer's finished draft, using three forced-tool LLM
+paths: an analyzer that records the convention, a judge that locates
+violations, and an enforcer that patches them. It does the same job as the
+deterministic ``format_consistency`` workflow but covers the open-ended
+violation space regex cannot; deploy it as a replacement by toggling
+``format_consistency`` off.
+
+This module is the data surface only -- constants, the tool schemas, and the
+``Workflow`` record. The hooks, loop, and pure helpers live in sibling modules
+and import these names back; keeping registration out of here avoids an import
+cycle with ``backend/workflows/__init__.py``.
+"""
+
+from __future__ import annotations
+
+from ..contracts import ToolSpec
+from ..registry import Workflow
+
+WORKFLOW_ID = "prose_format_llm"
+
+TOOL_ANALYZE = "prose_format_analyze"
+TOOL_REPORT = "prose_format_report"
+TOOL_PATCH = "prose_format_patch"
+
+# Seeded into each conversation's state. Values are guidance FOR the analyzer --
+# they describe what to record about each element, and are never themselves used
+# to judge a draft. The analyzer overwrites the parallel ``values`` map with the
+# convention it observes; until it does, the conversation is unarmed and the
+# loop stays dormant.
+DEFAULT_SCHEMA = {
+ "narration": "How narration is denoted (e.g. text wrapped in asterisks).",
+ "speech": "How spoken dialogue is denoted (e.g. text wrapped in double quotes).",
+ "internal_monologue": "How a character's unspoken thought is denoted.",
+ "quotation": "How quoted or cited text inside speech or narration is denoted.",
+}
+
+
+def _array_tool(name: str, description: str, array_key: str, array_description: str, item_props: dict[str, str]) -> ToolSpec:
+ """Build a standalone ToolSpec whose sole parameter is an array of fixed-shape
+ string objects. The schema is static (the per-conversation variation lives in
+ the array's runtime contents, not its shape), so it never busts a cache."""
+ return ToolSpec(
+ name=name,
+ schema={
+ "type": "function",
+ "function": {
+ "name": name,
+ "description": description,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ array_key: {
+ "type": "array",
+ "description": array_description,
+ "items": {
+ "type": "object",
+ "properties": {k: {"type": "string", "description": d} for k, d in item_props.items()},
+ "required": list(item_props),
+ },
+ }
+ },
+ "required": [array_key],
+ },
+ },
+ },
+ choice={"type": "function", "function": {"name": name}},
+ )
+
+
+ANALYZE_TOOL = _array_tool(
+ TOOL_ANALYZE,
+ "Record how each prose element is denoted in this conversation.",
+ "records",
+ "One record per element you can characterize from the prose; omit elements with no evidence.",
+ {
+ "category": "The element name, exactly as listed in the request.",
+ "denotation": "A short description of how that element is marked in this conversation's prose.",
+ },
+)
+
+REPORT_TOOL = _array_tool(
+ TOOL_REPORT,
+ "Report spans of the draft that violate the recorded prose format.",
+ "violations",
+ "One entry per offending span; report nothing for a clean draft.",
+ {
+ "excerpt": "The offending text, copied verbatim from the draft.",
+ "category": "The single element name the span violates, exactly as listed.",
+ },
+)
+
+PATCH_TOOL = _array_tool(
+ TOOL_PATCH,
+ "Apply minimal search/replace edits that bring flagged spans into the recorded format.",
+ "patches",
+ "One patch per flagged span.",
+ {
+ "search": "The exact text to replace, copied verbatim from the draft.",
+ "replace": "That same text rewritten to the recorded format, wording unchanged.",
+ },
+)
+
+# Every key the hooks read with ``cfg.get(...)`` is present here. The framework
+# returns a non-empty config slot verbatim (no per-key merge with defaults), so a
+# slot must never be written partially; the frontend always sends all keys, and
+# this full default set covers the empty-slot path.
+_CONFIG_DEFAULTS = {"max_iterations": 1, "prompt_mode": "minimal", "auto_analyze": False, "reasoning": False}
+
+_CONFIG_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "max_iterations": {"type": "integer", "minimum": 0, "default": 1},
+ "prompt_mode": {"type": "string", "enum": ["minimal", "extend"], "default": "minimal"},
+ "auto_analyze": {"type": "boolean", "default": False},
+ "reasoning": {"type": "boolean", "default": False},
+ },
+}
+
+prose_format_llm_workflow = Workflow(
+ id=WORKFLOW_ID,
+ display_name="Prose Format (LLM)",
+ tools=[ANALYZE_TOOL, REPORT_TOOL, PATCH_TOOL],
+ config_defaults=_CONFIG_DEFAULTS,
+ config_schema=_CONFIG_SCHEMA,
+)
diff --git a/backend/workflows/prose_format_llm/hooks.py b/backend/workflows/prose_format_llm/hooks.py
new file mode 100644
index 00000000..a4478f2e
--- /dev/null
+++ b/backend/workflows/prose_format_llm/hooks.py
@@ -0,0 +1,145 @@
+"""Pipeline and HTTP bindings for the prose-format workflow.
+
+PRE runs the single automatic analysis attempt; POST runs the judge/enforce
+loop over the finished draft; ON_DEMAND is the menu RPC. State reads/writes use
+the toolkit helpers directly: the framework already holds
+``workflow_state_lock`` for the full lifetime of each hook, so they must not
+re-acquire it.
+"""
+
+from __future__ import annotations
+
+from ..contracts import EV_DRAFT_REPLACED
+from ..toolkit import get_workflow_config, get_workflow_state, set_workflow_state
+from . import WORKFLOW_ID
+from .loop import make_enforce_fn, make_judge_fn, run_analyzer, run_enforcement_loop
+from .patching import apply_patches
+from .statedoc import filled_elements, is_armed, seed
+
+
+def _as_n(value) -> int:
+ """Coerce max_iterations to a non-negative int, falling back to the default on
+ a malformed config slot rather than raising on the per-turn path."""
+ try:
+ return max(0, int(value))
+ except (TypeError, ValueError):
+ return 1
+
+
+def _str_map(d: dict) -> dict[str, str]:
+ """Keep only string-valued entries -- a non-string value would later crash the
+ state read that computes ``armed`` (mirrors the analyzer's own guard)."""
+ return {str(k): v for k, v in d.items() if isinstance(v, str)}
+
+
+def _state_payload(state) -> dict:
+ return {"schema": state.get("schema", {}), "values": state.get("values", {}), "armed": is_armed(state)}
+
+
+async def _ensure_seed(conversation_id: str, state):
+ if state is not None:
+ return state
+ state = seed()
+ await set_workflow_state(conversation_id, WORKFLOW_ID, state)
+ return state
+
+
+async def pre_pipeline(ctx):
+ """One automatic analysis attempt per conversation, opt-in via ``auto_analyze``.
+
+ Gated on ``is_armed OR auto_analyzed`` so it fires exactly once when the spec
+ is still empty, then never again automatically -- whether or not that attempt
+ produced anything -- which bounds the per-turn cost. Manual re-analysis is the
+ refresh path.
+ """
+ cfg = await get_workflow_config(WORKFLOW_ID)
+ if not cfg.get("auto_analyze"):
+ return
+ state = await _ensure_seed(ctx.conversation_id, await get_workflow_state(ctx.conversation_id, WORKFLOW_ID))
+ if is_armed(state) or state.get("auto_analyzed"):
+ return
+
+ reasoning_on = bool(cfg.get("reasoning", False))
+ pass_id = f"{WORKFLOW_ID}:analyze" if reasoning_on else None
+ values: dict = {}
+ async for ev in run_analyzer(
+ ctx, state.get("schema", {}), pass_id=pass_id, kv_tracker=ctx.kv_tracker, reasoning_on=reasoning_on
+ ):
+ if ev.get("type") == "result":
+ values = ev["values"]
+ else:
+ yield ev
+ merged = {**state, "values": {**state.get("values", {}), **values}, "auto_analyzed": True}
+ await set_workflow_state(ctx.conversation_id, WORKFLOW_ID, merged)
+
+
+async def post_pipeline(ctx):
+ """Run the judge/enforce loop on the draft when the spec is armed.
+
+ Dormant otherwise (no LLM call). The phase pill is yielded here, around the
+ loop, so it clears even when the loop made no edit; the loop's own events are
+ re-yielded and its final draft is read off the terminal sentinel.
+ """
+ state = await get_workflow_state(ctx.conversation_id, WORKFLOW_ID)
+ if state is None or not is_armed(state):
+ return
+
+ cfg = await get_workflow_config(WORKFLOW_ID)
+ n = _as_n(cfg.get("max_iterations", 1))
+ mode = cfg.get("prompt_mode") or "minimal"
+ reasoning_on = bool(cfg.get("reasoning", False))
+ spec = filled_elements(state)
+
+ judge_fn = make_judge_fn(ctx, spec, mode, reasoning_on)
+ enforce_fn = make_enforce_fn(ctx, spec, mode, reasoning_on)
+
+ def is_aborted():
+ return ctx.client.is_aborted
+
+ yield {"event": "phase_status", "data": {"channel": f"workflow:{WORKFLOW_ID}", "label": "Enforcing format"}}
+ final = ctx.draft
+ async for ev in run_enforcement_loop(ctx.draft, n, judge_fn, enforce_fn, apply_patches, is_aborted):
+ if ev.get("type") == "loop_done":
+ final = ev["draft"]
+ else:
+ yield ev
+ if final != ctx.draft:
+ yield {"type": EV_DRAFT_REPLACED, "draft": final}
+ yield {"event": "phase_status", "data": {"channel": f"workflow:{WORKFLOW_ID}", "state": "done"}}
+
+
+async def on_demand(ctx, body):
+ """Menu RPC dispatched on ``body['action']``. Seeds state on first contact so
+ the menu always has a schema to show."""
+ action = body.get("action") if isinstance(body, dict) else None
+ state = await _ensure_seed(ctx.conversation_id, await get_workflow_state(ctx.conversation_id, WORKFLOW_ID))
+
+ if action == "get":
+ return _state_payload(state)
+
+ if action == "save":
+ new = dict(state)
+ if isinstance(body.get("schema"), dict):
+ new["schema"] = _str_map(body["schema"])
+ if isinstance(body.get("values"), dict):
+ new["values"] = _str_map(body["values"])
+ await set_workflow_state(ctx.conversation_id, WORKFLOW_ID, new)
+ return _state_payload(new)
+
+ if action == "analyze":
+ # On-demand has no SSE stream, so no pass_id/kv_tracker. Leaves
+ # auto_analyzed untouched -- this is the manual refresh, not the auto attempt.
+ values: dict = {}
+ async for ev in run_analyzer(ctx, state.get("schema", {}), pass_id=None, kv_tracker=None, reasoning_on=False):
+ if ev.get("type") == "result":
+ values = ev["values"]
+ merged = {**state, "values": {**state.get("values", {}), **values}}
+ await set_workflow_state(ctx.conversation_id, WORKFLOW_ID, merged)
+ return _state_payload(merged)
+
+ if action == "reset":
+ new = seed()
+ await set_workflow_state(ctx.conversation_id, WORKFLOW_ID, new)
+ return _state_payload(new)
+
+ return {"error": "unknown action"}
diff --git a/backend/workflows/prose_format_llm/loop.py b/backend/workflows/prose_format_llm/loop.py
new file mode 100644
index 00000000..2979cb22
--- /dev/null
+++ b/backend/workflows/prose_format_llm/loop.py
@@ -0,0 +1,220 @@
+"""The judge/enforce loop and the three forced-tool paths.
+
+``run_enforcement_loop`` is the orchestration (pure control flow over injected
+callables). ``make_judge_fn`` / ``make_enforce_fn`` / ``run_analyzer`` are the
+paths that actually call the model via ``forced_tool_call``, shaped per the
+selected prompt mode.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from ..toolkit import forced_tool_call
+from . import TOOL_ANALYZE, TOOL_PATCH, TOOL_REPORT, WORKFLOW_ID
+from .prompts import (
+ ANALYZER_PREAMBLE,
+ ENFORCER_PREAMBLE,
+ JUDGE_PREAMBLE,
+ analyze_instruction,
+ enforce_instruction,
+ judge_instruction,
+ render_spec_block,
+)
+from .violations import clean_analyzer_records, validate_violations
+
+logger = logging.getLogger(__name__)
+
+# Judge classifies, so run it deterministic -- a stable count is what lets the
+# no-progress guard mean something. The enforcer rewrites a span and gets a
+# little headroom.
+_JUDGE_TEMPERATURE = 0.0
+_ENFORCE_TEMPERATURE = 0.2
+_ANALYZE_TEMPERATURE = 0.0
+
+
+def _rail(step: str, i: int, payload: list) -> dict:
+ """A one-line rail summary, emitted regardless of the ``reasoning`` config.
+
+ Shaped as an ``event`` (not a ``type``) so the bridge forwards it to SSE
+ rather than dropping it as an unknown control event. Each delta ends with a
+ newline because the frontend rail concatenates deltas without separators.
+ """
+ pass_id = f"{WORKFLOW_ID}:enforce" if step.startswith("enforce") else f"{WORKFLOW_ID}:judge"
+ if step == "judge":
+ cats = ", ".join(sorted({v["category"] for v in payload}))
+ delta = f"judge iter {i}: {len(payload)} violations" + (f" [{cats}]" if cats else "") + "\n"
+ elif step == "enforce":
+ delta = f"enforce iter {i}: {len(payload)} patches\n"
+ else: # enforce_errors
+ delta = f"enforce iter {i}: {len(payload)} patch(es) skipped\n"
+ return {"event": "reasoning", "data": {"pass": pass_id, "delta": delta}}
+
+
+async def run_enforcement_loop(draft, n, judge_fn, enforce_fn, apply_fn, is_aborted):
+ """Judge -> (enforce -> re-judge)* up to *n* times, yielding rail/reasoning
+ events as it goes and finishing with ``{"type":"loop_done","draft":...}``.
+
+ An async generator, not a coroutine: a workflow hook's only channel to SSE
+ is what it yields, so the final draft rides a terminal sentinel rather than a
+ return value. Stops on a clean judge, no patches, no progress (the count
+ failed to drop -- guards against an enforcer that thrashes), abort, or the
+ cap. ``n == 0`` judges once for diagnosis and never edits.
+ """
+ violations: list = []
+ async for ev in judge_fn(draft):
+ if ev.get("type") == "result":
+ violations = ev["violations"]
+ else:
+ yield ev
+ yield _rail("judge", 0, violations)
+
+ if violations and n > 0:
+ prev = len(violations)
+ for i in range(n):
+ if is_aborted():
+ break
+ patches: list = []
+ async for ev in enforce_fn(draft, violations):
+ if ev.get("type") == "result":
+ patches = ev["patches"]
+ else:
+ yield ev
+ yield _rail("enforce", i + 1, patches)
+ if not patches:
+ break
+ draft, errs = apply_fn(draft, patches)
+ if errs:
+ for e in errs:
+ logger.info("prose_format_llm enforce iter %d: %s", i + 1, e)
+ yield _rail("enforce_errors", i + 1, errs)
+ violations = []
+ async for ev in judge_fn(draft):
+ if ev.get("type") == "result":
+ violations = ev["violations"]
+ else:
+ yield ev
+ yield _rail("judge", i + 1, violations)
+ if not violations or len(violations) >= prev:
+ break
+ prev = len(violations)
+
+ yield {"type": "loop_done", "draft": draft}
+
+
+def _forced(ctx, mode, prefix, tail, tool_name, pass_id, reasoning_on, temperature):
+ """Drive one forced tool call, wired for KV reuse per prompt mode.
+
+ ``extend`` reproduces the pipeline's warm prefix + tools blob; ``minimal``
+ sends its own small self-contained prefix and a single-tool array.
+ """
+ common = dict(
+ client=ctx.client,
+ prefix=prefix,
+ tail_messages=tail,
+ tool_name=tool_name,
+ settings=ctx.settings,
+ pass_id=pass_id,
+ kv_tracker=ctx.kv_tracker,
+ reasoning_on=reasoning_on,
+ temperature=temperature,
+ )
+ if mode == "extend":
+ return forced_tool_call(enabled_tools=ctx.enabled_tools, schema_overrides=ctx.schema_overrides, **common)
+ return forced_tool_call(enabled_tools=None, **common)
+
+
+def _spec_messages(ctx, mode, preamble, spec_block, instruction, draft):
+ """Build (prefix, tail) for a judge/enforce call.
+
+ ``minimal``: preamble + spec ride a fresh system prefix, the draft rides the
+ tail. ``extend``: the draft is replayed as an assistant turn over the
+ pipeline's prefix, and the preamble/spec/instruction ride the final user turn.
+ """
+ if mode == "extend":
+ tail = [
+ {"role": "user", "content": ctx.effective_msg},
+ {"role": "assistant", "content": draft},
+ {
+ "role": "user",
+ "content": f"{preamble}\n\nRecorded prose format:\n{spec_block}\n\n{instruction}\n\n(The draft is the assistant reply directly above.)",
+ },
+ ]
+ return ctx.prefix, tail
+ prefix = [{"role": "system", "content": f"{preamble}\n\nRecorded prose format:\n{spec_block}"}]
+ tail = [{"role": "user", "content": f"{instruction}\n\nDraft:\n{draft}"}]
+ return prefix, tail
+
+
+def make_judge_fn(ctx, spec, mode, reasoning_on):
+ """A factory returning a fresh judge async generator per call (one per loop
+ iteration). It re-yields the model's reasoning, then yields a terminal
+ ``{"type":"result","violations":[...]}`` of validated violations."""
+ spec_block = render_spec_block(spec)
+ filled_keys = list(spec.keys())
+ pass_id = f"{WORKFLOW_ID}:judge" if reasoning_on else None
+
+ async def judge_fn(draft):
+ prefix, tail = _spec_messages(ctx, mode, JUDGE_PREAMBLE, spec_block, judge_instruction(TOOL_REPORT), draft)
+ args: dict = {}
+ async for ev in _forced(ctx, mode, prefix, tail, TOOL_REPORT, pass_id, reasoning_on, _JUDGE_TEMPERATURE):
+ if ev.get("type") == "result":
+ args = ev["args"]
+ else:
+ yield ev
+ yield {"type": "result", "violations": validate_violations(args.get("violations"), draft, filled_keys)}
+
+ return judge_fn
+
+
+def make_enforce_fn(ctx, spec, mode, reasoning_on):
+ """A factory returning a fresh enforcer async generator per call. It re-yields
+ reasoning, then yields a terminal ``{"type":"result","patches":[...]}``."""
+ spec_block = render_spec_block(spec)
+ pass_id = f"{WORKFLOW_ID}:enforce" if reasoning_on else None
+
+ async def enforce_fn(draft, violations):
+ instruction = enforce_instruction(violations, TOOL_PATCH)
+ prefix, tail = _spec_messages(ctx, mode, ENFORCER_PREAMBLE, spec_block, instruction, draft)
+ args: dict = {}
+ async for ev in _forced(ctx, mode, prefix, tail, TOOL_PATCH, pass_id, reasoning_on, _ENFORCE_TEMPERATURE):
+ if ev.get("type") == "result":
+ args = ev["args"]
+ else:
+ yield ev
+ patches = args.get("patches")
+ yield {"type": "result", "patches": patches if isinstance(patches, list) else []}
+
+ return enforce_fn
+
+
+async def run_analyzer(ctx, schema, *, pass_id, kv_tracker, reasoning_on):
+ """Infer the convention from recent prose and yield a terminal
+ ``{"type":"result","values":{...}}``.
+
+ Self-contained (own system prefix, history-derived samples) so the PRE and
+ on-demand paths produce the same prompt -- ``OnDemandCtx`` exposes no pipeline
+ prefix or kv_tracker, and the analyzer runs at most once per conversation, so
+ pipeline cache reuse is moot.
+ """
+ prefix = [{"role": "system", "content": ANALYZER_PREAMBLE}]
+ tail = [{"role": "user", "content": analyze_instruction(schema, ctx.history, TOOL_ANALYZE)}]
+ args: dict = {}
+ async for ev in forced_tool_call(
+ client=ctx.client,
+ prefix=prefix,
+ tail_messages=tail,
+ tool_name=TOOL_ANALYZE,
+ settings=ctx.settings,
+ pass_id=pass_id,
+ enabled_tools=None,
+ kv_tracker=kv_tracker,
+ reasoning_on=reasoning_on,
+ temperature=_ANALYZE_TEMPERATURE,
+ ):
+ if ev.get("type") == "result":
+ args = ev["args"]
+ else:
+ yield ev
+ keys = list(schema.keys()) if isinstance(schema, dict) else []
+ yield {"type": "result", "values": clean_analyzer_records(args.get("records"), keys)}
diff --git a/backend/workflows/prose_format_llm/patching.py b/backend/workflows/prose_format_llm/patching.py
new file mode 100644
index 00000000..e51bea3c
--- /dev/null
+++ b/backend/workflows/prose_format_llm/patching.py
@@ -0,0 +1,46 @@
+"""Pure search/replace patch application.
+
+Deliberately a separate copy of the editor's ``apply_patches`` rather than a
+shared import: the editor's lives in ``backend/pipeline/``, a higher layer this
+workflow may not import. It also omits the editor's quote/asterisk
+normalization fallbacks on purpose -- this workflow exists to fix quote/asterisk
+markup, so normalizing during the match would hide the very drift it must catch.
+Keep both facts in mind before "DRY-ing" this away.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def apply_patches(draft: str, patches: Any) -> tuple[str, list[str]]:
+ """Apply each ``{search, replace}`` patch to *draft*, returning the new text
+ and a list of human-readable error strings for patches that were skipped.
+
+ A patch applies only on an exact, unique match; zero or multiple matches are
+ errors (the model must quote more context). Errors are never fatal -- the
+ surviving patches still apply and the caller surfaces the skips.
+ """
+ errors: list[str] = []
+ if not isinstance(patches, list):
+ return draft, errors
+ for i, p in enumerate(patches):
+ if not isinstance(p, dict):
+ errors.append(f"patch {i}: not an object")
+ continue
+ search = p.get("search")
+ replace = p.get("replace")
+ if not isinstance(search, str) or not isinstance(replace, str):
+ errors.append(f"patch {i}: search/replace must be strings")
+ continue
+ if not search or search == replace:
+ continue
+ count = draft.count(search)
+ if count == 0:
+ errors.append(f"patch {i}: search not found")
+ continue
+ if count > 1:
+ errors.append(f"patch {i}: search matched {count} times (not unique)")
+ continue
+ draft = draft.replace(search, replace, 1)
+ return draft, errors
diff --git a/backend/workflows/prose_format_llm/prompts.py b/backend/workflows/prose_format_llm/prompts.py
new file mode 100644
index 00000000..f3355985
--- /dev/null
+++ b/backend/workflows/prose_format_llm/prompts.py
@@ -0,0 +1,99 @@
+"""Prompt construction for the analyzer, judge, and enforcer.
+
+Pure string assembly: preambles, the rendered spec/schema/violation blocks, and
+the per-pass task instructions. The message-stack wiring (which block is a
+system message vs a trailing user message, per prompt mode) lives in ``loop.py``.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Mapping, Sequence
+
+# Recent assistant messages the analyzer reads to infer the convention. The
+# format is conventionally stable across a conversation, so a small window is
+# enough and keeps the one-shot analyzer prompt cheap.
+ANALYZER_SAMPLE_WINDOW = 8
+
+ANALYZER_PREAMBLE = (
+ "You are a prose-format analyzer for a roleplay chat. From a conversation's assistant prose, "
+ "you infer how each listed prose element is marked (its delimiters/convention) and record a "
+ "short description of it. Judge only from the text shown, not from the element's guidance."
+)
+
+JUDGE_PREAMBLE = (
+ "You are a prose-format judge for a roleplay chat. Given the recorded prose format and a draft "
+ "reply, you locate spans of the draft that break the recorded format. You only locate and "
+ "categorize -- you never rewrite, suggest fixes, or explain."
+)
+
+ENFORCER_PREAMBLE = (
+ "You are a prose-format enforcer for a roleplay chat. You apply the smallest edits that bring "
+ "flagged spans into the recorded format, changing only markup -- never the wording, meaning, or "
+ "content of the prose."
+)
+
+_ANALYZE_TASK = (
+ "For each element below, describe how it is denoted in the prose above. Skip any element you see "
+ "no evidence for. Call {tool} with one record per element you can characterize.\n\nElements:\n{schema}"
+)
+
+_JUDGE_TASK = (
+ "Find every span of the draft that breaks the recorded prose format. For each, give the exact "
+ "offending text (copied verbatim from the draft) and the single element name it violates. Do not "
+ "report compliant text. Call {tool}."
+)
+
+_ENFORCE_TASK = (
+ "Each flagged span below violates the recorded format. For each, emit a patch whose 'search' is "
+ "the span copied verbatim from the draft and whose 'replace' is that span rewritten to the "
+ "recorded format, with every word preserved. Call {tool}.\n\nFlagged spans:\n{violations}"
+)
+
+
+def render_spec_block(spec: Mapping[str, str]) -> str:
+ """The recorded format the judge/enforcer hold the draft to, one element per line."""
+ return "\n".join(f"- {k}: {v}" for k, v in spec.items())
+
+
+def render_schema_block(schema: Mapping[str, str]) -> str:
+ """The analyzer's element list: name plus the guidance description for each."""
+ return "\n".join(f"- {k}: {v}" for k, v in schema.items())
+
+
+def render_violations(violations: Sequence[Mapping[str, str]]) -> str:
+ """The judge's findings, formatted for the enforcer: one ``[category] excerpt`` per line."""
+ return "\n".join(f"- [{v['category']}] {v['excerpt']}" for v in violations)
+
+
+def recent_assistant_prose(history: Sequence[Any]) -> str:
+ """Up to ANALYZER_SAMPLE_WINDOW recent assistant messages, oldest-first, joined.
+
+ Assistant history is always plain text; a non-string body (the multimodal
+ list form rides only user messages) carries no prose to sample.
+ """
+ window: list[str] = []
+ for msg in reversed(history):
+ if not isinstance(msg, Mapping) or msg.get("role") != "assistant":
+ continue
+ content = msg.get("content")
+ if isinstance(content, str) and content.strip():
+ window.append(content)
+ if len(window) >= ANALYZER_SAMPLE_WINDOW:
+ break
+ window.reverse()
+ return "\n\n".join(window)
+
+
+def analyze_instruction(schema: Mapping[str, str], history: Sequence[Any], tool: str) -> str:
+ """The analyzer's trailing user message: prose samples plus the element list."""
+ samples = recent_assistant_prose(history) or "(no prior assistant prose)"
+ task = _ANALYZE_TASK.format(tool=tool, schema=render_schema_block(schema))
+ return f"Recent assistant prose:\n{samples}\n\n{task}"
+
+
+def judge_instruction(tool: str) -> str:
+ return _JUDGE_TASK.format(tool=tool)
+
+
+def enforce_instruction(violations: Sequence[Mapping[str, str]], tool: str) -> str:
+ return _ENFORCE_TASK.format(tool=tool, violations=render_violations(violations))
diff --git a/backend/workflows/prose_format_llm/statedoc.py b/backend/workflows/prose_format_llm/statedoc.py
new file mode 100644
index 00000000..d48f4d2b
--- /dev/null
+++ b/backend/workflows/prose_format_llm/statedoc.py
@@ -0,0 +1,36 @@
+"""Pure helpers over the per-conversation state document.
+
+State shape: ``{"schema": {elem: description}, "values": {elem: denotation},
+"auto_analyzed": bool}``. ``schema`` guides the analyzer; ``values`` is the only
+thing the judge and enforcer read; ``auto_analyzed`` records that the one
+automatic analysis attempt has fired.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+from . import DEFAULT_SCHEMA
+
+
+def seed() -> dict:
+ """A fresh, unarmed state: schema defaults, no recorded values, auto attempt pending."""
+ return {"schema": dict(DEFAULT_SCHEMA), "values": {}, "auto_analyzed": False}
+
+
+def filled_elements(state: Mapping[str, Any] | None) -> dict[str, str]:
+ """The elements the judge/enforcer act on: those with a non-empty string value.
+
+ The ``isinstance`` guard is defensive -- the analyzer and ``save`` only ever
+ write strings, but a malformed LLM reply or a hand-edited slot must not make
+ this (or any caller, e.g. the menu's ``get``) raise.
+ """
+ values = state.get("values") if isinstance(state, Mapping) else None
+ if not isinstance(values, Mapping):
+ return {}
+ return {k: v for k, v in values.items() if isinstance(v, str) and v.strip()}
+
+
+def is_armed(state: Mapping[str, Any] | None) -> bool:
+ """True once at least one element has a recorded value; the loop runs only when armed."""
+ return bool(filled_elements(state))
diff --git a/backend/workflows/prose_format_llm/violations.py b/backend/workflows/prose_format_llm/violations.py
new file mode 100644
index 00000000..95ae3375
--- /dev/null
+++ b/backend/workflows/prose_format_llm/violations.py
@@ -0,0 +1,70 @@
+"""Deterministic cleaning of the judge's and analyzer's raw tool output.
+
+Both functions take whatever the model emitted (already coerced to a Python
+value by the tool-call parser, but otherwise untrusted) and return a clean,
+typed result. They are the graceful-degradation boundary for these two tools: a
+malformed reply yields fewer/empty results, never an exception.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Iterable
+
+
+def validate_violations(raw: Any, draft: str, filled_keys: Iterable[str]) -> list[dict]:
+ """Keep only violations the enforcer can actually act on.
+
+ A violation survives iff its ``excerpt`` is a non-empty string occurring
+ verbatim in *draft* (so a patch can locate it) and its ``category`` is a
+ filled element name (so it maps to a recorded rule). Duplicate
+ ``(excerpt, category)`` pairs collapse to one. The verbatim check doubles as
+ a hallucination filter, which is what makes the surviving count trustworthy
+ enough to drive the loop's no-progress guard.
+ """
+ if not isinstance(raw, list):
+ return []
+ keys = set(filled_keys)
+ seen: set[tuple[str, str]] = set()
+ out: list[dict] = []
+ for v in raw:
+ if not isinstance(v, dict):
+ continue
+ excerpt = v.get("excerpt")
+ category = v.get("category")
+ if not isinstance(excerpt, str) or not excerpt:
+ continue
+ if not isinstance(category, str) or category not in keys:
+ continue
+ if excerpt not in draft:
+ continue
+ key = (excerpt, category)
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append({"excerpt": excerpt, "category": category})
+ return out
+
+
+def clean_analyzer_records(raw: Any, schema_keys: Iterable[str]) -> dict[str, str]:
+ """Map the analyzer's records to ``{element: denotation}``, keeping only
+ schema elements with a non-empty string denotation.
+
+ The string guard is load-bearing: an un-guarded non-string denotation in
+ ``values`` would later crash ``filled_elements``/``is_armed`` (and thus 500
+ the menu's state read) the moment it is consulted.
+ """
+ if not isinstance(raw, list):
+ return {}
+ keys = set(schema_keys)
+ out: dict[str, str] = {}
+ for r in raw:
+ if not isinstance(r, dict):
+ continue
+ category = r.get("category")
+ denotation = r.get("denotation")
+ if not isinstance(category, str) or category not in keys:
+ continue
+ if not isinstance(denotation, str) or not denotation.strip():
+ continue
+ out[category] = denotation
+ return out
diff --git a/frontend/workflows/prose_format_llm/config_panel.js b/frontend/workflows/prose_format_llm/config_panel.js
new file mode 100644
index 00000000..7e4290f0
--- /dev/null
+++ b/frontend/workflows/prose_format_llm/config_panel.js
@@ -0,0 +1,227 @@
+// Settings modal for the prose_format_llm workflow. Global behavior config goes
+// through the /config route; the per-conversation prose-format spec goes through
+// the on-demand trigger RPC. Mirrors the TTS panel pattern.
+
+import { api } from "/static/api.js";
+import { S } from "/static/state.js";
+import { convUrl, esc } from "/static/utils.js";
+import { showModal } from "/static/modal.js";
+
+const WORKFLOW_ID = "prose_format_llm";
+
+// One array of {name, description, value} drives both spec sections, so renaming
+// an element never desyncs its recorded value. Rebuilt from the server on every
+// open / analyze / reset.
+let spec = [];
+
+export function initConfigPanel() {
+ window.pfOpenSettings = openSettings;
+ window.pfSaveGlobal = saveGlobal;
+ window.pfAddRow = addRow;
+ window.pfDelRow = delRow;
+ window.pfAnalyze = analyze;
+ window.pfSaveSpec = saveSpec;
+ window.pfReset = reset;
+}
+
+export function configCardRenderer() {
+ return `
Enforce a recorded prose format on replies via an LLM judge/enforce loop.
Enforce a recorded prose format on replies via an LLM judge/enforce loop.
- `;
+ return `
Hold replies to a recorded prose format with an LLM judge/enforce pass.
+ `;
}
function triggerUrl() {
@@ -40,19 +40,22 @@ async function openSettings() {
}
function modalShell() {
- return `
Prose Format (LLM)
-
-
Enforcement
-
-
-
-
+ return `
Prose Format
+
An LLM judge finds where a reply breaks this conversation's recorded format; an enforcer makes the minimal fixes. Dormant until a conversation is analyzed.
`;
}
diff --git a/frontend/workflows/prose_format_llm/prose_format.css b/frontend/workflows/prose_format_llm/prose_format.css
index 1cb336aa..02fc7329 100644
--- a/frontend/workflows/prose_format_llm/prose_format.css
+++ b/frontend/workflows/prose_format_llm/prose_format.css
@@ -1,45 +1,99 @@
+/* Prose-format workflow appearance. Loaded via a injected by index.js so
+ the workflow ships its own styling without editing the core stylesheet. Buttons,
+ inputs, fields, and the modal frame come from Orb's shared classes (.btn, .field,
+ .modal-*); only the per-conversation spec editor's two-column rows and section
+ dividers are defined here, all in theme variables so they track every theme. */
+
.pf-settings-btn {
margin-top: 6px;
}
-.pf-config,
-.pf-spec {
- margin-top: 10px;
+/* Section divider inside the settings modal -- mirrors the look of Orb's other
+ in-modal sub-headings (uppercase, muted, hairline rule). */
+.pf-section-title {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ color: var(--text-muted);
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 5px;
+ margin: 18px 0 10px;
+}
+
+/* Tighter than the shared .modal-checkbox-label default so the two behavior
+ toggles don't leave a large gap before the spec editor. */
+.pf-check {
+ margin-bottom: 8px;
}
-.pf-cfg-row {
+.pf-rows {
display: flex;
- align-items: center;
+ flex-direction: column;
gap: 8px;
- margin: 6px 0;
}
.pf-row {
display: flex;
align-items: flex-start;
gap: 8px;
- margin: 6px 0;
}
-.pf-row input[type="text"],
+/* Element-name column: the editable input and the read-only label share a width
+ so the description and value columns line up across the two sections. */
+.pf-name {
+ flex: 0 0 34%;
+ min-width: 0;
+}
+
.pf-elem {
- flex: 0 0 30%;
+ flex: 0 0 34%;
min-width: 0;
+ display: flex;
+ align-items: center;
+ padding: 7px 10px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.pf-row textarea {
+.pf-field {
flex: 1 1 auto;
min-width: 0;
+ min-height: 38px;
+}
+
+.pf-del {
+ flex: 0 0 auto;
+}
+
+.pf-add {
+ margin-top: 8px;
}
.pf-actions {
display: flex;
align-items: center;
gap: 8px;
- margin-top: 10px;
+ margin-top: 18px;
+ padding-top: 14px;
+ border-top: 1px solid var(--border);
+}
+
+/* Push the status text + Save to the right, leaving Analyze/Reset at the left. */
+.pf-status {
+ margin-left: auto;
+ font-size: 12px;
+ color: var(--text-muted);
}
.pf-note {
- opacity: 0.7;
+ padding: 8px 0;
+ font-size: 12px;
font-style: italic;
+ color: var(--text-muted);
}
From 0234068b16efc3dc676192c46072c0985bcd6da3 Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Tue, 23 Jun 2026 19:50:13 +0000
Subject: [PATCH 08/15] Make Prose Format Workflow Reason Properly
---
backend/workflows/prose_format_llm/__init__.py | 13 ++++++++++++-
backend/workflows/prose_format_llm/hooks.py | 18 ++++++++++++------
backend/workflows/prose_format_llm/loop.py | 17 ++++++++++-------
.../workflows/prose_format_llm/config_panel.js | 6 +++++-
4 files changed, 39 insertions(+), 15 deletions(-)
diff --git a/backend/workflows/prose_format_llm/__init__.py b/backend/workflows/prose_format_llm/__init__.py
index 81116758..5cf76185 100644
--- a/backend/workflows/prose_format_llm/__init__.py
+++ b/backend/workflows/prose_format_llm/__init__.py
@@ -107,7 +107,17 @@ def _array_tool(name: str, description: str, array_key: str, array_description:
# returns a non-empty config slot verbatim (no per-key merge with defaults), so a
# slot must never be written partially; the frontend always sends all keys, and
# this full default set covers the empty-slot path.
-_CONFIG_DEFAULTS = {"max_iterations": 1, "prompt_mode": "minimal", "auto_analyze": False, "reasoning": False}
+# ``reasoning`` = the model thinks before each tool call (applies to every agent,
+# every path); ``stream_reasoning`` = surface that thinking on the inspector rail
+# (only the in-turn paths have a stream to surface on). They are independent: a
+# model can reason without the reasoning being shown.
+_CONFIG_DEFAULTS = {
+ "max_iterations": 1,
+ "prompt_mode": "minimal",
+ "auto_analyze": False,
+ "reasoning": False,
+ "stream_reasoning": False,
+}
_CONFIG_SCHEMA = {
"type": "object",
@@ -116,6 +126,7 @@ def _array_tool(name: str, description: str, array_key: str, array_description:
"prompt_mode": {"type": "string", "enum": ["minimal", "extend"], "default": "minimal"},
"auto_analyze": {"type": "boolean", "default": False},
"reasoning": {"type": "boolean", "default": False},
+ "stream_reasoning": {"type": "boolean", "default": False},
},
}
diff --git a/backend/workflows/prose_format_llm/hooks.py b/backend/workflows/prose_format_llm/hooks.py
index a4478f2e..af0acc71 100644
--- a/backend/workflows/prose_format_llm/hooks.py
+++ b/backend/workflows/prose_format_llm/hooks.py
@@ -60,7 +60,7 @@ async def pre_pipeline(ctx):
return
reasoning_on = bool(cfg.get("reasoning", False))
- pass_id = f"{WORKFLOW_ID}:analyze" if reasoning_on else None
+ pass_id = f"{WORKFLOW_ID}:analyze" if cfg.get("stream_reasoning") else None
values: dict = {}
async for ev in run_analyzer(
ctx, state.get("schema", {}), pass_id=pass_id, kv_tracker=ctx.kv_tracker, reasoning_on=reasoning_on
@@ -88,10 +88,11 @@ async def post_pipeline(ctx):
n = _as_n(cfg.get("max_iterations", 1))
mode = cfg.get("prompt_mode") or "minimal"
reasoning_on = bool(cfg.get("reasoning", False))
+ stream = bool(cfg.get("stream_reasoning", False))
spec = filled_elements(state)
- judge_fn = make_judge_fn(ctx, spec, mode, reasoning_on)
- enforce_fn = make_enforce_fn(ctx, spec, mode, reasoning_on)
+ judge_fn = make_judge_fn(ctx, spec, mode, reasoning_on, stream)
+ enforce_fn = make_enforce_fn(ctx, spec, mode, reasoning_on, stream)
def is_aborted():
return ctx.client.is_aborted
@@ -127,10 +128,15 @@ async def on_demand(ctx, body):
return _state_payload(new)
if action == "analyze":
- # On-demand has no SSE stream, so no pass_id/kv_tracker. Leaves
- # auto_analyzed untouched -- this is the manual refresh, not the auto attempt.
+ # Reasoning (the "think" knob) applies to every agent, this one included.
+ # On-demand has no SSE stream, so the reasoning is never surfaced here
+ # regardless of stream_reasoning (pass_id stays None) -- the model still
+ # thinks when reasoning is on. auto_analyzed is left untouched: this is the
+ # manual refresh, not the one auto attempt.
+ cfg = await get_workflow_config(WORKFLOW_ID)
+ reasoning_on = bool(cfg.get("reasoning", False))
values: dict = {}
- async for ev in run_analyzer(ctx, state.get("schema", {}), pass_id=None, kv_tracker=None, reasoning_on=False):
+ async for ev in run_analyzer(ctx, state.get("schema", {}), pass_id=None, kv_tracker=None, reasoning_on=reasoning_on):
if ev.get("type") == "result":
values = ev["values"]
merged = {**state, "values": {**state.get("values", {}), **values}}
diff --git a/backend/workflows/prose_format_llm/loop.py b/backend/workflows/prose_format_llm/loop.py
index 2979cb22..d3427adc 100644
--- a/backend/workflows/prose_format_llm/loop.py
+++ b/backend/workflows/prose_format_llm/loop.py
@@ -146,13 +146,15 @@ def _spec_messages(ctx, mode, preamble, spec_block, instruction, draft):
return prefix, tail
-def make_judge_fn(ctx, spec, mode, reasoning_on):
+def make_judge_fn(ctx, spec, mode, reasoning_on, stream):
"""A factory returning a fresh judge async generator per call (one per loop
- iteration). It re-yields the model's reasoning, then yields a terminal
+ iteration). ``reasoning_on`` makes the model think; ``stream`` independently
+ decides whether that thinking is surfaced to the rail (via ``pass_id``). Yields
+ the model's reasoning when streamed, then a terminal
``{"type":"result","violations":[...]}`` of validated violations."""
spec_block = render_spec_block(spec)
filled_keys = list(spec.keys())
- pass_id = f"{WORKFLOW_ID}:judge" if reasoning_on else None
+ pass_id = f"{WORKFLOW_ID}:judge" if stream else None
async def judge_fn(draft):
prefix, tail = _spec_messages(ctx, mode, JUDGE_PREAMBLE, spec_block, judge_instruction(TOOL_REPORT), draft)
@@ -167,11 +169,12 @@ async def judge_fn(draft):
return judge_fn
-def make_enforce_fn(ctx, spec, mode, reasoning_on):
- """A factory returning a fresh enforcer async generator per call. It re-yields
- reasoning, then yields a terminal ``{"type":"result","patches":[...]}``."""
+def make_enforce_fn(ctx, spec, mode, reasoning_on, stream):
+ """A factory returning a fresh enforcer async generator per call.
+ ``reasoning_on`` makes the model think; ``stream`` gates surfacing (``pass_id``).
+ Yields reasoning when streamed, then a terminal ``{"type":"result","patches":[...]}``."""
spec_block = render_spec_block(spec)
- pass_id = f"{WORKFLOW_ID}:enforce" if reasoning_on else None
+ pass_id = f"{WORKFLOW_ID}:enforce" if stream else None
async def enforce_fn(draft, violations):
instruction = enforce_instruction(violations, TOOL_PATCH)
diff --git a/frontend/workflows/prose_format_llm/config_panel.js b/frontend/workflows/prose_format_llm/config_panel.js
index 7cc90c0c..26373ba5 100644
--- a/frontend/workflows/prose_format_llm/config_panel.js
+++ b/frontend/workflows/prose_format_llm/config_panel.js
@@ -55,7 +55,8 @@ function modalShell() {
-
+
+
Loading...
`;
}
@@ -72,10 +73,12 @@ async function loadGlobal() {
const mode = document.getElementById("pf-cfg-mode");
const auto = document.getElementById("pf-cfg-auto");
const reasoning = document.getElementById("pf-cfg-reasoning");
+ const stream = document.getElementById("pf-cfg-stream");
if (iters) iters.value = Number.isInteger(cfg.max_iterations) ? cfg.max_iterations : 1;
if (mode) mode.value = cfg.prompt_mode === "extend" ? "extend" : "minimal";
if (auto) auto.checked = !!cfg.auto_analyze;
if (reasoning) reasoning.checked = !!cfg.reasoning;
+ if (stream) stream.checked = !!cfg.stream_reasoning;
}
// The config slot is replaced wholesale on write, so every key must be sent or an
@@ -87,6 +90,7 @@ async function saveGlobal() {
prompt_mode: document.getElementById("pf-cfg-mode")?.value === "extend" ? "extend" : "minimal",
auto_analyze: !!document.getElementById("pf-cfg-auto")?.checked,
reasoning: !!document.getElementById("pf-cfg-reasoning")?.checked,
+ stream_reasoning: !!document.getElementById("pf-cfg-stream")?.checked,
};
try {
await api.put("/workflows/" + WORKFLOW_ID + "/config", { config });
From fcea3b6131f8dffaad5a4a049ea38487e7c4adf0 Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Tue, 23 Jun 2026 20:14:14 +0000
Subject: [PATCH 09/15] Add perspective of narration and tense to narration
format
---
.../workflows/prose_format_llm/__init__.py | 32 ++++++++-------
.../workflows/prose_format_llm/patching.py | 5 ++-
backend/workflows/prose_format_llm/prompts.py | 39 +++++++++++--------
3 files changed, 44 insertions(+), 32 deletions(-)
diff --git a/backend/workflows/prose_format_llm/__init__.py b/backend/workflows/prose_format_llm/__init__.py
index 5cf76185..af7c2eb2 100644
--- a/backend/workflows/prose_format_llm/__init__.py
+++ b/backend/workflows/prose_format_llm/__init__.py
@@ -1,9 +1,9 @@
"""LLM prose-format workflow.
-Enforces a conversation's prose-markup convention (how narration, speech, etc.
-are delimited) on the writer's finished draft, using three forced-tool LLM
-paths: an analyzer that records the convention, a judge that locates
-violations, and an enforcer that patches them. It does the same job as the
+Enforces a conversation's prose convention -- each element's markup, plus
+narration's tense and narrative person -- on the writer's finished draft, using
+three forced-tool LLM paths: an analyzer that records the convention, a judge
+that locates violations, and an enforcer that patches them. It does the same job as the
deterministic ``format_consistency`` workflow but covers the open-ended
violation space regex cannot; deploy it as a replacement by toggling
``format_consistency`` off.
@@ -31,7 +31,11 @@
# convention it observes; until it does, the conversation is unarmed and the
# loop stays dormant.
DEFAULT_SCHEMA = {
- "narration": "How narration is denoted (e.g. text wrapped in asterisks).",
+ "narration": (
+ "How narration is written: (1) markup/delimiters (e.g. wrapped in asterisks), "
+ "(2) tense (past or present), and (3) narrative person (first/second/third). "
+ "Tense and person are narration-only -- they never apply to dialogue."
+ ),
"speech": "How spoken dialogue is denoted (e.g. text wrapped in double quotes).",
"internal_monologue": "How a character's unspoken thought is denoted.",
"quotation": "How quoted or cited text inside speech or narration is denoted.",
@@ -77,29 +81,29 @@ def _array_tool(name: str, description: str, array_key: str, array_description:
"One record per element you can characterize from the prose; omit elements with no evidence.",
{
"category": "The element name, exactly as listed in the request.",
- "denotation": "A short description of how that element is marked in this conversation's prose.",
+ "denotation": "How that element is written here: markup, and for narration also its tense and narrative person.",
},
)
REPORT_TOOL = _array_tool(
TOOL_REPORT,
- "Report spans of the draft that violate the recorded prose format.",
+ "Report whole fragments of the draft that violate the recorded prose format.",
"violations",
- "One entry per offending span; report nothing for a clean draft.",
+ "One entry per offending fragment -- the whole fragment, even with several issues; nothing for a clean draft.",
{
- "excerpt": "The offending text, copied verbatim from the draft.",
- "category": "The single element name the span violates, exactly as listed.",
+ "excerpt": "The whole offending fragment, copied verbatim from the draft.",
+ "category": "The element name the fragment belongs to, exactly as listed.",
},
)
PATCH_TOOL = _array_tool(
TOOL_PATCH,
- "Apply minimal search/replace edits that bring flagged spans into the recorded format.",
+ "Apply one search/replace edit per flagged fragment to bring it into the recorded format.",
"patches",
- "One patch per flagged span.",
+ "One patch per flagged fragment.",
{
- "search": "The exact text to replace, copied verbatim from the draft.",
- "replace": "That same text rewritten to the recorded format, wording unchanged.",
+ "search": "The whole fragment to replace, copied verbatim from the draft.",
+ "replace": "That fragment rewritten to the recorded format (markup, and for narration tense/person); events unchanged.",
},
)
diff --git a/backend/workflows/prose_format_llm/patching.py b/backend/workflows/prose_format_llm/patching.py
index e51bea3c..5a15e9dc 100644
--- a/backend/workflows/prose_format_llm/patching.py
+++ b/backend/workflows/prose_format_llm/patching.py
@@ -3,8 +3,9 @@
Deliberately a separate copy of the editor's ``apply_patches`` rather than a
shared import: the editor's lives in ``backend/pipeline/``, a higher layer this
workflow may not import. It also omits the editor's quote/asterisk
-normalization fallbacks on purpose -- this workflow exists to fix quote/asterisk
-markup, so normalizing during the match would hide the very drift it must catch.
+normalization fallbacks on purpose -- among other things this workflow fixes
+quote/asterisk markup, so normalizing during the match would hide the very
+markup drift it must catch.
Keep both facts in mind before "DRY-ing" this away.
"""
diff --git a/backend/workflows/prose_format_llm/prompts.py b/backend/workflows/prose_format_llm/prompts.py
index f3355985..61d937a0 100644
--- a/backend/workflows/prose_format_llm/prompts.py
+++ b/backend/workflows/prose_format_llm/prompts.py
@@ -15,38 +15,45 @@
ANALYZER_SAMPLE_WINDOW = 8
ANALYZER_PREAMBLE = (
- "You are a prose-format analyzer for a roleplay chat. From a conversation's assistant prose, "
- "you infer how each listed prose element is marked (its delimiters/convention) and record a "
- "short description of it. Judge only from the text shown, not from the element's guidance."
+ "You are a prose-format analyzer for a roleplay chat. From the conversation's assistant prose, "
+ "you record how each listed element is written, following each element's own guidance -- its "
+ "markup/delimiters, and for narration also its tense (past/present) and narrative person "
+ "(first/second/third). Record only what the prose actually shows."
)
JUDGE_PREAMBLE = (
"You are a prose-format judge for a roleplay chat. Given the recorded prose format and a draft "
- "reply, you locate spans of the draft that break the recorded format. You only locate and "
- "categorize -- you never rewrite, suggest fixes, or explain."
+ "reply, you locate whole fragments of the draft that break the recorded format. You only locate "
+ "and categorize -- you never rewrite, suggest fixes, or explain."
)
ENFORCER_PREAMBLE = (
- "You are a prose-format enforcer for a roleplay chat. You apply the smallest edits that bring "
- "flagged spans into the recorded format, changing only markup -- never the wording, meaning, or "
- "content of the prose."
+ "You are a prose-format enforcer for a roleplay chat. You rewrite each flagged fragment to match "
+ "its recorded format, correcting every deviation at once while keeping the story's events unchanged."
)
_ANALYZE_TASK = (
- "For each element below, describe how it is denoted in the prose above. Skip any element you see "
- "no evidence for. Call {tool} with one record per element you can characterize.\n\nElements:\n{schema}"
+ "For each element below, record how it is written in the prose above, following the element's "
+ "guidance. Skip any element you see no evidence for. Call {tool} with one record per element you "
+ "can characterize.\n\nElements:\n{schema}"
)
_JUDGE_TASK = (
- "Find every span of the draft that breaks the recorded prose format. For each, give the exact "
- "offending text (copied verbatim from the draft) and the single element name it violates. Do not "
- "report compliant text. Call {tool}."
+ "Find every fragment of the draft that breaks its recorded format. A fragment is one contiguous "
+ "block of a single element (a whole narration passage, a line of dialogue). Report each offending "
+ "fragment ONCE -- the entire fragment as the excerpt, its element name as the category -- even when "
+ "it breaks the format several ways at once (for narration: markup, tense, and narrative person "
+ "together). Never split one fragment into separate entries per issue. Tense and narrative person "
+ "are narration-only; never flag them in dialogue. Do not report compliant fragments. Call {tool}."
)
_ENFORCE_TASK = (
- "Each flagged span below violates the recorded format. For each, emit a patch whose 'search' is "
- "the span copied verbatim from the draft and whose 'replace' is that span rewritten to the "
- "recorded format, with every word preserved. Call {tool}.\n\nFlagged spans:\n{violations}"
+ "Each flagged fragment below breaks its recorded format. For each, emit one patch: 'search' is the "
+ "fragment copied verbatim from the draft; 'replace' is that fragment rewritten to its recorded "
+ "format -- fixing markup and, for narration, its tense and narrative person, all together. Changing "
+ "verb tense and pronouns/person is expected; keep every event, detail, and line of dialogue, and "
+ "leave dialogue's own tense and person untouched. Add nothing, drop nothing. Call {tool}."
+ "\n\nFlagged fragments:\n{violations}"
)
From fe51e46c9aa3bdb9828a6707d9cd84a23a0257be Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Wed, 24 Jun 2026 15:58:08 +0000
Subject: [PATCH 10/15] Close OOC aside in direction-note prompt builder
build_direction_note_prompt opens an [OOC: aside (DIRECTION_NOTE_PREAMBLE) but
never closed it, unlike the other pass builders. Adopt the same close-with-]
convention ahead of merging main's cf6cad71, which adds it to the four
pre-existing OOC builders, so the merged tree stays consistent.
---
backend/inference/prompt_builder.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/backend/inference/prompt_builder.py b/backend/inference/prompt_builder.py
index 3fbf4845..6c6251d5 100644
--- a/backend/inference/prompt_builder.py
+++ b/backend/inference/prompt_builder.py
@@ -396,7 +396,8 @@ def build_direction_note_prompt(
if tool_schema is not None:
labels = {df["id"]: (df.get("injection_label") or df.get("label") or "").strip() for df in direction_note_fragments}
parts.append(_tool_call_instruction("record_direction_note", tool_schema, labels=labels))
- return "\n\n".join(parts)
+ # Close the [OOC: aside opened in DIRECTION_NOTE_PREAMBLE; the whole instruction is the aside.
+ return "\n\n".join(parts) + "]"
def build_editor_prompt(
From dae35331ef7eae8c8c0d6c7007bb25c8b6d5e822 Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Fri, 31 Jul 2026 00:39:52 +0000
Subject: [PATCH 11/15] Match main's tool-registry assertions verbatim
Both sides independently rewrote the same two assertions in
TestBuiltinToolNames and TestStandaloneToolsBaseline once a workflow
started contributing standalone tools, reaching the same set equation by
different spellings. Adopting main's text makes the hunks identical, so
the upcoming merge sees no conflict here at all.
---
tests/unit/test_tool_registry.py | 15 ++++-----------
1 file changed, 4 insertions(+), 11 deletions(-)
diff --git a/tests/unit/test_tool_registry.py b/tests/unit/test_tool_registry.py
index b6093be7..97251be8 100644
--- a/tests/unit/test_tool_registry.py
+++ b/tests/unit/test_tool_registry.py
@@ -41,23 +41,16 @@ def _restore_registry():
class TestBuiltinToolNames:
- def test_builtins_are_the_non_standalone_tools(self):
- # Built-ins are exactly the non-standalone tools. A workflow that
- # contributes tools registers them standalone (e.g. prose_format_llm), so
- # they land in STANDALONE_TOOLS instead of widening the built-in set. The
- # exact built-in <-> TOOLS-literal match is asserted at module load in
- # tool_registry.py itself, before any workflow registers.
- assert frozenset(TOOLS) - STANDALONE_TOOLS == BUILTIN_TOOL_NAMES
+ def test_matches_tools_keys_at_module_load(self):
+ assert BUILTIN_TOOL_NAMES == frozenset(TOOLS) - STANDALONE_TOOLS
def test_is_a_frozenset(self):
assert isinstance(BUILTIN_TOOL_NAMES, frozenset)
class TestStandaloneToolsBaseline:
- def test_no_builtin_is_standalone(self):
- # STANDALONE_TOOLS holds only workflow-contributed tools; no built-in is
- # ever standalone (register_workflow rejects built-in tool names).
- assert STANDALONE_TOOLS.isdisjoint(BUILTIN_TOOL_NAMES)
+ def test_empty_at_module_load(self):
+ assert BUILTIN_TOOL_NAMES.isdisjoint(STANDALONE_TOOLS)
class TestPipelinePhaseSets:
From fca700391b5c6703c63255d09a6692ea388232dc Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Fri, 31 Jul 2026 00:41:22 +0000
Subject: [PATCH 12/15] Port prose_format_llm frontend to the workflow_api ABI
Main replaced the ad-hoc plugin surface with a single facade,
frontend/workflow_api.js, and added scripts/check_frontend_layers.py to
lint.sh to enforce it. A module under frontend/workflows/ may now import
only that facade plus its own relative files, so the deep imports of
state.js, api.js, utils.js and modal.js all become hard errors.
The same check ratchets the number of inline on*= attributes across the
frontend and refuses to let it grow. Twelve of them here move to the
facade's delegated dispatcher: markup carries data-wf-action, and
initConfigPanel registers the handlers through registerAction instead of
planting window.pf* globals.
The global state object S is deliberately absent from the facade, so the
two reads of S.activeConvId go through its getActiveConvId accessor.
---
.../prose_format_llm/config_panel.js | 48 +++++++++----------
frontend/workflows/prose_format_llm/index.js | 2 +-
2 files changed, 24 insertions(+), 26 deletions(-)
diff --git a/frontend/workflows/prose_format_llm/config_panel.js b/frontend/workflows/prose_format_llm/config_panel.js
index 26373ba5..d0c73257 100644
--- a/frontend/workflows/prose_format_llm/config_panel.js
+++ b/frontend/workflows/prose_format_llm/config_panel.js
@@ -2,10 +2,7 @@
// through the /config route; the per-conversation prose-format spec goes through
// the on-demand trigger RPC. Mirrors the TTS panel pattern.
-import { api } from "/static/api.js";
-import { S } from "/static/state.js";
-import { convUrl, esc } from "/static/utils.js";
-import { showModal } from "/static/modal.js";
+import { api, closeModal, convUrl, esc, getActiveConvId, registerAction, showModal } from "/static/workflow_api.js";
const WORKFLOW_ID = "prose_format_llm";
@@ -15,22 +12,23 @@ const WORKFLOW_ID = "prose_format_llm";
let spec = [];
export function initConfigPanel() {
- window.pfOpenSettings = openSettings;
- window.pfSaveGlobal = saveGlobal;
- window.pfAddRow = addRow;
- window.pfDelRow = delRow;
- window.pfAnalyze = analyze;
- window.pfSaveSpec = saveSpec;
- window.pfReset = reset;
+ registerAction(WORKFLOW_ID, "settings", () => openSettings());
+ registerAction(WORKFLOW_ID, "close", () => closeModal());
+ registerAction(WORKFLOW_ID, "saveGlobal", () => saveGlobal());
+ registerAction(WORKFLOW_ID, "addRow", () => addRow());
+ registerAction(WORKFLOW_ID, "delRow", (el) => delRow(Number(el.dataset.i)));
+ registerAction(WORKFLOW_ID, "analyze", () => analyze());
+ registerAction(WORKFLOW_ID, "saveSpec", () => saveSpec());
+ registerAction(WORKFLOW_ID, "reset", () => reset());
}
export function configCardRenderer() {
return `
Hold replies to a recorded prose format with an LLM judge/enforce pass.
`;
}
diff --git a/frontend/workflows/prose_format_llm/index.js b/frontend/workflows/prose_format_llm/index.js
index d4cac1b1..6e7b9bf7 100644
--- a/frontend/workflows/prose_format_llm/index.js
+++ b/frontend/workflows/prose_format_llm/index.js
@@ -3,7 +3,7 @@
// rail; this module supplies the card body, the rail's pass list, and the
// settings modal wiring.
-import { registerWorkflowPipeline, registerWorkflowToolsPanelCard } from "/static/state.js";
+import { registerWorkflowPipeline, registerWorkflowToolsPanelCard } from "/static/workflow_api.js";
import { configCardRenderer, initConfigPanel } from "./config_panel.js";
const WORKFLOW_ID = "prose_format_llm";
From 06ef7ef73e48f27a1f6a1881e3cd3d90803a3e8d Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Fri, 31 Jul 2026 00:42:17 +0000
Subject: [PATCH 13/15] Run the on-demand analyzer on the agent lane
Main split off-turn LLM work into a writer lane and an agent lane, gave
OnDemandCtx and RegenCtx an agent_client plus agent_model_name, and added
a model_name argument to forced_tool_call. image_gen's off-turn hooks
already route their tool calls that way; the manual Analyze button now
does the same.
run_analyzer takes the client and model as arguments rather than reading
ctx.client, because only the off-turn contexts resolve a second lane.
PreCtx does not, so the automatic pre-pipeline attempt keeps passing its
single client -- see the note at the on-demand call site for what that
asymmetry costs.
---
backend/workflows/prose_format_llm/hooks.py | 24 +++++++++++++++++++--
backend/workflows/prose_format_llm/loop.py | 10 +++++++--
2 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/backend/workflows/prose_format_llm/hooks.py b/backend/workflows/prose_format_llm/hooks.py
index af0acc71..75399ce6 100644
--- a/backend/workflows/prose_format_llm/hooks.py
+++ b/backend/workflows/prose_format_llm/hooks.py
@@ -63,7 +63,12 @@ async def pre_pipeline(ctx):
pass_id = f"{WORKFLOW_ID}:analyze" if cfg.get("stream_reasoning") else None
values: dict = {}
async for ev in run_analyzer(
- ctx, state.get("schema", {}), pass_id=pass_id, kv_tracker=ctx.kv_tracker, reasoning_on=reasoning_on
+ ctx,
+ state.get("schema", {}),
+ pass_id=pass_id,
+ kv_tracker=ctx.kv_tracker,
+ reasoning_on=reasoning_on,
+ client=ctx.client,
):
if ev.get("type") == "result":
values = ev["values"]
@@ -133,10 +138,25 @@ async def on_demand(ctx, body):
# regardless of stream_reasoning (pass_id stays None) -- the model still
# thinks when reasoning is on. auto_analyzed is left untouched: this is the
# manual refresh, not the one auto attempt.
+ #
+ # Off-turn tool-calling work runs on the agent lane, matching the other
+ # off-turn workflow hooks. In a dual-model setup that puts this analyzer on
+ # a different model than the pre-pipeline one, which reads the same prose
+ # and writes the same slot: PreCtx carries no agent lane, so the automatic
+ # attempt cannot follow. The two can therefore record different conventions
+ # for one conversation.
cfg = await get_workflow_config(WORKFLOW_ID)
reasoning_on = bool(cfg.get("reasoning", False))
values: dict = {}
- async for ev in run_analyzer(ctx, state.get("schema", {}), pass_id=None, kv_tracker=None, reasoning_on=reasoning_on):
+ async for ev in run_analyzer(
+ ctx,
+ state.get("schema", {}),
+ pass_id=None,
+ kv_tracker=None,
+ reasoning_on=reasoning_on,
+ client=ctx.agent_client,
+ model_name=ctx.agent_model_name,
+ ):
if ev.get("type") == "result":
values = ev["values"]
merged = {**state, "values": {**state.get("values", {}), **values}}
diff --git a/backend/workflows/prose_format_llm/loop.py b/backend/workflows/prose_format_llm/loop.py
index d3427adc..794f86a0 100644
--- a/backend/workflows/prose_format_llm/loop.py
+++ b/backend/workflows/prose_format_llm/loop.py
@@ -191,7 +191,7 @@ async def enforce_fn(draft, violations):
return enforce_fn
-async def run_analyzer(ctx, schema, *, pass_id, kv_tracker, reasoning_on):
+async def run_analyzer(ctx, schema, *, pass_id, kv_tracker, reasoning_on, client, model_name=None):
"""Infer the convention from recent prose and yield a terminal
``{"type":"result","values":{...}}``.
@@ -199,12 +199,17 @@ async def run_analyzer(ctx, schema, *, pass_id, kv_tracker, reasoning_on):
on-demand paths produce the same prompt -- ``OnDemandCtx`` exposes no pipeline
prefix or kv_tracker, and the analyzer runs at most once per conversation, so
pipeline cache reuse is moot.
+
+ The lane is the caller's to choose rather than read off ``ctx``: only the
+ off-turn contexts resolve an agent lane, so the in-turn caller has no choice
+ to make and passes its single client with ``model_name=None`` (which falls
+ back to ``settings["model_name"]``).
"""
prefix = [{"role": "system", "content": ANALYZER_PREAMBLE}]
tail = [{"role": "user", "content": analyze_instruction(schema, ctx.history, TOOL_ANALYZE)}]
args: dict = {}
async for ev in forced_tool_call(
- client=ctx.client,
+ client=client,
prefix=prefix,
tail_messages=tail,
tool_name=TOOL_ANALYZE,
@@ -212,6 +217,7 @@ async def run_analyzer(ctx, schema, *, pass_id, kv_tracker, reasoning_on):
pass_id=pass_id,
enabled_tools=None,
kv_tracker=kv_tracker,
+ model_name=model_name,
reasoning_on=reasoning_on,
temperature=_ANALYZE_TEMPERATURE,
):
From 11287b782a1493a1ef93bd1ae4ca7b7e8d54845b Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Fri, 31 Jul 2026 00:47:23 +0000
Subject: [PATCH 14/15] Move prose_format_llm typing imports to collections.abc
Main raised the Python floor to 3.11 and added ruff's UP rules, which
reject the deprecated typing aliases for Mapping, Sequence and Iterable.
These three modules were written before that and fail ruff as merged.
---
backend/workflows/prose_format_llm/prompts.py | 3 ++-
backend/workflows/prose_format_llm/statedoc.py | 3 ++-
backend/workflows/prose_format_llm/violations.py | 3 ++-
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/backend/workflows/prose_format_llm/prompts.py b/backend/workflows/prose_format_llm/prompts.py
index 61d937a0..5249bc8a 100644
--- a/backend/workflows/prose_format_llm/prompts.py
+++ b/backend/workflows/prose_format_llm/prompts.py
@@ -7,7 +7,8 @@
from __future__ import annotations
-from typing import Any, Mapping, Sequence
+from collections.abc import Mapping, Sequence
+from typing import Any
# Recent assistant messages the analyzer reads to infer the convention. The
# format is conventionally stable across a conversation, so a small window is
diff --git a/backend/workflows/prose_format_llm/statedoc.py b/backend/workflows/prose_format_llm/statedoc.py
index d48f4d2b..f58b4e84 100644
--- a/backend/workflows/prose_format_llm/statedoc.py
+++ b/backend/workflows/prose_format_llm/statedoc.py
@@ -8,7 +8,8 @@
from __future__ import annotations
-from typing import Any, Mapping
+from collections.abc import Mapping
+from typing import Any
from . import DEFAULT_SCHEMA
diff --git a/backend/workflows/prose_format_llm/violations.py b/backend/workflows/prose_format_llm/violations.py
index 95ae3375..52b2fbdc 100644
--- a/backend/workflows/prose_format_llm/violations.py
+++ b/backend/workflows/prose_format_llm/violations.py
@@ -8,7 +8,8 @@
from __future__ import annotations
-from typing import Any, Iterable
+from collections.abc import Iterable
+from typing import Any
def validate_violations(raw: Any, draft: str, filled_keys: Iterable[str]) -> list[dict]:
From c95d705c7c46bbfcda45201e01a53b89fc8b8fd3 Mon Sep 17 00:00:00 2001
From: hpnyagman <115356333+hpnyaggerman@users.noreply.github.com>
Date: Mon, 3 Aug 2026 23:25:08 +0000
Subject: [PATCH 15/15] Pin prose_format_llm's schemas to the structured path
Main now withholds tools and tool_choice from every chat request to an endpoint whose profile sets structured_tool_calls, deriving a strict response_format from the forced tool's parameters and rebuilding the reply from a synthesized tool-call message. Every path in this workflow is a forced tool call, so all three of its schemas ride that rewrite and none of them was exercised through it.
The cases assert what the rewrite can quietly break: strictify_schema closes each array's item object, and every item property has to stay a plain string, because a leaf widened to ["string", "null"] decodes as None and is then dropped by the string guards in violations.py and patching.py -- an empty result rather than a visible failure. They sit in this workflow's own suite because the schemas are its own; test_structured_tool_calls.py already covers the transport itself.
---
tests/unit/test_prose_format_llm.py | 57 +++++++++++++++++++++++++++++
1 file changed, 57 insertions(+)
diff --git a/tests/unit/test_prose_format_llm.py b/tests/unit/test_prose_format_llm.py
index 724bfd62..505258d7 100644
--- a/tests/unit/test_prose_format_llm.py
+++ b/tests/unit/test_prose_format_llm.py
@@ -2,10 +2,20 @@
No LLM, no DB: the loop is exercised with stub async-generator judge/enforce
factories, and the validators/patcher/state helpers are table-tested directly.
+The transport-interop cases at the end are pure as well -- they push this
+workflow's own registered tool schemas through the functions the structured
+forced-call path is built from, without a wire.
"""
from __future__ import annotations
+import json
+
+import pytest
+
+from backend.inference.client import parse_tool_calls, strictify_schema
+from backend.inference.text_completion import forced_schema, forced_tool_message
+from backend.workflows.prose_format_llm import ANALYZE_TOOL, PATCH_TOOL, REPORT_TOOL
from backend.workflows.prose_format_llm.loop import run_enforcement_loop
from backend.workflows.prose_format_llm.patching import apply_patches
from backend.workflows.prose_format_llm.statedoc import filled_elements, is_armed, seed
@@ -234,3 +244,50 @@ async def test_loop_surfaces_apply_errors():
run_enforcement_loop("draft", 1, _stub_judge([_V, []], log), _stub_enforce(log), _apply_with_errors, lambda: False)
)
assert any(e["data"]["pass"].endswith(":enforce") and "skipped" in e["data"]["delta"] for e in events)
+
+
+# --- structured-output transport interop ---
+
+# Every path in this workflow is a forced tool call, so on an endpoint whose
+# profile opts into structured output the transport withholds `tools` entirely
+# and rebuilds the call from `response_format` plus a synthesized message. These
+# cases pin that our three schemas survive that rewrite; the transport's own
+# behavior is covered in tests/unit/test_structured_tool_calls.py.
+_INTEROP_CASES = [
+ (ANALYZE_TOOL, {"records": [{"category": "narration", "denotation": "asterisks, past, third"}]}),
+ (REPORT_TOOL, {"violations": [{"excerpt": "He ran.", "category": "narration"}]}),
+ (PATCH_TOOL, {"patches": [{"search": "He ran.", "replace": "*He ran.*"}]}),
+]
+_INTEROP_IDS = [spec.name for spec, _ in _INTEROP_CASES]
+
+
+@pytest.mark.parametrize(("spec", "args"), _INTEROP_CASES, ids=_INTEROP_IDS)
+def test_tool_schema_survives_strict_rewrite(spec, args):
+ schema = forced_schema([spec.schema], spec.choice)
+ # A ToolSpec whose choice does not name its own schema yields None here, and
+ # the transport then sends no response_format at all -- the forced call would
+ # quietly degrade to an unforced one.
+ assert schema is not None
+ strict = strictify_schema(schema)
+
+ assert strict["additionalProperties"] is False
+ (array_key,) = strict["required"]
+
+ items = strict["properties"][array_key]["items"]
+ assert items["additionalProperties"] is False
+ # `_array_tool` marks every item property required, so strict mode must not
+ # widen any of them to `["string", "null"]`. A nullable leaf would decode as
+ # None and be dropped by the string guards in violations.py / patching.py --
+ # a silently empty result rather than a visible failure.
+ assert set(items["required"]) == set(items["properties"])
+ assert all(prop["type"] == "string" for prop in items["properties"].values())
+ # Keeps the sample payloads honest if the item properties ever change.
+ assert set(args[array_key][0]) == set(items["properties"])
+
+
+@pytest.mark.parametrize(("spec", "args"), _INTEROP_CASES, ids=_INTEROP_IDS)
+def test_structured_content_round_trips_to_args(spec, args):
+ """The transport synthesizes a tool-call message from grammar-constrained
+ content; the arguments the workflow reads must come back byte-identical."""
+ message = forced_tool_message(spec.name, json.dumps(args))
+ assert parse_tool_calls(message) == [{"name": spec.name, "arguments": args}]