@@ -887,47 +887,39 @@ def _slugify(s: str) -> str:
887887 git-tracked — assume the worst.
888888
889889═══════════════════════════════════════════════════════════════════
890- INPUTS
890+ INPUTS (provided as tagged blocks in the user message)
891891═══════════════════════════════════════════════════════════════════
892892
893- <active_project>
894- slug: {{PROJECT_SLUG}}
895- (use this when picking paths; lessons clearly tied to {{PROJECT_SLUG}} → \
896- ``projects/{{PROJECT_SLUG}}/...``; lessons that apply across repos → \
897- ``global/...``)
898- </active_project>
899-
900- <rolling_buffer>
901- {{ROLLING_BUFFER}}
902- </rolling_buffer>
903-
904- Each turn is ``[turn_id] (turn_seq=S) [role] <text>``. ``[turn_id]`` is a \
905- scan-local label you may quote in ``reasoning``; ``(turn_seq=S)`` is a \
906- STABLE id you MUST echo in ``cited_turn_seq`` when emitting a \
907- ``used_helpfully`` signal (it is how the daemon avoids double-counting a \
908- re-seen turn). ``[USER-ASSERTED]`` marks turns that arrived via the \
909- user's /ultan command — file these by default unless they are \
910- nonsensical.
911-
912- <library_snapshot>
913- {{LIBRARY_SNAPSHOT}}
914- </library_snapshot>
915-
916- This is your read-only view of the library's current state. If you need \
917- to verify an entry's contents before proposing an action against it, use \
918- ``read_entry`` with the relative path of an entry you see in the snapshot \
919- above. Use ``grep_library`` / ``bm25_search`` / ``embedding_search`` to \
920- find anything you suspect exists but don't see in the snapshot.
893+ The user message for this scan supplies five tagged blocks. Read them \
894+ there; this section explains how to use each.
895+
896+ ``<project_context>`` carries the active project's ``slug``. Use it when \
897+ picking paths: lessons clearly tied to that slug → \
898+ ``projects/<slug>/...``; lessons that apply across repos → ``global/...``.
899+
900+ ``<rolling_buffer>`` is the conversation buffer. Each turn is \
901+ ``[turn_id] (turn_seq=S) [role] <text>``. ``[turn_id]`` is a scan-local \
902+ label you may quote in ``reasoning``; ``(turn_seq=S)`` is a STABLE id you \
903+ MUST echo in ``cited_turn_seq`` when emitting a ``used_helpfully`` signal \
904+ (it is how the daemon avoids double-counting a re-seen turn). \
905+ ``[USER-ASSERTED]`` marks turns that arrived via the user's /ultan \
906+ command — file these by default unless they are nonsensical.
907+
908+ ``<library_snapshot>`` is your read-only view of the library's current \
909+ state. If you need to verify an entry's contents before proposing an \
910+ action against it, use ``read_entry`` with the relative path of an entry \
911+ you see in that snapshot. Use ``grep_library`` / ``bm25_search`` / \
912+ ``embedding_search`` to find anything you suspect exists but don't see in \
913+ the snapshot.
921914
922915═══════════════════════════════════════════════════════════════════
923916INTEGRITY-REPAIR TASKS (HIGHEST PRIORITY — fix these first)
924917═══════════════════════════════════════════════════════════════════
925918
926- <repair_tasks>
927- {{REPAIR_TASKS}}
928- </repair_tasks>
919+ The ``<repair_tasks>`` block in the user message lists any integrity-repair \
920+ tasks for this scan.
929921
930- If the block above is not empty, the daemon's deterministic post-write \
922+ If that block is not empty, the daemon's deterministic post-write \
931923 pass found library invariants it could NOT fix on its own and is handing \
932924 them to you. These are NOT discretionary — propose an action to repair \
933925 each one. They take priority over salience-driven proposals.
@@ -1213,11 +1205,10 @@ def _slugify(s: str) -> str:
12131205APPLIES-WHEN TABLE (for interrupt candidates only)
12141206═══════════════════════════════════════════════════════════════════
12151207
1216- <applies_when_table>
1217- {{APPLIES_WHEN_TABLE}}
1218- </applies_when_table>
1208+ The ``<applies_when_table>`` block in the user message lists one \
1209+ ``<lesson_id> | <scope> | <applies-when phrase>`` row per confirmed entry.
12191210
1220- Scan the buffer against these phrases. If a buffer turn matches a phrase \
1211+ Scan the buffer against those phrases. If a buffer turn matches a phrase \
12211212 (intent, not literal substring), emit an interrupt candidate. Score 0.5+ \
12221213 only. Max 5 interrupts per run.
12231214
@@ -1254,35 +1245,91 @@ def format_repair_tasks(tasks: Sequence[repair_queue.RepairTask]) -> str:
12541245 return "\n " .join (lines )
12551246
12561247
1257- def assemble_prompt (
1248+ # ── System prompt (byte-stable static prefix — the cacheable region) ──
1249+ #
1250+ # Prompt caching: the SDK/Claude Code engine prompt-caches the prefix
1251+ # ``system_prompt + tool definitions`` automatically. To make that cache
1252+ # hit across scans, the system prompt must be BYTE-IDENTICAL on every
1253+ # call — so it carries ONLY the static instructions (plus the two
1254+ # schema-derived, deterministic blocks ACTION_TYPES / RESPONSE_SHAPE), and
1255+ # NONE of the per-scan dynamic data. All five dynamic blocks (project
1256+ # context, rolling buffer, library snapshot, applies-when table, repair
1257+ # tasks) move to the first user message via :func:`build_librarian_user_message`.
1258+
1259+ # Role preamble folded in at the top of the system prompt (was the short
1260+ # ``_SYSTEM_PROMPT`` in librarian.py). Its final line now points at the
1261+ # user-message blocks rather than "the detailed instructions".
1262+ _SYSTEM_PREAMBLE = (
1263+ "You are the Librarian, the recall tier of a two-tier memory curator. "
1264+ "Use the read-only research tools (read_entry, grep_library, bm25_search, "
1265+ "embedding_search) to inspect the existing library before proposing "
1266+ "actions. You write nothing to disk and you only PROPOSE — the Scholar "
1267+ "approves or vetoes. When ready, call submit_result EXACTLY ONCE with a "
1268+ "LibrarianProposal object. The conversation buffer, library snapshot, "
1269+ "project context, applies-when table, and any repair tasks are provided "
1270+ "as tagged blocks in the user message.\n \n "
1271+ )
1272+
1273+
1274+ def librarian_system_prompt () -> str :
1275+ """The byte-stable static Librarian system prompt (the cacheable prefix).
1276+
1277+ It is the full instruction template with the FIVE dynamic placeholders
1278+ removed (their data now rides in the user message — see
1279+ :func:`build_librarian_user_message`) and the TWO static, schema-derived
1280+ placeholders substituted. ACTION_TYPES and RESPONSE_SHAPE are generated
1281+ from ``_schemas.py``; both are deterministic (they iterate a fixed
1282+ ``_ACTION_CLASSES`` list and ``model_fields`` in declaration order, and
1283+ ``model_json_schema()`` is stable), so the output is byte-identical on
1284+ every call and the engine's prefix cache hits across scans.
1285+
1286+ Contains NO timestamp, slug, buffer, snapshot, applies-when table, or
1287+ repair tasks — anything per-scan would defeat the cache.
1288+ """
1289+ out = _SYSTEM_PREAMBLE + load_prompt_template ()
1290+ for needle , value in (
1291+ ("{{ACTION_TYPES}}" , describe_action_types_markdown ()),
1292+ ("{{RESPONSE_SHAPE}}" , describe_librarian_response_shape ()),
1293+ ):
1294+ out = out .replace (needle , value )
1295+ return out
1296+
1297+
1298+ def build_librarian_user_message (
12581299 * ,
12591300 project_slug : str ,
12601301 rolling_buffer : str ,
12611302 library_snapshot : str ,
12621303 applies_when_table : str ,
12631304 repair_tasks : str = _NO_REPAIR_TASKS ,
12641305) -> str :
1265- """Substitute placeholders into the prompt template.
1266-
1267- ACTION_TYPES and RESPONSE_SHAPE are generated from ``_schemas.py``
1268- at call time so the prompt instructions can never drift from the
1269- Pydantic models the parser actually validates against. ``repair_tasks``
1270- is the pre-rendered ``<repair_tasks>`` body (see
1271- :func:`format_repair_tasks`); it defaults to the empty sentinel so
1272- callers that don't escalate anything need not pass it.
1306+ """Emit ONLY the five per-scan dynamic blocks, each in a clearly-labelled
1307+ tag, in a STABLE order.
1308+
1309+ This is the first user message; the static instructions (which reference
1310+ these blocks by tag) live in :func:`librarian_system_prompt`. The
1311+ empty-value fallbacks match the old ``assemble_prompt`` behaviour so
1312+ nothing the model sees changes apart from where the data lives.
1313+ ``repair_tasks`` is the pre-rendered ``<repair_tasks>`` body (see
1314+ :func:`format_repair_tasks`); it defaults to the empty sentinel.
12731315 """
1274- out = load_prompt_template ()
1275- for needle , value in (
1276- ("{{PROJECT_SLUG}}" , project_slug or "unknown" ),
1277- ("{{ROLLING_BUFFER}}" , rolling_buffer or "(empty)" ),
1278- ("{{LIBRARY_SNAPSHOT}}" , library_snapshot or "(empty)" ),
1279- ("{{REPAIR_TASKS}}" , repair_tasks or _NO_REPAIR_TASKS ),
1280- ("{{APPLIES_WHEN_TABLE}}" , applies_when_table or "(empty)" ),
1281- ("{{ACTION_TYPES}}" , describe_action_types_markdown ()),
1282- ("{{RESPONSE_SHAPE}}" , describe_librarian_response_shape ()),
1283- ):
1284- out = out .replace (needle , value )
1285- return out
1316+ return (
1317+ "<project_context>\n "
1318+ f"slug: { project_slug or 'unknown' } \n "
1319+ "</project_context>\n "
1320+ "<rolling_buffer>\n "
1321+ f"{ rolling_buffer or '(empty)' } \n "
1322+ "</rolling_buffer>\n "
1323+ "<library_snapshot>\n "
1324+ f"{ library_snapshot or '(empty)' } \n "
1325+ "</library_snapshot>\n "
1326+ "<applies_when_table>\n "
1327+ f"{ applies_when_table or '(empty)' } \n "
1328+ "</applies_when_table>\n "
1329+ "<repair_tasks>\n "
1330+ f"{ repair_tasks or _NO_REPAIR_TASKS } \n "
1331+ "</repair_tasks>\n "
1332+ )
12861333
12871334
12881335# ── Convenience: flatten + format in one shot ─────────────────────────
0 commit comments